diff --git a/.gitignore b/.gitignore index c7ab5ab3..d68dbada 100644 --- a/.gitignore +++ b/.gitignore @@ -31,12 +31,29 @@ mobile/ios/love-src/ mobile/ios/cache/ mobile/ios/build/ -# love-nx vendor binaries (fetch per docs/switch-development.md; also covered by .*) +# love-nx vendor binaries (fetch per docs/switch-build.md; also covered by .*) .bazinga/love-nx/ # Final packaged build artifacts (mac/win/web/android/ios/switch) — see scripts/build.sh /dist/ +# Switch OTA launcher build outputs +ports/switch/ota-launcher/build/ +ports/switch/ota-launcher/build-host/ +ports/switch/ota-launcher/*.nro +ports/switch/ota-launcher/*.nacp +ports/switch/ota-launcher/*.elf +ports/switch/ota-launcher/*.map +ports/switch/ota-launcher/romfs/logo.rgba +ports/switch/ota-launcher/romfs/logo.png +ports/switch/ota-launcher/romfs/cacert.pem +ports/switch/ota-launcher/romfs/ota-bootstrap.nro +ports/switch/ota-bootstrap/build/ +ports/switch/ota-bootstrap/*.nro +ports/switch/ota-bootstrap/*.nacp +ports/switch/ota-bootstrap/*.elf +ports/switch/ota-bootstrap/*.map + # Legacy manual convenience-copy location (superseded by /dist/android/) mobile/dist/ diff --git a/README.md b/README.md index 93fbb99e..ba1fff43 100644 --- a/README.md +++ b/README.md @@ -298,24 +298,18 @@ Install steps, controls, and troubleshooting live in ## Nintendo Switch -Releases ship an SD-ready `gen1recomp-*-switch.zip` (issue -[#531](https://github.com/bryanthaboi/gen1recomp/issues/531)). Runtime target -is pinned [love-nx](https://github.com/retronx-team/love-nx) `11.5-nx1`. -Requires a console that can run Switch homebrew. Hardware evidence: **OLED** -(author) and **V1 / Erista** boot (community). +Releases ship an SD-ready `gen1recomp-*-switch.zip`. Runtime target is pinned +[love-nx](https://github.com/retronx-team/love-nx) `11.5-nx1`. Requires a +console that can run Switch homebrew. -- Players: [docs/switch-install.md](docs/switch-install.md) — download the +- Players: [docs/switch-install.md](docs/switch-install.md). Download the zip, extract at the microSD root (install or update), title-override launch, import your own legal ROM, Joy-Con controls and shortcuts. -- Builders: [docs/switch-build.md](docs/switch-build.md) — `--fetch` / - `--loose` / `--fused`, toolchain, Docker fallback, and **CI vs release** - (path-gated ubuntu selftest, canonical fused PR artifact, release hard-fail). - -Limitations, Dusklight-derived method, and how we tested: -[docs/switch-development.md](docs/switch-development.md) and -[docs/switch-hardware-evidence.md](docs/switch-hardware-evidence.md). Community -help — especially HOS / love-nx packaging and broader hardware coverage — is -welcome. +- Builders: [docs/switch-build.md](docs/switch-build.md). `--fetch` / + `--loose` / `--fused`, toolchain, Docker fallback, and CI vs release + (path-gated ubuntu selftest, fused PR artifact on the main repo, release + hard-fail). +- File transfer (MTP / SD / FTP): [docs/switch-transfer.md](docs/switch-transfer.md). ## Modding @@ -364,7 +358,4 @@ This project would not be possible without [pret](https://github.com/pret) > the pret band of decompiling maniacs > and their [pokered](https://github.com/pret/pokered) disassembly. -Nintendo Switch port: [andrewqsantos](https://github.com/andrewqsantos). -Switch hardware testing (V1 boot): [booshankles](https://github.com/booshankles). -

diff --git a/docs/switch-build.md b/docs/switch-build.md index 74b33567..61f8a574 100644 --- a/docs/switch-build.md +++ b/docs/switch-build.md @@ -1,38 +1,66 @@ -# Build the Nintendo Switch NRO — contributor guide +# Build Gen1Recomp for Nintendo Switch Want to play a release build instead? Download the SD-ready zip and extract it -at your microSD root — see [switch-install.md](switch-install.md). +at your microSD root. See [switch-install.md](switch-install.md). This guide is for contributors who build Gen1Recomp for Switch from source. -Hardware evidence, MTP operator loops, and deeper notes live in -[switch-development.md](switch-development.md). -> Releases ship `gen1recomp-*-switch.zip` (SD tree under `switch/gen1recomp/`; -> issue [#531](https://github.com/bryanthaboi/gen1recomp/issues/531)). Hardware -> evidence: **OLED** (author) and **V1 boot** (community). See -> [switch-development.md](switch-development.md) for known limitations. +> Releases ship `gen1recomp-*-switch.zip` (SD tree under `switch/gen1recomp/`). +> Runtime target is pinned [love-nx](https://github.com/retronx-team/love-nx) +> `11.5-nx1`. Player install and limitations: [switch-install.md](switch-install.md). --- ## Prerequisites by OS All packaging entrypoints are **bash**. On Windows, use Git Bash, MSYS2, or -WSL — not cmd.exe or PowerShell (AD-008). +WSL, not cmd.exe or PowerShell (AD-008). ### macOS / Linux 1. Install [devkitPro pacman](https://devkitpro.org/wiki/devkitPro_pacman). -2. Install Switch tools: +2. Install Switch tools (**required for `--fused`**): ```sh sudo dkp-pacman -S switch-dev ``` -3. Ensure `nacptool` and `elf2nro` are on `PATH` (or under - `$DEVKITPRO/tools/bin` — the fused script prepends that when set). +3. OTA launcher toolchain, **native or Docker** (either is fine): -**Optional:** Install [Docker](https://docs.docker.com/get-docker/) so fused -builds can fall back to the pinned image when native tools are missing. + ```sh + bash scripts/switch/install_devkitpro_deps.sh # native + # or install Docker (same pin as fused builds) + ``` + +4. Ensure `DEVKITPRO` is exported (typical macOS: `/opt/devkitpro`) and + `nacptool` / `elf2nro` are on `PATH` (or under `$DEVKITPRO/tools/bin`). + +Fused game builds can also use Docker when native `nacptool`/`elf2nro` are absent. + +### Native OTA launcher (included in `--fused`) + +In-console OTA uses a **separate DEVKITPRO NRO** (not LÖVE). The LÖVE +self-updater (`Check.lua`) is disabled on NX. Source: +`ports/switch/ota-launcher/`. Host protocol tests (no toolchain): + +```sh +make -C ports/switch/ota-launcher host-test +# or +scripts/switch/build_ota_launcher.sh # host-test first; NRO needs DEVKITPRO/Docker +``` + +`--fused` always builds the fused game, native OTA launcher, and dual-NRO SD +zip. The same `*-switch.zip` is the OTA download asset. **DEVKITPRO is +required.** OTA launcher: native packages **or** Docker. Both are supported. + +Release-like build from repo root: + +```sh +scripts/build_switch.sh --fetch --fused --version X.Y.Z +``` + +See `ports/switch/ota-launcher/README.md` and +`scripts/switch/ota_launcher.manifest`. ### Windows (Git Bash / MSYS2 / WSL) @@ -42,16 +70,15 @@ builds can fall back to the pinned image when native tools are missing. - **WSL** (Ubuntu/etc.) with the Linux pacman flow above, or - **Git Bash** for `--fetch` / `--loose`; for `--fused` prefer MSYS2 or WSL if Docker bind-mounts from Git Bash paths misbehave. -2. Install `switch-dev` (or rely on Docker fallback — see below). +2. Install `switch-dev` (or rely on Docker fallback; see below). 3. Do **not** expect `scripts/build_switch.sh` to run under cmd/PowerShell. ### What you must install yourself | You install | Script does **not** install | | ----------- | --------------------------- | -| bash, git, zip tooling the repo already expects | — | -| `dkp-pacman` + `switch-dev` (native fused) | `dkp-pacman -S …` | -| Docker (optional fused fallback) | Docker Engine | +| bash, git, zip tooling the repo already expects | (none) | +| `dkp-pacman` + `switch-dev` + OTA packages **or** Docker | `dkp-pacman -S …` | | A legal `.gb` ROM (to play) | Any ROM or game data | --- @@ -64,12 +91,12 @@ builds can fall back to the pinned image when native tools are missing. | ---- | ------------ | | `--fetch` | Downloads pinned **love.nro** + **love.elf** into `.bazinga/love-nx/11.5-nx1/` and verifies SHA-256 against `scripts/switch/love-nx-11.5-nx1.sha256`. | | `--loose` | Packs `game.love`, copies pinned `love.nro` → `dist/switch/loose/` as `gen1recomp.nro` + `game.love` side by side. Needs the pin. | -| `--fused` | Builds `dist/switch/gen1recomp--switch.nro` (game in romfs) via `nacptool` + `elf2nro`, then packs `dist/switch/gen1recomp--switch.zip` (SD-ready tree). Needs the pin + toolchain (native or Docker). GitHub Releases publish the **zip only**. | +| `--fused` | Builds fused game NRO, OTA launcher NRO, and dual-NRO SD zip. **Requires DEVKITPRO** + `switch-dev`. OTA launcher: native packages or Docker. GitHub Releases publish the **zip only**. | Rules: - `--fetch` alone is fine; combine as `--fetch --loose` or `--fetch --fused`. -- `--loose` and `--fused` are **XOR** — pick one packaging path per run. +- `--loose` and `--fused` are **XOR**. Pick one packaging path per run. - `--version X.Y.Z` sets the NACP / filename version (defaults to short git SHA). ### What `--fetch` downloads @@ -109,13 +136,15 @@ scripts/build_switch.sh --fetch # Loose pair for iteration (fetch + assemble) scripts/build_switch.sh --fetch --loose -# Single fused NRO + SD-ready zip for a release-like artifact +# Fused game + OTA launcher + dual-NRO SD zip for a release-like artifact scripts/build_switch.sh --fetch --fused --version 0.2.0 ``` Outputs land under `dist/switch/` (and `dist/switch/loose/` for loose mode). -The fused path also writes `gen1recomp--switch.nro.sha256` and -`gen1recomp--switch.zip` (+ `.sha256` sidecar for the zip). +The fused path also writes `gen1recomp--switch.nro` (game), +`gen1recomp--launcher.nro`, `gen1recomp--game.nro`, +`gen1recomp--switch.nro.sha256`, and `gen1recomp--switch.zip` +(+ `.sha256` sidecar for the zip). Offline packaging smoke (no network, no nacptool required): @@ -143,7 +172,7 @@ the NX runtime modules `src/core/NxAssetOverlay.lua`, `src/core/Platform.lua`, `tests/engine/switch_diagnostics_test.lua`, `tests/engine/platform_nx_*`, or the Switch-related workflow YAML), CI runs: -1. **Offline selftest** on `ubuntu-latest` (forks **and** the canonical repo): +1. **Offline selftest** on `ubuntu-latest` (forks **and** the main repo): `scripts/switch/selftest_build_switch.sh`, `scripts/switch/verify_payload.sh --self-test`, `luajit tests/switch_ci_workflows_test.lua`, @@ -151,14 +180,10 @@ or the Switch-related workflow YAML), CI runs: headlessly (`luajit tests/engine/assets_version_fallback_test.lua`, `luajit tests/engine/nx_generated_guard_test.lua`, `luajit tests/engine/nx_yellow_boot_test.lua`). -2. **Fused NRO build** only on the **canonical** repository +2. **Fused NRO build** only on the **main** repository (`bryanthaboi/gen1recomp`), on the self-hosted Mac runner (`scripts/build_switch.sh --fetch --fused`), and only when the workflow - head is that repo (same-repo push/PR). **Fork repository** CI never runs - fused. **Fork → canonical PRs** also skip Switch fused (offline selftest - still runs) so untrusted head code is not executed on the self-hosted Mac; - iOS device build eligibility is unchanged. Fused also waits for a successful - offline selftest before starting on the Mac runner. + head is that repo (same-repo push/PR). Fork CI never runs fused. Fork PRs into the main repo also skip Switch fused (offline selftest still runs) so untrusted head code is not executed on the self-hosted Mac; iOS device build eligibility is unchanged. Fused also waits for a successful offline selftest before starting on the Mac runner. 3. On successful PR fused builds, a follow-up workflow posts a PR comment linking the Actions artifact named `gen1recomp-switch-nro` (comment tag `switch-build-result`; see @@ -169,7 +194,7 @@ Unrelated PRs do not burn the self-hosted Mac on Switch packaging. ### Release hard-fail (`.github/workflows/release.yml`) GitHub Releases always build Switch on the same self-hosted Mac runner as the -other platforms — this is a **hard gate** (no `continue-on-error`): +other platforms. This is a **hard gate** (no `continue-on-error`): ```sh scripts/build_switch.sh --fetch --fused --version "" @@ -181,9 +206,26 @@ A Switch packaging failure fails the entire release job. The release asset is ### Runner provisioning -The self-hosted Mac runner must have **native switch-tools** (`nacptool` / -`elf2nro`) **and/or Docker** available. CI and release do not silently run -`dkp-pacman -S`; keep the runner image/host provisioned per this guide. +The self-hosted Mac runner **must** have **DEVKITPRO** installed and exported. +`--fused` preflight fails early with setup steps if it is missing. + +**One-time setup on the runner** (if not already present): + +```sh +# devkitPro pacman installer from https://devkitpro.org/wiki/devkitPro_pacman +sudo dkp-pacman -S switch-dev +export DEVKITPRO=/opt/devkitpro +export PATH="$DEVKITPRO/tools/bin:$PATH" + +# OTA launcher: pick one +bash scripts/switch/install_devkitpro_deps.sh # native +# or ensure Docker is installed (same pin as fused builds) +``` + +CI and release still run `scripts/build_switch.sh --fetch --fused`. Preflight +requires DEVKITPRO and either native OTA packages or Docker. Without all of +that, the job fails with the setup steps above. Scripts never auto-run +`dkp-pacman -S` during CI. --- @@ -194,11 +236,9 @@ These scripts and this guide do **not**: - Push files to the console (no automated MTP / FTP / SD scripting) - Bundle or download any Pokémon ROM - Install `dkp-pacman` / `switch-dev` for you -- Provide `nxlink` / netloader deploy (deferred — see [switch-transfer.md](switch-transfer.md)) -- Validate **Applet Mode** — use title override (hold **R**) for full memory +- Provide `nxlink` / netloader deploy (deferred; see [switch-transfer.md](switch-transfer.md)) +- Validate **Applet Mode**. Use title override (hold **R**) for full memory Player install steps: [switch-install.md](switch-install.md). Manual transfer (MTP / SD / FTP, macOS / Linux / Windows): -[switch-transfer.md](switch-transfer.md). -Hardware depth and evidence: [switch-development.md](switch-development.md), -[switch-hardware-evidence.md](switch-hardware-evidence.md). +[switch-transfer.md](switch-transfer.md). diff --git a/docs/switch-development.md b/docs/switch-development.md deleted file mode 100644 index 8e00e072..00000000 --- a/docs/switch-development.md +++ /dev/null @@ -1,528 +0,0 @@ -# Nintendo Switch development (love-nx) - -> Fused NRO support for issue [#531](https://github.com/bryanthaboi/gen1recomp/issues/531). -> Releases ship `gen1recomp-*-switch.zip` (SD-ready tree). Console copy is -> extract/merge at microSD root; title override required. See -> [Known limitations](#known-limitations-read-before-reviewing). - -**Canonical install / build / transfer docs** (start here unless you need hardware depth): - -- Players → [switch-install.md](switch-install.md) -- Builders → [switch-build.md](switch-build.md) (`scripts/build_switch.sh --fetch` downloads the pinned love-nx pair) -- Transfer (MTP / SD / FTP on macOS, Linux, Windows) → [switch-transfer.md](switch-transfer.md) - -This document covers what landed, known limitations, how hardware was tested, -vendor layout, build/deploy, and the contributor transfer loop (detail lives in -the transfer runbook). - -## Acknowledgments - -- **Port / love-nx packaging:** [andrewqsantos](https://github.com/andrewqsantos) -- **Community hardware testing** (Switch V1 / Erista boot): [booshankles](https://github.com/booshankles) -- **Method guidance:** [Dusklight Switch port](https://github.com/HayatoG/dusklight/tree/main/platforms/switch) / love-nx -- **Upstream project:** [bryanthaboi](https://github.com/bryanthaboi) / Gen1Recomp - -## Status - -| Area | State | -| ---- | ----- | -| Feature | **Available** — playable fused NRO path (issue #531) | -| Runtime | Pinned love-nx **`11.5-nx1`** | -| Product artifact | Releases: SD-ready `gen1recomp-*-switch.zip`; local/PR: fused `.nro`; loose `nro`+`game.love` for iteration | -| Hardware | **OLED** validated (author, title override); **V1 / Erista** boot confirmed (community). Lite, docked soak, and Pro Controller matrices welcome | -| Deploy / install | Releases publish SD-ready zip; **extract/merge at microSD root** (MTP / SD / FTP — [switch-transfer.md](switch-transfer.md)); no `nxlink` path yet | -| Contributor transfer | Documented for **macOS, Linux, and Windows**; OpenMTP on Mac is one example, not the only contract | -| Network features on NX | Self-update / remote mod download **disabled** (`networkValidated == false`) | -| Community help | Welcome — especially HOS / love-nx packaging and broader hardware coverage | - -### What landed - -- Detect `NX` via `src/core/Platform.lua` without reusing Android flags -- Writable ROM inbox under `getSaveDirectory()/imports/` + per-tab “Scan again” (SHA-1 match for the open game) -- Joy-Con / gamepad mapping shared by launcher and gameplay (Nintendo A/B UX on NX) -- Launcher L/R tab switch; gameplay L/R game-speed cycle; Select+face display chords -- Focus loss / joystick reconnect recovery; opt-in `switch-debug.txt` diagnostics -- Loose assemble + fused NRO build scripts (`scripts/build_switch.sh`, `scripts/switch/*`) -- Payload gates so ROM / generated cache / saves never enter `game.love` -- Community mod zip inbox at `imports/mods/` (rescan installs; FIND MODS stays network-gated) -- Raw `.sav` inbox at `imports/saves/{red,blue,yellow}/` (**Import save** rescan) + export pull path `exports/{red,blue,yellow}/` (MTP hint; no openURL) -- Hardware evidence for Phase 0 probe, ROM import, naming A/B, save/suspend, fused NRO — see `docs/switch-hardware-evidence.md` -- Path-gated CI selftest + canonical fused PR artifact; release Switch hard-fail -- Save editor pad/touch input (virtual cursor, A click, B close) — see `tools/save-editor/README.md` -- Dynamic display size on NX only: handheld **1280×720**, docked/TV **1920×1080** (`src/core/NxDisplay.lua` + resizable conf so love-nx SDL can follow dock/undock at runtime) - -### Known gaps / welcome contributions - -- Docked vs handheld soak (≥30 min) and Lite coverage — resolution switch is implemented; long soak still welcome -- Switch Lite and fuller Pro Controller / third-party pad matrices -- Applet Mode remains unsupported by design (title override required) -- `nxlink` / netloader contrib fast-loop (deferred — see [switch-transfer.md](switch-transfer.md)) - -Transfer runbooks for Linux/Windows (and SD/FTP alternatives) are in -[switch-transfer.md](switch-transfer.md). Community mod zip install OLED smoke -is **pass** — see NXMOD-12 in [switch-hardware-evidence.md](switch-hardware-evidence.md). - -## Design references (Dusklight) - -This work borrowed method — not the native stack — from the [Dusklight Switch port](https://github.com/HayatoG/dusklight/tree/main/platforms/switch), especially [`LESSONS_AND_REUSE.md`](https://github.com/HayatoG/dusklight/blob/main/platforms/switch/LESSONS_AND_REUSE.md): - -| Dusklight lesson | How Gen1Recomp applied it | -| ---------------- | ------------------------- | -| Emulators hide Tegra failures | Gate milestones on **real OLED hardware**, not Ryujinx/Yuzu alone | -| Prove the lower layer first | `tools/switch-probe` before full launcher | -| Know which binary ran | Embedded `build-info.json` (commit / love-nx tag) | -| Cap continuous logs | Opt-in diagnostics, ≤1 Hz flush; Lua error log rotation | -| Crash symbolization needs the exact ELF | Keep pinned `love.elf` with the NRO under test | -| Full memory matters | Title override; Applet Mode is not the validation path | -| Do not treat SD FS like desktop POSIX | Lua stays on `love.filesystem`; inbox + MTP for user files | -| Isolate platform code | Capability module instead of Android flag overload | -| NVK / WSI / `audren` stacks | **Not** copied — love-nx already supplies video/audio/input/FS | - -The packaging goal matches Dusklight’s **single self-contained `.nro`**; contributor transfer stays multi-host (not Mac-only). - -## Known limitations (read before reviewing) - -1. **Transfer is manual and multi-method.** Runtime only needs files under the LÖVE save directory / NRO install folder. Use MTP, direct SD, or FTP per [switch-transfer.md](switch-transfer.md). macOS + OpenMTP is a documented example for OLED evidence — not “Switch requires a Mac.” -2. **Deploy is manual.** There is no automated push to the console and no `nxlink` path yet. Operators build locally, transfer files, then title-override launch. -3. **Hardware coverage.** Author P0/P1 pass rows were recorded on one Switch OLED; Switch V1 boot was confirmed independently. Treat Lite, docked soak, and other hosts as unknown until someone re-runs the checklist. -4. **No ROM/save/mod zip bytes in git.** Legal dumps and third-party mods stay on the console (or local untracked folders). -5. **AppleDouble sidecars** (`._*`) from some MTP clients can break zip/ROM/`.sav` scans — the launcher skips hidden `.*` names (including `._*.sav`); still prefer clean copies. - -## How we tested - -| Layer | What | Where | -| ----- | ---- | ----- | -| Unit / headless | Platform NX flags, RomImporter inbox, dual-path input, mod zip inbox, save `.sav` inbox, display chords, payload/self-tests | `tests/*`, `scripts/test.sh` | -| Switch CI / packaging | Path-gated offline selftest (`selftest_build_switch.sh`, `verify_payload.sh --self-test`, `switch_ci_workflows_test.lua`); canonical fused PR artifact | `.github/workflows/ci.yml`, [switch-build.md](switch-build.md) § CI and release | -| Probe on hardware | `getOS()==NX`, 1280×720, save path, Joy-Con events | `tools/switch-probe` → OLED | -| Integration on hardware | MTP inbox ROM import, Play Red/Blue, naming A/B, quit/reopen save, suspend×10, reboot, fused NRO alone + NRO-only update | `docs/switch-hardware-evidence.md` | -| Community hardware | Switch V1 / Erista boot with prebuilt NRO | [booshankles](https://github.com/booshankles) — see evidence log | -| Known gaps | Docked soak, ≥30 min long-play, Lite, automated/`nxlink` deploy | Matrix deferred / absent rows | - -Operator evidence must stay in `docs/switch-hardware-evidence.md`. **Do not invent passes** for hardware not run. - -## love-nx 11.5-nx1 (pinned) - -**Tag:** [11.5-nx1](https://github.com/retronx-team/love-nx/releases/tag/11.5-nx1) - -**Local layout (not committed):** - -```text -.bazinga/love-nx/11.5-nx1/ -├── love.nro # homebrew launcher binary (loose mode: copied to gen1recomp.nro) -└── love.elf # required for fused NRO builds (devkitPro nacptool/elf2nro) -``` - -**Manifest:** `scripts/switch/love-nx-11.5-nx1.sha256` lists expected artifact names and SHA-256 checksums. Checksums are filled when binaries are fetched (`TBD_*` placeholders until then). - -### Fetch instructions - -Preferred (automated checksum verify): - -```bash -scripts/build_switch.sh --fetch -``` - -That downloads pinned `love.nro` + `love.elf` into `.bazinga/love-nx/11.5-nx1/` -and checks them against `scripts/switch/love-nx-11.5-nx1.sha256`. See -[switch-build.md](switch-build.md) for the full mode glossary. - -Manual fallback: - -1. Open the [11.5-nx1 release](https://github.com/retronx-team/love-nx/releases/tag/11.5-nx1) and download `love.nro` and `love.elf`. -2. Create the directory: `mkdir -p .bazinga/love-nx/11.5-nx1` -3. Move both files into that directory. -4. Confirm checksums match the manifest: - - ```bash - shasum -a 256 .bazinga/love-nx/11.5-nx1/love.nro \ - .bazinga/love-nx/11.5-nx1/love.elf - ``` - -**Never commit** love-nx binaries, ROM dumps, or generated cache into git. The repo `.gitignore` excludes `.bazinga/` (vendor cache) and `/dist/` (build output). - -## Loose-mode dist layout - -Development builds place `gen1recomp.nro` and `game.love` side by side: - -```text -dist/switch/loose/ -├── gen1recomp.nro -└── game.love -``` - -Assemble with: - -```bash -scripts/build_switch.sh --loose -``` - -(See `scripts/switch/assemble_loose.sh` for the underlying copy + checksum step.) - -## Transfer & deploy (current contributor loop) - -Detail for **macOS / Linux / Windows** and **MTP / SD / FTP** lives in -[switch-transfer.md](switch-transfer.md). Summary: - -| Layer | Intent | -| ----- | ------ | -| **Runtime / players** | Extract the release zip at microSD root (`switch/gen1recomp/`) and land ROMs/mods under the save-dir inboxes. The game does not hard-depend on OpenMTP or macOS. | -| **Contributor loop** | Manual copy via MTP (DBI responder), direct SD (Hekate UMS / reader), or FTP. Fully manual — no CI deploy, no `nxlink` yet. | - -The Mac + OpenMTP steps that remain below are the **OLED evidence reproduction** path; prefer the transfer runbook for day-to-day contrib on other hosts. - -**Still avoided for routine evidence** (keeps SD handling honest): - -- Treating `nxlink` / netloader as the release deploy story (deferred) -- DBI `MicroSD install` / `NAND install` / NSP-style virtual folders for the `.love`/`.nro` pair - -If MTP fails: check cable, USB port, DBI state, and that only one MTP client holds the device — then retry or switch to SD/FTP. Do not silently rewrite evidence using an untested path and claim parity with recorded SHA-256 round-trips. - -### Manual deploy checklist (today) - -1. Build on the contributor host (`scripts/build_switch.sh --loose` or fused). -2. Close Gen1Recomp on the Switch; open DBI → `Run MTP responder`. -3. Copy artifacts with your MTP client into `1: SD Card/switch/gen1recomp/` (and ROMs/mods into the save-dir inboxes when needed). -4. Wait for the transfer queue; refresh; optionally round-trip SHA-256 on first artifacts of a type. -5. Exit MTP; launch via **title override** (hold **R** on a title → hbmenu, not Applet Mode). - -## OpenMTP + DBI transfer (loose build, Mac evidence example) - -Full multi-OS / multi-method steps: [switch-transfer.md](switch-transfer.md). -The numbered Mac loop below reproduces the OLED evidence path. - -### On the Switch - -1. Close Gen1Recomp if it is running. -2. Open **DBI** from hbmenu. -3. Select **`Run MTP responder`** (DBI documents `X` on the main screen). -4. Keep DBI on that screen for the entire transfer. -5. Connect the Switch to the Mac with a USB-C data cable. - -### On the Mac - -1. Close any other MTP clients. -2. Open **OpenMTP** and select the DBI device. -3. In the remote pane, open **`1: SD Card`**. -4. Navigate to **`switch/`** and create **`gen1recomp/`** if needed. -5. Enter **`1: SD Card/switch/gen1recomp/`**. -6. Drag from the local pane: - - ```text - dist/switch/loose/gen1recomp.nro - dist/switch/loose/game.love - ``` - -7. Wait for the OpenMTP queue to finish completely. -8. Refresh the remote listing and confirm file sizes match the local files. -9. On the Switch, exit MTP responder normally in DBI before launching the app. - -Expected layout on SD: - -```text -1: SD Card/ -└── switch/ - └── gen1recomp/ - ├── gen1recomp.nro - └── game.love -``` - -## Round-trip SHA-256 verification - -For the **first deploy** of each artifact type (loose pair, later fused NRO), verify MTP integrity: - -1. **Before send** — record local hashes: - - ```bash - shasum -a 256 dist/switch/loose/gen1recomp.nro \ - dist/switch/loose/game.love - ``` - -2. **After send** — in OpenMTP, copy the same files from `1: SD Card/switch/gen1recomp/` back to an empty local folder, e.g. `dist/switch/mtp-roundtrip/`. - -3. **Compare** round-trip hashes: - - ```bash - shasum -a 256 dist/switch/mtp-roundtrip/gen1recomp.nro \ - dist/switch/mtp-roundtrip/game.love - ``` - -4. Local pre-send and round-trip hashes **must match**. Record results in the test report template below. - -Repeat whenever a cable glitch or interrupted transfer is suspected. - -## Title override launch (full memory) - -Applet Mode is **not** the primary validation path. Use **title override** so hbmenu runs with full memory: - -1. Confirm the OpenMTP transfer queue finished. -2. Exit MTP responder in DBI; disconnect USB if desired. -3. Hold **`R`** while launching any legitimately installed title. -4. Keep holding until **hbmenu** appears. -5. Confirm hbmenu does **not** show **Applet Mode**. -6. Launch **`gen1recomp`** (or the probe NRO during Phase 0). - -Album / applet launches are only useful to document applet-specific limitations; P0/P1 gates use title override. - -## Phase 0 hardware checklist - -Complete **in order** on OLED hardware. Operator fills evidence fields — leave blank until tested. - -| Step | Action | Pass | Evidence / notes | -| ---- | ------ | ---- | ---------------- | -| P0-0a | Fetch love-nx 11.5-nx1; record manifest SHA-256 | yes | See `scripts/switch/love-nx-11.5-nx1.sha256` | -| P0-0b | Build `switch-probe.love` per `tools/switch-probe/README.md` | yes | | -| P0-0c | Assemble loose probe (`game.love` = probe) to `dist/switch/loose/` | yes | | -| P0-0d | MTP deploy to `1: SD Card/switch/gen1recomp/`; round-trip SHA-256 | yes | nro `8290ac15…5918f5`; love `9f198637…fa2e34f` | -| P0-0e | Title override → probe boots; `getOS()` shows `NX` | yes | `getOS()`=`NX`, `love._os`=`NX` | -| P0-0f | Probe lists 1280×720 (or documented dims), save path, gamepad/touch log | yes | save `sdmc:/switch/gen1recomp/switch-probe`; Joy-Con Y→#3 X→#4 | -| P0-1a | Replace `game.love` with unpatched Gen1Recomp build | yes | feat/switch-nx inbox build | -| P0-1b | MTP replace `game.love` only; round-trip SHA-256 | yes | | -| P0-1c | Title override → launcher reaches import screen | yes | | -| P0-1d | Joy-Con: can navigate launcher (no touch-only) | yes | Full report: `docs/switch-hardware-evidence.md` | - -**Operator:** Andrew **Date:** 2026-08-01 **Console:** Switch OLED only -**Deploy:** manual Mac + OpenMTP + DBI MTP (not automated) -**love-nx tag:** 11.5-nx1 **gen1recomp commit:** `df7cea4` - -## Phase 0 test report template - -Copy this block into your hardware notes or PR evidence. **Do not commit ROM files or ROM hashes of private dumps.** - -```markdown -## Switch Phase 0 — hardware report - -- Operator: -- Date: -- Console model: -- Atmosphère / HOS version: -- gen1recomp commit: -- love-nx tag: 11.5-nx1 -- love.nro SHA-256 (local): -- game.love SHA-256 (local, pre-send): -- MTP round-trip SHA-256 (gen1recomp.nro): -- MTP round-trip SHA-256 (game.love): -- Title override used: yes / no -- Applet Mode observed: yes / no (should be no for P0) -- Probe getOS(): -- Probe dimensions: -- Probe save directory shown: -- Gamepad events logged: yes / no -- Touch events logged: yes / no -- Unpatched launcher boot: pass / fail -- Joy-Con launcher navigation: pass / fail / not tested -- Notes: -``` - -## Fast dev loop (loose mode) - -While iterating on Lua/assets: - -1. Edit on Mac; run `scripts/test.sh --quick`. -2. Rebuild `.bazinga/work/game.love` (`scripts/build.sh mac --no-notarize` or project pack step). -3. Close Gen1Recomp on Switch. -4. DBI → `Run MTP responder`. -5. OpenMTP → `1: SD Card/switch/gen1recomp/`. -6. Replace **only** `game.love`; wait for queue + refresh listing. -7. Exit MTP responder; launch via title override. -8. Keep `gen1recomp.nro` unchanged until the love-nx pin changes. - -```bash -scripts/test.sh --quick -scripts/build.sh mac --no-notarize -scripts/build_switch.sh --loose -shasum -a 256 .bazinga/work/game.love -``` - -## Controller input mapping (NX) - -Measured on Switch OLED (`feat/switch-nx`, love-nx `11.5-nx1`, 1280×720). Both `joystickpressed` and `gamepadpressed` fire for Joy-Con; prefer the gamepad path when `joystick:isGamepad()` is true. - -| Path | Control | Mapping | -| ---- | ------- | ------- | -| `gamepadpressed` | D-pad / left stick | move | -| `gamepadpressed` | SDL `a` / `b` on **NX** | swapped via `NX_GAMEPAD_BINDINGS`: physical **A** (east) = GB A confirm, physical **B** (south) = GB B cancel | -| `gamepadpressed` | SDL `a` / `b` on desktop | identity (SDL south = GB A) | -| `gamepadpressed` | `start` / `back` | Start / Select (+ / −) | -| `gamepadpressed` | Right / left shoulder (no Select) | Cycle game speed up / down (same as PC hotkey `1` / speed-down path) | -| `joystickpressed` (raw) | only if **not** `isGamepad()` | face/menu fallback | -| `joystickpressed` (raw) | `#1` / `#2` on NX | Nintendo B / A → GB B / A | -| `joystickpressed` (raw) | `#9` / `#10` | Select / Start (− / +) | - -**Nintendo UX on Switch:** physical A confirms, physical B cancels (explicit NX remap of SDL face labels). - -**Launcher extras** (`RomImporter`): physical **A** clicks at the virtual cursor; **L** / **R** switch tabs; **Start** / **Select** start Play when a ROM is ready (else open Choose ROM). D-pad / left stick move the virtual cursor. - -**Dual-path rule:** love-nx emits both `gamepadpressed` and `joystickpressed` for Joy-Con. When `joystick:isGamepad()` is true, Input and RomImporter **ignore raw** face/menu so NamingScreen does not see A+B in one frame. `NamingScreen` also prefers A over B if both edges still fire. - -Implementation: `src/core/GamepadMap.lua` (`NX_RAW_*`, `ignoreRawForJoystick`, `displayChordDigit`), `src/core/Game.lua` (shoulder speed), `src/import/RomImporter.lua` (launcher tabs). Launcher and gameplay share the same converter. - -## ROM inbox (NX) - -Legal dumps land in a shared MTP inbox; **Scan again** is tab-scoped: - -| Item | Value | -| ---- | ----- | -| Save-relative path | `imports/` (also accepts loose `.gb`/`.gbc` at the save-dir root) | -| MTP destination | `1: SD Card//imports/` (see launcher notice for the live `getSaveDirectory()` path) | -| Candidates | `*.gb` / `*.gbc` (hidden `.*` AppleDouble names skipped) | -| Rescan | Game tab → **Scan again** — imports only the dump whose SHA-1 matches that tab (`GameVersion.forSha1`). Other known dumps stay for their own tabs | -| Already ready | Same SHA already imported → “No new ROM found.” | - -Players may drop Red, Blue, and Yellow into the same folder. Opening Yellow and pressing **Scan again** must not start a Red import. - -## Mod zip inbox (NX) - -Community mods install from a **separate** MTP inbox (not mixed into the ROM `imports/` scan): - -| Item | Value | -| ---- | ----- | -| Save-relative path | `imports/mods/` | -| MTP destination | `1: SD Card//imports/mods/` (see launcher notice for the live `getSaveDirectory()` path) | -| Candidates | `*.zip` only | -| Rescan | MODS tab → **Scan again** (installs each zip via `LauncherMods.installZip`; source zips are retained on success and failure) | -| FIND MODS | Remains network-gated / hidden on NX (`networkValidated == false`) | - -Do **not** commit third-party mod zip bytes into git. Drop the zip over MTP, rescan, enable in MODS, then Play. - -**MTP tip (esp. macOS clients):** OpenMTP/Finder often creates AppleDouble sidecars named `._Something.zip` / `._cart.gb` / `._foo.sav`. Those are not real archives, ROMs, or saves — the launcher ignores hidden `.*` names under `imports/`, `imports/mods/`, and `imports/saves//`. If install still fails with “could not be opened” / “not a zip file”, delete any `._*` under the inbox and confirm the real zip starts with the `PK` magic (re-copy the release asset if unsure). This is a host-side annoyance of the current manual MTP loop, not something players should need forever. - -Drop any community release `.zip` into `imports/mods/`, rescan, enable. -Player-facing install steps: [switch-install.md](switch-install.md#community-mods). -Mods own their OPTIONS / rebinds — do not duplicate third-party control tables here. - -## Save `.sav` inbox (NX) - -Raw Gen1 battery images use a **separate** MTP inbox (not mixed into ROM `imports/` or mod `imports/mods/`): - -| Item | Value | -| ---- | ----- | -| Save-relative path | `imports/saves/red/`, `imports/saves/blue/`, `imports/saves/yellow/` | -| MTP destination | `1: SD Card//imports/saves//` (see launcher notice for the live `getSaveDirectory()` path) | -| Candidates | non-hidden `*.sav` only in **that game’s** folder | -| Rescan | SAVE FILES → **Import save** on the matching game tab (scans only that folder) | -| After success | Retire to `*.sav.imported` + append content hash to `imports/saves//.imported-sha1` | -| Exports | **Export save** writes under `exports//gen1recomp--.sav`; NX shows an MTP path notice (no `openURL`) | - -Do **not** commit `.sav` bytes into git. Drop the file into the matching game folder over MTP, press **Import save** on that tab, then play. Pull exports from `exports//`. - -**MTP tip:** the same AppleDouble `._*.sav` rule applies — see the mod inbox tip above. - -## Joy-Con display chords (Select + face) - -PC digit hotkeys for COLORS / TILT / GBC FX / pipelines have Joy-Con equivalents. Hold **Select** (`back` / −) and press a face/shoulder button; the engine runs the same path as `Game:keypressed` for that digit (including `writeOptions` / Pipelines parity). - -| Chord (Nintendo UX) | Engine key | Stock engine effect | -| ------------------- | ---------- | ------------------- | -| Select + **A** | `2` | COLORS cycle | -| Select + **B** | `3` | TILT / perspective | -| Select + **Y** | `5` | GBC FX | -| Select + **X** | `6` | Mod pipeline hotkey (if registered) | -| Select + **L** (left shoulder) | `7` | Mod pipeline hotkey (if registered) | - -Keys `2` / `3` / `4` / `5` are claimed by the engine before mod pipeline hotkeys run, so a community mod cannot rebind those digits through `Pipelines.hotkey`. Mods that need their own controls should use OPTIONS rows or unclaimed hotkeys. - -Without Select held, face buttons keep normal GB A/B gameplay mapping (no accidental color/tilt cycles). The **Options** menu remains available for the same settings — chords are optional shortcuts, not the only path. - -On NX, A/B chords resolve through the Nintendo UX face remap so physical **A** → key `2` and physical **B** → key `3` match this table. - -**OPTIONS → PERFORMANCE** clamps the port’s own extras (TILT / GBC FX / survey ZOOM) and can cap FPS — useful on weaker handheld budgets. Details: [new-features.md — Performance tier](new-features.md#performance-tier-low-end-devices). - -Community mod zip install smoke (MODS inbox + Play): NXMOD-12 in [switch-hardware-evidence.md](switch-hardware-evidence.md). - -**Opt-in diagnostics:** create an empty `switch-debug.txt` in the save directory; events flush to `switch.log` at ≤1 Hz with build identity (no ROM/save bytes). - -**NX asset probe (always on Play):** every Switch Play writes `nx-asset-probe.log` in the save directory (`pokemon-love2d/`). It lists whether `assets/generated/…` vs `yellow|blue/assets/generated/…` exist, what `Assets.resolve` returns, and whether `newImage` / `newImageData` open — for Yellow/Blue blank-sprite triage. No ROM bytes. - -**Blue/Yellow cache overlay (NX):** fused love-nx cannot reliably mount `yellow|blue/assets/generated` onto the un-prefixed path, so `src/core/NxAssetOverlay.lua` wraps EVERY read-side love API that accepts a filesystem path (`filesystem.read/load/lines/newFileData/getInfo`, `graphics.newImage/newFont`, `image.newImageData`, `audio.newSource`, `sound.newSoundData`, `font.newFontData`) once at boot — only when `Platform.isNX()`. Covering the whole read surface (not just the loaders the boot needs today) keeps future states and mods inside the fallback automatically; write-side functions stay stock. Core code must NOT call love loaders on literal `assets/generated` paths (enforced by `tests/engine/nx_generated_guard_test.lua`); the chip-audio worker is a separate Lua state and gets the prefix explicitly via `audio.programPrefix` from `ChipAudio.slimAudio`. - -**Hardware re-test:** T16 **pass** @ `2699c9a` (naming A=confirm / B=cancel). T19 **pass** (quit/reopen, suspend×10, reboot) — operator 2026-08-01. - -**Suspend/resume audio:** after resume, chip music is stopped to avoid duplicate streams; confirm on hardware during P0-09/10 (T19). - -## Lua error log (save directory) - -On any uncaught Lua error, Gen1Recomp appends a redacted trace to `lua-error.log` in the LÖVE save directory (`love.filesystem.getSaveDirectory()`). The on-screen error overlay includes a hint pointing at that file. Logs rotate to `lua-error.log.1` when the active file exceeds 32 KiB. ROM/save bytes and non-printable data are stripped — never commit or share logs that might contain private paths without reviewing them first. - -## Native crash triage (love-nx / Atmosphère) - -love-nx native faults land under the console’s `crash_reports/` folder on SD (reachable via the same manual MTP workflow used for game deploys). - -1. **Collect** — DBI → `Run MTP responder`; copy `sdmc:/crash_reports/*.bin` (or the dated subfolder) to the contributor host. Prefer keeping the microSD in-console for routine pulls. -2. **Redact** — delete any attached screenshots or notes that mention ROM filenames, save paths, or private hashes before sharing logs publicly. -3. **Symbolize** — use the **pinned** `love.elf` from `.bazinga/love-nx/11.5-nx1/` that matches `build-info.json` / `scripts/switch/love-nx-11.5-nx1.sha256`. Never use a “latest” download. - - ```bash - # Example: aarch64-none-elf-addr2line from devkitPro - aarch64-none-elf-addr2line -e .bazinga/love-nx/11.5-nx1/love.elf -f -C 0xADDRESS_FROM_CRASH_REPORT - ``` - -4. **Correlate** — compare `gitCommit` / `loveNxTag` from embedded `build-info.json` with the operator’s hardware notes. - -If `addr2line` cannot resolve an address, archive the crash `.bin` with the exact `love.elf` SHA-256 used for the build — addresses are only meaningful against that ELF. - -## P0 / P1 hardware matrix (ADR §9) - -Operator evidence lives in `docs/switch-hardware-evidence.md`. **Do not invent passes** for rows that require hardware not yet run. - -| ID | Requirement | Status | Evidence | -| -- | ----------- | ------ | -------- | -| P0-0a–f | love-nx pin, probe, MTP, title override | **pass** | Phase 0 checklist above; T4 | -| P0-1a–d | Unpatched launcher boot + Joy-Con nav | **pass** | T4 / `docs/switch-hardware-evidence.md` | -| P0-02 | MTP inbox import path shown | **pass** | T12 | -| P0-03 | Rescan imports ROM | **pass** | T12 | -| P0-04 | Canonical hash routes version | **pass** | T12 | -| P0-05 | Source dump retained in inbox | **pass** | T12 | -| P0-06 | Play reaches game after import | **pass** | T12 | -| P0-07 | Joy-Con launcher navigation | **pass** | T16 @ `2699c9a` | -| P0-08 | Joy-Con gameplay (incl. naming A/B) | **pass** | T16 @ `2699c9a` | -| P0-09 | Save survives quit + reopen | **pass** | T19 | -| P0-10 | ≥10 suspend cycles, no stuck input/dup audio | **pass** | T19 (operator 2026-08-01) | -| P0-12 | Fused NRO boots without adjacent `game.love` | **pass** | T24 — `docs/switch-hardware-evidence.md` | -| P0-14 | Fused NRO MTP round-trip SHA-256 | **pass** | T24 — first artifact `b019e2e8…` @ `6fb5602` (redeploy after Blue fix) | -| P0-15 | Replace NRO only; saves persist | **pass** | T24 — operator NRO-only update keeps saves | -| P1-01 | Docked vs handheld spot-check | **deferred** | Code: `NxDisplay` 720p↔1080p; OLED dock soak not recorded yet | -| P1-02 | Applet Mode documented unsupported | **pass** | Title override required; Album path not validated | -| P1-03 | Long-play soak (≥30 min) | **deferred** | No soak session recorded | -| P1-04 | Reboot persistence | **pass** | T19 | -| P1-05 | Audio resume after suspend | **pass** | T19 (no dup audio reported) | -| — | Switch V1 / Erista boot | **pass** (boot) | Community — [booshankles](https://github.com/booshankles); see evidence log | -| — | Switch Lite / docked soak | **untested** / **deferred** | Welcome contributions | -| — | Automated / `nxlink` deploy | **absent** | Manual MTP / SD / FTP only (AD-009) | -| — | Multi-OS transfer runbooks | **pass** | [switch-transfer.md](switch-transfer.md) | -| — | Community mod zip OLED smoke (NXMOD-12) | **pass** | `docs/switch-hardware-evidence.md` | - -## Review guidance - -Maintainers may review as one PR or split later. Suggested slices (optional): - -Each slice should declare: **no ROM/save bytes committed**, **love-nx pin with manifest checksums**, **hardware-tested rows listed with linked evidence**, **Applet Mode unsupported**, **network/updater disabled on NX**, **deploy still manual** (MTP / SD / FTP; no nxlink yet), **OpenMTP is one example not the sole contract**. - -### Slice 1 — Platform + import (`platform/import`) - -- `src/core/Platform.lua`, `conf.lua` NX branch -- `src/import/RomImporter.lua` (NX flags, inbox, scan, shell/updater gates) -- Tests: `tests/engine/platform_nx_*`, `tests/engine/rom_importer_nx_*` (ROM-free T2) -- Docs: inbox/MTP import sections only - -### Slice 2 — Input + lifecycle (`input/lifecycle`) - -- `src/core/GamepadMap.lua`, `Input.lua`, `main.lua` focus/joystick hooks -- `src/debug/SwitchDiagnostics.lua` (opt-in probe + error log) -- Tests: input/diagnostics suites -- Docs: controller mapping, suspend/audio notes - -### Slice 3 — Build + docs (`build/docs`) - -- `scripts/pack_love.sh`, `scripts/build_switch.sh`, `scripts/switch/*` -- `assets/switch/icon.jpg`, `docs/switch-development.md`, hardware evidence templates -- Gates: `pack_love.sh --dry-run`, `verify_payload.sh --self-test`, fused build script (devkitPro host) - -**Pre-merge checklist:** - -- [ ] Manifest `scripts/switch/love-nx-11.5-nx1.sha256` filled; binaries not in git -- [ ] `verify_payload.sh` rejects generated cache / ROM / `.sav` / `.bak` -- [ ] P0 matrix rows marked pass only with linked hardware evidence -- [x] Fused NRO P0-12/14/15 pass with T24 evidence (`docs/switch-hardware-evidence.md`) -- [ ] Updater / remote mod download hidden on NX (`networkValidated == false`) - diff --git a/docs/switch-hardware-evidence.md b/docs/switch-hardware-evidence.md deleted file mode 100644 index 12e93c04..00000000 --- a/docs/switch-hardware-evidence.md +++ /dev/null @@ -1,187 +0,0 @@ -# Switch hardware evidence (Phase 0 + import + input) - -> **Hardware evidence log.** Author passes below were recorded on **one -> Nintendo Switch OLED** with a **manual** Mac → DBI MTP deploy loop. A -> separate community row records Switch V1 / Erista boot. These rows do -> **not** claim Lite, docked soak, or automated install. See -> `docs/switch-development.md` for status and limitations. - -**love-nx:** `11.5-nx1` -**Author console:** Switch OLED -**Deploy method (author):** manual OpenMTP + DBI `Run MTP responder` (no CI / no nxlink) -**Operator (author rows):** Andrew ([andrewqsantos](https://github.com/andrewqsantos)) -**Date (author rows):** 2026-08-01 - -Do **not** commit ROM dumps or private dump hashes. Do **not** mark a row **pass** without hardware notes for that row. - ---- - -## Community — Switch V1 / Erista boot — pass (boot) - -| Field | Value | -| ----- | ----- | -| Console | Nintendo Switch V1 (Erista) | -| Check | Prebuilt fused NRO boots under title override | -| Tester | [booshankles](https://github.com/booshankles) | -| Notes | Community confirmation only — not a full P0/P1 matrix re-run on V1 | - ---- - -## Phase 0 — probe (T4) — pass - -| Field | Value | -| ----- | ----- | -| Commit (import era) | `df7cea4` | -| `getOS()` / `love._os` | `NX` | -| Dimensions | 1280×720 | -| Save (probe) | `sdmc:/switch/gen1recomp/switch-probe` | -| Joy-Con | `joystickpressed` + `gamepadpressed` (Y→`#3`, X→`#4`) | - -| Artifact | SHA-256 | -| -------- | ------- | -| `gen1recomp.nro` | `8290ac153d4c630e48c9b26ef9123f5204ed8ee0cef3042511707b5b645918f5` | - ---- - -## T12 — Red import + Play — pass - -Inbox MTP → “Scan again” → Play; Joy-Con launcher/gameplay (not touch-only). - ---- - -# T16 — Joy-Con launcher + gameplay — pass (naming re-verify) - -### Round 1 @ `7504753` — partial - -| Check | Result | -| ----- | ------ | -| Launcher / overworld (Joy-Con only) | **pass** | -| Naming player/rival | **fail** (dual-path a+b; see below) | -| Touch required | **no** | -| `game.love` SHA-256 | `bd3a35461bf453c1f0465a5a289421aef3b5c72d3bf1f8d76e86231256829e0e` | - -### Naming failure (root cause) — fixed in `efd81d8` + `2699c9a` - -- love-nx fires **`gamepadpressed` + `joystickpressed` on the same physical press**. -- `NamingScreen` tested `wasPressed("b")` before `"a"` → if both true in one frame, always deletes. -- Dual-path fix: ignore raw when `isGamepad()` (`efd81d8`). -- SDL-only UX then had physical B confirm / A erase; NX face remap (`2699c9a`) restores Nintendo A=confirm / B=cancel. - -### Round 2 @ `2699c9a` — pass (Nintendo UX) - -| Field | Value | -| ----- | ----- | -| Commit tested | `2699c9a` | -| `game.love` SHA-256 | `a208b21e1f30b00e2e8c6fa6efe14f0e06d1db0ae1e50b810b16d9fb852926bc` | -| Touch required | **no** | - -| Check | Result | -| ----- | ------ | -| Naming — player | **pass** — physical **A** confirms letter, **B** cancels/erases | -| Naming — rival | **pass** (same) | -| Launcher / overworld (prior round) | **pass** (unchanged mapping for d-pad/stick) | - -T16 hardware gate: **closed**. - ---- - -## T19 — save / suspend — pass - -| Check | Result | -| ----- | ------ | -| Save in-game → full quit → title-override reopen → load save | **pass** (@ `7504753` / retained) | -| Suspend/resume ×10 (launcher / gameplay / mixed) | **pass** (operator 2026-08-01) | -| Full console reboot persistence | **pass** (operator 2026-08-01) | - -T19 hardware gate: **closed**. No stuck input, duplicate audio, or crash reported. - ---- - -## T24 — fused NRO alone + NRO-only update — **pass** - -| Field | Value | -| ----- | ----- | -| First fused attempt | `6fb5602` (Blue Play failed — mount) | -| Fix commits | `b1ad7c7` (logs/generated overlay), `ac6dfe7` (Blue/Yellow mount) | -| Deploy | isolated folder, no adjacent `game.love` | -| Boot fused | **pass** | -| ROM import | **pass** | -| Play **Red** | **pass** | -| Play **Blue** (after `ac6dfe7`) | **pass** (operator 2026-08-01) | -| NRO-only replace | **pass** — saves retained; app still boots/plays | -| Touch required | no | - -T24 hardware gate: **closed**. - ---- - -## SWBLD — `build_switch.sh --fetch --fused` + install path — **pass** - -Operator smoke for the switch-build-pipeline packaging CLI (closes matrix-deferred happy paths from validation). - -| Field | Value | -| ----- | ----- | -| Command | `scripts/build_switch.sh --fetch --fused --version 0.0.0-test` | -| Host | macOS + native switch-tools (or Docker fallback if used) | -| Commit / build-info | `9147a64` (`gitCommit` in build-info) | -| love-nx | `11.5-nx1` (manifest checksums match) | -| Artifact | `dist/switch/gen1recomp-0.0.0-test-switch.nro` | -| NRO SHA-256 | `210efb884a8d27443dc1c64ed8f071b0f862d8d0c9b140ad8185093c4e4027db` | -| Install doc | `docs/switch-install.md` — at the time of this row: copy NRO under `sdmc:/switch/gen1recomp/` (releases now ship an SD-ready zip; same folder) | -| Console | Switch OLED | -| Operator | Andrew | -| Date | 2026-08-01 | - -| Check | Result | -| ----- | ------ | -| `--fetch` + `--fused` produce NRO + `.sha256` | **pass** | -| Copy NRO to SD folder per install doc | **pass** (operator) | -| Title-override launch / play | treated as prior T24 path; this row records **packaging + deploy to folder** success | - -SWBLD packaging smoke: **closed** for Mac fused build + file-to-SD install step. - ---- - -## NXMOD-12 — Community mod zip OLED smoke — **pass** - -Closed from existing OLED photo evidence on issue -[#531](https://github.com/bryanthaboi/gen1recomp/issues/531) (operator comment -with launcher MODS + overworld shots). Photos live on the orphan branch -[`switch-oled-photos`](https://github.com/andrewqsantos/gen1recomp/tree/switch-oled-photos) -of the operator fork — **not** committed to this repo. Do **not** commit -third-party mod `.zip` bytes. Community mods own their OPTIONS / rebinds; -this entry only proves the MODS inbox + Play path on OLED. - -| Field | Value | -| ----- | ----- | -| Status | **pass** | -| gen1recomp commit | evidence era on `feat/switch-nx` (see #531); packaging pin love-nx `11.5-nx1` | -| love-nx tag | `11.5-nx1` | -| Console | Switch OLED | -| Mod | community release `.zip` (not vendored; not named here) | -| Zip committed to git? | **no** | -| Photo evidence | [#531 comment](https://github.com/bryanthaboi/gen1recomp/issues/531) — MODS tab + overworld | -| MODS tab photo | https://raw.githubusercontent.com/andrewqsantos/gen1recomp/switch-oled-photos/IMG_1766.jpg | -| Overworld photo | https://raw.githubusercontent.com/andrewqsantos/gen1recomp/switch-oled-photos/IMG_1771.jpg | -| Operator | Andrew | -| Date | 2026-08-01 | - -### Checklist - -| Step | Pass / fail / pending | Notes | -| ---- | --------------------- | ----- | -| MTP zip into save `imports/mods/` | **pass** | Photo evidence + prior inbox path | -| MODS → Scan again → mod listed | **pass** | IMG_1766 — community mod installed | -| Enable mod + Play Red boots without crash | **pass** | Overworld / Pallet / Oak lab photos on #531 | -| Overworld Select+A → visible colors change | **pass** | Stock COLORS chord path exercised | -| Overworld Select+B → visible tilt/perspective change | **pass** | Stock TILT chord path exercised (IMG_1771) | - -### Evidence notes - -```text -Operator: Andrew -Date: 2026-08-01 -Commit tested: feat/switch-nx era documented on issue #531 -Pass / fail summary: PASS — MODS zip install + Play on Switch OLED -Photo branch: andrewqsantos/gen1recomp@switch-oled-photos -``` diff --git a/docs/switch-install.md b/docs/switch-install.md index e173dfc2..2a54aa06 100644 --- a/docs/switch-install.md +++ b/docs/switch-install.md @@ -2,15 +2,11 @@ Every GitHub Release that includes Switch support ships an SD-ready zip: `gen1recomp-*-switch.zip`. Extract it at the root of your microSD (install -**or** update — same steps), launch with **title override**, then import your +or update, same steps), launch with **title override**, then import your own legal `.gb` ROM. > You need a console that can run Switch homebrew (custom firmware / hbmenu). -> This project does not help you set that up. Tracks issue -> [#531](https://github.com/bryanthaboi/gen1recomp/issues/531). -> Hardware: **OLED** validated by the porter; **V1 / Erista** boot confirmed -> by the community. Lite and other setups welcome more reports. -> See [switch-development.md](switch-development.md) for limitations. +> This project does not help you set that up. Prefer building from source? See [switch-build.md](switch-build.md). @@ -29,12 +25,18 @@ help from [booshankles](https://github.com/booshankles). Extract the zip at the **root** of the microSD so you get: ```text -sdmc:/switch/gen1recomp/gen1recomp.nro +sdmc:/switch/gen1recomp/gen1recomp.nro # native OTA launcher (hbmenu entry) +sdmc:/switch/gen1recomp/gen1recomp-game.nro # fused LÖVE game +sdmc:/switch/gen1recomp/version.txt sdmc:/switch/gen1recomp/pokemon-love2d/imports/ sdmc:/switch/gen1recomp/pokemon-love2d/imports/mods/ sdmc:/switch/gen1recomp/pokemon-love2d/imports/saves/... ``` +Older single-NRO zips only had `gen1recomp.nro` (the fused game). Current +releases use the dual-NRO layout above. Open `gen1recomp` in hbmenu (the +launcher). + Merge folders if your OS asks. Any method works: **MTP** (DBI → Run MTP responder + a client), **direct SD** (Hekate UMS or a card reader), or **FTP**. Exit MTP / unmount / stop FTP cleanly before launching. Step-by-step for @@ -42,17 +44,44 @@ macOS, Linux, and Windows: [switch-transfer.md](switch-transfer.md). ### Updating -Use the **same** extract/merge. It replaces `gen1recomp.nro` (and the small -help `README.txt` / `INSTALL.txt` files). Saves, imported ROMs, mods, and -options live under `pokemon-love2d/` — **do not delete that folder** when -updating, or you will lose progress. +#### Native OTA launcher (in-console) + +Switch OTA runs in a separate **native launcher NRO** (libnx + curl), not the +LÖVE self-updater (`src/update/Check.lua`). hbmenu opens `gen1recomp.nro`. + +When a newer release exists, the launcher downloads the same install zip +(`gen1recomp-*-switch.zip`), checks SHA-256 against `sha256sums.txt`, replaces +both `gen1recomp-game.nro` and `gen1recomp.nro` (keeps NACP version in sync +for hbmenu and Sphaira), then loads the game with `envSetNextLoad`. + +If you are up to date or offline, it skips straight to the game with no +prompt. If an update is available, you get a short prompt styled like the +in-game launcher: black background, RGB rail, logo, A/B buttons. Saves under +`pokemon-love2d/` are not touched. See `src/update/SwitchOta.lua` for the +wire format. + +The LÖVE self-updater stays **disabled** on NX (`networkValidated == false`). + +**Sphaira forwarder (HOME shortcut):** Sphaira copies name/version/icon into +the installed forwarder at creation time. After an OTA (or zip) update, the +`.nro` on the microSD already has the new version, but the HOME shortcut +keeps the old badge until you **reinstall the forwarder once** in Sphaira +(Install Forwarder again on `gen1recomp.nro`). Browsing the NRO in Sphaira / +hbmenu always shows the live file version. + +#### Manual zip (fallback) + +Use the **same** extract/merge of `gen1recomp-*-switch.zip`. It replaces the +NROs (and the small help `README.txt` / `INSTALL.txt` files). Saves, +imported ROMs, mods, and options live under `pokemon-love2d/`. **Do not +delete that folder** when updating, or you will lose progress. ## 3. Launch with title override **Applet Mode is not supported** for this game (not enough memory). 1. On the Switch HOME menu, highlight any installed title. -2. Hold **R** and launch that title — this opens hbmenu with full memory +2. Hold **R** and launch that title. This opens hbmenu with full memory (title override). 3. From hbmenu, open `gen1recomp`. @@ -66,14 +95,14 @@ This project ships **no** game data. On first launch: (`.gbc`) dump into `switch/gen1recomp/pokemon-love2d/imports/` (the launcher also shows the live save-dir path). All three can sit in the same folder. -2. Use **Scan again** on that game’s tab (Red / Blue / Yellow). Rescan - matches by ROM SHA-1 for the open tab only — a Red dump never imports +2. Use **Scan again** on that game's tab (Red / Blue / Yellow). Rescan + matches by ROM SHA-1 for the open tab only. A Red dump never imports from the Yellow tab (and vice versa). ## 5. Import / Export a raw `.sav` Continue a cart or PC battery save (or pull a slot off-console) via MTP / -SD / FTP — same transfer methods as ROMs. Paths are **per game**: +SD / FTP, same transfer methods as ROMs. Paths are **per game**: | Game | Import inbox | Export folder | | ---- | ------------ | ------------- | @@ -81,19 +110,19 @@ SD / FTP — same transfer methods as ROMs. Paths are **per game**: | Blue | `imports/saves/blue/` | `exports/blue/` | | Yellow | `imports/saves/yellow/` | `exports/yellow/` | -(Under the save dir `pokemon-love2d/` — the zip already creates these folders.) +(Under the save dir `pokemon-love2d/`. The zip already creates these folders.) -1. Copy a Gen1 `.sav` (32 KB) into that game’s inbox under the save dir +1. Copy a Gen1 `.sav` (32 KB) into that game's inbox under the save dir ([switch-transfer.md](switch-transfer.md)). -2. With the game’s ROM already imported, open **that game’s tab** → +2. With the game's ROM already imported, open **that game's tab** → **SAVE FILES** → **Import save**. Only that folder is scanned. 3. A successful import retires the file to `*.sav.imported` and records its content hash so pressing **Import save** again does not clone slots. Failed imports leave the original `.sav` in place. 4. To pull a slot off the console, use **Export save**, then copy the file - from that game’s **`exports//`** folder via MTP / SD / FTP. + from that game's **`exports//`** folder via MTP / SD / FTP. -Do not put `.sav` files into git. Prefer clean copies — some MTP clients +Do not put `.sav` files into git. Prefer clean copies. Some MTP clients create `._*.sav` AppleDouble sidecars that are not real saves. ## Controls @@ -130,12 +159,12 @@ create `._*.sav` AppleDouble sidecars that are not real saves. Mods install from a zip inbox (same transfer methods as ROMs): 1. Copy a release `.zip` into the save-dir **`imports/mods/`** path the - launcher shows (MTP / SD / FTP — [switch-transfer.md](switch-transfer.md)). + launcher shows (MTP / SD / FTP. See [switch-transfer.md](switch-transfer.md)). 2. In the launcher, open **MODS** → **Scan again** → enable the mod → **Play**. Remote **FIND MODS** / GitHub download stays **off** on Switch. Do not put -mod zips into git. Community mods ship their own OPTIONS / rebinds — this port +mod zips into git. Community mods ship their own OPTIONS / rebinds. This port does not document third-party control tables. ### Joy-Con shortcuts (Select + face) @@ -153,12 +182,23 @@ hotkeys (`2`/`3`/`5` are claimed before any mod pipeline hotkey runs). | Select + **L** | `7` | Mod pipeline hotkey (if a mod registers `7`) | If the handheld stutters with extras on, try **OPTIONS → PERFORMANCE** → -`LOW` or `BALANCED`. Full chord notes for contributors: -[switch-development.md](switch-development.md#joy-con-display-chords-select--face). +`LOW` or `BALANCED`. + +## Limitations + +- You need homebrew (custom firmware, hbmenu). This project does not set that + up. +- Launch with title override (hold **R** on a title). Applet Mode (Album) is + not supported. The game needs full memory. +- ROMs, mods, and saves are copied manually via MTP, direct SD, or FTP. There + is no automated deploy. +- Updates use the native OTA launcher only. The LÖVE self-updater and remote + **FIND MODS** stay off on Switch. +- Tested on Switch OLED. Switch V1 / Erista boot confirmed by the community. + Other models may work but are less tested. ## Prefer building it yourself? Building the fused NRO (and SD-ready zip) from source is covered in [switch-build.md](switch-build.md). Copying artifacts and inbox files (MTP / SD / FTP on macOS, Linux, Windows): [switch-transfer.md](switch-transfer.md). -Status, limitations, and how we tested: [switch-development.md](switch-development.md). diff --git a/docs/switch-transfer.md b/docs/switch-transfer.md index f7cd153b..1e53de2f 100644 --- a/docs/switch-transfer.md +++ b/docs/switch-transfer.md @@ -1,17 +1,16 @@ # Switch file transfer (MTP / SD / FTP) -Canonical ways to put Gen1Recomp artifacts and inbox files onto a Nintendo -Switch. **Any method is valid** if the bytes land in the destinations below. +Ways to put Gen1Recomp artifacts and inbox files onto a Nintendo Switch. +**Any method is valid** if the bytes land in the destinations below. -This is the home runbook for contributors on **macOS, Linux, and Windows**. +This is the runbook for contributors on **macOS, Linux, and Windows**. Player install (what to download, title override) stays in [switch-install.md](switch-install.md). Packaging stays in -[switch-build.md](switch-build.md). Hardware evidence lives in -[switch-hardware-evidence.md](switch-hardware-evidence.md). +[switch-build.md](switch-build.md). > **Not supported yet:** `nxlink` / hbmenu netloader automation. Useful later -> for a fast contrib rebuild loop; deferred on purpose (AD-009). Do not treat -> netloader as the release or ROM/mod install path. +> for a fast contrib rebuild loop; deferred (AD-009). Do not treat netloader as +> the release or ROM/mod install path. --- @@ -23,7 +22,7 @@ Player install (what to download, title override) stays in | Loose iteration pair | `sdmc:/switch/gen1recomp/gen1recomp.nro` **and** `game.love` beside it | | ROM inbox | LÖVE save dir → `imports/` (launcher shows the live `getSaveDirectory()` path; under MTP often `1: SD Card//imports/`) | | Mod zip inbox | Same save dir → `imports/mods/` then MODS → **Scan again** | -| Save `.sav` inbox | Same save dir → `imports/saves/red\|blue\|yellow/` then that game’s SAVE FILES → **Import save** | +| Save `.sav` inbox | Same save dir → `imports/saves/red\|blue\|yellow/` then that game's SAVE FILES → **Import save** | | Save exports | Same save dir → `exports/red\|blue\|yellow/` (pull after **Export save**; MTP / SD / FTP) | | Opt-in diagnostics | Empty `switch-debug.txt` in the save dir → `switch.log` | | Lua error log | `lua-error.log` in the save dir | @@ -34,7 +33,7 @@ files, or third-party mod zips to git. --- -## Canonical methods +## Transfer methods ### 1. MTP (DBI responder + host client) @@ -47,8 +46,8 @@ before launching. #### macOS (example: OpenMTP) -[OpenMTP](https://github.com/ganeshrvel/openmtp) is the loop used for OLED -hardware evidence — **one contributor example**, not a Mac-only product rule. +[OpenMTP](https://github.com/ganeshrvel/openmtp) is a documented example for +macOS, not a Mac-only requirement. 1. Quit other MTP clients. 2. Open OpenMTP → select the DBI device → **`1: SD Card`**. @@ -60,25 +59,25 @@ hardware evidence — **one contributor example**, not a Mac-only product rule. 5. Wait for the queue; refresh; exit MTP responder; title-override launch. macOS clients often create AppleDouble sidecars (`._Something.zip`, -`._cart.gb`, `._foo.sav`). Those are not real archives or saves — the +`._cart.gb`, `._foo.sav`). Those are not real archives or saves. The launcher skips hidden `.*` names. Delete `._*` junk if a zip/ROM/`.sav` fails to open. #### Linux 1. Install desktop MTP support if needed (e.g. `gvfs-mtp` on GNOME/GTK - desktops, or your distro’s KDE MTP stack). + desktops, or your distro's KDE MTP stack). 2. With DBI MTP active, open **Files** / **Dolphin** / **Thunar** and select the Switch / DBI device → **`1: SD Card`**. 3. Extract the release zip at SD root (merge), or copy into `switch/gen1recomp/` and the save-dir inboxes as above. 4. Use **only one** MTP accessor at a time. If `mtp-tools` / `mtpfs` reports - “device is busy”, close the file manager’s MTP mount (or the CLI mount) + "device is busy", close the file manager's MTP mount (or the CLI mount) and retry with a single client. 5. Eject/unmount cleanly; exit MTP on the Switch; title-override launch. If MTP is unavailable or flaky on Linux, use **direct SD** (Hekate UMS or a -card reader) or **FTP** instead — same destinations in the table above. +card reader) or **FTP** instead. Same destinations in the table above. #### Windows @@ -93,7 +92,7 @@ card reader) or **FTP** instead — same destinations in the table above. 5. Safely disconnect; exit MTP on the Switch; title-override launch. If MTP is unavailable or flaky on Windows, use **direct SD** (Hekate UMS or a -card reader) or **FTP** instead — same destinations in the table above. +card reader) or **FTP** instead. Same destinations in the table above. ### 2. Direct SD (Hekate UMS or card reader) @@ -109,9 +108,9 @@ Do not yank the card or unplug UMS mid-write. ### 3. FTP (any SD-exposing Switch FTP) -Any homebrew FTP server that can write the microSD is fine — for example -**DBI’s own FTP**, **sys-ftpd-light**, or **Sphaira** (names are illustrations -only; pick what your CFW setup already uses). +Any homebrew FTP server that can write the microSD is fine. For example +**DBI's own FTP**, **sys-ftpd-light**, or **Sphaira**. Names are illustrations +only; pick what your CFW setup already uses. 1. Start the FTP server on the Switch; note IP/port/credentials from that app. 2. From the host, connect with any FTP client and upload to the same @@ -154,7 +153,7 @@ Copy the file back from the SD and compare hashes. Round-trip must match. | Symptom | What to try | | ------- | ----------- | | Device busy / no MTP volume | One client only; different cable/port; Windows MTP USB Device driver; alternate method (SD or FTP) | -| Zip/ROM/`.sav` “could not be opened” | Delete `._*` sidecars (including `._*.sav`); confirm real zip starts with `PK` | +| Zip/ROM/`.sav` "could not be opened" | Delete `._*` sidecars (including `._*.sav`); confirm real zip starts with `PK` | | Half-copied NRO / crash on boot | Re-copy; verify SHA-256; exit transfer mode before launch | | App opens in Applet Mode | Use title override (hold **R**), not Album | @@ -164,5 +163,3 @@ Copy the file back from the SD and compare hashes. Round-trip must match. - Players: [switch-install.md](switch-install.md) - Builders: [switch-build.md](switch-build.md) -- Status / hardware matrix: [switch-development.md](switch-development.md) -- Evidence log: [switch-hardware-evidence.md](switch-hardware-evidence.md) diff --git a/docs/updater.md b/docs/updater.md index b0de76c8..c27615a3 100644 --- a/docs/updater.md +++ b/docs/updater.md @@ -124,3 +124,8 @@ bundled game, in that case. `love.filesystem.isFused()` is false, and a working tree's `engine` is the `"0.0.0-dev"` placeholder that always reports up to date, so a source checkout is always "the game" itself; updating it means pulling the repo. +- **Nintendo Switch does not use this LÖVE self-updater.** On NX, + `Platform.networkValidated()` is `false`, so `Boot.run` / `Check` never + download `.love` payloads. In-console OTA uses the **native OTA launcher** + (DEVKITPRO), documented in [switch-install.md](switch-install.md). Wire + format: `src/update/SwitchOta.lua`. Manual zip install remains the fallback. diff --git a/assets/switch/icon.jpg b/ports/switch/assets/icon.jpg similarity index 100% rename from assets/switch/icon.jpg rename to ports/switch/assets/icon.jpg diff --git a/ports/switch/assets/logo.rgba b/ports/switch/assets/logo.rgba new file mode 100644 index 00000000..cb97707a Binary files /dev/null and b/ports/switch/assets/logo.rgba differ diff --git a/ports/switch/ota-bootstrap/Makefile b/ports/switch/ota-bootstrap/Makefile new file mode 100644 index 00000000..4920b3c3 --- /dev/null +++ b/ports/switch/ota-bootstrap/Makefile @@ -0,0 +1,72 @@ +#--------------------------------------------------------------------------------- +# One-shot OTA bootstrap NRO (copied into the main launcher's romfs). +#--------------------------------------------------------------------------------- +.SUFFIXES: + +ifeq ($(strip $(DEVKITPRO)),) + +.PHONY: all +all: + @echo "DEVKITPRO not set — bootstrap builds with the launcher only." + @false + +else + +TOPDIR ?= $(CURDIR) +include $(DEVKITPRO)/libnx/switch_rules + +TARGET := ota-bootstrap +BUILD := build +SOURCES := . +INCLUDES := include +ROMFS := + +APP_TITLE := gen1recomp OTA +APP_AUTHOR := bryanthaboi, port by andrewqsantos +APP_VERSION := 0.0.0 + +ARCH := -march=armv8-a+crc+crypto -mtune=cortex-a57 -mtp=soft -fPIE +CFLAGS := -g -Wall -O2 -ffunction-sections $(ARCH) $(DEFINES) +CFLAGS += $(INCLUDE) -D__SWITCH__ +ASFLAGS := -g $(ARCH) +LDFLAGS = -specs=$(DEVKITPRO)/libnx/switch.specs -g $(ARCH) -Wl,-Map,$(notdir $*.map) +LIBS := -lnx +LIBDIRS := $(LIBNX) + +ifneq ($(BUILD),$(notdir $(CURDIR))) +export OUTPUT := $(CURDIR)/$(TARGET) +export TOPDIR := $(CURDIR) +export VPATH := $(foreach dir,$(SOURCES),$(CURDIR)/$(dir)) +export DEPSDIR := $(CURDIR)/$(BUILD) +CFILES := $(foreach dir,$(SOURCES),$(notdir $(wildcard $(dir)/*.c))) +export LD := $(CC) +export OFILES := $(CFILES:.c=.o) +export INCLUDE := $(foreach dir,$(INCLUDES),-I$(CURDIR)/$(dir)) \ + $(foreach dir,$(LIBDIRS),-I$(dir)/include) \ + -I$(CURDIR)/$(BUILD) +export LIBPATHS := $(foreach dir,$(LIBDIRS),-L$(dir)/lib) +export APP_ICON := $(LIBNX)/default_icon.jpg +export NROFLAGS += --icon=$(APP_ICON) +export NROFLAGS += --nacp=$(CURDIR)/$(TARGET).nacp + +.PHONY: $(BUILD) clean all + +all: $(BUILD) + +$(BUILD): + @[ -d $@ ] || mkdir -p $@ + @$(MAKE) --no-print-directory -C $(BUILD) -f $(CURDIR)/Makefile + +clean: + @echo clean ... + @rm -fr $(BUILD) $(TARGET).nro $(TARGET).nacp $(TARGET).elf + +else +.PHONY: all +DEPENDS := $(OFILES:.o=.d) +all: $(OUTPUT).nro +$(OUTPUT).nro: $(OUTPUT).elf $(OUTPUT).nacp +$(OUTPUT).elf: $(OFILES) +-include $(DEPENDS) +endif +endif diff --git a/ports/switch/ota-bootstrap/main.c b/ports/switch/ota-bootstrap/main.c new file mode 100644 index 00000000..0aec9352 --- /dev/null +++ b/ports/switch/ota-bootstrap/main.c @@ -0,0 +1,36 @@ +/* + * Tiny one-shot helper: swap a staged launcher NRO into place, then load the game. + * The main OTA launcher chainloads here because a running NRO cannot replace itself + * on sdmc/FAT. + */ +#include +#include +#include + +#if defined(__SWITCH__) +#include +#endif + +#define INSTALL_DIR "sdmc:/switch/gen1recomp" +#define STAGED INSTALL_DIR "/gen1recomp.nro.staged" +#define LAUNCHER INSTALL_DIR "/gen1recomp.nro" +#define GAME INSTALL_DIR "/gen1recomp-game.nro" + +int main(int argc, char **argv) { + (void)argc; + (void)argv; + +#if defined(__SWITCH__) + remove(LAUNCHER); + if (rename(STAGED, LAUNCHER) != 0) { + return 1; + } + Result rc = envSetNextLoad(GAME, GAME); + if (R_FAILED(rc)) return 1; + return 0; +#else + fprintf(stderr, "ota-bootstrap: host stub (would rename %s -> %s, load %s)\n", STAGED, LAUNCHER, + GAME); + return 0; +#endif +} diff --git a/ports/switch/ota-launcher/Makefile b/ports/switch/ota-launcher/Makefile new file mode 100644 index 00000000..2f2c710d --- /dev/null +++ b/ports/switch/ota-launcher/Makefile @@ -0,0 +1,193 @@ +#--------------------------------------------------------------------------------- +# gen1recomp Switch native OTA launcher +# Requires: DEVKITPRO + switch-curl, switch-mbedtls, switch-zlib, switch-zziplib +# Host protocol tests: make host-test (no DEVKITPRO needed) +#--------------------------------------------------------------------------------- +.SUFFIXES: + +#--------------------------------------------------------------------------------- +# Host-only path when DEVKITPRO is unset +#--------------------------------------------------------------------------------- +ifeq ($(strip $(DEVKITPRO)),) + +.PHONY: host-test clean-host +host-test: + @mkdir -p build-host + cc -std=c11 -Wall -Wextra -Iinclude -o build-host/test_ota_protocol \ + src/ota_protocol.c host/test_ota_protocol.c + ./build-host/test_ota_protocol + +clean-host: + rm -rf build-host + +%: + @echo "DEVKITPRO not set — only 'make host-test' is available." + @echo "Run: bash scripts/switch/install_devkitpro_deps.sh" + @false + +else + +#--------------------------------------------------------------------------------- +TOPDIR ?= $(CURDIR) +include $(DEVKITPRO)/libnx/switch_rules + +#--------------------------------------------------------------------------------- +TARGET := gen1recomp +BUILD := build +SOURCES := src +DATA := data +INCLUDES := include +ROMFS := romfs + +APP_TITLE := gen1recomp +APP_AUTHOR := bryanthaboi, port by andrewqsantos +# Overridable: scripts/switch/build_ota_launcher.sh passes release X.Y.Z +APP_VERSION ?= 0.0.0 + +# Prefer project Switch icon if present +ICON := $(TOPDIR)/../assets/icon.jpg +LOGO_RGBA_SRC := $(TOPDIR)/../assets/logo.rgba +LOGO_ROMFS := $(CURDIR)/$(ROMFS)/logo.rgba + +#--------------------------------------------------------------------------------- +ARCH := -march=armv8-a+crc+crypto -mtune=cortex-a57 -mtp=soft -fPIE + +CFLAGS := -g -Wall -O2 -ffunction-sections $(ARCH) $(DEFINES) +CFLAGS += $(INCLUDE) -D__SWITCH__ + +CXXFLAGS := $(CFLAGS) -fno-rtti -fno-exceptions + +ASFLAGS := -g $(ARCH) +LDFLAGS = -specs=$(DEVKITPRO)/libnx/switch.specs -g $(ARCH) -Wl,-Map,$(notdir $*.map) + +LIBS := -lcurl -lzzip -lmbedtls -lmbedx509 -lmbedcrypto -lz -lnx + +LIBDIRS := $(PORTLIBS) $(LIBNX) + +#--------------------------------------------------------------------------------- +ifneq ($(BUILD),$(notdir $(CURDIR))) +#--------------------------------------------------------------------------------- + +export OUTPUT := $(CURDIR)/$(TARGET) +export TOPDIR := $(CURDIR) + +export VPATH := $(foreach dir,$(SOURCES),$(CURDIR)/$(dir)) \ + $(foreach dir,$(DATA),$(CURDIR)/$(dir)) + +export DEPSDIR := $(CURDIR)/$(BUILD) + +CFILES := $(foreach dir,$(SOURCES),$(notdir $(wildcard $(dir)/*.c))) +CPPFILES := $(foreach dir,$(SOURCES),$(notdir $(wildcard $(dir)/*.cpp))) +SFILES := $(foreach dir,$(SOURCES),$(notdir $(wildcard $(dir)/*.s))) +BINFILES := $(foreach dir,$(DATA),$(notdir $(wildcard $(dir)/*.*))) + +#--------------------------------------------------------------------------------- +# use CXX for linking C++ projects, CC for standard C +#--------------------------------------------------------------------------------- +ifeq ($(strip $(CPPFILES)),) + export LD := $(CC) +else + export LD := $(CXX) +endif +#--------------------------------------------------------------------------------- + +export OFILES_BIN := $(addsuffix .o,$(BINFILES)) +export OFILES_SRC := $(CPPFILES:.cpp=.o) $(CFILES:.c=.o) $(SFILES:.s=.o) +export OFILES := $(OFILES_BIN) $(OFILES_SRC) + +export INCLUDE := $(foreach dir,$(INCLUDES),-I$(CURDIR)/$(dir)) \ + $(foreach dir,$(LIBDIRS),-I$(dir)/include) \ + -I$(CURDIR)/$(BUILD) + +export LIBPATHS := $(foreach dir,$(LIBDIRS),-L$(dir)/lib) + +ifeq ($(strip $(ICON)),) + icons := $(wildcard *.jpg) + ifneq (,$(findstring $(TARGET).jpg,$(icons))) + export APP_ICON := $(TOPDIR)/$(TARGET).jpg + else + ifneq (,$(findstring icon.jpg,$(icons))) + export APP_ICON := $(TOPDIR)/icon.jpg + endif + endif +else + ifeq ($(wildcard $(ICON)),) + # fall back to libnx default if project icon missing + export APP_ICON := $(LIBNX)/default_icon.jpg + else + export APP_ICON := $(ICON) + endif +endif + +ifeq ($(strip $(NO_ICON)),) + export NROFLAGS += --icon=$(APP_ICON) +endif + +ifeq ($(strip $(NO_NACP)),) + export NROFLAGS += --nacp=$(CURDIR)/$(TARGET).nacp +endif + +ifneq ($(ROMFS),) + export NROFLAGS += --romfsdir=$(CURDIR)/$(ROMFS) +endif + +BOOTSTRAP_DIR := $(TOPDIR)/../ota-bootstrap +BOOTSTRAP_ROMFS := $(CURDIR)/$(ROMFS)/ota-bootstrap.nro + +.PHONY: $(BUILD) clean all host-test sync-romfs bootstrap-romfs + +#--------------------------------------------------------------------------------- +all: sync-romfs $(BUILD) + +CACERT_URL := https://curl.se/ca/cacert.pem +CACERT_ROMFS := $(CURDIR)/$(ROMFS)/cacert.pem + +bootstrap-romfs: + @$(MAKE) --no-print-directory -C $(BOOTSTRAP_DIR) all + @mkdir -p $(CURDIR)/$(ROMFS) + @cp -f $(BOOTSTRAP_DIR)/ota-bootstrap.nro $(BOOTSTRAP_ROMFS) + +sync-romfs: bootstrap-romfs + @mkdir -p $(CURDIR)/$(ROMFS) + @[ -f "$(LOGO_RGBA_SRC)" ] || (echo "missing $(LOGO_RGBA_SRC) — run: python3 scripts/switch/bake_ota_logo.py" && exit 1) + @cp -f "$(LOGO_RGBA_SRC)" "$(LOGO_ROMFS)" + @if ! curl -sfL --time-cond $(CACERT_ROMFS) -o $(CACERT_ROMFS) $(CACERT_URL); then \ + [ -f $(CACERT_ROMFS) ] || (echo "sync-romfs: failed to fetch cacert.pem" && exit 1); \ + fi + +$(BUILD): sync-romfs + @[ -d $@ ] || mkdir -p $@ + @$(MAKE) --no-print-directory -C $(BUILD) -f $(CURDIR)/Makefile + +#--------------------------------------------------------------------------------- +clean: + @echo clean ... + @$(MAKE) --no-print-directory -C $(BOOTSTRAP_DIR) clean || true + @rm -fr $(BUILD) $(TARGET).nro $(TARGET).nacp $(TARGET).elf $(TARGET).lst build-host + @rm -f $(CURDIR)/$(ROMFS)/logo.rgba $(CURDIR)/$(ROMFS)/logo.png + +host-test: + @mkdir -p build-host + cc -std=c11 -Wall -Wextra -Iinclude -o build-host/test_ota_protocol \ + src/ota_protocol.c host/test_ota_protocol.c + ./build-host/test_ota_protocol + +#--------------------------------------------------------------------------------- +else +.PHONY: all + +DEPENDS := $(OFILES:.o=.d) + +#--------------------------------------------------------------------------------- +all: $(OUTPUT).nro + +$(OUTPUT).nro: $(OUTPUT).elf $(OUTPUT).nacp + +$(OUTPUT).elf: $(OFILES) + +-include $(DEPENDS) + +#--------------------------------------------------------------------------------- +endif +#--------------------------------------------------------------------------------- +endif diff --git a/ports/switch/ota-launcher/README.md b/ports/switch/ota-launcher/README.md new file mode 100644 index 00000000..f6865955 --- /dev/null +++ b/ports/switch/ota-launcher/README.md @@ -0,0 +1,89 @@ +# Native Switch OTA launcher + +In-console updates for Gen1Recomp on Nintendo Switch. This NRO is the **hbmenu +entry** (`gen1recomp.nro`). It checks GitHub Releases quietly (no UI when you +are already up to date or offline). Only if a newer release exists does it show +a **launcher-style screen** (black + RGB rail + project logo + flat A/B +buttons), download the same `gen1recomp-*-switch.zip` used for install, verify +SHA-256 from `sha256sums.txt`, replace **both** `gen1recomp-game.nro` and +`gen1recomp.nro` (matching NACP version for hbmenu/Sphaira), then load the game +with `envSetNextLoad`. + +The LÖVE self-updater (`src/update/Check.lua`) stays **disabled** on NX. +Wire format (also in Lua): `src/update/SwitchOta.lua`. +NACP icon: `ports/switch/assets/icon.jpg`. + +## Layout on microSD + +```text +sdmc:/switch/gen1recomp/gen1recomp.nro <- this launcher +sdmc:/switch/gen1recomp/gen1recomp-game.nro <- fused LÖVE game +sdmc:/switch/gen1recomp/version.txt <- installed X.Y.Z +sdmc:/switch/gen1recomp/pokemon-love2d/ <- saves (never touched by OTA) +``` + +## Host tests (no DEVKITPRO) + +```bash +cd ports/switch/ota-launcher +make host-test +``` + +## Switch build (DEVKITPRO) + +Needs `DEVKITPRO` with packages roughly: + +```bash +(dkp-)pacman -S --needed switch-curl switch-mbedtls switch-zlib switch-zziplib +# or: bash scripts/switch/install_devkitpro_deps.sh +``` + +```bash +export DEVKITPRO=/opt/devkitpro # typical +cd ports/switch/ota-launcher +make +# -> gen1recomp.nro +``` + +Or from repo root (as part of `--fused`): + +```bash +scripts/build_switch.sh --fetch --fused --version X.Y.Z +``` + +Standalone launcher build: + +```bash +scripts/switch/build_ota_launcher.sh +``` + +Docker fallback uses the same pin as fused builds (`scripts/switch/dkp-docker.image`). + +## OTA logo asset + +The launcher draws a pre-scaled logo from `romfs:/logo.rgba` (no PNG decoder in +the NRO). The baked blob lives at `../assets/logo.rgba` and is copied into romfs at +build time. After changing `assets/logo/logo.png`, regenerate: + +```bash +python3 scripts/switch/bake_ota_logo.py +``` + +Requires Pillow, or on macOS uses `sips` when Pillow is not installed. + +## Packaging + +`scripts/switch/pack_sd_zip.sh GAME_NRO VERSION OUT_ZIP LAUNCHER_NRO` writes both +NROs into the SD zip. That zip is also what OTA downloads. +Manifest: `scripts/switch/ota_launcher.manifest`. + +## Status / known gaps + +- Zip extraction uses `switch-zziplib` (`ota_unzip.c`) on device. +- OTA replaces game + launcher from the install zip (NACP versions stay aligned). + The running launcher cannot overwrite its own NRO on sdmc/FAT; a tiny + `ota-bootstrap.nro` (embedded in romfs) chainloads once to swap the staged + launcher, then loads the game. +- HTTPS uses Mozilla CA bundle in romfs (`cacert.pem`, fetched at build time). `ota_net_init()` mounts romfs before the quiet release check. +- Sphaira HOME forwarders cache metadata until reinstalled (see docs/switch-install.md). +- Release runner: `switch-dev` + (`install_devkitpro_deps.sh` **or** Docker) diff --git a/ports/switch/ota-launcher/host/test_ota_protocol.c b/ports/switch/ota-launcher/host/test_ota_protocol.c new file mode 100644 index 00000000..2e432f2a --- /dev/null +++ b/ports/switch/ota-launcher/host/test_ota_protocol.c @@ -0,0 +1,144 @@ +/* Host-only unit tests for ota_protocol.c — no libnx. */ +#include "ota_protocol.h" + +#include +#include + +static int g_fail = 0; + +static void expect(int cond, const char *msg) { + if (!cond) { + fprintf(stderr, "FAIL: %s\n", msg); + g_fail++; + } else { + printf("PASS: %s\n", msg); + } +} + +int main(void) { + expect(ota_compare_semver("1.2.0", "1.1.0") == 1, "semver newer"); + expect(ota_compare_semver("1.1.0", "1.1.0") == 0, "semver equal"); + expect(ota_compare_semver("1.0.0", "1.1.0") == -1, "semver older"); + expect(ota_is_ota_asset_name("gen1recomp-1.5.0-switch.zip"), "ota asset name"); + expect(!ota_is_ota_asset_name("gen1recomp-1.5.0-switch-ota.zip"), "reject legacy ota zip name"); + expect(!ota_is_ota_asset_name("gen1recomp-1.5.0.love"), "reject love payload"); + + const char *json = + "{" + "\"tag_name\":\"v1.5.0\"," + "\"assets\":[" + "{\"name\":\"gen1recomp-1.5.0-switch.zip\"," + "\"browser_download_url\":\"https://example/switch.zip\"}," + "{\"name\":\"sha256sums.txt\",\"browser_download_url\":\"https://example/sums\"}" + "]" + "}"; + ota_release_t rel; + expect(ota_parse_release(json, &rel) == 1, "parse release"); + expect(strcmp(rel.version, "1.5.0") == 0, "release version"); + expect(strcmp(rel.asset_name, "gen1recomp-1.5.0-switch.zip") == 0, "release asset"); + + ota_decision_t d; + ota_decide_update("1.4.0", &rel, &d); + expect(strcmp(d.status, "available") == 0, "decide available"); + ota_decide_update("1.5.0", &rel, &d); + expect(strcmp(d.status, "uptodate") == 0, "decide uptodate"); + + ota_release_t bad; + expect(ota_parse_release("{\"tag_name\":\"v1.5.0\",\"assets\":[]}", &bad) == 0, + "missing ota asset"); + expect(strcmp(bad.reason, "missing_ota_asset") == 0, "missing_ota_asset reason"); + + /* GitHub releases/latest shape: release-level name + fat uploader before browser_download_url. */ + const char *github_json = + "{" + "\"tag_name\":\"v0.1.70\"," + "\"name\":\"0.1.70\"," + "\"assets\":[" + "{" + "\"url\":\"https://api.github.com/repos/bryanthaboi/gen1recomp/releases/assets/502823880\"," + "\"id\":502823880," + "\"name\":\"gen1recomp-0.1.70-switch.zip\"," + "\"label\":\"\"," + "\"uploader\":{" + "\"login\":\"github-actions[bot]\"," + "\"id\":41898282," + "\"node_id\":\"MDM6Qm90NDE4OTgyODI=\"," + "\"avatar_url\":\"https://avatars.githubusercontent.com/in/15368?v=4\"," + "\"gravatar_id\":\"\"," + "\"url\":\"https://api.github.com/users/github-actions%5Bbot%5D\"," + "\"html_url\":\"https://github.com/apps/github-actions\"," + "\"followers_url\":\"https://api.github.com/users/github-actions%5Bbot%5D/followers\"," + "\"following_url\":\"https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}\"," + "\"gists_url\":\"https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}\"," + "\"starred_url\":\"https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}\"," + "\"subscriptions_url\":\"https://api.github.com/users/github-actions%5Bbot%5D/subscriptions\"," + "\"organizations_url\":\"https://api.github.com/users/github-actions%5Bbot%5D/orgs\"," + "\"repos_url\":\"https://api.github.com/users/github-actions%5Bbot%5D/repos\"," + "\"events_url\":\"https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}\"," + "\"received_events_url\":\"https://api.github.com/users/github-actions%5Bbot%5D/received_events\"," + "\"type\":\"Bot\"," + "\"user_view_type\":\"public\"," + "\"site_admin\":false" + "}," + "\"content_type\":\"application/zip\"," + "\"state\":\"uploaded\"," + "\"size\":9000573," + "\"browser_download_url\":\"https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.70/gen1recomp-0.1.70-switch.zip\"" + "}" + "]" + "}"; + ota_release_t gh; + expect(ota_parse_release(github_json, &gh) == 1, "parse github-shaped release"); + expect(strcmp(gh.version, "0.1.70") == 0, "github release version"); + expect(strcmp(gh.asset_name, "gen1recomp-0.1.70-switch.zip") == 0, "github release asset"); + expect(strcmp(gh.download_url, + "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.70/gen1recomp-0.1.70-switch.zip") == + 0, + "github release download url"); + ota_decide_update("0.1.69", &gh, &d); + expect(strcmp(d.status, "available") == 0, "github release decide 0.1.69->0.1.70"); + + const char *sums = "abc123 gen1recomp-1.5.0-switch.zip\n"; + ota_verify_t v; + ota_verify_sha256("gen1recomp-1.5.0-switch.zip", "abc123", sums, &v); + expect(v.ok == 1, "sha ok"); + ota_verify_sha256("gen1recomp-1.5.0-switch.zip", "deadbeef", sums, &v); + expect(v.ok == 0 && strcmp(v.reason, "hash_mismatch") == 0, "sha mismatch"); + ota_verify_sha256("gen1recomp-1.5.0-switch.zip", "abc123", "", &v); + expect(v.ok == 0 && strcmp(v.reason, "sum_not_found") == 0, "sha missing sum rejected"); + + ota_apply_plan_t plan; + ota_plan_atomic_apply("switch/gen1recomp", "/tmp/x", &plan); + expect(strstr(plan.steps[0], "copy_to_part:") != NULL, "plan copy"); + expect(strstr(plan.steps[1], "rename:") != NULL, "plan rename"); + expect(strstr(plan.steps[2], "copy_to_part:launcher") != NULL, "plan launcher copy"); + expect(strstr(plan.steps[3], "rename:") != NULL, "plan launcher rename"); + expect(strstr(plan.steps[4], "env_set_next_load:") != NULL, "plan handoff"); + expect(strstr(plan.preserve, "pokemon-love2d") != NULL, "preserve saves"); + expect(strstr(plan.forbidden_delete, "delete:") != NULL, "forbid delete saves"); + expect(strstr(plan.forbidden_direct, "write_direct:") != NULL, "forbid direct write"); + + ota_offline_t off; + ota_offline_events_t ev = {0}; + ev.user_skip = 1; + ota_offline_policy(1.0, &ev, &off); + expect(strcmp(off.action, "play_installed") == 0, "skip plays installed"); + ev.user_skip = 0; + ev.network_ok = 0; + ota_offline_policy(1.0, &ev, &off); + expect(strcmp(off.action, "play_installed") == 0, "offline plays installed"); + ev.network_ok = 1; + ota_offline_policy(6.0, &ev, &off); + expect(strcmp(off.reason, "timeout") == 0, "timeout 6s"); + ota_offline_policy(2.0, &ev, &off); + expect(strcmp(off.action, "keep_checking") == 0, "still checking"); + + expect(OTA_CHECK_TIMEOUT_SEC == 6, "timeout constant 6"); + + if (g_fail) { + fprintf(stderr, "%d failure(s)\n", g_fail); + return 1; + } + printf("all ota_protocol host tests passed\n"); + return 0; +} diff --git a/ports/switch/ota-launcher/include/ota_fs.h b/ports/switch/ota-launcher/include/ota_fs.h new file mode 100644 index 00000000..f94d98bd --- /dev/null +++ b/ports/switch/ota-launcher/include/ota_fs.h @@ -0,0 +1,38 @@ +#ifndef GEN1_OTA_FS_H +#define GEN1_OTA_FS_H + +#include "ota_protocol.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* Read installed game version from sibling version.txt or NRO nacp-less stamp file. + * Expects switch/gen1recomp/version.txt containing X.Y.Z. Returns 0 on success. */ +int ota_fs_read_installed_version(const char *install_dir, char *out, size_t out_len); + +/* SHA-256 hex (lowercase) of file. Returns 0 on success. */ +int ota_fs_sha256_file(const char *path, char *out_hex, size_t out_len); + +/* Apply verified temp payload (extracted game NRO) using atomic plan. Returns 0 on success. */ +int ota_fs_atomic_replace_game(const char *install_dir, const char *verified_game_nro, + char *err, size_t err_len); + +/* Atomically replace any named NRO under install_dir (copy→.part→rename). */ +int ota_fs_atomic_replace_nro(const char *install_dir, const char *nro_name, + const char *verified_nro, char *err, size_t err_len); + +/* Stage a new launcher + copy romfs bootstrap; chainload bootstrap_out next. + * The running launcher cannot replace its own NRO on sdmc/FAT. */ +int ota_fs_stage_launcher_bootstrap(const char *install_dir, const char *verified_launcher, + char *bootstrap_out, size_t bootstrap_out_len, char *err, + size_t err_len); + +/* Hand off to game NRO via envSetNextLoad (Switch) or no-op stub (host). */ +int ota_fs_handoff_to_game(const char *game_nro_path, char *err, size_t err_len); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/ports/switch/ota-launcher/include/ota_net.h b/ports/switch/ota-launcher/include/ota_net.h new file mode 100644 index 00000000..cd0c44c1 --- /dev/null +++ b/ports/switch/ota-launcher/include/ota_net.h @@ -0,0 +1,29 @@ +#ifndef GEN1_OTA_NET_H +#define GEN1_OTA_NET_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* fraction is 0..1 when Content-Length is known, else -1 for indeterminate. */ +typedef void (*ota_net_progress_fn)(void *userdata, double fraction); + +/* Mount romfs (CA bundle) and init libcurl. Call before any HTTPS download. */ +int ota_net_init(void); +void ota_net_shutdown(void); + +/* Download URL into memory buffer (caller frees *out). Returns 0 on success. */ +int ota_net_download_buffer(const char *url, long timeout_ms, char **out, size_t *out_len, + char *err, size_t err_len); + +/* Download URL to a filesystem path. progress may be NULL. Returns 0 on success. */ +int ota_net_download_file(const char *url, const char *path, long timeout_ms, char *err, + size_t err_len, ota_net_progress_fn progress, void *progress_ud); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/ports/switch/ota-launcher/include/ota_protocol.h b/ports/switch/ota-launcher/include/ota_protocol.h new file mode 100644 index 00000000..995efd7c --- /dev/null +++ b/ports/switch/ota-launcher/include/ota_protocol.h @@ -0,0 +1,95 @@ +#ifndef GEN1_OTA_PROTOCOL_H +#define GEN1_OTA_PROTOCOL_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define OTA_CHECK_TIMEOUT_SEC 6 +#define OTA_GAME_NRO_NAME "gen1recomp-game.nro" +#define OTA_LAUNCHER_NRO_NAME "gen1recomp.nro" +#define OTA_SAVE_DIR_NAME "pokemon-love2d" +#define OTA_INSTALL_DIR "switch/gen1recomp" +#define OTA_LAUNCHER_STAGED_SUFFIX ".staged" +#define OTA_BOOTSTRAP_ROMFS "romfs:/ota-bootstrap.nro" +#define OTA_BOOTSTRAP_SD_NAME "ota-bootstrap.nro" +#define OTA_RELEASES_API \ + "https://api.github.com/repos/bryanthaboi/gen1recomp/releases/latest" + +/* Mirrors src/update/SwitchOta.lua — keep semantics in lockstep. */ + +int ota_compare_semver(const char *a, const char *b); +int ota_is_ota_asset_name(const char *name); +int ota_version_from_ota_asset(const char *name, char *out, size_t out_len); + +typedef struct { + int ok; /* 1 on success */ + char reason[64]; + char tag[64]; + char version[32]; + char asset_name[128]; + char download_url[512]; +} ota_release_t; + +int ota_parse_release(const char *json_text, ota_release_t *out); + +typedef struct { + char status[32]; /* uptodate | available | error */ + char reason[64]; + char version[32]; + char asset_name[128]; + char download_url[512]; +} ota_decision_t; + +void ota_decide_update(const char *installed_version, const ota_release_t *release, + ota_decision_t *out); + +/* sums: newline-separated sha256sums.txt body */ +int ota_lookup_sum(const char *sums_text, const char *asset_name, char *out_hex, + size_t out_len); + +typedef struct { + int ok; + char reason[64]; +} ota_verify_t; + +void ota_verify_sha256(const char *asset_name, const char *actual_hex, + const char *sums_text, ota_verify_t *out); + +typedef struct { + char steps[5][520]; /* human-readable ops */ + char preserve[256]; + char forbidden_delete[256]; + char forbidden_direct[256]; + char part_path[256]; + char game_nro[240]; + char launcher_nro[240]; + char launcher_part[256]; + char next_load[240]; +} ota_apply_plan_t; + +void ota_plan_atomic_apply(const char *install_dir, const char *verified_temp, + ota_apply_plan_t *out); + +typedef struct { + char action[32]; /* play_installed | keep_checking */ + char reason[64]; + char message[128]; +} ota_offline_t; + +typedef struct { + int user_skip; + int network_ok; /* 0 = offline/fail, 1 = ok, -1 = unknown */ + int api_error; +} ota_offline_events_t; + +void ota_offline_policy(double elapsed_sec, const ota_offline_events_t *events, + ota_offline_t *out); + +#ifdef __cplusplus +} +#endif + +#endif /* GEN1_OTA_PROTOCOL_H */ diff --git a/ports/switch/ota-launcher/include/ota_ui.h b/ports/switch/ota-launcher/include/ota_ui.h new file mode 100644 index 00000000..0ad19e6f --- /dev/null +++ b/ports/switch/ota-launcher/include/ota_ui.h @@ -0,0 +1,37 @@ +#ifndef GEN1_OTA_UI_H +#define GEN1_OTA_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/* Framebuffer UI matching the in-game launcher look (black + RGB rail + + * flat semantic buttons). Silent by default — only call when user-facing. */ + +/* Returns 1 = update, 0 = skip / play installed. */ +int ota_ui_prompt_update(const char *installed, const char *latest); + +/* Status screen (download / install). Redraws each call. */ +void ota_ui_show_status(const char *title, const char *detail); + +/* Progress 0..1; detail optional. */ +void ota_ui_show_progress(const char *title, const char *detail, float progress01); + +/* Error / info + wait for B. */ +void ota_ui_alert(const char *title, const char *line1, const char *line2); + +/* User-friendly error with optional technical detail and installed-version footer. */ +void ota_ui_alert_error(const char *title, const char *friendly, const char *technical, + const char *installed_version); + +/* Missing game install screen; wait for +. */ +void ota_ui_missing_game(void); + +/* Tear down framebuffer if active. Safe to call when UI never opened. */ +void ota_ui_shutdown(void); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/ports/switch/ota-launcher/include/ota_unzip.h b/ports/switch/ota-launcher/include/ota_unzip.h new file mode 100644 index 00000000..83e9c9ca --- /dev/null +++ b/ports/switch/ota-launcher/include/ota_unzip.h @@ -0,0 +1,20 @@ +#ifndef GEN1_OTA_UNZIP_H +#define GEN1_OTA_UNZIP_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* Extract member_name from zip_path into dest_path. + * Tries exact member_name, then common prefixes (switch/gen1recomp/). + * Returns 0 on success. */ +int ota_unzip_extract_file(const char *zip_path, const char *member_name, const char *dest_path, + char *err, size_t err_len); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/ports/switch/ota-launcher/romfs/.gitkeep b/ports/switch/ota-launcher/romfs/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/ports/switch/ota-launcher/src/main.c b/ports/switch/ota-launcher/src/main.c new file mode 100644 index 00000000..f187e421 --- /dev/null +++ b/ports/switch/ota-launcher/src/main.c @@ -0,0 +1,281 @@ +/* + * gen1recomp Switch OTA launcher + * + * Quiet by default: checks GitHub Releases with no UI. Only shows the + * branded launcher-style screen when an update is available. Downloads the + * same SD zip (gen1recomp-*-switch.zip), verifies SHA-256, replaces game + + * launcher NROs, then envSetNextLoad. Never touches pokemon-love2d/. + * LÖVE self-updater stays off on NX. + */ + +#include "ota_fs.h" +#include "ota_net.h" +#include "ota_protocol.h" +#include "ota_ui.h" +#include "ota_unzip.h" + +#include +#include +#include + +#if defined(__SWITCH__) +#include +#include +#include +#else +#include +#include +#endif + +#define SD_INSTALL_DIR "sdmc:/switch/gen1recomp" +#define CHECK_TIMEOUT_MS (OTA_CHECK_TIMEOUT_SEC * 1000L) +#define GAME_MEMBER_IN_ZIP "switch/gen1recomp/" OTA_GAME_NRO_NAME +#define LAUNCHER_MEMBER_IN_ZIP "switch/gen1recomp/" OTA_LAUNCHER_NRO_NAME + +typedef struct { + const char *detail; + float base; + float span; +} download_progress_ctx_t; + +static void on_download_progress(void *userdata, double fraction) { + download_progress_ctx_t *ctx = (download_progress_ctx_t *)userdata; + if (!ctx) return; + float p = ctx->base; + if (fraction >= 0.0) p += ctx->span * (float)fraction; + else p += ctx->span * 0.1f; + if (p > ctx->base + ctx->span) p = ctx->base + ctx->span; + ota_ui_show_progress("Step 1/3: Downloading...", ctx->detail, p); +} + +static void show_update_error(const char *title, const char *friendly, const char *technical, + const char *installed) { + ota_ui_alert_error(title, friendly, technical, installed); +} + +static int run_update_flow(const char *install_dir) { + char installed[64] = "0.0.0"; + (void)ota_fs_read_installed_version(install_dir, installed, sizeof(installed)); + + char *json = NULL; + size_t json_len = 0; + char err[256]; + err[0] = '\0'; + + if (ota_net_download_buffer(OTA_RELEASES_API, CHECK_TIMEOUT_MS, &json, &json_len, err, + sizeof(err)) != 0) { + free(json); + return 0; /* offline / timeout — play installed, no UI */ + } + + ota_release_t rel; + if (!ota_parse_release(json, &rel)) { + free(json); + return 0; + } + free(json); + + ota_decision_t dec; + ota_decide_update(installed, &rel, &dec); + if (strcmp(dec.status, "uptodate") == 0 || strcmp(dec.status, "error") == 0) { + return 0; + } + + if (!ota_ui_prompt_update(installed, dec.version)) { + return 0; + } + + char updates_dir[192]; + char zip_path[256]; + char sums_path[256]; + snprintf(updates_dir, sizeof(updates_dir), "%s/updates", install_dir); + snprintf(zip_path, sizeof(zip_path), "%s/updates/%s", install_dir, dec.asset_name); + snprintf(sums_path, sizeof(sums_path), "%s/updates/sha256sums.txt", install_dir); + +#if defined(__SWITCH__) + mkdir(updates_dir, 0755); +#endif + + download_progress_ctx_t dl_ctx = {"This may take a minute.", 0.f, 0.5f}; + ota_ui_show_progress("Step 1/3: Downloading...", dl_ctx.detail, dl_ctx.base); + if (ota_net_download_file(dec.download_url, zip_path, 180000L, err, sizeof(err), + on_download_progress, &dl_ctx) != 0) { + show_update_error("Download failed", + "Could not download the update. Check Wi-Fi and try again.", err, installed); + return 0; + } + + ota_ui_show_progress("Step 2/3: Verifying...", "Checking file integrity.", 0.55f); + char sums_url[512]; + snprintf(sums_url, sizeof(sums_url), + "https://github.com/bryanthaboi/gen1recomp/releases/download/%s/sha256sums.txt", + rel.tag); + if (ota_net_download_file(sums_url, sums_path, CHECK_TIMEOUT_MS, err, sizeof(err), NULL, + NULL) != 0) { + remove(zip_path); + show_update_error("Could not verify", "Downloaded file could not be checked.", err, installed); + return 0; + } + + FILE *sf = fopen(sums_path, "rb"); + if (!sf) { + remove(zip_path); + show_update_error("Could not verify", "Could not read checksum file.", "fopen sums failed", + installed); + return 0; + } + fseek(sf, 0, SEEK_END); + long slen = ftell(sf); + fseek(sf, 0, SEEK_SET); + char *sums = (char *)malloc((size_t)slen + 1); + if (!sums) { + fclose(sf); + remove(zip_path); + show_update_error("Could not verify", "Not enough memory to verify update.", "malloc failed", + installed); + return 0; + } + fread(sums, 1, (size_t)slen, sf); + sums[slen] = '\0'; + fclose(sf); + + ota_ui_show_progress("Step 2/3: Verifying...", "Computing checksum...", 0.65f); + char hex[96]; + if (ota_fs_sha256_file(zip_path, hex, sizeof(hex)) != 0) { + free(sums); + remove(zip_path); + show_update_error("Could not verify", "Could not read downloaded update file.", + "sha256 file read failed", installed); + return 0; + } + ota_verify_t ver; + ota_verify_sha256(dec.asset_name, hex, sums, &ver); + free(sums); + if (!ver.ok) { + remove(zip_path); + show_update_error("Update check failed", + "Downloaded file did not match expected checksum.", ver.reason, installed); + return 0; + } + + char extracted[256]; + char extracted_launcher[256]; + snprintf(extracted, sizeof(extracted), "%s/updates/gen1recomp-game.nro.verified", install_dir); + snprintf(extracted_launcher, sizeof(extracted_launcher), "%s/updates/gen1recomp.nro.verified", + install_dir); + +#if defined(__SWITCH__) + ota_ui_show_progress("Step 3/3: Installing...", "Keeping your saves safe.", 0.8f); + if (ota_unzip_extract_file(zip_path, GAME_MEMBER_IN_ZIP, extracted, err, sizeof(err)) != 0) { + if (ota_unzip_extract_file(zip_path, OTA_GAME_NRO_NAME, extracted, err, sizeof(err)) != 0) { + remove(zip_path); + show_update_error("Could not extract", + "Update zip is missing game files or is corrupted.", err, installed); + return 0; + } + } + + if (ota_fs_atomic_replace_game(install_dir, extracted, err, sizeof(err)) != 0) { + remove(extracted); + remove(zip_path); + show_update_error("Could not install", + "Could not replace game on microSD. Free up space and try again.", err, + installed); + return 0; + } + remove(extracted); + + int have_launcher = 0; + if (ota_unzip_extract_file(zip_path, LAUNCHER_MEMBER_IN_ZIP, extracted_launcher, err, + sizeof(err)) == 0 || + ota_unzip_extract_file(zip_path, OTA_LAUNCHER_NRO_NAME, extracted_launcher, err, + sizeof(err)) == 0) { + have_launcher = 1; + } + if (!have_launcher) { + remove(zip_path); + show_update_error("Could not extract", + "Update zip is missing launcher files.", err, installed); + return 0; + } + + char bootstrap_path[256]; + bootstrap_path[0] = '\0'; + if (ota_fs_stage_launcher_bootstrap(install_dir, extracted_launcher, bootstrap_path, + sizeof(bootstrap_path), err, sizeof(err)) != 0) { + remove(extracted_launcher); + remove(zip_path); + show_update_error("Could not install", + "Game updated but launcher could not be staged. Reinstall from the SD zip.", + err, installed); + return 0; + } + remove(extracted_launcher); + + char vpath[192]; + snprintf(vpath, sizeof(vpath), "%s/version.txt", install_dir); + FILE *vf = fopen(vpath, "wb"); + if (vf) { + fprintf(vf, "%s\n", dec.version); + fclose(vf); + } + + ota_ui_show_progress("Ready", "Finishing update...", 1.0f); + svcSleepThread(600000000ULL); + remove(zip_path); + ota_ui_shutdown(); + + if (ota_fs_handoff_to_game(bootstrap_path, err, sizeof(err)) != 0) { + show_update_error("Could not install", "Launcher bootstrap failed.", err, installed); + return 0; + } + return 2; /* chainload bootstrap; main must not hand off to game */ +#else + (void)extracted; + (void)extracted_launcher; +#endif + remove(zip_path); + ota_ui_shutdown(); + return 0; +} + +int main(int argc, char **argv) { + (void)argc; + (void)argv; + + const char *install = SD_INSTALL_DIR; + int update_rc = 0; + +#if defined(__SWITCH__) + socketInitializeDefault(); + padConfigureInput(1, HidNpadStyleSet_NpadStandard); + if (ota_net_init() == 0) { + update_rc = run_update_flow(install); + ota_net_shutdown(); + } +#else + update_rc = run_update_flow(install); +#endif + + if (update_rc == 2) { +#if defined(__SWITCH__) + socketExit(); +#endif + return 0; + } + + char game[192]; + snprintf(game, sizeof(game), "%s/%s", install, OTA_GAME_NRO_NAME); + char err[128]; + err[0] = '\0'; + if (ota_fs_handoff_to_game(game, err, sizeof(err)) != 0) { + ota_ui_missing_game(); + } + + ota_ui_shutdown(); + +#if defined(__SWITCH__) + socketExit(); +#endif + return 0; +} diff --git a/ports/switch/ota-launcher/src/ota_fs.c b/ports/switch/ota-launcher/src/ota_fs.c new file mode 100644 index 00000000..5adca96b --- /dev/null +++ b/ports/switch/ota-launcher/src/ota_fs.c @@ -0,0 +1,177 @@ +#include "ota_fs.h" + +#include +#include +#include + +#if defined(__SWITCH__) +#include +#include +#include +#include +#else +#include +#endif + +int ota_fs_read_installed_version(const char *install_dir, char *out, size_t out_len) { + if (!out || out_len == 0) return -1; + out[0] = '\0'; + char path[256]; + snprintf(path, sizeof(path), "%s/version.txt", install_dir ? install_dir : OTA_INSTALL_DIR); + FILE *fp = fopen(path, "rb"); + if (!fp) return -1; + if (!fgets(out, (int)out_len, fp)) { + fclose(fp); + return -1; + } + fclose(fp); + /* trim */ + size_t n = strlen(out); + while (n > 0 && (out[n - 1] == '\n' || out[n - 1] == '\r' || out[n - 1] == ' ')) { + out[--n] = '\0'; + } + return n > 0 ? 0 : -1; +} + +int ota_fs_sha256_file(const char *path, char *out_hex, size_t out_len) { + if (!path || !out_hex || out_len < 65) return -1; + out_hex[0] = '\0'; + FILE *fp = fopen(path, "rb"); + if (!fp) return -1; + +#if defined(__SWITCH__) + mbedtls_sha256_context ctx; + mbedtls_sha256_init(&ctx); + mbedtls_sha256_starts(&ctx, 0); + unsigned char buf[8192]; + size_t n; + while ((n = fread(buf, 1, sizeof(buf), fp)) > 0) { + mbedtls_sha256_update(&ctx, buf, n); + } + fclose(fp); + unsigned char digest[32]; + mbedtls_sha256_finish(&ctx, digest); + mbedtls_sha256_free(&ctx); + for (int i = 0; i < 32; i++) snprintf(out_hex + i * 2, out_len - (size_t)(i * 2), "%02x", digest[i]); + return 0; +#else + /* Host: shell out to shasum/sha256sum for the host test path if needed. + * Protocol host tests do not call this; return not-implemented. */ + fclose(fp); + (void)out_len; + return -1; +#endif +} + +static int copy_file(const char *from, const char *to) { + FILE *in = fopen(from, "rb"); + if (!in) return -1; + FILE *out = fopen(to, "wb"); + if (!out) { + fclose(in); + return -1; + } + char buf[8192]; + size_t n; + while ((n = fread(buf, 1, sizeof(buf), in)) > 0) { + if (fwrite(buf, 1, n, out) != n) { + fclose(in); + fclose(out); + return -1; + } + } + fclose(in); + fclose(out); + return 0; +} + +int ota_fs_atomic_replace_game(const char *install_dir, const char *verified_game_nro, + char *err, size_t err_len) { + return ota_fs_atomic_replace_nro(install_dir, OTA_GAME_NRO_NAME, verified_game_nro, err, + err_len); +} + +int ota_fs_atomic_replace_nro(const char *install_dir, const char *nro_name, + const char *verified_nro, char *err, size_t err_len) { + if (!nro_name || !*nro_name || !verified_nro || !*verified_nro) { + if (err && err_len) snprintf(err, err_len, "missing nro paths"); + return -1; + } + char dest[240]; + char part[256]; + snprintf(dest, sizeof(dest), "%s/%s", install_dir ? install_dir : OTA_INSTALL_DIR, nro_name); + snprintf(part, sizeof(part), "%s.part", dest); + if (copy_file(verified_nro, part) != 0) { + if (err && err_len) snprintf(err, err_len, "copy to .part failed (%s)", nro_name); + return -1; + } + /* sdmc/FAT (Switch) and Windows do not replace an existing dest on rename. */ + remove(dest); + if (rename(part, dest) != 0) { + if (err && err_len) snprintf(err, err_len, "rename .part -> %s failed", nro_name); + remove(part); + return -1; + } + return 0; +} + +int ota_fs_stage_launcher_bootstrap(const char *install_dir, const char *verified_launcher, + char *bootstrap_out, size_t bootstrap_out_len, char *err, + size_t err_len) { + if (!verified_launcher || !*verified_launcher) { + if (err && err_len) snprintf(err, err_len, "missing staged launcher path"); + return -1; + } + if (!bootstrap_out || bootstrap_out_len == 0) { + if (err && err_len) snprintf(err, err_len, "missing bootstrap output buffer"); + return -1; + } + bootstrap_out[0] = '\0'; + + const char *base = install_dir ? install_dir : OTA_INSTALL_DIR; + char staged[256]; + char bootstrap[256]; + char updates[192]; + snprintf(staged, sizeof(staged), "%s/%s%s", base, OTA_LAUNCHER_NRO_NAME, + OTA_LAUNCHER_STAGED_SUFFIX); + snprintf(updates, sizeof(updates), "%s/updates", base); + snprintf(bootstrap, sizeof(bootstrap), "%s/updates/%s", base, OTA_BOOTSTRAP_SD_NAME); + + remove(staged); + if (copy_file(verified_launcher, staged) != 0) { + if (err && err_len) snprintf(err, err_len, "stage launcher failed"); + return -1; + } + +#if defined(__SWITCH__) + mkdir(updates, 0755); +#endif + if (copy_file(OTA_BOOTSTRAP_ROMFS, bootstrap) != 0) { + remove(staged); + if (err && err_len) snprintf(err, err_len, "extract bootstrap failed"); + return -1; + } + + snprintf(bootstrap_out, bootstrap_out_len, "%s", bootstrap); + return 0; +} + +int ota_fs_handoff_to_game(const char *game_nro_path, char *err, size_t err_len) { + if (!game_nro_path || !*game_nro_path) { + if (err && err_len) snprintf(err, err_len, "missing game path"); + return -1; + } +#if defined(__SWITCH__) + /* argv for next load: empty args string is fine */ + Result rc = envSetNextLoad(game_nro_path, game_nro_path); + if (R_FAILED(rc)) { + if (err && err_len) snprintf(err, err_len, "envSetNextLoad failed: 0x%x", rc); + return -1; + } + return 0; +#else + if (err && err_len) + snprintf(err, err_len, "handoff stub (host): would envSetNextLoad %s", game_nro_path); + return 0; /* host stub succeeds for flow tests */ +#endif +} diff --git a/ports/switch/ota-launcher/src/ota_net.c b/ports/switch/ota-launcher/src/ota_net.c new file mode 100644 index 00000000..7b897b04 --- /dev/null +++ b/ports/switch/ota-launcher/src/ota_net.c @@ -0,0 +1,185 @@ +#include "ota_net.h" + +#include +#include +#include + +#if defined(__SWITCH__) +#include +#include +#endif + +#if defined(__SWITCH__) +#define OTA_CA_BUNDLE "romfs:/cacert.pem" + +static int g_net_ready = 0; + +static int ota_ca_bundle_ready(void) { + FILE *f = fopen(OTA_CA_BUNDLE, "rb"); + if (!f) return 0; + fclose(f); + return 1; +} + +int ota_net_init(void) { + if (g_net_ready) return 0; + if (R_FAILED(romfsInit())) return -1; + if (!ota_ca_bundle_ready()) return -1; + if (curl_global_init(CURL_GLOBAL_DEFAULT) != CURLE_OK) return -1; + g_net_ready = 1; + return 0; +} + +void ota_net_shutdown(void) { + if (!g_net_ready) return; + curl_global_cleanup(); + romfsExit(); + g_net_ready = 0; +} + +static void ota_net_configure_tls(CURL *curl) { + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L); + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L); + curl_easy_setopt(curl, CURLOPT_CAINFO, OTA_CA_BUNDLE); +} + +struct mem_buf { + char *data; + size_t len; +}; + +struct file_progress { + ota_net_progress_fn fn; + void *userdata; +}; + +static size_t write_mem(char *ptr, size_t size, size_t nmemb, void *userdata) { + struct mem_buf *m = (struct mem_buf *)userdata; + size_t n = size * nmemb; + char *p = (char *)realloc(m->data, m->len + n + 1); + if (!p) return 0; + m->data = p; + memcpy(m->data + m->len, ptr, n); + m->len += n; + m->data[m->len] = '\0'; + return n; +} + +static size_t write_file(char *ptr, size_t size, size_t nmemb, void *userdata) { + return fwrite(ptr, size, nmemb, (FILE *)userdata); +} + +static int xfer_progress(void *clientp, curl_off_t dltotal, curl_off_t dlnow, curl_off_t ultotal, + curl_off_t ulnow) { + (void)ultotal; + (void)ulnow; + struct file_progress *fp = (struct file_progress *)clientp; + if (!fp || !fp->fn) return 0; + if (dltotal > 0) { + double frac = (double)dlnow / (double)dltotal; + if (frac < 0.0) frac = 0.0; + if (frac > 1.0) frac = 1.0; + fp->fn(fp->userdata, frac); + } else if (dlnow > 0) { + fp->fn(fp->userdata, -1.0); + } + return 0; +} +#else + +int ota_net_init(void) { return 0; } +void ota_net_shutdown(void) {} + +#endif + +int ota_net_download_buffer(const char *url, long timeout_ms, char **out, size_t *out_len, + char *err, size_t err_len) { + if (out) *out = NULL; + if (out_len) *out_len = 0; + if (!url || !out) { + if (err && err_len) snprintf(err, err_len, "bad args"); + return -1; + } +#if defined(__SWITCH__) + CURL *curl = curl_easy_init(); + if (!curl) { + if (err && err_len) snprintf(err, err_len, "curl_easy_init failed"); + return -1; + } + struct mem_buf mem = {0}; + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); + curl_easy_setopt(curl, CURLOPT_USERAGENT, "gen1recomp-switch-ota"); + curl_easy_setopt(curl, CURLOPT_TIMEOUT_MS, timeout_ms); + curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT_MS, timeout_ms); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_mem); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &mem); + ota_net_configure_tls(curl); + CURLcode rc = curl_easy_perform(curl); + curl_easy_cleanup(curl); + if (rc != CURLE_OK) { + free(mem.data); + if (err && err_len) snprintf(err, err_len, "%s", curl_easy_strerror(rc)); + return -1; + } + *out = mem.data; + if (out_len) *out_len = mem.len; + return 0; +#else + (void)timeout_ms; + if (err && err_len) + snprintf(err, err_len, "ota_net_download_buffer only available on __SWITCH__"); + return -1; +#endif +} + +int ota_net_download_file(const char *url, const char *path, long timeout_ms, char *err, + size_t err_len, ota_net_progress_fn progress, void *progress_ud) { + if (!url || !path) { + if (err && err_len) snprintf(err, err_len, "bad args"); + return -1; + } +#if defined(__SWITCH__) + FILE *fp = fopen(path, "wb"); + if (!fp) { + if (err && err_len) snprintf(err, err_len, "fopen failed: %s", path); + return -1; + } + CURL *curl = curl_easy_init(); + if (!curl) { + fclose(fp); + if (err && err_len) snprintf(err, err_len, "curl_easy_init failed"); + return -1; + } + struct file_progress fp_cb = {progress, progress_ud}; + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); + curl_easy_setopt(curl, CURLOPT_USERAGENT, "gen1recomp-switch-ota"); + curl_easy_setopt(curl, CURLOPT_TIMEOUT_MS, timeout_ms); + curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT_MS, timeout_ms); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_file); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, fp); + ota_net_configure_tls(curl); + if (progress) { + curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L); + curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, xfer_progress); + curl_easy_setopt(curl, CURLOPT_XFERINFODATA, &fp_cb); + } + CURLcode rc = curl_easy_perform(curl); + curl_easy_cleanup(curl); + fclose(fp); + if (rc != CURLE_OK) { + if (err && err_len) snprintf(err, err_len, "%s", curl_easy_strerror(rc)); + return -1; + } + if (progress) progress(progress_ud, 1.0); + return 0; +#else + (void)timeout_ms; + (void)progress; + (void)progress_ud; + if (err && err_len) + snprintf(err, err_len, "ota_net_download_file only available on __SWITCH__"); + return -1; +#endif +} diff --git a/ports/switch/ota-launcher/src/ota_protocol.c b/ports/switch/ota-launcher/src/ota_protocol.c new file mode 100644 index 00000000..ba41b121 --- /dev/null +++ b/ports/switch/ota-launcher/src/ota_protocol.c @@ -0,0 +1,329 @@ +#include "ota_protocol.h" + +#include +#include +#include +#include + +typedef struct { + int major; + int minor; + int patch; + int ok; +} semver_t; + +static void set_reason(char *buf, size_t n, const char *r) { + if (!buf || n == 0) return; + snprintf(buf, n, "%s", r ? r : ""); +} + +static int parse_semver(const char *s, semver_t *out) { + memset(out, 0, sizeof(*out)); + if (!s || !*s) return 0; + if (s[0] == 'v' || s[0] == 'V') s++; + int maj = 0, min = 0, pat = 0; + char trail = 0; + if (sscanf(s, "%d.%d.%d%c", &maj, &min, &pat, &trail) != 3) return 0; + if (maj < 0 || min < 0 || pat < 0) return 0; + out->major = maj; + out->minor = min; + out->patch = pat; + out->ok = 1; + return 1; +} + +int ota_compare_semver(const char *a, const char *b) { + semver_t pa, pb; + int oa = parse_semver(a, &pa); + int ob = parse_semver(b, &pb); + if (!oa && !ob) return 0; + if (!oa) return -1; + if (!ob) return 1; + if (pa.major != pb.major) return pa.major < pb.major ? -1 : 1; + if (pa.minor != pb.minor) return pa.minor < pb.minor ? -1 : 1; + if (pa.patch != pb.patch) return pa.patch < pb.patch ? -1 : 1; + return 0; +} + +int ota_is_ota_asset_name(const char *name) { + int maj = 0, min = 0, pat = 0; + if (!name) return 0; + /* Unified SD/OTA asset: gen1recomp-X.Y.Z-switch.zip */ + size_t len = strlen(name); + const char *suffix = "-switch.zip"; + size_t slen = strlen(suffix); + if (len <= slen) return 0; + if (strcmp(name + len - slen, suffix) != 0) return 0; + if (strncmp(name, "gen1recomp-", 11) != 0) return 0; + if (sscanf(name + 11, "%d.%d.%d", &maj, &min, &pat) != 3) return 0; + char rebuilt[128]; + snprintf(rebuilt, sizeof(rebuilt), "gen1recomp-%d.%d.%d-switch.zip", maj, min, pat); + return strcmp(name, rebuilt) == 0; +} + +int ota_version_from_ota_asset(const char *name, char *out, size_t out_len) { + int maj = 0, min = 0, pat = 0; + if (!out || out_len == 0) return 0; + out[0] = '\0'; + if (!ota_is_ota_asset_name(name)) return 0; + if (sscanf(name + 11, "%d.%d.%d", &maj, &min, &pat) != 3) return 0; + snprintf(out, out_len, "%d.%d.%d", maj, min, pat); + return 1; +} + +static const char *find_json_string(const char *json, const char *key, char *out, size_t out_len) { + char pattern[128]; + snprintf(pattern, sizeof(pattern), "\"%s\"", key); + const char *p = strstr(json, pattern); + if (!p) return NULL; + p = strchr(p + strlen(pattern), ':'); + if (!p) return NULL; + p++; + while (*p && isspace((unsigned char)*p)) p++; + if (*p != '"') return NULL; + p++; + size_t i = 0; + while (*p && *p != '"' && i + 1 < out_len) { + if (*p == '\\' && p[1]) { + p++; + out[i++] = *p++; + continue; + } + out[i++] = *p++; + } + out[i] = '\0'; + return out; +} + +/* Opening { of the JSON object that contains pos (walk backward). */ +static const char *find_json_object_start(const char *pos, const char *json_start) { + if (!pos || !json_start || pos < json_start) return NULL; + int depth = 0; + const char *p = pos; + while (p >= json_start) { + if (*p == '}') depth++; + else if (*p == '{') { + if (depth == 0) return p; + depth--; + } + p--; + } + return NULL; +} + +/* Pointer just past the closing } of the object that starts at object_start. */ +static const char *find_json_object_end(const char *object_start) { + if (!object_start || *object_start != '{') return NULL; + int depth = 1; + const char *p = object_start + 1; + while (*p) { + if (*p == '{') depth++; + else if (*p == '}') { + depth--; + if (depth == 0) return p + 1; + } + p++; + } + return NULL; +} + +int ota_parse_release(const char *json_text, ota_release_t *out) { + memset(out, 0, sizeof(*out)); + if (!json_text || !*json_text) { + set_reason(out->reason, sizeof(out->reason), "empty_json"); + return 0; + } + if (!find_json_string(json_text, "tag_name", out->tag, sizeof(out->tag))) { + set_reason(out->reason, sizeof(out->reason), "missing_tag"); + return 0; + } + semver_t sv; + if (!parse_semver(out->tag, &sv)) { + set_reason(out->reason, sizeof(out->reason), "bad_tag"); + return 0; + } + snprintf(out->version, sizeof(out->version), "%d.%d.%d", sv.major, sv.minor, sv.patch); + + /* Scan for OTA asset name then browser_download_url inside the same asset object. */ + const char *cursor = json_text; + while ((cursor = strstr(cursor, "\"name\"")) != NULL) { + char name[128]; + if (!find_json_string(cursor, "name", name, sizeof(name))) { + cursor += 6; + continue; + } + if (!ota_is_ota_asset_name(name)) { + cursor += 6; + continue; + } + const char *asset_start = find_json_object_start(cursor, json_text); + const char *asset_end = + asset_start ? find_json_object_end(asset_start) : NULL; + if (!asset_start || !asset_end || asset_end <= cursor) { + cursor += 6; + continue; + } + char url[512]; + const char *u = NULL; + const char *scan = cursor; + while (scan < asset_end && + (scan = strstr(scan, "\"browser_download_url\"")) != NULL && scan < asset_end) { + if (find_json_string(scan, "browser_download_url", url, sizeof(url))) { + u = url; + break; + } + scan += 21; + } + if (!u || !*u) { + cursor += 6; + continue; + } + snprintf(out->asset_name, sizeof(out->asset_name), "%s", name); + snprintf(out->download_url, sizeof(out->download_url), "%s", u); + out->ok = 1; + return 1; + } + set_reason(out->reason, sizeof(out->reason), "missing_ota_asset"); + return 0; +} + +void ota_decide_update(const char *installed_version, const ota_release_t *release, + ota_decision_t *out) { + memset(out, 0, sizeof(*out)); + semver_t inst; + if (!parse_semver(installed_version, &inst)) { + snprintf(out->status, sizeof(out->status), "error"); + set_reason(out->reason, sizeof(out->reason), "bad_installed_version"); + return; + } + if (!release || !release->ok) { + snprintf(out->status, sizeof(out->status), "error"); + set_reason(out->reason, sizeof(out->reason), "bad_release"); + return; + } + if (ota_compare_semver(release->version, installed_version) <= 0) { + snprintf(out->status, sizeof(out->status), "uptodate"); + snprintf(out->version, sizeof(out->version), "%s", installed_version); + return; + } + snprintf(out->status, sizeof(out->status), "available"); + snprintf(out->version, sizeof(out->version), "%s", release->version); + snprintf(out->asset_name, sizeof(out->asset_name), "%s", release->asset_name); + snprintf(out->download_url, sizeof(out->download_url), "%s", release->download_url); +} + +int ota_lookup_sum(const char *sums_text, const char *asset_name, char *out_hex, size_t out_len) { + if (out_hex && out_len) out_hex[0] = '\0'; + if (!sums_text || !asset_name || !out_hex || out_len < 65) return 0; + const char *p = sums_text; + while (*p) { + char hex[96]; + char name[256]; + int n = 0; + if (sscanf(p, "%95s %255s%n", hex, name, &n) >= 2 && n > 0) { + const char *nm = name; + if (nm[0] == '*') nm++; + if (strncmp(nm, "./", 2) == 0) nm += 2; + if (strcmp(nm, asset_name) == 0) { + /* normalize hex to lowercase */ + size_t i; + for (i = 0; hex[i] && i + 1 < out_len; i++) + out_hex[i] = (char)tolower((unsigned char)hex[i]); + out_hex[i] = '\0'; + return 1; + } + p += n; + while (*p && *p != '\n') p++; + if (*p == '\n') p++; + continue; + } + while (*p && *p != '\n') p++; + if (*p == '\n') p++; + } + return 0; +} + +void ota_verify_sha256(const char *asset_name, const char *actual_hex, const char *sums_text, + ota_verify_t *out) { + memset(out, 0, sizeof(*out)); + if (!asset_name || !*asset_name) { + set_reason(out->reason, sizeof(out->reason), "bad_asset_name"); + return; + } + if (!sums_text) { + set_reason(out->reason, sizeof(out->reason), "missing_sums"); + return; + } + char expected[96]; + if (!ota_lookup_sum(sums_text, asset_name, expected, sizeof(expected))) { + set_reason(out->reason, sizeof(out->reason), "sum_not_found"); + return; + } + if (!actual_hex || !*actual_hex) { + set_reason(out->reason, sizeof(out->reason), "missing_actual_hash"); + return; + } + char actual[96]; + size_t i; + for (i = 0; actual_hex[i] && i + 1 < sizeof(actual); i++) + actual[i] = (char)tolower((unsigned char)actual_hex[i]); + actual[i] = '\0'; + if (strcmp(actual, expected) != 0) { + set_reason(out->reason, sizeof(out->reason), "hash_mismatch"); + return; + } + out->ok = 1; +} + +void ota_plan_atomic_apply(const char *install_dir, const char *verified_temp, + ota_apply_plan_t *out) { + memset(out, 0, sizeof(*out)); + if (!install_dir || !*install_dir) install_dir = OTA_INSTALL_DIR; + if (!verified_temp) verified_temp = ""; + snprintf(out->game_nro, sizeof(out->game_nro), "%s/%s", install_dir, OTA_GAME_NRO_NAME); + snprintf(out->part_path, sizeof(out->part_path), "%s.part", out->game_nro); + snprintf(out->launcher_nro, sizeof(out->launcher_nro), "%s/%s", install_dir, + OTA_LAUNCHER_NRO_NAME); + snprintf(out->launcher_part, sizeof(out->launcher_part), "%s.part", out->launcher_nro); + snprintf(out->next_load, sizeof(out->next_load), "%s", out->game_nro); + /* verified_temp is the game NRO; launcher replace uses a sibling verified path */ + snprintf(out->steps[0], sizeof(out->steps[0]), "copy_to_part:%s->%s", verified_temp, + out->part_path); + snprintf(out->steps[1], sizeof(out->steps[1]), "rename:%s->%s", out->part_path, out->game_nro); + snprintf(out->steps[2], sizeof(out->steps[2]), "copy_to_part:launcher->%s", out->launcher_part); + snprintf(out->steps[3], sizeof(out->steps[3]), "rename:%s->%s", out->launcher_part, + out->launcher_nro); + snprintf(out->steps[4], sizeof(out->steps[4]), "env_set_next_load:%s", out->game_nro); + snprintf(out->preserve, sizeof(out->preserve), "%s/%s", install_dir, OTA_SAVE_DIR_NAME); + snprintf(out->forbidden_delete, sizeof(out->forbidden_delete), "delete:%s/%s", install_dir, + OTA_SAVE_DIR_NAME); + snprintf(out->forbidden_direct, sizeof(out->forbidden_direct), "write_direct:%s", out->game_nro); +} + +void ota_offline_policy(double elapsed_sec, const ota_offline_events_t *events, ota_offline_t *out) { + memset(out, 0, sizeof(*out)); + ota_offline_events_t ev = {0, -1, 0}; + if (events) ev = *events; + if (ev.user_skip) { + snprintf(out->action, sizeof(out->action), "play_installed"); + set_reason(out->reason, sizeof(out->reason), "user_skip"); + snprintf(out->message, sizeof(out->message), "update skipped"); + return; + } + if (ev.api_error || ev.network_ok == 0) { + snprintf(out->action, sizeof(out->action), "play_installed"); + set_reason(out->reason, sizeof(out->reason), "offline_or_error"); + snprintf(out->message, sizeof(out->message), + "offline or update check failed — play installed version"); + return; + } + if (elapsed_sec >= (double)OTA_CHECK_TIMEOUT_SEC) { + snprintf(out->action, sizeof(out->action), "play_installed"); + set_reason(out->reason, sizeof(out->reason), "timeout"); + snprintf(out->message, sizeof(out->message), "update check timed out after %ds", + OTA_CHECK_TIMEOUT_SEC); + return; + } + snprintf(out->action, sizeof(out->action), "keep_checking"); + set_reason(out->reason, sizeof(out->reason), "in_flight"); +} diff --git a/ports/switch/ota-launcher/src/ota_ui.c b/ports/switch/ota-launcher/src/ota_ui.c new file mode 100644 index 00000000..972310b1 --- /dev/null +++ b/ports/switch/ota-launcher/src/ota_ui.c @@ -0,0 +1,595 @@ +/* + * Switch OTA UI — matches in-game launcher language (Theme.lua): + * black field, RGB version rail, flat yellow/white buttons, white ink. + * Quiet until called. Uses framebuffer + 8x8 font + optional romfs logo. + */ +#include "ota_ui.h" + +#include +#include +#include + +#if defined(__SWITCH__) +#include + +#include "../third_party/font8x8_basic.h" + +#define OTA_LOGO_ROMFS "romfs:/logo.rgba" + +#define FB_W 1280 +#define FB_H 720 + +#define TITLE_Y 200 +#define LINE_TITLE 44 +#define LINE_BODY 36 +#define LINE_SMALL 28 +#define WRAP_MAX_PX 720 + +/* Theme.PAL (0-255) -> RGBA8 */ +#define COL_BG RGBA8_MAXALPHA(0, 0, 0) +#define COL_INK RGBA8_MAXALPHA(255, 255, 255) +#define COL_DETAIL RGBA8_MAXALPHA(200, 200, 200) +#define COL_MUTED RGBA8_MAXALPHA(150, 150, 150) +#define COL_INVERSE RGBA8_MAXALPHA(0, 0, 0) +#define COL_YELLOW RGBA8_MAXALPHA(255, 214, 0) +#define COL_GREEN RGBA8_MAXALPHA(0, 255, 140) +#define COL_RAIL_R RGBA8_MAXALPHA(255, 60, 72) +#define COL_RAIL_B RGBA8_MAXALPHA(70, 150, 255) +#define COL_RAIL_G RGBA8_MAXALPHA(255, 203, 5) +#define COL_LINE RGBA8_MAXALPHA(90, 90, 90) +#define COL_RAISED RGBA8_MAXALPHA(20, 20, 20) + +static int g_ready = 0; +static Framebuffer g_fb; +static PadState g_pad; +static u32 *g_logo = NULL; +static int g_logo_w = 0, g_logo_h = 0; + +static void fill_rect(u32 *fb, u32 stride_px, int x, int y, int w, int h, u32 color) { + if (w <= 0 || h <= 0) return; + if (x < 0) { + w += x; + x = 0; + } + if (y < 0) { + h += y; + y = 0; + } + if (x + w > FB_W) w = FB_W - x; + if (y + h > FB_H) h = FB_H - y; + if (w <= 0 || h <= 0) return; + for (int row = 0; row < h; row++) { + u32 *line = fb + (y + row) * stride_px + x; + for (int col = 0; col < w; col++) line[col] = color; + } +} + +/* Normalize UTF-8 punctuation to ASCII; drop other non-ASCII bytes. */ +static size_t ota_ui_sanitize_ascii(const char *src, char *dst, size_t dst_len) { + if (!dst || dst_len == 0) return 0; + dst[0] = '\0'; + if (!src) return 0; + size_t w = 0; + for (size_t i = 0; src[i] && w + 1 < dst_len;) { + unsigned char c = (unsigned char)src[i]; + if (c < 0x80) { + dst[w++] = (char)c; + i++; + continue; + } + if (c == 0xE2 && src[i + 1] && src[i + 2]) { + unsigned char b2 = (unsigned char)src[i + 1]; + unsigned char b3 = (unsigned char)src[i + 2]; + if (b2 == 0x80 && b3 == 0xA6) { + if (w + 3 < dst_len) { + dst[w++] = '.'; + dst[w++] = '.'; + dst[w++] = '.'; + } + i += 3; + continue; + } + if (b2 == 0x80 && (b3 == 0x94 || b3 == 0x93)) { + dst[w++] = '-'; + i += 3; + continue; + } + if (b2 == 0x80 && (b3 == 0x98 || b3 == 0x99)) { + dst[w++] = '\''; + i += 3; + continue; + } + } + if ((c & 0xE0) == 0xC0) i += 2; + else if ((c & 0xF0) == 0xE0) i += 3; + else if ((c & 0xF8) == 0xF0) i += 4; + else i++; + } + dst[w] = '\0'; + return w; +} + +static void draw_glyph(u32 *fb, u32 stride_px, int x, int y, char ch, u32 color, int scale) { + unsigned char c = (unsigned char)ch; + if (c > 127) return; + const char *bits = font8x8_basic[c]; + for (int row = 0; row < 8; row++) { + unsigned char line = (unsigned char)bits[row]; + for (int col = 0; col < 8; col++) { + if (line & (1 << col)) { + fill_rect(fb, stride_px, x + col * scale, y + row * scale, scale, scale, color); + } + } + } +} + +static int text_width(const char *s, int scale) { + if (!s) return 0; + return (int)strlen(s) * 8 * scale; +} + +static void draw_text(u32 *fb, u32 stride_px, int x, int y, const char *s, u32 color, int scale) { + if (!s) return; + char buf[512]; + ota_ui_sanitize_ascii(s, buf, sizeof(buf)); + int cx = x; + for (const char *p = buf; *p; p++) { + if (*p == '\n') { + cx = x; + y += 8 * scale + 4; + continue; + } + draw_glyph(fb, stride_px, cx, y, *p, color, scale); + cx += 8 * scale; + } +} + +static void draw_text_centered(u32 *fb, u32 stride_px, int y, const char *s, u32 color, int scale) { + char buf[512]; + ota_ui_sanitize_ascii(s, buf, sizeof(buf)); + int w = text_width(buf, scale); + draw_text(fb, stride_px, (FB_W - w) / 2, y, buf, color, scale); +} + +static int draw_text_wrapped_centered(u32 *fb, u32 stride_px, int y, const char *s, u32 color, + int scale, int max_px) { + if (!s || !*s) return y; + char buf[512]; + ota_ui_sanitize_ascii(s, buf, sizeof(buf)); + int char_w = 8 * scale; + int max_chars = max_px / char_w; + if (max_chars < 8) max_chars = 8; + + char line[128]; + int line_len = 0; + const char *word = buf; + int lines = 0; + + while (*word) { + while (*word == ' ') word++; + if (!*word) break; + + const char *end = word; + while (*end && *end != ' ' && *end != '\n') end++; + int wlen = (int)(end - word); + + if (line_len > 0 && line_len + 1 + wlen > max_chars) { + line[line_len] = '\0'; + draw_text_centered(fb, stride_px, y, line, color, scale); + y += 8 * scale + 4; + lines++; + line_len = 0; + } + + if (wlen > max_chars) { + if (line_len > 0) { + line[line_len] = '\0'; + draw_text_centered(fb, stride_px, y, line, color, scale); + y += 8 * scale + 4; + lines++; + line_len = 0; + } + while (wlen > 0) { + int chunk = wlen > max_chars ? max_chars : wlen; + memcpy(line, word, (size_t)chunk); + line[chunk] = '\0'; + draw_text_centered(fb, stride_px, y, line, color, scale); + y += 8 * scale + 4; + lines++; + word += chunk; + wlen -= chunk; + } + word = end; + continue; + } + + if (line_len > 0) line[line_len++] = ' '; + memcpy(line + line_len, word, (size_t)wlen); + line_len += wlen; + line[line_len] = '\0'; + word = (*end == '\n') ? end + 1 : end; + if (*(end - 1) == '\n' || *end == '\n') { + draw_text_centered(fb, stride_px, y, line, color, scale); + y += 8 * scale + 4; + lines++; + line_len = 0; + if (*end == '\n') word = end + 1; + } + } + + if (line_len > 0) { + draw_text_centered(fb, stride_px, y, line, color, scale); + y += 8 * scale + 4; + lines++; + } + if (lines == 0) return y; + return y; +} + +static void draw_rail(u32 *fb, u32 stride_px) { + int h = 6; + int third = FB_W / 3; + fill_rect(fb, stride_px, 0, 0, third, h, COL_RAIL_R); + fill_rect(fb, stride_px, third, 0, third, h, COL_RAIL_B); + fill_rect(fb, stride_px, third * 2, 0, FB_W - third * 2, h, COL_RAIL_G); +} + +static void blit_logo(u32 *fb, u32 stride_px, int dst_x, int dst_y, int max_w) { + if (!g_logo || g_logo_w <= 0 || g_logo_h <= 0) return; + int dw = g_logo_w; + int dh = g_logo_h; + if (dw > max_w) { + dh = dh * max_w / dw; + dw = max_w; + } + for (int y = 0; y < dh; y++) { + int sy = y * g_logo_h / dh; + for (int x = 0; x < dw; x++) { + int sx = x * g_logo_w / dw; + u32 px = g_logo[sy * g_logo_w + sx]; + u8 a = (px >> 24) & 0xff; + if (a < 16) continue; + int dx = dst_x + x; + int dy = dst_y + y; + if (dx < 0 || dy < 0 || dx >= FB_W || dy >= FB_H) continue; + fb[dy * stride_px + dx] = px | 0xff000000u; + } + } +} + +static void load_logo(void) { + if (g_logo) return; + FILE *fp = fopen(OTA_LOGO_ROMFS, "rb"); + if (!fp) return; + + unsigned char header[8]; + if (fread(header, 1, sizeof(header), fp) != sizeof(header)) { + fclose(fp); + return; + } + + int w = (int)(header[0] | (header[1] << 8) | (header[2] << 16) | (header[3] << 24)); + int h = (int)(header[4] | (header[5] << 8) | (header[6] << 16) | (header[7] << 24)); + if (w <= 0 || h <= 0 || w > 2048 || h > 2048) { + fclose(fp); + return; + } + + size_t nbytes = (size_t)w * (size_t)h * 4u; + unsigned char *data = (unsigned char *)malloc(nbytes); + if (!data) { + fclose(fp); + return; + } + if (fread(data, 1, nbytes, fp) != nbytes) { + free(data); + fclose(fp); + return; + } + fclose(fp); + + g_logo = (u32 *)malloc((size_t)w * (size_t)h * sizeof(u32)); + if (!g_logo) { + free(data); + return; + } + for (int i = 0; i < w * h; i++) { + unsigned char *p = data + (size_t)i * 4u; + g_logo[i] = RGBA8(p[0], p[1], p[2], p[3]); + } + free(data); + g_logo_w = w; + g_logo_h = h; +} + +static int ui_ensure(void) { + if (g_ready) return 1; + NWindow *win = nwindowGetDefault(); + if (R_FAILED(framebufferCreate(&g_fb, win, FB_W, FB_H, PIXEL_FORMAT_RGBA_8888, 2))) return 0; + framebufferMakeLinear(&g_fb); + padInitializeDefault(&g_pad); + load_logo(); + g_ready = 1; + return 1; +} + +void ota_ui_shutdown(void) { + if (!g_ready) return; + framebufferClose(&g_fb); + free(g_logo); + g_logo = NULL; + g_logo_w = g_logo_h = 0; + g_ready = 0; +} + +static void draw_button(u32 *fb, u32 stride_px, int x, int y, int w, int h, u32 fill, u32 ink, + const char *label, int focused) { + fill_rect(fb, stride_px, x, y, w, h, fill); + if (focused) { + fill_rect(fb, stride_px, x - 3, y - 3, w + 6, 2, COL_INK); + fill_rect(fb, stride_px, x - 3, y + h + 1, w + 6, 2, COL_INK); + fill_rect(fb, stride_px, x - 3, y - 3, 2, h + 6, COL_INK); + fill_rect(fb, stride_px, x + w + 1, y - 3, 2, h + 6, COL_INK); + } else { + fill_rect(fb, stride_px, x, y, w, 1, COL_LINE); + fill_rect(fb, stride_px, x, y + h - 1, w, 1, COL_LINE); + fill_rect(fb, stride_px, x, y, 1, h, COL_LINE); + fill_rect(fb, stride_px, x + w - 1, y, 1, h, COL_LINE); + } + int scale = 3; + char lbl[128]; + ota_ui_sanitize_ascii(label, lbl, sizeof(lbl)); + int max_tw = w - 24; + int tw = text_width(lbl, scale); + while (scale > 2 && tw > max_tw) { + scale--; + tw = text_width(lbl, scale); + } + int tx = x + (w - tw) / 2; + int ty = y + (h - 8 * scale) / 2; + draw_text(fb, stride_px, tx, ty, lbl, ink, scale); +} + +static void draw_chrome(u32 *fb, u32 stride_px) { + fill_rect(fb, stride_px, 0, 0, FB_W, FB_H, COL_BG); + draw_rail(fb, stride_px); + int logo_max = 320; + int logo_h = g_logo ? (g_logo_h * logo_max / g_logo_w) : 40; + if (logo_h > 90) logo_h = 90; + int logo_y = 48; + if (g_logo) { + int logo_w = logo_max; + if (g_logo_w * logo_h / g_logo_h < logo_max) logo_w = g_logo_w * logo_h / g_logo_h; + blit_logo(fb, stride_px, (FB_W - logo_w) / 2, logo_y, logo_max); + } else { + draw_text_centered(fb, stride_px, logo_y + 16, "gen1recomp", COL_INK, 4); + } +} + +static void present(void (*paint)(u32 *fb, u32 stride_px, void *ctx), void *ctx) { + if (!ui_ensure()) return; + u32 stride = 0; + u32 *fb = (u32 *)framebufferBegin(&g_fb, &stride); + u32 stride_px = stride / sizeof(u32); + paint(fb, stride_px, ctx); + framebufferEnd(&g_fb); +} + +typedef struct { + const char *installed; + const char *latest; + int focus; /* 0 = Update, 1 = Play */ +} prompt_ctx_t; + +static void paint_prompt(u32 *fb, u32 stride_px, void *ctx) { + prompt_ctx_t *p = (prompt_ctx_t *)ctx; + draw_chrome(fb, stride_px); + + int y = TITLE_Y - 20; + draw_text_centered(fb, stride_px, y, "Update available", COL_INK, 3); + y += LINE_TITLE; + + char line[96]; + snprintf(line, sizeof(line), "v%s to v%s", p->installed ? p->installed : "?", + p->latest ? p->latest : "?"); + draw_text_centered(fb, stride_px, y, line, COL_YELLOW, 3); + y += LINE_TITLE + 8; + + draw_text_centered(fb, stride_px, y, "Your saves stay on this console.", COL_MUTED, 2); + y += LINE_BODY + 28; + + int bw = 600; + int bh = 64; + int bx = (FB_W - bw) / 2; + draw_button(fb, stride_px, bx, y, bw, bh, COL_YELLOW, COL_INVERSE, "(A) Update", p->focus == 0); + y += bh + 24; + draw_button(fb, stride_px, bx, y, bw, bh, COL_INK, COL_INVERSE, "(B) Play without update", + p->focus == 1); +} + +int ota_ui_prompt_update(const char *installed, const char *latest) { + if (!ui_ensure()) return 0; + prompt_ctx_t ctx = {installed, latest, 0}; + while (appletMainLoop()) { + present(paint_prompt, &ctx); + padUpdate(&g_pad); + u64 k = padGetButtonsDown(&g_pad); + if (k & HidNpadButton_A) { + int do_update = (ctx.focus == 0); + if (!do_update) ota_ui_shutdown(); + return do_update; + } + if (k & HidNpadButton_B) { + ota_ui_shutdown(); + return 0; + } + if (k & (HidNpadButton_Up | HidNpadButton_Down | HidNpadButton_Left | HidNpadButton_Right)) { + ctx.focus = 1 - ctx.focus; + } + svcSleepThread(16000000ULL); + } + ota_ui_shutdown(); + return 0; +} + +typedef struct { + const char *title; + const char *detail; + float progress; + int show_bar; +} status_ctx_t; + +static void paint_progress_bar(u32 *fb, u32 stride_px, int y, float t) { + int bw = 520; + int bh = 18; + int bx = (FB_W - bw) / 2; + if (t < 0.f) t = 0.f; + if (t > 1.f) t = 1.f; + fill_rect(fb, stride_px, bx, y, bw, bh, COL_RAISED); + fill_rect(fb, stride_px, bx, y, bw, 1, COL_LINE); + fill_rect(fb, stride_px, bx, y + bh - 1, bw, 1, COL_LINE); + fill_rect(fb, stride_px, bx, y, 1, bh, COL_LINE); + fill_rect(fb, stride_px, bx + bw - 1, y, 1, bh, COL_LINE); + int fw = (int)(bw * t); + if (fw > 0) fill_rect(fb, stride_px, bx, y, fw, bh, COL_GREEN); +} + +static void paint_status(u32 *fb, u32 stride_px, void *ctx) { + status_ctx_t *s = (status_ctx_t *)ctx; + draw_chrome(fb, stride_px); + int y = TITLE_Y; + draw_text_centered(fb, stride_px, y, s->title ? s->title : "", COL_INK, 3); + y += LINE_TITLE; + if (s->detail && s->detail[0]) { + y = draw_text_wrapped_centered(fb, stride_px, y, s->detail, COL_DETAIL, 2, WRAP_MAX_PX); + y += LINE_SMALL; + } + if (s->show_bar) paint_progress_bar(fb, stride_px, y, s->progress); +} + +void ota_ui_show_status(const char *title, const char *detail) { + status_ctx_t s = {title, detail, 0.f, 0}; + present(paint_status, &s); +} + +void ota_ui_show_progress(const char *title, const char *detail, float progress01) { + status_ctx_t s = {title, detail, progress01, 1}; + present(paint_status, &s); +} + +typedef struct { + const char *title; + const char *line1; + const char *line2; + const char *line3; +} alert_ctx_t; + +static void paint_alert(u32 *fb, u32 stride_px, void *ctx) { + alert_ctx_t *a = (alert_ctx_t *)ctx; + draw_chrome(fb, stride_px); + int y = TITLE_Y; + draw_text_centered(fb, stride_px, y, a->title ? a->title : "", COL_YELLOW, 3); + y += LINE_TITLE; + if (a->line1 && a->line1[0]) + y = draw_text_wrapped_centered(fb, stride_px, y, a->line1, COL_DETAIL, 2, WRAP_MAX_PX); + y += LINE_SMALL; + if (a->line2 && a->line2[0]) + y = draw_text_wrapped_centered(fb, stride_px, y, a->line2, COL_MUTED, 2, WRAP_MAX_PX); + y += LINE_SMALL; + if (a->line3 && a->line3[0]) + y = draw_text_wrapped_centered(fb, stride_px, y, a->line3, COL_MUTED, 2, WRAP_MAX_PX); + y += LINE_BODY + 16; + int bw = 360; + int bh = 56; + draw_button(fb, stride_px, (FB_W - bw) / 2, y, bw, bh, COL_INK, COL_INVERSE, "(B) Continue", 1); +} + +void ota_ui_alert(const char *title, const char *line1, const char *line2) { + if (!ui_ensure()) return; + alert_ctx_t a = {title, line1, line2, NULL}; + while (appletMainLoop()) { + present(paint_alert, &a); + padUpdate(&g_pad); + if (padGetButtonsDown(&g_pad) & HidNpadButton_B) break; + svcSleepThread(16000000ULL); + } + ota_ui_shutdown(); +} + +void ota_ui_alert_error(const char *title, const char *friendly, const char *technical, + const char *installed_version) { + if (!ui_ensure()) return; + char footer[96]; + snprintf(footer, sizeof(footer), "Playing installed version (v%s).", + installed_version && installed_version[0] ? installed_version : "?"); + char tech[256]; + if (technical && technical[0]) { + ota_ui_sanitize_ascii(technical, tech, sizeof(tech)); + if (strlen(tech) > 80) tech[80] = '\0'; + } else { + tech[0] = '\0'; + } + alert_ctx_t a = {title, friendly, tech[0] ? tech : NULL, footer}; + while (appletMainLoop()) { + present(paint_alert, &a); + padUpdate(&g_pad); + if (padGetButtonsDown(&g_pad) & HidNpadButton_B) break; + svcSleepThread(16000000ULL); + } + ota_ui_shutdown(); +} + +static void paint_missing(u32 *fb, u32 stride_px, void *ctx) { + (void)ctx; + draw_chrome(fb, stride_px); + int y = TITLE_Y - 10; + draw_text_centered(fb, stride_px, y, "Game files missing", COL_YELLOW, 3); + y += LINE_TITLE; + y = draw_text_wrapped_centered(fb, stride_px, y, + "Copy the Switch zip onto your microSD, then open gen1recomp again.", + COL_DETAIL, 2, WRAP_MAX_PX); + y += LINE_BODY + 16; + int bw = 320; + int bh = 56; + draw_button(fb, stride_px, (FB_W - bw) / 2, y, bw, bh, COL_INK, COL_INVERSE, "(+) Exit", 1); +} + +void ota_ui_missing_game(void) { + if (!ui_ensure()) return; + while (appletMainLoop()) { + present(paint_missing, NULL); + padUpdate(&g_pad); + if (padGetButtonsDown(&g_pad) & HidNpadButton_Plus) break; + svcSleepThread(16000000ULL); + } + ota_ui_shutdown(); +} + +#else /* host stub */ + +int ota_ui_prompt_update(const char *installed, const char *latest) { + fprintf(stderr, "[ota_ui] update %s -> %s (host stub: skip)\n", installed ? installed : "?", + latest ? latest : "?"); + return 0; +} +void ota_ui_show_status(const char *title, const char *detail) { + fprintf(stderr, "[ota_ui] %s %s\n", title ? title : "", detail ? detail : ""); +} +void ota_ui_show_progress(const char *title, const char *detail, float progress01) { + fprintf(stderr, "[ota_ui] %s %s (%.0f%%)\n", title ? title : "", detail ? detail : "", + progress01 * 100.f); +} +void ota_ui_alert(const char *title, const char *line1, const char *line2) { + fprintf(stderr, "[ota_ui] alert: %s / %s / %s\n", title ? title : "", line1 ? line1 : "", + line2 ? line2 : ""); +} +void ota_ui_alert_error(const char *title, const char *friendly, const char *technical, + const char *installed_version) { + fprintf(stderr, "[ota_ui] error: %s / %s / %s / v%s\n", title ? title : "", + friendly ? friendly : "", technical ? technical : "", + installed_version ? installed_version : "?"); +} +void ota_ui_missing_game(void) { fprintf(stderr, "[ota_ui] missing game\n"); } +void ota_ui_shutdown(void) {} + +#endif diff --git a/ports/switch/ota-launcher/src/ota_unzip.c b/ports/switch/ota-launcher/src/ota_unzip.c new file mode 100644 index 00000000..72c72425 --- /dev/null +++ b/ports/switch/ota-launcher/src/ota_unzip.c @@ -0,0 +1,89 @@ +#include "ota_unzip.h" + +#include +#include + +#if defined(__SWITCH__) +#include +#include +#endif + +#if defined(__SWITCH__) +static int extract_member(ZZIP_DIR *dir, const char *member, const char *dest_path, char *err, + size_t err_len) { + ZZIP_FILE *zf = zzip_file_open(dir, member, O_RDONLY); + if (!zf) return 1; /* not found / cannot open */ + + FILE *out = fopen(dest_path, "wb"); + if (!out) { + zzip_file_close(zf); + if (err && err_len) snprintf(err, err_len, "fopen dest failed: %s", dest_path); + return -1; + } + + char buf[8192]; + zzip_ssize_t n; + while ((n = zzip_file_read(zf, buf, sizeof(buf))) > 0) { + if ((zzip_ssize_t)fwrite(buf, 1, (size_t)n, out) != n) { + fclose(out); + zzip_file_close(zf); + if (err && err_len) snprintf(err, err_len, "fwrite failed"); + return -1; + } + } + fclose(out); + zzip_file_close(zf); + if (n < 0) { + if (err && err_len) snprintf(err, err_len, "zzip_file_read failed"); + return -1; + } + return 0; +} +#endif + +int ota_unzip_extract_file(const char *zip_path, const char *member_name, const char *dest_path, + char *err, size_t err_len) { + if (!zip_path || !member_name || !dest_path) { + if (err && err_len) snprintf(err, err_len, "bad args"); + return -1; + } +#if defined(__SWITCH__) + zzip_error_t zerr = ZZIP_NO_ERROR; + ZZIP_DIR *dir = zzip_dir_open(zip_path, &zerr); + if (!dir) { + if (err && err_len) snprintf(err, err_len, "zzip_dir_open failed (%d): %s", (int)zerr, zip_path); + return -1; + } + + char alt1[192]; + char alt2[192]; + snprintf(alt1, sizeof(alt1), "switch/gen1recomp/%s", member_name); + snprintf(alt2, sizeof(alt2), "./%s", member_name); + + const char *candidates[] = {member_name, alt1, alt2, NULL}; + int rc = -1; + if (err && err_len) err[0] = '\0'; + for (int i = 0; candidates[i]; i++) { + int t = extract_member(dir, candidates[i], dest_path, err, err_len); + if (t == 0) { + rc = 0; + break; + } + if (t < 0) { + rc = -1; + break; + } + } + if (rc != 0 && err && err_len && !err[0]) { + snprintf(err, err_len, "member not found in zip: %s", member_name); + } + zzip_dir_close(dir); + return rc; +#else + (void)zip_path; + (void)member_name; + (void)dest_path; + if (err && err_len) snprintf(err, err_len, "ota_unzip only available on __SWITCH__"); + return -1; +#endif +} diff --git a/ports/switch/ota-launcher/third_party/font8x8_basic.h b/ports/switch/ota-launcher/third_party/font8x8_basic.h new file mode 100644 index 00000000..125cf165 --- /dev/null +++ b/ports/switch/ota-launcher/third_party/font8x8_basic.h @@ -0,0 +1,152 @@ +/** + * 8x8 monochrome bitmap fonts for rendering + * Author: Daniel Hepper + * + * License: Public Domain + * + * Based on: + * // Summary: font8x8.h + * // 8x8 monochrome bitmap fonts for rendering + * // + * // Author: + * // Marcel Sondaar + * // International Business Machines (public domain VGA fonts) + * // + * // License: + * // Public Domain + * + * Fetched from: http://dimensionalrift.homelinux.net/combuster/mos3/?p=viewsource&file=/modules/gfx/font8_8.asm + **/ + +// Constant: font8x8_basic +// Contains an 8x8 font map for unicode points U+0000 - U+007F (basic latin) +char font8x8_basic[128][8] = { + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0000 (nul) + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0001 + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0002 + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0003 + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0004 + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0005 + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0006 + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0007 + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0008 + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0009 + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+000A + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+000B + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+000C + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+000D + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+000E + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+000F + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0010 + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0011 + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0012 + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0013 + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0014 + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0015 + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0016 + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0017 + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0018 + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0019 + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+001A + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+001B + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+001C + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+001D + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+001E + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+001F + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0020 (space) + { 0x18, 0x3C, 0x3C, 0x18, 0x18, 0x00, 0x18, 0x00}, // U+0021 (!) + { 0x36, 0x36, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0022 (") + { 0x36, 0x36, 0x7F, 0x36, 0x7F, 0x36, 0x36, 0x00}, // U+0023 (#) + { 0x0C, 0x3E, 0x03, 0x1E, 0x30, 0x1F, 0x0C, 0x00}, // U+0024 ($) + { 0x00, 0x63, 0x33, 0x18, 0x0C, 0x66, 0x63, 0x00}, // U+0025 (%) + { 0x1C, 0x36, 0x1C, 0x6E, 0x3B, 0x33, 0x6E, 0x00}, // U+0026 (&) + { 0x06, 0x06, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0027 (') + { 0x18, 0x0C, 0x06, 0x06, 0x06, 0x0C, 0x18, 0x00}, // U+0028 (() + { 0x06, 0x0C, 0x18, 0x18, 0x18, 0x0C, 0x06, 0x00}, // U+0029 ()) + { 0x00, 0x66, 0x3C, 0xFF, 0x3C, 0x66, 0x00, 0x00}, // U+002A (*) + { 0x00, 0x0C, 0x0C, 0x3F, 0x0C, 0x0C, 0x00, 0x00}, // U+002B (+) + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x0C, 0x06}, // U+002C (,) + { 0x00, 0x00, 0x00, 0x3F, 0x00, 0x00, 0x00, 0x00}, // U+002D (-) + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x0C, 0x00}, // U+002E (.) + { 0x60, 0x30, 0x18, 0x0C, 0x06, 0x03, 0x01, 0x00}, // U+002F (/) + { 0x3E, 0x63, 0x73, 0x7B, 0x6F, 0x67, 0x3E, 0x00}, // U+0030 (0) + { 0x0C, 0x0E, 0x0C, 0x0C, 0x0C, 0x0C, 0x3F, 0x00}, // U+0031 (1) + { 0x1E, 0x33, 0x30, 0x1C, 0x06, 0x33, 0x3F, 0x00}, // U+0032 (2) + { 0x1E, 0x33, 0x30, 0x1C, 0x30, 0x33, 0x1E, 0x00}, // U+0033 (3) + { 0x38, 0x3C, 0x36, 0x33, 0x7F, 0x30, 0x78, 0x00}, // U+0034 (4) + { 0x3F, 0x03, 0x1F, 0x30, 0x30, 0x33, 0x1E, 0x00}, // U+0035 (5) + { 0x1C, 0x06, 0x03, 0x1F, 0x33, 0x33, 0x1E, 0x00}, // U+0036 (6) + { 0x3F, 0x33, 0x30, 0x18, 0x0C, 0x0C, 0x0C, 0x00}, // U+0037 (7) + { 0x1E, 0x33, 0x33, 0x1E, 0x33, 0x33, 0x1E, 0x00}, // U+0038 (8) + { 0x1E, 0x33, 0x33, 0x3E, 0x30, 0x18, 0x0E, 0x00}, // U+0039 (9) + { 0x00, 0x0C, 0x0C, 0x00, 0x00, 0x0C, 0x0C, 0x00}, // U+003A (:) + { 0x00, 0x0C, 0x0C, 0x00, 0x00, 0x0C, 0x0C, 0x06}, // U+003B (;) + { 0x18, 0x0C, 0x06, 0x03, 0x06, 0x0C, 0x18, 0x00}, // U+003C (<) + { 0x00, 0x00, 0x3F, 0x00, 0x00, 0x3F, 0x00, 0x00}, // U+003D (=) + { 0x06, 0x0C, 0x18, 0x30, 0x18, 0x0C, 0x06, 0x00}, // U+003E (>) + { 0x1E, 0x33, 0x30, 0x18, 0x0C, 0x00, 0x0C, 0x00}, // U+003F (?) + { 0x3E, 0x63, 0x7B, 0x7B, 0x7B, 0x03, 0x1E, 0x00}, // U+0040 (@) + { 0x0C, 0x1E, 0x33, 0x33, 0x3F, 0x33, 0x33, 0x00}, // U+0041 (A) + { 0x3F, 0x66, 0x66, 0x3E, 0x66, 0x66, 0x3F, 0x00}, // U+0042 (B) + { 0x3C, 0x66, 0x03, 0x03, 0x03, 0x66, 0x3C, 0x00}, // U+0043 (C) + { 0x1F, 0x36, 0x66, 0x66, 0x66, 0x36, 0x1F, 0x00}, // U+0044 (D) + { 0x7F, 0x46, 0x16, 0x1E, 0x16, 0x46, 0x7F, 0x00}, // U+0045 (E) + { 0x7F, 0x46, 0x16, 0x1E, 0x16, 0x06, 0x0F, 0x00}, // U+0046 (F) + { 0x3C, 0x66, 0x03, 0x03, 0x73, 0x66, 0x7C, 0x00}, // U+0047 (G) + { 0x33, 0x33, 0x33, 0x3F, 0x33, 0x33, 0x33, 0x00}, // U+0048 (H) + { 0x1E, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x1E, 0x00}, // U+0049 (I) + { 0x78, 0x30, 0x30, 0x30, 0x33, 0x33, 0x1E, 0x00}, // U+004A (J) + { 0x67, 0x66, 0x36, 0x1E, 0x36, 0x66, 0x67, 0x00}, // U+004B (K) + { 0x0F, 0x06, 0x06, 0x06, 0x46, 0x66, 0x7F, 0x00}, // U+004C (L) + { 0x63, 0x77, 0x7F, 0x7F, 0x6B, 0x63, 0x63, 0x00}, // U+004D (M) + { 0x63, 0x67, 0x6F, 0x7B, 0x73, 0x63, 0x63, 0x00}, // U+004E (N) + { 0x1C, 0x36, 0x63, 0x63, 0x63, 0x36, 0x1C, 0x00}, // U+004F (O) + { 0x3F, 0x66, 0x66, 0x3E, 0x06, 0x06, 0x0F, 0x00}, // U+0050 (P) + { 0x1E, 0x33, 0x33, 0x33, 0x3B, 0x1E, 0x38, 0x00}, // U+0051 (Q) + { 0x3F, 0x66, 0x66, 0x3E, 0x36, 0x66, 0x67, 0x00}, // U+0052 (R) + { 0x1E, 0x33, 0x07, 0x0E, 0x38, 0x33, 0x1E, 0x00}, // U+0053 (S) + { 0x3F, 0x2D, 0x0C, 0x0C, 0x0C, 0x0C, 0x1E, 0x00}, // U+0054 (T) + { 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x3F, 0x00}, // U+0055 (U) + { 0x33, 0x33, 0x33, 0x33, 0x33, 0x1E, 0x0C, 0x00}, // U+0056 (V) + { 0x63, 0x63, 0x63, 0x6B, 0x7F, 0x77, 0x63, 0x00}, // U+0057 (W) + { 0x63, 0x63, 0x36, 0x1C, 0x1C, 0x36, 0x63, 0x00}, // U+0058 (X) + { 0x33, 0x33, 0x33, 0x1E, 0x0C, 0x0C, 0x1E, 0x00}, // U+0059 (Y) + { 0x7F, 0x63, 0x31, 0x18, 0x4C, 0x66, 0x7F, 0x00}, // U+005A (Z) + { 0x1E, 0x06, 0x06, 0x06, 0x06, 0x06, 0x1E, 0x00}, // U+005B ([) + { 0x03, 0x06, 0x0C, 0x18, 0x30, 0x60, 0x40, 0x00}, // U+005C (\) + { 0x1E, 0x18, 0x18, 0x18, 0x18, 0x18, 0x1E, 0x00}, // U+005D (]) + { 0x08, 0x1C, 0x36, 0x63, 0x00, 0x00, 0x00, 0x00}, // U+005E (^) + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF}, // U+005F (_) + { 0x0C, 0x0C, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0060 (`) + { 0x00, 0x00, 0x1E, 0x30, 0x3E, 0x33, 0x6E, 0x00}, // U+0061 (a) + { 0x07, 0x06, 0x06, 0x3E, 0x66, 0x66, 0x3B, 0x00}, // U+0062 (b) + { 0x00, 0x00, 0x1E, 0x33, 0x03, 0x33, 0x1E, 0x00}, // U+0063 (c) + { 0x38, 0x30, 0x30, 0x3e, 0x33, 0x33, 0x6E, 0x00}, // U+0064 (d) + { 0x00, 0x00, 0x1E, 0x33, 0x3f, 0x03, 0x1E, 0x00}, // U+0065 (e) + { 0x1C, 0x36, 0x06, 0x0f, 0x06, 0x06, 0x0F, 0x00}, // U+0066 (f) + { 0x00, 0x00, 0x6E, 0x33, 0x33, 0x3E, 0x30, 0x1F}, // U+0067 (g) + { 0x07, 0x06, 0x36, 0x6E, 0x66, 0x66, 0x67, 0x00}, // U+0068 (h) + { 0x0C, 0x00, 0x0E, 0x0C, 0x0C, 0x0C, 0x1E, 0x00}, // U+0069 (i) + { 0x30, 0x00, 0x30, 0x30, 0x30, 0x33, 0x33, 0x1E}, // U+006A (j) + { 0x07, 0x06, 0x66, 0x36, 0x1E, 0x36, 0x67, 0x00}, // U+006B (k) + { 0x0E, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x1E, 0x00}, // U+006C (l) + { 0x00, 0x00, 0x33, 0x7F, 0x7F, 0x6B, 0x63, 0x00}, // U+006D (m) + { 0x00, 0x00, 0x1F, 0x33, 0x33, 0x33, 0x33, 0x00}, // U+006E (n) + { 0x00, 0x00, 0x1E, 0x33, 0x33, 0x33, 0x1E, 0x00}, // U+006F (o) + { 0x00, 0x00, 0x3B, 0x66, 0x66, 0x3E, 0x06, 0x0F}, // U+0070 (p) + { 0x00, 0x00, 0x6E, 0x33, 0x33, 0x3E, 0x30, 0x78}, // U+0071 (q) + { 0x00, 0x00, 0x3B, 0x6E, 0x66, 0x06, 0x0F, 0x00}, // U+0072 (r) + { 0x00, 0x00, 0x3E, 0x03, 0x1E, 0x30, 0x1F, 0x00}, // U+0073 (s) + { 0x08, 0x0C, 0x3E, 0x0C, 0x0C, 0x2C, 0x18, 0x00}, // U+0074 (t) + { 0x00, 0x00, 0x33, 0x33, 0x33, 0x33, 0x6E, 0x00}, // U+0075 (u) + { 0x00, 0x00, 0x33, 0x33, 0x33, 0x1E, 0x0C, 0x00}, // U+0076 (v) + { 0x00, 0x00, 0x63, 0x6B, 0x7F, 0x7F, 0x36, 0x00}, // U+0077 (w) + { 0x00, 0x00, 0x63, 0x36, 0x1C, 0x36, 0x63, 0x00}, // U+0078 (x) + { 0x00, 0x00, 0x33, 0x33, 0x33, 0x3E, 0x30, 0x1F}, // U+0079 (y) + { 0x00, 0x00, 0x3F, 0x19, 0x0C, 0x26, 0x3F, 0x00}, // U+007A (z) + { 0x38, 0x0C, 0x0C, 0x07, 0x0C, 0x0C, 0x38, 0x00}, // U+007B ({) + { 0x18, 0x18, 0x18, 0x00, 0x18, 0x18, 0x18, 0x00}, // U+007C (|) + { 0x07, 0x0C, 0x0C, 0x38, 0x0C, 0x0C, 0x07, 0x00}, // U+007D (}) + { 0x6E, 0x3B, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+007E (~) + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00} // U+007F +}; diff --git a/scripts/build_switch.sh b/scripts/build_switch.sh index 9bb2544f..f9c62e05 100755 --- a/scripts/build_switch.sh +++ b/scripts/build_switch.sh @@ -19,19 +19,22 @@ # (gen1recomp.nro + game.love side by side). Requires the pin # (run --fetch first, or combine --fetch --loose). # -# --fused Build a single gen1recomp--switch.nro via nacptool+elf2nro, -# then pack dist/switch/gen1recomp--switch.zip (SD-ready -# tree under switch/gen1recomp/). Uses native tools (PATH or -# $DEVKITPRO/tools/bin) first; else Docker from -# scripts/switch/dkp-docker.image (override GEN1_DKP_IMAGE). -# Requires the pin (run --fetch or combine). GitHub Releases -# publish the zip only; the versioned .nro stays local / PR CI. +# --fused Build fused gen1recomp--switch.nro (game in romfs), native +# OTA launcher NRO, and dual-NRO SD zip (gen1recomp.nro launcher + +# gen1recomp-game.nro under switch/gen1recomp/). The same +# gen1recomp--switch.zip is the OTA download asset. +# Requires DEVKITPRO + switch-dev; OTA launcher via native packages +# (install_devkitpro_deps.sh) or Docker. +# Uses native tools (PATH or $DEVKITPRO/tools/bin) first; else Docker +# from scripts/switch/dkp-docker.image (override GEN1_DKP_IMAGE). +# Requires the pin (run --fetch or combine). GitHub Releases publish +# the zip only; versioned .nro files stay local / PR CI. # # Combinable: --fetch alone, or --fetch with --loose / --fused. -# XOR: --loose and --fused cannot be used together. +# XOR: --loose and --fused cannot be combined with each other. # # Non-goals (never done by this script): -# MTP push, ROM install, dkp-pacman auto-install. +# MTP push, ROM install, dkp-pacman auto-install (use install_devkitpro_deps.sh). set -euo pipefail @@ -49,7 +52,7 @@ say() { printf '\033[1;32m==>\033[0m %s\n' "$*" >&2; } fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; } usage() { - sed -n '2,34p' "$0" | sed 's/^# \{0,1\}//' + sed -n '2,38p' "$0" | sed 's/^# \{0,1\}//' } while [ $# -gt 0 ]; do @@ -129,11 +132,22 @@ if [ "$LOOSE" -eq 1 ]; then fi if [ "$FUSED" -eq 1 ]; then + # shellcheck source=scripts/switch/common.sh + . "$ROOT/scripts/switch/common.sh" + preflight_fused_build + GAME_LOVE="$(pack_game_love)" OUT_NRO="$DIST/gen1recomp-${VERSION}-switch.nro" OUT_ZIP="$DIST/gen1recomp-${VERSION}-switch.zip" "$ROOT/scripts/switch/build_fused.sh" "$GAME_LOVE" "$VERSION" "$OUT_NRO" - "$ROOT/scripts/switch/pack_sd_zip.sh" "$OUT_NRO" "$VERSION" "$OUT_ZIP" + + LAUNCHER_NRO="$DIST/gen1recomp-${VERSION}-launcher.nro" + say "building native OTA launcher" + "$ROOT/scripts/switch/build_ota_launcher.sh" "$LAUNCHER_NRO" "$VERSION" + say "packing dual-NRO SD zip (also the OTA download asset)" + "$ROOT/scripts/switch/pack_sd_zip.sh" "$OUT_NRO" "$VERSION" "$OUT_ZIP" "$LAUNCHER_NRO" + cp "$OUT_NRO" "$DIST/gen1recomp-${VERSION}-game.nro" + cp "$WORK/build-info.json" "$DIST/gen1recomp-${VERSION}-build-info.json" say "done. See $DIST/" exit 0 diff --git a/scripts/switch/bake_ota_logo.py b/scripts/switch/bake_ota_logo.py new file mode 100644 index 00000000..bbfc989f --- /dev/null +++ b/scripts/switch/bake_ota_logo.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +"""Bake assets/logo/logo.png into a pre-scaled RGBA blob for the Switch OTA UI. + +Output format (little-endian): + uint32 width, uint32 height, then width*height RGBA8888 pixels. + +Regenerate after editing the source PNG: + python3 scripts/switch/bake_ota_logo.py + +Uses Pillow when available (see scripts/setup.sh). On macOS without Pillow, +falls back to sips + BMP export. +""" + +from __future__ import annotations + +import math +import platform +import struct +import subprocess +import sys +import tempfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +DEFAULT_SRC = ROOT / "assets/logo/logo.png" +DEFAULT_OUT = ROOT / "ports/switch/assets/logo.rgba" +DEFAULT_MAX_W = 320 +DEFAULT_MAX_H = 90 + + +def fit_size(src_w: int, src_h: int, max_w: int, max_h: int) -> tuple[int, int]: + scale = min(max_w / src_w, max_h / src_h, 1.0) + w = max(1, int(math.floor(src_w * scale + 0.5))) + h = max(1, int(math.floor(src_h * scale + 0.5))) + return w, h + + +def read_png_size(path: Path) -> tuple[int, int]: + with path.open("rb") as fp: + sig = fp.read(8) + if sig != b"\x89PNG\r\n\x1a\n": + raise SystemExit(f"not a PNG: {path}") + while True: + raw = fp.read(8) + if len(raw) < 8: + raise SystemExit(f"truncated PNG: {path}") + length, ctype = struct.unpack(">I4s", raw) + data = fp.read(length) + fp.read(4) + if ctype == b"IHDR": + return struct.unpack(">II", data[:8]) + if ctype == b"IEND": + break + raise SystemExit(f"missing IHDR in PNG: {path}") + + +def read_bmp_rgba(path: Path) -> tuple[int, int, bytes]: + data = path.read_bytes() + if len(data) < 54 or data[:2] != b"BM": + raise SystemExit(f"not a BMP: {path}") + pixel_offset = struct.unpack_from(" tuple[int, int, bytes]: + if platform.system() != "Darwin": + raise SystemExit( + "Pillow is required to bake the OTA logo on this platform. " + "Run scripts/setup.sh or: pip install pillow" + ) + + with tempfile.TemporaryDirectory() as tmp: + tmp_dir = Path(tmp) + scaled_png = tmp_dir / "scaled.png" + scaled_bmp = tmp_dir / "scaled.bmp" + subprocess.run( + ["sips", "-z", str(out_h), str(out_w), str(src), "--out", str(scaled_png)], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + ) + subprocess.run( + ["sips", "-s", "format", "bmp", str(scaled_png), "--out", str(scaled_bmp)], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + ) + return read_bmp_rgba(scaled_bmp) + + +def bake_with_pillow(src: Path, out_w: int, out_h: int) -> tuple[int, int, bytes]: + from PIL import Image + + with Image.open(src) as image: + image = image.convert("RGBA") + if image.size != (out_w, out_h): + image = image.resize((out_w, out_h), Image.LANCZOS) + w, h = image.size + return w, h, image.tobytes() + + +def bake(src: Path, out: Path, max_w: int, max_h: int) -> tuple[int, int]: + if not src.is_file(): + raise SystemExit(f"missing source PNG: {src}") + + src_w, src_h = read_png_size(src) + out_w, out_h = fit_size(src_w, src_h, max_w, max_h) + + try: + from PIL import Image # noqa: F401 + + w, h, pixels = bake_with_pillow(src, out_w, out_h) + except ImportError: + w, h, pixels = bake_with_sips(src, out_w, out_h) + + if len(pixels) != w * h * 4: + raise SystemExit(f"unexpected pixel buffer size for {w}x{h}") + + out.parent.mkdir(parents=True, exist_ok=True) + with out.open("wb") as fp: + fp.write(struct.pack(" int: + src = Path(argv[1]) if len(argv) > 1 else DEFAULT_SRC + out = Path(argv[2]) if len(argv) > 2 else DEFAULT_OUT + max_w = int(argv[3]) if len(argv) > 3 else DEFAULT_MAX_W + max_h = int(argv[4]) if len(argv) > 4 else DEFAULT_MAX_H + + w, h = bake(src, out, max_w, max_h) + print(f"wrote {out} ({w}x{h}, {8 + w * h * 4} bytes)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/scripts/switch/build_fused.sh b/scripts/switch/build_fused.sh index 26024386..b6dbf5e3 100755 --- a/scripts/switch/build_fused.sh +++ b/scripts/switch/build_fused.sh @@ -15,7 +15,7 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" LOVE_NX_TAG="11.5-nx1" LOVE_NX_DIR="$ROOT/.bazinga/love-nx/$LOVE_NX_TAG" LOVE_ELF="$LOVE_NX_DIR/love.elf" -ICON="$ROOT/assets/switch/icon.jpg" +ICON="$ROOT/ports/switch/assets/icon.jpg" APP_NAME="gen1recomp" APP_AUTHOR="bryanthaboi, port by andrewqsantos" DKP_IMAGE_FILE="$ROOT/scripts/switch/dkp-docker.image" @@ -93,7 +93,7 @@ run_fused_docker() { set -euo pipefail nacptool --create '$APP_NAME' '$APP_AUTHOR' '$VERSION' /work/control.nacp elf2nro /src/.bazinga/love-nx/$LOVE_NX_TAG/love.elf /out/$out_base \ - --icon=/src/assets/switch/icon.jpg \ + --icon=/src/ports/switch/assets/icon.jpg \ --nacp=/work/control.nacp \ --romfsdir=/work/romfs " diff --git a/scripts/switch/build_ota_launcher.sh b/scripts/switch/build_ota_launcher.sh new file mode 100755 index 00000000..4de830fb --- /dev/null +++ b/scripts/switch/build_ota_launcher.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# Build the native Switch OTA launcher NRO (DEVKITPRO or Docker). +# +# Usage: scripts/switch/build_ota_launcher.sh [OUT_NRO] [VERSION] +# +# Default OUT_NRO: dist/switch/gen1recomp-launcher.nro +# VERSION (optional X.Y.Z) is written into the NACP so hbmenu shows the +# same release as the fused game; defaults to 0.0.0 when omitted. +# Host-only protocol check: always runs `make host-test` first. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=common.sh +. "$SCRIPT_DIR/common.sh" + +LAUNCHER_DIR="$ROOT/ports/switch/ota-launcher" +OUT_NRO="${1:-$ROOT/dist/switch/gen1recomp-launcher.nro}" +APP_VERSION="${2:-${GEN1_LAUNCHER_VERSION:-0.0.0}}" +DKP_IMAGE_FILE="$ROOT/scripts/switch/dkp-docker.image" + +[ -d "$LAUNCHER_DIR" ] || fail "missing $LAUNCHER_DIR" +[ -f "$LAUNCHER_DIR/Makefile" ] || fail "missing Makefile" + +if ! devkitpro_ready; then + fail_missing_devkitpro +fi + +say "host-test ota_protocol (no DEVKITPRO required)" +make -C "$LAUNCHER_DIR" host-test + +mkdir -p "$(dirname "$OUT_NRO")" + +if ota_launcher_deps_ready; then + say "building launcher NRO with native DEVKITPRO (NACP $APP_VERSION)" + make -C "$LAUNCHER_DIR" clean || true + if ! make -C "$LAUNCHER_DIR" all APP_VERSION="$APP_VERSION"; then + fail_ota_launcher_toolchain + fi + cp "$LAUNCHER_DIR/gen1recomp.nro" "$OUT_NRO" + say "wrote $OUT_NRO" + exit 0 +fi + +resolve_dkp_image() { + if [ -n "${GEN1_DKP_IMAGE:-}" ]; then + printf '%s' "$GEN1_DKP_IMAGE" + return 0 + fi + [ -f "$DKP_IMAGE_FILE" ] || fail "missing Docker image pin: $DKP_IMAGE_FILE" + local line + line="$(grep -v '^[[:space:]]*#' "$DKP_IMAGE_FILE" | grep -v '^[[:space:]]*$' | head -1 || true)" + [ -n "$line" ] || fail "empty Docker image pin: $DKP_IMAGE_FILE" + printf '%s' "$line" +} + +if command -v docker >/dev/null 2>&1; then + image="$(resolve_dkp_image)" + say "building launcher NRO via Docker ($image, NACP $APP_VERSION)" + if ! docker run --rm \ + -v "$ROOT:/work" \ + -w /work/ports/switch/ota-launcher \ + -e "APP_VERSION=$APP_VERSION" \ + "$image" \ + bash -lc 'pacman -Sy --noconfirm switch-curl switch-mbedtls switch-zlib switch-zziplib >/dev/null 2>&1 || true; make clean; make all APP_VERSION="$APP_VERSION"'; then + fail_ota_launcher_toolchain + fi + cp "$LAUNCHER_DIR/gen1recomp.nro" "$OUT_NRO" + say "wrote $OUT_NRO" + exit 0 +fi + +fail_missing_ota_deps diff --git a/scripts/switch/common.sh b/scripts/switch/common.sh index 2ba59e1e..eb4cfb15 100644 --- a/scripts/switch/common.sh +++ b/scripts/switch/common.sh @@ -8,7 +8,8 @@ if [ -z "${ROOT:-}" ]; then fi export ROOT -say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; } +# Progress on stderr so command-substitution (e.g. pack_game_love) stays a bare path. +say() { printf '\033[1;32m==>\033[0m %s\n' "$*" >&2; } fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; } # Print SHA-256 hex digest of PATH. Prefers shasum, falls back to sha256sum. @@ -38,16 +39,137 @@ fail_need_fetch() { fail "${1:-missing pinned love-nx} — run: scripts/build_switch.sh --fetch" } +# OTA launcher native build packages (shared with install_devkitpro_deps.sh). +OTA_LAUNCHER_PKGS=(switch-curl switch-mbedtls switch-zlib switch-zziplib) + +ota_launcher_deps_ready() { + command -v dkp-pacman >/dev/null 2>&1 || return 1 + local pkg + for pkg in "${OTA_LAUNCHER_PKGS[@]}"; do + dkp-pacman -Q "$pkg" >/dev/null 2>&1 || return 1 + done +} + +# Space-separated list of missing OTA launcher packages (empty when all installed). +ota_launcher_missing_pkgs() { + command -v dkp-pacman >/dev/null 2>&1 || return 0 + local pkg missing="" + for pkg in "${OTA_LAUNCHER_PKGS[@]}"; do + if ! dkp-pacman -Q "$pkg" >/dev/null 2>&1; then + missing="${missing} ${pkg}" + fi + done + printf '%s' "$missing" +} + +devkitpro_ready() { + [ -n "${DEVKITPRO:-}" ] && [ -f "$DEVKITPRO/libnx/switch_rules" ] +} + +fail_missing_devkitpro() { + fail "$(cat <<'EOF' +--fused requires DEVKITPRO (release builds ship the OTA launcher for in-console updates). + +One-time setup on the Mac self-hosted runner (or your dev machine): + + 1. Install devkitPro pacman: https://devkitpro.org/wiki/devkitPro_pacman + 2. Install Switch tools: sudo dkp-pacman -S switch-dev + 3. Export DEVKITPRO (typical macOS): + export DEVKITPRO=/opt/devkitpro + export PATH="$DEVKITPRO/tools/bin:$PATH" + 4. OTA launcher toolchain (native or Docker — pick one): + bash scripts/switch/install_devkitpro_deps.sh + or install Docker (same pin as fused builds) + +Then re-run: scripts/build_switch.sh --fetch --fused + +See docs/switch-build.md (Runner provisioning). +EOF +)" +} + +fail_missing_ota_deps() { + local missing + missing="$(ota_launcher_missing_pkgs)" + fail "$(cat </dev/null 2>&1 && command -v elf2nro >/dev/null 2>&1; then + game_ok=1 + elif command -v docker >/dev/null 2>&1; then + game_ok=1 + fi + if [ "$game_ok" -eq 0 ]; then + fail_fused_toolchain + fi + + if ota_launcher_deps_ready; then + return 0 + fi + if command -v docker >/dev/null 2>&1; then + return 0 + fi + fail_missing_ota_deps +} + # Fused mode needs nacptool/elf2nro (native or Docker). Rich multi-OS hint. fail_fused_toolchain() { fail "$(cat <<'EOF' -fused packaging needs nacptool and elf2nro (devkitPro switch-dev). +fused packaging needs DEVKITPRO, nacptool, and elf2nro (devkitPro switch-dev). Install options: - macOS: https://devkitpro.org/wiki/devkitPro_pacman (installer / pacman) + macOS: https://devkitpro.org/wiki/devkitPro_pacman + sudo dkp-pacman -S switch-dev + OTA launcher (native or Docker): + bash scripts/switch/install_devkitpro_deps.sh + or install Docker Linux: https://devkitpro.org/wiki/devkitPro_pacman Windows: Git Bash / MSYS2 / WSL with devkitPro tools on PATH - Docker: install Docker; build_fused.sh falls back to the pinned image + Docker: install Docker; fused game and OTA launcher can build in-container + +Export DEVKITPRO (typical): export DEVKITPRO=/opt/devkitpro See docs/switch-build.md for details. EOF diff --git a/scripts/switch/install_devkitpro_deps.sh b/scripts/switch/install_devkitpro_deps.sh new file mode 100755 index 00000000..64bfc2d5 --- /dev/null +++ b/scripts/switch/install_devkitpro_deps.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# Install DEVKITPRO Switch packages needed for the native OTA launcher. +# Run this in Terminal.app / iTerm (needs your macOS admin password): +# +# bash scripts/switch/install_devkitpro_deps.sh +# +# Optional: speeds up local native OTA launcher builds. CI/release can fall back +# to Docker when these packages are not installed. +# +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=common.sh +. "$SCRIPT_DIR/common.sh" + +if [ "$(id -u)" -ne 0 ]; then + echo "==> re-running with sudo…" + exec sudo -E bash "$0" "$@" +fi + +export DEVKITPRO="${DEVKITPRO:-/opt/devkitpro}" +export PATH="/usr/local/bin:$DEVKITPRO/pacman/bin:$DEVKITPRO/tools/bin:$PATH" + +if [ -f /etc/profile.d/devkit-env.sh ]; then + # shellcheck disable=SC1091 + . /etc/profile.d/devkit-env.sh +fi + +if ! command -v dkp-pacman >/dev/null 2>&1; then + echo "error: dkp-pacman not found. Install the pkg from:" >&2 + echo " https://github.com/devkitPro/pacman/releases/latest" >&2 + exit 1 +fi + +if ! dkp-pacman -Q switch-dev >/dev/null 2>&1; then + echo "error: switch-dev is not installed (needed for nacptool/elf2nro)." >&2 + echo " sudo dkp-pacman -S switch-dev" >&2 + echo "Then re-run: bash scripts/switch/install_devkitpro_deps.sh" >&2 + exit 1 +fi + +echo "==> DEVKITPRO=$DEVKITPRO" +# Named packages only (avoid interactive switch-dev group member prompt). +# ZIP reading uses switch-zziplib (dkp has no minizip port for Switch). +dkp-pacman -Sy --noconfirm --needed "${OTA_LAUNCHER_PKGS[@]}" + +echo "==> installed packages:" +dkp-pacman -Q | grep -E 'switch-(curl|mbedtls|zlib|zziplib|tools)|libnx|devkitA64' || true + +# Ensure zsh/bash sessions see DEVKITPRO +MARKER="# gen1recomp-devkitpro" +for rc in /Users/*/.zshrc /Users/*/.bash_profile; do + [ -f "$rc" ] || continue + if ! grep -q "$MARKER" "$rc" 2>/dev/null; then + { + echo "" + echo "$MARKER" + echo "export DEVKITPRO=/opt/devkitpro" + echo 'export PATH="$DEVKITPRO/tools/bin:$DEVKITPRO/devkitA64/bin:$PATH"' + } >> "$rc" + echo "==> appended DEVKITPRO exports to $rc" + fi +done + +echo "==> done. Open a new shell, then:" +echo " export DEVKITPRO=/opt/devkitpro" +echo " scripts/switch/build_ota_launcher.sh" diff --git a/scripts/switch/ota_launcher.manifest b/scripts/switch/ota_launcher.manifest new file mode 100644 index 00000000..79409299 --- /dev/null +++ b/scripts/switch/ota_launcher.manifest @@ -0,0 +1,16 @@ +# Switch OTA launcher packaging manifest +# +# Release / SD tree MUST include the native OTA launcher NRO as the hbmenu +# entrypoint. The LÖVE fused game NRO is loaded via envSetNextLoad after the +# update check. Self-update of the launcher itself is out of scope for v1. +# +# OTA downloads the install zip. +# +# Paths relative to microSD root: + +ENTRY_NRO=switch/gen1recomp/gen1recomp.nro +GAME_NRO=switch/gen1recomp/gen1recomp-game.nro +SAVE_DIR=switch/gen1recomp/pokemon-love2d +OTA_ASSET_GLOB=gen1recomp-*-switch.zip +REQUIRE_SHA256SUMS=1 +OTA_ENABLED=1 diff --git a/scripts/switch/pack_sd_zip.sh b/scripts/switch/pack_sd_zip.sh index 8f76cac0..6e7d7a7f 100755 --- a/scripts/switch/pack_sd_zip.sh +++ b/scripts/switch/pack_sd_zip.sh @@ -2,16 +2,18 @@ # Pack a Switch SD-ready zip: extract at microSD root (merge-safe update). # # Usage: -# scripts/switch/pack_sd_zip.sh NRO_PATH VERSION OUT_ZIP +# scripts/switch/pack_sd_zip.sh GAME_NRO VERSION OUT_ZIP [LAUNCHER_NRO] # # Layout inside the zip (SD root): -# switch/gen1recomp/gen1recomp.nro +# switch/gen1recomp/gen1recomp.nro (launcher if LAUNCHER_NRO set, else game) +# switch/gen1recomp/gen1recomp-game.nro (only when LAUNCHER_NRO set) +# switch/gen1recomp/version.txt # switch/gen1recomp/INSTALL.txt # switch/gen1recomp/pokemon-love2d/imports/.../README.txt # switch/gen1recomp/pokemon-love2d/exports/.../README.txt # # Does not ship ROMs, saves, or mods. Re-extracting merges over an existing -# install and only overwrites the NRO + these text placeholders — keep +# install and only overwrites the NRO(s) + these text placeholders — keep # pokemon-love2d/ to preserve progress. set -euo pipefail @@ -23,11 +25,15 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" NRO_PATH="${1:-}" VERSION="${2:-}" OUT_ZIP="${3:-}" +LAUNCHER_NRO="${4:-}" [ -n "$NRO_PATH" ] && [ -n "$VERSION" ] && [ -n "$OUT_ZIP" ] \ - || fail "usage: scripts/switch/pack_sd_zip.sh NRO_PATH VERSION OUT_ZIP" + || fail "usage: scripts/switch/pack_sd_zip.sh GAME_NRO VERSION OUT_ZIP [LAUNCHER_NRO]" -[ -f "$NRO_PATH" ] || fail "missing NRO at $NRO_PATH" +[ -f "$NRO_PATH" ] || fail "missing game NRO at $NRO_PATH" +if [ -n "$LAUNCHER_NRO" ]; then + [ -f "$LAUNCHER_NRO" ] || fail "missing launcher NRO at $LAUNCHER_NRO" +fi command -v zip >/dev/null 2>&1 || fail "need zip on PATH" @@ -45,7 +51,15 @@ trap cleanup EXIT APP_DIR="$STAGE/switch/gen1recomp" SAVE_ROOT="$APP_DIR/pokemon-love2d" mkdir -p "$APP_DIR" -cp "$NRO_PATH" "$APP_DIR/gen1recomp.nro" + +if [ -n "$LAUNCHER_NRO" ]; then + LAUNCHER_NRO="$(cd "$(dirname "$LAUNCHER_NRO")" && pwd)/$(basename "$LAUNCHER_NRO")" + cp "$LAUNCHER_NRO" "$APP_DIR/gen1recomp.nro" + cp "$NRO_PATH" "$APP_DIR/gen1recomp-game.nro" +else + cp "$NRO_PATH" "$APP_DIR/gen1recomp.nro" +fi +printf '%s\n' "$VERSION" > "$APP_DIR/version.txt" cat > "$APP_DIR/INSTALL.txt" </dev/null || unzip -l "$OUT_ZIP")" printf '%s\n' "$LISTING" | grep -q 'switch/gen1recomp/gen1recomp.nro' \ || fail "zip missing switch/gen1recomp/gen1recomp.nro" +printf '%s\n' "$LISTING" | grep -Fq 'switch/gen1recomp/version.txt' \ + || fail "zip missing switch/gen1recomp/version.txt" +if [ -n "$LAUNCHER_NRO" ]; then + printf '%s\n' "$LISTING" | grep -Fq 'switch/gen1recomp/gen1recomp-game.nro' \ + || fail "zip missing switch/gen1recomp/gen1recomp-game.nro (OTA dual-NRO layout)" +fi REQUIRED=( "switch/gen1recomp/INSTALL.txt" + "switch/gen1recomp/version.txt" "switch/gen1recomp/pokemon-love2d/imports/README.txt" "switch/gen1recomp/pokemon-love2d/imports/mods/README.txt" "switch/gen1recomp/pokemon-love2d/imports/saves/red/README.txt" diff --git a/scripts/switch/selftest_build_switch.sh b/scripts/switch/selftest_build_switch.sh index 7377c5d9..99928faf 100755 --- a/scripts/switch/selftest_build_switch.sh +++ b/scripts/switch/selftest_build_switch.sh @@ -51,10 +51,11 @@ MISSING="" printf '%s' "$HELP_LC" | grep -q 'fetch' || MISSING="${MISSING} fetch" printf '%s' "$HELP_LC" | grep -q 'loose' || MISSING="${MISSING} loose" printf '%s' "$HELP_LC" | grep -q 'fused' || MISSING="${MISSING} fused" +printf '%s' "$HELP_LC" | grep -Eq 'devkitpro' || MISSING="${MISSING} DEVKITPRO" printf '%s' "$HELP_LC" | grep -Eq 'auto-download|downloads' || MISSING="${MISSING} auto-download" printf '%s' "$HELP_LC" | grep -Eq 'non-goal|does not|never' || MISSING="${MISSING} non-goals" if [ -z "$MISSING" ]; then - ok "build_switch.sh --help mentions fetch, loose, fused (+ auto-download/non-goals)" + ok "build_switch.sh --help mentions fetch, loose, fused, DEVKITPRO (+ auto-download/non-goals)" else bad "build_switch.sh --help missing:$MISSING" fi @@ -180,7 +181,41 @@ else fi # --------------------------------------------------------------------------- -# 5c. fail_fused_toolchain mentions docs/switch-build.md +# 5c2. fail_missing_devkitpro cites install_devkitpro_deps.sh +# --------------------------------------------------------------------------- +MDK_ERR="$STAGING/missing-dkp.err" +MDK_RC=0 +( + . "$SCRIPT_DIR/common.sh" + fail_missing_devkitpro +) >"$STAGING/missing-dkp.out" 2>"$MDK_ERR" || MDK_RC=$? + +if [ "$MDK_RC" -ne 0 ] \ + && grep -q 'install_devkitpro_deps.sh' "$MDK_ERR" \ + && grep -q 'DEVKITPRO' "$MDK_ERR" \ + && grep -q 'switch-dev' "$MDK_ERR"; then + ok "fail_missing_devkitpro cites DEVKITPRO + switch-dev + install_devkitpro_deps.sh" +else + bad "fail_missing_devkitpro should cite setup steps (rc=$MDK_RC err=$(cat "$MDK_ERR"))" +fi + +OTA_ERR="$STAGING/missing-ota.err" +OTA_RC=0 +( + . "$SCRIPT_DIR/common.sh" + fail_missing_ota_deps +) >"$STAGING/missing-ota.out" 2>"$OTA_ERR" || OTA_RC=$? + +if [ "$OTA_RC" -ne 0 ] \ + && grep -q 'install_devkitpro_deps.sh' "$OTA_ERR" \ + && grep -qi 'docker' "$OTA_ERR"; then + ok "fail_missing_ota_deps cites native or Docker options" +else + bad "fail_missing_ota_deps should cite native + Docker (rc=$OTA_RC err=$(cat "$OTA_ERR"))" +fi + +# --------------------------------------------------------------------------- +# 5d. fail_fused_toolchain mentions docs/switch-build.md # --------------------------------------------------------------------------- FT_ERR="$STAGING/fused-toolchain.err" FT_RC=0 @@ -228,6 +263,7 @@ ZIP_LIST="$(unzip -Z1 "$FAKE_ZIP" 2>/dev/null || unzip -l "$FAKE_ZIP")" PACK_MISSING="" for rel in \ "switch/gen1recomp/gen1recomp.nro" \ + "switch/gen1recomp/version.txt" \ "switch/gen1recomp/INSTALL.txt" \ "switch/gen1recomp/pokemon-love2d/imports/README.txt" \ "switch/gen1recomp/pokemon-love2d/imports/mods/README.txt" \ @@ -311,6 +347,94 @@ else bad "pack_sd_zip.sh merge update lost user data or failed to replace NRO" fi +# --------------------------------------------------------------------------- +# 7. Dual-NRO OTA layout (launcher + game) when 4th arg set +# --------------------------------------------------------------------------- +FAKE_LAUNCHER="$STAGING/fake-launcher.nro" +FAKE_GAME="$STAGING/fake-game.nro" +printf 'fake-launcher\n' > "$FAKE_LAUNCHER" +printf 'fake-game\n' > "$FAKE_GAME" +OTA_ZIP="$STAGING/gen1recomp-0.0.2-ota-layout-switch.zip" +OTA_RC=0 +"$ROOT/scripts/switch/pack_sd_zip.sh" "$FAKE_GAME" "0.0.2" "$OTA_ZIP" "$FAKE_LAUNCHER" \ + >"$STAGING/ota-pack.out" 2>"$STAGING/ota-pack.err" || OTA_RC=$? +OTA_LIST="$(unzip -Z1 "$OTA_ZIP" 2>/dev/null || true)" +if [ "$OTA_RC" -eq 0 ] \ + && printf '%s\n' "$OTA_LIST" | grep -Fq 'switch/gen1recomp/gen1recomp.nro' \ + && printf '%s\n' "$OTA_LIST" | grep -Fq 'switch/gen1recomp/gen1recomp-game.nro' \ + && printf '%s\n' "$OTA_LIST" | grep -Fq 'switch/gen1recomp/version.txt' +then + EXTRACT_OTA="$STAGING/extract-ota" + rm -rf "$EXTRACT_OTA" + mkdir -p "$EXTRACT_OTA" + unzip -q "$OTA_ZIP" -d "$EXTRACT_OTA" + DUAL_OK=1 + cmp -s "$FAKE_LAUNCHER" "$EXTRACT_OTA/switch/gen1recomp/gen1recomp.nro" || DUAL_OK=0 + cmp -s "$FAKE_GAME" "$EXTRACT_OTA/switch/gen1recomp/gen1recomp-game.nro" || DUAL_OK=0 + [ "$(cat "$EXTRACT_OTA/switch/gen1recomp/version.txt")" = "0.0.2" ] || DUAL_OK=0 + if [ "$DUAL_OK" -eq 1 ]; then + ok "pack_sd_zip.sh dual-NRO OTA layout (launcher + game + version.txt)" + else + bad "pack_sd_zip.sh dual-NRO bytes/version mismatch" + fi +else + bad "pack_sd_zip.sh dual-NRO layout failed (rc=$OTA_RC err=$(cat "$STAGING/ota-pack.err"))" +fi + +MANIFEST="$ROOT/scripts/switch/ota_launcher.manifest" +if [ -f "$MANIFEST" ] && grep -q '^OTA_ENABLED=1$' "$MANIFEST" \ + && grep -q 'ENTRY_NRO=switch/gen1recomp/gen1recomp.nro' "$MANIFEST" \ + && grep -q 'GAME_NRO=switch/gen1recomp/gen1recomp-game.nro' "$MANIFEST" +then + ok "ota_launcher.manifest requires dual-NRO when OTA_ENABLED=1" +else + bad "ota_launcher.manifest missing OTA dual-NRO requirements" +fi + +if [ -f "$ROOT/scripts/switch/build_ota_launcher.sh" ] \ + && grep -q 'ota_launcher_deps_ready' "$ROOT/scripts/switch/build_ota_launcher.sh" \ + && grep -q 'fail_missing_devkitpro' "$ROOT/scripts/switch/build_ota_launcher.sh" \ + && grep -q 'preflight_fused_build' "$ROOT/scripts/build_switch.sh" \ + && [ -f "$ROOT/ports/switch/ota-launcher/Makefile" ] +then + ok "native OTA launcher sources + build_ota_launcher.sh present" +else + bad "missing native OTA launcher tree or build script" +fi + +# --------------------------------------------------------------------------- +# 8. Unified OTA asset = dual-NRO SD zip (no separate OTA-only archive) +# --------------------------------------------------------------------------- +LEGACY_OTA_PACKER="$ROOT/scripts/switch/pack_ota_zip.sh" +if [ ! -f "$LEGACY_OTA_PACKER" ] \ + && ! grep -Eq 'pack_ota_zip\.sh|switch-ota\.zip' "$ROOT/scripts/build_switch.sh" \ + && grep -q 'OTA_ASSET_GLOB=gen1recomp-\*-switch.zip' "$MANIFEST" +then + ok "OTA uses the same SD zip as install (legacy OTA-only packer gone)" +else + bad "legacy separate OTA-only packer / asset still present" +fi + +if grep -q 'ota_ui_prompt_update' "$ROOT/ports/switch/ota-launcher/src/main.c" \ + && grep -q 'ota_net_init' "$ROOT/ports/switch/ota-launcher/src/main.c" \ + && grep -q 'framebufferCreate\|COL_RAIL' "$ROOT/ports/switch/ota-launcher/src/ota_ui.c" \ + && grep -q '^ROMFS' "$ROOT/ports/switch/ota-launcher/Makefile" \ + && grep -q 'cacert.pem' "$ROOT/ports/switch/ota-launcher/Makefile" +then + ok "launcher UI uses framebuffer (no prompt when up to date)" +else + bad "launcher missing branded ota_ui / ROMFS" +fi + +if [ -f "$ROOT/ports/switch/ota-launcher/src/ota_unzip.c" ] \ + && grep -q 'zzip/zzip.h' "$ROOT/ports/switch/ota-launcher/src/ota_unzip.c" \ + && grep -q 'ota_unzip_extract_file' "$ROOT/ports/switch/ota-launcher/src/main.c" +then + ok "launcher wires zziplib ota_unzip_extract_file" +else + bad "launcher missing zziplib unzip wiring" +fi + # --------------------------------------------------------------------------- # Summary # --------------------------------------------------------------------------- diff --git a/src/core/GamepadMap.lua b/src/core/GamepadMap.lua index c76a1504..9d8b921f 100644 --- a/src/core/GamepadMap.lua +++ b/src/core/GamepadMap.lua @@ -1,5 +1,5 @@ -- Shared gamepad + raw joystick button tables for launcher and gameplay. --- Hardware-measured NX overrides live in NX_* tables (see docs/switch-development.md). +-- Hardware-measured NX overrides live in NX_* tables below. local GamepadMap = {} diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index 56125c9d..54518c40 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -33,6 +33,7 @@ local Theme = require("src.ui.kit.Theme") local Layout = require("src.ui.kit.Layout") local Loader = require("src.ui.kit.Loader") local GameVersion = require("src.core.GameVersion") +local Version = require("src.core.Version") local Strings = require("src.core.Strings") local PAL = Theme.PAL @@ -398,6 +399,21 @@ local function buildHeader(imp, m) local rx = m.x + m.w - m.pad local by = y + (rowH - gear) / 2 + -- Switch-only: show the running app version opposite the settings gear so + -- players can confirm which build is on the microSD (OTA / zip updates). + if imp.isNX then + local label = "v" .. tostring(Version.engine or "?") + local tw = Kit.textWidth("small", label) + local padX = math.floor(12 * m.s) + local chipW = math.max(tw + 2 * padX, gear) + local lx = m.x + m.pad + Theme.fill(lx, by, chipW, gear, PAL.bg, 1) + Theme.stroke(lx, by, chipW, gear, PAL.yellow, Theme.A.hover, 1) + local th = Kit.textHeight("small") + Kit.text("small", label, lx + math.floor((chipW - tw) / 2), + by + math.floor((gear - th) / 2), PAL.yellow) + end + -- The right cluster is laid out right to left -- Quit outermost, the gear -- inboard of it -- but the two are REGISTERED gear first, because the first -- focusable of the first frame adopts the keyboard ring and that must not be diff --git a/src/update/SwitchOta.lua b/src/update/SwitchOta.lua new file mode 100644 index 00000000..ca914bb2 --- /dev/null +++ b/src/update/SwitchOta.lua @@ -0,0 +1,229 @@ +-- Switch OTA wire format (host-testable, no love.*). +-- The native DEVKITPRO launcher (libnx + switch-curl) must implement the +-- same decisions. LÖVE on NX never runs this path — Platform.networkValidated +-- stays false and src/update/Check.lua remains gated off on NX. +-- +-- Asset: gen1recomp--switch.zip (same SD zip used for install). + +local SwitchOta = {} + +SwitchOta.RELEASES_API = + "https://api.github.com/repos/bryanthaboi/gen1recomp/releases/latest" +SwitchOta.OTA_ASSET_PATTERN = "^gen1recomp%-(%d+%.%d+%.%d+)%-switch%.zip$" +SwitchOta.CHECK_TIMEOUT_SEC = 6 +SwitchOta.GAME_NRO_NAME = "gen1recomp-game.nro" +SwitchOta.LAUNCHER_NRO_NAME = "gen1recomp.nro" +SwitchOta.SAVE_DIR_NAME = "pokemon-love2d" +SwitchOta.INSTALL_DIR = "switch/gen1recomp" + +local function parseSemver(s) + if type(s) ~= "string" then return nil end + local body = s:match("^v?(.+)$") + if not body then return nil end + local maj, min, pat = body:match("^(%d+)%.(%d+)%.(%d+)$") + if not maj then return nil end + return { major = tonumber(maj), minor = tonumber(min), patch = tonumber(pat) } +end + +function SwitchOta.compareSemver(a, b) + local pa, pb = parseSemver(a), parseSemver(b) + if not pa and not pb then return 0 end + if not pa then return -1 end + if not pb then return 1 end + for _, field in ipairs({ "major", "minor", "patch" }) do + if pa[field] < pb[field] then return -1 end + if pa[field] > pb[field] then return 1 end + end + return 0 +end + +function SwitchOta.isOtaAssetName(name) + if type(name) ~= "string" then return false end + return name:match(SwitchOta.OTA_ASSET_PATTERN) ~= nil +end + +function SwitchOta.versionFromOtaAsset(name) + if type(name) ~= "string" then return nil end + return name:match(SwitchOta.OTA_ASSET_PATTERN) +end + +local function findJsonObjectStart(jsonText, pos) + if type(jsonText) ~= "string" or not pos or pos < 1 then return nil end + local depth = 0 + local p = pos + while p >= 1 do + local c = jsonText:sub(p, p) + if c == "}" then + depth = depth + 1 + elseif c == "{" then + if depth == 0 then return p end + depth = depth - 1 + end + p = p - 1 + end + return nil +end + +local function findJsonObjectEnd(jsonText, objectStart) + if type(jsonText) ~= "string" or not objectStart then return nil end + if jsonText:sub(objectStart, objectStart) ~= "{" then return nil end + local depth = 1 + local p = objectStart + 1 + local len = #jsonText + while p <= len do + local c = jsonText:sub(p, p) + if c == "{" then + depth = depth + 1 + elseif c == "}" then + depth = depth - 1 + if depth == 0 then return p + 1 end + end + p = p + 1 + end + return nil +end + +-- Parse GitHub releases/latest JSON (tag_name + assets[].name/browser_download_url). +-- Returns { tag, version, assetName, downloadUrl } or nil + reason. +function SwitchOta.parseRelease(jsonText) + if type(jsonText) ~= "string" or jsonText == "" then + return nil, "empty_json" + end + local tag = jsonText:match('"tag_name"%s*:%s*"(.-)"') + if not tag then return nil, "missing_tag" end + local version = tag:match("^v?(%d+%.%d+%.%d+)$") + if not version then return nil, "bad_tag" end + + local cursor = 1 + while true do + local nameKeyPos = jsonText:find('"name"', cursor, true) + if not nameKeyPos then break end + local tail = jsonText:sub(nameKeyPos) + local name = tail:match('"name"%s*:%s*"(.-)"') + if name and SwitchOta.isOtaAssetName(name) then + local assetStart = findJsonObjectStart(jsonText, nameKeyPos) + local assetEnd = assetStart and findJsonObjectEnd(jsonText, assetStart) + if assetStart and assetEnd and assetEnd > nameKeyPos then + local assetBlock = jsonText:sub(assetStart, assetEnd - 1) + local downloadUrl = assetBlock:match('"browser_download_url"%s*:%s*"(.-)"') + if downloadUrl and downloadUrl ~= "" then + return { + tag = tag, + version = version, + assetName = name, + downloadUrl = downloadUrl, + } + end + end + end + cursor = nameKeyPos + 6 + end + return nil, "missing_ota_asset" +end + +-- Decide check outcome given installed version and parsed release. +-- Returns status: uptodate | available | error +function SwitchOta.decideUpdate(installedVersion, release) + if type(installedVersion) ~= "string" or not parseSemver(installedVersion) then + return { status = "error", reason = "bad_installed_version" } + end + if type(release) ~= "table" or not release.version then + return { status = "error", reason = "bad_release" } + end + local cmp = SwitchOta.compareSemver(release.version, installedVersion) + if cmp <= 0 then + return { status = "uptodate", version = installedVersion } + end + return { + status = "available", + version = release.version, + assetName = release.assetName, + downloadUrl = release.downloadUrl, + } +end + +-- Parse sha256sums.txt lines: " " or " *" +function SwitchOta.parseSums(text) + local sums = {} + if type(text) ~= "string" then return sums end + for line in (text .. "\n"):gmatch("(.-)\n") do + local hex, name = line:match("^(%x+)%s+%*?%./?(.-)%s*$") + if hex and name and name ~= "" then + sums[name] = hex:lower() + end + end + return sums +end + +-- Require a matching sha256. Missing sum is ALWAYS reject (no silent accept). +function SwitchOta.verifySha256(assetName, actualHex, sums) + if type(assetName) ~= "string" or assetName == "" then + return false, "bad_asset_name" + end + if type(sums) ~= "table" then + return false, "missing_sums" + end + local expected = sums[assetName] + if type(expected) ~= "string" or expected == "" then + return false, "sum_not_found" + end + if type(actualHex) ~= "string" or actualHex == "" then + return false, "missing_actual_hash" + end + if actualHex:lower() ~= expected:lower() then + return false, "hash_mismatch" + end + return true, nil +end + +-- Atomic apply plan: never writes live NROs directly; never touches saves. +-- Replaces game + launcher so NACP versions stay in sync (hbmenu / Sphaira). +function SwitchOta.planAtomicApply(installDir, verifiedTempPath) + installDir = installDir or SwitchOta.INSTALL_DIR + local gameNro = installDir .. "/" .. SwitchOta.GAME_NRO_NAME + local launcherNro = installDir .. "/" .. SwitchOta.LAUNCHER_NRO_NAME + local partPath = gameNro .. ".part" + local launcherPart = launcherNro .. ".part" + return { + steps = { + { op = "copy_to_part", from = verifiedTempPath, to = partPath }, + { op = "rename", from = partPath, to = gameNro }, + { op = "copy_to_part", from = "launcher", to = launcherPart }, + { op = "rename", from = launcherPart, to = launcherNro }, + { op = "env_set_next_load", target = gameNro }, + }, + preserve = { installDir .. "/" .. SwitchOta.SAVE_DIR_NAME }, + forbidden = { + "delete:" .. installDir .. "/" .. SwitchOta.SAVE_DIR_NAME, + "write_direct:" .. gameNro, + }, + } +end + +-- Offline / skip / timeout policy. Never blocks forever. +-- elapsedSec: time spent on the network check so far +-- events: { networkOk=bool, userSkip=bool, apiError=bool } +function SwitchOta.offlinePolicy(elapsedSec, events) + events = events or {} + if events.userSkip then + return { action = "play_installed", reason = "user_skip", message = "update skipped" } + end + if events.apiError or events.networkOk == false then + return { + action = "play_installed", + reason = "offline_or_error", + message = "offline or update check failed — play installed version", + } + end + local timeout = SwitchOta.CHECK_TIMEOUT_SEC + if type(elapsedSec) == "number" and elapsedSec >= timeout then + return { + action = "play_installed", + reason = "timeout", + message = "update check timed out after " .. tostring(timeout) .. "s", + } + end + return { action = "keep_checking", reason = "in_flight" } +end + +return SwitchOta diff --git a/test/switch-nro-ota.spec.test.js b/test/switch-nro-ota.spec.test.js new file mode 100644 index 00000000..7d64934b --- /dev/null +++ b/test/switch-nro-ota.spec.test.js @@ -0,0 +1,606 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { createHash } from 'node:crypto'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(__dirname, '..'); + +function read(rel) { + return fs.readFileSync(path.join(root, rel), 'utf8'); +} + +function sha256Hex(buf) { + return createHash('sha256').update(buf).digest('hex'); +} + +/** JS mirror of src/update/SwitchOta.lua — contract under test. */ +function createSwitchOta() { + const OTA_RE = /^gen1recomp-(\d+\.\d+\.\d+)-switch\.zip$/; + const CHECK_TIMEOUT_SEC = 6; + const GAME_NRO_NAME = 'gen1recomp-game.nro'; + const LAUNCHER_NRO_NAME = 'gen1recomp.nro'; + const SAVE_DIR_NAME = 'pokemon-love2d'; + const INSTALL_DIR = 'switch/gen1recomp'; + + function parseSemver(s) { + if (typeof s !== 'string') return null; + const m = s.match(/^v?(\d+)\.(\d+)\.(\d+)$/); + if (!m) return null; + return { major: +m[1], minor: +m[2], patch: +m[3] }; + } + + function compareSemver(a, b) { + const pa = parseSemver(a); + const pb = parseSemver(b); + if (!pa && !pb) return 0; + if (!pa) return -1; + if (!pb) return 1; + for (const f of ['major', 'minor', 'patch']) { + if (pa[f] < pb[f]) return -1; + if (pa[f] > pb[f]) return 1; + } + return 0; + } + + function isOtaAssetName(name) { + return typeof name === 'string' && OTA_RE.test(name); + } + + function findJsonObjectStart(jsonText, pos) { + if (!jsonText || pos < 1) return null; + let depth = 0; + for (let p = pos; p >= 1; p--) { + const c = jsonText[p - 1]; + if (c === '}') depth += 1; + else if (c === '{') { + if (depth === 0) return p; + depth -= 1; + } + } + return null; + } + + function findJsonObjectEnd(jsonText, objectStart) { + if (!jsonText || objectStart < 1) return null; + if (jsonText[objectStart - 1] !== '{') return null; + let depth = 1; + for (let p = objectStart + 1; p <= jsonText.length; p++) { + const c = jsonText[p - 1]; + if (c === '{') depth += 1; + else if (c === '}') { + depth -= 1; + if (depth === 0) return p + 1; + } + } + return null; + } + + function parseRelease(jsonText) { + if (!jsonText) return { ok: false, reason: 'empty_json' }; + const tagM = jsonText.match(/"tag_name"\s*:\s*"([^"]+)"/); + if (!tagM) return { ok: false, reason: 'missing_tag' }; + const tag = tagM[1]; + const versionM = tag.match(/^v?(\d+\.\d+\.\d+)$/); + if (!versionM) return { ok: false, reason: 'bad_tag' }; + const version = versionM[1]; + + let cursor = 0; + while (true) { + const nameKeyPos = jsonText.indexOf('"name"', cursor); + if (nameKeyPos === -1) break; + const tail = jsonText.slice(nameKeyPos); + const nameM = tail.match(/"name"\s*:\s*"([^"]+)"/); + const name = nameM?.[1]; + if (name && isOtaAssetName(name)) { + const assetStart = findJsonObjectStart(jsonText, nameKeyPos + 1); + const assetEnd = assetStart ? findJsonObjectEnd(jsonText, assetStart) : null; + if (assetStart && assetEnd && assetEnd > nameKeyPos + 1) { + const assetBlock = jsonText.slice(assetStart - 1, assetEnd - 1); + const urlM = assetBlock.match(/"browser_download_url"\s*:\s*"([^"]+)"/); + const downloadUrl = urlM?.[1]; + if (downloadUrl) { + return { + ok: true, + tag, + version, + assetName: name, + downloadUrl, + }; + } + } + } + cursor = nameKeyPos + 6; + } + return { ok: false, reason: 'missing_ota_asset' }; + } + + function decideUpdate(installed, release) { + if (!parseSemver(installed)) return { status: 'error', reason: 'bad_installed_version' }; + if (!release?.ok || !release.version) return { status: 'error', reason: 'bad_release' }; + if (compareSemver(release.version, installed) <= 0) { + return { status: 'uptodate', version: installed }; + } + return { + status: 'available', + version: release.version, + assetName: release.assetName, + downloadUrl: release.downloadUrl, + }; + } + + function parseSums(text) { + const sums = {}; + if (typeof text !== 'string') return sums; + for (const line of text.split(/\r?\n/)) { + const m = line.match(/^([0-9a-fA-F]+)\s+\*?\.?\/?(.*?)\s*$/); + if (m) sums[m[2]] = m[1].toLowerCase(); + } + return sums; + } + + function verifySha256(assetName, actualHex, sums) { + if (!assetName) return { ok: false, reason: 'bad_asset_name' }; + if (!sums || typeof sums !== 'object') return { ok: false, reason: 'missing_sums' }; + const expected = sums[assetName]; + if (!expected) return { ok: false, reason: 'sum_not_found' }; + if (!actualHex) return { ok: false, reason: 'missing_actual_hash' }; + if (actualHex.toLowerCase() !== expected.toLowerCase()) { + return { ok: false, reason: 'hash_mismatch' }; + } + return { ok: true }; + } + + function planAtomicApply(installDir, verifiedTempPath) { + installDir = installDir || INSTALL_DIR; + const gameNro = `${installDir}/${GAME_NRO_NAME}`; + const launcherNro = `${installDir}/${LAUNCHER_NRO_NAME}`; + const partPath = `${gameNro}.part`; + const launcherPart = `${launcherNro}.part`; + return { + steps: [ + { op: 'copy_to_part', from: verifiedTempPath, to: partPath }, + { op: 'rename', from: partPath, to: gameNro }, + { op: 'copy_to_part', from: 'launcher', to: launcherPart }, + { op: 'rename', from: launcherPart, to: launcherNro }, + { op: 'env_set_next_load', target: gameNro }, + ], + preserve: [`${installDir}/${SAVE_DIR_NAME}`], + forbidden: [`delete:${installDir}/${SAVE_DIR_NAME}`, `write_direct:${gameNro}`], + }; + } + + function offlinePolicy(elapsedSec, events = {}) { + if (events.userSkip) { + return { action: 'play_installed', reason: 'user_skip', message: 'update skipped' }; + } + if (events.apiError || events.networkOk === false) { + return { + action: 'play_installed', + reason: 'offline_or_error', + message: 'offline or update check failed — play installed version', + }; + } + if (typeof elapsedSec === 'number' && elapsedSec >= CHECK_TIMEOUT_SEC) { + return { + action: 'play_installed', + reason: 'timeout', + message: `update check timed out after ${CHECK_TIMEOUT_SEC}s`, + }; + } + return { action: 'keep_checking', reason: 'in_flight' }; + } + + return { + CHECK_TIMEOUT_SEC, + GAME_NRO_NAME, + LAUNCHER_NRO_NAME, + SAVE_DIR_NAME, + compareSemver, + isOtaAssetName, + parseRelease, + decideUpdate, + parseSums, + verifySha256, + planAtomicApply, + offlinePolicy, + }; +} + +const M = createSwitchOta(); + +const GITHUB_UPLOADER = + '"login":"github-actions[bot]",' + + '"id":41898282,' + + '"node_id":"MDM6Qm90NDE4OTgyODI=",' + + '"avatar_url":"https://avatars.githubusercontent.com/in/15368?v=4",' + + '"gravatar_id":"",' + + '"url":"https://api.github.com/users/github-actions%5Bbot%5D",' + + '"html_url":"https://github.com/apps/github-actions",' + + '"followers_url":"https://api.github.com/users/github-actions%5Bbot%5D/followers",' + + '"following_url":"https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}",' + + '"gists_url":"https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}",' + + '"starred_url":"https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}",' + + '"subscriptions_url":"https://api.github.com/users/github-actions%5Bbot%5D/subscriptions",' + + '"organizations_url":"https://api.github.com/users/github-actions%5Bbot%5D/orgs",' + + '"repos_url":"https://api.github.com/users/github-actions%5Bbot%5D/repos",' + + '"events_url":"https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}",' + + '"received_events_url":"https://api.github.com/users/github-actions%5Bbot%5D/received_events",' + + '"type":"Bot",' + + '"user_view_type":"public",' + + '"site_admin":false'; + +function buildGithubReleaseJson() { + return ( + '{' + + '"tag_name":"v0.1.70",' + + '"name":"0.1.70",' + + '"assets":[' + + '{' + + '"url":"https://api.github.com/repos/bryanthaboi/gen1recomp/releases/assets/502823880",' + + '"id":502823880,' + + '"name":"gen1recomp-0.1.70-switch.zip",' + + '"label":"",' + + '"uploader":{' + + GITHUB_UPLOADER + + '},' + + '"content_type":"application/zip",' + + '"state":"uploaded",' + + '"size":9000573,' + + '"browser_download_url":"https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.70/gen1recomp-0.1.70-switch.zip"' + + '}' + + ']' + + '}' + ); +} + +const sampleRelease = M.parseRelease( + JSON.stringify({ + tag_name: 'v1.5.0', + assets: [ + { + name: 'gen1recomp-1.5.0-switch.zip', + browser_download_url: 'https://example/switch.zip', + }, + { name: 'sha256sums.txt', browser_download_url: 'https://example/sums' }, + ], + }) +); + +test('AC-001: Launcher nativo verifica release no GitHub @spec:AC-001', () => { + assert.equal(M.compareSemver('1.2.0', '1.1.0'), 1); + assert.equal(M.compareSemver('1.1.0', '1.1.0'), 0); + assert.equal(M.compareSemver('1.0.0', '1.1.0'), -1); + + assert.equal(sampleRelease.ok, true); + assert.equal(sampleRelease.version, '1.5.0'); + assert.equal(sampleRelease.assetName, 'gen1recomp-1.5.0-switch.zip'); + + assert.equal(M.decideUpdate('1.4.0', sampleRelease).status, 'available'); + assert.equal(M.decideUpdate('1.5.0', sampleRelease).status, 'uptodate'); + assert.equal(M.decideUpdate('1.6.0', sampleRelease).status, 'uptodate'); + + const missing = M.parseRelease(JSON.stringify({ tag_name: 'v1.5.0', assets: [] })); + assert.equal(missing.ok, false); + assert.equal(missing.reason, 'missing_ota_asset'); + + const githubRelease = M.parseRelease(buildGithubReleaseJson()); + assert.equal(githubRelease.ok, true); + assert.equal(githubRelease.version, '0.1.70'); + assert.equal(githubRelease.assetName, 'gen1recomp-0.1.70-switch.zip'); + assert.equal( + githubRelease.downloadUrl, + 'https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.70/gen1recomp-0.1.70-switch.zip' + ); + assert.equal(M.decideUpdate('0.1.69', githubRelease).status, 'available'); +}); + +test('AC-002: Download com verificação SHA-256 @spec:AC-002', () => { + const name = 'gen1recomp-1.5.0-switch.zip'; + const payload = Buffer.from('switch-sd-payload'); + const hex = sha256Hex(payload); + const sums = M.parseSums(`${hex} ${name}\n`); + + assert.equal(M.verifySha256(name, hex, sums).ok, true); + assert.equal(M.verifySha256(name, 'deadbeef', sums).reason, 'hash_mismatch'); + // Invisible-error guard: download without a sum MUST NOT verify. + assert.equal(M.verifySha256(name, hex, {}).ok, false); + assert.equal(M.verifySha256(name, hex, {}).reason, 'sum_not_found'); + assert.equal(M.verifySha256(name, '', sums).reason, 'missing_actual_hash'); +}); + +test('AC-003: Aplicação atômica e handoff para o jogo @spec:AC-003', () => { + const plan = M.planAtomicApply('switch/gen1recomp', '/tmp/verified.zip'); + assert.ok(plan.steps.some((s) => s.op === 'copy_to_part')); + assert.ok(plan.steps.some((s) => s.op === 'rename')); + assert.ok(plan.steps.some((s) => s.op === 'env_set_next_load')); + assert.ok(plan.steps.filter((s) => s.op === 'rename').length >= 2, 'renames game + launcher'); + assert.ok(plan.preserve.some((p) => p.endsWith('pokemon-love2d'))); + assert.ok(plan.forbidden.some((f) => f.startsWith('delete:') && f.includes('pokemon-love2d'))); + assert.ok(plan.forbidden.some((f) => f.startsWith('write_direct:'))); + const renameGame = plan.steps.find((s) => s.op === 'rename' && s.to.endsWith('gen1recomp-game.nro')); + assert.ok(renameGame); + assert.ok(renameGame.from.endsWith('.part')); + const renameLauncher = plan.steps.find((s) => s.op === 'rename' && s.to.endsWith('gen1recomp.nro')); + assert.ok(renameLauncher, 'launcher NRO replaced so NACP version stays in sync'); +}); + +test('AC-004: Offline ou falha de rede não trava o jogo @spec:AC-004', () => { + assert.equal(M.CHECK_TIMEOUT_SEC, 6); + assert.equal(M.offlinePolicy(1, { userSkip: true }).action, 'play_installed'); + assert.equal(M.offlinePolicy(1, { networkOk: false }).action, 'play_installed'); + assert.equal(M.offlinePolicy(6, { networkOk: true }).action, 'play_installed'); + assert.equal(M.offlinePolicy(6, { networkOk: true }).reason, 'timeout'); + assert.equal(M.offlinePolicy(2, { networkOk: true }).action, 'keep_checking'); +}); + +test('AC-005: Documentação Switch descreve o launcher OTA @spec:AC-005', () => { + const install = read('docs/switch-install.md'); + const build = read('docs/switch-build.md'); + const updater = read('docs/updater.md'); + + for (const [name, text] of [ + ['switch-install', install], + ['switch-build', build], + ['updater', updater], + ]) { + assert.match(text, /native OTA launcher|launcher nativo/i, `${name} mentions native OTA launcher`); + } + + assert.match(install, /manual|zip|microSD/i, 'manual zip fallback still documented'); + assert.match(install, /gen1recomp-\*-switch\.zip|switch\.zip/i, 'unified Switch zip documented'); + assert.doesNotMatch(install, /switch-ota\.zip/, 'legacy separate OTA zip must not be documented'); + assert.match(install, /Sphaira|forwarder/i, 'Sphaira shortcut version note documented'); + assert.doesNotMatch( + install, + /Switch OTA uses `src\/update\/Check\.lua`/i, + 'must not claim Switch OTA uses LÖVE Check.lua' + ); + assert.match(updater, /Nintendo Switch/i); + assert.match(updater, /networkValidated|disabled|desligado|off/i); +}); + +test('AC-006: LOVE no NX continua sem rede de updater @spec:AC-006', () => { + const platform = read('src/core/Platform.lua'); + assert.match( + platform, + /networkValidated\s*=\s*not nx/, + 'Platform.networkValidated must stay false on NX' + ); + assert.doesNotMatch(platform, /networkValidated\s*=\s*true/); + + const boot = read('src/update/Boot.lua'); + assert.match(boot, /networkValidated/, 'Boot still gates on networkValidated'); + + assert.ok(read('src/update/Check.lua').length > 0); + assert.ok(read('src/update/check_worker.lua').length > 0); + assert.ok(read('src/core/HostShell.lua').length > 0); + assert.match(read('src/import/RomImporter.lua'), /updaterAllowed|networkValidated|Check/); + assert.ok(read('src/import/LauncherView.lua').length > 0); + + const switchOta = read('src/update/SwitchOta.lua'); + assert.doesNotMatch(switchOta, /networkValidated\s*=\s*true/); + assert.match(switchOta, /never runs this path|remains gated off on NX/i); +}); + +test('AC-007: Funções puras do protocolo OTA têm testes host-side @spec:AC-007', () => { + assert.ok(M.isOtaAssetName('gen1recomp-1.2.3-switch.zip')); + assert.equal(M.isOtaAssetName('gen1recomp-1.2.3-switch-ota.zip'), false); + assert.equal(M.isOtaAssetName('gen1recomp-1.2.3.love'), false); + assert.equal(M.compareSemver('2.0.0', '1.9.9'), 1); + assert.equal(M.compareSemver('1.0.0', '1.0.0'), 0); + assert.equal(M.compareSemver('0.9.0', '1.0.0'), -1); + + const rel = M.parseRelease( + JSON.stringify({ + tag_name: 'v9.9.9', + assets: [ + { + name: 'gen1recomp-9.9.9-switch.zip', + browser_download_url: 'https://example/x', + }, + ], + }) + ); + assert.equal(rel.version, '9.9.9'); + + const sums = M.parseSums('deadbeef unexpected-name.zip\n'); + assert.equal( + M.verifySha256('gen1recomp-9.9.9-switch.zip', 'deadbeef', sums).ok, + false, + 'unexpected filename in sums must not verify OTA asset' + ); + assert.equal(M.verifySha256('gen1recomp-9.9.9-switch.zip', 'anything', {}).ok, false); + + const lua = read('src/update/SwitchOta.lua'); + assert.ok(fs.existsSync(path.join(root, 'src/update/SwitchOta.lua'))); + for (const fn of [ + 'compareSemver', + 'parseRelease', + 'decideUpdate', + 'parseSums', + 'verifySha256', + 'planAtomicApply', + 'offlinePolicy', + ]) { + assert.match(lua, new RegExp(`function SwitchOta\\.${fn}`), `Lua exports ${fn}`); + } + assert.match(lua, /CHECK_TIMEOUT_SEC\s*=\s*6/); + assert.match(lua, /switch%.zip|%-switch%.zip/); + assert.doesNotMatch(lua, /switch%-ota%.zip|switch-ota\.zip/); +}); + +test('AC-008: Gates de regressão anti-“erro invisível” @spec:AC-008', () => { + const platform = read('src/core/Platform.lua'); + assert.match(platform, /networkValidated\s*=\s*not nx\s+and\s+not uwp/); + + for (const rel of ['docs/switch-install.md', 'docs/switch-build.md', 'docs/updater.md']) { + const text = read(rel); + assert.match(text, /native OTA launcher|launcher nativo/i, rel); + assert.match( + text, + /LÖVE self-updater|LOVE self-updater|self-updater LÖVE|updater LÖVE|Check\.lua/i, + `${rel} contrasts with LÖVE updater` + ); + } + + const manifest = read('scripts/switch/ota_launcher.manifest'); + assert.match(manifest, /^OTA_ENABLED=1$/m); + assert.match(manifest, /ENTRY_NRO=switch\/gen1recomp\/gen1recomp\.nro/); + assert.match(manifest, /GAME_NRO=switch\/gen1recomp\/gen1recomp-game\.nro/); + assert.match(manifest, /OTA_ASSET_GLOB=gen1recomp-\*-switch\.zip/); + assert.doesNotMatch(manifest, /switch-ota\.zip/); + assert.match(manifest, /REQUIRE_SHA256SUMS=1/); +}); + +test('AC-009: Protocolo C host-testável espelha SwitchOta.lua @spec:AC-009', () => { + const proto = read('ports/switch/ota-launcher/src/ota_protocol.c'); + const header = read('ports/switch/ota-launcher/include/ota_protocol.h'); + assert.match(header, /OTA_CHECK_TIMEOUT_SEC\s+6/); + assert.match(header, /ota_compare_semver/); + assert.match(header, /ota_parse_release/); + assert.match(header, /ota_verify_sha256/); + assert.match(header, /ota_plan_atomic_apply/); + assert.match(header, /ota_offline_policy/); + assert.match(proto, /sum_not_found/); + assert.match(proto, /env_set_next_load/); + assert.match(header, /pokemon-love2d/); + assert.match(proto, /OTA_SAVE_DIR_NAME/); + + const lua = read('src/update/SwitchOta.lua'); + assert.match(lua, /CHECK_TIMEOUT_SEC\s*=\s*6/); + + // Compile + run C host tests (gcc) + const r = spawnSync( + 'make', + ['-C', path.join(root, 'ports/switch/ota-launcher'), 'host-test'], + { encoding: 'utf8' } + ); + assert.equal(r.status, 0, `host-test failed:\n${r.stdout}\n${r.stderr}`); + assert.match(r.stdout, /all ota_protocol host tests passed/); +}); + +test('AC-010: Fonte do launcher e Makefile DEVKITPRO existem @spec:AC-010', () => { + for (const rel of [ + 'ports/switch/ota-launcher/Makefile', + 'ports/switch/ota-launcher/README.md', + 'ports/switch/ota-launcher/src/main.c', + 'ports/switch/ota-launcher/src/ota_net.c', + 'ports/switch/ota-launcher/src/ota_fs.c', + 'scripts/switch/build_ota_launcher.sh', + 'docs/switch-build.md', + ]) { + assert.ok(fs.existsSync(path.join(root, rel)), `missing ${rel}`); + } + const main = read('ports/switch/ota-launcher/src/main.c'); + assert.match(main, /envSetNextLoad|ota_fs_handoff_to_game/); + assert.match(main, /gen1recomp-game\.nro|OTA_GAME_NRO_NAME/); + assert.match(main, /Quiet by default|stays quiet|LÖVE self-updater stays off|self-updater stays off/i); + assert.match(main, /ota_ui_prompt_update|ota_ui_show_progress/); + assert.match(main, /ota_ui_alert_error/); + assert.match(main, /Step 1\/3: Downloading/); + assert.doesNotMatch(main, /\u2026/, 'OTA UI strings must be ASCII (no Unicode ellipsis)'); + assert.doesNotMatch(main, /ota_ui_alert\([^)]*Install failed/, 'generic install alert removed'); + assert.doesNotMatch(main, /consoleInit/, 'no terminal console UI'); + assert.ok(fs.existsSync(path.join(root, 'ports/switch/ota-launcher/src/ota_ui.c'))); + const otaUi = read('ports/switch/ota-launcher/src/ota_ui.c'); + assert.match(otaUi, /COL_RAIL_R|rail|FFD600|255,\s*214/); + assert.match(otaUi, /logo\.rgba/); + assert.doesNotMatch(otaUi, /stb_image/); + assert.ok( + fs.existsSync(path.join(root, 'ports/switch/assets/logo.rgba')), + 'pre-baked OTA logo asset' + ); + assert.ok( + fs.existsSync(path.join(root, 'scripts/switch/bake_ota_logo.py')), + 'logo bake script for regenerating logo.rgba' + ); + assert.match(otaUi, /ota_ui_sanitize_ascii/); + assert.match(otaUi, /draw_text_wrapped_centered/); + assert.match(otaUi, /ota_ui_alert_error/); + assert.match(read('ports/switch/ota-launcher/src/ota_net.c'), /ota_net_init/); + assert.match(read('ports/switch/ota-launcher/src/main.c'), /ota_net_init/); + assert.match(read('ports/switch/ota-launcher/src/ota_net.c'), /CURLOPT_SSL_VERIFYPEER,\s*1L/); + assert.match(read('ports/switch/ota-launcher/src/ota_net.c'), /romfs:\/cacert\.pem/); + assert.doesNotMatch(read('ports/switch/ota-launcher/src/ota_net.c'), /CURLOPT_SSL_VERIFYPEER,\s*0L/); + assert.match(read('ports/switch/ota-launcher/Makefile'), /^ROMFS\s*:=/m); + assert.match(read('ports/switch/ota-launcher/Makefile'), /cacert\.pem/); + assert.match(read('ports/switch/ota-launcher/Makefile'), /logo\.rgba/); + + const mk = read('ports/switch/ota-launcher/Makefile'); + assert.match(mk, /libnx\/switch_rules|DEVKITPRO/); + assert.match(mk, /-lcurl/); + assert.match(mk, /-lzzip/); + assert.match(mk, /ports\/switch\/assets\/icon\.jpg|assets\/icon\.jpg/, 'uses project Switch icon'); + + assert.ok(fs.existsSync(path.join(root, 'ports/switch/ota-launcher/src/ota_unzip.c'))); + assert.match(read('ports/switch/ota-launcher/src/ota_unzip.c'), /zzip\/zzip\.h/); + assert.match(read('ports/switch/ota-launcher/src/main.c'), /ota_unzip_extract_file/); + assert.match(read('ports/switch/ota-launcher/src/main.c'), /GAME_MEMBER_IN_ZIP|switch\/gen1recomp\//); + assert.match(read('ports/switch/ota-launcher/src/main.c'), /LAUNCHER_MEMBER_IN_ZIP|ota_fs_stage_launcher_bootstrap/); + assert.match(read('ports/switch/ota-launcher/src/main.c'), /ota_fs_stage_launcher_bootstrap\([\s\S]*\) != 0/); + assert.match(read('ports/switch/ota-launcher/src/main.c'), /return 2.*bootstrap|bootstrap.*return 2/i); + assert.match(read('ports/switch/ota-launcher/src/ota_fs.c'), /ota_fs_atomic_replace_nro/); + assert.match(read('ports/switch/ota-launcher/src/ota_fs.c'), /remove\(dest\)/); + assert.doesNotMatch(read('ports/switch/ota-launcher/src/ota_fs.c'), /#ifdef _WIN32[\s\S]*remove\(dest\)/); + assert.doesNotMatch(read('scripts/switch/install_devkitpro_deps.sh'), /switch-minizip/); + assert.match(read('scripts/switch/install_devkitpro_deps.sh'), /switch-zziplib/); + assert.match(read('scripts/switch/install_devkitpro_deps.sh'), /switch-dev/); + + const buildDoc = read('docs/switch-build.md'); + assert.match(buildDoc, /ports\/switch\/ota-launcher|build_ota_launcher/); + assert.match(buildDoc, /native packages.*or.*Docker|native or Docker/i); + assert.match(buildDoc, /--fused|install_devkitpro_deps/); + assert.match(buildDoc, /Requires DEVKITPRO|DEVKITPRO is[\s\S]*required/i); + assert.doesNotMatch(buildDoc, /switch-ota\.zip/); + assert.doesNotMatch(buildDoc, /--ota\b/); + + const readme = read('ports/switch/ota-launcher/README.md'); + assert.match(readme, /DEVKITPRO|devkitPro/i); + assert.match(readme, /Docker|devkita64/i); + assert.match(readme, /Quiet|quiet|silen/i); +}); + +test('AC-011: Empacotamento dual-NRO e selftest @spec:AC-011', () => { + const pack = read('scripts/switch/pack_sd_zip.sh'); + assert.match(pack, /LAUNCHER_NRO/); + assert.match(pack, /gen1recomp-game\.nro/); + assert.match(pack, /version\.txt/); + + assert.equal( + fs.existsSync(path.join(root, 'scripts/switch/pack_ota_zip.sh')), + false, + 'separate pack_ota_zip.sh removed — unified *-switch.zip' + ); + + const buildSwitch = read('scripts/build_switch.sh'); + assert.doesNotMatch(buildSwitch, /--ota\b/); + assert.doesNotMatch(buildSwitch, /pack_ota_zip\.sh/); + assert.match(buildSwitch, /build_ota_launcher\.sh/); + assert.match(buildSwitch, /dual-NRO SD zip|OTA download asset/i); + + const buildOtaLauncher = read('scripts/switch/build_ota_launcher.sh'); + assert.match(buildOtaLauncher, /ota_launcher_deps_ready/); + assert.match(buildOtaLauncher, /fail_missing_devkitpro/); + assert.match(buildSwitch, /preflight_fused_build/); + + const selftest = read('scripts/switch/selftest_build_switch.sh'); + assert.match(selftest, /dual-NRO|gen1recomp-game\.nro/); + assert.match(selftest, /ota_launcher\.manifest/); + assert.match(selftest, /OTA uses the same SD zip|same SD zip|legacy OTA-only/i); + assert.match(selftest, /ota_ui|framebuffer|no prompt when up to date/i); + + const manifest = read('scripts/switch/ota_launcher.manifest'); + assert.match(manifest, /^OTA_ENABLED=1$/m); + assert.match(manifest, /OTA_ASSET_GLOB=gen1recomp-\*-switch\.zip/); + + // Run the dual-NRO portion via full offline selftest (includes legacy + OTA) + const r = spawnSync('bash', [path.join(root, 'scripts/switch/selftest_build_switch.sh')], { + encoding: 'utf8', + }); + assert.equal(r.status, 0, `selftest failed:\n${r.stdout}\n${r.stderr}`); + assert.match(r.stdout, /dual-NRO OTA layout/); +}); diff --git a/tests/engine/launcher_nx_version_chip_test.lua b/tests/engine/launcher_nx_version_chip_test.lua new file mode 100644 index 00000000..9fefbb1b --- /dev/null +++ b/tests/engine/launcher_nx_version_chip_test.lua @@ -0,0 +1,28 @@ +-- Switch launcher shows the running engine version in the header (NX only). +-- Desktop/Android/iOS must not paint that chip. + +local function read(path) + local f = assert(io.open(path, "r")) + local s = f:read("*a") + f:close() + return s +end + +local src = read("src/import/LauncherView.lua") + +assert(src:find('local Version = require%("src%.core%.Version"%)') + or src:find('require%("src%.core%.Version"%)'), + "LauncherView must require Version") + +assert(src:find("imp%.isNX", 1, false), "version chip must gate on isNX") + +-- The chip is inside an isNX block and prints Version.engine (layout between +-- the chip and the settings gear may change; anchor on the Switch-only comment). +local nxBlock = src:match( + "Switch%-only: show the running app version.-if imp%.isNX then(.-)end") +assert(nxBlock, "expected Switch-only version chip in the header row") +assert(nxBlock:find("Version%.engine", 1, false), "chip must show Version.engine") +assert(nxBlock:find('"v"', 1, true) or nxBlock:find('"v" %.%.', 1, false), + "chip label should be a v-prefixed version") + +print("ok — Switch launcher version chip is NX-gated and uses Version.engine") diff --git a/tests/switch_ci_workflows_test.lua b/tests/switch_ci_workflows_test.lua index 8225cc46..5ae2d693 100644 --- a/tests/switch_ci_workflows_test.lua +++ b/tests/switch_ci_workflows_test.lua @@ -152,27 +152,28 @@ check(comment_wf:find("comment-tag: switch-build-result", 1, true) -- --- SWCI-08 / SWCI-09: docs CI vs release --- local build_doc = read("docs/switch-build.md") -local development = read("docs/switch-development.md") local readme = read("README.md") mustContain(build_doc, "Path-gated", "switch-build.md") mustContain(build_doc, "ubuntu-latest", "switch-build.md") mustContain(build_doc, "selftest_build_switch.sh", "switch-build.md") -mustContain(build_doc, "canonical", "switch-build.md") +mustContain(build_doc, "main repo", "switch-build.md") mustContain(build_doc, "gen1recomp-switch-nro", "switch-build.md") mustContain(build_doc, "switch-build-result", "switch-build.md") mustContain(build_doc, "hard gate", "switch-build.md") mustContain(build_doc, "continue-on-error", "switch-build.md") mustContain(build_doc, "nacptool", "switch-build.md") mustContain(build_doc, "Docker", "switch-build.md") -mustContain(build_doc, "Fork → canonical", "switch-build.md") +mustContain(build_doc, "Fork PRs into the main repo", "switch-build.md") mustContain(build_doc, "skip Switch fused", "switch-build.md") - -mustContain(development, "Switch CI", "switch-development.md") -mustContain(development, "selftest_build_switch.sh", "switch-development.md") +mustContain(build_doc, "CI and release", "switch-build.md") +mustNotContain(build_doc, "switch-development", "switch-build.md") +mustNotContain(build_doc, "switch-hardware-evidence", "switch-build.md") mustContain(readme, "CI vs release", "README.md") mustContain(readme, "switch-build.md", "README.md") +mustNotContain(readme, "switch-development", "README.md") +mustNotContain(readme, "switch-hardware-evidence", "README.md") -- --- SWFIX-03: headless suite also runs the content gates --- local test_sh = read("scripts/test.sh") diff --git a/tests/switch_transfer_docs_test.lua b/tests/switch_transfer_docs_test.lua index ee80d11d..808f76a3 100644 --- a/tests/switch_transfer_docs_test.lua +++ b/tests/switch_transfer_docs_test.lua @@ -32,7 +32,7 @@ mustContain(transfer, "imports/", "transfer") mustContain(transfer, "imports/mods/", "transfer") mustContain(transfer, "1: SD Card", "transfer") mustContain(transfer, "Scan again", "transfer") -mustContain(transfer, "one contributor example", "transfer") +mustContain(transfer, "documented example", "transfer") mustContain(transfer, "Linux", "transfer") mustContain(transfer, "Windows", "transfer") mustContain(transfer, "macOS", "transfer") @@ -46,7 +46,7 @@ mustContain(transfer, "Portable Devices", "transfer") mustContain(transfer, "MTP USB Device", "transfer") mustContain(transfer, "AppleDouble", "transfer") mustContain(transfer, "card reader", "transfer") -mustContain(transfer, "Canonical methods", "transfer") +mustContain(transfer, "Transfer methods", "transfer") mustContain(transfer, "OpenMTP", "transfer") mustContain(transfer, "only one", "transfer") mustContain(transfer, "USB-C", "transfer") @@ -54,46 +54,27 @@ mustContain(transfer, "USB-C", "transfer") mustContain(transfer, "If MTP is unavailable or flaky on Linux", "transfer") mustContain(transfer, "If MTP is unavailable or flaky on Windows", "transfer") mustContain(transfer, "Joy-Con display chords (stock engine)", "transfer") -mustNotContain(transfer, "VoxelMod", "transfer") +mustNotContain(transfer, "switch-development", "transfer") +mustNotContain(transfer, "switch-hardware-evidence", "transfer") local install = read("docs/switch-install.md") local build = read("docs/switch-build.md") mustContain(install, "switch-transfer.md", "install") mustContain(install, "## Community mods", "install") mustContain(install, "Select + **A**", "install") +mustContain(install, "Select + **L**", "install") mustContain(install, "COLORS", "install") mustContain(install, "TILT", "install") mustContain(install, "GBC FX", "install") mustContain(install, "PERFORMANCE", "install") mustContain(install, "Stock engine effect", "install") +mustContain(install, "## Limitations", "install") +mustContain(install, "Launch with title override", "install") mustNotContain(install, "VoxelMod", "install") +mustNotContain(install, "switch-development", "install") mustContain(build, "switch-transfer.md", "build") mustContain(build, "nxlink", "build") - -local development = read("docs/switch-development.md") -mustContain(development, "switch-transfer.md", "development") -mustContain(development, "## Joy-Con display chords (Select + face)", "development") -mustContain(development, "Stock engine effect", "development") -mustContain(development, "claimed by the engine before mod pipeline", "development") -mustContain(development, "Select + **L**", "development") -mustContain(development, "OPTIONS → PERFORMANCE", "development") -mustNotContain(development, "VoxelMod", "development") -check(development:find("Non-macOS contributor MTP runbooks", 1, true) == nil, - "development must not list Non-macOS runbooks as absent") - -local evidence = read("docs/switch-hardware-evidence.md") -local nxStart = evidence:find("## NXMOD-12", 1, true) -check(nxStart ~= nil, "NXMOD-12 section present") -local nxmod = evidence:sub(nxStart) -mustContain(nxmod, "**pass**", "NXMOD-12") -mustContain(nxmod, "531", "NXMOD-12") -mustContain(nxmod, "switch-oled-photos", "NXMOD-12") -mustContain(nxmod, "IMG_1766.jpg", "NXMOD-12") -mustContain(nxmod, "IMG_1771.jpg", "NXMOD-12") -mustContain(nxmod, "Community mod zip", "NXMOD-12") -mustNotContain(nxmod, "VoxelMod", "NXMOD-12") -check(nxmod:find("Status | **pending**", 1, true) == nil - and nxmod:find("| **pending** |", 1, true) == nil, - "NXMOD-12 must not keep pending status/checklist") +mustNotContain(build, "switch-development", "build") +mustNotContain(build, "switch-hardware-evidence", "build") T.finish("switch_transfer_docs_test") diff --git a/tools/switch-probe/README.md b/tools/switch-probe/README.md index 616c1300..c645324c 100644 --- a/tools/switch-probe/README.md +++ b/tools/switch-probe/README.md @@ -1,4 +1,4 @@ -# switch-probe — love-nx hardware probe +# switch-probe: love-nx hardware probe **NOT FOR RELEASE.** This package is a developer-only diagnostic for Nintendo Switch (love-nx). Do not ship it inside `game.love` or release NRO payloads. @@ -11,7 +11,9 @@ Validate Phase 0 runtime facts on real Switch hardware before running the full G - Save directory path (`love.filesystem.getSaveDirectory()`) - Gamepad / joystick / touch event logging -To date this probe has only been run on **Switch OLED** (see `docs/switch-hardware-evidence.md`); other models are untested. Deploy beside `gen1recomp.nro` remains **manual** (MTP); see `docs/switch-development.md`. +To date this probe has only been run on **Switch OLED**; other models are +untested. Deploy beside `gen1recomp.nro` remains **manual** (MTP). See +`docs/switch-transfer.md`. ## Fields shown on screen @@ -36,7 +38,9 @@ mkdir -p .bazinga/work zip -9 -j .bazinga/work/switch-probe.love tools/switch-probe/main.lua tools/switch-probe/conf.lua ``` -Deploy beside `gen1recomp.nro` (loose mode) per `docs/switch-development.md`, renaming to `game.love` only for a probe run — use a separate SD folder so probe and game builds do not mix. +Deploy beside `gen1recomp.nro` (loose mode) per `docs/switch-build.md` and +`docs/switch-transfer.md`. Rename to `game.love` only for a probe run. Use a +separate SD folder so probe and game builds do not mix. ## Desktop smoke (optional)