Skip to main content

shuyi_cli/contract/
target.rs

1//! `check --target <id>`: whether the game fits a target (ADR-0008 §1),
2//! by the target's profile and the game's setting for it in `shuyi.ron`
3//! ([`TargetSetting`]). Five checks for now:
4//!
5//! - **Fits.** The frame times its scale fits the display inside its safe
6//!   area.
7//! - **Text.** The smallest text (the font's cell height × the scale) is
8//!   at least the profile's `min_text_px` (an error) and its
9//!   `good_text_px` (a warning).
10//! - **Controls.** On every screen of the game, the required actions it
11//!   declares bind to distinct inputs of the target, in the keymap file's
12//!   table named after it ([`shuyi_ui::input::fit`]; [`controls`]). It
13//!   needs the game's tools (`GameTools::controls`).
14//! - **One bit.** On a target whose display is black and white, every
15//!   role of the skin's palette has patterns, the roles that tell things
16//!   apart stay distinct, and text is black on white or the inverse
17//!   (ADR-0008 §4; [`shuyi_assets::onebit::check`]; [`one_bit`]). It needs
18//!   the game's tools (`GameTools::one_bit`).
19//! - **App.** Who the game is, `shuyi.ron`'s `app` part: its name, id,
20//!   version, build and storage's name, and whether the Rust file
21//!   `app-info` writes is up to date ([`crate::app_info`]; [`app`]). The
22//!   same on every target.
23//!
24//! The engine's profiles are the files of `targets/` at the repository's
25//! root, embedded; a game's own (`shuyi.ron`'s `target_profiles`) add to
26//! them, or replace one by its id.
27
28use std::collections::BTreeMap;
29use std::fs;
30use std::path::Path;
31
32use include_dir::{Dir, include_dir};
33use schemars::JsonSchema;
34use serde::{Deserialize, Serialize};
35use shuyi_assets::onebit::{self, Severity};
36use shuyi_assets::target::{Size, TargetProfile, TargetRon};
37use shuyi_assets::{AssetPath, ContentError, DecodeCx, Format};
38use shuyi_ui::input::fit::{FitProblem, FitRule};
39
40use crate::app_info::AppProblem;
41use crate::contract::codes;
42use crate::game::OneBitSkin;
43use crate::project::{Config, FILE, Scale, TargetSetting};
44
45/// The engine's target profiles, embedded.
46static TARGETS: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/../../targets");
47
48/// What `--target all` asks for: every target in the game's `shuyi.ron`.
49pub const ALL: &str = "all";
50
51/// How bad a target problem is.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
53#[serde(rename_all = "kebab-case")]
54pub enum Level {
55    /// The game doesn't fit the target: the check fails.
56    Error,
57    /// It fits, short of what the target recommends.
58    Warning,
59}
60
61/// Which check found a target problem.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
63#[serde(rename_all = "kebab-case")]
64// A set that grows with the checks (ADR-0039).
65#[non_exhaustive]
66pub enum TargetCheck {
67    /// The frame at its scale fits the display.
68    Fits,
69    /// The smallest text is tall enough.
70    Text,
71    /// The required actions of each screen bind to distinct inputs of the
72    /// target.
73    Controls,
74    /// On a one-bit display, every role has patterns and the roles that
75    /// tell things apart stay distinct.
76    OneBit,
77    /// Who the game is: `shuyi.ron`'s `app` part.
78    App,
79}
80
81/// A problem of the game on one target.
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
83#[serde(deny_unknown_fields)]
84pub struct TargetProblem {
85    /// An error or a warning.
86    pub level: Level,
87    /// The check that found it.
88    pub check: TargetCheck,
89    /// The game's setting it is about: in `shuyi.ron`
90    /// (`targets."playdate".frame`), the keymap file's table for the
91    /// target (`keymap targets."playdate"`), a screen's declaration
92    /// (`screen "title"`), or the skin's pattern map
93    /// (`files/palette@onebit.ron`).
94    pub setting: String,
95    /// What is wrong, with the numbers.
96    pub message: String,
97    /// The screen a controls problem is about, if one.
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub screen: Option<String>,
100    /// The actions a controls problem is about, by name.
101    #[serde(default, skip_serializing_if = "Vec::is_empty")]
102    pub actions: Vec<String>,
103    /// The inputs a controls problem is about, as the keymap file names
104    /// them (`East`, `East+West`).
105    #[serde(default, skip_serializing_if = "Vec::is_empty")]
106    pub inputs: Vec<String>,
107    /// The palette roles a one-bit problem is about (`team.player`).
108    #[serde(default, skip_serializing_if = "Vec::is_empty")]
109    pub roles: Vec<String>,
110}
111
112impl TargetProblem {
113    /// A problem of the display's (`check` [`Fits`](TargetCheck::Fits) or
114    /// [`Text`](TargetCheck::Text)), or of the game's `app` part
115    /// ([`App`](TargetCheck::App)), with `setting`.
116    pub(crate) fn display(
117        level: Level,
118        check: TargetCheck,
119        setting: String,
120        message: String,
121    ) -> Self {
122        Self {
123            level,
124            check,
125            setting,
126            message,
127            screen: None,
128            actions: Vec::new(),
129            inputs: Vec::new(),
130            roles: Vec::new(),
131        }
132    }
133}
134
135/// What `check --target` found on one target.
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
137#[serde(deny_unknown_fields)]
138pub struct TargetReport {
139    /// The target's id.
140    pub target: String,
141    /// The profile's file: the engine's (`targets/playdate.ron`) or the
142    /// game's.
143    pub profile: String,
144    /// Every problem, errors and warnings, display by display.
145    pub problems: Vec<TargetProblem>,
146}
147
148impl TargetReport {
149    /// Whether it has an error.
150    pub fn failed(&self) -> bool {
151        self.problems.iter().any(|p| p.level == Level::Error)
152    }
153}
154
155/// A target profile and the file it came from.
156#[derive(Debug, Clone, PartialEq, Eq)]
157pub struct Profile {
158    /// Its file, as messages show it.
159    pub file: String,
160    /// What it says.
161    pub profile: TargetProfile,
162}
163
164/// The file `name` with `bytes`, shown as `shown`, read as a profile.
165fn decode(shown: &str, bytes: &[u8]) -> Result<TargetProfile, Vec<ContentError>> {
166    let file = AssetPath::new(shown);
167    TargetRon.decode(&file, bytes, &DecodeCx::new())
168}
169
170/// The engine's profiles, by id.
171pub fn engine_profiles() -> Result<BTreeMap<String, Profile>, Vec<ContentError>> {
172    let files = TARGETS.files().map(|f| {
173        let shown = format!("targets/{}", f.path().display());
174        (shown, f.contents().to_vec())
175    });
176    read_all(files)
177}
178
179/// Whether the file shown as `shown` is a profile's: a `.ron` file.
180fn is_profile(shown: &str) -> bool {
181    AssetPath::new(shown)
182        .extension()
183        .is_some_and(|extension| extension.eq_ignore_ascii_case("ron"))
184}
185
186/// The profiles of `files` (each a shown name and its bytes; only `.ron`
187/// ones are read), by id, with every error of every file.
188fn read_all(
189    files: impl Iterator<Item = (String, Vec<u8>)>,
190) -> Result<BTreeMap<String, Profile>, Vec<ContentError>> {
191    let mut out = BTreeMap::new();
192    let mut errors = Vec::new();
193    for (shown, bytes) in files.filter(|(shown, _)| is_profile(shown)) {
194        match decode(&shown, &bytes) {
195            Ok(profile) => {
196                out.insert(
197                    profile.id.clone(),
198                    Profile {
199                        file: shown,
200                        profile,
201                    },
202                );
203            }
204            Err(e) => errors.extend(e),
205        }
206    }
207    if errors.is_empty() {
208        Ok(out)
209    } else {
210        Err(errors)
211    }
212}
213
214/// The engine's profiles, with the game's of `config` (from `root`) added
215/// or in place of the engine's of the same id.
216pub fn profiles(
217    config: &Config,
218    root: &Path,
219) -> Result<BTreeMap<String, Profile>, Vec<ContentError>> {
220    let mut all = engine_profiles()?;
221    let Some(dir) = &config.target_profiles else {
222        return Ok(all);
223    };
224    let entries = fs::read_dir(root.join(dir))
225        .map_err(|e| vec![ContentError::new(dir.as_str(), e.to_string())])?;
226    let mut files: Vec<(String, Vec<u8>)> = Vec::new();
227    let mut errors = Vec::new();
228    for entry in entries.flatten() {
229        let shown = format!("{dir}/{}", entry.file_name().to_string_lossy());
230        if !is_profile(&shown) {
231            continue;
232        }
233        match fs::read(entry.path()) {
234            Ok(bytes) => files.push((shown, bytes)),
235            Err(e) => errors.push(ContentError::new(shown, e.to_string())),
236        }
237    }
238    files.sort();
239    match read_all(files.into_iter()) {
240        Ok(game) if errors.is_empty() => {
241            all.extend(game);
242            Ok(all)
243        }
244        Ok(_) => Err(errors),
245        Err(e) => {
246            errors.extend(e);
247            Err(errors)
248        }
249    }
250}
251
252/// The profile (with its file) of target `id` (the engine's or the game's of `config`,
253/// from `root`), or the code and what is wrong: the profiles don't load,
254/// or none has that id. Both are [`codes::TARGET`].
255pub fn profile(config: &Config, root: &Path, id: &str) -> Result<Profile, (codes::Code, String)> {
256    let profiles = profiles(config, root).map_err(|errors| {
257        let shown: Vec<String> = errors.iter().map(ToString::to_string).collect();
258        (
259            codes::TARGET,
260            format!("the target profiles don't load: {}", shown.join("; ")),
261        )
262    })?;
263    if let Some(found) = profiles.get(id) {
264        return Ok(found.clone());
265    }
266    let known: Vec<&str> = profiles.keys().map(String::as_str).collect();
267    Err((
268        codes::TARGET,
269        format!(
270            "no target profile \"{id}\"; the profiles are {}",
271            known.join(", ")
272        ),
273    ))
274}
275
276/// The scale a frame is shown at, as a fraction: `num / den`.
277#[derive(Debug, Clone, Copy, PartialEq, Eq)]
278pub struct Factor {
279    /// Its numerator.
280    pub num: u32,
281    /// Its denominator, never 0.
282    pub den: u32,
283}
284
285impl Factor {
286    /// The whole number `n`.
287    pub const fn whole(n: u32) -> Self {
288        Self { num: n, den: 1 }
289    }
290
291    /// `px` times this, rounded down.
292    pub fn of(self, px: u16) -> u32 {
293        u32::from(px) * self.num / self.den
294    }
295
296    /// The smaller of two.
297    fn min(self, other: Self) -> Self {
298        // a/b < c/d when a·d < c·b; the numbers are u16 products.
299        if u64::from(self.num) * u64::from(other.den) <= u64::from(other.num) * u64::from(self.den)
300        {
301            self
302        } else {
303            other
304        }
305    }
306
307    /// The factor as messages show it: `2`, or `1.60` rounded down.
308    pub fn shown(self) -> String {
309        if self.num.is_multiple_of(self.den) {
310            return (self.num / self.den).to_string();
311        }
312        let hundredths = u64::from(self.num) * 100 / u64::from(self.den);
313        format!("{}.{:02}", hundredths / 100, hundredths % 100)
314    }
315}
316
317/// The scale `scale` gives frame `(width, height)` on `space` (the
318/// display inside its safe area): for [`Scale::Integer`], the largest
319/// whole number that fits, which is 0 when even 1 doesn't; for
320/// [`Scale::Fit`], the largest factor that fits.
321pub fn scale_on(scale: Scale, (width, height): (u16, u16), space: &Size) -> Factor {
322    let across = Factor {
323        num: u32::from(space.width),
324        den: u32::from(width.max(1)),
325    };
326    let down = Factor {
327        num: u32::from(space.height),
328        den: u32::from(height.max(1)),
329    };
330    let fit = across.min(down);
331    match scale {
332        Scale::Integer => Factor::whole(fit.num / fit.den),
333        Scale::Fit => fit,
334    }
335}
336
337/// The game's `setting` for target `id` checked against `profile`: every
338/// problem, display size by display size. A display of any size (a
339/// window) shows the frame at scale 1.
340pub fn check_target(
341    id: &str,
342    setting: &TargetSetting,
343    profile: &TargetProfile,
344) -> Vec<TargetProblem> {
345    let display = &profile.display;
346    let key = format!("targets.\"{id}\"");
347    let mut out = Vec::new();
348    let (width, height) = setting.frame;
349    let fixed: Vec<Option<&Size>> = if display.sizes.is_empty() {
350        vec![None]
351    } else {
352        display.sizes.iter().map(Some).collect()
353    };
354    for size in fixed {
355        let factor = match size {
356            None => Factor::whole(1),
357            Some(size) => {
358                let space = size.inside(display.safe_area);
359                let factor = scale_on(setting.scale, setting.frame, &space);
360                if factor.num == 0 {
361                    let place = if space == *size {
362                        format!("the display {}", size.name())
363                    } else {
364                        format!(
365                            "the safe area {} × {} of the display {}",
366                            space.width,
367                            space.height,
368                            size.name()
369                        )
370                    };
371                    out.push(TargetProblem::display(
372                        Level::Error,
373                        TargetCheck::Fits,
374                        format!("{key}.frame"),
375                        format!("frame {width} × {height} at scale 1 is larger than {place}"),
376                    ));
377                    // The text is checked as the frame would show if it
378                    // fitted.
379                    Factor::whole(1)
380                } else {
381                    factor
382                }
383            }
384        };
385        let shown = factor.of(setting.text_px);
386        let on = size.map_or_else(String::new, |size| format!(" on {}", size.name()));
387        let text = format!(
388            "text {} px at scale {} is {shown} px{on}",
389            setting.text_px,
390            factor.shown()
391        );
392        let problem = if shown < u32::from(display.min_text_px) {
393            Some((
394                Level::Error,
395                format!(
396                    "{text}, below the target's minimum of {} px",
397                    display.min_text_px
398                ),
399            ))
400        } else if shown < u32::from(display.good_text_px) {
401            Some((
402                Level::Warning,
403                format!(
404                    "{text}, below the {} px the target recommends",
405                    display.good_text_px
406                ),
407            ))
408        } else {
409            None
410        };
411        if let Some((level, message)) = problem {
412            out.push(TargetProblem::display(
413                level,
414                TargetCheck::Text,
415                format!("{key}.text_px"),
416                message,
417            ));
418        }
419    }
420    out
421}
422
423/// What the controls' check found on target `id` ([`FitProblem`]s), as
424/// target problems: errors, but for a screen that doesn't declare its
425/// actions, a warning.
426pub fn controls(id: &str, found: Vec<FitProblem>) -> Vec<TargetProblem> {
427    found
428        .into_iter()
429        .map(|p| {
430            let level = if p.is_error() {
431                Level::Error
432            } else {
433                Level::Warning
434            };
435            let setting = match (p.rule, p.screen) {
436                (FitRule::Undeclared, Some(screen)) => format!("screen \"{screen}\""),
437                _ => format!("keymap targets.\"{id}\""),
438            };
439            TargetProblem {
440                level,
441                check: TargetCheck::Controls,
442                setting,
443                message: p.message,
444                screen: p.screen.map(str::to_owned),
445                actions: p.actions.iter().map(|&a| a.to_owned()).collect(),
446                inputs: p.inputs,
447                roles: Vec::new(),
448            }
449        })
450        .collect()
451}
452
453/// What the one-bit check finds of `skin` ([`shuyi_assets::onebit::check`]),
454/// as target problems about its pattern map: errors, but for text that
455/// isn't black and white, a warning.
456pub fn one_bit(skin: &OneBitSkin) -> Vec<TargetProblem> {
457    onebit::check(&skin.palette, &skin.map)
458        .into_iter()
459        .map(|p| TargetProblem {
460            level: match p.severity {
461                Severity::Error => Level::Error,
462                Severity::Warning => Level::Warning,
463            },
464            check: TargetCheck::OneBit,
465            setting: skin.map_file.clone(),
466            message: p.message,
467            screen: None,
468            actions: Vec::new(),
469            inputs: Vec::new(),
470            roles: p.roles,
471        })
472        .collect()
473}
474
475/// The problems of the game's `app` part ([`crate::app_info::problems`]),
476/// as target problems about its fields (`app.id`).
477pub fn app(found: Vec<AppProblem>) -> Vec<TargetProblem> {
478    found
479        .into_iter()
480        .map(|p| TargetProblem::display(p.level, TargetCheck::App, p.setting, p.message))
481        .collect()
482}
483
484/// The targets `target` asks for in `config`: itself, or every one with
485/// [`ALL`]; an error if `shuyi.ron` doesn't list it.
486pub fn asked<'a>(
487    config: &'a Config,
488    target: &str,
489) -> Result<Vec<(&'a str, &'a TargetSetting)>, String> {
490    if target == ALL {
491        if config.targets.is_empty() {
492            return Err(format!("{FILE} lists no targets (`targets`)"));
493        }
494        return Ok(config
495            .targets
496            .iter()
497            .map(|(id, s)| (id.as_str(), s))
498            .collect());
499    }
500    config
501        .targets
502        .get_key_value(target)
503        .map(|(id, s)| vec![(id.as_str(), s)])
504        .ok_or_else(|| {
505            let listed: Vec<&str> = config.targets.keys().map(String::as_str).collect();
506            let listed = if listed.is_empty() {
507                "none".to_owned()
508            } else {
509                listed.join(", ")
510            };
511            format!(
512                "{FILE}'s `targets` has no \"{target}\" (it lists {listed}): a game lists the targets it ships on"
513            )
514        })
515}
516
517#[cfg(test)]
518mod tests;