diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 2b85e32e..c9d7e950 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -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
diff --git a/.github/workflows/ios-artifact-comment.yml b/.github/workflows/ios-artifact-comment.yml
index 6c8763fd..b0bd58ce 100644
--- a/.github/workflows/ios-artifact-comment.yml
+++ b/.github/workflows/ios-artifact-comment.yml
@@ -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:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 87284540..8568f37f 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -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"
diff --git a/.github/workflows/switch-artifact-comment.yml b/.github/workflows/switch-artifact-comment.yml
new file mode 100644
index 00000000..286f2f62
--- /dev/null
+++ b/.github/workflows/switch-artifact-comment.yml
@@ -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 }}`
+
+ This comment was automatically generated. [View workflow run](https://github.com/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }})
+ pr-number: ${{ steps.artifact.outputs.pr_number }}
+ comment-tag: switch-build-result
+ github-token: ${{ github.token }}
diff --git a/.gitignore b/.gitignore
index 59022aa0..bc7f4683 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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/)
diff --git a/README.md b/README.md
index 9f2e1b6f..c1d4d08e 100644
--- a/README.md
+++ b/README.md
@@ -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).
+

diff --git a/assets/switch/icon.jpg b/assets/switch/icon.jpg
new file mode 100644
index 00000000..d735bdfe
Binary files /dev/null and b/assets/switch/icon.jpg differ
diff --git a/conf.lua b/conf.lua
index 08bd3583..2894ffe5 100644
--- a/conf.lua
+++ b/conf.lua
@@ -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
diff --git a/docs/launcher.md b/docs/launcher.md
index 8bf8eb3f..dde41189 100644
--- a/docs/launcher.md
+++ b/docs/launcher.md
@@ -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//` via MTP / SD / FTP
+ (one folder per game), then press **Import save** on that game’s 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//.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 game’s 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--.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//gen1recomp--.sav` in the save
+ directory (`exports/` and `exports//` 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//` 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
diff --git a/docs/new-features.md b/docs/new-features.md
index 532cbf3a..c3e6d30f 100644
--- a/docs/new-features.md
+++ b/docs/new-features.md
@@ -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
diff --git a/docs/switch-build.md b/docs/switch-build.md
new file mode 100644
index 00000000..74b33567
--- /dev/null
+++ b/docs/switch-build.md
@@ -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--switch.nro` (game in romfs) via `nacptool` + `elf2nro`, then packs `dist/switch/gen1recomp--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--switch.nro.sha256` and
+`gen1recomp--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 ""
+```
+
+A Switch packaging failure fails the entire release job. The release asset is
+`gen1recomp--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).
diff --git a/docs/switch-development.md b/docs/switch-development.md
new file mode 100644
index 00000000..8e00e072
--- /dev/null
+++ b/docs/switch-development.md
@@ -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 Dusklight’s **single self-contained `.nro`**; contributor transfer stays multi-host (not Mac-only).
+
+## Known limitations (read before reviewing)
+
+1. **Transfer is manual and multi-method.** Runtime only needs files under the LÖVE save directory / NRO install folder. Use MTP, direct SD, or FTP per [switch-transfer.md](switch-transfer.md). macOS + OpenMTP is a documented example for OLED evidence — not “Switch requires a Mac.”
+2. **Deploy is manual.** There is no automated push to the console and no `nxlink` path yet. Operators build locally, transfer files, then title-override launch.
+3. **Hardware coverage.** Author P0/P1 pass rows were recorded on one Switch OLED; Switch V1 boot was confirmed independently. Treat Lite, docked soak, and other hosts as unknown until someone re-runs the checklist.
+4. **No ROM/save/mod zip bytes in git.** Legal dumps and third-party mods stay on the console (or local untracked folders).
+5. **AppleDouble sidecars** (`._*`) from some MTP clients can break zip/ROM/`.sav` scans — the launcher skips hidden `.*` names (including `._*.sav`); still prefer clean copies.
+
+## How we tested
+
+| Layer | What | Where |
+| ----- | ---- | ----- |
+| Unit / headless | Platform NX flags, RomImporter inbox, dual-path input, mod zip inbox, save `.sav` inbox, display chords, payload/self-tests | `tests/*`, `scripts/test.sh` |
+| Switch CI / packaging | Path-gated offline selftest (`selftest_build_switch.sh`, `verify_payload.sh --self-test`, `switch_ci_workflows_test.lua`); canonical fused PR artifact | `.github/workflows/ci.yml`, [switch-build.md](switch-build.md) § CI and release |
+| Probe on hardware | `getOS()==NX`, 1280×720, save path, Joy-Con events | `tools/switch-probe` → OLED |
+| Integration on hardware | MTP inbox ROM import, Play Red/Blue, naming A/B, quit/reopen save, suspend×10, reboot, fused NRO alone + NRO-only update | `docs/switch-hardware-evidence.md` |
+| Community hardware | Switch V1 / Erista boot with prebuilt NRO | [booshankles](https://github.com/booshankles) — see evidence log |
+| Known gaps | Docked soak, ≥30 min long-play, Lite, automated/`nxlink` deploy | Matrix deferred / absent rows |
+
+Operator evidence must stay in `docs/switch-hardware-evidence.md`. **Do not invent passes** for hardware not run.
+
+## love-nx 11.5-nx1 (pinned)
+
+**Tag:** [11.5-nx1](https://github.com/retronx-team/love-nx/releases/tag/11.5-nx1)
+
+**Local layout (not committed):**
+
+```text
+.bazinga/love-nx/11.5-nx1/
+├── love.nro # homebrew launcher binary (loose mode: copied to gen1recomp.nro)
+└── love.elf # required for fused NRO builds (devkitPro nacptool/elf2nro)
+```
+
+**Manifest:** `scripts/switch/love-nx-11.5-nx1.sha256` lists expected artifact names and SHA-256 checksums. Checksums are filled when binaries are fetched (`TBD_*` placeholders until then).
+
+### Fetch instructions
+
+Preferred (automated checksum verify):
+
+```bash
+scripts/build_switch.sh --fetch
+```
+
+That downloads pinned `love.nro` + `love.elf` into `.bazinga/love-nx/11.5-nx1/`
+and checks them against `scripts/switch/love-nx-11.5-nx1.sha256`. See
+[switch-build.md](switch-build.md) for the full mode glossary.
+
+Manual fallback:
+
+1. Open the [11.5-nx1 release](https://github.com/retronx-team/love-nx/releases/tag/11.5-nx1) and download `love.nro` and `love.elf`.
+2. Create the directory: `mkdir -p .bazinga/love-nx/11.5-nx1`
+3. Move both files into that directory.
+4. Confirm checksums match the manifest:
+
+ ```bash
+ shasum -a 256 .bazinga/love-nx/11.5-nx1/love.nro \
+ .bazinga/love-nx/11.5-nx1/love.elf
+ ```
+
+**Never commit** love-nx binaries, ROM dumps, or generated cache into git. The repo `.gitignore` excludes `.bazinga/` (vendor cache) and `/dist/` (build output).
+
+## Loose-mode dist layout
+
+Development builds place `gen1recomp.nro` and `game.love` side by side:
+
+```text
+dist/switch/loose/
+├── gen1recomp.nro
+└── game.love
+```
+
+Assemble with:
+
+```bash
+scripts/build_switch.sh --loose
+```
+
+(See `scripts/switch/assemble_loose.sh` for the underlying copy + checksum step.)
+
+## Transfer & deploy (current contributor loop)
+
+Detail for **macOS / Linux / Windows** and **MTP / SD / FTP** lives in
+[switch-transfer.md](switch-transfer.md). Summary:
+
+| Layer | Intent |
+| ----- | ------ |
+| **Runtime / players** | Extract the release zip at microSD root (`switch/gen1recomp/`) and land ROMs/mods under the save-dir inboxes. The game does not hard-depend on OpenMTP or macOS. |
+| **Contributor loop** | Manual copy via MTP (DBI responder), direct SD (Hekate UMS / reader), or FTP. Fully manual — no CI deploy, no `nxlink` yet. |
+
+The Mac + OpenMTP steps that remain below are the **OLED evidence reproduction** path; prefer the transfer runbook for day-to-day contrib on other hosts.
+
+**Still avoided for routine evidence** (keeps SD handling honest):
+
+- Treating `nxlink` / netloader as the release deploy story (deferred)
+- DBI `MicroSD install` / `NAND install` / NSP-style virtual folders for the `.love`/`.nro` pair
+
+If MTP fails: check cable, USB port, DBI state, and that only one MTP client holds the device — then retry or switch to SD/FTP. Do not silently rewrite evidence using an untested path and claim parity with recorded SHA-256 round-trips.
+
+### Manual deploy checklist (today)
+
+1. Build on the contributor host (`scripts/build_switch.sh --loose` or fused).
+2. Close Gen1Recomp on the Switch; open DBI → `Run MTP responder`.
+3. Copy artifacts with your MTP client into `1: SD Card/switch/gen1recomp/` (and ROMs/mods into the save-dir inboxes when needed).
+4. Wait for the transfer queue; refresh; optionally round-trip SHA-256 on first artifacts of a type.
+5. Exit MTP; launch via **title override** (hold **R** on a title → hbmenu, not Applet Mode).
+
+## OpenMTP + DBI transfer (loose build, Mac evidence example)
+
+Full multi-OS / multi-method steps: [switch-transfer.md](switch-transfer.md).
+The numbered Mac loop below reproduces the OLED evidence path.
+
+### On the Switch
+
+1. Close Gen1Recomp if it is running.
+2. Open **DBI** from hbmenu.
+3. Select **`Run MTP responder`** (DBI documents `X` on the main screen).
+4. Keep DBI on that screen for the entire transfer.
+5. Connect the Switch to the Mac with a USB-C data cable.
+
+### On the Mac
+
+1. Close any other MTP clients.
+2. Open **OpenMTP** and select the DBI device.
+3. In the remote pane, open **`1: SD Card`**.
+4. Navigate to **`switch/`** and create **`gen1recomp/`** if needed.
+5. Enter **`1: SD Card/switch/gen1recomp/`**.
+6. Drag from the local pane:
+
+ ```text
+ dist/switch/loose/gen1recomp.nro
+ dist/switch/loose/game.love
+ ```
+
+7. Wait for the OpenMTP queue to finish completely.
+8. Refresh the remote listing and confirm file sizes match the local files.
+9. On the Switch, exit MTP responder normally in DBI before launching the app.
+
+Expected layout on SD:
+
+```text
+1: SD Card/
+└── switch/
+ └── gen1recomp/
+ ├── gen1recomp.nro
+ └── game.love
+```
+
+## Round-trip SHA-256 verification
+
+For the **first deploy** of each artifact type (loose pair, later fused NRO), verify MTP integrity:
+
+1. **Before send** — record local hashes:
+
+ ```bash
+ shasum -a 256 dist/switch/loose/gen1recomp.nro \
+ dist/switch/loose/game.love
+ ```
+
+2. **After send** — in OpenMTP, copy the same files from `1: SD Card/switch/gen1recomp/` back to an empty local folder, e.g. `dist/switch/mtp-roundtrip/`.
+
+3. **Compare** round-trip hashes:
+
+ ```bash
+ shasum -a 256 dist/switch/mtp-roundtrip/gen1recomp.nro \
+ dist/switch/mtp-roundtrip/game.love
+ ```
+
+4. Local pre-send and round-trip hashes **must match**. Record results in the test report template below.
+
+Repeat whenever a cable glitch or interrupted transfer is suspected.
+
+## Title override launch (full memory)
+
+Applet Mode is **not** the primary validation path. Use **title override** so hbmenu runs with full memory:
+
+1. Confirm the OpenMTP transfer queue finished.
+2. Exit MTP responder in DBI; disconnect USB if desired.
+3. Hold **`R`** while launching any legitimately installed title.
+4. Keep holding until **hbmenu** appears.
+5. Confirm hbmenu does **not** show **Applet Mode**.
+6. Launch **`gen1recomp`** (or the probe NRO during Phase 0).
+
+Album / applet launches are only useful to document applet-specific limitations; P0/P1 gates use title override.
+
+## Phase 0 hardware checklist
+
+Complete **in order** on OLED hardware. Operator fills evidence fields — leave blank until tested.
+
+| Step | Action | Pass | Evidence / notes |
+| ---- | ------ | ---- | ---------------- |
+| P0-0a | Fetch love-nx 11.5-nx1; record manifest SHA-256 | yes | See `scripts/switch/love-nx-11.5-nx1.sha256` |
+| P0-0b | Build `switch-probe.love` per `tools/switch-probe/README.md` | yes | |
+| P0-0c | Assemble loose probe (`game.love` = probe) to `dist/switch/loose/` | yes | |
+| P0-0d | MTP deploy to `1: SD Card/switch/gen1recomp/`; round-trip SHA-256 | yes | nro `8290ac15…5918f5`; love `9f198637…fa2e34f` |
+| P0-0e | Title override → probe boots; `getOS()` shows `NX` | yes | `getOS()`=`NX`, `love._os`=`NX` |
+| P0-0f | Probe lists 1280×720 (or documented dims), save path, gamepad/touch log | yes | save `sdmc:/switch/gen1recomp/switch-probe`; Joy-Con Y→#3 X→#4 |
+| P0-1a | Replace `game.love` with unpatched Gen1Recomp build | yes | feat/switch-nx inbox build |
+| P0-1b | MTP replace `game.love` only; round-trip SHA-256 | yes | |
+| P0-1c | Title override → launcher reaches import screen | yes | |
+| P0-1d | Joy-Con: can navigate launcher (no touch-only) | yes | Full report: `docs/switch-hardware-evidence.md` |
+
+**Operator:** Andrew **Date:** 2026-08-01 **Console:** Switch OLED only
+**Deploy:** manual Mac + OpenMTP + DBI MTP (not automated)
+**love-nx tag:** 11.5-nx1 **gen1recomp commit:** `df7cea4`
+
+## Phase 0 test report template
+
+Copy this block into your hardware notes or PR evidence. **Do not commit ROM files or ROM hashes of private dumps.**
+
+```markdown
+## Switch Phase 0 — hardware report
+
+- Operator:
+- Date:
+- Console model:
+- Atmosphère / HOS version:
+- gen1recomp commit:
+- love-nx tag: 11.5-nx1
+- love.nro SHA-256 (local):
+- game.love SHA-256 (local, pre-send):
+- MTP round-trip SHA-256 (gen1recomp.nro):
+- MTP round-trip SHA-256 (game.love):
+- Title override used: yes / no
+- Applet Mode observed: yes / no (should be no for P0)
+- Probe getOS():
+- Probe dimensions:
+- Probe save directory shown:
+- Gamepad events logged: yes / no
+- Touch events logged: yes / no
+- Unpatched launcher boot: pass / fail
+- Joy-Con launcher navigation: pass / fail / not tested
+- Notes:
+```
+
+## Fast dev loop (loose mode)
+
+While iterating on Lua/assets:
+
+1. Edit on Mac; run `scripts/test.sh --quick`.
+2. Rebuild `.bazinga/work/game.love` (`scripts/build.sh mac --no-notarize` or project pack step).
+3. Close Gen1Recomp on Switch.
+4. DBI → `Run MTP responder`.
+5. OpenMTP → `1: SD Card/switch/gen1recomp/`.
+6. Replace **only** `game.love`; wait for queue + refresh listing.
+7. Exit MTP responder; launch via title override.
+8. Keep `gen1recomp.nro` unchanged until the love-nx pin changes.
+
+```bash
+scripts/test.sh --quick
+scripts/build.sh mac --no-notarize
+scripts/build_switch.sh --loose
+shasum -a 256 .bazinga/work/game.love
+```
+
+## Controller input mapping (NX)
+
+Measured on Switch OLED (`feat/switch-nx`, love-nx `11.5-nx1`, 1280×720). Both `joystickpressed` and `gamepadpressed` fire for Joy-Con; prefer the gamepad path when `joystick:isGamepad()` is true.
+
+| Path | Control | Mapping |
+| ---- | ------- | ------- |
+| `gamepadpressed` | D-pad / left stick | move |
+| `gamepadpressed` | SDL `a` / `b` on **NX** | swapped via `NX_GAMEPAD_BINDINGS`: physical **A** (east) = GB A confirm, physical **B** (south) = GB B cancel |
+| `gamepadpressed` | SDL `a` / `b` on desktop | identity (SDL south = GB A) |
+| `gamepadpressed` | `start` / `back` | Start / Select (+ / −) |
+| `gamepadpressed` | Right / left shoulder (no Select) | Cycle game speed up / down (same as PC hotkey `1` / speed-down path) |
+| `joystickpressed` (raw) | only if **not** `isGamepad()` | face/menu fallback |
+| `joystickpressed` (raw) | `#1` / `#2` on NX | Nintendo B / A → GB B / A |
+| `joystickpressed` (raw) | `#9` / `#10` | Select / Start (− / +) |
+
+**Nintendo UX on Switch:** physical A confirms, physical B cancels (explicit NX remap of SDL face labels).
+
+**Launcher extras** (`RomImporter`): physical **A** clicks at the virtual cursor; **L** / **R** switch tabs; **Start** / **Select** start Play when a ROM is ready (else open Choose ROM). D-pad / left stick move the virtual cursor.
+
+**Dual-path rule:** love-nx emits both `gamepadpressed` and `joystickpressed` for Joy-Con. When `joystick:isGamepad()` is true, Input and RomImporter **ignore raw** face/menu so NamingScreen does not see A+B in one frame. `NamingScreen` also prefers A over B if both edges still fire.
+
+Implementation: `src/core/GamepadMap.lua` (`NX_RAW_*`, `ignoreRawForJoystick`, `displayChordDigit`), `src/core/Game.lua` (shoulder speed), `src/import/RomImporter.lua` (launcher tabs). Launcher and gameplay share the same converter.
+
+## ROM inbox (NX)
+
+Legal dumps land in a shared MTP inbox; **Scan again** is tab-scoped:
+
+| Item | Value |
+| ---- | ----- |
+| Save-relative path | `imports/` (also accepts loose `.gb`/`.gbc` at the save-dir root) |
+| MTP destination | `1: SD Card//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//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//`. 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//imports/saves//` (see launcher notice for the live `getSaveDirectory()` path) |
+| Candidates | non-hidden `*.sav` only in **that game’s** folder |
+| Rescan | SAVE FILES → **Import save** on the matching game tab (scans only that folder) |
+| After success | Retire to `*.sav.imported` + append content hash to `imports/saves//.imported-sha1` |
+| Exports | **Export save** writes under `exports//gen1recomp--.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//`.
+
+**MTP tip:** the same AppleDouble `._*.sav` rule applies — see the mod inbox tip above.
+
+## Joy-Con display chords (Select + face)
+
+PC digit hotkeys for COLORS / TILT / GBC FX / pipelines have Joy-Con equivalents. Hold **Select** (`back` / −) and press a face/shoulder button; the engine runs the same path as `Game:keypressed` for that digit (including `writeOptions` / Pipelines parity).
+
+| Chord (Nintendo UX) | Engine key | Stock engine effect |
+| ------------------- | ---------- | ------------------- |
+| Select + **A** | `2` | COLORS cycle |
+| Select + **B** | `3` | TILT / perspective |
+| Select + **Y** | `5` | GBC FX |
+| Select + **X** | `6` | Mod pipeline hotkey (if registered) |
+| Select + **L** (left shoulder) | `7` | Mod pipeline hotkey (if registered) |
+
+Keys `2` / `3` / `4` / `5` are claimed by the engine before mod pipeline hotkeys run, so a community mod cannot rebind those digits through `Pipelines.hotkey`. Mods that need their own controls should use OPTIONS rows or unclaimed hotkeys.
+
+Without Select held, face buttons keep normal GB A/B gameplay mapping (no accidental color/tilt cycles). The **Options** menu remains available for the same settings — chords are optional shortcuts, not the only path.
+
+On NX, A/B chords resolve through the Nintendo UX face remap so physical **A** → key `2` and physical **B** → key `3` match this table.
+
+**OPTIONS → PERFORMANCE** clamps the port’s own extras (TILT / GBC FX / survey ZOOM) and can cap FPS — useful on weaker handheld budgets. Details: [new-features.md — Performance tier](new-features.md#performance-tier-low-end-devices).
+
+Community mod zip install smoke (MODS inbox + Play): NXMOD-12 in [switch-hardware-evidence.md](switch-hardware-evidence.md).
+
+**Opt-in diagnostics:** create an empty `switch-debug.txt` in the save directory; events flush to `switch.log` at ≤1 Hz with build identity (no ROM/save bytes).
+
+**NX asset probe (always on Play):** every Switch Play writes `nx-asset-probe.log` in the save directory (`pokemon-love2d/`). It lists whether `assets/generated/…` vs `yellow|blue/assets/generated/…` exist, what `Assets.resolve` returns, and whether `newImage` / `newImageData` open — for Yellow/Blue blank-sprite triage. No ROM bytes.
+
+**Blue/Yellow cache overlay (NX):** fused love-nx cannot reliably mount `yellow|blue/assets/generated` onto the un-prefixed path, so `src/core/NxAssetOverlay.lua` wraps EVERY read-side love API that accepts a filesystem path (`filesystem.read/load/lines/newFileData/getInfo`, `graphics.newImage/newFont`, `image.newImageData`, `audio.newSource`, `sound.newSoundData`, `font.newFontData`) once at boot — only when `Platform.isNX()`. Covering the whole read surface (not just the loaders the boot needs today) keeps future states and mods inside the fallback automatically; write-side functions stay stock. Core code must NOT call love loaders on literal `assets/generated` paths (enforced by `tests/engine/nx_generated_guard_test.lua`); the chip-audio worker is a separate Lua state and gets the prefix explicitly via `audio.programPrefix` from `ChipAudio.slimAudio`.
+
+**Hardware re-test:** T16 **pass** @ `2699c9a` (naming A=confirm / B=cancel). T19 **pass** (quit/reopen, suspend×10, reboot) — operator 2026-08-01.
+
+**Suspend/resume audio:** after resume, chip music is stopped to avoid duplicate streams; confirm on hardware during P0-09/10 (T19).
+
+## Lua error log (save directory)
+
+On any uncaught Lua error, Gen1Recomp appends a redacted trace to `lua-error.log` in the LÖVE save directory (`love.filesystem.getSaveDirectory()`). The on-screen error overlay includes a hint pointing at that file. Logs rotate to `lua-error.log.1` when the active file exceeds 32 KiB. ROM/save bytes and non-printable data are stripped — never commit or share logs that might contain private paths without reviewing them first.
+
+## Native crash triage (love-nx / Atmosphère)
+
+love-nx native faults land under the console’s `crash_reports/` folder on SD (reachable via the same manual MTP workflow used for game deploys).
+
+1. **Collect** — DBI → `Run MTP responder`; copy `sdmc:/crash_reports/*.bin` (or the dated subfolder) to the contributor host. Prefer keeping the microSD in-console for routine pulls.
+2. **Redact** — delete any attached screenshots or notes that mention ROM filenames, save paths, or private hashes before sharing logs publicly.
+3. **Symbolize** — use the **pinned** `love.elf` from `.bazinga/love-nx/11.5-nx1/` that matches `build-info.json` / `scripts/switch/love-nx-11.5-nx1.sha256`. Never use a “latest” download.
+
+ ```bash
+ # Example: aarch64-none-elf-addr2line from devkitPro
+ aarch64-none-elf-addr2line -e .bazinga/love-nx/11.5-nx1/love.elf -f -C 0xADDRESS_FROM_CRASH_REPORT
+ ```
+
+4. **Correlate** — compare `gitCommit` / `loveNxTag` from embedded `build-info.json` with the operator’s hardware notes.
+
+If `addr2line` cannot resolve an address, archive the crash `.bin` with the exact `love.elf` SHA-256 used for the build — addresses are only meaningful against that ELF.
+
+## P0 / P1 hardware matrix (ADR §9)
+
+Operator evidence lives in `docs/switch-hardware-evidence.md`. **Do not invent passes** for rows that require hardware not yet run.
+
+| ID | Requirement | Status | Evidence |
+| -- | ----------- | ------ | -------- |
+| P0-0a–f | love-nx pin, probe, MTP, title override | **pass** | Phase 0 checklist above; T4 |
+| P0-1a–d | Unpatched launcher boot + Joy-Con nav | **pass** | T4 / `docs/switch-hardware-evidence.md` |
+| P0-02 | MTP inbox import path shown | **pass** | T12 |
+| P0-03 | Rescan imports ROM | **pass** | T12 |
+| P0-04 | Canonical hash routes version | **pass** | T12 |
+| P0-05 | Source dump retained in inbox | **pass** | T12 |
+| P0-06 | Play reaches game after import | **pass** | T12 |
+| P0-07 | Joy-Con launcher navigation | **pass** | T16 @ `2699c9a` |
+| P0-08 | Joy-Con gameplay (incl. naming A/B) | **pass** | T16 @ `2699c9a` |
+| P0-09 | Save survives quit + reopen | **pass** | T19 |
+| P0-10 | ≥10 suspend cycles, no stuck input/dup audio | **pass** | T19 (operator 2026-08-01) |
+| P0-12 | Fused NRO boots without adjacent `game.love` | **pass** | T24 — `docs/switch-hardware-evidence.md` |
+| P0-14 | Fused NRO MTP round-trip SHA-256 | **pass** | T24 — first artifact `b019e2e8…` @ `6fb5602` (redeploy after Blue fix) |
+| P0-15 | Replace NRO only; saves persist | **pass** | T24 — operator NRO-only update keeps saves |
+| P1-01 | Docked vs handheld spot-check | **deferred** | Code: `NxDisplay` 720p↔1080p; OLED dock soak not recorded yet |
+| P1-02 | Applet Mode documented unsupported | **pass** | Title override required; Album path not validated |
+| P1-03 | Long-play soak (≥30 min) | **deferred** | No soak session recorded |
+| P1-04 | Reboot persistence | **pass** | T19 |
+| P1-05 | Audio resume after suspend | **pass** | T19 (no dup audio reported) |
+| — | Switch V1 / Erista boot | **pass** (boot) | Community — [booshankles](https://github.com/booshankles); see evidence log |
+| — | Switch Lite / docked soak | **untested** / **deferred** | Welcome contributions |
+| — | Automated / `nxlink` deploy | **absent** | Manual MTP / SD / FTP only (AD-009) |
+| — | Multi-OS transfer runbooks | **pass** | [switch-transfer.md](switch-transfer.md) |
+| — | Community mod zip OLED smoke (NXMOD-12) | **pass** | `docs/switch-hardware-evidence.md` |
+
+## Review guidance
+
+Maintainers may review as one PR or split later. Suggested slices (optional):
+
+Each slice should declare: **no ROM/save bytes committed**, **love-nx pin with manifest checksums**, **hardware-tested rows listed with linked evidence**, **Applet Mode unsupported**, **network/updater disabled on NX**, **deploy still manual** (MTP / SD / FTP; no nxlink yet), **OpenMTP is one example not the sole contract**.
+
+### Slice 1 — Platform + import (`platform/import`)
+
+- `src/core/Platform.lua`, `conf.lua` NX branch
+- `src/import/RomImporter.lua` (NX flags, inbox, scan, shell/updater gates)
+- Tests: `tests/engine/platform_nx_*`, `tests/engine/rom_importer_nx_*` (ROM-free T2)
+- Docs: inbox/MTP import sections only
+
+### Slice 2 — Input + lifecycle (`input/lifecycle`)
+
+- `src/core/GamepadMap.lua`, `Input.lua`, `main.lua` focus/joystick hooks
+- `src/debug/SwitchDiagnostics.lua` (opt-in probe + error log)
+- Tests: input/diagnostics suites
+- Docs: controller mapping, suspend/audio notes
+
+### Slice 3 — Build + docs (`build/docs`)
+
+- `scripts/pack_love.sh`, `scripts/build_switch.sh`, `scripts/switch/*`
+- `assets/switch/icon.jpg`, `docs/switch-development.md`, hardware evidence templates
+- Gates: `pack_love.sh --dry-run`, `verify_payload.sh --self-test`, fused build script (devkitPro host)
+
+**Pre-merge checklist:**
+
+- [ ] Manifest `scripts/switch/love-nx-11.5-nx1.sha256` filled; binaries not in git
+- [ ] `verify_payload.sh` rejects generated cache / ROM / `.sav` / `.bak`
+- [ ] P0 matrix rows marked pass only with linked hardware evidence
+- [x] Fused NRO P0-12/14/15 pass with T24 evidence (`docs/switch-hardware-evidence.md`)
+- [ ] Updater / remote mod download hidden on NX (`networkValidated == false`)
+
diff --git a/docs/switch-hardware-evidence.md b/docs/switch-hardware-evidence.md
new file mode 100644
index 00000000..12e93c04
--- /dev/null
+++ b/docs/switch-hardware-evidence.md
@@ -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
+```
diff --git a/docs/switch-install.md b/docs/switch-install.md
new file mode 100644
index 00000000..e173dfc2
--- /dev/null
+++ b/docs/switch-install.md
@@ -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 game’s tab (Red / Blue / Yellow). Rescan
+ matches by ROM SHA-1 for the open tab only — a Red dump never imports
+ from the Yellow tab (and vice versa).
+
+## 5. Import / Export a raw `.sav`
+
+Continue a cart or PC battery save (or pull a slot off-console) via MTP /
+SD / FTP — same transfer methods as ROMs. Paths are **per game**:
+
+| 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 game’s inbox under the save dir
+ ([switch-transfer.md](switch-transfer.md)).
+2. With the game’s ROM already imported, open **that game’s tab** →
+ **SAVE FILES** → **Import save**. Only that folder is scanned.
+3. A successful import retires the file to `*.sav.imported` and records its
+ content hash so pressing **Import save** again does not clone slots.
+ Failed imports leave the original `.sav` in place.
+4. To pull a slot off the console, use **Export save**, then copy the file
+ from that game’s **`exports//`** 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).
diff --git a/docs/switch-transfer.md b/docs/switch-transfer.md
new file mode 100644
index 00000000..f7cd153b
--- /dev/null
+++ b/docs/switch-transfer.md
@@ -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//imports/`) |
+| Mod zip inbox | Same save dir → `imports/mods/` then MODS → **Scan again** |
+| Save `.sav` inbox | Same save dir → `imports/saves/red\|blue\|yellow/` then that game’s SAVE FILES → **Import save** |
+| Save 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//`, or `exports//` 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 distro’s KDE MTP stack).
+2. With DBI MTP active, open **Files** / **Dolphin** / **Thunar** and select
+ the Switch / DBI device → **`1: SD Card`**.
+3. Extract the release zip at SD root (merge), or copy into `switch/gen1recomp/`
+ and the save-dir inboxes as above.
+4. Use **only one** MTP accessor at a time. If `mtp-tools` / `mtpfs` reports
+ “device is busy”, close the file manager’s MTP mount (or the CLI mount)
+ 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
+**DBI’s own FTP**, **sys-ftpd-light**, or **Sphaira** (names are illustrations
+only; pick what your CFW setup already uses).
+
+1. Start the FTP server on the Switch; note IP/port/credentials from that app.
+2. From the host, connect with any FTP client and upload to the same
+ `switch/gen1recomp/`, `imports/`, `imports/mods/`, `imports/saves//`,
+ and `exports//` 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//`). Pull exported `.sav` files from
+ `exports//`. 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)
diff --git a/libs/flexlove/modules/ScrollManager.lua b/libs/flexlove/modules/ScrollManager.lua
index a2a5d698..a23d4d19 100644
--- a/libs/flexlove/modules/ScrollManager.lua
+++ b/libs/flexlove/modules/ScrollManager.lua
@@ -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
diff --git a/main.lua b/main.lua
index d5874db4..e1937fca 100644
--- a/main.lua
+++ b/main.lua
@@ -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
diff --git a/mobile/ios/native/GRPickerBridge.swift b/mobile/ios/native/GRPickerBridge.swift
index 3298705e..9f3e3966 100644
--- a/mobile/ios/native/GRPickerBridge.swift
+++ b/mobile/ios/native/GRPickerBridge.swift
@@ -30,6 +30,45 @@ public final class GRPickerBridge: NSObject {
// (/Library/Application Support/).
private static let loveIdentity = "pokemon-love2d"
+ @objc(httpDownloadWithUrl:destination:userAgent:accept:)
+ public static func httpDownload(url: UnsafePointer?,
+ destination: UnsafePointer?,
+ userAgent: UnsafePointer?,
+ accept: UnsafePointer?) -> 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:)
diff --git a/mobile/ios/patch_love_src.py b/mobile/ios/patch_love_src.py
index cf09295e..ef9d1b39 100644
--- a/mobile/ios/patch_love_src.py
+++ b/mobile/ios/patch_love_src.py
@@ -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():
diff --git a/scripts/build_switch.sh b/scripts/build_switch.sh
new file mode 100755
index 00000000..9bb2544f
--- /dev/null
+++ b/scripts/build_switch.sh
@@ -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--switch.nro via nacptool+elf2nro,
+# then pack dist/switch/gen1recomp--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)"
diff --git a/scripts/pack_love.sh b/scripts/pack_love.sh
new file mode 100755
index 00000000..05898d27
--- /dev/null
+++ b/scripts/pack_love.sh
@@ -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"
diff --git a/scripts/switch/assemble_loose.sh b/scripts/switch/assemble_loose.sh
new file mode 100755
index 00000000..ff8a1b3f
--- /dev/null
+++ b/scripts/switch/assemble_loose.sh
@@ -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"
diff --git a/scripts/switch/build_fused.sh b/scripts/switch/build_fused.sh
new file mode 100755
index 00000000..26024386
--- /dev/null
+++ b/scripts/switch/build_fused.sh
@@ -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")"
diff --git a/scripts/switch/common.sh b/scripts/switch/common.sh
new file mode 100644
index 00000000..2ba59e1e
--- /dev/null
+++ b/scripts/switch/common.sh
@@ -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
+)"
+}
diff --git a/scripts/switch/dkp-docker.image b/scripts/switch/dkp-docker.image
new file mode 100644
index 00000000..699b33ad
--- /dev/null
+++ b/scripts/switch/dkp-docker.image
@@ -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
diff --git a/scripts/switch/fetch_love_nx.sh b/scripts/switch/fetch_love_nx.sh
new file mode 100755
index 00000000..a579092a
--- /dev/null
+++ b/scripts/switch/fetch_love_nx.sh
@@ -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"
diff --git a/scripts/switch/love-nx-11.5-nx1.sha256 b/scripts/switch/love-nx-11.5-nx1.sha256
new file mode 100644
index 00000000..945b8374
--- /dev/null
+++ b/scripts/switch/love-nx-11.5-nx1.sha256
@@ -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/).
+
+love.nro 8290ac153d4c630e48c9b26ef9123f5204ed8ee0cef3042511707b5b645918f5
+love.elf f820d2f73ed72a8a24002ccf540e8a12b16b2b4c86b69e82c0d3629e99547175
diff --git a/scripts/switch/pack_sd_zip.sh b/scripts/switch/pack_sd_zip.sh
new file mode 100755
index 00000000..8f76cac0
--- /dev/null
+++ b/scripts/switch/pack_sd_zip.sh
@@ -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" < "$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"
diff --git a/scripts/switch/selftest_build_switch.sh b/scripts/switch/selftest_build_switch.sh
new file mode 100755
index 00000000..7377c5d9
--- /dev/null
+++ b/scripts/switch/selftest_build_switch.sh
@@ -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
diff --git a/scripts/switch/verify_love_nx.sh b/scripts/switch/verify_love_nx.sh
new file mode 100755
index 00000000..b2e0f028
--- /dev/null
+++ b/scripts/switch/verify_love_nx.sh
@@ -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
diff --git a/scripts/switch/verify_payload.sh b/scripts/switch/verify_payload.sh
new file mode 100755
index 00000000..11e6e524
--- /dev/null
+++ b/scripts/switch/verify_payload.sh
@@ -0,0 +1,107 @@
+#!/usr/bin/env bash
+# Reject private / generated content inside a .love payload.
+#
+# Usage:
+# scripts/switch/verify_payload.sh
+# 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 | --self-test"
+verify_love "$LOVE_FILE"
diff --git a/scripts/switch/write_build_info.sh b/scripts/switch/write_build_info.sh
new file mode 100755
index 00000000..b5323be4
--- /dev/null
+++ b/scripts/switch/write_build_info.sh
@@ -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" < 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
diff --git a/src/core/HostShell.lua b/src/core/HostShell.lua
index 32850e64..96dbd065 100644
--- a/src/core/HostShell.lua
+++ b/src/core/HostShell.lua
@@ -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.
diff --git a/src/core/Input.lua b/src/core/Input.lua
index aca80914..b450994d 100644
--- a/src/core/Input.lua
+++ b/src/core/Input.lua
@@ -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)
diff --git a/src/core/NxAssetOverlay.lua b/src/core/NxAssetOverlay.lua
new file mode 100644
index 00000000..f14a593e
--- /dev/null
+++ b/src/core/NxAssetOverlay.lua
@@ -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
diff --git a/src/core/NxDisplay.lua b/src/core/NxDisplay.lua
new file mode 100644
index 00000000..e72abb77
--- /dev/null
+++ b/src/core/NxDisplay.lua
@@ -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
diff --git a/src/core/Orientation.lua b/src/core/Orientation.lua
new file mode 100644
index 00000000..e166a7ca
--- /dev/null
+++ b/src/core/Orientation.lua
@@ -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
diff --git a/src/core/Platform.lua b/src/core/Platform.lua
new file mode 100644
index 00000000..ebac81e5
--- /dev/null
+++ b/src/core/Platform.lua
@@ -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
diff --git a/src/core/Sound.lua b/src/core/Sound.lua
index 2c5deb14..e0427380 100644
--- a/src/core/Sound.lua
+++ b/src/core/Sound.lua
@@ -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
diff --git a/src/debug/SwitchDiagnostics.lua b/src/debug/SwitchDiagnostics.lua
new file mode 100644
index 00000000..e40cb6fc
--- /dev/null
+++ b/src/debug/SwitchDiagnostics.lua
@@ -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 "" end
+ if b < 32 and b ~= 9 and b ~= 10 and b ~= 13 then return "" end
+ if b > 126 then return "" end
+ end
+ if #s > 8192 then return s:sub(1, 8192) .. "..." end
+ return s
+end
+
+local function sanitize(value, depth)
+ depth = depth or 0
+ if depth > 4 then return "" 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] = ""
+ 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
diff --git a/src/import/CacheFs.lua b/src/import/CacheFs.lua
index 5cca3b27..1f19dc66 100644
--- a/src/import/CacheFs.lua
+++ b/src/import/CacheFs.lua
@@ -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
diff --git a/src/import/LauncherSettings.lua b/src/import/LauncherSettings.lua
index 665b5504..19e74312 100644
--- a/src/import/LauncherSettings.lua
+++ b/src/import/LauncherSettings.lua
@@ -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"),
diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua
index ab4f6d01..04402f42 100644
--- a/src/import/LauncherView.lua
+++ b/src/import/LauncherView.lua
@@ -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)
diff --git a/src/import/RomExtractor.lua b/src/import/RomExtractor.lua
index 1d2297f6..3d5e8e77 100644
--- a/src/import/RomExtractor.lua
+++ b/src/import/RomExtractor.lua
@@ -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),
diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua
index b39458b4..99c0e782 100644
--- a/src/import/RomImporter.lua
+++ b/src/import/RomImporter.lua
@@ -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//.
+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//: 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 = {}, {}, {}, {}, {}
diff --git a/src/import/SaveFileIO.lua b/src/import/SaveFileIO.lua
index f6a1f04f..76a0ebf1 100644
--- a/src/import/SaveFileIO.lua
+++ b/src/import/SaveFileIO.lua
@@ -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// 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--.sav in the save directory (created if
--- absent). Returns true + the absolute path on success, false + a friendly
--- message otherwise.
+-- exports//gen1recomp--.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 ""
diff --git a/src/link/Handshake.lua b/src/link/Handshake.lua
index aec8437e..710ad5c8 100644
--- a/src/link/Handshake.lua
+++ b/src/link/Handshake.lua
@@ -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)
diff --git a/src/link/LinkBattle.lua b/src/link/LinkBattle.lua
index c51f57c5..99e36aef 100644
--- a/src/link/LinkBattle.lua
+++ b/src/link/LinkBattle.lua
@@ -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 }
diff --git a/src/link/LinkState.lua b/src/link/LinkState.lua
index 59d15520..4e97133e 100644
--- a/src/link/LinkState.lua
+++ b/src/link/LinkState.lua
@@ -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)
diff --git a/src/mods/LauncherMods.lua b/src/mods/LauncherMods.lua
index 189f49c3..2091af91 100644
--- a/src/mods/LauncherMods.lua
+++ b/src/mods/LauncherMods.lua
@@ -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 "/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))
diff --git a/src/render/Assets.lua b/src/render/Assets.lua
index 5f41af88..afcc577b 100644
--- a/src/render/Assets.lua
+++ b/src/render/Assets.lua
@@ -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)
diff --git a/src/ui/NamingScreen.lua b/src/ui/NamingScreen.lua
index 171d85de..6073ca44 100644
--- a/src/ui/NamingScreen.lua
+++ b/src/ui/NamingScreen.lua
@@ -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
diff --git a/src/ui/OptionsMenu.lua b/src/ui/OptionsMenu.lua
index 0cdc7f36..56379f7f 100644
--- a/src/ui/OptionsMenu.lua
+++ b/src/ui/OptionsMenu.lua
@@ -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
diff --git a/src/ui/PadCursor.lua b/src/ui/PadCursor.lua
new file mode 100644
index 00000000..2286e23a
--- /dev/null
+++ b/src/ui/PadCursor.lua
@@ -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
diff --git a/src/ui/TouchControlsEditor.lua b/src/ui/TouchControlsEditor.lua
index 6d088189..909a172e 100644
--- a/src/ui/TouchControlsEditor.lua
+++ b/src/ui/TouchControlsEditor.lua
@@ -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()
diff --git a/src/update/Boot.lua b/src/update/Boot.lua
index 069fbe43..b3936ad4 100644
--- a/src/update/Boot.lua
+++ b/src/update/Boot.lua
@@ -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
diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua
index ff2b9f58..086140fd 100644
--- a/src/world/OverworldController.lua
+++ b/src/world/OverworldController.lua
@@ -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)
diff --git a/tests/drivers/fly_anim_bug702_test.lua b/tests/drivers/fly_anim_bug702_test.lua
new file mode 100644
index 00000000..20e8a8af
--- /dev/null
+++ b/tests/drivers/fly_anim_bug702_test.lua
@@ -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
diff --git a/tests/engine/assets_version_fallback_test.lua b/tests/engine/assets_version_fallback_test.lua
new file mode 100644
index 00000000..e891d612
--- /dev/null
+++ b/tests/engine/assets_version_fallback_test.lua
@@ -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()
diff --git a/tests/engine/cache_fs_blue_mount_test.lua b/tests/engine/cache_fs_blue_mount_test.lua
new file mode 100644
index 00000000..f0a81e8a
--- /dev/null
+++ b/tests/engine/cache_fs_blue_mount_test.lua
@@ -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()
diff --git a/tests/engine/game_display_chord_test.lua b/tests/engine/game_display_chord_test.lua
new file mode 100644
index 00000000..31f3802a
--- /dev/null
+++ b/tests/engine/game_display_chord_test.lua
@@ -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()
diff --git a/tests/engine/input_display_chord_test.lua b/tests/engine/input_display_chord_test.lua
new file mode 100644
index 00000000..47b115f6
--- /dev/null
+++ b/tests/engine/input_display_chord_test.lua
@@ -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()
diff --git a/tests/engine/input_dual_path_test.lua b/tests/engine/input_dual_path_test.lua
new file mode 100644
index 00000000..dfd8dc93
--- /dev/null
+++ b/tests/engine/input_dual_path_test.lua
@@ -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()
diff --git a/tests/engine/input_focus_reset_test.lua b/tests/engine/input_focus_reset_test.lua
new file mode 100644
index 00000000..d403615f
--- /dev/null
+++ b/tests/engine/input_focus_reset_test.lua
@@ -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()
diff --git a/tests/engine/input_joystick_recovery_test.lua b/tests/engine/input_joystick_recovery_test.lua
new file mode 100644
index 00000000..3bdeaeb1
--- /dev/null
+++ b/tests/engine/input_joystick_recovery_test.lua
@@ -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()
diff --git a/tests/engine/input_nx_raw_map_test.lua b/tests/engine/input_nx_raw_map_test.lua
new file mode 100644
index 00000000..b353ccf8
--- /dev/null
+++ b/tests/engine/input_nx_raw_map_test.lua
@@ -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()
diff --git a/tests/engine/input_shared_map_test.lua b/tests/engine/input_shared_map_test.lua
new file mode 100644
index 00000000..a34a2466
--- /dev/null
+++ b/tests/engine/input_shared_map_test.lua
@@ -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()
diff --git a/tests/engine/launcher_nx_pad_cursor_test.lua b/tests/engine/launcher_nx_pad_cursor_test.lua
new file mode 100644
index 00000000..8e27bffd
--- /dev/null
+++ b/tests/engine/launcher_nx_pad_cursor_test.lua
@@ -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")
diff --git a/tests/engine/launcher_save_slot_overlap_bug748.lua b/tests/engine/launcher_save_slot_overlap_bug748.lua
new file mode 100644
index 00000000..2be74fe3
--- /dev/null
+++ b/tests/engine/launcher_save_slot_overlap_bug748.lua
@@ -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")
diff --git a/tests/engine/launcher_text_input_bug578.lua b/tests/engine/launcher_text_input_bug578.lua
index 222232b8..8d6061af 100644
--- a/tests/engine/launcher_text_input_bug578.lua
+++ b/tests/engine/launcher_text_input_bug578.lua
@@ -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
diff --git a/tests/engine/nx_display_test.lua b/tests/engine/nx_display_test.lua
new file mode 100644
index 00000000..ce446232
--- /dev/null
+++ b/tests/engine/nx_display_test.lua
@@ -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")
diff --git a/tests/engine/nx_generated_guard_test.lua b/tests/engine/nx_generated_guard_test.lua
new file mode 100644
index 00000000..2780e395
--- /dev/null
+++ b/tests/engine/nx_generated_guard_test.lua
@@ -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()
diff --git a/tests/engine/nx_yellow_boot_test.lua b/tests/engine/nx_yellow_boot_test.lua
new file mode 100644
index 00000000..764198ec
--- /dev/null
+++ b/tests/engine/nx_yellow_boot_test.lua
@@ -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()
diff --git a/tests/engine/orientation_option.lua b/tests/engine/orientation_option.lua
new file mode 100644
index 00000000..cb000198
--- /dev/null
+++ b/tests/engine/orientation_option.lua
@@ -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
diff --git a/tests/engine/platform_nx_network_gate_test.lua b/tests/engine/platform_nx_network_gate_test.lua
new file mode 100644
index 00000000..d46a5e7d
--- /dev/null
+++ b/tests/engine/platform_nx_network_gate_test.lua
@@ -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()
diff --git a/tests/engine/platform_nx_shell_gate_test.lua b/tests/engine/platform_nx_shell_gate_test.lua
new file mode 100644
index 00000000..740afc58
--- /dev/null
+++ b/tests/engine/platform_nx_shell_gate_test.lua
@@ -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()
diff --git a/tests/engine/platform_nx_test.lua b/tests/engine/platform_nx_test.lua
new file mode 100644
index 00000000..af7c88aa
--- /dev/null
+++ b/tests/engine/platform_nx_test.lua
@@ -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()
diff --git a/tests/engine/rom_importer_nx_flags_test.lua b/tests/engine/rom_importer_nx_flags_test.lua
new file mode 100644
index 00000000..ca4c77ec
--- /dev/null
+++ b/tests/engine/rom_importer_nx_flags_test.lua
@@ -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()
diff --git a/tests/engine/rom_importer_nx_inbox_test.lua b/tests/engine/rom_importer_nx_inbox_test.lua
new file mode 100644
index 00000000..38c617ee
--- /dev/null
+++ b/tests/engine/rom_importer_nx_inbox_test.lua
@@ -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()
diff --git a/tests/engine/rom_importer_nx_mods_inbox_test.lua b/tests/engine/rom_importer_nx_mods_inbox_test.lua
new file mode 100644
index 00000000..43d0f4db
--- /dev/null
+++ b/tests/engine/rom_importer_nx_mods_inbox_test.lua
@@ -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()
diff --git a/tests/engine/rom_importer_nx_saves_inbox_test.lua b/tests/engine/rom_importer_nx_saves_inbox_test.lua
new file mode 100644
index 00000000..9a911531
--- /dev/null
+++ b/tests/engine/rom_importer_nx_saves_inbox_test.lua
@@ -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// 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//
+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()
diff --git a/tests/engine/save_file_io_tests.lua b/tests/engine/save_file_io_tests.lua
index 6645c7c9..90a7fd07 100644
--- a/tests/engine/save_file_io_tests.lua
+++ b/tests/engine/save_file_io_tests.lua
@@ -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//")
- 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)
diff --git a/tests/engine/switch_diagnostics_test.lua b/tests/engine/switch_diagnostics_test.lua
new file mode 100644
index 00000000..8c781f6e
--- /dev/null
+++ b/tests/engine/switch_diagnostics_test.lua
@@ -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("", 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
+-- "" (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()
diff --git a/tests/engine/touch_controls_pad_cursor_test.lua b/tests/engine/touch_controls_pad_cursor_test.lua
new file mode 100644
index 00000000..ba4c3928
--- /dev/null
+++ b/tests/engine/touch_controls_pad_cursor_test.lua
@@ -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")
diff --git a/tests/input_hold_test.lua b/tests/input_hold_test.lua
index c8a716aa..0bdcd099 100644
--- a/tests/input_hold_test.lua
+++ b/tests/input_hold_test.lua
@@ -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()
diff --git a/tests/launcher_mods_install_zip_test.lua b/tests/launcher_mods_install_zip_test.lua
new file mode 100644
index 00000000..0fb9d1d0
--- /dev/null
+++ b/tests/launcher_mods_install_zip_test.lua
@@ -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()
diff --git a/tests/love_stub.lua b/tests/love_stub.lua
index 8973c8a0..06fb2a91 100644
--- a/tests/love_stub.lua
+++ b/tests/love_stub.lua
@@ -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 ""
+ 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()
diff --git a/tests/mod_link_tests.lua b/tests/mod_link_tests.lua
index bb55a825..eaa2839a 100644
--- a/tests/mod_link_tests.lua
+++ b/tests/mod_link_tests.lua
@@ -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, " ")
diff --git a/tests/parity_fly_anim.lua b/tests/parity_fly_anim.lua
new file mode 100644
index 00000000..e3e14ef1
--- /dev/null
+++ b/tests/parity_fly_anim.lua
@@ -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()
diff --git a/tests/run_tests.lua b/tests/run_tests.lua
index 763893c0..6119e18c 100644
--- a/tests/run_tests.lua
+++ b/tests/run_tests.lua
@@ -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
diff --git a/tests/save_editor_pad_input_test.lua b/tests/save_editor_pad_input_test.lua
new file mode 100644
index 00000000..cb65cf45
--- /dev/null
+++ b/tests/save_editor_pad_input_test.lua
@@ -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")
diff --git a/tests/switch_ci_workflows_test.lua b/tests/switch_ci_workflows_test.lua
new file mode 100644
index 00000000..8225cc46
--- /dev/null
+++ b/tests/switch_ci_workflows_test.lua
@@ -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")
diff --git a/tests/switch_transfer_docs_test.lua b/tests/switch_transfer_docs_test.lua
new file mode 100644
index 00000000..ee80d11d
--- /dev/null
+++ b/tests/switch_transfer_docs_test.lua
@@ -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")
diff --git a/tools/save-editor/App.lua b/tools/save-editor/App.lua
index 85450aa3..7173ab94 100644
--- a/tools/save-editor/App.lua
+++ b/tools/save-editor/App.lua
@@ -25,6 +25,7 @@ local State = require("State")
local Kit = require("Kit")
local Theme = require("Theme")
local Ops = require("Ops")
+local PadInput = require("PadInput")
local PAL = Theme.PAL
local Party = require("Party")
@@ -42,6 +43,10 @@ local S
-- vanilla records over an already-merged Data
local mods
local mouseClicked = false
+-- Click position from the press event. Kit samples the pointer in draw, so a
+-- touch / mouse / pad-A click must use the event coords -- not love.mouse
+-- (often stale on NX) and not the virtual cursor when a finger taps elsewhere.
+local clickX, clickY
-- Wheel notches queued by App.wheelmoved since the last draw, handed to Kit
-- there like mouseClicked is: LOVE delivers events before love.draw, so a
-- notch is always spent by the frame that follows it (#595).
@@ -234,6 +239,34 @@ function App.unload()
-- deaf to every click (#541).
Kit.blur()
Kit.blockClicks = false
+ PadInput.reset()
+end
+
+local function cycleTab(delta)
+ if not S then return end
+ local idx = 1
+ for i, t in ipairs(TABS) do
+ if t.id == S.tab then idx = i; break end
+ end
+ idx = ((idx - 1 + delta) % #TABS) + 1
+ S.tab = TABS[idx].id
+ Ops.say(S, "Tab: " .. TABS[idx].label)
+end
+
+-- Pad / Joy-Con actions from PadInput.gamepadpressed (A/B via GamepadMap so
+-- NX physical A confirms and B closes).
+local function handlePadAction(action)
+ if not action or not S then return end
+ if action == "a" then
+ local mx, my = PadInput.pointer()
+ App.mousepressed(mx, my, 1)
+ elseif action == "b" then
+ App.close()
+ elseif action == "tab_prev" then
+ cycleTab(-1)
+ elseif action == "tab_next" then
+ cycleTab(1)
+ end
end
function App.save()
@@ -278,6 +311,7 @@ end
-- host's onClose runs App.unload, which drops S -- doing that inline left the
-- rest of the frame drawing against a nil state.
function App.close()
+ if not S then return false end
if S.dirty and not S._quitArmed then
S._quitArmed = true
S.status = "Unsaved changes, Save first or click Close again to discard"
@@ -302,16 +336,55 @@ function App.update(dt)
-- directly in App.draw() via Kit.beginFrame. Tile animation (water,
-- flowers) still needs ticking so the Map tab isn't static.
TileRenderer.tick()
+ PadInput.update(dt)
+ local notches = PadInput.takeWheel()
+ if notches ~= 0 then
+ App.wheelmoved(0, notches)
+ end
end
function App.mousepressed(x, y, button)
- if button == 1 then mouseClicked = true end
+ if button == 1 then
+ mouseClicked = true
+ clickX, clickY = x, y
+ -- A finger / mouse tap yields the virtual cursor so the click lands where
+ -- the event said, not under the Joy-Con pointer (NX touch soft-miss).
+ PadInput.yieldToPointer()
+ end
end
function App.textinput(text)
Kit.textinput(text)
end
+function App.gamepadpressed(joystick, button)
+ handlePadAction(PadInput.gamepadpressed(joystick, button))
+end
+
+function App.gamepadreleased(joystick, button)
+ PadInput.gamepadreleased(joystick, button)
+end
+
+function App.gamepadaxis(joystick, axis, value)
+ PadInput.gamepadaxis(joystick, axis, value)
+end
+
+function App.joystickpressed(joystick, button)
+ handlePadAction(PadInput.joystickpressed(joystick, button))
+end
+
+function App.joystickreleased(joystick, button)
+ PadInput.joystickreleased(joystick, button)
+end
+
+function App.joystickaxis(joystick, axis, value)
+ PadInput.joystickaxis(joystick, axis, value)
+end
+
+function App.joystickhat(joystick, hat, direction)
+ PadInput.joystickhat(joystick, hat, direction)
+end
+
-- ------------------------------------------------------------------ chrome
-- The file chip: the single source of truth for "which file am I editing".
-- The path truncates from the LEFT so the filename is always readable, and
@@ -663,8 +736,15 @@ function App.draw()
local s = Kit.scale
local mx, my = love.mouse.getPosition()
+ local padX, padY, padOn = PadInput.pointer()
+ if mouseClicked and clickX ~= nil then
+ mx, my = clickX, clickY
+ elseif padOn then
+ mx, my = padX, padY
+ end
Kit.beginFrame(mx, my, mouseClicked, wheelY)
mouseClicked = false
+ clickX, clickY = nil, nil
wheelY = 0
-- Modal shield. Kit has no z-order, so the picker cannot simply be drawn
-- last: the chrome and the panel underneath would take the same tap. The
@@ -700,6 +780,7 @@ function App.draw()
Kit.blockClicks = false
SpeciesPicker.draw(S, Kit, width, height)
Kit.endFrame()
+ PadInput.draw()
-- Only now, with the whole frame painted, is it safe to drop the editor.
if S._closeRequested then finishClose() end
diff --git a/tools/save-editor/PadInput.lua b/tools/save-editor/PadInput.lua
new file mode 100644
index 00000000..460d315c
--- /dev/null
+++ b/tools/save-editor/PadInput.lua
@@ -0,0 +1,3 @@
+-- Compat shim: PadInput lived here first; shared implementation is now
+-- src/ui/PadCursor.lua so the touch-controls editor can reuse it.
+return require("src.ui.PadCursor")
diff --git a/tools/save-editor/README.md b/tools/save-editor/README.md
index 900a069b..e6461965 100644
--- a/tools/save-editor/README.md
+++ b/tools/save-editor/README.md
@@ -37,6 +37,7 @@ If the file isn't there (or you want another copy), use **Open...**, drop a
| --- | --- |
| `Theme.lua` | the launcher's palette + drawing primitives (cards, glow, dashed outlines, letterspaced captions) |
| `Kit.lua` | immediate-mode widgets built on Theme: buttons, rows, meters, chips, checkboxes, a real text field, pagers |
+| `PadInput.lua` | virtual cursor for Switch / gamepads (stick move, A click, B close, shoulders cycle tabs) |
| `Ops.lua` | **every mutation**, behind one funnel that sets dirty + status together |
| `App.lua` | chrome (version rail, title bar, tab rail, status bar) and the panel router |
| `panels/` | one file per tab; pure layout that dispatches into Ops |
@@ -46,6 +47,15 @@ the modal species search the inspector opens, drawn by `App.draw` after the
panel rather than routed through the tab table. Kit has no z-order, so while
it is up `Kit.blockClicks` shields every widget underneath it.
+### Switch / gamepad
+
+On Nintendo Switch (and any gamepad without a mouse), the editor uses the same
+virtual-cursor idea as the launcher: left stick / D-pad moves a pointer, **A**
+clicks, **B** closes (with the usual unsaved confirm), L/R cycle tabs, and the
+right stick scrolls lists. Touch taps forward as clicks. Without that path the
+editor soft-locked until HOME — `main.lua` used to drop all pad/touch events
+while `editorMode` was set.
+
The design reference is the `SaveEditor.dc.html` mockup that this port
transcribes; its measurements are in the same pixel space `App.lua` draws in.
@@ -70,6 +80,7 @@ luajit tests/save_editor_task6_tests.lua # Boxes + Items rules
luajit tests/save_editor_task7_tests.lua # Events + Dex rules
luajit tests/save_editor_task8_tests.lua # map browser + spawn points
luajit tests/save_editor_mod_tests.lua # modded species/items stay editable
+luajit tests/save_editor_pad_input_test.lua # pad cursor / NX input routing
```
They drive `Ops.lua` rather than clicking pixel coordinates. The panels are
diff --git a/tools/switch-probe/README.md b/tools/switch-probe/README.md
new file mode 100644
index 00000000..616c1300
--- /dev/null
+++ b/tools/switch-probe/README.md
@@ -0,0 +1,47 @@
+# switch-probe — love-nx hardware probe
+
+**NOT FOR RELEASE.** This package is a developer-only diagnostic for Nintendo Switch (love-nx). Do not ship it inside `game.love` or release NRO payloads.
+
+## Purpose
+
+Validate Phase 0 runtime facts on real Switch hardware before running the full Gen1Recomp launcher:
+
+- `love.system.getOS()` (expect `NX` on Switch)
+- Window dimensions (`love.graphics.getDimensions()`)
+- Save directory path (`love.filesystem.getSaveDirectory()`)
+- Gamepad / joystick / touch event logging
+
+To date this probe has only been run on **Switch OLED** (see `docs/switch-hardware-evidence.md`); other models are untested. Deploy beside `gen1recomp.nro` remains **manual** (MTP); see `docs/switch-development.md`.
+
+## Fields shown on screen
+
+| Field | Source |
+| ----- | ------ |
+| OS name | `love.system.getOS()` |
+| Dimensions | `love.graphics.getDimensions()` |
+| Save directory | `love.filesystem.getSaveDirectory()` |
+| `love._os` | Engine boot hint (when available) |
+| Event log | Last 24 `gamepad*`, `joystick*`, `touch*` events |
+
+## Build `.love` (from repo root)
+
+```bash
+(cd tools/switch-probe && zip -9 -r ../../.bazinga/work/switch-probe.love main.lua conf.lua)
+```
+
+Or:
+
+```bash
+mkdir -p .bazinga/work
+zip -9 -j .bazinga/work/switch-probe.love tools/switch-probe/main.lua tools/switch-probe/conf.lua
+```
+
+Deploy beside `gen1recomp.nro` (loose mode) per `docs/switch-development.md`, renaming to `game.love` only for a probe run — use a separate SD folder so probe and game builds do not mix.
+
+## Desktop smoke (optional)
+
+```bash
+love tools/switch-probe
+```
+
+Expect desktop `getOS()`; input events appear when using keyboard/gamepad/touch (if available).
diff --git a/tools/switch-probe/conf.lua b/tools/switch-probe/conf.lua
new file mode 100644
index 00000000..03395856
--- /dev/null
+++ b/tools/switch-probe/conf.lua
@@ -0,0 +1,11 @@
+function love.conf(t)
+ t.identity = "switch-probe"
+ t.version = "11.5"
+ t.window.title = "switch-probe (NOT FOR RELEASE)"
+ t.window.width = 1280
+ t.window.height = 720
+ t.window.fullscreen = true
+ t.window.resizable = false
+ t.window.highdpi = false
+ t.modules.physics = false
+end
diff --git a/tools/switch-probe/main.lua b/tools/switch-probe/main.lua
new file mode 100644
index 00000000..4393d529
--- /dev/null
+++ b/tools/switch-probe/main.lua
@@ -0,0 +1,70 @@
+-- Minimal love-nx hardware probe. NOT FOR RELEASE — dev-only diagnostic.
+-- Draws runtime facts and logs input events to help validate Phase 0 on OLED.
+
+local lines = {}
+local log = {}
+local maxLog = 24
+
+local function push(msg)
+ log[#log + 1] = msg
+ if #log > maxLog then table.remove(log, 1) end
+end
+
+local function refreshStatic()
+ lines = {}
+ local osName = love.system.getOS()
+ lines[#lines + 1] = "switch-probe — NOT FOR RELEASE"
+ lines[#lines + 1] = "getOS(): " .. tostring(osName)
+ local w, h = love.graphics.getDimensions()
+ lines[#lines + 1] = ("dimensions: %d x %d"):format(w, h)
+ lines[#lines + 1] = "save: " .. tostring(love.filesystem.getSaveDirectory())
+ if love._os then
+ lines[#lines + 1] = "love._os: " .. tostring(love._os)
+ end
+end
+
+function love.load()
+ love.graphics.setBackgroundColor(0.08, 0.1, 0.16)
+ refreshStatic()
+ push("load")
+end
+
+function love.gamepadpressed(joystick, button)
+ push(("gamepadpressed %s %s"):format(joystick:getName(), tostring(button)))
+end
+
+function love.gamepadreleased(joystick, button)
+ push(("gamepadreleased %s %s"):format(joystick:getName(), tostring(button)))
+end
+
+function love.joystickpressed(joystick, button)
+ push(("joystickpressed %s #%s"):format(joystick:getName(), tostring(button)))
+end
+
+function love.joystickreleased(joystick, button)
+ push(("joystickreleased %s #%s"):format(joystick:getName(), tostring(button)))
+end
+
+function love.touchpressed(id, x, y, dx, dy, pressure)
+ push(("touchpressed id=%s (%.0f,%.0f)"):format(tostring(id), x, y))
+end
+
+function love.touchreleased(id, x, y, dx, dy, pressure)
+ push(("touchreleased id=%s (%.0f,%.0f)"):format(tostring(id), x, y))
+end
+
+function love.draw()
+ refreshStatic()
+ love.graphics.setColor(0.9, 0.92, 1)
+ local y = 16
+ for _, line in ipairs(lines) do
+ love.graphics.print(line, 16, y)
+ y = y + 22
+ end
+ love.graphics.print("— event log —", 16, y + 8)
+ y = y + 30
+ for _, line in ipairs(log) do
+ love.graphics.print(line, 16, y)
+ y = y + 18
+ end
+end