Add Switch OTA launcher with unified SD zip and quiet UI.

Ships a dual-NRO native launcher that checks GitHub Releases, updates both NROs from gen1recomp-*-switch.zip with matching NACP versions, and stays silent unless an update needs confirm.
This commit is contained in:
Andrew Quenehen
2026-08-05 01:48:39 -03:00
parent f0ed2efe07
commit cafa6f3d61
25 changed files with 2505 additions and 26 deletions
+8
View File
@@ -37,6 +37,14 @@ mobile/ios/build/
# Final packaged build artifacts (mac/win/web/android/ios/switch) — see scripts/build.sh
/dist/
# Native Switch OTA launcher build outputs
native/switch-ota-launcher/build/
native/switch-ota-launcher/build-host/
native/switch-ota-launcher/*.nro
native/switch-ota-launcher/*.nacp
native/switch-ota-launcher/*.elf
native/switch-ota-launcher/*.map
# Legacy manual convenience-copy location (superseded by /dist/android/)
mobile/dist/
+36
View File
@@ -34,6 +34,42 @@ WSL — not cmd.exe or PowerShell (AD-008).
**Optional:** Install [Docker](https://docs.docker.com/get-docker/) so fused
builds can fall back to the pinned image when native tools are missing.
### Native OTA launcher (optional but required for in-console updates)
In-console OTA uses a **separate DEVKITPRO NRO** (not LÖVE). Source:
`native/switch-ota-launcher/`. Host protocol tests (no toolchain):
```sh
make -C native/switch-ota-launcher host-test
# or
scripts/switch/build_ota_launcher.sh # host-test first; NRO needs DEVKITPRO/Docker
```
Extra packages beyond `switch-dev`:
```sh
sudo dkp-pacman -S --noconfirm --needed switch-curl switch-mbedtls switch-zlib switch-zziplib
# or: bash scripts/switch/install_devkitpro_deps.sh
```
Packaging dual-NRO SD zip (launcher entry + fused game):
```sh
scripts/switch/pack_sd_zip.sh dist/switch/game.nro VERSION out.zip dist/switch/gen1recomp-launcher.nro
```
One-shot OTA release build (fused game + launcher + dual-NRO SD zip;
the same `*-switch.zip` is the OTA download asset):
```sh
# once: bash scripts/switch/install_devkitpro_deps.sh
export DEVKITPRO=/opt/devkitpro
scripts/build_switch.sh --fetch --ota --version X.Y.Z
```
See `native/switch-ota-launcher/README.md` and
`scripts/switch/ota_launcher.manifest`.
### Windows (Git Bash / MSYS2 / WSL)
1. Use a bash environment:
+9 -1
View File
@@ -32,7 +32,7 @@ the transfer runbook).
| 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`) |
| Network features on NX | LÖVE self-update / remote mod download **disabled** (`networkValidated == false`). In-console OTA is the **native OTA launcher** only (see below) |
| Community help | Welcome — especially HOS / love-nx packaging and broader hardware coverage |
### What landed
@@ -57,6 +57,14 @@ the transfer runbook).
- 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))
- **Native OTA launcher:** in-console updates use a DEVKITPRO
**native OTA launcher** (libnx + switch-curl) before the LÖVE game NRO —
**not** the LÖVE self-updater (`src/update/Check.lua`). Contract:
`src/update/SwitchOta.lua`; packaging gate:
`scripts/switch/ota_launcher.manifest`. The download asset is the same
`gen1recomp-*-switch.zip` as install. UI stays quiet unless an update
needs confirm. Player-facing flow: [switch-install.md](switch-install.md)
§ Updating.
Transfer runbooks for Linux/Windows (and SD/FTP alternatives) are in
[switch-transfer.md](switch-transfer.md). Community mod zip install OLED smoke
+35 -5
View File
@@ -29,12 +29,17 @@ 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 (when OTA layout ships)
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). New OTA-ready
zips 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,10 +47,35 @@ 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)
On Switch, over-the-air updates are handled by the **native OTA launcher**
(DEVKITPRO / libnx + curl), **not** by the LÖVE self-updater
(`src/update/Check.lua`). The hbmenu entry is the launcher NRO
(`gen1recomp.nro`). If a newer release exists it may download the same
`gen1recomp-*-switch.zip` used for install, verify SHA-256 from
`sha256sums.txt`, replace **both** `gen1recomp-game.nro` and
`gen1recomp.nro` (so the NACP version matches the release for hbmenu /
Sphaira), then hand off via `envSetNextLoad`. When you are already up to
date (or offline), the launcher stays quiet and opens the game with no
console flash. Saves under `pokemon-love2d/` are never touched. Protocol
contract: `src/update/SwitchOta.lua`.
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
+6
View File
@@ -124,3 +124,9 @@ 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 is the **native OTA launcher**
(DEVKITPRO) documented in [switch-install.md](switch-install.md) and
[switch-development.md](switch-development.md); protocol contract
`src/update/SwitchOta.lua`. Manual zip install remains the fallback.
+165
View File
@@ -0,0 +1,165 @@
#---------------------------------------------------------------------------------
# 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
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/switch/icon.jpg
#---------------------------------------------------------------------------------
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
.PHONY: $(BUILD) clean all host-test
#---------------------------------------------------------------------------------
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 $(TARGET).lst build-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
#---------------------------------------------------------------------------------
else
.PHONY: all
DEPENDS := $(OFILES:.o=.d)
#---------------------------------------------------------------------------------
all: $(OUTPUT).nro
$(OUTPUT).nro: $(OUTPUT).elf $(OUTPUT).nacp
$(OUTPUT).elf: $(OFILES)
-include $(DEPENDS)
#---------------------------------------------------------------------------------
endif
#---------------------------------------------------------------------------------
endif
+66
View File
@@ -0,0 +1,66 @@
# 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 console flash
when you are already up to date or offline). Only if a newer release exists
does it prompt to 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 hand off
with `envSetNextLoad`.
The LÖVE self-updater (`src/update/Check.lua`) stays **disabled** on NX.
Protocol contract (also in Lua): `src/update/SwitchOta.lua`.
NACP icon: `assets/switch/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 native/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 native/switch-ota-launcher
make
# → gen1recomp.nro
```
Or from repo root:
```bash
scripts/switch/build_ota_launcher.sh
```
Docker fallback uses the same pin as fused builds (`scripts/switch/dkp-docker.image`).
## 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 the OTA download asset (unified).
Manifest: `scripts/switch/ota_launcher.manifest`.
## Status / known gaps
- Zip extraction uses `switch-zziplib` (`ota_unzip.c`) on device.
- OTA replaces launcher + game from the unified zip (NACP versions stay aligned).
- Sphaira HOME forwarders cache metadata until reinstalled (see docs/switch-install.md).
- Install toolchain deps once: `bash scripts/switch/install_devkitpro_deps.sh`
@@ -0,0 +1,94 @@
/* 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");
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,32 @@
#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);
/* 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,22 @@
#ifndef GEN1_OTA_NET_H
#define GEN1_OTA_NET_H
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
/* 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. Returns 0 on success. */
int ota_net_download_file(const char *url, const char *path, long timeout_ms, char *err,
size_t err_len);
#ifdef __cplusplus
}
#endif
#endif
@@ -0,0 +1,92 @@
#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_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,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
+330
View File
@@ -0,0 +1,330 @@
/*
* gen1recomp Switch OTA launcher
*
* Quiet by default: checks GitHub Releases with no console flash. Only shows
* a short prompt when an update is available. Downloads the same SD zip
* published for install (gen1recomp-*-switch.zip), verifies SHA-256, extracts
* switch/gen1recomp/gen1recomp-game.nro, then envSetNextLoad to the game.
* 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_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
#if defined(__SWITCH__)
static int g_ui = 0;
static void ui_begin(void) {
if (g_ui) return;
consoleInit(NULL);
consoleClear();
g_ui = 1;
}
static void ui_end(void) {
if (!g_ui) return;
consoleExit(NULL);
g_ui = 0;
}
static void ui_line(const char *msg) {
ui_begin();
printf("%s\n", msg);
consoleUpdate(NULL);
}
static void ui_blank(void) {
if (!g_ui) return;
consoleClear();
consoleUpdate(NULL);
}
#else
static void ui_begin(void) {}
static void ui_end(void) {}
static void ui_line(const char *msg) { fprintf(stderr, "%s\n", msg); }
static void ui_blank(void) {}
#endif
/* Returns 1 = update, 0 = skip. Only called when UI is already needed. */
static int wait_confirm_or_skip(void) {
#if defined(__SWITCH__)
PadState pad;
padInitializeDefault(&pad);
ui_line("");
ui_line(" A — Update");
ui_line(" B — Play without updating");
while (appletMainLoop()) {
padUpdate(&pad);
u64 k = padGetButtonsDown(&pad);
if (k & HidNpadButton_A) return 1;
if (k & HidNpadButton_B) return 0;
consoleUpdate(NULL);
}
return 0;
#else
return 0;
#endif
}
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;
}
/* Update available — only now show UI */
{
char msg[160];
ui_blank();
ui_line("");
ui_line(" gen1recomp");
ui_line("");
snprintf(msg, sizeof(msg), " Update available: %s → %s", installed, dec.version);
ui_line(msg);
}
if (!wait_confirm_or_skip()) {
ui_end();
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
ui_blank();
ui_line("");
ui_line(" Downloading…");
if (ota_net_download_file(dec.download_url, zip_path, 180000L, err, sizeof(err)) != 0) {
ui_line("");
ui_line(" Download failed.");
ui_line(" Playing the installed version.");
#if defined(__SWITCH__)
{
PadState pad;
padInitializeDefault(&pad);
ui_line("");
ui_line(" Press B to continue");
while (appletMainLoop()) {
padUpdate(&pad);
if (padGetButtonsDown(&pad) & HidNpadButton_B) break;
consoleUpdate(NULL);
}
}
#endif
ui_end();
return 0;
}
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)) != 0) {
ui_line("");
ui_line(" Could not verify the update.");
ui_line(" Playing the installed version.");
remove(zip_path);
ui_end();
#if defined(__SWITCH__)
svcSleepThread(1200000000ULL);
#endif
return 0;
}
FILE *sf = fopen(sums_path, "rb");
if (!sf) {
remove(zip_path);
ui_end();
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);
ui_end();
return 0;
}
fread(sums, 1, (size_t)slen, sf);
sums[slen] = '\0';
fclose(sf);
char hex[96];
if (ota_fs_sha256_file(zip_path, hex, sizeof(hex)) != 0) {
free(sums);
remove(zip_path);
ui_end();
return 0;
}
ota_verify_t ver;
ota_verify_sha256(dec.asset_name, hex, sums, &ver);
free(sums);
if (!ver.ok) {
ui_line("");
ui_line(" Update file failed checks.");
ui_line(" Playing the installed version.");
remove(zip_path);
ui_end();
#if defined(__SWITCH__)
svcSleepThread(1200000000ULL);
#endif
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__)
ui_line("");
ui_line(" Installing…");
/* Prefer path inside SD zip; fall back to bare name. */
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) {
ui_line("");
ui_line(" Install failed.");
ui_line(" Playing the installed version.");
remove(zip_path);
ui_end();
svcSleepThread(1200000000ULL);
return 0;
}
}
if (ota_fs_atomic_replace_game(install_dir, extracted, err, sizeof(err)) != 0) {
ui_line("");
ui_line(" Install failed.");
ui_line(" Playing the installed version.");
remove(extracted);
remove(zip_path);
ui_end();
svcSleepThread(1200000000ULL);
return 0;
}
remove(extracted);
/* Also replace launcher so hbmenu/Sphaira see the matching NACP version.
* Best-effort: game update already succeeded if this fails. */
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) {
if (ota_fs_atomic_replace_nro(install_dir, OTA_LAUNCHER_NRO_NAME, extracted_launcher, err,
sizeof(err)) == 0) {
remove(extracted_launcher);
} else {
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);
}
#else
(void)extracted;
(void)extracted_launcher;
#endif
remove(zip_path);
ui_end();
return 0;
}
int main(int argc, char **argv) {
(void)argc;
(void)argv;
#if defined(__SWITCH__)
socketInitializeDefault();
padConfigureInput(1, HidNpadStyleSet_NpadStandard);
#endif
const char *install = SD_INSTALL_DIR;
run_update_flow(install);
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) {
ui_line("");
ui_line(" gen1recomp");
ui_line("");
ui_line(" Game files are missing.");
ui_line(" Copy the Switch zip onto your microSD,");
ui_line(" then open gen1recomp again.");
#if defined(__SWITCH__)
ui_line("");
ui_line(" Press + to exit");
PadState pad;
padInitializeDefault(&pad);
while (appletMainLoop()) {
padUpdate(&pad);
if (padGetButtonsDown(&pad) & HidNpadButton_Plus) break;
consoleUpdate(NULL);
}
#endif
ui_end();
}
#if defined(__SWITCH__)
socketExit();
#endif
return 0;
}
+136
View File
@@ -0,0 +1,136 @@
#include "ota_fs.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#if defined(__SWITCH__)
#include <switch.h>
#include <mbedtls/sha256.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;
}
#ifdef _WIN32
remove(dest);
#endif
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_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
}
+119
View File
@@ -0,0 +1,119 @@
#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__)
struct mem_buf {
char *data;
size_t len;
};
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);
}
#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);
/* Atmosphere / homebrew CA store is limited; match common homebrew practice. */
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0L);
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 0L);
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) {
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;
}
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);
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0L);
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 0L);
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;
}
return 0;
#else
(void)timeout_ms;
if (err && err_len)
snprintf(err, err_len, "ota_net_download_file only available on __SWITCH__");
return -1;
#endif
}
@@ -0,0 +1,292 @@
#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;
}
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 its browser_download_url in the same object-ish window. */
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)) {
snprintf(out->asset_name, sizeof(out->asset_name), "%s", name);
/* Prefer URL after this name key within a limited window. */
const char *window_end = cursor + 800;
if (window_end > json_text + strlen(json_text)) window_end = json_text + strlen(json_text);
char url[512];
const char *u = NULL;
const char *scan = cursor;
while (scan < window_end && (scan = strstr(scan, "\"browser_download_url\"")) != NULL &&
scan < window_end) {
if (find_json_string(scan, "browser_download_url", url, sizeof(url))) {
u = url;
break;
}
scan += 21;
}
if (!u || !*u) {
set_reason(out->reason, sizeof(out->reason), "missing_download_url");
return 0;
}
snprintf(out->download_url, sizeof(out->download_url), "%s", u);
out->ok = 1;
return 1;
}
cursor += 6;
}
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,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
}
+33 -11
View File
@@ -5,8 +5,10 @@
# scripts/build_switch.sh --fetch
# scripts/build_switch.sh --loose [path/to/game.love]
# scripts/build_switch.sh --fused [--version X.Y.Z]
# scripts/build_switch.sh --ota [--version X.Y.Z]
# scripts/build_switch.sh --fetch --loose
# scripts/build_switch.sh --fetch --fused [--version X.Y.Z]
# scripts/build_switch.sh --fetch --ota [--version X.Y.Z]
#
# Modes:
# --fetch Download pinned love.nro + love.elf into
@@ -27,11 +29,17 @@
# Requires the pin (run --fetch or combine). GitHub Releases
# publish the zip only; the versioned .nro stays local / PR CI.
#
# Combinable: --fetch alone, or --fetch with --loose / --fused.
# XOR: --loose and --fused cannot be used together.
# --ota Like --fused, plus native OTA launcher NRO and dual-NRO SD zip
# (gen1recomp.nro launcher + gen1recomp-game.nro). The same
# gen1recomp-<ver>-switch.zip is the OTA download asset. Needs
# DEVKITPRO switch-curl/mbedtls/zlib/zziplib — see
# scripts/switch/install_devkitpro_deps.sh.
#
# Combinable: --fetch alone, or --fetch with --loose / --fused / --ota.
# XOR: --loose, --fused, and --ota 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
@@ -41,6 +49,7 @@ DIST="$ROOT/dist/switch"
LOOSE=0
FUSED=0
FETCH=0
OTA=0
GAME_LOVE=""
VERSION="$(git -C "$ROOT" rev-parse --short HEAD 2>/dev/null || echo dev)"
@@ -49,13 +58,14 @@ 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,40p' "$0" | sed 's/^# \{0,1\}//'
}
while [ $# -gt 0 ]; do
case "$1" in
--loose) LOOSE=1; shift ;;
--fused) FUSED=1; shift ;;
--ota) OTA=1; shift ;;
--fetch) FETCH=1; shift ;;
--version) VERSION="$2"; shift 2 ;;
-h|--help)
@@ -73,12 +83,13 @@ while [ $# -gt 0 ]; do
esac
done
if [ "$LOOSE" -eq 1 ] && [ "$FUSED" -eq 1 ]; then
fail "choose one of --loose or --fused (XOR)"
MODE_COUNT=$((LOOSE + FUSED + OTA))
if [ "$MODE_COUNT" -gt 1 ]; then
fail "choose one of --loose, --fused, or --ota (XOR)"
fi
if [ "$FETCH" -eq 0 ] && [ "$LOOSE" -eq 0 ] && [ "$FUSED" -eq 0 ]; then
fail "specify --fetch, --loose, and/or --fused (see --help)"
if [ "$FETCH" -eq 0 ] && [ "$LOOSE" -eq 0 ] && [ "$FUSED" -eq 0 ] && [ "$OTA" -eq 0 ]; then
fail "specify --fetch, --loose, --fused, and/or --ota (see --help)"
fi
if [ "$FETCH" -eq 1 ]; then
@@ -128,12 +139,23 @@ if [ "$LOOSE" -eq 1 ]; then
exec "$ROOT/scripts/switch/assemble_loose.sh" "$GAME_LOVE"
fi
if [ "$FUSED" -eq 1 ]; then
if [ "$FUSED" -eq 1 ] || [ "$OTA" -eq 1 ]; then
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"
if [ "$OTA" -eq 1 ]; then
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"
else
"$ROOT/scripts/switch/pack_sd_zip.sh" "$OUT_NRO" "$VERSION" "$OUT_ZIP"
fi
cp "$WORK/build-info.json" "$DIST/gen1recomp-${VERSION}-build-info.json"
say "done. See $DIST/"
exit 0
@@ -145,4 +167,4 @@ if [ "$FETCH" -eq 1 ]; then
exit 0
fi
fail "specify --fetch, --loose, and/or --fused (see --help)"
fail "specify --fetch, --loose, --fused, and/or --ota (see --help)"
+74
View File
@@ -0,0 +1,74 @@
#!/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/native/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"
say "host-test ota_protocol (no DEVKITPRO required)"
make -C "$LAUNCHER_DIR" host-test
mkdir -p "$(dirname "$OUT_NRO")"
if [ -n "${DEVKITPRO:-}" ] && [ -f "$DEVKITPRO/libnx/switch_rules" ]; then
say "building launcher NRO with native DEVKITPRO (NACP $APP_VERSION)"
make -C "$LAUNCHER_DIR" clean || true
make -C "$LAUNCHER_DIR" all APP_VERSION="$APP_VERSION"
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)"
docker run --rm \
-v "$ROOT:/work" \
-w /work/native/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"'
cp "$LAUNCHER_DIR/gen1recomp.nro" "$OUT_NRO"
say "wrote $OUT_NRO"
exit 0
fi
fail "$(cat <<EOF
Cannot build Switch OTA launcher NRO: DEVKITPRO unset and Docker unavailable.
Host protocol tests already passed (make host-test).
Install DEVKITPRO switch-dev + switch-curl, or Docker, then re-run:
scripts/switch/build_ota_launcher.sh
See native/switch-ota-launcher/README.md and docs/switch-build.md.
EOF
)"
+57
View File
@@ -0,0 +1,57 @@
#!/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
#
set -euo pipefail
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
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 \
switch-curl \
switch-mbedtls \
switch-zlib \
switch-zziplib
echo "==> installed packages:"
dkp-pacman -Q | grep -E 'switch-(curl|mbedtls|zlib|zziplib|tools)|libnx|devkitA64' || true
# Ensure zsh/bash sessions see DEVKITPRO
MARKER="# gen1recomp-devkitpro"
for rc in /Users/*/.zshrc /Users/*/.bash_profile; do
[ -f "$rc" ] || continue
if ! grep -q "$MARKER" "$rc" 2>/dev/null; then
{
echo ""
echo "$MARKER"
echo "export DEVKITPRO=/opt/devkitpro"
echo 'export PATH="$DEVKITPRO/tools/bin:$DEVKITPRO/devkitA64/bin:$PATH"'
} >> "$rc"
echo "==> appended DEVKITPRO exports to $rc"
fi
done
echo "==> done. Open a new shell, then:"
echo " export DEVKITPRO=/opt/devkitpro"
echo " scripts/switch/build_ota_launcher.sh"
+16
View File
@@ -0,0 +1,16 @@
# Switch OTA launcher packaging manifest
#
# Release / SD tree MUST include the native OTA launcher NRO as the hbmenu
# entrypoint. The LÖVE fused game NRO is loaded via envSetNextLoad after the
# update check. Self-update of the launcher itself is out of scope for v1.
#
# OTA download uses the same SD zip as install (unified asset).
#
# Paths relative to microSD root:
ENTRY_NRO=switch/gen1recomp/gen1recomp.nro
GAME_NRO=switch/gen1recomp/gen1recomp-game.nro
SAVE_DIR=switch/gen1recomp/pokemon-love2d
OTA_ASSET_GLOB=gen1recomp-*-switch.zip
REQUIRE_SHA256SUMS=1
OTA_ENABLED=1
+28 -7
View File
@@ -2,16 +2,18 @@
# Pack a Switch SD-ready zip: extract at microSD root (merge-safe update).
#
# Usage:
# scripts/switch/pack_sd_zip.sh NRO_PATH VERSION OUT_ZIP
# scripts/switch/pack_sd_zip.sh GAME_NRO VERSION OUT_ZIP [LAUNCHER_NRO]
#
# Layout inside the zip (SD root):
# switch/gen1recomp/gen1recomp.nro
# switch/gen1recomp/gen1recomp.nro (launcher if LAUNCHER_NRO set, else game)
# switch/gen1recomp/gen1recomp-game.nro (only when LAUNCHER_NRO set)
# switch/gen1recomp/version.txt
# switch/gen1recomp/INSTALL.txt
# switch/gen1recomp/pokemon-love2d/imports/.../README.txt
# switch/gen1recomp/pokemon-love2d/exports/.../README.txt
#
# Does not ship ROMs, saves, or mods. Re-extracting merges over an existing
# install and only overwrites the NRO + these text placeholders — keep
# install and only overwrites the NRO(s) + these text placeholders — keep
# pokemon-love2d/ to preserve progress.
set -euo pipefail
@@ -23,11 +25,15 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
NRO_PATH="${1:-}"
VERSION="${2:-}"
OUT_ZIP="${3:-}"
LAUNCHER_NRO="${4:-}"
[ -n "$NRO_PATH" ] && [ -n "$VERSION" ] && [ -n "$OUT_ZIP" ] \
|| fail "usage: scripts/switch/pack_sd_zip.sh NRO_PATH VERSION OUT_ZIP"
|| fail "usage: scripts/switch/pack_sd_zip.sh GAME_NRO VERSION OUT_ZIP [LAUNCHER_NRO]"
[ -f "$NRO_PATH" ] || fail "missing NRO at $NRO_PATH"
[ -f "$NRO_PATH" ] || fail "missing game NRO at $NRO_PATH"
if [ -n "$LAUNCHER_NRO" ]; then
[ -f "$LAUNCHER_NRO" ] || fail "missing launcher NRO at $LAUNCHER_NRO"
fi
command -v zip >/dev/null 2>&1 || fail "need zip on PATH"
@@ -45,7 +51,15 @@ trap cleanup EXIT
APP_DIR="$STAGE/switch/gen1recomp"
SAVE_ROOT="$APP_DIR/pokemon-love2d"
mkdir -p "$APP_DIR"
cp "$NRO_PATH" "$APP_DIR/gen1recomp.nro"
if [ -n "$LAUNCHER_NRO" ]; then
LAUNCHER_NRO="$(cd "$(dirname "$LAUNCHER_NRO")" && pwd)/$(basename "$LAUNCHER_NRO")"
cp "$LAUNCHER_NRO" "$APP_DIR/gen1recomp.nro"
cp "$NRO_PATH" "$APP_DIR/gen1recomp-game.nro"
else
cp "$NRO_PATH" "$APP_DIR/gen1recomp.nro"
fi
printf '%s\n' "$VERSION" > "$APP_DIR/version.txt"
cat > "$APP_DIR/INSTALL.txt" <<EOF
gen1recomp Switch (v${VERSION})
@@ -55,7 +69,7 @@ First install or update (same steps):
1. Extract this zip at the root of your microSD (merge folders if asked).
2. Do NOT delete switch/gen1recomp/pokemon-love2d/ — that folder holds
your saves, imported ROMs, mods, and options. Re-extracting only
replaces gen1recomp.nro and these help files.
replaces the NRO(s) and these help files.
3. Launch with title override (hold R on HOME, open any title → hbmenu).
4. Copy a legal Pokemon Red/Blue .gb into:
switch/gen1recomp/pokemon-love2d/imports/
@@ -106,9 +120,16 @@ rm -f "$OUT_ZIP"
LISTING="$(unzip -Z1 "$OUT_ZIP" 2>/dev/null || unzip -l "$OUT_ZIP")"
printf '%s\n' "$LISTING" | grep -q 'switch/gen1recomp/gen1recomp.nro' \
|| fail "zip missing switch/gen1recomp/gen1recomp.nro"
printf '%s\n' "$LISTING" | grep -Fq 'switch/gen1recomp/version.txt' \
|| fail "zip missing switch/gen1recomp/version.txt"
if [ -n "$LAUNCHER_NRO" ]; then
printf '%s\n' "$LISTING" | grep -Fq 'switch/gen1recomp/gen1recomp-game.nro' \
|| fail "zip missing switch/gen1recomp/gen1recomp-game.nro (OTA dual-NRO layout)"
fi
REQUIRED=(
"switch/gen1recomp/INSTALL.txt"
"switch/gen1recomp/version.txt"
"switch/gen1recomp/pokemon-love2d/imports/README.txt"
"switch/gen1recomp/pokemon-love2d/imports/mods/README.txt"
"switch/gen1recomp/pokemon-love2d/imports/saves/red/README.txt"
+93 -2
View File
@@ -51,16 +51,17 @@ 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 -q 'ota' || MISSING="${MISSING} ota"
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, ota (+ auto-download/non-goals)"
else
bad "build_switch.sh --help missing:$MISSING"
fi
# ---------------------------------------------------------------------------
# 3. XOR --loose --fused exits non-zero
# 3. XOR --loose --fused / --ota exits non-zero
# ---------------------------------------------------------------------------
XOR_RC=0
"$ROOT/scripts/build_switch.sh" --loose --fused >/dev/null 2>&1 || XOR_RC=$?
@@ -69,6 +70,13 @@ if [ "$XOR_RC" -ne 0 ]; then
else
bad "build_switch.sh --loose --fused should exit non-zero"
fi
XOR_OTA_RC=0
"$ROOT/scripts/build_switch.sh" --fused --ota >/dev/null 2>&1 || XOR_OTA_RC=$?
if [ "$XOR_OTA_RC" -ne 0 ]; then
ok "build_switch.sh --fused --ota exits non-zero ($XOR_OTA_RC)"
else
bad "build_switch.sh --fused --ota should exit non-zero"
fi
# ---------------------------------------------------------------------------
# 4. assemble_loose without pin → stderr contains --fetch
@@ -228,6 +236,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 +320,88 @@ 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" ] \
&& [ -f "$ROOT/native/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 "unified OTA download is the same SD zip (legacy OTA-only packer gone)"
else
bad "legacy separate OTA-only packer / asset still present"
fi
if grep -q 'Quiet by default\|stays quiet\|Quiet' "$ROOT/native/switch-ota-launcher/src/main.c" \
&& grep -q 'ui_begin' "$ROOT/native/switch-ota-launcher/src/main.c"
then
ok "launcher UI is quiet by default (console only when needed)"
else
bad "launcher missing quiet-by-default UI gate"
fi
if [ -f "$ROOT/native/switch-ota-launcher/src/ota_unzip.c" ] \
&& grep -q 'zzip/zzip.h' "$ROOT/native/switch-ota-launcher/src/ota_unzip.c" \
&& grep -q 'ota_unzip_extract_file' "$ROOT/native/switch-ota-launcher/src/main.c"
then
ok "launcher wires zziplib ota_unzip_extract_file"
else
bad "launcher missing zziplib unzip wiring"
fi
# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------
+186
View File
@@ -0,0 +1,186 @@
-- Switch OTA protocol contract (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
-- Parse GitHub releases/latest JSON (minimal: 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 assetName, downloadUrl
for block in jsonText:gmatch("%b{}") do
local name = block:match('"name"%s*:%s*"(.-)"')
if name and SwitchOta.isOtaAssetName(name) then
assetName = name
downloadUrl = block:match('"browser_download_url"%s*:%s*"(.-)"')
break
end
end
if not assetName then return nil, "missing_ota_asset" end
if not downloadUrl or downloadUrl == "" then return nil, "missing_download_url" end
return {
tag = tag,
version = version,
assetName = assetName,
downloadUrl = downloadUrl,
}
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
+467
View File
@@ -0,0 +1,467 @@
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 parseRelease(jsonText) {
if (!jsonText) return { ok: false, reason: 'empty_json' };
let obj;
try {
obj = JSON.parse(jsonText);
} catch {
return { ok: false, reason: 'bad_json' };
}
const tag = obj.tag_name;
if (!tag) return { ok: false, reason: 'missing_tag' };
const version = String(tag).match(/^v?(\d+\.\d+\.\d+)$/)?.[1];
if (!version) return { ok: false, reason: 'bad_tag' };
const asset = (obj.assets || []).find((a) => isOtaAssetName(a.name));
if (!asset) return { ok: false, reason: 'missing_ota_asset' };
if (!asset.browser_download_url) return { ok: false, reason: 'missing_download_url' };
return {
ok: true,
tag,
version,
assetName: asset.name,
downloadUrl: asset.browser_download_url,
};
}
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 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');
});
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 development = read('docs/switch-development.md');
const updater = read('docs/updater.md');
for (const [name, text] of [
['switch-install', install],
['switch-development', development],
['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-development.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('native/switch-ota-launcher/src/ota_protocol.c');
const header = read('native/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, 'native/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 [
'native/switch-ota-launcher/Makefile',
'native/switch-ota-launcher/README.md',
'native/switch-ota-launcher/src/main.c',
'native/switch-ota-launcher/src/ota_net.c',
'native/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('native/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, /consoleInit/, 'UI uses console only when needed');
assert.match(main, /ui_begin|g_ui/, 'UI is gated behind begin');
const mk = read('native/switch-ota-launcher/Makefile');
assert.match(mk, /libnx\/switch_rules|DEVKITPRO/);
assert.match(mk, /-lcurl/);
assert.match(mk, /-lzzip/);
assert.match(mk, /assets\/switch\/icon\.jpg/, 'uses project Switch icon');
assert.ok(fs.existsSync(path.join(root, 'native/switch-ota-launcher/src/ota_unzip.c')));
assert.match(read('native/switch-ota-launcher/src/ota_unzip.c'), /zzip\/zzip\.h/);
assert.match(read('native/switch-ota-launcher/src/main.c'), /ota_unzip_extract_file/);
assert.match(read('native/switch-ota-launcher/src/main.c'), /GAME_MEMBER_IN_ZIP|switch\/gen1recomp\//);
assert.match(read('native/switch-ota-launcher/src/main.c'), /LAUNCHER_MEMBER_IN_ZIP|ota_fs_atomic_replace_nro/);
assert.match(read('native/switch-ota-launcher/src/ota_fs.c'), /ota_fs_atomic_replace_nro/);
assert.doesNotMatch(read('scripts/switch/install_devkitpro_deps.sh'), /switch-minizip/);
assert.match(read('scripts/switch/install_devkitpro_deps.sh'), /switch-zziplib/);
const buildDoc = read('docs/switch-build.md');
assert.match(buildDoc, /native\/switch-ota-launcher|build_ota_launcher/);
assert.match(buildDoc, /switch-curl/);
assert.match(buildDoc, /--ota|install_devkitpro_deps/);
assert.doesNotMatch(buildDoc, /switch-ota\.zip/);
const readme = read('native/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.match(buildSwitch, /--ota/);
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 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, /unified OTA|same SD zip|legacy OTA-only/i);
assert.match(selftest, /Quiet by default|quiet by default|ui_begin/);
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/);
});