mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-13 00:40:59 +02:00
Compare commits
158 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 604b9338f9 | |||
| 3a6557ffe2 | |||
| fe139ca7f4 | |||
| 5c617d3e32 | |||
| 8d11c04d43 | |||
| 92f73d8bed | |||
| 8da51aa7ba | |||
| 78f87111de | |||
| e3fcdd0776 | |||
| 6ea59f1a79 | |||
| 51fb4db82e | |||
| 7f78de8718 | |||
| 76aab74bf1 | |||
| a2bf08c6ff | |||
| b831076839 | |||
| 45575a1fd8 | |||
| 56d92246cb | |||
| 4a00f2335c | |||
| e9cb830d71 | |||
| c2666d2b43 | |||
| f89977a8e7 | |||
| 8d512b95cb | |||
| 0914387c16 | |||
| 105672c132 | |||
| 0dd6ecc219 | |||
| 9ef5c8dead | |||
| 12ff4dba2e | |||
| ff9992dff8 | |||
| 98c08e4b22 | |||
| 0bd3b26acc | |||
| 59b55a8f37 | |||
| 67e6b1fb04 | |||
| 17243a7d8b | |||
| 16b6b98ede | |||
| e4ce1063a1 | |||
| f099593136 | |||
| f944507520 | |||
| 6824949247 | |||
| 4f54255518 | |||
| cfe482e1fc | |||
| 0e5ea26e36 | |||
| d5886e69aa | |||
| dd5d8afd82 | |||
| f19f4e9341 | |||
| 0886573d34 | |||
| 30cd9afc92 | |||
| cceb74a025 | |||
| 42540076c1 | |||
| d86ac70d9e | |||
| be8023a994 | |||
| 50fd0e9c7d | |||
| 17843c2f7a | |||
| 54c8d2706b | |||
| 44bfb1b93f | |||
| 7a78992881 | |||
| d7581dbece | |||
| eeb8c89d3d | |||
| 2591fde764 | |||
| a8f3a3155c | |||
| 2a927651c5 | |||
| 62ef647811 | |||
| fe4491a87b | |||
| 105bf65e02 | |||
| 8120f113af | |||
| 7e0c64cb78 | |||
| 4afb54c54f | |||
| 74f6b68034 | |||
| 5d1e7ff3c1 | |||
| 3c628140f7 | |||
| 8bf99c3318 | |||
| f0829ed54f | |||
| 59dba325b9 | |||
| d13064ee64 | |||
| 669c9f4d8b | |||
| 228306f883 | |||
| 0a2ef854e6 | |||
| 61c471343a | |||
| 95eb1c3a35 | |||
| cf50976d65 | |||
| 3fcee7f951 | |||
| 17a4c83970 | |||
| 9712fb1e9b | |||
| cbf622f4aa | |||
| 0bb0518093 | |||
| 41f549b271 | |||
| ba8aa7b263 | |||
| 243cb6a477 | |||
| c987fefede | |||
| 2a038f7112 | |||
| 548e75a15e | |||
| 339c85b590 | |||
| 0f0420314b | |||
| a08b5e65f7 | |||
| 99b89c4e5e | |||
| 20586e0c48 | |||
| b54d0c605a | |||
| 2f75af902c | |||
| 9147a6413e | |||
| 9a467d4bf1 | |||
| e40f9337b6 | |||
| 1f0712d2d5 | |||
| 345bc9519a | |||
| 0b30bb2eff | |||
| f0e88aa581 | |||
| 317893bd61 | |||
| bd181271d9 | |||
| 4df09010a8 | |||
| 1a5b2b96dc | |||
| 43d89813fc | |||
| 5bc76ce6fe | |||
| 8654444d1a | |||
| 772c39edb8 | |||
| 74e7474349 | |||
| 3aefe41ea1 | |||
| 2223c31e93 | |||
| 568f0fa9eb | |||
| e91cf0f0c2 | |||
| 8a1f583d88 | |||
| 7518319c80 | |||
| 304df44742 | |||
| e92bab6f4f | |||
| 43755c5457 | |||
| d4455c508f | |||
| 7d01242cd1 | |||
| 9d77189cec | |||
| c8adbdcc64 | |||
| ac6dfe7134 | |||
| b1ad7c7254 | |||
| 6fb5602cb0 | |||
| b942d37944 | |||
| b7ee191b6c | |||
| 4ea24a0a6f | |||
| 6ad8c8d73d | |||
| 8982952fa4 | |||
| b135878824 | |||
| e9463d39aa | |||
| dcee7ca8e4 | |||
| 1653aa0a5d | |||
| 2699c9a2f9 | |||
| efd81d8e34 | |||
| 925b224eda | |||
| 7504753ea8 | |||
| da60f40dfc | |||
| 560ebc5997 | |||
| 361d4b81df | |||
| 83cf1fdb00 | |||
| 228a0baf81 | |||
| df7cea4387 | |||
| 13b55a797e | |||
| 1ec33b1374 | |||
| c06991e03d | |||
| 2fcd7abbfd | |||
| 5ca17cfc99 | |||
| 05f1e3852c | |||
| 3b22a45a23 | |||
| f172d3129f | |||
| a7a84b6fab | |||
| d4afa09e03 |
@@ -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.
@@ -0,0 +1,13 @@
|
||||
# Plain Pixel font
|
||||
|
||||
"Plain Pixel Font" by Douglas Vautour (Burpy Fresh) is licensed under
|
||||
CC-BY 4.0: https://burpyfresh.itch.io
|
||||
|
||||
Version 0.009 (CJK character additions), unmodified. Characters for most
|
||||
languages have a 5x11 base but can extend vertically; double-width
|
||||
characters such as Hiragana and Katakana are 11x11.
|
||||
|
||||
Bundled so a translation mod can opt into TTF text rendering
|
||||
(`mod.content.font:register("ttf", {})`; see the Translation support
|
||||
section of docs/new-features.md) instead of drawing hundreds of glyph-page
|
||||
tiles. The tile font extracted from the player's ROM stays the default.
|
||||
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
|
||||
|
||||
+35
-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
|
||||
@@ -150,6 +153,17 @@ before `Game:load`, so **it never loads a mod's entry chunk**; only
|
||||
- `LauncherMods.uninstall(id)` removes `mods/<id>/` and clears
|
||||
`options.mods[id]` so a later reinstall starts from the loader's default
|
||||
(enabled). The mods panel Delete control calls this and re-derives the list.
|
||||
- A mod that declares `github` shows its total GitHub downloads (every
|
||||
release's summed asset `download_count`, from the same cached release
|
||||
fetch the update check uses) as a highlighted body line like "12,345
|
||||
downloads across all releases - Released 2024-05-31 - Updated 2026-07-01"
|
||||
(first and latest `published_at`). Old cache entries written before the
|
||||
counts existed show no line rather than a wrong zero; a manual check
|
||||
refreshes them.
|
||||
- The MODS panel sorts its rows by Name, Popularity (downloads),
|
||||
Release date (first release), or Last updated, chosen by chips under the
|
||||
header and persisted in `options.modSort`. Mods without release data
|
||||
(no `github` field, or a stale cache) sink to the bottom of data sorts.
|
||||
|
||||
## Import / Export save
|
||||
|
||||
@@ -157,9 +171,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 +186,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
|
||||
|
||||
+28
-4
@@ -1,10 +1,10 @@
|
||||
# New features (deliberate additions beyond the original)
|
||||
|
||||
Intentional enhancements this port adds on top of faithful Pokémon Red
|
||||
behavior. They have no Game Boy equivalent and are kept by design.
|
||||
Intentional enhancements this port adds on top of faithful Pokémon Red, Blue,
|
||||
and Yellow behavior. They have no Game Boy equivalent and are kept by design.
|
||||
Genuine divergences from the original (things still missing, wrong, or
|
||||
approximated) live in docs/known-differences.md; faithfully-ported
|
||||
behavior is in docs/behavior-porting-notes.md.
|
||||
approximated) live in docs/known-differences.md; faithfully-ported behavior is
|
||||
in docs/behavior-porting-notes.md.
|
||||
|
||||
## Survey zoom
|
||||
|
||||
@@ -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
|
||||
@@ -365,6 +376,19 @@ plus a glyph-page and charmap stub, a naming-grid stub, and a
|
||||
be packed). `--refresh` re-harvests after an engine update, keeping
|
||||
existing translations and parking orphaned keys rather than dropping them.
|
||||
|
||||
A translation can also skip glyph pages entirely: scaffolding with
|
||||
`--pixel-font` (or registering `mod.content.font:register("ttf", {})` in
|
||||
an existing mod) renders text through a bundled TTF covering Latin with
|
||||
diacritics, Cyrillic, kana and CJK, while box borders and `<PK>`-style
|
||||
macro glyphs keep their tiles. The font is "Plain Pixel Font" by Douglas
|
||||
Vautour (Burpy Fresh), licensed under CC-BY 4.0 (5x11 base characters,
|
||||
11x11 double-width; see `assets/fonts/plainpixel/README.md`). Options on
|
||||
the registry entry: `file` for a mod-shipped TTF, `size` (the font's
|
||||
design em; Plain Pixel rasterizes cleanly only at multiples of 15),
|
||||
`spacing` added to every advance, `yOffset` for vertical alignment
|
||||
against the 8px cell grid, and `bold`, which double-prints at a 1px
|
||||
offset for fonts whose strokes read too light.
|
||||
|
||||
See the wiki's Translations guide.
|
||||
|
||||
## Save editor (bundled, reachable from the launcher)
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
# What This Port Requires
|
||||
|
||||
The packaged desktop app requires one user-supplied input on first boot: a
|
||||
canonical 1 MiB US Pokemon Red ROM.
|
||||
canonical 1 MiB US Pokemon Red, Blue, or Yellow ROM.
|
||||
|
||||
The importer verifies SHA-1
|
||||
`ea9bcae617fdf159b045185467ae58b2e4a48b9a`. Other revisions, Virtual
|
||||
Console releases, and Pokemon Blue are rejected rather than decoded with
|
||||
incorrect addresses.
|
||||
The importer verifies the SHA-1 for the game (see `src/core/GameVersion.lua`
|
||||
for specific hashes). Other revisions and Virtual Console releases are rejected
|
||||
rather than decoded with incorrect addresses.
|
||||
|
||||
After verification, the app generates its private cache in the LÖVE save
|
||||
directory. It does not keep a copy of the ROM. Later boots use the cache.
|
||||
@@ -15,9 +14,11 @@ Python and Pillow are not required by the packaged app.
|
||||
## Bundled Metadata
|
||||
|
||||
Assembly removes high-level names and some relationships that the Lua port
|
||||
needs. `tools/rom_manifest.json` therefore contains:
|
||||
needs. The version-specific files `tools/rom_manifest.json`,
|
||||
`tools/rom_manifest_blue.json`, and `tools/rom_manifest_yellow.json` therefore
|
||||
contain:
|
||||
|
||||
- the 3,268 ROM symbol addresses actually read by the extractor
|
||||
- the ROM symbol addresses actually read by the extractor
|
||||
- symbolic IDs and ordering for maps, species, moves, items, and trainers
|
||||
- source-erased dimensions, image names, and map object integration names
|
||||
- hand-ported field/script integration tables
|
||||
|
||||
@@ -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)
|
||||
@@ -230,7 +230,9 @@ function flexlove.init(config)
|
||||
-- Initialize Performance if available
|
||||
if Performance then
|
||||
flexlove._Performance = Performance.init({
|
||||
enabled = config.performanceMonitoring or true,
|
||||
-- ~= false (not `or true`): `performanceMonitoring = false` must
|
||||
-- actually disable the per-frame timers + memory sampling.
|
||||
enabled = config.performanceMonitoring ~= false,
|
||||
hudEnabled = false, -- Start with HUD disabled
|
||||
hudToggleKey = config.performanceHudKey or "f3",
|
||||
hudPosition = config.performanceHudPosition or { x = 10, y = 10 },
|
||||
|
||||
@@ -2709,6 +2709,7 @@ function Element:_drawChildren(backdropCanvas)
|
||||
|
||||
-- Set up clipping: rounded-corners (stencil) > overflow (scissor) > none
|
||||
local clipMode = "none"
|
||||
local prevSx, prevSy, prevSw, prevSh
|
||||
if hasRoundedCorners then
|
||||
local roundedBoxWidth = self._borderBoxWidth or (self.width + self.padding.left + self.padding.right)
|
||||
local roundedBoxHeight = self._borderBoxHeight or (self.height + self.padding.top + self.padding.bottom)
|
||||
@@ -2721,7 +2722,18 @@ function Element:_drawChildren(backdropCanvas)
|
||||
love.graphics.setStencilTest("greater", 0)
|
||||
clipMode = "stencil"
|
||||
elseif needsOverflowClipping then
|
||||
love.graphics.setScissor(self.x + self.padding.left, self.y + self.padding.top, self.width, self.height)
|
||||
-- Intersect with (and later restore) any ancestor scissor: replacing
|
||||
-- it let a nested scroller widen its parent's clip, and the bare
|
||||
-- setScissor() restore then cleared it entirely - scrolled page
|
||||
-- content drew straight over the pinned header.
|
||||
-- transformPoint: scissor rects live in window coordinates and ignore
|
||||
-- the scroll translate an ancestor scroller has pushed, so a nested
|
||||
-- scroller's clip sat at its untranslated layout position and cut its
|
||||
-- own rows away once the page scrolled.
|
||||
prevSx, prevSy, prevSw, prevSh = love.graphics.getScissor()
|
||||
local scx, scy = love.graphics.transformPoint(
|
||||
self.x + self.padding.left, self.y + self.padding.top)
|
||||
love.graphics.intersectScissor(scx, scy, self.width, self.height)
|
||||
clipMode = "scissor"
|
||||
end
|
||||
|
||||
@@ -2745,7 +2757,11 @@ function Element:_drawChildren(backdropCanvas)
|
||||
if clipMode == "stencil" then
|
||||
love.graphics.setStencilTest()
|
||||
elseif clipMode == "scissor" then
|
||||
love.graphics.setScissor()
|
||||
if prevSx then
|
||||
love.graphics.setScissor(prevSx, prevSy, prevSw, prevSh)
|
||||
else
|
||||
love.graphics.setScissor()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -112,7 +112,8 @@ function ScrollManager.new(config, deps)
|
||||
self.touchScrollEnabled = config.touchScrollEnabled ~= false -- Default true
|
||||
self.momentumScrollEnabled = config.momentumScrollEnabled ~= false -- Default true
|
||||
self.bounceEnabled = config.bounceEnabled ~= false -- Default true
|
||||
self.scrollFriction = config.scrollFriction or 0.95 -- Exponential decay per frame
|
||||
self.scrollFriction = config.scrollFriction or 0.95 -- legacy per-frame decay; superseded by _momentumDecel
|
||||
self._momentumDecel = config.momentumDecel or 2.0 -- fling decay rate (1/s), dt-based; ~iOS "normal"
|
||||
self.bounceStiffness = config.bounceStiffness or 0.2 -- Spring constant
|
||||
self.maxOverscroll = config.maxOverscroll or 100 -- pixels
|
||||
|
||||
@@ -127,7 +128,8 @@ function ScrollManager.new(config, deps)
|
||||
self._scrollY = config._scrollY or 0
|
||||
self._targetScrollX = nil
|
||||
self._targetScrollY = nil
|
||||
self._smoothScrollSpeed = 0.25 -- Interpolation speed (0-1, higher = faster)
|
||||
self._smoothScrollSpeed = 0.25 -- Interpolation speed (0-1, higher = faster); legacy, superseded by _smoothScrollRate
|
||||
self._smoothScrollRate = config.smoothScrollRate or 30 -- dt-based decay constant (1/s); ~90% converged in 2.3/rate s
|
||||
self.smoothScrollEnabled = config.smoothScrollEnabled or false -- Enable smooth wheel scrolling
|
||||
self._maxScrollX = 0
|
||||
self._maxScrollY = 0
|
||||
@@ -729,6 +731,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 +845,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
|
||||
@@ -892,9 +927,15 @@ function ScrollManager:handleTouchMove(touchX, touchY)
|
||||
dx = -dx
|
||||
dy = -dy
|
||||
|
||||
-- Calculate velocity (pixels per second)
|
||||
self._scrollVelocityX = dx / dt
|
||||
self._scrollVelocityY = dy / dt
|
||||
-- Velocity tracking (pixels per second). Not the raw last-sample d/dt:
|
||||
-- touch samples jitter, and the final pre-release sample often reads near
|
||||
-- zero, which is why single-sample flings feel dead compared to a native
|
||||
-- list. Blend toward the instantaneous velocity with a ~50 ms time
|
||||
-- constant instead, like OS velocity trackers that average recent samples.
|
||||
local instX, instY = dx / dt, dy / dt
|
||||
local blend = math.min(1, dt / 0.05)
|
||||
self._scrollVelocityX = self._scrollVelocityX + (instX - self._scrollVelocityX) * blend
|
||||
self._scrollVelocityY = self._scrollVelocityY + (instY - self._scrollVelocityY) * blend
|
||||
|
||||
-- Apply scroll with bounce if enabled
|
||||
if self.bounceEnabled then
|
||||
@@ -935,6 +976,15 @@ function ScrollManager:handleTouchRelease()
|
||||
|
||||
self._touchScrolling = false
|
||||
|
||||
-- A finger that pauses and then lifts is a stop, not a fling: without
|
||||
-- this, the smoothed velocity from the earlier drag still launches the
|
||||
-- list after a deliberate hold (native lists kill the tracker the same
|
||||
-- way).
|
||||
if love.timer.getTime() - (self._lastTouchTime or 0) > 0.1 then
|
||||
self._scrollVelocityX = 0
|
||||
self._scrollVelocityY = 0
|
||||
end
|
||||
|
||||
-- Start momentum scrolling if enabled and velocity is significant
|
||||
if self.momentumScrollEnabled then
|
||||
local velocityThreshold = 50 -- pixels per second
|
||||
@@ -957,12 +1007,23 @@ end
|
||||
--- Update momentum scrolling (call every frame with dt)
|
||||
---@param dt number Delta time in seconds
|
||||
function ScrollManager:update(dt)
|
||||
-- Smooth scroll interpolation
|
||||
-- Smooth scroll interpolation. The blend factor is derived from dt
|
||||
-- (exponential approach) rather than a fixed per-frame fraction, so the
|
||||
-- animation converges in the same wall-clock time regardless of frame
|
||||
-- rate; a fixed fraction made scrolling visibly slower whenever frame
|
||||
-- time rose. _smoothScrollRate is the decay constant in 1/seconds:
|
||||
-- ~90% of the remaining distance is covered in 2.3/rate seconds.
|
||||
-- dt can legitimately be 0 here: in immediate mode beginFrame resets the
|
||||
-- accumulated dt BEFORE endFrame updates the elements, so the launcher's
|
||||
-- update order always hands this 0. Treat that (and nil) as one nominal
|
||||
-- 60 Hz step so the interpolation still advances one frame's worth.
|
||||
local step = (dt and dt > 0) and dt or (1 / 60)
|
||||
if self._targetScrollX or self._targetScrollY then
|
||||
local alpha = 1 - math.exp(-(self._smoothScrollRate or 30) * step)
|
||||
if self._targetScrollY then
|
||||
local diff = self._targetScrollY - self._scrollY
|
||||
if math.abs(diff) > 0.5 then
|
||||
self._scrollY = self._scrollY + diff * self._smoothScrollSpeed
|
||||
self._scrollY = self._scrollY + diff * alpha
|
||||
else
|
||||
self._scrollY = self._targetScrollY
|
||||
self._targetScrollY = nil
|
||||
@@ -972,7 +1033,7 @@ function ScrollManager:update(dt)
|
||||
if self._targetScrollX then
|
||||
local diff = self._targetScrollX - self._scrollX
|
||||
if math.abs(diff) > 0.5 then
|
||||
self._scrollX = self._scrollX + diff * self._smoothScrollSpeed
|
||||
self._scrollX = self._scrollX + diff * alpha
|
||||
else
|
||||
self._scrollX = self._targetScrollX
|
||||
self._targetScrollX = nil
|
||||
@@ -988,25 +1049,43 @@ function ScrollManager:update(dt)
|
||||
return
|
||||
end
|
||||
|
||||
-- Apply velocity to scroll position
|
||||
local dx = self._scrollVelocityX * dt
|
||||
local dy = self._scrollVelocityY * dt
|
||||
-- Apply velocity to scroll position (step, not dt: dt is 0 in the
|
||||
-- immediate-mode endFrame path, which left touch flings completely dead)
|
||||
local dx = self._scrollVelocityX * step
|
||||
local dy = self._scrollVelocityY * step
|
||||
|
||||
if self.bounceEnabled then
|
||||
-- Allow overscroll during momentum
|
||||
self._scrollX = self._scrollX + dx
|
||||
self._scrollY = self._scrollY + dy
|
||||
-- Allow overscroll during momentum, but never past maxOverscroll: an
|
||||
-- unclamped fling carried a list hundreds of pixels past its edge
|
||||
-- (rows vanished into a blank card until the spring crawled back).
|
||||
self._scrollX = self._utils.clamp(self._scrollX + dx,
|
||||
-self.maxOverscroll, self._maxScrollX + self.maxOverscroll)
|
||||
self._scrollY = self._utils.clamp(self._scrollY + dy,
|
||||
-self.maxOverscroll, self._maxScrollY + self.maxOverscroll)
|
||||
else
|
||||
self:scrollBy(dx, dy)
|
||||
end
|
||||
|
||||
-- Apply friction (exponential decay)
|
||||
self._scrollVelocityX = self._scrollVelocityX * self.scrollFriction
|
||||
self._scrollVelocityY = self._scrollVelocityY * self.scrollFriction
|
||||
-- Frame-rate-independent exponential decay tuned to native list feel:
|
||||
-- iOS "normal" deceleration is ~0.998 per millisecond, i.e. exp(-2.0 t).
|
||||
-- The old per-frame 0.95 factor killed a fling in a fraction of a second
|
||||
-- (and faster the higher the frame rate). Total fling travel is
|
||||
-- velocity / rate.
|
||||
local decay = math.exp(-(self._momentumDecel or 2.0) * step)
|
||||
-- Past the content edge, kill the fling an order of magnitude faster so
|
||||
-- the rubber band absorbs it instead of stretching to the clamp and
|
||||
-- sitting there (native lists do the same).
|
||||
local overX = self._scrollX < 0 or self._scrollX > self._maxScrollX
|
||||
local overY = self._scrollY < 0 or self._scrollY > self._maxScrollY
|
||||
if overX or overY then
|
||||
decay = decay * math.exp(-20 * step)
|
||||
end
|
||||
self._scrollVelocityX = self._scrollVelocityX * decay
|
||||
self._scrollVelocityY = self._scrollVelocityY * decay
|
||||
|
||||
-- Stop momentum when velocity is very low
|
||||
local totalVelocity = math.sqrt(self._scrollVelocityX ^ 2 + self._scrollVelocityY ^ 2)
|
||||
if totalVelocity < 1 then
|
||||
if totalVelocity < 5 then
|
||||
self._momentumScrolling = false
|
||||
self._scrollVelocityX = 0
|
||||
self._scrollVelocityY = 0
|
||||
|
||||
@@ -214,10 +214,13 @@ local function onDraw(element, _ctx)
|
||||
if not (scrollbarDims.vertical.visible or scrollbarDims.horizontal.visible) then
|
||||
return
|
||||
end
|
||||
-- Clear any parent scissor clipping before drawing scrollbars so they render
|
||||
-- fully visible (scrollbars must not be clipped by ancestor overflow).
|
||||
-- Lift the parent scissor while drawing scrollbars so they render fully
|
||||
-- visible, then RESTORE it: clearing it outright let every later sibling
|
||||
-- draw unclipped (scrolled page content over the pinned header).
|
||||
local sx, sy, sw, sh = love.graphics.getScissor()
|
||||
love.graphics.setScissor()
|
||||
element._renderer:drawScrollbars(element, element.x, element.y, element.width, element.height, scrollbarDims)
|
||||
if sx then love.graphics.setScissor(sx, sy, sw, sh) end
|
||||
end
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -12,6 +12,20 @@
|
||||
"tintColor": "3b5ca8",
|
||||
"category": "games",
|
||||
"versions": [
|
||||
{
|
||||
"version": "0.1.65",
|
||||
"date": "2026-08-03",
|
||||
"size": 9376618,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.65/gen1recomp-0.1.65-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #592 Screen Orientation\n- #702 FLY overworld animation incorrect/incomplete\n- #748 Launcher Menu has overlap\n- #754 Can push boulders with STRENGTH through walls\n- #758 PC to Mac Online Multiplayer Disconnects Shortly After Starting Match\n\n## Contributors\n\n- @andrewqsantos\n- @Bortlesboat\n- @bryanthaboi\n- @castdrian\n- @johnjohto\n- @ShaneMcGovernIE"
|
||||
},
|
||||
{
|
||||
"version": "0.1.64",
|
||||
"date": "2026-08-03",
|
||||
"size": 9317129,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.64/gen1recomp-0.1.64-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi\n- @ShaneMcGovernIE"
|
||||
},
|
||||
{
|
||||
"version": "0.1.63",
|
||||
"date": "2026-08-03",
|
||||
|
||||
@@ -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():
|
||||
|
||||
+8
-3
@@ -72,8 +72,13 @@ rm -f "$LOVE_FILE"
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
tools/rom_manifest_yellow.json \
|
||||
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
||||
if unzip -Z1 "$LOVE_FILE" \
|
||||
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
|
||||
# Materialize the listing once and grep the file: piping unzip straight into
|
||||
# grep -q under `set -o pipefail` SIGPIPEs unzip when grep exits early on a
|
||||
# match, and the pipeline's failure reads as "missing <file>" for whichever
|
||||
# entry happened to match first (see the same fix in pack_love.sh).
|
||||
LOVE_LISTING="$WORK/love-listing.txt"
|
||||
unzip -Z1 "$LOVE_FILE" > "$LOVE_LISTING"
|
||||
if grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/' "$LOVE_LISTING"; then
|
||||
fail "game.love unexpectedly contains generated ROM data"
|
||||
fi
|
||||
# The editor is only reachable if its entry point and both module directories
|
||||
@@ -86,7 +91,7 @@ for required in tools/save-editor/App.lua tools/save-editor/Kit.lua \
|
||||
libs/flexlove/FlexLove.lua \
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
tools/rom_manifest_yellow.json; do
|
||||
unzip -Z1 "$LOVE_FILE" | grep -qx "$required" \
|
||||
grep -qxF "$required" "$LOVE_LISTING" \
|
||||
|| fail "game.love is missing $required"
|
||||
done
|
||||
say "game.love: $(du -h "$LOVE_FILE" | cut -f1)"
|
||||
|
||||
@@ -695,7 +695,11 @@ run_xcodebuild() {
|
||||
fi
|
||||
|
||||
# Fuse even if the pbxproj wire-up failed, LÖVE runs any bundled *.love.
|
||||
if [ ! -f "$app/game.love" ]; then
|
||||
# Byte-compare, never just existence: xcodebuild's incremental Copy Bundle
|
||||
# Resources can leave a previous build's game.love in a surviving .app, and
|
||||
# an existence check shipped that stale payload in the .ipa (today's Lua
|
||||
# fixes present in ios/resources/ but absent from the installed app).
|
||||
if ! cmp -s "$LOVE_FILE" "$app/game.love"; then
|
||||
say "fusing game.love into $(basename "$app")"
|
||||
cp "$LOVE_FILE" "$app/game.love"
|
||||
fi
|
||||
|
||||
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
|
||||
|
||||
@@ -380,18 +380,21 @@ local CHARGE_TEXT = {
|
||||
-- pokered's <USER>/<TARGET> text macros (home/text.asm
|
||||
-- PlaceMoveUsersName): battle texts naming the enemy mon print
|
||||
-- "Enemy " before the nickname; player-side mons never get it.
|
||||
-- Translatable as one "Enemy %s" template (#779) so languages that
|
||||
-- qualify after the name, or decline, can (e.g. "%s ennemi").
|
||||
local function displayName(b)
|
||||
return b.isPlayer and b.name or ("Enemy " .. b.name)
|
||||
return b.isPlayer and b.name or Strings("Enemy %s", b.name)
|
||||
end
|
||||
|
||||
-- Apply the "Enemy " prefix to a pre-built message from a module that
|
||||
-- only knows the raw nickname (Status.beforeMove/residual,
|
||||
-- TrainerAI.useItem): splice it in before the first name occurrence.
|
||||
-- Apply the enemy qualifier to a pre-built message from a module that
|
||||
-- only knows the raw nickname (Status.beforeMove/residual): replace the
|
||||
-- first name occurrence with the qualified form.
|
||||
local function prefixEnemy(msg, battler)
|
||||
if battler.isPlayer then return msg end
|
||||
local s = msg:find(battler.name, 1, true)
|
||||
if not s then return msg end
|
||||
return msg:sub(1, s - 1) .. "Enemy " .. msg:sub(s)
|
||||
return msg:sub(1, s - 1) .. Strings("Enemy %s", battler.name)
|
||||
.. msg:sub(s + #battler.name)
|
||||
end
|
||||
|
||||
-- Level-up stats window (PrintStatsBox .LevelUpStatsBox: box (9,2)
|
||||
@@ -3044,8 +3047,11 @@ function BattleState:executeAction(user, target, action)
|
||||
-- trainer class AI actions (engine/battle/trainer_ai.asm)
|
||||
if action.special == "aiItem" then
|
||||
self.aiUses = (self.aiUses or 1) - 1
|
||||
-- useItem's messages arrive final: its item line prints the raw
|
||||
-- nickname on purpose (no "Enemy " in AIPrintItemUseText), so the
|
||||
-- prefix splice must not touch them.
|
||||
for _, m in ipairs(TrainerAI.useItem(self, action.item)) do
|
||||
self:sayNext(prefixEnemy(m, self.enemy))
|
||||
self:sayNext(m)
|
||||
end
|
||||
self:drainNext()
|
||||
require("src.core.Sound").play(self.data, "Heal_Ailment")
|
||||
|
||||
@@ -29,7 +29,7 @@ end
|
||||
-- pokered's <USER>/<TARGET> text macros print "Enemy " before the enemy
|
||||
-- mon's nickname (home/text.asm PlaceMoveUsersName)
|
||||
local function displayName(b)
|
||||
return b.isPlayer and b.name or ("Enemy " .. b.name)
|
||||
return b.isPlayer and b.name or Strings("Enemy %s", b.name) -- #779
|
||||
end
|
||||
EffectRegistry.displayName = displayName
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ local MoveEffects = {}
|
||||
-- pokered's <USER>/<TARGET> text macros print "Enemy " before the
|
||||
-- enemy mon's nickname (home/text.asm PlaceMoveUsersName)
|
||||
local function displayName(b)
|
||||
return b.isPlayer and b.name or ("Enemy " .. b.name)
|
||||
return b.isPlayer and b.name or Strings("Enemy %s", b.name) -- #779
|
||||
end
|
||||
|
||||
local STAT_LABEL = {
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
+49
-30
@@ -6,6 +6,7 @@
|
||||
-- back to the vanilla records, which is bit-identical behavior.
|
||||
|
||||
local Strings = require("src.core.Strings")
|
||||
local romText = require("src.core.RomText")
|
||||
|
||||
local Status = {}
|
||||
|
||||
@@ -34,8 +35,8 @@ end
|
||||
-- sentence rather than the noun: "hurt by poison" and "hurt by the burn"
|
||||
-- decline differently once translated, so a shared fragment cannot be the
|
||||
-- translatable unit.
|
||||
local function damageOverTime(template)
|
||||
return function(battler)
|
||||
local function damageOverTime(label, template)
|
||||
return function(battler, _, battle)
|
||||
local mon = battler.mon
|
||||
local base = math.max(1, math.floor(mon.stats.hp / 16))
|
||||
local dmg = base
|
||||
@@ -44,7 +45,7 @@ local function damageOverTime(template)
|
||||
battler.toxicCounter = battler.toxicCounter + 1
|
||||
end
|
||||
mon.hp = math.max(0, mon.hp - dmg)
|
||||
return { Strings(template, name(battler)) }
|
||||
return { romText(battle and battle.data, label, template, name(battler)) }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -63,53 +64,63 @@ Status.RECORDS = {
|
||||
id = "SLP", label = "SLP", hudLabel = "SLP",
|
||||
catchBonus = 25, shakeBonus = 10,
|
||||
beforeMovePriority = 40,
|
||||
beforeMove = function(battler)
|
||||
beforeMove = function(battler, _, battle)
|
||||
battler.sleepTurns = (battler.sleepTurns or 1) - 1
|
||||
if battler.sleepTurns <= 0 then
|
||||
battler.mon.status = nil
|
||||
return false, { Strings("%s\nwoke up!", name(battler)) } -- wakes, loses the turn
|
||||
-- wakes, loses the turn
|
||||
return false, { romText(battle and battle.data, "_WokeUpText",
|
||||
"%s\nwoke up!", name(battler)) }
|
||||
end
|
||||
return false, { Strings("%s\nis fast asleep!", name(battler)) }
|
||||
return false, { romText(battle and battle.data, "_FastAsleepText",
|
||||
"%s\nis fast asleep!", name(battler)) }
|
||||
end,
|
||||
onInflict = function(battle, target, opts, display)
|
||||
target.sleepTurns = battle.rng(1, 7)
|
||||
return { Strings("%s\nfell asleep!", display) }
|
||||
return { romText(battle.data, "_FellAsleepText",
|
||||
"%s\nfell asleep!", display) }
|
||||
end,
|
||||
},
|
||||
FRZ = {
|
||||
id = "FRZ", label = "FRZ", hudLabel = "FRZ",
|
||||
catchBonus = 25, shakeBonus = 10,
|
||||
beforeMovePriority = 30,
|
||||
beforeMove = function(battler)
|
||||
return false, { Strings("%s\nis frozen solid!", name(battler)) }
|
||||
beforeMove = function(battler, _, battle)
|
||||
return false, { romText(battle and battle.data, "_IsFrozenText",
|
||||
"%s\nis frozen solid!", name(battler)) }
|
||||
end,
|
||||
canInflict = function(target) return not hasType(target, "ICE") end,
|
||||
onInflict = function(_, _, _, display)
|
||||
return { Strings("%s\nwas frozen solid!", display) }
|
||||
onInflict = function(battle, _, _, display)
|
||||
return { romText(battle and battle.data, "_FrozenText",
|
||||
"%s\nwas frozen solid!", display) }
|
||||
end,
|
||||
},
|
||||
PSN = {
|
||||
id = "PSN", label = "PSN", hudLabel = "PSN",
|
||||
catchBonus = 12, shakeBonus = 5,
|
||||
residual = damageOverTime(Strings.source("%s's\nhurt by poison!")),
|
||||
residual = damageOverTime("_HurtByPoisonText",
|
||||
Strings.source("%s's\nhurt by poison!")),
|
||||
canInflict = function(target) return not hasType(target, "POISON") end,
|
||||
onInflict = function(_, target, opts, display)
|
||||
onInflict = function(battle, target, opts, display)
|
||||
if opts.toxic then
|
||||
target.toxicCounter = 1
|
||||
-- _BadlyPoisonedText
|
||||
return { Strings("%s's\nbadly poisoned!", display) }
|
||||
return { romText(battle and battle.data, "_BadlyPoisonedText",
|
||||
"%s's\nbadly poisoned!", display) }
|
||||
end
|
||||
return { Strings("%s\nwas poisoned!", display) }
|
||||
return { romText(battle and battle.data, "_PoisonedText",
|
||||
"%s\nwas poisoned!", display) }
|
||||
end,
|
||||
},
|
||||
BRN = {
|
||||
id = "BRN", label = "BRN", hudLabel = "BRN",
|
||||
catchBonus = 12, shakeBonus = 5,
|
||||
statPenalty = { stat = "attack", div = 2 },
|
||||
residual = damageOverTime(Strings.source("%s's\nhurt by the burn!")),
|
||||
residual = damageOverTime("_HurtByBurnText",
|
||||
Strings.source("%s's\nhurt by the burn!")),
|
||||
canInflict = function(target) return not hasType(target, "FIRE") end,
|
||||
onInflict = function(_, _, _, display)
|
||||
return { Strings("%s\nwas burned!", display) }
|
||||
onInflict = function(battle, _, _, display)
|
||||
return { romText(battle and battle.data, "_BurnedText",
|
||||
"%s\nwas burned!", display) }
|
||||
end,
|
||||
},
|
||||
PAR = {
|
||||
@@ -117,10 +128,11 @@ Status.RECORDS = {
|
||||
catchBonus = 12, shakeBonus = 5,
|
||||
statPenalty = { stat = "speed", div = 4 },
|
||||
beforeMovePriority = 10,
|
||||
beforeMove = function(battler, rng)
|
||||
beforeMove = function(battler, rng, battle)
|
||||
-- cp 25 percent / jr nc: fully paralyzed on rand < 63 (63/256)
|
||||
if rng(0, 255) < 63 then
|
||||
return false, { Strings("%s's\nfully paralyzed!", name(battler)) }
|
||||
return false, { romText(battle and battle.data, "_FullyParalyzedText",
|
||||
"%s's\nfully paralyzed!", name(battler)) }
|
||||
end
|
||||
return true, {}
|
||||
end,
|
||||
@@ -128,9 +140,10 @@ Status.RECORDS = {
|
||||
-- ParalyzeEffect_: Electric-type moves can't paralyze Ground-types
|
||||
return not (opts.moveType == "ELECTRIC" and hasType(target, "GROUND"))
|
||||
end,
|
||||
onInflict = function(_, _, _, display)
|
||||
-- _ParalyzedMayNotAttackText (primary and secondary paralysis)
|
||||
return { Strings("%s's\nparalyzed! It may\nnot attack!", display) }
|
||||
onInflict = function(battle, _, _, display)
|
||||
-- primary and secondary paralysis share this line
|
||||
return { romText(battle and battle.data, "_ParalyzedMayNotAttackText",
|
||||
"%s's\nparalyzed! It may\nnot attack!", display) }
|
||||
end,
|
||||
},
|
||||
}
|
||||
@@ -166,7 +179,8 @@ function Status.beforeMove(battler, rng, battle)
|
||||
end
|
||||
if battler.flinched then
|
||||
battler.flinched = false
|
||||
return false, { Strings("%s\nflinched!", name(battler)) }
|
||||
return false, { romText(battle and battle.data, "_FlinchedText",
|
||||
"%s\nflinched!", name(battler)) }
|
||||
end
|
||||
local record = Status.recordFor(battleStatuses(battle), mon.status)
|
||||
local handler = record and record.beforeMove
|
||||
@@ -184,23 +198,27 @@ function Status.beforeMove(battler, rng, battle)
|
||||
end
|
||||
if battler.boundTurns and battler.boundTurns > 0 then
|
||||
battler.boundTurns = battler.boundTurns - 1
|
||||
msgs[#msgs + 1] = Strings("%s\ncan't move!", name(battler))
|
||||
msgs[#msgs + 1] = romText(battle and battle.data, "_CantMoveText",
|
||||
"%s\ncan't move!", name(battler))
|
||||
return false, msgs
|
||||
end
|
||||
if battler.disabledTurns then
|
||||
battler.disabledTurns = battler.disabledTurns - 1
|
||||
if battler.disabledTurns <= 0 then
|
||||
battler.disabledTurns, battler.disabledSlot = nil, nil
|
||||
table.insert(msgs, Strings("%s's\ndisabled no more!", name(battler)))
|
||||
table.insert(msgs, romText(battle and battle.data, "_DisabledNoMoreText",
|
||||
"%s's\ndisabled no more!", name(battler)))
|
||||
end
|
||||
end
|
||||
if battler.confusedTurns then
|
||||
battler.confusedTurns = battler.confusedTurns - 1
|
||||
if battler.confusedTurns <= 0 then
|
||||
battler.confusedTurns = nil
|
||||
table.insert(msgs, Strings("%s\nsnapped out of\nconfusion!", name(battler)))
|
||||
table.insert(msgs, romText(battle and battle.data, "_ConfusedNoMoreText",
|
||||
"%s\nsnapped out of\nconfusion!", name(battler)))
|
||||
else
|
||||
table.insert(msgs, Strings("%s\nis confused!", name(battler)))
|
||||
table.insert(msgs, romText(battle and battle.data, "_IsConfusedText",
|
||||
"%s\nis confused!", name(battler)))
|
||||
-- cp 50 percent + 1 / jr c: hurt itself on rand >= 128 (128/256)
|
||||
if rng(0, 255) < 128 then
|
||||
return false, msgs, true -- hurt itself
|
||||
@@ -241,7 +259,8 @@ function Status.residual(battler, opponent, battle)
|
||||
dmg = math.min(dmg, mon.hp)
|
||||
mon.hp = mon.hp - dmg
|
||||
opponent.mon.hp = math.min(opponent.mon.stats.hp, opponent.mon.hp + dmg)
|
||||
table.insert(msgs, Strings("LEECH SEED saps\n%s!", name(battler)))
|
||||
table.insert(msgs, romText(battle and battle.data, "_HurtByLeechSeedText",
|
||||
"LEECH SEED saps\n%s!", name(battler)))
|
||||
end
|
||||
return msgs
|
||||
end
|
||||
|
||||
@@ -12,7 +12,7 @@ local StatusRegistry = {}
|
||||
-- pokered's <USER>/<TARGET> text macros (home/text.asm
|
||||
-- PlaceMoveUsersName): enemy-mon texts print "Enemy " before the name
|
||||
local function displayName(b)
|
||||
return b.isPlayer and b.name or ("Enemy " .. b.name)
|
||||
return b.isPlayer and b.name or Strings("Enemy %s", b.name) -- #779
|
||||
end
|
||||
|
||||
-- opts: toxic (start the Toxic counter), moveType (for the type gates),
|
||||
|
||||
@@ -20,9 +20,16 @@
|
||||
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
local Strings = require("src.core.Strings")
|
||||
local romText = require("src.core.RomText")
|
||||
|
||||
local TrainerAI = {}
|
||||
|
||||
-- pokered's <USER>/<TARGET> text macros print "Enemy " before the
|
||||
-- enemy mon's nickname (home/text.asm PlaceMoveUsersName)
|
||||
local function displayName(b)
|
||||
return b.isPlayer and b.name or Strings("Enemy %s", b.name) -- #779
|
||||
end
|
||||
|
||||
local HEAL_AMOUNT = { POTION = 20, SUPER_POTION = 50, HYPER_POTION = 200 }
|
||||
local X_STAT = { X_ATTACK = "attack", X_DEFEND = "defense", X_SPEED = "speed" }
|
||||
|
||||
@@ -95,12 +102,16 @@ function TrainerAI.switchAction(battle)
|
||||
return { special = "aiSwitch", index = alive[1] }
|
||||
end
|
||||
|
||||
-- Apply an aiItem action to the enemy battler; returns messages.
|
||||
-- Apply an aiItem action to the enemy battler; returns messages, already
|
||||
-- final: the item line prints the raw nickname (AIPrintItemUseText has no
|
||||
-- "Enemy " prefix in pokered), the stat lines carry it via displayName, so
|
||||
-- the caller must not run these through prefixEnemy.
|
||||
function TrainerAI.useItem(battle, item)
|
||||
local enemy = battle.enemy
|
||||
local trainerName = battle.trainer.name
|
||||
local itemName = battle.data.items[item] and battle.data.items[item].name or item
|
||||
local msgs = { Strings("%s\nused %s!", trainerName, itemName) }
|
||||
local msgs = { romText(battle.data, "_AIBattleUseItemText",
|
||||
"%s\nused %s!", trainerName, itemName, enemy.name) }
|
||||
if item == "FULL_HEAL" then
|
||||
enemy.mon.status = nil
|
||||
enemy.toxicCounter = nil
|
||||
@@ -113,10 +124,10 @@ function TrainerAI.useItem(battle, item)
|
||||
elseif X_STAT[item] then
|
||||
local stat = X_STAT[item]
|
||||
enemy.stages[stat] = math.min(6, (enemy.stages[stat] or 0) + 1)
|
||||
table.insert(msgs, Strings("%s's\n%s rose!", enemy.name, stat:upper()))
|
||||
table.insert(msgs, Strings("%s's\n%s rose!", displayName(enemy), stat:upper()))
|
||||
elseif item == "GUARD_SPEC" then
|
||||
enemy.mist = true
|
||||
table.insert(msgs, Strings("%s's\nprotected against\nstat changes!", enemy.name))
|
||||
table.insert(msgs, Strings("%s's\nprotected against\nstat changes!", displayName(enemy)))
|
||||
end
|
||||
return msgs
|
||||
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"),
|
||||
|
||||
+292
-48
@@ -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,66 @@ 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
|
||||
|
||||
-- All platforms: FlexLove used to map `performanceMonitoring = false` to
|
||||
-- true (`false or true`), leaving layout/render timers + memory sampling on
|
||||
-- every immediate-mode frame (pad-cursor lag on NX, scroll drag on desktop).
|
||||
-- The vendored init is fixed, but force the flags off here too so a hot
|
||||
-- reload against an already-inited FlexLove stays clean. Exported so the
|
||||
-- engine tier can assert the guards without drawing the full tree.
|
||||
function LauncherView.applyNxPerfGuards(imp)
|
||||
if not (imp 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 strategy triggers a full blocking collect past 100 MB, a
|
||||
-- visible hitch mid-scroll. Less frequent steps, higher threshold, on
|
||||
-- every platform (was NX-only; desktop hit the same hitch, see the
|
||||
-- scroll-sluggishness investigation).
|
||||
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 +178,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 +199,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)
|
||||
@@ -263,12 +335,22 @@ end
|
||||
local function textWidth(size, text) return mfont(size):getWidth(text) end
|
||||
local function textHeight(size) return mfont(size):getHeight() end
|
||||
|
||||
-- wrapped text height at a width, from the same font the element renders
|
||||
-- wrapped text height at a width, from the same font the element renders.
|
||||
-- Memoized: the immediate-mode rebuild asks for the same (size, width,
|
||||
-- text) every frame for every visible row, and Font:getWrap re-shapes the
|
||||
-- whole string each time - one of the hottest calls in the frame on mobile.
|
||||
-- The key space is small (mod summaries and notes at a handful of widths).
|
||||
local wrapHeightCache = {}
|
||||
local function wrapHeight(size, text, width)
|
||||
if not text or text == "" or (width or 0) <= 0 then return 0 end
|
||||
local key = size .. ":" .. width .. ":" .. text
|
||||
local h = wrapHeightCache[key]
|
||||
if h then return h end
|
||||
local f = mfont(size)
|
||||
local _, lines = f:getWrap(text, width)
|
||||
return math.max(1, #lines) * f:getHeight()
|
||||
h = math.max(1, #lines) * f:getHeight()
|
||||
wrapHeightCache[key] = h
|
||||
return h
|
||||
end
|
||||
|
||||
-- Every text size in this file is already scaled by m.s, so FlexLove's own
|
||||
@@ -352,6 +434,45 @@ local function button(imp, parent, key, text, opts)
|
||||
return mk(p)
|
||||
end
|
||||
|
||||
-- Measured single-row width of a header's labels and buttons, using the
|
||||
-- same integer-sized fonts label()/button() render with (button() adds
|
||||
-- 12px horizontal padding + 1px border per side). Tab headers use this to
|
||||
-- decide between one row and a split title/buttons pair: flexWrap cannot
|
||||
-- save a narrow window here, because the engine does not grow an
|
||||
-- auto-sized parent for wrapped children (#748 family), so a wrapped
|
||||
-- button used to land on top of whatever followed the header.
|
||||
local function headNeededW(m, items)
|
||||
local w, n = 0, 0
|
||||
for _, it in ipairs(items) do
|
||||
local size = math.floor(it.size + 0.5)
|
||||
local tw = math.ceil(textWidth(size, it.text))
|
||||
w = w + (it.btn and (tw + 26) or tw)
|
||||
n = n + 1
|
||||
end
|
||||
-- inter-item gaps, plus one gap of slack where the flex spacer sits
|
||||
return w + n * (10 * m.s)
|
||||
end
|
||||
|
||||
-- One header row when everything fits, otherwise a title row and a
|
||||
-- right-aligned button row stacked under it. Returns the row for the
|
||||
-- title/status labels and a function to call AFTER adding them, which
|
||||
-- returns the row for the buttons (inserting the flex spacer so buttons
|
||||
-- sit flush right in both shapes).
|
||||
local function headRows(parent, m, items)
|
||||
local split = headNeededW(m, items) > (parent._innerW or m.contentW)
|
||||
local function row()
|
||||
return mk({ parent = parent, width = "100%",
|
||||
positioning = "flex", flexDirection = "horizontal",
|
||||
alignItems = "center", gap = 10 * m.s })
|
||||
end
|
||||
local titleRow = row()
|
||||
return titleRow, function()
|
||||
local btnRow = split and row() or titleRow
|
||||
mk({ parent = btnRow, flex = 1 })
|
||||
return btnRow
|
||||
end
|
||||
end
|
||||
|
||||
local function card(parent, props)
|
||||
local p = {
|
||||
parent = parent,
|
||||
@@ -639,8 +760,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 +784,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 +799,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 +838,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 +851,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 +1023,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,36 +1055,49 @@ 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
|
||||
|
||||
local function buildModsPanel(imp, parent, m)
|
||||
imp:_ensureMods()
|
||||
local ModUpdate = require("src.mods.ModUpdate")
|
||||
local mods = imp.mods or {}
|
||||
local enabledCount = 0
|
||||
for _, mod in ipairs(mods) do
|
||||
if mod.enabled then enabledCount = enabledCount + 1 end
|
||||
end
|
||||
|
||||
local head = mk({ parent = parent, width = "100%",
|
||||
positioning = "flex", flexDirection = "horizontal", flexWrap = "wrap",
|
||||
alignItems = "center", gap = 10 * m.s })
|
||||
label(head, Strings("Mods"), 22 * m.s + 4, C("white"), { textWrap = false })
|
||||
label(head, Strings("%d of %d enabled", enabledCount, #mods),
|
||||
12 * m.s + 2, C("warn"), { textWrap = false })
|
||||
mk({ parent = head, flex = 1 })
|
||||
local title = Strings("Mods")
|
||||
local count = Strings("%d of %d enabled", enabledCount, #mods)
|
||||
local importLabel = imp:_modsImportButtonLabel()
|
||||
local items = {
|
||||
{ size = 22 * m.s + 4, text = title },
|
||||
{ size = 12 * m.s + 2, text = count },
|
||||
{ size = 13 * m.s + 1, text = importLabel, btn = true },
|
||||
}
|
||||
if #mods > 0 then
|
||||
button(imp, head, "mods-enable-all", Strings("Enable all"), {
|
||||
items[#items + 1] = { size = 11 * m.s + 1, text = Strings("Enable all"), btn = true }
|
||||
items[#items + 1] = { size = 11 * m.s + 1, text = Strings("Disable all"), btn = true }
|
||||
end
|
||||
local head, buttonsRow = headRows(parent, m, items)
|
||||
label(head, title, 22 * m.s + 4, C("white"), { textWrap = false })
|
||||
label(head, count, 12 * m.s + 2, C("warn"), { textWrap = false })
|
||||
local btnRow = buttonsRow()
|
||||
if #mods > 0 then
|
||||
button(imp, btnRow, "mods-enable-all", Strings("Enable all"), {
|
||||
size = 11 * m.s + 1, kind = "neutral",
|
||||
action = function() imp:_setAllMods(true) end,
|
||||
})
|
||||
button(imp, head, "mods-disable-all", Strings("Disable all"), {
|
||||
button(imp, btnRow, "mods-disable-all", Strings("Disable all"), {
|
||||
size = 11 * m.s + 1, kind = "neutral",
|
||||
action = function() imp:_setAllMods(false) end,
|
||||
})
|
||||
end
|
||||
button(imp, head, "mods-import", Strings("Import mod .zip"), {
|
||||
button(imp, btnRow, "mods-import", importLabel, {
|
||||
h = m.btnH, size = 13 * m.s + 1, kind = "neutral",
|
||||
action = function() imp:chooseMod() end,
|
||||
})
|
||||
@@ -972,8 +1106,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,13 +1117,94 @@ 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
|
||||
|
||||
-- Sort row: Name / Popularity / Release date / Last updated. The choice
|
||||
-- persists in options.modSort; data-less mods (no github field, or a
|
||||
-- cache that predates the feature) sink to the bottom of data sorts.
|
||||
local sortKey = imp.modSort or "name"
|
||||
if imp.modSort == nil then
|
||||
local ok, opts = pcall(require("src.core.SaveData").loadOptions)
|
||||
if ok and type(opts) == "table" and type(opts.modSort) == "string" then
|
||||
sortKey = opts.modSort
|
||||
imp.modSort = sortKey
|
||||
end
|
||||
end
|
||||
local sortRow = mk({ parent = parent, width = "100%",
|
||||
positioning = "flex", flexDirection = "horizontal",
|
||||
flexWrap = "wrap", alignItems = "center", gap = 6 * m.s })
|
||||
label(sortRow, Strings("Sort:"), 11 * m.s + 2, C("detail"), { textWrap = false })
|
||||
local sorts = {
|
||||
{ key = "name", label = Strings("Name") },
|
||||
{ key = "popularity", label = Strings("Popularity") },
|
||||
{ key = "release", label = Strings("Release date") },
|
||||
{ key = "updated", label = Strings("Last updated") },
|
||||
}
|
||||
for _, s in ipairs(sorts) do
|
||||
local active = sortKey == s.key
|
||||
local key = "mod-sort-" .. s.key
|
||||
mk({
|
||||
parent = sortRow, text = s.label,
|
||||
textColor = active and C("green")
|
||||
or (imp._hot[key] and C("white") or C("detail")),
|
||||
textSize = 11 * m.s + 2, textAlign = "center-center", autoScaleText = false,
|
||||
backgroundColor = active and C("green", 0.18) or C("border", 0.10),
|
||||
border = 1,
|
||||
borderColor = active and C("green", 0.6) or C("border", 0.35),
|
||||
cornerRadius = 999,
|
||||
padding = { horizontal = 10, vertical = 4 },
|
||||
onEvent = handler(imp, key, function()
|
||||
imp.modSort = s.key
|
||||
pcall(function()
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local opts = SaveData.loadOptions()
|
||||
opts.modSort = s.key
|
||||
SaveData.saveOptions(opts)
|
||||
end)
|
||||
end),
|
||||
})
|
||||
end
|
||||
|
||||
-- Immediate mode rebuilds this panel every frame; re-sorting the whole
|
||||
-- mod list per frame (with lowercased-string allocations in the
|
||||
-- comparator) fed the GC for nothing. Cache the sorted array, keyed on
|
||||
-- the list identity/length, the sort mode, and the update-info revision
|
||||
-- that _syncModUpdateInfo bumps when release data changes.
|
||||
local cache = imp._modSortCache
|
||||
if cache and cache.src == mods and cache.n == #mods
|
||||
and cache.key == sortKey and cache.rev == (imp._modUpdateRev or 0) then
|
||||
mods = cache.list
|
||||
else
|
||||
local sorted = {}
|
||||
for i, v in ipairs(mods) do sorted[i] = v end
|
||||
table.sort(sorted, function(a, b)
|
||||
local function value(mod)
|
||||
if sortKey == "name" then return (mod.name or ""):lower() end
|
||||
local info = mod.github and mod.github ~= "" and imp:_modUpdateInfo(mod.id)
|
||||
if sortKey == "popularity" then
|
||||
return info and info.downloads and info.downloads.total or -1
|
||||
end
|
||||
local date = info and info.dates
|
||||
if sortKey == "release" then
|
||||
return date and date.first or "0000-00-00"
|
||||
end
|
||||
return date and date.latest or "0000-00-00"
|
||||
end
|
||||
local va, vb = value(a), value(b)
|
||||
if va ~= vb then
|
||||
if sortKey == "name" then return va < vb end
|
||||
return va > vb -- data sorts newest / most popular first
|
||||
end
|
||||
return (a.name or ""):lower() < (b.name or ""):lower()
|
||||
end)
|
||||
imp._modSortCache = { src = mods, n = #mods, key = sortKey,
|
||||
rev = imp._modUpdateRev or 0, list = sorted }
|
||||
mods = sorted
|
||||
end
|
||||
|
||||
-- Explicit column widths AND heights: a flex-grown container collapses
|
||||
-- its children's layout in this engine, and card auto-height came up
|
||||
-- short on some displays, dropping the bottom action row out of the card.
|
||||
@@ -1022,6 +1236,19 @@ local function buildModsPanel(imp, parent, m)
|
||||
elseif mod.github and mod.github ~= "" then
|
||||
checkLine, checkCol = Strings("Not checked for updates yet"), "warn"
|
||||
end
|
||||
-- Total downloads plus first/latest release dates, all from the same
|
||||
-- cached release fetch. Only shown once that data actually carries
|
||||
-- counts, so a pre-downloads cache entry costs the line, not a wrong "0".
|
||||
local dlLine
|
||||
if info and info.downloads then
|
||||
local formatted = ModUpdate.formatCount(info.downloads.total)
|
||||
if info.dates then
|
||||
dlLine = Strings("%s downloads across all releases - Released %s - Updated %s",
|
||||
formatted, info.dates.first, info.dates.latest)
|
||||
else
|
||||
dlLine = Strings("%s downloads across all releases", formatted)
|
||||
end
|
||||
end
|
||||
|
||||
-- measure the body: name (with the badge beside it only when it fits),
|
||||
-- version, check line, wrapped description
|
||||
@@ -1035,6 +1262,9 @@ local function buildModsPanel(imp, parent, m)
|
||||
if checkLine then
|
||||
bodyH = bodyH + 4 + wrapHeight(smallSize, checkLine, bodyW)
|
||||
end
|
||||
if dlLine then
|
||||
bodyH = bodyH + 4 + wrapHeight(smallSize, dlLine, bodyW)
|
||||
end
|
||||
if mod.description ~= "" then
|
||||
bodyH = bodyH + 4 + wrapHeight(smallSize, mod.description, bodyW)
|
||||
end
|
||||
@@ -1086,6 +1316,9 @@ local function buildModsPanel(imp, parent, m)
|
||||
if checkLine then
|
||||
label(body, checkLine, smallSize, C(checkCol), { width = "100%" })
|
||||
end
|
||||
if dlLine then
|
||||
label(body, dlLine, smallSize, C("gold"), { width = "100%" })
|
||||
end
|
||||
if mod.description ~= "" then
|
||||
label(body, mod.description, smallSize, C("detail"), { width = "100%" })
|
||||
end
|
||||
@@ -1143,18 +1376,26 @@ local function buildFindPanel(imp, parent, m)
|
||||
local rows = imp:_findRows()
|
||||
local total = #((imp.findIndex and imp.findIndex.mods) or {})
|
||||
|
||||
local head = mk({ parent = parent, width = "100%",
|
||||
positioning = "flex", flexDirection = "horizontal", flexWrap = "wrap",
|
||||
alignItems = "center", gap = 10 * m.s })
|
||||
label(head, Strings("Find Mods"), 22 * m.s + 4, C("white"), { textWrap = false })
|
||||
local title = Strings("Find Mods")
|
||||
local count = (#sources > 0) and ((#rows == total) and Strings("%d mods listed", total)
|
||||
or Strings("%d of %d mods", #rows, total)) or nil
|
||||
local addLabel = (#sources == 0) and Strings("Add an index") or Strings("Add index")
|
||||
local items = {
|
||||
{ size = 22 * m.s + 4, text = title },
|
||||
{ size = 13 * m.s + 1, text = addLabel, btn = true },
|
||||
}
|
||||
if count then items[#items + 1] = { size = 12 * m.s + 2, text = count } end
|
||||
if #sources > 0 then
|
||||
label(head, (#rows == total) and Strings("%d mods listed", total)
|
||||
or Strings("%d of %d mods", #rows, total), 12 * m.s + 2, C("warn"),
|
||||
{ textWrap = false })
|
||||
items[#items + 1] = { size = 13 * m.s + 1, text = Strings("Refresh"), btn = true }
|
||||
end
|
||||
mk({ parent = head, flex = 1 })
|
||||
local head, buttonsRow = headRows(parent, m, items)
|
||||
label(head, title, 22 * m.s + 4, C("white"), { textWrap = false })
|
||||
if count then
|
||||
label(head, count, 12 * m.s + 2, C("warn"), { textWrap = false })
|
||||
end
|
||||
local btnRow = buttonsRow()
|
||||
if #sources > 0 then
|
||||
button(imp, head, "find-refresh", Strings("Refresh"), {
|
||||
button(imp, btnRow, "find-refresh", Strings("Refresh"), {
|
||||
h = m.btnH, size = 13 * m.s + 1, kind = "neutral",
|
||||
action = function()
|
||||
imp._findSearchFocus = false
|
||||
@@ -1163,11 +1404,10 @@ local function buildFindPanel(imp, parent, m)
|
||||
end,
|
||||
})
|
||||
end
|
||||
button(imp, head, "find-add",
|
||||
(#sources == 0) and Strings("Add an index") or Strings("Add index"), {
|
||||
h = m.btnH, size = 13 * m.s + 1, kind = "neutral",
|
||||
action = function() imp:_promptAddIndex() end,
|
||||
})
|
||||
button(imp, btnRow, "find-add", addLabel, {
|
||||
h = m.btnH, size = 13 * m.s + 1, kind = "neutral",
|
||||
action = function() imp:_promptAddIndex() end,
|
||||
})
|
||||
|
||||
if imp.findNotice then
|
||||
label(parent, imp.findNotice.text, 12 * m.s + 2,
|
||||
@@ -1214,8 +1454,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 +2016,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),
|
||||
|
||||
+671
-44
@@ -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
|
||||
@@ -2042,6 +2593,8 @@ function RomImporter:_syncModUpdateInfo(force)
|
||||
latest = best and best.version or nil,
|
||||
best = best,
|
||||
releases = packed.releases,
|
||||
downloads = ModUpdate.totalDownloads(packed.releases),
|
||||
dates = ModUpdate.releaseDates(packed.releases),
|
||||
err = nil,
|
||||
checkedAt = packed.checkedAt or os.time(),
|
||||
}
|
||||
@@ -2058,6 +2611,9 @@ function RomImporter:_syncModUpdateInfo(force)
|
||||
self.modUpdateInfo[m.id] = nil
|
||||
end
|
||||
end
|
||||
-- Bump so the view's sorted-list cache (keyed on this revision) rebuilds
|
||||
-- when release/download data actually changes, not every frame.
|
||||
self._modUpdateRev = (self._modUpdateRev or 0) + 1
|
||||
end
|
||||
|
||||
function RomImporter:_modUpdateInfo(id)
|
||||
@@ -2143,6 +2699,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
|
||||
@@ -2166,6 +2727,8 @@ function RomImporter:_modGithubAction(id, action)
|
||||
self.modUpdateInfo[row.id] = {
|
||||
status = status, latest = best and best.version, best = best,
|
||||
releases = releases,
|
||||
downloads = ModUpdate.totalDownloads(releases),
|
||||
dates = ModUpdate.releaseDates(releases),
|
||||
}
|
||||
self._modVersions = {
|
||||
id = row.id, name = row.name, current = row.version,
|
||||
@@ -2209,6 +2772,8 @@ function RomImporter:_modGithubAction(id, action)
|
||||
self.modUpdateInfo[row.id] = {
|
||||
status = status, latest = best and best.version, best = best,
|
||||
releases = releases, checkedAt = os.time(),
|
||||
downloads = ModUpdate.totalDownloads(releases),
|
||||
dates = ModUpdate.releaseDates(releases),
|
||||
}
|
||||
if status == "available" and best then
|
||||
self.modNotice = { ok = true,
|
||||
@@ -2282,6 +2847,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 +2921,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 = {}, {}, {}, {}, {}
|
||||
@@ -2372,12 +2989,22 @@ end
|
||||
-- The rows the filters leave, and the installed-mod context the compatibility
|
||||
-- warnings are judged against.
|
||||
function RomImporter:_findRows()
|
||||
local ModIndex = require("src.mods.ModIndex")
|
||||
local all = (self.findIndex and self.findIndex.mods) or {}
|
||||
return ModIndex.filter(all, {
|
||||
-- The view asks every frame (immediate mode); only re-filter when the
|
||||
-- index, query, or category actually changed.
|
||||
local c = self._findRowsCache
|
||||
if c and c.src == all and c.query == self.findQuery
|
||||
and c.category == self.findCategory then
|
||||
return c.rows
|
||||
end
|
||||
local ModIndex = require("src.mods.ModIndex")
|
||||
local rows = ModIndex.filter(all, {
|
||||
query = self.findQuery,
|
||||
category = self.findCategory,
|
||||
})
|
||||
self._findRowsCache = { src = all, query = self.findQuery,
|
||||
category = self.findCategory, rows = rows }
|
||||
return rows
|
||||
end
|
||||
|
||||
function RomImporter:_findInstalledMap()
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -12,9 +12,19 @@
|
||||
|
||||
local Flags = require("src.script.Flags")
|
||||
local Strings = require("src.core.Strings")
|
||||
local romText = require("src.core.RomText")
|
||||
|
||||
local ItemEffects = {}
|
||||
|
||||
local function notTime(data, save)
|
||||
return romText(data, "_ItemUseNotTimeText",
|
||||
"OAK: %s!\nThis isn't the\ntime to use that!", save.player.name)
|
||||
end
|
||||
|
||||
local function noEffect(data)
|
||||
return romText(data, "_ItemUseNoEffectText", "It won't have\nany effect.")
|
||||
end
|
||||
|
||||
local HEAL_AMOUNT = {
|
||||
POTION = 20, SUPER_POTION = 50, HYPER_POTION = 200,
|
||||
FRESH_WATER = 50, SODA_POP = 60, LEMONADE = 80,
|
||||
@@ -82,6 +92,14 @@ local function cureActiveToxic(battle, target)
|
||||
end
|
||||
end
|
||||
|
||||
-- the per-item cure lines (item_effects.asm .cureStatusAilment picks the
|
||||
-- text by item id); FULL_RESTORE lands here too when it acts as a cure
|
||||
local CURE_TEXT = {
|
||||
ANTIDOTE = "_AntidoteText", BURN_HEAL = "_BurnHealText",
|
||||
ICE_HEAL = "_IceHealText", AWAKENING = "_AwakeningText",
|
||||
PARLYZ_HEAL = "_ParlyzHealText", FULL_HEAL = "_FullHealText",
|
||||
}
|
||||
|
||||
-- battle-only stat boosters (engine/items/item_effects.asm ItemUseXStat)
|
||||
local X_ITEMS = {
|
||||
X_ATTACK = "attack", X_DEFEND = "defense", X_SPEED = "speed",
|
||||
@@ -130,8 +148,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
if battle and (VITAMINS[itemId] or STONES[itemId] or itemId == "PP_UP"
|
||||
or itemId == "RARE_CANDY" or itemId == "COIN_CASE"
|
||||
or (itemDef and itemDef.machine)) then
|
||||
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!",
|
||||
save.player.name) }
|
||||
return "failed", { notTime(data, save) }
|
||||
end
|
||||
|
||||
if BALLS[itemId] then
|
||||
@@ -148,13 +165,14 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
-- data/scripts/story.lua's snorlaxWake)
|
||||
local mapId, npc = adjacentSleepingSnorlax(save, ow)
|
||||
if npc then
|
||||
return "flute_wake", { data.text._PlayedFluteHadEffectText
|
||||
or Strings("{PLAYER} played the\nPOKé FLUTE.") },
|
||||
return "flute_wake", { romText(data, "_PlayedFluteHadEffectText",
|
||||
"{PLAYER} played the\nPOKé FLUTE.") },
|
||||
{ mapId = mapId, npc = npc }
|
||||
end
|
||||
-- otherwise: play the tune, nothing happens (ItemUsePokeFlute's
|
||||
-- PlayedFluteNoEffectText branch)
|
||||
return "flute_field", { Strings("Played the POKé\nFLUTE.\fNow, that's a\ncatchy tune!") }
|
||||
return "flute_field", { romText(data, "_PlayedFluteNoEffectText",
|
||||
"Played the POKé\nFLUTE.\fNow, that's a\ncatchy tune!") }
|
||||
end
|
||||
local woke = false
|
||||
local function wake(mon)
|
||||
@@ -169,17 +187,20 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
-- WakeUpEntireParty runs on the enemy's bench too
|
||||
for _, mon in ipairs(battle.enemyParty or {}) do wake(mon) end
|
||||
if not woke then
|
||||
return "failed", { Strings("Played the POKé\nFLUTE.\fNow, that's a\ncatchy tune!") }
|
||||
return "failed", { romText(data, "_PlayedFluteNoEffectText",
|
||||
"Played the POKé\nFLUTE.\fNow, that's a\ncatchy tune!") }
|
||||
end
|
||||
return "flute", { Strings("%s played the\nPOKé FLUTE.", save.player.name),
|
||||
Strings("All sleeping\nPOKéMON woke up!") }
|
||||
return "flute", { romText(data, "_PlayedFluteHadEffectText",
|
||||
"%s played the\nPOKé FLUTE.", save.player.name),
|
||||
romText(data, "_FluteWokeUpText",
|
||||
"All sleeping\nPOKéMON woke up!") }
|
||||
end
|
||||
|
||||
-- battle-only items
|
||||
if X_ITEMS[itemId] or itemId == "DIRE_HIT" or itemId == "GUARD_SPEC"
|
||||
or itemId == "POKE_DOLL" then
|
||||
if not battle then
|
||||
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
|
||||
return "failed", { notTime(data, save) }
|
||||
end
|
||||
local b = battle.player
|
||||
-- PIKAHAPPY_USEDXITEM (item_effects.asm ItemUseXAccuracy /
|
||||
@@ -201,7 +222,8 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
-- effect, so at +6 it is still consumed and StatModifierUpEffect
|
||||
-- just prints "Nothing happened!"
|
||||
if cur >= 6 then
|
||||
return "consumed", { Strings("Nothing happened!") }
|
||||
return "consumed", { romText(data, "_NothingHappenedText",
|
||||
"Nothing happened!") }
|
||||
end
|
||||
b.stages[stat] = cur + 1
|
||||
return "consumed", { Strings("%s's\n%s rose!", b.name, stat:upper()) }
|
||||
@@ -210,7 +232,8 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
-- the item, even when it is already active
|
||||
if itemId == "DIRE_HIT" then
|
||||
b.focusEnergy = true
|
||||
return "consumed", { Strings("%s's\ngetting pumped!", b.name) }
|
||||
return "consumed", { romText(data, "_GettingPumpedText",
|
||||
"%s's\ngetting pumped!", b.name) }
|
||||
end
|
||||
if itemId == "GUARD_SPEC" then
|
||||
b.mist = true
|
||||
@@ -219,10 +242,10 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
if itemId == "POKE_DOLL" then
|
||||
if battle.kind ~= "wild" then
|
||||
-- ItemUsePokeDoll jumps to ItemUseNotTime in trainer battles
|
||||
return "failed", { Strings(
|
||||
"OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
|
||||
return "failed", { notTime(data, save) }
|
||||
end
|
||||
return "consumed_escape", { Strings("The wild POKéMON\nran away!") }
|
||||
return "consumed_escape", { romText(data, "_WildRanText",
|
||||
"The wild POKéMON\nran away!", battle.enemy and battle.enemy.name) }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -231,7 +254,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
-- restore every move with no menu.
|
||||
if itemId == "ETHER" or itemId == "MAX_ETHER"
|
||||
or itemId == "ELIXER" or itemId == "MAX_ELIXER" then
|
||||
if not target then return "failed", { Strings("It won't have\nany effect.") } end
|
||||
if not target then return "failed", { noEffect(data) } end
|
||||
local restored = false
|
||||
local full = itemId == "MAX_ETHER" or itemId == "MAX_ELIXER"
|
||||
local allMoves = itemId == "ELIXER" or itemId == "MAX_ELIXER"
|
||||
@@ -253,9 +276,10 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
restored = mv and restore(mv) or false
|
||||
end
|
||||
if not restored then
|
||||
return "failed", { Strings("It won't have\nany effect.") }
|
||||
return "failed", { noEffect(data) }
|
||||
end
|
||||
return "consumed", { Strings("%s's PP\nwas restored!", monName(data, target)) }
|
||||
-- pokered's line names no mon, so the extracted text takes no args
|
||||
return "consumed", { romText(data, "_PPRestoredText", "PP was restored.") }
|
||||
end
|
||||
|
||||
-- PIKAHAPPY_USEDITEM (item_effects.asm ItemUseMedicine, item id up to
|
||||
@@ -280,10 +304,11 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
target.status = nil
|
||||
cureActiveToxic(battle, target)
|
||||
require("src.core.Sound").play(data, "Heal_Ailment")
|
||||
return "consumed", { Strings("%s's\nstatus returned\nto normal!", monName(data, target)) }
|
||||
return "consumed", { romText(data, CURE_TEXT.FULL_HEAL,
|
||||
"%s's\nstatus returned\nto normal!", monName(data, target)) }
|
||||
end
|
||||
if not target or target.hp <= 0 or target.hp >= target.stats.hp then
|
||||
return "failed", { Strings("It won't have\nany effect.") }
|
||||
return "failed", { noEffect(data) }
|
||||
end
|
||||
-- wHPBarOldHP: the bar animation starts from the HP the mon had BEFORE
|
||||
-- the item landed (item_effects.asm latches it with the party menu still
|
||||
@@ -295,7 +320,10 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
else
|
||||
target.hp = math.min(target.stats.hp, target.hp + heal)
|
||||
end
|
||||
local msgs = { Strings("%s's HP\nwas restored!", monName(data, target)) }
|
||||
-- _PotionText's second slot is the recovered amount ({NUM:
|
||||
-- wHPBarHPDifference}); the engine fallback never prints it
|
||||
local msgs = { romText(data, "_PotionText", "%s's HP\nwas restored!",
|
||||
monName(data, target), target.hp - before) }
|
||||
if itemId == "FULL_RESTORE" then
|
||||
target.status = nil
|
||||
cureActiveToxic(battle, target)
|
||||
@@ -307,17 +335,18 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
local cures = STATUS_HEAL[itemId]
|
||||
if cures then
|
||||
if not target or not target.status or not cures[target.status] then
|
||||
return "failed", { Strings("It won't have\nany effect.") }
|
||||
return "failed", { noEffect(data) }
|
||||
end
|
||||
target.status = nil
|
||||
cureActiveToxic(battle, target)
|
||||
require("src.core.Sound").play(data, "Heal_Ailment")
|
||||
return "consumed", { Strings("%s's\nstatus returned\nto normal!", monName(data, target)) }
|
||||
return "consumed", { romText(data, CURE_TEXT[itemId],
|
||||
"%s's\nstatus returned\nto normal!", monName(data, target)) }
|
||||
end
|
||||
|
||||
if itemId == "REVIVE" or itemId == "MAX_REVIVE" then
|
||||
if not target or target.hp > 0 then
|
||||
return "failed", { Strings("It won't have\nany effect.") }
|
||||
return "failed", { noEffect(data) }
|
||||
end
|
||||
target.status = nil
|
||||
target.hp = itemId == "REVIVE" and math.floor(target.stats.hp / 2) or target.stats.hp
|
||||
@@ -329,13 +358,14 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
if battle and battle.participants then
|
||||
battle.participants[target] = true
|
||||
end
|
||||
return "consumed", { Strings("%s\nis revitalized!", monName(data, target)) },
|
||||
return "consumed", { romText(data, "_ReviveText",
|
||||
"%s\nis revitalized!", monName(data, target)) },
|
||||
{ healedFrom = 0 }
|
||||
end
|
||||
|
||||
if itemId == "RARE_CANDY" then
|
||||
if not target or target.level >= 100 then
|
||||
return "failed", { Strings("It won't have\nany effect.") }
|
||||
return "failed", { noEffect(data) }
|
||||
end
|
||||
local Growth = require("src.pokemon.Growth")
|
||||
local Stats = require("src.pokemon.Stats")
|
||||
@@ -348,12 +378,13 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
-- PIKAHAPPY_LEVELUP on a candy level (item_effects.asm:1540)
|
||||
require("src.world.PikachuFollower")
|
||||
.modifyHappiness(save, "LEVELUP", target)
|
||||
return "consumed", { Strings("%s grew\nto level %d!", monName(data, target), target.level) },
|
||||
return "consumed", { romText(data, "_RareCandyText",
|
||||
"%s grew\nto level %d!", monName(data, target), target.level) },
|
||||
{ leveledTo = target.level }
|
||||
end
|
||||
|
||||
if STONES[itemId] then
|
||||
if not target then return "failed", { Strings("It won't have\nany effect.") } end
|
||||
if not target then return "failed", { noEffect(data) } end
|
||||
-- Yellow's starter Pikachu never evolves: ItemUseEvoStone runs
|
||||
-- IsThisPartyMonStarterPikachu (OT identity match) before
|
||||
-- TryEvolvingMon and bails with the voiced cry + RefusingText.
|
||||
@@ -363,10 +394,8 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
and target.ot == save.player.name
|
||||
and target.otId == save.player.id then
|
||||
require("src.core.Sound").playCry(data, "PIKACHU")
|
||||
local raw = data.text and data.text._RefusingText
|
||||
local line = raw and raw:gsub("{RAM:[^}]*}", monName(data, target))
|
||||
or Strings("%s\nis refusing!", monName(data, target))
|
||||
return "failed", { line }
|
||||
return "failed", { romText(data, "_RefusingText",
|
||||
"%s\nis refusing!", monName(data, target)) }
|
||||
end
|
||||
local speciesDef = data.pokemon[target.species]
|
||||
for _, evo in ipairs(speciesDef.evolutions) do
|
||||
@@ -374,44 +403,48 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
return "consumed", nil, { evolveTo = evo.species }
|
||||
end
|
||||
end
|
||||
return "failed", { Strings("It won't have\nany effect.") }
|
||||
return "failed", { noEffect(data) }
|
||||
end
|
||||
|
||||
-- vitamins: +2560 stat exp, refused at 25600+ (ItemUseVitamin,
|
||||
-- engine/items/item_effects.asm)
|
||||
local vitaminStat = VITAMINS[itemId]
|
||||
if vitaminStat then
|
||||
if not target then return "failed", { Strings("It won't have\nany effect.") } end
|
||||
if not target then return "failed", { noEffect(data) } end
|
||||
target.statExp = target.statExp or {}
|
||||
local cur = target.statExp[vitaminStat] or 0
|
||||
if cur >= 25600 then
|
||||
return "failed", { Strings("It won't have\nany effect.") }
|
||||
return "failed", { noEffect(data) }
|
||||
end
|
||||
target.statExp[vitaminStat] = math.min(65535, cur + 2560)
|
||||
local Stats = require("src.pokemon.Stats")
|
||||
target.stats = Stats.calc(data.pokemon[target.species], target.level,
|
||||
target.dvs, target.statExp)
|
||||
target.hp = math.min(target.hp, target.stats.hp)
|
||||
-- _VitaminStatRoseText's slot order is localization-dependent (the
|
||||
-- Spanish ROM puts the stat before the name), so the extracted line
|
||||
-- cannot be filled positionally; the engine wording stands
|
||||
return "consumed", { Strings("%s's %s\nrose!", monName(data, target),
|
||||
vitaminStat == "hp" and "HP" or vitaminStat:upper()) }
|
||||
end
|
||||
|
||||
-- PP UP boosts the move the player picked (ItemUsePPUp's move menu)
|
||||
if itemId == "PP_UP" then
|
||||
if not target then return "failed", { Strings("It won't have\nany effect.") } end
|
||||
if not target then return "failed", { noEffect(data) } end
|
||||
local mv = target.moves[moveIndex or 1]
|
||||
local mdef = mv and data.moves[mv.id]
|
||||
if mdef and (mv.ppUps or 0) < 3 then
|
||||
mv.ppUps = (mv.ppUps or 0) + 1
|
||||
-- each PP UP adds maxPP/5 uses on top of the base maximum
|
||||
mv.pp = mv.pp + math.floor(mdef.pp / 5)
|
||||
return "consumed", { Strings("%s's PP\nincreased!", mdef.name) }
|
||||
return "consumed", { romText(data, "_PPIncreasedText",
|
||||
"%s's PP\nincreased!", mdef.name) }
|
||||
end
|
||||
return "failed", { Strings("It won't have\nany effect.") }
|
||||
return "failed", { noEffect(data) }
|
||||
end
|
||||
|
||||
if itemDef and itemDef.machine then
|
||||
if not target then return "failed", { Strings("It won't have\nany effect.") } end
|
||||
if not target then return "failed", { noEffect(data) } end
|
||||
local speciesDef = data.pokemon[target.species]
|
||||
local ok = false
|
||||
for _, m in ipairs(speciesDef.tmhm) do
|
||||
@@ -422,11 +455,16 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
-- plays SFX_DENIED before MonCannotLearnMachineMoveText (the generic
|
||||
-- ItemUseNotTime/NoCyclingAllowedHere paths are silent)
|
||||
require("src.core.Sound").play(data, "Denied")
|
||||
return "failed", { Strings("%s can't\nlearn that move!", monName(data, target)) }
|
||||
local moveName = data.moves[itemDef.machine.move].name
|
||||
return "failed", { romText(data, "_MonCannotLearnMachineMoveText",
|
||||
"%s can't\nlearn that move!",
|
||||
monName(data, target), moveName, moveName) }
|
||||
end
|
||||
for _, mv in ipairs(target.moves) do
|
||||
if mv.id == itemDef.machine.move then
|
||||
return "failed", { Strings("It knows that\nmove already!") }
|
||||
return "failed", { romText(data, "_AlreadyKnowsText",
|
||||
"It knows that\nmove already!",
|
||||
monName(data, target), data.moves[itemDef.machine.move].name) }
|
||||
end
|
||||
end
|
||||
-- HMs are never consumed; TMs are single-use
|
||||
@@ -435,21 +473,21 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
|
||||
if itemId == "OLD_ROD" or itemId == "GOOD_ROD" or itemId == "SUPER_ROD" then
|
||||
if battle then
|
||||
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
|
||||
return "failed", { notTime(data, save) }
|
||||
end
|
||||
-- FishingInit (engine/items/item_effects.asm): cp wWalkBikeSurfState, 2
|
||||
-- (surfing) sets carry, and every ItemUseXRod does jp c, ItemUseNotTime
|
||||
-- on that carry -- surfing refuses the rod with the same OAK text as
|
||||
-- the mid-battle case above, no rod-specific message (#533)
|
||||
if ow and ow.player and ow.player.surfing then
|
||||
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
|
||||
return "failed", { notTime(data, save) }
|
||||
end
|
||||
return "fish", itemId
|
||||
end
|
||||
|
||||
if itemId == "BICYCLE" then
|
||||
if battle then
|
||||
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
|
||||
return "failed", { notTime(data, save) }
|
||||
end
|
||||
return "bicycle"
|
||||
end
|
||||
@@ -459,18 +497,19 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
end
|
||||
if itemId == "TOWN_MAP" then
|
||||
if battle then
|
||||
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
|
||||
return "failed", { notTime(data, save) }
|
||||
end
|
||||
return "townmap"
|
||||
end
|
||||
if itemId == "ITEMFINDER" then
|
||||
if battle then
|
||||
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
|
||||
return "failed", { notTime(data, save) }
|
||||
end
|
||||
return "itemfinder"
|
||||
end
|
||||
if itemId == "COIN_CASE" then
|
||||
return "failed", { Strings("Coin count:\n%d", save.coins or 0) }
|
||||
return "failed", { romText(data, "_CoinCaseNumCoinsText",
|
||||
"Coin count:\n%d", save.coins or 0) }
|
||||
end
|
||||
if itemId == "REPEL" or itemId == "SUPER_REPEL" or itemId == "MAX_REPEL" then
|
||||
local steps = itemId == "REPEL" and 100 or itemId == "SUPER_REPEL" and 200 or 250
|
||||
@@ -478,7 +517,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
return "consumed", { Strings("%s used\n%s!", save.player.name, name) }
|
||||
end
|
||||
|
||||
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
|
||||
return "failed", { notTime(data, save) }
|
||||
end
|
||||
|
||||
return ItemEffects
|
||||
|
||||
+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))
|
||||
|
||||
+78
-3
@@ -102,7 +102,9 @@ function ModUpdate.previewLine(text, maxChars)
|
||||
return s
|
||||
end
|
||||
|
||||
-- Decode one GitHub release object into { version, tag, zip, prerelease, body }.
|
||||
-- Decode one GitHub release object into { version, tag, zip, prerelease,
|
||||
-- name, body, downloads }. `downloads` is the sum of every asset's
|
||||
-- download_count, GitHub's own measure of a release's downloads.
|
||||
function ModUpdate.parseRelease(doc, modId)
|
||||
if type(doc) ~= "table" or not doc.tag_name then
|
||||
return nil, "no tag_name in release"
|
||||
@@ -114,6 +116,13 @@ function ModUpdate.parseRelease(doc, modId)
|
||||
local triple = version:match("^(%d+%.%d+%.%d+)")
|
||||
local zip = ModUpdate.pickZipAsset(doc.assets, modId, triple)
|
||||
local body = type(doc.body) == "string" and doc.body or ""
|
||||
local downloads = 0
|
||||
if type(doc.assets) == "table" then
|
||||
for _, a in ipairs(doc.assets) do
|
||||
local d = type(a) == "table" and tonumber(a.download_count)
|
||||
if d and d > 0 then downloads = downloads + d end
|
||||
end
|
||||
end
|
||||
return {
|
||||
version = triple,
|
||||
tag = tostring(doc.tag_name),
|
||||
@@ -121,6 +130,8 @@ function ModUpdate.parseRelease(doc, modId)
|
||||
prerelease = doc.prerelease == true,
|
||||
name = type(doc.name) == "string" and doc.name or triple,
|
||||
body = body,
|
||||
downloads = downloads,
|
||||
published = (doc.published_at or doc.created_at or ""):match("^(%d+%-%d+%-%d+)"),
|
||||
}
|
||||
end
|
||||
|
||||
@@ -154,7 +165,7 @@ function ModUpdate.parseReleases(jsonText, modId, Json)
|
||||
end
|
||||
|
||||
function ModUpdate.apiReleasesUrl(repo)
|
||||
return "https://api.github.com/repos/" .. repo .. "/releases?per_page=30"
|
||||
return "https://api.github.com/repos/" .. repo .. "/releases?per_page=100"
|
||||
end
|
||||
|
||||
function ModUpdate.apiLatestUrl(repo)
|
||||
@@ -180,6 +191,52 @@ function ModUpdate.pickBest(releases)
|
||||
return releases[1]
|
||||
end
|
||||
|
||||
-- Sum of per-release download counts. Returns nil when no release carries
|
||||
-- the field (a cache entry written before downloads existed), else
|
||||
-- { total = <sum>, releases = <release count> } so a caller can tell a
|
||||
-- real "0 downloads" apart from "no data yet".
|
||||
function ModUpdate.totalDownloads(releases)
|
||||
if type(releases) ~= "table" or #releases == 0 then return nil end
|
||||
local total, hasData = 0, false
|
||||
for _, rel in ipairs(releases) do
|
||||
local d = type(rel) == "table" and tonumber(rel.downloads)
|
||||
if d then
|
||||
hasData = true
|
||||
total = total + d
|
||||
end
|
||||
end
|
||||
if not hasData then return nil end
|
||||
return { total = total, releases = #releases }
|
||||
end
|
||||
|
||||
-- First and latest release dates ("YYYY-MM-DD", ISO strings compare in
|
||||
-- calendar order). Returns nil when no release carries a date -- same
|
||||
-- "no data yet" rule as totalDownloads.
|
||||
function ModUpdate.releaseDates(releases)
|
||||
if type(releases) ~= "table" or #releases == 0 then return nil end
|
||||
local first, latest, has = nil, nil, false
|
||||
for _, rel in ipairs(releases) do
|
||||
local p = type(rel) == "table" and rel.published
|
||||
if type(p) == "string" and p ~= "" then
|
||||
has = true
|
||||
if not first or p < first then first = p end
|
||||
if not latest or p > latest then latest = p end
|
||||
end
|
||||
end
|
||||
if not has then return nil end
|
||||
return { first = first, latest = latest }
|
||||
end
|
||||
|
||||
-- Thousands-separated count for the launcher ("12,345"), plain for small
|
||||
-- numbers. Never throws; garbage in, "0" out.
|
||||
function ModUpdate.formatCount(n)
|
||||
n = tonumber(n)
|
||||
if not n or n ~= n or n < 0 then return "0" end
|
||||
local s = tostring(math.floor(n))
|
||||
s = s:reverse():gsub("(%d%d%d)", "%1,"):reverse()
|
||||
return (s:gsub("^,", ""))
|
||||
end
|
||||
|
||||
-- ------- cache (options.modUpdateCache[repo])
|
||||
|
||||
local function cacheStore()
|
||||
@@ -210,6 +267,22 @@ function ModUpdate.cacheFresh(entry, now, ttl)
|
||||
and (now - entry.checkedAt) < ttl
|
||||
end
|
||||
|
||||
-- A cache entry written before download counts existed carries no
|
||||
-- `downloads` on any release. It is provably stale -- parseRelease always
|
||||
-- sets the field now -- so treat it as expired: the next fetch rewrites
|
||||
-- the entry in the current format, one refetch per repo, and the launcher's
|
||||
-- download line stops hiding behind an old cache.
|
||||
function ModUpdate.cacheUsable(cached)
|
||||
if type(cached) ~= "table" or type(cached.releases) ~= "table" then
|
||||
return false
|
||||
end
|
||||
if #cached.releases == 0 then return true end
|
||||
for _, rel in ipairs(cached.releases) do
|
||||
if tonumber(rel.downloads) then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
function ModUpdate.writeCache(repo, releases)
|
||||
if type(repo) ~= "string" or repo == "" then return false end
|
||||
local ok = pcall(function()
|
||||
@@ -226,6 +299,8 @@ function ModUpdate.writeCache(repo, releases)
|
||||
name = rel.name,
|
||||
prerelease = rel.prerelease == true,
|
||||
body = type(rel.body) == "string" and rel.body or "",
|
||||
downloads = tonumber(rel.downloads) or 0,
|
||||
published = rel.published,
|
||||
zip = rel.zip and {
|
||||
name = rel.zip.name,
|
||||
url = rel.zip.url,
|
||||
@@ -280,7 +355,7 @@ function ModUpdate.fetchReleases(repo, modId, opts)
|
||||
end
|
||||
if not opts.force then
|
||||
local cached = ModUpdate.readCache(repo)
|
||||
if ModUpdate.cacheFresh(cached) then
|
||||
if ModUpdate.cacheFresh(cached) and ModUpdate.cacheUsable(cached) then
|
||||
return cached.releases, nil, { fromCache = true }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1064,6 +1064,12 @@ local function fontIsCharmap(id)
|
||||
return tostring(id):match("^charmap:.+$") ~= nil
|
||||
end
|
||||
|
||||
-- the third id form: "ttf" switches text rendering to a real TTF (the
|
||||
-- bundled Plain Pixel when `file` is omitted -- src/render/Font.lua)
|
||||
local function fontIsTtf(id)
|
||||
return tostring(id) == "ttf"
|
||||
end
|
||||
|
||||
R.font = {
|
||||
semantics = "record", target = "font",
|
||||
value = f.union{
|
||||
@@ -1071,6 +1077,9 @@ R.font = {
|
||||
advance = f.opt(f.int(1)),
|
||||
charmap = f.opt(f.list(f.rec{ code = f.int(0), seq = f.str })) },
|
||||
f.rec{ seq = f.str, code = f.int(0) },
|
||||
f.rec{ file = f.opt(f.path), size = f.opt(f.int(1)),
|
||||
spacing = f.opt(f.num), yOffset = f.opt(f.num),
|
||||
bold = f.opt(f.bool) },
|
||||
},
|
||||
extra = function(id, value)
|
||||
if fontIsCharmap(id) then
|
||||
@@ -1080,12 +1089,18 @@ R.font = {
|
||||
if type(value.code) ~= "number" then
|
||||
return "a charmap: entry needs a code"
|
||||
end
|
||||
elseif fontIsTtf(id) then
|
||||
-- every field optional: {} is "the bundled font at its native size"
|
||||
if value.image ~= nil or value.base ~= nil then
|
||||
return 'the "ttf" entry takes file/size/spacing/yOffset/bold, not a page'
|
||||
end
|
||||
elseif value.image == nil or value.base == nil then
|
||||
return "a font page needs an image and a base"
|
||||
end
|
||||
end,
|
||||
baseAt = function(base, id)
|
||||
if fontIsCharmap(id) then return nil end
|
||||
if fontIsTtf(id) then return base.ttf end
|
||||
return base.pages and base.pages[id] or nil
|
||||
end,
|
||||
baseIds = function(base)
|
||||
@@ -1096,6 +1111,9 @@ R.font = {
|
||||
write = function(target, registry)
|
||||
local pages = target.pages or {}
|
||||
target.pages = pages
|
||||
-- the extractor never emits a ttf entry, so like the charmap rows it is
|
||||
-- rebuilt from the registry each merge: disabling the mod disables it
|
||||
target.ttf = nil
|
||||
-- the extractor's rows have no id and stay put; the registry's own are
|
||||
-- rebuilt every merge so a re-merge replaces them instead of stacking
|
||||
local rows = {}
|
||||
@@ -1108,6 +1126,8 @@ R.font = {
|
||||
if value ~= nil then
|
||||
rows[#rows + 1] = { id = id, seq = value.seq, code = value.code }
|
||||
end
|
||||
elseif fontIsTtf(id) then
|
||||
target.ttf = value
|
||||
else
|
||||
pages[id] = value
|
||||
end
|
||||
|
||||
@@ -15,6 +15,7 @@ local Screens = require("src.ui.Screens")
|
||||
local Stats = require("src.pokemon.Stats")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Strings = require("src.core.Strings")
|
||||
local romText = require("src.core.RomText")
|
||||
|
||||
local Evolution = {}
|
||||
|
||||
@@ -140,7 +141,8 @@ function Evolution.learnEvolutionMoves(game, mon, onDone)
|
||||
table.insert(mon.moves, { id = moveId, pp = mdef.pp })
|
||||
Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId })
|
||||
game.stack:push(TextBox.new(game,
|
||||
Strings("%s learned\n%s!", name, mdef.name), nextStep))
|
||||
romText(game.data, "_LearnedMove1Text",
|
||||
"%s learned\n%s!", name, mdef.name), nextStep))
|
||||
else
|
||||
-- LearnMoveFromLevelUp with a full moveset: the forget UI
|
||||
Screens.push(game, "MoveLearnMenu", mon, moveId, nextStep)
|
||||
@@ -161,8 +163,12 @@ function Evolution.evolve(game, mon, newSpecies, onDone, via)
|
||||
Music.play(game.data, Music.special(game.data, "evolution"))
|
||||
local oldName = mon.nickname or game.data.pokemon[mon.species].name
|
||||
Evolution.apply(game, mon, newSpecies, via)
|
||||
local msg = Strings("What?\n%s is\nevolving!\fCongratulations!\nYour %s\nevolved into\n%s!",
|
||||
oldName, oldName, game.data.pokemon[newSpecies].name)
|
||||
-- the congrats page keeps the engine wording: _EvolvedText extracts
|
||||
-- truncated (it stops at a dynamic marker the decoder does not follow)
|
||||
local msg = romText(game.data, "_IsEvolvingText",
|
||||
"What?\n%s is\nevolving!", oldName)
|
||||
.. "\f" .. Strings("Congratulations!\nYour %s\nevolved into\n%s!",
|
||||
oldName, game.data.pokemon[newSpecies].name)
|
||||
game.stack:push(TextBox.new(game, msg, function()
|
||||
Music.restoreMap(game.data)
|
||||
-- re-run the evolved species' level-up learn check before onDone
|
||||
|
||||
+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)
|
||||
|
||||
+136
-1
@@ -6,6 +6,13 @@
|
||||
-- for variable-width text; the default is the GB's flat 8px.
|
||||
-- The charmap is matched greedily (longest sequence first) so multi-byte
|
||||
-- UTF-8 chars and ligature glyphs like 'd 'l 's map to single glyphs.
|
||||
--
|
||||
-- A translation may instead set data.font.ttf and render its text through a
|
||||
-- real TTF (the bundled Plain Pixel by default), so a script that would need
|
||||
-- hundreds of page tiles works out of the box. Single characters then draw
|
||||
-- from the TTF; multi-character charmap sequences (<PK>, the 'd ligatures)
|
||||
-- and the sub-0x80 chrome glyphs (borders, arrows) keep their tiles, which
|
||||
-- is why the box border never depends on the TTF's coverage.
|
||||
|
||||
local Assets = require("src.render.Assets")
|
||||
|
||||
@@ -13,6 +20,19 @@ local Font = {}
|
||||
|
||||
local GLYPH = 8
|
||||
|
||||
-- TTF glyph codes are the Unicode codepoint offset far above any page base,
|
||||
-- so they flow through the same span/encode/drawCode pipeline as tiles.
|
||||
local TTF_BASE = 0x400000
|
||||
Font.TTF_BASE = TTF_BASE
|
||||
|
||||
-- The engine's bundled TTF (assets/fonts/plainpixel/README.md: CC-BY 4.0,
|
||||
-- Douglas Vautour). data.font.ttf.file overrides for a mod-shipped font.
|
||||
-- 15 is the font's design em: its glyphs only rasterize at their true
|
||||
-- pixel size (5x11 base, 11x11 double-width) at multiples of 15; at any
|
||||
-- other size they downscale unevenly (at 11, M comes out 4px and A 5px).
|
||||
Font.PLAINPIXEL = "assets/fonts/plainpixel/PlainPixel-Regular.ttf"
|
||||
Font.PLAINPIXEL_SIZE = 15
|
||||
|
||||
local state
|
||||
local loadedFrom
|
||||
|
||||
@@ -83,6 +103,42 @@ function Font.load(data)
|
||||
Font.BORDER = {}
|
||||
for key, code in pairs(Font.DEFAULT_BORDER) do Font.BORDER[key] = code end
|
||||
for key, code in pairs(def.border or {}) do Font.BORDER[key] = code end
|
||||
|
||||
-- TTF mode. All fields optional: {} means "the bundled Plain Pixel at
|
||||
-- its native 11px". A failed load logs and falls back to tiles, so a
|
||||
-- typo'd path degrades exactly like a missing page image does above.
|
||||
if type(def.ttf) == "table" then
|
||||
local file = def.ttf.file or Font.PLAINPIXEL
|
||||
local ok, obj = pcall(love.graphics.newFont, file,
|
||||
def.ttf.size or Font.PLAINPIXEL_SIZE, "mono")
|
||||
if ok and obj then
|
||||
-- nearest keeps the pixel font crisp under the integer UI scale
|
||||
if obj.setFilter then pcall(obj.setFilter, obj, "nearest", "nearest") end
|
||||
state.ttf = {
|
||||
font = obj, file = file,
|
||||
-- the font's own advances already carry a 1px gap at its design
|
||||
-- size; spacing adds to (or, negative, takes from) every advance
|
||||
spacing = def.ttf.spacing or 0,
|
||||
-- bold double-prints each glyph at a 1px offset, for fonts whose
|
||||
-- single-pixel strokes read too light against the tile art
|
||||
bold = def.ttf.bold == true,
|
||||
-- glyphs are taller than the 8px cell (11px base, and the em box
|
||||
-- reserves even more for vertical extension); anchor the font's
|
||||
-- baseline to the tile font's, which sits on row 7 of the cell, so
|
||||
-- caps line up and descenders hang below as the GB font's own do
|
||||
yOffset = def.ttf.yOffset or (obj.getBaseline
|
||||
and (GLYPH - 1 - obj:getBaseline()) or (GLYPH - obj:getHeight())),
|
||||
widths = {}, chars = {},
|
||||
}
|
||||
else
|
||||
require("src.core.Logger").warn("font: could not load ttf %q (%s)",
|
||||
tostring(file), tostring(obj))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function Font.ttfActive()
|
||||
return state ~= nil and state.ttf ~= nil
|
||||
end
|
||||
|
||||
-- re-run load against the data it last saw, so hot reload picks up an
|
||||
@@ -104,6 +160,49 @@ end
|
||||
|
||||
local SPACE = 0x7F
|
||||
|
||||
-- Decode one UTF-8 sequence: codepoint and the index of its last byte, or
|
||||
-- nil on a malformed lead/continuation (the caller falls back to bytes).
|
||||
local function utf8Decode(text, i)
|
||||
local b = text:byte(i)
|
||||
if not b then return nil, i end
|
||||
if b < 0x80 then return b, i end
|
||||
local cont, cp
|
||||
if b >= 0xF0 then cont, cp = 3, b - 0xF0
|
||||
elseif b >= 0xE0 then cont, cp = 2, b - 0xE0
|
||||
elseif b >= 0xC0 then cont, cp = 1, b - 0xC0
|
||||
else return nil, i end
|
||||
for k = i + 1, i + cont do
|
||||
local c = text:byte(k)
|
||||
if not c or c < 0x80 or c > 0xBF then return nil, i end
|
||||
cp = cp * 64 + (c - 0x80)
|
||||
end
|
||||
return cp, i + cont
|
||||
end
|
||||
|
||||
local function utf8Encode(cp)
|
||||
if cp < 0x80 then return string.char(cp) end
|
||||
if cp < 0x800 then
|
||||
return string.char(0xC0 + math.floor(cp / 64), 0x80 + cp % 64)
|
||||
end
|
||||
if cp < 0x10000 then
|
||||
return string.char(0xE0 + math.floor(cp / 4096),
|
||||
0x80 + math.floor(cp / 64) % 64, 0x80 + cp % 64)
|
||||
end
|
||||
return string.char(0xF0 + math.floor(cp / 262144),
|
||||
0x80 + math.floor(cp / 4096) % 64,
|
||||
0x80 + math.floor(cp / 64) % 64, 0x80 + cp % 64)
|
||||
end
|
||||
|
||||
-- the character a TTF code draws, cached per code
|
||||
local function ttfChar(ttf, code)
|
||||
local ch = ttf.chars[code]
|
||||
if not ch then
|
||||
ch = utf8Encode(code - TTF_BASE)
|
||||
ttf.chars[code] = ch
|
||||
end
|
||||
return ch
|
||||
end
|
||||
|
||||
-- Segment text into glyph spans: `{ from, to, code }` byte ranges, one per
|
||||
-- drawn glyph, code nil when the charmap has nothing. A span is a whole
|
||||
-- charmap sequence, so a multi-byte char ("é", "♂") and an ASCII ligature
|
||||
@@ -119,6 +218,7 @@ local SPACE = 0x7F
|
||||
-- boundaries, which is all a headless paginate needs.
|
||||
function Font.split(text)
|
||||
local spans = {}
|
||||
local ttf = state and state.ttf
|
||||
local i, n = 1, #text
|
||||
while i <= n do
|
||||
local span
|
||||
@@ -127,11 +227,23 @@ function Font.split(text)
|
||||
for _, entry in ipairs(candidates) do
|
||||
local len = #entry.seq
|
||||
if text:sub(i, i + len - 1) == entry.seq then
|
||||
if ttf then
|
||||
-- single characters belong to the TTF; only multi-character
|
||||
-- sequences (ligatures, <PK> macros) keep their tile mapping
|
||||
local cp, last = utf8Decode(entry.seq, 1)
|
||||
if cp and last == len then break end
|
||||
end
|
||||
span = { from = i, to = i + len - 1, code = entry.code }
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
if not span and ttf then
|
||||
local cp, last = utf8Decode(text, i)
|
||||
if cp and cp >= 0x20 then
|
||||
span = { from = i, to = last, code = TTF_BASE + cp }
|
||||
end
|
||||
end
|
||||
if not span then
|
||||
-- Nothing matched. Still keep a UTF-8 sequence whole, so a cut never
|
||||
-- lands mid-character even for a glyph we cannot draw.
|
||||
@@ -185,14 +297,37 @@ function Font.encode(text)
|
||||
end
|
||||
|
||||
function Font.drawCode(code, x, y)
|
||||
local ttf = state and state.ttf
|
||||
if ttf and code >= TTF_BASE then
|
||||
local prev = love.graphics.getFont()
|
||||
love.graphics.setFont(ttf.font)
|
||||
local ch = ttfChar(ttf, code)
|
||||
love.graphics.print(ch, x, y + ttf.yOffset)
|
||||
if ttf.bold then love.graphics.print(ch, x + 1, y + ttf.yOffset) end
|
||||
if prev then love.graphics.setFont(prev) end
|
||||
return
|
||||
end
|
||||
local page = pageFor(code)
|
||||
if not page then return end
|
||||
local quad = page.quads[code - page.base]
|
||||
if quad then love.graphics.draw(page.image, quad, x, y) end
|
||||
end
|
||||
|
||||
-- how far the pen moves past a glyph; 8 unless its page says otherwise
|
||||
-- how far the pen moves past a glyph; 8 unless its page says otherwise.
|
||||
-- TTF glyphs answer with the font's own metrics (5px base, 11px for
|
||||
-- double-width kana/CJK in Plain Pixel), which is what makes TextBox's
|
||||
-- pixel-budget pagination fit more of a narrow script per line.
|
||||
function Font.advanceOf(code)
|
||||
local ttf = state and state.ttf
|
||||
if ttf and code >= TTF_BASE then
|
||||
local w = ttf.widths[code]
|
||||
if not w then
|
||||
w = ttf.font:getWidth(ttfChar(ttf, code)) + ttf.spacing
|
||||
+ (ttf.bold and 1 or 0)
|
||||
ttf.widths[code] = w
|
||||
end
|
||||
return w
|
||||
end
|
||||
local page = pageFor(code)
|
||||
return page and page.advance or GLYPH
|
||||
end
|
||||
|
||||
@@ -370,8 +370,12 @@ function TextBox:draw()
|
||||
local ys = { self.line1Y, self.line2Y }
|
||||
for i, line in ipairs(self.shown) do
|
||||
local y = (ys[i] or self.line2Y) + (i == 1 and off or 0)
|
||||
for j, code in ipairs(line) do
|
||||
Font.drawCode(code, self.textX + (j - 1) * 8, y)
|
||||
-- the pen advances per glyph, matching the pixel budget paginate
|
||||
-- measured with; every fixed-width page still lands on the 8px grid
|
||||
local pen = self.textX
|
||||
for _, code in ipairs(line) do
|
||||
Font.drawCode(code, pen, y)
|
||||
pen = pen + Font.advanceOf(code)
|
||||
end
|
||||
end
|
||||
if (self.waiting or (self.done and not self.choice and not self.auto
|
||||
|
||||
@@ -240,7 +240,7 @@ local function decodeName(bytes, off, len)
|
||||
return table.concat(out)
|
||||
end
|
||||
|
||||
local function encodeName(buf, off, len, text)
|
||||
local function encodeName(buf, off, len, text, padTail)
|
||||
local i, pos = 0, 1
|
||||
while i < len - 1 and pos <= #text do
|
||||
-- a bracketed control token (e.g. "<DOT>", from decodeName reading a
|
||||
@@ -259,16 +259,17 @@ local function encodeName(buf, off, len, text)
|
||||
setByte(buf, off + i, charmap.byToken[ch] or charmap.byToken["?"] or 0x50)
|
||||
i, pos = i + 1, pos + clen
|
||||
end
|
||||
-- Write exactly ONE $50 terminator and then $50-pad the rest of the
|
||||
-- field: every real save the naming screen ever wrote fills the tail
|
||||
-- with $50, and a zero tail is what PKHeX renders as garbage glyphs
|
||||
-- after the name ("JOHN{}", #206). Template bytes that are NOT zero
|
||||
-- stay untouched, so an unchanged name still round-trips
|
||||
-- byte-identical and stale template data survives.
|
||||
-- Write exactly ONE $50 terminator. The tail past it is $50-padded only
|
||||
-- on a templateless (engine-origin) export, where the zero-filled buffer
|
||||
-- is what PKHeX renders as garbage glyphs after the name ("JOHN{}", #206).
|
||||
-- With a template the tail keeps the original save's bytes verbatim --
|
||||
-- real cartridge saves legitimately hold 0x00 (and other stale glyph)
|
||||
-- bytes after the terminator, and rewriting any of them broke the
|
||||
-- import->export byte-identical round trip (the game and PKHeX both stop
|
||||
-- reading at the terminator, so preserved tails are always safe).
|
||||
if i < len then setByte(buf, off + i, 0x50) end
|
||||
for j = i + 1, len - 1 do
|
||||
local cur = buf[off + j + 1]
|
||||
if cur == nil or cur:byte() == 0 then setByte(buf, off + j, 0x50) end
|
||||
if padTail then
|
||||
for j = i + 1, len - 1 do setByte(buf, off + j, 0x50) end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -781,8 +782,9 @@ function GenSave.encode(save, data, template)
|
||||
for i = 1, GenSave.SAVE_SIZE do buf[i] = zero end
|
||||
end
|
||||
|
||||
encodeName(buf, O.playerName, NAME_LENGTH, (save.player and save.player.name) or "RED")
|
||||
encodeName(buf, O.rivalName, NAME_LENGTH, (save.player and save.player.rival) or "BLUE")
|
||||
local padTail = not src
|
||||
encodeName(buf, O.playerName, NAME_LENGTH, (save.player and save.player.name) or "RED", padTail)
|
||||
encodeName(buf, O.rivalName, NAME_LENGTH, (save.player and save.player.rival) or "BLUE", padTail)
|
||||
setU16be(buf, O.playerId, (save.player and save.player.id) or 0)
|
||||
-- wOptions (engine/menus/main_menu.asm InitOptions): bit 7 = battle
|
||||
-- effects OFF, bit 6 = SET style, bits 2-0 = text speed -- the recomp's
|
||||
@@ -875,11 +877,11 @@ function GenSave.encode(save, data, template)
|
||||
encodeMon(buf, O.partyMons + i * PARTY_STRUCT_SIZE, mon, true, cw)
|
||||
setByte(buf, O.partySpecies + i, cw.pokemonIndex[mon.species] or 0)
|
||||
encodeName(buf, O.partyMonOT + i * NAME_LENGTH, NAME_LENGTH,
|
||||
mon.ot or (save.player and save.player.name) or "RED")
|
||||
mon.ot or (save.player and save.player.name) or "RED", padTail)
|
||||
-- no nickname stores the species' DISPLAY name, not its ROM constant id
|
||||
-- ("NIDORAN_M" would charmap the "_" to "?") (#257)
|
||||
encodeName(buf, O.partyMonNicks + i * NAME_LENGTH, NAME_LENGTH,
|
||||
mon.nickname or speciesName(cw, mon.species))
|
||||
mon.nickname or speciesName(cw, mon.species), padTail)
|
||||
end
|
||||
-- $FF-terminate the species index list right after the last real mon. The
|
||||
-- struct, OT-name and nickname bytes of the empty slots past partyN are left
|
||||
@@ -900,9 +902,9 @@ function GenSave.encode(save, data, template)
|
||||
encodeMon(buf, base + 22 + i * BOX_STRUCT_SIZE, mon, false, cw)
|
||||
setByte(buf, base + 1 + i, cw.pokemonIndex[mon.species] or 0)
|
||||
encodeName(buf, base + 22 + MONS_PER_BOX * BOX_STRUCT_SIZE + i * NAME_LENGTH, NAME_LENGTH,
|
||||
mon.ot or (save.player and save.player.name) or "RED")
|
||||
mon.ot or (save.player and save.player.name) or "RED", padTail)
|
||||
encodeName(buf, base + 22 + MONS_PER_BOX * (BOX_STRUCT_SIZE + NAME_LENGTH) + i * NAME_LENGTH, NAME_LENGTH,
|
||||
mon.nickname or speciesName(cw, mon.species)) -- #257, as above
|
||||
mon.nickname or speciesName(cw, mon.species), padTail) -- #257, as above
|
||||
end
|
||||
-- $FF-terminate the species list after the last real mon; empty slots past
|
||||
-- n keep their template bytes (byte-identical round-trip) or zero (fresh
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
local Font = require("src.render.Font")
|
||||
local Music = require("src.core.Music")
|
||||
local Strings = require("src.core.Strings")
|
||||
local romText = require("src.core.RomText")
|
||||
|
||||
local EvolutionState = {}
|
||||
EvolutionState.__index = EvolutionState
|
||||
@@ -89,9 +90,9 @@ function EvolutionState:update(dt)
|
||||
self.done = true
|
||||
self.canceled = true
|
||||
local TextBox = require("src.render.TextBox")
|
||||
-- mirrors data/generated/text.lua _StoppedEvolvingText
|
||||
game.stack:push(TextBox.new(game,
|
||||
Strings("Huh? %s\nstopped evolving!", self.oldName),
|
||||
romText(game.data, "_StoppedEvolvingText",
|
||||
"Huh? %s\nstopped evolving!", self.oldName),
|
||||
function()
|
||||
Music.restoreMap(game.data)
|
||||
game.stack:pop() -- the evolution screen itself
|
||||
@@ -106,6 +107,8 @@ function EvolutionState:update(dt)
|
||||
require("src.core.Sound").playCry(game.data, self.newSpecies)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local newName = game.data.pokemon[self.newSpecies].name
|
||||
-- _EvolvedText extracts truncated (it stops at a dynamic marker the
|
||||
-- decoder does not follow), so the engine's wording stands here
|
||||
game.stack:push(TextBox.new(game,
|
||||
Strings("Congratulations!\nYour %s\nevolved into\n%s!",
|
||||
self.oldName, newName),
|
||||
|
||||
+21
-10
@@ -5,6 +5,7 @@
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local Strings = require("src.core.Strings")
|
||||
local romText = require("src.core.RomText")
|
||||
|
||||
local MoveLearnMenu = {}
|
||||
MoveLearnMenu.__index = MoveLearnMenu
|
||||
@@ -43,10 +44,13 @@ function MoveLearnMenu:enter()
|
||||
local mdef = game.data.moves[self.newMoveId]
|
||||
local name = self:monName()
|
||||
self.selecting = false
|
||||
-- _TryingToLearnText is the whole exchange in pokered, delete prompt
|
||||
-- included, so the extracted line carries all four slots at once
|
||||
game.stack:push(TextBox.new(game,
|
||||
Strings("%s is\ntrying to learn\v%s!\fBut, %s\ncan't learn more\vthan 4 moves!\f",
|
||||
name, mdef.name, name) ..
|
||||
Strings("Delete an older\nmove to make room\vfor %s?", mdef.name),
|
||||
romText(game.data, "_TryingToLearnText",
|
||||
"%s is\ntrying to learn\v%s!\fBut, %s\ncan't learn more\vthan 4 moves!\f"
|
||||
.. "Delete an older\nmove to make room\vfor %s?",
|
||||
name, mdef.name, name, mdef.name),
|
||||
nil, {
|
||||
choice = function(yes)
|
||||
if yes then
|
||||
@@ -77,7 +81,8 @@ function MoveLearnMenu:update(dt)
|
||||
-- HMCantDeleteText, then back to the forget list
|
||||
local TextBox = require("src.render.TextBox")
|
||||
self.game.stack:push(TextBox.new(self.game,
|
||||
Strings("HM techniques\ncan't be deleted!")))
|
||||
romText(self.game.data, "_HMCantDeleteText",
|
||||
"HM techniques\ncan't be deleted!")))
|
||||
return
|
||||
end
|
||||
local mdef = self.game.data.moves[self.newMoveId]
|
||||
@@ -97,7 +102,8 @@ function MoveLearnMenu:confirmAbandon()
|
||||
local mdef = game.data.moves[self.newMoveId]
|
||||
self.selecting = false
|
||||
game.stack:push(TextBox.new(game,
|
||||
Strings("Abandon learning\n%s?", mdef.name), nil, {
|
||||
romText(game.data, "_AbandonLearningText",
|
||||
"Abandon learning\n%s?", mdef.name), nil, {
|
||||
choice = function(yes)
|
||||
if yes then self:finish(false) else self:enter() end
|
||||
end,
|
||||
@@ -113,12 +119,17 @@ function MoveLearnMenu:finish(learned)
|
||||
game.stack:pop()
|
||||
local msg
|
||||
if learned then
|
||||
-- OneTwoAndText/PoofText/ForgotAndText
|
||||
msg = Strings("1, 2 and... Poof!\f%s forgot\n%s!\fAnd...\f%s learned\n%s!",
|
||||
name, self.forgot, name, mdef.name)
|
||||
-- pokered pages this as four texts in a row; _ForgotAndText carries
|
||||
-- the "And..." tail
|
||||
msg = romText(game.data, "_OneTwoAndText", "1, 2 and...")
|
||||
.. romText(game.data, "_PoofText", " Poof!")
|
||||
.. romText(game.data, "_ForgotAndText",
|
||||
"\f%s forgot\n%s!\fAnd...", name, self.forgot)
|
||||
.. "\f" .. romText(game.data, "_LearnedMove1Text",
|
||||
"%s learned\n%s!", name, mdef.name)
|
||||
else
|
||||
-- DidNotLearnText
|
||||
msg = Strings("%s\ndid not learn\v%s!", name, mdef.name)
|
||||
msg = romText(game.data, "_DidNotLearnText",
|
||||
"%s\ndid not learn\v%s!", name, mdef.name)
|
||||
end
|
||||
game.stack:push(TextBox.new(game, msg, function()
|
||||
if self.onDone then self.onDone(learned) end
|
||||
|
||||
+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,83 @@
|
||||
-- Driver: TTF text mode through the bundled Plain Pixel font. Teleports
|
||||
-- straight into the overworld (no intro) and shows one dialogue line per
|
||||
-- language back to back, shooting each: lang_english.png, lang_french.png,
|
||||
-- lang_german.png, lang_spanish.png, lang_russian.png, lang_japanese.png,
|
||||
-- lang_chinese.png in SHOT_DIR. Judge glyph coverage, spacing, baseline,
|
||||
-- and that the tile box border survives every one of them.
|
||||
-- POKEPORT_DRIVER=tests/drivers/plainpixel_font_test.lua \
|
||||
-- POKEPORT_IDENTITY=ttfdemo POKEPORT_TOUCH=0 POKEPORT_VERSION=red \
|
||||
-- SHOT_DIR=/tmp/shots love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Font = require("src.render.Font")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- ---- preconditions -----------------------------------------------------
|
||||
check("the bundled TTF is present",
|
||||
love.filesystem.getInfo(Font.PLAINPIXEL) ~= nil)
|
||||
local okLoad, ttf = pcall(love.graphics.newFont, Font.PLAINPIXEL,
|
||||
Font.PLAINPIXEL_SIZE)
|
||||
check("real LOVE can load it", okLoad and ttf ~= nil)
|
||||
|
||||
game.save.player.name = "bryan"
|
||||
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
|
||||
U.wait(10)
|
||||
|
||||
-- what a translation's register("ttf", {}) merges to
|
||||
game.data.font.ttf = {}
|
||||
Font.invalidate()
|
||||
check("ttf active after the merge", Font.ttfActive())
|
||||
|
||||
local LINES = {
|
||||
{ "english", "The quick brown fox\njumps over the lazy dog!" },
|
||||
{ "french", "Un \195\169clair enveloppe le\nPOK\195\169MON sauvage! \195\135a alors!" },
|
||||
{ "german", "Gr\195\182\195\159e und Gewicht des\nPOK\195\169MON \195\188berpr\195\188ft!" },
|
||||
{ "spanish", "\194\161El POK\195\169MON enemigo\nest\195\161 paralizado! \194\191Y ahora?" },
|
||||
{ "russian", "\208\148\208\184\208\186\208\184\208\185 "
|
||||
.. "\208\159\208\158\208\154\208\149\208\156\208\158\208\157 "
|
||||
.. "\208\191\208\190\209\143\208\178\208\184\208\187\209\129\209\143!" },
|
||||
{ "japanese", "\227\129\147\227\130\147\227\129\171\227\129\161\227\129\175! "
|
||||
.. "\227\131\157\227\130\177\227\131\162\227\131\179\227\129\174\n"
|
||||
.. "\227\129\155\227\129\139\227\129\132\227\129\184 "
|
||||
.. "\227\130\136\227\129\134\227\129\147\227\129\157!" },
|
||||
-- NB: Plain Pixel 0.009's CJK block is partial (e.g. U+62D3, U+5922
|
||||
-- draw as tofu); stick to characters it covers
|
||||
{ "chinese", "\228\189\160\229\165\189! \230\136\145\230\152\175"
|
||||
.. "\229\164\167\229\178\169\229\141\154\229\163\171!" },
|
||||
}
|
||||
|
||||
for _, entry in ipairs(LINES) do
|
||||
local name, text = entry[1], entry[2]
|
||||
local done = false
|
||||
local box = TextBox.new(game, text, function() done = true end)
|
||||
game.stack:push(box)
|
||||
-- let the typewriter finish the page before shooting it
|
||||
for _ = 1, 300 do
|
||||
if box.waiting or box.done then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(5)
|
||||
U.shot(game, DIR .. "/lang_" .. name .. ".png")
|
||||
for _ = 1, 20 do
|
||||
if done then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(10)
|
||||
end
|
||||
U.wait(5)
|
||||
check("dismissed " .. name, done)
|
||||
end
|
||||
|
||||
game.data.font.ttf = nil
|
||||
Font.invalidate()
|
||||
check("clearing the entry restores tile mode", not Font.ttfActive())
|
||||
|
||||
U.log("shots in", DIR)
|
||||
U.log("DONE")
|
||||
love.event.quit(0)
|
||||
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,63 @@
|
||||
-- Wheel scrolling must survive the launcher's immediate-mode frame order:
|
||||
-- FlexLove.update(dt) runs in love.update, but beginFrame (in draw) resets
|
||||
-- the accumulated dt to 0 BEFORE endFrame updates the elements, so
|
||||
-- ScrollManager:update always receives dt = 0 there. The smooth-scroll
|
||||
-- interpolation must still advance (it once used a fixed per-frame
|
||||
-- fraction; the dt-aware blend has to treat dt <= 0 as one nominal 60 Hz
|
||||
-- step, or every launcher list stops responding to the wheel entirely).
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
love.window.getMode = function() return 1024, 768, { fullscreen = false } end
|
||||
love.window.getDesktopDimensions = function() return 1920, 1080 end
|
||||
love.mouse = love.mouse or {}
|
||||
love.mouse.getPosition = function() return 100, 100 end
|
||||
love.mouse.isDown = love.mouse.isDown or function() return false 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 = true,
|
||||
performanceMonitoring = false,
|
||||
keyboardNavigation = false,
|
||||
})
|
||||
|
||||
-- One launcher-shaped frame: update BEFORE beginFrame, exactly like
|
||||
-- LauncherView.update / draw, so endFrame sees _accumulatedDt == 0.
|
||||
local function frame(wheelAfterBuild)
|
||||
FlexLove.update(1 / 60)
|
||||
FlexLove.beginFrame()
|
||||
local scroller = FlexLove.new({
|
||||
id = "scroller", x = 0, y = 0, width = 400, height = 300,
|
||||
overflowY = "scroll", hideScrollbars = true,
|
||||
smoothScrollEnabled = true, scrollSpeed = 60,
|
||||
positioning = "flex", flexDirection = "vertical",
|
||||
})
|
||||
for i = 1, 30 do
|
||||
FlexLove.new({ parent = scroller, id = "row" .. i, width = 380, height = 40 })
|
||||
end
|
||||
FlexLove.endFrame()
|
||||
if wheelAfterBuild then FlexLove.wheelmoved(0, -1) end
|
||||
local sm = scroller._scrollManager
|
||||
return sm and sm._scrollY or nil, sm and sm._targetScrollY or nil
|
||||
end
|
||||
|
||||
for _ = 1, 3 do frame(false) end
|
||||
local y0, t0 = frame(true)
|
||||
T.eq(t0, 60, "one wheel notch targets one scrollSpeed step")
|
||||
T.eq(y0, 0, "the notch lands on the frame after input, not the same one")
|
||||
|
||||
local y = y0
|
||||
for _ = 1, 5 do y = frame(false) end
|
||||
T.check(y ~= nil and y > 30, "smooth scroll advances past halfway within 5 dt=0 frames")
|
||||
for _ = 1, 60 do y = frame(false) end
|
||||
T.check(y ~= nil and math.abs(y - 60) < 1, "and converges on the target")
|
||||
|
||||
T.finish("flexlove wheel scroll at dt=0")
|
||||
@@ -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,291 @@
|
||||
-- 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")
|
||||
-- The guard used to be NX-only; the same perf-timer and GC hitches showed
|
||||
-- up in desktop scrolling, so it now applies on every platform.
|
||||
check(view:find("if not (imp and FlexLove.isReady()", 1, true) ~= nil,
|
||||
"perf guard applies on every platform (not 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")
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user