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)