Compare commits

..

31 Commits

Author SHA1 Message Date
bryanthaboi 60cf07fb0a Merge pull request #912 from bryanthaboi/dev
launcher update, switch and xbox updates
2026-08-06 10:32:17 -04:00
bryanthaboi aa6217e581 Merge pull request #910 from andrewqsantos/feat/switch-ota-unified-launcher
Feat Switch OTA Update
2026-08-06 10:20:23 -04:00
bryanthaboi a640f5504f Merge pull request #906 from caorthann-celt/dev
Fix Xbox mod downloads and internal ROM imports
2026-08-06 10:20:00 -04:00
Caorthann e01672b97d Update Xbox UWP assets to match upstream 2026-08-06 15:15:07 +01:00
Caorthann 9e3d487a08 Add UWP LocalState baserom discovery 2026-08-06 15:15:07 +01:00
Caorthann 506cd2c5c5 Enable UWP mod downloads 2026-08-06 15:13:54 +01:00
bryanthaboi 6cb0ac5666 Merge pull request #908 from johnjohto/fix-red-cache-folder-899
Move Red's extracted cache under red/ with a legacy migration
2026-08-06 10:09:34 -04:00
bryanthaboi b7d0ff2e16 Merge pull request #904 from ShaneMcGovernIE/shanemcgovernie-hit-sfx-pitch-regression-test
Guard hit-sfx noise pitch with a CI regression test (#902)
2026-08-06 10:09:08 -04:00
bryanthaboi 984cefdc7b launcher updates 2026-08-06 09:57:31 -04:00
johnjohto 6c892cb7c1 Move Red's extracted cache under red/ with a legacy migration
Importing Red unpacked data/generated, assets/generated and
rom-cache.complete straight into the save-dir root, while Blue and
Yellow land under blue/ and yellow/. Red now uses cachePrefix red/
like the others.

CacheFs.migrateLegacyRedCache moves a pre-existing root cache into
red/ on first boot, from RomImporter.new before the readiness loop
and from mountVersion, so existing installs keep their import instead
of being asked for the ROM again. The move only runs when the root
marker resolves to the save directory, so a dev checkout's source
tree is never touched, and the portable game folder is skipped when
it is the physfs source.

Closes #899
2026-08-06 09:55:28 -04:00
Andrew Quenehen 52d1ddc5af Fix Switch OTA clean and NX version chip test after launcher layout changes. 2026-08-06 10:29:43 -03:00
Andrew Quenehen 75cd28435c Update build scripts and tests for ports/switch OTA paths.
Point launcher, fused build, docs, and gitignore at the relocated Switch port tree.
2026-08-06 10:24:04 -03:00
Andrew Quenehen 9aa8b4e371 Move Switch OTA launcher sources under ports/switch.
Consolidate the native OTA launcher, bootstrap, and assets into the Switch port tree.
2026-08-06 10:22:51 -03:00
Andrew Quenehen e94d8e32ef Drop stb_image from Switch OTA launcher with a pre-baked logo asset. 2026-08-06 10:14:11 -03:00
Andrew Quenehen 3fae3088ca Fix Switch OTA launcher self-update via a bootstrap NRO.
A running launcher cannot replace its own NRO on sdmc/FAT; stage the new
binary and chainload a tiny helper from romfs to swap it in before loading
the game.
2026-08-06 09:59:01 -03:00
Shane McGovern cbb08f76ae Add ROM-free regression test for hit-sfx noise pitch (#826/#902)
The super-effective / not-very-effective hit sounds were only covered by
the manual ears-only driver (tests/drivers/hit_sfx_bug826_test.lua), so
the wFrequencyModifier wiring that fixed #826 (and that #902 reports as
a swap had no CI guard.  This suite pins the polynomial-counter bytes
for all three hit sounds against the real audio/sfx programs: bare they
read swapped (super effective ends duller), and pitched they read
correct (super effective 4 shift 1 crack, not very effective
shift 10 thud).  A port that drops the modifier fails 11 of 24 checks.

CLOSES #902

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
EOF
)
2026-08-06 13:35:07 +01:00
Andrew Quenehen dd503706b0 Merge origin/dev (v0.1.74) into feat/switch-ota-unified-launcher.
Keep Switch version chip alongside upstream's new quit button in the launcher header.
2026-08-06 09:28:26 -03:00
Andrew Quenehen eeb181bcc0 Wrap Switch OTA button labels in parentheses so A/B/+ read as controls. 2026-08-06 09:17:49 -03:00
Andrew Quenehen 287534cf26 Mount romfs before the quiet OTA release check so HTTPS can load cacert.pem.
ota_net_init() now mounts romfs and initializes curl before the GitHub check runs, fixing silent update-check failures after TLS verification was enabled.
2026-08-06 09:14:35 -03:00
Andrew Quenehen d08fce5fd5 Fix fused Switch build polluting GAME_LOVE path capture.
Route common.sh say() to stderr so sourcing it before pack_game_love does not
mix progress lines into command substitution output.
2026-08-06 09:07:58 -03:00
Andrew Quenehen 84e1a5177b Harden Switch OTA install with TLS verification and reliable NRO replace.
Enable HTTPS peer checks via a romfs CA bundle, remove existing NROs before rename on sdmc, and surface launcher replace failures before writing version.txt.
2026-08-06 08:58:22 -03:00
Andrew Quenehen c7e72149f1 Tighten Switch docs prose and drop filler patterns. 2026-08-06 08:43:32 -03:00
Andrew Quenehen ecfca19f11 Consolidate Switch docs into three production guides.
Remove internal development and hardware-evidence docs, drop issue references, and update cross-links and doc gates.
2026-08-06 08:36:43 -03:00
Andrew Quenehen 90a53e509a Remove duplicate Switch port credits from the README footer. 2026-08-06 08:29:03 -03:00
Andrew Quenehen 15fc97a996 Unify --fused with OTA launcher and require DEVKITPRO preflight for release builds. 2026-08-06 08:28:31 -03:00
github-actions 35ed326dda chore(ios): update app-repo.json [skip ci] 2026-08-06 07:27:09 -04:00
Andrew Quenehen dc04a4e848 Improve Switch OTA flow with download progress and clearer error screens. 2026-08-06 07:54:28 -03:00
Andrew Quenehen 1bfc89fdd4 Fix Switch OTA parser for GitHub releases/latest JSON.
Scan asset objects by brace bounds instead of an 800-byte window so
browser_download_url survives fat uploader blocks; mirror logic in Lua
and add realistic API fixtures.
2026-08-05 15:15:44 -03:00
Andrew Quenehen ae5276b2d1 Show the running app version on the Switch launcher header.
Add an NX-only yellow version chip so players can confirm which build is on the microSD after OTA or zip updates.
2026-08-05 02:08:20 -03:00
Andrew Quenehen 4dd4c5c463 Add launcher-styled framebuffer UI for Switch OTA prompts.
Replace the console flash with a quiet branded screen (RGB rail, logo, A/B buttons) that only appears when an update needs confirm.
2026-08-05 02:03:51 -03:00
Andrew Quenehen cafa6f3d61 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.
2026-08-05 01:48:39 -03:00
73 changed files with 6315 additions and 2011 deletions
+1 -1
View File
@@ -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"
+18 -1
View File
@@ -31,12 +31,29 @@ mobile/ios/love-src/
mobile/ios/cache/
mobile/ios/build/
# love-nx vendor binaries (fetch per docs/switch-development.md; also covered by .*)
# love-nx vendor binaries (fetch per docs/switch-build.md; also covered by .*)
.bazinga/love-nx/
# Final packaged build artifacts (mac/win/web/android/ios/switch) — see scripts/build.sh
/dist/
# Switch OTA launcher build outputs
ports/switch/ota-launcher/build/
ports/switch/ota-launcher/build-host/
ports/switch/ota-launcher/*.nro
ports/switch/ota-launcher/*.nacp
ports/switch/ota-launcher/*.elf
ports/switch/ota-launcher/*.map
ports/switch/ota-launcher/romfs/logo.rgba
ports/switch/ota-launcher/romfs/logo.png
ports/switch/ota-launcher/romfs/cacert.pem
ports/switch/ota-launcher/romfs/ota-bootstrap.nro
ports/switch/ota-bootstrap/build/
ports/switch/ota-bootstrap/*.nro
ports/switch/ota-bootstrap/*.nacp
ports/switch/ota-bootstrap/*.elf
ports/switch/ota-bootstrap/*.map
# Legacy manual convenience-copy location (superseded by /dist/android/)
mobile/dist/
+43 -18
View File
@@ -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 games 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>
+26 -636
View File
@@ -1,636 +1,26 @@
# New features (deliberate additions beyond the original)
Intentional enhancements this port adds on top of faithful Pokémon Red, Blue,
and Yellow behavior. They have no Game Boy equivalent and are kept by design.
Genuine divergences from the original (things still missing, wrong, or
approximated) live in docs/known-differences.md; faithfully-ported behavior is
in docs/behavior-porting-notes.md.
## Survey zoom
The mouse wheel (or `-`/`=`), the Options **ZOOM** row, or hotkey `4`
zooms the overworld between 1 pixel per world pixel (full survey) and 2×
the window fit scale (close-up), in crisp integer steps. This has no Game
Boy equivalent:
- Connected maps render their full bodies, and their NPCs appear as
visual-only "ghosts", they wander but have no sight lines, triggers,
dialogue, or collision until the map is actually entered.
- Menus, text boxes, and battles draw at normal scale on top of the
zoomed world. Zoom input is ignored while a script, menu, or battle is
active; the zoom offset is persisted as `save.options.zoom` (default
`0` = FIT) and survives New Game via `options.lua`.
- Hotkey `4` ticks through every integer zoom level (survey → FIT →
close-up → wrap). The Options row shows `FIT` / `OUTn` / `INn`.
- Beyond the border ring the void fill repeats indefinitely (see VOID
FILL below); interiors keep their own border block. Each visible map
area is colorized with its own SGB palette (the original recolored the
whole screen per map).
- Neighbor maps load two connection hops out so corner-adjacent maps
don't pop in and out, and ghost NPCs share instances with the real ones
so their wander positions persist across seamless connection crossings
(a warp or fresh map entry still respawns everything at its script
position, like the original's per-entry sprite init).
## VOID FILL
The Options **VOID FILL** row picks what paints the infinite beyond-edge
space on OVERWORLD-tileset maps during survey zoom:
- **TREES** (default): solid tree wall block `$0F`.
- **WATER**: animated water tile `$14` (same hshift cycle as on-map water).
- **BLACK**: solid black.
Other tilesets are unchanged (house/cave borders stay as authored).
Persisted as `save.options.voidFill`.
## Tilt mode
The `3` key (and the Options menu TILT row) cycles a visual-only perspective
tilt of the overworld through **OFF → 15° → 35° → 50° → OFF** for an HD-2D /
diorama look. Like survey zoom this is purely presentational and has no
Game Boy equivalent:
- The entire map tilts as one rigid ground plane, paths, grass, water,
floors, and every background-tile structure (buildings, trees, fences,
signs; in Gen 1 these are baked into the tile layer, not sprites), so
rows above the player recede and rows below come toward the viewer. Only
things that actually *stand* on the ground draw as upright billboards,
unscaled and pixel-identical to flat mode: the player, NPCs, item balls,
and the standing FX attached to them (emote bubbles, the fishing rod,
the FLY bird). The Poké Center heal-machine overlay stays on the ground
plane with the machine tiles (it is OAM glued to a BG graphic, not a
standing sprite). An earlier revision tried
billboarding buildings/trees/signs too (cutting them out of the ground
per hand-curated per-tileset tables); that chased an endless tail of
special cases, dense tree canopy, fences fused into grass, building
facades with their own baked-in fake perspective, because Gen 1's art
was never drawn with a clean seam between ground and standing scenery. It
wasn't merged; tilting everything but the characters as one plane is the
simpler, shipped tradeoff (buildings recede/foreshorten with the ground
like a photo of a diorama, rather than standing fully upright next to
a full-height character).
- Cycling tweens the angle between levels over ~0.25s rather than snapping;
with tilt fully off the world pass drops back onto the flat blit path, so
flat rendering stays pixel-identical to tilt-off and off costs nothing.
- Tilt input is gated exactly like survey zoom, honored only while
free-roaming, ignored while a script, menu, or battle is active, and it
composes with survey zoom (the zoom scale feeds the projection). The tilt
level is persisted in `save.options.tilt` (default OFF).
- It applies everywhere the overworld draws, interiors and caves included.
Menus, text boxes, and battles render flat on top, unaffected, and the
infinite beyond-the-border-ring fill stays flat by design.
- Collision, movement, sight lines, triggers, encounters, and scripts are
untouched; nothing about the tilt reaches gameplay.
## Colors mode
The `2` key (and the Options menu COLORS row) cycles the display mode
through **OG RED → SGB → ADVANCED → OG → OG INV → SGB INV → CLASSIC → OG RED**
(on Blue the first slot labels **OG BLUE**; on Yellow, **OG YELLOW**).
The first three are the real colorizations; the rest are DMG-shade novelties:
- **OG RED** / **OG BLUE**: the Game Boy Color boot-ROM look for that cart --
one global BG palette + one OBJ palette, every map, no per-map variation
(Red/Blue ship no CGB code, so on a GBC the boot ROM colors them globally).
The player/NPCs keep the boot-ROM OBJ color over the terrain via the OBP
bake + post-zone redraw (`PaletteFX.GBC_BG` / `GBC_OBJ`, or Blue's blue/pink
pair).
- **OG YELLOW** (Yellow playthrough, same `ogred` save id): Pokemon Yellow's
authentic GBC look from `CGBBasePalettes` (`data/palettes_yellow.lua`,
sourced from pret/pokeyellow). Per-map / per-species colors, not a single
boot-ROM ramp -- Yellow was CGB-enhanced.
- **SGB** (default): the per-map Super Game Boy region palettes
(`data/sgb/sgb_palettes.asm`). Sprites tint with the region palette, as on
real SGB. (This is the mode formerly mislabeled "GBC".)
- **ADVANCED**: pokered-gbc SuperPalettes -- real per-tile GBC coloring plus
per-species mon colors (`data/palettes_gbc.lua`). (Formerly labeled
"RED++"; it is the richest colorization rather than anything Red-specific.)
- **OG**: force the four DMG grays (colorization off).
- **OG INV**: inverted DMG grays.
- **SGB INV**: each SGB zone palette with shade order reversed.
- **CLASSIC**: original Game Boy pea-soup greens
(`#9BBC0F` / `#8BAC0F` / `#306230` / `#0F380F`).
The shade-remap transform is applied centrally in `PaletteFX.sendColors`, so
it covers overworld, menus, battles, and tilt upright billboards. OG RED's
global BG palette is supplied by `OverworldState:overworldBgColors` (per-map
override in the overworld pass). Persisted as `save.options.colors`; the
`gbc` / `gbc_inv` / `redpp` save ids are kept for back-compat under the new
labels.
## GBC FX
The `5` key (and the Options menu GBC FX row) cycles a "played on real
unlit-GBC hardware" post-process through **OFF → 1 → 2 → 3 → 4**. The
levels are a cumulative ladder:
- **1**: reflective-screen backing transparency.
- **2**: + LCD pixel grid.
- **3**: + pixel drop shadows.
- **4**: + sunlight glare and rainbow shimmer with a drifting light.
It runs as a final present pass after world + UI composite in
`Renderer:endFrame`, inspired by the Pixel Transparency RetroArch shader
([github.com/mattakins/Pixel_Transparency](https://github.com/mattakins/Pixel_Transparency)).
Default OFF; persisted as `save.options.gbcfx`.
Mobile GPUs often compile the pass but present a black frame, so Android and
iOS hide the row entirely, pin the level to OFF, and rewrite a level already
persisted in `options.lua` (issue #136). `POKEPORT_GBCFX` overrides that
decision either way, same tri-state as `POKEPORT_TOUCH`: `=0` refuses the
effect, `=1` forces it available. The Anbernic handheld pack exports `0` from
its launcher because the device reports `"Linux"` while its GPU is in the
phone class (see [Anbernic RG34XXSP](anbernic-rg34xxsp.md)).
## Performance tier (low-end devices)
The Options **PERFORMANCE** row scales the port's optional presentation
extras down for weaker hardware. The extras it governs are the three
heaviest things the port adds on top of the original -- the whole-screen 3D
**TILT** (transforms the entire map as a ground plane), the **GBC FX**
post-process shader (a fullscreen pass), and survey **ZOOM** (zooming out
renders the connected neighbor maps, a lot of extra overdraw) -- plus a hard
FPS ceiling. None of this touches game logic, which is fixed-step off `dt`
(`src/core/FixedStep.lua`), so every tier plays identically; they differ
only in how much eye-candy the renderer is allowed to do.
| Tier | TILT | GBC FX | Survey ZOOM | Extra FPS ceiling |
| ------------ | ---- | ------ | ----------- | ----------------- |
| **HIGH** | on | on | on | none |
| **BALANCED** | off | off | on | none |
| **LOW** | off | off | off | 60 |
| **AUTO** | picks a default from the device (below) |||
- **AUTO** (the default) reads the device once at boot: ARM Linux handhelds
(e.g. the RG34XXSP) resolve to **LOW**, phones/tablets and very-low-core
desktops to **BALANCED**, and everything else -- a normal desktop, and
every existing `options.lua` that predates this option -- to **HIGH**,
so the common case is unchanged. See `src/core/Performance.detect`.
- AUTO only chooses the *default*; all four tiers are selectable, so a
wrong guess is one row away from being overridden.
- The clamps are applied **live** against your stored options and never
rewrite them (`Game:applyOptions`), so a lower tier hides your TILT / GBC
FX / ZOOM without forgetting them -- raising the tier restores exactly
what you had. (This is why the TILT / GBC FX / ZOOM rows still show your
saved choice on a clamped tier: it's your preference, waiting for a tier
that can afford it.)
- Persisted as `save.options.performance` (`auto` | `high` | `balanced` |
`low`); unit-tested in `tests/engine/performance_tiers.lua`.
## Peer-to-peer link play (lua-enet)
Trades and link battles connect two copies of the game directly over
lua-enet (ENet ships inside LÖVE, nothing to install, no server to run)
on a reliable-ordered channel, replacing the original standalone Python
room-code relay (`tools/relay_server.py`, deleted). HOST A GAME shows the
host's LAN address (UDP 7777; `POKEPORT_LINK_PORT` overrides); JOIN A
GAME enters it. Closing performs a graceful ENet disconnect so the final
confirm/bye always lands; a vanished peer exits with "The link was
broken." Internet play needs a forwarded UDP port or a VPN (deliberate
tradeoff vs. the relay). Headless tests drive the protocol over an
in-memory loopback (`Net.loopbackPair`); under LÖVE the same test file
also exercises real UDP pairing.
Red, Blue, and Yellow copies link with each other, as the real cable
does. The compatibility fingerprint hashes only data a link mode can
actually read, so Yellow's Dragonair/Dragonite catch-rate retunes (the
only R/B/Y link-surface difference) no longer read as different games
(issue #511). Moving the fingerprint is a link parity change: builds
from before this fix will refuse to pair with builds after it.
## Fair play in link and online matches
A link session is decided by the battle and nothing else, so for its
duration:
- **Game speed is pinned to normal.** The GAME SPEED option and
`POKEPORT_SPEED` are ignored from the moment LINK PLAY opens until it
closes, and apply again after. Fast-forward otherwise runs one peer's
queue faster than the peer it is locked to and drains a tournament shot
clock faster than the opponent racing it.
- **Online play runs vanilla, except for your language.** Picking ONLINE
MATCH or TOURNAMENT with mods enabled offers to switch the gameplay ones
off and relaunch (mods merge at boot, so a restart is the only way). The
restart is confirmed, not silent. They stay listed as disabled, ready to
switch back on. A mod that declares itself a translation and provably
writes nothing but text stays on: the two games hash the same link
surface, so a Spanish install and an English one can battle and trade,
each reading the game in its own language and naming the other player's
party out of its own text.
- **Only a meaningful split ends a match.** The per-turn state signature
both peers exchange is split three ways: `actives` and `bench` carry
species, HP, status, stat stages, PP and the rest of the party, and a
divergence there ends the match as a draw. `volatile` carries per-turn
flags both sides recompute anyway - a divergence there is logged and
reported to mods, and play continues.
The relay logs which component diverged on which turn, so a desync report
names something specific.
## Custom boot text
The boot sequence replaces the Nintendo / GAME FREAK identifiers with
"bois club" / "bryanthaboi", a deliberate branding customization. The
rest of the boot beats (copyright splash, "presents" shooting-star, the
Nidorino-vs-Gengar attract scene) mirror the original.
## Custom Options
Options persist in a standalone `options.lua` (separate from the game
progress `save.lua`), so audio/display/battle preferences survive New Game
and aren't wiped when a save slot is cleared. Changing a row in the Options
menu or cycling hotkeys `2`/`3`/`4`/`5` writes immediately; an in-game save also
flushes the live options. Old saves that still embed an `options` table are
migrated once into `options.lua` on load.
- Music / SFX volume
- PIKACHU VOL (0-7, Yellow only): trims Pikachu's PCM voice clips under the
SFX level, so the follower's constant chatter, the title-screen cry and
every in-battle "Pika!" can be pulled down (or muted at 0) without
quieting the rest of the sound effects. The row is hidden on Red/Blue,
which have no voice clips.
- Music Filter
- OG GLITCHES on / off (Gen 1 quirks vs. modern-clean battle rules)
- BATTLE LAYOUT (OG / WIDE); see "Widescreen battle layout" below
- COLORS (OG RED / SGB / ADVANCED / OG / OG INV / SGB INV / CLASSIC), also
hotkey `2` (OG RED = GBC boot-ROM look; ADVANCED uses pokered-gbc
SuperPalettes + per-species mon colors)
- TILT (OFF / 15 / 35 / 50), also hotkey `3` while free-roaming
- ZOOM (FIT / OUTn / INn), also hotkey `4` while free-roaming; wheel and
`-`/`=` step one level and save
- VOID FILL (TREES / WATER / BLACK) for OVERWORLD beyond-edge space
- GBC FX (OFF / 1 / 2 / 3 / 4), also hotkey `5`
- MAX FPS (30 / 40 / 50 / 60 / 75 / 90 / 100 / 120 / 144 / 160, default 60),
a hard render frame-rate cap (`save.options.fpsCap`).
## Battle transition cascade + white battle letterbox
Into-battle wipes still run the original eight styles inside the classic
160×144 letterbox. On wide/tall windows (survey zoom), matching black 8×8
blocks cascade outward from that square into the surrounding world so the
void outside the OG wipe fills in lockstep. Once the battle state is up,
letterbox voids around the battle canvas fill **white** instead of black
so the whole window reads as one continuous battle screen.
## Widescreen battle layout
Options **BATTLE LAYOUT** picks the battle screen's composition: **OG**
(the default: the original 160×144 arrangement, unchanged) or **WIDE**,
which gives battles a 304×144 native-pixel surface and a Gen 3-style
arrangement on it:
- the foe's status box upper left, the foe's picture upper right;
- the player's picture lower left, the player's status box lower right,
with a longer HP bar and the numeric HP under it;
- a full-width message window;
- a split "What will X do?" prompt / 2×2 command window;
- a 2×2 move menu, navigated with all four directions, with a PP and type
panel attached to its right.
Only the composition changes. Pictures, palettes, HP-bar colors, font
pages, window borders, sounds, animations, timing and every battle rule
stay the engine's, so a COLORS mode or an asset mod still owns the look.
Each side's picture keeps its original pixels and placement math and is
composited into its own region of the wider battlefield -- nothing is
scaled or squeezed -- and animations, which are authored in the original
160-pixel space, shift as one rigid group onto whichever side they play
on. The whole screen is drawn at the window's integer fit scale for the
wider surface, so a 304-pixel screen is drawn a step smaller than a
160-pixel one in the same window.
The wide surface is live only while the battle itself is the screen on
top: a party menu, the bag or a nickname prompt is a 160×144 screen and
brings the classic surface back with it.
## On-screen touch controls (mobile)
On Android/iOS the game draws a translucent d-pad (bottom-left), A/B
buttons (bottom-right, Game Boy diagonal), and +/- START/SELECT (bottom
center) over the frame, using Xelu's CC0 controller prompts
(`assets/touch/`). Real buttons, not gestures: press lands the frame the
finger does, sliding on the d-pad changes direction without lifting, and
multi-touch chords (e.g. hold a direction + tap B) work. The overlay only
appears while no controller is being used: the first gamepad button or
stick push hides it, the next screen touch brings it back, and unplugging
the last controller restores it immediately. Layout re-derives from the
window size on rotation. Desktop testing: `POKEPORT_TOUCH=1 love .` forces
the overlay on and lets the mouse act as a finger (`=0` forces it off).
The launcher's **Touch Controls** button opens a drag editor: move each
button freely, resize the whole pad with **-/+** (60% to 160%), **Disable**
to hide the overlay permanently (for controllers / emulation handhelds --
distinct from the temporary gamepad auto-hide), **Reset** for defaults,
**Done** to save into `options.lua` as normalized window fractions so a
different screen keeps the relative placement.
Portrait and landscape are edited and saved separately (#633): the editor
follows whichever orientation is on screen, and **Reset** only clears that
one, so a layout that works held upright does not have to double as the
one used sideways. An `options.lua` from before this split keeps its single
layout in both orientations until one of them is edited. In-game, Options →
**TOUCH PAD** toggles the same on/off flag without leaving a play session.
## Haptic feedback (mobile)
Options → **VIBRATION** (also in the launcher's gear menu) buzzes the device
the instant an on-screen control takes a button (#806). A glass pad has no
edges under a thumb, so the pulse is what tells you the press landed:
sliding the d-pad from one direction to the next buzzes again, a second
finger landing on a button that is already held does not, and releasing
never does.
Four levels: **OFF**, **LIGHT** (the default), **MEDIUM**, **HEAVY**.
"Intensity" is really a pulse length -- the platform call takes a duration
and nothing else -- so LIGHT is a 12 ms tick, MEDIUM 25 ms, HEAVY 45 ms.
Stepping the row fires one sample pulse at the level you land on, so the
three can be compared without leaving the menu. On iOS the system
vibration has one fixed length, so all three levels feel the same there and
the row is effectively on/off. The setting lives in `options.lua` and the
row only appears where the on-screen pad can (Android/iOS, or desktop with
`POKEPORT_TOUCH=1`, where it does nothing since desktop LOVE has no
vibrator).
## Screen orientation lock (Android)
Options → **ORIENTATION** (also in the launcher's gear menu) locks the
screen to **PORTRAIT**, **LANDSCAPE** (either landscape, following the
device), or **REVERSE LANDSCAPE**, or leaves it on **AUTO** (#592). AUTO
allows every orientation but defers to the system: with auto-rotate turned
off in Android's quick settings, the game stays put instead of following
the sensor (#716). Changes apply immediately -- the screen rotates as the
row is stepped -- and persist in `options.lua`. Android only: iOS follows
the app's fixed orientation list, and desktop windows rotate nothing.
## Translation support
Every string the player can read is now reachable from a mod, so a
translation is an ordinary content mod rather than a fork.
Two things had to change. Text layout stopped counting bytes: the dialogue
box measures a line in glyphs (charmap sequences), so a 3-byte character
costs one column, a cut never lands inside a character, and a page with a
non-default `advance` re-measures instead of overflowing. That also fixed
25 vanilla English lines that were wrapping early because `é` in POKéMON
and POKéDEX costs two bytes ("I study POKéMON as" is 19 bytes and 18
glyphs, and the box was breaking it).
Second, the text the engine writes itself - battle messages, item results,
menu labels, the link-play screens - moved behind `src/core/Strings.lua`
and the new `strings` registry. Extracted script text was already
overridable through `text`; this covers the other half. Entries are keyed
by the English source, so a translation that has not reached a string yet
keeps rendering in English and a half-finished translation stays playable.
Authors generate the whole thing:
```sh
python3 tools/modkit.py translation francais --language "Francais"
```
That scaffolds a mod with every translatable string as an empty catalog,
plus a glyph-page and charmap stub, a naming-grid stub, and a
`francais-worksheet/` directory holding the English to translate from
(deliberately outside the mod: extracted text is ROM content and must not
be packed). `--refresh` re-harvests after an engine update, keeping
existing translations and parking orphaned keys rather than dropping them.
A translation can also skip glyph pages entirely: scaffolding with
`--pixel-font` (or registering `mod.content.font:register("ttf", {})` in
an existing mod) renders text through a bundled TTF covering Latin with
diacritics, Cyrillic, kana and CJK, while box borders and `<PK>`-style
macro glyphs keep their tiles. The font is "Plain Pixel Font" by Douglas
Vautour (Burpy Fresh), licensed under CC-BY 4.0 (5x11 base characters,
11x11 double-width; see `assets/fonts/plainpixel/README.md`). Options on
the registry entry: `file` for a mod-shipped TTF, `size` (the font's
design em; Plain Pixel rasterizes cleanly only at multiples of 15),
`spacing` added to every advance, `yOffset` for vertical alignment
against the 8px cell grid, `bold`, which double-prints at a 1px
offset for fonts whose strokes read too light, and `tiles`, the
characters that keep their ROM tile instead of coming from the TTF.
`tiles` matters for a CJK translation. Sizing the font so a kana fills
the 8px cell leaves Latin narrower than the tile font it replaces, which
pulls the numeric columns out of line: the party menu's `:L12` stops
sitting over `34/ 34`. Naming `"0123456789/:"` keeps those on the
vanilla tiles, so numbers render exactly as they do in English while
kana still come from the font. It takes a string of characters, or a
list when a multi-character charmap sequence is meant.
See the wiki's Translations guide.
## Save editor (bundled, reachable from the launcher)
The save editor ships inside every build instead of being a developer-only
script, and the launcher's SAVE SLOT card grows an **Edit** label next to
Delete on every slot that actually holds a save. Edit suspends the
launcher, opens that slot's file in the editor, and **Close** hands the
process back to the launcher with the slot list re-read (a rename, a badge
or a dex change shows up on the row immediately). Unsaved edits arm a
confirm first, so leaving cannot lose work. `love . --editor` still opens
it standalone, where Close quits instead; `--save <path>` points it at any
file, and a save can be dragged onto the window.
The editor now wears the launcher's visual language - the same navy radial
field, 16px translucent cards, tri-colour version rail and green/yellow/red
semantics - so the two windows read as one app. Six tabs:
- **Party**: the roster with sprites, HP bars and level chips on the left,
and the mon inspector permanently docked on the right instead of floating
over the list. Species, level, DVs and moves all round-trip through the
Gen 1 formulas, so the inspector can never show illegal stats.
- **Boxes**: the 12 PC boxes as a 5x4 grid with a fill meter per box and a
party dock, so deposit and withdraw live in one place. Empty slots are
clickable and create a mon there.
- **Items**: money, a searchable item picker (replacing the arrows that
cycled one id at a time through ~250 items), the configurable bag (20 slots
by default), PC storage
with no slot cap, and the eight badges as toggle chips. The picker, the bag
and PC storage all scroll under the mouse wheel, so the whole catalog is
reachable one-handed without typing a query.
- **Events**: flags, defeated trainers, taken items and per-map object
toggles, with a real filter field and a two-column paged grid.
- **Map**: any map rendered with the game's own renderer, warps followable,
and the player / lastHeal / lastOutdoor spawn points settable by clicking
a cell. Setting lastOutdoor on a map the game would not accept as an
outdoor source is refused with the reason.
- **Dex**: seen / owned completion meters and a four-column grid; owning
implies seen and un-seeing clears owned, exactly as the game requires.
Two rules run through all of it. Every mutation goes through one funnel
that sets the dirty flag and writes the status line together, so nothing
changes silently and no branch can quietly no-op - "Party is full", "Bag is
full", "click a cell first" all say so. And every destructive verb (Remove,
Release, Clear all, Wipe dex) arms on the first click and commits on the
second, relabelling itself to `Confirm?` in between.
A validation pill in the tab rail mirrors what the running game would
quarantine on load; clicking it jumps to the tab holding the first problem.
## Tiled map editing (mod authoring)
`tools/tiled_export.py` turns the imported ROM cache into a Tiled workspace,
so maps can be edited in a real map editor and exported back out as a mod.
It has its own document: docs/tiled-map-editing.md.
## Pokédex diploma (both versions)
The Celadon Mansion 3F game designer shows the dex-completion diploma
once 150 species are owned. On Yellow, the graphic artist next to him
then offers to print it, saving the certificate as a PNG under `prints/`
in the save directory, and Bill's PC gains Yellow's PRINT BOX item which
exports the current box list the same way.
## Pokédex printing (Yellow)
Yellow's Game Boy Printer PRNT option in the Pokédex side menu is stood in
for by an image export: choosing PRNT renders the mon's entry page (sprite,
kind, number, height/weight, dex text) to a PNG at 4x scale under
`prints/` in the save directory, then reports the filename in a dialog.
No printer hardware or link cable emulation involved; the file is the
printout.
## Find Mods (community mod indexes)
A FIND MODS tab sits beside MODS in the launcher and browses a published
mod index: a metadata-only feed listing mods that live in their authors'
own repositories. No index ships with the launcher and none is ever added
automatically, so the tab opens on an "Add an index" prompt until you name
one; paste an index URL or its `owner/repo` and it is remembered in
`options.lua`. More than one index can be added, and the listings merge.
A feed author can publish per-mod release stats by adding three optional
fields to an entry -- `downloads` (total across every release), and
`first_release` / `last_release` (ISO days) -- which the listing shows in
the same gold line the MODS tab uses. When a feed does not carry them,
the row fetches the mod's own GitHub releases instead -- the same cached
`ModUpdate` fetch the MODS tab uses, one entry per frame -- so the stats
appear for any mod with a `github` field regardless of feed maintenance.
The fields are additive: feeds that carry them stay readable by every
build that predates them, and feeds that do not render exactly as before.
## Soft reset (all versions)
Holding A, B, START and SELECT together restarts the game the way flicking
a Game Boy's power switch did, dropping straight back to the title screen.
It works from anywhere, including mid-battle, which the QUIT entry on the
start menu cannot do: the original combo is how stationary and gift
Pokemon get their stats rerolled without sitting through a full relaunch.
Unsaved progress is discarded, exactly as on hardware.
As on the original, the four buttons have to stay held for 16 straight
polls (better than a quarter of a second) and any direction in the mix
cancels it, so it is hard to hit by accident -- including on the on-screen
touch controls, where it would take four fingers held on four separate
controls.
## Controls rebinding (CONTROLS screen)
OPTIONS -> CONTROLS lists every Game Boy button with its current keyboard
key and controller button side by side (Z/A). Press A on a row, then press
and release the key or pad button you want; the rebind commits on the
release. If that input already belongs to another row, the two rows swap,
so no button is ever stranded without an input and no input ever serves
two buttons. Holding a second key or pad button while the first is still
down backs out of the capture without touching a keyboard; Escape still
cancels too. SELECT clears one row back to its default, and START resets
every binding after a confirmation.
Controllers a system has no mapping for (common on Linux handhelds and
off-brand pads) report bare button numbers rather than names. Those are
rebindable on the same screen and show up as JOY1, JOY2 and so on in the
controller column. Recognized controllers are read only through their
named buttons, so a rebind on those is never shadowed by the factory
layout underneath it.
## Mod profiles (#593)
The mod manager's PROFILES tab holds named setups. A profile remembers which
mods are on, every mod's own options, and which save slot each game version
plays, so swapping profiles swaps the whole playthrough and not just the mod
list. The setup that existed before profiles shipped becomes PROFILE 1 the
first time the manager opens.
EXPORT.. writes the selected profile to `profiles/<NAME>.g1rmodlist` in the
save directory; drop a `.g1rmodlist` someone shared into that folder and
IMPORT.. adds it. Imported profiles never overwrite an existing one (a name
clash gets a number). Mods the shared profile names but that are not installed
are reported when the profile is applied; installing them is still a manual
trip through the mods list or Find Mods.
## Windows: no console windows on launcher actions
Checking for updates, browsing a mod index, adding a mod repo, installing a
mod and picking a ROM all run a host tool (curl, PowerShell) in a child
process. On Windows those children used to each open their own console
window, so a session could end up buried under half a dozen of them. The
game now claims one console for itself at boot and hides it; the children
inherit that invisible console and nothing pops up. Nothing else changes:
file pickers are ordinary desktop dialogs and still appear normally, and a
run started from a terminal (`lovec.exe`, what `scripts\run.ps1` prefers)
keeps its terminal and its printed output. Set `POKEPORT_CONSOLE=1` to opt
out.
## A faster, higher-contrast launcher
The launcher and the save editor were rebuilt on one small immediate-mode UI
kit (`src/ui/kit/`), replacing the vendored FlexLove layout engine. The
visible result is that the launcher is quick: building and drawing a frame
went from about 9 ms to under 1 ms on the same machine and the same data, at
every window size, so the window keeps up with the pointer instead of
trailing it. Measure it yourself with `POKEPORT_LAUNCHER_PROF=200 love .`.
**Nothing blocks the window any more.** Fetching a mod index, checking a mod
for updates, listing versions, downloading an install and pulling thumbnails
all run on background threads. Opening FIND MODS on a cold cache used to
freeze the launcher for as long as the server took -- often minutes, with no
indication anything was happening. Mod indexes are now also fetched at boot,
so the tab is usually already populated by the time you reach it.
**Anything you wait on says so.** Every operation that takes time raises a
loading panel with a spinner or a progress bar that cannot be clicked around
or dismissed, so a half-finished install can never be interrupted by a stray
click. Work that only affects one row (a mod's update check) shows a small
spinner on that row instead and leaves the rest of the list usable.
**Lists page instead of scrolling.** Mods, Find Mods, save slots, settings,
release notes and the version list all show a fixed number of rows with a
pager underneath, and the number of rows comes from the window height -- a
tall window shows more, a phone shows fewer. A long list costs exactly what a
short one does. The mouse wheel turns pages.
**Updates live in the top right.** The in-app updater moved next to the
settings gear and pulses when an update is waiting, instead of sitting in a
banner at the bottom of the page that you had to scroll to notice. Checking
for updates from there shows a loader like everything else.
**A quit button.** An X sits to the right of the settings gear and closes
the app cleanly, the same shutdown path as the window's close button. Mostly
for platforms where reaching the window chrome is awkward (Android, Steam
Deck, fullscreen desktops).
**The look.** Black background, white outlines, no gradients or glows, and
buttons that are solid colour-coded keys: green commits, blue navigates, red
destroys, yellow wants attention. The three game tabs keep their red, blue
and gold cartridge colours. Everything is about a third larger than before.
The save editor follows the same theme, and adding an item there is now a
searchable pop-up like adding a Pokemon, rather than a cramped list wedged
into the tab.
**Reset rebinds.** Input rebinds are additive, so there was no in-game way to
undo one. A RESET REBINDS row in Settings, and a matching button under Touch
Controls on each game tab, restore the stock keyboard, gamepad and touch
layout. Both ask twice.
## Launch options: boot straight into a game
`love . --game=red` skips the launcher and starts that game; `--slot=<id or
number>` picks the save slot to load, and `--launcher` forces the launcher
anyway. Spell these with an `=`: LÖVE reads the command line first and takes a
bare word as a path to a game, so `--game red` fails looking for a folder
called `red`. `POKEPORT_GAME` / `POKEPORT_SLOT` do the same for shortcuts that
can only pass environment variables. This is for one-click entries: a desktop
shortcut per game, a Steam entry, or a handheld frontend. Asking for a game
whose ROM has not been imported opens the launcher on that game's tab rather
than failing.
# New Features
Features intentionally added beyond the original Pokémon Red, Blue, and Yellow games:
* **Survey zoom** with connected-map rendering and configurable void fill
* **Perspective tilt mode** for an HD-2D-style overworld
* **Multiple color modes**, including original, SGB, advanced GBC, monochrome, and classic green
* **Optional GBC screen effects**, including pixel grids, shadows, glare, and transparency
* **Performance presets** and configurable FPS limits
* **Peer-to-peer link play** for trades and battles between Red, Blue, and Yellow
* **Persistent custom options** stored separately from game saves
* **Optional widescreen battle layout**
* **Mobile touch controls** with editable layouts, vibration, and orientation settings
* **Translation and custom font support**
* **Built-in save editor** for parties, boxes, items, events, maps, and Pokédex data
* **Tiled map editing tools** for mod authors
* **Pokédex diploma and printer image exports**
* **Community mod browser**
* **Soft reset button combination**
* **Keyboard and controller rebinding**
* **Mod profiles** with separate mod settings and save slots
* **Improved launcher and save editor UI**, including background downloads and update checks
* **Direct-launch options** for shortcuts, Steam entries, and handheld frontends
* **Custom boot branding**
Actual approximations, and missing original behavior are documented separately in `docs/known-differences.md`.
+79 -39
View File
@@ -1,38 +1,66 @@
# Build the Nintendo Switch NRO — contributor guide
# Build Gen1Recomp for Nintendo Switch
Want to play a release build instead? Download the SD-ready zip and extract it
at your microSD root — see [switch-install.md](switch-install.md).
at your microSD root. See [switch-install.md](switch-install.md).
This guide is for contributors who build Gen1Recomp for Switch from source.
Hardware evidence, MTP operator loops, and deeper notes live in
[switch-development.md](switch-development.md).
> Releases ship `gen1recomp-*-switch.zip` (SD tree under `switch/gen1recomp/`;
> issue [#531](https://github.com/bryanthaboi/gen1recomp/issues/531)). Hardware
> evidence: **OLED** (author) and **V1 boot** (community). See
> [switch-development.md](switch-development.md) for known limitations.
> Releases ship `gen1recomp-*-switch.zip` (SD tree under `switch/gen1recomp/`).
> Runtime target is pinned [love-nx](https://github.com/retronx-team/love-nx)
> `11.5-nx1`. Player install and limitations: [switch-install.md](switch-install.md).
---
## Prerequisites by OS
All packaging entrypoints are **bash**. On Windows, use Git Bash, MSYS2, or
WSL not cmd.exe or PowerShell (AD-008).
WSL, not cmd.exe or PowerShell (AD-008).
### macOS / Linux
1. Install [devkitPro pacman](https://devkitpro.org/wiki/devkitPro_pacman).
2. Install Switch tools:
2. Install Switch tools (**required for `--fused`**):
```sh
sudo dkp-pacman -S switch-dev
```
3. Ensure `nacptool` and `elf2nro` are on `PATH` (or under
`$DEVKITPRO/tools/bin` — the fused script prepends that when set).
3. OTA launcher toolchain, **native or Docker** (either is fine):
**Optional:** Install [Docker](https://docs.docker.com/get-docker/) so fused
builds can fall back to the pinned image when native tools are missing.
```sh
bash scripts/switch/install_devkitpro_deps.sh # native
# or install Docker (same pin as fused builds)
```
4. Ensure `DEVKITPRO` is exported (typical macOS: `/opt/devkitpro`) and
`nacptool` / `elf2nro` are on `PATH` (or under `$DEVKITPRO/tools/bin`).
Fused game builds can also use Docker when native `nacptool`/`elf2nro` are absent.
### Native OTA launcher (included in `--fused`)
In-console OTA uses a **separate DEVKITPRO NRO** (not LÖVE). The LÖVE
self-updater (`Check.lua`) is disabled on NX. Source:
`ports/switch/ota-launcher/`. Host protocol tests (no toolchain):
```sh
make -C ports/switch/ota-launcher host-test
# or
scripts/switch/build_ota_launcher.sh # host-test first; NRO needs DEVKITPRO/Docker
```
`--fused` always builds the fused game, native OTA launcher, and dual-NRO SD
zip. The same `*-switch.zip` is the OTA download asset. **DEVKITPRO is
required.** OTA launcher: native packages **or** Docker. Both are supported.
Release-like build from repo root:
```sh
scripts/build_switch.sh --fetch --fused --version X.Y.Z
```
See `ports/switch/ota-launcher/README.md` and
`scripts/switch/ota_launcher.manifest`.
### Windows (Git Bash / MSYS2 / WSL)
@@ -42,16 +70,15 @@ builds can fall back to the pinned image when native tools are missing.
- **WSL** (Ubuntu/etc.) with the Linux pacman flow above, or
- **Git Bash** for `--fetch` / `--loose`; for `--fused` prefer MSYS2 or
WSL if Docker bind-mounts from Git Bash paths misbehave.
2. Install `switch-dev` (or rely on Docker fallback see below).
2. Install `switch-dev` (or rely on Docker fallback; see below).
3. Do **not** expect `scripts/build_switch.sh` to run under cmd/PowerShell.
### What you must install yourself
| You install | Script does **not** install |
| ----------- | --------------------------- |
| bash, git, zip tooling the repo already expects | |
| `dkp-pacman` + `switch-dev` (native fused) | `dkp-pacman -S …` |
| Docker (optional fused fallback) | Docker Engine |
| bash, git, zip tooling the repo already expects | (none) |
| `dkp-pacman` + `switch-dev` + OTA packages **or** Docker | `dkp-pacman -S …` |
| A legal `.gb` ROM (to play) | Any ROM or game data |
---
@@ -64,12 +91,12 @@ builds can fall back to the pinned image when native tools are missing.
| ---- | ------------ |
| `--fetch` | Downloads pinned **love.nro** + **love.elf** into `.bazinga/love-nx/11.5-nx1/` and verifies SHA-256 against `scripts/switch/love-nx-11.5-nx1.sha256`. |
| `--loose` | Packs `game.love`, copies pinned `love.nro` → `dist/switch/loose/` as `gen1recomp.nro` + `game.love` side by side. Needs the pin. |
| `--fused` | Builds `dist/switch/gen1recomp-<ver>-switch.nro` (game in romfs) via `nacptool` + `elf2nro`, then packs `dist/switch/gen1recomp-<ver>-switch.zip` (SD-ready tree). Needs the pin + toolchain (native or Docker). GitHub Releases publish the **zip only**. |
| `--fused` | Builds fused game NRO, OTA launcher NRO, and dual-NRO SD zip. **Requires DEVKITPRO** + `switch-dev`. OTA launcher: native packages or Docker. GitHub Releases publish the **zip only**. |
Rules:
- `--fetch` alone is fine; combine as `--fetch --loose` or `--fetch --fused`.
- `--loose` and `--fused` are **XOR** — pick one packaging path per run.
- `--loose` and `--fused` are **XOR**. Pick one packaging path per run.
- `--version X.Y.Z` sets the NACP / filename version (defaults to short git SHA).
### What `--fetch` downloads
@@ -109,13 +136,15 @@ scripts/build_switch.sh --fetch
# Loose pair for iteration (fetch + assemble)
scripts/build_switch.sh --fetch --loose
# Single fused NRO + SD-ready zip for a release-like artifact
# Fused game + OTA launcher + dual-NRO SD zip for a release-like artifact
scripts/build_switch.sh --fetch --fused --version 0.2.0
```
Outputs land under `dist/switch/` (and `dist/switch/loose/` for loose mode).
The fused path also writes `gen1recomp-<ver>-switch.nro.sha256` and
`gen1recomp-<ver>-switch.zip` (+ `.sha256` sidecar for the zip).
The fused path also writes `gen1recomp-<ver>-switch.nro` (game),
`gen1recomp-<ver>-launcher.nro`, `gen1recomp-<ver>-game.nro`,
`gen1recomp-<ver>-switch.nro.sha256`, and `gen1recomp-<ver>-switch.zip`
(+ `.sha256` sidecar for the zip).
Offline packaging smoke (no network, no nacptool required):
@@ -143,7 +172,7 @@ the NX runtime modules `src/core/NxAssetOverlay.lua`, `src/core/Platform.lua`,
`tests/engine/switch_diagnostics_test.lua`, `tests/engine/platform_nx_*`,
or the Switch-related workflow YAML), CI runs:
1. **Offline selftest** on `ubuntu-latest` (forks **and** the canonical repo):
1. **Offline selftest** on `ubuntu-latest` (forks **and** the main repo):
`scripts/switch/selftest_build_switch.sh`,
`scripts/switch/verify_payload.sh --self-test`,
`luajit tests/switch_ci_workflows_test.lua`,
@@ -151,14 +180,10 @@ or the Switch-related workflow YAML), CI runs:
headlessly (`luajit tests/engine/assets_version_fallback_test.lua`,
`luajit tests/engine/nx_generated_guard_test.lua`,
`luajit tests/engine/nx_yellow_boot_test.lua`).
2. **Fused NRO build** only on the **canonical** repository
2. **Fused NRO build** only on the **main** repository
(`bryanthaboi/gen1recomp`), on the self-hosted Mac runner
(`scripts/build_switch.sh --fetch --fused`), and only when the workflow
head is that repo (same-repo push/PR). **Fork repository** CI never runs
fused. **Fork → canonical PRs** also skip Switch fused (offline selftest
still runs) so untrusted head code is not executed on the self-hosted Mac;
iOS device build eligibility is unchanged. Fused also waits for a successful
offline selftest before starting on the Mac runner.
head is that repo (same-repo push/PR). Fork CI never runs fused. Fork PRs into the main repo also skip Switch fused (offline selftest still runs) so untrusted head code is not executed on the self-hosted Mac; iOS device build eligibility is unchanged. Fused also waits for a successful offline selftest before starting on the Mac runner.
3. On successful PR fused builds, a follow-up workflow posts a PR comment
linking the Actions artifact named `gen1recomp-switch-nro`
(comment tag `switch-build-result`; see
@@ -169,7 +194,7 @@ Unrelated PRs do not burn the self-hosted Mac on Switch packaging.
### Release hard-fail (`.github/workflows/release.yml`)
GitHub Releases always build Switch on the same self-hosted Mac runner as the
other platforms — this is a **hard gate** (no `continue-on-error`):
other platforms. This is a **hard gate** (no `continue-on-error`):
```sh
scripts/build_switch.sh --fetch --fused --version "<release version>"
@@ -181,9 +206,26 @@ A Switch packaging failure fails the entire release job. The release asset is
### Runner provisioning
The self-hosted Mac runner must have **native switch-tools** (`nacptool` /
`elf2nro`) **and/or Docker** available. CI and release do not silently run
`dkp-pacman -S`; keep the runner image/host provisioned per this guide.
The self-hosted Mac runner **must** have **DEVKITPRO** installed and exported.
`--fused` preflight fails early with setup steps if it is missing.
**One-time setup on the runner** (if not already present):
```sh
# devkitPro pacman installer from https://devkitpro.org/wiki/devkitPro_pacman
sudo dkp-pacman -S switch-dev
export DEVKITPRO=/opt/devkitpro
export PATH="$DEVKITPRO/tools/bin:$PATH"
# OTA launcher: pick one
bash scripts/switch/install_devkitpro_deps.sh # native
# or ensure Docker is installed (same pin as fused builds)
```
CI and release still run `scripts/build_switch.sh --fetch --fused`. Preflight
requires DEVKITPRO and either native OTA packages or Docker. Without all of
that, the job fails with the setup steps above. Scripts never auto-run
`dkp-pacman -S` during CI.
---
@@ -194,11 +236,9 @@ These scripts and this guide do **not**:
- Push files to the console (no automated MTP / FTP / SD scripting)
- Bundle or download any Pokémon ROM
- Install `dkp-pacman` / `switch-dev` for you
- Provide `nxlink` / netloader deploy (deferred see [switch-transfer.md](switch-transfer.md))
- Validate **Applet Mode** — use title override (hold **R**) for full memory
- Provide `nxlink` / netloader deploy (deferred; see [switch-transfer.md](switch-transfer.md))
- Validate **Applet Mode**. Use title override (hold **R**) for full memory
Player install steps: [switch-install.md](switch-install.md).
Manual transfer (MTP / SD / FTP, macOS / Linux / Windows):
[switch-transfer.md](switch-transfer.md).
Hardware depth and evidence: [switch-development.md](switch-development.md),
[switch-hardware-evidence.md](switch-hardware-evidence.md).
[switch-transfer.md](switch-transfer.md).
-528
View File
@@ -1,528 +0,0 @@
# Nintendo Switch development (love-nx)
> Fused NRO support for issue [#531](https://github.com/bryanthaboi/gen1recomp/issues/531).
> Releases ship `gen1recomp-*-switch.zip` (SD-ready tree). Console copy is
> extract/merge at microSD root; title override required. See
> [Known limitations](#known-limitations-read-before-reviewing).
**Canonical install / build / transfer docs** (start here unless you need hardware depth):
- Players → [switch-install.md](switch-install.md)
- Builders → [switch-build.md](switch-build.md) (`scripts/build_switch.sh --fetch` downloads the pinned love-nx pair)
- Transfer (MTP / SD / FTP on macOS, Linux, Windows) → [switch-transfer.md](switch-transfer.md)
This document covers what landed, known limitations, how hardware was tested,
vendor layout, build/deploy, and the contributor transfer loop (detail lives in
the transfer runbook).
## Acknowledgments
- **Port / love-nx packaging:** [andrewqsantos](https://github.com/andrewqsantos)
- **Community hardware testing** (Switch V1 / Erista boot): [booshankles](https://github.com/booshankles)
- **Method guidance:** [Dusklight Switch port](https://github.com/HayatoG/dusklight/tree/main/platforms/switch) / love-nx
- **Upstream project:** [bryanthaboi](https://github.com/bryanthaboi) / Gen1Recomp
## Status
| Area | State |
| ---- | ----- |
| Feature | **Available** — playable fused NRO path (issue #531) |
| Runtime | Pinned love-nx **`11.5-nx1`** |
| Product artifact | Releases: SD-ready `gen1recomp-*-switch.zip`; local/PR: fused `.nro`; loose `nro`+`game.love` for iteration |
| Hardware | **OLED** validated (author, title override); **V1 / Erista** boot confirmed (community). Lite, docked soak, and Pro Controller matrices welcome |
| Deploy / install | Releases publish SD-ready zip; **extract/merge at microSD root** (MTP / SD / FTP — [switch-transfer.md](switch-transfer.md)); no `nxlink` path yet |
| Contributor transfer | Documented for **macOS, Linux, and Windows**; OpenMTP on Mac is one example, not the only contract |
| Network features on NX | Self-update / remote mod download **disabled** (`networkValidated == false`) |
| Community help | Welcome — especially HOS / love-nx packaging and broader hardware coverage |
### What landed
- Detect `NX` via `src/core/Platform.lua` without reusing Android flags
- Writable ROM inbox under `getSaveDirectory()/imports/` + per-tab “Scan again” (SHA-1 match for the open game)
- Joy-Con / gamepad mapping shared by launcher and gameplay (Nintendo A/B UX on NX)
- Launcher L/R tab switch; gameplay L/R game-speed cycle; Select+face display chords
- Focus loss / joystick reconnect recovery; opt-in `switch-debug.txt` diagnostics
- Loose assemble + fused NRO build scripts (`scripts/build_switch.sh`, `scripts/switch/*`)
- Payload gates so ROM / generated cache / saves never enter `game.love`
- Community mod zip inbox at `imports/mods/` (rescan installs; FIND MODS stays network-gated)
- Raw `.sav` inbox at `imports/saves/{red,blue,yellow}/` (**Import save** rescan) + export pull path `exports/{red,blue,yellow}/` (MTP hint; no openURL)
- Hardware evidence for Phase 0 probe, ROM import, naming A/B, save/suspend, fused NRO — see `docs/switch-hardware-evidence.md`
- Path-gated CI selftest + canonical fused PR artifact; release Switch hard-fail
- Save editor pad/touch input (virtual cursor, A click, B close) — see `tools/save-editor/README.md`
- Dynamic display size on NX only: handheld **1280×720**, docked/TV **1920×1080** (`src/core/NxDisplay.lua` + resizable conf so love-nx SDL can follow dock/undock at runtime)
### Known gaps / welcome contributions
- Docked vs handheld soak (≥30 min) and Lite coverage — resolution switch is implemented; long soak still welcome
- Switch Lite and fuller Pro Controller / third-party pad matrices
- Applet Mode remains unsupported by design (title override required)
- `nxlink` / netloader contrib fast-loop (deferred — see [switch-transfer.md](switch-transfer.md))
Transfer runbooks for Linux/Windows (and SD/FTP alternatives) are in
[switch-transfer.md](switch-transfer.md). Community mod zip install OLED smoke
is **pass** — see NXMOD-12 in [switch-hardware-evidence.md](switch-hardware-evidence.md).
## Design references (Dusklight)
This work borrowed method — not the native stack — from the [Dusklight Switch port](https://github.com/HayatoG/dusklight/tree/main/platforms/switch), especially [`LESSONS_AND_REUSE.md`](https://github.com/HayatoG/dusklight/blob/main/platforms/switch/LESSONS_AND_REUSE.md):
| Dusklight lesson | How Gen1Recomp applied it |
| ---------------- | ------------------------- |
| Emulators hide Tegra failures | Gate milestones on **real OLED hardware**, not Ryujinx/Yuzu alone |
| Prove the lower layer first | `tools/switch-probe` before full launcher |
| Know which binary ran | Embedded `build-info.json` (commit / love-nx tag) |
| Cap continuous logs | Opt-in diagnostics, ≤1 Hz flush; Lua error log rotation |
| Crash symbolization needs the exact ELF | Keep pinned `love.elf` with the NRO under test |
| Full memory matters | Title override; Applet Mode is not the validation path |
| Do not treat SD FS like desktop POSIX | Lua stays on `love.filesystem`; inbox + MTP for user files |
| Isolate platform code | Capability module instead of Android flag overload |
| NVK / WSI / `audren` stacks | **Not** copied — love-nx already supplies video/audio/input/FS |
The packaging goal matches Dusklights **single self-contained `.nro`**; contributor transfer stays multi-host (not Mac-only).
## Known limitations (read before reviewing)
1. **Transfer is manual and multi-method.** Runtime only needs files under the LÖVE save directory / NRO install folder. Use MTP, direct SD, or FTP per [switch-transfer.md](switch-transfer.md). macOS + OpenMTP is a documented example for OLED evidence — not “Switch requires a Mac.”
2. **Deploy is manual.** There is no automated push to the console and no `nxlink` path yet. Operators build locally, transfer files, then title-override launch.
3. **Hardware coverage.** Author P0/P1 pass rows were recorded on one Switch OLED; Switch V1 boot was confirmed independently. Treat Lite, docked soak, and other hosts as unknown until someone re-runs the checklist.
4. **No ROM/save/mod zip bytes in git.** Legal dumps and third-party mods stay on the console (or local untracked folders).
5. **AppleDouble sidecars** (`._*`) from some MTP clients can break zip/ROM/`.sav` scans — the launcher skips hidden `.*` names (including `._*.sav`); still prefer clean copies.
## How we tested
| Layer | What | Where |
| ----- | ---- | ----- |
| Unit / headless | Platform NX flags, RomImporter inbox, dual-path input, mod zip inbox, save `.sav` inbox, display chords, payload/self-tests | `tests/*`, `scripts/test.sh` |
| Switch CI / packaging | Path-gated offline selftest (`selftest_build_switch.sh`, `verify_payload.sh --self-test`, `switch_ci_workflows_test.lua`); canonical fused PR artifact | `.github/workflows/ci.yml`, [switch-build.md](switch-build.md) § CI and release |
| Probe on hardware | `getOS()==NX`, 1280×720, save path, Joy-Con events | `tools/switch-probe` → OLED |
| Integration on hardware | MTP inbox ROM import, Play Red/Blue, naming A/B, quit/reopen save, suspend×10, reboot, fused NRO alone + NRO-only update | `docs/switch-hardware-evidence.md` |
| Community hardware | Switch V1 / Erista boot with prebuilt NRO | [booshankles](https://github.com/booshankles) — see evidence log |
| Known gaps | Docked soak, ≥30 min long-play, Lite, automated/`nxlink` deploy | Matrix deferred / absent rows |
Operator evidence must stay in `docs/switch-hardware-evidence.md`. **Do not invent passes** for hardware not run.
## love-nx 11.5-nx1 (pinned)
**Tag:** [11.5-nx1](https://github.com/retronx-team/love-nx/releases/tag/11.5-nx1)
**Local layout (not committed):**
```text
.bazinga/love-nx/11.5-nx1/
├── love.nro # homebrew launcher binary (loose mode: copied to gen1recomp.nro)
└── love.elf # required for fused NRO builds (devkitPro nacptool/elf2nro)
```
**Manifest:** `scripts/switch/love-nx-11.5-nx1.sha256` lists expected artifact names and SHA-256 checksums. Checksums are filled when binaries are fetched (`TBD_*` placeholders until then).
### Fetch instructions
Preferred (automated checksum verify):
```bash
scripts/build_switch.sh --fetch
```
That downloads pinned `love.nro` + `love.elf` into `.bazinga/love-nx/11.5-nx1/`
and checks them against `scripts/switch/love-nx-11.5-nx1.sha256`. See
[switch-build.md](switch-build.md) for the full mode glossary.
Manual fallback:
1. Open the [11.5-nx1 release](https://github.com/retronx-team/love-nx/releases/tag/11.5-nx1) and download `love.nro` and `love.elf`.
2. Create the directory: `mkdir -p .bazinga/love-nx/11.5-nx1`
3. Move both files into that directory.
4. Confirm checksums match the manifest:
```bash
shasum -a 256 .bazinga/love-nx/11.5-nx1/love.nro \
.bazinga/love-nx/11.5-nx1/love.elf
```
**Never commit** love-nx binaries, ROM dumps, or generated cache into git. The repo `.gitignore` excludes `.bazinga/` (vendor cache) and `/dist/` (build output).
## Loose-mode dist layout
Development builds place `gen1recomp.nro` and `game.love` side by side:
```text
dist/switch/loose/
├── gen1recomp.nro
└── game.love
```
Assemble with:
```bash
scripts/build_switch.sh --loose
```
(See `scripts/switch/assemble_loose.sh` for the underlying copy + checksum step.)
## Transfer & deploy (current contributor loop)
Detail for **macOS / Linux / Windows** and **MTP / SD / FTP** lives in
[switch-transfer.md](switch-transfer.md). Summary:
| Layer | Intent |
| ----- | ------ |
| **Runtime / players** | Extract the release zip at microSD root (`switch/gen1recomp/`) and land ROMs/mods under the save-dir inboxes. The game does not hard-depend on OpenMTP or macOS. |
| **Contributor loop** | Manual copy via MTP (DBI responder), direct SD (Hekate UMS / reader), or FTP. Fully manual — no CI deploy, no `nxlink` yet. |
The Mac + OpenMTP steps that remain below are the **OLED evidence reproduction** path; prefer the transfer runbook for day-to-day contrib on other hosts.
**Still avoided for routine evidence** (keeps SD handling honest):
- Treating `nxlink` / netloader as the release deploy story (deferred)
- DBI `MicroSD install` / `NAND install` / NSP-style virtual folders for the `.love`/`.nro` pair
If MTP fails: check cable, USB port, DBI state, and that only one MTP client holds the device — then retry or switch to SD/FTP. Do not silently rewrite evidence using an untested path and claim parity with recorded SHA-256 round-trips.
### Manual deploy checklist (today)
1. Build on the contributor host (`scripts/build_switch.sh --loose` or fused).
2. Close Gen1Recomp on the Switch; open DBI → `Run MTP responder`.
3. Copy artifacts with your MTP client into `1: SD Card/switch/gen1recomp/` (and ROMs/mods into the save-dir inboxes when needed).
4. Wait for the transfer queue; refresh; optionally round-trip SHA-256 on first artifacts of a type.
5. Exit MTP; launch via **title override** (hold **R** on a title → hbmenu, not Applet Mode).
## OpenMTP + DBI transfer (loose build, Mac evidence example)
Full multi-OS / multi-method steps: [switch-transfer.md](switch-transfer.md).
The numbered Mac loop below reproduces the OLED evidence path.
### On the Switch
1. Close Gen1Recomp if it is running.
2. Open **DBI** from hbmenu.
3. Select **`Run MTP responder`** (DBI documents `X` on the main screen).
4. Keep DBI on that screen for the entire transfer.
5. Connect the Switch to the Mac with a USB-C data cable.
### On the Mac
1. Close any other MTP clients.
2. Open **OpenMTP** and select the DBI device.
3. In the remote pane, open **`1: SD Card`**.
4. Navigate to **`switch/`** and create **`gen1recomp/`** if needed.
5. Enter **`1: SD Card/switch/gen1recomp/`**.
6. Drag from the local pane:
```text
dist/switch/loose/gen1recomp.nro
dist/switch/loose/game.love
```
7. Wait for the OpenMTP queue to finish completely.
8. Refresh the remote listing and confirm file sizes match the local files.
9. On the Switch, exit MTP responder normally in DBI before launching the app.
Expected layout on SD:
```text
1: SD Card/
└── switch/
└── gen1recomp/
├── gen1recomp.nro
└── game.love
```
## Round-trip SHA-256 verification
For the **first deploy** of each artifact type (loose pair, later fused NRO), verify MTP integrity:
1. **Before send** — record local hashes:
```bash
shasum -a 256 dist/switch/loose/gen1recomp.nro \
dist/switch/loose/game.love
```
2. **After send** — in OpenMTP, copy the same files from `1: SD Card/switch/gen1recomp/` back to an empty local folder, e.g. `dist/switch/mtp-roundtrip/`.
3. **Compare** round-trip hashes:
```bash
shasum -a 256 dist/switch/mtp-roundtrip/gen1recomp.nro \
dist/switch/mtp-roundtrip/game.love
```
4. Local pre-send and round-trip hashes **must match**. Record results in the test report template below.
Repeat whenever a cable glitch or interrupted transfer is suspected.
## Title override launch (full memory)
Applet Mode is **not** the primary validation path. Use **title override** so hbmenu runs with full memory:
1. Confirm the OpenMTP transfer queue finished.
2. Exit MTP responder in DBI; disconnect USB if desired.
3. Hold **`R`** while launching any legitimately installed title.
4. Keep holding until **hbmenu** appears.
5. Confirm hbmenu does **not** show **Applet Mode**.
6. Launch **`gen1recomp`** (or the probe NRO during Phase 0).
Album / applet launches are only useful to document applet-specific limitations; P0/P1 gates use title override.
## Phase 0 hardware checklist
Complete **in order** on OLED hardware. Operator fills evidence fields — leave blank until tested.
| Step | Action | Pass | Evidence / notes |
| ---- | ------ | ---- | ---------------- |
| P0-0a | Fetch love-nx 11.5-nx1; record manifest SHA-256 | yes | See `scripts/switch/love-nx-11.5-nx1.sha256` |
| P0-0b | Build `switch-probe.love` per `tools/switch-probe/README.md` | yes | |
| P0-0c | Assemble loose probe (`game.love` = probe) to `dist/switch/loose/` | yes | |
| P0-0d | MTP deploy to `1: SD Card/switch/gen1recomp/`; round-trip SHA-256 | yes | nro `8290ac15…5918f5`; love `9f198637…fa2e34f` |
| P0-0e | Title override → probe boots; `getOS()` shows `NX` | yes | `getOS()`=`NX`, `love._os`=`NX` |
| P0-0f | Probe lists 1280×720 (or documented dims), save path, gamepad/touch log | yes | save `sdmc:/switch/gen1recomp/switch-probe`; Joy-Con Y→#3 X→#4 |
| P0-1a | Replace `game.love` with unpatched Gen1Recomp build | yes | feat/switch-nx inbox build |
| P0-1b | MTP replace `game.love` only; round-trip SHA-256 | yes | |
| P0-1c | Title override → launcher reaches import screen | yes | |
| P0-1d | Joy-Con: can navigate launcher (no touch-only) | yes | Full report: `docs/switch-hardware-evidence.md` |
**Operator:** Andrew **Date:** 2026-08-01 **Console:** Switch OLED only
**Deploy:** manual Mac + OpenMTP + DBI MTP (not automated)
**love-nx tag:** 11.5-nx1 **gen1recomp commit:** `df7cea4`
## Phase 0 test report template
Copy this block into your hardware notes or PR evidence. **Do not commit ROM files or ROM hashes of private dumps.**
```markdown
## Switch Phase 0 — hardware report
- Operator:
- Date:
- Console model:
- Atmosphère / HOS version:
- gen1recomp commit:
- love-nx tag: 11.5-nx1
- love.nro SHA-256 (local):
- game.love SHA-256 (local, pre-send):
- MTP round-trip SHA-256 (gen1recomp.nro):
- MTP round-trip SHA-256 (game.love):
- Title override used: yes / no
- Applet Mode observed: yes / no (should be no for P0)
- Probe getOS():
- Probe dimensions:
- Probe save directory shown:
- Gamepad events logged: yes / no
- Touch events logged: yes / no
- Unpatched launcher boot: pass / fail
- Joy-Con launcher navigation: pass / fail / not tested
- Notes:
```
## Fast dev loop (loose mode)
While iterating on Lua/assets:
1. Edit on Mac; run `scripts/test.sh --quick`.
2. Rebuild `.bazinga/work/game.love` (`scripts/build.sh mac --no-notarize` or project pack step).
3. Close Gen1Recomp on Switch.
4. DBI → `Run MTP responder`.
5. OpenMTP → `1: SD Card/switch/gen1recomp/`.
6. Replace **only** `game.love`; wait for queue + refresh listing.
7. Exit MTP responder; launch via title override.
8. Keep `gen1recomp.nro` unchanged until the love-nx pin changes.
```bash
scripts/test.sh --quick
scripts/build.sh mac --no-notarize
scripts/build_switch.sh --loose
shasum -a 256 .bazinga/work/game.love
```
## Controller input mapping (NX)
Measured on Switch OLED (`feat/switch-nx`, love-nx `11.5-nx1`, 1280×720). Both `joystickpressed` and `gamepadpressed` fire for Joy-Con; prefer the gamepad path when `joystick:isGamepad()` is true.
| Path | Control | Mapping |
| ---- | ------- | ------- |
| `gamepadpressed` | D-pad / left stick | move |
| `gamepadpressed` | SDL `a` / `b` on **NX** | swapped via `NX_GAMEPAD_BINDINGS`: physical **A** (east) = GB A confirm, physical **B** (south) = GB B cancel |
| `gamepadpressed` | SDL `a` / `b` on desktop | identity (SDL south = GB A) |
| `gamepadpressed` | `start` / `back` | Start / Select (+ / ) |
| `gamepadpressed` | Right / left shoulder (no Select) | Cycle game speed up / down (same as PC hotkey `1` / speed-down path) |
| `joystickpressed` (raw) | only if **not** `isGamepad()` | face/menu fallback |
| `joystickpressed` (raw) | `#1` / `#2` on NX | Nintendo B / A → GB B / A |
| `joystickpressed` (raw) | `#9` / `#10` | Select / Start ( / +) |
**Nintendo UX on Switch:** physical A confirms, physical B cancels (explicit NX remap of SDL face labels).
**Launcher extras** (`RomImporter`): physical **A** clicks at the virtual cursor; **L** / **R** switch tabs; **Start** / **Select** start Play when a ROM is ready (else open Choose ROM). D-pad / left stick move the virtual cursor.
**Dual-path rule:** love-nx emits both `gamepadpressed` and `joystickpressed` for Joy-Con. When `joystick:isGamepad()` is true, Input and RomImporter **ignore raw** face/menu so NamingScreen does not see A+B in one frame. `NamingScreen` also prefers A over B if both edges still fire.
Implementation: `src/core/GamepadMap.lua` (`NX_RAW_*`, `ignoreRawForJoystick`, `displayChordDigit`), `src/core/Game.lua` (shoulder speed), `src/import/RomImporter.lua` (launcher tabs). Launcher and gameplay share the same converter.
## ROM inbox (NX)
Legal dumps land in a shared MTP inbox; **Scan again** is tab-scoped:
| Item | Value |
| ---- | ----- |
| Save-relative path | `imports/` (also accepts loose `.gb`/`.gbc` at the save-dir root) |
| MTP destination | `1: SD Card/<save identity>/imports/` (see launcher notice for the live `getSaveDirectory()` path) |
| Candidates | `*.gb` / `*.gbc` (hidden `.*` AppleDouble names skipped) |
| Rescan | Game tab → **Scan again** — imports only the dump whose SHA-1 matches that tab (`GameVersion.forSha1`). Other known dumps stay for their own tabs |
| Already ready | Same SHA already imported → “No new ROM found.” |
Players may drop Red, Blue, and Yellow into the same folder. Opening Yellow and pressing **Scan again** must not start a Red import.
## Mod zip inbox (NX)
Community mods install from a **separate** MTP inbox (not mixed into the ROM `imports/` scan):
| Item | Value |
| ---- | ----- |
| Save-relative path | `imports/mods/` |
| MTP destination | `1: SD Card/<save identity>/imports/mods/` (see launcher notice for the live `getSaveDirectory()` path) |
| Candidates | `*.zip` only |
| Rescan | MODS tab → **Scan again** (installs each zip via `LauncherMods.installZip`; source zips are retained on success and failure) |
| FIND MODS | Remains network-gated / hidden on NX (`networkValidated == false`) |
Do **not** commit third-party mod zip bytes into git. Drop the zip over MTP, rescan, enable in MODS, then Play.
**MTP tip (esp. macOS clients):** OpenMTP/Finder often creates AppleDouble sidecars named `._Something.zip` / `._cart.gb` / `._foo.sav`. Those are not real archives, ROMs, or saves — the launcher ignores hidden `.*` names under `imports/`, `imports/mods/`, and `imports/saves/<game>/`. If install still fails with “could not be opened” / “not a zip file”, delete any `._*` under the inbox and confirm the real zip starts with the `PK` magic (re-copy the release asset if unsure). This is a host-side annoyance of the current manual MTP loop, not something players should need forever.
Drop any community release `.zip` into `imports/mods/`, rescan, enable.
Player-facing install steps: [switch-install.md](switch-install.md#community-mods).
Mods own their OPTIONS / rebinds — do not duplicate third-party control tables here.
## Save `.sav` inbox (NX)
Raw Gen1 battery images use a **separate** MTP inbox (not mixed into ROM `imports/` or mod `imports/mods/`):
| Item | Value |
| ---- | ----- |
| Save-relative path | `imports/saves/red/`, `imports/saves/blue/`, `imports/saves/yellow/` |
| MTP destination | `1: SD Card/<save identity>/imports/saves/<game>/` (see launcher notice for the live `getSaveDirectory()` path) |
| Candidates | non-hidden `*.sav` only in **that games** folder |
| Rescan | SAVE FILES → **Import save** on the matching game tab (scans only that folder) |
| After success | Retire to `*.sav.imported` + append content hash to `imports/saves/<game>/.imported-sha1` |
| Exports | **Export save** writes under `exports/<game>/gen1recomp-<game>-<slot>.sav`; NX shows an MTP path notice (no `openURL`) |
Do **not** commit `.sav` bytes into git. Drop the file into the matching game folder over MTP, press **Import save** on that tab, then play. Pull exports from `exports/<game>/`.
**MTP tip:** the same AppleDouble `._*.sav` rule applies — see the mod inbox tip above.
## Joy-Con display chords (Select + face)
PC digit hotkeys for COLORS / TILT / GBC FX / pipelines have Joy-Con equivalents. Hold **Select** (`back` / ) and press a face/shoulder button; the engine runs the same path as `Game:keypressed` for that digit (including `writeOptions` / Pipelines parity).
| Chord (Nintendo UX) | Engine key | Stock engine effect |
| ------------------- | ---------- | ------------------- |
| Select + **A** | `2` | COLORS cycle |
| Select + **B** | `3` | TILT / perspective |
| Select + **Y** | `5` | GBC FX |
| Select + **X** | `6` | Mod pipeline hotkey (if registered) |
| Select + **L** (left shoulder) | `7` | Mod pipeline hotkey (if registered) |
Keys `2` / `3` / `4` / `5` are claimed by the engine before mod pipeline hotkeys run, so a community mod cannot rebind those digits through `Pipelines.hotkey`. Mods that need their own controls should use OPTIONS rows or unclaimed hotkeys.
Without Select held, face buttons keep normal GB A/B gameplay mapping (no accidental color/tilt cycles). The **Options** menu remains available for the same settings — chords are optional shortcuts, not the only path.
On NX, A/B chords resolve through the Nintendo UX face remap so physical **A** → key `2` and physical **B** → key `3` match this table.
**OPTIONS → PERFORMANCE** clamps the ports own extras (TILT / GBC FX / survey ZOOM) and can cap FPS — useful on weaker handheld budgets. Details: [new-features.md — Performance tier](new-features.md#performance-tier-low-end-devices).
Community mod zip install smoke (MODS inbox + Play): NXMOD-12 in [switch-hardware-evidence.md](switch-hardware-evidence.md).
**Opt-in diagnostics:** create an empty `switch-debug.txt` in the save directory; events flush to `switch.log` at ≤1 Hz with build identity (no ROM/save bytes).
**NX asset probe (always on Play):** every Switch Play writes `nx-asset-probe.log` in the save directory (`pokemon-love2d/`). It lists whether `assets/generated/…` vs `yellow|blue/assets/generated/…` exist, what `Assets.resolve` returns, and whether `newImage` / `newImageData` open — for Yellow/Blue blank-sprite triage. No ROM bytes.
**Blue/Yellow cache overlay (NX):** fused love-nx cannot reliably mount `yellow|blue/assets/generated` onto the un-prefixed path, so `src/core/NxAssetOverlay.lua` wraps EVERY read-side love API that accepts a filesystem path (`filesystem.read/load/lines/newFileData/getInfo`, `graphics.newImage/newFont`, `image.newImageData`, `audio.newSource`, `sound.newSoundData`, `font.newFontData`) once at boot — only when `Platform.isNX()`. Covering the whole read surface (not just the loaders the boot needs today) keeps future states and mods inside the fallback automatically; write-side functions stay stock. Core code must NOT call love loaders on literal `assets/generated` paths (enforced by `tests/engine/nx_generated_guard_test.lua`); the chip-audio worker is a separate Lua state and gets the prefix explicitly via `audio.programPrefix` from `ChipAudio.slimAudio`.
**Hardware re-test:** T16 **pass** @ `2699c9a` (naming A=confirm / B=cancel). T19 **pass** (quit/reopen, suspend×10, reboot) — operator 2026-08-01.
**Suspend/resume audio:** after resume, chip music is stopped to avoid duplicate streams; confirm on hardware during P0-09/10 (T19).
## Lua error log (save directory)
On any uncaught Lua error, Gen1Recomp appends a redacted trace to `lua-error.log` in the LÖVE save directory (`love.filesystem.getSaveDirectory()`). The on-screen error overlay includes a hint pointing at that file. Logs rotate to `lua-error.log.1` when the active file exceeds 32 KiB. ROM/save bytes and non-printable data are stripped — never commit or share logs that might contain private paths without reviewing them first.
## Native crash triage (love-nx / Atmosphère)
love-nx native faults land under the consoles `crash_reports/` folder on SD (reachable via the same manual MTP workflow used for game deploys).
1. **Collect** — DBI → `Run MTP responder`; copy `sdmc:/crash_reports/*.bin` (or the dated subfolder) to the contributor host. Prefer keeping the microSD in-console for routine pulls.
2. **Redact** — delete any attached screenshots or notes that mention ROM filenames, save paths, or private hashes before sharing logs publicly.
3. **Symbolize** — use the **pinned** `love.elf` from `.bazinga/love-nx/11.5-nx1/` that matches `build-info.json` / `scripts/switch/love-nx-11.5-nx1.sha256`. Never use a “latest” download.
```bash
# Example: aarch64-none-elf-addr2line from devkitPro
aarch64-none-elf-addr2line -e .bazinga/love-nx/11.5-nx1/love.elf -f -C 0xADDRESS_FROM_CRASH_REPORT
```
4. **Correlate** — compare `gitCommit` / `loveNxTag` from embedded `build-info.json` with the operators hardware notes.
If `addr2line` cannot resolve an address, archive the crash `.bin` with the exact `love.elf` SHA-256 used for the build — addresses are only meaningful against that ELF.
## P0 / P1 hardware matrix (ADR §9)
Operator evidence lives in `docs/switch-hardware-evidence.md`. **Do not invent passes** for rows that require hardware not yet run.
| ID | Requirement | Status | Evidence |
| -- | ----------- | ------ | -------- |
| P0-0af | love-nx pin, probe, MTP, title override | **pass** | Phase 0 checklist above; T4 |
| P0-1ad | Unpatched launcher boot + Joy-Con nav | **pass** | T4 / `docs/switch-hardware-evidence.md` |
| P0-02 | MTP inbox import path shown | **pass** | T12 |
| P0-03 | Rescan imports ROM | **pass** | T12 |
| P0-04 | Canonical hash routes version | **pass** | T12 |
| P0-05 | Source dump retained in inbox | **pass** | T12 |
| P0-06 | Play reaches game after import | **pass** | T12 |
| P0-07 | Joy-Con launcher navigation | **pass** | T16 @ `2699c9a` |
| P0-08 | Joy-Con gameplay (incl. naming A/B) | **pass** | T16 @ `2699c9a` |
| P0-09 | Save survives quit + reopen | **pass** | T19 |
| P0-10 | ≥10 suspend cycles, no stuck input/dup audio | **pass** | T19 (operator 2026-08-01) |
| P0-12 | Fused NRO boots without adjacent `game.love` | **pass** | T24 — `docs/switch-hardware-evidence.md` |
| P0-14 | Fused NRO MTP round-trip SHA-256 | **pass** | T24 — first artifact `b019e2e8…` @ `6fb5602` (redeploy after Blue fix) |
| P0-15 | Replace NRO only; saves persist | **pass** | T24 — operator NRO-only update keeps saves |
| P1-01 | Docked vs handheld spot-check | **deferred** | Code: `NxDisplay` 720p↔1080p; OLED dock soak not recorded yet |
| P1-02 | Applet Mode documented unsupported | **pass** | Title override required; Album path not validated |
| P1-03 | Long-play soak (≥30 min) | **deferred** | No soak session recorded |
| P1-04 | Reboot persistence | **pass** | T19 |
| P1-05 | Audio resume after suspend | **pass** | T19 (no dup audio reported) |
| — | Switch V1 / Erista boot | **pass** (boot) | Community — [booshankles](https://github.com/booshankles); see evidence log |
| — | Switch Lite / docked soak | **untested** / **deferred** | Welcome contributions |
| — | Automated / `nxlink` deploy | **absent** | Manual MTP / SD / FTP only (AD-009) |
| — | Multi-OS transfer runbooks | **pass** | [switch-transfer.md](switch-transfer.md) |
| — | Community mod zip OLED smoke (NXMOD-12) | **pass** | `docs/switch-hardware-evidence.md` |
## Review guidance
Maintainers may review as one PR or split later. Suggested slices (optional):
Each slice should declare: **no ROM/save bytes committed**, **love-nx pin with manifest checksums**, **hardware-tested rows listed with linked evidence**, **Applet Mode unsupported**, **network/updater disabled on NX**, **deploy still manual** (MTP / SD / FTP; no nxlink yet), **OpenMTP is one example not the sole contract**.
### Slice 1 — Platform + import (`platform/import`)
- `src/core/Platform.lua`, `conf.lua` NX branch
- `src/import/RomImporter.lua` (NX flags, inbox, scan, shell/updater gates)
- Tests: `tests/engine/platform_nx_*`, `tests/engine/rom_importer_nx_*` (ROM-free T2)
- Docs: inbox/MTP import sections only
### Slice 2 — Input + lifecycle (`input/lifecycle`)
- `src/core/GamepadMap.lua`, `Input.lua`, `main.lua` focus/joystick hooks
- `src/debug/SwitchDiagnostics.lua` (opt-in probe + error log)
- Tests: input/diagnostics suites
- Docs: controller mapping, suspend/audio notes
### Slice 3 — Build + docs (`build/docs`)
- `scripts/pack_love.sh`, `scripts/build_switch.sh`, `scripts/switch/*`
- `assets/switch/icon.jpg`, `docs/switch-development.md`, hardware evidence templates
- Gates: `pack_love.sh --dry-run`, `verify_payload.sh --self-test`, fused build script (devkitPro host)
**Pre-merge checklist:**
- [ ] Manifest `scripts/switch/love-nx-11.5-nx1.sha256` filled; binaries not in git
- [ ] `verify_payload.sh` rejects generated cache / ROM / `.sav` / `.bak`
- [ ] P0 matrix rows marked pass only with linked hardware evidence
- [x] Fused NRO P0-12/14/15 pass with T24 evidence (`docs/switch-hardware-evidence.md`)
- [ ] Updater / remote mod download hidden on NX (`networkValidated == false`)
-187
View File
@@ -1,187 +0,0 @@
# Switch hardware evidence (Phase 0 + import + input)
> **Hardware evidence log.** Author passes below were recorded on **one
> Nintendo Switch OLED** with a **manual** Mac → DBI MTP deploy loop. A
> separate community row records Switch V1 / Erista boot. These rows do
> **not** claim Lite, docked soak, or automated install. See
> `docs/switch-development.md` for status and limitations.
**love-nx:** `11.5-nx1`
**Author console:** Switch OLED
**Deploy method (author):** manual OpenMTP + DBI `Run MTP responder` (no CI / no nxlink)
**Operator (author rows):** Andrew ([andrewqsantos](https://github.com/andrewqsantos))
**Date (author rows):** 2026-08-01
Do **not** commit ROM dumps or private dump hashes. Do **not** mark a row **pass** without hardware notes for that row.
---
## Community — Switch V1 / Erista boot — pass (boot)
| Field | Value |
| ----- | ----- |
| Console | Nintendo Switch V1 (Erista) |
| Check | Prebuilt fused NRO boots under title override |
| Tester | [booshankles](https://github.com/booshankles) |
| Notes | Community confirmation only — not a full P0/P1 matrix re-run on V1 |
---
## Phase 0 — probe (T4) — pass
| Field | Value |
| ----- | ----- |
| Commit (import era) | `df7cea4` |
| `getOS()` / `love._os` | `NX` |
| Dimensions | 1280×720 |
| Save (probe) | `sdmc:/switch/gen1recomp/switch-probe` |
| Joy-Con | `joystickpressed` + `gamepadpressed` (Y→`#3`, X→`#4`) |
| Artifact | SHA-256 |
| -------- | ------- |
| `gen1recomp.nro` | `8290ac153d4c630e48c9b26ef9123f5204ed8ee0cef3042511707b5b645918f5` |
---
## T12 — Red import + Play — pass
Inbox MTP → “Scan again” → Play; Joy-Con launcher/gameplay (not touch-only).
---
# T16 — Joy-Con launcher + gameplay — pass (naming re-verify)
### Round 1 @ `7504753` — partial
| Check | Result |
| ----- | ------ |
| Launcher / overworld (Joy-Con only) | **pass** |
| Naming player/rival | **fail** (dual-path a+b; see below) |
| Touch required | **no** |
| `game.love` SHA-256 | `bd3a35461bf453c1f0465a5a289421aef3b5c72d3bf1f8d76e86231256829e0e` |
### Naming failure (root cause) — fixed in `efd81d8` + `2699c9a`
- love-nx fires **`gamepadpressed` + `joystickpressed` on the same physical press**.
- `NamingScreen` tested `wasPressed("b")` before `"a"` → if both true in one frame, always deletes.
- Dual-path fix: ignore raw when `isGamepad()` (`efd81d8`).
- SDL-only UX then had physical B confirm / A erase; NX face remap (`2699c9a`) restores Nintendo A=confirm / B=cancel.
### Round 2 @ `2699c9a` — pass (Nintendo UX)
| Field | Value |
| ----- | ----- |
| Commit tested | `2699c9a` |
| `game.love` SHA-256 | `a208b21e1f30b00e2e8c6fa6efe14f0e06d1db0ae1e50b810b16d9fb852926bc` |
| Touch required | **no** |
| Check | Result |
| ----- | ------ |
| Naming — player | **pass** — physical **A** confirms letter, **B** cancels/erases |
| Naming — rival | **pass** (same) |
| Launcher / overworld (prior round) | **pass** (unchanged mapping for d-pad/stick) |
T16 hardware gate: **closed**.
---
## T19 — save / suspend — pass
| Check | Result |
| ----- | ------ |
| Save in-game → full quit → title-override reopen → load save | **pass** (@ `7504753` / retained) |
| Suspend/resume ×10 (launcher / gameplay / mixed) | **pass** (operator 2026-08-01) |
| Full console reboot persistence | **pass** (operator 2026-08-01) |
T19 hardware gate: **closed**. No stuck input, duplicate audio, or crash reported.
---
## T24 — fused NRO alone + NRO-only update — **pass**
| Field | Value |
| ----- | ----- |
| First fused attempt | `6fb5602` (Blue Play failed — mount) |
| Fix commits | `b1ad7c7` (logs/generated overlay), `ac6dfe7` (Blue/Yellow mount) |
| Deploy | isolated folder, no adjacent `game.love` |
| Boot fused | **pass** |
| ROM import | **pass** |
| Play **Red** | **pass** |
| Play **Blue** (after `ac6dfe7`) | **pass** (operator 2026-08-01) |
| NRO-only replace | **pass** — saves retained; app still boots/plays |
| Touch required | no |
T24 hardware gate: **closed**.
---
## SWBLD — `build_switch.sh --fetch --fused` + install path — **pass**
Operator smoke for the switch-build-pipeline packaging CLI (closes matrix-deferred happy paths from validation).
| Field | Value |
| ----- | ----- |
| Command | `scripts/build_switch.sh --fetch --fused --version 0.0.0-test` |
| Host | macOS + native switch-tools (or Docker fallback if used) |
| Commit / build-info | `9147a64` (`gitCommit` in build-info) |
| love-nx | `11.5-nx1` (manifest checksums match) |
| Artifact | `dist/switch/gen1recomp-0.0.0-test-switch.nro` |
| NRO SHA-256 | `210efb884a8d27443dc1c64ed8f071b0f862d8d0c9b140ad8185093c4e4027db` |
| Install doc | `docs/switch-install.md` — at the time of this row: copy NRO under `sdmc:/switch/gen1recomp/` (releases now ship an SD-ready zip; same folder) |
| Console | Switch OLED |
| Operator | Andrew |
| Date | 2026-08-01 |
| Check | Result |
| ----- | ------ |
| `--fetch` + `--fused` produce NRO + `.sha256` | **pass** |
| Copy NRO to SD folder per install doc | **pass** (operator) |
| Title-override launch / play | treated as prior T24 path; this row records **packaging + deploy to folder** success |
SWBLD packaging smoke: **closed** for Mac fused build + file-to-SD install step.
---
## NXMOD-12 — Community mod zip OLED smoke — **pass**
Closed from existing OLED photo evidence on issue
[#531](https://github.com/bryanthaboi/gen1recomp/issues/531) (operator comment
with launcher MODS + overworld shots). Photos live on the orphan branch
[`switch-oled-photos`](https://github.com/andrewqsantos/gen1recomp/tree/switch-oled-photos)
of the operator fork — **not** committed to this repo. Do **not** commit
third-party mod `.zip` bytes. Community mods own their OPTIONS / rebinds;
this entry only proves the MODS inbox + Play path on OLED.
| Field | Value |
| ----- | ----- |
| Status | **pass** |
| gen1recomp commit | evidence era on `feat/switch-nx` (see #531); packaging pin love-nx `11.5-nx1` |
| love-nx tag | `11.5-nx1` |
| Console | Switch OLED |
| Mod | community release `.zip` (not vendored; not named here) |
| Zip committed to git? | **no** |
| Photo evidence | [#531 comment](https://github.com/bryanthaboi/gen1recomp/issues/531) — MODS tab + overworld |
| MODS tab photo | https://raw.githubusercontent.com/andrewqsantos/gen1recomp/switch-oled-photos/IMG_1766.jpg |
| Overworld photo | https://raw.githubusercontent.com/andrewqsantos/gen1recomp/switch-oled-photos/IMG_1771.jpg |
| Operator | Andrew |
| Date | 2026-08-01 |
### Checklist
| Step | Pass / fail / pending | Notes |
| ---- | --------------------- | ----- |
| MTP zip into save `imports/mods/` | **pass** | Photo evidence + prior inbox path |
| MODS → Scan again → mod listed | **pass** | IMG_1766 — community mod installed |
| Enable mod + Play Red boots without crash | **pass** | Overworld / Pallet / Oak lab photos on #531 |
| Overworld Select+A → visible colors change | **pass** | Stock COLORS chord path exercised |
| Overworld Select+B → visible tilt/perspective change | **pass** | Stock TILT chord path exercised (IMG_1771) |
### Evidence notes
```text
Operator: Andrew
Date: 2026-08-01
Commit tested: feat/switch-nx era documented on issue #531
Pass / fail summary: PASS — MODS zip install + Play on Switch OLED
Photo branch: andrewqsantos/gen1recomp@switch-oled-photos
```
+65 -25
View File
@@ -2,15 +2,11 @@
Every GitHub Release that includes Switch support ships an SD-ready zip:
`gen1recomp-*-switch.zip`. Extract it at the root of your microSD (install
**or** update same steps), launch with **title override**, then import your
or update, same steps), launch with **title override**, then import your
own legal `.gb` ROM.
> You need a console that can run Switch homebrew (custom firmware / hbmenu).
> This project does not help you set that up. Tracks issue
> [#531](https://github.com/bryanthaboi/gen1recomp/issues/531).
> Hardware: **OLED** validated by the porter; **V1 / Erista** boot confirmed
> by the community. Lite and other setups welcome more reports.
> See [switch-development.md](switch-development.md) for limitations.
> This project does not help you set that up.
Prefer building from source? See [switch-build.md](switch-build.md).
@@ -29,12 +25,18 @@ help from [booshankles](https://github.com/booshankles).
Extract the zip at the **root** of the microSD so you get:
```text
sdmc:/switch/gen1recomp/gen1recomp.nro
sdmc:/switch/gen1recomp/gen1recomp.nro # native OTA launcher (hbmenu entry)
sdmc:/switch/gen1recomp/gen1recomp-game.nro # fused LÖVE game
sdmc:/switch/gen1recomp/version.txt
sdmc:/switch/gen1recomp/pokemon-love2d/imports/
sdmc:/switch/gen1recomp/pokemon-love2d/imports/mods/
sdmc:/switch/gen1recomp/pokemon-love2d/imports/saves/...
```
Older single-NRO zips only had `gen1recomp.nro` (the fused game). Current
releases use the dual-NRO layout above. Open `gen1recomp` in hbmenu (the
launcher).
Merge folders if your OS asks. Any method works: **MTP** (DBI → Run MTP
responder + a client), **direct SD** (Hekate UMS or a card reader), or **FTP**.
Exit MTP / unmount / stop FTP cleanly before launching. Step-by-step for
@@ -42,17 +44,44 @@ macOS, Linux, and Windows: [switch-transfer.md](switch-transfer.md).
### Updating
Use the **same** extract/merge. It replaces `gen1recomp.nro` (and the small
help `README.txt` / `INSTALL.txt` files). Saves, imported ROMs, mods, and
options live under `pokemon-love2d/`**do not delete that folder** when
updating, or you will lose progress.
#### Native OTA launcher (in-console)
Switch OTA runs in a separate **native launcher NRO** (libnx + curl), not the
LÖVE self-updater (`src/update/Check.lua`). hbmenu opens `gen1recomp.nro`.
When a newer release exists, the launcher downloads the same install zip
(`gen1recomp-*-switch.zip`), checks SHA-256 against `sha256sums.txt`, replaces
both `gen1recomp-game.nro` and `gen1recomp.nro` (keeps NACP version in sync
for hbmenu and Sphaira), then loads the game with `envSetNextLoad`.
If you are up to date or offline, it skips straight to the game with no
prompt. If an update is available, you get a short prompt styled like the
in-game launcher: black background, RGB rail, logo, A/B buttons. Saves under
`pokemon-love2d/` are not touched. See `src/update/SwitchOta.lua` for the
wire format.
The LÖVE self-updater stays **disabled** on NX (`networkValidated == false`).
**Sphaira forwarder (HOME shortcut):** Sphaira copies name/version/icon into
the installed forwarder at creation time. After an OTA (or zip) update, the
`.nro` on the microSD already has the new version, but the HOME shortcut
keeps the old badge until you **reinstall the forwarder once** in Sphaira
(Install Forwarder again on `gen1recomp.nro`). Browsing the NRO in Sphaira /
hbmenu always shows the live file version.
#### Manual zip (fallback)
Use the **same** extract/merge of `gen1recomp-*-switch.zip`. It replaces the
NROs (and the small help `README.txt` / `INSTALL.txt` files). Saves,
imported ROMs, mods, and options live under `pokemon-love2d/`. **Do not
delete that folder** when updating, or you will lose progress.
## 3. Launch with title override
**Applet Mode is not supported** for this game (not enough memory).
1. On the Switch HOME menu, highlight any installed title.
2. Hold **R** and launch that title — this opens hbmenu with full memory
2. Hold **R** and launch that title. This opens hbmenu with full memory
(title override).
3. From hbmenu, open `gen1recomp`.
@@ -66,14 +95,14 @@ This project ships **no** game data. On first launch:
(`.gbc`) dump into `switch/gen1recomp/pokemon-love2d/imports/` (the
launcher also shows the live save-dir path). All three can sit in the
same folder.
2. Use **Scan again** on that games tab (Red / Blue / Yellow). Rescan
matches by ROM SHA-1 for the open tab only — a Red dump never imports
2. Use **Scan again** on that game's tab (Red / Blue / Yellow). Rescan
matches by ROM SHA-1 for the open tab only. A Red dump never imports
from the Yellow tab (and vice versa).
## 5. Import / Export a raw `.sav`
Continue a cart or PC battery save (or pull a slot off-console) via MTP /
SD / FTP same transfer methods as ROMs. Paths are **per game**:
SD / FTP, same transfer methods as ROMs. Paths are **per game**:
| Game | Import inbox | Export folder |
| ---- | ------------ | ------------- |
@@ -81,19 +110,19 @@ SD / FTP — same transfer methods as ROMs. Paths are **per game**:
| Blue | `imports/saves/blue/` | `exports/blue/` |
| Yellow | `imports/saves/yellow/` | `exports/yellow/` |
(Under the save dir `pokemon-love2d/` — the zip already creates these folders.)
(Under the save dir `pokemon-love2d/`. The zip already creates these folders.)
1. Copy a Gen1 `.sav` (32 KB) into that games inbox under the save dir
1. Copy a Gen1 `.sav` (32 KB) into that game's inbox under the save dir
([switch-transfer.md](switch-transfer.md)).
2. With the games ROM already imported, open **that games tab**
2. With the game's ROM already imported, open **that game's tab**
**SAVE FILES****Import save**. Only that folder is scanned.
3. A successful import retires the file to `*.sav.imported` and records its
content hash so pressing **Import save** again does not clone slots.
Failed imports leave the original `.sav` in place.
4. To pull a slot off the console, use **Export save**, then copy the file
from that games **`exports/<game>/`** folder via MTP / SD / FTP.
from that game's **`exports/<game>/`** folder via MTP / SD / FTP.
Do not put `.sav` files into git. Prefer clean copies — some MTP clients
Do not put `.sav` files into git. Prefer clean copies. Some MTP clients
create `._*.sav` AppleDouble sidecars that are not real saves.
## Controls
@@ -130,12 +159,12 @@ create `._*.sav` AppleDouble sidecars that are not real saves.
Mods install from a zip inbox (same transfer methods as ROMs):
1. Copy a release `.zip` into the save-dir **`imports/mods/`** path the
launcher shows (MTP / SD / FTP [switch-transfer.md](switch-transfer.md)).
launcher shows (MTP / SD / FTP. See [switch-transfer.md](switch-transfer.md)).
2. In the launcher, open **MODS****Scan again** → enable the mod →
**Play**.
Remote **FIND MODS** / GitHub download stays **off** on Switch. Do not put
mod zips into git. Community mods ship their own OPTIONS / rebinds — this port
mod zips into git. Community mods ship their own OPTIONS / rebinds. This port
does not document third-party control tables.
### Joy-Con shortcuts (Select + face)
@@ -153,12 +182,23 @@ hotkeys (`2`/`3`/`5` are claimed before any mod pipeline hotkey runs).
| Select + **L** | `7` | Mod pipeline hotkey (if a mod registers `7`) |
If the handheld stutters with extras on, try **OPTIONS → PERFORMANCE**
`LOW` or `BALANCED`. Full chord notes for contributors:
[switch-development.md](switch-development.md#joy-con-display-chords-select--face).
`LOW` or `BALANCED`.
## Limitations
- You need homebrew (custom firmware, hbmenu). This project does not set that
up.
- Launch with title override (hold **R** on a title). Applet Mode (Album) is
not supported. The game needs full memory.
- ROMs, mods, and saves are copied manually via MTP, direct SD, or FTP. There
is no automated deploy.
- Updates use the native OTA launcher only. The LÖVE self-updater and remote
**FIND MODS** stay off on Switch.
- Tested on Switch OLED. Switch V1 / Erista boot confirmed by the community.
Other models may work but are less tested.
## Prefer building it yourself?
Building the fused NRO (and SD-ready zip) from source is covered in
[switch-build.md](switch-build.md). Copying artifacts and inbox files
(MTP / SD / FTP on macOS, Linux, Windows): [switch-transfer.md](switch-transfer.md).
Status, limitations, and how we tested: [switch-development.md](switch-development.md).
+19 -22
View File
@@ -1,17 +1,16 @@
# Switch file transfer (MTP / SD / FTP)
Canonical ways to put Gen1Recomp artifacts and inbox files onto a Nintendo
Switch. **Any method is valid** if the bytes land in the destinations below.
Ways to put Gen1Recomp artifacts and inbox files onto a Nintendo Switch.
**Any method is valid** if the bytes land in the destinations below.
This is the home runbook for contributors on **macOS, Linux, and Windows**.
This is the runbook for contributors on **macOS, Linux, and Windows**.
Player install (what to download, title override) stays in
[switch-install.md](switch-install.md). Packaging stays in
[switch-build.md](switch-build.md). Hardware evidence lives in
[switch-hardware-evidence.md](switch-hardware-evidence.md).
[switch-build.md](switch-build.md).
> **Not supported yet:** `nxlink` / hbmenu netloader automation. Useful later
> for a fast contrib rebuild loop; deferred on purpose (AD-009). Do not treat
> netloader as the release or ROM/mod install path.
> for a fast contrib rebuild loop; deferred (AD-009). Do not treat netloader as
> the release or ROM/mod install path.
---
@@ -23,7 +22,7 @@ Player install (what to download, title override) stays in
| Loose iteration pair | `sdmc:/switch/gen1recomp/gen1recomp.nro` **and** `game.love` beside it |
| ROM inbox | LÖVE save dir → `imports/` (launcher shows the live `getSaveDirectory()` path; under MTP often `1: SD Card/<save identity>/imports/`) |
| Mod zip inbox | Same save dir → `imports/mods/` then MODS → **Scan again** |
| Save `.sav` inbox | Same save dir → `imports/saves/red\|blue\|yellow/` then that games SAVE FILES → **Import save** |
| Save `.sav` inbox | Same save dir → `imports/saves/red\|blue\|yellow/` then that game's SAVE FILES → **Import save** |
| Save exports | Same save dir → `exports/red\|blue\|yellow/` (pull after **Export save**; MTP / SD / FTP) |
| Opt-in diagnostics | Empty `switch-debug.txt` in the save dir → `switch.log` |
| Lua error log | `lua-error.log` in the save dir |
@@ -34,7 +33,7 @@ files, or third-party mod zips to git.
---
## Canonical methods
## Transfer methods
### 1. MTP (DBI responder + host client)
@@ -47,8 +46,8 @@ before launching.
#### macOS (example: OpenMTP)
[OpenMTP](https://github.com/ganeshrvel/openmtp) is the loop used for OLED
hardware evidence — **one contributor example**, not a Mac-only product rule.
[OpenMTP](https://github.com/ganeshrvel/openmtp) is a documented example for
macOS, not a Mac-only requirement.
1. Quit other MTP clients.
2. Open OpenMTP → select the DBI device → **`1: SD Card`**.
@@ -60,25 +59,25 @@ hardware evidence — **one contributor example**, not a Mac-only product rule.
5. Wait for the queue; refresh; exit MTP responder; title-override launch.
macOS clients often create AppleDouble sidecars (`._Something.zip`,
`._cart.gb`, `._foo.sav`). Those are not real archives or saves — the
`._cart.gb`, `._foo.sav`). Those are not real archives or saves. The
launcher skips hidden `.*` names. Delete `._*` junk if a zip/ROM/`.sav`
fails to open.
#### Linux
1. Install desktop MTP support if needed (e.g. `gvfs-mtp` on GNOME/GTK
desktops, or your distros KDE MTP stack).
desktops, or your distro's KDE MTP stack).
2. With DBI MTP active, open **Files** / **Dolphin** / **Thunar** and select
the Switch / DBI device → **`1: SD Card`**.
3. Extract the release zip at SD root (merge), or copy into `switch/gen1recomp/`
and the save-dir inboxes as above.
4. Use **only one** MTP accessor at a time. If `mtp-tools` / `mtpfs` reports
device is busy, close the file managers MTP mount (or the CLI mount)
"device is busy", close the file manager's MTP mount (or the CLI mount)
and retry with a single client.
5. Eject/unmount cleanly; exit MTP on the Switch; title-override launch.
If MTP is unavailable or flaky on Linux, use **direct SD** (Hekate UMS or a
card reader) or **FTP** instead — same destinations in the table above.
card reader) or **FTP** instead. Same destinations in the table above.
#### Windows
@@ -93,7 +92,7 @@ card reader) or **FTP** instead — same destinations in the table above.
5. Safely disconnect; exit MTP on the Switch; title-override launch.
If MTP is unavailable or flaky on Windows, use **direct SD** (Hekate UMS or a
card reader) or **FTP** instead — same destinations in the table above.
card reader) or **FTP** instead. Same destinations in the table above.
### 2. Direct SD (Hekate UMS or card reader)
@@ -109,9 +108,9 @@ Do not yank the card or unplug UMS mid-write.
### 3. FTP (any SD-exposing Switch FTP)
Any homebrew FTP server that can write the microSD is fine — for example
**DBIs own FTP**, **sys-ftpd-light**, or **Sphaira** (names are illustrations
only; pick what your CFW setup already uses).
Any homebrew FTP server that can write the microSD is fine. For example
**DBI's own FTP**, **sys-ftpd-light**, or **Sphaira**. Names are illustrations
only; pick what your CFW setup already uses.
1. Start the FTP server on the Switch; note IP/port/credentials from that app.
2. From the host, connect with any FTP client and upload to the same
@@ -154,7 +153,7 @@ Copy the file back from the SD and compare hashes. Round-trip must match.
| Symptom | What to try |
| ------- | ----------- |
| Device busy / no MTP volume | One client only; different cable/port; Windows MTP USB Device driver; alternate method (SD or FTP) |
| Zip/ROM/`.sav` could not be opened | Delete `._*` sidecars (including `._*.sav`); confirm real zip starts with `PK` |
| Zip/ROM/`.sav` "could not be opened" | Delete `._*` sidecars (including `._*.sav`); confirm real zip starts with `PK` |
| Half-copied NRO / crash on boot | Re-copy; verify SHA-256; exit transfer mode before launch |
| App opens in Applet Mode | Use title override (hold **R**), not Album |
@@ -164,5 +163,3 @@ Copy the file back from the SD and compare hashes. Round-trip must match.
- Players: [switch-install.md](switch-install.md)
- Builders: [switch-build.md](switch-build.md)
- Status / hardware matrix: [switch-development.md](switch-development.md)
- Evidence log: [switch-hardware-evidence.md](switch-hardware-evidence.md)
+5
View File
@@ -124,3 +124,8 @@ bundled game, in that case.
`love.filesystem.isFused()` is false, and a working tree's `engine` is the
`"0.0.0-dev"` placeholder that always reports up to date, so a source
checkout is always "the game" itself; updating it means pulling the repo.
- **Nintendo Switch does not use this LÖVE self-updater.** On NX,
`Platform.networkValidated()` is `false`, so `Boot.run` / `Check` never
download `.love` payloads. In-console OTA uses the **native OTA launcher**
(DEVKITPRO), documented in [switch-install.md](switch-install.md). Wire
format: `src/update/SwitchOta.lua`. Manual zip install remains the fallback.
+7
View File
@@ -12,6 +12,13 @@
"tintColor": "3b5ca8",
"category": "games",
"versions": [
{
"version": "0.1.74",
"date": "2026-08-06",
"size": 9588946,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.74/gen1recomp-0.1.74-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #883 Evolution Stones consumed if evolution is cancelled\n- #887 MacOS Closing game just keeps reopening\n- #894 Max Repel usable in battle\n\n## Contributors\n\n- @bryanthaboi\n- @MarceloMachadoxD\n- @ratherDashing"
},
{
"version": "0.1.73",
"date": "2026-08-06",

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.
+72
View File
@@ -0,0 +1,72 @@
#---------------------------------------------------------------------------------
# One-shot OTA bootstrap NRO (copied into the main launcher's romfs).
#---------------------------------------------------------------------------------
.SUFFIXES:
ifeq ($(strip $(DEVKITPRO)),)
.PHONY: all
all:
@echo "DEVKITPRO not set — bootstrap builds with the launcher only."
@false
else
TOPDIR ?= $(CURDIR)
include $(DEVKITPRO)/libnx/switch_rules
TARGET := ota-bootstrap
BUILD := build
SOURCES := .
INCLUDES := include
ROMFS :=
APP_TITLE := gen1recomp OTA
APP_AUTHOR := bryanthaboi, port by andrewqsantos
APP_VERSION := 0.0.0
ARCH := -march=armv8-a+crc+crypto -mtune=cortex-a57 -mtp=soft -fPIE
CFLAGS := -g -Wall -O2 -ffunction-sections $(ARCH) $(DEFINES)
CFLAGS += $(INCLUDE) -D__SWITCH__
ASFLAGS := -g $(ARCH)
LDFLAGS = -specs=$(DEVKITPRO)/libnx/switch.specs -g $(ARCH) -Wl,-Map,$(notdir $*.map)
LIBS := -lnx
LIBDIRS := $(LIBNX)
ifneq ($(BUILD),$(notdir $(CURDIR)))
export OUTPUT := $(CURDIR)/$(TARGET)
export TOPDIR := $(CURDIR)
export VPATH := $(foreach dir,$(SOURCES),$(CURDIR)/$(dir))
export DEPSDIR := $(CURDIR)/$(BUILD)
CFILES := $(foreach dir,$(SOURCES),$(notdir $(wildcard $(dir)/*.c)))
export LD := $(CC)
export OFILES := $(CFILES:.c=.o)
export INCLUDE := $(foreach dir,$(INCLUDES),-I$(CURDIR)/$(dir)) \
$(foreach dir,$(LIBDIRS),-I$(dir)/include) \
-I$(CURDIR)/$(BUILD)
export LIBPATHS := $(foreach dir,$(LIBDIRS),-L$(dir)/lib)
export APP_ICON := $(LIBNX)/default_icon.jpg
export NROFLAGS += --icon=$(APP_ICON)
export NROFLAGS += --nacp=$(CURDIR)/$(TARGET).nacp
.PHONY: $(BUILD) clean all
all: $(BUILD)
$(BUILD):
@[ -d $@ ] || mkdir -p $@
@$(MAKE) --no-print-directory -C $(BUILD) -f $(CURDIR)/Makefile
clean:
@echo clean ...
@rm -fr $(BUILD) $(TARGET).nro $(TARGET).nacp $(TARGET).elf
else
.PHONY: all
DEPENDS := $(OFILES:.o=.d)
all: $(OUTPUT).nro
$(OUTPUT).nro: $(OUTPUT).elf $(OUTPUT).nacp
$(OUTPUT).elf: $(OFILES)
-include $(DEPENDS)
endif
endif
+36
View File
@@ -0,0 +1,36 @@
/*
* Tiny one-shot helper: swap a staged launcher NRO into place, then load the game.
* The main OTA launcher chainloads here because a running NRO cannot replace itself
* on sdmc/FAT.
*/
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#if defined(__SWITCH__)
#include <switch.h>
#endif
#define INSTALL_DIR "sdmc:/switch/gen1recomp"
#define STAGED INSTALL_DIR "/gen1recomp.nro.staged"
#define LAUNCHER INSTALL_DIR "/gen1recomp.nro"
#define GAME INSTALL_DIR "/gen1recomp-game.nro"
int main(int argc, char **argv) {
(void)argc;
(void)argv;
#if defined(__SWITCH__)
remove(LAUNCHER);
if (rename(STAGED, LAUNCHER) != 0) {
return 1;
}
Result rc = envSetNextLoad(GAME, GAME);
if (R_FAILED(rc)) return 1;
return 0;
#else
fprintf(stderr, "ota-bootstrap: host stub (would rename %s -> %s, load %s)\n", STAGED, LAUNCHER,
GAME);
return 0;
#endif
}
+193
View File
@@ -0,0 +1,193 @@
#---------------------------------------------------------------------------------
# gen1recomp Switch native OTA launcher
# Requires: DEVKITPRO + switch-curl, switch-mbedtls, switch-zlib, switch-zziplib
# Host protocol tests: make host-test (no DEVKITPRO needed)
#---------------------------------------------------------------------------------
.SUFFIXES:
#---------------------------------------------------------------------------------
# Host-only path when DEVKITPRO is unset
#---------------------------------------------------------------------------------
ifeq ($(strip $(DEVKITPRO)),)
.PHONY: host-test clean-host
host-test:
@mkdir -p build-host
cc -std=c11 -Wall -Wextra -Iinclude -o build-host/test_ota_protocol \
src/ota_protocol.c host/test_ota_protocol.c
./build-host/test_ota_protocol
clean-host:
rm -rf build-host
%:
@echo "DEVKITPRO not set — only 'make host-test' is available."
@echo "Run: bash scripts/switch/install_devkitpro_deps.sh"
@false
else
#---------------------------------------------------------------------------------
TOPDIR ?= $(CURDIR)
include $(DEVKITPRO)/libnx/switch_rules
#---------------------------------------------------------------------------------
TARGET := gen1recomp
BUILD := build
SOURCES := src
DATA := data
INCLUDES := include
ROMFS := romfs
APP_TITLE := gen1recomp
APP_AUTHOR := bryanthaboi, port by andrewqsantos
# Overridable: scripts/switch/build_ota_launcher.sh passes release X.Y.Z
APP_VERSION ?= 0.0.0
# Prefer project Switch icon if present
ICON := $(TOPDIR)/../assets/icon.jpg
LOGO_RGBA_SRC := $(TOPDIR)/../assets/logo.rgba
LOGO_ROMFS := $(CURDIR)/$(ROMFS)/logo.rgba
#---------------------------------------------------------------------------------
ARCH := -march=armv8-a+crc+crypto -mtune=cortex-a57 -mtp=soft -fPIE
CFLAGS := -g -Wall -O2 -ffunction-sections $(ARCH) $(DEFINES)
CFLAGS += $(INCLUDE) -D__SWITCH__
CXXFLAGS := $(CFLAGS) -fno-rtti -fno-exceptions
ASFLAGS := -g $(ARCH)
LDFLAGS = -specs=$(DEVKITPRO)/libnx/switch.specs -g $(ARCH) -Wl,-Map,$(notdir $*.map)
LIBS := -lcurl -lzzip -lmbedtls -lmbedx509 -lmbedcrypto -lz -lnx
LIBDIRS := $(PORTLIBS) $(LIBNX)
#---------------------------------------------------------------------------------
ifneq ($(BUILD),$(notdir $(CURDIR)))
#---------------------------------------------------------------------------------
export OUTPUT := $(CURDIR)/$(TARGET)
export TOPDIR := $(CURDIR)
export VPATH := $(foreach dir,$(SOURCES),$(CURDIR)/$(dir)) \
$(foreach dir,$(DATA),$(CURDIR)/$(dir))
export DEPSDIR := $(CURDIR)/$(BUILD)
CFILES := $(foreach dir,$(SOURCES),$(notdir $(wildcard $(dir)/*.c)))
CPPFILES := $(foreach dir,$(SOURCES),$(notdir $(wildcard $(dir)/*.cpp)))
SFILES := $(foreach dir,$(SOURCES),$(notdir $(wildcard $(dir)/*.s)))
BINFILES := $(foreach dir,$(DATA),$(notdir $(wildcard $(dir)/*.*)))
#---------------------------------------------------------------------------------
# use CXX for linking C++ projects, CC for standard C
#---------------------------------------------------------------------------------
ifeq ($(strip $(CPPFILES)),)
export LD := $(CC)
else
export LD := $(CXX)
endif
#---------------------------------------------------------------------------------
export OFILES_BIN := $(addsuffix .o,$(BINFILES))
export OFILES_SRC := $(CPPFILES:.cpp=.o) $(CFILES:.c=.o) $(SFILES:.s=.o)
export OFILES := $(OFILES_BIN) $(OFILES_SRC)
export INCLUDE := $(foreach dir,$(INCLUDES),-I$(CURDIR)/$(dir)) \
$(foreach dir,$(LIBDIRS),-I$(dir)/include) \
-I$(CURDIR)/$(BUILD)
export LIBPATHS := $(foreach dir,$(LIBDIRS),-L$(dir)/lib)
ifeq ($(strip $(ICON)),)
icons := $(wildcard *.jpg)
ifneq (,$(findstring $(TARGET).jpg,$(icons)))
export APP_ICON := $(TOPDIR)/$(TARGET).jpg
else
ifneq (,$(findstring icon.jpg,$(icons)))
export APP_ICON := $(TOPDIR)/icon.jpg
endif
endif
else
ifeq ($(wildcard $(ICON)),)
# fall back to libnx default if project icon missing
export APP_ICON := $(LIBNX)/default_icon.jpg
else
export APP_ICON := $(ICON)
endif
endif
ifeq ($(strip $(NO_ICON)),)
export NROFLAGS += --icon=$(APP_ICON)
endif
ifeq ($(strip $(NO_NACP)),)
export NROFLAGS += --nacp=$(CURDIR)/$(TARGET).nacp
endif
ifneq ($(ROMFS),)
export NROFLAGS += --romfsdir=$(CURDIR)/$(ROMFS)
endif
BOOTSTRAP_DIR := $(TOPDIR)/../ota-bootstrap
BOOTSTRAP_ROMFS := $(CURDIR)/$(ROMFS)/ota-bootstrap.nro
.PHONY: $(BUILD) clean all host-test sync-romfs bootstrap-romfs
#---------------------------------------------------------------------------------
all: sync-romfs $(BUILD)
CACERT_URL := https://curl.se/ca/cacert.pem
CACERT_ROMFS := $(CURDIR)/$(ROMFS)/cacert.pem
bootstrap-romfs:
@$(MAKE) --no-print-directory -C $(BOOTSTRAP_DIR) all
@mkdir -p $(CURDIR)/$(ROMFS)
@cp -f $(BOOTSTRAP_DIR)/ota-bootstrap.nro $(BOOTSTRAP_ROMFS)
sync-romfs: bootstrap-romfs
@mkdir -p $(CURDIR)/$(ROMFS)
@[ -f "$(LOGO_RGBA_SRC)" ] || (echo "missing $(LOGO_RGBA_SRC) — run: python3 scripts/switch/bake_ota_logo.py" && exit 1)
@cp -f "$(LOGO_RGBA_SRC)" "$(LOGO_ROMFS)"
@if ! curl -sfL --time-cond $(CACERT_ROMFS) -o $(CACERT_ROMFS) $(CACERT_URL); then \
[ -f $(CACERT_ROMFS) ] || (echo "sync-romfs: failed to fetch cacert.pem" && exit 1); \
fi
$(BUILD): sync-romfs
@[ -d $@ ] || mkdir -p $@
@$(MAKE) --no-print-directory -C $(BUILD) -f $(CURDIR)/Makefile
#---------------------------------------------------------------------------------
clean:
@echo clean ...
@$(MAKE) --no-print-directory -C $(BOOTSTRAP_DIR) clean || true
@rm -fr $(BUILD) $(TARGET).nro $(TARGET).nacp $(TARGET).elf $(TARGET).lst build-host
@rm -f $(CURDIR)/$(ROMFS)/logo.rgba $(CURDIR)/$(ROMFS)/logo.png
host-test:
@mkdir -p build-host
cc -std=c11 -Wall -Wextra -Iinclude -o build-host/test_ota_protocol \
src/ota_protocol.c host/test_ota_protocol.c
./build-host/test_ota_protocol
#---------------------------------------------------------------------------------
else
.PHONY: all
DEPENDS := $(OFILES:.o=.d)
#---------------------------------------------------------------------------------
all: $(OUTPUT).nro
$(OUTPUT).nro: $(OUTPUT).elf $(OUTPUT).nacp
$(OUTPUT).elf: $(OFILES)
-include $(DEPENDS)
#---------------------------------------------------------------------------------
endif
#---------------------------------------------------------------------------------
endif
+89
View File
@@ -0,0 +1,89 @@
# Native Switch OTA launcher
In-console updates for Gen1Recomp on Nintendo Switch. This NRO is the **hbmenu
entry** (`gen1recomp.nro`). It checks GitHub Releases quietly (no UI when you
are already up to date or offline). Only if a newer release exists does it show
a **launcher-style screen** (black + RGB rail + project logo + flat A/B
buttons), download the same `gen1recomp-*-switch.zip` used for install, verify
SHA-256 from `sha256sums.txt`, replace **both** `gen1recomp-game.nro` and
`gen1recomp.nro` (matching NACP version for hbmenu/Sphaira), then load the game
with `envSetNextLoad`.
The LÖVE self-updater (`src/update/Check.lua`) stays **disabled** on NX.
Wire format (also in Lua): `src/update/SwitchOta.lua`.
NACP icon: `ports/switch/assets/icon.jpg`.
## Layout on microSD
```text
sdmc:/switch/gen1recomp/gen1recomp.nro <- this launcher
sdmc:/switch/gen1recomp/gen1recomp-game.nro <- fused LÖVE game
sdmc:/switch/gen1recomp/version.txt <- installed X.Y.Z
sdmc:/switch/gen1recomp/pokemon-love2d/ <- saves (never touched by OTA)
```
## Host tests (no DEVKITPRO)
```bash
cd ports/switch/ota-launcher
make host-test
```
## Switch build (DEVKITPRO)
Needs `DEVKITPRO` with packages roughly:
```bash
(dkp-)pacman -S --needed switch-curl switch-mbedtls switch-zlib switch-zziplib
# or: bash scripts/switch/install_devkitpro_deps.sh
```
```bash
export DEVKITPRO=/opt/devkitpro # typical
cd ports/switch/ota-launcher
make
# -> gen1recomp.nro
```
Or from repo root (as part of `--fused`):
```bash
scripts/build_switch.sh --fetch --fused --version X.Y.Z
```
Standalone launcher build:
```bash
scripts/switch/build_ota_launcher.sh
```
Docker fallback uses the same pin as fused builds (`scripts/switch/dkp-docker.image`).
## OTA logo asset
The launcher draws a pre-scaled logo from `romfs:/logo.rgba` (no PNG decoder in
the NRO). The baked blob lives at `../assets/logo.rgba` and is copied into romfs at
build time. After changing `assets/logo/logo.png`, regenerate:
```bash
python3 scripts/switch/bake_ota_logo.py
```
Requires Pillow, or on macOS uses `sips` when Pillow is not installed.
## Packaging
`scripts/switch/pack_sd_zip.sh GAME_NRO VERSION OUT_ZIP LAUNCHER_NRO` writes both
NROs into the SD zip. That zip is also what OTA downloads.
Manifest: `scripts/switch/ota_launcher.manifest`.
## Status / known gaps
- Zip extraction uses `switch-zziplib` (`ota_unzip.c`) on device.
- OTA replaces game + launcher from the install zip (NACP versions stay aligned).
The running launcher cannot overwrite its own NRO on sdmc/FAT; a tiny
`ota-bootstrap.nro` (embedded in romfs) chainloads once to swap the staged
launcher, then loads the game.
- HTTPS uses Mozilla CA bundle in romfs (`cacert.pem`, fetched at build time). `ota_net_init()` mounts romfs before the quiet release check.
- Sphaira HOME forwarders cache metadata until reinstalled (see docs/switch-install.md).
- Release runner: `switch-dev` + (`install_devkitpro_deps.sh` **or** Docker)
@@ -0,0 +1,144 @@
/* Host-only unit tests for ota_protocol.c — no libnx. */
#include "ota_protocol.h"
#include <stdio.h>
#include <string.h>
static int g_fail = 0;
static void expect(int cond, const char *msg) {
if (!cond) {
fprintf(stderr, "FAIL: %s\n", msg);
g_fail++;
} else {
printf("PASS: %s\n", msg);
}
}
int main(void) {
expect(ota_compare_semver("1.2.0", "1.1.0") == 1, "semver newer");
expect(ota_compare_semver("1.1.0", "1.1.0") == 0, "semver equal");
expect(ota_compare_semver("1.0.0", "1.1.0") == -1, "semver older");
expect(ota_is_ota_asset_name("gen1recomp-1.5.0-switch.zip"), "ota asset name");
expect(!ota_is_ota_asset_name("gen1recomp-1.5.0-switch-ota.zip"), "reject legacy ota zip name");
expect(!ota_is_ota_asset_name("gen1recomp-1.5.0.love"), "reject love payload");
const char *json =
"{"
"\"tag_name\":\"v1.5.0\","
"\"assets\":["
"{\"name\":\"gen1recomp-1.5.0-switch.zip\","
"\"browser_download_url\":\"https://example/switch.zip\"},"
"{\"name\":\"sha256sums.txt\",\"browser_download_url\":\"https://example/sums\"}"
"]"
"}";
ota_release_t rel;
expect(ota_parse_release(json, &rel) == 1, "parse release");
expect(strcmp(rel.version, "1.5.0") == 0, "release version");
expect(strcmp(rel.asset_name, "gen1recomp-1.5.0-switch.zip") == 0, "release asset");
ota_decision_t d;
ota_decide_update("1.4.0", &rel, &d);
expect(strcmp(d.status, "available") == 0, "decide available");
ota_decide_update("1.5.0", &rel, &d);
expect(strcmp(d.status, "uptodate") == 0, "decide uptodate");
ota_release_t bad;
expect(ota_parse_release("{\"tag_name\":\"v1.5.0\",\"assets\":[]}", &bad) == 0,
"missing ota asset");
expect(strcmp(bad.reason, "missing_ota_asset") == 0, "missing_ota_asset reason");
/* GitHub releases/latest shape: release-level name + fat uploader before browser_download_url. */
const char *github_json =
"{"
"\"tag_name\":\"v0.1.70\","
"\"name\":\"0.1.70\","
"\"assets\":["
"{"
"\"url\":\"https://api.github.com/repos/bryanthaboi/gen1recomp/releases/assets/502823880\","
"\"id\":502823880,"
"\"name\":\"gen1recomp-0.1.70-switch.zip\","
"\"label\":\"\","
"\"uploader\":{"
"\"login\":\"github-actions[bot]\","
"\"id\":41898282,"
"\"node_id\":\"MDM6Qm90NDE4OTgyODI=\","
"\"avatar_url\":\"https://avatars.githubusercontent.com/in/15368?v=4\","
"\"gravatar_id\":\"\","
"\"url\":\"https://api.github.com/users/github-actions%5Bbot%5D\","
"\"html_url\":\"https://github.com/apps/github-actions\","
"\"followers_url\":\"https://api.github.com/users/github-actions%5Bbot%5D/followers\","
"\"following_url\":\"https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}\","
"\"gists_url\":\"https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}\","
"\"starred_url\":\"https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}\","
"\"subscriptions_url\":\"https://api.github.com/users/github-actions%5Bbot%5D/subscriptions\","
"\"organizations_url\":\"https://api.github.com/users/github-actions%5Bbot%5D/orgs\","
"\"repos_url\":\"https://api.github.com/users/github-actions%5Bbot%5D/repos\","
"\"events_url\":\"https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}\","
"\"received_events_url\":\"https://api.github.com/users/github-actions%5Bbot%5D/received_events\","
"\"type\":\"Bot\","
"\"user_view_type\":\"public\","
"\"site_admin\":false"
"},"
"\"content_type\":\"application/zip\","
"\"state\":\"uploaded\","
"\"size\":9000573,"
"\"browser_download_url\":\"https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.70/gen1recomp-0.1.70-switch.zip\""
"}"
"]"
"}";
ota_release_t gh;
expect(ota_parse_release(github_json, &gh) == 1, "parse github-shaped release");
expect(strcmp(gh.version, "0.1.70") == 0, "github release version");
expect(strcmp(gh.asset_name, "gen1recomp-0.1.70-switch.zip") == 0, "github release asset");
expect(strcmp(gh.download_url,
"https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.70/gen1recomp-0.1.70-switch.zip") ==
0,
"github release download url");
ota_decide_update("0.1.69", &gh, &d);
expect(strcmp(d.status, "available") == 0, "github release decide 0.1.69->0.1.70");
const char *sums = "abc123 gen1recomp-1.5.0-switch.zip\n";
ota_verify_t v;
ota_verify_sha256("gen1recomp-1.5.0-switch.zip", "abc123", sums, &v);
expect(v.ok == 1, "sha ok");
ota_verify_sha256("gen1recomp-1.5.0-switch.zip", "deadbeef", sums, &v);
expect(v.ok == 0 && strcmp(v.reason, "hash_mismatch") == 0, "sha mismatch");
ota_verify_sha256("gen1recomp-1.5.0-switch.zip", "abc123", "", &v);
expect(v.ok == 0 && strcmp(v.reason, "sum_not_found") == 0, "sha missing sum rejected");
ota_apply_plan_t plan;
ota_plan_atomic_apply("switch/gen1recomp", "/tmp/x", &plan);
expect(strstr(plan.steps[0], "copy_to_part:") != NULL, "plan copy");
expect(strstr(plan.steps[1], "rename:") != NULL, "plan rename");
expect(strstr(plan.steps[2], "copy_to_part:launcher") != NULL, "plan launcher copy");
expect(strstr(plan.steps[3], "rename:") != NULL, "plan launcher rename");
expect(strstr(plan.steps[4], "env_set_next_load:") != NULL, "plan handoff");
expect(strstr(plan.preserve, "pokemon-love2d") != NULL, "preserve saves");
expect(strstr(plan.forbidden_delete, "delete:") != NULL, "forbid delete saves");
expect(strstr(plan.forbidden_direct, "write_direct:") != NULL, "forbid direct write");
ota_offline_t off;
ota_offline_events_t ev = {0};
ev.user_skip = 1;
ota_offline_policy(1.0, &ev, &off);
expect(strcmp(off.action, "play_installed") == 0, "skip plays installed");
ev.user_skip = 0;
ev.network_ok = 0;
ota_offline_policy(1.0, &ev, &off);
expect(strcmp(off.action, "play_installed") == 0, "offline plays installed");
ev.network_ok = 1;
ota_offline_policy(6.0, &ev, &off);
expect(strcmp(off.reason, "timeout") == 0, "timeout 6s");
ota_offline_policy(2.0, &ev, &off);
expect(strcmp(off.action, "keep_checking") == 0, "still checking");
expect(OTA_CHECK_TIMEOUT_SEC == 6, "timeout constant 6");
if (g_fail) {
fprintf(stderr, "%d failure(s)\n", g_fail);
return 1;
}
printf("all ota_protocol host tests passed\n");
return 0;
}
@@ -0,0 +1,38 @@
#ifndef GEN1_OTA_FS_H
#define GEN1_OTA_FS_H
#include "ota_protocol.h"
#ifdef __cplusplus
extern "C" {
#endif
/* Read installed game version from sibling version.txt or NRO nacp-less stamp file.
* Expects switch/gen1recomp/version.txt containing X.Y.Z. Returns 0 on success. */
int ota_fs_read_installed_version(const char *install_dir, char *out, size_t out_len);
/* SHA-256 hex (lowercase) of file. Returns 0 on success. */
int ota_fs_sha256_file(const char *path, char *out_hex, size_t out_len);
/* Apply verified temp payload (extracted game NRO) using atomic plan. Returns 0 on success. */
int ota_fs_atomic_replace_game(const char *install_dir, const char *verified_game_nro,
char *err, size_t err_len);
/* Atomically replace any named NRO under install_dir (copy→.part→rename). */
int ota_fs_atomic_replace_nro(const char *install_dir, const char *nro_name,
const char *verified_nro, char *err, size_t err_len);
/* Stage a new launcher + copy romfs bootstrap; chainload bootstrap_out next.
* The running launcher cannot replace its own NRO on sdmc/FAT. */
int ota_fs_stage_launcher_bootstrap(const char *install_dir, const char *verified_launcher,
char *bootstrap_out, size_t bootstrap_out_len, char *err,
size_t err_len);
/* Hand off to game NRO via envSetNextLoad (Switch) or no-op stub (host). */
int ota_fs_handoff_to_game(const char *game_nro_path, char *err, size_t err_len);
#ifdef __cplusplus
}
#endif
#endif
@@ -0,0 +1,29 @@
#ifndef GEN1_OTA_NET_H
#define GEN1_OTA_NET_H
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
/* fraction is 0..1 when Content-Length is known, else -1 for indeterminate. */
typedef void (*ota_net_progress_fn)(void *userdata, double fraction);
/* Mount romfs (CA bundle) and init libcurl. Call before any HTTPS download. */
int ota_net_init(void);
void ota_net_shutdown(void);
/* Download URL into memory buffer (caller frees *out). Returns 0 on success. */
int ota_net_download_buffer(const char *url, long timeout_ms, char **out, size_t *out_len,
char *err, size_t err_len);
/* Download URL to a filesystem path. progress may be NULL. Returns 0 on success. */
int ota_net_download_file(const char *url, const char *path, long timeout_ms, char *err,
size_t err_len, ota_net_progress_fn progress, void *progress_ud);
#ifdef __cplusplus
}
#endif
#endif
@@ -0,0 +1,95 @@
#ifndef GEN1_OTA_PROTOCOL_H
#define GEN1_OTA_PROTOCOL_H
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
#define OTA_CHECK_TIMEOUT_SEC 6
#define OTA_GAME_NRO_NAME "gen1recomp-game.nro"
#define OTA_LAUNCHER_NRO_NAME "gen1recomp.nro"
#define OTA_SAVE_DIR_NAME "pokemon-love2d"
#define OTA_INSTALL_DIR "switch/gen1recomp"
#define OTA_LAUNCHER_STAGED_SUFFIX ".staged"
#define OTA_BOOTSTRAP_ROMFS "romfs:/ota-bootstrap.nro"
#define OTA_BOOTSTRAP_SD_NAME "ota-bootstrap.nro"
#define OTA_RELEASES_API \
"https://api.github.com/repos/bryanthaboi/gen1recomp/releases/latest"
/* Mirrors src/update/SwitchOta.lua — keep semantics in lockstep. */
int ota_compare_semver(const char *a, const char *b);
int ota_is_ota_asset_name(const char *name);
int ota_version_from_ota_asset(const char *name, char *out, size_t out_len);
typedef struct {
int ok; /* 1 on success */
char reason[64];
char tag[64];
char version[32];
char asset_name[128];
char download_url[512];
} ota_release_t;
int ota_parse_release(const char *json_text, ota_release_t *out);
typedef struct {
char status[32]; /* uptodate | available | error */
char reason[64];
char version[32];
char asset_name[128];
char download_url[512];
} ota_decision_t;
void ota_decide_update(const char *installed_version, const ota_release_t *release,
ota_decision_t *out);
/* sums: newline-separated sha256sums.txt body */
int ota_lookup_sum(const char *sums_text, const char *asset_name, char *out_hex,
size_t out_len);
typedef struct {
int ok;
char reason[64];
} ota_verify_t;
void ota_verify_sha256(const char *asset_name, const char *actual_hex,
const char *sums_text, ota_verify_t *out);
typedef struct {
char steps[5][520]; /* human-readable ops */
char preserve[256];
char forbidden_delete[256];
char forbidden_direct[256];
char part_path[256];
char game_nro[240];
char launcher_nro[240];
char launcher_part[256];
char next_load[240];
} ota_apply_plan_t;
void ota_plan_atomic_apply(const char *install_dir, const char *verified_temp,
ota_apply_plan_t *out);
typedef struct {
char action[32]; /* play_installed | keep_checking */
char reason[64];
char message[128];
} ota_offline_t;
typedef struct {
int user_skip;
int network_ok; /* 0 = offline/fail, 1 = ok, -1 = unknown */
int api_error;
} ota_offline_events_t;
void ota_offline_policy(double elapsed_sec, const ota_offline_events_t *events,
ota_offline_t *out);
#ifdef __cplusplus
}
#endif
#endif /* GEN1_OTA_PROTOCOL_H */
@@ -0,0 +1,37 @@
#ifndef GEN1_OTA_UI_H
#define GEN1_OTA_UI_H
#ifdef __cplusplus
extern "C" {
#endif
/* Framebuffer UI matching the in-game launcher look (black + RGB rail +
* flat semantic buttons). Silent by default — only call when user-facing. */
/* Returns 1 = update, 0 = skip / play installed. */
int ota_ui_prompt_update(const char *installed, const char *latest);
/* Status screen (download / install). Redraws each call. */
void ota_ui_show_status(const char *title, const char *detail);
/* Progress 0..1; detail optional. */
void ota_ui_show_progress(const char *title, const char *detail, float progress01);
/* Error / info + wait for B. */
void ota_ui_alert(const char *title, const char *line1, const char *line2);
/* User-friendly error with optional technical detail and installed-version footer. */
void ota_ui_alert_error(const char *title, const char *friendly, const char *technical,
const char *installed_version);
/* Missing game install screen; wait for +. */
void ota_ui_missing_game(void);
/* Tear down framebuffer if active. Safe to call when UI never opened. */
void ota_ui_shutdown(void);
#ifdef __cplusplus
}
#endif
#endif
@@ -0,0 +1,20 @@
#ifndef GEN1_OTA_UNZIP_H
#define GEN1_OTA_UNZIP_H
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
/* Extract member_name from zip_path into dest_path.
* Tries exact member_name, then common prefixes (switch/gen1recomp/).
* Returns 0 on success. */
int ota_unzip_extract_file(const char *zip_path, const char *member_name, const char *dest_path,
char *err, size_t err_len);
#ifdef __cplusplus
}
#endif
#endif
+281
View File
@@ -0,0 +1,281 @@
/*
* gen1recomp Switch OTA launcher
*
* Quiet by default: checks GitHub Releases with no UI. Only shows the
* branded launcher-style screen when an update is available. Downloads the
* same SD zip (gen1recomp-*-switch.zip), verifies SHA-256, replaces game +
* launcher NROs, then envSetNextLoad. Never touches pokemon-love2d/.
* LÖVE self-updater stays off on NX.
*/
#include "ota_fs.h"
#include "ota_net.h"
#include "ota_protocol.h"
#include "ota_ui.h"
#include "ota_unzip.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#if defined(__SWITCH__)
#include <sys/stat.h>
#include <switch.h>
#include <unistd.h>
#else
#include <sys/stat.h>
#include <unistd.h>
#endif
#define SD_INSTALL_DIR "sdmc:/switch/gen1recomp"
#define CHECK_TIMEOUT_MS (OTA_CHECK_TIMEOUT_SEC * 1000L)
#define GAME_MEMBER_IN_ZIP "switch/gen1recomp/" OTA_GAME_NRO_NAME
#define LAUNCHER_MEMBER_IN_ZIP "switch/gen1recomp/" OTA_LAUNCHER_NRO_NAME
typedef struct {
const char *detail;
float base;
float span;
} download_progress_ctx_t;
static void on_download_progress(void *userdata, double fraction) {
download_progress_ctx_t *ctx = (download_progress_ctx_t *)userdata;
if (!ctx) return;
float p = ctx->base;
if (fraction >= 0.0) p += ctx->span * (float)fraction;
else p += ctx->span * 0.1f;
if (p > ctx->base + ctx->span) p = ctx->base + ctx->span;
ota_ui_show_progress("Step 1/3: Downloading...", ctx->detail, p);
}
static void show_update_error(const char *title, const char *friendly, const char *technical,
const char *installed) {
ota_ui_alert_error(title, friendly, technical, installed);
}
static int run_update_flow(const char *install_dir) {
char installed[64] = "0.0.0";
(void)ota_fs_read_installed_version(install_dir, installed, sizeof(installed));
char *json = NULL;
size_t json_len = 0;
char err[256];
err[0] = '\0';
if (ota_net_download_buffer(OTA_RELEASES_API, CHECK_TIMEOUT_MS, &json, &json_len, err,
sizeof(err)) != 0) {
free(json);
return 0; /* offline / timeout — play installed, no UI */
}
ota_release_t rel;
if (!ota_parse_release(json, &rel)) {
free(json);
return 0;
}
free(json);
ota_decision_t dec;
ota_decide_update(installed, &rel, &dec);
if (strcmp(dec.status, "uptodate") == 0 || strcmp(dec.status, "error") == 0) {
return 0;
}
if (!ota_ui_prompt_update(installed, dec.version)) {
return 0;
}
char updates_dir[192];
char zip_path[256];
char sums_path[256];
snprintf(updates_dir, sizeof(updates_dir), "%s/updates", install_dir);
snprintf(zip_path, sizeof(zip_path), "%s/updates/%s", install_dir, dec.asset_name);
snprintf(sums_path, sizeof(sums_path), "%s/updates/sha256sums.txt", install_dir);
#if defined(__SWITCH__)
mkdir(updates_dir, 0755);
#endif
download_progress_ctx_t dl_ctx = {"This may take a minute.", 0.f, 0.5f};
ota_ui_show_progress("Step 1/3: Downloading...", dl_ctx.detail, dl_ctx.base);
if (ota_net_download_file(dec.download_url, zip_path, 180000L, err, sizeof(err),
on_download_progress, &dl_ctx) != 0) {
show_update_error("Download failed",
"Could not download the update. Check Wi-Fi and try again.", err, installed);
return 0;
}
ota_ui_show_progress("Step 2/3: Verifying...", "Checking file integrity.", 0.55f);
char sums_url[512];
snprintf(sums_url, sizeof(sums_url),
"https://github.com/bryanthaboi/gen1recomp/releases/download/%s/sha256sums.txt",
rel.tag);
if (ota_net_download_file(sums_url, sums_path, CHECK_TIMEOUT_MS, err, sizeof(err), NULL,
NULL) != 0) {
remove(zip_path);
show_update_error("Could not verify", "Downloaded file could not be checked.", err, installed);
return 0;
}
FILE *sf = fopen(sums_path, "rb");
if (!sf) {
remove(zip_path);
show_update_error("Could not verify", "Could not read checksum file.", "fopen sums failed",
installed);
return 0;
}
fseek(sf, 0, SEEK_END);
long slen = ftell(sf);
fseek(sf, 0, SEEK_SET);
char *sums = (char *)malloc((size_t)slen + 1);
if (!sums) {
fclose(sf);
remove(zip_path);
show_update_error("Could not verify", "Not enough memory to verify update.", "malloc failed",
installed);
return 0;
}
fread(sums, 1, (size_t)slen, sf);
sums[slen] = '\0';
fclose(sf);
ota_ui_show_progress("Step 2/3: Verifying...", "Computing checksum...", 0.65f);
char hex[96];
if (ota_fs_sha256_file(zip_path, hex, sizeof(hex)) != 0) {
free(sums);
remove(zip_path);
show_update_error("Could not verify", "Could not read downloaded update file.",
"sha256 file read failed", installed);
return 0;
}
ota_verify_t ver;
ota_verify_sha256(dec.asset_name, hex, sums, &ver);
free(sums);
if (!ver.ok) {
remove(zip_path);
show_update_error("Update check failed",
"Downloaded file did not match expected checksum.", ver.reason, installed);
return 0;
}
char extracted[256];
char extracted_launcher[256];
snprintf(extracted, sizeof(extracted), "%s/updates/gen1recomp-game.nro.verified", install_dir);
snprintf(extracted_launcher, sizeof(extracted_launcher), "%s/updates/gen1recomp.nro.verified",
install_dir);
#if defined(__SWITCH__)
ota_ui_show_progress("Step 3/3: Installing...", "Keeping your saves safe.", 0.8f);
if (ota_unzip_extract_file(zip_path, GAME_MEMBER_IN_ZIP, extracted, err, sizeof(err)) != 0) {
if (ota_unzip_extract_file(zip_path, OTA_GAME_NRO_NAME, extracted, err, sizeof(err)) != 0) {
remove(zip_path);
show_update_error("Could not extract",
"Update zip is missing game files or is corrupted.", err, installed);
return 0;
}
}
if (ota_fs_atomic_replace_game(install_dir, extracted, err, sizeof(err)) != 0) {
remove(extracted);
remove(zip_path);
show_update_error("Could not install",
"Could not replace game on microSD. Free up space and try again.", err,
installed);
return 0;
}
remove(extracted);
int have_launcher = 0;
if (ota_unzip_extract_file(zip_path, LAUNCHER_MEMBER_IN_ZIP, extracted_launcher, err,
sizeof(err)) == 0 ||
ota_unzip_extract_file(zip_path, OTA_LAUNCHER_NRO_NAME, extracted_launcher, err,
sizeof(err)) == 0) {
have_launcher = 1;
}
if (!have_launcher) {
remove(zip_path);
show_update_error("Could not extract",
"Update zip is missing launcher files.", err, installed);
return 0;
}
char bootstrap_path[256];
bootstrap_path[0] = '\0';
if (ota_fs_stage_launcher_bootstrap(install_dir, extracted_launcher, bootstrap_path,
sizeof(bootstrap_path), err, sizeof(err)) != 0) {
remove(extracted_launcher);
remove(zip_path);
show_update_error("Could not install",
"Game updated but launcher could not be staged. Reinstall from the SD zip.",
err, installed);
return 0;
}
remove(extracted_launcher);
char vpath[192];
snprintf(vpath, sizeof(vpath), "%s/version.txt", install_dir);
FILE *vf = fopen(vpath, "wb");
if (vf) {
fprintf(vf, "%s\n", dec.version);
fclose(vf);
}
ota_ui_show_progress("Ready", "Finishing update...", 1.0f);
svcSleepThread(600000000ULL);
remove(zip_path);
ota_ui_shutdown();
if (ota_fs_handoff_to_game(bootstrap_path, err, sizeof(err)) != 0) {
show_update_error("Could not install", "Launcher bootstrap failed.", err, installed);
return 0;
}
return 2; /* chainload bootstrap; main must not hand off to game */
#else
(void)extracted;
(void)extracted_launcher;
#endif
remove(zip_path);
ota_ui_shutdown();
return 0;
}
int main(int argc, char **argv) {
(void)argc;
(void)argv;
const char *install = SD_INSTALL_DIR;
int update_rc = 0;
#if defined(__SWITCH__)
socketInitializeDefault();
padConfigureInput(1, HidNpadStyleSet_NpadStandard);
if (ota_net_init() == 0) {
update_rc = run_update_flow(install);
ota_net_shutdown();
}
#else
update_rc = run_update_flow(install);
#endif
if (update_rc == 2) {
#if defined(__SWITCH__)
socketExit();
#endif
return 0;
}
char game[192];
snprintf(game, sizeof(game), "%s/%s", install, OTA_GAME_NRO_NAME);
char err[128];
err[0] = '\0';
if (ota_fs_handoff_to_game(game, err, sizeof(err)) != 0) {
ota_ui_missing_game();
}
ota_ui_shutdown();
#if defined(__SWITCH__)
socketExit();
#endif
return 0;
}
+177
View File
@@ -0,0 +1,177 @@
#include "ota_fs.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#if defined(__SWITCH__)
#include <switch.h>
#include <mbedtls/sha256.h>
#include <sys/stat.h>
#include <unistd.h>
#else
#include <unistd.h>
#endif
int ota_fs_read_installed_version(const char *install_dir, char *out, size_t out_len) {
if (!out || out_len == 0) return -1;
out[0] = '\0';
char path[256];
snprintf(path, sizeof(path), "%s/version.txt", install_dir ? install_dir : OTA_INSTALL_DIR);
FILE *fp = fopen(path, "rb");
if (!fp) return -1;
if (!fgets(out, (int)out_len, fp)) {
fclose(fp);
return -1;
}
fclose(fp);
/* trim */
size_t n = strlen(out);
while (n > 0 && (out[n - 1] == '\n' || out[n - 1] == '\r' || out[n - 1] == ' ')) {
out[--n] = '\0';
}
return n > 0 ? 0 : -1;
}
int ota_fs_sha256_file(const char *path, char *out_hex, size_t out_len) {
if (!path || !out_hex || out_len < 65) return -1;
out_hex[0] = '\0';
FILE *fp = fopen(path, "rb");
if (!fp) return -1;
#if defined(__SWITCH__)
mbedtls_sha256_context ctx;
mbedtls_sha256_init(&ctx);
mbedtls_sha256_starts(&ctx, 0);
unsigned char buf[8192];
size_t n;
while ((n = fread(buf, 1, sizeof(buf), fp)) > 0) {
mbedtls_sha256_update(&ctx, buf, n);
}
fclose(fp);
unsigned char digest[32];
mbedtls_sha256_finish(&ctx, digest);
mbedtls_sha256_free(&ctx);
for (int i = 0; i < 32; i++) snprintf(out_hex + i * 2, out_len - (size_t)(i * 2), "%02x", digest[i]);
return 0;
#else
/* Host: shell out to shasum/sha256sum for the host test path if needed.
* Protocol host tests do not call this; return not-implemented. */
fclose(fp);
(void)out_len;
return -1;
#endif
}
static int copy_file(const char *from, const char *to) {
FILE *in = fopen(from, "rb");
if (!in) return -1;
FILE *out = fopen(to, "wb");
if (!out) {
fclose(in);
return -1;
}
char buf[8192];
size_t n;
while ((n = fread(buf, 1, sizeof(buf), in)) > 0) {
if (fwrite(buf, 1, n, out) != n) {
fclose(in);
fclose(out);
return -1;
}
}
fclose(in);
fclose(out);
return 0;
}
int ota_fs_atomic_replace_game(const char *install_dir, const char *verified_game_nro,
char *err, size_t err_len) {
return ota_fs_atomic_replace_nro(install_dir, OTA_GAME_NRO_NAME, verified_game_nro, err,
err_len);
}
int ota_fs_atomic_replace_nro(const char *install_dir, const char *nro_name,
const char *verified_nro, char *err, size_t err_len) {
if (!nro_name || !*nro_name || !verified_nro || !*verified_nro) {
if (err && err_len) snprintf(err, err_len, "missing nro paths");
return -1;
}
char dest[240];
char part[256];
snprintf(dest, sizeof(dest), "%s/%s", install_dir ? install_dir : OTA_INSTALL_DIR, nro_name);
snprintf(part, sizeof(part), "%s.part", dest);
if (copy_file(verified_nro, part) != 0) {
if (err && err_len) snprintf(err, err_len, "copy to .part failed (%s)", nro_name);
return -1;
}
/* sdmc/FAT (Switch) and Windows do not replace an existing dest on rename. */
remove(dest);
if (rename(part, dest) != 0) {
if (err && err_len) snprintf(err, err_len, "rename .part -> %s failed", nro_name);
remove(part);
return -1;
}
return 0;
}
int ota_fs_stage_launcher_bootstrap(const char *install_dir, const char *verified_launcher,
char *bootstrap_out, size_t bootstrap_out_len, char *err,
size_t err_len) {
if (!verified_launcher || !*verified_launcher) {
if (err && err_len) snprintf(err, err_len, "missing staged launcher path");
return -1;
}
if (!bootstrap_out || bootstrap_out_len == 0) {
if (err && err_len) snprintf(err, err_len, "missing bootstrap output buffer");
return -1;
}
bootstrap_out[0] = '\0';
const char *base = install_dir ? install_dir : OTA_INSTALL_DIR;
char staged[256];
char bootstrap[256];
char updates[192];
snprintf(staged, sizeof(staged), "%s/%s%s", base, OTA_LAUNCHER_NRO_NAME,
OTA_LAUNCHER_STAGED_SUFFIX);
snprintf(updates, sizeof(updates), "%s/updates", base);
snprintf(bootstrap, sizeof(bootstrap), "%s/updates/%s", base, OTA_BOOTSTRAP_SD_NAME);
remove(staged);
if (copy_file(verified_launcher, staged) != 0) {
if (err && err_len) snprintf(err, err_len, "stage launcher failed");
return -1;
}
#if defined(__SWITCH__)
mkdir(updates, 0755);
#endif
if (copy_file(OTA_BOOTSTRAP_ROMFS, bootstrap) != 0) {
remove(staged);
if (err && err_len) snprintf(err, err_len, "extract bootstrap failed");
return -1;
}
snprintf(bootstrap_out, bootstrap_out_len, "%s", bootstrap);
return 0;
}
int ota_fs_handoff_to_game(const char *game_nro_path, char *err, size_t err_len) {
if (!game_nro_path || !*game_nro_path) {
if (err && err_len) snprintf(err, err_len, "missing game path");
return -1;
}
#if defined(__SWITCH__)
/* argv for next load: empty args string is fine */
Result rc = envSetNextLoad(game_nro_path, game_nro_path);
if (R_FAILED(rc)) {
if (err && err_len) snprintf(err, err_len, "envSetNextLoad failed: 0x%x", rc);
return -1;
}
return 0;
#else
if (err && err_len)
snprintf(err, err_len, "handoff stub (host): would envSetNextLoad %s", game_nro_path);
return 0; /* host stub succeeds for flow tests */
#endif
}
+185
View File
@@ -0,0 +1,185 @@
#include "ota_net.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#if defined(__SWITCH__)
#include <curl/curl.h>
#include <switch.h>
#endif
#if defined(__SWITCH__)
#define OTA_CA_BUNDLE "romfs:/cacert.pem"
static int g_net_ready = 0;
static int ota_ca_bundle_ready(void) {
FILE *f = fopen(OTA_CA_BUNDLE, "rb");
if (!f) return 0;
fclose(f);
return 1;
}
int ota_net_init(void) {
if (g_net_ready) return 0;
if (R_FAILED(romfsInit())) return -1;
if (!ota_ca_bundle_ready()) return -1;
if (curl_global_init(CURL_GLOBAL_DEFAULT) != CURLE_OK) return -1;
g_net_ready = 1;
return 0;
}
void ota_net_shutdown(void) {
if (!g_net_ready) return;
curl_global_cleanup();
romfsExit();
g_net_ready = 0;
}
static void ota_net_configure_tls(CURL *curl) {
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L);
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L);
curl_easy_setopt(curl, CURLOPT_CAINFO, OTA_CA_BUNDLE);
}
struct mem_buf {
char *data;
size_t len;
};
struct file_progress {
ota_net_progress_fn fn;
void *userdata;
};
static size_t write_mem(char *ptr, size_t size, size_t nmemb, void *userdata) {
struct mem_buf *m = (struct mem_buf *)userdata;
size_t n = size * nmemb;
char *p = (char *)realloc(m->data, m->len + n + 1);
if (!p) return 0;
m->data = p;
memcpy(m->data + m->len, ptr, n);
m->len += n;
m->data[m->len] = '\0';
return n;
}
static size_t write_file(char *ptr, size_t size, size_t nmemb, void *userdata) {
return fwrite(ptr, size, nmemb, (FILE *)userdata);
}
static int xfer_progress(void *clientp, curl_off_t dltotal, curl_off_t dlnow, curl_off_t ultotal,
curl_off_t ulnow) {
(void)ultotal;
(void)ulnow;
struct file_progress *fp = (struct file_progress *)clientp;
if (!fp || !fp->fn) return 0;
if (dltotal > 0) {
double frac = (double)dlnow / (double)dltotal;
if (frac < 0.0) frac = 0.0;
if (frac > 1.0) frac = 1.0;
fp->fn(fp->userdata, frac);
} else if (dlnow > 0) {
fp->fn(fp->userdata, -1.0);
}
return 0;
}
#else
int ota_net_init(void) { return 0; }
void ota_net_shutdown(void) {}
#endif
int ota_net_download_buffer(const char *url, long timeout_ms, char **out, size_t *out_len,
char *err, size_t err_len) {
if (out) *out = NULL;
if (out_len) *out_len = 0;
if (!url || !out) {
if (err && err_len) snprintf(err, err_len, "bad args");
return -1;
}
#if defined(__SWITCH__)
CURL *curl = curl_easy_init();
if (!curl) {
if (err && err_len) snprintf(err, err_len, "curl_easy_init failed");
return -1;
}
struct mem_buf mem = {0};
curl_easy_setopt(curl, CURLOPT_URL, url);
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
curl_easy_setopt(curl, CURLOPT_USERAGENT, "gen1recomp-switch-ota");
curl_easy_setopt(curl, CURLOPT_TIMEOUT_MS, timeout_ms);
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT_MS, timeout_ms);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_mem);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &mem);
ota_net_configure_tls(curl);
CURLcode rc = curl_easy_perform(curl);
curl_easy_cleanup(curl);
if (rc != CURLE_OK) {
free(mem.data);
if (err && err_len) snprintf(err, err_len, "%s", curl_easy_strerror(rc));
return -1;
}
*out = mem.data;
if (out_len) *out_len = mem.len;
return 0;
#else
(void)timeout_ms;
if (err && err_len)
snprintf(err, err_len, "ota_net_download_buffer only available on __SWITCH__");
return -1;
#endif
}
int ota_net_download_file(const char *url, const char *path, long timeout_ms, char *err,
size_t err_len, ota_net_progress_fn progress, void *progress_ud) {
if (!url || !path) {
if (err && err_len) snprintf(err, err_len, "bad args");
return -1;
}
#if defined(__SWITCH__)
FILE *fp = fopen(path, "wb");
if (!fp) {
if (err && err_len) snprintf(err, err_len, "fopen failed: %s", path);
return -1;
}
CURL *curl = curl_easy_init();
if (!curl) {
fclose(fp);
if (err && err_len) snprintf(err, err_len, "curl_easy_init failed");
return -1;
}
struct file_progress fp_cb = {progress, progress_ud};
curl_easy_setopt(curl, CURLOPT_URL, url);
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
curl_easy_setopt(curl, CURLOPT_USERAGENT, "gen1recomp-switch-ota");
curl_easy_setopt(curl, CURLOPT_TIMEOUT_MS, timeout_ms);
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT_MS, timeout_ms);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_file);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, fp);
ota_net_configure_tls(curl);
if (progress) {
curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L);
curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, xfer_progress);
curl_easy_setopt(curl, CURLOPT_XFERINFODATA, &fp_cb);
}
CURLcode rc = curl_easy_perform(curl);
curl_easy_cleanup(curl);
fclose(fp);
if (rc != CURLE_OK) {
if (err && err_len) snprintf(err, err_len, "%s", curl_easy_strerror(rc));
return -1;
}
if (progress) progress(progress_ud, 1.0);
return 0;
#else
(void)timeout_ms;
(void)progress;
(void)progress_ud;
if (err && err_len)
snprintf(err, err_len, "ota_net_download_file only available on __SWITCH__");
return -1;
#endif
}
@@ -0,0 +1,329 @@
#include "ota_protocol.h"
#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
typedef struct {
int major;
int minor;
int patch;
int ok;
} semver_t;
static void set_reason(char *buf, size_t n, const char *r) {
if (!buf || n == 0) return;
snprintf(buf, n, "%s", r ? r : "");
}
static int parse_semver(const char *s, semver_t *out) {
memset(out, 0, sizeof(*out));
if (!s || !*s) return 0;
if (s[0] == 'v' || s[0] == 'V') s++;
int maj = 0, min = 0, pat = 0;
char trail = 0;
if (sscanf(s, "%d.%d.%d%c", &maj, &min, &pat, &trail) != 3) return 0;
if (maj < 0 || min < 0 || pat < 0) return 0;
out->major = maj;
out->minor = min;
out->patch = pat;
out->ok = 1;
return 1;
}
int ota_compare_semver(const char *a, const char *b) {
semver_t pa, pb;
int oa = parse_semver(a, &pa);
int ob = parse_semver(b, &pb);
if (!oa && !ob) return 0;
if (!oa) return -1;
if (!ob) return 1;
if (pa.major != pb.major) return pa.major < pb.major ? -1 : 1;
if (pa.minor != pb.minor) return pa.minor < pb.minor ? -1 : 1;
if (pa.patch != pb.patch) return pa.patch < pb.patch ? -1 : 1;
return 0;
}
int ota_is_ota_asset_name(const char *name) {
int maj = 0, min = 0, pat = 0;
if (!name) return 0;
/* Unified SD/OTA asset: gen1recomp-X.Y.Z-switch.zip */
size_t len = strlen(name);
const char *suffix = "-switch.zip";
size_t slen = strlen(suffix);
if (len <= slen) return 0;
if (strcmp(name + len - slen, suffix) != 0) return 0;
if (strncmp(name, "gen1recomp-", 11) != 0) return 0;
if (sscanf(name + 11, "%d.%d.%d", &maj, &min, &pat) != 3) return 0;
char rebuilt[128];
snprintf(rebuilt, sizeof(rebuilt), "gen1recomp-%d.%d.%d-switch.zip", maj, min, pat);
return strcmp(name, rebuilt) == 0;
}
int ota_version_from_ota_asset(const char *name, char *out, size_t out_len) {
int maj = 0, min = 0, pat = 0;
if (!out || out_len == 0) return 0;
out[0] = '\0';
if (!ota_is_ota_asset_name(name)) return 0;
if (sscanf(name + 11, "%d.%d.%d", &maj, &min, &pat) != 3) return 0;
snprintf(out, out_len, "%d.%d.%d", maj, min, pat);
return 1;
}
static const char *find_json_string(const char *json, const char *key, char *out, size_t out_len) {
char pattern[128];
snprintf(pattern, sizeof(pattern), "\"%s\"", key);
const char *p = strstr(json, pattern);
if (!p) return NULL;
p = strchr(p + strlen(pattern), ':');
if (!p) return NULL;
p++;
while (*p && isspace((unsigned char)*p)) p++;
if (*p != '"') return NULL;
p++;
size_t i = 0;
while (*p && *p != '"' && i + 1 < out_len) {
if (*p == '\\' && p[1]) {
p++;
out[i++] = *p++;
continue;
}
out[i++] = *p++;
}
out[i] = '\0';
return out;
}
/* Opening { of the JSON object that contains pos (walk backward). */
static const char *find_json_object_start(const char *pos, const char *json_start) {
if (!pos || !json_start || pos < json_start) return NULL;
int depth = 0;
const char *p = pos;
while (p >= json_start) {
if (*p == '}') depth++;
else if (*p == '{') {
if (depth == 0) return p;
depth--;
}
p--;
}
return NULL;
}
/* Pointer just past the closing } of the object that starts at object_start. */
static const char *find_json_object_end(const char *object_start) {
if (!object_start || *object_start != '{') return NULL;
int depth = 1;
const char *p = object_start + 1;
while (*p) {
if (*p == '{') depth++;
else if (*p == '}') {
depth--;
if (depth == 0) return p + 1;
}
p++;
}
return NULL;
}
int ota_parse_release(const char *json_text, ota_release_t *out) {
memset(out, 0, sizeof(*out));
if (!json_text || !*json_text) {
set_reason(out->reason, sizeof(out->reason), "empty_json");
return 0;
}
if (!find_json_string(json_text, "tag_name", out->tag, sizeof(out->tag))) {
set_reason(out->reason, sizeof(out->reason), "missing_tag");
return 0;
}
semver_t sv;
if (!parse_semver(out->tag, &sv)) {
set_reason(out->reason, sizeof(out->reason), "bad_tag");
return 0;
}
snprintf(out->version, sizeof(out->version), "%d.%d.%d", sv.major, sv.minor, sv.patch);
/* Scan for OTA asset name then browser_download_url inside the same asset object. */
const char *cursor = json_text;
while ((cursor = strstr(cursor, "\"name\"")) != NULL) {
char name[128];
if (!find_json_string(cursor, "name", name, sizeof(name))) {
cursor += 6;
continue;
}
if (!ota_is_ota_asset_name(name)) {
cursor += 6;
continue;
}
const char *asset_start = find_json_object_start(cursor, json_text);
const char *asset_end =
asset_start ? find_json_object_end(asset_start) : NULL;
if (!asset_start || !asset_end || asset_end <= cursor) {
cursor += 6;
continue;
}
char url[512];
const char *u = NULL;
const char *scan = cursor;
while (scan < asset_end &&
(scan = strstr(scan, "\"browser_download_url\"")) != NULL && scan < asset_end) {
if (find_json_string(scan, "browser_download_url", url, sizeof(url))) {
u = url;
break;
}
scan += 21;
}
if (!u || !*u) {
cursor += 6;
continue;
}
snprintf(out->asset_name, sizeof(out->asset_name), "%s", name);
snprintf(out->download_url, sizeof(out->download_url), "%s", u);
out->ok = 1;
return 1;
}
set_reason(out->reason, sizeof(out->reason), "missing_ota_asset");
return 0;
}
void ota_decide_update(const char *installed_version, const ota_release_t *release,
ota_decision_t *out) {
memset(out, 0, sizeof(*out));
semver_t inst;
if (!parse_semver(installed_version, &inst)) {
snprintf(out->status, sizeof(out->status), "error");
set_reason(out->reason, sizeof(out->reason), "bad_installed_version");
return;
}
if (!release || !release->ok) {
snprintf(out->status, sizeof(out->status), "error");
set_reason(out->reason, sizeof(out->reason), "bad_release");
return;
}
if (ota_compare_semver(release->version, installed_version) <= 0) {
snprintf(out->status, sizeof(out->status), "uptodate");
snprintf(out->version, sizeof(out->version), "%s", installed_version);
return;
}
snprintf(out->status, sizeof(out->status), "available");
snprintf(out->version, sizeof(out->version), "%s", release->version);
snprintf(out->asset_name, sizeof(out->asset_name), "%s", release->asset_name);
snprintf(out->download_url, sizeof(out->download_url), "%s", release->download_url);
}
int ota_lookup_sum(const char *sums_text, const char *asset_name, char *out_hex, size_t out_len) {
if (out_hex && out_len) out_hex[0] = '\0';
if (!sums_text || !asset_name || !out_hex || out_len < 65) return 0;
const char *p = sums_text;
while (*p) {
char hex[96];
char name[256];
int n = 0;
if (sscanf(p, "%95s %255s%n", hex, name, &n) >= 2 && n > 0) {
const char *nm = name;
if (nm[0] == '*') nm++;
if (strncmp(nm, "./", 2) == 0) nm += 2;
if (strcmp(nm, asset_name) == 0) {
/* normalize hex to lowercase */
size_t i;
for (i = 0; hex[i] && i + 1 < out_len; i++)
out_hex[i] = (char)tolower((unsigned char)hex[i]);
out_hex[i] = '\0';
return 1;
}
p += n;
while (*p && *p != '\n') p++;
if (*p == '\n') p++;
continue;
}
while (*p && *p != '\n') p++;
if (*p == '\n') p++;
}
return 0;
}
void ota_verify_sha256(const char *asset_name, const char *actual_hex, const char *sums_text,
ota_verify_t *out) {
memset(out, 0, sizeof(*out));
if (!asset_name || !*asset_name) {
set_reason(out->reason, sizeof(out->reason), "bad_asset_name");
return;
}
if (!sums_text) {
set_reason(out->reason, sizeof(out->reason), "missing_sums");
return;
}
char expected[96];
if (!ota_lookup_sum(sums_text, asset_name, expected, sizeof(expected))) {
set_reason(out->reason, sizeof(out->reason), "sum_not_found");
return;
}
if (!actual_hex || !*actual_hex) {
set_reason(out->reason, sizeof(out->reason), "missing_actual_hash");
return;
}
char actual[96];
size_t i;
for (i = 0; actual_hex[i] && i + 1 < sizeof(actual); i++)
actual[i] = (char)tolower((unsigned char)actual_hex[i]);
actual[i] = '\0';
if (strcmp(actual, expected) != 0) {
set_reason(out->reason, sizeof(out->reason), "hash_mismatch");
return;
}
out->ok = 1;
}
void ota_plan_atomic_apply(const char *install_dir, const char *verified_temp,
ota_apply_plan_t *out) {
memset(out, 0, sizeof(*out));
if (!install_dir || !*install_dir) install_dir = OTA_INSTALL_DIR;
if (!verified_temp) verified_temp = "";
snprintf(out->game_nro, sizeof(out->game_nro), "%s/%s", install_dir, OTA_GAME_NRO_NAME);
snprintf(out->part_path, sizeof(out->part_path), "%s.part", out->game_nro);
snprintf(out->launcher_nro, sizeof(out->launcher_nro), "%s/%s", install_dir,
OTA_LAUNCHER_NRO_NAME);
snprintf(out->launcher_part, sizeof(out->launcher_part), "%s.part", out->launcher_nro);
snprintf(out->next_load, sizeof(out->next_load), "%s", out->game_nro);
/* verified_temp is the game NRO; launcher replace uses a sibling verified path */
snprintf(out->steps[0], sizeof(out->steps[0]), "copy_to_part:%s->%s", verified_temp,
out->part_path);
snprintf(out->steps[1], sizeof(out->steps[1]), "rename:%s->%s", out->part_path, out->game_nro);
snprintf(out->steps[2], sizeof(out->steps[2]), "copy_to_part:launcher->%s", out->launcher_part);
snprintf(out->steps[3], sizeof(out->steps[3]), "rename:%s->%s", out->launcher_part,
out->launcher_nro);
snprintf(out->steps[4], sizeof(out->steps[4]), "env_set_next_load:%s", out->game_nro);
snprintf(out->preserve, sizeof(out->preserve), "%s/%s", install_dir, OTA_SAVE_DIR_NAME);
snprintf(out->forbidden_delete, sizeof(out->forbidden_delete), "delete:%s/%s", install_dir,
OTA_SAVE_DIR_NAME);
snprintf(out->forbidden_direct, sizeof(out->forbidden_direct), "write_direct:%s", out->game_nro);
}
void ota_offline_policy(double elapsed_sec, const ota_offline_events_t *events, ota_offline_t *out) {
memset(out, 0, sizeof(*out));
ota_offline_events_t ev = {0, -1, 0};
if (events) ev = *events;
if (ev.user_skip) {
snprintf(out->action, sizeof(out->action), "play_installed");
set_reason(out->reason, sizeof(out->reason), "user_skip");
snprintf(out->message, sizeof(out->message), "update skipped");
return;
}
if (ev.api_error || ev.network_ok == 0) {
snprintf(out->action, sizeof(out->action), "play_installed");
set_reason(out->reason, sizeof(out->reason), "offline_or_error");
snprintf(out->message, sizeof(out->message),
"offline or update check failed — play installed version");
return;
}
if (elapsed_sec >= (double)OTA_CHECK_TIMEOUT_SEC) {
snprintf(out->action, sizeof(out->action), "play_installed");
set_reason(out->reason, sizeof(out->reason), "timeout");
snprintf(out->message, sizeof(out->message), "update check timed out after %ds",
OTA_CHECK_TIMEOUT_SEC);
return;
}
snprintf(out->action, sizeof(out->action), "keep_checking");
set_reason(out->reason, sizeof(out->reason), "in_flight");
}
+595
View File
@@ -0,0 +1,595 @@
/*
* Switch OTA UI — matches in-game launcher language (Theme.lua):
* black field, RGB version rail, flat yellow/white buttons, white ink.
* Quiet until called. Uses framebuffer + 8x8 font + optional romfs logo.
*/
#include "ota_ui.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#if defined(__SWITCH__)
#include <switch.h>
#include "../third_party/font8x8_basic.h"
#define OTA_LOGO_ROMFS "romfs:/logo.rgba"
#define FB_W 1280
#define FB_H 720
#define TITLE_Y 200
#define LINE_TITLE 44
#define LINE_BODY 36
#define LINE_SMALL 28
#define WRAP_MAX_PX 720
/* Theme.PAL (0-255) -> RGBA8 */
#define COL_BG RGBA8_MAXALPHA(0, 0, 0)
#define COL_INK RGBA8_MAXALPHA(255, 255, 255)
#define COL_DETAIL RGBA8_MAXALPHA(200, 200, 200)
#define COL_MUTED RGBA8_MAXALPHA(150, 150, 150)
#define COL_INVERSE RGBA8_MAXALPHA(0, 0, 0)
#define COL_YELLOW RGBA8_MAXALPHA(255, 214, 0)
#define COL_GREEN RGBA8_MAXALPHA(0, 255, 140)
#define COL_RAIL_R RGBA8_MAXALPHA(255, 60, 72)
#define COL_RAIL_B RGBA8_MAXALPHA(70, 150, 255)
#define COL_RAIL_G RGBA8_MAXALPHA(255, 203, 5)
#define COL_LINE RGBA8_MAXALPHA(90, 90, 90)
#define COL_RAISED RGBA8_MAXALPHA(20, 20, 20)
static int g_ready = 0;
static Framebuffer g_fb;
static PadState g_pad;
static u32 *g_logo = NULL;
static int g_logo_w = 0, g_logo_h = 0;
static void fill_rect(u32 *fb, u32 stride_px, int x, int y, int w, int h, u32 color) {
if (w <= 0 || h <= 0) return;
if (x < 0) {
w += x;
x = 0;
}
if (y < 0) {
h += y;
y = 0;
}
if (x + w > FB_W) w = FB_W - x;
if (y + h > FB_H) h = FB_H - y;
if (w <= 0 || h <= 0) return;
for (int row = 0; row < h; row++) {
u32 *line = fb + (y + row) * stride_px + x;
for (int col = 0; col < w; col++) line[col] = color;
}
}
/* Normalize UTF-8 punctuation to ASCII; drop other non-ASCII bytes. */
static size_t ota_ui_sanitize_ascii(const char *src, char *dst, size_t dst_len) {
if (!dst || dst_len == 0) return 0;
dst[0] = '\0';
if (!src) return 0;
size_t w = 0;
for (size_t i = 0; src[i] && w + 1 < dst_len;) {
unsigned char c = (unsigned char)src[i];
if (c < 0x80) {
dst[w++] = (char)c;
i++;
continue;
}
if (c == 0xE2 && src[i + 1] && src[i + 2]) {
unsigned char b2 = (unsigned char)src[i + 1];
unsigned char b3 = (unsigned char)src[i + 2];
if (b2 == 0x80 && b3 == 0xA6) {
if (w + 3 < dst_len) {
dst[w++] = '.';
dst[w++] = '.';
dst[w++] = '.';
}
i += 3;
continue;
}
if (b2 == 0x80 && (b3 == 0x94 || b3 == 0x93)) {
dst[w++] = '-';
i += 3;
continue;
}
if (b2 == 0x80 && (b3 == 0x98 || b3 == 0x99)) {
dst[w++] = '\'';
i += 3;
continue;
}
}
if ((c & 0xE0) == 0xC0) i += 2;
else if ((c & 0xF0) == 0xE0) i += 3;
else if ((c & 0xF8) == 0xF0) i += 4;
else i++;
}
dst[w] = '\0';
return w;
}
static void draw_glyph(u32 *fb, u32 stride_px, int x, int y, char ch, u32 color, int scale) {
unsigned char c = (unsigned char)ch;
if (c > 127) return;
const char *bits = font8x8_basic[c];
for (int row = 0; row < 8; row++) {
unsigned char line = (unsigned char)bits[row];
for (int col = 0; col < 8; col++) {
if (line & (1 << col)) {
fill_rect(fb, stride_px, x + col * scale, y + row * scale, scale, scale, color);
}
}
}
}
static int text_width(const char *s, int scale) {
if (!s) return 0;
return (int)strlen(s) * 8 * scale;
}
static void draw_text(u32 *fb, u32 stride_px, int x, int y, const char *s, u32 color, int scale) {
if (!s) return;
char buf[512];
ota_ui_sanitize_ascii(s, buf, sizeof(buf));
int cx = x;
for (const char *p = buf; *p; p++) {
if (*p == '\n') {
cx = x;
y += 8 * scale + 4;
continue;
}
draw_glyph(fb, stride_px, cx, y, *p, color, scale);
cx += 8 * scale;
}
}
static void draw_text_centered(u32 *fb, u32 stride_px, int y, const char *s, u32 color, int scale) {
char buf[512];
ota_ui_sanitize_ascii(s, buf, sizeof(buf));
int w = text_width(buf, scale);
draw_text(fb, stride_px, (FB_W - w) / 2, y, buf, color, scale);
}
static int draw_text_wrapped_centered(u32 *fb, u32 stride_px, int y, const char *s, u32 color,
int scale, int max_px) {
if (!s || !*s) return y;
char buf[512];
ota_ui_sanitize_ascii(s, buf, sizeof(buf));
int char_w = 8 * scale;
int max_chars = max_px / char_w;
if (max_chars < 8) max_chars = 8;
char line[128];
int line_len = 0;
const char *word = buf;
int lines = 0;
while (*word) {
while (*word == ' ') word++;
if (!*word) break;
const char *end = word;
while (*end && *end != ' ' && *end != '\n') end++;
int wlen = (int)(end - word);
if (line_len > 0 && line_len + 1 + wlen > max_chars) {
line[line_len] = '\0';
draw_text_centered(fb, stride_px, y, line, color, scale);
y += 8 * scale + 4;
lines++;
line_len = 0;
}
if (wlen > max_chars) {
if (line_len > 0) {
line[line_len] = '\0';
draw_text_centered(fb, stride_px, y, line, color, scale);
y += 8 * scale + 4;
lines++;
line_len = 0;
}
while (wlen > 0) {
int chunk = wlen > max_chars ? max_chars : wlen;
memcpy(line, word, (size_t)chunk);
line[chunk] = '\0';
draw_text_centered(fb, stride_px, y, line, color, scale);
y += 8 * scale + 4;
lines++;
word += chunk;
wlen -= chunk;
}
word = end;
continue;
}
if (line_len > 0) line[line_len++] = ' ';
memcpy(line + line_len, word, (size_t)wlen);
line_len += wlen;
line[line_len] = '\0';
word = (*end == '\n') ? end + 1 : end;
if (*(end - 1) == '\n' || *end == '\n') {
draw_text_centered(fb, stride_px, y, line, color, scale);
y += 8 * scale + 4;
lines++;
line_len = 0;
if (*end == '\n') word = end + 1;
}
}
if (line_len > 0) {
draw_text_centered(fb, stride_px, y, line, color, scale);
y += 8 * scale + 4;
lines++;
}
if (lines == 0) return y;
return y;
}
static void draw_rail(u32 *fb, u32 stride_px) {
int h = 6;
int third = FB_W / 3;
fill_rect(fb, stride_px, 0, 0, third, h, COL_RAIL_R);
fill_rect(fb, stride_px, third, 0, third, h, COL_RAIL_B);
fill_rect(fb, stride_px, third * 2, 0, FB_W - third * 2, h, COL_RAIL_G);
}
static void blit_logo(u32 *fb, u32 stride_px, int dst_x, int dst_y, int max_w) {
if (!g_logo || g_logo_w <= 0 || g_logo_h <= 0) return;
int dw = g_logo_w;
int dh = g_logo_h;
if (dw > max_w) {
dh = dh * max_w / dw;
dw = max_w;
}
for (int y = 0; y < dh; y++) {
int sy = y * g_logo_h / dh;
for (int x = 0; x < dw; x++) {
int sx = x * g_logo_w / dw;
u32 px = g_logo[sy * g_logo_w + sx];
u8 a = (px >> 24) & 0xff;
if (a < 16) continue;
int dx = dst_x + x;
int dy = dst_y + y;
if (dx < 0 || dy < 0 || dx >= FB_W || dy >= FB_H) continue;
fb[dy * stride_px + dx] = px | 0xff000000u;
}
}
}
static void load_logo(void) {
if (g_logo) return;
FILE *fp = fopen(OTA_LOGO_ROMFS, "rb");
if (!fp) return;
unsigned char header[8];
if (fread(header, 1, sizeof(header), fp) != sizeof(header)) {
fclose(fp);
return;
}
int w = (int)(header[0] | (header[1] << 8) | (header[2] << 16) | (header[3] << 24));
int h = (int)(header[4] | (header[5] << 8) | (header[6] << 16) | (header[7] << 24));
if (w <= 0 || h <= 0 || w > 2048 || h > 2048) {
fclose(fp);
return;
}
size_t nbytes = (size_t)w * (size_t)h * 4u;
unsigned char *data = (unsigned char *)malloc(nbytes);
if (!data) {
fclose(fp);
return;
}
if (fread(data, 1, nbytes, fp) != nbytes) {
free(data);
fclose(fp);
return;
}
fclose(fp);
g_logo = (u32 *)malloc((size_t)w * (size_t)h * sizeof(u32));
if (!g_logo) {
free(data);
return;
}
for (int i = 0; i < w * h; i++) {
unsigned char *p = data + (size_t)i * 4u;
g_logo[i] = RGBA8(p[0], p[1], p[2], p[3]);
}
free(data);
g_logo_w = w;
g_logo_h = h;
}
static int ui_ensure(void) {
if (g_ready) return 1;
NWindow *win = nwindowGetDefault();
if (R_FAILED(framebufferCreate(&g_fb, win, FB_W, FB_H, PIXEL_FORMAT_RGBA_8888, 2))) return 0;
framebufferMakeLinear(&g_fb);
padInitializeDefault(&g_pad);
load_logo();
g_ready = 1;
return 1;
}
void ota_ui_shutdown(void) {
if (!g_ready) return;
framebufferClose(&g_fb);
free(g_logo);
g_logo = NULL;
g_logo_w = g_logo_h = 0;
g_ready = 0;
}
static void draw_button(u32 *fb, u32 stride_px, int x, int y, int w, int h, u32 fill, u32 ink,
const char *label, int focused) {
fill_rect(fb, stride_px, x, y, w, h, fill);
if (focused) {
fill_rect(fb, stride_px, x - 3, y - 3, w + 6, 2, COL_INK);
fill_rect(fb, stride_px, x - 3, y + h + 1, w + 6, 2, COL_INK);
fill_rect(fb, stride_px, x - 3, y - 3, 2, h + 6, COL_INK);
fill_rect(fb, stride_px, x + w + 1, y - 3, 2, h + 6, COL_INK);
} else {
fill_rect(fb, stride_px, x, y, w, 1, COL_LINE);
fill_rect(fb, stride_px, x, y + h - 1, w, 1, COL_LINE);
fill_rect(fb, stride_px, x, y, 1, h, COL_LINE);
fill_rect(fb, stride_px, x + w - 1, y, 1, h, COL_LINE);
}
int scale = 3;
char lbl[128];
ota_ui_sanitize_ascii(label, lbl, sizeof(lbl));
int max_tw = w - 24;
int tw = text_width(lbl, scale);
while (scale > 2 && tw > max_tw) {
scale--;
tw = text_width(lbl, scale);
}
int tx = x + (w - tw) / 2;
int ty = y + (h - 8 * scale) / 2;
draw_text(fb, stride_px, tx, ty, lbl, ink, scale);
}
static void draw_chrome(u32 *fb, u32 stride_px) {
fill_rect(fb, stride_px, 0, 0, FB_W, FB_H, COL_BG);
draw_rail(fb, stride_px);
int logo_max = 320;
int logo_h = g_logo ? (g_logo_h * logo_max / g_logo_w) : 40;
if (logo_h > 90) logo_h = 90;
int logo_y = 48;
if (g_logo) {
int logo_w = logo_max;
if (g_logo_w * logo_h / g_logo_h < logo_max) logo_w = g_logo_w * logo_h / g_logo_h;
blit_logo(fb, stride_px, (FB_W - logo_w) / 2, logo_y, logo_max);
} else {
draw_text_centered(fb, stride_px, logo_y + 16, "gen1recomp", COL_INK, 4);
}
}
static void present(void (*paint)(u32 *fb, u32 stride_px, void *ctx), void *ctx) {
if (!ui_ensure()) return;
u32 stride = 0;
u32 *fb = (u32 *)framebufferBegin(&g_fb, &stride);
u32 stride_px = stride / sizeof(u32);
paint(fb, stride_px, ctx);
framebufferEnd(&g_fb);
}
typedef struct {
const char *installed;
const char *latest;
int focus; /* 0 = Update, 1 = Play */
} prompt_ctx_t;
static void paint_prompt(u32 *fb, u32 stride_px, void *ctx) {
prompt_ctx_t *p = (prompt_ctx_t *)ctx;
draw_chrome(fb, stride_px);
int y = TITLE_Y - 20;
draw_text_centered(fb, stride_px, y, "Update available", COL_INK, 3);
y += LINE_TITLE;
char line[96];
snprintf(line, sizeof(line), "v%s to v%s", p->installed ? p->installed : "?",
p->latest ? p->latest : "?");
draw_text_centered(fb, stride_px, y, line, COL_YELLOW, 3);
y += LINE_TITLE + 8;
draw_text_centered(fb, stride_px, y, "Your saves stay on this console.", COL_MUTED, 2);
y += LINE_BODY + 28;
int bw = 600;
int bh = 64;
int bx = (FB_W - bw) / 2;
draw_button(fb, stride_px, bx, y, bw, bh, COL_YELLOW, COL_INVERSE, "(A) Update", p->focus == 0);
y += bh + 24;
draw_button(fb, stride_px, bx, y, bw, bh, COL_INK, COL_INVERSE, "(B) Play without update",
p->focus == 1);
}
int ota_ui_prompt_update(const char *installed, const char *latest) {
if (!ui_ensure()) return 0;
prompt_ctx_t ctx = {installed, latest, 0};
while (appletMainLoop()) {
present(paint_prompt, &ctx);
padUpdate(&g_pad);
u64 k = padGetButtonsDown(&g_pad);
if (k & HidNpadButton_A) {
int do_update = (ctx.focus == 0);
if (!do_update) ota_ui_shutdown();
return do_update;
}
if (k & HidNpadButton_B) {
ota_ui_shutdown();
return 0;
}
if (k & (HidNpadButton_Up | HidNpadButton_Down | HidNpadButton_Left | HidNpadButton_Right)) {
ctx.focus = 1 - ctx.focus;
}
svcSleepThread(16000000ULL);
}
ota_ui_shutdown();
return 0;
}
typedef struct {
const char *title;
const char *detail;
float progress;
int show_bar;
} status_ctx_t;
static void paint_progress_bar(u32 *fb, u32 stride_px, int y, float t) {
int bw = 520;
int bh = 18;
int bx = (FB_W - bw) / 2;
if (t < 0.f) t = 0.f;
if (t > 1.f) t = 1.f;
fill_rect(fb, stride_px, bx, y, bw, bh, COL_RAISED);
fill_rect(fb, stride_px, bx, y, bw, 1, COL_LINE);
fill_rect(fb, stride_px, bx, y + bh - 1, bw, 1, COL_LINE);
fill_rect(fb, stride_px, bx, y, 1, bh, COL_LINE);
fill_rect(fb, stride_px, bx + bw - 1, y, 1, bh, COL_LINE);
int fw = (int)(bw * t);
if (fw > 0) fill_rect(fb, stride_px, bx, y, fw, bh, COL_GREEN);
}
static void paint_status(u32 *fb, u32 stride_px, void *ctx) {
status_ctx_t *s = (status_ctx_t *)ctx;
draw_chrome(fb, stride_px);
int y = TITLE_Y;
draw_text_centered(fb, stride_px, y, s->title ? s->title : "", COL_INK, 3);
y += LINE_TITLE;
if (s->detail && s->detail[0]) {
y = draw_text_wrapped_centered(fb, stride_px, y, s->detail, COL_DETAIL, 2, WRAP_MAX_PX);
y += LINE_SMALL;
}
if (s->show_bar) paint_progress_bar(fb, stride_px, y, s->progress);
}
void ota_ui_show_status(const char *title, const char *detail) {
status_ctx_t s = {title, detail, 0.f, 0};
present(paint_status, &s);
}
void ota_ui_show_progress(const char *title, const char *detail, float progress01) {
status_ctx_t s = {title, detail, progress01, 1};
present(paint_status, &s);
}
typedef struct {
const char *title;
const char *line1;
const char *line2;
const char *line3;
} alert_ctx_t;
static void paint_alert(u32 *fb, u32 stride_px, void *ctx) {
alert_ctx_t *a = (alert_ctx_t *)ctx;
draw_chrome(fb, stride_px);
int y = TITLE_Y;
draw_text_centered(fb, stride_px, y, a->title ? a->title : "", COL_YELLOW, 3);
y += LINE_TITLE;
if (a->line1 && a->line1[0])
y = draw_text_wrapped_centered(fb, stride_px, y, a->line1, COL_DETAIL, 2, WRAP_MAX_PX);
y += LINE_SMALL;
if (a->line2 && a->line2[0])
y = draw_text_wrapped_centered(fb, stride_px, y, a->line2, COL_MUTED, 2, WRAP_MAX_PX);
y += LINE_SMALL;
if (a->line3 && a->line3[0])
y = draw_text_wrapped_centered(fb, stride_px, y, a->line3, COL_MUTED, 2, WRAP_MAX_PX);
y += LINE_BODY + 16;
int bw = 360;
int bh = 56;
draw_button(fb, stride_px, (FB_W - bw) / 2, y, bw, bh, COL_INK, COL_INVERSE, "(B) Continue", 1);
}
void ota_ui_alert(const char *title, const char *line1, const char *line2) {
if (!ui_ensure()) return;
alert_ctx_t a = {title, line1, line2, NULL};
while (appletMainLoop()) {
present(paint_alert, &a);
padUpdate(&g_pad);
if (padGetButtonsDown(&g_pad) & HidNpadButton_B) break;
svcSleepThread(16000000ULL);
}
ota_ui_shutdown();
}
void ota_ui_alert_error(const char *title, const char *friendly, const char *technical,
const char *installed_version) {
if (!ui_ensure()) return;
char footer[96];
snprintf(footer, sizeof(footer), "Playing installed version (v%s).",
installed_version && installed_version[0] ? installed_version : "?");
char tech[256];
if (technical && technical[0]) {
ota_ui_sanitize_ascii(technical, tech, sizeof(tech));
if (strlen(tech) > 80) tech[80] = '\0';
} else {
tech[0] = '\0';
}
alert_ctx_t a = {title, friendly, tech[0] ? tech : NULL, footer};
while (appletMainLoop()) {
present(paint_alert, &a);
padUpdate(&g_pad);
if (padGetButtonsDown(&g_pad) & HidNpadButton_B) break;
svcSleepThread(16000000ULL);
}
ota_ui_shutdown();
}
static void paint_missing(u32 *fb, u32 stride_px, void *ctx) {
(void)ctx;
draw_chrome(fb, stride_px);
int y = TITLE_Y - 10;
draw_text_centered(fb, stride_px, y, "Game files missing", COL_YELLOW, 3);
y += LINE_TITLE;
y = draw_text_wrapped_centered(fb, stride_px, y,
"Copy the Switch zip onto your microSD, then open gen1recomp again.",
COL_DETAIL, 2, WRAP_MAX_PX);
y += LINE_BODY + 16;
int bw = 320;
int bh = 56;
draw_button(fb, stride_px, (FB_W - bw) / 2, y, bw, bh, COL_INK, COL_INVERSE, "(+) Exit", 1);
}
void ota_ui_missing_game(void) {
if (!ui_ensure()) return;
while (appletMainLoop()) {
present(paint_missing, NULL);
padUpdate(&g_pad);
if (padGetButtonsDown(&g_pad) & HidNpadButton_Plus) break;
svcSleepThread(16000000ULL);
}
ota_ui_shutdown();
}
#else /* host stub */
int ota_ui_prompt_update(const char *installed, const char *latest) {
fprintf(stderr, "[ota_ui] update %s -> %s (host stub: skip)\n", installed ? installed : "?",
latest ? latest : "?");
return 0;
}
void ota_ui_show_status(const char *title, const char *detail) {
fprintf(stderr, "[ota_ui] %s %s\n", title ? title : "", detail ? detail : "");
}
void ota_ui_show_progress(const char *title, const char *detail, float progress01) {
fprintf(stderr, "[ota_ui] %s %s (%.0f%%)\n", title ? title : "", detail ? detail : "",
progress01 * 100.f);
}
void ota_ui_alert(const char *title, const char *line1, const char *line2) {
fprintf(stderr, "[ota_ui] alert: %s / %s / %s\n", title ? title : "", line1 ? line1 : "",
line2 ? line2 : "");
}
void ota_ui_alert_error(const char *title, const char *friendly, const char *technical,
const char *installed_version) {
fprintf(stderr, "[ota_ui] error: %s / %s / %s / v%s\n", title ? title : "",
friendly ? friendly : "", technical ? technical : "",
installed_version ? installed_version : "?");
}
void ota_ui_missing_game(void) { fprintf(stderr, "[ota_ui] missing game\n"); }
void ota_ui_shutdown(void) {}
#endif
+89
View File
@@ -0,0 +1,89 @@
#include "ota_unzip.h"
#include <stdio.h>
#include <string.h>
#if defined(__SWITCH__)
#include <fcntl.h>
#include <zzip/zzip.h>
#endif
#if defined(__SWITCH__)
static int extract_member(ZZIP_DIR *dir, const char *member, const char *dest_path, char *err,
size_t err_len) {
ZZIP_FILE *zf = zzip_file_open(dir, member, O_RDONLY);
if (!zf) return 1; /* not found / cannot open */
FILE *out = fopen(dest_path, "wb");
if (!out) {
zzip_file_close(zf);
if (err && err_len) snprintf(err, err_len, "fopen dest failed: %s", dest_path);
return -1;
}
char buf[8192];
zzip_ssize_t n;
while ((n = zzip_file_read(zf, buf, sizeof(buf))) > 0) {
if ((zzip_ssize_t)fwrite(buf, 1, (size_t)n, out) != n) {
fclose(out);
zzip_file_close(zf);
if (err && err_len) snprintf(err, err_len, "fwrite failed");
return -1;
}
}
fclose(out);
zzip_file_close(zf);
if (n < 0) {
if (err && err_len) snprintf(err, err_len, "zzip_file_read failed");
return -1;
}
return 0;
}
#endif
int ota_unzip_extract_file(const char *zip_path, const char *member_name, const char *dest_path,
char *err, size_t err_len) {
if (!zip_path || !member_name || !dest_path) {
if (err && err_len) snprintf(err, err_len, "bad args");
return -1;
}
#if defined(__SWITCH__)
zzip_error_t zerr = ZZIP_NO_ERROR;
ZZIP_DIR *dir = zzip_dir_open(zip_path, &zerr);
if (!dir) {
if (err && err_len) snprintf(err, err_len, "zzip_dir_open failed (%d): %s", (int)zerr, zip_path);
return -1;
}
char alt1[192];
char alt2[192];
snprintf(alt1, sizeof(alt1), "switch/gen1recomp/%s", member_name);
snprintf(alt2, sizeof(alt2), "./%s", member_name);
const char *candidates[] = {member_name, alt1, alt2, NULL};
int rc = -1;
if (err && err_len) err[0] = '\0';
for (int i = 0; candidates[i]; i++) {
int t = extract_member(dir, candidates[i], dest_path, err, err_len);
if (t == 0) {
rc = 0;
break;
}
if (t < 0) {
rc = -1;
break;
}
}
if (rc != 0 && err && err_len && !err[0]) {
snprintf(err, err_len, "member not found in zip: %s", member_name);
}
zzip_dir_close(dir);
return rc;
#else
(void)zip_path;
(void)member_name;
(void)dest_path;
if (err && err_len) snprintf(err, err_len, "ota_unzip only available on __SWITCH__");
return -1;
#endif
}
+152
View File
@@ -0,0 +1,152 @@
/**
* 8x8 monochrome bitmap fonts for rendering
* Author: Daniel Hepper <daniel@hepper.net>
*
* License: Public Domain
*
* Based on:
* // Summary: font8x8.h
* // 8x8 monochrome bitmap fonts for rendering
* //
* // Author:
* // Marcel Sondaar
* // International Business Machines (public domain VGA fonts)
* //
* // License:
* // Public Domain
*
* Fetched from: http://dimensionalrift.homelinux.net/combuster/mos3/?p=viewsource&file=/modules/gfx/font8_8.asm
**/
// Constant: font8x8_basic
// Contains an 8x8 font map for unicode points U+0000 - U+007F (basic latin)
char font8x8_basic[128][8] = {
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0000 (nul)
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0001
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0002
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0003
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0004
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0005
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0006
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0007
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0008
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0009
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+000A
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+000B
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+000C
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+000D
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+000E
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+000F
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0010
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0011
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0012
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0013
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0014
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0015
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0016
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0017
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0018
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0019
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+001A
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+001B
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+001C
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+001D
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+001E
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+001F
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0020 (space)
{ 0x18, 0x3C, 0x3C, 0x18, 0x18, 0x00, 0x18, 0x00}, // U+0021 (!)
{ 0x36, 0x36, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0022 (")
{ 0x36, 0x36, 0x7F, 0x36, 0x7F, 0x36, 0x36, 0x00}, // U+0023 (#)
{ 0x0C, 0x3E, 0x03, 0x1E, 0x30, 0x1F, 0x0C, 0x00}, // U+0024 ($)
{ 0x00, 0x63, 0x33, 0x18, 0x0C, 0x66, 0x63, 0x00}, // U+0025 (%)
{ 0x1C, 0x36, 0x1C, 0x6E, 0x3B, 0x33, 0x6E, 0x00}, // U+0026 (&)
{ 0x06, 0x06, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0027 (')
{ 0x18, 0x0C, 0x06, 0x06, 0x06, 0x0C, 0x18, 0x00}, // U+0028 (()
{ 0x06, 0x0C, 0x18, 0x18, 0x18, 0x0C, 0x06, 0x00}, // U+0029 ())
{ 0x00, 0x66, 0x3C, 0xFF, 0x3C, 0x66, 0x00, 0x00}, // U+002A (*)
{ 0x00, 0x0C, 0x0C, 0x3F, 0x0C, 0x0C, 0x00, 0x00}, // U+002B (+)
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x0C, 0x06}, // U+002C (,)
{ 0x00, 0x00, 0x00, 0x3F, 0x00, 0x00, 0x00, 0x00}, // U+002D (-)
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x0C, 0x00}, // U+002E (.)
{ 0x60, 0x30, 0x18, 0x0C, 0x06, 0x03, 0x01, 0x00}, // U+002F (/)
{ 0x3E, 0x63, 0x73, 0x7B, 0x6F, 0x67, 0x3E, 0x00}, // U+0030 (0)
{ 0x0C, 0x0E, 0x0C, 0x0C, 0x0C, 0x0C, 0x3F, 0x00}, // U+0031 (1)
{ 0x1E, 0x33, 0x30, 0x1C, 0x06, 0x33, 0x3F, 0x00}, // U+0032 (2)
{ 0x1E, 0x33, 0x30, 0x1C, 0x30, 0x33, 0x1E, 0x00}, // U+0033 (3)
{ 0x38, 0x3C, 0x36, 0x33, 0x7F, 0x30, 0x78, 0x00}, // U+0034 (4)
{ 0x3F, 0x03, 0x1F, 0x30, 0x30, 0x33, 0x1E, 0x00}, // U+0035 (5)
{ 0x1C, 0x06, 0x03, 0x1F, 0x33, 0x33, 0x1E, 0x00}, // U+0036 (6)
{ 0x3F, 0x33, 0x30, 0x18, 0x0C, 0x0C, 0x0C, 0x00}, // U+0037 (7)
{ 0x1E, 0x33, 0x33, 0x1E, 0x33, 0x33, 0x1E, 0x00}, // U+0038 (8)
{ 0x1E, 0x33, 0x33, 0x3E, 0x30, 0x18, 0x0E, 0x00}, // U+0039 (9)
{ 0x00, 0x0C, 0x0C, 0x00, 0x00, 0x0C, 0x0C, 0x00}, // U+003A (:)
{ 0x00, 0x0C, 0x0C, 0x00, 0x00, 0x0C, 0x0C, 0x06}, // U+003B (;)
{ 0x18, 0x0C, 0x06, 0x03, 0x06, 0x0C, 0x18, 0x00}, // U+003C (<)
{ 0x00, 0x00, 0x3F, 0x00, 0x00, 0x3F, 0x00, 0x00}, // U+003D (=)
{ 0x06, 0x0C, 0x18, 0x30, 0x18, 0x0C, 0x06, 0x00}, // U+003E (>)
{ 0x1E, 0x33, 0x30, 0x18, 0x0C, 0x00, 0x0C, 0x00}, // U+003F (?)
{ 0x3E, 0x63, 0x7B, 0x7B, 0x7B, 0x03, 0x1E, 0x00}, // U+0040 (@)
{ 0x0C, 0x1E, 0x33, 0x33, 0x3F, 0x33, 0x33, 0x00}, // U+0041 (A)
{ 0x3F, 0x66, 0x66, 0x3E, 0x66, 0x66, 0x3F, 0x00}, // U+0042 (B)
{ 0x3C, 0x66, 0x03, 0x03, 0x03, 0x66, 0x3C, 0x00}, // U+0043 (C)
{ 0x1F, 0x36, 0x66, 0x66, 0x66, 0x36, 0x1F, 0x00}, // U+0044 (D)
{ 0x7F, 0x46, 0x16, 0x1E, 0x16, 0x46, 0x7F, 0x00}, // U+0045 (E)
{ 0x7F, 0x46, 0x16, 0x1E, 0x16, 0x06, 0x0F, 0x00}, // U+0046 (F)
{ 0x3C, 0x66, 0x03, 0x03, 0x73, 0x66, 0x7C, 0x00}, // U+0047 (G)
{ 0x33, 0x33, 0x33, 0x3F, 0x33, 0x33, 0x33, 0x00}, // U+0048 (H)
{ 0x1E, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x1E, 0x00}, // U+0049 (I)
{ 0x78, 0x30, 0x30, 0x30, 0x33, 0x33, 0x1E, 0x00}, // U+004A (J)
{ 0x67, 0x66, 0x36, 0x1E, 0x36, 0x66, 0x67, 0x00}, // U+004B (K)
{ 0x0F, 0x06, 0x06, 0x06, 0x46, 0x66, 0x7F, 0x00}, // U+004C (L)
{ 0x63, 0x77, 0x7F, 0x7F, 0x6B, 0x63, 0x63, 0x00}, // U+004D (M)
{ 0x63, 0x67, 0x6F, 0x7B, 0x73, 0x63, 0x63, 0x00}, // U+004E (N)
{ 0x1C, 0x36, 0x63, 0x63, 0x63, 0x36, 0x1C, 0x00}, // U+004F (O)
{ 0x3F, 0x66, 0x66, 0x3E, 0x06, 0x06, 0x0F, 0x00}, // U+0050 (P)
{ 0x1E, 0x33, 0x33, 0x33, 0x3B, 0x1E, 0x38, 0x00}, // U+0051 (Q)
{ 0x3F, 0x66, 0x66, 0x3E, 0x36, 0x66, 0x67, 0x00}, // U+0052 (R)
{ 0x1E, 0x33, 0x07, 0x0E, 0x38, 0x33, 0x1E, 0x00}, // U+0053 (S)
{ 0x3F, 0x2D, 0x0C, 0x0C, 0x0C, 0x0C, 0x1E, 0x00}, // U+0054 (T)
{ 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x3F, 0x00}, // U+0055 (U)
{ 0x33, 0x33, 0x33, 0x33, 0x33, 0x1E, 0x0C, 0x00}, // U+0056 (V)
{ 0x63, 0x63, 0x63, 0x6B, 0x7F, 0x77, 0x63, 0x00}, // U+0057 (W)
{ 0x63, 0x63, 0x36, 0x1C, 0x1C, 0x36, 0x63, 0x00}, // U+0058 (X)
{ 0x33, 0x33, 0x33, 0x1E, 0x0C, 0x0C, 0x1E, 0x00}, // U+0059 (Y)
{ 0x7F, 0x63, 0x31, 0x18, 0x4C, 0x66, 0x7F, 0x00}, // U+005A (Z)
{ 0x1E, 0x06, 0x06, 0x06, 0x06, 0x06, 0x1E, 0x00}, // U+005B ([)
{ 0x03, 0x06, 0x0C, 0x18, 0x30, 0x60, 0x40, 0x00}, // U+005C (\)
{ 0x1E, 0x18, 0x18, 0x18, 0x18, 0x18, 0x1E, 0x00}, // U+005D (])
{ 0x08, 0x1C, 0x36, 0x63, 0x00, 0x00, 0x00, 0x00}, // U+005E (^)
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF}, // U+005F (_)
{ 0x0C, 0x0C, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0060 (`)
{ 0x00, 0x00, 0x1E, 0x30, 0x3E, 0x33, 0x6E, 0x00}, // U+0061 (a)
{ 0x07, 0x06, 0x06, 0x3E, 0x66, 0x66, 0x3B, 0x00}, // U+0062 (b)
{ 0x00, 0x00, 0x1E, 0x33, 0x03, 0x33, 0x1E, 0x00}, // U+0063 (c)
{ 0x38, 0x30, 0x30, 0x3e, 0x33, 0x33, 0x6E, 0x00}, // U+0064 (d)
{ 0x00, 0x00, 0x1E, 0x33, 0x3f, 0x03, 0x1E, 0x00}, // U+0065 (e)
{ 0x1C, 0x36, 0x06, 0x0f, 0x06, 0x06, 0x0F, 0x00}, // U+0066 (f)
{ 0x00, 0x00, 0x6E, 0x33, 0x33, 0x3E, 0x30, 0x1F}, // U+0067 (g)
{ 0x07, 0x06, 0x36, 0x6E, 0x66, 0x66, 0x67, 0x00}, // U+0068 (h)
{ 0x0C, 0x00, 0x0E, 0x0C, 0x0C, 0x0C, 0x1E, 0x00}, // U+0069 (i)
{ 0x30, 0x00, 0x30, 0x30, 0x30, 0x33, 0x33, 0x1E}, // U+006A (j)
{ 0x07, 0x06, 0x66, 0x36, 0x1E, 0x36, 0x67, 0x00}, // U+006B (k)
{ 0x0E, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x1E, 0x00}, // U+006C (l)
{ 0x00, 0x00, 0x33, 0x7F, 0x7F, 0x6B, 0x63, 0x00}, // U+006D (m)
{ 0x00, 0x00, 0x1F, 0x33, 0x33, 0x33, 0x33, 0x00}, // U+006E (n)
{ 0x00, 0x00, 0x1E, 0x33, 0x33, 0x33, 0x1E, 0x00}, // U+006F (o)
{ 0x00, 0x00, 0x3B, 0x66, 0x66, 0x3E, 0x06, 0x0F}, // U+0070 (p)
{ 0x00, 0x00, 0x6E, 0x33, 0x33, 0x3E, 0x30, 0x78}, // U+0071 (q)
{ 0x00, 0x00, 0x3B, 0x6E, 0x66, 0x06, 0x0F, 0x00}, // U+0072 (r)
{ 0x00, 0x00, 0x3E, 0x03, 0x1E, 0x30, 0x1F, 0x00}, // U+0073 (s)
{ 0x08, 0x0C, 0x3E, 0x0C, 0x0C, 0x2C, 0x18, 0x00}, // U+0074 (t)
{ 0x00, 0x00, 0x33, 0x33, 0x33, 0x33, 0x6E, 0x00}, // U+0075 (u)
{ 0x00, 0x00, 0x33, 0x33, 0x33, 0x1E, 0x0C, 0x00}, // U+0076 (v)
{ 0x00, 0x00, 0x63, 0x6B, 0x7F, 0x7F, 0x36, 0x00}, // U+0077 (w)
{ 0x00, 0x00, 0x63, 0x36, 0x1C, 0x36, 0x63, 0x00}, // U+0078 (x)
{ 0x00, 0x00, 0x33, 0x33, 0x33, 0x3E, 0x30, 0x1F}, // U+0079 (y)
{ 0x00, 0x00, 0x3F, 0x19, 0x0C, 0x26, 0x3F, 0x00}, // U+007A (z)
{ 0x38, 0x0C, 0x0C, 0x07, 0x0C, 0x0C, 0x38, 0x00}, // U+007B ({)
{ 0x18, 0x18, 0x18, 0x00, 0x18, 0x18, 0x18, 0x00}, // U+007C (|)
{ 0x07, 0x0C, 0x0C, 0x38, 0x0C, 0x0C, 0x07, 0x00}, // U+007D (})
{ 0x6E, 0x3B, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+007E (~)
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00} // U+007F
};
Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

+5
View File
@@ -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 -1
View File
@@ -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.
Binary file not shown.
+2 -2
View File
@@ -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",
+25 -11
View File
@@ -19,19 +19,22 @@
# (gen1recomp.nro + game.love side by side). Requires the pin
# (run --fetch first, or combine --fetch --loose).
#
# --fused Build a single gen1recomp-<ver>-switch.nro via nacptool+elf2nro,
# then pack dist/switch/gen1recomp-<ver>-switch.zip (SD-ready
# tree under switch/gen1recomp/). Uses native tools (PATH or
# $DEVKITPRO/tools/bin) first; else Docker from
# scripts/switch/dkp-docker.image (override GEN1_DKP_IMAGE).
# Requires the pin (run --fetch or combine). GitHub Releases
# publish the zip only; the versioned .nro stays local / PR CI.
# --fused Build fused gen1recomp-<ver>-switch.nro (game in romfs), native
# OTA launcher NRO, and dual-NRO SD zip (gen1recomp.nro launcher +
# gen1recomp-game.nro under switch/gen1recomp/). The same
# gen1recomp-<ver>-switch.zip is the OTA download asset.
# Requires DEVKITPRO + switch-dev; OTA launcher via native packages
# (install_devkitpro_deps.sh) or Docker.
# Uses native tools (PATH or $DEVKITPRO/tools/bin) first; else Docker
# from scripts/switch/dkp-docker.image (override GEN1_DKP_IMAGE).
# Requires the pin (run --fetch or combine). GitHub Releases publish
# the zip only; versioned .nro files stay local / PR CI.
#
# Combinable: --fetch alone, or --fetch with --loose / --fused.
# XOR: --loose and --fused cannot be used together.
# XOR: --loose and --fused cannot be combined with each other.
#
# Non-goals (never done by this script):
# MTP push, ROM install, dkp-pacman auto-install.
# MTP push, ROM install, dkp-pacman auto-install (use install_devkitpro_deps.sh).
set -euo pipefail
@@ -49,7 +52,7 @@ say() { printf '\033[1;32m==>\033[0m %s\n' "$*" >&2; }
fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
usage() {
sed -n '2,34p' "$0" | sed 's/^# \{0,1\}//'
sed -n '2,38p' "$0" | sed 's/^# \{0,1\}//'
}
while [ $# -gt 0 ]; do
@@ -129,11 +132,22 @@ if [ "$LOOSE" -eq 1 ]; then
fi
if [ "$FUSED" -eq 1 ]; then
# shellcheck source=scripts/switch/common.sh
. "$ROOT/scripts/switch/common.sh"
preflight_fused_build
GAME_LOVE="$(pack_game_love)"
OUT_NRO="$DIST/gen1recomp-${VERSION}-switch.nro"
OUT_ZIP="$DIST/gen1recomp-${VERSION}-switch.zip"
"$ROOT/scripts/switch/build_fused.sh" "$GAME_LOVE" "$VERSION" "$OUT_NRO"
"$ROOT/scripts/switch/pack_sd_zip.sh" "$OUT_NRO" "$VERSION" "$OUT_ZIP"
LAUNCHER_NRO="$DIST/gen1recomp-${VERSION}-launcher.nro"
say "building native OTA launcher"
"$ROOT/scripts/switch/build_ota_launcher.sh" "$LAUNCHER_NRO" "$VERSION"
say "packing dual-NRO SD zip (also the OTA download asset)"
"$ROOT/scripts/switch/pack_sd_zip.sh" "$OUT_NRO" "$VERSION" "$OUT_ZIP" "$LAUNCHER_NRO"
cp "$OUT_NRO" "$DIST/gen1recomp-${VERSION}-game.nro"
cp "$WORK/build-info.json" "$DIST/gen1recomp-${VERSION}-build-info.json"
say "done. See $DIST/"
exit 0
+156
View File
@@ -0,0 +1,156 @@
#!/usr/bin/env python3
"""Bake assets/logo/logo.png into a pre-scaled RGBA blob for the Switch OTA UI.
Output format (little-endian):
uint32 width, uint32 height, then width*height RGBA8888 pixels.
Regenerate after editing the source PNG:
python3 scripts/switch/bake_ota_logo.py
Uses Pillow when available (see scripts/setup.sh). On macOS without Pillow,
falls back to sips + BMP export.
"""
from __future__ import annotations
import math
import platform
import struct
import subprocess
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
DEFAULT_SRC = ROOT / "assets/logo/logo.png"
DEFAULT_OUT = ROOT / "ports/switch/assets/logo.rgba"
DEFAULT_MAX_W = 320
DEFAULT_MAX_H = 90
def fit_size(src_w: int, src_h: int, max_w: int, max_h: int) -> tuple[int, int]:
scale = min(max_w / src_w, max_h / src_h, 1.0)
w = max(1, int(math.floor(src_w * scale + 0.5)))
h = max(1, int(math.floor(src_h * scale + 0.5)))
return w, h
def read_png_size(path: Path) -> tuple[int, int]:
with path.open("rb") as fp:
sig = fp.read(8)
if sig != b"\x89PNG\r\n\x1a\n":
raise SystemExit(f"not a PNG: {path}")
while True:
raw = fp.read(8)
if len(raw) < 8:
raise SystemExit(f"truncated PNG: {path}")
length, ctype = struct.unpack(">I4s", raw)
data = fp.read(length)
fp.read(4)
if ctype == b"IHDR":
return struct.unpack(">II", data[:8])
if ctype == b"IEND":
break
raise SystemExit(f"missing IHDR in PNG: {path}")
def read_bmp_rgba(path: Path) -> tuple[int, int, bytes]:
data = path.read_bytes()
if len(data) < 54 or data[:2] != b"BM":
raise SystemExit(f"not a BMP: {path}")
pixel_offset = struct.unpack_from("<I", data, 10)[0]
width = struct.unpack_from("<i", data, 18)[0]
height_raw = struct.unpack_from("<i", data, 22)[0]
bpp = struct.unpack_from("<H", data, 28)[0]
height = abs(height_raw)
top_down = height_raw < 0
if bpp != 32 or width <= 0 or height <= 0:
raise SystemExit(f"unsupported BMP {width}x{height_raw} @{bpp}bpp: {path}")
row_bytes = ((width * 4 + 3) // 4) * 4
pixels = bytearray(width * height * 4)
for row in range(height):
src_row = row if top_down else height - 1 - row
row_off = pixel_offset + src_row * row_bytes
for col in range(width):
b, g, r, a = data[row_off + col * 4 : row_off + col * 4 + 4]
dst = (row * width + col) * 4
pixels[dst : dst + 4] = bytes((r, g, b, a))
return width, height, bytes(pixels)
def bake_with_sips(src: Path, out_w: int, out_h: int) -> tuple[int, int, bytes]:
if platform.system() != "Darwin":
raise SystemExit(
"Pillow is required to bake the OTA logo on this platform. "
"Run scripts/setup.sh or: pip install pillow"
)
with tempfile.TemporaryDirectory() as tmp:
tmp_dir = Path(tmp)
scaled_png = tmp_dir / "scaled.png"
scaled_bmp = tmp_dir / "scaled.bmp"
subprocess.run(
["sips", "-z", str(out_h), str(out_w), str(src), "--out", str(scaled_png)],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
)
subprocess.run(
["sips", "-s", "format", "bmp", str(scaled_png), "--out", str(scaled_bmp)],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
)
return read_bmp_rgba(scaled_bmp)
def bake_with_pillow(src: Path, out_w: int, out_h: int) -> tuple[int, int, bytes]:
from PIL import Image
with Image.open(src) as image:
image = image.convert("RGBA")
if image.size != (out_w, out_h):
image = image.resize((out_w, out_h), Image.LANCZOS)
w, h = image.size
return w, h, image.tobytes()
def bake(src: Path, out: Path, max_w: int, max_h: int) -> tuple[int, int]:
if not src.is_file():
raise SystemExit(f"missing source PNG: {src}")
src_w, src_h = read_png_size(src)
out_w, out_h = fit_size(src_w, src_h, max_w, max_h)
try:
from PIL import Image # noqa: F401
w, h, pixels = bake_with_pillow(src, out_w, out_h)
except ImportError:
w, h, pixels = bake_with_sips(src, out_w, out_h)
if len(pixels) != w * h * 4:
raise SystemExit(f"unexpected pixel buffer size for {w}x{h}")
out.parent.mkdir(parents=True, exist_ok=True)
with out.open("wb") as fp:
fp.write(struct.pack("<II", w, h))
fp.write(pixels)
return w, h
def main(argv: list[str]) -> int:
src = Path(argv[1]) if len(argv) > 1 else DEFAULT_SRC
out = Path(argv[2]) if len(argv) > 2 else DEFAULT_OUT
max_w = int(argv[3]) if len(argv) > 3 else DEFAULT_MAX_W
max_h = int(argv[4]) if len(argv) > 4 else DEFAULT_MAX_H
w, h = bake(src, out, max_w, max_h)
print(f"wrote {out} ({w}x{h}, {8 + w * h * 4} bytes)")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))
+2 -2
View File
@@ -15,7 +15,7 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
LOVE_NX_TAG="11.5-nx1"
LOVE_NX_DIR="$ROOT/.bazinga/love-nx/$LOVE_NX_TAG"
LOVE_ELF="$LOVE_NX_DIR/love.elf"
ICON="$ROOT/assets/switch/icon.jpg"
ICON="$ROOT/ports/switch/assets/icon.jpg"
APP_NAME="gen1recomp"
APP_AUTHOR="bryanthaboi, port by andrewqsantos"
DKP_IMAGE_FILE="$ROOT/scripts/switch/dkp-docker.image"
@@ -93,7 +93,7 @@ run_fused_docker() {
set -euo pipefail
nacptool --create '$APP_NAME' '$APP_AUTHOR' '$VERSION' /work/control.nacp
elf2nro /src/.bazinga/love-nx/$LOVE_NX_TAG/love.elf /out/$out_base \
--icon=/src/assets/switch/icon.jpg \
--icon=/src/ports/switch/assets/icon.jpg \
--nacp=/work/control.nacp \
--romfsdir=/work/romfs
"
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env bash
# Build the native Switch OTA launcher NRO (DEVKITPRO or Docker).
#
# Usage: scripts/switch/build_ota_launcher.sh [OUT_NRO] [VERSION]
#
# Default OUT_NRO: dist/switch/gen1recomp-launcher.nro
# VERSION (optional X.Y.Z) is written into the NACP so hbmenu shows the
# same release as the fused game; defaults to 0.0.0 when omitted.
# Host-only protocol check: always runs `make host-test` first.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=common.sh
. "$SCRIPT_DIR/common.sh"
LAUNCHER_DIR="$ROOT/ports/switch/ota-launcher"
OUT_NRO="${1:-$ROOT/dist/switch/gen1recomp-launcher.nro}"
APP_VERSION="${2:-${GEN1_LAUNCHER_VERSION:-0.0.0}}"
DKP_IMAGE_FILE="$ROOT/scripts/switch/dkp-docker.image"
[ -d "$LAUNCHER_DIR" ] || fail "missing $LAUNCHER_DIR"
[ -f "$LAUNCHER_DIR/Makefile" ] || fail "missing Makefile"
if ! devkitpro_ready; then
fail_missing_devkitpro
fi
say "host-test ota_protocol (no DEVKITPRO required)"
make -C "$LAUNCHER_DIR" host-test
mkdir -p "$(dirname "$OUT_NRO")"
if ota_launcher_deps_ready; then
say "building launcher NRO with native DEVKITPRO (NACP $APP_VERSION)"
make -C "$LAUNCHER_DIR" clean || true
if ! make -C "$LAUNCHER_DIR" all APP_VERSION="$APP_VERSION"; then
fail_ota_launcher_toolchain
fi
cp "$LAUNCHER_DIR/gen1recomp.nro" "$OUT_NRO"
say "wrote $OUT_NRO"
exit 0
fi
resolve_dkp_image() {
if [ -n "${GEN1_DKP_IMAGE:-}" ]; then
printf '%s' "$GEN1_DKP_IMAGE"
return 0
fi
[ -f "$DKP_IMAGE_FILE" ] || fail "missing Docker image pin: $DKP_IMAGE_FILE"
local line
line="$(grep -v '^[[:space:]]*#' "$DKP_IMAGE_FILE" | grep -v '^[[:space:]]*$' | head -1 || true)"
[ -n "$line" ] || fail "empty Docker image pin: $DKP_IMAGE_FILE"
printf '%s' "$line"
}
if command -v docker >/dev/null 2>&1; then
image="$(resolve_dkp_image)"
say "building launcher NRO via Docker ($image, NACP $APP_VERSION)"
if ! docker run --rm \
-v "$ROOT:/work" \
-w /work/ports/switch/ota-launcher \
-e "APP_VERSION=$APP_VERSION" \
"$image" \
bash -lc 'pacman -Sy --noconfirm switch-curl switch-mbedtls switch-zlib switch-zziplib >/dev/null 2>&1 || true; make clean; make all APP_VERSION="$APP_VERSION"'; then
fail_ota_launcher_toolchain
fi
cp "$LAUNCHER_DIR/gen1recomp.nro" "$OUT_NRO"
say "wrote $OUT_NRO"
exit 0
fi
fail_missing_ota_deps
+126 -4
View File
@@ -8,7 +8,8 @@ if [ -z "${ROOT:-}" ]; then
fi
export ROOT
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
# Progress on stderr so command-substitution (e.g. pack_game_love) stays a bare path.
say() { printf '\033[1;32m==>\033[0m %s\n' "$*" >&2; }
fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
# Print SHA-256 hex digest of PATH. Prefers shasum, falls back to sha256sum.
@@ -38,16 +39,137 @@ fail_need_fetch() {
fail "${1:-missing pinned love-nx} — run: scripts/build_switch.sh --fetch"
}
# OTA launcher native build packages (shared with install_devkitpro_deps.sh).
OTA_LAUNCHER_PKGS=(switch-curl switch-mbedtls switch-zlib switch-zziplib)
ota_launcher_deps_ready() {
command -v dkp-pacman >/dev/null 2>&1 || return 1
local pkg
for pkg in "${OTA_LAUNCHER_PKGS[@]}"; do
dkp-pacman -Q "$pkg" >/dev/null 2>&1 || return 1
done
}
# Space-separated list of missing OTA launcher packages (empty when all installed).
ota_launcher_missing_pkgs() {
command -v dkp-pacman >/dev/null 2>&1 || return 0
local pkg missing=""
for pkg in "${OTA_LAUNCHER_PKGS[@]}"; do
if ! dkp-pacman -Q "$pkg" >/dev/null 2>&1; then
missing="${missing} ${pkg}"
fi
done
printf '%s' "$missing"
}
devkitpro_ready() {
[ -n "${DEVKITPRO:-}" ] && [ -f "$DEVKITPRO/libnx/switch_rules" ]
}
fail_missing_devkitpro() {
fail "$(cat <<'EOF'
--fused requires DEVKITPRO (release builds ship the OTA launcher for in-console updates).
One-time setup on the Mac self-hosted runner (or your dev machine):
1. Install devkitPro pacman: https://devkitpro.org/wiki/devkitPro_pacman
2. Install Switch tools: sudo dkp-pacman -S switch-dev
3. Export DEVKITPRO (typical macOS):
export DEVKITPRO=/opt/devkitpro
export PATH="$DEVKITPRO/tools/bin:$PATH"
4. OTA launcher toolchain (native or Docker — pick one):
bash scripts/switch/install_devkitpro_deps.sh
or install Docker (same pin as fused builds)
Then re-run: scripts/build_switch.sh --fetch --fused
See docs/switch-build.md (Runner provisioning).
EOF
)"
}
fail_missing_ota_deps() {
local missing
missing="$(ota_launcher_missing_pkgs)"
fail "$(cat <<EOF
--fused needs a way to build the OTA launcher: native dkp packages or Docker.
Missing dkp packages:${missing:- (dkp-pacman not on PATH)}
Pick one:
bash scripts/switch/install_devkitpro_deps.sh
or install Docker (build_ota_launcher.sh uses the pinned devkitPro image)
Then re-run: scripts/build_switch.sh --fetch --fused
See docs/switch-build.md and ports/switch/ota-launcher/README.md.
EOF
)"
}
fail_ota_launcher_toolchain() {
if ! devkitpro_ready; then
fail_missing_devkitpro
fi
if ota_launcher_deps_ready; then
fail "$(cat <<'EOF'
OTA launcher native build failed after host-test passed.
Re-run with a clean tree:
scripts/switch/build_ota_launcher.sh
If packages look wrong, reinstall:
bash scripts/switch/install_devkitpro_deps.sh
See ports/switch/ota-launcher/README.md.
EOF
)"
fi
fail_missing_ota_deps
}
# Fail fast before packing when --fused is requested.
preflight_fused_build() {
if ! devkitpro_ready; then
fail_missing_devkitpro
fi
ensure_dkp_tools_path
local game_ok=0
if command -v nacptool >/dev/null 2>&1 && command -v elf2nro >/dev/null 2>&1; then
game_ok=1
elif command -v docker >/dev/null 2>&1; then
game_ok=1
fi
if [ "$game_ok" -eq 0 ]; then
fail_fused_toolchain
fi
if ota_launcher_deps_ready; then
return 0
fi
if command -v docker >/dev/null 2>&1; then
return 0
fi
fail_missing_ota_deps
}
# Fused mode needs nacptool/elf2nro (native or Docker). Rich multi-OS hint.
fail_fused_toolchain() {
fail "$(cat <<'EOF'
fused packaging needs nacptool and elf2nro (devkitPro switch-dev).
fused packaging needs DEVKITPRO, nacptool, and elf2nro (devkitPro switch-dev).
Install options:
macOS: https://devkitpro.org/wiki/devkitPro_pacman (installer / pacman)
macOS: https://devkitpro.org/wiki/devkitPro_pacman
sudo dkp-pacman -S switch-dev
OTA launcher (native or Docker):
bash scripts/switch/install_devkitpro_deps.sh
or install Docker
Linux: https://devkitpro.org/wiki/devkitPro_pacman
Windows: Git Bash / MSYS2 / WSL with devkitPro tools on PATH
Docker: install Docker; build_fused.sh falls back to the pinned image
Docker: install Docker; fused game and OTA launcher can build in-container
Export DEVKITPRO (typical): export DEVKITPRO=/opt/devkitpro
See docs/switch-build.md for details.
EOF
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
# Install DEVKITPRO Switch packages needed for the native OTA launcher.
# Run this in Terminal.app / iTerm (needs your macOS admin password):
#
# bash scripts/switch/install_devkitpro_deps.sh
#
# Optional: speeds up local native OTA launcher builds. CI/release can fall back
# to Docker when these packages are not installed.
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=common.sh
. "$SCRIPT_DIR/common.sh"
if [ "$(id -u)" -ne 0 ]; then
echo "==> re-running with sudo…"
exec sudo -E bash "$0" "$@"
fi
export DEVKITPRO="${DEVKITPRO:-/opt/devkitpro}"
export PATH="/usr/local/bin:$DEVKITPRO/pacman/bin:$DEVKITPRO/tools/bin:$PATH"
if [ -f /etc/profile.d/devkit-env.sh ]; then
# shellcheck disable=SC1091
. /etc/profile.d/devkit-env.sh
fi
if ! command -v dkp-pacman >/dev/null 2>&1; then
echo "error: dkp-pacman not found. Install the pkg from:" >&2
echo " https://github.com/devkitPro/pacman/releases/latest" >&2
exit 1
fi
if ! dkp-pacman -Q switch-dev >/dev/null 2>&1; then
echo "error: switch-dev is not installed (needed for nacptool/elf2nro)." >&2
echo " sudo dkp-pacman -S switch-dev" >&2
echo "Then re-run: bash scripts/switch/install_devkitpro_deps.sh" >&2
exit 1
fi
echo "==> DEVKITPRO=$DEVKITPRO"
# Named packages only (avoid interactive switch-dev group member prompt).
# ZIP reading uses switch-zziplib (dkp has no minizip port for Switch).
dkp-pacman -Sy --noconfirm --needed "${OTA_LAUNCHER_PKGS[@]}"
echo "==> installed packages:"
dkp-pacman -Q | grep -E 'switch-(curl|mbedtls|zlib|zziplib|tools)|libnx|devkitA64' || true
# Ensure zsh/bash sessions see DEVKITPRO
MARKER="# gen1recomp-devkitpro"
for rc in /Users/*/.zshrc /Users/*/.bash_profile; do
[ -f "$rc" ] || continue
if ! grep -q "$MARKER" "$rc" 2>/dev/null; then
{
echo ""
echo "$MARKER"
echo "export DEVKITPRO=/opt/devkitpro"
echo 'export PATH="$DEVKITPRO/tools/bin:$DEVKITPRO/devkitA64/bin:$PATH"'
} >> "$rc"
echo "==> appended DEVKITPRO exports to $rc"
fi
done
echo "==> done. Open a new shell, then:"
echo " export DEVKITPRO=/opt/devkitpro"
echo " scripts/switch/build_ota_launcher.sh"
+16
View File
@@ -0,0 +1,16 @@
# Switch OTA launcher packaging manifest
#
# Release / SD tree MUST include the native OTA launcher NRO as the hbmenu
# entrypoint. The LÖVE fused game NRO is loaded via envSetNextLoad after the
# update check. Self-update of the launcher itself is out of scope for v1.
#
# OTA downloads the install zip.
#
# Paths relative to microSD root:
ENTRY_NRO=switch/gen1recomp/gen1recomp.nro
GAME_NRO=switch/gen1recomp/gen1recomp-game.nro
SAVE_DIR=switch/gen1recomp/pokemon-love2d
OTA_ASSET_GLOB=gen1recomp-*-switch.zip
REQUIRE_SHA256SUMS=1
OTA_ENABLED=1
+28 -7
View File
@@ -2,16 +2,18 @@
# Pack a Switch SD-ready zip: extract at microSD root (merge-safe update).
#
# Usage:
# scripts/switch/pack_sd_zip.sh NRO_PATH VERSION OUT_ZIP
# scripts/switch/pack_sd_zip.sh GAME_NRO VERSION OUT_ZIP [LAUNCHER_NRO]
#
# Layout inside the zip (SD root):
# switch/gen1recomp/gen1recomp.nro
# switch/gen1recomp/gen1recomp.nro (launcher if LAUNCHER_NRO set, else game)
# switch/gen1recomp/gen1recomp-game.nro (only when LAUNCHER_NRO set)
# switch/gen1recomp/version.txt
# switch/gen1recomp/INSTALL.txt
# switch/gen1recomp/pokemon-love2d/imports/.../README.txt
# switch/gen1recomp/pokemon-love2d/exports/.../README.txt
#
# Does not ship ROMs, saves, or mods. Re-extracting merges over an existing
# install and only overwrites the NRO + these text placeholders — keep
# install and only overwrites the NRO(s) + these text placeholders — keep
# pokemon-love2d/ to preserve progress.
set -euo pipefail
@@ -23,11 +25,15 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
NRO_PATH="${1:-}"
VERSION="${2:-}"
OUT_ZIP="${3:-}"
LAUNCHER_NRO="${4:-}"
[ -n "$NRO_PATH" ] && [ -n "$VERSION" ] && [ -n "$OUT_ZIP" ] \
|| fail "usage: scripts/switch/pack_sd_zip.sh NRO_PATH VERSION OUT_ZIP"
|| fail "usage: scripts/switch/pack_sd_zip.sh GAME_NRO VERSION OUT_ZIP [LAUNCHER_NRO]"
[ -f "$NRO_PATH" ] || fail "missing NRO at $NRO_PATH"
[ -f "$NRO_PATH" ] || fail "missing game NRO at $NRO_PATH"
if [ -n "$LAUNCHER_NRO" ]; then
[ -f "$LAUNCHER_NRO" ] || fail "missing launcher NRO at $LAUNCHER_NRO"
fi
command -v zip >/dev/null 2>&1 || fail "need zip on PATH"
@@ -45,7 +51,15 @@ trap cleanup EXIT
APP_DIR="$STAGE/switch/gen1recomp"
SAVE_ROOT="$APP_DIR/pokemon-love2d"
mkdir -p "$APP_DIR"
cp "$NRO_PATH" "$APP_DIR/gen1recomp.nro"
if [ -n "$LAUNCHER_NRO" ]; then
LAUNCHER_NRO="$(cd "$(dirname "$LAUNCHER_NRO")" && pwd)/$(basename "$LAUNCHER_NRO")"
cp "$LAUNCHER_NRO" "$APP_DIR/gen1recomp.nro"
cp "$NRO_PATH" "$APP_DIR/gen1recomp-game.nro"
else
cp "$NRO_PATH" "$APP_DIR/gen1recomp.nro"
fi
printf '%s\n' "$VERSION" > "$APP_DIR/version.txt"
cat > "$APP_DIR/INSTALL.txt" <<EOF
gen1recomp Switch (v${VERSION})
@@ -55,7 +69,7 @@ First install or update (same steps):
1. Extract this zip at the root of your microSD (merge folders if asked).
2. Do NOT delete switch/gen1recomp/pokemon-love2d/ — that folder holds
your saves, imported ROMs, mods, and options. Re-extracting only
replaces gen1recomp.nro and these help files.
replaces the NRO(s) and these help files.
3. Launch with title override (hold R on HOME, open any title → hbmenu).
4. Copy a legal Pokemon Red/Blue .gb into:
switch/gen1recomp/pokemon-love2d/imports/
@@ -106,9 +120,16 @@ rm -f "$OUT_ZIP"
LISTING="$(unzip -Z1 "$OUT_ZIP" 2>/dev/null || unzip -l "$OUT_ZIP")"
printf '%s\n' "$LISTING" | grep -q 'switch/gen1recomp/gen1recomp.nro' \
|| fail "zip missing switch/gen1recomp/gen1recomp.nro"
printf '%s\n' "$LISTING" | grep -Fq 'switch/gen1recomp/version.txt' \
|| fail "zip missing switch/gen1recomp/version.txt"
if [ -n "$LAUNCHER_NRO" ]; then
printf '%s\n' "$LISTING" | grep -Fq 'switch/gen1recomp/gen1recomp-game.nro' \
|| fail "zip missing switch/gen1recomp/gen1recomp-game.nro (OTA dual-NRO layout)"
fi
REQUIRED=(
"switch/gen1recomp/INSTALL.txt"
"switch/gen1recomp/version.txt"
"switch/gen1recomp/pokemon-love2d/imports/README.txt"
"switch/gen1recomp/pokemon-love2d/imports/mods/README.txt"
"switch/gen1recomp/pokemon-love2d/imports/saves/red/README.txt"
+126 -2
View File
@@ -51,10 +51,11 @@ MISSING=""
printf '%s' "$HELP_LC" | grep -q 'fetch' || MISSING="${MISSING} fetch"
printf '%s' "$HELP_LC" | grep -q 'loose' || MISSING="${MISSING} loose"
printf '%s' "$HELP_LC" | grep -q 'fused' || MISSING="${MISSING} fused"
printf '%s' "$HELP_LC" | grep -Eq 'devkitpro' || MISSING="${MISSING} DEVKITPRO"
printf '%s' "$HELP_LC" | grep -Eq 'auto-download|downloads' || MISSING="${MISSING} auto-download"
printf '%s' "$HELP_LC" | grep -Eq 'non-goal|does not|never' || MISSING="${MISSING} non-goals"
if [ -z "$MISSING" ]; then
ok "build_switch.sh --help mentions fetch, loose, fused (+ auto-download/non-goals)"
ok "build_switch.sh --help mentions fetch, loose, fused, DEVKITPRO (+ auto-download/non-goals)"
else
bad "build_switch.sh --help missing:$MISSING"
fi
@@ -180,7 +181,41 @@ else
fi
# ---------------------------------------------------------------------------
# 5c. fail_fused_toolchain mentions docs/switch-build.md
# 5c2. fail_missing_devkitpro cites install_devkitpro_deps.sh
# ---------------------------------------------------------------------------
MDK_ERR="$STAGING/missing-dkp.err"
MDK_RC=0
(
. "$SCRIPT_DIR/common.sh"
fail_missing_devkitpro
) >"$STAGING/missing-dkp.out" 2>"$MDK_ERR" || MDK_RC=$?
if [ "$MDK_RC" -ne 0 ] \
&& grep -q 'install_devkitpro_deps.sh' "$MDK_ERR" \
&& grep -q 'DEVKITPRO' "$MDK_ERR" \
&& grep -q 'switch-dev' "$MDK_ERR"; then
ok "fail_missing_devkitpro cites DEVKITPRO + switch-dev + install_devkitpro_deps.sh"
else
bad "fail_missing_devkitpro should cite setup steps (rc=$MDK_RC err=$(cat "$MDK_ERR"))"
fi
OTA_ERR="$STAGING/missing-ota.err"
OTA_RC=0
(
. "$SCRIPT_DIR/common.sh"
fail_missing_ota_deps
) >"$STAGING/missing-ota.out" 2>"$OTA_ERR" || OTA_RC=$?
if [ "$OTA_RC" -ne 0 ] \
&& grep -q 'install_devkitpro_deps.sh' "$OTA_ERR" \
&& grep -qi 'docker' "$OTA_ERR"; then
ok "fail_missing_ota_deps cites native or Docker options"
else
bad "fail_missing_ota_deps should cite native + Docker (rc=$OTA_RC err=$(cat "$OTA_ERR"))"
fi
# ---------------------------------------------------------------------------
# 5d. fail_fused_toolchain mentions docs/switch-build.md
# ---------------------------------------------------------------------------
FT_ERR="$STAGING/fused-toolchain.err"
FT_RC=0
@@ -228,6 +263,7 @@ ZIP_LIST="$(unzip -Z1 "$FAKE_ZIP" 2>/dev/null || unzip -l "$FAKE_ZIP")"
PACK_MISSING=""
for rel in \
"switch/gen1recomp/gen1recomp.nro" \
"switch/gen1recomp/version.txt" \
"switch/gen1recomp/INSTALL.txt" \
"switch/gen1recomp/pokemon-love2d/imports/README.txt" \
"switch/gen1recomp/pokemon-love2d/imports/mods/README.txt" \
@@ -311,6 +347,94 @@ else
bad "pack_sd_zip.sh merge update lost user data or failed to replace NRO"
fi
# ---------------------------------------------------------------------------
# 7. Dual-NRO OTA layout (launcher + game) when 4th arg set
# ---------------------------------------------------------------------------
FAKE_LAUNCHER="$STAGING/fake-launcher.nro"
FAKE_GAME="$STAGING/fake-game.nro"
printf 'fake-launcher\n' > "$FAKE_LAUNCHER"
printf 'fake-game\n' > "$FAKE_GAME"
OTA_ZIP="$STAGING/gen1recomp-0.0.2-ota-layout-switch.zip"
OTA_RC=0
"$ROOT/scripts/switch/pack_sd_zip.sh" "$FAKE_GAME" "0.0.2" "$OTA_ZIP" "$FAKE_LAUNCHER" \
>"$STAGING/ota-pack.out" 2>"$STAGING/ota-pack.err" || OTA_RC=$?
OTA_LIST="$(unzip -Z1 "$OTA_ZIP" 2>/dev/null || true)"
if [ "$OTA_RC" -eq 0 ] \
&& printf '%s\n' "$OTA_LIST" | grep -Fq 'switch/gen1recomp/gen1recomp.nro' \
&& printf '%s\n' "$OTA_LIST" | grep -Fq 'switch/gen1recomp/gen1recomp-game.nro' \
&& printf '%s\n' "$OTA_LIST" | grep -Fq 'switch/gen1recomp/version.txt'
then
EXTRACT_OTA="$STAGING/extract-ota"
rm -rf "$EXTRACT_OTA"
mkdir -p "$EXTRACT_OTA"
unzip -q "$OTA_ZIP" -d "$EXTRACT_OTA"
DUAL_OK=1
cmp -s "$FAKE_LAUNCHER" "$EXTRACT_OTA/switch/gen1recomp/gen1recomp.nro" || DUAL_OK=0
cmp -s "$FAKE_GAME" "$EXTRACT_OTA/switch/gen1recomp/gen1recomp-game.nro" || DUAL_OK=0
[ "$(cat "$EXTRACT_OTA/switch/gen1recomp/version.txt")" = "0.0.2" ] || DUAL_OK=0
if [ "$DUAL_OK" -eq 1 ]; then
ok "pack_sd_zip.sh dual-NRO OTA layout (launcher + game + version.txt)"
else
bad "pack_sd_zip.sh dual-NRO bytes/version mismatch"
fi
else
bad "pack_sd_zip.sh dual-NRO layout failed (rc=$OTA_RC err=$(cat "$STAGING/ota-pack.err"))"
fi
MANIFEST="$ROOT/scripts/switch/ota_launcher.manifest"
if [ -f "$MANIFEST" ] && grep -q '^OTA_ENABLED=1$' "$MANIFEST" \
&& grep -q 'ENTRY_NRO=switch/gen1recomp/gen1recomp.nro' "$MANIFEST" \
&& grep -q 'GAME_NRO=switch/gen1recomp/gen1recomp-game.nro' "$MANIFEST"
then
ok "ota_launcher.manifest requires dual-NRO when OTA_ENABLED=1"
else
bad "ota_launcher.manifest missing OTA dual-NRO requirements"
fi
if [ -f "$ROOT/scripts/switch/build_ota_launcher.sh" ] \
&& grep -q 'ota_launcher_deps_ready' "$ROOT/scripts/switch/build_ota_launcher.sh" \
&& grep -q 'fail_missing_devkitpro' "$ROOT/scripts/switch/build_ota_launcher.sh" \
&& grep -q 'preflight_fused_build' "$ROOT/scripts/build_switch.sh" \
&& [ -f "$ROOT/ports/switch/ota-launcher/Makefile" ]
then
ok "native OTA launcher sources + build_ota_launcher.sh present"
else
bad "missing native OTA launcher tree or build script"
fi
# ---------------------------------------------------------------------------
# 8. Unified OTA asset = dual-NRO SD zip (no separate OTA-only archive)
# ---------------------------------------------------------------------------
LEGACY_OTA_PACKER="$ROOT/scripts/switch/pack_ota_zip.sh"
if [ ! -f "$LEGACY_OTA_PACKER" ] \
&& ! grep -Eq 'pack_ota_zip\.sh|switch-ota\.zip' "$ROOT/scripts/build_switch.sh" \
&& grep -q 'OTA_ASSET_GLOB=gen1recomp-\*-switch.zip' "$MANIFEST"
then
ok "OTA uses the same SD zip as install (legacy OTA-only packer gone)"
else
bad "legacy separate OTA-only packer / asset still present"
fi
if grep -q 'ota_ui_prompt_update' "$ROOT/ports/switch/ota-launcher/src/main.c" \
&& grep -q 'ota_net_init' "$ROOT/ports/switch/ota-launcher/src/main.c" \
&& grep -q 'framebufferCreate\|COL_RAIL' "$ROOT/ports/switch/ota-launcher/src/ota_ui.c" \
&& grep -q '^ROMFS' "$ROOT/ports/switch/ota-launcher/Makefile" \
&& grep -q 'cacert.pem' "$ROOT/ports/switch/ota-launcher/Makefile"
then
ok "launcher UI uses framebuffer (no prompt when up to date)"
else
bad "launcher missing branded ota_ui / ROMFS"
fi
if [ -f "$ROOT/ports/switch/ota-launcher/src/ota_unzip.c" ] \
&& grep -q 'zzip/zzip.h' "$ROOT/ports/switch/ota-launcher/src/ota_unzip.c" \
&& grep -q 'ota_unzip_extract_file' "$ROOT/ports/switch/ota-launcher/src/main.c"
then
ok "launcher wires zziplib ota_unzip_extract_file"
else
bad "launcher missing zziplib unzip wiring"
fi
# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------
+6 -5
View File
@@ -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 = {
@@ -40,7 +41,7 @@ GameVersion.VERSIONS = {
id = "yellow",
label = "Yellow",
displayName = "Pokemon Yellow",
launcherName = "Yellow (alpha)",
launcherName = "Yellow",
sha1 = "cc7d03262ebfaf2f06772c1a480c7d9d5f4a38e1",
manifest = "tools/rom_manifest_yellow.json",
cachePrefix = "yellow/", -- yellow/data/generated, yellow/assets/generated
+1 -1
View File
@@ -1,5 +1,5 @@
-- Shared gamepad + raw joystick button tables for launcher and gameplay.
-- Hardware-measured NX overrides live in NX_* tables (see docs/switch-development.md).
-- Hardware-measured NX overrides live in NX_* tables below.
local GamepadMap = {}
+154 -15
View File
@@ -88,14 +88,60 @@ function HostShell.releasePointerGrab()
end
end
-- POPEN IS NOT THREAD SAFE, and this app calls it from four threads (the main
-- one, the update checker, and a pool of three fetch workers).
--
-- On Darwin, popen() flushes every open stream first: _fwalk walks libc's
-- global FILE list and locks each entry as it goes. pclose() frees a FILE and
-- takes it off that list. Run the two concurrently and the walker can end up
-- waiting on the lock of a FILE another thread has already freed -- a wait
-- that nothing will ever satisfy. That is the launcher freezing on close
-- after a visit to the mod tabs: sampling a hung process shows a fetch worker
-- parked in popen -> _fwalk -> flockfile with NO curl running anywhere on the
-- machine, and the main thread blocked in Thread:wait() for that worker, which
-- is why LOVE never reaches the process exit.
--
-- The fix is a process-wide mutex around the two list-mutating calls, and only
-- those: a LOVE Channel's performAtomic runs its callback holding the
-- channel's own mutex, which is the one lock primitive shared across love
-- threads. Reading a pipe stays outside it, so the fetch pool still runs its
-- transfers in parallel -- a spawn is microseconds, a transfer is seconds.
local POPEN_LOCK = "hostshell_popen_lock"
local function popenLock()
if not (love and love.thread and love.thread.getChannel) then return nil end
local ok, ch = pcall(love.thread.getChannel, POPEN_LOCK)
return ok and ch or nil
end
-- Run `fn` with the spawn lock held, or plain when there is no love.thread to
-- take one from (the headless test stub, a plain luajit run).
local function withPopenLock(fn)
local ch = popenLock()
if not ch then return fn() end
local okAtomic = pcall(function() ch:performAtomic(fn) end)
if not okAtomic then fn() end
end
-- Wraps io.popen with the AppImage env fix applied and lua errors swallowed
function HostShell.popen(command, mode)
HostShell.releasePointerGrab()
local ok, pipe = pcall(io.popen, HostShell.envPrefix() .. command, mode or "r")
if not ok or not pipe then return nil end
local pipe
withPopenLock(function()
local ok, p = pcall(io.popen, HostShell.envPrefix() .. command, mode or "r")
pipe = (ok and p) or nil
end)
return pipe
end
-- Close a pipe HostShell.popen opened. Callers MUST use this rather than
-- pipe:close(): pclose is the other half of the race above, and a close that
-- skips the lock can free a FILE out from under another thread's spawn.
function HostShell.pclose(pipe)
if not pipe then return end
withPopenLock(function() pcall(function() pipe:close() end) end)
end
-- Restart the whole app. The obvious love.event.quit("restart") re-runs LÖVE's
-- boot in-process, which calls love.filesystem.init a second time -- and inside
-- an AppImage physfs is already initialized, so that second init throws
@@ -157,6 +203,65 @@ end
-- block the calling thread and deal in whole files, so callers keep exactly
-- the contract they had with curl.
-- DIAGNOSING A FAILED FETCH. curl's own stderr ("curl: (56) The requested
-- URL returned error: 403") went straight to the terminal, naming neither the
-- URL nor which of the launcher's many fetches produced it, while the caller
-- got back a generic "empty response". Both curl branches below now merge
-- stderr into the pipe and ask curl for the HTTP status with --write-out, so
-- the message that reaches the UI and the log says which URL failed and how.
--
-- The status rides a marker rather than a bare "%{http_code}": a GET streams
-- its body through the same pipe, so the code has to be findable at the end
-- of arbitrary text. Matched from the END, and only the last occurrence is
-- cut, so a body that happens to contain the marker keeps its content.
-- Two spellings on purpose. HTTP_MARK is what comes back down the pipe; the
-- FMT one is what goes to curl, where the newline MUST be the two characters
-- backslash-n (curl expands the escape itself). A literal newline inside the
-- argument would be quoted fine by a POSIX shell and be a syntax error in
-- cmd.exe, which has no multi-line quoted string.
local HTTP_MARK = "\n__gen1recomp_http__"
local HTTP_MARK_FMT = "\\n__gen1recomp_http__%{http_code}"
-- Split a curl pipe's output into (body, status, noise). `status` is nil
-- when curl never got far enough to have one (DNS failure, no route, a
-- timeout), in which case `noise` carries curl's own complaint.
local function splitCurlOutput(out)
out = tostring(out or "")
local at = nil
local from = 1
while true do
local s = out:find(HTTP_MARK, from, true)
if not s then break end
at, from = s, s + 1
end
if not at then return out, nil, out end
local body = out:sub(1, at - 1)
local code = tonumber(out:sub(at + #HTTP_MARK):match("^(%d+)"))
-- curl writes http_code 0 when it never got a response at all (DNS, no
-- route, connect timeout). That is not a status, and reporting it as
-- "HTTP 0" buries the real reason, which is in curl's own message.
if code == 0 then code = nil end
return body, code, body
end
-- The error string a caller (and the launcher's notice line) sees. It always
-- names the URL, because "403" on its own is unactionable when the launcher
-- has an index feed, a releases API and a page of thumbnails in flight.
local function fetchError(url, status, noise)
if status then
local extra = (noise or ""):gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "")
if #extra > 160 then extra = extra:sub(1, 157) .. "..." end
if extra ~= "" then
return ("HTTP %d from %s (%s)"):format(status, url, extra)
end
return ("HTTP %d from %s"):format(status, url)
end
local why = (noise or ""):gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "")
if why == "" then why = "no response" end
if #why > 160 then why = why:sub(1, 157) .. "..." end
return ("fetch failed for %s: %s"):format(url, why)
end
-- Shell quoting for one curl argument; cmd.exe has no single-quote form.
function HostShell.quote(s)
s = tostring(s)
@@ -167,12 +272,21 @@ function HostShell.quote(s)
return "'" .. s:gsub("'", "'\\''") .. "'"
end
-- MEMOISED per Lua state (so once per thread). This used to spawn a whole
-- `curl --version` process on every single fetch -- twice for a GET through
-- the Android-bridge fallback -- which doubled the number of spawns the lock
-- above has to serialise, for an answer that cannot change while the app is
-- running.
local curlAvailable = nil
function HostShell.haveCurl()
if curlAvailable ~= nil then return curlAvailable end
local pipe = HostShell.popen("curl --version")
if not pipe then return false end
if not pipe then curlAvailable = false return false end
local readOk, out = pcall(function() return pipe:read("*a") end)
pcall(function() pipe:close() end)
return readOk and out ~= nil and out:find("curl", 1, true) ~= nil
HostShell.pclose(pipe)
curlAvailable = readOk and out ~= nil and out:find("curl", 1, true) ~= nil
return curlAvailable
end
-- An older mobile build reports nil here and falls back to the "no transport"
@@ -217,11 +331,24 @@ function HostShell.httpDownload(url, absPath, userAgent, accept, maxTime)
if accept then
cmd = cmd .. "-H " .. HostShell.quote("Accept: " .. accept) .. " "
end
cmd = cmd .. "-o " .. HostShell.quote(absPath) .. " " .. HostShell.quote(url)
cmd = cmd .. "-o " .. HostShell.quote(absPath) .. " "
.. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " "
.. HostShell.quote(url) .. " 2>&1"
local pipe = HostShell.popen(cmd)
if not pipe then return nil, "could not start download" end
pcall(function() pipe:read("*a") end)
pcall(function() pipe:close() end)
local readOk, out = pcall(function() return pipe:read("*a") end)
HostShell.pclose(pipe)
-- The file is still what the caller judges success by (-f writes nothing
-- on an HTTP error, and the callers all check the file anyway). The
-- status is here purely so the failure can NAME itself: "download failed"
-- with no URL and no code is the report this whole change exists to fix.
local body, status, noise = splitCurlOutput(readOk and out or "")
if status and (status < 200 or status >= 300) then
return nil, fetchError(url, status, body)
end
if not status and (noise or ""):match("%S") then
return nil, fetchError(url, nil, noise)
end
return true
end
if not haveBridge() then
@@ -229,7 +356,7 @@ function HostShell.httpDownload(url, absPath, userAgent, accept, maxTime)
end
local ok, done = pcall(love.system.httpDownload, url, absPath, userAgent, accept)
if ok and done then return true end
return nil, "download failed"
return nil, "download failed for " .. url
end
-- GET returning the body. curl streams it through a pipe; the Android bridge
@@ -239,20 +366,32 @@ function HostShell.httpGet(url, userAgent, accept, maxTime)
if type(url) ~= "string" or url == "" then return nil, "missing url" end
userAgent = userAgent or "gen1recomp"
if HostShell.haveCurl() then
local cmd = ("curl -fsSL --connect-timeout 10 --max-time %d ")
-- No -f here (the download branch keeps it). -f suppresses the error
-- BODY, and on the two services this talks to that body is the whole
-- diagnosis: GitHub's 403 says "API rate limit exceeded for <ip>", which
-- tells a user to wait rather than to go hunting for a broken index.
local cmd = ("curl -sSL --connect-timeout 10 --max-time %d ")
:format(tonumber(maxTime) or 40)
.. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " "
if accept then
cmd = cmd .. "-H " .. HostShell.quote("Accept: " .. accept) .. " "
end
cmd = cmd .. HostShell.quote(url)
cmd = cmd .. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " "
.. HostShell.quote(url) .. " 2>&1"
local pipe = HostShell.popen(cmd)
if not pipe then return nil, "could not run curl" end
local readOk, out = pcall(function() return pipe:read("*a") end)
pcall(function() pipe:close() end)
if not readOk then return nil, "fetch failed: " .. tostring(out) end
if not out or out == "" then return nil, "empty response from " .. url end
return out
HostShell.pclose(pipe)
if not readOk then
return nil, fetchError(url, nil, tostring(out))
end
local body, status, noise = splitCurlOutput(out)
if not status then return nil, fetchError(url, nil, noise) end
if status < 200 or status >= 300 then
return nil, fetchError(url, status, body)
end
if body == "" then return nil, "empty response from " .. url end
return body
end
if not haveBridge() then
return nil, "no network transport on this platform"
+128 -22
View File
@@ -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
+25 -3
View File
@@ -62,7 +62,7 @@ local FILTERS = { "OFF", "1X", "2X", "3X" }
-- The core rows. Helper modules are required lazily under pcall: they are
-- pure label/cycle tables, but the launcher must never die because a render
-- module grew a dependency on live game data.
local function coreRows(opts)
local function coreRows(opts, hooks)
local rows = {}
local function add(label, value, step)
rows[#rows + 1] = { label = label, value = value, step = step }
@@ -257,6 +257,26 @@ local function coreRows(opts)
end
end
-- TOUCH CONTROLS, the on-screen pad's layout editor. It used to be a
-- button on the game panel, once per game -- but the overlay layout is
-- global (options.touchControls.layouts), so three tabs offered three
-- buttons that edited the same thing while crowding the column that has to
-- hold Play. It belongs with the other control rows, behind the gear.
-- The host owns the editor screen, so the row only fires when a hook was
-- supplied (the standalone save editor opens this model with none).
if hooks and hooks.editTouchControls then
rows[#rows + 1] = {
label = Strings("TOUCH CONTROLS"),
actionLabel = Strings("Edit"),
action = function()
hooks.editTouchControls()
-- The editor replaces the whole screen: nothing left to persist here
-- beyond what the caller already saved on the way out.
return false
end,
}
end
-- RESET REBINDS, directly under the touch-pad row. Rebinds are additive
-- (src/core/Input.lua:applyBindings layers options.bindings over the
-- defaults rather than replacing them), so a player who has bound
@@ -417,10 +437,12 @@ end
-- sections of rows, and a save() that persists it. The caller keeps the
-- model for as long as the panel is open; nothing else in the launcher
-- writes options while a modal covers it, so the cached table stays true.
function LauncherSettings.open()
-- `hooks` carries the host actions a row cannot perform itself:
-- editTouchControls() -- hand the screen to the touch-overlay editor
function LauncherSettings.open(hooks)
local opts = SaveData.loadOptions()
local sections = {
{ title = Strings("OPTIONS"), rows = coreRows(opts) },
{ title = Strings("OPTIONS"), rows = coreRows(opts, hooks) },
}
for _, mod in ipairs(discoverModSchemas(opts)) do
local rows = modRows(opts, mod)
File diff suppressed because it is too large Load Diff
+135 -40
View File
@@ -32,7 +32,7 @@ end
-- carry Red's bank $1f header, wave-table, and CryData offsets.
local CACHE_FORMAT = "rom-cache-v9:"
-- 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
@@ -135,8 +135,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
@@ -153,11 +153,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()
@@ -214,8 +218,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
@@ -328,12 +332,16 @@ local function commandOutput(command)
local pipe = HostShell.popen(command)
if not pipe then return nil end
local result = pipe:read("*a")
pipe:close()
-- HostShell.pclose, never pipe:close(): closing a pipe outside the spawn
-- lock can free a FILE while a worker thread's popen is walking the stream
-- list, which deadlocks that thread for good (see HostShell).
HostShell.pclose(pipe)
result = trim(result)
return result ~= "" and result or nil
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
@@ -467,6 +475,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
@@ -1043,6 +1110,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
@@ -1121,6 +1191,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
@@ -1137,6 +1212,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
@@ -1743,6 +1819,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
@@ -1865,6 +1956,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.
@@ -1909,6 +2001,12 @@ function RomImporter:update(dt)
"Mods are not reviewed - trust the author." },
}
end
-- POKEPORT_LAUNCHER_SETTINGS=1 opens the gear panel, the other layout
-- a capture cannot otherwise reach without a click. Pair it with
-- POKEPORT_LAUNCHER_SETTINGS_PAGE to land on a page past the first.
if os.getenv("POKEPORT_LAUNCHER_SETTINGS") == "1" then
self:_openSettings()
end
local query = os.getenv("POKEPORT_LAUNCHER_QUERY")
if query and query ~= "" then
self.findQuery = query
@@ -2275,6 +2373,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)
@@ -2450,8 +2552,19 @@ end
-- ------- settings gear (options.lua + enabled mods' option schemas)
function RomImporter:_openSettings()
-- The touch-overlay editor is a host screen, so the model gets it as a
-- hook rather than reaching for main.lua's handler itself. Closing the
-- settings panel FIRST persists the pending edits (_closeSettings saves)
-- and leaves no modal behind the editor to return to.
local hooks = {}
if self.onEditTouchControls then
hooks.editTouchControls = function()
self:_closeSettings()
self.onEditTouchControls()
end
end
local ok, model = pcall(function()
return require("src.import.LauncherSettings").open()
return require("src.import.LauncherSettings").open(hooks)
end)
if ok and model then self._settings = model end
end
@@ -3334,34 +3447,11 @@ function RomImporter:_pumpFindFetch()
self:_clearBusy()
end
-- Clear every input rebind and the dragged touch-overlay layout, restoring
-- the stock keyboard/gamepad bindings. Rebinds are ADDITIVE
-- (src/core/Input.lua:applyBindings layers options.bindings over the
-- defaults instead of replacing them), so a player who has bound themselves
-- into a corner has no in-game way out; this is it. The running game reads
-- bindings on its next start, which is the same contract every other
-- launcher setting has.
function RomImporter:_resetRebinds()
local ok = pcall(function()
local SaveData = require("src.core.SaveData")
local opts = SaveData.loadOptions()
opts.bindings = nil
if type(opts.touchControls) == "table" then
opts.touchControls.layouts = nil
end
SaveData.saveOptions(opts)
end)
-- Its own notice slot: this button lives on the game panel, and borrowing
-- the mods or save notice would print the result on a tab the user is not
-- looking at.
if ok then
self.controlsNotice = { ok = true,
text = Strings("Controls reset to defaults. Applies on the next start.") }
else
self.controlsNotice = { ok = false,
text = Strings("Could not reset controls.") }
end
end
-- Clearing rebinds used to live here, behind a button on the game panel. It
-- is now the RESET REBINDS row of the settings model
-- (src/import/LauncherSettings.lua), which edits the same options table the
-- rest of that panel does and saves through the same save() -- one control
-- for a setting that was never per-game in the first place.
-- ------- busy state (drives the non-dismissable loader overlay)
-- Anything that makes the user wait sets this; LauncherView renders it as a
@@ -3440,7 +3530,12 @@ function RomImporter:_findThumb(entry)
:format(tostring(entry.id):gsub("[^%w%-_]", "_"), ext)
local Fetch = require("src.net.Fetch")
self._findThumbFetch[entry.id] = {
job = Fetch.download(url, name, { userAgent = "gen1recomp-mod-index" }),
-- A short ceiling on purpose: a page of these is queued at once, and
-- each one's ceiling is part of the worst case for closing the window
-- (Fetch.shutdown). A thumbnail that has not arrived in 15s is not
-- worth holding the process open for -- the card shows its placeholder.
job = Fetch.download(url, name,
{ userAgent = "gen1recomp-mod-index", maxSeconds = 15 }),
}
end
return nil
+30 -5
View File
@@ -29,6 +29,7 @@ local Fetch = {}
local CMD = "fetch_cmd"
local RESULT = "fetch_result"
local QUIT = "fetch_quit"
-- Worker count. Three is enough to overlap the common burst (a mod index
-- refresh plus a couple of per-mod release checks) without spawning a thread
@@ -36,7 +37,7 @@ local RESULT = "fetch_result"
local POOL = 3
local workers = {}
local cmdCh, resCh
local cmdCh, resCh, quitCh
local ready -- nil = untried, true = running, false = unavailable
local jobs = {} -- id -> { status, body, err, progress, path }
local nextId = 0
@@ -50,6 +51,13 @@ local function ensureWorkers()
end
cmdCh = love.thread.getChannel(CMD)
resCh = love.thread.getChannel(RESULT)
quitCh = love.thread.getChannel(QUIT)
-- Channels outlive a pool (they are global to the process, keyed by name),
-- so a pool started after a shutdown -- the save editor opens from a live
-- launcher and hands the screen back -- must clear the previous round's
-- flag and leftovers or its workers quit on their first job.
quitCh:clear()
cmdCh:clear()
for i = 1, POOL do
local ok, th = pcall(love.thread.newThread, "src/net/fetch_worker.lua")
if ok and th and pcall(function() th:start() end) then
@@ -114,12 +122,14 @@ local function submit(cmd)
end
-- GET a URL, returning the body as a string.
-- opts: { userAgent, accept }
-- opts: { userAgent, accept, maxSeconds }
-- maxSeconds is the transfer ceiling, and it is also this job's worst-case
-- contribution to how long closing the window takes (see Fetch.shutdown).
function Fetch.get(url, opts)
opts = opts or {}
return submit({ kind = "get", url = url,
userAgent = opts.userAgent or "gen1recomp",
accept = opts.accept })
accept = opts.accept, maxSeconds = opts.maxSeconds })
end
-- Download a URL to `saveRel`, a path relative to the LOVE save directory.
@@ -129,7 +139,7 @@ function Fetch.download(url, saveRel, opts)
return submit({ kind = "download", url = url, dest = saveRel,
size = opts.size,
userAgent = opts.userAgent or "gen1recomp",
accept = opts.accept })
accept = opts.accept, maxSeconds = opts.maxSeconds })
end
-- Non-blocking status. Returns a table; never nil, even for an unknown id
@@ -176,13 +186,28 @@ end
-- End every worker. Their command loops sit in Channel:demand(), which never
-- returns on its own, and LOVE waits for every live love.thread before the
-- process exits (#339).
--
-- ORDER MATTERS, and getting it wrong is what froze the launcher on close
-- after a visit to the mod tabs. A quit pushed as an ordinary command is
-- just another item in a FIFO the workers are already chewing through: a page
-- of thumbnail downloads sits in front of it, and th:wait() below blocks the
-- main thread until every one of them finishes. So:
-- 1. raise the quit FLAG, which workers check after every demand(),
-- 2. CLEAR the queue -- nobody will read those results, and dropping them
-- is what turns "wait for the backlog" into "wait for what is in flight",
-- 3. push one wake sentinel per worker, because a worker idling inside
-- demand() has nothing to check the flag on until something arrives.
-- What remains is at most one transfer per worker, bounded by the caller's
-- maxSeconds; there is no portable way to interrupt a running curl.
function Fetch.shutdown()
if quitCh then quitCh:push(true) end
if cmdCh then
cmdCh:clear()
for _ = 1, #workers do cmdCh:push({ kind = "quit" }) end
end
for _, th in ipairs(workers) do pcall(function() th:wait() end) end
workers = {}
cmdCh, resCh, ready = nil, nil, false
cmdCh, resCh, quitCh, ready = nil, nil, nil, false
end
return Fetch
+21 -3
View File
@@ -27,16 +27,27 @@ local HostShell = loadModule("src/core/HostShell.lua")
local cmdCh = love.thread.getChannel("fetch_cmd")
local resCh = love.thread.getChannel("fetch_result")
-- Raised by Fetch.shutdown BEFORE the wake sentinels go out. A worker checks
-- it after every demand() and drops whatever it just pulled, so a quit does
-- not have to wait its turn behind a queue of jobs nobody will ever read the
-- results of.
local quitCh = love.thread.getChannel("fetch_quit")
local saveDir = love.filesystem.getSaveDirectory()
-- See the note in doGet: these bound how long a quit can block. A mod index
-- or a release list is a small JSON document, and a mod zip is a few MB; the
-- old 300s download ceiling was sized for the self-updater's whole payload,
-- which does not come through this pool.
-- which does not come through this pool. Callers may pass a shorter one
-- (job.maxSeconds) -- a thumbnail has no business holding the process open
-- for as long as a mod install does.
local GET_MAX_SECONDS = 20
local DOWNLOAD_MAX_SECONDS = 90
local function quitting()
return quitCh:peek() ~= nil
end
local function post(t) resCh:push(t) end
local function doGet(job)
@@ -48,7 +59,7 @@ local function doGet(job)
-- command, and LOVE waits for live threads before exiting (#339), so this
-- ceiling is also the worst case for how long closing the window can take.
local body, err = HostShell.httpGet(job.url, job.userAgent, job.accept,
GET_MAX_SECONDS)
tonumber(job.maxSeconds) or GET_MAX_SECONDS)
if not body then
post({ id = job.id, ok = false, err = err or "fetch failed" })
return
@@ -74,7 +85,7 @@ local function doDownload(job)
love.filesystem.remove(rel)
local ok, err = HostShell.httpDownload(job.url, abs, job.userAgent,
job.accept, DOWNLOAD_MAX_SECONDS)
job.accept, tonumber(job.maxSeconds) or DOWNLOAD_MAX_SECONDS)
if not ok then
post({ id = job.id, ok = false, err = err or "download failed" })
return
@@ -90,6 +101,13 @@ end
while true do
local job = cmdCh:demand()
-- The flag is checked before the job's KIND, so a worker woken by a
-- sentinel abandons whatever real job it happened to pull instead of
-- running it. Without this the quit commands queued behind a page of
-- thumbnail downloads and closing the window blocked for as long as those
-- transfers took -- the launcher froze on close after a visit to the mods
-- tabs, which is exactly what LOVE waiting on live threads looks like.
if quitting() then break end
if type(job) == "table" then
if job.kind == "quit" then
break
+28 -14
View File
@@ -29,8 +29,10 @@
-- 4. Lists PAGINATE. Row count is bounded by the page size, so a 500-mod
-- index costs exactly what a 10-mod one does. There is no virtualised
-- scroller and no momentum integrator to run.
-- 5. Draw flat. No stencil, no mesh, no blend-mode change, no rounded
-- corners (see Theme.lua) -- every one is a pipeline flush.
-- 5. Draw flat. No stencil, no mesh, no canvas, no shader, no blend-mode
-- change -- every one is a pipeline flush. Rounded corners, the emboss
-- and a card's drop shadow are allowed because they only add VERTICES at
-- the same pipeline state (see Theme.lua's header for the full rule).
--
-- ACCESSIBILITY / INPUT: every control is reachable four ways -- mouse,
-- touch (>= 30px targets), keyboard (spatial focus ring, arrows + Enter),
@@ -544,8 +546,9 @@ function Kit.row(x, y, w, h, selected, id)
-- The focus ring is a second inset outline, so it reads on both a black
-- row and a white selected one.
if focused then
Theme.stroke(x + 2, y + 2, w - 4, h - 4,
selected and PAL.inverse or PAL.lineStrong, Theme.A.focus, 1)
Theme.strokeRounded(x + 2, y + 2, w - 4, h - 4,
selected and PAL.inverse or PAL.lineStrong, Theme.A.focus, 1,
Theme.radius())
end
local clicked = Kit.press(x, y, w, h)
or (id ~= nil and Kit._activateId == id)
@@ -557,7 +560,7 @@ end
-- hairline says the same thing for one rect.)
function Kit.emptyBox(x, y, w, h, message)
if not G then return end
Theme.stroke(x, y, w, h, PAL.line, 0.22, 1)
Theme.strokeRounded(x, y, w, h, PAL.line, 0.22, 1, Theme.radius())
Kit.textCenter("button", Kit.ellipsize("button", message, w - 24 * Kit.scale),
x, y + (h - Kit.textHeight("button")) / 2, w, PAL.muted)
end
@@ -596,11 +599,16 @@ local KINDS = {
}
Kit.KINDS = KINDS
-- opts: { kind, font, enabled, align, id, glow }
-- opts: { kind, font, enabled, align, id, glow, fill, ink }
-- id -- opts into the focus ring (give every real control one)
-- glow -- a pulsing outline for "something is waiting for you" (the
-- update button). No blend-mode change: the alpha of the
-- existing outline is animated instead.
-- fill/ink -- override the kind's colours. The ONE caller is the
-- launcher's Play button, which wears its cartridge colour
-- (red/blue/gold) rather than a semantic one: on that screen
-- "which game am I launching" outranks "what kind of verb is
-- this", and the colour is already the tab's identity.
-- Returns true when activated, by click OR by the focus ring's Enter/A.
function Kit.button(x, y, w, h, label, opts)
opts = opts or {}
@@ -611,6 +619,9 @@ function Kit.button(x, y, w, h, label, opts)
local focused = enabled and opts.id
and Kit.focusable(opts.id, x, y, w, h) or false
local kind = KINDS[enabled and (opts.kind or "ghost") or "disabled"]
if enabled and opts.fill then
kind = { fill = opts.fill, ink = opts.ink or PAL.inverse }
end
local hot = enabled and Kit.hover(x, y, w, h)
if G then
@@ -700,12 +711,13 @@ function Kit.checkbox(x, y, w, h, checked, label, id, labelColor)
local box = 20 * Kit.scale
local bx, by = x + 12 * Kit.scale, y + (h - box) / 2
if G then
local br = math.min(Theme.radius(), box / 3)
if checked then
Theme.fill(bx, by, box, box, PAL.ink, 1)
Theme.fillRounded(bx, by, box, box, PAL.ink, 1, br)
Kit.textCenter("small", "X", bx,
by + (box - Kit.textHeight("small")) / 2, box, PAL.inverse)
else
Theme.stroke(bx, by, box, box, PAL.line, Theme.A.hover, 1)
Theme.strokeRounded(bx, by, box, box, PAL.line, Theme.A.hover, 1, br)
end
local lx = bx + box + 12 * Kit.scale
Kit.text("mono", Kit.ellipsize("mono", label, x + w - lx - 10 * Kit.scale),
@@ -724,12 +736,14 @@ function Kit.toggle(x, y, w, h, on, id)
-- Track, then a knob inset inside it, so the control reads as a switch
-- rather than as a white square with a word next to it. The label sits
-- in the empty half, which is the half that says what pressing does.
Theme.stroke(x, y, w, h, PAL.line,
(focused or Kit.hover(x, y, w, h)) and Theme.A.focus or Theme.A.hover, 1)
local r = math.min(Theme.radius(), h / 2)
Theme.fillRounded(x, y, w, h, PAL.rowBg, 1, r)
Theme.strokeRounded(x, y, w, h, PAL.line,
(focused or Kit.hover(x, y, w, h)) and Theme.A.focus or Theme.A.hover, 1, r)
local inset = 3
local knob = w / 2 - inset
Theme.fill(on and (x + w / 2) or (x + inset), y + inset, knob, h - 2 * inset,
PAL.ink, 1)
Theme.fillRounded(on and (x + w / 2) or (x + inset), y + inset, knob,
h - 2 * inset, PAL.ink, 1, math.min(r, (h - 2 * inset) / 2))
Kit.textCenter("micro", on and "ON" or "OFF",
on and x or (x + w / 2), y + (h - Kit.textHeight("micro")) / 2, w / 2,
PAL.text)
@@ -768,8 +782,8 @@ function Kit.textfield(id, x, y, w, h, value, placeholder)
end
end
if G then
Theme.fill(x, y, w, h, PAL.bg, 1)
Theme.stroke(x, y, w, h, PAL.line,
Theme.fillRounded(x, y, w, h, PAL.bg, 1)
Theme.strokeRounded(x, y, w, h, PAL.line,
(focused or focusRing) and Theme.A.focus or Theme.A.hairline,
focused and 2 or 1)
local pad = 10 * Kit.scale
+84 -34
View File
@@ -1,7 +1,9 @@
-- High-contrast theme shared by the launcher (src/import/LauncherView.lua)
-- and the save editor (tools/save-editor/). This replaces the old navy
-- gradient look wholesale: black field, white hairline outlines, flat fills,
-- no gradients and no glows anywhere.
-- High-contrast theme for the launcher (src/import/LauncherView.lua). The
-- save editor keeps its own tools/save-editor/Theme.lua, whose primitives
-- take a radius where these take a colour -- do not cross-wire them. This
-- replaces the old navy gradient look wholesale: a near-black field with the
-- faintest red cast, cards a few values above it, white hairline outlines,
-- flat fills, no gradients and no glows anywhere.
--
-- That is not only a visual choice. Every effect this theme drops was a GPU
-- pipeline flush in the old renderer:
@@ -9,10 +11,13 @@
-- (G.stencil / setStencilTest / draw(mesh) = 3 state changes per card),
-- * glows set blend mode "add", drew 7 stacked rects, then set it back.
-- Flat fills with a 1px outline all share one pipeline state, so LOVE batches
-- an entire panel into a couple of draw calls. Controls do carry a small
-- corner radius and a two-rect emboss, which cost extra vertices but no state
-- change -- that is the tier of expense this theme is willing to pay, and the
-- tier above it (stencils, meshes, blend modes) is the one it will not.
-- an entire panel into a couple of draw calls. What this theme DOES pay for
-- is extra vertices at the same pipeline state: rounded corners, the
-- two-rect emboss on a control, and the three stacked rounded rects that make
-- a card's drop shadow (Theme.shadow). Vertices are the tier of expense this
-- theme is willing to pay; the tier above it -- stencils, meshes, blend-mode
-- changes, canvases, shaders -- is the one it will not, and a shadow drawn as
-- a blurred canvas would land squarely in it.
--
-- Emphasis is carried by INVERSION, not by colour weight: a selected or
-- focused control fills white and prints black. That keeps contrast at
@@ -25,10 +30,16 @@
local Theme = {}
local PAL = {
-- field + surfaces. Only three fills exist in the whole UI.
bg = { 0, 0, 0 }, -- the page, and every card interior
surface = { 0, 0, 0 }, -- cards/rows: same black, told apart by outline
raised = { 20, 20, 20 }, -- the one non-black fill: hover feedback
-- field + surfaces. The field carries a FAINT red cast (a few points of
-- red over an otherwise neutral near-black) and cards sit a few steps above
-- it in the same hue, so a card reads as a raised object rather than as an
-- outline drawn on the page. These are still flat fills -- the depth comes
-- from the value step plus Theme.shadow, not from a gradient.
field = { 16, 8, 10 }, -- the page BEHIND the cards
bg = { 0, 0, 0 }, -- true black: button rests, field interiors
surface = { 28, 21, 24 }, -- card interiors
rowBg = { 20, 14, 17 }, -- rows inside a card, one step below it
raised = { 44, 34, 38 }, -- hover feedback
ink = { 255, 255, 255 }, -- the selected/focused fill
-- outlines. Two weights only: a hairline for structure, solid for focus.
line = { 255, 255, 255 }, -- hairline, drawn at alpha 0.35
@@ -55,7 +66,6 @@ local PAL = {
}
-- Semantic aliases kept so ported call sites read the same as before.
PAL.cardBorder = PAL.line
PAL.rowBg = PAL.surface
PAL.greenInk = PAL.inverse
PAL.blueInk = PAL.blue
PAL.redSoft = PAL.red
@@ -106,11 +116,34 @@ function Theme.fill(x, y, w, h, c, a)
G.rectangle("fill", snap(x), snap(y), snap(w), snap(h))
end
-- Corner radius for controls. Small and fixed: enough to read as a physical
-- key rather than a painted rectangle, small enough that the extra
-- tessellation is noise next to the rest of the frame.
-- Corner radius for controls. Fixed rather than scaled: LOVE tessellates a
-- rounded rect by radius, so a scale-driven radius would change the vertex
-- count with the window size, and these are the two tiers the design needs.
-- Controls get the smaller one, containers the larger, so a button never
-- looks like a card and a card never looks like a button.
function Theme.radius()
return 4
return 8
end
function Theme.cardRadius()
return 14
end
-- DROP SHADOW. Three stacked rounded rects at low alpha, each one step wider
-- and one step lower than the last -- a cheap falloff that needs no blur, no
-- canvas and no blend-mode change, so it stays inside the pipeline budget the
-- rest of this file is written to. Drawn BEFORE the surface it belongs to,
-- and never for a control (only containers cast one, or the whole screen
-- reads as floating debris).
function Theme.shadow(x, y, w, h, r)
if not G or w <= 0 or h <= 0 then return end
r = r or Theme.cardRadius()
for i = 1, 3 do
local spread = i * 2
col(PAL.bg, 0.13)
G.rectangle("fill", snap(x - spread), snap(y - spread + i * 3),
snap(w + 2 * spread), snap(h + 2 * spread), r + spread, r + spread)
end
end
function Theme.fillRounded(x, y, w, h, c, a, r)
@@ -139,7 +172,10 @@ function Theme.emboss(x, y, w, h, strength)
if not G or w <= 2 or h <= 2 then return end
strength = strength or 1
local t = math.max(1, math.floor(h * 0.10))
local r = Theme.radius()
-- The inset must clear the corner arc, but a narrow control (a stepper, a
-- row chip) is thinner than two radii -- clamp or the highlight rect goes
-- negative-width and vanishes.
local r = math.min(Theme.radius(), math.floor(w / 3))
-- highlight along the top
col(PAL.ink, 0.28 * strength)
G.rectangle("fill", snap(x) + r, snap(y) + 1, snap(w) - 2 * r, t)
@@ -166,35 +202,46 @@ function Theme.stroke(x, y, w, h, c, a, lw)
if probe("setLineWidth") then G.setLineWidth(1) end
end
-- The design's only container: black interior, white hairline. `emphasis`
-- raises the outline to full white (used for the focused/active card).
-- The design's only container: a rounded surface a few values above the
-- field, its own drop shadow, and a white hairline. `emphasis` raises the
-- outline to full white (used for the focused/active card).
function Theme.card(x, y, w, h, emphasis)
Theme.fill(x, y, w, h, PAL.bg, 1)
Theme.stroke(x, y, w, h, PAL.line, emphasis and Theme.A.focus or Theme.A.hairline, 1)
local r = Theme.cardRadius()
Theme.shadow(x, y, w, h, r)
Theme.fillRounded(x, y, w, h, PAL.surface, 1, r)
Theme.strokeRounded(x, y, w, h, PAL.line,
emphasis and Theme.A.focus or Theme.A.hairline, 1, r)
end
-- A list row. Three states, each one rect plus one outline:
-- normal black fill, hairline
-- hover near-black fill, brighter hairline
-- normal one value below the card it sits in, hairline
-- hover lifted fill, brighter hairline
-- selected WHITE fill (callers print ink = PAL.inverse over it)
function Theme.row(x, y, w, h, state)
local r = Theme.radius()
if state == "selected" then
Theme.fill(x, y, w, h, PAL.ink, 1)
Theme.fillRounded(x, y, w, h, PAL.ink, 1, r)
return PAL.inverse
end
Theme.fill(x, y, w, h, state == "hover" and PAL.raised or PAL.surface, 1)
Theme.stroke(x, y, w, h, PAL.line,
state == "hover" and Theme.A.hover or Theme.A.hairline, 1)
Theme.fillRounded(x, y, w, h,
state == "hover" and PAL.raised or PAL.rowBg, 1, r)
Theme.strokeRounded(x, y, w, h, PAL.line,
state == "hover" and Theme.A.hover or Theme.A.hairline, 1, r)
return PAL.text
end
-- A percentage meter (HP, box fill, dex completion, import progress).
-- pct is 0-100. Outline + solid white fill, no rounding.
-- pct is 0-100. Outline + solid fill, rounded to the track's own half-height
-- so a thin bar reads as a capsule instead of a clipped rectangle.
function Theme.meter(x, y, w, h, pct, c)
if not G then return end
Theme.stroke(x, y, w, h, PAL.line, Theme.A.hairline, 1)
local r = math.min(Theme.radius(), h / 2)
Theme.strokeRounded(x, y, w, h, PAL.line, Theme.A.hairline, 1, r)
local fill = (w - 2) * clamp((pct or 0) / 100, 0, 1)
if fill > 0 then Theme.fill(x + 1, y + 1, fill, h - 2, c or PAL.ink, 1) end
if fill > 0 then
Theme.fillRounded(x + 1, y + 1, fill, h - 2, c or PAL.ink, 1,
math.min(r, fill / 2))
end
end
-- The 4px tri-colour rail across the top of both windows: the only brand
@@ -325,11 +372,14 @@ function Theme.ellipsizeLeft(font, text, maxW)
return ell
end
-- The background: a flat black clear. One call, no mesh, no fan, no
-- allocation -- the old radial field built a 66-vertex mesh EVERY frame.
-- The background: one flat clear to the faintly red-cast field colour. One
-- call, no mesh, no fan, no allocation -- the old radial field built a
-- 66-vertex mesh EVERY frame. The tint is deliberately small (a handful of
-- points of red at near-black): enough that the cards read as sitting ON
-- something, not enough to compete with the tri-colour rail for brand duty.
function Theme.field()
if not G then return end
G.clear(0, 0, 0, 1)
G.clear(PAL.field[1] / 255, PAL.field[2] / 255, PAL.field[3] / 255, 1)
end
-- ------------------------------------------------------------------- fonts
+229
View File
@@ -0,0 +1,229 @@
-- Switch OTA wire format (host-testable, no love.*).
-- The native DEVKITPRO launcher (libnx + switch-curl) must implement the
-- same decisions. LÖVE on NX never runs this path — Platform.networkValidated
-- stays false and src/update/Check.lua remains gated off on NX.
--
-- Asset: gen1recomp-<X.Y.Z>-switch.zip (same SD zip used for install).
local SwitchOta = {}
SwitchOta.RELEASES_API =
"https://api.github.com/repos/bryanthaboi/gen1recomp/releases/latest"
SwitchOta.OTA_ASSET_PATTERN = "^gen1recomp%-(%d+%.%d+%.%d+)%-switch%.zip$"
SwitchOta.CHECK_TIMEOUT_SEC = 6
SwitchOta.GAME_NRO_NAME = "gen1recomp-game.nro"
SwitchOta.LAUNCHER_NRO_NAME = "gen1recomp.nro"
SwitchOta.SAVE_DIR_NAME = "pokemon-love2d"
SwitchOta.INSTALL_DIR = "switch/gen1recomp"
local function parseSemver(s)
if type(s) ~= "string" then return nil end
local body = s:match("^v?(.+)$")
if not body then return nil end
local maj, min, pat = body:match("^(%d+)%.(%d+)%.(%d+)$")
if not maj then return nil end
return { major = tonumber(maj), minor = tonumber(min), patch = tonumber(pat) }
end
function SwitchOta.compareSemver(a, b)
local pa, pb = parseSemver(a), parseSemver(b)
if not pa and not pb then return 0 end
if not pa then return -1 end
if not pb then return 1 end
for _, field in ipairs({ "major", "minor", "patch" }) do
if pa[field] < pb[field] then return -1 end
if pa[field] > pb[field] then return 1 end
end
return 0
end
function SwitchOta.isOtaAssetName(name)
if type(name) ~= "string" then return false end
return name:match(SwitchOta.OTA_ASSET_PATTERN) ~= nil
end
function SwitchOta.versionFromOtaAsset(name)
if type(name) ~= "string" then return nil end
return name:match(SwitchOta.OTA_ASSET_PATTERN)
end
local function findJsonObjectStart(jsonText, pos)
if type(jsonText) ~= "string" or not pos or pos < 1 then return nil end
local depth = 0
local p = pos
while p >= 1 do
local c = jsonText:sub(p, p)
if c == "}" then
depth = depth + 1
elseif c == "{" then
if depth == 0 then return p end
depth = depth - 1
end
p = p - 1
end
return nil
end
local function findJsonObjectEnd(jsonText, objectStart)
if type(jsonText) ~= "string" or not objectStart then return nil end
if jsonText:sub(objectStart, objectStart) ~= "{" then return nil end
local depth = 1
local p = objectStart + 1
local len = #jsonText
while p <= len do
local c = jsonText:sub(p, p)
if c == "{" then
depth = depth + 1
elseif c == "}" then
depth = depth - 1
if depth == 0 then return p + 1 end
end
p = p + 1
end
return nil
end
-- Parse GitHub releases/latest JSON (tag_name + assets[].name/browser_download_url).
-- Returns { tag, version, assetName, downloadUrl } or nil + reason.
function SwitchOta.parseRelease(jsonText)
if type(jsonText) ~= "string" or jsonText == "" then
return nil, "empty_json"
end
local tag = jsonText:match('"tag_name"%s*:%s*"(.-)"')
if not tag then return nil, "missing_tag" end
local version = tag:match("^v?(%d+%.%d+%.%d+)$")
if not version then return nil, "bad_tag" end
local cursor = 1
while true do
local nameKeyPos = jsonText:find('"name"', cursor, true)
if not nameKeyPos then break end
local tail = jsonText:sub(nameKeyPos)
local name = tail:match('"name"%s*:%s*"(.-)"')
if name and SwitchOta.isOtaAssetName(name) then
local assetStart = findJsonObjectStart(jsonText, nameKeyPos)
local assetEnd = assetStart and findJsonObjectEnd(jsonText, assetStart)
if assetStart and assetEnd and assetEnd > nameKeyPos then
local assetBlock = jsonText:sub(assetStart, assetEnd - 1)
local downloadUrl = assetBlock:match('"browser_download_url"%s*:%s*"(.-)"')
if downloadUrl and downloadUrl ~= "" then
return {
tag = tag,
version = version,
assetName = name,
downloadUrl = downloadUrl,
}
end
end
end
cursor = nameKeyPos + 6
end
return nil, "missing_ota_asset"
end
-- Decide check outcome given installed version and parsed release.
-- Returns status: uptodate | available | error
function SwitchOta.decideUpdate(installedVersion, release)
if type(installedVersion) ~= "string" or not parseSemver(installedVersion) then
return { status = "error", reason = "bad_installed_version" }
end
if type(release) ~= "table" or not release.version then
return { status = "error", reason = "bad_release" }
end
local cmp = SwitchOta.compareSemver(release.version, installedVersion)
if cmp <= 0 then
return { status = "uptodate", version = installedVersion }
end
return {
status = "available",
version = release.version,
assetName = release.assetName,
downloadUrl = release.downloadUrl,
}
end
-- Parse sha256sums.txt lines: "<hex> <filename>" or "<hex> *<filename>"
function SwitchOta.parseSums(text)
local sums = {}
if type(text) ~= "string" then return sums end
for line in (text .. "\n"):gmatch("(.-)\n") do
local hex, name = line:match("^(%x+)%s+%*?%./?(.-)%s*$")
if hex and name and name ~= "" then
sums[name] = hex:lower()
end
end
return sums
end
-- Require a matching sha256. Missing sum is ALWAYS reject (no silent accept).
function SwitchOta.verifySha256(assetName, actualHex, sums)
if type(assetName) ~= "string" or assetName == "" then
return false, "bad_asset_name"
end
if type(sums) ~= "table" then
return false, "missing_sums"
end
local expected = sums[assetName]
if type(expected) ~= "string" or expected == "" then
return false, "sum_not_found"
end
if type(actualHex) ~= "string" or actualHex == "" then
return false, "missing_actual_hash"
end
if actualHex:lower() ~= expected:lower() then
return false, "hash_mismatch"
end
return true, nil
end
-- Atomic apply plan: never writes live NROs directly; never touches saves.
-- Replaces game + launcher so NACP versions stay in sync (hbmenu / Sphaira).
function SwitchOta.planAtomicApply(installDir, verifiedTempPath)
installDir = installDir or SwitchOta.INSTALL_DIR
local gameNro = installDir .. "/" .. SwitchOta.GAME_NRO_NAME
local launcherNro = installDir .. "/" .. SwitchOta.LAUNCHER_NRO_NAME
local partPath = gameNro .. ".part"
local launcherPart = launcherNro .. ".part"
return {
steps = {
{ op = "copy_to_part", from = verifiedTempPath, to = partPath },
{ op = "rename", from = partPath, to = gameNro },
{ op = "copy_to_part", from = "launcher", to = launcherPart },
{ op = "rename", from = launcherPart, to = launcherNro },
{ op = "env_set_next_load", target = gameNro },
},
preserve = { installDir .. "/" .. SwitchOta.SAVE_DIR_NAME },
forbidden = {
"delete:" .. installDir .. "/" .. SwitchOta.SAVE_DIR_NAME,
"write_direct:" .. gameNro,
},
}
end
-- Offline / skip / timeout policy. Never blocks forever.
-- elapsedSec: time spent on the network check so far
-- events: { networkOk=bool, userSkip=bool, apiError=bool }
function SwitchOta.offlinePolicy(elapsedSec, events)
events = events or {}
if events.userSkip then
return { action = "play_installed", reason = "user_skip", message = "update skipped" }
end
if events.apiError or events.networkOk == false then
return {
action = "play_installed",
reason = "offline_or_error",
message = "offline or update check failed — play installed version",
}
end
local timeout = SwitchOta.CHECK_TIMEOUT_SEC
if type(elapsedSec) == "number" and elapsedSec >= timeout then
return {
action = "play_installed",
reason = "timeout",
message = "update check timed out after " .. tostring(timeout) .. "s",
}
end
return { action = "keep_checking", reason = "in_flight" }
end
return SwitchOta
+5 -2
View File
@@ -80,7 +80,10 @@ local function curlCapture(url)
local pipe = HostShell.popen(cmd)
if not pipe then return nil end
local out = pipe:read("*a")
pipe:close()
-- HostShell.pclose, not pipe:close(): a close outside the spawn lock can
-- free a FILE while another thread's popen walks the stream list, which
-- deadlocks that thread permanently (see HostShell's popen notes).
HostShell.pclose(pipe)
if not out or out == "" then return nil end
return out
end
@@ -89,7 +92,7 @@ local function haveCurl()
local pipe = HostShell.popen("curl --version")
if not pipe then return false end
local out = pipe:read("*a")
pipe:close()
HostShell.pclose(pipe)
return out ~= nil and out:find("curl", 1, true) ~= nil
end
+606
View File
@@ -0,0 +1,606 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { createHash } from 'node:crypto';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, '..');
function read(rel) {
return fs.readFileSync(path.join(root, rel), 'utf8');
}
function sha256Hex(buf) {
return createHash('sha256').update(buf).digest('hex');
}
/** JS mirror of src/update/SwitchOta.lua — contract under test. */
function createSwitchOta() {
const OTA_RE = /^gen1recomp-(\d+\.\d+\.\d+)-switch\.zip$/;
const CHECK_TIMEOUT_SEC = 6;
const GAME_NRO_NAME = 'gen1recomp-game.nro';
const LAUNCHER_NRO_NAME = 'gen1recomp.nro';
const SAVE_DIR_NAME = 'pokemon-love2d';
const INSTALL_DIR = 'switch/gen1recomp';
function parseSemver(s) {
if (typeof s !== 'string') return null;
const m = s.match(/^v?(\d+)\.(\d+)\.(\d+)$/);
if (!m) return null;
return { major: +m[1], minor: +m[2], patch: +m[3] };
}
function compareSemver(a, b) {
const pa = parseSemver(a);
const pb = parseSemver(b);
if (!pa && !pb) return 0;
if (!pa) return -1;
if (!pb) return 1;
for (const f of ['major', 'minor', 'patch']) {
if (pa[f] < pb[f]) return -1;
if (pa[f] > pb[f]) return 1;
}
return 0;
}
function isOtaAssetName(name) {
return typeof name === 'string' && OTA_RE.test(name);
}
function findJsonObjectStart(jsonText, pos) {
if (!jsonText || pos < 1) return null;
let depth = 0;
for (let p = pos; p >= 1; p--) {
const c = jsonText[p - 1];
if (c === '}') depth += 1;
else if (c === '{') {
if (depth === 0) return p;
depth -= 1;
}
}
return null;
}
function findJsonObjectEnd(jsonText, objectStart) {
if (!jsonText || objectStart < 1) return null;
if (jsonText[objectStart - 1] !== '{') return null;
let depth = 1;
for (let p = objectStart + 1; p <= jsonText.length; p++) {
const c = jsonText[p - 1];
if (c === '{') depth += 1;
else if (c === '}') {
depth -= 1;
if (depth === 0) return p + 1;
}
}
return null;
}
function parseRelease(jsonText) {
if (!jsonText) return { ok: false, reason: 'empty_json' };
const tagM = jsonText.match(/"tag_name"\s*:\s*"([^"]+)"/);
if (!tagM) return { ok: false, reason: 'missing_tag' };
const tag = tagM[1];
const versionM = tag.match(/^v?(\d+\.\d+\.\d+)$/);
if (!versionM) return { ok: false, reason: 'bad_tag' };
const version = versionM[1];
let cursor = 0;
while (true) {
const nameKeyPos = jsonText.indexOf('"name"', cursor);
if (nameKeyPos === -1) break;
const tail = jsonText.slice(nameKeyPos);
const nameM = tail.match(/"name"\s*:\s*"([^"]+)"/);
const name = nameM?.[1];
if (name && isOtaAssetName(name)) {
const assetStart = findJsonObjectStart(jsonText, nameKeyPos + 1);
const assetEnd = assetStart ? findJsonObjectEnd(jsonText, assetStart) : null;
if (assetStart && assetEnd && assetEnd > nameKeyPos + 1) {
const assetBlock = jsonText.slice(assetStart - 1, assetEnd - 1);
const urlM = assetBlock.match(/"browser_download_url"\s*:\s*"([^"]+)"/);
const downloadUrl = urlM?.[1];
if (downloadUrl) {
return {
ok: true,
tag,
version,
assetName: name,
downloadUrl,
};
}
}
}
cursor = nameKeyPos + 6;
}
return { ok: false, reason: 'missing_ota_asset' };
}
function decideUpdate(installed, release) {
if (!parseSemver(installed)) return { status: 'error', reason: 'bad_installed_version' };
if (!release?.ok || !release.version) return { status: 'error', reason: 'bad_release' };
if (compareSemver(release.version, installed) <= 0) {
return { status: 'uptodate', version: installed };
}
return {
status: 'available',
version: release.version,
assetName: release.assetName,
downloadUrl: release.downloadUrl,
};
}
function parseSums(text) {
const sums = {};
if (typeof text !== 'string') return sums;
for (const line of text.split(/\r?\n/)) {
const m = line.match(/^([0-9a-fA-F]+)\s+\*?\.?\/?(.*?)\s*$/);
if (m) sums[m[2]] = m[1].toLowerCase();
}
return sums;
}
function verifySha256(assetName, actualHex, sums) {
if (!assetName) return { ok: false, reason: 'bad_asset_name' };
if (!sums || typeof sums !== 'object') return { ok: false, reason: 'missing_sums' };
const expected = sums[assetName];
if (!expected) return { ok: false, reason: 'sum_not_found' };
if (!actualHex) return { ok: false, reason: 'missing_actual_hash' };
if (actualHex.toLowerCase() !== expected.toLowerCase()) {
return { ok: false, reason: 'hash_mismatch' };
}
return { ok: true };
}
function planAtomicApply(installDir, verifiedTempPath) {
installDir = installDir || INSTALL_DIR;
const gameNro = `${installDir}/${GAME_NRO_NAME}`;
const launcherNro = `${installDir}/${LAUNCHER_NRO_NAME}`;
const partPath = `${gameNro}.part`;
const launcherPart = `${launcherNro}.part`;
return {
steps: [
{ op: 'copy_to_part', from: verifiedTempPath, to: partPath },
{ op: 'rename', from: partPath, to: gameNro },
{ op: 'copy_to_part', from: 'launcher', to: launcherPart },
{ op: 'rename', from: launcherPart, to: launcherNro },
{ op: 'env_set_next_load', target: gameNro },
],
preserve: [`${installDir}/${SAVE_DIR_NAME}`],
forbidden: [`delete:${installDir}/${SAVE_DIR_NAME}`, `write_direct:${gameNro}`],
};
}
function offlinePolicy(elapsedSec, events = {}) {
if (events.userSkip) {
return { action: 'play_installed', reason: 'user_skip', message: 'update skipped' };
}
if (events.apiError || events.networkOk === false) {
return {
action: 'play_installed',
reason: 'offline_or_error',
message: 'offline or update check failed — play installed version',
};
}
if (typeof elapsedSec === 'number' && elapsedSec >= CHECK_TIMEOUT_SEC) {
return {
action: 'play_installed',
reason: 'timeout',
message: `update check timed out after ${CHECK_TIMEOUT_SEC}s`,
};
}
return { action: 'keep_checking', reason: 'in_flight' };
}
return {
CHECK_TIMEOUT_SEC,
GAME_NRO_NAME,
LAUNCHER_NRO_NAME,
SAVE_DIR_NAME,
compareSemver,
isOtaAssetName,
parseRelease,
decideUpdate,
parseSums,
verifySha256,
planAtomicApply,
offlinePolicy,
};
}
const M = createSwitchOta();
const GITHUB_UPLOADER =
'"login":"github-actions[bot]",' +
'"id":41898282,' +
'"node_id":"MDM6Qm90NDE4OTgyODI=",' +
'"avatar_url":"https://avatars.githubusercontent.com/in/15368?v=4",' +
'"gravatar_id":"",' +
'"url":"https://api.github.com/users/github-actions%5Bbot%5D",' +
'"html_url":"https://github.com/apps/github-actions",' +
'"followers_url":"https://api.github.com/users/github-actions%5Bbot%5D/followers",' +
'"following_url":"https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}",' +
'"gists_url":"https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}",' +
'"starred_url":"https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}",' +
'"subscriptions_url":"https://api.github.com/users/github-actions%5Bbot%5D/subscriptions",' +
'"organizations_url":"https://api.github.com/users/github-actions%5Bbot%5D/orgs",' +
'"repos_url":"https://api.github.com/users/github-actions%5Bbot%5D/repos",' +
'"events_url":"https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}",' +
'"received_events_url":"https://api.github.com/users/github-actions%5Bbot%5D/received_events",' +
'"type":"Bot",' +
'"user_view_type":"public",' +
'"site_admin":false';
function buildGithubReleaseJson() {
return (
'{' +
'"tag_name":"v0.1.70",' +
'"name":"0.1.70",' +
'"assets":[' +
'{' +
'"url":"https://api.github.com/repos/bryanthaboi/gen1recomp/releases/assets/502823880",' +
'"id":502823880,' +
'"name":"gen1recomp-0.1.70-switch.zip",' +
'"label":"",' +
'"uploader":{' +
GITHUB_UPLOADER +
'},' +
'"content_type":"application/zip",' +
'"state":"uploaded",' +
'"size":9000573,' +
'"browser_download_url":"https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.70/gen1recomp-0.1.70-switch.zip"' +
'}' +
']' +
'}'
);
}
const sampleRelease = M.parseRelease(
JSON.stringify({
tag_name: 'v1.5.0',
assets: [
{
name: 'gen1recomp-1.5.0-switch.zip',
browser_download_url: 'https://example/switch.zip',
},
{ name: 'sha256sums.txt', browser_download_url: 'https://example/sums' },
],
})
);
test('AC-001: Launcher nativo verifica release no GitHub @spec:AC-001', () => {
assert.equal(M.compareSemver('1.2.0', '1.1.0'), 1);
assert.equal(M.compareSemver('1.1.0', '1.1.0'), 0);
assert.equal(M.compareSemver('1.0.0', '1.1.0'), -1);
assert.equal(sampleRelease.ok, true);
assert.equal(sampleRelease.version, '1.5.0');
assert.equal(sampleRelease.assetName, 'gen1recomp-1.5.0-switch.zip');
assert.equal(M.decideUpdate('1.4.0', sampleRelease).status, 'available');
assert.equal(M.decideUpdate('1.5.0', sampleRelease).status, 'uptodate');
assert.equal(M.decideUpdate('1.6.0', sampleRelease).status, 'uptodate');
const missing = M.parseRelease(JSON.stringify({ tag_name: 'v1.5.0', assets: [] }));
assert.equal(missing.ok, false);
assert.equal(missing.reason, 'missing_ota_asset');
const githubRelease = M.parseRelease(buildGithubReleaseJson());
assert.equal(githubRelease.ok, true);
assert.equal(githubRelease.version, '0.1.70');
assert.equal(githubRelease.assetName, 'gen1recomp-0.1.70-switch.zip');
assert.equal(
githubRelease.downloadUrl,
'https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.70/gen1recomp-0.1.70-switch.zip'
);
assert.equal(M.decideUpdate('0.1.69', githubRelease).status, 'available');
});
test('AC-002: Download com verificação SHA-256 @spec:AC-002', () => {
const name = 'gen1recomp-1.5.0-switch.zip';
const payload = Buffer.from('switch-sd-payload');
const hex = sha256Hex(payload);
const sums = M.parseSums(`${hex} ${name}\n`);
assert.equal(M.verifySha256(name, hex, sums).ok, true);
assert.equal(M.verifySha256(name, 'deadbeef', sums).reason, 'hash_mismatch');
// Invisible-error guard: download without a sum MUST NOT verify.
assert.equal(M.verifySha256(name, hex, {}).ok, false);
assert.equal(M.verifySha256(name, hex, {}).reason, 'sum_not_found');
assert.equal(M.verifySha256(name, '', sums).reason, 'missing_actual_hash');
});
test('AC-003: Aplicação atômica e handoff para o jogo @spec:AC-003', () => {
const plan = M.planAtomicApply('switch/gen1recomp', '/tmp/verified.zip');
assert.ok(plan.steps.some((s) => s.op === 'copy_to_part'));
assert.ok(plan.steps.some((s) => s.op === 'rename'));
assert.ok(plan.steps.some((s) => s.op === 'env_set_next_load'));
assert.ok(plan.steps.filter((s) => s.op === 'rename').length >= 2, 'renames game + launcher');
assert.ok(plan.preserve.some((p) => p.endsWith('pokemon-love2d')));
assert.ok(plan.forbidden.some((f) => f.startsWith('delete:') && f.includes('pokemon-love2d')));
assert.ok(plan.forbidden.some((f) => f.startsWith('write_direct:')));
const renameGame = plan.steps.find((s) => s.op === 'rename' && s.to.endsWith('gen1recomp-game.nro'));
assert.ok(renameGame);
assert.ok(renameGame.from.endsWith('.part'));
const renameLauncher = plan.steps.find((s) => s.op === 'rename' && s.to.endsWith('gen1recomp.nro'));
assert.ok(renameLauncher, 'launcher NRO replaced so NACP version stays in sync');
});
test('AC-004: Offline ou falha de rede não trava o jogo @spec:AC-004', () => {
assert.equal(M.CHECK_TIMEOUT_SEC, 6);
assert.equal(M.offlinePolicy(1, { userSkip: true }).action, 'play_installed');
assert.equal(M.offlinePolicy(1, { networkOk: false }).action, 'play_installed');
assert.equal(M.offlinePolicy(6, { networkOk: true }).action, 'play_installed');
assert.equal(M.offlinePolicy(6, { networkOk: true }).reason, 'timeout');
assert.equal(M.offlinePolicy(2, { networkOk: true }).action, 'keep_checking');
});
test('AC-005: Documentação Switch descreve o launcher OTA @spec:AC-005', () => {
const install = read('docs/switch-install.md');
const build = read('docs/switch-build.md');
const updater = read('docs/updater.md');
for (const [name, text] of [
['switch-install', install],
['switch-build', build],
['updater', updater],
]) {
assert.match(text, /native OTA launcher|launcher nativo/i, `${name} mentions native OTA launcher`);
}
assert.match(install, /manual|zip|microSD/i, 'manual zip fallback still documented');
assert.match(install, /gen1recomp-\*-switch\.zip|switch\.zip/i, 'unified Switch zip documented');
assert.doesNotMatch(install, /switch-ota\.zip/, 'legacy separate OTA zip must not be documented');
assert.match(install, /Sphaira|forwarder/i, 'Sphaira shortcut version note documented');
assert.doesNotMatch(
install,
/Switch OTA uses `src\/update\/Check\.lua`/i,
'must not claim Switch OTA uses LÖVE Check.lua'
);
assert.match(updater, /Nintendo Switch/i);
assert.match(updater, /networkValidated|disabled|desligado|off/i);
});
test('AC-006: LOVE no NX continua sem rede de updater @spec:AC-006', () => {
const platform = read('src/core/Platform.lua');
assert.match(
platform,
/networkValidated\s*=\s*not nx/,
'Platform.networkValidated must stay false on NX'
);
assert.doesNotMatch(platform, /networkValidated\s*=\s*true/);
const boot = read('src/update/Boot.lua');
assert.match(boot, /networkValidated/, 'Boot still gates on networkValidated');
assert.ok(read('src/update/Check.lua').length > 0);
assert.ok(read('src/update/check_worker.lua').length > 0);
assert.ok(read('src/core/HostShell.lua').length > 0);
assert.match(read('src/import/RomImporter.lua'), /updaterAllowed|networkValidated|Check/);
assert.ok(read('src/import/LauncherView.lua').length > 0);
const switchOta = read('src/update/SwitchOta.lua');
assert.doesNotMatch(switchOta, /networkValidated\s*=\s*true/);
assert.match(switchOta, /never runs this path|remains gated off on NX/i);
});
test('AC-007: Funções puras do protocolo OTA têm testes host-side @spec:AC-007', () => {
assert.ok(M.isOtaAssetName('gen1recomp-1.2.3-switch.zip'));
assert.equal(M.isOtaAssetName('gen1recomp-1.2.3-switch-ota.zip'), false);
assert.equal(M.isOtaAssetName('gen1recomp-1.2.3.love'), false);
assert.equal(M.compareSemver('2.0.0', '1.9.9'), 1);
assert.equal(M.compareSemver('1.0.0', '1.0.0'), 0);
assert.equal(M.compareSemver('0.9.0', '1.0.0'), -1);
const rel = M.parseRelease(
JSON.stringify({
tag_name: 'v9.9.9',
assets: [
{
name: 'gen1recomp-9.9.9-switch.zip',
browser_download_url: 'https://example/x',
},
],
})
);
assert.equal(rel.version, '9.9.9');
const sums = M.parseSums('deadbeef unexpected-name.zip\n');
assert.equal(
M.verifySha256('gen1recomp-9.9.9-switch.zip', 'deadbeef', sums).ok,
false,
'unexpected filename in sums must not verify OTA asset'
);
assert.equal(M.verifySha256('gen1recomp-9.9.9-switch.zip', 'anything', {}).ok, false);
const lua = read('src/update/SwitchOta.lua');
assert.ok(fs.existsSync(path.join(root, 'src/update/SwitchOta.lua')));
for (const fn of [
'compareSemver',
'parseRelease',
'decideUpdate',
'parseSums',
'verifySha256',
'planAtomicApply',
'offlinePolicy',
]) {
assert.match(lua, new RegExp(`function SwitchOta\\.${fn}`), `Lua exports ${fn}`);
}
assert.match(lua, /CHECK_TIMEOUT_SEC\s*=\s*6/);
assert.match(lua, /switch%.zip|%-switch%.zip/);
assert.doesNotMatch(lua, /switch%-ota%.zip|switch-ota\.zip/);
});
test('AC-008: Gates de regressão anti-“erro invisível” @spec:AC-008', () => {
const platform = read('src/core/Platform.lua');
assert.match(platform, /networkValidated\s*=\s*not nx\s+and\s+not uwp/);
for (const rel of ['docs/switch-install.md', 'docs/switch-build.md', 'docs/updater.md']) {
const text = read(rel);
assert.match(text, /native OTA launcher|launcher nativo/i, rel);
assert.match(
text,
/LÖVE self-updater|LOVE self-updater|self-updater LÖVE|updater LÖVE|Check\.lua/i,
`${rel} contrasts with LÖVE updater`
);
}
const manifest = read('scripts/switch/ota_launcher.manifest');
assert.match(manifest, /^OTA_ENABLED=1$/m);
assert.match(manifest, /ENTRY_NRO=switch\/gen1recomp\/gen1recomp\.nro/);
assert.match(manifest, /GAME_NRO=switch\/gen1recomp\/gen1recomp-game\.nro/);
assert.match(manifest, /OTA_ASSET_GLOB=gen1recomp-\*-switch\.zip/);
assert.doesNotMatch(manifest, /switch-ota\.zip/);
assert.match(manifest, /REQUIRE_SHA256SUMS=1/);
});
test('AC-009: Protocolo C host-testável espelha SwitchOta.lua @spec:AC-009', () => {
const proto = read('ports/switch/ota-launcher/src/ota_protocol.c');
const header = read('ports/switch/ota-launcher/include/ota_protocol.h');
assert.match(header, /OTA_CHECK_TIMEOUT_SEC\s+6/);
assert.match(header, /ota_compare_semver/);
assert.match(header, /ota_parse_release/);
assert.match(header, /ota_verify_sha256/);
assert.match(header, /ota_plan_atomic_apply/);
assert.match(header, /ota_offline_policy/);
assert.match(proto, /sum_not_found/);
assert.match(proto, /env_set_next_load/);
assert.match(header, /pokemon-love2d/);
assert.match(proto, /OTA_SAVE_DIR_NAME/);
const lua = read('src/update/SwitchOta.lua');
assert.match(lua, /CHECK_TIMEOUT_SEC\s*=\s*6/);
// Compile + run C host tests (gcc)
const r = spawnSync(
'make',
['-C', path.join(root, 'ports/switch/ota-launcher'), 'host-test'],
{ encoding: 'utf8' }
);
assert.equal(r.status, 0, `host-test failed:\n${r.stdout}\n${r.stderr}`);
assert.match(r.stdout, /all ota_protocol host tests passed/);
});
test('AC-010: Fonte do launcher e Makefile DEVKITPRO existem @spec:AC-010', () => {
for (const rel of [
'ports/switch/ota-launcher/Makefile',
'ports/switch/ota-launcher/README.md',
'ports/switch/ota-launcher/src/main.c',
'ports/switch/ota-launcher/src/ota_net.c',
'ports/switch/ota-launcher/src/ota_fs.c',
'scripts/switch/build_ota_launcher.sh',
'docs/switch-build.md',
]) {
assert.ok(fs.existsSync(path.join(root, rel)), `missing ${rel}`);
}
const main = read('ports/switch/ota-launcher/src/main.c');
assert.match(main, /envSetNextLoad|ota_fs_handoff_to_game/);
assert.match(main, /gen1recomp-game\.nro|OTA_GAME_NRO_NAME/);
assert.match(main, /Quiet by default|stays quiet|LÖVE self-updater stays off|self-updater stays off/i);
assert.match(main, /ota_ui_prompt_update|ota_ui_show_progress/);
assert.match(main, /ota_ui_alert_error/);
assert.match(main, /Step 1\/3: Downloading/);
assert.doesNotMatch(main, /\u2026/, 'OTA UI strings must be ASCII (no Unicode ellipsis)');
assert.doesNotMatch(main, /ota_ui_alert\([^)]*Install failed/, 'generic install alert removed');
assert.doesNotMatch(main, /consoleInit/, 'no terminal console UI');
assert.ok(fs.existsSync(path.join(root, 'ports/switch/ota-launcher/src/ota_ui.c')));
const otaUi = read('ports/switch/ota-launcher/src/ota_ui.c');
assert.match(otaUi, /COL_RAIL_R|rail|FFD600|255,\s*214/);
assert.match(otaUi, /logo\.rgba/);
assert.doesNotMatch(otaUi, /stb_image/);
assert.ok(
fs.existsSync(path.join(root, 'ports/switch/assets/logo.rgba')),
'pre-baked OTA logo asset'
);
assert.ok(
fs.existsSync(path.join(root, 'scripts/switch/bake_ota_logo.py')),
'logo bake script for regenerating logo.rgba'
);
assert.match(otaUi, /ota_ui_sanitize_ascii/);
assert.match(otaUi, /draw_text_wrapped_centered/);
assert.match(otaUi, /ota_ui_alert_error/);
assert.match(read('ports/switch/ota-launcher/src/ota_net.c'), /ota_net_init/);
assert.match(read('ports/switch/ota-launcher/src/main.c'), /ota_net_init/);
assert.match(read('ports/switch/ota-launcher/src/ota_net.c'), /CURLOPT_SSL_VERIFYPEER,\s*1L/);
assert.match(read('ports/switch/ota-launcher/src/ota_net.c'), /romfs:\/cacert\.pem/);
assert.doesNotMatch(read('ports/switch/ota-launcher/src/ota_net.c'), /CURLOPT_SSL_VERIFYPEER,\s*0L/);
assert.match(read('ports/switch/ota-launcher/Makefile'), /^ROMFS\s*:=/m);
assert.match(read('ports/switch/ota-launcher/Makefile'), /cacert\.pem/);
assert.match(read('ports/switch/ota-launcher/Makefile'), /logo\.rgba/);
const mk = read('ports/switch/ota-launcher/Makefile');
assert.match(mk, /libnx\/switch_rules|DEVKITPRO/);
assert.match(mk, /-lcurl/);
assert.match(mk, /-lzzip/);
assert.match(mk, /ports\/switch\/assets\/icon\.jpg|assets\/icon\.jpg/, 'uses project Switch icon');
assert.ok(fs.existsSync(path.join(root, 'ports/switch/ota-launcher/src/ota_unzip.c')));
assert.match(read('ports/switch/ota-launcher/src/ota_unzip.c'), /zzip\/zzip\.h/);
assert.match(read('ports/switch/ota-launcher/src/main.c'), /ota_unzip_extract_file/);
assert.match(read('ports/switch/ota-launcher/src/main.c'), /GAME_MEMBER_IN_ZIP|switch\/gen1recomp\//);
assert.match(read('ports/switch/ota-launcher/src/main.c'), /LAUNCHER_MEMBER_IN_ZIP|ota_fs_stage_launcher_bootstrap/);
assert.match(read('ports/switch/ota-launcher/src/main.c'), /ota_fs_stage_launcher_bootstrap\([\s\S]*\) != 0/);
assert.match(read('ports/switch/ota-launcher/src/main.c'), /return 2.*bootstrap|bootstrap.*return 2/i);
assert.match(read('ports/switch/ota-launcher/src/ota_fs.c'), /ota_fs_atomic_replace_nro/);
assert.match(read('ports/switch/ota-launcher/src/ota_fs.c'), /remove\(dest\)/);
assert.doesNotMatch(read('ports/switch/ota-launcher/src/ota_fs.c'), /#ifdef _WIN32[\s\S]*remove\(dest\)/);
assert.doesNotMatch(read('scripts/switch/install_devkitpro_deps.sh'), /switch-minizip/);
assert.match(read('scripts/switch/install_devkitpro_deps.sh'), /switch-zziplib/);
assert.match(read('scripts/switch/install_devkitpro_deps.sh'), /switch-dev/);
const buildDoc = read('docs/switch-build.md');
assert.match(buildDoc, /ports\/switch\/ota-launcher|build_ota_launcher/);
assert.match(buildDoc, /native packages.*or.*Docker|native or Docker/i);
assert.match(buildDoc, /--fused|install_devkitpro_deps/);
assert.match(buildDoc, /Requires DEVKITPRO|DEVKITPRO is[\s\S]*required/i);
assert.doesNotMatch(buildDoc, /switch-ota\.zip/);
assert.doesNotMatch(buildDoc, /--ota\b/);
const readme = read('ports/switch/ota-launcher/README.md');
assert.match(readme, /DEVKITPRO|devkitPro/i);
assert.match(readme, /Docker|devkita64/i);
assert.match(readme, /Quiet|quiet|silen/i);
});
test('AC-011: Empacotamento dual-NRO e selftest @spec:AC-011', () => {
const pack = read('scripts/switch/pack_sd_zip.sh');
assert.match(pack, /LAUNCHER_NRO/);
assert.match(pack, /gen1recomp-game\.nro/);
assert.match(pack, /version\.txt/);
assert.equal(
fs.existsSync(path.join(root, 'scripts/switch/pack_ota_zip.sh')),
false,
'separate pack_ota_zip.sh removed — unified *-switch.zip'
);
const buildSwitch = read('scripts/build_switch.sh');
assert.doesNotMatch(buildSwitch, /--ota\b/);
assert.doesNotMatch(buildSwitch, /pack_ota_zip\.sh/);
assert.match(buildSwitch, /build_ota_launcher\.sh/);
assert.match(buildSwitch, /dual-NRO SD zip|OTA download asset/i);
const buildOtaLauncher = read('scripts/switch/build_ota_launcher.sh');
assert.match(buildOtaLauncher, /ota_launcher_deps_ready/);
assert.match(buildOtaLauncher, /fail_missing_devkitpro/);
assert.match(buildSwitch, /preflight_fused_build/);
const selftest = read('scripts/switch/selftest_build_switch.sh');
assert.match(selftest, /dual-NRO|gen1recomp-game\.nro/);
assert.match(selftest, /ota_launcher\.manifest/);
assert.match(selftest, /OTA uses the same SD zip|same SD zip|legacy OTA-only/i);
assert.match(selftest, /ota_ui|framebuffer|no prompt when up to date/i);
const manifest = read('scripts/switch/ota_launcher.manifest');
assert.match(manifest, /^OTA_ENABLED=1$/m);
assert.match(manifest, /OTA_ASSET_GLOB=gen1recomp-\*-switch\.zip/);
// Run the dual-NRO portion via full offline selftest (includes legacy + OTA)
const r = spawnSync('bash', [path.join(root, 'scripts/switch/selftest_build_switch.sh')], {
encoding: 'utf8',
});
assert.equal(r.status, 0, `selftest failed:\n${r.stdout}\n${r.stderr}`);
assert.match(r.stdout, /dual-NRO OTA layout/);
});
@@ -0,0 +1,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()
+145
View File
@@ -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)")
+134
View File
@@ -0,0 +1,134 @@
-- HostShell's HTTP error reporting. No pokered cite: host transport is
-- port-only plumbing.
--
-- A user hit the launcher's mod index against a rate-limited GitHub and all
-- they got was one line on the terminal:
--
-- curl: (56) The requested URL returned error: 403
--
-- That is curl talking to its own stderr. It names no URL, so with an index
-- feed, a releases API and a page of thumbnails all in flight there was no way
-- to tell WHICH fetch failed, and the caller upstream got a generic "empty
-- response" that said even less. HostShell now merges curl's stderr into the
-- pipe and asks for the status with --write-out, so every failure names its
-- URL and its HTTP code, and a 403 body ("API rate limit exceeded") reaches
-- the launcher's notice line where a user can act on it.
--
-- The seam is io.popen: these cases stub it to replay exactly what curl writes
-- for each outcome, which is the only way to pin the parsing without a network
-- and a cooperating server.
-- luajit tests/engine/host_shell_fetch_errors.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check = T.check
love = love or require("tests.love_stub")
local HostShell = require("src.core.HostShell")
-- The marker HostShell asks curl to print before the status code. Spelled
-- here the way it arrives (a real newline), not the way it is passed to curl
-- (a backslash-n escape curl expands itself).
local MARK = "\n__gen1recomp_http__"
-- Replay `output` as the next popen's whole stdout. `curl --version` is
-- answered separately so HostShell.haveCurl agrees a transport exists.
local realPopen = io.popen
local lastCommand
local function stubPopen(output)
io.popen = function(cmd, mode)
lastCommand = cmd
if cmd:find("--version", 1, true) then
return { read = function() return "curl 8.7.1 (test)" end,
close = function() return true end }
end
return { read = function() return output end,
close = function() return true end }
end
end
local function restorePopen() io.popen = realPopen end
local URL = "https://api.github.com/repos/example/thing/releases"
-- ------------------------------------------------------------------- 200
stubPopen('{"tag_name":"v1.2.3"}' .. MARK .. "200")
local body, err = HostShell.httpGet(URL, "gen1recomp", nil, 10)
check(body == '{"tag_name":"v1.2.3"}',
"a 200 returns the body with the status marker stripped: " .. tostring(body))
check(err == nil, "a 200 reports no error")
check(lastCommand:find("%-w ") ~= nil,
"the GET asks curl for the status code")
check(lastCommand:find("2>&1", 1, true) ~= nil,
"the GET captures curl's stderr instead of leaking it to the terminal")
check(lastCommand:find(" -f", 1, true) == nil,
"the GET does NOT pass -f: the error body is the diagnosis")
-- ------------------------------------------------------------------- 403
-- What GitHub actually sends when the launcher has burned its unauthenticated
-- hourly allowance, with curl's own stderr merged in ahead of it.
stubPopen('{"message":"API rate limit exceeded for 203.0.113.7."}'
.. MARK .. "403")
local body403, err403 = HostShell.httpGet(URL, "gen1recomp", nil, 10)
check(body403 == nil, "a 403 is a failure, not a body")
check(err403:find(URL, 1, true) ~= nil,
"a 403 names the URL that failed: " .. tostring(err403))
check(err403:find("403", 1, true) ~= nil, "a 403 names the status code")
check(err403:find("rate limit", 1, true) ~= nil,
"a 403 carries the server's own explanation through to the caller")
-- --------------------------------------------------- no response at all
-- DNS failure: curl writes its complaint and a http_code of 0. Zero is not a
-- status, and reporting "HTTP 0" would bury the only useful line there is.
stubPopen("curl: (6) Could not resolve host: nope.invalid" .. MARK .. "0")
local bodyDns, errDns = HostShell.httpGet("https://nope.invalid/x", "ua", nil, 10)
check(bodyDns == nil, "an unresolvable host is a failure")
check(errDns:find("HTTP 0", 1, true) == nil,
"a no-response failure is not reported as HTTP 0: " .. tostring(errDns))
check(errDns:find("https://nope.invalid/x", 1, true) ~= nil,
"an unresolvable host still names the URL")
check(errDns:find("Could not resolve", 1, true) ~= nil,
"an unresolvable host reports curl's own reason")
-- --------------------------------------------- a body containing the marker
-- The status is cut from the LAST marker only, so a payload that happens to
-- contain the token keeps every byte of its content.
local sneaky = "prefix" .. MARK .. "999" .. "suffix"
stubPopen(sneaky .. MARK .. "200")
local bodySneaky = HostShell.httpGet(URL, "gen1recomp", nil, 10)
check(bodySneaky == sneaky,
"only the trailing status marker is stripped: " .. tostring(bodySneaky))
-- ------------------------------------------------------------- downloads
-- The download branch keeps -f (no error body is written to the file), but it
-- must still name the URL and the code rather than "download failed".
stubPopen("curl: (56) The requested URL returned error: 403" .. MARK .. "403")
local ok, dlErr = HostShell.httpDownload(URL, "/tmp/gen1recomp-test.bin",
"gen1recomp", nil, 10)
check(ok == nil, "a 403 download fails")
check(dlErr:find(URL, 1, true) ~= nil,
"a failed download names the URL: " .. tostring(dlErr))
check(dlErr:find("403", 1, true) ~= nil, "a failed download names the code")
stubPopen(MARK .. "200")
local ok2, dlErr2 = HostShell.httpDownload(URL, "/tmp/gen1recomp-test.bin",
"gen1recomp", nil, 10)
check(ok2 == true, "a 200 download succeeds: " .. tostring(dlErr2))
restorePopen()
-- ---------------------------------------------------------------- pclose
-- Every pipe HostShell hands out must be closed through pclose: a bare
-- pipe:close() from one thread can free a FILE while another thread's popen
-- is walking libc's stream list, and that thread never wakes up again (the
-- launcher freezing on close after a visit to the mod tabs). Nothing here can
-- exercise the race headlessly -- the test stub has no love.thread -- so this
-- pins the entry point's existence and its tolerance of junk.
check(type(HostShell.pclose) == "function", "HostShell exposes pclose")
local closed = false
HostShell.pclose({ close = function() closed = true return true end })
check(closed, "pclose closes the pipe it is given")
local okNil = pcall(HostShell.pclose, nil)
check(okNil, "pclose on nil is a no-op rather than an error")
T.finish("host shell fetch errors")
@@ -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")
@@ -14,9 +14,18 @@
-- The seam is LauncherView.draw itself: it publishes the page-scroll extent
-- on the importer (imp._pageScroll / imp._pageScrollMax, the values the
-- touch-drag and wheel paths feed), so a headless draw shows whether the
-- scroll engaged without reading any file-local constant. The 480x900
-- window below is the discriminator: its natural panel space satisfies the
-- old flat threshold (inert, slot card clipped) but not the one-column one.
-- scroll engaged without reading any file-local constant.
--
-- What is asserted here is REACHABILITY, not scrolling: "+ New save slot" is
-- the control that sits at the very bottom of the one-column pile, and the
-- bug was that it drew where no tap could land. Either it fits in the
-- window outright, or the page scrolls far enough to bring it in -- both are
-- correct, and which one a given window gets depends on how tall the panel's
-- content happens to be. Asserting "this window scrolls" instead pinned the
-- test to the size of the stack: when the pinned Touch-Controls /
-- Reset-rebinds pair moved behind the gear and the save-file buttons moved
-- into the slot card, 480x900 started fitting outright and a scroll
-- assertion failed on a window that had just got BETTER.
--
-- #810 gets its unit-conversion pin in tests/engine/safe_area_units_test.lua;
-- here the complementary end-to-end anchor: Layout.metrics must place the
@@ -37,6 +46,7 @@ love.graphics.setLineJoin = love.graphics.setLineJoin or function() end
love.graphics.newShader = love.graphics.newShader or function() return {} end
local Layout = require("src.ui.kit.Layout")
local Kit = require("src.ui.kit.Kit")
local RomImporter = require("src.import.RomImporter")
local LauncherView = require("src.import.LauncherView")
@@ -52,7 +62,43 @@ local function freshLauncher()
return RomImporter.new(function() end, { launcher = true })
end
-- ------------------------------------------------ #852: the scroll engages
-- Draw a frame with the layout audit on and report where "+ New save slot"
-- landed. Kit records the rect every clickable control occupies plus the
-- clip that bounds its hit test, so this sees exactly what a tap would.
local function newSlotRect(imp)
Kit.audit = {}
LauncherView.draw(imp)
local found
for _, r in ipairs(Kit.audit) do
if r.class == "control" and tostring(r.label):find("New save slot", 1, true) then
found = r
end
end
Kit.audit = nil
return found
end
-- The control is reachable when its rect, intersected with whatever clip
-- bounds it, still has real area inside the window -- either straight away or
-- after the page is scrolled to the bottom.
local function reachable(imp, H)
local function visible()
local r = newSlotRect(imp)
if not r then return false end
local y1, y2 = r.y, r.y + r.h
if r.clip then
y1 = math.max(y1, r.clip.y)
y2 = math.min(y2, r.clip.y + r.clip.h)
end
y1, y2 = math.max(y1, 0), math.min(y2, H)
return (y2 - y1) > 1
end
if visible() then return true end
imp._pageScroll = 1e6 -- clamps to the extent inside draw()
return visible()
end
-- ------------------------------------------------ #852: the bottom is reachable
-- 480x900 one column: enough room for the old flat 460*s threshold, not for
-- the one-column stack. Before the fix draw() left _pageScrollMax at 0 here
-- and the slot card sat clipped inert against the pinned buttons.
@@ -61,30 +107,37 @@ local m = Layout.metrics(1200)
eq(m.twoCol, false, "480-wide window lays out one column")
local imp = freshLauncher()
LauncherView.draw(imp)
check((imp._pageScrollMax or 0) > 0,
"one-column window short of the stack engages the page scroll")
check(reachable(imp, 900),
"one-column window can reach the bottom of the slot card")
imp = freshLauncher()
LauncherView.draw(imp)
eq(imp._pageScroll, 0, "a fresh page starts at the top")
-- The wheel moves the page (the same offset the touch drag feeds), and the
-- offset clamps to the extent, so the whole stack down to "+ New save slot"
-- and the footer is reachable rather than clipped away.
local extent = imp._pageScrollMax
imp._wheelY = -1
LauncherView.draw(imp)
eq(imp._pageScroll, math.min(math.floor(48 * m.s), extent),
"one wheel notch scrolls the page down by its step")
imp._pageScroll = 1e6
LauncherView.draw(imp)
eq(imp._pageScroll, imp._pageScrollMax,
"an offset past the end clamps to the extent, so the bottom is reachable")
-- The reporter's portrait phone (360x780 units) is shorter still and must
-- also scroll; before the fix its slot list was unreachable.
-- The reporter's portrait phone (360x780 units) is shorter and narrower, so
-- it is the one that still engages the scroll; before the fix its slot list
-- was unreachable at any offset.
window(360, 780)
local pm = Layout.metrics(1200)
local phone = freshLauncher()
LauncherView.draw(phone)
check((phone._pageScrollMax or 0) > 0,
"portrait-phone one-column window engages the page scroll")
eq(phone._pageScroll, 0, "a fresh page starts at the top")
-- The wheel moves the page (the same offset the touch drag feeds), and the
-- offset clamps to the extent, so the whole stack down to "+ New save slot"
-- and the footer is reachable rather than clipped away.
local extent = phone._pageScrollMax
phone._wheelY = -1
LauncherView.draw(phone)
eq(phone._pageScroll, math.min(math.floor(48 * pm.s), extent),
"one wheel notch scrolls the page down by its step")
phone._pageScroll = 1e6
LauncherView.draw(phone)
eq(phone._pageScroll, phone._pageScrollMax,
"an offset past the end clamps to the extent, so the bottom is reachable")
check(reachable(phone, 780),
"the scrolled portrait phone reaches the bottom of the slot card")
-- A one-column window tall enough for the whole stack stays inert: the
-- column-aware minimum is a floor, not a permanent scroll.
+208
View File
@@ -0,0 +1,208 @@
-- Launcher panel reflow. No pokered cite: the launcher is port-only chrome.
--
-- Three reports from the same round of testing, all of them the same root
-- cause -- a panel laying out more content than its window could hold, with
-- no scrollbar to rescue what fell off:
--
-- * "Import failed only appears in that single line" -- the ROM card's
-- detail paragraph is elastic and got trimmed to zero lines whenever the
-- card's height budget was tight, so a failed import printed a headline
-- with no reason under it. The reporter's German ROM was rejected for a
-- specific, printable reason and the launcher swallowed it.
-- * "The settings labels are barely visible at all" -- settings rows put
-- the label and the value ladder side by side, and on a portrait phone
-- the ladder took so much of the width that every label ellipsized to
-- three characters ("TEX...", "BAT...", "BAT...").
-- * "these buttons don't appear correctly" / Play walking off the bottom --
-- the game panel pinned Play and a Touch-Controls/Reset-rebinds pair to
-- the bottom of a column whose height was whatever its cards needed, so
-- on a short window the pinned block left the window entirely.
--
-- The audit sweep at the end is the general form of the third: Kit records
-- every control that could take a click (plus the clip that bounds its hit
-- test) while Kit.audit is set, so a window-size sweep can assert that no two
-- controls overlap and that nothing escapes a window which is not scrolling.
-- A window that IS scrolling legitimately draws below the fold -- reachability
-- there is pinned by tests/engine/launcher_one_column_reach_bug852.lua.
-- luajit tests/engine/launcher_panel_reflow.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
love.graphics.setLineJoin = love.graphics.setLineJoin or function() end
love.graphics.newShader = love.graphics.newShader or function() return {} end
local Kit = require("src.ui.kit.Kit")
local RomImporter = require("src.import.RomImporter")
local LauncherView = require("src.import.LauncherView")
local function window(w, h)
love.graphics.getDimensions = function() return w, h end
love.graphics.getPixelDimensions = function() return w, h end
end
local function freshLauncher()
return RomImporter.new(function() end, { launcher = true })
end
-- Every string the frame printed. The kit falls back to love.graphics.print
-- under the stub (no newText), so recording that call captures the text the
-- panel actually put on screen -- ellipsis and all, which is the point.
local realPrint = love.graphics.print
local function drawAndCapture(imp)
local seen = {}
love.graphics.print = function(str, ...)
seen[#seen + 1] = tostring(str)
return realPrint(str, ...)
end
local ok, err = pcall(LauncherView.draw, imp)
love.graphics.print = realPrint
check(ok, "the frame draws: " .. tostring(err))
return table.concat(seen, "\n")
end
-- ------------------------------------- a failed import explains itself
-- setError stores the reason on imp.detail; the ROM card must print it, not
-- just the "Import failed" headline above it. Checked on the reporter's
-- phone shape, since a narrow window is exactly where the old budget
-- arithmetic trimmed the paragraph away.
local REASON = "This is a German ROM; only the English releases are supported."
window(360, 780)
local failed = freshLauncher()
failed:setError(REASON, "red")
failed.tab = "red"
local text = drawAndCapture(failed)
check(text:find("Import failed", 1, true) ~= nil,
"a failed import prints its headline")
check(text:find(REASON, 1, true) ~= nil,
"a failed import prints the REASON it failed, not just the headline")
-- The same on a desktop window, so the detail is not an artefact of one shape.
window(1280, 720)
local failedWide = freshLauncher()
failedWide:setError(REASON, "red")
failedWide.tab = "red"
check(drawAndCapture(failedWide):find(REASON, 1, true) ~= nil,
"the failure reason survives on a desktop window too")
-- ------------------------------- settings labels stay readable in portrait
-- Every core row's label must print in FULL. Side by side they could not,
-- so a narrow panel stacks the label on its own line above its control; the
-- assertion is on the text, not on the layout mode, because "the label is
-- readable" is the property that broke.
local function settingsText(w, h)
window(w, h)
local imp = freshLauncher()
imp:_openSettings()
check(imp._settings ~= nil, "the gear opens the settings model")
drawAndCapture(imp) -- first frame paginates
return drawAndCapture(imp)
end
local LONG_LABELS = { "TEXT SPEED", "BATTLE ANIMATION", "BATTLE STYLE" }
local portrait = settingsText(360, 780)
for _, label in ipairs(LONG_LABELS) do
check(portrait:find(label, 1, true) ~= nil,
("portrait settings print %q in full"):format(label))
end
check(portrait:find("BAT...", 1, true) == nil,
"no settings label is clipped to an ellipsis on a portrait phone")
-- A desktop window has room for the side-by-side shape and must not regress.
local desktop = settingsText(1280, 720)
for _, label in ipairs(LONG_LABELS) do
check(desktop:find(label, 1, true) ~= nil,
("desktop settings print %q in full"):format(label))
end
-- ----------------------------------------------- the layout audit sweep
local function clipped(r)
local x1, y1, x2, y2 = r.x, r.y, r.x + r.w, r.y + r.h
if r.clip then
x1 = math.max(x1, r.clip.x); y1 = math.max(y1, r.clip.y)
x2 = math.min(x2, r.clip.x + r.clip.w); y2 = math.min(y2, r.clip.y + r.clip.h)
end
if x2 - x1 <= 1 or y2 - y1 <= 1 then return nil end
return x1, y1, x2, y2
end
local function overlap(a, b)
local ax1, ay1, ax2, ay2 = clipped(a)
if not ax1 then return false end
local bx1, by1, bx2, by2 = clipped(b)
if not bx1 then return false end
return math.min(ax2, bx2) - math.max(ax1, bx1) > 1
and math.min(ay2, by2) - math.max(ay1, by1) > 1
end
-- `scrolling` windows are allowed to draw below the fold: that is the page
-- scroll doing its job, and the reach test covers it.
local function auditFrame(label, W, H, scrolling)
local controls = {}
for _, r in ipairs(Kit.audit or {}) do
if r.class == "control" then controls[#controls + 1] = r end
end
check(#controls > 0, label .. ": the frame dispatched controls at all")
local collisions, escapes = 0, 0
for i = 1, #controls do
local a = controls[i]
local x1, y1, x2, y2 = clipped(a)
if x1 and not scrolling
and (x1 < -0.5 or y1 < -0.5 or x2 > W + 0.5 or y2 > H + 0.5) then
escapes = escapes + 1
print((" escape: %s (%.0f,%.0f %.0fx%.0f)")
:format(a.label, a.x, a.y, a.w, a.h))
end
for j = i + 1, #controls do
if overlap(a, controls[j]) then
collisions = collisions + 1
print((" overlap: '%s' vs '%s' at (%.0f,%.0f) / (%.0f,%.0f)")
:format(a.label, controls[j].label, a.x, a.y,
controls[j].x, controls[j].y))
end
end
end
check(collisions == 0, label .. ": no two controls overlap")
check(escapes == 0, label .. ": every control stays inside the window")
end
-- The shapes the reports came from, plus the desktop ones they have to keep
-- serving: portrait phones, a 150%-scaled Linux handheld, 4:3, and widescreen.
local SIZES = {
{ 360, 780 }, { 412, 915 }, { 480, 900 }, { 720, 1280 },
{ 1280, 720 }, { 1024, 768 }, { 900, 700 }, { 1920, 1080 },
}
for _, size in ipairs(SIZES) do
local W, H = size[1], size[2]
window(W, H)
for _, tab in ipairs({ "red", "yellow", "mods", "find" }) do
local imp = freshLauncher()
imp.tab = tab
LauncherView.draw(imp) -- warm frame: pagination settles
Kit.audit = {}
local ok, err = pcall(LauncherView.draw, imp)
Kit.audit = ok and Kit.audit or nil
check(ok, ("%dx%d %s draws: %s"):format(W, H, tab, tostring(err)))
if ok then
auditFrame(("%dx%d %s"):format(W, H, tab), W, H,
(imp._pageScrollMax or 0) > 0)
end
Kit.audit = nil
end
-- The settings panel is its own layout and its own reflow.
local imp = freshLauncher()
imp:_openSettings()
LauncherView.draw(imp)
Kit.audit = {}
local ok, err = pcall(LauncherView.draw, imp)
Kit.audit = ok and Kit.audit or nil
check(ok, ("%dx%d settings draws: %s"):format(W, H, tostring(err)))
if ok then auditFrame(("%dx%d settings"):format(W, H), W, H, false) end
Kit.audit = nil
end
T.finish("launcher panel reflow")
+156
View File
@@ -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()
+7 -6
View File
@@ -152,27 +152,28 @@ check(comment_wf:find("comment-tag: switch-build-result", 1, true)
-- --- SWCI-08 / SWCI-09: docs CI vs release ---
local build_doc = read("docs/switch-build.md")
local development = read("docs/switch-development.md")
local readme = read("README.md")
mustContain(build_doc, "Path-gated", "switch-build.md")
mustContain(build_doc, "ubuntu-latest", "switch-build.md")
mustContain(build_doc, "selftest_build_switch.sh", "switch-build.md")
mustContain(build_doc, "canonical", "switch-build.md")
mustContain(build_doc, "main repo", "switch-build.md")
mustContain(build_doc, "gen1recomp-switch-nro", "switch-build.md")
mustContain(build_doc, "switch-build-result", "switch-build.md")
mustContain(build_doc, "hard gate", "switch-build.md")
mustContain(build_doc, "continue-on-error", "switch-build.md")
mustContain(build_doc, "nacptool", "switch-build.md")
mustContain(build_doc, "Docker", "switch-build.md")
mustContain(build_doc, "Fork → canonical", "switch-build.md")
mustContain(build_doc, "Fork PRs into the main repo", "switch-build.md")
mustContain(build_doc, "skip Switch fused", "switch-build.md")
mustContain(development, "Switch CI", "switch-development.md")
mustContain(development, "selftest_build_switch.sh", "switch-development.md")
mustContain(build_doc, "CI and release", "switch-build.md")
mustNotContain(build_doc, "switch-development", "switch-build.md")
mustNotContain(build_doc, "switch-hardware-evidence", "switch-build.md")
mustContain(readme, "CI vs release", "README.md")
mustContain(readme, "switch-build.md", "README.md")
mustNotContain(readme, "switch-development", "README.md")
mustNotContain(readme, "switch-hardware-evidence", "README.md")
-- --- SWFIX-03: headless suite also runs the content gates ---
local test_sh = read("scripts/test.sh")
+10 -29
View File
@@ -32,7 +32,7 @@ mustContain(transfer, "imports/", "transfer")
mustContain(transfer, "imports/mods/", "transfer")
mustContain(transfer, "1: SD Card", "transfer")
mustContain(transfer, "Scan again", "transfer")
mustContain(transfer, "one contributor example", "transfer")
mustContain(transfer, "documented example", "transfer")
mustContain(transfer, "Linux", "transfer")
mustContain(transfer, "Windows", "transfer")
mustContain(transfer, "macOS", "transfer")
@@ -46,7 +46,7 @@ mustContain(transfer, "Portable Devices", "transfer")
mustContain(transfer, "MTP USB Device", "transfer")
mustContain(transfer, "AppleDouble", "transfer")
mustContain(transfer, "card reader", "transfer")
mustContain(transfer, "Canonical methods", "transfer")
mustContain(transfer, "Transfer methods", "transfer")
mustContain(transfer, "OpenMTP", "transfer")
mustContain(transfer, "only one", "transfer")
mustContain(transfer, "USB-C", "transfer")
@@ -54,46 +54,27 @@ mustContain(transfer, "USB-C", "transfer")
mustContain(transfer, "If MTP is unavailable or flaky on Linux", "transfer")
mustContain(transfer, "If MTP is unavailable or flaky on Windows", "transfer")
mustContain(transfer, "Joy-Con display chords (stock engine)", "transfer")
mustNotContain(transfer, "VoxelMod", "transfer")
mustNotContain(transfer, "switch-development", "transfer")
mustNotContain(transfer, "switch-hardware-evidence", "transfer")
local install = read("docs/switch-install.md")
local build = read("docs/switch-build.md")
mustContain(install, "switch-transfer.md", "install")
mustContain(install, "## Community mods", "install")
mustContain(install, "Select + **A**", "install")
mustContain(install, "Select + **L**", "install")
mustContain(install, "COLORS", "install")
mustContain(install, "TILT", "install")
mustContain(install, "GBC FX", "install")
mustContain(install, "PERFORMANCE", "install")
mustContain(install, "Stock engine effect", "install")
mustContain(install, "## Limitations", "install")
mustContain(install, "Launch with title override", "install")
mustNotContain(install, "VoxelMod", "install")
mustNotContain(install, "switch-development", "install")
mustContain(build, "switch-transfer.md", "build")
mustContain(build, "nxlink", "build")
local development = read("docs/switch-development.md")
mustContain(development, "switch-transfer.md", "development")
mustContain(development, "## Joy-Con display chords (Select + face)", "development")
mustContain(development, "Stock engine effect", "development")
mustContain(development, "claimed by the engine before mod pipeline", "development")
mustContain(development, "Select + **L**", "development")
mustContain(development, "OPTIONS → PERFORMANCE", "development")
mustNotContain(development, "VoxelMod", "development")
check(development:find("Non-macOS contributor MTP runbooks", 1, true) == nil,
"development must not list Non-macOS runbooks as absent")
local evidence = read("docs/switch-hardware-evidence.md")
local nxStart = evidence:find("## NXMOD-12", 1, true)
check(nxStart ~= nil, "NXMOD-12 section present")
local nxmod = evidence:sub(nxStart)
mustContain(nxmod, "**pass**", "NXMOD-12")
mustContain(nxmod, "531", "NXMOD-12")
mustContain(nxmod, "switch-oled-photos", "NXMOD-12")
mustContain(nxmod, "IMG_1766.jpg", "NXMOD-12")
mustContain(nxmod, "IMG_1771.jpg", "NXMOD-12")
mustContain(nxmod, "Community mod zip", "NXMOD-12")
mustNotContain(nxmod, "VoxelMod", "NXMOD-12")
check(nxmod:find("Status | **pending**", 1, true) == nil
and nxmod:find("| **pending** |", 1, true) == nil,
"NXMOD-12 must not keep pending status/checklist")
mustNotContain(build, "switch-development", "build")
mustNotContain(build, "switch-hardware-evidence", "build")
T.finish("switch_transfer_docs_test")
+7 -3
View File
@@ -1,4 +1,4 @@
# switch-probe love-nx hardware probe
# switch-probe: love-nx hardware probe
**NOT FOR RELEASE.** This package is a developer-only diagnostic for Nintendo Switch (love-nx). Do not ship it inside `game.love` or release NRO payloads.
@@ -11,7 +11,9 @@ Validate Phase 0 runtime facts on real Switch hardware before running the full G
- Save directory path (`love.filesystem.getSaveDirectory()`)
- Gamepad / joystick / touch event logging
To date this probe has only been run on **Switch OLED** (see `docs/switch-hardware-evidence.md`); other models are untested. Deploy beside `gen1recomp.nro` remains **manual** (MTP); see `docs/switch-development.md`.
To date this probe has only been run on **Switch OLED**; other models are
untested. Deploy beside `gen1recomp.nro` remains **manual** (MTP). See
`docs/switch-transfer.md`.
## Fields shown on screen
@@ -36,7 +38,9 @@ mkdir -p .bazinga/work
zip -9 -j .bazinga/work/switch-probe.love tools/switch-probe/main.lua tools/switch-probe/conf.lua
```
Deploy beside `gen1recomp.nro` (loose mode) per `docs/switch-development.md`, renaming to `game.love` only for a probe run — use a separate SD folder so probe and game builds do not mix.
Deploy beside `gen1recomp.nro` (loose mode) per `docs/switch-build.md` and
`docs/switch-transfer.md`. Rename to `game.love` only for a probe run. Use a
separate SD folder so probe and game builds do not mix.
## Desktop smoke (optional)