Merge pull request #910 from andrewqsantos/feat/switch-ota-unified-launcher

Feat Switch OTA Update
This commit is contained in:
bryanthaboi
2026-08-06 10:20:23 -04:00
committed by GitHub
45 changed files with 4279 additions and 885 deletions
+18 -1
View File
@@ -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/
+9 -18
View File
@@ -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).
<p align="center"><a href="https://boisclub.games"><img src="https://raw.githubusercontent.com/bryanthaboi/gen1recomp/refs/heads/dev/assets/logo/bcg.png"></a></p>
+79 -39
View File
@@ -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-<ver>-switch.nro` (game in romfs) via `nacptool` + `elf2nro`, then packs `dist/switch/gen1recomp-<ver>-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-<ver>-switch.nro.sha256` and
`gen1recomp-<ver>-switch.zip` (+ `.sha256` sidecar for the zip).
The fused path also writes `gen1recomp-<ver>-switch.nro` (game),
`gen1recomp-<ver>-launcher.nro`, `gen1recomp-<ver>-game.nro`,
`gen1recomp-<ver>-switch.nro.sha256`, and `gen1recomp-<ver>-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 "<release 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).
-528
View File
@@ -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 Dusklights **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/<save identity>/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/<save identity>/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/<game>/`. 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/<save identity>/imports/saves/<game>/` (see launcher notice for the live `getSaveDirectory()` path) |
| Candidates | non-hidden `*.sav` only in **that games** 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/<game>/.imported-sha1` |
| Exports | **Export save** writes under `exports/<game>/gen1recomp-<game>-<slot>.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/<game>/`.
**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 ports 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 consoles `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 operators 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-0af | love-nx pin, probe, MTP, title override | **pass** | Phase 0 checklist above; T4 |
| P0-1ad | 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`)
-187
View File
@@ -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
```
+65 -25
View File
@@ -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 games 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 games 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 games ROM already imported, open **that games 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 games **`exports/<game>/`** folder via MTP / SD / FTP.
from that game's **`exports/<game>/`** 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).
+19 -22
View File
@@ -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/<save identity>/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 games 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 distros 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 managers 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
**DBIs 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)
+5
View File
@@ -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.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.
+72
View File
@@ -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
+36
View File
@@ -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 <stdio.h>
#include <string.h>
#include <unistd.h>
#if defined(__SWITCH__)
#include <switch.h>
#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
}
+193
View File
@@ -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
+89
View File
@@ -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)
@@ -0,0 +1,144 @@
/* Host-only unit tests for ota_protocol.c — no libnx. */
#include "ota_protocol.h"
#include <stdio.h>
#include <string.h>
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;
}
@@ -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
@@ -0,0 +1,29 @@
#ifndef GEN1_OTA_NET_H
#define GEN1_OTA_NET_H
#include <stddef.h>
#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
@@ -0,0 +1,95 @@
#ifndef GEN1_OTA_PROTOCOL_H
#define GEN1_OTA_PROTOCOL_H
#include <stddef.h>
#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 */
@@ -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
@@ -0,0 +1,20 @@
#ifndef GEN1_OTA_UNZIP_H
#define GEN1_OTA_UNZIP_H
#include <stddef.h>
#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
+281
View File
@@ -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 <stdio.h>
#include <stdlib.h>
#include <string.h>
#if defined(__SWITCH__)
#include <sys/stat.h>
#include <switch.h>
#include <unistd.h>
#else
#include <sys/stat.h>
#include <unistd.h>
#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;
}
+177
View File
@@ -0,0 +1,177 @@
#include "ota_fs.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#if defined(__SWITCH__)
#include <switch.h>
#include <mbedtls/sha256.h>
#include <sys/stat.h>
#include <unistd.h>
#else
#include <unistd.h>
#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
}
+185
View File
@@ -0,0 +1,185 @@
#include "ota_net.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#if defined(__SWITCH__)
#include <curl/curl.h>
#include <switch.h>
#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
}
@@ -0,0 +1,329 @@
#include "ota_protocol.h"
#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
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");
}
+595
View File
@@ -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 <stdio.h>
#include <stdlib.h>
#include <string.h>
#if defined(__SWITCH__)
#include <switch.h>
#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
+89
View File
@@ -0,0 +1,89 @@
#include "ota_unzip.h"
#include <stdio.h>
#include <string.h>
#if defined(__SWITCH__)
#include <fcntl.h>
#include <zzip/zzip.h>
#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
}
+152
View File
@@ -0,0 +1,152 @@
/**
* 8x8 monochrome bitmap fonts for rendering
* Author: Daniel Hepper <daniel@hepper.net>
*
* 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
};
+25 -11
View File
@@ -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-<ver>-switch.nro via nacptool+elf2nro,
# then pack dist/switch/gen1recomp-<ver>-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-<ver>-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-<ver>-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
+156
View File
@@ -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("<I", data, 10)[0]
width = struct.unpack_from("<i", data, 18)[0]
height_raw = struct.unpack_from("<i", data, 22)[0]
bpp = struct.unpack_from("<H", data, 28)[0]
height = abs(height_raw)
top_down = height_raw < 0
if bpp != 32 or width <= 0 or height <= 0:
raise SystemExit(f"unsupported BMP {width}x{height_raw} @{bpp}bpp: {path}")
row_bytes = ((width * 4 + 3) // 4) * 4
pixels = bytearray(width * height * 4)
for row in range(height):
src_row = row if top_down else height - 1 - row
row_off = pixel_offset + src_row * row_bytes
for col in range(width):
b, g, r, a = data[row_off + col * 4 : row_off + col * 4 + 4]
dst = (row * width + col) * 4
pixels[dst : dst + 4] = bytes((r, g, b, a))
return width, height, bytes(pixels)
def bake_with_sips(src: Path, out_w: int, out_h: int) -> 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("<II", w, h))
fp.write(pixels)
return w, h
def main(argv: list[str]) -> 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))
+2 -2
View File
@@ -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
"
+73
View File
@@ -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
+126 -4
View File
@@ -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 <<EOF
--fused needs a way to build the OTA launcher: native dkp packages or Docker.
Missing dkp packages:${missing:- (dkp-pacman not on PATH)}
Pick one:
bash scripts/switch/install_devkitpro_deps.sh
or install Docker (build_ota_launcher.sh uses the pinned devkitPro image)
Then re-run: scripts/build_switch.sh --fetch --fused
See docs/switch-build.md and ports/switch/ota-launcher/README.md.
EOF
)"
}
fail_ota_launcher_toolchain() {
if ! devkitpro_ready; then
fail_missing_devkitpro
fi
if ota_launcher_deps_ready; then
fail "$(cat <<'EOF'
OTA launcher native build failed after host-test passed.
Re-run with a clean tree:
scripts/switch/build_ota_launcher.sh
If packages look wrong, reinstall:
bash scripts/switch/install_devkitpro_deps.sh
See ports/switch/ota-launcher/README.md.
EOF
)"
fi
fail_missing_ota_deps
}
# Fail fast before packing when --fused is requested.
preflight_fused_build() {
if ! devkitpro_ready; then
fail_missing_devkitpro
fi
ensure_dkp_tools_path
local game_ok=0
if command -v nacptool >/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
+67
View File
@@ -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"
+16
View File
@@ -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
+28 -7
View File
@@ -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" <<EOF
gen1recomp Switch (v${VERSION})
@@ -55,7 +69,7 @@ First install or update (same steps):
1. Extract this zip at the root of your microSD (merge folders if asked).
2. Do NOT delete switch/gen1recomp/pokemon-love2d/ — that folder holds
your saves, imported ROMs, mods, and options. Re-extracting only
replaces gen1recomp.nro and these help files.
replaces the NRO(s) and these help files.
3. Launch with title override (hold R on HOME, open any title → hbmenu).
4. Copy a legal Pokemon Red/Blue .gb into:
switch/gen1recomp/pokemon-love2d/imports/
@@ -106,9 +120,16 @@ rm -f "$OUT_ZIP"
LISTING="$(unzip -Z1 "$OUT_ZIP" 2>/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"
+126 -2
View File
@@ -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
# ---------------------------------------------------------------------------
+1 -1
View File
@@ -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 = {}
+16
View File
@@ -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
+229
View File
@@ -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-<X.Y.Z>-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: "<hex> <filename>" or "<hex> *<filename>"
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
+606
View File
@@ -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/);
});
@@ -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")
+7 -6
View File
@@ -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")
+10 -29
View File
@@ -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")
+7 -3
View File
@@ -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)