1use 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#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(deny_unknown_fields)]
57pub struct TargetProfile {
58 pub id: String,
61 pub status: Status,
63 pub display: Display,
65 pub frame_rate: FrameRate,
67 pub input: Input,
69 #[serde(default)]
71 pub quit: Quit,
72 #[serde(default)]
74 pub budgets: Budgets,
75 pub backend: String,
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
82pub enum Status {
83 Supported,
85 Planned,
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91#[serde(deny_unknown_fields)]
92pub struct Display {
93 #[serde(default)]
97 pub sizes: Vec<Size>,
98 pub color: Color,
101 pub min_text_px: u16,
103 pub good_text_px: u16,
105 #[serde(default)]
107 pub safe_area: Insets,
108 #[serde(default)]
112 pub windowed: bool,
113}
114
115#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
117#[serde(deny_unknown_fields)]
118pub struct Size {
119 #[serde(default)]
122 pub mode: Option<String>,
123 pub width: u16,
125 pub height: u16,
127}
128
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
131pub enum Color {
132 Rgba8,
134 OneBit,
136}
137
138#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
140#[non_exhaustive]
141pub enum Quit {
142 #[default]
144 Game,
145 System,
149}
150
151#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
153#[serde(deny_unknown_fields, default)]
154pub struct Insets {
155 pub top: u16,
157 pub right: u16,
159 pub bottom: u16,
161 pub left: u16,
163}
164
165#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
167#[serde(deny_unknown_fields)]
168pub struct FrameRate {
169 pub target: u16,
171 pub floor: u16,
173}
174
175#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
177#[serde(deny_unknown_fields, default)]
178pub struct Input {
179 pub buttons: Vec<Button>,
183 pub chords: Vec<[Button; 2]>,
185 pub axes: Vec<String>,
188 pub system_menu_slots: u8,
191 pub pointer: bool,
193 pub touch: bool,
195 pub keyboard: bool,
197}
198
199#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
202#[serde(deny_unknown_fields, default)]
203pub struct Budgets {
204 pub frame_instructions: Option<u64>,
207 pub peak_heap_bytes: Option<u64>,
209 pub package_bytes: Option<u64>,
211 pub startup_ms: Option<u32>,
213}
214
215impl TargetProfile {
216 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 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 pub fn new(width: u16, height: u16) -> Self {
292 Self {
293 mode: None,
294 width,
295 height,
296 }
297 }
298
299 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 #[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 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 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}