Compare commits
447 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e3a215e081 | |||
| 7fa758602a | |||
| bb979911c8 | |||
| f225f8a6e7 | |||
| ee891fb8fa | |||
| 9d73ff4110 | |||
| 0b7ecf1599 | |||
| aa45f05c63 | |||
| 9234f12ed0 | |||
| 26e0bd227e | |||
| 64ce66a619 | |||
| 9302bbcbf3 | |||
| 1855f838ef | |||
| 11af06a752 | |||
| 05668591ef | |||
| e74beec32d | |||
| 3efb2c9a6c | |||
| d3af63e013 | |||
| ca6bee64b9 | |||
| b52d183dba | |||
| 23e1877a62 | |||
| d8736fe33e | |||
| a74365a4fd | |||
| 1c2163bb5f | |||
| 12c2677dc2 | |||
| ae1b59ce7f | |||
| 02c4de897d | |||
| 3c6242717e | |||
| 3c71afb9fa | |||
| e79107c644 | |||
| 0afce96c8d | |||
| d881997acd | |||
| 8906133b93 | |||
| b9e3c4a689 | |||
| 81f18e244d | |||
| ff91481018 | |||
| fdb3184c71 | |||
| aa3b2a18ec | |||
| d498016a47 | |||
| 492e0344b5 | |||
| a710c64909 | |||
| 943ba5dcbf | |||
| 371edd1a02 | |||
| 9fc6ba5b01 | |||
| 983bea61aa | |||
| ab74f7adf7 | |||
| bdd0bf45bd | |||
| 4b950fc23d | |||
| af183d95ad | |||
| 1d4d5d2337 | |||
| 653b72d86a | |||
| 12b6ef6350 | |||
| c979685a1f | |||
| 5b1d0261ff | |||
| 5bffc89a2f | |||
| 2ce612d5b1 | |||
| 6999e5aecf | |||
| 44a7910c69 | |||
| 85c6fde443 | |||
| 67b6dcc293 | |||
| 24d9f279ec | |||
| 83f93c27a1 | |||
| d756b7cd43 | |||
| 1e6f504a84 | |||
| abb4f3f9f0 | |||
| 5b0209a7ce | |||
| c1e0685e97 | |||
| 3c57b28c6d | |||
| cab62ff7b3 | |||
| 5b6dfed359 | |||
| 91a85a065b | |||
| 32e2c51365 | |||
| 1992fdf398 | |||
| 59ac27971a | |||
| cd0ace2a39 | |||
| 274c728e77 | |||
| 8547127f54 | |||
| 45d799aa98 | |||
| 660c90626d | |||
| d9dc42895f | |||
| 02fd21bcdb | |||
| af00d6ad14 | |||
| 0d4a518a08 | |||
| 05b43ca258 | |||
| 9d6ea845d7 | |||
| bbca4e8e39 | |||
| 49954ec4ad | |||
| 6e94625f2a | |||
| 0399ad040f | |||
| 726ed1102e | |||
| 5bc7d203a5 | |||
| ce204aaf16 | |||
| ed8a89c5ce | |||
| 66e7d9432e | |||
| d59a9522ee | |||
| f1661b62b3 | |||
| a98562f46b | |||
| 365bd24740 | |||
| d50ea4d067 | |||
| 52c96437fa | |||
| 2a7c8ec81b | |||
| bd6c606303 | |||
| 112120e8fe | |||
| 00f6e3a7b4 | |||
| 9cc72f951b | |||
| 0f484682d8 | |||
| 60cf07fb0a | |||
| 493caee715 | |||
| aa6217e581 | |||
| a640f5504f | |||
| e01672b97d | |||
| 9e3d487a08 | |||
| 506cd2c5c5 | |||
| 6cb0ac5666 | |||
| b7d0ff2e16 | |||
| 984cefdc7b | |||
| 6c892cb7c1 | |||
| 52d1ddc5af | |||
| 75cd28435c | |||
| 9aa8b4e371 | |||
| e94d8e32ef | |||
| 3fae3088ca | |||
| cbb08f76ae | |||
| dd503706b0 | |||
| eeb181bcc0 | |||
| 287534cf26 | |||
| d08fce5fd5 | |||
| 0ae29744c8 | |||
| 84e1a5177b | |||
| c7e72149f1 | |||
| ecfca19f11 | |||
| 90a53e509a | |||
| 15fc97a996 | |||
| 35ed326dda | |||
| f4a1dd9e16 | |||
| 6f75a64c46 | |||
| dc04a4e848 | |||
| cd37aa175c | |||
| cb6cfb5556 | |||
| 439e97aee6 | |||
| 5528525888 | |||
| a49ba66c36 | |||
| 9de4db8531 | |||
| dbc48f377a | |||
| 1d77d42fe2 | |||
| 683fee8028 | |||
| a83d18fc51 | |||
| b9e8b00af0 | |||
| e2820e02c5 | |||
| f92364a002 | |||
| 9696c8d3e2 | |||
| 18b2bcd0a7 | |||
| f56970350a | |||
| 863f371e68 | |||
| 1bfc89fdd4 | |||
| 24c5114745 | |||
| 7def560726 | |||
| 6cbd0de77d | |||
| 9a8101df6a | |||
| 78744a6853 | |||
| efce1bb116 | |||
| 104c95a942 | |||
| f6392e8932 | |||
| 88e34ef65c | |||
| 8f0117a145 | |||
| 1f878c3098 | |||
| ae5276b2d1 | |||
| 4dd4c5c463 | |||
| cafa6f3d61 | |||
| f0ed2efe07 | |||
| bbf48c7e9e | |||
| 99d54b82b6 | |||
| 49776ca1eb | |||
| 272305f3a4 | |||
| 89f023c7dd | |||
| afc5a52978 | |||
| 12a04f4188 | |||
| 0fe7a96781 | |||
| a06a49bfed | |||
| 86b90fd4b8 | |||
| d18824cfae | |||
| 12b4c93279 | |||
| af47e19e1a | |||
| 4e666303d0 | |||
| 0fa8206321 | |||
| 78ac154998 | |||
| bbcaac7b71 | |||
| 719ba49a85 | |||
| 54ae20c69b | |||
| f864837cbe | |||
| 21191b8e80 | |||
| 6426e913b6 | |||
| 3d3e42c6a3 | |||
| 6fe106f55c | |||
| ba8ac3d143 | |||
| 1963a5453e | |||
| 59a383736c | |||
| 1820f411ae | |||
| 8327942a9c | |||
| aec7f6001a | |||
| d377518632 | |||
| ad9b03fe56 | |||
| 3b490bc42f | |||
| dacd9abe73 | |||
| 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 | |||
| f2ea250364 | |||
| d86ac70d9e | |||
| be8023a994 | |||
| a0ab949399 | |||
| 30ed68ae22 | |||
| f2aa376304 | |||
| 1b48862923 | |||
| 25417ffac8 | |||
| 4dfbc5a828 | |||
| f0f5bc7551 | |||
| f002db2929 | |||
| c5791574f5 | |||
| 8c1fbfb429 | |||
| 8e5501a23b | |||
| 50fd0e9c7d | |||
| 17843c2f7a | |||
| 54c8d2706b | |||
| 44bfb1b93f | |||
| 7a78992881 | |||
| 6ed41c3b87 | |||
| 7414c176d9 | |||
| d7581dbece | |||
| eeb8c89d3d | |||
| 2591fde764 | |||
| a8f3a3155c | |||
| 2a927651c5 | |||
| 62ef647811 | |||
| fe4491a87b | |||
| 105bf65e02 | |||
| 8120f113af | |||
| 7e0c64cb78 | |||
| 4afb54c54f | |||
| 74f6b68034 | |||
| 5d1e7ff3c1 | |||
| 3c628140f7 | |||
| 01f68d4038 | |||
| 8bf99c3318 | |||
| f0829ed54f | |||
| 21276e20d1 | |||
| 9fb9648e4c | |||
| af7689a1bc | |||
| 59dba325b9 | |||
| d13064ee64 | |||
| 669c9f4d8b | |||
| 228306f883 | |||
| 0a2ef854e6 | |||
| 61c471343a | |||
| 175bff4b29 | |||
| 95eb1c3a35 | |||
| cf50976d65 | |||
| 3fcee7f951 | |||
| 17a4c83970 | |||
| 9712fb1e9b | |||
| cbf622f4aa | |||
| 7c1cdea2eb | |||
| 2bfa491b6a | |||
| 41fbfc9ba6 | |||
| 1fa7c0eb43 | |||
| 839b238e74 | |||
| ec22d57cd1 | |||
| 23ef283272 | |||
| 327579f9b6 | |||
| e5f9d2903d | |||
| 1f3aa4e111 | |||
| 3f88d1c49a | |||
| 5348ba1c65 | |||
| 2f35fd44a1 | |||
| 470fe70f07 | |||
| 399bf557f3 | |||
| fc2ca6cc26 | |||
| d9d42ec956 | |||
| 5e41c74682 | |||
| 02ad846dfa | |||
| 4e7eda65ed | |||
| 3fabe4f591 | |||
| 81234ef2ab | |||
| 4bd28390ea | |||
| fa886899fc | |||
| a81126a03a | |||
| 164c555bb4 | |||
| e5926893a9 | |||
| dbcaf705c2 | |||
| f6809be81d | |||
| 3ddf70888e | |||
| fe7dcf33ec | |||
| 733450bf86 | |||
| 1dae9622e1 | |||
| da0fa5c9ad | |||
| e576dea676 | |||
| 4cac51a831 | |||
| b6a397460e | |||
| 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 |
@@ -44,7 +44,7 @@ jobs:
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
if git diff --name-only "$BASE_SHA" "$HEAD_SHA" | grep -Eq '^(mobile/ios/|scripts/build_ios\.sh$|\.github/workflows/(ci|release)\.yml$)'; then
|
||||
if git diff --name-only "$BASE_SHA" "$HEAD_SHA" | grep -Eq '^(mobile/ios/|scripts/build_ios\.sh$)'; then
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
@@ -102,6 +102,275 @@ 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
|
||||
|
||||
xbox-uwp-changes:
|
||||
name: detect Xbox UWP 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 '^(ports/uwp/|scripts/build_xbox_uwp\.sh$|scripts/xbox-uwp/|scripts/pack_love\.sh$|\.github/workflows/(ci|release)\.yml$|src/core/Platform\.lua$|src/import/(CacheFs|LauncherView|RomImporter)\.lua$|src/update/Check\.lua$|tests/engine/(platform_nx|uwp_baseroms|uwp_native_picker)_test\.lua$|tests/rom_importer_double_pick_test\.lua$)'; then
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
xbox-uwp-selftest:
|
||||
name: Xbox UWP offline selftest
|
||||
needs: xbox-uwp-changes
|
||||
if: needs.xbox-uwp-changes.outputs.changed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Xbox UWP offline selftest
|
||||
run: bash scripts/xbox-uwp/selftest_build_xbox_uwp.sh
|
||||
- name: Build shared payload
|
||||
run: |
|
||||
scripts/pack_love.sh \
|
||||
--output .bazinga/work/ci-game.love \
|
||||
--listing .bazinga/work/ci-love-listing.txt \
|
||||
--version 0.0.0
|
||||
- name: Upload shared payload
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: gen1recomp-xbox-uwp-payload
|
||||
path: .bazinga/work/ci-game.love
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
xbox-uwp-build:
|
||||
name: Xbox UWP build
|
||||
needs: [xbox-uwp-changes, xbox-uwp-selftest]
|
||||
if: |
|
||||
always()
|
||||
&& needs.xbox-uwp-changes.outputs.changed == 'true'
|
||||
&& needs.xbox-uwp-selftest.result == 'success'
|
||||
runs-on: windows-2022
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Download shared payload
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: gen1recomp-xbox-uwp-payload
|
||||
path: .bazinga/work
|
||||
- name: Build Xbox UWP package
|
||||
shell: bash
|
||||
run: |
|
||||
bash scripts/build_xbox_uwp.sh \
|
||||
--release \
|
||||
--version 0.0.0 \
|
||||
--game-love .bazinga/work/ci-game.love
|
||||
- name: Upload Xbox UWP package
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: gen1recomp-xbox-uwp
|
||||
path: |
|
||||
dist/xbox-uwp/gen1recomp-0.0.0-xbox-uwp.zip
|
||||
dist/xbox-uwp/gen1recomp-0.0.0-xbox-uwp.zip.sha256
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
linux-arm64-changes:
|
||||
name: detect Linux arm64 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_linux_arm64\.sh$|scripts/linux-arm64/|scripts/pack_love\.sh$|docs/linux-arm64-build\.md$|\.github/workflows/(ci|release)\.yml$)'; then
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
linux-arm64-selftest:
|
||||
name: Linux arm64 offline selftest
|
||||
needs: linux-arm64-changes
|
||||
if: needs.linux-arm64-changes.outputs.changed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
# Deliberately on x86_64: everything this gate checks (pins, the
|
||||
# host-arch guard, the dependency exclude list, the AppRun fusion
|
||||
# contract) is answerable without a container or an aarch64 machine,
|
||||
# so the slow native job below only ever starts on a sane tree.
|
||||
- name: Linux arm64 offline selftest
|
||||
run: bash scripts/linux-arm64/selftest_build_linux_arm64.sh
|
||||
|
||||
linux-arm64-build:
|
||||
name: Linux arm64 AppImage build
|
||||
needs: [linux-arm64-changes, linux-arm64-selftest]
|
||||
if: |
|
||||
always()
|
||||
&& needs.linux-arm64-changes.outputs.changed == 'true'
|
||||
&& needs.linux-arm64-selftest.result == 'success'
|
||||
# No fork restriction, unlike switch-build: this needs no secrets and no
|
||||
# self-hosted hardware, just GitHub's free arm64 runner for public repos,
|
||||
# so contributors get the same coverage on their own PRs.
|
||||
runs-on: ubuntu-24.04-arm
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Build the aarch64 AppImage
|
||||
run: |
|
||||
set -euo pipefail
|
||||
scripts/build_linux_arm64.sh --version 0.0.0
|
||||
- name: Verify the AppImage is self-contained and bullseye-compatible
|
||||
run: |
|
||||
set -euo pipefail
|
||||
image="dist/linux-arm64/gen1recomp-0.0.0-linux-arm64.AppImage"
|
||||
|
||||
# --appimage-extract needs no FUSE, so this works on a runner
|
||||
# without /dev/fuse and still exercises the real payload.
|
||||
"$image" --appimage-extract >/dev/null
|
||||
for required in AppRun bin/love game.love lib/liblove-11.5.so; do
|
||||
[ -e "squashfs-root/$required" ] \
|
||||
|| { echo "::error::AppImage is missing $required"; exit 1; }
|
||||
done
|
||||
|
||||
# Every bundled object must resolve once AppRun's LD_LIBRARY_PATH is
|
||||
# applied; an unresolved soname here is a user-visible launch crash.
|
||||
#
|
||||
# This runs on a HEADLESS runner on purpose, and that is the point.
|
||||
# The first version of this build bundled Debian's SDL2, which
|
||||
# hard-links libpulse/libasound/libX11/libwayland, so it only ever
|
||||
# started on a full desktop -- a bare runner is what exposed it.
|
||||
missing="$(LD_LIBRARY_PATH="$PWD/squashfs-root/lib" \
|
||||
ldd squashfs-root/bin/love squashfs-root/lib/*.so* 2>/dev/null \
|
||||
| grep 'not found' || true)"
|
||||
[ -z "$missing" ] || { echo "::error::unresolved deps:"; echo "$missing"; exit 1; }
|
||||
|
||||
# Nothing may hard-link a driver, session or audio-stack library:
|
||||
# those must be reached through dlopen so the AppImage runs on a box
|
||||
# with only ALSA, only Wayland, or only KMSDRM.
|
||||
linked="$(for f in squashfs-root/bin/love squashfs-root/lib/*.so*; do
|
||||
objdump -p "$f" 2>/dev/null | awk '/NEEDED/{print $2}'
|
||||
done | sort -u | grep -E '^lib(pulse|asound|X11|wayland|GL|EGL|drm|gbm|xcb|cairo|sndio|dbus)' || true)"
|
||||
[ -z "$linked" ] \
|
||||
|| { echo "::error::these must be dlopened, not linked:"; echo "$linked"; exit 1; }
|
||||
|
||||
# The whole point of compiling on bullseye. If a future change moves
|
||||
# the builder to a newer base, the glibc floor silently rises and
|
||||
# every user on an older distro gets "GLIBC_2.xx not found" -- catch
|
||||
# it here instead of in a release.
|
||||
floor="$(objdump -T squashfs-root/bin/love squashfs-root/lib/*.so* 2>/dev/null \
|
||||
| grep -o 'GLIBC_[0-9.]*' | sort -V | tail -1)"
|
||||
echo "highest required glibc symbol version: $floor"
|
||||
[ -n "$floor" ] \
|
||||
|| { echo "::error::found no versioned glibc symbols -- objdump read nothing"; exit 1; }
|
||||
highest="$(printf '%s\n' "$floor" "GLIBC_2.31" | sort -V | tail -1)"
|
||||
[ "$highest" = "GLIBC_2.31" ] \
|
||||
|| { echo "::error::AppImage requires $floor, above the bullseye 2.31 floor"; exit 1; }
|
||||
- name: Upload the AppImage
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: gen1recomp-linux-arm64
|
||||
path: |
|
||||
dist/linux-arm64/gen1recomp-0.0.0-linux-arm64.AppImage
|
||||
dist/linux-arm64/gen1recomp-0.0.0-linux-arm64.AppImage.sha256
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
headless:
|
||||
name: headless suites (no ROM)
|
||||
runs-on: ubuntu-latest
|
||||
@@ -114,6 +383,9 @@ jobs:
|
||||
- name: install luajit
|
||||
run: sudo apt-get update && sudo apt-get install -y luajit
|
||||
|
||||
- name: install Pillow
|
||||
run: python3 -m pip install --upgrade pillow
|
||||
|
||||
- name: interpreter version
|
||||
run: luajit -v
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
@@ -28,6 +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"
|
||||
# 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), Xbox UWP, and the Anbernic
|
||||
# RG34XXSP (Stock OS 64-bit MOD / PortMaster) port, then publishes them as a
|
||||
# GitHub Release.
|
||||
#
|
||||
# Versioning:
|
||||
# - First ever release is 0.1.0.
|
||||
@@ -42,22 +43,17 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ${{ fromJSON(github.repository == 'bryanthaboi/gen1recomp' && '["self-hosted", "macOS"]' || '"macos-latest"') }}
|
||||
|
||||
version:
|
||||
name: determine release version
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.ver.outputs.version }}
|
||||
tag: ${{ steps.ver.outputs.tag }}
|
||||
steps:
|
||||
# The self-hosted runner lives under the machine owner's home
|
||||
# directory; mask it first so absolute paths in every later step's
|
||||
# output show up as *** in the public workflow logs.
|
||||
- name: Mask runner paths
|
||||
run: echo "::add-mask::$HOME"
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- name: Determine version
|
||||
id: ver
|
||||
env:
|
||||
@@ -65,7 +61,6 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
semver_re='^[0-9]+\.[0-9]+\.[0-9]+$'
|
||||
|
||||
# 1) Explicit override from a manual run.
|
||||
@@ -95,7 +90,6 @@ jobs:
|
||||
| grep -E "$semver_re" \
|
||||
| sort -t. -k1,1n -k2,2n -k3,3n \
|
||||
| tail -1 || true)"
|
||||
|
||||
if [ -z "$latest" ]; then
|
||||
version="0.1.0"
|
||||
echo "No existing release tag; starting at $version"
|
||||
@@ -123,10 +117,157 @@ jobs:
|
||||
echo "::error::Release $tag already exists. Pick a different version."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
|
||||
love-payload:
|
||||
name: build release game.love
|
||||
needs: version
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Build shared payload
|
||||
run: |
|
||||
scripts/pack_love.sh \
|
||||
--output dist/payload/game.love \
|
||||
--listing dist/payload/love-listing.txt \
|
||||
--version "${{ needs.version.outputs.version }}"
|
||||
- name: Upload shared payload
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: gen1recomp-release-love
|
||||
path: dist/payload/game.love
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
linux-arm64:
|
||||
name: build Linux arm64 AppImage
|
||||
needs: [version, love-payload]
|
||||
# GitHub's free arm64 runner for public repos. It has to be arm64: the
|
||||
# AppImage compiles LÖVE natively inside a Debian bullseye arm64
|
||||
# container, and the qemu-emulated alternative takes hours.
|
||||
runs-on: ubuntu-24.04-arm
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Download shared payload
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: gen1recomp-release-love
|
||||
path: .bazinga/work
|
||||
- name: Build Linux arm64 AppImage
|
||||
run: |
|
||||
set -euo pipefail
|
||||
scripts/build_linux_arm64.sh \
|
||||
--version "${{ needs.version.outputs.version }}" \
|
||||
--game-love .bazinga/work/game.love
|
||||
- name: Upload Linux arm64 release
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: gen1recomp-linux-arm64-release
|
||||
path: |
|
||||
dist/linux-arm64/gen1recomp-${{ needs.version.outputs.version }}-linux-arm64.AppImage
|
||||
dist/linux-arm64/gen1recomp-${{ needs.version.outputs.version }}-linux-arm64.AppImage.sha256
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
xbox-uwp:
|
||||
name: build Xbox UWP release
|
||||
needs: [version, love-payload]
|
||||
runs-on: windows-2022
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Download shared payload
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: gen1recomp-release-love
|
||||
path: .bazinga/work
|
||||
- name: Prepare signing certificate
|
||||
shell: pwsh
|
||||
env:
|
||||
CERTIFICATE_BASE64: ${{ secrets.XBOX_UWP_SIGNING_CERTIFICATE }}
|
||||
CERTIFICATE_PASSWORD: ${{ secrets.XBOX_UWP_SIGNING_PASSWORD }}
|
||||
CANONICAL_REPOSITORY: ${{ github.repository == 'bryanthaboi/gen1recomp' }}
|
||||
run: |
|
||||
if ($env:CANONICAL_REPOSITORY -eq 'true' -and
|
||||
[string]::IsNullOrWhiteSpace($env:CERTIFICATE_BASE64)) {
|
||||
throw 'XBOX_UWP_SIGNING_CERTIFICATE is not configured.'
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($env:CERTIFICATE_BASE64)) {
|
||||
"UWP_PUBLISHER=CN=Gen1Recomp" | Out-File $env:GITHUB_ENV -Append
|
||||
exit 0
|
||||
}
|
||||
$pfx = Join-Path $env:RUNNER_TEMP 'gen1recomp-uwp.pfx'
|
||||
[IO.File]::WriteAllBytes($pfx, [Convert]::FromBase64String($env:CERTIFICATE_BASE64))
|
||||
$flags = [Security.Cryptography.X509Certificates.X509KeyStorageFlags]::EphemeralKeySet
|
||||
$cert = [Security.Cryptography.X509Certificates.X509Certificate2]::new(
|
||||
$pfx, $env:CERTIFICATE_PASSWORD, $flags)
|
||||
$cer = Join-Path $env:RUNNER_TEMP 'gen1recomp-uwp.cer'
|
||||
[IO.File]::WriteAllBytes(
|
||||
$cer,
|
||||
$cert.Export([Security.Cryptography.X509Certificates.X509ContentType]::Cert))
|
||||
Import-Certificate -FilePath $cer -CertStoreLocation Cert:\LocalMachine\TrustedPeople | Out-Null
|
||||
"UWP_PFX=$pfx" | Out-File $env:GITHUB_ENV -Append
|
||||
"UWP_CERT_THUMBPRINT=$($cert.Thumbprint)" | Out-File $env:GITHUB_ENV -Append
|
||||
"UWP_PUBLISHER=$($cert.Subject)" | Out-File $env:GITHUB_ENV -Append
|
||||
- name: Build Xbox UWP package
|
||||
shell: bash
|
||||
run: |
|
||||
bash scripts/build_xbox_uwp.sh \
|
||||
--release \
|
||||
--version "${{ needs.version.outputs.version }}" \
|
||||
--publisher "$UWP_PUBLISHER" \
|
||||
--game-love .bazinga/work/game.love
|
||||
- name: Sign and stage Xbox UWP release
|
||||
shell: pwsh
|
||||
env:
|
||||
CERTIFICATE_PASSWORD: ${{ secrets.XBOX_UWP_SIGNING_PASSWORD }}
|
||||
run: |
|
||||
if (-not $env:UWP_PFX) {
|
||||
exit 0
|
||||
}
|
||||
scripts/xbox-uwp/stage_release.ps1 `
|
||||
-Version '${{ needs.version.outputs.version }}' `
|
||||
-Configuration Release `
|
||||
-BuildInfo .bazinga/work/xbox-uwp-build-info.json `
|
||||
-CertificatePath $env:UWP_PFX `
|
||||
-CertificatePassword $env:CERTIFICATE_PASSWORD
|
||||
- name: Upload Xbox UWP release
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: gen1recomp-xbox-uwp-release
|
||||
path: |
|
||||
dist/xbox-uwp/gen1recomp-${{ needs.version.outputs.version }}-xbox-uwp.zip
|
||||
dist/xbox-uwp/gen1recomp-${{ needs.version.outputs.version }}-xbox-uwp.zip.sha256
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
- name: Remove signing certificate
|
||||
if: always()
|
||||
shell: pwsh
|
||||
run: |
|
||||
if ($env:UWP_CERT_THUMBPRINT) {
|
||||
Remove-Item "Cert:\LocalMachine\TrustedPeople\$env:UWP_CERT_THUMBPRINT" -ErrorAction SilentlyContinue
|
||||
}
|
||||
if ($env:UWP_PFX) {
|
||||
Remove-Item $env:UWP_PFX -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
release:
|
||||
needs: [version, xbox-uwp, linux-arm64]
|
||||
runs-on: ${{ fromJSON(github.repository == 'bryanthaboi/gen1recomp' && '["self-hosted", "macOS"]' || '"macos-latest"') }}
|
||||
|
||||
steps:
|
||||
# The self-hosted runner lives under the machine owner's home
|
||||
# directory; mask it first so absolute paths in every later step's
|
||||
# output show up as *** in the public workflow logs.
|
||||
- name: Mask runner paths
|
||||
run: echo "::add-mask::$HOME"
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- name: Import signing certificate into a temporary keychain
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
run: |
|
||||
@@ -171,12 +312,12 @@ jobs:
|
||||
# notarize separately below so it uses secret credentials, not a
|
||||
# login-keychain profile. "all" also builds the Linux AppImage,
|
||||
# which needs no signing/notarization.
|
||||
scripts/build.sh all --version "${{ steps.ver.outputs.version }}" --no-notarize
|
||||
scripts/build.sh all --version "${{ needs.version.outputs.version }}" --no-notarize
|
||||
|
||||
- name: Build Android
|
||||
run: |
|
||||
set -euo pipefail
|
||||
scripts/build_android.sh --version "${{ steps.ver.outputs.version }}"
|
||||
scripts/build_android.sh --version "${{ needs.version.outputs.version }}"
|
||||
|
||||
- name: Install xcbeautify
|
||||
run: |
|
||||
@@ -190,18 +331,29 @@ jobs:
|
||||
set -euo pipefail
|
||||
if [ "$CANONICAL_REPOSITORY" = true ]; then
|
||||
scripts/build_ios.sh --fetch --device --release \
|
||||
--version "${{ steps.ver.outputs.version }}"
|
||||
--version "${{ needs.version.outputs.version }}"
|
||||
else
|
||||
scripts/build_ios.sh --fetch --release \
|
||||
--version "${{ steps.ver.outputs.version }}"
|
||||
--version "${{ needs.version.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 "${{ needs.version.outputs.version }}"
|
||||
|
||||
- name: Build Anbernic RG34XXSP port
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Self-contained aarch64 PortMaster-style pack; pulls the LÖVE 11.5
|
||||
# runtime from PortMaster-GUI, so it needs no signing/notarization.
|
||||
./build-rg34xxsp.sh --version "${{ steps.ver.outputs.version }}"
|
||||
./build-rg34xxsp.sh --version "${{ needs.version.outputs.version }}"
|
||||
|
||||
- name: Notarize & staple macOS app
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
@@ -238,18 +390,41 @@ jobs:
|
||||
ditto -c -k --sequesterRsrc --keepParent "$app" "$zip"
|
||||
echo "Notarized + stapled ✓"
|
||||
|
||||
- name: Download Xbox UWP release
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: gen1recomp-xbox-uwp-release
|
||||
path: dist/xbox-uwp
|
||||
|
||||
- name: Download Linux arm64 release
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: gen1recomp-linux-arm64-release
|
||||
path: dist/linux-arm64
|
||||
|
||||
- name: Stage release assets
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
id: assets
|
||||
run: |
|
||||
set -euo pipefail
|
||||
v="${{ steps.ver.outputs.version }}"
|
||||
v="${{ needs.version.outputs.version }}"
|
||||
outdir="dist/release"
|
||||
rm -rf "$outdir"
|
||||
mkdir -p "$outdir"
|
||||
cp "dist/mac/gen1recomp-macos.zip" "$outdir/gen1recomp-${v}-macos.zip"
|
||||
cp "dist/win/gen1recomp-win64.zip" "$outdir/gen1recomp-${v}-windows.zip"
|
||||
cp "dist/linux/gen1recomp-linux.zip" "$outdir/gen1recomp-${v}-linux.zip"
|
||||
|
||||
# arm64 desktop Linux (Raspberry Pi, Armbian, arm64 VMs). Built on
|
||||
# its own runner because LÖVE publishes no aarch64 binary and the
|
||||
# AppImage has to be compiled natively; ships as a runnable
|
||||
# AppImage rather than a zip so `chmod +x && ./it` just works.
|
||||
arm64_appimage="dist/linux-arm64/gen1recomp-${v}-linux-arm64.AppImage"
|
||||
[ -f "$arm64_appimage" ] || { echo "::error::$arm64_appimage not found (expected from the linux-arm64 job)"; exit 1; }
|
||||
cp "$arm64_appimage" "$outdir/gen1recomp-${v}-linux-arm64.AppImage"
|
||||
chmod +x "$outdir/gen1recomp-${v}-linux-arm64.AppImage"
|
||||
apk="$(find dist/android/debug -name '*.apk' | head -1)"
|
||||
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; }
|
||||
cp "$apk" "$outdir/gen1recomp-${v}-android.apk"
|
||||
@@ -258,6 +433,16 @@ 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.
|
||||
|
||||
uwp="dist/xbox-uwp/gen1recomp-${v}-xbox-uwp.zip"
|
||||
[ -f "$uwp" ] || { echo "::error::$uwp not found (expected from the Xbox UWP job)"; exit 1; }
|
||||
cp "$uwp" "$outdir/gen1recomp-${v}-xbox-uwp.zip"
|
||||
|
||||
# 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"
|
||||
@@ -284,8 +469,8 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
v="${{ steps.ver.outputs.version }}"
|
||||
tag="${{ steps.ver.outputs.tag }}"
|
||||
v="${{ needs.version.outputs.version }}"
|
||||
tag="${{ needs.version.outputs.tag }}"
|
||||
|
||||
# Issues this release closes. Three sources, deduped by number:
|
||||
# 1. GitHub's own "closing issues" links on every PR whose
|
||||
@@ -369,18 +554,25 @@ 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}-linux-arm64.AppImage"
|
||||
"dist/release/gen1recomp-${v}-android.apk"
|
||||
"dist/release/gen1recomp-${v}-ios.ipa"
|
||||
"dist/release/gen1recomp-${v}-switch.zip"
|
||||
"dist/release/gen1recomp-${v}-xbox-uwp.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"
|
||||
|
||||
@@ -388,7 +580,7 @@ jobs:
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
v="${{ steps.ver.outputs.version }}"
|
||||
v="${{ needs.version.outputs.version }}"
|
||||
ipa="dist/release/gen1recomp-${v}-ios.ipa"
|
||||
app_repo="mobile/ios/app-repo.json"
|
||||
[ -f "$ipa" ] || { echo "::error::$ipa not found"; exit 1; }
|
||||
@@ -397,12 +589,18 @@ jobs:
|
||||
date="$(date -u +"%Y-%m-%d")"
|
||||
size="$(wc -c < "$ipa" | tr -d '[:space:]')"
|
||||
download_url="https://github.com/${GITHUB_REPOSITORY}/releases/download/v${v}/gen1recomp-${v}-ios.ipa"
|
||||
localized_description="Gen1Recomp - A native Lua / LÖVE2D recreation of Gen 1 Poke"
|
||||
release_notes="$(GH_TOKEN="${{ github.token }}" gh release view "v${v}" --json body --jq '.body // ""' 2>/dev/null || true)"
|
||||
if [ -n "$release_notes" ]; then
|
||||
localized_description="$release_notes"
|
||||
fi
|
||||
entry="$(jq -n \
|
||||
--arg version "$v" \
|
||||
--arg date "$date" \
|
||||
--arg download_url "$download_url" \
|
||||
--arg localized_description "$localized_description" \
|
||||
--argjson size "$size" \
|
||||
'{version: $version, date: $date, size: $size, downloadURL: $download_url, localizedDescription: "Gen1Recomp - A native Lua / LÖVE2D recreation of Gen 1 Poke"}')"
|
||||
'{version: $version, date: $date, size: $size, downloadURL: $download_url, localizedDescription: $localized_description}')"
|
||||
|
||||
if jq -e --arg version "$v" \
|
||||
'any(.apps[] | select(.bundleIdentifier == "com.theboisclub.gen1recomp").versions[]?; .version == $version)' \
|
||||
|
||||
@@ -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,9 +31,29 @@ 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-build.md; also covered by .*)
|
||||
.bazinga/love-nx/
|
||||
|
||||
# Final packaged build artifacts (mac/win/web/android/ios/switch) — see scripts/build.sh
|
||||
/dist/
|
||||
|
||||
# Switch OTA launcher build outputs
|
||||
ports/switch/ota-launcher/build/
|
||||
ports/switch/ota-launcher/build-host/
|
||||
ports/switch/ota-launcher/*.nro
|
||||
ports/switch/ota-launcher/*.nacp
|
||||
ports/switch/ota-launcher/*.elf
|
||||
ports/switch/ota-launcher/*.map
|
||||
ports/switch/ota-launcher/romfs/logo.rgba
|
||||
ports/switch/ota-launcher/romfs/logo.png
|
||||
ports/switch/ota-launcher/romfs/cacert.pem
|
||||
ports/switch/ota-launcher/romfs/ota-bootstrap.nro
|
||||
ports/switch/ota-bootstrap/build/
|
||||
ports/switch/ota-bootstrap/*.nro
|
||||
ports/switch/ota-bootstrap/*.nacp
|
||||
ports/switch/ota-bootstrap/*.elf
|
||||
ports/switch/ota-bootstrap/*.map
|
||||
|
||||
# Legacy manual convenience-copy location (superseded by /dist/android/)
|
||||
mobile/dist/
|
||||
|
||||
@@ -43,3 +63,8 @@ mobile/dist/
|
||||
|
||||
# per-machine iOS bundle-id pin (see scripts/build_ios.sh)
|
||||
mobile/ios/bundle_id.local
|
||||
|
||||
# Xbox UWP build output
|
||||
/ports/uwp/build/
|
||||
/ports/uwp/third_party/*/source/
|
||||
/ports/uwp/third_party/angle/depot_tools/
|
||||
|
||||
@@ -112,6 +112,7 @@ supported out of the box.
|
||||
| Key | What it does |
|
||||
| --------- | ---------------------------------------------------- |
|
||||
| `-` / `=` | Zoom out / in (overworld; also mouse wheel) |
|
||||
| `1` | Cycle GAME SPEED up (controller: R2 faster, L2 slower) |
|
||||
| `2` | Cycle COLORS |
|
||||
| `3` | Cycle TILT (free-roam overworld) |
|
||||
| `4` | Cycle ZOOM through every level (free-roam overworld) |
|
||||
@@ -121,8 +122,8 @@ supported out of the box.
|
||||
| `F10` | Open / close the mod manager |
|
||||
|
||||
|
||||
COLORS, TILT, ZOOM, GBC FX, and VOID FILL are also in the Options menu
|
||||
and persist in `options.lua`.
|
||||
COLORS, TILT, ZOOM, GBC FX, GAME SPEED, and VOID FILL are also in the
|
||||
Options menu and persist in `options.lua`.
|
||||
|
||||
### Low-end devices
|
||||
|
||||
@@ -204,6 +205,39 @@ even on a different computer, as long as the same folder comes along.
|
||||
already written to either location is touched automatically, so copy files
|
||||
over yourself if you want to carry existing progress across the switch.
|
||||
|
||||
## Launch Options
|
||||
|
||||
By default the app opens the launcher so you can pick a game. Launch options
|
||||
skip it and start one game directly, which is what you want for a one-click
|
||||
entry: a desktop shortcut per game, a Steam entry, or a handheld frontend.
|
||||
|
||||
| Option | Effect |
|
||||
| --- | --- |
|
||||
| `--game=red` | boot Red, skipping the launcher (`blue` and `yellow` too, or just `r` / `b` / `y`) |
|
||||
| `--slot=2` | load that save slot; takes a slot number or a slot id |
|
||||
| `--launcher` | open the launcher anyway, so you can edit a shortcut you already made |
|
||||
|
||||
|
||||
## Linux on arm64 (Raspberry Pi)
|
||||
|
||||
Alongside the x86_64 `gen1recomp-*-linux.zip`, every release ships
|
||||
`gen1recomp-*-linux-arm64.AppImage` for 64-bit ARM desktop Linux — Raspberry
|
||||
Pi 4/5, Armbian and other SBC distros, and arm64 VMs on Apple Silicon:
|
||||
|
||||
```sh
|
||||
chmod +x gen1recomp-*-linux-arm64.AppImage
|
||||
./gen1recomp-*-linux-arm64.AppImage
|
||||
```
|
||||
|
||||
LÖVE publishes no aarch64 binary of any kind, so this artifact compiles the
|
||||
engine — and SDL2, OpenAL and the codecs — from source inside a Debian
|
||||
bullseye arm64 container. It needs only glibc 2.29+, libstdc++, freetype and
|
||||
zlib on the host; OpenGL, X11, Wayland, KMSDRM, ALSA and PulseAudio are all
|
||||
dlopened, so the same image runs on a full desktop, a Wayland-only session or
|
||||
a KMSDRM handheld with no X server. Build instructions and the reasoning are
|
||||
in [docs/linux-arm64-build.md](docs/linux-arm64-build.md).
|
||||
|
||||
|
||||
## iOS
|
||||
|
||||
Every release ships `gen1recomp-*-ios.ipa`. Sideload it with AltStore
|
||||
@@ -221,6 +255,40 @@ build and install from source on a Mac instead, see
|
||||
<a href="https://github.com/bryanthaboi/gen1recomp/releases/latest"><img src="./.github/resources/github-badge.png" alt="Download from GitHub" height="60"></a>
|
||||
</div>
|
||||
|
||||
## Xbox Dev Mode
|
||||
|
||||
Every release ships `gen1recomp-*-xbox-uwp.zip` for Xbox One and Xbox Series
|
||||
consoles in Developer Mode. It cannot be installed in retail mode.
|
||||
|
||||
Extract the archive, then use Xbox Device Portal to install the `.msix` and
|
||||
the x64 package under `Dependencies`.
|
||||
|
||||
### External setup
|
||||
|
||||
1. Put your legally obtained Red, Blue, or Yellow ROMs on an external drive.
|
||||
Mod ZIPs can go on the same drive.
|
||||
2. Connect the drive to the Xbox and open Gen1Recomp.
|
||||
3. Select **Import ROM** or **Import Mod**, then choose the file with the Xbox
|
||||
file picker.
|
||||
4. Repeat the ROM import for each version you want to use.
|
||||
|
||||
### Internal setup
|
||||
|
||||
1. Create a folder named `baseroms` on your PC and place your legally obtained
|
||||
Red, Blue, or Yellow ROMs inside it.
|
||||
2. ZIP the folder, keeping `baseroms` at the top level of the archive.
|
||||
3. Launch Gen1Recomp once, then close it.
|
||||
4. Open Xbox Device Portal and upload the ZIP to
|
||||
`Gen1Recomp/LocalState/pokemon-love2d/`.
|
||||
5. Choose **Yes** when Device Portal asks whether to extract the archive.
|
||||
6. Open Gen1Recomp. The launcher checks baseroms once at startup. When it finds a compatible ROM, that game’s tab shows ROM FOUND and an Import detected ROM button.
|
||||
|
||||
ROMs, generated game data, saves, and mods remain in LocalState and are not
|
||||
included in the app.
|
||||
|
||||
Source builds and package details are covered in
|
||||
[the Xbox UWP build notes](ports/uwp/BUILD.md).
|
||||
|
||||
## Handhelds
|
||||
|
||||
A PortMaster-style port for the **Anbernic RG34XXSP** on Stock OS 64-bit MOD
|
||||
@@ -228,6 +296,21 @@ 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`. Runtime target is pinned
|
||||
[love-nx](https://github.com/retronx-team/love-nx) `11.5-nx1`. Requires a
|
||||
console that can run Switch homebrew.
|
||||
|
||||
- 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, fused PR artifact on the main repo, release
|
||||
hard-fail).
|
||||
- File transfer (MTP / SD / FTP): [docs/switch-transfer.md](docs/switch-transfer.md).
|
||||
|
||||
## Modding
|
||||
|
||||
The game ships a native mod platform: content registries, events and hooks,
|
||||
|
||||
@@ -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: 2.4 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 18 KiB |
@@ -89,7 +89,7 @@ mkdir -p "$GAME_SRC"
|
||||
# tools/save-editor is part of that payload: the launcher's Edit button on a
|
||||
# save row opens it in-process (main.lua).
|
||||
(cd "$ROOT" && zip -q -9 -r "$WORK/game-payload.zip" \
|
||||
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 \
|
||||
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
||||
if unzip -Z1 "$WORK/game-payload.zip" \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -14,9 +14,9 @@ return {
|
||||
-- else -> .WhatsLostIsLostText (player has TM_DIG)
|
||||
TEXT_CERULEANTRASHEDHOUSE_FISHING_GURU = {
|
||||
{ "check_item", "TM_DIG" },
|
||||
{ "jump_if_true", 4 },
|
||||
{ "jump_if_true", 5 },
|
||||
{ "show_text", "_CeruleanTrashedHouseFishingGuruTheyStoleATMText" },
|
||||
{ "jump", 5 },
|
||||
{ "jump", "end" },
|
||||
{ "show_text", "_CeruleanTrashedHouseFishingGuruWhatsLostIsLostText" },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
-- Fuchsia City (pokered/scripts/FuchsiaCity.asm)
|
||||
--
|
||||
-- The exhibit signs are text_asm bodies: PrintText of a single text_far,
|
||||
-- then DisplayPokedex for the exhibited species. The preview marks the
|
||||
-- species seen but not owned, same as the S.S. Anne passenger's Snorlax.
|
||||
--
|
||||
-- The fossil sign branches on the Mt. Moon fossil events. The exhibit
|
||||
-- holds the fossil the player did NOT take: taking the Dome Fossil puts
|
||||
-- Omanyte on display, taking the Helix Fossil puts Kabuto. With neither
|
||||
-- event set the sign only prints its undetermined line and no dex entry
|
||||
-- opens.
|
||||
--
|
||||
-- The other text pointers (city sign, Safari Game signs, mart/center/gym
|
||||
-- and warden signs, the four NPCs, the exhibited-mon FuchsiaCityPokemonText
|
||||
-- rows) are plain text_far wrappers that resolve through Data:resolveText,
|
||||
-- so they are not ported here.
|
||||
return {
|
||||
FUCHSIA_CITY = {
|
||||
talk = {
|
||||
-- FuchsiaCityChanseySignText: PrintText(_FuchsiaCityChanseySignText),
|
||||
-- then DisplayPokedex CHANSEY.
|
||||
TEXT_FUCHSIACITY_CHANSEY_SIGN = {
|
||||
{ "show_text", "_FuchsiaCityChanseySignText" },
|
||||
{ "mark_seen", "CHANSEY" },
|
||||
{ "push_screen", "DexEntryMenu", "CHANSEY" },
|
||||
},
|
||||
|
||||
-- FuchsiaCityVoltorbSignText: PrintText(_FuchsiaCityVoltorbSignText),
|
||||
-- then DisplayPokedex VOLTORB.
|
||||
TEXT_FUCHSIACITY_VOLTORB_SIGN = {
|
||||
{ "show_text", "_FuchsiaCityVoltorbSignText" },
|
||||
{ "mark_seen", "VOLTORB" },
|
||||
{ "push_screen", "DexEntryMenu", "VOLTORB" },
|
||||
},
|
||||
|
||||
-- FuchsiaCityKangaskhanSignText: PrintText(_FuchsiaCityKangaskhanSignText),
|
||||
-- then DisplayPokedex KANGASKHAN.
|
||||
TEXT_FUCHSIACITY_KANGASKHAN_SIGN = {
|
||||
{ "show_text", "_FuchsiaCityKangaskhanSignText" },
|
||||
{ "mark_seen", "KANGASKHAN" },
|
||||
{ "push_screen", "DexEntryMenu", "KANGASKHAN" },
|
||||
},
|
||||
|
||||
-- FuchsiaCitySlowpokeSignText: PrintText(_FuchsiaCitySlowpokeSignText),
|
||||
-- then DisplayPokedex SLOWPOKE.
|
||||
TEXT_FUCHSIACITY_SLOWPOKE_SIGN = {
|
||||
{ "show_text", "_FuchsiaCitySlowpokeSignText" },
|
||||
{ "mark_seen", "SLOWPOKE" },
|
||||
{ "push_screen", "DexEntryMenu", "SLOWPOKE" },
|
||||
},
|
||||
|
||||
-- FuchsiaCityLaprasSignText: PrintText(_FuchsiaCityLaprasSignText),
|
||||
-- then DisplayPokedex LAPRAS.
|
||||
TEXT_FUCHSIACITY_LAPRAS_SIGN = {
|
||||
{ "show_text", "_FuchsiaCityLaprasSignText" },
|
||||
{ "mark_seen", "LAPRAS" },
|
||||
{ "push_screen", "DexEntryMenu", "LAPRAS" },
|
||||
},
|
||||
|
||||
-- FuchsiaCityFossilSignText: CheckEvent EVENT_GOT_DOME_FOSSIL /
|
||||
-- CheckEventReuseA EVENT_GOT_HELIX_FOSSIL pick the text and the
|
||||
-- displayed entry; with neither set only the undetermined line prints.
|
||||
TEXT_FUCHSIACITY_FOSSIL_SIGN = {
|
||||
{ "check_flag", "EVENT_GOT_DOME_FOSSIL" }, -- 1
|
||||
{ "jump_if_true", 7 }, -- 2
|
||||
{ "check_flag", "EVENT_GOT_HELIX_FOSSIL" }, -- 3
|
||||
{ "jump_if_true", 11 }, -- 4
|
||||
{ "show_text", "_FuchsiaCityFossilSignUndeterminedText" }, -- 5
|
||||
{ "jump", "end" }, -- 6
|
||||
{ "show_text", "_FuchsiaCityFossilSignOmanyteText" }, -- 7
|
||||
{ "mark_seen", "OMANYTE" }, -- 8
|
||||
{ "push_screen", "DexEntryMenu", "OMANYTE" }, -- 9
|
||||
{ "jump", "end" }, -- 10
|
||||
{ "show_text", "_FuchsiaCityFossilSignKabutoText" }, -- 11
|
||||
{ "mark_seen", "KABUTO" }, -- 12
|
||||
{ "push_screen", "DexEntryMenu", "KABUTO" }, -- 13
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -8,9 +8,9 @@ return {
|
||||
TEXT_LAVENDERMART_COOLTRAINER_M = {
|
||||
{ "face_player" },
|
||||
{ "check_flag", "EVENT_RESCUED_MR_FUJI" },
|
||||
{ "jump_if_true", 5 },
|
||||
{ "jump_if_true", 6 },
|
||||
{ "show_text", "_LavenderMartCooltrainerMReviveText" },
|
||||
{ "jump", 6 },
|
||||
{ "jump", "end" },
|
||||
{ "show_text", "_LavenderMartCooltrainerMNuggetText" },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -13,9 +13,9 @@ M.MR_FUJIS_HOUSE = {
|
||||
TEXT_MRFUJISHOUSE_SUPER_NERD = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_RESCUED_MR_FUJI" }, -- 2
|
||||
{ "jump_if_true", 5 }, -- 3
|
||||
{ "jump_if_true", 6 }, -- 3
|
||||
{ "show_text", "_MrFujisHouseSuperNerdMrFujiIsntHereText" }, -- 4
|
||||
{ "jump", 6 }, -- 5
|
||||
{ "jump", "end" }, -- 5
|
||||
{ "show_text", "_MrFujisHouseSuperNerdMrFujiHadBeenPrayingText" }, -- 6
|
||||
},
|
||||
|
||||
@@ -25,9 +25,9 @@ M.MR_FUJIS_HOUSE = {
|
||||
TEXT_MRFUJISHOUSE_LITTLE_GIRL = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_RESCUED_MR_FUJI" }, -- 2
|
||||
{ "jump_if_true", 5 }, -- 3
|
||||
{ "jump_if_true", 6 }, -- 3
|
||||
{ "show_text", "_MrFujisHouseLittleGirlThisIsMrFujisHouseText" }, -- 4
|
||||
{ "jump", 6 }, -- 5
|
||||
{ "jump", "end" }, -- 5
|
||||
{ "show_text", "_MrFujisHouseLittleGirlPokemonAreNiceToHugText" }, -- 6
|
||||
},
|
||||
|
||||
|
||||
@@ -16,9 +16,13 @@ local function push(game, s, done)
|
||||
game.stack:push(TextBox.new(game, s, done))
|
||||
end
|
||||
|
||||
-- PrintText on a text_end string returns with the box still drawn and
|
||||
-- YesNoChoice then draws the menu above it (InitYesNoTextBoxParameters,
|
||||
-- engine/menus/text_box.asm); no A press clears the question first. Ride
|
||||
-- TextBox's opts.choice, the same as Commands.ask (#854).
|
||||
local function ask(game, s, cb)
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
push(game, s, function() game.stack:push(ChoiceBox.new(game, cb)) end)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game, s, nil, { choice = cb }))
|
||||
end
|
||||
|
||||
M.PEWTER_CITY = {
|
||||
|
||||
@@ -12,9 +12,9 @@ return {
|
||||
TEXT_ROUTE16GATE1F_GUARD = {
|
||||
{ "face_player" },
|
||||
{ "check_item", "BICYCLE" },
|
||||
{ "jump_if_true", 5 },
|
||||
{ "jump_if_true", 6 },
|
||||
{ "show_text", "_Route16Gate1FGuardNoPedestriansAllowedText" },
|
||||
{ "jump", 6 },
|
||||
{ "jump", "end" },
|
||||
{ "show_text", "_Route16Gate1FGuardCyclingRoadExplanationText" },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -14,9 +14,9 @@ return {
|
||||
TEXT_ROUTE18GATE1F_GUARD = {
|
||||
{ "face_player" },
|
||||
{ "check_item", "BICYCLE" },
|
||||
{ "jump_if_true", 5 },
|
||||
{ "jump_if_true", 6 },
|
||||
{ "show_text", "_Route18Gate1FGuardYouNeedABicycleText" },
|
||||
{ "jump", 6 },
|
||||
{ "jump", "end" },
|
||||
{ "show_text", "_Route18Gate1FGuardCyclingRoadUphillText" },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -12,9 +12,9 @@ return {
|
||||
TEXT_SILPHCO10F_SILPH_WORKER_F = {
|
||||
{ "face_player" },
|
||||
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
|
||||
{ "jump_if_true", 5 },
|
||||
{ "jump_if_true", 6 },
|
||||
{ "show_text", "_SilphCo10FSilphWorkerFImScaredText" },
|
||||
{ "jump", 6 },
|
||||
{ "jump", "end" },
|
||||
{ "show_text", "_SilphCo10FSilphWorkerFQuietAboutMyCryingText" },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -10,9 +10,9 @@ return {
|
||||
-- not set: _SilphCo3FSilphWorkerMWhatShouldIDoText
|
||||
TEXT_SILPHCO3F_SILPH_WORKER_M = {
|
||||
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
|
||||
{ "jump_if_true", 4 },
|
||||
{ "jump_if_true", 5 },
|
||||
{ "show_text", "_SilphCo3FSilphWorkerMWhatShouldIDoText" },
|
||||
{ "jump", 5 },
|
||||
{ "jump", "end" },
|
||||
{ "show_text", "_SilphCo3FSilphWorkerMYouSavedUsText" },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -7,9 +7,9 @@ return {
|
||||
TEXT_SILPHCO4F_SILPH_WORKER_M = {
|
||||
{"face_player"},
|
||||
{"check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI"},
|
||||
{"jump_if_true", 5},
|
||||
{"jump_if_true", 6},
|
||||
{"show_text", "_SilphCo4FSilphWorkerMImHidingText"},
|
||||
{"jump", 6},
|
||||
{"jump", "end"},
|
||||
{"show_text", "_SilphCo4FSilphWorkerMTeamRocketIsGoneText"},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -7,9 +7,9 @@ return {
|
||||
TEXT_SILPHCO5F_SILPH_WORKER_M = {
|
||||
{"face_player"},
|
||||
{"check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI"},
|
||||
{"jump_if_true", 5},
|
||||
{"jump_if_true", 6},
|
||||
{"show_text", "_SilphCo5FSilphWorkerMThatsYouRightText"},
|
||||
{"jump", 6},
|
||||
{"jump", "end"},
|
||||
{"show_text", "_SilphCo5FSilphWorkerMYoureOurHeroText"},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -11,9 +11,9 @@ return {
|
||||
TEXT_SILPHCO6F_SILPH_WORKER_M1 = {
|
||||
{ "face_player" },
|
||||
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
|
||||
{ "jump_if_true", 5 },
|
||||
{ "jump_if_true", 6 },
|
||||
{ "show_text", "_SilphCo6FSilphWorkerM1TookOverTheBuildingText" },
|
||||
{ "jump", 6 },
|
||||
{ "jump", "end" },
|
||||
{ "show_text", "_SilphCo6FSilphWorkerM1BackToWorkText" },
|
||||
},
|
||||
|
||||
@@ -21,9 +21,9 @@ return {
|
||||
TEXT_SILPHCO6F_SILPH_WORKER_M2 = {
|
||||
{ "face_player" },
|
||||
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
|
||||
{ "jump_if_true", 5 },
|
||||
{ "jump_if_true", 6 },
|
||||
{ "show_text", "_SilphCo6FSilphWorkerMHelpMePleaseText" },
|
||||
{ "jump", 6 },
|
||||
{ "jump", "end" },
|
||||
{ "show_text", "_SilphCo6FSilphWorkerMWeGotEngagedText" },
|
||||
},
|
||||
|
||||
@@ -31,9 +31,9 @@ return {
|
||||
TEXT_SILPHCO6F_SILPH_WORKER_F1 = {
|
||||
{ "face_player" },
|
||||
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
|
||||
{ "jump_if_true", 5 },
|
||||
{ "jump_if_true", 6 },
|
||||
{ "show_text", "_SilphCo6FSilphWorkerF1SuchACowardText" },
|
||||
{ "jump", 6 },
|
||||
{ "jump", "end" },
|
||||
{ "show_text", "_SilphCo6FSilphWorkerF1HaveToMarryHimText" },
|
||||
},
|
||||
|
||||
@@ -41,9 +41,9 @@ return {
|
||||
TEXT_SILPHCO6F_SILPH_WORKER_F2 = {
|
||||
{ "face_player" },
|
||||
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
|
||||
{ "jump_if_true", 5 },
|
||||
{ "jump_if_true", 6 },
|
||||
{ "show_text", "_SilphCo6FSilphWorkerF2TeamRocketConquerWorldText" },
|
||||
{ "jump", 6 },
|
||||
{ "jump", "end" },
|
||||
{ "show_text", "_SilphCo6FSilphWorkerF2TeamRocketRanText" },
|
||||
},
|
||||
|
||||
@@ -51,9 +51,9 @@ return {
|
||||
TEXT_SILPHCO6F_SILPH_WORKER_M3 = {
|
||||
{ "face_player" },
|
||||
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
|
||||
{ "jump_if_true", 5 },
|
||||
{ "jump_if_true", 6 },
|
||||
{ "show_text", "_SilphCo6FSilphWorkerM3TargetedSilphText" },
|
||||
{ "jump", 6 },
|
||||
{ "jump", "end" },
|
||||
{ "show_text", "_SilphCo6FSilphWorkerM3WorkForSilphText" },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -10,9 +10,9 @@ return {
|
||||
-- set: _SilphCo7FSilphWorkerM2CancelledMasterBallText
|
||||
TEXT_SILPHCO7F_SILPH_WORKER_M2 = {
|
||||
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
|
||||
{ "jump_if_true", 4 },
|
||||
{ "jump_if_true", 5 },
|
||||
{ "show_text", "_SilphCo7FSilphWorkerM2AfterTheMasterBallText" },
|
||||
{ "jump", 5 },
|
||||
{ "jump", "end" },
|
||||
{ "show_text", "_SilphCo7FSilphWorkerM2CancelledMasterBallText" },
|
||||
},
|
||||
|
||||
@@ -22,9 +22,9 @@ return {
|
||||
-- set: _SilphCo7FSilphWorkerM3YouChasedOffTeamRocketText
|
||||
TEXT_SILPHCO7F_SILPH_WORKER_M3 = {
|
||||
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
|
||||
{ "jump_if_true", 4 },
|
||||
{ "jump_if_true", 5 },
|
||||
{ "show_text", "_SilphCo7FSilphWorkerM3ItWouldBeBadText" },
|
||||
{ "jump", 5 },
|
||||
{ "jump", "end" },
|
||||
{ "show_text", "_SilphCo7FSilphWorkerM3YouChasedOffTeamRocketText" },
|
||||
},
|
||||
|
||||
@@ -34,9 +34,9 @@ return {
|
||||
-- set: _SilphCo7FSilphWorkerM4SafeAtLastText
|
||||
TEXT_SILPHCO7F_SILPH_WORKER_M4 = {
|
||||
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
|
||||
{ "jump_if_true", 4 },
|
||||
{ "jump_if_true", 5 },
|
||||
{ "show_text", "_SilphCo7FSilphWorkerM4ItsReallyDangerousHereText" },
|
||||
{ "jump", 5 },
|
||||
{ "jump", "end" },
|
||||
{ "show_text", "_SilphCo7FSilphWorkerM4SafeAtLastText" },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -10,9 +10,9 @@ return {
|
||||
-- set: _SilphCo8FSilphWorkerMThanksForSavingUsText
|
||||
TEXT_SILPHCO8F_SILPH_WORKER_M = {
|
||||
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
|
||||
{ "jump_if_true", 4 },
|
||||
{ "jump_if_true", 5 },
|
||||
{ "show_text", "_SilphCo8FSilphWorkerMSilphIsFinishedText" },
|
||||
{ "jump", 5 },
|
||||
{ "jump", "end" },
|
||||
{ "show_text", "_SilphCo8FSilphWorkerMThanksForSavingUsText" },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -23,9 +23,13 @@ local function push(game, s, done)
|
||||
game.stack:push(TextBox.new(game, s, done))
|
||||
end
|
||||
|
||||
-- PrintText on a text_end string returns with the box still drawn and
|
||||
-- YesNoChoice then draws the menu above it (InitYesNoTextBoxParameters,
|
||||
-- engine/menus/text_box.asm); no A press clears the question first. Ride
|
||||
-- TextBox's opts.choice, the same as Commands.ask (#854).
|
||||
local function ask(game, s, cb)
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
push(game, s, function() game.stack:push(ChoiceBox.new(game, cb)) end)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game, s, nil, { choice = cb }))
|
||||
end
|
||||
|
||||
M.VIRIDIAN_CITY = {
|
||||
|
||||
@@ -15,6 +15,7 @@ local files = {
|
||||
"data.scripts.flavor.cerulean_trashed_house",
|
||||
"data.scripts.flavor.copycats_house_1f",
|
||||
"data.scripts.flavor.copycats_house_2f",
|
||||
"data.scripts.flavor.fuchsia_city",
|
||||
"data.scripts.flavor.game_corner",
|
||||
"data.scripts.flavor.lavender_cubone_house",
|
||||
"data.scripts.flavor.lavender_mart",
|
||||
|
||||
@@ -18,6 +18,26 @@ local M = {
|
||||
VIRIDIAN_GYM = { city = "VIRIDIAN CITY", leader = "GIOVANNI", badge = "EARTHBADGE" },
|
||||
}
|
||||
|
||||
-- The originals' middle branch shared by every leader's text_asm: beaten
|
||||
-- but EVENT_GOT_TM* unset means the bag was full when the victory script
|
||||
-- ran GiveItem, so talking to the leader re-runs the ReceiveTM script.
|
||||
-- Returns true when the retry took over the talk. A save from before
|
||||
-- #797 already holds the TM without the flag; treat the owned TM as
|
||||
-- received so those saves fall through to the advice text instead of
|
||||
-- collecting a second copy.
|
||||
local function retryTmGive(game, ow, victoryKey, done)
|
||||
local reward = require("data.scripts.victories")[victoryKey]
|
||||
if not (reward and reward.gotFlag) then return false end
|
||||
if game.save.flags[reward.gotFlag] then return false end
|
||||
local owned = game.save.inventory and game.save.inventory[reward.item] or 0
|
||||
if owned > 0 then
|
||||
game.save.flags[reward.gotFlag] = true
|
||||
return false
|
||||
end
|
||||
ow:offerGymTm(reward, done)
|
||||
return true
|
||||
end
|
||||
|
||||
-- scripts/PewterGym.asm PewterGymBrockText (text_asm): CheckEvent
|
||||
-- EVENT_BEAT_BROCK branches his dialogue. Before the badge he prints
|
||||
-- _PewterGymBrockPreBattleText and engages the leader battle
|
||||
@@ -25,12 +45,14 @@ local M = {
|
||||
-- badge/TM34 rewards and EVENT_BEAT_BROCK come from
|
||||
-- data/scripts/victories.lua OPP_BROCK#1). After the badge his
|
||||
-- .afterBeat branch prints _PewterGymBrockPostBattleAdviceText ("Go to
|
||||
-- the GYM in CERULEAN..."). The original's middle branch (beat but
|
||||
-- TM34 not yet handed over, CheckEventReuseA EVENT_GOT_TM34) is
|
||||
-- unreachable in the port: the TM is granted with the victory.
|
||||
-- the GYM in CERULEAN..."). The middle branch (beat but TM34 not yet
|
||||
-- handed over, CheckEventReuseA EVENT_GOT_TM34 -> call
|
||||
-- PewterGymScriptReceiveTM34) retries the TM give when the bag was full
|
||||
-- at the victory (#797).
|
||||
M.PEWTER_GYM.talk = {
|
||||
TEXT_PEWTERGYM_BROCK = function(game, ow, npc, done)
|
||||
if game.save.flags.EVENT_BEAT_BROCK then
|
||||
if retryTmGive(game, ow, "OPP_BROCK#1", done) then return end
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game,
|
||||
game.data.text._PewterGymBrockPostBattleAdviceText
|
||||
@@ -48,16 +70,17 @@ M.PEWTER_GYM.talk = {
|
||||
-- (engageTrainer shows that same pre-battle text via resolveText; the
|
||||
-- badge/TM rewards and the beat flag come from data/scripts/victories.lua)
|
||||
-- -- and once beaten print the post-battle advice text. As with Brock,
|
||||
-- the originals' middle branch (beaten but the TM not yet handed over,
|
||||
-- CheckEventReuseA EVENT_GOT_TM*) is unreachable in the port: the TM is
|
||||
-- granted with the victory.
|
||||
-- the middle branch (beaten but the TM not yet handed over,
|
||||
-- CheckEventReuseA EVENT_GOT_TM*) retries the TM give when the bag was
|
||||
-- full at the victory.
|
||||
-- afterAdvice, when given, takes over `done`: it is handed (game, ow, npc,
|
||||
-- done) and must call done() itself once whatever it's doing (e.g. a fade
|
||||
-- around a HideObject) finishes, rather than having it invoked
|
||||
-- automatically. Only Giovanni's farewell uses this.
|
||||
local function leaderTalk(beatFlag, adviceLabel, fallback, afterAdvice)
|
||||
local function leaderTalk(beatFlag, adviceLabel, fallback, afterAdvice, victoryKey)
|
||||
return function(game, ow, npc, done)
|
||||
if game.save.flags[beatFlag] then
|
||||
if victoryKey and retryTmGive(game, ow, victoryKey, done) then return end
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local finish = done
|
||||
if afterAdvice then
|
||||
@@ -79,42 +102,42 @@ end
|
||||
M.CERULEAN_GYM.talk = {
|
||||
TEXT_CERULEANGYM_MISTY = leaderTalk("EVENT_BEAT_MISTY",
|
||||
"_CeruleanGymMistyTM11ExplanationText",
|
||||
"TM11 teaches\nBUBBLEBEAM!"),
|
||||
"TM11 teaches\nBUBBLEBEAM!", nil, "OPP_MISTY#1"),
|
||||
}
|
||||
|
||||
-- scripts/VermilionGym.asm VermilionGymLTSurgeText .got_tm24_already
|
||||
M.VERMILION_GYM.talk = {
|
||||
TEXT_VERMILIONGYM_LT_SURGE = leaderTalk("EVENT_BEAT_LT_SURGE",
|
||||
"_VermilionGymLTSurgePostBattleAdviceText",
|
||||
"A little word of\nadvice, kid!"),
|
||||
"A little word of\nadvice, kid!", nil, "OPP_LT_SURGE#1"),
|
||||
}
|
||||
|
||||
-- scripts/CeladonGym.asm CeladonGymErikaText .afterBeat
|
||||
M.CELADON_GYM.talk = {
|
||||
TEXT_CELADONGYM_ERIKA = leaderTalk("EVENT_BEAT_ERIKA",
|
||||
"_CeladonGymErikaPostBattleAdviceText",
|
||||
"You are cataloging\nPOKéMON? I must\nsay I'm impressed."),
|
||||
"You are cataloging\nPOKéMON? I must\nsay I'm impressed.", nil, "OPP_ERIKA#1"),
|
||||
}
|
||||
|
||||
-- scripts/FuchsiaGym.asm FuchsiaGymKogaText .afterBeat
|
||||
M.FUCHSIA_GYM.talk = {
|
||||
TEXT_FUCHSIAGYM_KOGA = leaderTalk("EVENT_BEAT_KOGA",
|
||||
"_FuchsiaGymKogaPostBattleAdviceText",
|
||||
"When afflicted by\nTOXIC, POKéMON\nsuffer more and\nmore as battle\nprogresses!"),
|
||||
"When afflicted by\nTOXIC, POKéMON\nsuffer more and\nmore as battle\nprogresses!", nil, "OPP_KOGA#1"),
|
||||
}
|
||||
|
||||
-- scripts/SaffronGym.asm SaffronGymSabrinaText .afterBeat
|
||||
M.SAFFRON_GYM.talk = {
|
||||
TEXT_SAFFRONGYM_SABRINA = leaderTalk("EVENT_BEAT_SABRINA",
|
||||
"_SaffronGymSabrinaPostBattleAdviceText",
|
||||
"Everyone has\npsychic power!\nPeople just don't\nrealize it!"),
|
||||
"Everyone has\npsychic power!\nPeople just don't\nrealize it!", nil, "OPP_SABRINA#1"),
|
||||
}
|
||||
|
||||
-- scripts/CinnabarGym.asm CinnabarGymBlaineText .afterBeat
|
||||
M.CINNABAR_GYM.talk = {
|
||||
TEXT_CINNABARGYM_BLAINE = leaderTalk("EVENT_BEAT_BLAINE",
|
||||
"_CinnabarGymBlainePostBattleAdviceText",
|
||||
"FIRE BLAST is the\nultimate fire\ntechnique!"),
|
||||
"FIRE BLAST is the\nultimate fire\ntechnique!", nil, "OPP_BLAINE#1"),
|
||||
}
|
||||
|
||||
-- scripts/ViridianGym.asm ViridianGymGiovanniText .afterBeat: after the
|
||||
@@ -142,7 +165,7 @@ M.VIRIDIAN_GYM.talk = {
|
||||
"VIRIDIAN_GYM", "VIRIDIANGYM_GIOVANNI")
|
||||
end
|
||||
end, done))
|
||||
end),
|
||||
end, "OPP_GIOVANNI#3"),
|
||||
}
|
||||
|
||||
return M
|
||||
|
||||
@@ -22,10 +22,10 @@ local function starterBall(askText, species, choseFlag, ownBall,
|
||||
rivalBallX, rivalBall)
|
||||
return {
|
||||
{ "check_flag", "EVENT_GOT_STARTER" }, -- 1
|
||||
{ "jump_if_true", 20 }, -- 2
|
||||
{ "jump_if_true", 22 }, -- 2
|
||||
-- no picking until Oak has walked you in (OaksLabScript gating)
|
||||
{ "check_flag", "EVENT_FOLLOWED_OAK_INTO_LAB" }, -- 3
|
||||
{ "jump_if_false", 23 }, -- 4
|
||||
{ "jump_if_false", 25 }, -- 4
|
||||
-- the Pokédex "new species" entry shows before the ask (predef
|
||||
-- StarterDex ahead of OaksLabYouWant...Text). StarterDex temporarily
|
||||
-- sets the owned bits so ShowPokedexData prints height/weight/text;
|
||||
@@ -37,36 +37,41 @@ local function starterBall(askText, species, choseFlag, ownBall,
|
||||
-- OaksLab.asm prints ReceivedMon then AddPartyMon (AskName lives
|
||||
-- inside give_pokemon). Show the received text first so the
|
||||
-- nickname prompt follows "you got X", matching Gen1.
|
||||
{ "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 8
|
||||
{ "give_pokemon", species, 5 }, -- 9
|
||||
{ "set_flag", "EVENT_GOT_STARTER" }, -- 10
|
||||
{ "set_flag", choseFlag }, -- 11
|
||||
-- The received text carries sound_get_key_item (OaksLab.asm
|
||||
-- OaksLabReceivedMonText); the jingle plays as the box opens
|
||||
-- (same beat as the Yellow port's starter, #668).
|
||||
{ "play_sound", "Get_Key_Item" }, -- 8
|
||||
{ "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 9
|
||||
{ "give_pokemon", species, 5 }, -- 10
|
||||
{ "set_flag", "EVENT_GOT_STARTER" }, -- 11
|
||||
{ "set_flag", choseFlag }, -- 12
|
||||
-- POKé BALLs are not handed out here in the original -- Oak gives
|
||||
-- them later, at OaksLabOak1Text's .give_poke_balls beat once the
|
||||
-- player has beaten the Route 22 rival (see TEXT_OAKSLAB_OAK1 below)
|
||||
{ "hide_object", "OAKS_LAB", ownBall }, -- 12
|
||||
{ "hide_object", "OAKS_LAB", ownBall }, -- 13
|
||||
-- the rival walks to the countering ball (around the furniture)
|
||||
{ "move_npc_to", 1, rivalBallX, 4 }, -- 13
|
||||
{ "face_object", 1, "up" }, -- 14
|
||||
{ "show_text", "_OaksLabRivalIllTakeThisOneText" }, -- 15
|
||||
{ "hide_object", "OAKS_LAB", rivalBall }, -- 16
|
||||
{ "move_npc_to", 1, rivalBallX, 4 }, -- 14
|
||||
{ "face_object", 1, "up" }, -- 15
|
||||
{ "show_text", "_OaksLabRivalIllTakeThisOneText" }, -- 16
|
||||
{ "hide_object", "OAKS_LAB", rivalBall }, -- 17
|
||||
{ "play_sound", "Get_Key_Item" }, -- 18 (sound_get_key_item)
|
||||
{ "show_text", "_OaksLabRivalReceivedMonText",
|
||||
{ RAM = rivalBall == "OAKSLAB_CHARMANDER_POKE_BALL" and "CHARMANDER"
|
||||
or rivalBall == "OAKSLAB_SQUIRTLE_POKE_BALL" and "SQUIRTLE"
|
||||
or "BULBASAUR" } }, -- 17
|
||||
{ "jump", "end" }, -- 18
|
||||
{ "jump", "end" }, -- 19 (spacer)
|
||||
or "BULBASAUR" } }, -- 19
|
||||
{ "jump", "end" }, -- 20
|
||||
{ "jump", "end" }, -- 21 (spacer)
|
||||
-- a leftover ball after the player's pick: Oak turns to face the
|
||||
-- player and reads the last-mon line instead of re-offering the
|
||||
-- starter (scripts/OaksLab.asm OaksLabSelectedPokeBallScript ->
|
||||
-- OaksLabLastMonScript; #601). The ROM's "#MON" ligature is spelled
|
||||
-- out as Pokémon here.
|
||||
{ "face_object", 5, "down" }, -- 20
|
||||
{ "show_text", "That's PROF.OAK's\nlast Pokémon!" }, -- 21
|
||||
{ "face_object", 5, "down" }, -- 22
|
||||
{ "show_text", "That's PROF.OAK's\nlast Pokémon!" }, -- 23
|
||||
-- OaksLabLastMonScript ends at TextScriptEnd; the port used to fall
|
||||
-- through into the pre-pick line below (#601 remnant, reported on #600)
|
||||
{ "jump", "end" }, -- 22
|
||||
{ "show_text", "_OaksLabThoseArePokeBallsText" }, -- 23
|
||||
{ "jump", "end" }, -- 24
|
||||
{ "show_text", "_OaksLabThoseArePokeBallsText" }, -- 25
|
||||
}
|
||||
end
|
||||
|
||||
@@ -320,9 +325,14 @@ return {
|
||||
table.insert(rows, { "jump_if_false", base + 6 })
|
||||
table.insert(rows, { "show_text", "_OaksLabRivalIPickedTheWrongPokemonText" })
|
||||
table.insert(rows, { "show_text", "_OaksLabRivalSmellYouLaterText" })
|
||||
-- OaksLabRivalStartsExitScript: parting shot, rival exit fanfare, then
|
||||
-- walk out past the player. The fanfare was dropped here (#683) -- the
|
||||
-- parcel scene above already plays Music_MeetRival on both arrival and
|
||||
-- departure (lines 144-146), and this exit should match (#596).
|
||||
table.insert(rows, { "stop_music" })
|
||||
table.insert(rows, { "play_music", "Music_MeetRival" })
|
||||
table.insert(rows, { "move_npc_to", 1, 4, 11 })
|
||||
table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" })
|
||||
-- restore the lab theme once he's walked out, same as the Yellow port
|
||||
table.insert(rows, { "play_music", "Music_OaksLab" })
|
||||
ow.runner:run(rows, { npc = rival })
|
||||
return true
|
||||
|
||||
@@ -287,10 +287,12 @@ return {
|
||||
table.insert(rows, { "label", "lost_lab" })
|
||||
table.insert(rows, { "set_field", "rivalStarter", 3 })
|
||||
table.insert(rows, { "label", "exit" })
|
||||
-- OaksLabRivalStartsExitScript: parting shot, walk out past the
|
||||
-- player, restore the lab theme
|
||||
-- OaksLabRivalStartsExitScript: parting shot, rival exit fanfare, then
|
||||
-- walk out past the player (#683).
|
||||
table.insert(rows, { "wait", 20 })
|
||||
table.insert(rows, { "show_text", "_OaksLabRivalSmellYouLaterText" })
|
||||
table.insert(rows, { "stop_music" })
|
||||
table.insert(rows, { "play_music", "Music_MeetRival" })
|
||||
table.insert(rows, { "move_npc_to", RIVAL, 4, 11 })
|
||||
table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" })
|
||||
table.insert(rows, { "play_music", "Music_OaksLab" })
|
||||
|
||||
@@ -56,6 +56,7 @@ for mapId, holes in pairs(HOLE_FALLS) do
|
||||
M[mapId].onStep = function(game, ow, x, y)
|
||||
for _, h in ipairs(holes) do
|
||||
if x == h[1] and y == h[2] then
|
||||
require("src.core.Sound").play(game.data, "Faint_Fall")
|
||||
ow:startWarpTo(h[3], h[4], h[5], ow.player.facing)
|
||||
return true
|
||||
end
|
||||
|
||||
@@ -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
|
||||
@@ -778,9 +800,13 @@ M.SILPH_CO_11F = {
|
||||
-- line) would touch, and the whole Silph ending -- the flag, the Master
|
||||
-- Ball, the Saffron streets clearing -- silently never happened.
|
||||
--
|
||||
-- engageTrainer shows TEXT_SILPHCO11F_GIOVANNI as the battle text and,
|
||||
-- via victories.lua OPP_GIOVANNI#2, sets the event on a win; a loss
|
||||
-- sets nothing, so the trigger re-arms exactly as vanilla does.
|
||||
-- SilphCo11FDefaultScript orders it DisplayTextID TEXT_SILPHCO11F_GIOVANNI
|
||||
-- FIRST, then MoveSprite .GiovanniMovement: he speaks from behind the desk
|
||||
-- and only then walks the three tiles down. Moving him before the box made
|
||||
-- him cross the room in silence and deliver the speech point-blank (#869),
|
||||
-- so the box comes first here and engageTrainer skips its own battle text.
|
||||
-- victories.lua OPP_GIOVANNI#2 sets the event on a win; a loss sets
|
||||
-- nothing, so the trigger re-arms exactly as vanilla does.
|
||||
onStep = function(game, ow, x, y)
|
||||
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then return false end
|
||||
if not ((x == 6 and y == 13) or (x == 7 and y == 12)) then return false end
|
||||
@@ -789,17 +815,28 @@ M.SILPH_CO_11F = {
|
||||
if npc.def and npc.def.name == "SILPHCO11F_GIOVANNI" then gio = npc break end
|
||||
end
|
||||
if not gio or ow:trainerDefeated(gio) then return false end
|
||||
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).
|
||||
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
|
||||
silphRocketsLeave(game, ow)
|
||||
end
|
||||
end)
|
||||
end)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game,
|
||||
game.data.text._SilphCo11FGiovanniText
|
||||
or "Ah {PLAYER}!\nSo we meet again!",
|
||||
function()
|
||||
ow:scriptMove(gio, "down", 3, function()
|
||||
gio:facePlayer(ow.player)
|
||||
ow:engageTrainer(gio, function()
|
||||
-- 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
|
||||
ow:queueScript(silphAftermathRows())
|
||||
end
|
||||
end, nil, true)
|
||||
end)
|
||||
end))
|
||||
return true
|
||||
end,
|
||||
onEnter = function(game, ow)
|
||||
@@ -942,6 +979,7 @@ M.VICTORY_ROAD_3F = {
|
||||
-- fall is onStep, not a collision block.
|
||||
onStep = function(game, ow, x, y)
|
||||
if x == 23 and y == 15 then
|
||||
require("src.core.Sound").play(game.data, "Faint_Fall")
|
||||
ow:startWarpTo("VICTORY_ROAD_2F", 22, 16, ow.player.facing)
|
||||
return true
|
||||
end
|
||||
@@ -980,40 +1018,67 @@ M.VICTORY_ROAD_3F = {
|
||||
local championsRoomRivalScript = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 2
|
||||
{ "jump_if_true", 25 }, -- 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
|
||||
{ "rival_battle", "OPP_RIVAL3", 1 }, -- 5
|
||||
{ "jump_if_false", 25 }, -- 6 past end
|
||||
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 7
|
||||
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL" }, -- 8
|
||||
-- ChampionsRoomRivalReadyToBattleScript plays MUSIC_FINAL_BATTLE after
|
||||
-- the intro text, before the battle itself (#706); pushBattle's wipe-time
|
||||
-- playBattle("final") then no-ops on the same song, so the theme stays
|
||||
-- continuous into the fight
|
||||
{ "play_music", "Music_FinalBattle" }, -- 5
|
||||
{ "rival_battle", "OPP_RIVAL3", 1 }, -- 6
|
||||
-- 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
|
||||
-- defeating RIVAL3), then Oak's "{PLAYER}!" + reveal + walk in.
|
||||
-- audio/alternate_tempo.asm Music_Cities1AlternateTempo is not a plain
|
||||
-- PlayMusic: it fades the current song out (wAudioFadeOutControl = 10),
|
||||
-- waits 100 frames for the fade, then restarts Cities1 with channel 1
|
||||
-- pointed at Music_Cities1_Ch1_AlternateTempo -- `tempo 232` where the
|
||||
-- normal Music_Cities1_Ch1 opens `tempo 144`, i.e. the slower, heavier
|
||||
-- reading of the town theme this scene is known for (#847).
|
||||
{ "fade_music", 10 }, -- 11
|
||||
{ "wait", 100 }, -- 12
|
||||
{ "play_music", "Music_Cities1", { keep = true, tempo = 232 } }, -- 13
|
||||
{ "show_text", "_ChampionsRoomOakText" }, -- 14
|
||||
{ "show_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 15
|
||||
{ "move_npc", 2, "up", 5 }, -- 16 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" }, -- 17
|
||||
{ "face_object", 2, "down" }, -- 18
|
||||
{ "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 19
|
||||
-- OakDisappointedWithRivalScript: Oak turns to the rival (right)
|
||||
{ "face_object", 2, "right" }, -- 17
|
||||
{ "show_text", "_ChampionsRoomOakDisappointedWithRivalText" }, -- 18
|
||||
{ "face_object", 2, "right" }, -- 20
|
||||
{ "show_text", "_ChampionsRoomOakDisappointedWithRivalText" }, -- 21
|
||||
-- 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" }, -- 22
|
||||
{ "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 23
|
||||
{ "move_npc", 2, "up", 2 }, -- 24 OakExitChampionsRoomMovement
|
||||
{ "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 25
|
||||
-- ChampionsRoomPlayerFollowsOakScript / WalkToHallOfFame_RLEMovement.
|
||||
-- The player walks out after Oak instead of the screen just fading on the
|
||||
-- spot (#704). Route one tile right before walking north so the player
|
||||
-- reaches the north-wall HALL_OF_FAME warp without sharing the rival's
|
||||
-- (4,2) cell. The original simulated movement bypasses entity collision,
|
||||
-- but this scene should not visibly walk through the defeated rival.
|
||||
{ "move_player", "right", 1 }, -- 26
|
||||
{ "move_player", "up", 3 }, -- 27
|
||||
-- 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 }, -- 28
|
||||
{ "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 29
|
||||
}
|
||||
|
||||
M.CHAMPIONS_ROOM = {
|
||||
|
||||
@@ -151,9 +151,27 @@ M.POKEMON_TOWER_6F = {
|
||||
-- trick, and the speedrun route this bot follows depends on it.
|
||||
if result == "win" or battle.pokeDollEscape then
|
||||
game.save.flags.EVENT_BEAT_GHOST_MAROWAK = true
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._PokemonTower6FSoulWasCalmedText
|
||||
or "The mother's soul\nwas calmed.\012It departed to\nthe afterlife!"))
|
||||
-- PokemonTower6FMarowakDepartedText (scripts/PokemonTower6F.asm)
|
||||
-- is two texts, not one: the CUBONE's-mother line first, then
|
||||
-- PlayCry RESTLESS_SOUL (EQU MAROWAK, constants/pokemon_constants
|
||||
-- .asm:209) + WaitForSoundToFinish + DelayFrames 30 before the
|
||||
-- calmed line; the port dropped the first text and the cry
|
||||
-- (#867). play_cry arms the next show_text, so the cry rides
|
||||
-- the calmed box's open with the button prompt kept, and the
|
||||
-- wait row stands in for the asm's 30-frame gap.
|
||||
local rows = {
|
||||
{ "show_text", t._PokemonTower6FGhostWasCubonesMotherText
|
||||
or "The GHOST was the\nrestless soul of\vCUBONE's mother!" },
|
||||
{ "play_cry", "MAROWAK", true },
|
||||
{ "wait", 30 },
|
||||
{ "show_text", t._PokemonTower6FSoulWasCalmedText
|
||||
or "The mother's soul\nwas calmed.\012It departed to\nthe afterlife!" },
|
||||
}
|
||||
if ow.runner then
|
||||
ow.runner:run(rows)
|
||||
elseif ow.queueScript then
|
||||
ow:queueScript(rows)
|
||||
end
|
||||
elseif result ~= "lose" then
|
||||
-- .did_not_defeat: one simulated step right, off the trigger,
|
||||
-- so fleeing does not leave you standing on a cell that
|
||||
@@ -517,6 +535,14 @@ M.GAME_CORNER = {
|
||||
done()
|
||||
return
|
||||
end
|
||||
-- GameCornerRocketText hands the battle its own loss line through
|
||||
-- SaveEndBattleTextPointers (.BattleEndText ->
|
||||
-- _GameCornerRocketBattleEndText, "Dang!"), and PrintEndBattleText
|
||||
-- prints it ON the battle screen between TrainerDefeatedText and
|
||||
-- MoneyForWinningText (engine/battle/core.asm TrainerBattleVictory).
|
||||
-- He is a text_asm trainer with no def_trainers header, so there is no
|
||||
-- header.won for engageTrainer to find and the line has to be handed
|
||||
-- over here or it never shows at all (#862).
|
||||
ow:engageTrainer(npc, function()
|
||||
if not ow:trainerDefeated(npc) then
|
||||
done()
|
||||
@@ -527,19 +553,44 @@ M.GAME_CORNER = {
|
||||
game.data.text._GameCornerRocketAfterBattleText
|
||||
or "Our hideout might\nbe discovered! I\nbetter tell BOSS!",
|
||||
function()
|
||||
-- #198: GameCornerRocketExitScript (scripts/GameCorner.asm)
|
||||
-- ApplyMovementData walks the grunt one tile UP into the poster
|
||||
-- (the hideout's secret entrance at 9,4) before HideObject, so
|
||||
-- he leaves the floor rather than popping out of existence on
|
||||
-- (9,5). scriptMove locks player input (#scriptMoves>0) and
|
||||
-- ignores collision, so we despawn + unfreeze (done) only once
|
||||
-- the step lands.
|
||||
ow:scriptMove(npc, "up", 1, function()
|
||||
hideRocket()
|
||||
done()
|
||||
end)
|
||||
-- #198/#862: GameCornerRocketBattleScript (scripts/GameCorner.asm)
|
||||
-- picks the exit walk from where the player is standing, because
|
||||
-- the grunt on (9,5) has to get past him: wYCoord == 6 (talked to
|
||||
-- from the south) or wXCoord == 8 (from the west) leaves the row
|
||||
-- clear and takes GameCornerMovement_Rocket_WalkDirect, five steps
|
||||
-- RIGHT; otherwise the player is east of him on (10,5) and
|
||||
-- GameCornerMovement_Rocket_WalkAroundPlayer steps DOWN, right, UP
|
||||
-- and right again to go AROUND him. pokeyellow's copy of the
|
||||
-- around-path takes one extra RIGHT on the lower row before coming
|
||||
-- back up (it also has to clear Pikachu); both versions end on
|
||||
-- (15,5). He never steps UP: (9,4) is the poster wall, which is
|
||||
-- where the old single UP step sent him.
|
||||
local px = ow.player and ow.player.cellX
|
||||
local py = ow.player and ow.player.cellY
|
||||
local path
|
||||
if py == 6 or px == 8 then
|
||||
path = { { "right", 5 } }
|
||||
elseif require("src.core.GameVersion").isYellow() then
|
||||
path = { { "down", 1 }, { "right", 3 }, { "up", 1 }, { "right", 3 } }
|
||||
else
|
||||
path = { { "down", 1 }, { "right", 2 }, { "up", 1 }, { "right", 4 } }
|
||||
end
|
||||
-- GameCornerRocketExitScript only HideObjects him once
|
||||
-- BIT_SCRIPTED_NPC_MOVEMENT clears, i.e. after the last step.
|
||||
-- scriptMove locks player input (#scriptMoves>0) and ignores
|
||||
-- collision, so the despawn + unfreeze (done) ride the final step.
|
||||
local function step(i)
|
||||
if i > #path then
|
||||
hideRocket()
|
||||
done()
|
||||
return
|
||||
end
|
||||
ow:scriptMove(npc, path[i][1], path[i][2],
|
||||
function() step(i + 1) end)
|
||||
end
|
||||
step(1)
|
||||
end))
|
||||
end)
|
||||
end, game.data.text._GameCornerRocketBattleEndText or "Dang!")
|
||||
end,
|
||||
-- GameCornerClerk1Text (scripts/GameCorner.asm): the offer, a
|
||||
-- YesNoChoice, then ¥1000 for 50 coins. Yellow drops the "1" from the
|
||||
@@ -549,21 +600,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 +658,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 +694,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),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -13,9 +13,18 @@ local function push(game, s, done)
|
||||
game.stack:push(TextBox.new(game, s, done))
|
||||
end
|
||||
|
||||
-- The question stays on screen under the YES/NO menu. The dojo prize
|
||||
-- balls are the clearest case: FightingDojoHitmonleePokeBallText
|
||||
-- (scripts/FightingDojo.asm) is `call PrintText` on a text_end string --
|
||||
-- no prompt, so no WaitForTextScrollButtonPress -- immediately followed
|
||||
-- by `call YesNoChoice`, and InitYesNoTextBoxParameters
|
||||
-- (engine/menus/text_box.asm) puts the menu above the dialogue box
|
||||
-- rather than replacing it. Ride TextBox's opts.choice, the same as
|
||||
-- Commands.ask, instead of popping the box with an A press and leaving a
|
||||
-- bare ChoiceBox over the overworld (#854).
|
||||
local function ask(game, s, cb)
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
push(game, s, function() game.stack:push(ChoiceBox.new(game, cb)) end)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game, s, nil, { choice = cb }))
|
||||
end
|
||||
|
||||
-- fill the extracted text placeholders ({NUM:...}, {RAM:...}, {PLAYER})
|
||||
@@ -155,19 +164,26 @@ local function dojoBall(species, ownBall, otherBall, askKey)
|
||||
push(game, "You'll have to\nbeat the master\nfirst!", done)
|
||||
return
|
||||
end
|
||||
ask(game, t[askKey] or ("You want\n" .. species .. "?"), function(yes)
|
||||
if not yes then done() return end
|
||||
flags["EVENT_GOT_" .. species] = true
|
||||
flags.EVENT_DEFEATED_FIGHTING_DOJO = true
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { save = game.save, game = game, overworld = ow }
|
||||
Commands.give_pokemon(ctx, species, 30)
|
||||
-- Hide ONLY the chosen ball; the other stays (FightingDojo.asm hides
|
||||
-- just the picked object's index) and routes to the greedy line above
|
||||
-- when talked to (#197).
|
||||
Commands.hide_object(ctx, "FIGHTING_DOJO", ownBall)
|
||||
push(game, ("%s got\n%s!"):format(game.save.player.name, species), done)
|
||||
end)
|
||||
-- Examining a ball shows that species' POKéDEX entry first
|
||||
-- (DisplayPokedex in FightingDojo.asm, which also marks it seen),
|
||||
-- then the yes/no take-it prompt (#853).
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { save = game.save, game = game, overworld = ow }
|
||||
Commands.mark_seen(ctx, species)
|
||||
local DexEntryMenu = require("src.ui.DexEntryMenu")
|
||||
game.stack:push(DexEntryMenu.new(game, species, function()
|
||||
ask(game, t[askKey] or ("You want\n" .. species .. "?"), function(yes)
|
||||
if not yes then done() return end
|
||||
flags["EVENT_GOT_" .. species] = true
|
||||
flags.EVENT_DEFEATED_FIGHTING_DOJO = true
|
||||
Commands.give_pokemon(ctx, species, 30)
|
||||
-- Hide ONLY the chosen ball; the other stays (FightingDojo.asm hides
|
||||
-- just the picked object's index) and routes to the greedy line above
|
||||
-- when talked to (#197).
|
||||
Commands.hide_object(ctx, "FIGHTING_DOJO", ownBall)
|
||||
push(game, ("%s got\n%s!"):format(game.save.player.name, species), done)
|
||||
end)
|
||||
end))
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -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",
|
||||
@@ -176,6 +186,20 @@ M.ROUTE_18_GATE_2F = {
|
||||
{ "face_player" },
|
||||
{ "trade", 6, "EVENT_TRADED_SLOWBRO_FOR_LICKITUNG" }, -- MARC
|
||||
},
|
||||
-- Yellow replaces the youngster with a cook trading SPIKE
|
||||
-- (TANGELA -> PARASECT): pokeyellow/scripts/Route18Gate2F.asm
|
||||
-- Route18Gate2FCookText runs TRADE_FOR_SPIKE, index 6 in the Yellow
|
||||
-- TradeMons table that Data:applyVersionedFieldData swaps in. Red
|
||||
-- maps have no COOK object here and Yellow maps have no YOUNGSTER,
|
||||
-- so each version only ever fires its own row (#651). Both rows
|
||||
-- share the Red-flavoured done flag on purpose: a .sav tracks
|
||||
-- "trade slot 6 completed" in one wCompletedInGameTradeFlags bit
|
||||
-- either version reads, and the save codec maps that bit to this
|
||||
-- flag name (src/save_convert/GenSave.lua EXTRA_FLAG_BITS).
|
||||
TEXT_ROUTE18GATE2F_COOK = {
|
||||
{ "face_player" },
|
||||
{ "trade", 6, "EVENT_TRADED_SLOWBRO_FOR_LICKITUNG" }, -- SPIKE (Yellow)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -12,9 +12,13 @@ local function push(game, s, done)
|
||||
game.stack:push(TextBox.new(game, s, done))
|
||||
end
|
||||
|
||||
-- PrintText on a text_end string returns with the box still drawn and
|
||||
-- YesNoChoice then draws the menu above it (InitYesNoTextBoxParameters,
|
||||
-- engine/menus/text_box.asm); no A press clears the question first. Ride
|
||||
-- TextBox's opts.choice, the same as Commands.ask (#854).
|
||||
local function ask(game, s, cb)
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
push(game, s, function() game.stack:push(ChoiceBox.new(game, cb)) end)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game, s, nil, { choice = cb }))
|
||||
end
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
@@ -118,6 +122,7 @@ local MANSION_HOLES = {
|
||||
M.POKEMON_MANSION_3F.onStep = function(game, ow, x, y)
|
||||
for _, h in ipairs(MANSION_HOLES) do
|
||||
if x == h[1] and y == h[2] then
|
||||
require("src.core.Sound").play(game.data, "Faint_Fall")
|
||||
ow:startWarpTo(h[3], h[4], h[5], ow.player.facing)
|
||||
return true
|
||||
end
|
||||
|
||||
@@ -18,6 +18,16 @@
|
||||
-- script). Leaders are not def_trainers entries, so engageTrainer has
|
||||
-- no header.won -- checkVictoryRewards shows this chain instead of a
|
||||
-- synthetic "received badge/TM" stub.
|
||||
--
|
||||
-- Gym entries split the TM hand-over out of `dialogue`, mirroring the
|
||||
-- originals' GiveItem check (`call GiveItem` / `jr nc, .BagFull`):
|
||||
-- `tmPre` is the ReceiveTM script's lead-in (badge info / "Wait! Take
|
||||
-- this!"), shown at the victory and again when a beaten leader retries
|
||||
-- the hand-over; `tmDialogue` shows only when the TM actually goes in
|
||||
-- the bag; `noRoom` is the "make room" line shown instead when the bag
|
||||
-- is full; `gotFlag` (pokered's EVENT_GOT_TM*) is set only on a
|
||||
-- successful give, which is what makes the leader's talk script retry
|
||||
-- later (gyms.lua).
|
||||
|
||||
local function range(prefix, first, last)
|
||||
local t = {}
|
||||
@@ -33,6 +43,8 @@ return {
|
||||
-- escort NPC and the first Route 22 rival stay gone after the badge.
|
||||
["OPP_BROCK#1"] = { badge = "BOULDERBADGE", flag = "EVENT_BEAT_BROCK",
|
||||
item = "TM_BIDE",
|
||||
gotFlag = "EVENT_GOT_TM34",
|
||||
noRoom = "_PewterGymTM34NoRoomText",
|
||||
deactivate = { "EVENT_BEAT_PEWTER_GYM_TRAINER_0" },
|
||||
hide = {
|
||||
{ "PEWTER_CITY", "PEWTERCITY_YOUNGSTER" },
|
||||
@@ -41,75 +53,113 @@ return {
|
||||
dialogue = {
|
||||
"_PewterGymBrockReceivedBoulderBadgeText",
|
||||
"_PewterGymBrockBoulderBadgeInfoText",
|
||||
"_PewterGymBrockWaitTakeThisText",
|
||||
},
|
||||
tmPre = { "_PewterGymBrockWaitTakeThisText" },
|
||||
tmDialogue = {
|
||||
"_PewterGymReceivedTM34Text",
|
||||
"_TM34ExplanationText",
|
||||
} },
|
||||
["OPP_MISTY#1"] = { badge = "CASCADEBADGE", flag = "EVENT_BEAT_MISTY",
|
||||
item = "TM_BUBBLEBEAM",
|
||||
gotFlag = "EVENT_GOT_TM11",
|
||||
noRoom = "_CeruleanGymMistyTM11NoRoomText",
|
||||
deactivate = range("EVENT_BEAT_CERULEAN_GYM_TRAINER_", 0, 1),
|
||||
dialogue = {
|
||||
"_CeruleanGymMistyReceivedCascadeBadgeText",
|
||||
"_CeruleanGymMistyCascadeBadgeInfoText",
|
||||
},
|
||||
tmPre = { "_CeruleanGymMistyCascadeBadgeInfoText" },
|
||||
tmDialogue = {
|
||||
"_CeruleanGymMistyReceivedTM11Text",
|
||||
} },
|
||||
["OPP_LT_SURGE#1"] = { badge = "THUNDERBADGE", flag = "EVENT_BEAT_LT_SURGE",
|
||||
item = "TM_THUNDERBOLT",
|
||||
gotFlag = "EVENT_GOT_TM24",
|
||||
noRoom = "_VermilionGymLTSurgeTM24NoRoomText",
|
||||
deactivate = range("EVENT_BEAT_VERMILION_GYM_TRAINER_", 0, 2),
|
||||
dialogue = {
|
||||
"_VermilionGymLTSurgeReceivedThunderBadgeText",
|
||||
"_VermilionGymLTSurgeThunderBadgeInfoText",
|
||||
},
|
||||
tmPre = { "_VermilionGymLTSurgeThunderBadgeInfoText" },
|
||||
tmDialogue = {
|
||||
"_VermilionGymLTSurgeReceivedTM24Text",
|
||||
"_TM24ExplanationText",
|
||||
} },
|
||||
["OPP_ERIKA#1"] = { badge = "RAINBOWBADGE", flag = "EVENT_BEAT_ERIKA",
|
||||
item = "TM_MEGA_DRAIN",
|
||||
gotFlag = "EVENT_GOT_TM21",
|
||||
noRoom = "_CeladonGymTM21NoRoomText",
|
||||
deactivate = range("EVENT_BEAT_CELADON_GYM_TRAINER_", 0, 6),
|
||||
dialogue = {
|
||||
"_CeladonGymErikaReceivedRainbowBadgeText",
|
||||
"_CeladonGymRainbowBadgeInfoText",
|
||||
},
|
||||
tmPre = { "_CeladonGymRainbowBadgeInfoText" },
|
||||
tmDialogue = {
|
||||
"_CeladonGymReceivedTM21Text",
|
||||
"_TM21ExplanationText",
|
||||
} },
|
||||
["OPP_KOGA#1"] = { badge = "SOULBADGE", flag = "EVENT_BEAT_KOGA",
|
||||
item = "TM_TOXIC",
|
||||
gotFlag = "EVENT_GOT_TM06",
|
||||
noRoom = "_FuchsiaGymKogaTM06NoRoomText",
|
||||
deactivate = range("EVENT_BEAT_FUCHSIA_GYM_TRAINER_", 0, 5),
|
||||
dialogue = {
|
||||
"_FuchsiaGymKogaReceivedSoulBadgeText",
|
||||
"_FuchsiaGymKogaSoulBadgeInfoText",
|
||||
},
|
||||
tmPre = { "_FuchsiaGymKogaSoulBadgeInfoText" },
|
||||
tmDialogue = {
|
||||
"_FuchsiaGymKogaReceivedTM06Text",
|
||||
"_FuchsiaGymKogaTM06ExplanationText",
|
||||
} },
|
||||
["OPP_SABRINA#1"] = { badge = "MARSHBADGE", flag = "EVENT_BEAT_SABRINA",
|
||||
item = "TM_PSYWAVE",
|
||||
gotFlag = "EVENT_GOT_TM46",
|
||||
noRoom = "_SaffronGymSabrinaTM46NoRoomText",
|
||||
deactivate = range("EVENT_BEAT_SAFFRON_GYM_TRAINER_", 0, 6),
|
||||
dialogue = {
|
||||
"_SaffronGymSabrinaReceivedMarshBadgeText",
|
||||
"_SaffronGymSabrinaMarshBadgeInfoText",
|
||||
},
|
||||
tmPre = { "_SaffronGymSabrinaMarshBadgeInfoText" },
|
||||
tmDialogue = {
|
||||
"_SaffronGymSabrinaReceivedTM46Text",
|
||||
"_TM46ExplanationText",
|
||||
} },
|
||||
["OPP_BLAINE#1"] = { badge = "VOLCANOBADGE", flag = "EVENT_BEAT_BLAINE",
|
||||
item = "TM_FIRE_BLAST",
|
||||
gotFlag = "EVENT_GOT_TM38",
|
||||
noRoom = "_CinnabarGymBlaineTM38NoRoomText",
|
||||
deactivate = range("EVENT_BEAT_CINNABAR_GYM_TRAINER_", 0, 6),
|
||||
dialogue = {
|
||||
"_CinnabarGymBlaineReceivedVolcanoBadgeText",
|
||||
"_CinnabarGymBlaineVolcanoBadgeInfoText",
|
||||
},
|
||||
tmPre = { "_CinnabarGymBlaineVolcanoBadgeInfoText" },
|
||||
tmDialogue = {
|
||||
"_CinnabarGymBlaineReceivedTM38Text",
|
||||
"_CinnabarGymBlaineTM38ExplanationText",
|
||||
} },
|
||||
["OPP_GIOVANNI#3"] = { badge = "EARTHBADGE", flag = "EVENT_BEAT_GIOVANNI",
|
||||
item = "TM_FISSURE",
|
||||
gotFlag = "EVENT_GOT_TM27",
|
||||
noRoom = "_ViridianGymGiovanniTM27NoRoomText",
|
||||
deactivate = range("EVENT_BEAT_VIRIDIAN_GYM_TRAINER_", 0, 7),
|
||||
dialogue = {
|
||||
"_ViridianGymGiovanniReceivedEarthBadgeText",
|
||||
"_ViridianGymGiovanniEarthBadgeInfoText",
|
||||
},
|
||||
tmPre = { "_ViridianGymGiovanniEarthBadgeInfoText" },
|
||||
tmDialogue = {
|
||||
"_ViridianGymGiovanniReceivedTM27Text",
|
||||
"_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,
|
||||
|
||||
@@ -62,10 +62,15 @@ M.MT_MOON_B2F = {
|
||||
{ "walk_npc", 6, { "left", "left", "left", "left", "left" } },
|
||||
{ "face_object", 6, "left" },
|
||||
{ "show_text", "_MtMoonJessieJamesText2" },
|
||||
-- MtMoonB2FScript12 arms _MtMoonJessieJamesText3 with
|
||||
-- SaveEndBattleTextPointers before it sets wCurOpponent, so
|
||||
-- TrainerBattleVictory prints it on the battle screen as "ROCKET: A
|
||||
-- brat beat us?" between TrainerDefeatedText and MoneyForWinningText.
|
||||
-- Its one-word first line only reads right behind that tag (#866).
|
||||
{ "save_end_battle_text", "_MtMoonJessieJamesText3" },
|
||||
{ "start_battle", "trainer", "OPP_ROCKET", 42 },
|
||||
{ "check_battle_result", "win" },
|
||||
{ "jump_if_false", "end" },
|
||||
{ "show_text", "_MtMoonJessieJamesText3" },
|
||||
{ "show_text", "_MtMoonJessieJamesText4" },
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetJessieJames" },
|
||||
@@ -85,7 +90,8 @@ M.MT_MOON_B2F = {
|
||||
-- motto plays from off-screen FIRST, then the duo pops in at (25,10) /
|
||||
-- (24,10) and whichever of them shares the player's column ($18=24 or
|
||||
-- $19=25, EVENT_ROCKET_HIDEOUT_4_JESSIE_JAMES_ON_LEFT) walks the three
|
||||
-- tiles down to loom over the player while the other steps one. A loss
|
||||
-- tiles down to loom over the player while the other walks four and ends
|
||||
-- up beside him. A loss
|
||||
-- re-hides them (RocketHideoutB4FResetScripts via EVENT_6A0), so the
|
||||
-- trigger re-arms clean.
|
||||
-- -------------------------------------------------------------------
|
||||
@@ -106,7 +112,7 @@ M.ROCKET_HIDEOUT_B4F = {
|
||||
if f.EVENT_BEAT_ROCKET_HIDEOUT_4_JESSIE_JAMES then return false end
|
||||
-- ON_LEFT: player under James's column (25); movement data pairs
|
||||
-- RocketHideoutB4FJessieJamesMovementData_45605/45606 swap so the
|
||||
-- column-mate walks 3, the other 1.
|
||||
-- column-mate walks 3, the other 4.
|
||||
local onLeft = (x == 25)
|
||||
ow.runner:run({
|
||||
{ "stop_music" },
|
||||
@@ -116,16 +122,30 @@ M.ROCKET_HIDEOUT_B4F = {
|
||||
{ "emote", "player", "shock", 30 },
|
||||
{ "show_object", "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_JAMES" },
|
||||
{ "show_object", "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_JESSIE" },
|
||||
-- James (object 2) then Jessie (object 3), Script4..Script9 order
|
||||
{ "walk_npc", 2, onLeft and { "down", "down", "down" } or { "down" } },
|
||||
-- James (object 2) then Jessie (object 3), Script4..Script9 order.
|
||||
-- RocketHideoutB4FJessieJamesMovementData_45605 is a lone $4 that FALLS
|
||||
-- THROUGH into _45606 ($4 $4 $4 $ff), so MoveSprite_ (home/pathfinding.asm)
|
||||
-- reads _45605 as FOUR steps and _45606 as three; $4 is DOWN in Yellow's
|
||||
-- Func_5288 lookup (engine/overworld/movement.asm), which walks with no
|
||||
-- collision test. From (25,10)/(24,10) against a player on y=14 the
|
||||
-- column-mate stops three down, right above him, and the other walks the
|
||||
-- full four to stand alongside -- which is what the facings below assume.
|
||||
-- Reading _45605 as a single step stranded whoever was off-column three
|
||||
-- tiles away, so James never reached the player (#865).
|
||||
{ "walk_npc", 2, onLeft and { "down", "down", "down" }
|
||||
or { "down", "down", "down", "down" } },
|
||||
{ "face_object", 2, onLeft and "down" or "left" },
|
||||
{ "walk_npc", 3, onLeft and { "down" } or { "down", "down", "down" } },
|
||||
{ "walk_npc", 3, onLeft and { "down", "down", "down", "down" }
|
||||
or { "down", "down", "down" } },
|
||||
{ "face_object", 3, onLeft and "right" or "down" },
|
||||
{ "show_text", "_RocketHideoutJessieJamesText2" },
|
||||
-- RocketHideoutB4FScript10 saves _RocketHideoutJessieJamesText3 as the
|
||||
-- end-battle text, so it prints as "ROCKET: Such a dreadful twerp!" on
|
||||
-- the battle screen ahead of MoneyForWinningText (#866).
|
||||
{ "save_end_battle_text", "_RocketHideoutJessieJamesText3" },
|
||||
{ "start_battle", "trainer", "OPP_ROCKET", 43 },
|
||||
{ "check_battle_result", "win" },
|
||||
{ "jump_if_false", "lost" },
|
||||
{ "show_text", "_RocketHideoutJessieJamesText3" },
|
||||
{ "show_text", "_RocketHideoutJessieJamesText4" },
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetJessieJames" },
|
||||
@@ -175,16 +195,27 @@ M.POKEMON_TOWER_7F = {
|
||||
{ "show_text", "_PokemonTowerJessieJamesText1" },
|
||||
{ "face_player_dir", "up" },
|
||||
{ "emote", "player", "shock", 30 },
|
||||
-- Jessie (object 1) then James (object 2), Script1..Script6 order
|
||||
{ "walk_npc", 1, onLeft and { "down" } or { "down", "down", "down" } },
|
||||
-- Jessie (object 1) then James (object 2), Script1..Script6 order.
|
||||
-- Same fall-through blob as the hideout: PokemonTower7FMovementData_60d7a
|
||||
-- is a lone $4 running into _60d7b ($4 $4 $4 $FF), so _60d7a is FOUR
|
||||
-- steps and _60d7b is three. From (10,8)/(11,8) against a player on
|
||||
-- y=12 the column-mate halts one tile above him and the other closes the
|
||||
-- full four to his side; the single-step reading is why James only
|
||||
-- "moved a bit" here (#865).
|
||||
{ "walk_npc", 1, onLeft and { "down", "down", "down", "down" }
|
||||
or { "down", "down", "down" } },
|
||||
{ "face_object", 1, onLeft and "right" or "down" },
|
||||
{ "walk_npc", 2, onLeft and { "down", "down", "down" } or { "down" } },
|
||||
{ "walk_npc", 2, onLeft and { "down", "down", "down" }
|
||||
or { "down", "down", "down", "down" } },
|
||||
{ "face_object", 2, onLeft and "down" or "left" },
|
||||
{ "show_text", "_PokemonTowerJessieJamesText2" },
|
||||
-- PokemonTower7FScript7 saves _PokemonTowerJessieJamesText3 as the
|
||||
-- end-battle text: "ROCKET: You will regret this!" on the battle screen,
|
||||
-- before the prize money (#866).
|
||||
{ "save_end_battle_text", "_PokemonTowerJessieJamesText3" },
|
||||
{ "start_battle", "trainer", "OPP_ROCKET", 44 },
|
||||
{ "check_battle_result", "win" },
|
||||
{ "jump_if_false", "end" },
|
||||
{ "show_text", "_PokemonTowerJessieJamesText3" },
|
||||
{ "show_text", "_PokemonTowerJessieJamesText4" },
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetJessieJames" },
|
||||
@@ -254,10 +285,12 @@ M.SILPH_CO_11F = {
|
||||
{ "walk_npc", 6, jessieDirs },
|
||||
{ "face_object", 6, jessieFace },
|
||||
{ "show_text", "_SilphCoJessieJamesText2" },
|
||||
-- SilphCo11FScript11 saves _SilphCoJessieJamesText3 (SilphCo11FText_624c2)
|
||||
-- as the end-battle text: "ROCKET: Like always..." before the money (#866).
|
||||
{ "save_end_battle_text", "_SilphCoJessieJamesText3" },
|
||||
{ "start_battle", "trainer", "OPP_ROCKET", 45 },
|
||||
{ "check_battle_result", "win" },
|
||||
{ "jump_if_false", "end" },
|
||||
{ "show_text", "_SilphCoJessieJamesText3" },
|
||||
{ "show_text", "_SilphCoJessieJamesText4" },
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetJessieJames" },
|
||||
|
||||
@@ -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" },
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ the same core data and graphics into the source tree for verification.
|
||||
| | `src/core/SaveData.lua` | Lua-serialized save in the LÖVE save dir |
|
||||
| render | `src/render/Renderer.lua` | 160x144 canvas, integer nearest scaling |
|
||||
| | `src/render/TileRenderer.lua` | one SpriteBatch per map (8x8 quads) + border-block ring |
|
||||
| | `src/render/SpriteRenderer.lua` | 6-frame walker sheets, flipped right facing |
|
||||
| | `src/render/SpriteRenderer.lua` | variable-size anchored sprite sheets, 6-frame walkers and flipped right facing |
|
||||
| | `src/render/Font.lua` | glyph rendering via charmap (greedy longest match) |
|
||||
| | `src/render/TextBox.lua` | dialogue box: typewriter, `\n` line, `\v` scroll, `\f` page |
|
||||
| | `src/render/Camera.lua`, `Transition.lua` | follow camera, warp fades |
|
||||
|
||||
@@ -234,8 +234,12 @@ What was ported from pokered's engine code and where it came from.
|
||||
pre-battle text and engages the leader battle (badge/TM via
|
||||
data/scripts/victories.lua); post-badge talk prints the leader's
|
||||
post-battle advice text (Misty's is her TM11 explanation). The
|
||||
originals' middle branch (beaten but TM not handed over) is
|
||||
unreachable since the TM is granted with the victory. Giovanni's
|
||||
originals' middle branch (beaten but TM not handed over,
|
||||
CheckEventReuseA EVENT_GOT_TM*) is ported too: the victory's GiveItem
|
||||
goes through the bag's capacity check, a full bag shows the leader's
|
||||
"make room" text instead of the received lines and leaves
|
||||
EVENT_GOT_TM* unset, and talking to the leader re-runs the ReceiveTM
|
||||
script until the TM goes in (#797). Giovanni's
|
||||
farewell (`ViridianGymGiovanniText` .afterBeat) hides him inside a
|
||||
fade-to-black/fade-in Transition matching ViridianGym.asm's
|
||||
GBFadeOutToBlack → HideObject → GBFadeInFromBlack, persisted
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
# Linux arm64 (aarch64) AppImage
|
||||
|
||||
Releases ship `gen1recomp-<version>-linux-arm64.AppImage` alongside the
|
||||
existing x86_64 `gen1recomp-<version>-linux.zip`. It targets 64-bit ARM
|
||||
desktop Linux: Raspberry Pi 4/5 running Raspberry Pi OS, Armbian and other
|
||||
SBC distros, arm64 VMs on Apple Silicon, Ampere/Graviton desktops, and the
|
||||
aarch64 handhelds that run a full distro.
|
||||
|
||||
> The Anbernic RG34XXSP has its own PortMaster-style pack
|
||||
> (`gen1recomp-*-rg34xxsp-stockos64-mod.zip`, see
|
||||
> [anbernic-rg34xxsp.md](anbernic-rg34xxsp.md)). That one bundles PortMaster's
|
||||
> LÖVE runtime and expects the device's own SDL; this AppImage is the generic
|
||||
> desktop-Linux artifact and shares nothing with it but the `game.love`.
|
||||
|
||||
## For players
|
||||
|
||||
```sh
|
||||
chmod +x gen1recomp-*-linux-arm64.AppImage
|
||||
./gen1recomp-*-linux-arm64.AppImage
|
||||
```
|
||||
|
||||
Then use **Import ROM** in the launcher to point it at your own legal Red /
|
||||
Blue / Yellow cartridge dump, exactly as on every other platform.
|
||||
|
||||
If your system has no FUSE (`dlopen(): error loading libfuse.so.2`), either
|
||||
install it (`sudo apt install libfuse2`) or run without it:
|
||||
|
||||
```sh
|
||||
./gen1recomp-*-linux-arm64.AppImage --appimage-extract-and-run
|
||||
```
|
||||
|
||||
### What the host has to provide
|
||||
|
||||
Very little, and this is enforced by an assertion in the build rather than by
|
||||
good intentions. The only libraries the AppImage requires at startup are:
|
||||
|
||||
```
|
||||
glibc 2.29+ libstdc++ libfreetype6 zlib
|
||||
```
|
||||
|
||||
Everything else — OpenGL/Mesa, X11, Wayland, KMSDRM, ALSA, PulseAudio — is
|
||||
**dlopened**, so it is used when present and skipped when absent. That means
|
||||
one image runs on a full desktop, on a Wayland-only session, on a
|
||||
KMSDRM-only handheld with no X server, and on a box with ALSA but no
|
||||
PulseAudio, without a different build for each.
|
||||
|
||||
That property does not come for free from Debian's packages, and getting it
|
||||
is most of what the build below is doing; see
|
||||
[Why five libraries are built from source](#why-five-libraries-are-built-from-source).
|
||||
|
||||
## For builders
|
||||
|
||||
```sh
|
||||
scripts/build_linux_arm64.sh --version 0.1.0
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```
|
||||
dist/linux-arm64/gen1recomp-<version>-linux-arm64.AppImage
|
||||
dist/linux-arm64/gen1recomp-<version>-linux-arm64.AppImage.sha256
|
||||
```
|
||||
|
||||
Useful flags: `--game-love PATH` reuses an already-packed payload (CI does
|
||||
this so every platform ships identical bytes), `--rebuild-image` forces the
|
||||
builder container to rebuild, `--clean-cache` throws away the pinned
|
||||
downloads and the compiled LÖVE prefix.
|
||||
|
||||
### Requirements
|
||||
|
||||
An **aarch64 host** with **docker or podman**. A Raspberry Pi 5 is the
|
||||
reference machine (a cold build takes about 10 minutes on one — six libraries
|
||||
plus the engine; rebuilds reuse the cached prefix and take seconds). Apple Silicon with Docker
|
||||
Desktop and GitHub's `ubuntu-24.04-arm` runner both work too.
|
||||
|
||||
The script refuses to run on x86_64 rather than falling back to qemu-user
|
||||
emulation: that path takes hours and has produced miscompiled LuaJIT.
|
||||
|
||||
### Why this is not just another `scripts/build.sh` target
|
||||
|
||||
`scripts/build.sh linux` downloads LÖVE's official `love-11.5-x86_64.AppImage`,
|
||||
unpacks its squashfs, drops `game.love` in, and glues it back together. That
|
||||
trick is not available here — **LÖVE publishes no aarch64 binary at all.** The
|
||||
11.5 release has win32, win64, macOS, Android, iOS and one x86_64 AppImage,
|
||||
and that is the entire list.
|
||||
|
||||
So this build compiles LÖVE 11.5 from the official `linux-src` tarball and
|
||||
assembles the AppImage from scratch. Every pinned input — the LÖVE source, the
|
||||
five libraries built alongside it, and the AppImage type-2 runtime — is
|
||||
SHA-256 verified on the host before the container ever sees it, and the
|
||||
container itself runs with no network access.
|
||||
|
||||
### Why the build happens in a Debian bullseye container
|
||||
|
||||
glibc is backward compatible but not forward compatible: a binary linked
|
||||
against glibc 2.41 will not start on a system with 2.31, and there is no way
|
||||
to fix that after the fact. Compiling on the oldest base we support is
|
||||
therefore the only thing that makes one artifact work everywhere.
|
||||
|
||||
Bullseye (glibc 2.31) is that base. The resulting binaries actually come out
|
||||
needing only **glibc 2.29** and **GLIBCXX_3.4.21**, so the AppImage covers
|
||||
everything from Ubuntu 20.04 and Raspberry Pi OS bullseye through current
|
||||
trixie.
|
||||
|
||||
This is a statement about the *compile environment*, not about where the
|
||||
artifact runs — building on your own newer distro would silently raise that
|
||||
floor and strand every user on an older one, with no symptom until they
|
||||
download it. CI enforces the floor: `linux-arm64-build` fails if the highest
|
||||
required glibc symbol version climbs above 2.31.
|
||||
|
||||
### Why five libraries are built from source
|
||||
|
||||
SDL2, OpenAL, libtheora, libogg/libvorbis and libmpg123 are compiled rather
|
||||
than installed from bullseye. In every case the reason is *correctness*, not
|
||||
a newer version number — Debian builds these for a system where every
|
||||
dependency is installed and co-versioned, which is the opposite of an
|
||||
AppImage's situation. Each one broke the build in a different way, and all
|
||||
three failure modes are now assertions that fail the build instead of
|
||||
shipping.
|
||||
|
||||
**1. Hard-linked backends (SDL2, OpenAL).** Debian's `libSDL2` lists
|
||||
`libpulse`, `libasound`, `libX11` and `libwayland-client` as `DT_NEEDED` —
|
||||
resolved by the loader at startup, not dlopened. An AppImage bundling it
|
||||
refuses to start unless the host has *all four*. It appeared to work in
|
||||
testing only because a desktop Pi has all four; a headless CI runner is what
|
||||
exposed it. Debian's OpenAL does the same via `libsndio`, which itself
|
||||
hard-links `libasound`. Built from source with `--enable-*-shared` and
|
||||
`ALSOFT_DLOPEN`, both dlopen their backends instead.
|
||||
|
||||
**2. A stray link (libtheora).** Debian's `libtheoradec.so.1` is linked
|
||||
against `libcairo.so.2` — a packaging artifact, since a video decoder has no
|
||||
business drawing vector graphics — and cairo drags in X11, xcb, fontconfig
|
||||
and freetype. `--disable-examples` produces a `libtheoradec` needing only
|
||||
`libogg`.
|
||||
|
||||
**3. SONAME collision with the host (ogg, vorbis, mpg123).** The subtle one.
|
||||
OpenAL dlopens ALSA, ALSA's config loads its PulseAudio hook plugin, and that
|
||||
plugin pulls the *host's* `libsndfile` into our process. `libsndfile` links
|
||||
`libogg`, `libvorbis` and `libmpg123` — the same three we bundle. The loader
|
||||
resolves a SONAME exactly once per process, so the host's `libsndfile` binds
|
||||
to *our* copies:
|
||||
|
||||
```
|
||||
openal -> libasound -> libasound_module_conf_pulse -> libsndfile (host, new)
|
||||
`-> mpg123_info2 -> libmpg123 (ours, bullseye 1.26)
|
||||
```
|
||||
|
||||
`mpg123_info2` arrived in mpg123 1.32, so the plugin failed to relocate, ALSA
|
||||
config collapsed, and the game ran with **no audio device at all**. Not
|
||||
bundling these instead would make `libogg`/`libvorbis`/`libmpg123` mandatory
|
||||
host packages; building them current means our copies *satisfy* the host's
|
||||
`libsndfile` rather than starving it.
|
||||
|
||||
The same collision is why the font stack — freetype, fontconfig, libpng,
|
||||
brotli, zlib — is left to the host entirely. Bundling a bullseye freetype
|
||||
2.10.4 meant a host `libcairo` could not find `FT_Get_Transform` (added in
|
||||
2.11) and the game died at startup. Leaving the whole stack to the host keeps
|
||||
it self-consistent, while `liblove` — compiled against 2.10.4 — only ever
|
||||
asks for symbols every supported host already has.
|
||||
|
||||
The general rule this all reduces to: **never bundle a library the host's own
|
||||
stack may also load, unless yours is at least as new as theirs.**
|
||||
|
||||
### CI
|
||||
|
||||
Three jobs, path-gated on `scripts/build_linux_arm64.sh`,
|
||||
`scripts/linux-arm64/`, `scripts/pack_love.sh` and this document:
|
||||
|
||||
- **`linux-arm64-selftest`** (`ubuntu-latest`, x86_64) — offline gate. Checks
|
||||
the pins are real digests on a dated tag rather than the moving
|
||||
`continuous` one, that the Dockerfile still builds on bullseye, that the
|
||||
exclude list still classifies known sonames correctly, that AppRun still
|
||||
launches `game.love` with `--fused`, and that the host-arch guard actually
|
||||
fires. Needs no container and no arm64 machine.
|
||||
- **`linux-arm64-build`** (`ubuntu-24.04-arm`) — the real build, then extracts
|
||||
the artifact and asserts the layout, that every bundled object resolves
|
||||
under AppRun's `LD_LIBRARY_PATH`, and that the glibc floor is still ≤ 2.31.
|
||||
Uploads the AppImage for 7 days.
|
||||
- **release** — `linux-arm64` runs on `ubuntu-24.04-arm`, reuses the shared
|
||||
`game.love` from the `love-payload` job, and the AppImage is staged and
|
||||
published like every other release asset.
|
||||
|
||||
Unlike the Switch job, none of this needs secrets or self-hosted hardware, so
|
||||
it runs on fork PRs too.
|
||||
|
||||
### Updating the pins
|
||||
|
||||
Both pins live in `scripts/linux-arm64/common.sh`:
|
||||
|
||||
- `LOVE_VERSION` / `LOVE_SRC_SHA256` — bumping any version invalidates the
|
||||
cached prefix automatically (its name is keyed by every source version at
|
||||
once, so a partial rebuild cannot mix vintages). Check that bullseye still
|
||||
has `-dev` packages new enough for the new release; `build_appimage.sh`
|
||||
asserts every optional module actually linked, because LÖVE's `configure`
|
||||
exits 0 and silently drops a module when one is missing.
|
||||
- `SDL2_*`, `OPENAL_*`, `THEORA_*`, `OGG_*`, `VORBIS_*`, `MPG123_*` — the
|
||||
source-built libraries. Bumping these is usually safe and occasionally
|
||||
necessary: `libmpg123` in particular must stay at least as new as what a
|
||||
target host's `libsndfile` expects, which is asserted for `mpg123_info2`.
|
||||
- `APPIMAGE_RUNTIME_TAG` / `APPIMAGE_RUNTIME_SHA256` — always a dated tag
|
||||
from [AppImage/type2-runtime](https://github.com/AppImage/type2-runtime/releases).
|
||||
The selftest fails the build if this ever points at `continuous`.
|
||||
@@ -35,6 +35,16 @@ An edited vanilla map becomes a `mod.content.maps:patch` carrying only the
|
||||
fields that moved; a new map becomes a `:register`. See
|
||||
`docs/new-features.md` and the extension's own README.
|
||||
|
||||
## Read-only map overviews
|
||||
|
||||
`mod.world:mapOverview()` returns collision `rows` at map-cell resolution,
|
||||
optional visual `tileRows` at 2x resolution, and optional `tileDetailRows` at
|
||||
4x resolution. Visual rows contain Game Boy shades from `"0"` (lightest) to
|
||||
`"3"` (darkest); their matching width and height fields describe the grid.
|
||||
`markers` contains active `{ kind, x, y }` points in map-cell coordinates for
|
||||
`warp`, visible `item`, and untaken `hidden` locations. All fields are
|
||||
read-only snapshots; mods choose which layers to render.
|
||||
|
||||
## Rendering pipelines
|
||||
|
||||
Most registries hand the engine *content*. `render_pipelines` hands it
|
||||
@@ -100,6 +110,44 @@ Three rules worth knowing:
|
||||
Returning `nil` from `drawWorld` is a normal answer meaning "not this
|
||||
frame"; the engine draws the vanilla world instead.
|
||||
|
||||
## Variable-size overworld sprites
|
||||
|
||||
The `sprites` registry keeps the vanilla 16x16 grounded walker as its default,
|
||||
but a mod can describe any frame rectangle and anchor for player characters,
|
||||
NPCs, followers, mounts, vehicles, bosses, or other field actors:
|
||||
|
||||
```lua
|
||||
mod.content.sprites:register("SPRITE_COMPANION", {
|
||||
image = "mods/example/companion.png", -- one frame per row
|
||||
frames = 6,
|
||||
walker = true,
|
||||
frameWidth = 32,
|
||||
frameHeight = 32,
|
||||
anchorX = 16, -- frame-relative bottom-center anchor
|
||||
anchorY = 32,
|
||||
})
|
||||
```
|
||||
|
||||
`frameWidth` and `frameHeight` are sheet pixels. `anchorX` and `anchorY` are
|
||||
measured from each frame's top-left; when omitted they default to the frame's
|
||||
horizontal center and bottom edge, so a larger sprite grows upward while its
|
||||
feet stay on the same world cell. Omitting all four fields is exactly the
|
||||
vanilla 16x16 placement. The normal player/NPC/follower draw paths consume
|
||||
these values automatically, including horizontal flips and the fishing pose.
|
||||
|
||||
Custom render pipelines can use the same geometry without reproducing the
|
||||
pose rules:
|
||||
|
||||
```lua
|
||||
local geometry = sprite:getPoseGeometry(facing, walkPhase, stepFlip)
|
||||
-- geometry.quad, .x/.y/.width/.height, .anchorX/.anchorY, .mirror
|
||||
local originX, originY = sprite:getScreenOrigin(px, py, camX, camY)
|
||||
```
|
||||
|
||||
`getFrameGeometry(frame)` is the corresponding accessor for a specific
|
||||
zero-based sheet frame. Both accessors return fresh tables and share the
|
||||
renderer’s frame selection and mirror conventions.
|
||||
|
||||
## Battle sprite scaling
|
||||
|
||||
The enemy's front pic draws at 1x and the player's back pic at 2x, the way
|
||||
@@ -140,6 +188,90 @@ default** (1x front, 2x back).
|
||||
ball-to-pic grow multiplies your scale through each stage, so a rescaled
|
||||
mon still grows into place from the ball, grounded the whole way.
|
||||
|
||||
## Durable tool storage and runtime checkpoints
|
||||
|
||||
`mod.save` remains the right place for state that should travel with the next
|
||||
normal Pokémon SAVE. Tools that need independently written, larger data-only
|
||||
records can use `mod.storage`; the engine scopes every logical key by game
|
||||
version, opaque playthrough identity, and mod id, and routes it through the same
|
||||
standard or portable persistence backend as saves:
|
||||
|
||||
```lua
|
||||
local context, code, message = mod.storage:context(game)
|
||||
local ok, code, message = mod.storage:write(game, "history/quick/q0001", {
|
||||
format = 1, createdAt = os.time(), payload = { money = 3000 },
|
||||
})
|
||||
local value, code, message = mod.storage:read(game, "history/quick/q0001")
|
||||
local keys, code, message = mod.storage:list(game, "history/quick")
|
||||
local deleted, code, message = mod.storage:delete(game, "history/quick/q0001")
|
||||
```
|
||||
|
||||
`context` returns `{ engineVersion, gameVersion, playthroughId }`. The engine
|
||||
version is compatibility metadata; physical launcher-slot and path identity stays
|
||||
private.
|
||||
|
||||
Values must be tables containing serializable data only. Keys are conservative
|
||||
slash-separated segments (letters, digits, `_`, `-`); paths and filesystem
|
||||
handles are never exposed. Writes are staged and decode-verified, reads recover
|
||||
from a valid staged/backup generation, and methods return structured errors for
|
||||
normal data or I/O failures. The playthrough identity is allocated lazily on the
|
||||
first storage/checkpoint call, so an unused API changes no save bytes.
|
||||
|
||||
`mod.checkpoints` captures and reconstructs engine-owned semantic runtime state:
|
||||
|
||||
```lua
|
||||
local capability = mod.checkpoints:inspect(game)
|
||||
if capability.canCapture then
|
||||
local checkpoint, code, message = mod.checkpoints:capture(game)
|
||||
-- Store the detached data-only checkpoint through mod.storage.
|
||||
end
|
||||
|
||||
local ok, code, message = mod.checkpoints:restore(game, checkpoint)
|
||||
```
|
||||
|
||||
Checkpoint format 1 supports settled overworld control and proven battle
|
||||
player-decision safe points. Battle checkpoints are limited to ordinary
|
||||
single-player wild/trainer origins with no suspended script; link, Safari,
|
||||
ghost, demo, scripted, animation, message, queue, and forced-action phases fail
|
||||
closed. New checkpoints preserve gameplay RNG, while legacy overworld records
|
||||
without RNG remain loadable. Capture excludes global options and runtime
|
||||
objects. Restore validates format, game/playthrough identity, content,
|
||||
coordinates, battle relationships, continuation, and RNG before mutation;
|
||||
preserves current options; suppresses normal map-entry/save-load/intro side
|
||||
effects; verifies a recapture; and rolls back runtime plus RNG in memory if
|
||||
reconstruction fails. Callers that need crash recovery should durably capture
|
||||
their own recovery checkpoint before restore.
|
||||
|
||||
Checkpoint ownership follows the persistence model rather than mod identity:
|
||||
|
||||
- canonical `game.save` progress, including every mod's `save.modData` /
|
||||
`mod.save` bucket and data-only fields added to saved Pokémon, rewinds;
|
||||
- global and per-mod options remain at their current values;
|
||||
- independently written `mod.storage` records do not rewind; and
|
||||
- mod-owned runtime objects, references, and caches are never serialized.
|
||||
|
||||
Successful restore emits `checkpoint.restored` only after reconstruction and
|
||||
differential recapture have committed. Mods that cache rewound progress or hold
|
||||
references to reconstructed runtime objects can re-read their own public state
|
||||
and rebuild at that point:
|
||||
|
||||
```lua
|
||||
mod.events:on("checkpoint.restored", function(ev)
|
||||
-- ev.kind is "overworld" or "battle"; ev.game is fully reconstructed.
|
||||
cachedQuestStage = mod.save:get("quest_stage", 0)
|
||||
rebuildRuntimeFor(ev.game, ev.kind)
|
||||
end)
|
||||
```
|
||||
|
||||
The event is not emitted for validation failure, failed reconstruction, or a
|
||||
successful rollback. Its payload contains no checkpoint data or other mod's
|
||||
private state. A mod that deliberately stores progress-coupled truth in
|
||||
`mod.storage` must version and reconcile that relationship itself; the engine
|
||||
cannot distinguish it safely from independent history, configuration, or cache
|
||||
data.
|
||||
|
||||
See RFC 0003, RFC 0004, and RFC 0005 for exact contracts and error codes.
|
||||
|
||||
## Developer console
|
||||
|
||||
Boot with developer mode on to unlock the in-game console and hot-reload
|
||||
@@ -174,6 +306,32 @@ It runs immediately before queued button edges are promoted, so input added by
|
||||
the wrapper is visible during that same fixed step. The callback receives
|
||||
`(next, game, dt)` and must call `next(game, dt)`.
|
||||
|
||||
`input.pointer` delivers uncaptured gameplay pointer events -- touches and
|
||||
real mouse input alike. The callback receives `(next, game, ev)` where `ev`
|
||||
is `{ phase, source, id, x, y, dx, dy, pressure, button }`: `phase` is
|
||||
`"pressed"`, `"moved"`, `"released"` or `"cancelled"`; `source` is `"touch"`
|
||||
or `"mouse"`; `id` is the LÖVE touch id or `"mouse"`; and the coordinates
|
||||
are LOVE window units, the same space `render.hud`'s viewport and the touch
|
||||
overlay lay out in. The on-screen touch controls keep first refusal: a
|
||||
pointer that begins on a virtual control belongs to the pad for its whole
|
||||
lifecycle and never reaches the hook, while one that begins outside stays
|
||||
visible even if it later crosses a control. A real mouse reaches the hook
|
||||
without `POKEPORT_TOUCH` (synthesized `istouch` mouse twins are dropped, so
|
||||
a mobile touch fires once), and focus or visibility loss and input recovery
|
||||
deliver a `"cancelled"` for every pointer the hook saw pressed but not yet
|
||||
released. Return `true` without calling `next` to consume the event.
|
||||
|
||||
`mod.input` presses GB buttons source-safely. `mod.input:tap(game, btn)`
|
||||
queues exactly one `wasPressed` edge for the next fixed step and holds
|
||||
nothing; `local token = mod.input:press(game, btn)` holds the button until
|
||||
`mod.input:release(token)`. Buttons are `up`, `down`, `left`, `right`, `a`,
|
||||
`b`, `start` and `select`. Every press is its own input source inside the
|
||||
engine's multi-source bookkeeping, so releasing a token never clears a hold
|
||||
the keyboard, a controller, the touch overlay or another mod still owns;
|
||||
`release` is idempotent and refuses tokens taken by another mod.
|
||||
Outstanding tokens are released automatically on entry-chunk rollback, hot
|
||||
reload and input recovery.
|
||||
|
||||
`ui.title_menu.items` receives `(next, game, items)` and follows the same
|
||||
decorate-after-`next` convention as `ui.start_menu.items`. It is the safe place
|
||||
for a tool to offer a fresh-session action before gameplay begins.
|
||||
@@ -195,10 +353,76 @@ the finished `worldCanvas` and `uiCanvas` with their SGB `zones` / `worldZones`,
|
||||
`worldActive`, the frame metrics (`ww`, `wh`, `pw`, `ph`, `ox`, `oy`, `vpw`,
|
||||
`vph`, `scale`, `Sx`, `Sy`, `dpiX`, `dpiY`), `renderer:blitCanvas(...)` for a
|
||||
palette-correct blit of either canvas into an arbitrary screen rect, and the
|
||||
`secondScreen` bridge (`available()` / `push(imageData, w, h)` / `setEnabled`)
|
||||
for driving a second physical display. This is what lets a mod lay the two
|
||||
passes out as two stacked Game Boy screens, or push one onto a second screen,
|
||||
without the engine knowing the layout.
|
||||
`secondScreen` bridge (`available()` / `push(imageData, w, h)` / `pollTouch()` /
|
||||
`setEnabled`) for driving a second physical display. `pollTouch()` returns the
|
||||
oldest queued event as `"action,x,y"` in submitted-frame coordinates, or `nil`.
|
||||
This is what lets a mod lay the two passes out as two stacked Game Boy screens,
|
||||
or push one onto a second screen, without the engine knowing the layout.
|
||||
|
||||
`screen.render_visible` receives `(next, state)` while the main screen is being
|
||||
composed. Return `false` to omit that state from drawing, opacity selection and
|
||||
palette-zone ownership. The state remains on the stack and keeps its normal
|
||||
update and input ownership, so a mod can mirror a native menu on another
|
||||
display without reimplementing it. The default is `true`. Treat the wrapper as
|
||||
a pure predicate: the renderer may ask it more than once per frame.
|
||||
|
||||
Scrollable list states expose `state.kind` for use with this hook. Generic
|
||||
lists fall back to their title; PC lists use stable, localization-independent
|
||||
identifiers: `pc_box_withdraw`, `pc_box_deposit`, `pc_box_release`,
|
||||
`pc_box_change`, `pc_item_withdraw`, `pc_item_deposit`, and `pc_item_toss`.
|
||||
|
||||
`battle.bottom_ui_visible` and `battle.status_hud_visible` independently
|
||||
control the battle text/menu layer and the HP/status panels. Both receive
|
||||
`(next, state)` and default to `true`, so vanilla rendering is unchanged.
|
||||
Pushed text boxes also pass through `battle.bottom_ui_visible`; a wrapper that
|
||||
only owns battle presentation should return `false` only for its active battle
|
||||
or text-box state.
|
||||
|
||||
`core.logic_speed` receives `(next, game)` once per `Game:logicSpeed()` call
|
||||
(once per frame). Vanilla behavior resolves the per-category GAME SPEED
|
||||
option (`GameSpeed.CATEGORIES`: overworld/battle/menu) for whichever
|
||||
category `Game.speedCategoryInStack` says is active right now. A mod may
|
||||
call `next(game)` and return its result to pass that resolution through, or
|
||||
return a different number outright to override it for that frame (a bot mod
|
||||
forcing 1X for one route segment, say, regardless of the category or saved
|
||||
option). The result is clamped to the nearest valid `GameSpeed.LEVELS` entry
|
||||
regardless of what a subscriber returns, so a bad value (0, negative, `nil`)
|
||||
cannot destabilize the fixed-step accumulator. This hook runs *after* link
|
||||
play's 1X lock and the `--speed`/equivalent run-argument override, both of
|
||||
which stay unconditional and are never visible to a subscriber.
|
||||
|
||||
Developer mode also arms the mod loader's dev tripwire, which flags mods
|
||||
that reach outside their permission set.
|
||||
|
||||
## Process-lifecycle hooks
|
||||
|
||||
These exist so a platform-specific launcher integration (a native shell
|
||||
that embeds this engine and wraps its window in platform UI) can live
|
||||
entirely in a mod instead of hand-patching `main.lua`, which every other
|
||||
engine change also touches.
|
||||
|
||||
`core.update` receives `(next, game, dt)` once per frame from
|
||||
`love.update`. Vanilla behavior is `game:update(dt)`, unconditionally. A
|
||||
mod may skip calling `next(game, dt)` to pause the simulation for that
|
||||
frame (e.g. while a native settings sheet is on top), and may run
|
||||
additional per-frame polling before or after that call regardless of
|
||||
whether it calls `next` -- useful for one-shot flags that must be observed
|
||||
every frame even while paused.
|
||||
|
||||
`core.quit_to_launcher` receives `(next)` once from `love.quit()`. `next()`
|
||||
returns the engine's own decision for whether closing the window should
|
||||
return to the Lua launcher instead of exiting; a mod may return `false`
|
||||
outright, without ever calling `next`, to veto that and let the process
|
||||
really quit -- for a platform host that owns its own "return to launcher"
|
||||
UI and would otherwise get looped straight back into the game it just
|
||||
quit.
|
||||
|
||||
A manifest may also declare `force_enable_env`, an environment variable
|
||||
name that re-enables the mod regardless of a saved disable in
|
||||
`options.mods` when that variable is set to `"1"`. This is for a mod that
|
||||
cannot function disabled on the one build where its env var is set (a
|
||||
platform-bridge mod bundled only with that build's launcher, for example).
|
||||
|
||||
Neither hook needs a `Runtime.wantsHook` guard before calling it: `Hooks:call`
|
||||
already falls straight through to the vanilla function when no mod has
|
||||
wrapped the name, at negligible cost.
|
||||
|
||||
@@ -1,510 +1,26 @@
|
||||
# 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.
|
||||
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.
|
||||
|
||||
## Survey zoom
|
||||
|
||||
The mouse wheel (or `-`/`=`), the Options **ZOOM** row, or hotkey `4`
|
||||
zooms the overworld between 1 pixel per world pixel (full survey) and 2×
|
||||
the window fit scale (close-up), in crisp integer steps. This has no Game
|
||||
Boy equivalent:
|
||||
|
||||
- Connected maps render their full bodies, and their NPCs appear as
|
||||
visual-only "ghosts", they wander but have no sight lines, triggers,
|
||||
dialogue, or collision until the map is actually entered.
|
||||
- Menus, text boxes, and battles draw at normal scale on top of the
|
||||
zoomed world. Zoom input is ignored while a script, menu, or battle is
|
||||
active; the zoom offset is persisted as `save.options.zoom` (default
|
||||
`0` = FIT) and survives New Game via `options.lua`.
|
||||
- Hotkey `4` ticks through every integer zoom level (survey → FIT →
|
||||
close-up → wrap). The Options row shows `FIT` / `OUTn` / `INn`.
|
||||
- Beyond the border ring the void fill repeats indefinitely (see VOID
|
||||
FILL below); interiors keep their own border block. Each visible map
|
||||
area is colorized with its own SGB palette (the original recolored the
|
||||
whole screen per map).
|
||||
- Neighbor maps load two connection hops out so corner-adjacent maps
|
||||
don't pop in and out, and ghost NPCs share instances with the real ones
|
||||
so their wander positions persist across seamless connection crossings
|
||||
(a warp or fresh map entry still respawns everything at its script
|
||||
position, like the original's per-entry sprite init).
|
||||
|
||||
## VOID FILL
|
||||
|
||||
The Options **VOID FILL** row picks what paints the infinite beyond-edge
|
||||
space on OVERWORLD-tileset maps during survey zoom:
|
||||
|
||||
- **TREES** (default): solid tree wall block `$0F`.
|
||||
- **WATER**: animated water tile `$14` (same hshift cycle as on-map water).
|
||||
- **BLACK**: solid black.
|
||||
|
||||
Other tilesets are unchanged (house/cave borders stay as authored).
|
||||
Persisted as `save.options.voidFill`.
|
||||
|
||||
## Tilt mode
|
||||
|
||||
The `3` key (and the Options menu TILT row) cycles a visual-only perspective
|
||||
tilt of the overworld through **OFF → 15° → 35° → 50° → OFF** for an HD-2D /
|
||||
diorama look. Like survey zoom this is purely presentational and has no
|
||||
Game Boy equivalent:
|
||||
|
||||
- The entire map tilts as one rigid ground plane, paths, grass, water,
|
||||
floors, and every background-tile structure (buildings, trees, fences,
|
||||
signs; in Gen 1 these are baked into the tile layer, not sprites), so
|
||||
rows above the player recede and rows below come toward the viewer. Only
|
||||
things that actually *stand* on the ground draw as upright billboards,
|
||||
unscaled and pixel-identical to flat mode: the player, NPCs, item balls,
|
||||
and the standing FX attached to them (emote bubbles, the fishing rod,
|
||||
the FLY bird). The Poké Center heal-machine overlay stays on the ground
|
||||
plane with the machine tiles (it is OAM glued to a BG graphic, not a
|
||||
standing sprite). An earlier revision tried
|
||||
billboarding buildings/trees/signs too (cutting them out of the ground
|
||||
per hand-curated per-tileset tables); that chased an endless tail of
|
||||
special cases, dense tree canopy, fences fused into grass, building
|
||||
facades with their own baked-in fake perspective, because Gen 1's art
|
||||
was never drawn with a clean seam between ground and standing scenery. It
|
||||
wasn't merged; tilting everything but the characters as one plane is the
|
||||
simpler, shipped tradeoff (buildings recede/foreshorten with the ground
|
||||
like a photo of a diorama, rather than standing fully upright next to
|
||||
a full-height character).
|
||||
- Cycling tweens the angle between levels over ~0.25s rather than snapping;
|
||||
with tilt fully off the world pass drops back onto the flat blit path, so
|
||||
flat rendering stays pixel-identical to tilt-off and off costs nothing.
|
||||
- Tilt input is gated exactly like survey zoom, honored only while
|
||||
free-roaming, ignored while a script, menu, or battle is active, and it
|
||||
composes with survey zoom (the zoom scale feeds the projection). The tilt
|
||||
level is persisted in `save.options.tilt` (default OFF).
|
||||
- It applies everywhere the overworld draws, interiors and caves included.
|
||||
Menus, text boxes, and battles render flat on top, unaffected, and the
|
||||
infinite beyond-the-border-ring fill stays flat by design.
|
||||
- Collision, movement, sight lines, triggers, encounters, and scripts are
|
||||
untouched; nothing about the tilt reaches gameplay.
|
||||
|
||||
## Colors mode
|
||||
|
||||
The `2` key (and the Options menu COLORS row) cycles the display mode
|
||||
through **OG RED → SGB → ADVANCED → OG → OG INV → SGB INV → CLASSIC → OG RED**
|
||||
(on Blue the first slot labels **OG BLUE**; on Yellow, **OG YELLOW**).
|
||||
The first three are the real colorizations; the rest are DMG-shade novelties:
|
||||
|
||||
- **OG RED** / **OG BLUE**: the Game Boy Color boot-ROM look for that cart --
|
||||
one global BG palette + one OBJ palette, every map, no per-map variation
|
||||
(Red/Blue ship no CGB code, so on a GBC the boot ROM colors them globally).
|
||||
The player/NPCs keep the boot-ROM OBJ color over the terrain via the OBP
|
||||
bake + post-zone redraw (`PaletteFX.GBC_BG` / `GBC_OBJ`, or Blue's blue/pink
|
||||
pair).
|
||||
- **OG YELLOW** (Yellow playthrough, same `ogred` save id): Pokemon Yellow's
|
||||
authentic GBC look from `CGBBasePalettes` (`data/palettes_yellow.lua`,
|
||||
sourced from pret/pokeyellow). Per-map / per-species colors, not a single
|
||||
boot-ROM ramp -- Yellow was CGB-enhanced.
|
||||
- **SGB** (default): the per-map Super Game Boy region palettes
|
||||
(`data/sgb/sgb_palettes.asm`). Sprites tint with the region palette, as on
|
||||
real SGB. (This is the mode formerly mislabeled "GBC".)
|
||||
- **ADVANCED**: pokered-gbc SuperPalettes -- real per-tile GBC coloring plus
|
||||
per-species mon colors (`data/palettes_gbc.lua`). (Formerly labeled
|
||||
"RED++"; it is the richest colorization rather than anything Red-specific.)
|
||||
- **OG**: force the four DMG grays (colorization off).
|
||||
- **OG INV**: inverted DMG grays.
|
||||
- **SGB INV**: each SGB zone palette with shade order reversed.
|
||||
- **CLASSIC**: original Game Boy pea-soup greens
|
||||
(`#9BBC0F` / `#8BAC0F` / `#306230` / `#0F380F`).
|
||||
|
||||
The shade-remap transform is applied centrally in `PaletteFX.sendColors`, so
|
||||
it covers overworld, menus, battles, and tilt upright billboards. OG RED's
|
||||
global BG palette is supplied by `OverworldState:overworldBgColors` (per-map
|
||||
override in the overworld pass). Persisted as `save.options.colors`; the
|
||||
`gbc` / `gbc_inv` / `redpp` save ids are kept for back-compat under the new
|
||||
labels.
|
||||
|
||||
## GBC FX
|
||||
|
||||
The `5` key (and the Options menu GBC FX row) cycles a "played on real
|
||||
unlit-GBC hardware" post-process through **OFF → 1 → 2 → 3 → 4**. The
|
||||
levels are a cumulative ladder:
|
||||
|
||||
- **1**: reflective-screen backing transparency.
|
||||
- **2**: + LCD pixel grid.
|
||||
- **3**: + pixel drop shadows.
|
||||
- **4**: + sunlight glare and rainbow shimmer with a drifting light.
|
||||
|
||||
It runs as a final present pass after world + UI composite in
|
||||
`Renderer:endFrame`, inspired by the Pixel Transparency RetroArch shader
|
||||
([github.com/mattakins/Pixel_Transparency](https://github.com/mattakins/Pixel_Transparency)).
|
||||
Default OFF; persisted as `save.options.gbcfx`.
|
||||
|
||||
Mobile GPUs often compile the pass but present a black frame, so Android and
|
||||
iOS hide the row entirely, pin the level to OFF, and rewrite a level already
|
||||
persisted in `options.lua` (issue #136). `POKEPORT_GBCFX` overrides that
|
||||
decision either way, same tri-state as `POKEPORT_TOUCH`: `=0` refuses the
|
||||
effect, `=1` forces it available. The Anbernic handheld pack exports `0` from
|
||||
its launcher because the device reports `"Linux"` while its GPU is in the
|
||||
phone class (see [Anbernic RG34XXSP](anbernic-rg34xxsp.md)).
|
||||
|
||||
## Performance tier (low-end devices)
|
||||
|
||||
The Options **PERFORMANCE** row scales the port's optional presentation
|
||||
extras down for weaker hardware. The extras it governs are the three
|
||||
heaviest things the port adds on top of the original -- the whole-screen 3D
|
||||
**TILT** (transforms the entire map as a ground plane), the **GBC FX**
|
||||
post-process shader (a fullscreen pass), and survey **ZOOM** (zooming out
|
||||
renders the connected neighbor maps, a lot of extra overdraw) -- plus a hard
|
||||
FPS ceiling. None of this touches game logic, which is fixed-step off `dt`
|
||||
(`src/core/FixedStep.lua`), so every tier plays identically; they differ
|
||||
only in how much eye-candy the renderer is allowed to do.
|
||||
|
||||
| Tier | TILT | GBC FX | Survey ZOOM | Extra FPS ceiling |
|
||||
| ------------ | ---- | ------ | ----------- | ----------------- |
|
||||
| **HIGH** | on | on | on | none |
|
||||
| **BALANCED** | off | off | on | none |
|
||||
| **LOW** | off | off | off | 60 |
|
||||
| **AUTO** | picks a default from the device (below) |||
|
||||
|
||||
- **AUTO** (the default) reads the device once at boot: ARM Linux handhelds
|
||||
(e.g. the RG34XXSP) resolve to **LOW**, phones/tablets and very-low-core
|
||||
desktops to **BALANCED**, and everything else -- a normal desktop, and
|
||||
every existing `options.lua` that predates this option -- to **HIGH**,
|
||||
so the common case is unchanged. See `src/core/Performance.detect`.
|
||||
- AUTO only chooses the *default*; all four tiers are selectable, so a
|
||||
wrong guess is one row away from being overridden.
|
||||
- The clamps are applied **live** against your stored options and never
|
||||
rewrite them (`Game:applyOptions`), so a lower tier hides your TILT / GBC
|
||||
FX / ZOOM without forgetting them -- raising the tier restores exactly
|
||||
what you had. (This is why the TILT / GBC FX / ZOOM rows still show your
|
||||
saved choice on a clamped tier: it's your preference, waiting for a tier
|
||||
that can afford it.)
|
||||
- Persisted as `save.options.performance` (`auto` | `high` | `balanced` |
|
||||
`low`); unit-tested in `tests/engine/performance_tiers.lua`.
|
||||
|
||||
## Peer-to-peer link play (lua-enet)
|
||||
|
||||
Trades and link battles connect two copies of the game directly over
|
||||
lua-enet (ENet ships inside LÖVE, nothing to install, no server to run)
|
||||
on a reliable-ordered channel, replacing the original standalone Python
|
||||
room-code relay (`tools/relay_server.py`, deleted). HOST A GAME shows the
|
||||
host's LAN address (UDP 7777; `POKEPORT_LINK_PORT` overrides); JOIN A
|
||||
GAME enters it. Closing performs a graceful ENet disconnect so the final
|
||||
confirm/bye always lands; a vanished peer exits with "The link was
|
||||
broken." Internet play needs a forwarded UDP port or a VPN (deliberate
|
||||
tradeoff vs. the relay). Headless tests drive the protocol over an
|
||||
in-memory loopback (`Net.loopbackPair`); under LÖVE the same test file
|
||||
also exercises real UDP pairing.
|
||||
|
||||
Red, Blue, and Yellow copies link with each other, as the real cable
|
||||
does. The compatibility fingerprint hashes only data a link mode can
|
||||
actually read, so Yellow's Dragonair/Dragonite catch-rate retunes (the
|
||||
only R/B/Y link-surface difference) no longer read as different games
|
||||
(issue #511). Moving the fingerprint is a link parity change: builds
|
||||
from before this fix will refuse to pair with builds after it.
|
||||
|
||||
## Fair play in link and online matches
|
||||
|
||||
A link session is decided by the battle and nothing else, so for its
|
||||
duration:
|
||||
|
||||
- **Game speed is pinned to normal.** The GAME SPEED option and
|
||||
`POKEPORT_SPEED` are ignored from the moment LINK PLAY opens until it
|
||||
closes, and apply again after. Fast-forward otherwise runs one peer's
|
||||
queue faster than the peer it is locked to and drains a tournament shot
|
||||
clock faster than the opponent racing it.
|
||||
- **Online play runs vanilla, except for your language.** Picking ONLINE
|
||||
MATCH or TOURNAMENT with mods enabled offers to switch the gameplay ones
|
||||
off and relaunch (mods merge at boot, so a restart is the only way). The
|
||||
restart is confirmed, not silent. They stay listed as disabled, ready to
|
||||
switch back on. A mod that declares itself a translation and provably
|
||||
writes nothing but text stays on: the two games hash the same link
|
||||
surface, so a Spanish install and an English one can battle and trade,
|
||||
each reading the game in its own language and naming the other player's
|
||||
party out of its own text.
|
||||
- **Only a meaningful split ends a match.** The per-turn state signature
|
||||
both peers exchange is split three ways: `actives` and `bench` carry
|
||||
species, HP, status, stat stages, PP and the rest of the party, and a
|
||||
divergence there ends the match as a draw. `volatile` carries per-turn
|
||||
flags both sides recompute anyway - a divergence there is logged and
|
||||
reported to mods, and play continues.
|
||||
|
||||
The relay logs which component diverged on which turn, so a desync report
|
||||
names something specific.
|
||||
|
||||
## Custom boot text
|
||||
|
||||
The boot sequence replaces the Nintendo / GAME FREAK identifiers with
|
||||
"bois club" / "bryanthaboi", a deliberate branding customization. The
|
||||
rest of the boot beats (copyright splash, "presents" shooting-star, the
|
||||
Nidorino-vs-Gengar attract scene) mirror the original.
|
||||
|
||||
|
||||
## Custom Options
|
||||
|
||||
Options persist in a standalone `options.lua` (separate from the game
|
||||
progress `save.lua`), so audio/display/battle preferences survive New Game
|
||||
and aren't wiped when a save slot is cleared. Changing a row in the Options
|
||||
menu or cycling hotkeys `2`/`3`/`4`/`5` writes immediately; an in-game save also
|
||||
flushes the live options. Old saves that still embed an `options` table are
|
||||
migrated once into `options.lua` on load.
|
||||
|
||||
- Music / SFX volume
|
||||
- PIKACHU VOL (0-7, Yellow only): trims Pikachu's PCM voice clips under the
|
||||
SFX level, so the follower's constant chatter, the title-screen cry and
|
||||
every in-battle "Pika!" can be pulled down (or muted at 0) without
|
||||
quieting the rest of the sound effects. The row is hidden on Red/Blue,
|
||||
which have no voice clips.
|
||||
- Music Filter
|
||||
- OG GLITCHES on / off (Gen 1 quirks vs. modern-clean battle rules)
|
||||
- BATTLE LAYOUT (OG / WIDE); see "Widescreen battle layout" below
|
||||
- COLORS (OG RED / SGB / ADVANCED / OG / OG INV / SGB INV / CLASSIC), also
|
||||
hotkey `2` (OG RED = GBC boot-ROM look; ADVANCED uses pokered-gbc
|
||||
SuperPalettes + per-species mon colors)
|
||||
- TILT (OFF / 15 / 35 / 50), also hotkey `3` while free-roaming
|
||||
- ZOOM (FIT / OUTn / INn), also hotkey `4` while free-roaming; wheel and
|
||||
`-`/`=` step one level and save
|
||||
- VOID FILL (TREES / WATER / BLACK) for OVERWORLD beyond-edge space
|
||||
- GBC FX (OFF / 1 / 2 / 3 / 4), also hotkey `5`
|
||||
- MAX FPS (30 / 40 / 50 / 60 / 75 / 90 / 100 / 120 / 144 / 160, default 60),
|
||||
a hard render frame-rate cap (`save.options.fpsCap`).
|
||||
|
||||
## Battle transition cascade + white battle letterbox
|
||||
|
||||
Into-battle wipes still run the original eight styles inside the classic
|
||||
160×144 letterbox. On wide/tall windows (survey zoom), matching black 8×8
|
||||
blocks cascade outward from that square into the surrounding world so the
|
||||
void outside the OG wipe fills in lockstep. Once the battle state is up,
|
||||
letterbox voids around the battle canvas fill **white** instead of black
|
||||
so the whole window reads as one continuous battle screen.
|
||||
|
||||
## Widescreen battle layout
|
||||
|
||||
Options **BATTLE LAYOUT** picks the battle screen's composition: **OG**
|
||||
(the default: the original 160×144 arrangement, unchanged) or **WIDE**,
|
||||
which gives battles a 304×144 native-pixel surface and a Gen 3-style
|
||||
arrangement on it:
|
||||
|
||||
- the foe's status box upper left, the foe's picture upper right;
|
||||
- the player's picture lower left, the player's status box lower right,
|
||||
with a longer HP bar and the numeric HP under it;
|
||||
- a full-width message window;
|
||||
- a split "What will X do?" prompt / 2×2 command window;
|
||||
- a 2×2 move menu, navigated with all four directions, with a PP and type
|
||||
panel attached to its right.
|
||||
|
||||
Only the composition changes. Pictures, palettes, HP-bar colors, font
|
||||
pages, window borders, sounds, animations, timing and every battle rule
|
||||
stay the engine's, so a COLORS mode or an asset mod still owns the look.
|
||||
Each side's picture keeps its original pixels and placement math and is
|
||||
composited into its own region of the wider battlefield -- nothing is
|
||||
scaled or squeezed -- and animations, which are authored in the original
|
||||
160-pixel space, shift as one rigid group onto whichever side they play
|
||||
on. The whole screen is drawn at the window's integer fit scale for the
|
||||
wider surface, so a 304-pixel screen is drawn a step smaller than a
|
||||
160-pixel one in the same window.
|
||||
|
||||
The wide surface is live only while the battle itself is the screen on
|
||||
top: a party menu, the bag or a nickname prompt is a 160×144 screen and
|
||||
brings the classic surface back with it.
|
||||
|
||||
## On-screen touch controls (mobile)
|
||||
|
||||
On Android/iOS the game draws a translucent d-pad (bottom-left), A/B
|
||||
buttons (bottom-right, Game Boy diagonal), and +/- START/SELECT (bottom
|
||||
center) over the frame, using Xelu's CC0 controller prompts
|
||||
(`assets/touch/`). Real buttons, not gestures: press lands the frame the
|
||||
finger does, sliding on the d-pad changes direction without lifting, and
|
||||
multi-touch chords (e.g. hold a direction + tap B) work. The overlay only
|
||||
appears while no controller is being used: the first gamepad button or
|
||||
stick push hides it, the next screen touch brings it back, and unplugging
|
||||
the last controller restores it immediately. Layout re-derives from the
|
||||
window size on rotation. Desktop testing: `POKEPORT_TOUCH=1 love .` forces
|
||||
the overlay on and lets the mouse act as a finger (`=0` forces it off).
|
||||
|
||||
The launcher's **Touch Controls** button opens a drag editor: move each
|
||||
button freely, resize the whole pad with **-/+** (60% to 160%), **Disable**
|
||||
to hide the overlay permanently (for controllers / emulation handhelds --
|
||||
distinct from the temporary gamepad auto-hide), **Reset** for defaults,
|
||||
**Done** to save into `options.lua` as normalized window fractions so a
|
||||
different screen keeps the relative placement.
|
||||
|
||||
Portrait and landscape are edited and saved separately (#633): the editor
|
||||
follows whichever orientation is on screen, and **Reset** only clears that
|
||||
one, so a layout that works held upright does not have to double as the
|
||||
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.
|
||||
|
||||
## Translation support
|
||||
|
||||
Every string the player can read is now reachable from a mod, so a
|
||||
translation is an ordinary content mod rather than a fork.
|
||||
|
||||
Two things had to change. Text layout stopped counting bytes: the dialogue
|
||||
box measures a line in glyphs (charmap sequences), so a 3-byte character
|
||||
costs one column, a cut never lands inside a character, and a page with a
|
||||
non-default `advance` re-measures instead of overflowing. That also fixed
|
||||
25 vanilla English lines that were wrapping early because `é` in POKéMON
|
||||
and POKéDEX costs two bytes ("I study POKéMON as" is 19 bytes and 18
|
||||
glyphs, and the box was breaking it).
|
||||
|
||||
Second, the text the engine writes itself - battle messages, item results,
|
||||
menu labels, the link-play screens - moved behind `src/core/Strings.lua`
|
||||
and the new `strings` registry. Extracted script text was already
|
||||
overridable through `text`; this covers the other half. Entries are keyed
|
||||
by the English source, so a translation that has not reached a string yet
|
||||
keeps rendering in English and a half-finished translation stays playable.
|
||||
|
||||
Authors generate the whole thing:
|
||||
|
||||
```sh
|
||||
python3 tools/modkit.py translation francais --language "Francais"
|
||||
```
|
||||
|
||||
That scaffolds a mod with every translatable string as an empty catalog,
|
||||
plus a glyph-page and charmap stub, a naming-grid stub, and a
|
||||
`francais-worksheet/` directory holding the English to translate from
|
||||
(deliberately outside the mod: extracted text is ROM content and must not
|
||||
be packed). `--refresh` re-harvests after an engine update, keeping
|
||||
existing translations and parking orphaned keys rather than dropping them.
|
||||
|
||||
See the wiki's Translations guide.
|
||||
|
||||
## Save editor (bundled, reachable from the launcher)
|
||||
|
||||
The save editor ships inside every build instead of being a developer-only
|
||||
script, and the launcher's SAVE SLOT card grows an **Edit** label next to
|
||||
Delete on every slot that actually holds a save. Edit suspends the
|
||||
launcher, opens that slot's file in the editor, and **Close** hands the
|
||||
process back to the launcher with the slot list re-read (a rename, a badge
|
||||
or a dex change shows up on the row immediately). Unsaved edits arm a
|
||||
confirm first, so leaving cannot lose work. `love . --editor` still opens
|
||||
it standalone, where Close quits instead; `--save <path>` points it at any
|
||||
file, and a save can be dragged onto the window.
|
||||
|
||||
The editor now wears the launcher's visual language - the same navy radial
|
||||
field, 16px translucent cards, tri-colour version rail and green/yellow/red
|
||||
semantics - so the two windows read as one app. Six tabs:
|
||||
|
||||
- **Party**: the roster with sprites, HP bars and level chips on the left,
|
||||
and the mon inspector permanently docked on the right instead of floating
|
||||
over the list. Species, level, DVs and moves all round-trip through the
|
||||
Gen 1 formulas, so the inspector can never show illegal stats.
|
||||
- **Boxes**: the 12 PC boxes as a 5x4 grid with a fill meter per box and a
|
||||
party dock, so deposit and withdraw live in one place. Empty slots are
|
||||
clickable and create a mon there.
|
||||
- **Items**: money, a searchable item picker (replacing the arrows that
|
||||
cycled one id at a time through ~250 items), the configurable bag (20 slots
|
||||
by default), PC storage
|
||||
with no slot cap, and the eight badges as toggle chips. The picker, the bag
|
||||
and PC storage all scroll under the mouse wheel, so the whole catalog is
|
||||
reachable one-handed without typing a query.
|
||||
- **Events**: flags, defeated trainers, taken items and per-map object
|
||||
toggles, with a real filter field and a two-column paged grid.
|
||||
- **Map**: any map rendered with the game's own renderer, warps followable,
|
||||
and the player / lastHeal / lastOutdoor spawn points settable by clicking
|
||||
a cell. Setting lastOutdoor on a map the game would not accept as an
|
||||
outdoor source is refused with the reason.
|
||||
- **Dex**: seen / owned completion meters and a four-column grid; owning
|
||||
implies seen and un-seeing clears owned, exactly as the game requires.
|
||||
|
||||
Two rules run through all of it. Every mutation goes through one funnel
|
||||
that sets the dirty flag and writes the status line together, so nothing
|
||||
changes silently and no branch can quietly no-op - "Party is full", "Bag is
|
||||
full", "click a cell first" all say so. And every destructive verb (Remove,
|
||||
Release, Clear all, Wipe dex) arms on the first click and commits on the
|
||||
second, relabelling itself to `Confirm?` in between.
|
||||
|
||||
A validation pill in the tab rail mirrors what the running game would
|
||||
quarantine on load; clicking it jumps to the tab holding the first problem.
|
||||
|
||||
## Tiled map editing (mod authoring)
|
||||
|
||||
`tools/tiled_export.py` turns the imported ROM cache into a Tiled workspace,
|
||||
so maps can be edited in a real map editor and exported back out as a mod.
|
||||
It has its own document: docs/tiled-map-editing.md.
|
||||
|
||||
## Pokédex diploma (both versions)
|
||||
|
||||
The Celadon Mansion 3F game designer shows the dex-completion diploma
|
||||
once 150 species are owned. On Yellow, the graphic artist next to him
|
||||
then offers to print it, saving the certificate as a PNG under `prints/`
|
||||
in the save directory, and Bill's PC gains Yellow's PRINT BOX item which
|
||||
exports the current box list the same way.
|
||||
|
||||
## Pokédex printing (Yellow)
|
||||
|
||||
Yellow's Game Boy Printer PRNT option in the Pokédex side menu is stood in
|
||||
for by an image export: choosing PRNT renders the mon's entry page (sprite,
|
||||
kind, number, height/weight, dex text) to a PNG at 4x scale under
|
||||
`prints/` in the save directory, then reports the filename in a dialog.
|
||||
No printer hardware or link cable emulation involved; the file is the
|
||||
printout.
|
||||
|
||||
## Find Mods (community mod indexes)
|
||||
|
||||
A FIND MODS tab sits beside MODS in the launcher and browses a published
|
||||
mod index: a metadata-only feed listing mods that live in their authors'
|
||||
own repositories. No index ships with the launcher and none is ever added
|
||||
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.
|
||||
|
||||
## Soft reset (all versions)
|
||||
|
||||
Holding A, B, START and SELECT together restarts the game the way flicking
|
||||
a Game Boy's power switch did, dropping straight back to the title screen.
|
||||
It works from anywhere, including mid-battle, which the QUIT entry on the
|
||||
start menu cannot do: the original combo is how stationary and gift
|
||||
Pokemon get their stats rerolled without sitting through a full relaunch.
|
||||
Unsaved progress is discarded, exactly as on hardware.
|
||||
|
||||
As on the original, the four buttons have to stay held for 16 straight
|
||||
polls (better than a quarter of a second) and any direction in the mix
|
||||
cancels it, so it is hard to hit by accident -- including on the on-screen
|
||||
touch controls, where it would take four fingers held on four separate
|
||||
controls.
|
||||
|
||||
## Controls rebinding (CONTROLS screen)
|
||||
|
||||
OPTIONS -> CONTROLS lists every Game Boy button with its current keyboard
|
||||
key and controller button side by side (Z/A). Press A on a row, then press
|
||||
and release the key or pad button you want; the rebind commits on the
|
||||
release. If that input already belongs to another row, the two rows swap,
|
||||
so no button is ever stranded without an input and no input ever serves
|
||||
two buttons. Holding a second key or pad button while the first is still
|
||||
down backs out of the capture without touching a keyboard; Escape still
|
||||
cancels too. SELECT clears one row back to its default, and START resets
|
||||
every binding after a confirmation.
|
||||
|
||||
Controllers a system has no mapping for (common on Linux handhelds and
|
||||
off-brand pads) report bare button numbers rather than names. Those are
|
||||
rebindable on the same screen and show up as JOY1, JOY2 and so on in the
|
||||
controller column. Recognized controllers are read only through their
|
||||
named buttons, so a rebind on those is never shadowed by the factory
|
||||
layout underneath it.
|
||||
|
||||
## Mod profiles (#593)
|
||||
|
||||
The mod manager's PROFILES tab holds named setups. A profile remembers which
|
||||
mods are on, every mod's own options, and which save slot each game version
|
||||
plays, so swapping profiles swaps the whole playthrough and not just the mod
|
||||
list. The setup that existed before profiles shipped becomes PROFILE 1 the
|
||||
first time the manager opens.
|
||||
|
||||
EXPORT.. writes the selected profile to `profiles/<NAME>.g1rmodlist` in the
|
||||
save directory; drop a `.g1rmodlist` someone shared into that folder and
|
||||
IMPORT.. adds it. Imported profiles never overwrite an existing one (a name
|
||||
clash gets a number). Mods the shared profile names but that are not installed
|
||||
are reported when the profile is applied; installing them is still a manual
|
||||
trip through the mods list or Find Mods.
|
||||
|
||||
## Windows: no console windows on launcher actions
|
||||
|
||||
Checking for updates, browsing a mod index, adding a mod repo, installing a
|
||||
mod and picking a ROM all run a host tool (curl, PowerShell) in a child
|
||||
process. On Windows those children used to each open their own console
|
||||
window, so a session could end up buried under half a dozen of them. The
|
||||
game now claims one console for itself at boot and hides it; the children
|
||||
inherit that invisible console and nothing pops up. Nothing else changes:
|
||||
file pickers are ordinary desktop dialogs and still appear normally, and a
|
||||
run started from a terminal (`lovec.exe`, what `scripts\run.ps1` prefers)
|
||||
keeps its terminal and its printed output. Set `POKEPORT_CONSOLE=1` to opt
|
||||
out.
|
||||
# New Features
|
||||
|
||||
Features intentionally added beyond the original Pokémon Red, Blue, and Yellow games:
|
||||
|
||||
* **Survey zoom** with connected-map rendering and configurable void fill
|
||||
* **Perspective tilt mode** for an HD-2D-style overworld
|
||||
* **Multiple color modes**, including original, SGB, advanced GBC, monochrome, and classic green
|
||||
* **Optional GBC screen effects**, including pixel grids, shadows, glare, and transparency
|
||||
* **Performance presets** and configurable FPS limits
|
||||
* **Peer-to-peer link play** for trades and battles between Red, Blue, and Yellow
|
||||
* **Persistent custom options** stored separately from game saves
|
||||
* **Optional widescreen battle layout**
|
||||
* **Mobile touch controls** with editable layouts, vibration, and orientation settings
|
||||
* **Translation and custom font support**
|
||||
* **Built-in save editor** for parties, boxes, items, events, maps, and Pokédex data
|
||||
* **Tiled map editing tools** for mod authors
|
||||
* **Pokédex diploma and printer image exports**
|
||||
* **Community mod browser**
|
||||
* **Soft reset button combination**
|
||||
* **Keyboard and controller rebinding**
|
||||
* **Mod profiles** with separate mod settings and save slots
|
||||
* **Improved launcher and save editor UI**, including background downloads and update checks
|
||||
* **Direct-launch options** for shortcuts, Steam entries, and handheld frontends
|
||||
* **Custom boot branding**
|
||||
|
||||
Actual approximations, and missing original behavior are documented separately in `docs/known-differences.md`.
|
||||
|
||||
@@ -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,54 @@
|
||||
# RFC 0002 — Let mods hide an active screen state from the main render
|
||||
|
||||
## Status
|
||||
|
||||
Proposed. Engine: `StateStack.lua`, `Game.lua`. Tests:
|
||||
`screen_render_visible.lua`.
|
||||
|
||||
## Motivation
|
||||
|
||||
A mod can render a native menu on a companion display through
|
||||
`render.compose`, but it cannot remove that menu from the main display without
|
||||
also popping it. Popping transfers update and input ownership and forces the
|
||||
mod to reimplement native menu behavior.
|
||||
|
||||
## The decision it extends
|
||||
|
||||
No prior D-number. Extends the render-hook plan in `docs/modding.md` and the
|
||||
state-stack rendering contract in `docs/architecture.md`.
|
||||
|
||||
## The exact API delta
|
||||
|
||||
Backward-compatible, additive-only.
|
||||
|
||||
### `screen.render_visible`
|
||||
|
||||
New hook called with `(state) -> boolean` through the public wrapper signature
|
||||
`(next, state)`. Its vanilla result is `true`.
|
||||
|
||||
Returning `false` excludes the state from the main draw, from opaque-base
|
||||
selection and from palette-zone ownership. It does not remove the state or
|
||||
change update, input, push or pop behavior. The call sites are
|
||||
`StateStack:visibleBase`, `StateStack:draw` and the equivalent draw and palette
|
||||
walks in `Game:draw`.
|
||||
|
||||
The hook is guarded by `Runtime.wantsHook`, so the no-subscriber path allocates
|
||||
nothing. It is a pure render predicate and may be evaluated more than once per
|
||||
frame.
|
||||
|
||||
## Migration note for existing mods
|
||||
|
||||
**Nothing.** With no subscriber every state remains visible, and the existing
|
||||
state-stack, event and hook behavior is unchanged.
|
||||
|
||||
## Parity tests
|
||||
|
||||
- **No-mod:** the topmost opaque state still owns drawing and palette zones,
|
||||
and `Runtime.wantsHook("screen.render_visible")` stays false.
|
||||
- **Mod-API:** a fixture mod registers through `mod.hooks:wrap`, hides one
|
||||
opaque state and proves the state beneath draws and owns the palette while
|
||||
the hidden state remains topmost and continues updating.
|
||||
|
||||
## Deprecation etiquette
|
||||
|
||||
Nothing deprecated. This is one additive hook with a `true` vanilla default.
|
||||
@@ -0,0 +1,114 @@
|
||||
# RFC 0003 — Add a reusable multiplayer session layer
|
||||
|
||||
## Status
|
||||
|
||||
Proposed. Engine: `Session.lua`, `Net.lua`, `LinkState.lua`,
|
||||
`Tournament.lua`. Tests: `link_session.lua`.
|
||||
|
||||
## Motivation
|
||||
|
||||
Link play and tournaments currently own transport lifecycle details and
|
||||
temporarily remove and reinsert packets in `Net.inbox` when a handshake or
|
||||
battle starts. That makes packet ownership fragile and gives a future
|
||||
shared-world mode no stable host/guest-aware boundary to reuse.
|
||||
|
||||
The engine needs one small layer that preserves today's wire protocol while
|
||||
owning received-packet order and terminal cleanup. Pokémon, battle, tournament,
|
||||
save, and overworld rules remain outside that layer.
|
||||
|
||||
## The decision it extends
|
||||
|
||||
Extends the existing split between `Net` (backend setup, framing, and relay
|
||||
controls), `Handshake`/`Protocol` (mode payloads), and the states that
|
||||
interpret those payloads. It does not replace any of those components.
|
||||
|
||||
## The exact API delta
|
||||
|
||||
Backward-compatible and internal-only.
|
||||
|
||||
### `Session.new(transport, options)`
|
||||
|
||||
Wraps one successfully configured Net-compatible transport. `options.role`
|
||||
is exactly `"host"` or `"guest"`; `options.kind` is a non-empty
|
||||
local label such as `"link"` or `"tournament"`. Role and kind are
|
||||
immutable session metadata selected locally and are never inferred from peer
|
||||
packets.
|
||||
|
||||
The facade forwards the narrow fields current consumers need:
|
||||
`paired`, `code`, `address`, `target`,
|
||||
`error`, and `closed`.
|
||||
It forwards valid outbound tables unchanged through `send(message)`.
|
||||
|
||||
### Receive and lifecycle methods
|
||||
|
||||
- `update()` pumps the transport, validates decoded inbound values, and
|
||||
appends accepted messages to a private FIFO.
|
||||
- `pollOne()` removes the oldest queued message.
|
||||
- `poll()` removes every queued message in order.
|
||||
- `take(type)` removes the first queued message with that type without
|
||||
disturbing any other message.
|
||||
- `hasPending()` reports whether the FIFO is non-empty.
|
||||
- `getRole()`, `getKind()`, `getStatus()`, and
|
||||
`getFailure()` expose local metadata and lifecycle.
|
||||
- `close()` closes the underlying transport once and is safe to repeat.
|
||||
|
||||
Statuses are `connecting`, `paired`, `draining`, `closed`,
|
||||
and `failed`. A transport close or failure becomes `draining` while
|
||||
accepted packets remain queued. The terminal `closed`/`error`
|
||||
compatibility projection appears only after that FIFO drains, so a last packet
|
||||
travelling with a disconnect remains observable.
|
||||
|
||||
An inbound value is structurally valid only when it is a table with a string
|
||||
`type`. Invalid decoded values end the session with a protocol failure.
|
||||
Unknown but structurally valid types remain queued for the owning mode; the
|
||||
session does not contain a packet allowlist.
|
||||
|
||||
## Authority direction
|
||||
|
||||
A later `WorldSession` may compose this facade. In that mode the host
|
||||
will own the world snapshot, map state, NPC state, event results, and shared
|
||||
progression. A guest will bring a trainer identity plus their Pokémon party,
|
||||
inventory, and other explicitly selected profile snapshot.
|
||||
|
||||
Guest profile data and commands will be untrusted input. The host must validate
|
||||
them and must authorize every world mutation before rebroadcasting the result.
|
||||
The concrete snapshot schema, command vocabulary, conflict rules, and
|
||||
persistence policy require a separate RFC and are not introduced here.
|
||||
|
||||
## Compatibility and security
|
||||
|
||||
No packet envelope, message name, payload shape, framing rule, relay protocol,
|
||||
save schema, or engine protocol version changes. Existing valid outbound
|
||||
messages encode exactly as before, and existing link and tournament screens
|
||||
keep their current player-facing behavior.
|
||||
|
||||
The layer does not authenticate players or encrypt traffic. Existing LAN and
|
||||
relay access assumptions remain unchanged; knowledge of a join address or code
|
||||
still grants the same access it grants today. Authentication, reconnect
|
||||
identity, rate limits, and abuse controls remain future protocol decisions.
|
||||
|
||||
## Migration note for players, mods, and peers
|
||||
|
||||
**Nothing.** `LinkState` and `Tournament` adopt the facade
|
||||
internally. Existing peers receive the same messages, mods gain no new API, and
|
||||
players do not migrate saves or settings.
|
||||
|
||||
## Parity tests
|
||||
|
||||
- **ROM-free facade:** constructor validation, immutable role/kind, unchanged
|
||||
send shape, FIFO ordering, typed retrieval, unknown typed packets, draining,
|
||||
terminal failure latching, protected transport calls, and decoded-value
|
||||
rejection.
|
||||
- **Existing modes:** source guards prohibit direct inbox mutation; headless
|
||||
module loads and the complete engine tier cover both migrated states.
|
||||
- **ROM-backed link play:** run the existing link driver when generated ROM
|
||||
data is available; the normal quick suite remains the required baseline.
|
||||
|
||||
## Deprecation etiquette and non-goals
|
||||
|
||||
Nothing deprecated. This RFC adds an internal facade and removes no transport
|
||||
method.
|
||||
|
||||
It does not add shared-world packets, co-op screens, a remote actor, save
|
||||
transfer, server persistence, matchmaking, reconnect, or a protocol-version
|
||||
bump. Those changes require the world-specific layer and its own review.
|
||||
@@ -0,0 +1,124 @@
|
||||
# RFC 0003 — Playthrough-scoped mod storage
|
||||
|
||||
## Status
|
||||
|
||||
Proposed. Engine: `SaveData.lua`, `SaveSerializer.lua`, `Storage.lua`,
|
||||
`Loader.lua`. Tests: `playthrough_identity.lua`, `storage.lua`, the existing
|
||||
save-slot and mod-save suites.
|
||||
|
||||
## Motivation
|
||||
|
||||
`mod.save` intentionally lives inside the normal progress record. That is the
|
||||
right home for quest state, but not for independent tool data such as replay
|
||||
captures, checkpoint histories, or recovery records: writing it would require a
|
||||
normal Pokémon SAVE, and storing copies of progress beneath `save.modData` would
|
||||
recursively embed the save that contains them.
|
||||
|
||||
Mods also cannot safely infer which launcher slot or portable filesystem backs
|
||||
the active playthrough. Direct filesystem access would expose private paths and
|
||||
make isolation dependent on engine implementation details.
|
||||
|
||||
## The decision it extends
|
||||
|
||||
Extends the per-mod persistence contract documented in `docs/modding.md` and the
|
||||
wiki's Save Model. `mod.save` and `mod.options` keep their existing behavior.
|
||||
|
||||
## The exact API delta
|
||||
|
||||
Backward-compatible, additive-only. `Loader:_api` binds a new `mod.storage`
|
||||
facade to the calling mod id. Mods receive logical keys and decoded values, never
|
||||
filesystem handles or physical paths.
|
||||
|
||||
### Lazy opaque playthrough identity
|
||||
|
||||
`SaveData.ensurePlaythroughId(save[, fs]) -> id | nil` allocates an opaque
|
||||
32-hex-character identity without consuming gameplay RNG. It is called only when
|
||||
`mod.storage` or `mod.checkpoints` first needs a scope; New Game, ordinary SAVE,
|
||||
and ordinary load remain byte-compatible when no caller uses either API.
|
||||
|
||||
The id is stored in `save.meta.playthroughId` after allocation. Until the next
|
||||
ordinary SAVE writes it into progress, a mapping in `options.lua` keeps legacy
|
||||
saves stable by game version and active launcher slot (or the legacy flat-save
|
||||
scope). A newly created playthrough never adopts the previous playthrough's
|
||||
mapping for that slot.
|
||||
|
||||
`SaveData.persistenceFs([fs])` is engine-only routing used by the storage
|
||||
implementation. It follows the same standard/portable backend as progress and
|
||||
honors injected test filesystems; it is not exposed on the mod object.
|
||||
|
||||
### `mod.storage:context(game)`
|
||||
|
||||
Returns:
|
||||
|
||||
```lua
|
||||
{ engineVersion = "0.9.0", gameVersion = "red", playthroughId = "..." }
|
||||
```
|
||||
|
||||
or `nil, code, message`. `engineVersion` is warning-grade compatibility metadata;
|
||||
the context intentionally omits launcher slot ids and paths.
|
||||
|
||||
### `mod.storage:write(game, key, value)`
|
||||
|
||||
Accepts a data-only table and returns `true`, or
|
||||
`false, code, message`. Keys are nonempty slash-separated segments containing
|
||||
letters, digits, underscore, or dash. Empty segments, leading/trailing slash,
|
||||
`.`/`..`, and other characters are rejected.
|
||||
|
||||
The engine encodes deterministically, stages and decodes a `.tmp` witness,
|
||||
preserves the previous valid generation, writes and decodes the main record,
|
||||
then rolls the verified bytes to `.bak`. A failed stage or replacement leaves a
|
||||
verified prior generation readable.
|
||||
|
||||
### `mod.storage:read(game, key)`
|
||||
|
||||
Returns a freshly decoded table, or `nil, code, message`. It tries main, staged,
|
||||
then backup data. A valid staged/backup value is returned and promoted
|
||||
best-effort; corrupt bytes are never executed.
|
||||
|
||||
### `mod.storage:list(game[, prefix])`
|
||||
|
||||
Returns sorted logical keys beneath a valid prefix, an exact key when the prefix
|
||||
names one, or `nil, code, message`. Physical witness filenames are hidden.
|
||||
|
||||
### `mod.storage:delete(game, key)`
|
||||
|
||||
Deletes only that key's main, backup, and staged witnesses. Returns `true`, or
|
||||
`false, code, message`.
|
||||
|
||||
### Scope and errors
|
||||
|
||||
Physical records are scoped as:
|
||||
|
||||
`persistence root / mod_storage / game version / playthrough id / mod id`
|
||||
|
||||
Stable error codes are `not_in_playthrough`, `storage_unavailable`,
|
||||
`invalid_key`, `encode_failed`, `write_failed`, `verify_failed`, and
|
||||
`not_found`. Ordinary data and I/O failures are return values, not callback-
|
||||
terminating errors.
|
||||
|
||||
The restricted serializer's recursive writer runs outside LuaJIT traces. A
|
||||
1,000-process GC stress regression found compiled recursion could intermittently
|
||||
drop a newly inserted nested identity entry and produce undecodable bytes; save
|
||||
encoding is infrequent and I/O-bound, so interpreter execution is the safe
|
||||
boundary.
|
||||
|
||||
## Migration note for existing mods
|
||||
|
||||
**Nothing.** No API is removed, no manifest field changes, and no storage path or
|
||||
playthrough id is created unless a mod invokes `mod.storage` or
|
||||
`mod.checkpoints`. Existing save bytes remain unchanged on the no-caller path.
|
||||
|
||||
## Parity tests
|
||||
|
||||
- **No-mod:** New Game plus ordinary save/load creates no identity or storage
|
||||
file; the existing save-slot and mod-save suites remain green.
|
||||
- **Engine identity:** lazy allocation, save/load preservation, stable legacy
|
||||
mapping, fresh-playthrough replacement, and version/slot isolation.
|
||||
- **Public Mod API:** two real API-2 entry chunks prove data-only roundtrip,
|
||||
deterministic listing, key rejection, mod/game/playthrough isolation,
|
||||
corrupt-main recovery, failure retention, exact delete, and no-mod no-write.
|
||||
|
||||
## Deprecation etiquette
|
||||
|
||||
Nothing deprecated. The additions are one bound public facade and engine-private
|
||||
persistence/identity helpers.
|
||||
@@ -0,0 +1,138 @@
|
||||
# RFC 0004 — Stable runtime checkpoints for mods
|
||||
|
||||
## Status
|
||||
|
||||
Proposed. Engine: `Checkpoint.lua`, `Game.lua`, `OverworldController.lua`,
|
||||
`Loader.lua`. Tests: `checkpoints.lua`, existing world and engine suites.
|
||||
|
||||
## Motivation
|
||||
|
||||
Mods can observe world events and request semantic actions, but no supported API
|
||||
can capture canonical progress at a proven-safe runtime boundary or reconstruct
|
||||
the overworld without replaying map-entry scripts. Reaching into the state stack,
|
||||
controller, ScriptRunner, or save restore internals would bind distributable mods
|
||||
to private objects and can duplicate story side effects.
|
||||
|
||||
The engine is the only component that can authoritatively decide whether the
|
||||
runtime is settled and rebuild its controller objects. A generic checkpoint seam
|
||||
lets tools store data-only records while keeping those responsibilities private.
|
||||
|
||||
## The decision it extends
|
||||
|
||||
Extends the public world/tool surfaces in `docs/modding.md`. It does not change
|
||||
`mod.world`, normal CONTINUE, vanilla SAVE, or save lifecycle hooks/events.
|
||||
|
||||
## The exact API delta
|
||||
|
||||
Backward-compatible, additive-only. `Loader:_api` binds `mod.checkpoints`; mods
|
||||
never receive `Game`, StateStack, controller, coroutine, renderer, or filesystem
|
||||
internals inside a checkpoint.
|
||||
|
||||
### `mod.checkpoints:inspect(game)`
|
||||
|
||||
Returns a capability record. Stable overworld control returns:
|
||||
|
||||
```lua
|
||||
{ canCapture = true, canRestore = true, kind = "overworld" }
|
||||
```
|
||||
|
||||
A refusal returns the same booleans as `false` plus `kind`, `reason`, and a
|
||||
player-readable `message`. Format-1 supports only an overworld whose controller
|
||||
is topmost, player movement has settled on a tile, and no transition, foreground
|
||||
or parallel ScriptRunner, queued script, scripted move, engagement, emote,
|
||||
teleport, field animation, or similar partial controller mutation is active.
|
||||
|
||||
Refusal reasons are `not_in_playthrough`, `not_overworld`, `screen_busy`,
|
||||
`transition_busy`, `script_busy`, `animation_busy`, and `movement_busy`.
|
||||
Identity allocation is lazy and happens only after an active topmost overworld
|
||||
has been established.
|
||||
|
||||
### `mod.checkpoints:capture(game)`
|
||||
|
||||
Returns a detached data-only format-1 checkpoint, or
|
||||
`nil, code, message`:
|
||||
|
||||
```lua
|
||||
{
|
||||
format = 1,
|
||||
kind = "overworld",
|
||||
identity = {
|
||||
engineVersion = "...", gameVersion = "red", playthroughId = "...",
|
||||
},
|
||||
save = { -- canonical dynamic progress, excluding global options },
|
||||
runtime = { overworld = {
|
||||
map = "PALLET_TOWN", x = 5, y = 6,
|
||||
facing = "down", surfing = false,
|
||||
} },
|
||||
}
|
||||
```
|
||||
|
||||
`engineVersion` is metadata for caller compatibility warnings; the engine does
|
||||
not reject patch/minor mismatches on restore. Capture deep-copies through the restricted serializer before and after
|
||||
`OverworldController:captureSave` synchronizes live map, tile, facing, and surf
|
||||
state. It excludes `save.options`, functions, userdata, threads, metatables as
|
||||
behavior, controller instances, and static content registries. Failure code
|
||||
`capture_failed` covers non-data progress and synchronization errors.
|
||||
|
||||
### `mod.checkpoints:restore(game, checkpoint)`
|
||||
|
||||
Returns `true`, or `false, code, message`. Before mutation it requires the current
|
||||
runtime to be capturable and validates a detached copy of the complete record:
|
||||
format, kind, internal identity consistency, current game/playthrough identity,
|
||||
map availability, integral in-bounds tile, facing, surfing, and synchronized save
|
||||
position.
|
||||
|
||||
Validation codes are `invalid_checkpoint`, `invalid_content`, `unsupported_format`,
|
||||
`unsupported_runtime_kind`, `wrong_game`, `wrong_playthrough`, `invalid_map`, and
|
||||
`invalid_position`, in addition to the capability refusal reasons.
|
||||
|
||||
The canonical save validator runs against the detached record. Unlike ordinary
|
||||
CONTINUE, a checkpoint never accepts a quarantine, remap, reclaim, clamp, or
|
||||
repair: any such content change returns `invalid_content` before live mutation.
|
||||
|
||||
The engine captures an in-memory rollback checkpoint, preserves current global
|
||||
options, then reconstructs semantic overworld state through
|
||||
`Game:restoreCheckpointSave`. Checkpoint entry suppresses normal map exit/entry
|
||||
events, `onEnter` scripts, forced-movement/current checks, and last-map rewrites;
|
||||
it does not emit normal `save.loading`/`save.loaded` lifecycle events. After
|
||||
reconstruction, the engine recaptures and byte-compares normalized data. A failed
|
||||
apply rolls back and returns `restore_failed`; failure of that rollback returns
|
||||
`rollback_failed`. Only after a successful comparison does the engine emit
|
||||
`checkpoint.restored` with `{ game = game, kind = "overworld" }`. Validation
|
||||
failure, failed apply, and successful rollback emit nothing.
|
||||
|
||||
Durable recovery remains a caller responsibility: in-memory rollback handles a
|
||||
runtime exception, not process termination.
|
||||
|
||||
## Runtime boundary and future kinds
|
||||
|
||||
This RFC's original Level A contract intentionally rejects battles, menus,
|
||||
transitions, animations, and suspended/queued scripts. RFC 0005 subsequently
|
||||
adds a separately inventoried `battle` kind with deterministic RNG and
|
||||
differential reconstruction tests; it does not broaden script or arbitrary-frame
|
||||
support implied here.
|
||||
|
||||
## Migration note for existing mods
|
||||
|
||||
**Nothing required.** No existing hook, save, controller, or world action changes
|
||||
when `mod.checkpoints` is unused. The reconstruction path is called only by a
|
||||
successful public restore after validation. Mods whose runtime caches derive from
|
||||
rewound `game.save` or `mod.save` state may optionally subscribe to
|
||||
`checkpoint.restored` and rebuild from their own public state.
|
||||
|
||||
## Parity tests
|
||||
|
||||
- **No-mod:** the complete ROM-free engine suite and existing world behavior stay
|
||||
green; ordinary New Game/save/load allocates no checkpoint identity.
|
||||
- **Public Mod API:** a real API-2 entry chunk proves stable inspection and every
|
||||
unsafe refusal, detached data-only capture, exact map/tile/facing/surf sync,
|
||||
`A -> mutate B -> restore A -> recapture A2` equality across representative
|
||||
progress, settings preservation, compatibility rejection without mutation,
|
||||
map-side-effect suppression, injected reconstruction rollback, mod-owned
|
||||
metadata and `mod.save` rewind, independent `mod.storage`/options preservation,
|
||||
and success-only runtime-cache reconciliation through `checkpoint.restored`.
|
||||
|
||||
## Deprecation etiquette
|
||||
|
||||
Nothing deprecated. This adds one public facade and a checkpoint-only semantic
|
||||
reconstruction route.
|
||||
@@ -0,0 +1,140 @@
|
||||
# RFC 0005 — Persistent battle safe-point checkpoints
|
||||
|
||||
## Status
|
||||
|
||||
Proposed. Extends RFC 0004. Engine: `BattleCheckpoint.lua`, `Checkpoint.lua`,
|
||||
`Game.lua`, `BattleState.lua`, and `OverworldController.lua`. Tests:
|
||||
`battle_checkpoint_*.lua`, `checkpoints.lua`, and the existing no-mod suites.
|
||||
|
||||
## Motivation
|
||||
|
||||
RFC 0004 lets a tool capture and reconstruct settled overworld progress without
|
||||
private engine access. A battle is a different runtime: its queue can hold Lua
|
||||
functions and UI factories, its controller contains renderer objects and live
|
||||
references, completion is currently an `onFinish` closure, and scripted battles
|
||||
resume a suspended `ScriptRunner` coroutine. Copying the controller would create
|
||||
a record that is neither data-only nor process-independent.
|
||||
|
||||
The engine can instead expose a narrow semantic safe point. This gives all mods
|
||||
the strongest persistent battle checkpoint the current architecture can prove,
|
||||
without claiming mid-animation or suspended-script support.
|
||||
|
||||
## API delta
|
||||
|
||||
No new facade is added. The existing additive `mod.checkpoints` API gains a
|
||||
second format-1 runtime kind.
|
||||
|
||||
### Capability
|
||||
|
||||
`mod.checkpoints:inspect(game)` returns this only when an ordinary single-player
|
||||
wild or trainer battle is settled at the player command menu:
|
||||
|
||||
```lua
|
||||
{ canCapture = true, canRestore = true, kind = "battle" }
|
||||
```
|
||||
|
||||
The action/message queue, waits, UI, animations, HP/status presentation, and
|
||||
faint processing must be settled. The player must actually control the menu.
|
||||
The underlying overworld must have no running/queued script or scripted move,
|
||||
and the battle must carry an engine-owned semantic continuation descriptor.
|
||||
|
||||
Additional refusal codes are `battle_phase_busy`, `battle_origin_unsupported`,
|
||||
`battle_variant_unsupported`, and `link_battle_unsupported`. Link, Safari,
|
||||
ghost, old-man/demo, fishing, static-object, script-suspended, and mod-created
|
||||
closure continuations remain rejected.
|
||||
|
||||
### Capture
|
||||
|
||||
A battle checkpoint remains detached and data-only:
|
||||
|
||||
```lua
|
||||
{
|
||||
format = 1,
|
||||
kind = "battle",
|
||||
identity = { engineVersion = "...", gameVersion = "red",
|
||||
playthroughId = "..." },
|
||||
save = { -- canonical dynamic progress, excluding global options },
|
||||
runtime = {
|
||||
overworld = { map = "ROUTE_1", x = 7, y = 8,
|
||||
facing = "left", surfing = false },
|
||||
battle = { -- normalized semantic model and continuation },
|
||||
},
|
||||
rng = { love = "..." },
|
||||
}
|
||||
```
|
||||
|
||||
The model carries player/enemy roster indices, dynamic enemy Pokémon, turn and
|
||||
escape state, HP/PP/status/stages/volatiles, participants, level-up tracking,
|
||||
trainer AI state, battle ruleset identity, side/field extension data, and
|
||||
normalized pointer relationships such as multi-turn move slots and Mimic
|
||||
restoration entries. Definitions, sprites, canvases, queues, callbacks, and
|
||||
controller objects are reconstructed or excluded.
|
||||
|
||||
Callback-bearing battle extension tokens fail with `battle_extension_unsafe`;
|
||||
invalid live reference relationships fail with `battle_state_invalid`. Nothing
|
||||
is silently stripped.
|
||||
|
||||
New overworld checkpoints also carry the LÖVE gameplay RNG state. Legacy
|
||||
format-1 overworld checkpoints without `rng` remain loadable and leave the
|
||||
current stream untouched.
|
||||
|
||||
### Restore
|
||||
|
||||
Battle restore validates the detached save, map, content references, ruleset,
|
||||
roster indices, move references, continuation identity, and RNG before live
|
||||
mutation. The engine then:
|
||||
|
||||
1. reconstructs the saved overworld return point without entry side effects;
|
||||
2. creates a fresh `BattleState` from current content registries;
|
||||
3. applies the normalized battle model and rebuilds object-reference relations;
|
||||
4. binds an engine-owned wild/trainer completion continuation;
|
||||
5. installs the battle directly at the settled menu without replaying its intro;
|
||||
6. restores the RNG after reconstruction has finished; and
|
||||
7. recaptures and compares the complete checkpoint; and
|
||||
8. emits `checkpoint.restored` with `{ game = game, kind = "battle" }` after the
|
||||
comparison succeeds.
|
||||
|
||||
The pre-operation checkpoint is the transaction rollback. A failed post-install
|
||||
RNG restore is covered: both battle runtime and RNG are reconstructed back to
|
||||
their original values. Validation failure, failed reconstruction, and successful
|
||||
rollback emit no checkpoint lifecycle event.
|
||||
|
||||
## Continuation decision
|
||||
|
||||
Ordinary random wild battles resume through `OverworldState:afterBattle`.
|
||||
Ordinary trainer battles use a descriptor containing map id, stable NPC id,
|
||||
trainer class/party, and optional header event; a win reapplies the same defeated
|
||||
flag, event, reward, and `afterBattle` path. Reconstructed overworld input and
|
||||
NPC freeze state are normalized instead of reviving the old closure.
|
||||
|
||||
`Commands.start_battle` is deliberately unsupported: its completion closure
|
||||
mutates script context and resumes a coroutine whose program counter and Lua
|
||||
stack cannot be serialized. Existing script rejection remains the correct safe
|
||||
contract until a separate semantic ScriptRunner checkpoint RFC exists.
|
||||
|
||||
## Migration note
|
||||
|
||||
**Existing mods require no changes.** The facade and format number are unchanged;
|
||||
the new kind, RNG field, and success-only lifecycle event are additive.
|
||||
Overworld-only callers may continue to filter `capability.kind`. Mods with derived
|
||||
runtime caches may rebuild them from restored public state when the event fires.
|
||||
No-mod behavior is unchanged when checkpoints are unused.
|
||||
|
||||
## Verification
|
||||
|
||||
- settled/unsafe boundary and every variant refusal;
|
||||
- data-only wild and trainer capture, including callback-bearing extension
|
||||
rejection;
|
||||
- process-independent controller and continuation reconstruction;
|
||||
- exact differential recapture for wild and trainer states;
|
||||
- HP, PP, status/stages/volatiles, AI layer, participants, enemy roster,
|
||||
multi-turn move references, and Mimic restore pointers;
|
||||
- exact damage, critical, accuracy, random AI, escape, next encounter, and next
|
||||
raw RNG result after reload;
|
||||
- corrupt content/continuation rejection before mutation;
|
||||
- injected post-install failure with full runtime and RNG rollback;
|
||||
- mod-added Pokémon metadata and `mod.save` rewind while independent
|
||||
`mod.storage` and options remain current;
|
||||
- exactly one post-verification `checkpoint.restored` event and none on failure;
|
||||
- legacy overworld checkpoint compatibility;
|
||||
- complete ROM-free engine and public mod-API suites.
|
||||
@@ -0,0 +1,107 @@
|
||||
# RFC 0006 — Generic process-lifecycle hooks for platform launcher integrations
|
||||
|
||||
## Status
|
||||
|
||||
Proposed. Engine: `PlatformHooks.lua` (new), `main.lua`, `Manifest.lua`,
|
||||
`Loader.lua`. Tests: `tests/modkit/cases/platform_lifecycle_hooks.lua`,
|
||||
`tests/mod_loader_tests.lua`, `tests/mod_manifest_tests.lua`.
|
||||
|
||||
## Motivation
|
||||
|
||||
A platform-specific launcher wrapper -- a native shell that embeds this
|
||||
engine and owns its own UI around the game window (a mobile app shell,
|
||||
say, presenting its own settings/import/save screens and only handing
|
||||
control to the LÖVE window once play starts) needs three things no
|
||||
current hook covers:
|
||||
|
||||
1. Pause the simulation while its own UI is on top of the game window.
|
||||
2. Live-reload options it wrote from outside any Lua UI.
|
||||
3. Veto `main.lua`'s "closing the window returns to the Lua launcher"
|
||||
behavior when the platform shell owns that job itself -- without this,
|
||||
a shell that re-fronts its own launcher UI on quit gets looped straight
|
||||
back into `HostShell.restart()`'s in-process reboot instead.
|
||||
|
||||
Implementing this by hand-patching `main.lua`'s `love.update`/`love.quit`
|
||||
directly ties every such integration to editing the one file every other
|
||||
engine change also touches, guaranteeing merge conflicts for any second
|
||||
platform integration (or any unrelated engine PR landing around the same
|
||||
time). No existing hook covers "should the per-frame simulation step run"
|
||||
or "should closing the window return to the Lua launcher."
|
||||
|
||||
## The decision it extends
|
||||
|
||||
No prior D-number. Extends the hook-contract section of `docs/modding.md`
|
||||
alongside `input.step`, `render.hud`, `screen.render_visible`, etc.
|
||||
|
||||
## The exact API delta
|
||||
|
||||
Backward-compatible, additive-only.
|
||||
|
||||
### `core.update`
|
||||
|
||||
New hook, `(game, dt) -> nil` through the public wrapper signature
|
||||
`(next, game, dt)`, called once per frame from `love.update` via
|
||||
`src/core/PlatformHooks.lua`'s `PlatformHooks.update(game, dt)`. Vanilla
|
||||
behavior (used when no mod claims the hook) is `game:update(dt)`,
|
||||
unconditionally -- identical to `love.update`'s behavior before this hook
|
||||
existed. A subscriber may skip calling `next(game, dt)` to pause the
|
||||
simulation for that frame, or do additional per-frame work before/after
|
||||
calling it regardless of whether it calls `next`.
|
||||
|
||||
### `core.quit_to_launcher`
|
||||
|
||||
New hook, `() -> boolean` through the public wrapper signature `(next)`,
|
||||
called once from `love.quit()` via
|
||||
`PlatformHooks.quitToLauncher(vanilla)`. `vanilla` is the pre-existing
|
||||
non-platform-specific decision (`Game and not Importer and not
|
||||
quitToLauncher and not scripted and not launchedIntoGame`). A subscriber
|
||||
may return `false` outright to veto returning to the Lua launcher (without
|
||||
ever calling `next`, so the vanilla condition is never evaluated), or call
|
||||
`next()` and return its result to pass the vanilla decision through
|
||||
unchanged.
|
||||
|
||||
Neither hook is guarded by `Runtime.wantsHook` -- both fire unconditionally
|
||||
every call, matching the existing `input.step` precedent
|
||||
(`src/core/Game.lua`), since `Hooks:call` already fast-paths to a bare
|
||||
`vanilla(...)` call when no mod has wrapped the name.
|
||||
|
||||
### `Manifest.force_enable_env`
|
||||
|
||||
New optional manifest field, a bare env-var name. `Loader:load` re-enables
|
||||
a mod carrying this field whenever that variable is set to `"1"`,
|
||||
regardless of a saved disable in `options.mods`. This exists for exactly
|
||||
the mod class this RFC is for: a platform-bridge mod that ships only with
|
||||
one build and cannot function disabled there, but must still behave like
|
||||
every other mod (a manifest opt-in, not an engine special case) on every
|
||||
build that doesn't set its variable.
|
||||
|
||||
## Migration note for existing mods
|
||||
|
||||
**Nothing.** With no subscriber, `love.update` still calls `Game:update(dt)`
|
||||
unconditionally every frame and `love.quit()`'s restart-to-launcher
|
||||
decision is exactly the pre-existing condition -- bit-identical to today's
|
||||
behavior on every platform where no mod wraps either hook. A manifest with
|
||||
no `force_enable_env` field behaves exactly as before.
|
||||
|
||||
## Parity tests
|
||||
|
||||
- **No-mod:** `core.update`'s vanilla runs exactly once per call with the
|
||||
hook chain empty; `core.quit_to_launcher`'s vanilla return value passes
|
||||
through unchanged. Both hooks are picked up automatically by the
|
||||
catalog-driven no-mod gate (`tests/engine/gate_hooks.lua`, which scans
|
||||
for `Runtime.call("...")` call sites), so neither needs a dedicated
|
||||
no-mod test file.
|
||||
- **Mod-API:** `tests/modkit/cases/platform_lifecycle_hooks.lua` proves,
|
||||
through a fixture mod loaded via the public loader (not the engine's
|
||||
internals), that a subscriber can skip the vanilla update call (pause),
|
||||
run extra per-frame polling regardless of pause state, and veto the
|
||||
quit-to-launcher decision without the vanilla condition ever running.
|
||||
- `tests/mod_loader_tests.lua` and `tests/mod_manifest_tests.lua` cover
|
||||
`force_enable_env`: a matching env var re-enables a mod saved as
|
||||
disabled, and an unset one leaves the saved disable alone.
|
||||
|
||||
## Deprecation etiquette
|
||||
|
||||
Nothing deprecated. These are two additive hooks and one additive manifest
|
||||
field; `main.lua`'s only footprint is one `require` and two call sites
|
||||
into `src/core/PlatformHooks.lua`.
|
||||
@@ -0,0 +1,212 @@
|
||||
# RFC 0007 — Per-category GAME SPEED and the `core.logic_speed` hook
|
||||
|
||||
## Status
|
||||
|
||||
Proposed. Engine: `GameSpeed.lua`, `Game.lua`, `BattleState.lua`,
|
||||
`OptionsMenu.lua`, `SaveData.lua`, `LauncherSettings.lua`. Tests:
|
||||
`tests/engine/game_speed_categories_test.lua`,
|
||||
`tests/engine/gate_hooks.lua` (structural, automatic), `tests/run_tests.lua`
|
||||
(OptionsMenu row walk), `tests/mod_ui_tests.lua` (row id/order).
|
||||
|
||||
## Motivation
|
||||
|
||||
`GameSpeed` (`src/core/GameSpeed.lua`) is a single fast-forward multiplier
|
||||
applied uniformly to the whole logic clock in `Game:logicSpeed()` /
|
||||
`Game:update()` -- overworld walking, menu navigation and battle turns all
|
||||
scale together. A player who wants 4X battles (grinding, a long gym fight)
|
||||
but 1X overworld (so a scripted cutscene or NPC dialogue doesn't blur past)
|
||||
has no way to get both; the one GAME SPEED row is a single ladder that
|
||||
applies everywhere at once.
|
||||
|
||||
This needs to be an engine change, not a mod: there is no per-frame seam a
|
||||
mod can use to swap the multiplier mid-step, and no public event granular
|
||||
enough to say "which category is active" (`screen.pushed`/`screen.popped`
|
||||
and `battle.started`/`battle.ended` are the closest and are not enough --
|
||||
see Decisions below). The engine's own speed resolution has to become
|
||||
category-aware.
|
||||
|
||||
A category-aware speed resolution is also the general seam a
|
||||
platform-launcher integration or automation tool needs to read or override
|
||||
the effective multiplier for a given frame without caring which category
|
||||
produced it -- this RFC's `core.logic_speed` hook is written for that case
|
||||
alongside the player-facing Options rows.
|
||||
|
||||
## The decision it extends
|
||||
|
||||
No prior D-number. Extends `GameSpeed.lua`'s multiplier ladder (unchanged)
|
||||
with per-category resolution.
|
||||
|
||||
## The exact API delta
|
||||
|
||||
Backward-compatible except for one save-data field rename, which ships with
|
||||
an automatic migration (see below) -- nothing in the public mod API (hooks,
|
||||
events, registries, `mod.*`) is renamed or removed.
|
||||
|
||||
### `save.options`: `speed` -> `speedOverworld` / `speedBattle` / `speedMenu`
|
||||
|
||||
`GameSpeed.CATEGORIES = { "overworld", "battle", "menu" }` is the new list
|
||||
of categories, and `GameSpeed.optionKey(category)` maps a category to its
|
||||
`save.options` field name (`"overworld"` -> `"speedOverworld"`, etc.).
|
||||
`GameSpeed.LEVELS`, `.DEFAULT`, `.levelLabel`, `.clamp` and `.cycle` are
|
||||
unchanged -- the ladder and its behavior are exactly what they were, just
|
||||
applied three times instead of once.
|
||||
|
||||
`SaveData.defaultOptions()` drops `speed = 1` and adds `speedOverworld = 1`,
|
||||
`speedBattle = 1`, `speedMenu = 1`. `SaveData.mergeOptions()` migrates: a
|
||||
loaded options table that still has `speed` and none of the three new
|
||||
fields seeds all three from it, so an existing player's fast-forward
|
||||
preference carries over instead of two of the three categories silently
|
||||
resetting to 1X. `speed` is dropped on the way out (not carried forward),
|
||||
so a re-save never re-triggers the migration.
|
||||
|
||||
### `Game.speedCategoryInStack(stack)`
|
||||
|
||||
New static helper, `(stack) -> "battle" | "overworld" | "menu"`. Walks the
|
||||
whole state stack top-down -- the same idiom `Game.wideBattleInStack` and
|
||||
`Game.fillScaleInStack` already use -- looking for `state.isBattle` (new
|
||||
marker, `BattleState.isBattle = true`, covering every battle: wild,
|
||||
trainer, link, safari, the old-man demo) or `state.isOverworld` (existing
|
||||
marker, `OverworldController`'s `OverworldState.isOverworld = true`). The
|
||||
first match wins; a state with neither marker (a menu, a text box, a
|
||||
naming screen, a cutscene) is transparent to the walk and falls through to
|
||||
whatever is under it. Nothing in the stack matching either falls back to
|
||||
`"menu"`.
|
||||
|
||||
### `Game:logicSpeed()` / `Game:_resolveLogicSpeed()`
|
||||
|
||||
`Game:_resolveLogicSpeed()` is new: it resolves `Game.speedCategoryInStack`
|
||||
against the live stack, maps the category to its `save.options` key via
|
||||
`GameSpeed.optionKey`, and returns `GameSpeed.clamp` of that option (or
|
||||
`GameSpeed.DEFAULT`). This is the exact category-resolution logic the new
|
||||
hook wraps.
|
||||
|
||||
`Game:logicSpeed()` keeps its existing early returns -- link play forces
|
||||
`1`, a run-argument speed override wins over the saved option -- unchanged,
|
||||
and in the same order, before ever calling the hook. Only once neither
|
||||
applies does it call the `core.logic_speed` hook.
|
||||
|
||||
### `Game:_cycleSpeed(dir)`
|
||||
|
||||
The keyboard hotkey and the gamepad shoulders/triggers that used to cycle
|
||||
the single `speed` option now cycle whichever category
|
||||
`Game.speedCategoryInStack` says is active: pressing the hotkey during a
|
||||
battle speeds up just the battle, on the overworld just the walk, in a menu
|
||||
just the menu. This is the natural per-category answer for a control that
|
||||
used to have one option to reach and now has three -- see Decisions below
|
||||
for why this reading was chosen over, say, always cycling `overworld`.
|
||||
|
||||
### `core.logic_speed`
|
||||
|
||||
New hook, `(game) -> number` through the public wrapper signature
|
||||
`(next, game)`, called once per `Game:logicSpeed()` (i.e. once per frame).
|
||||
Vanilla behavior (used when no mod claims the hook) is
|
||||
`Game:_resolveLogicSpeed()` -- exactly the category resolution above,
|
||||
nothing else. A subscriber may call `next(game)` and return its result to
|
||||
pass the vanilla multiplier through, or return a different number outright
|
||||
to override it for that frame (e.g. a bot mod forcing `1` during one route
|
||||
segment regardless of what category or option is active).
|
||||
|
||||
This intentionally sits *after* the link and speed-override checks in
|
||||
`Game:logicSpeed()`, not around them: link play staying locked to 1X "no
|
||||
matter what either player set this to" is exactly the invariant that would
|
||||
break if a mod's hook could override it, and the run-argument override
|
||||
exists so a bot/screenshot run's speed does not depend on a mod any more
|
||||
than on the player's saved option. Both stay unconditional early returns a
|
||||
mod never sees.
|
||||
|
||||
Not guarded by `Runtime.wantsHook`: `Hooks:call` already fast-paths to a
|
||||
bare `vanilla(...)` call when no mod has wrapped the name, and this hook
|
||||
fires every frame regardless.
|
||||
|
||||
## Decisions on the issue's open questions
|
||||
|
||||
**1. Overlays on top of another category's state (a party menu, a choice
|
||||
box, a naming screen opened mid-battle or mid-overworld).** Resolved by
|
||||
making the category a property of stack *position*, not of the overlay's
|
||||
own type: an overlay with no `isBattle`/`isOverworld` marker is transparent
|
||||
to `Game.speedCategoryInStack`'s walk and inherits whatever is under it. A
|
||||
party swap opened mid-battle reads as `"battle"`; a bag opened while
|
||||
walking reads as `"overworld"`. This was chosen over giving every UI state
|
||||
its own fixed category (which would make a fast-forwarded battle visibly
|
||||
stutter back to 1X every time its party menu opens) because it matches
|
||||
what the player is actually doing moment to moment, and it reuses a
|
||||
pattern the codebase already leans on for exactly this "menus opened over
|
||||
X should behave like X" class of problem (`Game.fillScaleInStack`,
|
||||
`Game.wideBattleInStack`).
|
||||
|
||||
**2. Cutscenes/scripts.** No fourth category. A scripted sequence runs
|
||||
through the owning state's own machinery -- the overworld's script runner
|
||||
or a battle's message queue -- rather than pushing a state of its own, so
|
||||
it is already covered by decision 1: it inherits whatever category the
|
||||
state driving it resolves to. A cutscene state that genuinely has nothing
|
||||
under it (a pre-game intro) falls to `"menu"`, the default for anything
|
||||
that is not battle or overworld gameplay -- consistent with those being
|
||||
pre-game presentation, not something a player is likely to want scaled
|
||||
differently from menu navigation.
|
||||
|
||||
**3. Category granularity (splitting "menu" further).** Deferred. Start
|
||||
with the three named here; `GameSpeed.CATEGORIES` and `GameSpeed.optionKey`
|
||||
are written so adding a fourth later (a Pokédex/Bag category, say) is one
|
||||
entry plus one new `save.options` field, not a resolution-logic rewrite.
|
||||
No current request motivates it.
|
||||
|
||||
**4. The GAME SPEED hotkey/shoulder buttons, once "the" speed is three
|
||||
things.** `Game:_cycleSpeed` now cycles whichever category is currently
|
||||
active (`Game.speedCategoryInStack`), rather than, say, always cycling
|
||||
`overworld` or requiring a modifier key to pick a category. A single
|
||||
physical control that means "speed up whatever I'm looking at right now"
|
||||
is the reading that needs no new UI and matches what a player pressing it
|
||||
mid-battle almost certainly wants.
|
||||
|
||||
## Migration note for existing mods
|
||||
|
||||
**Nothing**, for the mod API surface: `content.X:register/override/get`,
|
||||
`events:on`, `hooks:wrap`, `mod.log`, `mod:read`, manifest v1 fields are
|
||||
untouched, and `GameSpeed.LEVELS`/`.DEFAULT`/`.levelLabel`/`.clamp`/`.cycle`
|
||||
keep their exact signatures and behavior.
|
||||
|
||||
**One save-data field**, for anything that read `save.options.speed`
|
||||
directly (not a formal registry/hook surface, but worth naming): it is
|
||||
superseded by `speedOverworld`/`speedBattle`/`speedMenu`, migrated
|
||||
automatically on load (see above) so a save from before this RFC keeps its
|
||||
player's chosen speed. A mod reading `save.options.speed` after this change
|
||||
sees `nil` (the key is dropped on migration, not kept as a stale alias) and
|
||||
should read the per-category fields, or hook `core.logic_speed` to observe
|
||||
the resolved multiplier directly regardless of which category produced it.
|
||||
|
||||
## Parity tests
|
||||
|
||||
- **No-mod:** `core.logic_speed` needs no dedicated no-mod test file --
|
||||
`tests/engine/gate_hooks.lua` walks the live hook catalog (which scans
|
||||
`src` for `Runtime.call("...")` call sites), so the new
|
||||
`Runtime.call("core.logic_speed", ...)` site is picked up and gated
|
||||
automatically: vanilla runs exactly once with an empty hook chain, an
|
||||
unsubscribed-but-live bus passes values and multiple returns through
|
||||
unchanged, and `Runtime.wantsHook` reads `false`.
|
||||
- **Mod-API:** `tests/engine/game_speed_categories_test.lua` exercises the
|
||||
hook through the public API (`Hooks.new()` + `bus:wrap("core.logic_speed",
|
||||
...)` + `Runtime.call`, the same idiom other hooks' tests use) -- a
|
||||
subscriber can read the vanilla category resolution via `next(game)` and
|
||||
can override it outright -- plus direct coverage of
|
||||
`Game.speedCategoryInStack` (battle-on-top, overworld-on-top, an overlay
|
||||
inheriting each, an empty/unmatched stack falling to `"menu"`) and
|
||||
`Game:logicSpeed()`'s precedence (link forces 1X over all three
|
||||
categories and over a hook override; the run-argument override wins over
|
||||
the category resolution).
|
||||
- `tests/run_tests.lua`'s OptionsMenu walk exercises the three new rows
|
||||
(OVERWORLD SPEED / BATTLE SPEED / MENU SPEED) cycling and wrapping
|
||||
independently, in place of the old single GAME SPEED row.
|
||||
- `tests/mod_ui_tests.lua`'s row-id/order check and hardcoded row-index
|
||||
activations (MODS, CONTROLS) are updated for the two extra rows.
|
||||
- A link-play driver should set all three per-category speeds high before
|
||||
asserting `game:logicSpeed()` reads `1` during a real link session,
|
||||
proving the lock wins over every category at once, not just whichever
|
||||
one happens to be active.
|
||||
|
||||
## Deprecation etiquette
|
||||
|
||||
Nothing deprecated in the mod-facing hook/event/registry catalog -- this
|
||||
adds one hook, additive. The `save.options.speed` field is superseded with
|
||||
an automatic migration rather than a deprecation notice, since it was never
|
||||
a registered mod-API surface (no schema entry, no registry) -- the same
|
||||
treatment any other `save.options` field would get if it needed reshaping.
|
||||
@@ -0,0 +1,244 @@
|
||||
# Build Gen1Recomp for Nintendo Switch
|
||||
|
||||
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.
|
||||
|
||||
> Releases ship `gen1recomp-*-switch.zip` (SD tree under `switch/gen1recomp/`).
|
||||
> Runtime target is pinned [love-nx](https://github.com/retronx-team/love-nx)
|
||||
> `11.5-nx1`. Player install and limitations: [switch-install.md](switch-install.md).
|
||||
|
||||
---
|
||||
|
||||
## 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 (**required for `--fused`**):
|
||||
|
||||
```sh
|
||||
sudo dkp-pacman -S switch-dev
|
||||
```
|
||||
|
||||
3. OTA launcher toolchain, **native or Docker** (either is fine):
|
||||
|
||||
```sh
|
||||
bash scripts/switch/install_devkitpro_deps.sh # native
|
||||
# or install Docker (same pin as fused builds)
|
||||
```
|
||||
|
||||
4. Ensure `DEVKITPRO` is exported (typical macOS: `/opt/devkitpro`) and
|
||||
`nacptool` / `elf2nro` are on `PATH` (or under `$DEVKITPRO/tools/bin`).
|
||||
|
||||
Fused game builds can also use Docker when native `nacptool`/`elf2nro` are absent.
|
||||
|
||||
### Native OTA launcher (included in `--fused`)
|
||||
|
||||
In-console OTA uses a **separate DEVKITPRO NRO** (not LÖVE). The LÖVE
|
||||
self-updater (`Check.lua`) is disabled on NX. Source:
|
||||
`ports/switch/ota-launcher/`. Host protocol tests (no toolchain):
|
||||
|
||||
```sh
|
||||
make -C ports/switch/ota-launcher host-test
|
||||
# or
|
||||
scripts/switch/build_ota_launcher.sh # host-test first; NRO needs DEVKITPRO/Docker
|
||||
```
|
||||
|
||||
`--fused` always builds the fused game, native OTA launcher, and dual-NRO SD
|
||||
zip. The same `*-switch.zip` is the OTA download asset. **DEVKITPRO is
|
||||
required.** OTA launcher: native packages **or** Docker. Both are supported.
|
||||
|
||||
Release-like build from repo root:
|
||||
|
||||
```sh
|
||||
scripts/build_switch.sh --fetch --fused --version X.Y.Z
|
||||
```
|
||||
|
||||
See `ports/switch/ota-launcher/README.md` and
|
||||
`scripts/switch/ota_launcher.manifest`.
|
||||
|
||||
### 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 | (none) |
|
||||
| `dkp-pacman` + `switch-dev` + OTA packages **or** Docker | `dkp-pacman -S …` |
|
||||
| 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 fused game NRO, OTA launcher NRO, and dual-NRO SD zip. **Requires DEVKITPRO** + `switch-dev`. OTA launcher: native packages 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
|
||||
|
||||
# Fused game + OTA launcher + dual-NRO SD 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` (game),
|
||||
`gen1recomp-<ver>-launcher.nro`, `gen1recomp-<ver>-game.nro`,
|
||||
`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 main 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 **main** 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 CI never runs fused. Fork PRs into the main repo 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 **DEVKITPRO** installed and exported.
|
||||
`--fused` preflight fails early with setup steps if it is missing.
|
||||
|
||||
**One-time setup on the runner** (if not already present):
|
||||
|
||||
```sh
|
||||
# devkitPro pacman installer from https://devkitpro.org/wiki/devkitPro_pacman
|
||||
sudo dkp-pacman -S switch-dev
|
||||
export DEVKITPRO=/opt/devkitpro
|
||||
export PATH="$DEVKITPRO/tools/bin:$PATH"
|
||||
|
||||
# OTA launcher: pick one
|
||||
bash scripts/switch/install_devkitpro_deps.sh # native
|
||||
# or ensure Docker is installed (same pin as fused builds)
|
||||
```
|
||||
|
||||
CI and release still run `scripts/build_switch.sh --fetch --fused`. Preflight
|
||||
requires DEVKITPRO and either native OTA packages or Docker. Without all of
|
||||
that, the job fails with the setup steps above. Scripts never auto-run
|
||||
`dkp-pacman -S` during CI.
|
||||
|
||||
---
|
||||
|
||||
## 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).
|
||||
@@ -0,0 +1,204 @@
|
||||
# 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.
|
||||
|
||||
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 # native OTA launcher (hbmenu entry)
|
||||
sdmc:/switch/gen1recomp/gen1recomp-game.nro # fused LÖVE game
|
||||
sdmc:/switch/gen1recomp/version.txt
|
||||
sdmc:/switch/gen1recomp/pokemon-love2d/imports/
|
||||
sdmc:/switch/gen1recomp/pokemon-love2d/imports/mods/
|
||||
sdmc:/switch/gen1recomp/pokemon-love2d/imports/saves/...
|
||||
```
|
||||
|
||||
Older single-NRO zips only had `gen1recomp.nro` (the fused game). Current
|
||||
releases use the dual-NRO layout above. Open `gen1recomp` in hbmenu (the
|
||||
launcher).
|
||||
|
||||
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
|
||||
|
||||
#### Native OTA launcher (in-console)
|
||||
|
||||
Switch OTA runs in a separate **native launcher NRO** (libnx + curl), not the
|
||||
LÖVE self-updater (`src/update/Check.lua`). hbmenu opens `gen1recomp.nro`.
|
||||
|
||||
When a newer release exists, the launcher downloads the same install zip
|
||||
(`gen1recomp-*-switch.zip`), checks SHA-256 against `sha256sums.txt`, replaces
|
||||
both `gen1recomp-game.nro` and `gen1recomp.nro` (keeps NACP version in sync
|
||||
for hbmenu and Sphaira), then loads the game with `envSetNextLoad`.
|
||||
|
||||
If you are up to date or offline, it skips straight to the game with no
|
||||
prompt. If an update is available, you get a short prompt styled like the
|
||||
in-game launcher: black background, RGB rail, logo, A/B buttons. Saves under
|
||||
`pokemon-love2d/` are not touched. See `src/update/SwitchOta.lua` for the
|
||||
wire format.
|
||||
|
||||
The LÖVE self-updater stays **disabled** on NX (`networkValidated == false`).
|
||||
|
||||
**Sphaira forwarder (HOME shortcut):** Sphaira copies name/version/icon into
|
||||
the installed forwarder at creation time. After an OTA (or zip) update, the
|
||||
`.nro` on the microSD already has the new version, but the HOME shortcut
|
||||
keeps the old badge until you **reinstall the forwarder once** in Sphaira
|
||||
(Install Forwarder again on `gen1recomp.nro`). Browsing the NRO in Sphaira /
|
||||
hbmenu always shows the live file version.
|
||||
|
||||
#### Manual zip (fallback)
|
||||
|
||||
Use the **same** extract/merge of `gen1recomp-*-switch.zip`. It replaces the
|
||||
NROs (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. See [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`.
|
||||
|
||||
## Limitations
|
||||
|
||||
- You need homebrew (custom firmware, hbmenu). This project does not set that
|
||||
up.
|
||||
- Launch with title override (hold **R** on a title). Applet Mode (Album) is
|
||||
not supported. The game needs full memory.
|
||||
- ROMs, mods, and saves are copied manually via MTP, direct SD, or FTP. There
|
||||
is no automated deploy.
|
||||
- Updates use the native OTA launcher only. The LÖVE self-updater and remote
|
||||
**FIND MODS** stay off on Switch.
|
||||
- Tested on Switch OLED. Switch V1 / Erista boot confirmed by the community.
|
||||
Other models may work but are less tested.
|
||||
|
||||
## 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).
|
||||
@@ -0,0 +1,165 @@
|
||||
# Switch file transfer (MTP / SD / FTP)
|
||||
|
||||
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 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).
|
||||
|
||||
> **Not supported yet:** `nxlink` / hbmenu netloader automation. Useful later
|
||||
> for a fast contrib rebuild loop; deferred (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.
|
||||
|
||||
---
|
||||
|
||||
## Transfer 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 a documented example for
|
||||
macOS, not a Mac-only requirement.
|
||||
|
||||
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)
|
||||
@@ -124,3 +124,8 @@ bundled game, in that case.
|
||||
`love.filesystem.isFused()` is false, and a working tree's `engine` is the
|
||||
`"0.0.0-dev"` placeholder that always reports up to date, so a source
|
||||
checkout is always "the game" itself; updating it means pulling the repo.
|
||||
- **Nintendo Switch does not use this LÖVE self-updater.** On NX,
|
||||
`Platform.networkValidated()` is `false`, so `Boot.run` / `Check` never
|
||||
download `.love` payloads. In-console OTA uses the **native OTA launcher**
|
||||
(DEVKITPRO), documented in [switch-install.md](switch-install.md). Wire
|
||||
format: `src/update/SwitchOta.lua`. Manual zip install remains the fallback.
|
||||
|
||||
@@ -10,8 +10,42 @@
|
||||
|
||||
local editorMode = os.getenv("POKEPORT_EDITOR") == "1" or POKEPORT_EDITOR_MODE == true
|
||||
|
||||
local SwitchDiagnostics = require("src.debug.SwitchDiagnostics")
|
||||
local LaunchOptions = require("src.core.LaunchOptions")
|
||||
local NxDisplay = require("src.core.NxDisplay")
|
||||
local PlatformHooks = require("src.core.PlatformHooks")
|
||||
|
||||
-- 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
|
||||
|
||||
-- #887: quit-to-launcher state, shared by love.load and love.quit (both need
|
||||
-- it, so it is declared here rather than next to love.quit).
|
||||
-- * launchedIntoGame -- a --game / POKEPORT_GAME shortcut booted this
|
||||
-- session straight into a game, so there is no launcher behind it and a
|
||||
-- window close must exit. Restarting instead re-read the same shortcut
|
||||
-- and came right back into the game, and the next close did it again:
|
||||
-- the app could not be closed at all (macOS feels this worst, where the
|
||||
-- red X, Cmd+Q and the Dock's Quit are all the same quit event).
|
||||
-- * RELAUNCH_MARKER -- written in the save dir just before the #785
|
||||
-- restart, so the fresh boot ignores any boot-straight-into-a-game
|
||||
-- option exactly once and keeps #785's promise of landing in the
|
||||
-- launcher, whatever put the game on screen this time.
|
||||
local launchedIntoGame = false
|
||||
local RELAUNCH_MARKER = "relaunch_to_launcher.txt"
|
||||
|
||||
local autopilot -- optional scripted-input dev tool (tests/autopilot.lua)
|
||||
local driverCo -- optional frame-driver (POKEPORT_DRIVER=file.lua): a
|
||||
-- coroutine that receives `Game` and yields once per
|
||||
@@ -103,6 +137,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 +168,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 +184,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 +197,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 +209,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 +246,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 +272,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 +330,47 @@ 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
|
||||
|
||||
-- LAUNCH OPTIONS: skip the launcher and boot a game directly.
|
||||
-- --game red|blue|yellow (or POKEPORT_GAME / POKEPORT_LAUNCH)
|
||||
-- --slot <id> optional; picks the save slot to load
|
||||
-- --launcher force the launcher even if a game is set
|
||||
-- This is what a desktop shortcut, a Steam entry, or a frontend like
|
||||
-- EmulationStation needs: one click into the game the player wants, with no
|
||||
-- menu in between. A game that is not imported falls through to the
|
||||
-- launcher on its tab rather than booting into nothing.
|
||||
-- A window close that restarted us into the launcher (#785) leaves the
|
||||
-- marker behind: consume it and stay on the launcher, or the shortcut below
|
||||
-- would boot the same game again and that close would restart again,
|
||||
-- forever (#887). Consumed on read, so the very next launch is normal.
|
||||
local relaunched = love.filesystem.getInfo(RELAUNCH_MARKER) ~= nil
|
||||
if relaunched then pcall(love.filesystem.remove, RELAUNCH_MARKER) end
|
||||
|
||||
local launchGame, launchSlot = LaunchOptions.resolve(arg)
|
||||
if launchGame and not relaunched and not LaunchOptions.forceLauncher(arg) then
|
||||
if RomImporter.isReady(launchGame) then
|
||||
if launchSlot then LaunchOptions.selectSlot(launchGame, launchSlot) end
|
||||
-- No launcher behind this session: love.quit must exit, not restart.
|
||||
launchedIntoGame = true
|
||||
bootGame(launchGame)
|
||||
return
|
||||
end
|
||||
-- Not importable yet: open the launcher already showing that game, so the
|
||||
-- shortcut still lands the player where they meant to go.
|
||||
LaunchOptions.pendingTab = launchGame
|
||||
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 +388,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
|
||||
@@ -309,7 +427,11 @@ function love.update(dt)
|
||||
end
|
||||
return
|
||||
end
|
||||
Game:update(dt)
|
||||
-- Mods may wrap or veto the per-frame simulation step (pause it, react
|
||||
-- to external platform state, etc.) -- see docs/modding.md's core.update
|
||||
-- entry. Vanilla behavior (used when no mod claims the hook) is just
|
||||
-- Game:update(dt), unconditionally, exactly as before this hook existed.
|
||||
PlatformHooks.update(Game, dt)
|
||||
end
|
||||
|
||||
function love.draw()
|
||||
@@ -347,48 +469,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 +614,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 +624,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,18 +654,12 @@ function love.touchpressed(id, x, y, dx, dy, pressure)
|
||||
return TouchEditor.touchpressed(id, x, y)
|
||||
end
|
||||
if Importer then
|
||||
-- iOS: LÖVE already synthesizes a mousepressed for the primary touch,
|
||||
-- and love.mousepressed below forwards that to the Importer, so
|
||||
-- forwarding here too fires every launcher button twice per tap. The
|
||||
-- resulting double-present was fatal for the document picker: the
|
||||
-- second sheet stole the first one's weakly-held delegate, so picking
|
||||
-- a file silently did nothing. Android keeps the forward for upstream
|
||||
-- parity (its SAF picker is a separate activity and tolerates the
|
||||
-- re-launch).
|
||||
if love.system.getOS() == "iOS" then return 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)
|
||||
Game:touchpressed(id, x, y, dx, dy, pressure)
|
||||
end
|
||||
|
||||
function love.touchmoved(id, x, y, dx, dy, pressure)
|
||||
@@ -442,8 +668,10 @@ function love.touchmoved(id, x, y, dx, dy, pressure)
|
||||
if love.system.getOS() == "iOS" then return end
|
||||
return TouchEditor.touchmoved(id, x, y)
|
||||
end
|
||||
if Importer then return end
|
||||
Game:touchmoved(id, x, y)
|
||||
if Importer then
|
||||
return Importer:touchmoved(id, x, y, dx, dy, pressure)
|
||||
end
|
||||
Game:touchmoved(id, x, y, dx, dy, pressure)
|
||||
end
|
||||
|
||||
function love.touchreleased(id, x, y, dx, dy, pressure)
|
||||
@@ -452,8 +680,10 @@ function love.touchreleased(id, x, y, dx, dy, pressure)
|
||||
if love.system.getOS() == "iOS" then return end
|
||||
return TouchEditor.touchreleased(id, x, y)
|
||||
end
|
||||
if Importer then return end
|
||||
Game:touchreleased(id, x, y)
|
||||
if Importer then
|
||||
return Importer:touchreleased(id, x, y, dx, dy, pressure)
|
||||
end
|
||||
Game:touchreleased(id, x, y, dx, dy, pressure)
|
||||
end
|
||||
|
||||
function love.wheelmoved(x, y)
|
||||
@@ -466,7 +696,33 @@ function love.wheelmoved(x, y)
|
||||
Game:wheelmoved(x, y)
|
||||
end
|
||||
|
||||
-- #781: Linux X11 multi-monitor with the primary display away from desktop
|
||||
-- (0,0): SDL's polled mouse state can come back in desktop-virtual
|
||||
-- coordinates while the event stream stays window-relative, which strands
|
||||
-- every polled consumer (launcher Kit rising-edge clicks, the pad-cursor
|
||||
-- motion yield, PadCursor) on coordinates no hit test can match. Sanitize
|
||||
-- the poll once here: remember the last window-relative event coordinates
|
||||
-- and substitute them whenever the polled value falls outside the window.
|
||||
-- Linux only -- macOS / Windows / mobile keep the stock function, and the
|
||||
-- NX launcher shim still composes because it captures whatever
|
||||
-- love.mouse.getPosition is at bridge time (_ensureNxPointerBridge).
|
||||
local eventMouseX, eventMouseY
|
||||
if love.system and love.system.getOS() == "Linux"
|
||||
and love.mouse and love.mouse.getPosition then
|
||||
local polledGetPosition = love.mouse.getPosition
|
||||
love.mouse.getPosition = function()
|
||||
local x, y = polledGetPosition()
|
||||
local w, h = love.graphics.getDimensions()
|
||||
if x < 0 or y < 0 or x > w or y > h then
|
||||
if eventMouseX then return eventMouseX, eventMouseY end
|
||||
return math.max(0, math.min(x, w)), math.max(0, math.min(y, h))
|
||||
end
|
||||
return x, y
|
||||
end
|
||||
end
|
||||
|
||||
function love.mousepressed(x, y, button, istouch)
|
||||
if not istouch then eventMouseX, eventMouseY = x, y end
|
||||
if TouchEditor then
|
||||
-- Android primary touch already arrived via love.touchpressed; a second
|
||||
-- mouse path would double-fire Done / begin a second drag.
|
||||
@@ -474,32 +730,35 @@ 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.
|
||||
if istouch and love.system.getOS() == "Android" then return end
|
||||
-- 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
|
||||
Game:touchpressed("mouse", x, y)
|
||||
if mouseTouch then
|
||||
-- the mouse is standing in for a finger: the touch path owns it, and
|
||||
-- feeding the same press back in as a mouse pointer would double it
|
||||
if Game and button == 1 then Game:touchpressed("mouse", x, y) end
|
||||
return
|
||||
end
|
||||
-- #807: a real mouse reaches gameplay as a pointer event for mods; Game
|
||||
-- drops synthesized istouch twins so a mobile touch that already arrived
|
||||
-- through love.touchpressed cannot fire twice
|
||||
if Game then Game:mousepressed(x, y, button, istouch) end
|
||||
end
|
||||
|
||||
function love.mousereleased(x, y, button)
|
||||
function love.mousereleased(x, y, button, istouch)
|
||||
if TouchEditor then
|
||||
if love.system.getOS() == "Android" then return end
|
||||
return TouchEditor.mousereleased(x, y, button)
|
||||
@@ -508,20 +767,25 @@ function love.mousereleased(x, y, button)
|
||||
if editorMode and EditorApp.mousereleased then
|
||||
return EditorApp.mousereleased(x, y, button)
|
||||
end
|
||||
if mouseTouch and Game and button == 1 then
|
||||
Game:touchreleased("mouse", x, y)
|
||||
if mouseTouch then
|
||||
if Game and button == 1 then Game:touchreleased("mouse", x, y) end
|
||||
return
|
||||
end
|
||||
if Game then Game:mousereleased(x, y, button, istouch) end
|
||||
end
|
||||
|
||||
function love.mousemoved(x, y)
|
||||
function love.mousemoved(x, y, dx, dy, istouch)
|
||||
if not istouch then eventMouseX, eventMouseY = x, y end
|
||||
if TouchEditor then
|
||||
if love.system.getOS() == "Android" then return end
|
||||
return TouchEditor.mousemoved(x, y)
|
||||
end
|
||||
if editorMode or Importer then return end
|
||||
if mouseTouch and Game and love.mouse.isDown(1) then
|
||||
Game:touchmoved("mouse", x, y)
|
||||
if mouseTouch then
|
||||
if Game and love.mouse.isDown(1) then Game:touchmoved("mouse", x, y) end
|
||||
return
|
||||
end
|
||||
if Game then Game:mousemoved(x, y, dx, dy, istouch) end
|
||||
end
|
||||
|
||||
function love.textinput(text)
|
||||
@@ -532,9 +796,51 @@ function love.textinput(text)
|
||||
end
|
||||
end
|
||||
|
||||
-- #785: set once love.quit has routed a window close into HostShell.restart,
|
||||
-- so the follow-up quit event the restart itself raises (quit("restart") on
|
||||
-- desktop; AppImage and Android relaunch the process instead, #575) falls
|
||||
-- through to the normal shutdown below instead of restarting forever.
|
||||
local quitToLauncher = false
|
||||
|
||||
function love.quit()
|
||||
if editorMode and EditorApp.quit then
|
||||
return EditorApp.quit() -- return true to abort quit
|
||||
-- true blocks the quit (unsaved-changes prompt). A quit that proceeds
|
||||
-- must fall through to the worker shutdowns below instead of returning:
|
||||
-- the bundled editor opens from a live launcher whose update-check and
|
||||
-- fetch-pool workers are still parked in Channel:demand(), and returning
|
||||
-- here skipped their "quit" push, so the process outlived the closed
|
||||
-- window and kept the install folder locked on Windows (#727).
|
||||
if EditorApp.quit() then return true end
|
||||
end
|
||||
-- Closing the window of a running game returns to the launcher instead of
|
||||
-- exiting the app, so testing a mod does not need a relaunch every time
|
||||
-- (#785). Game is only non-nil once bootGame ran; Importer non-nil means
|
||||
-- the launcher (or its import) owns the window and its close still quits.
|
||||
-- Scripted and headless runs (autopilot, frame driver, import-only, ROM
|
||||
-- path import) keep the plain exit so they terminate as before. Nothing
|
||||
-- is saved here on purpose: a window close never wrote the save, and the
|
||||
-- restart path must be no worse than that, not quietly better.
|
||||
local scripted = os.getenv("POKEPORT_AUTOPILOT") or os.getenv("POKEPORT_DRIVER")
|
||||
or os.getenv("POKEPORT_IMPORT_ONLY") == "1" or os.getenv("POKEPORT_IMPORT_ROM")
|
||||
-- #887: a shortcut session (--game / POKEPORT_GAME) has no launcher to go
|
||||
-- back to and the restart would re-read the shortcut, so it exits instead.
|
||||
--
|
||||
-- A platform launcher that owns "return to launcher" itself (see
|
||||
-- docs/modding.md's core.quit_to_launcher entry) may veto returning to
|
||||
-- this Lua launcher via that hook. Vanilla behavior (used when no mod
|
||||
-- claims the hook) is exactly the condition below.
|
||||
local wouldReturnToLauncher = PlatformHooks.quitToLauncher(function()
|
||||
return Game and not Importer and not quitToLauncher and not scripted
|
||||
and not launchedIntoGame
|
||||
end)
|
||||
if wouldReturnToLauncher then
|
||||
quitToLauncher = true
|
||||
-- Tell the fresh boot to ignore any boot-straight-into-a-game option this
|
||||
-- once, so the restart really does land in the launcher (#887). A failed
|
||||
-- write only costs that suppression, so it must never block the restart.
|
||||
pcall(love.filesystem.write, RELAUNCH_MARKER, "1")
|
||||
require("src.core.HostShell").restart()
|
||||
return true -- abort this quit; the restart lands back in the launcher
|
||||
end
|
||||
pcall(function()
|
||||
require("src.core.DiscordPresence").shutdown()
|
||||
@@ -549,6 +855,12 @@ function love.quit()
|
||||
if package.loaded["src.update.Check"] then
|
||||
pcall(package.loaded["src.update.Check"].shutdown)
|
||||
end
|
||||
-- The launcher's fetch pool is the same story: its workers idle in
|
||||
-- Channel:demand(), which never returns on its own, so a launcher that ever
|
||||
-- touched the network would hang the process on exit (#339's shape again).
|
||||
if package.loaded["src.net.Fetch"] then
|
||||
pcall(package.loaded["src.net.Fetch"].shutdown)
|
||||
end
|
||||
end
|
||||
|
||||
function love.filedropped(file)
|
||||
|
||||
@@ -108,7 +108,8 @@ The APK lands under `app/build/outputs/apk/embedNoRecord/debug/`.
|
||||
### Payload path
|
||||
|
||||
`app/src/embed/assets/game.love` - zip of `main.lua`, `conf.lua`, `src/`,
|
||||
`data/`, `assets/`, and the Red, Blue, and Yellow ROM manifests. The Android
|
||||
`libs/` (the vendored FlexLove toolkit the launcher UI needs), `data/`,
|
||||
`assets/`, and the Red, Blue, and Yellow ROM manifests. The Android
|
||||
packer verifies the Yellow manifest before it packages; if a partial source
|
||||
export omitted it, it restores the file from this checkout's Git data and then
|
||||
falls back to the project's GitHub copy. Generated game data,
|
||||
|
||||
|
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 |
@@ -37,6 +37,13 @@
|
||||
|
||||
#include "filesystem/physfs/PhysfsIo.h"
|
||||
|
||||
// #604 / #839: the SAF bridges below must hand GameActivity the exact
|
||||
// directory physfs mounted as the save dir -- the same contract the iOS
|
||||
// GRPickerBridge already gets (mobile/ios/patch_love_src.py,
|
||||
// gr_saveDirectory) -- instead of letting Java recompute the root on its
|
||||
// own, which can name a different volume on merged / adopted-SD storage.
|
||||
#include "filesystem/Filesystem.h"
|
||||
|
||||
namespace love
|
||||
{
|
||||
namespace android
|
||||
@@ -183,6 +190,19 @@ void vibrate(double seconds)
|
||||
env->DeleteLocalRef(activity);
|
||||
}
|
||||
|
||||
// The directory physfs actually mounted as the save dir, or "" before the
|
||||
// filesystem module is up. GameActivity must copy SAF picks HERE: its own
|
||||
// getExternalFilesDir(null) recomputation can disagree with the mounted
|
||||
// root on merged / adopted-SD storage (#604, #839).
|
||||
static const char *bridgeSaveDirectory()
|
||||
{
|
||||
auto fs = Module::getInstance<love::filesystem::Filesystem>(Module::M_FILESYSTEM);
|
||||
if (fs == nullptr)
|
||||
return "";
|
||||
const char *dir = fs->getSaveDirectory();
|
||||
return dir != nullptr ? dir : "";
|
||||
}
|
||||
|
||||
bool showFilePicker(const char *destFilename)
|
||||
{
|
||||
if (destFilename == nullptr || destFilename[0] == '\0')
|
||||
@@ -192,9 +212,11 @@ bool showFilePicker(const char *destFilename)
|
||||
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
||||
|
||||
jmethodID method = env->GetStaticMethodID(activity, "showFilePicker",
|
||||
"(Ljava/lang/String;)Z");
|
||||
"(Ljava/lang/String;Ljava/lang/String;)Z");
|
||||
jstring jname = env->NewStringUTF(destFilename);
|
||||
jboolean result = env->CallStaticBooleanMethod(activity, method, jname);
|
||||
jstring jsavedir = env->NewStringUTF(bridgeSaveDirectory());
|
||||
jboolean result = env->CallStaticBooleanMethod(activity, method, jname, jsavedir);
|
||||
env->DeleteLocalRef(jsavedir);
|
||||
env->DeleteLocalRef(jname);
|
||||
|
||||
env->DeleteLocalRef(activity);
|
||||
@@ -210,9 +232,11 @@ bool showCreateDocument(const char *suggestedName)
|
||||
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
||||
|
||||
jmethodID method = env->GetStaticMethodID(activity, "showCreateDocument",
|
||||
"(Ljava/lang/String;)Z");
|
||||
"(Ljava/lang/String;Ljava/lang/String;)Z");
|
||||
jstring jname = env->NewStringUTF(suggestedName);
|
||||
jboolean result = env->CallStaticBooleanMethod(activity, method, jname);
|
||||
jstring jsavedir = env->NewStringUTF(bridgeSaveDirectory());
|
||||
jboolean result = env->CallStaticBooleanMethod(activity, method, jname, jsavedir);
|
||||
env->DeleteLocalRef(jsavedir);
|
||||
env->DeleteLocalRef(jname);
|
||||
|
||||
env->DeleteLocalRef(activity);
|
||||
@@ -259,7 +283,19 @@ bool httpDownload(const char *url, const char *destPath, const char *userAgent,
|
||||
return false;
|
||||
|
||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
||||
// NOT FindClass: this is the one bridge called off the main thread
|
||||
// (love.thread workers in src/net/fetch_worker.lua and
|
||||
// src/update/check_worker.lua). A worker is a raw pthread whose JNI
|
||||
// class loader is the system one, which cannot see app classes, so
|
||||
// FindClass("org/love2d/android/GameActivity") left a pending
|
||||
// ClassNotFoundException and the next JNI call aborted the process --
|
||||
// opening FIND MODS killed the app on the first stats fetch. Resolving
|
||||
// through the live activity instance works from any attached thread.
|
||||
jobject activityObj = (jobject) SDL_AndroidGetActivity();
|
||||
if (activityObj == nullptr)
|
||||
return false;
|
||||
jclass activity = env->GetObjectClass(activityObj);
|
||||
env->DeleteLocalRef(activityObj);
|
||||
|
||||
// Old APK / new liblove skew: report "no transport" the same way a
|
||||
// missing curl does, instead of aborting on a missing method (#597).
|
||||
@@ -967,4 +1003,33 @@ void love_android_secondary_enable(int on)
|
||||
env->DeleteLocalRef(activity);
|
||||
}
|
||||
|
||||
extern "C" __attribute__((visibility("default")))
|
||||
const char *love_android_poll_secondary_touch()
|
||||
{
|
||||
static thread_local std::string event;
|
||||
event.clear();
|
||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
||||
jmethodID method = env->GetStaticMethodID(activity, "pollSecondaryDisplayTouch",
|
||||
"()Ljava/lang/String;");
|
||||
if (!method)
|
||||
env->ExceptionClear();
|
||||
else
|
||||
{
|
||||
jstring value = (jstring) env->CallStaticObjectMethod(activity, method);
|
||||
if (value)
|
||||
{
|
||||
const char *utf = env->GetStringUTFChars(value, nullptr);
|
||||
if (utf)
|
||||
{
|
||||
event = utf;
|
||||
env->ReleaseStringUTFChars(value, utf);
|
||||
}
|
||||
env->DeleteLocalRef(value);
|
||||
}
|
||||
}
|
||||
env->DeleteLocalRef(activity);
|
||||
return event.empty() ? nullptr : event.c_str();
|
||||
}
|
||||
|
||||
#endif // LOVE_ANDROID
|
||||
|
||||
@@ -116,6 +116,18 @@ public class GameActivity extends SDLActivity {
|
||||
// bad ROM instead of installing it (#553).
|
||||
private String pendingPickFilename = PICKED_ROM_FILENAME;
|
||||
private static final String STATE_PENDING_PICK = "pendingPickFilename";
|
||||
// Absolute save directory physfs actually mounted, as reported by the
|
||||
// native bridge call that opened the picker (love/src/common/android.cpp,
|
||||
// bridgeSaveDirectory). This activity used to recompute
|
||||
// getExternalFilesDir(null)/save/<identity> on its own at result time; on
|
||||
// merged / adopted-SD storage that can name a different volume than the
|
||||
// one LOVE mounted, so the copied pick (and pick_error.flag) landed where
|
||||
// Lua never scans -- the launcher then "did nothing" after a pick (#604)
|
||||
// and the folders a file manager can browse stayed empty while the game
|
||||
// saved fine elsewhere (#839). Empty string means "not told yet": fall
|
||||
// back to the historical computation.
|
||||
private String pendingPickSaveDir = "";
|
||||
private static final String STATE_PENDING_PICK_DIR = "pendingPickSaveDir";
|
||||
private static final String STATE_PENDING_CREATE = "pendingCreateSuggestedName";
|
||||
// Suggested download name for the in-flight SAF create (set by showCreateDocument).
|
||||
private String pendingCreateSuggestedName = "export.sav";
|
||||
@@ -186,6 +198,8 @@ public class GameActivity extends SDLActivity {
|
||||
// a recreated activity still lands under the basename it asked for.
|
||||
String pick = savedInstanceState.getString(STATE_PENDING_PICK);
|
||||
if (pick != null) pendingPickFilename = pick;
|
||||
String pickDir = savedInstanceState.getString(STATE_PENDING_PICK_DIR);
|
||||
if (pickDir != null) pendingPickSaveDir = pickDir;
|
||||
String create = savedInstanceState.getString(STATE_PENDING_CREATE);
|
||||
if (create != null) pendingCreateSuggestedName = create;
|
||||
}
|
||||
@@ -467,13 +481,23 @@ public class GameActivity extends SDLActivity {
|
||||
* @param destFilename basename under the app save identity (e.g.
|
||||
* picked_rom.gb, picked_mod.zip, picked_save.sav)
|
||||
*/
|
||||
/** Legacy single-argument entry; resolves the save dir itself. */
|
||||
@Keep
|
||||
public static boolean showFilePicker(String destFilename) {
|
||||
return showFilePicker(destFilename, null);
|
||||
}
|
||||
|
||||
@Keep
|
||||
public static boolean showFilePicker(String destFilename, String saveDir) {
|
||||
GameActivity self = (GameActivity) mSingleton;
|
||||
if (self == null) return false;
|
||||
if (destFilename == null || destFilename.length() == 0) {
|
||||
destFilename = PICKED_ROM_FILENAME;
|
||||
}
|
||||
// Remember where LOVE's filesystem is really mounted so
|
||||
// onActivityResult copies the pick there, not into a recomputed
|
||||
// (possibly different-volume) root (#604, #839).
|
||||
self.pendingPickSaveDir = (saveDir != null) ? saveDir : "";
|
||||
// Reject path separators so a hostile JNI caller cannot escape the
|
||||
// save identity directory.
|
||||
if (destFilename.indexOf('/') >= 0 || destFilename.indexOf('\\') >= 0) {
|
||||
@@ -644,8 +668,14 @@ public class GameActivity extends SDLActivity {
|
||||
* return degrades on the Lua side (RomImporter export) to "Exported
|
||||
* inside the app folder", which is the correct pre-KitKat behavior.
|
||||
*/
|
||||
/** Legacy single-argument entry; resolves the save dir itself. */
|
||||
@Keep
|
||||
public static boolean showCreateDocument(String suggestedName) {
|
||||
return showCreateDocument(suggestedName, null);
|
||||
}
|
||||
|
||||
@Keep
|
||||
public static boolean showCreateDocument(String suggestedName, String saveDir) {
|
||||
if (android.os.Build.VERSION.SDK_INT < 19) return false;
|
||||
// (see showFilePicker for why the import side got a pre-19 path)
|
||||
GameActivity self = (GameActivity) mSingleton;
|
||||
@@ -657,9 +687,12 @@ public class GameActivity extends SDLActivity {
|
||||
Log.d("GameActivity", "refusing unsafe create name: " + suggestedName);
|
||||
return false;
|
||||
}
|
||||
File source = new File(
|
||||
new File(self.getExternalFilesDir(null), "save"),
|
||||
ROM_SAVE_IDENTITY + "/" + PENDING_EXPORT_FILENAME);
|
||||
// Route through the mounted save dir (#604, #839): Lua staged
|
||||
// pending_export.sav where physfs writes, which is not necessarily
|
||||
// where a fresh getExternalFilesDir(null) points on merged /
|
||||
// adopted-SD storage.
|
||||
self.pendingPickSaveDir = (saveDir != null) ? saveDir : "";
|
||||
File source = new File(self.saveIdentityDir(), PENDING_EXPORT_FILENAME);
|
||||
if (!source.isFile()) {
|
||||
Log.d("GameActivity", "no pending export at " + source);
|
||||
return false;
|
||||
@@ -680,7 +713,21 @@ public class GameActivity extends SDLActivity {
|
||||
}
|
||||
|
||||
private File saveIdentityDir() {
|
||||
return new File(new File(getExternalFilesDir(null), "save"), ROM_SAVE_IDENTITY);
|
||||
// Prefer the mounted save dir the last bridge call reported: the
|
||||
// recomputation below can name a different volume than the one LOVE
|
||||
// mounted on merged / adopted-SD storage (#604, #839).
|
||||
if (pendingPickSaveDir != null && pendingPickSaveDir.length() > 0) {
|
||||
return new File(pendingPickSaveDir);
|
||||
}
|
||||
File ext = getExternalFilesDir(null);
|
||||
if (ext == null) {
|
||||
// Shared storage unavailable (ejected / mid-adoption): without
|
||||
// this guard File(null, "save") silently built the RELATIVE
|
||||
// path save/<identity>, mkdirs() failed against "/", and the
|
||||
// pick was dropped with no message at all (#604).
|
||||
ext = getFilesDir();
|
||||
}
|
||||
return new File(new File(ext, "save"), ROM_SAVE_IDENTITY);
|
||||
}
|
||||
|
||||
/** Drops a small flag file in the save identity for Lua to consume on focus. */
|
||||
@@ -875,6 +922,7 @@ public class GameActivity extends SDLActivity {
|
||||
protected void onSaveInstanceState(Bundle outState) {
|
||||
super.onSaveInstanceState(outState);
|
||||
outState.putString(STATE_PENDING_PICK, pendingPickFilename);
|
||||
outState.putString(STATE_PENDING_PICK_DIR, pendingPickSaveDir);
|
||||
outState.putString(STATE_PENDING_CREATE, pendingCreateSuggestedName);
|
||||
}
|
||||
|
||||
@@ -1277,6 +1325,9 @@ public class GameActivity extends SDLActivity {
|
||||
// in src/jni/love/src/common/android.cpp.
|
||||
private static volatile SecondaryPresentation secondaryPresentation;
|
||||
private static volatile boolean secondaryEnabled = false;
|
||||
private static final int MAX_SECONDARY_TOUCHES = 32;
|
||||
private static final java.util.ArrayDeque<String> secondaryTouches =
|
||||
new java.util.ArrayDeque<>();
|
||||
|
||||
@Keep
|
||||
public static void setSecondaryEnabled(final boolean on) {
|
||||
@@ -1329,6 +1380,7 @@ public class GameActivity extends SDLActivity {
|
||||
private static void teardownSecondaryDisplay() {
|
||||
SecondaryPresentation p = secondaryPresentation;
|
||||
secondaryPresentation = null;
|
||||
synchronized (secondaryTouches) { secondaryTouches.clear(); }
|
||||
if (p != null) {
|
||||
try { p.dismiss(); } catch (Throwable t) {}
|
||||
}
|
||||
@@ -1347,6 +1399,13 @@ public class GameActivity extends SDLActivity {
|
||||
}
|
||||
}
|
||||
|
||||
@Keep
|
||||
public static String pollSecondaryDisplayTouch() {
|
||||
synchronized (secondaryTouches) {
|
||||
return secondaryTouches.pollFirst();
|
||||
}
|
||||
}
|
||||
|
||||
private static class SecondaryPresentation extends android.app.Presentation {
|
||||
private final FrameView frameView;
|
||||
|
||||
@@ -1413,6 +1472,7 @@ public class GameActivity extends SDLActivity {
|
||||
private final android.graphics.Paint paint = new android.graphics.Paint();
|
||||
private final Object lock = new Object();
|
||||
private int fw, fh;
|
||||
private int activePointer = -1;
|
||||
|
||||
FrameView(Context context) {
|
||||
super(context);
|
||||
@@ -1434,6 +1494,52 @@ public class GameActivity extends SDLActivity {
|
||||
postInvalidate();
|
||||
}
|
||||
|
||||
private void enqueueTouch(String event) {
|
||||
synchronized (secondaryTouches) {
|
||||
if (secondaryTouches.size() >= MAX_SECONDARY_TOUCHES) {
|
||||
secondaryTouches.clear();
|
||||
secondaryTouches.addLast("cancel,0,0");
|
||||
} else {
|
||||
secondaryTouches.addLast(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private int logicalX(float x) {
|
||||
return Math.min(fw - 1, Math.max(0,
|
||||
(int) ((x - dst.left) * fw / dst.width())));
|
||||
}
|
||||
|
||||
private int logicalY(float y) {
|
||||
return Math.min(fh - 1, Math.max(0,
|
||||
(int) ((y - dst.top) * fh / dst.height())));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onTouchEvent(android.view.MotionEvent event) {
|
||||
synchronized (lock) {
|
||||
int action = event.getActionMasked();
|
||||
if (action == android.view.MotionEvent.ACTION_DOWN && fw > 0
|
||||
&& dst.contains((int) event.getX(), (int) event.getY())) {
|
||||
activePointer = event.getPointerId(0);
|
||||
enqueueTouch("down," + logicalX(event.getX()) + ","
|
||||
+ logicalY(event.getY()));
|
||||
} else if (action == android.view.MotionEvent.ACTION_UP
|
||||
&& activePointer >= 0) {
|
||||
int index = event.findPointerIndex(activePointer);
|
||||
if (index >= 0 && fw > 0) {
|
||||
enqueueTouch("up," + logicalX(event.getX(index)) + ","
|
||||
+ logicalY(event.getY(index)));
|
||||
}
|
||||
activePointer = -1;
|
||||
} else if (action == android.view.MotionEvent.ACTION_CANCEL) {
|
||||
activePointer = -1;
|
||||
enqueueTouch("cancel,0,0");
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDraw(android.graphics.Canvas canvas) {
|
||||
synchronized (lock) {
|
||||
|
||||
@@ -1,17 +1,144 @@
|
||||
{
|
||||
"name": "gen1recomp App Repo",
|
||||
"identifier": "com.theboisclub.gen1recomp.repo",
|
||||
"iconURL": "https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/assets/logo/logo.png",
|
||||
"iconURL": "https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/assets/logo/gen1recomp_cover.png",
|
||||
"apps": [
|
||||
{
|
||||
"name": "gen1recomp",
|
||||
"bundleIdentifier": "com.theboisclub.gen1recomp",
|
||||
"developerName": "bryanthaboi",
|
||||
"iconURL": "https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/assets/logo/logo.png",
|
||||
"iconURL": "https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/assets/logo/gen1recomp_cover.png",
|
||||
"localizedDescription": "Gen1Recomp - A native Lua / LÖVE2D recreation of Gen 1 Poke",
|
||||
"tintColor": "3b5ca8",
|
||||
"category": "games",
|
||||
"versions": []
|
||||
"versions": [
|
||||
{
|
||||
"version": "0.1.75",
|
||||
"date": "2026-08-06",
|
||||
"size": 9574086,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.75/gen1recomp-0.1.75-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #826 \"Super effective\" and \"not very effective\" SFX are reversed\n- #848 Roms not showing up\n- #876 Remote mod download is unavailable on this platform\n- #899 Importing Pokémon red leads to the contents extracted without a folder called 'red'\n- #902 Audio is wrong!\n\n## Contributors\n\n- @andrewqsantos\n- @bryanthaboi\n- @caorthann-celt\n- @johnjohto\n- @ShaneMcGovernIE"
|
||||
},
|
||||
{
|
||||
"version": "0.1.74",
|
||||
"date": "2026-08-06",
|
||||
"size": 9588946,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.74/gen1recomp-0.1.74-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #883 Evolution Stones consumed if evolution is cancelled\n- #887 MacOS Closing game just keeps reopening\n- #894 Max Repel usable in battle\n\n## Contributors\n\n- @bryanthaboi\n- @MarceloMachadoxD\n- @ratherDashing"
|
||||
},
|
||||
{
|
||||
"version": "0.1.73",
|
||||
"date": "2026-08-06",
|
||||
"size": 9585729,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.73/gen1recomp-0.1.73-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #878 Mod API: allow active screen states to be hidden from the main render\n\n## Contributors\n\n- @AverageConsumer\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.72",
|
||||
"date": "2026-08-05",
|
||||
"size": 9586678,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.72/gen1recomp-0.1.72-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #604 Android, retroid pocket 2+ Rom won't import\n- #666 Pikachu emotions are not working on Android\n- #716 Lock Auto Rotate Mobile\n- #727 [Bug] [Windows] Gen1 Recomp \"still in use\" after closing\n- #763 Some EVENTS are turned off\n- #781 Mouse cursor broken on Linux with multi-monitor X11 setup\n- #784 Leech Seed effect\n- #799 Held direction randomly stops player movement (requires re-input)\n- #801 Cannot update mods from the launcher (MacOS)\n- #810 Launcher menu cuts off in vertical mode iOS\n- #828 Closing the app causes settings in launcher to reset\n- #834 Mod import failing\n- #838 Exporting save file Pokemon Yellow\n- #839 AYN Thor Misplaced Data files\n- #849 Public folder support on iOS\n- #852 Cannot switch between saves states on smaller 4:3 screen or in vertical mode\n- #857 Mt. Moon Fossils Reappeared and Won’t Disappear.\n- #863 [Yellow] When you use stairs, Pikachu shouldn't be next to you in the new area\n- #864 Faithful Ratio\n- #867 Missing Dialogue after defeating Marowak in Pokemon Tower\n- #869 Giovanni moves up to the player too early\n- #870 Start Menu on Classic Color\n- #872 Missing text when finding an item with full inventory\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.71",
|
||||
"date": "2026-08-05",
|
||||
"size": 9569738,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.71/gen1recomp-0.1.71-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #806 Haptic Feedback for On-screen controls (mobile)\n- #809 NPC Stuck in Rock when battle triggered and pushing rock towards it\n- #853 When selecting one of the two pokemon after defeating the Karate Master it should show the Pokedex entry\n- #854 Textbox disappears when the yes/no dialogue appears\n- #860 Disabled moves can still be used in the turn they were disabled\n- #862 Casino Poster Rocket Grunt walks into the poster and doesn't \"Dang!\"\n- #865 [Yellow] James doesn't move in multiple encounters\n- #866 [Yellow] Dialogue in wrong \"order\" after multiple encounters (J+J, Giovanni, probably more)\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.70",
|
||||
"date": "2026-08-05",
|
||||
"size": 9562731,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.70/gen1recomp-0.1.70-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #590 Launcher/Save Editor/Dex\n- #788 Fly to the Pokemon Centers in the Routes is not available in original\n- #795 Fly logic is drastically different from the originals.\n- #796 Using Rare Candy from the menu closes it out.\n- #797 Gym Leaders giving items when your bag is full / Bypassing bag limit.\n- #805 Escape Rope Moltres Tower\n- #826 \"Super effective\" and \"not very effective\" SFX are reversed\n- #833 Cancelling nickname entry results in \"A\" as the nickname\n- #835 Restarting the launcher forgets the last rom used\n- #837 Wrong sound effect for Pikachu when entering battle\n- #844 Blizzard sound effect.\n- #845 Moderate issue: Fuchsia City binoculars.\n- #846 Surfing speed after using the bicycle.\n- #847 Minor issues related to the endgame.\n\n## Contributors\n\n- @bryanthaboi\n- @dburton95\n- @johnjohto\n- @KikiManjaro"
|
||||
},
|
||||
{
|
||||
"version": "0.1.69",
|
||||
"date": "2026-08-04",
|
||||
"size": 9552851,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.69/gen1recomp-0.1.69-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #768 Menu behaviour for Pokemon and HM moves\n- #785 Back to launcher\n- #792 HM moves in the wrong position in the menu.\n- #807 Expose gameplay pointer events and source-safe mod input injection\n- #811 Untranslatables\n- #814 [minor thing] bold arrow on move swap (select)\n\n## Contributors\n\n- @bryanthaboi\n- @johnjohto"
|
||||
},
|
||||
{
|
||||
"version": "0.1.68",
|
||||
"date": "2026-08-04",
|
||||
"size": 9768011,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.68/gen1recomp-0.1.68-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi\n- @caorthann-celt"
|
||||
},
|
||||
{
|
||||
"version": "0.1.67",
|
||||
"date": "2026-08-04",
|
||||
"size": 9767619,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.67/gen1recomp-0.1.67-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #373 Lines at Title Screen\n- #644 Substitute failing causes visual issues\n- #673 iOS: thin vertical seam below the START menu panel (v0.1.56, iPhone 15 Pro Max)\n- #703 Credits playing too fast (song ends after it should)\n- #726 Surfing Pikachu Minigame Broken\n- #737 Battle menu move cursor fails to reset to the first slot after switching Pokémon\n- #750 NPC (possibly player) trades still graphically broken\n- #752 Launcher exports save to AppData while in portable mode\n- #764 Trainer Fanfare doesn't play\n- #765 Text Advance broken in certain aspects\n- #768 Menu behaviour for Pokemon and HM moves\n- #773 Battle screen colours messed up when BG = World, battle in un-flashed Rock Tunnel\n- #774 bug(build): Desktop build can reject a valid game archive under pipefail\n- #775 TM42 Dream Eater dialog.\n- #777 Battle Screen is Very dark\n- #780 Do not delete save\n- #782 Giovanni battle at Silph Co plays wrong song\n\n## Contributors\n\n- @bryanthaboi\n- @luisgonzaleznf\n- @ShaneMcGovernIE"
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"size": 8337730,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.60/gen1recomp-0.1.60-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #596 No rival theme\n- #641 PP UP UI\n- #648 The revive item does not return the divided experience to fallen Pokemon that participated in battle.\n- #677 Gamespeed 3x\n- #683 Rival theme not playing in Oak's Lab\n- #689 Corrupted color on table in Celadon City using Advanced Colors preset\n- #694 Missing sound effect when falling from boulder holes\n- #695 Pressing B on the PC should bring you back to the previous screen, not turn it off\n\n## Contributors\n\n- @bryanthaboi\n- @castdrian\n- @ShaneMcGovernIE\n- @spiritsnails\n- Shane McGovern"
|
||||
},
|
||||
{
|
||||
"version": "0.1.59",
|
||||
"date": "2026-08-02",
|
||||
"size": 8334731,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.59/gen1recomp-0.1.59-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #668 No Jingle playing when receiving first Pokemon from Oak\n- #671 faint animation error\n\n## Contributors\n\n- @bryanthaboi\n- @castdrian\n- @jherediagu\n- @spiritsnails\n- Shane McGovern"
|
||||
},
|
||||
{
|
||||
"version": "0.1.58",
|
||||
"date": "2026-08-02",
|
||||
"size": 8331163,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.58/gen1recomp-0.1.58-ios.ipa",
|
||||
"localizedDescription": "Gen1Recomp - A native Lua / LÖVE2D recreation of Gen 1 Poke"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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:)
|
||||
@@ -46,11 +85,30 @@ public final class GRPickerBridge: NSObject {
|
||||
types = [.zip]
|
||||
case "sav":
|
||||
destName = "picked_save.sav"
|
||||
default:
|
||||
// A Nintendo 64 cartridge, for mods that build assets out of one --
|
||||
// the voxel mod's Pokemon Stadium battle models are the caller this
|
||||
// was added for. Its own filename on purpose: an N64 ROM landing on
|
||||
// picked_rom.gb is swept up by the Game Boy importer, deleted, and
|
||||
// reported to the player as a broken cartridge.
|
||||
case "stadium":
|
||||
destName = "picked_stadium.z64"
|
||||
for ext in ["z64", "n64", "v64"] {
|
||||
if let t = UTType(filenameExtension: ext) { types.append(t) }
|
||||
}
|
||||
case "rom", "":
|
||||
destName = "picked_rom.gb"
|
||||
for ext in ["gb", "gbc"] {
|
||||
if let t = UTType(filenameExtension: ext) { types.append(t) }
|
||||
}
|
||||
// An unknown kind is REFUSED rather than treated as a Game Boy ROM.
|
||||
//
|
||||
// It used to fall through to picked_rom.gb, so a caller asking for a
|
||||
// kind this build had never heard of got its file deleted and
|
||||
// reported as a broken cartridge -- the worst possible answer to
|
||||
// "I do not know that one". Returning false lets the caller find out
|
||||
// and offer its own fallback.
|
||||
default:
|
||||
return false
|
||||
}
|
||||
// .gb/.gbc/.sav resolve to dynamic UTTypes on most devices; offering
|
||||
// .data as well keeps every real file selectable. The importer
|
||||
@@ -68,6 +126,20 @@ public final class GRPickerBridge: NSObject {
|
||||
return present(picker, with: delegate)
|
||||
}
|
||||
|
||||
// Which kinds presentPicker understands, comma separated.
|
||||
//
|
||||
// So a CALLER can ask before it calls. A mod that wants a kind this build
|
||||
// predates cannot otherwise tell "refused" from "the picker would not
|
||||
// open", and guessing wrong used to cost the player their ROM (see the
|
||||
// default case above). Asking first turns that into a fallback the caller
|
||||
// chooses rather than a file it loses.
|
||||
//
|
||||
// Kept beside the switch it describes, because the two drifting apart is
|
||||
// the only way this can lie.
|
||||
@objc public static func supportedPickerKinds() -> NSString {
|
||||
return "rom,mod,sav,stadium" as NSString
|
||||
}
|
||||
|
||||
@objc(presentExportWithName:saveDir:)
|
||||
public static func presentExport(name: UnsafePointer<CChar>?,
|
||||
saveDir: UnsafePointer<CChar>?) -> Bool {
|
||||
@@ -174,15 +246,27 @@ public final class GRPickerBridge: NSObject {
|
||||
// Without this the stacked present makes the picker auto-dismiss
|
||||
// with zero documents (observed as didPickDocumentsAt 0 urls)
|
||||
// and the user's pick silently does nothing.
|
||||
guard var top = UIApplication.shared.windows
|
||||
.first(where: { $0.isKeyWindow })?.rootViewController
|
||||
else { return false }
|
||||
// Self-heal before consulting the list. If UIKit is presenting
|
||||
// nothing at all, any delegate still in it belongs to a sheet
|
||||
// that is long gone, and treating it as live would lock the
|
||||
// picker out for the rest of the session. Belt and braces with
|
||||
// the dismissal callback above: that one closes the known hole,
|
||||
// this one closes whatever hole iOS invents next.
|
||||
if top.presentedViewController == nil, !liveDelegates.isEmpty {
|
||||
NSLog("GRPickerBridge: clearing %d stale delegate(s)",
|
||||
liveDelegates.count)
|
||||
liveDelegates.removeAll()
|
||||
}
|
||||
guard liveDelegates.isEmpty else {
|
||||
NSLog("GRPickerBridge: picker already active; ignoring re-present")
|
||||
return true
|
||||
}
|
||||
guard var top = UIApplication.shared.windows
|
||||
.first(where: { $0.isKeyWindow })?.rootViewController
|
||||
else { return false }
|
||||
while let presented = top.presentedViewController { top = presented }
|
||||
picker.delegate = delegate
|
||||
picker.presentationController?.delegate = delegate
|
||||
liveDelegates.append(delegate)
|
||||
delegate.onFinish = { [weak delegate] in
|
||||
liveDelegates.removeAll { $0 === delegate }
|
||||
@@ -197,7 +281,8 @@ public final class GRPickerBridge: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
private final class PickerDelegate: NSObject, UIDocumentPickerDelegate {
|
||||
private final class PickerDelegate: NSObject, UIDocumentPickerDelegate,
|
||||
UIAdaptivePresentationControllerDelegate {
|
||||
private let onPick: ([URL]) -> Void
|
||||
var onFinish: (() -> Void)?
|
||||
init(onPick: @escaping ([URL]) -> Void) { self.onPick = onPick }
|
||||
@@ -215,4 +300,18 @@ private final class PickerDelegate: NSObject, UIDocumentPickerDelegate {
|
||||
NSLog("GRPickerBridge: picker cancelled")
|
||||
onFinish?()
|
||||
}
|
||||
|
||||
// Swiping the sheet down calls NEITHER of the two above: since iOS 13 an
|
||||
// interactively dismissed picker reports only through the adaptive
|
||||
// presentation delegate. Without this the delegate is never taken out of
|
||||
// liveDelegates, the re-present guard below then swallows every later
|
||||
// picker while still answering true -- so Lua arms its poll and waits for
|
||||
// a file that no sheet is ever going to produce. That is the whole of the
|
||||
// "Import ROM does nothing until you restart the app" report: the restart
|
||||
// is not refreshing anything, it is clearing this array.
|
||||
func presentationControllerDidDismiss(_ presentationController:
|
||||
UIPresentationController) {
|
||||
NSLog("GRPickerBridge: picker dismissed interactively")
|
||||
onFinish?()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>${PRODUCT_NAME}</string>
|
||||
<string>gen1recomp</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
@@ -44,9 +44,9 @@
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
<false/>
|
||||
<true/>
|
||||
<key>UIFileSharingEnabled</key>
|
||||
<false/>
|
||||
<true/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>Launch Screen</string>
|
||||
<key>UIStatusBarHidden</key>
|
||||
@@ -96,13 +96,6 @@
|
||||
</dict>
|
||||
</dict>
|
||||
</array>
|
||||
<!-- gen1recomp iOS: expose the app's Documents folder in the Files app /
|
||||
Finder so ROMs, mod .zips, and .sav files can be dropped in without
|
||||
the picker; GRBootstrap sweeps them into the LÖVE save dir. -->
|
||||
<key>UIFileSharingEnabled</key>
|
||||
<true/>
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
<true/>
|
||||
<!-- gen1recomp iOS: Pokéwalker mod reads step counts (opt-in, in the
|
||||
in-game mod manager) and converts them to Pokémon EXP. -->
|
||||
<key>NSHealthShareUsageDescription</key>
|
||||
|
||||
@@ -84,6 +84,49 @@ int w_pickFile(lua_State *L)
|
||||
return gr_callBridge(L, "GRPickerBridge", "presentPickerWithKind:saveDir:", kind);
|
||||
}
|
||||
|
||||
// love.system.pickFileKinds() -> "rom,mod,sav,stadium", or nil off iOS.
|
||||
//
|
||||
// So a caller can ask what this build's picker understands BEFORE opening it.
|
||||
// An unknown kind is refused (GRPickerBridge), and a refusal looks exactly
|
||||
// like a picker that would not open -- so a caller with a fallback worth
|
||||
// showing needs to know which it is facing. A mod that guesses instead has
|
||||
// no way back: before the refusal landed, an unrecognised kind wrote
|
||||
// picked_rom.gb and the ROM importer deleted it.
|
||||
//
|
||||
// nil where there is no bridge at all, which reads the same as "no kinds".
|
||||
int w_pickFileKinds(lua_State *L)
|
||||
{
|
||||
Class cls = objc_getClass("GRPickerBridge");
|
||||
if (cls == nullptr)
|
||||
{
|
||||
lua_pushnil(L);
|
||||
return 1;
|
||||
}
|
||||
// Fetched through the runtime: wrap_System.cpp is compiled as C++ rather
|
||||
// than Objective-C++, so no Foundation type may be NAMED here -- writing
|
||||
// `NSString` alone breaks the whole translation unit. objc_msgSend is a
|
||||
// plain C entry point and `id` comes from objc/runtime.h, so the string
|
||||
// is asked for its UTF8 bytes without ever being typed.
|
||||
typedef id (*GRObj)(Class, SEL);
|
||||
id kinds = ((GRObj)objc_msgSend)(cls,
|
||||
sel_registerName("supportedPickerKinds"));
|
||||
if (kinds == nullptr)
|
||||
{
|
||||
lua_pushnil(L);
|
||||
return 1;
|
||||
}
|
||||
typedef const char *(*GRUTF8)(id, SEL);
|
||||
const char *bytes = ((GRUTF8)objc_msgSend)(kinds,
|
||||
sel_registerName("UTF8String"));
|
||||
if (bytes == nullptr || bytes[0] == '\0')
|
||||
{
|
||||
lua_pushnil(L);
|
||||
return 1;
|
||||
}
|
||||
lua_pushstring(L, bytes);
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_createFile(lua_State *L)
|
||||
{
|
||||
const char *name = luaL_optstring(L, 1, "export.sav");
|
||||
@@ -101,8 +144,77 @@ int w_syncHealthSteps(lua_State *L)
|
||||
|
||||
WRAP_REGISTRATION = """#ifdef LOVE_IOS
|
||||
{ "pickFile", w_pickFile },
|
||||
{ "pickFileKinds", w_pickFileKinds },
|
||||
{ "createFile", w_createFile },
|
||||
{ "syncHealthSteps", w_syncHealthSteps },
|
||||
{ "httpDownload", w_httpDownload },
|
||||
#endif
|
||||
"""
|
||||
|
||||
WRAP_SYNC_FUNCS = """
|
||||
#ifdef LOVE_IOS
|
||||
static const char *gr_saveDirectory()
|
||||
{
|
||||
static std::string saveDirectory;
|
||||
auto fs = Module::getInstance<love::filesystem::Filesystem>(Module::M_FILESYSTEM);
|
||||
if (fs == nullptr)
|
||||
return "";
|
||||
saveDirectory = fs->getSaveDirectory();
|
||||
return saveDirectory.c_str();
|
||||
}
|
||||
|
||||
static int gr_callBridge(lua_State *L, const char *className,
|
||||
const char *selector, const char *arg)
|
||||
{
|
||||
Class cls = objc_getClass(className);
|
||||
if (cls == nullptr)
|
||||
{
|
||||
lua_pushboolean(L, 0);
|
||||
return 1;
|
||||
}
|
||||
typedef signed char (*GRMsg)(Class, SEL, const char *, const char *);
|
||||
signed char ok = ((GRMsg)objc_msgSend)(cls, sel_registerName(selector),
|
||||
arg, gr_saveDirectory());
|
||||
lua_pushboolean(L, ok != 0);
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_syncHealthSteps(lua_State *L)
|
||||
{
|
||||
return gr_callBridge(L, "GRHealthBridge", "syncStepsWithCommand:saveDir:", "sync");
|
||||
}
|
||||
#endif
|
||||
|
||||
"""
|
||||
|
||||
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
|
||||
"""
|
||||
|
||||
@@ -174,14 +286,17 @@ def patch_wrap_system():
|
||||
anchor = "static const luaL_Reg functions[] ="
|
||||
if anchor not in text:
|
||||
fail(f"anchor not found in {WRAP_SYSTEM}")
|
||||
text = text.replace(anchor, WRAP_FUNCS + anchor, 1)
|
||||
has_native_picker = re.search(r"\bint w_pickFile\s*\(", text) is not None
|
||||
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}")
|
||||
text = text.replace(reg_anchor, reg_anchor + WRAP_REGISTRATION, 1)
|
||||
registration = WRAP_SYNC_REGISTRATION if has_native_picker else WRAP_REGISTRATION
|
||||
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():
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# spanish_ui
|
||||
|
||||
A Espanol translation of the game.
|
||||
|
||||
Generated with `python3 tools/modkit.py translation spanish_ui`. See
|
||||
`TRANSLATING.md` for how to work on it.
|
||||
|
||||
## Status
|
||||
|
||||
Nothing is translated yet: 601 strings are waiting in `lang/`.
|
||||
|
||||
| Catalog | Entries |
|
||||
|---|---|
|
||||
| `lang/dialogue.lua` | 6 |
|
||||
| `lang/strings.lua` | 577 |
|
||||
| `lang/species_names.lua` | 3 |
|
||||
| `lang/move_names.lua` | 4 |
|
||||
| `lang/item_names.lua` | 5 |
|
||||
| `lang/trainer_names.lua` | 1 |
|
||||
| `lang/status_labels.lua` | 5 |
|
||||
|
||||
## Layout
|
||||
|
||||
- `manifest.json` - identity and the engine version range
|
||||
- `main.lua` - registers whatever is filled in and skips whatever is not
|
||||
- `lang/` - the catalogs; this is the whole job
|
||||
- `assets/font/` - your glyph sheet
|
||||
@@ -0,0 +1,111 @@
|
||||
# Translating into Espanol
|
||||
|
||||
Everything the player can read is one of two kinds of string, and they live
|
||||
in different places for a reason.
|
||||
|
||||
| lang/ file | What it is | Key |
|
||||
|---|---|---|
|
||||
| `dialogue.lua` | Every line of extracted script text | the original label, e.g. `_PalletTownText1` |
|
||||
| `strings.lua` | Text the engine itself writes: battle messages, menus, link play | the English source string |
|
||||
| `species.lua` `moves.lua` `items.lua` `trainers.lua` | Names | the vanilla id |
|
||||
| `statuses.lua` | `PSN`, `BRN`, ... as they appear in the HUD | the status id |
|
||||
| `font.lua` `charmap.lua` | Your glyph sheet and what draws what | see below |
|
||||
| `naming.lua` | The letter grid for entering names | - |
|
||||
|
||||
Fill in a value and it takes effect. Leave it `""` and that string stays in
|
||||
English, so the game is playable at every point along the way.
|
||||
|
||||
## Where the English is
|
||||
|
||||
The catalogs hold keys and *your* text, never the original English. The
|
||||
English lives next door, in `spanish_ui-worksheet/`, one tab-separated file per
|
||||
catalog:
|
||||
|
||||
```
|
||||
"_AbandonLearningText" "Abandon learning\n{RAM:wStringBuffer}?"
|
||||
```
|
||||
|
||||
That directory is deliberately outside the mod. Extracted script text and
|
||||
the vanilla names are ROM content, and `modkit pack` zips everything under
|
||||
the mod directory, so a worksheet kept inside would end up in your release
|
||||
whatever a `.gitignore` said. Keep it beside the mod, never in it.
|
||||
|
||||
`lang/strings.lua` is the exception: those sources are the engine's own Lua
|
||||
rather than anything out of the ROM, so there the key *is* the English and
|
||||
you can translate straight from it.
|
||||
|
||||
## Start with the font, not the text
|
||||
|
||||
The engine draws from **glyph pages**: an image of 8x8 cells plus a charmap
|
||||
saying which byte sequence draws which cell. The vanilla pages sit at `$60`
|
||||
and `$80`. Anything from `0x100` up is free, so a new alphabet is added
|
||||
rather than swapped in:
|
||||
|
||||
```lua
|
||||
-- lang/font.lua
|
||||
return {
|
||||
spanish_ui = {
|
||||
image = "assets/font/spanish_ui.png",
|
||||
base = 0x100, -- first code this page owns
|
||||
glyphsPerRow = 16,
|
||||
-- advance = 8, -- set this if your glyphs are not 8px wide
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
```lua
|
||||
-- lang/charmap.lua: sequence -> code, in the same order as the sheet
|
||||
return {
|
||||
["A"] = 0x100,
|
||||
["B"] = 0x101,
|
||||
}
|
||||
```
|
||||
|
||||
The sheet is a plain PNG, 16 glyphs to a row by default, each cell 8x8,
|
||||
black on white like `assets/generated/font.png`. Codes run left to right,
|
||||
top to bottom from `base`.
|
||||
|
||||
Sequences are matched **longest first**, so a multi-byte character and a
|
||||
multi-character ligature both work and neither shadows the other:
|
||||
|
||||
```lua
|
||||
["\u{3042}"] = 0x120, -- one 3-byte character, one glyph
|
||||
["ch"] = 0x121, -- two ASCII letters, one glyph
|
||||
```
|
||||
|
||||
## Line length is counted in glyphs
|
||||
|
||||
The dialogue box fits 18 glyphs a line, not 18 bytes. A 3-byte character
|
||||
costs one column, and the engine will never cut a character in half. Your
|
||||
own `\n` line breaks are respected exactly as written, so break lines where
|
||||
they read best rather than where they fit English.
|
||||
|
||||
If your glyphs are not 8px wide, set `advance` on the page and the box
|
||||
re-measures.
|
||||
|
||||
## Format directives must survive
|
||||
|
||||
Some sources carry `%s` or `%d`:
|
||||
|
||||
```lua
|
||||
["Wild %s\nappeared!"] = "...",
|
||||
```
|
||||
|
||||
Keep every directive, in a count that matches. Word order is yours to
|
||||
change; the engine substitutes in the order the directives appear, so if
|
||||
your language needs the name last, write the sentence with the `%s` last.
|
||||
A translation whose directive count does not match the English is refused
|
||||
at runtime and the English is drawn instead, with a line in the log saying
|
||||
so - it will not crash a battle.
|
||||
|
||||
## Checking your work
|
||||
|
||||
```sh
|
||||
python3 tools/modkit.py validate spanish_ui --base imported
|
||||
python3 tools/modkit.py translation spanish_ui --refresh # pick up new engine strings
|
||||
POKEPORT_DEV=1 scripts/run.sh # F5 hot-reloads lang/
|
||||
```
|
||||
|
||||
`--refresh` rewrites the catalogs from the current engine, keeping every
|
||||
translation you have already written and reporting what changed. Run it
|
||||
after pulling a new engine version.
|
||||
@@ -0,0 +1,11 @@
|
||||
Put your glyph sheet here.
|
||||
|
||||
A page is a PNG of 8x8 cells, 16 per row by default, black on white. Codes
|
||||
run left to right and top to bottom starting at the page's `base`, so the
|
||||
first cell is `base`, the second `base + 1`, and so on.
|
||||
|
||||
`assets/generated/font.png` in the player's cache is the vanilla sheet at
|
||||
the same scale; open it alongside yours to match weight and baseline.
|
||||
|
||||
Declare the sheet in `lang/font.lua` and map sequences to codes in
|
||||
`lang/charmap.lua`.
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Which byte sequence draws which glyph code.
|
||||
--
|
||||
-- Sequences are matched longest-first, so a multi-byte character and a
|
||||
-- multi-character ligature both work: "ch" can be one glyph even though
|
||||
-- "c" is also mapped. Codes here must land inside a page declared in
|
||||
-- lang/font.lua.
|
||||
return {
|
||||
-- ["A"] = 0x100,
|
||||
-- ["B"] = 0x101,
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Script text
|
||||
--
|
||||
-- Keyed by the original text label. The English is in the comment.
|
||||
|
||||
return {
|
||||
["_FixMartText"] = "",
|
||||
["_FixRouteTrainerAfterText"] = "",
|
||||
["_FixRouteTrainerBattleText"] = "",
|
||||
["_FixRouteTrainerEndText"] = "",
|
||||
["_FixTownGreeterText"] = "",
|
||||
["_FixTownSignText"] = "",
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Glyph pages this translation adds. Delete the entry if the vanilla
|
||||
-- alphabet already covers your language.
|
||||
--
|
||||
-- base is the first glyph code the page owns. 0x100 and up is free space
|
||||
-- above the vanilla $60/$80 pages, so this adds an alphabet rather than
|
||||
-- replacing one. Set `advance` if your glyphs are not 8px wide.
|
||||
return {
|
||||
-- spanish_ui = {
|
||||
-- image = "assets/font/spanish_ui.png",
|
||||
-- base = 0x100,
|
||||
-- glyphsPerRow = 16,
|
||||
-- },
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Item names
|
||||
--
|
||||
-- Item names for Espanol.
|
||||
|
||||
return {
|
||||
["FIX_BADGE_1"] = "",
|
||||
["FIX_BADGE_2"] = "",
|
||||
["FIX_BALL"] = "",
|
||||
["FIX_POTION"] = "",
|
||||
["FIX_TM"] = "",
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Move names
|
||||
--
|
||||
-- Move names for Espanol.
|
||||
|
||||
return {
|
||||
["FIX_CUT"] = "",
|
||||
["FIX_EMBERISH"] = "",
|
||||
["FIX_SCRATCH"] = "",
|
||||
["FIX_TACKLE"] = "",
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
-- The naming screen's letter grid. Return an empty table to keep the
|
||||
-- English alphabet.
|
||||
--
|
||||
-- Each entry is a row of cells; a cell is whatever sequence your charmap
|
||||
-- maps, so a multi-byte character is one cell. The row holding a single
|
||||
-- "lower case" / "UPPER CASE" cell is the case switch, and the cell
|
||||
-- spelled "ED" is the confirm.
|
||||
--
|
||||
-- The screen is 160x144 and NamingScreen draws cell `c` of row `r` at
|
||||
-- (c * 16, 32 + r * 16), so the grid is capped at **9 columns and 6 rows**:
|
||||
-- a 10th column lands at x=160 and a 7th row at y=144, both off screen.
|
||||
-- That leaves 44 usable cells, exactly what vanilla uses, so Spanish
|
||||
-- letters have to displace something rather than being added.
|
||||
--
|
||||
-- What gives way is vanilla's `× ( ) : ; [ ]` row. Those are legal in a
|
||||
-- Gen-1 nickname but nobody reaches for them, whereas Ñ is not optional in
|
||||
-- Spanish -- and here it sits in its alphabetical place after N, which is
|
||||
-- where a Spanish speaker will look for it. Space, <PK> and <MN> are kept.
|
||||
--
|
||||
-- These glyphs exist in the Spanish cartridge's font ($CA Ñ, $BF Á, $C7 É,
|
||||
-- $C9 Í, $CC Ó, $CE Ú, $C2 Ü and their lowercase). On an English ROM they
|
||||
-- do not, so main.lua checks the running game's charmap first and keeps the
|
||||
-- English grid rather than drawing blank cells.
|
||||
return {
|
||||
upper = {
|
||||
{ "A", "B", "C", "D", "E", "F", "G", "H", "I" },
|
||||
{ "J", "K", "L", "M", "N", "Ñ", "O", "P", "Q" },
|
||||
{ "R", "S", "T", "U", "V", "W", "X", "Y", "Z" },
|
||||
{ "Á", "É", "Í", "Ó", "Ú", "Ü", " ", "<PK>", "<MN>" },
|
||||
{ "-", "?", "!", "♂", "♀", "/", ".", ",", "ED" },
|
||||
{ "lower case" },
|
||||
},
|
||||
lower = {
|
||||
{ "a", "b", "c", "d", "e", "f", "g", "h", "i" },
|
||||
{ "j", "k", "l", "m", "n", "ñ", "o", "p", "q" },
|
||||
{ "r", "s", "t", "u", "v", "w", "x", "y", "z" },
|
||||
{ "á", "é", "í", "ó", "ú", "ü", " ", "<PK>", "<MN>" },
|
||||
{ "-", "?", "!", "♂", "♀", "/", ".", ",", "ED" },
|
||||
{ "UPPER CASE" },
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Species names
|
||||
--
|
||||
-- Species names for Espanol.
|
||||
|
||||
return {
|
||||
["FIXMON_A"] = "",
|
||||
["FIXMON_B"] = "",
|
||||
["FIXMON_C"] = "",
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Status labels
|
||||
--
|
||||
-- Short enough for the battle HUD: the vanilla ones are three glyphs.
|
||||
|
||||
return {
|
||||
["BRN"] = "",
|
||||
["FRZ"] = "",
|
||||
["PAR"] = "",
|
||||
["PSN"] = "",
|
||||
["SLP"] = "",
|
||||
}
|
||||
@@ -0,0 +1,584 @@
|
||||
-- Engine text
|
||||
--
|
||||
-- Keyed by the English source, which is also what draws if you leave
|
||||
-- an entry empty. Keep any %s / %d directives.
|
||||
|
||||
return {
|
||||
["%s\nflew up high!"] = "¡%s\nvoló muy alto!",
|
||||
["%s\ndug a hole!"] = "¡%s\ncavó un hoyo!",
|
||||
["%s\nmade a whirlwind!"] = "¡%s\ncreó un torbellino!",
|
||||
["%s\ntook in sunlight!"] = "¡%s\nabsorbió luz!",
|
||||
["%s\nlowered its head!"] = "¡%s\nbajó la cabeza!",
|
||||
["%s\nis glowing!"] = "¡%s\nestá brillando!",
|
||||
["The hooked\n%s\nattacked!"] = "¡El %s\nenganchado atacó!",
|
||||
["Wild %s\nappeared!"] = "¡Un %s\nsalvaje apareció!",
|
||||
["%s wants\nto fight!"] = "¡%s\nquiere luchar!",
|
||||
["The GHOST\nappeared!"] = "¡Apareció el\nFANTASMA!",
|
||||
["Go! %s!"] = "¡Ve, %s!",
|
||||
["Do it! %s!"] = "¡Hazlo, %s!",
|
||||
["Get'm! %s!"] = "¡A por él, %s!",
|
||||
["The enemy's weak!\nGet'm! %s!"] = "¡Está débil!\n¡A por él, %s!",
|
||||
["%s is out of\nuseable POKéMON!"] = "¡%s no tiene\nPOKéMON útiles!",
|
||||
["%s blacked\nout!"] = "¡%s se\ndebilitó!",
|
||||
["%s sent\nout %s!"] = "¡%s envió\na %s!",
|
||||
["PA: You're out of\nSAFARI BALLs!\nGame over!"] = "AV: ¡No te quedan\nSAFARI BALLs!\n¡Fin del juego!",
|
||||
["%s is too\nscared to move!"] = "¡%s tiene\ndemasiado miedo!",
|
||||
["%s has no\nmoves left!"] = "¡%s no tiene\nmovimientos!",
|
||||
["The move is\ndisabled!"] = "¡El movimiento\nestá anulado!",
|
||||
["No PP left for\nthis move!"] = "¡No quedan PP para\neste movimiento!",
|
||||
["But, it failed!"] = "¡Pero falló!",
|
||||
["%s\nlearned\n%s!"] = "¡%s\naprendió\n%s!",
|
||||
["POKé BALL"] = "POKé BALL",
|
||||
["%s used\nPOKé BALL!"] = "¡%s usó\nPOKé BALL!",
|
||||
["All right!\n%s was\ncaught!"] = "¡Bien!\n¡%s fue\ncapturado!",
|
||||
["GHOST: Get out...\nGet out..."] = "FANTASMA: Fuera...\nFuera...",
|
||||
["%s with-\ndrew %s!"] = "¡%s retiró\na %s!",
|
||||
["%s\nmust recharge!"] = "¡%s debe\nrecargarse!",
|
||||
["%s\nis fast asleep!"] = "¡%s está\nprofundamente dormido!",
|
||||
["%s\nis confused!"] = "¡%s está\nconfuso!",
|
||||
["%s\nwoke up!"] = "¡%s se\ndespertó!",
|
||||
["%s\nis frozen solid!"] = "¡%s está\ncongelado!",
|
||||
["%s\ncan't move!"] = "¡%s no\npuede moverse!",
|
||||
["%s\nflinched!"] = "¡%s se\namedrentó!",
|
||||
["It hurt itself in\nits confusion!"] = "¡Se hirió a sí\nmismo por confusión!",
|
||||
["%s\nused %s!"] = "¡%s usó\n%s!",
|
||||
["%s\nis charging up!"] = "¡%s está\ncargando energía!",
|
||||
["%s's\nattack missed!"] = "¡El ataque de %s\nfalló!",
|
||||
["%s's\nattack continues!"] = "¡El ataque de %s\ncontinúa!",
|
||||
["%s\nis storing energy!"] = "¡%s está\nacumulando energía!",
|
||||
["%s\nunleashed energy!"] = "¡%s liberó\nsu energía!",
|
||||
["%s's\nSUBSTITUTE broke!"] = "¡El SUSTITUTO de\n%s se rompió!",
|
||||
["The SUBSTITUTE\ntook damage for\n%s!"] = "¡El SUSTITUTO\nrecibió el daño\nde %s!",
|
||||
["%s's\nRAGE is building!"] = "¡La FURIA de %s\nva creciendo!",
|
||||
["%s\nfainted!"] = "¡%s se\ndebilitó!",
|
||||
["%s gained\n%d EXP. Points!"] = "¡%s ganó\n%d P. EXP.!",
|
||||
["%s gained\nwith EXP.ALL,\v%d EXP. Points!"] = "¡%s ganó\ncon EXP.TODOS,\v%d P. EXP.!",
|
||||
["%s gained\na boosted\v%d EXP. Points!"] = "¡%s ganó\nun extra de\v%d P. EXP.!",
|
||||
["%s grew\nto level %d!"] = "¡%s subió\nal nivel %d!",
|
||||
["%s is\nabout to use"] = "%s va a usar",
|
||||
["%s!"] = "¡%s!",
|
||||
["Will %s\nchange POKéMON?"] = "¿%s va a\ncambiar de POKéMON?",
|
||||
["%s defeated\n%s!"] = "¡%s venció\na %s!",
|
||||
["%s got ¥%d\nfor winning!"] = "¡%s ganó\n¥%d!",
|
||||
["%s learned\n%s!"] = "¡%s aprendió\n%s!",
|
||||
["{RIVAL}: Yeah! Am\nI great or what?"] = "{RIVAL}: ¡Sí! ¿Soy\ngenial o qué?",
|
||||
["Use next POKéMON?"] = "¿Sacar al siguiente?",
|
||||
["Got away safely!"] = "¡Escapaste!",
|
||||
["Can't escape!"] = "¡No puedes escapar!",
|
||||
["There's no will\nto fight!"] = "¡No hay ganas de\nluchar!",
|
||||
["%s used\nSAFARI BALL!"] = "¡%s usó\nSAFARI BALL!",
|
||||
["%s threw some\nBAIT."] = "%s echó\nCEBO.",
|
||||
["%s threw a\nROCK."] = "%s tiró una\nPIEDRA.",
|
||||
["Wild %s\nis eating!"] = "¡El %s\nsalvaje come!",
|
||||
["Wild %s\nis angry!"] = "¡El %s\nsalvaje se enfadó!",
|
||||
["Wild %s\nran!"] = "¡El %s\nsalvaje huyó!",
|
||||
["No! There's no\nrunning from a\vtrainer battle!"] = "¡No! ¡No puedes\nhuir de un combate\vcontra un entrenador!",
|
||||
["You missed the\nPOKéMON!"] = "¡Fallaste el tiro!",
|
||||
["Darn! The POKéMON\nbroke free!"] = "¡Vaya! ¡El POKéMON\nse escapó!",
|
||||
["Aww! It appeared\nto be caught!"] = "¡Oh! ¡Parecía que\nestaba capturado!",
|
||||
["Shoot! It was so\nclose too!"] = "¡Vaya! ¡Estuvo\nmuy cerca!",
|
||||
["Do you want to\ngive a nickname\nto %s?"] = "¿Quieres poner un\nmote a\n%s?",
|
||||
["NICKNAME?"] = "MOTE?",
|
||||
["New POKéDEX data\nwill be added for\n%s!"] = "¡Se añadirán datos\nnuevos a la POKéDEX\nde %s!",
|
||||
["someone's PC"] = "el PC de alguien",
|
||||
["%s was\ntransferred to\n%s!"] = "¡%s fue\ntransferido a\n%s!",
|
||||
["But every BOX\nis full!"] = "¡Pero todas las\nCAJAS están llenas!",
|
||||
["%s used\n%s!"] = "¡%s usó\n%s!",
|
||||
["The trainer\nblocked the BALL!"] = "¡El entrenador\nbloqueó la BALL!",
|
||||
["Don't be a thief!"] = "¡No seas ladrón!",
|
||||
["It dodged the\nthrown BALL!"] = "¡Esquivó la BALL!",
|
||||
["This POKéMON\ncan't be caught!"] = "¡Este POKéMON no\nse puede capturar!",
|
||||
["%s is\nalready out!"] = "¡%s ya\nestá fuera!",
|
||||
["%s picked up\n¥%d!"] = "¡%s recogió\n¥%d!",
|
||||
["FIGHT"] = "LUCHAR",
|
||||
["ITEM"] = "OBJETO",
|
||||
["RUN"] = "HUIR",
|
||||
["BALLx"] = "BALLx",
|
||||
["BAIT"] = "CEBO",
|
||||
["THROW ROCK"] = "TIRAR PIEDRA",
|
||||
["disabled!"] = "¡anulado!",
|
||||
["TYPE/"] = "TIPO/",
|
||||
["It doesn't affect\n%s!"] = "¡No afecta a\n%s!",
|
||||
["Critical hit!"] = "¡Golpe crítico!",
|
||||
["One-hit KO!"] = "¡KO en un golpe!",
|
||||
["It's super\neffective!"] = "¡Es muy eficaz!",
|
||||
["It's not very\neffective..."] = "¡No es muy\neficaz...",
|
||||
["Hit the enemy\n%d times!"] = "¡Golpeó al enemigo\n%d veces!",
|
||||
["Hit %d times!"] = "¡Golpeó %d veces!",
|
||||
["%s's\nhit with recoil!"] = "¡%s sufrió\nel retroceso!",
|
||||
["%s is\nprotected by MIST!"] = "¡%s está\nprotegido por NIEBLA!",
|
||||
["Nothing happened!"] = "¡No pasó nada!",
|
||||
["%s's\n%s\ngreatly rose!"] = "¡%s\nmejoró mucho su\n%s!",
|
||||
["%s's\n%s rose!"] = "¡%s mejoró\nsu %s!",
|
||||
["%s's\n%s fell!"] = "¡%s bajó\nsu %s!",
|
||||
["%s's\n%s\ngreatly fell!"] = "¡%s\nbajó mucho su\n%s!",
|
||||
["Fire defrosted\n%s!"] = "¡El fuego descongeló\na %s!",
|
||||
["%s\nbecame confused!"] = "¡%s se\nconfundió!",
|
||||
["%s\nwas seeded!"] = "¡%s recibió\nla DRENADORA!",
|
||||
["%s\nstarted sleeping!"] = "¡%s se\nquedó dormido!",
|
||||
["%s\nregained health!"] = "¡%s recuperó\nsalud!",
|
||||
["%s's\nprotected against\nspecial attacks!"] = "¡%s está\nprotegido de los\nataques especiales!",
|
||||
["%s\ngained armor!"] = "¡%s ganó\narmadura!",
|
||||
["%s's\nshrouded in mist!"] = "¡%s se\ncubrió de niebla!",
|
||||
["%s's\ngetting pumped!"] = "¡%s se\nestá animando!",
|
||||
["All STATUS changes\nare eliminated!"] = "¡Los cambios de\nESTADO desaparecen!",
|
||||
["%s\nhas a SUBSTITUTE!"] = "¡%s tiene\nun SUSTITUTO!",
|
||||
["Too weak to make\na SUBSTITUTE!"] = "¡Muy débil para\nhacer un SUSTITUTO!",
|
||||
["It created a\nSUBSTITUTE!"] = "¡Creó un SUSTITUTO!",
|
||||
["Converted type to\n%s's!"] = "¡Cambió su tipo al\nde %s!",
|
||||
["%s\ntransformed into\n%s!"] = "¡%s se\ntransformó en\n%s!",
|
||||
["%s's\n%s was\ndisabled!"] = "¡El %s\nde %s\nfue anulado!",
|
||||
["No effect!"] = "¡Sin efecto!",
|
||||
["Sucked health from\n%s!"] = "¡Absorbió salud de\n%s!",
|
||||
["%s's\ndream was eaten!"] = "¡Devoró el sueño\nde %s!",
|
||||
["%s\nkept going and\ncrashed!"] = "¡%s siguió\nadelante y se\nestrelló!",
|
||||
["Coins scattered\neverywhere!"] = "¡Las monedas se\ndesparramaron!",
|
||||
["%s\nran away scared!"] = "¡%s huyó\nasustado!",
|
||||
["%s\nwas blown away!"] = "¡%s salió\nvolando!",
|
||||
["%s\nran from battle!"] = "¡%s huyó\ndel combate!",
|
||||
["It didn't affect\n%s!"] = "¡No afectó a\n%s!",
|
||||
["%s\nis unaffected!"] = "¡%s no se\nvio afectado!",
|
||||
["The MIRROR MOVE\nfailed!"] = "¡El MOVIMIENTO\nESPEJO falló!",
|
||||
["%s\nfell asleep!"] = "¡%s se\nquedó dormido!",
|
||||
["%s\nwas frozen solid!"] = "¡%s se\ncongeló!",
|
||||
["%s's\nhurt by poison!"] = "¡El veneno hiere a\n%s!",
|
||||
["%s's\nbadly poisoned!"] = "¡%s está\ngravemente envenenado!",
|
||||
["%s\nwas poisoned!"] = "¡%s fue\nenvenenado!",
|
||||
["%s's\nhurt by the burn!"] = "¡La quemadura hiere\na %s!",
|
||||
["%s\nwas burned!"] = "¡%s se\nquemó!",
|
||||
["%s's\nfully paralyzed!"] = "¡%s está\ntotalmente paralizado!",
|
||||
["%s's\nparalyzed! It may\nnot attack!"] = "¡%s está\nparalizado! ¡Puede\nque no ataque!",
|
||||
["%s's\ndisabled no more!"] = "¡%s ya no\nestá anulado!",
|
||||
["%s\nsnapped out of\nconfusion!"] = "¡%s salió\nde su confusión!",
|
||||
["LEECH SEED saps\n%s!"] = "¡La DRENADORA\nabsorbe a %s!",
|
||||
["%s\nwas afflicted\nby %s!"] = "¡%s sufre\n%s!",
|
||||
["%s's\nprotected against\nstat changes!"] = "¡%s está\nprotegido de los\ncambios de estado!",
|
||||
["What will"] = "¿Qué va a hacer",
|
||||
[" do?"] = "?",
|
||||
["You can't get off\nhere."] = "No puedes bajarte\naquí.",
|
||||
["%s got off\nthe BICYCLE."] = "%s se bajó\nde la BICICLETA.",
|
||||
["%s got on\nthe BICYCLE!"] = "¡%s se subió\na la BICICLETA!",
|
||||
["No cycling\nallowed here."] = "No se puede montar\naquí.",
|
||||
["No good! It's not\neven near water."] = "¡No sirve! No hay\nagua cerca.",
|
||||
["OAK: %s!\nThis isn't the\ntime to use that!"] = "OAK: ¡%s!\n¡No es momento\nde usar eso!",
|
||||
["The TOWN MAP is\nunreadable here."] = "El MAPA PUEBLO no\nse puede leer aquí.",
|
||||
["Yes! ITEMFINDER\nindicates there's\nan item nearby."] = "¡Sí! El BUSCAOBJ.\nindica que hay algo\ncerca.",
|
||||
["Nope! ITEMFINDER\nisn't responding."] = "¡No! El BUSCAOBJ.\nno responde.",
|
||||
["Booted up a TM!"] = "¡Se activó una MT!",
|
||||
["It contained\n%s!"] = "¡Contenía\n%s!",
|
||||
["USE"] = "USAR",
|
||||
["TOSS"] = "TIRAR",
|
||||
["That's too impor-\ntant to toss!"] = "¡Es demasiado\nimportante!",
|
||||
["Threw away\n%s."] = "Tiraste\n%s.",
|
||||
["PRESS A BUTTON"] = "PULSA UN BOTON",
|
||||
["ESC TO CANCEL"] = "ESC PARA CANCELAR",
|
||||
["%s :L%d"] = "%s :N%d",
|
||||
["STATS"] = "DATOS",
|
||||
["CANCEL"] = "CANCELAR",
|
||||
["What? There are\nno POKéMON here!"] = "¿Qué? ¡Aquí no hay\nningún POKéMON!",
|
||||
["You can't take\nany more POKéMON.\fDeposit POKéMON\nfirst."] = "No puedes llevar\nmás POKéMON.\fGuarda alguno\nprimero.",
|
||||
["BOX %d (WITHDRAW)"] = "CAJA %d (RETIRAR)",
|
||||
["%s is\ntaken out.\vGot %s."] = "Retirado\n%s.\vRecibes %s.",
|
||||
["You can't deposit\nthe last POKéMON!"] = "¡No puedes guardar\nel último POKéMON!",
|
||||
["Oops! This Box is\nfull of POKéMON."] = "¡Uups! Esta CAJA\nestá llena.",
|
||||
["You need at least\none POKéMON!"] = "¡Necesitas al menos\nun POKéMON!",
|
||||
["BOX %d is full!"] = "¡La CAJA %d está\nllena!",
|
||||
["%s was\nstored in Box %s."] = "%s se\nguardó en la CAJA %s.",
|
||||
["BOX %d (RELEASE)"] = "CAJA %d (SOLTAR)",
|
||||
["Once released,\n%s is\ngone forever. OK?"] = "Si lo sueltas,\n%s se\nirá para siempre. ¿OK?",
|
||||
["%s was\nreleased outside.\fBye %s!"] = "%s fue\nliberado.\f¡Adiós, %s!",
|
||||
["%sBOX %2d"] = "%sCAJA %2d",
|
||||
["When you change a\nPOKéMON BOX, data\nwill be saved. OK?"] = "Al cambiar de CAJA\nse guardarán los\ndatos. ¿OK?",
|
||||
["What?"] = "¿Qué?",
|
||||
["BOX No."] = "CAJA No.",
|
||||
["BOX No.%d"] = "CAJA No.%d",
|
||||
["Empty."] = "Vacía.",
|
||||
[":L%d No.%03d"] = ":N%d No.%03d",
|
||||
["Printed BOX %d!\fSaved as\n%s\vin the save\nfolder."] = "¡CAJA %d impresa!\fGuardada como\n%s\ven la carpeta de\nguardado.",
|
||||
["Printer error!\n%s"] = "¡Error de impresión!\n%s",
|
||||
["WITHDRAW <PK><MN>"] = "RETIRAR <PK><MN>",
|
||||
["DEPOSIT <PK><MN>"] = "GUARDAR <PK><MN>",
|
||||
["RELEASE <PK><MN>"] = "SOLTAR <PK><MN>",
|
||||
["CHANGE BOX"] = "CAMBIAR CAJA",
|
||||
["PRINT BOX"] = "IMPRIMIR CAJA",
|
||||
["SEE YA!"] = "HASTA LUEGO!",
|
||||
["YES"] = "SI",
|
||||
["NO"] = "NO",
|
||||
["GAME FREAK"] = "",
|
||||
["Nintendo"] = "",
|
||||
["Creatures inc."] = "",
|
||||
["GAME FREAK inc."] = "",
|
||||
["T H E E N D"] = "F I N",
|
||||
["HT %d′%02d″"] = "AL %d′%02d″",
|
||||
["WT %.1flb"] = "PE %.1flb",
|
||||
["Data unknown."] = "Datos desconocidos.",
|
||||
["<Diploma>"] = "<Diploma>",
|
||||
["Player"] = "Jugador",
|
||||
["Huh? %s\nstopped evolving!"] = "¿Eh? ¡%s\ndejó de evolucionar!",
|
||||
["Congratulations!\nYour %s\nevolved into\n%s!"] = "¡Enhorabuena!\n¡Tu %s\nevolucionó a\n%s!",
|
||||
["evolving!"] = "evolucionando",
|
||||
["POKéDEX Seen:{NUM:wDexRatingNumMonsSeen, 1, 3}\n Owned:{NUM:wDexRatingNumMonsOwned, 1, 3}"] = "POKéDEX Vistos:{NUM:wDexRatingNumMonsSeen, 1, 3}\n Capturados:{NUM:wDexRatingNumMonsOwned, 1, 3}",
|
||||
["POKéDEX Rating{COLON}"] = "Nota POKéDEX{COLON}",
|
||||
["Keep it up!"] = "¡Sigue así!",
|
||||
["LEVEL/"] = "NIVEL/",
|
||||
["TYPE1/"] = "TIPO1/",
|
||||
["TYPE2/"] = "TIPO2/",
|
||||
["HALL OF FAME"] = "SALON DE LA FAMA",
|
||||
["PLAY TIME"] = "TIEMPO",
|
||||
["MONEY"] = "DINERO",
|
||||
["bois club games"] = "bois club games",
|
||||
["GENGAR VS NIDORINO"] = "",
|
||||
["bois club"] = "bois club",
|
||||
["Nothing here."] = "Aquí no hay nada.",
|
||||
["%s is\ntrying to learn\v%s!\fBut, %s\ncan't learn more\vthan 4 moves!\f"] = "¡%s está\nintentando aprender\v%s!\f¡Pero %s\nno puede aprender\vmás de 4!\f",
|
||||
["Delete an older\nmove to make room\vfor %s?"] = "¿Borrar un movi-\nmiento antiguo para\vaprender %s?",
|
||||
["HM techniques\ncan't be deleted!"] = "¡Los movimientos MO\nno se pueden borrar!",
|
||||
["Abandon learning\n%s?"] = "¿Dejar de aprender\n%s?",
|
||||
["1, 2 and... Poof!\f%s forgot\n%s!\fAnd...\f%s learned\n%s!"] = "¡1, 2 y... plaf!\f¡%s olvidó\n%s!\f¡Y...\f%s aprendió\n%s!",
|
||||
["%s\ndid not learn\v%s!"] = "¡%s no\naprendió\v%s!",
|
||||
["Which move should"] = "¿Qué movimiento",
|
||||
["be forgotten?"] = "hay que olvidar?",
|
||||
["YOUR NAME?"] = "TU NOMBRE?",
|
||||
["NEW NAME"] = "NUEVO NOMBRE",
|
||||
["Hello there!\nWelcome to the\vworld of POKéMON!\fMy name is OAK!\nPeople call me\vthe POKéMON PROF!"] = "¡Hola!\n¡Bienvenido al\vmundo POKéMON!\fMe llamo OAK.\nMe llaman el\vPROF. POKéMON.",
|
||||
["This world is\ninhabited by\vcreatures called\vPOKéMON!"] = "¡Este mundo está\nhabitado por unas\vcriaturas llamadas\vPOKéMON!",
|
||||
["\fFor some people,\nPOKéMON are\vpets. Others use\vthem for fights.\fMyself...\fI study POKéMON\nas a profession."] = "\fPara algunos, los\nPOKéMON son masco-\vtas. Otros luchan\vcon ellos.\fYo...\fEstudio los POKéMON\ncomo profesión.",
|
||||
["{PLAYER}!\fYour very own\nPOKéMON legend is\vabout to unfold!\fA world of dreams\nand adventures\vwith POKéMON\vawaits! Let's go!"] = "¡{PLAYER}!\f¡Tu propia leyenda\nPOKéMON está a\vpunto de comenzar!\f¡Un mundo de sueños\ny aventuras con\vPOKéMON te espera!\v¡Vamos!",
|
||||
["First, what is\nyour name?"] = "¿Cómo te llamas?",
|
||||
["This is my grand-\nson. He's been\vyour rival since\vyou were a baby.\f...Erm, what is\nhis name again?"] = "Este es mi nieto.\nHa sido tu rival\vdesde que erais\vbebés.\f...Mmm, ¿cómo se\nllamaba?",
|
||||
["HIS NAME?"] = "SU NOMBRE?",
|
||||
["_OakSpeechText2A"] = "",
|
||||
["TEXT SPEED"] = "VEL TEXTO",
|
||||
["BATTLE ANIMATION"] = "ANIMACIONES",
|
||||
["OFF"] = "NO",
|
||||
["ON"] = "SI",
|
||||
["BATTLE STYLE"] = "ESTILO COMBATE",
|
||||
["SET"] = "FIJO",
|
||||
["SHIFT"] = "CAMBIO",
|
||||
["BATTLE LAYOUT"] = "DISENO COMBATE",
|
||||
["WIDE"] = "ANCHO",
|
||||
["OG"] = "OG",
|
||||
["RULESET"] = "REGLAS",
|
||||
["MUSIC VOL"] = "VOL MUSICA",
|
||||
["SFX VOL"] = "VOL SONIDO",
|
||||
["PIKACHU VOL"] = "VOL PIKACHU",
|
||||
["MUSIC FILTER"] = "FILTRO MUSICA",
|
||||
["COLORS"] = "COLORES",
|
||||
["TILT"] = "INCLINACION",
|
||||
["GBC FX"] = "EFECTO GBC",
|
||||
["ZOOM"] = "ZOOM",
|
||||
["VOID FILL"] = "RELLENO VACIO",
|
||||
["VIDEO MODE"] = "MODO VIDEO",
|
||||
["MAX FPS"] = "FPS MAXIMO",
|
||||
["GAME SPEED"] = "VELOCIDAD JUEGO",
|
||||
["MODS"] = "MODS",
|
||||
["%d INSTALLED"] = "%d INSTALADOS",
|
||||
["CONTROLS"] = "CONTROLES",
|
||||
["TOUCH PAD"] = "CONTROL TACTIL",
|
||||
["SURE? AGAIN"] = "SEGURO? OTRA VEZ",
|
||||
["AUTO HIDE PAD"] = "OCULTAR AUTO",
|
||||
["A blinding FLASH\nlights the area!"] = "¡Un DESTELLO\nilumina la zona!",
|
||||
["No SURFing here!"] = "¡Aquí no se puede\nSURFEAR!",
|
||||
["Nothing to CUT!"] = "¡Nada que CORTAR!",
|
||||
["{RAM:wNameBuffer} used\nSTRENGTH."] = "{RAM:wNameBuffer} usó\nFUERZA.",
|
||||
["{RAM:wNameBuffer} can\nmove boulders."] = "{RAM:wNameBuffer} puede\nmover rocas.",
|
||||
["It won't have\nany effect."] = "No tendrá ningún\nefecto.",
|
||||
["%s's HP\nwas restored!"] = "¡Los PS de %s\nse recuperaron!",
|
||||
["SWITCH"] = "CAMBIAR",
|
||||
["FLY"] = "VUELO",
|
||||
["FLASH"] = "DESTELLO",
|
||||
["CUT"] = "CORTE",
|
||||
["SURF"] = "SURF",
|
||||
["STRENGTH"] = "FUERZA",
|
||||
["SOFTBOILED"] = "HUEVO SUERTE",
|
||||
["TELEPORT"] = "TELETRANSPORTE",
|
||||
["DIG"] = "EXCAVAR",
|
||||
["Use TM on which\nPOKéMON?"] = "¿Usar la MT en qué\nPOKéMON?",
|
||||
["Bring out which\nPOKéMON?"] = "¿Qué POKéMON\nquieres sacar?",
|
||||
["Choose a POKéMON."] = "Elige un POKéMON.",
|
||||
["No POKéMON!"] = "¡Ningún POKéMON!",
|
||||
["ABLE"] = "PUEDE",
|
||||
["NOT ABLE"] = "NO PUEDE",
|
||||
["FNT"] = "DEB",
|
||||
["Move to where?"] = "¿Mover a dónde?",
|
||||
["Use on which one?"] = "¿Usar en cuál?",
|
||||
["You can't carry\nany more items."] = "No puedes llevar\nmás objetos.",
|
||||
["Withdrew\n%s."] = "Retirado\n%s.",
|
||||
["No room left to\nstore items."] = "No queda sitio para\nguardar objetos.",
|
||||
["%s was\nstored via PC."] = "%s se\nguardó en el PC.",
|
||||
["Toss %s?"] = "¿Tirar %s?",
|
||||
["Threw away %s."] = "Tiraste %s.",
|
||||
["WITHDRAW ITEM"] = "RETIRAR OBJETO",
|
||||
["DEPOSIT ITEM"] = "GUARDAR OBJETO",
|
||||
["TOSS ITEM"] = "TIRAR OBJETO",
|
||||
["LOG OFF"] = "SALIR",
|
||||
["SEEN %d OWNED %d"] = "VISTOS %d CAPT. %d",
|
||||
["DATA"] = "DATOS",
|
||||
["CRY"] = "VOZ",
|
||||
["AREA"] = "ZONA",
|
||||
["PRNT"] = "IMPR",
|
||||
["Printed %s's\ndata!\fSaved as\n%s\vin the save\nfolder."] = "¡Datos de %s\nimpresos!\fGuardado como\n%s\ven la carpeta de\nguardado.",
|
||||
["QUIT"] = "SALIR",
|
||||
["%s (%s)"] = "%s (%s)",
|
||||
["%s x%d"] = "%s x%d",
|
||||
["%s to box %d"] = "%s a caja %d",
|
||||
["LOAD REPORT"] = "CARGAR PARTIDA",
|
||||
["A:CONTINUE"] = "A:CONTINUAR",
|
||||
["You don't have\nenough money."] = "No tienes dinero\nsuficiente.",
|
||||
["%s?\nThat will be\n¥%d. OK?"] = "¿%s?\nSon ¥%d.\n¿OK?",
|
||||
["Here you are!\nThank you!"] = "¡Aquí tienes!\n¡Gracias!",
|
||||
["I can't put a\nprice on that."] = "No puedo ponerle\nprecio a eso.",
|
||||
["I can pay you\n¥%d for that."] = "Te doy ¥%d\npor eso.",
|
||||
["BUY"] = "COMPRAR",
|
||||
["SELL"] = "VENDER",
|
||||
["%s lined up!\nScored %d coins!"] = "¡%s alineados!\n¡%d fichas!",
|
||||
["Darn!\nRan out of coins!"] = "¡Vaya!\n¡Sin fichas!",
|
||||
["Not enough\ncoins!"] = "¡Fichas\ninsuficientes!",
|
||||
["SLOT MACHINE"] = "MAQUINA TRAGAPERRAS",
|
||||
["COINS %4d"] = "FICHAS %4d",
|
||||
["POKéDEX"] = "POKéDEX",
|
||||
["POKéMON"] = "POKéMON",
|
||||
["SAVE"] = "GUARDAR",
|
||||
["PLAYER %s\nBADGES %d\nPOKéDEX %3d\nTIME %6d:%02d"] = "JUGADOR %s\nMEDALLAS %d\nPOKéDEX %3d\nTIEMPO %6d:%02d",
|
||||
["\fWould you like to\nSAVE the game?"] = "\f¿Quieres GUARDAR\nla partida?",
|
||||
["Now saving..."] = "Guardando...",
|
||||
["%s saved\nthe game!"] = "¡%s guardó\nla partida!",
|
||||
["OPTION"] = "OPCION",
|
||||
["LINK"] = "LINK",
|
||||
["RETURN TO MAIN\nMENU?"] = "¿VOLVER AL MENU\nPRINCIPAL?",
|
||||
["BALL"] = "BALL",
|
||||
["STATUS/"] = "ESTADO/",
|
||||
["OT/"] = "EO/",
|
||||
["EXP POINTS"] = "P. EXP.",
|
||||
["LEVEL UP"] = "SUBE NIVEL",
|
||||
["PP"] = "PP",
|
||||
["SCORE %d"] = "PUNTOS %d",
|
||||
["New record!"] = "¡Nuevo récord!",
|
||||
["HI %d"] = "MAX %d",
|
||||
["A: done"] = "A: listo",
|
||||
["PLAYER"] = "JUGADOR",
|
||||
["BADGES"] = "MEDALLAS",
|
||||
["TIME"] = "TIEMPO",
|
||||
["CONTINUE"] = "CONTINUAR",
|
||||
["NEW GAME"] = "NUEVA PARTIDA",
|
||||
["EXIT GAME"] = "SALIR DEL JUEGO",
|
||||
["POKéMON RED"] = "",
|
||||
["2026 bois club games"] = "",
|
||||
["OT/%s"] = "EO/%s",
|
||||
["NAME/%s"] = "NOMBRE/%s",
|
||||
["In battle"] = "En combate",
|
||||
["Wild battle"] = "Combate salvaje",
|
||||
["Trainer battle"] = "Combate entrenador",
|
||||
["Link battle"] = "Combate link",
|
||||
["Title screen"] = "Pantalla de título",
|
||||
["Level %d"] = "Nivel %d",
|
||||
["What?\n%s is\nevolving!\fCongratulations!\nYour %s\nevolved into\n%s!"] = "¿Qué?\n¡%s está\nevolucionando!\f¡Enhorabuena!\n¡Tu %s\nevolucionó a\n%s!",
|
||||
["Not even a nibble!"] = "¡Ni un mordisco!",
|
||||
["Oh!\nIt's a bite!"] = "¡Oh!\n¡Ha picado!",
|
||||
["It's a sculpture\nof DIGLETT."] = "Es una escultura\nde DIGLETT.",
|
||||
["Crammed full of\nPOKéMON books!"] = "¡Repleto de libros\nsobre POKéMON!",
|
||||
["There's a slew of\nPOKéMON stuff!"] = "¡Hay un montón de\ncosas POKéMON!",
|
||||
["An elevator!"] = "¡Un ascensor!",
|
||||
["INDIGO PLATEAU"] = "MESETA ANIL",
|
||||
["POKéMON LEAGUE HQ"] = "SEDE DE LA LIGA\nPOKéMON",
|
||||
["You can't carry\nany more items!"] = "¡No puedes llevar\nmás objetos!",
|
||||
["%s found\n%s!"] = "¡%s encontró\n%s!",
|
||||
["%s found\n%d coins!"] = "¡%s encontró\n%d fichas!",
|
||||
["OUT OF ORDER\nThis is broken."] = "FUERA DE SERVICIO\nEsto está roto.",
|
||||
["OUT TO LUNCH\nThis is reserved."] = "CERRADO POR COMIDA\nEsto está reservado.",
|
||||
["Someone's keys!\nThey'll be back."] = "¡Las llaves de\nalguien! Volverá.",
|
||||
["A COIN CASE is\nrequired!"] = "¡Se necesita un\nMONEDERO!",
|
||||
["You don't have\nany coins!"] = "¡No tienes fichas!",
|
||||
["{RAM}\nPOKéMON GYM\nLEADER: {RAM}"] = "{RAM}\nGIMNASIO POKéMON\nLIDER: {RAM}",
|
||||
["Nope, there's\nonly trash here."] = "No, aquí solo hay\nbasura.",
|
||||
["Darn! It needs a\nCARD KEY!"] = "¡Vaya! ¡Necesita\nuna LLAVE MAGNET.!",
|
||||
["Bingo!"] = "¡Bingo!",
|
||||
["\nThe CARD KEY\nopened the door!"] = "\n¡La LLAVE MAGNET.\nabrió la puerta!",
|
||||
["Hey! There's a\nswitch under the\ntrash!\fThe 1st electric\nlock opened!"] = "¡Hay un interruptor\nbajo la basura!\f¡Se abrió el 1er\ncierre eléctrico!",
|
||||
["The 2nd electric\nlock opened!\fThe motorized door\nopened!"] = "¡Se abrió el 2o\ncierre eléctrico!\f¡La puerta se\nabrió!",
|
||||
["Nope! There's\nonly trash here.\fHey! The electric\nlocks were reset!"] = "¡No! Aquí solo hay\nbasura.\f¡Los cierres se\nreiniciaron!",
|
||||
["TELEPORTER is\ndisplayed on the\nPC monitor."] = "El TELETRANSPORTE\naparece en el\nmonitor del PC.",
|
||||
["{PLAYER} initiated\nTELEPORTER's Cell\nSeparator!"] = "¡{PLAYER} activó el\nSeparador de Células\ndel TELETRANSPORTE!",
|
||||
["BILL's favorite\nPOKéMON list!"] = "¡La lista de POKéMON\nfavoritos de BILL!",
|
||||
["{PLAYER} got on\n{RAM:wNameBuffer}!"] = "¡{PLAYER} se subió\na {RAM:wNameBuffer}!",
|
||||
["{RAM:wNameBuffer} hacked\naway with CUT!"] = "¡{RAM:wNameBuffer} cortó\ncon CORTE!",
|
||||
["Gyaoo!"] = "¡Gyaoo!",
|
||||
["Hi there!\nMay I help you?"] = "¡Hola!\n¿Puedo ayudarte?",
|
||||
["SOMEONE'S PC"] = "EL PC DE ALGUIEN",
|
||||
["PROF.OAK's PC"] = "EL PC DEL PROF.OAK",
|
||||
["POKéDEX comp-\nletion is:\f{NUM:hDexRatingNumMonsSeen} POKéMON seen\n{NUM:hDexRatingNumMonsOwned} POKéMON owned\fPROF.OAK's\nRating:"] = "La POKéDEX está\nasí:\f{NUM:hDexRatingNumMonsSeen} POKéMON vistos\n{NUM:hDexRatingNumMonsOwned} POKéMON capturados\fNota del\nPROF.OAK:",
|
||||
["We hope to see\nyou again!"] = "¡Esperamos verte\nde nuevo!",
|
||||
["Welcome to our\nPOKéMON CENTER!"] = "¡Bienvenido a\nnuestro CENTRO\nPOKéMON!",
|
||||
["Shall we heal your\nPOKéMON?"] = "¿Curamos a tus\nPOKéMON?",
|
||||
["OK. We'll need\nyour POKéMON."] = "Bien. Necesitamos\ntus POKéMON.",
|
||||
["Your POKéMON are\nfighting fit!"] = "¡Tus POKéMON están\nen plena forma!",
|
||||
["Welcome to the\nCable Club!"] = "¡Bienvenido al Club\nde Cable!",
|
||||
["We're making\npreparations.\vPlease wait."] = "Estamos preparando\ntodo.\vEspera un momento.",
|
||||
["Please apply here.\fBefore opening\nthe link, we have\vto save the game."] = "Solicítalo aquí.\fAntes de abrir el\nlink hay que\vguardar la partida.",
|
||||
["Please come\nagain!"] = "¡Vuelve pronto!",
|
||||
["I like shorts!\nThey're comfy and\neasy to wear!"] = "¡Me gustan los\npantalones cortos!\n¡Son cómodos!",
|
||||
["%s received\nthe %s!"] = "¡%s recibió\nel %s!",
|
||||
["%s received\n%s!"] = "¡%s recibió\n%s!",
|
||||
["REPEL's effect\nwore off."] = "El efecto del REPEL\nse ha pasado.",
|
||||
["Go right ahead!"] = "¡Adelante!",
|
||||
["You don't have the\nBOULDERBADGE yet!"] = "¡Aún no tienes la\nMEDALLA ROCA!",
|
||||
["Oh! That is the\n{RAM}!"] = "¡Oh! ¡Eso es el\n{RAM}!",
|
||||
["You don't have the\n{RAM} yet!"] = "¡Aún no tienes el\n{RAM}!",
|
||||
["You need a\nBICYCLE for the\nCycling Road!"] = "¡Necesitas una\nBICICLETA para el\nCarril Bici!",
|
||||
["The boulder fell\nthrough the hole!"] = "¡La roca cayó por\nel agujero!",
|
||||
["PA: Ding-dong!\nTime's up!"] = "AV: ¡Ding-dong!\n¡Se acabó el tiempo!",
|
||||
["PA: Your SAFARI\nGAME is over!"] = "AV: ¡Tu JUEGO\nSAFARI ha terminado!",
|
||||
["PA: You're out of\nSAFARI BALLs!"] = "AV: ¡No te quedan\nSAFARI BALLs!",
|
||||
["{PLAYER} got\n%s!"] = "¡{PLAYER} consiguió\n%s!",
|
||||
["There's no more\nroom for POKéMON!\v%s was\vsent to POKéMON\vBOX %s on PC!"] = "¡No hay sitio para\nmás POKéMON!\v¡%s fue\venviado a la CAJA\vPOKéMON %s del PC!",
|
||||
["contribution is not a table"] = "",
|
||||
[" [%s %s.%s]"] = " [%s %s.%s]",
|
||||
["Link battle needs\nthe same mods on\nboth games."] = "El combate link\nnecesita los mismos\nmods en los dos\njuegos.",
|
||||
["Your %s can't\nbattle on the\nother game."] = "Tu %s no puede\nluchar en el otro\njuego.",
|
||||
["Their %s isn't\nin this game.\n(%s)"] = "Su %s no está\nen este juego.\n(%s)",
|
||||
["%s wants\nto battle!"] = "¡%s quiere\nluchar!",
|
||||
["Link desync!\n%s differs.\fAre both games\nrunning the same\nmods?"] = "¡Link desincroni-\nzado!\n%s difiere.\f¿Están los dos\njuegos con los\nmismos mods?",
|
||||
["%s ran from\nthe battle!"] = "¡%s huyó del\ncombate!",
|
||||
["Items can't be\nused in a link\nbattle!"] = "¡No se pueden usar\nobjetos en un\ncombate link!",
|
||||
["%s is out of\nPOKéMON!\f%s wins!"] = "¡%s no tiene\nPOKéMON!\f¡%s gana!",
|
||||
["%s left the\nbattle."] = "%s dejó el\ncombate.",
|
||||
["%s ran out of\ntime!"] = "¡%s se quedó\nsin tiempo!",
|
||||
["Time's up! You\nforfeit the match."] = "¡Se acabó el tiempo!\nPierdes el combate.",
|
||||
["%s's %s can't\nbattle on this\ngame."] = "El %s de %s\nno puede luchar en\neste juego.",
|
||||
["%s's %s can't\nbattle on this\ngame.\n(%s)"] = "El %s de %s\nno puede luchar en\neste juego.\n(%s)",
|
||||
["%s vs %s!"] = "¡%s contra %s!",
|
||||
["Link error:\n%s"] = "Error de link:\n%s",
|
||||
["Online play runs\nvanilla for both\nplayers.\fTurn off %s\nand restart?"] = "El juego en línea\nva sin mods para\nlos dos jugadores.\f¿Desactivar %s\ny reiniciar?",
|
||||
["The link was\nbroken."] = "Se ha perdido el\nlink.",
|
||||
["Link battle\ncan't start."] = "El combate link no\npuede empezar.",
|
||||
["The trade stopped:\n%s."] = "El intercambio se\ndetuvo:\n%s.",
|
||||
["The trade was\ncancelled."] = "El intercambio se\nha cancelado.",
|
||||
["Trade completed!\f%s received\n%s!"] = "¡Intercambio hecho!\f¡%s recibió\n%s!",
|
||||
["LINK CABLE (LAN)"] = "CABLE LINK (LAN)",
|
||||
["ONLINE MATCH"] = "PARTIDA EN LINEA",
|
||||
["TOURNAMENT"] = "TORNEO",
|
||||
["HOST A GAME"] = "CREAR PARTIDA",
|
||||
["JOIN A GAME"] = "UNIRSE A PARTIDA",
|
||||
["UDP port %s"] = "Puerto UDP %s",
|
||||
["HOST ONLINE"] = "CREAR EN LINEA",
|
||||
["JOIN ONLINE"] = "UNIRSE EN LINEA",
|
||||
["Tell your friend"] = "Dile a tu amigo",
|
||||
["the code:"] = "el código:",
|
||||
["Waiting for join..."] = "Esperando...",
|
||||
["A: connect B: back"] = "A: conectar B: atrás",
|
||||
["Calling..."] = "Llamando...",
|
||||
["Friend joins at:"] = "Tu amigo entra en:",
|
||||
["Port: %s"] = "Puerto: %s",
|
||||
["TRADE"] = "INTERCAMBIO",
|
||||
["BATTLE"] = "COMBATE",
|
||||
["LEVELS:"] = "NIVELES:",
|
||||
["A: continue B: back"] = "A: seguir B: atrás",
|
||||
["Checking the"] = "Comprobando el",
|
||||
["other game..."] = "otro juego...",
|
||||
["Waiting for the"] = "Esperando a que",
|
||||
["host to choose..."] = "el anfitrión elija...",
|
||||
["A: trade anyway"] = "A: intercambiar igual",
|
||||
["YOURS"] = "TUYO",
|
||||
["THEIRS"] = "SUYO",
|
||||
["X: not on theirs"] = "X: no en el suyo",
|
||||
["A: trade B: cancel"] = "A: cambiar B: cancelar",
|
||||
["Exchanging data..."] = "Intercambiando...",
|
||||
["can't reach relay %s:%d\n(%s)"] = "",
|
||||
["That code wasn't\nfound."] = "Ese código no se\nha encontrado.",
|
||||
["That game already\nhas two players."] = "Esa partida ya\ntiene dos jugadores.",
|
||||
["That code has\nexpired."] = "Ese código ha\ncaducado.",
|
||||
["Couldn't join:\n%s"] = "No se pudo unir:\n%s",
|
||||
["no answer from\n%s"] = "",
|
||||
["That tournament\nhas already begun."] = "Ese torneo ya ha\nempezado.",
|
||||
["Can't host:\nneed %d Pokemon\nLv %s-%s."] = "No puedes crearlo:\nnecesitas %d Pokemon\nNv %s-%s.",
|
||||
["Couldn't host\nthat tournament."] = "No se pudo crear\nese torneo.",
|
||||
["Your party needs\n%d Pokemon, Lv\n%s-%s."] = "Tu equipo necesita\n%d Pokemon, Nv\n%s-%s.",
|
||||
["Couldn't join\nthat tournament."] = "No se pudo unir a\nese torneo.",
|
||||
["Link error:\nversion mismatch\nwith opponent."] = "Error de link:\nversión distinta a\nla del rival.",
|
||||
["The tournament\nconnection was\nlost."] = "Se perdió la\nconexión del torneo.",
|
||||
["Can't watch this\nmatch."] = "No se puede ver\neste combate.",
|
||||
["HOST"] = "CREAR",
|
||||
["JOIN"] = "UNIRSE",
|
||||
["START: create"] = "START: crear",
|
||||
["A: join B: back"] = "A: unirse B: atrás",
|
||||
["B: cancel"] = "B: cancelar",
|
||||
["TOURNAMENT %s"] = "TORNEO %s",
|
||||
["ROUND %d"] = "RONDA %d",
|
||||
["%s (bye)"] = "%s (pasa)",
|
||||
["%s%s vs %s%s"] = "%s%s contra %s%s",
|
||||
["(organizing --"] = "(organizando --",
|
||||
["not playing)"] = "no juega)",
|
||||
["Waiting for"] = "Esperando a que",
|
||||
["players to join:"] = "entren jugadores:",
|
||||
["A: START B: cancel"] = "A: START B: cancelar",
|
||||
["%s is the"] = "¡%s es el",
|
||||
["champion!"] = "campeón!",
|
||||
["A: continue"] = "A: continuar",
|
||||
["{PLAYER} played the\nPOKé FLUTE."] = "{PLAYER} tocó la\nFLAUTA POKé.",
|
||||
["Played the POKé\nFLUTE.\fNow, that's a\ncatchy tune!"] = "Tocaste la FLAUTA\nPOKé.\f¡Qué melodía tan\npegadiza!",
|
||||
["%s played the\nPOKé FLUTE."] = "%s tocó la\nFLAUTA POKé.",
|
||||
["All sleeping\nPOKéMON woke up!"] = "¡Todos los POKéMON\ndormidos despertaron!",
|
||||
["%s's\nhits will never\nmiss!"] = "¡Los golpes de %s\nnunca fallarán!",
|
||||
["The wild POKéMON\nran away!"] = "¡El POKéMON salvaje\nhuyó!",
|
||||
["%s's PP\nwas restored!"] = "¡Los PP de %s\nse recuperaron!",
|
||||
["%s's\nstatus returned\nto normal!"] = "¡El estado de %s\nvolvió a la\nnormalidad!",
|
||||
["%s\nis revitalized!"] = "¡%s se ha\nrevitalizado!",
|
||||
["%s\nis refusing!"] = "¡%s se\nniega!",
|
||||
["%s's %s\nrose!"] = "¡El %s de %s\nsubió!",
|
||||
["%s's PP\nincreased!"] = "¡Los PP de %s\naumentaron!",
|
||||
["%s can't\nlearn that move!"] = "¡%s no puede\naprender ese\nmovimiento!",
|
||||
["It knows that\nmove already!"] = "¡Ya conoce ese\nmovimiento!",
|
||||
["Coin count:\n%d"] = "Fichas:\n%d",
|
||||
["NO MODS INSTALLED"] = "NO HAY MODS",
|
||||
["SAVE CURRENT AS.."] = "GUARDAR ACTUAL..",
|
||||
["OPTIONS.."] = "OPCIONES..",
|
||||
["PERMISSIONS.."] = "PERMISOS..",
|
||||
["VIEW ERROR.."] = "VER ERROR..",
|
||||
["BACK"] = "ATRAS",
|
||||
["APPLY & RESTART"] = "APLICAR Y REINICIAR",
|
||||
["DISCARD CHANGES"] = "DESCARTAR CAMBIOS",
|
||||
["DATA & API ONLY"] = "SOLO DATOS Y API",
|
||||
["DISABLE BOTH?"] = "DESACTIVAR AMBOS?",
|
||||
["PROFILE NAME?"] = "NOMBRE DEL PERFIL?",
|
||||
["RENAME?"] = "RENOMBRAR?",
|
||||
["RESET DEFAULTS"] = "VALORES POR DEFECTO",
|
||||
["NO CHANGES"] = "SIN CAMBIOS",
|
||||
["A:OK"] = "A:OK",
|
||||
["B:DONE (NO RESTART)"] = "B:LISTO (SIN REINICIAR)",
|
||||
["MOD MANAGER"] = "GESTOR DE MODS",
|
||||
["Choose a mod .zip"] = "Elige un .zip de mod",
|
||||
["Choose a .sav save file"] = "Elige un archivo .sav",
|
||||
["An update is available"] = "Hay una actualización",
|
||||
["Name save slot"] = "Nombra la ranura",
|
||||
["Enter to save - Esc to cancel - empty clears"] = "Enter para guardar - Esc para cancelar - vacío la borra",
|
||||
["Add a mod index"] = "Añadir un índice de mods",
|
||||
["Paste the index URL, or its owner/repo."] = "Pega la URL del índice, o su owner/repo.",
|
||||
["Enter to add - Esc to cancel"] = "Enter para añadir - Esc para cancelar",
|
||||
["Import a ROM to play"] = "Importa una ROM para jugar",
|
||||
["RED"] = "ROJO",
|
||||
["BLUE"] = "AZUL",
|
||||
["YELLOW"] = "AMARILLO",
|
||||
["FIND MODS"] = "BUSCAR MODS",
|
||||
["%d of 3 ready"] = "%d de 3 listos",
|
||||
["Or drop the .gb/.gbc file here."] = "O arrastra aquí el archivo .gb/.gbc.",
|
||||
["ROM imported"] = "ROM importada",
|
||||
["That ROM could not be imported."] = "No se pudo importar esa ROM.",
|
||||
["Open folder"] = "Abrir carpeta",
|
||||
["%d badges - %s - %d caught"] = "%d medallas - %s - %d capturados",
|
||||
["%d of %d enabled"] = "%d de %d activados",
|
||||
["Or drop a mod .zip onto the window."] = "O arrastra un .zip de mod a la ventana.",
|
||||
["No mods installed - drop a mod .zip here to add one."] = "No hay mods - arrastra aquí un .zip para añadir uno.",
|
||||
["Refreshed - %d mods listed"] = "Actualizado - %d mods listados",
|
||||
["Added %s"] = "Añadido %s",
|
||||
["Index removed"] = "Índice eliminado",
|
||||
["Downloading %s..."] = "Descargando %s...",
|
||||
["Installed %s %s"] = "Instalado %s %s",
|
||||
["%d mods listed"] = "%d mods listados",
|
||||
["%d of %d mods"] = "%d de %d mods",
|
||||
["Mods here are listed, not reviewed - read the source and trust the author."] = "Los mods aquí se listan, no se revisan - lee el código y confía en el autor.",
|
||||
["No mod index added"] = "No hay índice de mods",
|
||||
["Add an index to browse mods. An index is a published list; paste its URL or its owner/repo."] = "Añade un índice para explorar mods. Un índice es una lista publicada; pega su URL o su owner/repo.",
|
||||
["Search mods"] = "Buscar mods",
|
||||
["This index lists no mods yet."] = "Este índice aún no lista mods.",
|
||||
["No mods match that search."] = "Ningún mod coincide con esa búsqueda.",
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Trainer class names
|
||||
--
|
||||
-- Trainer class names for Espanol.
|
||||
|
||||
return {
|
||||
["OPP_FIX_YOUNGSTER"] = "",
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
-- spanish_ui: a translation of the game into Espanol.
|
||||
--
|
||||
-- Nothing here is translated yet. Every table under lang/ starts with
|
||||
-- empty strings; fill one in and it takes effect on the next boot, and
|
||||
-- anything still empty keeps rendering in English. That means a
|
||||
-- half-finished translation is always playable, so you can ship early and
|
||||
-- fill the long tail in later.
|
||||
--
|
||||
-- Read TRANSLATING.md before the first edit; the font is the part people
|
||||
-- get wrong.
|
||||
return function(mod)
|
||||
-- mod:read is the supported way into your own directory; the catalogs are
|
||||
-- plain Lua tables, so read and run them rather than require()ing them.
|
||||
local function catalog(name)
|
||||
local rel = "lang/" .. name .. ".lua"
|
||||
local body = mod:read(rel)
|
||||
if not body then return {} end
|
||||
local chunk, err = loadstring(body, rel)
|
||||
if not chunk then
|
||||
mod.log:warn("%s has a syntax error: %s", rel, tostring(err))
|
||||
return {}
|
||||
end
|
||||
local ok, table_ = pcall(chunk)
|
||||
if not ok or type(table_) ~= "table" then
|
||||
mod.log:warn("%s did not return a table: %s", rel, tostring(table_))
|
||||
return {}
|
||||
end
|
||||
return table_
|
||||
end
|
||||
|
||||
-- An empty value means "not translated yet", never "translate to blank".
|
||||
local function each(name, apply)
|
||||
local n = 0
|
||||
for key, value in pairs(catalog(name)) do
|
||||
if type(value) == "string" and value ~= "" then
|
||||
apply(key, value)
|
||||
n = n + 1
|
||||
end
|
||||
end
|
||||
return n
|
||||
end
|
||||
|
||||
-- ---- glyphs -------------------------------------------------------
|
||||
-- Register the sheet BEFORE anything asks for a glyph on it. base is
|
||||
-- the first code the page owns; 0x100 and up is free space above the
|
||||
-- vanilla pages, so a new alphabet never collides with them.
|
||||
for id, page in pairs(catalog("font")) do
|
||||
mod.content.font:register(id, page)
|
||||
end
|
||||
-- charmap: which byte sequence draws which code
|
||||
for seq, code in pairs(catalog("charmap")) do
|
||||
mod.content.font:register("charmap:" .. seq, { seq = seq, code = code })
|
||||
end
|
||||
|
||||
-- ---- text ---------------------------------------------------------
|
||||
local counts = {}
|
||||
counts.dialogue = each("dialogue", function(id, value)
|
||||
mod.content.text:override(id, value)
|
||||
end)
|
||||
counts.strings = each("strings", function(source, value)
|
||||
mod.content.strings:override(source, value)
|
||||
end)
|
||||
counts.species = each("species_names", function(id, value)
|
||||
mod.content.pokemon:patch(id, { name = value })
|
||||
end)
|
||||
counts.moves = each("move_names", function(id, value)
|
||||
mod.content.moves:patch(id, { name = value })
|
||||
end)
|
||||
counts.items = each("item_names", function(id, value)
|
||||
mod.content.items:patch(id, { name = value })
|
||||
end)
|
||||
counts.trainers = each("trainer_names", function(id, value)
|
||||
mod.content.trainers:patch(id, { name = value })
|
||||
end)
|
||||
counts.statuses = each("status_labels", function(id, value)
|
||||
mod.content.statuses:patch(id, { label = value })
|
||||
end)
|
||||
|
||||
-- ---- name entry ---------------------------------------------------
|
||||
-- The naming screen's letter grid. Leave lang/naming.lua returning nil
|
||||
-- to keep the English alphabet.
|
||||
local grid = catalog("naming")
|
||||
if grid.upper then
|
||||
-- Only offer the accented cells when the running cartridge can actually
|
||||
-- draw them. A Spanish ROM has Ñ and the accented vowels in its font
|
||||
-- and the manifest maps them; an English one does not, and an
|
||||
-- unmappable cell renders blank -- a naming screen with six empty keys
|
||||
-- is worse than an English one. So check the charmap and fall back.
|
||||
local function drawable(cells, ctx)
|
||||
local font = ((ctx.game or {}).data or {}).font
|
||||
local charmap = font and font.charmap
|
||||
if not charmap then return false end
|
||||
local have = {}
|
||||
for _, entry in ipairs(charmap) do have[entry.seq] = true end
|
||||
for _, row in ipairs(cells) do
|
||||
for _, cell in ipairs(row) do
|
||||
-- Only the non-ASCII cells are at risk; A-Z and punctuation are
|
||||
-- on every page.
|
||||
if cell:byte(1) and cell:byte(1) > 127 and not have[cell] then
|
||||
return false
|
||||
end
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
local warned = false
|
||||
mod.hooks:on("ui.naming.grid", function(base, ctx)
|
||||
local want = ctx.lower and grid.lower or grid.upper
|
||||
if not want then return base end
|
||||
if not drawable(want, ctx) then
|
||||
if not warned then
|
||||
warned = true
|
||||
mod.log:info("naming grid: this ROM has no accented glyphs, "
|
||||
.. "keeping the English alphabet")
|
||||
end
|
||||
return base
|
||||
end
|
||||
return want
|
||||
end)
|
||||
end
|
||||
|
||||
mod.events:on("game.ready", function()
|
||||
local total = 0
|
||||
for _, n in pairs(counts) do total = total + n end
|
||||
mod.log:info("Espanol: %d strings translated", total)
|
||||
end)
|
||||
end
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"id": "spanish_ui",
|
||||
"name": "Espanol (interfaz)",
|
||||
"version": "0.1.0",
|
||||
"api": 2,
|
||||
"entry": "main.lua",
|
||||
"profile": "content",
|
||||
"game_version": ">=0.0.0-dev <1.0.0",
|
||||
"category": "LANGUAGE",
|
||||
"priority": 100,
|
||||
"dependencies": [],
|
||||
"optional_dependencies": [],
|
||||
"conflicts": [],
|
||||
"incompatible": [],
|
||||
"experimental": false,
|
||||
"description": "Spanish for the app's own settings and menus. The game's text comes from your ROM and is untouched, so an English cartridge stays an English adventure with Spanish menus."
|
||||
}
|
||||
|
After Width: | Height: | Size: 28 KiB |
@@ -0,0 +1,72 @@
|
||||
#---------------------------------------------------------------------------------
|
||||
# One-shot OTA bootstrap NRO (copied into the main launcher's romfs).
|
||||
#---------------------------------------------------------------------------------
|
||||
.SUFFIXES:
|
||||
|
||||
ifeq ($(strip $(DEVKITPRO)),)
|
||||
|
||||
.PHONY: all
|
||||
all:
|
||||
@echo "DEVKITPRO not set — bootstrap builds with the launcher only."
|
||||
@false
|
||||
|
||||
else
|
||||
|
||||
TOPDIR ?= $(CURDIR)
|
||||
include $(DEVKITPRO)/libnx/switch_rules
|
||||
|
||||
TARGET := ota-bootstrap
|
||||
BUILD := build
|
||||
SOURCES := .
|
||||
INCLUDES := include
|
||||
ROMFS :=
|
||||
|
||||
APP_TITLE := gen1recomp OTA
|
||||
APP_AUTHOR := bryanthaboi, port by andrewqsantos
|
||||
APP_VERSION := 0.0.0
|
||||
|
||||
ARCH := -march=armv8-a+crc+crypto -mtune=cortex-a57 -mtp=soft -fPIE
|
||||
CFLAGS := -g -Wall -O2 -ffunction-sections $(ARCH) $(DEFINES)
|
||||
CFLAGS += $(INCLUDE) -D__SWITCH__
|
||||
ASFLAGS := -g $(ARCH)
|
||||
LDFLAGS = -specs=$(DEVKITPRO)/libnx/switch.specs -g $(ARCH) -Wl,-Map,$(notdir $*.map)
|
||||
LIBS := -lnx
|
||||
LIBDIRS := $(LIBNX)
|
||||
|
||||
ifneq ($(BUILD),$(notdir $(CURDIR)))
|
||||
export OUTPUT := $(CURDIR)/$(TARGET)
|
||||
export TOPDIR := $(CURDIR)
|
||||
export VPATH := $(foreach dir,$(SOURCES),$(CURDIR)/$(dir))
|
||||
export DEPSDIR := $(CURDIR)/$(BUILD)
|
||||
CFILES := $(foreach dir,$(SOURCES),$(notdir $(wildcard $(dir)/*.c)))
|
||||
export LD := $(CC)
|
||||
export OFILES := $(CFILES:.c=.o)
|
||||
export INCLUDE := $(foreach dir,$(INCLUDES),-I$(CURDIR)/$(dir)) \
|
||||
$(foreach dir,$(LIBDIRS),-I$(dir)/include) \
|
||||
-I$(CURDIR)/$(BUILD)
|
||||
export LIBPATHS := $(foreach dir,$(LIBDIRS),-L$(dir)/lib)
|
||||
export APP_ICON := $(LIBNX)/default_icon.jpg
|
||||
export NROFLAGS += --icon=$(APP_ICON)
|
||||
export NROFLAGS += --nacp=$(CURDIR)/$(TARGET).nacp
|
||||
|
||||
.PHONY: $(BUILD) clean all
|
||||
|
||||
all: $(BUILD)
|
||||
|
||||
$(BUILD):
|
||||
@[ -d $@ ] || mkdir -p $@
|
||||
@$(MAKE) --no-print-directory -C $(BUILD) -f $(CURDIR)/Makefile
|
||||
|
||||
clean:
|
||||
@echo clean ...
|
||||
@rm -fr $(BUILD) $(TARGET).nro $(TARGET).nacp $(TARGET).elf
|
||||
|
||||
else
|
||||
.PHONY: all
|
||||
DEPENDS := $(OFILES:.o=.d)
|
||||
all: $(OUTPUT).nro
|
||||
$(OUTPUT).nro: $(OUTPUT).elf $(OUTPUT).nacp
|
||||
$(OUTPUT).elf: $(OFILES)
|
||||
-include $(DEPENDS)
|
||||
endif
|
||||
endif
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Tiny one-shot helper: swap a staged launcher NRO into place, then load the game.
|
||||
* The main OTA launcher chainloads here because a running NRO cannot replace itself
|
||||
* on sdmc/FAT.
|
||||
*/
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#if defined(__SWITCH__)
|
||||
#include <switch.h>
|
||||
#endif
|
||||
|
||||
#define INSTALL_DIR "sdmc:/switch/gen1recomp"
|
||||
#define STAGED INSTALL_DIR "/gen1recomp.nro.staged"
|
||||
#define LAUNCHER INSTALL_DIR "/gen1recomp.nro"
|
||||
#define GAME INSTALL_DIR "/gen1recomp-game.nro"
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
(void)argc;
|
||||
(void)argv;
|
||||
|
||||
#if defined(__SWITCH__)
|
||||
remove(LAUNCHER);
|
||||
if (rename(STAGED, LAUNCHER) != 0) {
|
||||
return 1;
|
||||
}
|
||||
Result rc = envSetNextLoad(GAME, GAME);
|
||||
if (R_FAILED(rc)) return 1;
|
||||
return 0;
|
||||
#else
|
||||
fprintf(stderr, "ota-bootstrap: host stub (would rename %s -> %s, load %s)\n", STAGED, LAUNCHER,
|
||||
GAME);
|
||||
return 0;
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
#---------------------------------------------------------------------------------
|
||||
# gen1recomp Switch native OTA launcher
|
||||
# Requires: DEVKITPRO + switch-curl, switch-mbedtls, switch-zlib, switch-zziplib
|
||||
# Host protocol tests: make host-test (no DEVKITPRO needed)
|
||||
#---------------------------------------------------------------------------------
|
||||
.SUFFIXES:
|
||||
|
||||
#---------------------------------------------------------------------------------
|
||||
# Host-only path when DEVKITPRO is unset
|
||||
#---------------------------------------------------------------------------------
|
||||
ifeq ($(strip $(DEVKITPRO)),)
|
||||
|
||||
.PHONY: host-test clean-host
|
||||
host-test:
|
||||
@mkdir -p build-host
|
||||
cc -std=c11 -Wall -Wextra -Iinclude -o build-host/test_ota_protocol \
|
||||
src/ota_protocol.c host/test_ota_protocol.c
|
||||
./build-host/test_ota_protocol
|
||||
|
||||
clean-host:
|
||||
rm -rf build-host
|
||||
|
||||
%:
|
||||
@echo "DEVKITPRO not set — only 'make host-test' is available."
|
||||
@echo "Run: bash scripts/switch/install_devkitpro_deps.sh"
|
||||
@false
|
||||
|
||||
else
|
||||
|
||||
#---------------------------------------------------------------------------------
|
||||
TOPDIR ?= $(CURDIR)
|
||||
include $(DEVKITPRO)/libnx/switch_rules
|
||||
|
||||
#---------------------------------------------------------------------------------
|
||||
TARGET := gen1recomp
|
||||
BUILD := build
|
||||
SOURCES := src
|
||||
DATA := data
|
||||
INCLUDES := include
|
||||
ROMFS := romfs
|
||||
|
||||
APP_TITLE := gen1recomp
|
||||
APP_AUTHOR := bryanthaboi, port by andrewqsantos
|
||||
# Overridable: scripts/switch/build_ota_launcher.sh passes release X.Y.Z
|
||||
APP_VERSION ?= 0.0.0
|
||||
|
||||
# Prefer project Switch icon if present
|
||||
ICON := $(TOPDIR)/../assets/icon.jpg
|
||||
LOGO_RGBA_SRC := $(TOPDIR)/../assets/logo.rgba
|
||||
LOGO_ROMFS := $(CURDIR)/$(ROMFS)/logo.rgba
|
||||
|
||||
#---------------------------------------------------------------------------------
|
||||
ARCH := -march=armv8-a+crc+crypto -mtune=cortex-a57 -mtp=soft -fPIE
|
||||
|
||||
CFLAGS := -g -Wall -O2 -ffunction-sections $(ARCH) $(DEFINES)
|
||||
CFLAGS += $(INCLUDE) -D__SWITCH__
|
||||
|
||||
CXXFLAGS := $(CFLAGS) -fno-rtti -fno-exceptions
|
||||
|
||||
ASFLAGS := -g $(ARCH)
|
||||
LDFLAGS = -specs=$(DEVKITPRO)/libnx/switch.specs -g $(ARCH) -Wl,-Map,$(notdir $*.map)
|
||||
|
||||
LIBS := -lcurl -lzzip -lmbedtls -lmbedx509 -lmbedcrypto -lz -lnx
|
||||
|
||||
LIBDIRS := $(PORTLIBS) $(LIBNX)
|
||||
|
||||
#---------------------------------------------------------------------------------
|
||||
ifneq ($(BUILD),$(notdir $(CURDIR)))
|
||||
#---------------------------------------------------------------------------------
|
||||
|
||||
export OUTPUT := $(CURDIR)/$(TARGET)
|
||||
export TOPDIR := $(CURDIR)
|
||||
|
||||
export VPATH := $(foreach dir,$(SOURCES),$(CURDIR)/$(dir)) \
|
||||
$(foreach dir,$(DATA),$(CURDIR)/$(dir))
|
||||
|
||||
export DEPSDIR := $(CURDIR)/$(BUILD)
|
||||
|
||||
CFILES := $(foreach dir,$(SOURCES),$(notdir $(wildcard $(dir)/*.c)))
|
||||
CPPFILES := $(foreach dir,$(SOURCES),$(notdir $(wildcard $(dir)/*.cpp)))
|
||||
SFILES := $(foreach dir,$(SOURCES),$(notdir $(wildcard $(dir)/*.s)))
|
||||
BINFILES := $(foreach dir,$(DATA),$(notdir $(wildcard $(dir)/*.*)))
|
||||
|
||||
#---------------------------------------------------------------------------------
|
||||
# use CXX for linking C++ projects, CC for standard C
|
||||
#---------------------------------------------------------------------------------
|
||||
ifeq ($(strip $(CPPFILES)),)
|
||||
export LD := $(CC)
|
||||
else
|
||||
export LD := $(CXX)
|
||||
endif
|
||||
#---------------------------------------------------------------------------------
|
||||
|
||||
export OFILES_BIN := $(addsuffix .o,$(BINFILES))
|
||||
export OFILES_SRC := $(CPPFILES:.cpp=.o) $(CFILES:.c=.o) $(SFILES:.s=.o)
|
||||
export OFILES := $(OFILES_BIN) $(OFILES_SRC)
|
||||
|
||||
export INCLUDE := $(foreach dir,$(INCLUDES),-I$(CURDIR)/$(dir)) \
|
||||
$(foreach dir,$(LIBDIRS),-I$(dir)/include) \
|
||||
-I$(CURDIR)/$(BUILD)
|
||||
|
||||
export LIBPATHS := $(foreach dir,$(LIBDIRS),-L$(dir)/lib)
|
||||
|
||||
ifeq ($(strip $(ICON)),)
|
||||
icons := $(wildcard *.jpg)
|
||||
ifneq (,$(findstring $(TARGET).jpg,$(icons)))
|
||||
export APP_ICON := $(TOPDIR)/$(TARGET).jpg
|
||||
else
|
||||
ifneq (,$(findstring icon.jpg,$(icons)))
|
||||
export APP_ICON := $(TOPDIR)/icon.jpg
|
||||
endif
|
||||
endif
|
||||
else
|
||||
ifeq ($(wildcard $(ICON)),)
|
||||
# fall back to libnx default if project icon missing
|
||||
export APP_ICON := $(LIBNX)/default_icon.jpg
|
||||
else
|
||||
export APP_ICON := $(ICON)
|
||||
endif
|
||||
endif
|
||||
|
||||
ifeq ($(strip $(NO_ICON)),)
|
||||
export NROFLAGS += --icon=$(APP_ICON)
|
||||
endif
|
||||
|
||||
ifeq ($(strip $(NO_NACP)),)
|
||||
export NROFLAGS += --nacp=$(CURDIR)/$(TARGET).nacp
|
||||
endif
|
||||
|
||||
ifneq ($(ROMFS),)
|
||||
export NROFLAGS += --romfsdir=$(CURDIR)/$(ROMFS)
|
||||
endif
|
||||
|
||||
BOOTSTRAP_DIR := $(TOPDIR)/../ota-bootstrap
|
||||
BOOTSTRAP_ROMFS := $(CURDIR)/$(ROMFS)/ota-bootstrap.nro
|
||||
|
||||
.PHONY: $(BUILD) clean all host-test sync-romfs bootstrap-romfs
|
||||
|
||||
#---------------------------------------------------------------------------------
|
||||
all: sync-romfs $(BUILD)
|
||||
|
||||
CACERT_URL := https://curl.se/ca/cacert.pem
|
||||
CACERT_ROMFS := $(CURDIR)/$(ROMFS)/cacert.pem
|
||||
|
||||
bootstrap-romfs:
|
||||
@$(MAKE) --no-print-directory -C $(BOOTSTRAP_DIR) all
|
||||
@mkdir -p $(CURDIR)/$(ROMFS)
|
||||
@cp -f $(BOOTSTRAP_DIR)/ota-bootstrap.nro $(BOOTSTRAP_ROMFS)
|
||||
|
||||
sync-romfs: bootstrap-romfs
|
||||
@mkdir -p $(CURDIR)/$(ROMFS)
|
||||
@[ -f "$(LOGO_RGBA_SRC)" ] || (echo "missing $(LOGO_RGBA_SRC) — run: python3 scripts/switch/bake_ota_logo.py" && exit 1)
|
||||
@cp -f "$(LOGO_RGBA_SRC)" "$(LOGO_ROMFS)"
|
||||
@if ! curl -sfL --time-cond $(CACERT_ROMFS) -o $(CACERT_ROMFS) $(CACERT_URL); then \
|
||||
[ -f $(CACERT_ROMFS) ] || (echo "sync-romfs: failed to fetch cacert.pem" && exit 1); \
|
||||
fi
|
||||
|
||||
$(BUILD): sync-romfs
|
||||
@[ -d $@ ] || mkdir -p $@
|
||||
@$(MAKE) --no-print-directory -C $(BUILD) -f $(CURDIR)/Makefile
|
||||
|
||||
#---------------------------------------------------------------------------------
|
||||
clean:
|
||||
@echo clean ...
|
||||
@$(MAKE) --no-print-directory -C $(BOOTSTRAP_DIR) clean || true
|
||||
@rm -fr $(BUILD) $(TARGET).nro $(TARGET).nacp $(TARGET).elf $(TARGET).lst build-host
|
||||
@rm -f $(CURDIR)/$(ROMFS)/logo.rgba $(CURDIR)/$(ROMFS)/logo.png
|
||||
|
||||
host-test:
|
||||
@mkdir -p build-host
|
||||
cc -std=c11 -Wall -Wextra -Iinclude -o build-host/test_ota_protocol \
|
||||
src/ota_protocol.c host/test_ota_protocol.c
|
||||
./build-host/test_ota_protocol
|
||||
|
||||
#---------------------------------------------------------------------------------
|
||||
else
|
||||
.PHONY: all
|
||||
|
||||
DEPENDS := $(OFILES:.o=.d)
|
||||
|
||||
#---------------------------------------------------------------------------------
|
||||
all: $(OUTPUT).nro
|
||||
|
||||
$(OUTPUT).nro: $(OUTPUT).elf $(OUTPUT).nacp
|
||||
|
||||
$(OUTPUT).elf: $(OFILES)
|
||||
|
||||
-include $(DEPENDS)
|
||||
|
||||
#---------------------------------------------------------------------------------
|
||||
endif
|
||||
#---------------------------------------------------------------------------------
|
||||
endif
|
||||
@@ -0,0 +1,89 @@
|
||||
# Native Switch OTA launcher
|
||||
|
||||
In-console updates for Gen1Recomp on Nintendo Switch. This NRO is the **hbmenu
|
||||
entry** (`gen1recomp.nro`). It checks GitHub Releases quietly (no UI when you
|
||||
are already up to date or offline). Only if a newer release exists does it show
|
||||
a **launcher-style screen** (black + RGB rail + project logo + flat A/B
|
||||
buttons), download the same `gen1recomp-*-switch.zip` used for install, verify
|
||||
SHA-256 from `sha256sums.txt`, replace **both** `gen1recomp-game.nro` and
|
||||
`gen1recomp.nro` (matching NACP version for hbmenu/Sphaira), then load the game
|
||||
with `envSetNextLoad`.
|
||||
|
||||
The LÖVE self-updater (`src/update/Check.lua`) stays **disabled** on NX.
|
||||
Wire format (also in Lua): `src/update/SwitchOta.lua`.
|
||||
NACP icon: `ports/switch/assets/icon.jpg`.
|
||||
|
||||
## Layout on microSD
|
||||
|
||||
```text
|
||||
sdmc:/switch/gen1recomp/gen1recomp.nro <- this launcher
|
||||
sdmc:/switch/gen1recomp/gen1recomp-game.nro <- fused LÖVE game
|
||||
sdmc:/switch/gen1recomp/version.txt <- installed X.Y.Z
|
||||
sdmc:/switch/gen1recomp/pokemon-love2d/ <- saves (never touched by OTA)
|
||||
```
|
||||
|
||||
## Host tests (no DEVKITPRO)
|
||||
|
||||
```bash
|
||||
cd ports/switch/ota-launcher
|
||||
make host-test
|
||||
```
|
||||
|
||||
## Switch build (DEVKITPRO)
|
||||
|
||||
Needs `DEVKITPRO` with packages roughly:
|
||||
|
||||
```bash
|
||||
(dkp-)pacman -S --needed switch-curl switch-mbedtls switch-zlib switch-zziplib
|
||||
# or: bash scripts/switch/install_devkitpro_deps.sh
|
||||
```
|
||||
|
||||
```bash
|
||||
export DEVKITPRO=/opt/devkitpro # typical
|
||||
cd ports/switch/ota-launcher
|
||||
make
|
||||
# -> gen1recomp.nro
|
||||
```
|
||||
|
||||
Or from repo root (as part of `--fused`):
|
||||
|
||||
```bash
|
||||
scripts/build_switch.sh --fetch --fused --version X.Y.Z
|
||||
```
|
||||
|
||||
Standalone launcher build:
|
||||
|
||||
```bash
|
||||
scripts/switch/build_ota_launcher.sh
|
||||
```
|
||||
|
||||
Docker fallback uses the same pin as fused builds (`scripts/switch/dkp-docker.image`).
|
||||
|
||||
## OTA logo asset
|
||||
|
||||
The launcher draws a pre-scaled logo from `romfs:/logo.rgba` (no PNG decoder in
|
||||
the NRO). The baked blob lives at `../assets/logo.rgba` and is copied into romfs at
|
||||
build time. After changing `assets/logo/logo.png`, regenerate:
|
||||
|
||||
```bash
|
||||
python3 scripts/switch/bake_ota_logo.py
|
||||
```
|
||||
|
||||
Requires Pillow, or on macOS uses `sips` when Pillow is not installed.
|
||||
|
||||
## Packaging
|
||||
|
||||
`scripts/switch/pack_sd_zip.sh GAME_NRO VERSION OUT_ZIP LAUNCHER_NRO` writes both
|
||||
NROs into the SD zip. That zip is also what OTA downloads.
|
||||
Manifest: `scripts/switch/ota_launcher.manifest`.
|
||||
|
||||
## Status / known gaps
|
||||
|
||||
- Zip extraction uses `switch-zziplib` (`ota_unzip.c`) on device.
|
||||
- OTA replaces game + launcher from the install zip (NACP versions stay aligned).
|
||||
The running launcher cannot overwrite its own NRO on sdmc/FAT; a tiny
|
||||
`ota-bootstrap.nro` (embedded in romfs) chainloads once to swap the staged
|
||||
launcher, then loads the game.
|
||||
- HTTPS uses Mozilla CA bundle in romfs (`cacert.pem`, fetched at build time). `ota_net_init()` mounts romfs before the quiet release check.
|
||||
- Sphaira HOME forwarders cache metadata until reinstalled (see docs/switch-install.md).
|
||||
- Release runner: `switch-dev` + (`install_devkitpro_deps.sh` **or** Docker)
|
||||
@@ -0,0 +1,144 @@
|
||||
/* Host-only unit tests for ota_protocol.c — no libnx. */
|
||||
#include "ota_protocol.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
static int g_fail = 0;
|
||||
|
||||
static void expect(int cond, const char *msg) {
|
||||
if (!cond) {
|
||||
fprintf(stderr, "FAIL: %s\n", msg);
|
||||
g_fail++;
|
||||
} else {
|
||||
printf("PASS: %s\n", msg);
|
||||
}
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
expect(ota_compare_semver("1.2.0", "1.1.0") == 1, "semver newer");
|
||||
expect(ota_compare_semver("1.1.0", "1.1.0") == 0, "semver equal");
|
||||
expect(ota_compare_semver("1.0.0", "1.1.0") == -1, "semver older");
|
||||
expect(ota_is_ota_asset_name("gen1recomp-1.5.0-switch.zip"), "ota asset name");
|
||||
expect(!ota_is_ota_asset_name("gen1recomp-1.5.0-switch-ota.zip"), "reject legacy ota zip name");
|
||||
expect(!ota_is_ota_asset_name("gen1recomp-1.5.0.love"), "reject love payload");
|
||||
|
||||
const char *json =
|
||||
"{"
|
||||
"\"tag_name\":\"v1.5.0\","
|
||||
"\"assets\":["
|
||||
"{\"name\":\"gen1recomp-1.5.0-switch.zip\","
|
||||
"\"browser_download_url\":\"https://example/switch.zip\"},"
|
||||
"{\"name\":\"sha256sums.txt\",\"browser_download_url\":\"https://example/sums\"}"
|
||||
"]"
|
||||
"}";
|
||||
ota_release_t rel;
|
||||
expect(ota_parse_release(json, &rel) == 1, "parse release");
|
||||
expect(strcmp(rel.version, "1.5.0") == 0, "release version");
|
||||
expect(strcmp(rel.asset_name, "gen1recomp-1.5.0-switch.zip") == 0, "release asset");
|
||||
|
||||
ota_decision_t d;
|
||||
ota_decide_update("1.4.0", &rel, &d);
|
||||
expect(strcmp(d.status, "available") == 0, "decide available");
|
||||
ota_decide_update("1.5.0", &rel, &d);
|
||||
expect(strcmp(d.status, "uptodate") == 0, "decide uptodate");
|
||||
|
||||
ota_release_t bad;
|
||||
expect(ota_parse_release("{\"tag_name\":\"v1.5.0\",\"assets\":[]}", &bad) == 0,
|
||||
"missing ota asset");
|
||||
expect(strcmp(bad.reason, "missing_ota_asset") == 0, "missing_ota_asset reason");
|
||||
|
||||
/* GitHub releases/latest shape: release-level name + fat uploader before browser_download_url. */
|
||||
const char *github_json =
|
||||
"{"
|
||||
"\"tag_name\":\"v0.1.70\","
|
||||
"\"name\":\"0.1.70\","
|
||||
"\"assets\":["
|
||||
"{"
|
||||
"\"url\":\"https://api.github.com/repos/bryanthaboi/gen1recomp/releases/assets/502823880\","
|
||||
"\"id\":502823880,"
|
||||
"\"name\":\"gen1recomp-0.1.70-switch.zip\","
|
||||
"\"label\":\"\","
|
||||
"\"uploader\":{"
|
||||
"\"login\":\"github-actions[bot]\","
|
||||
"\"id\":41898282,"
|
||||
"\"node_id\":\"MDM6Qm90NDE4OTgyODI=\","
|
||||
"\"avatar_url\":\"https://avatars.githubusercontent.com/in/15368?v=4\","
|
||||
"\"gravatar_id\":\"\","
|
||||
"\"url\":\"https://api.github.com/users/github-actions%5Bbot%5D\","
|
||||
"\"html_url\":\"https://github.com/apps/github-actions\","
|
||||
"\"followers_url\":\"https://api.github.com/users/github-actions%5Bbot%5D/followers\","
|
||||
"\"following_url\":\"https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}\","
|
||||
"\"gists_url\":\"https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}\","
|
||||
"\"starred_url\":\"https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}\","
|
||||
"\"subscriptions_url\":\"https://api.github.com/users/github-actions%5Bbot%5D/subscriptions\","
|
||||
"\"organizations_url\":\"https://api.github.com/users/github-actions%5Bbot%5D/orgs\","
|
||||
"\"repos_url\":\"https://api.github.com/users/github-actions%5Bbot%5D/repos\","
|
||||
"\"events_url\":\"https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}\","
|
||||
"\"received_events_url\":\"https://api.github.com/users/github-actions%5Bbot%5D/received_events\","
|
||||
"\"type\":\"Bot\","
|
||||
"\"user_view_type\":\"public\","
|
||||
"\"site_admin\":false"
|
||||
"},"
|
||||
"\"content_type\":\"application/zip\","
|
||||
"\"state\":\"uploaded\","
|
||||
"\"size\":9000573,"
|
||||
"\"browser_download_url\":\"https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.70/gen1recomp-0.1.70-switch.zip\""
|
||||
"}"
|
||||
"]"
|
||||
"}";
|
||||
ota_release_t gh;
|
||||
expect(ota_parse_release(github_json, &gh) == 1, "parse github-shaped release");
|
||||
expect(strcmp(gh.version, "0.1.70") == 0, "github release version");
|
||||
expect(strcmp(gh.asset_name, "gen1recomp-0.1.70-switch.zip") == 0, "github release asset");
|
||||
expect(strcmp(gh.download_url,
|
||||
"https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.70/gen1recomp-0.1.70-switch.zip") ==
|
||||
0,
|
||||
"github release download url");
|
||||
ota_decide_update("0.1.69", &gh, &d);
|
||||
expect(strcmp(d.status, "available") == 0, "github release decide 0.1.69->0.1.70");
|
||||
|
||||
const char *sums = "abc123 gen1recomp-1.5.0-switch.zip\n";
|
||||
ota_verify_t v;
|
||||
ota_verify_sha256("gen1recomp-1.5.0-switch.zip", "abc123", sums, &v);
|
||||
expect(v.ok == 1, "sha ok");
|
||||
ota_verify_sha256("gen1recomp-1.5.0-switch.zip", "deadbeef", sums, &v);
|
||||
expect(v.ok == 0 && strcmp(v.reason, "hash_mismatch") == 0, "sha mismatch");
|
||||
ota_verify_sha256("gen1recomp-1.5.0-switch.zip", "abc123", "", &v);
|
||||
expect(v.ok == 0 && strcmp(v.reason, "sum_not_found") == 0, "sha missing sum rejected");
|
||||
|
||||
ota_apply_plan_t plan;
|
||||
ota_plan_atomic_apply("switch/gen1recomp", "/tmp/x", &plan);
|
||||
expect(strstr(plan.steps[0], "copy_to_part:") != NULL, "plan copy");
|
||||
expect(strstr(plan.steps[1], "rename:") != NULL, "plan rename");
|
||||
expect(strstr(plan.steps[2], "copy_to_part:launcher") != NULL, "plan launcher copy");
|
||||
expect(strstr(plan.steps[3], "rename:") != NULL, "plan launcher rename");
|
||||
expect(strstr(plan.steps[4], "env_set_next_load:") != NULL, "plan handoff");
|
||||
expect(strstr(plan.preserve, "pokemon-love2d") != NULL, "preserve saves");
|
||||
expect(strstr(plan.forbidden_delete, "delete:") != NULL, "forbid delete saves");
|
||||
expect(strstr(plan.forbidden_direct, "write_direct:") != NULL, "forbid direct write");
|
||||
|
||||
ota_offline_t off;
|
||||
ota_offline_events_t ev = {0};
|
||||
ev.user_skip = 1;
|
||||
ota_offline_policy(1.0, &ev, &off);
|
||||
expect(strcmp(off.action, "play_installed") == 0, "skip plays installed");
|
||||
ev.user_skip = 0;
|
||||
ev.network_ok = 0;
|
||||
ota_offline_policy(1.0, &ev, &off);
|
||||
expect(strcmp(off.action, "play_installed") == 0, "offline plays installed");
|
||||
ev.network_ok = 1;
|
||||
ota_offline_policy(6.0, &ev, &off);
|
||||
expect(strcmp(off.reason, "timeout") == 0, "timeout 6s");
|
||||
ota_offline_policy(2.0, &ev, &off);
|
||||
expect(strcmp(off.action, "keep_checking") == 0, "still checking");
|
||||
|
||||
expect(OTA_CHECK_TIMEOUT_SEC == 6, "timeout constant 6");
|
||||
|
||||
if (g_fail) {
|
||||
fprintf(stderr, "%d failure(s)\n", g_fail);
|
||||
return 1;
|
||||
}
|
||||
printf("all ota_protocol host tests passed\n");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
#ifndef GEN1_OTA_FS_H
|
||||
#define GEN1_OTA_FS_H
|
||||
|
||||
#include "ota_protocol.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* Read installed game version from sibling version.txt or NRO nacp-less stamp file.
|
||||
* Expects switch/gen1recomp/version.txt containing X.Y.Z. Returns 0 on success. */
|
||||
int ota_fs_read_installed_version(const char *install_dir, char *out, size_t out_len);
|
||||
|
||||
/* SHA-256 hex (lowercase) of file. Returns 0 on success. */
|
||||
int ota_fs_sha256_file(const char *path, char *out_hex, size_t out_len);
|
||||
|
||||
/* Apply verified temp payload (extracted game NRO) using atomic plan. Returns 0 on success. */
|
||||
int ota_fs_atomic_replace_game(const char *install_dir, const char *verified_game_nro,
|
||||
char *err, size_t err_len);
|
||||
|
||||
/* Atomically replace any named NRO under install_dir (copy→.part→rename). */
|
||||
int ota_fs_atomic_replace_nro(const char *install_dir, const char *nro_name,
|
||||
const char *verified_nro, char *err, size_t err_len);
|
||||
|
||||
/* Stage a new launcher + copy romfs bootstrap; chainload bootstrap_out next.
|
||||
* The running launcher cannot replace its own NRO on sdmc/FAT. */
|
||||
int ota_fs_stage_launcher_bootstrap(const char *install_dir, const char *verified_launcher,
|
||||
char *bootstrap_out, size_t bootstrap_out_len, char *err,
|
||||
size_t err_len);
|
||||
|
||||
/* Hand off to game NRO via envSetNextLoad (Switch) or no-op stub (host). */
|
||||
int ota_fs_handoff_to_game(const char *game_nro_path, char *err, size_t err_len);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,29 @@
|
||||
#ifndef GEN1_OTA_NET_H
|
||||
#define GEN1_OTA_NET_H
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* fraction is 0..1 when Content-Length is known, else -1 for indeterminate. */
|
||||
typedef void (*ota_net_progress_fn)(void *userdata, double fraction);
|
||||
|
||||
/* Mount romfs (CA bundle) and init libcurl. Call before any HTTPS download. */
|
||||
int ota_net_init(void);
|
||||
void ota_net_shutdown(void);
|
||||
|
||||
/* Download URL into memory buffer (caller frees *out). Returns 0 on success. */
|
||||
int ota_net_download_buffer(const char *url, long timeout_ms, char **out, size_t *out_len,
|
||||
char *err, size_t err_len);
|
||||
|
||||
/* Download URL to a filesystem path. progress may be NULL. Returns 0 on success. */
|
||||
int ota_net_download_file(const char *url, const char *path, long timeout_ms, char *err,
|
||||
size_t err_len, ota_net_progress_fn progress, void *progress_ud);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,95 @@
|
||||
#ifndef GEN1_OTA_PROTOCOL_H
|
||||
#define GEN1_OTA_PROTOCOL_H
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#define OTA_CHECK_TIMEOUT_SEC 6
|
||||
#define OTA_GAME_NRO_NAME "gen1recomp-game.nro"
|
||||
#define OTA_LAUNCHER_NRO_NAME "gen1recomp.nro"
|
||||
#define OTA_SAVE_DIR_NAME "pokemon-love2d"
|
||||
#define OTA_INSTALL_DIR "switch/gen1recomp"
|
||||
#define OTA_LAUNCHER_STAGED_SUFFIX ".staged"
|
||||
#define OTA_BOOTSTRAP_ROMFS "romfs:/ota-bootstrap.nro"
|
||||
#define OTA_BOOTSTRAP_SD_NAME "ota-bootstrap.nro"
|
||||
#define OTA_RELEASES_API \
|
||||
"https://api.github.com/repos/bryanthaboi/gen1recomp/releases/latest"
|
||||
|
||||
/* Mirrors src/update/SwitchOta.lua — keep semantics in lockstep. */
|
||||
|
||||
int ota_compare_semver(const char *a, const char *b);
|
||||
int ota_is_ota_asset_name(const char *name);
|
||||
int ota_version_from_ota_asset(const char *name, char *out, size_t out_len);
|
||||
|
||||
typedef struct {
|
||||
int ok; /* 1 on success */
|
||||
char reason[64];
|
||||
char tag[64];
|
||||
char version[32];
|
||||
char asset_name[128];
|
||||
char download_url[512];
|
||||
} ota_release_t;
|
||||
|
||||
int ota_parse_release(const char *json_text, ota_release_t *out);
|
||||
|
||||
typedef struct {
|
||||
char status[32]; /* uptodate | available | error */
|
||||
char reason[64];
|
||||
char version[32];
|
||||
char asset_name[128];
|
||||
char download_url[512];
|
||||
} ota_decision_t;
|
||||
|
||||
void ota_decide_update(const char *installed_version, const ota_release_t *release,
|
||||
ota_decision_t *out);
|
||||
|
||||
/* sums: newline-separated sha256sums.txt body */
|
||||
int ota_lookup_sum(const char *sums_text, const char *asset_name, char *out_hex,
|
||||
size_t out_len);
|
||||
|
||||
typedef struct {
|
||||
int ok;
|
||||
char reason[64];
|
||||
} ota_verify_t;
|
||||
|
||||
void ota_verify_sha256(const char *asset_name, const char *actual_hex,
|
||||
const char *sums_text, ota_verify_t *out);
|
||||
|
||||
typedef struct {
|
||||
char steps[5][520]; /* human-readable ops */
|
||||
char preserve[256];
|
||||
char forbidden_delete[256];
|
||||
char forbidden_direct[256];
|
||||
char part_path[256];
|
||||
char game_nro[240];
|
||||
char launcher_nro[240];
|
||||
char launcher_part[256];
|
||||
char next_load[240];
|
||||
} ota_apply_plan_t;
|
||||
|
||||
void ota_plan_atomic_apply(const char *install_dir, const char *verified_temp,
|
||||
ota_apply_plan_t *out);
|
||||
|
||||
typedef struct {
|
||||
char action[32]; /* play_installed | keep_checking */
|
||||
char reason[64];
|
||||
char message[128];
|
||||
} ota_offline_t;
|
||||
|
||||
typedef struct {
|
||||
int user_skip;
|
||||
int network_ok; /* 0 = offline/fail, 1 = ok, -1 = unknown */
|
||||
int api_error;
|
||||
} ota_offline_events_t;
|
||||
|
||||
void ota_offline_policy(double elapsed_sec, const ota_offline_events_t *events,
|
||||
ota_offline_t *out);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* GEN1_OTA_PROTOCOL_H */
|
||||
@@ -0,0 +1,37 @@
|
||||
#ifndef GEN1_OTA_UI_H
|
||||
#define GEN1_OTA_UI_H
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* Framebuffer UI matching the in-game launcher look (black + RGB rail +
|
||||
* flat semantic buttons). Silent by default — only call when user-facing. */
|
||||
|
||||
/* Returns 1 = update, 0 = skip / play installed. */
|
||||
int ota_ui_prompt_update(const char *installed, const char *latest);
|
||||
|
||||
/* Status screen (download / install). Redraws each call. */
|
||||
void ota_ui_show_status(const char *title, const char *detail);
|
||||
|
||||
/* Progress 0..1; detail optional. */
|
||||
void ota_ui_show_progress(const char *title, const char *detail, float progress01);
|
||||
|
||||
/* Error / info + wait for B. */
|
||||
void ota_ui_alert(const char *title, const char *line1, const char *line2);
|
||||
|
||||
/* User-friendly error with optional technical detail and installed-version footer. */
|
||||
void ota_ui_alert_error(const char *title, const char *friendly, const char *technical,
|
||||
const char *installed_version);
|
||||
|
||||
/* Missing game install screen; wait for +. */
|
||||
void ota_ui_missing_game(void);
|
||||
|
||||
/* Tear down framebuffer if active. Safe to call when UI never opened. */
|
||||
void ota_ui_shutdown(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,20 @@
|
||||
#ifndef GEN1_OTA_UNZIP_H
|
||||
#define GEN1_OTA_UNZIP_H
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* Extract member_name from zip_path into dest_path.
|
||||
* Tries exact member_name, then common prefixes (switch/gen1recomp/).
|
||||
* Returns 0 on success. */
|
||||
int ota_unzip_extract_file(const char *zip_path, const char *member_name, const char *dest_path,
|
||||
char *err, size_t err_len);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||