Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev
@@ -199,7 +199,7 @@ jobs:
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
if git diff --name-only "$BASE_SHA" "$HEAD_SHA" | grep -Eq '^(ports/uwp/|scripts/build_xbox_uwp\.sh$|scripts/xbox-uwp/|scripts/pack_love\.sh$|\.github/workflows/(ci|release)\.yml$|src/core/Platform\.lua$|src/import/(CacheFs|RomImporter)\.lua$|src/update/Check\.lua$|tests/engine/(platform_nx|uwp_native_picker)_test\.lua$|tests/rom_importer_double_pick_test\.lua$)'; then
|
||||
if git diff --name-only "$BASE_SHA" "$HEAD_SHA" | grep -Eq '^(ports/uwp/|scripts/build_xbox_uwp\.sh$|scripts/xbox-uwp/|scripts/pack_love\.sh$|\.github/workflows/(ci|release)\.yml$|src/core/Platform\.lua$|src/import/(CacheFs|LauncherView|RomImporter)\.lua$|src/update/Check\.lua$|tests/engine/(platform_nx|uwp_baseroms|uwp_native_picker)_test\.lua$|tests/rom_importer_double_pick_test\.lua$)'; then
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
@@ -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/
|
||||
|
||||
|
||||
@@ -255,6 +255,40 @@ build and install from source on a Mac instead, see
|
||||
<a href="https://github.com/bryanthaboi/gen1recomp/releases/latest"><img src="./.github/resources/github-badge.png" alt="Download from GitHub" height="60"></a>
|
||||
</div>
|
||||
|
||||
## Xbox Dev Mode
|
||||
|
||||
Every release ships `gen1recomp-*-xbox-uwp.zip` for Xbox One and Xbox Series
|
||||
consoles in Developer Mode. It cannot be installed in retail mode.
|
||||
|
||||
Extract the archive, then use Xbox Device Portal to install the `.msix` and
|
||||
the x64 package under `Dependencies`.
|
||||
|
||||
### External setup
|
||||
|
||||
1. Put your legally obtained Red, Blue, or Yellow ROMs on an external drive.
|
||||
Mod ZIPs can go on the same drive.
|
||||
2. Connect the drive to the Xbox and open Gen1Recomp.
|
||||
3. Select **Import ROM** or **Import Mod**, then choose the file with the Xbox
|
||||
file picker.
|
||||
4. Repeat the ROM import for each version you want to use.
|
||||
|
||||
### Internal setup
|
||||
|
||||
1. Create a folder named `baseroms` on your PC and place your legally obtained
|
||||
Red, Blue, or Yellow ROMs inside it.
|
||||
2. ZIP the folder, keeping `baseroms` at the top level of the archive.
|
||||
3. Launch Gen1Recomp once, then close it.
|
||||
4. Open Xbox Device Portal and upload the ZIP to
|
||||
`Gen1Recomp/LocalState/pokemon-love2d/`.
|
||||
5. Choose **Yes** when Device Portal asks whether to extract the archive.
|
||||
6. Open Gen1Recomp. The launcher checks baseroms once at startup. When it finds a compatible ROM, that game’s tab shows ROM FOUND and an Import detected ROM button.
|
||||
|
||||
ROMs, generated game data, saves, and mods remain in LocalState and are not
|
||||
included in the app.
|
||||
|
||||
Source builds and package details are covered in
|
||||
[the Xbox UWP build notes](ports/uwp/BUILD.md).
|
||||
|
||||
## Handhelds
|
||||
|
||||
A PortMaster-style port for the **Anbernic RG34XXSP** on Stock OS 64-bit MOD
|
||||
@@ -264,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
|
||||
|
||||
@@ -330,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>
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -1,528 +0,0 @@
|
||||
# Nintendo Switch development (love-nx)
|
||||
|
||||
> Fused NRO support for issue [#531](https://github.com/bryanthaboi/gen1recomp/issues/531).
|
||||
> Releases ship `gen1recomp-*-switch.zip` (SD-ready tree). Console copy is
|
||||
> extract/merge at microSD root; title override required. See
|
||||
> [Known limitations](#known-limitations-read-before-reviewing).
|
||||
|
||||
**Canonical install / build / transfer docs** (start here unless you need hardware depth):
|
||||
|
||||
- Players → [switch-install.md](switch-install.md)
|
||||
- Builders → [switch-build.md](switch-build.md) (`scripts/build_switch.sh --fetch` downloads the pinned love-nx pair)
|
||||
- Transfer (MTP / SD / FTP on macOS, Linux, Windows) → [switch-transfer.md](switch-transfer.md)
|
||||
|
||||
This document covers what landed, known limitations, how hardware was tested,
|
||||
vendor layout, build/deploy, and the contributor transfer loop (detail lives in
|
||||
the transfer runbook).
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
- **Port / love-nx packaging:** [andrewqsantos](https://github.com/andrewqsantos)
|
||||
- **Community hardware testing** (Switch V1 / Erista boot): [booshankles](https://github.com/booshankles)
|
||||
- **Method guidance:** [Dusklight Switch port](https://github.com/HayatoG/dusklight/tree/main/platforms/switch) / love-nx
|
||||
- **Upstream project:** [bryanthaboi](https://github.com/bryanthaboi) / Gen1Recomp
|
||||
|
||||
## Status
|
||||
|
||||
| Area | State |
|
||||
| ---- | ----- |
|
||||
| Feature | **Available** — playable fused NRO path (issue #531) |
|
||||
| Runtime | Pinned love-nx **`11.5-nx1`** |
|
||||
| Product artifact | Releases: SD-ready `gen1recomp-*-switch.zip`; local/PR: fused `.nro`; loose `nro`+`game.love` for iteration |
|
||||
| Hardware | **OLED** validated (author, title override); **V1 / Erista** boot confirmed (community). Lite, docked soak, and Pro Controller matrices welcome |
|
||||
| Deploy / install | Releases publish SD-ready zip; **extract/merge at microSD root** (MTP / SD / FTP — [switch-transfer.md](switch-transfer.md)); no `nxlink` path yet |
|
||||
| Contributor transfer | Documented for **macOS, Linux, and Windows**; OpenMTP on Mac is one example, not the only contract |
|
||||
| Network features on NX | Self-update / remote mod download **disabled** (`networkValidated == false`) |
|
||||
| Community help | Welcome — especially HOS / love-nx packaging and broader hardware coverage |
|
||||
|
||||
### What landed
|
||||
|
||||
- Detect `NX` via `src/core/Platform.lua` without reusing Android flags
|
||||
- Writable ROM inbox under `getSaveDirectory()/imports/` + per-tab “Scan again” (SHA-1 match for the open game)
|
||||
- Joy-Con / gamepad mapping shared by launcher and gameplay (Nintendo A/B UX on NX)
|
||||
- Launcher L/R tab switch; gameplay L/R game-speed cycle; Select+face display chords
|
||||
- Focus loss / joystick reconnect recovery; opt-in `switch-debug.txt` diagnostics
|
||||
- Loose assemble + fused NRO build scripts (`scripts/build_switch.sh`, `scripts/switch/*`)
|
||||
- Payload gates so ROM / generated cache / saves never enter `game.love`
|
||||
- Community mod zip inbox at `imports/mods/` (rescan installs; FIND MODS stays network-gated)
|
||||
- Raw `.sav` inbox at `imports/saves/{red,blue,yellow}/` (**Import save** rescan) + export pull path `exports/{red,blue,yellow}/` (MTP hint; no openURL)
|
||||
- Hardware evidence for Phase 0 probe, ROM import, naming A/B, save/suspend, fused NRO — see `docs/switch-hardware-evidence.md`
|
||||
- Path-gated CI selftest + canonical fused PR artifact; release Switch hard-fail
|
||||
- Save editor pad/touch input (virtual cursor, A click, B close) — see `tools/save-editor/README.md`
|
||||
- Dynamic display size on NX only: handheld **1280×720**, docked/TV **1920×1080** (`src/core/NxDisplay.lua` + resizable conf so love-nx SDL can follow dock/undock at runtime)
|
||||
|
||||
### Known gaps / welcome contributions
|
||||
|
||||
- Docked vs handheld soak (≥30 min) and Lite coverage — resolution switch is implemented; long soak still welcome
|
||||
- Switch Lite and fuller Pro Controller / third-party pad matrices
|
||||
- Applet Mode remains unsupported by design (title override required)
|
||||
- `nxlink` / netloader contrib fast-loop (deferred — see [switch-transfer.md](switch-transfer.md))
|
||||
|
||||
Transfer runbooks for Linux/Windows (and SD/FTP alternatives) are in
|
||||
[switch-transfer.md](switch-transfer.md). Community mod zip install OLED smoke
|
||||
is **pass** — see NXMOD-12 in [switch-hardware-evidence.md](switch-hardware-evidence.md).
|
||||
|
||||
## Design references (Dusklight)
|
||||
|
||||
This work borrowed method — not the native stack — from the [Dusklight Switch port](https://github.com/HayatoG/dusklight/tree/main/platforms/switch), especially [`LESSONS_AND_REUSE.md`](https://github.com/HayatoG/dusklight/blob/main/platforms/switch/LESSONS_AND_REUSE.md):
|
||||
|
||||
| Dusklight lesson | How Gen1Recomp applied it |
|
||||
| ---------------- | ------------------------- |
|
||||
| Emulators hide Tegra failures | Gate milestones on **real OLED hardware**, not Ryujinx/Yuzu alone |
|
||||
| Prove the lower layer first | `tools/switch-probe` before full launcher |
|
||||
| Know which binary ran | Embedded `build-info.json` (commit / love-nx tag) |
|
||||
| Cap continuous logs | Opt-in diagnostics, ≤1 Hz flush; Lua error log rotation |
|
||||
| Crash symbolization needs the exact ELF | Keep pinned `love.elf` with the NRO under test |
|
||||
| Full memory matters | Title override; Applet Mode is not the validation path |
|
||||
| Do not treat SD FS like desktop POSIX | Lua stays on `love.filesystem`; inbox + MTP for user files |
|
||||
| Isolate platform code | Capability module instead of Android flag overload |
|
||||
| NVK / WSI / `audren` stacks | **Not** copied — love-nx already supplies video/audio/input/FS |
|
||||
|
||||
The packaging goal matches Dusklight’s **single self-contained `.nro`**; contributor transfer stays multi-host (not Mac-only).
|
||||
|
||||
## Known limitations (read before reviewing)
|
||||
|
||||
1. **Transfer is manual and multi-method.** Runtime only needs files under the LÖVE save directory / NRO install folder. Use MTP, direct SD, or FTP per [switch-transfer.md](switch-transfer.md). macOS + OpenMTP is a documented example for OLED evidence — not “Switch requires a Mac.”
|
||||
2. **Deploy is manual.** There is no automated push to the console and no `nxlink` path yet. Operators build locally, transfer files, then title-override launch.
|
||||
3. **Hardware coverage.** Author P0/P1 pass rows were recorded on one Switch OLED; Switch V1 boot was confirmed independently. Treat Lite, docked soak, and other hosts as unknown until someone re-runs the checklist.
|
||||
4. **No ROM/save/mod zip bytes in git.** Legal dumps and third-party mods stay on the console (or local untracked folders).
|
||||
5. **AppleDouble sidecars** (`._*`) from some MTP clients can break zip/ROM/`.sav` scans — the launcher skips hidden `.*` names (including `._*.sav`); still prefer clean copies.
|
||||
|
||||
## How we tested
|
||||
|
||||
| Layer | What | Where |
|
||||
| ----- | ---- | ----- |
|
||||
| Unit / headless | Platform NX flags, RomImporter inbox, dual-path input, mod zip inbox, save `.sav` inbox, display chords, payload/self-tests | `tests/*`, `scripts/test.sh` |
|
||||
| Switch CI / packaging | Path-gated offline selftest (`selftest_build_switch.sh`, `verify_payload.sh --self-test`, `switch_ci_workflows_test.lua`); canonical fused PR artifact | `.github/workflows/ci.yml`, [switch-build.md](switch-build.md) § CI and release |
|
||||
| Probe on hardware | `getOS()==NX`, 1280×720, save path, Joy-Con events | `tools/switch-probe` → OLED |
|
||||
| Integration on hardware | MTP inbox ROM import, Play Red/Blue, naming A/B, quit/reopen save, suspend×10, reboot, fused NRO alone + NRO-only update | `docs/switch-hardware-evidence.md` |
|
||||
| Community hardware | Switch V1 / Erista boot with prebuilt NRO | [booshankles](https://github.com/booshankles) — see evidence log |
|
||||
| Known gaps | Docked soak, ≥30 min long-play, Lite, automated/`nxlink` deploy | Matrix deferred / absent rows |
|
||||
|
||||
Operator evidence must stay in `docs/switch-hardware-evidence.md`. **Do not invent passes** for hardware not run.
|
||||
|
||||
## love-nx 11.5-nx1 (pinned)
|
||||
|
||||
**Tag:** [11.5-nx1](https://github.com/retronx-team/love-nx/releases/tag/11.5-nx1)
|
||||
|
||||
**Local layout (not committed):**
|
||||
|
||||
```text
|
||||
.bazinga/love-nx/11.5-nx1/
|
||||
├── love.nro # homebrew launcher binary (loose mode: copied to gen1recomp.nro)
|
||||
└── love.elf # required for fused NRO builds (devkitPro nacptool/elf2nro)
|
||||
```
|
||||
|
||||
**Manifest:** `scripts/switch/love-nx-11.5-nx1.sha256` lists expected artifact names and SHA-256 checksums. Checksums are filled when binaries are fetched (`TBD_*` placeholders until then).
|
||||
|
||||
### Fetch instructions
|
||||
|
||||
Preferred (automated checksum verify):
|
||||
|
||||
```bash
|
||||
scripts/build_switch.sh --fetch
|
||||
```
|
||||
|
||||
That downloads pinned `love.nro` + `love.elf` into `.bazinga/love-nx/11.5-nx1/`
|
||||
and checks them against `scripts/switch/love-nx-11.5-nx1.sha256`. See
|
||||
[switch-build.md](switch-build.md) for the full mode glossary.
|
||||
|
||||
Manual fallback:
|
||||
|
||||
1. Open the [11.5-nx1 release](https://github.com/retronx-team/love-nx/releases/tag/11.5-nx1) and download `love.nro` and `love.elf`.
|
||||
2. Create the directory: `mkdir -p .bazinga/love-nx/11.5-nx1`
|
||||
3. Move both files into that directory.
|
||||
4. Confirm checksums match the manifest:
|
||||
|
||||
```bash
|
||||
shasum -a 256 .bazinga/love-nx/11.5-nx1/love.nro \
|
||||
.bazinga/love-nx/11.5-nx1/love.elf
|
||||
```
|
||||
|
||||
**Never commit** love-nx binaries, ROM dumps, or generated cache into git. The repo `.gitignore` excludes `.bazinga/` (vendor cache) and `/dist/` (build output).
|
||||
|
||||
## Loose-mode dist layout
|
||||
|
||||
Development builds place `gen1recomp.nro` and `game.love` side by side:
|
||||
|
||||
```text
|
||||
dist/switch/loose/
|
||||
├── gen1recomp.nro
|
||||
└── game.love
|
||||
```
|
||||
|
||||
Assemble with:
|
||||
|
||||
```bash
|
||||
scripts/build_switch.sh --loose
|
||||
```
|
||||
|
||||
(See `scripts/switch/assemble_loose.sh` for the underlying copy + checksum step.)
|
||||
|
||||
## Transfer & deploy (current contributor loop)
|
||||
|
||||
Detail for **macOS / Linux / Windows** and **MTP / SD / FTP** lives in
|
||||
[switch-transfer.md](switch-transfer.md). Summary:
|
||||
|
||||
| Layer | Intent |
|
||||
| ----- | ------ |
|
||||
| **Runtime / players** | Extract the release zip at microSD root (`switch/gen1recomp/`) and land ROMs/mods under the save-dir inboxes. The game does not hard-depend on OpenMTP or macOS. |
|
||||
| **Contributor loop** | Manual copy via MTP (DBI responder), direct SD (Hekate UMS / reader), or FTP. Fully manual — no CI deploy, no `nxlink` yet. |
|
||||
|
||||
The Mac + OpenMTP steps that remain below are the **OLED evidence reproduction** path; prefer the transfer runbook for day-to-day contrib on other hosts.
|
||||
|
||||
**Still avoided for routine evidence** (keeps SD handling honest):
|
||||
|
||||
- Treating `nxlink` / netloader as the release deploy story (deferred)
|
||||
- DBI `MicroSD install` / `NAND install` / NSP-style virtual folders for the `.love`/`.nro` pair
|
||||
|
||||
If MTP fails: check cable, USB port, DBI state, and that only one MTP client holds the device — then retry or switch to SD/FTP. Do not silently rewrite evidence using an untested path and claim parity with recorded SHA-256 round-trips.
|
||||
|
||||
### Manual deploy checklist (today)
|
||||
|
||||
1. Build on the contributor host (`scripts/build_switch.sh --loose` or fused).
|
||||
2. Close Gen1Recomp on the Switch; open DBI → `Run MTP responder`.
|
||||
3. Copy artifacts with your MTP client into `1: SD Card/switch/gen1recomp/` (and ROMs/mods into the save-dir inboxes when needed).
|
||||
4. Wait for the transfer queue; refresh; optionally round-trip SHA-256 on first artifacts of a type.
|
||||
5. Exit MTP; launch via **title override** (hold **R** on a title → hbmenu, not Applet Mode).
|
||||
|
||||
## OpenMTP + DBI transfer (loose build, Mac evidence example)
|
||||
|
||||
Full multi-OS / multi-method steps: [switch-transfer.md](switch-transfer.md).
|
||||
The numbered Mac loop below reproduces the OLED evidence path.
|
||||
|
||||
### On the Switch
|
||||
|
||||
1. Close Gen1Recomp if it is running.
|
||||
2. Open **DBI** from hbmenu.
|
||||
3. Select **`Run MTP responder`** (DBI documents `X` on the main screen).
|
||||
4. Keep DBI on that screen for the entire transfer.
|
||||
5. Connect the Switch to the Mac with a USB-C data cable.
|
||||
|
||||
### On the Mac
|
||||
|
||||
1. Close any other MTP clients.
|
||||
2. Open **OpenMTP** and select the DBI device.
|
||||
3. In the remote pane, open **`1: SD Card`**.
|
||||
4. Navigate to **`switch/`** and create **`gen1recomp/`** if needed.
|
||||
5. Enter **`1: SD Card/switch/gen1recomp/`**.
|
||||
6. Drag from the local pane:
|
||||
|
||||
```text
|
||||
dist/switch/loose/gen1recomp.nro
|
||||
dist/switch/loose/game.love
|
||||
```
|
||||
|
||||
7. Wait for the OpenMTP queue to finish completely.
|
||||
8. Refresh the remote listing and confirm file sizes match the local files.
|
||||
9. On the Switch, exit MTP responder normally in DBI before launching the app.
|
||||
|
||||
Expected layout on SD:
|
||||
|
||||
```text
|
||||
1: SD Card/
|
||||
└── switch/
|
||||
└── gen1recomp/
|
||||
├── gen1recomp.nro
|
||||
└── game.love
|
||||
```
|
||||
|
||||
## Round-trip SHA-256 verification
|
||||
|
||||
For the **first deploy** of each artifact type (loose pair, later fused NRO), verify MTP integrity:
|
||||
|
||||
1. **Before send** — record local hashes:
|
||||
|
||||
```bash
|
||||
shasum -a 256 dist/switch/loose/gen1recomp.nro \
|
||||
dist/switch/loose/game.love
|
||||
```
|
||||
|
||||
2. **After send** — in OpenMTP, copy the same files from `1: SD Card/switch/gen1recomp/` back to an empty local folder, e.g. `dist/switch/mtp-roundtrip/`.
|
||||
|
||||
3. **Compare** round-trip hashes:
|
||||
|
||||
```bash
|
||||
shasum -a 256 dist/switch/mtp-roundtrip/gen1recomp.nro \
|
||||
dist/switch/mtp-roundtrip/game.love
|
||||
```
|
||||
|
||||
4. Local pre-send and round-trip hashes **must match**. Record results in the test report template below.
|
||||
|
||||
Repeat whenever a cable glitch or interrupted transfer is suspected.
|
||||
|
||||
## Title override launch (full memory)
|
||||
|
||||
Applet Mode is **not** the primary validation path. Use **title override** so hbmenu runs with full memory:
|
||||
|
||||
1. Confirm the OpenMTP transfer queue finished.
|
||||
2. Exit MTP responder in DBI; disconnect USB if desired.
|
||||
3. Hold **`R`** while launching any legitimately installed title.
|
||||
4. Keep holding until **hbmenu** appears.
|
||||
5. Confirm hbmenu does **not** show **Applet Mode**.
|
||||
6. Launch **`gen1recomp`** (or the probe NRO during Phase 0).
|
||||
|
||||
Album / applet launches are only useful to document applet-specific limitations; P0/P1 gates use title override.
|
||||
|
||||
## Phase 0 hardware checklist
|
||||
|
||||
Complete **in order** on OLED hardware. Operator fills evidence fields — leave blank until tested.
|
||||
|
||||
| Step | Action | Pass | Evidence / notes |
|
||||
| ---- | ------ | ---- | ---------------- |
|
||||
| P0-0a | Fetch love-nx 11.5-nx1; record manifest SHA-256 | yes | See `scripts/switch/love-nx-11.5-nx1.sha256` |
|
||||
| P0-0b | Build `switch-probe.love` per `tools/switch-probe/README.md` | yes | |
|
||||
| P0-0c | Assemble loose probe (`game.love` = probe) to `dist/switch/loose/` | yes | |
|
||||
| P0-0d | MTP deploy to `1: SD Card/switch/gen1recomp/`; round-trip SHA-256 | yes | nro `8290ac15…5918f5`; love `9f198637…fa2e34f` |
|
||||
| P0-0e | Title override → probe boots; `getOS()` shows `NX` | yes | `getOS()`=`NX`, `love._os`=`NX` |
|
||||
| P0-0f | Probe lists 1280×720 (or documented dims), save path, gamepad/touch log | yes | save `sdmc:/switch/gen1recomp/switch-probe`; Joy-Con Y→#3 X→#4 |
|
||||
| P0-1a | Replace `game.love` with unpatched Gen1Recomp build | yes | feat/switch-nx inbox build |
|
||||
| P0-1b | MTP replace `game.love` only; round-trip SHA-256 | yes | |
|
||||
| P0-1c | Title override → launcher reaches import screen | yes | |
|
||||
| P0-1d | Joy-Con: can navigate launcher (no touch-only) | yes | Full report: `docs/switch-hardware-evidence.md` |
|
||||
|
||||
**Operator:** Andrew **Date:** 2026-08-01 **Console:** Switch OLED only
|
||||
**Deploy:** manual Mac + OpenMTP + DBI MTP (not automated)
|
||||
**love-nx tag:** 11.5-nx1 **gen1recomp commit:** `df7cea4`
|
||||
|
||||
## Phase 0 test report template
|
||||
|
||||
Copy this block into your hardware notes or PR evidence. **Do not commit ROM files or ROM hashes of private dumps.**
|
||||
|
||||
```markdown
|
||||
## Switch Phase 0 — hardware report
|
||||
|
||||
- Operator:
|
||||
- Date:
|
||||
- Console model:
|
||||
- Atmosphère / HOS version:
|
||||
- gen1recomp commit:
|
||||
- love-nx tag: 11.5-nx1
|
||||
- love.nro SHA-256 (local):
|
||||
- game.love SHA-256 (local, pre-send):
|
||||
- MTP round-trip SHA-256 (gen1recomp.nro):
|
||||
- MTP round-trip SHA-256 (game.love):
|
||||
- Title override used: yes / no
|
||||
- Applet Mode observed: yes / no (should be no for P0)
|
||||
- Probe getOS():
|
||||
- Probe dimensions:
|
||||
- Probe save directory shown:
|
||||
- Gamepad events logged: yes / no
|
||||
- Touch events logged: yes / no
|
||||
- Unpatched launcher boot: pass / fail
|
||||
- Joy-Con launcher navigation: pass / fail / not tested
|
||||
- Notes:
|
||||
```
|
||||
|
||||
## Fast dev loop (loose mode)
|
||||
|
||||
While iterating on Lua/assets:
|
||||
|
||||
1. Edit on Mac; run `scripts/test.sh --quick`.
|
||||
2. Rebuild `.bazinga/work/game.love` (`scripts/build.sh mac --no-notarize` or project pack step).
|
||||
3. Close Gen1Recomp on Switch.
|
||||
4. DBI → `Run MTP responder`.
|
||||
5. OpenMTP → `1: SD Card/switch/gen1recomp/`.
|
||||
6. Replace **only** `game.love`; wait for queue + refresh listing.
|
||||
7. Exit MTP responder; launch via title override.
|
||||
8. Keep `gen1recomp.nro` unchanged until the love-nx pin changes.
|
||||
|
||||
```bash
|
||||
scripts/test.sh --quick
|
||||
scripts/build.sh mac --no-notarize
|
||||
scripts/build_switch.sh --loose
|
||||
shasum -a 256 .bazinga/work/game.love
|
||||
```
|
||||
|
||||
## Controller input mapping (NX)
|
||||
|
||||
Measured on Switch OLED (`feat/switch-nx`, love-nx `11.5-nx1`, 1280×720). Both `joystickpressed` and `gamepadpressed` fire for Joy-Con; prefer the gamepad path when `joystick:isGamepad()` is true.
|
||||
|
||||
| Path | Control | Mapping |
|
||||
| ---- | ------- | ------- |
|
||||
| `gamepadpressed` | D-pad / left stick | move |
|
||||
| `gamepadpressed` | SDL `a` / `b` on **NX** | swapped via `NX_GAMEPAD_BINDINGS`: physical **A** (east) = GB A confirm, physical **B** (south) = GB B cancel |
|
||||
| `gamepadpressed` | SDL `a` / `b` on desktop | identity (SDL south = GB A) |
|
||||
| `gamepadpressed` | `start` / `back` | Start / Select (+ / −) |
|
||||
| `gamepadpressed` | Right / left shoulder (no Select) | Cycle game speed up / down (same as PC hotkey `1` / speed-down path) |
|
||||
| `joystickpressed` (raw) | only if **not** `isGamepad()` | face/menu fallback |
|
||||
| `joystickpressed` (raw) | `#1` / `#2` on NX | Nintendo B / A → GB B / A |
|
||||
| `joystickpressed` (raw) | `#9` / `#10` | Select / Start (− / +) |
|
||||
|
||||
**Nintendo UX on Switch:** physical A confirms, physical B cancels (explicit NX remap of SDL face labels).
|
||||
|
||||
**Launcher extras** (`RomImporter`): physical **A** clicks at the virtual cursor; **L** / **R** switch tabs; **Start** / **Select** start Play when a ROM is ready (else open Choose ROM). D-pad / left stick move the virtual cursor.
|
||||
|
||||
**Dual-path rule:** love-nx emits both `gamepadpressed` and `joystickpressed` for Joy-Con. When `joystick:isGamepad()` is true, Input and RomImporter **ignore raw** face/menu so NamingScreen does not see A+B in one frame. `NamingScreen` also prefers A over B if both edges still fire.
|
||||
|
||||
Implementation: `src/core/GamepadMap.lua` (`NX_RAW_*`, `ignoreRawForJoystick`, `displayChordDigit`), `src/core/Game.lua` (shoulder speed), `src/import/RomImporter.lua` (launcher tabs). Launcher and gameplay share the same converter.
|
||||
|
||||
## ROM inbox (NX)
|
||||
|
||||
Legal dumps land in a shared MTP inbox; **Scan again** is tab-scoped:
|
||||
|
||||
| Item | Value |
|
||||
| ---- | ----- |
|
||||
| Save-relative path | `imports/` (also accepts loose `.gb`/`.gbc` at the save-dir root) |
|
||||
| MTP destination | `1: SD Card/<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 game’s** folder |
|
||||
| Rescan | SAVE FILES → **Import save** on the matching game tab (scans only that folder) |
|
||||
| After success | Retire to `*.sav.imported` + append content hash to `imports/saves/<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 port’s own extras (TILT / GBC FX / survey ZOOM) and can cap FPS — useful on weaker handheld budgets. Details: [new-features.md — Performance tier](new-features.md#performance-tier-low-end-devices).
|
||||
|
||||
Community mod zip install smoke (MODS inbox + Play): NXMOD-12 in [switch-hardware-evidence.md](switch-hardware-evidence.md).
|
||||
|
||||
**Opt-in diagnostics:** create an empty `switch-debug.txt` in the save directory; events flush to `switch.log` at ≤1 Hz with build identity (no ROM/save bytes).
|
||||
|
||||
**NX asset probe (always on Play):** every Switch Play writes `nx-asset-probe.log` in the save directory (`pokemon-love2d/`). It lists whether `assets/generated/…` vs `yellow|blue/assets/generated/…` exist, what `Assets.resolve` returns, and whether `newImage` / `newImageData` open — for Yellow/Blue blank-sprite triage. No ROM bytes.
|
||||
|
||||
**Blue/Yellow cache overlay (NX):** fused love-nx cannot reliably mount `yellow|blue/assets/generated` onto the un-prefixed path, so `src/core/NxAssetOverlay.lua` wraps EVERY read-side love API that accepts a filesystem path (`filesystem.read/load/lines/newFileData/getInfo`, `graphics.newImage/newFont`, `image.newImageData`, `audio.newSource`, `sound.newSoundData`, `font.newFontData`) once at boot — only when `Platform.isNX()`. Covering the whole read surface (not just the loaders the boot needs today) keeps future states and mods inside the fallback automatically; write-side functions stay stock. Core code must NOT call love loaders on literal `assets/generated` paths (enforced by `tests/engine/nx_generated_guard_test.lua`); the chip-audio worker is a separate Lua state and gets the prefix explicitly via `audio.programPrefix` from `ChipAudio.slimAudio`.
|
||||
|
||||
**Hardware re-test:** T16 **pass** @ `2699c9a` (naming A=confirm / B=cancel). T19 **pass** (quit/reopen, suspend×10, reboot) — operator 2026-08-01.
|
||||
|
||||
**Suspend/resume audio:** after resume, chip music is stopped to avoid duplicate streams; confirm on hardware during P0-09/10 (T19).
|
||||
|
||||
## Lua error log (save directory)
|
||||
|
||||
On any uncaught Lua error, Gen1Recomp appends a redacted trace to `lua-error.log` in the LÖVE save directory (`love.filesystem.getSaveDirectory()`). The on-screen error overlay includes a hint pointing at that file. Logs rotate to `lua-error.log.1` when the active file exceeds 32 KiB. ROM/save bytes and non-printable data are stripped — never commit or share logs that might contain private paths without reviewing them first.
|
||||
|
||||
## Native crash triage (love-nx / Atmosphère)
|
||||
|
||||
love-nx native faults land under the console’s `crash_reports/` folder on SD (reachable via the same manual MTP workflow used for game deploys).
|
||||
|
||||
1. **Collect** — DBI → `Run MTP responder`; copy `sdmc:/crash_reports/*.bin` (or the dated subfolder) to the contributor host. Prefer keeping the microSD in-console for routine pulls.
|
||||
2. **Redact** — delete any attached screenshots or notes that mention ROM filenames, save paths, or private hashes before sharing logs publicly.
|
||||
3. **Symbolize** — use the **pinned** `love.elf` from `.bazinga/love-nx/11.5-nx1/` that matches `build-info.json` / `scripts/switch/love-nx-11.5-nx1.sha256`. Never use a “latest” download.
|
||||
|
||||
```bash
|
||||
# Example: aarch64-none-elf-addr2line from devkitPro
|
||||
aarch64-none-elf-addr2line -e .bazinga/love-nx/11.5-nx1/love.elf -f -C 0xADDRESS_FROM_CRASH_REPORT
|
||||
```
|
||||
|
||||
4. **Correlate** — compare `gitCommit` / `loveNxTag` from embedded `build-info.json` with the operator’s hardware notes.
|
||||
|
||||
If `addr2line` cannot resolve an address, archive the crash `.bin` with the exact `love.elf` SHA-256 used for the build — addresses are only meaningful against that ELF.
|
||||
|
||||
## P0 / P1 hardware matrix (ADR §9)
|
||||
|
||||
Operator evidence lives in `docs/switch-hardware-evidence.md`. **Do not invent passes** for rows that require hardware not yet run.
|
||||
|
||||
| ID | Requirement | Status | Evidence |
|
||||
| -- | ----------- | ------ | -------- |
|
||||
| P0-0a–f | love-nx pin, probe, MTP, title override | **pass** | Phase 0 checklist above; T4 |
|
||||
| P0-1a–d | Unpatched launcher boot + Joy-Con nav | **pass** | T4 / `docs/switch-hardware-evidence.md` |
|
||||
| P0-02 | MTP inbox import path shown | **pass** | T12 |
|
||||
| P0-03 | Rescan imports ROM | **pass** | T12 |
|
||||
| P0-04 | Canonical hash routes version | **pass** | T12 |
|
||||
| P0-05 | Source dump retained in inbox | **pass** | T12 |
|
||||
| P0-06 | Play reaches game after import | **pass** | T12 |
|
||||
| P0-07 | Joy-Con launcher navigation | **pass** | T16 @ `2699c9a` |
|
||||
| P0-08 | Joy-Con gameplay (incl. naming A/B) | **pass** | T16 @ `2699c9a` |
|
||||
| P0-09 | Save survives quit + reopen | **pass** | T19 |
|
||||
| P0-10 | ≥10 suspend cycles, no stuck input/dup audio | **pass** | T19 (operator 2026-08-01) |
|
||||
| P0-12 | Fused NRO boots without adjacent `game.love` | **pass** | T24 — `docs/switch-hardware-evidence.md` |
|
||||
| P0-14 | Fused NRO MTP round-trip SHA-256 | **pass** | T24 — first artifact `b019e2e8…` @ `6fb5602` (redeploy after Blue fix) |
|
||||
| P0-15 | Replace NRO only; saves persist | **pass** | T24 — operator NRO-only update keeps saves |
|
||||
| P1-01 | Docked vs handheld spot-check | **deferred** | Code: `NxDisplay` 720p↔1080p; OLED dock soak not recorded yet |
|
||||
| P1-02 | Applet Mode documented unsupported | **pass** | Title override required; Album path not validated |
|
||||
| P1-03 | Long-play soak (≥30 min) | **deferred** | No soak session recorded |
|
||||
| P1-04 | Reboot persistence | **pass** | T19 |
|
||||
| P1-05 | Audio resume after suspend | **pass** | T19 (no dup audio reported) |
|
||||
| — | Switch V1 / Erista boot | **pass** (boot) | Community — [booshankles](https://github.com/booshankles); see evidence log |
|
||||
| — | Switch Lite / docked soak | **untested** / **deferred** | Welcome contributions |
|
||||
| — | Automated / `nxlink` deploy | **absent** | Manual MTP / SD / FTP only (AD-009) |
|
||||
| — | Multi-OS transfer runbooks | **pass** | [switch-transfer.md](switch-transfer.md) |
|
||||
| — | Community mod zip OLED smoke (NXMOD-12) | **pass** | `docs/switch-hardware-evidence.md` |
|
||||
|
||||
## Review guidance
|
||||
|
||||
Maintainers may review as one PR or split later. Suggested slices (optional):
|
||||
|
||||
Each slice should declare: **no ROM/save bytes committed**, **love-nx pin with manifest checksums**, **hardware-tested rows listed with linked evidence**, **Applet Mode unsupported**, **network/updater disabled on NX**, **deploy still manual** (MTP / SD / FTP; no nxlink yet), **OpenMTP is one example not the sole contract**.
|
||||
|
||||
### Slice 1 — Platform + import (`platform/import`)
|
||||
|
||||
- `src/core/Platform.lua`, `conf.lua` NX branch
|
||||
- `src/import/RomImporter.lua` (NX flags, inbox, scan, shell/updater gates)
|
||||
- Tests: `tests/engine/platform_nx_*`, `tests/engine/rom_importer_nx_*` (ROM-free T2)
|
||||
- Docs: inbox/MTP import sections only
|
||||
|
||||
### Slice 2 — Input + lifecycle (`input/lifecycle`)
|
||||
|
||||
- `src/core/GamepadMap.lua`, `Input.lua`, `main.lua` focus/joystick hooks
|
||||
- `src/debug/SwitchDiagnostics.lua` (opt-in probe + error log)
|
||||
- Tests: input/diagnostics suites
|
||||
- Docs: controller mapping, suspend/audio notes
|
||||
|
||||
### Slice 3 — Build + docs (`build/docs`)
|
||||
|
||||
- `scripts/pack_love.sh`, `scripts/build_switch.sh`, `scripts/switch/*`
|
||||
- `assets/switch/icon.jpg`, `docs/switch-development.md`, hardware evidence templates
|
||||
- Gates: `pack_love.sh --dry-run`, `verify_payload.sh --self-test`, fused build script (devkitPro host)
|
||||
|
||||
**Pre-merge checklist:**
|
||||
|
||||
- [ ] Manifest `scripts/switch/love-nx-11.5-nx1.sha256` filled; binaries not in git
|
||||
- [ ] `verify_payload.sh` rejects generated cache / ROM / `.sav` / `.bak`
|
||||
- [ ] P0 matrix rows marked pass only with linked hardware evidence
|
||||
- [x] Fused NRO P0-12/14/15 pass with T24 evidence (`docs/switch-hardware-evidence.md`)
|
||||
- [ ] Updater / remote mod download hidden on NX (`networkValidated == false`)
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -2,15 +2,11 @@
|
||||
|
||||
Every GitHub Release that includes Switch support ships an SD-ready zip:
|
||||
`gen1recomp-*-switch.zip`. Extract it at the root of your microSD (install
|
||||
**or** update — same steps), launch with **title override**, then import your
|
||||
or update, same steps), launch with **title override**, then import your
|
||||
own legal `.gb` ROM.
|
||||
|
||||
> You need a console that can run Switch homebrew (custom firmware / hbmenu).
|
||||
> This project does not help you set that up. Tracks issue
|
||||
> [#531](https://github.com/bryanthaboi/gen1recomp/issues/531).
|
||||
> Hardware: **OLED** validated by the porter; **V1 / Erista** boot confirmed
|
||||
> by the community. Lite and other setups welcome more reports.
|
||||
> See [switch-development.md](switch-development.md) for limitations.
|
||||
> This project does not help you set that up.
|
||||
|
||||
Prefer building from source? See [switch-build.md](switch-build.md).
|
||||
|
||||
@@ -29,12 +25,18 @@ help from [booshankles](https://github.com/booshankles).
|
||||
Extract the zip at the **root** of the microSD so you get:
|
||||
|
||||
```text
|
||||
sdmc:/switch/gen1recomp/gen1recomp.nro
|
||||
sdmc:/switch/gen1recomp/gen1recomp.nro # native OTA launcher (hbmenu entry)
|
||||
sdmc:/switch/gen1recomp/gen1recomp-game.nro # fused LÖVE game
|
||||
sdmc:/switch/gen1recomp/version.txt
|
||||
sdmc:/switch/gen1recomp/pokemon-love2d/imports/
|
||||
sdmc:/switch/gen1recomp/pokemon-love2d/imports/mods/
|
||||
sdmc:/switch/gen1recomp/pokemon-love2d/imports/saves/...
|
||||
```
|
||||
|
||||
Older single-NRO zips only had `gen1recomp.nro` (the fused game). Current
|
||||
releases use the dual-NRO layout above. Open `gen1recomp` in hbmenu (the
|
||||
launcher).
|
||||
|
||||
Merge folders if your OS asks. Any method works: **MTP** (DBI → Run MTP
|
||||
responder + a client), **direct SD** (Hekate UMS or a card reader), or **FTP**.
|
||||
Exit MTP / unmount / stop FTP cleanly before launching. Step-by-step for
|
||||
@@ -42,17 +44,44 @@ macOS, Linux, and Windows: [switch-transfer.md](switch-transfer.md).
|
||||
|
||||
### Updating
|
||||
|
||||
Use the **same** extract/merge. It replaces `gen1recomp.nro` (and the small
|
||||
help `README.txt` / `INSTALL.txt` files). Saves, imported ROMs, mods, and
|
||||
options live under `pokemon-love2d/` — **do not delete that folder** when
|
||||
updating, or you will lose progress.
|
||||
#### Native OTA launcher (in-console)
|
||||
|
||||
Switch OTA runs in a separate **native launcher NRO** (libnx + curl), not the
|
||||
LÖVE self-updater (`src/update/Check.lua`). hbmenu opens `gen1recomp.nro`.
|
||||
|
||||
When a newer release exists, the launcher downloads the same install zip
|
||||
(`gen1recomp-*-switch.zip`), checks SHA-256 against `sha256sums.txt`, replaces
|
||||
both `gen1recomp-game.nro` and `gen1recomp.nro` (keeps NACP version in sync
|
||||
for hbmenu and Sphaira), then loads the game with `envSetNextLoad`.
|
||||
|
||||
If you are up to date or offline, it skips straight to the game with no
|
||||
prompt. If an update is available, you get a short prompt styled like the
|
||||
in-game launcher: black background, RGB rail, logo, A/B buttons. Saves under
|
||||
`pokemon-love2d/` are not touched. See `src/update/SwitchOta.lua` for the
|
||||
wire format.
|
||||
|
||||
The LÖVE self-updater stays **disabled** on NX (`networkValidated == false`).
|
||||
|
||||
**Sphaira forwarder (HOME shortcut):** Sphaira copies name/version/icon into
|
||||
the installed forwarder at creation time. After an OTA (or zip) update, the
|
||||
`.nro` on the microSD already has the new version, but the HOME shortcut
|
||||
keeps the old badge until you **reinstall the forwarder once** in Sphaira
|
||||
(Install Forwarder again on `gen1recomp.nro`). Browsing the NRO in Sphaira /
|
||||
hbmenu always shows the live file version.
|
||||
|
||||
#### Manual zip (fallback)
|
||||
|
||||
Use the **same** extract/merge of `gen1recomp-*-switch.zip`. It replaces the
|
||||
NROs (and the small help `README.txt` / `INSTALL.txt` files). Saves,
|
||||
imported ROMs, mods, and options live under `pokemon-love2d/`. **Do not
|
||||
delete that folder** when updating, or you will lose progress.
|
||||
|
||||
## 3. Launch with title override
|
||||
|
||||
**Applet Mode is not supported** for this game (not enough memory).
|
||||
|
||||
1. On the Switch HOME menu, highlight any installed title.
|
||||
2. Hold **R** and launch that title — this opens hbmenu with full memory
|
||||
2. Hold **R** and launch that title. This opens hbmenu with full memory
|
||||
(title override).
|
||||
3. From hbmenu, open `gen1recomp`.
|
||||
|
||||
@@ -66,14 +95,14 @@ This project ships **no** game data. On first launch:
|
||||
(`.gbc`) dump into `switch/gen1recomp/pokemon-love2d/imports/` (the
|
||||
launcher also shows the live save-dir path). All three can sit in the
|
||||
same folder.
|
||||
2. Use **Scan again** on that game’s tab (Red / Blue / Yellow). Rescan
|
||||
matches by ROM SHA-1 for the open tab only — a Red dump never imports
|
||||
2. Use **Scan again** on that game's tab (Red / Blue / Yellow). Rescan
|
||||
matches by ROM SHA-1 for the open tab only. A Red dump never imports
|
||||
from the Yellow tab (and vice versa).
|
||||
|
||||
## 5. Import / Export a raw `.sav`
|
||||
|
||||
Continue a cart or PC battery save (or pull a slot off-console) via MTP /
|
||||
SD / FTP — same transfer methods as ROMs. Paths are **per game**:
|
||||
SD / FTP, same transfer methods as ROMs. Paths are **per game**:
|
||||
|
||||
| Game | Import inbox | Export folder |
|
||||
| ---- | ------------ | ------------- |
|
||||
@@ -81,19 +110,19 @@ SD / FTP — same transfer methods as ROMs. Paths are **per game**:
|
||||
| Blue | `imports/saves/blue/` | `exports/blue/` |
|
||||
| Yellow | `imports/saves/yellow/` | `exports/yellow/` |
|
||||
|
||||
(Under the save dir `pokemon-love2d/` — the zip already creates these folders.)
|
||||
(Under the save dir `pokemon-love2d/`. The zip already creates these folders.)
|
||||
|
||||
1. Copy a Gen1 `.sav` (32 KB) into that game’s inbox under the save dir
|
||||
1. Copy a Gen1 `.sav` (32 KB) into that game's inbox under the save dir
|
||||
([switch-transfer.md](switch-transfer.md)).
|
||||
2. With the game’s ROM already imported, open **that game’s tab** →
|
||||
2. With the game's ROM already imported, open **that game's tab** →
|
||||
**SAVE FILES** → **Import save**. Only that folder is scanned.
|
||||
3. A successful import retires the file to `*.sav.imported` and records its
|
||||
content hash so pressing **Import save** again does not clone slots.
|
||||
Failed imports leave the original `.sav` in place.
|
||||
4. To pull a slot off the console, use **Export save**, then copy the file
|
||||
from that game’s **`exports/<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).
|
||||
|
||||
@@ -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 game’s SAVE FILES → **Import save** |
|
||||
| Save `.sav` inbox | Same save dir → `imports/saves/red\|blue\|yellow/` then that game's SAVE FILES → **Import save** |
|
||||
| Save exports | Same save dir → `exports/red\|blue\|yellow/` (pull after **Export save**; MTP / SD / FTP) |
|
||||
| Opt-in diagnostics | Empty `switch-debug.txt` in the save dir → `switch.log` |
|
||||
| Lua error log | `lua-error.log` in the save dir |
|
||||
@@ -34,7 +33,7 @@ files, or third-party mod zips to git.
|
||||
|
||||
---
|
||||
|
||||
## Canonical methods
|
||||
## Transfer methods
|
||||
|
||||
### 1. MTP (DBI responder + host client)
|
||||
|
||||
@@ -47,8 +46,8 @@ before launching.
|
||||
|
||||
#### macOS (example: OpenMTP)
|
||||
|
||||
[OpenMTP](https://github.com/ganeshrvel/openmtp) is the loop used for OLED
|
||||
hardware evidence — **one contributor example**, not a Mac-only product rule.
|
||||
[OpenMTP](https://github.com/ganeshrvel/openmtp) is a documented example for
|
||||
macOS, not a Mac-only requirement.
|
||||
|
||||
1. Quit other MTP clients.
|
||||
2. Open OpenMTP → select the DBI device → **`1: SD Card`**.
|
||||
@@ -60,25 +59,25 @@ hardware evidence — **one contributor example**, not a Mac-only product rule.
|
||||
5. Wait for the queue; refresh; exit MTP responder; title-override launch.
|
||||
|
||||
macOS clients often create AppleDouble sidecars (`._Something.zip`,
|
||||
`._cart.gb`, `._foo.sav`). Those are not real archives or saves — the
|
||||
`._cart.gb`, `._foo.sav`). Those are not real archives or saves. The
|
||||
launcher skips hidden `.*` names. Delete `._*` junk if a zip/ROM/`.sav`
|
||||
fails to open.
|
||||
|
||||
#### Linux
|
||||
|
||||
1. Install desktop MTP support if needed (e.g. `gvfs-mtp` on GNOME/GTK
|
||||
desktops, or your distro’s KDE MTP stack).
|
||||
desktops, or your distro's KDE MTP stack).
|
||||
2. With DBI MTP active, open **Files** / **Dolphin** / **Thunar** and select
|
||||
the Switch / DBI device → **`1: SD Card`**.
|
||||
3. Extract the release zip at SD root (merge), or copy into `switch/gen1recomp/`
|
||||
and the save-dir inboxes as above.
|
||||
4. Use **only one** MTP accessor at a time. If `mtp-tools` / `mtpfs` reports
|
||||
“device is busy”, close the file manager’s MTP mount (or the CLI mount)
|
||||
"device is busy", close the file manager's MTP mount (or the CLI mount)
|
||||
and retry with a single client.
|
||||
5. Eject/unmount cleanly; exit MTP on the Switch; title-override launch.
|
||||
|
||||
If MTP is unavailable or flaky on Linux, use **direct SD** (Hekate UMS or a
|
||||
card reader) or **FTP** instead — same destinations in the table above.
|
||||
card reader) or **FTP** instead. Same destinations in the table above.
|
||||
|
||||
#### Windows
|
||||
|
||||
@@ -93,7 +92,7 @@ card reader) or **FTP** instead — same destinations in the table above.
|
||||
5. Safely disconnect; exit MTP on the Switch; title-override launch.
|
||||
|
||||
If MTP is unavailable or flaky on Windows, use **direct SD** (Hekate UMS or a
|
||||
card reader) or **FTP** instead — same destinations in the table above.
|
||||
card reader) or **FTP** instead. Same destinations in the table above.
|
||||
|
||||
### 2. Direct SD (Hekate UMS or card reader)
|
||||
|
||||
@@ -109,9 +108,9 @@ Do not yank the card or unplug UMS mid-write.
|
||||
|
||||
### 3. FTP (any SD-exposing Switch FTP)
|
||||
|
||||
Any homebrew FTP server that can write the microSD is fine — for example
|
||||
**DBI’s own FTP**, **sys-ftpd-light**, or **Sphaira** (names are illustrations
|
||||
only; pick what your CFW setup already uses).
|
||||
Any homebrew FTP server that can write the microSD is fine. For example
|
||||
**DBI's own FTP**, **sys-ftpd-light**, or **Sphaira**. Names are illustrations
|
||||
only; pick what your CFW setup already uses.
|
||||
|
||||
1. Start the FTP server on the Switch; note IP/port/credentials from that app.
|
||||
2. From the host, connect with any FTP client and upload to the same
|
||||
@@ -154,7 +153,7 @@ Copy the file back from the SD and compare hashes. Round-trip must match.
|
||||
| Symptom | What to try |
|
||||
| ------- | ----------- |
|
||||
| Device busy / no MTP volume | One client only; different cable/port; Windows MTP USB Device driver; alternate method (SD or FTP) |
|
||||
| Zip/ROM/`.sav` “could not be opened” | Delete `._*` sidecars (including `._*.sav`); confirm real zip starts with `PK` |
|
||||
| Zip/ROM/`.sav` "could not be opened" | Delete `._*` sidecars (including `._*.sav`); confirm real zip starts with `PK` |
|
||||
| Half-copied NRO / crash on boot | Re-copy; verify SHA-256; exit transfer mode before launch |
|
||||
| App opens in Applet Mode | Use title override (hold **R**), not Album |
|
||||
|
||||
@@ -164,5 +163,3 @@ Copy the file back from the SD and compare hashes. Round-trip must match.
|
||||
|
||||
- Players: [switch-install.md](switch-install.md)
|
||||
- Builders: [switch-build.md](switch-build.md)
|
||||
- Status / hardware matrix: [switch-development.md](switch-development.md)
|
||||
- Evidence log: [switch-hardware-evidence.md](switch-hardware-evidence.md)
|
||||
|
||||
@@ -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 |
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
|
Before Width: | Height: | Size: 153 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 5.0 KiB |
@@ -113,6 +113,11 @@ picker copies user-selected files into LocalState and the launcher imports them
|
||||
from there. Saves, ROM cache and installed mods remain under the LÖVE save
|
||||
directory in LocalState.
|
||||
|
||||
The launcher also checks `LocalState\pokemon-love2d\baseroms` once at startup
|
||||
for clean Red, Blue, and Yellow ROMs. Compatible files are offered on their
|
||||
launcher tabs and remain in `baseroms` after import. The file picker remains
|
||||
available when no compatible ROM is found.
|
||||
|
||||
LuaJIT requires the `codeGeneration` capability. `removableStorage` exposes
|
||||
external media to the Xbox picker. The network capabilities support relay play
|
||||
and direct hosting. The package does not request full trust or broad filesystem
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# LÖVE UWP binaries
|
||||
|
||||
These x64 UWP Release binaries were built from [`caorthann-celt/love-xbox-uwp`](https://github.com/caorthann-celt/love-xbox-uwp) at commit `62275ed10190afbec01f65754b3bcc04f244dd35`.
|
||||
These x64 UWP Release binaries were built from [`caorthann-celt/love-xbox-uwp`](https://github.com/caorthann-celt/love-xbox-uwp) at commit `cdf85f28ed794c95d6f5f7e3a8f23ca9ee1fbdcb`.
|
||||
|
||||
The build uses LÖVE 11.5, LuaJIT, SDL2, and ANGLE. Keep the DLLs and import libraries together; they are one binary interface.
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"love": {
|
||||
"version": "11.5",
|
||||
"repository": "https://github.com/caorthann-celt/love-xbox-uwp.git",
|
||||
"commit": "62275ed10190afbec01f65754b3bcc04f244dd35"
|
||||
"commit": "cdf85f28ed794c95d6f5f7e3a8f23ca9ee1fbdcb"
|
||||
},
|
||||
"luajit": {
|
||||
"repository": "https://github.com/LuaJIT/LuaJIT.git",
|
||||
@@ -99,7 +99,7 @@
|
||||
},
|
||||
{
|
||||
"path": "love/bin/love.dll",
|
||||
"sha256": "7B1BE4699AC950173474BB0C03B5DD52E1202ABA6F60C29E294957057F587692"
|
||||
"sha256": "A3622828014F03DDB502D2A7807DC8F94432172595BE03508099BD66937FD45F"
|
||||
},
|
||||
{
|
||||
"path": "love/bin/lua51.dll",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
@@ -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
|
||||
"
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
-- extracted cache lives, and the save-file suffix -- so the importer,
|
||||
-- cache mount, SaveData, title screen and palette all agree.
|
||||
--
|
||||
-- Red keeps every un-suffixed path it always used (save.lua, the root cache),
|
||||
-- so existing installs are untouched; Blue is namespaced under blue/ and
|
||||
-- _blue, Yellow under yellow/ and _yellow, so all three can be imported and
|
||||
-- Red keeps the un-suffixed save paths it always used (save.lua) so existing
|
||||
-- saves are untouched, but its extracted cache lives under red/ like Blue and
|
||||
-- Yellow (issue #899); a legacy root cache is moved into red/ once by
|
||||
-- CacheFs.migrateLegacyRedCache. All three versions can be imported and
|
||||
-- played side by side.
|
||||
--
|
||||
-- Zero requires, so it loads during love.conf and under plain Lua for tools
|
||||
@@ -23,7 +24,7 @@ GameVersion.VERSIONS = {
|
||||
launcherName = "Red", -- game-panel header in the launcher
|
||||
sha1 = "ea9bcae617fdf159b045185467ae58b2e4a48b9a",
|
||||
manifest = "tools/rom_manifest.json",
|
||||
cachePrefix = "", -- Red owns the cache root (backwards compatible)
|
||||
cachePrefix = "red/", -- red/data/generated, red/assets/generated (#899)
|
||||
saveSuffix = "", -- save.lua / save.lua.bak / save.lua.tmp
|
||||
},
|
||||
blue = {
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
|
||||
@@ -33,11 +33,11 @@ local Platform = require("src.core.Platform")
|
||||
local SEP = package.config:sub(1, 1)
|
||||
|
||||
-- Cache-relative paths are prefixed with this before every read/write, so a
|
||||
-- Blue/Yellow import lands under its GameVersion.cachePrefix (blue/, yellow/)
|
||||
-- while a Red import keeps the historical root. The launcher sets it per
|
||||
-- import / per readiness check; it stays "" for Red. Runtime *reads*
|
||||
-- (require / newImage) do NOT go through here -- CacheFs.mountVersion overlays
|
||||
-- the active version's subtree onto the un-prefixed paths instead.
|
||||
-- version's import lands under its GameVersion.cachePrefix (red/, blue/,
|
||||
-- yellow/). The launcher sets it per import / per readiness check; it stays
|
||||
-- "" outside those flows. Runtime *reads* (require / newImage) do NOT go
|
||||
-- through here -- CacheFs.mountVersion overlays the active version's subtree
|
||||
-- onto the un-prefixed paths instead.
|
||||
CacheFs.prefix = ""
|
||||
|
||||
local function withPrefix(rel)
|
||||
@@ -383,17 +383,120 @@ function CacheFs.removeTree(rel)
|
||||
walk(rel)
|
||||
end
|
||||
|
||||
-- One-time move of Red's pre-#899 cache (data/generated, assets/generated
|
||||
-- and the rom-cache.complete marker at the cache root) into red/, the
|
||||
-- layout Blue and Yellow always used. Idempotent: an existing red/ cache
|
||||
-- wins and a missing root marker means nothing to do.
|
||||
--
|
||||
-- The two cache homes are handled separately: the save directory goes
|
||||
-- through love.filesystem so every host (NX included) and the headless test
|
||||
-- stub take the same path, and the portable game folder goes through
|
||||
-- os.rename on real paths -- skipped for a source run, where the game
|
||||
-- folder IS the checkout and its data/generated is Red's source data, not
|
||||
-- a cache. Called from RomImporter.new (before the readiness loop) and
|
||||
-- from mountVersion, so no boot path can probe red/ before the move ran.
|
||||
function CacheFs.migrateLegacyRedCache()
|
||||
if not (love and love.filesystem and love.filesystem.getInfo) then return end
|
||||
local fs = love.filesystem
|
||||
|
||||
local function hasFile(p) return fs.getInfo(p, "file") ~= nil end
|
||||
local function hasDir(p) return fs.getInfo(p, "directory") ~= nil end
|
||||
|
||||
local function moveFile(src, dst)
|
||||
local data = fs.read(src)
|
||||
if data then
|
||||
local parent = dst:match("^(.*)/[^/]+$")
|
||||
if parent and fs.createDirectory then fs.createDirectory(parent) end
|
||||
fs.write(dst, data)
|
||||
end
|
||||
fs.remove(src)
|
||||
end
|
||||
|
||||
local function moveTree(src, dst)
|
||||
for _, child in ipairs(fs.getDirectoryItems(src) or {}) do
|
||||
local sp, dp = src .. "/" .. child, dst .. "/" .. child
|
||||
if hasDir(sp) then moveTree(sp, dp) else moveFile(sp, dp) end
|
||||
end
|
||||
-- remove only takes an empty directory; a non-empty one simply stays
|
||||
fs.remove(src)
|
||||
end
|
||||
|
||||
-- --- save directory
|
||||
if hasDir("red/data/generated") or hasFile("red/rom-cache.complete") then
|
||||
-- already on the new layout
|
||||
elseif hasFile("rom-cache.complete") then
|
||||
-- The marker must be a save-dir file before anything moves: a developer
|
||||
-- checkout also resolves data/generated at the root, but from the physfs
|
||||
-- SOURCE, and moving that tree would gut the repository.
|
||||
local real = fs.getRealDirectory and fs.getRealDirectory("rom-cache.complete")
|
||||
if not real or (fs.getSaveDirectory and real == fs.getSaveDirectory()) then
|
||||
-- cheap path first: renames inside the same directory; the copy below
|
||||
-- covers whatever rename could not take (or hosts where the save dir
|
||||
-- is not a plain os path, like the headless stub)
|
||||
local saveDir = fs.getSaveDirectory and fs.getSaveDirectory()
|
||||
if saveDir and fs.createDirectory then
|
||||
fs.createDirectory("red/data")
|
||||
fs.createDirectory("red/assets")
|
||||
os.rename(saveDir .. SEP .. "data" .. SEP .. "generated",
|
||||
saveDir .. SEP .. "red" .. SEP .. "data" .. SEP .. "generated")
|
||||
os.rename(saveDir .. SEP .. "assets" .. SEP .. "generated",
|
||||
saveDir .. SEP .. "red" .. SEP .. "assets" .. SEP .. "generated")
|
||||
os.rename(saveDir .. SEP .. "rom-cache.complete",
|
||||
saveDir .. SEP .. "red" .. SEP .. "rom-cache.complete")
|
||||
end
|
||||
if hasDir("data/generated") then
|
||||
moveTree("data/generated", "red/data/generated")
|
||||
end
|
||||
if hasDir("assets/generated") then
|
||||
moveTree("assets/generated", "red/assets/generated")
|
||||
end
|
||||
if hasFile("rom-cache.complete") then
|
||||
moveFile("rom-cache.complete", "red/rom-cache.complete")
|
||||
end
|
||||
-- drop the emptied roots; a non-empty one (e.g. mods/ beside them is
|
||||
-- untouched -- only data and assets are cache subtrees) simply stays
|
||||
fs.remove("data")
|
||||
fs.remove("assets")
|
||||
end
|
||||
end
|
||||
|
||||
-- --- portable game folder (desktop only): rename on real paths
|
||||
local root = CacheFs.root()
|
||||
if root and not (fs.getSource and root == fs.getSource()) then
|
||||
local function rootHas(rel)
|
||||
local f = io.open(realPath(root, rel), "rb")
|
||||
if f then f:close() return true end
|
||||
return false
|
||||
end
|
||||
if rootHas("rom-cache.complete") and not rootHas("red/rom-cache.complete") then
|
||||
local mkdir = resolveMkdir()
|
||||
if mkdir then
|
||||
mkdir(realPath(root, "red"))
|
||||
mkdir(realPath(root, "red/data"))
|
||||
mkdir(realPath(root, "red/assets"))
|
||||
os.rename(realPath(root, "data/generated"),
|
||||
realPath(root, "red/data/generated"))
|
||||
os.rename(realPath(root, "assets/generated"),
|
||||
realPath(root, "red/assets/generated"))
|
||||
os.rename(realPath(root, "rom-cache.complete"),
|
||||
realPath(root, "red/rom-cache.complete"))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Overlay the active version's extracted cache onto the un-prefixed read
|
||||
-- paths, so require("data.generated.*") and love.graphics.newImage(
|
||||
-- "assets/generated/*") resolve to that version's files.
|
||||
--
|
||||
-- Non-Red versions live under blue/ / yellow/ in the save directory. On
|
||||
-- desktop fused+portable we PHYSFS_mount that folder by absolute path. On
|
||||
-- NX (and any host without a working FFI mount) love.filesystem.mount of
|
||||
-- the save-dir-relative name must succeed, or Play boots with Red's paths
|
||||
-- and Data:load dies. Always also prepend-mount the version's
|
||||
-- data/generated + assets/generated onto the un-prefixed paths so PhysFS
|
||||
-- directory non-merge (archive data/ vs save generated) cannot hide them.
|
||||
-- Each version lives under its cachePrefix folder in the save directory.
|
||||
-- On desktop fused+portable we PHYSFS_mount that folder by absolute path.
|
||||
-- On NX (and any host without a working FFI mount) love.filesystem.mount
|
||||
-- of the save-dir-relative name must succeed, or Play boots with another
|
||||
-- version's paths and Data:load dies. Always also prepend-mount the
|
||||
-- version's data/generated + assets/generated onto the un-prefixed paths
|
||||
-- so PhysFS directory non-merge (archive data/ vs save generated) cannot
|
||||
-- hide them.
|
||||
local function mountGeneratedTrees(prefix)
|
||||
prefix = prefix or ""
|
||||
if not (love and love.filesystem and love.filesystem.mount) then
|
||||
@@ -416,10 +519,13 @@ local function mountGeneratedTrees(prefix)
|
||||
end
|
||||
|
||||
function CacheFs.mountVersion(version)
|
||||
-- A legacy root Red cache has to move into red/ before anything probes
|
||||
-- red/ paths (idempotent and near-free once migrated, issue #899).
|
||||
if version == "red" then CacheFs.migrateLegacyRedCache() end
|
||||
local prefix = require("src.core.GameVersion").cachePrefix(version)
|
||||
local sub = prefix:gsub("/+$", "")
|
||||
|
||||
-- Save-dir relative mount first (NX / no-FFI). Prepend so blue|yellow win.
|
||||
-- Save-dir relative mount first (NX / no-FFI). Prepend so the version wins.
|
||||
if sub ~= "" and love.filesystem.mount
|
||||
and love.filesystem.getInfo(sub, "directory") then
|
||||
love.filesystem.mount(sub, "", false)
|
||||
@@ -436,21 +542,21 @@ function CacheFs.mountVersion(version)
|
||||
end
|
||||
end
|
||||
|
||||
-- Version-scoped generated trees → un-prefixed paths (Red prefix is "").
|
||||
-- Version-scoped generated trees → un-prefixed paths.
|
||||
mountGeneratedTrees(prefix)
|
||||
return true
|
||||
end
|
||||
|
||||
-- Undo mountVersion. A process normally mounts exactly one version and then
|
||||
-- boots it, but the launcher can open the save editor on a Blue/Yellow save,
|
||||
-- close it, and press Play on Red: with that version's subtree still
|
||||
-- prepended, Red's require("data.generated.*") and its generated art would
|
||||
-- silently resolve to the other game's files. Callers must also drop the
|
||||
-- generated modules from package.loaded (src.core.Data:unloadGenerated) --
|
||||
-- unmounting alone only fixes the read path, not what require already cached.
|
||||
-- boots it, but the launcher can open the save editor on one game's save,
|
||||
-- close it, and press Play on another: with the first version's subtree
|
||||
-- still prepended, the other's require("data.generated.*") and generated
|
||||
-- art would silently resolve to the first game's files. Callers must also
|
||||
-- drop the generated modules from package.loaded
|
||||
-- (src.core.Data:unloadGenerated) -- unmounting alone only fixes the read
|
||||
-- path, not what require already cached.
|
||||
--
|
||||
-- Returns true when nothing was mounted or the unmount took. Red is a no-op
|
||||
-- because its cache lives at the root and was never overlaid.
|
||||
-- Returns true when nothing was mounted or the unmount took.
|
||||
function CacheFs.unmountVersion(version)
|
||||
local prefix = require("src.core.GameVersion").cachePrefix(version)
|
||||
if prefix == "" then return true end
|
||||
|
||||
@@ -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
|
||||
@@ -541,11 +557,15 @@ local function romModel(imp, version, info, ready, locked)
|
||||
label = Strings("Import unavailable"), enabled = false }
|
||||
end
|
||||
local dropHint = imp.isNX and Strings("Copy the .gb/.gbc via MTP into imports/.")
|
||||
or (imp.android and Strings("Copy the .gb/.gbc via USB.")
|
||||
or Strings("Or drop the .gb/.gbc file here."))
|
||||
or (imp.baseRomDiscovery and Strings("Or copy the .gb/.gbc into baseroms/.")
|
||||
or (imp.android and Strings("Copy the .gb/.gbc via USB.")
|
||||
or Strings("Or drop the .gb/.gbc file here.")))
|
||||
local importing = imp.importing == version
|
||||
local erroring = imp.workState == "error" and imp.errorVersion == version
|
||||
local notice = imp.notice and imp.notice.version == version and imp.notice
|
||||
local baseRom = imp.baseRoms and imp.baseRoms[version]
|
||||
local scanning = imp.baseRomDiscovery and imp.baseRomScan
|
||||
and imp.baseRomScan.state ~= "done"
|
||||
if importing and (imp.workState == "working" or imp.workState == "complete") then
|
||||
return { state = imp.status or Strings("Importing"),
|
||||
detail = imp.detail or "", progress = imp.progress or 0 }
|
||||
@@ -564,6 +584,14 @@ local function romModel(imp, version, info, ready, locked)
|
||||
detail = ((notice.status or "") .. " " .. (notice.detail or ""))
|
||||
:gsub("^%s+", ""):gsub("%s+$", ""),
|
||||
label = importLabel, enabled = true }
|
||||
elseif baseRom then
|
||||
return { state = Strings("Compatible ROM found"),
|
||||
detail = Strings("Found in baseroms/: %s", baseRom.name),
|
||||
label = Strings("Import detected ROM"), enabled = true }
|
||||
elseif scanning then
|
||||
return { state = Strings("Checking baseroms..."),
|
||||
detail = Strings("Looking for compatible Red, Blue, and Yellow ROMs."),
|
||||
label = Strings("Import ROM"), enabled = false }
|
||||
elseif imp.returning[version] then
|
||||
return { state = Strings("Update required"),
|
||||
detail = Strings("This build needs a few more things from your ")
|
||||
@@ -922,6 +950,8 @@ local function buildGamePanel(imp, x, y, w, availH, m, version)
|
||||
Kit.text("title", Kit.ellipsize("title", gameName, w * 0.6), x, y, PAL.heading)
|
||||
local tagText, tagCol
|
||||
if ready then tagText, tagCol = Strings("GOOD TO GO"), PAL.green
|
||||
elseif imp.baseRoms and imp.baseRoms[version] then
|
||||
tagText, tagCol = Strings("ROM FOUND"), PAL.green
|
||||
elseif locked then tagText, tagCol = Strings("COMING SOON"), PAL.steel
|
||||
else tagText, tagCol = Strings("ROM REQUIRED"), PAL.yellow end
|
||||
local tagW = Kit.textWidth("micro", tagText) + math.floor(18 * m.s)
|
||||
|
||||
@@ -36,7 +36,7 @@ end
|
||||
-- the same unbootable save as before.
|
||||
local CACHE_FORMAT = "rom-cache-v10:"
|
||||
-- The completion marker is written under each version's cache prefix
|
||||
-- (rom-cache.complete for Red, blue/rom-cache.complete for Blue).
|
||||
-- (red/rom-cache.complete, blue/rom-cache.complete, ...).
|
||||
local MARKER_PATH = "rom-cache.complete"
|
||||
|
||||
-- The marker a finished import writes for a version: the generation tag plus
|
||||
@@ -139,8 +139,8 @@ local PAL = {
|
||||
|
||||
-- CacheFs.exists checks the game folder directly for a portable install,
|
||||
-- otherwise the save directory through love.filesystem. It honors
|
||||
-- CacheFs.prefix, so we point it at the version's cache subtree (Red at the
|
||||
-- root, Blue under blue/).
|
||||
-- CacheFs.prefix, so we point it at the version's cache subtree (red/,
|
||||
-- blue/, yellow/).
|
||||
local function allRequiredFilesExist(version)
|
||||
local CacheFs = require("src.import.CacheFs")
|
||||
local saved = CacheFs.prefix
|
||||
@@ -157,11 +157,15 @@ local function allRequiredFilesExist(version)
|
||||
end
|
||||
|
||||
-- A developer checkout / Python build leaves Red's generated data in the
|
||||
-- physfs SOURCE at the un-prefixed root; that is always current. Only Red
|
||||
-- ships this way (Blue is import-only), so this stays a Red-root check.
|
||||
-- physfs SOURCE at the un-prefixed root (the checked-out data/generated and
|
||||
-- assets/generated); it is always current and never moves into red/. Only
|
||||
-- Red ships this way (Blue/Yellow are import-only). The check goes through
|
||||
-- love.filesystem directly so the red/ cache prefix cannot hide the source
|
||||
-- tree, and the realDirectory test keeps a save-dir cache from counting.
|
||||
local function sourceTreeHasData()
|
||||
if not allRequiredFilesExist("red") or not love.filesystem.getRealDirectory then
|
||||
return false
|
||||
if not love.filesystem.getRealDirectory then return false end
|
||||
for _, path in ipairs(REQUIRED_FILES) do
|
||||
if love.filesystem.getInfo(path, "file") == nil then return false end
|
||||
end
|
||||
local real = love.filesystem.getRealDirectory(REQUIRED_FILES[1])
|
||||
return real == love.filesystem.getSource()
|
||||
@@ -218,8 +222,8 @@ local function purgeSaveDirCache()
|
||||
f:close()
|
||||
return true
|
||||
end
|
||||
-- Purge each version's stale save-directory copy (Red at the root, Blue
|
||||
-- under blue/) so it cannot shadow the portable game-folder cache.
|
||||
-- Purge each version's stale save-directory copy (under its red/ / blue/
|
||||
-- / yellow/ prefix) so it cannot shadow the portable game-folder cache.
|
||||
for _, version in ipairs(GameVersion.ORDER) do
|
||||
local prefix = GameVersion.cachePrefix(version)
|
||||
if saveDirHas(prefix .. MARKER_PATH) or saveDirHas(prefix .. REQUIRED_FILES[1]) then
|
||||
@@ -341,6 +345,7 @@ local function commandOutput(command)
|
||||
end
|
||||
|
||||
local IMPORTS_DIR = "imports"
|
||||
local BASE_ROMS_DIR = "baseroms"
|
||||
local MODS_INBOX_DIR = "imports/mods"
|
||||
local SAVES_INBOX_DIR = "imports/saves"
|
||||
local ROM_BYTES = 1024 * 1024
|
||||
@@ -474,6 +479,65 @@ local function listRomPaths(dir)
|
||||
return paths
|
||||
end
|
||||
|
||||
local function baseRomScanSatisfied(self)
|
||||
for _, version in ipairs(GameVersion.ORDER) do
|
||||
if not self.ready[version] and not self.baseRoms[version] then
|
||||
return false
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
function RomImporter:_queueBaseRomScan()
|
||||
if not self.baseRomDiscovery then return end
|
||||
if baseRomScanSatisfied(self) then
|
||||
self.baseRomScan = { state = "done" }
|
||||
return
|
||||
end
|
||||
self.baseRomScan = { state = "queued", index = 1 }
|
||||
end
|
||||
|
||||
function RomImporter:_stepBaseRomScan()
|
||||
local scan = self.baseRomScan
|
||||
if not scan or scan.state == "done" or self.workState == "working" then
|
||||
return
|
||||
end
|
||||
if scan.state == "queued" then
|
||||
local info = love.filesystem.getInfo(BASE_ROMS_DIR)
|
||||
if not info and love.filesystem.createDirectory then
|
||||
love.filesystem.createDirectory(BASE_ROMS_DIR)
|
||||
end
|
||||
scan.paths = listRomPaths(BASE_ROMS_DIR)
|
||||
table.sort(scan.paths)
|
||||
scan.state = "running"
|
||||
end
|
||||
|
||||
local path = scan.paths[scan.index]
|
||||
if not path then
|
||||
scan.state = "done"
|
||||
return
|
||||
end
|
||||
scan.index = scan.index + 1
|
||||
|
||||
local info = love.filesystem.getInfo(path, "file")
|
||||
if info and info.size == ROM_BYTES then
|
||||
local data = love.filesystem.read(path)
|
||||
if type(data) == "string" and #data == ROM_BYTES then
|
||||
local version = GameVersion.forSha1(sha1(data))
|
||||
if version and not self.ready[version] and not self.baseRoms[version] then
|
||||
self.baseRoms[version] = {
|
||||
path = path,
|
||||
name = path:match("[^/\\]+$") or path,
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if baseRomScanSatisfied(self) or not scan.paths[scan.index] then
|
||||
scan.state = "done"
|
||||
end
|
||||
end
|
||||
|
||||
local function listZipPaths(dir)
|
||||
local paths = {}
|
||||
for _, name in ipairs(love.filesystem.getDirectoryItems(dir) or {}) do
|
||||
@@ -1050,6 +1114,9 @@ function RomImporter.new(onComplete, opts)
|
||||
android = android,
|
||||
ios = mobileOS == "iOS",
|
||||
nativePicker = romImportMode == "native-picker",
|
||||
baseRomDiscovery = opts.launcher and Platform.isUWP(),
|
||||
baseRoms = {},
|
||||
baseRomScan = nil,
|
||||
-- One startup poll pass on both mobiles. iOS: files dropped through the
|
||||
-- Files app are swept into the save dir before Lua boots (GRBootstrap) with
|
||||
-- no love.focus event necessarily following. Android: the SAF picker is a
|
||||
@@ -1128,6 +1195,11 @@ function RomImporter.new(onComplete, opts)
|
||||
_padInited = false,
|
||||
}, RomImporter)
|
||||
|
||||
-- Pre-#899 installs keep Red's extracted cache at the save-dir root; move
|
||||
-- it under red/ before the readiness loop looks for red/ paths, or every
|
||||
-- such install would read as "never imported" and demand the ROM again.
|
||||
CacheFs.migrateLegacyRedCache()
|
||||
|
||||
for _, version in ipairs(GameVersion.ORDER) do
|
||||
local info = GameVersion.info(version)
|
||||
local ready = RomImporter.isReady(version) and not self.forceImport
|
||||
@@ -1144,6 +1216,7 @@ function RomImporter.new(onComplete, opts)
|
||||
.. (info.id == "yellow" and ".gbc" or ".gb")
|
||||
end
|
||||
self:_applyLastVersionTab()
|
||||
self:_queueBaseRomScan()
|
||||
|
||||
-- Android: import a save-dir .gb/.gbc that is not yet ready (USB drop or a
|
||||
-- leftover SAF pick), routed by SHA-1. Already-imported carts are skipped
|
||||
@@ -1750,6 +1823,21 @@ function RomImporter:choose(version)
|
||||
self:rescanAction(self.chooseVersion)
|
||||
return
|
||||
end
|
||||
local baseRom = self.baseRomDiscovery and self.baseRoms[self.chooseVersion]
|
||||
if baseRom then
|
||||
self.baseRoms[self.chooseVersion] = nil
|
||||
local data = love.filesystem.read(baseRom.path)
|
||||
if not data then
|
||||
self.notice = {
|
||||
version = self.chooseVersion,
|
||||
status = "The detected ROM is no longer available.",
|
||||
detail = "Choose Import ROM to select it another way.",
|
||||
}
|
||||
return
|
||||
end
|
||||
self:startData(data, baseRom.name)
|
||||
return
|
||||
end
|
||||
if self.nativePicker and love.system.getPickedFile then
|
||||
self.pickerPendingKind = "rom"
|
||||
if not pickFile("rom") then
|
||||
@@ -1872,6 +1960,7 @@ end
|
||||
function RomImporter:update(dt)
|
||||
self.pulse = self.pulse + dt
|
||||
self:_updatePadCursor(dt)
|
||||
self:_stepBaseRomScan()
|
||||
-- Pump the FlexLove view (input polling + the queued click actions). The
|
||||
-- flag is only set once draw() has built a tree, so headless runs and the
|
||||
-- test tier never touch the toolkit.
|
||||
@@ -2288,6 +2377,10 @@ function RomImporter:reimport(version)
|
||||
self.ready[version] = false
|
||||
self.returning[version] = false
|
||||
self.chooseVersion = version
|
||||
if self.baseRomDiscovery then
|
||||
self.baseRoms[version] = nil
|
||||
self:_queueBaseRomScan()
|
||||
end
|
||||
end
|
||||
|
||||
local function clamp(v, lo, hi)
|
||||
|
||||
@@ -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
|
||||
@@ -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,69 @@
|
||||
-- Issue #899: Red's extracted cache lives under red/ like blue/ and
|
||||
-- yellow/, and a legacy root cache (pre-fix installs) is migrated on first
|
||||
-- boot instead of reading as "never imported".
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check = T.check
|
||||
local eq = T.eq
|
||||
|
||||
local CacheFs = require("src.import.CacheFs")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
|
||||
eq(GameVersion.cachePrefix("red"), "red/",
|
||||
"Red's cache is namespaced under red/")
|
||||
|
||||
-- legacy layout: the marker and both generated trees at the save-dir root
|
||||
love.filesystem.write("rom-cache.complete", "rom-cache-v9:abc")
|
||||
love.filesystem.write("data/generated/maps.lua", "return {}")
|
||||
love.filesystem.write("data/generated/constants.lua", "return {}")
|
||||
love.filesystem.write("assets/generated/fonts/font.png", "font-bytes")
|
||||
|
||||
CacheFs.migrateLegacyRedCache()
|
||||
|
||||
eq(love.filesystem.read("red/rom-cache.complete"), "rom-cache-v9:abc",
|
||||
"the marker moved under red/")
|
||||
eq(love.filesystem.read("red/data/generated/maps.lua"), "return {}",
|
||||
"the data tree moved under red/")
|
||||
eq(love.filesystem.read("red/assets/generated/fonts/font.png"), "font-bytes",
|
||||
"the assets tree moved under red/")
|
||||
check(love.filesystem.read("rom-cache.complete") == nil,
|
||||
"the root marker is gone")
|
||||
check(love.filesystem.read("data/generated/maps.lua") == nil,
|
||||
"the root data tree is gone")
|
||||
check(love.filesystem.read("assets/generated/fonts/font.png") == nil,
|
||||
"the root assets tree is gone")
|
||||
|
||||
-- idempotent: a second run leaves the migrated tree alone
|
||||
CacheFs.migrateLegacyRedCache()
|
||||
eq(love.filesystem.read("red/rom-cache.complete"), "rom-cache-v9:abc",
|
||||
"a second run keeps the migrated cache")
|
||||
|
||||
-- an existing red/ cache wins over a legacy root leftover: no clobber
|
||||
love.filesystem.write("rom-cache.complete", "rom-cache-v9:STALE")
|
||||
CacheFs.migrateLegacyRedCache()
|
||||
eq(love.filesystem.read("red/rom-cache.complete"), "rom-cache-v9:abc",
|
||||
"an existing red/ cache is not clobbered")
|
||||
check(love.filesystem.read("rom-cache.complete") ~= nil,
|
||||
"the unmigrated leftover stays (a stale-marker re-import handles it)")
|
||||
love.filesystem.remove("rom-cache.complete")
|
||||
|
||||
-- mountVersion("red") overlays red/ at the un-prefixed paths, like blue/
|
||||
love.filesystem._mounts = {}
|
||||
check(CacheFs.mountVersion("red") == true, "mountVersion(red) returns true")
|
||||
eq(love.filesystem.read("assets/generated/fonts/font.png"), "font-bytes",
|
||||
"post-mount probe reads red assets at the un-prefixed path")
|
||||
eq(love.filesystem.read("data/generated/constants.lua"), "return {}",
|
||||
"post-mount probe reads red data at the un-prefixed path")
|
||||
|
||||
-- no legacy cache at all: migration is a no-op, not an error
|
||||
love.filesystem.remove("red/rom-cache.complete")
|
||||
love.filesystem.remove("red/data/generated/maps.lua")
|
||||
love.filesystem.remove("red/data/generated/constants.lua")
|
||||
love.filesystem.remove("red/assets/generated/fonts/font.png")
|
||||
CacheFs.migrateLegacyRedCache()
|
||||
check(love.filesystem.read("red/rom-cache.complete") == nil,
|
||||
"nothing to migrate invents nothing")
|
||||
|
||||
T.finish()
|
||||
@@ -0,0 +1,145 @@
|
||||
-- The battle hit sounds must carry pokered's wFrequencyModifier onto the
|
||||
-- noise channel (#826; #902 reports the same swap). PlayApplyingAttackSound
|
||||
-- (engine/battle/animations.asm) picks SFX_DAMAGE / SFX_SUPER_EFFECTIVE /
|
||||
-- SFX_NOT_VERY_EFFECTIVE off wDamageMultipliers and writes a frequency
|
||||
-- modifier with it ($20 / $e0 / $50), and Audio2_ApplyFrequencyModifier adds
|
||||
-- that to the polynomial-counter byte -- the low byte of NR43 -- with 8-bit
|
||||
-- wrap (audio/engine_2.asm). The three programs are CHAN8-only, so that byte
|
||||
-- IS their pitch. Dropped, the super effective hit reads as the duller of
|
||||
-- the two: super effective's tail (shifts 3 then 6) sits below not very
|
||||
-- effective's (5, 4, 2, 2), which is exactly the "swapped" sound #826 and
|
||||
-- #902 describe. With the modifier on, super effective goes to shifts 1/4 --
|
||||
-- a bright crack -- and not very effective to 10/9/7/7 -- a dull thud.
|
||||
--
|
||||
-- ROM-free: ChipAsm blobs stand in for the sfx headers, so nothing here
|
||||
-- reads data/generated/. The noise-note streams below are transcribed from
|
||||
-- audio/sfx/{damage,super_effective,not_very_effective}.asm, so the "sounds
|
||||
-- swapped when bare" ordering is asserted against the real program shape.
|
||||
-- luajit tests/engine/hit_sfx_noise_pitch_bug826.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check = T.check
|
||||
|
||||
love = require("tests.love_stub")
|
||||
|
||||
local ChipAsm = require("src.audio.ChipAsm")
|
||||
local ChipSynth = require("src.core.ChipSynth")
|
||||
|
||||
local data = { audio = {} }
|
||||
|
||||
-- noise_note len, volume, fade, parameter (the asm streams, verbatim)
|
||||
local HITS = {
|
||||
{
|
||||
name = "Damage",
|
||||
pitch = 0x20,
|
||||
notes = {
|
||||
{ len = 2, parameter = 0x44 },
|
||||
{ len = 2, parameter = 0x14 },
|
||||
{ len = 15, parameter = 0x32 },
|
||||
},
|
||||
want = { 0x64, 0x34, 0x52 },
|
||||
},
|
||||
{
|
||||
name = "Super_Effective",
|
||||
pitch = 0xe0,
|
||||
notes = {
|
||||
{ len = 4, parameter = 0x34 },
|
||||
{ len = 15, parameter = 0x64 },
|
||||
},
|
||||
want = { 0x14, 0x44 },
|
||||
},
|
||||
{
|
||||
name = "Not_Very_Effective",
|
||||
pitch = 0x50,
|
||||
notes = {
|
||||
{ len = 4, parameter = 0x55 },
|
||||
{ len = 2, parameter = 0x44 },
|
||||
{ len = 8, parameter = 0x22 },
|
||||
{ len = 15, parameter = 0x21 },
|
||||
},
|
||||
want = { 0xa5, 0x94, 0x72, 0x71 },
|
||||
},
|
||||
}
|
||||
|
||||
local function hitDef(notes)
|
||||
local program = {}
|
||||
for _, n in ipairs(notes) do
|
||||
program[#program + 1] = {
|
||||
noiseNote = { len = n.len, volume = 15, fade = 1, parameter = n.parameter },
|
||||
}
|
||||
end
|
||||
return ChipAsm.sfx{ channels = { { hw = 4, program = program } } }
|
||||
end
|
||||
|
||||
-- every noise note the program emits, as { parameter, duration }, walking
|
||||
-- one event at a time by marking each consumed
|
||||
local function noiseNotes(def, offset)
|
||||
local engine = ChipSynth.newEngine(data, def, {
|
||||
sfx = true, allowLoops = false, frequencyOffset = offset,
|
||||
})
|
||||
local channel = assert(engine.channels[1], "hit sfx uses exactly CHAN8")
|
||||
local out = {}
|
||||
while not engine:finished() do
|
||||
channel:sample()
|
||||
local event = channel.event
|
||||
if not event then break end
|
||||
if event.noiseParameter ~= nil then
|
||||
out[#out + 1] = { parameter = event.noiseParameter, duration = event.duration }
|
||||
end
|
||||
event.sample = event.samples -- force the walk on to the next event
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- NR43 shift-clock nibble, weighted by each note's on-air duration: the
|
||||
-- number #826/#902 ears actually compare (higher = duller)
|
||||
local function weightedShift(notes)
|
||||
local total, sum = 0, 0
|
||||
for _, n in ipairs(notes) do
|
||||
total = total + n.duration
|
||||
sum = sum + math.floor(n.parameter / 16) * n.duration
|
||||
end
|
||||
return total > 0 and sum / total or 0
|
||||
end
|
||||
|
||||
local results = {}
|
||||
for _, hit in ipairs(HITS) do
|
||||
local def = hitDef(hit.notes)
|
||||
local bare = noiseNotes(def, 0)
|
||||
local pitched = noiseNotes(def, hit.pitch)
|
||||
results[hit.name] = { bare = bare, pitched = pitched }
|
||||
check(#bare == #hit.notes,
|
||||
hit.name .. " program emits " .. #hit.notes .. " notes, not " .. #bare)
|
||||
for i, n in ipairs(hit.notes) do
|
||||
check(bare[i] and bare[i].parameter == n.parameter,
|
||||
("%s note %d reads NR43 $%02x unmodified"):format(hit.name, i, n.parameter))
|
||||
check(pitched[i] and pitched[i].parameter == hit.want[i],
|
||||
("%s note %d reads NR43 $%02x once $%02x is applied"):format(
|
||||
hit.name, i, hit.want[i], hit.pitch))
|
||||
end
|
||||
end
|
||||
|
||||
-- the ordering that IS the bug: unpitched, super effective ends duller than
|
||||
-- not very effective, so they sound swapped; pitched, the bright crack lands
|
||||
-- on super effective and the dull thud on not very effective
|
||||
local superBare = weightedShift(results.Super_Effective.bare)
|
||||
local nveBare = weightedShift(results.Not_Very_Effective.bare)
|
||||
check(superBare > nveBare,
|
||||
("bare, super effective (%.2f) reads duller than not very effective (%.2f)"):format(
|
||||
superBare, nveBare))
|
||||
local superPitched = weightedShift(results.Super_Effective.pitched)
|
||||
local nvePitched = weightedShift(results.Not_Very_Effective.pitched)
|
||||
check(superPitched < nvePitched,
|
||||
("pitched, super effective (%.2f) is the brighter hit, not very effective (%.2f) the duller"):format(
|
||||
superPitched, nvePitched))
|
||||
|
||||
-- the neutral hit shifts a shade duller than it used to be, per pokered
|
||||
local damageBare = weightedShift(results.Damage.bare)
|
||||
local damagePitched = weightedShift(results.Damage.pitched)
|
||||
check(damagePitched > damageBare,
|
||||
("the neutral hit dulls a little under $20 (%.2f -> %.2f)"):format(
|
||||
damageBare, damagePitched))
|
||||
|
||||
T.finish("hit sfx noise pitch (#826/#902)")
|
||||
@@ -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")
|
||||
@@ -0,0 +1,156 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local S = require("tests.harness").suite("UWP baseroms discovery")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Platform = require("src.core.Platform")
|
||||
local RomImporter = require("src.import.RomImporter")
|
||||
|
||||
love.data = love.data or {}
|
||||
love.system = love.system or {}
|
||||
love.filesystem = love.filesystem or {}
|
||||
|
||||
local MiB = 1024 * 1024
|
||||
local roms = {
|
||||
["baseroms/z-red.gb"] = string.rep("R", MiB),
|
||||
["baseroms/a-blue.gb"] = string.rep("B", MiB),
|
||||
["baseroms/b-yellow.gbc"] = string.rep("Y", MiB),
|
||||
["baseroms/small.gb"] = "small",
|
||||
["baseroms/unknown.gb"] = string.rep("?", MiB),
|
||||
}
|
||||
|
||||
local saved = {
|
||||
hash = love.data.hash,
|
||||
encode = love.data.encode,
|
||||
getOS = love.system.getOS,
|
||||
pickFile = love.system.pickFile,
|
||||
getPickedFile = love.system.getPickedFile,
|
||||
read = love.filesystem.read,
|
||||
getInfo = love.filesystem.getInfo,
|
||||
getDirectoryItems = love.filesystem.getDirectoryItems,
|
||||
createDirectory = love.filesystem.createDirectory,
|
||||
isReady = RomImporter.isReady,
|
||||
}
|
||||
|
||||
love.data.hash = function(_, data) return data:sub(1, 1) end
|
||||
love.data.encode = function(_, _, digest)
|
||||
if digest == "R" then return GameVersion.info("red").sha1 end
|
||||
if digest == "B" then return GameVersion.info("blue").sha1 end
|
||||
if digest == "Y" then return GameVersion.info("yellow").sha1 end
|
||||
return "0000000000000000000000000000000000000000"
|
||||
end
|
||||
|
||||
local reads, listings, picks = 0, 0, 0
|
||||
love.filesystem.read = function(path)
|
||||
reads = reads + 1
|
||||
return roms[path]
|
||||
end
|
||||
love.filesystem.getInfo = function(path, filter)
|
||||
if path == "baseroms" then
|
||||
return filter and nil or { type = "directory" }
|
||||
end
|
||||
local data = roms[path]
|
||||
if data and (not filter or filter == "file") then
|
||||
return { type = "file", size = #data }
|
||||
end
|
||||
return nil
|
||||
end
|
||||
love.filesystem.getDirectoryItems = function(path)
|
||||
if path ~= "baseroms" then return {} end
|
||||
listings = listings + 1
|
||||
return { "z-red.gb", "small.gb", "unknown.gb", "b-yellow.gbc", "a-blue.gb" }
|
||||
end
|
||||
love.filesystem.createDirectory = function() return true end
|
||||
love.system.pickFile = function()
|
||||
picks = picks + 1
|
||||
return true
|
||||
end
|
||||
love.system.getPickedFile = function() return nil end
|
||||
|
||||
local function importer(ready)
|
||||
return setmetatable({
|
||||
baseRomDiscovery = true,
|
||||
baseRoms = {},
|
||||
ready = ready or { red = false, blue = false, yellow = false },
|
||||
returning = {},
|
||||
workState = nil,
|
||||
nativePicker = true,
|
||||
isNX = false,
|
||||
}, RomImporter)
|
||||
end
|
||||
|
||||
local allReady = importer({ red = true, blue = true, yellow = true })
|
||||
allReady:_queueBaseRomScan()
|
||||
eq(allReady.baseRomScan.state, "done", "ready launcher skips discovery")
|
||||
eq(listings, 0, "ready launcher does not enumerate baseroms")
|
||||
|
||||
local imp = importer()
|
||||
imp:_queueBaseRomScan()
|
||||
for _ = 1, 5 do
|
||||
local before = reads
|
||||
imp:_stepBaseRomScan()
|
||||
check(reads - before <= 1, "discovery reads at most one ROM per step")
|
||||
end
|
||||
eq(listings, 1, "discovery enumerates baseroms once")
|
||||
eq(imp.baseRoms.blue.name, "a-blue.gb", "Blue ROM is detected by SHA-1")
|
||||
eq(imp.baseRoms.yellow.name, "b-yellow.gbc", "Yellow ROM is detected by SHA-1")
|
||||
eq(imp.baseRoms.red.name, "z-red.gb", "Red ROM is detected by SHA-1")
|
||||
eq(reads, 4, "wrong-sized files are skipped before reading")
|
||||
eq(imp.baseRomScan.state, "done", "discovery stops when every missing ROM is found")
|
||||
|
||||
local settledReads, settledListings = reads, listings
|
||||
for _ = 1, 10 do imp:_stepBaseRomScan() end
|
||||
eq(reads, settledReads, "completed discovery does not read again")
|
||||
eq(listings, settledListings, "completed discovery does not enumerate again")
|
||||
|
||||
local detected = importer()
|
||||
detected.baseRoms.red = { path = "baseroms/z-red.gb", name = "z-red.gb" }
|
||||
detected.startData = function(self, data, name)
|
||||
self.started = { data = data, name = name }
|
||||
end
|
||||
detected:choose("red")
|
||||
eq(detected.started.name, "z-red.gb", "detected ROM uses the normal import path")
|
||||
eq(picks, 0, "detected ROM does not open the picker")
|
||||
check(roms["baseroms/z-red.gb"] ~= nil, "detected ROM remains in baseroms")
|
||||
|
||||
local missing = importer()
|
||||
missing.baseRoms.red = { path = "baseroms/gone.gb", name = "gone.gb" }
|
||||
missing:choose("red")
|
||||
check(missing.notice ~= nil, "missing detected ROM reports a notice")
|
||||
eq(picks, 0, "missing detected ROM does not open the picker unexpectedly")
|
||||
missing:choose("red")
|
||||
eq(picks, 1, "the next import attempt falls back to the native picker")
|
||||
|
||||
local rescanned = importer({ red = true, blue = true, yellow = true })
|
||||
rescanned.baseRoms.red = { path = "baseroms/z-red.gb", name = "z-red.gb" }
|
||||
rescanned:reimport("red")
|
||||
check(rescanned.baseRoms.red == nil, "re-import clears the detected ROM")
|
||||
eq(rescanned.baseRomScan.state, "queued", "re-import queues one fresh scan")
|
||||
|
||||
RomImporter.isReady = function() return false end
|
||||
love.system.getOS = function() return "UWP" end
|
||||
Platform._resetForTests()
|
||||
local launcher = RomImporter.new(function() end, { launcher = true })
|
||||
check(launcher.baseRomDiscovery, "UWP launcher enables baseroms discovery")
|
||||
local importOnly = RomImporter.new(function() end)
|
||||
check(not importOnly.baseRomDiscovery, "non-launcher UWP import stays unchanged")
|
||||
love.system.getOS = function() return "Windows" end
|
||||
Platform._resetForTests()
|
||||
local desktop = RomImporter.new(function() end, { launcher = true })
|
||||
check(not desktop.baseRomDiscovery, "desktop launcher does not scan baseroms")
|
||||
|
||||
love.data.hash = saved.hash
|
||||
love.data.encode = saved.encode
|
||||
love.system.getOS = saved.getOS
|
||||
love.system.pickFile = saved.pickFile
|
||||
love.system.getPickedFile = saved.getPickedFile
|
||||
love.filesystem.read = saved.read
|
||||
love.filesystem.getInfo = saved.getInfo
|
||||
love.filesystem.getDirectoryItems = saved.getDirectoryItems
|
||||
love.filesystem.createDirectory = saved.createDirectory
|
||||
RomImporter.isReady = saved.isReady
|
||||
Platform._resetForTests()
|
||||
|
||||
S.finish()
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||