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