Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 60cf07fb0a | |||
| aa6217e581 | |||
| a640f5504f | |||
| e01672b97d | |||
| 9e3d487a08 | |||
| 506cd2c5c5 | |||
| 6cb0ac5666 | |||
| b7d0ff2e16 | |||
| 984cefdc7b | |||
| 6c892cb7c1 | |||
| 52d1ddc5af | |||
| 75cd28435c | |||
| 9aa8b4e371 | |||
| e94d8e32ef | |||
| 3fae3088ca | |||
| cbb08f76ae | |||
| dd503706b0 | |||
| eeb181bcc0 | |||
| 287534cf26 | |||
| d08fce5fd5 | |||
| 84e1a5177b | |||
| c7e72149f1 | |||
| ecfca19f11 | |||
| 90a53e509a | |||
| 15fc97a996 | |||
| 35ed326dda | |||
| dc04a4e848 | |||
| 1bfc89fdd4 | |||
| ae5276b2d1 | |||
| 4dd4c5c463 | |||
| cafa6f3d61 |
@@ -199,7 +199,7 @@ jobs:
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
if git diff --name-only "$BASE_SHA" "$HEAD_SHA" | grep -Eq '^(ports/uwp/|scripts/build_xbox_uwp\.sh$|scripts/xbox-uwp/|scripts/pack_love\.sh$|\.github/workflows/(ci|release)\.yml$|src/core/Platform\.lua$|src/import/(CacheFs|RomImporter)\.lua$|src/update/Check\.lua$|tests/engine/(platform_nx|uwp_native_picker)_test\.lua$|tests/rom_importer_double_pick_test\.lua$)'; then
|
||||
if git diff --name-only "$BASE_SHA" "$HEAD_SHA" | grep -Eq '^(ports/uwp/|scripts/build_xbox_uwp\.sh$|scripts/xbox-uwp/|scripts/pack_love\.sh$|\.github/workflows/(ci|release)\.yml$|src/core/Platform\.lua$|src/import/(CacheFs|LauncherView|RomImporter)\.lua$|src/update/Check\.lua$|tests/engine/(platform_nx|uwp_baseroms|uwp_native_picker)_test\.lua$|tests/rom_importer_double_pick_test\.lua$)'; then
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
@@ -31,12 +31,29 @@ mobile/ios/love-src/
|
||||
mobile/ios/cache/
|
||||
mobile/ios/build/
|
||||
|
||||
# love-nx vendor binaries (fetch per docs/switch-development.md; also covered by .*)
|
||||
# love-nx vendor binaries (fetch per docs/switch-build.md; also covered by .*)
|
||||
.bazinga/love-nx/
|
||||
|
||||
# Final packaged build artifacts (mac/win/web/android/ios/switch) — see scripts/build.sh
|
||||
/dist/
|
||||
|
||||
# Switch OTA launcher build outputs
|
||||
ports/switch/ota-launcher/build/
|
||||
ports/switch/ota-launcher/build-host/
|
||||
ports/switch/ota-launcher/*.nro
|
||||
ports/switch/ota-launcher/*.nacp
|
||||
ports/switch/ota-launcher/*.elf
|
||||
ports/switch/ota-launcher/*.map
|
||||
ports/switch/ota-launcher/romfs/logo.rgba
|
||||
ports/switch/ota-launcher/romfs/logo.png
|
||||
ports/switch/ota-launcher/romfs/cacert.pem
|
||||
ports/switch/ota-launcher/romfs/ota-bootstrap.nro
|
||||
ports/switch/ota-bootstrap/build/
|
||||
ports/switch/ota-bootstrap/*.nro
|
||||
ports/switch/ota-bootstrap/*.nacp
|
||||
ports/switch/ota-bootstrap/*.elf
|
||||
ports/switch/ota-bootstrap/*.map
|
||||
|
||||
# Legacy manual convenience-copy location (superseded by /dist/android/)
|
||||
mobile/dist/
|
||||
|
||||
|
||||
@@ -255,6 +255,40 @@ build and install from source on a Mac instead, see
|
||||
<a href="https://github.com/bryanthaboi/gen1recomp/releases/latest"><img src="./.github/resources/github-badge.png" alt="Download from GitHub" height="60"></a>
|
||||
</div>
|
||||
|
||||
## Xbox Dev Mode
|
||||
|
||||
Every release ships `gen1recomp-*-xbox-uwp.zip` for Xbox One and Xbox Series
|
||||
consoles in Developer Mode. It cannot be installed in retail mode.
|
||||
|
||||
Extract the archive, then use Xbox Device Portal to install the `.msix` and
|
||||
the x64 package under `Dependencies`.
|
||||
|
||||
### External setup
|
||||
|
||||
1. Put your legally obtained Red, Blue, or Yellow ROMs on an external drive.
|
||||
Mod ZIPs can go on the same drive.
|
||||
2. Connect the drive to the Xbox and open Gen1Recomp.
|
||||
3. Select **Import ROM** or **Import Mod**, then choose the file with the Xbox
|
||||
file picker.
|
||||
4. Repeat the ROM import for each version you want to use.
|
||||
|
||||
### Internal setup
|
||||
|
||||
1. Create a folder named `baseroms` on your PC and place your legally obtained
|
||||
Red, Blue, or Yellow ROMs inside it.
|
||||
2. ZIP the folder, keeping `baseroms` at the top level of the archive.
|
||||
3. Launch Gen1Recomp once, then close it.
|
||||
4. Open Xbox Device Portal and upload the ZIP to
|
||||
`Gen1Recomp/LocalState/pokemon-love2d/`.
|
||||
5. Choose **Yes** when Device Portal asks whether to extract the archive.
|
||||
6. Open Gen1Recomp. The launcher checks baseroms once at startup. When it finds a compatible ROM, that game’s tab shows ROM FOUND and an Import detected ROM button.
|
||||
|
||||
ROMs, generated game data, saves, and mods remain in LocalState and are not
|
||||
included in the app.
|
||||
|
||||
Source builds and package details are covered in
|
||||
[the Xbox UWP build notes](ports/uwp/BUILD.md).
|
||||
|
||||
## Handhelds
|
||||
|
||||
A PortMaster-style port for the **Anbernic RG34XXSP** on Stock OS 64-bit MOD
|
||||
@@ -264,24 +298,18 @@ Install steps, controls, and troubleshooting live in
|
||||
|
||||
## Nintendo Switch
|
||||
|
||||
Releases ship an SD-ready `gen1recomp-*-switch.zip` (issue
|
||||
[#531](https://github.com/bryanthaboi/gen1recomp/issues/531)). Runtime target
|
||||
is pinned [love-nx](https://github.com/retronx-team/love-nx) `11.5-nx1`.
|
||||
Requires a console that can run Switch homebrew. Hardware evidence: **OLED**
|
||||
(author) and **V1 / Erista** boot (community).
|
||||
Releases ship an SD-ready `gen1recomp-*-switch.zip`. Runtime target is pinned
|
||||
[love-nx](https://github.com/retronx-team/love-nx) `11.5-nx1`. Requires a
|
||||
console that can run Switch homebrew.
|
||||
|
||||
- Players: [docs/switch-install.md](docs/switch-install.md) — download the
|
||||
- Players: [docs/switch-install.md](docs/switch-install.md). Download the
|
||||
zip, extract at the microSD root (install or update), title-override
|
||||
launch, import your own legal ROM, Joy-Con controls and shortcuts.
|
||||
- Builders: [docs/switch-build.md](docs/switch-build.md) — `--fetch` /
|
||||
`--loose` / `--fused`, toolchain, Docker fallback, and **CI vs release**
|
||||
(path-gated ubuntu selftest, canonical fused PR artifact, release hard-fail).
|
||||
|
||||
Limitations, Dusklight-derived method, and how we tested:
|
||||
[docs/switch-development.md](docs/switch-development.md) and
|
||||
[docs/switch-hardware-evidence.md](docs/switch-hardware-evidence.md). Community
|
||||
help — especially HOS / love-nx packaging and broader hardware coverage — is
|
||||
welcome.
|
||||
- Builders: [docs/switch-build.md](docs/switch-build.md). `--fetch` /
|
||||
`--loose` / `--fused`, toolchain, Docker fallback, and CI vs release
|
||||
(path-gated ubuntu selftest, fused PR artifact on the main repo, release
|
||||
hard-fail).
|
||||
- File transfer (MTP / SD / FTP): [docs/switch-transfer.md](docs/switch-transfer.md).
|
||||
|
||||
## Modding
|
||||
|
||||
@@ -330,7 +358,4 @@ This project would not be possible without [pret](https://github.com/pret) >
|
||||
the pret band of decompiling maniacs > and their
|
||||
[pokered](https://github.com/pret/pokered) disassembly.
|
||||
|
||||
Nintendo Switch port: [andrewqsantos](https://github.com/andrewqsantos).
|
||||
Switch hardware testing (V1 boot): [booshankles](https://github.com/booshankles).
|
||||
|
||||
<p align="center"><a href="https://boisclub.games"><img src="https://raw.githubusercontent.com/bryanthaboi/gen1recomp/refs/heads/dev/assets/logo/bcg.png"></a></p>
|
||||
|
||||
@@ -1,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`.
|
||||
|
||||
@@ -1,38 +1,66 @@
|
||||
# Build the Nintendo Switch NRO — contributor guide
|
||||
# Build Gen1Recomp for Nintendo Switch
|
||||
|
||||
Want to play a release build instead? Download the SD-ready zip and extract it
|
||||
at your microSD root — see [switch-install.md](switch-install.md).
|
||||
at your microSD root. See [switch-install.md](switch-install.md).
|
||||
|
||||
This guide is for contributors who build Gen1Recomp for Switch from source.
|
||||
Hardware evidence, MTP operator loops, and deeper notes live in
|
||||
[switch-development.md](switch-development.md).
|
||||
|
||||
> Releases ship `gen1recomp-*-switch.zip` (SD tree under `switch/gen1recomp/`;
|
||||
> issue [#531](https://github.com/bryanthaboi/gen1recomp/issues/531)). Hardware
|
||||
> evidence: **OLED** (author) and **V1 boot** (community). See
|
||||
> [switch-development.md](switch-development.md) for known limitations.
|
||||
> Releases ship `gen1recomp-*-switch.zip` (SD tree under `switch/gen1recomp/`).
|
||||
> Runtime target is pinned [love-nx](https://github.com/retronx-team/love-nx)
|
||||
> `11.5-nx1`. Player install and limitations: [switch-install.md](switch-install.md).
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites by OS
|
||||
|
||||
All packaging entrypoints are **bash**. On Windows, use Git Bash, MSYS2, or
|
||||
WSL — not cmd.exe or PowerShell (AD-008).
|
||||
WSL, not cmd.exe or PowerShell (AD-008).
|
||||
|
||||
### macOS / Linux
|
||||
|
||||
1. Install [devkitPro pacman](https://devkitpro.org/wiki/devkitPro_pacman).
|
||||
2. Install Switch tools:
|
||||
2. Install Switch tools (**required for `--fused`**):
|
||||
|
||||
```sh
|
||||
sudo dkp-pacman -S switch-dev
|
||||
```
|
||||
|
||||
3. Ensure `nacptool` and `elf2nro` are on `PATH` (or under
|
||||
`$DEVKITPRO/tools/bin` — the fused script prepends that when set).
|
||||
3. OTA launcher toolchain, **native or Docker** (either is fine):
|
||||
|
||||
**Optional:** Install [Docker](https://docs.docker.com/get-docker/) so fused
|
||||
builds can fall back to the pinned image when native tools are missing.
|
||||
```sh
|
||||
bash scripts/switch/install_devkitpro_deps.sh # native
|
||||
# or install Docker (same pin as fused builds)
|
||||
```
|
||||
|
||||
4. Ensure `DEVKITPRO` is exported (typical macOS: `/opt/devkitpro`) and
|
||||
`nacptool` / `elf2nro` are on `PATH` (or under `$DEVKITPRO/tools/bin`).
|
||||
|
||||
Fused game builds can also use Docker when native `nacptool`/`elf2nro` are absent.
|
||||
|
||||
### Native OTA launcher (included in `--fused`)
|
||||
|
||||
In-console OTA uses a **separate DEVKITPRO NRO** (not LÖVE). The LÖVE
|
||||
self-updater (`Check.lua`) is disabled on NX. Source:
|
||||
`ports/switch/ota-launcher/`. Host protocol tests (no toolchain):
|
||||
|
||||
```sh
|
||||
make -C ports/switch/ota-launcher host-test
|
||||
# or
|
||||
scripts/switch/build_ota_launcher.sh # host-test first; NRO needs DEVKITPRO/Docker
|
||||
```
|
||||
|
||||
`--fused` always builds the fused game, native OTA launcher, and dual-NRO SD
|
||||
zip. The same `*-switch.zip` is the OTA download asset. **DEVKITPRO is
|
||||
required.** OTA launcher: native packages **or** Docker. Both are supported.
|
||||
|
||||
Release-like build from repo root:
|
||||
|
||||
```sh
|
||||
scripts/build_switch.sh --fetch --fused --version X.Y.Z
|
||||
```
|
||||
|
||||
See `ports/switch/ota-launcher/README.md` and
|
||||
`scripts/switch/ota_launcher.manifest`.
|
||||
|
||||
### Windows (Git Bash / MSYS2 / WSL)
|
||||
|
||||
@@ -42,16 +70,15 @@ builds can fall back to the pinned image when native tools are missing.
|
||||
- **WSL** (Ubuntu/etc.) with the Linux pacman flow above, or
|
||||
- **Git Bash** for `--fetch` / `--loose`; for `--fused` prefer MSYS2 or
|
||||
WSL if Docker bind-mounts from Git Bash paths misbehave.
|
||||
2. Install `switch-dev` (or rely on Docker fallback — see below).
|
||||
2. Install `switch-dev` (or rely on Docker fallback; see below).
|
||||
3. Do **not** expect `scripts/build_switch.sh` to run under cmd/PowerShell.
|
||||
|
||||
### What you must install yourself
|
||||
|
||||
| You install | Script does **not** install |
|
||||
| ----------- | --------------------------- |
|
||||
| bash, git, zip tooling the repo already expects | — |
|
||||
| `dkp-pacman` + `switch-dev` (native fused) | `dkp-pacman -S …` |
|
||||
| Docker (optional fused fallback) | Docker Engine |
|
||||
| bash, git, zip tooling the repo already expects | (none) |
|
||||
| `dkp-pacman` + `switch-dev` + OTA packages **or** Docker | `dkp-pacman -S …` |
|
||||
| A legal `.gb` ROM (to play) | Any ROM or game data |
|
||||
|
||||
---
|
||||
@@ -64,12 +91,12 @@ builds can fall back to the pinned image when native tools are missing.
|
||||
| ---- | ------------ |
|
||||
| `--fetch` | Downloads pinned **love.nro** + **love.elf** into `.bazinga/love-nx/11.5-nx1/` and verifies SHA-256 against `scripts/switch/love-nx-11.5-nx1.sha256`. |
|
||||
| `--loose` | Packs `game.love`, copies pinned `love.nro` → `dist/switch/loose/` as `gen1recomp.nro` + `game.love` side by side. Needs the pin. |
|
||||
| `--fused` | Builds `dist/switch/gen1recomp-<ver>-switch.nro` (game in romfs) via `nacptool` + `elf2nro`, then packs `dist/switch/gen1recomp-<ver>-switch.zip` (SD-ready tree). Needs the pin + toolchain (native or Docker). GitHub Releases publish the **zip only**. |
|
||||
| `--fused` | Builds fused game NRO, OTA launcher NRO, and dual-NRO SD zip. **Requires DEVKITPRO** + `switch-dev`. OTA launcher: native packages or Docker. GitHub Releases publish the **zip only**. |
|
||||
|
||||
Rules:
|
||||
|
||||
- `--fetch` alone is fine; combine as `--fetch --loose` or `--fetch --fused`.
|
||||
- `--loose` and `--fused` are **XOR** — pick one packaging path per run.
|
||||
- `--loose` and `--fused` are **XOR**. Pick one packaging path per run.
|
||||
- `--version X.Y.Z` sets the NACP / filename version (defaults to short git SHA).
|
||||
|
||||
### What `--fetch` downloads
|
||||
@@ -109,13 +136,15 @@ scripts/build_switch.sh --fetch
|
||||
# Loose pair for iteration (fetch + assemble)
|
||||
scripts/build_switch.sh --fetch --loose
|
||||
|
||||
# Single fused NRO + SD-ready zip for a release-like artifact
|
||||
# Fused game + OTA launcher + dual-NRO SD zip for a release-like artifact
|
||||
scripts/build_switch.sh --fetch --fused --version 0.2.0
|
||||
```
|
||||
|
||||
Outputs land under `dist/switch/` (and `dist/switch/loose/` for loose mode).
|
||||
The fused path also writes `gen1recomp-<ver>-switch.nro.sha256` and
|
||||
`gen1recomp-<ver>-switch.zip` (+ `.sha256` sidecar for the zip).
|
||||
The fused path also writes `gen1recomp-<ver>-switch.nro` (game),
|
||||
`gen1recomp-<ver>-launcher.nro`, `gen1recomp-<ver>-game.nro`,
|
||||
`gen1recomp-<ver>-switch.nro.sha256`, and `gen1recomp-<ver>-switch.zip`
|
||||
(+ `.sha256` sidecar for the zip).
|
||||
|
||||
Offline packaging smoke (no network, no nacptool required):
|
||||
|
||||
@@ -143,7 +172,7 @@ the NX runtime modules `src/core/NxAssetOverlay.lua`, `src/core/Platform.lua`,
|
||||
`tests/engine/switch_diagnostics_test.lua`, `tests/engine/platform_nx_*`,
|
||||
or the Switch-related workflow YAML), CI runs:
|
||||
|
||||
1. **Offline selftest** on `ubuntu-latest` (forks **and** the canonical repo):
|
||||
1. **Offline selftest** on `ubuntu-latest` (forks **and** the main repo):
|
||||
`scripts/switch/selftest_build_switch.sh`,
|
||||
`scripts/switch/verify_payload.sh --self-test`,
|
||||
`luajit tests/switch_ci_workflows_test.lua`,
|
||||
@@ -151,14 +180,10 @@ or the Switch-related workflow YAML), CI runs:
|
||||
headlessly (`luajit tests/engine/assets_version_fallback_test.lua`,
|
||||
`luajit tests/engine/nx_generated_guard_test.lua`,
|
||||
`luajit tests/engine/nx_yellow_boot_test.lua`).
|
||||
2. **Fused NRO build** only on the **canonical** repository
|
||||
2. **Fused NRO build** only on the **main** repository
|
||||
(`bryanthaboi/gen1recomp`), on the self-hosted Mac runner
|
||||
(`scripts/build_switch.sh --fetch --fused`), and only when the workflow
|
||||
head is that repo (same-repo push/PR). **Fork repository** CI never runs
|
||||
fused. **Fork → canonical PRs** also skip Switch fused (offline selftest
|
||||
still runs) so untrusted head code is not executed on the self-hosted Mac;
|
||||
iOS device build eligibility is unchanged. Fused also waits for a successful
|
||||
offline selftest before starting on the Mac runner.
|
||||
head is that repo (same-repo push/PR). Fork CI never runs fused. Fork PRs into the main repo also skip Switch fused (offline selftest still runs) so untrusted head code is not executed on the self-hosted Mac; iOS device build eligibility is unchanged. Fused also waits for a successful offline selftest before starting on the Mac runner.
|
||||
3. On successful PR fused builds, a follow-up workflow posts a PR comment
|
||||
linking the Actions artifact named `gen1recomp-switch-nro`
|
||||
(comment tag `switch-build-result`; see
|
||||
@@ -169,7 +194,7 @@ Unrelated PRs do not burn the self-hosted Mac on Switch packaging.
|
||||
### Release hard-fail (`.github/workflows/release.yml`)
|
||||
|
||||
GitHub Releases always build Switch on the same self-hosted Mac runner as the
|
||||
other platforms — this is a **hard gate** (no `continue-on-error`):
|
||||
other platforms. This is a **hard gate** (no `continue-on-error`):
|
||||
|
||||
```sh
|
||||
scripts/build_switch.sh --fetch --fused --version "<release version>"
|
||||
@@ -181,9 +206,26 @@ A Switch packaging failure fails the entire release job. The release asset is
|
||||
|
||||
### Runner provisioning
|
||||
|
||||
The self-hosted Mac runner must have **native switch-tools** (`nacptool` /
|
||||
`elf2nro`) **and/or Docker** available. CI and release do not silently run
|
||||
`dkp-pacman -S`; keep the runner image/host provisioned per this guide.
|
||||
The self-hosted Mac runner **must** have **DEVKITPRO** installed and exported.
|
||||
`--fused` preflight fails early with setup steps if it is missing.
|
||||
|
||||
**One-time setup on the runner** (if not already present):
|
||||
|
||||
```sh
|
||||
# devkitPro pacman installer from https://devkitpro.org/wiki/devkitPro_pacman
|
||||
sudo dkp-pacman -S switch-dev
|
||||
export DEVKITPRO=/opt/devkitpro
|
||||
export PATH="$DEVKITPRO/tools/bin:$PATH"
|
||||
|
||||
# OTA launcher: pick one
|
||||
bash scripts/switch/install_devkitpro_deps.sh # native
|
||||
# or ensure Docker is installed (same pin as fused builds)
|
||||
```
|
||||
|
||||
CI and release still run `scripts/build_switch.sh --fetch --fused`. Preflight
|
||||
requires DEVKITPRO and either native OTA packages or Docker. Without all of
|
||||
that, the job fails with the setup steps above. Scripts never auto-run
|
||||
`dkp-pacman -S` during CI.
|
||||
|
||||
---
|
||||
|
||||
@@ -194,11 +236,9 @@ These scripts and this guide do **not**:
|
||||
- Push files to the console (no automated MTP / FTP / SD scripting)
|
||||
- Bundle or download any Pokémon ROM
|
||||
- Install `dkp-pacman` / `switch-dev` for you
|
||||
- Provide `nxlink` / netloader deploy (deferred — see [switch-transfer.md](switch-transfer.md))
|
||||
- Validate **Applet Mode** — use title override (hold **R**) for full memory
|
||||
- Provide `nxlink` / netloader deploy (deferred; see [switch-transfer.md](switch-transfer.md))
|
||||
- Validate **Applet Mode**. Use title override (hold **R**) for full memory
|
||||
|
||||
Player install steps: [switch-install.md](switch-install.md).
|
||||
Manual transfer (MTP / SD / FTP, macOS / Linux / Windows):
|
||||
[switch-transfer.md](switch-transfer.md).
|
||||
Hardware depth and evidence: [switch-development.md](switch-development.md),
|
||||
[switch-hardware-evidence.md](switch-hardware-evidence.md).
|
||||
[switch-transfer.md](switch-transfer.md).
|
||||
|
||||
@@ -1,528 +0,0 @@
|
||||
# Nintendo Switch development (love-nx)
|
||||
|
||||
> Fused NRO support for issue [#531](https://github.com/bryanthaboi/gen1recomp/issues/531).
|
||||
> Releases ship `gen1recomp-*-switch.zip` (SD-ready tree). Console copy is
|
||||
> extract/merge at microSD root; title override required. See
|
||||
> [Known limitations](#known-limitations-read-before-reviewing).
|
||||
|
||||
**Canonical install / build / transfer docs** (start here unless you need hardware depth):
|
||||
|
||||
- Players → [switch-install.md](switch-install.md)
|
||||
- Builders → [switch-build.md](switch-build.md) (`scripts/build_switch.sh --fetch` downloads the pinned love-nx pair)
|
||||
- Transfer (MTP / SD / FTP on macOS, Linux, Windows) → [switch-transfer.md](switch-transfer.md)
|
||||
|
||||
This document covers what landed, known limitations, how hardware was tested,
|
||||
vendor layout, build/deploy, and the contributor transfer loop (detail lives in
|
||||
the transfer runbook).
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
- **Port / love-nx packaging:** [andrewqsantos](https://github.com/andrewqsantos)
|
||||
- **Community hardware testing** (Switch V1 / Erista boot): [booshankles](https://github.com/booshankles)
|
||||
- **Method guidance:** [Dusklight Switch port](https://github.com/HayatoG/dusklight/tree/main/platforms/switch) / love-nx
|
||||
- **Upstream project:** [bryanthaboi](https://github.com/bryanthaboi) / Gen1Recomp
|
||||
|
||||
## Status
|
||||
|
||||
| Area | State |
|
||||
| ---- | ----- |
|
||||
| Feature | **Available** — playable fused NRO path (issue #531) |
|
||||
| Runtime | Pinned love-nx **`11.5-nx1`** |
|
||||
| Product artifact | Releases: SD-ready `gen1recomp-*-switch.zip`; local/PR: fused `.nro`; loose `nro`+`game.love` for iteration |
|
||||
| Hardware | **OLED** validated (author, title override); **V1 / Erista** boot confirmed (community). Lite, docked soak, and Pro Controller matrices welcome |
|
||||
| Deploy / install | Releases publish SD-ready zip; **extract/merge at microSD root** (MTP / SD / FTP — [switch-transfer.md](switch-transfer.md)); no `nxlink` path yet |
|
||||
| Contributor transfer | Documented for **macOS, Linux, and Windows**; OpenMTP on Mac is one example, not the only contract |
|
||||
| Network features on NX | Self-update / remote mod download **disabled** (`networkValidated == false`) |
|
||||
| Community help | Welcome — especially HOS / love-nx packaging and broader hardware coverage |
|
||||
|
||||
### What landed
|
||||
|
||||
- Detect `NX` via `src/core/Platform.lua` without reusing Android flags
|
||||
- Writable ROM inbox under `getSaveDirectory()/imports/` + per-tab “Scan again” (SHA-1 match for the open game)
|
||||
- Joy-Con / gamepad mapping shared by launcher and gameplay (Nintendo A/B UX on NX)
|
||||
- Launcher L/R tab switch; gameplay L/R game-speed cycle; Select+face display chords
|
||||
- Focus loss / joystick reconnect recovery; opt-in `switch-debug.txt` diagnostics
|
||||
- Loose assemble + fused NRO build scripts (`scripts/build_switch.sh`, `scripts/switch/*`)
|
||||
- Payload gates so ROM / generated cache / saves never enter `game.love`
|
||||
- Community mod zip inbox at `imports/mods/` (rescan installs; FIND MODS stays network-gated)
|
||||
- Raw `.sav` inbox at `imports/saves/{red,blue,yellow}/` (**Import save** rescan) + export pull path `exports/{red,blue,yellow}/` (MTP hint; no openURL)
|
||||
- Hardware evidence for Phase 0 probe, ROM import, naming A/B, save/suspend, fused NRO — see `docs/switch-hardware-evidence.md`
|
||||
- Path-gated CI selftest + canonical fused PR artifact; release Switch hard-fail
|
||||
- Save editor pad/touch input (virtual cursor, A click, B close) — see `tools/save-editor/README.md`
|
||||
- Dynamic display size on NX only: handheld **1280×720**, docked/TV **1920×1080** (`src/core/NxDisplay.lua` + resizable conf so love-nx SDL can follow dock/undock at runtime)
|
||||
|
||||
### Known gaps / welcome contributions
|
||||
|
||||
- Docked vs handheld soak (≥30 min) and Lite coverage — resolution switch is implemented; long soak still welcome
|
||||
- Switch Lite and fuller Pro Controller / third-party pad matrices
|
||||
- Applet Mode remains unsupported by design (title override required)
|
||||
- `nxlink` / netloader contrib fast-loop (deferred — see [switch-transfer.md](switch-transfer.md))
|
||||
|
||||
Transfer runbooks for Linux/Windows (and SD/FTP alternatives) are in
|
||||
[switch-transfer.md](switch-transfer.md). Community mod zip install OLED smoke
|
||||
is **pass** — see NXMOD-12 in [switch-hardware-evidence.md](switch-hardware-evidence.md).
|
||||
|
||||
## Design references (Dusklight)
|
||||
|
||||
This work borrowed method — not the native stack — from the [Dusklight Switch port](https://github.com/HayatoG/dusklight/tree/main/platforms/switch), especially [`LESSONS_AND_REUSE.md`](https://github.com/HayatoG/dusklight/blob/main/platforms/switch/LESSONS_AND_REUSE.md):
|
||||
|
||||
| Dusklight lesson | How Gen1Recomp applied it |
|
||||
| ---------------- | ------------------------- |
|
||||
| Emulators hide Tegra failures | Gate milestones on **real OLED hardware**, not Ryujinx/Yuzu alone |
|
||||
| Prove the lower layer first | `tools/switch-probe` before full launcher |
|
||||
| Know which binary ran | Embedded `build-info.json` (commit / love-nx tag) |
|
||||
| Cap continuous logs | Opt-in diagnostics, ≤1 Hz flush; Lua error log rotation |
|
||||
| Crash symbolization needs the exact ELF | Keep pinned `love.elf` with the NRO under test |
|
||||
| Full memory matters | Title override; Applet Mode is not the validation path |
|
||||
| Do not treat SD FS like desktop POSIX | Lua stays on `love.filesystem`; inbox + MTP for user files |
|
||||
| Isolate platform code | Capability module instead of Android flag overload |
|
||||
| NVK / WSI / `audren` stacks | **Not** copied — love-nx already supplies video/audio/input/FS |
|
||||
|
||||
The packaging goal matches Dusklight’s **single self-contained `.nro`**; contributor transfer stays multi-host (not Mac-only).
|
||||
|
||||
## Known limitations (read before reviewing)
|
||||
|
||||
1. **Transfer is manual and multi-method.** Runtime only needs files under the LÖVE save directory / NRO install folder. Use MTP, direct SD, or FTP per [switch-transfer.md](switch-transfer.md). macOS + OpenMTP is a documented example for OLED evidence — not “Switch requires a Mac.”
|
||||
2. **Deploy is manual.** There is no automated push to the console and no `nxlink` path yet. Operators build locally, transfer files, then title-override launch.
|
||||
3. **Hardware coverage.** Author P0/P1 pass rows were recorded on one Switch OLED; Switch V1 boot was confirmed independently. Treat Lite, docked soak, and other hosts as unknown until someone re-runs the checklist.
|
||||
4. **No ROM/save/mod zip bytes in git.** Legal dumps and third-party mods stay on the console (or local untracked folders).
|
||||
5. **AppleDouble sidecars** (`._*`) from some MTP clients can break zip/ROM/`.sav` scans — the launcher skips hidden `.*` names (including `._*.sav`); still prefer clean copies.
|
||||
|
||||
## How we tested
|
||||
|
||||
| Layer | What | Where |
|
||||
| ----- | ---- | ----- |
|
||||
| Unit / headless | Platform NX flags, RomImporter inbox, dual-path input, mod zip inbox, save `.sav` inbox, display chords, payload/self-tests | `tests/*`, `scripts/test.sh` |
|
||||
| Switch CI / packaging | Path-gated offline selftest (`selftest_build_switch.sh`, `verify_payload.sh --self-test`, `switch_ci_workflows_test.lua`); canonical fused PR artifact | `.github/workflows/ci.yml`, [switch-build.md](switch-build.md) § CI and release |
|
||||
| Probe on hardware | `getOS()==NX`, 1280×720, save path, Joy-Con events | `tools/switch-probe` → OLED |
|
||||
| Integration on hardware | MTP inbox ROM import, Play Red/Blue, naming A/B, quit/reopen save, suspend×10, reboot, fused NRO alone + NRO-only update | `docs/switch-hardware-evidence.md` |
|
||||
| Community hardware | Switch V1 / Erista boot with prebuilt NRO | [booshankles](https://github.com/booshankles) — see evidence log |
|
||||
| Known gaps | Docked soak, ≥30 min long-play, Lite, automated/`nxlink` deploy | Matrix deferred / absent rows |
|
||||
|
||||
Operator evidence must stay in `docs/switch-hardware-evidence.md`. **Do not invent passes** for hardware not run.
|
||||
|
||||
## love-nx 11.5-nx1 (pinned)
|
||||
|
||||
**Tag:** [11.5-nx1](https://github.com/retronx-team/love-nx/releases/tag/11.5-nx1)
|
||||
|
||||
**Local layout (not committed):**
|
||||
|
||||
```text
|
||||
.bazinga/love-nx/11.5-nx1/
|
||||
├── love.nro # homebrew launcher binary (loose mode: copied to gen1recomp.nro)
|
||||
└── love.elf # required for fused NRO builds (devkitPro nacptool/elf2nro)
|
||||
```
|
||||
|
||||
**Manifest:** `scripts/switch/love-nx-11.5-nx1.sha256` lists expected artifact names and SHA-256 checksums. Checksums are filled when binaries are fetched (`TBD_*` placeholders until then).
|
||||
|
||||
### Fetch instructions
|
||||
|
||||
Preferred (automated checksum verify):
|
||||
|
||||
```bash
|
||||
scripts/build_switch.sh --fetch
|
||||
```
|
||||
|
||||
That downloads pinned `love.nro` + `love.elf` into `.bazinga/love-nx/11.5-nx1/`
|
||||
and checks them against `scripts/switch/love-nx-11.5-nx1.sha256`. See
|
||||
[switch-build.md](switch-build.md) for the full mode glossary.
|
||||
|
||||
Manual fallback:
|
||||
|
||||
1. Open the [11.5-nx1 release](https://github.com/retronx-team/love-nx/releases/tag/11.5-nx1) and download `love.nro` and `love.elf`.
|
||||
2. Create the directory: `mkdir -p .bazinga/love-nx/11.5-nx1`
|
||||
3. Move both files into that directory.
|
||||
4. Confirm checksums match the manifest:
|
||||
|
||||
```bash
|
||||
shasum -a 256 .bazinga/love-nx/11.5-nx1/love.nro \
|
||||
.bazinga/love-nx/11.5-nx1/love.elf
|
||||
```
|
||||
|
||||
**Never commit** love-nx binaries, ROM dumps, or generated cache into git. The repo `.gitignore` excludes `.bazinga/` (vendor cache) and `/dist/` (build output).
|
||||
|
||||
## Loose-mode dist layout
|
||||
|
||||
Development builds place `gen1recomp.nro` and `game.love` side by side:
|
||||
|
||||
```text
|
||||
dist/switch/loose/
|
||||
├── gen1recomp.nro
|
||||
└── game.love
|
||||
```
|
||||
|
||||
Assemble with:
|
||||
|
||||
```bash
|
||||
scripts/build_switch.sh --loose
|
||||
```
|
||||
|
||||
(See `scripts/switch/assemble_loose.sh` for the underlying copy + checksum step.)
|
||||
|
||||
## Transfer & deploy (current contributor loop)
|
||||
|
||||
Detail for **macOS / Linux / Windows** and **MTP / SD / FTP** lives in
|
||||
[switch-transfer.md](switch-transfer.md). Summary:
|
||||
|
||||
| Layer | Intent |
|
||||
| ----- | ------ |
|
||||
| **Runtime / players** | Extract the release zip at microSD root (`switch/gen1recomp/`) and land ROMs/mods under the save-dir inboxes. The game does not hard-depend on OpenMTP or macOS. |
|
||||
| **Contributor loop** | Manual copy via MTP (DBI responder), direct SD (Hekate UMS / reader), or FTP. Fully manual — no CI deploy, no `nxlink` yet. |
|
||||
|
||||
The Mac + OpenMTP steps that remain below are the **OLED evidence reproduction** path; prefer the transfer runbook for day-to-day contrib on other hosts.
|
||||
|
||||
**Still avoided for routine evidence** (keeps SD handling honest):
|
||||
|
||||
- Treating `nxlink` / netloader as the release deploy story (deferred)
|
||||
- DBI `MicroSD install` / `NAND install` / NSP-style virtual folders for the `.love`/`.nro` pair
|
||||
|
||||
If MTP fails: check cable, USB port, DBI state, and that only one MTP client holds the device — then retry or switch to SD/FTP. Do not silently rewrite evidence using an untested path and claim parity with recorded SHA-256 round-trips.
|
||||
|
||||
### Manual deploy checklist (today)
|
||||
|
||||
1. Build on the contributor host (`scripts/build_switch.sh --loose` or fused).
|
||||
2. Close Gen1Recomp on the Switch; open DBI → `Run MTP responder`.
|
||||
3. Copy artifacts with your MTP client into `1: SD Card/switch/gen1recomp/` (and ROMs/mods into the save-dir inboxes when needed).
|
||||
4. Wait for the transfer queue; refresh; optionally round-trip SHA-256 on first artifacts of a type.
|
||||
5. Exit MTP; launch via **title override** (hold **R** on a title → hbmenu, not Applet Mode).
|
||||
|
||||
## OpenMTP + DBI transfer (loose build, Mac evidence example)
|
||||
|
||||
Full multi-OS / multi-method steps: [switch-transfer.md](switch-transfer.md).
|
||||
The numbered Mac loop below reproduces the OLED evidence path.
|
||||
|
||||
### On the Switch
|
||||
|
||||
1. Close Gen1Recomp if it is running.
|
||||
2. Open **DBI** from hbmenu.
|
||||
3. Select **`Run MTP responder`** (DBI documents `X` on the main screen).
|
||||
4. Keep DBI on that screen for the entire transfer.
|
||||
5. Connect the Switch to the Mac with a USB-C data cable.
|
||||
|
||||
### On the Mac
|
||||
|
||||
1. Close any other MTP clients.
|
||||
2. Open **OpenMTP** and select the DBI device.
|
||||
3. In the remote pane, open **`1: SD Card`**.
|
||||
4. Navigate to **`switch/`** and create **`gen1recomp/`** if needed.
|
||||
5. Enter **`1: SD Card/switch/gen1recomp/`**.
|
||||
6. Drag from the local pane:
|
||||
|
||||
```text
|
||||
dist/switch/loose/gen1recomp.nro
|
||||
dist/switch/loose/game.love
|
||||
```
|
||||
|
||||
7. Wait for the OpenMTP queue to finish completely.
|
||||
8. Refresh the remote listing and confirm file sizes match the local files.
|
||||
9. On the Switch, exit MTP responder normally in DBI before launching the app.
|
||||
|
||||
Expected layout on SD:
|
||||
|
||||
```text
|
||||
1: SD Card/
|
||||
└── switch/
|
||||
└── gen1recomp/
|
||||
├── gen1recomp.nro
|
||||
└── game.love
|
||||
```
|
||||
|
||||
## Round-trip SHA-256 verification
|
||||
|
||||
For the **first deploy** of each artifact type (loose pair, later fused NRO), verify MTP integrity:
|
||||
|
||||
1. **Before send** — record local hashes:
|
||||
|
||||
```bash
|
||||
shasum -a 256 dist/switch/loose/gen1recomp.nro \
|
||||
dist/switch/loose/game.love
|
||||
```
|
||||
|
||||
2. **After send** — in OpenMTP, copy the same files from `1: SD Card/switch/gen1recomp/` back to an empty local folder, e.g. `dist/switch/mtp-roundtrip/`.
|
||||
|
||||
3. **Compare** round-trip hashes:
|
||||
|
||||
```bash
|
||||
shasum -a 256 dist/switch/mtp-roundtrip/gen1recomp.nro \
|
||||
dist/switch/mtp-roundtrip/game.love
|
||||
```
|
||||
|
||||
4. Local pre-send and round-trip hashes **must match**. Record results in the test report template below.
|
||||
|
||||
Repeat whenever a cable glitch or interrupted transfer is suspected.
|
||||
|
||||
## Title override launch (full memory)
|
||||
|
||||
Applet Mode is **not** the primary validation path. Use **title override** so hbmenu runs with full memory:
|
||||
|
||||
1. Confirm the OpenMTP transfer queue finished.
|
||||
2. Exit MTP responder in DBI; disconnect USB if desired.
|
||||
3. Hold **`R`** while launching any legitimately installed title.
|
||||
4. Keep holding until **hbmenu** appears.
|
||||
5. Confirm hbmenu does **not** show **Applet Mode**.
|
||||
6. Launch **`gen1recomp`** (or the probe NRO during Phase 0).
|
||||
|
||||
Album / applet launches are only useful to document applet-specific limitations; P0/P1 gates use title override.
|
||||
|
||||
## Phase 0 hardware checklist
|
||||
|
||||
Complete **in order** on OLED hardware. Operator fills evidence fields — leave blank until tested.
|
||||
|
||||
| Step | Action | Pass | Evidence / notes |
|
||||
| ---- | ------ | ---- | ---------------- |
|
||||
| P0-0a | Fetch love-nx 11.5-nx1; record manifest SHA-256 | yes | See `scripts/switch/love-nx-11.5-nx1.sha256` |
|
||||
| P0-0b | Build `switch-probe.love` per `tools/switch-probe/README.md` | yes | |
|
||||
| P0-0c | Assemble loose probe (`game.love` = probe) to `dist/switch/loose/` | yes | |
|
||||
| P0-0d | MTP deploy to `1: SD Card/switch/gen1recomp/`; round-trip SHA-256 | yes | nro `8290ac15…5918f5`; love `9f198637…fa2e34f` |
|
||||
| P0-0e | Title override → probe boots; `getOS()` shows `NX` | yes | `getOS()`=`NX`, `love._os`=`NX` |
|
||||
| P0-0f | Probe lists 1280×720 (or documented dims), save path, gamepad/touch log | yes | save `sdmc:/switch/gen1recomp/switch-probe`; Joy-Con Y→#3 X→#4 |
|
||||
| P0-1a | Replace `game.love` with unpatched Gen1Recomp build | yes | feat/switch-nx inbox build |
|
||||
| P0-1b | MTP replace `game.love` only; round-trip SHA-256 | yes | |
|
||||
| P0-1c | Title override → launcher reaches import screen | yes | |
|
||||
| P0-1d | Joy-Con: can navigate launcher (no touch-only) | yes | Full report: `docs/switch-hardware-evidence.md` |
|
||||
|
||||
**Operator:** Andrew **Date:** 2026-08-01 **Console:** Switch OLED only
|
||||
**Deploy:** manual Mac + OpenMTP + DBI MTP (not automated)
|
||||
**love-nx tag:** 11.5-nx1 **gen1recomp commit:** `df7cea4`
|
||||
|
||||
## Phase 0 test report template
|
||||
|
||||
Copy this block into your hardware notes or PR evidence. **Do not commit ROM files or ROM hashes of private dumps.**
|
||||
|
||||
```markdown
|
||||
## Switch Phase 0 — hardware report
|
||||
|
||||
- Operator:
|
||||
- Date:
|
||||
- Console model:
|
||||
- Atmosphère / HOS version:
|
||||
- gen1recomp commit:
|
||||
- love-nx tag: 11.5-nx1
|
||||
- love.nro SHA-256 (local):
|
||||
- game.love SHA-256 (local, pre-send):
|
||||
- MTP round-trip SHA-256 (gen1recomp.nro):
|
||||
- MTP round-trip SHA-256 (game.love):
|
||||
- Title override used: yes / no
|
||||
- Applet Mode observed: yes / no (should be no for P0)
|
||||
- Probe getOS():
|
||||
- Probe dimensions:
|
||||
- Probe save directory shown:
|
||||
- Gamepad events logged: yes / no
|
||||
- Touch events logged: yes / no
|
||||
- Unpatched launcher boot: pass / fail
|
||||
- Joy-Con launcher navigation: pass / fail / not tested
|
||||
- Notes:
|
||||
```
|
||||
|
||||
## Fast dev loop (loose mode)
|
||||
|
||||
While iterating on Lua/assets:
|
||||
|
||||
1. Edit on Mac; run `scripts/test.sh --quick`.
|
||||
2. Rebuild `.bazinga/work/game.love` (`scripts/build.sh mac --no-notarize` or project pack step).
|
||||
3. Close Gen1Recomp on Switch.
|
||||
4. DBI → `Run MTP responder`.
|
||||
5. OpenMTP → `1: SD Card/switch/gen1recomp/`.
|
||||
6. Replace **only** `game.love`; wait for queue + refresh listing.
|
||||
7. Exit MTP responder; launch via title override.
|
||||
8. Keep `gen1recomp.nro` unchanged until the love-nx pin changes.
|
||||
|
||||
```bash
|
||||
scripts/test.sh --quick
|
||||
scripts/build.sh mac --no-notarize
|
||||
scripts/build_switch.sh --loose
|
||||
shasum -a 256 .bazinga/work/game.love
|
||||
```
|
||||
|
||||
## Controller input mapping (NX)
|
||||
|
||||
Measured on Switch OLED (`feat/switch-nx`, love-nx `11.5-nx1`, 1280×720). Both `joystickpressed` and `gamepadpressed` fire for Joy-Con; prefer the gamepad path when `joystick:isGamepad()` is true.
|
||||
|
||||
| Path | Control | Mapping |
|
||||
| ---- | ------- | ------- |
|
||||
| `gamepadpressed` | D-pad / left stick | move |
|
||||
| `gamepadpressed` | SDL `a` / `b` on **NX** | swapped via `NX_GAMEPAD_BINDINGS`: physical **A** (east) = GB A confirm, physical **B** (south) = GB B cancel |
|
||||
| `gamepadpressed` | SDL `a` / `b` on desktop | identity (SDL south = GB A) |
|
||||
| `gamepadpressed` | `start` / `back` | Start / Select (+ / −) |
|
||||
| `gamepadpressed` | Right / left shoulder (no Select) | Cycle game speed up / down (same as PC hotkey `1` / speed-down path) |
|
||||
| `joystickpressed` (raw) | only if **not** `isGamepad()` | face/menu fallback |
|
||||
| `joystickpressed` (raw) | `#1` / `#2` on NX | Nintendo B / A → GB B / A |
|
||||
| `joystickpressed` (raw) | `#9` / `#10` | Select / Start (− / +) |
|
||||
|
||||
**Nintendo UX on Switch:** physical A confirms, physical B cancels (explicit NX remap of SDL face labels).
|
||||
|
||||
**Launcher extras** (`RomImporter`): physical **A** clicks at the virtual cursor; **L** / **R** switch tabs; **Start** / **Select** start Play when a ROM is ready (else open Choose ROM). D-pad / left stick move the virtual cursor.
|
||||
|
||||
**Dual-path rule:** love-nx emits both `gamepadpressed` and `joystickpressed` for Joy-Con. When `joystick:isGamepad()` is true, Input and RomImporter **ignore raw** face/menu so NamingScreen does not see A+B in one frame. `NamingScreen` also prefers A over B if both edges still fire.
|
||||
|
||||
Implementation: `src/core/GamepadMap.lua` (`NX_RAW_*`, `ignoreRawForJoystick`, `displayChordDigit`), `src/core/Game.lua` (shoulder speed), `src/import/RomImporter.lua` (launcher tabs). Launcher and gameplay share the same converter.
|
||||
|
||||
## ROM inbox (NX)
|
||||
|
||||
Legal dumps land in a shared MTP inbox; **Scan again** is tab-scoped:
|
||||
|
||||
| Item | Value |
|
||||
| ---- | ----- |
|
||||
| Save-relative path | `imports/` (also accepts loose `.gb`/`.gbc` at the save-dir root) |
|
||||
| MTP destination | `1: SD Card/<save identity>/imports/` (see launcher notice for the live `getSaveDirectory()` path) |
|
||||
| Candidates | `*.gb` / `*.gbc` (hidden `.*` AppleDouble names skipped) |
|
||||
| Rescan | Game tab → **Scan again** — imports only the dump whose SHA-1 matches that tab (`GameVersion.forSha1`). Other known dumps stay for their own tabs |
|
||||
| Already ready | Same SHA already imported → “No new ROM found.” |
|
||||
|
||||
Players may drop Red, Blue, and Yellow into the same folder. Opening Yellow and pressing **Scan again** must not start a Red import.
|
||||
|
||||
## Mod zip inbox (NX)
|
||||
|
||||
Community mods install from a **separate** MTP inbox (not mixed into the ROM `imports/` scan):
|
||||
|
||||
| Item | Value |
|
||||
| ---- | ----- |
|
||||
| Save-relative path | `imports/mods/` |
|
||||
| MTP destination | `1: SD Card/<save identity>/imports/mods/` (see launcher notice for the live `getSaveDirectory()` path) |
|
||||
| Candidates | `*.zip` only |
|
||||
| Rescan | MODS tab → **Scan again** (installs each zip via `LauncherMods.installZip`; source zips are retained on success and failure) |
|
||||
| FIND MODS | Remains network-gated / hidden on NX (`networkValidated == false`) |
|
||||
|
||||
Do **not** commit third-party mod zip bytes into git. Drop the zip over MTP, rescan, enable in MODS, then Play.
|
||||
|
||||
**MTP tip (esp. macOS clients):** OpenMTP/Finder often creates AppleDouble sidecars named `._Something.zip` / `._cart.gb` / `._foo.sav`. Those are not real archives, ROMs, or saves — the launcher ignores hidden `.*` names under `imports/`, `imports/mods/`, and `imports/saves/<game>/`. If install still fails with “could not be opened” / “not a zip file”, delete any `._*` under the inbox and confirm the real zip starts with the `PK` magic (re-copy the release asset if unsure). This is a host-side annoyance of the current manual MTP loop, not something players should need forever.
|
||||
|
||||
Drop any community release `.zip` into `imports/mods/`, rescan, enable.
|
||||
Player-facing install steps: [switch-install.md](switch-install.md#community-mods).
|
||||
Mods own their OPTIONS / rebinds — do not duplicate third-party control tables here.
|
||||
|
||||
## Save `.sav` inbox (NX)
|
||||
|
||||
Raw Gen1 battery images use a **separate** MTP inbox (not mixed into ROM `imports/` or mod `imports/mods/`):
|
||||
|
||||
| Item | Value |
|
||||
| ---- | ----- |
|
||||
| Save-relative path | `imports/saves/red/`, `imports/saves/blue/`, `imports/saves/yellow/` |
|
||||
| MTP destination | `1: SD Card/<save identity>/imports/saves/<game>/` (see launcher notice for the live `getSaveDirectory()` path) |
|
||||
| Candidates | non-hidden `*.sav` only in **that game’s** folder |
|
||||
| Rescan | SAVE FILES → **Import save** on the matching game tab (scans only that folder) |
|
||||
| After success | Retire to `*.sav.imported` + append content hash to `imports/saves/<game>/.imported-sha1` |
|
||||
| Exports | **Export save** writes under `exports/<game>/gen1recomp-<game>-<slot>.sav`; NX shows an MTP path notice (no `openURL`) |
|
||||
|
||||
Do **not** commit `.sav` bytes into git. Drop the file into the matching game folder over MTP, press **Import save** on that tab, then play. Pull exports from `exports/<game>/`.
|
||||
|
||||
**MTP tip:** the same AppleDouble `._*.sav` rule applies — see the mod inbox tip above.
|
||||
|
||||
## Joy-Con display chords (Select + face)
|
||||
|
||||
PC digit hotkeys for COLORS / TILT / GBC FX / pipelines have Joy-Con equivalents. Hold **Select** (`back` / −) and press a face/shoulder button; the engine runs the same path as `Game:keypressed` for that digit (including `writeOptions` / Pipelines parity).
|
||||
|
||||
| Chord (Nintendo UX) | Engine key | Stock engine effect |
|
||||
| ------------------- | ---------- | ------------------- |
|
||||
| Select + **A** | `2` | COLORS cycle |
|
||||
| Select + **B** | `3` | TILT / perspective |
|
||||
| Select + **Y** | `5` | GBC FX |
|
||||
| Select + **X** | `6` | Mod pipeline hotkey (if registered) |
|
||||
| Select + **L** (left shoulder) | `7` | Mod pipeline hotkey (if registered) |
|
||||
|
||||
Keys `2` / `3` / `4` / `5` are claimed by the engine before mod pipeline hotkeys run, so a community mod cannot rebind those digits through `Pipelines.hotkey`. Mods that need their own controls should use OPTIONS rows or unclaimed hotkeys.
|
||||
|
||||
Without Select held, face buttons keep normal GB A/B gameplay mapping (no accidental color/tilt cycles). The **Options** menu remains available for the same settings — chords are optional shortcuts, not the only path.
|
||||
|
||||
On NX, A/B chords resolve through the Nintendo UX face remap so physical **A** → key `2` and physical **B** → key `3` match this table.
|
||||
|
||||
**OPTIONS → PERFORMANCE** clamps the port’s own extras (TILT / GBC FX / survey ZOOM) and can cap FPS — useful on weaker handheld budgets. Details: [new-features.md — Performance tier](new-features.md#performance-tier-low-end-devices).
|
||||
|
||||
Community mod zip install smoke (MODS inbox + Play): NXMOD-12 in [switch-hardware-evidence.md](switch-hardware-evidence.md).
|
||||
|
||||
**Opt-in diagnostics:** create an empty `switch-debug.txt` in the save directory; events flush to `switch.log` at ≤1 Hz with build identity (no ROM/save bytes).
|
||||
|
||||
**NX asset probe (always on Play):** every Switch Play writes `nx-asset-probe.log` in the save directory (`pokemon-love2d/`). It lists whether `assets/generated/…` vs `yellow|blue/assets/generated/…` exist, what `Assets.resolve` returns, and whether `newImage` / `newImageData` open — for Yellow/Blue blank-sprite triage. No ROM bytes.
|
||||
|
||||
**Blue/Yellow cache overlay (NX):** fused love-nx cannot reliably mount `yellow|blue/assets/generated` onto the un-prefixed path, so `src/core/NxAssetOverlay.lua` wraps EVERY read-side love API that accepts a filesystem path (`filesystem.read/load/lines/newFileData/getInfo`, `graphics.newImage/newFont`, `image.newImageData`, `audio.newSource`, `sound.newSoundData`, `font.newFontData`) once at boot — only when `Platform.isNX()`. Covering the whole read surface (not just the loaders the boot needs today) keeps future states and mods inside the fallback automatically; write-side functions stay stock. Core code must NOT call love loaders on literal `assets/generated` paths (enforced by `tests/engine/nx_generated_guard_test.lua`); the chip-audio worker is a separate Lua state and gets the prefix explicitly via `audio.programPrefix` from `ChipAudio.slimAudio`.
|
||||
|
||||
**Hardware re-test:** T16 **pass** @ `2699c9a` (naming A=confirm / B=cancel). T19 **pass** (quit/reopen, suspend×10, reboot) — operator 2026-08-01.
|
||||
|
||||
**Suspend/resume audio:** after resume, chip music is stopped to avoid duplicate streams; confirm on hardware during P0-09/10 (T19).
|
||||
|
||||
## Lua error log (save directory)
|
||||
|
||||
On any uncaught Lua error, Gen1Recomp appends a redacted trace to `lua-error.log` in the LÖVE save directory (`love.filesystem.getSaveDirectory()`). The on-screen error overlay includes a hint pointing at that file. Logs rotate to `lua-error.log.1` when the active file exceeds 32 KiB. ROM/save bytes and non-printable data are stripped — never commit or share logs that might contain private paths without reviewing them first.
|
||||
|
||||
## Native crash triage (love-nx / Atmosphère)
|
||||
|
||||
love-nx native faults land under the console’s `crash_reports/` folder on SD (reachable via the same manual MTP workflow used for game deploys).
|
||||
|
||||
1. **Collect** — DBI → `Run MTP responder`; copy `sdmc:/crash_reports/*.bin` (or the dated subfolder) to the contributor host. Prefer keeping the microSD in-console for routine pulls.
|
||||
2. **Redact** — delete any attached screenshots or notes that mention ROM filenames, save paths, or private hashes before sharing logs publicly.
|
||||
3. **Symbolize** — use the **pinned** `love.elf` from `.bazinga/love-nx/11.5-nx1/` that matches `build-info.json` / `scripts/switch/love-nx-11.5-nx1.sha256`. Never use a “latest” download.
|
||||
|
||||
```bash
|
||||
# Example: aarch64-none-elf-addr2line from devkitPro
|
||||
aarch64-none-elf-addr2line -e .bazinga/love-nx/11.5-nx1/love.elf -f -C 0xADDRESS_FROM_CRASH_REPORT
|
||||
```
|
||||
|
||||
4. **Correlate** — compare `gitCommit` / `loveNxTag` from embedded `build-info.json` with the operator’s hardware notes.
|
||||
|
||||
If `addr2line` cannot resolve an address, archive the crash `.bin` with the exact `love.elf` SHA-256 used for the build — addresses are only meaningful against that ELF.
|
||||
|
||||
## P0 / P1 hardware matrix (ADR §9)
|
||||
|
||||
Operator evidence lives in `docs/switch-hardware-evidence.md`. **Do not invent passes** for rows that require hardware not yet run.
|
||||
|
||||
| ID | Requirement | Status | Evidence |
|
||||
| -- | ----------- | ------ | -------- |
|
||||
| P0-0a–f | love-nx pin, probe, MTP, title override | **pass** | Phase 0 checklist above; T4 |
|
||||
| P0-1a–d | Unpatched launcher boot + Joy-Con nav | **pass** | T4 / `docs/switch-hardware-evidence.md` |
|
||||
| P0-02 | MTP inbox import path shown | **pass** | T12 |
|
||||
| P0-03 | Rescan imports ROM | **pass** | T12 |
|
||||
| P0-04 | Canonical hash routes version | **pass** | T12 |
|
||||
| P0-05 | Source dump retained in inbox | **pass** | T12 |
|
||||
| P0-06 | Play reaches game after import | **pass** | T12 |
|
||||
| P0-07 | Joy-Con launcher navigation | **pass** | T16 @ `2699c9a` |
|
||||
| P0-08 | Joy-Con gameplay (incl. naming A/B) | **pass** | T16 @ `2699c9a` |
|
||||
| P0-09 | Save survives quit + reopen | **pass** | T19 |
|
||||
| P0-10 | ≥10 suspend cycles, no stuck input/dup audio | **pass** | T19 (operator 2026-08-01) |
|
||||
| P0-12 | Fused NRO boots without adjacent `game.love` | **pass** | T24 — `docs/switch-hardware-evidence.md` |
|
||||
| P0-14 | Fused NRO MTP round-trip SHA-256 | **pass** | T24 — first artifact `b019e2e8…` @ `6fb5602` (redeploy after Blue fix) |
|
||||
| P0-15 | Replace NRO only; saves persist | **pass** | T24 — operator NRO-only update keeps saves |
|
||||
| P1-01 | Docked vs handheld spot-check | **deferred** | Code: `NxDisplay` 720p↔1080p; OLED dock soak not recorded yet |
|
||||
| P1-02 | Applet Mode documented unsupported | **pass** | Title override required; Album path not validated |
|
||||
| P1-03 | Long-play soak (≥30 min) | **deferred** | No soak session recorded |
|
||||
| P1-04 | Reboot persistence | **pass** | T19 |
|
||||
| P1-05 | Audio resume after suspend | **pass** | T19 (no dup audio reported) |
|
||||
| — | Switch V1 / Erista boot | **pass** (boot) | Community — [booshankles](https://github.com/booshankles); see evidence log |
|
||||
| — | Switch Lite / docked soak | **untested** / **deferred** | Welcome contributions |
|
||||
| — | Automated / `nxlink` deploy | **absent** | Manual MTP / SD / FTP only (AD-009) |
|
||||
| — | Multi-OS transfer runbooks | **pass** | [switch-transfer.md](switch-transfer.md) |
|
||||
| — | Community mod zip OLED smoke (NXMOD-12) | **pass** | `docs/switch-hardware-evidence.md` |
|
||||
|
||||
## Review guidance
|
||||
|
||||
Maintainers may review as one PR or split later. Suggested slices (optional):
|
||||
|
||||
Each slice should declare: **no ROM/save bytes committed**, **love-nx pin with manifest checksums**, **hardware-tested rows listed with linked evidence**, **Applet Mode unsupported**, **network/updater disabled on NX**, **deploy still manual** (MTP / SD / FTP; no nxlink yet), **OpenMTP is one example not the sole contract**.
|
||||
|
||||
### Slice 1 — Platform + import (`platform/import`)
|
||||
|
||||
- `src/core/Platform.lua`, `conf.lua` NX branch
|
||||
- `src/import/RomImporter.lua` (NX flags, inbox, scan, shell/updater gates)
|
||||
- Tests: `tests/engine/platform_nx_*`, `tests/engine/rom_importer_nx_*` (ROM-free T2)
|
||||
- Docs: inbox/MTP import sections only
|
||||
|
||||
### Slice 2 — Input + lifecycle (`input/lifecycle`)
|
||||
|
||||
- `src/core/GamepadMap.lua`, `Input.lua`, `main.lua` focus/joystick hooks
|
||||
- `src/debug/SwitchDiagnostics.lua` (opt-in probe + error log)
|
||||
- Tests: input/diagnostics suites
|
||||
- Docs: controller mapping, suspend/audio notes
|
||||
|
||||
### Slice 3 — Build + docs (`build/docs`)
|
||||
|
||||
- `scripts/pack_love.sh`, `scripts/build_switch.sh`, `scripts/switch/*`
|
||||
- `assets/switch/icon.jpg`, `docs/switch-development.md`, hardware evidence templates
|
||||
- Gates: `pack_love.sh --dry-run`, `verify_payload.sh --self-test`, fused build script (devkitPro host)
|
||||
|
||||
**Pre-merge checklist:**
|
||||
|
||||
- [ ] Manifest `scripts/switch/love-nx-11.5-nx1.sha256` filled; binaries not in git
|
||||
- [ ] `verify_payload.sh` rejects generated cache / ROM / `.sav` / `.bak`
|
||||
- [ ] P0 matrix rows marked pass only with linked hardware evidence
|
||||
- [x] Fused NRO P0-12/14/15 pass with T24 evidence (`docs/switch-hardware-evidence.md`)
|
||||
- [ ] Updater / remote mod download hidden on NX (`networkValidated == false`)
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
# Switch hardware evidence (Phase 0 + import + input)
|
||||
|
||||
> **Hardware evidence log.** Author passes below were recorded on **one
|
||||
> Nintendo Switch OLED** with a **manual** Mac → DBI MTP deploy loop. A
|
||||
> separate community row records Switch V1 / Erista boot. These rows do
|
||||
> **not** claim Lite, docked soak, or automated install. See
|
||||
> `docs/switch-development.md` for status and limitations.
|
||||
|
||||
**love-nx:** `11.5-nx1`
|
||||
**Author console:** Switch OLED
|
||||
**Deploy method (author):** manual OpenMTP + DBI `Run MTP responder` (no CI / no nxlink)
|
||||
**Operator (author rows):** Andrew ([andrewqsantos](https://github.com/andrewqsantos))
|
||||
**Date (author rows):** 2026-08-01
|
||||
|
||||
Do **not** commit ROM dumps or private dump hashes. Do **not** mark a row **pass** without hardware notes for that row.
|
||||
|
||||
---
|
||||
|
||||
## Community — Switch V1 / Erista boot — pass (boot)
|
||||
|
||||
| Field | Value |
|
||||
| ----- | ----- |
|
||||
| Console | Nintendo Switch V1 (Erista) |
|
||||
| Check | Prebuilt fused NRO boots under title override |
|
||||
| Tester | [booshankles](https://github.com/booshankles) |
|
||||
| Notes | Community confirmation only — not a full P0/P1 matrix re-run on V1 |
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — probe (T4) — pass
|
||||
|
||||
| Field | Value |
|
||||
| ----- | ----- |
|
||||
| Commit (import era) | `df7cea4` |
|
||||
| `getOS()` / `love._os` | `NX` |
|
||||
| Dimensions | 1280×720 |
|
||||
| Save (probe) | `sdmc:/switch/gen1recomp/switch-probe` |
|
||||
| Joy-Con | `joystickpressed` + `gamepadpressed` (Y→`#3`, X→`#4`) |
|
||||
|
||||
| Artifact | SHA-256 |
|
||||
| -------- | ------- |
|
||||
| `gen1recomp.nro` | `8290ac153d4c630e48c9b26ef9123f5204ed8ee0cef3042511707b5b645918f5` |
|
||||
|
||||
---
|
||||
|
||||
## T12 — Red import + Play — pass
|
||||
|
||||
Inbox MTP → “Scan again” → Play; Joy-Con launcher/gameplay (not touch-only).
|
||||
|
||||
---
|
||||
|
||||
# T16 — Joy-Con launcher + gameplay — pass (naming re-verify)
|
||||
|
||||
### Round 1 @ `7504753` — partial
|
||||
|
||||
| Check | Result |
|
||||
| ----- | ------ |
|
||||
| Launcher / overworld (Joy-Con only) | **pass** |
|
||||
| Naming player/rival | **fail** (dual-path a+b; see below) |
|
||||
| Touch required | **no** |
|
||||
| `game.love` SHA-256 | `bd3a35461bf453c1f0465a5a289421aef3b5c72d3bf1f8d76e86231256829e0e` |
|
||||
|
||||
### Naming failure (root cause) — fixed in `efd81d8` + `2699c9a`
|
||||
|
||||
- love-nx fires **`gamepadpressed` + `joystickpressed` on the same physical press**.
|
||||
- `NamingScreen` tested `wasPressed("b")` before `"a"` → if both true in one frame, always deletes.
|
||||
- Dual-path fix: ignore raw when `isGamepad()` (`efd81d8`).
|
||||
- SDL-only UX then had physical B confirm / A erase; NX face remap (`2699c9a`) restores Nintendo A=confirm / B=cancel.
|
||||
|
||||
### Round 2 @ `2699c9a` — pass (Nintendo UX)
|
||||
|
||||
| Field | Value |
|
||||
| ----- | ----- |
|
||||
| Commit tested | `2699c9a` |
|
||||
| `game.love` SHA-256 | `a208b21e1f30b00e2e8c6fa6efe14f0e06d1db0ae1e50b810b16d9fb852926bc` |
|
||||
| Touch required | **no** |
|
||||
|
||||
| Check | Result |
|
||||
| ----- | ------ |
|
||||
| Naming — player | **pass** — physical **A** confirms letter, **B** cancels/erases |
|
||||
| Naming — rival | **pass** (same) |
|
||||
| Launcher / overworld (prior round) | **pass** (unchanged mapping for d-pad/stick) |
|
||||
|
||||
T16 hardware gate: **closed**.
|
||||
|
||||
---
|
||||
|
||||
## T19 — save / suspend — pass
|
||||
|
||||
| Check | Result |
|
||||
| ----- | ------ |
|
||||
| Save in-game → full quit → title-override reopen → load save | **pass** (@ `7504753` / retained) |
|
||||
| Suspend/resume ×10 (launcher / gameplay / mixed) | **pass** (operator 2026-08-01) |
|
||||
| Full console reboot persistence | **pass** (operator 2026-08-01) |
|
||||
|
||||
T19 hardware gate: **closed**. No stuck input, duplicate audio, or crash reported.
|
||||
|
||||
---
|
||||
|
||||
## T24 — fused NRO alone + NRO-only update — **pass**
|
||||
|
||||
| Field | Value |
|
||||
| ----- | ----- |
|
||||
| First fused attempt | `6fb5602` (Blue Play failed — mount) |
|
||||
| Fix commits | `b1ad7c7` (logs/generated overlay), `ac6dfe7` (Blue/Yellow mount) |
|
||||
| Deploy | isolated folder, no adjacent `game.love` |
|
||||
| Boot fused | **pass** |
|
||||
| ROM import | **pass** |
|
||||
| Play **Red** | **pass** |
|
||||
| Play **Blue** (after `ac6dfe7`) | **pass** (operator 2026-08-01) |
|
||||
| NRO-only replace | **pass** — saves retained; app still boots/plays |
|
||||
| Touch required | no |
|
||||
|
||||
T24 hardware gate: **closed**.
|
||||
|
||||
---
|
||||
|
||||
## SWBLD — `build_switch.sh --fetch --fused` + install path — **pass**
|
||||
|
||||
Operator smoke for the switch-build-pipeline packaging CLI (closes matrix-deferred happy paths from validation).
|
||||
|
||||
| Field | Value |
|
||||
| ----- | ----- |
|
||||
| Command | `scripts/build_switch.sh --fetch --fused --version 0.0.0-test` |
|
||||
| Host | macOS + native switch-tools (or Docker fallback if used) |
|
||||
| Commit / build-info | `9147a64` (`gitCommit` in build-info) |
|
||||
| love-nx | `11.5-nx1` (manifest checksums match) |
|
||||
| Artifact | `dist/switch/gen1recomp-0.0.0-test-switch.nro` |
|
||||
| NRO SHA-256 | `210efb884a8d27443dc1c64ed8f071b0f862d8d0c9b140ad8185093c4e4027db` |
|
||||
| Install doc | `docs/switch-install.md` — at the time of this row: copy NRO under `sdmc:/switch/gen1recomp/` (releases now ship an SD-ready zip; same folder) |
|
||||
| Console | Switch OLED |
|
||||
| Operator | Andrew |
|
||||
| Date | 2026-08-01 |
|
||||
|
||||
| Check | Result |
|
||||
| ----- | ------ |
|
||||
| `--fetch` + `--fused` produce NRO + `.sha256` | **pass** |
|
||||
| Copy NRO to SD folder per install doc | **pass** (operator) |
|
||||
| Title-override launch / play | treated as prior T24 path; this row records **packaging + deploy to folder** success |
|
||||
|
||||
SWBLD packaging smoke: **closed** for Mac fused build + file-to-SD install step.
|
||||
|
||||
---
|
||||
|
||||
## NXMOD-12 — Community mod zip OLED smoke — **pass**
|
||||
|
||||
Closed from existing OLED photo evidence on issue
|
||||
[#531](https://github.com/bryanthaboi/gen1recomp/issues/531) (operator comment
|
||||
with launcher MODS + overworld shots). Photos live on the orphan branch
|
||||
[`switch-oled-photos`](https://github.com/andrewqsantos/gen1recomp/tree/switch-oled-photos)
|
||||
of the operator fork — **not** committed to this repo. Do **not** commit
|
||||
third-party mod `.zip` bytes. Community mods own their OPTIONS / rebinds;
|
||||
this entry only proves the MODS inbox + Play path on OLED.
|
||||
|
||||
| Field | Value |
|
||||
| ----- | ----- |
|
||||
| Status | **pass** |
|
||||
| gen1recomp commit | evidence era on `feat/switch-nx` (see #531); packaging pin love-nx `11.5-nx1` |
|
||||
| love-nx tag | `11.5-nx1` |
|
||||
| Console | Switch OLED |
|
||||
| Mod | community release `.zip` (not vendored; not named here) |
|
||||
| Zip committed to git? | **no** |
|
||||
| Photo evidence | [#531 comment](https://github.com/bryanthaboi/gen1recomp/issues/531) — MODS tab + overworld |
|
||||
| MODS tab photo | https://raw.githubusercontent.com/andrewqsantos/gen1recomp/switch-oled-photos/IMG_1766.jpg |
|
||||
| Overworld photo | https://raw.githubusercontent.com/andrewqsantos/gen1recomp/switch-oled-photos/IMG_1771.jpg |
|
||||
| Operator | Andrew |
|
||||
| Date | 2026-08-01 |
|
||||
|
||||
### Checklist
|
||||
|
||||
| Step | Pass / fail / pending | Notes |
|
||||
| ---- | --------------------- | ----- |
|
||||
| MTP zip into save `imports/mods/` | **pass** | Photo evidence + prior inbox path |
|
||||
| MODS → Scan again → mod listed | **pass** | IMG_1766 — community mod installed |
|
||||
| Enable mod + Play Red boots without crash | **pass** | Overworld / Pallet / Oak lab photos on #531 |
|
||||
| Overworld Select+A → visible colors change | **pass** | Stock COLORS chord path exercised |
|
||||
| Overworld Select+B → visible tilt/perspective change | **pass** | Stock TILT chord path exercised (IMG_1771) |
|
||||
|
||||
### Evidence notes
|
||||
|
||||
```text
|
||||
Operator: Andrew
|
||||
Date: 2026-08-01
|
||||
Commit tested: feat/switch-nx era documented on issue #531
|
||||
Pass / fail summary: PASS — MODS zip install + Play on Switch OLED
|
||||
Photo branch: andrewqsantos/gen1recomp@switch-oled-photos
|
||||
```
|
||||
@@ -2,15 +2,11 @@
|
||||
|
||||
Every GitHub Release that includes Switch support ships an SD-ready zip:
|
||||
`gen1recomp-*-switch.zip`. Extract it at the root of your microSD (install
|
||||
**or** update — same steps), launch with **title override**, then import your
|
||||
or update, same steps), launch with **title override**, then import your
|
||||
own legal `.gb` ROM.
|
||||
|
||||
> You need a console that can run Switch homebrew (custom firmware / hbmenu).
|
||||
> This project does not help you set that up. Tracks issue
|
||||
> [#531](https://github.com/bryanthaboi/gen1recomp/issues/531).
|
||||
> Hardware: **OLED** validated by the porter; **V1 / Erista** boot confirmed
|
||||
> by the community. Lite and other setups welcome more reports.
|
||||
> See [switch-development.md](switch-development.md) for limitations.
|
||||
> This project does not help you set that up.
|
||||
|
||||
Prefer building from source? See [switch-build.md](switch-build.md).
|
||||
|
||||
@@ -29,12 +25,18 @@ help from [booshankles](https://github.com/booshankles).
|
||||
Extract the zip at the **root** of the microSD so you get:
|
||||
|
||||
```text
|
||||
sdmc:/switch/gen1recomp/gen1recomp.nro
|
||||
sdmc:/switch/gen1recomp/gen1recomp.nro # native OTA launcher (hbmenu entry)
|
||||
sdmc:/switch/gen1recomp/gen1recomp-game.nro # fused LÖVE game
|
||||
sdmc:/switch/gen1recomp/version.txt
|
||||
sdmc:/switch/gen1recomp/pokemon-love2d/imports/
|
||||
sdmc:/switch/gen1recomp/pokemon-love2d/imports/mods/
|
||||
sdmc:/switch/gen1recomp/pokemon-love2d/imports/saves/...
|
||||
```
|
||||
|
||||
Older single-NRO zips only had `gen1recomp.nro` (the fused game). Current
|
||||
releases use the dual-NRO layout above. Open `gen1recomp` in hbmenu (the
|
||||
launcher).
|
||||
|
||||
Merge folders if your OS asks. Any method works: **MTP** (DBI → Run MTP
|
||||
responder + a client), **direct SD** (Hekate UMS or a card reader), or **FTP**.
|
||||
Exit MTP / unmount / stop FTP cleanly before launching. Step-by-step for
|
||||
@@ -42,17 +44,44 @@ macOS, Linux, and Windows: [switch-transfer.md](switch-transfer.md).
|
||||
|
||||
### Updating
|
||||
|
||||
Use the **same** extract/merge. It replaces `gen1recomp.nro` (and the small
|
||||
help `README.txt` / `INSTALL.txt` files). Saves, imported ROMs, mods, and
|
||||
options live under `pokemon-love2d/` — **do not delete that folder** when
|
||||
updating, or you will lose progress.
|
||||
#### Native OTA launcher (in-console)
|
||||
|
||||
Switch OTA runs in a separate **native launcher NRO** (libnx + curl), not the
|
||||
LÖVE self-updater (`src/update/Check.lua`). hbmenu opens `gen1recomp.nro`.
|
||||
|
||||
When a newer release exists, the launcher downloads the same install zip
|
||||
(`gen1recomp-*-switch.zip`), checks SHA-256 against `sha256sums.txt`, replaces
|
||||
both `gen1recomp-game.nro` and `gen1recomp.nro` (keeps NACP version in sync
|
||||
for hbmenu and Sphaira), then loads the game with `envSetNextLoad`.
|
||||
|
||||
If you are up to date or offline, it skips straight to the game with no
|
||||
prompt. If an update is available, you get a short prompt styled like the
|
||||
in-game launcher: black background, RGB rail, logo, A/B buttons. Saves under
|
||||
`pokemon-love2d/` are not touched. See `src/update/SwitchOta.lua` for the
|
||||
wire format.
|
||||
|
||||
The LÖVE self-updater stays **disabled** on NX (`networkValidated == false`).
|
||||
|
||||
**Sphaira forwarder (HOME shortcut):** Sphaira copies name/version/icon into
|
||||
the installed forwarder at creation time. After an OTA (or zip) update, the
|
||||
`.nro` on the microSD already has the new version, but the HOME shortcut
|
||||
keeps the old badge until you **reinstall the forwarder once** in Sphaira
|
||||
(Install Forwarder again on `gen1recomp.nro`). Browsing the NRO in Sphaira /
|
||||
hbmenu always shows the live file version.
|
||||
|
||||
#### Manual zip (fallback)
|
||||
|
||||
Use the **same** extract/merge of `gen1recomp-*-switch.zip`. It replaces the
|
||||
NROs (and the small help `README.txt` / `INSTALL.txt` files). Saves,
|
||||
imported ROMs, mods, and options live under `pokemon-love2d/`. **Do not
|
||||
delete that folder** when updating, or you will lose progress.
|
||||
|
||||
## 3. Launch with title override
|
||||
|
||||
**Applet Mode is not supported** for this game (not enough memory).
|
||||
|
||||
1. On the Switch HOME menu, highlight any installed title.
|
||||
2. Hold **R** and launch that title — this opens hbmenu with full memory
|
||||
2. Hold **R** and launch that title. This opens hbmenu with full memory
|
||||
(title override).
|
||||
3. From hbmenu, open `gen1recomp`.
|
||||
|
||||
@@ -66,14 +95,14 @@ This project ships **no** game data. On first launch:
|
||||
(`.gbc`) dump into `switch/gen1recomp/pokemon-love2d/imports/` (the
|
||||
launcher also shows the live save-dir path). All three can sit in the
|
||||
same folder.
|
||||
2. Use **Scan again** on that game’s tab (Red / Blue / Yellow). Rescan
|
||||
matches by ROM SHA-1 for the open tab only — a Red dump never imports
|
||||
2. Use **Scan again** on that game's tab (Red / Blue / Yellow). Rescan
|
||||
matches by ROM SHA-1 for the open tab only. A Red dump never imports
|
||||
from the Yellow tab (and vice versa).
|
||||
|
||||
## 5. Import / Export a raw `.sav`
|
||||
|
||||
Continue a cart or PC battery save (or pull a slot off-console) via MTP /
|
||||
SD / FTP — same transfer methods as ROMs. Paths are **per game**:
|
||||
SD / FTP, same transfer methods as ROMs. Paths are **per game**:
|
||||
|
||||
| Game | Import inbox | Export folder |
|
||||
| ---- | ------------ | ------------- |
|
||||
@@ -81,19 +110,19 @@ SD / FTP — same transfer methods as ROMs. Paths are **per game**:
|
||||
| Blue | `imports/saves/blue/` | `exports/blue/` |
|
||||
| Yellow | `imports/saves/yellow/` | `exports/yellow/` |
|
||||
|
||||
(Under the save dir `pokemon-love2d/` — the zip already creates these folders.)
|
||||
(Under the save dir `pokemon-love2d/`. The zip already creates these folders.)
|
||||
|
||||
1. Copy a Gen1 `.sav` (32 KB) into that game’s inbox under the save dir
|
||||
1. Copy a Gen1 `.sav` (32 KB) into that game's inbox under the save dir
|
||||
([switch-transfer.md](switch-transfer.md)).
|
||||
2. With the game’s ROM already imported, open **that game’s tab** →
|
||||
2. With the game's ROM already imported, open **that game's tab** →
|
||||
**SAVE FILES** → **Import save**. Only that folder is scanned.
|
||||
3. A successful import retires the file to `*.sav.imported` and records its
|
||||
content hash so pressing **Import save** again does not clone slots.
|
||||
Failed imports leave the original `.sav` in place.
|
||||
4. To pull a slot off the console, use **Export save**, then copy the file
|
||||
from that game’s **`exports/<game>/`** folder via MTP / SD / FTP.
|
||||
from that game's **`exports/<game>/`** folder via MTP / SD / FTP.
|
||||
|
||||
Do not put `.sav` files into git. Prefer clean copies — some MTP clients
|
||||
Do not put `.sav` files into git. Prefer clean copies. Some MTP clients
|
||||
create `._*.sav` AppleDouble sidecars that are not real saves.
|
||||
|
||||
## Controls
|
||||
@@ -130,12 +159,12 @@ create `._*.sav` AppleDouble sidecars that are not real saves.
|
||||
Mods install from a zip inbox (same transfer methods as ROMs):
|
||||
|
||||
1. Copy a release `.zip` into the save-dir **`imports/mods/`** path the
|
||||
launcher shows (MTP / SD / FTP — [switch-transfer.md](switch-transfer.md)).
|
||||
launcher shows (MTP / SD / FTP. See [switch-transfer.md](switch-transfer.md)).
|
||||
2. In the launcher, open **MODS** → **Scan again** → enable the mod →
|
||||
**Play**.
|
||||
|
||||
Remote **FIND MODS** / GitHub download stays **off** on Switch. Do not put
|
||||
mod zips into git. Community mods ship their own OPTIONS / rebinds — this port
|
||||
mod zips into git. Community mods ship their own OPTIONS / rebinds. This port
|
||||
does not document third-party control tables.
|
||||
|
||||
### Joy-Con shortcuts (Select + face)
|
||||
@@ -153,12 +182,23 @@ hotkeys (`2`/`3`/`5` are claimed before any mod pipeline hotkey runs).
|
||||
| Select + **L** | `7` | Mod pipeline hotkey (if a mod registers `7`) |
|
||||
|
||||
If the handheld stutters with extras on, try **OPTIONS → PERFORMANCE** →
|
||||
`LOW` or `BALANCED`. Full chord notes for contributors:
|
||||
[switch-development.md](switch-development.md#joy-con-display-chords-select--face).
|
||||
`LOW` or `BALANCED`.
|
||||
|
||||
## Limitations
|
||||
|
||||
- You need homebrew (custom firmware, hbmenu). This project does not set that
|
||||
up.
|
||||
- Launch with title override (hold **R** on a title). Applet Mode (Album) is
|
||||
not supported. The game needs full memory.
|
||||
- ROMs, mods, and saves are copied manually via MTP, direct SD, or FTP. There
|
||||
is no automated deploy.
|
||||
- Updates use the native OTA launcher only. The LÖVE self-updater and remote
|
||||
**FIND MODS** stay off on Switch.
|
||||
- Tested on Switch OLED. Switch V1 / Erista boot confirmed by the community.
|
||||
Other models may work but are less tested.
|
||||
|
||||
## Prefer building it yourself?
|
||||
|
||||
Building the fused NRO (and SD-ready zip) from source is covered in
|
||||
[switch-build.md](switch-build.md). Copying artifacts and inbox files
|
||||
(MTP / SD / FTP on macOS, Linux, Windows): [switch-transfer.md](switch-transfer.md).
|
||||
Status, limitations, and how we tested: [switch-development.md](switch-development.md).
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
# Switch file transfer (MTP / SD / FTP)
|
||||
|
||||
Canonical ways to put Gen1Recomp artifacts and inbox files onto a Nintendo
|
||||
Switch. **Any method is valid** if the bytes land in the destinations below.
|
||||
Ways to put Gen1Recomp artifacts and inbox files onto a Nintendo Switch.
|
||||
**Any method is valid** if the bytes land in the destinations below.
|
||||
|
||||
This is the home runbook for contributors on **macOS, Linux, and Windows**.
|
||||
This is the runbook for contributors on **macOS, Linux, and Windows**.
|
||||
Player install (what to download, title override) stays in
|
||||
[switch-install.md](switch-install.md). Packaging stays in
|
||||
[switch-build.md](switch-build.md). Hardware evidence lives in
|
||||
[switch-hardware-evidence.md](switch-hardware-evidence.md).
|
||||
[switch-build.md](switch-build.md).
|
||||
|
||||
> **Not supported yet:** `nxlink` / hbmenu netloader automation. Useful later
|
||||
> for a fast contrib rebuild loop; deferred on purpose (AD-009). Do not treat
|
||||
> netloader as the release or ROM/mod install path.
|
||||
> for a fast contrib rebuild loop; deferred (AD-009). Do not treat netloader as
|
||||
> the release or ROM/mod install path.
|
||||
|
||||
---
|
||||
|
||||
@@ -23,7 +22,7 @@ Player install (what to download, title override) stays in
|
||||
| Loose iteration pair | `sdmc:/switch/gen1recomp/gen1recomp.nro` **and** `game.love` beside it |
|
||||
| ROM inbox | LÖVE save dir → `imports/` (launcher shows the live `getSaveDirectory()` path; under MTP often `1: SD Card/<save identity>/imports/`) |
|
||||
| Mod zip inbox | Same save dir → `imports/mods/` then MODS → **Scan again** |
|
||||
| Save `.sav` inbox | Same save dir → `imports/saves/red\|blue\|yellow/` then that game’s SAVE FILES → **Import save** |
|
||||
| Save `.sav` inbox | Same save dir → `imports/saves/red\|blue\|yellow/` then that game's SAVE FILES → **Import save** |
|
||||
| Save exports | Same save dir → `exports/red\|blue\|yellow/` (pull after **Export save**; MTP / SD / FTP) |
|
||||
| Opt-in diagnostics | Empty `switch-debug.txt` in the save dir → `switch.log` |
|
||||
| Lua error log | `lua-error.log` in the save dir |
|
||||
@@ -34,7 +33,7 @@ files, or third-party mod zips to git.
|
||||
|
||||
---
|
||||
|
||||
## Canonical methods
|
||||
## Transfer methods
|
||||
|
||||
### 1. MTP (DBI responder + host client)
|
||||
|
||||
@@ -47,8 +46,8 @@ before launching.
|
||||
|
||||
#### macOS (example: OpenMTP)
|
||||
|
||||
[OpenMTP](https://github.com/ganeshrvel/openmtp) is the loop used for OLED
|
||||
hardware evidence — **one contributor example**, not a Mac-only product rule.
|
||||
[OpenMTP](https://github.com/ganeshrvel/openmtp) is a documented example for
|
||||
macOS, not a Mac-only requirement.
|
||||
|
||||
1. Quit other MTP clients.
|
||||
2. Open OpenMTP → select the DBI device → **`1: SD Card`**.
|
||||
@@ -60,25 +59,25 @@ hardware evidence — **one contributor example**, not a Mac-only product rule.
|
||||
5. Wait for the queue; refresh; exit MTP responder; title-override launch.
|
||||
|
||||
macOS clients often create AppleDouble sidecars (`._Something.zip`,
|
||||
`._cart.gb`, `._foo.sav`). Those are not real archives or saves — the
|
||||
`._cart.gb`, `._foo.sav`). Those are not real archives or saves. The
|
||||
launcher skips hidden `.*` names. Delete `._*` junk if a zip/ROM/`.sav`
|
||||
fails to open.
|
||||
|
||||
#### Linux
|
||||
|
||||
1. Install desktop MTP support if needed (e.g. `gvfs-mtp` on GNOME/GTK
|
||||
desktops, or your distro’s KDE MTP stack).
|
||||
desktops, or your distro's KDE MTP stack).
|
||||
2. With DBI MTP active, open **Files** / **Dolphin** / **Thunar** and select
|
||||
the Switch / DBI device → **`1: SD Card`**.
|
||||
3. Extract the release zip at SD root (merge), or copy into `switch/gen1recomp/`
|
||||
and the save-dir inboxes as above.
|
||||
4. Use **only one** MTP accessor at a time. If `mtp-tools` / `mtpfs` reports
|
||||
“device is busy”, close the file manager’s MTP mount (or the CLI mount)
|
||||
"device is busy", close the file manager's MTP mount (or the CLI mount)
|
||||
and retry with a single client.
|
||||
5. Eject/unmount cleanly; exit MTP on the Switch; title-override launch.
|
||||
|
||||
If MTP is unavailable or flaky on Linux, use **direct SD** (Hekate UMS or a
|
||||
card reader) or **FTP** instead — same destinations in the table above.
|
||||
card reader) or **FTP** instead. Same destinations in the table above.
|
||||
|
||||
#### Windows
|
||||
|
||||
@@ -93,7 +92,7 @@ card reader) or **FTP** instead — same destinations in the table above.
|
||||
5. Safely disconnect; exit MTP on the Switch; title-override launch.
|
||||
|
||||
If MTP is unavailable or flaky on Windows, use **direct SD** (Hekate UMS or a
|
||||
card reader) or **FTP** instead — same destinations in the table above.
|
||||
card reader) or **FTP** instead. Same destinations in the table above.
|
||||
|
||||
### 2. Direct SD (Hekate UMS or card reader)
|
||||
|
||||
@@ -109,9 +108,9 @@ Do not yank the card or unplug UMS mid-write.
|
||||
|
||||
### 3. FTP (any SD-exposing Switch FTP)
|
||||
|
||||
Any homebrew FTP server that can write the microSD is fine — for example
|
||||
**DBI’s own FTP**, **sys-ftpd-light**, or **Sphaira** (names are illustrations
|
||||
only; pick what your CFW setup already uses).
|
||||
Any homebrew FTP server that can write the microSD is fine. For example
|
||||
**DBI's own FTP**, **sys-ftpd-light**, or **Sphaira**. Names are illustrations
|
||||
only; pick what your CFW setup already uses.
|
||||
|
||||
1. Start the FTP server on the Switch; note IP/port/credentials from that app.
|
||||
2. From the host, connect with any FTP client and upload to the same
|
||||
@@ -154,7 +153,7 @@ Copy the file back from the SD and compare hashes. Round-trip must match.
|
||||
| Symptom | What to try |
|
||||
| ------- | ----------- |
|
||||
| Device busy / no MTP volume | One client only; different cable/port; Windows MTP USB Device driver; alternate method (SD or FTP) |
|
||||
| Zip/ROM/`.sav` “could not be opened” | Delete `._*` sidecars (including `._*.sav`); confirm real zip starts with `PK` |
|
||||
| Zip/ROM/`.sav` "could not be opened" | Delete `._*` sidecars (including `._*.sav`); confirm real zip starts with `PK` |
|
||||
| Half-copied NRO / crash on boot | Re-copy; verify SHA-256; exit transfer mode before launch |
|
||||
| App opens in Applet Mode | Use title override (hold **R**), not Album |
|
||||
|
||||
@@ -164,5 +163,3 @@ Copy the file back from the SD and compare hashes. Round-trip must match.
|
||||
|
||||
- Players: [switch-install.md](switch-install.md)
|
||||
- Builders: [switch-build.md](switch-build.md)
|
||||
- Status / hardware matrix: [switch-development.md](switch-development.md)
|
||||
- Evidence log: [switch-hardware-evidence.md](switch-hardware-evidence.md)
|
||||
|
||||
@@ -124,3 +124,8 @@ bundled game, in that case.
|
||||
`love.filesystem.isFused()` is false, and a working tree's `engine` is the
|
||||
`"0.0.0-dev"` placeholder that always reports up to date, so a source
|
||||
checkout is always "the game" itself; updating it means pulling the repo.
|
||||
- **Nintendo Switch does not use this LÖVE self-updater.** On NX,
|
||||
`Platform.networkValidated()` is `false`, so `Boot.run` / `Check` never
|
||||
download `.love` payloads. In-console OTA uses the **native OTA launcher**
|
||||
(DEVKITPRO), documented in [switch-install.md](switch-install.md). Wire
|
||||
format: `src/update/SwitchOta.lua`. Manual zip install remains the fallback.
|
||||
|
||||
@@ -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 |
@@ -0,0 +1,72 @@
|
||||
#---------------------------------------------------------------------------------
|
||||
# One-shot OTA bootstrap NRO (copied into the main launcher's romfs).
|
||||
#---------------------------------------------------------------------------------
|
||||
.SUFFIXES:
|
||||
|
||||
ifeq ($(strip $(DEVKITPRO)),)
|
||||
|
||||
.PHONY: all
|
||||
all:
|
||||
@echo "DEVKITPRO not set — bootstrap builds with the launcher only."
|
||||
@false
|
||||
|
||||
else
|
||||
|
||||
TOPDIR ?= $(CURDIR)
|
||||
include $(DEVKITPRO)/libnx/switch_rules
|
||||
|
||||
TARGET := ota-bootstrap
|
||||
BUILD := build
|
||||
SOURCES := .
|
||||
INCLUDES := include
|
||||
ROMFS :=
|
||||
|
||||
APP_TITLE := gen1recomp OTA
|
||||
APP_AUTHOR := bryanthaboi, port by andrewqsantos
|
||||
APP_VERSION := 0.0.0
|
||||
|
||||
ARCH := -march=armv8-a+crc+crypto -mtune=cortex-a57 -mtp=soft -fPIE
|
||||
CFLAGS := -g -Wall -O2 -ffunction-sections $(ARCH) $(DEFINES)
|
||||
CFLAGS += $(INCLUDE) -D__SWITCH__
|
||||
ASFLAGS := -g $(ARCH)
|
||||
LDFLAGS = -specs=$(DEVKITPRO)/libnx/switch.specs -g $(ARCH) -Wl,-Map,$(notdir $*.map)
|
||||
LIBS := -lnx
|
||||
LIBDIRS := $(LIBNX)
|
||||
|
||||
ifneq ($(BUILD),$(notdir $(CURDIR)))
|
||||
export OUTPUT := $(CURDIR)/$(TARGET)
|
||||
export TOPDIR := $(CURDIR)
|
||||
export VPATH := $(foreach dir,$(SOURCES),$(CURDIR)/$(dir))
|
||||
export DEPSDIR := $(CURDIR)/$(BUILD)
|
||||
CFILES := $(foreach dir,$(SOURCES),$(notdir $(wildcard $(dir)/*.c)))
|
||||
export LD := $(CC)
|
||||
export OFILES := $(CFILES:.c=.o)
|
||||
export INCLUDE := $(foreach dir,$(INCLUDES),-I$(CURDIR)/$(dir)) \
|
||||
$(foreach dir,$(LIBDIRS),-I$(dir)/include) \
|
||||
-I$(CURDIR)/$(BUILD)
|
||||
export LIBPATHS := $(foreach dir,$(LIBDIRS),-L$(dir)/lib)
|
||||
export APP_ICON := $(LIBNX)/default_icon.jpg
|
||||
export NROFLAGS += --icon=$(APP_ICON)
|
||||
export NROFLAGS += --nacp=$(CURDIR)/$(TARGET).nacp
|
||||
|
||||
.PHONY: $(BUILD) clean all
|
||||
|
||||
all: $(BUILD)
|
||||
|
||||
$(BUILD):
|
||||
@[ -d $@ ] || mkdir -p $@
|
||||
@$(MAKE) --no-print-directory -C $(BUILD) -f $(CURDIR)/Makefile
|
||||
|
||||
clean:
|
||||
@echo clean ...
|
||||
@rm -fr $(BUILD) $(TARGET).nro $(TARGET).nacp $(TARGET).elf
|
||||
|
||||
else
|
||||
.PHONY: all
|
||||
DEPENDS := $(OFILES:.o=.d)
|
||||
all: $(OUTPUT).nro
|
||||
$(OUTPUT).nro: $(OUTPUT).elf $(OUTPUT).nacp
|
||||
$(OUTPUT).elf: $(OFILES)
|
||||
-include $(DEPENDS)
|
||||
endif
|
||||
endif
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Tiny one-shot helper: swap a staged launcher NRO into place, then load the game.
|
||||
* The main OTA launcher chainloads here because a running NRO cannot replace itself
|
||||
* on sdmc/FAT.
|
||||
*/
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#if defined(__SWITCH__)
|
||||
#include <switch.h>
|
||||
#endif
|
||||
|
||||
#define INSTALL_DIR "sdmc:/switch/gen1recomp"
|
||||
#define STAGED INSTALL_DIR "/gen1recomp.nro.staged"
|
||||
#define LAUNCHER INSTALL_DIR "/gen1recomp.nro"
|
||||
#define GAME INSTALL_DIR "/gen1recomp-game.nro"
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
(void)argc;
|
||||
(void)argv;
|
||||
|
||||
#if defined(__SWITCH__)
|
||||
remove(LAUNCHER);
|
||||
if (rename(STAGED, LAUNCHER) != 0) {
|
||||
return 1;
|
||||
}
|
||||
Result rc = envSetNextLoad(GAME, GAME);
|
||||
if (R_FAILED(rc)) return 1;
|
||||
return 0;
|
||||
#else
|
||||
fprintf(stderr, "ota-bootstrap: host stub (would rename %s -> %s, load %s)\n", STAGED, LAUNCHER,
|
||||
GAME);
|
||||
return 0;
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
#---------------------------------------------------------------------------------
|
||||
# gen1recomp Switch native OTA launcher
|
||||
# Requires: DEVKITPRO + switch-curl, switch-mbedtls, switch-zlib, switch-zziplib
|
||||
# Host protocol tests: make host-test (no DEVKITPRO needed)
|
||||
#---------------------------------------------------------------------------------
|
||||
.SUFFIXES:
|
||||
|
||||
#---------------------------------------------------------------------------------
|
||||
# Host-only path when DEVKITPRO is unset
|
||||
#---------------------------------------------------------------------------------
|
||||
ifeq ($(strip $(DEVKITPRO)),)
|
||||
|
||||
.PHONY: host-test clean-host
|
||||
host-test:
|
||||
@mkdir -p build-host
|
||||
cc -std=c11 -Wall -Wextra -Iinclude -o build-host/test_ota_protocol \
|
||||
src/ota_protocol.c host/test_ota_protocol.c
|
||||
./build-host/test_ota_protocol
|
||||
|
||||
clean-host:
|
||||
rm -rf build-host
|
||||
|
||||
%:
|
||||
@echo "DEVKITPRO not set — only 'make host-test' is available."
|
||||
@echo "Run: bash scripts/switch/install_devkitpro_deps.sh"
|
||||
@false
|
||||
|
||||
else
|
||||
|
||||
#---------------------------------------------------------------------------------
|
||||
TOPDIR ?= $(CURDIR)
|
||||
include $(DEVKITPRO)/libnx/switch_rules
|
||||
|
||||
#---------------------------------------------------------------------------------
|
||||
TARGET := gen1recomp
|
||||
BUILD := build
|
||||
SOURCES := src
|
||||
DATA := data
|
||||
INCLUDES := include
|
||||
ROMFS := romfs
|
||||
|
||||
APP_TITLE := gen1recomp
|
||||
APP_AUTHOR := bryanthaboi, port by andrewqsantos
|
||||
# Overridable: scripts/switch/build_ota_launcher.sh passes release X.Y.Z
|
||||
APP_VERSION ?= 0.0.0
|
||||
|
||||
# Prefer project Switch icon if present
|
||||
ICON := $(TOPDIR)/../assets/icon.jpg
|
||||
LOGO_RGBA_SRC := $(TOPDIR)/../assets/logo.rgba
|
||||
LOGO_ROMFS := $(CURDIR)/$(ROMFS)/logo.rgba
|
||||
|
||||
#---------------------------------------------------------------------------------
|
||||
ARCH := -march=armv8-a+crc+crypto -mtune=cortex-a57 -mtp=soft -fPIE
|
||||
|
||||
CFLAGS := -g -Wall -O2 -ffunction-sections $(ARCH) $(DEFINES)
|
||||
CFLAGS += $(INCLUDE) -D__SWITCH__
|
||||
|
||||
CXXFLAGS := $(CFLAGS) -fno-rtti -fno-exceptions
|
||||
|
||||
ASFLAGS := -g $(ARCH)
|
||||
LDFLAGS = -specs=$(DEVKITPRO)/libnx/switch.specs -g $(ARCH) -Wl,-Map,$(notdir $*.map)
|
||||
|
||||
LIBS := -lcurl -lzzip -lmbedtls -lmbedx509 -lmbedcrypto -lz -lnx
|
||||
|
||||
LIBDIRS := $(PORTLIBS) $(LIBNX)
|
||||
|
||||
#---------------------------------------------------------------------------------
|
||||
ifneq ($(BUILD),$(notdir $(CURDIR)))
|
||||
#---------------------------------------------------------------------------------
|
||||
|
||||
export OUTPUT := $(CURDIR)/$(TARGET)
|
||||
export TOPDIR := $(CURDIR)
|
||||
|
||||
export VPATH := $(foreach dir,$(SOURCES),$(CURDIR)/$(dir)) \
|
||||
$(foreach dir,$(DATA),$(CURDIR)/$(dir))
|
||||
|
||||
export DEPSDIR := $(CURDIR)/$(BUILD)
|
||||
|
||||
CFILES := $(foreach dir,$(SOURCES),$(notdir $(wildcard $(dir)/*.c)))
|
||||
CPPFILES := $(foreach dir,$(SOURCES),$(notdir $(wildcard $(dir)/*.cpp)))
|
||||
SFILES := $(foreach dir,$(SOURCES),$(notdir $(wildcard $(dir)/*.s)))
|
||||
BINFILES := $(foreach dir,$(DATA),$(notdir $(wildcard $(dir)/*.*)))
|
||||
|
||||
#---------------------------------------------------------------------------------
|
||||
# use CXX for linking C++ projects, CC for standard C
|
||||
#---------------------------------------------------------------------------------
|
||||
ifeq ($(strip $(CPPFILES)),)
|
||||
export LD := $(CC)
|
||||
else
|
||||
export LD := $(CXX)
|
||||
endif
|
||||
#---------------------------------------------------------------------------------
|
||||
|
||||
export OFILES_BIN := $(addsuffix .o,$(BINFILES))
|
||||
export OFILES_SRC := $(CPPFILES:.cpp=.o) $(CFILES:.c=.o) $(SFILES:.s=.o)
|
||||
export OFILES := $(OFILES_BIN) $(OFILES_SRC)
|
||||
|
||||
export INCLUDE := $(foreach dir,$(INCLUDES),-I$(CURDIR)/$(dir)) \
|
||||
$(foreach dir,$(LIBDIRS),-I$(dir)/include) \
|
||||
-I$(CURDIR)/$(BUILD)
|
||||
|
||||
export LIBPATHS := $(foreach dir,$(LIBDIRS),-L$(dir)/lib)
|
||||
|
||||
ifeq ($(strip $(ICON)),)
|
||||
icons := $(wildcard *.jpg)
|
||||
ifneq (,$(findstring $(TARGET).jpg,$(icons)))
|
||||
export APP_ICON := $(TOPDIR)/$(TARGET).jpg
|
||||
else
|
||||
ifneq (,$(findstring icon.jpg,$(icons)))
|
||||
export APP_ICON := $(TOPDIR)/icon.jpg
|
||||
endif
|
||||
endif
|
||||
else
|
||||
ifeq ($(wildcard $(ICON)),)
|
||||
# fall back to libnx default if project icon missing
|
||||
export APP_ICON := $(LIBNX)/default_icon.jpg
|
||||
else
|
||||
export APP_ICON := $(ICON)
|
||||
endif
|
||||
endif
|
||||
|
||||
ifeq ($(strip $(NO_ICON)),)
|
||||
export NROFLAGS += --icon=$(APP_ICON)
|
||||
endif
|
||||
|
||||
ifeq ($(strip $(NO_NACP)),)
|
||||
export NROFLAGS += --nacp=$(CURDIR)/$(TARGET).nacp
|
||||
endif
|
||||
|
||||
ifneq ($(ROMFS),)
|
||||
export NROFLAGS += --romfsdir=$(CURDIR)/$(ROMFS)
|
||||
endif
|
||||
|
||||
BOOTSTRAP_DIR := $(TOPDIR)/../ota-bootstrap
|
||||
BOOTSTRAP_ROMFS := $(CURDIR)/$(ROMFS)/ota-bootstrap.nro
|
||||
|
||||
.PHONY: $(BUILD) clean all host-test sync-romfs bootstrap-romfs
|
||||
|
||||
#---------------------------------------------------------------------------------
|
||||
all: sync-romfs $(BUILD)
|
||||
|
||||
CACERT_URL := https://curl.se/ca/cacert.pem
|
||||
CACERT_ROMFS := $(CURDIR)/$(ROMFS)/cacert.pem
|
||||
|
||||
bootstrap-romfs:
|
||||
@$(MAKE) --no-print-directory -C $(BOOTSTRAP_DIR) all
|
||||
@mkdir -p $(CURDIR)/$(ROMFS)
|
||||
@cp -f $(BOOTSTRAP_DIR)/ota-bootstrap.nro $(BOOTSTRAP_ROMFS)
|
||||
|
||||
sync-romfs: bootstrap-romfs
|
||||
@mkdir -p $(CURDIR)/$(ROMFS)
|
||||
@[ -f "$(LOGO_RGBA_SRC)" ] || (echo "missing $(LOGO_RGBA_SRC) — run: python3 scripts/switch/bake_ota_logo.py" && exit 1)
|
||||
@cp -f "$(LOGO_RGBA_SRC)" "$(LOGO_ROMFS)"
|
||||
@if ! curl -sfL --time-cond $(CACERT_ROMFS) -o $(CACERT_ROMFS) $(CACERT_URL); then \
|
||||
[ -f $(CACERT_ROMFS) ] || (echo "sync-romfs: failed to fetch cacert.pem" && exit 1); \
|
||||
fi
|
||||
|
||||
$(BUILD): sync-romfs
|
||||
@[ -d $@ ] || mkdir -p $@
|
||||
@$(MAKE) --no-print-directory -C $(BUILD) -f $(CURDIR)/Makefile
|
||||
|
||||
#---------------------------------------------------------------------------------
|
||||
clean:
|
||||
@echo clean ...
|
||||
@$(MAKE) --no-print-directory -C $(BOOTSTRAP_DIR) clean || true
|
||||
@rm -fr $(BUILD) $(TARGET).nro $(TARGET).nacp $(TARGET).elf $(TARGET).lst build-host
|
||||
@rm -f $(CURDIR)/$(ROMFS)/logo.rgba $(CURDIR)/$(ROMFS)/logo.png
|
||||
|
||||
host-test:
|
||||
@mkdir -p build-host
|
||||
cc -std=c11 -Wall -Wextra -Iinclude -o build-host/test_ota_protocol \
|
||||
src/ota_protocol.c host/test_ota_protocol.c
|
||||
./build-host/test_ota_protocol
|
||||
|
||||
#---------------------------------------------------------------------------------
|
||||
else
|
||||
.PHONY: all
|
||||
|
||||
DEPENDS := $(OFILES:.o=.d)
|
||||
|
||||
#---------------------------------------------------------------------------------
|
||||
all: $(OUTPUT).nro
|
||||
|
||||
$(OUTPUT).nro: $(OUTPUT).elf $(OUTPUT).nacp
|
||||
|
||||
$(OUTPUT).elf: $(OFILES)
|
||||
|
||||
-include $(DEPENDS)
|
||||
|
||||
#---------------------------------------------------------------------------------
|
||||
endif
|
||||
#---------------------------------------------------------------------------------
|
||||
endif
|
||||
@@ -0,0 +1,89 @@
|
||||
# Native Switch OTA launcher
|
||||
|
||||
In-console updates for Gen1Recomp on Nintendo Switch. This NRO is the **hbmenu
|
||||
entry** (`gen1recomp.nro`). It checks GitHub Releases quietly (no UI when you
|
||||
are already up to date or offline). Only if a newer release exists does it show
|
||||
a **launcher-style screen** (black + RGB rail + project logo + flat A/B
|
||||
buttons), download the same `gen1recomp-*-switch.zip` used for install, verify
|
||||
SHA-256 from `sha256sums.txt`, replace **both** `gen1recomp-game.nro` and
|
||||
`gen1recomp.nro` (matching NACP version for hbmenu/Sphaira), then load the game
|
||||
with `envSetNextLoad`.
|
||||
|
||||
The LÖVE self-updater (`src/update/Check.lua`) stays **disabled** on NX.
|
||||
Wire format (also in Lua): `src/update/SwitchOta.lua`.
|
||||
NACP icon: `ports/switch/assets/icon.jpg`.
|
||||
|
||||
## Layout on microSD
|
||||
|
||||
```text
|
||||
sdmc:/switch/gen1recomp/gen1recomp.nro <- this launcher
|
||||
sdmc:/switch/gen1recomp/gen1recomp-game.nro <- fused LÖVE game
|
||||
sdmc:/switch/gen1recomp/version.txt <- installed X.Y.Z
|
||||
sdmc:/switch/gen1recomp/pokemon-love2d/ <- saves (never touched by OTA)
|
||||
```
|
||||
|
||||
## Host tests (no DEVKITPRO)
|
||||
|
||||
```bash
|
||||
cd ports/switch/ota-launcher
|
||||
make host-test
|
||||
```
|
||||
|
||||
## Switch build (DEVKITPRO)
|
||||
|
||||
Needs `DEVKITPRO` with packages roughly:
|
||||
|
||||
```bash
|
||||
(dkp-)pacman -S --needed switch-curl switch-mbedtls switch-zlib switch-zziplib
|
||||
# or: bash scripts/switch/install_devkitpro_deps.sh
|
||||
```
|
||||
|
||||
```bash
|
||||
export DEVKITPRO=/opt/devkitpro # typical
|
||||
cd ports/switch/ota-launcher
|
||||
make
|
||||
# -> gen1recomp.nro
|
||||
```
|
||||
|
||||
Or from repo root (as part of `--fused`):
|
||||
|
||||
```bash
|
||||
scripts/build_switch.sh --fetch --fused --version X.Y.Z
|
||||
```
|
||||
|
||||
Standalone launcher build:
|
||||
|
||||
```bash
|
||||
scripts/switch/build_ota_launcher.sh
|
||||
```
|
||||
|
||||
Docker fallback uses the same pin as fused builds (`scripts/switch/dkp-docker.image`).
|
||||
|
||||
## OTA logo asset
|
||||
|
||||
The launcher draws a pre-scaled logo from `romfs:/logo.rgba` (no PNG decoder in
|
||||
the NRO). The baked blob lives at `../assets/logo.rgba` and is copied into romfs at
|
||||
build time. After changing `assets/logo/logo.png`, regenerate:
|
||||
|
||||
```bash
|
||||
python3 scripts/switch/bake_ota_logo.py
|
||||
```
|
||||
|
||||
Requires Pillow, or on macOS uses `sips` when Pillow is not installed.
|
||||
|
||||
## Packaging
|
||||
|
||||
`scripts/switch/pack_sd_zip.sh GAME_NRO VERSION OUT_ZIP LAUNCHER_NRO` writes both
|
||||
NROs into the SD zip. That zip is also what OTA downloads.
|
||||
Manifest: `scripts/switch/ota_launcher.manifest`.
|
||||
|
||||
## Status / known gaps
|
||||
|
||||
- Zip extraction uses `switch-zziplib` (`ota_unzip.c`) on device.
|
||||
- OTA replaces game + launcher from the install zip (NACP versions stay aligned).
|
||||
The running launcher cannot overwrite its own NRO on sdmc/FAT; a tiny
|
||||
`ota-bootstrap.nro` (embedded in romfs) chainloads once to swap the staged
|
||||
launcher, then loads the game.
|
||||
- HTTPS uses Mozilla CA bundle in romfs (`cacert.pem`, fetched at build time). `ota_net_init()` mounts romfs before the quiet release check.
|
||||
- Sphaira HOME forwarders cache metadata until reinstalled (see docs/switch-install.md).
|
||||
- Release runner: `switch-dev` + (`install_devkitpro_deps.sh` **or** Docker)
|
||||
@@ -0,0 +1,144 @@
|
||||
/* Host-only unit tests for ota_protocol.c — no libnx. */
|
||||
#include "ota_protocol.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
static int g_fail = 0;
|
||||
|
||||
static void expect(int cond, const char *msg) {
|
||||
if (!cond) {
|
||||
fprintf(stderr, "FAIL: %s\n", msg);
|
||||
g_fail++;
|
||||
} else {
|
||||
printf("PASS: %s\n", msg);
|
||||
}
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
expect(ota_compare_semver("1.2.0", "1.1.0") == 1, "semver newer");
|
||||
expect(ota_compare_semver("1.1.0", "1.1.0") == 0, "semver equal");
|
||||
expect(ota_compare_semver("1.0.0", "1.1.0") == -1, "semver older");
|
||||
expect(ota_is_ota_asset_name("gen1recomp-1.5.0-switch.zip"), "ota asset name");
|
||||
expect(!ota_is_ota_asset_name("gen1recomp-1.5.0-switch-ota.zip"), "reject legacy ota zip name");
|
||||
expect(!ota_is_ota_asset_name("gen1recomp-1.5.0.love"), "reject love payload");
|
||||
|
||||
const char *json =
|
||||
"{"
|
||||
"\"tag_name\":\"v1.5.0\","
|
||||
"\"assets\":["
|
||||
"{\"name\":\"gen1recomp-1.5.0-switch.zip\","
|
||||
"\"browser_download_url\":\"https://example/switch.zip\"},"
|
||||
"{\"name\":\"sha256sums.txt\",\"browser_download_url\":\"https://example/sums\"}"
|
||||
"]"
|
||||
"}";
|
||||
ota_release_t rel;
|
||||
expect(ota_parse_release(json, &rel) == 1, "parse release");
|
||||
expect(strcmp(rel.version, "1.5.0") == 0, "release version");
|
||||
expect(strcmp(rel.asset_name, "gen1recomp-1.5.0-switch.zip") == 0, "release asset");
|
||||
|
||||
ota_decision_t d;
|
||||
ota_decide_update("1.4.0", &rel, &d);
|
||||
expect(strcmp(d.status, "available") == 0, "decide available");
|
||||
ota_decide_update("1.5.0", &rel, &d);
|
||||
expect(strcmp(d.status, "uptodate") == 0, "decide uptodate");
|
||||
|
||||
ota_release_t bad;
|
||||
expect(ota_parse_release("{\"tag_name\":\"v1.5.0\",\"assets\":[]}", &bad) == 0,
|
||||
"missing ota asset");
|
||||
expect(strcmp(bad.reason, "missing_ota_asset") == 0, "missing_ota_asset reason");
|
||||
|
||||
/* GitHub releases/latest shape: release-level name + fat uploader before browser_download_url. */
|
||||
const char *github_json =
|
||||
"{"
|
||||
"\"tag_name\":\"v0.1.70\","
|
||||
"\"name\":\"0.1.70\","
|
||||
"\"assets\":["
|
||||
"{"
|
||||
"\"url\":\"https://api.github.com/repos/bryanthaboi/gen1recomp/releases/assets/502823880\","
|
||||
"\"id\":502823880,"
|
||||
"\"name\":\"gen1recomp-0.1.70-switch.zip\","
|
||||
"\"label\":\"\","
|
||||
"\"uploader\":{"
|
||||
"\"login\":\"github-actions[bot]\","
|
||||
"\"id\":41898282,"
|
||||
"\"node_id\":\"MDM6Qm90NDE4OTgyODI=\","
|
||||
"\"avatar_url\":\"https://avatars.githubusercontent.com/in/15368?v=4\","
|
||||
"\"gravatar_id\":\"\","
|
||||
"\"url\":\"https://api.github.com/users/github-actions%5Bbot%5D\","
|
||||
"\"html_url\":\"https://github.com/apps/github-actions\","
|
||||
"\"followers_url\":\"https://api.github.com/users/github-actions%5Bbot%5D/followers\","
|
||||
"\"following_url\":\"https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}\","
|
||||
"\"gists_url\":\"https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}\","
|
||||
"\"starred_url\":\"https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}\","
|
||||
"\"subscriptions_url\":\"https://api.github.com/users/github-actions%5Bbot%5D/subscriptions\","
|
||||
"\"organizations_url\":\"https://api.github.com/users/github-actions%5Bbot%5D/orgs\","
|
||||
"\"repos_url\":\"https://api.github.com/users/github-actions%5Bbot%5D/repos\","
|
||||
"\"events_url\":\"https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}\","
|
||||
"\"received_events_url\":\"https://api.github.com/users/github-actions%5Bbot%5D/received_events\","
|
||||
"\"type\":\"Bot\","
|
||||
"\"user_view_type\":\"public\","
|
||||
"\"site_admin\":false"
|
||||
"},"
|
||||
"\"content_type\":\"application/zip\","
|
||||
"\"state\":\"uploaded\","
|
||||
"\"size\":9000573,"
|
||||
"\"browser_download_url\":\"https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.70/gen1recomp-0.1.70-switch.zip\""
|
||||
"}"
|
||||
"]"
|
||||
"}";
|
||||
ota_release_t gh;
|
||||
expect(ota_parse_release(github_json, &gh) == 1, "parse github-shaped release");
|
||||
expect(strcmp(gh.version, "0.1.70") == 0, "github release version");
|
||||
expect(strcmp(gh.asset_name, "gen1recomp-0.1.70-switch.zip") == 0, "github release asset");
|
||||
expect(strcmp(gh.download_url,
|
||||
"https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.70/gen1recomp-0.1.70-switch.zip") ==
|
||||
0,
|
||||
"github release download url");
|
||||
ota_decide_update("0.1.69", &gh, &d);
|
||||
expect(strcmp(d.status, "available") == 0, "github release decide 0.1.69->0.1.70");
|
||||
|
||||
const char *sums = "abc123 gen1recomp-1.5.0-switch.zip\n";
|
||||
ota_verify_t v;
|
||||
ota_verify_sha256("gen1recomp-1.5.0-switch.zip", "abc123", sums, &v);
|
||||
expect(v.ok == 1, "sha ok");
|
||||
ota_verify_sha256("gen1recomp-1.5.0-switch.zip", "deadbeef", sums, &v);
|
||||
expect(v.ok == 0 && strcmp(v.reason, "hash_mismatch") == 0, "sha mismatch");
|
||||
ota_verify_sha256("gen1recomp-1.5.0-switch.zip", "abc123", "", &v);
|
||||
expect(v.ok == 0 && strcmp(v.reason, "sum_not_found") == 0, "sha missing sum rejected");
|
||||
|
||||
ota_apply_plan_t plan;
|
||||
ota_plan_atomic_apply("switch/gen1recomp", "/tmp/x", &plan);
|
||||
expect(strstr(plan.steps[0], "copy_to_part:") != NULL, "plan copy");
|
||||
expect(strstr(plan.steps[1], "rename:") != NULL, "plan rename");
|
||||
expect(strstr(plan.steps[2], "copy_to_part:launcher") != NULL, "plan launcher copy");
|
||||
expect(strstr(plan.steps[3], "rename:") != NULL, "plan launcher rename");
|
||||
expect(strstr(plan.steps[4], "env_set_next_load:") != NULL, "plan handoff");
|
||||
expect(strstr(plan.preserve, "pokemon-love2d") != NULL, "preserve saves");
|
||||
expect(strstr(plan.forbidden_delete, "delete:") != NULL, "forbid delete saves");
|
||||
expect(strstr(plan.forbidden_direct, "write_direct:") != NULL, "forbid direct write");
|
||||
|
||||
ota_offline_t off;
|
||||
ota_offline_events_t ev = {0};
|
||||
ev.user_skip = 1;
|
||||
ota_offline_policy(1.0, &ev, &off);
|
||||
expect(strcmp(off.action, "play_installed") == 0, "skip plays installed");
|
||||
ev.user_skip = 0;
|
||||
ev.network_ok = 0;
|
||||
ota_offline_policy(1.0, &ev, &off);
|
||||
expect(strcmp(off.action, "play_installed") == 0, "offline plays installed");
|
||||
ev.network_ok = 1;
|
||||
ota_offline_policy(6.0, &ev, &off);
|
||||
expect(strcmp(off.reason, "timeout") == 0, "timeout 6s");
|
||||
ota_offline_policy(2.0, &ev, &off);
|
||||
expect(strcmp(off.action, "keep_checking") == 0, "still checking");
|
||||
|
||||
expect(OTA_CHECK_TIMEOUT_SEC == 6, "timeout constant 6");
|
||||
|
||||
if (g_fail) {
|
||||
fprintf(stderr, "%d failure(s)\n", g_fail);
|
||||
return 1;
|
||||
}
|
||||
printf("all ota_protocol host tests passed\n");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
#ifndef GEN1_OTA_FS_H
|
||||
#define GEN1_OTA_FS_H
|
||||
|
||||
#include "ota_protocol.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* Read installed game version from sibling version.txt or NRO nacp-less stamp file.
|
||||
* Expects switch/gen1recomp/version.txt containing X.Y.Z. Returns 0 on success. */
|
||||
int ota_fs_read_installed_version(const char *install_dir, char *out, size_t out_len);
|
||||
|
||||
/* SHA-256 hex (lowercase) of file. Returns 0 on success. */
|
||||
int ota_fs_sha256_file(const char *path, char *out_hex, size_t out_len);
|
||||
|
||||
/* Apply verified temp payload (extracted game NRO) using atomic plan. Returns 0 on success. */
|
||||
int ota_fs_atomic_replace_game(const char *install_dir, const char *verified_game_nro,
|
||||
char *err, size_t err_len);
|
||||
|
||||
/* Atomically replace any named NRO under install_dir (copy→.part→rename). */
|
||||
int ota_fs_atomic_replace_nro(const char *install_dir, const char *nro_name,
|
||||
const char *verified_nro, char *err, size_t err_len);
|
||||
|
||||
/* Stage a new launcher + copy romfs bootstrap; chainload bootstrap_out next.
|
||||
* The running launcher cannot replace its own NRO on sdmc/FAT. */
|
||||
int ota_fs_stage_launcher_bootstrap(const char *install_dir, const char *verified_launcher,
|
||||
char *bootstrap_out, size_t bootstrap_out_len, char *err,
|
||||
size_t err_len);
|
||||
|
||||
/* Hand off to game NRO via envSetNextLoad (Switch) or no-op stub (host). */
|
||||
int ota_fs_handoff_to_game(const char *game_nro_path, char *err, size_t err_len);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,29 @@
|
||||
#ifndef GEN1_OTA_NET_H
|
||||
#define GEN1_OTA_NET_H
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* fraction is 0..1 when Content-Length is known, else -1 for indeterminate. */
|
||||
typedef void (*ota_net_progress_fn)(void *userdata, double fraction);
|
||||
|
||||
/* Mount romfs (CA bundle) and init libcurl. Call before any HTTPS download. */
|
||||
int ota_net_init(void);
|
||||
void ota_net_shutdown(void);
|
||||
|
||||
/* Download URL into memory buffer (caller frees *out). Returns 0 on success. */
|
||||
int ota_net_download_buffer(const char *url, long timeout_ms, char **out, size_t *out_len,
|
||||
char *err, size_t err_len);
|
||||
|
||||
/* Download URL to a filesystem path. progress may be NULL. Returns 0 on success. */
|
||||
int ota_net_download_file(const char *url, const char *path, long timeout_ms, char *err,
|
||||
size_t err_len, ota_net_progress_fn progress, void *progress_ud);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,95 @@
|
||||
#ifndef GEN1_OTA_PROTOCOL_H
|
||||
#define GEN1_OTA_PROTOCOL_H
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#define OTA_CHECK_TIMEOUT_SEC 6
|
||||
#define OTA_GAME_NRO_NAME "gen1recomp-game.nro"
|
||||
#define OTA_LAUNCHER_NRO_NAME "gen1recomp.nro"
|
||||
#define OTA_SAVE_DIR_NAME "pokemon-love2d"
|
||||
#define OTA_INSTALL_DIR "switch/gen1recomp"
|
||||
#define OTA_LAUNCHER_STAGED_SUFFIX ".staged"
|
||||
#define OTA_BOOTSTRAP_ROMFS "romfs:/ota-bootstrap.nro"
|
||||
#define OTA_BOOTSTRAP_SD_NAME "ota-bootstrap.nro"
|
||||
#define OTA_RELEASES_API \
|
||||
"https://api.github.com/repos/bryanthaboi/gen1recomp/releases/latest"
|
||||
|
||||
/* Mirrors src/update/SwitchOta.lua — keep semantics in lockstep. */
|
||||
|
||||
int ota_compare_semver(const char *a, const char *b);
|
||||
int ota_is_ota_asset_name(const char *name);
|
||||
int ota_version_from_ota_asset(const char *name, char *out, size_t out_len);
|
||||
|
||||
typedef struct {
|
||||
int ok; /* 1 on success */
|
||||
char reason[64];
|
||||
char tag[64];
|
||||
char version[32];
|
||||
char asset_name[128];
|
||||
char download_url[512];
|
||||
} ota_release_t;
|
||||
|
||||
int ota_parse_release(const char *json_text, ota_release_t *out);
|
||||
|
||||
typedef struct {
|
||||
char status[32]; /* uptodate | available | error */
|
||||
char reason[64];
|
||||
char version[32];
|
||||
char asset_name[128];
|
||||
char download_url[512];
|
||||
} ota_decision_t;
|
||||
|
||||
void ota_decide_update(const char *installed_version, const ota_release_t *release,
|
||||
ota_decision_t *out);
|
||||
|
||||
/* sums: newline-separated sha256sums.txt body */
|
||||
int ota_lookup_sum(const char *sums_text, const char *asset_name, char *out_hex,
|
||||
size_t out_len);
|
||||
|
||||
typedef struct {
|
||||
int ok;
|
||||
char reason[64];
|
||||
} ota_verify_t;
|
||||
|
||||
void ota_verify_sha256(const char *asset_name, const char *actual_hex,
|
||||
const char *sums_text, ota_verify_t *out);
|
||||
|
||||
typedef struct {
|
||||
char steps[5][520]; /* human-readable ops */
|
||||
char preserve[256];
|
||||
char forbidden_delete[256];
|
||||
char forbidden_direct[256];
|
||||
char part_path[256];
|
||||
char game_nro[240];
|
||||
char launcher_nro[240];
|
||||
char launcher_part[256];
|
||||
char next_load[240];
|
||||
} ota_apply_plan_t;
|
||||
|
||||
void ota_plan_atomic_apply(const char *install_dir, const char *verified_temp,
|
||||
ota_apply_plan_t *out);
|
||||
|
||||
typedef struct {
|
||||
char action[32]; /* play_installed | keep_checking */
|
||||
char reason[64];
|
||||
char message[128];
|
||||
} ota_offline_t;
|
||||
|
||||
typedef struct {
|
||||
int user_skip;
|
||||
int network_ok; /* 0 = offline/fail, 1 = ok, -1 = unknown */
|
||||
int api_error;
|
||||
} ota_offline_events_t;
|
||||
|
||||
void ota_offline_policy(double elapsed_sec, const ota_offline_events_t *events,
|
||||
ota_offline_t *out);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* GEN1_OTA_PROTOCOL_H */
|
||||
@@ -0,0 +1,37 @@
|
||||
#ifndef GEN1_OTA_UI_H
|
||||
#define GEN1_OTA_UI_H
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* Framebuffer UI matching the in-game launcher look (black + RGB rail +
|
||||
* flat semantic buttons). Silent by default — only call when user-facing. */
|
||||
|
||||
/* Returns 1 = update, 0 = skip / play installed. */
|
||||
int ota_ui_prompt_update(const char *installed, const char *latest);
|
||||
|
||||
/* Status screen (download / install). Redraws each call. */
|
||||
void ota_ui_show_status(const char *title, const char *detail);
|
||||
|
||||
/* Progress 0..1; detail optional. */
|
||||
void ota_ui_show_progress(const char *title, const char *detail, float progress01);
|
||||
|
||||
/* Error / info + wait for B. */
|
||||
void ota_ui_alert(const char *title, const char *line1, const char *line2);
|
||||
|
||||
/* User-friendly error with optional technical detail and installed-version footer. */
|
||||
void ota_ui_alert_error(const char *title, const char *friendly, const char *technical,
|
||||
const char *installed_version);
|
||||
|
||||
/* Missing game install screen; wait for +. */
|
||||
void ota_ui_missing_game(void);
|
||||
|
||||
/* Tear down framebuffer if active. Safe to call when UI never opened. */
|
||||
void ota_ui_shutdown(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,20 @@
|
||||
#ifndef GEN1_OTA_UNZIP_H
|
||||
#define GEN1_OTA_UNZIP_H
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* Extract member_name from zip_path into dest_path.
|
||||
* Tries exact member_name, then common prefixes (switch/gen1recomp/).
|
||||
* Returns 0 on success. */
|
||||
int ota_unzip_extract_file(const char *zip_path, const char *member_name, const char *dest_path,
|
||||
char *err, size_t err_len);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,281 @@
|
||||
/*
|
||||
* gen1recomp Switch OTA launcher
|
||||
*
|
||||
* Quiet by default: checks GitHub Releases with no UI. Only shows the
|
||||
* branded launcher-style screen when an update is available. Downloads the
|
||||
* same SD zip (gen1recomp-*-switch.zip), verifies SHA-256, replaces game +
|
||||
* launcher NROs, then envSetNextLoad. Never touches pokemon-love2d/.
|
||||
* LÖVE self-updater stays off on NX.
|
||||
*/
|
||||
|
||||
#include "ota_fs.h"
|
||||
#include "ota_net.h"
|
||||
#include "ota_protocol.h"
|
||||
#include "ota_ui.h"
|
||||
#include "ota_unzip.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#if defined(__SWITCH__)
|
||||
#include <sys/stat.h>
|
||||
#include <switch.h>
|
||||
#include <unistd.h>
|
||||
#else
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
#endif
|
||||
|
||||
#define SD_INSTALL_DIR "sdmc:/switch/gen1recomp"
|
||||
#define CHECK_TIMEOUT_MS (OTA_CHECK_TIMEOUT_SEC * 1000L)
|
||||
#define GAME_MEMBER_IN_ZIP "switch/gen1recomp/" OTA_GAME_NRO_NAME
|
||||
#define LAUNCHER_MEMBER_IN_ZIP "switch/gen1recomp/" OTA_LAUNCHER_NRO_NAME
|
||||
|
||||
typedef struct {
|
||||
const char *detail;
|
||||
float base;
|
||||
float span;
|
||||
} download_progress_ctx_t;
|
||||
|
||||
static void on_download_progress(void *userdata, double fraction) {
|
||||
download_progress_ctx_t *ctx = (download_progress_ctx_t *)userdata;
|
||||
if (!ctx) return;
|
||||
float p = ctx->base;
|
||||
if (fraction >= 0.0) p += ctx->span * (float)fraction;
|
||||
else p += ctx->span * 0.1f;
|
||||
if (p > ctx->base + ctx->span) p = ctx->base + ctx->span;
|
||||
ota_ui_show_progress("Step 1/3: Downloading...", ctx->detail, p);
|
||||
}
|
||||
|
||||
static void show_update_error(const char *title, const char *friendly, const char *technical,
|
||||
const char *installed) {
|
||||
ota_ui_alert_error(title, friendly, technical, installed);
|
||||
}
|
||||
|
||||
static int run_update_flow(const char *install_dir) {
|
||||
char installed[64] = "0.0.0";
|
||||
(void)ota_fs_read_installed_version(install_dir, installed, sizeof(installed));
|
||||
|
||||
char *json = NULL;
|
||||
size_t json_len = 0;
|
||||
char err[256];
|
||||
err[0] = '\0';
|
||||
|
||||
if (ota_net_download_buffer(OTA_RELEASES_API, CHECK_TIMEOUT_MS, &json, &json_len, err,
|
||||
sizeof(err)) != 0) {
|
||||
free(json);
|
||||
return 0; /* offline / timeout — play installed, no UI */
|
||||
}
|
||||
|
||||
ota_release_t rel;
|
||||
if (!ota_parse_release(json, &rel)) {
|
||||
free(json);
|
||||
return 0;
|
||||
}
|
||||
free(json);
|
||||
|
||||
ota_decision_t dec;
|
||||
ota_decide_update(installed, &rel, &dec);
|
||||
if (strcmp(dec.status, "uptodate") == 0 || strcmp(dec.status, "error") == 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!ota_ui_prompt_update(installed, dec.version)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
char updates_dir[192];
|
||||
char zip_path[256];
|
||||
char sums_path[256];
|
||||
snprintf(updates_dir, sizeof(updates_dir), "%s/updates", install_dir);
|
||||
snprintf(zip_path, sizeof(zip_path), "%s/updates/%s", install_dir, dec.asset_name);
|
||||
snprintf(sums_path, sizeof(sums_path), "%s/updates/sha256sums.txt", install_dir);
|
||||
|
||||
#if defined(__SWITCH__)
|
||||
mkdir(updates_dir, 0755);
|
||||
#endif
|
||||
|
||||
download_progress_ctx_t dl_ctx = {"This may take a minute.", 0.f, 0.5f};
|
||||
ota_ui_show_progress("Step 1/3: Downloading...", dl_ctx.detail, dl_ctx.base);
|
||||
if (ota_net_download_file(dec.download_url, zip_path, 180000L, err, sizeof(err),
|
||||
on_download_progress, &dl_ctx) != 0) {
|
||||
show_update_error("Download failed",
|
||||
"Could not download the update. Check Wi-Fi and try again.", err, installed);
|
||||
return 0;
|
||||
}
|
||||
|
||||
ota_ui_show_progress("Step 2/3: Verifying...", "Checking file integrity.", 0.55f);
|
||||
char sums_url[512];
|
||||
snprintf(sums_url, sizeof(sums_url),
|
||||
"https://github.com/bryanthaboi/gen1recomp/releases/download/%s/sha256sums.txt",
|
||||
rel.tag);
|
||||
if (ota_net_download_file(sums_url, sums_path, CHECK_TIMEOUT_MS, err, sizeof(err), NULL,
|
||||
NULL) != 0) {
|
||||
remove(zip_path);
|
||||
show_update_error("Could not verify", "Downloaded file could not be checked.", err, installed);
|
||||
return 0;
|
||||
}
|
||||
|
||||
FILE *sf = fopen(sums_path, "rb");
|
||||
if (!sf) {
|
||||
remove(zip_path);
|
||||
show_update_error("Could not verify", "Could not read checksum file.", "fopen sums failed",
|
||||
installed);
|
||||
return 0;
|
||||
}
|
||||
fseek(sf, 0, SEEK_END);
|
||||
long slen = ftell(sf);
|
||||
fseek(sf, 0, SEEK_SET);
|
||||
char *sums = (char *)malloc((size_t)slen + 1);
|
||||
if (!sums) {
|
||||
fclose(sf);
|
||||
remove(zip_path);
|
||||
show_update_error("Could not verify", "Not enough memory to verify update.", "malloc failed",
|
||||
installed);
|
||||
return 0;
|
||||
}
|
||||
fread(sums, 1, (size_t)slen, sf);
|
||||
sums[slen] = '\0';
|
||||
fclose(sf);
|
||||
|
||||
ota_ui_show_progress("Step 2/3: Verifying...", "Computing checksum...", 0.65f);
|
||||
char hex[96];
|
||||
if (ota_fs_sha256_file(zip_path, hex, sizeof(hex)) != 0) {
|
||||
free(sums);
|
||||
remove(zip_path);
|
||||
show_update_error("Could not verify", "Could not read downloaded update file.",
|
||||
"sha256 file read failed", installed);
|
||||
return 0;
|
||||
}
|
||||
ota_verify_t ver;
|
||||
ota_verify_sha256(dec.asset_name, hex, sums, &ver);
|
||||
free(sums);
|
||||
if (!ver.ok) {
|
||||
remove(zip_path);
|
||||
show_update_error("Update check failed",
|
||||
"Downloaded file did not match expected checksum.", ver.reason, installed);
|
||||
return 0;
|
||||
}
|
||||
|
||||
char extracted[256];
|
||||
char extracted_launcher[256];
|
||||
snprintf(extracted, sizeof(extracted), "%s/updates/gen1recomp-game.nro.verified", install_dir);
|
||||
snprintf(extracted_launcher, sizeof(extracted_launcher), "%s/updates/gen1recomp.nro.verified",
|
||||
install_dir);
|
||||
|
||||
#if defined(__SWITCH__)
|
||||
ota_ui_show_progress("Step 3/3: Installing...", "Keeping your saves safe.", 0.8f);
|
||||
if (ota_unzip_extract_file(zip_path, GAME_MEMBER_IN_ZIP, extracted, err, sizeof(err)) != 0) {
|
||||
if (ota_unzip_extract_file(zip_path, OTA_GAME_NRO_NAME, extracted, err, sizeof(err)) != 0) {
|
||||
remove(zip_path);
|
||||
show_update_error("Could not extract",
|
||||
"Update zip is missing game files or is corrupted.", err, installed);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (ota_fs_atomic_replace_game(install_dir, extracted, err, sizeof(err)) != 0) {
|
||||
remove(extracted);
|
||||
remove(zip_path);
|
||||
show_update_error("Could not install",
|
||||
"Could not replace game on microSD. Free up space and try again.", err,
|
||||
installed);
|
||||
return 0;
|
||||
}
|
||||
remove(extracted);
|
||||
|
||||
int have_launcher = 0;
|
||||
if (ota_unzip_extract_file(zip_path, LAUNCHER_MEMBER_IN_ZIP, extracted_launcher, err,
|
||||
sizeof(err)) == 0 ||
|
||||
ota_unzip_extract_file(zip_path, OTA_LAUNCHER_NRO_NAME, extracted_launcher, err,
|
||||
sizeof(err)) == 0) {
|
||||
have_launcher = 1;
|
||||
}
|
||||
if (!have_launcher) {
|
||||
remove(zip_path);
|
||||
show_update_error("Could not extract",
|
||||
"Update zip is missing launcher files.", err, installed);
|
||||
return 0;
|
||||
}
|
||||
|
||||
char bootstrap_path[256];
|
||||
bootstrap_path[0] = '\0';
|
||||
if (ota_fs_stage_launcher_bootstrap(install_dir, extracted_launcher, bootstrap_path,
|
||||
sizeof(bootstrap_path), err, sizeof(err)) != 0) {
|
||||
remove(extracted_launcher);
|
||||
remove(zip_path);
|
||||
show_update_error("Could not install",
|
||||
"Game updated but launcher could not be staged. Reinstall from the SD zip.",
|
||||
err, installed);
|
||||
return 0;
|
||||
}
|
||||
remove(extracted_launcher);
|
||||
|
||||
char vpath[192];
|
||||
snprintf(vpath, sizeof(vpath), "%s/version.txt", install_dir);
|
||||
FILE *vf = fopen(vpath, "wb");
|
||||
if (vf) {
|
||||
fprintf(vf, "%s\n", dec.version);
|
||||
fclose(vf);
|
||||
}
|
||||
|
||||
ota_ui_show_progress("Ready", "Finishing update...", 1.0f);
|
||||
svcSleepThread(600000000ULL);
|
||||
remove(zip_path);
|
||||
ota_ui_shutdown();
|
||||
|
||||
if (ota_fs_handoff_to_game(bootstrap_path, err, sizeof(err)) != 0) {
|
||||
show_update_error("Could not install", "Launcher bootstrap failed.", err, installed);
|
||||
return 0;
|
||||
}
|
||||
return 2; /* chainload bootstrap; main must not hand off to game */
|
||||
#else
|
||||
(void)extracted;
|
||||
(void)extracted_launcher;
|
||||
#endif
|
||||
remove(zip_path);
|
||||
ota_ui_shutdown();
|
||||
return 0;
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
(void)argc;
|
||||
(void)argv;
|
||||
|
||||
const char *install = SD_INSTALL_DIR;
|
||||
int update_rc = 0;
|
||||
|
||||
#if defined(__SWITCH__)
|
||||
socketInitializeDefault();
|
||||
padConfigureInput(1, HidNpadStyleSet_NpadStandard);
|
||||
if (ota_net_init() == 0) {
|
||||
update_rc = run_update_flow(install);
|
||||
ota_net_shutdown();
|
||||
}
|
||||
#else
|
||||
update_rc = run_update_flow(install);
|
||||
#endif
|
||||
|
||||
if (update_rc == 2) {
|
||||
#if defined(__SWITCH__)
|
||||
socketExit();
|
||||
#endif
|
||||
return 0;
|
||||
}
|
||||
|
||||
char game[192];
|
||||
snprintf(game, sizeof(game), "%s/%s", install, OTA_GAME_NRO_NAME);
|
||||
char err[128];
|
||||
err[0] = '\0';
|
||||
if (ota_fs_handoff_to_game(game, err, sizeof(err)) != 0) {
|
||||
ota_ui_missing_game();
|
||||
}
|
||||
|
||||
ota_ui_shutdown();
|
||||
|
||||
#if defined(__SWITCH__)
|
||||
socketExit();
|
||||
#endif
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
#include "ota_fs.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#if defined(__SWITCH__)
|
||||
#include <switch.h>
|
||||
#include <mbedtls/sha256.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
#else
|
||||
#include <unistd.h>
|
||||
#endif
|
||||
|
||||
int ota_fs_read_installed_version(const char *install_dir, char *out, size_t out_len) {
|
||||
if (!out || out_len == 0) return -1;
|
||||
out[0] = '\0';
|
||||
char path[256];
|
||||
snprintf(path, sizeof(path), "%s/version.txt", install_dir ? install_dir : OTA_INSTALL_DIR);
|
||||
FILE *fp = fopen(path, "rb");
|
||||
if (!fp) return -1;
|
||||
if (!fgets(out, (int)out_len, fp)) {
|
||||
fclose(fp);
|
||||
return -1;
|
||||
}
|
||||
fclose(fp);
|
||||
/* trim */
|
||||
size_t n = strlen(out);
|
||||
while (n > 0 && (out[n - 1] == '\n' || out[n - 1] == '\r' || out[n - 1] == ' ')) {
|
||||
out[--n] = '\0';
|
||||
}
|
||||
return n > 0 ? 0 : -1;
|
||||
}
|
||||
|
||||
int ota_fs_sha256_file(const char *path, char *out_hex, size_t out_len) {
|
||||
if (!path || !out_hex || out_len < 65) return -1;
|
||||
out_hex[0] = '\0';
|
||||
FILE *fp = fopen(path, "rb");
|
||||
if (!fp) return -1;
|
||||
|
||||
#if defined(__SWITCH__)
|
||||
mbedtls_sha256_context ctx;
|
||||
mbedtls_sha256_init(&ctx);
|
||||
mbedtls_sha256_starts(&ctx, 0);
|
||||
unsigned char buf[8192];
|
||||
size_t n;
|
||||
while ((n = fread(buf, 1, sizeof(buf), fp)) > 0) {
|
||||
mbedtls_sha256_update(&ctx, buf, n);
|
||||
}
|
||||
fclose(fp);
|
||||
unsigned char digest[32];
|
||||
mbedtls_sha256_finish(&ctx, digest);
|
||||
mbedtls_sha256_free(&ctx);
|
||||
for (int i = 0; i < 32; i++) snprintf(out_hex + i * 2, out_len - (size_t)(i * 2), "%02x", digest[i]);
|
||||
return 0;
|
||||
#else
|
||||
/* Host: shell out to shasum/sha256sum for the host test path if needed.
|
||||
* Protocol host tests do not call this; return not-implemented. */
|
||||
fclose(fp);
|
||||
(void)out_len;
|
||||
return -1;
|
||||
#endif
|
||||
}
|
||||
|
||||
static int copy_file(const char *from, const char *to) {
|
||||
FILE *in = fopen(from, "rb");
|
||||
if (!in) return -1;
|
||||
FILE *out = fopen(to, "wb");
|
||||
if (!out) {
|
||||
fclose(in);
|
||||
return -1;
|
||||
}
|
||||
char buf[8192];
|
||||
size_t n;
|
||||
while ((n = fread(buf, 1, sizeof(buf), in)) > 0) {
|
||||
if (fwrite(buf, 1, n, out) != n) {
|
||||
fclose(in);
|
||||
fclose(out);
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
fclose(in);
|
||||
fclose(out);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int ota_fs_atomic_replace_game(const char *install_dir, const char *verified_game_nro,
|
||||
char *err, size_t err_len) {
|
||||
return ota_fs_atomic_replace_nro(install_dir, OTA_GAME_NRO_NAME, verified_game_nro, err,
|
||||
err_len);
|
||||
}
|
||||
|
||||
int ota_fs_atomic_replace_nro(const char *install_dir, const char *nro_name,
|
||||
const char *verified_nro, char *err, size_t err_len) {
|
||||
if (!nro_name || !*nro_name || !verified_nro || !*verified_nro) {
|
||||
if (err && err_len) snprintf(err, err_len, "missing nro paths");
|
||||
return -1;
|
||||
}
|
||||
char dest[240];
|
||||
char part[256];
|
||||
snprintf(dest, sizeof(dest), "%s/%s", install_dir ? install_dir : OTA_INSTALL_DIR, nro_name);
|
||||
snprintf(part, sizeof(part), "%s.part", dest);
|
||||
if (copy_file(verified_nro, part) != 0) {
|
||||
if (err && err_len) snprintf(err, err_len, "copy to .part failed (%s)", nro_name);
|
||||
return -1;
|
||||
}
|
||||
/* sdmc/FAT (Switch) and Windows do not replace an existing dest on rename. */
|
||||
remove(dest);
|
||||
if (rename(part, dest) != 0) {
|
||||
if (err && err_len) snprintf(err, err_len, "rename .part -> %s failed", nro_name);
|
||||
remove(part);
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int ota_fs_stage_launcher_bootstrap(const char *install_dir, const char *verified_launcher,
|
||||
char *bootstrap_out, size_t bootstrap_out_len, char *err,
|
||||
size_t err_len) {
|
||||
if (!verified_launcher || !*verified_launcher) {
|
||||
if (err && err_len) snprintf(err, err_len, "missing staged launcher path");
|
||||
return -1;
|
||||
}
|
||||
if (!bootstrap_out || bootstrap_out_len == 0) {
|
||||
if (err && err_len) snprintf(err, err_len, "missing bootstrap output buffer");
|
||||
return -1;
|
||||
}
|
||||
bootstrap_out[0] = '\0';
|
||||
|
||||
const char *base = install_dir ? install_dir : OTA_INSTALL_DIR;
|
||||
char staged[256];
|
||||
char bootstrap[256];
|
||||
char updates[192];
|
||||
snprintf(staged, sizeof(staged), "%s/%s%s", base, OTA_LAUNCHER_NRO_NAME,
|
||||
OTA_LAUNCHER_STAGED_SUFFIX);
|
||||
snprintf(updates, sizeof(updates), "%s/updates", base);
|
||||
snprintf(bootstrap, sizeof(bootstrap), "%s/updates/%s", base, OTA_BOOTSTRAP_SD_NAME);
|
||||
|
||||
remove(staged);
|
||||
if (copy_file(verified_launcher, staged) != 0) {
|
||||
if (err && err_len) snprintf(err, err_len, "stage launcher failed");
|
||||
return -1;
|
||||
}
|
||||
|
||||
#if defined(__SWITCH__)
|
||||
mkdir(updates, 0755);
|
||||
#endif
|
||||
if (copy_file(OTA_BOOTSTRAP_ROMFS, bootstrap) != 0) {
|
||||
remove(staged);
|
||||
if (err && err_len) snprintf(err, err_len, "extract bootstrap failed");
|
||||
return -1;
|
||||
}
|
||||
|
||||
snprintf(bootstrap_out, bootstrap_out_len, "%s", bootstrap);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int ota_fs_handoff_to_game(const char *game_nro_path, char *err, size_t err_len) {
|
||||
if (!game_nro_path || !*game_nro_path) {
|
||||
if (err && err_len) snprintf(err, err_len, "missing game path");
|
||||
return -1;
|
||||
}
|
||||
#if defined(__SWITCH__)
|
||||
/* argv for next load: empty args string is fine */
|
||||
Result rc = envSetNextLoad(game_nro_path, game_nro_path);
|
||||
if (R_FAILED(rc)) {
|
||||
if (err && err_len) snprintf(err, err_len, "envSetNextLoad failed: 0x%x", rc);
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
#else
|
||||
if (err && err_len)
|
||||
snprintf(err, err_len, "handoff stub (host): would envSetNextLoad %s", game_nro_path);
|
||||
return 0; /* host stub succeeds for flow tests */
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
#include "ota_net.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#if defined(__SWITCH__)
|
||||
#include <curl/curl.h>
|
||||
#include <switch.h>
|
||||
#endif
|
||||
|
||||
#if defined(__SWITCH__)
|
||||
#define OTA_CA_BUNDLE "romfs:/cacert.pem"
|
||||
|
||||
static int g_net_ready = 0;
|
||||
|
||||
static int ota_ca_bundle_ready(void) {
|
||||
FILE *f = fopen(OTA_CA_BUNDLE, "rb");
|
||||
if (!f) return 0;
|
||||
fclose(f);
|
||||
return 1;
|
||||
}
|
||||
|
||||
int ota_net_init(void) {
|
||||
if (g_net_ready) return 0;
|
||||
if (R_FAILED(romfsInit())) return -1;
|
||||
if (!ota_ca_bundle_ready()) return -1;
|
||||
if (curl_global_init(CURL_GLOBAL_DEFAULT) != CURLE_OK) return -1;
|
||||
g_net_ready = 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
void ota_net_shutdown(void) {
|
||||
if (!g_net_ready) return;
|
||||
curl_global_cleanup();
|
||||
romfsExit();
|
||||
g_net_ready = 0;
|
||||
}
|
||||
|
||||
static void ota_net_configure_tls(CURL *curl) {
|
||||
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L);
|
||||
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L);
|
||||
curl_easy_setopt(curl, CURLOPT_CAINFO, OTA_CA_BUNDLE);
|
||||
}
|
||||
|
||||
struct mem_buf {
|
||||
char *data;
|
||||
size_t len;
|
||||
};
|
||||
|
||||
struct file_progress {
|
||||
ota_net_progress_fn fn;
|
||||
void *userdata;
|
||||
};
|
||||
|
||||
static size_t write_mem(char *ptr, size_t size, size_t nmemb, void *userdata) {
|
||||
struct mem_buf *m = (struct mem_buf *)userdata;
|
||||
size_t n = size * nmemb;
|
||||
char *p = (char *)realloc(m->data, m->len + n + 1);
|
||||
if (!p) return 0;
|
||||
m->data = p;
|
||||
memcpy(m->data + m->len, ptr, n);
|
||||
m->len += n;
|
||||
m->data[m->len] = '\0';
|
||||
return n;
|
||||
}
|
||||
|
||||
static size_t write_file(char *ptr, size_t size, size_t nmemb, void *userdata) {
|
||||
return fwrite(ptr, size, nmemb, (FILE *)userdata);
|
||||
}
|
||||
|
||||
static int xfer_progress(void *clientp, curl_off_t dltotal, curl_off_t dlnow, curl_off_t ultotal,
|
||||
curl_off_t ulnow) {
|
||||
(void)ultotal;
|
||||
(void)ulnow;
|
||||
struct file_progress *fp = (struct file_progress *)clientp;
|
||||
if (!fp || !fp->fn) return 0;
|
||||
if (dltotal > 0) {
|
||||
double frac = (double)dlnow / (double)dltotal;
|
||||
if (frac < 0.0) frac = 0.0;
|
||||
if (frac > 1.0) frac = 1.0;
|
||||
fp->fn(fp->userdata, frac);
|
||||
} else if (dlnow > 0) {
|
||||
fp->fn(fp->userdata, -1.0);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
#else
|
||||
|
||||
int ota_net_init(void) { return 0; }
|
||||
void ota_net_shutdown(void) {}
|
||||
|
||||
#endif
|
||||
|
||||
int ota_net_download_buffer(const char *url, long timeout_ms, char **out, size_t *out_len,
|
||||
char *err, size_t err_len) {
|
||||
if (out) *out = NULL;
|
||||
if (out_len) *out_len = 0;
|
||||
if (!url || !out) {
|
||||
if (err && err_len) snprintf(err, err_len, "bad args");
|
||||
return -1;
|
||||
}
|
||||
#if defined(__SWITCH__)
|
||||
CURL *curl = curl_easy_init();
|
||||
if (!curl) {
|
||||
if (err && err_len) snprintf(err, err_len, "curl_easy_init failed");
|
||||
return -1;
|
||||
}
|
||||
struct mem_buf mem = {0};
|
||||
curl_easy_setopt(curl, CURLOPT_URL, url);
|
||||
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
||||
curl_easy_setopt(curl, CURLOPT_USERAGENT, "gen1recomp-switch-ota");
|
||||
curl_easy_setopt(curl, CURLOPT_TIMEOUT_MS, timeout_ms);
|
||||
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT_MS, timeout_ms);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_mem);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &mem);
|
||||
ota_net_configure_tls(curl);
|
||||
CURLcode rc = curl_easy_perform(curl);
|
||||
curl_easy_cleanup(curl);
|
||||
if (rc != CURLE_OK) {
|
||||
free(mem.data);
|
||||
if (err && err_len) snprintf(err, err_len, "%s", curl_easy_strerror(rc));
|
||||
return -1;
|
||||
}
|
||||
*out = mem.data;
|
||||
if (out_len) *out_len = mem.len;
|
||||
return 0;
|
||||
#else
|
||||
(void)timeout_ms;
|
||||
if (err && err_len)
|
||||
snprintf(err, err_len, "ota_net_download_buffer only available on __SWITCH__");
|
||||
return -1;
|
||||
#endif
|
||||
}
|
||||
|
||||
int ota_net_download_file(const char *url, const char *path, long timeout_ms, char *err,
|
||||
size_t err_len, ota_net_progress_fn progress, void *progress_ud) {
|
||||
if (!url || !path) {
|
||||
if (err && err_len) snprintf(err, err_len, "bad args");
|
||||
return -1;
|
||||
}
|
||||
#if defined(__SWITCH__)
|
||||
FILE *fp = fopen(path, "wb");
|
||||
if (!fp) {
|
||||
if (err && err_len) snprintf(err, err_len, "fopen failed: %s", path);
|
||||
return -1;
|
||||
}
|
||||
CURL *curl = curl_easy_init();
|
||||
if (!curl) {
|
||||
fclose(fp);
|
||||
if (err && err_len) snprintf(err, err_len, "curl_easy_init failed");
|
||||
return -1;
|
||||
}
|
||||
struct file_progress fp_cb = {progress, progress_ud};
|
||||
curl_easy_setopt(curl, CURLOPT_URL, url);
|
||||
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
||||
curl_easy_setopt(curl, CURLOPT_USERAGENT, "gen1recomp-switch-ota");
|
||||
curl_easy_setopt(curl, CURLOPT_TIMEOUT_MS, timeout_ms);
|
||||
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT_MS, timeout_ms);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_file);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, fp);
|
||||
ota_net_configure_tls(curl);
|
||||
if (progress) {
|
||||
curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L);
|
||||
curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, xfer_progress);
|
||||
curl_easy_setopt(curl, CURLOPT_XFERINFODATA, &fp_cb);
|
||||
}
|
||||
CURLcode rc = curl_easy_perform(curl);
|
||||
curl_easy_cleanup(curl);
|
||||
fclose(fp);
|
||||
if (rc != CURLE_OK) {
|
||||
if (err && err_len) snprintf(err, err_len, "%s", curl_easy_strerror(rc));
|
||||
return -1;
|
||||
}
|
||||
if (progress) progress(progress_ud, 1.0);
|
||||
return 0;
|
||||
#else
|
||||
(void)timeout_ms;
|
||||
(void)progress;
|
||||
(void)progress_ud;
|
||||
if (err && err_len)
|
||||
snprintf(err, err_len, "ota_net_download_file only available on __SWITCH__");
|
||||
return -1;
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
#include "ota_protocol.h"
|
||||
|
||||
#include <ctype.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
typedef struct {
|
||||
int major;
|
||||
int minor;
|
||||
int patch;
|
||||
int ok;
|
||||
} semver_t;
|
||||
|
||||
static void set_reason(char *buf, size_t n, const char *r) {
|
||||
if (!buf || n == 0) return;
|
||||
snprintf(buf, n, "%s", r ? r : "");
|
||||
}
|
||||
|
||||
static int parse_semver(const char *s, semver_t *out) {
|
||||
memset(out, 0, sizeof(*out));
|
||||
if (!s || !*s) return 0;
|
||||
if (s[0] == 'v' || s[0] == 'V') s++;
|
||||
int maj = 0, min = 0, pat = 0;
|
||||
char trail = 0;
|
||||
if (sscanf(s, "%d.%d.%d%c", &maj, &min, &pat, &trail) != 3) return 0;
|
||||
if (maj < 0 || min < 0 || pat < 0) return 0;
|
||||
out->major = maj;
|
||||
out->minor = min;
|
||||
out->patch = pat;
|
||||
out->ok = 1;
|
||||
return 1;
|
||||
}
|
||||
|
||||
int ota_compare_semver(const char *a, const char *b) {
|
||||
semver_t pa, pb;
|
||||
int oa = parse_semver(a, &pa);
|
||||
int ob = parse_semver(b, &pb);
|
||||
if (!oa && !ob) return 0;
|
||||
if (!oa) return -1;
|
||||
if (!ob) return 1;
|
||||
if (pa.major != pb.major) return pa.major < pb.major ? -1 : 1;
|
||||
if (pa.minor != pb.minor) return pa.minor < pb.minor ? -1 : 1;
|
||||
if (pa.patch != pb.patch) return pa.patch < pb.patch ? -1 : 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int ota_is_ota_asset_name(const char *name) {
|
||||
int maj = 0, min = 0, pat = 0;
|
||||
if (!name) return 0;
|
||||
/* Unified SD/OTA asset: gen1recomp-X.Y.Z-switch.zip */
|
||||
size_t len = strlen(name);
|
||||
const char *suffix = "-switch.zip";
|
||||
size_t slen = strlen(suffix);
|
||||
if (len <= slen) return 0;
|
||||
if (strcmp(name + len - slen, suffix) != 0) return 0;
|
||||
if (strncmp(name, "gen1recomp-", 11) != 0) return 0;
|
||||
if (sscanf(name + 11, "%d.%d.%d", &maj, &min, &pat) != 3) return 0;
|
||||
char rebuilt[128];
|
||||
snprintf(rebuilt, sizeof(rebuilt), "gen1recomp-%d.%d.%d-switch.zip", maj, min, pat);
|
||||
return strcmp(name, rebuilt) == 0;
|
||||
}
|
||||
|
||||
int ota_version_from_ota_asset(const char *name, char *out, size_t out_len) {
|
||||
int maj = 0, min = 0, pat = 0;
|
||||
if (!out || out_len == 0) return 0;
|
||||
out[0] = '\0';
|
||||
if (!ota_is_ota_asset_name(name)) return 0;
|
||||
if (sscanf(name + 11, "%d.%d.%d", &maj, &min, &pat) != 3) return 0;
|
||||
snprintf(out, out_len, "%d.%d.%d", maj, min, pat);
|
||||
return 1;
|
||||
}
|
||||
|
||||
static const char *find_json_string(const char *json, const char *key, char *out, size_t out_len) {
|
||||
char pattern[128];
|
||||
snprintf(pattern, sizeof(pattern), "\"%s\"", key);
|
||||
const char *p = strstr(json, pattern);
|
||||
if (!p) return NULL;
|
||||
p = strchr(p + strlen(pattern), ':');
|
||||
if (!p) return NULL;
|
||||
p++;
|
||||
while (*p && isspace((unsigned char)*p)) p++;
|
||||
if (*p != '"') return NULL;
|
||||
p++;
|
||||
size_t i = 0;
|
||||
while (*p && *p != '"' && i + 1 < out_len) {
|
||||
if (*p == '\\' && p[1]) {
|
||||
p++;
|
||||
out[i++] = *p++;
|
||||
continue;
|
||||
}
|
||||
out[i++] = *p++;
|
||||
}
|
||||
out[i] = '\0';
|
||||
return out;
|
||||
}
|
||||
|
||||
/* Opening { of the JSON object that contains pos (walk backward). */
|
||||
static const char *find_json_object_start(const char *pos, const char *json_start) {
|
||||
if (!pos || !json_start || pos < json_start) return NULL;
|
||||
int depth = 0;
|
||||
const char *p = pos;
|
||||
while (p >= json_start) {
|
||||
if (*p == '}') depth++;
|
||||
else if (*p == '{') {
|
||||
if (depth == 0) return p;
|
||||
depth--;
|
||||
}
|
||||
p--;
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Pointer just past the closing } of the object that starts at object_start. */
|
||||
static const char *find_json_object_end(const char *object_start) {
|
||||
if (!object_start || *object_start != '{') return NULL;
|
||||
int depth = 1;
|
||||
const char *p = object_start + 1;
|
||||
while (*p) {
|
||||
if (*p == '{') depth++;
|
||||
else if (*p == '}') {
|
||||
depth--;
|
||||
if (depth == 0) return p + 1;
|
||||
}
|
||||
p++;
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
int ota_parse_release(const char *json_text, ota_release_t *out) {
|
||||
memset(out, 0, sizeof(*out));
|
||||
if (!json_text || !*json_text) {
|
||||
set_reason(out->reason, sizeof(out->reason), "empty_json");
|
||||
return 0;
|
||||
}
|
||||
if (!find_json_string(json_text, "tag_name", out->tag, sizeof(out->tag))) {
|
||||
set_reason(out->reason, sizeof(out->reason), "missing_tag");
|
||||
return 0;
|
||||
}
|
||||
semver_t sv;
|
||||
if (!parse_semver(out->tag, &sv)) {
|
||||
set_reason(out->reason, sizeof(out->reason), "bad_tag");
|
||||
return 0;
|
||||
}
|
||||
snprintf(out->version, sizeof(out->version), "%d.%d.%d", sv.major, sv.minor, sv.patch);
|
||||
|
||||
/* Scan for OTA asset name then browser_download_url inside the same asset object. */
|
||||
const char *cursor = json_text;
|
||||
while ((cursor = strstr(cursor, "\"name\"")) != NULL) {
|
||||
char name[128];
|
||||
if (!find_json_string(cursor, "name", name, sizeof(name))) {
|
||||
cursor += 6;
|
||||
continue;
|
||||
}
|
||||
if (!ota_is_ota_asset_name(name)) {
|
||||
cursor += 6;
|
||||
continue;
|
||||
}
|
||||
const char *asset_start = find_json_object_start(cursor, json_text);
|
||||
const char *asset_end =
|
||||
asset_start ? find_json_object_end(asset_start) : NULL;
|
||||
if (!asset_start || !asset_end || asset_end <= cursor) {
|
||||
cursor += 6;
|
||||
continue;
|
||||
}
|
||||
char url[512];
|
||||
const char *u = NULL;
|
||||
const char *scan = cursor;
|
||||
while (scan < asset_end &&
|
||||
(scan = strstr(scan, "\"browser_download_url\"")) != NULL && scan < asset_end) {
|
||||
if (find_json_string(scan, "browser_download_url", url, sizeof(url))) {
|
||||
u = url;
|
||||
break;
|
||||
}
|
||||
scan += 21;
|
||||
}
|
||||
if (!u || !*u) {
|
||||
cursor += 6;
|
||||
continue;
|
||||
}
|
||||
snprintf(out->asset_name, sizeof(out->asset_name), "%s", name);
|
||||
snprintf(out->download_url, sizeof(out->download_url), "%s", u);
|
||||
out->ok = 1;
|
||||
return 1;
|
||||
}
|
||||
set_reason(out->reason, sizeof(out->reason), "missing_ota_asset");
|
||||
return 0;
|
||||
}
|
||||
|
||||
void ota_decide_update(const char *installed_version, const ota_release_t *release,
|
||||
ota_decision_t *out) {
|
||||
memset(out, 0, sizeof(*out));
|
||||
semver_t inst;
|
||||
if (!parse_semver(installed_version, &inst)) {
|
||||
snprintf(out->status, sizeof(out->status), "error");
|
||||
set_reason(out->reason, sizeof(out->reason), "bad_installed_version");
|
||||
return;
|
||||
}
|
||||
if (!release || !release->ok) {
|
||||
snprintf(out->status, sizeof(out->status), "error");
|
||||
set_reason(out->reason, sizeof(out->reason), "bad_release");
|
||||
return;
|
||||
}
|
||||
if (ota_compare_semver(release->version, installed_version) <= 0) {
|
||||
snprintf(out->status, sizeof(out->status), "uptodate");
|
||||
snprintf(out->version, sizeof(out->version), "%s", installed_version);
|
||||
return;
|
||||
}
|
||||
snprintf(out->status, sizeof(out->status), "available");
|
||||
snprintf(out->version, sizeof(out->version), "%s", release->version);
|
||||
snprintf(out->asset_name, sizeof(out->asset_name), "%s", release->asset_name);
|
||||
snprintf(out->download_url, sizeof(out->download_url), "%s", release->download_url);
|
||||
}
|
||||
|
||||
int ota_lookup_sum(const char *sums_text, const char *asset_name, char *out_hex, size_t out_len) {
|
||||
if (out_hex && out_len) out_hex[0] = '\0';
|
||||
if (!sums_text || !asset_name || !out_hex || out_len < 65) return 0;
|
||||
const char *p = sums_text;
|
||||
while (*p) {
|
||||
char hex[96];
|
||||
char name[256];
|
||||
int n = 0;
|
||||
if (sscanf(p, "%95s %255s%n", hex, name, &n) >= 2 && n > 0) {
|
||||
const char *nm = name;
|
||||
if (nm[0] == '*') nm++;
|
||||
if (strncmp(nm, "./", 2) == 0) nm += 2;
|
||||
if (strcmp(nm, asset_name) == 0) {
|
||||
/* normalize hex to lowercase */
|
||||
size_t i;
|
||||
for (i = 0; hex[i] && i + 1 < out_len; i++)
|
||||
out_hex[i] = (char)tolower((unsigned char)hex[i]);
|
||||
out_hex[i] = '\0';
|
||||
return 1;
|
||||
}
|
||||
p += n;
|
||||
while (*p && *p != '\n') p++;
|
||||
if (*p == '\n') p++;
|
||||
continue;
|
||||
}
|
||||
while (*p && *p != '\n') p++;
|
||||
if (*p == '\n') p++;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
void ota_verify_sha256(const char *asset_name, const char *actual_hex, const char *sums_text,
|
||||
ota_verify_t *out) {
|
||||
memset(out, 0, sizeof(*out));
|
||||
if (!asset_name || !*asset_name) {
|
||||
set_reason(out->reason, sizeof(out->reason), "bad_asset_name");
|
||||
return;
|
||||
}
|
||||
if (!sums_text) {
|
||||
set_reason(out->reason, sizeof(out->reason), "missing_sums");
|
||||
return;
|
||||
}
|
||||
char expected[96];
|
||||
if (!ota_lookup_sum(sums_text, asset_name, expected, sizeof(expected))) {
|
||||
set_reason(out->reason, sizeof(out->reason), "sum_not_found");
|
||||
return;
|
||||
}
|
||||
if (!actual_hex || !*actual_hex) {
|
||||
set_reason(out->reason, sizeof(out->reason), "missing_actual_hash");
|
||||
return;
|
||||
}
|
||||
char actual[96];
|
||||
size_t i;
|
||||
for (i = 0; actual_hex[i] && i + 1 < sizeof(actual); i++)
|
||||
actual[i] = (char)tolower((unsigned char)actual_hex[i]);
|
||||
actual[i] = '\0';
|
||||
if (strcmp(actual, expected) != 0) {
|
||||
set_reason(out->reason, sizeof(out->reason), "hash_mismatch");
|
||||
return;
|
||||
}
|
||||
out->ok = 1;
|
||||
}
|
||||
|
||||
void ota_plan_atomic_apply(const char *install_dir, const char *verified_temp,
|
||||
ota_apply_plan_t *out) {
|
||||
memset(out, 0, sizeof(*out));
|
||||
if (!install_dir || !*install_dir) install_dir = OTA_INSTALL_DIR;
|
||||
if (!verified_temp) verified_temp = "";
|
||||
snprintf(out->game_nro, sizeof(out->game_nro), "%s/%s", install_dir, OTA_GAME_NRO_NAME);
|
||||
snprintf(out->part_path, sizeof(out->part_path), "%s.part", out->game_nro);
|
||||
snprintf(out->launcher_nro, sizeof(out->launcher_nro), "%s/%s", install_dir,
|
||||
OTA_LAUNCHER_NRO_NAME);
|
||||
snprintf(out->launcher_part, sizeof(out->launcher_part), "%s.part", out->launcher_nro);
|
||||
snprintf(out->next_load, sizeof(out->next_load), "%s", out->game_nro);
|
||||
/* verified_temp is the game NRO; launcher replace uses a sibling verified path */
|
||||
snprintf(out->steps[0], sizeof(out->steps[0]), "copy_to_part:%s->%s", verified_temp,
|
||||
out->part_path);
|
||||
snprintf(out->steps[1], sizeof(out->steps[1]), "rename:%s->%s", out->part_path, out->game_nro);
|
||||
snprintf(out->steps[2], sizeof(out->steps[2]), "copy_to_part:launcher->%s", out->launcher_part);
|
||||
snprintf(out->steps[3], sizeof(out->steps[3]), "rename:%s->%s", out->launcher_part,
|
||||
out->launcher_nro);
|
||||
snprintf(out->steps[4], sizeof(out->steps[4]), "env_set_next_load:%s", out->game_nro);
|
||||
snprintf(out->preserve, sizeof(out->preserve), "%s/%s", install_dir, OTA_SAVE_DIR_NAME);
|
||||
snprintf(out->forbidden_delete, sizeof(out->forbidden_delete), "delete:%s/%s", install_dir,
|
||||
OTA_SAVE_DIR_NAME);
|
||||
snprintf(out->forbidden_direct, sizeof(out->forbidden_direct), "write_direct:%s", out->game_nro);
|
||||
}
|
||||
|
||||
void ota_offline_policy(double elapsed_sec, const ota_offline_events_t *events, ota_offline_t *out) {
|
||||
memset(out, 0, sizeof(*out));
|
||||
ota_offline_events_t ev = {0, -1, 0};
|
||||
if (events) ev = *events;
|
||||
if (ev.user_skip) {
|
||||
snprintf(out->action, sizeof(out->action), "play_installed");
|
||||
set_reason(out->reason, sizeof(out->reason), "user_skip");
|
||||
snprintf(out->message, sizeof(out->message), "update skipped");
|
||||
return;
|
||||
}
|
||||
if (ev.api_error || ev.network_ok == 0) {
|
||||
snprintf(out->action, sizeof(out->action), "play_installed");
|
||||
set_reason(out->reason, sizeof(out->reason), "offline_or_error");
|
||||
snprintf(out->message, sizeof(out->message),
|
||||
"offline or update check failed — play installed version");
|
||||
return;
|
||||
}
|
||||
if (elapsed_sec >= (double)OTA_CHECK_TIMEOUT_SEC) {
|
||||
snprintf(out->action, sizeof(out->action), "play_installed");
|
||||
set_reason(out->reason, sizeof(out->reason), "timeout");
|
||||
snprintf(out->message, sizeof(out->message), "update check timed out after %ds",
|
||||
OTA_CHECK_TIMEOUT_SEC);
|
||||
return;
|
||||
}
|
||||
snprintf(out->action, sizeof(out->action), "keep_checking");
|
||||
set_reason(out->reason, sizeof(out->reason), "in_flight");
|
||||
}
|
||||
@@ -0,0 +1,595 @@
|
||||
/*
|
||||
* Switch OTA UI — matches in-game launcher language (Theme.lua):
|
||||
* black field, RGB version rail, flat yellow/white buttons, white ink.
|
||||
* Quiet until called. Uses framebuffer + 8x8 font + optional romfs logo.
|
||||
*/
|
||||
#include "ota_ui.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#if defined(__SWITCH__)
|
||||
#include <switch.h>
|
||||
|
||||
#include "../third_party/font8x8_basic.h"
|
||||
|
||||
#define OTA_LOGO_ROMFS "romfs:/logo.rgba"
|
||||
|
||||
#define FB_W 1280
|
||||
#define FB_H 720
|
||||
|
||||
#define TITLE_Y 200
|
||||
#define LINE_TITLE 44
|
||||
#define LINE_BODY 36
|
||||
#define LINE_SMALL 28
|
||||
#define WRAP_MAX_PX 720
|
||||
|
||||
/* Theme.PAL (0-255) -> RGBA8 */
|
||||
#define COL_BG RGBA8_MAXALPHA(0, 0, 0)
|
||||
#define COL_INK RGBA8_MAXALPHA(255, 255, 255)
|
||||
#define COL_DETAIL RGBA8_MAXALPHA(200, 200, 200)
|
||||
#define COL_MUTED RGBA8_MAXALPHA(150, 150, 150)
|
||||
#define COL_INVERSE RGBA8_MAXALPHA(0, 0, 0)
|
||||
#define COL_YELLOW RGBA8_MAXALPHA(255, 214, 0)
|
||||
#define COL_GREEN RGBA8_MAXALPHA(0, 255, 140)
|
||||
#define COL_RAIL_R RGBA8_MAXALPHA(255, 60, 72)
|
||||
#define COL_RAIL_B RGBA8_MAXALPHA(70, 150, 255)
|
||||
#define COL_RAIL_G RGBA8_MAXALPHA(255, 203, 5)
|
||||
#define COL_LINE RGBA8_MAXALPHA(90, 90, 90)
|
||||
#define COL_RAISED RGBA8_MAXALPHA(20, 20, 20)
|
||||
|
||||
static int g_ready = 0;
|
||||
static Framebuffer g_fb;
|
||||
static PadState g_pad;
|
||||
static u32 *g_logo = NULL;
|
||||
static int g_logo_w = 0, g_logo_h = 0;
|
||||
|
||||
static void fill_rect(u32 *fb, u32 stride_px, int x, int y, int w, int h, u32 color) {
|
||||
if (w <= 0 || h <= 0) return;
|
||||
if (x < 0) {
|
||||
w += x;
|
||||
x = 0;
|
||||
}
|
||||
if (y < 0) {
|
||||
h += y;
|
||||
y = 0;
|
||||
}
|
||||
if (x + w > FB_W) w = FB_W - x;
|
||||
if (y + h > FB_H) h = FB_H - y;
|
||||
if (w <= 0 || h <= 0) return;
|
||||
for (int row = 0; row < h; row++) {
|
||||
u32 *line = fb + (y + row) * stride_px + x;
|
||||
for (int col = 0; col < w; col++) line[col] = color;
|
||||
}
|
||||
}
|
||||
|
||||
/* Normalize UTF-8 punctuation to ASCII; drop other non-ASCII bytes. */
|
||||
static size_t ota_ui_sanitize_ascii(const char *src, char *dst, size_t dst_len) {
|
||||
if (!dst || dst_len == 0) return 0;
|
||||
dst[0] = '\0';
|
||||
if (!src) return 0;
|
||||
size_t w = 0;
|
||||
for (size_t i = 0; src[i] && w + 1 < dst_len;) {
|
||||
unsigned char c = (unsigned char)src[i];
|
||||
if (c < 0x80) {
|
||||
dst[w++] = (char)c;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (c == 0xE2 && src[i + 1] && src[i + 2]) {
|
||||
unsigned char b2 = (unsigned char)src[i + 1];
|
||||
unsigned char b3 = (unsigned char)src[i + 2];
|
||||
if (b2 == 0x80 && b3 == 0xA6) {
|
||||
if (w + 3 < dst_len) {
|
||||
dst[w++] = '.';
|
||||
dst[w++] = '.';
|
||||
dst[w++] = '.';
|
||||
}
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
if (b2 == 0x80 && (b3 == 0x94 || b3 == 0x93)) {
|
||||
dst[w++] = '-';
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
if (b2 == 0x80 && (b3 == 0x98 || b3 == 0x99)) {
|
||||
dst[w++] = '\'';
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ((c & 0xE0) == 0xC0) i += 2;
|
||||
else if ((c & 0xF0) == 0xE0) i += 3;
|
||||
else if ((c & 0xF8) == 0xF0) i += 4;
|
||||
else i++;
|
||||
}
|
||||
dst[w] = '\0';
|
||||
return w;
|
||||
}
|
||||
|
||||
static void draw_glyph(u32 *fb, u32 stride_px, int x, int y, char ch, u32 color, int scale) {
|
||||
unsigned char c = (unsigned char)ch;
|
||||
if (c > 127) return;
|
||||
const char *bits = font8x8_basic[c];
|
||||
for (int row = 0; row < 8; row++) {
|
||||
unsigned char line = (unsigned char)bits[row];
|
||||
for (int col = 0; col < 8; col++) {
|
||||
if (line & (1 << col)) {
|
||||
fill_rect(fb, stride_px, x + col * scale, y + row * scale, scale, scale, color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static int text_width(const char *s, int scale) {
|
||||
if (!s) return 0;
|
||||
return (int)strlen(s) * 8 * scale;
|
||||
}
|
||||
|
||||
static void draw_text(u32 *fb, u32 stride_px, int x, int y, const char *s, u32 color, int scale) {
|
||||
if (!s) return;
|
||||
char buf[512];
|
||||
ota_ui_sanitize_ascii(s, buf, sizeof(buf));
|
||||
int cx = x;
|
||||
for (const char *p = buf; *p; p++) {
|
||||
if (*p == '\n') {
|
||||
cx = x;
|
||||
y += 8 * scale + 4;
|
||||
continue;
|
||||
}
|
||||
draw_glyph(fb, stride_px, cx, y, *p, color, scale);
|
||||
cx += 8 * scale;
|
||||
}
|
||||
}
|
||||
|
||||
static void draw_text_centered(u32 *fb, u32 stride_px, int y, const char *s, u32 color, int scale) {
|
||||
char buf[512];
|
||||
ota_ui_sanitize_ascii(s, buf, sizeof(buf));
|
||||
int w = text_width(buf, scale);
|
||||
draw_text(fb, stride_px, (FB_W - w) / 2, y, buf, color, scale);
|
||||
}
|
||||
|
||||
static int draw_text_wrapped_centered(u32 *fb, u32 stride_px, int y, const char *s, u32 color,
|
||||
int scale, int max_px) {
|
||||
if (!s || !*s) return y;
|
||||
char buf[512];
|
||||
ota_ui_sanitize_ascii(s, buf, sizeof(buf));
|
||||
int char_w = 8 * scale;
|
||||
int max_chars = max_px / char_w;
|
||||
if (max_chars < 8) max_chars = 8;
|
||||
|
||||
char line[128];
|
||||
int line_len = 0;
|
||||
const char *word = buf;
|
||||
int lines = 0;
|
||||
|
||||
while (*word) {
|
||||
while (*word == ' ') word++;
|
||||
if (!*word) break;
|
||||
|
||||
const char *end = word;
|
||||
while (*end && *end != ' ' && *end != '\n') end++;
|
||||
int wlen = (int)(end - word);
|
||||
|
||||
if (line_len > 0 && line_len + 1 + wlen > max_chars) {
|
||||
line[line_len] = '\0';
|
||||
draw_text_centered(fb, stride_px, y, line, color, scale);
|
||||
y += 8 * scale + 4;
|
||||
lines++;
|
||||
line_len = 0;
|
||||
}
|
||||
|
||||
if (wlen > max_chars) {
|
||||
if (line_len > 0) {
|
||||
line[line_len] = '\0';
|
||||
draw_text_centered(fb, stride_px, y, line, color, scale);
|
||||
y += 8 * scale + 4;
|
||||
lines++;
|
||||
line_len = 0;
|
||||
}
|
||||
while (wlen > 0) {
|
||||
int chunk = wlen > max_chars ? max_chars : wlen;
|
||||
memcpy(line, word, (size_t)chunk);
|
||||
line[chunk] = '\0';
|
||||
draw_text_centered(fb, stride_px, y, line, color, scale);
|
||||
y += 8 * scale + 4;
|
||||
lines++;
|
||||
word += chunk;
|
||||
wlen -= chunk;
|
||||
}
|
||||
word = end;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line_len > 0) line[line_len++] = ' ';
|
||||
memcpy(line + line_len, word, (size_t)wlen);
|
||||
line_len += wlen;
|
||||
line[line_len] = '\0';
|
||||
word = (*end == '\n') ? end + 1 : end;
|
||||
if (*(end - 1) == '\n' || *end == '\n') {
|
||||
draw_text_centered(fb, stride_px, y, line, color, scale);
|
||||
y += 8 * scale + 4;
|
||||
lines++;
|
||||
line_len = 0;
|
||||
if (*end == '\n') word = end + 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (line_len > 0) {
|
||||
draw_text_centered(fb, stride_px, y, line, color, scale);
|
||||
y += 8 * scale + 4;
|
||||
lines++;
|
||||
}
|
||||
if (lines == 0) return y;
|
||||
return y;
|
||||
}
|
||||
|
||||
static void draw_rail(u32 *fb, u32 stride_px) {
|
||||
int h = 6;
|
||||
int third = FB_W / 3;
|
||||
fill_rect(fb, stride_px, 0, 0, third, h, COL_RAIL_R);
|
||||
fill_rect(fb, stride_px, third, 0, third, h, COL_RAIL_B);
|
||||
fill_rect(fb, stride_px, third * 2, 0, FB_W - third * 2, h, COL_RAIL_G);
|
||||
}
|
||||
|
||||
static void blit_logo(u32 *fb, u32 stride_px, int dst_x, int dst_y, int max_w) {
|
||||
if (!g_logo || g_logo_w <= 0 || g_logo_h <= 0) return;
|
||||
int dw = g_logo_w;
|
||||
int dh = g_logo_h;
|
||||
if (dw > max_w) {
|
||||
dh = dh * max_w / dw;
|
||||
dw = max_w;
|
||||
}
|
||||
for (int y = 0; y < dh; y++) {
|
||||
int sy = y * g_logo_h / dh;
|
||||
for (int x = 0; x < dw; x++) {
|
||||
int sx = x * g_logo_w / dw;
|
||||
u32 px = g_logo[sy * g_logo_w + sx];
|
||||
u8 a = (px >> 24) & 0xff;
|
||||
if (a < 16) continue;
|
||||
int dx = dst_x + x;
|
||||
int dy = dst_y + y;
|
||||
if (dx < 0 || dy < 0 || dx >= FB_W || dy >= FB_H) continue;
|
||||
fb[dy * stride_px + dx] = px | 0xff000000u;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void load_logo(void) {
|
||||
if (g_logo) return;
|
||||
FILE *fp = fopen(OTA_LOGO_ROMFS, "rb");
|
||||
if (!fp) return;
|
||||
|
||||
unsigned char header[8];
|
||||
if (fread(header, 1, sizeof(header), fp) != sizeof(header)) {
|
||||
fclose(fp);
|
||||
return;
|
||||
}
|
||||
|
||||
int w = (int)(header[0] | (header[1] << 8) | (header[2] << 16) | (header[3] << 24));
|
||||
int h = (int)(header[4] | (header[5] << 8) | (header[6] << 16) | (header[7] << 24));
|
||||
if (w <= 0 || h <= 0 || w > 2048 || h > 2048) {
|
||||
fclose(fp);
|
||||
return;
|
||||
}
|
||||
|
||||
size_t nbytes = (size_t)w * (size_t)h * 4u;
|
||||
unsigned char *data = (unsigned char *)malloc(nbytes);
|
||||
if (!data) {
|
||||
fclose(fp);
|
||||
return;
|
||||
}
|
||||
if (fread(data, 1, nbytes, fp) != nbytes) {
|
||||
free(data);
|
||||
fclose(fp);
|
||||
return;
|
||||
}
|
||||
fclose(fp);
|
||||
|
||||
g_logo = (u32 *)malloc((size_t)w * (size_t)h * sizeof(u32));
|
||||
if (!g_logo) {
|
||||
free(data);
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < w * h; i++) {
|
||||
unsigned char *p = data + (size_t)i * 4u;
|
||||
g_logo[i] = RGBA8(p[0], p[1], p[2], p[3]);
|
||||
}
|
||||
free(data);
|
||||
g_logo_w = w;
|
||||
g_logo_h = h;
|
||||
}
|
||||
|
||||
static int ui_ensure(void) {
|
||||
if (g_ready) return 1;
|
||||
NWindow *win = nwindowGetDefault();
|
||||
if (R_FAILED(framebufferCreate(&g_fb, win, FB_W, FB_H, PIXEL_FORMAT_RGBA_8888, 2))) return 0;
|
||||
framebufferMakeLinear(&g_fb);
|
||||
padInitializeDefault(&g_pad);
|
||||
load_logo();
|
||||
g_ready = 1;
|
||||
return 1;
|
||||
}
|
||||
|
||||
void ota_ui_shutdown(void) {
|
||||
if (!g_ready) return;
|
||||
framebufferClose(&g_fb);
|
||||
free(g_logo);
|
||||
g_logo = NULL;
|
||||
g_logo_w = g_logo_h = 0;
|
||||
g_ready = 0;
|
||||
}
|
||||
|
||||
static void draw_button(u32 *fb, u32 stride_px, int x, int y, int w, int h, u32 fill, u32 ink,
|
||||
const char *label, int focused) {
|
||||
fill_rect(fb, stride_px, x, y, w, h, fill);
|
||||
if (focused) {
|
||||
fill_rect(fb, stride_px, x - 3, y - 3, w + 6, 2, COL_INK);
|
||||
fill_rect(fb, stride_px, x - 3, y + h + 1, w + 6, 2, COL_INK);
|
||||
fill_rect(fb, stride_px, x - 3, y - 3, 2, h + 6, COL_INK);
|
||||
fill_rect(fb, stride_px, x + w + 1, y - 3, 2, h + 6, COL_INK);
|
||||
} else {
|
||||
fill_rect(fb, stride_px, x, y, w, 1, COL_LINE);
|
||||
fill_rect(fb, stride_px, x, y + h - 1, w, 1, COL_LINE);
|
||||
fill_rect(fb, stride_px, x, y, 1, h, COL_LINE);
|
||||
fill_rect(fb, stride_px, x + w - 1, y, 1, h, COL_LINE);
|
||||
}
|
||||
int scale = 3;
|
||||
char lbl[128];
|
||||
ota_ui_sanitize_ascii(label, lbl, sizeof(lbl));
|
||||
int max_tw = w - 24;
|
||||
int tw = text_width(lbl, scale);
|
||||
while (scale > 2 && tw > max_tw) {
|
||||
scale--;
|
||||
tw = text_width(lbl, scale);
|
||||
}
|
||||
int tx = x + (w - tw) / 2;
|
||||
int ty = y + (h - 8 * scale) / 2;
|
||||
draw_text(fb, stride_px, tx, ty, lbl, ink, scale);
|
||||
}
|
||||
|
||||
static void draw_chrome(u32 *fb, u32 stride_px) {
|
||||
fill_rect(fb, stride_px, 0, 0, FB_W, FB_H, COL_BG);
|
||||
draw_rail(fb, stride_px);
|
||||
int logo_max = 320;
|
||||
int logo_h = g_logo ? (g_logo_h * logo_max / g_logo_w) : 40;
|
||||
if (logo_h > 90) logo_h = 90;
|
||||
int logo_y = 48;
|
||||
if (g_logo) {
|
||||
int logo_w = logo_max;
|
||||
if (g_logo_w * logo_h / g_logo_h < logo_max) logo_w = g_logo_w * logo_h / g_logo_h;
|
||||
blit_logo(fb, stride_px, (FB_W - logo_w) / 2, logo_y, logo_max);
|
||||
} else {
|
||||
draw_text_centered(fb, stride_px, logo_y + 16, "gen1recomp", COL_INK, 4);
|
||||
}
|
||||
}
|
||||
|
||||
static void present(void (*paint)(u32 *fb, u32 stride_px, void *ctx), void *ctx) {
|
||||
if (!ui_ensure()) return;
|
||||
u32 stride = 0;
|
||||
u32 *fb = (u32 *)framebufferBegin(&g_fb, &stride);
|
||||
u32 stride_px = stride / sizeof(u32);
|
||||
paint(fb, stride_px, ctx);
|
||||
framebufferEnd(&g_fb);
|
||||
}
|
||||
|
||||
typedef struct {
|
||||
const char *installed;
|
||||
const char *latest;
|
||||
int focus; /* 0 = Update, 1 = Play */
|
||||
} prompt_ctx_t;
|
||||
|
||||
static void paint_prompt(u32 *fb, u32 stride_px, void *ctx) {
|
||||
prompt_ctx_t *p = (prompt_ctx_t *)ctx;
|
||||
draw_chrome(fb, stride_px);
|
||||
|
||||
int y = TITLE_Y - 20;
|
||||
draw_text_centered(fb, stride_px, y, "Update available", COL_INK, 3);
|
||||
y += LINE_TITLE;
|
||||
|
||||
char line[96];
|
||||
snprintf(line, sizeof(line), "v%s to v%s", p->installed ? p->installed : "?",
|
||||
p->latest ? p->latest : "?");
|
||||
draw_text_centered(fb, stride_px, y, line, COL_YELLOW, 3);
|
||||
y += LINE_TITLE + 8;
|
||||
|
||||
draw_text_centered(fb, stride_px, y, "Your saves stay on this console.", COL_MUTED, 2);
|
||||
y += LINE_BODY + 28;
|
||||
|
||||
int bw = 600;
|
||||
int bh = 64;
|
||||
int bx = (FB_W - bw) / 2;
|
||||
draw_button(fb, stride_px, bx, y, bw, bh, COL_YELLOW, COL_INVERSE, "(A) Update", p->focus == 0);
|
||||
y += bh + 24;
|
||||
draw_button(fb, stride_px, bx, y, bw, bh, COL_INK, COL_INVERSE, "(B) Play without update",
|
||||
p->focus == 1);
|
||||
}
|
||||
|
||||
int ota_ui_prompt_update(const char *installed, const char *latest) {
|
||||
if (!ui_ensure()) return 0;
|
||||
prompt_ctx_t ctx = {installed, latest, 0};
|
||||
while (appletMainLoop()) {
|
||||
present(paint_prompt, &ctx);
|
||||
padUpdate(&g_pad);
|
||||
u64 k = padGetButtonsDown(&g_pad);
|
||||
if (k & HidNpadButton_A) {
|
||||
int do_update = (ctx.focus == 0);
|
||||
if (!do_update) ota_ui_shutdown();
|
||||
return do_update;
|
||||
}
|
||||
if (k & HidNpadButton_B) {
|
||||
ota_ui_shutdown();
|
||||
return 0;
|
||||
}
|
||||
if (k & (HidNpadButton_Up | HidNpadButton_Down | HidNpadButton_Left | HidNpadButton_Right)) {
|
||||
ctx.focus = 1 - ctx.focus;
|
||||
}
|
||||
svcSleepThread(16000000ULL);
|
||||
}
|
||||
ota_ui_shutdown();
|
||||
return 0;
|
||||
}
|
||||
|
||||
typedef struct {
|
||||
const char *title;
|
||||
const char *detail;
|
||||
float progress;
|
||||
int show_bar;
|
||||
} status_ctx_t;
|
||||
|
||||
static void paint_progress_bar(u32 *fb, u32 stride_px, int y, float t) {
|
||||
int bw = 520;
|
||||
int bh = 18;
|
||||
int bx = (FB_W - bw) / 2;
|
||||
if (t < 0.f) t = 0.f;
|
||||
if (t > 1.f) t = 1.f;
|
||||
fill_rect(fb, stride_px, bx, y, bw, bh, COL_RAISED);
|
||||
fill_rect(fb, stride_px, bx, y, bw, 1, COL_LINE);
|
||||
fill_rect(fb, stride_px, bx, y + bh - 1, bw, 1, COL_LINE);
|
||||
fill_rect(fb, stride_px, bx, y, 1, bh, COL_LINE);
|
||||
fill_rect(fb, stride_px, bx + bw - 1, y, 1, bh, COL_LINE);
|
||||
int fw = (int)(bw * t);
|
||||
if (fw > 0) fill_rect(fb, stride_px, bx, y, fw, bh, COL_GREEN);
|
||||
}
|
||||
|
||||
static void paint_status(u32 *fb, u32 stride_px, void *ctx) {
|
||||
status_ctx_t *s = (status_ctx_t *)ctx;
|
||||
draw_chrome(fb, stride_px);
|
||||
int y = TITLE_Y;
|
||||
draw_text_centered(fb, stride_px, y, s->title ? s->title : "", COL_INK, 3);
|
||||
y += LINE_TITLE;
|
||||
if (s->detail && s->detail[0]) {
|
||||
y = draw_text_wrapped_centered(fb, stride_px, y, s->detail, COL_DETAIL, 2, WRAP_MAX_PX);
|
||||
y += LINE_SMALL;
|
||||
}
|
||||
if (s->show_bar) paint_progress_bar(fb, stride_px, y, s->progress);
|
||||
}
|
||||
|
||||
void ota_ui_show_status(const char *title, const char *detail) {
|
||||
status_ctx_t s = {title, detail, 0.f, 0};
|
||||
present(paint_status, &s);
|
||||
}
|
||||
|
||||
void ota_ui_show_progress(const char *title, const char *detail, float progress01) {
|
||||
status_ctx_t s = {title, detail, progress01, 1};
|
||||
present(paint_status, &s);
|
||||
}
|
||||
|
||||
typedef struct {
|
||||
const char *title;
|
||||
const char *line1;
|
||||
const char *line2;
|
||||
const char *line3;
|
||||
} alert_ctx_t;
|
||||
|
||||
static void paint_alert(u32 *fb, u32 stride_px, void *ctx) {
|
||||
alert_ctx_t *a = (alert_ctx_t *)ctx;
|
||||
draw_chrome(fb, stride_px);
|
||||
int y = TITLE_Y;
|
||||
draw_text_centered(fb, stride_px, y, a->title ? a->title : "", COL_YELLOW, 3);
|
||||
y += LINE_TITLE;
|
||||
if (a->line1 && a->line1[0])
|
||||
y = draw_text_wrapped_centered(fb, stride_px, y, a->line1, COL_DETAIL, 2, WRAP_MAX_PX);
|
||||
y += LINE_SMALL;
|
||||
if (a->line2 && a->line2[0])
|
||||
y = draw_text_wrapped_centered(fb, stride_px, y, a->line2, COL_MUTED, 2, WRAP_MAX_PX);
|
||||
y += LINE_SMALL;
|
||||
if (a->line3 && a->line3[0])
|
||||
y = draw_text_wrapped_centered(fb, stride_px, y, a->line3, COL_MUTED, 2, WRAP_MAX_PX);
|
||||
y += LINE_BODY + 16;
|
||||
int bw = 360;
|
||||
int bh = 56;
|
||||
draw_button(fb, stride_px, (FB_W - bw) / 2, y, bw, bh, COL_INK, COL_INVERSE, "(B) Continue", 1);
|
||||
}
|
||||
|
||||
void ota_ui_alert(const char *title, const char *line1, const char *line2) {
|
||||
if (!ui_ensure()) return;
|
||||
alert_ctx_t a = {title, line1, line2, NULL};
|
||||
while (appletMainLoop()) {
|
||||
present(paint_alert, &a);
|
||||
padUpdate(&g_pad);
|
||||
if (padGetButtonsDown(&g_pad) & HidNpadButton_B) break;
|
||||
svcSleepThread(16000000ULL);
|
||||
}
|
||||
ota_ui_shutdown();
|
||||
}
|
||||
|
||||
void ota_ui_alert_error(const char *title, const char *friendly, const char *technical,
|
||||
const char *installed_version) {
|
||||
if (!ui_ensure()) return;
|
||||
char footer[96];
|
||||
snprintf(footer, sizeof(footer), "Playing installed version (v%s).",
|
||||
installed_version && installed_version[0] ? installed_version : "?");
|
||||
char tech[256];
|
||||
if (technical && technical[0]) {
|
||||
ota_ui_sanitize_ascii(technical, tech, sizeof(tech));
|
||||
if (strlen(tech) > 80) tech[80] = '\0';
|
||||
} else {
|
||||
tech[0] = '\0';
|
||||
}
|
||||
alert_ctx_t a = {title, friendly, tech[0] ? tech : NULL, footer};
|
||||
while (appletMainLoop()) {
|
||||
present(paint_alert, &a);
|
||||
padUpdate(&g_pad);
|
||||
if (padGetButtonsDown(&g_pad) & HidNpadButton_B) break;
|
||||
svcSleepThread(16000000ULL);
|
||||
}
|
||||
ota_ui_shutdown();
|
||||
}
|
||||
|
||||
static void paint_missing(u32 *fb, u32 stride_px, void *ctx) {
|
||||
(void)ctx;
|
||||
draw_chrome(fb, stride_px);
|
||||
int y = TITLE_Y - 10;
|
||||
draw_text_centered(fb, stride_px, y, "Game files missing", COL_YELLOW, 3);
|
||||
y += LINE_TITLE;
|
||||
y = draw_text_wrapped_centered(fb, stride_px, y,
|
||||
"Copy the Switch zip onto your microSD, then open gen1recomp again.",
|
||||
COL_DETAIL, 2, WRAP_MAX_PX);
|
||||
y += LINE_BODY + 16;
|
||||
int bw = 320;
|
||||
int bh = 56;
|
||||
draw_button(fb, stride_px, (FB_W - bw) / 2, y, bw, bh, COL_INK, COL_INVERSE, "(+) Exit", 1);
|
||||
}
|
||||
|
||||
void ota_ui_missing_game(void) {
|
||||
if (!ui_ensure()) return;
|
||||
while (appletMainLoop()) {
|
||||
present(paint_missing, NULL);
|
||||
padUpdate(&g_pad);
|
||||
if (padGetButtonsDown(&g_pad) & HidNpadButton_Plus) break;
|
||||
svcSleepThread(16000000ULL);
|
||||
}
|
||||
ota_ui_shutdown();
|
||||
}
|
||||
|
||||
#else /* host stub */
|
||||
|
||||
int ota_ui_prompt_update(const char *installed, const char *latest) {
|
||||
fprintf(stderr, "[ota_ui] update %s -> %s (host stub: skip)\n", installed ? installed : "?",
|
||||
latest ? latest : "?");
|
||||
return 0;
|
||||
}
|
||||
void ota_ui_show_status(const char *title, const char *detail) {
|
||||
fprintf(stderr, "[ota_ui] %s %s\n", title ? title : "", detail ? detail : "");
|
||||
}
|
||||
void ota_ui_show_progress(const char *title, const char *detail, float progress01) {
|
||||
fprintf(stderr, "[ota_ui] %s %s (%.0f%%)\n", title ? title : "", detail ? detail : "",
|
||||
progress01 * 100.f);
|
||||
}
|
||||
void ota_ui_alert(const char *title, const char *line1, const char *line2) {
|
||||
fprintf(stderr, "[ota_ui] alert: %s / %s / %s\n", title ? title : "", line1 ? line1 : "",
|
||||
line2 ? line2 : "");
|
||||
}
|
||||
void ota_ui_alert_error(const char *title, const char *friendly, const char *technical,
|
||||
const char *installed_version) {
|
||||
fprintf(stderr, "[ota_ui] error: %s / %s / %s / v%s\n", title ? title : "",
|
||||
friendly ? friendly : "", technical ? technical : "",
|
||||
installed_version ? installed_version : "?");
|
||||
}
|
||||
void ota_ui_missing_game(void) { fprintf(stderr, "[ota_ui] missing game\n"); }
|
||||
void ota_ui_shutdown(void) {}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,89 @@
|
||||
#include "ota_unzip.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#if defined(__SWITCH__)
|
||||
#include <fcntl.h>
|
||||
#include <zzip/zzip.h>
|
||||
#endif
|
||||
|
||||
#if defined(__SWITCH__)
|
||||
static int extract_member(ZZIP_DIR *dir, const char *member, const char *dest_path, char *err,
|
||||
size_t err_len) {
|
||||
ZZIP_FILE *zf = zzip_file_open(dir, member, O_RDONLY);
|
||||
if (!zf) return 1; /* not found / cannot open */
|
||||
|
||||
FILE *out = fopen(dest_path, "wb");
|
||||
if (!out) {
|
||||
zzip_file_close(zf);
|
||||
if (err && err_len) snprintf(err, err_len, "fopen dest failed: %s", dest_path);
|
||||
return -1;
|
||||
}
|
||||
|
||||
char buf[8192];
|
||||
zzip_ssize_t n;
|
||||
while ((n = zzip_file_read(zf, buf, sizeof(buf))) > 0) {
|
||||
if ((zzip_ssize_t)fwrite(buf, 1, (size_t)n, out) != n) {
|
||||
fclose(out);
|
||||
zzip_file_close(zf);
|
||||
if (err && err_len) snprintf(err, err_len, "fwrite failed");
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
fclose(out);
|
||||
zzip_file_close(zf);
|
||||
if (n < 0) {
|
||||
if (err && err_len) snprintf(err, err_len, "zzip_file_read failed");
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
#endif
|
||||
|
||||
int ota_unzip_extract_file(const char *zip_path, const char *member_name, const char *dest_path,
|
||||
char *err, size_t err_len) {
|
||||
if (!zip_path || !member_name || !dest_path) {
|
||||
if (err && err_len) snprintf(err, err_len, "bad args");
|
||||
return -1;
|
||||
}
|
||||
#if defined(__SWITCH__)
|
||||
zzip_error_t zerr = ZZIP_NO_ERROR;
|
||||
ZZIP_DIR *dir = zzip_dir_open(zip_path, &zerr);
|
||||
if (!dir) {
|
||||
if (err && err_len) snprintf(err, err_len, "zzip_dir_open failed (%d): %s", (int)zerr, zip_path);
|
||||
return -1;
|
||||
}
|
||||
|
||||
char alt1[192];
|
||||
char alt2[192];
|
||||
snprintf(alt1, sizeof(alt1), "switch/gen1recomp/%s", member_name);
|
||||
snprintf(alt2, sizeof(alt2), "./%s", member_name);
|
||||
|
||||
const char *candidates[] = {member_name, alt1, alt2, NULL};
|
||||
int rc = -1;
|
||||
if (err && err_len) err[0] = '\0';
|
||||
for (int i = 0; candidates[i]; i++) {
|
||||
int t = extract_member(dir, candidates[i], dest_path, err, err_len);
|
||||
if (t == 0) {
|
||||
rc = 0;
|
||||
break;
|
||||
}
|
||||
if (t < 0) {
|
||||
rc = -1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (rc != 0 && err && err_len && !err[0]) {
|
||||
snprintf(err, err_len, "member not found in zip: %s", member_name);
|
||||
}
|
||||
zzip_dir_close(dir);
|
||||
return rc;
|
||||
#else
|
||||
(void)zip_path;
|
||||
(void)member_name;
|
||||
(void)dest_path;
|
||||
if (err && err_len) snprintf(err, err_len, "ota_unzip only available on __SWITCH__");
|
||||
return -1;
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* 8x8 monochrome bitmap fonts for rendering
|
||||
* Author: Daniel Hepper <daniel@hepper.net>
|
||||
*
|
||||
* License: Public Domain
|
||||
*
|
||||
* Based on:
|
||||
* // Summary: font8x8.h
|
||||
* // 8x8 monochrome bitmap fonts for rendering
|
||||
* //
|
||||
* // Author:
|
||||
* // Marcel Sondaar
|
||||
* // International Business Machines (public domain VGA fonts)
|
||||
* //
|
||||
* // License:
|
||||
* // Public Domain
|
||||
*
|
||||
* Fetched from: http://dimensionalrift.homelinux.net/combuster/mos3/?p=viewsource&file=/modules/gfx/font8_8.asm
|
||||
**/
|
||||
|
||||
// Constant: font8x8_basic
|
||||
// Contains an 8x8 font map for unicode points U+0000 - U+007F (basic latin)
|
||||
char font8x8_basic[128][8] = {
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0000 (nul)
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0001
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0002
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0003
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0004
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0005
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0006
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0007
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0008
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0009
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+000A
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+000B
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+000C
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+000D
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+000E
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+000F
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0010
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0011
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0012
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0013
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0014
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0015
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0016
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0017
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0018
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0019
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+001A
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+001B
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+001C
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+001D
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+001E
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+001F
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0020 (space)
|
||||
{ 0x18, 0x3C, 0x3C, 0x18, 0x18, 0x00, 0x18, 0x00}, // U+0021 (!)
|
||||
{ 0x36, 0x36, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0022 (")
|
||||
{ 0x36, 0x36, 0x7F, 0x36, 0x7F, 0x36, 0x36, 0x00}, // U+0023 (#)
|
||||
{ 0x0C, 0x3E, 0x03, 0x1E, 0x30, 0x1F, 0x0C, 0x00}, // U+0024 ($)
|
||||
{ 0x00, 0x63, 0x33, 0x18, 0x0C, 0x66, 0x63, 0x00}, // U+0025 (%)
|
||||
{ 0x1C, 0x36, 0x1C, 0x6E, 0x3B, 0x33, 0x6E, 0x00}, // U+0026 (&)
|
||||
{ 0x06, 0x06, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0027 (')
|
||||
{ 0x18, 0x0C, 0x06, 0x06, 0x06, 0x0C, 0x18, 0x00}, // U+0028 (()
|
||||
{ 0x06, 0x0C, 0x18, 0x18, 0x18, 0x0C, 0x06, 0x00}, // U+0029 ())
|
||||
{ 0x00, 0x66, 0x3C, 0xFF, 0x3C, 0x66, 0x00, 0x00}, // U+002A (*)
|
||||
{ 0x00, 0x0C, 0x0C, 0x3F, 0x0C, 0x0C, 0x00, 0x00}, // U+002B (+)
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x0C, 0x06}, // U+002C (,)
|
||||
{ 0x00, 0x00, 0x00, 0x3F, 0x00, 0x00, 0x00, 0x00}, // U+002D (-)
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x0C, 0x00}, // U+002E (.)
|
||||
{ 0x60, 0x30, 0x18, 0x0C, 0x06, 0x03, 0x01, 0x00}, // U+002F (/)
|
||||
{ 0x3E, 0x63, 0x73, 0x7B, 0x6F, 0x67, 0x3E, 0x00}, // U+0030 (0)
|
||||
{ 0x0C, 0x0E, 0x0C, 0x0C, 0x0C, 0x0C, 0x3F, 0x00}, // U+0031 (1)
|
||||
{ 0x1E, 0x33, 0x30, 0x1C, 0x06, 0x33, 0x3F, 0x00}, // U+0032 (2)
|
||||
{ 0x1E, 0x33, 0x30, 0x1C, 0x30, 0x33, 0x1E, 0x00}, // U+0033 (3)
|
||||
{ 0x38, 0x3C, 0x36, 0x33, 0x7F, 0x30, 0x78, 0x00}, // U+0034 (4)
|
||||
{ 0x3F, 0x03, 0x1F, 0x30, 0x30, 0x33, 0x1E, 0x00}, // U+0035 (5)
|
||||
{ 0x1C, 0x06, 0x03, 0x1F, 0x33, 0x33, 0x1E, 0x00}, // U+0036 (6)
|
||||
{ 0x3F, 0x33, 0x30, 0x18, 0x0C, 0x0C, 0x0C, 0x00}, // U+0037 (7)
|
||||
{ 0x1E, 0x33, 0x33, 0x1E, 0x33, 0x33, 0x1E, 0x00}, // U+0038 (8)
|
||||
{ 0x1E, 0x33, 0x33, 0x3E, 0x30, 0x18, 0x0E, 0x00}, // U+0039 (9)
|
||||
{ 0x00, 0x0C, 0x0C, 0x00, 0x00, 0x0C, 0x0C, 0x00}, // U+003A (:)
|
||||
{ 0x00, 0x0C, 0x0C, 0x00, 0x00, 0x0C, 0x0C, 0x06}, // U+003B (;)
|
||||
{ 0x18, 0x0C, 0x06, 0x03, 0x06, 0x0C, 0x18, 0x00}, // U+003C (<)
|
||||
{ 0x00, 0x00, 0x3F, 0x00, 0x00, 0x3F, 0x00, 0x00}, // U+003D (=)
|
||||
{ 0x06, 0x0C, 0x18, 0x30, 0x18, 0x0C, 0x06, 0x00}, // U+003E (>)
|
||||
{ 0x1E, 0x33, 0x30, 0x18, 0x0C, 0x00, 0x0C, 0x00}, // U+003F (?)
|
||||
{ 0x3E, 0x63, 0x7B, 0x7B, 0x7B, 0x03, 0x1E, 0x00}, // U+0040 (@)
|
||||
{ 0x0C, 0x1E, 0x33, 0x33, 0x3F, 0x33, 0x33, 0x00}, // U+0041 (A)
|
||||
{ 0x3F, 0x66, 0x66, 0x3E, 0x66, 0x66, 0x3F, 0x00}, // U+0042 (B)
|
||||
{ 0x3C, 0x66, 0x03, 0x03, 0x03, 0x66, 0x3C, 0x00}, // U+0043 (C)
|
||||
{ 0x1F, 0x36, 0x66, 0x66, 0x66, 0x36, 0x1F, 0x00}, // U+0044 (D)
|
||||
{ 0x7F, 0x46, 0x16, 0x1E, 0x16, 0x46, 0x7F, 0x00}, // U+0045 (E)
|
||||
{ 0x7F, 0x46, 0x16, 0x1E, 0x16, 0x06, 0x0F, 0x00}, // U+0046 (F)
|
||||
{ 0x3C, 0x66, 0x03, 0x03, 0x73, 0x66, 0x7C, 0x00}, // U+0047 (G)
|
||||
{ 0x33, 0x33, 0x33, 0x3F, 0x33, 0x33, 0x33, 0x00}, // U+0048 (H)
|
||||
{ 0x1E, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x1E, 0x00}, // U+0049 (I)
|
||||
{ 0x78, 0x30, 0x30, 0x30, 0x33, 0x33, 0x1E, 0x00}, // U+004A (J)
|
||||
{ 0x67, 0x66, 0x36, 0x1E, 0x36, 0x66, 0x67, 0x00}, // U+004B (K)
|
||||
{ 0x0F, 0x06, 0x06, 0x06, 0x46, 0x66, 0x7F, 0x00}, // U+004C (L)
|
||||
{ 0x63, 0x77, 0x7F, 0x7F, 0x6B, 0x63, 0x63, 0x00}, // U+004D (M)
|
||||
{ 0x63, 0x67, 0x6F, 0x7B, 0x73, 0x63, 0x63, 0x00}, // U+004E (N)
|
||||
{ 0x1C, 0x36, 0x63, 0x63, 0x63, 0x36, 0x1C, 0x00}, // U+004F (O)
|
||||
{ 0x3F, 0x66, 0x66, 0x3E, 0x06, 0x06, 0x0F, 0x00}, // U+0050 (P)
|
||||
{ 0x1E, 0x33, 0x33, 0x33, 0x3B, 0x1E, 0x38, 0x00}, // U+0051 (Q)
|
||||
{ 0x3F, 0x66, 0x66, 0x3E, 0x36, 0x66, 0x67, 0x00}, // U+0052 (R)
|
||||
{ 0x1E, 0x33, 0x07, 0x0E, 0x38, 0x33, 0x1E, 0x00}, // U+0053 (S)
|
||||
{ 0x3F, 0x2D, 0x0C, 0x0C, 0x0C, 0x0C, 0x1E, 0x00}, // U+0054 (T)
|
||||
{ 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x3F, 0x00}, // U+0055 (U)
|
||||
{ 0x33, 0x33, 0x33, 0x33, 0x33, 0x1E, 0x0C, 0x00}, // U+0056 (V)
|
||||
{ 0x63, 0x63, 0x63, 0x6B, 0x7F, 0x77, 0x63, 0x00}, // U+0057 (W)
|
||||
{ 0x63, 0x63, 0x36, 0x1C, 0x1C, 0x36, 0x63, 0x00}, // U+0058 (X)
|
||||
{ 0x33, 0x33, 0x33, 0x1E, 0x0C, 0x0C, 0x1E, 0x00}, // U+0059 (Y)
|
||||
{ 0x7F, 0x63, 0x31, 0x18, 0x4C, 0x66, 0x7F, 0x00}, // U+005A (Z)
|
||||
{ 0x1E, 0x06, 0x06, 0x06, 0x06, 0x06, 0x1E, 0x00}, // U+005B ([)
|
||||
{ 0x03, 0x06, 0x0C, 0x18, 0x30, 0x60, 0x40, 0x00}, // U+005C (\)
|
||||
{ 0x1E, 0x18, 0x18, 0x18, 0x18, 0x18, 0x1E, 0x00}, // U+005D (])
|
||||
{ 0x08, 0x1C, 0x36, 0x63, 0x00, 0x00, 0x00, 0x00}, // U+005E (^)
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF}, // U+005F (_)
|
||||
{ 0x0C, 0x0C, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0060 (`)
|
||||
{ 0x00, 0x00, 0x1E, 0x30, 0x3E, 0x33, 0x6E, 0x00}, // U+0061 (a)
|
||||
{ 0x07, 0x06, 0x06, 0x3E, 0x66, 0x66, 0x3B, 0x00}, // U+0062 (b)
|
||||
{ 0x00, 0x00, 0x1E, 0x33, 0x03, 0x33, 0x1E, 0x00}, // U+0063 (c)
|
||||
{ 0x38, 0x30, 0x30, 0x3e, 0x33, 0x33, 0x6E, 0x00}, // U+0064 (d)
|
||||
{ 0x00, 0x00, 0x1E, 0x33, 0x3f, 0x03, 0x1E, 0x00}, // U+0065 (e)
|
||||
{ 0x1C, 0x36, 0x06, 0x0f, 0x06, 0x06, 0x0F, 0x00}, // U+0066 (f)
|
||||
{ 0x00, 0x00, 0x6E, 0x33, 0x33, 0x3E, 0x30, 0x1F}, // U+0067 (g)
|
||||
{ 0x07, 0x06, 0x36, 0x6E, 0x66, 0x66, 0x67, 0x00}, // U+0068 (h)
|
||||
{ 0x0C, 0x00, 0x0E, 0x0C, 0x0C, 0x0C, 0x1E, 0x00}, // U+0069 (i)
|
||||
{ 0x30, 0x00, 0x30, 0x30, 0x30, 0x33, 0x33, 0x1E}, // U+006A (j)
|
||||
{ 0x07, 0x06, 0x66, 0x36, 0x1E, 0x36, 0x67, 0x00}, // U+006B (k)
|
||||
{ 0x0E, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x1E, 0x00}, // U+006C (l)
|
||||
{ 0x00, 0x00, 0x33, 0x7F, 0x7F, 0x6B, 0x63, 0x00}, // U+006D (m)
|
||||
{ 0x00, 0x00, 0x1F, 0x33, 0x33, 0x33, 0x33, 0x00}, // U+006E (n)
|
||||
{ 0x00, 0x00, 0x1E, 0x33, 0x33, 0x33, 0x1E, 0x00}, // U+006F (o)
|
||||
{ 0x00, 0x00, 0x3B, 0x66, 0x66, 0x3E, 0x06, 0x0F}, // U+0070 (p)
|
||||
{ 0x00, 0x00, 0x6E, 0x33, 0x33, 0x3E, 0x30, 0x78}, // U+0071 (q)
|
||||
{ 0x00, 0x00, 0x3B, 0x6E, 0x66, 0x06, 0x0F, 0x00}, // U+0072 (r)
|
||||
{ 0x00, 0x00, 0x3E, 0x03, 0x1E, 0x30, 0x1F, 0x00}, // U+0073 (s)
|
||||
{ 0x08, 0x0C, 0x3E, 0x0C, 0x0C, 0x2C, 0x18, 0x00}, // U+0074 (t)
|
||||
{ 0x00, 0x00, 0x33, 0x33, 0x33, 0x33, 0x6E, 0x00}, // U+0075 (u)
|
||||
{ 0x00, 0x00, 0x33, 0x33, 0x33, 0x1E, 0x0C, 0x00}, // U+0076 (v)
|
||||
{ 0x00, 0x00, 0x63, 0x6B, 0x7F, 0x7F, 0x36, 0x00}, // U+0077 (w)
|
||||
{ 0x00, 0x00, 0x63, 0x36, 0x1C, 0x36, 0x63, 0x00}, // U+0078 (x)
|
||||
{ 0x00, 0x00, 0x33, 0x33, 0x33, 0x3E, 0x30, 0x1F}, // U+0079 (y)
|
||||
{ 0x00, 0x00, 0x3F, 0x19, 0x0C, 0x26, 0x3F, 0x00}, // U+007A (z)
|
||||
{ 0x38, 0x0C, 0x0C, 0x07, 0x0C, 0x0C, 0x38, 0x00}, // U+007B ({)
|
||||
{ 0x18, 0x18, 0x18, 0x00, 0x18, 0x18, 0x18, 0x00}, // U+007C (|)
|
||||
{ 0x07, 0x0C, 0x0C, 0x38, 0x0C, 0x0C, 0x07, 0x00}, // U+007D (})
|
||||
{ 0x6E, 0x3B, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+007E (~)
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00} // U+007F
|
||||
};
|
||||
|
Before Width: | Height: | Size: 153 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 5.0 KiB |
@@ -113,6 +113,11 @@ picker copies user-selected files into LocalState and the launcher imports them
|
||||
from there. Saves, ROM cache and installed mods remain under the LÖVE save
|
||||
directory in LocalState.
|
||||
|
||||
The launcher also checks `LocalState\pokemon-love2d\baseroms` once at startup
|
||||
for clean Red, Blue, and Yellow ROMs. Compatible files are offered on their
|
||||
launcher tabs and remain in `baseroms` after import. The file picker remains
|
||||
available when no compatible ROM is found.
|
||||
|
||||
LuaJIT requires the `codeGeneration` capability. `removableStorage` exposes
|
||||
external media to the Xbox picker. The network capabilities support relay play
|
||||
and direct hosting. The package does not request full trust or broad filesystem
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# LÖVE UWP binaries
|
||||
|
||||
These x64 UWP Release binaries were built from [`caorthann-celt/love-xbox-uwp`](https://github.com/caorthann-celt/love-xbox-uwp) at commit `62275ed10190afbec01f65754b3bcc04f244dd35`.
|
||||
These x64 UWP Release binaries were built from [`caorthann-celt/love-xbox-uwp`](https://github.com/caorthann-celt/love-xbox-uwp) at commit `cdf85f28ed794c95d6f5f7e3a8f23ca9ee1fbdcb`.
|
||||
|
||||
The build uses LÖVE 11.5, LuaJIT, SDL2, and ANGLE. Keep the DLLs and import libraries together; they are one binary interface.
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"love": {
|
||||
"version": "11.5",
|
||||
"repository": "https://github.com/caorthann-celt/love-xbox-uwp.git",
|
||||
"commit": "62275ed10190afbec01f65754b3bcc04f244dd35"
|
||||
"commit": "cdf85f28ed794c95d6f5f7e3a8f23ca9ee1fbdcb"
|
||||
},
|
||||
"luajit": {
|
||||
"repository": "https://github.com/LuaJIT/LuaJIT.git",
|
||||
@@ -99,7 +99,7 @@
|
||||
},
|
||||
{
|
||||
"path": "love/bin/love.dll",
|
||||
"sha256": "7B1BE4699AC950173474BB0C03B5DD52E1202ABA6F60C29E294957057F587692"
|
||||
"sha256": "A3622828014F03DDB502D2A7807DC8F94432172595BE03508099BD66937FD45F"
|
||||
},
|
||||
{
|
||||
"path": "love/bin/lua51.dll",
|
||||
|
||||
@@ -19,19 +19,22 @@
|
||||
# (gen1recomp.nro + game.love side by side). Requires the pin
|
||||
# (run --fetch first, or combine --fetch --loose).
|
||||
#
|
||||
# --fused Build a single gen1recomp-<ver>-switch.nro via nacptool+elf2nro,
|
||||
# then pack dist/switch/gen1recomp-<ver>-switch.zip (SD-ready
|
||||
# tree under switch/gen1recomp/). Uses native tools (PATH or
|
||||
# $DEVKITPRO/tools/bin) first; else Docker from
|
||||
# scripts/switch/dkp-docker.image (override GEN1_DKP_IMAGE).
|
||||
# Requires the pin (run --fetch or combine). GitHub Releases
|
||||
# publish the zip only; the versioned .nro stays local / PR CI.
|
||||
# --fused Build fused gen1recomp-<ver>-switch.nro (game in romfs), native
|
||||
# OTA launcher NRO, and dual-NRO SD zip (gen1recomp.nro launcher +
|
||||
# gen1recomp-game.nro under switch/gen1recomp/). The same
|
||||
# gen1recomp-<ver>-switch.zip is the OTA download asset.
|
||||
# Requires DEVKITPRO + switch-dev; OTA launcher via native packages
|
||||
# (install_devkitpro_deps.sh) or Docker.
|
||||
# Uses native tools (PATH or $DEVKITPRO/tools/bin) first; else Docker
|
||||
# from scripts/switch/dkp-docker.image (override GEN1_DKP_IMAGE).
|
||||
# Requires the pin (run --fetch or combine). GitHub Releases publish
|
||||
# the zip only; versioned .nro files stay local / PR CI.
|
||||
#
|
||||
# Combinable: --fetch alone, or --fetch with --loose / --fused.
|
||||
# XOR: --loose and --fused cannot be used together.
|
||||
# XOR: --loose and --fused cannot be combined with each other.
|
||||
#
|
||||
# Non-goals (never done by this script):
|
||||
# MTP push, ROM install, dkp-pacman auto-install.
|
||||
# MTP push, ROM install, dkp-pacman auto-install (use install_devkitpro_deps.sh).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -49,7 +52,7 @@ say() { printf '\033[1;32m==>\033[0m %s\n' "$*" >&2; }
|
||||
fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
usage() {
|
||||
sed -n '2,34p' "$0" | sed 's/^# \{0,1\}//'
|
||||
sed -n '2,38p' "$0" | sed 's/^# \{0,1\}//'
|
||||
}
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
@@ -129,11 +132,22 @@ if [ "$LOOSE" -eq 1 ]; then
|
||||
fi
|
||||
|
||||
if [ "$FUSED" -eq 1 ]; then
|
||||
# shellcheck source=scripts/switch/common.sh
|
||||
. "$ROOT/scripts/switch/common.sh"
|
||||
preflight_fused_build
|
||||
|
||||
GAME_LOVE="$(pack_game_love)"
|
||||
OUT_NRO="$DIST/gen1recomp-${VERSION}-switch.nro"
|
||||
OUT_ZIP="$DIST/gen1recomp-${VERSION}-switch.zip"
|
||||
"$ROOT/scripts/switch/build_fused.sh" "$GAME_LOVE" "$VERSION" "$OUT_NRO"
|
||||
"$ROOT/scripts/switch/pack_sd_zip.sh" "$OUT_NRO" "$VERSION" "$OUT_ZIP"
|
||||
|
||||
LAUNCHER_NRO="$DIST/gen1recomp-${VERSION}-launcher.nro"
|
||||
say "building native OTA launcher"
|
||||
"$ROOT/scripts/switch/build_ota_launcher.sh" "$LAUNCHER_NRO" "$VERSION"
|
||||
say "packing dual-NRO SD zip (also the OTA download asset)"
|
||||
"$ROOT/scripts/switch/pack_sd_zip.sh" "$OUT_NRO" "$VERSION" "$OUT_ZIP" "$LAUNCHER_NRO"
|
||||
cp "$OUT_NRO" "$DIST/gen1recomp-${VERSION}-game.nro"
|
||||
|
||||
cp "$WORK/build-info.json" "$DIST/gen1recomp-${VERSION}-build-info.json"
|
||||
say "done. See $DIST/"
|
||||
exit 0
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Bake assets/logo/logo.png into a pre-scaled RGBA blob for the Switch OTA UI.
|
||||
|
||||
Output format (little-endian):
|
||||
uint32 width, uint32 height, then width*height RGBA8888 pixels.
|
||||
|
||||
Regenerate after editing the source PNG:
|
||||
python3 scripts/switch/bake_ota_logo.py
|
||||
|
||||
Uses Pillow when available (see scripts/setup.sh). On macOS without Pillow,
|
||||
falls back to sips + BMP export.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import platform
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
DEFAULT_SRC = ROOT / "assets/logo/logo.png"
|
||||
DEFAULT_OUT = ROOT / "ports/switch/assets/logo.rgba"
|
||||
DEFAULT_MAX_W = 320
|
||||
DEFAULT_MAX_H = 90
|
||||
|
||||
|
||||
def fit_size(src_w: int, src_h: int, max_w: int, max_h: int) -> tuple[int, int]:
|
||||
scale = min(max_w / src_w, max_h / src_h, 1.0)
|
||||
w = max(1, int(math.floor(src_w * scale + 0.5)))
|
||||
h = max(1, int(math.floor(src_h * scale + 0.5)))
|
||||
return w, h
|
||||
|
||||
|
||||
def read_png_size(path: Path) -> tuple[int, int]:
|
||||
with path.open("rb") as fp:
|
||||
sig = fp.read(8)
|
||||
if sig != b"\x89PNG\r\n\x1a\n":
|
||||
raise SystemExit(f"not a PNG: {path}")
|
||||
while True:
|
||||
raw = fp.read(8)
|
||||
if len(raw) < 8:
|
||||
raise SystemExit(f"truncated PNG: {path}")
|
||||
length, ctype = struct.unpack(">I4s", raw)
|
||||
data = fp.read(length)
|
||||
fp.read(4)
|
||||
if ctype == b"IHDR":
|
||||
return struct.unpack(">II", data[:8])
|
||||
if ctype == b"IEND":
|
||||
break
|
||||
raise SystemExit(f"missing IHDR in PNG: {path}")
|
||||
|
||||
|
||||
def read_bmp_rgba(path: Path) -> tuple[int, int, bytes]:
|
||||
data = path.read_bytes()
|
||||
if len(data) < 54 or data[:2] != b"BM":
|
||||
raise SystemExit(f"not a BMP: {path}")
|
||||
pixel_offset = struct.unpack_from("<I", data, 10)[0]
|
||||
width = struct.unpack_from("<i", data, 18)[0]
|
||||
height_raw = struct.unpack_from("<i", data, 22)[0]
|
||||
bpp = struct.unpack_from("<H", data, 28)[0]
|
||||
height = abs(height_raw)
|
||||
top_down = height_raw < 0
|
||||
if bpp != 32 or width <= 0 or height <= 0:
|
||||
raise SystemExit(f"unsupported BMP {width}x{height_raw} @{bpp}bpp: {path}")
|
||||
|
||||
row_bytes = ((width * 4 + 3) // 4) * 4
|
||||
pixels = bytearray(width * height * 4)
|
||||
for row in range(height):
|
||||
src_row = row if top_down else height - 1 - row
|
||||
row_off = pixel_offset + src_row * row_bytes
|
||||
for col in range(width):
|
||||
b, g, r, a = data[row_off + col * 4 : row_off + col * 4 + 4]
|
||||
dst = (row * width + col) * 4
|
||||
pixels[dst : dst + 4] = bytes((r, g, b, a))
|
||||
return width, height, bytes(pixels)
|
||||
|
||||
|
||||
def bake_with_sips(src: Path, out_w: int, out_h: int) -> tuple[int, int, bytes]:
|
||||
if platform.system() != "Darwin":
|
||||
raise SystemExit(
|
||||
"Pillow is required to bake the OTA logo on this platform. "
|
||||
"Run scripts/setup.sh or: pip install pillow"
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_dir = Path(tmp)
|
||||
scaled_png = tmp_dir / "scaled.png"
|
||||
scaled_bmp = tmp_dir / "scaled.bmp"
|
||||
subprocess.run(
|
||||
["sips", "-z", str(out_h), str(out_w), str(src), "--out", str(scaled_png)],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
subprocess.run(
|
||||
["sips", "-s", "format", "bmp", str(scaled_png), "--out", str(scaled_bmp)],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
return read_bmp_rgba(scaled_bmp)
|
||||
|
||||
|
||||
def bake_with_pillow(src: Path, out_w: int, out_h: int) -> tuple[int, int, bytes]:
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(src) as image:
|
||||
image = image.convert("RGBA")
|
||||
if image.size != (out_w, out_h):
|
||||
image = image.resize((out_w, out_h), Image.LANCZOS)
|
||||
w, h = image.size
|
||||
return w, h, image.tobytes()
|
||||
|
||||
|
||||
def bake(src: Path, out: Path, max_w: int, max_h: int) -> tuple[int, int]:
|
||||
if not src.is_file():
|
||||
raise SystemExit(f"missing source PNG: {src}")
|
||||
|
||||
src_w, src_h = read_png_size(src)
|
||||
out_w, out_h = fit_size(src_w, src_h, max_w, max_h)
|
||||
|
||||
try:
|
||||
from PIL import Image # noqa: F401
|
||||
|
||||
w, h, pixels = bake_with_pillow(src, out_w, out_h)
|
||||
except ImportError:
|
||||
w, h, pixels = bake_with_sips(src, out_w, out_h)
|
||||
|
||||
if len(pixels) != w * h * 4:
|
||||
raise SystemExit(f"unexpected pixel buffer size for {w}x{h}")
|
||||
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
with out.open("wb") as fp:
|
||||
fp.write(struct.pack("<II", w, h))
|
||||
fp.write(pixels)
|
||||
|
||||
return w, h
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
src = Path(argv[1]) if len(argv) > 1 else DEFAULT_SRC
|
||||
out = Path(argv[2]) if len(argv) > 2 else DEFAULT_OUT
|
||||
max_w = int(argv[3]) if len(argv) > 3 else DEFAULT_MAX_W
|
||||
max_h = int(argv[4]) if len(argv) > 4 else DEFAULT_MAX_H
|
||||
|
||||
w, h = bake(src, out, max_w, max_h)
|
||||
print(f"wrote {out} ({w}x{h}, {8 + w * h * 4} bytes)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv))
|
||||
@@ -15,7 +15,7 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
LOVE_NX_TAG="11.5-nx1"
|
||||
LOVE_NX_DIR="$ROOT/.bazinga/love-nx/$LOVE_NX_TAG"
|
||||
LOVE_ELF="$LOVE_NX_DIR/love.elf"
|
||||
ICON="$ROOT/assets/switch/icon.jpg"
|
||||
ICON="$ROOT/ports/switch/assets/icon.jpg"
|
||||
APP_NAME="gen1recomp"
|
||||
APP_AUTHOR="bryanthaboi, port by andrewqsantos"
|
||||
DKP_IMAGE_FILE="$ROOT/scripts/switch/dkp-docker.image"
|
||||
@@ -93,7 +93,7 @@ run_fused_docker() {
|
||||
set -euo pipefail
|
||||
nacptool --create '$APP_NAME' '$APP_AUTHOR' '$VERSION' /work/control.nacp
|
||||
elf2nro /src/.bazinga/love-nx/$LOVE_NX_TAG/love.elf /out/$out_base \
|
||||
--icon=/src/assets/switch/icon.jpg \
|
||||
--icon=/src/ports/switch/assets/icon.jpg \
|
||||
--nacp=/work/control.nacp \
|
||||
--romfsdir=/work/romfs
|
||||
"
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build the native Switch OTA launcher NRO (DEVKITPRO or Docker).
|
||||
#
|
||||
# Usage: scripts/switch/build_ota_launcher.sh [OUT_NRO] [VERSION]
|
||||
#
|
||||
# Default OUT_NRO: dist/switch/gen1recomp-launcher.nro
|
||||
# VERSION (optional X.Y.Z) is written into the NACP so hbmenu shows the
|
||||
# same release as the fused game; defaults to 0.0.0 when omitted.
|
||||
# Host-only protocol check: always runs `make host-test` first.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
# shellcheck source=common.sh
|
||||
. "$SCRIPT_DIR/common.sh"
|
||||
|
||||
LAUNCHER_DIR="$ROOT/ports/switch/ota-launcher"
|
||||
OUT_NRO="${1:-$ROOT/dist/switch/gen1recomp-launcher.nro}"
|
||||
APP_VERSION="${2:-${GEN1_LAUNCHER_VERSION:-0.0.0}}"
|
||||
DKP_IMAGE_FILE="$ROOT/scripts/switch/dkp-docker.image"
|
||||
|
||||
[ -d "$LAUNCHER_DIR" ] || fail "missing $LAUNCHER_DIR"
|
||||
[ -f "$LAUNCHER_DIR/Makefile" ] || fail "missing Makefile"
|
||||
|
||||
if ! devkitpro_ready; then
|
||||
fail_missing_devkitpro
|
||||
fi
|
||||
|
||||
say "host-test ota_protocol (no DEVKITPRO required)"
|
||||
make -C "$LAUNCHER_DIR" host-test
|
||||
|
||||
mkdir -p "$(dirname "$OUT_NRO")"
|
||||
|
||||
if ota_launcher_deps_ready; then
|
||||
say "building launcher NRO with native DEVKITPRO (NACP $APP_VERSION)"
|
||||
make -C "$LAUNCHER_DIR" clean || true
|
||||
if ! make -C "$LAUNCHER_DIR" all APP_VERSION="$APP_VERSION"; then
|
||||
fail_ota_launcher_toolchain
|
||||
fi
|
||||
cp "$LAUNCHER_DIR/gen1recomp.nro" "$OUT_NRO"
|
||||
say "wrote $OUT_NRO"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
resolve_dkp_image() {
|
||||
if [ -n "${GEN1_DKP_IMAGE:-}" ]; then
|
||||
printf '%s' "$GEN1_DKP_IMAGE"
|
||||
return 0
|
||||
fi
|
||||
[ -f "$DKP_IMAGE_FILE" ] || fail "missing Docker image pin: $DKP_IMAGE_FILE"
|
||||
local line
|
||||
line="$(grep -v '^[[:space:]]*#' "$DKP_IMAGE_FILE" | grep -v '^[[:space:]]*$' | head -1 || true)"
|
||||
[ -n "$line" ] || fail "empty Docker image pin: $DKP_IMAGE_FILE"
|
||||
printf '%s' "$line"
|
||||
}
|
||||
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
image="$(resolve_dkp_image)"
|
||||
say "building launcher NRO via Docker ($image, NACP $APP_VERSION)"
|
||||
if ! docker run --rm \
|
||||
-v "$ROOT:/work" \
|
||||
-w /work/ports/switch/ota-launcher \
|
||||
-e "APP_VERSION=$APP_VERSION" \
|
||||
"$image" \
|
||||
bash -lc 'pacman -Sy --noconfirm switch-curl switch-mbedtls switch-zlib switch-zziplib >/dev/null 2>&1 || true; make clean; make all APP_VERSION="$APP_VERSION"'; then
|
||||
fail_ota_launcher_toolchain
|
||||
fi
|
||||
cp "$LAUNCHER_DIR/gen1recomp.nro" "$OUT_NRO"
|
||||
say "wrote $OUT_NRO"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
fail_missing_ota_deps
|
||||
@@ -8,7 +8,8 @@ if [ -z "${ROOT:-}" ]; then
|
||||
fi
|
||||
export ROOT
|
||||
|
||||
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||
# Progress on stderr so command-substitution (e.g. pack_game_love) stays a bare path.
|
||||
say() { printf '\033[1;32m==>\033[0m %s\n' "$*" >&2; }
|
||||
fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
# Print SHA-256 hex digest of PATH. Prefers shasum, falls back to sha256sum.
|
||||
@@ -38,16 +39,137 @@ fail_need_fetch() {
|
||||
fail "${1:-missing pinned love-nx} — run: scripts/build_switch.sh --fetch"
|
||||
}
|
||||
|
||||
# OTA launcher native build packages (shared with install_devkitpro_deps.sh).
|
||||
OTA_LAUNCHER_PKGS=(switch-curl switch-mbedtls switch-zlib switch-zziplib)
|
||||
|
||||
ota_launcher_deps_ready() {
|
||||
command -v dkp-pacman >/dev/null 2>&1 || return 1
|
||||
local pkg
|
||||
for pkg in "${OTA_LAUNCHER_PKGS[@]}"; do
|
||||
dkp-pacman -Q "$pkg" >/dev/null 2>&1 || return 1
|
||||
done
|
||||
}
|
||||
|
||||
# Space-separated list of missing OTA launcher packages (empty when all installed).
|
||||
ota_launcher_missing_pkgs() {
|
||||
command -v dkp-pacman >/dev/null 2>&1 || return 0
|
||||
local pkg missing=""
|
||||
for pkg in "${OTA_LAUNCHER_PKGS[@]}"; do
|
||||
if ! dkp-pacman -Q "$pkg" >/dev/null 2>&1; then
|
||||
missing="${missing} ${pkg}"
|
||||
fi
|
||||
done
|
||||
printf '%s' "$missing"
|
||||
}
|
||||
|
||||
devkitpro_ready() {
|
||||
[ -n "${DEVKITPRO:-}" ] && [ -f "$DEVKITPRO/libnx/switch_rules" ]
|
||||
}
|
||||
|
||||
fail_missing_devkitpro() {
|
||||
fail "$(cat <<'EOF'
|
||||
--fused requires DEVKITPRO (release builds ship the OTA launcher for in-console updates).
|
||||
|
||||
One-time setup on the Mac self-hosted runner (or your dev machine):
|
||||
|
||||
1. Install devkitPro pacman: https://devkitpro.org/wiki/devkitPro_pacman
|
||||
2. Install Switch tools: sudo dkp-pacman -S switch-dev
|
||||
3. Export DEVKITPRO (typical macOS):
|
||||
export DEVKITPRO=/opt/devkitpro
|
||||
export PATH="$DEVKITPRO/tools/bin:$PATH"
|
||||
4. OTA launcher toolchain (native or Docker — pick one):
|
||||
bash scripts/switch/install_devkitpro_deps.sh
|
||||
or install Docker (same pin as fused builds)
|
||||
|
||||
Then re-run: scripts/build_switch.sh --fetch --fused
|
||||
|
||||
See docs/switch-build.md (Runner provisioning).
|
||||
EOF
|
||||
)"
|
||||
}
|
||||
|
||||
fail_missing_ota_deps() {
|
||||
local missing
|
||||
missing="$(ota_launcher_missing_pkgs)"
|
||||
fail "$(cat <<EOF
|
||||
--fused needs a way to build the OTA launcher: native dkp packages or Docker.
|
||||
|
||||
Missing dkp packages:${missing:- (dkp-pacman not on PATH)}
|
||||
|
||||
Pick one:
|
||||
bash scripts/switch/install_devkitpro_deps.sh
|
||||
or install Docker (build_ota_launcher.sh uses the pinned devkitPro image)
|
||||
|
||||
Then re-run: scripts/build_switch.sh --fetch --fused
|
||||
|
||||
See docs/switch-build.md and ports/switch/ota-launcher/README.md.
|
||||
EOF
|
||||
)"
|
||||
}
|
||||
|
||||
fail_ota_launcher_toolchain() {
|
||||
if ! devkitpro_ready; then
|
||||
fail_missing_devkitpro
|
||||
fi
|
||||
if ota_launcher_deps_ready; then
|
||||
fail "$(cat <<'EOF'
|
||||
OTA launcher native build failed after host-test passed.
|
||||
|
||||
Re-run with a clean tree:
|
||||
scripts/switch/build_ota_launcher.sh
|
||||
|
||||
If packages look wrong, reinstall:
|
||||
bash scripts/switch/install_devkitpro_deps.sh
|
||||
|
||||
See ports/switch/ota-launcher/README.md.
|
||||
EOF
|
||||
)"
|
||||
fi
|
||||
fail_missing_ota_deps
|
||||
}
|
||||
|
||||
# Fail fast before packing when --fused is requested.
|
||||
preflight_fused_build() {
|
||||
if ! devkitpro_ready; then
|
||||
fail_missing_devkitpro
|
||||
fi
|
||||
ensure_dkp_tools_path
|
||||
|
||||
local game_ok=0
|
||||
if command -v nacptool >/dev/null 2>&1 && command -v elf2nro >/dev/null 2>&1; then
|
||||
game_ok=1
|
||||
elif command -v docker >/dev/null 2>&1; then
|
||||
game_ok=1
|
||||
fi
|
||||
if [ "$game_ok" -eq 0 ]; then
|
||||
fail_fused_toolchain
|
||||
fi
|
||||
|
||||
if ota_launcher_deps_ready; then
|
||||
return 0
|
||||
fi
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
fail_missing_ota_deps
|
||||
}
|
||||
|
||||
# Fused mode needs nacptool/elf2nro (native or Docker). Rich multi-OS hint.
|
||||
fail_fused_toolchain() {
|
||||
fail "$(cat <<'EOF'
|
||||
fused packaging needs nacptool and elf2nro (devkitPro switch-dev).
|
||||
fused packaging needs DEVKITPRO, nacptool, and elf2nro (devkitPro switch-dev).
|
||||
|
||||
Install options:
|
||||
macOS: https://devkitpro.org/wiki/devkitPro_pacman (installer / pacman)
|
||||
macOS: https://devkitpro.org/wiki/devkitPro_pacman
|
||||
sudo dkp-pacman -S switch-dev
|
||||
OTA launcher (native or Docker):
|
||||
bash scripts/switch/install_devkitpro_deps.sh
|
||||
or install Docker
|
||||
Linux: https://devkitpro.org/wiki/devkitPro_pacman
|
||||
Windows: Git Bash / MSYS2 / WSL with devkitPro tools on PATH
|
||||
Docker: install Docker; build_fused.sh falls back to the pinned image
|
||||
Docker: install Docker; fused game and OTA launcher can build in-container
|
||||
|
||||
Export DEVKITPRO (typical): export DEVKITPRO=/opt/devkitpro
|
||||
|
||||
See docs/switch-build.md for details.
|
||||
EOF
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env bash
|
||||
# Install DEVKITPRO Switch packages needed for the native OTA launcher.
|
||||
# Run this in Terminal.app / iTerm (needs your macOS admin password):
|
||||
#
|
||||
# bash scripts/switch/install_devkitpro_deps.sh
|
||||
#
|
||||
# Optional: speeds up local native OTA launcher builds. CI/release can fall back
|
||||
# to Docker when these packages are not installed.
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
# shellcheck source=common.sh
|
||||
. "$SCRIPT_DIR/common.sh"
|
||||
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "==> re-running with sudo…"
|
||||
exec sudo -E bash "$0" "$@"
|
||||
fi
|
||||
|
||||
export DEVKITPRO="${DEVKITPRO:-/opt/devkitpro}"
|
||||
export PATH="/usr/local/bin:$DEVKITPRO/pacman/bin:$DEVKITPRO/tools/bin:$PATH"
|
||||
|
||||
if [ -f /etc/profile.d/devkit-env.sh ]; then
|
||||
# shellcheck disable=SC1091
|
||||
. /etc/profile.d/devkit-env.sh
|
||||
fi
|
||||
|
||||
if ! command -v dkp-pacman >/dev/null 2>&1; then
|
||||
echo "error: dkp-pacman not found. Install the pkg from:" >&2
|
||||
echo " https://github.com/devkitPro/pacman/releases/latest" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! dkp-pacman -Q switch-dev >/dev/null 2>&1; then
|
||||
echo "error: switch-dev is not installed (needed for nacptool/elf2nro)." >&2
|
||||
echo " sudo dkp-pacman -S switch-dev" >&2
|
||||
echo "Then re-run: bash scripts/switch/install_devkitpro_deps.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> DEVKITPRO=$DEVKITPRO"
|
||||
# Named packages only (avoid interactive switch-dev group member prompt).
|
||||
# ZIP reading uses switch-zziplib (dkp has no minizip port for Switch).
|
||||
dkp-pacman -Sy --noconfirm --needed "${OTA_LAUNCHER_PKGS[@]}"
|
||||
|
||||
echo "==> installed packages:"
|
||||
dkp-pacman -Q | grep -E 'switch-(curl|mbedtls|zlib|zziplib|tools)|libnx|devkitA64' || true
|
||||
|
||||
# Ensure zsh/bash sessions see DEVKITPRO
|
||||
MARKER="# gen1recomp-devkitpro"
|
||||
for rc in /Users/*/.zshrc /Users/*/.bash_profile; do
|
||||
[ -f "$rc" ] || continue
|
||||
if ! grep -q "$MARKER" "$rc" 2>/dev/null; then
|
||||
{
|
||||
echo ""
|
||||
echo "$MARKER"
|
||||
echo "export DEVKITPRO=/opt/devkitpro"
|
||||
echo 'export PATH="$DEVKITPRO/tools/bin:$DEVKITPRO/devkitA64/bin:$PATH"'
|
||||
} >> "$rc"
|
||||
echo "==> appended DEVKITPRO exports to $rc"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "==> done. Open a new shell, then:"
|
||||
echo " export DEVKITPRO=/opt/devkitpro"
|
||||
echo " scripts/switch/build_ota_launcher.sh"
|
||||
@@ -0,0 +1,16 @@
|
||||
# Switch OTA launcher packaging manifest
|
||||
#
|
||||
# Release / SD tree MUST include the native OTA launcher NRO as the hbmenu
|
||||
# entrypoint. The LÖVE fused game NRO is loaded via envSetNextLoad after the
|
||||
# update check. Self-update of the launcher itself is out of scope for v1.
|
||||
#
|
||||
# OTA downloads the install zip.
|
||||
#
|
||||
# Paths relative to microSD root:
|
||||
|
||||
ENTRY_NRO=switch/gen1recomp/gen1recomp.nro
|
||||
GAME_NRO=switch/gen1recomp/gen1recomp-game.nro
|
||||
SAVE_DIR=switch/gen1recomp/pokemon-love2d
|
||||
OTA_ASSET_GLOB=gen1recomp-*-switch.zip
|
||||
REQUIRE_SHA256SUMS=1
|
||||
OTA_ENABLED=1
|
||||
@@ -2,16 +2,18 @@
|
||||
# Pack a Switch SD-ready zip: extract at microSD root (merge-safe update).
|
||||
#
|
||||
# Usage:
|
||||
# scripts/switch/pack_sd_zip.sh NRO_PATH VERSION OUT_ZIP
|
||||
# scripts/switch/pack_sd_zip.sh GAME_NRO VERSION OUT_ZIP [LAUNCHER_NRO]
|
||||
#
|
||||
# Layout inside the zip (SD root):
|
||||
# switch/gen1recomp/gen1recomp.nro
|
||||
# switch/gen1recomp/gen1recomp.nro (launcher if LAUNCHER_NRO set, else game)
|
||||
# switch/gen1recomp/gen1recomp-game.nro (only when LAUNCHER_NRO set)
|
||||
# switch/gen1recomp/version.txt
|
||||
# switch/gen1recomp/INSTALL.txt
|
||||
# switch/gen1recomp/pokemon-love2d/imports/.../README.txt
|
||||
# switch/gen1recomp/pokemon-love2d/exports/.../README.txt
|
||||
#
|
||||
# Does not ship ROMs, saves, or mods. Re-extracting merges over an existing
|
||||
# install and only overwrites the NRO + these text placeholders — keep
|
||||
# install and only overwrites the NRO(s) + these text placeholders — keep
|
||||
# pokemon-love2d/ to preserve progress.
|
||||
|
||||
set -euo pipefail
|
||||
@@ -23,11 +25,15 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
NRO_PATH="${1:-}"
|
||||
VERSION="${2:-}"
|
||||
OUT_ZIP="${3:-}"
|
||||
LAUNCHER_NRO="${4:-}"
|
||||
|
||||
[ -n "$NRO_PATH" ] && [ -n "$VERSION" ] && [ -n "$OUT_ZIP" ] \
|
||||
|| fail "usage: scripts/switch/pack_sd_zip.sh NRO_PATH VERSION OUT_ZIP"
|
||||
|| fail "usage: scripts/switch/pack_sd_zip.sh GAME_NRO VERSION OUT_ZIP [LAUNCHER_NRO]"
|
||||
|
||||
[ -f "$NRO_PATH" ] || fail "missing NRO at $NRO_PATH"
|
||||
[ -f "$NRO_PATH" ] || fail "missing game NRO at $NRO_PATH"
|
||||
if [ -n "$LAUNCHER_NRO" ]; then
|
||||
[ -f "$LAUNCHER_NRO" ] || fail "missing launcher NRO at $LAUNCHER_NRO"
|
||||
fi
|
||||
|
||||
command -v zip >/dev/null 2>&1 || fail "need zip on PATH"
|
||||
|
||||
@@ -45,7 +51,15 @@ trap cleanup EXIT
|
||||
APP_DIR="$STAGE/switch/gen1recomp"
|
||||
SAVE_ROOT="$APP_DIR/pokemon-love2d"
|
||||
mkdir -p "$APP_DIR"
|
||||
cp "$NRO_PATH" "$APP_DIR/gen1recomp.nro"
|
||||
|
||||
if [ -n "$LAUNCHER_NRO" ]; then
|
||||
LAUNCHER_NRO="$(cd "$(dirname "$LAUNCHER_NRO")" && pwd)/$(basename "$LAUNCHER_NRO")"
|
||||
cp "$LAUNCHER_NRO" "$APP_DIR/gen1recomp.nro"
|
||||
cp "$NRO_PATH" "$APP_DIR/gen1recomp-game.nro"
|
||||
else
|
||||
cp "$NRO_PATH" "$APP_DIR/gen1recomp.nro"
|
||||
fi
|
||||
printf '%s\n' "$VERSION" > "$APP_DIR/version.txt"
|
||||
|
||||
cat > "$APP_DIR/INSTALL.txt" <<EOF
|
||||
gen1recomp Switch (v${VERSION})
|
||||
@@ -55,7 +69,7 @@ First install or update (same steps):
|
||||
1. Extract this zip at the root of your microSD (merge folders if asked).
|
||||
2. Do NOT delete switch/gen1recomp/pokemon-love2d/ — that folder holds
|
||||
your saves, imported ROMs, mods, and options. Re-extracting only
|
||||
replaces gen1recomp.nro and these help files.
|
||||
replaces the NRO(s) and these help files.
|
||||
3. Launch with title override (hold R on HOME, open any title → hbmenu).
|
||||
4. Copy a legal Pokemon Red/Blue .gb into:
|
||||
switch/gen1recomp/pokemon-love2d/imports/
|
||||
@@ -106,9 +120,16 @@ rm -f "$OUT_ZIP"
|
||||
LISTING="$(unzip -Z1 "$OUT_ZIP" 2>/dev/null || unzip -l "$OUT_ZIP")"
|
||||
printf '%s\n' "$LISTING" | grep -q 'switch/gen1recomp/gen1recomp.nro' \
|
||||
|| fail "zip missing switch/gen1recomp/gen1recomp.nro"
|
||||
printf '%s\n' "$LISTING" | grep -Fq 'switch/gen1recomp/version.txt' \
|
||||
|| fail "zip missing switch/gen1recomp/version.txt"
|
||||
if [ -n "$LAUNCHER_NRO" ]; then
|
||||
printf '%s\n' "$LISTING" | grep -Fq 'switch/gen1recomp/gen1recomp-game.nro' \
|
||||
|| fail "zip missing switch/gen1recomp/gen1recomp-game.nro (OTA dual-NRO layout)"
|
||||
fi
|
||||
|
||||
REQUIRED=(
|
||||
"switch/gen1recomp/INSTALL.txt"
|
||||
"switch/gen1recomp/version.txt"
|
||||
"switch/gen1recomp/pokemon-love2d/imports/README.txt"
|
||||
"switch/gen1recomp/pokemon-love2d/imports/mods/README.txt"
|
||||
"switch/gen1recomp/pokemon-love2d/imports/saves/red/README.txt"
|
||||
|
||||
@@ -51,10 +51,11 @@ MISSING=""
|
||||
printf '%s' "$HELP_LC" | grep -q 'fetch' || MISSING="${MISSING} fetch"
|
||||
printf '%s' "$HELP_LC" | grep -q 'loose' || MISSING="${MISSING} loose"
|
||||
printf '%s' "$HELP_LC" | grep -q 'fused' || MISSING="${MISSING} fused"
|
||||
printf '%s' "$HELP_LC" | grep -Eq 'devkitpro' || MISSING="${MISSING} DEVKITPRO"
|
||||
printf '%s' "$HELP_LC" | grep -Eq 'auto-download|downloads' || MISSING="${MISSING} auto-download"
|
||||
printf '%s' "$HELP_LC" | grep -Eq 'non-goal|does not|never' || MISSING="${MISSING} non-goals"
|
||||
if [ -z "$MISSING" ]; then
|
||||
ok "build_switch.sh --help mentions fetch, loose, fused (+ auto-download/non-goals)"
|
||||
ok "build_switch.sh --help mentions fetch, loose, fused, DEVKITPRO (+ auto-download/non-goals)"
|
||||
else
|
||||
bad "build_switch.sh --help missing:$MISSING"
|
||||
fi
|
||||
@@ -180,7 +181,41 @@ else
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5c. fail_fused_toolchain mentions docs/switch-build.md
|
||||
# 5c2. fail_missing_devkitpro cites install_devkitpro_deps.sh
|
||||
# ---------------------------------------------------------------------------
|
||||
MDK_ERR="$STAGING/missing-dkp.err"
|
||||
MDK_RC=0
|
||||
(
|
||||
. "$SCRIPT_DIR/common.sh"
|
||||
fail_missing_devkitpro
|
||||
) >"$STAGING/missing-dkp.out" 2>"$MDK_ERR" || MDK_RC=$?
|
||||
|
||||
if [ "$MDK_RC" -ne 0 ] \
|
||||
&& grep -q 'install_devkitpro_deps.sh' "$MDK_ERR" \
|
||||
&& grep -q 'DEVKITPRO' "$MDK_ERR" \
|
||||
&& grep -q 'switch-dev' "$MDK_ERR"; then
|
||||
ok "fail_missing_devkitpro cites DEVKITPRO + switch-dev + install_devkitpro_deps.sh"
|
||||
else
|
||||
bad "fail_missing_devkitpro should cite setup steps (rc=$MDK_RC err=$(cat "$MDK_ERR"))"
|
||||
fi
|
||||
|
||||
OTA_ERR="$STAGING/missing-ota.err"
|
||||
OTA_RC=0
|
||||
(
|
||||
. "$SCRIPT_DIR/common.sh"
|
||||
fail_missing_ota_deps
|
||||
) >"$STAGING/missing-ota.out" 2>"$OTA_ERR" || OTA_RC=$?
|
||||
|
||||
if [ "$OTA_RC" -ne 0 ] \
|
||||
&& grep -q 'install_devkitpro_deps.sh' "$OTA_ERR" \
|
||||
&& grep -qi 'docker' "$OTA_ERR"; then
|
||||
ok "fail_missing_ota_deps cites native or Docker options"
|
||||
else
|
||||
bad "fail_missing_ota_deps should cite native + Docker (rc=$OTA_RC err=$(cat "$OTA_ERR"))"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5d. fail_fused_toolchain mentions docs/switch-build.md
|
||||
# ---------------------------------------------------------------------------
|
||||
FT_ERR="$STAGING/fused-toolchain.err"
|
||||
FT_RC=0
|
||||
@@ -228,6 +263,7 @@ ZIP_LIST="$(unzip -Z1 "$FAKE_ZIP" 2>/dev/null || unzip -l "$FAKE_ZIP")"
|
||||
PACK_MISSING=""
|
||||
for rel in \
|
||||
"switch/gen1recomp/gen1recomp.nro" \
|
||||
"switch/gen1recomp/version.txt" \
|
||||
"switch/gen1recomp/INSTALL.txt" \
|
||||
"switch/gen1recomp/pokemon-love2d/imports/README.txt" \
|
||||
"switch/gen1recomp/pokemon-love2d/imports/mods/README.txt" \
|
||||
@@ -311,6 +347,94 @@ else
|
||||
bad "pack_sd_zip.sh merge update lost user data or failed to replace NRO"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. Dual-NRO OTA layout (launcher + game) when 4th arg set
|
||||
# ---------------------------------------------------------------------------
|
||||
FAKE_LAUNCHER="$STAGING/fake-launcher.nro"
|
||||
FAKE_GAME="$STAGING/fake-game.nro"
|
||||
printf 'fake-launcher\n' > "$FAKE_LAUNCHER"
|
||||
printf 'fake-game\n' > "$FAKE_GAME"
|
||||
OTA_ZIP="$STAGING/gen1recomp-0.0.2-ota-layout-switch.zip"
|
||||
OTA_RC=0
|
||||
"$ROOT/scripts/switch/pack_sd_zip.sh" "$FAKE_GAME" "0.0.2" "$OTA_ZIP" "$FAKE_LAUNCHER" \
|
||||
>"$STAGING/ota-pack.out" 2>"$STAGING/ota-pack.err" || OTA_RC=$?
|
||||
OTA_LIST="$(unzip -Z1 "$OTA_ZIP" 2>/dev/null || true)"
|
||||
if [ "$OTA_RC" -eq 0 ] \
|
||||
&& printf '%s\n' "$OTA_LIST" | grep -Fq 'switch/gen1recomp/gen1recomp.nro' \
|
||||
&& printf '%s\n' "$OTA_LIST" | grep -Fq 'switch/gen1recomp/gen1recomp-game.nro' \
|
||||
&& printf '%s\n' "$OTA_LIST" | grep -Fq 'switch/gen1recomp/version.txt'
|
||||
then
|
||||
EXTRACT_OTA="$STAGING/extract-ota"
|
||||
rm -rf "$EXTRACT_OTA"
|
||||
mkdir -p "$EXTRACT_OTA"
|
||||
unzip -q "$OTA_ZIP" -d "$EXTRACT_OTA"
|
||||
DUAL_OK=1
|
||||
cmp -s "$FAKE_LAUNCHER" "$EXTRACT_OTA/switch/gen1recomp/gen1recomp.nro" || DUAL_OK=0
|
||||
cmp -s "$FAKE_GAME" "$EXTRACT_OTA/switch/gen1recomp/gen1recomp-game.nro" || DUAL_OK=0
|
||||
[ "$(cat "$EXTRACT_OTA/switch/gen1recomp/version.txt")" = "0.0.2" ] || DUAL_OK=0
|
||||
if [ "$DUAL_OK" -eq 1 ]; then
|
||||
ok "pack_sd_zip.sh dual-NRO OTA layout (launcher + game + version.txt)"
|
||||
else
|
||||
bad "pack_sd_zip.sh dual-NRO bytes/version mismatch"
|
||||
fi
|
||||
else
|
||||
bad "pack_sd_zip.sh dual-NRO layout failed (rc=$OTA_RC err=$(cat "$STAGING/ota-pack.err"))"
|
||||
fi
|
||||
|
||||
MANIFEST="$ROOT/scripts/switch/ota_launcher.manifest"
|
||||
if [ -f "$MANIFEST" ] && grep -q '^OTA_ENABLED=1$' "$MANIFEST" \
|
||||
&& grep -q 'ENTRY_NRO=switch/gen1recomp/gen1recomp.nro' "$MANIFEST" \
|
||||
&& grep -q 'GAME_NRO=switch/gen1recomp/gen1recomp-game.nro' "$MANIFEST"
|
||||
then
|
||||
ok "ota_launcher.manifest requires dual-NRO when OTA_ENABLED=1"
|
||||
else
|
||||
bad "ota_launcher.manifest missing OTA dual-NRO requirements"
|
||||
fi
|
||||
|
||||
if [ -f "$ROOT/scripts/switch/build_ota_launcher.sh" ] \
|
||||
&& grep -q 'ota_launcher_deps_ready' "$ROOT/scripts/switch/build_ota_launcher.sh" \
|
||||
&& grep -q 'fail_missing_devkitpro' "$ROOT/scripts/switch/build_ota_launcher.sh" \
|
||||
&& grep -q 'preflight_fused_build' "$ROOT/scripts/build_switch.sh" \
|
||||
&& [ -f "$ROOT/ports/switch/ota-launcher/Makefile" ]
|
||||
then
|
||||
ok "native OTA launcher sources + build_ota_launcher.sh present"
|
||||
else
|
||||
bad "missing native OTA launcher tree or build script"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8. Unified OTA asset = dual-NRO SD zip (no separate OTA-only archive)
|
||||
# ---------------------------------------------------------------------------
|
||||
LEGACY_OTA_PACKER="$ROOT/scripts/switch/pack_ota_zip.sh"
|
||||
if [ ! -f "$LEGACY_OTA_PACKER" ] \
|
||||
&& ! grep -Eq 'pack_ota_zip\.sh|switch-ota\.zip' "$ROOT/scripts/build_switch.sh" \
|
||||
&& grep -q 'OTA_ASSET_GLOB=gen1recomp-\*-switch.zip' "$MANIFEST"
|
||||
then
|
||||
ok "OTA uses the same SD zip as install (legacy OTA-only packer gone)"
|
||||
else
|
||||
bad "legacy separate OTA-only packer / asset still present"
|
||||
fi
|
||||
|
||||
if grep -q 'ota_ui_prompt_update' "$ROOT/ports/switch/ota-launcher/src/main.c" \
|
||||
&& grep -q 'ota_net_init' "$ROOT/ports/switch/ota-launcher/src/main.c" \
|
||||
&& grep -q 'framebufferCreate\|COL_RAIL' "$ROOT/ports/switch/ota-launcher/src/ota_ui.c" \
|
||||
&& grep -q '^ROMFS' "$ROOT/ports/switch/ota-launcher/Makefile" \
|
||||
&& grep -q 'cacert.pem' "$ROOT/ports/switch/ota-launcher/Makefile"
|
||||
then
|
||||
ok "launcher UI uses framebuffer (no prompt when up to date)"
|
||||
else
|
||||
bad "launcher missing branded ota_ui / ROMFS"
|
||||
fi
|
||||
|
||||
if [ -f "$ROOT/ports/switch/ota-launcher/src/ota_unzip.c" ] \
|
||||
&& grep -q 'zzip/zzip.h' "$ROOT/ports/switch/ota-launcher/src/ota_unzip.c" \
|
||||
&& grep -q 'ota_unzip_extract_file' "$ROOT/ports/switch/ota-launcher/src/main.c"
|
||||
then
|
||||
ok "launcher wires zziplib ota_unzip_extract_file"
|
||||
else
|
||||
bad "launcher missing zziplib unzip wiring"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Summary
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
-- extracted cache lives, and the save-file suffix -- so the importer,
|
||||
-- cache mount, SaveData, title screen and palette all agree.
|
||||
--
|
||||
-- Red keeps every un-suffixed path it always used (save.lua, the root cache),
|
||||
-- so existing installs are untouched; Blue is namespaced under blue/ and
|
||||
-- _blue, Yellow under yellow/ and _yellow, so all three can be imported and
|
||||
-- Red keeps the un-suffixed save paths it always used (save.lua) so existing
|
||||
-- saves are untouched, but its extracted cache lives under red/ like Blue and
|
||||
-- Yellow (issue #899); a legacy root cache is moved into red/ once by
|
||||
-- CacheFs.migrateLegacyRedCache. All three versions can be imported and
|
||||
-- played side by side.
|
||||
--
|
||||
-- Zero requires, so it loads during love.conf and under plain Lua for tools
|
||||
@@ -23,7 +24,7 @@ GameVersion.VERSIONS = {
|
||||
launcherName = "Red", -- game-panel header in the launcher
|
||||
sha1 = "ea9bcae617fdf159b045185467ae58b2e4a48b9a",
|
||||
manifest = "tools/rom_manifest.json",
|
||||
cachePrefix = "", -- Red owns the cache root (backwards compatible)
|
||||
cachePrefix = "red/", -- red/data/generated, red/assets/generated (#899)
|
||||
saveSuffix = "", -- save.lua / save.lua.bak / save.lua.tmp
|
||||
},
|
||||
blue = {
|
||||
@@ -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,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 = {}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,606 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const root = path.resolve(__dirname, '..');
|
||||
|
||||
function read(rel) {
|
||||
return fs.readFileSync(path.join(root, rel), 'utf8');
|
||||
}
|
||||
|
||||
function sha256Hex(buf) {
|
||||
return createHash('sha256').update(buf).digest('hex');
|
||||
}
|
||||
|
||||
/** JS mirror of src/update/SwitchOta.lua — contract under test. */
|
||||
function createSwitchOta() {
|
||||
const OTA_RE = /^gen1recomp-(\d+\.\d+\.\d+)-switch\.zip$/;
|
||||
const CHECK_TIMEOUT_SEC = 6;
|
||||
const GAME_NRO_NAME = 'gen1recomp-game.nro';
|
||||
const LAUNCHER_NRO_NAME = 'gen1recomp.nro';
|
||||
const SAVE_DIR_NAME = 'pokemon-love2d';
|
||||
const INSTALL_DIR = 'switch/gen1recomp';
|
||||
|
||||
function parseSemver(s) {
|
||||
if (typeof s !== 'string') return null;
|
||||
const m = s.match(/^v?(\d+)\.(\d+)\.(\d+)$/);
|
||||
if (!m) return null;
|
||||
return { major: +m[1], minor: +m[2], patch: +m[3] };
|
||||
}
|
||||
|
||||
function compareSemver(a, b) {
|
||||
const pa = parseSemver(a);
|
||||
const pb = parseSemver(b);
|
||||
if (!pa && !pb) return 0;
|
||||
if (!pa) return -1;
|
||||
if (!pb) return 1;
|
||||
for (const f of ['major', 'minor', 'patch']) {
|
||||
if (pa[f] < pb[f]) return -1;
|
||||
if (pa[f] > pb[f]) return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function isOtaAssetName(name) {
|
||||
return typeof name === 'string' && OTA_RE.test(name);
|
||||
}
|
||||
|
||||
function findJsonObjectStart(jsonText, pos) {
|
||||
if (!jsonText || pos < 1) return null;
|
||||
let depth = 0;
|
||||
for (let p = pos; p >= 1; p--) {
|
||||
const c = jsonText[p - 1];
|
||||
if (c === '}') depth += 1;
|
||||
else if (c === '{') {
|
||||
if (depth === 0) return p;
|
||||
depth -= 1;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findJsonObjectEnd(jsonText, objectStart) {
|
||||
if (!jsonText || objectStart < 1) return null;
|
||||
if (jsonText[objectStart - 1] !== '{') return null;
|
||||
let depth = 1;
|
||||
for (let p = objectStart + 1; p <= jsonText.length; p++) {
|
||||
const c = jsonText[p - 1];
|
||||
if (c === '{') depth += 1;
|
||||
else if (c === '}') {
|
||||
depth -= 1;
|
||||
if (depth === 0) return p + 1;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseRelease(jsonText) {
|
||||
if (!jsonText) return { ok: false, reason: 'empty_json' };
|
||||
const tagM = jsonText.match(/"tag_name"\s*:\s*"([^"]+)"/);
|
||||
if (!tagM) return { ok: false, reason: 'missing_tag' };
|
||||
const tag = tagM[1];
|
||||
const versionM = tag.match(/^v?(\d+\.\d+\.\d+)$/);
|
||||
if (!versionM) return { ok: false, reason: 'bad_tag' };
|
||||
const version = versionM[1];
|
||||
|
||||
let cursor = 0;
|
||||
while (true) {
|
||||
const nameKeyPos = jsonText.indexOf('"name"', cursor);
|
||||
if (nameKeyPos === -1) break;
|
||||
const tail = jsonText.slice(nameKeyPos);
|
||||
const nameM = tail.match(/"name"\s*:\s*"([^"]+)"/);
|
||||
const name = nameM?.[1];
|
||||
if (name && isOtaAssetName(name)) {
|
||||
const assetStart = findJsonObjectStart(jsonText, nameKeyPos + 1);
|
||||
const assetEnd = assetStart ? findJsonObjectEnd(jsonText, assetStart) : null;
|
||||
if (assetStart && assetEnd && assetEnd > nameKeyPos + 1) {
|
||||
const assetBlock = jsonText.slice(assetStart - 1, assetEnd - 1);
|
||||
const urlM = assetBlock.match(/"browser_download_url"\s*:\s*"([^"]+)"/);
|
||||
const downloadUrl = urlM?.[1];
|
||||
if (downloadUrl) {
|
||||
return {
|
||||
ok: true,
|
||||
tag,
|
||||
version,
|
||||
assetName: name,
|
||||
downloadUrl,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
cursor = nameKeyPos + 6;
|
||||
}
|
||||
return { ok: false, reason: 'missing_ota_asset' };
|
||||
}
|
||||
|
||||
function decideUpdate(installed, release) {
|
||||
if (!parseSemver(installed)) return { status: 'error', reason: 'bad_installed_version' };
|
||||
if (!release?.ok || !release.version) return { status: 'error', reason: 'bad_release' };
|
||||
if (compareSemver(release.version, installed) <= 0) {
|
||||
return { status: 'uptodate', version: installed };
|
||||
}
|
||||
return {
|
||||
status: 'available',
|
||||
version: release.version,
|
||||
assetName: release.assetName,
|
||||
downloadUrl: release.downloadUrl,
|
||||
};
|
||||
}
|
||||
|
||||
function parseSums(text) {
|
||||
const sums = {};
|
||||
if (typeof text !== 'string') return sums;
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const m = line.match(/^([0-9a-fA-F]+)\s+\*?\.?\/?(.*?)\s*$/);
|
||||
if (m) sums[m[2]] = m[1].toLowerCase();
|
||||
}
|
||||
return sums;
|
||||
}
|
||||
|
||||
function verifySha256(assetName, actualHex, sums) {
|
||||
if (!assetName) return { ok: false, reason: 'bad_asset_name' };
|
||||
if (!sums || typeof sums !== 'object') return { ok: false, reason: 'missing_sums' };
|
||||
const expected = sums[assetName];
|
||||
if (!expected) return { ok: false, reason: 'sum_not_found' };
|
||||
if (!actualHex) return { ok: false, reason: 'missing_actual_hash' };
|
||||
if (actualHex.toLowerCase() !== expected.toLowerCase()) {
|
||||
return { ok: false, reason: 'hash_mismatch' };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function planAtomicApply(installDir, verifiedTempPath) {
|
||||
installDir = installDir || INSTALL_DIR;
|
||||
const gameNro = `${installDir}/${GAME_NRO_NAME}`;
|
||||
const launcherNro = `${installDir}/${LAUNCHER_NRO_NAME}`;
|
||||
const partPath = `${gameNro}.part`;
|
||||
const launcherPart = `${launcherNro}.part`;
|
||||
return {
|
||||
steps: [
|
||||
{ op: 'copy_to_part', from: verifiedTempPath, to: partPath },
|
||||
{ op: 'rename', from: partPath, to: gameNro },
|
||||
{ op: 'copy_to_part', from: 'launcher', to: launcherPart },
|
||||
{ op: 'rename', from: launcherPart, to: launcherNro },
|
||||
{ op: 'env_set_next_load', target: gameNro },
|
||||
],
|
||||
preserve: [`${installDir}/${SAVE_DIR_NAME}`],
|
||||
forbidden: [`delete:${installDir}/${SAVE_DIR_NAME}`, `write_direct:${gameNro}`],
|
||||
};
|
||||
}
|
||||
|
||||
function offlinePolicy(elapsedSec, events = {}) {
|
||||
if (events.userSkip) {
|
||||
return { action: 'play_installed', reason: 'user_skip', message: 'update skipped' };
|
||||
}
|
||||
if (events.apiError || events.networkOk === false) {
|
||||
return {
|
||||
action: 'play_installed',
|
||||
reason: 'offline_or_error',
|
||||
message: 'offline or update check failed — play installed version',
|
||||
};
|
||||
}
|
||||
if (typeof elapsedSec === 'number' && elapsedSec >= CHECK_TIMEOUT_SEC) {
|
||||
return {
|
||||
action: 'play_installed',
|
||||
reason: 'timeout',
|
||||
message: `update check timed out after ${CHECK_TIMEOUT_SEC}s`,
|
||||
};
|
||||
}
|
||||
return { action: 'keep_checking', reason: 'in_flight' };
|
||||
}
|
||||
|
||||
return {
|
||||
CHECK_TIMEOUT_SEC,
|
||||
GAME_NRO_NAME,
|
||||
LAUNCHER_NRO_NAME,
|
||||
SAVE_DIR_NAME,
|
||||
compareSemver,
|
||||
isOtaAssetName,
|
||||
parseRelease,
|
||||
decideUpdate,
|
||||
parseSums,
|
||||
verifySha256,
|
||||
planAtomicApply,
|
||||
offlinePolicy,
|
||||
};
|
||||
}
|
||||
|
||||
const M = createSwitchOta();
|
||||
|
||||
const GITHUB_UPLOADER =
|
||||
'"login":"github-actions[bot]",' +
|
||||
'"id":41898282,' +
|
||||
'"node_id":"MDM6Qm90NDE4OTgyODI=",' +
|
||||
'"avatar_url":"https://avatars.githubusercontent.com/in/15368?v=4",' +
|
||||
'"gravatar_id":"",' +
|
||||
'"url":"https://api.github.com/users/github-actions%5Bbot%5D",' +
|
||||
'"html_url":"https://github.com/apps/github-actions",' +
|
||||
'"followers_url":"https://api.github.com/users/github-actions%5Bbot%5D/followers",' +
|
||||
'"following_url":"https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}",' +
|
||||
'"gists_url":"https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}",' +
|
||||
'"starred_url":"https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}",' +
|
||||
'"subscriptions_url":"https://api.github.com/users/github-actions%5Bbot%5D/subscriptions",' +
|
||||
'"organizations_url":"https://api.github.com/users/github-actions%5Bbot%5D/orgs",' +
|
||||
'"repos_url":"https://api.github.com/users/github-actions%5Bbot%5D/repos",' +
|
||||
'"events_url":"https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}",' +
|
||||
'"received_events_url":"https://api.github.com/users/github-actions%5Bbot%5D/received_events",' +
|
||||
'"type":"Bot",' +
|
||||
'"user_view_type":"public",' +
|
||||
'"site_admin":false';
|
||||
|
||||
function buildGithubReleaseJson() {
|
||||
return (
|
||||
'{' +
|
||||
'"tag_name":"v0.1.70",' +
|
||||
'"name":"0.1.70",' +
|
||||
'"assets":[' +
|
||||
'{' +
|
||||
'"url":"https://api.github.com/repos/bryanthaboi/gen1recomp/releases/assets/502823880",' +
|
||||
'"id":502823880,' +
|
||||
'"name":"gen1recomp-0.1.70-switch.zip",' +
|
||||
'"label":"",' +
|
||||
'"uploader":{' +
|
||||
GITHUB_UPLOADER +
|
||||
'},' +
|
||||
'"content_type":"application/zip",' +
|
||||
'"state":"uploaded",' +
|
||||
'"size":9000573,' +
|
||||
'"browser_download_url":"https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.70/gen1recomp-0.1.70-switch.zip"' +
|
||||
'}' +
|
||||
']' +
|
||||
'}'
|
||||
);
|
||||
}
|
||||
|
||||
const sampleRelease = M.parseRelease(
|
||||
JSON.stringify({
|
||||
tag_name: 'v1.5.0',
|
||||
assets: [
|
||||
{
|
||||
name: 'gen1recomp-1.5.0-switch.zip',
|
||||
browser_download_url: 'https://example/switch.zip',
|
||||
},
|
||||
{ name: 'sha256sums.txt', browser_download_url: 'https://example/sums' },
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
test('AC-001: Launcher nativo verifica release no GitHub @spec:AC-001', () => {
|
||||
assert.equal(M.compareSemver('1.2.0', '1.1.0'), 1);
|
||||
assert.equal(M.compareSemver('1.1.0', '1.1.0'), 0);
|
||||
assert.equal(M.compareSemver('1.0.0', '1.1.0'), -1);
|
||||
|
||||
assert.equal(sampleRelease.ok, true);
|
||||
assert.equal(sampleRelease.version, '1.5.0');
|
||||
assert.equal(sampleRelease.assetName, 'gen1recomp-1.5.0-switch.zip');
|
||||
|
||||
assert.equal(M.decideUpdate('1.4.0', sampleRelease).status, 'available');
|
||||
assert.equal(M.decideUpdate('1.5.0', sampleRelease).status, 'uptodate');
|
||||
assert.equal(M.decideUpdate('1.6.0', sampleRelease).status, 'uptodate');
|
||||
|
||||
const missing = M.parseRelease(JSON.stringify({ tag_name: 'v1.5.0', assets: [] }));
|
||||
assert.equal(missing.ok, false);
|
||||
assert.equal(missing.reason, 'missing_ota_asset');
|
||||
|
||||
const githubRelease = M.parseRelease(buildGithubReleaseJson());
|
||||
assert.equal(githubRelease.ok, true);
|
||||
assert.equal(githubRelease.version, '0.1.70');
|
||||
assert.equal(githubRelease.assetName, 'gen1recomp-0.1.70-switch.zip');
|
||||
assert.equal(
|
||||
githubRelease.downloadUrl,
|
||||
'https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.70/gen1recomp-0.1.70-switch.zip'
|
||||
);
|
||||
assert.equal(M.decideUpdate('0.1.69', githubRelease).status, 'available');
|
||||
});
|
||||
|
||||
test('AC-002: Download com verificação SHA-256 @spec:AC-002', () => {
|
||||
const name = 'gen1recomp-1.5.0-switch.zip';
|
||||
const payload = Buffer.from('switch-sd-payload');
|
||||
const hex = sha256Hex(payload);
|
||||
const sums = M.parseSums(`${hex} ${name}\n`);
|
||||
|
||||
assert.equal(M.verifySha256(name, hex, sums).ok, true);
|
||||
assert.equal(M.verifySha256(name, 'deadbeef', sums).reason, 'hash_mismatch');
|
||||
// Invisible-error guard: download without a sum MUST NOT verify.
|
||||
assert.equal(M.verifySha256(name, hex, {}).ok, false);
|
||||
assert.equal(M.verifySha256(name, hex, {}).reason, 'sum_not_found');
|
||||
assert.equal(M.verifySha256(name, '', sums).reason, 'missing_actual_hash');
|
||||
});
|
||||
|
||||
test('AC-003: Aplicação atômica e handoff para o jogo @spec:AC-003', () => {
|
||||
const plan = M.planAtomicApply('switch/gen1recomp', '/tmp/verified.zip');
|
||||
assert.ok(plan.steps.some((s) => s.op === 'copy_to_part'));
|
||||
assert.ok(plan.steps.some((s) => s.op === 'rename'));
|
||||
assert.ok(plan.steps.some((s) => s.op === 'env_set_next_load'));
|
||||
assert.ok(plan.steps.filter((s) => s.op === 'rename').length >= 2, 'renames game + launcher');
|
||||
assert.ok(plan.preserve.some((p) => p.endsWith('pokemon-love2d')));
|
||||
assert.ok(plan.forbidden.some((f) => f.startsWith('delete:') && f.includes('pokemon-love2d')));
|
||||
assert.ok(plan.forbidden.some((f) => f.startsWith('write_direct:')));
|
||||
const renameGame = plan.steps.find((s) => s.op === 'rename' && s.to.endsWith('gen1recomp-game.nro'));
|
||||
assert.ok(renameGame);
|
||||
assert.ok(renameGame.from.endsWith('.part'));
|
||||
const renameLauncher = plan.steps.find((s) => s.op === 'rename' && s.to.endsWith('gen1recomp.nro'));
|
||||
assert.ok(renameLauncher, 'launcher NRO replaced so NACP version stays in sync');
|
||||
});
|
||||
|
||||
test('AC-004: Offline ou falha de rede não trava o jogo @spec:AC-004', () => {
|
||||
assert.equal(M.CHECK_TIMEOUT_SEC, 6);
|
||||
assert.equal(M.offlinePolicy(1, { userSkip: true }).action, 'play_installed');
|
||||
assert.equal(M.offlinePolicy(1, { networkOk: false }).action, 'play_installed');
|
||||
assert.equal(M.offlinePolicy(6, { networkOk: true }).action, 'play_installed');
|
||||
assert.equal(M.offlinePolicy(6, { networkOk: true }).reason, 'timeout');
|
||||
assert.equal(M.offlinePolicy(2, { networkOk: true }).action, 'keep_checking');
|
||||
});
|
||||
|
||||
test('AC-005: Documentação Switch descreve o launcher OTA @spec:AC-005', () => {
|
||||
const install = read('docs/switch-install.md');
|
||||
const build = read('docs/switch-build.md');
|
||||
const updater = read('docs/updater.md');
|
||||
|
||||
for (const [name, text] of [
|
||||
['switch-install', install],
|
||||
['switch-build', build],
|
||||
['updater', updater],
|
||||
]) {
|
||||
assert.match(text, /native OTA launcher|launcher nativo/i, `${name} mentions native OTA launcher`);
|
||||
}
|
||||
|
||||
assert.match(install, /manual|zip|microSD/i, 'manual zip fallback still documented');
|
||||
assert.match(install, /gen1recomp-\*-switch\.zip|switch\.zip/i, 'unified Switch zip documented');
|
||||
assert.doesNotMatch(install, /switch-ota\.zip/, 'legacy separate OTA zip must not be documented');
|
||||
assert.match(install, /Sphaira|forwarder/i, 'Sphaira shortcut version note documented');
|
||||
assert.doesNotMatch(
|
||||
install,
|
||||
/Switch OTA uses `src\/update\/Check\.lua`/i,
|
||||
'must not claim Switch OTA uses LÖVE Check.lua'
|
||||
);
|
||||
assert.match(updater, /Nintendo Switch/i);
|
||||
assert.match(updater, /networkValidated|disabled|desligado|off/i);
|
||||
});
|
||||
|
||||
test('AC-006: LOVE no NX continua sem rede de updater @spec:AC-006', () => {
|
||||
const platform = read('src/core/Platform.lua');
|
||||
assert.match(
|
||||
platform,
|
||||
/networkValidated\s*=\s*not nx/,
|
||||
'Platform.networkValidated must stay false on NX'
|
||||
);
|
||||
assert.doesNotMatch(platform, /networkValidated\s*=\s*true/);
|
||||
|
||||
const boot = read('src/update/Boot.lua');
|
||||
assert.match(boot, /networkValidated/, 'Boot still gates on networkValidated');
|
||||
|
||||
assert.ok(read('src/update/Check.lua').length > 0);
|
||||
assert.ok(read('src/update/check_worker.lua').length > 0);
|
||||
assert.ok(read('src/core/HostShell.lua').length > 0);
|
||||
assert.match(read('src/import/RomImporter.lua'), /updaterAllowed|networkValidated|Check/);
|
||||
assert.ok(read('src/import/LauncherView.lua').length > 0);
|
||||
|
||||
const switchOta = read('src/update/SwitchOta.lua');
|
||||
assert.doesNotMatch(switchOta, /networkValidated\s*=\s*true/);
|
||||
assert.match(switchOta, /never runs this path|remains gated off on NX/i);
|
||||
});
|
||||
|
||||
test('AC-007: Funções puras do protocolo OTA têm testes host-side @spec:AC-007', () => {
|
||||
assert.ok(M.isOtaAssetName('gen1recomp-1.2.3-switch.zip'));
|
||||
assert.equal(M.isOtaAssetName('gen1recomp-1.2.3-switch-ota.zip'), false);
|
||||
assert.equal(M.isOtaAssetName('gen1recomp-1.2.3.love'), false);
|
||||
assert.equal(M.compareSemver('2.0.0', '1.9.9'), 1);
|
||||
assert.equal(M.compareSemver('1.0.0', '1.0.0'), 0);
|
||||
assert.equal(M.compareSemver('0.9.0', '1.0.0'), -1);
|
||||
|
||||
const rel = M.parseRelease(
|
||||
JSON.stringify({
|
||||
tag_name: 'v9.9.9',
|
||||
assets: [
|
||||
{
|
||||
name: 'gen1recomp-9.9.9-switch.zip',
|
||||
browser_download_url: 'https://example/x',
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
assert.equal(rel.version, '9.9.9');
|
||||
|
||||
const sums = M.parseSums('deadbeef unexpected-name.zip\n');
|
||||
assert.equal(
|
||||
M.verifySha256('gen1recomp-9.9.9-switch.zip', 'deadbeef', sums).ok,
|
||||
false,
|
||||
'unexpected filename in sums must not verify OTA asset'
|
||||
);
|
||||
assert.equal(M.verifySha256('gen1recomp-9.9.9-switch.zip', 'anything', {}).ok, false);
|
||||
|
||||
const lua = read('src/update/SwitchOta.lua');
|
||||
assert.ok(fs.existsSync(path.join(root, 'src/update/SwitchOta.lua')));
|
||||
for (const fn of [
|
||||
'compareSemver',
|
||||
'parseRelease',
|
||||
'decideUpdate',
|
||||
'parseSums',
|
||||
'verifySha256',
|
||||
'planAtomicApply',
|
||||
'offlinePolicy',
|
||||
]) {
|
||||
assert.match(lua, new RegExp(`function SwitchOta\\.${fn}`), `Lua exports ${fn}`);
|
||||
}
|
||||
assert.match(lua, /CHECK_TIMEOUT_SEC\s*=\s*6/);
|
||||
assert.match(lua, /switch%.zip|%-switch%.zip/);
|
||||
assert.doesNotMatch(lua, /switch%-ota%.zip|switch-ota\.zip/);
|
||||
});
|
||||
|
||||
test('AC-008: Gates de regressão anti-“erro invisível” @spec:AC-008', () => {
|
||||
const platform = read('src/core/Platform.lua');
|
||||
assert.match(platform, /networkValidated\s*=\s*not nx\s+and\s+not uwp/);
|
||||
|
||||
for (const rel of ['docs/switch-install.md', 'docs/switch-build.md', 'docs/updater.md']) {
|
||||
const text = read(rel);
|
||||
assert.match(text, /native OTA launcher|launcher nativo/i, rel);
|
||||
assert.match(
|
||||
text,
|
||||
/LÖVE self-updater|LOVE self-updater|self-updater LÖVE|updater LÖVE|Check\.lua/i,
|
||||
`${rel} contrasts with LÖVE updater`
|
||||
);
|
||||
}
|
||||
|
||||
const manifest = read('scripts/switch/ota_launcher.manifest');
|
||||
assert.match(manifest, /^OTA_ENABLED=1$/m);
|
||||
assert.match(manifest, /ENTRY_NRO=switch\/gen1recomp\/gen1recomp\.nro/);
|
||||
assert.match(manifest, /GAME_NRO=switch\/gen1recomp\/gen1recomp-game\.nro/);
|
||||
assert.match(manifest, /OTA_ASSET_GLOB=gen1recomp-\*-switch\.zip/);
|
||||
assert.doesNotMatch(manifest, /switch-ota\.zip/);
|
||||
assert.match(manifest, /REQUIRE_SHA256SUMS=1/);
|
||||
});
|
||||
|
||||
test('AC-009: Protocolo C host-testável espelha SwitchOta.lua @spec:AC-009', () => {
|
||||
const proto = read('ports/switch/ota-launcher/src/ota_protocol.c');
|
||||
const header = read('ports/switch/ota-launcher/include/ota_protocol.h');
|
||||
assert.match(header, /OTA_CHECK_TIMEOUT_SEC\s+6/);
|
||||
assert.match(header, /ota_compare_semver/);
|
||||
assert.match(header, /ota_parse_release/);
|
||||
assert.match(header, /ota_verify_sha256/);
|
||||
assert.match(header, /ota_plan_atomic_apply/);
|
||||
assert.match(header, /ota_offline_policy/);
|
||||
assert.match(proto, /sum_not_found/);
|
||||
assert.match(proto, /env_set_next_load/);
|
||||
assert.match(header, /pokemon-love2d/);
|
||||
assert.match(proto, /OTA_SAVE_DIR_NAME/);
|
||||
|
||||
const lua = read('src/update/SwitchOta.lua');
|
||||
assert.match(lua, /CHECK_TIMEOUT_SEC\s*=\s*6/);
|
||||
|
||||
// Compile + run C host tests (gcc)
|
||||
const r = spawnSync(
|
||||
'make',
|
||||
['-C', path.join(root, 'ports/switch/ota-launcher'), 'host-test'],
|
||||
{ encoding: 'utf8' }
|
||||
);
|
||||
assert.equal(r.status, 0, `host-test failed:\n${r.stdout}\n${r.stderr}`);
|
||||
assert.match(r.stdout, /all ota_protocol host tests passed/);
|
||||
});
|
||||
|
||||
test('AC-010: Fonte do launcher e Makefile DEVKITPRO existem @spec:AC-010', () => {
|
||||
for (const rel of [
|
||||
'ports/switch/ota-launcher/Makefile',
|
||||
'ports/switch/ota-launcher/README.md',
|
||||
'ports/switch/ota-launcher/src/main.c',
|
||||
'ports/switch/ota-launcher/src/ota_net.c',
|
||||
'ports/switch/ota-launcher/src/ota_fs.c',
|
||||
'scripts/switch/build_ota_launcher.sh',
|
||||
'docs/switch-build.md',
|
||||
]) {
|
||||
assert.ok(fs.existsSync(path.join(root, rel)), `missing ${rel}`);
|
||||
}
|
||||
const main = read('ports/switch/ota-launcher/src/main.c');
|
||||
assert.match(main, /envSetNextLoad|ota_fs_handoff_to_game/);
|
||||
assert.match(main, /gen1recomp-game\.nro|OTA_GAME_NRO_NAME/);
|
||||
assert.match(main, /Quiet by default|stays quiet|LÖVE self-updater stays off|self-updater stays off/i);
|
||||
assert.match(main, /ota_ui_prompt_update|ota_ui_show_progress/);
|
||||
assert.match(main, /ota_ui_alert_error/);
|
||||
assert.match(main, /Step 1\/3: Downloading/);
|
||||
assert.doesNotMatch(main, /\u2026/, 'OTA UI strings must be ASCII (no Unicode ellipsis)');
|
||||
assert.doesNotMatch(main, /ota_ui_alert\([^)]*Install failed/, 'generic install alert removed');
|
||||
assert.doesNotMatch(main, /consoleInit/, 'no terminal console UI');
|
||||
assert.ok(fs.existsSync(path.join(root, 'ports/switch/ota-launcher/src/ota_ui.c')));
|
||||
const otaUi = read('ports/switch/ota-launcher/src/ota_ui.c');
|
||||
assert.match(otaUi, /COL_RAIL_R|rail|FFD600|255,\s*214/);
|
||||
assert.match(otaUi, /logo\.rgba/);
|
||||
assert.doesNotMatch(otaUi, /stb_image/);
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(root, 'ports/switch/assets/logo.rgba')),
|
||||
'pre-baked OTA logo asset'
|
||||
);
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(root, 'scripts/switch/bake_ota_logo.py')),
|
||||
'logo bake script for regenerating logo.rgba'
|
||||
);
|
||||
assert.match(otaUi, /ota_ui_sanitize_ascii/);
|
||||
assert.match(otaUi, /draw_text_wrapped_centered/);
|
||||
assert.match(otaUi, /ota_ui_alert_error/);
|
||||
assert.match(read('ports/switch/ota-launcher/src/ota_net.c'), /ota_net_init/);
|
||||
assert.match(read('ports/switch/ota-launcher/src/main.c'), /ota_net_init/);
|
||||
assert.match(read('ports/switch/ota-launcher/src/ota_net.c'), /CURLOPT_SSL_VERIFYPEER,\s*1L/);
|
||||
assert.match(read('ports/switch/ota-launcher/src/ota_net.c'), /romfs:\/cacert\.pem/);
|
||||
assert.doesNotMatch(read('ports/switch/ota-launcher/src/ota_net.c'), /CURLOPT_SSL_VERIFYPEER,\s*0L/);
|
||||
assert.match(read('ports/switch/ota-launcher/Makefile'), /^ROMFS\s*:=/m);
|
||||
assert.match(read('ports/switch/ota-launcher/Makefile'), /cacert\.pem/);
|
||||
assert.match(read('ports/switch/ota-launcher/Makefile'), /logo\.rgba/);
|
||||
|
||||
const mk = read('ports/switch/ota-launcher/Makefile');
|
||||
assert.match(mk, /libnx\/switch_rules|DEVKITPRO/);
|
||||
assert.match(mk, /-lcurl/);
|
||||
assert.match(mk, /-lzzip/);
|
||||
assert.match(mk, /ports\/switch\/assets\/icon\.jpg|assets\/icon\.jpg/, 'uses project Switch icon');
|
||||
|
||||
assert.ok(fs.existsSync(path.join(root, 'ports/switch/ota-launcher/src/ota_unzip.c')));
|
||||
assert.match(read('ports/switch/ota-launcher/src/ota_unzip.c'), /zzip\/zzip\.h/);
|
||||
assert.match(read('ports/switch/ota-launcher/src/main.c'), /ota_unzip_extract_file/);
|
||||
assert.match(read('ports/switch/ota-launcher/src/main.c'), /GAME_MEMBER_IN_ZIP|switch\/gen1recomp\//);
|
||||
assert.match(read('ports/switch/ota-launcher/src/main.c'), /LAUNCHER_MEMBER_IN_ZIP|ota_fs_stage_launcher_bootstrap/);
|
||||
assert.match(read('ports/switch/ota-launcher/src/main.c'), /ota_fs_stage_launcher_bootstrap\([\s\S]*\) != 0/);
|
||||
assert.match(read('ports/switch/ota-launcher/src/main.c'), /return 2.*bootstrap|bootstrap.*return 2/i);
|
||||
assert.match(read('ports/switch/ota-launcher/src/ota_fs.c'), /ota_fs_atomic_replace_nro/);
|
||||
assert.match(read('ports/switch/ota-launcher/src/ota_fs.c'), /remove\(dest\)/);
|
||||
assert.doesNotMatch(read('ports/switch/ota-launcher/src/ota_fs.c'), /#ifdef _WIN32[\s\S]*remove\(dest\)/);
|
||||
assert.doesNotMatch(read('scripts/switch/install_devkitpro_deps.sh'), /switch-minizip/);
|
||||
assert.match(read('scripts/switch/install_devkitpro_deps.sh'), /switch-zziplib/);
|
||||
assert.match(read('scripts/switch/install_devkitpro_deps.sh'), /switch-dev/);
|
||||
|
||||
const buildDoc = read('docs/switch-build.md');
|
||||
assert.match(buildDoc, /ports\/switch\/ota-launcher|build_ota_launcher/);
|
||||
assert.match(buildDoc, /native packages.*or.*Docker|native or Docker/i);
|
||||
assert.match(buildDoc, /--fused|install_devkitpro_deps/);
|
||||
assert.match(buildDoc, /Requires DEVKITPRO|DEVKITPRO is[\s\S]*required/i);
|
||||
assert.doesNotMatch(buildDoc, /switch-ota\.zip/);
|
||||
assert.doesNotMatch(buildDoc, /--ota\b/);
|
||||
|
||||
const readme = read('ports/switch/ota-launcher/README.md');
|
||||
assert.match(readme, /DEVKITPRO|devkitPro/i);
|
||||
assert.match(readme, /Docker|devkita64/i);
|
||||
assert.match(readme, /Quiet|quiet|silen/i);
|
||||
});
|
||||
|
||||
test('AC-011: Empacotamento dual-NRO e selftest @spec:AC-011', () => {
|
||||
const pack = read('scripts/switch/pack_sd_zip.sh');
|
||||
assert.match(pack, /LAUNCHER_NRO/);
|
||||
assert.match(pack, /gen1recomp-game\.nro/);
|
||||
assert.match(pack, /version\.txt/);
|
||||
|
||||
assert.equal(
|
||||
fs.existsSync(path.join(root, 'scripts/switch/pack_ota_zip.sh')),
|
||||
false,
|
||||
'separate pack_ota_zip.sh removed — unified *-switch.zip'
|
||||
);
|
||||
|
||||
const buildSwitch = read('scripts/build_switch.sh');
|
||||
assert.doesNotMatch(buildSwitch, /--ota\b/);
|
||||
assert.doesNotMatch(buildSwitch, /pack_ota_zip\.sh/);
|
||||
assert.match(buildSwitch, /build_ota_launcher\.sh/);
|
||||
assert.match(buildSwitch, /dual-NRO SD zip|OTA download asset/i);
|
||||
|
||||
const buildOtaLauncher = read('scripts/switch/build_ota_launcher.sh');
|
||||
assert.match(buildOtaLauncher, /ota_launcher_deps_ready/);
|
||||
assert.match(buildOtaLauncher, /fail_missing_devkitpro/);
|
||||
assert.match(buildSwitch, /preflight_fused_build/);
|
||||
|
||||
const selftest = read('scripts/switch/selftest_build_switch.sh');
|
||||
assert.match(selftest, /dual-NRO|gen1recomp-game\.nro/);
|
||||
assert.match(selftest, /ota_launcher\.manifest/);
|
||||
assert.match(selftest, /OTA uses the same SD zip|same SD zip|legacy OTA-only/i);
|
||||
assert.match(selftest, /ota_ui|framebuffer|no prompt when up to date/i);
|
||||
|
||||
const manifest = read('scripts/switch/ota_launcher.manifest');
|
||||
assert.match(manifest, /^OTA_ENABLED=1$/m);
|
||||
assert.match(manifest, /OTA_ASSET_GLOB=gen1recomp-\*-switch\.zip/);
|
||||
|
||||
// Run the dual-NRO portion via full offline selftest (includes legacy + OTA)
|
||||
const r = spawnSync('bash', [path.join(root, 'scripts/switch/selftest_build_switch.sh')], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.equal(r.status, 0, `selftest failed:\n${r.stdout}\n${r.stderr}`);
|
||||
assert.match(r.stdout, /dual-NRO OTA layout/);
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
-- Issue #899: Red's extracted cache lives under red/ like blue/ and
|
||||
-- yellow/, and a legacy root cache (pre-fix installs) is migrated on first
|
||||
-- boot instead of reading as "never imported".
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check = T.check
|
||||
local eq = T.eq
|
||||
|
||||
local CacheFs = require("src.import.CacheFs")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
|
||||
eq(GameVersion.cachePrefix("red"), "red/",
|
||||
"Red's cache is namespaced under red/")
|
||||
|
||||
-- legacy layout: the marker and both generated trees at the save-dir root
|
||||
love.filesystem.write("rom-cache.complete", "rom-cache-v9:abc")
|
||||
love.filesystem.write("data/generated/maps.lua", "return {}")
|
||||
love.filesystem.write("data/generated/constants.lua", "return {}")
|
||||
love.filesystem.write("assets/generated/fonts/font.png", "font-bytes")
|
||||
|
||||
CacheFs.migrateLegacyRedCache()
|
||||
|
||||
eq(love.filesystem.read("red/rom-cache.complete"), "rom-cache-v9:abc",
|
||||
"the marker moved under red/")
|
||||
eq(love.filesystem.read("red/data/generated/maps.lua"), "return {}",
|
||||
"the data tree moved under red/")
|
||||
eq(love.filesystem.read("red/assets/generated/fonts/font.png"), "font-bytes",
|
||||
"the assets tree moved under red/")
|
||||
check(love.filesystem.read("rom-cache.complete") == nil,
|
||||
"the root marker is gone")
|
||||
check(love.filesystem.read("data/generated/maps.lua") == nil,
|
||||
"the root data tree is gone")
|
||||
check(love.filesystem.read("assets/generated/fonts/font.png") == nil,
|
||||
"the root assets tree is gone")
|
||||
|
||||
-- idempotent: a second run leaves the migrated tree alone
|
||||
CacheFs.migrateLegacyRedCache()
|
||||
eq(love.filesystem.read("red/rom-cache.complete"), "rom-cache-v9:abc",
|
||||
"a second run keeps the migrated cache")
|
||||
|
||||
-- an existing red/ cache wins over a legacy root leftover: no clobber
|
||||
love.filesystem.write("rom-cache.complete", "rom-cache-v9:STALE")
|
||||
CacheFs.migrateLegacyRedCache()
|
||||
eq(love.filesystem.read("red/rom-cache.complete"), "rom-cache-v9:abc",
|
||||
"an existing red/ cache is not clobbered")
|
||||
check(love.filesystem.read("rom-cache.complete") ~= nil,
|
||||
"the unmigrated leftover stays (a stale-marker re-import handles it)")
|
||||
love.filesystem.remove("rom-cache.complete")
|
||||
|
||||
-- mountVersion("red") overlays red/ at the un-prefixed paths, like blue/
|
||||
love.filesystem._mounts = {}
|
||||
check(CacheFs.mountVersion("red") == true, "mountVersion(red) returns true")
|
||||
eq(love.filesystem.read("assets/generated/fonts/font.png"), "font-bytes",
|
||||
"post-mount probe reads red assets at the un-prefixed path")
|
||||
eq(love.filesystem.read("data/generated/constants.lua"), "return {}",
|
||||
"post-mount probe reads red data at the un-prefixed path")
|
||||
|
||||
-- no legacy cache at all: migration is a no-op, not an error
|
||||
love.filesystem.remove("red/rom-cache.complete")
|
||||
love.filesystem.remove("red/data/generated/maps.lua")
|
||||
love.filesystem.remove("red/data/generated/constants.lua")
|
||||
love.filesystem.remove("red/assets/generated/fonts/font.png")
|
||||
CacheFs.migrateLegacyRedCache()
|
||||
check(love.filesystem.read("red/rom-cache.complete") == nil,
|
||||
"nothing to migrate invents nothing")
|
||||
|
||||
T.finish()
|
||||
@@ -0,0 +1,145 @@
|
||||
-- The battle hit sounds must carry pokered's wFrequencyModifier onto the
|
||||
-- noise channel (#826; #902 reports the same swap). PlayApplyingAttackSound
|
||||
-- (engine/battle/animations.asm) picks SFX_DAMAGE / SFX_SUPER_EFFECTIVE /
|
||||
-- SFX_NOT_VERY_EFFECTIVE off wDamageMultipliers and writes a frequency
|
||||
-- modifier with it ($20 / $e0 / $50), and Audio2_ApplyFrequencyModifier adds
|
||||
-- that to the polynomial-counter byte -- the low byte of NR43 -- with 8-bit
|
||||
-- wrap (audio/engine_2.asm). The three programs are CHAN8-only, so that byte
|
||||
-- IS their pitch. Dropped, the super effective hit reads as the duller of
|
||||
-- the two: super effective's tail (shifts 3 then 6) sits below not very
|
||||
-- effective's (5, 4, 2, 2), which is exactly the "swapped" sound #826 and
|
||||
-- #902 describe. With the modifier on, super effective goes to shifts 1/4 --
|
||||
-- a bright crack -- and not very effective to 10/9/7/7 -- a dull thud.
|
||||
--
|
||||
-- ROM-free: ChipAsm blobs stand in for the sfx headers, so nothing here
|
||||
-- reads data/generated/. The noise-note streams below are transcribed from
|
||||
-- audio/sfx/{damage,super_effective,not_very_effective}.asm, so the "sounds
|
||||
-- swapped when bare" ordering is asserted against the real program shape.
|
||||
-- luajit tests/engine/hit_sfx_noise_pitch_bug826.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check = T.check
|
||||
|
||||
love = require("tests.love_stub")
|
||||
|
||||
local ChipAsm = require("src.audio.ChipAsm")
|
||||
local ChipSynth = require("src.core.ChipSynth")
|
||||
|
||||
local data = { audio = {} }
|
||||
|
||||
-- noise_note len, volume, fade, parameter (the asm streams, verbatim)
|
||||
local HITS = {
|
||||
{
|
||||
name = "Damage",
|
||||
pitch = 0x20,
|
||||
notes = {
|
||||
{ len = 2, parameter = 0x44 },
|
||||
{ len = 2, parameter = 0x14 },
|
||||
{ len = 15, parameter = 0x32 },
|
||||
},
|
||||
want = { 0x64, 0x34, 0x52 },
|
||||
},
|
||||
{
|
||||
name = "Super_Effective",
|
||||
pitch = 0xe0,
|
||||
notes = {
|
||||
{ len = 4, parameter = 0x34 },
|
||||
{ len = 15, parameter = 0x64 },
|
||||
},
|
||||
want = { 0x14, 0x44 },
|
||||
},
|
||||
{
|
||||
name = "Not_Very_Effective",
|
||||
pitch = 0x50,
|
||||
notes = {
|
||||
{ len = 4, parameter = 0x55 },
|
||||
{ len = 2, parameter = 0x44 },
|
||||
{ len = 8, parameter = 0x22 },
|
||||
{ len = 15, parameter = 0x21 },
|
||||
},
|
||||
want = { 0xa5, 0x94, 0x72, 0x71 },
|
||||
},
|
||||
}
|
||||
|
||||
local function hitDef(notes)
|
||||
local program = {}
|
||||
for _, n in ipairs(notes) do
|
||||
program[#program + 1] = {
|
||||
noiseNote = { len = n.len, volume = 15, fade = 1, parameter = n.parameter },
|
||||
}
|
||||
end
|
||||
return ChipAsm.sfx{ channels = { { hw = 4, program = program } } }
|
||||
end
|
||||
|
||||
-- every noise note the program emits, as { parameter, duration }, walking
|
||||
-- one event at a time by marking each consumed
|
||||
local function noiseNotes(def, offset)
|
||||
local engine = ChipSynth.newEngine(data, def, {
|
||||
sfx = true, allowLoops = false, frequencyOffset = offset,
|
||||
})
|
||||
local channel = assert(engine.channels[1], "hit sfx uses exactly CHAN8")
|
||||
local out = {}
|
||||
while not engine:finished() do
|
||||
channel:sample()
|
||||
local event = channel.event
|
||||
if not event then break end
|
||||
if event.noiseParameter ~= nil then
|
||||
out[#out + 1] = { parameter = event.noiseParameter, duration = event.duration }
|
||||
end
|
||||
event.sample = event.samples -- force the walk on to the next event
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- NR43 shift-clock nibble, weighted by each note's on-air duration: the
|
||||
-- number #826/#902 ears actually compare (higher = duller)
|
||||
local function weightedShift(notes)
|
||||
local total, sum = 0, 0
|
||||
for _, n in ipairs(notes) do
|
||||
total = total + n.duration
|
||||
sum = sum + math.floor(n.parameter / 16) * n.duration
|
||||
end
|
||||
return total > 0 and sum / total or 0
|
||||
end
|
||||
|
||||
local results = {}
|
||||
for _, hit in ipairs(HITS) do
|
||||
local def = hitDef(hit.notes)
|
||||
local bare = noiseNotes(def, 0)
|
||||
local pitched = noiseNotes(def, hit.pitch)
|
||||
results[hit.name] = { bare = bare, pitched = pitched }
|
||||
check(#bare == #hit.notes,
|
||||
hit.name .. " program emits " .. #hit.notes .. " notes, not " .. #bare)
|
||||
for i, n in ipairs(hit.notes) do
|
||||
check(bare[i] and bare[i].parameter == n.parameter,
|
||||
("%s note %d reads NR43 $%02x unmodified"):format(hit.name, i, n.parameter))
|
||||
check(pitched[i] and pitched[i].parameter == hit.want[i],
|
||||
("%s note %d reads NR43 $%02x once $%02x is applied"):format(
|
||||
hit.name, i, hit.want[i], hit.pitch))
|
||||
end
|
||||
end
|
||||
|
||||
-- the ordering that IS the bug: unpitched, super effective ends duller than
|
||||
-- not very effective, so they sound swapped; pitched, the bright crack lands
|
||||
-- on super effective and the dull thud on not very effective
|
||||
local superBare = weightedShift(results.Super_Effective.bare)
|
||||
local nveBare = weightedShift(results.Not_Very_Effective.bare)
|
||||
check(superBare > nveBare,
|
||||
("bare, super effective (%.2f) reads duller than not very effective (%.2f)"):format(
|
||||
superBare, nveBare))
|
||||
local superPitched = weightedShift(results.Super_Effective.pitched)
|
||||
local nvePitched = weightedShift(results.Not_Very_Effective.pitched)
|
||||
check(superPitched < nvePitched,
|
||||
("pitched, super effective (%.2f) is the brighter hit, not very effective (%.2f) the duller"):format(
|
||||
superPitched, nvePitched))
|
||||
|
||||
-- the neutral hit shifts a shade duller than it used to be, per pokered
|
||||
local damageBare = weightedShift(results.Damage.bare)
|
||||
local damagePitched = weightedShift(results.Damage.pitched)
|
||||
check(damagePitched > damageBare,
|
||||
("the neutral hit dulls a little under $20 (%.2f -> %.2f)"):format(
|
||||
damageBare, damagePitched))
|
||||
|
||||
T.finish("hit sfx noise pitch (#826/#902)")
|
||||
@@ -0,0 +1,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.
|
||||
|
||||
@@ -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")
|
||||
@@ -0,0 +1,156 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local S = require("tests.harness").suite("UWP baseroms discovery")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Platform = require("src.core.Platform")
|
||||
local RomImporter = require("src.import.RomImporter")
|
||||
|
||||
love.data = love.data or {}
|
||||
love.system = love.system or {}
|
||||
love.filesystem = love.filesystem or {}
|
||||
|
||||
local MiB = 1024 * 1024
|
||||
local roms = {
|
||||
["baseroms/z-red.gb"] = string.rep("R", MiB),
|
||||
["baseroms/a-blue.gb"] = string.rep("B", MiB),
|
||||
["baseroms/b-yellow.gbc"] = string.rep("Y", MiB),
|
||||
["baseroms/small.gb"] = "small",
|
||||
["baseroms/unknown.gb"] = string.rep("?", MiB),
|
||||
}
|
||||
|
||||
local saved = {
|
||||
hash = love.data.hash,
|
||||
encode = love.data.encode,
|
||||
getOS = love.system.getOS,
|
||||
pickFile = love.system.pickFile,
|
||||
getPickedFile = love.system.getPickedFile,
|
||||
read = love.filesystem.read,
|
||||
getInfo = love.filesystem.getInfo,
|
||||
getDirectoryItems = love.filesystem.getDirectoryItems,
|
||||
createDirectory = love.filesystem.createDirectory,
|
||||
isReady = RomImporter.isReady,
|
||||
}
|
||||
|
||||
love.data.hash = function(_, data) return data:sub(1, 1) end
|
||||
love.data.encode = function(_, _, digest)
|
||||
if digest == "R" then return GameVersion.info("red").sha1 end
|
||||
if digest == "B" then return GameVersion.info("blue").sha1 end
|
||||
if digest == "Y" then return GameVersion.info("yellow").sha1 end
|
||||
return "0000000000000000000000000000000000000000"
|
||||
end
|
||||
|
||||
local reads, listings, picks = 0, 0, 0
|
||||
love.filesystem.read = function(path)
|
||||
reads = reads + 1
|
||||
return roms[path]
|
||||
end
|
||||
love.filesystem.getInfo = function(path, filter)
|
||||
if path == "baseroms" then
|
||||
return filter and nil or { type = "directory" }
|
||||
end
|
||||
local data = roms[path]
|
||||
if data and (not filter or filter == "file") then
|
||||
return { type = "file", size = #data }
|
||||
end
|
||||
return nil
|
||||
end
|
||||
love.filesystem.getDirectoryItems = function(path)
|
||||
if path ~= "baseroms" then return {} end
|
||||
listings = listings + 1
|
||||
return { "z-red.gb", "small.gb", "unknown.gb", "b-yellow.gbc", "a-blue.gb" }
|
||||
end
|
||||
love.filesystem.createDirectory = function() return true end
|
||||
love.system.pickFile = function()
|
||||
picks = picks + 1
|
||||
return true
|
||||
end
|
||||
love.system.getPickedFile = function() return nil end
|
||||
|
||||
local function importer(ready)
|
||||
return setmetatable({
|
||||
baseRomDiscovery = true,
|
||||
baseRoms = {},
|
||||
ready = ready or { red = false, blue = false, yellow = false },
|
||||
returning = {},
|
||||
workState = nil,
|
||||
nativePicker = true,
|
||||
isNX = false,
|
||||
}, RomImporter)
|
||||
end
|
||||
|
||||
local allReady = importer({ red = true, blue = true, yellow = true })
|
||||
allReady:_queueBaseRomScan()
|
||||
eq(allReady.baseRomScan.state, "done", "ready launcher skips discovery")
|
||||
eq(listings, 0, "ready launcher does not enumerate baseroms")
|
||||
|
||||
local imp = importer()
|
||||
imp:_queueBaseRomScan()
|
||||
for _ = 1, 5 do
|
||||
local before = reads
|
||||
imp:_stepBaseRomScan()
|
||||
check(reads - before <= 1, "discovery reads at most one ROM per step")
|
||||
end
|
||||
eq(listings, 1, "discovery enumerates baseroms once")
|
||||
eq(imp.baseRoms.blue.name, "a-blue.gb", "Blue ROM is detected by SHA-1")
|
||||
eq(imp.baseRoms.yellow.name, "b-yellow.gbc", "Yellow ROM is detected by SHA-1")
|
||||
eq(imp.baseRoms.red.name, "z-red.gb", "Red ROM is detected by SHA-1")
|
||||
eq(reads, 4, "wrong-sized files are skipped before reading")
|
||||
eq(imp.baseRomScan.state, "done", "discovery stops when every missing ROM is found")
|
||||
|
||||
local settledReads, settledListings = reads, listings
|
||||
for _ = 1, 10 do imp:_stepBaseRomScan() end
|
||||
eq(reads, settledReads, "completed discovery does not read again")
|
||||
eq(listings, settledListings, "completed discovery does not enumerate again")
|
||||
|
||||
local detected = importer()
|
||||
detected.baseRoms.red = { path = "baseroms/z-red.gb", name = "z-red.gb" }
|
||||
detected.startData = function(self, data, name)
|
||||
self.started = { data = data, name = name }
|
||||
end
|
||||
detected:choose("red")
|
||||
eq(detected.started.name, "z-red.gb", "detected ROM uses the normal import path")
|
||||
eq(picks, 0, "detected ROM does not open the picker")
|
||||
check(roms["baseroms/z-red.gb"] ~= nil, "detected ROM remains in baseroms")
|
||||
|
||||
local missing = importer()
|
||||
missing.baseRoms.red = { path = "baseroms/gone.gb", name = "gone.gb" }
|
||||
missing:choose("red")
|
||||
check(missing.notice ~= nil, "missing detected ROM reports a notice")
|
||||
eq(picks, 0, "missing detected ROM does not open the picker unexpectedly")
|
||||
missing:choose("red")
|
||||
eq(picks, 1, "the next import attempt falls back to the native picker")
|
||||
|
||||
local rescanned = importer({ red = true, blue = true, yellow = true })
|
||||
rescanned.baseRoms.red = { path = "baseroms/z-red.gb", name = "z-red.gb" }
|
||||
rescanned:reimport("red")
|
||||
check(rescanned.baseRoms.red == nil, "re-import clears the detected ROM")
|
||||
eq(rescanned.baseRomScan.state, "queued", "re-import queues one fresh scan")
|
||||
|
||||
RomImporter.isReady = function() return false end
|
||||
love.system.getOS = function() return "UWP" end
|
||||
Platform._resetForTests()
|
||||
local launcher = RomImporter.new(function() end, { launcher = true })
|
||||
check(launcher.baseRomDiscovery, "UWP launcher enables baseroms discovery")
|
||||
local importOnly = RomImporter.new(function() end)
|
||||
check(not importOnly.baseRomDiscovery, "non-launcher UWP import stays unchanged")
|
||||
love.system.getOS = function() return "Windows" end
|
||||
Platform._resetForTests()
|
||||
local desktop = RomImporter.new(function() end, { launcher = true })
|
||||
check(not desktop.baseRomDiscovery, "desktop launcher does not scan baseroms")
|
||||
|
||||
love.data.hash = saved.hash
|
||||
love.data.encode = saved.encode
|
||||
love.system.getOS = saved.getOS
|
||||
love.system.pickFile = saved.pickFile
|
||||
love.system.getPickedFile = saved.getPickedFile
|
||||
love.filesystem.read = saved.read
|
||||
love.filesystem.getInfo = saved.getInfo
|
||||
love.filesystem.getDirectoryItems = saved.getDirectoryItems
|
||||
love.filesystem.createDirectory = saved.createDirectory
|
||||
RomImporter.isReady = saved.isReady
|
||||
Platform._resetForTests()
|
||||
|
||||
S.finish()
|
||||
@@ -152,27 +152,28 @@ check(comment_wf:find("comment-tag: switch-build-result", 1, true)
|
||||
|
||||
-- --- SWCI-08 / SWCI-09: docs CI vs release ---
|
||||
local build_doc = read("docs/switch-build.md")
|
||||
local development = read("docs/switch-development.md")
|
||||
local readme = read("README.md")
|
||||
|
||||
mustContain(build_doc, "Path-gated", "switch-build.md")
|
||||
mustContain(build_doc, "ubuntu-latest", "switch-build.md")
|
||||
mustContain(build_doc, "selftest_build_switch.sh", "switch-build.md")
|
||||
mustContain(build_doc, "canonical", "switch-build.md")
|
||||
mustContain(build_doc, "main repo", "switch-build.md")
|
||||
mustContain(build_doc, "gen1recomp-switch-nro", "switch-build.md")
|
||||
mustContain(build_doc, "switch-build-result", "switch-build.md")
|
||||
mustContain(build_doc, "hard gate", "switch-build.md")
|
||||
mustContain(build_doc, "continue-on-error", "switch-build.md")
|
||||
mustContain(build_doc, "nacptool", "switch-build.md")
|
||||
mustContain(build_doc, "Docker", "switch-build.md")
|
||||
mustContain(build_doc, "Fork → canonical", "switch-build.md")
|
||||
mustContain(build_doc, "Fork PRs into the main repo", "switch-build.md")
|
||||
mustContain(build_doc, "skip Switch fused", "switch-build.md")
|
||||
|
||||
mustContain(development, "Switch CI", "switch-development.md")
|
||||
mustContain(development, "selftest_build_switch.sh", "switch-development.md")
|
||||
mustContain(build_doc, "CI and release", "switch-build.md")
|
||||
mustNotContain(build_doc, "switch-development", "switch-build.md")
|
||||
mustNotContain(build_doc, "switch-hardware-evidence", "switch-build.md")
|
||||
|
||||
mustContain(readme, "CI vs release", "README.md")
|
||||
mustContain(readme, "switch-build.md", "README.md")
|
||||
mustNotContain(readme, "switch-development", "README.md")
|
||||
mustNotContain(readme, "switch-hardware-evidence", "README.md")
|
||||
|
||||
-- --- SWFIX-03: headless suite also runs the content gates ---
|
||||
local test_sh = read("scripts/test.sh")
|
||||
|
||||
@@ -32,7 +32,7 @@ mustContain(transfer, "imports/", "transfer")
|
||||
mustContain(transfer, "imports/mods/", "transfer")
|
||||
mustContain(transfer, "1: SD Card", "transfer")
|
||||
mustContain(transfer, "Scan again", "transfer")
|
||||
mustContain(transfer, "one contributor example", "transfer")
|
||||
mustContain(transfer, "documented example", "transfer")
|
||||
mustContain(transfer, "Linux", "transfer")
|
||||
mustContain(transfer, "Windows", "transfer")
|
||||
mustContain(transfer, "macOS", "transfer")
|
||||
@@ -46,7 +46,7 @@ mustContain(transfer, "Portable Devices", "transfer")
|
||||
mustContain(transfer, "MTP USB Device", "transfer")
|
||||
mustContain(transfer, "AppleDouble", "transfer")
|
||||
mustContain(transfer, "card reader", "transfer")
|
||||
mustContain(transfer, "Canonical methods", "transfer")
|
||||
mustContain(transfer, "Transfer methods", "transfer")
|
||||
mustContain(transfer, "OpenMTP", "transfer")
|
||||
mustContain(transfer, "only one", "transfer")
|
||||
mustContain(transfer, "USB-C", "transfer")
|
||||
@@ -54,46 +54,27 @@ mustContain(transfer, "USB-C", "transfer")
|
||||
mustContain(transfer, "If MTP is unavailable or flaky on Linux", "transfer")
|
||||
mustContain(transfer, "If MTP is unavailable or flaky on Windows", "transfer")
|
||||
mustContain(transfer, "Joy-Con display chords (stock engine)", "transfer")
|
||||
mustNotContain(transfer, "VoxelMod", "transfer")
|
||||
mustNotContain(transfer, "switch-development", "transfer")
|
||||
mustNotContain(transfer, "switch-hardware-evidence", "transfer")
|
||||
|
||||
local install = read("docs/switch-install.md")
|
||||
local build = read("docs/switch-build.md")
|
||||
mustContain(install, "switch-transfer.md", "install")
|
||||
mustContain(install, "## Community mods", "install")
|
||||
mustContain(install, "Select + **A**", "install")
|
||||
mustContain(install, "Select + **L**", "install")
|
||||
mustContain(install, "COLORS", "install")
|
||||
mustContain(install, "TILT", "install")
|
||||
mustContain(install, "GBC FX", "install")
|
||||
mustContain(install, "PERFORMANCE", "install")
|
||||
mustContain(install, "Stock engine effect", "install")
|
||||
mustContain(install, "## Limitations", "install")
|
||||
mustContain(install, "Launch with title override", "install")
|
||||
mustNotContain(install, "VoxelMod", "install")
|
||||
mustNotContain(install, "switch-development", "install")
|
||||
mustContain(build, "switch-transfer.md", "build")
|
||||
mustContain(build, "nxlink", "build")
|
||||
|
||||
local development = read("docs/switch-development.md")
|
||||
mustContain(development, "switch-transfer.md", "development")
|
||||
mustContain(development, "## Joy-Con display chords (Select + face)", "development")
|
||||
mustContain(development, "Stock engine effect", "development")
|
||||
mustContain(development, "claimed by the engine before mod pipeline", "development")
|
||||
mustContain(development, "Select + **L**", "development")
|
||||
mustContain(development, "OPTIONS → PERFORMANCE", "development")
|
||||
mustNotContain(development, "VoxelMod", "development")
|
||||
check(development:find("Non-macOS contributor MTP runbooks", 1, true) == nil,
|
||||
"development must not list Non-macOS runbooks as absent")
|
||||
|
||||
local evidence = read("docs/switch-hardware-evidence.md")
|
||||
local nxStart = evidence:find("## NXMOD-12", 1, true)
|
||||
check(nxStart ~= nil, "NXMOD-12 section present")
|
||||
local nxmod = evidence:sub(nxStart)
|
||||
mustContain(nxmod, "**pass**", "NXMOD-12")
|
||||
mustContain(nxmod, "531", "NXMOD-12")
|
||||
mustContain(nxmod, "switch-oled-photos", "NXMOD-12")
|
||||
mustContain(nxmod, "IMG_1766.jpg", "NXMOD-12")
|
||||
mustContain(nxmod, "IMG_1771.jpg", "NXMOD-12")
|
||||
mustContain(nxmod, "Community mod zip", "NXMOD-12")
|
||||
mustNotContain(nxmod, "VoxelMod", "NXMOD-12")
|
||||
check(nxmod:find("Status | **pending**", 1, true) == nil
|
||||
and nxmod:find("| **pending** |", 1, true) == nil,
|
||||
"NXMOD-12 must not keep pending status/checklist")
|
||||
mustNotContain(build, "switch-development", "build")
|
||||
mustNotContain(build, "switch-hardware-evidence", "build")
|
||||
|
||||
T.finish("switch_transfer_docs_test")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# switch-probe — love-nx hardware probe
|
||||
# switch-probe: love-nx hardware probe
|
||||
|
||||
**NOT FOR RELEASE.** This package is a developer-only diagnostic for Nintendo Switch (love-nx). Do not ship it inside `game.love` or release NRO payloads.
|
||||
|
||||
@@ -11,7 +11,9 @@ Validate Phase 0 runtime facts on real Switch hardware before running the full G
|
||||
- Save directory path (`love.filesystem.getSaveDirectory()`)
|
||||
- Gamepad / joystick / touch event logging
|
||||
|
||||
To date this probe has only been run on **Switch OLED** (see `docs/switch-hardware-evidence.md`); other models are untested. Deploy beside `gen1recomp.nro` remains **manual** (MTP); see `docs/switch-development.md`.
|
||||
To date this probe has only been run on **Switch OLED**; other models are
|
||||
untested. Deploy beside `gen1recomp.nro` remains **manual** (MTP). See
|
||||
`docs/switch-transfer.md`.
|
||||
|
||||
## Fields shown on screen
|
||||
|
||||
@@ -36,7 +38,9 @@ mkdir -p .bazinga/work
|
||||
zip -9 -j .bazinga/work/switch-probe.love tools/switch-probe/main.lua tools/switch-probe/conf.lua
|
||||
```
|
||||
|
||||
Deploy beside `gen1recomp.nro` (loose mode) per `docs/switch-development.md`, renaming to `game.love` only for a probe run — use a separate SD folder so probe and game builds do not mix.
|
||||
Deploy beside `gen1recomp.nro` (loose mode) per `docs/switch-build.md` and
|
||||
`docs/switch-transfer.md`. Rename to `game.love` only for a probe run. Use a
|
||||
separate SD folder so probe and game builds do not mix.
|
||||
|
||||
## Desktop smoke (optional)
|
||||
|
||||
|
||||