Skip to main content

shuyi_app/
lib.rs

1//! Shuyi Engine's macroquad backend (ADR-0008 §2, game ADR-0004): the
2//! window, blitting the frame ([`render`]), the keyboard ([`keys`]) and
3//! controllers ([`pads`]), the audio player and storage ([`storage`]),
4//! behind `shuyi_platform`'s `Platform`, and the loop ([`run`]) that calls
5//! `shuyi_platform::App::frame` once per frame of macroquad's, on desktop,
6//! the web and the Steam Deck.
7//!
8//! On desktop, `read_folder` reads a folder on disk (a player's mod) into
9//! a bundle layer the game lays over its own files. The web has no
10//! folders, so it isn't there: there [`page::page_layer`] reads the files
11//! the page holds into one instead. In a build with debug tools the folders it
12//! reads are watched, and `watched_layer` gives the one `cargo shuyi run
13//! --watch` names (ADR-0071).
14//!
15//! A game on the short path (ADR-0036) runs from a plain `main`, with
16//! [`run_game`] and its `BasicGame`:
17//!
18//! ```ignore
19//! fn main() {
20//!     shuyi_app::run_game(game::game());
21//! }
22//! ```
23//!
24//! A game of its own shape keeps macroquad's entry point, which has to be
25//! in the binary, and hands over its [`AppConfig`] and how it starts
26//! ([`Start`]):
27//!
28//! ```ignore
29//! fn window_conf() -> macroquad::window::Conf {
30//!     shuyi_app::window_conf(&WINDOW)
31//! }
32//!
33//! fn look(game: &MyGame) -> Look<'_> {
34//!     Look::new(&game.font, &game.images)
35//! }
36//!
37//! #[macroquad::main(window_conf)]
38//! async fn main() {
39//!     let start = Start::new(Card::text("My Game"), look, |setup| {
40//!         let ctx = setup.ctx(my_ctx()?);
41//!         Ok(Game::new(ctx, Box::new(Title), GameHooks::default()))
42//!     });
43//!     shuyi_app::run(config(), start).await;
44//! }
45//! ```
46//!
47//! With the `link` feature, a debug build listens for the live link
48//! (ADR-0047), so `shuyi attach` reaches the game running in its window:
49//! run it with `SHUYI_LINK=0` (a free port; or a port of your choosing),
50//! and its log says `shuyi link: listening on 127.0.0.1:<port>, token
51//! <token>`. `SHUYI_LINK_TOKEN` sets the token instead of a random one. A
52//! release build, or one without the feature, never listens; on the web
53//! the feature does nothing yet.
54//!
55//! With the `capture` feature, a desktop build runs a plan instead of the
56//! player's input when `SHUYI_CAPTURE` names one, compares the window with
57//! the raster on the frames it names, and exits (the `capture` module, ADR-0052):
58//!
59//! ```text
60//! SHUYI_CAPTURE=examples/hello-grid/capture/frames.ron cargo run -p hello-grid --features capture
61//! ```
62//!
63//! It writes each capture's pictures and `capture.txt` under
64//! `SHUYI_CAPTURE_OUT` (`target/capture` unless set), and exits 0 when
65//! every capture is within the plan's tolerance, 1 when one isn't, and 2
66//! when the plan can't run. Without the feature the variable is never
67//! read. On the web a build with the feature always runs the plan
68//! `capture.ron` beside the page, writes no files and logs each capture's
69//! line to the browser's console instead (ticket 2019).
70//!
71//! With the `sound-probe` feature, `SoundProbeBackend` names the sound
72//! backend, for the example `sound_probe` that the parity gates record. No
73//! game turns it on.
74//!
75//! With the `in-house-sound` feature, sound goes through `shuyi-sound`'s mixer and
76//! `shuyi-sound-out` instead of quad-snd.
77//!
78//! A desktop debug build writes its frames' times as it runs when
79//! `SHUYI_PERF` names a file (`SHUYI_PERF=perf.jsonl cargo run`): its log
80//! names the file, and after each frame it writes that frame's record as a
81//! JSON line (`shuyi_ui::perf::PerfLine`) and flushes, so `tail -f`, or
82//! reading the file again from the last line read, follows it (ADR-0046).
83//! A build without debug tools logs why and writes nothing; a file that
84//! can't be written is one warning, and the game goes on.
85//!
86//! A panic writes a crash report through the game's storage, beside its
87//! saves (the [`crash`] module, ADR-0079): `crash-<when>.ron` in its data
88//! folder on native, and in `localStorage` on the web. At its next start
89//! the game hears of it once (`shuyi_platform::Setup::last_crash`).
90//! [`AppConfig::crash`] names the game's version in it, or turns it off.
91//!
92//! A desktop debug build keeps its last sessions in `sessions/` under the
93//! game's data folder (the `sessions` module, ADR-0074): the record of a run,
94//! written when the game quits and when the debug menu's "Save session" asks,
95//! the newest ten kept. On the web that entry offers a download. `shuyi run
96//! --session <file>` plays one.
97//!
98//! Every debug build counts the heap: this crate turns on
99//! `shuyi_alloc_count`'s `global` feature, whose `#[global_allocator]`
100//! counts the bytes the whole program asks for, every thread's, and each
101//! frame's record gets the live bytes, their peak and that frame's
102//! allocations (`shuyi_ui::perf::FrameRecord::heap`). Its counts are
103//! also kept by memory tag: the frame's phases (`load`, `update`, `draw`,
104//! `paint`, `perf` and `app`), the game's spans, and each asset kind's
105//! decode (`shuyi_alloc_count::tags`). A release build has no counter. A game with a `#[global_allocator]` of its own (a binary
106//! has one) opts out in its `.cargo/config.toml`:
107//!
108//! ```toml
109//! [build]
110//! rustflags = ["--cfg", "shuyi_no_heap_count"]
111//! ```
112//!
113//! On the web the game's binary must link with `--allow-undefined`: the
114//! JS plugins' functions (`crates/cli/web/gamepad.js`'s, `download.js`'s,
115//! `page-files.js`'s, `quad-storage`'s) are
116//! imports the page provides, not link errors. A link argument only
117//! reaches the binary from its own build script
118//! (`crates/cli/web/README.md` shows how).
119
120// Every public type here is config a game fills in, so each is
121// `#[non_exhaustive]` with a constructor and may gain fields without
122// breaking a game (ADR-0039). The lints hold that here only: in the other
123// crates most public types are plain values that rightly stay exhaustive.
124// They live in this file, not in `Cargo.toml`, because its `[lints]` must
125// stay a copy of the workspace's (the test at the bottom).
126#![warn(clippy::exhaustive_structs, clippy::exhaustive_enums)]
127
128mod audio;
129mod backend;
130#[cfg(feature = "capture")]
131pub mod capture;
132pub mod crash;
133mod facts;
134mod focus;
135#[cfg(not(target_arch = "wasm32"))]
136mod folder;
137pub mod keys;
138#[cfg(all(feature = "link", not(target_arch = "wasm32")))]
139mod link;
140mod pacing;
141pub mod pads;
142pub mod page;
143mod painted;
144#[cfg(not(target_arch = "wasm32"))]
145mod perf_file;
146mod pictures;
147#[cfg(any(
148    all(feature = "link", not(target_arch = "wasm32")),
149    feature = "capture"
150))]
151mod readback;
152pub mod render;
153mod sessions;
154pub mod storage;
155#[cfg(not(target_arch = "wasm32"))]
156mod watch;
157
158use core::fmt::Debug;
159
160use macroquad::prelude::*;
161use shuyi_assets::audio::{Bytes, Files};
162use shuyi_assets::keymap::ActionSet;
163use shuyi_platform::{App, BasicGame, FrameOutcome, Millis, Platform, Start, TextInput};
164use shuyi_ui::GameData;
165use shuyi_ui::basic::FONT_PNG;
166use shuyi_ui::log::{LogLevel, LogLine};
167
168#[cfg(feature = "sound-probe")]
169pub use crate::audio::Chosen as SoundProbeBackend;
170pub use crate::audio::voice::read_manifest as read_voice_manifest;
171use crate::backend::Macroquad;
172pub use crate::crash::CrashReports;
173#[cfg(not(target_arch = "wasm32"))]
174pub(crate) use crate::folder::watched_file;
175#[cfg(not(target_arch = "wasm32"))]
176pub use crate::folder::{FolderError, read_folder, watched_layer};
177use crate::pacing::{Pacer, WebFrame};
178
179/// The web has no folders to watch.
180#[cfg(target_arch = "wasm32")]
181pub fn watched_layer() -> Option<shuyi_assets::bundle::Layer> {
182    None
183}
184
185/// The web has no folders to read through.
186#[cfg(target_arch = "wasm32")]
187pub(crate) fn watched_file(_: &str) -> Option<&'static [u8]> {
188    None
189}
190
191/// The window a game opens. Build it with [`Window::new`]: a struct
192/// literal doesn't compile outside this crate, so a new field never breaks
193/// a game (ADR-0039).
194///
195/// ```compile_fail
196/// let window = shuyi_app::Window {
197///     title: "A game",
198///     width: 640,
199///     height: 480,
200///     icon: None,
201///     vsync: true,
202/// };
203/// ```
204#[derive(Debug, Clone, Copy)]
205#[non_exhaustive]
206pub struct Window {
207    /// The title bar's text.
208    pub title: &'static str,
209    /// The window's size at start, in pixels (the window is resizable).
210    pub width: i32,
211    /// See [`width`](Self::width).
212    pub height: i32,
213    /// The window's icon, if the game has one.
214    pub icon: Option<Icon>,
215    /// Whether the window asks the display's driver for vsync: on by
216    /// default ([`Window::new`]), so frames don't tear. The driver may
217    /// ignore it, and it is read only when the window opens, so it is the
218    /// developer's choice, not the player's.
219    ///
220    /// The frame cap (`shuyi_ui::frame_cap`, 60 frames a second unless the
221    /// game or the player says) limits the rate either way, and never
222    /// waits on top of vsync: on a 60 Hz display with vsync on, a cap of 60
223    /// leaves vsync to it. On a VRR display (G-Sync, `FreeSync`, adaptive
224    /// sync) with vsync on, a cap a few frames under the display's refresh
225    /// (141 on a 144 Hz display) keeps it in its variable range, and the
226    /// display follows the frames. A tick stage steps 60 times a second, so
227    /// above 60 each step shows for whole frames: evenly at 120 and 240, but
228    /// 2 and 3 frames in turn at 144 and 165; a cap that is a multiple of 60
229    /// avoids it.
230    pub vsync: bool,
231}
232
233impl Window {
234    /// A window titled `title`, `width` × `height` pixels at start, with
235    /// no icon ([`with_icon`](Self::with_icon) gives one) and vsync on
236    /// ([`with_vsync`](Self::with_vsync) turns it off).
237    #[must_use]
238    pub const fn new(title: &'static str, width: i32, height: i32) -> Self {
239        Self {
240            title,
241            width,
242            height,
243            icon: None,
244            vsync: true,
245        }
246    }
247
248    /// The window with `icon` (`None` for none): usable in a `const`
249    /// item.
250    #[must_use]
251    pub const fn with_icon(self, icon: Option<Icon>) -> Self {
252        Self { icon, ..self }
253    }
254
255    /// The window asking for vsync or not ([`vsync`](Self::vsync)):
256    /// usable in a `const` item.
257    #[must_use]
258    pub const fn with_vsync(self, vsync: bool) -> Self {
259        Self { vsync, ..self }
260    }
261}
262
263/// A window icon: PNG files of 16, 32 and 64 pixels square. It is
264/// `shuyi_platform`'s, which a game's `AppInfo` carries (ADR-0061).
265pub use shuyi_platform::WindowIcon as Icon;
266
267/// Where a game's voice clips are (game ADR-0046): a folder shipped beside the
268/// game (not embedded), with a folder per language holding the manifest
269/// and the clips.
270#[derive(Debug, Clone, Copy)]
271#[non_exhaustive]
272pub struct VoiceFiles {
273    /// The folder beside the game, e.g. `voice`.
274    pub folder: &'static str,
275    /// The script's language, whose folder is read, e.g. `en`.
276    pub lang: &'static str,
277    /// The manifest's file name in that folder, e.g. `voice.ron`.
278    pub manifest: &'static str,
279}
280
281impl VoiceFiles {
282    /// The clips in `folder`, in its folder for `lang`, listed by the
283    /// manifest file `manifest`.
284    #[must_use]
285    pub const fn new(folder: &'static str, lang: &'static str, manifest: &'static str) -> Self {
286        Self {
287            folder,
288            lang,
289            manifest,
290        }
291    }
292}
293
294/// What a game tells the macroquad backend. How it starts, loads and is
295/// painted is its [`Start`]. Build it with [`AppConfig::new`].
296#[non_exhaustive]
297pub struct AppConfig {
298    /// The window.
299    pub window: Window,
300    /// The storage's name: the folder in the OS's data folder on native
301    /// ([`storage::data_dir`]), the prefix of every `localStorage` key on
302    /// the web ([`storage::web_prefix`]). Changing it loses every save.
303    pub storage_name: &'static str,
304    /// The game's embedded files by path: the images, the font atlas's
305    /// picture and a loading card's picture are read with it. Under `cargo
306    /// shuyi run --watch` the folder of its files on disk lies over it
307    /// ([`watched_layer`]), and a picture saved there is drawn from then on.
308    pub files: fn(&str) -> Option<&'static [u8]>,
309    /// The font atlas's picture: its path for [`files`](Self::files).
310    pub font_png: &'static str,
311    /// The sound files the audio manifest names, and the music folder
312    /// shipped beside the game (not embedded).
313    pub sounds: Files<'static>,
314    /// Where the voice clips are; `None` for a game without voices.
315    pub voices: Option<VoiceFiles>,
316    /// The system's own keyboard for text, if the game's build has one
317    /// (Steam's on-screen keyboard is the game's: its ticket 0903);
318    /// `None` where the player types on keys.
319    pub text_input: Option<Box<dyn TextInput>>,
320    /// Whether a panic writes a crash report, and the game's version in it
321    /// ([`crash`]): reports with no version until the game assigns
322    /// [`CrashReports::new`] with its own, or [`CrashReports::off`].
323    pub crash: CrashReports,
324}
325
326impl AppConfig {
327    /// The config of a game with every field it must choose, with no
328    /// voices and no system keyboard: a game that has them assigns
329    /// [`voices`](Self::voices) and [`text_input`](Self::text_input).
330    #[must_use]
331    pub const fn new(
332        window: Window,
333        storage_name: &'static str,
334        files: fn(&str) -> Option<&'static [u8]>,
335        font_png: &'static str,
336        sounds: Files<'static>,
337    ) -> Self {
338        Self {
339            window,
340            storage_name,
341            files,
342            font_png,
343            sounds,
344            voices: None,
345            text_input: None,
346            crash: CrashReports::new(""),
347        }
348    }
349}
350
351/// The window settings for macroquad's entry point
352/// (`#[macroquad::main(window_conf)]`).
353pub fn window_conf(window: &Window) -> Conf {
354    Conf {
355        window_title: window.title.to_owned(),
356        window_width: window.width,
357        window_height: window.height,
358        window_resizable: true,
359        // Full-resolution framebuffer on scaled displays, so the console's
360        // integer scale is in real pixels (see render.rs).
361        high_dpi: true,
362        icon: window.icon.and_then(|icon| window_icon(&icon)),
363        platform: miniquad::conf::Platform {
364            // A hint the driver may ignore; miniquad reads it only here.
365            swap_interval: Some(i32::from(window.vsync)),
366            ..Default::default()
367        },
368        ..Default::default()
369    }
370}
371
372/// The window's icon, or `None` if a picture doesn't decode or isn't the
373/// right size.
374fn window_icon(icon: &Icon) -> Option<miniquad::conf::Icon> {
375    fn pixels<const N: usize>(png: &[u8]) -> Option<[u8; N]> {
376        let image = Image::from_file_with_format(png, None).ok()?;
377        image.bytes.try_into().ok()
378    }
379    Some(miniquad::conf::Icon {
380        small: pixels(icon.small)?,
381        medium: pixels(icon.medium)?,
382        big: pixels(icon.big)?,
383    })
384}
385
386/// Runs a game until it quits, as `start` says: the first frame shows its
387/// loading card, the next loads it (or shows why it can't: a wrong command
388/// line, a broken asset), and each frame after, `App` reads the keyboard
389/// and controllers, steps the game, plays its sounds, music and voices and
390/// draws its frame. Closing the window lets the game save first.
391///
392/// The game's context gets this build's target's facts
393/// (`shuyi_ui::PlatformFacts`): the target is `SHUYI_TARGET` when the
394/// build sets it (the Steam Deck's export sets `steam-deck`), else `web`
395/// on the web and `desktop` elsewhere. A target this backend has no
396/// profile of is shown as the game's error.
397///
398/// Frames come no faster than the game's frame cap (`shuyi_ui::frame_cap`,
399/// 60 a second unless the game or the player says): after each frame is
400/// drawn, the loop waits until the next is due, unless vsync or the
401/// machine already keeps them that far apart, and reports how it paces to
402/// the game before the next. On the web the browser sets the rate: a frame
403/// before the due time shows the last one again, and its input waits for
404/// the next frame that runs.
405///
406/// A panic writes a crash report as [`AppConfig::crash`] says ([`crash`]).
407/// On native a panic of the game's frame then stops the sound, adds the
408/// game's record of its play to the report and ends the process with exit
409/// code 101; the game's system hook isn't called, so a broken game never
410/// saves.
411pub async fn run<G: GameData>(mut config: AppConfig, start: Start<G>) {
412    // Which target this build is: an unknown one is the load's error.
413    let (start, facts) = facts::start_as(facts::this_target(), start);
414    #[cfg(feature = "capture")]
415    if let Some(plan) = capture::requested() {
416        capture::run(config, start, plan, facts).await;
417        return;
418    }
419    // Voices (game ADR-0046): none, and nothing said, without the folder.
420    let voice_dir = config
421        .voices
422        .map(|v| audio::voice::platform_voice_dir(v.folder, v.lang));
423    let mut voice_manifest = None;
424    if let (Some(voices), Some(dir)) = (&config.voices, &voice_dir) {
425        match read_voice_manifest(dir, voices.manifest).await {
426            Ok(manifest) => voice_manifest = manifest,
427            Err(e) => {
428                warn!("no voices: {}", e);
429            }
430        }
431    }
432    let recorded_manifest = voice_manifest.clone();
433    let start = start.before_load(move |setup| setup.voice_manifest = voice_manifest);
434    // What each decode allocates counts under its kind, from the load on.
435    backend::tag_decodes();
436    let storage_name = config.storage_name;
437    crash::install(config.crash, move || storage::platform(storage_name));
438    let panic_at = crash::panic_at_frame();
439    let mut platform = Macroquad::new(&mut config, facts);
440    let mut app = App::new(start);
441    let kept = sessions::Kept::start(&mut app, config.storage_name, recorded_manifest);
442    let mut pacer = Pacer::new(config.window.vsync);
443    #[cfg(all(feature = "link", not(target_arch = "wasm32")))]
444    let mut link = link::start(config.window.title);
445    #[cfg(not(target_arch = "wasm32"))]
446    let mut perf_file = perf_file::start();
447    loop {
448        let start = pacing::seconds();
449        if cfg!(target_arch = "wasm32") {
450            if pacer.web_frame(start) == WebFrame::Skip {
451                platform.hold_input();
452                app.present_again(&mut platform);
453                next_frame().await;
454                continue;
455            }
456            app.set_pacing(pacer.pacing());
457        } else {
458            pacer.start(start);
459        }
460        // The pacer's reading: one clock for the frame (it never goes back).
461        let now = Millis::from_secs_f64(start);
462        #[cfg(all(feature = "link", not(target_arch = "wasm32")))]
463        if let Some(link) = &mut link {
464            link.before_frame(&app, platform.link_input());
465        }
466        let outcome = frame_or_crash(&mut app, &mut platform, now, panic_at);
467        if app.take_session_save() {
468            kept.save(&app);
469        }
470        #[cfg(all(feature = "link", not(target_arch = "wasm32")))]
471        if let Some(link) = &mut link {
472            answer_link(link, &app, &mut platform);
473        }
474        // E.g. saved key bindings that had to be repaired (game ADR-0031).
475        for line in app.take_new_log() {
476            print_line(&line);
477            let (level, category) = (line.level.name(), line.category.name());
478            crash::note_log(format!("f{} {level} {category}: {}", line.frame, line.text));
479        }
480        #[cfg(not(target_arch = "wasm32"))]
481        if let Some(file) = perf_file.take() {
482            let frame = app.frame_number();
483            perf_file = file.write(&app.take_new_perf(), frame, || {
484                app.heap_snapshot(&mut platform)
485            });
486        }
487        if !platform.started()
488            && let Some(game) = app.game()
489        {
490            let voice_dir = voice_dir.clone().unwrap_or_default();
491            platform.start(game, &config.sounds, voice_dir).await;
492        }
493        // Native: leaving the loop closes the window. The web page can't be
494        // closed, so it keeps showing the last frame.
495        if outcome == FrameOutcome::Quit && !cfg!(target_arch = "wasm32") {
496            kept.save_at_quit(&app);
497            break;
498        }
499        pace(&mut pacer, &platform, &mut app);
500        next_frame().await;
501    }
502}
503
504/// Runs `app`'s frame at `now`, and panics first if it is the one
505/// `SHUYI_PANIC_AT_FRAME` names (`panic_at`). A panic has written its
506/// report: the sound stops, the game's record is added to it, and the
507/// process ends with exit code 101.
508fn frame_or_crash<G: GameData>(
509    app: &mut App<G>,
510    platform: &mut Macroquad,
511    now: Millis,
512    panic_at: Option<u32>,
513) -> FrameOutcome {
514    let frame = app.frame_number().saturating_add(1);
515    crash::set_frame(frame, app.game().and_then(shuyi_ui::Game::top_screen));
516    let run = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
517        assert!(panic_at != Some(frame), "{}", crash::PANIC_ON_PURPOSE);
518        app.frame(platform, now)
519    }));
520    if let Ok(outcome) = run {
521        return outcome;
522    }
523    platform.audio().stop_all();
524    let record = crash::quiet(|| {
525        std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| app.crash_record()))
526    });
527    crash::add_record(record.ok().flatten());
528    std::process::exit(101);
529}
530
531/// After a frame is drawn: the cap it asked for applies from the next, and
532/// on native the loop waits until the next is due, then reports how it
533/// paces to `app` (on the web the report goes before each frame that runs).
534fn pace<G: GameData>(pacer: &mut Pacer, platform: &Macroquad, app: &mut App<G>) {
535    let (cap, serial) = platform.frame_cap();
536    pacer.set_cap(cap, serial);
537    if cfg!(target_arch = "wasm32") {
538        return;
539    }
540    let wait = pacer.wait(pacing::seconds());
541    pacing::sleep(wait, pacing::seconds);
542    pacer.waited(pacing::seconds());
543    app.set_pacing(pacer.pacing());
544}
545
546/// Sends the link's answers due after `app`'s frame, and the window's
547/// picture if its client asked: read now, drawn and not yet shown; or a
548/// heap snapshot with the frames' records, taken now.
549#[cfg(all(feature = "link", not(target_arch = "wasm32")))]
550fn answer_link<G: GameData>(link: &mut link::Listener, app: &App<G>, platform: &mut Macroquad) {
551    link.after_frame(app);
552    if link.wants_screenshot() {
553        match readback::window_png() {
554            Ok((png, width, height)) => link.screenshot(&png, width, height),
555            Err(e) => link.no_screenshot(e),
556        }
557    }
558    if link.wants_snapshot() {
559        let snapshot = app.heap_snapshot(platform);
560        link.snapshot(app, snapshot);
561    }
562}
563
564/// Runs a game on the short path until it quits, in a window of its
565/// console at twice its size: its files become the process's bundle, and
566/// [`run`] starts it as [`BasicGame::start`] says, with no sounds, no
567/// voices and no system keyboard. Called from a plain `fn main`. Under
568/// `cargo shuyi run --watch` the game's files on disk are laid over the
569/// embedded ones ([`watched_layer`]).
570pub fn run_game<A: ActionSet, T: Debug + 'static>(game: BasicGame<A, T>) {
571    let game = match watched_layer() {
572        Some(layer) => game.over(layer),
573        None => game,
574    };
575    // Only the binary installs the bundle: the tools and the tests read
576    // the one the game's data holds, since one test process runs many
577    // games. A bundle installed before (none is) would stay.
578    let _ = shuyi_assets::bundle::install(game.bundle());
579    let window = basic_window(&game);
580    // What `#[macroquad::main]` expands to (macroquad_macro 0.1.8).
581    macroquad::Window::from_config(window_conf(&window), run(basic_config(&game), game.start()));
582}
583
584/// The margin around a short-path game's console, in pixels.
585const BASIC_MARGIN: i32 = 16;
586
587/// The window of a game on the short path: its console at twice its size
588/// and a small margin, titled with its name (`AppInfo::name`), with its
589/// icon (`AppInfo::window_icon`).
590fn basic_window<A: ActionSet, T: Debug + 'static>(game: &BasicGame<A, T>) -> Window {
591    let console = game.console_spec();
592    let side = |cells: u16, cell: u16| i32::from(cells) * i32::from(cell) * 2 + BASIC_MARGIN;
593    Window::new(
594        game.name(),
595        side(console.cols, console.cell_w),
596        side(console.rows, console.cell_h),
597    )
598    .with_icon(game.app().window_icon)
599}
600
601/// A file of the bundle [`run_game`] installed.
602fn installed_file(path: &str) -> Option<&'static [u8]> {
603    shuyi_assets::bundle::installed()?.bytes(path)
604}
605
606/// No sound file: a game on the short path has none.
607fn no_sound(_: &str) -> Bytes {
608    None
609}
610
611/// How errors name a sound file.
612fn sound_shown(name: &str) -> String {
613    name.to_owned()
614}
615
616/// Whether a build leaves a sound file out on purpose: none does.
617fn sound_left_out(_: &str) -> bool {
618    false
619}
620
621/// What the backend needs to know about a game on the short path: its
622/// storage's name (`AppInfo::storage`), its files from the installed bundle and its font's
623/// picture, and its version in its crash reports (`AppInfo::version`); no
624/// sounds, no voices and no system keyboard.
625fn basic_config<A: ActionSet, T: Debug + 'static>(game: &BasicGame<A, T>) -> AppConfig {
626    let sounds = Files::new(&no_sound, &sound_shown, &sound_left_out, "music");
627    let mut config = AppConfig::new(
628        basic_window(game),
629        game.storage_name(),
630        installed_file,
631        FONT_PNG,
632        sounds,
633    );
634    config.crash = CrashReports::new(game.app().version);
635    config
636}
637
638/// Prints a line of App's log with macroquad's macro for its level.
639fn print_line(line: &LogLine) {
640    let (category, text) = (line.category.name(), &line.text);
641    match line.level {
642        LogLevel::Error => {
643            error!("{}: {}", category, text);
644        }
645        LogLevel::Warn => {
646            warn!("{}: {}", category, text);
647        }
648        LogLevel::Info => {
649            info!("{}: {}", category, text);
650        }
651        _ => {
652            debug!("{}: {}", category, text);
653        }
654    }
655}
656
657/// The most characters of an error shown on one line.
658const ERROR_WIDTH: usize = 150;
659
660/// `text` with every line broken at spaces so that none is longer than
661/// `width` characters (a single longer word stays whole).
662fn wrap(text: &str, width: usize) -> String {
663    let mut out = Vec::new();
664    for line in text.lines() {
665        let mut current = String::new();
666        for word in line.split(' ') {
667            let fits = current.chars().count() + 1 + word.chars().count() <= width;
668            if !current.is_empty() && !fits {
669                out.push(std::mem::take(&mut current));
670            }
671            if !current.is_empty() {
672                current.push(' ');
673            }
674            current.push_str(word);
675        }
676        out.push(current);
677    }
678    out.join("\n")
679}
680
681#[cfg(test)]
682mod tests {
683    use shuyi_platform::AppInfo;
684
685    use super::*;
686
687    #[test]
688    fn long_lines_are_broken_at_spaces() {
689        assert_eq!(wrap("aa bb cc", 5), "aa bb\ncc");
690        assert_eq!(wrap("aa bb cc", 4), "aa\nbb\ncc");
691        assert_eq!(wrap("aa bb cc", 8), "aa bb cc");
692        assert_eq!(wrap("aa bb cc", 7), "aa bb\ncc");
693        assert_eq!(wrap("abcdefgh ij", 3), "abcdefgh\nij");
694        assert_eq!(wrap("one\n\ntwo three", 5), "one\n\ntwo\nthree");
695        assert_eq!(wrap("", 5), "");
696    }
697
698    #[test]
699    fn an_icon_that_does_not_decode_is_none() {
700        let broken = Icon::new(b"not a png", b"not a png", b"not a png");
701        assert!(window_icon(&broken).is_none());
702        let window = Window::new("A game", 640, 480).with_icon(Some(broken));
703        let conf = window_conf(&window);
704        assert!(conf.icon.is_none());
705        assert_eq!(
706            (
707                conf.window_title.as_str(),
708                conf.window_width,
709                conf.window_height
710            ),
711            ("A game", 640, 480)
712        );
713    }
714
715    /// A window built in a `const` item, as games build theirs.
716    const ICONED: Window =
717        Window::new("A game", 640, 480).with_icon(Some(Icon::new(b"small", b"medium", b"big")));
718
719    /// A window without vsync, built in a `const` item.
720    const NO_VSYNC: Window = Window::new("A game", 640, 480).with_vsync(false);
721
722    #[test]
723    fn a_window_asks_for_vsync_unless_told_not_to() {
724        let window = Window::new("A game", 640, 480);
725        assert!(window.vsync);
726        assert_eq!(window_conf(&window).platform.swap_interval, Some(1));
727        let off = NO_VSYNC;
728        assert!(!off.vsync);
729        assert_eq!(window_conf(&off).platform.swap_interval, Some(0));
730        assert!(off.with_vsync(true).vsync);
731        assert!(basic_window(&basic()).vsync);
732    }
733
734    #[test]
735    fn a_new_window_has_no_icon_and_with_icon_sets_one() {
736        let window = Window::new("A game", 640, 480);
737        assert_eq!(
738            (window.title, window.width, window.height),
739            ("A game", 640, 480)
740        );
741        assert!(window.icon.is_none());
742        let icon = ICONED.icon.unwrap();
743        assert_eq!(
744            (icon.small, icon.medium, icon.big),
745            (&b"small"[..], &b"medium"[..], &b"big"[..])
746        );
747        assert!(ICONED.with_icon(None).icon.is_none());
748    }
749
750    #[test]
751    fn a_new_config_has_no_voices_and_no_system_keyboard() {
752        let mut config = basic_config(&basic());
753        config = AppConfig::new(
754            config.window,
755            "a-game",
756            installed_file,
757            FONT_PNG,
758            config.sounds,
759        );
760        assert!(config.voices.is_none() && config.text_input.is_none());
761        config.voices = Some(VoiceFiles::new("voice", "en", "voice.ron"));
762        let voices = config.voices.unwrap();
763        assert_eq!(
764            (voices.folder, voices.lang, voices.manifest),
765            ("voice", "en", "voice.ron")
766        );
767    }
768
769    /// A game on the short path with no files.
770    fn basic() -> BasicGame<shuyi_assets::keymap::UiAction> {
771        let files = shuyi_assets::bundle::Layer::table("files", &[]);
772        let app = AppInfo::new("A game", "com.example.a-game", "0.1.0").with_storage("a-game");
773        BasicGame::new(app, files, |_| unreachable!("never started"))
774    }
775
776    #[test]
777    fn a_short_path_games_window_is_its_console_twice_and_a_margin() {
778        let window = basic_window(&basic());
779        assert_eq!(
780            (window.title, window.width, window.height),
781            ("A game", 784, 784)
782        );
783        assert!(window.icon.is_none());
784        let small = basic().console(shuyi_ui::frame::ConsoleSpec::text(20, 10));
785        let window = basic_window(&small);
786        assert_eq!((window.width, window.height), (336, 336));
787    }
788
789    #[test]
790    fn a_short_path_games_crash_reports_name_its_version() {
791        assert_eq!(basic_config(&basic()).crash, CrashReports::new("0.1.0"));
792        assert_eq!(basic_config(&basic()).crash.game_version, "0.1.0");
793    }
794
795    /// A config built in a `const fn`, as `AppConfig::new` stays one.
796    const fn const_config(sounds: Files<'static>) -> AppConfig {
797        AppConfig::new(
798            Window::new("A game", 640, 480),
799            "a-game",
800            installed_file,
801            FONT_PNG,
802            sounds,
803        )
804    }
805
806    #[test]
807    fn a_new_config_writes_reports_with_no_version_and_off_keeps_none() {
808        let sounds = Files::new(&no_sound, &sound_shown, &sound_left_out, "music");
809        let config = const_config(sounds);
810        assert_eq!(config.crash, CrashReports::default());
811        assert_eq!((config.crash.game_version, config.crash.keep), ("", 5));
812        assert_eq!(CrashReports::off().keep, 0);
813    }
814
815    #[test]
816    fn a_short_path_games_storage_is_its_ids_unless_it_says() {
817        let files = shuyi_assets::bundle::Layer::table("files", &[]);
818        let app = AppInfo::new("A game", "com.example.a-game", "0.1.0");
819        let game: BasicGame<shuyi_assets::keymap::UiAction> =
820            BasicGame::new(app, files, |_| unreachable!("never started"));
821        assert_eq!(basic_config(&game).storage_name, "com.example.a-game");
822        assert_eq!(basic_window(&game).title, "A game");
823    }
824
825    #[test]
826    fn a_short_path_games_window_has_its_apps_icon() {
827        let files = shuyi_assets::bundle::Layer::table("files", &[]);
828        let icon = Icon::new(b"small", b"medium", b"big");
829        let app =
830            AppInfo::new("A game", "com.example.a-game", "0.1.0").with_window_icon(Some(icon));
831        let game: BasicGame<shuyi_assets::keymap::UiAction> =
832            BasicGame::new(app, files, |_| unreachable!("never started"));
833        assert_eq!(basic_window(&game).icon, Some(icon));
834        assert_eq!(basic_config(&game).window.icon, Some(icon));
835    }
836
837    #[test]
838    fn a_short_path_games_config_is_silent_and_reads_its_font() {
839        let config = basic_config(&basic());
840        assert_eq!(config.storage_name, "a-game");
841        assert_eq!(config.font_png, "font/atlas.png");
842        assert_eq!(config.window.title, "A game");
843        assert_eq!((config.sounds.sound)("sfx/move.wav"), None);
844        assert_eq!((config.sounds.sound_shown)("sfx/move.wav"), "sfx/move.wav");
845        assert!(!(config.sounds.sound_left_out)("sfx/move.wav"));
846        assert!(config.sounds.music.is_none());
847        assert!(config.voices.is_none() && config.text_input.is_none());
848        // Nothing is installed in the tests: no file is found.
849        assert_eq!((config.files)("font/atlas.png"), None);
850    }
851
852    /// Without the `link` feature the backend never reads `SHUYI_LINK`:
853    /// only the link's module names it, and it is compiled with the
854    /// feature alone.
855    #[test]
856    fn only_the_link_feature_reads_shuyi_link() {
857        let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
858        let mut files = vec![src.clone()];
859        let mut naming = Vec::new();
860        while let Some(path) = files.pop() {
861            if path.is_dir() {
862                files.extend(std::fs::read_dir(&path).unwrap().map(|e| e.unwrap().path()));
863            } else if path.extension().is_some_and(|e| e == "rs") {
864                let text = std::fs::read_to_string(&path).unwrap();
865                if text.contains("SHUYI_LINK") {
866                    naming.push(path.strip_prefix(&src).unwrap().to_owned());
867                }
868            }
869        }
870        naming.sort();
871        let link = std::path::PathBuf::from("link.rs");
872        let lib = std::path::PathBuf::from("lib.rs");
873        assert_eq!(naming, [lib, link]);
874        let here = include_str!("lib.rs");
875        let gate = "#[cfg(all(feature = \"link\", not(target_arch = \"wasm32\")))]
876mod link;";
877        assert!(here.contains(gate));
878    }
879
880    /// The lines of `manifest`'s `[header]` table, without comments and
881    /// blank lines.
882    fn table<'a>(manifest: &'a str, header: &str) -> Vec<&'a str> {
883        let lines = manifest.lines().skip_while(|l| l.trim() != header).skip(1);
884        lines
885            .take_while(|l| !l.starts_with('['))
886            .filter(|l| !l.trim().is_empty() && !l.starts_with('#'))
887            .collect()
888    }
889
890    /// This crate copies the workspace lints because one of them differs
891    /// (`unsafe_code`, game ADR-0034); the copy must not drift. Every other
892    /// crate inherits the workspace's `forbid`.
893    #[test]
894    fn the_lints_are_the_workspace_lints_except_unsafe_code() {
895        let here = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
896        let read = |path: &std::path::Path| std::fs::read_to_string(path).unwrap();
897        let root = here.parent().unwrap().parent().unwrap();
898        let workspace = read(&root.join("Cargo.toml"));
899        let clippy = table(&workspace, "[workspace.lints.clippy]");
900        assert!(clippy.len() > 5, "{clippy:?}");
901        let rust: Vec<String> = table(&workspace, "[workspace.lints.rust]")
902            .iter()
903            .map(|l| l.replace("unsafe_code = \"forbid\"", "unsafe_code = \"deny\""))
904            .collect();
905        assert!(
906            rust.contains(&"unsafe_code = \"deny\"".to_owned()),
907            "{rust:?}"
908        );
909        let manifest = read(&here.join("Cargo.toml"));
910        assert_eq!(table(&manifest, "[lints.clippy]"), clippy);
911        assert_eq!(table(&manifest, "[lints.rust]"), rust);
912        for other in [
913            "crates/assets",
914            "crates/cli",
915            "crates/core",
916            "crates/platform",
917            "crates/ui",
918            "examples/hello-grid",
919        ] {
920            let manifest = read(&root.join(other).join("Cargo.toml"));
921            assert_eq!(table(&manifest, "[lints]"), ["workspace = true"], "{other}");
922        }
923    }
924}