Skip to main content

shuyi_cli/export/
playdate.rs

1//! The Playdate exporter (ADR-0062): the game's library built as a static
2//! library for the device (`thumbv7em-none-eabihf`) and, on Windows where
3//! MinGW is at hand, for the Simulator (`x86_64-pc-windows-gnu`), linked with
4//! the backend's C shim and the SDK's `setup.c`; its pack and its sounds and
5//! music ([`audio`]) baked beside them, its `pdxinfo` and launcher images
6//! written and `NOTICES.txt`; all packed by
7//! the SDK's `pdc` into `<base>.pdx`, and zipped for sideloading.
8//!
9//! The export is a list of [`Step`]s ([`plan`]), so the tests check every
10//! command without the SDK; `export` runs them.
11
12pub mod audio;
13
14use std::collections::BTreeMap;
15use std::env;
16use std::ffi::{OsStr, OsString};
17use std::fs;
18use std::path::{Path, PathBuf};
19use std::process::Command;
20
21use shuyi_assets::bitmap::Bitmap;
22use shuyi_assets::bitmap::dither::{Dither, OneBitSettings, dither};
23use shuyi_assets::image::Image;
24
25use super::disclosure::{FileKind, Shipped};
26use super::{ExportCx, ExportError, ExportReport, Exporter, RunCx, Signing, archive, fresh, paths};
27use crate::app_info;
28use crate::codec;
29use crate::contract::bake::{BakeInput, bake};
30use crate::contract::target::{Level, TargetCheck, TargetProblem, engine_profiles};
31use crate::game::{GameTools, is_unsupported};
32use crate::notices::{self, Build, Section, Text, Work};
33use crate::project::{AppPart, Project};
34
35/// The device's Rust target.
36pub const DEVICE: &str = "thumbv7em-none-eabihf";
37
38/// The Simulator's Rust target: MinGW, like the GNU host toolchain.
39pub const SIMULATOR: &str = "x86_64-pc-windows-gnu";
40
41/// The cargo feature of a game's package that makes its library the game on
42/// the Playdate.
43pub const FEATURE: &str = "playdate";
44
45/// What the build tells `shuyi-playdate`, to link the SDK.
46const CFG: &str = "--cfg=shuyi_playdate_sdk";
47
48/// The device's Rust flags: the device's single-precision FPU, and
49/// position-independent code, so no MOVW/MOVT address pairs, which `pdc`
50/// can't relocate.
51const DEVICE_RUSTFLAGS: [&str; 4] = [
52    "-Ctarget-cpu=cortex-m7",
53    "-Ctarget-feature=-fp64",
54    "-Crelocation-model=pic",
55    CFG,
56];
57
58/// The C compiler's CPU flags for the device (the SDK's `common.mk`'s
59/// `MCFLAGS`).
60const DEVICE_CPU_FLAGS: [&str; 5] = [
61    "-mthumb",
62    "-mcpu=cortex-m7",
63    "-mfloat-abi=hard",
64    "-mfpu=fpv5-sp-d16",
65    "-D__FPU_USED=1",
66];
67
68/// The rest of `common.mk`'s compile flags for the device, without its
69/// debug information, which would hold the build machine's paths, and its
70/// listing and dependency files.
71const DEVICE_COMPILE_FLAGS: [&str; 13] = [
72    "-O2",
73    "-falign-functions=16",
74    "-fomit-frame-pointer",
75    "-Wall",
76    "-Wno-unused",
77    "-Wstrict-prototypes",
78    "-Wno-unknown-pragmas",
79    "-mword-relocations",
80    "-fno-common",
81    "-ffunction-sections",
82    "-fdata-sections",
83    "-DTARGET_PLAYDATE=1",
84    "-DTARGET_EXTENSION=1",
85];
86
87/// The backend's C side, written beside the build's objects: it is compiled
88/// against the SDK's headers, which are never copied into the repository.
89const SHIM: &str = include_str!("../../../playdate/shim/shim.c");
90
91/// Where MSYS2 puts the device's compiler
92/// (`mingw-w64-ucrt-x86_64-arm-none-eabi-toolchain`).
93const MSYS2_ARM_GCC: &str = r"C:\msys64\ucrt64\bin\arm-none-eabi-gcc.exe";
94
95/// Where MSYS2 puts MinGW's compiler (`mingw-w64-x86_64-gcc`), the one a
96/// Windows build links with.
97const MSYS2_GCC: &str = r"C:\msys64\mingw64\bin\gcc.exe";
98
99/// The launcher's folder, in the `.pdx`.
100const LAUNCHER: &str = "launcher";
101
102/// The launcher's list icon, a side in pixels (the SDK's docs).
103const ICON_SIDE: u32 = 32;
104
105/// The launcher's card, in pixels (the SDK's docs).
106const CARD_SIZE: (u32, u32) = (350, 155);
107
108/// The side of the game's icon on its card.
109const CARD_ICON_SIDE: u32 = 128;
110
111/// How to install the SDK.
112fn sdk_help(looked: &str) -> String {
113    format!(
114        "the Playdate SDK wasn't found (looked in: {looked}). Read Panic's SDK license at \
115         https://play.date/dev/ and, if you accept it, download the SDK and run its installer \
116         with the defaults. Set PLAYDATE_SDK_PATH if it isn't in ~/Documents/PlaydateSDK"
117    )
118}
119
120/// How to install the device's compiler.
121fn arm_gcc_help() -> String {
122    format!(
123        "arm-none-eabi-gcc wasn't found on PATH or at {MSYS2_ARM_GCC}. Install it with MSYS2 \
124         (C:\\msys64\\usr\\bin\\pacman.exe -S mingw-w64-ucrt-x86_64-arm-none-eabi-toolchain) or, \
125         on Linux, apt-get install gcc-arm-none-eabi"
126    )
127}
128
129/// Where to look for the SDK, in order: `PLAYDATE_SDK_PATH`, then
130/// `~/Documents/PlaydateSDK` (the installer's default).
131pub fn sdk_candidates(sdk_path: Option<OsString>, home: Option<PathBuf>) -> Vec<PathBuf> {
132    let from_env = sdk_path.filter(|path| !path.is_empty()).map(PathBuf::from);
133    let default = home.map(|home| home.join("Documents").join("PlaydateSDK"));
134    from_env.into_iter().chain(default).collect()
135}
136
137/// The first of `candidates` that holds an SDK (its `C_API/pd_api.h`), or
138/// how to install one.
139pub fn find_sdk(candidates: &[PathBuf]) -> Result<PathBuf, ExportError> {
140    candidates
141        .iter()
142        .find(|path| path.join("C_API").join("pd_api.h").is_file())
143        .cloned()
144        .ok_or_else(|| {
145            let looked: Vec<String> = candidates.iter().map(|p| p.display().to_string()).collect();
146            let looked = if looked.is_empty() {
147                "nowhere: no PLAYDATE_SDK_PATH and no home folder".to_owned()
148            } else {
149                looked.join(", ")
150            };
151            ExportError::MissingTool(sdk_help(&looked))
152        })
153}
154
155/// The SDK on this machine.
156fn sdk_here() -> Result<PathBuf, ExportError> {
157    find_sdk(&sdk_candidates(
158        env::var_os("PLAYDATE_SDK_PATH"),
159        paths::home(),
160    ))
161}
162
163/// The program `file_name` (with `.exe` on Windows) among `first`, in a
164/// folder of `path_var` (`PATH`), or among `then`, whichever is there first.
165fn find_tool(
166    file_name: &str,
167    first: &[&Path],
168    path_var: Option<&OsStr>,
169    then: &[&Path],
170) -> Option<PathBuf> {
171    first
172        .iter()
173        .map(|path| path.to_path_buf())
174        .chain(
175            path_var
176                .into_iter()
177                .flat_map(env::split_paths)
178                .map(|dir| dir.join(file_name)),
179        )
180        .chain(then.iter().map(|path| path.to_path_buf()))
181        .find(|path| path.is_file())
182}
183
184/// What the build needs from the machine.
185#[derive(Debug, Clone, PartialEq, Eq)]
186pub struct Tools {
187    /// The Playdate SDK.
188    pub sdk: PathBuf,
189    /// `arm-none-eabi-gcc`.
190    pub arm_gcc: PathBuf,
191    /// What the Simulator's part is linked with, or why there is none.
192    pub simulator: Result<PathBuf, String>,
193    /// `CARGO_ENCODED_RUSTFLAGS` of a device build and a Simulator's.
194    pub rustflags: (String, String),
195}
196
197/// The Simulator's linker on this machine, or why there is no Simulator
198/// part: it is a Windows DLL here, and a build needs MinGW's `gcc` and the
199/// Rust target.
200fn simulator_here(path_var: Option<&OsStr>) -> Result<PathBuf, String> {
201    if !cfg!(windows) {
202        return Err(
203            "no Simulator part: the export builds one on Windows only, so this .pdx runs on the \
204             device alone"
205                .to_owned(),
206        );
207    }
208    if super::installed(SIMULATOR).is_err() {
209        return Err(format!(
210            "no Simulator part: the Rust target {SIMULATOR} isn't installed (rustup target add \
211             {SIMULATOR})"
212        ));
213    }
214    find_tool("gcc.exe", &[Path::new(MSYS2_GCC)], path_var, &[]).ok_or_else(|| {
215        format!(
216            "no Simulator part: MinGW's gcc wasn't found at {MSYS2_GCC} or on PATH (C:\\msys64\\usr\\bin\\pacman.exe \
217             -S mingw-w64-x86_64-gcc)"
218        )
219    })
220}
221
222/// What the build needs, found on this machine.
223fn tools_here(cx: &ExportCx) -> Result<Tools, ExportError> {
224    let sdk = sdk_here()?;
225    super::installed(DEVICE)?;
226    let path_var = env::var_os("PATH");
227    let arm_gcc = find_tool(
228        &format!("arm-none-eabi-gcc{}", env::consts::EXE_SUFFIX),
229        &[],
230        path_var.as_deref(),
231        &[Path::new(MSYS2_ARM_GCC)],
232    )
233    .ok_or_else(|| ExportError::MissingTool(arm_gcc_help()))?;
234    let remaps = if cx.release {
235        let outside = paths::outside(&cx.root, &cx.workspace);
236        paths::remaps(&cx.workspace, &cx.target_dir, &cx.package, &outside)
237    } else {
238        Vec::new()
239    };
240    let flags = paths::remapped_flags_here(&remaps);
241    Ok(Tools {
242        sdk,
243        arm_gcc,
244        simulator: simulator_here(path_var.as_deref()),
245        rustflags: (
246            with_flags(&flags, &DEVICE_RUSTFLAGS),
247            with_flags(&flags, &[CFG]),
248        ),
249    })
250}
251
252/// `encoded`, Cargo's flags variable, with `flags` after what it holds.
253fn with_flags(encoded: &str, flags: &[&str]) -> String {
254    let mut all: Vec<&str> = encoded.split('\x1f').filter(|f| !f.is_empty()).collect();
255    all.extend(flags);
256    all.join("\x1f")
257}
258
259/// One thing the export does.
260#[derive(Debug, Clone, PartialEq, Eq)]
261pub enum Step {
262    /// Runs `program` with `args` and `envs`.
263    Run {
264        /// What it does, for the error when it fails.
265        what: &'static str,
266        /// The program.
267        program: PathBuf,
268        /// Its arguments.
269        args: Vec<OsString>,
270        /// What it has in its environment beside this process's.
271        envs: Vec<(String, String)>,
272    },
273    /// Fails if `0` holds a path of the build machine (a release build).
274    Search(PathBuf),
275}
276
277/// `items` as arguments.
278fn os_args<I, S>(items: I) -> Vec<OsString>
279where
280    I: IntoIterator<Item = S>,
281    S: Into<OsString>,
282{
283    items.into_iter().map(Into::into).collect()
284}
285
286/// `cargo rustc`'s arguments for the game's library as a static library.
287fn cargo_args(cx: &ExportCx, triple: &str) -> Vec<String> {
288    let mut args: Vec<String> = [
289        "rustc",
290        "-p",
291        &cx.package,
292        "--lib",
293        "--crate-type",
294        "staticlib",
295        "--no-default-features",
296        "--features",
297        FEATURE,
298        "--target",
299        triple,
300    ]
301    .map(str::to_owned)
302    .to_vec();
303    if cx.release {
304        args.push("--release".to_owned());
305    }
306    args
307}
308
309/// Cargo's profile settings for the build, by variable, over the game's own
310/// profile: a `no_std` library has no unwinding, and the device's link wants
311/// everything inlined. Line tables stay in `pdex.elf` for reading a crash's
312/// addresses (`pdc` leaves them out of the game it packs), their paths
313/// remapped.
314fn profile_env(release: bool, rustflags: &str) -> Vec<(String, String)> {
315    let settings: &[(&str, &str)] = if release {
316        &[
317            ("CARGO_PROFILE_RELEASE_PANIC", "abort"),
318            ("CARGO_PROFILE_RELEASE_LTO", "fat"),
319            ("CARGO_PROFILE_RELEASE_CODEGEN_UNITS", "1"),
320            ("CARGO_PROFILE_RELEASE_STRIP", "false"),
321            ("CARGO_PROFILE_RELEASE_DEBUG", "line-tables-only"),
322        ]
323    } else {
324        &[("CARGO_PROFILE_DEV_PANIC", "abort")]
325    };
326    settings
327        .iter()
328        .map(|&(key, value)| (key.to_owned(), value.to_owned()))
329        .chain([(paths::ENCODED.to_owned(), rustflags.to_owned())])
330        .collect()
331}
332
333/// The folders of a build.
334#[derive(Debug, Clone, PartialEq, Eq)]
335pub struct Dirs {
336    /// What `pdc` packs.
337    pub source: PathBuf,
338    /// The C side's sources and objects.
339    pub obj: PathBuf,
340    /// The build's own folder, which outlives a build.
341    pub build: PathBuf,
342}
343
344impl Dirs {
345    /// The folders of `cx`'s build, in Cargo's target folder.
346    fn of(cx: &ExportCx) -> Self {
347        let build = cx.target_dir.join("playdate").join(&cx.package);
348        Self {
349            source: build.join("Source"),
350            obj: build.join("obj"),
351            build,
352        }
353    }
354}
355
356/// The static library Cargo builds of `cx`'s package for `triple`.
357fn rust_lib(cx: &ExportCx, triple: &str) -> PathBuf {
358    let profile = if cx.release { "release" } else { "debug" };
359    cx.target_dir
360        .join(triple)
361        .join(profile)
362        .join(format!("lib{}.a", cx.package.replace('-', "_")))
363}
364
365/// The steps that build `pdex.elf`, for the device.
366fn device_steps(cx: &ExportCx, tools: &Tools, dirs: &Dirs) -> Vec<Step> {
367    let c_api = tools.sdk.join("C_API");
368    let compile = |input: PathBuf, output: PathBuf, strict: bool| {
369        let mut args = os_args(
370            DEVICE_CPU_FLAGS
371                .iter()
372                .chain(&DEVICE_COMPILE_FLAGS)
373                .copied(),
374        );
375        if strict {
376            args.push("-Werror".into());
377        }
378        args.extend(os_args(["-I".into(), c_api.clone().into_os_string()]));
379        args.extend(os_args(["-c".into(), input.into_os_string()]));
380        args.extend(os_args(["-o".into(), output.into_os_string()]));
381        args
382    };
383    let mut link = os_args([
384        dirs.obj.join("shim.o"),
385        dirs.obj.join("setup.o"),
386        rust_lib(cx, DEVICE),
387    ]);
388    link.push("-nostartfiles".into());
389    link.extend(os_args(DEVICE_CPU_FLAGS.iter().copied()));
390    let mut script = OsString::from("-T");
391    script.push(c_api.join("buildsupport").join("link_map.ld"));
392    link.push(script);
393    let mut map = OsString::from("-Wl,-Map=");
394    map.push(dirs.obj.join("pdex.map"));
395    map.push(",--cref,--gc-sections,--no-warn-mismatch,--emit-relocs");
396    link.push(map);
397    let elf = dirs.source.join("pdex.elf");
398    link.extend(os_args(["-o".into(), elf.clone().into_os_string()]));
399    let mut steps = vec![
400        Step::Run {
401            what: "cargo rustc (device)",
402            program: crate::cargo().into(),
403            args: os_args(cargo_args(cx, DEVICE)),
404            envs: profile_env(cx.release, &tools.rustflags.0),
405        },
406        Step::Run {
407            what: "compile shim.c (device)",
408            program: tools.arm_gcc.clone(),
409            // Our own C: no warnings.
410            args: compile(dirs.obj.join("shim.c"), dirs.obj.join("shim.o"), true),
411            envs: Vec::new(),
412        },
413        Step::Run {
414            what: "compile setup.c (device)",
415            program: tools.arm_gcc.clone(),
416            args: compile(
417                c_api.join("buildsupport").join("setup.c"),
418                dirs.obj.join("setup.o"),
419                false,
420            ),
421            envs: Vec::new(),
422        },
423        Step::Run {
424            what: "link pdex.elf (device)",
425            program: tools.arm_gcc.clone(),
426            args: link,
427            envs: Vec::new(),
428        },
429    ];
430    if !cx.release {
431        // The SDK's link map discards `.ARM.exidx` but not each function's
432        // `.ARM.exidx.*`: unoptimized, a game of some 57,000 functions keeps
433        // one for each (and its relocations), past the ELF's 65,280 sections,
434        // and `pdc` crashes on that (0xc0000005, no message). With `panic =
435        // "abort"` nothing unwinds, so they go. A release build, inlined and
436        // LTO'd, stays well under.
437        steps.push(Step::Run {
438            what: "drop unwind index sections (device)",
439            program: tools
440                .arm_gcc
441                .with_file_name(format!("arm-none-eabi-objcopy{}", env::consts::EXE_SUFFIX)),
442            args: os_args([
443                OsString::from("--remove-section=.ARM.exidx*"),
444                OsString::from("--remove-section=.rel.ARM.exidx*"),
445                elf.clone().into_os_string(),
446            ]),
447            envs: Vec::new(),
448        });
449    }
450    steps.push(Step::Search(elf));
451    steps
452}
453
454/// The steps that build `pdex.dll`, for the Simulator, linked by `gcc`.
455fn simulator_steps(cx: &ExportCx, tools: &Tools, gcc: &Path, dirs: &Dirs) -> Vec<Step> {
456    let c_api = tools.sdk.join("C_API");
457    let mut args = os_args([
458        "-shared",
459        "-O2",
460        // Stripped: the `.pdx` carries the Simulator's file to the device too.
461        "-s",
462        "-Wall",
463        "-D_WINDLL",
464        "-DTARGET_SIMULATOR=1",
465        "-DTARGET_EXTENSION=1",
466        "-I",
467    ]);
468    args.push(c_api.clone().into_os_string());
469    args.extend(os_args([
470        dirs.obj.join("shim.c"),
471        c_api.join("buildsupport").join("setup.c"),
472        rust_lib(cx, SIMULATOR),
473    ]));
474    // No time of this build in the file: the same input gives the same file.
475    args.extend(os_args([
476        "-static-libgcc",
477        "-Wl,--no-insert-timestamp",
478        "-o",
479    ]));
480    let dll = dirs.source.join("pdex.dll");
481    args.push(dll.clone().into_os_string());
482    vec![
483        Step::Run {
484            what: "cargo rustc (Simulator)",
485            program: crate::cargo().into(),
486            args: os_args(cargo_args(cx, SIMULATOR)),
487            envs: profile_env(cx.release, &tools.rustflags.1),
488        },
489        Step::Run {
490            what: "link pdex.dll (Simulator)",
491            program: gcc.to_path_buf(),
492            args,
493            envs: Vec::new(),
494        },
495        Step::Search(dll),
496    ]
497}
498
499/// The steps that pack `dirs.source` with the SDK's `pdc` into `pdx`.
500fn pdc_step(tools: &Tools, dirs: &Dirs, pdx: &Path) -> Step {
501    let pdc = tools
502        .sdk
503        .join("bin")
504        .join(format!("pdc{}", env::consts::EXE_SUFFIX));
505    Step::Run {
506        what: "pdc",
507        program: pdc,
508        args: os_args([
509            OsString::from("-sdkpath"),
510            tools.sdk.clone().into_os_string(),
511            dirs.source.clone().into_os_string(),
512            pdx.as_os_str().to_owned(),
513        ]),
514        envs: Vec::new(),
515    }
516}
517
518/// The steps of `cx`'s export, with `tools`, into `pdx`.
519pub fn plan(cx: &ExportCx, tools: &Tools, dirs: &Dirs, pdx: &Path) -> Vec<Step> {
520    let mut steps = device_steps(cx, tools, dirs);
521    if let Ok(gcc) = &tools.simulator {
522        steps.extend(simulator_steps(cx, tools, gcc, dirs));
523    }
524    steps.push(pdc_step(tools, dirs, pdx));
525    steps
526}
527
528/// Runs `steps` in order, stopping at the first that fails.
529fn execute(cx: &ExportCx, steps: &[Step]) -> Result<(), ExportError> {
530    for step in steps {
531        match step {
532            Step::Run {
533                what,
534                program,
535                args,
536                envs,
537            } => {
538                let status = Command::new(program)
539                    .args(args)
540                    .envs(envs.iter().map(|(k, v)| (k, v)))
541                    .current_dir(&cx.root)
542                    .status()
543                    .map_err(|e| {
544                        ExportError::MissingTool(format!(
545                            "{what}: {} doesn't start: {e}",
546                            program.display()
547                        ))
548                    })?;
549                if !status.success() {
550                    return Err(ExportError::Build(format!("{what} exited with {status}")));
551                }
552            }
553            Step::Search(file) => cx.search(file)?,
554        }
555    }
556    Ok(())
557}
558
559/// The `pdxinfo` of the game `part` says, at `version`, with the launcher's
560/// images in its launcher folder if it has any.
561pub fn pdxinfo(part: &AppPart, version: &str, images: bool) -> String {
562    let image_path = if images {
563        format!("imagePath={LAUNCHER}\n")
564    } else {
565        String::new()
566    };
567    format!(
568        "name={}\nauthor={}\nbundleID={}\nversion={version}\nbuildNumber={}\n{image_path}",
569        part.name, part.publisher, part.id, part.build
570    )
571}
572
573/// One-bit picture file `file`'s first style as an RGBA PNG: what is
574/// see-through is white.
575fn one_bit_png(file: &[u8]) -> Result<Vec<u8>, String> {
576    let bitmap = Bitmap::parse(file)?;
577    let (width, height) = (bitmap.width(), bitmap.height());
578    let style = bitmap.style(0).ok_or("no style")?;
579    let stride = usize::from(width).div_ceil(8);
580    let mut rgba = Vec::with_capacity(usize::from(width) * usize::from(height) * 4);
581    for y in 0..usize::from(height) {
582        for x in 0..usize::from(width) {
583            let at = y * stride + x / 8;
584            let bit = |plane: &[u8]| plane[at] >> (7 - (x % 8)) & 1 == 1;
585            let white = bit(style) || bitmap.mask().is_some_and(|mask| !bit(mask));
586            let shade = if white { 255 } else { 0 };
587            rgba.extend_from_slice(&[shade, shade, shade, 255]);
588        }
589    }
590    codec::encode_png(u32::from(width), u32::from(height), &rgba)
591}
592
593/// A black and white PNG of the `side` × `side` RGBA pixels, by `settings`.
594fn dithered(side: u32, rgba: Vec<u8>, settings: OneBitSettings) -> Result<Vec<u8>, String> {
595    let image = Image {
596        width: side,
597        height: side,
598        rgba,
599    };
600    one_bit_png(&dither(&image, settings)?)
601}
602
603/// `icon`'s card: its [`CARD_ICON_SIDE`] middle on white, [`CARD_SIZE`], in
604/// black and white by a threshold, since a card's art is line art.
605fn card_from_icon(side: u32, rgba: &[u8]) -> Result<Vec<u8>, String> {
606    let small = app_info::downscale(side, side, rgba, CARD_ICON_SIDE);
607    let (width, height) = CARD_SIZE;
608    let mut out = vec![255; (width * height * 4) as usize];
609    let (left, top) = ((width - CARD_ICON_SIDE) / 2, (height - CARD_ICON_SIDE) / 2);
610    for y in 0..CARD_ICON_SIDE {
611        for x in 0..CARD_ICON_SIDE {
612            let from = ((y * CARD_ICON_SIDE + x) * 4) as usize;
613            let to = (((y + top) * width + x + left) * 4) as usize;
614            // On white, by its alpha.
615            let alpha = u32::from(small[from + 3]);
616            for channel in 0..3 {
617                let shade = u32::from(small[from + channel]) * alpha + 255 * (255 - alpha);
618                out[to + channel] = u8::try_from(shade / 255).unwrap_or(u8::MAX);
619            }
620        }
621    }
622    let image = Image {
623        width,
624        height,
625        rgba: out,
626    };
627    one_bit_png(&dither(
628        &image,
629        OneBitSettings::new(Dither::Threshold(128)),
630    )?)
631}
632
633/// The launcher's images of the game `part` says, by file name: from its
634/// `playdate_icon` and `playdate_card` where it names them, else a one-bit
635/// bake of its `icon`. None if it has neither.
636fn launcher_images(part: &AppPart, root: &Path) -> Result<BTreeMap<&'static str, Vec<u8>>, String> {
637    let mut images = BTreeMap::new();
638    let read = |path: &String| fs::read(root.join(path)).map_err(|e| format!("{path}: {e}"));
639    if let Some(path) = &part.playdate_icon {
640        images.insert("icon.png", read(path)?);
641    }
642    if let Some(path) = &part.playdate_card {
643        images.insert("card.png", read(path)?);
644    }
645    if let Some(icon) = app_info::icon(part, root)? {
646        if !images.contains_key("icon.png") {
647            let small = app_info::downscale(icon.side, icon.side, &icon.rgba, ICON_SIDE);
648            images.insert(
649                "icon.png",
650                dithered(ICON_SIDE, small, OneBitSettings::default())?,
651            );
652        }
653        if !images.contains_key("card.png") {
654            images.insert("card.png", card_from_icon(icon.side, &icon.rgba)?);
655        }
656    }
657    Ok(images)
658}
659
660/// The SDK's `setup.c`, linked into every game, in `NOTICES.txt`: its
661/// license is the SDK's `buildsupport/License.md`.
662fn setup_c_notice(sdk: &Path) -> Result<Work, ExportError> {
663    let file = sdk.join("C_API").join("buildsupport").join("License.md");
664    let license = fs::read_to_string(&file)
665        .map_err(|e| ExportError::Build(format!("read {}: {e}", file.display())))?;
666    let mut work = Work::new(Section::Crates, "setup.c, of the Playdate SDK");
667    "0BSD".clone_into(&mut work.license);
668    "Panic Inc., https://play.date/dev/".clone_into(&mut work.source);
669    work.texts = vec![Text::new("the SDK's buildsupport/License.md", license)];
670    Ok(work)
671}
672
673/// The pack's file in the export's folder, between the bake and the build.
674const BAKED: &str = "game.pack";
675
676/// The baked audio's folder in the export's folder, between the bake and the
677/// build. It isn't there when the game's tools offer no audio.
678const BAKED_AUDIO: &str = "baked-audio";
679
680/// Bakes the sounds and music `game` ships on `cx`'s target, for the build.
681fn bake_audio(cx: &ExportCx, game: &dyn GameTools) -> Result<(), ExportError> {
682    let into = cx.out.join(BAKED_AUDIO);
683    if into.exists() {
684        fs::remove_dir_all(&into)
685            .map_err(|e| ExportError::Build(format!("clear {}: {e}", into.display())))?;
686    }
687    let shipped = match game.audio(&cx.profile) {
688        Ok(shipped) => shipped,
689        Err(e) if is_unsupported(&e) => return Ok(()),
690        Err(e) => return Err(ExportError::Setup(format!("the game's audio: {e}"))),
691    };
692    let jobs = audio::jobs(&shipped, &cx.root)
693        .map_err(|e| ExportError::Setup(format!("the game's audio: {e}")))?;
694    fs::create_dir_all(&into)
695        .map_err(|e| ExportError::Build(format!("create {}: {e}", into.display())))?;
696    audio::bake(&jobs, &audio::cache_dir(&Dirs::of(cx).build), &into)
697        .map_err(|e| ExportError::Build(format!("the game's audio: {e}")))
698}
699
700/// Moves what [`bake_audio`] made into what `pdc` packs: true if there was any.
701fn place_audio(cx: &ExportCx, dirs: &Dirs) -> Result<bool, ExportError> {
702    let baked = cx.out.join(BAKED_AUDIO);
703    if !baked.is_dir() {
704        return Ok(false);
705    }
706    let names = fs::read_dir(&baked)
707        .map_err(|e| ExportError::Build(format!("read {}: {e}", baked.display())))?;
708    for entry in names.flatten() {
709        let to = dirs.source.join(entry.file_name());
710        fs::rename(entry.path(), &to)
711            .map_err(|e| ExportError::Build(format!("move to {}: {e}", to.display())))?;
712    }
713    fs::remove_dir_all(&baked)
714        .map_err(|e| ExportError::Build(format!("clear {}: {e}", baked.display())))?;
715    Ok(true)
716}
717
718/// The Playdate exporter.
719pub static PLAYDATE: PlaydateExporter = PlaydateExporter;
720
721/// The Playdate exporter's type.
722pub struct PlaydateExporter;
723
724impl PlaydateExporter {
725    /// `report` of `pdx`, with a warning if it is bigger than the profile's
726    /// budget, and why it has no Simulator part if it hasn't.
727    fn warnings(
728        cx: &ExportCx,
729        pdx_bytes: u64,
730        simulator: &Result<PathBuf, String>,
731        has_audio: bool,
732    ) -> Vec<TargetProblem> {
733        let budget = engine_profiles().ok().and_then(|profiles| {
734            profiles
735                .get(&cx.profile)
736                .and_then(|profile| profile.profile.budgets.package_bytes)
737        });
738        let mut warnings = Vec::new();
739        if let Some(budget) = budget.filter(|&budget| pdx_bytes > budget) {
740            warnings.push(TargetProblem::display(
741                Level::Warning,
742                TargetCheck::Fits,
743                "budgets.package_bytes".to_owned(),
744                format!("the .pdx is {pdx_bytes} bytes, over the profile's {budget}"),
745            ));
746        }
747        if let Err(why) = simulator {
748            warnings.push(TargetProblem::display(
749                Level::Warning,
750                TargetCheck::Fits,
751                "simulator".to_owned(),
752                why.clone(),
753            ));
754        }
755        if !has_audio {
756            warnings.push(TargetProblem::display(
757                Level::Warning,
758                TargetCheck::Fits,
759                "audio".to_owned(),
760                "the game's tools offer no audio (GameTools::audio), so this .pdx is silent"
761                    .to_owned(),
762            ));
763        }
764        warnings
765    }
766}
767
768impl Exporter for PlaydateExporter {
769    fn id(&self) -> &'static str {
770        "playdate"
771    }
772
773    fn profile(&self) -> &'static str {
774        "playdate"
775    }
776
777    fn triples(&self) -> &'static [&'static str] {
778        &[DEVICE, SIMULATOR]
779    }
780
781    fn builds_a_program(&self) -> bool {
782        false
783    }
784
785    fn takes_export_part(&self) -> bool {
786        false
787    }
788
789    fn cargo_args(&self, cx: &ExportCx) -> Vec<Vec<String>> {
790        self.triples()
791            .iter()
792            .map(|triple| cargo_args(cx, triple))
793            .collect()
794    }
795
796    /// Bakes the game's pack for the target with its tools.
797    fn prepare(
798        &self,
799        cx: &ExportCx,
800        project: &Project,
801        game: Option<&dyn GameTools>,
802    ) -> Result<(), ExportError> {
803        if !cx.features.iter().any(|feature| feature == FEATURE) {
804            return Err(ExportError::Setup(format!(
805                "the package `{}` has no `{FEATURE}` feature, which makes its library the game \
806                 on the Playdate (docs/targets/playdate.md)",
807                cx.package
808            )));
809        }
810        let game = game.ok_or_else(|| {
811            ExportError::Setup(format!(
812                "{} has no `game` part: the Playdate's pack is baked by the game's tools",
813                crate::project::FILE
814            ))
815        })?;
816        bake_audio(cx, game)?;
817        let input = BakeInput {
818            target: Some(cx.profile.clone()),
819            out: Some(format!("{}/{BAKED}", cx.shown.replace('\\', "/"))),
820            ..BakeInput::default()
821        };
822        let answer = bake(game, project, &input);
823        if answer.ok {
824            Ok(())
825        } else {
826            let problems: Vec<String> = answer
827                .result
828                .iter()
829                .flat_map(|output| &output.errors)
830                .map(|problem| problem.message.clone())
831                .chain(answer.error.clone())
832                .collect();
833            Err(ExportError::Build(format!(
834                "the pack: {}",
835                problems.join("; ")
836            )))
837        }
838    }
839
840    fn export(&self, cx: &ExportCx) -> Result<ExportReport, ExportError> {
841        for args in self.cargo_args(cx) {
842            super::no_live_link(&args)?;
843        }
844        let part = cx.app.as_ref().ok_or_else(|| {
845            ExportError::Setup(format!(
846                "{} has no `app` part: a .pdx's pdxinfo is written from it",
847                crate::project::FILE
848            ))
849        })?;
850        let tools = tools_here(cx)?;
851        let dirs = Dirs::of(cx);
852        fresh(&dirs.source)?;
853        fresh(&dirs.obj)?;
854        super::write(&dirs.obj.join("shim.c"), SHIM)?;
855        let images = launcher_images(part, &cx.root).map_err(ExportError::Setup)?;
856        let info = pdxinfo(part, &cx.version, !images.is_empty());
857        super::write(&dirs.source.join("pdxinfo"), &info)?;
858        if !images.is_empty() {
859            let folder = dirs.source.join(LAUNCHER);
860            fresh(&folder)?;
861            for (name, bytes) in &images {
862                let file = folder.join(name);
863                fs::write(&file, bytes)
864                    .map_err(|e| ExportError::Build(format!("write {}: {e}", file.display())))?;
865            }
866        }
867        let baked = cx.out.join(BAKED);
868        fs::rename(&baked, dirs.source.join(playdate_pack()))
869            .map_err(|e| ExportError::Build(format!("move the pack {}: {e}", baked.display())))?;
870        let has_audio = place_audio(cx, &dirs)?;
871        let mut works = notices::crates(&cx.root, &cx.package, DEVICE, &[FEATURE.to_owned()])
872            .map_err(ExportError::Build)?;
873        works.extend(notices::engine_files(Build::Native, &works));
874        works.push(setup_c_notice(&tools.sdk)?);
875        works.extend(cx.game.iter().cloned());
876        notices::write(&dirs.source, &cx.title, &works).map_err(ExportError::Build)?;
877        let name = format!("{}.pdx", cx.base());
878        let pdx = cx.out.join(&name);
879        if pdx.exists() {
880            fs::remove_dir_all(&pdx)
881                .map_err(|e| ExportError::Build(format!("clear {}: {e}", pdx.display())))?;
882        }
883        execute(cx, &plan(cx, &tools, &dirs, &pdx))?;
884        for part in ["pdex.bin", "pdex.dll"] {
885            let file = pdx.join(part);
886            if file.exists() {
887                cx.search(&file)?;
888            }
889        }
890        // Panic's sideloading page takes a zip of the folder.
891        let zip = format!("{name}.zip");
892        let entries =
893            archive::entries(&pdx, &format!("{name}/"), &|_| false).map_err(ExportError::Build)?;
894        archive::zip(&cx.out.join(&zip), &entries).map_err(ExportError::Build)?;
895        let size = cx.size(&pdx)?;
896        let warnings = Self::warnings(cx, size.bytes, &tools.simulator, has_audio);
897        let sizes = vec![size, cx.size(&cx.out.join(&zip))?];
898        let mut report = cx.report(&name, sizes, Signing::None, None);
899        report.warnings = warnings;
900        Ok(report)
901    }
902
903    /// What `pdc` makes of what is written here: the programs, `pdxinfo`, the
904    /// launcher's images, the notices and the pack; the sounds and music it
905    /// bakes from the game's tools; and `pdex.elf`, read in place of the
906    /// `pdex.bin` that may be compressed.
907    fn shipped(&self, cx: &ExportCx, report: &ExportReport) -> Shipped {
908        let tree = cx.out.join(&report.package);
909        let archive = cx.out.join(format!("{}.zip", report.package));
910        let mut shipped = Shipped::new(tree, Some(archive), format!("{}/", report.package));
911        for file in ["pdex.bin", "pdex.dll"] {
912            shipped.source(file, FileKind::Program, "the built library");
913        }
914        for file in [
915            "pdxinfo",
916            notices::FILE,
917            "launcher/icon.pdi",
918            "launcher/card.pdi",
919        ] {
920            shipped.source(file, FileKind::Written, "written by the export");
921        }
922        shipped.source(BAKED, FileKind::Pack, "the game's bundle, baked");
923        // `pdc` writes the time it ran into the `pdxinfo` it copies.
924        shipped
925            .varying
926            .insert("pdxinfo".to_owned(), "buildtime=".to_owned());
927        let found = archive::entries(&shipped.tree, "", &|_| false).unwrap_or_default();
928        for entry in found {
929            if entry.name.starts_with("audio/") || entry.name.starts_with("music/") {
930                shipped.source(entry.name, FileKind::LooseAsset, "GameTools::audio, baked");
931            }
932        }
933        shipped.inputs.push((
934            "pdex.elf".to_owned(),
935            "what pdc makes pdex.bin from".to_owned(),
936            Dirs::of(cx).source.join("pdex.elf"),
937        ));
938        shipped
939    }
940
941    /// Opens the `.pdx` in the SDK's Simulator, and waits for it to close.
942    /// What the game logs shows in the Simulator's own console: on Windows
943    /// the program gives its caller nothing.
944    fn run(&self, cx: &RunCx) -> Result<(), ExportError> {
945        let pdx = cx.out.join(&cx.report.package);
946        if !pdx.join("pdex.dll").is_file() {
947            let why = cx
948                .report
949                .warnings
950                .iter()
951                .find(|warning| warning.setting == "simulator")
952                .map_or("no Simulator part was built", |warning| {
953                    warning.message.as_str()
954                });
955            return Err(ExportError::NoRunStep(format!(
956                "{} can't open in the Simulator: {why}",
957                cx.report.package
958            )));
959        }
960        let simulator = sdk_here()?
961            .join("bin")
962            .join(format!("PlaydateSimulator{}", env::consts::EXE_SUFFIX));
963        let status = Command::new(&simulator).arg(&pdx).status().map_err(|e| {
964            ExportError::MissingTool(format!("{} doesn't start: {e}", simulator.display()))
965        })?;
966        if status.success() {
967            Ok(())
968        } else {
969            Err(ExportError::Build(format!(
970                "the Simulator exited with {status}"
971            )))
972        }
973    }
974}
975
976/// The pack's file in the `.pdx`, as `shuyi-playdate` reads it: a test keeps
977/// the two equal.
978pub const fn playdate_pack() -> &'static str {
979    BAKED
980}
981
982#[cfg(test)]
983mod tests;