1use 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
45static TARGETS: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/../../targets");
47
48pub const ALL: &str = "all";
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
53#[serde(rename_all = "kebab-case")]
54pub enum Level {
55 Error,
57 Warning,
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
63#[serde(rename_all = "kebab-case")]
64#[non_exhaustive]
66pub enum TargetCheck {
67 Fits,
69 Text,
71 Controls,
74 OneBit,
77 App,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
83#[serde(deny_unknown_fields)]
84pub struct TargetProblem {
85 pub level: Level,
87 pub check: TargetCheck,
89 pub setting: String,
95 pub message: String,
97 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub screen: Option<String>,
100 #[serde(default, skip_serializing_if = "Vec::is_empty")]
102 pub actions: Vec<String>,
103 #[serde(default, skip_serializing_if = "Vec::is_empty")]
106 pub inputs: Vec<String>,
107 #[serde(default, skip_serializing_if = "Vec::is_empty")]
109 pub roles: Vec<String>,
110}
111
112impl TargetProblem {
113 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
137#[serde(deny_unknown_fields)]
138pub struct TargetReport {
139 pub target: String,
141 pub profile: String,
144 pub problems: Vec<TargetProblem>,
146}
147
148impl TargetReport {
149 pub fn failed(&self) -> bool {
151 self.problems.iter().any(|p| p.level == Level::Error)
152 }
153}
154
155#[derive(Debug, Clone, PartialEq, Eq)]
157pub struct Profile {
158 pub file: String,
160 pub profile: TargetProfile,
162}
163
164fn decode(shown: &str, bytes: &[u8]) -> Result<TargetProfile, Vec<ContentError>> {
166 let file = AssetPath::new(shown);
167 TargetRon.decode(&file, bytes, &DecodeCx::new())
168}
169
170pub 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
179fn is_profile(shown: &str) -> bool {
181 AssetPath::new(shown)
182 .extension()
183 .is_some_and(|extension| extension.eq_ignore_ascii_case("ron"))
184}
185
186fn 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
214pub 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
252pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
278pub struct Factor {
279 pub num: u32,
281 pub den: u32,
283}
284
285impl Factor {
286 pub const fn whole(n: u32) -> Self {
288 Self { num: n, den: 1 }
289 }
290
291 pub fn of(self, px: u16) -> u32 {
293 u32::from(px) * self.num / self.den
294 }
295
296 fn min(self, other: Self) -> Self {
298 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 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
317pub 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
337pub 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 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
423pub 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
453pub 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
475pub 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
484pub 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;