mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 08:21:02 +02:00
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
name: Release
|
||||
|
||||
# Builds the macOS, Windows, and Linux desktop apps, an Android APK, an iOS
|
||||
# IPA, and the Anbernic RG34XXSP (Stock OS 64-bit MOD / PortMaster) port on
|
||||
# the self-hosted Mac runner, and publishes them as a GitHub Release.
|
||||
# IPA, a Nintendo Switch SD-ready zip (experimental), and the Anbernic RG34XXSP
|
||||
# (Stock OS 64-bit MOD / PortMaster) port on the self-hosted Mac runner, and
|
||||
# publishes them as a GitHub Release.
|
||||
#
|
||||
# Versioning:
|
||||
# - First ever release is 0.1.0.
|
||||
@@ -196,6 +197,17 @@ jobs:
|
||||
--version "${{ steps.ver.outputs.version }}"
|
||||
fi
|
||||
|
||||
- name: Build Switch
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Hard-fail gate: Switch ships with every release (never soft-fail).
|
||||
# PR CI is path-gated (ubuntu selftest + canonical fused); release
|
||||
# always builds Switch regardless of which files changed.
|
||||
# Needs native switch-tools (nacptool/elf2nro) and/or Docker on the
|
||||
# Mac self-hosted runner; see docs/switch-build.md.
|
||||
scripts/build_switch.sh --fetch --fused \
|
||||
--version "${{ steps.ver.outputs.version }}"
|
||||
|
||||
- name: Build Anbernic RG34XXSP port
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -258,6 +270,12 @@ jobs:
|
||||
[ -f "$ipa" ] || { echo "::error::$ipa not found (expected from scripts/build_ios.sh --device)"; exit 1; }
|
||||
cp "$ipa" "$outdir/gen1recomp-${v}-ios.ipa"
|
||||
|
||||
swzip="dist/switch/gen1recomp-${v}-switch.zip"
|
||||
[ -f "$swzip" ] || { echo "::error::$swzip not found (expected from scripts/build_switch.sh --fused → pack_sd_zip.sh)"; exit 1; }
|
||||
cp "$swzip" "$outdir/gen1recomp-${v}-switch.zip"
|
||||
# Local fused .nro stays under dist/switch/ for PR CI / debug; release
|
||||
# publishes the SD-ready zip only.
|
||||
|
||||
# Anbernic handheld port (suffix names the CFW it targets, so a
|
||||
# future RG35XX/other-CFW pack can ship alongside it).
|
||||
rg34="dist/rg34xxsp/gen1recomp-rg34xxsp-stockos64-mod.zip"
|
||||
@@ -369,18 +387,23 @@ jobs:
|
||||
fi
|
||||
printf 'Release notes:\n%s\n' "$notes"
|
||||
|
||||
release_files=(
|
||||
"dist/release/gen1recomp-${v}-macos.zip"
|
||||
"dist/release/gen1recomp-${v}-windows.zip"
|
||||
"dist/release/gen1recomp-${v}-linux.zip"
|
||||
"dist/release/gen1recomp-${v}-android.apk"
|
||||
"dist/release/gen1recomp-${v}-ios.ipa"
|
||||
"dist/release/gen1recomp-${v}-switch.zip"
|
||||
"dist/release/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip"
|
||||
"dist/release/gen1recomp-${v}.love"
|
||||
"dist/release/sha256sums.txt"
|
||||
)
|
||||
|
||||
gh release create "$tag" \
|
||||
--target "$GITHUB_SHA" \
|
||||
--title "$v" \
|
||||
--notes "$notes" \
|
||||
"dist/release/gen1recomp-${v}-macos.zip" \
|
||||
"dist/release/gen1recomp-${v}-windows.zip" \
|
||||
"dist/release/gen1recomp-${v}-linux.zip" \
|
||||
"dist/release/gen1recomp-${v}-android.apk" \
|
||||
"dist/release/gen1recomp-${v}-ios.ipa" \
|
||||
"dist/release/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip" \
|
||||
"dist/release/gen1recomp-${v}.love" \
|
||||
"dist/release/sha256sums.txt"
|
||||
"${release_files[@]}"
|
||||
|
||||
echo "Published release $tag"
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
name: Switch artifact comment
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [ci]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: artifact
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RUN_ID: ${{ github.event.workflow_run.id }}
|
||||
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||
HEAD_REPOSITORY: ${{ github.event.workflow_run.head_repository.full_name }}
|
||||
run: |
|
||||
artifact_id="$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts" --jq '.artifacts[] | select(.name == "gen1recomp-switch-nro") | .id')"
|
||||
[ -n "$artifact_id" ] || exit 0
|
||||
head_owner="${HEAD_REPOSITORY%%/*}"
|
||||
pr_number="$(gh api "repos/$GITHUB_REPOSITORY/pulls?state=open&head=$head_owner:$HEAD_BRANCH" --jq '.[0].number // empty')"
|
||||
[ -n "$pr_number" ] || exit 0
|
||||
echo "artifact_url=https://github.com/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts/$artifact_id" >> "$GITHUB_OUTPUT"
|
||||
echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT"
|
||||
# Upsert via comment-tag only — do not delete-all bot comments (clobbers iOS).
|
||||
- name: Get build info
|
||||
id: build-info
|
||||
env:
|
||||
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
run: |
|
||||
commit_hash="$(printf '%s' "$HEAD_SHA" | cut -c1-7)"
|
||||
build_time="$(date "+%Y-%m-%d %H:%M:%S")"
|
||||
echo "hash=$commit_hash" >> "$GITHUB_OUTPUT"
|
||||
echo "time=$build_time" >> "$GITHUB_OUTPUT"
|
||||
- name: comment Switch artifact
|
||||
if: steps.artifact.outputs.pr_number != ''
|
||||
uses: thollander/actions-comment-pull-request@v3
|
||||
with:
|
||||
message: |
|
||||
[gen1recomp-switch.nro](${{ steps.artifact.outputs.artifact_url }})
|
||||
|
||||
**Commit**: [#${{ steps.build-info.outputs.hash }}](https://github.com/${{ github.event.workflow_run.head_repository.full_name }}/commit/${{ github.event.workflow_run.head_sha }})
|
||||
**Build Time**: `${{ steps.build-info.outputs.time }}`
|
||||
|
||||
<sub>This comment was automatically generated. [View workflow run](https://github.com/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }})</sub>
|
||||
pr-number: ${{ steps.artifact.outputs.pr_number }}
|
||||
comment-tag: switch-build-result
|
||||
github-token: ${{ github.token }}
|
||||
+4
-1
@@ -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/)
|
||||
|
||||
@@ -229,6 +229,27 @@ ships with every release as `gen1recomp-*-rg34xxsp-stockos64-mod.zip`.
|
||||
Install steps, controls, and troubleshooting live in
|
||||
[docs/anbernic-rg34xxsp.md](docs/anbernic-rg34xxsp.md).
|
||||
|
||||
## Nintendo Switch
|
||||
|
||||
Releases ship an SD-ready `gen1recomp-*-switch.zip` (issue
|
||||
[#531](https://github.com/bryanthaboi/gen1recomp/issues/531)). Runtime target
|
||||
is pinned [love-nx](https://github.com/retronx-team/love-nx) `11.5-nx1`.
|
||||
Requires a console that can run Switch homebrew. Hardware evidence: **OLED**
|
||||
(author) and **V1 / Erista** boot (community).
|
||||
|
||||
- Players: [docs/switch-install.md](docs/switch-install.md) — download the
|
||||
zip, extract at the microSD root (install or update), title-override
|
||||
launch, import your own legal ROM, Joy-Con controls and shortcuts.
|
||||
- Builders: [docs/switch-build.md](docs/switch-build.md) — `--fetch` /
|
||||
`--loose` / `--fused`, toolchain, Docker fallback, and **CI vs release**
|
||||
(path-gated ubuntu selftest, canonical fused PR artifact, release hard-fail).
|
||||
|
||||
Limitations, Dusklight-derived method, and how we tested:
|
||||
[docs/switch-development.md](docs/switch-development.md) and
|
||||
[docs/switch-hardware-evidence.md](docs/switch-hardware-evidence.md). Community
|
||||
help — especially HOS / love-nx packaging and broader hardware coverage — is
|
||||
welcome.
|
||||
|
||||
## Modding
|
||||
|
||||
The game ships a native mod platform: content registries, events and hooks,
|
||||
@@ -276,4 +297,7 @@ This project would not be possible without [pret](https://github.com/pret) >
|
||||
the pret band of decompiling maniacs > and their
|
||||
[pokered](https://github.com/pret/pokered) disassembly.
|
||||
|
||||
Nintendo Switch port: [andrewqsantos](https://github.com/andrewqsantos).
|
||||
Switch hardware testing (V1 boot): [booshankles](https://github.com/booshankles).
|
||||
|
||||
<p align="center"><a href="https://boisclub.games"><img src="https://raw.githubusercontent.com/bryanthaboi/gen1recomp/refs/heads/dev/assets/logo/bcg.png"></a></p>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
@@ -58,7 +58,17 @@ function love.conf(t)
|
||||
-- engine before conf runs (LÖVE 11.x / 11.5).
|
||||
local osName = love._os
|
||||
local mobile = osName == "Android" or osName == "iOS"
|
||||
if mobile then
|
||||
local nx = osName == "NX"
|
||||
if nx then
|
||||
-- Switch (love-nx): hint handheld 720p. SDL auto-switches portable↔dock
|
||||
-- (720p↔1080p) only when the window is resizable and not exclusive
|
||||
-- fullscreen; NxDisplay.sync also applies the size on boot and dock change.
|
||||
t.window.width = 1280
|
||||
t.window.height = 720
|
||||
t.window.fullscreen = false
|
||||
t.window.resizable = true
|
||||
t.window.highdpi = false
|
||||
elseif mobile then
|
||||
-- resizable is what unlocks orientation. SDL's Android backend, given no
|
||||
-- SDL_HINT_ORIENTATIONS (LÖVE sets none), calls setRequestedOrientation
|
||||
-- at window creation -- FULL_SENSOR when the window is resizable (rotates
|
||||
@@ -70,6 +80,10 @@ function love.conf(t)
|
||||
-- just work. FULL_SENSOR ignores the device's rotation lock, so
|
||||
-- GameActivity.setOrientationBis remaps it to FULL_USER after SDL has
|
||||
-- run: same orientations allowed, but auto-rotate being off now wins.
|
||||
-- A persisted ORIENTATION lock (#592) overrides all of this after boot:
|
||||
-- src/core/Orientation.lua sets SDL_HINT_ORIENTATIONS over the FFI and
|
||||
-- re-triggers the request, from main.lua for the launcher and from
|
||||
-- Game:applyOptions in game.
|
||||
-- iOS follows the Info.plist orientations
|
||||
-- (see mobile/ios/overlays/love-ios.plist, now portrait + landscape).
|
||||
t.window.resizable = true
|
||||
|
||||
+24
-8
@@ -45,6 +45,9 @@ same system picker and install the chosen archive on return.
|
||||
draws one chip per game plus a MODS chip and rebuilds `self.tabRects` every
|
||||
frame so `mousepressed` can dispatch clicks; switching tabs mid-import is
|
||||
allowed (a dropped ROM still routes by SHA-1 regardless of which tab shows).
|
||||
On **NX**, **Scan again** is stricter: it only starts an import whose SHA-1
|
||||
matches the open game tab, so a shared `imports/` folder with Red+Yellow
|
||||
cannot jump Yellow → Red.
|
||||
|
||||
- A game tab (`_drawGamePanel`) shows the ROM card, the SAVE FILES card, the
|
||||
Play button, and the SAVE SLOT card in a responsive two-column grid (see
|
||||
@@ -157,9 +160,14 @@ The SAVE FILES card wires a raw Gen1 `.sav` battery image to the save slots
|
||||
through `src/import/SaveFileIO.lua`, which sits on top of
|
||||
`src/save_convert/SaveConvert.lua` and the slot API in `SaveData`.
|
||||
|
||||
- **Import save** is live once the game's ROM is imported (playable). It opens
|
||||
a native `.sav` picker (`chooseSav` on desktop; on Android,
|
||||
`love.system.pickFile("sav")` → `picked_save.sav`, same SAF path as ROMs).
|
||||
- **Import save** is live once the game's ROM is imported (playable).
|
||||
On desktop it opens a native `.sav` picker (`chooseSav`); on Android,
|
||||
`love.system.pickFile("sav")` → `picked_save.sav`, same SAF path as ROMs.
|
||||
On **NX (Switch)** there is no picker: copy a `.sav` into
|
||||
`getSaveDirectory()/imports/saves/<red|blue|yellow>/` via MTP / SD / FTP
|
||||
(one folder per game), then press **Import save** on that 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/<game>/.imported-sha1` so a second **Import save** (or the same
|
||||
bytes under a new name) does not clone slots; failures leave the original
|
||||
`.sav`. Only that 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-<version>-<slotId>.sav` in the save directory
|
||||
(`love.filesystem.createDirectory("exports")`). On desktop it returns the
|
||||
absolute path (`love.filesystem.getSaveDirectory()`), which the notice line
|
||||
shows with an "Open folder" affordance (`love.system.openURL("file://" .. dir)`).
|
||||
writes `exports/<version>/gen1recomp-<version>-<slotId>.sav` in the save
|
||||
directory (`exports/` and `exports/<version>/` are created as needed). On
|
||||
desktop it returns the absolute path (`love.filesystem.getSaveDirectory()`),
|
||||
which the notice line shows with an "Open folder" affordance
|
||||
(`love.system.openURL("file://" .. dir)`).
|
||||
On Android the bytes are also staged as `pending_export.sav` and
|
||||
`love.system.createFile(suggestedName)` opens `ACTION_CREATE_DOCUMENT` so the
|
||||
player can save to Downloads / Drive / etc.; on return `export_done.flag`
|
||||
makes focus show "Save exported."
|
||||
On **NX**, export success sets a notice with the `exports/<game>/` path and an
|
||||
MTP-oriented hint — no `openURL` / Open folder (pull the file via MTP /
|
||||
SD / FTP instead).
|
||||
- **Drag-drop.** `filedropped` routes a `.sav` to the import path for the
|
||||
currently active game tab; when a non-game tab (mods, or the locked yellow
|
||||
placeholder) is showing it defaults to red, the always-present first game
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
# Build the Nintendo Switch NRO — contributor guide
|
||||
|
||||
Want to play a release build instead? Download the SD-ready zip and extract it
|
||||
at your microSD root — see [switch-install.md](switch-install.md).
|
||||
|
||||
This guide is for contributors who build Gen1Recomp for Switch from source.
|
||||
Hardware evidence, MTP operator loops, and deeper notes live in
|
||||
[switch-development.md](switch-development.md).
|
||||
|
||||
> Releases ship `gen1recomp-*-switch.zip` (SD tree under `switch/gen1recomp/`;
|
||||
> issue [#531](https://github.com/bryanthaboi/gen1recomp/issues/531)). Hardware
|
||||
> evidence: **OLED** (author) and **V1 boot** (community). See
|
||||
> [switch-development.md](switch-development.md) for known limitations.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites by OS
|
||||
|
||||
All packaging entrypoints are **bash**. On Windows, use Git Bash, MSYS2, or
|
||||
WSL — not cmd.exe or PowerShell (AD-008).
|
||||
|
||||
### macOS / Linux
|
||||
|
||||
1. Install [devkitPro pacman](https://devkitpro.org/wiki/devkitPro_pacman).
|
||||
2. Install Switch tools:
|
||||
|
||||
```sh
|
||||
sudo dkp-pacman -S switch-dev
|
||||
```
|
||||
|
||||
3. Ensure `nacptool` and `elf2nro` are on `PATH` (or under
|
||||
`$DEVKITPRO/tools/bin` — the fused script prepends that when set).
|
||||
|
||||
**Optional:** Install [Docker](https://docs.docker.com/get-docker/) so fused
|
||||
builds can fall back to the pinned image when native tools are missing.
|
||||
|
||||
### Windows (Git Bash / MSYS2 / WSL)
|
||||
|
||||
1. Use a bash environment:
|
||||
- **MSYS2** with the [devkitPro](https://devkitpro.org/wiki/devkitPro_pacman)
|
||||
packages (preferred for native `nacptool`/`elf2nro`), or
|
||||
- **WSL** (Ubuntu/etc.) with the Linux pacman flow above, or
|
||||
- **Git Bash** for `--fetch` / `--loose`; for `--fused` prefer MSYS2 or
|
||||
WSL if Docker bind-mounts from Git Bash paths misbehave.
|
||||
2. Install `switch-dev` (or rely on Docker fallback — see below).
|
||||
3. Do **not** expect `scripts/build_switch.sh` to run under cmd/PowerShell.
|
||||
|
||||
### What you must install yourself
|
||||
|
||||
| You install | Script does **not** install |
|
||||
| ----------- | --------------------------- |
|
||||
| bash, git, zip tooling the repo already expects | — |
|
||||
| `dkp-pacman` + `switch-dev` (native fused) | `dkp-pacman -S …` |
|
||||
| Docker (optional fused fallback) | Docker Engine |
|
||||
| A legal `.gb` ROM (to play) | Any ROM or game data |
|
||||
|
||||
---
|
||||
|
||||
## Mode glossary
|
||||
|
||||
`scripts/build_switch.sh` supports three modes (combinable as noted):
|
||||
|
||||
| Mode | What it does |
|
||||
| ---- | ------------ |
|
||||
| `--fetch` | Downloads pinned **love.nro** + **love.elf** into `.bazinga/love-nx/11.5-nx1/` and verifies SHA-256 against `scripts/switch/love-nx-11.5-nx1.sha256`. |
|
||||
| `--loose` | Packs `game.love`, copies pinned `love.nro` → `dist/switch/loose/` as `gen1recomp.nro` + `game.love` side by side. Needs the pin. |
|
||||
| `--fused` | Builds `dist/switch/gen1recomp-<ver>-switch.nro` (game in romfs) via `nacptool` + `elf2nro`, then packs `dist/switch/gen1recomp-<ver>-switch.zip` (SD-ready tree). Needs the pin + toolchain (native or Docker). GitHub Releases publish the **zip only**. |
|
||||
|
||||
Rules:
|
||||
|
||||
- `--fetch` alone is fine; combine as `--fetch --loose` or `--fetch --fused`.
|
||||
- `--loose` and `--fused` are **XOR** — pick one packaging path per run.
|
||||
- `--version X.Y.Z` sets the NACP / filename version (defaults to short git SHA).
|
||||
|
||||
### What `--fetch` downloads
|
||||
|
||||
Only the two pinned love-nx release assets (`love.nro`, `love.elf`). It does
|
||||
**not** install:
|
||||
|
||||
- devkitPro / `dkp-pacman` / `switch-dev`
|
||||
- Docker
|
||||
- ROMs, saves, or mods
|
||||
|
||||
---
|
||||
|
||||
## Native tools, then Docker
|
||||
|
||||
Fused packaging (`scripts/switch/build_fused.sh`):
|
||||
|
||||
1. Prefer native `nacptool` + `elf2nro` on `PATH` (or `$DEVKITPRO/tools/bin`).
|
||||
2. Else fall back to Docker using:
|
||||
- `GEN1_DKP_IMAGE` if set, otherwise
|
||||
- the image named in `scripts/switch/dkp-docker.image` (default
|
||||
`devkitpro/devkita64:latest`).
|
||||
|
||||
If neither native tools nor Docker work, the script exits non-zero with
|
||||
macOS / Linux / Windows / Docker hints and a pointer to this doc.
|
||||
|
||||
---
|
||||
|
||||
## Example commands
|
||||
|
||||
From the repo root:
|
||||
|
||||
```sh
|
||||
# Download pinned love-nx only
|
||||
scripts/build_switch.sh --fetch
|
||||
|
||||
# Loose pair for iteration (fetch + assemble)
|
||||
scripts/build_switch.sh --fetch --loose
|
||||
|
||||
# Single fused NRO + SD-ready zip for a release-like artifact
|
||||
scripts/build_switch.sh --fetch --fused --version 0.2.0
|
||||
```
|
||||
|
||||
Outputs land under `dist/switch/` (and `dist/switch/loose/` for loose mode).
|
||||
The fused path also writes `gen1recomp-<ver>-switch.nro.sha256` and
|
||||
`gen1recomp-<ver>-switch.zip` (+ `.sha256` sidecar for the zip).
|
||||
|
||||
Offline packaging smoke (no network, no nacptool required):
|
||||
|
||||
```sh
|
||||
bash scripts/switch/selftest_build_switch.sh
|
||||
bash scripts/switch/verify_payload.sh --self-test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CI and release
|
||||
|
||||
Switch packaging has three automated surfaces (same policy as AD-010):
|
||||
|
||||
### Path-gated PR / push CI (`.github/workflows/ci.yml`)
|
||||
|
||||
When a change touches Switch packaging / Switch docs / NX runtime paths
|
||||
(`scripts/build_switch.sh`, `scripts/switch/**`, `docs/switch-*.md`,
|
||||
`tests/switch_ci_workflows_test.lua`, `tests/switch_transfer_docs_test.lua`,
|
||||
the NX runtime modules `src/core/NxAssetOverlay.lua`, `src/core/Platform.lua`,
|
||||
`src/core/GameVersion.lua`, `src/import/CacheFs.lua`, the NX engine suites
|
||||
`tests/engine/assets_version_fallback_test.lua`,
|
||||
`tests/engine/nx_generated_guard_test.lua`,
|
||||
`tests/engine/nx_yellow_boot_test.lua`,
|
||||
`tests/engine/switch_diagnostics_test.lua`, `tests/engine/platform_nx_*`,
|
||||
or the Switch-related workflow YAML), CI runs:
|
||||
|
||||
1. **Offline selftest** on `ubuntu-latest` (forks **and** the canonical repo):
|
||||
`scripts/switch/selftest_build_switch.sh`,
|
||||
`scripts/switch/verify_payload.sh --self-test`,
|
||||
`luajit tests/switch_ci_workflows_test.lua`,
|
||||
`luajit tests/switch_transfer_docs_test.lua`, and the NX engine suites
|
||||
headlessly (`luajit tests/engine/assets_version_fallback_test.lua`,
|
||||
`luajit tests/engine/nx_generated_guard_test.lua`,
|
||||
`luajit tests/engine/nx_yellow_boot_test.lua`).
|
||||
2. **Fused NRO build** only on the **canonical** repository
|
||||
(`bryanthaboi/gen1recomp`), on the self-hosted Mac runner
|
||||
(`scripts/build_switch.sh --fetch --fused`), and only when the workflow
|
||||
head is that repo (same-repo push/PR). **Fork repository** CI never runs
|
||||
fused. **Fork → canonical PRs** also skip Switch fused (offline selftest
|
||||
still runs) so untrusted head code is not executed on the self-hosted Mac;
|
||||
iOS device build eligibility is unchanged. Fused also waits for a successful
|
||||
offline selftest before starting on the Mac runner.
|
||||
3. On successful PR fused builds, a follow-up workflow posts a PR comment
|
||||
linking the Actions artifact named `gen1recomp-switch-nro`
|
||||
(comment tag `switch-build-result`; see
|
||||
`.github/workflows/switch-artifact-comment.yml`).
|
||||
|
||||
Unrelated PRs do not burn the self-hosted Mac on Switch packaging.
|
||||
|
||||
### Release hard-fail (`.github/workflows/release.yml`)
|
||||
|
||||
GitHub Releases always build Switch on the same self-hosted Mac runner as the
|
||||
other platforms — this is a **hard gate** (no `continue-on-error`):
|
||||
|
||||
```sh
|
||||
scripts/build_switch.sh --fetch --fused --version "<release version>"
|
||||
```
|
||||
|
||||
A Switch packaging failure fails the entire release job. The release asset is
|
||||
`gen1recomp-<ver>-switch.zip` (SD-ready); the versioned `.nro` stays under
|
||||
`dist/switch/` for the packer and for PR CI artifacts.
|
||||
|
||||
### Runner provisioning
|
||||
|
||||
The self-hosted Mac runner must have **native switch-tools** (`nacptool` /
|
||||
`elf2nro`) **and/or Docker** available. CI and release do not silently run
|
||||
`dkp-pacman -S`; keep the runner image/host provisioned per this guide.
|
||||
|
||||
---
|
||||
|
||||
## Limitations / non-goals
|
||||
|
||||
These scripts and this guide do **not**:
|
||||
|
||||
- Push files to the console (no automated MTP / FTP / SD scripting)
|
||||
- Bundle or download any Pokémon ROM
|
||||
- Install `dkp-pacman` / `switch-dev` for you
|
||||
- Provide `nxlink` / netloader deploy (deferred — see [switch-transfer.md](switch-transfer.md))
|
||||
- Validate **Applet Mode** — use title override (hold **R**) for full memory
|
||||
|
||||
Player install steps: [switch-install.md](switch-install.md).
|
||||
Manual transfer (MTP / SD / FTP, macOS / Linux / Windows):
|
||||
[switch-transfer.md](switch-transfer.md).
|
||||
Hardware depth and evidence: [switch-development.md](switch-development.md),
|
||||
[switch-hardware-evidence.md](switch-hardware-evidence.md).
|
||||
@@ -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/<save identity>/imports/` (see launcher notice for the live `getSaveDirectory()` path) |
|
||||
| Candidates | `*.gb` / `*.gbc` (hidden `.*` AppleDouble names skipped) |
|
||||
| Rescan | Game tab → **Scan again** — imports only the dump whose SHA-1 matches that tab (`GameVersion.forSha1`). Other known dumps stay for their own tabs |
|
||||
| Already ready | Same SHA already imported → “No new ROM found.” |
|
||||
|
||||
Players may drop Red, Blue, and Yellow into the same folder. Opening Yellow and pressing **Scan again** must not start a Red import.
|
||||
|
||||
## Mod zip inbox (NX)
|
||||
|
||||
Community mods install from a **separate** MTP inbox (not mixed into the ROM `imports/` scan):
|
||||
|
||||
| Item | Value |
|
||||
| ---- | ----- |
|
||||
| Save-relative path | `imports/mods/` |
|
||||
| MTP destination | `1: SD Card/<save identity>/imports/mods/` (see launcher notice for the live `getSaveDirectory()` path) |
|
||||
| Candidates | `*.zip` only |
|
||||
| Rescan | MODS tab → **Scan again** (installs each zip via `LauncherMods.installZip`; source zips are retained on success and failure) |
|
||||
| FIND MODS | Remains network-gated / hidden on NX (`networkValidated == false`) |
|
||||
|
||||
Do **not** commit third-party mod zip bytes into git. Drop the zip over MTP, rescan, enable in MODS, then Play.
|
||||
|
||||
**MTP tip (esp. macOS clients):** OpenMTP/Finder often creates AppleDouble sidecars named `._Something.zip` / `._cart.gb` / `._foo.sav`. Those are not real archives, ROMs, or saves — the launcher ignores hidden `.*` names under `imports/`, `imports/mods/`, and `imports/saves/<game>/`. If install still fails with “could not be opened” / “not a zip file”, delete any `._*` under the inbox and confirm the real zip starts with the `PK` magic (re-copy the release asset if unsure). This is a host-side annoyance of the current manual MTP loop, not something players should need forever.
|
||||
|
||||
Drop any community release `.zip` into `imports/mods/`, rescan, enable.
|
||||
Player-facing install steps: [switch-install.md](switch-install.md#community-mods).
|
||||
Mods own their OPTIONS / rebinds — do not duplicate third-party control tables here.
|
||||
|
||||
## Save `.sav` inbox (NX)
|
||||
|
||||
Raw Gen1 battery images use a **separate** MTP inbox (not mixed into ROM `imports/` or mod `imports/mods/`):
|
||||
|
||||
| Item | Value |
|
||||
| ---- | ----- |
|
||||
| Save-relative path | `imports/saves/red/`, `imports/saves/blue/`, `imports/saves/yellow/` |
|
||||
| MTP destination | `1: SD Card/<save identity>/imports/saves/<game>/` (see launcher notice for the live `getSaveDirectory()` path) |
|
||||
| Candidates | non-hidden `*.sav` only in **that game’s** folder |
|
||||
| Rescan | SAVE FILES → **Import save** on the matching game tab (scans only that folder) |
|
||||
| After success | Retire to `*.sav.imported` + append content hash to `imports/saves/<game>/.imported-sha1` |
|
||||
| Exports | **Export save** writes under `exports/<game>/gen1recomp-<game>-<slot>.sav`; NX shows an MTP path notice (no `openURL`) |
|
||||
|
||||
Do **not** commit `.sav` bytes into git. Drop the file into the matching game folder over MTP, press **Import save** on that tab, then play. Pull exports from `exports/<game>/`.
|
||||
|
||||
**MTP tip:** the same AppleDouble `._*.sav` rule applies — see the mod inbox tip above.
|
||||
|
||||
## Joy-Con display chords (Select + face)
|
||||
|
||||
PC digit hotkeys for COLORS / TILT / GBC FX / pipelines have Joy-Con equivalents. Hold **Select** (`back` / −) and press a face/shoulder button; the engine runs the same path as `Game:keypressed` for that digit (including `writeOptions` / Pipelines parity).
|
||||
|
||||
| Chord (Nintendo UX) | Engine key | Stock engine effect |
|
||||
| ------------------- | ---------- | ------------------- |
|
||||
| Select + **A** | `2` | COLORS cycle |
|
||||
| Select + **B** | `3` | TILT / perspective |
|
||||
| Select + **Y** | `5` | GBC FX |
|
||||
| Select + **X** | `6` | Mod pipeline hotkey (if registered) |
|
||||
| Select + **L** (left shoulder) | `7` | Mod pipeline hotkey (if registered) |
|
||||
|
||||
Keys `2` / `3` / `4` / `5` are claimed by the engine before mod pipeline hotkeys run, so a community mod cannot rebind those digits through `Pipelines.hotkey`. Mods that need their own controls should use OPTIONS rows or unclaimed hotkeys.
|
||||
|
||||
Without Select held, face buttons keep normal GB A/B gameplay mapping (no accidental color/tilt cycles). The **Options** menu remains available for the same settings — chords are optional shortcuts, not the only path.
|
||||
|
||||
On NX, A/B chords resolve through the Nintendo UX face remap so physical **A** → key `2` and physical **B** → key `3` match this table.
|
||||
|
||||
**OPTIONS → PERFORMANCE** clamps the port’s own extras (TILT / GBC FX / survey ZOOM) and can cap FPS — useful on weaker handheld budgets. Details: [new-features.md — Performance tier](new-features.md#performance-tier-low-end-devices).
|
||||
|
||||
Community mod zip install smoke (MODS inbox + Play): NXMOD-12 in [switch-hardware-evidence.md](switch-hardware-evidence.md).
|
||||
|
||||
**Opt-in diagnostics:** create an empty `switch-debug.txt` in the save directory; events flush to `switch.log` at ≤1 Hz with build identity (no ROM/save bytes).
|
||||
|
||||
**NX asset probe (always on Play):** every Switch Play writes `nx-asset-probe.log` in the save directory (`pokemon-love2d/`). It lists whether `assets/generated/…` vs `yellow|blue/assets/generated/…` exist, what `Assets.resolve` returns, and whether `newImage` / `newImageData` open — for Yellow/Blue blank-sprite triage. No ROM bytes.
|
||||
|
||||
**Blue/Yellow cache overlay (NX):** fused love-nx cannot reliably mount `yellow|blue/assets/generated` onto the un-prefixed path, so `src/core/NxAssetOverlay.lua` wraps EVERY read-side love API that accepts a filesystem path (`filesystem.read/load/lines/newFileData/getInfo`, `graphics.newImage/newFont`, `image.newImageData`, `audio.newSource`, `sound.newSoundData`, `font.newFontData`) once at boot — only when `Platform.isNX()`. Covering the whole read surface (not just the loaders the boot needs today) keeps future states and mods inside the fallback automatically; write-side functions stay stock. Core code must NOT call love loaders on literal `assets/generated` paths (enforced by `tests/engine/nx_generated_guard_test.lua`); the chip-audio worker is a separate Lua state and gets the prefix explicitly via `audio.programPrefix` from `ChipAudio.slimAudio`.
|
||||
|
||||
**Hardware re-test:** T16 **pass** @ `2699c9a` (naming A=confirm / B=cancel). T19 **pass** (quit/reopen, suspend×10, reboot) — operator 2026-08-01.
|
||||
|
||||
**Suspend/resume audio:** after resume, chip music is stopped to avoid duplicate streams; confirm on hardware during P0-09/10 (T19).
|
||||
|
||||
## Lua error log (save directory)
|
||||
|
||||
On any uncaught Lua error, Gen1Recomp appends a redacted trace to `lua-error.log` in the LÖVE save directory (`love.filesystem.getSaveDirectory()`). The on-screen error overlay includes a hint pointing at that file. Logs rotate to `lua-error.log.1` when the active file exceeds 32 KiB. ROM/save bytes and non-printable data are stripped — never commit or share logs that might contain private paths without reviewing them first.
|
||||
|
||||
## Native crash triage (love-nx / Atmosphère)
|
||||
|
||||
love-nx native faults land under the console’s `crash_reports/` folder on SD (reachable via the same manual MTP workflow used for game deploys).
|
||||
|
||||
1. **Collect** — DBI → `Run MTP responder`; copy `sdmc:/crash_reports/*.bin` (or the dated subfolder) to the contributor host. Prefer keeping the microSD in-console for routine pulls.
|
||||
2. **Redact** — delete any attached screenshots or notes that mention ROM filenames, save paths, or private hashes before sharing logs publicly.
|
||||
3. **Symbolize** — use the **pinned** `love.elf` from `.bazinga/love-nx/11.5-nx1/` that matches `build-info.json` / `scripts/switch/love-nx-11.5-nx1.sha256`. Never use a “latest” download.
|
||||
|
||||
```bash
|
||||
# Example: aarch64-none-elf-addr2line from devkitPro
|
||||
aarch64-none-elf-addr2line -e .bazinga/love-nx/11.5-nx1/love.elf -f -C 0xADDRESS_FROM_CRASH_REPORT
|
||||
```
|
||||
|
||||
4. **Correlate** — compare `gitCommit` / `loveNxTag` from embedded `build-info.json` with the operator’s hardware notes.
|
||||
|
||||
If `addr2line` cannot resolve an address, archive the crash `.bin` with the exact `love.elf` SHA-256 used for the build — addresses are only meaningful against that ELF.
|
||||
|
||||
## P0 / P1 hardware matrix (ADR §9)
|
||||
|
||||
Operator evidence lives in `docs/switch-hardware-evidence.md`. **Do not invent passes** for rows that require hardware not yet run.
|
||||
|
||||
| ID | Requirement | Status | Evidence |
|
||||
| -- | ----------- | ------ | -------- |
|
||||
| P0-0a–f | love-nx pin, probe, MTP, title override | **pass** | Phase 0 checklist above; T4 |
|
||||
| P0-1a–d | Unpatched launcher boot + Joy-Con nav | **pass** | T4 / `docs/switch-hardware-evidence.md` |
|
||||
| P0-02 | MTP inbox import path shown | **pass** | T12 |
|
||||
| P0-03 | Rescan imports ROM | **pass** | T12 |
|
||||
| P0-04 | Canonical hash routes version | **pass** | T12 |
|
||||
| P0-05 | Source dump retained in inbox | **pass** | T12 |
|
||||
| P0-06 | Play reaches game after import | **pass** | T12 |
|
||||
| P0-07 | Joy-Con launcher navigation | **pass** | T16 @ `2699c9a` |
|
||||
| P0-08 | Joy-Con gameplay (incl. naming A/B) | **pass** | T16 @ `2699c9a` |
|
||||
| P0-09 | Save survives quit + reopen | **pass** | T19 |
|
||||
| P0-10 | ≥10 suspend cycles, no stuck input/dup audio | **pass** | T19 (operator 2026-08-01) |
|
||||
| P0-12 | Fused NRO boots without adjacent `game.love` | **pass** | T24 — `docs/switch-hardware-evidence.md` |
|
||||
| P0-14 | Fused NRO MTP round-trip SHA-256 | **pass** | T24 — first artifact `b019e2e8…` @ `6fb5602` (redeploy after Blue fix) |
|
||||
| P0-15 | Replace NRO only; saves persist | **pass** | T24 — operator NRO-only update keeps saves |
|
||||
| P1-01 | Docked vs handheld spot-check | **deferred** | Code: `NxDisplay` 720p↔1080p; OLED dock soak not recorded yet |
|
||||
| P1-02 | Applet Mode documented unsupported | **pass** | Title override required; Album path not validated |
|
||||
| P1-03 | Long-play soak (≥30 min) | **deferred** | No soak session recorded |
|
||||
| P1-04 | Reboot persistence | **pass** | T19 |
|
||||
| P1-05 | Audio resume after suspend | **pass** | T19 (no dup audio reported) |
|
||||
| — | Switch V1 / Erista boot | **pass** (boot) | Community — [booshankles](https://github.com/booshankles); see evidence log |
|
||||
| — | Switch Lite / docked soak | **untested** / **deferred** | Welcome contributions |
|
||||
| — | Automated / `nxlink` deploy | **absent** | Manual MTP / SD / FTP only (AD-009) |
|
||||
| — | Multi-OS transfer runbooks | **pass** | [switch-transfer.md](switch-transfer.md) |
|
||||
| — | Community mod zip OLED smoke (NXMOD-12) | **pass** | `docs/switch-hardware-evidence.md` |
|
||||
|
||||
## Review guidance
|
||||
|
||||
Maintainers may review as one PR or split later. Suggested slices (optional):
|
||||
|
||||
Each slice should declare: **no ROM/save bytes committed**, **love-nx pin with manifest checksums**, **hardware-tested rows listed with linked evidence**, **Applet Mode unsupported**, **network/updater disabled on NX**, **deploy still manual** (MTP / SD / FTP; no nxlink yet), **OpenMTP is one example not the sole contract**.
|
||||
|
||||
### Slice 1 — Platform + import (`platform/import`)
|
||||
|
||||
- `src/core/Platform.lua`, `conf.lua` NX branch
|
||||
- `src/import/RomImporter.lua` (NX flags, inbox, scan, shell/updater gates)
|
||||
- Tests: `tests/engine/platform_nx_*`, `tests/engine/rom_importer_nx_*` (ROM-free T2)
|
||||
- Docs: inbox/MTP import sections only
|
||||
|
||||
### Slice 2 — Input + lifecycle (`input/lifecycle`)
|
||||
|
||||
- `src/core/GamepadMap.lua`, `Input.lua`, `main.lua` focus/joystick hooks
|
||||
- `src/debug/SwitchDiagnostics.lua` (opt-in probe + error log)
|
||||
- Tests: input/diagnostics suites
|
||||
- Docs: controller mapping, suspend/audio notes
|
||||
|
||||
### Slice 3 — Build + docs (`build/docs`)
|
||||
|
||||
- `scripts/pack_love.sh`, `scripts/build_switch.sh`, `scripts/switch/*`
|
||||
- `assets/switch/icon.jpg`, `docs/switch-development.md`, hardware evidence templates
|
||||
- Gates: `pack_love.sh --dry-run`, `verify_payload.sh --self-test`, fused build script (devkitPro host)
|
||||
|
||||
**Pre-merge checklist:**
|
||||
|
||||
- [ ] Manifest `scripts/switch/love-nx-11.5-nx1.sha256` filled; binaries not in git
|
||||
- [ ] `verify_payload.sh` rejects generated cache / ROM / `.sav` / `.bak`
|
||||
- [ ] P0 matrix rows marked pass only with linked hardware evidence
|
||||
- [x] Fused NRO P0-12/14/15 pass with T24 evidence (`docs/switch-hardware-evidence.md`)
|
||||
- [ ] Updater / remote mod download hidden on NX (`networkValidated == false`)
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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/<game>/`** folder via MTP / SD / FTP.
|
||||
|
||||
Do not put `.sav` files into git. Prefer clean copies — some MTP clients
|
||||
create `._*.sav` AppleDouble sidecars that are not real saves.
|
||||
|
||||
## Controls
|
||||
|
||||
### Gameplay
|
||||
|
||||
| Control | Action |
|
||||
| ------- | ------ |
|
||||
| D-pad / left stick | Move |
|
||||
| **A** | Confirm |
|
||||
| **B** | Cancel |
|
||||
| **+** (Start) | Start |
|
||||
| **−** (Select) | Select |
|
||||
| **R** (no Select held) | Cycle game speed up |
|
||||
| **L** (no Select held) | Cycle game speed down |
|
||||
|
||||
### Launcher
|
||||
|
||||
| Control | Action |
|
||||
| ------- | ------ |
|
||||
| D-pad / left stick | Move virtual cursor |
|
||||
| **A** | Click at cursor |
|
||||
| **L** / **R** | Previous / next tab |
|
||||
| **Start** / **Select** | Play if a ROM is ready; otherwise Choose ROM |
|
||||
|
||||
### System
|
||||
|
||||
| Control | Action |
|
||||
| ------- | ------ |
|
||||
| Hold **R** on HOME, then open from hbmenu | Title override (full memory) |
|
||||
|
||||
## Community mods
|
||||
|
||||
Mods install from a zip inbox (same transfer methods as ROMs):
|
||||
|
||||
1. Copy a release `.zip` into the save-dir **`imports/mods/`** path the
|
||||
launcher shows (MTP / SD / FTP — [switch-transfer.md](switch-transfer.md)).
|
||||
2. In the launcher, open **MODS** → **Scan again** → enable the mod →
|
||||
**Play**.
|
||||
|
||||
Remote **FIND MODS** / GitHub download stays **off** on Switch. Do not put
|
||||
mod zips into git. Community mods ship their own OPTIONS / rebinds — this port
|
||||
does not document third-party control tables.
|
||||
|
||||
### Joy-Con shortcuts (Select + face)
|
||||
|
||||
Hold **Select** (−) and press a face/shoulder button. Without Select, A/B stay
|
||||
normal gameplay confirm/cancel. These chords are the stock engine display
|
||||
hotkeys (`2`/`3`/`5` are claimed before any mod pipeline hotkey runs).
|
||||
|
||||
| Chord | Same as PC key | Stock engine effect |
|
||||
| ----- | -------------- | ------------------- |
|
||||
| Select + **A** | `2` | COLORS |
|
||||
| Select + **B** | `3` | TILT |
|
||||
| Select + **Y** | `5` | GBC FX |
|
||||
| Select + **X** | `6` | Mod pipeline hotkey (if a mod registers `6`) |
|
||||
| Select + **L** | `7` | Mod pipeline hotkey (if a mod registers `7`) |
|
||||
|
||||
If the handheld stutters with extras on, try **OPTIONS → PERFORMANCE** →
|
||||
`LOW` or `BALANCED`. Full chord notes for contributors:
|
||||
[switch-development.md](switch-development.md#joy-con-display-chords-select--face).
|
||||
|
||||
## Prefer building it yourself?
|
||||
|
||||
Building the fused NRO (and SD-ready zip) from source is covered in
|
||||
[switch-build.md](switch-build.md). Copying artifacts and inbox files
|
||||
(MTP / SD / FTP on macOS, Linux, Windows): [switch-transfer.md](switch-transfer.md).
|
||||
Status, limitations, and how we tested: [switch-development.md](switch-development.md).
|
||||
@@ -0,0 +1,168 @@
|
||||
# Switch file transfer (MTP / SD / FTP)
|
||||
|
||||
Canonical ways to put Gen1Recomp artifacts and inbox files onto a Nintendo
|
||||
Switch. **Any method is valid** if the bytes land in the destinations below.
|
||||
|
||||
This is the home runbook for contributors on **macOS, Linux, and Windows**.
|
||||
Player install (what to download, title override) stays in
|
||||
[switch-install.md](switch-install.md). Packaging stays in
|
||||
[switch-build.md](switch-build.md). Hardware evidence lives in
|
||||
[switch-hardware-evidence.md](switch-hardware-evidence.md).
|
||||
|
||||
> **Not supported yet:** `nxlink` / hbmenu netloader automation. Useful later
|
||||
> for a fast contrib rebuild loop; deferred on purpose (AD-009). Do not treat
|
||||
> netloader as the release or ROM/mod install path.
|
||||
|
||||
---
|
||||
|
||||
## Destinations (shared by every method)
|
||||
|
||||
| What | Where on the console |
|
||||
| ---- | -------------------- |
|
||||
| SD-ready release zip | Extract at microSD **root** → `sdmc:/switch/gen1recomp/gen1recomp.nro` plus `pokemon-love2d/` inbox folders. Install and update use the same merge; do **not** delete `pokemon-love2d/` |
|
||||
| Loose iteration pair | `sdmc:/switch/gen1recomp/gen1recomp.nro` **and** `game.love` beside it |
|
||||
| ROM inbox | LÖVE save dir → `imports/` (launcher shows the live `getSaveDirectory()` path; under MTP often `1: SD Card/<save identity>/imports/`) |
|
||||
| Mod zip inbox | Same save dir → `imports/mods/` then MODS → **Scan again** |
|
||||
| Save `.sav` inbox | Same save dir → `imports/saves/red\|blue\|yellow/` then that 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/<red|blue|yellow>/`, or `exports/<red|blue|yellow>/` path the
|
||||
launcher prints.
|
||||
5. Wait for the queue; refresh; exit MTP responder; title-override launch.
|
||||
|
||||
macOS clients often create AppleDouble sidecars (`._Something.zip`,
|
||||
`._cart.gb`, `._foo.sav`). Those are not real archives or saves — the
|
||||
launcher skips hidden `.*` names. Delete `._*` junk if a zip/ROM/`.sav`
|
||||
fails to open.
|
||||
|
||||
#### Linux
|
||||
|
||||
1. Install desktop MTP support if needed (e.g. `gvfs-mtp` on GNOME/GTK
|
||||
desktops, or your 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/<game>/`,
|
||||
and `exports/<game>/` paths.
|
||||
3. Stop the FTP server cleanly before launching Gen1Recomp.
|
||||
|
||||
If credentials or chroots differ by app, trust the **destination paths**, not
|
||||
a single vendor tutorial.
|
||||
|
||||
---
|
||||
|
||||
## After every transfer
|
||||
|
||||
1. Exit MTP / unmount SD / stop FTP cleanly.
|
||||
2. Launch via **title override** (hold **R** on a title → hbmenu). **Applet
|
||||
Mode is not supported** (not enough memory).
|
||||
3. For ROMs: open the matching game tab → **Scan again** if the file was
|
||||
added after boot (SHA-1 must match that tab; other dumps in `imports/`
|
||||
stay for their own tabs). For mods: MODS → **Scan again** → enable →
|
||||
Play. For saves: SAVE FILES → **Import save** (rescans
|
||||
`imports/saves/<game>/`). Pull exported `.sav` files from
|
||||
`exports/<game>/`. Joy-Con display chords (stock engine):
|
||||
[switch-install.md](switch-install.md#joy-con-shortcuts-select--face).
|
||||
|
||||
### Optional NRO integrity check
|
||||
|
||||
For the first deploy of a given artifact (or after a flaky cable):
|
||||
|
||||
```bash
|
||||
shasum -a 256 path/to/gen1recomp.nro # or sha256sum
|
||||
```
|
||||
|
||||
Copy the file back from the SD and compare hashes. Round-trip must match.
|
||||
|
||||
---
|
||||
|
||||
## Failure modes (quick)
|
||||
|
||||
| Symptom | What to try |
|
||||
| ------- | ----------- |
|
||||
| Device busy / no MTP volume | One client only; different cable/port; Windows MTP USB Device driver; alternate method (SD or FTP) |
|
||||
| Zip/ROM/`.sav` “could not be opened” | Delete `._*` sidecars (including `._*.sav`); confirm real zip starts with `PK` |
|
||||
| Half-copied NRO / crash on boot | Re-copy; verify SHA-256; exit transfer mode before launch |
|
||||
| App opens in Applet Mode | Use title override (hold **R**), not Album |
|
||||
|
||||
---
|
||||
|
||||
## Related
|
||||
|
||||
- Players: [switch-install.md](switch-install.md)
|
||||
- Builders: [switch-build.md](switch-build.md)
|
||||
- Status / hardware matrix: [switch-development.md](switch-development.md)
|
||||
- Evidence log: [switch-hardware-evidence.md](switch-hardware-evidence.md)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -30,6 +30,45 @@ public final class GRPickerBridge: NSObject {
|
||||
// (<sandbox>/Library/Application Support/<identity>).
|
||||
private static let loveIdentity = "pokemon-love2d"
|
||||
|
||||
@objc(httpDownloadWithUrl:destination:userAgent:accept:)
|
||||
public static func httpDownload(url: UnsafePointer<CChar>?,
|
||||
destination: UnsafePointer<CChar>?,
|
||||
userAgent: UnsafePointer<CChar>?,
|
||||
accept: UnsafePointer<CChar>?) -> Bool {
|
||||
guard let url, let destination,
|
||||
let requestURL = URL(string: String(cString: url)) else { return false }
|
||||
var request = URLRequest(url: requestURL)
|
||||
request.timeoutInterval = 300
|
||||
if let userAgent, userAgent.pointee != 0 {
|
||||
request.setValue(String(cString: userAgent), forHTTPHeaderField: "User-Agent")
|
||||
}
|
||||
if let accept, accept.pointee != 0 {
|
||||
request.setValue(String(cString: accept), forHTTPHeaderField: "Accept")
|
||||
}
|
||||
let target = URL(fileURLWithPath: String(cString: destination))
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
var succeeded = false
|
||||
let task = URLSession.shared.downloadTask(with: request) { temporary, response, error in
|
||||
defer { semaphore.signal() }
|
||||
guard error == nil, let temporary,
|
||||
let http = response as? HTTPURLResponse,
|
||||
(200..<300).contains(http.statusCode) else { return }
|
||||
try? FileManager.default.removeItem(at: target)
|
||||
do {
|
||||
try FileManager.default.moveItem(at: temporary, to: target)
|
||||
succeeded = true
|
||||
} catch {
|
||||
succeeded = false
|
||||
}
|
||||
}
|
||||
task.resume()
|
||||
guard semaphore.wait(timeout: .now() + 305) == .success else {
|
||||
task.cancel()
|
||||
return false
|
||||
}
|
||||
return succeeded
|
||||
}
|
||||
|
||||
// MARK: - Entry points called from liblove (C strings on purpose)
|
||||
|
||||
@objc(presentPickerWithKind:saveDir:)
|
||||
|
||||
@@ -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():
|
||||
|
||||
Executable
+148
@@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env bash
|
||||
# Nintendo Switch packaging entry point.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/build_switch.sh --fetch
|
||||
# scripts/build_switch.sh --loose [path/to/game.love]
|
||||
# scripts/build_switch.sh --fused [--version X.Y.Z]
|
||||
# scripts/build_switch.sh --fetch --loose
|
||||
# scripts/build_switch.sh --fetch --fused [--version X.Y.Z]
|
||||
#
|
||||
# Modes:
|
||||
# --fetch Download pinned love.nro + love.elf into
|
||||
# .bazinga/love-nx/11.5-nx1/ and verify SHA-256 against
|
||||
# scripts/switch/love-nx-11.5-nx1.sha256.
|
||||
# Auto-downloads those two release assets only.
|
||||
# Does NOT install devkitPro / dkp-pacman.
|
||||
#
|
||||
# --loose Pack game.love and copy pinned love.nro → dist/switch/loose/
|
||||
# (gen1recomp.nro + game.love side by side). Requires the pin
|
||||
# (run --fetch first, or combine --fetch --loose).
|
||||
#
|
||||
# --fused Build a single gen1recomp-<ver>-switch.nro via nacptool+elf2nro,
|
||||
# then pack dist/switch/gen1recomp-<ver>-switch.zip (SD-ready
|
||||
# tree under switch/gen1recomp/). Uses native tools (PATH or
|
||||
# $DEVKITPRO/tools/bin) first; else Docker from
|
||||
# scripts/switch/dkp-docker.image (override GEN1_DKP_IMAGE).
|
||||
# Requires the pin (run --fetch or combine). GitHub Releases
|
||||
# publish the zip only; the versioned .nro stays local / PR CI.
|
||||
#
|
||||
# Combinable: --fetch alone, or --fetch with --loose / --fused.
|
||||
# XOR: --loose and --fused cannot be used together.
|
||||
#
|
||||
# Non-goals (never done by this script):
|
||||
# MTP push, ROM install, dkp-pacman auto-install.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
WORK="$ROOT/.bazinga/work"
|
||||
DIST="$ROOT/dist/switch"
|
||||
LOOSE=0
|
||||
FUSED=0
|
||||
FETCH=0
|
||||
GAME_LOVE=""
|
||||
VERSION="$(git -C "$ROOT" rev-parse --short HEAD 2>/dev/null || echo dev)"
|
||||
|
||||
# Progress on stderr so command-substitution of pack_game_love stays a bare path.
|
||||
say() { printf '\033[1;32m==>\033[0m %s\n' "$*" >&2; }
|
||||
fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
usage() {
|
||||
sed -n '2,34p' "$0" | sed 's/^# \{0,1\}//'
|
||||
}
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--loose) LOOSE=1; shift ;;
|
||||
--fused) FUSED=1; shift ;;
|
||||
--fetch) FETCH=1; shift ;;
|
||||
--version) VERSION="$2"; shift 2 ;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
if [ -z "$GAME_LOVE" ]; then
|
||||
GAME_LOVE="$1"
|
||||
else
|
||||
fail "unknown argument: $1"
|
||||
fi
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "$LOOSE" -eq 1 ] && [ "$FUSED" -eq 1 ]; then
|
||||
fail "choose one of --loose or --fused (XOR)"
|
||||
fi
|
||||
|
||||
if [ "$FETCH" -eq 0 ] && [ "$LOOSE" -eq 0 ] && [ "$FUSED" -eq 0 ]; then
|
||||
fail "specify --fetch, --loose, and/or --fused (see --help)"
|
||||
fi
|
||||
|
||||
if [ "$FETCH" -eq 1 ]; then
|
||||
"$ROOT/scripts/switch/fetch_love_nx.sh"
|
||||
fi
|
||||
|
||||
pack_game_love() {
|
||||
mkdir -p "$WORK" "$DIST"
|
||||
local build_info="$WORK/build-info.json"
|
||||
"$ROOT/scripts/switch/write_build_info.sh" "$build_info" "$VERSION"
|
||||
local love_out="$WORK/game.love"
|
||||
local listing="$WORK/love-listing.txt"
|
||||
"$ROOT/scripts/pack_love.sh" \
|
||||
--output "$love_out" \
|
||||
--listing "$listing" \
|
||||
--build-info "$build_info" >/dev/null
|
||||
# Stamp release version into the archive (same as build.sh / build_android.sh).
|
||||
# Working tree keeps 0.0.0-dev; only X.Y.Z --version patches Version.lua in-place.
|
||||
if printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
say "stamping engine version $VERSION into game.love"
|
||||
local stamp_dir="$WORK/stamp"
|
||||
rm -rf "$stamp_dir"
|
||||
mkdir -p "$stamp_dir/src/core"
|
||||
sed -E "s/(engine[[:space:]]*=[[:space:]]*\")[^\"]*(\")/\1$VERSION\2/" \
|
||||
"$ROOT/src/core/Version.lua" > "$stamp_dir/src/core/Version.lua"
|
||||
(cd "$stamp_dir" && zip -q "$love_out" src/core/Version.lua)
|
||||
local version_re
|
||||
version_re="$(printf '%s' "$VERSION" | sed 's/\./\\./g')"
|
||||
unzip -p "$love_out" src/core/Version.lua \
|
||||
| grep -Eq "engine[[:space:]]*=[[:space:]]*\"$version_re\"" \
|
||||
|| fail "version stamp failed: game.love does not report engine $VERSION"
|
||||
say "stamped engine version: $VERSION"
|
||||
else
|
||||
say "version '$VERSION' is not X.Y.Z, shipping default engine (no stamp)"
|
||||
fi
|
||||
cp "$build_info" "$DIST/build-info.json"
|
||||
cp "$build_info" "$DIST/gen1recomp-${VERSION}-build-info.json"
|
||||
printf '%s' "$love_out"
|
||||
}
|
||||
|
||||
if [ "$LOOSE" -eq 1 ]; then
|
||||
if [ -z "$GAME_LOVE" ]; then
|
||||
GAME_LOVE="$(pack_game_love)"
|
||||
else
|
||||
pack_game_love >/dev/null
|
||||
fi
|
||||
exec "$ROOT/scripts/switch/assemble_loose.sh" "$GAME_LOVE"
|
||||
fi
|
||||
|
||||
if [ "$FUSED" -eq 1 ]; then
|
||||
GAME_LOVE="$(pack_game_love)"
|
||||
OUT_NRO="$DIST/gen1recomp-${VERSION}-switch.nro"
|
||||
OUT_ZIP="$DIST/gen1recomp-${VERSION}-switch.zip"
|
||||
"$ROOT/scripts/switch/build_fused.sh" "$GAME_LOVE" "$VERSION" "$OUT_NRO"
|
||||
"$ROOT/scripts/switch/pack_sd_zip.sh" "$OUT_NRO" "$VERSION" "$OUT_ZIP"
|
||||
cp "$WORK/build-info.json" "$DIST/gen1recomp-${VERSION}-build-info.json"
|
||||
say "done. See $DIST/"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --fetch alone
|
||||
if [ "$FETCH" -eq 1 ]; then
|
||||
say "fetch complete"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
fail "specify --fetch, --loose, and/or --fused (see --help)"
|
||||
Executable
+90
@@ -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"
|
||||
Executable
+40
@@ -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"
|
||||
Executable
+113
@@ -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")"
|
||||
@@ -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
|
||||
)"
|
||||
}
|
||||
@@ -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
|
||||
Executable
+129
@@ -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"
|
||||
@@ -0,0 +1,11 @@
|
||||
# love-nx 11.5-nx1 — pinned runtime artifacts (RetronX team)
|
||||
# https://github.com/retronx-team/love-nx/releases/tag/11.5-nx1
|
||||
#
|
||||
# Download love.nro and love.elf from the release above and place them under:
|
||||
# .bazinga/love-nx/11.5-nx1/
|
||||
#
|
||||
# These binaries are NOT committed. After fetching, fill in the SHA-256 fields
|
||||
# below (run: shasum -a 256 .bazinga/love-nx/11.5-nx1/<file>).
|
||||
|
||||
love.nro 8290ac153d4c630e48c9b26ef9123f5204ed8ee0cef3042511707b5b645918f5
|
||||
love.elf f820d2f73ed72a8a24002ccf540e8a12b16b2b4c86b69e82c0d3629e99547175
|
||||
Executable
+128
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env bash
|
||||
# Pack a Switch SD-ready zip: extract at microSD root (merge-safe update).
|
||||
#
|
||||
# Usage:
|
||||
# scripts/switch/pack_sd_zip.sh NRO_PATH VERSION OUT_ZIP
|
||||
#
|
||||
# Layout inside the zip (SD root):
|
||||
# switch/gen1recomp/gen1recomp.nro
|
||||
# switch/gen1recomp/INSTALL.txt
|
||||
# switch/gen1recomp/pokemon-love2d/imports/.../README.txt
|
||||
# switch/gen1recomp/pokemon-love2d/exports/.../README.txt
|
||||
#
|
||||
# Does not ship ROMs, saves, or mods. Re-extracting merges over an existing
|
||||
# install and only overwrites the NRO + these text placeholders — keep
|
||||
# pokemon-love2d/ to preserve progress.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
# shellcheck source=common.sh
|
||||
. "$SCRIPT_DIR/common.sh"
|
||||
|
||||
NRO_PATH="${1:-}"
|
||||
VERSION="${2:-}"
|
||||
OUT_ZIP="${3:-}"
|
||||
|
||||
[ -n "$NRO_PATH" ] && [ -n "$VERSION" ] && [ -n "$OUT_ZIP" ] \
|
||||
|| fail "usage: scripts/switch/pack_sd_zip.sh NRO_PATH VERSION OUT_ZIP"
|
||||
|
||||
[ -f "$NRO_PATH" ] || fail "missing NRO at $NRO_PATH"
|
||||
|
||||
command -v zip >/dev/null 2>&1 || fail "need zip on PATH"
|
||||
|
||||
# Absolutize before any cd — relative OUT_ZIP would otherwise land inside the
|
||||
# staging dir and vanish when the EXIT trap cleans up.
|
||||
NRO_PATH="$(cd "$(dirname "$NRO_PATH")" && pwd)/$(basename "$NRO_PATH")"
|
||||
OUT_DIR="$(dirname "$OUT_ZIP")"
|
||||
mkdir -p "$OUT_DIR"
|
||||
OUT_ZIP="$(cd "$OUT_DIR" && pwd)/$(basename "$OUT_ZIP")"
|
||||
|
||||
STAGE="$(mktemp -d "${TMPDIR:-/tmp}/gen1recomp-sd-zip.XXXXXX")"
|
||||
cleanup() { rm -rf "$STAGE"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
APP_DIR="$STAGE/switch/gen1recomp"
|
||||
SAVE_ROOT="$APP_DIR/pokemon-love2d"
|
||||
mkdir -p "$APP_DIR"
|
||||
cp "$NRO_PATH" "$APP_DIR/gen1recomp.nro"
|
||||
|
||||
cat > "$APP_DIR/INSTALL.txt" <<EOF
|
||||
gen1recomp Switch (v${VERSION})
|
||||
===============================
|
||||
|
||||
First install or update (same steps):
|
||||
1. Extract this zip at the root of your microSD (merge folders if asked).
|
||||
2. Do NOT delete switch/gen1recomp/pokemon-love2d/ — that folder holds
|
||||
your saves, imported ROMs, mods, and options. Re-extracting only
|
||||
replaces gen1recomp.nro and these help files.
|
||||
3. Launch with title override (hold R on HOME, open any title → hbmenu).
|
||||
4. Copy a legal Pokemon Red/Blue .gb into:
|
||||
switch/gen1recomp/pokemon-love2d/imports/
|
||||
then use Scan again in the launcher if needed.
|
||||
|
||||
Inboxes (drop files here via MTP / SD / FTP):
|
||||
imports/ — ROM .gb / .gbc
|
||||
imports/mods/ — community mod .zip
|
||||
imports/saves/red|blue|yellow/ — raw .sav import
|
||||
exports/red|blue|yellow/ — pull after Export save
|
||||
|
||||
Full guide: https://github.com/bryanthaboi/gen1recomp/blob/main/docs/switch-install.md
|
||||
EOF
|
||||
|
||||
write_readme() {
|
||||
local path="$1"
|
||||
local body="$2"
|
||||
mkdir -p "$(dirname "$path")"
|
||||
printf '%s\n' "$body" > "$path"
|
||||
}
|
||||
|
||||
write_readme "$SAVE_ROOT/imports/README.txt" \
|
||||
"Put a legal Pokemon Red or Blue .gb / .gbc here, then Scan again in the launcher."
|
||||
write_readme "$SAVE_ROOT/imports/mods/README.txt" \
|
||||
"Put community mod .zip files here, then MODS → Scan again."
|
||||
write_readme "$SAVE_ROOT/imports/saves/red/README.txt" \
|
||||
"Put a Red .sav (32 KB) here, then Red tab → SAVE FILES → Import save."
|
||||
write_readme "$SAVE_ROOT/imports/saves/blue/README.txt" \
|
||||
"Put a Blue .sav (32 KB) here, then Blue tab → SAVE FILES → Import save."
|
||||
write_readme "$SAVE_ROOT/imports/saves/yellow/README.txt" \
|
||||
"Put a Yellow .sav (32 KB) here, then Yellow tab → SAVE FILES → Import save."
|
||||
write_readme "$SAVE_ROOT/exports/red/README.txt" \
|
||||
"After Export save (Red), copy the .sav out of this folder via MTP / SD / FTP."
|
||||
write_readme "$SAVE_ROOT/exports/blue/README.txt" \
|
||||
"After Export save (Blue), copy the .sav out of this folder via MTP / SD / FTP."
|
||||
write_readme "$SAVE_ROOT/exports/yellow/README.txt" \
|
||||
"After Export save (Yellow), copy the .sav out of this folder via MTP / SD / FTP."
|
||||
|
||||
rm -f "$OUT_ZIP"
|
||||
(
|
||||
cd "$STAGE"
|
||||
zip -q -r "$OUT_ZIP" switch
|
||||
)
|
||||
|
||||
[ -f "$OUT_ZIP" ] || fail "zip was not created at $OUT_ZIP"
|
||||
[ -s "$OUT_ZIP" ] || fail "zip is empty: $OUT_ZIP"
|
||||
|
||||
LISTING="$(unzip -Z1 "$OUT_ZIP" 2>/dev/null || unzip -l "$OUT_ZIP")"
|
||||
printf '%s\n' "$LISTING" | grep -q 'switch/gen1recomp/gen1recomp.nro' \
|
||||
|| fail "zip missing switch/gen1recomp/gen1recomp.nro"
|
||||
|
||||
REQUIRED=(
|
||||
"switch/gen1recomp/INSTALL.txt"
|
||||
"switch/gen1recomp/pokemon-love2d/imports/README.txt"
|
||||
"switch/gen1recomp/pokemon-love2d/imports/mods/README.txt"
|
||||
"switch/gen1recomp/pokemon-love2d/imports/saves/red/README.txt"
|
||||
"switch/gen1recomp/pokemon-love2d/imports/saves/blue/README.txt"
|
||||
"switch/gen1recomp/pokemon-love2d/imports/saves/yellow/README.txt"
|
||||
"switch/gen1recomp/pokemon-love2d/exports/red/README.txt"
|
||||
"switch/gen1recomp/pokemon-love2d/exports/blue/README.txt"
|
||||
"switch/gen1recomp/pokemon-love2d/exports/yellow/README.txt"
|
||||
)
|
||||
for rel in "${REQUIRED[@]}"; do
|
||||
printf '%s\n' "$LISTING" | grep -Fq "$rel" || fail "zip missing $rel"
|
||||
done
|
||||
|
||||
ZIP_SHA="$(sha256_file "$OUT_ZIP")"
|
||||
printf '%s %s\n' "$ZIP_SHA" "$OUT_ZIP" > "${OUT_ZIP}.sha256"
|
||||
say "SD-ready zip: $OUT_ZIP"
|
||||
printf '%s %s\n' "$ZIP_SHA" "$OUT_ZIP"
|
||||
Executable
+322
@@ -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
|
||||
Executable
+38
@@ -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
|
||||
Executable
+107
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env bash
|
||||
# Reject private / generated content inside a .love payload.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/switch/verify_payload.sh <path/to/game.love>
|
||||
# scripts/switch/verify_payload.sh --self-test
|
||||
#
|
||||
# Fails on generated cache, ROM dumps (.gb/.gbc/.sav), rom-cache.complete,
|
||||
# and save backup files (.bak). Never ships user ROMs or save data.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
LOVE_FILE=""
|
||||
SELF_TEST=0
|
||||
|
||||
fail() { printf 'error: %s\n' "$*" >&2; exit 1; }
|
||||
say() { printf 'verify_payload: %s\n' "$*"; }
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--self-test) SELF_TEST=1; shift ;;
|
||||
-h|--help)
|
||||
sed -n '2,9p' "$0" | sed 's/^# \{0,1\}//'
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
if [ -z "$LOVE_FILE" ]; then
|
||||
LOVE_FILE="$1"
|
||||
else
|
||||
fail "unknown argument: $1"
|
||||
fi
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
verify_love() {
|
||||
local love="$1"
|
||||
local listing
|
||||
listing="$(mktemp "${TMPDIR:-/tmp}/love-listing.XXXXXX")"
|
||||
|
||||
[ -f "$love" ] || { rm -f "$listing"; fail "missing love archive: $love"; }
|
||||
unzip -Z1 "$love" > "$listing"
|
||||
|
||||
if grep -Eq '^(data|assets)/generated/|/(data|assets)/generated/' "$listing"; then
|
||||
rm -f "$listing"
|
||||
fail "forbidden generated cache path in $love"
|
||||
fi
|
||||
|
||||
if grep -Eiq '\.(gb|gbc|sav)$' "$listing"; then
|
||||
rm -f "$listing"
|
||||
fail "forbidden ROM or save file extension in $love"
|
||||
fi
|
||||
|
||||
if grep -Eq '(^|/)rom-cache\.complete$' "$listing"; then
|
||||
rm -f "$listing"
|
||||
fail "forbidden rom-cache.complete marker in $love"
|
||||
fi
|
||||
|
||||
if grep -Eiq '\.bak$' "$listing"; then
|
||||
rm -f "$listing"
|
||||
fail "forbidden save backup (.bak) in $love"
|
||||
fi
|
||||
|
||||
rm -f "$listing"
|
||||
say "OK $love"
|
||||
}
|
||||
|
||||
run_self_test() {
|
||||
local work clean bad staging
|
||||
work="$(mktemp -d "${TMPDIR:-/tmp}/verify-payload.XXXXXX")"
|
||||
trap "rm -rf '$work'" EXIT
|
||||
|
||||
clean="$work/clean.love"
|
||||
"$ROOT/scripts/pack_love.sh" --output "$clean" --listing "$work/clean-listing.txt" >/dev/null
|
||||
verify_love "$clean"
|
||||
|
||||
bad="$work/bad.love"
|
||||
cp "$clean" "$bad"
|
||||
staging="$work/staging"
|
||||
mkdir -p "$staging/data/generated"
|
||||
echo "secret" > "$staging/data/generated/rom.bin"
|
||||
(cd "$staging" && zip -q "$bad" data/generated/rom.bin)
|
||||
|
||||
if ( verify_love "$bad" >/dev/null 2>&1 ); then
|
||||
fail "self-test: expected forbidden generated path to fail"
|
||||
fi
|
||||
|
||||
bad2="$work/bad2.love"
|
||||
cp "$clean" "$bad2"
|
||||
echo "x" > "$work/rom-cache.complete"
|
||||
(cd "$work" && zip -q "$bad2" rom-cache.complete)
|
||||
if ( verify_love "$bad2" >/dev/null 2>&1 ); then
|
||||
fail "self-test: expected rom-cache.complete to fail"
|
||||
fi
|
||||
|
||||
say "self-test OK"
|
||||
}
|
||||
|
||||
if [ "$SELF_TEST" -eq 1 ]; then
|
||||
run_self_test
|
||||
exit 0
|
||||
fi
|
||||
|
||||
[ -n "$LOVE_FILE" ] || fail "usage: $0 <game.love> | --self-test"
|
||||
verify_love "$LOVE_FILE"
|
||||
Executable
+55
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env bash
|
||||
# Write build-info.json for Switch artifacts.
|
||||
#
|
||||
# Usage: scripts/switch/write_build_info.sh OUTPUT.json [VERSION]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
LOVE_NX_TAG="11.5-nx1"
|
||||
MANIFEST="$ROOT/scripts/switch/love-nx-11.5-nx1.sha256"
|
||||
OUTPUT="${1:-}"
|
||||
VERSION="${2:-}"
|
||||
|
||||
fail() { printf 'error: %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
[ -n "$OUTPUT" ] || fail "usage: $0 OUTPUT.json [VERSION]"
|
||||
[ -f "$MANIFEST" ] || fail "missing love-nx manifest: $MANIFEST"
|
||||
|
||||
if [ -z "$VERSION" ]; then
|
||||
VERSION="$(git -C "$ROOT" rev-parse --short HEAD 2>/dev/null || echo dev)"
|
||||
fi
|
||||
|
||||
GIT_COMMIT="$(git -C "$ROOT" rev-parse HEAD 2>/dev/null || echo unknown)"
|
||||
GIT_SHORT="$(git -C "$ROOT" rev-parse --short HEAD 2>/dev/null || echo dev)"
|
||||
BUILT_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
|
||||
read_manifest_hash() {
|
||||
local name="$1"
|
||||
local line hash
|
||||
line="$(grep -E "^${name}[[:space:]]+" "$MANIFEST" | head -1 || true)"
|
||||
[ -n "$line" ] || fail "manifest missing entry for $name"
|
||||
hash="$(printf '%s' "$line" | awk '{print $2}')"
|
||||
case "$hash" in
|
||||
TBD_*|"") fail "manifest hash for $name is not filled in ($hash)" ;;
|
||||
esac
|
||||
printf '%s' "$hash"
|
||||
}
|
||||
|
||||
LOVE_NRO_SHA="$(read_manifest_hash love.nro)"
|
||||
LOVE_ELF_SHA="$(read_manifest_hash love.elf)"
|
||||
|
||||
mkdir -p "$(dirname "$OUTPUT")"
|
||||
cat > "$OUTPUT" <<EOF
|
||||
{
|
||||
"version": "$VERSION",
|
||||
"gitCommit": "$GIT_SHORT",
|
||||
"gitCommitFull": "$GIT_COMMIT",
|
||||
"loveNxTag": "$LOVE_NX_TAG",
|
||||
"loveNxChecksums": {
|
||||
"love.nro": "$LOVE_NRO_SHA",
|
||||
"love.elf": "$LOVE_ELF_SHA"
|
||||
},
|
||||
"builtAt": "$BUILT_AT"
|
||||
}
|
||||
EOF
|
||||
@@ -65,6 +65,14 @@ run_tier() {
|
||||
|
||||
# ------- ROM-free tiers: these are what CI runs
|
||||
|
||||
run_tier "T0 switch CI workflow content gate" "$LUA" tests/switch_ci_workflows_test.lua
|
||||
run_tier "T0 switch transfer docs gate" "$LUA" tests/switch_transfer_docs_test.lua
|
||||
# NX Blue/Yellow asset overlay: ROM-free, must run on every checkout so a
|
||||
# Sound.lua / overlay regression is not gated only on switch-changes paths.
|
||||
run_tier "T0 NX asset overlay fallback" "$LUA" tests/engine/assets_version_fallback_test.lua
|
||||
run_tier "T0 NX generated-path static guard" "$LUA" tests/engine/nx_generated_guard_test.lua
|
||||
run_tier "T0 NX Yellow/Blue boot (dynamic paths)" "$LUA" tests/engine/nx_yellow_boot_test.lua
|
||||
run_tier "T0 touch-controls pad cursor" "$LUA" tests/engine/touch_controls_pad_cursor_test.lua
|
||||
run_tier "T1/T2 engine invariants + parity gates" "$LUA" tests/run_engine.lua
|
||||
run_tier "T4 mod-SDK" "$LUA" tests/run_modkit.lua
|
||||
|
||||
@@ -124,6 +132,7 @@ if [ -f data/generated/maps.lua ]; then
|
||||
run_tier "T3 save editor: map browser" "$LUA" tests/save_editor_task8_tests.lua
|
||||
run_tier "T3 save editor: mod awareness" "$LUA" tests/save_editor_mod_tests.lua
|
||||
run_tier "T3 save editor: wheel scrolling" "$LUA" tests/save_editor_wheel_bug595_test.lua
|
||||
run_tier "T3 save editor: pad / NX input" "$LUA" tests/save_editor_pad_input_test.lua
|
||||
run_tier "T5 link (loopback lockstep)" "$LUA" tests/run_link_tests.lua
|
||||
fi
|
||||
else
|
||||
|
||||
@@ -332,7 +332,7 @@ MoveEffects.primary = {
|
||||
battle.data.moves[id].name) }
|
||||
end,
|
||||
|
||||
SPLASH_EFFECT = function()
|
||||
SPLASH_EFFECT = function(battle)
|
||||
return { romText(battle.data, "_NoEffectText", "No effect!") }
|
||||
end,
|
||||
}
|
||||
|
||||
@@ -100,14 +100,27 @@ end
|
||||
-- play so a hot-reloaded dataset (or a mod's audio) always reaches the worker
|
||||
local function slimAudio(data)
|
||||
local audio = data.audio or {}
|
||||
-- NX-only: resolve the versioned cache prefix on the main thread and hand
|
||||
-- it to the worker, which runs in a fresh Lua state without GameVersion.
|
||||
local programPrefix
|
||||
if require("src.core.Platform").isNX() then
|
||||
local prefix = require("src.core.GameVersion").cachePrefix()
|
||||
if prefix ~= "" then programPrefix = prefix end
|
||||
end
|
||||
return {
|
||||
programFile = audio.programFile,
|
||||
programPrefix = programPrefix,
|
||||
bankOrder = audio.bankOrder,
|
||||
waveBanks = audio.waveBanks,
|
||||
noiseHeaders = audio.noiseHeaders,
|
||||
}
|
||||
end
|
||||
|
||||
-- test-only: expose slimAudio so the NX prefix hand-off is verifiable
|
||||
function ChipAudio._slimAudioForTest(data)
|
||||
return slimAudio(data)
|
||||
end
|
||||
|
||||
-- If the worker died (a malformed def that errors mid-synth), fall back to the
|
||||
-- synchronous path for the rest of the session instead of going silent.
|
||||
local function workerAlive()
|
||||
|
||||
+17
-1
@@ -123,7 +123,18 @@ local function loadBanks(data)
|
||||
if cachedProgramFile == audio.programFile and cachedBanks then
|
||||
return cachedBanks
|
||||
end
|
||||
local raw, readError = love.filesystem.read(audio.programFile)
|
||||
local raw, readError
|
||||
-- The chip worker runs in a separate Lua state without the NX overlay;
|
||||
-- ChipAudio hands it the versioned cache prefix explicitly. On the main
|
||||
-- thread the NX overlay (or desktop mountVersion) makes the plain read
|
||||
-- resolve, so no platform branching belongs here.
|
||||
local prefix = audio.programPrefix
|
||||
if prefix and prefix ~= "" then
|
||||
raw, readError = love.filesystem.read(prefix .. audio.programFile)
|
||||
end
|
||||
if not raw then
|
||||
raw, readError = love.filesystem.read(audio.programFile)
|
||||
end
|
||||
if not raw then error("could not read sound programs: " .. tostring(readError)) end
|
||||
local banks = {}
|
||||
for index, bank in ipairs(audio.bankOrder) do
|
||||
@@ -140,6 +151,11 @@ function ChipSynth.invalidateBanks()
|
||||
cachedProgramFile, cachedBanks = nil, nil
|
||||
end
|
||||
|
||||
-- test-only: exercise loadBanks without building a full engine
|
||||
function ChipSynth._loadBanksForTest(data)
|
||||
return loadBanks(data)
|
||||
end
|
||||
|
||||
-- A def-local program (ChipAsm output) is mounted as pseudo-bank 0 next to
|
||||
-- the ROM banks, so the 0x4000-window byte reader and every call/loop
|
||||
-- target work unchanged. The ROM's own cached bank table is never touched
|
||||
|
||||
+14
-1
@@ -203,7 +203,20 @@ local function loadModule(dir, name)
|
||||
if not chunk then return false, err end
|
||||
return pcall(chunk)
|
||||
end
|
||||
return pcall(require, "data.generated." .. name)
|
||||
local ok, mod = pcall(require, "data.generated." .. name)
|
||||
if ok then return true, mod end
|
||||
-- Fused PhysFS / Blue|Yellow prefix: load bytes from the active version's
|
||||
-- cache explicitly when require cannot see the mounted tree.
|
||||
local CacheFs = require("src.import.CacheFs")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local path = "data/generated/" .. name .. ".lua"
|
||||
local bytes = CacheFs.readActive(path)
|
||||
if type(bytes) == "string" then
|
||||
local chunk, err = loadstring(bytes, "@" .. GameVersion.cachePrefix() .. path)
|
||||
if not chunk then return false, err or mod end
|
||||
return pcall(chunk)
|
||||
end
|
||||
return false, mod
|
||||
end
|
||||
|
||||
function Data:load()
|
||||
|
||||
+69
-8
@@ -9,6 +9,7 @@ local Renderer = require("src.render.Renderer")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local StateStack = require("src.core.StateStack")
|
||||
local TouchControls = require("src.core.TouchControls")
|
||||
local GamepadMap = require("src.core.GamepadMap")
|
||||
local ModLoader = require("src.mods.Loader")
|
||||
local ModRuntime = require("src.mods.Runtime")
|
||||
local Screens = require("src.ui.Screens")
|
||||
@@ -656,17 +657,29 @@ function Game:gamepadpressed(joystick, button)
|
||||
-- a controller is being used: the touch overlay steps aside until the
|
||||
-- next screen touch (mobile only; a no-op elsewhere)
|
||||
TouchControls:noteGamepad()
|
||||
-- Select held? Needed both to suppress shoulder speed hotkeys (Select+L
|
||||
-- is a display chord on NX) and for the chord path below.
|
||||
local selectHeld = Input:isDown("select")
|
||||
if not selectHeld and joystick and joystick.isGamepadDown then
|
||||
local ok, down = pcall(function()
|
||||
return joystick:isGamepadDown("back")
|
||||
end)
|
||||
selectHeld = ok and down == true
|
||||
end
|
||||
-- shoulder buttons and analog triggers cycle GAME SPEED (R1/RB or
|
||||
-- R2/RT = faster, L1/LB or L2/LT = slower; same as keyboard hotkey
|
||||
-- 1). LÖVE reports an analog trigger as gamepadpressed once it
|
||||
-- crosses the press threshold, so a trigger pull lands here like any
|
||||
-- other pad button.
|
||||
if button == "rightshoulder" or button == "righttrigger" then
|
||||
self:_cycleSpeed(1)
|
||||
return
|
||||
elseif button == "leftshoulder" or button == "lefttrigger" then
|
||||
self:_cycleSpeed(-1)
|
||||
return
|
||||
-- other pad button. Skip while Select is held so Select+L can reach
|
||||
-- displayChordDigit ("7").
|
||||
if not selectHeld then
|
||||
if button == "rightshoulder" or button == "righttrigger" then
|
||||
self:_cycleSpeed(1)
|
||||
return
|
||||
elseif button == "leftshoulder" or button == "lefttrigger" then
|
||||
self:_cycleSpeed(-1)
|
||||
return
|
||||
end
|
||||
end
|
||||
-- BindingsMenu's pad capture rides the same top-state routing as keys
|
||||
local top = self.stack and self.stack:top()
|
||||
@@ -674,6 +687,16 @@ function Game:gamepadpressed(joystick, button)
|
||||
top:onGamepadPressed(button)
|
||||
return
|
||||
end
|
||||
-- Select+face display chords → same digit path as Game:keypressed
|
||||
-- (COLORS/TILT/pipelines). Intercept before Input so face does not
|
||||
-- also fire GB A/B. Dual-path: raw already ignored when isGamepad().
|
||||
if selectHeld then
|
||||
local digit = GamepadMap.displayChordDigit(button)
|
||||
if digit then
|
||||
self:keypressed(digit)
|
||||
return
|
||||
end
|
||||
end
|
||||
Input:gamepadpressed(joystick, button)
|
||||
end
|
||||
|
||||
@@ -757,15 +780,51 @@ function Game:focus(f)
|
||||
end
|
||||
|
||||
function Game:visible(v)
|
||||
if v then
|
||||
self:onResume()
|
||||
else
|
||||
Input:reset()
|
||||
TouchControls:reset()
|
||||
end
|
||||
end
|
||||
|
||||
function Game:onResume()
|
||||
Input:reset()
|
||||
TouchControls:reset()
|
||||
-- Chip music may survive NX suspend as a duplicate stream; stop it and let
|
||||
-- the active screen re-cue on the next frame (hardware audio check: T19).
|
||||
-- Desktop/mobile window-visible flips must not kill overworld music.
|
||||
if require("src.core.Platform").isNX() then
|
||||
require("src.core.ChipAudio").stopMusic()
|
||||
end
|
||||
local SwitchDiagnostics = require("src.debug.SwitchDiagnostics")
|
||||
if SwitchDiagnostics.isEnabled() then
|
||||
SwitchDiagnostics.onEvent("lifecycle", { event = "resume" })
|
||||
end
|
||||
end
|
||||
|
||||
function Game:recoverInput(event, joystick)
|
||||
Input:reset()
|
||||
TouchControls:reset()
|
||||
local SwitchDiagnostics = require("src.debug.SwitchDiagnostics")
|
||||
if SwitchDiagnostics.isEnabled() then
|
||||
if joystick then
|
||||
SwitchDiagnostics.onJoystickEvent(event, joystick)
|
||||
else
|
||||
SwitchDiagnostics.onEvent("lifecycle", { event = event })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function Game:joystickadded(joystick)
|
||||
self:recoverInput("joystickadded", joystick)
|
||||
end
|
||||
|
||||
-- A disconnected/dropped controller can't send the button-up for whatever
|
||||
-- it was holding, so drop all input state rather than try to guess which
|
||||
-- flags it owned.
|
||||
function Game:joystickremoved(joystick)
|
||||
Input:reset()
|
||||
self:recoverInput("joystickremoved", joystick)
|
||||
TouchControls:joystickremoved()
|
||||
end
|
||||
|
||||
@@ -842,6 +901,8 @@ function Game:applyOptions(opts)
|
||||
-- returns true when a persisted GBC FX level was cleared on mobile
|
||||
local gbcCleared = require("src.render.GBCFX").applyOptions(opts)
|
||||
require("src.core.VideoMode").applyOptions(opts)
|
||||
-- Android orientation lock (#592); no-op everywhere else
|
||||
require("src.core.Orientation").applyOptions(opts)
|
||||
-- after VideoMode: a faithful-resolution lock is an exact window size, so
|
||||
-- it has to be the last word on the window (it drops fullscreen to hold)
|
||||
require("src.core.FaithfulRes").applyOptions(opts)
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
-- Shared gamepad + raw joystick button tables for launcher and gameplay.
|
||||
-- Hardware-measured NX overrides live in NX_* tables (see docs/switch-development.md).
|
||||
|
||||
local GamepadMap = {}
|
||||
|
||||
-- LÖVE SDL game-controller mapping (D-pad / face / menu) — desktop/mobile.
|
||||
GamepadMap.DEFAULT_GAMEPAD_BINDINGS = {
|
||||
dpup = "up", dpdown = "down", dpleft = "left", dpright = "right",
|
||||
a = "a", b = "b",
|
||||
start = "start", back = "select",
|
||||
}
|
||||
|
||||
-- Switch: LÖVE/SDL labels south as "a" and east as "b", but Nintendo UX is
|
||||
-- physical A (east) = confirm (GB A), physical B (south) = cancel (GB B).
|
||||
GamepadMap.NX_GAMEPAD_BINDINGS = {
|
||||
dpup = "up", dpdown = "down", dpleft = "left", dpright = "right",
|
||||
a = "b", -- SDL south = Nintendo B → GB B
|
||||
b = "a", -- SDL east = Nintendo A → GB A
|
||||
start = "start", back = "select",
|
||||
}
|
||||
|
||||
-- Generic SDL joysticks without a game-controller DB entry (Linux handhelds).
|
||||
-- Desktop XInput order only: raw numbering is per-driver, and SDL's iOS/MFi
|
||||
-- driver packs only the buttons a pad reports, which slides the D-pad onto
|
||||
-- 7..10 (#620). These are defaults; Input:applyBindings layers "joyN" pad
|
||||
-- rebinds over them (#632). Only sticks SDL does NOT recognize as gamepads
|
||||
-- are served from this table (see GamepadMap.ignoreRawForJoystick).
|
||||
GamepadMap.RAW_BUTTON_BINDINGS = {
|
||||
[1] = "a", [2] = "b",
|
||||
[7] = "select", [8] = "start", [9] = "select", [10] = "start",
|
||||
}
|
||||
|
||||
-- Switch OLED raw indices (1-based). Only when NOT isGamepad() — love-nx
|
||||
-- also emits gamepadpressed; dual-path face presses break NamingScreen.
|
||||
-- #1 = Nintendo B, #2 = Nintendo A (probe); Y/X left unmapped for naming.
|
||||
GamepadMap.NX_RAW_BUTTON_BINDINGS = {
|
||||
[1] = "b", [2] = "a",
|
||||
[9] = "select", [10] = "start",
|
||||
}
|
||||
|
||||
-- Raw index -> gamepad button *name* for RomImporter (then NX face swap applies).
|
||||
GamepadMap.RAW_TO_GAMEPAD_BUTTON = {
|
||||
[1] = "a", [2] = "b",
|
||||
[7] = "back", [8] = "start", [9] = "back", [10] = "start",
|
||||
}
|
||||
|
||||
GamepadMap.NX_RAW_TO_GAMEPAD_BUTTON = {
|
||||
[1] = "a", [2] = "b", -- SDL south/east names; NX_GAMEPAD_BINDINGS swaps to GB
|
||||
[9] = "back", [10] = "start",
|
||||
}
|
||||
|
||||
-- Test hook: force NX tables without stubbing love.
|
||||
GamepadMap._forceNXForTests = false
|
||||
|
||||
function GamepadMap._setForceNXForTests(v)
|
||||
GamepadMap._forceNXForTests = not not v
|
||||
end
|
||||
|
||||
local function nxActive()
|
||||
if GamepadMap._forceNXForTests then return true end
|
||||
if love and love._os == "NX" then return true end
|
||||
if love and love.system and love.system.getOS() == "NX" then return true end
|
||||
return false
|
||||
end
|
||||
|
||||
function GamepadMap.gamepadBindings()
|
||||
if nxActive() then return GamepadMap.NX_GAMEPAD_BINDINGS end
|
||||
return GamepadMap.DEFAULT_GAMEPAD_BINDINGS
|
||||
end
|
||||
|
||||
-- Whole raw-index table for Input:applyBindings joyBindings seeding (#632).
|
||||
function GamepadMap.rawBindings()
|
||||
if nxActive() then return GamepadMap.NX_RAW_BUTTON_BINDINGS end
|
||||
return GamepadMap.RAW_BUTTON_BINDINGS
|
||||
end
|
||||
|
||||
function GamepadMap.mapGamepadButton(button)
|
||||
return GamepadMap.gamepadBindings()[button]
|
||||
end
|
||||
|
||||
-- Select+face display chords (docs / Nintendo UX):
|
||||
-- Select+A → "2" (COLORS), Select+B → "3" (TILT),
|
||||
-- Select+Y → "5", Select+X → "6", Select+L (leftshoulder) → "7".
|
||||
-- For a/b: resolve through mapGamepadButton then GB a→"2", b→"3" so NX
|
||||
-- Nintendo physical A/B match the docs despite SDL face-label swap.
|
||||
-- Caller (Game:gamepadpressed) must require Select held; this is map-only.
|
||||
function GamepadMap.displayChordDigit(gamepadButton)
|
||||
if gamepadButton == "y" then return "5" end
|
||||
if gamepadButton == "x" then return "6" end
|
||||
if gamepadButton == "leftshoulder" then return "7" end
|
||||
if gamepadButton == "a" or gamepadButton == "b" then
|
||||
local gb = GamepadMap.mapGamepadButton(gamepadButton)
|
||||
if gb == "a" then return "2" end
|
||||
if gb == "b" then return "3" end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- love-nx / SDL: when isGamepad(), face+menu already arrive via gamepad*.
|
||||
-- Applying joystickpressed raw on top double-fires GB A/B in one frame.
|
||||
function GamepadMap.ignoreRawForJoystick(joystick)
|
||||
if not joystick then return false end
|
||||
local ok, isPad = pcall(function()
|
||||
return joystick.isGamepad and joystick:isGamepad()
|
||||
end)
|
||||
return ok and isPad == true
|
||||
end
|
||||
|
||||
function GamepadMap.mapRawButton(index)
|
||||
if nxActive() then
|
||||
local nx = GamepadMap.NX_RAW_BUTTON_BINDINGS[index]
|
||||
if nx then return nx end
|
||||
end
|
||||
return GamepadMap.RAW_BUTTON_BINDINGS[index]
|
||||
end
|
||||
|
||||
function GamepadMap.mapRawToGamepadButton(index)
|
||||
if nxActive() then
|
||||
local nx = GamepadMap.NX_RAW_TO_GAMEPAD_BUTTON[index]
|
||||
if nx then return nx end
|
||||
end
|
||||
return GamepadMap.RAW_TO_GAMEPAD_BUTTON[index]
|
||||
end
|
||||
|
||||
return GamepadMap
|
||||
@@ -148,15 +148,14 @@ function HostShell.haveCurl()
|
||||
return readOk and out ~= nil and out:find("curl", 1, true) ~= nil
|
||||
end
|
||||
|
||||
-- The bridge only exists in our Android liblove. An older APK reports nil
|
||||
-- here and falls back to the "no transport" error the callers already show;
|
||||
-- the iOS build compiles the same wrapper but always returns false, so gate
|
||||
-- on the OS as well and keep its error message honest.
|
||||
-- An older mobile build reports nil here and falls back to the "no transport"
|
||||
-- error the callers already show.
|
||||
local function haveBridge()
|
||||
if not (love and love.system and type(love.system.httpDownload) == "function") then
|
||||
return false
|
||||
end
|
||||
return love.system.getOS and love.system.getOS() == "Android"
|
||||
local osName = love.system.getOS and love.system.getOS()
|
||||
return osName == "Android" or osName == "iOS"
|
||||
end
|
||||
|
||||
-- Is any transport available at all? Callers gate on this, never on curl.
|
||||
|
||||
+18
-34
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,54 @@
|
||||
-- Platform capability detection for NX / mobile / desktop.
|
||||
|
||||
local Platform = {}
|
||||
|
||||
local cached
|
||||
|
||||
local function compute()
|
||||
local osName = (love and love.system and love.system.getOS and love.system.getOS())
|
||||
or "Unknown"
|
||||
local nx = osName == "NX"
|
||||
local mobile = osName == "Android" or osName == "iOS"
|
||||
local nativePicker = love and love.system
|
||||
and type(love.system.pickFile) == "function"
|
||||
return {
|
||||
os = osName,
|
||||
nx = nx,
|
||||
mobile = mobile,
|
||||
console = nx,
|
||||
hasNativePicker = nativePicker,
|
||||
canSpawnProcess = osName == "OS X" or osName == "Windows" or osName == "Linux",
|
||||
romImportMode = nx and "save-directory"
|
||||
or (nativePicker and "native-picker")
|
||||
or "desktop",
|
||||
networkValidated = not nx,
|
||||
}
|
||||
end
|
||||
|
||||
function Platform.detect()
|
||||
if not cached then cached = compute() end
|
||||
return cached
|
||||
end
|
||||
|
||||
function Platform.isNX()
|
||||
return Platform.detect().nx
|
||||
end
|
||||
|
||||
function Platform.romImportMode()
|
||||
return Platform.detect().romImportMode
|
||||
end
|
||||
|
||||
function Platform.canSpawnProcess()
|
||||
return Platform.detect().canSpawnProcess
|
||||
end
|
||||
|
||||
function Platform.networkValidated()
|
||||
return Platform.detect().networkValidated
|
||||
end
|
||||
|
||||
-- Tests may swap love.system between cases.
|
||||
function Platform._resetForTests()
|
||||
cached = nil
|
||||
end
|
||||
|
||||
return Platform
|
||||
+25
-11
@@ -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
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
-- Opt-in Switch diagnostics: ring buffer + ≤1 Hz flush when switch-debug.txt exists.
|
||||
-- Never logs ROM/save bytes — see spec SWNX-13/28.
|
||||
|
||||
local SwitchDiagnostics = {}
|
||||
|
||||
local MARKER = "switch-debug.txt"
|
||||
local LOG_FILE = "switch.log"
|
||||
local ERROR_LOG = "lua-error.log"
|
||||
local ERROR_LOG_ROTATED = "lua-error.log.1"
|
||||
local ERROR_LOG_MAX = 32 * 1024
|
||||
local FLUSH_INTERVAL = 1.0
|
||||
local RING_SIZE = 64
|
||||
|
||||
local enabled = nil
|
||||
local buffer = {}
|
||||
local bufCount = 0
|
||||
local lastFlushAt = -math.huge
|
||||
local identityLine = nil
|
||||
|
||||
local function fs()
|
||||
return love and love.filesystem
|
||||
end
|
||||
|
||||
local function redactString(s)
|
||||
if type(s) ~= "string" then return s end
|
||||
-- Keep printable ASCII + TAB/LF/CR so Lua stack traces remain readable.
|
||||
-- Reject NULs and other C0 controls, and high bytes (ROM/binary dumps).
|
||||
for i = 1, #s do
|
||||
local b = s:byte(i)
|
||||
if b == 0 then return "<redacted>" end
|
||||
if b < 32 and b ~= 9 and b ~= 10 and b ~= 13 then return "<redacted>" end
|
||||
if b > 126 then return "<redacted>" end
|
||||
end
|
||||
if #s > 8192 then return s:sub(1, 8192) .. "...<truncated>" end
|
||||
return s
|
||||
end
|
||||
|
||||
local function sanitize(value, depth)
|
||||
depth = depth or 0
|
||||
if depth > 4 then return "<deep>" end
|
||||
local t = type(value)
|
||||
if t == "string" then return redactString(value) end
|
||||
if t == "number" or t == "boolean" or value == nil then return value end
|
||||
if t == "table" then
|
||||
local out = {}
|
||||
for k, v in pairs(value) do
|
||||
local key = type(k) == "string" and k or tostring(k)
|
||||
if key:lower():find("rom") or key:lower():find("save") then
|
||||
out[key] = "<redacted>"
|
||||
else
|
||||
out[key] = sanitize(v, depth + 1)
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
return tostring(value)
|
||||
end
|
||||
|
||||
local function encodePayload(payload)
|
||||
if payload == nil then return "" end
|
||||
if type(payload) == "string" then return redactString(payload) end
|
||||
local parts = {}
|
||||
for k, v in pairs(sanitize(payload)) do
|
||||
parts[#parts + 1] = tostring(k) .. "=" .. tostring(v)
|
||||
end
|
||||
table.sort(parts)
|
||||
return table.concat(parts, " ")
|
||||
end
|
||||
|
||||
function SwitchDiagnostics._resetForTests()
|
||||
enabled = nil
|
||||
buffer = {}
|
||||
bufCount = 0
|
||||
lastFlushAt = -math.huge
|
||||
identityLine = nil
|
||||
local filesystem = fs()
|
||||
if filesystem then
|
||||
filesystem.remove(ERROR_LOG)
|
||||
filesystem.remove(ERROR_LOG_ROTATED)
|
||||
end
|
||||
end
|
||||
|
||||
function SwitchDiagnostics.isEnabled()
|
||||
if enabled ~= nil then return enabled end
|
||||
local filesystem = fs()
|
||||
if not filesystem then
|
||||
enabled = false
|
||||
return false
|
||||
end
|
||||
enabled = filesystem.getInfo(MARKER) ~= nil
|
||||
return enabled
|
||||
end
|
||||
|
||||
function SwitchDiagnostics.identityOverlay()
|
||||
if identityLine then return identityLine end
|
||||
local gitCommit = os.getenv("POKEPORT_GIT_COMMIT") or "unknown"
|
||||
local loveNxTag = "11.5-nx1"
|
||||
local buildVersion = "dev"
|
||||
local filesystem = fs()
|
||||
if filesystem then
|
||||
local raw = filesystem.read("build-info.json")
|
||||
if raw and raw ~= "" then
|
||||
local ver = raw:match('"version"%s*:%s*"([^"]+)"')
|
||||
if ver then buildVersion = ver end
|
||||
local tag = raw:match('"loveNxTag"%s*:%s*"([^"]+)"')
|
||||
if tag then loveNxTag = tag end
|
||||
local commit = raw:match('"gitCommit"%s*:%s*"([^"]+)"')
|
||||
if commit then gitCommit = commit end
|
||||
end
|
||||
end
|
||||
identityLine = ("gitCommit=%s loveNxTag=%s buildVersion=%s os=%s"):format(
|
||||
gitCommit, loveNxTag, buildVersion,
|
||||
love and love.system and love.system.getOS() or "unknown")
|
||||
return identityLine
|
||||
end
|
||||
|
||||
function SwitchDiagnostics.onEvent(kind, payload)
|
||||
if not SwitchDiagnostics.isEnabled() then return end
|
||||
bufCount = bufCount + 1
|
||||
local slot = ((bufCount - 1) % RING_SIZE) + 1
|
||||
buffer[slot] = ("%s %s"):format(tostring(kind), encodePayload(payload))
|
||||
end
|
||||
|
||||
function SwitchDiagnostics.onJoystickEvent(kind, joystick, button, extra)
|
||||
if not SwitchDiagnostics.isEnabled() then return end
|
||||
local payload = { button = button }
|
||||
if joystick then
|
||||
if joystick.getGUID then payload.guid = joystick:getGUID() end
|
||||
if joystick.isGamepad then payload.isGamepad = joystick:isGamepad() end
|
||||
if joystick.getName then payload.name = joystick:getName() end
|
||||
end
|
||||
if extra then
|
||||
for k, v in pairs(extra) do payload[k] = v end
|
||||
end
|
||||
SwitchDiagnostics.onEvent(kind, payload)
|
||||
end
|
||||
|
||||
function SwitchDiagnostics.logLuaError(msg)
|
||||
local filesystem = fs()
|
||||
if not filesystem then return nil end
|
||||
|
||||
local text = redactString(tostring(msg or "unknown error"))
|
||||
local existing = filesystem.read(ERROR_LOG) or ""
|
||||
if #existing > ERROR_LOG_MAX then
|
||||
filesystem.write(ERROR_LOG_ROTATED, existing)
|
||||
existing = ""
|
||||
end
|
||||
|
||||
local stamp = os.date("!%Y-%m-%dT%H:%M:%SZ")
|
||||
local line = ("[%s] %s\n"):format(stamp, text)
|
||||
filesystem.write(ERROR_LOG, existing .. line .. SwitchDiagnostics.identityOverlay() .. "\n")
|
||||
|
||||
return "Details saved to lua-error.log in the save directory."
|
||||
end
|
||||
|
||||
function SwitchDiagnostics.maybeFlush(force, now)
|
||||
if not SwitchDiagnostics.isEnabled() then return end
|
||||
now = now or (love and love.timer and love.timer.getTime() or 0)
|
||||
if not force and (now - lastFlushAt) < FLUSH_INTERVAL then return end
|
||||
lastFlushAt = now
|
||||
|
||||
local filesystem = fs()
|
||||
if not filesystem then return end
|
||||
|
||||
local lines = { SwitchDiagnostics.identityOverlay(), "---" }
|
||||
local start = math.max(1, bufCount - RING_SIZE + 1)
|
||||
for i = start, bufCount do
|
||||
local slot = ((i - 1) % RING_SIZE) + 1
|
||||
if buffer[slot] then lines[#lines + 1] = buffer[slot] end
|
||||
end
|
||||
filesystem.write(LOG_FILE, table.concat(lines, "\n") .. "\n")
|
||||
end
|
||||
|
||||
-- One-shot NX asset probe written on every Play. No ROM/save bytes — only
|
||||
-- paths, sizes, resolve results, and whether newImage/newImageData open.
|
||||
-- Pull sdmc:.../pokemon-love2d/nx-asset-probe.log after a Yellow boot.
|
||||
local PROBE_LOG = "nx-asset-probe.log"
|
||||
|
||||
local function probeInfo(filesystem, path)
|
||||
local info = filesystem.getInfo(path)
|
||||
if not info then return "missing" end
|
||||
local size = info.size
|
||||
if size == nil then
|
||||
local bytes = filesystem.read(path)
|
||||
size = type(bytes) == "string" and #bytes or -1
|
||||
end
|
||||
return ("type=%s size=%s"):format(tostring(info.type), tostring(size))
|
||||
end
|
||||
|
||||
local function probeOpen(kind, path)
|
||||
if kind == "image" then
|
||||
local ok, err = pcall(love.graphics.newImage, path)
|
||||
return ok and "ok" or ("FAIL " .. tostring(err):gsub("%s+", " "):sub(1, 160))
|
||||
end
|
||||
if not (love.image and love.image.newImageData) then return "skip-no-imageData" end
|
||||
local ok, err = pcall(love.image.newImageData, path)
|
||||
return ok and "ok" or ("FAIL " .. tostring(err):gsub("%s+", " "):sub(1, 160))
|
||||
end
|
||||
|
||||
function SwitchDiagnostics.probeAssets(version)
|
||||
local Platform = require("src.core.Platform")
|
||||
if not Platform.isNX() then return end
|
||||
local filesystem = fs()
|
||||
if not filesystem then return end
|
||||
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Assets = require("src.render.Assets")
|
||||
local prefix = GameVersion.cachePrefix(version or GameVersion.get())
|
||||
local lines = {
|
||||
SwitchDiagnostics.identityOverlay(),
|
||||
"probe=nx-asset",
|
||||
"version=" .. tostring(version or GameVersion.get()),
|
||||
"cachePrefix=" .. tostring(prefix),
|
||||
"isNX=" .. tostring(Platform.isNX()),
|
||||
"saveDir=" .. tostring(filesystem.getSaveDirectory and filesystem.getSaveDirectory() or "?"),
|
||||
}
|
||||
|
||||
local samples = {
|
||||
"assets/generated/fonts/font.png",
|
||||
"assets/generated/tilesets/reds_house.png",
|
||||
"assets/generated/sprites/red.png",
|
||||
"assets/generated/sprites/monster.png",
|
||||
}
|
||||
for _, path in ipairs(samples) do
|
||||
local versioned = prefix ~= "" and (prefix .. path) or path
|
||||
local resolved = Assets.resolve(path)
|
||||
lines[#lines + 1] = ("--- %s"):format(path)
|
||||
lines[#lines + 1] = "unprefixed=" .. probeInfo(filesystem, path)
|
||||
if prefix ~= "" then
|
||||
lines[#lines + 1] = "versioned=" .. probeInfo(filesystem, versioned)
|
||||
end
|
||||
lines[#lines + 1] = "resolve=" .. tostring(resolved)
|
||||
lines[#lines + 1] = "newImage=" .. probeOpen("image", resolved)
|
||||
lines[#lines + 1] = "newImageData=" .. probeOpen("imageData", resolved)
|
||||
if prefix ~= "" and resolved ~= versioned then
|
||||
lines[#lines + 1] = "newImage_versioned=" .. probeOpen("image", versioned)
|
||||
lines[#lines + 1] = "newImageData_versioned=" .. probeOpen("imageData", versioned)
|
||||
end
|
||||
end
|
||||
|
||||
-- Shallow listing so we can see if the extract tree exists at all.
|
||||
local roots = { "yellow", "blue", "assets", "yellow/assets/generated",
|
||||
"yellow/assets/generated/sprites", "blue/assets/generated/sprites" }
|
||||
for _, dir in ipairs(roots) do
|
||||
local info = filesystem.getInfo(dir)
|
||||
if info and info.type == "directory" and filesystem.getDirectoryItems then
|
||||
local items = filesystem.getDirectoryItems(dir) or {}
|
||||
local n = math.min(8, #items)
|
||||
local head = {}
|
||||
for i = 1, n do head[i] = items[i] end
|
||||
lines[#lines + 1] = ("list %s count=%d head=%s"):format(
|
||||
dir, #items, table.concat(head, ","))
|
||||
else
|
||||
lines[#lines + 1] = ("list %s %s"):format(dir, info and info.type or "missing")
|
||||
end
|
||||
end
|
||||
|
||||
filesystem.write(PROBE_LOG, table.concat(lines, "\n") .. "\n")
|
||||
end
|
||||
|
||||
return SwitchDiagnostics
|
||||
+68
-19
@@ -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
|
||||
|
||||
@@ -173,6 +173,25 @@ local function coreRows(opts)
|
||||
end)
|
||||
end
|
||||
|
||||
-- ORIENTATION (#592): Android only -- the lock rides SDL's orientation
|
||||
-- hint, which iOS reads only at startup (the Info.plist governs there) and
|
||||
-- desktop ignores. Unlike the other launcher rows this one live-applies:
|
||||
-- the window exists here too, and rotating under the player's finger is
|
||||
-- the only feedback that reads.
|
||||
do
|
||||
local osName = love.system and love.system.getOS and love.system.getOS()
|
||||
local okOr, Orientation = pcall(require, "src.core.Orientation")
|
||||
if okOr and osName == "Android" then
|
||||
add(Strings("ORIENTATION"),
|
||||
function() return Strings(Orientation.modeLabel(opts.orientation)) end,
|
||||
function(dir)
|
||||
opts.orientation = Orientation.cycle(opts.orientation, dir)
|
||||
Orientation.apply(opts.orientation)
|
||||
return true
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
local okFr, FaithfulRes = pcall(require, "src.core.FaithfulRes")
|
||||
if okFr then
|
||||
add(Strings("FAITHFUL RATIO"),
|
||||
|
||||
+96
-23
@@ -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)
|
||||
|
||||
@@ -2023,21 +2023,23 @@ end
|
||||
-- PikachuCriesPointerTable, 42 `dba` rows; each clip is `dw length` then
|
||||
-- 1-bit PCM, MSB first -- home/pikachu_cries.asm PlayPikachuPCM toggles
|
||||
-- rAUD3LEVEL per bit at roughly 190 CPU cycles a sample). Decoded to
|
||||
-- plain 8-bit mono WAVs; returns the clip count for data.audio.pikaCries,
|
||||
-- 16-bit stereo WAVs (identical L/R) so OpenAL never spatializes them as
|
||||
-- ambient surround (#626); returns the clip count for data.audio.pikaCries,
|
||||
-- or nil when the manifest has no pointer table (Red/Blue).
|
||||
function RomExtractor:extractPikachuCries()
|
||||
if not self.symbols["PikachuCriesPointerTable"] then return nil end
|
||||
local NUM = 42 -- NUM_PIKA_CRIES
|
||||
local RATE = 22050 -- ~4.19 MHz / ~190 cycles per sample
|
||||
-- byte -> 8 samples, MSB first (LoadNextSoundClipSample: `and $80`)
|
||||
-- byte -> 8 mono sample levels, MSB first (LoadNextSoundClipSample: `and $80`)
|
||||
-- levels match the old unsigned-8 WAV (on=0xE0, off=0x20) as floats in [-1,1]
|
||||
local lut = {}
|
||||
for byte = 0, 255 do
|
||||
local out = {}
|
||||
for bit = 7, 0, -1 do
|
||||
local on = math.floor(byte / 2 ^ bit) % 2 == 1
|
||||
out[#out + 1] = string.char(on and 0xE0 or 0x20)
|
||||
out[#out + 1] = on and ((0xE0 - 128) / 128) or ((0x20 - 128) / 128)
|
||||
end
|
||||
lut[byte] = table.concat(out)
|
||||
lut[byte] = out
|
||||
end
|
||||
local function u16(v)
|
||||
return string.char(v % 256, math.floor(v / 256) % 256)
|
||||
@@ -2046,6 +2048,12 @@ function RomExtractor:extractPikachuCries()
|
||||
return string.char(v % 256, math.floor(v / 256) % 256,
|
||||
math.floor(v / 65536) % 256, math.floor(v / 16777216) % 256)
|
||||
end
|
||||
local function i16le(f)
|
||||
local v = math.floor(f * 32767 + (f >= 0 and 0.5 or -0.5))
|
||||
if v > 32767 then v = 32767 elseif v < -32768 then v = -32768 end
|
||||
if v < 0 then v = v + 65536 end
|
||||
return string.char(v % 256, math.floor(v / 256) % 256)
|
||||
end
|
||||
local CacheFs = require("src.import.CacheFs")
|
||||
local pointers = self:symbol("PikachuCriesPointerTable")
|
||||
for index = 0, NUM - 1 do
|
||||
@@ -2054,11 +2062,16 @@ function RomExtractor:extractPikachuCries()
|
||||
local header = self.rom:bytes(bank, address, 2)
|
||||
local length = header[1] + header[2] * 256
|
||||
local raw = self.rom:bytes(bank, address + 2, length)
|
||||
local samples = {}
|
||||
for i, byte in ipairs(raw) do samples[i] = lut[byte] end
|
||||
local pcm = table.concat(samples)
|
||||
local parts = {}
|
||||
for _, byte in ipairs(raw) do
|
||||
for _, level in ipairs(lut[byte]) do
|
||||
local s = i16le(level)
|
||||
parts[#parts + 1] = s .. s -- identical L/R (#626)
|
||||
end
|
||||
end
|
||||
local pcm = table.concat(parts)
|
||||
local wav = "RIFF" .. u32(36 + #pcm) .. "WAVEfmt " .. u32(16)
|
||||
.. u16(1) .. u16(1) .. u32(RATE) .. u32(RATE) .. u16(1) .. u16(8)
|
||||
.. u16(1) .. u16(2) .. u32(RATE) .. u32(RATE * 4) .. u16(4) .. u16(16)
|
||||
.. "data" .. u32(#pcm) .. pcm
|
||||
local ok, err = CacheFs.write(
|
||||
("assets/generated/audio/pika_cries/cry_%02d.wav"):format(index + 1),
|
||||
|
||||
+650
-42
@@ -1,6 +1,8 @@
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local GamepadMap = require("src.core.GamepadMap")
|
||||
local Strings = require("src.core.Strings")
|
||||
local HostShell = require("src.core.HostShell")
|
||||
local Platform = require("src.core.Platform")
|
||||
local SafeArea = require("src.core.SafeArea")
|
||||
|
||||
local RomImporter = {}
|
||||
@@ -328,6 +330,7 @@ local function releasePointerGrab()
|
||||
end
|
||||
|
||||
local function commandOutput(command)
|
||||
if not Platform.canSpawnProcess() then return nil end
|
||||
releasePointerGrab()
|
||||
local pipe = HostShell.popen(command)
|
||||
if not pipe then return nil end
|
||||
@@ -337,6 +340,417 @@ local function commandOutput(command)
|
||||
return result ~= "" and result or nil
|
||||
end
|
||||
|
||||
local IMPORTS_DIR = "imports"
|
||||
local MODS_INBOX_DIR = "imports/mods"
|
||||
local SAVES_INBOX_DIR = "imports/saves"
|
||||
local ROM_BYTES = 1024 * 1024
|
||||
|
||||
local function savesInboxDir(version)
|
||||
return SAVES_INBOX_DIR .. "/" .. tostring(version)
|
||||
end
|
||||
|
||||
local function savesImportedHashesPath(version)
|
||||
return savesInboxDir(version) .. "/.imported-sha1"
|
||||
end
|
||||
|
||||
local function exportsDir(version)
|
||||
return "exports/" .. tostring(version)
|
||||
end
|
||||
|
||||
-- Strip only a validated sdmc:/ prefix for OpenMTP/DBI relative paths.
|
||||
function RomImporter.mtpHintPath(saveDir)
|
||||
if type(saveDir) ~= "string" then return "" end
|
||||
if saveDir:sub(1, 6) == "sdmc:/" then return saveDir:sub(7) end
|
||||
return saveDir
|
||||
end
|
||||
|
||||
function RomImporter:ensureImportsDir()
|
||||
local info = love.filesystem.getInfo(IMPORTS_DIR)
|
||||
if info and info.type == "directory" then return true end
|
||||
if info then return false end
|
||||
if love.filesystem.createDirectory then
|
||||
return love.filesystem.createDirectory(IMPORTS_DIR)
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- NX mod zip inbox (separate from ROM imports/). Parent imports/ first —
|
||||
-- love.filesystem.createDirectory does not create nested parents.
|
||||
function RomImporter:ensureModsInboxDir()
|
||||
self:ensureImportsDir()
|
||||
local info = love.filesystem.getInfo(MODS_INBOX_DIR)
|
||||
if info and info.type == "directory" then return true end
|
||||
if info then return false end
|
||||
if love.filesystem.createDirectory then
|
||||
return love.filesystem.createDirectory(MODS_INBOX_DIR)
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- NX raw .sav inbox per game: imports/saves/{red,blue,yellow}/.
|
||||
-- Parent imports/ then imports/saves/ first — createDirectory is not nested.
|
||||
-- Creates all three version folders so MTP browsing shows where each game goes.
|
||||
function RomImporter:ensureSavesInboxDir(version)
|
||||
self:ensureImportsDir()
|
||||
local info = love.filesystem.getInfo(SAVES_INBOX_DIR)
|
||||
if info and info.type ~= "directory" then return false end
|
||||
if not info then
|
||||
if not (love.filesystem.createDirectory
|
||||
and love.filesystem.createDirectory(SAVES_INBOX_DIR)) then
|
||||
return false
|
||||
end
|
||||
end
|
||||
for v in pairs(GameVersion.VERSIONS) do
|
||||
local dir = savesInboxDir(v)
|
||||
local vInfo = love.filesystem.getInfo(dir)
|
||||
if vInfo and vInfo.type ~= "directory" then return false end
|
||||
if not vInfo then
|
||||
if not (love.filesystem.createDirectory
|
||||
and love.filesystem.createDirectory(dir)) then
|
||||
return false
|
||||
end
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
function RomImporter:_setNxInboxNotice(version)
|
||||
version = version or self.tab or "red"
|
||||
local saveDir = love.filesystem.getSaveDirectory()
|
||||
local rel = RomImporter.mtpHintPath(saveDir)
|
||||
if rel ~= "" and rel:sub(-1) ~= "/" then rel = rel .. "/" end
|
||||
self.notice = {
|
||||
version = version,
|
||||
status = Strings("Copy your .gb/.gbc into:"),
|
||||
detail = Strings("%s/imports/\nDBI MTP → 1: SD Card/%simports/", saveDir, rel),
|
||||
}
|
||||
end
|
||||
|
||||
function RomImporter:_setNxModsInboxNotice()
|
||||
local saveDir = love.filesystem.getSaveDirectory()
|
||||
local rel = RomImporter.mtpHintPath(saveDir)
|
||||
if rel ~= "" and rel:sub(-1) ~= "/" then rel = rel .. "/" end
|
||||
self.modNotice = {
|
||||
ok = true,
|
||||
text = Strings("Copy your .zip into:\n%s/imports/mods/\nDBI MTP → 1: SD Card/%simports/mods/",
|
||||
saveDir, rel),
|
||||
}
|
||||
end
|
||||
|
||||
function RomImporter:_resolveSaveVersion(version)
|
||||
version = version or self.panelVersion or self.tab
|
||||
if GameVersion.VERSIONS[version] then return version end
|
||||
return self:_savedropTarget()
|
||||
end
|
||||
|
||||
function RomImporter:_setNxSavesInboxNotice(version)
|
||||
version = self:_resolveSaveVersion(version)
|
||||
local inbox = savesInboxDir(version)
|
||||
local saveDir = love.filesystem.getSaveDirectory()
|
||||
local rel = RomImporter.mtpHintPath(saveDir)
|
||||
if rel ~= "" and rel:sub(-1) ~= "/" then rel = rel .. "/" end
|
||||
local game = GameVersion.info(version).displayName
|
||||
self.saveNotice = self.saveNotice or {}
|
||||
self.saveNotice[version] = {
|
||||
ok = true,
|
||||
text = Strings("Copy your %s .sav into:\n%s/%s/\nDBI MTP → 1: SD Card/%s%s/",
|
||||
game, saveDir, inbox, rel, inbox),
|
||||
}
|
||||
end
|
||||
|
||||
local function listRomPaths(dir)
|
||||
local paths = {}
|
||||
for _, name in ipairs(love.filesystem.getDirectoryItems(dir) or {}) do
|
||||
-- Skip AppleDouble / hidden junk from Mac MTP (._cart.gb ends in .gb
|
||||
-- but is not a ROM — rescan would try it first and block the real dump).
|
||||
if name:sub(1, 1) ~= "." then
|
||||
local path = (dir == "" or dir == "/") and name or (dir .. "/" .. name)
|
||||
if name:lower():match("%.gbc?$")
|
||||
and love.filesystem.getInfo(path, "file") then
|
||||
paths[#paths + 1] = path
|
||||
end
|
||||
end
|
||||
end
|
||||
return paths
|
||||
end
|
||||
|
||||
local function listZipPaths(dir)
|
||||
local paths = {}
|
||||
for _, name in ipairs(love.filesystem.getDirectoryItems(dir) or {}) do
|
||||
-- Skip AppleDouble / hidden junk from Mac MTP (._foo.zip ends in .zip
|
||||
-- but is not a PhysFS archive — mount fails with "could not be opened").
|
||||
if name:sub(1, 1) ~= "." then
|
||||
local path = (dir == "" or dir == "/") and name or (dir .. "/" .. name)
|
||||
if name:lower():match("%.zip$")
|
||||
and love.filesystem.getInfo(path, "file") then
|
||||
paths[#paths + 1] = path
|
||||
end
|
||||
end
|
||||
end
|
||||
return paths
|
||||
end
|
||||
|
||||
local function listSavPaths(dir)
|
||||
local paths = {}
|
||||
for _, name in ipairs(love.filesystem.getDirectoryItems(dir) or {}) do
|
||||
-- Skip AppleDouble / hidden junk from Mac MTP (._foo.sav ends in .sav
|
||||
-- but is not a real battery save — import would fail and invent noise).
|
||||
if name:sub(1, 1) ~= "." then
|
||||
local path = (dir == "" or dir == "/") and name or (dir .. "/" .. name)
|
||||
if name:lower():match("%.sav$")
|
||||
and love.filesystem.getInfo(path, "file") then
|
||||
paths[#paths + 1] = path
|
||||
end
|
||||
end
|
||||
end
|
||||
return paths
|
||||
end
|
||||
|
||||
function RomImporter:scanInbox()
|
||||
local paths = {}
|
||||
for _, path in ipairs(listRomPaths(IMPORTS_DIR)) do
|
||||
paths[#paths + 1] = path
|
||||
end
|
||||
for _, path in ipairs(listRomPaths("")) do
|
||||
-- Root scan is second; imports/ entries were already collected above.
|
||||
paths[#paths + 1] = path
|
||||
end
|
||||
return paths
|
||||
end
|
||||
|
||||
-- NX mods inbox: only *.zip under imports/mods/ (never ROM extensions).
|
||||
function RomImporter:scanModsInbox()
|
||||
self:ensureModsInboxDir()
|
||||
return listZipPaths(MODS_INBOX_DIR)
|
||||
end
|
||||
|
||||
-- NX saves inbox: only non-hidden *.sav under imports/saves/<version>/.
|
||||
function RomImporter:scanSavesInbox(version)
|
||||
version = self:_resolveSaveVersion(version)
|
||||
self:ensureSavesInboxDir(version)
|
||||
return listSavPaths(savesInboxDir(version))
|
||||
end
|
||||
|
||||
local function loadImportedSavHashes(version)
|
||||
local set = {}
|
||||
local raw = love.filesystem.read(savesImportedHashesPath(version))
|
||||
if type(raw) ~= "string" then return set end
|
||||
for line in raw:gmatch("[^\r\n]+") do
|
||||
local h = line:match("^(%x+)$")
|
||||
if h then set[h] = true end
|
||||
end
|
||||
return set
|
||||
end
|
||||
|
||||
local function appendImportedSavHash(version, hash)
|
||||
if type(hash) ~= "string" or hash == "" then return end
|
||||
local path = savesImportedHashesPath(version)
|
||||
local prev = love.filesystem.read(path) or ""
|
||||
if prev:find(hash, 1, true) then return end
|
||||
love.filesystem.write(path, prev .. hash .. string.char(10))
|
||||
end
|
||||
|
||||
-- Keep bytes for the player (MTP recovery) but stop matching %.sav$ on rescan.
|
||||
local function retireImportedSav(path)
|
||||
if type(path) ~= "string" or path == "" then return false end
|
||||
local data = love.filesystem.read(path)
|
||||
if type(data) ~= "string" then return false end
|
||||
local dest = path .. ".imported"
|
||||
if love.filesystem.getInfo(dest) then
|
||||
dest = path .. ".imported." .. tostring(os.time())
|
||||
end
|
||||
if not love.filesystem.write(dest, data) then return false end
|
||||
love.filesystem.remove(path)
|
||||
return true
|
||||
end
|
||||
|
||||
-- Rescan imports/mods/: install each .zip via _installMod / installZip.
|
||||
-- Never deletes inbox zips (success or failure). Empty inbox → MTP notice.
|
||||
function RomImporter:rescanModsAction()
|
||||
if self.workState == "working" then return end
|
||||
self.tab = "mods"
|
||||
self:ensureModsInboxDir()
|
||||
local candidates = self:scanModsInbox()
|
||||
if #candidates == 0 then
|
||||
self:_setNxModsInboxNotice()
|
||||
return
|
||||
end
|
||||
local anyOk = false
|
||||
local lastOk = nil
|
||||
local lastFail = nil
|
||||
local failCount = 0
|
||||
for _, path in ipairs(candidates) do
|
||||
-- Reuse _installMod carefully: it must not remove the inbox source.
|
||||
self:_installMod(path)
|
||||
if self.modNotice and self.modNotice.ok then
|
||||
anyOk = true
|
||||
lastOk = self.modNotice
|
||||
else
|
||||
failCount = failCount + 1
|
||||
lastFail = self.modNotice
|
||||
end
|
||||
end
|
||||
-- Success wins overall ok=true so a leftover MTP junk sibling cannot hide
|
||||
-- a good install; still append the last failure so a real broken zip is
|
||||
-- visible beside the success line.
|
||||
if anyOk and lastFail then
|
||||
local okText = (lastOk and lastOk.text) or "Installed"
|
||||
local failText = (lastFail and lastFail.text) or "unknown error"
|
||||
self.modNotice = {
|
||||
ok = true,
|
||||
text = Strings("%s\n(%d failed: %s)", okText, failCount, failText),
|
||||
}
|
||||
elseif anyOk then
|
||||
self.modNotice = lastOk
|
||||
elseif lastFail then
|
||||
self.modNotice = lastFail
|
||||
end
|
||||
end
|
||||
|
||||
-- Rescan imports/saves/<version>/: import each new .sav via _importSave.
|
||||
-- Failure retains the original .sav. Success records a per-game content hash
|
||||
-- and retires the file to `*.sav.imported` so a second Import save cannot clone
|
||||
-- slots (bytes stay in the inbox for MTP recovery). Already-hashed content
|
||||
-- is skipped even under a new filename. Empty / AppleDouble-only → MTP notice.
|
||||
function RomImporter:rescanSavesAction(version)
|
||||
if self.workState == "working" then return end
|
||||
version = self:_resolveSaveVersion(version)
|
||||
self:ensureSavesInboxDir(version)
|
||||
local candidates = self:scanSavesInbox(version)
|
||||
if #candidates == 0 then
|
||||
self:_setNxSavesInboxNotice(version)
|
||||
return
|
||||
end
|
||||
local seenHashes = loadImportedSavHashes(version)
|
||||
local okCount, failCount, skipCount = 0, 0, 0
|
||||
local lastOk, lastFail = nil, nil
|
||||
local gameLabel = GameVersion.info(version).displayName
|
||||
for _, path in ipairs(candidates) do
|
||||
local data = love.filesystem.read(path)
|
||||
local hash = (type(data) == "string" and data ~= "") and sha1(data) or nil
|
||||
if hash and seenHashes[hash] then
|
||||
skipCount = skipCount + 1
|
||||
-- Leftover live .sav after a prior success: retire without re-importing.
|
||||
retireImportedSav(path)
|
||||
else
|
||||
self:_importSave(version, path)
|
||||
local notice = self.saveNotice and self.saveNotice[version]
|
||||
if notice and notice.ok then
|
||||
okCount = okCount + 1
|
||||
lastOk = notice
|
||||
if hash then
|
||||
seenHashes[hash] = true
|
||||
appendImportedSavHash(version, hash)
|
||||
end
|
||||
retireImportedSav(path)
|
||||
else
|
||||
failCount = failCount + 1
|
||||
lastFail = notice
|
||||
end
|
||||
end
|
||||
end
|
||||
if okCount > 0 then
|
||||
local okText
|
||||
if okCount == 1 and lastOk then
|
||||
okText = Strings("%s (%s tab)", lastOk.text, gameLabel)
|
||||
else
|
||||
okText = Strings("Imported %d saves into %s. Active: %s.",
|
||||
okCount, gameLabel, tostring(self.activeSlot[version]))
|
||||
end
|
||||
if failCount > 0 then
|
||||
local failText = (lastFail and lastFail.text) or "unknown error"
|
||||
okText = Strings("%s\n(%d failed: %s)", okText, failCount, failText)
|
||||
end
|
||||
if skipCount > 0 then
|
||||
okText = Strings("%s\n(%d already imported, skipped)", okText, skipCount)
|
||||
end
|
||||
self.saveNotice[version] = { ok = true, text = okText }
|
||||
elseif failCount > 0 then
|
||||
self.saveNotice[version] = lastFail
|
||||
elseif skipCount > 0 then
|
||||
self.saveNotice[version] = {
|
||||
ok = true,
|
||||
text = Strings("Already imported — %d file(s) skipped. Check SAVE SLOT.",
|
||||
skipCount),
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
-- NX "Scan again" on a game tab: import only the dump whose SHA-1 matches
|
||||
-- that tab. A shared imports/ inbox often holds Red+Blue+Yellow at once;
|
||||
-- picking the first pending file would jump Yellow → Red (and switch the
|
||||
-- launcher tab via startData). Other known dumps stay for their own tabs.
|
||||
-- Junk (wrong size / unknown hash) still surfaces when nothing matches the
|
||||
-- tab and no other known dump is present — same feedback as before for a
|
||||
-- lone bad file.
|
||||
function RomImporter:rescanAction(version)
|
||||
if self.workState == "working" then return end
|
||||
version = version or self.tab or "red"
|
||||
self.chooseVersion = version
|
||||
self:ensureImportsDir()
|
||||
local ready = self.ready
|
||||
local candidates = self:scanInbox()
|
||||
local targetReady = false
|
||||
local sawOtherVersion = false
|
||||
local junkData, junkName = nil, nil
|
||||
for _, path in ipairs(candidates) do
|
||||
local data = love.filesystem.read(path)
|
||||
local displayName = path:match("[^/\\]+$") or path
|
||||
if type(data) ~= "string" then
|
||||
self:setError("The file could not be read: " .. displayName, version)
|
||||
return
|
||||
end
|
||||
if #data ~= ROM_BYTES then
|
||||
if not junkData then junkData, junkName = data, displayName end
|
||||
else
|
||||
local romVersion = GameVersion.forSha1(sha1(data))
|
||||
if not romVersion then
|
||||
if not junkData then junkData, junkName = data, displayName end
|
||||
elseif romVersion ~= version then
|
||||
sawOtherVersion = true
|
||||
elseif ready[romVersion] then
|
||||
targetReady = true
|
||||
else
|
||||
self:startData(data, displayName)
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
if targetReady then
|
||||
self.notice = {
|
||||
version = version,
|
||||
status = Strings("No new ROM found."),
|
||||
detail = Strings("Already-imported dumps are ignored. Add another version or "
|
||||
.. "delete the copy when finished."),
|
||||
}
|
||||
return
|
||||
end
|
||||
if junkData and not sawOtherVersion then
|
||||
self:startData(junkData, junkName)
|
||||
return
|
||||
end
|
||||
if #candidates > 0 then
|
||||
local label = GameVersion.info(version).displayName
|
||||
self.notice = {
|
||||
version = version,
|
||||
status = Strings("No matching ROM found."),
|
||||
detail = Strings(
|
||||
"%s is matched by SHA-1 on this tab. Other dumps in imports/ stay "
|
||||
.. "for their own tabs — open that game and Scan again.", label),
|
||||
}
|
||||
return
|
||||
end
|
||||
self:_setNxInboxNotice(version)
|
||||
end
|
||||
|
||||
function RomImporter:_romAction(version)
|
||||
if self.isNX then
|
||||
if self.ready[version] then self:reimport(version)
|
||||
else self:rescanAction(version) end
|
||||
elseif self.ready[version] then self:reimport(version)
|
||||
else self:choose(version) end
|
||||
end
|
||||
|
||||
-- Sanitize a string before it is interpolated into a picker shell command:
|
||||
-- * "%" would be eaten as a string.format directive (#665);
|
||||
-- * '"' would break the AppleScript / zenity double-quoted argument and
|
||||
@@ -572,6 +986,7 @@ end
|
||||
-- import-only run all skip the release check so headless and CI runs never spin
|
||||
-- up the background worker or reach out to the network.
|
||||
local function updaterAllowed()
|
||||
if not Platform.networkValidated() then return false end
|
||||
if not (love.filesystem.isFused and love.filesystem.isFused()) then return false end
|
||||
if os.getenv("POKEPORT_AUTOPILOT") or os.getenv("POKEPORT_DRIVER") then return false end
|
||||
if os.getenv("POKEPORT_IMPORT_ONLY") == "1" then return false end
|
||||
@@ -596,8 +1011,13 @@ function RomImporter.new(onComplete, opts)
|
||||
-- pending-file scan plus love.system.pickFile / createFile, provided
|
||||
-- natively by the Swift GRPickerBridge (mobile/ios/native/). The flag
|
||||
-- keeps its historical name so every Android call site stays untouched.
|
||||
-- NX uses a separate save-directory inbox (isNX / romImportMode) and must
|
||||
-- never set android or take the mobile delete-after-import path.
|
||||
local mobileOS = love.system.getOS()
|
||||
local android = mobileOS == "Android" or mobileOS == "iOS"
|
||||
local isNX = Platform.isNX()
|
||||
local romImportMode = Platform.romImportMode()
|
||||
local mobileFileBridge = mobileOS == "Android" or mobileOS == "iOS"
|
||||
local android = mobileFileBridge
|
||||
local CacheFs = require("src.import.CacheFs")
|
||||
local self = setmetatable({
|
||||
onComplete = onComplete,
|
||||
@@ -605,6 +1025,9 @@ function RomImporter.new(onComplete, opts)
|
||||
forceImport = opts.forceImport or false,
|
||||
onEditSave = opts.onEditSave,
|
||||
onEditTouchControls = opts.onEditTouchControls,
|
||||
isNX = isNX,
|
||||
romImportMode = romImportMode,
|
||||
mobileFileBridge = mobileFileBridge,
|
||||
android = android,
|
||||
ios = mobileOS == "iOS",
|
||||
-- One startup poll pass on both mobiles. iOS: files dropped through the
|
||||
@@ -616,11 +1039,11 @@ function RomImporter.new(onComplete, opts)
|
||||
-- pick never arrives. The file is sitting in the save dir either way, so
|
||||
-- boot armed and let the first poll tick consume it, rather than making the
|
||||
-- player tap Import a second time to trigger the scan by hand (#553).
|
||||
pickPending = android or nil,
|
||||
pickPending = mobileFileBridge or nil,
|
||||
-- Mobile drag-scroll goes through FlexLove.touch* (main.lua forwards the
|
||||
-- full touch stream while the launcher is up). love.touch remains pollable
|
||||
-- for click hit-testing inside EventHandler.
|
||||
touchPollable = android and love.touch ~= nil
|
||||
touchPollable = mobileFileBridge and love.touch ~= nil
|
||||
and love.touch.getTouches ~= nil and love.touch.getPosition ~= nil,
|
||||
tab = "red", -- active launcher tab: "red"/"blue"/"yellow"/"mods"
|
||||
logo = love.graphics.newImage("assets/logo/logo.png"),
|
||||
@@ -702,7 +1125,7 @@ function RomImporter.new(onComplete, opts)
|
||||
for _, version in ipairs(GameVersion.ORDER) do
|
||||
if not self.ready[version] then needRom = true; break end
|
||||
end
|
||||
if android and needRom then
|
||||
if mobileFileBridge and needRom then
|
||||
local name, data = findPendingRom(self.ready)
|
||||
if name then
|
||||
self:startData(data, name)
|
||||
@@ -711,6 +1134,9 @@ function RomImporter.new(onComplete, opts)
|
||||
-- is up, so a rejected pick can outlive the focus handler (#442).
|
||||
consumePickedRomError(self)
|
||||
end
|
||||
elseif self.isNX and self.launcher then
|
||||
self:ensureImportsDir()
|
||||
self:_setNxInboxNotice()
|
||||
end
|
||||
|
||||
-- Mouse-wheel scroll for the save-slot / mods lists. main.lua (off limits)
|
||||
@@ -740,13 +1166,16 @@ function RomImporter.new(onComplete, opts)
|
||||
end
|
||||
end
|
||||
|
||||
-- On Linux handhelds a gamepad is usually already connected at boot; arm
|
||||
-- the virtual cursor immediately so the player does not have to press a
|
||||
-- button before seeing something move.
|
||||
if self.launcher and love.system.getOS() == "Linux"
|
||||
and love.joystick and love.joystick.getJoystickCount
|
||||
-- On Linux handhelds / NX a gamepad is usually already connected at boot;
|
||||
-- arm the virtual cursor immediately so the player does not have to press a
|
||||
-- button before seeing something move. Desktop keeps the cursor latent
|
||||
-- until the first stick bump so a plugged DualSense does not steal the mouse.
|
||||
if self.launcher and love.joystick and love.joystick.getJoystickCount
|
||||
and love.joystick.getJoystickCount() > 0 then
|
||||
self:_activatePadCursor()
|
||||
local osName = (love.system and love.system.getOS and love.system.getOS()) or ""
|
||||
if osName == "Linux" or self.isNX then
|
||||
self:_activatePadCursor()
|
||||
end
|
||||
end
|
||||
|
||||
return self
|
||||
@@ -940,7 +1369,7 @@ function RomImporter:startData(data, displayName)
|
||||
and (displayName:match("[^/\\]+$") or displayName)) or self.romName[version]
|
||||
-- Android: drop the consumed save-dir .gb/.gbc (picked_rom.gb or a USB copy)
|
||||
-- so the next Choose / focus cannot treat it as a fresh pending ROM.
|
||||
if self.android and type(displayName) == "string"
|
||||
if self.mobileFileBridge and type(displayName) == "string"
|
||||
and not displayName:find("[/\\]") then
|
||||
love.filesystem.remove(displayName)
|
||||
end
|
||||
@@ -948,7 +1377,14 @@ function RomImporter:startData(data, displayName)
|
||||
self.workState = "complete"
|
||||
self.completeVersion = version
|
||||
self.status = "Ready"
|
||||
self.detail = "Starting " .. info.displayName .. "..."
|
||||
-- NX launcher stays put: keep the imports/ cleanup hint instead of
|
||||
-- overwriting it with a "Starting…" line that never boots from here.
|
||||
if self.launcher and self.isNX and type(displayName) == "string" then
|
||||
self.detail = Strings("%s imported. You may delete the copy from "
|
||||
.. "imports/ when finished.", displayName)
|
||||
else
|
||||
self.detail = "Starting " .. info.displayName .. "..."
|
||||
end
|
||||
self.progress = 1
|
||||
if self.launcher then
|
||||
-- Stay on the launcher; the player presses Play to boot the new game.
|
||||
@@ -1045,8 +1481,14 @@ end
|
||||
-- Android mirrors ROM import: scan for a pending .zip in the save dir (USB
|
||||
-- or a fresh SAF drop), else love.system.pickFile("mod") -> picked_mod.zip
|
||||
-- which focus/Choose consumes on return.
|
||||
-- NX: no HostShell/desktop picker — rescan imports/mods/ inbox instead.
|
||||
function RomImporter:chooseMod()
|
||||
if self.workState == "working" then return end
|
||||
if self.isNX then
|
||||
self:ensureModsInboxDir()
|
||||
self:rescanModsAction()
|
||||
return
|
||||
end
|
||||
if self.ios and love.system.getPickedFile then
|
||||
self.iosPendingKind = "mod"
|
||||
if not pickFile("mod") then
|
||||
@@ -1114,8 +1556,15 @@ end
|
||||
|
||||
-- "Import save" button: open a native .sav picker and import the pick.
|
||||
-- Android mirrors ROM / mod import via love.system.pickFile("sav").
|
||||
-- NX: no HostShell/desktop picker — rescan imports/saves/ inbox instead.
|
||||
function RomImporter:chooseSaveImport(version)
|
||||
if self.workState == "working" then return end
|
||||
version = self:_resolveSaveVersion(version)
|
||||
if self.isNX then
|
||||
self:ensureSavesInboxDir(version)
|
||||
self:rescanSavesAction(version)
|
||||
return
|
||||
end
|
||||
if self.ios and love.system.getPickedFile then
|
||||
self.iosPendingKind = "sav"
|
||||
self.iosPendingVersion = version
|
||||
@@ -1155,15 +1604,29 @@ end
|
||||
-- affordance. On Android, stage pending_export.sav and open the system
|
||||
-- create-document picker (love.system.createFile) so the player can save to
|
||||
-- Downloads / Drive / etc. -- the app-private exports/ path is not useful there.
|
||||
-- NX: surface exports path + MTP hint; do not rely on openURL / open-folder.
|
||||
function RomImporter:exportSave(version)
|
||||
if self.workState == "working" then return end
|
||||
version = self:_resolveSaveVersion(version)
|
||||
local ok, res = require("src.import.SaveFileIO").exportActiveSlot(version)
|
||||
if not ok then
|
||||
self.saveNotice[version] = { ok = false, text = tostring(res) }
|
||||
return
|
||||
end
|
||||
if self.isNX then
|
||||
local saveDir = love.filesystem.getSaveDirectory()
|
||||
local rel = RomImporter.mtpHintPath(saveDir)
|
||||
if rel ~= "" and rel:sub(-1) ~= "/" then rel = rel .. "/" end
|
||||
local outDir = exportsDir(version)
|
||||
self.saveNotice[version] = {
|
||||
ok = true,
|
||||
text = Strings("Exported to %s\nDBI MTP → 1: SD Card/%s%s/", res, rel, outDir),
|
||||
}
|
||||
return
|
||||
end
|
||||
if self.android then
|
||||
local rel = res:match("exports[/\\][^/\\]+$")
|
||||
local rel = res:match("(exports[/\\].+%.[Ss][Aa][Vv])$")
|
||||
or res:match("(exports[/\\].+)$")
|
||||
local data = rel and love.filesystem.read(rel)
|
||||
if not data then
|
||||
self.saveNotice[version] = { ok = false,
|
||||
@@ -1215,6 +1678,11 @@ end
|
||||
function RomImporter:choose(version)
|
||||
if self.workState == "working" then return end
|
||||
self.chooseVersion = version or "red"
|
||||
if self.isNX then
|
||||
-- Same path as the Scan again button: rescan imports/ (or show MTP hint).
|
||||
self:rescanAction(self.chooseVersion)
|
||||
return
|
||||
end
|
||||
if self.ios and love.system.getPickedFile then
|
||||
self.iosPendingKind = "rom"
|
||||
if not pickFile("rom") then
|
||||
@@ -1469,6 +1937,71 @@ function RomImporter:_activatePadCursor()
|
||||
self._padCursorActive = true
|
||||
end
|
||||
|
||||
-- NX: FlexLove hover/hit-test polls love.mouse.getPosition every interactive
|
||||
-- element. Warping via setPosition every stick frame is expensive on love-nx
|
||||
-- and makes the virtual cursor lag. Expose the pad pointer through a getPosition
|
||||
-- shim instead; desktop keeps the setPosition path unchanged.
|
||||
function RomImporter:_ensureNxPointerBridge()
|
||||
if not self.isNX or self._nxPointerBridge then return end
|
||||
if not (love and love.mouse and love.mouse.getPosition) then return end
|
||||
self._nxRealGetPosition = love.mouse.getPosition
|
||||
local importer = self
|
||||
love.mouse.getPosition = function()
|
||||
if importer._padCursorActive then
|
||||
return importer._padCursor.x, importer._padCursor.y
|
||||
end
|
||||
return importer._nxRealGetPosition()
|
||||
end
|
||||
self._nxPointerBridge = true
|
||||
end
|
||||
|
||||
function RomImporter:_restoreNxPointerBridge()
|
||||
if not self._nxPointerBridge then return end
|
||||
if love and love.mouse and self._nxRealGetPosition then
|
||||
love.mouse.getPosition = self._nxRealGetPosition
|
||||
end
|
||||
self._nxPointerBridge = false
|
||||
self._nxRealGetPosition = nil
|
||||
end
|
||||
|
||||
-- NX only: drop the getPosition shim + hide the virtual cursor before a host
|
||||
-- takes over input (embedded save editor). Desktop is a no-op.
|
||||
function RomImporter:parkNxPointerForHost()
|
||||
if not self.isNX then return end
|
||||
self._padCursorActive = false
|
||||
self:_restoreNxPointerBridge()
|
||||
end
|
||||
|
||||
-- Temporary overlay handoff (Edit Save / Touch Controls): restore the system
|
||||
-- arrow cursor, hide the virtual pad pointer, tear down FlexLove when the
|
||||
-- view is already loaded, and drop the NX getPosition shim. Play uses
|
||||
-- resetPointerCursor + detach directly because it never returns here.
|
||||
function RomImporter:prepareOverlayHandoff()
|
||||
resetPointerCursor(self)
|
||||
self._padCursorActive = false
|
||||
-- Avoid requiring LauncherView from headless unit tests (no luautf8). In
|
||||
-- a real session draw() has already loaded it, so detach runs normally.
|
||||
if self._flex and package.loaded["src.import.LauncherView"] then
|
||||
require("src.import.LauncherView").detach(self)
|
||||
else
|
||||
self._flex = nil
|
||||
self:parkNxPointerForHost()
|
||||
end
|
||||
end
|
||||
|
||||
-- After an overlay closes: re-arm the pad cursor when a stick is already
|
||||
-- connected so NX / handhelds are not stranded without a pointer until the
|
||||
-- next stick bump (same class of bug as opening Touch Controls).
|
||||
function RomImporter:resumeAfterOverlay()
|
||||
if not self.launcher then return end
|
||||
if not (love.joystick and love.joystick.getJoystickCount) then return end
|
||||
if love.joystick.getJoystickCount() <= 0 then return end
|
||||
local osName = (love.system and love.system.getOS and love.system.getOS()) or ""
|
||||
if osName == "Linux" or self.isNX then
|
||||
self:_activatePadCursor()
|
||||
end
|
||||
end
|
||||
|
||||
function RomImporter:_cycleTab(delta)
|
||||
local order = { "red", "blue", "yellow", "mods", "find" }
|
||||
local idx = 1
|
||||
@@ -1479,15 +2012,26 @@ function RomImporter:_cycleTab(delta)
|
||||
end
|
||||
|
||||
function RomImporter:_updatePadCursor(dt)
|
||||
-- Real mouse motion yields the pad cursor so desktop users keep a normal
|
||||
-- pointer after bumping a stick once.
|
||||
local mx, my = love.mouse.getPosition()
|
||||
if self._lastMouseX and self._padCursorActive then
|
||||
if math.abs(mx - self._lastMouseX) > 3 or math.abs(my - self._lastMouseY) > 3 then
|
||||
self._padCursorActive = false
|
||||
end
|
||||
if self.isNX then
|
||||
self:_ensureNxPointerBridge()
|
||||
-- Cap dt so a hitch in the FlexLove immediate-mode frame does not fling
|
||||
-- the cursor; desktop keeps raw dt (setPosition path already smooth there).
|
||||
if dt > 1 / 30 then dt = 1 / 30 end
|
||||
end
|
||||
|
||||
-- Real mouse motion yields the pad cursor so desktop users keep a normal
|
||||
-- pointer after bumping a stick once. On NX this must stay off: love-nx /
|
||||
-- SDL often drifts the system mouse with the stick (or touch), and axis
|
||||
-- events are not every frame, so yield+reactivate flickers the overlay.
|
||||
if not self.isNX then
|
||||
local mx, my = love.mouse.getPosition()
|
||||
if self._lastMouseX and self._padCursorActive then
|
||||
if math.abs(mx - self._lastMouseX) > 3 or math.abs(my - self._lastMouseY) > 3 then
|
||||
self._padCursorActive = false
|
||||
end
|
||||
end
|
||||
self._lastMouseX, self._lastMouseY = mx, my
|
||||
end
|
||||
self._lastMouseX, self._lastMouseY = mx, my
|
||||
|
||||
local ax = self._padAxis.leftx or 0
|
||||
local ay = self._padAxis.lefty or 0
|
||||
@@ -1510,11 +2054,9 @@ function RomImporter:_updatePadCursor(dt)
|
||||
local ny = self._padCursor.y + dy * speed * dt
|
||||
self._padCursor.x = math.max(ox, math.min(ox + w, nx))
|
||||
self._padCursor.y = math.max(oy, math.min(oy + h, ny))
|
||||
-- The FlexLove view polls the real mouse for hover and wheel targeting,
|
||||
-- so the pad pointer warps it along. The self-caused motion is recorded
|
||||
-- as the last seen position, or the yield check above would read the warp
|
||||
-- as real mouse movement and drop the pad cursor immediately.
|
||||
if love.mouse.setPosition then
|
||||
-- Desktop: FlexLove polls the real mouse, so warp it with the pad pointer.
|
||||
-- NX: the getPosition bridge already returns pad coords — skip setPosition.
|
||||
if not self.isNX and love.mouse.setPosition then
|
||||
pcall(love.mouse.setPosition, self._padCursor.x, self._padCursor.y)
|
||||
self._lastMouseX, self._lastMouseY = self._padCursor.x, self._padCursor.y
|
||||
end
|
||||
@@ -1532,7 +2074,9 @@ end
|
||||
|
||||
function RomImporter:gamepadpressed(_, button)
|
||||
self:_activatePadCursor()
|
||||
if button == "a" then
|
||||
-- Map through GamepadMap so NX swaps SDL face labels to Nintendo A/B.
|
||||
local action = GamepadMap.mapGamepadButton(button)
|
||||
if action == "a" then
|
||||
-- Instant click at the virtual pointer: dispatched straight into the
|
||||
-- view, since the launcher no longer hit-tests presses itself.
|
||||
if self._flex then
|
||||
@@ -1551,7 +2095,7 @@ function RomImporter:gamepadpressed(_, button)
|
||||
if self.workState == "working" then return end
|
||||
local version = self.tab
|
||||
if GameVersion.VERSIONS[version] then
|
||||
if self.ready[version] then self:play(version) else self:choose(version) end
|
||||
if self.ready[version] then self:play(version) else self:_romAction(version) end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1570,25 +2114,23 @@ function RomImporter:gamepadaxis(_, axis, value)
|
||||
end
|
||||
end
|
||||
|
||||
-- Same gate as src/core/Input.lua's isMappedPad: a pad SDL can map already
|
||||
-- reached gamepadpressed this frame, so re-entering it from the raw event
|
||||
-- would fire the virtual cursor's click twice off one A press (#620).
|
||||
local function isMappedPad(joystick)
|
||||
return joystick ~= nil and joystick.isGamepad ~= nil and joystick:isGamepad()
|
||||
end
|
||||
|
||||
-- Same gate as src/core/Input.lua: a pad SDL can map already reached
|
||||
-- gamepadpressed this frame, so re-entering it from the raw event would
|
||||
-- fire the virtual cursor's click twice off one A press (#620).
|
||||
function RomImporter:joystickpressed(joystick, button)
|
||||
if isMappedPad(joystick) then return end
|
||||
if button == 1 then self:gamepadpressed(joystick, "a") end
|
||||
if GamepadMap.ignoreRawForJoystick(joystick) then return end
|
||||
local padButton = GamepadMap.mapRawToGamepadButton(button)
|
||||
if padButton then self:gamepadpressed(joystick, padButton) end
|
||||
end
|
||||
|
||||
function RomImporter:joystickreleased(joystick, button)
|
||||
if isMappedPad(joystick) then return end
|
||||
if button == 1 then self:gamepadreleased(joystick, "a") end
|
||||
if GamepadMap.ignoreRawForJoystick(joystick) then return end
|
||||
local padButton = GamepadMap.mapRawToGamepadButton(button)
|
||||
if padButton then self:gamepadreleased(joystick, padButton) end
|
||||
end
|
||||
|
||||
function RomImporter:joystickaxis(joystick, axis, value)
|
||||
if isMappedPad(joystick) then return end
|
||||
if GamepadMap.ignoreRawForJoystick(joystick) then return end
|
||||
if axis == 1 then
|
||||
self:gamepadaxis(joystick, "leftx", value)
|
||||
elseif axis == 2 then
|
||||
@@ -1597,7 +2139,7 @@ function RomImporter:joystickaxis(joystick, axis, value)
|
||||
end
|
||||
|
||||
function RomImporter:joystickhat(joystick, hat, direction)
|
||||
if isMappedPad(joystick) then return end
|
||||
if GamepadMap.ignoreRawForJoystick(joystick) then return end
|
||||
for _, dir in ipairs(self._rawHatDirs[hat] or {}) do
|
||||
self._padDir[dir] = nil
|
||||
end
|
||||
@@ -1733,6 +2275,15 @@ function RomImporter:_switchTab(id)
|
||||
self:_disarmTextInput()
|
||||
end
|
||||
|
||||
function RomImporter:_toggleFindSearchFocus()
|
||||
self._findSearchFocus = not self._findSearchFocus
|
||||
if self._findSearchFocus then
|
||||
self:_armTextInput()
|
||||
else
|
||||
self:_disarmTextInput()
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- settings gear (options.lua + enabled mods' option schemas)
|
||||
|
||||
function RomImporter:_openSettings()
|
||||
@@ -1835,7 +2386,7 @@ function RomImporter:keypressed(key)
|
||||
-- open its picker. The mods tab has no keyboard action.
|
||||
local version = self.tab
|
||||
if GameVersion.VERSIONS[version] then
|
||||
if self.ready[version] then self:play(version) else self:choose(version) end
|
||||
if self.ready[version] then self:play(version) else self:_romAction(version) end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -2143,6 +2694,11 @@ end
|
||||
-- Update button: when a newer release is known, confirm then install; when
|
||||
-- already current, force-refresh the 6h cache and report / offer update.
|
||||
function RomImporter:_modGithubAction(id, action)
|
||||
if not Platform.networkValidated() then
|
||||
self.modNotice = { ok = false,
|
||||
text = "Remote mod download is unavailable on this platform." }
|
||||
return
|
||||
end
|
||||
local ran, err = pcall(function()
|
||||
local ModUpdate = require("src.mods.ModUpdate")
|
||||
local row
|
||||
@@ -2282,6 +2838,53 @@ function RomImporter:_installModVersion(modId, release)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
-- NX / desktop / Android labels and inbox hints for the FlexLove view.
|
||||
function RomImporter:_modsImportButtonLabel()
|
||||
if self.isNX then return Strings("Scan again") end
|
||||
return "Import mod .zip"
|
||||
end
|
||||
|
||||
function RomImporter:_modsDefaultHint()
|
||||
if self.isNX then
|
||||
local saveDir = love.filesystem.getSaveDirectory()
|
||||
local rel = RomImporter.mtpHintPath(saveDir)
|
||||
if rel ~= "" and rel:sub(-1) ~= "/" then rel = rel .. "/" end
|
||||
return Strings("Copy a .zip via MTP into %s/imports/mods/\n"
|
||||
.. "DBI MTP → 1: SD Card/%simports/mods/", saveDir, rel)
|
||||
end
|
||||
if self.android then return "Or copy a mod .zip via USB." end
|
||||
return Strings("Or drop a mod .zip onto the window.")
|
||||
end
|
||||
|
||||
function RomImporter:_savesDefaultHint(version)
|
||||
if self.isNX then
|
||||
version = self:_resolveSaveVersion(version)
|
||||
local inbox = savesInboxDir(version)
|
||||
local saveDir = love.filesystem.getSaveDirectory()
|
||||
local rel = RomImporter.mtpHintPath(saveDir)
|
||||
if rel ~= "" and rel:sub(-1) ~= "/" then rel = rel .. "/" end
|
||||
local game = GameVersion.info(version).displayName
|
||||
return Strings("Copy a %s .sav via MTP into %s/%s/\n"
|
||||
.. "DBI MTP → 1: SD Card/%s%s/", game, saveDir, inbox, rel, inbox)
|
||||
end
|
||||
if self.android then
|
||||
return "Import or export a .sav with the system file picker."
|
||||
end
|
||||
return Strings("Import a .sav to a new slot, or export the active slot.")
|
||||
end
|
||||
|
||||
function RomImporter:_modsEmptyHint()
|
||||
if self.isNX then
|
||||
return Strings("No mods installed - copy a .zip into imports/mods/ "
|
||||
.. "and tap Scan again.")
|
||||
end
|
||||
if self.android then
|
||||
return "No mods installed - tap Import mod .zip to add one."
|
||||
end
|
||||
return Strings("No mods installed - drop a mod .zip here to add one.")
|
||||
end
|
||||
|
||||
-- ------- FIND MODS: browsing a community mod index -------------------------
|
||||
--
|
||||
-- The index is metadata only (src/mods/ModIndex.lua): it says where a mod's
|
||||
@@ -2309,6 +2912,11 @@ end
|
||||
-- must not offer two. Per-source failures are collected rather than fatal: an
|
||||
-- index that is down should cost its own rows, not everybody else's.
|
||||
function RomImporter:_refreshFind(force)
|
||||
if not Platform.networkValidated() then
|
||||
self.findLoaded = true
|
||||
self.findIndex = { mods = {}, categories = {} }
|
||||
return
|
||||
end
|
||||
local ModIndex = require("src.mods.ModIndex")
|
||||
self:_refreshFindSources()
|
||||
local mods, seen, cats, catSeen, errs = {}, {}, {}, {}, {}
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
-- bytes), runs them through SaveConvert.importSav (32768-byte + checksum
|
||||
-- validated), then registers a fresh slot, writes it, and makes it active.
|
||||
-- Export loads the active slot, encodes it back to a 32768-byte SRAM image, and
|
||||
-- drops it in the save directory's exports/ folder, returning the absolute path
|
||||
-- so the launcher can offer an "open folder" affordance.
|
||||
-- drops it in the save directory's exports/<version>/ folder, returning the
|
||||
-- absolute path so the launcher can offer an "open folder" affordance.
|
||||
--
|
||||
-- Every failure returns false + a friendly one-line message (never raises), so
|
||||
-- the card can surface it as a red notice line rather than crashing.
|
||||
@@ -99,9 +99,9 @@ end
|
||||
-- exportActiveSlot(version) -> ok, pathOrErr
|
||||
-- Loads the version's active slot save (SaveData.load semantics), encodes it
|
||||
-- back to a 32768-byte SRAM image, and writes it to
|
||||
-- exports/gen1recomp-<version>-<slotId>.sav in the save directory (created if
|
||||
-- absent). Returns true + the absolute path on success, false + a friendly
|
||||
-- message otherwise.
|
||||
-- exports/<version>/gen1recomp-<version>-<slotId>.sav in the save directory
|
||||
-- (created if absent). Returns true + the absolute path on success, false + a
|
||||
-- friendly message otherwise.
|
||||
function SaveFileIO.exportActiveSlot(version)
|
||||
version = version or GameVersion.get()
|
||||
local save = SaveData.load(version)
|
||||
@@ -111,8 +111,12 @@ function SaveFileIO.exportActiveSlot(version)
|
||||
local slotId = SaveData.activeSlot(version) or "save"
|
||||
local fs = love and love.filesystem
|
||||
if not (fs and fs.write) then return false, "no filesystem available to export to" end
|
||||
if fs.createDirectory then fs.createDirectory("exports") end
|
||||
local rel = ("exports/gen1recomp-%s-%s.sav"):format(version, slotId)
|
||||
if fs.createDirectory then
|
||||
fs.createDirectory("exports")
|
||||
fs.createDirectory("exports/" .. version)
|
||||
end
|
||||
-- Per-game folder so MTP browsing matches inbox layout (red/blue/yellow).
|
||||
local rel = ("exports/%s/gen1recomp-%s-%s.sav"):format(version, version, slotId)
|
||||
local ok, writeErr = fs.write(rel, bytes)
|
||||
if not ok then return false, "could not write the export: " .. tostring(writeErr) end
|
||||
local base = fs.getSaveDirectory and fs.getSaveDirectory() or ""
|
||||
|
||||
+31
-1
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -714,7 +714,9 @@ function LinkState:draw()
|
||||
end
|
||||
|
||||
elseif self.stage == "notice" then
|
||||
drawTitle("CHECK YOUR MODS")
|
||||
-- a version-skew notice has nothing to do with mods (#758)
|
||||
drawTitle(self.verdict == "engine_skew" and "UPDATE YOUR GAME"
|
||||
or "CHECK YOUR MODS")
|
||||
for i, line in ipairs(self.noticeLines or {}) do
|
||||
if i > 8 then break end -- what fits above the prompt row
|
||||
Font.draw(line, 8, 24 + (i - 1) * 12)
|
||||
|
||||
+56
-15
@@ -282,10 +282,18 @@ end
|
||||
|
||||
-- ------- install (love.filesystem)
|
||||
|
||||
-- Read a .zip source into bytes. A string is an external absolute path (like
|
||||
-- a chosen ROM) read with io.*, falling back to a save-dir-relative
|
||||
-- love.filesystem read; a love DroppedFile is opened the way RomImporter
|
||||
-- ingests dropped ROMs.
|
||||
-- Read a .zip source into bytes. Save-dir-relative paths (inbox /
|
||||
-- picked_mod.zip) prefer love.filesystem so NX/Android never hit a cwd-relative
|
||||
-- io.open that can see a different file than PhysFS. Absolute host paths
|
||||
-- (desktop picker) still use io.*. DroppedFile matches RomImporter ROM drops.
|
||||
local function isHostAbsolutePath(path)
|
||||
return type(path) == "string" and (
|
||||
path:match("^/")
|
||||
or path:match("^%a:[/\\]")
|
||||
or path:match("^[Ss][Dd][Mm][Cc]:")
|
||||
)
|
||||
end
|
||||
|
||||
local function readArchive(source)
|
||||
local t = type(source)
|
||||
if (t == "userdata" or t == "table") and type(source.open) == "function" then
|
||||
@@ -297,6 +305,10 @@ local function readArchive(source)
|
||||
return data
|
||||
end
|
||||
if t == "string" then
|
||||
if not isHostAbsolutePath(source) and love and love.filesystem then
|
||||
local data = love.filesystem.read(source)
|
||||
if data then return data end
|
||||
end
|
||||
local f = io.open(source, "rb")
|
||||
if f then
|
||||
local data = f:read("*a")
|
||||
@@ -313,6 +325,12 @@ local function readArchive(source)
|
||||
return nil, "unsupported archive source"
|
||||
end
|
||||
|
||||
-- Local PK\3\4 / empty-file check before mount (corrupt MTP / AppleDouble).
|
||||
local function zipLooksValid(data)
|
||||
if type(data) ~= "string" or #data < 4 then return false end
|
||||
return data:sub(1, 2) == "PK"
|
||||
end
|
||||
|
||||
-- Shallow listing of a mounted archive shaped for locateRoot: files by name,
|
||||
-- and for each top-level directory a "<dir>/manifest.json" marker only when it
|
||||
-- actually holds one (so a lone folder with no manifest still reads as empty).
|
||||
@@ -510,21 +528,44 @@ function LauncherMods._installZipInner(source, opts)
|
||||
local fs = love.filesystem
|
||||
local data, readErr = readArchive(source)
|
||||
if not data then return nil, readErr end
|
||||
|
||||
-- stage into a save-dir temp so mount can reach it
|
||||
local tmp = ("mod_import_%d_%d.zip"):format(os.time(), math.random(0, 999999))
|
||||
local ok, writeErr = fs.write(tmp, data)
|
||||
if not ok then
|
||||
return nil, "could not stage the .zip: " .. tostring(writeErr)
|
||||
if not zipLooksValid(data) then
|
||||
local label = type(source) == "string" and (source:match("[^/\\]+$") or source)
|
||||
or "archive"
|
||||
return nil, "not a zip file: " .. tostring(label)
|
||||
.. " (need a real .zip; skip Mac ._ files from MTP)"
|
||||
end
|
||||
|
||||
-- Prefer in-memory mount (PHYSFS_mountMemory via FileData). Avoids Horizon's
|
||||
-- "file already open" failure when write-then-mount reopens a save-dir zip.
|
||||
local mount = "mod_import_mount"
|
||||
if not fs.mount(tmp, mount) then
|
||||
fs.remove(tmp)
|
||||
return nil, "that .zip could not be opened"
|
||||
local tmp = nil
|
||||
local mountKey = nil
|
||||
local mounted = false
|
||||
if fs.newFileData then
|
||||
local archiveName = ("mod_import_%d_%d.zip"):format(
|
||||
os.time(), math.random(0, 999999))
|
||||
local okFd, fd = pcall(fs.newFileData, data, archiveName)
|
||||
if okFd and fd and fs.mount(fd, mount) then
|
||||
mounted = true
|
||||
mountKey = fd
|
||||
end
|
||||
end
|
||||
if not mounted then
|
||||
-- Fallback: stage into a save-dir temp so path-mount can reach it.
|
||||
tmp = ("mod_import_%d_%d.zip"):format(os.time(), math.random(0, 999999))
|
||||
local ok, writeErr = fs.write(tmp, data)
|
||||
if not ok then
|
||||
return nil, "could not stage the .zip: " .. tostring(writeErr)
|
||||
end
|
||||
if not fs.mount(tmp, mount) then
|
||||
fs.remove(tmp)
|
||||
return nil, "that .zip could not be opened"
|
||||
end
|
||||
mountKey = tmp
|
||||
end
|
||||
local function cleanup()
|
||||
pcall(fs.unmount, tmp)
|
||||
fs.remove(tmp)
|
||||
pcall(fs.unmount, mountKey)
|
||||
if tmp then fs.remove(tmp) end
|
||||
end
|
||||
|
||||
local prefix, rootErr = LauncherMods.locateRoot(topLevelPaths(mount))
|
||||
|
||||
+20
-11
@@ -4,9 +4,9 @@
|
||||
-- its own file without editing a single record, and one flush() drops
|
||||
-- every downstream cache for dev-mode hot reload.
|
||||
--
|
||||
-- No loader installed means resolve() is the identity, which is what
|
||||
-- keeps a mod-free boot (and every headless test) loading exactly the
|
||||
-- paths it always did.
|
||||
-- No loader installed means resolve() is the identity. The NX Blue/Yellow
|
||||
-- versioned-cache fallback lives in src/core/NxAssetOverlay.lua (installed
|
||||
-- once at boot on NX only), not here, so this module stays platform-free.
|
||||
|
||||
local Assets = {}
|
||||
|
||||
@@ -29,18 +29,27 @@ local function exists(path)
|
||||
end
|
||||
Assets.exists = exists
|
||||
|
||||
-- an override dir shadows the generated cache; a transform's derived
|
||||
-- output is the fallback under it, so hand-authored art beats generated
|
||||
-- Mod overrides win; on NX, Blue/Yellow then use the prefixed save-dir file;
|
||||
-- otherwise the caller's unprefixed path (Red / mounted overlay).
|
||||
function Assets.resolve(path)
|
||||
local loader = Assets.loader
|
||||
if not loader or type(path) ~= "string" then return path end
|
||||
if type(path) ~= "string" then return path end
|
||||
if path:sub(1, #GENERATED) ~= GENERATED then return path end
|
||||
|
||||
local rel = path:sub(#GENERATED + 1)
|
||||
for _, mod in ipairs(loader:overrideOrder()) do
|
||||
local candidate = mod.path .. "/overrides/" .. rel
|
||||
if exists(candidate) then return candidate end
|
||||
local loader = Assets.loader
|
||||
if loader then
|
||||
for _, mod in ipairs(loader:overrideOrder()) do
|
||||
local candidate = mod.path .. "/overrides/" .. rel
|
||||
if exists(candidate) then return candidate end
|
||||
end
|
||||
local derived = loader:derivedPath(rel)
|
||||
if derived then return derived end
|
||||
end
|
||||
return loader:derivedPath(rel) or path
|
||||
|
||||
-- NX Blue/Yellow: no rewrite here -- NxAssetOverlay (installed once at
|
||||
-- boot on NX only) covers every loader globally, so this module stays
|
||||
-- the mod-override choke point it always was.
|
||||
return path
|
||||
end
|
||||
|
||||
function Assets.image(path)
|
||||
|
||||
+22
-15
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
-- Driver: Fly overworld animation (#702).
|
||||
--
|
||||
-- POKEPORT_DRIVER=tests/drivers/fly_anim_bug702_test.lua \
|
||||
-- POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love .
|
||||
--
|
||||
-- Teleports to Route 17, starts Fly to Pallet Town and captures the
|
||||
-- departure (in-place flap, up-right path, top-left exit) and the
|
||||
-- landing swoop.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
U.teleport(game, "ROUTE_17", 4, 10, "down")
|
||||
local ow = game.stack:top()
|
||||
ow:flyTo("PALLET_TOWN")
|
||||
|
||||
local function waitFrames(n)
|
||||
for _ = 1, n do coroutine.yield() end
|
||||
end
|
||||
|
||||
waitFrames(12) -- mid in-place flap
|
||||
U.shot(game, DIR .. "/fly_1_flap.png")
|
||||
waitFrames(24) -- path1 ~half-way (24 + 12 = 36 into the anim)
|
||||
U.shot(game, DIR .. "/fly_2_path1.png")
|
||||
waitFrames(50) -- hold + start of path2
|
||||
U.shot(game, DIR .. "/fly_3_path2.png")
|
||||
|
||||
-- wait out the warp transition, then catch the swoop mid-flight
|
||||
local guard = 0
|
||||
while ow.map.id == "ROUTE_17" and guard < 600 do
|
||||
guard = guard + 1
|
||||
coroutine.yield()
|
||||
end
|
||||
guard = 0
|
||||
while not ow.flyArrive and guard < 600 do
|
||||
guard = guard + 1
|
||||
coroutine.yield()
|
||||
end
|
||||
waitFrames(12) -- mid swoop
|
||||
U.shot(game, DIR .. "/fly_4_arrive.png")
|
||||
guard = 0
|
||||
while ow.flyArrive and guard < 600 do
|
||||
guard = guard + 1
|
||||
coroutine.yield()
|
||||
end
|
||||
U.shot(game, DIR .. "/fly_5_landed.png")
|
||||
|
||||
U.log("Screenshots are under " .. DIR)
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
@@ -0,0 +1,173 @@
|
||||
-- NxAssetOverlay: fused love-nx often cannot mount blue|yellow onto
|
||||
-- assets/generated, so on NX the love loaders are wrapped once at boot and
|
||||
-- fall back to the versioned save-dir path. Desktop/Android never install
|
||||
-- the overlay; the chip worker gets the prefix explicitly via the audio
|
||||
-- payload. Self-contained: luajit tests/engine/assets_version_fallback_test.lua
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check = T.check
|
||||
local eq = T.eq
|
||||
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Platform = require("src.core.Platform")
|
||||
local Assets = require("src.render.Assets")
|
||||
local Overlay = require("src.core.NxAssetOverlay")
|
||||
|
||||
local PNG = "assets/generated/tilesets/reds_house.png"
|
||||
local savedVersion = GameVersion.get()
|
||||
local savedSystem = love.system
|
||||
|
||||
local function clearPath(path)
|
||||
love.filesystem.remove(path)
|
||||
end
|
||||
|
||||
local function setOS(osName)
|
||||
love.system = {
|
||||
getOS = function() return osName end,
|
||||
}
|
||||
Platform._resetForTests()
|
||||
end
|
||||
|
||||
-- --- Assets.resolve stays platform-free: no rewrite even for NX Yellow
|
||||
setOS("NX")
|
||||
GameVersion.set("yellow")
|
||||
love.filesystem.write("yellow/" .. PNG, "yellow-png-bytes")
|
||||
clearPath(PNG)
|
||||
eq(Assets.resolve(PNG), PNG,
|
||||
"resolve is the identity without a mod loader (overlay owns NX fallback)")
|
||||
|
||||
-- --- Overlay installed: every loader falls back to the versioned path
|
||||
-- Write-side functions must NEVER be wrapped (the importer targets the
|
||||
-- versioned tree explicitly); capture references to prove identity.
|
||||
local rawWrite = love.filesystem.write
|
||||
local rawRemove = love.filesystem.remove
|
||||
local rawGetInfo = love.filesystem.getInfo
|
||||
local seed_chunk = "assets/generated/boot_chunk.lua"
|
||||
love.filesystem.write("yellow/" .. seed_chunk, "return 42")
|
||||
|
||||
Overlay.install()
|
||||
check(Overlay.isInstalled(), "overlay installs")
|
||||
check(love.filesystem.write == rawWrite,
|
||||
"install leaves filesystem.write stock (writes never wrapped)")
|
||||
check(love.filesystem.remove == rawRemove,
|
||||
"install leaves filesystem.remove stock")
|
||||
check(love.filesystem.getInfo ~= rawGetInfo, "install wraps getInfo")
|
||||
|
||||
local img = love.graphics.newImage(PNG)
|
||||
eq(img.path, "yellow/" .. PNG, "wrapped newImage receives the yellow/ path")
|
||||
|
||||
local id = love.image.newImageData(PNG)
|
||||
eq(id.path, "yellow/" .. PNG, "wrapped newImageData receives the yellow/ path")
|
||||
|
||||
eq(love.filesystem.read(PNG), "yellow-png-bytes",
|
||||
"wrapped filesystem.read returns the versioned bytes")
|
||||
|
||||
check(love.filesystem.getInfo(PNG) ~= nil,
|
||||
"wrapped getInfo sees the versioned file at the un-prefixed path")
|
||||
|
||||
-- The whole read surface, not just image/audio loaders: a future state
|
||||
-- using any of these APIs with a generated path stays inside the fallback.
|
||||
local chunk = love.filesystem.load(seed_chunk)
|
||||
eq(type(chunk) == "function" and chunk() or nil, 42,
|
||||
"wrapped filesystem.load resolves the versioned chunk")
|
||||
|
||||
local sd = love.sound.newSoundData(PNG)
|
||||
eq(sd.path, "yellow/" .. PNG,
|
||||
"wrapped newSoundData receives the yellow/ path (widenMono's re-read)")
|
||||
eq(sd:getChannelCount(), 1,
|
||||
"path-form newSoundData stays mono so widenMono has work to do")
|
||||
eq(sd:getBitDepth(), 8, "path-form stub mimics the 8-bit pika-cry WAVs")
|
||||
|
||||
local fnt = love.graphics.newFont(14)
|
||||
check(fnt ~= nil, "wrapped newFont ignores non-path arguments")
|
||||
|
||||
-- Assets.image/imageData benefit transparently (no call-site changes)
|
||||
Assets.flush()
|
||||
local aimg = Assets.image(PNG)
|
||||
eq(aimg.path, "yellow/" .. PNG, "Assets.image loads via the overlay")
|
||||
|
||||
-- Non-string arguments pass through untouched
|
||||
local fromData = love.graphics.newImage(id)
|
||||
check(fromData ~= nil, "newImage(ImageData) is not rewritten")
|
||||
|
||||
-- Non-generated paths pass through untouched
|
||||
local launcher = love.graphics.newImage("assets/launcher/gear.png")
|
||||
eq(launcher.path, "assets/launcher/gear.png",
|
||||
"overlay leaves non-generated paths alone")
|
||||
|
||||
-- The real un-prefixed file wins when it exists
|
||||
love.filesystem.write(PNG, "root-png-bytes")
|
||||
eq(love.filesystem.read(PNG), "root-png-bytes",
|
||||
"overlay prefers the real un-prefixed file over the versioned copy")
|
||||
clearPath(PNG)
|
||||
|
||||
-- Blue gets the same treatment
|
||||
GameVersion.set("blue")
|
||||
love.filesystem.write("blue/" .. PNG, "blue-png-bytes")
|
||||
clearPath("yellow/" .. PNG)
|
||||
eq(love.filesystem.read(PNG), "blue-png-bytes",
|
||||
"overlay maps generated reads to blue/ for Blue")
|
||||
|
||||
-- Red has no prefix: nothing is rewritten
|
||||
GameVersion.set("red")
|
||||
clearPath("blue/" .. PNG)
|
||||
eq(love.filesystem.read(PNG), nil, "Red keeps the stock miss behavior")
|
||||
|
||||
-- Uninstall restores the stock loaders byte for byte
|
||||
GameVersion.set("yellow")
|
||||
love.filesystem.write("yellow/" .. PNG, "yellow-png-bytes")
|
||||
Overlay.uninstall()
|
||||
check(not Overlay.isInstalled(), "overlay uninstalls")
|
||||
eq(love.filesystem.read(PNG), nil,
|
||||
"after uninstall the stock loader no longer sees the versioned path")
|
||||
|
||||
-- --- ChipSynth honors audio.programPrefix (the worker exception)
|
||||
local ChipSynth = require("src.core.ChipSynth")
|
||||
ChipSynth.invalidateBanks()
|
||||
local PROG = "assets/generated/audio/programs.bin"
|
||||
local PROG_BYTES = string.rep("\0", 0x4000 * 2)
|
||||
clearPath(PROG)
|
||||
love.filesystem.write("yellow/" .. PROG, PROG_BYTES)
|
||||
local workerData = { audio = {
|
||||
programFile = PROG,
|
||||
programPrefix = "yellow/",
|
||||
bankOrder = { 1, 2 },
|
||||
} }
|
||||
local okW, wbanks = pcall(ChipSynth._loadBanksForTest, workerData)
|
||||
check(okW and wbanks ~= nil, "loadBanks uses audio.programPrefix when set")
|
||||
if okW and wbanks then
|
||||
eq(wbanks[1], PROG_BYTES:sub(1, 0x4000),
|
||||
"programPrefix loads the bank 1 bytes from the versioned file")
|
||||
end
|
||||
|
||||
-- Without programPrefix the sync path relies on the overlay/mount: with the
|
||||
-- overlay uninstalled (this test process), the plain read misses.
|
||||
ChipSynth.invalidateBanks()
|
||||
local plainData = { audio = { programFile = PROG, bankOrder = { 1, 2 } } }
|
||||
local okP = pcall(ChipSynth._loadBanksForTest, plainData)
|
||||
check(not okP, "without programPrefix or overlay, programs.bin is a clean miss")
|
||||
|
||||
-- --- ChipAudio.slimAudio hands the NX prefix to the worker payload
|
||||
setOS("NX")
|
||||
GameVersion.set("yellow")
|
||||
local ChipAudio = require("src.core.ChipAudio")
|
||||
local slim = ChipAudio._slimAudioForTest(plainData)
|
||||
eq(slim.programPrefix, "yellow/",
|
||||
"slimAudio passes the NX cache prefix to the worker")
|
||||
setOS("OS X")
|
||||
local slimDesktop = ChipAudio._slimAudioForTest(plainData)
|
||||
eq(slimDesktop.programPrefix, nil,
|
||||
"desktop worker payloads carry no prefix (mount owns the overlay)")
|
||||
|
||||
clearPath("yellow/" .. PNG)
|
||||
clearPath("yellow/" .. PROG)
|
||||
clearPath("yellow/" .. seed_chunk)
|
||||
|
||||
love.system = savedSystem
|
||||
Platform._resetForTests()
|
||||
GameVersion.set(savedVersion)
|
||||
Assets.flush()
|
||||
|
||||
T.finish()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,24 @@
|
||||
-- Focus / visibility loss clears held directions (SWNX-18).
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check = T.check
|
||||
|
||||
local Input = require("src.core.Input")
|
||||
local Game = require("src.core.Game")
|
||||
|
||||
Input:init()
|
||||
Input:keypressed("left")
|
||||
Input:step()
|
||||
check(Input:isDown("left"), "left held before focus loss")
|
||||
|
||||
Game:focus(false)
|
||||
check(not Input:isDown("left"), "focus loss clears held direction")
|
||||
|
||||
Input:keypressed("up")
|
||||
Input:step()
|
||||
Game:visible(false)
|
||||
check(not Input:isDown("up"), "visibility loss clears held direction")
|
||||
|
||||
T.finish()
|
||||
@@ -0,0 +1,30 @@
|
||||
-- Joystick remove / resume clears stuck input sources (SWNX-19).
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check = T.check
|
||||
|
||||
local Input = require("src.core.Input")
|
||||
local Game = require("src.core.Game")
|
||||
|
||||
Input:init()
|
||||
Input:gamepadaxis(nil, "leftx", -0.9)
|
||||
Input:step()
|
||||
check(Input:isDown("left"), "stick holds left before disconnect")
|
||||
|
||||
Game:joystickremoved({})
|
||||
check(not Input:isDown("left"), "joystickremoved clears stick hold")
|
||||
|
||||
Input:gamepadpressed(nil, "a")
|
||||
Input:step()
|
||||
check(Input:isDown("a"), "button held before reconnect reset")
|
||||
Game:joystickadded({ getName = function() return "Joy-Con" end })
|
||||
check(not Input:isDown("a"), "joystickadded clears stale button hold")
|
||||
|
||||
Input:gamepadaxis(nil, "lefty", 0.9)
|
||||
Input:step()
|
||||
Game:onResume()
|
||||
check(not Input:isDown("down"), "resume clears stuck axis state")
|
||||
|
||||
T.finish()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,51 @@
|
||||
-- Shared gamepad/raw map: launcher and gameplay use identical converters (SWNX-10/11).
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check = T.check
|
||||
local eq = T.eq
|
||||
|
||||
local GamepadMap = require("src.core.GamepadMap")
|
||||
local Input = require("src.core.Input")
|
||||
local RomImporter = require("src.import.RomImporter")
|
||||
|
||||
-- Gamepad names map to GB actions.
|
||||
eq(GamepadMap.mapGamepadButton("a"), "a", "gamepad A -> GB A")
|
||||
eq(GamepadMap.mapGamepadButton("back"), "select", "gamepad back -> select")
|
||||
eq(GamepadMap.mapGamepadButton("dpup"), "up", "gamepad d-pad up")
|
||||
|
||||
-- Generic raw indices (Linux handheld fallback).
|
||||
eq(GamepadMap.mapRawButton(1), "a", "raw #1 -> A")
|
||||
eq(GamepadMap.mapRawButton(8), "start", "raw #8 -> start")
|
||||
eq(GamepadMap.mapRawToGamepadButton(2), "b", "raw #2 routes to gamepad b")
|
||||
|
||||
-- Input and RomImporter agree on the same GB action for a raw press.
|
||||
Input:init()
|
||||
Input:joystickpressed(nil, 1)
|
||||
Input:step()
|
||||
check(Input:isDown("a"), "Input raw #1 holds A")
|
||||
|
||||
local importer = setmetatable({
|
||||
_padCursor = { x = 0, y = 0 }, _padCursorActive = false,
|
||||
_padAxis = { leftx = 0, lefty = 0, righty = 0 },
|
||||
_padDir = {}, _rawHatDirs = {}, _padInited = true,
|
||||
-- FlexLove view marker: without it the pad A path is a no-op (headless).
|
||||
_flex = true,
|
||||
}, RomImporter)
|
||||
local clicked = false
|
||||
local prevView = package.loaded["src.import.LauncherView"]
|
||||
package.loaded["src.import.LauncherView"] = {
|
||||
clickAt = function(_, x, y)
|
||||
clicked = (x == 0 and y == 0)
|
||||
end,
|
||||
}
|
||||
importer:joystickpressed(nil, 1)
|
||||
package.loaded["src.import.LauncherView"] = prevView
|
||||
check(clicked, "RomImporter raw #1 clicks via shared map (not hardcoded-only)")
|
||||
|
||||
importer:joystickpressed(nil, 2)
|
||||
check(importer._padDir.dpright == nil,
|
||||
"raw #2 maps to B, not spurious d-pad")
|
||||
|
||||
T.finish()
|
||||
@@ -0,0 +1,289 @@
|
||||
-- NX launcher pad-cursor lag guards (Switch-only).
|
||||
-- Proves the virtual mouse no longer warps love.mouse via setPosition on NX,
|
||||
-- that FlexLove still sees pad coords through the getPosition bridge, that
|
||||
-- desktop keeps setPosition, and that LauncherView wires NX perf guards.
|
||||
-- Also prints a small metric block (setPosition counts + update cost).
|
||||
--
|
||||
-- FlexLove itself is not loaded here: the engine tier runs under plain luajit
|
||||
-- without luautf8, which FlexLove requires. RomImporter owns the pointer
|
||||
-- bridge; LauncherView wiring is asserted via source seams.
|
||||
-- luajit tests/engine/launcher_nx_pad_cursor_test.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
-- Instrument setPosition so we can count warps (love_stub has none).
|
||||
local setPositionCalls = 0
|
||||
local mouseX, mouseY = 0, 0
|
||||
love.mouse.getPosition = function() return mouseX, mouseY end
|
||||
love.mouse.setPosition = function(x, y)
|
||||
setPositionCalls = setPositionCalls + 1
|
||||
mouseX, mouseY = x, y
|
||||
end
|
||||
|
||||
local RomImporter = require("src.import.RomImporter")
|
||||
|
||||
local function freshImporter(isNX)
|
||||
return setmetatable({
|
||||
isNX = isNX and true or false,
|
||||
_padCursor = { x = 100, y = 100 },
|
||||
_padCursorActive = false,
|
||||
_padAxis = { leftx = 0, lefty = 0, righty = 0 },
|
||||
_padDir = {},
|
||||
_rawHatDirs = {},
|
||||
_padInited = true,
|
||||
_flex = true,
|
||||
tab = "red",
|
||||
}, RomImporter)
|
||||
end
|
||||
|
||||
local function stickRight(imp, frames, dt)
|
||||
dt = dt or (1 / 60)
|
||||
imp._padAxis.leftx = 1
|
||||
for _ = 1, frames do
|
||||
imp:_updatePadCursor(dt)
|
||||
end
|
||||
end
|
||||
|
||||
local function read(path)
|
||||
local f = assert(io.open(path, "r"))
|
||||
local src = f:read("*a")
|
||||
f:close()
|
||||
return src
|
||||
end
|
||||
|
||||
-- ------- NX: no setPosition warps; getPosition bridge tracks the pad
|
||||
|
||||
do
|
||||
setPositionCalls = 0
|
||||
mouseX, mouseY = 0, 0
|
||||
local imp = freshImporter(true)
|
||||
local x0 = imp._padCursor.x
|
||||
stickRight(imp, 30)
|
||||
check(imp._padCursorActive, "NX stick activates pad cursor")
|
||||
check(imp._padCursor.x > x0, "NX stick moves pad cursor right")
|
||||
eq(setPositionCalls, 0, "NX pad move never calls love.mouse.setPosition")
|
||||
check(imp._nxPointerBridge, "NX installs getPosition bridge")
|
||||
local gx, gy = love.mouse.getPosition()
|
||||
eq(gx, imp._padCursor.x, "NX getPosition X matches pad cursor")
|
||||
eq(gy, imp._padCursor.y, "NX getPosition Y matches pad cursor")
|
||||
-- Real (stored) mouse must stay where the stub left it — bridge only.
|
||||
eq(mouseX, 0, "NX does not warp the underlying mouse X")
|
||||
eq(mouseY, 0, "NX does not warp the underlying mouse Y")
|
||||
imp:_restoreNxPointerBridge()
|
||||
check(not imp._nxPointerBridge, "restore clears NX mouse bridge")
|
||||
local rx, ry = love.mouse.getPosition()
|
||||
eq(rx, 0, "after restore getPosition is the real stub again")
|
||||
eq(ry, 0, "after restore getPosition Y is the real stub again")
|
||||
end
|
||||
|
||||
-- ------- NX: A after idle must not false-yield the pad cursor
|
||||
|
||||
do
|
||||
mouseX, mouseY = 10, 20
|
||||
local imp = freshImporter(true)
|
||||
imp._padCursor.x, imp._padCursor.y = 400, 300
|
||||
-- Idle frames (NX ignores mouse yield entirely).
|
||||
imp:_updatePadCursor(1 / 60)
|
||||
-- A / activate without stick motion (clickAt path).
|
||||
imp:_activatePadCursor()
|
||||
imp:_updatePadCursor(1 / 60)
|
||||
check(imp._padCursorActive,
|
||||
"NX A after idle keeps pad cursor")
|
||||
local gx, gy = love.mouse.getPosition()
|
||||
eq(gx, 400, "bridged getPosition still reports pad X after A")
|
||||
eq(gy, 300, "bridged getPosition still reports pad Y after A")
|
||||
-- System mouse drift must NOT yield on NX (SDL stick→mouse / touch noise).
|
||||
mouseX, mouseY = 80, 90
|
||||
imp:_updatePadCursor(1 / 60)
|
||||
check(imp._padCursorActive, "NX ignores real mouse drift for yield")
|
||||
imp:parkNxPointerForHost()
|
||||
end
|
||||
|
||||
-- ------- NX: sparse stick + SDL mouse drift must not flicker the overlay
|
||||
|
||||
do
|
||||
mouseX, mouseY = 100, 100
|
||||
local imp = freshImporter(true)
|
||||
imp._padCursor.x, imp._padCursor.y = 100, 100
|
||||
local flickers = 0
|
||||
for i = 1, 60 do
|
||||
mouseX = mouseX + 8 -- simulated SDL stick→mouse drift
|
||||
if i % 2 == 1 then
|
||||
imp._padAxis.leftx = 1
|
||||
else
|
||||
imp._padAxis.leftx = 0 -- axis events not every frame
|
||||
end
|
||||
local before = imp._padCursorActive
|
||||
imp:_updatePadCursor(1 / 60)
|
||||
if before and not imp._padCursorActive then
|
||||
flickers = flickers + 1
|
||||
end
|
||||
end
|
||||
eq(flickers, 0, "NX sparse stick + mouse drift causes zero pad flickers")
|
||||
check(imp._padCursorActive, "NX pad stays active after sparse stick run")
|
||||
-- dt clamp: a 0.2s hitch must not move more than a 1/30 step
|
||||
local xBefore = imp._padCursor.x
|
||||
imp._padAxis.leftx = 1
|
||||
imp:_updatePadCursor(0.2)
|
||||
local moved = imp._padCursor.x - xBefore
|
||||
local maxStep = 560 * (1 / 30) + 0.01
|
||||
check(moved <= maxStep, "NX pad cursor dt is clamped at 1/30")
|
||||
imp:parkNxPointerForHost()
|
||||
end
|
||||
|
||||
-- ------- NX: parkNxPointerForHost restores mouse for embedded editor
|
||||
|
||||
do
|
||||
mouseX, mouseY = 5, 6
|
||||
local imp = freshImporter(true)
|
||||
stickRight(imp, 5)
|
||||
check(imp._nxPointerBridge, "bridge on before park")
|
||||
check(imp._padCursorActive, "pad active before park")
|
||||
imp:parkNxPointerForHost()
|
||||
check(not imp._nxPointerBridge, "park clears bridge")
|
||||
check(not imp._padCursorActive, "park clears pad active")
|
||||
local gx, gy = love.mouse.getPosition()
|
||||
eq(gx, 5, "after park getPosition is real mouse X")
|
||||
eq(gy, 6, "after park getPosition is real mouse Y")
|
||||
-- Desktop no-op.
|
||||
local desk = freshImporter(false)
|
||||
desk._padCursorActive = true
|
||||
desk:parkNxPointerForHost()
|
||||
check(desk._padCursorActive, "desktop parkNxPointerForHost is a no-op")
|
||||
end
|
||||
|
||||
-- ------- Overlay handoff / resume (Edit Save + Touch Controls)
|
||||
|
||||
do
|
||||
mouseX, mouseY = 9, 10
|
||||
local imp = freshImporter(true)
|
||||
imp.launcher = true
|
||||
stickRight(imp, 3)
|
||||
check(imp._padCursorActive, "pad active before overlay handoff")
|
||||
check(imp._nxPointerBridge, "bridge on before overlay handoff")
|
||||
imp:prepareOverlayHandoff()
|
||||
check(not imp._padCursorActive, "prepareOverlayHandoff clears pad")
|
||||
check(not imp._nxPointerBridge, "prepareOverlayHandoff clears NX bridge")
|
||||
check(imp._flex == nil, "prepareOverlayHandoff clears flex without FlexLove")
|
||||
local gx, gy = love.mouse.getPosition()
|
||||
eq(gx, 9, "after overlay handoff getPosition is real mouse X")
|
||||
eq(gy, 10, "after overlay handoff getPosition is real mouse Y")
|
||||
|
||||
local prevCount = love.joystick and love.joystick.getJoystickCount
|
||||
love.joystick = love.joystick or {}
|
||||
love.joystick.getJoystickCount = function() return 1 end
|
||||
imp:resumeAfterOverlay()
|
||||
check(imp._padCursorActive, "NX resumeAfterOverlay re-arms pad with a stick")
|
||||
love.joystick.getJoystickCount = function() return 0 end
|
||||
imp._padCursorActive = false
|
||||
imp:resumeAfterOverlay()
|
||||
check(not imp._padCursorActive, "resumeAfterOverlay stays latent with no stick")
|
||||
love.joystick.getJoystickCount = prevCount
|
||||
|
||||
local desk = freshImporter(false)
|
||||
desk.launcher = true
|
||||
desk._padCursorActive = true
|
||||
desk:prepareOverlayHandoff()
|
||||
check(not desk._padCursorActive, "desktop prepareOverlayHandoff clears pad too")
|
||||
end
|
||||
|
||||
-- ------- Desktop: setPosition still warps (unchanged path)
|
||||
|
||||
do
|
||||
setPositionCalls = 0
|
||||
mouseX, mouseY = 0, 0
|
||||
local imp = freshImporter(false)
|
||||
local x0 = imp._padCursor.x
|
||||
stickRight(imp, 30)
|
||||
check(imp._padCursorActive, "desktop stick activates pad cursor")
|
||||
check(imp._padCursor.x > x0, "desktop stick moves pad cursor right")
|
||||
eq(setPositionCalls, 30, "desktop pad move warps mouse every frame")
|
||||
eq(mouseX, imp._padCursor.x, "desktop setPosition tracks pad X")
|
||||
eq(mouseY, imp._padCursor.y, "desktop setPosition tracks pad Y")
|
||||
check(not imp._nxPointerBridge, "desktop never installs NX bridge")
|
||||
-- Desktop yield still drops the pad when the real mouse moves (stick released).
|
||||
imp._padAxis.leftx = 0
|
||||
mouseX, mouseY = mouseX + 20, mouseY + 20
|
||||
imp:_updatePadCursor(1 / 60)
|
||||
check(not imp._padCursorActive, "desktop real mouse motion still yields pad")
|
||||
end
|
||||
|
||||
-- ------- Metrics: setPosition counts + pad-update cost (NX vs desktop)
|
||||
|
||||
do
|
||||
local frames = 120
|
||||
local dt = 1 / 60
|
||||
|
||||
setPositionCalls = 0
|
||||
local nx = freshImporter(true)
|
||||
local t0 = os.clock()
|
||||
stickRight(nx, frames, dt)
|
||||
local nxMs = (os.clock() - t0) * 1000
|
||||
local nxSet = setPositionCalls
|
||||
nx:_restoreNxPointerBridge()
|
||||
|
||||
setPositionCalls = 0
|
||||
local desk = freshImporter(false)
|
||||
t0 = os.clock()
|
||||
stickRight(desk, frames, dt)
|
||||
local deskMs = (os.clock() - t0) * 1000
|
||||
local deskSet = setPositionCalls
|
||||
|
||||
eq(nxSet, 0, "metric: NX setPosition count is 0 over 120 frames")
|
||||
eq(deskSet, frames, "metric: desktop setPosition count equals frame count")
|
||||
|
||||
print(string.format(
|
||||
"METRICS nx_pad_cursor: frames=%d nx_setPosition=%d desk_setPosition=%d nx_update_ms=%.3f desk_update_ms=%.3f",
|
||||
frames, nxSet, deskSet, nxMs, deskMs))
|
||||
end
|
||||
|
||||
-- ------- Source seams: LauncherView NX perf + detach restore
|
||||
|
||||
do
|
||||
local view = read("src/import/LauncherView.lua")
|
||||
check(view:find("function LauncherView.applyNxPerfGuards", 1, true) ~= nil,
|
||||
"LauncherView exports applyNxPerfGuards")
|
||||
check(view:find("LauncherView.applyNxPerfGuards(imp)", 1, true) ~= nil,
|
||||
"ensureFlex calls applyNxPerfGuards")
|
||||
check(view:find("FlexLove._Performance.enabled = false", 1, true) ~= nil,
|
||||
"NX guard disables Performance.enabled")
|
||||
check(view:find("mp.enabled = false", 1, true) ~= nil,
|
||||
"NX guard disables memory profiling")
|
||||
check(view:find("if not (imp and imp.isNX", 1, true) ~= nil,
|
||||
"perf guard is gated on imp.isNX")
|
||||
check(view:find("parkNxPointerForHost", 1, true) ~= nil,
|
||||
"detach parks NX pointer before tearing down")
|
||||
|
||||
local impSrc = read("src/import/RomImporter.lua")
|
||||
check(impSrc:find("function RomImporter:_ensureNxPointerBridge", 1, true) ~= nil,
|
||||
"RomImporter owns NX getPosition bridge")
|
||||
check(impSrc:find("function RomImporter:parkNxPointerForHost", 1, true) ~= nil,
|
||||
"RomImporter exports parkNxPointerForHost")
|
||||
check(impSrc:find("function RomImporter:prepareOverlayHandoff", 1, true) ~= nil,
|
||||
"RomImporter exports prepareOverlayHandoff")
|
||||
check(impSrc:find("function RomImporter:resumeAfterOverlay", 1, true) ~= nil,
|
||||
"RomImporter exports resumeAfterOverlay")
|
||||
check(impSrc:find("if not self.isNX then", 1, true) ~= nil,
|
||||
"NX skips desktop mouse-yield path")
|
||||
check(impSrc:find("if not self.isNX and love.mouse.setPosition", 1, true) ~= nil,
|
||||
"RomImporter skips setPosition on NX")
|
||||
check(impSrc:find("dt > 1 / 30", 1, true) ~= nil,
|
||||
"NX clamps pad cursor dt")
|
||||
|
||||
local mainSrc = read("main.lua")
|
||||
check(mainSrc:find("prepareOverlayHandoff", 1, true) ~= nil,
|
||||
"openEditor / Touch Controls use prepareOverlayHandoff")
|
||||
check(mainSrc:find("resumeAfterOverlay", 1, true) ~= nil,
|
||||
"close paths resume the launcher pad cursor")
|
||||
|
||||
check(view:find("math.floor(x + 0.5)", 1, true) ~= nil,
|
||||
"NX pad cursor draw is pixel-snapped")
|
||||
check(view:find('strategy = "periodic"', 1, true) ~= nil,
|
||||
"NX softens FlexLove GC strategy")
|
||||
end
|
||||
|
||||
T.finish("launcher_nx_pad_cursor")
|
||||
@@ -0,0 +1,173 @@
|
||||
-- Regression for #748: FlexLove propagates a nested child's auto-height
|
||||
-- change only to its direct parent while the launcher tree is constructed.
|
||||
-- The two-column grid can therefore retain the shorter left-column height
|
||||
-- after the save-slot card makes the right column taller, placing the footer
|
||||
-- over the bottom of that card. Keep this test ROM- and renderer-free.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
-- Element sizing asks the window for its current mode. FlexLove also loads
|
||||
-- UTF8 helpers for rendering, although this geometry test draws no text.
|
||||
love.window.getMode = function()
|
||||
return 1024, 768, { fullscreen = false }
|
||||
end
|
||||
love.window.getDesktopDimensions = function() return 1920, 1080 end
|
||||
package.loaded["libs.flexlove.modules.UTF8"] = {
|
||||
char = string.char,
|
||||
charpattern = ".",
|
||||
codepoint = string.byte,
|
||||
len = string.len,
|
||||
offset = function(_, n) return n end,
|
||||
codes = function(s)
|
||||
local i = 0
|
||||
return function()
|
||||
i = i + 1
|
||||
if i <= #s then return i, s:byte(i) end
|
||||
end
|
||||
end,
|
||||
}
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local FlexLove = require("libs.flexlove.FlexLove")
|
||||
FlexLove.init({
|
||||
immediateMode = false,
|
||||
performanceMonitoring = false,
|
||||
keyboardNavigation = false,
|
||||
})
|
||||
local LauncherView = require("src.import.LauncherView")
|
||||
local refreshAutoHeight = LauncherView._refreshAutoHeight
|
||||
|
||||
-- Preserve the launcher's construction order. The left card grows first;
|
||||
-- adding the right column then refreshes the grid to 220. Growing a card
|
||||
-- nested inside that right column reaches the column, but not the grid.
|
||||
local function nestedColumns(leftHeight, rightHeight, gridOverrides)
|
||||
local page = FlexLove.new({
|
||||
width = 800,
|
||||
positioning = "flex",
|
||||
flexDirection = "vertical",
|
||||
gap = 12,
|
||||
})
|
||||
local gridProps = {
|
||||
parent = page,
|
||||
width = 800,
|
||||
positioning = "flex",
|
||||
flexDirection = "horizontal",
|
||||
alignItems = "flex-start",
|
||||
}
|
||||
for key, value in pairs(gridOverrides or {}) do
|
||||
gridProps[key] = value
|
||||
end
|
||||
local grid = FlexLove.new(gridProps)
|
||||
local left = FlexLove.new({
|
||||
parent = grid,
|
||||
width = 390,
|
||||
positioning = "flex",
|
||||
flexDirection = "vertical",
|
||||
})
|
||||
local leftCard = FlexLove.new({
|
||||
parent = left,
|
||||
width = 390,
|
||||
positioning = "flex",
|
||||
flexDirection = "vertical",
|
||||
})
|
||||
FlexLove.new({ parent = leftCard, width = 390, height = leftHeight })
|
||||
local right = FlexLove.new({
|
||||
parent = grid,
|
||||
width = 390,
|
||||
positioning = "flex",
|
||||
flexDirection = "vertical",
|
||||
})
|
||||
local rightCard = FlexLove.new({
|
||||
parent = right,
|
||||
width = 390,
|
||||
positioning = "flex",
|
||||
flexDirection = "vertical",
|
||||
})
|
||||
FlexLove.new({ parent = rightCard, width = 390, height = rightHeight })
|
||||
return { page = page, grid = grid, left = left, right = right }
|
||||
end
|
||||
|
||||
-- Reported shape: real FlexLove elements finish with a 520px right column,
|
||||
-- but both the grid and page still reserve only the 220px left extent.
|
||||
local tallRight = nestedColumns(220, 520)
|
||||
local gap = 12
|
||||
T.eq(tallRight.left:getBorderBoxHeight(), 220,
|
||||
"the left launcher column finishes at its content height")
|
||||
T.eq(tallRight.right:getBorderBoxHeight(), 520,
|
||||
"the nested save-slot column grows to its completed height")
|
||||
T.eq(tallRight.grid:getBorderBoxHeight(), 220,
|
||||
"FlexLove leaves the outer two-column grid stale")
|
||||
T.eq(tallRight.page:getBorderBoxHeight(), 220,
|
||||
"the page inherits the stale grid extent")
|
||||
T.eq(tallRight.grid:calculateAutoHeight(), 520,
|
||||
"the completed grid can measure the correct taller extent")
|
||||
T.check(tallRight.grid:getBorderBoxHeight() + gap
|
||||
< tallRight.right:getBorderBoxHeight(),
|
||||
"the stale grid places the following footer over the save-slot column")
|
||||
|
||||
if not T.check(type(refreshAutoHeight) == "function",
|
||||
"launcher exports the auto-height reconciliation seam") then
|
||||
T.finish("launcher save-slot overlap #748")
|
||||
end
|
||||
|
||||
tallRight.grid._dirty = false
|
||||
tallRight.page._childrenDirty = false
|
||||
local refreshedHeight = refreshAutoHeight(tallRight.grid)
|
||||
T.eq(refreshedHeight, 520, "reconciliation returns the taller border-box height")
|
||||
T.eq(tallRight.grid.height, 520, "reconciliation updates content height")
|
||||
T.eq(tallRight.grid:getBorderBoxHeight(), 520,
|
||||
"reconciliation updates the cached border-box height")
|
||||
T.check(tallRight.grid._dirty, "reconciliation invalidates the grid")
|
||||
T.check(tallRight.page._childrenDirty,
|
||||
"reconciliation invalidates ancestor layout")
|
||||
T.check(tallRight.grid:getBorderBoxHeight() + gap
|
||||
>= tallRight.right:getBorderBoxHeight(),
|
||||
"the following footer starts below the completed save-slot column")
|
||||
|
||||
-- Reconciliation uses the maximum completed column; it must not blindly
|
||||
-- copy the right side and shrink an already-taller left side.
|
||||
local shortRight = nestedColumns(420, 220)
|
||||
T.eq(refreshAutoHeight(shortRight.grid), 420,
|
||||
"a shorter right column preserves the taller left extent")
|
||||
T.eq(shortRight.grid.height, 420,
|
||||
"short content keeps its correct content height")
|
||||
T.eq(shortRight.grid:getBorderBoxHeight(), 420,
|
||||
"short content keeps its correct border-box height")
|
||||
|
||||
-- Match FlexLove's resize path: clamp the padded border box, then derive and
|
||||
-- clamp the content box from it.
|
||||
local constrained = nestedColumns(600, 500, {
|
||||
padding = { top = 10, bottom = 20 },
|
||||
maxHeight = 550,
|
||||
})
|
||||
T.eq(constrained.grid:calculateAutoHeight(), 600,
|
||||
"the constrained grid measures its taller child before padding")
|
||||
T.eq(refreshAutoHeight(constrained.grid), 550,
|
||||
"max-height clamps the padded border box")
|
||||
T.eq(constrained.grid.height, 520,
|
||||
"content height subtracts padding from the constrained border box")
|
||||
T.eq(constrained.grid:getBorderBoxHeight(), 550,
|
||||
"the constrained border-box cache stays synchronized")
|
||||
|
||||
-- Invalid or inapplicable measurements are fail-closed and leave geometry
|
||||
-- untouched rather than poisoning the next layout pass.
|
||||
local notANumber = nestedColumns(220, 520)
|
||||
notANumber.grid.calculateAutoHeight = function() return 0 / 0 end
|
||||
notANumber.grid._dirty = false
|
||||
T.eq(refreshAutoHeight(notANumber.grid), false, "NaN auto height is rejected")
|
||||
T.eq(notANumber.grid.height, 220, "NaN leaves content height unchanged")
|
||||
T.eq(notANumber.grid:getBorderBoxHeight(), 220,
|
||||
"NaN leaves border-box height unchanged")
|
||||
T.eq(notANumber.grid._dirty, false, "NaN does not invalidate layout")
|
||||
|
||||
local infinite = nestedColumns(220, 520)
|
||||
infinite.grid.calculateAutoHeight = function() return math.huge end
|
||||
T.eq(refreshAutoHeight(infinite.grid), false, "infinite auto height is rejected")
|
||||
T.eq(infinite.grid.height, 220, "infinite height leaves geometry unchanged")
|
||||
|
||||
local fixed = FlexLove.new({ width = 800, height = 220 })
|
||||
T.eq(refreshAutoHeight(fixed), false, "fixed-height elements are ignored")
|
||||
T.eq(fixed.height, 220, "fixed-height geometry is unchanged")
|
||||
T.eq(refreshAutoHeight(nil), false, "a missing element is ignored")
|
||||
|
||||
T.finish("launcher save-slot overlap #748")
|
||||
@@ -133,6 +133,13 @@ check(ri._findSearchFocus == false, "a tab change drops the caret")
|
||||
eq(lastArm(), false, "and disarms setTextInput")
|
||||
ri.tab = "find"
|
||||
|
||||
ri:_toggleFindSearchFocus()
|
||||
check(ri._findSearchFocus == true, "tapping the search field focuses it")
|
||||
eq(lastArm(), true, "refocusing the search field arms setTextInput")
|
||||
ri:_toggleFindSearchFocus()
|
||||
check(ri._findSearchFocus == false, "tapping the focused search field blurs it")
|
||||
eq(lastArm(), false, "blurring the search field disarms setTextInput")
|
||||
|
||||
-- ---- desktop contract (#529): disarm never lowers off Android -------------
|
||||
|
||||
ri.android = false
|
||||
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,66 @@
|
||||
-- ORIENTATION lock (#592, #716): the option model.
|
||||
--
|
||||
-- The Android side (SDL hint parsing, GameActivity's *_SENSOR -> *_USER
|
||||
-- remap) can only be exercised on a device; what this tier pins down is the
|
||||
-- Lua contract every UI row leans on: the mode set, normalization of stale
|
||||
-- or garbage saves, the cycle order in both directions, and that apply() is
|
||||
-- a safe no-op anywhere that is not Android -- including here, where love
|
||||
-- is a headless stub and no SDL library is loaded.
|
||||
-- luajit tests/engine/orientation_option.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Orientation = require("src.core.Orientation")
|
||||
|
||||
local realOS = love.system and love.system.getOS
|
||||
love.system = love.system or {}
|
||||
|
||||
-- ------------------------------------------------------------- normalize
|
||||
|
||||
T.eq(Orientation.DEFAULT, "auto", "AUTO is the default")
|
||||
T.eq(Orientation.normalize(nil), "auto", "missing option reads as AUTO")
|
||||
T.eq(Orientation.normalize("sideways"), "auto", "garbage reads as AUTO")
|
||||
T.eq(Orientation.normalize("portrait"), "portrait", "valid modes pass through")
|
||||
T.eq(Orientation.normalize("reverseLandscape"), "reverseLandscape",
|
||||
"reverse landscape is a real mode")
|
||||
|
||||
-- ------------------------------------------------------------------ cycle
|
||||
|
||||
T.eq(Orientation.cycle("auto", 1), "portrait", "cycle forward from AUTO")
|
||||
T.eq(Orientation.cycle("reverseLandscape", 1), "auto", "cycle wraps forward")
|
||||
T.eq(Orientation.cycle("auto", -1), "reverseLandscape", "cycle wraps back")
|
||||
T.eq(Orientation.cycle(nil, 1), "portrait", "cycling a fresh save starts at AUTO")
|
||||
|
||||
-- one full lap forward touches every mode exactly once
|
||||
local seen, mode = {}, "auto"
|
||||
for _ = 1, #Orientation.MODES do
|
||||
seen[mode] = true
|
||||
mode = Orientation.cycle(mode, 1)
|
||||
end
|
||||
T.eq(mode, "auto", "a full lap returns to the start")
|
||||
for _, m in ipairs(Orientation.MODES) do
|
||||
T.eq(seen[m], true, "lap visits " .. m)
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------------- labels
|
||||
|
||||
for _, m in ipairs(Orientation.MODES) do
|
||||
T.eq(type(Orientation.modeLabel(m)), "string", m .. " has a label")
|
||||
T.eq(#Orientation.modeLabel(m) <= 17, true,
|
||||
m .. "'s label fits the OPTION box value line (17 cells at x=24)")
|
||||
end
|
||||
|
||||
-- -------------------------------------------------- apply() stays harmless
|
||||
|
||||
love.system.getOS = function() return "OS X" end
|
||||
T.eq(Orientation.apply("portrait"), false, "desktop apply is a refused no-op")
|
||||
love.system.getOS = function() return "iOS" end
|
||||
T.eq(Orientation.apply("portrait"), false, "iOS defers to the Info.plist")
|
||||
-- Android posed but no SDL loaded in this process: the FFI path must fail
|
||||
-- closed inside its pcall, never throw.
|
||||
love.system.getOS = function() return "Android" end
|
||||
local ok, err = pcall(Orientation.applyOptions, { orientation = "landscape" })
|
||||
T.eq(ok, true, "posed-Android apply never raises (" .. tostring(err) .. ")")
|
||||
|
||||
love.system.getOS = realOS
|
||||
@@ -0,0 +1,47 @@
|
||||
-- Self-updater and remote mod download must stay off on NX until validated.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
love.system = love.system or {}
|
||||
love.filesystem = love.filesystem or {}
|
||||
|
||||
local S = require("tests.harness").suite("platform NX network gate")
|
||||
local check = S.check
|
||||
local eq = S.eq
|
||||
|
||||
local checkStarted = false
|
||||
package.loaded["src.update.Check"] = {
|
||||
start = function() checkStarted = true end,
|
||||
state = function() return { status = "idle" } end,
|
||||
}
|
||||
|
||||
love.system.getOS = function() return "NX" end
|
||||
love.filesystem.isFused = function() return true end
|
||||
love.filesystem.getSaveDirectory = function() return "/save/pokemon-love2d" end
|
||||
|
||||
package.loaded["src.core.Platform"] = nil
|
||||
package.loaded["src.import.RomImporter"] = nil
|
||||
local RomImporter = require("src.import.RomImporter")
|
||||
|
||||
local ri = RomImporter.new(function() end, { launcher = true })
|
||||
ri.ready = { red = false, blue = false, yellow = false }
|
||||
eq(checkStarted, false, "self-updater does not start on NX fused launcher")
|
||||
check(ri.Check == nil, "launcher has no Check module on NX")
|
||||
|
||||
eq(require("src.core.Platform").networkValidated(), false,
|
||||
"NX reports networkValidated false")
|
||||
|
||||
ri.mods = { { id = "demo", name = "Demo", github = "owner/repo", version = "1.0.0" } }
|
||||
ri:_modGithubAction("demo", "update")
|
||||
check(ri.modNotice and not ri.modNotice.ok,
|
||||
"remote mod github action is blocked on NX")
|
||||
|
||||
ri.tab = "find"
|
||||
ri:_refreshFind(true)
|
||||
eq(#((ri.findIndex and ri.findIndex.mods) or {}), 0,
|
||||
"find mods refresh stays empty on NX")
|
||||
|
||||
package.loaded["src.update.Check"] = nil
|
||||
package.loaded["src.core.Platform"] = nil
|
||||
package.loaded["src.import.RomImporter"] = nil
|
||||
|
||||
S.finish()
|
||||
@@ -0,0 +1,55 @@
|
||||
-- NX must not invoke HostShell / desktop file pickers (SWNX-04).
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
love.system = love.system or {}
|
||||
love.filesystem = love.filesystem or {}
|
||||
|
||||
local S = require("tests.harness").suite("platform NX shell gate")
|
||||
local check = S.check
|
||||
local eq = S.eq
|
||||
|
||||
local popenCalls = 0
|
||||
local realHostShell = package.loaded["src.core.HostShell"]
|
||||
package.loaded["src.core.HostShell"] = {
|
||||
envPrefix = function() return "" end,
|
||||
popen = function()
|
||||
popenCalls = popenCalls + 1
|
||||
return nil
|
||||
end,
|
||||
restart = function() end,
|
||||
}
|
||||
|
||||
love.system.getOS = function() return "NX" end
|
||||
love.filesystem.getSaveDirectory = function() return "/save/pokemon-love2d" end
|
||||
|
||||
package.loaded["src.core.Platform"] = nil
|
||||
package.loaded["src.import.RomImporter"] = nil
|
||||
local RomImporter = require("src.import.RomImporter")
|
||||
|
||||
local ri = RomImporter.new(function() end, { launcher = true })
|
||||
ri.ready = { red = false, blue = false, yellow = false }
|
||||
|
||||
popenCalls = 0
|
||||
ri:choose("red")
|
||||
eq(popenCalls, 0, "choose on NX does not call HostShell.popen")
|
||||
check(ri.notice ~= nil, "NX choose sets a save-directory notice")
|
||||
check(ri.notice.detail:find("imports", 1, true) ~= nil,
|
||||
"notice mentions imports inbox path")
|
||||
|
||||
-- Desktop path still reaches the shell when a picker exists.
|
||||
love.system.getOS = function() return "Linux" end
|
||||
package.loaded["src.core.Platform"] = nil
|
||||
package.loaded["src.import.RomImporter"] = nil
|
||||
RomImporter = require("src.import.RomImporter")
|
||||
ri = RomImporter.new(function() end, { launcher = true })
|
||||
ri.ready = { red = false, blue = false, yellow = false }
|
||||
popenCalls = 0
|
||||
ri:choose("red")
|
||||
check(popenCalls >= 1 or ri.notice ~= nil,
|
||||
"Linux choose still attempts shell picker or falls back with notice")
|
||||
|
||||
package.loaded["src.core.HostShell"] = realHostShell
|
||||
package.loaded["src.core.Platform"] = nil
|
||||
package.loaded["src.import.RomImporter"] = nil
|
||||
|
||||
S.finish()
|
||||
@@ -0,0 +1,62 @@
|
||||
-- NX / Android / desktop capability detection (SWNX-01).
|
||||
-- Self-contained: luajit tests/engine/platform_nx_test.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check = T.check
|
||||
local eq = T.eq
|
||||
|
||||
local savedLove = _G.love
|
||||
|
||||
local function withOS(osName, pickFile, fn)
|
||||
_G.love = {
|
||||
system = {
|
||||
getOS = function() return osName end,
|
||||
pickFile = pickFile,
|
||||
},
|
||||
}
|
||||
package.loaded["src.core.Platform"] = nil
|
||||
local Platform = require("src.core.Platform")
|
||||
Platform._resetForTests()
|
||||
local ok, err = pcall(fn, Platform)
|
||||
_G.love = savedLove
|
||||
package.loaded["src.core.Platform"] = nil
|
||||
if not ok then error(err) end
|
||||
end
|
||||
|
||||
-- NX: save-directory import, no shell spawn, network gated off
|
||||
withOS("NX", nil, function(Platform)
|
||||
local caps = Platform.detect()
|
||||
eq(caps.os, "NX", "NX detect os")
|
||||
eq(caps.nx, true, "NX flag")
|
||||
eq(caps.romImportMode, "save-directory", "NX romImportMode")
|
||||
eq(caps.canSpawnProcess, false, "NX cannot spawn processes")
|
||||
eq(caps.networkValidated, false, "NX network not validated")
|
||||
eq(Platform.isNX(), true, "isNX convenience")
|
||||
eq(Platform.romImportMode(), "save-directory", "romImportMode helper")
|
||||
end)
|
||||
|
||||
-- Android: mobile native picker path, not NX semantics
|
||||
withOS("Android", function() end, function(Platform)
|
||||
local caps = Platform.detect()
|
||||
eq(caps.os, "Android", "Android detect os")
|
||||
eq(caps.nx, false, "Android is not NX")
|
||||
eq(caps.mobile, true, "Android mobile")
|
||||
eq(caps.hasNativePicker, true, "Android has pickFile")
|
||||
eq(caps.romImportMode, "native-picker", "Android romImportMode")
|
||||
eq(Platform.isNX(), false, "Android isNX false")
|
||||
end)
|
||||
|
||||
-- Desktop Linux: shell spawn + desktop import mode
|
||||
withOS("Linux", nil, function(Platform)
|
||||
local caps = Platform.detect()
|
||||
eq(caps.os, "Linux", "Linux detect os")
|
||||
eq(caps.nx, false, "Linux is not NX")
|
||||
eq(caps.canSpawnProcess, true, "Linux can spawn processes")
|
||||
eq(caps.romImportMode, "desktop", "Linux romImportMode")
|
||||
eq(caps.networkValidated, true, "Linux network validated")
|
||||
eq(Platform.canSpawnProcess(), true, "canSpawnProcess helper")
|
||||
end)
|
||||
|
||||
T.finish()
|
||||
@@ -0,0 +1,47 @@
|
||||
-- NX RomImporter must not inherit Android mobile-file-bridge semantics (SWNX-03).
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local S = require("tests.harness").suite("rom importer NX flags")
|
||||
local eq = S.eq
|
||||
local check = S.check
|
||||
|
||||
love.system = love.system or {}
|
||||
love.filesystem = love.filesystem or {}
|
||||
local saved = {
|
||||
getOS = love.system.getOS,
|
||||
getSaveDirectory = love.filesystem.getSaveDirectory,
|
||||
createDirectory = love.filesystem.createDirectory,
|
||||
}
|
||||
|
||||
love.system.getOS = function() return "NX" end
|
||||
love.filesystem.getSaveDirectory = function() return "sdmc:/switch/gen1recomp/pokemon-love2d" end
|
||||
love.filesystem.createDirectory = function() return true end
|
||||
|
||||
package.loaded["src.core.Platform"] = nil
|
||||
package.loaded["src.import.RomImporter"] = nil
|
||||
local RomImporter = require("src.import.RomImporter")
|
||||
|
||||
local ri = RomImporter.new(function() end, { launcher = true })
|
||||
eq(ri.isNX, true, "NX importer sets isNX")
|
||||
eq(ri.android, false, "NX importer does not set android")
|
||||
eq(ri.mobileFileBridge, false, "NX importer does not set mobileFileBridge")
|
||||
eq(ri.romImportMode, "save-directory", "NX importer exposes save-directory mode")
|
||||
check(ri.pickPending == nil, "NX importer does not arm mobile pick polling")
|
||||
|
||||
love.system.getOS = function() return "Android" end
|
||||
package.loaded["src.core.Platform"] = nil
|
||||
package.loaded["src.import.RomImporter"] = nil
|
||||
RomImporter = require("src.import.RomImporter")
|
||||
ri = RomImporter.new(function() end, { launcher = true })
|
||||
eq(ri.isNX, false, "Android importer is not NX")
|
||||
eq(ri.android, true, "Android importer keeps android mobile path")
|
||||
eq(ri.mobileFileBridge, true, "Android importer sets mobileFileBridge")
|
||||
|
||||
love.system.getOS = saved.getOS
|
||||
love.filesystem.getSaveDirectory = saved.getSaveDirectory
|
||||
love.filesystem.createDirectory = saved.createDirectory
|
||||
package.loaded["src.core.Platform"] = nil
|
||||
package.loaded["src.import.RomImporter"] = nil
|
||||
|
||||
S.finish()
|
||||
@@ -0,0 +1,251 @@
|
||||
-- NX writable-inbox import: path hint, rescan, validate, hash route (SWNX-05..09).
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local S = require("tests.harness").suite("rom importer NX inbox")
|
||||
local eq = S.eq
|
||||
local check = S.check
|
||||
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local RomImporter = require("src.import.RomImporter")
|
||||
|
||||
local MiB = 1024 * 1024
|
||||
local redData = string.rep("R", MiB)
|
||||
local blueData = string.rep("B", MiB)
|
||||
local yellowData = string.rep("Y", MiB)
|
||||
local badSizeData = string.rep("?", MiB - 1)
|
||||
local unknownData = string.rep("?", MiB)
|
||||
|
||||
love.data = love.data or {}
|
||||
love.system = love.system or {}
|
||||
love.filesystem = love.filesystem or {}
|
||||
|
||||
local saved = {
|
||||
hash = love.data.hash,
|
||||
encode = love.data.encode,
|
||||
getOS = love.system.getOS,
|
||||
getSaveDirectory = love.filesystem.getSaveDirectory,
|
||||
createDirectory = love.filesystem.createDirectory,
|
||||
remove = love.filesystem.remove,
|
||||
}
|
||||
|
||||
love.data.hash = function(_, data)
|
||||
return { tag = data:sub(1, 1) }
|
||||
end
|
||||
love.data.encode = function(_, _, digest)
|
||||
if type(digest) == "table" and digest.tag == "R" then
|
||||
return GameVersion.info("red").sha1
|
||||
end
|
||||
if type(digest) == "table" and digest.tag == "B" then
|
||||
return GameVersion.info("blue").sha1
|
||||
end
|
||||
if type(digest) == "table" and digest.tag == "Y" then
|
||||
return GameVersion.info("yellow").sha1
|
||||
end
|
||||
return "0000000000000000000000000000000000000000"
|
||||
end
|
||||
|
||||
love.system.getOS = function() return "NX" end
|
||||
love.filesystem.getSaveDirectory = function() return "sdmc:/switch/gen1recomp/pokemon-love2d" end
|
||||
|
||||
local createdDirs = {}
|
||||
love.filesystem.createDirectory = function(name)
|
||||
createdDirs[name] = true
|
||||
return true
|
||||
end
|
||||
|
||||
local removed = {}
|
||||
love.filesystem.remove = function(name)
|
||||
removed[name] = true
|
||||
return saved.remove(name)
|
||||
end
|
||||
|
||||
package.loaded["src.core.Platform"] = nil
|
||||
package.loaded["src.import.RomImporter"] = nil
|
||||
RomImporter = require("src.import.RomImporter")
|
||||
|
||||
-- mtpHintPath strips only validated sdmc:/ prefix
|
||||
eq(RomImporter.mtpHintPath("sdmc:/switch/gen1recomp/pokemon-love2d"),
|
||||
"switch/gen1recomp/pokemon-love2d", "mtpHintPath strips sdmc:/")
|
||||
eq(RomImporter.mtpHintPath("/save/pokemon-love2d"),
|
||||
"/save/pokemon-love2d", "mtpHintPath leaves non-sdmc paths alone")
|
||||
eq(RomImporter.mtpHintPath("sdmc:"), "sdmc:",
|
||||
"mtpHintPath does not strip bare sdmc: without slash")
|
||||
|
||||
local function clearInbox()
|
||||
for _, name in ipairs(love.filesystem.getDirectoryItems("imports") or {}) do
|
||||
love.filesystem.remove("imports/" .. name)
|
||||
end
|
||||
for _, name in ipairs(love.filesystem.getDirectoryItems("") or {}) do
|
||||
if name:lower():match("%.gbc?$") then love.filesystem.remove(name) end
|
||||
end
|
||||
end
|
||||
|
||||
local function freshImporter(ready)
|
||||
clearInbox()
|
||||
createdDirs = {}
|
||||
removed = {}
|
||||
package.loaded["src.import.RomImporter"] = nil
|
||||
RomImporter = require("src.import.RomImporter")
|
||||
return setmetatable({
|
||||
isNX = true,
|
||||
romImportMode = "save-directory",
|
||||
mobileFileBridge = false,
|
||||
android = false,
|
||||
launcher = true,
|
||||
workState = nil,
|
||||
tab = "red",
|
||||
ready = {
|
||||
red = ready.red and true or false,
|
||||
blue = ready.blue and true or false,
|
||||
yellow = ready.yellow and true or false,
|
||||
},
|
||||
notice = nil,
|
||||
chooseVersion = nil,
|
||||
startData = function(self, data, displayName)
|
||||
self._started = { data = data, name = displayName }
|
||||
if #data ~= MiB then
|
||||
self.workState = "error"
|
||||
self.detail = "size"
|
||||
elseif not GameVersion.forSha1(
|
||||
love.data.encode("string", "hex", love.data.hash("sha1", data))) then
|
||||
self.workState = "error"
|
||||
self.detail = "hash"
|
||||
end
|
||||
end,
|
||||
setError = function(self, message)
|
||||
self.workState = "error"
|
||||
self.detail = message
|
||||
end,
|
||||
ensureImportsDir = RomImporter.ensureImportsDir,
|
||||
_setNxInboxNotice = RomImporter._setNxInboxNotice,
|
||||
scanInbox = RomImporter.scanInbox,
|
||||
rescanAction = RomImporter.rescanAction,
|
||||
}, RomImporter)
|
||||
end
|
||||
|
||||
-- First open creates imports/ and shows save path + MTP hint
|
||||
createdDirs = {}
|
||||
local ri = freshImporter({ red = false, blue = false, yellow = false })
|
||||
ri:ensureImportsDir()
|
||||
check(createdDirs.imports, "ensureImportsDir creates imports/")
|
||||
ri:_setNxInboxNotice("red")
|
||||
check(ri.notice ~= nil, "NX notice is set")
|
||||
check(ri.notice.detail:find("sdmc:/switch/gen1recomp/pokemon-love2d/imports/", 1, true),
|
||||
"notice contains runtime save path")
|
||||
check(ri.notice.detail:find("DBI MTP", 1, true) ~= nil,
|
||||
"notice contains OpenMTP-oriented hint")
|
||||
check(ri.notice.detail:find("switch/gen1recomp/pokemon-love2d/imports/", 1, true),
|
||||
"hint uses sdmc-stripped relative path")
|
||||
|
||||
-- Empty inbox rescan refreshes notice
|
||||
ri = freshImporter({ red = false, blue = false, yellow = false })
|
||||
ri:rescanAction("red")
|
||||
check(ri.notice ~= nil, "empty inbox rescan shows notice")
|
||||
check(ri._started == nil, "empty inbox does not start import")
|
||||
|
||||
-- Bad extension ignored
|
||||
ri = freshImporter({ red = false, blue = false, yellow = false })
|
||||
love.filesystem.write("imports/readme.txt", "nope")
|
||||
ri:rescanAction("red")
|
||||
check(ri._started == nil, "non-ROM extension is ignored")
|
||||
|
||||
-- Bad size rejected
|
||||
ri = freshImporter({ red = false, blue = false, yellow = false })
|
||||
love.filesystem.write("imports/small.gb", badSizeData)
|
||||
ri:rescanAction("red")
|
||||
check(ri._started ~= nil, "undersized ROM triggers import attempt")
|
||||
eq(ri.workState, "error", "undersized ROM is rejected")
|
||||
check(ri._started.name == "small.gb", "bad size uses basename")
|
||||
|
||||
-- Unknown hash rejected
|
||||
ri = freshImporter({ red = false, blue = false, yellow = false })
|
||||
love.filesystem.write("imports/hacked.gb", unknownData)
|
||||
ri:rescanAction("red")
|
||||
check(ri._started ~= nil, "unknown hash ROM is routed through startData")
|
||||
eq(ri.workState, "error", "unknown hash is rejected")
|
||||
|
||||
-- Valid Red from imports/
|
||||
ri = freshImporter({ red = false, blue = false, yellow = false })
|
||||
love.filesystem.write("imports/pokemon red.gb", redData)
|
||||
ri:rescanAction("red")
|
||||
check(ri._started ~= nil, "valid Red stub imports")
|
||||
eq(ri._started.name, "pokemon red.gb", "unicode/space filename preserved")
|
||||
eq(ri._started.data, redData, "Red bytes passed through")
|
||||
check(not removed["pokemon red.gb"], "NX retains source dump after import start")
|
||||
|
||||
-- imports/ scanned before save root
|
||||
ri = freshImporter({ red = false, blue = false, yellow = false })
|
||||
love.filesystem.write("imports/blue.gbc", blueData)
|
||||
love.filesystem.write("red_root.gb", redData)
|
||||
ri:rescanAction("blue")
|
||||
eq(ri._started.name, "blue.gbc", "imports/ wins over save root ordering")
|
||||
|
||||
-- Already-imported dump skipped so another version can import
|
||||
ri = freshImporter({ red = true, blue = false, yellow = false })
|
||||
love.filesystem.write("imports/pokemon red.gb", redData)
|
||||
love.filesystem.write("imports/pokemon blue.gb", blueData)
|
||||
ri:rescanAction("blue")
|
||||
eq(ri._started.name, "pokemon blue.gb", "ready Red dump ignored for pending Blue")
|
||||
|
||||
-- Yellow valid stub
|
||||
ri = freshImporter({ red = false, blue = false, yellow = false })
|
||||
love.filesystem.write("imports/pika.gbc", yellowData)
|
||||
ri:rescanAction("yellow")
|
||||
eq(ri._started.name, "pika.gbc", "valid Yellow stub imports")
|
||||
|
||||
-- Tab Scan again matches by SHA: Yellow must not import a pending Red dump
|
||||
ri = freshImporter({ red = false, blue = false, yellow = false })
|
||||
love.filesystem.write("imports/pokemon red.gb", redData)
|
||||
ri:rescanAction("yellow")
|
||||
check(ri._started == nil, "Yellow Scan again does not import pending Red")
|
||||
check(ri.notice ~= nil, "Yellow Scan again with only Red sets notice")
|
||||
eq(ri.notice.version, "yellow", "notice stays on Yellow tab")
|
||||
check(ri.notice.status:find("matching", 1, true) or ri.notice.status:find("Matching", 1, true),
|
||||
"notice reports no matching ROM for the tab")
|
||||
|
||||
-- Mixed inbox: Red listed first, Yellow pending — Yellow tab still picks Yellow
|
||||
ri = freshImporter({ red = false, blue = false, yellow = false })
|
||||
love.filesystem.write("imports/aaa_red.gb", redData)
|
||||
love.filesystem.write("imports/zzz_yellow.gbc", yellowData)
|
||||
ri:rescanAction("yellow")
|
||||
eq(ri._started.name, "zzz_yellow.gbc",
|
||||
"Yellow Scan again prefers Yellow SHA over earlier Red file")
|
||||
|
||||
-- Blue tab ignores pending Red (same SHA filter as Yellow)
|
||||
ri = freshImporter({ red = false, blue = false, yellow = false })
|
||||
love.filesystem.write("imports/pokemon red.gb", redData)
|
||||
ri:rescanAction("blue")
|
||||
check(ri._started == nil, "Blue Scan again does not import pending Red")
|
||||
eq(ri.notice.version, "blue", "Blue-only-other-dump notice stays on Blue")
|
||||
|
||||
-- Target already ready in a mixed inbox → No new ROM (not other-version import)
|
||||
ri = freshImporter({ red = false, blue = false, yellow = true })
|
||||
love.filesystem.write("imports/aaa_red.gb", redData)
|
||||
love.filesystem.write("imports/zzz_yellow.gbc", yellowData)
|
||||
ri:rescanAction("yellow")
|
||||
check(ri._started == nil, "ready Yellow + pending Red does not start import")
|
||||
check(ri.notice ~= nil and ri.notice.status:find("No new ROM", 1, true),
|
||||
"ready Yellow dump yields No new ROM found")
|
||||
|
||||
-- Cleanup + restore stubs shared with other suites
|
||||
love.filesystem.remove("imports/readme.txt")
|
||||
love.filesystem.remove("imports/small.gb")
|
||||
love.filesystem.remove("imports/hacked.gb")
|
||||
love.filesystem.remove("imports/pokemon red.gb")
|
||||
love.filesystem.remove("imports/blue.gbc")
|
||||
love.filesystem.remove("imports/pokemon blue.gb")
|
||||
love.filesystem.remove("imports/pika.gbc")
|
||||
love.filesystem.remove("imports/aaa_red.gb")
|
||||
love.filesystem.remove("imports/zzz_yellow.gbc")
|
||||
love.filesystem.remove("red_root.gb")
|
||||
love.data.hash = saved.hash
|
||||
love.data.encode = saved.encode
|
||||
love.system.getOS = saved.getOS
|
||||
love.filesystem.getSaveDirectory = saved.getSaveDirectory
|
||||
love.filesystem.createDirectory = saved.createDirectory
|
||||
love.filesystem.remove = saved.remove
|
||||
package.loaded["src.core.Platform"] = nil
|
||||
package.loaded["src.import.RomImporter"] = nil
|
||||
|
||||
S.finish()
|
||||
@@ -0,0 +1,312 @@
|
||||
-- NX mods zip inbox: ensure imports/mods/, MTP hint (NXMOD-01..05).
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local S = require("tests.harness").suite("rom importer NX mods inbox")
|
||||
local eq = S.eq
|
||||
local check = S.check
|
||||
|
||||
local RomImporter = require("src.import.RomImporter")
|
||||
|
||||
love.system = love.system or {}
|
||||
love.filesystem = love.filesystem or {}
|
||||
|
||||
local saved = {
|
||||
getOS = love.system.getOS,
|
||||
getSaveDirectory = love.filesystem.getSaveDirectory,
|
||||
createDirectory = love.filesystem.createDirectory,
|
||||
remove = love.filesystem.remove,
|
||||
}
|
||||
|
||||
love.system.getOS = function() return "NX" end
|
||||
love.filesystem.getSaveDirectory = function()
|
||||
return "sdmc:/switch/gen1recomp/pokemon-love2d"
|
||||
end
|
||||
|
||||
local createdDirs = {}
|
||||
love.filesystem.createDirectory = function(name)
|
||||
createdDirs[name] = true
|
||||
return true
|
||||
end
|
||||
|
||||
local removed = {}
|
||||
love.filesystem.remove = function(name)
|
||||
removed[name] = true
|
||||
return saved.remove(name)
|
||||
end
|
||||
|
||||
package.loaded["src.core.Platform"] = nil
|
||||
package.loaded["src.import.RomImporter"] = nil
|
||||
RomImporter = require("src.import.RomImporter")
|
||||
|
||||
local function clearModsInbox()
|
||||
for _, name in ipairs(love.filesystem.getDirectoryItems("imports/mods") or {}) do
|
||||
love.filesystem.remove("imports/mods/" .. name)
|
||||
end
|
||||
for _, name in ipairs(love.filesystem.getDirectoryItems("imports") or {}) do
|
||||
love.filesystem.remove("imports/" .. name)
|
||||
end
|
||||
end
|
||||
|
||||
local function freshImporter()
|
||||
clearModsInbox()
|
||||
createdDirs = {}
|
||||
removed = {}
|
||||
package.loaded["src.import.RomImporter"] = nil
|
||||
RomImporter = require("src.import.RomImporter")
|
||||
return setmetatable({
|
||||
isNX = true,
|
||||
android = false,
|
||||
launcher = true,
|
||||
workState = nil,
|
||||
tab = "mods",
|
||||
modNotice = nil,
|
||||
mods = {},
|
||||
ready = { red = false, blue = false, yellow = false },
|
||||
ensureImportsDir = RomImporter.ensureImportsDir,
|
||||
ensureModsInboxDir = RomImporter.ensureModsInboxDir,
|
||||
_setNxModsInboxNotice = RomImporter._setNxModsInboxNotice,
|
||||
scanModsInbox = RomImporter.scanModsInbox,
|
||||
scanInbox = RomImporter.scanInbox,
|
||||
rescanModsAction = RomImporter.rescanModsAction,
|
||||
chooseMod = RomImporter.chooseMod,
|
||||
_installMod = RomImporter._installMod,
|
||||
_refreshMods = function(self)
|
||||
self._refreshed = (self._refreshed or 0) + 1
|
||||
self.mods = self.mods or {}
|
||||
end,
|
||||
}, RomImporter)
|
||||
end
|
||||
|
||||
-- NXMOD-01: ensureModsInboxDir creates imports/mods/ under save FS
|
||||
createdDirs = {}
|
||||
local ri = freshImporter()
|
||||
ri:ensureModsInboxDir()
|
||||
check(createdDirs.imports or createdDirs["imports/mods"],
|
||||
"ensureModsInboxDir creates parent imports/ or nested path")
|
||||
check(createdDirs["imports/mods"],
|
||||
"ensureModsInboxDir creates imports/mods/")
|
||||
|
||||
-- NXMOD-01: notice/hint includes save dir + relative imports/mods/ MTP path
|
||||
ri = freshImporter()
|
||||
ri:_setNxModsInboxNotice()
|
||||
check(ri.modNotice ~= nil, "NX mods inbox notice is set")
|
||||
check(ri.modNotice.text:find("sdmc:/switch/gen1recomp/pokemon-love2d/imports/mods/", 1, true),
|
||||
"mods notice contains runtime save path + imports/mods/")
|
||||
check(ri.modNotice.text:find("DBI MTP", 1, true) ~= nil,
|
||||
"mods notice contains OpenMTP-oriented hint")
|
||||
check(ri.modNotice.text:find("switch/gen1recomp/pokemon-love2d/imports/mods/", 1, true),
|
||||
"hint uses sdmc-stripped relative imports/mods/ path")
|
||||
|
||||
-- NXMOD-02: scanModsInbox returns only *.zip under imports/mods/
|
||||
ri = freshImporter()
|
||||
love.filesystem.write("imports/mods/valid.zip", "ZIPDATA")
|
||||
love.filesystem.write("imports/mods/readme.txt", "nope")
|
||||
love.filesystem.write("imports/mods/cart.gb", string.rep("R", 16))
|
||||
love.filesystem.write("imports/other.zip", "WRONGDIR")
|
||||
local zips = ri:scanModsInbox()
|
||||
eq(#zips, 1, "scanModsInbox returns one zip candidate")
|
||||
eq(zips[1], "imports/mods/valid.zip", "scanModsInbox path is under imports/mods/")
|
||||
|
||||
-- ROM scanInbox must not treat .zip as ROM
|
||||
ri = freshImporter()
|
||||
love.filesystem.write("imports/modpack.zip", "ZIPROM")
|
||||
love.filesystem.write("imports/mods/also.zip", "ZIPMOD")
|
||||
local roms = ri:scanInbox()
|
||||
for _, path in ipairs(roms) do
|
||||
check(not path:lower():match("%.zip$"),
|
||||
"ROM scanInbox ignores zip: " .. tostring(path))
|
||||
end
|
||||
eq(#roms, 0, "ROM scanInbox finds no zip-only inbox entries")
|
||||
|
||||
-- Edge: ROM inbox with .gb alongside .zip still ignores zip (spec edge)
|
||||
ri = freshImporter()
|
||||
love.filesystem.write("imports/cart.gb", string.rep("G", 16))
|
||||
love.filesystem.write("imports/sidecar.zip", "NOTAROM")
|
||||
roms = ri:scanInbox()
|
||||
local sawGb, sawZip = false, false
|
||||
for _, path in ipairs(roms) do
|
||||
if path:lower():match("%.zip$") then sawZip = true end
|
||||
if path:lower():match("%.gb$") then sawGb = true end
|
||||
end
|
||||
check(sawGb, "ROM scan still finds .gb when zip present")
|
||||
check(not sawZip, "ROM scan never lists .zip even beside .gb")
|
||||
|
||||
-- Stub LauncherMods.installZip for rescan tests (NXMOD-02..04)
|
||||
local installCalls = {}
|
||||
local installBehavior = {} -- path -> {ok=bool, id=string|err}
|
||||
package.loaded["src.mods.LauncherMods"] = {
|
||||
installZip = function(source)
|
||||
installCalls[#installCalls + 1] = source
|
||||
local b = installBehavior[source]
|
||||
if not b then return false, "unexpected source: " .. tostring(source) end
|
||||
if b.ok then return true, b.id or "mod-id" end
|
||||
return false, b.err or "bad zip"
|
||||
end,
|
||||
}
|
||||
|
||||
-- Empty inbox rescan → MTP notice, no install
|
||||
ri = freshImporter()
|
||||
installCalls = {}
|
||||
ri:rescanModsAction()
|
||||
eq(#installCalls, 0, "empty mods inbox does not call installZip")
|
||||
check(ri.modNotice ~= nil and ri.modNotice.text:find("imports/mods/", 1, true),
|
||||
"empty rescan shows mods MTP notice")
|
||||
|
||||
-- Success → refresh; zip retained (no remove)
|
||||
ri = freshImporter()
|
||||
installCalls = {}
|
||||
removed = {}
|
||||
love.filesystem.write("imports/mods/good.zip", "GOODZIP")
|
||||
installBehavior["imports/mods/good.zip"] = { ok = true, id = "good-mod" }
|
||||
ri:rescanModsAction()
|
||||
eq(#installCalls, 1, "success path calls installZip once")
|
||||
eq(installCalls[1], "imports/mods/good.zip", "installZip receives inbox path")
|
||||
check(ri._refreshed and ri._refreshed >= 1, "success refreshes mods list")
|
||||
check(ri.modNotice and ri.modNotice.ok, "success sets ok notice")
|
||||
check(not removed["imports/mods/good.zip"], "success retains inbox zip")
|
||||
check(love.filesystem.read("imports/mods/good.zip") == "GOODZIP",
|
||||
"success leaves zip bytes in inbox")
|
||||
|
||||
-- Failure → clear notice; zip retained
|
||||
ri = freshImporter()
|
||||
installCalls = {}
|
||||
removed = {}
|
||||
love.filesystem.write("imports/mods/bad.zip", "BADZIP")
|
||||
installBehavior["imports/mods/bad.zip"] = { ok = false, err = "missing manifest" }
|
||||
ri:rescanModsAction()
|
||||
eq(#installCalls, 1, "failure path still attempts installZip")
|
||||
check(ri.modNotice and not ri.modNotice.ok, "failure sets clear error notice")
|
||||
check(ri.modNotice.text:find("missing manifest", 1, true),
|
||||
"failure notice includes installZip error")
|
||||
check(not removed["imports/mods/bad.zip"], "failure does not remove inbox zip")
|
||||
check(love.filesystem.read("imports/mods/bad.zip") == "BADZIP",
|
||||
"failure leaves zip in inbox")
|
||||
|
||||
-- Mixed valid/invalid: attempt each; no zip deleted
|
||||
ri = freshImporter()
|
||||
installCalls = {}
|
||||
removed = {}
|
||||
love.filesystem.write("imports/mods/a-bad.zip", "BAD")
|
||||
love.filesystem.write("imports/mods/b-good.zip", "GOOD")
|
||||
installBehavior["imports/mods/a-bad.zip"] = { ok = false, err = "no manifest" }
|
||||
installBehavior["imports/mods/b-good.zip"] = { ok = true, id = "b-mod" }
|
||||
ri:rescanModsAction()
|
||||
eq(#installCalls, 2, "mixed inbox attempts each zip")
|
||||
check(not removed["imports/mods/a-bad.zip"], "mixed: bad zip retained")
|
||||
check(not removed["imports/mods/b-good.zip"], "mixed: good zip retained")
|
||||
check(love.filesystem.read("imports/mods/a-bad.zip") ~= nil, "mixed bad still present")
|
||||
check(love.filesystem.read("imports/mods/b-good.zip") ~= nil, "mixed good still present")
|
||||
check(ri.modNotice and ri.modNotice.ok, "mixed keeps overall success when one zip installs")
|
||||
check(ri.modNotice.text:find("failed", 1, true),
|
||||
"mixed success notice still surfaces sibling failure")
|
||||
check(ri.modNotice.text:find("no manifest", 1, true),
|
||||
"mixed success notice includes the failure reason")
|
||||
|
||||
-- Mac MTP AppleDouble (._*.zip) must not be install candidates
|
||||
ri = freshImporter()
|
||||
installCalls = {}
|
||||
love.filesystem.write("imports/mods/._DRAMATIC_SHAPE-1.4.0.zip", "APPL")
|
||||
love.filesystem.write("imports/mods/DRAMATIC_SHAPE-1.4.0.zip", "GOOD")
|
||||
installBehavior["imports/mods/DRAMATIC_SHAPE-1.4.0.zip"] = { ok = true, id = "dramatic_shape" }
|
||||
ri:rescanModsAction()
|
||||
eq(#installCalls, 1, "AppleDouble ._*.zip is skipped")
|
||||
eq(installCalls[1], "imports/mods/DRAMATIC_SHAPE-1.4.0.zip",
|
||||
"only the real zip is installed")
|
||||
check(ri.modNotice and ri.modNotice.ok, "AppleDouble skip still shows install success")
|
||||
check(not (ri.modNotice.text or ""):find("failed", 1, true),
|
||||
"AppleDouble-only sibling does not invent a mixed failure line")
|
||||
|
||||
-- Mac MTP AppleDouble ROM sidecar must not be ROM inbox candidates
|
||||
ri = freshImporter()
|
||||
love.filesystem.write("imports/._cart.gb", string.rep("X", 16))
|
||||
love.filesystem.write("imports/cart.gb", string.rep("G", 16))
|
||||
roms = ri:scanInbox()
|
||||
local sawHidden, sawReal = false, false
|
||||
for _, path in ipairs(roms) do
|
||||
if path:find("._cart", 1, true) then sawHidden = true end
|
||||
if path == "imports/cart.gb" then sawReal = true end
|
||||
end
|
||||
check(not sawHidden, "ROM scanInbox skips AppleDouble ._*.gb")
|
||||
check(sawReal, "ROM scanInbox still finds the real .gb")
|
||||
love.filesystem.remove("imports/._cart.gb")
|
||||
love.filesystem.remove("imports/cart.gb")
|
||||
|
||||
-- NXMOD-05: chooseMod on NX routes to inbox rescan; no HostShell/chooseZip
|
||||
local hostShellCalls = 0
|
||||
package.loaded["src.core.HostShell"] = {
|
||||
run = function()
|
||||
hostShellCalls = hostShellCalls + 1
|
||||
error("HostShell must not run on NX chooseMod")
|
||||
end,
|
||||
available = function() return false end,
|
||||
}
|
||||
ri = freshImporter()
|
||||
installCalls = {}
|
||||
love.filesystem.write("imports/mods/from-choose.zip", "CHOOSE")
|
||||
installBehavior["imports/mods/from-choose.zip"] = { ok = true, id = "choose-mod" }
|
||||
ri:chooseMod()
|
||||
eq(hostShellCalls, 0, "NX chooseMod does not require HostShell")
|
||||
eq(#installCalls, 1, "NX chooseMod rescans and installs inbox zip")
|
||||
eq(installCalls[1], "imports/mods/from-choose.zip",
|
||||
"NX chooseMod installs from imports/mods/")
|
||||
check(ri.modNotice and ri.modNotice.ok, "NX chooseMod success notice")
|
||||
|
||||
-- NXMOD-01 UI: NX MODS panel label + hints mention imports/mods/
|
||||
ri = freshImporter()
|
||||
eq(ri:_modsImportButtonLabel(), "Scan again",
|
||||
"NX MODS button label is Scan again")
|
||||
local defaultHint = ri:_modsDefaultHint()
|
||||
check(defaultHint:find("imports/mods/", 1, true),
|
||||
"NX default hint mentions imports/mods/")
|
||||
check(defaultHint:find("DBI MTP", 1, true),
|
||||
"NX default hint mentions DBI MTP")
|
||||
local emptyHint = ri:_modsEmptyHint()
|
||||
check(emptyHint:find("imports/mods/", 1, true),
|
||||
"NX empty-state hint mentions imports/mods/")
|
||||
check(emptyHint:find("Scan again", 1, true),
|
||||
"NX empty-state hint mentions Scan again")
|
||||
|
||||
-- Desktop keeps Import mod .zip (non-NX)
|
||||
local desk = setmetatable({
|
||||
isNX = false, android = false,
|
||||
_modsImportButtonLabel = RomImporter._modsImportButtonLabel,
|
||||
_modsDefaultHint = RomImporter._modsDefaultHint,
|
||||
_modsEmptyHint = RomImporter._modsEmptyHint,
|
||||
}, RomImporter)
|
||||
eq(desk:_modsImportButtonLabel(), "Import mod .zip",
|
||||
"desktop MODS button stays Import mod .zip")
|
||||
check(desk:_modsDefaultHint():find("drop a mod", 1, true),
|
||||
"desktop default hint stays drop-oriented")
|
||||
|
||||
-- Edge: id-already-exists conflict retains inbox zip (no silent delete)
|
||||
ri = freshImporter()
|
||||
installCalls = {}
|
||||
removed = {}
|
||||
love.filesystem.write("imports/mods/dup.zip", "DUP")
|
||||
installBehavior["imports/mods/dup.zip"] = {
|
||||
ok = false, err = "mod id already installed",
|
||||
}
|
||||
ri:rescanModsAction()
|
||||
eq(#installCalls, 1, "conflict still attempts installZip")
|
||||
check(ri.modNotice and not ri.modNotice.ok, "conflict surfaces notice")
|
||||
check(not removed["imports/mods/dup.zip"], "conflict retains inbox zip")
|
||||
check(love.filesystem.read("imports/mods/dup.zip") == "DUP",
|
||||
"conflict leaves zip bytes intact")
|
||||
|
||||
-- Cleanup + restore stubs
|
||||
clearModsInbox()
|
||||
love.filesystem.remove("imports/other.zip")
|
||||
love.filesystem.remove("imports/modpack.zip")
|
||||
love.filesystem.remove("imports/cart.gb")
|
||||
love.filesystem.remove("imports/sidecar.zip")
|
||||
package.loaded["src.mods.LauncherMods"] = nil
|
||||
package.loaded["src.core.HostShell"] = nil
|
||||
love.system.getOS = saved.getOS
|
||||
love.filesystem.getSaveDirectory = saved.getSaveDirectory
|
||||
love.filesystem.createDirectory = saved.createDirectory
|
||||
love.filesystem.remove = saved.remove
|
||||
package.loaded["src.core.Platform"] = nil
|
||||
package.loaded["src.import.RomImporter"] = nil
|
||||
|
||||
S.finish()
|
||||
@@ -0,0 +1,514 @@
|
||||
-- NX saves .sav inbox: ensure imports/saves/, MTP hint, AppleDouble/retain/
|
||||
-- retire-on-success + hash dedupe (NXSAV + RES harden).
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local S = require("tests.harness").suite("rom importer NX saves inbox")
|
||||
local eq = S.eq
|
||||
local check = S.check
|
||||
|
||||
local RomImporter = require("src.import.RomImporter")
|
||||
|
||||
love.data = love.data or {}
|
||||
love.system = love.system or {}
|
||||
love.filesystem = love.filesystem or {}
|
||||
|
||||
local saved = {
|
||||
hash = love.data.hash,
|
||||
encode = love.data.encode,
|
||||
getOS = love.system.getOS,
|
||||
getSaveDirectory = love.filesystem.getSaveDirectory,
|
||||
createDirectory = love.filesystem.createDirectory,
|
||||
remove = love.filesystem.remove,
|
||||
}
|
||||
|
||||
-- Deterministic fake sha1 so content-hash dedupe is testable headless.
|
||||
love.data.hash = function(_, data)
|
||||
return data
|
||||
end
|
||||
love.data.encode = function(_, _, digest)
|
||||
local s = type(digest) == "string" and digest or tostring(digest)
|
||||
local hex = {}
|
||||
for i = 1, #s do
|
||||
hex[#hex + 1] = string.format("%02x", s:byte(i))
|
||||
end
|
||||
local h = table.concat(hex)
|
||||
if #h < 40 then h = h .. string.rep("0", 40 - #h) end
|
||||
return h:sub(1, 40)
|
||||
end
|
||||
|
||||
love.system.getOS = function() return "NX" end
|
||||
love.filesystem.getSaveDirectory = function()
|
||||
return "sdmc:/switch/gen1recomp/pokemon-love2d"
|
||||
end
|
||||
|
||||
local createdDirs = {}
|
||||
love.filesystem.createDirectory = function(name)
|
||||
createdDirs[name] = true
|
||||
return true
|
||||
end
|
||||
|
||||
local removed = {}
|
||||
love.filesystem.remove = function(name)
|
||||
removed[name] = true
|
||||
return saved.remove(name)
|
||||
end
|
||||
|
||||
package.loaded["src.core.Platform"] = nil
|
||||
package.loaded["src.import.RomImporter"] = nil
|
||||
RomImporter = require("src.import.RomImporter")
|
||||
|
||||
local function clearSavesInbox()
|
||||
for _, ver in ipairs({ "red", "blue", "yellow" }) do
|
||||
local dir = "imports/saves/" .. ver
|
||||
for _, name in ipairs(love.filesystem.getDirectoryItems(dir) or {}) do
|
||||
love.filesystem.remove(dir .. "/" .. name)
|
||||
end
|
||||
love.filesystem.remove(dir .. "/.imported-sha1")
|
||||
end
|
||||
for _, name in ipairs(love.filesystem.getDirectoryItems("imports/saves") or {}) do
|
||||
love.filesystem.remove("imports/saves/" .. name)
|
||||
end
|
||||
for _, name in ipairs(love.filesystem.getDirectoryItems("imports/mods") or {}) do
|
||||
love.filesystem.remove("imports/mods/" .. name)
|
||||
end
|
||||
for _, name in ipairs(love.filesystem.getDirectoryItems("imports") or {}) do
|
||||
love.filesystem.remove("imports/" .. name)
|
||||
end
|
||||
end
|
||||
|
||||
local function freshImporter()
|
||||
clearSavesInbox()
|
||||
createdDirs = {}
|
||||
removed = {}
|
||||
package.loaded["src.import.RomImporter"] = nil
|
||||
RomImporter = require("src.import.RomImporter")
|
||||
return setmetatable({
|
||||
isNX = true,
|
||||
android = false,
|
||||
launcher = true,
|
||||
workState = nil,
|
||||
tab = "red",
|
||||
panelVersion = "red",
|
||||
saveNotice = {},
|
||||
ready = { red = true, blue = false, yellow = false },
|
||||
activeSlot = {},
|
||||
slotScroll = {},
|
||||
slots = {},
|
||||
ensureImportsDir = RomImporter.ensureImportsDir,
|
||||
ensureSavesInboxDir = RomImporter.ensureSavesInboxDir,
|
||||
ensureModsInboxDir = RomImporter.ensureModsInboxDir,
|
||||
_setNxSavesInboxNotice = RomImporter._setNxSavesInboxNotice,
|
||||
_resolveSaveVersion = RomImporter._resolveSaveVersion,
|
||||
scanSavesInbox = RomImporter.scanSavesInbox,
|
||||
scanModsInbox = RomImporter.scanModsInbox,
|
||||
scanInbox = RomImporter.scanInbox,
|
||||
rescanSavesAction = RomImporter.rescanSavesAction,
|
||||
chooseSaveImport = RomImporter.chooseSaveImport,
|
||||
exportSave = RomImporter.exportSave,
|
||||
_importSave = RomImporter._importSave,
|
||||
_savedropTarget = RomImporter._savedropTarget,
|
||||
_savesDefaultHint = RomImporter._savesDefaultHint,
|
||||
_refreshSlots = function(self, version)
|
||||
self._refreshed = (self._refreshed or 0) + 1
|
||||
self._refreshVersion = version
|
||||
end,
|
||||
}, RomImporter)
|
||||
end
|
||||
|
||||
-- RES-07: fixture uses isNX=true, android=false
|
||||
local ri = freshImporter()
|
||||
eq(ri.isNX, true, "RES-07: fixture isNX=true")
|
||||
eq(ri.android, false, "RES-07: fixture android=false")
|
||||
|
||||
-- RES-01: ensureSavesInboxDir creates imports/, imports/saves/, and per-game dirs
|
||||
createdDirs = {}
|
||||
ri = freshImporter()
|
||||
ri:ensureSavesInboxDir("red")
|
||||
check(createdDirs.imports == true,
|
||||
"RES-01: ensureSavesInboxDir creates parent imports/")
|
||||
check(createdDirs["imports/saves"] == true,
|
||||
"RES-01: ensureSavesInboxDir creates imports/saves/")
|
||||
check(createdDirs["imports/saves/red"] == true,
|
||||
"RES-01: ensureSavesInboxDir creates imports/saves/red/")
|
||||
check(createdDirs["imports/saves/blue"] == true,
|
||||
"RES-01: ensureSavesInboxDir creates imports/saves/blue/")
|
||||
check(createdDirs["imports/saves/yellow"] == true,
|
||||
"RES-01: ensureSavesInboxDir creates imports/saves/yellow/")
|
||||
|
||||
-- NXSAV-02: notice/hint includes save dir + per-game imports/saves/<version>/ MTP path
|
||||
ri = freshImporter()
|
||||
ri:_setNxSavesInboxNotice("red")
|
||||
check(ri.saveNotice.red ~= nil, "NX saves inbox notice is set")
|
||||
check(ri.saveNotice.red.text:find("sdmc:/switch/gen1recomp/pokemon-love2d/imports/saves/red/", 1, true),
|
||||
"saves notice contains runtime save path + imports/saves/red/")
|
||||
check(ri.saveNotice.red.text:find("DBI MTP", 1, true) ~= nil,
|
||||
"saves notice contains OpenMTP-oriented hint")
|
||||
check(ri.saveNotice.red.text:find("imports/saves/red/", 1, true),
|
||||
"hint uses per-game imports/saves/red/ path")
|
||||
|
||||
-- NXSAV-01 / RES-08: scanSavesInbox returns only *.sav under imports/saves/<version>/
|
||||
ri = freshImporter()
|
||||
love.filesystem.write("imports/saves/red/valid.sav", string.rep("S", 32))
|
||||
love.filesystem.write("imports/saves/red/readme.txt", "nope")
|
||||
love.filesystem.write("imports/saves/red/cart.gb", string.rep("R", 16))
|
||||
love.filesystem.write("imports/saves/red/pack.zip", "ZIP")
|
||||
love.filesystem.write("imports/saves/blue/other.sav", "WRONGGAME")
|
||||
love.filesystem.write("imports/other.sav", "WRONGDIR")
|
||||
local savs = ri:scanSavesInbox("red")
|
||||
eq(#savs, 1, "scanSavesInbox returns one .sav candidate for red")
|
||||
eq(savs[1], "imports/saves/red/valid.sav", "scanSavesInbox path is under imports/saves/red/")
|
||||
|
||||
-- RES-08: ROM scanInbox must not treat imports/saves/*.sav as ROM
|
||||
ri = freshImporter()
|
||||
love.filesystem.write("imports/saves/red/cart.sav", string.rep("S", 32))
|
||||
love.filesystem.write("imports/saves/red/dump.gb", string.rep("G", 16))
|
||||
local roms = ri:scanInbox()
|
||||
for _, path in ipairs(roms) do
|
||||
check(not path:lower():match("%.sav$"),
|
||||
"ROM scanInbox ignores .sav: " .. tostring(path))
|
||||
check(not path:find("imports/saves/", 1, true),
|
||||
"ROM scanInbox ignores imports/saves/: " .. tostring(path))
|
||||
end
|
||||
|
||||
-- RES-08: mod scanModsInbox ignores .sav
|
||||
ri = freshImporter()
|
||||
ri:ensureModsInboxDir()
|
||||
love.filesystem.write("imports/mods/mod.zip", "ZIP")
|
||||
love.filesystem.write("imports/saves/red/slot.sav", string.rep("S", 32))
|
||||
local zips = ri:scanModsInbox()
|
||||
for _, path in ipairs(zips) do
|
||||
check(not path:lower():match("%.sav$"),
|
||||
"mod scanModsInbox ignores .sav: " .. tostring(path))
|
||||
end
|
||||
eq(#zips, 1, "mod scanModsInbox still finds only its zip")
|
||||
|
||||
-- Stub SaveFileIO.importToSlot for rescan tests
|
||||
local importCalls = {}
|
||||
local importBehavior = {} -- path -> {ok=bool, id=string|err}
|
||||
package.loaded["src.import.SaveFileIO"] = {
|
||||
importToSlot = function(source, version)
|
||||
importCalls[#importCalls + 1] = { source = source, version = version }
|
||||
local b = importBehavior[source]
|
||||
if not b then return false, "unexpected source: " .. tostring(source) end
|
||||
if b.ok then return true, b.id or "slot-1" end
|
||||
return false, b.err or "bad sav"
|
||||
end,
|
||||
exportActiveSlot = function()
|
||||
return false, "no save"
|
||||
end,
|
||||
}
|
||||
|
||||
-- RES-04: empty inbox rescan → MTP notice, no import
|
||||
ri = freshImporter()
|
||||
importCalls = {}
|
||||
ri:rescanSavesAction("red")
|
||||
eq(#importCalls, 0, "empty saves inbox does not call importToSlot")
|
||||
check(ri.saveNotice.red ~= nil, "RES-04: empty rescan sets saveNotice")
|
||||
check(ri.saveNotice.red.text:find("imports/saves/red/", 1, true),
|
||||
"empty rescan shows saves MTP notice")
|
||||
|
||||
-- RES-02: AppleDouble-only inbox ≡ empty
|
||||
ri = freshImporter()
|
||||
importCalls = {}
|
||||
love.filesystem.write("imports/saves/red/._foo.sav", "APPL")
|
||||
ri:rescanSavesAction("red")
|
||||
eq(#importCalls, 0, "RES-02: AppleDouble-only does not import")
|
||||
check(ri.saveNotice.red ~= nil and ri.saveNotice.red.text:find("imports/saves/red/", 1, true),
|
||||
"RES-02: AppleDouble-only shows MTP notice")
|
||||
|
||||
-- NXSAV-03 / RES-05: success → refresh; bytes kept as *.sav.imported (not re-scanned)
|
||||
ri = freshImporter()
|
||||
importCalls = {}
|
||||
removed = {}
|
||||
love.filesystem.write("imports/saves/red/good.sav", "GOODSAV")
|
||||
importBehavior["imports/saves/red/good.sav"] = { ok = true, id = "slot-good" }
|
||||
ri:rescanSavesAction("red")
|
||||
eq(#importCalls, 1, "success path calls importToSlot once")
|
||||
eq(importCalls[1].source, "imports/saves/red/good.sav", "importToSlot receives inbox path")
|
||||
eq(importCalls[1].version, "red", "importToSlot uses panel version")
|
||||
check(ri._refreshed and ri._refreshed >= 1, "success refreshes slots")
|
||||
check(ri.saveNotice.red and ri.saveNotice.red.ok, "success sets ok notice")
|
||||
check(ri.saveNotice.red.text:find("Pokemon Red", 1, true)
|
||||
or ri.saveNotice.red.text:find("Red", 1, true),
|
||||
"success notice names the game tab")
|
||||
check(love.filesystem.getInfo("imports/saves/red/good.sav") == nil,
|
||||
"RES-05: success retires live .sav (no longer a candidate)")
|
||||
check(love.filesystem.read("imports/saves/red/good.sav.imported") == "GOODSAV",
|
||||
"RES-05: success keeps bytes under .sav.imported")
|
||||
check(love.filesystem.getInfo("imports/saves/red/.imported-sha1") ~= nil,
|
||||
"success records content hash ledger")
|
||||
|
||||
-- Re-press Import save must not clone slots (hash ledger + retired file)
|
||||
ri = freshImporter()
|
||||
importCalls = {}
|
||||
love.filesystem.write("imports/saves/red/again.sav", "SAMEBYTES")
|
||||
importBehavior["imports/saves/red/again.sav"] = { ok = true, id = "slot-1" }
|
||||
ri:rescanSavesAction("red")
|
||||
eq(#importCalls, 1, "first import of again.sav")
|
||||
-- Put the same bytes back under a new name (player re-copied / renamed)
|
||||
love.filesystem.write("imports/saves/red/again-copy.sav", "SAMEBYTES")
|
||||
importBehavior["imports/saves/red/again-copy.sav"] = { ok = true, id = "slot-clone" }
|
||||
importCalls = {}
|
||||
ri:rescanSavesAction("red")
|
||||
eq(#importCalls, 0, "harden: same content hash is not imported again")
|
||||
check(ri.saveNotice.red and ri.saveNotice.red.ok,
|
||||
"harden: already-imported skip sets ok notice")
|
||||
check(ri.saveNotice.red.text:find("Already imported", 1, true)
|
||||
or ri.saveNotice.red.text:find("skipped", 1, true),
|
||||
"harden: notice explains skip")
|
||||
check(love.filesystem.getInfo("imports/saves/red/again-copy.sav") == nil,
|
||||
"harden: leftover duplicate .sav is retired without importing")
|
||||
|
||||
-- NXSAV-04 / RES-05: failure → clear notice; .sav retained as-is
|
||||
ri = freshImporter()
|
||||
importCalls = {}
|
||||
removed = {}
|
||||
love.filesystem.write("imports/saves/red/bad.sav", "BADSAV")
|
||||
importBehavior["imports/saves/red/bad.sav"] = { ok = false, err = "save file must be 32768 bytes" }
|
||||
ri:rescanSavesAction("red")
|
||||
eq(#importCalls, 1, "failure path still attempts importToSlot")
|
||||
check(ri.saveNotice.red and not ri.saveNotice.red.ok, "failure sets clear error notice")
|
||||
check(ri.saveNotice.red.text:find("32768", 1, true),
|
||||
"failure notice includes import error")
|
||||
check(not removed["imports/saves/red/bad.sav"], "RES-05: failure does not remove inbox .sav")
|
||||
check(love.filesystem.read("imports/saves/red/bad.sav") == "BADSAV",
|
||||
"RES-05: failure leaves .sav in inbox")
|
||||
|
||||
-- Mixed valid/invalid: attempt each; bad retained, good retired
|
||||
ri = freshImporter()
|
||||
importCalls = {}
|
||||
removed = {}
|
||||
love.filesystem.write("imports/saves/red/a-bad.sav", "BAD")
|
||||
love.filesystem.write("imports/saves/red/b-good.sav", "GOOD")
|
||||
importBehavior["imports/saves/red/a-bad.sav"] = { ok = false, err = "bad checksum" }
|
||||
importBehavior["imports/saves/red/b-good.sav"] = { ok = true, id = "slot-b" }
|
||||
ri:rescanSavesAction("red")
|
||||
eq(#importCalls, 2, "mixed inbox attempts each .sav")
|
||||
check(love.filesystem.read("imports/saves/red/a-bad.sav") == "BAD",
|
||||
"mixed: bad .sav retained")
|
||||
check(love.filesystem.getInfo("imports/saves/red/b-good.sav") == nil,
|
||||
"mixed: good .sav retired")
|
||||
check(love.filesystem.read("imports/saves/red/b-good.sav.imported") == "GOOD",
|
||||
"mixed: good bytes kept as .imported")
|
||||
check(ri.saveNotice.red and ri.saveNotice.red.ok, "mixed keeps overall success when one imports")
|
||||
check(ri.saveNotice.red.text:find("failed", 1, true),
|
||||
"mixed success notice still surfaces sibling failure")
|
||||
check(ri.saveNotice.red.text:find("bad checksum", 1, true),
|
||||
"mixed success notice includes the failure reason")
|
||||
|
||||
-- Multi-success notice names count + active slot (not only last ok line)
|
||||
ri = freshImporter()
|
||||
importCalls = {}
|
||||
love.filesystem.write("imports/saves/red/one.sav", "ONE")
|
||||
love.filesystem.write("imports/saves/red/two.sav", "TWO")
|
||||
importBehavior["imports/saves/red/one.sav"] = { ok = true, id = "slot-one" }
|
||||
importBehavior["imports/saves/red/two.sav"] = { ok = true, id = "slot-two" }
|
||||
ri:rescanSavesAction("red")
|
||||
eq(#importCalls, 2, "multi-success imports each distinct .sav")
|
||||
check(ri.saveNotice.red.text:find("Imported 2 saves", 1, true),
|
||||
"multi-success notice reports count")
|
||||
check(ri.saveNotice.red.text:find("Active:", 1, true),
|
||||
"multi-success notice reports active slot")
|
||||
eq(ri.activeSlot.red, "slot-two", "multi-success leaves last import active")
|
||||
|
||||
-- RES-03: Mac MTP AppleDouble (._*.sav) must not be import candidates
|
||||
ri = freshImporter()
|
||||
importCalls = {}
|
||||
love.filesystem.write("imports/saves/red/._cart.sav", "APPL")
|
||||
love.filesystem.write("imports/saves/red/cart.sav", "GOOD")
|
||||
importBehavior["imports/saves/red/cart.sav"] = { ok = true, id = "slot-cart" }
|
||||
ri:rescanSavesAction("red")
|
||||
eq(#importCalls, 1, "RES-03: AppleDouble ._*.sav is skipped")
|
||||
eq(importCalls[1].source, "imports/saves/red/cart.sav",
|
||||
"only the real .sav is imported")
|
||||
check(ri.saveNotice.red and ri.saveNotice.red.ok, "AppleDouble skip still shows import success")
|
||||
check(not (ri.saveNotice.red.text or ""):find("failed", 1, true),
|
||||
"RES-03: AppleDouble-only sibling does not invent a mixed failure line")
|
||||
|
||||
-- RES-06: NX chooseSaveImport must not call HostShell / chooseSav path
|
||||
local hostShellCalls = 0
|
||||
package.loaded["src.core.HostShell"] = {
|
||||
run = function()
|
||||
hostShellCalls = hostShellCalls + 1
|
||||
error("HostShell must not run on NX chooseSaveImport")
|
||||
end,
|
||||
popen = function()
|
||||
hostShellCalls = hostShellCalls + 1
|
||||
error("HostShell.popen must not run on NX chooseSaveImport")
|
||||
end,
|
||||
available = function() return false end,
|
||||
}
|
||||
-- Re-require so chooseSav sees the stubbed HostShell if it were reached.
|
||||
package.loaded["src.import.RomImporter"] = nil
|
||||
RomImporter = require("src.import.RomImporter")
|
||||
ri = freshImporter()
|
||||
hostShellCalls = 0
|
||||
ri:chooseSaveImport("red")
|
||||
eq(hostShellCalls, 0, "RES-06: NX chooseSaveImport does not require HostShell")
|
||||
|
||||
-- NXSAV-05: chooseSaveImport on NX rescans inbox
|
||||
ri = freshImporter()
|
||||
importCalls = {}
|
||||
hostShellCalls = 0
|
||||
love.filesystem.write("imports/saves/red/from-choose.sav", "CHOOSE")
|
||||
importBehavior["imports/saves/red/from-choose.sav"] = { ok = true, id = "slot-choose" }
|
||||
ri:chooseSaveImport("red")
|
||||
eq(hostShellCalls, 0, "NX chooseSaveImport does not use HostShell")
|
||||
eq(#importCalls, 1, "NX chooseSaveImport rescans and imports inbox .sav")
|
||||
eq(importCalls[1].source, "imports/saves/red/from-choose.sav",
|
||||
"NX chooseSaveImport imports from imports/saves/red/")
|
||||
check(ri.saveNotice.red and ri.saveNotice.red.ok, "NX chooseSaveImport success notice")
|
||||
|
||||
-- Empty chooseSaveImport still sets notice (RES-04 via Import save button)
|
||||
ri = freshImporter()
|
||||
importCalls = {}
|
||||
ri:chooseSaveImport("red")
|
||||
eq(#importCalls, 0, "empty NX chooseSaveImport does not import")
|
||||
check(ri.saveNotice.red ~= nil and ri.saveNotice.red.text:find("imports/saves/red/", 1, true),
|
||||
"empty NX chooseSaveImport sets MTP notice")
|
||||
|
||||
-- Edge: ROM not ready → refuse with existing notice (no silent no-op)
|
||||
ri = freshImporter()
|
||||
importCalls = {}
|
||||
removed = {}
|
||||
ri.ready.red = false
|
||||
love.filesystem.write("imports/saves/red/need-rom.sav", "NEEDROM")
|
||||
importBehavior["imports/saves/red/need-rom.sav"] = { ok = true, id = "should-not-import" }
|
||||
ri:chooseSaveImport("red")
|
||||
eq(#importCalls, 0, "ROM-not-ready: chooseSaveImport does not call importToSlot")
|
||||
check(ri.saveNotice.red and not ri.saveNotice.red.ok,
|
||||
"ROM-not-ready: chooseSaveImport sets error notice")
|
||||
check(ri.saveNotice.red.text:find("Import the Pokemon Red ROM before importing a save", 1, true),
|
||||
"ROM-not-ready: notice tells player to import ROM first")
|
||||
check(not removed["imports/saves/red/need-rom.sav"],
|
||||
"ROM-not-ready: retains inbox .sav")
|
||||
check(love.filesystem.read("imports/saves/red/need-rom.sav") == "NEEDROM",
|
||||
"ROM-not-ready: leaves .sav bytes in inbox")
|
||||
|
||||
ri = freshImporter()
|
||||
importCalls = {}
|
||||
ri.ready.red = false
|
||||
love.filesystem.write("imports/saves/red/need-rom2.sav", "NEEDROM2")
|
||||
importBehavior["imports/saves/red/need-rom2.sav"] = { ok = true, id = "should-not" }
|
||||
ri:rescanSavesAction("red")
|
||||
eq(#importCalls, 0, "ROM-not-ready: rescan does not call importToSlot")
|
||||
check(ri.saveNotice.red and not ri.saveNotice.red.ok,
|
||||
"ROM-not-ready: rescan sets error notice")
|
||||
check(ri.saveNotice.red.text:find("Import the Pokemon Red ROM before importing a save", 1, true),
|
||||
"ROM-not-ready: rescan surfaces ROM-first notice")
|
||||
|
||||
-- Edge: workState == "working" → Import/rescan no-op without clearing notice
|
||||
ri = freshImporter()
|
||||
importCalls = {}
|
||||
ri.saveNotice.red = { ok = true, text = "PRESERVE_ME" }
|
||||
ri.workState = "working"
|
||||
love.filesystem.write("imports/saves/red/busy.sav", "BUSY")
|
||||
importBehavior["imports/saves/red/busy.sav"] = { ok = true, id = "slot-busy" }
|
||||
ri:chooseSaveImport("red")
|
||||
eq(#importCalls, 0, "workState working: chooseSaveImport does not import")
|
||||
eq(ri.saveNotice.red.text, "PRESERVE_ME",
|
||||
"workState working: chooseSaveImport leaves saveNotice unchanged")
|
||||
ri:rescanSavesAction("red")
|
||||
eq(#importCalls, 0, "workState working: rescanSavesAction does not import")
|
||||
eq(ri.saveNotice.red.text, "PRESERVE_ME",
|
||||
"workState working: rescanSavesAction leaves saveNotice unchanged")
|
||||
|
||||
-- RES-11 / NXSAV-07: NX default SAVE FILES hint mentions per-game inbox
|
||||
ri = freshImporter()
|
||||
local defaultHint = ri:_savesDefaultHint("red")
|
||||
check(defaultHint:find("imports/saves/red/", 1, true),
|
||||
"RES-11: NX default hint mentions imports/saves/red/")
|
||||
check(defaultHint:find("DBI MTP", 1, true),
|
||||
"RES-11: NX default hint mentions DBI MTP")
|
||||
check(not defaultHint:find("system file picker", 1, true),
|
||||
"RES-11: NX default hint is not desktop picker wording")
|
||||
|
||||
-- Desktop keeps picker-oriented default hint (non-NX)
|
||||
local desk = setmetatable({
|
||||
isNX = false, android = false,
|
||||
_savesDefaultHint = RomImporter._savesDefaultHint,
|
||||
}, RomImporter)
|
||||
check(desk:_savesDefaultHint():find("new slot", 1, true),
|
||||
"desktop default save hint stays picker/drop-oriented")
|
||||
|
||||
-- RES-09 / NXSAV-08/09: NX exportSave success notice + no openURL / no dir
|
||||
local exportCalls = {}
|
||||
local openURLCalls = 0
|
||||
love.system.openURL = function()
|
||||
openURLCalls = openURLCalls + 1
|
||||
error("openURL must not run on NX exportSave")
|
||||
end
|
||||
package.loaded["src.import.SaveFileIO"] = {
|
||||
importToSlot = function(source, version)
|
||||
importCalls[#importCalls + 1] = { source = source, version = version }
|
||||
local b = importBehavior[source]
|
||||
if not b then return false, "unexpected source: " .. tostring(source) end
|
||||
if b.ok then return true, b.id or "slot-1" end
|
||||
return false, b.err or "bad sav"
|
||||
end,
|
||||
exportActiveSlot = function(version)
|
||||
exportCalls[#exportCalls + 1] = version
|
||||
return true, "sdmc:/switch/gen1recomp/pokemon-love2d/exports/red/gen1recomp-red-slot-1.sav"
|
||||
end,
|
||||
}
|
||||
ri = freshImporter()
|
||||
exportCalls = {}
|
||||
openURLCalls = 0
|
||||
ri:exportSave("red")
|
||||
eq(#exportCalls, 1, "NXSAV-08: exportSave calls exportActiveSlot")
|
||||
eq(exportCalls[1], "red", "exportSave passes panel version")
|
||||
check(ri.saveNotice.red and ri.saveNotice.red.ok, "NXSAV-09: export success sets ok notice")
|
||||
check(ri.saveNotice.red.text:find("exports", 1, true),
|
||||
"RES-09: export notice mentions exports path")
|
||||
check(ri.saveNotice.red.text:find("DBI MTP", 1, true),
|
||||
"RES-09: export notice mentions MTP hint")
|
||||
check(ri.saveNotice.red.dir == nil,
|
||||
"RES-09: NX export does not set open-folder dir")
|
||||
eq(openURLCalls, 0, "RES-09: NX exportSave does not call openURL")
|
||||
|
||||
-- Export failure still sets notice (not silent)
|
||||
package.loaded["src.import.SaveFileIO"] = {
|
||||
importToSlot = function() return false, "unused" end,
|
||||
exportActiveSlot = function() return false, "No save in the active slot." end,
|
||||
}
|
||||
ri = freshImporter()
|
||||
ri:exportSave("red")
|
||||
check(ri.saveNotice.red and not ri.saveNotice.red.ok,
|
||||
"export failure sets clear error notice")
|
||||
check(ri.saveNotice.red.text:find("No save", 1, true),
|
||||
"export failure notice includes reason")
|
||||
|
||||
-- Edge: workState == "working" → exportSave no-op without clearing notice
|
||||
package.loaded["src.import.SaveFileIO"] = {
|
||||
importToSlot = function() return false, "unused" end,
|
||||
exportActiveSlot = function(version)
|
||||
exportCalls[#exportCalls + 1] = version
|
||||
return true, "exports/should-not-export.sav"
|
||||
end,
|
||||
}
|
||||
ri = freshImporter()
|
||||
exportCalls = {}
|
||||
ri.saveNotice.red = { ok = true, text = "PRESERVE_EXPORT" }
|
||||
ri.workState = "working"
|
||||
ri:exportSave("red")
|
||||
eq(#exportCalls, 0, "workState working: exportSave does not call exportActiveSlot")
|
||||
eq(ri.saveNotice.red.text, "PRESERVE_EXPORT",
|
||||
"workState working: exportSave leaves saveNotice unchanged")
|
||||
|
||||
-- Cleanup + restore stubs
|
||||
clearSavesInbox()
|
||||
love.filesystem.remove("imports/other.sav")
|
||||
package.loaded["src.import.SaveFileIO"] = nil
|
||||
package.loaded["src.core.HostShell"] = nil
|
||||
love.data.hash = saved.hash
|
||||
love.data.encode = saved.encode
|
||||
love.system.getOS = saved.getOS
|
||||
love.system.openURL = nil
|
||||
love.filesystem.getSaveDirectory = saved.getSaveDirectory
|
||||
love.filesystem.createDirectory = saved.createDirectory
|
||||
love.filesystem.remove = saved.remove
|
||||
package.loaded["src.core.Platform"] = nil
|
||||
package.loaded["src.import.RomImporter"] = nil
|
||||
|
||||
S.finish()
|
||||
@@ -148,11 +148,11 @@ do
|
||||
|
||||
local ok, path = SaveFileIO.exportActiveSlot("red")
|
||||
eq(ok, true, "exportActiveSlot succeeds for an active slot with a save")
|
||||
eq(path, "/fake/save/exports/gen1recomp-red-slot1.sav",
|
||||
"the export path is absolute and names the version + slot")
|
||||
eq(path, "/fake/save/exports/red/gen1recomp-red-slot1.sav",
|
||||
"the export path is absolute under exports/<version>/")
|
||||
|
||||
local outBytes = files["exports/gen1recomp-red-slot1.sav"]
|
||||
check(outBytes ~= nil, "the export file lands in the save-dir exports/ folder")
|
||||
local outBytes = files["exports/red/gen1recomp-red-slot1.sav"]
|
||||
check(outBytes ~= nil, "the export file lands in the per-game exports/ folder")
|
||||
eq(outBytes and #outBytes, GenSave.SAVE_SIZE, "the export is exactly 32768 bytes")
|
||||
check(outBytes and mainChecksumValid(outBytes),
|
||||
"the export carries a valid main-data checksum")
|
||||
@@ -281,7 +281,7 @@ do
|
||||
if not SaveFileIO.importToSlot(GenSave.encode(seed, data, nil), "red") then return nil end
|
||||
if not SaveData.load("red") then return nil end
|
||||
if not SaveFileIO.exportActiveSlot("red") then return nil end
|
||||
return files["exports/gen1recomp-red-slot1.sav"]
|
||||
return files["exports/red/gen1recomp-red-slot1.sav"]
|
||||
end
|
||||
|
||||
local function assertLoadable(label, mapId, x, y)
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
-- Opt-in Switch input diagnostics (SWNX-13/28): marker file, ring buffer, flush cap.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check = T.check
|
||||
local eq = T.eq
|
||||
|
||||
local SwitchDiagnostics = require("src.debug.SwitchDiagnostics")
|
||||
|
||||
local function reset()
|
||||
SwitchDiagnostics._resetForTests()
|
||||
love.filesystem.remove("switch-debug.txt")
|
||||
love.filesystem.remove("switch.log")
|
||||
end
|
||||
|
||||
reset()
|
||||
check(not SwitchDiagnostics.isEnabled(), "disabled without marker file")
|
||||
|
||||
love.filesystem.write("switch-debug.txt", "")
|
||||
reset()
|
||||
love.filesystem.write("switch-debug.txt", "")
|
||||
check(SwitchDiagnostics.isEnabled(), "enabled when marker exists")
|
||||
|
||||
SwitchDiagnostics.onEvent("probe", { kind = "gamepadpressed", button = "a" })
|
||||
SwitchDiagnostics.maybeFlush(true, 0)
|
||||
local log = love.filesystem.read("switch.log") or ""
|
||||
check(log:find("gamepadpressed", 1, true) ~= nil, "flush writes buffered events")
|
||||
check(log:find("gitCommit=", 1, true) ~= nil, "identity includes gitCommit field")
|
||||
check(log:find("loveNxTag=", 1, true) ~= nil, "identity includes loveNxTag field")
|
||||
|
||||
-- ROM-like byte sequences must never appear in diagnostics output.
|
||||
local romSnippet = string.char(0xEA, 0x9B, 0xCA, 0xE6)
|
||||
SwitchDiagnostics.onEvent("probe", { sample = romSnippet, note = "redacted" })
|
||||
SwitchDiagnostics.maybeFlush(true, 1)
|
||||
log = love.filesystem.read("switch.log") or ""
|
||||
check(not log:find(romSnippet, 1, true),
|
||||
"ROM bytes are stripped from diagnostic payloads")
|
||||
|
||||
-- Flush rate capped at 1 Hz unless forced.
|
||||
reset()
|
||||
love.filesystem.write("switch-debug.txt", "")
|
||||
SwitchDiagnostics.onEvent("tick", { n = 1 })
|
||||
SwitchDiagnostics.maybeFlush(true, 0.0)
|
||||
SwitchDiagnostics.onEvent("tick", { n = 2 })
|
||||
SwitchDiagnostics.maybeFlush(false, 0.5)
|
||||
local logMid = love.filesystem.read("switch.log") or ""
|
||||
SwitchDiagnostics.maybeFlush(false, 1.0)
|
||||
local logLate = love.filesystem.read("switch.log") or ""
|
||||
check(not logMid:find("n=2", 1, true), "flush waits until 1s elapsed")
|
||||
check(logLate:find("n=2", 1, true) ~= nil, "flush includes events after 1s")
|
||||
|
||||
-- Lua error log: redacted, no ROM bytes.
|
||||
local romErr = string.char(0xEA, 0x9B, 0xCA, 0xE6)
|
||||
local hint = SwitchDiagnostics.logLuaError("probe failure")
|
||||
check(type(hint) == "string" and hint:find("lua-error.log", 1, true) ~= nil,
|
||||
"error handler hint mentions lua-error.log")
|
||||
SwitchDiagnostics.logLuaError(romErr)
|
||||
local errLog = love.filesystem.read("lua-error.log") or ""
|
||||
check(errLog:find("probe failure", 1, true) ~= nil, "lua-error.log records message")
|
||||
check(errLog:find("<redacted>", 1, true) ~= nil, "lua-error.log strips ROM bytes")
|
||||
check(not errLog:find(romErr, 1, true), "lua-error.log omits raw ROM bytes")
|
||||
|
||||
-- Stack-trace style messages (newlines) must remain readable — not wholesale
|
||||
-- "<redacted>" (fused Play triage regression).
|
||||
SwitchDiagnostics.logLuaError("missing module 'data/generated/maps.lua'.\nImport again.\n(detail)")
|
||||
errLog = love.filesystem.read("lua-error.log") or ""
|
||||
check(errLog:find("missing module", 1, true) ~= nil,
|
||||
"lua-error.log keeps printable multiline error text")
|
||||
check(errLog:find("Import again", 1, true) ~= nil,
|
||||
"lua-error.log preserves lines after newline")
|
||||
|
||||
-- NX asset probe: always writes nx-asset-probe.log on Play (Switch only).
|
||||
local Platform = require("src.core.Platform")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local savedSystem = love.system
|
||||
love.system = { getOS = function() return "NX" end }
|
||||
Platform._resetForTests()
|
||||
GameVersion.set("yellow")
|
||||
love.filesystem.write("yellow/assets/generated/fonts/font.png", "font-bytes")
|
||||
love.filesystem.write("yellow/assets/generated/tilesets/reds_house.png", "house-bytes")
|
||||
love.filesystem.write("yellow/assets/generated/sprites/red.png", "red-bytes")
|
||||
SwitchDiagnostics.probeAssets("yellow")
|
||||
local probe = love.filesystem.read("nx-asset-probe.log") or ""
|
||||
check(probe:find("probe=nx-asset", 1, true) ~= nil, "probe log writes header")
|
||||
check(probe:find("cachePrefix=yellow/", 1, true) ~= nil, "probe records yellow prefix")
|
||||
check(probe:find("resolve=yellow/assets/generated/fonts/font.png", 1, true) ~= nil
|
||||
or probe:find("versioned=type=file", 1, true) ~= nil,
|
||||
"probe records versioned font path visibility")
|
||||
check(not probe:find(string.char(0xEA, 0x9B), 1, true),
|
||||
"probe log contains no ROM-like binary")
|
||||
|
||||
love.system = { getOS = function() return "OS X" end }
|
||||
Platform._resetForTests()
|
||||
love.filesystem.remove("nx-asset-probe.log")
|
||||
SwitchDiagnostics.probeAssets("yellow")
|
||||
check(love.filesystem.read("nx-asset-probe.log") == nil,
|
||||
"probe is a no-op off NX")
|
||||
|
||||
love.system = savedSystem
|
||||
Platform._resetForTests()
|
||||
GameVersion.set("red")
|
||||
love.filesystem.remove("nx-asset-probe.log")
|
||||
love.filesystem.remove("yellow/assets/generated/fonts/font.png")
|
||||
love.filesystem.remove("yellow/assets/generated/tilesets/reds_house.png")
|
||||
love.filesystem.remove("yellow/assets/generated/sprites/red.png")
|
||||
|
||||
T.finish()
|
||||
@@ -0,0 +1,85 @@
|
||||
-- Touch-controls editor pad / Joy-Con cursor (same class as save-editor soft-lock).
|
||||
-- Opening Touch Controls parked the launcher cursor and dropped all gamepad
|
||||
-- input; touch still worked. PadCursor + main.lua forwarding restore the
|
||||
-- virtual pointer.
|
||||
-- luajit tests/engine/touch_controls_pad_cursor_test.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
|
||||
local PadCursor = require("src.ui.PadCursor")
|
||||
local GamepadMap = require("src.core.GamepadMap")
|
||||
|
||||
PadCursor.reset()
|
||||
PadCursor.gamepadaxis(nil, "leftx", 1)
|
||||
PadCursor.update(0.05)
|
||||
check(PadCursor.isActive(), "left stick activates pad cursor")
|
||||
local x0 = select(1, PadCursor.pointer())
|
||||
PadCursor.update(0.05)
|
||||
check(select(1, PadCursor.pointer()) > x0, "left stick moves cursor right")
|
||||
|
||||
eq(PadCursor.gamepadpressed(nil, "a"), "a", "gamepad a → click")
|
||||
eq(PadCursor.gamepadpressed(nil, "b"), "b", "gamepad b → close")
|
||||
eq(PadCursor.gamepadpressed(nil, "leftshoulder"), "tab_prev", "L → size down")
|
||||
eq(PadCursor.gamepadpressed(nil, "rightshoulder"), "tab_next", "R → size up")
|
||||
|
||||
GamepadMap._setForceNXForTests(true)
|
||||
eq(PadCursor.gamepadpressed(nil, "a"), "b", "NX SDL a (south) → close (GB b)")
|
||||
eq(PadCursor.gamepadpressed(nil, "b"), "a", "NX SDL b (east) → click (GB a)")
|
||||
GamepadMap._setForceNXForTests(false)
|
||||
|
||||
PadCursor.yieldToPointer()
|
||||
check(not PadCursor.isActive(), "yieldToPointer drops the virtual cursor")
|
||||
|
||||
-- Compat: tools/save-editor/PadInput.lua re-exports the shared module.
|
||||
package.path = package.path .. ";./tools/save-editor/?.lua"
|
||||
local PadInput = require("PadInput")
|
||||
eq(PadInput, PadCursor, "PadInput shim is PadCursor")
|
||||
|
||||
local function read(path)
|
||||
local f = assert(io.open(path, "r"))
|
||||
local src = f:read("*a")
|
||||
f:close()
|
||||
return src
|
||||
end
|
||||
|
||||
local mainSrc = read("main.lua")
|
||||
check(mainSrc:find("prepareOverlayHandoff", 1, true) ~= nil
|
||||
and mainSrc:find("openTouchControlsEditor", 1, true) ~= nil,
|
||||
"main.lua mentions prepareOverlayHandoff + touch editor")
|
||||
-- prepare must run inside openTouchControlsEditor, not only openEditor
|
||||
local touchOpen = mainSrc:match("local function openTouchControlsEditor%(%)(.-)\nend")
|
||||
check(touchOpen ~= nil, "openTouchControlsEditor body found")
|
||||
check(touchOpen:find("prepareOverlayHandoff", 1, true) ~= nil,
|
||||
"openTouchControlsEditor prepares overlay handoff like the save editor")
|
||||
local touchClose = mainSrc:match("function closeTouchControlsEditor%(%)(.-)\nend")
|
||||
check(touchClose ~= nil, "closeTouchControlsEditor body found")
|
||||
check(touchClose:find("resumeAfterOverlay", 1, true) ~= nil,
|
||||
"closeTouchControlsEditor resumes the launcher pad cursor")
|
||||
check(mainSrc:find("TouchEditor.gamepadpressed", 1, true) ~= nil,
|
||||
"main.lua forwards gamepadpressed to TouchEditor")
|
||||
check(mainSrc:find("TouchEditor.gamepadaxis", 1, true) ~= nil,
|
||||
"main.lua forwards gamepadaxis to TouchEditor")
|
||||
|
||||
local edSrc = read("src/ui/TouchControlsEditor.lua")
|
||||
check(edSrc:find('require("src.ui.PadCursor")', 1, true) ~= nil,
|
||||
"TouchControlsEditor loads PadCursor")
|
||||
check(edSrc:find("function Editor.gamepadpressed", 1, true) ~= nil,
|
||||
"TouchControlsEditor exposes gamepadpressed")
|
||||
check(edSrc:find("PadCursor.draw()", 1, true) ~= nil,
|
||||
"TouchControlsEditor draws the pad cursor")
|
||||
check(edSrc:find("PadCursor.yieldToPointer()", 1, true) ~= nil,
|
||||
"touch/mouse yields the pad so taps use event coords")
|
||||
check(edSrc:find('beginDrag("pad"', 1, true) ~= nil,
|
||||
"A begins a pad drag at the virtual cursor")
|
||||
check(edSrc:find('endDrag("pad")', 1, true) ~= nil,
|
||||
"A release ends a pad drag")
|
||||
|
||||
local packSrc = read("scripts/pack_love.sh")
|
||||
check(packSrc:find("tools/save-editor/PadInput.lua", 1, true) ~= nil,
|
||||
"pack still requires PadInput path (compat shim)")
|
||||
|
||||
T.finish("touch_controls_pad_cursor")
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
-- LauncherMods.installZip: PK gate, FileData mount preference, path fallback.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local S = require("tests.harness").suite("launcher mods installZip mount")
|
||||
local eq = S.eq
|
||||
local check = S.check
|
||||
|
||||
local MOD_ID = "mount_probe"
|
||||
local ARCHIVE = {
|
||||
[MOD_ID .. "/manifest.json"] =
|
||||
('{"id":"%s","name":"Mount Probe","version":"1.0.0","entry":"main.lua"}')
|
||||
:format(MOD_ID),
|
||||
[MOD_ID .. "/main.lua"] = "return function() end\n",
|
||||
}
|
||||
|
||||
local files, dirs, arch = {}, {}, {}
|
||||
local fileDataMounts, pathMounts, stagedTemps = 0, 0, {}
|
||||
local stagedEver = false
|
||||
|
||||
local function resetFs()
|
||||
for k in pairs(files) do files[k] = nil end
|
||||
for k in pairs(dirs) do dirs[k] = nil end
|
||||
for k in pairs(arch) do arch[k] = nil end
|
||||
fileDataMounts, pathMounts = 0, 0
|
||||
stagedTemps = {}
|
||||
stagedEver = false
|
||||
end
|
||||
|
||||
local function dirChild(key, name)
|
||||
if name == nil or name == "" then return key:match("^[^/]+") end
|
||||
local prefix = name .. "/"
|
||||
if key:sub(1, #prefix) ~= prefix then return nil end
|
||||
return key:sub(#prefix + 1):match("^[^/]+")
|
||||
end
|
||||
|
||||
local function mapInfo(map, name, kind)
|
||||
if map[name] ~= nil then return { type = kind or "file" } end
|
||||
for key in pairs(map) do
|
||||
if dirChild(key, name) then return { type = "directory" } end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local vfs = {}
|
||||
|
||||
function vfs.write(name, data)
|
||||
files[name] = data
|
||||
if name:match("^mod_import_") then
|
||||
stagedTemps[name] = true
|
||||
stagedEver = true
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
function vfs.read(name)
|
||||
if arch[name] ~= nil then return arch[name] end
|
||||
return files[name]
|
||||
end
|
||||
|
||||
function vfs.remove(name)
|
||||
files[name] = nil
|
||||
dirs[name] = nil
|
||||
stagedTemps[name] = nil
|
||||
return true
|
||||
end
|
||||
|
||||
function vfs.createDirectory(name)
|
||||
dirs[name] = true
|
||||
return true
|
||||
end
|
||||
|
||||
function vfs.getInfo(name, kind)
|
||||
local info = mapInfo(arch, name)
|
||||
or mapInfo(files, name)
|
||||
or mapInfo(dirs, name, "directory")
|
||||
if info and kind and info.type ~= kind then return nil end
|
||||
return info
|
||||
end
|
||||
|
||||
function vfs.getDirectoryItems(name)
|
||||
local seen, items = {}, {}
|
||||
local function add(child)
|
||||
if child and not seen[child] then
|
||||
seen[child] = true
|
||||
items[#items + 1] = child
|
||||
end
|
||||
end
|
||||
for key in pairs(arch) do add(dirChild(key, name)) end
|
||||
for key in pairs(files) do add(dirChild(key, name)) end
|
||||
for key in pairs(dirs) do add(dirChild(key, name)) end
|
||||
table.sort(items)
|
||||
return items
|
||||
end
|
||||
|
||||
function vfs.mount(archive, point)
|
||||
if type(archive) == "table" and archive.__filedata then
|
||||
fileDataMounts = fileDataMounts + 1
|
||||
else
|
||||
pathMounts = pathMounts + 1
|
||||
end
|
||||
for rel, body in pairs(ARCHIVE) do
|
||||
arch[point .. "/" .. rel] = body
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
function vfs.unmount()
|
||||
for k in pairs(arch) do arch[k] = nil end
|
||||
return true
|
||||
end
|
||||
|
||||
function vfs.newFileData(data, name)
|
||||
return { __filedata = true, data = data, name = name }
|
||||
end
|
||||
|
||||
function vfs.getSaveDirectory()
|
||||
return "/tmp/pokeport-install-zip-test"
|
||||
end
|
||||
|
||||
function vfs.getSource()
|
||||
return nil
|
||||
end
|
||||
|
||||
local savedFs = love.filesystem
|
||||
local savedCacheFs = package.loaded["src.import.CacheFs"]
|
||||
local savedLauncherMods = package.loaded["src.mods.LauncherMods"]
|
||||
local savedSaveDataPortable = nil
|
||||
|
||||
local SaveData = require("src.core.SaveData")
|
||||
savedSaveDataPortable = SaveData.portableBaseDir
|
||||
|
||||
local function freshMods()
|
||||
package.loaded["src.import.CacheFs"] = nil
|
||||
package.loaded["src.mods.LauncherMods"] = nil
|
||||
SaveData.portableBaseDir = function() return nil end
|
||||
return require("src.mods.LauncherMods")
|
||||
end
|
||||
|
||||
love.filesystem = vfs
|
||||
local LauncherMods = freshMods()
|
||||
|
||||
-- Reject non-PK / empty / AppleDouble-shaped bytes before mount
|
||||
resetFs()
|
||||
files["imports/mods/junk.zip"] = "\0\5\22\7AppleDouble"
|
||||
local ok, err = LauncherMods.installZip("imports/mods/junk.zip")
|
||||
check(not ok, "non-PK bytes are rejected")
|
||||
check(tostring(err):find("not a zip file", 1, true),
|
||||
"rejection names not a zip file")
|
||||
eq(fileDataMounts + pathMounts, 0, "invalid zip never mounts")
|
||||
|
||||
resetFs()
|
||||
files["imports/mods/empty.zip"] = ""
|
||||
ok, err = LauncherMods.installZip("imports/mods/empty.zip")
|
||||
check(not ok, "empty file is rejected")
|
||||
check(tostring(err):find("not a zip file", 1, true),
|
||||
"empty rejection is not a zip file")
|
||||
|
||||
-- Prefer FileData / in-memory mount for relative save-dir zips
|
||||
resetFs()
|
||||
files["imports/mods/good.zip"] = "PK\3\4relative-inbox"
|
||||
ok, err = LauncherMods.installZip("imports/mods/good.zip")
|
||||
check(ok == true, "PK zip installs via relative love.filesystem path ("
|
||||
.. tostring(err) .. ")")
|
||||
eq(err, MOD_ID, "install reports manifest id")
|
||||
eq(fileDataMounts, 1, "relative zip prefers FileData mount")
|
||||
eq(pathMounts, 0, "relative zip does not fall back to path mount when FileData works")
|
||||
local staged = 0
|
||||
for _ in pairs(stagedTemps) do staged = staged + 1 end
|
||||
eq(staged, 0, "FileData path leaves no staged temp zip")
|
||||
check(files["mods/" .. MOD_ID .. "/manifest.json"] ~= nil,
|
||||
"install wrote manifest into mods/")
|
||||
|
||||
-- Fallback: no newFileData → stage temp + path mount
|
||||
resetFs()
|
||||
vfs.newFileData = nil
|
||||
package.loaded["src.mods.LauncherMods"] = nil
|
||||
package.loaded["src.import.CacheFs"] = nil
|
||||
LauncherMods = freshMods()
|
||||
files["imports/mods/fallback.zip"] = "PK\3\4fallback"
|
||||
ok, err = LauncherMods.installZip("imports/mods/fallback.zip")
|
||||
check(ok == true, "install still works without newFileData ("
|
||||
.. tostring(err) .. ")")
|
||||
eq(fileDataMounts, 0, "no FileData mounts when API absent")
|
||||
eq(pathMounts, 1, "falls back to path mount")
|
||||
check(stagedEver, "fallback stages a mod_import_*.zip temp")
|
||||
local leftover = 0
|
||||
for _ in pairs(stagedTemps) do leftover = leftover + 1 end
|
||||
eq(leftover, 0, "fallback cleans staged temp after install")
|
||||
|
||||
-- Restore
|
||||
love.filesystem = savedFs
|
||||
SaveData.portableBaseDir = savedSaveDataPortable
|
||||
package.loaded["src.import.CacheFs"] = savedCacheFs
|
||||
package.loaded["src.mods.LauncherMods"] = savedLauncherMods
|
||||
|
||||
S.finish()
|
||||
+172
-38
@@ -11,6 +11,9 @@ Image.__index = Image
|
||||
function Image:getDimensions() return self.w, self.h end
|
||||
function Image:getWidth() return self.w end
|
||||
function Image:getHeight() return self.h end
|
||||
-- IntroMovie sets the studio logo filter unconditionally on load
|
||||
function Image:setFilter(min, mag) self.minFilter, self.magFilter = min, mag end
|
||||
function Image:getFilter() return self.minFilter or "nearest", self.magFilter or "nearest" end
|
||||
|
||||
-- read PNG dimensions from the file header (no decoder needed)
|
||||
local function pngSize(path)
|
||||
@@ -40,8 +43,15 @@ local gstate = { shader = nil, canvas = nil, blend = "alpha",
|
||||
local gstack = {}
|
||||
|
||||
stub.graphics = {
|
||||
newImage = function(path)
|
||||
local w, h = pngSize(path)
|
||||
newImage = function(pathOrData)
|
||||
local path = pathOrData
|
||||
if type(pathOrData) == "table" and pathOrData._fileData then
|
||||
path = pathOrData.name or "<filedata>"
|
||||
elseif type(pathOrData) == "table" and pathOrData.path then
|
||||
path = pathOrData.path
|
||||
end
|
||||
local w, h = 8, 8
|
||||
if type(path) == "string" then w, h = pngSize(path) end
|
||||
return setmetatable({ w = w, h = h, path = path }, Image)
|
||||
end,
|
||||
newQuad = function(x, y, w, h) return { x = x, y = y, w = w, h = h } end,
|
||||
@@ -118,49 +128,130 @@ stub.math = {
|
||||
|
||||
stub.filesystem = {
|
||||
write = function(name, content) files[name] = content return true end,
|
||||
read = function(name) return files[name] end,
|
||||
remove = function(name) files[name] = nil return true end,
|
||||
-- directories are implied by key prefixes ("mods/x/manifest.json")
|
||||
getInfo = function(name)
|
||||
if files[name] then return { type = "file" } end
|
||||
newFileData = function(contents, name)
|
||||
return { _fileData = true, contents = contents, name = name or "" }
|
||||
end,
|
||||
createDirectory = function() return true end,
|
||||
-- Record mounts for CacheFs.mountVersion tests (NX Blue/Yellow overlay).
|
||||
_mounts = {},
|
||||
mount = function(archive, mountpoint, appendToPath)
|
||||
stub.filesystem._mounts[#stub.filesystem._mounts + 1] = {
|
||||
archive = archive, mountpoint = mountpoint or "",
|
||||
append = appendToPath and true or false,
|
||||
}
|
||||
return true
|
||||
end,
|
||||
unmount = function(archive)
|
||||
local mounts = stub.filesystem._mounts
|
||||
for i = #mounts, 1, -1 do
|
||||
if mounts[i].archive == archive then
|
||||
table.remove(mounts, i)
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end,
|
||||
getSaveDirectory = function() return "/tmp/pokeport-stub-save" end,
|
||||
isFused = function() return false end,
|
||||
}
|
||||
|
||||
-- Resolve a PhysFS path through recorded mounts (prepend first, newest wins).
|
||||
local function resolveViaMounts(name)
|
||||
local mounts = stub.filesystem._mounts
|
||||
for i = #mounts, 1, -1 do
|
||||
local m = mounts[i]
|
||||
if not m.append then
|
||||
local mp = m.mountpoint or ""
|
||||
local key
|
||||
if mp == "" then
|
||||
key = m.archive .. "/" .. name
|
||||
elseif name == mp then
|
||||
key = m.archive
|
||||
elseif name:sub(1, #mp + 1) == mp .. "/" then
|
||||
local rel = name:sub(#mp + 2)
|
||||
key = m.archive .. "/" .. rel
|
||||
end
|
||||
if key then
|
||||
if files[key] then return key, "file" end
|
||||
local prefix = key .. "/"
|
||||
for k in pairs(files) do
|
||||
if k:sub(1, #prefix) == prefix then return key, "directory" end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function stub.filesystem.read(name)
|
||||
if files[name] then return files[name] end
|
||||
local key = resolveViaMounts(name)
|
||||
if key and files[key] then return files[key] end
|
||||
return nil
|
||||
end
|
||||
|
||||
function stub.filesystem.getInfo(name, filter)
|
||||
if files[name] then
|
||||
if filter and filter ~= "file" then return nil end
|
||||
return { type = "file" }
|
||||
end
|
||||
local prefix = name .. "/"
|
||||
for key in pairs(files) do
|
||||
if key:sub(1, #prefix) == prefix then
|
||||
if filter and filter ~= "directory" then return nil end
|
||||
return { type = "directory" }
|
||||
end
|
||||
end
|
||||
local key, kind = resolveViaMounts(name)
|
||||
if key and kind then
|
||||
if filter and filter ~= kind then return nil end
|
||||
return { type = kind }
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
stub.filesystem.load = function(name)
|
||||
local data = stub.filesystem.read(name)
|
||||
if not data then return nil, "no file" end
|
||||
return load(data, name)
|
||||
end
|
||||
|
||||
stub.filesystem.getDirectoryItems = function(name)
|
||||
local seen, items = {}, {}
|
||||
name = name or ""
|
||||
local function addChild(child)
|
||||
if child and not seen[child] then
|
||||
seen[child] = true
|
||||
items[#items + 1] = child
|
||||
end
|
||||
end
|
||||
-- "" / "/" = save-dir root (RomImporter Android ROM scan)
|
||||
if name == "" or name == "/" then
|
||||
for key in pairs(files) do
|
||||
addChild(key:match("^[^/]+"))
|
||||
end
|
||||
else
|
||||
local prefix = name .. "/"
|
||||
for key in pairs(files) do
|
||||
if key:sub(1, #prefix) == prefix then return { type = "directory" } end
|
||||
end
|
||||
return nil
|
||||
end,
|
||||
load = function(name)
|
||||
if not files[name] then return nil, "no file" end
|
||||
return load(files[name], name)
|
||||
end,
|
||||
getDirectoryItems = function(name)
|
||||
local seen, items = {}, {}
|
||||
name = name or ""
|
||||
-- "" / "/" = save-dir root (RomImporter Android ROM scan)
|
||||
if name == "" or name == "/" then
|
||||
for key in pairs(files) do
|
||||
local child = key:match("^[^/]+")
|
||||
if child and not seen[child] then
|
||||
seen[child] = true
|
||||
items[#items + 1] = child
|
||||
end
|
||||
if key:sub(1, #prefix) == prefix then
|
||||
addChild(key:sub(#prefix + 1):match("^[^/]+"))
|
||||
end
|
||||
else
|
||||
local prefix = name .. "/"
|
||||
for key in pairs(files) do
|
||||
if key:sub(1, #prefix) == prefix then
|
||||
local child = key:sub(#prefix + 1):match("^[^/]+")
|
||||
if child and not seen[child] then
|
||||
seen[child] = true
|
||||
items[#items + 1] = child
|
||||
end
|
||||
end
|
||||
-- Also surface children exposed via mounts.
|
||||
local key = resolveViaMounts(name)
|
||||
if key then
|
||||
local mprefix = key .. "/"
|
||||
for k in pairs(files) do
|
||||
if k:sub(1, #mprefix) == mprefix then
|
||||
addChild(k:sub(#mprefix + 1):match("^[^/]+"))
|
||||
end
|
||||
end
|
||||
end
|
||||
table.sort(items)
|
||||
return items
|
||||
end,
|
||||
}
|
||||
end
|
||||
table.sort(items)
|
||||
return items
|
||||
end
|
||||
|
||||
-- table-backed SoundData so ChipAudio's offline render seam
|
||||
-- (_renderMusicForTest) runs headless; modkit bounce writes WAVs from it
|
||||
@@ -187,6 +278,25 @@ function SoundData:getDuration() return self.samples / self.rate end
|
||||
|
||||
stub.sound = {
|
||||
newSoundData = function(samples, rate, bits, channels)
|
||||
-- Path form (love.sound.newSoundData(filename)): only succeed when the
|
||||
-- stub FS has the file, matching real LÖVE. Synthesize a short mono
|
||||
-- 8-bit buffer so Sound.widenMono can run headless for seeded paths
|
||||
-- (pika cries); missing files must error so widenMono keeps the
|
||||
-- original Source (give_item_jingle identity checks, etc.).
|
||||
if type(samples) == "string" then
|
||||
if not stub.filesystem.getInfo(samples) then
|
||||
error("Could not open file " .. samples .. ". Does not exist.")
|
||||
end
|
||||
local n = 32
|
||||
local sd = setmetatable({
|
||||
samples = n, rate = rate or 22050, bits = bits or 8,
|
||||
channels = channels or 1, data = {}, path = samples,
|
||||
}, SoundData)
|
||||
for i = 0, n - 1 do
|
||||
sd:setSample(i, (i % 2 == 0) and 0.5 or -0.5)
|
||||
end
|
||||
return sd
|
||||
end
|
||||
return setmetatable({ samples = samples, rate = rate or 44100,
|
||||
bits = bits or 16, channels = channels or 1, data = {} }, SoundData)
|
||||
end,
|
||||
@@ -203,6 +313,30 @@ stub.mouse = {
|
||||
|
||||
stub.timer = { getTime = function() return 0 end }
|
||||
|
||||
-- Minimal image module so Assets.imageData can decode FileData fallbacks
|
||||
-- headless (full pixel stubs live in tests/mod_graphics_tests.lua).
|
||||
local ImageData = {}
|
||||
ImageData.__index = ImageData
|
||||
function ImageData:getWidth() return self.w end
|
||||
function ImageData:getHeight() return self.h end
|
||||
function ImageData:getDimensions() return self.w, self.h end
|
||||
function ImageData:getPixel() return 0, 0, 0, 1 end
|
||||
function ImageData:setPixel() end
|
||||
function ImageData:mapPixel() end
|
||||
function ImageData:encode() return { getString = function() return "" end } end
|
||||
|
||||
stub.image = {
|
||||
newImageData = function(a, b)
|
||||
if type(a) == "table" and a._fileData then
|
||||
return setmetatable({ w = 8, h = 8, path = a.name, source = a }, ImageData)
|
||||
end
|
||||
if type(a) == "string" then
|
||||
return setmetatable({ w = 8, h = 8, path = a }, ImageData)
|
||||
end
|
||||
return setmetatable({ w = a or 8, h = b or 8 }, ImageData)
|
||||
end,
|
||||
}
|
||||
|
||||
-- Desktop / headless: full-window safe area (matches LÖVE's fallback).
|
||||
stub.window = {
|
||||
getSafeArea = function()
|
||||
|
||||
@@ -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, " ")
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -0,0 +1,99 @@
|
||||
-- Content gate for Switch transfer runbooks (XFER-01..08).
|
||||
-- Self-contained: luajit tests/switch_transfer_docs_test.lua
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check = T.check
|
||||
|
||||
local function read(path)
|
||||
local f, err = io.open(path, "r")
|
||||
if not f then error("cannot read " .. path .. ": " .. tostring(err)) end
|
||||
local s = f:read("*a")
|
||||
f:close()
|
||||
return s
|
||||
end
|
||||
|
||||
local function mustContain(body, needle, label)
|
||||
check(body:find(needle, 1, true) ~= nil,
|
||||
label .. " must contain " .. string.format("%q", needle))
|
||||
end
|
||||
|
||||
local function mustNotContain(body, needle, label)
|
||||
check(body:find(needle, 1, true) == nil,
|
||||
label .. " must not contain " .. string.format("%q", needle))
|
||||
end
|
||||
|
||||
local transfer = read("docs/switch-transfer.md")
|
||||
|
||||
mustContain(transfer, "MTP", "transfer")
|
||||
mustContain(transfer, "Hekate UMS", "transfer")
|
||||
mustContain(transfer, "FTP", "transfer")
|
||||
mustContain(transfer, "sdmc:/switch/gen1recomp/", "transfer")
|
||||
mustContain(transfer, "imports/", "transfer")
|
||||
mustContain(transfer, "imports/mods/", "transfer")
|
||||
mustContain(transfer, "1: SD Card", "transfer")
|
||||
mustContain(transfer, "Scan again", "transfer")
|
||||
mustContain(transfer, "one contributor example", "transfer")
|
||||
mustContain(transfer, "Linux", "transfer")
|
||||
mustContain(transfer, "Windows", "transfer")
|
||||
mustContain(transfer, "macOS", "transfer")
|
||||
mustContain(transfer, "title override", "transfer")
|
||||
mustContain(transfer, "Applet Mode", "transfer")
|
||||
mustContain(transfer, "Exit MTP", "transfer")
|
||||
mustContain(transfer, "nxlink", "transfer")
|
||||
mustContain(transfer, "deferred", "transfer")
|
||||
mustContain(transfer, "gvfs-mtp", "transfer")
|
||||
mustContain(transfer, "Portable Devices", "transfer")
|
||||
mustContain(transfer, "MTP USB Device", "transfer")
|
||||
mustContain(transfer, "AppleDouble", "transfer")
|
||||
mustContain(transfer, "card reader", "transfer")
|
||||
mustContain(transfer, "Canonical methods", "transfer")
|
||||
mustContain(transfer, "OpenMTP", "transfer")
|
||||
mustContain(transfer, "only one", "transfer")
|
||||
mustContain(transfer, "USB-C", "transfer")
|
||||
-- Per-OS SD/FTP fallback when MTP is flaky (XFER-05 AC)
|
||||
mustContain(transfer, "If MTP is unavailable or flaky on Linux", "transfer")
|
||||
mustContain(transfer, "If MTP is unavailable or flaky on Windows", "transfer")
|
||||
mustContain(transfer, "Joy-Con display chords (stock engine)", "transfer")
|
||||
mustNotContain(transfer, "VoxelMod", "transfer")
|
||||
|
||||
local install = read("docs/switch-install.md")
|
||||
local build = read("docs/switch-build.md")
|
||||
mustContain(install, "switch-transfer.md", "install")
|
||||
mustContain(install, "## Community mods", "install")
|
||||
mustContain(install, "Select + **A**", "install")
|
||||
mustContain(install, "COLORS", "install")
|
||||
mustContain(install, "TILT", "install")
|
||||
mustContain(install, "GBC FX", "install")
|
||||
mustContain(install, "PERFORMANCE", "install")
|
||||
mustContain(install, "Stock engine effect", "install")
|
||||
mustNotContain(install, "VoxelMod", "install")
|
||||
mustContain(build, "switch-transfer.md", "build")
|
||||
mustContain(build, "nxlink", "build")
|
||||
|
||||
local development = read("docs/switch-development.md")
|
||||
mustContain(development, "switch-transfer.md", "development")
|
||||
mustContain(development, "## Joy-Con display chords (Select + face)", "development")
|
||||
mustContain(development, "Stock engine effect", "development")
|
||||
mustContain(development, "claimed by the engine before mod pipeline", "development")
|
||||
mustContain(development, "Select + **L**", "development")
|
||||
mustContain(development, "OPTIONS → PERFORMANCE", "development")
|
||||
mustNotContain(development, "VoxelMod", "development")
|
||||
check(development:find("Non-macOS contributor MTP runbooks", 1, true) == nil,
|
||||
"development must not list Non-macOS runbooks as absent")
|
||||
|
||||
local evidence = read("docs/switch-hardware-evidence.md")
|
||||
local nxStart = evidence:find("## NXMOD-12", 1, true)
|
||||
check(nxStart ~= nil, "NXMOD-12 section present")
|
||||
local nxmod = evidence:sub(nxStart)
|
||||
mustContain(nxmod, "**pass**", "NXMOD-12")
|
||||
mustContain(nxmod, "531", "NXMOD-12")
|
||||
mustContain(nxmod, "switch-oled-photos", "NXMOD-12")
|
||||
mustContain(nxmod, "IMG_1766.jpg", "NXMOD-12")
|
||||
mustContain(nxmod, "IMG_1771.jpg", "NXMOD-12")
|
||||
mustContain(nxmod, "Community mod zip", "NXMOD-12")
|
||||
mustNotContain(nxmod, "VoxelMod", "NXMOD-12")
|
||||
check(nxmod:find("Status | **pending**", 1, true) == nil
|
||||
and nxmod:find("| **pending** |", 1, true) == nil,
|
||||
"NXMOD-12 must not keep pending status/checklist")
|
||||
|
||||
T.finish("switch_transfer_docs_test")
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user