Skip to main content

shuyi_assets/
target.rs

1//! Export targets as data (ADR-0008 §1): a [`TargetProfile`] says what a
2//! target's display shows, how fast it runs, what the player can press,
3//! what a game may spend there, and which platform backend builds it. The
4//! engine's profiles are the files of `targets/` at the repository's root
5//! (`playdate.ron`, `steam-deck.ron`, …); a game adds one, or overrides
6//! one by its id, with a file of its own. As a format (ADR-0007):
7//! [`TargetRon`].
8//!
9//! Every unit is an integer: pixels are `u16`, milliseconds `u32`, bytes
10//! and instructions `u64`.
11use crate::prelude::*;
12
13use serde::{Deserialize, Serialize};
14
15use crate::error::ContentError;
16use crate::format::{AssetKind, AssetPath, DecodeCx, Format, TEXT_MAX_BYTES, text};
17use crate::keymap::Button;
18use crate::ron::parse_ron_implicit_some;
19
20impl AssetKind for TargetProfile {
21    const NAME: &'static str = "target profile";
22}
23
24/// A target profile file (`target-ron`, `.ron`), read and checked as
25/// [`TargetProfile::from_source`] says.
26#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
27pub struct TargetRon;
28
29impl Format for TargetRon {
30    type Asset = TargetProfile;
31
32    fn id(&self) -> &'static str {
33        "target-ron"
34    }
35
36    fn extensions(&self) -> &'static [&'static str] {
37        &["ron"]
38    }
39
40    fn max_bytes(&self) -> usize {
41        TEXT_MAX_BYTES
42    }
43
44    fn decode(
45        &self,
46        file: &AssetPath,
47        bytes: &[u8],
48        _cx: &DecodeCx<'_>,
49    ) -> Result<TargetProfile, Vec<ContentError>> {
50        TargetProfile::from_source(file.shown(), file.stem(), text(file, bytes)?)
51    }
52}
53
54/// One export target.
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(deny_unknown_fields)]
57pub struct TargetProfile {
58    /// Its id, which `shuyi check --target` and a game's `shuyi.ron` name
59    /// it by: the file's name without `.ron`.
60    pub id: String,
61    /// Whether the engine runs on it yet.
62    pub status: Status,
63    /// Its screen.
64    pub display: Display,
65    /// The frames a second a game holds there.
66    pub frame_rate: FrameRate,
67    /// What the player can press.
68    pub input: Input,
69    /// How the player leaves a game there: by its own Quit unless said.
70    #[serde(default)]
71    pub quit: Quit,
72    /// What a game may spend there.
73    #[serde(default)]
74    pub budgets: Budgets,
75    /// The platform backend that builds it: a crate (`shuyi-app`,
76    /// `shuyi-playdate`), or `private` for a console's.
77    pub backend: String,
78}
79
80/// Whether the engine runs on a target yet.
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
82pub enum Status {
83    /// The target's example runs on the device.
84    Supported,
85    /// Not yet.
86    Planned,
87}
88
89/// A target's screen.
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91#[serde(deny_unknown_fields)]
92pub struct Display {
93    /// The sizes it shows, in device pixels: one, or one per mode (the
94    /// Switch handheld and docked). Empty for a window or a browser
95    /// canvas, which takes any size.
96    #[serde(default)]
97    pub sizes: Vec<Size>,
98    /// How many colours it shows. ADR-0012 moves this into the target's
99    /// capabilities (ticket 3017).
100    pub color: Color,
101    /// Text shorter than this, in device pixels, is an error.
102    pub min_text_px: u16,
103    /// Text shorter than this, in device pixels, is a warning.
104    pub good_text_px: u16,
105    /// The edges a game may not draw what matters under.
106    #[serde(default)]
107    pub safe_area: Insets,
108    /// Whether the game is in a window the player can switch to full
109    /// screen and back (a desktop's, a browser's canvas). `false` for a
110    /// device that is always full screen.
111    #[serde(default)]
112    pub windowed: bool,
113}
114
115/// A screen's size, in device pixels.
116#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
117#[serde(deny_unknown_fields)]
118pub struct Size {
119    /// The mode it is the size in (`handheld`, `docked`), if the display
120    /// has more than one.
121    #[serde(default)]
122    pub mode: Option<String>,
123    /// Its width.
124    pub width: u16,
125    /// Its height.
126    pub height: u16,
127}
128
129/// How many colours a display shows.
130#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
131pub enum Color {
132    /// Full colour, 8 bits a channel and alpha.
133    Rgba8,
134    /// Black and white (ADR-0008 §4).
135    OneBit,
136}
137
138/// How the player leaves a game on a target.
139#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
140#[non_exhaustive]
141pub enum Quit {
142    /// The game's own Quit closes it (a desktop's window).
143    #[default]
144    Game,
145    /// The player leaves through the system (a browser's tab, the
146    /// Playdate's Home, a console's or a phone's home button), and a game
147    /// shows no Quit.
148    System,
149}
150
151/// Insets from each edge of a display, in device pixels.
152#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
153#[serde(deny_unknown_fields, default)]
154pub struct Insets {
155    /// From the top.
156    pub top: u16,
157    /// From the right.
158    pub right: u16,
159    /// From the bottom.
160    pub bottom: u16,
161    /// From the left.
162    pub left: u16,
163}
164
165/// The frames a second a game holds on a target.
166#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
167#[serde(deny_unknown_fields)]
168pub struct FrameRate {
169    /// What it aims at.
170    pub target: u16,
171    /// What it never drops below.
172    pub floor: u16,
173}
174
175/// What the player can press on a target (ADR-0008 §3).
176#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
177#[serde(deny_unknown_fields, default)]
178pub struct Input {
179    /// The pad's buttons, by position, as [`Button`] names them. The
180    /// system's own (the Playdate's Menu, the Deck's Steam button) are not
181    /// listed: they are never the game's.
182    pub buttons: Vec<Button>,
183    /// Pairs of the buttons pressed together that count as one more input.
184    pub chords: Vec<[Button; 2]>,
185    /// Analog inputs: `LeftStick`, `RightStick`, `Crank`. A crank's notches
186    /// are buttons (`CrankForward`, `CrankBack`).
187    pub axes: Vec<String>,
188    /// The items of the game's own the system menu holds: a keymap binds
189    /// optional actions to `SystemMenu0` and on, up to this many.
190    pub system_menu_slots: u8,
191    /// Whether a mouse or another pointer exists.
192    pub pointer: bool,
193    /// Whether a touch screen exists.
194    pub touch: bool,
195    /// Whether a keyboard exists.
196    pub keyboard: bool,
197}
198
199/// What a game may spend on a target (ADR-0008 §6). A budget left out is
200/// not measured there yet.
201#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
202#[serde(deny_unknown_fields, default)]
203pub struct Budgets {
204    /// The most instructions one frame of the engine's work may take in
205    /// the heaviest benchmark scenario.
206    pub frame_instructions: Option<u64>,
207    /// The most heap in use at once, in bytes.
208    pub peak_heap_bytes: Option<u64>,
209    /// The biggest package, in bytes.
210    pub package_bytes: Option<u64>,
211    /// The longest start-up, in milliseconds.
212    pub startup_ms: Option<u32>,
213}
214
215impl TargetProfile {
216    /// Parses and checks profile `source`, the file `stem.ron` shown as
217    /// `file`. Reports every problem: an id that isn't the file's name,
218    /// text sizes or frame rates out of order, an empty size, a safe area
219    /// that leaves nothing, a chord of buttons the target doesn't have.
220    pub fn from_source(file: &str, stem: &str, source: &str) -> Result<Self, Vec<ContentError>> {
221        let profile: Self = parse_ron_implicit_some(file, source).map_err(|e| vec![e])?;
222        let problems = profile.problems(stem);
223        if problems.is_empty() {
224            Ok(profile)
225        } else {
226            Err(problems
227                .into_iter()
228                .map(|message| ContentError::new(file, message))
229                .collect())
230        }
231    }
232
233    /// What is wrong with this profile, read from the file `stem.ron`.
234    fn problems(&self, stem: &str) -> Vec<String> {
235        let mut out = Vec::new();
236        if self.id != stem {
237            out.push(format!(
238                "id \"{}\" is not the file's name \"{stem}\"",
239                self.id
240            ));
241        }
242        let display = &self.display;
243        if display.min_text_px > display.good_text_px {
244            out.push(format!(
245                "display: min_text_px {} is more than good_text_px {}",
246                display.min_text_px, display.good_text_px
247            ));
248        }
249        let insets = display.safe_area;
250        for size in &display.sizes {
251            let name = size.name();
252            if size.width == 0 || size.height == 0 {
253                out.push(format!("display: size {name} is empty"));
254            } else if u32::from(insets.left) + u32::from(insets.right) >= u32::from(size.width)
255                || u32::from(insets.top) + u32::from(insets.bottom) >= u32::from(size.height)
256            {
257                out.push(format!("display: the safe area leaves nothing of {name}"));
258            }
259        }
260        let rate = self.frame_rate;
261        if rate.floor == 0 || rate.floor > rate.target {
262            out.push(format!(
263                "frame_rate: floor {} must be from 1 to target {}",
264                rate.floor, rate.target
265            ));
266        }
267        for button in &self.input.buttons {
268            if button.system_menu_slot().is_some() {
269                out.push(format!(
270                    "input: {button} is a system menu item, not a button: \
271                     system_menu_slots says how many the target has"
272                ));
273            }
274        }
275        for chord in &self.input.chords {
276            for button in chord {
277                if !self.input.buttons.contains(button) {
278                    out.push(format!(
279                        "input: chord {} + {} uses {button}, which is not in buttons",
280                        chord[0], chord[1]
281                    ));
282                }
283            }
284        }
285        out
286    }
287}
288
289impl Size {
290    /// A size of `width` × `height`, in no mode.
291    pub fn new(width: u16, height: u16) -> Self {
292        Self {
293            mode: None,
294            width,
295            height,
296        }
297    }
298
299    /// The size as messages show it: `400 × 240`, or `1920 × 1080
300    /// docked`.
301    pub fn name(&self) -> String {
302        match &self.mode {
303            Some(mode) => format!("{} × {} {mode}", self.width, self.height),
304            None => format!("{} × {}", self.width, self.height),
305        }
306    }
307
308    /// What `insets` leave of it, saturating at nothing.
309    #[must_use]
310    pub fn inside(&self, insets: Insets) -> Self {
311        Self {
312            mode: self.mode.clone(),
313            width: self
314                .width
315                .saturating_sub(insets.left)
316                .saturating_sub(insets.right),
317            height: self
318                .height
319                .saturating_sub(insets.top)
320                .saturating_sub(insets.bottom),
321        }
322    }
323}
324
325#[cfg(test)]
326pub(crate) mod tests {
327    use proptest::prelude::*;
328
329    use super::*;
330    use crate::format::Formats;
331
332    /// A whole profile, with `extra` added to its display.
333    pub(crate) fn profile(id: &str, display_extra: &str) -> String {
334        format!(
335            "(
336    id: \"{id}\",
337    status: Supported,
338    display: (sizes: [(width: 400, height: 240)], color: OneBit, min_text_px: 8, good_text_px: 10, {display_extra}),
339    frame_rate: (target: 30, floor: 30),
340    input: (buttons: [\"East\", \"West\"], chords: [(\"East\", \"West\")], axes: [\"Crank\"], system_menu_slots: 3),
341    budgets: (startup_ms: 5000),
342    backend: \"shuyi-playdate\",
343)"
344        )
345    }
346
347    fn read(stem: &str, source: &str) -> Result<TargetProfile, Vec<String>> {
348        TargetProfile::from_source("t.ron", stem, source)
349            .map_err(|e| e.iter().map(ToString::to_string).collect())
350    }
351
352    #[test]
353    fn a_profile_reads_with_optional_fields_as_plain_values() {
354        let p = read("small", &profile("small", "")).unwrap();
355        assert_eq!(p.display.sizes, [Size::new(400, 240)]);
356        assert_eq!(p.display.safe_area, Insets::default());
357        assert_eq!(p.input.chords, [[Button::East, Button::West]]);
358        assert_eq!(p.budgets.startup_ms, Some(5000));
359        assert_eq!(p.budgets.frame_instructions, None);
360        assert!(!p.input.touch);
361        // A profile from before `quit` and `windowed`: the game quits
362        // itself, always full screen.
363        assert_eq!((p.quit, p.display.windowed), (Quit::Game, false));
364    }
365
366    #[test]
367    fn a_profile_says_how_the_player_quits_and_whether_it_is_windowed() {
368        let source = profile("small", "windowed: true")
369            .replace("    backend:", "    quit: System,\n    backend:");
370        let p = read("small", &source).unwrap();
371        assert_eq!((p.quit, p.display.windowed), (Quit::System, true));
372        let wrong = profile("small", "").replace("    backend:", "    quit: Home,\n    backend:");
373        let errors = read("small", &wrong).unwrap_err();
374        assert!(errors[0].contains("Home"), "{errors:?}");
375    }
376
377    #[test]
378    fn unknown_fields_and_buttons_are_refused_with_where() {
379        let errors = read("small", &profile("small", "colour: Rgba8")).unwrap_err();
380        assert_eq!(errors.len(), 1);
381        assert!(errors[0].starts_with("t.ron:4:"), "{errors:?}");
382        let source = profile("small", "").replace("\"West\"],", "\"Wst\"],");
383        let errors = read("small", &source).unwrap_err();
384        assert!(errors[0].contains("unknown button \"Wst\""), "{errors:?}");
385    }
386
387    #[test]
388    fn every_problem_of_a_profile_is_reported() {
389        let source = profile("other", "safe_area: (left: 200, right: 200)")
390            .replace("min_text_px: 8", "min_text_px: 12")
391            .replace("floor: 30", "floor: 40")
392            .replace("[\"East\", \"West\"]", "[\"East\", \"SystemMenu0\"]")
393            .replace(
394                "[(width: 400, height: 240)]",
395                "[(width: 400, height: 240), (mode: \"tiny\", width: 0, height: 9)]",
396            );
397        assert_eq!(
398            read("small", &source).unwrap_err(),
399            [
400                "t.ron: id \"other\" is not the file's name \"small\"",
401                "t.ron: display: min_text_px 12 is more than good_text_px 10",
402                "t.ron: display: the safe area leaves nothing of 400 × 240",
403                "t.ron: display: size 0 × 9 tiny is empty",
404                "t.ron: frame_rate: floor 40 must be from 1 to target 30",
405                "t.ron: input: SystemMenu0 is a system menu item, not a button: system_menu_slots \
406                 says how many the target has",
407                "t.ron: input: chord East + West uses West, which is not in buttons",
408            ]
409        );
410    }
411
412    #[test]
413    fn a_safe_area_may_leave_a_pixel_and_text_sizes_may_be_equal() {
414        let equal = profile("small", "").replace("good_text_px: 10", "good_text_px: 8");
415        assert!(read("small", &equal).is_ok());
416        let pixel = profile(
417            "small",
418            "safe_area: (left: 1, right: 398, top: 1, bottom: 238)",
419        );
420        assert!(read("small", &pixel).is_ok());
421        for insets in ["left: 1, right: 399", "top: 1, bottom: 239"] {
422            let none = profile("small", &format!("safe_area: ({insets})"));
423            assert_eq!(
424                read("small", &none).unwrap_err(),
425                ["t.ron: display: the safe area leaves nothing of 400 × 240"],
426                "{insets}"
427            );
428        }
429    }
430
431    #[test]
432    fn a_size_shrinks_by_its_insets_and_shows_its_mode() {
433        let insets = Insets {
434            top: 10,
435            right: 20,
436            bottom: 30,
437            left: 40,
438        };
439        assert_eq!(Size::new(400, 240).inside(insets), Size::new(340, 200));
440        assert_eq!(Size::new(10, 10).inside(insets), Size::new(0, 0));
441        let docked = Size {
442            mode: Some("docked".to_owned()),
443            ..Size::new(1920, 1080)
444        };
445        assert_eq!(docked.name(), "1920 × 1080 docked");
446    }
447
448    #[test]
449    fn the_standard_registry_reads_a_profile_by_its_stem() {
450        let formats = Formats::standard();
451        assert_eq!(formats.ids::<TargetProfile>(), ["target-ron"]);
452        let file = AssetPath::new("targets/small.ron");
453        let p = formats.decode::<TargetProfile>(
454            &file,
455            profile("small", "").as_bytes(),
456            &DecodeCx::new(),
457        );
458        assert_eq!(p.map(|p| p.id), Ok("small".to_owned()));
459        assert_eq!(TargetRon.max_bytes(), TEXT_MAX_BYTES);
460    }
461
462    proptest! {
463        #[test]
464        fn no_bytes_panic_the_target_format(
465            random in proptest::collection::vec(any::<u8>(), 0..512),
466            cut in 0..600_usize,
467            (at, with) in (0..600_usize, any::<char>()),
468        ) {
469            let whole = profile("small", "");
470            let truncated = &whole.as_bytes()[..cut.min(whole.len())];
471            let mut changed: Vec<char> = whole.chars().collect();
472            let i = at.min(changed.len() - 1);
473            changed[i] = with;
474            let changed: String = changed.into_iter().collect();
475            for bytes in [&random[..], truncated, changed.as_bytes()] {
476                let _ = TargetRon.decode(&AssetPath::new("small.ron"), bytes, &DecodeCx::new());
477            }
478        }
479    }
480}