1use 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
35pub const OUT_DIR: &str = "target/shuyi";
38
39pub const LOAD_RUNS: u32 = 5;
41
42#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
44#[serde(deny_unknown_fields)]
45#[non_exhaustive]
46pub struct BakeInput {
47 #[serde(default)]
50 pub target: Option<String>,
51 #[serde(default)]
54 pub out: Option<String>,
55 #[serde(default)]
58 pub sheet: Option<String>,
59}
60
61#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
63#[serde(deny_unknown_fields)]
64pub struct BakeOutput {
65 pub errors: Vec<Problem>,
68 pub pack: Option<PackMade>,
70}
71
72#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
74#[serde(deny_unknown_fields)]
75#[non_exhaustive]
76pub struct PackMade {
77 pub file: String,
79 pub target: Option<String>,
81 pub files: u32,
83 pub baked: u32,
86 pub kinds: Vec<String>,
88 pub source_bytes: u64,
90 pub pack_bytes: u64,
92 pub bake_us: u64,
94 pub load_sources_us: u64,
97 pub load_pack_us: u64,
100 #[serde(default)]
103 pub dithered: Vec<String>,
104 #[serde(default)]
107 pub replaced: Vec<String>,
108 #[serde(default)]
111 pub sheet: Option<String>,
112}
113
114fn 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
141fn micros(duration: Duration) -> u64 {
143 u64::try_from(duration.as_micros()).unwrap_or(u64::MAX)
144}
145
146fn 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
158fn 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
168fn 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
174fn 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
189fn 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
197pub 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
277pub 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
294pub 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
314pub fn usage(command: &str) -> String {
316 format!("usage: {command} bake [--target <id>] [--out <file>] [--sheet <file>] [--json]")
317}
318
319fn 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
332fn ms(us: u64) -> String {
334 format!("{}.{} ms", us / 1000, us % 1000 / 100)
335}
336
337pub 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 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;