Compare commits
187 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f74e21782b | |||
| 8fbe819493 | |||
| 626080d228 | |||
| f56e82de81 | |||
| 7d3950658a | |||
| 3c1246ee06 | |||
| d69965e3b1 | |||
| 30edac03a6 | |||
| dd4aface93 | |||
| b1350fdecd | |||
| f2d9b74490 | |||
| 84d09d0894 | |||
| e61f231316 | |||
| 33998aab5d | |||
| 9ce7379946 | |||
| b692474e24 | |||
| 6cfc85ca83 | |||
| 7b060c19f4 | |||
| 604b9338f9 | |||
| 3a6557ffe2 | |||
| fe139ca7f4 | |||
| 5c617d3e32 | |||
| 8d11c04d43 | |||
| 92f73d8bed | |||
| 8da51aa7ba | |||
| 78f87111de | |||
| e3fcdd0776 | |||
| 6ea59f1a79 | |||
| 51fb4db82e | |||
| 7f78de8718 | |||
| 76aab74bf1 | |||
| a2bf08c6ff | |||
| b831076839 | |||
| 45575a1fd8 | |||
| 56d92246cb | |||
| 4a00f2335c | |||
| e9cb830d71 | |||
| c2666d2b43 | |||
| 9ceec3070d | |||
| f89977a8e7 | |||
| 8d512b95cb | |||
| 0914387c16 | |||
| 105672c132 | |||
| 0dd6ecc219 | |||
| 9ef5c8dead | |||
| 12ff4dba2e | |||
| ff9992dff8 | |||
| 98c08e4b22 | |||
| 0bd3b26acc | |||
| 59b55a8f37 | |||
| 67e6b1fb04 | |||
| 17243a7d8b | |||
| 16b6b98ede | |||
| e4ce1063a1 | |||
| f099593136 | |||
| f944507520 | |||
| 6824949247 | |||
| 4f54255518 | |||
| cfe482e1fc | |||
| 0e5ea26e36 | |||
| d5886e69aa | |||
| 32fa215243 | |||
| dd5d8afd82 | |||
| f19f4e9341 | |||
| 3aec2ca89b | |||
| 0f7261dd92 | |||
| 0886573d34 | |||
| 5c041ea857 | |||
| 30cd9afc92 | |||
| cceb74a025 | |||
| 42540076c1 | |||
| a528868656 | |||
| c82af28fd4 | |||
| d72e5db0a5 | |||
| 9907cfb0a0 | |||
| a3cd5a132e | |||
| 0971b47bec | |||
| 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 }}
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
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
|
||||
|
||||
@@ -766,6 +766,28 @@ local function silphRocketsLeave(game, ow, onlyMap)
|
||||
end
|
||||
end
|
||||
|
||||
-- SilphCo11FGiovanniAfterBattleScript (scripts/SilphCo11F.asm) is the whole
|
||||
-- aftermath: DisplayTextID TEXT_SILPHCO11F_GIOVANNI_YOU_RUINED_OUR_PLANS,
|
||||
-- GBFadeOutToBlack, SilphCo11FTeamRocketLeavesScript, Delay3,
|
||||
-- GBFadeInFromBlack, then SetEvent. The port had only the hide pass, so the
|
||||
-- speech never played and every rocket blinked out in front of the player
|
||||
-- (#722). Same hide list as silphRocketsLeave, spelled as script rows so the
|
||||
-- fade can hold over it.
|
||||
local function silphAftermathRows()
|
||||
local rows = {
|
||||
{ "show_text", "_SilphCo11FGiovanniYouRuinedOurPlansText" },
|
||||
{ "fade", "out" },
|
||||
}
|
||||
for _, floor in ipairs(SILPH_ROCKET_OBJECTS) do
|
||||
for _, name in ipairs(floor[2]) do
|
||||
rows[#rows + 1] = { "hide_object", floor[1], name }
|
||||
end
|
||||
end
|
||||
rows[#rows + 1] = { "wait", 3 } -- Delay3
|
||||
rows[#rows + 1] = { "fade", "in" }
|
||||
return rows
|
||||
end
|
||||
|
||||
M.SILPH_CO_11F = {
|
||||
-- Giovanni's battle is a COORDINATE TRIGGER, not a talk.
|
||||
-- SilphCo11FDefaultScript (scripts/SilphCo11F.asm) checks
|
||||
@@ -792,11 +814,15 @@ M.SILPH_CO_11F = {
|
||||
ow:scriptMove(gio, "down", 3, function()
|
||||
gio:facePlayer(ow.player)
|
||||
ow:engageTrainer(gio, function()
|
||||
-- SilphCo11FTeamRocketLeavesScript: every Silph rocket leaves
|
||||
-- after the loss (the street rockets are handled by
|
||||
-- M.SAFFRON_CITY.onEnter in story4.lua).
|
||||
-- SilphCo11FGiovanniAfterBattleScript: the "Blast it all!" speech,
|
||||
-- then SilphCo11FTeamRocketLeavesScript behind a fade so every Silph
|
||||
-- rocket leaves off-screen (the street rockets are handled by
|
||||
-- M.SAFFRON_CITY.onEnter in story4.lua). Queued, not run here: the
|
||||
-- battle's own callbacks are still unwinding, so queueScript starts
|
||||
-- it on the first idle overworld frame -- after the end-battle
|
||||
-- "Arrgh!!" box victories.lua OPP_GIOVANNI#2 pushes (#722).
|
||||
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
|
||||
silphRocketsLeave(game, ow)
|
||||
ow:queueScript(silphAftermathRows())
|
||||
end
|
||||
end)
|
||||
end)
|
||||
@@ -981,7 +1007,11 @@ M.VICTORY_ROAD_3F = {
|
||||
local championsRoomRivalScript = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 2
|
||||
{ "jump_if_true", 26 }, -- 3 past end
|
||||
-- "end" rather than a row number past the tail: this script grew by a row
|
||||
-- when the follow-Oak walk landed (#704), which silently turned the old
|
||||
-- numeric 26 into a jump ONTO the closing HALL_OF_FAME warp instead of past
|
||||
-- it, so a returning champion warped straight into the induction.
|
||||
{ "jump_if_true", "end" }, -- 3
|
||||
{ "show_text", "_ChampionsRoomRivalIntroText" }, -- 4
|
||||
-- ChampionsRoomRivalReadyToBattleScript plays MUSIC_FINAL_BATTLE after
|
||||
-- the intro text, before the battle itself (#706); pushBattle's wipe-time
|
||||
@@ -989,37 +1019,50 @@ local championsRoomRivalScript = {
|
||||
-- continuous into the fight
|
||||
{ "play_music", "Music_FinalBattle" }, -- 5
|
||||
{ "rival_battle", "OPP_RIVAL3", 1 }, -- 6
|
||||
{ "jump_if_false", 26 }, -- 6 past end
|
||||
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 7
|
||||
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL" }, -- 8
|
||||
-- losing halts here; the numeric target this replaced pointed at the
|
||||
-- closing warp, which inducted a player who had just lost the fight (#704)
|
||||
{ "jump_if_false", "end" }, -- 7
|
||||
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 8
|
||||
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL" }, -- 9
|
||||
-- ChampionsRoomRivalDefeatedScript re-displays TEXT_CHAMPIONSROOM_RIVAL,
|
||||
-- whose text_asm takes the EVENT_BEAT_CHAMPION_RIVAL branch =
|
||||
-- _ChampionsRoomRivalAfterBattleText (the in-battle _RivalDefeatedText
|
||||
-- is the port's generic "<PLAYER> defeated BLUE!" engine line instead).
|
||||
{ "show_text", "_ChampionsRoomRivalAfterBattleText" }, -- 9
|
||||
{ "show_text", "_ChampionsRoomRivalAfterBattleText" }, -- 10
|
||||
-- ChampionsRoomOakArrivesScript: Music_Cities1AlternateTempo
|
||||
-- (Cities1, kept into HALL_OF_FAME like BIT_NO_MAP_MUSIC after
|
||||
-- defeating RIVAL3), then Oak's "{PLAYER}!" + reveal + walk in
|
||||
{ "play_music", "Music_Cities1", { keep = true } }, -- 10
|
||||
{ "show_text", "_ChampionsRoomOakText" }, -- 11
|
||||
{ "show_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 12
|
||||
{ "move_npc", 2, "up", 5 }, -- 13 OakEntranceAfterVictoryMovement
|
||||
{ "play_music", "Music_Cities1", { keep = true } }, -- 11
|
||||
{ "show_text", "_ChampionsRoomOakText" }, -- 12
|
||||
{ "show_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 13
|
||||
{ "move_npc", 2, "up", 5 }, -- 14 OakEntranceAfterVictoryMovement
|
||||
-- OakCongratulatesPlayerScript: rival faces left, Oak faces down
|
||||
{ "face_object", 1, "left" }, -- 14
|
||||
{ "face_object", 2, "down" }, -- 15
|
||||
{ "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 16
|
||||
{ "face_object", 1, "left" }, -- 15
|
||||
{ "face_object", 2, "down" }, -- 16
|
||||
{ "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 17
|
||||
-- OakDisappointedWithRivalScript: Oak turns to the rival (right)
|
||||
{ "face_object", 2, "right" }, -- 17
|
||||
{ "show_text", "_ChampionsRoomOakDisappointedWithRivalText" }, -- 18
|
||||
{ "face_object", 2, "right" }, -- 18
|
||||
{ "show_text", "_ChampionsRoomOakDisappointedWithRivalText" }, -- 19
|
||||
-- OakComeWithMeScript: Oak faces down again, then exits up
|
||||
{ "face_object", 2, "down" }, -- 19
|
||||
{ "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 20
|
||||
{ "move_npc", 2, "up", 2 }, -- 21 OakExitChampionsRoomMovement
|
||||
{ "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 22
|
||||
{ "face_object", 2, "down" }, -- 20
|
||||
{ "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 21
|
||||
{ "move_npc", 2, "up", 2 }, -- 22 OakExitChampionsRoomMovement
|
||||
{ "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 23
|
||||
-- ChampionsRoomPlayerFollowsOakScript / WalkToHallOfFame_RLEMovement
|
||||
-- (PAD_UP 4, PAD_LEFT 1): the player walks out after Oak instead of the
|
||||
-- screen just fading on the spot (#704). The entrance walk leaves the
|
||||
-- player at (4,3) and both north-wall warps sit on row 0, so the original
|
||||
-- only ever spends three of those simulated steps -- CheckWarpsNoCollision
|
||||
-- takes the HALL_OF_FAME warp the moment the walk lands on (4,0) and the
|
||||
-- trailing UP/LEFT are dropped. Scripted steps ignore collision here just
|
||||
-- as they do in the original (CollisionCheckOnLand skips its checks while
|
||||
-- wSimulatedJoypadStatesIndex is non-zero), so stepping through the
|
||||
-- rival's cell at (4,2) is the ported behavior, not a clip.
|
||||
{ "move_player", "up", 3 }, -- 24
|
||||
-- hand the induction off to the HALL_OF_FAME room (consumed by its
|
||||
-- onEnter), then warp up into it (destWarp 1 lands at (4,7) facing up)
|
||||
{ "set_field", "pendingHallOfFame", true }, -- 23
|
||||
{ "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 24
|
||||
{ "set_field", "pendingHallOfFame", true }, -- 25
|
||||
{ "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 26
|
||||
}
|
||||
|
||||
M.CHAMPIONS_ROOM = {
|
||||
|
||||
@@ -549,21 +549,57 @@ M.GAME_CORNER = {
|
||||
-- handler is bound to both text ids just below (#552).
|
||||
TEXT_GAMECORNER_CLERK1 = function(game, ow, npc, done)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
local Font = require("src.render.Font")
|
||||
local Strings = require("src.core.Strings")
|
||||
local t = game.data.text
|
||||
local function line(suffix, fallback)
|
||||
return t["_GameCornerClerk1" .. suffix]
|
||||
or t["_GameCornerClerk" .. suffix]
|
||||
or fallback
|
||||
end
|
||||
-- GameCornerDrawCoinBox (scripts/GameCorner.asm; pokeyellow's copy is
|
||||
-- identical): TextBoxBorder at hlcoord 11,0 with b=5 c=7, a 9x7-tile
|
||||
-- window in the top right holding MONEY at (12,2) over the amount on
|
||||
-- row 3 and COIN at (12,4) over the count on row 5. Both
|
||||
-- PrintBCDNumber calls pass LEADING_ZEROES, whose bit 7 SUPPRESSES
|
||||
-- leading zeroes (home/print_bcd.asm), and neither passes LEFT_ALIGN,
|
||||
-- so both numbers read plain and right-aligned against the inner edge
|
||||
-- at column 18. The asm draws the box before the offer and redraws it
|
||||
-- after the purchase, so it stands for the whole exchange: a draw-only
|
||||
-- state under the dialogue gets that lifetime, since StateStack draws
|
||||
-- every state above the last opaque one and updates only the top
|
||||
-- (src/core/StateStack.lua), and reading save each frame is the
|
||||
-- redraw (#624).
|
||||
local coinBox = { draw = function()
|
||||
Font.drawBox(11, 0, 9, 7)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(Strings("MONEY"), 96, 16)
|
||||
local money = ("¥%d"):format(game.save.money or 0)
|
||||
Font.draw(money, 152 - Font.width(money), 24)
|
||||
Font.draw(Strings("COIN"), 96, 32)
|
||||
local coins = ("%d"):format(game.save.coins or 0)
|
||||
Font.draw(coins, 152 - Font.width(coins), 40)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end }
|
||||
game.stack:push(coinBox)
|
||||
-- Every branch below finishes here. A TextBox pops itself before its
|
||||
-- onDone runs, so the coin box is top of the stack again by then and
|
||||
-- this pop takes it down, never someone else's state.
|
||||
local function finish()
|
||||
game.stack:pop()
|
||||
done()
|
||||
end
|
||||
-- YesNoChoice is called with the offer still printed, so the prompt
|
||||
-- has to ride the open text box (opts.choice) instead of being pushed
|
||||
-- after it closes, which is what made the question vanish (#624).
|
||||
game.stack:push(TextBox.new(game,
|
||||
line("DoYouNeedSomeGameCoinsText",
|
||||
"Do you need some\ngame coins?\f¥1000 for 50."), function()
|
||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||
"Do you need some\ngame coins?\f¥1000 for 50."),
|
||||
nil, { choice = function(yes)
|
||||
if not yes then
|
||||
game.stack:push(TextBox.new(game,
|
||||
line("PleaseComePlaySometimeText",
|
||||
"No? Please come\nplay sometime!"), done))
|
||||
"No? Please come\nplay sometime!"), finish))
|
||||
return
|
||||
end
|
||||
-- scripts/GameCorner.asm GameCornerClerk1Text: coins need
|
||||
@@ -571,29 +607,30 @@ M.GAME_CORNER = {
|
||||
if not game.save.inventory.COIN_CASE then
|
||||
game.stack:push(TextBox.new(game,
|
||||
line("DontHaveCoinCaseText",
|
||||
"You don't have a\nCOIN CASE!"), done))
|
||||
"You don't have a\nCOIN CASE!"), finish))
|
||||
return
|
||||
end
|
||||
if (game.save.coins or 0) >= 9990 then
|
||||
game.stack:push(TextBox.new(game,
|
||||
line("CoinCaseIsFullText",
|
||||
"Oops! Your COIN\nCASE is full."), done))
|
||||
"Oops! Your COIN\nCASE is full."), finish))
|
||||
return
|
||||
end
|
||||
if game.save.money < 1000 then
|
||||
game.stack:push(TextBox.new(game,
|
||||
line("CantAffordTheCoinsText",
|
||||
"You can't afford\nthe coins!"), done))
|
||||
"You can't afford\nthe coins!"), finish))
|
||||
return
|
||||
end
|
||||
game.save.money = game.save.money - 1000
|
||||
game.save.coins = math.min(9999, (game.save.coins or 0) + 50)
|
||||
-- the thanks text is the plain _GameCornerClerk1ThanksHereAre50-
|
||||
-- CoinsText; the new count belongs in the coin box the asm
|
||||
-- redraws here, not appended to the line (#624)
|
||||
game.stack:push(TextBox.new(game,
|
||||
line("ThanksHereAre50CoinsText",
|
||||
"Thanks! Here are\nyour 50 coins!")
|
||||
.. ("\fCOINS: %d"):format(game.save.coins), done))
|
||||
end))
|
||||
end))
|
||||
"Thanks! Here are\nyour 50 coins!"), finish))
|
||||
end }))
|
||||
end,
|
||||
},
|
||||
}
|
||||
@@ -606,99 +643,188 @@ M.GAME_CORNER.talk.TEXT_GAMECORNER_CLERK =
|
||||
M.GAME_CORNER.talk.TEXT_GAMECORNER_CLERK1
|
||||
|
||||
-- Game Corner prize lists (data/events/prizes.asm, prize_mon_levels.asm).
|
||||
-- The six mon prizes differ between Red and Blue; the three TM prizes are
|
||||
-- identical, so they are shared and appended to each version's mon list.
|
||||
-- Each counter owns ONE window of three prizes, not the whole catalogue:
|
||||
-- GetPrizeMenuId (engine/events/prize_menu.asm) subtracts
|
||||
-- TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_1 from hTextID and indexes
|
||||
-- PrizeDifferentMenuPtrs with the result, so vendor 1 sells
|
||||
-- PrizeMenuMon1Entries, vendor 2 PrizeMenuMon2Entries and vendor 3
|
||||
-- PrizeMenuTMsEntries (#623). The mon windows and their levels differ per
|
||||
-- version; the TM window is identical in all three, so it is shared.
|
||||
local PRIZE_TMS = {
|
||||
{ kind = "item", item = "TM_DRAGON_RAGE", cost = 3300 },
|
||||
{ kind = "item", item = "TM_HYPER_BEAM", cost = 5500 },
|
||||
{ kind = "item", item = "TM_SUBSTITUTE", cost = 7700 },
|
||||
}
|
||||
local RED_PRIZES = {
|
||||
{ kind = "mon", species = "ABRA", level = 9, cost = 180 },
|
||||
{ kind = "mon", species = "CLEFAIRY", level = 8, cost = 500 },
|
||||
{ kind = "mon", species = "NIDORINA", level = 17, cost = 1200 },
|
||||
{ kind = "mon", species = "DRATINI", level = 18, cost = 2800 },
|
||||
{ kind = "mon", species = "SCYTHER", level = 25, cost = 5500 },
|
||||
{ kind = "mon", species = "PORYGON", level = 26, cost = 9999 },
|
||||
PRIZE_TMS[1], PRIZE_TMS[2], PRIZE_TMS[3],
|
||||
local RED_PRIZE_WINDOWS = {
|
||||
{
|
||||
{ kind = "mon", species = "ABRA", level = 9, cost = 180 },
|
||||
{ kind = "mon", species = "CLEFAIRY", level = 8, cost = 500 },
|
||||
{ kind = "mon", species = "NIDORINA", level = 17, cost = 1200 },
|
||||
},
|
||||
{
|
||||
{ kind = "mon", species = "DRATINI", level = 18, cost = 2800 },
|
||||
{ kind = "mon", species = "SCYTHER", level = 25, cost = 5500 },
|
||||
{ kind = "mon", species = "PORYGON", level = 26, cost = 9999 },
|
||||
},
|
||||
PRIZE_TMS,
|
||||
}
|
||||
local BLUE_PRIZES = {
|
||||
{ kind = "mon", species = "ABRA", level = 6, cost = 120 },
|
||||
{ kind = "mon", species = "CLEFAIRY", level = 12, cost = 750 },
|
||||
{ kind = "mon", species = "NIDORINO", level = 17, cost = 1200 },
|
||||
{ kind = "mon", species = "PINSIR", level = 20, cost = 2500 },
|
||||
{ kind = "mon", species = "DRATINI", level = 24, cost = 4600 },
|
||||
{ kind = "mon", species = "PORYGON", level = 18, cost = 6500 },
|
||||
PRIZE_TMS[1], PRIZE_TMS[2], PRIZE_TMS[3],
|
||||
local BLUE_PRIZE_WINDOWS = {
|
||||
{
|
||||
{ kind = "mon", species = "ABRA", level = 6, cost = 120 },
|
||||
{ kind = "mon", species = "CLEFAIRY", level = 12, cost = 750 },
|
||||
{ kind = "mon", species = "NIDORINO", level = 17, cost = 1200 },
|
||||
},
|
||||
{
|
||||
{ kind = "mon", species = "PINSIR", level = 20, cost = 2500 },
|
||||
{ kind = "mon", species = "DRATINI", level = 24, cost = 4600 },
|
||||
{ kind = "mon", species = "PORYGON", level = 18, cost = 6500 },
|
||||
},
|
||||
PRIZE_TMS,
|
||||
}
|
||||
-- Yellow keeps the three windows but restocks both mon counters
|
||||
-- (pokeyellow/data/events/prizes.asm, prize_mon_levels.asm)
|
||||
local YELLOW_PRIZE_WINDOWS = {
|
||||
{
|
||||
{ kind = "mon", species = "ABRA", level = 15, cost = 230 },
|
||||
{ kind = "mon", species = "VULPIX", level = 18, cost = 1000 },
|
||||
{ kind = "mon", species = "WIGGLYTUFF", level = 22, cost = 2680 },
|
||||
},
|
||||
{
|
||||
{ kind = "mon", species = "SCYTHER", level = 30, cost = 6500 },
|
||||
{ kind = "mon", species = "PINSIR", level = 30, cost = 6500 },
|
||||
{ kind = "mon", species = "PORYGON", level = 26, cost = 9999 },
|
||||
},
|
||||
PRIZE_TMS,
|
||||
}
|
||||
|
||||
local function activePrizes()
|
||||
return require("src.core.GameVersion").isBlue() and BLUE_PRIZES or RED_PRIZES
|
||||
local function prizeWindow(n)
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local windows = RED_PRIZE_WINDOWS
|
||||
if GameVersion.isBlue() then
|
||||
windows = BLUE_PRIZE_WINDOWS
|
||||
elseif GameVersion.isYellow() then
|
||||
windows = YELLOW_PRIZE_WINDOWS
|
||||
end
|
||||
return windows[n]
|
||||
end
|
||||
|
||||
-- Prize counters (engine/menus/prize_menu.asm CeladonPrizeMenu; the prize
|
||||
-- Prize counters (engine/events/prize_menu.asm CeladonPrizeMenu; the prize
|
||||
-- list itself is data/events/prizes.asm, prize_mon_levels.asm). Gen1 gates
|
||||
-- the prize window on the COIN CASE: it does IsItemInBag COIN_CASE first, and
|
||||
-- with no case prints RequireCoinCaseText and returns without ever opening a
|
||||
-- window; only with the case does it print ExchangeCoinsForPrizesText and then
|
||||
-- show the prizes. #194: the port used to open the window unconditionally and
|
||||
-- skip both text boxes.
|
||||
local function prizeCounter(game, ow, npc, done)
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
local Commands = require("src.script.Commands")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local t = game.data.text
|
||||
-- IsItemInBag COIN_CASE: without the case, deny and open no window
|
||||
-- (COIN_CASE is a numeric count in save.inventory, nil when absent).
|
||||
if not game.save.inventory.COIN_CASE then
|
||||
-- skip both text boxes. wMaxMenuItem is 3, i.e. this window's three prizes
|
||||
-- plus the NO THANKS row, and HandlePrizeChoice confirms the pick with
|
||||
-- SoYouWantPrizeText + YesNoChoice before any coins move; every branch then
|
||||
-- rets out of CeladonPrizeMenu, so one transaction ends the conversation and
|
||||
-- buying again means talking to the counter again (#623).
|
||||
local function prizeCounter(window)
|
||||
return function(game, ow, npc, done)
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
local Commands = require("src.script.Commands")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local t = game.data.text
|
||||
-- IsItemInBag COIN_CASE: without the case, deny and open no window
|
||||
-- (COIN_CASE is a numeric count in save.inventory, nil when absent).
|
||||
if not game.save.inventory.COIN_CASE then
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._RequireCoinCaseText or "A COIN CASE is\nrequired!", done))
|
||||
return
|
||||
end
|
||||
-- ExchangeCoinsForPrizesText plays before the prize window opens.
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._RequireCoinCaseText or "A COIN CASE is\nrequired!", done))
|
||||
return
|
||||
end
|
||||
-- ExchangeCoinsForPrizesText plays before the prize window opens.
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._ExchangeCoinsForPrizesText or "We exchange your\ncoins for prizes.",
|
||||
function()
|
||||
local items = {}
|
||||
for _, p in ipairs(activePrizes()) do
|
||||
local label
|
||||
if p.kind == "mon" then
|
||||
label = ("%s L%d"):format(game.data.pokemon[p.species].name, p.level)
|
||||
else
|
||||
label = game.data.items[p.item].name
|
||||
t._ExchangeCoinsForPrizesText or "We exchange your\ncoins for prizes.",
|
||||
function()
|
||||
local items = {}
|
||||
for _, p in ipairs(prizeWindow(window)) do
|
||||
local label
|
||||
if p.kind == "mon" then
|
||||
label = ("%s L%d"):format(game.data.pokemon[p.species].name, p.level)
|
||||
else
|
||||
label = game.data.items[p.item].name
|
||||
end
|
||||
table.insert(items,
|
||||
{ label = label, right = tostring(p.cost), value = p })
|
||||
end
|
||||
table.insert(items,
|
||||
{ label = label, right = tostring(p.cost), value = p })
|
||||
end
|
||||
local list
|
||||
list = ListMenu.new(game, "PRIZES (COINS)", items, {
|
||||
footer = ("COINS %d"):format(game.save.coins or 0),
|
||||
onChoose = function(item)
|
||||
local p = item.value
|
||||
-- NoThanksText (data/events/prizes.asm) sits under the three prizes
|
||||
table.insert(items, { label = "NO THANKS" })
|
||||
local list
|
||||
-- close the window first: every ending in HandlePrizeChoice leaves
|
||||
-- the menu for good, and the closing line belongs over the map
|
||||
local function finish(msg)
|
||||
list:close()
|
||||
game.stack:push(TextBox.new(game, msg, done))
|
||||
end
|
||||
local function buy(p)
|
||||
if (game.save.coins or 0) < p.cost then
|
||||
list.footer = "Not enough coins!"
|
||||
finish(t._SorryNeedMoreCoinsText or "Sorry, you need\nmore coins.")
|
||||
return
|
||||
end
|
||||
-- HasEnoughCoins passed, so hand the prize over first and only
|
||||
-- subtract once it landed: the asm rets before .subtractCoins when
|
||||
-- the bag is full, or when both the party and every box are full
|
||||
local roomless = t._OopsYouDontHaveEnoughRoomText
|
||||
or "Oops! You don't\nhave enough room."
|
||||
if p.kind == "mon" then
|
||||
-- no runner here, so give_pokemon reports through ctx.lastCheck
|
||||
-- and skips the AskName prompt (Commands.give_pokemon)
|
||||
local ctx = { save = game.save, game = game }
|
||||
Commands.give_pokemon(ctx, p.species, p.level)
|
||||
if not ctx.lastCheck then
|
||||
finish(roomless)
|
||||
return
|
||||
end
|
||||
elseif not require("src.inventory.Bag").add(
|
||||
game.save, p.item, 1, game.data) then
|
||||
finish(roomless)
|
||||
return
|
||||
end
|
||||
game.save.coins = game.save.coins - p.cost
|
||||
if p.kind == "mon" then
|
||||
Commands.give_pokemon({ save = game.save, game = game },
|
||||
p.species, p.level)
|
||||
else
|
||||
game.save.inventory[p.item] = (game.save.inventory[p.item] or 0) + 1
|
||||
end
|
||||
list.footer = ("Got it! COINS %d"):format(game.save.coins)
|
||||
end,
|
||||
onCancel = done,
|
||||
})
|
||||
game.stack:push(list)
|
||||
end))
|
||||
-- no thank-you line: HereYouGoText is unreferenced in the asm,
|
||||
-- which just redraws the coin box (PrintPrizePrice) and returns
|
||||
list:close()
|
||||
done()
|
||||
end
|
||||
list = ListMenu.new(game, "PRIZES (COINS)", items, {
|
||||
footer = ("COINS %d"):format(game.save.coins or 0),
|
||||
onChoose = function(item)
|
||||
local p = item.value
|
||||
if not p then -- NO THANKS is the B exit (cp 3 -> .noChoice)
|
||||
list:close()
|
||||
done()
|
||||
return
|
||||
end
|
||||
local name = (p.kind == "mon")
|
||||
and game.data.pokemon[p.species].name
|
||||
or game.data.items[p.item].name
|
||||
-- SoYouWantPrizeText names the prize out of wNameBuffer, which
|
||||
-- is not one of TextBox's RAM tokens, so fill it in here
|
||||
local ask = (t._SoYouWantPrizeText
|
||||
or "So, you want\n{RAM:wNameBuffer}?")
|
||||
:gsub("{RAM:wNameBuffer}", name)
|
||||
game.stack:push(TextBox.new(game, ask, nil, {
|
||||
choice = function(yes)
|
||||
if not yes then
|
||||
finish(t._OhFineThenText or "Oh, fine then.")
|
||||
return
|
||||
end
|
||||
buy(p)
|
||||
end,
|
||||
}))
|
||||
end,
|
||||
onCancel = done,
|
||||
})
|
||||
game.stack:push(list)
|
||||
end))
|
||||
end
|
||||
end
|
||||
|
||||
M.GAME_CORNER_PRIZE_ROOM = {
|
||||
talk = { -- the three prize counters are bg events
|
||||
TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_1 = prizeCounter,
|
||||
TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_2 = prizeCounter,
|
||||
TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_3 = prizeCounter,
|
||||
talk = { -- the three prize counters are bg events, one window each
|
||||
TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_1 = prizeCounter(1),
|
||||
TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_2 = prizeCounter(2),
|
||||
TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_3 = prizeCounter(3),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ local function gift(opts)
|
||||
end
|
||||
end)
|
||||
end
|
||||
if opts.pre then say(opts.pre, "", give) else give() end
|
||||
if opts.pre then say(opts.pre, opts.preFallback or "", give) else give() end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -118,11 +118,21 @@ M.CINNABAR_LAB_METRONOME_ROOM = {
|
||||
},
|
||||
}
|
||||
|
||||
-- TM42 Dream Eater (scripts/ViridianCity.asm, the fisher; no pre text)
|
||||
-- TM42 Dream Eater (scripts/ViridianCity.asm, the fisher). The fisher's
|
||||
-- YouCanHaveThisText prints before GiveItem, so this gift needs a pre
|
||||
-- text (#775). Like the SilphCo2F worker (#393) that label carries no
|
||||
-- leading underscore, and on Red it sits outside the extractor's symbol
|
||||
-- set, so the literal from text/ViridianCity.asm rides along as the
|
||||
-- fallback; Yellow resolves the ROM string instead.
|
||||
M.VIRIDIAN_CITY = {
|
||||
talk = {
|
||||
TEXT_VIRIDIANCITY_FISHER = gift({
|
||||
flag = "EVENT_GOT_TM42", item = "TM_DREAM_EATER",
|
||||
pre = "ViridianCityFisherYouCanHaveThisText",
|
||||
preFallback = "Yawn!\nI must have dozed\voff in the sun."
|
||||
.. "\fI had this dream\nabout a DROWZEE\veating my dream."
|
||||
.. "\vWhat's this?\vWhere did this TM\vcome from?"
|
||||
.. "\fThis is spooky!\nHere, you can\vhave this TM.",
|
||||
received = "_ViridianCityFisherReceivedTM42Text",
|
||||
explain = "_ViridianCityFisherTM42ExplanationText",
|
||||
noRoom = "_ViridianCityFisherTM42NoRoomText",
|
||||
|
||||
@@ -108,8 +108,16 @@ return {
|
||||
"_ViridianGymGiovanniTM27ExplanationText",
|
||||
} },
|
||||
|
||||
-- Silph Co. Giovanni: unlocks the president's Master Ball gift
|
||||
["OPP_GIOVANNI#2"] = { flag = "EVENT_BEAT_SILPH_CO_GIOVANNI" },
|
||||
-- Silph Co. Giovanni: unlocks the president's Master Ball gift.
|
||||
-- SilphCo11FGiovanniStartBattleScript (scripts/SilphCo11F.asm) hands the
|
||||
-- battle SilphCo10FGiovanniILostAgainText through SaveEndBattleTextPointers,
|
||||
-- but he has no def_trainers header on 11F, so engageTrainer finds no
|
||||
-- header.won to give it -- this chain is the port's stand-in for that loss
|
||||
-- line (#722). The "Blast it all!" speech, the fade and the rockets
|
||||
-- leaving are SilphCo11FGiovanniAfterBattleScript, ported in M.SILPH_CO_11F
|
||||
-- (data/scripts/story.lua).
|
||||
["OPP_GIOVANNI#2"] = { flag = "EVENT_BEAT_SILPH_CO_GIOVANNI",
|
||||
dialogue = { "_SilphCo10FGiovanniILostAgainText" } },
|
||||
|
||||
-- Fighting Dojo Karate Master (scripts/FightingDojo.asm
|
||||
-- FightingDojoKarateMasterPostBattleScript sets EVENT_BEAT_KARATE_MASTER,
|
||||
|
||||
@@ -24,7 +24,8 @@
|
||||
-- ViridianCityPostInitialCatchTraining): stepping into (19,9) -- the gap
|
||||
-- east of the sleeper's cell -- faces the old man right and the player
|
||||
-- left, prints the apology, and without any choice runs the demo battle
|
||||
-- (BATTLE_TYPE_OLD_MAN, RATTATA lvl 5). After it, the same text pointer
|
||||
-- (BATTLE_TYPE_OLD_MAN, RATTATA lvl 5), which he FAILS -- the ball shakes
|
||||
-- three times and breaks open. After it, the same text pointer
|
||||
-- now prints _ViridianCityOldManLosingMyTouchText ("That didn't work!
|
||||
-- I must be losing my touch."), the old man walks off (down 6 with the
|
||||
-- player on (19,9), right 1 otherwise, Pikachu nudged out of the way
|
||||
@@ -63,7 +64,12 @@ end
|
||||
local function oldMan2Rows(game, ow, npc)
|
||||
local rows = {
|
||||
{ "show_text", "_ViridianCityOldManHadMyCoffeeNowText" },
|
||||
{ "old_man_demo" },
|
||||
-- ViridianCityOldManInitialCatchTrainingScript sets
|
||||
-- EVENT_INITIAL_CATCH_TRAINING before the battle runs, and
|
||||
-- ItemUseBall's .oldManBattle branch turns that event into anim data
|
||||
-- $63: three shakes, then the ball breaks open. The losing-my-touch
|
||||
-- line below only follows a throw that failed (#636).
|
||||
{ "old_man_demo", "fail" },
|
||||
{ "set_flag", "EVENT_COMPLETED_CATCH_TRAINING" },
|
||||
{ "show_text", "_ViridianCityOldManLosingMyTouchText" },
|
||||
}
|
||||
|
||||
@@ -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,29 @@ 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` under the same
|
||||
root `persistFs` writes slots to: the portable game folder when `portable.txt`
|
||||
marks the install, otherwise the save directory (`exports/` and
|
||||
`exports/<version>/` are created as needed; #752). On desktop it returns the
|
||||
absolute path (`SaveData.portableBaseDir()` when portable, else
|
||||
`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
|
||||
|
||||
@@ -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,28 @@ 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, `bold`, which double-prints at a 1px
|
||||
offset for fonts whose strokes read too light, and `tiles`, the
|
||||
characters that keep their ROM tile instead of coming from the TTF.
|
||||
|
||||
`tiles` matters for a CJK translation. Sizing the font so a kana fills
|
||||
the 8px cell leaves Latin narrower than the tile font it replaces, which
|
||||
pulls the numeric columns out of line: the party menu's `:L12` stops
|
||||
sitting over `34/ 34`. Naming `"0123456789/:"` keeps those on the
|
||||
vanilla tiles, so numbers render exactly as they do in English while
|
||||
kana still come from the font. It takes a string of characters, or a
|
||||
list when a multi-character charmap sequence is meant.
|
||||
|
||||
See the wiki's Translations guide.
|
||||
|
||||
## Save editor (bundled, reachable from the launcher)
|
||||
@@ -447,6 +480,16 @@ automatically, so the tab opens on an "Add an index" prompt until you name
|
||||
one; paste an index URL or its `owner/repo` and it is remembered in
|
||||
`options.lua`. More than one index can be added, and the listings merge.
|
||||
|
||||
A feed author can publish per-mod release stats by adding three optional
|
||||
fields to an entry -- `downloads` (total across every release), and
|
||||
`first_release` / `last_release` (ISO days) -- which the listing shows in
|
||||
the same gold line the MODS tab uses. When a feed does not carry them,
|
||||
the row fetches the mod's own GitHub releases instead -- the same cached
|
||||
`ModUpdate` fetch the MODS tab uses, one entry per frame -- so the stats
|
||||
appear for any mod with a `github` field regardless of feed maintenance.
|
||||
The fields are additive: feeds that carry them stay readable by every
|
||||
build that predates them, and feeds that do not render exactly as before.
|
||||
|
||||
## Soft reset (all versions)
|
||||
|
||||
Holding A, B, START and SELECT together restarts the game the way flicking
|
||||
|
||||
@@ -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)
|
||||
@@ -189,6 +189,12 @@ flexlove._accumulatedDt = 0
|
||||
---@type table<string, Element>
|
||||
flexlove._touchOwners = {}
|
||||
|
||||
-- Touch-drag scroll tracking: survives immediate-mode element recreation.
|
||||
-- Maps touch ID -> { id, lastX, lastY }. Scroll position is persisted into
|
||||
-- StateManager on every move (same contract as wheelmoved).
|
||||
---@type table<string, {id: string, lastX: number, lastY: number}>
|
||||
flexlove._touchScroll = {}
|
||||
|
||||
---@type table<number, boolean>
|
||||
flexlove._mouseButtonStates = {}
|
||||
|
||||
@@ -224,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 },
|
||||
@@ -1412,6 +1420,82 @@ function flexlove._getTouchElementAtPosition(x, y)
|
||||
return candidates[1]
|
||||
end
|
||||
|
||||
local function elementIsTouchScrollable(element)
|
||||
if not element or not element._scrollManager then
|
||||
return false
|
||||
end
|
||||
local overflowX = element.overflowX or element.overflow
|
||||
local overflowY = element.overflowY or element.overflow
|
||||
return overflowX == "scroll"
|
||||
or overflowX == "auto"
|
||||
or overflowY == "scroll"
|
||||
or overflowY == "auto"
|
||||
end
|
||||
|
||||
-- Walk parents from a hit target so a finger on a button/row still scrolls
|
||||
-- the containing list. Falls back to the wheel path's scrollable lookup when
|
||||
-- the press lands on empty space inside a scroller.
|
||||
local function findTouchScrollTarget(x, y, startElement)
|
||||
local el = startElement
|
||||
while el do
|
||||
if elementIsTouchScrollable(el) then
|
||||
return el
|
||||
end
|
||||
el = el.parent
|
||||
end
|
||||
return Context.findScrollableAtPosition(x, y)
|
||||
end
|
||||
|
||||
local function findElementByStateId(stateId)
|
||||
if not stateId or stateId == "" then
|
||||
return nil
|
||||
end
|
||||
local function walk(element)
|
||||
if element.id == stateId or element._stateId == stateId then
|
||||
return element
|
||||
end
|
||||
for _, child in ipairs(element.children or {}) do
|
||||
local found = walk(child)
|
||||
if found then
|
||||
return found
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
for _, element in ipairs(flexlove.topElements or {}) do
|
||||
local found = walk(element)
|
||||
if found then
|
||||
return found
|
||||
end
|
||||
end
|
||||
for _, element in ipairs(flexlove._currentFrameElements or {}) do
|
||||
local found = walk(element)
|
||||
if found then
|
||||
return found
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function persistTouchScroll(element)
|
||||
if flexlove._immediateMode and element and element._stateId and element._scrollManager then
|
||||
StateManager.updateState(element._stateId, {
|
||||
scrollManager = element._scrollManager:getState(),
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
local function resolveTouchScrollElement(track)
|
||||
if not track then
|
||||
return nil
|
||||
end
|
||||
local element = findElementByStateId(track.id)
|
||||
if elementIsTouchScrollable(element) then
|
||||
return element
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
--- Handle touch press events from LÖVE's touch input system
|
||||
--- Routes touch to the topmost element at the touch position and assigns touch ownership
|
||||
--- Hook this to love.touchpressed() to enable touch interaction
|
||||
@@ -1452,15 +1536,22 @@ function flexlove.touchpressed(id, x, y, dx, dy, pressure)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Route to scroll manager for scrollable elements
|
||||
if element._scrollManager then
|
||||
local overflowX = element.overflowX or element.overflow
|
||||
local overflowY = element.overflowY or element.overflow
|
||||
if overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto" then
|
||||
element._scrollManager:handleTouchPress(touchX, touchY)
|
||||
end
|
||||
-- Scroll target is the nearest scrollable ancestor (or the scroller under
|
||||
-- empty space). Tracked by stable id so immediate-mode recreation can resume.
|
||||
local scrollEl = findTouchScrollTarget(touchX, touchY, element)
|
||||
if scrollEl and scrollEl._scrollManager then
|
||||
local scrollId = scrollEl._stateId or scrollEl.id
|
||||
if scrollId and scrollId ~= "" then
|
||||
flexlove._touchScroll[touchId] = {
|
||||
id = scrollId,
|
||||
lastX = touchX,
|
||||
lastY = touchY,
|
||||
}
|
||||
end
|
||||
scrollEl._scrollManager:handleTouchPress(touchX, touchY)
|
||||
persistTouchScroll(scrollEl)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1500,15 +1591,21 @@ function flexlove.touchmoved(id, x, y, dx, dy, pressure)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Route to scroll manager for scrollable elements
|
||||
if element._scrollManager then
|
||||
local overflowX = element.overflowX or element.overflow
|
||||
local overflowY = element.overflowY or element.overflow
|
||||
if overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto" then
|
||||
element._scrollManager:handleTouchMove(touchX, touchY)
|
||||
end
|
||||
local track = flexlove._touchScroll[touchId]
|
||||
local scrollEl = resolveTouchScrollElement(track)
|
||||
if track and scrollEl then
|
||||
local sm = scrollEl._scrollManager
|
||||
-- Immediate mode recreates managers each frame; re-arm drag from the
|
||||
-- last persisted touch point so a move after beginFrame still scrolls.
|
||||
if not sm._touchScrolling then
|
||||
sm:handleTouchPress(track.lastX, track.lastY)
|
||||
end
|
||||
sm:handleTouchMove(touchX, touchY)
|
||||
persistTouchScroll(scrollEl)
|
||||
track.lastX = touchX
|
||||
track.lastY = touchY
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1548,19 +1645,23 @@ function flexlove.touchreleased(id, x, y, dx, dy, pressure)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Route to scroll manager for scrollable elements
|
||||
if element._scrollManager then
|
||||
local overflowX = element.overflowX or element.overflow
|
||||
local overflowY = element.overflowY or element.overflow
|
||||
if overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto" then
|
||||
element._scrollManager:handleTouchRelease()
|
||||
end
|
||||
local track = flexlove._touchScroll[touchId]
|
||||
local scrollEl = resolveTouchScrollElement(track)
|
||||
if track and scrollEl then
|
||||
local sm = scrollEl._scrollManager
|
||||
if not sm._touchScrolling then
|
||||
sm:handleTouchPress(track.lastX, track.lastY)
|
||||
end
|
||||
sm:handleTouchMove(touchX, touchY)
|
||||
sm:handleTouchRelease()
|
||||
persistTouchScroll(scrollEl)
|
||||
end
|
||||
|
||||
-- Clean up touch ownership (touch is complete)
|
||||
flexlove._touchOwners[touchId] = nil
|
||||
flexlove._touchScroll[touchId] = nil
|
||||
end
|
||||
|
||||
--- Get the number of currently active touches being tracked
|
||||
@@ -1659,6 +1760,7 @@ function flexlove.destroy()
|
||||
|
||||
-- Clean up touch state
|
||||
flexlove._touchOwners = {}
|
||||
flexlove._touchScroll = {}
|
||||
flexlove._mouseButtonStates = {}
|
||||
if flexlove._gestureRecognizer then
|
||||
flexlove._gestureRecognizer:reset()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -107,6 +107,11 @@ function FONT_CACHE.get(size, fontPath)
|
||||
font = love.graphics.newFont(size)
|
||||
end
|
||||
|
||||
-- Per-glyph fallback so a non-Latin UI string is not drawn as tofu.
|
||||
-- pcall'd require: FlexLove is vendored and must still load standalone.
|
||||
local okUi, UiFont = pcall(require, "src.render.UiFont")
|
||||
if okUi and UiFont then UiFont.attach(font, size) end
|
||||
|
||||
-- Add to cache with LRU metadata
|
||||
FONT_CACHE[cacheKey] = {
|
||||
font = font,
|
||||
|
||||
@@ -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
|
||||
@@ -256,6 +313,18 @@ function love.load(args)
|
||||
return
|
||||
end
|
||||
|
||||
-- The launcher draws before any game boots, so the mod loader has not run
|
||||
-- and Strings has no catalog. Routing the launcher's text through Strings
|
||||
-- (#767) only pays off if something fills that catalog this early, and no
|
||||
-- restart could: the ordering is the same on every launch. Read the
|
||||
-- enabled mods' string catalogs -- data only, no entry chunk -- so a
|
||||
-- translation reaches the launcher too. Game:load replaces this with the
|
||||
-- real merged catalog once a version boots.
|
||||
do
|
||||
local preload = require("src.mods.LauncherMods").translationStrings()
|
||||
if preload then require("src.core.Strings").load({ strings = preload }) end
|
||||
end
|
||||
|
||||
-- Interactive: the launcher always runs. Red, Blue, and Yellow are each
|
||||
-- live: a column shows Play when that game's ROM is already imported, or
|
||||
-- Choose ROM / drag-drop when it is not. Any dropped .gb is routed by its
|
||||
@@ -273,6 +342,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 +419,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 +564,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 +574,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.
|
||||
@@ -422,10 +604,10 @@ function love.touchpressed(id, x, y, dx, dy, pressure)
|
||||
return TouchEditor.touchpressed(id, x, y)
|
||||
end
|
||||
if Importer then
|
||||
if love.system.getOS() == "iOS" then
|
||||
return Importer:touchpressed(id, x, y)
|
||||
end
|
||||
return Importer:mousepressed(x, y, 1)
|
||||
-- Both mobiles: FlexLove scroll needs the real touch stream. Clicks are
|
||||
-- polled inside the view; the istouch filter on mousepressed still drops
|
||||
-- Android's synthesized mouse twin so Import cannot double-fire (#553).
|
||||
return Importer:touchpressed(id, x, y, dx, dy, pressure)
|
||||
end
|
||||
Game:touchpressed(id, x, y)
|
||||
end
|
||||
@@ -437,10 +619,7 @@ function love.touchmoved(id, x, y, dx, dy, pressure)
|
||||
return TouchEditor.touchmoved(id, x, y)
|
||||
end
|
||||
if Importer then
|
||||
if love.system.getOS() == "iOS" then
|
||||
return Importer:touchmoved(id, x, y)
|
||||
end
|
||||
return
|
||||
return Importer:touchmoved(id, x, y, dx, dy, pressure)
|
||||
end
|
||||
Game:touchmoved(id, x, y)
|
||||
end
|
||||
@@ -452,10 +631,7 @@ function love.touchreleased(id, x, y, dx, dy, pressure)
|
||||
return TouchEditor.touchreleased(id, x, y)
|
||||
end
|
||||
if Importer then
|
||||
if love.system.getOS() == "iOS" then
|
||||
return Importer:touchreleased(id, x, y)
|
||||
end
|
||||
return
|
||||
return Importer:touchreleased(id, x, y, dx, dy, pressure)
|
||||
end
|
||||
Game:touchreleased(id, x, y)
|
||||
end
|
||||
@@ -478,25 +654,20 @@ function love.mousepressed(x, y, button, istouch)
|
||||
return TouchEditor.mousepressed(x, y, button)
|
||||
end
|
||||
if Importer then
|
||||
-- The same double-fire TouchEditor guards against, which the launcher was
|
||||
-- missing: love.touchpressed above forwards the primary touch to the
|
||||
-- Importer on Android, and LÖVE ALSO synthesizes a mouse press for that
|
||||
-- same touch, so one tap ran every launcher button twice. On Import that
|
||||
-- meant two choose() calls and two stacked SAF picker activities: the
|
||||
-- player picked their ROM, the top picker closed, and the second was still
|
||||
-- underneath asking for it again, which is the "import the file twice"
|
||||
-- in #553. Filtering on istouch keeps a real mouse (DeX, a Chromebook, a
|
||||
-- USB mouse) working, which an Android-wide return would have broken.
|
||||
--
|
||||
-- ANDROID ONLY, and the OS test is load bearing: love.touchpressed above
|
||||
-- returns early on iOS and never forwards, so there the synthesized mouse
|
||||
-- press is the ONLY event the launcher gets. Filtering istouch on both
|
||||
-- killed every tap on iOS outright.
|
||||
-- love.touchpressed already forwards the primary touch into FlexLove for
|
||||
-- scroll. LÖVE ALSO synthesizes a mouse press for that same touch; if both
|
||||
-- reached a press handler, one tap ran every launcher button twice and
|
||||
-- stacked two SAF pickers (#553). Clicks are polled inside FlexLove from
|
||||
-- love.touch / mouse.isDown, so dropping the synthesized istouch press is
|
||||
-- safe. A real mouse (DeX, Chromebook, USB) still reaches mousepressed.
|
||||
if istouch and (love.system.getOS() == "Android"
|
||||
or love.system.getOS() == "iOS") then return end
|
||||
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
|
||||
|
||||
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 42 KiB |
@@ -12,6 +12,48 @@
|
||||
"tintColor": "3b5ca8",
|
||||
"category": "games",
|
||||
"versions": [
|
||||
{
|
||||
"version": "0.1.66",
|
||||
"date": "2026-08-04",
|
||||
"size": 9749980,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.66/gen1recomp-0.1.66-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #691 Save file transfer\n- #743 Cannot Scroll Main Menu/Mod Menu\n- #779 \"Enemy \" untranslateable\n\n## Contributors\n\n- @bryanthaboi\n- @jherediagu\n- @ShaneMcGovernIE\n- @vegerot"
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"size": 9316977,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.63/gen1recomp-0.1.63-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #623 Game Corner Prize Exchange various issues\n- #624 Coins purschase menu missing your current money and coins\n- #636 Inaccurate Old Man tutorial in yellow + misc issues relating to him\n- #637 True color broken on Hall of Fame and Party Stats when using the wide battle option\n- #639 Pokédex can't show the correct stats when you catch 100 or more pokémon + overly simplified layout\n- #650 Rocket Hideout Progression Bug - Stuck with door closed after defeating Jessie and James in Yellow\n- #697 Missaligned HUD on Hall of Fame\n- #704 Player doesn't follow Prof. Oak to the Hall of Fame on the first screen\n- #722 Issues with Giovanni in Silph C.O.\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.62",
|
||||
"date": "2026-08-03",
|
||||
"size": 9309934,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.62/gen1recomp-0.1.62-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.61",
|
||||
"date": "2026-08-03",
|
||||
"size": 9052744,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.61/gen1recomp-0.1.61-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #325 Launcher crashes importing a mod .zip with non-ASCII characters in the filename (Windows)\n- #646 Fuchsia City Pokemon not being added to Pokedex.\n- #651 Parasect trading NPC mute and deaf\n- #665 fix: file path sanitizing is needed\n- #706 Champion theme playing too late\n- #709 Fainted Pokemon plays its normal cry instead of the pitched-down, slower faint cry\n- #719 Silph C.O workers dialog bug.\n\n## Contributors\n\n- @bryanthaboi\n- @castdrian\n- @jherediagu\n- @johnjohto\n- @ShaneMcGovernIE\n- hernan"
|
||||
},
|
||||
{
|
||||
"version": "0.1.60",
|
||||
"date": "2026-08-02",
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
@@ -83,9 +88,10 @@ fi
|
||||
# way once).
|
||||
for required in tools/save-editor/App.lua tools/save-editor/Kit.lua \
|
||||
tools/save-editor/panels/Party.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)"
|
||||
@@ -115,6 +121,32 @@ else
|
||||
say "version '$VERSION' is not X.Y.Z, shipping default engine (no stamp)"
|
||||
fi
|
||||
|
||||
# --------------------------------------------------------------- app icon
|
||||
# One source of truth for every platform's launcher icon; iOS resizes the
|
||||
# same file in scripts/build_ios.sh (apply_ios_icon) and the Android res/
|
||||
# drawables are generated from it too.
|
||||
ICON_SRC="$ROOT/assets/logo/gen1recomp_cover.png"
|
||||
|
||||
# pipx installs peresed (Windows exe icon patcher) here, off the default PATH.
|
||||
PATH="$PATH:$HOME/.local/bin"
|
||||
|
||||
make_icns() { # $1 = output .icns path
|
||||
[ -f "$ICON_SRC" ] || fail "missing icon source: $ICON_SRC"
|
||||
local iconset="$WORK/GameIcon.iconset" size scaled
|
||||
rm -rf "$iconset"; mkdir -p "$iconset"
|
||||
for size in 16 32 128 256 512; do
|
||||
sips -z "$size" "$size" "$ICON_SRC" --out "$iconset/icon_${size}x${size}.png" >/dev/null
|
||||
scaled=$((size * 2))
|
||||
sips -z "$scaled" "$scaled" "$ICON_SRC" --out "$iconset/icon_${size}x${size}@2x.png" >/dev/null
|
||||
done
|
||||
iconutil -c icns "$iconset" -o "$1"
|
||||
}
|
||||
|
||||
make_ico() { # $1 = output .ico path
|
||||
[ -f "$ICON_SRC" ] || fail "missing icon source: $ICON_SRC"
|
||||
magick "$ICON_SRC" -define icon:auto-resize=256,128,64,48,32,16 "$1"
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------- macOS
|
||||
build_mac() {
|
||||
say "building macOS app"
|
||||
@@ -141,9 +173,21 @@ build_mac() {
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $VERSION" "$plist" 2>/dev/null \
|
||||
|| /usr/libexec/PlistBuddy -c "Add :CFBundleVersion string $VERSION" "$plist"
|
||||
|
||||
if [ -f "$ROOT/assets/icon.icns" ]; then
|
||||
cp "$ROOT/assets/icon.icns" "$out_app/Contents/Resources/GameIcon.icns"
|
||||
# Brand the app icon. LÖVE.app resolves its icon through CFBundleIconName ->
|
||||
# Assets.car first, so overwriting the loose .icns files alone changes
|
||||
# nothing; the compiled asset catalog has to go and the plist has to fall
|
||||
# back to CFBundleIconFile.
|
||||
local icns="$ROOT/assets/icon.icns"
|
||||
if [ ! -f "$icns" ]; then
|
||||
icns="$WORK/GameIcon.icns"
|
||||
make_icns "$icns"
|
||||
fi
|
||||
cp "$icns" "$out_app/Contents/Resources/GameIcon.icns"
|
||||
cp "$icns" "$out_app/Contents/Resources/OS X AppIcon.icns"
|
||||
rm -f "$out_app/Contents/Resources/Assets.car"
|
||||
/usr/libexec/PlistBuddy -c "Delete :CFBundleIconName" "$plist" 2>/dev/null || true
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleIconFile OS X AppIcon" "$plist" 2>/dev/null \
|
||||
|| /usr/libexec/PlistBuddy -c "Add :CFBundleIconFile string 'OS X AppIcon'" "$plist"
|
||||
|
||||
local id="$IDENTITY"
|
||||
if [ -z "$id" ]; then
|
||||
@@ -217,7 +261,47 @@ build_win() {
|
||||
cp "$love_dir"/*.dll "$out_dir"/
|
||||
cp "$love_dir"/license.txt "$out_dir"/ 2>/dev/null || true
|
||||
|
||||
cat "$love_dir/love.exe" "$LOVE_FILE" > "$out_dir/$APP_NAME.exe"
|
||||
# The exe's icon lives in love.exe's PE resources, so it must be patched
|
||||
# BEFORE the .love is appended: peresed rewrites the whole file and would
|
||||
# drop the fused bytes. peresed (pipx install pe_tools) has no .ico input,
|
||||
# only raw --set-resource, so split the .ico into RT_ICON blobs plus a
|
||||
# GRPICONDIR that reuses love.exe's existing resource ids (1..N, lang 1033).
|
||||
local ico="$WORK/$APP_NAME.ico"
|
||||
make_ico "$ico"
|
||||
if command -v peresed >/dev/null 2>&1; then
|
||||
local ico_parts="$WORK/ico-parts"
|
||||
rm -rf "$ico_parts"; mkdir -p "$ico_parts"
|
||||
python3 - "$ico" "$ico_parts" <<'PY'
|
||||
import struct, sys
|
||||
data = open(sys.argv[1], "rb").read()
|
||||
outdir = sys.argv[2]
|
||||
count = struct.unpack_from("<H", data, 4)[0]
|
||||
group = struct.pack("<HHH", 0, 1, count)
|
||||
for i in range(count):
|
||||
w, h, colors, res, planes, bpp, size, off = struct.unpack_from("<BBBBHHII", data, 6 + 16 * i)
|
||||
open("%s/icon_%d.bin" % (outdir, i + 1), "wb").write(data[off:off + size])
|
||||
group += struct.pack("<BBBBHHIH", w, h, colors, res, planes, bpp, size, i + 1)
|
||||
open(outdir + "/group.bin", "wb").write(group)
|
||||
print(count)
|
||||
PY
|
||||
local n_icons args=()
|
||||
n_icons=$(ls "$ico_parts" | grep -c '^icon_')
|
||||
for i in $(seq 1 "$n_icons"); do
|
||||
args+=(-R RT_ICON "#$i" 1033 "$ico_parts/icon_$i.bin")
|
||||
done
|
||||
args+=(-R RT_GROUP_ICON "#1" 1033 "$ico_parts/group.bin")
|
||||
local exe_branded="$WORK/love-branded.exe"
|
||||
cp "$love_dir/love.exe" "$exe_branded"
|
||||
if peresed "${args[@]}" "$exe_branded" >/dev/null; then
|
||||
cat "$exe_branded" "$LOVE_FILE" > "$out_dir/$APP_NAME.exe"
|
||||
else
|
||||
warn "peresed failed to patch the exe icon, shipping stock LÖVE icon"
|
||||
cat "$love_dir/love.exe" "$LOVE_FILE" > "$out_dir/$APP_NAME.exe"
|
||||
fi
|
||||
else
|
||||
warn "peresed not found (pipx install pe_tools), shipping stock LÖVE exe icon"
|
||||
cat "$love_dir/love.exe" "$LOVE_FILE" > "$out_dir/$APP_NAME.exe"
|
||||
fi
|
||||
|
||||
local zip_out="$DIST/win/$APP_NAME-win64.zip"
|
||||
rm -f "$zip_out"
|
||||
@@ -272,6 +356,15 @@ build_linux() {
|
||||
unsquashfs -q -no-xattrs -o "$sfs_offset" -d "$appdir" "$love_appimage" >/dev/null
|
||||
|
||||
cp "$LOVE_FILE" "$appdir/game.love"
|
||||
|
||||
# The .desktop's Icon=love resolves against the AppDir root by basename,
|
||||
# so drop the stock love.svg and provide our PNG under the same name;
|
||||
# .DirIcon is what appimaged/thumbnailers show for the file itself.
|
||||
[ -f "$ICON_SRC" ] || fail "missing icon source: $ICON_SRC"
|
||||
rm -f "$appdir/love.svg" "$appdir/.DirIcon"
|
||||
sips -z 512 512 "$ICON_SRC" --out "$appdir/love.png" >/dev/null
|
||||
cp "$appdir/love.png" "$appdir/.DirIcon"
|
||||
|
||||
sed -i '' 's|^#FUSE_PATH="$APPDIR/my_game.love"$|FUSE_PATH="$APPDIR/game.love"|' "$appdir/AppRun"
|
||||
grep -q '^FUSE_PATH="\$APPDIR/game.love"$' "$appdir/AppRun" \
|
||||
|| fail "failed to enable FUSE_PATH in AppRun (upstream AppRun changed?)"
|
||||
|
||||
@@ -202,24 +202,39 @@ pack_game_love() {
|
||||
# APK, so the mod manager's Delete can't remove it and it reappears every
|
||||
# launch. Pokewalker ships as an importable .zip instead, which gives it
|
||||
# a real install/upgrade/delete lifecycle.
|
||||
# libs/ carries the vendored FlexLove toolkit the launcher UI is built on
|
||||
# (src/import/LauncherView.lua requires it at the top level, and RomImporter
|
||||
# calls into that view from both update and draw), so an archive without it
|
||||
# dies on the first frame with nothing left to fall back to.
|
||||
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
|
||||
main.lua conf.lua src data assets tools/save-editor \
|
||||
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' -x '*/.git/*' -x '*/.DS_Store' \
|
||||
-x 'data/generated/*' -x 'assets/generated/*')
|
||||
if unzip -Z1 "$LOVE_FILE" \
|
||||
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
|
||||
fail "game.love unexpectedly contains generated ROM data"
|
||||
fi
|
||||
# Do not pipe unzip straight into grep here: on a large archive grep can
|
||||
# finish early and make unzip report SIGPIPE under `set -o pipefail`.
|
||||
# List once and match against the captured text: piping unzip straight into
|
||||
# grep under `set -o pipefail` SIGPIPEs unzip as soon as grep exits early,
|
||||
# and the pipeline's 141 outranks grep's own status. For the generated-data
|
||||
# guard that inverted the test -- an archive that really did carry generated
|
||||
# ROM data made grep match, killed unzip, and the `if` read the 141 as "no
|
||||
# match" and let the build through (#774). Same listing feeds the
|
||||
# required-file gates below, as in scripts/build.sh and scripts/pack_love.sh.
|
||||
local archive_entries
|
||||
archive_entries="$(unzip -Z1 "$LOVE_FILE")"
|
||||
if grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/' \
|
||||
<<< "$archive_entries"; then
|
||||
fail "game.love unexpectedly contains generated ROM data"
|
||||
fi
|
||||
grep -qx 'tools/save-editor/App.lua' <<< "$archive_entries" \
|
||||
|| fail "game.love is missing the save editor (Edit on a save row would crash)"
|
||||
grep -qx "$YELLOW_MANIFEST_RELATIVE" <<< "$archive_entries" \
|
||||
|| fail "game.love is missing the Yellow ROM import manifest"
|
||||
# This gate exists because libs/ was added to scripts/build.sh's payload and
|
||||
# to no other packager, so Android and iOS built an APK/IPA whose launcher
|
||||
# threw on require("libs.flexlove.FlexLove") before drawing anything. Source
|
||||
# runs read libs/ off the working tree, so only a build can catch it.
|
||||
grep -qx 'libs/flexlove/FlexLove.lua' <<< "$archive_entries" \
|
||||
|| fail "game.love is missing the FlexLove UI toolkit (launcher dies on frame 1)"
|
||||
say "game.love: $(du -h "$LOVE_FILE" | cut -f1) -> $LOVE_FILE"
|
||||
|
||||
# This script packs its own game.love (it does not reuse build.sh's), so it
|
||||
|
||||
@@ -315,9 +315,13 @@ pack_game_love() {
|
||||
# it reappears every launch. Mods install as .zips at runtime instead
|
||||
# (launcher -> MODS -> Import mod .zip), the same lifecycle as every
|
||||
# other platform.
|
||||
# libs/ carries the vendored FlexLove toolkit the launcher UI is built on
|
||||
# (src/import/LauncherView.lua requires it at the top level, and RomImporter
|
||||
# calls into that view from both update and draw), so an archive without it
|
||||
# dies on the first frame with nothing left to fall back to.
|
||||
# shellcheck disable=SC2086 # MANIFESTS is a deliberate word list
|
||||
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
|
||||
main.lua conf.lua src data assets tools/save-editor \
|
||||
main.lua conf.lua src libs data assets tools/save-editor \
|
||||
$MANIFESTS \
|
||||
-x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
|
||||
-x 'data/generated/*' -x 'assets/generated/*')
|
||||
@@ -333,10 +337,14 @@ pack_game_love() {
|
||||
# in 0.1.45 through 0.1.47: decodeManifest (src/import/RomImporter.lua) errors
|
||||
# outright when a version's manifest is absent, so Import ROM on Yellow died
|
||||
# in the built app while dev, which reads the source tree, stayed green.
|
||||
# libs/flexlove/FlexLove.lua is on the list for the same reason: it was added
|
||||
# to build.sh's payload and to no other packager, so the mobile builds shipped
|
||||
# a launcher that threw before drawing its first frame.
|
||||
archive_entries="$(unzip -Z1 "$LOVE_FILE")"
|
||||
# shellcheck disable=SC2086 # MANIFESTS is a deliberate word list
|
||||
for required in src/update/Boot.lua tools/save-editor/App.lua \
|
||||
tools/save-editor/Kit.lua tools/save-editor/panels/Party.lua \
|
||||
libs/flexlove/FlexLove.lua \
|
||||
$MANIFESTS; do
|
||||
printf '%s\n' "$archive_entries" | grep -qx "$required" \
|
||||
|| fail "game.love is missing $required"
|
||||
@@ -687,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
|
||||
|
||||
@@ -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)"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -525,6 +528,10 @@ BattleState.StatBox = StatBox -- the level-up stat window (PrintStatsBox)
|
||||
local function newBattle(game)
|
||||
local self = setmetatable({}, BattleState)
|
||||
self.game = game
|
||||
-- InitBattleVariables (engine/battle/init_battle_variables.asm) zeroes
|
||||
-- wPartyAndBillsPCSavedMenuItem, so entering a battle drops the party
|
||||
-- cursor the field menu has been carrying (src/ui/PartyMenu.lua). #768
|
||||
game.partyMenuSavedIndex = nil
|
||||
self.data = game.data
|
||||
-- ruleset from the merged registry (the requires above are the same
|
||||
-- records on a mod-free boot); an unknown save value falls back to the
|
||||
@@ -641,6 +648,9 @@ function BattleState.newTrainer(game, oppClass, partyIndex)
|
||||
local self = newBattle(game)
|
||||
self.kind = "trainer"
|
||||
self.oppClass = oppClass
|
||||
-- the object_event trainer arg (roster index). computeMusicKind keys
|
||||
-- data/scripts/victories.lua on class#party, so keep it on the battle (#782).
|
||||
self.partyIndex = partyIndex or 1
|
||||
self.trainer = game.data.trainers[oppClass]
|
||||
assert(self.trainer, "unknown trainer class " .. tostring(oppClass))
|
||||
-- pret GetTrainerName_: RIVAL1/2/3 copy wRivalName into wTrainerName
|
||||
@@ -768,13 +778,23 @@ end
|
||||
-- engine/battle/core.asm DisplayBattleMenu .oldManName branch): no
|
||||
-- player mon; the battle menu appears under the OLD MAN's name and a
|
||||
-- scripted cursor hovers FIGHT, hops to ITEM and forces the item menu
|
||||
-- (one POKé BALL x50). The throw always catches; nothing is kept.
|
||||
-- (one POKé BALL x50). Nothing is kept.
|
||||
-- Yellow's Pallet intro (BATTLE_TYPE_PIKACHU) is the same simulated
|
||||
-- script under "PROF.OAK" (pokeyellow core.asm .profOakName), so the
|
||||
-- displayed thrower name is a parameter.
|
||||
function BattleState:makeOldManDemo(name)
|
||||
-- The throw catches everywhere except Yellow's FIRST Viridian training.
|
||||
-- ItemUseBall's .oldManBattle branch checks EVENT_INITIAL_CATCH_TRAINING
|
||||
-- and, when it is set, stores anim data $63 in place of the $43 capture
|
||||
-- value -- three shakes, then a breakout (pokeyellow
|
||||
-- engine/items/item_effects.asm). Red/Blue's ItemUseBall has no such
|
||||
-- branch and jumps straight to .captured, and Yellow's repeat "Watch
|
||||
-- closely!" demo resets the event before its battle
|
||||
-- (ViridianCityOldManStartCatchTrainingScript), so only the initial
|
||||
-- tutorial passes failThrow -- it stands in for that event (#636).
|
||||
function BattleState:makeOldManDemo(name, failThrow)
|
||||
self.demo = true
|
||||
self.demoName = name or "OLD MAN"
|
||||
self.demoFails = failThrow and true or false
|
||||
-- LoadPlayerBackPic and DisplayBattleMenu split on the same wBattleType:
|
||||
-- BATTLE_TYPE_OLD_MAN gets .oldManName + OldManPicBack, BATTLE_TYPE_PIKACHU
|
||||
-- gets .profOakName + ProfOakPicBack (pokeyellow core.asm). The thrower
|
||||
@@ -811,6 +831,16 @@ function BattleState:say(text)
|
||||
table.insert(self.queue, { text = text })
|
||||
end
|
||||
|
||||
-- A message whose ROM tail is `text_end` / `done` rather than `prompt`:
|
||||
-- NextTextCommand returns straight out of PrintText on TX_END
|
||||
-- (home/text.asm:328-334) and only TX_PROMPT_BUTTON blinks the arrow and
|
||||
-- runs ManualTextScroll (home/text.asm:434-446), so these pages never wait
|
||||
-- on the player. autoDelay is the frame hold before the queue moves on
|
||||
-- (0 = the next row starts immediately, as PrintText returning does) (#765).
|
||||
function BattleState:sayAuto(text, delay)
|
||||
table.insert(self.queue, { text = text, auto = true, autoDelay = delay or 0 })
|
||||
end
|
||||
|
||||
-- Message that opens YES/NO once typed out, keeping the text visible
|
||||
-- underneath (pokered `done` + TWO_OPTION_MENU / TextBox opts.choice).
|
||||
function BattleState:sayChoice(text, onChoose)
|
||||
@@ -851,6 +881,13 @@ function BattleState:sayNext(text)
|
||||
table.insert(self.queue, self.nextInsert, { text = text })
|
||||
end
|
||||
|
||||
-- sayNext for a page that ends in `text_end` (see sayAuto) (#765)
|
||||
function BattleState:sayNextAuto(text, delay)
|
||||
self.nextInsert = (self.nextInsert or 0) + 1
|
||||
table.insert(self.queue, self.nextInsert,
|
||||
{ text = text, auto = true, autoDelay = delay or 0 })
|
||||
end
|
||||
|
||||
-- insert a UI push right after the current queue item (dex page, the
|
||||
-- level-up stat box -- anything that must keep queue order)
|
||||
function BattleState:uiNext(factory)
|
||||
@@ -1001,6 +1038,8 @@ function BattleState:startMessage(item)
|
||||
self.charIndex = 0
|
||||
self.msgWaiting = nil
|
||||
self.msgPrompt = nil
|
||||
self.msgAutoWait = nil
|
||||
self.msgHold = nil
|
||||
self.scrollPx = nil
|
||||
self:beginMsgLine()
|
||||
end
|
||||
@@ -1255,7 +1294,25 @@ function BattleState:updateQueue()
|
||||
end))
|
||||
return true
|
||||
end
|
||||
if not (item and item.choice) then
|
||||
if item and item.auto then
|
||||
-- No prompt: this page's ROM tail is `text_end`, so PrintText is
|
||||
-- already back with the box still on screen -- pokered's used-move
|
||||
-- line (engine/battle/used_move_text.asm EndUsedMove1Text..
|
||||
-- EndUsedMove5Text) and the item-use line (ItemUseText00,
|
||||
-- engine/items/item_effects.asm) are both of that kind. Only
|
||||
-- TX_PROMPT_BUTTON waits on A/B (home/text.asm:434-446) (#765).
|
||||
self.msgAutoWait = self.msgAutoWait or item.autoDelay or 0
|
||||
if self.msgAutoWait > 0 then
|
||||
self.msgAutoWait = self.msgAutoWait - 1
|
||||
else
|
||||
self.msgAutoWait = nil
|
||||
-- the typed page stays drawn behind whatever runs next (the move
|
||||
-- animation, the ball toss): PrintText leaves the textbox tilemap
|
||||
-- alone and animations only touch sprites (#296)
|
||||
self.msgHold = true
|
||||
self.current = nil
|
||||
end
|
||||
elseif not (item and item.choice) then
|
||||
-- The page is typed out and waiting on the player: PromptText
|
||||
-- (home/text.asm:209-217) writes '▼' at (18,16) and ManualTextScroll
|
||||
-- blinks it until A/B, so the arrow belongs on a finished page and not
|
||||
@@ -1307,14 +1364,17 @@ end
|
||||
-- gets the final-battle theme
|
||||
function BattleState:computeMusicKind()
|
||||
local isBoss = false
|
||||
if self.kind == "trainer" and self.trainer then
|
||||
if self.kind == "trainer" and self.oppClass then
|
||||
-- wGymLeaderNo is written only by the eight gym scripts
|
||||
-- (scripts/PewterGym.asm .. ViridianGym.asm), so the badge rosters in
|
||||
-- victories.lua are exactly the fights that set it. The lookup must
|
||||
-- include the party index: a class-wide prefix match also caught
|
||||
-- Giovanni's Rocket Hideout (#1) and Silph Co (#2) battles, which never
|
||||
-- touch wGymLeaderNo and take MUSIC_TRAINER_BATTLE like any other
|
||||
-- trainer (#782).
|
||||
local victories = require("data.scripts.victories")
|
||||
for key, reward in pairs(victories) do
|
||||
if reward.badge and key:find(self.trainer.id .. "#", 1, true) == 1 then
|
||||
isBoss = true
|
||||
break
|
||||
end
|
||||
end
|
||||
local reward = victories[self.oppClass .. "#" .. tostring(self.partyIndex or 1)]
|
||||
isBoss = reward ~= nil and reward.badge ~= nil
|
||||
end
|
||||
-- init_battle.asm: challenging a gym leader (wGymLeaderNo, the badge
|
||||
-- fights only -- not Lance or the Champion) bumps the companion's
|
||||
@@ -1562,6 +1622,9 @@ end
|
||||
-- (end_of_battle.asm clears wLowHealthAlarm when a battle ends)
|
||||
function BattleState:exit()
|
||||
require("src.core.Sound").stopLoop("Low_Health_Alarm")
|
||||
-- end_of_battle.asm clears wPartyAndBillsPCSavedMenuItem as well, so the
|
||||
-- field party menu comes back on slot 1 after a battle. #768
|
||||
self.game.partyMenuSavedIndex = nil
|
||||
-- Free this battle's own GPU objects now rather than waiting on a GC
|
||||
-- finalizer: the two full-screen wavy-effect canvases (colorMode) and
|
||||
-- the AnimPlayer's per-instance tilesheet images/quads. The shared
|
||||
@@ -1588,6 +1651,19 @@ local function clearTrapping(battler)
|
||||
battler.trapDamage = nil
|
||||
end
|
||||
|
||||
-- SendOutMon (core.asm:1733-1735) clears both battle cursors, though the
|
||||
-- disassembly only names one of them: `ld hl, wBattleAndStartSavedMenuItem /
|
||||
-- ld [hli], a / ld [hl], a` writes zero to that byte AND to the byte behind
|
||||
-- it, which is wPlayerMoveListIndex (wram.asm:242-244). So every player
|
||||
-- send-out puts the main menu back on FIGHT and the move list back on the
|
||||
-- first slot; the cursors are only remembered across sub-menus of the mon
|
||||
-- that is already out (#737). Enemy send-outs run EnemySendOutFirstMon,
|
||||
-- not SendOutMon, and leave both alone.
|
||||
local function sendOutMonCursors(self)
|
||||
self.menuIndex = 1
|
||||
self.moveIndex = 1
|
||||
end
|
||||
|
||||
-- core.asm:297-300: both sides' FLINCHED bits are cleared as a turn's move
|
||||
-- selection opens, but the clear is skipped for a mon that must recharge or
|
||||
-- is locked into Rage (core.asm:293-295 -- the Hyper Beam flinch-recharge
|
||||
@@ -2061,19 +2137,30 @@ end
|
||||
-- skipped -- the old man branch jumps straight to .captured, $43 anim
|
||||
-- data = 3 shakes and caught (:155-164 + :193-200) -- and
|
||||
-- .oldManCaughtMon prints the caught text WITHOUT adding the mon to
|
||||
-- the party or the dex (:568-570). The "used" line reads OLD MAN
|
||||
-- the party or the dex (:568-570). Yellow's initial training is the one
|
||||
-- exception (demoFails, #636): its .oldManBattle branch forces $63, so
|
||||
-- the same chain ends in a breakout and ItemUseBallText04 instead.
|
||||
-- The "used" line reads OLD MAN
|
||||
-- because DisplayBattleMenu swapped wPlayerName (core.asm:2024-2037);
|
||||
-- no ball is consumed (.done returns early, :576-578).
|
||||
function BattleState:oldManThrow()
|
||||
self.phase = "messages"
|
||||
self.afterQueue = "finish"
|
||||
self.result = "run" -- nothing is kept; wBattleResult only ends the demo
|
||||
self:say(Strings("%s used\nPOKé BALL!", self.demoName or "OLD MAN"))
|
||||
self:sayAuto(Strings("%s used\nPOKé BALL!", self.demoName or "OLD MAN"))
|
||||
self:act(function()
|
||||
require("src.core.Sound").play(self.data, "Ball_Toss")
|
||||
-- ItemUseBall's beat before the toss chain (like throwBall)
|
||||
self.nextInsert = (self.nextInsert or 0) + 1
|
||||
table.insert(self.queue, self.nextInsert, { wait = 20 })
|
||||
if self.demoFails then
|
||||
-- $63 instead of $43: the same three shakes, then POOF+SHOWPIC and
|
||||
-- ItemUseBallText04. No sound_caught_mon, and .captured is never
|
||||
-- reached, so nothing touches the party or the dex either (#636).
|
||||
self:ballChain("TOSS_ANIM", false, 3, "POKE_BALL")
|
||||
self:sayNext(self:ballMissMessage(3))
|
||||
return
|
||||
end
|
||||
self:ballChain("TOSS_ANIM", true, 3, "POKE_BALL")
|
||||
self:actNext(function()
|
||||
require("src.core.Sound").play(self.data, "Caught_Mon")
|
||||
@@ -2254,6 +2341,7 @@ function BattleState:resolveSwitch(newMon)
|
||||
previous = previous,
|
||||
})
|
||||
self:markParticipant()
|
||||
sendOutMonCursors(self)
|
||||
self.sendingOut = true
|
||||
self:sayNext(self:sendOutText(self.player.name))
|
||||
self:animNext("POOF_ANIM", false)
|
||||
@@ -3023,8 +3111,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")
|
||||
@@ -3225,10 +3316,16 @@ end
|
||||
-- damaging pipeline (EffectRegistry.runDamaging).
|
||||
|
||||
-- Gen 1 status/stat primary effects call PlayCurrentMoveAnimation only
|
||||
-- after they land; these failure texts print with no animation.
|
||||
-- after they land; these failure texts print with no animation. Failures
|
||||
-- whose text is an ordinary sentence rather than one of the shared fail
|
||||
-- lines set msgs.failed instead of relying on this sniffer -- Substitute's
|
||||
-- two failure lines name the move, not the failure (#644).
|
||||
local function primaryEffectFailed(msgs)
|
||||
if not msgs or #msgs == 0 then return true end
|
||||
local m = msgs[1]
|
||||
if msgs.failed then return true end
|
||||
-- the extracted lines keep the ROM's own trailing blank ("But, it
|
||||
-- failed! "), so match with it trimmed or a refused status animates
|
||||
local m = msgs[1]:gsub("%s+$", "")
|
||||
if m == "But, it failed!" or m == "Nothing happened!" then return true end
|
||||
if m:find("didn't affect", 1, true) then return true end
|
||||
if m:find("is unaffected", 1, true) then return true end
|
||||
@@ -3273,7 +3370,7 @@ function BattleState:performMove(user, target, moveInst, isCalled)
|
||||
|
||||
self.moveAnimRow = nil
|
||||
if not (user.thrashTurns and moveInst == user.thrashMove and user.thrashAnnounced) then
|
||||
self:sayNext(self:romText("_ItemUseText001", "%s\nused %s!", displayName(user), move.name))
|
||||
self:sayNextAuto(self:romText("_ItemUseText001", "%s\nused %s!", displayName(user), move.name))
|
||||
-- the move's animation plays right after the announcement; the
|
||||
-- damage path attaches the target's hit blink to this row so the
|
||||
-- blink follows the animation (pokered's order). Mimic is the
|
||||
@@ -3769,6 +3866,7 @@ function BattleState:enemyMonFainted()
|
||||
self.participants = {}
|
||||
self:markParticipant()
|
||||
self.nextInsert = 0
|
||||
sendOutMonCursors(self)
|
||||
self.sendingOut = true
|
||||
self:sayNext(self:sendOutText(self.player.name))
|
||||
self:animNext("POOF_ANIM", false)
|
||||
@@ -3961,6 +4059,7 @@ function BattleState:openReplacementMenu()
|
||||
})
|
||||
self:markParticipant()
|
||||
self.nextInsert = 0
|
||||
sendOutMonCursors(self)
|
||||
self.sendingOut = true
|
||||
self:sayNext(self:sendOutText(self.player.name))
|
||||
self:animNext("POOF_ANIM", false)
|
||||
@@ -3999,7 +4098,7 @@ function BattleState:safariAction(choice)
|
||||
|
||||
if choice == "ball" then
|
||||
st.balls = st.balls - 1
|
||||
self:say(Strings("%s used\nSAFARI BALL!", playerName))
|
||||
self:sayAuto(Strings("%s used\nSAFARI BALL!", playerName))
|
||||
self:act(function()
|
||||
require("src.core.Sound").play(self.data, "Ball_Toss")
|
||||
self.lastBall = "SAFARI_BALL"
|
||||
@@ -4321,7 +4420,7 @@ function BattleState:throwBall(ball)
|
||||
-- "<PLAYER> used <ITEM>!" line (#291). Safari and the old man demo are
|
||||
-- still wIsInBattle == 1, and this port models both as kind == "wild".
|
||||
if self.kind == "wild" then
|
||||
self:say(self:romText("_ItemUseText001", "%s used\n%s!", self.game.save.player.name,
|
||||
self:sayAuto(self:romText("_ItemUseText001", "%s used\n%s!", self.game.save.player.name,
|
||||
self.data.items[ball].name))
|
||||
end
|
||||
self:act(function()
|
||||
@@ -5321,7 +5420,8 @@ end
|
||||
function BattleState:drawTextArea()
|
||||
Font.drawBox(0, 12, 20, 6)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
if self.phase == "messages" and (self.current or self.animPlaying) then
|
||||
if self.phase == "messages"
|
||||
and (self.current or self.animPlaying or self.msgHold) then
|
||||
-- during the move animation self.current is nil but shown still holds
|
||||
-- the "used X!" lines; keep drawing them like pokered, whose move
|
||||
-- animations only touch sprites and never the textbox tilemap (#296)
|
||||
|
||||
@@ -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 = {
|
||||
@@ -259,15 +259,29 @@ MoveEffects.primary = {
|
||||
return { romText(battle.data, "_StatusChangesEliminatedText", "All STATUS changes\nare eliminated!") }
|
||||
end,
|
||||
|
||||
-- substitute.asm reaches its PlayCurrentMoveAnimation / AnimationSubstitute
|
||||
-- Bankswitch only inside the success branch, after `set HAS_SUBSTITUTE_UP`;
|
||||
-- .alreadyHasSubstitute and .notEnoughHP fall straight through to PrintText,
|
||||
-- so both failures print with no animation at all. That is load bearing
|
||||
-- here: the SUBSTITUTE animation opens with SE_SLIDE_MON_OFF, which leaves
|
||||
-- the user's pic hidden (BattleState.lua slideOff end state) until the doll
|
||||
-- is drawn in its place -- and with no substituteHP raised there is no doll,
|
||||
-- so a failed Substitute used to erase the user's sprite for the rest of the
|
||||
-- battle (#644). The failed flag rides the message list so performMove can
|
||||
-- peel the announcement-time anim row without matching on printed text.
|
||||
SUBSTITUTE_EFFECT = function(battle, user)
|
||||
if user.substituteHP then return { romText(battle.data, "_HasSubstituteText", "%s\nhas a SUBSTITUTE!", displayName(user)) } end
|
||||
if user.substituteHP then
|
||||
return { romText(battle.data, "_HasSubstituteText", "%s\nhas a SUBSTITUTE!", displayName(user)),
|
||||
failed = true }
|
||||
end
|
||||
local cost = math.floor(user.mon.stats.hp / 4)
|
||||
-- substitute.asm only fails on subtraction underflow (current HP
|
||||
-- strictly below maxHP/4); at equality the substitute is built and
|
||||
-- the user is left standing on exactly 0 HP (it faints only when
|
||||
-- the engine next checks HP, not here)
|
||||
if user.mon.hp < cost then
|
||||
return { romText(battle.data, "_TooWeakSubstituteText", "Too weak to make\na SUBSTITUTE!") }
|
||||
return { romText(battle.data, "_TooWeakSubstituteText", "Too weak to make\na SUBSTITUTE!"),
|
||||
failed = true }
|
||||
end
|
||||
user.mon.hp = user.mon.hp - cost
|
||||
user.substituteHP = cost + 1
|
||||
@@ -332,7 +346,7 @@ MoveEffects.primary = {
|
||||
battle.data.moves[id].name) }
|
||||
end,
|
||||
|
||||
SPLASH_EFFECT = function()
|
||||
SPLASH_EFFECT = function(battle)
|
||||
return { romText(battle.data, "_NoEffectText", "No effect!") }
|
||||
end,
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
@@ -40,6 +41,13 @@ function Game:load()
|
||||
-- render pipelines dispatch off the merged dataset; point them at the
|
||||
-- one the mods just merged into before anything can draw a frame
|
||||
require("src.render.Pipelines").install(Data)
|
||||
-- Same reason, same moment: TypeChart caches the merged type records in an
|
||||
-- upvalue, and until now only BattleState loaded it, on entering a battle.
|
||||
-- Every non-battle reader of a type -- the summary screen's TYPE1/TYPE2
|
||||
-- rows, the move-select TYPE/ box -- ran against an unloaded module and got
|
||||
-- the raw id back instead of the display name, so a translation could not
|
||||
-- reach them. Loading here means a type reads the same whoever asks first.
|
||||
require("src.battle.TypeChart").load(Data)
|
||||
|
||||
self.input = Input
|
||||
Input:init()
|
||||
@@ -278,6 +286,21 @@ function Game.worldBgBattleDim(stack)
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Is a BATTLE BG "world" battle composing itself over the live map right now?
|
||||
-- Same whole-stack walk as worldBgBattleDim, asked for a different reason: the
|
||||
-- dark-cave shade shift (wMapPalOffset) must not reach a frame a battle is
|
||||
-- drawing in. InitBattleCommon (engine/battle/core.asm) pushes wMapPalOffset,
|
||||
-- InitBattleVariables (engine/battle/init_battle_variables.asm) writes 0 over
|
||||
-- it and core.asm pops it back when the battle ends, so a battle in an
|
||||
-- un-flashed Rock Tunnel is lit on hardware. Every other BATTLE BG gets that
|
||||
-- for free -- no map draws beneath an opaque battle, so nothing re-arms the
|
||||
-- per-frame shade map -- but "world" keeps the overworld drawing underneath,
|
||||
-- and its arming then darkened the battle's own pics, HUD and text at colorize
|
||||
-- time (#773).
|
||||
function Game.worldBgBattleInStack(stack)
|
||||
return Game.worldBgBattleDim(stack) ~= nil
|
||||
end
|
||||
|
||||
-- Does anything on the stack want the surface scaled to FILL the window
|
||||
-- (aspect preserved, bars on the long axis) rather than sit at the fixed
|
||||
-- integer scale?
|
||||
@@ -441,7 +464,14 @@ function Game:draw()
|
||||
if classicOffset ~= 0 and not wideState then
|
||||
love.graphics.push()
|
||||
love.graphics.translate(classicOffset, 0)
|
||||
-- a classic state reports its trueColor rects in its own 160x144
|
||||
-- coordinates, so they take the same shift its pixels just got --
|
||||
-- centerClassicZones already does exactly this to its zone list,
|
||||
-- and without the pair the unshaded re-blit misses the pic (#637)
|
||||
local P = require("src.render.PaletteFX")
|
||||
P.setMarkOffset(classicOffset)
|
||||
state:draw()
|
||||
P.setMarkOffset(0)
|
||||
love.graphics.pop()
|
||||
else
|
||||
state:draw()
|
||||
@@ -566,7 +596,7 @@ function Game:keypressed(key)
|
||||
return
|
||||
elseif key == "1" then
|
||||
-- cycle GAME SPEED (0.25X → 200X, logic only; audio unaffected);
|
||||
-- R2/L2 on gamepad do the same (see gamepadpressed)
|
||||
-- shoulders/triggers on gamepad do the same (see gamepadpressed)
|
||||
self:_cycleSpeed(1)
|
||||
return
|
||||
elseif key == "2" then
|
||||
@@ -649,14 +679,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()
|
||||
-- shoulder buttons cycle GAME SPEED (R2/rightshoulder = faster,
|
||||
-- L2/leftshoulder = slower; same as keyboard hotkey 1)
|
||||
if button == "rightshoulder" then
|
||||
self:_cycleSpeed(1)
|
||||
return
|
||||
elseif button == "leftshoulder" then
|
||||
self:_cycleSpeed(-1)
|
||||
return
|
||||
-- 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. 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()
|
||||
@@ -664,6 +709,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
|
||||
|
||||
@@ -747,15 +802,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
|
||||
|
||||
@@ -832,6 +923,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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"),
|
||||
|
||||
@@ -54,10 +54,11 @@ local function C(name, a)
|
||||
return rgba(c[1], c[2], c[3], a)
|
||||
end
|
||||
|
||||
-- Every element in this view refuses to flex-shrink: overflow is always a
|
||||
-- scroll container's job here, and the engine otherwise compresses
|
||||
-- Non-scroll elements refuse to flex-shrink: the engine otherwise compresses
|
||||
-- auto-height children inside height-constrained columns until their text
|
||||
-- overlaps (the portrait single-column layout was the visible case).
|
||||
-- overlaps (the portrait single-column layout was the visible case). Scroll
|
||||
-- regions are the opposite — they MUST shrink to the viewport, or their
|
||||
-- height grows with content, maxScrollY stays 0, and drag/wheel do nothing.
|
||||
-- horizontal padding of a props table, for content-width bookkeeping
|
||||
local function propsPadH(p)
|
||||
local pad = p.padding
|
||||
@@ -68,8 +69,23 @@ local function propsPadH(p)
|
||||
return 0
|
||||
end
|
||||
|
||||
local function isScrollOverflow(props)
|
||||
local o = props.overflowY or props.overflowX or props.overflow
|
||||
return o == "scroll" or o == "auto"
|
||||
end
|
||||
|
||||
local function mk(props)
|
||||
if props.flexShrink == nil then props.flexShrink = 0 end
|
||||
if props.flexShrink == nil then
|
||||
props.flexShrink = isScrollOverflow(props) and 1 or 0
|
||||
end
|
||||
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
|
||||
-- padding, so every percent child of a padded container overflowed to the
|
||||
@@ -94,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({
|
||||
@@ -105,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 {}
|
||||
@@ -123,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)
|
||||
@@ -135,22 +218,58 @@ function LauncherView.update(imp, dt)
|
||||
if not imp._flex then return end
|
||||
FlexLove.update(dt)
|
||||
-- Drain the action queue OUTSIDE FlexLove's dispatch, so an action is free
|
||||
-- to destroy the view (Play/Edit) or block in a native picker.
|
||||
-- to destroy the view (Play/Edit) or block in a native picker. The batch
|
||||
-- is resolved by RomImporter:runActions so the drop/disarm rules are
|
||||
-- testable without a live FlexLove tree (#780).
|
||||
local queue = imp._uiActions
|
||||
if queue and #queue > 0 then
|
||||
imp._uiActions = {}
|
||||
for _, fn in ipairs(queue) do
|
||||
local ok, err = pcall(fn)
|
||||
if not ok then print("launcher action error: " .. tostring(err)) end
|
||||
end
|
||||
imp:runActions(queue)
|
||||
end
|
||||
end
|
||||
|
||||
-- One dedup window covers a touch release plus the mouse click SDL
|
||||
-- synthesizes for the same tap.
|
||||
local ACT_DEDUP = 0.35
|
||||
-- Finger travel past this (px) is a scroll drag, not a tap — so dragging a
|
||||
-- list row does not also fire that row's button.
|
||||
local TAP_SLOP2 = 16 * 16
|
||||
|
||||
function LauncherView.wheelmoved(imp, dx, dy)
|
||||
if not imp._flex then return end
|
||||
pcall(FlexLove.wheelmoved, dx, dy)
|
||||
end
|
||||
|
||||
-- Touch drag scroll: FlexLove's ScrollManager only moves when these are
|
||||
-- hooked. Clicks still come from EventHandler's love.touch / mouse polling;
|
||||
-- the view's action dedupe covers a tap that also synthesizes a mouse click,
|
||||
-- and a drag past TAP_SLOP suppresses the click that would otherwise fire
|
||||
-- on the row under the finger.
|
||||
function LauncherView.touchpressed(imp, id, x, y, dx, dy, pressure)
|
||||
if not imp._flex then return end
|
||||
imp._touchAt = imp._touchAt or {}
|
||||
imp._touchAt[tostring(id)] = { x = x, y = y }
|
||||
pcall(FlexLove.touchpressed, id, x, y, dx, dy, pressure)
|
||||
end
|
||||
|
||||
function LauncherView.touchmoved(imp, id, x, y, dx, dy, pressure)
|
||||
if not imp._flex then return end
|
||||
local start = imp._touchAt and imp._touchAt[tostring(id)]
|
||||
if start then
|
||||
local ddx, ddy = x - start.x, y - start.y
|
||||
if ddx * ddx + ddy * ddy > TAP_SLOP2 then
|
||||
imp._suppressClickUntil = love.timer.getTime() + ACT_DEDUP
|
||||
end
|
||||
end
|
||||
pcall(FlexLove.touchmoved, id, x, y, dx, dy, pressure)
|
||||
end
|
||||
|
||||
function LauncherView.touchreleased(imp, id, x, y, dx, dy, pressure)
|
||||
if not imp._flex then return end
|
||||
if imp._touchAt then imp._touchAt[tostring(id)] = nil end
|
||||
pcall(FlexLove.touchreleased, id, x, y, dx, dy, pressure)
|
||||
end
|
||||
|
||||
-- Synthetic click for the gamepad virtual cursor: find the element under the
|
||||
-- pad pointer and run its handler with a click-shaped event.
|
||||
function LauncherView.clickAt(imp, x, y)
|
||||
@@ -166,19 +285,18 @@ end
|
||||
|
||||
-- ------- shared widget helpers
|
||||
|
||||
-- One dedup window covers a touch release plus the mouse click SDL
|
||||
-- synthesizes for the same tap.
|
||||
local ACT_DEDUP = 0.35
|
||||
|
||||
local function queueAction(imp, key, fn, keepArm)
|
||||
local now = love.timer.getTime()
|
||||
local last = imp._actAt[key]
|
||||
if last and now - last < ACT_DEDUP then return end
|
||||
imp._actAt[key] = now
|
||||
-- Any press that is not a Delete's own second click disarms the pending
|
||||
-- delete confirm (#433's rule, preserved from the hit-rect launcher).
|
||||
if not keepArm then imp._confirmDelete = nil end
|
||||
imp._uiActions[#imp._uiActions + 1] = fn
|
||||
-- delete confirm (#433's rule, preserved from the hit-rect launcher). The
|
||||
-- disarm itself is applied by RomImporter:runActions when the batch drains,
|
||||
-- not here: one touch lands on a row AND on the chip inside it, and
|
||||
-- clearing the arm as the row queued left Delete stuck on its first press
|
||||
-- (#780).
|
||||
imp._uiActions[#imp._uiActions + 1] = { key = key, fn = fn, keepArm = keepArm }
|
||||
end
|
||||
|
||||
local function handler(imp, key, action, keepArm)
|
||||
@@ -188,6 +306,15 @@ local function handler(imp, key, action, keepArm)
|
||||
elseif ev.type == "unhover" then
|
||||
imp._hot[key] = nil
|
||||
elseif action and (ev.type == "click" or ev.type == "touchrelease") then
|
||||
if ev.type == "touchrelease" then
|
||||
local dx, dy = ev.dx or 0, ev.dy or 0
|
||||
if dx * dx + dy * dy > TAP_SLOP2 then
|
||||
imp._suppressClickUntil = love.timer.getTime() + ACT_DEDUP
|
||||
return
|
||||
end
|
||||
end
|
||||
local untilT = imp._suppressClickUntil
|
||||
if untilT and love.timer.getTime() < untilT then return end
|
||||
queueAction(imp, key, action, keepArm)
|
||||
end
|
||||
end
|
||||
@@ -202,7 +329,9 @@ local function mfont(size)
|
||||
size = math.max(8, math.floor(size + 0.5))
|
||||
local f = measureFonts[size]
|
||||
if not f then
|
||||
f = love.graphics.newFont(size)
|
||||
-- same fallback the rendering faces get (FlexLove FontCache), or the
|
||||
-- launcher measures Latin widths for text it draws with kana
|
||||
f = require("src.render.UiFont").attach(love.graphics.newFont(size), size)
|
||||
measureFonts[size] = f
|
||||
end
|
||||
return f
|
||||
@@ -210,12 +339,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
|
||||
@@ -299,6 +438,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,
|
||||
@@ -586,8 +764,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"),
|
||||
@@ -608,12 +788,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 ")
|
||||
@@ -623,13 +803,13 @@ 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
|
||||
|
||||
local accent = version == "yellow" and "gold" or version
|
||||
local c = card(parent, { padding = m.cardPad, gap = 8 * m.s })
|
||||
label(c, "ROM", 12 * m.s + 1, C("gray"))
|
||||
label(c, Strings("ROM"), 12 * m.s + 1, C("gray"))
|
||||
label(c, romState, 15 * m.s + 2, C("white"))
|
||||
label(c, romDetail, 12 * m.s + 2, C("detail"))
|
||||
if romProgress ~= nil then
|
||||
@@ -662,22 +842,20 @@ 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"))
|
||||
label(c, Strings("SAVE FILES"), 12 * m.s + 1, C("gray"))
|
||||
local row = mk({ parent = c, width = "100%",
|
||||
positioning = "flex", flexDirection = "horizontal", gap = 10 * m.s })
|
||||
-- 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()
|
||||
@@ -713,7 +891,7 @@ local function buildSlotCard(imp, parent, m, version)
|
||||
local head = mk({ parent = c, width = "100%",
|
||||
positioning = "flex", flexDirection = "horizontal",
|
||||
justifyContent = "space-between", alignItems = "center" })
|
||||
label(head, "SAVE SLOT", 12 * m.s + 1, C("gray"), { textWrap = false })
|
||||
label(head, Strings("SAVE SLOT"), 12 * m.s + 1, C("gray"), { textWrap = false })
|
||||
label(head, n == 1 and Strings("1 slot") or Strings("%d slots", n),
|
||||
12 * m.s + 1, C("gray"), { textWrap = false })
|
||||
|
||||
@@ -743,11 +921,23 @@ local function buildSlotCard(imp, parent, m, version)
|
||||
math.ceil(textHeight(pillSize)) + 8)
|
||||
local metaH = math.ceil(textHeight(metaSize))
|
||||
local rowH = 10 + headH + 5 + metaH + 8 + btnH + 10
|
||||
-- Fixed-height scroller so 40 slots actually overflow (page-level flex
|
||||
-- scroll alone was growing with content, leaving nothing to drag).
|
||||
local listParent = c
|
||||
if n > 0 then
|
||||
local listH = math.floor(clamp(m.h * (m.twoCol and 0.58 or 0.42), 200, 720))
|
||||
listParent = mk({
|
||||
parent = c, id = "slots-" .. version, width = "100%", height = listH,
|
||||
overflowY = "scroll", hideScrollbars = true,
|
||||
positioning = "flex", flexDirection = "vertical", gap = 10 * m.s,
|
||||
padding = { right = 4 },
|
||||
})
|
||||
end
|
||||
for _, slot in ipairs(slots) do
|
||||
local selected = slot.id == active
|
||||
local rowKey = "slot-" .. version .. "-" .. slot.id
|
||||
local row = mk({
|
||||
parent = c, width = "100%", height = rowH,
|
||||
parent = listParent, width = "100%", height = rowH,
|
||||
backgroundColor = C("rowBg", imp._hot[rowKey] and 0.85 or 0.6),
|
||||
border = 1,
|
||||
borderColor = selected and C("green", 0.9) or C("border", 0.25),
|
||||
@@ -795,10 +985,14 @@ local function buildSlotCard(imp, parent, m, version)
|
||||
})
|
||||
end
|
||||
local armed = deleteArmed(imp, "slot", slot.id, version)
|
||||
-- width pinned to the unarmed label so arming to "Sure?" never reflows
|
||||
-- the row under the pointer (#433)
|
||||
-- width pinned so arming to "Sure?" never reflows the row under the
|
||||
-- pointer (#433). Pinned to the wider of the two captions, not to the
|
||||
-- unarmed one: English "Delete" is the longer of the pair, but a
|
||||
-- translation need not keep that order (Japanese さくじょ is shorter than
|
||||
-- よろしい?), and pinning to the shorter one clips the other.
|
||||
button(imp, btnRow, rowKey .. "-del", DELETE_LABEL(armed), {
|
||||
w = math.ceil(textWidth(chipSize, DELETE_LABEL(false))) + 26,
|
||||
w = math.ceil(math.max(textWidth(chipSize, DELETE_LABEL(false)),
|
||||
textWidth(chipSize, DELETE_LABEL(true)))) + 26,
|
||||
size = chipSize, kind = armed and "dangerArmed" or "danger",
|
||||
keepArm = true,
|
||||
action = function()
|
||||
@@ -837,9 +1031,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,
|
||||
@@ -869,36 +1063,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,
|
||||
})
|
||||
@@ -907,8 +1114,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
|
||||
@@ -919,13 +1125,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.
|
||||
@@ -957,6 +1244,15 @@ 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 d = info.dates
|
||||
dlLine = ModUpdate.statsLine(info.downloads.total,
|
||||
d and d.first, d and d.latest)
|
||||
end
|
||||
|
||||
-- measure the body: name (with the badge beside it only when it fits),
|
||||
-- version, check line, wrapped description
|
||||
@@ -970,6 +1266,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
|
||||
@@ -1021,6 +1320,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
|
||||
@@ -1071,25 +1373,35 @@ end
|
||||
|
||||
local function buildFindPanel(imp, parent, m)
|
||||
imp._findThumbFetched = false
|
||||
imp._findStatsFetched = false
|
||||
imp:_ensureFind()
|
||||
imp:_ensureMods()
|
||||
local ModIndex = require("src.mods.ModIndex")
|
||||
local ModUpdate = require("src.mods.ModUpdate")
|
||||
local sources = imp.findSources or {}
|
||||
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
|
||||
@@ -1098,11 +1410,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,
|
||||
@@ -1149,8 +1460,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 {}
|
||||
@@ -1205,6 +1515,80 @@ local function buildFindPanel(imp, parent, m)
|
||||
return
|
||||
end
|
||||
|
||||
-- Sort row: Name / Popularity / Release date / Last updated, the same
|
||||
-- options the MODS tab offers, sharing its persisted choice
|
||||
-- (options.modSort). Data comes from the same _findStats resolution the
|
||||
-- cards use (feed-published, else the repo fetch); rows whose stats have
|
||||
-- not resolved yet sink to the bottom of data sorts and rise as the
|
||||
-- one-per-frame fetches complete.
|
||||
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 = "find-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
|
||||
|
||||
local sorted = {}
|
||||
for i, v in ipairs(rows) do sorted[i] = v end
|
||||
table.sort(sorted, function(a, b)
|
||||
local function value(entry)
|
||||
if sortKey == "name" then
|
||||
return (entry.title or entry.id or ""):lower()
|
||||
end
|
||||
local stats = imp:_findStats(entry)
|
||||
if sortKey == "popularity" then
|
||||
return stats and stats.total or -1
|
||||
end
|
||||
if sortKey == "release" then
|
||||
return stats and stats.first or "0000-00-00"
|
||||
end
|
||||
return stats and stats.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.title or a.id or ""):lower() < (b.title or b.id or ""):lower()
|
||||
end)
|
||||
rows = sorted
|
||||
|
||||
local installed = imp:_findInstalledMap()
|
||||
local thumbW = 64 * m.s
|
||||
-- Explicit measured widths AND heights, same reasoning as the mods card:
|
||||
@@ -1218,9 +1602,18 @@ local function buildFindPanel(imp, parent, m)
|
||||
local btnH = math.ceil(textHeight(chipSize)) + 14
|
||||
for _, entry in ipairs(rows) do
|
||||
local action, note = findActionFor(entry, installed[entry.id])
|
||||
-- Release stats for the row: feed-published when the feed carries
|
||||
-- them, otherwise fetched from the mod's GitHub repo (one per frame,
|
||||
-- cached six hours) exactly like the MODS tab does.
|
||||
local stats = imp:_findStats(entry)
|
||||
local statsLine
|
||||
if stats and (stats.total ~= nil or stats.first or stats.latest) then
|
||||
statsLine = ModUpdate.statsLine(stats.total, stats.first, stats.latest)
|
||||
end
|
||||
|
||||
local bodyH = math.ceil(textHeight(titleSize))
|
||||
+ 4 + math.ceil(textHeight(smallSize))
|
||||
if statsLine then bodyH = bodyH + 4 + wrapHeight(smallSize, statsLine, bodyW) end
|
||||
if note then bodyH = bodyH + 4 + wrapHeight(smallSize, note, bodyW) end
|
||||
if entry.summary and entry.summary ~= "" then
|
||||
bodyH = bodyH + 4 + wrapHeight(smallSize, entry.summary, bodyW)
|
||||
@@ -1265,6 +1658,9 @@ local function buildFindPanel(imp, parent, m)
|
||||
end
|
||||
label(body, meta, smallSize, C("detail"),
|
||||
{ width = "100%", textWrap = false, textOverflow = "ellipsis" })
|
||||
if statsLine then
|
||||
label(body, statsLine, smallSize, C("gold"), { width = "100%" })
|
||||
end
|
||||
if note then label(body, note, smallSize, C("green"), { width = "100%" }) end
|
||||
if entry.summary and entry.summary ~= "" then
|
||||
label(body, entry.summary, smallSize, C("detail"), { width = "100%" })
|
||||
@@ -1449,12 +1845,18 @@ end
|
||||
local function buildConfirmModal(imp, m)
|
||||
local c = imp._modConfirm
|
||||
local overlay = modalOverlay(imp, m, "confirm-out")
|
||||
local panel = modalPanel(overlay, m, 420 * m.s)
|
||||
label(panel, c.title or Strings("Confirm"), 15 * m.s + 2, C("white"))
|
||||
-- roomier than the shared 420 default: the install confirm carries the
|
||||
-- compat issue list and the trust warning, and those lines need air
|
||||
local panel = modalPanel(overlay, m, 520 * m.s, {
|
||||
gap = 12 * m.s, padding = { horizontal = 22, vertical = 20 },
|
||||
})
|
||||
label(panel, c.title or Strings("Confirm"), 17 * m.s + 2, C("white"))
|
||||
for _, line in ipairs(c.lines or {}) do
|
||||
label(panel, line, 12 * m.s + 1, C("detail"))
|
||||
label(panel, line, 13 * m.s + 1, C("detail"))
|
||||
end
|
||||
local btnRow = mk({ parent = panel, width = "100%",
|
||||
-- explicit height: an auto-sized row measures short while the panel
|
||||
-- auto-sizes, which clipped the buttons at the panel's bottom border
|
||||
local btnRow = mk({ parent = panel, width = "100%", height = m.btnH,
|
||||
positioning = "flex", flexDirection = "horizontal", gap = 10 * m.s })
|
||||
button(imp, btnRow, "confirm-yes", c.yesLabel or Strings("OK"), {
|
||||
flex = 1, h = m.btnH, size = 13 * m.s + 1, kind = "primary",
|
||||
@@ -1712,7 +2114,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)
|
||||
@@ -1782,9 +2189,11 @@ function LauncherView.draw(imp)
|
||||
|
||||
-- One scroll region per tab (stable id keeps its offset across frames and
|
||||
-- separate per tab), holding the panel, the updater banner and the footer.
|
||||
-- flexShrink/minHeight keep this viewport-sized so overflowY can scroll.
|
||||
local page = mk({
|
||||
parent = root, id = "page-" .. imp.tab,
|
||||
width = "100%", flex = 1, overflowY = "scroll", hideScrollbars = true,
|
||||
width = "100%", flex = 1, flexShrink = 1, minHeight = 0,
|
||||
overflowY = "scroll", hideScrollbars = true,
|
||||
positioning = "flex", flexDirection = "vertical",
|
||||
gap = 12 * m.s,
|
||||
padding = { left = m.pad, right = m.pad + m.gutter,
|
||||
|
||||
@@ -1639,9 +1639,100 @@ function RomExtractor:raw1bpp(label, width, height, relative, transparent)
|
||||
return image
|
||||
end
|
||||
|
||||
-- Trading animation art: gfx/trade.asm TradingAnimationGraphics is one
|
||||
-- 49-tile atlas (game_boy.2bpp, built with --remove-duplicates, then
|
||||
-- link_cable.2bpp), and the Game Boy and open-cable plates are painted out
|
||||
-- of it through the tilemaps in data/tilemaps.asm (GameBoyTiles 6x8,
|
||||
-- LinkCableTiles 12x3), whose ids are absolute vChars2 ids starting at $31
|
||||
-- because trade.asm reaches them through
|
||||
-- CopyTileIDsFromList_ZeroBaseTileID. Only the developer-only Python path
|
||||
-- ever wrote these files, so an imported cache had none of them and
|
||||
-- TradeAnim drew the whole cinematic as plain rectangles (#750).
|
||||
function RomExtractor:extractTradeArt()
|
||||
local BASE, COUNT = 0x31, 49
|
||||
local gfx = self:symbol("TradingAnimationGraphics")
|
||||
local atlas = ImageWriter.decode2bpp(
|
||||
self.rom:bytes(gfx.bank, gfx.address, COUNT * 16), COUNT * 8, 8)
|
||||
local function tileX(id)
|
||||
local index = id - BASE
|
||||
assert(index >= 0 and index < COUNT,
|
||||
("trade tile $%02X is outside the animation atlas"):format(id))
|
||||
return index * 8
|
||||
end
|
||||
local function plate(label, tilesWide, tilesHigh, relative, matte)
|
||||
local map = self:symbol(label)
|
||||
local ids = self.rom:bytes(map.bank, map.address, tilesWide * tilesHigh)
|
||||
local image = ImageWriter.blank(tilesWide * 8, tilesHigh * 8, 1, 1, 1, 1)
|
||||
for index, id in ipairs(ids) do
|
||||
ImageWriter.blit(image, atlas,
|
||||
(index - 1) % tilesWide * 8,
|
||||
math.floor((index - 1) / tilesWide) * 8, tileX(id), 0, 8, 8)
|
||||
end
|
||||
if matte then image = ImageWriter.matteColor0(image) end
|
||||
self:save(image, relative)
|
||||
end
|
||||
plate("GameBoyTiles", 6, 8, "trade/game_boy.png", true)
|
||||
plate("LinkCableTiles", 12, 3, "trade/open_cable.png", false)
|
||||
for _, spec in ipairs({
|
||||
{ 0x5D, "cable_conn" }, { 0x5E, "cable_seg" }, { 0x5F, "cable_corner" },
|
||||
{ 0x60, "cable_end" }, { 0x61, "cable_vert" },
|
||||
}) do
|
||||
local tile = ImageWriter.blank(8, 8, 1, 1, 1, 1)
|
||||
ImageWriter.blit(tile, atlas, 0, 0, tileX(spec[1]), 0, 8, 8)
|
||||
self:save(tile, "trade/" .. spec[2] .. ".png")
|
||||
end
|
||||
-- Trade_DrawCableAcrossScreen fills a whole 20-tile row with tile $5e.
|
||||
local horizontal = ImageWriter.blank(160, 8, 1, 1, 1, 1)
|
||||
for column = 0, 19 do
|
||||
ImageWriter.blit(horizontal, atlas, column * 8, 0, tileX(0x5E), 0, 8, 8)
|
||||
end
|
||||
self:save(horizontal, "trade/cable_horiz.png")
|
||||
|
||||
-- Trade_BallInsideLinkCableOAMBlock draws one tile four times with the
|
||||
-- X/Y flips, so each of the two frames -- $7e travelling, $7f bulging,
|
||||
-- the bottom row of TradingAnimationGraphics2 -- makes a 16x16 ball.
|
||||
local ball = self:symbol("TradingAnimationGraphics2")
|
||||
local frames = ImageWriter.decode2bpp(
|
||||
self.rom:bytes(ball.bank, ball.address, 64), 16, 16, true)
|
||||
for index, name in ipairs({ "cable_ball", "cable_ball_alt" }) do
|
||||
local image = ImageWriter.blank(16, 16, 1, 1, 1, 0)
|
||||
for y = 0, 7 do
|
||||
for x = 0, 7 do
|
||||
local r, g, b, a = frames:getPixel((index - 1) * 8 + x, 8 + y)
|
||||
image:setPixel(x, y, r, g, b, a)
|
||||
image:setPixel(15 - x, y, r, g, b, a)
|
||||
image:setPixel(x, 15 - y, r, g, b, a)
|
||||
image:setPixel(15 - x, 15 - y, r, g, b, a)
|
||||
end
|
||||
end
|
||||
self:save(image, "trade/" .. name .. ".png")
|
||||
end
|
||||
-- The ring around the travelling mon: one 16x16 quadrant per animation
|
||||
-- frame (engine/gfx/mon_icons.asm TradeBubbleIconGFX), mirrored into a
|
||||
-- 32x32 circle by the OAM attributes in Trade_CircleOAMBlocks.
|
||||
local bubble = self:symbol("TradeBubbleIconGFX")
|
||||
self:write2bpp(self.rom:bytes(bubble.bank, bubble.address, 128),
|
||||
16, 32, "trade/bubble.png", true)
|
||||
|
||||
return {
|
||||
gameBoy = "assets/generated/trade/game_boy.png",
|
||||
openCable = "assets/generated/trade/open_cable.png",
|
||||
cableHoriz = "assets/generated/trade/cable_horiz.png",
|
||||
cableConn = "assets/generated/trade/cable_conn.png",
|
||||
cableVert = "assets/generated/trade/cable_vert.png",
|
||||
cableCorner = "assets/generated/trade/cable_corner.png",
|
||||
cableEnd = "assets/generated/trade/cable_end.png",
|
||||
cableBall = "assets/generated/trade/cable_ball.png",
|
||||
cableBallAlt = "assets/generated/trade/cable_ball_alt.png",
|
||||
bubble = "assets/generated/trade/bubble.png",
|
||||
source = "ROM:TradingAnimationGraphics + ROM:TradeBubbleIconGFX"
|
||||
.. " (engine/movie/trade.asm InternalClockTradeAnim)",
|
||||
}
|
||||
end
|
||||
|
||||
function RomExtractor:extractField()
|
||||
self:beginStage("Interface artwork")
|
||||
local done, total = 0, 49
|
||||
local done, total = 0, 50
|
||||
local function tick()
|
||||
done = done + 1
|
||||
self:tick("Interface artwork", math.min(done, total), total)
|
||||
@@ -1832,6 +1923,8 @@ function RomExtractor:extractField()
|
||||
end
|
||||
self:save(emotes, "emotes.png"); tick()
|
||||
|
||||
local tradeArt = self:extractTradeArt(); tick()
|
||||
|
||||
-- Yellow-only: the Surfing Pikachu minigame sheets
|
||||
-- (gfx/surfing_pikachu.asm) at pret's canvas widths, so
|
||||
-- src/ui/SurfingMinigame.lua's quads can be read off the source pngs.
|
||||
@@ -1962,6 +2055,7 @@ function RomExtractor:extractField()
|
||||
local converted = {}
|
||||
for index, values in pairs(adjacency) do converted[tonumber(index)] = values end
|
||||
data.hiddenExtras.trashCans.adjacent = converted
|
||||
data.tradeArt = tradeArt
|
||||
data.source = "canonical Pokemon Red ROM + bundled port metadata"
|
||||
self:write("field", data)
|
||||
self:tick("Interface artwork", total, total)
|
||||
@@ -2023,21 +2117,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 +2142,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 +2156,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),
|
||||
|
||||
@@ -6,8 +6,10 @@
|
||||
-- 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 exports/<version>/ under the same root SaveData's persistFs
|
||||
-- writes slots to -- the portable game folder when portable.txt marks the
|
||||
-- install, otherwise the LOVE save directory (#752) -- 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,7 +101,8 @@ 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
|
||||
-- exports/<version>/gen1recomp-<version>-<slotId>.sav under the portable game
|
||||
-- folder when portable mode is on, otherwise the save directory (created if
|
||||
-- absent). Returns true + the absolute path on success, false + a friendly
|
||||
-- message otherwise.
|
||||
function SaveFileIO.exportActiveSlot(version)
|
||||
@@ -109,12 +112,31 @@ function SaveFileIO.exportActiveSlot(version)
|
||||
local bytes, exportErr = SaveConvert.exportSav(save, version)
|
||||
if not bytes then return false, exportErr end
|
||||
local slotId = SaveData.activeSlot(version) or "save"
|
||||
local fs = love and love.filesystem
|
||||
-- Portable mode is the same seam SaveData's own persistFs uses: when
|
||||
-- portable.txt marks the install every persistent write leaves the OS save
|
||||
-- directory for the game folder, and an export is no exception. Writing
|
||||
-- through love.filesystem here dropped the .sav in AppData while the slots
|
||||
-- it came from lived on the stick, and the desktop "Open folder" affordance
|
||||
-- (RomImporter:exportSave) followed the returned path straight there (#752).
|
||||
local portableFs = SaveData.portableFs()
|
||||
local fs = portableFs or (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
|
||||
-- Absolute path for the notice line, resolved against whichever root took
|
||||
-- the write. Portable paths use the OS separator (slotDiskPath does the
|
||||
-- same); LOVE save-directory paths stay "/"-joined as before.
|
||||
local portableBase = SaveData.portableBaseDir()
|
||||
if portableBase then
|
||||
local sep = package.config:sub(1, 1)
|
||||
return true, portableBase .. sep .. rel:gsub("/", sep)
|
||||
end
|
||||
local base = fs.getSaveDirectory and fs.getSaveDirectory() or ""
|
||||
if base ~= "" then return true, base .. "/" .. rel end
|
||||
return true, rel
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -254,6 +254,80 @@ function LauncherMods.list()
|
||||
return result or {}
|
||||
end
|
||||
|
||||
-- ------- pre-boot translation strings
|
||||
--
|
||||
-- The launcher draws before Game:load, so the loader has not run and Strings
|
||||
-- has no catalog. #767/#791 routed the launcher's text through Strings, but
|
||||
-- nothing filled the catalog this early, so a translation mod still could not
|
||||
-- reach the launcher however complete it was -- and no restart helped, because
|
||||
-- the ordering is the same on every launch.
|
||||
--
|
||||
-- This fills it, and deliberately does the smallest thing that can: one
|
||||
-- declarative file per enabled mod, lang/strings.lua, and never the entry
|
||||
-- chunk. That keeps the promise the rest of this module is built on -- no mod
|
||||
-- behaviour runs before the game boots -- because a catalog is data.
|
||||
--
|
||||
-- It is still a mod-authored chunk, so it runs with an empty environment: a
|
||||
-- plain `return { ... }` evaluates fine, while anything reaching for love, io
|
||||
-- or os raises and is skipped rather than being trusted this early.
|
||||
--
|
||||
-- Game:load calls Strings.load(Data) again after the real merge, which
|
||||
-- replaces whatever this installed, so the two never disagree for long.
|
||||
local STRINGS_CATALOG = "lang/strings.lua"
|
||||
|
||||
local function readStringsCatalog(path)
|
||||
local fs = love and love.filesystem
|
||||
if not (fs and fs.read) then return nil end
|
||||
local rel = path .. "/" .. STRINGS_CATALOG
|
||||
local raw = fs.read(rel)
|
||||
if type(raw) ~= "string" or raw == "" then return nil end
|
||||
local chunk = loadstring(raw, "@" .. rel)
|
||||
if not chunk then return nil end
|
||||
-- Lua 5.1/LuaJIT: no _ENV, so setfenv is the sandbox.
|
||||
if setfenv then setfenv(chunk, {}) end
|
||||
local ok, result = pcall(chunk)
|
||||
if not ok or type(result) ~= "table" then return nil end
|
||||
return result
|
||||
end
|
||||
|
||||
-- deriveStrings(rows, byId, read) -> the merged catalog, pure.
|
||||
-- rows is deriveList's output, byId the id -> manifest map, and read(path) a
|
||||
-- reader returning that mod's catalog table (or nil). Split out so the engine
|
||||
-- tier can table-drive the enable/precedence rules with no filesystem.
|
||||
function LauncherMods.deriveStrings(rows, byId, read)
|
||||
local out, any = {}, false
|
||||
for _, row in ipairs(rows or {}) do
|
||||
local manifest = row.enabled and byId and byId[row.id] or nil
|
||||
local catalog = manifest and manifest.path and read(manifest.path)
|
||||
for source, value in pairs(catalog or {}) do
|
||||
-- an empty value means "not translated yet", never "translate to
|
||||
-- blank" -- the same rule the mod's own loader applies
|
||||
if type(source) == "string" and type(value) == "string"
|
||||
and value ~= "" then
|
||||
out[source] = value
|
||||
any = true
|
||||
end
|
||||
end
|
||||
end
|
||||
return any and out or nil
|
||||
end
|
||||
|
||||
-- translationStrings() -> a source -> translation map for the launcher, or nil
|
||||
-- when no enabled mod ships one. Enable-state and ordering are deriveList's,
|
||||
-- so a mod that wins a key here wins it at boot too.
|
||||
function LauncherMods.translationStrings()
|
||||
local ok, merged = pcall(function()
|
||||
local manifests = discover()
|
||||
if #manifests == 0 then return nil end
|
||||
local rows = LauncherMods.deriveList(manifests, SaveData.loadOptions())
|
||||
local byId = {}
|
||||
for _, m in ipairs(manifests) do byId[m.id] = m end
|
||||
return LauncherMods.deriveStrings(rows, byId, readStringsCatalog)
|
||||
end)
|
||||
if not ok then return nil end
|
||||
return merged
|
||||
end
|
||||
|
||||
-- setEnabled(id, enabled): persist options.mods[id] in the exact shape
|
||||
-- Loader:_saveState writes (a plain boolean), so the running game and the
|
||||
-- in-game ManagerState pick it up unchanged.
|
||||
@@ -282,10 +356,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 +379,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 +399,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 +602,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))
|
||||
|
||||
@@ -174,6 +174,13 @@ local function parseEntry(raw)
|
||||
conflicts = raw.conflicts,
|
||||
thumbnail = str(raw.thumbnail),
|
||||
description_url = str(raw.description_url),
|
||||
-- Optional release stats a feed author can publish: total downloads
|
||||
-- across all releases plus first/last release dates. Additive-only,
|
||||
-- so a feed that carries them stays readable by every build that
|
||||
-- predates them (and one that does not still renders fine here).
|
||||
downloads = tonumber(raw.downloads),
|
||||
first_release = str(raw.first_release),
|
||||
last_release = str(raw.last_release),
|
||||
latest = parseLatest(raw.latest),
|
||||
update_check = str(raw.update_check) or "pending",
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ local ModUpdate = {}
|
||||
|
||||
ModUpdate.CACHE_TTL = 6 * 60 * 60 -- six hours
|
||||
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local function stripV(tag)
|
||||
return (tostring(tag):gsub("^[vV]", ""))
|
||||
end
|
||||
@@ -102,7 +104,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 +118,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 +132,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 +167,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 +193,84 @@ 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
|
||||
|
||||
-- One resolver over a release list: { total, first, latest } or nil when
|
||||
-- the list carries neither counts nor dates. The FIND MODS rows use this
|
||||
-- on the repo's fetched releases, the same source the MODS tab trusts.
|
||||
function ModUpdate.statsForReleases(releases)
|
||||
local dl = ModUpdate.totalDownloads(releases)
|
||||
local d = ModUpdate.releaseDates(releases)
|
||||
if not dl and not d then return nil end
|
||||
return {
|
||||
total = dl and dl.total or nil,
|
||||
first = d and d.first or nil,
|
||||
latest = d and d.latest or nil,
|
||||
}
|
||||
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
|
||||
|
||||
-- The one-line stats string both launcher panels show: "1,234 downloads
|
||||
-- across all releases - Released 2024-05-31 - Updated 2026-07-01".
|
||||
-- Each part is optional; nil everywhere means nil, so a row with no data
|
||||
-- shows no line rather than a wrong "0".
|
||||
function ModUpdate.statsLine(total, first, latest)
|
||||
local parts = {}
|
||||
if total ~= nil then
|
||||
parts[#parts + 1] = Strings("%s downloads across all releases",
|
||||
ModUpdate.formatCount(total))
|
||||
end
|
||||
if first or latest then
|
||||
parts[#parts + 1] = Strings("Released %s - Updated %s",
|
||||
first or "?", latest or "?")
|
||||
end
|
||||
if #parts == 0 then return nil end
|
||||
return table.concat(parts, " - ")
|
||||
end
|
||||
|
||||
-- ------- cache (options.modUpdateCache[repo])
|
||||
|
||||
local function cacheStore()
|
||||
@@ -210,6 +301,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 +333,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 +389,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
|
||||
|
||||
@@ -74,7 +74,11 @@ function f.map(key, value)
|
||||
desc = ("map of %s -> %s"):format(key.desc, value.desc) }
|
||||
end
|
||||
|
||||
function f.rec(fields)
|
||||
-- opts.strict closes the record to unknown fields even at the extensible
|
||||
-- top level. A union alternative whose fields are ALL optional needs this:
|
||||
-- with top-level leniency it matches every table, so the union stops
|
||||
-- rejecting anything (the font "ttf" shape was the first such alternative).
|
||||
function f.rec(fields, opts)
|
||||
local names = {}
|
||||
for name in pairs(fields) do names[#names + 1] = name end
|
||||
table.sort(names)
|
||||
@@ -83,7 +87,7 @@ function f.rec(fields)
|
||||
local ft = fields[name]
|
||||
parts[#parts + 1] = name .. (ft.kind == "opt" and "?" or "")
|
||||
end
|
||||
return { kind = "rec", fields = fields,
|
||||
return { kind = "rec", fields = fields, strict = opts and opts.strict or nil,
|
||||
desc = "{" .. table.concat(parts, ", ") .. "}" }
|
||||
end
|
||||
|
||||
@@ -175,7 +179,7 @@ checkValue = function(t, value, path, patchMode, errors, top)
|
||||
-- unknown keys are preserved unless they read as a typo of a known
|
||||
-- field. Nested recs stay strict, that is where typos hide.
|
||||
local hint = suggest(t.fields, key)
|
||||
if hint or not top then
|
||||
if hint or not top or t.strict then
|
||||
errors[#errors + 1] = ("%s.%s: unknown field%s"):format(path, tostring(key),
|
||||
hint and (' (did you mean "' .. hint .. '"?)') or "")
|
||||
end
|
||||
@@ -1064,6 +1068,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 +1081,17 @@ 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) },
|
||||
-- strict: every field here is optional ({} is a legal "ttf" entry), so
|
||||
-- with the usual top-level leniency this alternative would match ANY
|
||||
-- table and let malformed pages through the union unchecked
|
||||
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),
|
||||
-- characters that keep their ROM tile instead of coming from the
|
||||
-- TTF: a string of them, or a list when a multi-character charmap
|
||||
-- sequence is meant (src/render/Font.lua)
|
||||
tiles = f.opt(f.union{ f.str, f.list(f.str) }) },
|
||||
{ strict = true }),
|
||||
},
|
||||
extra = function(id, value)
|
||||
if fontIsCharmap(id) then
|
||||
@@ -1080,12 +1101,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/tiles, 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 +1123,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 +1138,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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -35,6 +55,34 @@ local function pagesOf(def)
|
||||
return pages
|
||||
end
|
||||
|
||||
-- ttf.tiles as a lookup keyed by charmap sequence. Accepts a plain string
|
||||
-- ("0123456789"), which is split into UTF-8 characters, or a list of
|
||||
-- sequences ({ "0", "1", "<PK>" }) when a multi-character macro is meant.
|
||||
local function tileSet(spec)
|
||||
local set = {}
|
||||
if type(spec) == "table" then
|
||||
for _, seq in ipairs(spec) do set[tostring(seq)] = true end
|
||||
elseif type(spec) == "string" then
|
||||
-- split on UTF-8 lead bytes rather than utf8Decode, which this file
|
||||
-- declares further down and would be nil here
|
||||
local i, n = 1, #spec
|
||||
while i <= n do
|
||||
local last = i
|
||||
if spec:byte(i) >= 0xC0 then
|
||||
local k = i + 1
|
||||
while k <= n do
|
||||
local b = spec:byte(k)
|
||||
if b < 0x80 or b > 0xBF then break end
|
||||
last, k = k, k + 1
|
||||
end
|
||||
end
|
||||
set[spec:sub(i, last)] = true
|
||||
i = last + 1
|
||||
end
|
||||
end
|
||||
return set
|
||||
end
|
||||
|
||||
function Font.load(data)
|
||||
loadedFrom = data
|
||||
local def = data.font
|
||||
@@ -83,6 +131,51 @@ 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())),
|
||||
-- Single characters that keep their ROM tile instead of coming from
|
||||
-- the TTF. A CJK translation sizes the font so a kana fills the 8px
|
||||
-- cell, which leaves Latin narrower than the tile font it replaces:
|
||||
-- the numbers in a right-aligned column (the party menu's ":L12" over
|
||||
-- "34/ 34") then no longer land where the 8px-per-character layout put
|
||||
-- them. Naming "0123456789" here keeps digits on the vanilla tiles --
|
||||
-- identical to the English build -- while kana still come from the
|
||||
-- font. Sequence keys, so "é" or a "<PK>" macro can be listed too.
|
||||
tiles = tileSet(def.ttf.tiles),
|
||||
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 +197,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 +255,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 +264,24 @@ 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 and not ttf.tiles[entry.seq] then
|
||||
-- single characters belong to the TTF; only multi-character
|
||||
-- sequences (ligatures, <PK> macros) keep their tile mapping,
|
||||
-- plus anything the mod named in ttf.tiles (see Font.load)
|
||||
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 +335,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
|
||||
@@ -232,8 +405,18 @@ for key, code in pairs(Font.DEFAULT_BORDER) do Font.BORDER[key] = code end
|
||||
|
||||
-- Draw a Game Boy style bordered box in tile coordinates.
|
||||
function Font.drawBox(tx, ty, tw, th)
|
||||
-- The white interior is a fill, so it needs the color; everything after it
|
||||
-- is a glyph and needs the caller's. Restoring is not cosmetic: the tile
|
||||
-- pages are black glyphs on transparent, so they come out black whatever
|
||||
-- the color is, and leaking white here was invisible for as long as every
|
||||
-- glyph was a tile. TTF text is not immune -- it draws in the current
|
||||
-- color -- so a leaked white left every label printed after a box white on
|
||||
-- white. On the summary screen that erased ATTACK/DEFENSE/SPEED/SPECIAL
|
||||
-- and TYPE1/TYPE2 while the numbers beside them, still tiles, stayed put.
|
||||
local r, g, b, a = love.graphics.getColor()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", tx * 8, ty * 8, tw * 8, th * 8)
|
||||
love.graphics.setColor(r, g, b, a)
|
||||
local B = Font.BORDER
|
||||
Font.drawCode(B.tl, tx * 8, ty * 8)
|
||||
Font.drawCode(B.tr, (tx + tw - 1) * 8, ty * 8)
|
||||
|
||||
@@ -240,6 +240,14 @@ end
|
||||
-- and the zone lists are exactly the ones the states returned.
|
||||
local trueColorRects = { ui = {}, world = {} }
|
||||
local currentPass = nil
|
||||
-- Horizontal shift applied to UI-pass marks. A wide battle keeps its 304px
|
||||
-- surface through every classic state it opens, and Game:draw centres each
|
||||
-- of those with a translate while centerClassicZones shifts their zone list
|
||||
-- by the same amount. A rect reported from inside that translate has to
|
||||
-- move with it, or the unshaded re-blit lands 72 columns off and the pic
|
||||
-- keeps the shade remap -- the party STATS screen in a wide battle (#637).
|
||||
-- World-pass marks are already in world-canvas space and are never centred.
|
||||
local markOffsetX = 0
|
||||
|
||||
-- which canvas the renderer is filling. nil for a pass that composites
|
||||
-- with no zone list of its own (tilt's upright billboards carry their own
|
||||
@@ -252,11 +260,20 @@ function PaletteFX.clearTrueColor()
|
||||
for _, rects in pairs(trueColorRects) do
|
||||
for i = #rects, 1, -1 do rects[i] = nil end
|
||||
end
|
||||
markOffsetX = 0
|
||||
end
|
||||
|
||||
-- Game:draw declares the translate it is drawing a classic state under, so
|
||||
-- that state's marks land where its pixels did. Cleared with the rects at
|
||||
-- the top of every frame (Renderer:beginFrame). #637
|
||||
function PaletteFX.setMarkOffset(dx)
|
||||
markOffsetX = tonumber(dx) or 0
|
||||
end
|
||||
|
||||
function PaletteFX.markTrueColor(x, y, w, h)
|
||||
local rects = currentPass and trueColorRects[currentPass]
|
||||
if not rects or w <= 0 or h <= 0 then return end
|
||||
if currentPass == "ui" then x = x + markOffsetX end
|
||||
rects[#rects + 1] = { colors = false, x = x, y = y, w = w, h = h }
|
||||
end
|
||||
|
||||
|
||||
@@ -564,16 +564,24 @@ function Renderer:drawTiltedWorld(zoneList, sx, sy, wox, woy, target)
|
||||
return true
|
||||
end
|
||||
|
||||
-- LÖVE 11 truncates scissor arguments to framebuffer pixels; the half-pixel
|
||||
-- bias keeps values divided back through a fractional DPI scale from landing
|
||||
-- one short. LÖVE 12 passes fractional arguments through and rounds in the
|
||||
-- graphics backend instead, where that bias shifts each origin by one pixel
|
||||
-- and extends its far edge by two (#673).
|
||||
local SCISSOR_PIXEL_BIAS = 0.5
|
||||
if love and love.getVersion and select(1, love.getVersion()) >= 12 then
|
||||
SCISSOR_PIXEL_BIAS = 0
|
||||
end
|
||||
|
||||
-- Clamp a scissor rect to the viewport box, then round it outward to whole
|
||||
-- framebuffer pixels. love.graphics.setScissor truncates x, y, w and h to
|
||||
-- pixels independently, so a rect with fractional unit edges (Android's
|
||||
-- non-integer DPI puts fitScale/dpi in Sx/Sy) loses up to a pixel per side
|
||||
-- and two adjacent SGB zones stop sharing an edge: the letterbox clear shows
|
||||
-- through as a horizontal seam at every zone boundary (#373). Rounding
|
||||
-- outward makes neighbours overlap by at most one row instead -- the overlap
|
||||
-- redraws the same canvas pixels one palette later, and past the canvas edge
|
||||
-- there is nothing to draw. The half pixel keeps LOVE's truncation on the
|
||||
-- snapped edge rather than one short of it.
|
||||
-- framebuffer pixels. On LÖVE 11, x, y, w and h are truncated independently,
|
||||
-- so a rect with fractional unit edges (Android's non-integer DPI puts
|
||||
-- fitScale/dpi in Sx/Sy) loses up to a pixel per side and two adjacent SGB
|
||||
-- zones stop sharing an edge: the letterbox clear shows through as a seam at
|
||||
-- every zone boundary (#373). Rounding outward makes neighbours overlap by
|
||||
-- at most one row instead; SCISSOR_PIXEL_BIAS preserves that result across the
|
||||
-- LÖVE 11 and 12 conversion rules.
|
||||
local function scissorClamped(x, y, w, h, ox, oy, vpw, vph, dpiX, dpiY)
|
||||
local x2, y2 = math.min(x + w, ox + vpw), math.min(y + h, oy + vph)
|
||||
x, y = math.max(x, ox), math.max(y, oy)
|
||||
@@ -581,9 +589,10 @@ local function scissorClamped(x, y, w, h, ox, oy, vpw, vph, dpiX, dpiY)
|
||||
dpiX, dpiY = dpiX or 1, dpiY or 1
|
||||
local px1, py1 = math.floor(x * dpiX), math.floor(y * dpiY)
|
||||
local px2, py2 = math.ceil(x2 * dpiX), math.ceil(y2 * dpiY)
|
||||
love.graphics.setScissor((px1 + 0.5) / dpiX, (py1 + 0.5) / dpiY,
|
||||
(px2 - px1 + 0.5) / dpiX,
|
||||
(py2 - py1 + 0.5) / dpiY)
|
||||
local b = SCISSOR_PIXEL_BIAS
|
||||
love.graphics.setScissor((px1 + b) / dpiX, (py1 + b) / dpiY,
|
||||
(px2 - px1 + b) / dpiX,
|
||||
(py2 - py1 + b) / dpiY)
|
||||
return true
|
||||
end
|
||||
|
||||
@@ -941,9 +950,22 @@ function Renderer:endFrame(zones, worldZones)
|
||||
-- reads as the foreground instead of competing with a fully lit map. Goes
|
||||
-- here rather than in the letterbox clear because with the world pass
|
||||
-- active there is no clear -- the world already covers the surface.
|
||||
--
|
||||
-- The veil covers the voids ONLY, never the battle's own letterbox: "world"
|
||||
-- changes what surrounds the battle and leaves the battle screen alone
|
||||
-- (BattleState:bgMode). On hardware there is no "behind the battle" to dim
|
||||
-- at all -- _InitBattleCommon calls ClearScreen (pokered home/copy2.asm),
|
||||
-- which blanks the whole tilemap before the battle draws. A whole-surface
|
||||
-- fill was invisible only because the classic battle paints an opaque paper
|
||||
-- field over it a few lines below; a render pipeline that stages the fight
|
||||
-- on the map and keys that field out got the veil straight onto its
|
||||
-- sprites, HP bars and HUD, which is the 55% whole-window dim of #777
|
||||
-- (and its duplicate #772).
|
||||
if self.battleDim and self.battleDim > 0 then
|
||||
love.graphics.setColor(0, 0, 0, self.battleDim)
|
||||
love.graphics.rectangle("fill", 0, 0, ww, wh)
|
||||
for _, r in ipairs(subtractRect({ { 0, 0, ww, wh } }, uox, uoy, uvpw, uvph)) do
|
||||
love.graphics.rectangle("fill", r[1], r[2], r[3], r[4])
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
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
|
||||
|
||||
@@ -59,7 +59,13 @@ local function styleOf(game, id)
|
||||
return record or Transition.STYLES[id]
|
||||
end
|
||||
|
||||
function Transition.new(game, onMidpoint, onDone)
|
||||
-- `warp` marks the map-change fade: PlayMapChangeSound's GBFadeOutToBlack
|
||||
-- has no matching fade in (LoadGBPal restores the palettes in one write), so
|
||||
-- warps land with framesIn 0. Script fades that bracket a HideObject
|
||||
-- (ViridianGym.asm .afterBeat, RocketHideoutB4F BeatGiovanniScript) call
|
||||
-- GBFadeOutToBlack -> GBFadeInFromBlack instead, so the default keeps the
|
||||
-- symmetric 32-frame fade back in (home/fade.asm:21, b = 4).
|
||||
function Transition.new(game, onMidpoint, onDone, warp)
|
||||
local self = setmetatable({}, Transition)
|
||||
self.game = game
|
||||
self.onMidpoint = onMidpoint
|
||||
@@ -68,9 +74,13 @@ function Transition.new(game, onMidpoint, onDone)
|
||||
self.phase = "out"
|
||||
local style = styleOf(game, "warp_fade")
|
||||
self.frames = style.frames or FRAMES
|
||||
-- a style may still ask for a fade in (mods, and the record is data-driven);
|
||||
-- the built-in warp is 0, matching hardware
|
||||
self.framesIn = style.framesIn or FRAMES_IN
|
||||
if warp then
|
||||
-- a style may still ask for a fade in (mods, and the record is
|
||||
-- data-driven); the built-in warp is 0, matching hardware
|
||||
self.framesIn = style.framesIn or FRAMES_IN
|
||||
else
|
||||
self.framesIn = Timing.FADE_IN_FROM_BLACK
|
||||
end
|
||||
return self
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
-- Per-glyph fallback for the launcher's UI faces.
|
||||
--
|
||||
-- The launcher draws with LÖVE's default face, which covers Latin and little
|
||||
-- else. That was invisible while the launcher was English-only, but its text
|
||||
-- now goes through Strings (#767/#791) and a translation mod's catalog is
|
||||
-- loaded before the first launcher frame (LauncherMods.translationStrings), so
|
||||
-- the moment one is Japanese every kana lands as a tofu box.
|
||||
--
|
||||
-- Font:setFallbacks fills ONLY the codepoints the primary face is missing, so
|
||||
-- Latin keeps the launcher's own look and nothing about an English install
|
||||
-- changes; only the glyphs it genuinely cannot draw come from the bundled
|
||||
-- Plain Pixel (assets/fonts/plainpixel/README.md: CC-BY 4.0, Douglas
|
||||
-- Vautour), which covers kana and CJK. That is the same face the in-game TTF
|
||||
-- text mode uses, so a translated launcher and a translated game agree.
|
||||
--
|
||||
-- Measuring and rendering must attach the same fallback or the launcher
|
||||
-- measures a width it does not draw -- which is how buttons clip.
|
||||
|
||||
local UiFont = {}
|
||||
|
||||
local FALLBACK_PATH = "assets/fonts/plainpixel/PlainPixel-Regular.ttf"
|
||||
-- Plain Pixel only rasterizes evenly at multiples of its 15px design em, so
|
||||
-- snap rather than matching the primary size exactly: a fallback glyph a pixel
|
||||
-- off its grid is far more obvious than one a pixel off its neighbours.
|
||||
local DESIGN_EM = 15
|
||||
|
||||
local cache = {}
|
||||
local unavailable = false
|
||||
|
||||
local function fallbackFor(size)
|
||||
if unavailable then return nil end
|
||||
local snapped = math.max(DESIGN_EM,
|
||||
math.floor(size / DESIGN_EM + 0.5) * DESIGN_EM)
|
||||
local hit = cache[snapped]
|
||||
if hit ~= nil then return hit or nil end
|
||||
local ok, font = pcall(love.graphics.newFont, FALLBACK_PATH, snapped)
|
||||
if not ok or not font then
|
||||
-- one failure means the file is absent (a trimmed build); stop retrying
|
||||
unavailable = true
|
||||
return nil
|
||||
end
|
||||
if font.setFilter then pcall(font.setFilter, font, "nearest", "nearest") end
|
||||
cache[snapped] = font
|
||||
return font
|
||||
end
|
||||
|
||||
-- attach(font, size) -> font. Safe to call on every cache miss; a font whose
|
||||
-- fallback is already set is left alone, and any failure is swallowed so a
|
||||
-- missing fallback file can never take the launcher down with it.
|
||||
function UiFont.attach(font, size)
|
||||
if not (font and font.setFallbacks) then return font end
|
||||
local fallback = fallbackFor(size or (font.getHeight and font:getHeight()) or DESIGN_EM)
|
||||
if not fallback or rawequal(fallback, font) then return font end
|
||||
pcall(font.setFallbacks, font, fallback)
|
||||
return font
|
||||
end
|
||||
|
||||
function UiFont.clear()
|
||||
cache, unavailable = {}, false
|
||||
end
|
||||
|
||||
return UiFont
|
||||
@@ -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
|
||||
|
||||
@@ -784,12 +784,17 @@ end
|
||||
-- The Viridian old man's catch tutorial (scripts/ViridianCity.asm
|
||||
-- BATTLE_TYPE_OLD_MAN): a demo wild battle where the old man throws
|
||||
-- one POKé BALL; nothing is kept.
|
||||
function Commands.old_man_demo(ctx)
|
||||
-- `outcome` == "fail" is Yellow's initial training: ItemUseBall's
|
||||
-- .oldManBattle branch reads EVENT_INITIAL_CATCH_TRAINING and stores anim
|
||||
-- data $63, so the ball shakes three times and breaks open (pokeyellow
|
||||
-- engine/items/item_effects.asm). Red, Blue and Yellow's repeat "Watch
|
||||
-- closely!" demo all catch, and all of them omit the argument (#636).
|
||||
function Commands.old_man_demo(ctx, outcome)
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local runner = ctx.runner
|
||||
local om = ctx.game.data.field.oldManBattle or { species = "WEEDLE", level = 5 }
|
||||
local battle = BattleState.newWild(ctx.game, om.species, om.level)
|
||||
battle:makeOldManDemo()
|
||||
battle:makeOldManDemo(nil, outcome == "fail")
|
||||
battle.onFinish = function() runner:resume() end
|
||||
-- InitWildBattle calls DoBattleTransitionAndInitBattleVariables
|
||||
-- unconditionally (core.asm:6699) -- there is no BATTLE_TYPE_OLD_MAN
|
||||
|
||||
@@ -9,8 +9,9 @@
|
||||
-- CRED_TEXT_MON text appears at once, hold 110, mon wipe
|
||||
-- CRED_TEXT_FADE fade in, hold 120, next screen replaces the text
|
||||
-- CRED_TEXT text appears at once, hold 140
|
||||
-- The mon wipe is DisplayCreditsMon: the middle band scrolls left 8px per
|
||||
-- frame for 27 frames (ScrollCreditsMonLeft x7 then x20) while the next
|
||||
-- The mon wipe is DisplayCreditsMon: three CreditsCopyTileMapToVRAM copies
|
||||
-- (9 frames of Delay3, text still up), then the middle band scrolls left 8px
|
||||
-- per frame for 27 frames (ScrollCreditsMonLeft x7 then x20) while the next
|
||||
-- CreditsMons entry crosses right-to-left as a black silhouette
|
||||
-- (BGP %11111100), leaving the band blank; BGP is left at %11000000, which
|
||||
-- is why every post-wipe screen is a FADE variant. CRED_COPYRIGHT
|
||||
@@ -52,6 +53,14 @@ local HOLD_FADE = 120
|
||||
local HOLD_TEXT = 140
|
||||
|
||||
local WIPE_FRAMES = 27 -- ScrollCreditsMonLeft: 7 + 20 calls, 8px/frame
|
||||
-- DisplayCreditsMon runs three CreditsCopyTileMapToVRAM calls (vBGMap0+$c,
|
||||
-- vBGMap0, vBGMap1) before the first scroll, and each one ends in `jp Delay3`
|
||||
-- (home/palettes.asm), so the credits text sits still for 9 more frames on
|
||||
-- every mon screen. Dropping them ran the 15 mon screens 135 frames short and
|
||||
-- brought THE END up 2.2s early against a credits theme whose length is fixed
|
||||
-- by the ROM program (Music_Credits is 5880 frames and does not loop), which
|
||||
-- is what made the song look like it overran the roll (#703).
|
||||
local MON_PREP_FRAMES = 9
|
||||
|
||||
-- LoadCopyrightTiles (engine/movie/title.asm CopyrightTextString): tile
|
||||
-- sequences into the extracted title/copyright.png strip (tiles $60-$72:
|
||||
@@ -204,12 +213,17 @@ function Credits:update(dt)
|
||||
self.timer = self.screen.mon and HOLD_FADE_MON or HOLD_FADE
|
||||
elseif self.phase == "hold" then
|
||||
if self.screen.mon then
|
||||
self.phase = "wipe"
|
||||
self.timer = WIPE_FRAMES
|
||||
self.monImg, self.monTint = self:monSprite(self.screen.mon)
|
||||
-- the text stays up through DisplayCreditsMon's VRAM copies; the
|
||||
-- silhouette only starts moving once ScrollCreditsMonLeft does
|
||||
self.phase = "mon_prep"
|
||||
self.timer = MON_PREP_FRAMES
|
||||
else
|
||||
self:nextScreen()
|
||||
end
|
||||
elseif self.phase == "mon_prep" then
|
||||
self.phase = "wipe"
|
||||
self.timer = WIPE_FRAMES
|
||||
self.monImg, self.monTint = self:monSprite(self.screen.mon)
|
||||
elseif self.phase == "wipe" then
|
||||
self.monImg = nil
|
||||
self:nextScreen()
|
||||
@@ -313,7 +327,8 @@ function Credits:draw()
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 32)
|
||||
love.graphics.rectangle("fill", 0, 112, 160, 32)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
if self.phase == "fade" or self.phase == "hold" then
|
||||
if self.phase == "fade" or self.phase == "hold"
|
||||
or self.phase == "mon_prep" then
|
||||
self:drawPage(self.screen, 0, self.shade)
|
||||
elseif self.phase == "wipe" then
|
||||
-- ScrollCreditsMonLeft: the middle band scrolls left 8px/frame while
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -73,8 +73,12 @@ function HallOfFame.new(game, onDone)
|
||||
self.timer = 0
|
||||
self.phase = "mons"
|
||||
self.sprites = {} -- species -> image or false
|
||||
self.playerPic = tryImage(require("src.pokemon.Sprites").playerPath(
|
||||
game.data, "front", { kind = "hof" }))
|
||||
self.spriteTrueColor = {} -- species -> full-color art flag (#637)
|
||||
local playerPath, playerTrueColor =
|
||||
require("src.pokemon.Sprites").playerPath(
|
||||
game.data, "front", { kind = "hof" })
|
||||
self.playerPic = tryImage(playerPath)
|
||||
self.playerTrueColor = self.playerPic and playerTrueColor or false
|
||||
self.scrollX = PIC_X
|
||||
self.showHofBanner = false
|
||||
self.fade = 0
|
||||
@@ -113,10 +117,11 @@ end
|
||||
function HallOfFame:spriteFor(species)
|
||||
local cached = self.sprites[species]
|
||||
if cached == nil then
|
||||
local path = require("src.pokemon.Sprites").path(
|
||||
local path, trueColor = require("src.pokemon.Sprites").path(
|
||||
self.game.data, species, "front", { kind = "hof" })
|
||||
cached = tryImage(path) or false
|
||||
self.sprites[species] = cached
|
||||
self.spriteTrueColor[species] = cached and trueColor or false
|
||||
end
|
||||
return cached or nil
|
||||
end
|
||||
@@ -225,13 +230,18 @@ function HallOfFame:drawMonInfo(mon)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
local name = mon.nickname or (def and def.name) or mon.species
|
||||
Font.draw(name, 1 * 8, 4 * 8)
|
||||
-- HoFMonInfoText is placed at (2,6) with "next" separators, and PlaceNextChar
|
||||
-- advances 2 * SCREEN_WIDTH per <NEXT> unless BIT_SINGLE_SPACED_LINES is set
|
||||
-- (home/text.asm); nothing on the HoF path sets it, so the labels sit on rows
|
||||
-- 6/8/10 lined up with their values, not 6/7/8 (#697).
|
||||
Font.draw(Strings("LEVEL/"), 2 * 8, 6 * 8)
|
||||
Font.draw(Strings("TYPE1/"), 2 * 8, 7 * 8)
|
||||
Font.draw(Strings("TYPE1/"), 2 * 8, 8 * 8)
|
||||
local t1 = def and def.types and def.types[1]
|
||||
local t2 = def and def.types and def.types[2]
|
||||
local dual = t2 and t2 ~= t1
|
||||
if dual then
|
||||
Font.draw(Strings("TYPE2/"), 2 * 8, 8 * 8)
|
||||
-- EraseType2Text blanks 6 tiles at hl+$13 from (3,9), so TYPE2/ is at (2,10)
|
||||
Font.draw(Strings("TYPE2/"), 2 * 8, 10 * 8)
|
||||
end
|
||||
-- PrintLevelCommon at (8,7): bare level digits (no <LV> tile here)
|
||||
Font.draw(tostring(mon.level), 8 * 8, 7 * 8)
|
||||
@@ -251,10 +261,20 @@ function HallOfFame:drawHofBanner()
|
||||
Font.draw(Strings("HALL OF FAME"), 4 * 8, 15 * 8)
|
||||
end
|
||||
|
||||
function HallOfFame:drawPic(img)
|
||||
function HallOfFame:drawPic(img, trueColor)
|
||||
if not img then return end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(img, self.scrollX or PIC_X, PIC_Y)
|
||||
local x = self.scrollX or PIC_X
|
||||
love.graphics.draw(img, x, PIC_Y)
|
||||
-- SET_PAL_POKEMON_WHOLE_SCREEN (HoFShowMonOrPlayer) colors the WHOLE
|
||||
-- screen in the mon's palette, so sgbPalettes above hands the blit a
|
||||
-- whole-canvas zone -- and a full-color pic has to sit that remap out.
|
||||
-- Report the rect the pic covers for the unshaded pass, the way
|
||||
-- SummaryMenu does for the status screen pic (#637; #430).
|
||||
if trueColor then
|
||||
require("src.render.PaletteFX").markTrueColor(x, PIC_Y,
|
||||
img:getDimensions())
|
||||
end
|
||||
end
|
||||
|
||||
-- HoFDisplayPlayerStats boxes + labels (player pic already on the right)
|
||||
@@ -284,7 +304,8 @@ function HallOfFame:draw()
|
||||
if self.phase == "mons" or self.phase == "fade" then
|
||||
local mon = self.game.save.party[self.index]
|
||||
if mon then
|
||||
self:drawPic(self:spriteFor(mon.species))
|
||||
self:drawPic(self:spriteFor(mon.species),
|
||||
self.spriteTrueColor[mon.species])
|
||||
if self.scrollX >= PIC_X then
|
||||
self:drawMonInfo(mon)
|
||||
if self.showHofBanner then
|
||||
@@ -297,13 +318,13 @@ function HallOfFame:draw()
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
end
|
||||
elseif self.phase == "player" then
|
||||
self:drawPic(self.playerPic)
|
||||
self:drawPic(self.playerPic, self.playerTrueColor)
|
||||
elseif self.phase == "player_stats" then
|
||||
self:drawPic(self.playerPic)
|
||||
self:drawPic(self.playerPic, self.playerTrueColor)
|
||||
self:drawPlayerStats()
|
||||
elseif self.phase == "player_dex" or self.phase == "player_rating" then
|
||||
-- the TextBox chain draws the dex texts over the stat boxes
|
||||
self:drawPic(self.playerPic)
|
||||
self:drawPic(self.playerPic, self.playerTrueColor)
|
||||
self:drawPlayerStats()
|
||||
end
|
||||
|
||||
|
||||