Merge pull request #761 from bryanthaboi/dev

bugs and switch
This commit is contained in:
bryanthaboi
2026-08-03 17:22:17 -04:00
committed by GitHub
106 changed files with 9794 additions and 318 deletions
+79
View File
@@ -102,6 +102,85 @@ jobs:
if: ${{ always() && github.repository == 'bryanthaboi/gen1recomp' }}
run: security delete-keychain "$RUNNER_TEMP/gen1recomp-ci-signing.keychain-db" 2>/dev/null || true
switch-changes:
name: detect Switch changes
runs-on: ubuntu-latest
outputs:
changed: ${{ steps.paths.outputs.changed }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- id: paths
env:
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
HEAD_SHA: ${{ github.sha }}
run: |
if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then
echo "changed=true" >> "$GITHUB_OUTPUT"
exit 0
fi
if git diff --name-only "$BASE_SHA" "$HEAD_SHA" | grep -Eq '^(scripts/build_switch\.sh$|scripts/switch/|docs/switch-.*\.md$|tests/switch_ci_workflows_test\.lua$|tests/switch_transfer_docs_test\.lua$|\.github/workflows/(ci|release|switch-artifact-comment)\.yml$|src/core/(NxAssetOverlay|Platform|GameVersion)\.lua$|src/import/CacheFs\.lua$|tests/engine/(assets_version_fallback|nx_generated_guard|nx_yellow_boot|switch_diagnostics)_test\.lua$|tests/engine/platform_nx)'; then
echo "changed=true" >> "$GITHUB_OUTPUT"
else
echo "changed=false" >> "$GITHUB_OUTPUT"
fi
switch-selftest:
name: Switch offline selftest
needs: switch-changes
if: needs.switch-changes.outputs.changed == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: install luajit
run: sudo apt-get update && sudo apt-get install -y luajit
- name: Switch offline selftest
run: bash scripts/switch/selftest_build_switch.sh
- name: verify_payload self-test
run: bash scripts/switch/verify_payload.sh --self-test
- name: Switch CI workflow content gate
run: luajit tests/switch_ci_workflows_test.lua
- name: Switch transfer docs content gate
run: luajit tests/switch_transfer_docs_test.lua
# NX runtime regressions gate this job via switch-changes; run the NX
# engine suites here too so a PR touching them gets feedback on the
# fork-safe ubuntu runner before the self-hosted Mac build.
- name: NX engine suites (headless)
run: |
luajit tests/engine/assets_version_fallback_test.lua
luajit tests/engine/nx_generated_guard_test.lua
luajit tests/engine/nx_yellow_boot_test.lua
switch-build:
name: Switch fused build
needs: [switch-changes, switch-selftest]
if: |
always()
&& needs.switch-changes.outputs.changed == 'true'
&& needs.switch-selftest.result == 'success'
&& github.repository == 'bryanthaboi/gen1recomp'
&& (github.event_name != 'pull_request'
|| github.event.pull_request.head.repo.full_name == github.repository)
runs-on: ["self-hosted", "macOS"]
steps:
- uses: actions/checkout@v7
- name: Build Switch fused NRO
run: |
set -euo pipefail
VER="$(printf '%s' "$GITHUB_SHA" | cut -c1-7)"
scripts/build_switch.sh --fetch --fused --version "$VER"
echo "SWITCH_VER=$VER" >> "$GITHUB_ENV"
- name: upload Switch NRO artifact
uses: actions/upload-artifact@v7
with:
name: gen1recomp-switch-nro
path: |
dist/switch/gen1recomp-${{ env.SWITCH_VER }}-switch.nro
dist/switch/gen1recomp-${{ env.SWITCH_VER }}-switch.nro.sha256
if-no-files-found: error
retention-days: 7
headless:
name: headless suites (no ROM)
runs-on: ubuntu-latest
+1 -7
View File
@@ -29,13 +29,7 @@ jobs:
[ -n "$pr_number" ] || exit 0
echo "artifact_url=https://github.com/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts/$artifact_id" >> "$GITHUB_OUTPUT"
echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT"
- name: Delete existing comment
if: steps.artifact.outputs.pr_number != ''
uses: izhangzhihao/delete-comment@master
with:
github_token: ${{ github.token }}
delete_user_name: github-actions[bot]
issue_number: ${{ steps.artifact.outputs.pr_number }}
# Upsert via comment-tag only — do not delete-all bot comments (clobbers Switch).
- name: Get build info
id: build-info
env:
+33 -10
View File
@@ -1,8 +1,9 @@
name: Release
# Builds the macOS, Windows, and Linux desktop apps, an Android APK, an iOS
# IPA, and the Anbernic RG34XXSP (Stock OS 64-bit MOD / PortMaster) port on
# the self-hosted Mac runner, and publishes them as a GitHub Release.
# IPA, a Nintendo Switch SD-ready zip (experimental), and the Anbernic RG34XXSP
# (Stock OS 64-bit MOD / PortMaster) port on the self-hosted Mac runner, and
# publishes them as a GitHub Release.
#
# Versioning:
# - First ever release is 0.1.0.
@@ -196,6 +197,17 @@ jobs:
--version "${{ steps.ver.outputs.version }}"
fi
- name: Build Switch
run: |
set -euo pipefail
# Hard-fail gate: Switch ships with every release (never soft-fail).
# PR CI is path-gated (ubuntu selftest + canonical fused); release
# always builds Switch regardless of which files changed.
# Needs native switch-tools (nacptool/elf2nro) and/or Docker on the
# Mac self-hosted runner; see docs/switch-build.md.
scripts/build_switch.sh --fetch --fused \
--version "${{ steps.ver.outputs.version }}"
- name: Build Anbernic RG34XXSP port
run: |
set -euo pipefail
@@ -258,6 +270,12 @@ jobs:
[ -f "$ipa" ] || { echo "::error::$ipa not found (expected from scripts/build_ios.sh --device)"; exit 1; }
cp "$ipa" "$outdir/gen1recomp-${v}-ios.ipa"
swzip="dist/switch/gen1recomp-${v}-switch.zip"
[ -f "$swzip" ] || { echo "::error::$swzip not found (expected from scripts/build_switch.sh --fused → pack_sd_zip.sh)"; exit 1; }
cp "$swzip" "$outdir/gen1recomp-${v}-switch.zip"
# Local fused .nro stays under dist/switch/ for PR CI / debug; release
# publishes the SD-ready zip only.
# Anbernic handheld port (suffix names the CFW it targets, so a
# future RG35XX/other-CFW pack can ship alongside it).
rg34="dist/rg34xxsp/gen1recomp-rg34xxsp-stockos64-mod.zip"
@@ -369,18 +387,23 @@ jobs:
fi
printf 'Release notes:\n%s\n' "$notes"
release_files=(
"dist/release/gen1recomp-${v}-macos.zip"
"dist/release/gen1recomp-${v}-windows.zip"
"dist/release/gen1recomp-${v}-linux.zip"
"dist/release/gen1recomp-${v}-android.apk"
"dist/release/gen1recomp-${v}-ios.ipa"
"dist/release/gen1recomp-${v}-switch.zip"
"dist/release/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip"
"dist/release/gen1recomp-${v}.love"
"dist/release/sha256sums.txt"
)
gh release create "$tag" \
--target "$GITHUB_SHA" \
--title "$v" \
--notes "$notes" \
"dist/release/gen1recomp-${v}-macos.zip" \
"dist/release/gen1recomp-${v}-windows.zip" \
"dist/release/gen1recomp-${v}-linux.zip" \
"dist/release/gen1recomp-${v}-android.apk" \
"dist/release/gen1recomp-${v}-ios.ipa" \
"dist/release/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip" \
"dist/release/gen1recomp-${v}.love" \
"dist/release/sha256sums.txt"
"${release_files[@]}"
echo "Published release $tag"
@@ -0,0 +1,55 @@
name: Switch artifact comment
on:
workflow_run:
workflows: [ci]
types: [completed]
permissions:
contents: read
issues: write
pull-requests: write
jobs:
comment:
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
steps:
- id: artifact
env:
GH_TOKEN: ${{ github.token }}
RUN_ID: ${{ github.event.workflow_run.id }}
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
HEAD_REPOSITORY: ${{ github.event.workflow_run.head_repository.full_name }}
run: |
artifact_id="$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts" --jq '.artifacts[] | select(.name == "gen1recomp-switch-nro") | .id')"
[ -n "$artifact_id" ] || exit 0
head_owner="${HEAD_REPOSITORY%%/*}"
pr_number="$(gh api "repos/$GITHUB_REPOSITORY/pulls?state=open&head=$head_owner:$HEAD_BRANCH" --jq '.[0].number // empty')"
[ -n "$pr_number" ] || exit 0
echo "artifact_url=https://github.com/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts/$artifact_id" >> "$GITHUB_OUTPUT"
echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT"
# Upsert via comment-tag only — do not delete-all bot comments (clobbers iOS).
- name: Get build info
id: build-info
env:
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
run: |
commit_hash="$(printf '%s' "$HEAD_SHA" | cut -c1-7)"
build_time="$(date "+%Y-%m-%d %H:%M:%S")"
echo "hash=$commit_hash" >> "$GITHUB_OUTPUT"
echo "time=$build_time" >> "$GITHUB_OUTPUT"
- name: comment Switch artifact
if: steps.artifact.outputs.pr_number != ''
uses: thollander/actions-comment-pull-request@v3
with:
message: |
[gen1recomp-switch.nro](${{ steps.artifact.outputs.artifact_url }})
**Commit**: [#${{ steps.build-info.outputs.hash }}](https://github.com/${{ github.event.workflow_run.head_repository.full_name }}/commit/${{ github.event.workflow_run.head_sha }})
**Build Time**: `${{ steps.build-info.outputs.time }}`
<sub>This comment was automatically generated. [View workflow run](https://github.com/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }})</sub>
pr-number: ${{ steps.artifact.outputs.pr_number }}
comment-tag: switch-build-result
github-token: ${{ github.token }}
+4 -1
View File
@@ -31,7 +31,10 @@ mobile/ios/love-src/
mobile/ios/cache/
mobile/ios/build/
# Final packaged build artifacts (mac/win/web/android/ios) — see scripts/build.sh
# love-nx vendor binaries (fetch per docs/switch-development.md; also covered by .*)
.bazinga/love-nx/
# Final packaged build artifacts (mac/win/web/android/ios/switch) — see scripts/build.sh
/dist/
# Legacy manual convenience-copy location (superseded by /dist/android/)
+24
View File
@@ -229,6 +229,27 @@ ships with every release as `gen1recomp-*-rg34xxsp-stockos64-mod.zip`.
Install steps, controls, and troubleshooting live in
[docs/anbernic-rg34xxsp.md](docs/anbernic-rg34xxsp.md).
## 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).
- 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.
## Modding
The game ships a native mod platform: content registries, events and hooks,
@@ -276,4 +297,7 @@ 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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

+15 -1
View File
@@ -58,7 +58,17 @@ function love.conf(t)
-- engine before conf runs (LÖVE 11.x / 11.5).
local osName = love._os
local mobile = osName == "Android" or osName == "iOS"
if mobile then
local nx = osName == "NX"
if nx then
-- Switch (love-nx): hint handheld 720p. SDL auto-switches portable↔dock
-- (720p↔1080p) only when the window is resizable and not exclusive
-- fullscreen; NxDisplay.sync also applies the size on boot and dock change.
t.window.width = 1280
t.window.height = 720
t.window.fullscreen = false
t.window.resizable = true
t.window.highdpi = false
elseif mobile then
-- resizable is what unlocks orientation. SDL's Android backend, given no
-- SDL_HINT_ORIENTATIONS (LÖVE sets none), calls setRequestedOrientation
-- at window creation -- FULL_SENSOR when the window is resizable (rotates
@@ -70,6 +80,10 @@ function love.conf(t)
-- just work. FULL_SENSOR ignores the device's rotation lock, so
-- GameActivity.setOrientationBis remaps it to FULL_USER after SDL has
-- run: same orientations allowed, but auto-rotate being off now wins.
-- A persisted ORIENTATION lock (#592) overrides all of this after boot:
-- src/core/Orientation.lua sets SDL_HINT_ORIENTATIONS over the FFI and
-- re-triggers the request, from main.lua for the launcher and from
-- Game:applyOptions in game.
-- iOS follows the Info.plist orientations
-- (see mobile/ios/overlays/love-ios.plist, now portrait + landscape).
t.window.resizable = true
+24 -8
View File
@@ -45,6 +45,9 @@ same system picker and install the chosen archive on return.
draws one chip per game plus a MODS chip and rebuilds `self.tabRects` every
frame so `mousepressed` can dispatch clicks; switching tabs mid-import is
allowed (a dropped ROM still routes by SHA-1 regardless of which tab shows).
On **NX**, **Scan again** is stricter: it only starts an import whose SHA-1
matches the open game tab, so a shared `imports/` folder with Red+Yellow
cannot jump Yellow → Red.
- A game tab (`_drawGamePanel`) shows the ROM card, the SAVE FILES card, the
Play button, and the SAVE SLOT card in a responsive two-column grid (see
@@ -157,9 +160,14 @@ The SAVE FILES card wires a raw Gen1 `.sav` battery image to the save slots
through `src/import/SaveFileIO.lua`, which sits on top of
`src/save_convert/SaveConvert.lua` and the slot API in `SaveData`.
- **Import save** is live once the game's ROM is imported (playable). It opens
a native `.sav` picker (`chooseSav` on desktop; on Android,
`love.system.pickFile("sav")``picked_save.sav`, same SAF path as ROMs).
- **Import save** is live once the game's ROM is imported (playable).
On desktop it opens a native `.sav` picker (`chooseSav`); on Android,
`love.system.pickFile("sav")``picked_save.sav`, same SAF path as ROMs.
On **NX (Switch)** there is no picker: copy a `.sav` into
`getSaveDirectory()/imports/saves/<red|blue|yellow>/` via MTP / SD / FTP
(one folder per game), then press **Import save** on that games tab to
ensure the inbox and rescan (same pattern as the ROM `imports/` and mod
`imports/mods/` inboxes). Hidden `._*.sav` AppleDouble sidecars are skipped.
`SaveFileIO.importToSlot` reads the bytes (an absolute path, a save-dir
relative name, a dropped LOVE file, or raw bytes),
guards the 32768-byte size, runs `SaveConvert.importSav` (which also rejects
@@ -167,19 +175,27 @@ through `src/import/SaveFileIO.lua`, which sits on top of
writes it (`SaveData.writeSlot`), and makes it active (`SaveData.setActiveSlot`).
The meta stamp is re-stamped off `gen1_import` to the current numeric format
so `SaveData.load`'s migration pass accepts the slot. On success the SAVE SLOT
panel is refreshed with the new slot selected.
panel is refreshed with the new slot selected. On **NX**, a successful inbox
import retires the file to `*.sav.imported` and records a content hash in
`imports/saves/<game>/.imported-sha1` so a second **Import save** (or the same
bytes under a new name) does not clone slots; failures leave the original
`.sav`. Only that games folder is scanned.
- **Export save** is live only when the active slot actually holds a save
(checked against `listSlots`). `SaveFileIO.exportActiveSlot` loads the active
slot, encodes it back with `SaveConvert.exportSav` (a slot never keeps
`rawImport`, so this is a zero-filled template export, which is valid), and
writes `exports/gen1recomp-<version>-<slotId>.sav` in the save directory
(`love.filesystem.createDirectory("exports")`). On desktop it returns the
absolute path (`love.filesystem.getSaveDirectory()`), which the notice line
shows with an "Open folder" affordance (`love.system.openURL("file://" .. dir)`).
writes `exports/<version>/gen1recomp-<version>-<slotId>.sav` in the save
directory (`exports/` and `exports/<version>/` are created as needed). On
desktop it returns the absolute path (`love.filesystem.getSaveDirectory()`),
which the notice line shows with an "Open folder" affordance
(`love.system.openURL("file://" .. dir)`).
On Android the bytes are also staged as `pending_export.sav` and
`love.system.createFile(suggestedName)` opens `ACTION_CREATE_DOCUMENT` so the
player can save to Downloads / Drive / etc.; on return `export_done.flag`
makes focus show "Save exported."
On **NX**, export success sets a notice with the `exports/<game>/` path and an
MTP-oriented hint — no `openURL` / Open folder (pull the file via MTP /
SD / FTP instead).
- **Drag-drop.** `filedropped` routes a `.sav` to the import path for the
currently active game tab; when a non-game tab (mods, or the locked yellow
placeholder) is showing it defaults to red, the always-present first game
+11
View File
@@ -332,6 +332,17 @@ 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.
## 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
+204
View File
@@ -0,0 +1,204 @@
# Build the Nintendo Switch NRO — contributor guide
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).
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.
---
## Prerequisites by OS
All packaging entrypoints are **bash**. On Windows, use Git Bash, MSYS2, or
WSL — not cmd.exe or PowerShell (AD-008).
### macOS / Linux
1. Install [devkitPro pacman](https://devkitpro.org/wiki/devkitPro_pacman).
2. Install Switch tools:
```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).
**Optional:** Install [Docker](https://docs.docker.com/get-docker/) so fused
builds can fall back to the pinned image when native tools are missing.
### Windows (Git Bash / MSYS2 / WSL)
1. Use a bash environment:
- **MSYS2** with the [devkitPro](https://devkitpro.org/wiki/devkitPro_pacman)
packages (preferred for native `nacptool`/`elf2nro`), or
- **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).
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 |
| A legal `.gb` ROM (to play) | Any ROM or game data |
---
## Mode glossary
`scripts/build_switch.sh` supports three modes (combinable as noted):
| Mode | What it does |
| ---- | ------------ |
| `--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**. |
Rules:
- `--fetch` alone is fine; combine as `--fetch --loose` or `--fetch --fused`.
- `--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
Only the two pinned love-nx release assets (`love.nro`, `love.elf`). It does
**not** install:
- devkitPro / `dkp-pacman` / `switch-dev`
- Docker
- ROMs, saves, or mods
---
## Native tools, then Docker
Fused packaging (`scripts/switch/build_fused.sh`):
1. Prefer native `nacptool` + `elf2nro` on `PATH` (or `$DEVKITPRO/tools/bin`).
2. Else fall back to Docker using:
- `GEN1_DKP_IMAGE` if set, otherwise
- the image named in `scripts/switch/dkp-docker.image` (default
`devkitpro/devkita64:latest`).
If neither native tools nor Docker work, the script exits non-zero with
macOS / Linux / Windows / Docker hints and a pointer to this doc.
---
## Example commands
From the repo root:
```sh
# Download pinned love-nx only
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
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).
Offline packaging smoke (no network, no nacptool required):
```sh
bash scripts/switch/selftest_build_switch.sh
bash scripts/switch/verify_payload.sh --self-test
```
---
## CI and release
Switch packaging has three automated surfaces (same policy as AD-010):
### Path-gated PR / push CI (`.github/workflows/ci.yml`)
When a change touches Switch packaging / Switch docs / NX runtime paths
(`scripts/build_switch.sh`, `scripts/switch/**`, `docs/switch-*.md`,
`tests/switch_ci_workflows_test.lua`, `tests/switch_transfer_docs_test.lua`,
the NX runtime modules `src/core/NxAssetOverlay.lua`, `src/core/Platform.lua`,
`src/core/GameVersion.lua`, `src/import/CacheFs.lua`, the NX engine suites
`tests/engine/assets_version_fallback_test.lua`,
`tests/engine/nx_generated_guard_test.lua`,
`tests/engine/nx_yellow_boot_test.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):
`scripts/switch/selftest_build_switch.sh`,
`scripts/switch/verify_payload.sh --self-test`,
`luajit tests/switch_ci_workflows_test.lua`,
`luajit tests/switch_transfer_docs_test.lua`, and the NX engine suites
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
(`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.
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
`.github/workflows/switch-artifact-comment.yml`).
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`):
```sh
scripts/build_switch.sh --fetch --fused --version "<release version>"
```
A Switch packaging failure fails the entire release job. The release asset is
`gen1recomp-<ver>-switch.zip` (SD-ready); the versioned `.nro` stays under
`dist/switch/` for the packer and for PR CI artifacts.
### 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.
---
## Limitations / non-goals
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
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).
+528
View File
@@ -0,0 +1,528 @@
# Nintendo Switch development (love-nx)
> Fused NRO support for issue [#531](https://github.com/bryanthaboi/gen1recomp/issues/531).
> Releases ship `gen1recomp-*-switch.zip` (SD-ready tree). Console copy is
> extract/merge at microSD root; title override required. See
> [Known limitations](#known-limitations-read-before-reviewing).
**Canonical install / build / transfer docs** (start here unless you need hardware depth):
- Players → [switch-install.md](switch-install.md)
- Builders → [switch-build.md](switch-build.md) (`scripts/build_switch.sh --fetch` downloads the pinned love-nx pair)
- Transfer (MTP / SD / FTP on macOS, Linux, Windows) → [switch-transfer.md](switch-transfer.md)
This document covers what landed, known limitations, how hardware was tested,
vendor layout, build/deploy, and the contributor transfer loop (detail lives in
the transfer runbook).
## Acknowledgments
- **Port / love-nx packaging:** [andrewqsantos](https://github.com/andrewqsantos)
- **Community hardware testing** (Switch V1 / Erista boot): [booshankles](https://github.com/booshankles)
- **Method guidance:** [Dusklight Switch port](https://github.com/HayatoG/dusklight/tree/main/platforms/switch) / love-nx
- **Upstream project:** [bryanthaboi](https://github.com/bryanthaboi) / Gen1Recomp
## Status
| Area | State |
| ---- | ----- |
| Feature | **Available** — playable fused NRO path (issue #531) |
| Runtime | Pinned love-nx **`11.5-nx1`** |
| Product artifact | Releases: SD-ready `gen1recomp-*-switch.zip`; local/PR: fused `.nro`; loose `nro`+`game.love` for iteration |
| Hardware | **OLED** validated (author, title override); **V1 / Erista** boot confirmed (community). Lite, docked soak, and Pro Controller matrices welcome |
| Deploy / install | Releases publish SD-ready zip; **extract/merge at microSD root** (MTP / SD / FTP — [switch-transfer.md](switch-transfer.md)); no `nxlink` path yet |
| Contributor transfer | Documented for **macOS, Linux, and Windows**; OpenMTP on Mac is one example, not the only contract |
| Network features on NX | Self-update / remote mod download **disabled** (`networkValidated == false`) |
| Community help | Welcome — especially HOS / love-nx packaging and broader hardware coverage |
### What landed
- Detect `NX` via `src/core/Platform.lua` without reusing Android flags
- Writable ROM inbox under `getSaveDirectory()/imports/` + per-tab “Scan again” (SHA-1 match for the open game)
- Joy-Con / gamepad mapping shared by launcher and gameplay (Nintendo A/B UX on NX)
- Launcher L/R tab switch; gameplay L/R game-speed cycle; Select+face display chords
- Focus loss / joystick reconnect recovery; opt-in `switch-debug.txt` diagnostics
- Loose assemble + fused NRO build scripts (`scripts/build_switch.sh`, `scripts/switch/*`)
- Payload gates so ROM / generated cache / saves never enter `game.love`
- Community mod zip inbox at `imports/mods/` (rescan installs; FIND MODS stays network-gated)
- Raw `.sav` inbox at `imports/saves/{red,blue,yellow}/` (**Import save** rescan) + export pull path `exports/{red,blue,yellow}/` (MTP hint; no openURL)
- Hardware evidence for Phase 0 probe, ROM import, naming A/B, save/suspend, fused NRO — see `docs/switch-hardware-evidence.md`
- Path-gated CI selftest + canonical fused PR artifact; release Switch hard-fail
- Save editor pad/touch input (virtual cursor, A click, B close) — see `tools/save-editor/README.md`
- Dynamic display size on NX only: handheld **1280×720**, docked/TV **1920×1080** (`src/core/NxDisplay.lua` + resizable conf so love-nx SDL can follow dock/undock at runtime)
### Known gaps / welcome contributions
- Docked vs handheld soak (≥30 min) and Lite coverage — resolution switch is implemented; long soak still welcome
- Switch Lite and fuller Pro Controller / third-party pad matrices
- Applet Mode remains unsupported by design (title override required)
- `nxlink` / netloader contrib fast-loop (deferred — see [switch-transfer.md](switch-transfer.md))
Transfer runbooks for Linux/Windows (and SD/FTP alternatives) are in
[switch-transfer.md](switch-transfer.md). Community mod zip install OLED smoke
is **pass** — see NXMOD-12 in [switch-hardware-evidence.md](switch-hardware-evidence.md).
## Design references (Dusklight)
This work borrowed method — not the native stack — from the [Dusklight Switch port](https://github.com/HayatoG/dusklight/tree/main/platforms/switch), especially [`LESSONS_AND_REUSE.md`](https://github.com/HayatoG/dusklight/blob/main/platforms/switch/LESSONS_AND_REUSE.md):
| Dusklight lesson | How Gen1Recomp applied it |
| ---------------- | ------------------------- |
| Emulators hide Tegra failures | Gate milestones on **real OLED hardware**, not Ryujinx/Yuzu alone |
| Prove the lower layer first | `tools/switch-probe` before full launcher |
| Know which binary ran | Embedded `build-info.json` (commit / love-nx tag) |
| Cap continuous logs | Opt-in diagnostics, ≤1 Hz flush; Lua error log rotation |
| Crash symbolization needs the exact ELF | Keep pinned `love.elf` with the NRO under test |
| Full memory matters | Title override; Applet Mode is not the validation path |
| Do not treat SD FS like desktop POSIX | Lua stays on `love.filesystem`; inbox + MTP for user files |
| Isolate platform code | Capability module instead of Android flag overload |
| NVK / WSI / `audren` stacks | **Not** copied — love-nx already supplies video/audio/input/FS |
The packaging goal matches Dusklights **single self-contained `.nro`**; contributor transfer stays multi-host (not Mac-only).
## Known limitations (read before reviewing)
1. **Transfer is manual and multi-method.** Runtime only needs files under the LÖVE save directory / NRO install folder. Use MTP, direct SD, or FTP per [switch-transfer.md](switch-transfer.md). macOS + OpenMTP is a documented example for OLED evidence — not “Switch requires a Mac.”
2. **Deploy is manual.** There is no automated push to the console and no `nxlink` path yet. Operators build locally, transfer files, then title-override launch.
3. **Hardware coverage.** Author P0/P1 pass rows were recorded on one Switch OLED; Switch V1 boot was confirmed independently. Treat Lite, docked soak, and other hosts as unknown until someone re-runs the checklist.
4. **No ROM/save/mod zip bytes in git.** Legal dumps and third-party mods stay on the console (or local untracked folders).
5. **AppleDouble sidecars** (`._*`) from some MTP clients can break zip/ROM/`.sav` scans — the launcher skips hidden `.*` names (including `._*.sav`); still prefer clean copies.
## How we tested
| Layer | What | Where |
| ----- | ---- | ----- |
| Unit / headless | Platform NX flags, RomImporter inbox, dual-path input, mod zip inbox, save `.sav` inbox, display chords, payload/self-tests | `tests/*`, `scripts/test.sh` |
| Switch CI / packaging | Path-gated offline selftest (`selftest_build_switch.sh`, `verify_payload.sh --self-test`, `switch_ci_workflows_test.lua`); canonical fused PR artifact | `.github/workflows/ci.yml`, [switch-build.md](switch-build.md) § CI and release |
| Probe on hardware | `getOS()==NX`, 1280×720, save path, Joy-Con events | `tools/switch-probe` → OLED |
| Integration on hardware | MTP inbox ROM import, Play Red/Blue, naming A/B, quit/reopen save, suspend×10, reboot, fused NRO alone + NRO-only update | `docs/switch-hardware-evidence.md` |
| Community hardware | Switch V1 / Erista boot with prebuilt NRO | [booshankles](https://github.com/booshankles) — see evidence log |
| Known gaps | Docked soak, ≥30 min long-play, Lite, automated/`nxlink` deploy | Matrix deferred / absent rows |
Operator evidence must stay in `docs/switch-hardware-evidence.md`. **Do not invent passes** for hardware not run.
## love-nx 11.5-nx1 (pinned)
**Tag:** [11.5-nx1](https://github.com/retronx-team/love-nx/releases/tag/11.5-nx1)
**Local layout (not committed):**
```text
.bazinga/love-nx/11.5-nx1/
├── love.nro # homebrew launcher binary (loose mode: copied to gen1recomp.nro)
└── love.elf # required for fused NRO builds (devkitPro nacptool/elf2nro)
```
**Manifest:** `scripts/switch/love-nx-11.5-nx1.sha256` lists expected artifact names and SHA-256 checksums. Checksums are filled when binaries are fetched (`TBD_*` placeholders until then).
### Fetch instructions
Preferred (automated checksum verify):
```bash
scripts/build_switch.sh --fetch
```
That downloads pinned `love.nro` + `love.elf` into `.bazinga/love-nx/11.5-nx1/`
and checks them against `scripts/switch/love-nx-11.5-nx1.sha256`. See
[switch-build.md](switch-build.md) for the full mode glossary.
Manual fallback:
1. Open the [11.5-nx1 release](https://github.com/retronx-team/love-nx/releases/tag/11.5-nx1) and download `love.nro` and `love.elf`.
2. Create the directory: `mkdir -p .bazinga/love-nx/11.5-nx1`
3. Move both files into that directory.
4. Confirm checksums match the manifest:
```bash
shasum -a 256 .bazinga/love-nx/11.5-nx1/love.nro \
.bazinga/love-nx/11.5-nx1/love.elf
```
**Never commit** love-nx binaries, ROM dumps, or generated cache into git. The repo `.gitignore` excludes `.bazinga/` (vendor cache) and `/dist/` (build output).
## Loose-mode dist layout
Development builds place `gen1recomp.nro` and `game.love` side by side:
```text
dist/switch/loose/
├── gen1recomp.nro
└── game.love
```
Assemble with:
```bash
scripts/build_switch.sh --loose
```
(See `scripts/switch/assemble_loose.sh` for the underlying copy + checksum step.)
## Transfer & deploy (current contributor loop)
Detail for **macOS / Linux / Windows** and **MTP / SD / FTP** lives in
[switch-transfer.md](switch-transfer.md). Summary:
| Layer | Intent |
| ----- | ------ |
| **Runtime / players** | Extract the release zip at microSD root (`switch/gen1recomp/`) and land ROMs/mods under the save-dir inboxes. The game does not hard-depend on OpenMTP or macOS. |
| **Contributor loop** | Manual copy via MTP (DBI responder), direct SD (Hekate UMS / reader), or FTP. Fully manual — no CI deploy, no `nxlink` yet. |
The Mac + OpenMTP steps that remain below are the **OLED evidence reproduction** path; prefer the transfer runbook for day-to-day contrib on other hosts.
**Still avoided for routine evidence** (keeps SD handling honest):
- Treating `nxlink` / netloader as the release deploy story (deferred)
- DBI `MicroSD install` / `NAND install` / NSP-style virtual folders for the `.love`/`.nro` pair
If MTP fails: check cable, USB port, DBI state, and that only one MTP client holds the device — then retry or switch to SD/FTP. Do not silently rewrite evidence using an untested path and claim parity with recorded SHA-256 round-trips.
### Manual deploy checklist (today)
1. Build on the contributor host (`scripts/build_switch.sh --loose` or fused).
2. Close Gen1Recomp on the Switch; open DBI → `Run MTP responder`.
3. Copy artifacts with your MTP client into `1: SD Card/switch/gen1recomp/` (and ROMs/mods into the save-dir inboxes when needed).
4. Wait for the transfer queue; refresh; optionally round-trip SHA-256 on first artifacts of a type.
5. Exit MTP; launch via **title override** (hold **R** on a title → hbmenu, not Applet Mode).
## OpenMTP + DBI transfer (loose build, Mac evidence example)
Full multi-OS / multi-method steps: [switch-transfer.md](switch-transfer.md).
The numbered Mac loop below reproduces the OLED evidence path.
### On the Switch
1. Close Gen1Recomp if it is running.
2. Open **DBI** from hbmenu.
3. Select **`Run MTP responder`** (DBI documents `X` on the main screen).
4. Keep DBI on that screen for the entire transfer.
5. Connect the Switch to the Mac with a USB-C data cable.
### On the Mac
1. Close any other MTP clients.
2. Open **OpenMTP** and select the DBI device.
3. In the remote pane, open **`1: SD Card`**.
4. Navigate to **`switch/`** and create **`gen1recomp/`** if needed.
5. Enter **`1: SD Card/switch/gen1recomp/`**.
6. Drag from the local pane:
```text
dist/switch/loose/gen1recomp.nro
dist/switch/loose/game.love
```
7. Wait for the OpenMTP queue to finish completely.
8. Refresh the remote listing and confirm file sizes match the local files.
9. On the Switch, exit MTP responder normally in DBI before launching the app.
Expected layout on SD:
```text
1: SD Card/
└── switch/
└── gen1recomp/
├── gen1recomp.nro
└── game.love
```
## Round-trip SHA-256 verification
For the **first deploy** of each artifact type (loose pair, later fused NRO), verify MTP integrity:
1. **Before send** — record local hashes:
```bash
shasum -a 256 dist/switch/loose/gen1recomp.nro \
dist/switch/loose/game.love
```
2. **After send** — in OpenMTP, copy the same files from `1: SD Card/switch/gen1recomp/` back to an empty local folder, e.g. `dist/switch/mtp-roundtrip/`.
3. **Compare** round-trip hashes:
```bash
shasum -a 256 dist/switch/mtp-roundtrip/gen1recomp.nro \
dist/switch/mtp-roundtrip/game.love
```
4. Local pre-send and round-trip hashes **must match**. Record results in the test report template below.
Repeat whenever a cable glitch or interrupted transfer is suspected.
## Title override launch (full memory)
Applet Mode is **not** the primary validation path. Use **title override** so hbmenu runs with full memory:
1. Confirm the OpenMTP transfer queue finished.
2. Exit MTP responder in DBI; disconnect USB if desired.
3. Hold **`R`** while launching any legitimately installed title.
4. Keep holding until **hbmenu** appears.
5. Confirm hbmenu does **not** show **Applet Mode**.
6. Launch **`gen1recomp`** (or the probe NRO during Phase 0).
Album / applet launches are only useful to document applet-specific limitations; P0/P1 gates use title override.
## Phase 0 hardware checklist
Complete **in order** on OLED hardware. Operator fills evidence fields — leave blank until tested.
| Step | Action | Pass | Evidence / notes |
| ---- | ------ | ---- | ---------------- |
| P0-0a | Fetch love-nx 11.5-nx1; record manifest SHA-256 | yes | See `scripts/switch/love-nx-11.5-nx1.sha256` |
| P0-0b | Build `switch-probe.love` per `tools/switch-probe/README.md` | yes | |
| P0-0c | Assemble loose probe (`game.love` = probe) to `dist/switch/loose/` | yes | |
| P0-0d | MTP deploy to `1: SD Card/switch/gen1recomp/`; round-trip SHA-256 | yes | nro `8290ac15…5918f5`; love `9f198637…fa2e34f` |
| P0-0e | Title override → probe boots; `getOS()` shows `NX` | yes | `getOS()`=`NX`, `love._os`=`NX` |
| P0-0f | Probe lists 1280×720 (or documented dims), save path, gamepad/touch log | yes | save `sdmc:/switch/gen1recomp/switch-probe`; Joy-Con Y→#3 X→#4 |
| P0-1a | Replace `game.love` with unpatched Gen1Recomp build | yes | feat/switch-nx inbox build |
| P0-1b | MTP replace `game.love` only; round-trip SHA-256 | yes | |
| P0-1c | Title override → launcher reaches import screen | yes | |
| P0-1d | Joy-Con: can navigate launcher (no touch-only) | yes | Full report: `docs/switch-hardware-evidence.md` |
**Operator:** Andrew **Date:** 2026-08-01 **Console:** Switch OLED only
**Deploy:** manual Mac + OpenMTP + DBI MTP (not automated)
**love-nx tag:** 11.5-nx1 **gen1recomp commit:** `df7cea4`
## Phase 0 test report template
Copy this block into your hardware notes or PR evidence. **Do not commit ROM files or ROM hashes of private dumps.**
```markdown
## Switch Phase 0 — hardware report
- Operator:
- Date:
- Console model:
- Atmosphère / HOS version:
- gen1recomp commit:
- love-nx tag: 11.5-nx1
- love.nro SHA-256 (local):
- game.love SHA-256 (local, pre-send):
- MTP round-trip SHA-256 (gen1recomp.nro):
- MTP round-trip SHA-256 (game.love):
- Title override used: yes / no
- Applet Mode observed: yes / no (should be no for P0)
- Probe getOS():
- Probe dimensions:
- Probe save directory shown:
- Gamepad events logged: yes / no
- Touch events logged: yes / no
- Unpatched launcher boot: pass / fail
- Joy-Con launcher navigation: pass / fail / not tested
- Notes:
```
## Fast dev loop (loose mode)
While iterating on Lua/assets:
1. Edit on Mac; run `scripts/test.sh --quick`.
2. Rebuild `.bazinga/work/game.love` (`scripts/build.sh mac --no-notarize` or project pack step).
3. Close Gen1Recomp on Switch.
4. DBI → `Run MTP responder`.
5. OpenMTP → `1: SD Card/switch/gen1recomp/`.
6. Replace **only** `game.love`; wait for queue + refresh listing.
7. Exit MTP responder; launch via title override.
8. Keep `gen1recomp.nro` unchanged until the love-nx pin changes.
```bash
scripts/test.sh --quick
scripts/build.sh mac --no-notarize
scripts/build_switch.sh --loose
shasum -a 256 .bazinga/work/game.love
```
## Controller input mapping (NX)
Measured on Switch OLED (`feat/switch-nx`, love-nx `11.5-nx1`, 1280×720). Both `joystickpressed` and `gamepadpressed` fire for Joy-Con; prefer the gamepad path when `joystick:isGamepad()` is true.
| Path | Control | Mapping |
| ---- | ------- | ------- |
| `gamepadpressed` | D-pad / left stick | move |
| `gamepadpressed` | SDL `a` / `b` on **NX** | swapped via `NX_GAMEPAD_BINDINGS`: physical **A** (east) = GB A confirm, physical **B** (south) = GB B cancel |
| `gamepadpressed` | SDL `a` / `b` on desktop | identity (SDL south = GB A) |
| `gamepadpressed` | `start` / `back` | Start / Select (+ / ) |
| `gamepadpressed` | Right / left shoulder (no Select) | Cycle game speed up / down (same as PC hotkey `1` / speed-down path) |
| `joystickpressed` (raw) | only if **not** `isGamepad()` | face/menu fallback |
| `joystickpressed` (raw) | `#1` / `#2` on NX | Nintendo B / A → GB B / A |
| `joystickpressed` (raw) | `#9` / `#10` | Select / Start ( / +) |
**Nintendo UX on Switch:** physical A confirms, physical B cancels (explicit NX remap of SDL face labels).
**Launcher extras** (`RomImporter`): physical **A** clicks at the virtual cursor; **L** / **R** switch tabs; **Start** / **Select** start Play when a ROM is ready (else open Choose ROM). D-pad / left stick move the virtual cursor.
**Dual-path rule:** love-nx emits both `gamepadpressed` and `joystickpressed` for Joy-Con. When `joystick:isGamepad()` is true, Input and RomImporter **ignore raw** face/menu so NamingScreen does not see A+B in one frame. `NamingScreen` also prefers A over B if both edges still fire.
Implementation: `src/core/GamepadMap.lua` (`NX_RAW_*`, `ignoreRawForJoystick`, `displayChordDigit`), `src/core/Game.lua` (shoulder speed), `src/import/RomImporter.lua` (launcher tabs). Launcher and gameplay share the same converter.
## ROM inbox (NX)
Legal dumps land in a shared MTP inbox; **Scan again** is tab-scoped:
| Item | Value |
| ---- | ----- |
| Save-relative path | `imports/` (also accepts loose `.gb`/`.gbc` at the save-dir root) |
| MTP destination | `1: SD Card/<save identity>/imports/` (see launcher notice for the live `getSaveDirectory()` path) |
| Candidates | `*.gb` / `*.gbc` (hidden `.*` AppleDouble names skipped) |
| Rescan | Game tab → **Scan again** — imports only the dump whose SHA-1 matches that tab (`GameVersion.forSha1`). Other known dumps stay for their own tabs |
| Already ready | Same SHA already imported → “No new ROM found.” |
Players may drop Red, Blue, and Yellow into the same folder. Opening Yellow and pressing **Scan again** must not start a Red import.
## Mod zip inbox (NX)
Community mods install from a **separate** MTP inbox (not mixed into the ROM `imports/` scan):
| Item | Value |
| ---- | ----- |
| Save-relative path | `imports/mods/` |
| MTP destination | `1: SD Card/<save identity>/imports/mods/` (see launcher notice for the live `getSaveDirectory()` path) |
| Candidates | `*.zip` only |
| Rescan | MODS tab → **Scan again** (installs each zip via `LauncherMods.installZip`; source zips are retained on success and failure) |
| FIND MODS | Remains network-gated / hidden on NX (`networkValidated == false`) |
Do **not** commit third-party mod zip bytes into git. Drop the zip over MTP, rescan, enable in MODS, then Play.
**MTP tip (esp. macOS clients):** OpenMTP/Finder often creates AppleDouble sidecars named `._Something.zip` / `._cart.gb` / `._foo.sav`. Those are not real archives, ROMs, or saves — the launcher ignores hidden `.*` names under `imports/`, `imports/mods/`, and `imports/saves/<game>/`. If install still fails with “could not be opened” / “not a zip file”, delete any `._*` under the inbox and confirm the real zip starts with the `PK` magic (re-copy the release asset if unsure). This is a host-side annoyance of the current manual MTP loop, not something players should need forever.
Drop any community release `.zip` into `imports/mods/`, rescan, enable.
Player-facing install steps: [switch-install.md](switch-install.md#community-mods).
Mods own their OPTIONS / rebinds — do not duplicate third-party control tables here.
## Save `.sav` inbox (NX)
Raw Gen1 battery images use a **separate** MTP inbox (not mixed into ROM `imports/` or mod `imports/mods/`):
| Item | Value |
| ---- | ----- |
| Save-relative path | `imports/saves/red/`, `imports/saves/blue/`, `imports/saves/yellow/` |
| MTP destination | `1: SD Card/<save identity>/imports/saves/<game>/` (see launcher notice for the live `getSaveDirectory()` path) |
| Candidates | non-hidden `*.sav` only in **that games** folder |
| Rescan | SAVE FILES → **Import save** on the matching game tab (scans only that folder) |
| After success | Retire to `*.sav.imported` + append content hash to `imports/saves/<game>/.imported-sha1` |
| Exports | **Export save** writes under `exports/<game>/gen1recomp-<game>-<slot>.sav`; NX shows an MTP path notice (no `openURL`) |
Do **not** commit `.sav` bytes into git. Drop the file into the matching game folder over MTP, press **Import save** on that tab, then play. Pull exports from `exports/<game>/`.
**MTP tip:** the same AppleDouble `._*.sav` rule applies — see the mod inbox tip above.
## Joy-Con display chords (Select + face)
PC digit hotkeys for COLORS / TILT / GBC FX / pipelines have Joy-Con equivalents. Hold **Select** (`back` / ) and press a face/shoulder button; the engine runs the same path as `Game:keypressed` for that digit (including `writeOptions` / Pipelines parity).
| Chord (Nintendo UX) | Engine key | Stock engine effect |
| ------------------- | ---------- | ------------------- |
| Select + **A** | `2` | COLORS cycle |
| Select + **B** | `3` | TILT / perspective |
| Select + **Y** | `5` | GBC FX |
| Select + **X** | `6` | Mod pipeline hotkey (if registered) |
| Select + **L** (left shoulder) | `7` | Mod pipeline hotkey (if registered) |
Keys `2` / `3` / `4` / `5` are claimed by the engine before mod pipeline hotkeys run, so a community mod cannot rebind those digits through `Pipelines.hotkey`. Mods that need their own controls should use OPTIONS rows or unclaimed hotkeys.
Without Select held, face buttons keep normal GB A/B gameplay mapping (no accidental color/tilt cycles). The **Options** menu remains available for the same settings — chords are optional shortcuts, not the only path.
On NX, A/B chords resolve through the Nintendo UX face remap so physical **A** → key `2` and physical **B** → key `3` match this table.
**OPTIONS → PERFORMANCE** clamps the ports own extras (TILT / GBC FX / survey ZOOM) and can cap FPS — useful on weaker handheld budgets. Details: [new-features.md — Performance tier](new-features.md#performance-tier-low-end-devices).
Community mod zip install smoke (MODS inbox + Play): NXMOD-12 in [switch-hardware-evidence.md](switch-hardware-evidence.md).
**Opt-in diagnostics:** create an empty `switch-debug.txt` in the save directory; events flush to `switch.log` at ≤1 Hz with build identity (no ROM/save bytes).
**NX asset probe (always on Play):** every Switch Play writes `nx-asset-probe.log` in the save directory (`pokemon-love2d/`). It lists whether `assets/generated/…` vs `yellow|blue/assets/generated/…` exist, what `Assets.resolve` returns, and whether `newImage` / `newImageData` open — for Yellow/Blue blank-sprite triage. No ROM bytes.
**Blue/Yellow cache overlay (NX):** fused love-nx cannot reliably mount `yellow|blue/assets/generated` onto the un-prefixed path, so `src/core/NxAssetOverlay.lua` wraps EVERY read-side love API that accepts a filesystem path (`filesystem.read/load/lines/newFileData/getInfo`, `graphics.newImage/newFont`, `image.newImageData`, `audio.newSource`, `sound.newSoundData`, `font.newFontData`) once at boot — only when `Platform.isNX()`. Covering the whole read surface (not just the loaders the boot needs today) keeps future states and mods inside the fallback automatically; write-side functions stay stock. Core code must NOT call love loaders on literal `assets/generated` paths (enforced by `tests/engine/nx_generated_guard_test.lua`); the chip-audio worker is a separate Lua state and gets the prefix explicitly via `audio.programPrefix` from `ChipAudio.slimAudio`.
**Hardware re-test:** T16 **pass** @ `2699c9a` (naming A=confirm / B=cancel). T19 **pass** (quit/reopen, suspend×10, reboot) — operator 2026-08-01.
**Suspend/resume audio:** after resume, chip music is stopped to avoid duplicate streams; confirm on hardware during P0-09/10 (T19).
## Lua error log (save directory)
On any uncaught Lua error, Gen1Recomp appends a redacted trace to `lua-error.log` in the LÖVE save directory (`love.filesystem.getSaveDirectory()`). The on-screen error overlay includes a hint pointing at that file. Logs rotate to `lua-error.log.1` when the active file exceeds 32 KiB. ROM/save bytes and non-printable data are stripped — never commit or share logs that might contain private paths without reviewing them first.
## Native crash triage (love-nx / Atmosphère)
love-nx native faults land under the consoles `crash_reports/` folder on SD (reachable via the same manual MTP workflow used for game deploys).
1. **Collect** — DBI → `Run MTP responder`; copy `sdmc:/crash_reports/*.bin` (or the dated subfolder) to the contributor host. Prefer keeping the microSD in-console for routine pulls.
2. **Redact** — delete any attached screenshots or notes that mention ROM filenames, save paths, or private hashes before sharing logs publicly.
3. **Symbolize** — use the **pinned** `love.elf` from `.bazinga/love-nx/11.5-nx1/` that matches `build-info.json` / `scripts/switch/love-nx-11.5-nx1.sha256`. Never use a “latest” download.
```bash
# Example: aarch64-none-elf-addr2line from devkitPro
aarch64-none-elf-addr2line -e .bazinga/love-nx/11.5-nx1/love.elf -f -C 0xADDRESS_FROM_CRASH_REPORT
```
4. **Correlate** — compare `gitCommit` / `loveNxTag` from embedded `build-info.json` with the operators hardware notes.
If `addr2line` cannot resolve an address, archive the crash `.bin` with the exact `love.elf` SHA-256 used for the build — addresses are only meaningful against that ELF.
## P0 / P1 hardware matrix (ADR §9)
Operator evidence lives in `docs/switch-hardware-evidence.md`. **Do not invent passes** for rows that require hardware not yet run.
| ID | Requirement | Status | Evidence |
| -- | ----------- | ------ | -------- |
| P0-0af | love-nx pin, probe, MTP, title override | **pass** | Phase 0 checklist above; T4 |
| P0-1ad | Unpatched launcher boot + Joy-Con nav | **pass** | T4 / `docs/switch-hardware-evidence.md` |
| P0-02 | MTP inbox import path shown | **pass** | T12 |
| P0-03 | Rescan imports ROM | **pass** | T12 |
| P0-04 | Canonical hash routes version | **pass** | T12 |
| P0-05 | Source dump retained in inbox | **pass** | T12 |
| P0-06 | Play reaches game after import | **pass** | T12 |
| P0-07 | Joy-Con launcher navigation | **pass** | T16 @ `2699c9a` |
| P0-08 | Joy-Con gameplay (incl. naming A/B) | **pass** | T16 @ `2699c9a` |
| P0-09 | Save survives quit + reopen | **pass** | T19 |
| P0-10 | ≥10 suspend cycles, no stuck input/dup audio | **pass** | T19 (operator 2026-08-01) |
| P0-12 | Fused NRO boots without adjacent `game.love` | **pass** | T24 — `docs/switch-hardware-evidence.md` |
| P0-14 | Fused NRO MTP round-trip SHA-256 | **pass** | T24 — first artifact `b019e2e8…` @ `6fb5602` (redeploy after Blue fix) |
| P0-15 | Replace NRO only; saves persist | **pass** | T24 — operator NRO-only update keeps saves |
| P1-01 | Docked vs handheld spot-check | **deferred** | Code: `NxDisplay` 720p↔1080p; OLED dock soak not recorded yet |
| P1-02 | Applet Mode documented unsupported | **pass** | Title override required; Album path not validated |
| P1-03 | Long-play soak (≥30 min) | **deferred** | No soak session recorded |
| P1-04 | Reboot persistence | **pass** | T19 |
| P1-05 | Audio resume after suspend | **pass** | T19 (no dup audio reported) |
| — | Switch V1 / Erista boot | **pass** (boot) | Community — [booshankles](https://github.com/booshankles); see evidence log |
| — | Switch Lite / docked soak | **untested** / **deferred** | Welcome contributions |
| — | Automated / `nxlink` deploy | **absent** | Manual MTP / SD / FTP only (AD-009) |
| — | Multi-OS transfer runbooks | **pass** | [switch-transfer.md](switch-transfer.md) |
| — | Community mod zip OLED smoke (NXMOD-12) | **pass** | `docs/switch-hardware-evidence.md` |
## Review guidance
Maintainers may review as one PR or split later. Suggested slices (optional):
Each slice should declare: **no ROM/save bytes committed**, **love-nx pin with manifest checksums**, **hardware-tested rows listed with linked evidence**, **Applet Mode unsupported**, **network/updater disabled on NX**, **deploy still manual** (MTP / SD / FTP; no nxlink yet), **OpenMTP is one example not the sole contract**.
### Slice 1 — Platform + import (`platform/import`)
- `src/core/Platform.lua`, `conf.lua` NX branch
- `src/import/RomImporter.lua` (NX flags, inbox, scan, shell/updater gates)
- Tests: `tests/engine/platform_nx_*`, `tests/engine/rom_importer_nx_*` (ROM-free T2)
- Docs: inbox/MTP import sections only
### Slice 2 — Input + lifecycle (`input/lifecycle`)
- `src/core/GamepadMap.lua`, `Input.lua`, `main.lua` focus/joystick hooks
- `src/debug/SwitchDiagnostics.lua` (opt-in probe + error log)
- Tests: input/diagnostics suites
- Docs: controller mapping, suspend/audio notes
### Slice 3 — Build + docs (`build/docs`)
- `scripts/pack_love.sh`, `scripts/build_switch.sh`, `scripts/switch/*`
- `assets/switch/icon.jpg`, `docs/switch-development.md`, hardware evidence templates
- Gates: `pack_love.sh --dry-run`, `verify_payload.sh --self-test`, fused build script (devkitPro host)
**Pre-merge checklist:**
- [ ] Manifest `scripts/switch/love-nx-11.5-nx1.sha256` filled; binaries not in git
- [ ] `verify_payload.sh` rejects generated cache / ROM / `.sav` / `.bak`
- [ ] P0 matrix rows marked pass only with linked hardware evidence
- [x] Fused NRO P0-12/14/15 pass with T24 evidence (`docs/switch-hardware-evidence.md`)
- [ ] Updater / remote mod download hidden on NX (`networkValidated == false`)
+187
View File
@@ -0,0 +1,187 @@
# 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
```
+164
View File
@@ -0,0 +1,164 @@
# Install Gen1Recomp on Nintendo Switch
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
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.
Prefer building from source? See [switch-build.md](switch-build.md).
Port by [andrewqsantos](https://github.com/andrewqsantos). Community testing
help from [booshankles](https://github.com/booshankles).
## 1. Download the zip
1. Open
[Releases](https://github.com/bryanthaboi/gen1recomp/releases).
2. Download `gen1recomp-*-switch.zip` for the version you want.
(Optional: verify against `sha256sums.txt` in the same release.)
## 2. Extract onto the microSD
Extract the zip at the **root** of the microSD so you get:
```text
sdmc:/switch/gen1recomp/gen1recomp.nro
sdmc:/switch/gen1recomp/pokemon-love2d/imports/
sdmc:/switch/gen1recomp/pokemon-love2d/imports/mods/
sdmc:/switch/gen1recomp/pokemon-love2d/imports/saves/...
```
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
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.
## 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
(title override).
3. From hbmenu, open `gen1recomp`.
Do **not** launch from the Album applet path for normal play.
## 4. Import your ROM
This project ships **no** game data. On first launch:
1. Put your own legally obtained Pokémon Red, Blue (`.gb`), or Yellow
(`.gbc`) dump into `switch/gen1recomp/pokemon-love2d/imports/` (the
launcher also shows the live save-dir path). All three can sit in the
same folder.
2. Use **Scan again** on that games tab (Red / Blue / Yellow). Rescan
matches by ROM SHA-1 for the open tab only — a Red dump never imports
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**:
| Game | Import inbox | Export folder |
| ---- | ------------ | ------------- |
| Red | `imports/saves/red/` | `exports/red/` |
| Blue | `imports/saves/blue/` | `exports/blue/` |
| Yellow | `imports/saves/yellow/` | `exports/yellow/` |
(Under the save dir `pokemon-love2d/` — the zip already creates these folders.)
1. Copy a Gen1 `.sav` (32 KB) into that games inbox under the save dir
([switch-transfer.md](switch-transfer.md)).
2. With the games ROM already imported, open **that games tab**
**SAVE FILES****Import save**. Only that folder is scanned.
3. A successful import retires the file to `*.sav.imported` and records its
content hash so pressing **Import save** again does not clone slots.
Failed imports leave the original `.sav` in place.
4. To pull a slot off the console, use **Export save**, then copy the file
from that games **`exports/<game>/`** folder via MTP / SD / FTP.
Do not put `.sav` files into git. Prefer clean copies — some MTP clients
create `._*.sav` AppleDouble sidecars that are not real saves.
## Controls
### Gameplay
| Control | Action |
| ------- | ------ |
| D-pad / left stick | Move |
| **A** | Confirm |
| **B** | Cancel |
| **+** (Start) | Start |
| **** (Select) | Select |
| **R** (no Select held) | Cycle game speed up |
| **L** (no Select held) | Cycle game speed down |
### Launcher
| Control | Action |
| ------- | ------ |
| D-pad / left stick | Move virtual cursor |
| **A** | Click at cursor |
| **L** / **R** | Previous / next tab |
| **Start** / **Select** | Play if a ROM is ready; otherwise Choose ROM |
### System
| Control | Action |
| ------- | ------ |
| Hold **R** on HOME, then open from hbmenu | Title override (full memory) |
## Community mods
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)).
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
does not document third-party control tables.
### Joy-Con shortcuts (Select + face)
Hold **Select** () and press a face/shoulder button. Without Select, A/B stay
normal gameplay confirm/cancel. These chords are the stock engine display
hotkeys (`2`/`3`/`5` are claimed before any mod pipeline hotkey runs).
| Chord | Same as PC key | Stock engine effect |
| ----- | -------------- | ------------------- |
| Select + **A** | `2` | COLORS |
| Select + **B** | `3` | TILT |
| Select + **Y** | `5` | GBC FX |
| Select + **X** | `6` | Mod pipeline hotkey (if a mod registers `6`) |
| 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).
## 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).
+168
View File
@@ -0,0 +1,168 @@
# 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.
This is the home 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).
> **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.
---
## Destinations (shared by every method)
| What | Where on the console |
| ---- | -------------------- |
| SD-ready release zip | Extract at microSD **root**`sdmc:/switch/gen1recomp/gen1recomp.nro` plus `pokemon-love2d/` inbox folders. Install and update use the same merge; do **not** delete `pokemon-love2d/` |
| Loose iteration pair | `sdmc:/switch/gen1recomp/gen1recomp.nro` **and** `game.love` beside it |
| ROM inbox | LÖVE save dir → `imports/` (launcher shows the live `getSaveDirectory()` path; under MTP often `1: SD Card/<save identity>/imports/`) |
| Mod zip inbox | Same save dir → `imports/mods/` then MODS → **Scan again** |
| Save `.sav` inbox | Same save dir → `imports/saves/red\|blue\|yellow/` then that games SAVE FILES → **Import save** |
| Save 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 |
Saves persist across zip re-extract / NRO replacements as long as
`pokemon-love2d/` is left in place. Never commit ROM dumps, `.sav`
files, or third-party mod zips to git.
---
## Canonical methods
### 1. MTP (DBI responder + host client)
On the Switch: close Gen1Recomp → open **DBI****Run MTP responder** (often
**X** on the main screen) → keep that screen up → USB-C data cable to the host.
On the host: open **one** MTP client, navigate to **`1: SD Card`**, then the
paths above. Wait for the transfer queue; refresh; exit MTP on the Switch
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.
1. Quit other MTP clients.
2. Open OpenMTP → select the DBI device → **`1: SD Card`**.
3. Create `switch/gen1recomp/` if needed; extract the release zip at SD root
(or copy NRO / `game.love` for loose).
4. For ROMs/mods/saves, open the save-dir `imports/`, `imports/mods/`,
`imports/saves/<red|blue|yellow>/`, or `exports/<red|blue|yellow>/` path the
launcher prints.
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
launcher skips hidden `.*` names. Delete `._*` junk if a zip/ROM/`.sav`
fails to open.
#### Linux
1. Install desktop MTP support if needed (e.g. `gvfs-mtp` on GNOME/GTK
desktops, or your distros KDE MTP stack).
2. With DBI MTP active, open **Files** / **Dolphin** / **Thunar** and select
the Switch / DBI device → **`1: SD Card`**.
3. Extract the release zip at SD root (merge), or copy into `switch/gen1recomp/`
and the save-dir inboxes as above.
4. Use **only one** MTP accessor at a time. If `mtp-tools` / `mtpfs` reports
“device is busy”, close the file managers MTP mount (or the CLI mount)
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.
#### Windows
1. With DBI MTP active, open **This PC** / **File Explorer** and look under
**Portable Devices** for the Switch / DBI MTP volume → **`1: SD Card`**.
2. Copy / extract into `switch\gen1recomp\` and the save-dir inboxes.
3. Optional: [OpenMTP](https://github.com/ganeshrvel/openmtp) on Windows if
Explorer is flaky.
4. If Windows does not show an MTP device: Device Manager → find DBI / Switch
→ Update driver → **MTP USB Device** (or Standard MTP Device). Prefer a
data-capable USB-C cable and a direct port.
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.
### 2. Direct SD (Hekate UMS or card reader)
Same destinations; no MTP client required.
- **Hekate UMS** (preferred when available): expose the microSD to the host
while the card stays in the console; mount the volume; copy files; **cleanly
unmount** before leaving UMS.
- **Physical reader**: power off / remove the microSD, copy on the host,
**eject safely**, reinsert, boot CFW, title-override launch.
Do not yank the card or unplug UMS mid-write.
### 3. FTP (any SD-exposing Switch FTP)
Any homebrew FTP server that can write the microSD is fine — for example
**DBIs own FTP**, **sys-ftpd-light**, or **Sphaira** (names are illustrations
only; pick what your CFW setup already uses).
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
`switch/gen1recomp/`, `imports/`, `imports/mods/`, `imports/saves/<game>/`,
and `exports/<game>/` paths.
3. Stop the FTP server cleanly before launching Gen1Recomp.
If credentials or chroots differ by app, trust the **destination paths**, not
a single vendor tutorial.
---
## After every transfer
1. Exit MTP / unmount SD / stop FTP cleanly.
2. Launch via **title override** (hold **R** on a title → hbmenu). **Applet
Mode is not supported** (not enough memory).
3. For ROMs: open the matching game tab → **Scan again** if the file was
added after boot (SHA-1 must match that tab; other dumps in `imports/`
stay for their own tabs). For mods: MODS → **Scan again** → enable →
Play. For saves: SAVE FILES → **Import save** (rescans
`imports/saves/<game>/`). Pull exported `.sav` files from
`exports/<game>/`. Joy-Con display chords (stock engine):
[switch-install.md](switch-install.md#joy-con-shortcuts-select--face).
### Optional NRO integrity check
For the first deploy of a given artifact (or after a flaky cable):
```bash
shasum -a 256 path/to/gen1recomp.nro # or sha256sum
```
Copy the file back from the SD and compare hashes. Round-trip must match.
---
## Failure modes (quick)
| 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` |
| 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 |
---
## Related
- 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)
+33
View File
@@ -729,6 +729,15 @@ function ScrollManager:getState()
_overflowY = self._overflowY,
_contentWidth = self._contentWidth,
_contentHeight = self._contentHeight,
-- Touch fling state: without these, immediate-mode recreation zeroes the
-- release velocity on the next frame and momentum scrolling never runs.
_touchScrolling = self._touchScrolling or false,
_momentumScrolling = self._momentumScrolling or false,
_scrollVelocityX = self._scrollVelocityX or 0,
_scrollVelocityY = self._scrollVelocityY or 0,
_lastTouchTime = self._lastTouchTime or 0,
_lastTouchX = self._lastTouchX or 0,
_lastTouchY = self._lastTouchY or 0,
}
end
@@ -834,6 +843,30 @@ function ScrollManager:setState(state)
if state._targetScrollY ~= nil then
self._targetScrollY = state._targetScrollY
end
-- Touch fling state (see getState): restore so momentum survives
-- immediate-mode element recreation between frames.
if state._touchScrolling ~= nil then
self._touchScrolling = state._touchScrolling
end
if state._momentumScrolling ~= nil then
self._momentumScrolling = state._momentumScrolling
end
if state._scrollVelocityX ~= nil then
self._scrollVelocityX = state._scrollVelocityX
end
if state._scrollVelocityY ~= nil then
self._scrollVelocityY = state._scrollVelocityY
end
if state._lastTouchTime ~= nil then
self._lastTouchTime = state._lastTouchTime
end
if state._lastTouchX ~= nil then
self._lastTouchX = state._lastTouchX
end
if state._lastTouchY ~= nil then
self._lastTouchY = state._lastTouchY
end
end
--- Handle touch press for scrolling
+183 -10
View File
@@ -10,6 +10,23 @@
local editorMode = os.getenv("POKEPORT_EDITOR") == "1" or POKEPORT_EDITOR_MODE == true
local SwitchDiagnostics = require("src.debug.SwitchDiagnostics")
local NxDisplay = require("src.core.NxDisplay")
-- Lua errors: persist a redacted trace in the save dir and surface a hint.
do
local defaultErrorHandler = love.errorhandler
function love.errorhandler(msg)
local hint = SwitchDiagnostics.logLuaError(msg)
if hint and type(msg) == "string" then
msg = msg .. "\n\n" .. hint
end
if defaultErrorHandler then
return defaultErrorHandler(msg)
end
end
end
local Game, EditorApp, Importer, TouchEditor
local autopilot -- optional scripted-input dev tool (tests/autopilot.lua)
@@ -103,6 +120,11 @@ local function openEditor(version, slotId)
require("src.import.CacheFs").mountVersion(version)
editorVersion = version
editorHost = Importer
-- Drop launcher pad/FlexLove so the save editor owns input (NX shim +
-- virtual cursor + system hand cursor). Desktop park is a light no-op.
if Importer and Importer.prepareOverlayHandoff then
Importer:prepareOverlayHandoff()
end
Importer = nil
editorMode = true
resizeForEditor()
@@ -129,6 +151,9 @@ function closeEditor()
restoreWindow()
Importer = editorHost
editorHost = nil
if Importer and Importer.resumeAfterOverlay then
Importer:resumeAfterOverlay()
end
if Importer and version and Importer.savesChanged then
Importer:savesChanged(version)
end
@@ -142,6 +167,9 @@ local closeTouchControlsEditor -- forward declaration
local function openTouchControlsEditor()
touchEditorHost = Importer
if Importer and Importer.prepareOverlayHandoff then
Importer:prepareOverlayHandoff()
end
Importer = nil
TouchEditor = require("src.ui.TouchControlsEditor")
TouchEditor.load({ onClose = function() closeTouchControlsEditor() end })
@@ -152,6 +180,9 @@ function closeTouchControlsEditor()
TouchEditor = nil
Importer = touchEditorHost
touchEditorHost = nil
if Importer and Importer.resumeAfterOverlay then
Importer:resumeAfterOverlay()
end
end
local function bootGame(version)
@@ -161,7 +192,16 @@ local function bootGame(version)
-- data, so data/generated + assets/generated resolve to that version's files.
local GameVersion = require("src.core.GameVersion")
GameVersion.set(version or os.getenv("POKEPORT_VERSION") or "red")
require("src.import.CacheFs").mountVersion(GameVersion.get())
local CacheFs = require("src.import.CacheFs")
-- Keep CacheFs.prefix aligned for any CacheFs.read fallback during Data:load
-- (Blue/Yellow caches live under blue/ / yellow/).
CacheFs.prefix = GameVersion.cachePrefix()
CacheFs.mountVersion(GameVersion.get())
-- NX: always write nx-asset-probe.log so Yellow/Blue art failures are
-- diagnosable from the SD without enabling switch-debug.txt.
pcall(function()
require("src.debug.SwitchDiagnostics").probeAssets(GameVersion.get())
end)
if love.window and love.window.setTitle then
local Version = require("src.core.Version")
love.window.setTitle(Version.title(
@@ -189,6 +229,13 @@ function love.load(args)
-- of each flashing their own cmd.exe window (#606). No-op elsewhere.
require("src.core.HostShell").hideHostConsole()
-- NX fused mounts are unreliable for the blue|yellow cache overlay: wrap
-- the love loaders once so every generated-asset read falls back to the
-- versioned save-dir copy. Never installed on desktop/Android/iOS.
if require("src.core.Platform").isNX() then
require("src.core.NxAssetOverlay").install()
end
-- Self-updater boot shell: a fused build may mount and chainload a newer
-- downloaded payload here. True means it took over, so we must stop. A
-- dev / source checkout no-ops (see src/update/Boot.lua).
@@ -208,6 +255,16 @@ function love.load(args)
end
end
love.graphics.setDefaultFilter("nearest", "nearest")
-- NX: handheld 720p / docked 1080p. Runs for every boot path (launcher,
-- editor, scripted); no-op on desktop/mobile.
NxDisplay.sync()
-- Apply the persisted Android orientation lock (#592) before the launcher
-- shows: SDL created the window with no orientation hint, so without this
-- the launcher would rotate freely until Game:applyOptions runs at boot.
-- No-op on desktop / iOS / when options.lua does not exist yet.
require("src.core.Orientation").applyOptions(
require("src.core.SaveData").loadOptions())
-- Standalone editor. A bare `--editor` run has no launcher behind it, so
-- Close quits; --save points it at a specific file, otherwise it opens the
@@ -273,6 +330,9 @@ function love.load(args)
end
function love.update(dt)
SwitchDiagnostics.maybeFlush(false)
-- NX only (no-op elsewhere): follow dock/undock without waiting for SDL.
NxDisplay.sync()
if editorMode then return EditorApp.update(dt) end
if TouchEditor then return TouchEditor.update(dt) end
if Importer then return Importer:update(dt) end
@@ -347,48 +407,140 @@ function love.keyreleased(key)
end
function love.gamepadpressed(joystick, button)
if editorMode or TouchEditor then return end
SwitchDiagnostics.onJoystickEvent("gamepadpressed", joystick, button)
if editorMode then
if EditorApp and EditorApp.gamepadpressed then
return EditorApp.gamepadpressed(joystick, button)
end
return
end
if TouchEditor then
if TouchEditor.gamepadpressed then
return TouchEditor.gamepadpressed(joystick, button)
end
return
end
if Importer then return Importer:gamepadpressed(joystick, button) end
Game:gamepadpressed(joystick, button)
end
function love.gamepadreleased(joystick, button)
if editorMode or TouchEditor then return end
SwitchDiagnostics.onJoystickEvent("gamepadreleased", joystick, button)
if editorMode then
if EditorApp and EditorApp.gamepadreleased then
return EditorApp.gamepadreleased(joystick, button)
end
return
end
if TouchEditor then
if TouchEditor.gamepadreleased then
return TouchEditor.gamepadreleased(joystick, button)
end
return
end
if Importer then return Importer:gamepadreleased(joystick, button) end
Game:gamepadreleased(joystick, button)
end
function love.gamepadaxis(joystick, axis, value)
if editorMode or TouchEditor then return end
SwitchDiagnostics.onJoystickEvent("gamepadaxis", joystick, axis, { value = value })
if editorMode then
if EditorApp and EditorApp.gamepadaxis then
return EditorApp.gamepadaxis(joystick, axis, value)
end
return
end
if TouchEditor then
if TouchEditor.gamepadaxis then
return TouchEditor.gamepadaxis(joystick, axis, value)
end
return
end
if Importer then return Importer:gamepadaxis(joystick, axis, value) end
Game:gamepadaxis(joystick, axis, value)
end
function love.joystickpressed(joystick, button)
if editorMode or TouchEditor then return end
SwitchDiagnostics.onJoystickEvent("joystickpressed", joystick, button)
if editorMode then
if EditorApp and EditorApp.joystickpressed then
return EditorApp.joystickpressed(joystick, button)
end
return
end
if TouchEditor then
if TouchEditor.joystickpressed then
return TouchEditor.joystickpressed(joystick, button)
end
return
end
if Importer then return Importer:joystickpressed(joystick, button) end
Game:joystickpressed(joystick, button)
end
function love.joystickreleased(joystick, button)
if editorMode or TouchEditor then return end
SwitchDiagnostics.onJoystickEvent("joystickreleased", joystick, button)
if editorMode then
if EditorApp and EditorApp.joystickreleased then
return EditorApp.joystickreleased(joystick, button)
end
return
end
if TouchEditor then
if TouchEditor.joystickreleased then
return TouchEditor.joystickreleased(joystick, button)
end
return
end
if Importer then return Importer:joystickreleased(joystick, button) end
Game:joystickreleased(joystick, button)
end
function love.joystickaxis(joystick, axis, value)
if editorMode or TouchEditor then return end
SwitchDiagnostics.onJoystickEvent("joystickaxis", joystick, axis, { value = value })
if editorMode then
if EditorApp and EditorApp.joystickaxis then
return EditorApp.joystickaxis(joystick, axis, value)
end
return
end
if TouchEditor then
if TouchEditor.joystickaxis then
return TouchEditor.joystickaxis(joystick, axis, value)
end
return
end
if Importer then return Importer:joystickaxis(joystick, axis, value) end
Game:joystickaxis(joystick, axis, value)
end
function love.joystickhat(joystick, hat, direction)
if editorMode or TouchEditor then return end
SwitchDiagnostics.onJoystickEvent("joystickhat", joystick, hat, { direction = direction })
if editorMode then
if EditorApp and EditorApp.joystickhat then
return EditorApp.joystickhat(joystick, hat, direction)
end
return
end
if TouchEditor then
if TouchEditor.joystickhat then
return TouchEditor.joystickhat(joystick, hat, direction)
end
return
end
if Importer then return Importer:joystickhat(joystick, hat, direction) end
Game:joystickhat(joystick, hat, direction)
end
function love.joystickadded(joystick)
SwitchDiagnostics.onJoystickEvent("joystickadded", joystick)
if editorMode or TouchEditor then return end
if Importer then return end
Game:joystickadded(joystick)
end
function love.joystickremoved(joystick)
SwitchDiagnostics.onJoystickEvent("joystickremoved", joystick)
if editorMode or TouchEditor then return end
if Importer then return end
Game:joystickremoved(joystick)
@@ -400,6 +552,7 @@ end
function love.focus(f)
if editorMode or TouchEditor then return end
if Importer then
require("src.core.Input"):reset()
if Importer.focus then Importer:focus(f) end
return
end
@@ -409,12 +562,29 @@ end
-- v is true when the window becomes visible again, false on minimize.
function love.visible(v)
if editorMode or TouchEditor then return end
if Importer then return end
if Importer then
require("src.core.Input"):reset()
return
end
Game:visible(v)
end
function love.lowmemory()
if editorMode or TouchEditor or Importer then return end
if Game then Game:onResume() end
end
function love.touchpressed(id, x, y, dx, dy, pressure)
if editorMode then return end
if editorMode then
-- iOS synthesizes mousepressed for the primary touch; forwarding here
-- would double-fire. Android / NX need the explicit touch → click path
-- (love-nx does not synthesize mouse for the editor the way desktop does).
if love.system.getOS() == "iOS" then return end
if EditorApp and EditorApp.mousepressed then
return EditorApp.mousepressed(x, y, 1)
end
return
end
if TouchEditor then
-- iOS synthesizes mousepressed for the primary touch (same as the
-- launcher); Android drives the editor through love.touch directly.
@@ -483,6 +653,9 @@ function love.mousepressed(x, y, button, istouch)
return Importer:mousepressed(x, y, button)
end
if editorMode and EditorApp.mousepressed then
-- Same Android double-fire guard: touchpressed already clicked for the
-- save editor; a synthesized mouse press must not fire again.
if istouch and love.system.getOS() == "Android" then return end
return EditorApp.mousepressed(x, y, button)
end
if mouseTouch and Game and button == 1 then
+39
View File
@@ -30,6 +30,45 @@ public final class GRPickerBridge: NSObject {
// (<sandbox>/Library/Application Support/<identity>).
private static let loveIdentity = "pokemon-love2d"
@objc(httpDownloadWithUrl:destination:userAgent:accept:)
public static func httpDownload(url: UnsafePointer<CChar>?,
destination: UnsafePointer<CChar>?,
userAgent: UnsafePointer<CChar>?,
accept: UnsafePointer<CChar>?) -> Bool {
guard let url, let destination,
let requestURL = URL(string: String(cString: url)) else { return false }
var request = URLRequest(url: requestURL)
request.timeoutInterval = 300
if let userAgent, userAgent.pointee != 0 {
request.setValue(String(cString: userAgent), forHTTPHeaderField: "User-Agent")
}
if let accept, accept.pointee != 0 {
request.setValue(String(cString: accept), forHTTPHeaderField: "Accept")
}
let target = URL(fileURLWithPath: String(cString: destination))
let semaphore = DispatchSemaphore(value: 0)
var succeeded = false
let task = URLSession.shared.downloadTask(with: request) { temporary, response, error in
defer { semaphore.signal() }
guard error == nil, let temporary,
let http = response as? HTTPURLResponse,
(200..<300).contains(http.statusCode) else { return }
try? FileManager.default.removeItem(at: target)
do {
try FileManager.default.moveItem(at: temporary, to: target)
succeeded = true
} catch {
succeeded = false
}
}
task.resume()
guard semaphore.wait(timeout: .now() + 305) == .success else {
task.cancel()
return false
}
return succeeded
}
// MARK: - Entry points called from liblove (C strings on purpose)
@objc(presentPickerWithKind:saveDir:)
+30 -2
View File
@@ -103,6 +103,7 @@ WRAP_REGISTRATION = """#ifdef LOVE_IOS
{ "pickFile", w_pickFile },
{ "createFile", w_createFile },
{ "syncHealthSteps", w_syncHealthSteps },
{ "httpDownload", w_httpDownload },
#endif
"""
@@ -144,6 +145,32 @@ int w_syncHealthSteps(lua_State *L)
WRAP_SYNC_REGISTRATION = """#ifdef LOVE_IOS
{ "syncHealthSteps", w_syncHealthSteps },
{ "httpDownload", w_httpDownload },
#endif
"""
BRIDGE_EXTRA_FUNCS = """
#ifdef LOVE_IOS
int w_httpDownload(lua_State *L)
{
const char *url = luaL_checkstring(L, 1);
const char *destination = luaL_checkstring(L, 2);
const char *userAgent = luaL_optstring(L, 3, "gen1recomp");
const char *accept = luaL_optstring(L, 4, "");
Class cls = objc_getClass("GRPickerBridge");
if (cls == nullptr)
{
lua_pushboolean(L, 0);
return 1;
}
typedef signed char (*GRDownload)(Class, SEL, const char *, const char *,
const char *, const char *);
signed char ok = ((GRDownload)objc_msgSend)(
cls, sel_registerName("httpDownloadWithUrl:destination:userAgent:accept:"),
url, destination, userAgent, accept);
lua_pushboolean(L, ok != 0);
return 1;
}
#endif
"""
@@ -216,7 +243,8 @@ def patch_wrap_system():
if anchor not in text:
fail(f"anchor not found in {WRAP_SYSTEM}")
has_native_picker = re.search(r"\bint w_pickFile\s*\(", text) is not None
text = text.replace(anchor, (WRAP_SYNC_FUNCS if has_native_picker else WRAP_FUNCS) + anchor, 1)
bridge_funcs = WRAP_SYNC_FUNCS if has_native_picker else WRAP_FUNCS
text = text.replace(anchor, bridge_funcs + BRIDGE_EXTRA_FUNCS + anchor, 1)
reg_anchor = '\t{ "vibrate", w_vibrate },\n'
if reg_anchor not in text:
fail(f"registration anchor not found in {WRAP_SYSTEM}")
@@ -224,7 +252,7 @@ def patch_wrap_system():
text = text.replace(reg_anchor, reg_anchor + registration, 1)
WRAP_SYSTEM.write_text(text)
print("patch_love_src: wrap_System.cpp patched "
"(pickFile/createFile/syncHealthSteps)")
"(pickFile/createFile/syncHealthSteps/httpDownload)")
def patch_pbxproj():
+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env bash
# Nintendo Switch packaging entry point.
#
# Usage:
# scripts/build_switch.sh --fetch
# scripts/build_switch.sh --loose [path/to/game.love]
# scripts/build_switch.sh --fused [--version X.Y.Z]
# scripts/build_switch.sh --fetch --loose
# scripts/build_switch.sh --fetch --fused [--version X.Y.Z]
#
# Modes:
# --fetch Download pinned love.nro + love.elf into
# .bazinga/love-nx/11.5-nx1/ and verify SHA-256 against
# scripts/switch/love-nx-11.5-nx1.sha256.
# Auto-downloads those two release assets only.
# Does NOT install devkitPro / dkp-pacman.
#
# --loose Pack game.love and copy pinned love.nro → dist/switch/loose/
# (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.
#
# Combinable: --fetch alone, or --fetch with --loose / --fused.
# XOR: --loose and --fused cannot be used together.
#
# Non-goals (never done by this script):
# MTP push, ROM install, dkp-pacman auto-install.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
WORK="$ROOT/.bazinga/work"
DIST="$ROOT/dist/switch"
LOOSE=0
FUSED=0
FETCH=0
GAME_LOVE=""
VERSION="$(git -C "$ROOT" rev-parse --short HEAD 2>/dev/null || echo dev)"
# Progress on stderr so command-substitution of 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; }
usage() {
sed -n '2,34p' "$0" | sed 's/^# \{0,1\}//'
}
while [ $# -gt 0 ]; do
case "$1" in
--loose) LOOSE=1; shift ;;
--fused) FUSED=1; shift ;;
--fetch) FETCH=1; shift ;;
--version) VERSION="$2"; shift 2 ;;
-h|--help)
usage
exit 0
;;
*)
if [ -z "$GAME_LOVE" ]; then
GAME_LOVE="$1"
else
fail "unknown argument: $1"
fi
shift
;;
esac
done
if [ "$LOOSE" -eq 1 ] && [ "$FUSED" -eq 1 ]; then
fail "choose one of --loose or --fused (XOR)"
fi
if [ "$FETCH" -eq 0 ] && [ "$LOOSE" -eq 0 ] && [ "$FUSED" -eq 0 ]; then
fail "specify --fetch, --loose, and/or --fused (see --help)"
fi
if [ "$FETCH" -eq 1 ]; then
"$ROOT/scripts/switch/fetch_love_nx.sh"
fi
pack_game_love() {
mkdir -p "$WORK" "$DIST"
local build_info="$WORK/build-info.json"
"$ROOT/scripts/switch/write_build_info.sh" "$build_info" "$VERSION"
local love_out="$WORK/game.love"
local listing="$WORK/love-listing.txt"
"$ROOT/scripts/pack_love.sh" \
--output "$love_out" \
--listing "$listing" \
--build-info "$build_info" >/dev/null
# Stamp release version into the archive (same as build.sh / build_android.sh).
# Working tree keeps 0.0.0-dev; only X.Y.Z --version patches Version.lua in-place.
if printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
say "stamping engine version $VERSION into game.love"
local stamp_dir="$WORK/stamp"
rm -rf "$stamp_dir"
mkdir -p "$stamp_dir/src/core"
sed -E "s/(engine[[:space:]]*=[[:space:]]*\")[^\"]*(\")/\1$VERSION\2/" \
"$ROOT/src/core/Version.lua" > "$stamp_dir/src/core/Version.lua"
(cd "$stamp_dir" && zip -q "$love_out" src/core/Version.lua)
local version_re
version_re="$(printf '%s' "$VERSION" | sed 's/\./\\./g')"
unzip -p "$love_out" src/core/Version.lua \
| grep -Eq "engine[[:space:]]*=[[:space:]]*\"$version_re\"" \
|| fail "version stamp failed: game.love does not report engine $VERSION"
say "stamped engine version: $VERSION"
else
say "version '$VERSION' is not X.Y.Z, shipping default engine (no stamp)"
fi
cp "$build_info" "$DIST/build-info.json"
cp "$build_info" "$DIST/gen1recomp-${VERSION}-build-info.json"
printf '%s' "$love_out"
}
if [ "$LOOSE" -eq 1 ]; then
if [ -z "$GAME_LOVE" ]; then
GAME_LOVE="$(pack_game_love)"
else
pack_game_love >/dev/null
fi
exec "$ROOT/scripts/switch/assemble_loose.sh" "$GAME_LOVE"
fi
if [ "$FUSED" -eq 1 ]; then
GAME_LOVE="$(pack_game_love)"
OUT_NRO="$DIST/gen1recomp-${VERSION}-switch.nro"
OUT_ZIP="$DIST/gen1recomp-${VERSION}-switch.zip"
"$ROOT/scripts/switch/build_fused.sh" "$GAME_LOVE" "$VERSION" "$OUT_NRO"
"$ROOT/scripts/switch/pack_sd_zip.sh" "$OUT_NRO" "$VERSION" "$OUT_ZIP"
cp "$WORK/build-info.json" "$DIST/gen1recomp-${VERSION}-build-info.json"
say "done. See $DIST/"
exit 0
fi
# --fetch alone
if [ "$FETCH" -eq 1 ]; then
say "fetch complete"
exit 0
fi
fail "specify --fetch, --loose, and/or --fused (see --help)"
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
# Shared game.love packer for desktop and Switch builds.
#
# Usage:
# scripts/pack_love.sh [--output PATH] [--listing PATH] [--dry-run]
# [--build-info PATH]
#
# Packs the same include/exclude set as the desktop release. Materializes a
# listing file once and greps it (avoids SIGPIPE from unzip|grep under pipefail).
#
# --build-info PATH copy JSON into the love archive root before verification
# --dry-run pack + verify only (for CI gates; no platform artifacts)
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
WORK="$ROOT/.bazinga/work"
OUTPUT="$WORK/game.love"
LISTING="$WORK/love-listing.txt"
DRY_RUN=0
BUILD_INFO=""
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
while [ $# -gt 0 ]; do
case "$1" in
--output) OUTPUT="$2"; shift 2 ;;
--listing) LISTING="$2"; shift 2 ;;
--build-info) BUILD_INFO="$2"; shift 2 ;;
--dry-run) DRY_RUN=1; shift ;;
-h|--help)
sed -n '2,12p' "$0" | sed 's/^# \{0,1\}//'
exit 0
;;
*) fail "unknown argument: $1" ;;
esac
done
mkdir -p "$(dirname "$OUTPUT")" "$(dirname "$LISTING")"
say "packing game.love"
rm -f "$OUTPUT"
# libs/ carries the vendored FlexLove toolkit the launcher UI is built on
# (src/import/LauncherView.lua); a build without it dies on the first frame.
(cd "$ROOT" && zip -q -9 -r "$OUTPUT" \
main.lua conf.lua src libs data assets tools/save-editor \
tools/rom_manifest.json tools/rom_manifest_blue.json \
tools/rom_manifest_yellow.json \
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
if [ -n "$BUILD_INFO" ]; then
[ -f "$BUILD_INFO" ] || fail "missing build-info: $BUILD_INFO"
[ "$(basename "$BUILD_INFO")" = "build-info.json" ] \
|| fail "build-info file must be named build-info.json"
(cd "$(dirname "$BUILD_INFO")" && zip -q "$OUTPUT" build-info.json)
fi
# Materialize the listing once. Piping unzip→grep -q under `set -o pipefail`
# SIGPIPEs unzip when grep exits early on a match and aborts the build.
unzip -Z1 "$OUTPUT" > "$LISTING"
if grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/' "$LISTING"; then
fail "game.love unexpectedly contains generated ROM data"
fi
for required in tools/save-editor/App.lua tools/save-editor/Kit.lua \
tools/save-editor/PadInput.lua \
tools/save-editor/panels/Party.lua \
tools/rom_manifest.json tools/rom_manifest_blue.json \
tools/rom_manifest_yellow.json \
libs/flexlove/FlexLove.lua \
src/import/LauncherView.lua; do
grep -qxF "$required" "$LISTING" \
|| fail "game.love is missing $required"
done
if [ -n "$BUILD_INFO" ]; then
grep -qxF "build-info.json" "$LISTING" \
|| fail "game.love is missing build-info.json"
fi
"$ROOT/scripts/switch/verify_payload.sh" "$OUTPUT"
say "game.love: $(du -h "$OUTPUT" | cut -f1)"
if [ "$DRY_RUN" -eq 1 ]; then
say "pack dry-run OK: $OUTPUT"
fi
printf '%s\n' "$OUTPUT"
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# Assemble loose-mode Switch dist: gen1recomp.nro + game.love side by side.
#
# Usage:
# scripts/switch/assemble_loose.sh [path/to/game.love]
#
# Defaults game.love to .bazinga/work/game.love. Copies pinned love.nro from
# .bazinga/love-nx/11.5-nx1/love.nro → dist/switch/loose/gen1recomp.nro.
# Prints SHA-256 for both outputs. Exits non-zero if love.nro is missing.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=common.sh
. "$SCRIPT_DIR/common.sh"
LOVE_NRO="$ROOT/.bazinga/love-nx/11.5-nx1/love.nro"
GAME_LOVE="${1:-$ROOT/.bazinga/work/game.love}"
OUT_DIR="$ROOT/dist/switch/loose"
OUT_NRO="$OUT_DIR/gen1recomp.nro"
OUT_LOVE="$OUT_DIR/game.love"
if [ ! -f "$LOVE_NRO" ]; then
fail_need_fetch "missing pinned love.nro at $LOVE_NRO"
fi
if [ ! -f "$GAME_LOVE" ]; then
fail "missing game.love at $GAME_LOVE (build with scripts/build.sh first)"
fi
mkdir -p "$OUT_DIR"
cp "$LOVE_NRO" "$OUT_NRO"
cp "$GAME_LOVE" "$OUT_LOVE"
echo "assembled loose Switch dist:"
echo " $OUT_NRO"
echo " $OUT_LOVE"
echo ""
printf '%s %s\n' "$(sha256_file "$OUT_NRO")" "$OUT_NRO"
printf '%s %s\n' "$(sha256_file "$OUT_LOVE")" "$OUT_LOVE"
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env bash
# Build fused gen1recomp Switch NRO (romfs game.love + nacp + icon).
#
# Usage: scripts/switch/build_fused.sh GAME_LOVE VERSION OUT_NRO
#
# Prefers native nacptool/elf2nro (PATH or $DEVKITPRO/tools/bin).
# Falls back to Docker using GEN1_DKP_IMAGE or scripts/switch/dkp-docker.image.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=common.sh
. "$SCRIPT_DIR/common.sh"
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"
APP_NAME="gen1recomp"
APP_AUTHOR="bryanthaboi, port by andrewqsantos"
DKP_IMAGE_FILE="$ROOT/scripts/switch/dkp-docker.image"
GAME_LOVE="${1:-}"
VERSION="${2:-}"
OUT_NRO="${3:-}"
[ -n "$GAME_LOVE" ] && [ -n "$VERSION" ] && [ -n "$OUT_NRO" ] \
|| fail "usage: $0 GAME_LOVE VERSION OUT_NRO"
[ -f "$GAME_LOVE" ] || fail "missing game.love at $GAME_LOVE"
"$ROOT/scripts/switch/verify_love_nx.sh"
[ -f "$ICON" ] || fail "missing Switch icon at $ICON"
ensure_dkp_tools_path
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"
}
run_fused_native() {
local work romfs_dir nacp
work="$(mktemp -d "${TMPDIR:-/tmp}/gen1recomp-fused.XXXXXX")"
# shellcheck disable=SC2064
trap "rm -rf '$work'" EXIT
romfs_dir="$work/romfs"
mkdir -p "$romfs_dir" "$(dirname "$OUT_NRO")"
cp "$GAME_LOVE" "$romfs_dir/game.love"
nacp="$work/control.nacp"
nacptool --create "$APP_NAME" "$APP_AUTHOR" "$VERSION" "$nacp"
say "building fused NRO with pinned love.elf (native)"
elf2nro "$LOVE_ELF" "$OUT_NRO" \
--icon="$ICON" \
--nacp="$nacp" \
--romfsdir="$romfs_dir"
}
run_fused_docker() {
local image stage out_dir out_base
image="$(resolve_dkp_image)"
command -v docker >/dev/null 2>&1 || fail_fused_toolchain
# Stage under ROOT so a single repo bind-mount covers love.elf, icon, and romfs.
stage="$ROOT/.bazinga/work/fused-docker-$$"
mkdir -p "$stage/romfs" "$(dirname "$OUT_NRO")"
cp "$GAME_LOVE" "$stage/romfs/game.love"
# shellcheck disable=SC2064
trap "rm -rf '$stage'" EXIT
out_dir="$(cd "$(dirname "$OUT_NRO")" && pwd)"
out_base="$(basename "$OUT_NRO")"
say "building fused NRO with pinned love.elf (Docker: $image)"
docker run --rm \
-v "$ROOT:/src:ro" \
-v "$stage:/work" \
-v "$out_dir:/out" \
-w /work \
"$image" \
bash -c "
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 \
--nacp=/work/control.nacp \
--romfsdir=/work/romfs
"
}
if command -v nacptool >/dev/null 2>&1 && command -v elf2nro >/dev/null 2>&1; then
run_fused_native
elif command -v docker >/dev/null 2>&1; then
run_fused_docker
else
fail_fused_toolchain
fi
[ -f "$OUT_NRO" ] || fail "fused NRO was not produced at $OUT_NRO"
sha256_file "$OUT_NRO" > "${OUT_NRO}.sha256"
say "fused NRO: $OUT_NRO"
say "sha256: $(cat "${OUT_NRO}.sha256")"
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env bash
# Shared helpers for Switch packaging scripts.
# Source from other scripts: . "$(dirname "$0")/common.sh" (or similar)
# shellcheck disable=SC2034
if [ -z "${ROOT:-}" ]; then
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
fi
export ROOT
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
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.
sha256_file() {
local path="$1"
if command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$path" | awk '{print $1}'
elif command -v sha256sum >/dev/null 2>&1; then
sha256sum "$path" | awk '{print $1}'
else
fail "need shasum or sha256sum (install coreutils / Xcode CLT)"
fi
}
# If nacptool is missing but $DEVKITPRO/tools/bin exists, prepend it to PATH.
ensure_dkp_tools_path() {
if command -v nacptool >/dev/null 2>&1; then
return 0
fi
if [ -n "${DEVKITPRO:-}" ] && [ -d "$DEVKITPRO/tools/bin" ]; then
export PATH="$DEVKITPRO/tools/bin:$PATH"
fi
}
# Missing love-nx pin — tell user to run --fetch.
fail_need_fetch() {
fail "${1:-missing pinned love-nx} — run: scripts/build_switch.sh --fetch"
}
# 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).
Install options:
macOS: https://devkitpro.org/wiki/devkitPro_pacman (installer / pacman)
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
See docs/switch-build.md for details.
EOF
)"
}
+3
View File
@@ -0,0 +1,3 @@
# Default Docker image for fused nacptool/elf2nro fallback.
# Override with GEN1_DKP_IMAGE; operators may pin a digest for reproducibility.
devkitpro/devkita64:latest
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env bash
# Download pinned love-nx 11.5-nx1 binaries (love.nro + love.elf) and verify SHA.
#
# Usage: scripts/switch/fetch_love_nx.sh
#
# Idempotent: if both files exist and match the manifest, exit 0 without download.
# Downloads only these two release assets — does NOT install devkitPro.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=common.sh
. "$SCRIPT_DIR/common.sh"
LOVE_NX_TAG="11.5-nx1"
LOVE_NX_DIR="$ROOT/.bazinga/love-nx/$LOVE_NX_TAG"
MANIFEST="$ROOT/scripts/switch/love-nx-11.5-nx1.sha256"
# Override for offline selftests (never used in release/docs as the default).
BASE_URL="${GEN1_LOVE_NX_BASE_URL:-https://github.com/retronx-team/love-nx/releases/download/${LOVE_NX_TAG}}"
fail_download() {
local url="$1"
local detail="$2"
rm -f "${3:-}"
fail "download failed: $url
detail: $detail
retry: scripts/build_switch.sh --fetch
See docs/switch-build.md (mode --fetch)."
}
read_manifest_hash() {
local name="$1"
local line hash
line="$(grep -E "^${name}[[:space:]]+" "$MANIFEST" | head -1 || true)"
[ -n "$line" ] || fail "manifest missing entry for $name"
hash="$(printf '%s' "$line" | awk '{print $2}')"
case "$hash" in
TBD_*|"") fail "manifest hash for $name is not filled in ($hash)" ;;
esac
printf '%s' "$hash"
}
# Downloads url → dest. On failure prints structured error (URL, tool status, retry).
download_file() {
local url="$1"
local dest="$2"
local rc=0 http_code errf
if command -v curl >/dev/null 2>&1; then
errf="$(mktemp "${TMPDIR:-/tmp}/love-nx-curl.XXXXXX")"
http_code="$(curl -fL --retry 3 --retry-delay 1 -o "$dest" -w '%{http_code}' \
"$url" 2>"$errf")" || rc=$?
if [ "$rc" -ne 0 ]; then
fail_download "$url" \
"curl exit $rc; HTTP status ${http_code:-unknown}; $(tr '\n' ' ' <"$errf" | sed 's/[[:space:]]*$//')" \
"$dest"
fi
rm -f "$errf"
elif command -v wget >/dev/null 2>&1; then
errf="$(mktemp "${TMPDIR:-/tmp}/love-nx-wget.XXXXXX")"
wget -O "$dest" "$url" 2>"$errf" || rc=$?
if [ "$rc" -ne 0 ]; then
fail_download "$url" \
"wget exit $rc; $(tr '\n' ' ' <"$errf" | sed 's/[[:space:]]*$//')" \
"$dest"
fi
rm -f "$errf"
else
fail "need curl or wget to download love-nx
retry: scripts/build_switch.sh --fetch
See docs/switch-build.md (mode --fetch)."
fi
}
fetch_one() {
local name="$1"
local expected actual
local url="$BASE_URL/$name"
local dest="$LOVE_NX_DIR/$name"
local tmp
expected="$(read_manifest_hash "$name")"
if [ -f "$dest" ]; then
actual="$(sha256_file "$dest")"
if [ "$actual" = "$expected" ]; then
return 0
fi
say "checksum mismatch for existing $name — re-downloading"
rm -f "$dest"
fi
mkdir -p "$LOVE_NX_DIR"
tmp="$(mktemp "${TMPDIR:-/tmp}/love-nx-${name}.XXXXXX")"
say "downloading $name"
# download_file fails the script with fail_download (URL + status + retry).
download_file "$url" "$tmp"
actual="$(sha256_file "$tmp")"
if [ "$actual" != "$expected" ]; then
rm -f "$tmp"
fail "$name checksum mismatch (expected $expected, got $actual) — $url
retry: scripts/build_switch.sh --fetch"
fi
mv "$tmp" "$dest"
say "verified $name ($actual)"
}
[ -f "$MANIFEST" ] || fail "missing love-nx manifest: $MANIFEST"
EXPECTED_NRO="$(read_manifest_hash love.nro)"
EXPECTED_ELF="$(read_manifest_hash love.elf)"
if [ -f "$LOVE_NX_DIR/love.nro" ] && [ -f "$LOVE_NX_DIR/love.elf" ]; then
ACTUAL_NRO="$(sha256_file "$LOVE_NX_DIR/love.nro")"
ACTUAL_ELF="$(sha256_file "$LOVE_NX_DIR/love.elf")"
if [ "$ACTUAL_NRO" = "$EXPECTED_NRO" ] && [ "$ACTUAL_ELF" = "$EXPECTED_ELF" ]; then
say "love-nx $LOVE_NX_TAG already present and verified — skipping download"
"$ROOT/scripts/switch/verify_love_nx.sh"
exit 0
fi
fi
fetch_one love.nro
fetch_one love.elf
"$ROOT/scripts/switch/verify_love_nx.sh"
say "love-nx $LOVE_NX_TAG ready at $LOVE_NX_DIR"
+11
View File
@@ -0,0 +1,11 @@
# love-nx 11.5-nx1 — pinned runtime artifacts (RetronX team)
# https://github.com/retronx-team/love-nx/releases/tag/11.5-nx1
#
# Download love.nro and love.elf from the release above and place them under:
# .bazinga/love-nx/11.5-nx1/
#
# These binaries are NOT committed. After fetching, fill in the SHA-256 fields
# below (run: shasum -a 256 .bazinga/love-nx/11.5-nx1/<file>).
love.nro 8290ac153d4c630e48c9b26ef9123f5204ed8ee0cef3042511707b5b645918f5
love.elf f820d2f73ed72a8a24002ccf540e8a12b16b2b4c86b69e82c0d3629e99547175
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env bash
# 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
#
# Layout inside the zip (SD root):
# switch/gen1recomp/gen1recomp.nro
# 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
# pokemon-love2d/ to preserve progress.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=common.sh
. "$SCRIPT_DIR/common.sh"
NRO_PATH="${1:-}"
VERSION="${2:-}"
OUT_ZIP="${3:-}"
[ -n "$NRO_PATH" ] && [ -n "$VERSION" ] && [ -n "$OUT_ZIP" ] \
|| fail "usage: scripts/switch/pack_sd_zip.sh NRO_PATH VERSION OUT_ZIP"
[ -f "$NRO_PATH" ] || fail "missing NRO at $NRO_PATH"
command -v zip >/dev/null 2>&1 || fail "need zip on PATH"
# Absolutize before any cd — relative OUT_ZIP would otherwise land inside the
# staging dir and vanish when the EXIT trap cleans up.
NRO_PATH="$(cd "$(dirname "$NRO_PATH")" && pwd)/$(basename "$NRO_PATH")"
OUT_DIR="$(dirname "$OUT_ZIP")"
mkdir -p "$OUT_DIR"
OUT_ZIP="$(cd "$OUT_DIR" && pwd)/$(basename "$OUT_ZIP")"
STAGE="$(mktemp -d "${TMPDIR:-/tmp}/gen1recomp-sd-zip.XXXXXX")"
cleanup() { rm -rf "$STAGE"; }
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"
cat > "$APP_DIR/INSTALL.txt" <<EOF
gen1recomp Switch (v${VERSION})
===============================
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.
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/
then use Scan again in the launcher if needed.
Inboxes (drop files here via MTP / SD / FTP):
imports/ — ROM .gb / .gbc
imports/mods/ — community mod .zip
imports/saves/red|blue|yellow/ — raw .sav import
exports/red|blue|yellow/ — pull after Export save
Full guide: https://github.com/bryanthaboi/gen1recomp/blob/main/docs/switch-install.md
EOF
write_readme() {
local path="$1"
local body="$2"
mkdir -p "$(dirname "$path")"
printf '%s\n' "$body" > "$path"
}
write_readme "$SAVE_ROOT/imports/README.txt" \
"Put a legal Pokemon Red or Blue .gb / .gbc here, then Scan again in the launcher."
write_readme "$SAVE_ROOT/imports/mods/README.txt" \
"Put community mod .zip files here, then MODS → Scan again."
write_readme "$SAVE_ROOT/imports/saves/red/README.txt" \
"Put a Red .sav (32 KB) here, then Red tab → SAVE FILES → Import save."
write_readme "$SAVE_ROOT/imports/saves/blue/README.txt" \
"Put a Blue .sav (32 KB) here, then Blue tab → SAVE FILES → Import save."
write_readme "$SAVE_ROOT/imports/saves/yellow/README.txt" \
"Put a Yellow .sav (32 KB) here, then Yellow tab → SAVE FILES → Import save."
write_readme "$SAVE_ROOT/exports/red/README.txt" \
"After Export save (Red), copy the .sav out of this folder via MTP / SD / FTP."
write_readme "$SAVE_ROOT/exports/blue/README.txt" \
"After Export save (Blue), copy the .sav out of this folder via MTP / SD / FTP."
write_readme "$SAVE_ROOT/exports/yellow/README.txt" \
"After Export save (Yellow), copy the .sav out of this folder via MTP / SD / FTP."
rm -f "$OUT_ZIP"
(
cd "$STAGE"
zip -q -r "$OUT_ZIP" switch
)
[ -f "$OUT_ZIP" ] || fail "zip was not created at $OUT_ZIP"
[ -s "$OUT_ZIP" ] || fail "zip is empty: $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"
REQUIRED=(
"switch/gen1recomp/INSTALL.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"
"switch/gen1recomp/pokemon-love2d/imports/saves/blue/README.txt"
"switch/gen1recomp/pokemon-love2d/imports/saves/yellow/README.txt"
"switch/gen1recomp/pokemon-love2d/exports/red/README.txt"
"switch/gen1recomp/pokemon-love2d/exports/blue/README.txt"
"switch/gen1recomp/pokemon-love2d/exports/yellow/README.txt"
)
for rel in "${REQUIRED[@]}"; do
printf '%s\n' "$LISTING" | grep -Fq "$rel" || fail "zip missing $rel"
done
ZIP_SHA="$(sha256_file "$OUT_ZIP")"
printf '%s %s\n' "$ZIP_SHA" "$OUT_ZIP" > "${OUT_ZIP}.sha256"
say "SD-ready zip: $OUT_ZIP"
printf '%s %s\n' "$ZIP_SHA" "$OUT_ZIP"
+322
View File
@@ -0,0 +1,322 @@
#!/usr/bin/env bash
# Offline self-test for Switch packaging entry points (no network, no nacptool).
#
# Usage: scripts/switch/selftest_build_switch.sh
#
# Covers: sha256_file, --help glossary, XOR loose/fused, fail_need_fetch,
# verify_love_nx mismatch, fail_fused_toolchain message, pack_sd_zip layout.
# Does not download love-nx or invoke nacptool/elf2nro/Docker.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=common.sh
. "$SCRIPT_DIR/common.sh"
PASS=0
FAIL=0
ok() {
PASS=$((PASS + 1))
printf ' PASS: %s\n' "$*"
}
bad() {
FAIL=$((FAIL + 1))
printf ' FAIL: %s\n' "$*" >&2
}
say "selftest_build_switch (offline)"
# ---------------------------------------------------------------------------
# 1. sha256_file on a known temp file
# ---------------------------------------------------------------------------
TMP="$(mktemp "${TMPDIR:-/tmp}/selftest-sha.XXXXXX")"
printf 'gen1recomp-selftest\n' > "$TMP"
EXPECTED_SHA="$(shasum -a 256 "$TMP" | awk '{print $1}')"
ACTUAL_SHA="$(sha256_file "$TMP")"
rm -f "$TMP"
if [ "$ACTUAL_SHA" = "$EXPECTED_SHA" ]; then
ok "sha256_file matches shasum ($ACTUAL_SHA)"
else
bad "sha256_file mismatch (expected $EXPECTED_SHA, got $ACTUAL_SHA)"
fi
# ---------------------------------------------------------------------------
# 2. --help contains fetch / loose / fused
# ---------------------------------------------------------------------------
HELP_OUT="$("$ROOT/scripts/build_switch.sh" --help 2>&1 || true)"
HELP_LC="$(printf '%s' "$HELP_OUT" | tr '[:upper:]' '[:lower:]')"
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 '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)"
else
bad "build_switch.sh --help missing:$MISSING"
fi
# ---------------------------------------------------------------------------
# 3. XOR --loose --fused exits non-zero
# ---------------------------------------------------------------------------
XOR_RC=0
"$ROOT/scripts/build_switch.sh" --loose --fused >/dev/null 2>&1 || XOR_RC=$?
if [ "$XOR_RC" -ne 0 ]; then
ok "build_switch.sh --loose --fused exits non-zero ($XOR_RC)"
else
bad "build_switch.sh --loose --fused should exit non-zero"
fi
# ---------------------------------------------------------------------------
# 4. assemble_loose without pin → stderr contains --fetch
# ---------------------------------------------------------------------------
STAGING="$(mktemp -d "${TMPDIR:-/tmp}/selftest-loose.XXXXXX")"
# shellcheck disable=SC2064
trap "rm -rf '$STAGING'" EXIT
FAKE_LOVE="$STAGING/game.love"
printf 'PK\x03\x04' > "$FAKE_LOVE" # minimal placeholder; assemble only checks -f
# Temporarily hide the pin dir if present by pointing ROOT's pin via a subshell
# that moves the pin aside — or run assemble against a missing path by
# ensuring .bazinga/love-nx/11.5-nx1/love.nro is absent for this check.
PIN_DIR="$ROOT/.bazinga/love-nx/11.5-nx1"
PIN_BACKUP=""
if [ -f "$PIN_DIR/love.nro" ]; then
PIN_BACKUP="$STAGING/love.nro.bak"
mv "$PIN_DIR/love.nro" "$PIN_BACKUP"
fi
ASS_ERR="$STAGING/assemble.err"
ASS_RC=0
"$ROOT/scripts/switch/assemble_loose.sh" "$FAKE_LOVE" >"$STAGING/assemble.out" 2>"$ASS_ERR" || ASS_RC=$?
if [ -n "$PIN_BACKUP" ]; then
mv "$PIN_BACKUP" "$PIN_DIR/love.nro"
fi
if [ "$ASS_RC" -ne 0 ] && grep -q -- '--fetch' "$ASS_ERR"; then
ok "assemble_loose without pin cites --fetch"
else
bad "assemble_loose without pin should fail citing --fetch (rc=$ASS_RC err=$(cat "$ASS_ERR"))"
fi
# ---------------------------------------------------------------------------
# 5a. verify_love_nx fails on checksum mismatch (corrupt copy)
# ---------------------------------------------------------------------------
VERIFY_WORK="$STAGING/verify-mismatch"
mkdir -p "$VERIFY_WORK"
# Create a private ROOT-like tree is hard; instead corrupt a temp copy and
# invoke verify by temporarily swapping the pin file.
CORRUPT_BACKUP=""
if [ -f "$PIN_DIR/love.nro" ]; then
CORRUPT_BACKUP="$STAGING/love.nro.real"
cp "$PIN_DIR/love.nro" "$CORRUPT_BACKUP"
printf 'not-the-real-love-nro\n' > "$PIN_DIR/love.nro"
VM_RC=0
VM_ERR="$STAGING/verify.err"
"$ROOT/scripts/switch/verify_love_nx.sh" >"$STAGING/verify.out" 2>"$VM_ERR" || VM_RC=$?
mv "$CORRUPT_BACKUP" "$PIN_DIR/love.nro"
if [ "$VM_RC" -ne 0 ] && grep -Eqi 'mismatch|expected' "$VM_ERR"; then
ok "verify_love_nx fails on checksum mismatch"
else
bad "verify_love_nx should fail on mismatch (rc=$VM_RC err=$(cat "$VM_ERR"))"
fi
else
# No real pin available — still assert sha256_file + manifest read path
ok "verify_love_nx mismatch skipped (no local pin binaries)"
fi
# ---------------------------------------------------------------------------
# 5b. Idempotent fetch skip when pin already valid (no network if present)
# ---------------------------------------------------------------------------
if [ -f "$PIN_DIR/love.nro" ] && [ -f "$PIN_DIR/love.elf" ]; then
if "$ROOT/scripts/switch/verify_love_nx.sh" >/dev/null 2>&1; then
FETCH_OUT="$STAGING/fetch.out"
FETCH_RC=0
"$ROOT/scripts/switch/fetch_love_nx.sh" >"$FETCH_OUT" 2>&1 || FETCH_RC=$?
if [ "$FETCH_RC" -eq 0 ] && grep -Eqi 'skip|already|verified' "$FETCH_OUT"; then
ok "fetch_love_nx idempotent skip when pin present"
elif [ "$FETCH_RC" -eq 0 ]; then
ok "fetch_love_nx exits 0 with existing valid pin"
else
bad "fetch_love_nx with valid pin failed (rc=$FETCH_RC out=$(cat "$FETCH_OUT"))"
fi
else
ok "fetch idempotent skipped (pin present but verify failed — left alone)"
fi
else
ok "fetch idempotent skipped (no local pin binaries; offline)"
fi
# ---------------------------------------------------------------------------
# 5b2. Mid-fetch / network failure: URL + tool status + retry --fetch (SWBLD-05)
# ---------------------------------------------------------------------------
FETCH_FAIL_ERR="$STAGING/fetch-fail.err"
FETCH_FAIL_OUT="$STAGING/fetch-fail.out"
FETCH_FAIL_RC=0
PIN_MOVED=""
if [ -d "$PIN_DIR" ]; then
PIN_MOVED="$STAGING/pin-backup"
mv "$PIN_DIR" "$PIN_MOVED"
fi
# Closed port / unreachable host — no real network asset required.
GEN1_LOVE_NX_BASE_URL="http://127.0.0.1:1" \
"$ROOT/scripts/switch/fetch_love_nx.sh" >"$FETCH_FAIL_OUT" 2>"$FETCH_FAIL_ERR" || FETCH_FAIL_RC=$?
if [ -n "$PIN_MOVED" ]; then
rm -rf "$PIN_DIR"
mv "$PIN_MOVED" "$PIN_DIR"
fi
if [ "$FETCH_FAIL_RC" -ne 0 ] \
&& grep -q 'download failed:' "$FETCH_FAIL_ERR" \
&& grep -Eq 'curl exit|wget exit|HTTP status' "$FETCH_FAIL_ERR" \
&& grep -q 'retry: scripts/build_switch.sh --fetch' "$FETCH_FAIL_ERR"; then
ok "fetch network failure cites URL status and retry --fetch"
else
bad "fetch failure should cite status + retry --fetch (rc=$FETCH_FAIL_RC err=$(cat "$FETCH_FAIL_ERR"))"
fi
# ---------------------------------------------------------------------------
# 5c. fail_fused_toolchain mentions docs/switch-build.md
# ---------------------------------------------------------------------------
FT_ERR="$STAGING/fused-toolchain.err"
FT_RC=0
(
# Invoke as a function in a subshell that sources common
. "$SCRIPT_DIR/common.sh"
fail_fused_toolchain
) >"$STAGING/fused-toolchain.out" 2>"$FT_ERR" || FT_RC=$?
if [ "$FT_RC" -ne 0 ] && grep -q 'docs/switch-build.md' "$FT_ERR"; then
ok "fail_fused_toolchain cites docs/switch-build.md"
else
bad "fail_fused_toolchain should mention docs/switch-build.md (rc=$FT_RC err=$(cat "$FT_ERR"))"
fi
# Also check multi-OS hints
FT_LC="$(tr '[:upper:]' '[:lower:]' < "$FT_ERR")"
OS_MISSING=""
printf '%s' "$FT_LC" | grep -q 'macos' || OS_MISSING="${OS_MISSING} macOS"
printf '%s' "$FT_LC" | grep -q 'linux' || OS_MISSING="${OS_MISSING} Linux"
printf '%s' "$FT_LC" | grep -Eq 'windows|msys' || OS_MISSING="${OS_MISSING} Windows"
printf '%s' "$FT_LC" | grep -q 'docker' || OS_MISSING="${OS_MISSING} Docker"
if [ -z "$OS_MISSING" ]; then
ok "fail_fused_toolchain mentions macOS/Linux/Windows/Docker"
else
bad "fail_fused_toolchain missing OS hints:$OS_MISSING"
fi
# ---------------------------------------------------------------------------
# 6. pack_sd_zip.sh builds SD-ready tree (offline; fake NRO)
# ---------------------------------------------------------------------------
FAKE_NRO="$STAGING/fake.nro"
printf 'fake-nro-bytes\n' > "$FAKE_NRO"
FAKE_ZIP="$STAGING/gen1recomp-0.0.0-test-switch.zip"
PACK_RC=0
"$ROOT/scripts/switch/pack_sd_zip.sh" "$FAKE_NRO" "0.0.0-test" "$FAKE_ZIP" \
>"$STAGING/pack.out" 2>"$STAGING/pack.err" || PACK_RC=$?
if [ "$PACK_RC" -eq 0 ] && [ -f "$FAKE_ZIP" ] && [ -s "$FAKE_ZIP" ]; then
ok "pack_sd_zip.sh writes a non-empty zip"
else
bad "pack_sd_zip.sh failed (rc=$PACK_RC err=$(cat "$STAGING/pack.err"))"
fi
ZIP_LIST="$(unzip -Z1 "$FAKE_ZIP" 2>/dev/null || unzip -l "$FAKE_ZIP")"
PACK_MISSING=""
for rel in \
"switch/gen1recomp/gen1recomp.nro" \
"switch/gen1recomp/INSTALL.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" \
"switch/gen1recomp/pokemon-love2d/imports/saves/blue/README.txt" \
"switch/gen1recomp/pokemon-love2d/imports/saves/yellow/README.txt" \
"switch/gen1recomp/pokemon-love2d/exports/red/README.txt" \
"switch/gen1recomp/pokemon-love2d/exports/blue/README.txt" \
"switch/gen1recomp/pokemon-love2d/exports/yellow/README.txt"
do
printf '%s\n' "$ZIP_LIST" | grep -Fq "$rel" || PACK_MISSING="${PACK_MISSING} ${rel}"
done
if [ -z "$PACK_MISSING" ]; then
ok "pack_sd_zip.sh zip contains SD tree + inbox READMEs"
else
bad "pack_sd_zip.sh zip missing:$PACK_MISSING"
fi
EXTRACT_DIR="$STAGING/extract-v1"
rm -rf "$EXTRACT_DIR"
mkdir -p "$EXTRACT_DIR"
unzip -q "$FAKE_ZIP" -d "$EXTRACT_DIR"
if cmp -s "$FAKE_NRO" "$EXTRACT_DIR/switch/gen1recomp/gen1recomp.nro"; then
ok "pack_sd_zip.sh NRO bytes match source"
else
bad "pack_sd_zip.sh NRO inside zip differs from source"
fi
if [ -f "${FAKE_ZIP}.sha256" ]; then
ok "pack_sd_zip.sh writes .sha256 sidecar"
else
bad "pack_sd_zip.sh should write ${FAKE_ZIP}.sha256"
fi
MISSING_NRO_RC=0
"$ROOT/scripts/switch/pack_sd_zip.sh" "$STAGING/does-not-exist.nro" "0.0.0" \
"$STAGING/should-fail.zip" >/dev/null 2>&1 || MISSING_NRO_RC=$?
if [ "$MISSING_NRO_RC" -ne 0 ]; then
ok "pack_sd_zip.sh fails when NRO is missing"
else
bad "pack_sd_zip.sh should fail on missing NRO"
fi
# Relative OUT_ZIP must survive (absolutized before cd into staging)
REL_DIR="$STAGING/rel-out"
mkdir -p "$REL_DIR"
REL_RC=0
(
cd "$REL_DIR"
"$ROOT/scripts/switch/pack_sd_zip.sh" "$FAKE_NRO" "0.0.1" "relative.zip" \
>"$STAGING/rel.out" 2>"$STAGING/rel.err"
) || REL_RC=$?
if [ "$REL_RC" -eq 0 ] && [ -f "$REL_DIR/relative.zip" ] && [ -s "$REL_DIR/relative.zip" ]; then
ok "pack_sd_zip.sh accepts relative OUT_ZIP"
else
bad "pack_sd_zip.sh relative OUT_ZIP failed (rc=$REL_RC err=$(cat "$STAGING/rel.err"))"
fi
# Merge-safe update: second extract replaces NRO, keeps user data
printf 'KEEP-SAVE' > "$EXTRACT_DIR/switch/gen1recomp/pokemon-love2d/slot.sav"
printf 'KEEP-ROM' > "$EXTRACT_DIR/switch/gen1recomp/pokemon-love2d/imports/red.gb"
printf 'KEEP-MOD' > "$EXTRACT_DIR/switch/gen1recomp/pokemon-love2d/imports/mods/mod.zip"
printf 'KEEP-OPTS' > "$EXTRACT_DIR/switch/gen1recomp/pokemon-love2d/options.lua"
FAKE_NRO2="$STAGING/fake-v2.nro"
printf 'fake-nro-bytes-v2\n' > "$FAKE_NRO2"
FAKE_ZIP2="$STAGING/gen1recomp-0.0.1-test-switch.zip"
"$ROOT/scripts/switch/pack_sd_zip.sh" "$FAKE_NRO2" "0.0.1-test" "$FAKE_ZIP2" \
>"$STAGING/pack2.out" 2>"$STAGING/pack2.err"
unzip -qo "$FAKE_ZIP2" -d "$EXTRACT_DIR"
MERGE_OK=1
cmp -s "$FAKE_NRO2" "$EXTRACT_DIR/switch/gen1recomp/gen1recomp.nro" || MERGE_OK=0
[ "$(cat "$EXTRACT_DIR/switch/gen1recomp/pokemon-love2d/slot.sav")" = "KEEP-SAVE" ] || MERGE_OK=0
[ "$(cat "$EXTRACT_DIR/switch/gen1recomp/pokemon-love2d/imports/red.gb")" = "KEEP-ROM" ] || MERGE_OK=0
[ "$(cat "$EXTRACT_DIR/switch/gen1recomp/pokemon-love2d/imports/mods/mod.zip")" = "KEEP-MOD" ] || MERGE_OK=0
[ "$(cat "$EXTRACT_DIR/switch/gen1recomp/pokemon-love2d/options.lua")" = "KEEP-OPTS" ] || MERGE_OK=0
if [ "$MERGE_OK" -eq 1 ]; then
ok "pack_sd_zip.sh merge update preserves user data"
else
bad "pack_sd_zip.sh merge update lost user data or failed to replace NRO"
fi
# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------
echo ""
say "selftest: $PASS passed, $FAIL failed"
if [ "$FAIL" -ne 0 ]; then
exit 1
fi
exit 0
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# Verify pinned love-nx binaries against the manifest checksums.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=common.sh
. "$SCRIPT_DIR/common.sh"
LOVE_NX_TAG="11.5-nx1"
LOVE_NX_DIR="$ROOT/.bazinga/love-nx/$LOVE_NX_TAG"
MANIFEST="$ROOT/scripts/switch/love-nx-11.5-nx1.sha256"
read_manifest_hash() {
local name="$1"
local line hash
line="$(grep -E "^${name}[[:space:]]+" "$MANIFEST" | head -1 || true)"
[ -n "$line" ] || fail "manifest missing entry for $name"
hash="$(printf '%s' "$line" | awk '{print $2}')"
case "$hash" in
TBD_*|"") fail "manifest hash for $name is not filled in ($hash)" ;;
esac
printf '%s' "$hash"
}
verify_file() {
local name="$1"
local path="$LOVE_NX_DIR/$name"
local expected actual
expected="$(read_manifest_hash "$name")"
[ -f "$path" ] || fail_need_fetch "missing pinned $name at $path"
actual="$(sha256_file "$path")"
[ "$actual" = "$expected" ] \
|| fail "$name checksum mismatch (expected $expected, got $actual)"
}
verify_file love.nro
verify_file love.elf
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env bash
# Reject private / generated content inside a .love payload.
#
# Usage:
# scripts/switch/verify_payload.sh <path/to/game.love>
# scripts/switch/verify_payload.sh --self-test
#
# Fails on generated cache, ROM dumps (.gb/.gbc/.sav), rom-cache.complete,
# and save backup files (.bak). Never ships user ROMs or save data.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
LOVE_FILE=""
SELF_TEST=0
fail() { printf 'error: %s\n' "$*" >&2; exit 1; }
say() { printf 'verify_payload: %s\n' "$*"; }
while [ $# -gt 0 ]; do
case "$1" in
--self-test) SELF_TEST=1; shift ;;
-h|--help)
sed -n '2,9p' "$0" | sed 's/^# \{0,1\}//'
exit 0
;;
*)
if [ -z "$LOVE_FILE" ]; then
LOVE_FILE="$1"
else
fail "unknown argument: $1"
fi
shift
;;
esac
done
verify_love() {
local love="$1"
local listing
listing="$(mktemp "${TMPDIR:-/tmp}/love-listing.XXXXXX")"
[ -f "$love" ] || { rm -f "$listing"; fail "missing love archive: $love"; }
unzip -Z1 "$love" > "$listing"
if grep -Eq '^(data|assets)/generated/|/(data|assets)/generated/' "$listing"; then
rm -f "$listing"
fail "forbidden generated cache path in $love"
fi
if grep -Eiq '\.(gb|gbc|sav)$' "$listing"; then
rm -f "$listing"
fail "forbidden ROM or save file extension in $love"
fi
if grep -Eq '(^|/)rom-cache\.complete$' "$listing"; then
rm -f "$listing"
fail "forbidden rom-cache.complete marker in $love"
fi
if grep -Eiq '\.bak$' "$listing"; then
rm -f "$listing"
fail "forbidden save backup (.bak) in $love"
fi
rm -f "$listing"
say "OK $love"
}
run_self_test() {
local work clean bad staging
work="$(mktemp -d "${TMPDIR:-/tmp}/verify-payload.XXXXXX")"
trap "rm -rf '$work'" EXIT
clean="$work/clean.love"
"$ROOT/scripts/pack_love.sh" --output "$clean" --listing "$work/clean-listing.txt" >/dev/null
verify_love "$clean"
bad="$work/bad.love"
cp "$clean" "$bad"
staging="$work/staging"
mkdir -p "$staging/data/generated"
echo "secret" > "$staging/data/generated/rom.bin"
(cd "$staging" && zip -q "$bad" data/generated/rom.bin)
if ( verify_love "$bad" >/dev/null 2>&1 ); then
fail "self-test: expected forbidden generated path to fail"
fi
bad2="$work/bad2.love"
cp "$clean" "$bad2"
echo "x" > "$work/rom-cache.complete"
(cd "$work" && zip -q "$bad2" rom-cache.complete)
if ( verify_love "$bad2" >/dev/null 2>&1 ); then
fail "self-test: expected rom-cache.complete to fail"
fi
say "self-test OK"
}
if [ "$SELF_TEST" -eq 1 ]; then
run_self_test
exit 0
fi
[ -n "$LOVE_FILE" ] || fail "usage: $0 <game.love> | --self-test"
verify_love "$LOVE_FILE"
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env bash
# Write build-info.json for Switch artifacts.
#
# Usage: scripts/switch/write_build_info.sh OUTPUT.json [VERSION]
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
LOVE_NX_TAG="11.5-nx1"
MANIFEST="$ROOT/scripts/switch/love-nx-11.5-nx1.sha256"
OUTPUT="${1:-}"
VERSION="${2:-}"
fail() { printf 'error: %s\n' "$*" >&2; exit 1; }
[ -n "$OUTPUT" ] || fail "usage: $0 OUTPUT.json [VERSION]"
[ -f "$MANIFEST" ] || fail "missing love-nx manifest: $MANIFEST"
if [ -z "$VERSION" ]; then
VERSION="$(git -C "$ROOT" rev-parse --short HEAD 2>/dev/null || echo dev)"
fi
GIT_COMMIT="$(git -C "$ROOT" rev-parse HEAD 2>/dev/null || echo unknown)"
GIT_SHORT="$(git -C "$ROOT" rev-parse --short HEAD 2>/dev/null || echo dev)"
BUILT_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
read_manifest_hash() {
local name="$1"
local line hash
line="$(grep -E "^${name}[[:space:]]+" "$MANIFEST" | head -1 || true)"
[ -n "$line" ] || fail "manifest missing entry for $name"
hash="$(printf '%s' "$line" | awk '{print $2}')"
case "$hash" in
TBD_*|"") fail "manifest hash for $name is not filled in ($hash)" ;;
esac
printf '%s' "$hash"
}
LOVE_NRO_SHA="$(read_manifest_hash love.nro)"
LOVE_ELF_SHA="$(read_manifest_hash love.elf)"
mkdir -p "$(dirname "$OUTPUT")"
cat > "$OUTPUT" <<EOF
{
"version": "$VERSION",
"gitCommit": "$GIT_SHORT",
"gitCommitFull": "$GIT_COMMIT",
"loveNxTag": "$LOVE_NX_TAG",
"loveNxChecksums": {
"love.nro": "$LOVE_NRO_SHA",
"love.elf": "$LOVE_ELF_SHA"
},
"builtAt": "$BUILT_AT"
}
EOF
+9
View File
@@ -65,6 +65,14 @@ run_tier() {
# ------- ROM-free tiers: these are what CI runs
run_tier "T0 switch CI workflow content gate" "$LUA" tests/switch_ci_workflows_test.lua
run_tier "T0 switch transfer docs gate" "$LUA" tests/switch_transfer_docs_test.lua
# NX Blue/Yellow asset overlay: ROM-free, must run on every checkout so a
# Sound.lua / overlay regression is not gated only on switch-changes paths.
run_tier "T0 NX asset overlay fallback" "$LUA" tests/engine/assets_version_fallback_test.lua
run_tier "T0 NX generated-path static guard" "$LUA" tests/engine/nx_generated_guard_test.lua
run_tier "T0 NX Yellow/Blue boot (dynamic paths)" "$LUA" tests/engine/nx_yellow_boot_test.lua
run_tier "T0 touch-controls pad cursor" "$LUA" tests/engine/touch_controls_pad_cursor_test.lua
run_tier "T1/T2 engine invariants + parity gates" "$LUA" tests/run_engine.lua
run_tier "T4 mod-SDK" "$LUA" tests/run_modkit.lua
@@ -124,6 +132,7 @@ if [ -f data/generated/maps.lua ]; then
run_tier "T3 save editor: map browser" "$LUA" tests/save_editor_task8_tests.lua
run_tier "T3 save editor: mod awareness" "$LUA" tests/save_editor_mod_tests.lua
run_tier "T3 save editor: wheel scrolling" "$LUA" tests/save_editor_wheel_bug595_test.lua
run_tier "T3 save editor: pad / NX input" "$LUA" tests/save_editor_pad_input_test.lua
run_tier "T5 link (loopback lockstep)" "$LUA" tests/run_link_tests.lua
fi
else
+1 -1
View File
@@ -332,7 +332,7 @@ MoveEffects.primary = {
battle.data.moves[id].name) }
end,
SPLASH_EFFECT = function()
SPLASH_EFFECT = function(battle)
return { romText(battle.data, "_NoEffectText", "No effect!") }
end,
}
+13
View File
@@ -100,14 +100,27 @@ end
-- play so a hot-reloaded dataset (or a mod's audio) always reaches the worker
local function slimAudio(data)
local audio = data.audio or {}
-- NX-only: resolve the versioned cache prefix on the main thread and hand
-- it to the worker, which runs in a fresh Lua state without GameVersion.
local programPrefix
if require("src.core.Platform").isNX() then
local prefix = require("src.core.GameVersion").cachePrefix()
if prefix ~= "" then programPrefix = prefix end
end
return {
programFile = audio.programFile,
programPrefix = programPrefix,
bankOrder = audio.bankOrder,
waveBanks = audio.waveBanks,
noiseHeaders = audio.noiseHeaders,
}
end
-- test-only: expose slimAudio so the NX prefix hand-off is verifiable
function ChipAudio._slimAudioForTest(data)
return slimAudio(data)
end
-- If the worker died (a malformed def that errors mid-synth), fall back to the
-- synchronous path for the rest of the session instead of going silent.
local function workerAlive()
+17 -1
View File
@@ -123,7 +123,18 @@ local function loadBanks(data)
if cachedProgramFile == audio.programFile and cachedBanks then
return cachedBanks
end
local raw, readError = love.filesystem.read(audio.programFile)
local raw, readError
-- The chip worker runs in a separate Lua state without the NX overlay;
-- ChipAudio hands it the versioned cache prefix explicitly. On the main
-- thread the NX overlay (or desktop mountVersion) makes the plain read
-- resolve, so no platform branching belongs here.
local prefix = audio.programPrefix
if prefix and prefix ~= "" then
raw, readError = love.filesystem.read(prefix .. audio.programFile)
end
if not raw then
raw, readError = love.filesystem.read(audio.programFile)
end
if not raw then error("could not read sound programs: " .. tostring(readError)) end
local banks = {}
for index, bank in ipairs(audio.bankOrder) do
@@ -140,6 +151,11 @@ function ChipSynth.invalidateBanks()
cachedProgramFile, cachedBanks = nil, nil
end
-- test-only: exercise loadBanks without building a full engine
function ChipSynth._loadBanksForTest(data)
return loadBanks(data)
end
-- A def-local program (ChipAsm output) is mounted as pseudo-bank 0 next to
-- the ROM banks, so the 0x4000-window byte reader and every call/loop
-- target work unchanged. The ROM's own cached bank table is never touched
+14 -1
View File
@@ -203,7 +203,20 @@ local function loadModule(dir, name)
if not chunk then return false, err end
return pcall(chunk)
end
return pcall(require, "data.generated." .. name)
local ok, mod = pcall(require, "data.generated." .. name)
if ok then return true, mod end
-- Fused PhysFS / Blue|Yellow prefix: load bytes from the active version's
-- cache explicitly when require cannot see the mounted tree.
local CacheFs = require("src.import.CacheFs")
local GameVersion = require("src.core.GameVersion")
local path = "data/generated/" .. name .. ".lua"
local bytes = CacheFs.readActive(path)
if type(bytes) == "string" then
local chunk, err = loadstring(bytes, "@" .. GameVersion.cachePrefix() .. path)
if not chunk then return false, err or mod end
return pcall(chunk)
end
return false, mod
end
function Data:load()
+69 -8
View File
@@ -9,6 +9,7 @@ local Renderer = require("src.render.Renderer")
local SaveData = require("src.core.SaveData")
local StateStack = require("src.core.StateStack")
local TouchControls = require("src.core.TouchControls")
local GamepadMap = require("src.core.GamepadMap")
local ModLoader = require("src.mods.Loader")
local ModRuntime = require("src.mods.Runtime")
local Screens = require("src.ui.Screens")
@@ -656,17 +657,29 @@ function Game:gamepadpressed(joystick, button)
-- a controller is being used: the touch overlay steps aside until the
-- next screen touch (mobile only; a no-op elsewhere)
TouchControls:noteGamepad()
-- Select held? Needed both to suppress shoulder speed hotkeys (Select+L
-- is a display chord on NX) and for the chord path below.
local selectHeld = Input:isDown("select")
if not selectHeld and joystick and joystick.isGamepadDown then
local ok, down = pcall(function()
return joystick:isGamepadDown("back")
end)
selectHeld = ok and down == true
end
-- shoulder buttons and analog triggers cycle GAME SPEED (R1/RB or
-- R2/RT = faster, L1/LB or L2/LT = slower; same as keyboard hotkey
-- 1). LÖVE reports an analog trigger as gamepadpressed once it
-- crosses the press threshold, so a trigger pull lands here like any
-- other pad button.
if button == "rightshoulder" or button == "righttrigger" then
self:_cycleSpeed(1)
return
elseif button == "leftshoulder" or button == "lefttrigger" then
self:_cycleSpeed(-1)
return
-- other pad button. Skip while Select is held so Select+L can reach
-- displayChordDigit ("7").
if not selectHeld then
if button == "rightshoulder" or button == "righttrigger" then
self:_cycleSpeed(1)
return
elseif button == "leftshoulder" or button == "lefttrigger" then
self:_cycleSpeed(-1)
return
end
end
-- BindingsMenu's pad capture rides the same top-state routing as keys
local top = self.stack and self.stack:top()
@@ -674,6 +687,16 @@ function Game:gamepadpressed(joystick, button)
top:onGamepadPressed(button)
return
end
-- Select+face display chords → same digit path as Game:keypressed
-- (COLORS/TILT/pipelines). Intercept before Input so face does not
-- also fire GB A/B. Dual-path: raw already ignored when isGamepad().
if selectHeld then
local digit = GamepadMap.displayChordDigit(button)
if digit then
self:keypressed(digit)
return
end
end
Input:gamepadpressed(joystick, button)
end
@@ -757,15 +780,51 @@ function Game:focus(f)
end
function Game:visible(v)
if v then
self:onResume()
else
Input:reset()
TouchControls:reset()
end
end
function Game:onResume()
Input:reset()
TouchControls:reset()
-- Chip music may survive NX suspend as a duplicate stream; stop it and let
-- the active screen re-cue on the next frame (hardware audio check: T19).
-- Desktop/mobile window-visible flips must not kill overworld music.
if require("src.core.Platform").isNX() then
require("src.core.ChipAudio").stopMusic()
end
local SwitchDiagnostics = require("src.debug.SwitchDiagnostics")
if SwitchDiagnostics.isEnabled() then
SwitchDiagnostics.onEvent("lifecycle", { event = "resume" })
end
end
function Game:recoverInput(event, joystick)
Input:reset()
TouchControls:reset()
local SwitchDiagnostics = require("src.debug.SwitchDiagnostics")
if SwitchDiagnostics.isEnabled() then
if joystick then
SwitchDiagnostics.onJoystickEvent(event, joystick)
else
SwitchDiagnostics.onEvent("lifecycle", { event = event })
end
end
end
function Game:joystickadded(joystick)
self:recoverInput("joystickadded", joystick)
end
-- A disconnected/dropped controller can't send the button-up for whatever
-- it was holding, so drop all input state rather than try to guess which
-- flags it owned.
function Game:joystickremoved(joystick)
Input:reset()
self:recoverInput("joystickremoved", joystick)
TouchControls:joystickremoved()
end
@@ -842,6 +901,8 @@ function Game:applyOptions(opts)
-- returns true when a persisted GBC FX level was cleared on mobile
local gbcCleared = require("src.render.GBCFX").applyOptions(opts)
require("src.core.VideoMode").applyOptions(opts)
-- Android orientation lock (#592); no-op everywhere else
require("src.core.Orientation").applyOptions(opts)
-- after VideoMode: a faithful-resolution lock is an exact window size, so
-- it has to be the last word on the window (it drops fullscreen to hold)
require("src.core.FaithfulRes").applyOptions(opts)
+125
View File
@@ -0,0 +1,125 @@
-- Shared gamepad + raw joystick button tables for launcher and gameplay.
-- Hardware-measured NX overrides live in NX_* tables (see docs/switch-development.md).
local GamepadMap = {}
-- LÖVE SDL game-controller mapping (D-pad / face / menu) — desktop/mobile.
GamepadMap.DEFAULT_GAMEPAD_BINDINGS = {
dpup = "up", dpdown = "down", dpleft = "left", dpright = "right",
a = "a", b = "b",
start = "start", back = "select",
}
-- Switch: LÖVE/SDL labels south as "a" and east as "b", but Nintendo UX is
-- physical A (east) = confirm (GB A), physical B (south) = cancel (GB B).
GamepadMap.NX_GAMEPAD_BINDINGS = {
dpup = "up", dpdown = "down", dpleft = "left", dpright = "right",
a = "b", -- SDL south = Nintendo B → GB B
b = "a", -- SDL east = Nintendo A → GB A
start = "start", back = "select",
}
-- Generic SDL joysticks without a game-controller DB entry (Linux handhelds).
-- Desktop XInput order only: raw numbering is per-driver, and SDL's iOS/MFi
-- driver packs only the buttons a pad reports, which slides the D-pad onto
-- 7..10 (#620). These are defaults; Input:applyBindings layers "joyN" pad
-- rebinds over them (#632). Only sticks SDL does NOT recognize as gamepads
-- are served from this table (see GamepadMap.ignoreRawForJoystick).
GamepadMap.RAW_BUTTON_BINDINGS = {
[1] = "a", [2] = "b",
[7] = "select", [8] = "start", [9] = "select", [10] = "start",
}
-- Switch OLED raw indices (1-based). Only when NOT isGamepad() — love-nx
-- also emits gamepadpressed; dual-path face presses break NamingScreen.
-- #1 = Nintendo B, #2 = Nintendo A (probe); Y/X left unmapped for naming.
GamepadMap.NX_RAW_BUTTON_BINDINGS = {
[1] = "b", [2] = "a",
[9] = "select", [10] = "start",
}
-- Raw index -> gamepad button *name* for RomImporter (then NX face swap applies).
GamepadMap.RAW_TO_GAMEPAD_BUTTON = {
[1] = "a", [2] = "b",
[7] = "back", [8] = "start", [9] = "back", [10] = "start",
}
GamepadMap.NX_RAW_TO_GAMEPAD_BUTTON = {
[1] = "a", [2] = "b", -- SDL south/east names; NX_GAMEPAD_BINDINGS swaps to GB
[9] = "back", [10] = "start",
}
-- Test hook: force NX tables without stubbing love.
GamepadMap._forceNXForTests = false
function GamepadMap._setForceNXForTests(v)
GamepadMap._forceNXForTests = not not v
end
local function nxActive()
if GamepadMap._forceNXForTests then return true end
if love and love._os == "NX" then return true end
if love and love.system and love.system.getOS() == "NX" then return true end
return false
end
function GamepadMap.gamepadBindings()
if nxActive() then return GamepadMap.NX_GAMEPAD_BINDINGS end
return GamepadMap.DEFAULT_GAMEPAD_BINDINGS
end
-- Whole raw-index table for Input:applyBindings joyBindings seeding (#632).
function GamepadMap.rawBindings()
if nxActive() then return GamepadMap.NX_RAW_BUTTON_BINDINGS end
return GamepadMap.RAW_BUTTON_BINDINGS
end
function GamepadMap.mapGamepadButton(button)
return GamepadMap.gamepadBindings()[button]
end
-- Select+face display chords (docs / Nintendo UX):
-- Select+A → "2" (COLORS), Select+B → "3" (TILT),
-- Select+Y → "5", Select+X → "6", Select+L (leftshoulder) → "7".
-- For a/b: resolve through mapGamepadButton then GB a→"2", b→"3" so NX
-- Nintendo physical A/B match the docs despite SDL face-label swap.
-- Caller (Game:gamepadpressed) must require Select held; this is map-only.
function GamepadMap.displayChordDigit(gamepadButton)
if gamepadButton == "y" then return "5" end
if gamepadButton == "x" then return "6" end
if gamepadButton == "leftshoulder" then return "7" end
if gamepadButton == "a" or gamepadButton == "b" then
local gb = GamepadMap.mapGamepadButton(gamepadButton)
if gb == "a" then return "2" end
if gb == "b" then return "3" end
end
return nil
end
-- love-nx / SDL: when isGamepad(), face+menu already arrive via gamepad*.
-- Applying joystickpressed raw on top double-fires GB A/B in one frame.
function GamepadMap.ignoreRawForJoystick(joystick)
if not joystick then return false end
local ok, isPad = pcall(function()
return joystick.isGamepad and joystick:isGamepad()
end)
return ok and isPad == true
end
function GamepadMap.mapRawButton(index)
if nxActive() then
local nx = GamepadMap.NX_RAW_BUTTON_BINDINGS[index]
if nx then return nx end
end
return GamepadMap.RAW_BUTTON_BINDINGS[index]
end
function GamepadMap.mapRawToGamepadButton(index)
if nxActive() then
local nx = GamepadMap.NX_RAW_TO_GAMEPAD_BUTTON[index]
if nx then return nx end
end
return GamepadMap.RAW_TO_GAMEPAD_BUTTON[index]
end
return GamepadMap
+4 -5
View File
@@ -148,15 +148,14 @@ function HostShell.haveCurl()
return readOk and out ~= nil and out:find("curl", 1, true) ~= nil
end
-- The bridge only exists in our Android liblove. An older APK reports nil
-- here and falls back to the "no transport" error the callers already show;
-- the iOS build compiles the same wrapper but always returns false, so gate
-- on the OS as well and keep its error message honest.
-- An older mobile build reports nil here and falls back to the "no transport"
-- error the callers already show.
local function haveBridge()
if not (love and love.system and type(love.system.httpDownload) == "function") then
return false
end
return love.system.getOS and love.system.getOS() == "Android"
local osName = love.system.getOS and love.system.getOS()
return osName == "Android" or osName == "iOS"
end
-- Is any transport available at all? Callers gate on this, never on curl.
+18 -34
View File
@@ -1,6 +1,8 @@
-- Input abstraction: maps keyboard to Game Boy buttons.
-- `down` = held this frame; `pressed` = edge, consumed per fixed step.
local GamepadMap = require("src.core.GamepadMap")
local Input = {}
local DEFAULT_BINDINGS = {
@@ -22,37 +24,14 @@ local DEFAULT_BINDINGS = {
-- keys that map to "start" but also to "a" would conflict; keep Enter = a,
-- Escape = start for desktop friendliness.
-- LÖVE's standard gamepad mapping (SDL game controller DB), consistent
-- across Xbox/PlayStation/generic controllers on desktop and mobile. Some
-- third-party pads report their own SDL mapping for a given physical
-- button (e.g. Select/Back/View on off-brand XInput pads), which is what
-- src/ui/BindingsMenu.lua's rebinding is for -- see applyBindings below.
local DEFAULT_GAMEPAD_BINDINGS = {
dpup = "up", dpdown = "down", dpleft = "left", dpright = "right",
a = "a", b = "b",
start = "start", back = "select",
}
-- left-stick deadzones: press past STICK_ON, release once back under
-- STICK_OFF. The gap (hysteresis) stops the direction from flickering
-- while the stick sits near the threshold.
local STICK_ON = 0.5
local STICK_OFF = 0.3
-- Generic SDL joysticks expose the left stick as the first two numbered
-- axes and the D-pad as a hat. This is common on Linux handhelds whose
-- controller has no game-controller database entry. The indices below are
-- the desktop XInput order and are only meaningful for such pads: raw
-- numbering is per-driver, and SDL's iOS/MFi driver packs only the buttons
-- a pad actually reports, which slides the D-pad down onto 7..10 (#620).
-- These are the raw DEFAULTS only: applyBindings layers the player's
-- "joyN" pad bindings over them (#632), and only a stick SDL does NOT
-- recognize as a gamepad is ever served out of this table -- see
-- joystickpressed below.
local RAW_BUTTON_BINDINGS = {
[1] = "a", [2] = "b",
[7] = "select", [8] = "start", [9] = "select", [10] = "start",
}
-- Raw joystick defaults + NX overrides live in src/core/GamepadMap.lua
-- (see RAW_BUTTON_BINDINGS / NX_RAW_BUTTON_BINDINGS and #620 / #632).
local HAT_DIRECTIONS = {
u = { "up" }, d = { "down" }, l = { "left" }, r = { "right" },
@@ -75,8 +54,15 @@ end
function Input:applyBindings(overlay)
local keys, pads, joys = {}, {}, {}
for key, action in pairs(DEFAULT_BINDINGS) do keys[key] = action end
for button, action in pairs(DEFAULT_GAMEPAD_BINDINGS) do pads[button] = action end
for index, action in pairs(RAW_BUTTON_BINDINGS) do joys[index] = action end
for button, action in pairs(GamepadMap.gamepadBindings()) do
pads[button] = action
end
-- Seed raw defaults from GamepadMap (desktop XInput order or NX OLED
-- indices) so joyN rebinds (#632) and dual-path guards (#620) share one
-- table with the Switch face-label remap.
for index, action in pairs(GamepadMap.rawBindings()) do
joys[index] = action
end
for actionId, binding in pairs(overlay or {}) do
if type(binding) == "table" then
if binding.key then keys[binding.key] = actionId end
@@ -225,18 +211,16 @@ end
-- alone; the raw path exists for sticks with no game-controller-database
-- entry. A nil joystick is a raw stick: that is how
-- tests/input_hold_test.lua and the drivers drive this path.
local function isRawStick(joystick)
return not (joystick and joystick.isGamepad and joystick:isGamepad())
end
-- Gate: GamepadMap.ignoreRawForJoystick (pcall-safe isGamepad check).
function Input:joystickpressed(joystick, button)
if not isRawStick(joystick) then return end
if GamepadMap.ignoreRawForJoystick(joystick) then return end
local btn = self.joyBindings[button]
if btn then press(self, btn, "joy:" .. button) end
end
function Input:joystickreleased(joystick, button)
if not isRawStick(joystick) then return end
if GamepadMap.ignoreRawForJoystick(joystick) then return end
local btn = self.joyBindings[button]
if btn then release(self, btn, "joy:" .. button) end
end
@@ -277,7 +261,7 @@ function Input:gamepadaxis(joystick, axis, value)
end
function Input:joystickaxis(joystick, axis, value)
if not isRawStick(joystick) then return end
if GamepadMap.ignoreRawForJoystick(joystick) then return end
if axis == 1 then
self:gamepadaxis(joystick, "leftx", value)
elseif axis == 2 then
@@ -290,7 +274,7 @@ end
-- gamepad map, so letting the hat answer too would re-assert the factory
-- directions on top of a direction rebind.
function Input:joystickhat(joystick, hat, direction)
if not isRawStick(joystick) then return end
if GamepadMap.ignoreRawForJoystick(joystick) then return end
local source = "hat:" .. hat
for _, btn in ipairs(self.hatDirs[hat] or {}) do
release(self, btn, source)
+107
View File
@@ -0,0 +1,107 @@
-- NX-only asset overlay: fused love-nx cannot reliably mount
-- blue|yellow/assets/generated onto the un-prefixed assets/generated, so
-- instead of teaching every call site about versioned caches, this module
-- wraps EVERY read-side love entry point that accepts a filesystem path
-- once at boot: any string path under assets/generated/ that does not
-- resolve falls back to the active version's prefixed copy
-- (yellow|blue/assets/generated/...). Covering the whole read surface --
-- not just the loaders we happened to need -- is what keeps future states
-- and mods inside the fallback without anyone updating this file.
--
-- main.lua installs it only when Platform.isNX(); desktop/Android/iOS never
-- install it, so their mountVersion overlay stays the single mechanism and
-- their loaders keep stock behavior. Write-side functions (write, remove,
-- createDirectory, mount, ...) are deliberately NOT wrapped: the importer
-- must keep targeting the versioned tree explicitly.
--
-- Two intentional exceptions stay outside this module:
-- * the chip-audio worker (src/core/chip_worker.lua) is a separate Lua
-- state without these wrappers; ChipAudio.slimAudio hands it the prefix
-- explicitly as audio.programPrefix.
-- * data/generated module loads go through CacheFs.readActive, which
-- already implements the same fallback for require bytes.
local GameVersion = require("src.core.GameVersion")
local GENERATED = "assets/generated/"
local NxAssetOverlay = {}
local originals -- raw love functions, non-nil while installed
-- Resolve `path` to the versioned copy when the un-prefixed file is missing
-- and the active version (Blue/Yellow) carries it. Returns nil when the
-- caller's path should be used untouched (non-generated path, Red, the real
-- file exists, or no versioned copy).
local function versioned(path)
if type(path) ~= "string" then return nil end
if path:sub(1, #GENERATED) ~= GENERATED then return nil end
local prefix = GameVersion.cachePrefix()
if prefix == "" then return nil end
if originals.getInfo(path) then return nil end
local candidate = prefix .. path
if originals.getInfo(candidate) then return candidate end
return nil
end
local function wrapLoader(fn)
return function(path, ...)
local alt = versioned(path)
if alt then return fn(alt, ...) end
return fn(path, ...)
end
end
-- Every read-side love function that can take an assets/generated path.
-- getInfo is wrapped separately (it must return the versioned file's info,
-- not just forward a rewritten argument list).
local WRAP_SPEC = {
{ "filesystem", "read" },
{ "filesystem", "load" },
{ "filesystem", "lines" },
{ "filesystem", "newFileData" },
{ "graphics", "newImage" },
{ "graphics", "newFont" },
{ "image", "newImageData" },
{ "audio", "newSource" },
{ "sound", "newSoundData" },
{ "font", "newFontData" },
}
function NxAssetOverlay.isInstalled()
return originals ~= nil
end
function NxAssetOverlay.install()
if originals then return end
if not (love and love.filesystem) then return end
originals = {}
for _, spec in ipairs(WRAP_SPEC) do
local ns, name = spec[1], spec[2]
local fn = love[ns] and love[ns][name]
if fn then
originals[ns .. "." .. name] = fn
love[ns][name] = wrapLoader(fn)
end
end
originals.getInfo = love.filesystem.getInfo
love.filesystem.getInfo = function(path, ...)
local alt = versioned(path)
if alt then return originals.getInfo(alt, ...) end
return originals.getInfo(path, ...)
end
end
-- Tests restore the stock loaders between cases; the game never uninstalls.
function NxAssetOverlay.uninstall()
if not originals then return end
for _, spec in ipairs(WRAP_SPEC) do
local ns, name = spec[1], spec[2]
local key = ns .. "." .. name
if originals[key] then love[ns][name] = originals[key] end
end
love.filesystem.getInfo = originals.getInfo
originals = nil
end
return NxAssetOverlay
+105
View File
@@ -0,0 +1,105 @@
-- Switch-only display size: handheld 1280x720, docked (TV) 1920x1080.
-- love-nx's SDL backend can auto-resize on dock/undock when the window is
-- resizable; this module also syncs on boot and when the operation mode
-- changes so a docked launch is not stuck at the conf.lua 720p hint.
--
-- Important: only call love.window.setMode when width/height must change.
-- Re-applying every frame (e.g. to "fix" fullscreen/resizable flags that
-- love-nx reports differently) recreates the EGL surface and flickers the launcher.
local Platform = require("src.core.Platform")
local NxDisplay = {}
NxDisplay.HANDHELD_W, NxDisplay.HANDHELD_H = 1280, 720
NxDisplay.DOCKED_W, NxDisplay.DOCKED_H = 1920, 1080
-- AppletOperationMode from libnx: Handheld = 0, Console (docked) = 1.
local MODE_HANDHELD = 0
local MODE_CONSOLE = 1
-- Test hooks (nil = use live Platform / FFI / love.window).
NxDisplay._forceNXForTests = nil
NxDisplay._operationModeForTests = nil
local ffiOk, ffiC
local function ensureFfi()
if ffiOk ~= nil then return ffiOk end
ffiOk = false
local ok, ffi = pcall(require, "ffi")
if not ok or not ffi then return false end
-- Redefinition is fine across hot reload / tests; we only need the symbol.
pcall(ffi.cdef, [[
unsigned char appletGetOperationMode(void);
]])
local probeOk = pcall(function()
return ffi.C.appletGetOperationMode
end)
if not probeOk then return false end
ffiC = ffi.C
ffiOk = true
return true
end
local function isNX()
if NxDisplay._forceNXForTests ~= nil then
return not not NxDisplay._forceNXForTests
end
return Platform.isNX()
end
-- Returns AppletOperationMode or nil when unavailable.
function NxDisplay.operationMode()
if NxDisplay._operationModeForTests ~= nil then
return NxDisplay._operationModeForTests
end
if not ensureFfi() or not ffiC then return nil end
local ok, mode = pcall(function()
return tonumber(ffiC.appletGetOperationMode())
end)
if not ok then return nil end
return mode
end
-- Map operation mode → framebuffer size.
-- Unknown / nil → nil,nil (do not fight SDL or force a wrong size).
function NxDisplay.desiredSize(mode)
if mode == nil then mode = NxDisplay.operationMode() end
if mode == MODE_CONSOLE then
return NxDisplay.DOCKED_W, NxDisplay.DOCKED_H
end
if mode == MODE_HANDHELD then
return NxDisplay.HANDHELD_W, NxDisplay.HANDHELD_H
end
return nil
end
-- Apply handheld/dock size when on NX and the window size differs.
-- Never setMode just to tweak flags — that flickers on love-nx.
-- Returns true when setMode ran.
function NxDisplay.sync()
if not isNX() then return false end
if not (love and love.window and love.window.getMode and love.window.setMode) then
return false
end
local wantW, wantH = NxDisplay.desiredSize()
if not wantW or not wantH then return false end
local curW, curH, flags = love.window.getMode()
if curW == wantW and curH == wantH then
return false
end
flags = flags or {}
flags.fullscreen = false
flags.resizable = true
love.window.setMode(wantW, wantH, flags)
return true
end
function NxDisplay._resetForTests()
NxDisplay._forceNXForTests = nil
NxDisplay._operationModeForTests = nil
ffiOk, ffiC = nil, nil
end
return NxDisplay
+113
View File
@@ -0,0 +1,113 @@
-- Screen orientation lock, Android only (#592, #716).
--
-- Persisted as options.orientation: "auto" | "portrait" | "landscape" |
-- "reverseLandscape". The lock travels through SDL_HINT_ORIENTATIONS:
-- SDLActivity.setOrientationBis parses the hint's space-separated names
-- into a setRequestedOrientation call, and GameActivity's override then
-- remaps any *_SENSOR result onto the matching *_USER constant, so a device
-- with auto-rotate off stays put (#716). AUTO leaves the hint empty, which
-- with a resizable window means "any orientation, deferring to the system
-- rotation lock"; LANDSCAPE allows both landscapes (SENSOR_LANDSCAPE ->
-- USER_LANDSCAPE); REVERSE LANDSCAPE is SDL's LandscapeRight alone.
--
-- SDL only re-reads the hint when the window is created or its resizable
-- flag changes (SDL_androidwindow.c: Android_CreateWindow /
-- Android_SetWindowResizable both call Android_JNI_SetOrientation). LOVE
-- 11.5 exposes neither hints nor a resizable setter, so apply() goes through
-- the FFI to SDL's C API: set the hint, then pulse the window's resizable
-- flag off and back on -- each edge makes the Android backend recompute the
-- requested orientation, so a change from the launcher or the OPTION menu
-- takes hold immediately, and the flag ends where it started (conf.lua sets
-- resizable on mobile). Everything is pcall-guarded: desktop, iOS (the
-- Info.plist governs there) and headless stubs make this a no-op.
local Orientation = {}
Orientation.MODES = { "auto", "portrait", "landscape", "reverseLandscape" }
Orientation.DEFAULT = "auto"
local LABELS = {
auto = "AUTO",
portrait = "PORTRAIT",
landscape = "LANDSCAPE",
reverseLandscape = "REVERSE LANDSCAPE",
}
-- SDL_HINT_ORIENTATIONS values, exactly the names SDLActivity parses
-- (SDLActivity.java setOrientationBis): "Portrait", "PortraitUpsideDown",
-- "LandscapeLeft", "LandscapeRight". Both landscapes together promote to
-- SENSOR_LANDSCAPE; LandscapeRight alone maps to REVERSE_LANDSCAPE.
local HINTS = {
auto = "",
portrait = "Portrait",
landscape = "LandscapeLeft LandscapeRight",
reverseLandscape = "LandscapeRight",
}
function Orientation.normalize(mode)
if HINTS[mode] then return mode end
return Orientation.DEFAULT
end
function Orientation.modeLabel(mode)
return LABELS[Orientation.normalize(mode)]
end
function Orientation.isAndroid()
if not love or not love.system or not love.system.getOS then return false end
return love.system.getOS() == "Android"
end
function Orientation.cycle(mode, dir)
local cur, idx = Orientation.normalize(mode), 1
for i, m in ipairs(Orientation.MODES) do
if m == cur then idx = i break end
end
local n = #Orientation.MODES
return Orientation.MODES[(idx - 1 + (dir or 1)) % n + 1]
end
-- The SDL2 C API this module needs. cdef errors on redefinition, so run it
-- once and remember whether it took; ffi itself may be absent (plain Lua
-- test interpreters), hence the pcall'd require.
local cdefOk = nil
local function sdlFfi()
local okFfi, ffi = pcall(require, "ffi")
if not okFfi then return nil end
if cdefOk == nil then
cdefOk = pcall(ffi.cdef, [[
typedef struct SDL_Window SDL_Window;
int SDL_SetHint(const char *name, const char *value);
SDL_Window *SDL_GL_GetCurrentWindow(void);
void SDL_SetWindowResizable(SDL_Window *window, int resizable);
]])
end
if not cdefOk then return nil end
return ffi
end
-- Push the mode into the live activity. Returns true when the hint reached
-- SDL (the symbols resolved), false on any non-Android / stubbed platform.
function Orientation.apply(mode)
if not Orientation.isAndroid() then return false end
local ffi = sdlFfi()
if not ffi then return false end
mode = Orientation.normalize(mode)
local ok = pcall(function()
-- "SDL_IOS_ORIENTATIONS" is SDL_HINT_ORIENTATIONS's name (SDL_hints.h);
-- despite the IOS in the string, the Android backend reads it too.
ffi.C.SDL_SetHint("SDL_IOS_ORIENTATIONS", HINTS[mode])
local win = ffi.C.SDL_GL_GetCurrentWindow()
if win ~= nil then
ffi.C.SDL_SetWindowResizable(win, 0)
ffi.C.SDL_SetWindowResizable(win, 1)
end
end)
return ok
end
function Orientation.applyOptions(opts)
return Orientation.apply(opts and opts.orientation)
end
return Orientation
+54
View File
@@ -0,0 +1,54 @@
-- Platform capability detection for NX / mobile / desktop.
local Platform = {}
local cached
local function compute()
local osName = (love and love.system and love.system.getOS and love.system.getOS())
or "Unknown"
local nx = osName == "NX"
local mobile = osName == "Android" or osName == "iOS"
local nativePicker = love and love.system
and type(love.system.pickFile) == "function"
return {
os = osName,
nx = nx,
mobile = mobile,
console = nx,
hasNativePicker = nativePicker,
canSpawnProcess = osName == "OS X" or osName == "Windows" or osName == "Linux",
romImportMode = nx and "save-directory"
or (nativePicker and "native-picker")
or "desktop",
networkValidated = not nx,
}
end
function Platform.detect()
if not cached then cached = compute() end
return cached
end
function Platform.isNX()
return Platform.detect().nx
end
function Platform.romImportMode()
return Platform.detect().romImportMode
end
function Platform.canSpawnProcess()
return Platform.detect().canSpawnProcess
end
function Platform.networkValidated()
return Platform.detect().networkValidated
end
-- Tests may swap love.system between cases.
function Platform._resetForTests()
cached = nil
end
return Platform
+25 -11
View File
@@ -92,19 +92,30 @@ end
-- Chip SFX and cries are already stereo at the source (ChipSynth
-- renderEffectData); this covers file defs, i.e. Yellow's 8-bit mono PCM
-- Pikachu clips (RomExtractor extractPikachuCries) and mod-supplied wav/ogg
-- SFX. Every step is guarded: a headless love stub without love.sound, or a
-- decoder that will not hand back SoundData, keeps the original Source.
-- SFX.
--
-- Decode the FILE (not Source:getChannelCount): love-nx/audren has reported
-- channel counts that skip this widen silently, and preserving 8-bit depth
-- into a stereo buffer also sounds wrong on that backend. Always emit
-- 16-bit stereo like ChipSynth. Failure keeps the original Source and logs.
local function widenMono(source, file)
if not (source and love.sound and love.sound.newSoundData) then
if type(file) ~= "string" then return source end
if not (love.sound and love.sound.newSoundData and love.audio
and love.audio.newSource) then
return source
end
-- Quiet skip when the path is unreadable (headless stub SFX keys, missing
-- files). On NX, overlay-wrapped getInfo makes the yellow|blue copy visible
-- at the bare assets/generated path so the widen still runs.
local fs = love.filesystem
if not (fs and fs.getInfo and fs.getInfo(file)) then
return source
end
local ok, channels = pcall(function() return source:getChannelCount() end)
if not ok or channels ~= 1 then return source end
local built, widened = pcall(function()
local mono = love.sound.newSoundData(file)
if mono:getChannelCount() ~= 1 then return source end
local frames = mono:getSampleCount()
local stereo = love.sound.newSoundData(frames, mono:getSampleRate(),
mono:getBitDepth(), 2)
local stereo = love.sound.newSoundData(frames, mono:getSampleRate(), 16, 2)
for index = 0, frames - 1 do
local value = mono:getSample(index)
stereo:setSample(index, 1, value)
@@ -112,7 +123,10 @@ local function widenMono(source, file)
end
return love.audio.newSource(stereo, "static")
end)
if built and widened then return widened end
if built and widened and widened ~= source then return widened end
if not built then
Logger.warn("sound: widenMono failed for %s: %s", file, tostring(widened))
end
return source
end
@@ -274,9 +288,9 @@ function Sound.playPikaCry(data, n)
cache[key] = false
return nil
end
-- the importer writes these clips as 8-bit mono (RomExtractor
-- extractPikachuCries), so they need the same widening as the chip
-- effects to stay off a multi-output device's surround channels (#626)
-- importer historically wrote these as 8-bit mono (RomExtractor
-- extractPikachuCries); widenMono re-decodes to 16-bit stereo so they
-- stay off surround outputs (#626). Fresh extracts are already stereo.
s = widenMono(s, path)
s:setVolume(volumeFor(key))
cache[key] = s
+261
View File
@@ -0,0 +1,261 @@
-- Opt-in Switch diagnostics: ring buffer + ≤1 Hz flush when switch-debug.txt exists.
-- Never logs ROM/save bytes — see spec SWNX-13/28.
local SwitchDiagnostics = {}
local MARKER = "switch-debug.txt"
local LOG_FILE = "switch.log"
local ERROR_LOG = "lua-error.log"
local ERROR_LOG_ROTATED = "lua-error.log.1"
local ERROR_LOG_MAX = 32 * 1024
local FLUSH_INTERVAL = 1.0
local RING_SIZE = 64
local enabled = nil
local buffer = {}
local bufCount = 0
local lastFlushAt = -math.huge
local identityLine = nil
local function fs()
return love and love.filesystem
end
local function redactString(s)
if type(s) ~= "string" then return s end
-- Keep printable ASCII + TAB/LF/CR so Lua stack traces remain readable.
-- Reject NULs and other C0 controls, and high bytes (ROM/binary dumps).
for i = 1, #s do
local b = s:byte(i)
if b == 0 then return "<redacted>" end
if b < 32 and b ~= 9 and b ~= 10 and b ~= 13 then return "<redacted>" end
if b > 126 then return "<redacted>" end
end
if #s > 8192 then return s:sub(1, 8192) .. "...<truncated>" end
return s
end
local function sanitize(value, depth)
depth = depth or 0
if depth > 4 then return "<deep>" end
local t = type(value)
if t == "string" then return redactString(value) end
if t == "number" or t == "boolean" or value == nil then return value end
if t == "table" then
local out = {}
for k, v in pairs(value) do
local key = type(k) == "string" and k or tostring(k)
if key:lower():find("rom") or key:lower():find("save") then
out[key] = "<redacted>"
else
out[key] = sanitize(v, depth + 1)
end
end
return out
end
return tostring(value)
end
local function encodePayload(payload)
if payload == nil then return "" end
if type(payload) == "string" then return redactString(payload) end
local parts = {}
for k, v in pairs(sanitize(payload)) do
parts[#parts + 1] = tostring(k) .. "=" .. tostring(v)
end
table.sort(parts)
return table.concat(parts, " ")
end
function SwitchDiagnostics._resetForTests()
enabled = nil
buffer = {}
bufCount = 0
lastFlushAt = -math.huge
identityLine = nil
local filesystem = fs()
if filesystem then
filesystem.remove(ERROR_LOG)
filesystem.remove(ERROR_LOG_ROTATED)
end
end
function SwitchDiagnostics.isEnabled()
if enabled ~= nil then return enabled end
local filesystem = fs()
if not filesystem then
enabled = false
return false
end
enabled = filesystem.getInfo(MARKER) ~= nil
return enabled
end
function SwitchDiagnostics.identityOverlay()
if identityLine then return identityLine end
local gitCommit = os.getenv("POKEPORT_GIT_COMMIT") or "unknown"
local loveNxTag = "11.5-nx1"
local buildVersion = "dev"
local filesystem = fs()
if filesystem then
local raw = filesystem.read("build-info.json")
if raw and raw ~= "" then
local ver = raw:match('"version"%s*:%s*"([^"]+)"')
if ver then buildVersion = ver end
local tag = raw:match('"loveNxTag"%s*:%s*"([^"]+)"')
if tag then loveNxTag = tag end
local commit = raw:match('"gitCommit"%s*:%s*"([^"]+)"')
if commit then gitCommit = commit end
end
end
identityLine = ("gitCommit=%s loveNxTag=%s buildVersion=%s os=%s"):format(
gitCommit, loveNxTag, buildVersion,
love and love.system and love.system.getOS() or "unknown")
return identityLine
end
function SwitchDiagnostics.onEvent(kind, payload)
if not SwitchDiagnostics.isEnabled() then return end
bufCount = bufCount + 1
local slot = ((bufCount - 1) % RING_SIZE) + 1
buffer[slot] = ("%s %s"):format(tostring(kind), encodePayload(payload))
end
function SwitchDiagnostics.onJoystickEvent(kind, joystick, button, extra)
if not SwitchDiagnostics.isEnabled() then return end
local payload = { button = button }
if joystick then
if joystick.getGUID then payload.guid = joystick:getGUID() end
if joystick.isGamepad then payload.isGamepad = joystick:isGamepad() end
if joystick.getName then payload.name = joystick:getName() end
end
if extra then
for k, v in pairs(extra) do payload[k] = v end
end
SwitchDiagnostics.onEvent(kind, payload)
end
function SwitchDiagnostics.logLuaError(msg)
local filesystem = fs()
if not filesystem then return nil end
local text = redactString(tostring(msg or "unknown error"))
local existing = filesystem.read(ERROR_LOG) or ""
if #existing > ERROR_LOG_MAX then
filesystem.write(ERROR_LOG_ROTATED, existing)
existing = ""
end
local stamp = os.date("!%Y-%m-%dT%H:%M:%SZ")
local line = ("[%s] %s\n"):format(stamp, text)
filesystem.write(ERROR_LOG, existing .. line .. SwitchDiagnostics.identityOverlay() .. "\n")
return "Details saved to lua-error.log in the save directory."
end
function SwitchDiagnostics.maybeFlush(force, now)
if not SwitchDiagnostics.isEnabled() then return end
now = now or (love and love.timer and love.timer.getTime() or 0)
if not force and (now - lastFlushAt) < FLUSH_INTERVAL then return end
lastFlushAt = now
local filesystem = fs()
if not filesystem then return end
local lines = { SwitchDiagnostics.identityOverlay(), "---" }
local start = math.max(1, bufCount - RING_SIZE + 1)
for i = start, bufCount do
local slot = ((i - 1) % RING_SIZE) + 1
if buffer[slot] then lines[#lines + 1] = buffer[slot] end
end
filesystem.write(LOG_FILE, table.concat(lines, "\n") .. "\n")
end
-- One-shot NX asset probe written on every Play. No ROM/save bytes — only
-- paths, sizes, resolve results, and whether newImage/newImageData open.
-- Pull sdmc:.../pokemon-love2d/nx-asset-probe.log after a Yellow boot.
local PROBE_LOG = "nx-asset-probe.log"
local function probeInfo(filesystem, path)
local info = filesystem.getInfo(path)
if not info then return "missing" end
local size = info.size
if size == nil then
local bytes = filesystem.read(path)
size = type(bytes) == "string" and #bytes or -1
end
return ("type=%s size=%s"):format(tostring(info.type), tostring(size))
end
local function probeOpen(kind, path)
if kind == "image" then
local ok, err = pcall(love.graphics.newImage, path)
return ok and "ok" or ("FAIL " .. tostring(err):gsub("%s+", " "):sub(1, 160))
end
if not (love.image and love.image.newImageData) then return "skip-no-imageData" end
local ok, err = pcall(love.image.newImageData, path)
return ok and "ok" or ("FAIL " .. tostring(err):gsub("%s+", " "):sub(1, 160))
end
function SwitchDiagnostics.probeAssets(version)
local Platform = require("src.core.Platform")
if not Platform.isNX() then return end
local filesystem = fs()
if not filesystem then return end
local GameVersion = require("src.core.GameVersion")
local Assets = require("src.render.Assets")
local prefix = GameVersion.cachePrefix(version or GameVersion.get())
local lines = {
SwitchDiagnostics.identityOverlay(),
"probe=nx-asset",
"version=" .. tostring(version or GameVersion.get()),
"cachePrefix=" .. tostring(prefix),
"isNX=" .. tostring(Platform.isNX()),
"saveDir=" .. tostring(filesystem.getSaveDirectory and filesystem.getSaveDirectory() or "?"),
}
local samples = {
"assets/generated/fonts/font.png",
"assets/generated/tilesets/reds_house.png",
"assets/generated/sprites/red.png",
"assets/generated/sprites/monster.png",
}
for _, path in ipairs(samples) do
local versioned = prefix ~= "" and (prefix .. path) or path
local resolved = Assets.resolve(path)
lines[#lines + 1] = ("--- %s"):format(path)
lines[#lines + 1] = "unprefixed=" .. probeInfo(filesystem, path)
if prefix ~= "" then
lines[#lines + 1] = "versioned=" .. probeInfo(filesystem, versioned)
end
lines[#lines + 1] = "resolve=" .. tostring(resolved)
lines[#lines + 1] = "newImage=" .. probeOpen("image", resolved)
lines[#lines + 1] = "newImageData=" .. probeOpen("imageData", resolved)
if prefix ~= "" and resolved ~= versioned then
lines[#lines + 1] = "newImage_versioned=" .. probeOpen("image", versioned)
lines[#lines + 1] = "newImageData_versioned=" .. probeOpen("imageData", versioned)
end
end
-- Shallow listing so we can see if the extract tree exists at all.
local roots = { "yellow", "blue", "assets", "yellow/assets/generated",
"yellow/assets/generated/sprites", "blue/assets/generated/sprites" }
for _, dir in ipairs(roots) do
local info = filesystem.getInfo(dir)
if info and info.type == "directory" and filesystem.getDirectoryItems then
local items = filesystem.getDirectoryItems(dir) or {}
local n = math.min(8, #items)
local head = {}
for i = 1, n do head[i] = items[i] end
lines[#lines + 1] = ("list %s count=%d head=%s"):format(
dir, #items, table.concat(head, ","))
else
lines[#lines + 1] = ("list %s %s"):format(dir, info and info.type or "missing")
end
end
filesystem.write(PROBE_LOG, table.concat(lines, "\n") .. "\n")
end
return SwitchDiagnostics
+68 -19
View File
@@ -294,6 +294,24 @@ function CacheFs.read(rel)
return love.filesystem.read(rel)
end
-- Read cache-relative `rel` for the active GameVersion when PhysFS may hide
-- prefixed Blue/Yellow trees (fused NX mount hole). Same order Data:load
-- already used: active version prefix with CacheFs.prefix cleared, then
-- `rel` under the caller's CacheFs.prefix. Returns the bytes or nil.
function CacheFs.readActive(rel)
local GameVersion = require("src.core.GameVersion")
local prefix = GameVersion.cachePrefix()
local saved = CacheFs.prefix
CacheFs.prefix = ""
local bytes = CacheFs.read(prefix .. rel)
CacheFs.prefix = saved
if type(bytes) ~= "string" then
bytes = CacheFs.read(rel)
end
if type(bytes) == "string" then return bytes end
return nil
end
-- does cache-relative `rel` exist as a file?
function CacheFs.exists(rel)
rel = withPrefix(rel)
@@ -361,29 +379,60 @@ 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. Red lives at the
-- cache root and needs nothing; non-Red versions (blue/, yellow/, …) are
-- *prepended* so they win over any Red copy at the root and over the game
-- source. Called once at boot, before Game:load (main.lua). Returns true
-- when nothing was needed or the mount succeeded.
-- "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.
local function mountGeneratedTrees(prefix)
prefix = prefix or ""
if not (love and love.filesystem and love.filesystem.mount) then
return false
end
local mounted = false
local pairs_ = {
{ prefix .. "data/generated", "data/generated" },
{ prefix .. "assets/generated", "assets/generated" },
}
for _, item in ipairs(pairs_) do
local src, dest = item[1], item[2]
if love.filesystem.getInfo(src, "directory") then
if love.filesystem.mount(src, dest, false) then
mounted = true
end
end
end
return mounted
end
function CacheFs.mountVersion(version)
local prefix = require("src.core.GameVersion").cachePrefix(version)
if prefix == "" then return true end -- Red: already at the root
local sub = prefix:gsub("/+$", "") -- "blue/" / "yellow/" -> bare dir
-- The cache root is the portable game folder when active, else LÖVE's OS
-- save directory (where love.filesystem wrote blue/... or yellow/...).
local base = CacheFs.root()
if not base and love.filesystem.getSaveDirectory then
base = love.filesystem.getSaveDirectory()
local sub = prefix:gsub("/+$", "")
-- Save-dir relative mount first (NX / no-FFI). Prepend so blue|yellow win.
if sub ~= "" and love.filesystem.mount
and love.filesystem.getInfo(sub, "directory") then
love.filesystem.mount(sub, "", false)
end
if not base then return false end
if mountReadable(base .. SEP .. sub, false) then return true end
-- Fallback when FFI/PHYSFS_mount is unavailable: LÖVE can mount a folder
-- that lives in the save directory by name (prepended: appendToPath=false).
if love.filesystem.mount then
return love.filesystem.mount(sub, "", false)
-- Portable / desktop fused: absolute PHYSFS_mount of the version folder.
if sub ~= "" then
local base = CacheFs.root()
if not base and love.filesystem.getSaveDirectory then
base = love.filesystem.getSaveDirectory()
end
if base then
mountReadable(base .. SEP .. sub, false)
end
end
return false
-- Version-scoped generated trees → un-prefixed paths (Red prefix is "").
mountGeneratedTrees(prefix)
return true
end
-- Undo mountVersion. A process normally mounts exactly one version and then
+19
View File
@@ -173,6 +173,25 @@ local function coreRows(opts)
end)
end
-- ORIENTATION (#592): Android only -- the lock rides SDL's orientation
-- hint, which iOS reads only at startup (the Info.plist governs there) and
-- desktop ignores. Unlike the other launcher rows this one live-applies:
-- the window exists here too, and rotating under the player's finger is
-- the only feedback that reads.
do
local osName = love.system and love.system.getOS and love.system.getOS()
local okOr, Orientation = pcall(require, "src.core.Orientation")
if okOr and osName == "Android" then
add(Strings("ORIENTATION"),
function() return Strings(Orientation.modeLabel(opts.orientation)) end,
function(dir)
opts.orientation = Orientation.cycle(opts.orientation, dir)
Orientation.apply(opts.orientation)
return true
end)
end
end
local okFr, FaithfulRes = pcall(require, "src.core.FaithfulRes")
if okFr then
add(Strings("FAITHFUL RATIO"),
+96 -23
View File
@@ -78,8 +78,13 @@ local function mk(props)
if props.flexShrink == nil then
props.flexShrink = isScrollOverflow(props) and 1 or 0
end
if isScrollOverflow(props) and props.minHeight == nil then
props.minHeight = 0
if isScrollOverflow(props) then
if props.minHeight == nil then props.minHeight = 0 end
-- Every launcher list scrolls like a native one: interpolated wheel
-- steps instead of hard 20px jumps, and a bigger per-notch distance so
-- long save/mod lists don't take dozens of notches to traverse.
if props.smoothScrollEnabled == nil then props.smoothScrollEnabled = true end
if props.scrollSpeed == nil then props.scrollSpeed = 60 end
end
-- Resolve "100%" here, against the parent's CONTENT width: the engine
-- resolves a percentage against the parent's border box and ignores its
@@ -105,9 +110,63 @@ local COMMUNITY_URL = "https://bois.icu"
local function clamp(v, lo, hi) return math.max(lo, math.min(hi, v)) end
-- FlexLove's addChild auto-sizing only propagates one ancestor while this
-- immediate-mode tree is assembled. Reconcile a completed nested container
-- with the same height box model used by Element:resize.
local function refreshAutoHeight(el)
if type(el) ~= "table" or type(el.autosizing) ~= "table"
or not el.autosizing.height
or type(el.calculateAutoHeight) ~= "function" then
return false
end
local ok, contentHeight = pcall(el.calculateAutoHeight, el)
if not ok or type(contentHeight) ~= "number"
or contentHeight ~= contentHeight
or contentHeight == math.huge or contentHeight == -math.huge then
return false
end
local padding = el.padding or {}
local top, bottom = padding.top or 0, padding.bottom or 0
local borderBoxHeight = clamp(contentHeight + top + bottom,
el.minHeight or -math.huge, el.maxHeight or math.huge)
el._borderBoxHeight = borderBoxHeight
el.height = clamp(math.max(0, borderBoxHeight - top - bottom),
el.minHeight or -math.huge, el.maxHeight or math.huge)
if type(el.invalidateLayout) == "function" then
el:invalidateLayout()
end
return borderBoxHeight
end
LauncherView._refreshAutoHeight = refreshAutoHeight
-- ------- lifecycle
-- NX-only: FlexLove's init maps `performanceMonitoring = false` to true
-- (`false or true`), which leaves layout/render timers + memory sampling on
-- every immediate-mode frame and makes the pad cursor feel lagged. Force
-- them off after init. Desktop keeps the library default. Exported so the
-- engine tier can assert the Switch guards without drawing the full tree.
function LauncherView.applyNxPerfGuards(imp)
if not (imp and imp.isNX and FlexLove.isReady() and FlexLove._Performance) then
return false
end
FlexLove._Performance.enabled = false
local mp = FlexLove._Performance._memoryProfiler
if mp then mp.enabled = false end
-- Immediate-mode rebuilds allocate a full tree every frame; the default
-- auto GC steps hitch the pad cursor on Switch. Less frequent steps, higher
-- threshold — desktop keeps FlexLove defaults.
if FlexLove._gcConfig then
FlexLove._gcConfig.strategy = "periodic"
FlexLove._gcConfig.interval = 90
FlexLove._gcConfig.stepSize = 40
FlexLove._gcConfig.memoryThreshold = 180
end
return true
end
local function ensureFlex(imp)
if not FlexLove.isReady() then
FlexLove.init({
@@ -116,6 +175,9 @@ local function ensureFlex(imp)
keyboardNavigation = false,
})
end
-- Re-apply on every ensure: FlexLove may already be ready from a prior
-- init (hot reload / editor round-trip). No-op when not NX.
LauncherView.applyNxPerfGuards(imp)
if not imp._flex then
imp._flex = true
imp._hot = imp._hot or {}
@@ -134,7 +196,14 @@ end
-- engine draws with raw love.graphics and must not share canvases or input
-- polling with a live UI toolkit.
function LauncherView.detach(imp)
if not imp._flex then return end
-- Restore the NX mouse shim even if _flex was never set (bridge can
-- install on the first update before the first draw).
if imp and imp.parkNxPointerForHost then
pcall(imp.parkNxPointerForHost, imp)
elseif imp and imp._restoreNxPointerBridge then
pcall(imp._restoreNxPointerBridge, imp)
end
if not imp or not imp._flex then return end
imp._flex = nil
if love.keyboard and love.keyboard.setKeyRepeat then
pcall(love.keyboard.setKeyRepeat, false)
@@ -639,8 +708,10 @@ end
-- ------- game panel
local function buildRomCard(imp, parent, m, version, info, ready, locked)
local dropHint = imp.android and Strings("Copy the .gb/.gbc via USB.")
or Strings("Or drop the .gb/.gbc file here.")
local dropHint = imp.isNX and Strings("Copy the .gb/.gbc via MTP into imports/.")
or (imp.android and Strings("Copy the .gb/.gbc via USB.")
or Strings("Or drop the .gb/.gbc file here."))
local importLabel = imp.isNX and Strings("Scan again") or Strings("Import ROM")
local romState, romDetail, romBtnLabel, romBtnEnabled, romProgress
if locked then
romState, romDetail = Strings("Not supported yet"),
@@ -661,12 +732,12 @@ local function buildRomCard(imp, parent, m, version, info, ready, locked)
elseif erroring then
romState = Strings("Import failed")
romDetail = imp.detail or Strings("That ROM could not be imported.")
romBtnLabel, romBtnEnabled = Strings("Import ROM"), true
romBtnLabel, romBtnEnabled = importLabel, true
elseif notice then
romState = Strings("No ROM imported")
romDetail = ((notice.status or "") .. " " .. (notice.detail or ""))
:gsub("^%s+", ""):gsub("%s+$", "")
romBtnLabel, romBtnEnabled = Strings("Import ROM"), true
romBtnLabel, romBtnEnabled = importLabel, true
elseif imp.returning[version] then
romState = Strings("Update required")
romDetail = Strings("This build needs a few more things from your ")
@@ -676,7 +747,7 @@ local function buildRomCard(imp, parent, m, version, info, ready, locked)
romState = Strings("No ROM imported")
romDetail = Strings("The ROM is verified before any files are created. ")
.. dropHint
romBtnLabel, romBtnEnabled = Strings("Import ROM"), true
romBtnLabel, romBtnEnabled = importLabel, true
end
end
@@ -715,13 +786,11 @@ local function buildSaveFilesCard(imp, parent, m, version, ready, locked)
hintText, hintCol = sfNotice.text, (sfNotice.ok and "green" or "danger")
elseif locked then
hintText, hintCol = Strings("Not available yet."), "warn"
elseif imp.android then
hintText = Strings("Import or export a .sav with the system file picker.")
hintCol = "warn"
else
hintText = Strings("Import a .sav to a new slot, or export the active slot.")
hintText = imp:_savesDefaultHint(version)
hintCol = "warn"
end
local savImportLabel = imp.isNX and Strings("Scan again") or Strings("Import save")
local c = card(parent, { padding = m.cardPad, gap = 8 * m.s })
label(c, "SAVE FILES", 12 * m.s + 1, C("gray"))
@@ -730,7 +799,7 @@ local function buildSaveFilesCard(imp, parent, m, version, ready, locked)
-- explicit halves rather than flex growth, which mis-distributed inside
-- an auto-height card
local halfW = math.floor((m.colW - 32 - 10 * m.s) / 2)
button(imp, row, "sav-import-" .. version, Strings("Import save"), {
button(imp, row, "sav-import-" .. version, savImportLabel, {
w = halfW, h = m.btnH, size = 13 * m.s + 1,
kind = sfImportEnabled and "neutral" or "disabled",
action = sfImportEnabled and function()
@@ -902,9 +971,9 @@ local function buildGamePanel(imp, parent, m, version)
-- mode adds the cards straight to the page instead of nesting columns:
-- the engine under-measures a vertical column-of-columns' auto height,
-- which pushed the footer up over the save-slot card on phone shapes.
local left, right
local grid, left, right
if m.twoCol then
local grid = mk({ parent = parent, width = "100%",
grid = mk({ parent = parent, width = "100%",
positioning = "flex", flexDirection = "horizontal",
gap = m.colGap, alignItems = "flex-start" })
left = mk({ parent = grid, width = m.colW,
@@ -934,6 +1003,9 @@ local function buildGamePanel(imp, parent, m, version)
if not locked then
buildSlotCard(imp, right, m, version)
end
-- The right subtree may have grown after FlexLove last measured its
-- grandparent. Refresh only the desktop grid once both columns are complete.
if grid then refreshAutoHeight(grid) end
end
-- ------- mods panel
@@ -963,7 +1035,7 @@ local function buildModsPanel(imp, parent, m)
action = function() imp:_setAllMods(false) end,
})
end
button(imp, head, "mods-import", Strings("Import mod .zip"), {
button(imp, head, "mods-import", imp:_modsImportButtonLabel(), {
h = m.btnH, size = 13 * m.s + 1, kind = "neutral",
action = function() imp:chooseMod() end,
})
@@ -972,8 +1044,7 @@ local function buildModsPanel(imp, parent, m)
label(parent, imp.modNotice.text, 12 * m.s + 2,
C(imp.modNotice.ok and "green" or "danger"))
else
label(parent, imp.android and Strings("Or copy a mod .zip via USB.")
or Strings("Or drop a mod .zip onto the window."), 12 * m.s + 2, C("warn"))
label(parent, imp:_modsDefaultHint(), 12 * m.s + 2, C("warn"))
end
if #mods == 0 then
@@ -984,9 +1055,7 @@ local function buildModsPanel(imp, parent, m)
positioning = "flex", justifyContent = "center", alignItems = "center",
padding = { horizontal = 16 },
})
label(box, imp.android
and Strings("No mods installed - tap Import mod .zip to add one.")
or Strings("No mods installed - drop a mod .zip here to add one."),
label(box, imp:_modsEmptyHint(),
math.floor(12 * m.s + 2.5), C("detail"), { textAlign = "center" })
return
end
@@ -1214,8 +1283,7 @@ local function buildFindPanel(imp, parent, m)
imp.findQuery or "", Strings("Search mods"),
imp._findSearchFocus == true,
function()
imp._findSearchFocus = true
imp:_armTextInput()
imp:_toggleFindSearchFocus()
end)
local cats = (imp.findIndex and imp.findIndex.categories) or {}
@@ -1777,7 +1845,12 @@ end
local function drawPadCursor(imp)
if not imp._padCursorActive then return end
-- Pixel-snap on NX: subpixel polygon edges shimmer on the 720p Switch
-- framebuffer when the stick advances by fractional pixels each frame.
local x, y = imp._padCursor.x, imp._padCursor.y
if imp.isNX then
x, y = math.floor(x + 0.5), math.floor(y + 0.5)
end
love.graphics.push("all")
love.graphics.origin()
love.graphics.setLineWidth(1)
+21 -8
View File
@@ -2023,21 +2023,23 @@ end
-- PikachuCriesPointerTable, 42 `dba` rows; each clip is `dw length` then
-- 1-bit PCM, MSB first -- home/pikachu_cries.asm PlayPikachuPCM toggles
-- rAUD3LEVEL per bit at roughly 190 CPU cycles a sample). Decoded to
-- plain 8-bit mono WAVs; returns the clip count for data.audio.pikaCries,
-- 16-bit stereo WAVs (identical L/R) so OpenAL never spatializes them as
-- ambient surround (#626); returns the clip count for data.audio.pikaCries,
-- or nil when the manifest has no pointer table (Red/Blue).
function RomExtractor:extractPikachuCries()
if not self.symbols["PikachuCriesPointerTable"] then return nil end
local NUM = 42 -- NUM_PIKA_CRIES
local RATE = 22050 -- ~4.19 MHz / ~190 cycles per sample
-- byte -> 8 samples, MSB first (LoadNextSoundClipSample: `and $80`)
-- byte -> 8 mono sample levels, MSB first (LoadNextSoundClipSample: `and $80`)
-- levels match the old unsigned-8 WAV (on=0xE0, off=0x20) as floats in [-1,1]
local lut = {}
for byte = 0, 255 do
local out = {}
for bit = 7, 0, -1 do
local on = math.floor(byte / 2 ^ bit) % 2 == 1
out[#out + 1] = string.char(on and 0xE0 or 0x20)
out[#out + 1] = on and ((0xE0 - 128) / 128) or ((0x20 - 128) / 128)
end
lut[byte] = table.concat(out)
lut[byte] = out
end
local function u16(v)
return string.char(v % 256, math.floor(v / 256) % 256)
@@ -2046,6 +2048,12 @@ function RomExtractor:extractPikachuCries()
return string.char(v % 256, math.floor(v / 256) % 256,
math.floor(v / 65536) % 256, math.floor(v / 16777216) % 256)
end
local function i16le(f)
local v = math.floor(f * 32767 + (f >= 0 and 0.5 or -0.5))
if v > 32767 then v = 32767 elseif v < -32768 then v = -32768 end
if v < 0 then v = v + 65536 end
return string.char(v % 256, math.floor(v / 256) % 256)
end
local CacheFs = require("src.import.CacheFs")
local pointers = self:symbol("PikachuCriesPointerTable")
for index = 0, NUM - 1 do
@@ -2054,11 +2062,16 @@ function RomExtractor:extractPikachuCries()
local header = self.rom:bytes(bank, address, 2)
local length = header[1] + header[2] * 256
local raw = self.rom:bytes(bank, address + 2, length)
local samples = {}
for i, byte in ipairs(raw) do samples[i] = lut[byte] end
local pcm = table.concat(samples)
local parts = {}
for _, byte in ipairs(raw) do
for _, level in ipairs(lut[byte]) do
local s = i16le(level)
parts[#parts + 1] = s .. s -- identical L/R (#626)
end
end
local pcm = table.concat(parts)
local wav = "RIFF" .. u32(36 + #pcm) .. "WAVEfmt " .. u32(16)
.. u16(1) .. u16(1) .. u32(RATE) .. u32(RATE) .. u16(1) .. u16(8)
.. u16(1) .. u16(2) .. u32(RATE) .. u32(RATE * 4) .. u16(4) .. u16(16)
.. "data" .. u32(#pcm) .. pcm
local ok, err = CacheFs.write(
("assets/generated/audio/pika_cries/cry_%02d.wav"):format(index + 1),
+650 -42
View File
@@ -1,6 +1,8 @@
local GameVersion = require("src.core.GameVersion")
local GamepadMap = require("src.core.GamepadMap")
local Strings = require("src.core.Strings")
local HostShell = require("src.core.HostShell")
local Platform = require("src.core.Platform")
local SafeArea = require("src.core.SafeArea")
local RomImporter = {}
@@ -328,6 +330,7 @@ local function releasePointerGrab()
end
local function commandOutput(command)
if not Platform.canSpawnProcess() then return nil end
releasePointerGrab()
local pipe = HostShell.popen(command)
if not pipe then return nil end
@@ -337,6 +340,417 @@ local function commandOutput(command)
return result ~= "" and result or nil
end
local IMPORTS_DIR = "imports"
local MODS_INBOX_DIR = "imports/mods"
local SAVES_INBOX_DIR = "imports/saves"
local ROM_BYTES = 1024 * 1024
local function savesInboxDir(version)
return SAVES_INBOX_DIR .. "/" .. tostring(version)
end
local function savesImportedHashesPath(version)
return savesInboxDir(version) .. "/.imported-sha1"
end
local function exportsDir(version)
return "exports/" .. tostring(version)
end
-- Strip only a validated sdmc:/ prefix for OpenMTP/DBI relative paths.
function RomImporter.mtpHintPath(saveDir)
if type(saveDir) ~= "string" then return "" end
if saveDir:sub(1, 6) == "sdmc:/" then return saveDir:sub(7) end
return saveDir
end
function RomImporter:ensureImportsDir()
local info = love.filesystem.getInfo(IMPORTS_DIR)
if info and info.type == "directory" then return true end
if info then return false end
if love.filesystem.createDirectory then
return love.filesystem.createDirectory(IMPORTS_DIR)
end
return false
end
-- NX mod zip inbox (separate from ROM imports/). Parent imports/ first —
-- love.filesystem.createDirectory does not create nested parents.
function RomImporter:ensureModsInboxDir()
self:ensureImportsDir()
local info = love.filesystem.getInfo(MODS_INBOX_DIR)
if info and info.type == "directory" then return true end
if info then return false end
if love.filesystem.createDirectory then
return love.filesystem.createDirectory(MODS_INBOX_DIR)
end
return false
end
-- NX raw .sav inbox per game: imports/saves/{red,blue,yellow}/.
-- Parent imports/ then imports/saves/ first — createDirectory is not nested.
-- Creates all three version folders so MTP browsing shows where each game goes.
function RomImporter:ensureSavesInboxDir(version)
self:ensureImportsDir()
local info = love.filesystem.getInfo(SAVES_INBOX_DIR)
if info and info.type ~= "directory" then return false end
if not info then
if not (love.filesystem.createDirectory
and love.filesystem.createDirectory(SAVES_INBOX_DIR)) then
return false
end
end
for v in pairs(GameVersion.VERSIONS) do
local dir = savesInboxDir(v)
local vInfo = love.filesystem.getInfo(dir)
if vInfo and vInfo.type ~= "directory" then return false end
if not vInfo then
if not (love.filesystem.createDirectory
and love.filesystem.createDirectory(dir)) then
return false
end
end
end
return true
end
function RomImporter:_setNxInboxNotice(version)
version = version or self.tab or "red"
local saveDir = love.filesystem.getSaveDirectory()
local rel = RomImporter.mtpHintPath(saveDir)
if rel ~= "" and rel:sub(-1) ~= "/" then rel = rel .. "/" end
self.notice = {
version = version,
status = Strings("Copy your .gb/.gbc into:"),
detail = Strings("%s/imports/\nDBI MTP → 1: SD Card/%simports/", saveDir, rel),
}
end
function RomImporter:_setNxModsInboxNotice()
local saveDir = love.filesystem.getSaveDirectory()
local rel = RomImporter.mtpHintPath(saveDir)
if rel ~= "" and rel:sub(-1) ~= "/" then rel = rel .. "/" end
self.modNotice = {
ok = true,
text = Strings("Copy your .zip into:\n%s/imports/mods/\nDBI MTP → 1: SD Card/%simports/mods/",
saveDir, rel),
}
end
function RomImporter:_resolveSaveVersion(version)
version = version or self.panelVersion or self.tab
if GameVersion.VERSIONS[version] then return version end
return self:_savedropTarget()
end
function RomImporter:_setNxSavesInboxNotice(version)
version = self:_resolveSaveVersion(version)
local inbox = savesInboxDir(version)
local saveDir = love.filesystem.getSaveDirectory()
local rel = RomImporter.mtpHintPath(saveDir)
if rel ~= "" and rel:sub(-1) ~= "/" then rel = rel .. "/" end
local game = GameVersion.info(version).displayName
self.saveNotice = self.saveNotice or {}
self.saveNotice[version] = {
ok = true,
text = Strings("Copy your %s .sav into:\n%s/%s/\nDBI MTP → 1: SD Card/%s%s/",
game, saveDir, inbox, rel, inbox),
}
end
local function listRomPaths(dir)
local paths = {}
for _, name in ipairs(love.filesystem.getDirectoryItems(dir) or {}) do
-- Skip AppleDouble / hidden junk from Mac MTP (._cart.gb ends in .gb
-- but is not a ROM — rescan would try it first and block the real dump).
if name:sub(1, 1) ~= "." then
local path = (dir == "" or dir == "/") and name or (dir .. "/" .. name)
if name:lower():match("%.gbc?$")
and love.filesystem.getInfo(path, "file") then
paths[#paths + 1] = path
end
end
end
return paths
end
local function listZipPaths(dir)
local paths = {}
for _, name in ipairs(love.filesystem.getDirectoryItems(dir) or {}) do
-- Skip AppleDouble / hidden junk from Mac MTP (._foo.zip ends in .zip
-- but is not a PhysFS archive — mount fails with "could not be opened").
if name:sub(1, 1) ~= "." then
local path = (dir == "" or dir == "/") and name or (dir .. "/" .. name)
if name:lower():match("%.zip$")
and love.filesystem.getInfo(path, "file") then
paths[#paths + 1] = path
end
end
end
return paths
end
local function listSavPaths(dir)
local paths = {}
for _, name in ipairs(love.filesystem.getDirectoryItems(dir) or {}) do
-- Skip AppleDouble / hidden junk from Mac MTP (._foo.sav ends in .sav
-- but is not a real battery save — import would fail and invent noise).
if name:sub(1, 1) ~= "." then
local path = (dir == "" or dir == "/") and name or (dir .. "/" .. name)
if name:lower():match("%.sav$")
and love.filesystem.getInfo(path, "file") then
paths[#paths + 1] = path
end
end
end
return paths
end
function RomImporter:scanInbox()
local paths = {}
for _, path in ipairs(listRomPaths(IMPORTS_DIR)) do
paths[#paths + 1] = path
end
for _, path in ipairs(listRomPaths("")) do
-- Root scan is second; imports/ entries were already collected above.
paths[#paths + 1] = path
end
return paths
end
-- NX mods inbox: only *.zip under imports/mods/ (never ROM extensions).
function RomImporter:scanModsInbox()
self:ensureModsInboxDir()
return listZipPaths(MODS_INBOX_DIR)
end
-- NX saves inbox: only non-hidden *.sav under imports/saves/<version>/.
function RomImporter:scanSavesInbox(version)
version = self:_resolveSaveVersion(version)
self:ensureSavesInboxDir(version)
return listSavPaths(savesInboxDir(version))
end
local function loadImportedSavHashes(version)
local set = {}
local raw = love.filesystem.read(savesImportedHashesPath(version))
if type(raw) ~= "string" then return set end
for line in raw:gmatch("[^\r\n]+") do
local h = line:match("^(%x+)$")
if h then set[h] = true end
end
return set
end
local function appendImportedSavHash(version, hash)
if type(hash) ~= "string" or hash == "" then return end
local path = savesImportedHashesPath(version)
local prev = love.filesystem.read(path) or ""
if prev:find(hash, 1, true) then return end
love.filesystem.write(path, prev .. hash .. string.char(10))
end
-- Keep bytes for the player (MTP recovery) but stop matching %.sav$ on rescan.
local function retireImportedSav(path)
if type(path) ~= "string" or path == "" then return false end
local data = love.filesystem.read(path)
if type(data) ~= "string" then return false end
local dest = path .. ".imported"
if love.filesystem.getInfo(dest) then
dest = path .. ".imported." .. tostring(os.time())
end
if not love.filesystem.write(dest, data) then return false end
love.filesystem.remove(path)
return true
end
-- Rescan imports/mods/: install each .zip via _installMod / installZip.
-- Never deletes inbox zips (success or failure). Empty inbox → MTP notice.
function RomImporter:rescanModsAction()
if self.workState == "working" then return end
self.tab = "mods"
self:ensureModsInboxDir()
local candidates = self:scanModsInbox()
if #candidates == 0 then
self:_setNxModsInboxNotice()
return
end
local anyOk = false
local lastOk = nil
local lastFail = nil
local failCount = 0
for _, path in ipairs(candidates) do
-- Reuse _installMod carefully: it must not remove the inbox source.
self:_installMod(path)
if self.modNotice and self.modNotice.ok then
anyOk = true
lastOk = self.modNotice
else
failCount = failCount + 1
lastFail = self.modNotice
end
end
-- Success wins overall ok=true so a leftover MTP junk sibling cannot hide
-- a good install; still append the last failure so a real broken zip is
-- visible beside the success line.
if anyOk and lastFail then
local okText = (lastOk and lastOk.text) or "Installed"
local failText = (lastFail and lastFail.text) or "unknown error"
self.modNotice = {
ok = true,
text = Strings("%s\n(%d failed: %s)", okText, failCount, failText),
}
elseif anyOk then
self.modNotice = lastOk
elseif lastFail then
self.modNotice = lastFail
end
end
-- Rescan imports/saves/<version>/: import each new .sav via _importSave.
-- Failure retains the original .sav. Success records a per-game content hash
-- and retires the file to `*.sav.imported` so a second Import save cannot clone
-- slots (bytes stay in the inbox for MTP recovery). Already-hashed content
-- is skipped even under a new filename. Empty / AppleDouble-only → MTP notice.
function RomImporter:rescanSavesAction(version)
if self.workState == "working" then return end
version = self:_resolveSaveVersion(version)
self:ensureSavesInboxDir(version)
local candidates = self:scanSavesInbox(version)
if #candidates == 0 then
self:_setNxSavesInboxNotice(version)
return
end
local seenHashes = loadImportedSavHashes(version)
local okCount, failCount, skipCount = 0, 0, 0
local lastOk, lastFail = nil, nil
local gameLabel = GameVersion.info(version).displayName
for _, path in ipairs(candidates) do
local data = love.filesystem.read(path)
local hash = (type(data) == "string" and data ~= "") and sha1(data) or nil
if hash and seenHashes[hash] then
skipCount = skipCount + 1
-- Leftover live .sav after a prior success: retire without re-importing.
retireImportedSav(path)
else
self:_importSave(version, path)
local notice = self.saveNotice and self.saveNotice[version]
if notice and notice.ok then
okCount = okCount + 1
lastOk = notice
if hash then
seenHashes[hash] = true
appendImportedSavHash(version, hash)
end
retireImportedSav(path)
else
failCount = failCount + 1
lastFail = notice
end
end
end
if okCount > 0 then
local okText
if okCount == 1 and lastOk then
okText = Strings("%s (%s tab)", lastOk.text, gameLabel)
else
okText = Strings("Imported %d saves into %s. Active: %s.",
okCount, gameLabel, tostring(self.activeSlot[version]))
end
if failCount > 0 then
local failText = (lastFail and lastFail.text) or "unknown error"
okText = Strings("%s\n(%d failed: %s)", okText, failCount, failText)
end
if skipCount > 0 then
okText = Strings("%s\n(%d already imported, skipped)", okText, skipCount)
end
self.saveNotice[version] = { ok = true, text = okText }
elseif failCount > 0 then
self.saveNotice[version] = lastFail
elseif skipCount > 0 then
self.saveNotice[version] = {
ok = true,
text = Strings("Already imported — %d file(s) skipped. Check SAVE SLOT.",
skipCount),
}
end
end
-- NX "Scan again" on a game tab: import only the dump whose SHA-1 matches
-- that tab. A shared imports/ inbox often holds Red+Blue+Yellow at once;
-- picking the first pending file would jump Yellow → Red (and switch the
-- launcher tab via startData). Other known dumps stay for their own tabs.
-- Junk (wrong size / unknown hash) still surfaces when nothing matches the
-- tab and no other known dump is present — same feedback as before for a
-- lone bad file.
function RomImporter:rescanAction(version)
if self.workState == "working" then return end
version = version or self.tab or "red"
self.chooseVersion = version
self:ensureImportsDir()
local ready = self.ready
local candidates = self:scanInbox()
local targetReady = false
local sawOtherVersion = false
local junkData, junkName = nil, nil
for _, path in ipairs(candidates) do
local data = love.filesystem.read(path)
local displayName = path:match("[^/\\]+$") or path
if type(data) ~= "string" then
self:setError("The file could not be read: " .. displayName, version)
return
end
if #data ~= ROM_BYTES then
if not junkData then junkData, junkName = data, displayName end
else
local romVersion = GameVersion.forSha1(sha1(data))
if not romVersion then
if not junkData then junkData, junkName = data, displayName end
elseif romVersion ~= version then
sawOtherVersion = true
elseif ready[romVersion] then
targetReady = true
else
self:startData(data, displayName)
return
end
end
end
if targetReady then
self.notice = {
version = version,
status = Strings("No new ROM found."),
detail = Strings("Already-imported dumps are ignored. Add another version or "
.. "delete the copy when finished."),
}
return
end
if junkData and not sawOtherVersion then
self:startData(junkData, junkName)
return
end
if #candidates > 0 then
local label = GameVersion.info(version).displayName
self.notice = {
version = version,
status = Strings("No matching ROM found."),
detail = Strings(
"%s is matched by SHA-1 on this tab. Other dumps in imports/ stay "
.. "for their own tabs — open that game and Scan again.", label),
}
return
end
self:_setNxInboxNotice(version)
end
function RomImporter:_romAction(version)
if self.isNX then
if self.ready[version] then self:reimport(version)
else self:rescanAction(version) end
elseif self.ready[version] then self:reimport(version)
else self:choose(version) end
end
-- Sanitize a string before it is interpolated into a picker shell command:
-- * "%" would be eaten as a string.format directive (#665);
-- * '"' would break the AppleScript / zenity double-quoted argument and
@@ -572,6 +986,7 @@ end
-- import-only run all skip the release check so headless and CI runs never spin
-- up the background worker or reach out to the network.
local function updaterAllowed()
if not Platform.networkValidated() then return false end
if not (love.filesystem.isFused and love.filesystem.isFused()) then return false end
if os.getenv("POKEPORT_AUTOPILOT") or os.getenv("POKEPORT_DRIVER") then return false end
if os.getenv("POKEPORT_IMPORT_ONLY") == "1" then return false end
@@ -596,8 +1011,13 @@ function RomImporter.new(onComplete, opts)
-- pending-file scan plus love.system.pickFile / createFile, provided
-- natively by the Swift GRPickerBridge (mobile/ios/native/). The flag
-- keeps its historical name so every Android call site stays untouched.
-- NX uses a separate save-directory inbox (isNX / romImportMode) and must
-- never set android or take the mobile delete-after-import path.
local mobileOS = love.system.getOS()
local android = mobileOS == "Android" or mobileOS == "iOS"
local isNX = Platform.isNX()
local romImportMode = Platform.romImportMode()
local mobileFileBridge = mobileOS == "Android" or mobileOS == "iOS"
local android = mobileFileBridge
local CacheFs = require("src.import.CacheFs")
local self = setmetatable({
onComplete = onComplete,
@@ -605,6 +1025,9 @@ function RomImporter.new(onComplete, opts)
forceImport = opts.forceImport or false,
onEditSave = opts.onEditSave,
onEditTouchControls = opts.onEditTouchControls,
isNX = isNX,
romImportMode = romImportMode,
mobileFileBridge = mobileFileBridge,
android = android,
ios = mobileOS == "iOS",
-- One startup poll pass on both mobiles. iOS: files dropped through the
@@ -616,11 +1039,11 @@ function RomImporter.new(onComplete, opts)
-- pick never arrives. The file is sitting in the save dir either way, so
-- boot armed and let the first poll tick consume it, rather than making the
-- player tap Import a second time to trigger the scan by hand (#553).
pickPending = android or nil,
pickPending = mobileFileBridge or nil,
-- Mobile drag-scroll goes through FlexLove.touch* (main.lua forwards the
-- full touch stream while the launcher is up). love.touch remains pollable
-- for click hit-testing inside EventHandler.
touchPollable = android and love.touch ~= nil
touchPollable = mobileFileBridge and love.touch ~= nil
and love.touch.getTouches ~= nil and love.touch.getPosition ~= nil,
tab = "red", -- active launcher tab: "red"/"blue"/"yellow"/"mods"
logo = love.graphics.newImage("assets/logo/logo.png"),
@@ -702,7 +1125,7 @@ function RomImporter.new(onComplete, opts)
for _, version in ipairs(GameVersion.ORDER) do
if not self.ready[version] then needRom = true; break end
end
if android and needRom then
if mobileFileBridge and needRom then
local name, data = findPendingRom(self.ready)
if name then
self:startData(data, name)
@@ -711,6 +1134,9 @@ function RomImporter.new(onComplete, opts)
-- is up, so a rejected pick can outlive the focus handler (#442).
consumePickedRomError(self)
end
elseif self.isNX and self.launcher then
self:ensureImportsDir()
self:_setNxInboxNotice()
end
-- Mouse-wheel scroll for the save-slot / mods lists. main.lua (off limits)
@@ -740,13 +1166,16 @@ function RomImporter.new(onComplete, opts)
end
end
-- On Linux handhelds a gamepad is usually already connected at boot; arm
-- the virtual cursor immediately so the player does not have to press a
-- button before seeing something move.
if self.launcher and love.system.getOS() == "Linux"
and love.joystick and love.joystick.getJoystickCount
-- On Linux handhelds / NX a gamepad is usually already connected at boot;
-- arm the virtual cursor immediately so the player does not have to press a
-- button before seeing something move. Desktop keeps the cursor latent
-- until the first stick bump so a plugged DualSense does not steal the mouse.
if self.launcher and love.joystick and love.joystick.getJoystickCount
and love.joystick.getJoystickCount() > 0 then
self:_activatePadCursor()
local osName = (love.system and love.system.getOS and love.system.getOS()) or ""
if osName == "Linux" or self.isNX then
self:_activatePadCursor()
end
end
return self
@@ -940,7 +1369,7 @@ function RomImporter:startData(data, displayName)
and (displayName:match("[^/\\]+$") or displayName)) or self.romName[version]
-- Android: drop the consumed save-dir .gb/.gbc (picked_rom.gb or a USB copy)
-- so the next Choose / focus cannot treat it as a fresh pending ROM.
if self.android and type(displayName) == "string"
if self.mobileFileBridge and type(displayName) == "string"
and not displayName:find("[/\\]") then
love.filesystem.remove(displayName)
end
@@ -948,7 +1377,14 @@ function RomImporter:startData(data, displayName)
self.workState = "complete"
self.completeVersion = version
self.status = "Ready"
self.detail = "Starting " .. info.displayName .. "..."
-- NX launcher stays put: keep the imports/ cleanup hint instead of
-- overwriting it with a "Starting…" line that never boots from here.
if self.launcher and self.isNX and type(displayName) == "string" then
self.detail = Strings("%s imported. You may delete the copy from "
.. "imports/ when finished.", displayName)
else
self.detail = "Starting " .. info.displayName .. "..."
end
self.progress = 1
if self.launcher then
-- Stay on the launcher; the player presses Play to boot the new game.
@@ -1045,8 +1481,14 @@ end
-- Android mirrors ROM import: scan for a pending .zip in the save dir (USB
-- or a fresh SAF drop), else love.system.pickFile("mod") -> picked_mod.zip
-- which focus/Choose consumes on return.
-- NX: no HostShell/desktop picker — rescan imports/mods/ inbox instead.
function RomImporter:chooseMod()
if self.workState == "working" then return end
if self.isNX then
self:ensureModsInboxDir()
self:rescanModsAction()
return
end
if self.ios and love.system.getPickedFile then
self.iosPendingKind = "mod"
if not pickFile("mod") then
@@ -1114,8 +1556,15 @@ end
-- "Import save" button: open a native .sav picker and import the pick.
-- Android mirrors ROM / mod import via love.system.pickFile("sav").
-- NX: no HostShell/desktop picker — rescan imports/saves/ inbox instead.
function RomImporter:chooseSaveImport(version)
if self.workState == "working" then return end
version = self:_resolveSaveVersion(version)
if self.isNX then
self:ensureSavesInboxDir(version)
self:rescanSavesAction(version)
return
end
if self.ios and love.system.getPickedFile then
self.iosPendingKind = "sav"
self.iosPendingVersion = version
@@ -1155,15 +1604,29 @@ end
-- affordance. On Android, stage pending_export.sav and open the system
-- create-document picker (love.system.createFile) so the player can save to
-- Downloads / Drive / etc. -- the app-private exports/ path is not useful there.
-- NX: surface exports path + MTP hint; do not rely on openURL / open-folder.
function RomImporter:exportSave(version)
if self.workState == "working" then return end
version = self:_resolveSaveVersion(version)
local ok, res = require("src.import.SaveFileIO").exportActiveSlot(version)
if not ok then
self.saveNotice[version] = { ok = false, text = tostring(res) }
return
end
if self.isNX then
local saveDir = love.filesystem.getSaveDirectory()
local rel = RomImporter.mtpHintPath(saveDir)
if rel ~= "" and rel:sub(-1) ~= "/" then rel = rel .. "/" end
local outDir = exportsDir(version)
self.saveNotice[version] = {
ok = true,
text = Strings("Exported to %s\nDBI MTP → 1: SD Card/%s%s/", res, rel, outDir),
}
return
end
if self.android then
local rel = res:match("exports[/\\][^/\\]+$")
local rel = res:match("(exports[/\\].+%.[Ss][Aa][Vv])$")
or res:match("(exports[/\\].+)$")
local data = rel and love.filesystem.read(rel)
if not data then
self.saveNotice[version] = { ok = false,
@@ -1215,6 +1678,11 @@ end
function RomImporter:choose(version)
if self.workState == "working" then return end
self.chooseVersion = version or "red"
if self.isNX then
-- Same path as the Scan again button: rescan imports/ (or show MTP hint).
self:rescanAction(self.chooseVersion)
return
end
if self.ios and love.system.getPickedFile then
self.iosPendingKind = "rom"
if not pickFile("rom") then
@@ -1469,6 +1937,71 @@ function RomImporter:_activatePadCursor()
self._padCursorActive = true
end
-- NX: FlexLove hover/hit-test polls love.mouse.getPosition every interactive
-- element. Warping via setPosition every stick frame is expensive on love-nx
-- and makes the virtual cursor lag. Expose the pad pointer through a getPosition
-- shim instead; desktop keeps the setPosition path unchanged.
function RomImporter:_ensureNxPointerBridge()
if not self.isNX or self._nxPointerBridge then return end
if not (love and love.mouse and love.mouse.getPosition) then return end
self._nxRealGetPosition = love.mouse.getPosition
local importer = self
love.mouse.getPosition = function()
if importer._padCursorActive then
return importer._padCursor.x, importer._padCursor.y
end
return importer._nxRealGetPosition()
end
self._nxPointerBridge = true
end
function RomImporter:_restoreNxPointerBridge()
if not self._nxPointerBridge then return end
if love and love.mouse and self._nxRealGetPosition then
love.mouse.getPosition = self._nxRealGetPosition
end
self._nxPointerBridge = false
self._nxRealGetPosition = nil
end
-- NX only: drop the getPosition shim + hide the virtual cursor before a host
-- takes over input (embedded save editor). Desktop is a no-op.
function RomImporter:parkNxPointerForHost()
if not self.isNX then return end
self._padCursorActive = false
self:_restoreNxPointerBridge()
end
-- Temporary overlay handoff (Edit Save / Touch Controls): restore the system
-- arrow cursor, hide the virtual pad pointer, tear down FlexLove when the
-- view is already loaded, and drop the NX getPosition shim. Play uses
-- resetPointerCursor + detach directly because it never returns here.
function RomImporter:prepareOverlayHandoff()
resetPointerCursor(self)
self._padCursorActive = false
-- Avoid requiring LauncherView from headless unit tests (no luautf8). In
-- a real session draw() has already loaded it, so detach runs normally.
if self._flex and package.loaded["src.import.LauncherView"] then
require("src.import.LauncherView").detach(self)
else
self._flex = nil
self:parkNxPointerForHost()
end
end
-- After an overlay closes: re-arm the pad cursor when a stick is already
-- connected so NX / handhelds are not stranded without a pointer until the
-- next stick bump (same class of bug as opening Touch Controls).
function RomImporter:resumeAfterOverlay()
if not self.launcher then return end
if not (love.joystick and love.joystick.getJoystickCount) then return end
if love.joystick.getJoystickCount() <= 0 then return end
local osName = (love.system and love.system.getOS and love.system.getOS()) or ""
if osName == "Linux" or self.isNX then
self:_activatePadCursor()
end
end
function RomImporter:_cycleTab(delta)
local order = { "red", "blue", "yellow", "mods", "find" }
local idx = 1
@@ -1479,15 +2012,26 @@ function RomImporter:_cycleTab(delta)
end
function RomImporter:_updatePadCursor(dt)
-- Real mouse motion yields the pad cursor so desktop users keep a normal
-- pointer after bumping a stick once.
local mx, my = love.mouse.getPosition()
if self._lastMouseX and self._padCursorActive then
if math.abs(mx - self._lastMouseX) > 3 or math.abs(my - self._lastMouseY) > 3 then
self._padCursorActive = false
end
if self.isNX then
self:_ensureNxPointerBridge()
-- Cap dt so a hitch in the FlexLove immediate-mode frame does not fling
-- the cursor; desktop keeps raw dt (setPosition path already smooth there).
if dt > 1 / 30 then dt = 1 / 30 end
end
-- Real mouse motion yields the pad cursor so desktop users keep a normal
-- pointer after bumping a stick once. On NX this must stay off: love-nx /
-- SDL often drifts the system mouse with the stick (or touch), and axis
-- events are not every frame, so yield+reactivate flickers the overlay.
if not self.isNX then
local mx, my = love.mouse.getPosition()
if self._lastMouseX and self._padCursorActive then
if math.abs(mx - self._lastMouseX) > 3 or math.abs(my - self._lastMouseY) > 3 then
self._padCursorActive = false
end
end
self._lastMouseX, self._lastMouseY = mx, my
end
self._lastMouseX, self._lastMouseY = mx, my
local ax = self._padAxis.leftx or 0
local ay = self._padAxis.lefty or 0
@@ -1510,11 +2054,9 @@ function RomImporter:_updatePadCursor(dt)
local ny = self._padCursor.y + dy * speed * dt
self._padCursor.x = math.max(ox, math.min(ox + w, nx))
self._padCursor.y = math.max(oy, math.min(oy + h, ny))
-- The FlexLove view polls the real mouse for hover and wheel targeting,
-- so the pad pointer warps it along. The self-caused motion is recorded
-- as the last seen position, or the yield check above would read the warp
-- as real mouse movement and drop the pad cursor immediately.
if love.mouse.setPosition then
-- Desktop: FlexLove polls the real mouse, so warp it with the pad pointer.
-- NX: the getPosition bridge already returns pad coords — skip setPosition.
if not self.isNX and love.mouse.setPosition then
pcall(love.mouse.setPosition, self._padCursor.x, self._padCursor.y)
self._lastMouseX, self._lastMouseY = self._padCursor.x, self._padCursor.y
end
@@ -1532,7 +2074,9 @@ end
function RomImporter:gamepadpressed(_, button)
self:_activatePadCursor()
if button == "a" then
-- Map through GamepadMap so NX swaps SDL face labels to Nintendo A/B.
local action = GamepadMap.mapGamepadButton(button)
if action == "a" then
-- Instant click at the virtual pointer: dispatched straight into the
-- view, since the launcher no longer hit-tests presses itself.
if self._flex then
@@ -1551,7 +2095,7 @@ function RomImporter:gamepadpressed(_, button)
if self.workState == "working" then return end
local version = self.tab
if GameVersion.VERSIONS[version] then
if self.ready[version] then self:play(version) else self:choose(version) end
if self.ready[version] then self:play(version) else self:_romAction(version) end
end
end
end
@@ -1570,25 +2114,23 @@ function RomImporter:gamepadaxis(_, axis, value)
end
end
-- Same gate as src/core/Input.lua's isMappedPad: a pad SDL can map already
-- reached gamepadpressed this frame, so re-entering it from the raw event
-- would fire the virtual cursor's click twice off one A press (#620).
local function isMappedPad(joystick)
return joystick ~= nil and joystick.isGamepad ~= nil and joystick:isGamepad()
end
-- Same gate as src/core/Input.lua: a pad SDL can map already reached
-- gamepadpressed this frame, so re-entering it from the raw event would
-- fire the virtual cursor's click twice off one A press (#620).
function RomImporter:joystickpressed(joystick, button)
if isMappedPad(joystick) then return end
if button == 1 then self:gamepadpressed(joystick, "a") end
if GamepadMap.ignoreRawForJoystick(joystick) then return end
local padButton = GamepadMap.mapRawToGamepadButton(button)
if padButton then self:gamepadpressed(joystick, padButton) end
end
function RomImporter:joystickreleased(joystick, button)
if isMappedPad(joystick) then return end
if button == 1 then self:gamepadreleased(joystick, "a") end
if GamepadMap.ignoreRawForJoystick(joystick) then return end
local padButton = GamepadMap.mapRawToGamepadButton(button)
if padButton then self:gamepadreleased(joystick, padButton) end
end
function RomImporter:joystickaxis(joystick, axis, value)
if isMappedPad(joystick) then return end
if GamepadMap.ignoreRawForJoystick(joystick) then return end
if axis == 1 then
self:gamepadaxis(joystick, "leftx", value)
elseif axis == 2 then
@@ -1597,7 +2139,7 @@ function RomImporter:joystickaxis(joystick, axis, value)
end
function RomImporter:joystickhat(joystick, hat, direction)
if isMappedPad(joystick) then return end
if GamepadMap.ignoreRawForJoystick(joystick) then return end
for _, dir in ipairs(self._rawHatDirs[hat] or {}) do
self._padDir[dir] = nil
end
@@ -1733,6 +2275,15 @@ function RomImporter:_switchTab(id)
self:_disarmTextInput()
end
function RomImporter:_toggleFindSearchFocus()
self._findSearchFocus = not self._findSearchFocus
if self._findSearchFocus then
self:_armTextInput()
else
self:_disarmTextInput()
end
end
-- ------- settings gear (options.lua + enabled mods' option schemas)
function RomImporter:_openSettings()
@@ -1835,7 +2386,7 @@ function RomImporter:keypressed(key)
-- open its picker. The mods tab has no keyboard action.
local version = self.tab
if GameVersion.VERSIONS[version] then
if self.ready[version] then self:play(version) else self:choose(version) end
if self.ready[version] then self:play(version) else self:_romAction(version) end
end
end
end
@@ -2143,6 +2694,11 @@ end
-- Update button: when a newer release is known, confirm then install; when
-- already current, force-refresh the 6h cache and report / offer update.
function RomImporter:_modGithubAction(id, action)
if not Platform.networkValidated() then
self.modNotice = { ok = false,
text = "Remote mod download is unavailable on this platform." }
return
end
local ran, err = pcall(function()
local ModUpdate = require("src.mods.ModUpdate")
local row
@@ -2282,6 +2838,53 @@ function RomImporter:_installModVersion(modId, release)
end
end
-- NX / desktop / Android labels and inbox hints for the FlexLove view.
function RomImporter:_modsImportButtonLabel()
if self.isNX then return Strings("Scan again") end
return "Import mod .zip"
end
function RomImporter:_modsDefaultHint()
if self.isNX then
local saveDir = love.filesystem.getSaveDirectory()
local rel = RomImporter.mtpHintPath(saveDir)
if rel ~= "" and rel:sub(-1) ~= "/" then rel = rel .. "/" end
return Strings("Copy a .zip via MTP into %s/imports/mods/\n"
.. "DBI MTP → 1: SD Card/%simports/mods/", saveDir, rel)
end
if self.android then return "Or copy a mod .zip via USB." end
return Strings("Or drop a mod .zip onto the window.")
end
function RomImporter:_savesDefaultHint(version)
if self.isNX then
version = self:_resolveSaveVersion(version)
local inbox = savesInboxDir(version)
local saveDir = love.filesystem.getSaveDirectory()
local rel = RomImporter.mtpHintPath(saveDir)
if rel ~= "" and rel:sub(-1) ~= "/" then rel = rel .. "/" end
local game = GameVersion.info(version).displayName
return Strings("Copy a %s .sav via MTP into %s/%s/\n"
.. "DBI MTP → 1: SD Card/%s%s/", game, saveDir, inbox, rel, inbox)
end
if self.android then
return "Import or export a .sav with the system file picker."
end
return Strings("Import a .sav to a new slot, or export the active slot.")
end
function RomImporter:_modsEmptyHint()
if self.isNX then
return Strings("No mods installed - copy a .zip into imports/mods/ "
.. "and tap Scan again.")
end
if self.android then
return "No mods installed - tap Import mod .zip to add one."
end
return Strings("No mods installed - drop a mod .zip here to add one.")
end
-- ------- FIND MODS: browsing a community mod index -------------------------
--
-- The index is metadata only (src/mods/ModIndex.lua): it says where a mod's
@@ -2309,6 +2912,11 @@ end
-- must not offer two. Per-source failures are collected rather than fatal: an
-- index that is down should cost its own rows, not everybody else's.
function RomImporter:_refreshFind(force)
if not Platform.networkValidated() then
self.findLoaded = true
self.findIndex = { mods = {}, categories = {} }
return
end
local ModIndex = require("src.mods.ModIndex")
self:_refreshFindSources()
local mods, seen, cats, catSeen, errs = {}, {}, {}, {}, {}
+11 -7
View File
@@ -6,8 +6,8 @@
-- bytes), runs them through SaveConvert.importSav (32768-byte + checksum
-- validated), then registers a fresh slot, writes it, and makes it active.
-- Export loads the active slot, encodes it back to a 32768-byte SRAM image, and
-- drops it in the save directory's exports/ folder, returning the absolute path
-- so the launcher can offer an "open folder" affordance.
-- drops it in the save directory's exports/<version>/ folder, returning the
-- absolute path so the launcher can offer an "open folder" affordance.
--
-- Every failure returns false + a friendly one-line message (never raises), so
-- the card can surface it as a red notice line rather than crashing.
@@ -99,9 +99,9 @@ end
-- exportActiveSlot(version) -> ok, pathOrErr
-- Loads the version's active slot save (SaveData.load semantics), encodes it
-- back to a 32768-byte SRAM image, and writes it to
-- exports/gen1recomp-<version>-<slotId>.sav in the save directory (created if
-- absent). Returns true + the absolute path on success, false + a friendly
-- message otherwise.
-- exports/<version>/gen1recomp-<version>-<slotId>.sav in the save directory
-- (created if absent). Returns true + the absolute path on success, false + a
-- friendly message otherwise.
function SaveFileIO.exportActiveSlot(version)
version = version or GameVersion.get()
local save = SaveData.load(version)
@@ -111,8 +111,12 @@ function SaveFileIO.exportActiveSlot(version)
local slotId = SaveData.activeSlot(version) or "save"
local fs = love and love.filesystem
if not (fs and fs.write) then return false, "no filesystem available to export to" end
if fs.createDirectory then fs.createDirectory("exports") end
local rel = ("exports/gen1recomp-%s-%s.sav"):format(version, slotId)
if fs.createDirectory then
fs.createDirectory("exports")
fs.createDirectory("exports/" .. version)
end
-- Per-game folder so MTP browsing matches inbox layout (red/blue/yellow).
local rel = ("exports/%s/gen1recomp-%s-%s.sav"):format(version, version, slotId)
local ok, writeErr = fs.write(rel, bytes)
if not ok then return false, "could not write the export: " .. tostring(writeErr) end
local base = fs.getSaveDirectory and fs.getSaveDirectory() or ""
+31 -1
View File
@@ -169,6 +169,8 @@ end
-- full identical link surfaces: nothing to negotiate, lockstep is safe
-- vanilla_peer an old build, and we are unmodified, so it is right about us
-- engine_skew both v2 on the same major, but different releases: trade
-- still negotiates, battle is refused (see below)
-- subset both v2 but the surfaces differ: negotiated trade, no battle
-- refused an old build we would silently corrupt, or a different engine
function Handshake.checkCompat(localHello, remoteHello)
@@ -182,6 +184,16 @@ function Handshake.checkCompat(localHello, remoteHello)
if major(remoteHello.engineVersion) ~= major(localHello.engineVersion) then
return "refused", "engine_mismatch"
end
-- A lockstep battle needs the same engine RELEASE, not just the same
-- major: the fingerprint only covers the data/mod link surface, and
-- battle logic changes between minor releases (parity fixes, move
-- effect rework...), so two honest vanilla installs a release apart
-- pair as "full" and then diverge a few turns in -- the mid-battle
-- "same mods?" desync draw of #758. Trade doesn't lockstep a
-- simulation, so it stays negotiable across releases.
if tostring(remoteHello.engineVersion) ~= tostring(localHello.engineVersion) then
return "engine_skew", "engine_release_mismatch"
end
if remoteHello.fingerprint == localHello.fingerprint then
return "full", nil
end
@@ -191,7 +203,7 @@ end
-- only two v2 peers that agreed on a verdict may reject a mon outright; a v1
-- peer keeps the old substitute-a-move behaviour it was built against
function Handshake.strict(verdict)
return verdict == "full" or verdict == "subset"
return verdict == "full" or verdict == "subset" or verdict == "engine_skew"
end
function Handshake.battleAllowed(verdict)
@@ -278,6 +290,24 @@ function Handshake.describe(localHello, remoteHello, verdict, mode)
end
return lines
end
if verdict == "engine_skew" then
-- name both releases so two friends can tell WHO updates: this used
-- to surface three turns in as a desync draw blaming mods (#758)
wrap(lines, "Your game versions")
wrap(lines, "differ:")
wrap(lines, (" you: v%s"):format(tostring(localHello.engineVersion)))
wrap(lines, (" %s: v%s"):format(peer:sub(1, 8),
tostring(remoteHello.engineVersion)))
if mode == "battle" then
wrap(lines, "Battle needs the")
wrap(lines, "same version on")
wrap(lines, "both games.")
else
wrap(lines, "Trading is limited")
wrap(lines, "to shared POKéMON.")
end
return lines
end
wrap(lines, "Your games differ.")
local diff = Handshake.modDiff(localHello, remoteHello)
listMods(lines, peer .. " has:", diff.onlyTheirs)
+3 -3
View File
@@ -202,7 +202,7 @@ function LinkBattle.new(game, net, opts)
local theirName = opts.theirName or "FOE"
if not Handshake.battleAllowed(opts.verdict) then
return nil, Strings("Link battle needs\nthe same mods on\nboth games.")
return nil, Strings("Link battle needs\nthe same version\nand mods.")
end
-- both parties pass through the same pack->unpack clamp on both
@@ -342,7 +342,7 @@ function LinkBattle.new(game, net, opts)
localHash = localH, remoteHash = remoteH,
fatal = true })
endAsDraw(s, Strings(
"Link desync!\n%s differs.\fAre both games\nrunning the same\nmods?",
"Link desync!\n%s differs.\fAre both games\nthe same version\nand mods?",
component))
end
@@ -692,7 +692,7 @@ function LinkBattle.newSpectator(game, net, opts)
local guestName = opts.guestName or "GUEST"
if not Handshake.battleAllowed(opts.verdict) then
return nil, Strings("Link battle needs\nthe same mods on\nboth games.")
return nil, Strings("Link battle needs\nthe same version\nand mods.")
end
local unpackOpts = { strict = opts.strict or false, forceLevel = opts.forceLevel }
+3 -1
View File
@@ -714,7 +714,9 @@ function LinkState:draw()
end
elseif self.stage == "notice" then
drawTitle("CHECK YOUR MODS")
-- a version-skew notice has nothing to do with mods (#758)
drawTitle(self.verdict == "engine_skew" and "UPDATE YOUR GAME"
or "CHECK YOUR MODS")
for i, line in ipairs(self.noticeLines or {}) do
if i > 8 then break end -- what fits above the prompt row
Font.draw(line, 8, 24 + (i - 1) * 12)
+56 -15
View File
@@ -282,10 +282,18 @@ end
-- ------- install (love.filesystem)
-- Read a .zip source into bytes. A string is an external absolute path (like
-- a chosen ROM) read with io.*, falling back to a save-dir-relative
-- love.filesystem read; a love DroppedFile is opened the way RomImporter
-- ingests dropped ROMs.
-- Read a .zip source into bytes. Save-dir-relative paths (inbox /
-- picked_mod.zip) prefer love.filesystem so NX/Android never hit a cwd-relative
-- io.open that can see a different file than PhysFS. Absolute host paths
-- (desktop picker) still use io.*. DroppedFile matches RomImporter ROM drops.
local function isHostAbsolutePath(path)
return type(path) == "string" and (
path:match("^/")
or path:match("^%a:[/\\]")
or path:match("^[Ss][Dd][Mm][Cc]:")
)
end
local function readArchive(source)
local t = type(source)
if (t == "userdata" or t == "table") and type(source.open) == "function" then
@@ -297,6 +305,10 @@ local function readArchive(source)
return data
end
if t == "string" then
if not isHostAbsolutePath(source) and love and love.filesystem then
local data = love.filesystem.read(source)
if data then return data end
end
local f = io.open(source, "rb")
if f then
local data = f:read("*a")
@@ -313,6 +325,12 @@ local function readArchive(source)
return nil, "unsupported archive source"
end
-- Local PK\3\4 / empty-file check before mount (corrupt MTP / AppleDouble).
local function zipLooksValid(data)
if type(data) ~= "string" or #data < 4 then return false end
return data:sub(1, 2) == "PK"
end
-- Shallow listing of a mounted archive shaped for locateRoot: files by name,
-- and for each top-level directory a "<dir>/manifest.json" marker only when it
-- actually holds one (so a lone folder with no manifest still reads as empty).
@@ -510,21 +528,44 @@ function LauncherMods._installZipInner(source, opts)
local fs = love.filesystem
local data, readErr = readArchive(source)
if not data then return nil, readErr end
-- stage into a save-dir temp so mount can reach it
local tmp = ("mod_import_%d_%d.zip"):format(os.time(), math.random(0, 999999))
local ok, writeErr = fs.write(tmp, data)
if not ok then
return nil, "could not stage the .zip: " .. tostring(writeErr)
if not zipLooksValid(data) then
local label = type(source) == "string" and (source:match("[^/\\]+$") or source)
or "archive"
return nil, "not a zip file: " .. tostring(label)
.. " (need a real .zip; skip Mac ._ files from MTP)"
end
-- Prefer in-memory mount (PHYSFS_mountMemory via FileData). Avoids Horizon's
-- "file already open" failure when write-then-mount reopens a save-dir zip.
local mount = "mod_import_mount"
if not fs.mount(tmp, mount) then
fs.remove(tmp)
return nil, "that .zip could not be opened"
local tmp = nil
local mountKey = nil
local mounted = false
if fs.newFileData then
local archiveName = ("mod_import_%d_%d.zip"):format(
os.time(), math.random(0, 999999))
local okFd, fd = pcall(fs.newFileData, data, archiveName)
if okFd and fd and fs.mount(fd, mount) then
mounted = true
mountKey = fd
end
end
if not mounted then
-- Fallback: stage into a save-dir temp so path-mount can reach it.
tmp = ("mod_import_%d_%d.zip"):format(os.time(), math.random(0, 999999))
local ok, writeErr = fs.write(tmp, data)
if not ok then
return nil, "could not stage the .zip: " .. tostring(writeErr)
end
if not fs.mount(tmp, mount) then
fs.remove(tmp)
return nil, "that .zip could not be opened"
end
mountKey = tmp
end
local function cleanup()
pcall(fs.unmount, tmp)
fs.remove(tmp)
pcall(fs.unmount, mountKey)
if tmp then fs.remove(tmp) end
end
local prefix, rootErr = LauncherMods.locateRoot(topLevelPaths(mount))
+20 -11
View File
@@ -4,9 +4,9 @@
-- its own file without editing a single record, and one flush() drops
-- every downstream cache for dev-mode hot reload.
--
-- No loader installed means resolve() is the identity, which is what
-- keeps a mod-free boot (and every headless test) loading exactly the
-- paths it always did.
-- No loader installed means resolve() is the identity. The NX Blue/Yellow
-- versioned-cache fallback lives in src/core/NxAssetOverlay.lua (installed
-- once at boot on NX only), not here, so this module stays platform-free.
local Assets = {}
@@ -29,18 +29,27 @@ local function exists(path)
end
Assets.exists = exists
-- an override dir shadows the generated cache; a transform's derived
-- output is the fallback under it, so hand-authored art beats generated
-- Mod overrides win; on NX, Blue/Yellow then use the prefixed save-dir file;
-- otherwise the caller's unprefixed path (Red / mounted overlay).
function Assets.resolve(path)
local loader = Assets.loader
if not loader or type(path) ~= "string" then return path end
if type(path) ~= "string" then return path end
if path:sub(1, #GENERATED) ~= GENERATED then return path end
local rel = path:sub(#GENERATED + 1)
for _, mod in ipairs(loader:overrideOrder()) do
local candidate = mod.path .. "/overrides/" .. rel
if exists(candidate) then return candidate end
local loader = Assets.loader
if loader then
for _, mod in ipairs(loader:overrideOrder()) do
local candidate = mod.path .. "/overrides/" .. rel
if exists(candidate) then return candidate end
end
local derived = loader:derivedPath(rel)
if derived then return derived end
end
return loader:derivedPath(rel) or path
-- NX Blue/Yellow: no rewrite here -- NxAssetOverlay (installed once at
-- boot on NX only) covers every loader globally, so this module stays
-- the mod-override choke point it always was.
return path
end
function Assets.image(path)
+22 -15
View File
@@ -153,21 +153,28 @@ function NamingScreen:update(dt)
if self.row ~= caseRow then
self.col = self.col < #GRID[self.row] and self.col + 1 or 1
end
elseif input:wasPressed("b") then
table.remove(self.glyphs)
elseif input:wasPressed("a") then
if self.row == edRow and self.col == edCol then
self:confirm()
return
end
if self.row == caseRow then
self.lower = not self.lower
return
end
if #self.glyphs < self.maxLen then
Sound.play(self.game.data, "Press_AB")
table.insert(self.glyphs, GRID[self.row][self.col])
if #self.glyphs >= self.maxLen then self:jumpToEnd() end
else
-- Prefer A over B when both edges fire in one frame (love-nx dual
-- gamepad+raw path historically set both; erase must not win).
local pressedA = input:wasPressed("a")
local pressedB = input:wasPressed("b")
if pressedA and pressedB then pressedB = false end
if pressedB then
table.remove(self.glyphs)
elseif pressedA then
if self.row == edRow and self.col == edCol then
self:confirm()
return
end
if self.row == caseRow then
self.lower = not self.lower
return
end
if #self.glyphs < self.maxLen then
Sound.play(self.game.data, "Press_AB")
table.insert(self.glyphs, GRID[self.row][self.col])
if #self.glyphs >= self.maxLen then self:jumpToEnd() end
end
end
end
end
+22
View File
@@ -19,6 +19,7 @@ local TileRenderer = require("src.render.TileRenderer")
local GameSpeed = require("src.core.GameSpeed")
local GameVersion = require("src.core.GameVersion")
local VideoMode = require("src.core.VideoMode")
local Orientation = require("src.core.Orientation")
local FaithfulRes = require("src.core.FaithfulRes")
local FrameCap = require("src.core.FrameCap")
local Performance = require("src.core.Performance")
@@ -350,6 +351,19 @@ local function buildRows(game)
VideoMode.apply(o.videoMode)
return true
end },
-- Android orientation lock (#592): AUTO / PORTRAIT / LANDSCAPE /
-- REVERSE LANDSCAPE, live-applied through SDL's orientation hint.
-- Filtered out below on everything that is not Android.
{ id = "orientation", label = Strings("ORIENTATION"),
value = function(g)
return Strings(Orientation.modeLabel(g.save.options.orientation))
end,
step = function(g, dir)
local o = g.save.options
o.orientation = Orientation.cycle(o.orientation, dir)
Orientation.apply(o.orientation)
return true
end },
-- Lock the window to an exact 160x144 multiple, so the surface IS the
-- Game Boy screen with no letterbox at all. Sits next to VIDEO MODE
-- because it overrides it: holding an exact size means dropping
@@ -432,6 +446,14 @@ local function buildRows(game)
end
rows = filtered
end
-- ORIENTATION only on Android, the one platform Orientation.apply reaches.
if not Orientation.isAndroid() then
local filtered = {}
for _, row in ipairs(rows) do
if row.id ~= "orientation" then filtered[#filtered + 1] = row end
end
rows = filtered
end
-- TOUCH PAD only where the overlay can appear (mobile, or desktop with
-- POKEPORT_TOUCH=1). POKEPORT_TOUCH=0 forces it off everywhere.
do
+215
View File
@@ -0,0 +1,215 @@
-- Virtual pointer for overlay hosts (save editor, touch-controls editor) on
-- Switch / handhelds / any gamepad. Mirrors the launcher's RomImporter pad
-- cursor (speeds, deadzone, dual-path raw gate) without sharing that module.
--
-- Stick / D-pad move; real mouse motion yields so desktop stays normal.
-- Callers map A → click, B → close, shoulders → host-specific actions, right
-- stick → wheel notches (save editor lists).
local SafeArea = require("src.core.SafeArea")
local GamepadMap = require("src.core.GamepadMap")
local PAD_DEAD = 0.28
local PAD_SPEED = 560
local PAD_DPAD_SPEED = 420
-- Right stick → Kit wheel notches: ~2 notches/sec at full deflection so lists
-- scroll at a usable pace without flooding one frame.
local PAD_WHEEL_RATE = 2.0
local PadCursor = {}
local cursor = { x = 0, y = 0 }
local active = false
local inited = false
local axis = { leftx = 0, lefty = 0, righty = 0 }
local dir = {}
local rawHatDirs = {}
local lastMouseX, lastMouseY
local wheelAcc = 0
local function activate()
if active then return end
local ox, oy, w, h = SafeArea.rect()
if not inited then
cursor.x = ox + w * 0.5
cursor.y = oy + h * 0.45
inited = true
end
active = true
end
function PadCursor.reset()
cursor.x, cursor.y = 0, 0
active = false
inited = false
axis.leftx, axis.lefty, axis.righty = 0, 0, 0
for k in pairs(dir) do dir[k] = nil end
for k in pairs(rawHatDirs) do rawHatDirs[k] = nil end
lastMouseX, lastMouseY = nil, nil
wheelAcc = 0
end
-- Touch / mouse press: drop the virtual cursor for this interaction so a tap
-- is not swallowed by the Joy-Con pointer sitting elsewhere on screen.
function PadCursor.yieldToPointer()
active = false
end
-- Returns mx, my, isActive. When inactive the caller should use the system
-- mouse; when active these coords feed hit-tests / draws.
function PadCursor.pointer()
return cursor.x, cursor.y, active
end
function PadCursor.isActive()
return active
end
-- Consume accumulated right-stick scroll as integer wheel notches (same
-- units App.wheelmoved feeds Kit). Fractional remainder stays for next frame.
function PadCursor.takeWheel()
local notches = 0
if wheelAcc >= 1 or wheelAcc <= -1 then
notches = wheelAcc > 0 and math.floor(wheelAcc) or math.ceil(wheelAcc)
wheelAcc = wheelAcc - notches
end
return notches
end
function PadCursor.update(dt)
if not (love and love.mouse and love.mouse.getPosition) then return end
local mx, my = love.mouse.getPosition()
if lastMouseX and active then
if math.abs(mx - lastMouseX) > 3 or math.abs(my - lastMouseY) > 3 then
active = false
end
end
lastMouseX, lastMouseY = mx, my
local ax = axis.leftx or 0
local ay = axis.lefty or 0
local dx, dy = 0, 0
if math.abs(ax) > PAD_DEAD then dx = dx + ax end
if math.abs(ay) > PAD_DEAD then dy = dy + ay end
if dir.dpleft then dx = dx - 1 end
if dir.dpright then dx = dx + 1 end
if dir.dpup then dy = dy - 1 end
if dir.dpdown then dy = dy + 1 end
if dx ~= 0 or dy ~= 0 then
activate()
local mag = math.sqrt(dx * dx + dy * dy)
if mag > 1 then dx, dy = dx / mag, dy / mag end
local speed = (math.abs(ax) > PAD_DEAD or math.abs(ay) > PAD_DEAD)
and PAD_SPEED or PAD_DPAD_SPEED
local ox, oy, w, h = SafeArea.rect()
local nx = cursor.x + dx * speed * dt
local ny = cursor.y + dy * speed * dt
cursor.x = math.max(ox, math.min(ox + w, nx))
cursor.y = math.max(oy, math.min(oy + h, ny))
end
local ry = axis.righty or 0
if math.abs(ry) > PAD_DEAD then
activate()
-- Negative righty (stick up) scrolls lists up = positive wheel notches.
wheelAcc = wheelAcc + (-ry) * PAD_WHEEL_RATE * dt
end
end
-- Returns a string action the host handles:
-- "a" | "b" | "tab_prev" | "tab_next" | nil
function PadCursor.gamepadpressed(_, button)
activate()
local action = GamepadMap.mapGamepadButton(button)
if action == "a" or action == "b" then
return action
elseif button == "leftshoulder" then
return "tab_prev"
elseif button == "rightshoulder" then
return "tab_next"
elseif button == "dpup" or button == "dpdown"
or button == "dpleft" or button == "dpright" then
dir[button] = true
end
return nil
end
function PadCursor.gamepadreleased(_, button)
if button == "dpup" or button == "dpdown"
or button == "dpleft" or button == "dpright" then
dir[button] = nil
end
end
function PadCursor.gamepadaxis(_, axisName, value)
if axisName == "leftx" or axisName == "lefty" or axisName == "righty" then
axis[axisName] = value
if math.abs(value) > PAD_DEAD then activate() end
end
end
function PadCursor.joystickpressed(joystick, button)
if GamepadMap.ignoreRawForJoystick(joystick) then return nil end
local padButton = GamepadMap.mapRawToGamepadButton(button)
if padButton then return PadCursor.gamepadpressed(joystick, padButton) end
return nil
end
function PadCursor.joystickreleased(joystick, button)
if GamepadMap.ignoreRawForJoystick(joystick) then return end
local padButton = GamepadMap.mapRawToGamepadButton(button)
if padButton then PadCursor.gamepadreleased(joystick, padButton) end
end
function PadCursor.joystickaxis(joystick, axisIndex, value)
if GamepadMap.ignoreRawForJoystick(joystick) then return end
if axisIndex == 1 then
PadCursor.gamepadaxis(joystick, "leftx", value)
elseif axisIndex == 2 then
PadCursor.gamepadaxis(joystick, "lefty", value)
end
end
function PadCursor.joystickhat(joystick, hat, direction)
if GamepadMap.ignoreRawForJoystick(joystick) then return end
for _, d in ipairs(rawHatDirs[hat] or {}) do
dir[d] = nil
end
local dirs = ({
u = { "dpup" }, d = { "dpdown" }, l = { "dpleft" }, r = { "dpright" },
lu = { "dpleft", "dpup" }, ru = { "dpright", "dpup" },
ld = { "dpleft", "dpdown" }, rd = { "dpright", "dpdown" },
})[direction] or {}
for _, d in ipairs(dirs) do dir[d] = true end
rawHatDirs[hat] = dirs
if #dirs > 0 then activate() end
end
function PadCursor.draw()
if not active then return end
if not (love and love.graphics) then return end
local x, y = cursor.x, cursor.y
love.graphics.push("all")
if love.graphics.origin then love.graphics.origin() end
if love.graphics.setLineWidth then love.graphics.setLineWidth(1) end
love.graphics.setColor(0, 0, 0, 0.45)
if love.graphics.polygon then
love.graphics.polygon("fill",
x + 2, y + 2, x + 2, y + 22, x + 8, y + 16, x + 14, y + 26,
x + 18, y + 24, x + 11, y + 14, x + 20, y + 14)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.polygon("fill",
x, y, x, y + 20, x + 6, y + 14, x + 12, y + 24,
x + 16, y + 22, x + 9, y + 12, x + 18, y + 12)
love.graphics.setColor(0.05, 0.07, 0.12, 1)
love.graphics.polygon("line",
x, y, x, y + 20, x + 6, y + 14, x + 12, y + 24,
x + 16, y + 22, x + 9, y + 12, x + 18, y + 12)
else
love.graphics.rectangle("fill", x, y, 12, 18)
end
love.graphics.pop()
end
return PadCursor
+72 -3
View File
@@ -11,9 +11,15 @@
--
-- Draws in full window LOVE units -- the same space TouchControls uses
-- after Renderer:endFrame -- so what you drag here is what you get in play.
--
-- Switch / gamepad: PadCursor draws the virtual pointer the launcher just
-- dropped (same soft-lock class as the save editor). A clicks / drags, B
-- closes (Done), shoulders nudge button size.
local SaveData = require("src.core.SaveData")
local TouchControls = require("src.core.TouchControls")
local PadCursor = require("src.ui.PadCursor")
local GamepadMap = require("src.core.GamepadMap")
local Editor = {}
@@ -56,11 +62,13 @@ function Editor.load(opts)
TouchControls:applyOptions(optsTbl)
TouchControls:setPreview(true)
Editor.enabled = TouchControls.enabled ~= false
PadCursor.reset()
end
function Editor.unload()
TouchControls:setPreview(false)
TouchControls:reset()
PadCursor.reset()
Editor.drag = nil
Editor.onClose = nil
end
@@ -94,13 +102,16 @@ local function toggleEnabled()
if not Editor.enabled then TouchControls:reset() end
end
function Editor.update(_dt)
-- drag follows the live pointer when love.touch / mouse is available;
function Editor.update(dt)
PadCursor.update(dt or 0)
-- drag follows the live pointer when love.touch / mouse / pad is available;
-- touchmoved / mousemoved also update, so this is a belt-and-suspenders
-- path for Android where move events can be thin
if not Editor.drag then return end
local x, y
if love.touch and love.touch.getPosition and Editor.drag.touchId then
if Editor.drag.touchId == "pad" then
x, y = PadCursor.pointer()
elseif love.touch and love.touch.getPosition and Editor.drag.touchId then
local ok, tx, ty = pcall(love.touch.getPosition, Editor.drag.touchId)
if ok and tx then x, y = tx, ty end
end
@@ -244,6 +255,9 @@ function Editor.draw()
love.graphics.circle("line", zone.cx, zone.cy, zone.w * 0.62)
end
end
-- pad / Joy-Con virtual cursor (after chrome so it sits on top)
PadCursor.draw()
end
local function beginDrag(id, x, y)
@@ -285,6 +299,9 @@ end
function Editor.mousepressed(x, y, button)
if button ~= 1 then return end
-- Finger / mouse tap yields the Joy-Con pointer so the click lands where
-- the event said (same NX soft-miss fix as the save editor).
PadCursor.yieldToPointer()
beginDrag("mouse", x, y)
end
@@ -298,6 +315,7 @@ function Editor.mousereleased(x, y, button)
end
function Editor.touchpressed(id, x, y)
PadCursor.yieldToPointer()
beginDrag(id, x, y)
end
@@ -309,6 +327,57 @@ function Editor.touchreleased(id, x, y)
endDrag(id)
end
local function handlePadAction(action)
if not action then return end
if action == "a" then
local mx, my = PadCursor.pointer()
beginDrag("pad", mx, my)
elseif action == "b" then
close()
elseif action == "tab_prev" then
TouchControls:nudgeScale(-TouchControls.SCALE_STEP)
elseif action == "tab_next" then
TouchControls:nudgeScale(TouchControls.SCALE_STEP)
end
end
function Editor.gamepadpressed(joystick, button)
handlePadAction(PadCursor.gamepadpressed(joystick, button))
end
function Editor.gamepadreleased(joystick, button)
PadCursor.gamepadreleased(joystick, button)
-- A release ends a pad drag (hold A + stick to reposition a control).
local action = GamepadMap.mapGamepadButton(button)
if action == "a" then endDrag("pad") end
end
function Editor.gamepadaxis(joystick, axis, value)
PadCursor.gamepadaxis(joystick, axis, value)
end
function Editor.joystickpressed(joystick, button)
handlePadAction(PadCursor.joystickpressed(joystick, button))
end
function Editor.joystickreleased(joystick, button)
PadCursor.joystickreleased(joystick, button)
if GamepadMap.ignoreRawForJoystick(joystick) then return end
local padButton = GamepadMap.mapRawToGamepadButton(button)
if padButton then
local action = GamepadMap.mapGamepadButton(padButton)
if action == "a" then endDrag("pad") end
end
end
function Editor.joystickaxis(joystick, axis, value)
PadCursor.joystickaxis(joystick, axis, value)
end
function Editor.joystickhat(joystick, hat, direction)
PadCursor.joystickhat(joystick, hat, direction)
end
function Editor.keypressed(key)
if key == "escape" or key == "return" or key == "space" then
close()
+6
View File
@@ -250,6 +250,12 @@ function Boot.run(args)
if not (love.filesystem.isFused and love.filesystem.isFused()) then
return false
end
-- Switch (and any host without validated network): never probe payloads.
local okp, Platform = pcall(require, "src.core.Platform")
if okp and Platform and Platform.networkValidated
and not Platform.networkValidated() then
return false
end
-- The chainloaded love.load calls Boot.run again; the flag makes it a no-op.
if _G.POKEPORT_PAYLOAD_MOUNTED then return false end
+99 -23
View File
@@ -37,6 +37,32 @@ local mapScripts -- registry of hand-ported map scripts
local COMPASS = { up = "north", down = "south", left = "west", right = "east" }
local DIRVEC = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } }
-- Fly animation coord paths (engine/overworld/player_animations.asm):
-- y/x pairs in GB screen pixels, one pair every 3 frames (DoFlyAnimation's
-- Delay3). The port anchors a path on the player's own position instead
-- of the GB screen center: FLY_ANCHOR is the pair where the original has
-- the player's sprite, so path1 starts exactly on the player.
local FLY_ANCHOR = { 0x3C, 0x48 }
local FLY_PATH1 = { -- FlyAnimationScreenCoords1: up and off to the right
{ 0x3C, 0x48 }, { 0x3C, 0x50 }, { 0x3B, 0x58 }, { 0x3A, 0x60 },
{ 0x39, 0x68 }, { 0x37, 0x70 }, { 0x37, 0x78 }, { 0x33, 0x80 },
{ 0x30, 0x88 }, { 0x2D, 0x90 }, { 0x2A, 0x98 }, { 0x27, 0xA0 },
}
local FLY_PATH2 = { -- FlyAnimationScreenCoords2: out over the top-left;
-- the 11th step reads the ($F0,$00) terminator, fully off screen
{ 0x1A, 0x90 }, { 0x19, 0x80 }, { 0x17, 0x70 }, { 0x15, 0x60 },
{ 0x12, 0x50 }, { 0x0F, 0x40 }, { 0x0C, 0x30 }, { 0x09, 0x20 },
{ 0x05, 0x10 }, { 0x00, 0x00 }, { -16, 0x00 },
}
-- FlyAnimationEnterScreenCoords: in from off the top-right. Its own last
-- pair is ($3C,$40), so the arrival anchors there and lands on the player.
local FLY_ARRIVE_ANCHOR = { 0x3C, 0x40 }
local FLY_PATH_IN = {
{ 0x05, 0x98 }, { 0x0F, 0x90 }, { 0x18, 0x88 }, { 0x20, 0x80 },
{ 0x27, 0x78 }, { 0x2D, 0x70 }, { 0x32, 0x68 }, { 0x36, 0x60 },
{ 0x39, 0x58 }, { 0x3B, 0x50 }, { 0x3C, 0x48 }, { 0x3C, 0x40 },
}
-- healing machine ball screen positions (PokeCenterOAMData dbsprite
-- rows are raw shadow-OAM bytes, so the hardware's -8/-16 OAM origin
-- applies: screen = tile*8 + pixel offset - 8/16); [3] = OAM_XFLIP
@@ -916,10 +942,20 @@ function OverworldState:update(dt)
return
end
if self.flyAnim then
self.flyAnim.frames = self.flyAnim.frames - 1
if self.flyAnim.frames <= 0 then
-- DoFlyAnimation runs one coord pair every Delay3 (3 frames); the
-- in-place flap is 8 pairs, then the two paths with a 40-frame beat
-- while the bird is parked off screen between them
local anim = self.flyAnim
anim.t = anim.t + 1
if anim.phase == "flap" and anim.t >= 8 * 3 then
anim.phase, anim.t = "path1", 0
require("src.core.Sound").play(Game.data, "Fly")
elseif anim.phase == "path1" and anim.t >= #FLY_PATH1 * 3 then
anim.phase, anim.t = "hold", 0
elseif anim.phase == "hold" and anim.t >= 40 then
anim.phase, anim.t = "path2", 0
elseif anim.phase == "path2" and anim.t >= #FLY_PATH2 * 3 then
self.flyAnim = nil
self.player.inputLocked = false
local d = self.flyDest
self.flyDest = nil
if d then
@@ -927,10 +963,21 @@ function OverworldState:update(dt)
-- SFX_FLY (EnterMapAnim .flyAnimation)
self.arriveWarp = "fly"
self:startWarpTo(d.map, d.x, d.y, "down", nil, { via = "fly" })
else
self.player.inputLocked = false
end
return
end
end
if self.flyArrive then
-- EnterMapAnim .flyAnimation: one swoop in from the top-right, then
-- LoadPlayerSpriteGraphics -- the player reappears where it lands
self.flyArrive.t = self.flyArrive.t + 1
if self.flyArrive.t >= #FLY_PATH_IN * 3 then
self.flyArrive = nil
self.player.inputLocked = false
end
end
-- Dig/Teleport/Escape-Rope departure spin (beginTeleportOut). The sprite
-- spins UP out of the map before the fade (player_animations.asm
@@ -1225,12 +1272,16 @@ function OverworldState:checkBoulderPush(dir)
end
local bx, by = Collision.target(fx, fy, dir)
if not self.map:inBounds(bx, by) then self.boulderTried = nil return false end
-- CheckForCollisionWhenPushingBoulder uses the same walkable check as
-- player movement (CheckTilePassable walks the same wTilesetCollisionPtr
-- list) -- there is no hole/warp escape hatch in the original, so a
-- boulder can never be pushed onto a cell the player cannot walk onto.
-- The known push targets (CAVERN $22 holes, Victory Road switches) are
-- walkable tiles in their tileset's coll list already, so removing the
-- port's isWarpTileCell exception only stops wall pushes (#754).
if not self.map:isWalkableCell(bx, by) then
-- boulders may be pushed into holes/switch spots that aren't walkable
if not self.map:isWarpTileCell(bx, by) then
self.boulderTried = nil
return false
end
self.boulderTried = nil
return false
end
if Collision.occupied(self.entities, bx, by, npc) then
self.boulderTried = nil
@@ -1609,14 +1660,15 @@ end
function OverworldState:flyTo(mapId)
local spot = Game.data.field.flyWarps[mapId]
if not spot then return end
require("src.core.Sound").play(Game.data, "Fly")
Game.save.onBike = false
Game.save.forcedBike = nil -- HandleFlyWarpOrDungeonWarp res BIT_ALWAYS_ON_BIKE
self.player.surfing = false
self:syncSurfingPikachu()
-- the bird carries the player off westward before the warp
-- (engine/overworld/player_animations.asm LoadBirdSpriteGraphics)
self.flyAnim = { frames = 48 }
-- _LeaveMapAnim .flyAnimation: the bird flaps in place (8 x Delay3),
-- then SFX_FLY and the up-right path, a 40-frame beat off screen, and
-- the exit over the top-left -- the warp fades only once the bird is
-- gone (#702). fxBird draws it; the player hides for the whole flight.
self.flyAnim = { phase = "flap", t = 0 }
self.player.inputLocked = true
self.flyDest = { map = mapId, x = spot.x, y = spot.y }
end
@@ -3887,6 +3939,10 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts)
-- door warps never take this branch.
if arriveWarp == "fly" then
require("src.core.Sound").play(Game.data, "Fly")
-- EnterMapAnim .flyAnimation: the bird swoops in off the top-right
-- edge and the player reappears where it lands (#702); the input
-- lock from flyTo releases when the swoop finishes
self.flyArrive = { t = 0 }
elseif arriveWarp == "teleport" then
require("src.core.Sound").play(Game.data, "Teleport_Enter1")
-- ENTER_2 caps the spin-down a moment later
@@ -4394,20 +4450,40 @@ function OverworldState:drawWorld()
-- the FLY bird sweeping off with the player
local function fxBird()
if not self.flyAnim then return end
local anim = self.flyAnim or self.flyArrive
if not anim then return end
local birdId = FieldDefaults.fieldValue(Game.data, "playerSprites", "fly")
if not self.birdSprite and birdId and Game.data.sprites[birdId] then
local SR = require("src.render.SpriteRenderer")
self.birdSprite = SR.new(Game.data.sprites[birdId])
end
if self.birdSprite then
local t = 48 - self.flyAnim.frames
local bx = self.player.px - t * 4
local by = self.player.py - math.floor(t * 1.5)
love.graphics.setColor(1, 1, 1, 1)
self.birdSprite:draw(bx, by, cam.x, cam.y, "left",
math.floor(t / 4) % 2, false)
if not self.birdSprite then return end
-- DoFlyAnimation: the bird flaps its wings every Delay3; each path is
-- anchored on the player's cell (FLY_ANCHOR / FLY_ARRIVE_ANCHOR) so
-- the flight rides any screen position, and it faces its travel
-- direction (rightward travel flips the left-drawn sheet)
local phase = anim.phase or "arrive"
if phase == "hold" then return end -- parked off screen between paths
local path, anchor, facing
if phase == "path1" then
path, anchor, facing = FLY_PATH1, FLY_ANCHOR, "right"
elseif phase == "path2" then
path, anchor, facing = FLY_PATH2, FLY_ANCHOR, "left"
elseif phase == "arrive" then
path, anchor, facing = FLY_PATH_IN, FLY_ARRIVE_ANCHOR, "left"
end
local step = math.floor(anim.t / 3)
local sx, sy
if path then
local pair = path[math.min(#path, step + 1)]
sx, sy = pair[2] - anchor[2], pair[1] - anchor[1]
else
sx, sy = 0, 0 -- the in-place flap sits on the player
facing = "right"
end
love.graphics.setColor(1, 1, 1, 1)
self.birdSprite:draw(self.player.px + sx, self.player.py + sy,
cam.x, cam.y, facing, step % 2, false)
end
-- fishing pose: the rod tile over the faced water (gfx/fishing.asm)
@@ -4557,7 +4633,7 @@ function OverworldState:drawWorld()
g.npc:draw(cam.x - g.ox, cam.y - g.oy)
end
for _, e in ipairs(self.entities) do
if not (self.flyAnim and e == self.player) then
if not ((self.flyAnim or self.flyArrive) and e == self.player) then
e:draw(cam.x, cam.y)
-- tall grass overdraws the sprite's feet (GB sprite priority);
-- the overdraw is BG tiles, so it rides the shake offset too
@@ -4608,7 +4684,7 @@ function OverworldState:drawWorld()
items[#items + 1] = { y = g.npc.py + g.oy + 16, kind = "ghost", g = g }
end
for _, e in ipairs(self.entities) do
if not (self.flyAnim and e == self.player) then
if not ((self.flyAnim or self.flyArrive) and e == self.player) then
items[#items + 1] = { y = e.py + 16, kind = "entity", e = e }
end
end
@@ -4659,7 +4735,7 @@ function OverworldState:drawWorld()
local fy = self.emote.npc.py - cam.y + 16
self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false, fxEmote)
end
if self.flyAnim then
if self.flyAnim or self.flyArrive then
local fx = self.player.px - cam.x + 8
local fy = self.player.py - cam.y + 16
self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false, fxBird)
+50
View File
@@ -0,0 +1,50 @@
-- Driver: Fly overworld animation (#702).
--
-- POKEPORT_DRIVER=tests/drivers/fly_anim_bug702_test.lua \
-- POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love .
--
-- Teleports to Route 17, starts Fly to Pallet Town and captures the
-- departure (in-place flap, up-right path, top-left exit) and the
-- landing swoop.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
U.teleport(game, "ROUTE_17", 4, 10, "down")
local ow = game.stack:top()
ow:flyTo("PALLET_TOWN")
local function waitFrames(n)
for _ = 1, n do coroutine.yield() end
end
waitFrames(12) -- mid in-place flap
U.shot(game, DIR .. "/fly_1_flap.png")
waitFrames(24) -- path1 ~half-way (24 + 12 = 36 into the anim)
U.shot(game, DIR .. "/fly_2_path1.png")
waitFrames(50) -- hold + start of path2
U.shot(game, DIR .. "/fly_3_path2.png")
-- wait out the warp transition, then catch the swoop mid-flight
local guard = 0
while ow.map.id == "ROUTE_17" and guard < 600 do
guard = guard + 1
coroutine.yield()
end
guard = 0
while not ow.flyArrive and guard < 600 do
guard = guard + 1
coroutine.yield()
end
waitFrames(12) -- mid swoop
U.shot(game, DIR .. "/fly_4_arrive.png")
guard = 0
while ow.flyArrive and guard < 600 do
guard = guard + 1
coroutine.yield()
end
U.shot(game, DIR .. "/fly_5_landed.png")
U.log("Screenshots are under " .. DIR)
while true do coroutine.yield() end
end
@@ -0,0 +1,173 @@
-- NxAssetOverlay: fused love-nx often cannot mount blue|yellow onto
-- assets/generated, so on NX the love loaders are wrapped once at boot and
-- fall back to the versioned save-dir path. Desktop/Android never install
-- the overlay; the chip worker gets the prefix explicitly via the audio
-- payload. Self-contained: luajit tests/engine/assets_version_fallback_test.lua
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 GameVersion = require("src.core.GameVersion")
local Platform = require("src.core.Platform")
local Assets = require("src.render.Assets")
local Overlay = require("src.core.NxAssetOverlay")
local PNG = "assets/generated/tilesets/reds_house.png"
local savedVersion = GameVersion.get()
local savedSystem = love.system
local function clearPath(path)
love.filesystem.remove(path)
end
local function setOS(osName)
love.system = {
getOS = function() return osName end,
}
Platform._resetForTests()
end
-- --- Assets.resolve stays platform-free: no rewrite even for NX Yellow
setOS("NX")
GameVersion.set("yellow")
love.filesystem.write("yellow/" .. PNG, "yellow-png-bytes")
clearPath(PNG)
eq(Assets.resolve(PNG), PNG,
"resolve is the identity without a mod loader (overlay owns NX fallback)")
-- --- Overlay installed: every loader falls back to the versioned path
-- Write-side functions must NEVER be wrapped (the importer targets the
-- versioned tree explicitly); capture references to prove identity.
local rawWrite = love.filesystem.write
local rawRemove = love.filesystem.remove
local rawGetInfo = love.filesystem.getInfo
local seed_chunk = "assets/generated/boot_chunk.lua"
love.filesystem.write("yellow/" .. seed_chunk, "return 42")
Overlay.install()
check(Overlay.isInstalled(), "overlay installs")
check(love.filesystem.write == rawWrite,
"install leaves filesystem.write stock (writes never wrapped)")
check(love.filesystem.remove == rawRemove,
"install leaves filesystem.remove stock")
check(love.filesystem.getInfo ~= rawGetInfo, "install wraps getInfo")
local img = love.graphics.newImage(PNG)
eq(img.path, "yellow/" .. PNG, "wrapped newImage receives the yellow/ path")
local id = love.image.newImageData(PNG)
eq(id.path, "yellow/" .. PNG, "wrapped newImageData receives the yellow/ path")
eq(love.filesystem.read(PNG), "yellow-png-bytes",
"wrapped filesystem.read returns the versioned bytes")
check(love.filesystem.getInfo(PNG) ~= nil,
"wrapped getInfo sees the versioned file at the un-prefixed path")
-- The whole read surface, not just image/audio loaders: a future state
-- using any of these APIs with a generated path stays inside the fallback.
local chunk = love.filesystem.load(seed_chunk)
eq(type(chunk) == "function" and chunk() or nil, 42,
"wrapped filesystem.load resolves the versioned chunk")
local sd = love.sound.newSoundData(PNG)
eq(sd.path, "yellow/" .. PNG,
"wrapped newSoundData receives the yellow/ path (widenMono's re-read)")
eq(sd:getChannelCount(), 1,
"path-form newSoundData stays mono so widenMono has work to do")
eq(sd:getBitDepth(), 8, "path-form stub mimics the 8-bit pika-cry WAVs")
local fnt = love.graphics.newFont(14)
check(fnt ~= nil, "wrapped newFont ignores non-path arguments")
-- Assets.image/imageData benefit transparently (no call-site changes)
Assets.flush()
local aimg = Assets.image(PNG)
eq(aimg.path, "yellow/" .. PNG, "Assets.image loads via the overlay")
-- Non-string arguments pass through untouched
local fromData = love.graphics.newImage(id)
check(fromData ~= nil, "newImage(ImageData) is not rewritten")
-- Non-generated paths pass through untouched
local launcher = love.graphics.newImage("assets/launcher/gear.png")
eq(launcher.path, "assets/launcher/gear.png",
"overlay leaves non-generated paths alone")
-- The real un-prefixed file wins when it exists
love.filesystem.write(PNG, "root-png-bytes")
eq(love.filesystem.read(PNG), "root-png-bytes",
"overlay prefers the real un-prefixed file over the versioned copy")
clearPath(PNG)
-- Blue gets the same treatment
GameVersion.set("blue")
love.filesystem.write("blue/" .. PNG, "blue-png-bytes")
clearPath("yellow/" .. PNG)
eq(love.filesystem.read(PNG), "blue-png-bytes",
"overlay maps generated reads to blue/ for Blue")
-- Red has no prefix: nothing is rewritten
GameVersion.set("red")
clearPath("blue/" .. PNG)
eq(love.filesystem.read(PNG), nil, "Red keeps the stock miss behavior")
-- Uninstall restores the stock loaders byte for byte
GameVersion.set("yellow")
love.filesystem.write("yellow/" .. PNG, "yellow-png-bytes")
Overlay.uninstall()
check(not Overlay.isInstalled(), "overlay uninstalls")
eq(love.filesystem.read(PNG), nil,
"after uninstall the stock loader no longer sees the versioned path")
-- --- ChipSynth honors audio.programPrefix (the worker exception)
local ChipSynth = require("src.core.ChipSynth")
ChipSynth.invalidateBanks()
local PROG = "assets/generated/audio/programs.bin"
local PROG_BYTES = string.rep("\0", 0x4000 * 2)
clearPath(PROG)
love.filesystem.write("yellow/" .. PROG, PROG_BYTES)
local workerData = { audio = {
programFile = PROG,
programPrefix = "yellow/",
bankOrder = { 1, 2 },
} }
local okW, wbanks = pcall(ChipSynth._loadBanksForTest, workerData)
check(okW and wbanks ~= nil, "loadBanks uses audio.programPrefix when set")
if okW and wbanks then
eq(wbanks[1], PROG_BYTES:sub(1, 0x4000),
"programPrefix loads the bank 1 bytes from the versioned file")
end
-- Without programPrefix the sync path relies on the overlay/mount: with the
-- overlay uninstalled (this test process), the plain read misses.
ChipSynth.invalidateBanks()
local plainData = { audio = { programFile = PROG, bankOrder = { 1, 2 } } }
local okP = pcall(ChipSynth._loadBanksForTest, plainData)
check(not okP, "without programPrefix or overlay, programs.bin is a clean miss")
-- --- ChipAudio.slimAudio hands the NX prefix to the worker payload
setOS("NX")
GameVersion.set("yellow")
local ChipAudio = require("src.core.ChipAudio")
local slim = ChipAudio._slimAudioForTest(plainData)
eq(slim.programPrefix, "yellow/",
"slimAudio passes the NX cache prefix to the worker")
setOS("OS X")
local slimDesktop = ChipAudio._slimAudioForTest(plainData)
eq(slimDesktop.programPrefix, nil,
"desktop worker payloads carry no prefix (mount owns the overlay)")
clearPath("yellow/" .. PNG)
clearPath("yellow/" .. PROG)
clearPath("yellow/" .. seed_chunk)
love.system = savedSystem
Platform._resetForTests()
GameVersion.set(savedVersion)
Assets.flush()
T.finish()
+53
View File
@@ -0,0 +1,53 @@
-- Blue/Yellow mountVersion must overlay save-dir caches without FFI (NX).
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")
love.filesystem._mounts = {}
-- Imply blue/data/generated and blue/assets/generated directories via file keys.
love.filesystem.write("blue/data/generated/maps.lua", "return {}")
love.filesystem.write("blue/data/generated/constants.lua", "return {}")
love.filesystem.write("blue/assets/generated/fonts/font.png", "font-bytes")
check(CacheFs.mountVersion("blue") == true, "mountVersion(blue) returns true")
local sawBlueRoot, sawDataGen, sawAssetsGen = false, false, false
for _, m in ipairs(love.filesystem._mounts) do
if m.archive == "blue" and m.mountpoint == "" and m.append == false then
sawBlueRoot = true
end
if m.archive == "blue/data/generated" and m.mountpoint == "data/generated"
and m.append == false then
sawDataGen = true
end
if m.archive == "blue/assets/generated" and m.mountpoint == "assets/generated"
and m.append == false then
sawAssetsGen = true
end
end
check(sawBlueRoot, "prepend-mounts save-dir relative blue/")
check(sawDataGen, "prepend-mounts blue/data/generated -> data/generated")
check(sawAssetsGen, "prepend-mounts blue/assets/generated -> assets/generated")
eq(love.filesystem.read("assets/generated/fonts/font.png"), "font-bytes",
"post-mount probe can read unprefixed assets/generated canary")
eq(love.filesystem.read("data/generated/constants.lua"), "return {}",
"post-mount probe can read unprefixed data/generated canary")
-- Yellow-only: same overlay contract
love.filesystem._mounts = {}
GameVersion.set("yellow")
love.filesystem.write("yellow/data/generated/constants.lua", "return {y=1}")
love.filesystem.write("yellow/assets/generated/fonts/font.png", "yellow-font")
check(CacheFs.mountVersion("yellow") == true, "mountVersion(yellow) returns true")
eq(love.filesystem.read("assets/generated/fonts/font.png"), "yellow-font",
"Yellow mount exposes fonts/font.png at the unprefixed path")
GameVersion.set("red")
T.finish()
+171
View File
@@ -0,0 +1,171 @@
-- Select+face chords fire Game:keypressed digits (NXMOD-06..10).
-- Spec: Select held + A/B/Y/X/L → keys 2/3/5/6/7; without Select, face stays GB.
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 Game = require("src.core.Game")
local Input = require("src.core.Input")
local GamepadMap = require("src.core.GamepadMap")
local joy = {
isGamepad = function() return true end,
isGamepadDown = function(_, button) return false end,
}
local digits = {}
local padForwarded = {}
local wroteOptions = false
local origKeypressed = Game.keypressed
local origPad = Input.gamepadpressed
local origWrite = Game.writeOptions
function Game:keypressed(key)
digits[#digits + 1] = key
-- Mimic digit persistence side-effect for keys that write options.
if key == "2" or key == "3" or key == "5" or key == "6" or key == "7" then
wroteOptions = true
end
end
function Input:gamepadpressed(joystick, button)
padForwarded[#padForwarded + 1] = button
return origPad(self, joystick, button)
end
local function resetSpies()
digits = {}
padForwarded = {}
wroteOptions = false
end
local function holdSelect()
Input:init()
-- Press Select (SDL back) into Input so isDown("select") is true.
origPad(Input, joy, "back")
Input:step()
check(Input:isDown("select"), "Select held via Input")
end
-- --- Without Select: face buttons keep normal GB path (NXMOD-09) ---
GamepadMap._setForceNXForTests(false)
Input:init()
resetSpies()
Game:gamepadpressed(joy, "a")
eq(#digits, 0, "no digit without Select")
eq(#padForwarded, 1, "face reaches Input without Select")
eq(padForwarded[1], "a", "Input receives face a")
check(not wroteOptions, "no options write without Select chord")
-- --- Select + face → same digit path as PC keys (NXMOD-06..08, NXMOD-10) ---
local chordCases = {
{ button = "a", digit = "2", label = "Select+A -> 2" },
{ button = "b", digit = "3", label = "Select+B -> 3" },
{ button = "y", digit = "5", label = "Select+Y -> 5" },
{ button = "x", digit = "6", label = "Select+X -> 6" },
{ button = "leftshoulder", digit = "7", label = "Select+L -> 7" },
}
for _, case in ipairs(chordCases) do
holdSelect()
resetSpies()
Game:gamepadpressed(joy, case.button)
eq(#digits, 1, case.label .. " fires keypressed once")
eq(digits[1], case.digit, case.label)
eq(#padForwarded, 0, case.label .. " does not forward face to Input")
check(wroteOptions, case.label .. " uses digit options path")
end
-- --- NX Nintendo UX: physical A (SDL b) → 2, physical B (SDL a) → 3 ---
GamepadMap._setForceNXForTests(true)
holdSelect()
resetSpies()
Game:gamepadpressed(joy, "b") -- physical Nintendo A
eq(digits[1], "2", "NX Select+physical A (SDL b) -> key 2")
eq(#padForwarded, 0, "NX chord A does not forward face")
check(wroteOptions, "NX chord A options path")
holdSelect()
resetSpies()
Game:gamepadpressed(joy, "a") -- physical Nintendo B
eq(digits[1], "3", "NX Select+physical B (SDL a) -> key 3")
eq(#padForwarded, 0, "NX chord B does not forward face")
-- Without Select on NX, face still goes to Input (no accidental cycle).
Input:init()
resetSpies()
Game:gamepadpressed(joy, "b")
eq(#digits, 0, "NX no digit without Select")
eq(#padForwarded, 1, "NX face reaches Input without Select")
-- Dual-path Select: joystick isGamepadDown("back") also counts as held.
GamepadMap._setForceNXForTests(false)
Input:init()
resetSpies()
local joySelectDown = {
isGamepad = function() return true end,
isGamepadDown = function(_, button) return button == "back" end,
}
Game:gamepadpressed(joySelectDown, "y")
eq(digits[1], "5", "Select via isGamepadDown(back) + Y -> 5")
eq(#padForwarded, 0, "isGamepadDown Select chord does not forward face")
-- Edge: face chords without Select must not cycle digits (NXMOD-09).
-- leftshoulder without Select is the GAME SPEED hotkey (upstream), so it
-- does not reach Input — only face buttons do.
GamepadMap._setForceNXForTests(false)
for _, btn in ipairs({ "a", "b", "y", "x" }) do
Input:init()
resetSpies()
Game:gamepadpressed(joy, btn)
eq(#digits, 0, "edge: no cycle without Select for " .. btn)
eq(#padForwarded, 1, "edge: " .. btn .. " still reaches Input without Select")
check(not wroteOptions, "edge: no options write without Select for " .. btn)
end
-- leftshoulder alone cycles speed (does not forward / does not digit).
do
local sped = 0
local origCycle = Game._cycleSpeed
function Game:_cycleSpeed(dir) sped = sped + (dir or 0) end
Input:init()
resetSpies()
Game:gamepadpressed(joy, "leftshoulder")
eq(#digits, 0, "edge: L alone does not fire display digit")
eq(#padForwarded, 0, "edge: L alone does not forward to Input (speed hotkey)")
eq(sped, -1, "edge: L alone cycles GAME SPEED down")
Game._cycleSpeed = origCycle
end
-- Select+L still wins over the speed hotkey.
holdSelect()
resetSpies()
do
local sped = 0
local origCycle = Game._cycleSpeed
function Game:_cycleSpeed(dir) sped = sped + (dir or 0) end
Game:gamepadpressed(joy, "leftshoulder")
eq(digits[1], "7", "Select+L still fires display digit 7")
eq(sped, 0, "Select+L does not cycle GAME SPEED")
eq(#padForwarded, 0, "Select+L does not forward L to Input")
Game._cycleSpeed = origCycle
end
-- Edge: Select alone (no face) does not synthesize a digit
holdSelect()
resetSpies()
-- pressing back again while held is not a display chord partner
Game:gamepadpressed(joy, "back")
eq(#digits, 0, "edge: Select alone does not fire a display digit")
eq(#padForwarded, 1, "edge: Select alone still forwards to Input")
GamepadMap._setForceNXForTests(false)
Game.keypressed = origKeypressed
Input.gamepadpressed = origPad
Game.writeOptions = origWrite
T.finish()
+49
View File
@@ -0,0 +1,49 @@
-- Select+face display chord digit map (NXMOD-06..09).
-- Spec: Nintendo UX A/B → keys 2/3; Y/X/L → 5/6/7. Map-only (Select held is Game's job).
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 GamepadMap = require("src.core.GamepadMap")
-- Desktop / default: SDL face labels map identity for A/B chords.
GamepadMap._setForceNXForTests(false)
eq(GamepadMap.displayChordDigit("a"), "2", "desktop SDL a (GB A) -> key 2")
eq(GamepadMap.displayChordDigit("b"), "3", "desktop SDL b (GB B) -> key 3")
eq(GamepadMap.displayChordDigit("y"), "5", "Y -> key 5")
eq(GamepadMap.displayChordDigit("x"), "6", "X -> key 6")
eq(GamepadMap.displayChordDigit("leftshoulder"), "7", "leftshoulder (L) -> key 7")
-- Unmapped buttons yield nil (no accidental digit).
eq(GamepadMap.displayChordDigit("start"), nil, "start is not a display chord")
eq(GamepadMap.displayChordDigit("back"), nil, "back/Select alone is not a digit")
eq(GamepadMap.displayChordDigit("dpup"), nil, "d-pad is not a display chord")
eq(GamepadMap.displayChordDigit("rightshoulder"), nil, "R is not a display chord")
eq(GamepadMap.displayChordDigit(nil), nil, "nil button -> nil")
-- NX Nintendo UX: GamepadMap swaps SDL a/b so physical A/B match docs.
-- Physical Nintendo A arrives as SDL "b" → GB a → key "2".
-- Physical Nintendo B arrives as SDL "a" → GB b → key "3".
GamepadMap._setForceNXForTests(true)
eq(GamepadMap.mapGamepadButton("b"), "a", "precondition: NX SDL east -> GB A")
eq(GamepadMap.mapGamepadButton("a"), "b", "precondition: NX SDL south -> GB B")
eq(GamepadMap.displayChordDigit("b"), "2",
"NX physical A (SDL b / GB a) -> key 2")
eq(GamepadMap.displayChordDigit("a"), "3",
"NX physical B (SDL a / GB b) -> key 3")
-- Y/X/L unchanged under NX face swap.
eq(GamepadMap.displayChordDigit("y"), "5", "NX Y -> key 5")
eq(GamepadMap.displayChordDigit("x"), "6", "NX X -> key 6")
eq(GamepadMap.displayChordDigit("leftshoulder"), "7", "NX L -> key 7")
GamepadMap._setForceNXForTests(false)
-- Edge: map alone never invents a digit for non-chord faces (NXMOD-09 map half)
for _, btn in ipairs({ "guide", "leftstick", "rightstick", "lefttrigger" }) do
eq(GamepadMap.displayChordDigit(btn), nil,
"edge: unmapped " .. btn .. " is not a display digit")
end
T.finish()
+60
View File
@@ -0,0 +1,60 @@
-- When isGamepad(), raw face presses must not stack on gamepad* (NamingScreen a+b).
-- On NX, SDL face labels are swapped so physical A confirms / B cancels.
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 GamepadMap = require("src.core.GamepadMap")
local Input = require("src.core.Input")
local gamepadJoy = {
isGamepad = function() return true end,
}
local rawJoy = {
isGamepad = function() return false end,
}
check(GamepadMap.ignoreRawForJoystick(gamepadJoy), "ignore raw when isGamepad")
check(not GamepadMap.ignoreRawForJoystick(rawJoy), "allow raw when not gamepad")
check(not GamepadMap.ignoreRawForJoystick(nil), "nil joystick does not ignore raw")
-- Desktop: SDL a → GB A (unchanged).
eq(GamepadMap.mapGamepadButton("a"), "a", "desktop SDL a -> GB A")
eq(GamepadMap.mapGamepadButton("b"), "b", "desktop SDL b -> GB B")
GamepadMap._setForceNXForTests(true)
eq(GamepadMap.mapGamepadButton("a"), "b", "NX SDL south (a) -> GB B (Nintendo B)")
eq(GamepadMap.mapGamepadButton("b"), "a", "NX SDL east (b) -> GB A (Nintendo A)")
eq(GamepadMap.mapRawButton(1), "b", "NX raw #1 Nintendo B -> GB B")
eq(GamepadMap.mapRawButton(2), "a", "NX raw #2 Nintendo A -> GB A")
eq(GamepadMap.mapRawButton(3), nil, "NX raw Y (#3) not mapped as confirm")
eq(GamepadMap.mapRawButton(4), nil, "NX raw X (#4) not mapped as confirm")
GamepadMap._setForceNXForTests(false)
Input:init()
Input:gamepadpressed(gamepadJoy, "a")
Input:joystickpressed(gamepadJoy, 1) -- must no-op
Input:joystickpressed(gamepadJoy, 2) -- must no-op
Input:step()
check(Input:wasPressed("a"), "desktop gamepad A edge present")
check(not Input:wasPressed("b"), "raw must not add B alongside gamepad A")
check(Input:isDown("a"), "A held from pad source only")
-- NX: physical A arrives as SDL "b" → GB A.
GamepadMap._setForceNXForTests(true)
Input:init()
Input:gamepadpressed(gamepadJoy, "b")
Input:step()
check(Input:wasPressed("a"), "NX physical A (SDL b) confirms as GB A")
check(not Input:wasPressed("b"), "NX physical A must not also erase")
GamepadMap._setForceNXForTests(false)
Input:init()
Input:joystickpressed(rawJoy, 1)
Input:step()
check(Input:wasPressed("a"), "non-gamepad raw #1 still maps to A on desktop")
T.finish()
+24
View File
@@ -0,0 +1,24 @@
-- Focus / visibility loss clears held directions (SWNX-18).
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 Input = require("src.core.Input")
local Game = require("src.core.Game")
Input:init()
Input:keypressed("left")
Input:step()
check(Input:isDown("left"), "left held before focus loss")
Game:focus(false)
check(not Input:isDown("left"), "focus loss clears held direction")
Input:keypressed("up")
Input:step()
Game:visible(false)
check(not Input:isDown("up"), "visibility loss clears held direction")
T.finish()
@@ -0,0 +1,30 @@
-- Joystick remove / resume clears stuck input sources (SWNX-19).
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 Input = require("src.core.Input")
local Game = require("src.core.Game")
Input:init()
Input:gamepadaxis(nil, "leftx", -0.9)
Input:step()
check(Input:isDown("left"), "stick holds left before disconnect")
Game:joystickremoved({})
check(not Input:isDown("left"), "joystickremoved clears stick hold")
Input:gamepadpressed(nil, "a")
Input:step()
check(Input:isDown("a"), "button held before reconnect reset")
Game:joystickadded({ getName = function() return "Joy-Con" end })
check(not Input:isDown("a"), "joystickadded clears stale button hold")
Input:gamepadaxis(nil, "lefty", 0.9)
Input:step()
Game:onResume()
check(not Input:isDown("down"), "resume clears stuck axis state")
T.finish()
+23
View File
@@ -0,0 +1,23 @@
-- NX raw fallback + Nintendo face remap (SWNX-11).
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 eq = T.eq
local GamepadMap = require("src.core.GamepadMap")
GamepadMap._setForceNXForTests(true)
eq(GamepadMap.mapGamepadButton("b"), "a", "NX physical A (SDL b) -> GB A")
eq(GamepadMap.mapGamepadButton("a"), "b", "NX physical B (SDL a) -> GB B")
eq(GamepadMap.mapRawButton(1), "b", "NX raw #1 Nintendo B -> GB B")
eq(GamepadMap.mapRawButton(2), "a", "NX raw #2 Nintendo A -> GB A")
eq(GamepadMap.mapRawToGamepadButton(1), "a", "NX raw #1 routes as SDL south name")
eq(GamepadMap.mapRawToGamepadButton(2), "b", "NX raw #2 routes as SDL east name")
eq(GamepadMap.mapRawButton(9), "select", "NX minus (#9) -> select")
eq(GamepadMap.mapRawButton(10), "start", "NX plus (#10) -> start")
GamepadMap._setForceNXForTests(false)
T.finish()
+51
View File
@@ -0,0 +1,51 @@
-- Shared gamepad/raw map: launcher and gameplay use identical converters (SWNX-10/11).
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 GamepadMap = require("src.core.GamepadMap")
local Input = require("src.core.Input")
local RomImporter = require("src.import.RomImporter")
-- Gamepad names map to GB actions.
eq(GamepadMap.mapGamepadButton("a"), "a", "gamepad A -> GB A")
eq(GamepadMap.mapGamepadButton("back"), "select", "gamepad back -> select")
eq(GamepadMap.mapGamepadButton("dpup"), "up", "gamepad d-pad up")
-- Generic raw indices (Linux handheld fallback).
eq(GamepadMap.mapRawButton(1), "a", "raw #1 -> A")
eq(GamepadMap.mapRawButton(8), "start", "raw #8 -> start")
eq(GamepadMap.mapRawToGamepadButton(2), "b", "raw #2 routes to gamepad b")
-- Input and RomImporter agree on the same GB action for a raw press.
Input:init()
Input:joystickpressed(nil, 1)
Input:step()
check(Input:isDown("a"), "Input raw #1 holds A")
local importer = setmetatable({
_padCursor = { x = 0, y = 0 }, _padCursorActive = false,
_padAxis = { leftx = 0, lefty = 0, righty = 0 },
_padDir = {}, _rawHatDirs = {}, _padInited = true,
-- FlexLove view marker: without it the pad A path is a no-op (headless).
_flex = true,
}, RomImporter)
local clicked = false
local prevView = package.loaded["src.import.LauncherView"]
package.loaded["src.import.LauncherView"] = {
clickAt = function(_, x, y)
clicked = (x == 0 and y == 0)
end,
}
importer:joystickpressed(nil, 1)
package.loaded["src.import.LauncherView"] = prevView
check(clicked, "RomImporter raw #1 clicks via shared map (not hardcoded-only)")
importer:joystickpressed(nil, 2)
check(importer._padDir.dpright == nil,
"raw #2 maps to B, not spurious d-pad")
T.finish()
@@ -0,0 +1,289 @@
-- NX launcher pad-cursor lag guards (Switch-only).
-- Proves the virtual mouse no longer warps love.mouse via setPosition on NX,
-- that FlexLove still sees pad coords through the getPosition bridge, that
-- desktop keeps setPosition, and that LauncherView wires NX perf guards.
-- Also prints a small metric block (setPosition counts + update cost).
--
-- FlexLove itself is not loaded here: the engine tier runs under plain luajit
-- without luautf8, which FlexLove requires. RomImporter owns the pointer
-- bridge; LauncherView wiring is asserted via source seams.
-- luajit tests/engine/launcher_nx_pad_cursor_test.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")
-- Instrument setPosition so we can count warps (love_stub has none).
local setPositionCalls = 0
local mouseX, mouseY = 0, 0
love.mouse.getPosition = function() return mouseX, mouseY end
love.mouse.setPosition = function(x, y)
setPositionCalls = setPositionCalls + 1
mouseX, mouseY = x, y
end
local RomImporter = require("src.import.RomImporter")
local function freshImporter(isNX)
return setmetatable({
isNX = isNX and true or false,
_padCursor = { x = 100, y = 100 },
_padCursorActive = false,
_padAxis = { leftx = 0, lefty = 0, righty = 0 },
_padDir = {},
_rawHatDirs = {},
_padInited = true,
_flex = true,
tab = "red",
}, RomImporter)
end
local function stickRight(imp, frames, dt)
dt = dt or (1 / 60)
imp._padAxis.leftx = 1
for _ = 1, frames do
imp:_updatePadCursor(dt)
end
end
local function read(path)
local f = assert(io.open(path, "r"))
local src = f:read("*a")
f:close()
return src
end
-- ------- NX: no setPosition warps; getPosition bridge tracks the pad
do
setPositionCalls = 0
mouseX, mouseY = 0, 0
local imp = freshImporter(true)
local x0 = imp._padCursor.x
stickRight(imp, 30)
check(imp._padCursorActive, "NX stick activates pad cursor")
check(imp._padCursor.x > x0, "NX stick moves pad cursor right")
eq(setPositionCalls, 0, "NX pad move never calls love.mouse.setPosition")
check(imp._nxPointerBridge, "NX installs getPosition bridge")
local gx, gy = love.mouse.getPosition()
eq(gx, imp._padCursor.x, "NX getPosition X matches pad cursor")
eq(gy, imp._padCursor.y, "NX getPosition Y matches pad cursor")
-- Real (stored) mouse must stay where the stub left it — bridge only.
eq(mouseX, 0, "NX does not warp the underlying mouse X")
eq(mouseY, 0, "NX does not warp the underlying mouse Y")
imp:_restoreNxPointerBridge()
check(not imp._nxPointerBridge, "restore clears NX mouse bridge")
local rx, ry = love.mouse.getPosition()
eq(rx, 0, "after restore getPosition is the real stub again")
eq(ry, 0, "after restore getPosition Y is the real stub again")
end
-- ------- NX: A after idle must not false-yield the pad cursor
do
mouseX, mouseY = 10, 20
local imp = freshImporter(true)
imp._padCursor.x, imp._padCursor.y = 400, 300
-- Idle frames (NX ignores mouse yield entirely).
imp:_updatePadCursor(1 / 60)
-- A / activate without stick motion (clickAt path).
imp:_activatePadCursor()
imp:_updatePadCursor(1 / 60)
check(imp._padCursorActive,
"NX A after idle keeps pad cursor")
local gx, gy = love.mouse.getPosition()
eq(gx, 400, "bridged getPosition still reports pad X after A")
eq(gy, 300, "bridged getPosition still reports pad Y after A")
-- System mouse drift must NOT yield on NX (SDL stick→mouse / touch noise).
mouseX, mouseY = 80, 90
imp:_updatePadCursor(1 / 60)
check(imp._padCursorActive, "NX ignores real mouse drift for yield")
imp:parkNxPointerForHost()
end
-- ------- NX: sparse stick + SDL mouse drift must not flicker the overlay
do
mouseX, mouseY = 100, 100
local imp = freshImporter(true)
imp._padCursor.x, imp._padCursor.y = 100, 100
local flickers = 0
for i = 1, 60 do
mouseX = mouseX + 8 -- simulated SDL stick→mouse drift
if i % 2 == 1 then
imp._padAxis.leftx = 1
else
imp._padAxis.leftx = 0 -- axis events not every frame
end
local before = imp._padCursorActive
imp:_updatePadCursor(1 / 60)
if before and not imp._padCursorActive then
flickers = flickers + 1
end
end
eq(flickers, 0, "NX sparse stick + mouse drift causes zero pad flickers")
check(imp._padCursorActive, "NX pad stays active after sparse stick run")
-- dt clamp: a 0.2s hitch must not move more than a 1/30 step
local xBefore = imp._padCursor.x
imp._padAxis.leftx = 1
imp:_updatePadCursor(0.2)
local moved = imp._padCursor.x - xBefore
local maxStep = 560 * (1 / 30) + 0.01
check(moved <= maxStep, "NX pad cursor dt is clamped at 1/30")
imp:parkNxPointerForHost()
end
-- ------- NX: parkNxPointerForHost restores mouse for embedded editor
do
mouseX, mouseY = 5, 6
local imp = freshImporter(true)
stickRight(imp, 5)
check(imp._nxPointerBridge, "bridge on before park")
check(imp._padCursorActive, "pad active before park")
imp:parkNxPointerForHost()
check(not imp._nxPointerBridge, "park clears bridge")
check(not imp._padCursorActive, "park clears pad active")
local gx, gy = love.mouse.getPosition()
eq(gx, 5, "after park getPosition is real mouse X")
eq(gy, 6, "after park getPosition is real mouse Y")
-- Desktop no-op.
local desk = freshImporter(false)
desk._padCursorActive = true
desk:parkNxPointerForHost()
check(desk._padCursorActive, "desktop parkNxPointerForHost is a no-op")
end
-- ------- Overlay handoff / resume (Edit Save + Touch Controls)
do
mouseX, mouseY = 9, 10
local imp = freshImporter(true)
imp.launcher = true
stickRight(imp, 3)
check(imp._padCursorActive, "pad active before overlay handoff")
check(imp._nxPointerBridge, "bridge on before overlay handoff")
imp:prepareOverlayHandoff()
check(not imp._padCursorActive, "prepareOverlayHandoff clears pad")
check(not imp._nxPointerBridge, "prepareOverlayHandoff clears NX bridge")
check(imp._flex == nil, "prepareOverlayHandoff clears flex without FlexLove")
local gx, gy = love.mouse.getPosition()
eq(gx, 9, "after overlay handoff getPosition is real mouse X")
eq(gy, 10, "after overlay handoff getPosition is real mouse Y")
local prevCount = love.joystick and love.joystick.getJoystickCount
love.joystick = love.joystick or {}
love.joystick.getJoystickCount = function() return 1 end
imp:resumeAfterOverlay()
check(imp._padCursorActive, "NX resumeAfterOverlay re-arms pad with a stick")
love.joystick.getJoystickCount = function() return 0 end
imp._padCursorActive = false
imp:resumeAfterOverlay()
check(not imp._padCursorActive, "resumeAfterOverlay stays latent with no stick")
love.joystick.getJoystickCount = prevCount
local desk = freshImporter(false)
desk.launcher = true
desk._padCursorActive = true
desk:prepareOverlayHandoff()
check(not desk._padCursorActive, "desktop prepareOverlayHandoff clears pad too")
end
-- ------- Desktop: setPosition still warps (unchanged path)
do
setPositionCalls = 0
mouseX, mouseY = 0, 0
local imp = freshImporter(false)
local x0 = imp._padCursor.x
stickRight(imp, 30)
check(imp._padCursorActive, "desktop stick activates pad cursor")
check(imp._padCursor.x > x0, "desktop stick moves pad cursor right")
eq(setPositionCalls, 30, "desktop pad move warps mouse every frame")
eq(mouseX, imp._padCursor.x, "desktop setPosition tracks pad X")
eq(mouseY, imp._padCursor.y, "desktop setPosition tracks pad Y")
check(not imp._nxPointerBridge, "desktop never installs NX bridge")
-- Desktop yield still drops the pad when the real mouse moves (stick released).
imp._padAxis.leftx = 0
mouseX, mouseY = mouseX + 20, mouseY + 20
imp:_updatePadCursor(1 / 60)
check(not imp._padCursorActive, "desktop real mouse motion still yields pad")
end
-- ------- Metrics: setPosition counts + pad-update cost (NX vs desktop)
do
local frames = 120
local dt = 1 / 60
setPositionCalls = 0
local nx = freshImporter(true)
local t0 = os.clock()
stickRight(nx, frames, dt)
local nxMs = (os.clock() - t0) * 1000
local nxSet = setPositionCalls
nx:_restoreNxPointerBridge()
setPositionCalls = 0
local desk = freshImporter(false)
t0 = os.clock()
stickRight(desk, frames, dt)
local deskMs = (os.clock() - t0) * 1000
local deskSet = setPositionCalls
eq(nxSet, 0, "metric: NX setPosition count is 0 over 120 frames")
eq(deskSet, frames, "metric: desktop setPosition count equals frame count")
print(string.format(
"METRICS nx_pad_cursor: frames=%d nx_setPosition=%d desk_setPosition=%d nx_update_ms=%.3f desk_update_ms=%.3f",
frames, nxSet, deskSet, nxMs, deskMs))
end
-- ------- Source seams: LauncherView NX perf + detach restore
do
local view = read("src/import/LauncherView.lua")
check(view:find("function LauncherView.applyNxPerfGuards", 1, true) ~= nil,
"LauncherView exports applyNxPerfGuards")
check(view:find("LauncherView.applyNxPerfGuards(imp)", 1, true) ~= nil,
"ensureFlex calls applyNxPerfGuards")
check(view:find("FlexLove._Performance.enabled = false", 1, true) ~= nil,
"NX guard disables Performance.enabled")
check(view:find("mp.enabled = false", 1, true) ~= nil,
"NX guard disables memory profiling")
check(view:find("if not (imp and imp.isNX", 1, true) ~= nil,
"perf guard is gated on imp.isNX")
check(view:find("parkNxPointerForHost", 1, true) ~= nil,
"detach parks NX pointer before tearing down")
local impSrc = read("src/import/RomImporter.lua")
check(impSrc:find("function RomImporter:_ensureNxPointerBridge", 1, true) ~= nil,
"RomImporter owns NX getPosition bridge")
check(impSrc:find("function RomImporter:parkNxPointerForHost", 1, true) ~= nil,
"RomImporter exports parkNxPointerForHost")
check(impSrc:find("function RomImporter:prepareOverlayHandoff", 1, true) ~= nil,
"RomImporter exports prepareOverlayHandoff")
check(impSrc:find("function RomImporter:resumeAfterOverlay", 1, true) ~= nil,
"RomImporter exports resumeAfterOverlay")
check(impSrc:find("if not self.isNX then", 1, true) ~= nil,
"NX skips desktop mouse-yield path")
check(impSrc:find("if not self.isNX and love.mouse.setPosition", 1, true) ~= nil,
"RomImporter skips setPosition on NX")
check(impSrc:find("dt > 1 / 30", 1, true) ~= nil,
"NX clamps pad cursor dt")
local mainSrc = read("main.lua")
check(mainSrc:find("prepareOverlayHandoff", 1, true) ~= nil,
"openEditor / Touch Controls use prepareOverlayHandoff")
check(mainSrc:find("resumeAfterOverlay", 1, true) ~= nil,
"close paths resume the launcher pad cursor")
check(view:find("math.floor(x + 0.5)", 1, true) ~= nil,
"NX pad cursor draw is pixel-snapped")
check(view:find('strategy = "periodic"', 1, true) ~= nil,
"NX softens FlexLove GC strategy")
end
T.finish("launcher_nx_pad_cursor")
@@ -0,0 +1,173 @@
-- Regression for #748: FlexLove propagates a nested child's auto-height
-- change only to its direct parent while the launcher tree is constructed.
-- The two-column grid can therefore retain the shorter left-column height
-- after the save-slot card makes the right column taller, placing the footer
-- over the bottom of that card. Keep this test ROM- and renderer-free.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
-- Element sizing asks the window for its current mode. FlexLove also loads
-- UTF8 helpers for rendering, although this geometry test draws no text.
love.window.getMode = function()
return 1024, 768, { fullscreen = false }
end
love.window.getDesktopDimensions = function() return 1920, 1080 end
package.loaded["libs.flexlove.modules.UTF8"] = {
char = string.char,
charpattern = ".",
codepoint = string.byte,
len = string.len,
offset = function(_, n) return n end,
codes = function(s)
local i = 0
return function()
i = i + 1
if i <= #s then return i, s:byte(i) end
end
end,
}
local T = require("tests.modkit")
local FlexLove = require("libs.flexlove.FlexLove")
FlexLove.init({
immediateMode = false,
performanceMonitoring = false,
keyboardNavigation = false,
})
local LauncherView = require("src.import.LauncherView")
local refreshAutoHeight = LauncherView._refreshAutoHeight
-- Preserve the launcher's construction order. The left card grows first;
-- adding the right column then refreshes the grid to 220. Growing a card
-- nested inside that right column reaches the column, but not the grid.
local function nestedColumns(leftHeight, rightHeight, gridOverrides)
local page = FlexLove.new({
width = 800,
positioning = "flex",
flexDirection = "vertical",
gap = 12,
})
local gridProps = {
parent = page,
width = 800,
positioning = "flex",
flexDirection = "horizontal",
alignItems = "flex-start",
}
for key, value in pairs(gridOverrides or {}) do
gridProps[key] = value
end
local grid = FlexLove.new(gridProps)
local left = FlexLove.new({
parent = grid,
width = 390,
positioning = "flex",
flexDirection = "vertical",
})
local leftCard = FlexLove.new({
parent = left,
width = 390,
positioning = "flex",
flexDirection = "vertical",
})
FlexLove.new({ parent = leftCard, width = 390, height = leftHeight })
local right = FlexLove.new({
parent = grid,
width = 390,
positioning = "flex",
flexDirection = "vertical",
})
local rightCard = FlexLove.new({
parent = right,
width = 390,
positioning = "flex",
flexDirection = "vertical",
})
FlexLove.new({ parent = rightCard, width = 390, height = rightHeight })
return { page = page, grid = grid, left = left, right = right }
end
-- Reported shape: real FlexLove elements finish with a 520px right column,
-- but both the grid and page still reserve only the 220px left extent.
local tallRight = nestedColumns(220, 520)
local gap = 12
T.eq(tallRight.left:getBorderBoxHeight(), 220,
"the left launcher column finishes at its content height")
T.eq(tallRight.right:getBorderBoxHeight(), 520,
"the nested save-slot column grows to its completed height")
T.eq(tallRight.grid:getBorderBoxHeight(), 220,
"FlexLove leaves the outer two-column grid stale")
T.eq(tallRight.page:getBorderBoxHeight(), 220,
"the page inherits the stale grid extent")
T.eq(tallRight.grid:calculateAutoHeight(), 520,
"the completed grid can measure the correct taller extent")
T.check(tallRight.grid:getBorderBoxHeight() + gap
< tallRight.right:getBorderBoxHeight(),
"the stale grid places the following footer over the save-slot column")
if not T.check(type(refreshAutoHeight) == "function",
"launcher exports the auto-height reconciliation seam") then
T.finish("launcher save-slot overlap #748")
end
tallRight.grid._dirty = false
tallRight.page._childrenDirty = false
local refreshedHeight = refreshAutoHeight(tallRight.grid)
T.eq(refreshedHeight, 520, "reconciliation returns the taller border-box height")
T.eq(tallRight.grid.height, 520, "reconciliation updates content height")
T.eq(tallRight.grid:getBorderBoxHeight(), 520,
"reconciliation updates the cached border-box height")
T.check(tallRight.grid._dirty, "reconciliation invalidates the grid")
T.check(tallRight.page._childrenDirty,
"reconciliation invalidates ancestor layout")
T.check(tallRight.grid:getBorderBoxHeight() + gap
>= tallRight.right:getBorderBoxHeight(),
"the following footer starts below the completed save-slot column")
-- Reconciliation uses the maximum completed column; it must not blindly
-- copy the right side and shrink an already-taller left side.
local shortRight = nestedColumns(420, 220)
T.eq(refreshAutoHeight(shortRight.grid), 420,
"a shorter right column preserves the taller left extent")
T.eq(shortRight.grid.height, 420,
"short content keeps its correct content height")
T.eq(shortRight.grid:getBorderBoxHeight(), 420,
"short content keeps its correct border-box height")
-- Match FlexLove's resize path: clamp the padded border box, then derive and
-- clamp the content box from it.
local constrained = nestedColumns(600, 500, {
padding = { top = 10, bottom = 20 },
maxHeight = 550,
})
T.eq(constrained.grid:calculateAutoHeight(), 600,
"the constrained grid measures its taller child before padding")
T.eq(refreshAutoHeight(constrained.grid), 550,
"max-height clamps the padded border box")
T.eq(constrained.grid.height, 520,
"content height subtracts padding from the constrained border box")
T.eq(constrained.grid:getBorderBoxHeight(), 550,
"the constrained border-box cache stays synchronized")
-- Invalid or inapplicable measurements are fail-closed and leave geometry
-- untouched rather than poisoning the next layout pass.
local notANumber = nestedColumns(220, 520)
notANumber.grid.calculateAutoHeight = function() return 0 / 0 end
notANumber.grid._dirty = false
T.eq(refreshAutoHeight(notANumber.grid), false, "NaN auto height is rejected")
T.eq(notANumber.grid.height, 220, "NaN leaves content height unchanged")
T.eq(notANumber.grid:getBorderBoxHeight(), 220,
"NaN leaves border-box height unchanged")
T.eq(notANumber.grid._dirty, false, "NaN does not invalidate layout")
local infinite = nestedColumns(220, 520)
infinite.grid.calculateAutoHeight = function() return math.huge end
T.eq(refreshAutoHeight(infinite.grid), false, "infinite auto height is rejected")
T.eq(infinite.grid.height, 220, "infinite height leaves geometry unchanged")
local fixed = FlexLove.new({ width = 800, height = 220 })
T.eq(refreshAutoHeight(fixed), false, "fixed-height elements are ignored")
T.eq(fixed.height, 220, "fixed-height geometry is unchanged")
T.eq(refreshAutoHeight(nil), false, "a missing element is ignored")
T.finish("launcher save-slot overlap #748")
@@ -133,6 +133,13 @@ check(ri._findSearchFocus == false, "a tab change drops the caret")
eq(lastArm(), false, "and disarms setTextInput")
ri.tab = "find"
ri:_toggleFindSearchFocus()
check(ri._findSearchFocus == true, "tapping the search field focuses it")
eq(lastArm(), true, "refocusing the search field arms setTextInput")
ri:_toggleFindSearchFocus()
check(ri._findSearchFocus == false, "tapping the focused search field blurs it")
eq(lastArm(), false, "blurring the search field disarms setTextInput")
-- ---- desktop contract (#529): disarm never lowers off Android -------------
ri.android = false
+130
View File
@@ -0,0 +1,130 @@
-- NX handheld/dock display sync (portable 720p / docked 1080p).
-- Self-contained: luajit tests/engine/nx_display_test.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check = T.check
local eq = T.eq
local savedLove = _G.love
package.loaded["src.core.Platform"] = nil
package.loaded["src.core.NxDisplay"] = nil
local NxDisplay = require("src.core.NxDisplay")
local function withWindow(state, fn)
local setCalls = {}
_G.love = {
system = {
getOS = function() return state.os or "NX" end,
},
window = {
getMode = function()
return state.w, state.h, {
fullscreen = state.fullscreen,
resizable = state.resizable,
vsync = 1,
}
end,
setMode = function(w, h, flags)
setCalls[#setCalls + 1] = { w = w, h = h, flags = flags }
state.w, state.h = w, h
state.fullscreen = flags and flags.fullscreen
state.resizable = flags and flags.resizable
end,
},
}
package.loaded["src.core.Platform"] = nil
require("src.core.Platform")._resetForTests()
NxDisplay._resetForTests()
package.loaded["src.core.NxDisplay"] = nil
NxDisplay = require("src.core.NxDisplay")
local ok, err = pcall(fn, setCalls)
_G.love = savedLove
package.loaded["src.core.Platform"] = nil
package.loaded["src.core.NxDisplay"] = nil
NxDisplay = require("src.core.NxDisplay")
NxDisplay._resetForTests()
if not ok then error(err) end
end
-- Size mapping
do
local w, h = NxDisplay.desiredSize(0)
eq(w, 1280, "handheld mode → 1280 wide")
eq(h, 720, "handheld mode → 720 tall")
w, h = NxDisplay.desiredSize(1)
eq(w, 1920, "console/docked mode → 1920 wide")
eq(h, 1080, "console/docked mode → 1080 tall")
w, h = NxDisplay.desiredSize(nil)
-- With no test hook and no real Switch FFI, operationMode is nil → no size.
check(w == nil and h == nil, "nil/unknown mode returns no size (do not force)")
w, h = NxDisplay.desiredSize(99)
check(w == nil and h == nil, "unknown mode returns no size")
end
-- Non-NX: sync is a no-op
withWindow({ os = "Linux", w = 1024, h = 768, fullscreen = false, resizable = true }, function(setCalls)
NxDisplay._forceNXForTests = false
NxDisplay._operationModeForTests = 1
eq(NxDisplay.sync(), false, "sync returns false off NX")
eq(#setCalls, 0, "sync never calls setMode off NX")
end)
-- NX handheld already correct: no setMode (even if flags look "wrong")
withWindow({
os = "NX", w = 1280, h = 720, fullscreen = true, resizable = false,
}, function(setCalls)
NxDisplay._forceNXForTests = true
NxDisplay._operationModeForTests = 0
eq(NxDisplay.sync(), false, "sync skips setMode when size already matches")
eq(#setCalls, 0, "no setMode when only flags differ (avoids flicker)")
end)
-- NX docked boot from 720p hint → 1080p
withWindow({
os = "NX", w = 1280, h = 720, fullscreen = true, resizable = false,
}, function(setCalls)
NxDisplay._forceNXForTests = true
NxDisplay._operationModeForTests = 1
eq(NxDisplay.sync(), true, "sync upgrades docked boot to 1080p")
eq(#setCalls, 1, "one setMode on docked boot")
eq(setCalls[1].w, 1920, "docked setMode width")
eq(setCalls[1].h, 1080, "docked setMode height")
eq(setCalls[1].flags.fullscreen, false, "docked setMode clears exclusive fullscreen")
eq(setCalls[1].flags.resizable, true, "docked setMode enables resizable for SDL backup")
-- Second sync must not setMode again (flicker guard)
eq(NxDisplay.sync(), false, "second sync is no-op after size matches")
eq(#setCalls, 1, "still only one setMode after repeated sync")
end)
-- NX undock: 1080p → 720p
withWindow({
os = "NX", w = 1920, h = 1080, fullscreen = false, resizable = true,
}, function(setCalls)
NxDisplay._forceNXForTests = true
NxDisplay._operationModeForTests = 0
eq(NxDisplay.sync(), true, "sync shrinks to handheld after undock")
eq(setCalls[1].w, 1280, "undock setMode width")
eq(setCalls[1].h, 720, "undock setMode height")
end)
-- Unknown mode: do not force a size (would fight SDL and flicker)
withWindow({
os = "NX", w = 1920, h = 1080, fullscreen = false, resizable = true,
}, function(setCalls)
NxDisplay._forceNXForTests = true
NxDisplay._operationModeForTests = nil
-- Force operationMode() to return nil by using a sentinel the API treats
-- as "use live" then stubbing via desiredSize path — set a mode that
-- desiredSize rejects by clearing the hook after setting force NX, and
-- monkey-patching operationMode through the test hook to a non-value:
-- _operationModeForTests = false is not nil, so use a dedicated unknown.
-- Actually nil hook means live FFI; in tests FFI has no symbol → nil mode.
eq(NxDisplay.sync(), false, "sync no-ops when mode unknown")
eq(#setCalls, 0, "unknown mode never calls setMode")
end)
T.finish("nx_display")
+69
View File
@@ -0,0 +1,69 @@
-- Guard: core code must not call love loaders directly on literal
-- assets/generated paths. Centralized loading (Assets / the NX overlay)
-- is what keeps mod overrides and the Blue/Yellow NX fallback working; a
-- raw literal load silently bypasses both. This scans every src/*.lua and
-- fails on new violations so the class of bug cannot regress by accident.
-- Self-contained: luajit tests/engine/nx_generated_guard_test.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check = T.check
local FORBIDDEN = {
'love%.graphics%.newImage%(%s*"assets/generated',
'love%.image%.newImageData%(%s*"assets/generated',
'love%.audio%.newSource%(%s*"assets/generated',
'love%.sound%.newSoundData%(%s*"assets/generated',
'love%.graphics%.newFont%(%s*"assets/generated',
'love%.font%.newFontData%(%s*"assets/generated',
'love%.filesystem%.read%(%s*"assets/generated',
'love%.filesystem%.load%(%s*"assets/generated',
'love%.filesystem%.lines%(%s*"assets/generated',
'love%.filesystem%.newFileData%(%s*"assets/generated',
'love%.filesystem%.getInfo%(%s*"assets/generated',
}
-- Files that legitimately reference generated literals but never load them
-- directly (writers, mount setup, the NX probe, mod source roots) are not
-- matched by the patterns above, so no allowlist is needed.
local function listLuaFiles(dir, out)
out = out or {}
local p = io.popen('find "' .. dir .. '" -name "*.lua" -type f')
if not p then return out end
for line in p:lines() do
out[#out + 1] = line
end
p:close()
return out
end
local violations = {}
for _, file in ipairs(listLuaFiles("src")) do
local f = io.open(file, "r")
if f then
local body = f:read("*a")
f:close()
for _, pat in ipairs(FORBIDDEN) do
if body:find(pat) then
violations[#violations + 1] = file .. " matches " .. pat
end
end
end
end
check(#violations == 0,
"no direct love loader call on literal assets/generated paths"
.. (#violations > 0 and (":\n " .. table.concat(violations, "\n ")) or ""))
-- The NX overlay module itself must exist and stay NX-gated at install time.
local f = io.open("main.lua", "r")
local mainSrc = f and f:read("*a") or ""
if f then f:close() end
check(mainSrc:find("NxAssetOverlay", 1, true) ~= nil,
"main.lua installs NxAssetOverlay")
check(mainSrc:find("isNX", 1, true) ~= nil
and mainSrc:find('require("src.core.NxAssetOverlay").install()', 1, true) ~= nil,
"the overlay install stays gated on Platform.isNX()")
T.finish()
+358
View File
@@ -0,0 +1,358 @@
-- NX Yellow boot, headless: the runtime complement to
-- tests/engine/nx_generated_guard_test.lua. The guard only sees literal
-- loader calls; this suite drives the REAL boot states (TitleState,
-- YellowIntro/IntroMovie, Sound.playPikaCry) against a broken-mount NX
-- filesystem where generated art exists ONLY under the versioned save-dir
-- prefix (yellow|blue/), and records every path that reaches the raw love
-- loaders AFTER NxAssetOverlay's rewrite. Any data-driven or formatted
-- assets/generated path the overlay misses shows up here as a bare path
-- the recorder saw. Self-contained:
-- luajit tests/engine/nx_yellow_boot_test.lua
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 GameVersion = require("src.core.GameVersion")
local Platform = require("src.core.Platform")
local Overlay = require("src.core.NxAssetOverlay")
local TitleState = require("src.ui.TitleState")
local YellowIntro = require("src.ui.YellowIntro")
local Sound = require("src.core.Sound")
local savedVersion = GameVersion.get()
local GENERATED = "assets/generated/"
-- --- fixtures ----------------------------------------------------------
-- Seeded ONLY under the versioned prefix, mirroring the fused love-nx bug
-- where yellow|blue/assets/generated is never mounted over assets/generated.
local seeded = {}
local function seed(path, bytes)
love.filesystem.write(path, bytes or "fake-asset-bytes")
seeded[#seeded + 1] = path
end
local Y_TITLE = {
"pikachu.png", "pika_bubble.png", "eyes_half.png", "eyes_closed.png",
"player.png", "copyright.png", "yellow_version.png",
}
for _, name in ipairs(Y_TITLE) do
seed("yellow/assets/generated/title/" .. name)
end
local Y_INTRO = {
"yellow_intro_1.png", "yellow_intro_2.png", "clouds.png",
-- data-driven manifest entries (the paths a literal scan cannot see)
"gf_logo.png", "gf_text.png", "big_star.png",
"falling_star.png", "falling_star_blink.png", "studio_logo.png",
"gengar_1.png", "gengar_2.png", "gengar_3.png",
"nidorino_1.png", "nidorino_2.png", "nidorino_3.png",
}
for _, name in ipairs(Y_INTRO) do
seed("yellow/assets/generated/intro/" .. name)
end
seed("yellow/assets/generated/audio/pika_cries/cry_01.wav", "RIFF-fake-wav")
-- --- recorder, installed BEFORE Overlay.install so it sees the final
-- resolved path (the overlay wraps whatever is in place at install time)
local recorded = {}
local function record(kind, path)
if type(path) == "string" then
recorded[#recorded + 1] = { kind = kind, path = path }
end
end
local rawNewImage = love.graphics.newImage
love.graphics.newImage = function(path, ...)
record("image", path)
return rawNewImage(path, ...)
end
local rawRead = love.filesystem.read
love.filesystem.read = function(path, ...)
record("read", path)
return rawRead(path, ...)
end
-- widenMono re-reads the caller's bare path via newSoundData; without this
-- recorder the boot suite only saw newSource and could not catch a missing
-- sound.newSoundData wrap (the silent hole that motivated the full-surface
-- overlay). Installed before Overlay.install so the overlay wraps us.
local rawNewSoundData = love.sound.newSoundData
love.sound.newSoundData = function(samples, ...)
record("sounddata", samples)
return rawNewSoundData(samples, ...)
end
local Source = {}
Source.__index = Source
function Source:play() self.playing = true end
function Source:stop() self.playing = false end
function Source:setVolume() end
function Source:isPlaying() return self.playing end
function Source:getChannelCount() return self.channels or 1 end
love.audio = {
newSource = function(pathOrData, mode)
record("source", pathOrData)
local channels = 1
if type(pathOrData) == "table" and pathOrData.getChannelCount then
channels = pathOrData:getChannelCount()
end
return setmetatable({
path = pathOrData, mode = mode, channels = channels,
}, Source)
end,
}
Overlay.install()
check(Overlay.isInstalled(), "overlay installs over the recorders")
-- Every recorded path under assets/generated/ must already carry the
-- active version prefix: a bare generated path here is a dynamic-path
-- regression the static guard cannot catch.
local function assertNoBareGenerated(prefix, label)
local bare = {}
for _, r in ipairs(recorded) do
if r.path:sub(1, #GENERATED) == GENERATED then
bare[#bare + 1] = r.kind .. " " .. r.path
end
end
check(#bare == 0,
label .. ": every generated path reached the raw loader " .. prefix
.. "-prefixed"
.. (#bare > 0 and (" (bare: " .. table.concat(bare, ", ") .. ")") or ""))
end
local function countRecorded(prefix)
local n = 0
for _, r in ipairs(recorded) do
if r.path:sub(1, #prefix) == prefix then n = n + 1 end
end
return n
end
-- --- Yellow boot -------------------------------------------------------
GameVersion.set("yellow")
local yellowTitleManifest = {
layout = "yellow_pikachu",
pikachu = { path = "assets/generated/title/pikachu.png" },
pikaBubble = { path = "assets/generated/title/pika_bubble.png" },
version = { path = "assets/generated/title/yellow_version.png" },
}
local yellowIntroManifest = {
studio = { logo = "assets/generated/intro/studio_logo.png" },
gamefreakLogo = { path = "assets/generated/intro/gf_logo.png" },
gamefreakText = { path = "assets/generated/intro/gf_text.png" },
bigStar = { path = "assets/generated/intro/big_star.png" },
fallingStar = { path = "assets/generated/intro/falling_star.png" },
fallingStarBlink = { path = "assets/generated/intro/falling_star_blink.png" },
gengar = {
frame1 = { path = "assets/generated/intro/gengar_1.png" },
frame2 = { path = "assets/generated/intro/gengar_2.png" },
frame3 = { path = "assets/generated/intro/gengar_3.png" },
},
nidorino = {
frame1 = { path = "assets/generated/intro/nidorino_1.png" },
frame2 = { path = "assets/generated/intro/nidorino_2.png" },
frame3 = { path = "assets/generated/intro/nidorino_3.png" },
},
}
local game = { data = { field = {
title = yellowTitleManifest,
intro = yellowIntroManifest,
} } }
local titleState = TitleState.new(game, {})
check(titleState.yellowLayout, "the Yellow manifest selects the Pikachu layout")
check(titleState.yellowPikachu ~= nil, "title Pikachu art loaded")
eq(titleState.yellowPikachu and titleState.yellowPikachu.path,
"yellow/assets/generated/title/pikachu.png",
"title Pikachu resolves to the yellow/ copy")
check(titleState.yellowBubble ~= nil, "title speech bubble loaded")
eq(titleState.yellowBubble and titleState.yellowBubble.path,
"yellow/assets/generated/title/pika_bubble.png",
"title bubble resolves to the yellow/ copy")
eq(titleState.eyesHalf and titleState.eyesHalf.path,
"yellow/assets/generated/title/eyes_half.png",
"blink overlay (eyes_half) resolves to the yellow/ copy")
eq(titleState.eyesClosed and titleState.eyesClosed.path,
"yellow/assets/generated/title/eyes_closed.png",
"blink overlay (eyes_closed) resolves to the yellow/ copy")
eq(titleState.player and titleState.player.path,
"yellow/assets/generated/title/player.png",
"title player resolves to the yellow/ copy")
eq(titleState.version and titleState.version.path,
"yellow/assets/generated/title/yellow_version.png",
"version ribbon resolves to the yellow/ copy")
-- YellowIntro.new internally builds IntroMovie.new as its pre-roll, so one
-- constructor covers the copyright card, the GAME FREAK splash and the
-- Yellow attract atlases.
local yellowIntro = YellowIntro.new(game, function() end)
eq(yellowIntro.atlas1 and yellowIntro.atlas1.path,
"yellow/assets/generated/intro/yellow_intro_1.png",
"YellowIntro atlas1 resolves to the yellow/ copy")
eq(yellowIntro.atlas2 and yellowIntro.atlas2.path,
"yellow/assets/generated/intro/yellow_intro_2.png",
"YellowIntro atlas2 resolves to the yellow/ copy")
eq(yellowIntro.clouds and yellowIntro.clouds.path,
"yellow/assets/generated/intro/clouds.png",
"YellowIntro clouds resolve to the yellow/ copy")
local pre = yellowIntro.pre
check(pre ~= nil, "the IntroMovie pre-roll was constructed")
eq(pre and pre.copyright and pre.copyright.path,
"yellow/assets/generated/title/copyright.png",
"copyright card resolves to the yellow/ copy")
eq(pre and pre.studioLogo and pre.studioLogo.path,
"yellow/assets/generated/intro/studio_logo.png",
"data-driven studio logo resolves to the yellow/ copy")
eq(pre and pre.logo and pre.logo.path,
"yellow/assets/generated/intro/gf_logo.png",
"data-driven gamefreakLogo resolves to the yellow/ copy")
eq(pre and pre.gfText and pre.gfText.path,
"yellow/assets/generated/intro/gf_text.png",
"data-driven gamefreakText resolves to the yellow/ copy")
eq(pre and pre.bigStar and pre.bigStar.path,
"yellow/assets/generated/intro/big_star.png",
"data-driven bigStar resolves to the yellow/ copy")
eq(pre and pre.gengarFrames and pre.gengarFrames[2]
and pre.gengarFrames[2].path,
"yellow/assets/generated/intro/gengar_2.png",
"data-driven gengar frame resolves to the yellow/ copy")
eq(pre and pre.nidoFrames and pre.nidoFrames[3]
and pre.nidoFrames[3].path,
"yellow/assets/generated/intro/nidorino_3.png",
"data-driven nidorino frame resolves to the yellow/ copy")
-- Yellow's voiced Pikachu clip: a FORMATTED path (cry_%02d.wav), invisible
-- to the static guard. playPikaCry also runs widenMono, which re-reads the
-- same bare path via newSoundData and must emit a 16-bit STEREO Source
-- (#626) -- path rewrite alone is not enough on Switch audren.
local cry = Sound.playPikaCry({ audio = { pikaCries = 1 } }, 1)
check(cry ~= nil, "playPikaCry returns a source on NX Yellow")
eq(cry and cry:getChannelCount(), 2,
"playPikaCry widens the mono PCM clip to stereo")
local sawCrySoundData = false
local sawCrySource = false
for _, r in ipairs(recorded) do
if r.kind == "sounddata"
and r.path == "yellow/assets/generated/audio/pika_cries/cry_01.wav" then
sawCrySoundData = true
end
if r.kind == "source"
and r.path == "yellow/assets/generated/audio/pika_cries/cry_01.wav" then
sawCrySource = true
end
end
check(sawCrySource,
"newSource loaded the cry through the yellow/ prefix")
check(sawCrySoundData,
"widenMono re-read the cry via newSoundData with the yellow/ path")
check(countRecorded("yellow/assets/generated/") >= 20,
"the boot pulled its generated art through the yellow/ prefix")
assertNoBareGenerated("yellow/", "Yellow boot")
-- --- Blue boot: parity with Yellow --------------------------------------
-- Blue's real boot is IntroMovie (the Red/Blue attract movie) straight onto
-- the stack, then TitleState with cycling mons -- no Pikachu layout. Same
-- broken-mount setup, same assertions, blue/ prefix.
recorded = {}
GameVersion.set("blue")
seed("blue/assets/generated/title/blue_version.png", "blue-version-bytes")
seed("blue/assets/generated/title/player.png")
seed("blue/assets/generated/title/copyright.png")
local B_INTRO = {
"gf_logo.png", "gf_text.png", "big_star.png",
"falling_star.png", "falling_star_blink.png", "studio_logo.png",
"gengar_1.png", "gengar_2.png", "gengar_3.png",
"nidorino_1.png", "nidorino_2.png", "nidorino_3.png",
}
for _, name in ipairs(B_INTRO) do
seed("blue/assets/generated/intro/" .. name)
end
local blueIntroManifest = {
studio = { logo = "assets/generated/intro/studio_logo.png" },
gamefreakLogo = { path = "assets/generated/intro/gf_logo.png" },
gamefreakText = { path = "assets/generated/intro/gf_text.png" },
bigStar = { path = "assets/generated/intro/big_star.png" },
fallingStar = { path = "assets/generated/intro/falling_star.png" },
fallingStarBlink = { path = "assets/generated/intro/falling_star_blink.png" },
gengar = {
frame1 = { path = "assets/generated/intro/gengar_1.png" },
frame2 = { path = "assets/generated/intro/gengar_2.png" },
frame3 = { path = "assets/generated/intro/gengar_3.png" },
},
nidorino = {
frame1 = { path = "assets/generated/intro/nidorino_1.png" },
frame2 = { path = "assets/generated/intro/nidorino_2.png" },
frame3 = { path = "assets/generated/intro/nidorino_3.png" },
},
}
local blueGame = { data = { field = {
title = { version = { path = "assets/generated/title/blue_version.png" } },
intro = blueIntroManifest,
} } }
local IntroMovie = require("src.ui.IntroMovie")
local blueIntro = IntroMovie.new(blueGame, function() end)
eq(blueIntro.copyright and blueIntro.copyright.path,
"blue/assets/generated/title/copyright.png",
"Blue copyright card resolves to the blue/ copy")
eq(blueIntro.studioLogo and blueIntro.studioLogo.path,
"blue/assets/generated/intro/studio_logo.png",
"Blue data-driven studio logo resolves to the blue/ copy")
eq(blueIntro.logo and blueIntro.logo.path,
"blue/assets/generated/intro/gf_logo.png",
"Blue data-driven gamefreakLogo resolves to the blue/ copy")
eq(blueIntro.gfText and blueIntro.gfText.path,
"blue/assets/generated/intro/gf_text.png",
"Blue data-driven gamefreakText resolves to the blue/ copy")
eq(blueIntro.bigStar and blueIntro.bigStar.path,
"blue/assets/generated/intro/big_star.png",
"Blue data-driven bigStar resolves to the blue/ copy")
eq(blueIntro.gengarFrames and blueIntro.gengarFrames[2]
and blueIntro.gengarFrames[2].path,
"blue/assets/generated/intro/gengar_2.png",
"Blue data-driven gengar frame resolves to the blue/ copy")
eq(blueIntro.nidoFrames and blueIntro.nidoFrames[3]
and blueIntro.nidoFrames[3].path,
"blue/assets/generated/intro/nidorino_3.png",
"Blue data-driven nidorino frame resolves to the blue/ copy")
local blueTitle = TitleState.new(blueGame, {})
check(not blueTitle.yellowLayout,
"the Blue manifest keeps the cycling-mons layout")
eq(blueTitle.version and blueTitle.version.path,
"blue/assets/generated/title/blue_version.png",
"Blue title ribbon resolves to the blue/ copy")
eq(blueTitle.player and blueTitle.player.path,
"blue/assets/generated/title/player.png",
"Blue title player resolves to the blue/ copy")
eq(love.filesystem.read("assets/generated/title/blue_version.png"),
"blue-version-bytes",
"a generated filesystem.read resolves to the blue/ bytes")
check(countRecorded("blue/assets/generated/") >= 12,
"the boot pulled its generated art through the blue/ prefix")
assertNoBareGenerated("blue/", "Blue boot")
-- --- cleanup ------------------------------------------------------------
Overlay.uninstall()
check(not Overlay.isInstalled(), "overlay uninstalls")
love.graphics.newImage = rawNewImage
love.filesystem.read = rawRead
love.sound.newSoundData = rawNewSoundData
love.audio = nil
for _, path in ipairs(seeded) do
love.filesystem.remove(path)
end
Platform._resetForTests()
GameVersion.set(savedVersion)
T.finish()
+66
View File
@@ -0,0 +1,66 @@
-- ORIENTATION lock (#592, #716): the option model.
--
-- The Android side (SDL hint parsing, GameActivity's *_SENSOR -> *_USER
-- remap) can only be exercised on a device; what this tier pins down is the
-- Lua contract every UI row leans on: the mode set, normalization of stale
-- or garbage saves, the cycle order in both directions, and that apply() is
-- a safe no-op anywhere that is not Android -- including here, where love
-- is a headless stub and no SDL library is loaded.
-- luajit tests/engine/orientation_option.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Orientation = require("src.core.Orientation")
local realOS = love.system and love.system.getOS
love.system = love.system or {}
-- ------------------------------------------------------------- normalize
T.eq(Orientation.DEFAULT, "auto", "AUTO is the default")
T.eq(Orientation.normalize(nil), "auto", "missing option reads as AUTO")
T.eq(Orientation.normalize("sideways"), "auto", "garbage reads as AUTO")
T.eq(Orientation.normalize("portrait"), "portrait", "valid modes pass through")
T.eq(Orientation.normalize("reverseLandscape"), "reverseLandscape",
"reverse landscape is a real mode")
-- ------------------------------------------------------------------ cycle
T.eq(Orientation.cycle("auto", 1), "portrait", "cycle forward from AUTO")
T.eq(Orientation.cycle("reverseLandscape", 1), "auto", "cycle wraps forward")
T.eq(Orientation.cycle("auto", -1), "reverseLandscape", "cycle wraps back")
T.eq(Orientation.cycle(nil, 1), "portrait", "cycling a fresh save starts at AUTO")
-- one full lap forward touches every mode exactly once
local seen, mode = {}, "auto"
for _ = 1, #Orientation.MODES do
seen[mode] = true
mode = Orientation.cycle(mode, 1)
end
T.eq(mode, "auto", "a full lap returns to the start")
for _, m in ipairs(Orientation.MODES) do
T.eq(seen[m], true, "lap visits " .. m)
end
-- ----------------------------------------------------------------- labels
for _, m in ipairs(Orientation.MODES) do
T.eq(type(Orientation.modeLabel(m)), "string", m .. " has a label")
T.eq(#Orientation.modeLabel(m) <= 17, true,
m .. "'s label fits the OPTION box value line (17 cells at x=24)")
end
-- -------------------------------------------------- apply() stays harmless
love.system.getOS = function() return "OS X" end
T.eq(Orientation.apply("portrait"), false, "desktop apply is a refused no-op")
love.system.getOS = function() return "iOS" end
T.eq(Orientation.apply("portrait"), false, "iOS defers to the Info.plist")
-- Android posed but no SDL loaded in this process: the FFI path must fail
-- closed inside its pcall, never throw.
love.system.getOS = function() return "Android" end
local ok, err = pcall(Orientation.applyOptions, { orientation = "landscape" })
T.eq(ok, true, "posed-Android apply never raises (" .. tostring(err) .. ")")
love.system.getOS = realOS
@@ -0,0 +1,47 @@
-- Self-updater and remote mod download must stay off on NX until validated.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
love.system = love.system or {}
love.filesystem = love.filesystem or {}
local S = require("tests.harness").suite("platform NX network gate")
local check = S.check
local eq = S.eq
local checkStarted = false
package.loaded["src.update.Check"] = {
start = function() checkStarted = true end,
state = function() return { status = "idle" } end,
}
love.system.getOS = function() return "NX" end
love.filesystem.isFused = function() return true end
love.filesystem.getSaveDirectory = function() return "/save/pokemon-love2d" end
package.loaded["src.core.Platform"] = nil
package.loaded["src.import.RomImporter"] = nil
local RomImporter = require("src.import.RomImporter")
local ri = RomImporter.new(function() end, { launcher = true })
ri.ready = { red = false, blue = false, yellow = false }
eq(checkStarted, false, "self-updater does not start on NX fused launcher")
check(ri.Check == nil, "launcher has no Check module on NX")
eq(require("src.core.Platform").networkValidated(), false,
"NX reports networkValidated false")
ri.mods = { { id = "demo", name = "Demo", github = "owner/repo", version = "1.0.0" } }
ri:_modGithubAction("demo", "update")
check(ri.modNotice and not ri.modNotice.ok,
"remote mod github action is blocked on NX")
ri.tab = "find"
ri:_refreshFind(true)
eq(#((ri.findIndex and ri.findIndex.mods) or {}), 0,
"find mods refresh stays empty on NX")
package.loaded["src.update.Check"] = nil
package.loaded["src.core.Platform"] = nil
package.loaded["src.import.RomImporter"] = nil
S.finish()
@@ -0,0 +1,55 @@
-- NX must not invoke HostShell / desktop file pickers (SWNX-04).
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
love.system = love.system or {}
love.filesystem = love.filesystem or {}
local S = require("tests.harness").suite("platform NX shell gate")
local check = S.check
local eq = S.eq
local popenCalls = 0
local realHostShell = package.loaded["src.core.HostShell"]
package.loaded["src.core.HostShell"] = {
envPrefix = function() return "" end,
popen = function()
popenCalls = popenCalls + 1
return nil
end,
restart = function() end,
}
love.system.getOS = function() return "NX" end
love.filesystem.getSaveDirectory = function() return "/save/pokemon-love2d" end
package.loaded["src.core.Platform"] = nil
package.loaded["src.import.RomImporter"] = nil
local RomImporter = require("src.import.RomImporter")
local ri = RomImporter.new(function() end, { launcher = true })
ri.ready = { red = false, blue = false, yellow = false }
popenCalls = 0
ri:choose("red")
eq(popenCalls, 0, "choose on NX does not call HostShell.popen")
check(ri.notice ~= nil, "NX choose sets a save-directory notice")
check(ri.notice.detail:find("imports", 1, true) ~= nil,
"notice mentions imports inbox path")
-- Desktop path still reaches the shell when a picker exists.
love.system.getOS = function() return "Linux" end
package.loaded["src.core.Platform"] = nil
package.loaded["src.import.RomImporter"] = nil
RomImporter = require("src.import.RomImporter")
ri = RomImporter.new(function() end, { launcher = true })
ri.ready = { red = false, blue = false, yellow = false }
popenCalls = 0
ri:choose("red")
check(popenCalls >= 1 or ri.notice ~= nil,
"Linux choose still attempts shell picker or falls back with notice")
package.loaded["src.core.HostShell"] = realHostShell
package.loaded["src.core.Platform"] = nil
package.loaded["src.import.RomImporter"] = nil
S.finish()
+62
View File
@@ -0,0 +1,62 @@
-- NX / Android / desktop capability detection (SWNX-01).
-- Self-contained: luajit tests/engine/platform_nx_test.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check = T.check
local eq = T.eq
local savedLove = _G.love
local function withOS(osName, pickFile, fn)
_G.love = {
system = {
getOS = function() return osName end,
pickFile = pickFile,
},
}
package.loaded["src.core.Platform"] = nil
local Platform = require("src.core.Platform")
Platform._resetForTests()
local ok, err = pcall(fn, Platform)
_G.love = savedLove
package.loaded["src.core.Platform"] = nil
if not ok then error(err) end
end
-- NX: save-directory import, no shell spawn, network gated off
withOS("NX", nil, function(Platform)
local caps = Platform.detect()
eq(caps.os, "NX", "NX detect os")
eq(caps.nx, true, "NX flag")
eq(caps.romImportMode, "save-directory", "NX romImportMode")
eq(caps.canSpawnProcess, false, "NX cannot spawn processes")
eq(caps.networkValidated, false, "NX network not validated")
eq(Platform.isNX(), true, "isNX convenience")
eq(Platform.romImportMode(), "save-directory", "romImportMode helper")
end)
-- Android: mobile native picker path, not NX semantics
withOS("Android", function() end, function(Platform)
local caps = Platform.detect()
eq(caps.os, "Android", "Android detect os")
eq(caps.nx, false, "Android is not NX")
eq(caps.mobile, true, "Android mobile")
eq(caps.hasNativePicker, true, "Android has pickFile")
eq(caps.romImportMode, "native-picker", "Android romImportMode")
eq(Platform.isNX(), false, "Android isNX false")
end)
-- Desktop Linux: shell spawn + desktop import mode
withOS("Linux", nil, function(Platform)
local caps = Platform.detect()
eq(caps.os, "Linux", "Linux detect os")
eq(caps.nx, false, "Linux is not NX")
eq(caps.canSpawnProcess, true, "Linux can spawn processes")
eq(caps.romImportMode, "desktop", "Linux romImportMode")
eq(caps.networkValidated, true, "Linux network validated")
eq(Platform.canSpawnProcess(), true, "canSpawnProcess helper")
end)
T.finish()
@@ -0,0 +1,47 @@
-- NX RomImporter must not inherit Android mobile-file-bridge semantics (SWNX-03).
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("rom importer NX flags")
local eq = S.eq
local check = S.check
love.system = love.system or {}
love.filesystem = love.filesystem or {}
local saved = {
getOS = love.system.getOS,
getSaveDirectory = love.filesystem.getSaveDirectory,
createDirectory = love.filesystem.createDirectory,
}
love.system.getOS = function() return "NX" end
love.filesystem.getSaveDirectory = function() return "sdmc:/switch/gen1recomp/pokemon-love2d" end
love.filesystem.createDirectory = function() return true end
package.loaded["src.core.Platform"] = nil
package.loaded["src.import.RomImporter"] = nil
local RomImporter = require("src.import.RomImporter")
local ri = RomImporter.new(function() end, { launcher = true })
eq(ri.isNX, true, "NX importer sets isNX")
eq(ri.android, false, "NX importer does not set android")
eq(ri.mobileFileBridge, false, "NX importer does not set mobileFileBridge")
eq(ri.romImportMode, "save-directory", "NX importer exposes save-directory mode")
check(ri.pickPending == nil, "NX importer does not arm mobile pick polling")
love.system.getOS = function() return "Android" end
package.loaded["src.core.Platform"] = nil
package.loaded["src.import.RomImporter"] = nil
RomImporter = require("src.import.RomImporter")
ri = RomImporter.new(function() end, { launcher = true })
eq(ri.isNX, false, "Android importer is not NX")
eq(ri.android, true, "Android importer keeps android mobile path")
eq(ri.mobileFileBridge, true, "Android importer sets mobileFileBridge")
love.system.getOS = saved.getOS
love.filesystem.getSaveDirectory = saved.getSaveDirectory
love.filesystem.createDirectory = saved.createDirectory
package.loaded["src.core.Platform"] = nil
package.loaded["src.import.RomImporter"] = nil
S.finish()
+251
View File
@@ -0,0 +1,251 @@
-- NX writable-inbox import: path hint, rescan, validate, hash route (SWNX-05..09).
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("rom importer NX inbox")
local eq = S.eq
local check = S.check
local GameVersion = require("src.core.GameVersion")
local RomImporter = require("src.import.RomImporter")
local MiB = 1024 * 1024
local redData = string.rep("R", MiB)
local blueData = string.rep("B", MiB)
local yellowData = string.rep("Y", MiB)
local badSizeData = string.rep("?", MiB - 1)
local unknownData = string.rep("?", MiB)
love.data = love.data or {}
love.system = love.system or {}
love.filesystem = love.filesystem or {}
local saved = {
hash = love.data.hash,
encode = love.data.encode,
getOS = love.system.getOS,
getSaveDirectory = love.filesystem.getSaveDirectory,
createDirectory = love.filesystem.createDirectory,
remove = love.filesystem.remove,
}
love.data.hash = function(_, data)
return { tag = data:sub(1, 1) }
end
love.data.encode = function(_, _, digest)
if type(digest) == "table" and digest.tag == "R" then
return GameVersion.info("red").sha1
end
if type(digest) == "table" and digest.tag == "B" then
return GameVersion.info("blue").sha1
end
if type(digest) == "table" and digest.tag == "Y" then
return GameVersion.info("yellow").sha1
end
return "0000000000000000000000000000000000000000"
end
love.system.getOS = function() return "NX" end
love.filesystem.getSaveDirectory = function() return "sdmc:/switch/gen1recomp/pokemon-love2d" end
local createdDirs = {}
love.filesystem.createDirectory = function(name)
createdDirs[name] = true
return true
end
local removed = {}
love.filesystem.remove = function(name)
removed[name] = true
return saved.remove(name)
end
package.loaded["src.core.Platform"] = nil
package.loaded["src.import.RomImporter"] = nil
RomImporter = require("src.import.RomImporter")
-- mtpHintPath strips only validated sdmc:/ prefix
eq(RomImporter.mtpHintPath("sdmc:/switch/gen1recomp/pokemon-love2d"),
"switch/gen1recomp/pokemon-love2d", "mtpHintPath strips sdmc:/")
eq(RomImporter.mtpHintPath("/save/pokemon-love2d"),
"/save/pokemon-love2d", "mtpHintPath leaves non-sdmc paths alone")
eq(RomImporter.mtpHintPath("sdmc:"), "sdmc:",
"mtpHintPath does not strip bare sdmc: without slash")
local function clearInbox()
for _, name in ipairs(love.filesystem.getDirectoryItems("imports") or {}) do
love.filesystem.remove("imports/" .. name)
end
for _, name in ipairs(love.filesystem.getDirectoryItems("") or {}) do
if name:lower():match("%.gbc?$") then love.filesystem.remove(name) end
end
end
local function freshImporter(ready)
clearInbox()
createdDirs = {}
removed = {}
package.loaded["src.import.RomImporter"] = nil
RomImporter = require("src.import.RomImporter")
return setmetatable({
isNX = true,
romImportMode = "save-directory",
mobileFileBridge = false,
android = false,
launcher = true,
workState = nil,
tab = "red",
ready = {
red = ready.red and true or false,
blue = ready.blue and true or false,
yellow = ready.yellow and true or false,
},
notice = nil,
chooseVersion = nil,
startData = function(self, data, displayName)
self._started = { data = data, name = displayName }
if #data ~= MiB then
self.workState = "error"
self.detail = "size"
elseif not GameVersion.forSha1(
love.data.encode("string", "hex", love.data.hash("sha1", data))) then
self.workState = "error"
self.detail = "hash"
end
end,
setError = function(self, message)
self.workState = "error"
self.detail = message
end,
ensureImportsDir = RomImporter.ensureImportsDir,
_setNxInboxNotice = RomImporter._setNxInboxNotice,
scanInbox = RomImporter.scanInbox,
rescanAction = RomImporter.rescanAction,
}, RomImporter)
end
-- First open creates imports/ and shows save path + MTP hint
createdDirs = {}
local ri = freshImporter({ red = false, blue = false, yellow = false })
ri:ensureImportsDir()
check(createdDirs.imports, "ensureImportsDir creates imports/")
ri:_setNxInboxNotice("red")
check(ri.notice ~= nil, "NX notice is set")
check(ri.notice.detail:find("sdmc:/switch/gen1recomp/pokemon-love2d/imports/", 1, true),
"notice contains runtime save path")
check(ri.notice.detail:find("DBI MTP", 1, true) ~= nil,
"notice contains OpenMTP-oriented hint")
check(ri.notice.detail:find("switch/gen1recomp/pokemon-love2d/imports/", 1, true),
"hint uses sdmc-stripped relative path")
-- Empty inbox rescan refreshes notice
ri = freshImporter({ red = false, blue = false, yellow = false })
ri:rescanAction("red")
check(ri.notice ~= nil, "empty inbox rescan shows notice")
check(ri._started == nil, "empty inbox does not start import")
-- Bad extension ignored
ri = freshImporter({ red = false, blue = false, yellow = false })
love.filesystem.write("imports/readme.txt", "nope")
ri:rescanAction("red")
check(ri._started == nil, "non-ROM extension is ignored")
-- Bad size rejected
ri = freshImporter({ red = false, blue = false, yellow = false })
love.filesystem.write("imports/small.gb", badSizeData)
ri:rescanAction("red")
check(ri._started ~= nil, "undersized ROM triggers import attempt")
eq(ri.workState, "error", "undersized ROM is rejected")
check(ri._started.name == "small.gb", "bad size uses basename")
-- Unknown hash rejected
ri = freshImporter({ red = false, blue = false, yellow = false })
love.filesystem.write("imports/hacked.gb", unknownData)
ri:rescanAction("red")
check(ri._started ~= nil, "unknown hash ROM is routed through startData")
eq(ri.workState, "error", "unknown hash is rejected")
-- Valid Red from imports/
ri = freshImporter({ red = false, blue = false, yellow = false })
love.filesystem.write("imports/pokemon red.gb", redData)
ri:rescanAction("red")
check(ri._started ~= nil, "valid Red stub imports")
eq(ri._started.name, "pokemon red.gb", "unicode/space filename preserved")
eq(ri._started.data, redData, "Red bytes passed through")
check(not removed["pokemon red.gb"], "NX retains source dump after import start")
-- imports/ scanned before save root
ri = freshImporter({ red = false, blue = false, yellow = false })
love.filesystem.write("imports/blue.gbc", blueData)
love.filesystem.write("red_root.gb", redData)
ri:rescanAction("blue")
eq(ri._started.name, "blue.gbc", "imports/ wins over save root ordering")
-- Already-imported dump skipped so another version can import
ri = freshImporter({ red = true, blue = false, yellow = false })
love.filesystem.write("imports/pokemon red.gb", redData)
love.filesystem.write("imports/pokemon blue.gb", blueData)
ri:rescanAction("blue")
eq(ri._started.name, "pokemon blue.gb", "ready Red dump ignored for pending Blue")
-- Yellow valid stub
ri = freshImporter({ red = false, blue = false, yellow = false })
love.filesystem.write("imports/pika.gbc", yellowData)
ri:rescanAction("yellow")
eq(ri._started.name, "pika.gbc", "valid Yellow stub imports")
-- Tab Scan again matches by SHA: Yellow must not import a pending Red dump
ri = freshImporter({ red = false, blue = false, yellow = false })
love.filesystem.write("imports/pokemon red.gb", redData)
ri:rescanAction("yellow")
check(ri._started == nil, "Yellow Scan again does not import pending Red")
check(ri.notice ~= nil, "Yellow Scan again with only Red sets notice")
eq(ri.notice.version, "yellow", "notice stays on Yellow tab")
check(ri.notice.status:find("matching", 1, true) or ri.notice.status:find("Matching", 1, true),
"notice reports no matching ROM for the tab")
-- Mixed inbox: Red listed first, Yellow pending — Yellow tab still picks Yellow
ri = freshImporter({ red = false, blue = false, yellow = false })
love.filesystem.write("imports/aaa_red.gb", redData)
love.filesystem.write("imports/zzz_yellow.gbc", yellowData)
ri:rescanAction("yellow")
eq(ri._started.name, "zzz_yellow.gbc",
"Yellow Scan again prefers Yellow SHA over earlier Red file")
-- Blue tab ignores pending Red (same SHA filter as Yellow)
ri = freshImporter({ red = false, blue = false, yellow = false })
love.filesystem.write("imports/pokemon red.gb", redData)
ri:rescanAction("blue")
check(ri._started == nil, "Blue Scan again does not import pending Red")
eq(ri.notice.version, "blue", "Blue-only-other-dump notice stays on Blue")
-- Target already ready in a mixed inbox → No new ROM (not other-version import)
ri = freshImporter({ red = false, blue = false, yellow = true })
love.filesystem.write("imports/aaa_red.gb", redData)
love.filesystem.write("imports/zzz_yellow.gbc", yellowData)
ri:rescanAction("yellow")
check(ri._started == nil, "ready Yellow + pending Red does not start import")
check(ri.notice ~= nil and ri.notice.status:find("No new ROM", 1, true),
"ready Yellow dump yields No new ROM found")
-- Cleanup + restore stubs shared with other suites
love.filesystem.remove("imports/readme.txt")
love.filesystem.remove("imports/small.gb")
love.filesystem.remove("imports/hacked.gb")
love.filesystem.remove("imports/pokemon red.gb")
love.filesystem.remove("imports/blue.gbc")
love.filesystem.remove("imports/pokemon blue.gb")
love.filesystem.remove("imports/pika.gbc")
love.filesystem.remove("imports/aaa_red.gb")
love.filesystem.remove("imports/zzz_yellow.gbc")
love.filesystem.remove("red_root.gb")
love.data.hash = saved.hash
love.data.encode = saved.encode
love.system.getOS = saved.getOS
love.filesystem.getSaveDirectory = saved.getSaveDirectory
love.filesystem.createDirectory = saved.createDirectory
love.filesystem.remove = saved.remove
package.loaded["src.core.Platform"] = nil
package.loaded["src.import.RomImporter"] = nil
S.finish()
@@ -0,0 +1,312 @@
-- NX mods zip inbox: ensure imports/mods/, MTP hint (NXMOD-01..05).
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("rom importer NX mods inbox")
local eq = S.eq
local check = S.check
local RomImporter = require("src.import.RomImporter")
love.system = love.system or {}
love.filesystem = love.filesystem or {}
local saved = {
getOS = love.system.getOS,
getSaveDirectory = love.filesystem.getSaveDirectory,
createDirectory = love.filesystem.createDirectory,
remove = love.filesystem.remove,
}
love.system.getOS = function() return "NX" end
love.filesystem.getSaveDirectory = function()
return "sdmc:/switch/gen1recomp/pokemon-love2d"
end
local createdDirs = {}
love.filesystem.createDirectory = function(name)
createdDirs[name] = true
return true
end
local removed = {}
love.filesystem.remove = function(name)
removed[name] = true
return saved.remove(name)
end
package.loaded["src.core.Platform"] = nil
package.loaded["src.import.RomImporter"] = nil
RomImporter = require("src.import.RomImporter")
local function clearModsInbox()
for _, name in ipairs(love.filesystem.getDirectoryItems("imports/mods") or {}) do
love.filesystem.remove("imports/mods/" .. name)
end
for _, name in ipairs(love.filesystem.getDirectoryItems("imports") or {}) do
love.filesystem.remove("imports/" .. name)
end
end
local function freshImporter()
clearModsInbox()
createdDirs = {}
removed = {}
package.loaded["src.import.RomImporter"] = nil
RomImporter = require("src.import.RomImporter")
return setmetatable({
isNX = true,
android = false,
launcher = true,
workState = nil,
tab = "mods",
modNotice = nil,
mods = {},
ready = { red = false, blue = false, yellow = false },
ensureImportsDir = RomImporter.ensureImportsDir,
ensureModsInboxDir = RomImporter.ensureModsInboxDir,
_setNxModsInboxNotice = RomImporter._setNxModsInboxNotice,
scanModsInbox = RomImporter.scanModsInbox,
scanInbox = RomImporter.scanInbox,
rescanModsAction = RomImporter.rescanModsAction,
chooseMod = RomImporter.chooseMod,
_installMod = RomImporter._installMod,
_refreshMods = function(self)
self._refreshed = (self._refreshed or 0) + 1
self.mods = self.mods or {}
end,
}, RomImporter)
end
-- NXMOD-01: ensureModsInboxDir creates imports/mods/ under save FS
createdDirs = {}
local ri = freshImporter()
ri:ensureModsInboxDir()
check(createdDirs.imports or createdDirs["imports/mods"],
"ensureModsInboxDir creates parent imports/ or nested path")
check(createdDirs["imports/mods"],
"ensureModsInboxDir creates imports/mods/")
-- NXMOD-01: notice/hint includes save dir + relative imports/mods/ MTP path
ri = freshImporter()
ri:_setNxModsInboxNotice()
check(ri.modNotice ~= nil, "NX mods inbox notice is set")
check(ri.modNotice.text:find("sdmc:/switch/gen1recomp/pokemon-love2d/imports/mods/", 1, true),
"mods notice contains runtime save path + imports/mods/")
check(ri.modNotice.text:find("DBI MTP", 1, true) ~= nil,
"mods notice contains OpenMTP-oriented hint")
check(ri.modNotice.text:find("switch/gen1recomp/pokemon-love2d/imports/mods/", 1, true),
"hint uses sdmc-stripped relative imports/mods/ path")
-- NXMOD-02: scanModsInbox returns only *.zip under imports/mods/
ri = freshImporter()
love.filesystem.write("imports/mods/valid.zip", "ZIPDATA")
love.filesystem.write("imports/mods/readme.txt", "nope")
love.filesystem.write("imports/mods/cart.gb", string.rep("R", 16))
love.filesystem.write("imports/other.zip", "WRONGDIR")
local zips = ri:scanModsInbox()
eq(#zips, 1, "scanModsInbox returns one zip candidate")
eq(zips[1], "imports/mods/valid.zip", "scanModsInbox path is under imports/mods/")
-- ROM scanInbox must not treat .zip as ROM
ri = freshImporter()
love.filesystem.write("imports/modpack.zip", "ZIPROM")
love.filesystem.write("imports/mods/also.zip", "ZIPMOD")
local roms = ri:scanInbox()
for _, path in ipairs(roms) do
check(not path:lower():match("%.zip$"),
"ROM scanInbox ignores zip: " .. tostring(path))
end
eq(#roms, 0, "ROM scanInbox finds no zip-only inbox entries")
-- Edge: ROM inbox with .gb alongside .zip still ignores zip (spec edge)
ri = freshImporter()
love.filesystem.write("imports/cart.gb", string.rep("G", 16))
love.filesystem.write("imports/sidecar.zip", "NOTAROM")
roms = ri:scanInbox()
local sawGb, sawZip = false, false
for _, path in ipairs(roms) do
if path:lower():match("%.zip$") then sawZip = true end
if path:lower():match("%.gb$") then sawGb = true end
end
check(sawGb, "ROM scan still finds .gb when zip present")
check(not sawZip, "ROM scan never lists .zip even beside .gb")
-- Stub LauncherMods.installZip for rescan tests (NXMOD-02..04)
local installCalls = {}
local installBehavior = {} -- path -> {ok=bool, id=string|err}
package.loaded["src.mods.LauncherMods"] = {
installZip = function(source)
installCalls[#installCalls + 1] = source
local b = installBehavior[source]
if not b then return false, "unexpected source: " .. tostring(source) end
if b.ok then return true, b.id or "mod-id" end
return false, b.err or "bad zip"
end,
}
-- Empty inbox rescan → MTP notice, no install
ri = freshImporter()
installCalls = {}
ri:rescanModsAction()
eq(#installCalls, 0, "empty mods inbox does not call installZip")
check(ri.modNotice ~= nil and ri.modNotice.text:find("imports/mods/", 1, true),
"empty rescan shows mods MTP notice")
-- Success → refresh; zip retained (no remove)
ri = freshImporter()
installCalls = {}
removed = {}
love.filesystem.write("imports/mods/good.zip", "GOODZIP")
installBehavior["imports/mods/good.zip"] = { ok = true, id = "good-mod" }
ri:rescanModsAction()
eq(#installCalls, 1, "success path calls installZip once")
eq(installCalls[1], "imports/mods/good.zip", "installZip receives inbox path")
check(ri._refreshed and ri._refreshed >= 1, "success refreshes mods list")
check(ri.modNotice and ri.modNotice.ok, "success sets ok notice")
check(not removed["imports/mods/good.zip"], "success retains inbox zip")
check(love.filesystem.read("imports/mods/good.zip") == "GOODZIP",
"success leaves zip bytes in inbox")
-- Failure → clear notice; zip retained
ri = freshImporter()
installCalls = {}
removed = {}
love.filesystem.write("imports/mods/bad.zip", "BADZIP")
installBehavior["imports/mods/bad.zip"] = { ok = false, err = "missing manifest" }
ri:rescanModsAction()
eq(#installCalls, 1, "failure path still attempts installZip")
check(ri.modNotice and not ri.modNotice.ok, "failure sets clear error notice")
check(ri.modNotice.text:find("missing manifest", 1, true),
"failure notice includes installZip error")
check(not removed["imports/mods/bad.zip"], "failure does not remove inbox zip")
check(love.filesystem.read("imports/mods/bad.zip") == "BADZIP",
"failure leaves zip in inbox")
-- Mixed valid/invalid: attempt each; no zip deleted
ri = freshImporter()
installCalls = {}
removed = {}
love.filesystem.write("imports/mods/a-bad.zip", "BAD")
love.filesystem.write("imports/mods/b-good.zip", "GOOD")
installBehavior["imports/mods/a-bad.zip"] = { ok = false, err = "no manifest" }
installBehavior["imports/mods/b-good.zip"] = { ok = true, id = "b-mod" }
ri:rescanModsAction()
eq(#installCalls, 2, "mixed inbox attempts each zip")
check(not removed["imports/mods/a-bad.zip"], "mixed: bad zip retained")
check(not removed["imports/mods/b-good.zip"], "mixed: good zip retained")
check(love.filesystem.read("imports/mods/a-bad.zip") ~= nil, "mixed bad still present")
check(love.filesystem.read("imports/mods/b-good.zip") ~= nil, "mixed good still present")
check(ri.modNotice and ri.modNotice.ok, "mixed keeps overall success when one zip installs")
check(ri.modNotice.text:find("failed", 1, true),
"mixed success notice still surfaces sibling failure")
check(ri.modNotice.text:find("no manifest", 1, true),
"mixed success notice includes the failure reason")
-- Mac MTP AppleDouble (._*.zip) must not be install candidates
ri = freshImporter()
installCalls = {}
love.filesystem.write("imports/mods/._DRAMATIC_SHAPE-1.4.0.zip", "APPL")
love.filesystem.write("imports/mods/DRAMATIC_SHAPE-1.4.0.zip", "GOOD")
installBehavior["imports/mods/DRAMATIC_SHAPE-1.4.0.zip"] = { ok = true, id = "dramatic_shape" }
ri:rescanModsAction()
eq(#installCalls, 1, "AppleDouble ._*.zip is skipped")
eq(installCalls[1], "imports/mods/DRAMATIC_SHAPE-1.4.0.zip",
"only the real zip is installed")
check(ri.modNotice and ri.modNotice.ok, "AppleDouble skip still shows install success")
check(not (ri.modNotice.text or ""):find("failed", 1, true),
"AppleDouble-only sibling does not invent a mixed failure line")
-- Mac MTP AppleDouble ROM sidecar must not be ROM inbox candidates
ri = freshImporter()
love.filesystem.write("imports/._cart.gb", string.rep("X", 16))
love.filesystem.write("imports/cart.gb", string.rep("G", 16))
roms = ri:scanInbox()
local sawHidden, sawReal = false, false
for _, path in ipairs(roms) do
if path:find("._cart", 1, true) then sawHidden = true end
if path == "imports/cart.gb" then sawReal = true end
end
check(not sawHidden, "ROM scanInbox skips AppleDouble ._*.gb")
check(sawReal, "ROM scanInbox still finds the real .gb")
love.filesystem.remove("imports/._cart.gb")
love.filesystem.remove("imports/cart.gb")
-- NXMOD-05: chooseMod on NX routes to inbox rescan; no HostShell/chooseZip
local hostShellCalls = 0
package.loaded["src.core.HostShell"] = {
run = function()
hostShellCalls = hostShellCalls + 1
error("HostShell must not run on NX chooseMod")
end,
available = function() return false end,
}
ri = freshImporter()
installCalls = {}
love.filesystem.write("imports/mods/from-choose.zip", "CHOOSE")
installBehavior["imports/mods/from-choose.zip"] = { ok = true, id = "choose-mod" }
ri:chooseMod()
eq(hostShellCalls, 0, "NX chooseMod does not require HostShell")
eq(#installCalls, 1, "NX chooseMod rescans and installs inbox zip")
eq(installCalls[1], "imports/mods/from-choose.zip",
"NX chooseMod installs from imports/mods/")
check(ri.modNotice and ri.modNotice.ok, "NX chooseMod success notice")
-- NXMOD-01 UI: NX MODS panel label + hints mention imports/mods/
ri = freshImporter()
eq(ri:_modsImportButtonLabel(), "Scan again",
"NX MODS button label is Scan again")
local defaultHint = ri:_modsDefaultHint()
check(defaultHint:find("imports/mods/", 1, true),
"NX default hint mentions imports/mods/")
check(defaultHint:find("DBI MTP", 1, true),
"NX default hint mentions DBI MTP")
local emptyHint = ri:_modsEmptyHint()
check(emptyHint:find("imports/mods/", 1, true),
"NX empty-state hint mentions imports/mods/")
check(emptyHint:find("Scan again", 1, true),
"NX empty-state hint mentions Scan again")
-- Desktop keeps Import mod .zip (non-NX)
local desk = setmetatable({
isNX = false, android = false,
_modsImportButtonLabel = RomImporter._modsImportButtonLabel,
_modsDefaultHint = RomImporter._modsDefaultHint,
_modsEmptyHint = RomImporter._modsEmptyHint,
}, RomImporter)
eq(desk:_modsImportButtonLabel(), "Import mod .zip",
"desktop MODS button stays Import mod .zip")
check(desk:_modsDefaultHint():find("drop a mod", 1, true),
"desktop default hint stays drop-oriented")
-- Edge: id-already-exists conflict retains inbox zip (no silent delete)
ri = freshImporter()
installCalls = {}
removed = {}
love.filesystem.write("imports/mods/dup.zip", "DUP")
installBehavior["imports/mods/dup.zip"] = {
ok = false, err = "mod id already installed",
}
ri:rescanModsAction()
eq(#installCalls, 1, "conflict still attempts installZip")
check(ri.modNotice and not ri.modNotice.ok, "conflict surfaces notice")
check(not removed["imports/mods/dup.zip"], "conflict retains inbox zip")
check(love.filesystem.read("imports/mods/dup.zip") == "DUP",
"conflict leaves zip bytes intact")
-- Cleanup + restore stubs
clearModsInbox()
love.filesystem.remove("imports/other.zip")
love.filesystem.remove("imports/modpack.zip")
love.filesystem.remove("imports/cart.gb")
love.filesystem.remove("imports/sidecar.zip")
package.loaded["src.mods.LauncherMods"] = nil
package.loaded["src.core.HostShell"] = nil
love.system.getOS = saved.getOS
love.filesystem.getSaveDirectory = saved.getSaveDirectory
love.filesystem.createDirectory = saved.createDirectory
love.filesystem.remove = saved.remove
package.loaded["src.core.Platform"] = nil
package.loaded["src.import.RomImporter"] = nil
S.finish()
@@ -0,0 +1,514 @@
-- NX saves .sav inbox: ensure imports/saves/, MTP hint, AppleDouble/retain/
-- retire-on-success + hash dedupe (NXSAV + RES harden).
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("rom importer NX saves inbox")
local eq = S.eq
local check = S.check
local RomImporter = require("src.import.RomImporter")
love.data = love.data or {}
love.system = love.system or {}
love.filesystem = love.filesystem or {}
local saved = {
hash = love.data.hash,
encode = love.data.encode,
getOS = love.system.getOS,
getSaveDirectory = love.filesystem.getSaveDirectory,
createDirectory = love.filesystem.createDirectory,
remove = love.filesystem.remove,
}
-- Deterministic fake sha1 so content-hash dedupe is testable headless.
love.data.hash = function(_, data)
return data
end
love.data.encode = function(_, _, digest)
local s = type(digest) == "string" and digest or tostring(digest)
local hex = {}
for i = 1, #s do
hex[#hex + 1] = string.format("%02x", s:byte(i))
end
local h = table.concat(hex)
if #h < 40 then h = h .. string.rep("0", 40 - #h) end
return h:sub(1, 40)
end
love.system.getOS = function() return "NX" end
love.filesystem.getSaveDirectory = function()
return "sdmc:/switch/gen1recomp/pokemon-love2d"
end
local createdDirs = {}
love.filesystem.createDirectory = function(name)
createdDirs[name] = true
return true
end
local removed = {}
love.filesystem.remove = function(name)
removed[name] = true
return saved.remove(name)
end
package.loaded["src.core.Platform"] = nil
package.loaded["src.import.RomImporter"] = nil
RomImporter = require("src.import.RomImporter")
local function clearSavesInbox()
for _, ver in ipairs({ "red", "blue", "yellow" }) do
local dir = "imports/saves/" .. ver
for _, name in ipairs(love.filesystem.getDirectoryItems(dir) or {}) do
love.filesystem.remove(dir .. "/" .. name)
end
love.filesystem.remove(dir .. "/.imported-sha1")
end
for _, name in ipairs(love.filesystem.getDirectoryItems("imports/saves") or {}) do
love.filesystem.remove("imports/saves/" .. name)
end
for _, name in ipairs(love.filesystem.getDirectoryItems("imports/mods") or {}) do
love.filesystem.remove("imports/mods/" .. name)
end
for _, name in ipairs(love.filesystem.getDirectoryItems("imports") or {}) do
love.filesystem.remove("imports/" .. name)
end
end
local function freshImporter()
clearSavesInbox()
createdDirs = {}
removed = {}
package.loaded["src.import.RomImporter"] = nil
RomImporter = require("src.import.RomImporter")
return setmetatable({
isNX = true,
android = false,
launcher = true,
workState = nil,
tab = "red",
panelVersion = "red",
saveNotice = {},
ready = { red = true, blue = false, yellow = false },
activeSlot = {},
slotScroll = {},
slots = {},
ensureImportsDir = RomImporter.ensureImportsDir,
ensureSavesInboxDir = RomImporter.ensureSavesInboxDir,
ensureModsInboxDir = RomImporter.ensureModsInboxDir,
_setNxSavesInboxNotice = RomImporter._setNxSavesInboxNotice,
_resolveSaveVersion = RomImporter._resolveSaveVersion,
scanSavesInbox = RomImporter.scanSavesInbox,
scanModsInbox = RomImporter.scanModsInbox,
scanInbox = RomImporter.scanInbox,
rescanSavesAction = RomImporter.rescanSavesAction,
chooseSaveImport = RomImporter.chooseSaveImport,
exportSave = RomImporter.exportSave,
_importSave = RomImporter._importSave,
_savedropTarget = RomImporter._savedropTarget,
_savesDefaultHint = RomImporter._savesDefaultHint,
_refreshSlots = function(self, version)
self._refreshed = (self._refreshed or 0) + 1
self._refreshVersion = version
end,
}, RomImporter)
end
-- RES-07: fixture uses isNX=true, android=false
local ri = freshImporter()
eq(ri.isNX, true, "RES-07: fixture isNX=true")
eq(ri.android, false, "RES-07: fixture android=false")
-- RES-01: ensureSavesInboxDir creates imports/, imports/saves/, and per-game dirs
createdDirs = {}
ri = freshImporter()
ri:ensureSavesInboxDir("red")
check(createdDirs.imports == true,
"RES-01: ensureSavesInboxDir creates parent imports/")
check(createdDirs["imports/saves"] == true,
"RES-01: ensureSavesInboxDir creates imports/saves/")
check(createdDirs["imports/saves/red"] == true,
"RES-01: ensureSavesInboxDir creates imports/saves/red/")
check(createdDirs["imports/saves/blue"] == true,
"RES-01: ensureSavesInboxDir creates imports/saves/blue/")
check(createdDirs["imports/saves/yellow"] == true,
"RES-01: ensureSavesInboxDir creates imports/saves/yellow/")
-- NXSAV-02: notice/hint includes save dir + per-game imports/saves/<version>/ MTP path
ri = freshImporter()
ri:_setNxSavesInboxNotice("red")
check(ri.saveNotice.red ~= nil, "NX saves inbox notice is set")
check(ri.saveNotice.red.text:find("sdmc:/switch/gen1recomp/pokemon-love2d/imports/saves/red/", 1, true),
"saves notice contains runtime save path + imports/saves/red/")
check(ri.saveNotice.red.text:find("DBI MTP", 1, true) ~= nil,
"saves notice contains OpenMTP-oriented hint")
check(ri.saveNotice.red.text:find("imports/saves/red/", 1, true),
"hint uses per-game imports/saves/red/ path")
-- NXSAV-01 / RES-08: scanSavesInbox returns only *.sav under imports/saves/<version>/
ri = freshImporter()
love.filesystem.write("imports/saves/red/valid.sav", string.rep("S", 32))
love.filesystem.write("imports/saves/red/readme.txt", "nope")
love.filesystem.write("imports/saves/red/cart.gb", string.rep("R", 16))
love.filesystem.write("imports/saves/red/pack.zip", "ZIP")
love.filesystem.write("imports/saves/blue/other.sav", "WRONGGAME")
love.filesystem.write("imports/other.sav", "WRONGDIR")
local savs = ri:scanSavesInbox("red")
eq(#savs, 1, "scanSavesInbox returns one .sav candidate for red")
eq(savs[1], "imports/saves/red/valid.sav", "scanSavesInbox path is under imports/saves/red/")
-- RES-08: ROM scanInbox must not treat imports/saves/*.sav as ROM
ri = freshImporter()
love.filesystem.write("imports/saves/red/cart.sav", string.rep("S", 32))
love.filesystem.write("imports/saves/red/dump.gb", string.rep("G", 16))
local roms = ri:scanInbox()
for _, path in ipairs(roms) do
check(not path:lower():match("%.sav$"),
"ROM scanInbox ignores .sav: " .. tostring(path))
check(not path:find("imports/saves/", 1, true),
"ROM scanInbox ignores imports/saves/: " .. tostring(path))
end
-- RES-08: mod scanModsInbox ignores .sav
ri = freshImporter()
ri:ensureModsInboxDir()
love.filesystem.write("imports/mods/mod.zip", "ZIP")
love.filesystem.write("imports/saves/red/slot.sav", string.rep("S", 32))
local zips = ri:scanModsInbox()
for _, path in ipairs(zips) do
check(not path:lower():match("%.sav$"),
"mod scanModsInbox ignores .sav: " .. tostring(path))
end
eq(#zips, 1, "mod scanModsInbox still finds only its zip")
-- Stub SaveFileIO.importToSlot for rescan tests
local importCalls = {}
local importBehavior = {} -- path -> {ok=bool, id=string|err}
package.loaded["src.import.SaveFileIO"] = {
importToSlot = function(source, version)
importCalls[#importCalls + 1] = { source = source, version = version }
local b = importBehavior[source]
if not b then return false, "unexpected source: " .. tostring(source) end
if b.ok then return true, b.id or "slot-1" end
return false, b.err or "bad sav"
end,
exportActiveSlot = function()
return false, "no save"
end,
}
-- RES-04: empty inbox rescan → MTP notice, no import
ri = freshImporter()
importCalls = {}
ri:rescanSavesAction("red")
eq(#importCalls, 0, "empty saves inbox does not call importToSlot")
check(ri.saveNotice.red ~= nil, "RES-04: empty rescan sets saveNotice")
check(ri.saveNotice.red.text:find("imports/saves/red/", 1, true),
"empty rescan shows saves MTP notice")
-- RES-02: AppleDouble-only inbox ≡ empty
ri = freshImporter()
importCalls = {}
love.filesystem.write("imports/saves/red/._foo.sav", "APPL")
ri:rescanSavesAction("red")
eq(#importCalls, 0, "RES-02: AppleDouble-only does not import")
check(ri.saveNotice.red ~= nil and ri.saveNotice.red.text:find("imports/saves/red/", 1, true),
"RES-02: AppleDouble-only shows MTP notice")
-- NXSAV-03 / RES-05: success → refresh; bytes kept as *.sav.imported (not re-scanned)
ri = freshImporter()
importCalls = {}
removed = {}
love.filesystem.write("imports/saves/red/good.sav", "GOODSAV")
importBehavior["imports/saves/red/good.sav"] = { ok = true, id = "slot-good" }
ri:rescanSavesAction("red")
eq(#importCalls, 1, "success path calls importToSlot once")
eq(importCalls[1].source, "imports/saves/red/good.sav", "importToSlot receives inbox path")
eq(importCalls[1].version, "red", "importToSlot uses panel version")
check(ri._refreshed and ri._refreshed >= 1, "success refreshes slots")
check(ri.saveNotice.red and ri.saveNotice.red.ok, "success sets ok notice")
check(ri.saveNotice.red.text:find("Pokemon Red", 1, true)
or ri.saveNotice.red.text:find("Red", 1, true),
"success notice names the game tab")
check(love.filesystem.getInfo("imports/saves/red/good.sav") == nil,
"RES-05: success retires live .sav (no longer a candidate)")
check(love.filesystem.read("imports/saves/red/good.sav.imported") == "GOODSAV",
"RES-05: success keeps bytes under .sav.imported")
check(love.filesystem.getInfo("imports/saves/red/.imported-sha1") ~= nil,
"success records content hash ledger")
-- Re-press Import save must not clone slots (hash ledger + retired file)
ri = freshImporter()
importCalls = {}
love.filesystem.write("imports/saves/red/again.sav", "SAMEBYTES")
importBehavior["imports/saves/red/again.sav"] = { ok = true, id = "slot-1" }
ri:rescanSavesAction("red")
eq(#importCalls, 1, "first import of again.sav")
-- Put the same bytes back under a new name (player re-copied / renamed)
love.filesystem.write("imports/saves/red/again-copy.sav", "SAMEBYTES")
importBehavior["imports/saves/red/again-copy.sav"] = { ok = true, id = "slot-clone" }
importCalls = {}
ri:rescanSavesAction("red")
eq(#importCalls, 0, "harden: same content hash is not imported again")
check(ri.saveNotice.red and ri.saveNotice.red.ok,
"harden: already-imported skip sets ok notice")
check(ri.saveNotice.red.text:find("Already imported", 1, true)
or ri.saveNotice.red.text:find("skipped", 1, true),
"harden: notice explains skip")
check(love.filesystem.getInfo("imports/saves/red/again-copy.sav") == nil,
"harden: leftover duplicate .sav is retired without importing")
-- NXSAV-04 / RES-05: failure → clear notice; .sav retained as-is
ri = freshImporter()
importCalls = {}
removed = {}
love.filesystem.write("imports/saves/red/bad.sav", "BADSAV")
importBehavior["imports/saves/red/bad.sav"] = { ok = false, err = "save file must be 32768 bytes" }
ri:rescanSavesAction("red")
eq(#importCalls, 1, "failure path still attempts importToSlot")
check(ri.saveNotice.red and not ri.saveNotice.red.ok, "failure sets clear error notice")
check(ri.saveNotice.red.text:find("32768", 1, true),
"failure notice includes import error")
check(not removed["imports/saves/red/bad.sav"], "RES-05: failure does not remove inbox .sav")
check(love.filesystem.read("imports/saves/red/bad.sav") == "BADSAV",
"RES-05: failure leaves .sav in inbox")
-- Mixed valid/invalid: attempt each; bad retained, good retired
ri = freshImporter()
importCalls = {}
removed = {}
love.filesystem.write("imports/saves/red/a-bad.sav", "BAD")
love.filesystem.write("imports/saves/red/b-good.sav", "GOOD")
importBehavior["imports/saves/red/a-bad.sav"] = { ok = false, err = "bad checksum" }
importBehavior["imports/saves/red/b-good.sav"] = { ok = true, id = "slot-b" }
ri:rescanSavesAction("red")
eq(#importCalls, 2, "mixed inbox attempts each .sav")
check(love.filesystem.read("imports/saves/red/a-bad.sav") == "BAD",
"mixed: bad .sav retained")
check(love.filesystem.getInfo("imports/saves/red/b-good.sav") == nil,
"mixed: good .sav retired")
check(love.filesystem.read("imports/saves/red/b-good.sav.imported") == "GOOD",
"mixed: good bytes kept as .imported")
check(ri.saveNotice.red and ri.saveNotice.red.ok, "mixed keeps overall success when one imports")
check(ri.saveNotice.red.text:find("failed", 1, true),
"mixed success notice still surfaces sibling failure")
check(ri.saveNotice.red.text:find("bad checksum", 1, true),
"mixed success notice includes the failure reason")
-- Multi-success notice names count + active slot (not only last ok line)
ri = freshImporter()
importCalls = {}
love.filesystem.write("imports/saves/red/one.sav", "ONE")
love.filesystem.write("imports/saves/red/two.sav", "TWO")
importBehavior["imports/saves/red/one.sav"] = { ok = true, id = "slot-one" }
importBehavior["imports/saves/red/two.sav"] = { ok = true, id = "slot-two" }
ri:rescanSavesAction("red")
eq(#importCalls, 2, "multi-success imports each distinct .sav")
check(ri.saveNotice.red.text:find("Imported 2 saves", 1, true),
"multi-success notice reports count")
check(ri.saveNotice.red.text:find("Active:", 1, true),
"multi-success notice reports active slot")
eq(ri.activeSlot.red, "slot-two", "multi-success leaves last import active")
-- RES-03: Mac MTP AppleDouble (._*.sav) must not be import candidates
ri = freshImporter()
importCalls = {}
love.filesystem.write("imports/saves/red/._cart.sav", "APPL")
love.filesystem.write("imports/saves/red/cart.sav", "GOOD")
importBehavior["imports/saves/red/cart.sav"] = { ok = true, id = "slot-cart" }
ri:rescanSavesAction("red")
eq(#importCalls, 1, "RES-03: AppleDouble ._*.sav is skipped")
eq(importCalls[1].source, "imports/saves/red/cart.sav",
"only the real .sav is imported")
check(ri.saveNotice.red and ri.saveNotice.red.ok, "AppleDouble skip still shows import success")
check(not (ri.saveNotice.red.text or ""):find("failed", 1, true),
"RES-03: AppleDouble-only sibling does not invent a mixed failure line")
-- RES-06: NX chooseSaveImport must not call HostShell / chooseSav path
local hostShellCalls = 0
package.loaded["src.core.HostShell"] = {
run = function()
hostShellCalls = hostShellCalls + 1
error("HostShell must not run on NX chooseSaveImport")
end,
popen = function()
hostShellCalls = hostShellCalls + 1
error("HostShell.popen must not run on NX chooseSaveImport")
end,
available = function() return false end,
}
-- Re-require so chooseSav sees the stubbed HostShell if it were reached.
package.loaded["src.import.RomImporter"] = nil
RomImporter = require("src.import.RomImporter")
ri = freshImporter()
hostShellCalls = 0
ri:chooseSaveImport("red")
eq(hostShellCalls, 0, "RES-06: NX chooseSaveImport does not require HostShell")
-- NXSAV-05: chooseSaveImport on NX rescans inbox
ri = freshImporter()
importCalls = {}
hostShellCalls = 0
love.filesystem.write("imports/saves/red/from-choose.sav", "CHOOSE")
importBehavior["imports/saves/red/from-choose.sav"] = { ok = true, id = "slot-choose" }
ri:chooseSaveImport("red")
eq(hostShellCalls, 0, "NX chooseSaveImport does not use HostShell")
eq(#importCalls, 1, "NX chooseSaveImport rescans and imports inbox .sav")
eq(importCalls[1].source, "imports/saves/red/from-choose.sav",
"NX chooseSaveImport imports from imports/saves/red/")
check(ri.saveNotice.red and ri.saveNotice.red.ok, "NX chooseSaveImport success notice")
-- Empty chooseSaveImport still sets notice (RES-04 via Import save button)
ri = freshImporter()
importCalls = {}
ri:chooseSaveImport("red")
eq(#importCalls, 0, "empty NX chooseSaveImport does not import")
check(ri.saveNotice.red ~= nil and ri.saveNotice.red.text:find("imports/saves/red/", 1, true),
"empty NX chooseSaveImport sets MTP notice")
-- Edge: ROM not ready → refuse with existing notice (no silent no-op)
ri = freshImporter()
importCalls = {}
removed = {}
ri.ready.red = false
love.filesystem.write("imports/saves/red/need-rom.sav", "NEEDROM")
importBehavior["imports/saves/red/need-rom.sav"] = { ok = true, id = "should-not-import" }
ri:chooseSaveImport("red")
eq(#importCalls, 0, "ROM-not-ready: chooseSaveImport does not call importToSlot")
check(ri.saveNotice.red and not ri.saveNotice.red.ok,
"ROM-not-ready: chooseSaveImport sets error notice")
check(ri.saveNotice.red.text:find("Import the Pokemon Red ROM before importing a save", 1, true),
"ROM-not-ready: notice tells player to import ROM first")
check(not removed["imports/saves/red/need-rom.sav"],
"ROM-not-ready: retains inbox .sav")
check(love.filesystem.read("imports/saves/red/need-rom.sav") == "NEEDROM",
"ROM-not-ready: leaves .sav bytes in inbox")
ri = freshImporter()
importCalls = {}
ri.ready.red = false
love.filesystem.write("imports/saves/red/need-rom2.sav", "NEEDROM2")
importBehavior["imports/saves/red/need-rom2.sav"] = { ok = true, id = "should-not" }
ri:rescanSavesAction("red")
eq(#importCalls, 0, "ROM-not-ready: rescan does not call importToSlot")
check(ri.saveNotice.red and not ri.saveNotice.red.ok,
"ROM-not-ready: rescan sets error notice")
check(ri.saveNotice.red.text:find("Import the Pokemon Red ROM before importing a save", 1, true),
"ROM-not-ready: rescan surfaces ROM-first notice")
-- Edge: workState == "working" → Import/rescan no-op without clearing notice
ri = freshImporter()
importCalls = {}
ri.saveNotice.red = { ok = true, text = "PRESERVE_ME" }
ri.workState = "working"
love.filesystem.write("imports/saves/red/busy.sav", "BUSY")
importBehavior["imports/saves/red/busy.sav"] = { ok = true, id = "slot-busy" }
ri:chooseSaveImport("red")
eq(#importCalls, 0, "workState working: chooseSaveImport does not import")
eq(ri.saveNotice.red.text, "PRESERVE_ME",
"workState working: chooseSaveImport leaves saveNotice unchanged")
ri:rescanSavesAction("red")
eq(#importCalls, 0, "workState working: rescanSavesAction does not import")
eq(ri.saveNotice.red.text, "PRESERVE_ME",
"workState working: rescanSavesAction leaves saveNotice unchanged")
-- RES-11 / NXSAV-07: NX default SAVE FILES hint mentions per-game inbox
ri = freshImporter()
local defaultHint = ri:_savesDefaultHint("red")
check(defaultHint:find("imports/saves/red/", 1, true),
"RES-11: NX default hint mentions imports/saves/red/")
check(defaultHint:find("DBI MTP", 1, true),
"RES-11: NX default hint mentions DBI MTP")
check(not defaultHint:find("system file picker", 1, true),
"RES-11: NX default hint is not desktop picker wording")
-- Desktop keeps picker-oriented default hint (non-NX)
local desk = setmetatable({
isNX = false, android = false,
_savesDefaultHint = RomImporter._savesDefaultHint,
}, RomImporter)
check(desk:_savesDefaultHint():find("new slot", 1, true),
"desktop default save hint stays picker/drop-oriented")
-- RES-09 / NXSAV-08/09: NX exportSave success notice + no openURL / no dir
local exportCalls = {}
local openURLCalls = 0
love.system.openURL = function()
openURLCalls = openURLCalls + 1
error("openURL must not run on NX exportSave")
end
package.loaded["src.import.SaveFileIO"] = {
importToSlot = function(source, version)
importCalls[#importCalls + 1] = { source = source, version = version }
local b = importBehavior[source]
if not b then return false, "unexpected source: " .. tostring(source) end
if b.ok then return true, b.id or "slot-1" end
return false, b.err or "bad sav"
end,
exportActiveSlot = function(version)
exportCalls[#exportCalls + 1] = version
return true, "sdmc:/switch/gen1recomp/pokemon-love2d/exports/red/gen1recomp-red-slot-1.sav"
end,
}
ri = freshImporter()
exportCalls = {}
openURLCalls = 0
ri:exportSave("red")
eq(#exportCalls, 1, "NXSAV-08: exportSave calls exportActiveSlot")
eq(exportCalls[1], "red", "exportSave passes panel version")
check(ri.saveNotice.red and ri.saveNotice.red.ok, "NXSAV-09: export success sets ok notice")
check(ri.saveNotice.red.text:find("exports", 1, true),
"RES-09: export notice mentions exports path")
check(ri.saveNotice.red.text:find("DBI MTP", 1, true),
"RES-09: export notice mentions MTP hint")
check(ri.saveNotice.red.dir == nil,
"RES-09: NX export does not set open-folder dir")
eq(openURLCalls, 0, "RES-09: NX exportSave does not call openURL")
-- Export failure still sets notice (not silent)
package.loaded["src.import.SaveFileIO"] = {
importToSlot = function() return false, "unused" end,
exportActiveSlot = function() return false, "No save in the active slot." end,
}
ri = freshImporter()
ri:exportSave("red")
check(ri.saveNotice.red and not ri.saveNotice.red.ok,
"export failure sets clear error notice")
check(ri.saveNotice.red.text:find("No save", 1, true),
"export failure notice includes reason")
-- Edge: workState == "working" → exportSave no-op without clearing notice
package.loaded["src.import.SaveFileIO"] = {
importToSlot = function() return false, "unused" end,
exportActiveSlot = function(version)
exportCalls[#exportCalls + 1] = version
return true, "exports/should-not-export.sav"
end,
}
ri = freshImporter()
exportCalls = {}
ri.saveNotice.red = { ok = true, text = "PRESERVE_EXPORT" }
ri.workState = "working"
ri:exportSave("red")
eq(#exportCalls, 0, "workState working: exportSave does not call exportActiveSlot")
eq(ri.saveNotice.red.text, "PRESERVE_EXPORT",
"workState working: exportSave leaves saveNotice unchanged")
-- Cleanup + restore stubs
clearSavesInbox()
love.filesystem.remove("imports/other.sav")
package.loaded["src.import.SaveFileIO"] = nil
package.loaded["src.core.HostShell"] = nil
love.data.hash = saved.hash
love.data.encode = saved.encode
love.system.getOS = saved.getOS
love.system.openURL = nil
love.filesystem.getSaveDirectory = saved.getSaveDirectory
love.filesystem.createDirectory = saved.createDirectory
love.filesystem.remove = saved.remove
package.loaded["src.core.Platform"] = nil
package.loaded["src.import.RomImporter"] = nil
S.finish()
+5 -5
View File
@@ -148,11 +148,11 @@ do
local ok, path = SaveFileIO.exportActiveSlot("red")
eq(ok, true, "exportActiveSlot succeeds for an active slot with a save")
eq(path, "/fake/save/exports/gen1recomp-red-slot1.sav",
"the export path is absolute and names the version + slot")
eq(path, "/fake/save/exports/red/gen1recomp-red-slot1.sav",
"the export path is absolute under exports/<version>/")
local outBytes = files["exports/gen1recomp-red-slot1.sav"]
check(outBytes ~= nil, "the export file lands in the save-dir exports/ folder")
local outBytes = files["exports/red/gen1recomp-red-slot1.sav"]
check(outBytes ~= nil, "the export file lands in the per-game exports/ folder")
eq(outBytes and #outBytes, GenSave.SAVE_SIZE, "the export is exactly 32768 bytes")
check(outBytes and mainChecksumValid(outBytes),
"the export carries a valid main-data checksum")
@@ -281,7 +281,7 @@ do
if not SaveFileIO.importToSlot(GenSave.encode(seed, data, nil), "red") then return nil end
if not SaveData.load("red") then return nil end
if not SaveFileIO.exportActiveSlot("red") then return nil end
return files["exports/gen1recomp-red-slot1.sav"]
return files["exports/red/gen1recomp-red-slot1.sav"]
end
local function assertLoadable(label, mapId, x, y)
+108
View File
@@ -0,0 +1,108 @@
-- Opt-in Switch input diagnostics (SWNX-13/28): marker file, ring buffer, flush cap.
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 SwitchDiagnostics = require("src.debug.SwitchDiagnostics")
local function reset()
SwitchDiagnostics._resetForTests()
love.filesystem.remove("switch-debug.txt")
love.filesystem.remove("switch.log")
end
reset()
check(not SwitchDiagnostics.isEnabled(), "disabled without marker file")
love.filesystem.write("switch-debug.txt", "")
reset()
love.filesystem.write("switch-debug.txt", "")
check(SwitchDiagnostics.isEnabled(), "enabled when marker exists")
SwitchDiagnostics.onEvent("probe", { kind = "gamepadpressed", button = "a" })
SwitchDiagnostics.maybeFlush(true, 0)
local log = love.filesystem.read("switch.log") or ""
check(log:find("gamepadpressed", 1, true) ~= nil, "flush writes buffered events")
check(log:find("gitCommit=", 1, true) ~= nil, "identity includes gitCommit field")
check(log:find("loveNxTag=", 1, true) ~= nil, "identity includes loveNxTag field")
-- ROM-like byte sequences must never appear in diagnostics output.
local romSnippet = string.char(0xEA, 0x9B, 0xCA, 0xE6)
SwitchDiagnostics.onEvent("probe", { sample = romSnippet, note = "redacted" })
SwitchDiagnostics.maybeFlush(true, 1)
log = love.filesystem.read("switch.log") or ""
check(not log:find(romSnippet, 1, true),
"ROM bytes are stripped from diagnostic payloads")
-- Flush rate capped at 1 Hz unless forced.
reset()
love.filesystem.write("switch-debug.txt", "")
SwitchDiagnostics.onEvent("tick", { n = 1 })
SwitchDiagnostics.maybeFlush(true, 0.0)
SwitchDiagnostics.onEvent("tick", { n = 2 })
SwitchDiagnostics.maybeFlush(false, 0.5)
local logMid = love.filesystem.read("switch.log") or ""
SwitchDiagnostics.maybeFlush(false, 1.0)
local logLate = love.filesystem.read("switch.log") or ""
check(not logMid:find("n=2", 1, true), "flush waits until 1s elapsed")
check(logLate:find("n=2", 1, true) ~= nil, "flush includes events after 1s")
-- Lua error log: redacted, no ROM bytes.
local romErr = string.char(0xEA, 0x9B, 0xCA, 0xE6)
local hint = SwitchDiagnostics.logLuaError("probe failure")
check(type(hint) == "string" and hint:find("lua-error.log", 1, true) ~= nil,
"error handler hint mentions lua-error.log")
SwitchDiagnostics.logLuaError(romErr)
local errLog = love.filesystem.read("lua-error.log") or ""
check(errLog:find("probe failure", 1, true) ~= nil, "lua-error.log records message")
check(errLog:find("<redacted>", 1, true) ~= nil, "lua-error.log strips ROM bytes")
check(not errLog:find(romErr, 1, true), "lua-error.log omits raw ROM bytes")
-- Stack-trace style messages (newlines) must remain readable — not wholesale
-- "<redacted>" (fused Play triage regression).
SwitchDiagnostics.logLuaError("missing module 'data/generated/maps.lua'.\nImport again.\n(detail)")
errLog = love.filesystem.read("lua-error.log") or ""
check(errLog:find("missing module", 1, true) ~= nil,
"lua-error.log keeps printable multiline error text")
check(errLog:find("Import again", 1, true) ~= nil,
"lua-error.log preserves lines after newline")
-- NX asset probe: always writes nx-asset-probe.log on Play (Switch only).
local Platform = require("src.core.Platform")
local GameVersion = require("src.core.GameVersion")
local savedSystem = love.system
love.system = { getOS = function() return "NX" end }
Platform._resetForTests()
GameVersion.set("yellow")
love.filesystem.write("yellow/assets/generated/fonts/font.png", "font-bytes")
love.filesystem.write("yellow/assets/generated/tilesets/reds_house.png", "house-bytes")
love.filesystem.write("yellow/assets/generated/sprites/red.png", "red-bytes")
SwitchDiagnostics.probeAssets("yellow")
local probe = love.filesystem.read("nx-asset-probe.log") or ""
check(probe:find("probe=nx-asset", 1, true) ~= nil, "probe log writes header")
check(probe:find("cachePrefix=yellow/", 1, true) ~= nil, "probe records yellow prefix")
check(probe:find("resolve=yellow/assets/generated/fonts/font.png", 1, true) ~= nil
or probe:find("versioned=type=file", 1, true) ~= nil,
"probe records versioned font path visibility")
check(not probe:find(string.char(0xEA, 0x9B), 1, true),
"probe log contains no ROM-like binary")
love.system = { getOS = function() return "OS X" end }
Platform._resetForTests()
love.filesystem.remove("nx-asset-probe.log")
SwitchDiagnostics.probeAssets("yellow")
check(love.filesystem.read("nx-asset-probe.log") == nil,
"probe is a no-op off NX")
love.system = savedSystem
Platform._resetForTests()
GameVersion.set("red")
love.filesystem.remove("nx-asset-probe.log")
love.filesystem.remove("yellow/assets/generated/fonts/font.png")
love.filesystem.remove("yellow/assets/generated/tilesets/reds_house.png")
love.filesystem.remove("yellow/assets/generated/sprites/red.png")
T.finish()
@@ -0,0 +1,85 @@
-- Touch-controls editor pad / Joy-Con cursor (same class as save-editor soft-lock).
-- Opening Touch Controls parked the launcher cursor and dropped all gamepad
-- input; touch still worked. PadCursor + main.lua forwarding restore the
-- virtual pointer.
-- luajit tests/engine/touch_controls_pad_cursor_test.lua
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, eq = T.check, T.eq
local PadCursor = require("src.ui.PadCursor")
local GamepadMap = require("src.core.GamepadMap")
PadCursor.reset()
PadCursor.gamepadaxis(nil, "leftx", 1)
PadCursor.update(0.05)
check(PadCursor.isActive(), "left stick activates pad cursor")
local x0 = select(1, PadCursor.pointer())
PadCursor.update(0.05)
check(select(1, PadCursor.pointer()) > x0, "left stick moves cursor right")
eq(PadCursor.gamepadpressed(nil, "a"), "a", "gamepad a → click")
eq(PadCursor.gamepadpressed(nil, "b"), "b", "gamepad b → close")
eq(PadCursor.gamepadpressed(nil, "leftshoulder"), "tab_prev", "L → size down")
eq(PadCursor.gamepadpressed(nil, "rightshoulder"), "tab_next", "R → size up")
GamepadMap._setForceNXForTests(true)
eq(PadCursor.gamepadpressed(nil, "a"), "b", "NX SDL a (south) → close (GB b)")
eq(PadCursor.gamepadpressed(nil, "b"), "a", "NX SDL b (east) → click (GB a)")
GamepadMap._setForceNXForTests(false)
PadCursor.yieldToPointer()
check(not PadCursor.isActive(), "yieldToPointer drops the virtual cursor")
-- Compat: tools/save-editor/PadInput.lua re-exports the shared module.
package.path = package.path .. ";./tools/save-editor/?.lua"
local PadInput = require("PadInput")
eq(PadInput, PadCursor, "PadInput shim is PadCursor")
local function read(path)
local f = assert(io.open(path, "r"))
local src = f:read("*a")
f:close()
return src
end
local mainSrc = read("main.lua")
check(mainSrc:find("prepareOverlayHandoff", 1, true) ~= nil
and mainSrc:find("openTouchControlsEditor", 1, true) ~= nil,
"main.lua mentions prepareOverlayHandoff + touch editor")
-- prepare must run inside openTouchControlsEditor, not only openEditor
local touchOpen = mainSrc:match("local function openTouchControlsEditor%(%)(.-)\nend")
check(touchOpen ~= nil, "openTouchControlsEditor body found")
check(touchOpen:find("prepareOverlayHandoff", 1, true) ~= nil,
"openTouchControlsEditor prepares overlay handoff like the save editor")
local touchClose = mainSrc:match("function closeTouchControlsEditor%(%)(.-)\nend")
check(touchClose ~= nil, "closeTouchControlsEditor body found")
check(touchClose:find("resumeAfterOverlay", 1, true) ~= nil,
"closeTouchControlsEditor resumes the launcher pad cursor")
check(mainSrc:find("TouchEditor.gamepadpressed", 1, true) ~= nil,
"main.lua forwards gamepadpressed to TouchEditor")
check(mainSrc:find("TouchEditor.gamepadaxis", 1, true) ~= nil,
"main.lua forwards gamepadaxis to TouchEditor")
local edSrc = read("src/ui/TouchControlsEditor.lua")
check(edSrc:find('require("src.ui.PadCursor")', 1, true) ~= nil,
"TouchControlsEditor loads PadCursor")
check(edSrc:find("function Editor.gamepadpressed", 1, true) ~= nil,
"TouchControlsEditor exposes gamepadpressed")
check(edSrc:find("PadCursor.draw()", 1, true) ~= nil,
"TouchControlsEditor draws the pad cursor")
check(edSrc:find("PadCursor.yieldToPointer()", 1, true) ~= nil,
"touch/mouse yields the pad so taps use event coords")
check(edSrc:find('beginDrag("pad"', 1, true) ~= nil,
"A begins a pad drag at the virtual cursor")
check(edSrc:find('endDrag("pad")', 1, true) ~= nil,
"A release ends a pad drag")
local packSrc = read("scripts/pack_love.sh")
check(packSrc:find("tools/save-editor/PadInput.lua", 1, true) ~= nil,
"pack still requires PadInput path (compat shim)")
T.finish("touch_controls_pad_cursor")
+7 -3
View File
@@ -89,11 +89,14 @@ local importer = setmetatable({
_padCursor = { x = 0, y = 0 }, _padCursorActive = false,
_padAxis = { leftx = 0, lefty = 0, righty = 0 },
_padDir = {}, _rawHatDirs = {}, _padInited = true,
-- FlexLove view marker: without it the pad A path is a no-op (headless).
_flex = true,
}, RomImporter)
local clicked = false
function importer:mousepressed(_, _, button)
clicked = button == 1
end
local prevView = package.loaded["src.import.LauncherView"]
package.loaded["src.import.LauncherView"] = {
clickAt = function() clicked = true end,
}
importer:joystickaxis(nil, 1, -0.8)
check(importer._padAxis.leftx == -0.8,
"raw joystick left axis reaches the launcher cursor")
@@ -108,6 +111,7 @@ check(clicked, "raw joystick primary button clicks the launcher cursor")
clicked = false
importer:joystickpressed(mapped, 1)
check(not clicked, "mapped pad does not double-click the launcher cursor")
package.loaded["src.import.LauncherView"] = prevView
-- Drivers that only inject pressQueue still get a one-step hold.
Input:reset()
+197
View File
@@ -0,0 +1,197 @@
-- LauncherMods.installZip: PK gate, FileData mount preference, path fallback.
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("launcher mods installZip mount")
local eq = S.eq
local check = S.check
local MOD_ID = "mount_probe"
local ARCHIVE = {
[MOD_ID .. "/manifest.json"] =
('{"id":"%s","name":"Mount Probe","version":"1.0.0","entry":"main.lua"}')
:format(MOD_ID),
[MOD_ID .. "/main.lua"] = "return function() end\n",
}
local files, dirs, arch = {}, {}, {}
local fileDataMounts, pathMounts, stagedTemps = 0, 0, {}
local stagedEver = false
local function resetFs()
for k in pairs(files) do files[k] = nil end
for k in pairs(dirs) do dirs[k] = nil end
for k in pairs(arch) do arch[k] = nil end
fileDataMounts, pathMounts = 0, 0
stagedTemps = {}
stagedEver = false
end
local function dirChild(key, name)
if name == nil or name == "" then return key:match("^[^/]+") end
local prefix = name .. "/"
if key:sub(1, #prefix) ~= prefix then return nil end
return key:sub(#prefix + 1):match("^[^/]+")
end
local function mapInfo(map, name, kind)
if map[name] ~= nil then return { type = kind or "file" } end
for key in pairs(map) do
if dirChild(key, name) then return { type = "directory" } end
end
return nil
end
local vfs = {}
function vfs.write(name, data)
files[name] = data
if name:match("^mod_import_") then
stagedTemps[name] = true
stagedEver = true
end
return true
end
function vfs.read(name)
if arch[name] ~= nil then return arch[name] end
return files[name]
end
function vfs.remove(name)
files[name] = nil
dirs[name] = nil
stagedTemps[name] = nil
return true
end
function vfs.createDirectory(name)
dirs[name] = true
return true
end
function vfs.getInfo(name, kind)
local info = mapInfo(arch, name)
or mapInfo(files, name)
or mapInfo(dirs, name, "directory")
if info and kind and info.type ~= kind then return nil end
return info
end
function vfs.getDirectoryItems(name)
local seen, items = {}, {}
local function add(child)
if child and not seen[child] then
seen[child] = true
items[#items + 1] = child
end
end
for key in pairs(arch) do add(dirChild(key, name)) end
for key in pairs(files) do add(dirChild(key, name)) end
for key in pairs(dirs) do add(dirChild(key, name)) end
table.sort(items)
return items
end
function vfs.mount(archive, point)
if type(archive) == "table" and archive.__filedata then
fileDataMounts = fileDataMounts + 1
else
pathMounts = pathMounts + 1
end
for rel, body in pairs(ARCHIVE) do
arch[point .. "/" .. rel] = body
end
return true
end
function vfs.unmount()
for k in pairs(arch) do arch[k] = nil end
return true
end
function vfs.newFileData(data, name)
return { __filedata = true, data = data, name = name }
end
function vfs.getSaveDirectory()
return "/tmp/pokeport-install-zip-test"
end
function vfs.getSource()
return nil
end
local savedFs = love.filesystem
local savedCacheFs = package.loaded["src.import.CacheFs"]
local savedLauncherMods = package.loaded["src.mods.LauncherMods"]
local savedSaveDataPortable = nil
local SaveData = require("src.core.SaveData")
savedSaveDataPortable = SaveData.portableBaseDir
local function freshMods()
package.loaded["src.import.CacheFs"] = nil
package.loaded["src.mods.LauncherMods"] = nil
SaveData.portableBaseDir = function() return nil end
return require("src.mods.LauncherMods")
end
love.filesystem = vfs
local LauncherMods = freshMods()
-- Reject non-PK / empty / AppleDouble-shaped bytes before mount
resetFs()
files["imports/mods/junk.zip"] = "\0\5\22\7AppleDouble"
local ok, err = LauncherMods.installZip("imports/mods/junk.zip")
check(not ok, "non-PK bytes are rejected")
check(tostring(err):find("not a zip file", 1, true),
"rejection names not a zip file")
eq(fileDataMounts + pathMounts, 0, "invalid zip never mounts")
resetFs()
files["imports/mods/empty.zip"] = ""
ok, err = LauncherMods.installZip("imports/mods/empty.zip")
check(not ok, "empty file is rejected")
check(tostring(err):find("not a zip file", 1, true),
"empty rejection is not a zip file")
-- Prefer FileData / in-memory mount for relative save-dir zips
resetFs()
files["imports/mods/good.zip"] = "PK\3\4relative-inbox"
ok, err = LauncherMods.installZip("imports/mods/good.zip")
check(ok == true, "PK zip installs via relative love.filesystem path ("
.. tostring(err) .. ")")
eq(err, MOD_ID, "install reports manifest id")
eq(fileDataMounts, 1, "relative zip prefers FileData mount")
eq(pathMounts, 0, "relative zip does not fall back to path mount when FileData works")
local staged = 0
for _ in pairs(stagedTemps) do staged = staged + 1 end
eq(staged, 0, "FileData path leaves no staged temp zip")
check(files["mods/" .. MOD_ID .. "/manifest.json"] ~= nil,
"install wrote manifest into mods/")
-- Fallback: no newFileData → stage temp + path mount
resetFs()
vfs.newFileData = nil
package.loaded["src.mods.LauncherMods"] = nil
package.loaded["src.import.CacheFs"] = nil
LauncherMods = freshMods()
files["imports/mods/fallback.zip"] = "PK\3\4fallback"
ok, err = LauncherMods.installZip("imports/mods/fallback.zip")
check(ok == true, "install still works without newFileData ("
.. tostring(err) .. ")")
eq(fileDataMounts, 0, "no FileData mounts when API absent")
eq(pathMounts, 1, "falls back to path mount")
check(stagedEver, "fallback stages a mod_import_*.zip temp")
local leftover = 0
for _ in pairs(stagedTemps) do leftover = leftover + 1 end
eq(leftover, 0, "fallback cleans staged temp after install")
-- Restore
love.filesystem = savedFs
SaveData.portableBaseDir = savedSaveDataPortable
package.loaded["src.import.CacheFs"] = savedCacheFs
package.loaded["src.mods.LauncherMods"] = savedLauncherMods
S.finish()
+172 -38
View File
@@ -11,6 +11,9 @@ Image.__index = Image
function Image:getDimensions() return self.w, self.h end
function Image:getWidth() return self.w end
function Image:getHeight() return self.h end
-- IntroMovie sets the studio logo filter unconditionally on load
function Image:setFilter(min, mag) self.minFilter, self.magFilter = min, mag end
function Image:getFilter() return self.minFilter or "nearest", self.magFilter or "nearest" end
-- read PNG dimensions from the file header (no decoder needed)
local function pngSize(path)
@@ -40,8 +43,15 @@ local gstate = { shader = nil, canvas = nil, blend = "alpha",
local gstack = {}
stub.graphics = {
newImage = function(path)
local w, h = pngSize(path)
newImage = function(pathOrData)
local path = pathOrData
if type(pathOrData) == "table" and pathOrData._fileData then
path = pathOrData.name or "<filedata>"
elseif type(pathOrData) == "table" and pathOrData.path then
path = pathOrData.path
end
local w, h = 8, 8
if type(path) == "string" then w, h = pngSize(path) end
return setmetatable({ w = w, h = h, path = path }, Image)
end,
newQuad = function(x, y, w, h) return { x = x, y = y, w = w, h = h } end,
@@ -118,49 +128,130 @@ stub.math = {
stub.filesystem = {
write = function(name, content) files[name] = content return true end,
read = function(name) return files[name] end,
remove = function(name) files[name] = nil return true end,
-- directories are implied by key prefixes ("mods/x/manifest.json")
getInfo = function(name)
if files[name] then return { type = "file" } end
newFileData = function(contents, name)
return { _fileData = true, contents = contents, name = name or "" }
end,
createDirectory = function() return true end,
-- Record mounts for CacheFs.mountVersion tests (NX Blue/Yellow overlay).
_mounts = {},
mount = function(archive, mountpoint, appendToPath)
stub.filesystem._mounts[#stub.filesystem._mounts + 1] = {
archive = archive, mountpoint = mountpoint or "",
append = appendToPath and true or false,
}
return true
end,
unmount = function(archive)
local mounts = stub.filesystem._mounts
for i = #mounts, 1, -1 do
if mounts[i].archive == archive then
table.remove(mounts, i)
return true
end
end
return false
end,
getSaveDirectory = function() return "/tmp/pokeport-stub-save" end,
isFused = function() return false end,
}
-- Resolve a PhysFS path through recorded mounts (prepend first, newest wins).
local function resolveViaMounts(name)
local mounts = stub.filesystem._mounts
for i = #mounts, 1, -1 do
local m = mounts[i]
if not m.append then
local mp = m.mountpoint or ""
local key
if mp == "" then
key = m.archive .. "/" .. name
elseif name == mp then
key = m.archive
elseif name:sub(1, #mp + 1) == mp .. "/" then
local rel = name:sub(#mp + 2)
key = m.archive .. "/" .. rel
end
if key then
if files[key] then return key, "file" end
local prefix = key .. "/"
for k in pairs(files) do
if k:sub(1, #prefix) == prefix then return key, "directory" end
end
end
end
end
return nil
end
function stub.filesystem.read(name)
if files[name] then return files[name] end
local key = resolveViaMounts(name)
if key and files[key] then return files[key] end
return nil
end
function stub.filesystem.getInfo(name, filter)
if files[name] then
if filter and filter ~= "file" then return nil end
return { type = "file" }
end
local prefix = name .. "/"
for key in pairs(files) do
if key:sub(1, #prefix) == prefix then
if filter and filter ~= "directory" then return nil end
return { type = "directory" }
end
end
local key, kind = resolveViaMounts(name)
if key and kind then
if filter and filter ~= kind then return nil end
return { type = kind }
end
return nil
end
stub.filesystem.load = function(name)
local data = stub.filesystem.read(name)
if not data then return nil, "no file" end
return load(data, name)
end
stub.filesystem.getDirectoryItems = function(name)
local seen, items = {}, {}
name = name or ""
local function addChild(child)
if child and not seen[child] then
seen[child] = true
items[#items + 1] = child
end
end
-- "" / "/" = save-dir root (RomImporter Android ROM scan)
if name == "" or name == "/" then
for key in pairs(files) do
addChild(key:match("^[^/]+"))
end
else
local prefix = name .. "/"
for key in pairs(files) do
if key:sub(1, #prefix) == prefix then return { type = "directory" } end
end
return nil
end,
load = function(name)
if not files[name] then return nil, "no file" end
return load(files[name], name)
end,
getDirectoryItems = function(name)
local seen, items = {}, {}
name = name or ""
-- "" / "/" = save-dir root (RomImporter Android ROM scan)
if name == "" or name == "/" then
for key in pairs(files) do
local child = key:match("^[^/]+")
if child and not seen[child] then
seen[child] = true
items[#items + 1] = child
end
if key:sub(1, #prefix) == prefix then
addChild(key:sub(#prefix + 1):match("^[^/]+"))
end
else
local prefix = name .. "/"
for key in pairs(files) do
if key:sub(1, #prefix) == prefix then
local child = key:sub(#prefix + 1):match("^[^/]+")
if child and not seen[child] then
seen[child] = true
items[#items + 1] = child
end
end
-- Also surface children exposed via mounts.
local key = resolveViaMounts(name)
if key then
local mprefix = key .. "/"
for k in pairs(files) do
if k:sub(1, #mprefix) == mprefix then
addChild(k:sub(#mprefix + 1):match("^[^/]+"))
end
end
end
table.sort(items)
return items
end,
}
end
table.sort(items)
return items
end
-- table-backed SoundData so ChipAudio's offline render seam
-- (_renderMusicForTest) runs headless; modkit bounce writes WAVs from it
@@ -187,6 +278,25 @@ function SoundData:getDuration() return self.samples / self.rate end
stub.sound = {
newSoundData = function(samples, rate, bits, channels)
-- Path form (love.sound.newSoundData(filename)): only succeed when the
-- stub FS has the file, matching real LÖVE. Synthesize a short mono
-- 8-bit buffer so Sound.widenMono can run headless for seeded paths
-- (pika cries); missing files must error so widenMono keeps the
-- original Source (give_item_jingle identity checks, etc.).
if type(samples) == "string" then
if not stub.filesystem.getInfo(samples) then
error("Could not open file " .. samples .. ". Does not exist.")
end
local n = 32
local sd = setmetatable({
samples = n, rate = rate or 22050, bits = bits or 8,
channels = channels or 1, data = {}, path = samples,
}, SoundData)
for i = 0, n - 1 do
sd:setSample(i, (i % 2 == 0) and 0.5 or -0.5)
end
return sd
end
return setmetatable({ samples = samples, rate = rate or 44100,
bits = bits or 16, channels = channels or 1, data = {} }, SoundData)
end,
@@ -203,6 +313,30 @@ stub.mouse = {
stub.timer = { getTime = function() return 0 end }
-- Minimal image module so Assets.imageData can decode FileData fallbacks
-- headless (full pixel stubs live in tests/mod_graphics_tests.lua).
local ImageData = {}
ImageData.__index = ImageData
function ImageData:getWidth() return self.w end
function ImageData:getHeight() return self.h end
function ImageData:getDimensions() return self.w, self.h end
function ImageData:getPixel() return 0, 0, 0, 1 end
function ImageData:setPixel() end
function ImageData:mapPixel() end
function ImageData:encode() return { getString = function() return "" end } end
stub.image = {
newImageData = function(a, b)
if type(a) == "table" and a._fileData then
return setmetatable({ w = 8, h = 8, path = a.name, source = a }, ImageData)
end
if type(a) == "string" then
return setmetatable({ w = 8, h = 8, path = a }, ImageData)
end
return setmetatable({ w = a or 8, h = b or 8 }, ImageData)
end,
}
-- Desktop / headless: full-window safe area (matches LÖVE's fallback).
stub.window = {
getSafeArea = function()
+19
View File
@@ -378,6 +378,25 @@ local nextEngine = Handshake.hello(fakeGame(vanilla, "BLUE"), nil)
nextEngine.engineVersion = "2.0.0"
eq(Handshake.checkCompat(helloA, nextEngine), "refused", "engine major mismatch refuses")
-- same major, different release: the fingerprint can't see engine code,
-- and battle logic changes between releases, so lockstep would desync a
-- few turns in (#758) -- battle is refused up front, trade still works
local skewed = Handshake.hello(fakeGame(vanilla, "BLUE"), nil)
skewed.engineVersion = (tostring(helloA.engineVersion):match("^(%d+)") or "0") .. ".999.0"
local skewVerdict, skewReason = Handshake.checkCompat(helloA, wire(skewed))
eq(skewVerdict, "engine_skew", "same-major release skew is its own verdict")
eq(skewReason, "engine_release_mismatch", "and says why")
check(not Handshake.battleAllowed("engine_skew"), "release skew refuses lockstep")
check(Handshake.tradeAllowed("engine_skew"), "release skew still trades")
check(Handshake.strict("engine_skew"), "release skew negotiates strictly")
local skewLines = Handshake.describe(helloA, wire(skewed), "engine_skew", "battle")
local skewJoined = table.concat(skewLines, " ")
check(skewJoined:find("version", 1, true) ~= nil, "skew notice mentions versions")
check(skewJoined:find("999", 1, true) ~= nil, "skew notice names the peer release")
for _, line in ipairs(skewLines) do
check(#line <= 20, "skew line fits the screen: " .. line)
end
local lines = Handshake.describe(helloA, wire(helloMod), "subset", "battle")
check(#lines > 0, "the incompatibility screen has something to say")
local joined = table.concat(lines, " ")
+91
View File
@@ -0,0 +1,91 @@
-- Parity: the Fly overworld animation (#702).
--
-- Oracle: engine/overworld/player_animations.asm. Departure
-- (_LeaveMapAnim .flyAnimation) flaps the bird in place for 8 x Delay3,
-- plays SFX_FLY, flies FlyAnimationScreenCoords1 up and off to the right
-- (12 pairs, 3 frames each), waits 40 frames, then exits over the
-- top-left along FlyAnimationScreenCoords2 (11 pairs). Arrival
-- (EnterMapAnim .flyAnimation) plays SFX_FLY again and swoops in along
-- FlyAnimationEnterScreenCoords (12 pairs), and only then does
-- LoadPlayerSpriteGraphics bring the player back.
--
-- Self-contained: `luajit tests/parity_fly_anim.lua`; also globbed by
-- tests/run_tests.lua.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end
local S = require("tests.harness").suite("parity fly anim (#702)")
local check, eq = S.check, S.eq
require("src.render.Font").load(Data)
local Game = require("src.core.Game")
local Input = require("src.core.Input")
local StateStack = require("src.core.StateStack")
local Renderer = require("src.render.Renderer")
local SaveData = require("src.core.SaveData")
local OW = require("src.world.OverworldController")
Game.data = Data
Game.input = Input; Input:init()
Game.renderer = Renderer; Renderer:init()
Game.stack = StateStack; StateStack:init()
Game.save = SaveData.newGame()
-- record SFX without touching the audio backend
local plays = {}
local Sound = require("src.core.Sound")
local realPlay = Sound.play
Sound.play = function(_, key) plays[#plays + 1] = key end
local function popAll() while Game.stack:top() do Game.stack:pop() end end
local function frame()
Input.pressed = {}
StateStack:update(1 / 60)
end
local function frames(n) for _ = 1, n do frame() end end
Game.stack:push(OW, "ROUTE_17", 4, 10, "down")
local ow = Game.stack:top()
ow:flyTo("PALLET_TOWN")
check(ow.flyAnim ~= nil, "the bird lead-in starts on FLY")
eq(ow.flyAnim and ow.flyAnim.phase, "flap", "the bird flaps in place first")
eq(ow.player.inputLocked, true, "input is locked for the flight")
eq(#plays, 0, "no SFX during the in-place flap")
frames(23)
eq(ow.flyAnim and ow.flyAnim.phase, "flap", "still flapping 23 frames in")
frame()
eq(ow.flyAnim and ow.flyAnim.phase, "path1",
"the up-right path starts after 8 x Delay3")
eq(plays[#plays], "Fly", "SFX_FLY plays as the bird takes off")
frames(36)
eq(ow.flyAnim and ow.flyAnim.phase, "hold",
"the bird parks off screen after the 12-pair path")
frames(40)
eq(ow.flyAnim and ow.flyAnim.phase, "path2",
"the top-left exit follows the 40-frame beat")
frames(33)
check(ow.flyAnim == nil, "the departure ends after the 11-pair exit")
-- the warp transition runs its fade out/in; the map switches inside it
local guard = 0
while ow.map.id == "ROUTE_17" and guard < 400 do
guard = guard + 1
frame()
end
eq(ow.map.id, "PALLET_TOWN", "the warp lands in Pallet Town")
check(ow.flyArrive ~= nil, "the landing swoop starts on arrival")
eq(plays[#plays], "Fly", "SFX_FLY plays again for the landing")
eq(ow.player.inputLocked, true, "input stays locked for the swoop")
frames(35)
check(ow.flyArrive ~= nil, "the swoop is still flying 35 frames in")
frame()
check(ow.flyArrive == nil, "the swoop ends after the 12-pair path")
eq(ow.player.inputLocked, false, "and hands input back")
Sound.play = realPlay
S.finish()
+4
View File
@@ -3375,6 +3375,10 @@ runSuites({ "tests/rom_importer_android_mod_pick_test.lua" })
-- ---------------------------------------------- import with no picker (#482)
runSuites({ "tests/rom_importer_no_picker_test.lua" })
runSuites({ "tests/rom_importer_double_pick_test.lua" })
-- ---------------------------------------------- Switch platform capabilities
-- platform_nx_* / rom_importer_nx_* live in tests/engine/ (ROM-free T2) so
-- CI's headless lane runs them without data/generated/.
runSuites({ "tests/launcher_mods_install_zip_test.lua" })
-- ---------------------------------------------- parity workstream tests
-- Each tests/parity_*.lua is a self-contained file (own bootstrap + check,
-- error()s if any assertion fails). Globbed, so dropping a new parity
+106
View File
@@ -0,0 +1,106 @@
-- Save editor pad / Joy-Con input (NX soft-lock fix).
-- PadInput mirrors the launcher cursor; main.lua must forward editorMode
-- gamepad/touch instead of discarding them.
-- luajit tests/save_editor_pad_input_test.lua
package.path = package.path .. ";./?.lua;./?/init.lua;./tools/save-editor/?.lua"
.. ";./tools/save-editor/panels/?.lua"
local love_stub = require("tests.love_stub")
love = love or love_stub
local T = require("tests.harness")
local check, eq = T.check, T.eq
local GamepadMap = require("src.core.GamepadMap")
local PadInput = require("PadInput")
PadInput.reset()
-- Stick deflection activates the cursor and moves it.
PadInput.gamepadaxis(nil, "leftx", 1)
PadInput.update(0.05)
check(PadInput.isActive(), "left stick activates pad cursor")
local x0 = select(1, PadInput.pointer())
PadInput.update(0.05)
local x1 = select(1, PadInput.pointer())
check(x1 > x0, "left stick moves cursor right")
-- A / B via GamepadMap (desktop labels = GB a/b).
eq(PadInput.gamepadpressed(nil, "a"), "a", "gamepad a → click action")
eq(PadInput.gamepadpressed(nil, "b"), "b", "gamepad b → close action")
eq(PadInput.gamepadpressed(nil, "leftshoulder"), "tab_prev", "L cycles tab prev")
eq(PadInput.gamepadpressed(nil, "rightshoulder"), "tab_next", "R cycles tab next")
-- NX face swap: SDL south "a" is Nintendo B → GB b (close).
GamepadMap._setForceNXForTests(true)
eq(PadInput.gamepadpressed(nil, "a"), "b", "NX SDL a (south) → close (GB b)")
eq(PadInput.gamepadpressed(nil, "b"), "a", "NX SDL b (east) → click (GB a)")
GamepadMap._setForceNXForTests(false)
-- Dual-path gate: gamepad sticks must not also fire from raw joystick (#620).
local gamepadJoy = {
isGamepad = function() return true end,
}
eq(PadInput.joystickpressed(gamepadJoy, 1), nil,
"ignoreRaw skips second fire on isGamepad sticks")
-- Raw (non-gamepad) stick still maps through GamepadMap.
local rawJoy = {
isGamepad = function() return false end,
}
eq(PadInput.joystickpressed(rawJoy, 1), "a",
"raw #1 clicks via shared map")
-- Right stick accumulates wheel notches.
PadInput.reset()
PadInput.gamepadaxis(nil, "righty", -1) -- stick up
PadInput.update(1.0)
local notches = PadInput.takeWheel()
check(notches >= 1, "right stick up yields positive wheel notches")
PadInput.reset()
check(not PadInput.isActive(), "reset clears active cursor")
-- Touch / mouse must yield the pad so a tap is not hit-tested at the Joy-Con
-- pointer (the NX "cursor shows but touch does nothing" bug).
PadInput.gamepadaxis(nil, "leftx", 1)
PadInput.update(0.05)
check(PadInput.isActive(), "stick activates before yield")
PadInput.yieldToPointer()
check(not PadInput.isActive(), "yieldToPointer drops the virtual cursor")
-- Source seams: main.lua must forward editorMode gamepad/touch; App must
-- own the pad handlers, prefer event click coords over the pad pointer, and
-- feed pad coords into Kit only when active and no pending click.
local function read(path)
local f = assert(io.open(path, "r"))
local src = f:read("*a")
f:close()
return src
end
local mainSrc = read("main.lua")
check(mainSrc:find("if editorMode then", 1, true) ~= nil
and mainSrc:find("EditorApp.gamepadpressed", 1, true) ~= nil,
"main.lua forwards gamepadpressed to EditorApp in editorMode")
check(mainSrc:find("EditorApp.mousepressed(x, y, 1)", 1, true) ~= nil,
"main.lua touchpressed clicks the save editor (non-iOS)")
check(mainSrc:find('istouch and love.system.getOS() == "Android"', 1, true) ~= nil,
"main.lua guards Android double-fire for editor mousepressed")
local appSrc = read("tools/save-editor/App.lua")
check(appSrc:find('require("PadInput")', 1, true) ~= nil,
"App.lua loads PadInput")
check(appSrc:find("function App.gamepadpressed", 1, true) ~= nil,
"App.lua exposes gamepadpressed")
check(appSrc:find("PadInput.reset()", 1, true) ~= nil,
"App.unload resets PadInput")
check(appSrc:find("PadInput.pointer()", 1, true) ~= nil,
"App.draw uses pad pointer when active")
check(appSrc:find("PadInput.yieldToPointer()", 1, true) ~= nil,
"App.mousepressed yields the pad so touch uses event coords")
check(appSrc:find("mouseClicked and clickX", 1, true) ~= nil,
"App.draw prefers click event coords over pad / mouse")
T.finish("save_editor_pad_input")
+243
View File
@@ -0,0 +1,243 @@
-- Content gate for Switch CI iOS-parity (SWCI-01..09).
-- Self-contained: luajit tests/switch_ci_workflows_test.lua
local T = require("tests.harness")
local check = T.check
local function read(path)
local f, err = io.open(path, "r")
if not f then error("cannot read " .. path .. ": " .. tostring(err)) end
local s = f:read("*a")
f:close()
return s
end
local function mustContain(body, needle, label)
check(body:find(needle, 1, true) ~= nil,
label .. " must contain " .. string.format("%q", needle))
end
local function mustNotContain(body, needle, label)
check(body:find(needle, 1, true) == nil,
label .. " must not contain " .. string.format("%q", needle))
end
-- Exact path regex contract (SWCI-01 / 4A + SWFIX-03 test path).
-- Also gates the NX runtime modules and the NX engine suites so an NX
-- runtime regression cannot slip past switch-selftest / switch-build.
local SWITCH_PATH_REGEX =
[[^(scripts/build_switch\.sh$|scripts/switch/|docs/switch-.*\.md$|tests/switch_ci_workflows_test\.lua$|tests/switch_transfer_docs_test\.lua$|\.github/workflows/(ci|release|switch-artifact-comment)\.yml$|src/core/(NxAssetOverlay|Platform|GameVersion)\.lua$|src/import/CacheFs\.lua$|tests/engine/(assets_version_fallback|nx_generated_guard|nx_yellow_boot|switch_diagnostics)_test\.lua$|tests/engine/platform_nx)]]
local ci = read(".github/workflows/ci.yml")
local release = read(".github/workflows/release.yml")
local comment_wf = read(".github/workflows/switch-artifact-comment.yml")
local ios_comment_wf = read(".github/workflows/ios-artifact-comment.yml")
-- --- SWCI-01: path detector ---
mustContain(ci, "switch-changes:", "ci.yml")
mustContain(ci, "detect Switch changes", "ci.yml")
mustContain(ci, SWITCH_PATH_REGEX, "ci.yml path regex")
mustContain(ci, 'echo "changed=true"', "ci.yml BASE_SHA fallback")
mustContain(ci, "0000000000000000000000000000000000000000", "ci.yml all-zero BASE_SHA")
-- SWCI-01 extension: NX runtime modules + NX engine suites must be gated
for _, fragment in ipairs({
"NxAssetOverlay",
"Platform",
"GameVersion",
"CacheFs",
"assets_version_fallback",
"nx_generated_guard",
"nx_yellow_boot",
"switch_diagnostics",
"tests/engine/platform_nx",
}) do
mustContain(ci, fragment, "ci.yml path regex NX fragment")
end
-- --- SWCI-02 / SWCI-03: offline selftest job ---
mustContain(ci, "switch-selftest:", "ci.yml")
mustContain(ci, "needs: switch-changes", "ci.yml")
mustContain(ci, "needs.switch-changes.outputs.changed == 'true'", "ci.yml")
mustContain(ci, "scripts/switch/selftest_build_switch.sh", "ci.yml")
mustContain(ci, "scripts/switch/verify_payload.sh --self-test", "ci.yml")
mustContain(ci, "luajit tests/switch_ci_workflows_test.lua", "ci.yml")
mustContain(ci, "luajit tests/switch_transfer_docs_test.lua", "ci.yml")
-- switch-selftest must be ubuntu-latest (fork-safe); pin via job block scan
do
local start = ci:find("switch-selftest:", 1, true)
check(start ~= nil, "switch-selftest job present")
local rest = ci:sub(start)
local nextJob = rest:find("\n [%w_-]+:", 2)
local block = nextJob and rest:sub(1, nextJob - 1) or rest
mustContain(block, "runs-on: ubuntu-latest", "switch-selftest")
mustContain(block, "selftest_build_switch.sh", "switch-selftest")
mustContain(block, "verify_payload.sh --self-test", "switch-selftest")
mustContain(block, "tests/switch_ci_workflows_test.lua", "switch-selftest")
mustContain(block, "tests/switch_transfer_docs_test.lua", "switch-selftest")
mustContain(block, "luajit tests/engine/assets_version_fallback_test.lua", "switch-selftest")
mustContain(block, "luajit tests/engine/nx_generated_guard_test.lua", "switch-selftest")
mustContain(block, "luajit tests/engine/nx_yellow_boot_test.lua", "switch-selftest")
mustNotContain(block, "continue-on-error:", "switch-selftest")
end
-- --- SWCI-04 / SWCI-05: canonical fused build + artifact ---
mustContain(ci, "switch-build:", "ci.yml")
mustContain(ci, "gen1recomp-switch-nro", "ci.yml")
mustContain(ci, "github.repository == 'bryanthaboi/gen1recomp'", "ci.yml canonical gate")
mustContain(ci, 'runs-on: ["self-hosted", "macOS"]', "ci.yml switch-build runner")
mustContain(ci, "scripts/build_switch.sh --fetch --fused", "ci.yml fused command")
mustContain(ci, "if-no-files-found: error", "ci.yml artifact")
mustContain(ci, "retention-days: 7", "ci.yml artifact retention")
do
local start = ci:find("switch-build:", 1, true)
check(start ~= nil, "switch-build job present")
local rest = ci:sub(start)
local nextJob = rest:find("\n [%w_-]+:", 2)
local block = nextJob and rest:sub(1, nextJob - 1) or rest
mustContain(block, "needs: [switch-changes, switch-selftest]", "switch-build needs")
mustContain(block, "needs.switch-selftest.result == 'success'", "switch-build waits for selftest")
mustContain(block, "always()", "switch-build always() for skipped deps")
mustContain(block, "needs.switch-changes.outputs.changed == 'true'", "switch-build")
mustContain(block, "bryanthaboi/gen1recomp", "switch-build canonical")
mustContain(block, '["self-hosted", "macOS"]', "switch-build runner")
mustContain(block, "gen1recomp-switch-nro", "switch-build artifact name")
mustContain(block, "gen1recomp-${{ env.SWITCH_VER }}-switch.nro", "switch-build explicit fused path")
mustContain(block, "gen1recomp-${{ env.SWITCH_VER }}-switch.nro.sha256", "switch-build sha256 sidecar")
mustContain(block, "if-no-files-found: error", "switch-build")
mustContain(block, "retention-days: 7", "switch-build")
mustNotContain(block, "continue-on-error:", "switch-build")
-- SWFIX-04: same-repo head only (skip fork→canonical PRs on self-hosted)
mustContain(block, "pull_request.head.repo.full_name", "switch-build fork-PR skip")
mustContain(block, "github.event_name != 'pull_request'", "switch-build non-PR allow")
check(block:find("bryanthaboi/gen1recomp", 1, true) ~= nil
and block:find("changed == 'true'", 1, true) ~= nil,
"switch-build requires changed=true AND canonical repository")
end
-- SWFIX-04 / M7: iOS build must NOT gain the Switch fork-PR head.repo guard
do
local start = ci:find("ios-build:", 1, true)
check(start ~= nil, "ios-build job present")
local rest = ci:sub(start)
local nextJob = rest:find("\n [%w_-]+:", 2)
local block = nextJob and rest:sub(1, nextJob - 1) or rest
mustNotContain(block, "pull_request.head.repo.full_name", "ios-build")
end
-- --- SWCI-06 / SWCI-07 / SWFIX-01: PR artifact comment (no delete-all clobber) ---
mustContain(comment_wf, "workflows: [ci]", "switch-artifact-comment")
mustContain(comment_wf, "gen1recomp-switch-nro", "switch-artifact-comment")
mustContain(comment_wf, "comment-tag: switch-build-result", "switch-artifact-comment")
mustContain(comment_wf, "pull_request", "switch-artifact-comment")
mustContain(comment_wf, "conclusion == 'success'", "switch-artifact-comment")
mustContain(comment_wf, 'exit 0', "switch-artifact-comment no-op")
mustContain(comment_wf, "**Commit**:", "switch-artifact-comment")
mustContain(comment_wf, "**Build Time**:", "switch-artifact-comment")
mustContain(comment_wf, "View workflow run", "switch-artifact-comment")
mustContain(comment_wf, "thollander/actions-comment-pull-request@v3", "switch-artifact-comment")
mustNotContain(comment_wf, "delete-comment", "switch-artifact-comment")
mustNotContain(comment_wf, "izhangzhihao/delete-comment", "switch-artifact-comment")
mustContain(ios_comment_wf, "comment-tag: ios-build-result", "ios-artifact-comment")
mustContain(ios_comment_wf, "thollander/actions-comment-pull-request@v3", "ios-artifact-comment")
mustNotContain(ios_comment_wf, "delete-comment", "ios-artifact-comment")
mustNotContain(ios_comment_wf, "izhangzhihao/delete-comment", "ios-artifact-comment")
-- Distinct tags so both commenters can coexist on the same PR
check(comment_wf:find("comment-tag: switch-build-result", 1, true)
and ios_comment_wf:find("comment-tag: ios-build-result", 1, true)
and comment_wf:find("comment-tag: ios-build-result", 1, true) == nil,
"iOS and Switch comment-tags must be distinct and present")
-- --- 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, "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, "skip Switch fused", "switch-build.md")
mustContain(development, "Switch CI", "switch-development.md")
mustContain(development, "selftest_build_switch.sh", "switch-development.md")
mustContain(readme, "CI vs release", "README.md")
mustContain(readme, "switch-build.md", "README.md")
-- --- SWFIX-03: headless suite also runs the content gates ---
local test_sh = read("scripts/test.sh")
mustContain(test_sh, "tests/switch_ci_workflows_test.lua", "scripts/test.sh")
mustContain(test_sh, "T0 switch CI workflow content gate", "scripts/test.sh")
mustContain(test_sh, "tests/switch_transfer_docs_test.lua", "scripts/test.sh")
mustContain(test_sh, "T0 switch transfer docs gate", "scripts/test.sh")
-- NX suites also run in the unified entry point (not only switch-selftest)
mustContain(test_sh, "tests/engine/assets_version_fallback_test.lua", "scripts/test.sh")
mustContain(test_sh, "tests/engine/nx_generated_guard_test.lua", "scripts/test.sh")
mustContain(test_sh, "tests/engine/nx_yellow_boot_test.lua", "scripts/test.sh")
mustContain(build_doc, "tests/switch_ci_workflows_test.lua", "switch-build.md path list")
mustContain(build_doc, "tests/switch_transfer_docs_test.lua", "switch-build.md path list")
-- docs parity: switch-build.md must enumerate the NX-gated paths too
for _, path in ipairs({
"src/core/NxAssetOverlay.lua",
"src/core/Platform.lua",
"src/core/GameVersion.lua",
"src/import/CacheFs.lua",
"tests/engine/assets_version_fallback_test.lua",
"tests/engine/nx_generated_guard_test.lua",
"tests/engine/nx_yellow_boot_test.lua",
"tests/engine/switch_diagnostics_test.lua",
"tests/engine/platform_nx_*",
}) do
mustContain(build_doc, path, "switch-build.md path list")
end
-- --- SWCI-08: release Switch hard-fail (no continue-on-error on build/stage) ---
do
local start = release:find("- name: Build Switch", 1, true)
check(start ~= nil, "release Build Switch step present")
local rest = release:sub(start)
local nextStep = rest:find("\n - name:", 2)
local block = nextStep and rest:sub(1, nextStep - 1) or rest
mustContain(block, "scripts/build_switch.sh --fetch --fused", "release Build Switch")
-- YAML key must be absent (comment prose may discuss soft-fail policy)
mustNotContain(block, "continue-on-error:", "release Build Switch")
mustContain(block, "path-gated", "release Build Switch comment")
mustContain(block, "Hard-fail", "release Build Switch comment")
end
-- Release publishes SD-ready zip only (no bare .nro / .nro.sha256 assets)
do
local start = release:find("- name: Stage release assets", 1, true)
check(start ~= nil, "release Stage release assets present")
local rest = release:sub(start)
local nextStep = rest:find("\n - name:", 2)
local block = nextStep and rest:sub(1, nextStep - 1) or rest
mustContain(block, "gen1recomp-${v}-switch.zip", "release Stage Switch zip")
mustContain(block, "pack_sd_zip.sh", "release Stage cites pack_sd_zip")
mustNotContain(block, "gen1recomp-${v}-switch.nro", "release Stage no bare NRO")
mustNotContain(block, "switch.nro.sha256", "release Stage no NRO sha256 sidecar")
end
do
local start = release:find("- name: Publish GitHub Release", 1, true)
check(start ~= nil, "release Publish GitHub Release present")
local rest = release:sub(start)
local nextStep = rest:find("\n - name:", 2)
local block = nextStep and rest:sub(1, nextStep - 1) or rest
mustContain(block, "gen1recomp-${v}-switch.zip", "release Publish Switch zip")
mustNotContain(block, "gen1recomp-${v}-switch.nro", "release Publish no bare NRO")
mustNotContain(block, "switch.nro.sha256", "release Publish no NRO sha256 sidecar")
end
T.finish("switch_ci_workflows_test")
+99
View File
@@ -0,0 +1,99 @@
-- Content gate for Switch transfer runbooks (XFER-01..08).
-- Self-contained: luajit tests/switch_transfer_docs_test.lua
local T = require("tests.harness")
local check = T.check
local function read(path)
local f, err = io.open(path, "r")
if not f then error("cannot read " .. path .. ": " .. tostring(err)) end
local s = f:read("*a")
f:close()
return s
end
local function mustContain(body, needle, label)
check(body:find(needle, 1, true) ~= nil,
label .. " must contain " .. string.format("%q", needle))
end
local function mustNotContain(body, needle, label)
check(body:find(needle, 1, true) == nil,
label .. " must not contain " .. string.format("%q", needle))
end
local transfer = read("docs/switch-transfer.md")
mustContain(transfer, "MTP", "transfer")
mustContain(transfer, "Hekate UMS", "transfer")
mustContain(transfer, "FTP", "transfer")
mustContain(transfer, "sdmc:/switch/gen1recomp/", "transfer")
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, "Linux", "transfer")
mustContain(transfer, "Windows", "transfer")
mustContain(transfer, "macOS", "transfer")
mustContain(transfer, "title override", "transfer")
mustContain(transfer, "Applet Mode", "transfer")
mustContain(transfer, "Exit MTP", "transfer")
mustContain(transfer, "nxlink", "transfer")
mustContain(transfer, "deferred", "transfer")
mustContain(transfer, "gvfs-mtp", "transfer")
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, "OpenMTP", "transfer")
mustContain(transfer, "only one", "transfer")
mustContain(transfer, "USB-C", "transfer")
-- Per-OS SD/FTP fallback when MTP is flaky (XFER-05 AC)
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")
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, "COLORS", "install")
mustContain(install, "TILT", "install")
mustContain(install, "GBC FX", "install")
mustContain(install, "PERFORMANCE", "install")
mustContain(install, "Stock engine effect", "install")
mustNotContain(install, "VoxelMod", "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")
T.finish("switch_transfer_docs_test")

Some files were not shown because too many files have changed in this diff Show More