Compare commits
317 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c2e1db0f89 | |||
| 7b1e796c48 | |||
| 4c13770e70 | |||
| cf45cbbf92 | |||
| 4e1ab1879b | |||
| 917735a41c | |||
| c877ea80a7 | |||
| 7d1ddf9b7c | |||
| faf82c2cec | |||
| 5bc6036735 | |||
| 809628f0fa | |||
| 4817ff8bf9 | |||
| 4f8739c029 | |||
| c655217120 | |||
| fbdfc1c053 | |||
| 72c244b433 | |||
| e0e030003b | |||
| ce2afb83f1 | |||
| 28f741f72f | |||
| 22bcd95da1 | |||
| ea28f886f3 | |||
| 6cd8f0ddea | |||
| 7e25da70f0 | |||
| df3d3e7600 | |||
| 8c9af95598 | |||
| 45519ad550 | |||
| 3cca70608f | |||
| fb4eaeda10 | |||
| 69100301a1 | |||
| a5b674f9da | |||
| 4356b94483 | |||
| c8bd205d0c | |||
| 995444774b | |||
| 360b692963 | |||
| 051040371f | |||
| c5ff95edcf | |||
| d6627eda4c | |||
| 6e28cd5dca | |||
| 08ddb882af | |||
| 2f6f094559 | |||
| 6ac425144d | |||
| 73f561e256 | |||
| b739fa76c0 | |||
| 0b00faf38e | |||
| b20b1370ab | |||
| 467566c799 | |||
| d6ddf23f97 | |||
| 9423337bcc | |||
| c280119d03 | |||
| 24d0c6528d | |||
| 1fc34da2a9 | |||
| 70b9def0b0 | |||
| d03d2af5f8 | |||
| c23f85cba9 | |||
| 455ff21aff | |||
| 2da2168dac | |||
| f62b1268c8 | |||
| 82ae667611 | |||
| 2b5229e73f | |||
| 881670db91 | |||
| a542ed90ba | |||
| e9a4a592a4 | |||
| 70f7d5c028 | |||
| 46f73b7bb3 | |||
| 99d9908017 | |||
| b72d1d34b5 | |||
| f3619a00c2 | |||
| 524138ff27 | |||
| fdffb12571 | |||
| 0c941cecd4 | |||
| dd59175c71 | |||
| 114352b75f | |||
| 0e40a7a1f4 | |||
| fe6a580e20 | |||
| a910b65434 | |||
| 1f3d13adaf | |||
| 393a1013e4 | |||
| a3a20a07e1 | |||
| 1151c188a7 | |||
| b39e11b7cd | |||
| 65128e13a4 | |||
| d74662ba99 | |||
| 5f2b2c616e | |||
| 32e6e4cef0 | |||
| fc841f7525 | |||
| 12fdfa1e88 | |||
| 90eb53b00c | |||
| a34d8611bf | |||
| 73fbaaa250 | |||
| 9469e39926 | |||
| 179048a58e | |||
| e1f5c2b217 | |||
| 1d8ac1e692 | |||
| 39df5bdfa6 | |||
| 4b7a4daf2c | |||
| e1d233d026 | |||
| c22888a7fd | |||
| 6780393f45 | |||
| 530f2bdd15 | |||
| 0e4fc3c54a | |||
| 82b91e36ca | |||
| 3588a5f3fe | |||
| 992dc80aa7 | |||
| 6e7073d32b | |||
| 000e691966 | |||
| fdbc51c5d1 | |||
| 3e3566d1b4 | |||
| fcb5d1d348 | |||
| b45d783dee | |||
| d99072b44e | |||
| 0ac55b0f9e | |||
| ab48572852 | |||
| 5d2c13ed2b | |||
| d87f6b8ad1 | |||
| 24cf367758 | |||
| 829d398a94 | |||
| 3ee50a27c5 | |||
| 871087a16b | |||
| 180ce6b2e7 | |||
| cf335f67de | |||
| 7e0d81a431 | |||
| 10314bcdcf | |||
| b8ec4fe6b5 | |||
| 099a4266a8 | |||
| cec1f196be | |||
| e24410f0fb | |||
| b29b6fd7bd | |||
| f06c4d4584 | |||
| 3a997e8a62 | |||
| e6ccdd57eb | |||
| 927507f8f7 | |||
| 9bf15c33fd | |||
| 52efdabf61 | |||
| ef208035ec | |||
| 18d61779eb | |||
| d573878a2f | |||
| a66efe207d | |||
| 5198b35945 | |||
| 40977337b1 | |||
| 1598f34954 | |||
| 43cbc554c3 | |||
| 3c3e2c54c5 | |||
| 00c72c441b | |||
| 7804ef9793 | |||
| 673d8b3ad8 | |||
| 62e1296ced | |||
| a3bbd78e7b | |||
| 8dfbd1daae | |||
| 4046b28a8c | |||
| a86d57ac44 | |||
| 72f126ae26 | |||
| 5d7a8c9291 | |||
| c48fc578ca | |||
| dfc216f974 | |||
| 542856c83d | |||
| 545a99d86d | |||
| 2b6473ae03 | |||
| df0be1cba6 | |||
| c9d67582ae | |||
| 1cfd91a503 | |||
| fb738fa1ce | |||
| c0ad6d0328 | |||
| 84de8c9cb1 | |||
| 6307bc92f6 | |||
| 052dd26b3e | |||
| c8f6c7241b | |||
| b3928388ef | |||
| a38fae5a97 | |||
| 9fab992d42 | |||
| dcc388a942 | |||
| 797a6bebfe | |||
| 97a9c0f58f | |||
| 407f649e9d | |||
| 84635cdfdf | |||
| 3de45b671c | |||
| 4395792226 | |||
| a77210799f | |||
| 6f67292b0e | |||
| 78e8a31ead | |||
| b6388013ec | |||
| ba7cd8fabf | |||
| c8f7f396ce | |||
| a94fecfec8 | |||
| 2010ba71a6 | |||
| e90dff6764 | |||
| f4658b89ae | |||
| da63163216 | |||
| fceaa2b54c | |||
| be2ef46ebc | |||
| d8e86e6878 | |||
| 7e57e3174e | |||
| 5192106730 | |||
| dfeacc36b0 | |||
| 1586aec9f6 | |||
| 35d44efb8b | |||
| 500d8c2c07 | |||
| 09204daa28 | |||
| 01329758ff | |||
| 7b8cb127a8 | |||
| 8e4d5b6807 | |||
| 26e9e1d597 | |||
| f7f32d2786 | |||
| c5192eaea4 | |||
| c2906744ce | |||
| 8e6b5ae73d | |||
| 0b96ac0ab6 | |||
| f6a035947f | |||
| 941181d31c | |||
| 9ceb1a8940 | |||
| 37051a26b5 | |||
| b12a0dac91 | |||
| e4786a78d9 | |||
| bde606f966 | |||
| a722d24759 | |||
| 833388c235 | |||
| 2ac527ae3c | |||
| df35193ad3 | |||
| 9b6365c9cc | |||
| 285a98533a | |||
| f952edf77a | |||
| ae8360b2ae | |||
| 52e36ad7e4 | |||
| 62b9f04191 | |||
| 1010d0584a | |||
| 6864173f35 | |||
| e9d431b3ff | |||
| f138827917 | |||
| a68c47e7e1 | |||
| d648990a81 | |||
| 6ff7dd0c38 | |||
| ee00728e9e | |||
| 0aab11b690 | |||
| c2d9af693d | |||
| 1642113d1f | |||
| d64627e062 | |||
| 5fea0b05a4 | |||
| c3136bf8f7 | |||
| 517170f1c8 | |||
| e5bebea437 | |||
| 3320eacc26 | |||
| b40ff167e7 | |||
| c0f654f8c2 | |||
| f4497b4dbb | |||
| b9684ef162 | |||
| f394f56395 | |||
| 6731937841 | |||
| e44769a48a | |||
| b2819d04e0 | |||
| 52fac8ced6 | |||
| d38faab03c | |||
| 83682f011d | |||
| 1176a269e5 | |||
| 96af652d3b | |||
| 29a2b9a123 | |||
| 238af263d6 | |||
| dbd8abe14c | |||
| 04490c9b9a | |||
| 49d094b14d | |||
| cd7985e4ac | |||
| 61edf3470f | |||
| cd02cd6f33 | |||
| 127e3da909 | |||
| 8fa702e715 | |||
| 3aaaf9936e | |||
| af33c6e810 | |||
| e8eccfd4df | |||
| 241c3345bd | |||
| 20e0692486 | |||
| 0136429d3e | |||
| 01aab1d763 | |||
| 3d2d53362d | |||
| 03838ee1d4 | |||
| 2fabc03841 | |||
| ae6cac89e1 | |||
| 41f02ecfbc | |||
| cef286b170 | |||
| ab94b96a79 | |||
| cc43bd77d2 | |||
| d4dc72d0f4 | |||
| 6d841526b1 | |||
| 8e48bb4e32 | |||
| a0279f5be2 | |||
| 2c8c800584 | |||
| ccb5358aad | |||
| 4db97164bb | |||
| 882763cfe1 | |||
| 798f3c25c0 | |||
| d21a0edffc | |||
| 02024fef58 | |||
| a4c51eccea | |||
| a9767e5df1 | |||
| e59175fc89 | |||
| 59725c0ead | |||
| 4e20f4585f | |||
| 1c37867e52 | |||
| 67491e2dac | |||
| 2d85c9595d | |||
| b8138ef850 | |||
| e3fc8380cf | |||
| b9afaaae60 | |||
| 4d03a38067 | |||
| 79ed37699e | |||
| abf0f9e98a | |||
| 3955721bfc | |||
| c3855cebec | |||
| 41e6507665 | |||
| 5482ac590d | |||
| 28440eb936 | |||
| f606840216 | |||
| 6a990bf79f | |||
| ab5fed60fe | |||
| 9f54734f62 | |||
| eec396e388 | |||
| 1ef0d8c2c0 | |||
| 0f24105686 | |||
| d35b9e5c3e | |||
| ee5c168983 |
@@ -22,12 +22,14 @@ body:
|
||||
id: game
|
||||
attributes:
|
||||
label: Which game were you playing
|
||||
description: Pick every version you saw the bug in.
|
||||
description: Pick every version you saw the bug in. Use N/A if it isn't game-specific.
|
||||
multiple: true
|
||||
options:
|
||||
- Red
|
||||
- Blue
|
||||
- Yellow
|
||||
- Gold
|
||||
- N/A
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -35,12 +37,16 @@ body:
|
||||
id: os
|
||||
attributes:
|
||||
label: Which build are you running
|
||||
description: Official release targets. Pick Multiple platforms if you saw it on more than one.
|
||||
options:
|
||||
- macOS
|
||||
- Windows
|
||||
- Linux
|
||||
- Android
|
||||
- iOS
|
||||
- Nintendo Switch
|
||||
- Xbox
|
||||
- Anbernic RG34XXSP
|
||||
- Multiple platforms
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -30,13 +30,14 @@ body:
|
||||
id: game
|
||||
attributes:
|
||||
label: Which game is this about
|
||||
description: Pick every version it applies to.
|
||||
description: Pick every version it applies to. Use N/A if it isn't game-specific.
|
||||
multiple: true
|
||||
options:
|
||||
- Red
|
||||
- Blue
|
||||
- Yellow
|
||||
- Not version-specific
|
||||
- Gold
|
||||
- N/A
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
||||
@@ -27,13 +27,14 @@ body:
|
||||
id: game
|
||||
attributes:
|
||||
label: Which game is this for
|
||||
description: Pick every version the mod should cover.
|
||||
description: Pick every version the mod should cover. Use N/A if it isn't game-specific.
|
||||
multiple: true
|
||||
options:
|
||||
- Red
|
||||
- Blue
|
||||
- Yellow
|
||||
- Not version-specific
|
||||
- Gold
|
||||
- N/A
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
||||
@@ -94,8 +94,8 @@ jobs:
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: gen1recomp-ios-ipa
|
||||
path: dist/ios/gen1recomp.ipa
|
||||
name: gen1recomp++-ios-ipa
|
||||
path: dist/ios/gen1recomp++.ipa
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
- name: clean up signing keychain
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
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-ios-ipa") | .id')"
|
||||
artifact_id="$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts" --jq '.artifacts[] | select(.name == "gen1recomp++-ios-ipa") | .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')"
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
uses: thollander/actions-comment-pull-request@v3
|
||||
with:
|
||||
message: |
|
||||
[gen1recomp.ipa](${{ steps.artifact.outputs.artifact_url }})
|
||||
[gen1recomp++.ipa](${{ 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 }}`
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
name: Release
|
||||
|
||||
# Builds the macOS, Windows, and Linux desktop apps, an Android APK, an iOS
|
||||
# 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
|
||||
# IPA, a Nintendo Switch SD-ready zip (experimental), Xbox UWP, the Anbernic
|
||||
# RG34XXSP (Stock OS 64-bit MOD / PortMaster) and Linux ARM SBC PortMaster
|
||||
# handheld ports on the self-hosted Mac runner, and publishes them as a
|
||||
# GitHub Release.
|
||||
#
|
||||
# Versioning:
|
||||
@@ -251,8 +252,40 @@ jobs:
|
||||
Remove-Item $env:UWP_PFX -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# Windows Native AOT TLS dialer. The Mac release runner fuses the win64 zip
|
||||
# from LÖVE's prebuilt binaries and cannot cross-compile this DLL, so build
|
||||
# it here and inject it in the release job before scripts/build.sh win.
|
||||
native-tls-win:
|
||||
name: build Windows gen1tls.dll
|
||||
needs: version
|
||||
runs-on: windows-2022
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Setup .NET 8
|
||||
uses: actions/setup-dotnet@v6
|
||||
with:
|
||||
dotnet-version: "8.0.x"
|
||||
- name: Publish gen1tls (win-x64 Native AOT)
|
||||
shell: pwsh
|
||||
run: |
|
||||
$out = "dist/native/win-x64"
|
||||
New-Item -ItemType Directory -Force -Path $out | Out-Null
|
||||
dotnet publish native/tls_dial/Gen1Tls.csproj `
|
||||
-c Release -r win-x64 -o $out
|
||||
if (-not (Test-Path "$out/gen1tls.dll")) {
|
||||
throw "gen1tls.dll missing after publish"
|
||||
}
|
||||
Get-Item "$out/gen1tls.dll" | Format-List Name, Length, LastWriteTime
|
||||
- name: Upload gen1tls.dll
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: gen1tls-win-x64
|
||||
path: dist/native/win-x64/gen1tls.dll
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
release:
|
||||
needs: [version, xbox-uwp, linux-arm64]
|
||||
needs: [version, xbox-uwp, linux-arm64, native-tls-win]
|
||||
runs-on: ${{ fromJSON(github.repository == 'bryanthaboi/gen1recomp' && '["self-hosted", "macOS"]' || '"macos-latest"') }}
|
||||
|
||||
steps:
|
||||
@@ -268,6 +301,12 @@ jobs:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- name: Download Windows gen1tls dialer
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: gen1tls-win-x64
|
||||
path: dist/native/win-x64
|
||||
|
||||
- name: Import signing certificate into a temporary keychain
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
run: |
|
||||
@@ -306,13 +345,21 @@ jobs:
|
||||
security find-identity -v -p codesigning "$KEYCHAIN_PATH"
|
||||
|
||||
- name: Build macOS + Windows + Linux
|
||||
env:
|
||||
GEN1TLS_DLL: ${{ github.workspace }}/dist/native/win-x64/gen1tls.dll
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Sign in-build (identity auto-detected from the temp keychain);
|
||||
# notarize separately below so it uses secret credentials, not a
|
||||
# login-keychain profile. "all" also builds the Linux AppImage,
|
||||
# which needs no signing/notarization.
|
||||
if [ ! -f "$GEN1TLS_DLL" ]; then
|
||||
echo "::error::gen1tls.dll missing at $GEN1TLS_DLL (native-tls-win job)"
|
||||
exit 1
|
||||
fi
|
||||
scripts/build.sh all --version "${{ needs.version.outputs.version }}" --no-notarize
|
||||
unzip -l dist/win/gen1recomp-win64.zip | grep -F gen1tls.dll \
|
||||
|| { echo "::error::Windows zip is missing gen1tls.dll"; exit 1; }
|
||||
|
||||
- name: Build Android
|
||||
run: |
|
||||
@@ -355,6 +402,20 @@ jobs:
|
||||
# runtime from PortMaster-GUI, so it needs no signing/notarization.
|
||||
./build-rg34xxsp.sh --version "${{ needs.version.outputs.version }}"
|
||||
|
||||
- name: Build Linux ARM SBC PortMaster port
|
||||
env:
|
||||
# The release workflow must package the commit being released. The
|
||||
# script defaults to the latest published release for standalone
|
||||
# builds, while this explicit local override keeps CI source-aligned.
|
||||
GEN1RECOMP_SOURCE_DIR: ${{ github.workspace }}
|
||||
GEN1RECOMP_RELEASE_TAG: v${{ needs.version.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Same aarch64 PortMaster-style pack for Linux ARM SBC PortMaster. The build
|
||||
# keeps its own cache because the two scripts use different staging
|
||||
# layouts and runtime package paths.
|
||||
./build-linux-arm-sbc.sh --version "${{ needs.version.outputs.version }}"
|
||||
|
||||
- name: Notarize & staple macOS app
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
run: |
|
||||
@@ -429,9 +490,9 @@ jobs:
|
||||
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; }
|
||||
cp "$apk" "$outdir/gen1recomp-${v}-android.apk"
|
||||
|
||||
ipa="dist/ios/gen1recomp.ipa"
|
||||
ipa="dist/ios/gen1recomp++.ipa"
|
||||
[ -f "$ipa" ] || { echo "::error::$ipa not found (expected from scripts/build_ios.sh --device)"; exit 1; }
|
||||
cp "$ipa" "$outdir/gen1recomp-${v}-ios.ipa"
|
||||
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; }
|
||||
@@ -449,6 +510,11 @@ jobs:
|
||||
[ -f "$rg34" ] || { echo "::error::$rg34 not found (expected from ./build-rg34xxsp.sh)"; exit 1; }
|
||||
cp "$rg34" "$outdir/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip"
|
||||
|
||||
# Linux ARM SBC PortMaster handheld port.
|
||||
sbc="dist/linux-arm-sbc/gen1recomp-sbc-portmaster.zip"
|
||||
[ -f "$sbc" ] || { echo "::error::$sbc not found (expected from ./build-linux-arm-sbc.sh)"; exit 1; }
|
||||
cp "$sbc" "$outdir/gen1recomp-${v}-sbc-portmaster.zip"
|
||||
|
||||
# Platform-independent update payload, built alongside the desktop
|
||||
# apps above (same game.love that gets fused into each of them).
|
||||
love_file=".bazinga/work/game.love"
|
||||
@@ -560,10 +626,11 @@ jobs:
|
||||
"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}-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}-sbc-portmaster.zip"
|
||||
"dist/release/gen1recomp-${v}.love"
|
||||
"dist/release/sha256sums.txt"
|
||||
)
|
||||
@@ -581,14 +648,15 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
v="${{ needs.version.outputs.version }}"
|
||||
ipa="dist/release/gen1recomp-${v}-ios.ipa"
|
||||
ipa="dist/release/gen1recomp++-${v}-ios.ipa"
|
||||
app_repo="mobile/ios/app-repo.json"
|
||||
[ -f "$ipa" ] || { echo "::error::$ipa not found"; exit 1; }
|
||||
[ -f "$app_repo" ] || { echo "::error::$app_repo not found"; exit 1; }
|
||||
|
||||
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"
|
||||
download_url="https://github.com/${GITHUB_REPOSITORY}/releases/download/v${v}/gen1recomp++-${v}-ios.ipa"
|
||||
bundle_id="com.theboisclub.gen1recompplusplus"
|
||||
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
|
||||
@@ -602,15 +670,15 @@ jobs:
|
||||
--argjson size "$size" \
|
||||
'{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)' \
|
||||
if jq -e --arg bundle_id "$bundle_id" --arg version "$v" \
|
||||
'any(.apps[] | select(.bundleIdentifier == $bundle_id).versions[]?; .version == $version)' \
|
||||
"$app_repo" >/dev/null; then
|
||||
jq --arg version "$v" --argjson entry "$entry" \
|
||||
'(.apps[] | select(.bundleIdentifier == "com.theboisclub.gen1recomp").versions) |= map(if .version == $version then $entry else . end)' \
|
||||
jq --arg bundle_id "$bundle_id" --arg version "$v" --argjson entry "$entry" \
|
||||
'(.apps[] | select(.bundleIdentifier == $bundle_id).versions) |= map(if .version == $version then $entry else . end)' \
|
||||
"$app_repo" > "$app_repo.tmp"
|
||||
else
|
||||
jq --argjson entry "$entry" \
|
||||
'(.apps[] | select(.bundleIdentifier == "com.theboisclub.gen1recomp").versions) |= [$entry] + .' \
|
||||
jq --arg bundle_id "$bundle_id" --argjson entry "$entry" \
|
||||
'(.apps[] | select(.bundleIdentifier == $bundle_id).versions) |= [$entry] + .' \
|
||||
"$app_repo" > "$app_repo.tmp"
|
||||
fi
|
||||
mv "$app_repo.tmp" "$app_repo"
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
data/generated/
|
||||
assets/generated/
|
||||
|
||||
# LÖVE packages
|
||||
# LÖVE packages & archives
|
||||
*.love
|
||||
*.zip
|
||||
|
||||
# Local saves (LÖVE writes to its save dir, but keep the repo clean anyway)
|
||||
save/
|
||||
@@ -68,3 +69,19 @@ mobile/ios/bundle_id.local
|
||||
/ports/uwp/build/
|
||||
/ports/uwp/third_party/*/source/
|
||||
/ports/uwp/third_party/angle/depot_tools/
|
||||
|
||||
# Native TLS dialer build output (dotnet publish)
|
||||
/native/tls_dial/bin/
|
||||
/native/tls_dial/obj/
|
||||
/dist/native/
|
||||
/dist/win/
|
||||
/.bazinga/
|
||||
|
||||
# Local options / preferences
|
||||
/options.lua*
|
||||
|
||||
# User-owned ROMs imported for individual mods. Manifests declare the
|
||||
# destinations, but source checkouts and packaged mods never ship the files.
|
||||
/mods/*/baseroms/
|
||||
/imports/baseroms/
|
||||
/imports/baseroms-recovery/
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
# AI Disclosure
|
||||
|
||||
This is a disclosure of the use of AI in this project.
|
||||
|
||||
## AI Use
|
||||
|
||||
Anyone who demands the dislosure of how AI was used in an engineering project,
|
||||
has no idea what AI is, or how it works.
|
||||
|
||||
AI was used in this project as a tool. Several contributors used AI in their
|
||||
commits, and so you will see like 7 commits by Claude or Codex or Cursor.
|
||||
|
||||
However those commits were reviewed by human beings, and it was declared that
|
||||
the exact same fix would have been done by a human, so they were accepted.
|
||||
|
||||
AI was not used to make decisions, or to create the project.
|
||||
|
||||
If you would like to read more, well then continue reading:
|
||||
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque ante leo, luctus in semper a, maximus ut est. Vivamus nec magna vitae quam luctus suscipit nec eu orci. Vestibulum ut felis a dolor cursus vulputate. Phasellus pharetra elementum sollicitudin. Aenean elementum imperdiet ultrices. In risus mauris, scelerisque sed viverra in, iaculis non eros. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin quam justo, maximus sit amet fringilla in, tristique eget sapien.
|
||||
|
||||
Pellentesque rhoncus, erat nec elementum ultricies, augue purus suscipit augue, in condimentum nisl enim vel velit. Etiam at semper turpis. Vestibulum ipsum magna, ultrices non sem ut, hendrerit bibendum mi. Curabitur in varius sapien. Morbi posuere bibendum ex, at ultrices orci. Fusce feugiat ultrices varius. Suspendisse sed ante ligula. Sed dignissim lorem est, nec tristique arcu commodo sed. Cras consectetur sapien dolor, vitae finibus enim lacinia id.
|
||||
|
||||
Donec quis magna est. Maecenas dui arcu, venenatis sit amet libero nec, lacinia eleifend leo. Quisque lobortis vulputate lacus a elementum. Proin nec metus lectus. Donec eu auctor sem, at finibus ipsum. Curabitur eget dignissim justo. Donec lobortis leo eu arcu tristique, in volutpat augue eleifend. Morbi lacinia a risus in suscipit. Maecenas suscipit est eu interdum dictum. Cras in nulla imperdiet, dapibus mauris posuere, facilisis velit. Nunc dapibus, leo quis interdum tempor, elit mi mattis dolor, sagittis dictum mi urna sed lectus. Maecenas elementum, mauris id molestie dapibus, diam arcu egestas erat, at tempor justo orci vitae nibh. Etiam sagittis facilisis erat a vulputate. Praesent condimentum ac odio quis sollicitudin.
|
||||
|
||||
Fusce vitae orci vestibulum, sagittis dolor non, cursus urna. Morbi eleifend pretium pellentesque. Pellentesque ornare elementum sem in imperdiet. Maecenas dapibus, erat et lobortis porttitor, velit magna auctor odio, quis interdum elit est eu justo. In posuere euismod odio, in porttitor magna iaculis eget. In id quam pulvinar, ultrices dolor in, pellentesque dolor. Nunc varius ante at felis dictum, id porttitor sem efficitur. Integer pretium dignissim commodo. Suspendisse in est a arcu blandit faucibus. Donec quis lacus mollis, tincidunt nunc quis, suscipit nunc. Nunc non arcu dignissim, dignissim sem in, finibus neque. Aliquam non porta eros. Donec et pretium augue, non cursus eros.
|
||||
|
||||
Nunc at dignissim nisi. Nam nec metus augue. Proin nulla sapien, tristique a purus vel, vulputate commodo mi. Sed id erat leo. Quisque ullamcorper a nisl id molestie. Aliquam erat volutpat. Donec eget hendrerit mauris. Fusce tincidunt nisl a lorem tincidunt dapibus. Nam volutpat rhoncus tortor.
|
||||
|
||||
Nunc et sapien enim. Proin at nunc a nulla maximus consectetur nec eget tortor. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Mauris orci odio, sodales et elementum laoreet, porta at lacus. Maecenas vestibulum lectus risus, pulvinar scelerisque dolor posuere viverra. Proin gravida tellus vitae accumsan dignissim. Nunc non sapien aliquet ex cursus ultricies ac quis diam. Sed luctus feugiat risus eu tincidunt. Duis auctor lacinia fringilla. Donec pretium cursus magna a feugiat. Duis tristique, leo vulputate semper iaculis, est ipsum dapibus lacus, et molestie nulla enim nec ex. Nunc non feugiat neque.
|
||||
|
||||
Fusce euismod egestas elit ut pretium. Nulla eros quam, auctor sit amet faucibus eu, scelerisque eu neque. Sed nisi felis, lobortis in sapien a, tempor efficitur nunc. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Pellentesque ut justo est. Sed maximus, tellus in bibendum posuere, quam augue finibus eros, sed gravida arcu diam quis lectus. Donec eu placerat ligula. Ut quis imperdiet lorem. Maecenas a mi ac augue semper sodales. Curabitur in justo velit. Praesent et felis quis enim porttitor sagittis.
|
||||
|
||||
Nulla sed sagittis felis, sit amet placerat tortor. Ut metus est, sollicitudin ac turpis quis, aliquet congue lorem. Fusce auctor erat non convallis aliquet. Nullam sodales rutrum tellus ac malesuada. Quisque sem diam, iaculis in ultricies sit amet, fermentum quis sem. Integer condimentum placerat purus non lacinia. Integer hendrerit ultricies tellus, at dignissim nibh. Suspendisse accumsan eget tortor nec cursus. Proin accumsan rhoncus leo, eget pretium est tristique ac.
|
||||
|
||||
Sed feugiat sed diam a porta. Nullam varius lacus at fermentum fringilla. Morbi pharetra scelerisque pharetra. Nulla placerat vitae ligula non efficitur. Suspendisse quam dui, rutrum eget nulla eu, semper eleifend ante. Aenean ut condimentum arcu. Suspendisse auctor metus non sem ornare, vel tincidunt odio vehicula. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos.
|
||||
|
||||
Donec vel metus ut libero sollicitudin posuere a nec nulla. Vivamus a scelerisque nisi. Aliquam eu sollicitudin tortor. Aliquam purus eros, blandit et quam et, pretium porttitor sem. Nunc iaculis arcu enim, et maximus sem malesuada in. Morbi nec nunc volutpat, semper diam sit amet, gravida elit. Vestibulum eu turpis vel lacus imperdiet congue. Donec rhoncus auctor sem.
|
||||
|
||||
Suspendisse eu lorem non dolor pretium finibus euismod quis dolor. Cras finibus egestas velit, commodo rutrum est placerat sit amet. Pellentesque vitae semper diam, sit amet auctor metus. Sed porttitor porttitor nunc, vel imperdiet neque volutpat quis. Sed hendrerit sapien et lacus imperdiet, nec hendrerit lorem sodales. Integer lobortis rutrum odio at ullamcorper. Aliquam tincidunt magna a tellus suscipit, at porta turpis dapibus. Ut ultricies auctor felis eu feugiat. Sed tempus sem et dictum fringilla. Nunc non pellentesque tortor. Suspendisse pulvinar, arcu ut imperdiet gravida, eros ex mattis mauris, vel ultricies est erat et dui. Praesent porttitor tortor et erat interdum efficitur. Phasellus et luctus lectus, et egestas ante. Praesent ex ipsum, rutrum id efficitur et, vulputate non tortor. Aenean maximus nunc ac purus sodales, et venenatis lacus laoreet.
|
||||
|
||||
Cras egestas ultrices dui, at tempor leo varius vitae. Donec porta, nisl nec ornare maximus, est arcu auctor mauris, varius elementum nisl arcu venenatis neque. Aenean metus quam, vestibulum eget justo non, hendrerit dapibus nunc. Vivamus diam ante, mattis sed nulla at, iaculis elementum magna. Sed massa diam, efficitur vel nunc sed, malesuada interdum tortor. Aliquam non neque aliquet ex imperdiet finibus eget ac neque. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae;
|
||||
|
||||
Pellentesque fringilla tortor metus, luctus commodo leo gravida et. Fusce nec turpis at lorem rutrum porta vel in justo. Curabitur mattis suscipit felis, id dapibus arcu ornare ut. Proin sapien felis, pulvinar ut tristique vitae, aliquet ac dui. Vestibulum a erat tellus. Vestibulum sagittis dolor eget augue egestas fringilla. Fusce et purus a nunc auctor dapibus vel sed dui. Fusce interdum, libero vel pellentesque rutrum, urna massa iaculis tellus, et aliquam lectus diam vel sem. Donec a nunc et dui semper gravida. Donec posuere, eros eu consequat efficitur, justo metus ullamcorper lectus, et molestie massa ipsum eu sapien. Mauris eu suscipit neque. Morbi convallis sit amet leo a scelerisque. Cras ultrices libero ac mattis accumsan. Nam gravida ligula id erat semper ornare. Duis consequat ut ipsum eu volutpat. Quisque egestas sollicitudin ullamcorper.
|
||||
|
||||
Cras pellentesque quam non neque porta fringilla. Integer elementum, augue mattis blandit consequat, enim ipsum finibus ex, quis finibus neque eros eu ex. Fusce at urna justo. Donec erat eros, maximus id mauris vel, rutrum rutrum sapien. Morbi sed rutrum ex. Suspendisse lacus velit, varius ut elementum vitae, finibus non enim. Suspendisse vehicula euismod ipsum, id consequat nulla sodales vel. Morbi eu sem id leo congue dapibus a nec velit. Sed nec neque quam. Etiam rhoncus id nulla id volutpat. Nulla facilisi. Donec non maximus enim. Aenean consequat, sapien sit amet malesuada rutrum, erat sem euismod sapien, et feugiat lectus mauris id velit.
|
||||
|
||||
Fusce sodales porttitor gravida. Proin placerat ante nec nibh tempor aliquam. Sed ut diam eu sem fringilla malesuada. Maecenas aliquam risus vel quam dictum, at iaculis nibh pretium. Mauris convallis quam vitae dolor varius suscipit. Etiam nec fermentum dui. Aliquam in magna tincidunt, consectetur quam eget, aliquam purus. Ut dictum aliquet finibus. Pellentesque vel lacinia felis. Nulla malesuada vestibulum varius. Sed quam diam, efficitur id felis in, volutpat bibendum erat. Praesent luctus vulputate urna at interdum. Aenean ac aliquam eros. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Morbi mollis, nisl vel consequat vehicula, massa tellus porta arcu, vel dictum sapien mauris sit amet nunc. Integer sed maximus neque, ac iaculis urna.
|
||||
|
||||
Integer non erat a leo euismod convallis quis eget magna. Morbi gravida ac urna sed ornare. Nunc vehicula mauris accumsan, ornare sem in, egestas mauris. Vestibulum vel vulputate felis. Nulla eu scelerisque diam. Suspendisse ac odio tempor nunc pellentesque hendrerit at a magna. Vivamus ultrices nunc ut orci fermentum pharetra. Nullam laoreet hendrerit ligula ut gravida. Proin scelerisque magna sit amet arcu malesuada, pharetra ultrices est molestie. Nullam pulvinar placerat dui, vitae hendrerit tortor luctus sed. Pellentesque elementum tellus eget arcu pulvinar varius.
|
||||
|
||||
Ut placerat, magna vitae tincidunt ultricies, est orci aliquet urna, at luctus augue erat vel ipsum. Fusce odio sem, venenatis vel consequat nec, bibendum sed dolor. Cras a sodales eros. Nullam eget dui congue, vehicula purus ut, condimentum dui. Maecenas libero ipsum, condimentum tincidunt nisi in, sodales lacinia ex. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus.
|
||||
|
||||
Duis gravida velit ac euismod suscipit. Proin sed ligula erat. Nullam eu ornare massa, non fringilla felis. Curabitur eu erat ex. Quisque sit amet dolor id arcu mattis scelerisque at at eros. In arcu nulla, fermentum non maximus eget, rhoncus in lacus. Nulla sit amet augue eu tortor vulputate congue. Vivamus laoreet condimentum tempus. Maecenas tempor, diam sed laoreet venenatis, mauris arcu lacinia enim, quis facilisis nunc turpis a ligula. Pellentesque quis placerat nisi, sit amet ullamcorper diam. Suspendisse a elementum elit, vel tristique dolor. Nam pretium ante tortor, vel tristique ipsum ultricies ut. Quisque non lectus imperdiet, placerat erat ac, pharetra tellus. In condimentum at magna a posuere. Aliquam et fringilla ipsum. Sed facilisis, nulla a finibus gravida, elit elit vulputate velit, dictum ornare est sapien a nisl.
|
||||
|
||||
Mauris eleifend vulputate felis sed mattis. Praesent id velit vitae ex porta pretium. Cras mollis malesuada justo, ut ornare quam placerat ac. Donec lobortis arcu tellus, luctus tempor mi malesuada quis. Maecenas condimentum libero vitae finibus malesuada. Vestibulum sollicitudin fringilla diam eget egestas. Sed vulputate urna nec ipsum maximus hendrerit. Maecenas blandit ex ut massa sodales, vitae tincidunt lorem ullamcorper. Phasellus vitae nisl ornare, cursus sem a, pulvinar arcu. Vestibulum faucibus risus nec tincidunt pellentesque. Pellentesque vel porttitor ex. Vivamus sollicitudin gravida lacus in suscipit. Aliquam urna neque, sodales quis quam ac, suscipit condimentum ante.
|
||||
|
||||
Morbi id arcu sit amet sapien ornare gravida eget quis sapien. In hac habitasse platea dictumst. In quis interdum ligula. Donec sed mi vulputate, scelerisque turpis vitae, interdum odio. Proin tristique condimentum arcu, et malesuada tellus convallis in. Fusce egestas maximus magna, sit amet convallis velit porttitor ut. Curabitur venenatis lacus ut blandit convallis. Phasellus scelerisque congue turpis eget vehicula. Nam venenatis mi sit amet rhoncus pretium. Nulla sed odio purus. Phasellus cursus id sapien ut feugiat. Duis et ipsum vel dui tempus porta. Curabitur non tortor consectetur, sollicitudin tellus mollis, sollicitudin lorem.
|
||||
|
||||
Ut quis ornare justo. Nunc aliquam, leo sit amet placerat placerat, dui nulla luctus dui, ac iaculis nisl orci id metus. Vestibulum nunc nunc, porta nec dictum id, feugiat et ante. Quisque lobortis, lacus tristique vestibulum rhoncus, massa nulla dignissim massa, at scelerisque massa nunc at velit. Donec eu ipsum nec dui luctus pulvinar et et turpis. Quisque eu neque erat. Donec varius egestas nunc, ut pretium libero tempor ac. Vestibulum pellentesque mi erat, et semper dolor semper vitae. Morbi enim dui, laoreet non venenatis sit amet, dignissim a orci. Sed id odio turpis. Phasellus non rutrum magna. Maecenas placerat arcu ultricies ultrices congue. Nulla quis neque ligula. Etiam in diam commodo, pharetra mauris ac, pretium nisi. Praesent sed nibh nec odio condimentum commodo vel vel lacus.
|
||||
|
||||
Pellentesque id libero vitae ex egestas pharetra placerat nec augue. Ut eget lobortis lorem, at vehicula sapien. Aliquam eu tincidunt ligula. Aenean et vestibulum dui, quis porttitor dui. Nullam quis dolor libero. Sed accumsan eros vitae nisi ornare congue. Aliquam nisi sapien, sollicitudin quis odio vel, pharetra maximus urna.
|
||||
|
||||
Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Morbi accumsan felis id urna malesuada, non vulputate velit maximus. Proin urna velit, viverra a metus vel, sollicitudin faucibus nulla. Vivamus ipsum lectus, pharetra sit amet varius vel, ullamcorper nec neque. Suspendisse potenti. Vivamus sit amet justo ac augue placerat hendrerit in eu felis. Integer luctus ex quam, in pellentesque eros interdum sed. Fusce finibus quis neque vitae efficitur. Pellentesque vulputate consectetur egestas. Integer a neque fermentum, tincidunt ligula id, gravida urna. Pellentesque ultrices, leo et suscipit accumsan, lorem nunc porta dui, non congue ligula leo ut urna. Duis vehicula risus in mi eleifend luctus. Duis convallis, mi faucibus pellentesque cursus, libero mauris varius sem, sit amet fermentum massa metus nec tellus. In tortor ligula, faucibus eu nibh id, lobortis viverra erat. Morbi non nisi suscipit, mollis enim at, convallis velit.
|
||||
|
||||
Pellentesque consequat imperdiet felis quis scelerisque. Duis aliquam mollis nibh quis tincidunt. Vivamus elit odio, blandit quis volutpat at, blandit nec tortor. Cras maximus ex at odio maximus, dapibus condimentum risus malesuada. Pellentesque viverra orci at ante commodo, quis posuere sapien efficitur. Nunc tristique imperdiet diam elementum lobortis. Fusce velit dui, ultrices id ante pharetra, fermentum egestas augue. Curabitur ante augue, vestibulum non magna quis, feugiat pulvinar diam. Suspendisse sagittis dui a tellus scelerisque, ut tincidunt neque accumsan. Cras pharetra metus vel eros tincidunt, vel tincidunt lacus egestas. Donec eget pellentesque sapien. Cras condimentum in justo pulvinar feugiat. Quisque malesuada ac odio eget rhoncus. Fusce posuere justo sed finibus ornare.
|
||||
|
||||
Duis a auctor tortor. Pellentesque lobortis auctor risus, ultrices varius mi cursus quis. Sed dui nulla, mattis sit amet justo mattis, condimentum commodo justo. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Pellentesque hendrerit diam vel lacinia volutpat. Sed et luctus nunc, vitae congue arcu. Aenean placerat tincidunt ipsum. Ut molestie orci eu dapibus viverra. Cras sodales ullamcorper augue, at aliquam ex. Sed et justo augue.
|
||||
|
||||
Nullam feugiat risus et turpis faucibus, vel tincidunt nulla consequat. Aliquam libero erat, pellentesque sit amet tellus in, tempor ornare nisl. Donec viverra eget magna non pharetra. Cras sollicitudin, justo ut porttitor venenatis, risus nulla auctor est, at commodo sem urna in mi. Vestibulum mattis sapien vel nibh pulvinar, vel dignissim lacus cursus. Maecenas vel dictum tortor, ac ultricies justo. Praesent quis venenatis nisl. Morbi a diam fringilla, auctor lectus sed, varius est. Praesent faucibus auctor dolor, a commodo nisi mattis id. Fusce porta molestie ultrices. Sed pulvinar, leo ac consectetur hendrerit, erat velit gravida enim, eu blandit ligula justo sit amet neque.
|
||||
|
||||
Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Aenean libero risus, porttitor in congue vel, consequat sed felis. Vivamus eget blandit ante. Vivamus vitae mattis massa. In ut dolor sit amet tellus sollicitudin mattis. Mauris iaculis nisl neque, in gravida lectus ultricies nec. Fusce vehicula vehicula lacinia. Fusce viverra sed nisl id rhoncus. Donec sed porta mi. Nam tempus purus non massa tincidunt iaculis. Morbi viverra massa ut gravida vestibulum.
|
||||
|
||||
Proin dapibus mi a libero sagittis, id vehicula nulla iaculis. Proin a enim in tortor tincidunt egestas. Integer finibus neque eu nibh pretium, et pellentesque urna finibus. Integer a sollicitudin mauris, at convallis erat. Nullam sit amet lectus sed turpis commodo efficitur. Nulla nec turpis dapibus, suscipit diam quis, vulputate urna. Aenean sed posuere justo.
|
||||
|
||||
Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Sed luctus purus nibh, id imperdiet risus pulvinar in. Quisque auctor sem lacinia turpis mollis, nec pretium ipsum suscipit. Aliquam sed metus sagittis, mollis nisi eget, hendrerit libero. Nulla sodales erat semper nisl condimentum, ultricies rhoncus lacus commodo. Ut suscipit libero augue, non vulputate dui tristique vel. Praesent convallis efficitur est, sed tincidunt mauris aliquet in. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos.
|
||||
|
||||
Etiam a vehicula purus. Curabitur lacus erat, ultrices et dictum id, posuere ut risus. Etiam at auctor leo. Pellentesque eleifend est a metus lacinia rutrum. Nullam justo ligula, tempor non facilisis vel, volutpat sed nulla. Integer non dolor consequat, dapibus lectus eu, luctus turpis. Aenean enim erat, sagittis vitae ornare bibendum, faucibus sit amet magna. Phasellus suscipit ultrices faucibus. Sed at risus molestie, viverra ipsum vel, bibendum lectus. Pellentesque molestie vitae risus non viverra. Phasellus eleifend massa id odio sagittis mattis. Nullam velit mauris, viverra quis fermentum sit amet, vestibulum ut ex. Suspendisse imperdiet, sapien sed sagittis pharetra, nisi nibh vulputate metus, quis mattis dolor nisi ut lorem. Praesent vestibulum nibh vulputate lacus pulvinar tempor. Vestibulum vulputate diam ligula, vitae efficitur enim dapibus non. Etiam at ornare enim.
|
||||
|
||||
Curabitur aliquet velit enim, euismod faucibus urna euismod sit amet. Vivamus viverra vulputate nulla, ut gravida neque rutrum in. Suspendisse potenti. Nulla vitae neque felis. Etiam eu erat ac nulla ornare volutpat. Quisque ut diam dui. Sed ut massa quis dolor volutpat eleifend. Duis posuere dolor sit amet varius auctor. Donec mollis malesuada erat, eu luctus libero viverra feugiat. Curabitur fermentum velit eu purus fringilla, consequat tincidunt diam rutrum. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc volutpat vel est in vehicula. Morbi euismod, tortor ut posuere ullamcorper, velit justo ultricies lorem, vitae tincidunt erat ante vitae sem. Praesent semper, quam eget condimentum finibus, metus leo imperdiet augue, nec fringilla sem nunc id sapien.
|
||||
|
||||
Aliquam vestibulum ante porta sem finibus, ut rhoncus elit sodales. Phasellus a lacus congue, sagittis nulla eget, cursus libero. Duis laoreet fringilla faucibus. Aenean gravida lorem sed fringilla facilisis. Pellentesque sodales urna lorem, non rutrum tortor vulputate eget. Duis a enim semper, iaculis sem ac, facilisis urna. Donec iaculis nulla sit amet dignissim volutpat. Mauris cursus dui id feugiat suscipit. Fusce tempor placerat nulla vitae vestibulum. Vivamus imperdiet blandit nulla, in aliquet justo viverra in. Cras malesuada molestie ligula sit amet volutpat. Praesent ornare orci sit amet rutrum eleifend. Ut placerat metus felis, id malesuada justo mollis eget. Etiam mi turpis, pulvinar in pulvinar in, tincidunt in neque.
|
||||
|
||||
In venenatis euismod neque, eu convallis diam semper ac. Mauris auctor mi non massa vestibulum viverra. Aenean non turpis sapien. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Praesent gravida feugiat interdum. Vivamus sit amet consequat ligula. Praesent dictum nunc ac sapien ullamcorper consectetur. Ut malesuada blandit neque.
|
||||
|
||||
Duis tincidunt mauris sit amet odio dictum accumsan. Suspendisse efficitur nibh magna, ac dapibus augue vulputate nec. Etiam lectus neque, sollicitudin quis sapien vitae, aliquet fermentum mi. Aenean interdum interdum rhoncus. Donec eu libero urna. Donec semper lacus eu nunc scelerisque, vitae viverra quam consectetur. Integer sagittis, nulla congue venenatis auctor, purus justo mollis metus, at rutrum magna arcu nec est. Maecenas felis lorem, consequat non cursus vitae, lobortis vitae nisi. Cras eget magna justo. Nullam sagittis tellus id luctus ultricies. Etiam a arcu efficitur, consectetur libero non, imperdiet turpis. Donec ac velit et nisl semper semper. Duis iaculis interdum nunc sed tempor.
|
||||
|
||||
Sed diam odio, sagittis non dignissim nec, accumsan ac diam. Fusce sit amet dui sit amet justo ultrices viverra. Sed vel massa suscipit nibh porttitor laoreet in id nunc. Nam quis libero vitae nunc blandit sollicitudin et a lorem. Duis urna arcu, accumsan sed dignissim sit amet, vulputate at ex. Mauris porttitor libero mauris, vel fringilla diam euismod quis. Sed varius placerat tellus vel efficitur. Phasellus pulvinar gravida magna. Nulla dignissim consectetur finibus.
|
||||
|
||||
Nunc quis aliquet nisi. Cras luctus bibendum eros ac dignissim. Aenean suscipit felis vitae elementum eleifend. Proin commodo nunc non diam dignissim, in tincidunt nulla ultrices. Vivamus faucibus quam scelerisque interdum finibus. Sed porttitor vehicula urna, in laoreet arcu condimentum non. Praesent ac lacus diam. Vivamus aliquam euismod risus, luctus dictum lectus sodales et. Proin quis velit ac massa tristique scelerisque.
|
||||
|
||||
Sed non dolor efficitur, tincidunt mi eget, sagittis tortor. Quisque at varius felis, at finibus sem. Vestibulum vel lectus tincidunt, pharetra diam sit amet, interdum nulla. Sed ut elit tortor. Nam tincidunt tempus aliquam. Vivamus rhoncus faucibus sapien eget facilisis. Aliquam erat volutpat. Phasellus placerat aliquam lacus, eget ultrices orci pretium hendrerit. Fusce vitae dolor sit amet ante condimentum placerat. Nunc varius risus id tellus mollis, euismod luctus sapien viverra. Donec sodales est vel massa suscipit, eu sollicitudin ante convallis. Curabitur eu condimentum velit. Sed pharetra euismod tincidunt.
|
||||
|
||||
Nam at libero eros. Quisque bibendum, ligula quis sagittis ullamcorper, eros leo consectetur ex, quis elementum dolor justo vitae mi. Maecenas et elementum erat, et auctor enim. Nunc at nibh fermentum, ullamcorper mi elementum, facilisis erat. Vestibulum vestibulum leo ut pellentesque placerat. Suspendisse imperdiet nisl vitae justo sodales pellentesque. Interdum et malesuada fames ac ante ipsum primis in faucibus. In faucibus pretium nunc, sed interdum lectus vestibulum quis. Vestibulum luctus viverra ex at efficitur. Etiam ac est lorem. Maecenas mollis, orci at rhoncus congue, nulla leo rutrum dui, et pellentesque orci ligula eget ipsum. Suspendisse fermentum nisi turpis, ut sollicitudin purus imperdiet non. Maecenas vitae quam ornare, porta sem quis, rhoncus neque. Donec mattis purus a erat tristique, ac mollis est convallis. Duis vitae ipsum viverra, condimentum ante vel, sagittis ex. Maecenas placerat odio libero, id interdum turpis fermentum at.
|
||||
|
||||
Praesent faucibus nulla eget vehicula accumsan. Nulla elementum ante a nibh venenatis hendrerit. Proin nec nunc mattis, imperdiet nisl rutrum, sollicitudin libero. Nullam bibendum dignissim faucibus. Sed eget tortor vitae sapien cursus faucibus nec et lectus. Duis pharetra non odio id consectetur. Suspendisse at est sem. Nunc mauris ligula, ultrices id ante non, venenatis mattis erat. Vivamus sit amet viverra nisl.
|
||||
|
||||
Sed cursus vel nisi in mattis. Nunc porttitor dictum leo ac euismod. Sed blandit ornare nunc id lobortis. Aenean convallis ligula at volutpat commodo. Vestibulum sit amet laoreet urna. Donec et pellentesque orci, ac egestas nulla. In accumsan venenatis porta. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Donec scelerisque, metus nec viverra pharetra, dolor libero dictum velit, id ullamcorper enim nisi eu nibh. Aliquam nec dapibus quam. Curabitur vulputate, libero sit amet tempor ullamcorper, libero purus congue quam, nec sollicitudin orci erat non massa. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas.
|
||||
|
||||
Aenean faucibus mollis placerat. Praesent lacinia venenatis turpis eu scelerisque. Nam tempus tortor a varius posuere. Cras ut viverra tortor. Cras facilisis mauris ut ante imperdiet, a malesuada justo luctus. Ut eu enim sit amet arcu porttitor pulvinar ac ac odio. Ut odio neque, molestie vitae ligula quis, dignissim viverra erat. In ullamcorper erat sed elementum varius. Sed in lacus maximus, euismod nisi vitae, tempus mi. Nunc tellus justo, auctor at luctus ac, feugiat sit amet dui.
|
||||
|
||||
Donec imperdiet purus lorem, sed venenatis dolor finibus non. Aenean lacus nunc, elementum nec arcu eget, faucibus elementum turpis. Aliquam lacinia massa ac quam efficitur, et tincidunt eros pretium. Fusce condimentum mi vel pharetra egestas. Quisque consectetur nibh vel leo dignissim sollicitudin. Duis ultrices felis ipsum, sed maximus arcu ornare vitae. Curabitur porttitor ligula in turpis facilisis, id venenatis augue ultricies. Phasellus vel dolor id tellus finibus sodales ut quis nisi. Integer id orci cursus erat tincidunt sagittis non in nunc. Pellentesque ligula lacus, vestibulum eu ante vel, facilisis viverra massa. Sed ut tincidunt metus, vel tristique est. Ut et cursus justo. Ut ac porttitor eros, at dictum felis. Phasellus ornare nisi sit amet risus varius, sed sollicitudin nulla ornare. Donec aliquam ipsum urna. Aliquam id bibendum magna, quis venenatis diam.
|
||||
|
||||
Duis tempor odio id iaculis egestas. Cras consequat neque ac posuere iaculis. Nulla tempus et nisi eu auctor. Vestibulum metus massa, dignissim ut metus eget, ullamcorper consectetur turpis. Integer vel est tellus. Ut ac vestibulum massa. Pellentesque nec venenatis erat. Nam vel pellentesque lectus.
|
||||
|
||||
Morbi a placerat est. Ut eleifend ante ut placerat porta. Donec sagittis semper leo, ut scelerisque nisi imperdiet feugiat. Mauris purus turpis, consequat ut fringilla ac, cursus eget augue. Fusce arcu dolor, sagittis et facilisis ut, scelerisque non lacus. Aliquam sit amet eleifend tellus. Mauris id est luctus, iaculis tortor eget, gravida justo. Suspendisse at tellus nisl. Nullam felis erat, vehicula eu porttitor bibendum, pulvinar et dui. Sed molestie lacus nec sagittis rutrum. Aliquam erat volutpat. Nullam ut aliquet eros. Sed feugiat, massa id pharetra auctor, leo turpis condimentum purus, sit amet volutpat sem nunc sed nisi.
|
||||
|
||||
Pellentesque feugiat ipsum at accumsan iaculis. Morbi et dui in lorem commodo hendrerit. Mauris tempor ex mollis mollis blandit. Cras eu turpis feugiat, suscipit velit quis, volutpat magna. Vestibulum varius ligula ut quam mollis, a volutpat nibh lobortis. Sed sodales euismod leo non suscipit. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Nullam ultricies sagittis justo, sit amet lacinia eros tincidunt tristique. Etiam faucibus turpis in lacus efficitur, vitae rutrum magna porttitor. Cras finibus eros vel ante semper, facilisis vestibulum erat accumsan. Ut mollis dui ut commodo varius. In et sem malesuada erat egestas pellentesque. Maecenas pulvinar sodales risus, at euismod ligula aliquet in. Quisque fringilla malesuada dui vel cursus. Curabitur eu ex vulputate, pretium elit non, sodales sapien. Integer egestas facilisis odio et pretium.
|
||||
|
||||
Integer eleifend, felis vitae faucibus tempus, tellus lectus placerat nunc, eu efficitur lacus mi vel nisl. Mauris commodo pretium feugiat. In aliquet nibh diam, ac egestas mauris consequat ut. Integer cursus, tortor pharetra pellentesque pulvinar, neque risus ultricies felis, et consequat felis eros at eros. Pellentesque fermentum velit ac sodales facilisis. Suspendisse vestibulum metus quis convallis lacinia. Donec in pharetra magna. Proin gravida dolor eget ligula lobortis sagittis.
|
||||
|
||||
Nullam consectetur ut massa id ultrices. Fusce consectetur at eros at mollis. Donec nec nibh fringilla, porttitor ipsum eget, aliquam neque. Quisque suscipit tortor in dui commodo, sed venenatis augue cursus. Etiam feugiat purus id justo elementum placerat. Sed interdum dictum nibh at sodales. Maecenas lobortis, metus ac sagittis lacinia, elit arcu varius felis, quis facilisis magna elit in leo. Proin condimentum orci sit amet dignissim imperdiet. Fusce sed iaculis felis. Maecenas sodales non magna vitae rutrum.
|
||||
|
||||
Nulla id ex massa. Sed vehicula sed quam non elementum. Aliquam luctus, enim vel molestie posuere, sem arcu laoreet justo, quis finibus nisl justo et magna. Vivamus malesuada elit in aliquam dignissim. Sed at tellus in orci vulputate ullamcorper. Integer magna sem, mattis id hendrerit non, tincidunt in est. Praesent posuere aliquet lobortis. Quisque euismod leo ut nisl pellentesque, et imperdiet dui dapibus.
|
||||
|
||||
Sed a erat nec risus pulvinar venenatis. Integer ultrices eros at aliquet efficitur. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Quisque ac maximus ante. Sed sodales, nisi et sagittis accumsan, diam odio consequat elit, et mollis dolor turpis tincidunt metus. Proin justo quam, tincidunt id convallis a, molestie hendrerit massa. Etiam pharetra turpis eu ultrices mollis.
|
||||
|
||||
Proin fermentum libero in purus cursus molestie. In varius magna eu ante maximus, eget rutrum felis iaculis. Maecenas hendrerit, diam eget vestibulum vehicula, est quam porta magna, a dictum urna mi ut libero. Vestibulum dictum quis lacus vitae eleifend. Integer sapien libero, pretium vitae euismod eget, semper eget ante. Vivamus mollis elementum odio vel hendrerit. Phasellus tristique, metus eget luctus tincidunt, ex enim faucibus ipsum, at dictum eros urna ac mi. Aenean imperdiet felis eu ultricies egestas. Vivamus fermentum convallis nisi, non sollicitudin felis posuere nec. Ut commodo sit amet felis semper dictum. Aenean accumsan, tellus id blandit aliquet, lorem nibh pellentesque mi, sit amet volutpat erat ligula ut est. Vivamus non posuere velit. Sed vel rutrum diam, non pulvinar nulla. Suspendisse quis gravida lectus, ac accumsan justo. Sed lobortis neque ante, a imperdiet nisl iaculis nec.
|
||||
@@ -138,10 +138,186 @@ qualifies only if every record it writes lands in `text`, `strings` or
|
||||
permission. Anything else and it is an ordinary content mod that happens to
|
||||
ship text.
|
||||
|
||||
### 4. `mod.card`
|
||||
### 4. `games` (and the legacy `gen2compat`)
|
||||
|
||||
Pokemon Gold is Gen 2, and it runs its own battle engine, overworld, script
|
||||
VM and save format. The mod API is shared across both generations (same hook
|
||||
names, same event names, same registry names) but Gold cannot serve all of it
|
||||
yet, so Gen 2 is opt-in. Say which games the mod is for:
|
||||
|
||||
```json
|
||||
"games": ["gen1", "gen2"]
|
||||
```
|
||||
|
||||
Each entry is a version id (`"red"`, `"blue"`, `"yellow"`, `"gold"`), a
|
||||
generation (`"gen1"`, `"gen2"`) or `"all"`;
|
||||
`src/mods/ModTargets.lua` resolves them off `GameVersion.ORDER` so nothing
|
||||
restates the game list. `python3 tools/modkit.py scaffold my_mod --games
|
||||
gen1,gen2` writes the key for you. The mod still installs to one directory,
|
||||
`mods/<id>/`, shared by every game -- targeting is declared, never filed.
|
||||
|
||||
Absent means Gen 1 only, which is what every mod written before the key existed
|
||||
was tested as. `"gen2compat": true` is the legacy spelling, still accepted and
|
||||
purely additive (it *adds* the Gen 2 games), so no manifest can lose a game it
|
||||
already ran on. On a Gold boot a mod claiming no Gen 2 game is not loaded at
|
||||
all: the manager lists it as `ENABLED (NOT THIS GAME)` and says why, because a
|
||||
mod that half-applies reads as a broken mod. Claim Gen 2 once you have actually
|
||||
run your mod on Gold.
|
||||
|
||||
Every token is enforced, per game: the loader gates on the same
|
||||
`ModTargets.supports` answer both mod surfaces draw, so `"games": ["blue"]`
|
||||
really does not load on Red and the skip line is the launcher's line, `For
|
||||
Blue, not Red`, and `"games": ["gold"]` alone does not load on Red either. A
|
||||
manifest with neither key still covers every Gen 1 game, so nothing written
|
||||
before the key existed changes behavior; list both generations or say `"all"`
|
||||
when you mean everywhere.
|
||||
|
||||
`docs/mod-api-gen2-compat.md` is the compatibility matrix: what works on Gold
|
||||
today (40 of the 46 registries, 40 event and 43 hook names shared with Gen 1,
|
||||
and 24 Gen 2-only ones), which registries have no Gen 2 home and drop their
|
||||
writes with a report, and which hooks and events are still to come.
|
||||
`docs/preparing-your-mod-for-gen2.md` is the step-by-step migration guide for a
|
||||
Gen 1 mod, and it is the one to start from.
|
||||
|
||||
Two consequences worth knowing before you claim Gen 2.
|
||||
|
||||
**Dependencies are contagious.** A mod whose hard dependency does not run here
|
||||
is left out too, with the dependency's own wording (`depends on X, which does
|
||||
not run here (For Blue, not Red)`). It is reported as a skip, not as a failure,
|
||||
and neither mod lands on the boot error list, but the mod does not run, so
|
||||
every hard dependency has to cover the same games.
|
||||
|
||||
**The player can override you.** The claim is yours, and a mod written before
|
||||
the key existed can never carry one, so the manager's detail pane offers
|
||||
`TRY HERE ANYWAY` for any mod that does not claim the game being played. It
|
||||
persists per game in `options.modsGen2[id][version]` and takes effect on the
|
||||
next boot; forcing a mod onto Red does not force it onto Gold. A forced mod
|
||||
loads normally and keeps a note saying its author never verified it here.
|
||||
|
||||
**Prefer the API on Gold, but the Gen 1 names still work.** Gen 2 is a
|
||||
parallel module tree behind `src/core/Game2.lua`. In new code take the live
|
||||
game from `mod.game` (or the `game.ready` payload, or any `ui.*` hook's first
|
||||
argument) and the world from `mod.world`; both resolve per generation, and
|
||||
neither needs `engine_internals`.
|
||||
|
||||
For the mods written before Gold existed, a require made from a mod's own file
|
||||
is answered on a Gold boot by an adapter presenting the Gen 1 API over Gen 2
|
||||
internals. Fifteen names are served -- `src.core.Game`,
|
||||
`src.world.OverworldController`, `src.world.Map`, `src.world.NPC`,
|
||||
`src.world.Collision`, `src.world.WorldAPI`, `src.world.PikachuFollower`,
|
||||
`src.world.FieldDefaults`, `src.pokemon.Boxes`, `src.script.ScriptRunner`,
|
||||
`src.ui.PartyMenu`, `src.ui.StartMenu`, `src.ui.OptionsMenu`, `src.ui.BoxMenu`
|
||||
and `src.battle.BattleState`. `src/mods/Gen2Compat.lua` is the full table and
|
||||
publishes what it covers through `Gen2Compat.coverage(name)`, whose members are
|
||||
`backed`, `warned` or `absent`. A name with no adapter (`src.script.Commands`,
|
||||
`src.ui.OptionRows`) is reported against the mod that required it, and a member
|
||||
an adapter cannot back is absent or logs once rather than answering wrongly.
|
||||
|
||||
Things no adapter can fix, all mod-side: a hardcoded version allow-list
|
||||
(`GameVersion.get() == "red" or ...`) excludes you from Gold by construction;
|
||||
Gold's builtin screen ids carry a `Gen2` prefix, so a string match on
|
||||
`"BoxMenu"` matches nothing there; a write to a field on a live Gen 2 menu
|
||||
instance is inert; and `map.warpAt` is a table on Gen 1 and a method on Gold,
|
||||
so indexing it raises. Each has a route that works on both generations, in
|
||||
`docs/preparing-your-mod-for-gen2.md`.
|
||||
|
||||
Check it statically, then load it headless:
|
||||
|
||||
```sh
|
||||
python3 tools/modkit.py gen2check mods/my_mod
|
||||
```
|
||||
|
||||
```lua
|
||||
local run = T.sdk.loadMod("mods/my_mod", { generation = 2 })
|
||||
T.eq(run.mod and run.mod.state, "loaded",
|
||||
"runs on gen 2: " .. tostring(run.mod and run.mod.skipReason))
|
||||
T.eq(#run.errors, 0, "and loads with no boot errors")
|
||||
```
|
||||
|
||||
Assert the state, not only the error count: a gate skip is deliberately not an
|
||||
error, so `#run.errors == 0` passes for a mod that never ran a line.
|
||||
|
||||
`gen2check` answers `will load`, `will load but degrade` or `will not work`,
|
||||
with a `MK4xx` finding per site and an `unresolved:` note, with a file and a
|
||||
line, for every reach a static scan could not follow. Neither substitutes for a
|
||||
real Gold boot.
|
||||
|
||||
### 5. What a mod's code can reach
|
||||
|
||||
Your code runs in a sandbox (`src/mods/Sandbox.lua`), not against the
|
||||
engine's globals. Every chunk you author gets it: `main.lua`, your
|
||||
`options_schema`, and anything you `load()` yourself.
|
||||
|
||||
The globals the sandbox took away are still *reachable*, as compat
|
||||
stand-ins (`src/mods/LegacyCompat.lua`) that answer with the new API
|
||||
underneath. A mod written before the sandbox keeps working; it logs one
|
||||
warning per call it should migrate, and the mod manager lists them. What
|
||||
each stand-in actually does:
|
||||
|
||||
| Pre-sandbox call | What it does now | Migrate to |
|
||||
| --- | --- | --- |
|
||||
| `io.open`, `io.lines`, `love.filesystem.read`/`lines`/`newFile` | reads your own shipped files, then your overlay, then `mod.storage` | `mod:read`, `mod.storage` |
|
||||
| `love.filesystem.write`/`append`, `io.open(…, "w")`, `os.remove`, `os.rename` | writes to a private per-mod overlay under `mod_compat/<your id>/` | `mod.storage` |
|
||||
| `love.filesystem.getDirectoryItems`/`getInfo` | your own directory plus your overlay | `mod:list`, `mod:info` |
|
||||
| `love.filesystem.getSaveDirectory` and friends | a virtual root; anything joined to it lands in your overlay | `mod.storage` |
|
||||
| `os.getenv` | `nil`, except home-like names, which answer with that same virtual root | nothing |
|
||||
| `love.filesystem.load`, `dofile`, `loadfile` | compiles the chunk into your sandbox | `require`, `mod:read` plus `load` |
|
||||
| `love.system` | `getOS`/`getPowerInfo`/`getProcessorCount` read through; clipboard and `openURL` do nothing | `mod.device:powerInfo()`, `mod.steps` |
|
||||
| `love.event` | passes through, except `quit`, which does nothing | `mod.events`, `mod.hooks` |
|
||||
| `love.mousemoved = fn` and the other callbacks | installs on the real `love` table, the way it always did | `mod.hooks`, `mod.events` |
|
||||
| `package` | an inert stub, so `package.path = …` does not crash | `require` |
|
||||
|
||||
What has no stand-in, because there is nothing honest to reroute it to:
|
||||
|
||||
| Still refused | Why |
|
||||
| --- | --- |
|
||||
| `love.thread` | a LÖVE thread is a fresh Lua state with the full standard library, which no environment-based sandbox in this state can reach. Use `mod.fetch` for background HTTP (`network`) or `mod.job` for background compute (`background`) — both run your code inside the sandbox instead of outside it |
|
||||
| `require("ffi")` | arbitrary C |
|
||||
| `debug`, `getfenv`, `setfenv` | each one undoes the sandbox from inside |
|
||||
| `io.popen`, `os.execute` | spawning a process |
|
||||
| `love.run`, `love.errorhandler` | the engine's own loop and its crash path |
|
||||
| replacing a `love` module table (`love.filesystem = {}`) | the engine reads those tables too |
|
||||
|
||||
The rest of `love` passes through unchanged, so graphics, audio, timers and
|
||||
input work as they always have.
|
||||
|
||||
Three consequences worth knowing before you write against it:
|
||||
|
||||
- **Your globals are yours.** `_G` inside a mod is that mod's own table. Two
|
||||
mods no longer share a namespace, and neither can reach the engine's. To
|
||||
publish something to another mod, put it on `mod.exports` and let them
|
||||
`mod.find("your_id").exports` — the channel that was always the intended
|
||||
one. The same goes for the standard library: `string`, `table` and `math`
|
||||
are per-mod copies, so patching one is a local decision.
|
||||
- **Paths cannot climb.** `mod:read`, `mod:list`, `mod:info`, `mod.assets:path`
|
||||
and `mod.assets:image` join to your own directory, and `..`, absolute paths
|
||||
and drive letters are refused. So are `entry` and `options_schema` in your
|
||||
manifest. `mod:list("assets")` is the sandboxed `getDirectoryItems` for a
|
||||
folder you shipped; `mod:info` tells file from directory so a walk can
|
||||
recurse.
|
||||
- **Ship source, not bytecode.** A precompiled entry file is refused.
|
||||
|
||||
`permissions` in the manifest is still a disclosure the manager shows the
|
||||
player. `network` gates `require("socket")` and friends plus `mod.fetch`
|
||||
(non-blocking HTTP), and `background` gates `mod.job` (compute on a worker
|
||||
thread). Those two are the sanctioned ways to work off the main thread now
|
||||
that `love.thread` is refused. There is no
|
||||
permission that grants raw filesystem access, because no mod needs one:
|
||||
everything a mod legitimately writes is already scoped by
|
||||
`mod.storage` or the asset-transform derived root.
|
||||
|
||||
If your mod used one of the rerouted globals, the fix is almost always
|
||||
`mod.storage`. The overlay is a compatibility floor, not a second storage
|
||||
system: it is not scoped per playthrough, it does not migrate, and it is
|
||||
the first thing that will be dropped once the mods on the index have
|
||||
moved off it. Open an issue if you have a case `mod.storage` does not
|
||||
cover.
|
||||
|
||||
### 6. `mod.card`
|
||||
|
||||
The manifest is the *engine's* contract: identity, load order, dependencies,
|
||||
permissions, profile. The card is the *human-facing* one: who made this,
|
||||
permissions, profile (see [Manifest specification](docs/modding.md#manifest-specification-manifestjson)).
|
||||
The card is the *human-facing* one: who made this,
|
||||
what it changes, what it does not do yet. It is never read by the loader's
|
||||
merge — only by tooling and the manager's detail pane — so an absent or
|
||||
malformed card can never break a load.
|
||||
@@ -159,7 +335,7 @@ Two fields deserve their own note:
|
||||
distributed mod never carries ROM-derived bytes, not even in its preview
|
||||
images.
|
||||
|
||||
### 5. Tags
|
||||
### 7. Tags
|
||||
|
||||
Lowercase kebab strings, open vocabulary. The showcase generator
|
||||
lowercases and de-dupes. A recommended starting set: `beginner`,
|
||||
@@ -217,12 +393,15 @@ registry or a new schema field lands with its catalog entry in the same PR
|
||||
and the generator runs clean:
|
||||
|
||||
```sh
|
||||
luajit tools/gen_registry_docs.lua # in-repo default
|
||||
luajit tools/gen_registry_docs.lua ../project.wiki # the wiki checkout
|
||||
luajit tools/gen_registry_docs.lua # docs/modding/reference/registries.md
|
||||
luajit tools/gen_registry_docs.lua ../project.wiki # Reference-Registries.md in a wiki checkout
|
||||
```
|
||||
|
||||
The prose reference lives in the GitHub wiki; the generated pages are
|
||||
written into a checkout of it, so they cannot drift from the engine.
|
||||
With no argument it writes inside the repo, which is the copy `python3
|
||||
tools/modkit.py docs` regenerates and `--out` copies from. Pass a directory
|
||||
(or set `POKEPORT_DOCS_DIR`) to write the wiki's flat page name into a wiki
|
||||
checkout instead. The prose reference lives in the GitHub wiki; both copies
|
||||
come off `src/mods/Schemas.lua`, so neither can drift from the engine.
|
||||
|
||||
### 5. Deprecation etiquette
|
||||
|
||||
|
||||
@@ -4,6 +4,10 @@ A native LÖVE2D recreation of Poke Red, Blue and Yellow. The engine and map
|
||||
behavior are hand-written Lua; game data and graphics are decoded from a ROM
|
||||
supplied by the player.
|
||||
|
||||
And before you say, "that's not a recomp", you're wrong. Recomp is an acronym. ***Reverse Engineering Causes Obsessive Mental Problems***
|
||||
|
||||
[Click Here for the AI Use Disclosure!](AIDisclosure.md)
|
||||
|
||||
> [!CAUTION]
|
||||
> **We are NOT affiliated with the website `gen1recomp[.]com`** That website is not run by this project, was not authorized by us, and we have no idea who operates it. It is impersonating this project; do not download anything from it, and treat anything it hosts or claims as untrustworthy. Even if the site currently links back to this repository, the people behind it can change its content at any time, so nothing on it should ever be trusted. This GitHub repository and the Discord linked below are the only official sources for this project.
|
||||
|
||||
@@ -53,13 +57,14 @@ supplied by the player.
|
||||
|
||||
|
||||
This project does not include a ROM, emulate the Game Boy, transpile assembly,
|
||||
or download a disassembly. A canonical US Poke Red, Blue, or Yellow ROM is the
|
||||
only game content input.
|
||||
or download a disassembly. A canonical US Poke Red, Blue, Yellow, or Gold ROM
|
||||
is the only game content input.
|
||||
|
||||
The ROM is verified, used during import, and then released from memory. It is
|
||||
not copied into the cache. Later launches load the private generated cache and
|
||||
do not ask for the ROM again. Red, Blue, and Yellow can all be imported and
|
||||
played side by side.
|
||||
do not ask for the ROM again. Red, Blue, Yellow, and Gold can all be imported
|
||||
side by side. Gold is Gen 2 Phase 1 (import + launcher; see
|
||||
`docs/gold-phase1.md`): the Gen 2 engine is still under construction.
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -67,12 +72,13 @@ Open the desktop app. On first boot, choose your legally obtained `.gb` /
|
||||
`.gbc` file or drop it onto the window. Import takes a few seconds and the
|
||||
game starts automatically.
|
||||
|
||||
Only the canonical 1 MiB US Red, Blue, and Yellow ROMs are accepted. The
|
||||
importer verifies SHA-1 before creating any game data:
|
||||
Only the canonical US Red, Blue, Yellow (1 MiB), and Gold (2 MiB) ROMs are
|
||||
accepted. The importer verifies SHA-1 before creating any game data:
|
||||
|
||||
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
|
||||
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
|
||||
- Yellow: `cc7d03262ebfaf2f06772c1a480c7d9d5f4a38e1`
|
||||
- Gold: `d8b8a3600a465308c9953dfa04f0081c05bdcb94`
|
||||
|
||||
The packaged app contains neither a ROM nor pre-extracted game data. Music,
|
||||
sound effects, and cries are synthesized while the game runs from compact
|
||||
@@ -240,7 +246,7 @@ in [docs/linux-arm64-build.md](docs/linux-arm64-build.md).
|
||||
|
||||
## iOS
|
||||
|
||||
Every release ships `gen1recomp-*-ios.ipa`. Sideload it with AltStore
|
||||
Every release ships `gen1recomp++-*-ios.ipa`. Sideload it with AltStore
|
||||
(Windows or Mac) — see [docs/ios-sideload.md](docs/ios-sideload.md). To
|
||||
build and install from source on a Mac instead, see
|
||||
[docs/ios-install.md](docs/ios-install.md).
|
||||
@@ -348,7 +354,8 @@ request with real detail is one that can actually get built.
|
||||
- [Save editor](https://github.com/bryanthaboi/gen1recomp/wiki/Guide-Save-Editor)
|
||||
— edit party, boxes, items, events, and Pokédex flags outside the game.
|
||||
- `docs/architecture.md` — runtime details;
|
||||
`docs/behavior-porting-notes.md` — formula provenance.
|
||||
`docs/behavior-porting-notes.md` — formula provenance;
|
||||
`docs/link-security.md` — what link play defends against, and what it doesn't.
|
||||
|
||||
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 243 KiB |
|
After Width: | Height: | Size: 217 KiB |
|
After Width: | Height: | Size: 236 KiB |
|
After Width: | Height: | Size: 242 KiB |
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 251 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 96 KiB |
@@ -0,0 +1,10 @@
|
||||
# gb_anim -- bundled touch skin
|
||||
|
||||
Bezel art and overlay layout from libretro's `common-overlays`
|
||||
(`gamepads/gb_anim_portrait`), licensed CC-BY-4.0:
|
||||
https://github.com/libretro/common-overlays
|
||||
|
||||
`overlay.cfg` is the upstream `gb_big.cfg`, unmodified. It ships as the
|
||||
reference skin for the RetroArch-overlay loader in
|
||||
`src/core/TouchSkin.lua`: a full-device bezel, per-button press art, a
|
||||
screen viewport, and page switching between the DMG and Color shells.
|
||||
|
After Width: | Height: | Size: 973 B |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 649 B |
|
After Width: | Height: | Size: 645 B |
|
After Width: | Height: | Size: 636 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,89 @@
|
||||
overlays = 2
|
||||
|
||||
overlay0_name = "GameBoy"
|
||||
overlay0_overlay = img/gb_back.png
|
||||
overlay0_full_screen = true
|
||||
overlay0_normalized = true
|
||||
overlay0_range_mod = 1.0
|
||||
overlay0_alpha_mod = 0.001
|
||||
overlay0_viewport = "0.0,0.0,1.0,0.505"
|
||||
overlay0_viewport_fill = true
|
||||
|
||||
overlay1_name = "GameBoyColor"
|
||||
overlay1_overlay = img/gbc_back.png
|
||||
overlay1_full_screen = true
|
||||
overlay1_normalized = true
|
||||
overlay1_range_mod = 1.0
|
||||
overlay1_alpha_mod = 0.001
|
||||
overlay1_viewport = "0.0,0.0,1.0,0.505"
|
||||
overlay1_viewport_fill = true
|
||||
|
||||
# GameBoy
|
||||
overlay0_descs = 18
|
||||
|
||||
overlay0_desc0 = "left,0.12778,0.73417,radial,0.09630,0.04635"
|
||||
overlay0_desc0_overlay = img/gb_left.png
|
||||
overlay0_desc1 = "right,0.35370,0.73417,radial,0.09630,0.04635"
|
||||
overlay0_desc1_overlay = img/gb_right.png
|
||||
overlay0_desc2 = "up,0.24074,0.67063,radial,0.08241,0.05417"
|
||||
overlay0_desc2_overlay = img/gb_up.png
|
||||
overlay0_desc3 = "down,0.24074,0.79771,radial,0.08241,0.05417"
|
||||
overlay0_desc3_overlay = img/gb_down.png
|
||||
overlay0_desc4 = "left|up,0.09259,0.65188,rect,0.06481,0.03646"
|
||||
overlay0_desc5 = "right|up,0.38704,0.65188,rect,0.06481,0.03646"
|
||||
overlay0_desc6 = "left|down,0.09259,0.81750,rect,0.06481,0.03646"
|
||||
overlay0_desc7 = "right|down,0.38704,0.81750,rect,0.06481,0.03646"
|
||||
overlay0_desc8 = "a,0.87407,0.72417,radial,0.08889,0.05000"
|
||||
overlay0_desc8_overlay = img/gb_a_b.png
|
||||
overlay0_desc9 = "b,0.68148,0.76584,radial,0.08889,0.05000"
|
||||
overlay0_desc9_overlay = img/gb_a_b.png
|
||||
overlay0_desc10 = "a|b,0.77037,0.73417,radial,0.02963,0.01667"
|
||||
overlay0_desc11 = "a|b,0.78518,0.75584,radial,0.02963,0.01667"
|
||||
overlay0_desc12 = "start,0.66666,0.93000,radial,0.07037,0.03958"
|
||||
overlay0_desc12_overlay = img/gb_start_select.png
|
||||
overlay0_desc13 = "select,0.33333,0.93000,radial,0.07037,0.03958"
|
||||
overlay0_desc13_overlay = img/gb_start_select.png
|
||||
overlay0_desc14 = "menu_toggle,0.05000,0.52800,radial,0.041296,0.02323"
|
||||
overlay0_desc14_overlay = img/menu.png
|
||||
overlay0_desc15 = "overlay_next,0.95000,0.52800,radial,0.041296,0.02323"
|
||||
overlay0_desc15_overlay = img/rotate.png
|
||||
overlay0_desc15_next_target = "GameBoyColor"
|
||||
overlay0_desc16 = "rewind,0.05000,0.97500,radial,0.041296,0.02323"
|
||||
overlay0_desc16_overlay =
|
||||
overlay0_desc17 = "hold_fast_forward,0.95000,0.97500,radial,0.041296,0.02323"
|
||||
overlay0_desc17_overlay =
|
||||
|
||||
# GameBoyColor
|
||||
overlay1_descs = 18
|
||||
|
||||
overlay1_desc0 = "left,0.14078,0.73417,radial,0.08530,0.04635"
|
||||
overlay1_desc0_overlay = img/gbc_left.png
|
||||
overlay1_desc1 = "right,0.34270,0.73417,radial,0.08530,0.04635"
|
||||
overlay1_desc1_overlay = img/gbc_right.png
|
||||
overlay1_desc2 = "up,0.24074,0.67863,radial,0.08241,0.04617"
|
||||
overlay1_desc2_overlay = img/gbc_up.png
|
||||
overlay1_desc3 = "down,0.24074,0.78971,radial,0.08241,0.04617"
|
||||
overlay1_desc3_overlay = img/gbc_down.png
|
||||
overlay1_desc4 = "left|up,0.09259,0.65188,rect,0.06481,0.03646"
|
||||
overlay1_desc5 = "right|up,0.38704,0.65188,rect,0.06481,0.03646"
|
||||
overlay1_desc6 = "left|down,0.09259,0.81750,rect,0.06481,0.03646"
|
||||
overlay1_desc7 = "right|down,0.38704,0.81750,rect,0.06481,0.03646"
|
||||
overlay1_desc8 = "a,0.87407,0.72417,radial,0.08889,0.05000"
|
||||
overlay1_desc8_overlay = img/gbc_a.png
|
||||
overlay1_desc9 = "b,0.68148,0.76584,radial,0.08889,0.05000"
|
||||
overlay1_desc9_overlay = img/gbc_b.png
|
||||
overlay1_desc10 = "a|b,0.77037,0.73417,radial,0.02963,0.01667"
|
||||
overlay1_desc11 = "a|b,0.78518,0.75584,radial,0.02963,0.01667"
|
||||
overlay1_desc12 = "start,0.66666,0.93000,radial,0.07037,0.03958"
|
||||
overlay1_desc12_overlay = img/gbc_start_select.png
|
||||
overlay1_desc13 = "select,0.33333,0.93000,radial,0.07037,0.03958"
|
||||
overlay1_desc13_overlay = img/gbc_start_select.png
|
||||
overlay1_desc14 = "menu_toggle,0.05000,0.52800,radial,0.041296,0.02323"
|
||||
overlay1_desc14_overlay = img/menu.png
|
||||
overlay1_desc15 = "overlay_next,0.95000,0.52800,radial,0.041296,0.02323"
|
||||
overlay1_desc15_overlay = img/rotate.png
|
||||
overlay1_desc15_next_target = "GameBoy"
|
||||
overlay1_desc16 = "rewind,0.05000,0.97500,radial,0.041296,0.02323"
|
||||
overlay1_desc16_overlay =
|
||||
overlay1_desc17 = "hold_fast_forward,0.95000,0.97500,radial,0.041296,0.02323"
|
||||
overlay1_desc17_overlay =
|
||||
@@ -0,0 +1,10 @@
|
||||
# tv_crt -- bundled desktop bezel
|
||||
|
||||
CRT television border from libretro's `common-overlays`
|
||||
(`borders/tv-integer.cfg` + `borders/img/tv-integer.png`), licensed
|
||||
CC-BY-4.0: https://github.com/libretro/common-overlays
|
||||
|
||||
`overlay.cfg` is the upstream file, unmodified. It is the reference
|
||||
DESKTOP skin: 1920x1080, `descs = 0` (pure decoration, no touch buttons),
|
||||
and a `viewport` naming the transparent screen hole, so the Game Boy
|
||||
picture is fitted into the TV's tube instead of the whole window.
|
||||
|
After Width: | Height: | Size: 1.9 MiB |
@@ -0,0 +1,6 @@
|
||||
overlays = 1
|
||||
overlay0_overlay = img/tv-integer.png
|
||||
overlay0_full_screen = true
|
||||
overlay0_descs = 0
|
||||
overlay0_viewport = "0.2335,0.0855,0.5335,0.830"
|
||||
overlay0_viewport_fill = true
|
||||
@@ -0,0 +1,366 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build a PortMaster aarch64 port of gen1recomp for Linux ARM SBC handhelds.
|
||||
# The package uses PortMaster control hooks and a self-contained LÖVE runtime,
|
||||
# while keeping paths relative to the launcher for broad CFW compatibility.
|
||||
#
|
||||
# The launcher uses SHDIR-relative paths and bundles the LÖVE 11.5 aarch64
|
||||
# runtime so the device does not need a separate runtime download on first launch.
|
||||
#
|
||||
# Usage:
|
||||
# ./build-linux-arm-sbc.sh [--version X.Y.Z]
|
||||
# GEN1RECOMP_SOURCE_DIR="$PWD" ./build-linux-arm-sbc.sh --version X.Y.Z
|
||||
# ./build-linux-arm-sbc.sh --source /path/to/gen1recomp --version X.Y.Z
|
||||
#
|
||||
# Output:
|
||||
# dist/linux-arm-sbc/gen1recomp-sbc-portmaster.zip
|
||||
#
|
||||
# Install on device:
|
||||
# 1. Install PortMaster for the handheld firmware.
|
||||
# 2. Unzip into the device's PortMaster ports folder so you have:
|
||||
# Roms/Ports (PORTS)/gen1recomp-sbc.sh
|
||||
# Roms/Ports (PORTS)/gen1recomp-sbc/...
|
||||
# 3. Copy a legal US Red or Blue .gb into Roms/Ports (PORTS)/gen1recomp-sbc/lovegame/
|
||||
# 4. Launch "gen1recomp-sbc" from the Ports list; press Choose ROM (scans that
|
||||
# folder when zenity is missing).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||
HERE="$ROOT/.bazinga"
|
||||
CACHE="$HERE/cache/linux-arm-sbc"
|
||||
WORK="$HERE/work/linux-arm-sbc"
|
||||
DIST="$ROOT/dist/linux-arm-sbc"
|
||||
|
||||
APP_NAME="gen1recomp-sbc"
|
||||
# Artifact suffix identifies this as the generic PortMaster SBC package.
|
||||
# Release uploads stage it as gen1recomp-<ver>-sbc-portmaster.zip.
|
||||
ARTIFACT_SUFFIX="portmaster"
|
||||
PORT_DIR_NAME="gen1recomp-sbc"
|
||||
LAUNCHER_NAME="gen1recomp-sbc.sh"
|
||||
LOVE_VERSION="11.5"
|
||||
# By default the pack is reproducible from the latest published GitHub release,
|
||||
# not whatever happens to be in the caller's checkout. Development builds can
|
||||
# point this at a local checkout with GEN1RECOMP_SOURCE_DIR=/path/to/repo.
|
||||
SOURCE_DIR_OVERRIDE="${GEN1RECOMP_SOURCE_DIR:-}"
|
||||
SOURCE_TAG_OVERRIDE="${GEN1RECOMP_RELEASE_TAG:-}"
|
||||
VERSION="${GEN1RECOMP_VERSION:-}"
|
||||
|
||||
# Official PortMaster LÖVE 11.5 aarch64 runtime (small love stub + liblove).
|
||||
PM_RUNTIME_BASE="https://raw.githubusercontent.com/PortsMaster/PortMaster-GUI/main/PortMaster/runtimes/love_${LOVE_VERSION}"
|
||||
RELEASES_LATEST_URL="https://github.com/bryanthaboi/gen1recomp/releases/latest"
|
||||
RELEASE_TARBALL_BASE="https://github.com/bryanthaboi/gen1recomp/archive/refs/tags"
|
||||
|
||||
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
|
||||
fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--version) [ $# -ge 2 ] || fail "--version needs X.Y.Z"; VERSION="$2"; shift ;;
|
||||
--source) [ $# -ge 2 ] || fail "--source needs a directory"; SOURCE_DIR_OVERRIDE="$2"; shift ;;
|
||||
--release-tag) [ $# -ge 2 ] || fail "--release-tag needs a tag"; SOURCE_TAG_OVERRIDE="$2"; shift ;;
|
||||
-h|--help)
|
||||
sed -n '2,24p' "$0"
|
||||
exit 0
|
||||
;;
|
||||
*) fail "unknown argument: $1" ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
command -v curl >/dev/null || fail "curl is required"
|
||||
command -v zip >/dev/null || fail "zip is required"
|
||||
command -v unzip >/dev/null || fail "unzip is required"
|
||||
command -v tar >/dev/null || fail "tar is required"
|
||||
|
||||
mkdir -p "$CACHE" "$WORK" "$DIST"
|
||||
|
||||
download() {
|
||||
local url="$1" dest="$2"
|
||||
if [ -f "$dest" ] && [ -s "$dest" ]; then
|
||||
return 0
|
||||
fi
|
||||
say "downloading $(basename "$dest")"
|
||||
curl -fL --progress-bar "$url" -o "$dest.tmp" \
|
||||
|| fail "download failed: $url"
|
||||
mv "$dest.tmp" "$dest"
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------- source + game tree
|
||||
# Release builds use the latest published source archive. A local checkout is
|
||||
# an explicit override for development and for CI's just-built release source.
|
||||
if [ -n "$SOURCE_DIR_OVERRIDE" ]; then
|
||||
SOURCE_DIR_OVERRIDE="$(cd "$SOURCE_DIR_OVERRIDE" 2>/dev/null && pwd)" \
|
||||
|| fail "source directory does not exist: $SOURCE_DIR_OVERRIDE"
|
||||
SOURCE_DIR="$SOURCE_DIR_OVERRIDE"
|
||||
SOURCE_TAG="${SOURCE_TAG_OVERRIDE:-local}"
|
||||
if [ "$SOURCE_TAG" != "local" ]; then
|
||||
printf '%s' "$SOURCE_TAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$' \
|
||||
|| fail "release tag must look like vX.Y.Z: $SOURCE_TAG"
|
||||
fi
|
||||
if [ -z "$VERSION" ]; then
|
||||
VERSION="$(git -C "$SOURCE_DIR" rev-parse --short HEAD 2>/dev/null || echo dev)"
|
||||
fi
|
||||
else
|
||||
if [ -z "$SOURCE_TAG_OVERRIDE" ]; then
|
||||
latest_location="$(curl -fsSI "$RELEASES_LATEST_URL" \
|
||||
| awk 'tolower($1) == "location:" { print $2 }' | tail -1 | tr -d '\r')" \
|
||||
|| fail "could not resolve latest published release"
|
||||
SOURCE_TAG_OVERRIDE="${latest_location##*/}"
|
||||
fi
|
||||
printf '%s' "$SOURCE_TAG_OVERRIDE" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$' \
|
||||
|| fail "release tag must look like vX.Y.Z: $SOURCE_TAG_OVERRIDE"
|
||||
SOURCE_TAG="$SOURCE_TAG_OVERRIDE"
|
||||
SOURCE_ARCHIVE="$CACHE/gen1recomp-${SOURCE_TAG}.tar.gz"
|
||||
download "$RELEASE_TARBALL_BASE/$SOURCE_TAG.tar.gz" "$SOURCE_ARCHIVE"
|
||||
SOURCE_EXTRACT="$WORK/source-$SOURCE_TAG"
|
||||
rm -rf "$SOURCE_EXTRACT"
|
||||
mkdir -p "$SOURCE_EXTRACT"
|
||||
tar -xzf "$SOURCE_ARCHIVE" -C "$SOURCE_EXTRACT"
|
||||
SOURCE_DIR="$(find "$SOURCE_EXTRACT" -mindepth 1 -maxdepth 1 -type d -print -quit)"
|
||||
[ -n "$SOURCE_DIR" ] || fail "release archive had no source directory"
|
||||
if [ -z "$VERSION" ]; then VERSION="${SOURCE_TAG#v}"; fi
|
||||
fi
|
||||
|
||||
say "staging lovegame/ from $SOURCE_TAG"
|
||||
GAME_SRC="$WORK/lovegame"
|
||||
rm -rf "$GAME_SRC"
|
||||
mkdir -p "$GAME_SRC"
|
||||
|
||||
# Same payload as scripts/build.sh's game.love — never ship ROM-derived cache.
|
||||
# tools/save-editor is part of that payload: the launcher's Edit button on a
|
||||
# save row opens it in-process (main.lua).
|
||||
(cd "$SOURCE_DIR" && zip -q -9 -r "$WORK/game-payload.zip" \
|
||||
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" \
|
||||
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
|
||||
fail "payload unexpectedly contains generated ROM data"
|
||||
fi
|
||||
unzip -q "$WORK/game-payload.zip" -d "$GAME_SRC"
|
||||
rm -f "$WORK/game-payload.zip"
|
||||
|
||||
# Stamp release version into the staged tree only (never the working tree).
|
||||
if printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
say "stamping engine version $VERSION"
|
||||
sed -E "s/(engine[[:space:]]*=[[:space:]]*\")[^\"]*(\")/\1$VERSION\2/" \
|
||||
"$SOURCE_DIR/src/core/Version.lua" > "$GAME_SRC/src/core/Version.lua"
|
||||
version_re="$(printf '%s' "$VERSION" | sed 's/\./\\./g')"
|
||||
grep -Eq "engine[[:space:]]*=[[:space:]]*\"$version_re\"" \
|
||||
"$GAME_SRC/src/core/Version.lua" \
|
||||
|| fail "version stamp failed"
|
||||
else
|
||||
say "version '$VERSION' is not X.Y.Z — shipping default engine (no stamp)"
|
||||
fi
|
||||
|
||||
# Portable marker: saves + ROM cache live next to the game on the SD card.
|
||||
: > "$GAME_SRC/portable.txt"
|
||||
|
||||
# --------------------------------------------------------------- love runtime
|
||||
say "fetching LÖVE $LOVE_VERSION aarch64 runtime"
|
||||
LOVE_BIN="$CACHE/love.aarch64"
|
||||
LOVE_LIB="$CACHE/liblove-11.5.so"
|
||||
LUAJIT_LIB="$CACHE/libluajit-5.1.so.2"
|
||||
MODPLUG_LIB="$CACHE/libmodplug.so.1"
|
||||
OGG_LIB="$CACHE/libogg.so.0"
|
||||
|
||||
download "$PM_RUNTIME_BASE/love.aarch64" "$LOVE_BIN"
|
||||
download "$PM_RUNTIME_BASE/libs.aarch64/liblove-11.5.so" "$LOVE_LIB"
|
||||
download "$PM_RUNTIME_BASE/libs.aarch64/libluajit-5.1.so.2" "$LUAJIT_LIB"
|
||||
download "$PM_RUNTIME_BASE/libs.aarch64/libmodplug.so.1" "$MODPLUG_LIB"
|
||||
download "$PM_RUNTIME_BASE/libs.aarch64/libogg.so.0" "$OGG_LIB"
|
||||
|
||||
# Sanity: love stub must be an aarch64 ELF.
|
||||
file "$LOVE_BIN" | grep -qi 'aarch64\|ARM aarch64' \
|
||||
|| fail "love.aarch64 does not look like an aarch64 ELF (got: $(file "$LOVE_BIN"))"
|
||||
|
||||
# --------------------------------------------------------------- port tree
|
||||
say "assembling port package"
|
||||
PORT_ROOT="$WORK/port"
|
||||
rm -rf "$PORT_ROOT"
|
||||
mkdir -p "$PORT_ROOT/$PORT_DIR_NAME/bin" \
|
||||
"$PORT_ROOT/$PORT_DIR_NAME/libs.aarch64" \
|
||||
"$PORT_ROOT/$PORT_DIR_NAME/licenses" \
|
||||
"$PORT_ROOT/$PORT_DIR_NAME/conf"
|
||||
|
||||
cp -R "$GAME_SRC" "$PORT_ROOT/$PORT_DIR_NAME/lovegame"
|
||||
cp "$LOVE_BIN" "$PORT_ROOT/$PORT_DIR_NAME/bin/love.aarch64"
|
||||
chmod +x "$PORT_ROOT/$PORT_DIR_NAME/bin/love.aarch64"
|
||||
cp "$LOVE_LIB" "$LUAJIT_LIB" "$MODPLUG_LIB" "$OGG_LIB" \
|
||||
"$PORT_ROOT/$PORT_DIR_NAME/libs.aarch64/"
|
||||
|
||||
# Drop a short license pointer for the bundled LÖVE bits.
|
||||
cat > "$PORT_ROOT/$PORT_DIR_NAME/licenses/LICENSE.love2d.txt" <<'EOF'
|
||||
This port bundles the LÖVE 11.5 aarch64 runtime from PortMaster
|
||||
(https://github.com/PortsMaster/PortMaster-GUI). LÖVE is zlib-licensed;
|
||||
see https://love2d.org/ for full terms.
|
||||
EOF
|
||||
|
||||
# --------------------------------------------------------------- launcher
|
||||
# Resolve the game directory from the launcher so this works with both
|
||||
# PortMaster-managed ports directories.
|
||||
cat > "$PORT_ROOT/$LAUNCHER_NAME" <<'EOF'
|
||||
#!/bin/bash
|
||||
# gen1recomp-sbc — Linux ARM SBC / PortMaster launcher
|
||||
# Uses SHDIR-relative paths so firmware-specific mount points do not matter.
|
||||
|
||||
export HOME="${HOME:-/root}"
|
||||
XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}"
|
||||
SHDIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
if [ -d "/mnt/SDCARD/Apps/PortMaster/PortMaster/" ]; then
|
||||
controlfolder="/mnt/SDCARD/Apps/PortMaster/PortMaster"
|
||||
elif [ -d "/mnt/SDCARD/Roms/ports/PortMaster" ]; then
|
||||
controlfolder="/mnt/SDCARD/Roms/ports/PortMaster"
|
||||
elif [ -d "/mnt/SDCARD/Data/PortMaster/" ]; then
|
||||
controlfolder="/mnt/SDCARD/Data/PortMaster"
|
||||
elif [ -d "$SHDIR/PortMaster" ]; then
|
||||
controlfolder="$SHDIR/PortMaster"
|
||||
elif [ -d "/opt/system/Tools/PortMaster/" ]; then
|
||||
controlfolder="/opt/system/Tools/PortMaster"
|
||||
elif [ -d "/opt/tools/PortMaster/" ]; then
|
||||
controlfolder="/opt/tools/PortMaster"
|
||||
elif [ -d "$XDG_DATA_HOME/PortMaster/" ]; then
|
||||
controlfolder="$XDG_DATA_HOME/PortMaster"
|
||||
elif [ -d "/roms/ports/PortMaster" ]; then
|
||||
controlfolder="/roms/ports/PortMaster"
|
||||
else
|
||||
controlfolder="/mnt/SDCARD/Roms/PORTS/PortMaster"
|
||||
fi
|
||||
|
||||
if [ ! -f "$controlfolder/control.txt" ]; then
|
||||
echo "PortMaster control.txt not found under $controlfolder" >&2
|
||||
exit 1
|
||||
fi
|
||||
# shellcheck disable=SC1090
|
||||
source "$controlfolder/control.txt"
|
||||
get_controls
|
||||
if [ -n "${CFW_NAME:-}" ] && [ -f "${controlfolder}/mod_${CFW_NAME}.txt" ]; then
|
||||
# shellcheck disable=SC1090
|
||||
source "${controlfolder}/mod_${CFW_NAME}.txt"
|
||||
fi
|
||||
|
||||
GAMEDIR="$SHDIR/gen1recomp-sbc"
|
||||
CONFDIR="$GAMEDIR/conf"
|
||||
mkdir -p "$CONFDIR"
|
||||
|
||||
cd "$GAMEDIR" || exit 1
|
||||
> "$GAMEDIR/log.txt" && exec > >(tee "$GAMEDIR/log.txt") 2>&1
|
||||
|
||||
export XDG_DATA_HOME="$CONFDIR"
|
||||
export XDG_CONFIG_HOME="$CONFDIR"
|
||||
export LD_LIBRARY_PATH="$GAMEDIR/libs.aarch64:${LD_LIBRARY_PATH:-}"
|
||||
export SDL_GAMECONTROLLERCONFIG="${sdl_controllerconfig:-}"
|
||||
# GLES is the common path on ARM SBC handhelds; firmware may override it.
|
||||
export LOVE_GRAPHICS_USE_OPENGLES="${LOVE_GRAPHICS_USE_OPENGLES:-1}"
|
||||
|
||||
$ESUDO chmod a+x ./bin/love.aarch64 2>/dev/null || chmod a+x ./bin/love.aarch64
|
||||
$ESUDO chmod 666 /dev/uinput 2>/dev/null || true
|
||||
|
||||
if [ -n "${GPTOKEYB:-}" ]; then
|
||||
$GPTOKEYB "love.aarch64" &
|
||||
fi
|
||||
if type pm_platform_helper >/dev/null 2>&1; then
|
||||
pm_platform_helper "$GAMEDIR/bin/love.aarch64"
|
||||
fi
|
||||
|
||||
./bin/love.aarch64 "$GAMEDIR/lovegame"
|
||||
|
||||
if type pm_finish >/dev/null 2>&1; then
|
||||
pm_finish
|
||||
else
|
||||
if [ -n "${ESUDO:-}" ]; then
|
||||
$ESUDO kill -9 $(pidof gptokeyb) 2>/dev/null || true
|
||||
else
|
||||
kill -9 $(pidof gptokeyb) 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
EOF
|
||||
chmod +x "$PORT_ROOT/$LAUNCHER_NAME"
|
||||
|
||||
# --------------------------------------------------------------- metadata
|
||||
cat > "$PORT_ROOT/port.json" <<EOF
|
||||
{
|
||||
"version": 2,
|
||||
"name": "gen1recomp-sbc.zip",
|
||||
"items": [
|
||||
"$LAUNCHER_NAME",
|
||||
"$PORT_DIR_NAME"
|
||||
],
|
||||
"items_opt": null,
|
||||
"attr": {
|
||||
"title": "gen1recomp-sbc",
|
||||
"desc": "Native LÖVE2D recreation of Pokemon Red and Blue. Supply your own legal US Red or Blue ROM.",
|
||||
"source": "https://github.com/bryanthaboi/gen1recomp/releases/tag/$SOURCE_TAG",
|
||||
"inst": "Requires a 64-bit Linux ARM handheld with PortMaster. Copy a canonical US Red or Blue .gb into gen1recomp-sbc/lovegame/, then launch and press Choose ROM.",
|
||||
"genres": ["adventure", "rpg"],
|
||||
"porter": ["gen1recomp-sbc"],
|
||||
"image": {},
|
||||
"rtr": true,
|
||||
"runtime": null,
|
||||
"reqs": [],
|
||||
"arch": ["aarch64"]
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
cat > "$PORT_ROOT/gameinfo.xml" <<EOF
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<gameList>
|
||||
<game>
|
||||
<path>./$LAUNCHER_NAME</path>
|
||||
<name>gen1recomp-sbc</name>
|
||||
<desc>Native LÖVE2D recreation of Pokemon Red and Blue. Requires your own legal US Red or Blue ROM.</desc>
|
||||
<releasedate>20250101T000000</releasedate>
|
||||
<developer>the bois club</developer>
|
||||
<publisher>the bois club</publisher>
|
||||
<genre>RPG</genre>
|
||||
</game>
|
||||
</gameList>
|
||||
EOF
|
||||
|
||||
cat > "$PORT_ROOT/README.md" <<'EOF'
|
||||
## gen1recomp-sbc (Linux ARM SBC / PortMaster)
|
||||
|
||||
Native LÖVE 11.5 aarch64 PortMaster port of gen1recomp for compatible Linux ARM SBC handhelds, including H700-class devices. This pack was built from source release **__SOURCE_TAG__**.
|
||||
|
||||
### Install
|
||||
|
||||
1. Install PortMaster for your handheld firmware.
|
||||
2. Unzip so `gen1recomp-sbc.sh` and the `gen1recomp-sbc/` folder are siblings in the device's PortMaster ports directory.
|
||||
3. Copy a legal US Pokémon Red or Blue `.gb` into `gen1recomp-sbc/lovegame/`.
|
||||
4. Refresh the launcher and launch **gen1recomp-sbc** from Ports.
|
||||
|
||||
### Controls
|
||||
|
||||
| Input | Action |
|
||||
|--|--|
|
||||
| D-pad | Move cursor |
|
||||
| A | Click |
|
||||
| L1 / R1 | Switch tabs |
|
||||
| Start / Select | Play or choose ROM |
|
||||
|
||||
Controls use the normal PortMaster / SDL pad map. Device-specific power/suspend behavior is supplied by the firmware and PortMaster runtime.
|
||||
|
||||
### First run
|
||||
|
||||
Put the `.gb` in `lovegame/`, then press **Choose ROM**. After import, the ROM-derived cache and saves stay beside the game (`portable.txt`).
|
||||
|
||||
### Thanks
|
||||
|
||||
LÖVE runtime binaries from [PortMaster](https://portmaster.games/). PortMaster device support and runtime integration are maintained by the PortMaster team.
|
||||
EOF
|
||||
sed -i.bak "s/__SOURCE_TAG__/$SOURCE_TAG/g" "$PORT_ROOT/README.md"
|
||||
rm -f "$PORT_ROOT/README.md.bak"
|
||||
|
||||
# --------------------------------------------------------------- zip
|
||||
ZIP_OUT="$DIST/$APP_NAME-$ARTIFACT_SUFFIX.zip"
|
||||
rm -f "$ZIP_OUT"
|
||||
say "packing $ZIP_OUT"
|
||||
(cd "$PORT_ROOT" && zip -q -9 -r "$ZIP_OUT" \
|
||||
"$LAUNCHER_NAME" "$PORT_DIR_NAME" port.json gameinfo.xml README.md)
|
||||
|
||||
say "done."
|
||||
say "artifact: $ZIP_OUT ($(du -h "$ZIP_OUT" | cut -f1))"
|
||||
say "copy into the device PortMaster ports folder, then drop your .gb into gen1recomp-sbc/lovegame/"
|
||||
@@ -91,11 +91,14 @@ mkdir -p "$GAME_SRC"
|
||||
(cd "$ROOT" && zip -q -9 -r "$WORK/game-payload.zip" \
|
||||
main.lua conf.lua src libs data assets tools/save-editor \
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
||||
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
||||
if unzip -Z1 "$WORK/game-payload.zip" \
|
||||
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
|
||||
fail "payload unexpectedly contains generated ROM data"
|
||||
fi
|
||||
payload_list="$(unzip -Z1 "$WORK/game-payload.zip")"
|
||||
printf '%s\n' "$payload_list" \
|
||||
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/' \
|
||||
&& fail "payload unexpectedly contains generated ROM data"
|
||||
printf '%s\n' "$payload_list" | grep -qxF "tools/rom_manifest_gold.json" \
|
||||
|| fail "payload is missing tools/rom_manifest_gold.json"
|
||||
unzip -q "$WORK/game-payload.zip" -d "$GAME_SRC"
|
||||
rm -f "$WORK/game-payload.zip"
|
||||
|
||||
|
||||
@@ -6,18 +6,30 @@ function love.conf(t)
|
||||
|
||||
local editor = os.getenv("POKEPORT_EDITOR") == "1"
|
||||
local developer = os.getenv("POKEPORT_DEV") == "1"
|
||||
local companion = nil
|
||||
if arg then
|
||||
for _, a in ipairs(arg) do
|
||||
if a == "--editor" then editor = true end
|
||||
if a == "--developer" then developer = true end
|
||||
local port, token = a:match("^%-%-display%-companion=(%d+),([%w]+)$")
|
||||
if port then companion = { port = tonumber(port), token = token } end
|
||||
end
|
||||
end
|
||||
-- main.lua runs in the same Lua state right after conf.lua; stash the
|
||||
-- decision in a global so it doesn't need to reparse `arg`.
|
||||
_G.POKEPORT_EDITOR_MODE = editor
|
||||
_G.POKEPORT_DEV_MODE = developer
|
||||
_G.POKEPORT_DISPLAY_COMPANION = companion
|
||||
|
||||
if editor then
|
||||
if companion then
|
||||
t.identity = "pokemon-love2d-companion"
|
||||
t.window.title = "gen1recomp Secondary Display"
|
||||
t.window.width = 640
|
||||
t.window.height = 576
|
||||
t.window.minwidth = 160
|
||||
t.window.minheight = 144
|
||||
t.window.resizable = true
|
||||
elseif editor then
|
||||
-- Same identity as the game, deliberately: the editor edits the game's
|
||||
-- saves and reads the game's ROM cache, both of which live under this
|
||||
-- folder. A private editor identity would point love.filesystem at an
|
||||
@@ -51,7 +63,8 @@ function love.conf(t)
|
||||
end
|
||||
t.version = love._os == "iOS" and "12.0" or "11.5"
|
||||
t.window.vsync = 1
|
||||
t.modules.joystick = true
|
||||
t.modules.audio = not companion
|
||||
t.modules.joystick = not companion
|
||||
t.modules.physics = false
|
||||
|
||||
-- love.system is not loaded during love.conf; love._os is set by the
|
||||
|
||||
@@ -92,7 +92,7 @@ return {
|
||||
{ "set_flag", "EVENT_GOT_EEVEE" }, -- 7
|
||||
{ "hide_object", "CELADON_MANSION_ROOF_HOUSE",
|
||||
"CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" }, -- 8
|
||||
{ "play_sound", "Get_Item1" }, -- 9 (GotMonText jingle)
|
||||
{ "text_sound", "Get_Item1" }, -- 9 (GotMonText jingle)
|
||||
{ "show_text", "_GotMonText", { RAM = "EEVEE" } }, -- 10
|
||||
{ "jump", 13 }, -- 11
|
||||
{ "show_text", "_BoxIsFullText" }, -- 12
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
local function coinGiver(opts)
|
||||
return function(game, ow, npc, done)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Sound = require("src.core.Sound")
|
||||
local t = game.data.text
|
||||
local function push(label, fallback, onDone)
|
||||
game.stack:push(TextBox.new(game, t[label] or fallback, onDone or done))
|
||||
local function push(label, fallback, onDone, popts)
|
||||
game.stack:push(TextBox.new(game, t[label] or fallback, onDone or done,
|
||||
popts))
|
||||
end
|
||||
if game.save.flags[opts.event] then
|
||||
push(opts.alreadyGotLabel, opts.alreadyGotFallback)
|
||||
@@ -30,9 +30,10 @@ local function coinGiver(opts)
|
||||
end
|
||||
game.save.coins = math.min(9999, (game.save.coins or 0) + opts.amount)
|
||||
game.save.flags[opts.event] = true
|
||||
Sound.play(game.data, "Get_Item1")
|
||||
-- the ReceivedNCoinsText strings carry sound_get_item_1
|
||||
push(opts.receivedLabel,
|
||||
("{PLAYER} received\n%d coins!"):format(opts.amount))
|
||||
("{PLAYER} received\n%d coins!"):format(opts.amount), nil,
|
||||
TextBox.soundOpts(game, "Get_Item1"))
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -22,8 +22,8 @@ return {
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Commands = require("src.script.Commands")
|
||||
local t = game.data.text
|
||||
local function say(label, cb)
|
||||
game.stack:push(TextBox.new(game, t[label] or label, cb))
|
||||
local function say(label, cb, sopts)
|
||||
game.stack:push(TextBox.new(game, t[label] or label, cb, sopts))
|
||||
end
|
||||
|
||||
if game.save.flags.EVENT_GOT_OLD_AMBER then
|
||||
@@ -39,8 +39,9 @@ return {
|
||||
game.save.flags.EVENT_GOT_OLD_AMBER = true
|
||||
Commands.hide_object({ save = game.save, overworld = ow, game = game },
|
||||
"MUSEUM_1F", "MUSEUM1F_OLD_AMBER")
|
||||
require("src.core.Sound").play(game.data, "Get_Item1")
|
||||
say("_Museum1FScientist2ReceivedOldAmberText", done)
|
||||
-- .ReceivedOldAmberText carries sound_get_item_1
|
||||
say("_Museum1FScientist2ReceivedOldAmberText", done,
|
||||
TextBox.soundOpts(game, "Get_Item1"))
|
||||
end)
|
||||
end,
|
||||
|
||||
|
||||
@@ -1,13 +1,39 @@
|
||||
-- Hand-ported flavor text for OaksLab (registry id OAKS_LAB).
|
||||
-- Source: pokered/scripts/OaksLab.asm. These five text_asm bodies are
|
||||
-- all simple "PrintText; jp TextScriptEnd" -- no flag branches, no
|
||||
-- YES/NO menu -- so a one-row talk script showing the real extracted
|
||||
-- text is a faithful port. (The rest of OaksLab.asm's TEXT_OAKSLAB_*
|
||||
-- constants -- OAK1, the three starter poke balls, RIVAL -- are already
|
||||
-- ported with full branching logic in data/scripts/oaks_lab.lua.)
|
||||
-- Hand-ported OAKS_LAB flavor: the simple talk texts (scripts/OaksLab.asm;
|
||||
-- OAK1, the starter balls and RIVAL live in data/scripts/oaks_lab.lua).
|
||||
|
||||
local TextBox = require("src.render.TextBox")
|
||||
|
||||
return {
|
||||
OAKS_LAB = {
|
||||
-- data/events/hidden_events.asm:147
|
||||
onInteract = function(game, ow, fx, fy)
|
||||
local t = game.data.text or {}
|
||||
-- engine/events/hidden_events/oaks_lab_posters.asm:1
|
||||
if fy == 0 and fx == 4 then
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._PushStartText or "Push START to\nopen the MENU!"))
|
||||
return true
|
||||
end
|
||||
if fy == 0 and fx == 5 then
|
||||
local owned = 0
|
||||
for _ in pairs(game.save.pokedex.owned or {}) do owned = owned + 1 end
|
||||
game.stack:push(TextBox.new(game,
|
||||
owned >= 2
|
||||
and (t._StrengthsAndWeaknessesText
|
||||
or "All POKéMON types\nhave strong and\vweak points\vagainst others.")
|
||||
or (t._SaveOptionText
|
||||
or "The SAVE option is\non the MENU\vscreen.")))
|
||||
return true
|
||||
end
|
||||
-- engine/events/hidden_events/oaks_lab_email.asm:1
|
||||
if fy == 1 and (fx == 0 or fx == 1) then
|
||||
if ow.player.facing ~= "up" then return false end
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._OakLabEmailText or "There's an e-mail\nmessage here!"))
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end,
|
||||
talk = {
|
||||
-- OaksLabGirlText (scripts/OaksLab.asm)
|
||||
TEXT_OAKSLAB_GIRL = {
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
-- guide and SUPER_NERD2 garden nerd.
|
||||
--
|
||||
-- The YOUNGSTER's gym escort (talk + east-exit onStep) lives in
|
||||
-- story5.lua so the lockstep RLE walk is not overwritten by this
|
||||
-- flavor merge. SUPER_NERD1's museum escort is not ported; only the
|
||||
-- YES/NO-branched flavor text is here.
|
||||
-- story5.lua; SUPER_NERD1's museum escort (scripts/PewterCity.asm:47-113)
|
||||
-- is below.
|
||||
|
||||
local M = {}
|
||||
|
||||
@@ -25,11 +24,138 @@ local function ask(game, s, cb)
|
||||
game.stack:push(TextBox.new(game, s, nil, { choice = cb }))
|
||||
end
|
||||
|
||||
-- RLEList_PewterMuseumGuy (engine/overworld/auto_movement.asm:199-204)
|
||||
local museumGuySteps = {
|
||||
"up", "up", "up", "up", "up", "up",
|
||||
"left", "left", "left", "left", "left", "left", "left", "left",
|
||||
"left", "left", "left", "left", "left",
|
||||
"up", "up", "up",
|
||||
"left",
|
||||
}
|
||||
|
||||
-- RLEList_PewterMuseumPlayer (engine/overworld/auto_movement.asm:192-197)
|
||||
local museumPlayerRle = {
|
||||
"NO",
|
||||
"up", "up", "up",
|
||||
"left", "left", "left", "left", "left", "left", "left", "left",
|
||||
"left", "left", "left", "left", "left",
|
||||
"up", "up", "up", "up", "up", "up",
|
||||
}
|
||||
|
||||
-- PewterMuseumGuyCoords (engine/events/pewter_guys.asm:58-75)
|
||||
local museumPreambles = {
|
||||
["27,18"] = { "up", "up" },
|
||||
["27,16"] = { "right", "left" },
|
||||
["26,17"] = { "up", "right" },
|
||||
["28,17"] = { "up", "left" },
|
||||
}
|
||||
|
||||
-- PewterGuys (engine/events/pewter_guys.asm:1-49), same transform as
|
||||
-- pewterEscort.playerPlan in story5.lua
|
||||
local function museumPlan(x, y)
|
||||
local pre = museumPreambles[x .. "," .. y]
|
||||
if not pre then return nil end
|
||||
local buf = {}
|
||||
for i, d in ipairs(museumPlayerRle) do buf[i] = d end
|
||||
buf[#buf] = pre[1]
|
||||
for i = 2, #pre do buf[#buf + 1] = pre[i] end
|
||||
local path = {}
|
||||
for i = #buf, 1, -1 do path[#path + 1] = buf[i] end
|
||||
local head = 0
|
||||
while path[head + 1] == "NO" do head = head + 1 end
|
||||
local tail = #path
|
||||
while tail > head and path[tail] == "NO" do tail = tail - 1 end
|
||||
local steps = {}
|
||||
for i = head + 1, tail do steps[#steps + 1] = path[i] end
|
||||
return { steps = steps, guyHeadStart = math.floor(head / 8) }
|
||||
end
|
||||
|
||||
-- PewterCitySuperNerd1ShowsPlayerMuseumScript (scripts/PewterCity.asm:47-113)
|
||||
local function museumEscortWalk(game, ow)
|
||||
if ow.runner:isRunning() or #ow.scriptMoves > 0 then return false end
|
||||
local plan = museumPlan(ow.player.cellX, ow.player.cellY)
|
||||
if not plan then return false end
|
||||
local Music = require("src.core.Music")
|
||||
local t = text(game)
|
||||
local guy = ow:npcByIndex(3) -- PEWTERCITY_SUPER_NERD1
|
||||
local head = plan.guyHeadStart
|
||||
|
||||
-- SetSpritePosition2 + ShowObject back on his spawn (27,17), the same
|
||||
-- snap walkHome does in story5.lua (scripts/PewterCity.asm:102-113)
|
||||
local function walkOut()
|
||||
if not guy then return end
|
||||
local i = 0
|
||||
local function tick()
|
||||
i = i + 1
|
||||
if i > 4 then
|
||||
guy.cellX, guy.cellY = 27, 17
|
||||
guy.px, guy.py = 27 * 16, 17 * 16
|
||||
guy.moving = false
|
||||
guy.targetX, guy.targetY = nil, nil
|
||||
guy.facing = "down"
|
||||
return
|
||||
end
|
||||
ow:scriptMove(guy, "down", 1, tick)
|
||||
end
|
||||
tick()
|
||||
end
|
||||
|
||||
-- SetSpritePosition1 pins him beside the museum door (map (17,12) minus
|
||||
-- the +4 border offset = (13,8)), then MovementData_PewterMuseumGuyExit
|
||||
local function afterWalk()
|
||||
if guy then
|
||||
guy.stepFrames = nil
|
||||
guy.cellX, guy.cellY = 13, 8
|
||||
guy.px, guy.py = 13 * 16, 8 * 16
|
||||
guy.moving = false
|
||||
guy.targetX, guy.targetY = nil, nil
|
||||
guy.facing = "up"
|
||||
end
|
||||
Music.playMap(game.data, "PEWTER_CITY")
|
||||
push(game, t._PewterCitySuperNerd1ItsRightHereText
|
||||
or "It's right here!", walkOut)
|
||||
end
|
||||
|
||||
local function lockstep()
|
||||
local i = 0
|
||||
local function tick()
|
||||
i = i + 1
|
||||
local ps = plan.steps[i]
|
||||
if not ps then
|
||||
afterWalk()
|
||||
return
|
||||
end
|
||||
local gs = museumGuySteps[head + i]
|
||||
if guy and gs then ow:scriptMove(guy, gs, 1) end
|
||||
ow:scriptMove(ow.player, ps, 1, tick)
|
||||
end
|
||||
tick()
|
||||
end
|
||||
|
||||
-- engine/overworld/movement.asm:737 (DoScriptedNPCMovement)
|
||||
if guy then
|
||||
guy.stepFrames = ow.player.stepFramesCur or ow.player.stepFrames
|
||||
end
|
||||
Music.play(game.data, "Music_MuseumGuy")
|
||||
if guy and head > 0 then
|
||||
local h = 0
|
||||
local function headTick()
|
||||
h = h + 1
|
||||
if h > head then lockstep(); return end
|
||||
ow:scriptMove(guy, museumGuySteps[h], 1, headTick)
|
||||
end
|
||||
headTick()
|
||||
else
|
||||
lockstep()
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
M.PEWTER_CITY = {
|
||||
museumEscort = { plan = museumPlan, guySteps = museumGuySteps },
|
||||
talk = {
|
||||
-- PewterCitySuperNerd1Text (scripts/PewterCity.asm): asks if you
|
||||
-- checked out the museum; YES -> fossils comment, NO -> "you have
|
||||
-- to go" (which in pokered also kicks off the escort script).
|
||||
-- PewterCitySuperNerd1Text (scripts/PewterCity.asm:209-237): YES ->
|
||||
-- fossils comment, NO -> "you have to go" and the museum escort
|
||||
TEXT_PEWTERCITY_SUPER_NERD1 = function(game, ow, npc, done)
|
||||
local t = text(game)
|
||||
ask(game, t._PewterCitySuperNerd1DidYouCheckOutMuseumText
|
||||
@@ -39,7 +165,10 @@ M.PEWTER_CITY = {
|
||||
or "Weren't those\nfossils from MT.\nMOON amazing?", done)
|
||||
else
|
||||
push(game, t._PewterCitySuperNerd1YouHaveToGoText
|
||||
or "Really?\nYou absolutely\nhave to go!", done)
|
||||
or "Really?\nYou absolutely\nhave to go!", function()
|
||||
museumEscortWalk(game, ow)
|
||||
if done then done() end
|
||||
end)
|
||||
end
|
||||
end)
|
||||
end,
|
||||
|
||||
@@ -22,10 +22,10 @@ local function starterBall(askText, species, choseFlag, ownBall,
|
||||
rivalBallX, rivalBall)
|
||||
return {
|
||||
{ "check_flag", "EVENT_GOT_STARTER" }, -- 1
|
||||
{ "jump_if_true", 22 }, -- 2
|
||||
{ "jump_if_true", 23 }, -- 2
|
||||
-- no picking until Oak has walked you in (OaksLabScript gating)
|
||||
{ "check_flag", "EVENT_FOLLOWED_OAK_INTO_LAB" }, -- 3
|
||||
{ "jump_if_false", 25 }, -- 4
|
||||
{ "jump_if_false", 26 }, -- 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;
|
||||
@@ -34,44 +34,38 @@ local function starterBall(askText, species, choseFlag, ownBall,
|
||||
{ species = species, forceOwned = true } }, -- 5
|
||||
{ "ask", askText }, -- 6
|
||||
{ "jump_if_false", "end" }, -- 7
|
||||
-- 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.
|
||||
-- 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 }, -- 13
|
||||
-- scripts/OaksLab.asm:919
|
||||
{ "show_text", "_OaksLabMonEnergeticText" }, -- 8
|
||||
-- OaksLab.asm: ReceivedMon (sound_get_key_item) then AddPartyMon; the
|
||||
-- jingle fires once the box has typed and holds it (#668)
|
||||
{ "text_sound", "Get_Key_Item" }, -- 9
|
||||
{ "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 10
|
||||
{ "give_pokemon", species, 5 }, -- 11
|
||||
{ "set_flag", "EVENT_GOT_STARTER" }, -- 12
|
||||
{ "set_flag", choseFlag }, -- 13
|
||||
-- POKé BALLs come later, at OaksLabOak1Text's .give_poke_balls beat
|
||||
-- once the Route 22 rival is beaten (see TEXT_OAKSLAB_OAK1 below)
|
||||
{ "hide_object", "OAKS_LAB", ownBall }, -- 14
|
||||
-- the rival walks to the countering ball (around the furniture)
|
||||
{ "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)
|
||||
{ "move_npc_to", 1, rivalBallX, 4 }, -- 15
|
||||
{ "face_object", 1, "up" }, -- 16
|
||||
{ "show_text", "_OaksLabRivalIllTakeThisOneText" }, -- 17
|
||||
{ "hide_object", "OAKS_LAB", rivalBall }, -- 18
|
||||
{ "text_sound", "Get_Key_Item" }, -- 19 (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" } }, -- 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" }, -- 22
|
||||
{ "show_text", "That's PROF.OAK's\nlast Pokémon!" }, -- 23
|
||||
or "BULBASAUR" } }, -- 20
|
||||
{ "jump", "end" }, -- 21
|
||||
{ "jump", "end" }, -- 22 (spacer)
|
||||
-- leftover ball: Oak reads the last-mon line (scripts/OaksLab.asm
|
||||
-- OaksLabSelectedPokeBallScript -> OaksLabLastMonScript, #601)
|
||||
{ "face_object", 5, "down" }, -- 23
|
||||
{ "show_text", "That's PROF.OAK's\nlast Pokémon!" }, -- 24
|
||||
-- OaksLabLastMonScript ends at TextScriptEnd; the port used to fall
|
||||
-- through into the pre-pick line below (#601 remnant, reported on #600)
|
||||
{ "jump", "end" }, -- 24
|
||||
{ "show_text", "_OaksLabThoseArePokeBallsText" }, -- 25
|
||||
{ "jump", "end" }, -- 25
|
||||
{ "show_text", "_OaksLabThoseArePokeBallsText" }, -- 26
|
||||
}
|
||||
end
|
||||
|
||||
@@ -106,8 +100,8 @@ return {
|
||||
{ "check_item", "OAKS_PARCEL" },
|
||||
{ "jump_if_false", "raise_young" },
|
||||
-- OaksLabOak1Text.got_parcel → RivalArrives + OakGivesPokedex
|
||||
{ "text_sound", "Get_Key_Item" },
|
||||
{ "show_text", "_OaksLabOak1DeliverParcelText" },
|
||||
{ "play_sound", "Get_Key_Item" },
|
||||
{ "show_text", "_OaksLabOak1ParcelThanksText" },
|
||||
{ "take_item", "OAKS_PARCEL", 1 },
|
||||
{ "stop_music" },
|
||||
@@ -128,8 +122,8 @@ return {
|
||||
{ "face_object", 1, "up" },
|
||||
{ "face_object", 5, "down" },
|
||||
{ "show_text", "_OaksLabOakMyInventionPokedexText" },
|
||||
{ "text_sound", "Get_Key_Item" },
|
||||
{ "show_text", "_OaksLabOakGotPokedexText" },
|
||||
{ "play_sound", "Get_Key_Item" },
|
||||
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" },
|
||||
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" },
|
||||
{ "face_object", 1, "up" },
|
||||
@@ -246,9 +240,17 @@ return {
|
||||
-- the table sprites; re-entering the lab applies the same HideObject
|
||||
-- the gift script now does (OaksLab.asm OakGivesPokedex).
|
||||
onEnter = function(game, ow)
|
||||
if not (game.save.flags and game.save.flags.EVENT_GOT_POKEDEX) then
|
||||
return
|
||||
local flags = game.save.flags or {}
|
||||
if flags.EVENT_GOT_STARTER and not flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB then
|
||||
local rival = ow:npcByIndex(1)
|
||||
if rival then
|
||||
rival.cellX = flags.EVENT_CHOSE_CHARMANDER and 7
|
||||
or flags.EVENT_CHOSE_SQUIRTLE and 8 or 6
|
||||
rival.cellY = 4
|
||||
rival.px, rival.py = rival.cellX * 16, rival.cellY * 16
|
||||
end
|
||||
end
|
||||
if not flags.EVENT_GOT_POKEDEX then return end
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { save = game.save, game = game, overworld = ow }
|
||||
Commands.hide_object(ctx, "OAKS_LAB", "OAKSLAB_POKEDEX1")
|
||||
@@ -286,6 +288,8 @@ return {
|
||||
-- fanfare for the taunt/challenge exchange, same as the Yellow port
|
||||
-- (oaks_lab_yellow.lua); it was silently dropped here (#596).
|
||||
local rows = {
|
||||
{ "face_object", 1, "down" }, -- scripts/OaksLab.asm:347-351
|
||||
{ "face_player_dir", "up" },
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetRival" },
|
||||
{ "show_text", "_OaksLabRivalIllTakeYouOnText" }, -- 1
|
||||
@@ -309,28 +313,19 @@ return {
|
||||
local base = #rows
|
||||
local party = flags.EVENT_CHOSE_BULBASAUR and 3
|
||||
or flags.EVENT_CHOSE_SQUIRTLE and 2 or 1
|
||||
table.insert(rows, { "save_end_battle_text", "_OaksLabRivalIPickedTheWrongPokemonText" })
|
||||
table.insert(rows, { "start_battle", "trainer", "OPP_RIVAL1", party })
|
||||
-- OaksLabRivalEndBattleScript: heal + flag on win or loss; no blackout
|
||||
table.insert(rows, { "heal_party" })
|
||||
table.insert(rows, { "set_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" })
|
||||
-- OaksLabRivalEndBattleScript: on WIN, print the "picked the wrong
|
||||
-- POKéMON!" gloat, then BOTH win and loss print the shared exit line
|
||||
-- _OaksLabRivalSmellYouLaterText ("OK! I'll make my POKéMON fight to
|
||||
-- toughen it up!\012<PLAYER>! Gramps! Smell you later!") before Blue
|
||||
-- marches out. A loss skips only the gloat (that taunt was already
|
||||
-- shown in-battle via Rival1WinText), never the exit line (#231). The
|
||||
-- jump_if_false convergence point is the exit line: base+6 indexes the
|
||||
-- SmellYouLater row below, so WIN falls IPicked -> SmellYouLater and
|
||||
-- LOSS jumps straight to SmellYouLater (both then walk-out + hide).
|
||||
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, { "play_music", "Music_MeetRival", { start = "rival" } })
|
||||
table.insert(rows, { "move_npc_to", 1, 4, 11 })
|
||||
table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" })
|
||||
table.insert(rows, { "play_music", "Music_OaksLab" })
|
||||
|
||||
@@ -38,13 +38,15 @@ return {
|
||||
{ "jump_if_false", "raise_young" },
|
||||
-- .DeliverParcelText: parcel handover, then the Pokédex scene
|
||||
-- (OaksLabRivalArrivesAtOaksRequestScript -> OakGivesPokedexScript)
|
||||
{ "text_sound", "Get_Key_Item" },
|
||||
{ "show_text", "_OaksLabOak1DeliverParcelText" },
|
||||
{ "play_sound", "Get_Key_Item" },
|
||||
{ "show_text", "_OaksLabOak1ParcelThanksText" },
|
||||
{ "take_item", "OAKS_PARCEL", 1 },
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetRival" },
|
||||
{ "show_text", "_OaksLabRivalGrampsText" },
|
||||
-- callfar OaksLabPikachuMovementScript, before ShowObject (#1021)
|
||||
{ "pikachu_make_way" },
|
||||
{ "show_object", "OAKS_LAB", "OAKSLAB_RIVAL" },
|
||||
{ "place_npc", RIVAL, 4, 7, "up" },
|
||||
{ "move_npc_to", RIVAL, 4, 3 },
|
||||
@@ -60,8 +62,8 @@ return {
|
||||
{ "face_object", RIVAL, "up" },
|
||||
{ "face_object", OAK1, "down" },
|
||||
{ "show_text", "_OaksLabOakMyInventionPokedexText" },
|
||||
{ "text_sound", "Get_Key_Item" },
|
||||
{ "show_text", "_OaksLabOakGotPokedexText" },
|
||||
{ "play_sound", "Get_Key_Item" },
|
||||
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" },
|
||||
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" },
|
||||
{ "face_object", RIVAL, "up" },
|
||||
@@ -111,8 +113,8 @@ return {
|
||||
{ "jump_if_true", "come_see" },
|
||||
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" },
|
||||
{ "give_item", "POKE_BALL", 5, false },
|
||||
{ "text_sound", "Get_Key_Item" },
|
||||
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" },
|
||||
{ "play_sound", "Get_Key_Item" },
|
||||
{ "show_text", "_OaksLabGivePokeballsExplanationText" },
|
||||
{ "jump", "end" },
|
||||
|
||||
@@ -174,7 +176,7 @@ return {
|
||||
-- rival starter baseline (RIVAL_STARTER_JOLTEON) at snatch time
|
||||
rows[#rows + 1] = { "set_field", "rivalStarter", 1 }
|
||||
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText1" }
|
||||
rows[#rows + 1] = { "play_sound", "Get_Key_Item" }
|
||||
rows[#rows + 1] = { "text_sound", "Get_Key_Item" }
|
||||
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText2" }
|
||||
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText3" }
|
||||
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText4" }
|
||||
@@ -192,15 +194,17 @@ return {
|
||||
end
|
||||
rows[#rows + 1] = { "face_player_dir", "up" }
|
||||
rows[#rows + 1] = { "face_object", OAK1, "down" }
|
||||
-- OaksLabPlayerReceivedMonText: no nickname prompt -- the starter
|
||||
-- Pikachu keeps its species name
|
||||
-- OaksLabPlayerReceivedMonText clears wMonDataLocation, so AskName runs (#1013)
|
||||
rows[#rows + 1] = { "show_text", "_OaksLabOakGivesText" }
|
||||
rows[#rows + 1] = { "play_sound", "Get_Key_Item" }
|
||||
rows[#rows + 1] = { "text_sound", "Get_Key_Item" }
|
||||
rows[#rows + 1] = { "show_text", "_OaksLabReceivedText", { RAM = "PIKACHU" } }
|
||||
rows[#rows + 1] = { "give_pokemon", "PIKACHU", 5, true }
|
||||
rows[#rows + 1] = { "give_pokemon", "PIKACHU", 5 }
|
||||
-- DisablePikachuOverworldSpriteDrawing keeps it in the ball (#1009)
|
||||
rows[#rows + 1] = { "set_field", "pikachuInBall", true }
|
||||
rows[#rows + 1] = { "set_flag", "EVENT_GOT_STARTER" }
|
||||
rows[#rows + 1] = { "set_flag", "EVENT_CHOSE_PIKACHU" }
|
||||
ow.runner:run(rows, { npc = npc, onDone = done })
|
||||
ow.runner:run(rows, { npc = npc, onDone = done,
|
||||
checkpointOnDone = "release_npc" })
|
||||
end,
|
||||
|
||||
TEXT_OAKSLAB_RIVAL = {
|
||||
@@ -222,9 +226,15 @@ return {
|
||||
},
|
||||
|
||||
onEnter = function(game, ow)
|
||||
if not (game.save.flags and game.save.flags.EVENT_GOT_POKEDEX) then
|
||||
return
|
||||
local flags = game.save.flags or {}
|
||||
if flags.EVENT_GOT_STARTER and not flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB then
|
||||
local rival = ow:npcByIndex(RIVAL)
|
||||
if rival then
|
||||
rival.cellX, rival.cellY = 7, 4
|
||||
rival.px, rival.py = 7 * 16, 4 * 16
|
||||
end
|
||||
end
|
||||
if not flags.EVENT_GOT_POKEDEX then return end
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { save = game.save, game = game, overworld = ow }
|
||||
Commands.hide_object(ctx, "OAKS_LAB", "OAKSLAB_POKEDEX1")
|
||||
@@ -253,6 +263,7 @@ return {
|
||||
local rival = ow:npcByIndex(RIVAL)
|
||||
if not rival then return false end
|
||||
local rows = {
|
||||
{ "face_object", RIVAL, "down" }, -- pokeyellow scripts/OaksLab.asm:311-315
|
||||
{ "face_player_dir", "up" },
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetRival" },
|
||||
@@ -292,13 +303,14 @@ return {
|
||||
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, { "play_music", "Music_MeetRival", { start = "rival" } })
|
||||
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" })
|
||||
-- OaksLabPikachuEscapesPokeballScript: Pikachu hates its ball.
|
||||
-- The overworld follower itself is still an open port
|
||||
-- (docs/yellow-version.md runtime backlog); the story beat plays.
|
||||
-- OaksLabPikachuEscapesPokeballScript: the follower reaches the map (#1009)
|
||||
table.insert(rows, { "face_player_dir", "up" })
|
||||
table.insert(rows, { "set_field", "pikachuInBall", false })
|
||||
table.insert(rows, { "spawn_pikachu_follower" })
|
||||
table.insert(rows, { "play_cry", "PIKACHU" })
|
||||
table.insert(rows, { "show_text", "_OaksLabPikachuDislikesPokeballsText1" })
|
||||
table.insert(rows, { "show_text", "_OaksLabPikachuDislikesPokeballsText2" })
|
||||
|
||||
@@ -140,22 +140,29 @@ M.VIRIDIAN_CITY = {
|
||||
-- Daisy hands over the TOWN MAP once Oak's errand is under way
|
||||
-- (scripts/BluesHouse.asm BluesHouseDaisySittingText)
|
||||
M.BLUES_HOUSE = {
|
||||
-- scripts/BluesHouse.asm:12-16
|
||||
onEnter = function(game, ow)
|
||||
game.save.flags.EVENT_ENTERED_BLUES_HOUSE = true
|
||||
end,
|
||||
talk = {
|
||||
TEXT_BLUESHOUSE_DAISY_SITTING = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_GOT_TOWN_MAP" }, -- 2
|
||||
{ "jump_if_true", 10 }, -- 3
|
||||
{ "check_flag", "EVENT_GOT_STARTER" }, -- 4
|
||||
{ "jump_if_false", 12 }, -- 5
|
||||
{ "show_text", "_BluesHouseDaisyOfferMapText" }, -- 6
|
||||
{ "face_player" },
|
||||
{ "check_flag", "EVENT_GOT_TOWN_MAP" },
|
||||
{ "jump_if_true", "got_map" },
|
||||
{ "check_flag", "EVENT_GOT_POKEDEX" },
|
||||
{ "jump_if_false", "too_early" },
|
||||
{ "show_text", "_BluesHouseDaisyOfferMapText" },
|
||||
-- _GotMapText: "{PLAYER} got a\n{RAM:wStringBuffer}!" -- the
|
||||
-- buffer supplies "TOWN MAP" (scripts/BluesHouse.asm GotMapText)
|
||||
{ "give_item", "TOWN_MAP", 1, "_GotMapText" }, -- 7
|
||||
{ "set_flag", "EVENT_GOT_TOWN_MAP" }, -- 8
|
||||
{ "jump", 13 }, -- 9
|
||||
{ "show_text", "_BluesHouseDaisyUseMapText" }, -- 10
|
||||
{ "jump", 13 }, -- 11
|
||||
{ "show_text", "_BluesHouseDaisyRivalAtLabText" }, -- 12
|
||||
{ "give_item", "TOWN_MAP", 1, "_GotMapText" },
|
||||
{ "hide_object", "BLUES_HOUSE", "BLUESHOUSE_TOWN_MAP" },
|
||||
{ "set_flag", "EVENT_GOT_TOWN_MAP" },
|
||||
{ "jump", "end" },
|
||||
{ "label", "got_map" },
|
||||
{ "show_text", "_BluesHouseDaisyUseMapText" },
|
||||
{ "jump", "end" },
|
||||
{ "label", "too_early" },
|
||||
{ "show_text", "_BluesHouseDaisyRivalAtLabText" },
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -294,6 +301,7 @@ M.BILLS_HOUSE = {
|
||||
|
||||
M.ROUTE_25 = {
|
||||
onEnter = function(game, ow)
|
||||
game.save.pikachuMapScriptActive = nil
|
||||
local flags = game.save.flags
|
||||
if flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING then return end
|
||||
local Commands = require("src.script.Commands")
|
||||
@@ -326,6 +334,7 @@ M.VERMILION_CITY = {
|
||||
-- only read while EVENT_1ST_LOCK_OPENED is unset (the gym is only
|
||||
-- reachable through this map, so a fresh visit always re-rolls).
|
||||
onEnter = function(game, ow)
|
||||
game.save.pikachuMapScriptActive = nil
|
||||
local puz = game.save.trashPuzzle or {}
|
||||
game.save.trashPuzzle = puz
|
||||
puz.first = love.math.random(0, 7) * 2
|
||||
@@ -1058,6 +1067,7 @@ local championsRoomRivalScript = {
|
||||
-- OakCongratulatesPlayerScript: rival faces left, Oak faces down
|
||||
{ "face_object", 1, "left" }, -- 17
|
||||
{ "face_object", 2, "down" }, -- 18
|
||||
{ "load_player_starter_name" },
|
||||
{ "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 19
|
||||
-- OakDisappointedWithRivalScript: Oak turns to the rival (right)
|
||||
{ "face_object", 2, "right" }, -- 20
|
||||
@@ -1067,13 +1077,8 @@ local championsRoomRivalScript = {
|
||||
{ "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
|
||||
-- scripts/ChampionsRoom.asm WalkToHallOfFame_RLEMovement
|
||||
{ "move_player", "left", 1 },
|
||||
{ "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)
|
||||
@@ -1238,6 +1243,7 @@ local function pokemonTower2FRivalScript(playerX)
|
||||
{ "jump_if_false", "end" }, -- 6 loss: stay
|
||||
{ "set_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 7
|
||||
{ "show_text", "_PokemonTower2FRivalDefeatedText" }, -- 8
|
||||
{ "play_music", "Music_MeetRival", { start = "rival" } },
|
||||
{ "walk_npc", 1, exitDirs }, -- 9
|
||||
{ "hide_object", "POKEMON_TOWER_2F", "POKEMONTOWER2F_RIVAL" }, -- 10
|
||||
{ "jump", "end" }, -- 11
|
||||
|
||||
@@ -87,6 +87,18 @@ end
|
||||
M.PALLET_TOWN = {
|
||||
talk = require("data.scripts.pallet_town").talk,
|
||||
escort = escort,
|
||||
-- scripts/PalletTown.asm:133-144
|
||||
onEnter = function(game, ow)
|
||||
local f = game.save.flags
|
||||
if f.EVENT_GOT_TOWN_MAP and f.EVENT_ENTERED_BLUES_HOUSE
|
||||
and not f.EVENT_DAISY_WALKING then
|
||||
f.EVENT_DAISY_WALKING = true
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { save = game.save, game = game, overworld = ow }
|
||||
Commands.hide_object(ctx, "BLUES_HOUSE", "BLUESHOUSE_DAISY1")
|
||||
Commands.show_object(ctx, "BLUES_HOUSE", "BLUESHOUSE_DAISY2")
|
||||
end
|
||||
end,
|
||||
-- Red: stop at y==1 from (8,5). Yellow: stop at y==0 from (10,4),
|
||||
-- then a wild Pikachu battle before the lab escort (pokeyellow
|
||||
-- PalletTownPikachuBattleScript).
|
||||
@@ -178,7 +190,8 @@ M.PALLET_TOWN = {
|
||||
end
|
||||
end
|
||||
|
||||
local function enterLab()
|
||||
local function enterLab(oak)
|
||||
if oak then oak.stepFrames = nil end
|
||||
Commands.hide_object(ctx, "PALLET_TOWN", "PALLETTOWN_OAK")
|
||||
Commands.show_object(ctx, "OAKS_LAB", "OAKSLAB_OAK2")
|
||||
ow.doorWarp = true
|
||||
@@ -187,12 +200,17 @@ M.PALLET_TOWN = {
|
||||
end
|
||||
|
||||
local function walkToLab(oak)
|
||||
-- lockstep half runs Oak on the player's own frames per cell
|
||||
-- engine/overworld/movement.asm:737 (DoScriptedNPCMovement)
|
||||
local i = 0
|
||||
if oak then
|
||||
oak.stepFrames = ow.player.stepFramesCur or ow.player.stepFrames
|
||||
end
|
||||
local function tick()
|
||||
i = i + 1
|
||||
local playerStep = escort.playerSteps[i]
|
||||
if not playerStep then
|
||||
enterLab()
|
||||
enterLab(oak)
|
||||
return
|
||||
end
|
||||
if oak and escort.oakSteps[i] then
|
||||
@@ -206,6 +224,13 @@ M.PALLET_TOWN = {
|
||||
end
|
||||
|
||||
local function escortToLab(oak)
|
||||
-- PalletMovementScript_OakMoveLeft
|
||||
-- (engine/overworld/auto_movement.asm) starts MUSIC_MUSEUM_GUY
|
||||
-- when the escort begins in Yellow. Until then, Pallet Town plays
|
||||
-- after the battle; Red/Blue leave MUSIC_MEET_PROF_OAK playing.
|
||||
if yellow then
|
||||
Music.play(game.data, "Music_MuseumGuy")
|
||||
end
|
||||
local numSteps = x - 10
|
||||
if oak and numSteps > 0 then
|
||||
ow:scriptMove(oak, "left", numSteps, function()
|
||||
@@ -249,14 +274,24 @@ M.PALLET_TOWN = {
|
||||
function()
|
||||
-- Oak turns toward the horizontally adjacent grass (left exit
|
||||
-- looks right, right exit looks left -- the
|
||||
-- EVENT_PLAYER_AT_RIGHT_EXIT_TO_PALLET_TOWN branch)
|
||||
-- EVENT_PLAYER_AT_RIGHT_EXIT_TO_PALLET_TOWN branch).
|
||||
-- In pokeyellow, PalletTownOakGreetsPlayerScript turns Oak and
|
||||
-- PalletTownPikachuBattleScript arms the battle on the next
|
||||
-- overworld iteration. OverworldLoopLessDelay
|
||||
-- (home/overworld.asm) burns two DelayFrame calls at the top
|
||||
-- of each iteration and calls RunMapScript before checking
|
||||
-- wCurOpponent, so those two DelayFrame calls are what keep
|
||||
-- Oak's turn on screen before the battle check fires.
|
||||
if oak then oak.facing = x == 10 and "right" or "left" end
|
||||
local battle = BattleState.newWild(game, "PIKACHU", 5)
|
||||
battle:makeOldManDemo("PROF.OAK")
|
||||
battle.onFinish = function()
|
||||
afterPikaBattle()
|
||||
end
|
||||
game.stack:push(battle)
|
||||
hold(2, nil, function()
|
||||
local battle = BattleState.newWild(game, "PIKACHU", 5)
|
||||
battle:makeOldManDemo("PROF.OAK")
|
||||
battle.onFinish = function()
|
||||
afterPikaBattle()
|
||||
end
|
||||
-- Use the standard wild-battle entry transition.
|
||||
Commands.pushBattle(ctx, battle)
|
||||
end)
|
||||
end))
|
||||
end
|
||||
|
||||
@@ -396,19 +431,31 @@ M.ROUTE_8_GATE = saffronGate("TEXT_ROUTE8GATE_GUARD", { { 2, 3 }, { 2, 4 } }, tr
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
M.POKEMON_FAN_CLUB = {
|
||||
onEnter = function(game, ow)
|
||||
require("src.world.PikachuFollower").onFanClubEntered(game, ow)
|
||||
end,
|
||||
talk = {
|
||||
TEXT_POKEMONFANCLUB_CHAIRMAN = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_RECEIVED_BIKE_VOUCHER" }, -- 2
|
||||
{ "jump_if_true", 9 }, -- 3
|
||||
{ "show_text", "_PokemonFanClubChairmanIntroText" }, -- 4
|
||||
{ "show_text", "_PokemonFanClubChairmanStoryText" }, -- 5
|
||||
{ "jump_if_true", "nothing_left" }, -- 3
|
||||
-- YesNoChoice (scripts/PokemonFanClub.asm): NO forfeits the voucher (#1050)
|
||||
{ "ask", "_PokemonFanClubChairmanIntroText" }, -- 4
|
||||
{ "jump_if_false", "no_story" }, -- 5
|
||||
{ "show_text", "_PokemonFanClubChairmanStoryText" }, -- 6
|
||||
-- give-then-print like scripts/PokemonFanClub.asm (GiveItem
|
||||
-- fills wStringBuffer; the received text reads it)
|
||||
{ "give_item", "BIKE_VOUCHER", 1, false }, -- 6
|
||||
{ "show_text", "_PokemonFanClubReceivedBikeVoucherText" }, -- 7
|
||||
{ "set_flag", "EVENT_RECEIVED_BIKE_VOUCHER" }, -- 8
|
||||
{ "show_text", "_PokemonFanClubExplainBikeVoucherText" }, -- 9
|
||||
{ "give_item", "BIKE_VOUCHER", 1, false }, -- 7
|
||||
{ "show_text", "_PokemonFanClubReceivedBikeVoucherText" }, -- 8
|
||||
{ "set_flag", "EVENT_RECEIVED_BIKE_VOUCHER" }, -- 9
|
||||
{ "show_text", "_PokemonFanClubExplainBikeVoucherText" }, -- 10
|
||||
{ "jump", "end" }, -- 11
|
||||
{ "label", "no_story" }, -- 12
|
||||
{ "show_text", "_PokemonFanClubNoStoryText" }, -- 13
|
||||
{ "jump", "end" }, -- 14
|
||||
-- .nothingleft: the gift is done, he only reminisces now
|
||||
{ "label", "nothing_left" }, -- 15
|
||||
{ "show_text", "_PokemonFanClubChairFinalText" }, -- 16
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -619,8 +666,10 @@ local function mtMoonFossil(itemId, otherName, gotFlag)
|
||||
end
|
||||
local idef = game.data.items[itemId]
|
||||
game.stringBuffer = idef and idef.name or itemId
|
||||
require("src.core.Sound").play(game.data, "Get_Key_Item")
|
||||
local dirs = mtMoonNerdWalk(ow.player.cellX, ow.player.cellY, itemId)
|
||||
-- MtMoonB2FReceivedFossilText: text_far, sound_get_key_item,
|
||||
-- text_waitbutton -- the jingle plays after the box has typed and
|
||||
-- the button wait comes after it
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._MtMoonB2FReceivedFossilText
|
||||
or ("{PLAYER} got the\n" .. game.stringBuffer .. "!"),
|
||||
@@ -633,11 +682,11 @@ local function mtMoonFossil(itemId, otherName, gotFlag)
|
||||
ow.runner:run({
|
||||
{ "walk_npc", 1, dirs },
|
||||
{ "text_opts", { auto = true } },
|
||||
{ "text_sound", "Get_Key_Item" },
|
||||
{ "show_text", "_MtMoonB2FSuperNerdThenThisIsMineText" },
|
||||
{ "play_sound", "Get_Key_Item" },
|
||||
{ "hide_object", "MT_MOON_B2F", otherName },
|
||||
}, { onDone = done })
|
||||
end))
|
||||
end, TextBox.soundOpts(game, "Get_Key_Item")))
|
||||
end }))
|
||||
end
|
||||
end
|
||||
@@ -653,6 +702,23 @@ M.MT_MOON_B2F = {
|
||||
return false
|
||||
end,
|
||||
talk = {
|
||||
-- MtMoonB2FSuperNerdText: once beaten his line turns on the fossils
|
||||
-- (scripts/MtMoonB2F.asm:187), which the header's flat `after` can't hold
|
||||
TEXT_MTMOONB2F_SUPER_NERD = function(game, ow, npc, done)
|
||||
if not superNerdBeaten(ow) then
|
||||
engageSuperNerd(game, ow, done)
|
||||
return
|
||||
end
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local t = game.data.text
|
||||
local flags = game.save.flags
|
||||
local line = (flags.EVENT_GOT_DOME_FOSSIL or flags.EVENT_GOT_HELIX_FOSSIL)
|
||||
and (t._MtMoonB2FSuperNerdTheresAPokemonLabText
|
||||
or "Far away, on\nCINNABAR ISLAND,\nthere's a POKéMON\nLAB.")
|
||||
or (t._MtMoonB2fSuperNerdEachTakeOneText
|
||||
or "We'll each take\none!\nNo being greedy!")
|
||||
game.stack:push(TextBox.new(game, line, done))
|
||||
end,
|
||||
TEXT_MTMOONB2F_DOME_FOSSIL = mtMoonFossil(
|
||||
"DOME_FOSSIL", "MTMOONB2F_HELIX_FOSSIL", "EVENT_GOT_DOME_FOSSIL"),
|
||||
TEXT_MTMOONB2F_HELIX_FOSSIL = mtMoonFossil(
|
||||
@@ -660,35 +726,40 @@ M.MT_MOON_B2F = {
|
||||
},
|
||||
}
|
||||
|
||||
-- The ticket clerk (scripts/Museum1F.asm Museum1FScientist1Text):
|
||||
-- Y50, once. Declining at the rope shoves the player one tile SOUTH back off
|
||||
-- the exhibit rope they crossed heading north (#151); the museum floor has no
|
||||
-- ledges, so a plain scriptMove("down",1) is the correct primitive.
|
||||
-- The ticket clerk (scripts/Museum1F.asm Museum1FScientist1Text): Y50, once.
|
||||
-- Declining at the rope shoves the player one tile south (#151)
|
||||
local function museumClerk(game, ow, done, onDecline)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
local t = game.data.text or {}
|
||||
if game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET then
|
||||
game.stack:push(TextBox.new(game,
|
||||
"Take your time,\nand enjoy it all!", done))
|
||||
t._Museum1FScientist1TakePlentyOfTimeText
|
||||
or "Take your time,\nand enjoy it all!", done))
|
||||
return
|
||||
end
|
||||
-- scripts/Museum1F.asm:72
|
||||
local money = function() return game.save.money end
|
||||
game.stack:push(TextBox.new(game,
|
||||
"It's ¥50 for a\nchild's ticket.\fWould you like to\ncome in?", function()
|
||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||
t._Museum1FScientist1WouldYouLikeToComeInText
|
||||
or "It's ¥50 for a\nchild's ticket.\fWould you like to\ncome in?",
|
||||
nil, { money = money, choice = function(yes)
|
||||
if yes and game.save.money >= 50 then
|
||||
game.save.money = game.save.money - 50
|
||||
game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET = true
|
||||
-- scripts/Museum1F.asm:106
|
||||
game.stack:push(TextBox.new(game,
|
||||
"Right, ¥50!\nThank you!", done))
|
||||
t._Museum1FScientist1ThankYouText or "Right, ¥50!\nThank you!", done,
|
||||
{ money = money }))
|
||||
elseif yes then
|
||||
game.stack:push(TextBox.new(game,
|
||||
"You don't have\nenough money.", onDecline or done))
|
||||
t._Museum1FScientist1DontHaveEnoughMoneyText
|
||||
or "You don't have\nenough money.", onDecline or done, { money = money }))
|
||||
else
|
||||
game.stack:push(TextBox.new(game,
|
||||
"Come again!", onDecline or done))
|
||||
t._Museum1FScientist1ComeAgainText
|
||||
or "Come again!", onDecline or done, { money = money }))
|
||||
end
|
||||
end))
|
||||
end))
|
||||
end }))
|
||||
end
|
||||
|
||||
M.MUSEUM_1F = {
|
||||
|
||||
@@ -937,15 +937,10 @@ M.VERMILION_DOCK = {
|
||||
ow:startDustAnim(cx, 1, function() puff(n - 1, cx + 2) end)
|
||||
end
|
||||
puff(3, 15)
|
||||
-- VermilionDock_EraseSSAnne deliberately leaves the blocks under the
|
||||
-- player alone ("south of the player and won't be redrawn"), so skip
|
||||
-- his own block: he must not spend the walk-out standing on water
|
||||
local pbx = math.floor(ow.player.cellX / 2)
|
||||
local pby = math.floor(ow.player.cellY / 2)
|
||||
-- scripts/VermilionDock.asm:182-203
|
||||
local rows = {}
|
||||
local function setBlock(bx, by, block)
|
||||
if bx < 1 or bx > 8 then return end
|
||||
if bx == pbx and by == pby then return end
|
||||
rows[#rows + 1] = { "replace_block", bx, by, block }
|
||||
end
|
||||
rows[#rows + 1] = { "wait", 120 }
|
||||
|
||||
@@ -8,9 +8,9 @@ local M = {}
|
||||
|
||||
local function text(game) return game.data.text end
|
||||
|
||||
local function push(game, s, done)
|
||||
local function push(game, s, done, opts)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game, s, done))
|
||||
game.stack:push(TextBox.new(game, s, done, opts))
|
||||
end
|
||||
|
||||
-- The question stays on screen under the YES/NO menu. The dojo prize
|
||||
@@ -27,11 +27,16 @@ local function ask(game, s, cb)
|
||||
game.stack:push(TextBox.new(game, s, nil, { choice = cb }))
|
||||
end
|
||||
|
||||
-- fill the extracted text placeholders ({NUM:...}, {RAM:...}, {PLAYER})
|
||||
-- fill text placeholders; key on the hram/wram symbol first, since one
|
||||
-- string can carry two different NUM slots (#1006)
|
||||
local function fill(s, subs)
|
||||
s = s:gsub("{PLAYER}", subs.player or "")
|
||||
s = s:gsub("{NUM:[^}]*}", function() return tostring(subs.num or "") end)
|
||||
s = s:gsub("{RAM:[^}]*}", function() return subs.ram or "" end)
|
||||
s = s:gsub("{NUM:([%w_]*)[^}]*}", function(name)
|
||||
return tostring(subs[name] or subs.num or "")
|
||||
end)
|
||||
s = s:gsub("{RAM:([%w_]*)[^}]*}", function(name)
|
||||
return subs[name] or subs.ram or ""
|
||||
end)
|
||||
return s
|
||||
end
|
||||
|
||||
@@ -82,9 +87,12 @@ local function oaksAide(threshold, itemId, repeatText)
|
||||
{ ram = itemName, player = game.save.player.name }), done)
|
||||
end)
|
||||
else
|
||||
-- .notEnoughOwnedMons prints owned then requirement, two counts
|
||||
push(game, fill(t._OaksAideUhOhText or
|
||||
"You have only\ncaught {NUM:}!",
|
||||
{ num = owned, ram = itemName }), done)
|
||||
{ num = owned, ram = itemName,
|
||||
hOaksAideNumMonsOwned = owned,
|
||||
hOaksAideRequirement = threshold }), done)
|
||||
end
|
||||
end)
|
||||
end
|
||||
@@ -110,33 +118,36 @@ M.ROUTE_15_GATE_2F = {
|
||||
|
||||
M.MT_MOON_POKECENTER = {
|
||||
talk = {
|
||||
TEXT_MTMOONPOKECENTER_MAGIKARP_SALESMAN = function(game, ow, npc, done)
|
||||
local t = text(game)
|
||||
if game.save.flags.EVENT_BOUGHT_MAGIKARP then
|
||||
push(game, t._MtMoonPokecenterMagikarpSalesmanNoRefundsText
|
||||
or "Well, I don't\ngive refunds!", done)
|
||||
return
|
||||
end
|
||||
ask(game, t._MtMoonPokecenterMagikarpSalesmanOfferText
|
||||
or "MAGIKARP! A\nsteal at ¥500!\nWant one?", function(yes)
|
||||
if not yes then
|
||||
push(game, t._MtMoonPokecenterMagikarpSalesmanNoText
|
||||
or "No? I'm only\nselling today!", done)
|
||||
return
|
||||
end
|
||||
if game.save.money < 500 then
|
||||
push(game, t._MtMoonPokecenterMagikarpSalesmanNoMoneyText
|
||||
or "You'll need more\nmoney than that!", done)
|
||||
return
|
||||
end
|
||||
game.save.money = game.save.money - 500
|
||||
game.save.flags.EVENT_BOUGHT_MAGIKARP = true
|
||||
local Commands = require("src.script.Commands")
|
||||
Commands.give_pokemon({ save = game.save, game = game, overworld = ow },
|
||||
"MAGIKARP", 5)
|
||||
push(game, ("%s got a\nMAGIKARP!"):format(game.save.player.name), done)
|
||||
end)
|
||||
end,
|
||||
-- command rows, not a Lua handler: give_pokemon needs a runner to AskName (#1407)
|
||||
TEXT_MTMOONPOKECENTER_MAGIKARP_SALESMAN = {
|
||||
{ "check_flag", "EVENT_BOUGHT_MAGIKARP" },
|
||||
{ "jump_if_true", "no_refunds" },
|
||||
-- MONEY_BOX goes up between the offer and YesNoChoice -- MtMoonPokecenter.asm:31
|
||||
{ "text_opts", { money = true } },
|
||||
{ "ask", "_MtMoonPokecenterMagikarpSalesmanIGotADealText" },
|
||||
{ "jump_if_false", "declined" },
|
||||
{ "check_money", 500 },
|
||||
{ "jump_if_false", "no_money" },
|
||||
{ "give_pokemon", "MAGIKARP", 5 },
|
||||
-- MtMoonPokecenter.asm:49 `jr nc, .done`: a refused gift is never charged
|
||||
{ "jump_if_false", "box_full" },
|
||||
{ "take_money", 500 },
|
||||
{ "set_flag", "EVENT_BOUGHT_MAGIKARP" },
|
||||
{ "text_sound", "Get_Item1" },
|
||||
{ "show_text", "_GotMonText", { RAM = "MAGIKARP" } },
|
||||
{ "jump", "end" },
|
||||
{ "label", "box_full" },
|
||||
{ "show_text", "_BoxIsFullText" },
|
||||
{ "jump", "end" },
|
||||
{ "label", "declined" },
|
||||
{ "show_text", "_MtMoonPokecenterMagikarpSalesmanNoText" },
|
||||
{ "jump", "end" },
|
||||
{ "label", "no_money" },
|
||||
{ "show_text", "_MtMoonPokecenterMagikarpSalesmanNoMoneyText" },
|
||||
{ "jump", "end" },
|
||||
{ "label", "no_refunds" },
|
||||
{ "show_text", "_MtMoonPokecenterMagikarpSalesmanNoRefundsText" },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -245,27 +256,32 @@ M.FIGHTING_DOJO = {
|
||||
|
||||
M.SILPH_CO_7F = {
|
||||
talk = {
|
||||
TEXT_SILPHCO7F_SILPH_WORKER_M1 = function(game, ow, npc, done)
|
||||
local t = text(game)
|
||||
if game.save.flags.EVENT_GOT_LAPRAS then
|
||||
push(game, t._SilphCo7FSilphWorkerM1LaprasDescriptionText
|
||||
or "How is LAPRAS\ndoing?", done)
|
||||
return
|
||||
end
|
||||
push(game, t._SilphCo7FSilphWorkerM1ThankYouText
|
||||
or "Thank you for\nsaving us!\fI want you to\nhave this LAPRAS!",
|
||||
function()
|
||||
game.save.flags.EVENT_GOT_LAPRAS = true
|
||||
local Commands = require("src.script.Commands")
|
||||
Commands.give_pokemon({ save = game.save, game = game, overworld = ow },
|
||||
"LAPRAS", 15)
|
||||
push(game, ("%s got\nLAPRAS!"):format(game.save.player.name),
|
||||
function()
|
||||
push(game, t._SilphCo7FSilphWorkerM1LaprasDescriptionText
|
||||
or "It's a good\nswimmer!", done)
|
||||
end)
|
||||
end)
|
||||
end,
|
||||
-- command rows, not a Lua handler: give_pokemon needs a runner to AskName (#1049)
|
||||
TEXT_SILPHCO7F_SILPH_WORKER_M1 = {
|
||||
{ "face_player" },
|
||||
{ "check_flag", "EVENT_GOT_LAPRAS" },
|
||||
{ "jump_if_true", "has_lapras" },
|
||||
{ "show_text", "_SilphCo7FSilphWorkerM1HaveThisPokemonText" },
|
||||
{ "give_pokemon", "LAPRAS", 15 },
|
||||
{ "jump_if_false", "box_full" },
|
||||
-- flag ahead of the jingle, like the Celadon EEVEE (#426)
|
||||
{ "set_flag", "EVENT_GOT_LAPRAS" },
|
||||
{ "text_sound", "Get_Item1" },
|
||||
{ "show_text", "_GotMonText", { RAM = "LAPRAS" } },
|
||||
{ "show_text", "_SilphCo7FSilphWorkerM1LaprasDescriptionText" },
|
||||
{ "jump", "end" },
|
||||
{ "label", "box_full" },
|
||||
{ "show_text", "_BoxIsFullText" },
|
||||
{ "jump", "end" },
|
||||
-- SilphCo7F.asm .saved_silph gates the thanks on Giovanni
|
||||
{ "label", "has_lapras" },
|
||||
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
|
||||
{ "jump_if_true", "saved" },
|
||||
{ "show_text", "_SilphCo7FSilphWorkerM1IsOurPresidentOkText" },
|
||||
{ "jump", "end" },
|
||||
{ "label", "saved" },
|
||||
{ "show_text", "_SilphCo7FSilphWorkerM1SavedText" },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -294,12 +310,11 @@ M.COPYCATS_HOUSE_2F = {
|
||||
return
|
||||
end
|
||||
game.stringBuffer = game.data.items.TM_MIMIC.name
|
||||
require("src.core.Sound").play(game.data, "Get_Item1")
|
||||
Bag.remove(game.save, "POKE_DOLL", 1)
|
||||
game.save.flags.EVENT_GOT_TM31 = true
|
||||
push(game, t._CopycatsHouse2FCopycatReceivedTM31Text, function()
|
||||
push(game, t._CopycatsHouse2FCopycatTM31Explanation1Text, done)
|
||||
end)
|
||||
end, require("src.render.TextBox").soundOpts(game, "Get_Item1"))
|
||||
end)
|
||||
end)
|
||||
end,
|
||||
@@ -465,7 +480,6 @@ M.CELADON_MART_ROOF = {
|
||||
return
|
||||
end
|
||||
game.save.flags[g.flag] = true
|
||||
require("src.core.Sound").play(game.data, "Get_Item1")
|
||||
local subs = { player = game.save.player.name,
|
||||
ram = game.data.items[g.tm].name }
|
||||
local explain = fill(t[g.explain] or "", subs)
|
||||
@@ -477,7 +491,7 @@ M.CELADON_MART_ROOF = {
|
||||
else
|
||||
done()
|
||||
end
|
||||
end)
|
||||
end, require("src.render.TextBox").soundOpts(game, "Get_Item1"))
|
||||
end)
|
||||
end,
|
||||
onCancel = done,
|
||||
@@ -499,25 +513,28 @@ M.ROUTE_24 = {
|
||||
local flags = game.save.flags
|
||||
local function battleOrDone()
|
||||
if ow:trainerDefeated(npc) then
|
||||
push(game, "I hate this!\nMy dreams of\nTEAM ROCKET...", done)
|
||||
push(game, text(game)._Route24CooltrainerM1YouCouldBecomeATopLeaderText,
|
||||
done)
|
||||
else
|
||||
ow:engageTrainer(npc, done)
|
||||
end
|
||||
end
|
||||
if not flags.EVENT_GOT_NUGGET then
|
||||
push(game, "Congratulations!\nYou beat our 5\ncontest trainers!\f"
|
||||
.. "You just earned a\nfabulous prize!", function()
|
||||
local t = text(game)
|
||||
push(game, t._Route24CooltrainerM1YouBeatOurContestText .. "\f"
|
||||
.. t._Route24CooltrainerM1YouJustEarnedAPrizeText, function()
|
||||
if not require("src.inventory.Bag").add(game.save, "NUGGET", 1,
|
||||
game.data) then
|
||||
push(game, t._Route24CooltrainerM1NoRoomText, done)
|
||||
return
|
||||
end
|
||||
flags.EVENT_GOT_NUGGET = true
|
||||
require("src.inventory.Bag").add(game.save, "NUGGET", 1)
|
||||
push(game, ("%s received\na NUGGET!"):format(game.save.player.name),
|
||||
function()
|
||||
ask(game, "By the way, would\nyou like to join\nTEAM ROCKET?",
|
||||
function()
|
||||
push(game, "Arrgh! You are\nnot convinced?\fThen I'll show\n"
|
||||
.. "you my power!", battleOrDone)
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
game.stringBuffer = game.data.items.NUGGET.name
|
||||
push(game, t._Route24CooltrainerM1ReceivedNuggetText, function()
|
||||
push(game, t._Route24CooltrainerM1JoinTeamRocketText,
|
||||
battleOrDone)
|
||||
end, require("src.render.TextBox").soundOpts(game, "Get_Item1"))
|
||||
end, require("src.render.TextBox").soundOpts(game, "Get_Item1"))
|
||||
return
|
||||
end
|
||||
battleOrDone()
|
||||
|
||||
@@ -4,9 +4,9 @@ local M = {}
|
||||
|
||||
local function text(game) return game.data.text end
|
||||
|
||||
local function push(game, s, done)
|
||||
local function push(game, s, done, opts)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game, s, done))
|
||||
game.stack:push(TextBox.new(game, s, done, opts))
|
||||
end
|
||||
|
||||
-- fill the extracted text placeholders ({RAM:...}, {PLAYER})
|
||||
@@ -25,8 +25,8 @@ local function gift(opts)
|
||||
local t = text(game)
|
||||
local itemName = game.data.items[opts.item].name
|
||||
local subs = { ram = itemName, player = game.save.player.name }
|
||||
local function say(label, fallback, cb)
|
||||
push(game, fill(t[label] or fallback, subs), cb)
|
||||
local function say(label, fallback, cb, sopts)
|
||||
push(game, fill(t[label] or fallback, subs), cb, sopts)
|
||||
end
|
||||
if game.save.flags[opts.flag] then
|
||||
say(opts.already or opts.explain, "It's a useful\nitem, isn't it?", done)
|
||||
@@ -39,15 +39,16 @@ local function gift(opts)
|
||||
end
|
||||
game.save.flags[opts.flag] = true
|
||||
local idef = game.data.items[opts.item]
|
||||
require("src.core.Sound").play(game.data,
|
||||
(idef and idef.keyItem) and "Get_Key_Item" or "Get_Item1")
|
||||
-- the received texts carry sound_get_item_1 / sound_get_key_item, so
|
||||
-- the jingle only fires once that box has typed out
|
||||
say(opts.received, "{PLAYER} received\n{RAM:}!", function()
|
||||
if opts.explain then
|
||||
say(opts.explain, "", done)
|
||||
else
|
||||
done()
|
||||
end
|
||||
end)
|
||||
end, require("src.render.TextBox").soundOpts(game,
|
||||
(idef and idef.keyItem) and "Get_Key_Item" or "Get_Item1"))
|
||||
end
|
||||
if opts.pre then say(opts.pre, opts.preFallback or "", give) else give() end
|
||||
end
|
||||
@@ -445,7 +446,7 @@ local function pewterGymEscort(game, ow)
|
||||
end
|
||||
|
||||
local function afterWalk()
|
||||
if guy then guy.facing = "left" end
|
||||
if guy then guy.stepFrames, guy.facing = nil, "left" end
|
||||
Music.playMap(game.data, "PEWTER_CITY")
|
||||
push(game, t._PewterCityYoungsterGoTakeOnBrockText
|
||||
or "Go take on BROCK\nat the GYM first!", walkHome)
|
||||
@@ -468,6 +469,11 @@ local function pewterGymEscort(game, ow)
|
||||
end
|
||||
|
||||
local function beginWalk()
|
||||
-- the escort runs the youngster on the player's own frames per cell
|
||||
-- engine/overworld/movement.asm:737 (DoScriptedNPCMovement)
|
||||
if guy then
|
||||
guy.stepFrames = ow.player.stepFramesCur or ow.player.stepFrames
|
||||
end
|
||||
Music.play(game.data, "Music_MuseumGuy")
|
||||
if guy and head > 0 then
|
||||
local h = 0
|
||||
@@ -508,12 +514,12 @@ M.PEWTER_CITY = {
|
||||
-- Rival ambush: show the hidden rival, walk him up to the player, run
|
||||
-- the battle rows, march him back and hide him. On a loss the walk is
|
||||
-- skipped (the blackout rebuilds the map mid-script).
|
||||
local function runAmbush(game, ow, rows, playerFacing)
|
||||
local function runAmbush(game, ow, rows, playerFacing, musicOpts)
|
||||
if ow.runner:isRunning() then return false end
|
||||
ow.player.facing = playerFacing
|
||||
-- the rival encounter sting (MUSIC_MEET_RIVAL); the battle music
|
||||
-- takes over and the map theme returns after the victory jingle
|
||||
require("src.core.Music").play(game.data, "Music_MeetRival")
|
||||
require("src.core.Music").play(game.data, "Music_MeetRival", nil, musicOpts)
|
||||
ow.runner:run(rows)
|
||||
return true
|
||||
end
|
||||
@@ -567,12 +573,15 @@ local function route22Scene(n, objIndex, objName, oppClass, baseParty, beatFlag,
|
||||
{ "face_object", objIndex, rivalFacing }, -- 3
|
||||
{ "show_text", "_Route22RivalBeforeBattleText" .. n }, -- 4
|
||||
{ "rival_battle", oppClass, baseParty }, -- 5
|
||||
{ "jump_if_false", 11 }, -- 6
|
||||
{ "jump_if_false", 13 }, -- 6
|
||||
{ "set_flag", beatFlag }, -- 7
|
||||
{ "show_text", "_Route22Rival" .. n .. "DefeatedText" }, -- 8
|
||||
{ "show_text", "_Route22RivalAfterBattleText" .. n }, -- 9
|
||||
{ "walk_npc", objIndex, route22ExitDirs(n, py) }, -- 10
|
||||
{ "hide_object", "ROUTE_22", objName }, -- 11
|
||||
{ "play_music", "Music_MeetRival", { start = "rival",
|
||||
tempo = n == 2 and 100 or nil } }, -- 10
|
||||
{ "walk_npc", objIndex, route22ExitDirs(n, py) }, -- 11
|
||||
{ "play_default_music" }, -- scripts/Route22.asm:230
|
||||
{ "hide_object", "ROUTE_22", objName }, -- 13
|
||||
}
|
||||
end
|
||||
|
||||
@@ -594,7 +603,8 @@ M.ROUTE_22 = {
|
||||
if f.EVENT_BEAT_GIOVANNI and not f.EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE then
|
||||
return runAmbush(game, ow,
|
||||
route22Scene(2, 2, "ROUTE22_RIVAL2", "OPP_RIVAL2", 10,
|
||||
"EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE", y), playerFacing)
|
||||
"EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE", y), playerFacing,
|
||||
{ tempo = 100 })
|
||||
end
|
||||
return false
|
||||
end,
|
||||
@@ -618,12 +628,14 @@ local function ceruleanRivalScene(px, py)
|
||||
{ "face_object", 1, "down" }, -- 3
|
||||
{ "show_text", "_CeruleanCityRivalPreBattleText" }, -- 4
|
||||
{ "rival_battle", "OPP_RIVAL1", 7 }, -- 5
|
||||
{ "jump_if_false", 11 }, -- 6
|
||||
{ "jump_if_false", 13 }, -- 6
|
||||
{ "set_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 7
|
||||
{ "show_text", "_CeruleanCityRivalDefeatedText" }, -- 8
|
||||
{ "show_text", "_CeruleanCityRivalIWentToBillsText" }, -- 9
|
||||
{ "walk_npc", 1, ceruleanRivalExitDirs(px) }, -- 10
|
||||
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 11
|
||||
{ "play_music", "Music_MeetRival", { start = "rival" } }, -- 10
|
||||
{ "walk_npc", 1, ceruleanRivalExitDirs(px) }, -- 11
|
||||
{ "play_default_music" }, -- scripts/CeruleanCity.asm:230
|
||||
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 13
|
||||
}
|
||||
end
|
||||
|
||||
@@ -730,7 +742,7 @@ local JIGGLYPUFF_SILENCE, JIGGLYPUFF_STEP, JIGGLYPUFF_TAIL = 32, 24, 48
|
||||
-- Built as a TextBox `auto` table: auto.sound fires the frame the last
|
||||
-- page has typed out (PrintText returning), and auto.tick then runs once
|
||||
-- per frame while the gate it returns still reads as playing.
|
||||
local function jigglypuffDance(game, npc)
|
||||
local function jigglypuffDance(game, npc, ow)
|
||||
local Music = require("src.core.Music")
|
||||
-- .findMatchingFacingDirectionLoop: the rotation picks up at the entry
|
||||
-- matching the sprite's current facing (showMapText has just turned it
|
||||
@@ -776,7 +788,13 @@ local function jigglypuffDance(game, npc)
|
||||
if npc then npc.facing = JIGGLYPUFF_SPIN[step] end
|
||||
return
|
||||
end
|
||||
if frames >= JIGGLYPUFF_TAIL then phase = "done" end
|
||||
if frames >= JIGGLYPUFF_TAIL then
|
||||
phase = "done"
|
||||
if require("src.core.GameVersion").isYellow()
|
||||
and require("src.world.PikachuFollower").starterInParty(game.save) then
|
||||
ow.pikachuPewterSleepScene = true
|
||||
end
|
||||
end
|
||||
end,
|
||||
}
|
||||
end
|
||||
@@ -789,7 +807,7 @@ M.PEWTER_POKECENTER = {
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game,
|
||||
text(game)._PewterPokecenterJigglypuffText or "JIGGLYPUFF: Puu\npupuu!",
|
||||
done, { auto = jigglypuffDance(game, npc) }))
|
||||
done, { auto = jigglypuffDance(game, npc, ow) }))
|
||||
end,
|
||||
},
|
||||
}
|
||||
@@ -864,12 +882,14 @@ M.SILPH_CO_7F = {
|
||||
{ "face_object", 9, "up" }, -- 4
|
||||
{ "show_text", "_SilphCo7FRivalWaitedHereText" }, -- 5
|
||||
{ "rival_battle", "OPP_RIVAL2", 7 }, -- 6
|
||||
{ "jump_if_false", 12 }, -- 7
|
||||
{ "jump_if_false", 14 }, -- 7
|
||||
{ "set_flag", "EVENT_BEAT_SILPH_CO_RIVAL" }, -- 8
|
||||
{ "show_text", "_SilphCo7FRivalDefeatedText" }, -- 9
|
||||
{ "show_text", "_SilphCo7FRivalGoodLuckToYouText" }, -- 10
|
||||
{ "move_npc_to", 9, 5, y + 1 }, -- 11
|
||||
{ "hide_object", "SILPH_CO_7F", "SILPHCO7F_RIVAL" }, -- 12
|
||||
{ "play_music", "Music_MeetRival", { start = "rival" } }, -- 11
|
||||
{ "move_npc_to", 9, 5, y + 1 }, -- 12
|
||||
{ "play_default_music" }, -- scripts/SilphCo7F.asm:261
|
||||
{ "hide_object", "SILPH_CO_7F", "SILPHCO7F_RIVAL" }, -- 14
|
||||
}, "down")
|
||||
end,
|
||||
}
|
||||
@@ -899,12 +919,14 @@ M.SS_ANNE_2F = {
|
||||
{ "face_object", 2, onLeft and "down" or "right" }, -- 3
|
||||
{ "show_text", "_SSAnne2FRivalText" }, -- 4
|
||||
{ "rival_battle", "OPP_RIVAL2", 1 }, -- 5
|
||||
{ "jump_if_false", 11 }, -- 6
|
||||
{ "jump_if_false", 13 }, -- 6
|
||||
{ "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 7
|
||||
{ "show_text", "_SSAnne2FRivalDefeatedText" }, -- 8
|
||||
{ "show_text", "_SSAnne2FRivalCutMasterText" }, -- 9
|
||||
{ "walk_npc", 2, ssAnne2FRivalExitDirs(onLeft) }, -- 10
|
||||
{ "hide_object", "SS_ANNE_2F", "SSANNE2F_RIVAL" }, -- 11
|
||||
{ "play_music", "Music_MeetRival", { start = "rival" } }, -- 10
|
||||
{ "walk_npc", 2, ssAnne2FRivalExitDirs(onLeft) }, -- 11
|
||||
{ "play_default_music" }, -- scripts/SSAnne2F.asm:175
|
||||
{ "hide_object", "SS_ANNE_2F", "SSANNE2F_RIVAL" }, -- 13
|
||||
}, onLeft and "up" or "left")
|
||||
end,
|
||||
}
|
||||
|
||||
@@ -28,6 +28,14 @@
|
||||
-- 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).
|
||||
--
|
||||
-- `badgeSound` / `tmSound` are the text sound command each gym's reward
|
||||
-- text carries right after its FIRST label -- home/text.asm TextCommand_SOUND
|
||||
-- plays it once that page has typed out and then blocks on
|
||||
-- WaitForSoundToFinish, so the jingle sits between the pages rather than
|
||||
-- under them. macros/scripts/text.asm defines sound_level_up as
|
||||
-- sound_get_item_1, so Pewter's and Viridian's badge lines are Get_Item1
|
||||
-- too. Vermilion, Celadon and Fuchsia carry no sound on the badge text.
|
||||
|
||||
local function range(prefix, first, last)
|
||||
local t = {}
|
||||
@@ -50,6 +58,8 @@ return {
|
||||
{ "PEWTER_CITY", "PEWTERCITY_YOUNGSTER" },
|
||||
{ "ROUTE_22", "ROUTE22_RIVAL1" },
|
||||
},
|
||||
badgeSound = "Get_Item1", -- sound_level_up
|
||||
tmSound = "Get_Item1",
|
||||
dialogue = {
|
||||
"_PewterGymBrockReceivedBoulderBadgeText",
|
||||
"_PewterGymBrockBoulderBadgeInfoText",
|
||||
@@ -64,6 +74,8 @@ return {
|
||||
gotFlag = "EVENT_GOT_TM11",
|
||||
noRoom = "_CeruleanGymMistyTM11NoRoomText",
|
||||
deactivate = range("EVENT_BEAT_CERULEAN_GYM_TRAINER_", 0, 1),
|
||||
badgeSound = "Get_Key_Item",
|
||||
tmSound = "Get_Item1",
|
||||
dialogue = {
|
||||
"_CeruleanGymMistyReceivedCascadeBadgeText",
|
||||
},
|
||||
@@ -76,6 +88,7 @@ return {
|
||||
gotFlag = "EVENT_GOT_TM24",
|
||||
noRoom = "_VermilionGymLTSurgeTM24NoRoomText",
|
||||
deactivate = range("EVENT_BEAT_VERMILION_GYM_TRAINER_", 0, 2),
|
||||
tmSound = "Get_Key_Item",
|
||||
dialogue = {
|
||||
"_VermilionGymLTSurgeReceivedThunderBadgeText",
|
||||
},
|
||||
@@ -89,6 +102,7 @@ return {
|
||||
gotFlag = "EVENT_GOT_TM21",
|
||||
noRoom = "_CeladonGymTM21NoRoomText",
|
||||
deactivate = range("EVENT_BEAT_CELADON_GYM_TRAINER_", 0, 6),
|
||||
tmSound = "Get_Item1",
|
||||
dialogue = {
|
||||
"_CeladonGymErikaReceivedRainbowBadgeText",
|
||||
},
|
||||
@@ -102,6 +116,7 @@ return {
|
||||
gotFlag = "EVENT_GOT_TM06",
|
||||
noRoom = "_FuchsiaGymKogaTM06NoRoomText",
|
||||
deactivate = range("EVENT_BEAT_FUCHSIA_GYM_TRAINER_", 0, 5),
|
||||
tmSound = "Get_Key_Item",
|
||||
dialogue = {
|
||||
"_FuchsiaGymKogaReceivedSoulBadgeText",
|
||||
},
|
||||
@@ -115,6 +130,8 @@ return {
|
||||
gotFlag = "EVENT_GOT_TM46",
|
||||
noRoom = "_SaffronGymSabrinaTM46NoRoomText",
|
||||
deactivate = range("EVENT_BEAT_SAFFRON_GYM_TRAINER_", 0, 6),
|
||||
badgeSound = "Get_Key_Item",
|
||||
tmSound = "Get_Item1",
|
||||
dialogue = {
|
||||
"_SaffronGymSabrinaReceivedMarshBadgeText",
|
||||
},
|
||||
@@ -128,6 +145,8 @@ return {
|
||||
gotFlag = "EVENT_GOT_TM38",
|
||||
noRoom = "_CinnabarGymBlaineTM38NoRoomText",
|
||||
deactivate = range("EVENT_BEAT_CINNABAR_GYM_TRAINER_", 0, 6),
|
||||
badgeSound = "Get_Key_Item",
|
||||
tmSound = "Get_Item1",
|
||||
dialogue = {
|
||||
"_CinnabarGymBlaineReceivedVolcanoBadgeText",
|
||||
},
|
||||
@@ -141,6 +160,8 @@ return {
|
||||
gotFlag = "EVENT_GOT_TM27",
|
||||
noRoom = "_ViridianGymGiovanniTM27NoRoomText",
|
||||
deactivate = range("EVENT_BEAT_VIRIDIAN_GYM_TRAINER_", 0, 7),
|
||||
badgeSound = "Get_Item1", -- sound_level_up
|
||||
tmSound = "Get_Item1",
|
||||
dialogue = {
|
||||
"_ViridianGymGiovanniReceivedEarthBadgeText",
|
||||
},
|
||||
|
||||
@@ -36,7 +36,8 @@ M.CERULEAN_MELANIES_HOUSE = {
|
||||
rows[#rows + 1] = { "label", "declined" }
|
||||
rows[#rows + 1] = { "show_text", "MelanieText5" }
|
||||
end
|
||||
ow.runner:run(rows, { npc = npc, onDone = done })
|
||||
ow.runner:run(rows, { npc = npc, onDone = done,
|
||||
checkpointOnDone = "release_npc" })
|
||||
end,
|
||||
-- pet flavor: the text with the species' cry over it
|
||||
TEXT_CERULEANMELANIESHOUSE_BULBASAUR = {
|
||||
@@ -105,7 +106,8 @@ M.VERMILION_CITY = {
|
||||
rows[#rows + 1] = { "label", "declined" }
|
||||
rows[#rows + 1] = { "show_text", "_OfficerJennyText4" }
|
||||
end
|
||||
ow.runner:run(rows, { npc = npc, onDone = done })
|
||||
ow.runner:run(rows, { npc = npc, onDone = done,
|
||||
checkpointOnDone = "release_npc" })
|
||||
end,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -100,7 +100,8 @@ local function oldMan2Talk(game, ow, npc, done)
|
||||
game.stack:push(TextBox.new(game, text(game).losingMyTouch, done))
|
||||
return
|
||||
end
|
||||
ow.runner:run(oldMan2Rows(game, ow, npc), { npc = npc, onDone = done })
|
||||
ow.runner:run(oldMan2Rows(game, ow, npc), { npc = npc, onDone = done,
|
||||
checkpointOnDone = "release_npc" })
|
||||
end
|
||||
|
||||
M.VIRIDIAN_CITY = {
|
||||
|
||||
@@ -1,519 +0,0 @@
|
||||
# Behavior porting notes
|
||||
|
||||
What was ported from pokered's engine code and where it came from.
|
||||
|
||||
## Overworld
|
||||
|
||||
- **Collision rule** (`home/overworld.asm` tile-in-front checks): a 16x16
|
||||
cell is passable when its bottom-left 8x8 tile is in the tileset's
|
||||
`coll_tiles` list. Verified against Pallet Town's fences/houses/water
|
||||
and Oak's Lab furniture.
|
||||
- **Warp activation** (`home/overworld.asm` CheckWarpsNoCollision /
|
||||
ExtraWarpCheck): a warp fires when arriving on a warp whose standing
|
||||
tile is in the tileset's door or warp tile list, or when standing on a
|
||||
warp and walking off the map edge (interior exit mats). Both paths are
|
||||
data-driven from `door_tile_ids.asm` / `warp_tile_ids.asm`.
|
||||
- **LAST_MAP warps** return to the remembered outdoor map/position, like
|
||||
`wLastMap`.
|
||||
- **Connections** (`map_header` connection directives): crossing an edge
|
||||
places the player at `destCoord = curCoord - offset*2` cells on the
|
||||
destination's opposite edge.
|
||||
- **Movement**: tile-by-tile, 1 px/frame at 60 fps (16 frames per step),
|
||||
tap-to-turn without stepping, hold-to-walk, input locked mid-step.
|
||||
- **Wild encounters** (`engine/battle/wild_encounters.asm`): per grass
|
||||
step, encounter iff `rand(0..255) < rate`; slot picked via the
|
||||
cumulative buckets 51/102/141/166/191/216/229/242/253/256.
|
||||
- **Initial object visibility** from `toggleable_objects.asm` (e.g. Oak
|
||||
hidden in his lab), with `show_object`/`hide_object` script commands
|
||||
persisting to the save like the missable-object bits.
|
||||
|
||||
## Pokémon math (`engine/pokemon/calc_stats.asm`, `experience.asm`)
|
||||
|
||||
- `stat = floor(((base + DV)*2 + floor(sqrt(statExp)/4)) * L / 100) + 5`
|
||||
(HP: `+ L + 10`); HP DV from the low bits of the other four DVs.
|
||||
- Growth curves use the exact cubic coefficients (MEDIUM_SLOW =
|
||||
1.2n^3 - 15n^2 + 100n - 140, etc).
|
||||
- Exp gain = `floor(baseExp * level / 7)` (x1.5 for trainer battles);
|
||||
defeated species' base stats accumulate as stat experience.
|
||||
|
||||
## Battle core (`engine/battle/core.asm`)
|
||||
|
||||
- Damage: `floor(floor(2L(x2 crit)/5 + 2) * power * atk / def / 50)`
|
||||
capped at 997, `+2`, STAB x1.5, per-matchup type multipliers applied
|
||||
sequentially (x10 fixed point), then `rand(217..255)/255` when
|
||||
damage > 1.
|
||||
- Critical hits: `rand(0..255) < baseSpeed/2` (x4 for Karate Chop, Razor
|
||||
Leaf, Crabhammer, Slash, capped 255); crits double level and ignore
|
||||
stat stages (gen1_faithful ruleset).
|
||||
- Accuracy: `rand(0..255) < floor(acc*255/100)` after accuracy/evasion
|
||||
stages, including the 1/256 miss at 100% accuracy (toggleable via the
|
||||
`modern_clean` ruleset).
|
||||
- Stat stages use the 25/28/33/40/50/66/100/150/.../400 multiplier table
|
||||
(`data/battle/stat_modifiers.asm`).
|
||||
- Physical/special split by type (special = Water/Grass/Fire/Ice/
|
||||
Electric/Psychic/Dragon).
|
||||
- Status: paralysis speed/4 and 25% full para, burn halves physical
|
||||
attack, poison/burn residual = maxHP/16, sleep 1-7 turns waking on the
|
||||
lost turn, freeze permanent (as in Gen 1).
|
||||
- Turn order: effective speed, coin-flip ties; Quick Attack first,
|
||||
Counter last (Gen 1's only priorities).
|
||||
- Run formula (`TryRunningFromBattle`): always escape if faster,
|
||||
otherwise `floor(pSpd*32 / (eSpd/4)) + 30*attempts` vs `rand(0..255)`.
|
||||
- Catching (`ItemUseBall`): ball-specific rand ranges (255/200/150),
|
||||
status bonus 25/12, second roll `floor(maxHP*255/ballFactor) /
|
||||
floor(HP/4)` capped 255.
|
||||
- Prize money: class base money x last defeated mon's level
|
||||
(`pic_pointers_money.asm`).
|
||||
|
||||
## Battle move effects (engine/battle/core.asm, move_effects/*)
|
||||
|
||||
- Mimic via Metronome (effects.asm:1203-1273): MimicEffect's
|
||||
.letPlayerChooseMove branch snapshots wCurrentMenuItem before the
|
||||
copy-picker menu opens and restores it afterward as the write index
|
||||
into wBattleMonMoves. Since SelectMenuItem always writes
|
||||
wCurrentMenuItem/wPlayerMoveListIndex together at the FIGHT-menu
|
||||
confirm and nothing (including MetronomePickMove) touches either
|
||||
variable during mid-move resolution, the reused value is always the
|
||||
calling move's own slot, BattleState.lua's applyMimic fallback uses
|
||||
self.moveIndex, frozen the same way, so a called Mimic (e.g. from
|
||||
METRONOME in slot 3) overwrites the calling move's own slot, keeping
|
||||
its PP, matching the Gen 1 quirk exactly.
|
||||
- Multi-hit distribution 2/2/2/3/3/3/4/5 over rand(0..7); all hits reuse
|
||||
the first damage roll (faithful).
|
||||
- Recoil = damage/4 (Struggle /2); drain/Dream Eater heal = damage/2;
|
||||
Dream Eater requires sleep.
|
||||
- Fixed damage: SonicBoom 20, Dragon Rage 40, Seismic Toss/Night Shade =
|
||||
level, Psywave rand(1 .. 1.5xlevel-1).
|
||||
- OHKO deals 65535, fails against faster targets; Swift skips accuracy;
|
||||
Jump Kick crash = 1 damage on miss; Explosion halves defense and
|
||||
faints the user even on a miss; Hyper Beam skips recharge if it KOs.
|
||||
- Charge moves (incl. Fly's invulnerable turn), trapping moves locking
|
||||
the victim out of its turns, Thrash's 3-4 turn lock ending in
|
||||
confusion, Bide's 2-3 turn store-and-double, Rage's permanent lock
|
||||
with attack-up on being hit, Counter/Quick Attack priority.
|
||||
- Side-effect chances: 26/256 (10%), 77/256 (30%), stat-down side
|
||||
effects 85/256; Twineedle 20% poison.
|
||||
- Substitute costs 1/4 max HP, absorbs damage, blocks status/stat/side
|
||||
effects; screens double effective defense (bypassed by crits); Focus
|
||||
Energy keeps the Gen 1 quarter-rate bug under gen1_faithful.
|
||||
- Status: sleep 1-7 turns (wake turn is lost), freeze permanent, burn
|
||||
halves physical attack, paralysis speed/4 + 25% full para, Toxic's
|
||||
rising counter, Leech Seed transfer, confusion 2-5 turns with 50%
|
||||
40-power typeless self-hit.
|
||||
- Trainer Pokémon use fixed DVs 9/8/8/8 (TrainerAI.asm convention).
|
||||
|
||||
## Items (engine/items/item_effects.asm)
|
||||
|
||||
- Potion family 20/50/200/full; drinks 50/60/80; status heals per item;
|
||||
Revive half HP; Rare Candy = exact next-level exp with HP delta kept;
|
||||
evolution stones use the extracted evos data; TMs single-use / HMs
|
||||
reusable, gated by the species' real tmhm list; Repel 100/200/250
|
||||
steps blocking wilds below the lead's level; Escape Rope returns to
|
||||
the last heal point.
|
||||
- Snorlax (Route 12/16) only wakes via `ItemUsePokeFlute` (item-use
|
||||
menu, adjacent to it, not yet beaten), talking to it with the POKé
|
||||
FLUTE merely in the bag has no effect (`engine/items/item_effects.asm`,
|
||||
`scripts/Route12.asm`/`Route16.asm`).
|
||||
- Mart inventories come from the script_mart lists per clerk; selling
|
||||
pays half price; TM prices from tm_prices.asm.
|
||||
|
||||
## Overworld field systems
|
||||
|
||||
- Ledges from ledge_tiles.asm (facing + standing tile + ledge tile +
|
||||
input direction -> two-cell hop).
|
||||
- Counter talk-through uses the tileset's counter tiles
|
||||
(tileset_headers.asm), which is how mart clerks and nurses work.
|
||||
- Trainer sight (`home/trainers.asm` CheckFightingMapTrainers +
|
||||
`engine/overworld/trainer_sight.asm`): extracted per-trainer range,
|
||||
inclusive tiles along the facing line; detection runs only on
|
||||
tile-aligned frames, before input handling, so on detection the d-pad
|
||||
is dead (wJoyIgnore) and the player freezes on the spotted tile; the
|
||||
"!" holds 60 frames (EmotionBubble), then the trainer walks
|
||||
distance−1 steps to the adjacent tile (none if already adjacent) and
|
||||
uses the real battle/won/after dialogue from the trainer headers.
|
||||
Sight is a pure screen-coordinate comparison with no line-of-sight
|
||||
obstruction check (TrainerEngage / CheckSpriteCanSeePlayer): an
|
||||
aligned in-range trainer engages through interposed NPCs and
|
||||
unwalkable tiles, and the walk-up (TrainerWalkUpToPlayer, a fixed
|
||||
distance−1 MoveSprite_ script) has no collision either, so the
|
||||
trainer simply walks/overlaps through anything on the line, as OAM
|
||||
sprites overlap on hardware.
|
||||
- Elevator rides (`engine/overworld/elevator.asm` ShakeElevator →
|
||||
`src/world/ElevatorShake.lua`): choosing a floor stops the music,
|
||||
bounces the BG scroll ±1 px around rest for 100 two-frame cycles with
|
||||
SFX_COLLISION retriggered every cycle, restores the scroll, plays
|
||||
SFX_SAFARI_ZONE_PA to completion, and restarts the map theme before
|
||||
the floor warp. Lead-in delays kept per script: 9 frames of Delay3s
|
||||
inside ShakeElevator (Celadon farjps in), 12 with the Silph/Rocket
|
||||
scripts' extra Delay3. The offset applies to the BG layer only,
|
||||
sprites are OAM and stay put. After the ride the port no longer
|
||||
jump-cuts: choosing a floor rewrites the car's own exit-warp entries
|
||||
to that floor (`engine/events/elevator.asm` DisplayElevatorFloorMenu
|
||||
.UpdateWarp, per scripts/SilphCoElevator.asm /
|
||||
CeladonMartElevator.asm / RocketHideoutElevator.asm), then the player
|
||||
is walked out through the doorway onto that warp (ow:scriptMove →
|
||||
ow:takeWarp), like the original.
|
||||
- Field-move gates (engine/overworld/field_move_messages.asm +
|
||||
start_sub_menus.asm): IsSurfingAllowed ported exactly, SURF refuses
|
||||
with _CyclingIsFunText while the Cycling Road's BIT_ALWAYS_ON_BIKE is
|
||||
armed (save.forcedBike: set on the Route 16/18 forced-bike tiles,
|
||||
cleared by the gates, Fly, dungeon/blackout warps; the forced mount
|
||||
itself is silent, as in CheckForceBikeOrSurf) and with
|
||||
_CurrentTooFastText on Seafoam B4F's stairs square (7,11) until both
|
||||
EVENT_SEAFOAM4 boulders are down. Re-selecting SURF while surfing is
|
||||
ItemUseSurfboard's dismount attempt: steps ashore silently if the
|
||||
facing tile is land-passable and unoccupied, else "There's no place
|
||||
to get off!", and the menu closes either way (wActionResult stays 1).
|
||||
STRENGTH's first page auto-advances after the cry + Delay3 (no
|
||||
prompt); "can move boulders." prompts. The GBPalWhiteOutWithDelay3
|
||||
white blink plays on every .goBackToMap closer: Strength, surf
|
||||
mount/dismount/no-place, Flash (after its text), and Dig/Teleport
|
||||
(Cut closes without a blink, per the asm).
|
||||
- Wild slot table + rate per map; water encounter tables used while
|
||||
surfing.
|
||||
- Cut-tree block swaps from cut_tree_blocks.asm; surfable tilesets from
|
||||
water_tilesets.asm (water tile $14, plus $32 on SHIP_PORT).
|
||||
|
||||
## Story events (data/scripts/story.lua and friends)
|
||||
|
||||
- Every hand-ported script cites its scripts/*.asm source and reuses the
|
||||
real extracted text and event-flag names.
|
||||
- Custom flag names (audited equivalent): three port-internal flag
|
||||
families have no pokered EVENT constant but mirror the original's
|
||||
state exactly. EVENT_TRADED_* are per-trade names for
|
||||
wCompletedInGameTradeFlags bits (engine/events/in_game_trades.asm:
|
||||
FLAG_TEST before the offer → after-trade text, FLAG_SET on completion;
|
||||
dialogset text families, party-menu pick, the received mon joins the
|
||||
end of the party, ConnectCable→anim→TradedFor→Thanks all ported).
|
||||
EVENT_GOT_EEVEE is bookkeeping alongside the real guard, the hidden
|
||||
ball object (scripts/CeladonMansionRoofHouse.asm HideObject, ≡
|
||||
save.objectToggles), and self-heals older saves; a full party+box
|
||||
keeps the ball claimable (_BoxIsFullText). EVENT_BEAT_SS_ANNE_RIVAL
|
||||
stands in for scripts/SSAnne2F.asm's saved wSSAnne2FCurScript NOOP
|
||||
progression, including the lose-and-retrigger path (flag only set on
|
||||
victory). Names are kept for save compatibility. Coverage:
|
||||
tests/parity_trade_gift.lua.
|
||||
- The Pallet Town intro follows pokered exactly: the trigger is
|
||||
PalletTownDefaultScript's wYCoord==1 check, Oak appears at (8,5) and
|
||||
takes FindPathToPlayer's zigzag to one tile below the player, and the
|
||||
escort is RLEList_ProfOakWalkToLab against the reverse-order playback
|
||||
of RLEList_PlayerWalkToLab (the 17th simulated press is eaten by the
|
||||
door-warp frame), followed by the OaksLab walk-in and choose-mon
|
||||
exchange with map music deferred like BIT_NO_MAP_MUSIC. Oak's speech
|
||||
ends with the real shrink: RedPicFront collapses through the extracted
|
||||
ShrinkPic1/ShrinkPic2 into the overworld walking sprite on
|
||||
OakSpeech.asm's frame timings (SFX_SHRINK, 4/4/20/50-frame beats, fade
|
||||
to white), with the closing text box held on screen. The escort's
|
||||
scripted steps run 16 frames/tile (chained single-tile scriptMoves
|
||||
start back-to-back, no idle frame); Oak marches in place on the door
|
||||
mat for RLEList_ProfOakWalkToLab's trailing NPC_CHANGE_FACING beat
|
||||
(movement.asm ChangeFacingDirection → zero-delta TryWalking); the "!"
|
||||
EmotionBubble overlaps the still-shown "Hey! Wait!" box
|
||||
(PalletTownOakText prints without a button wait, then DelayFrames 10 →
|
||||
EmotionBubble before the box clears); and the shrink beat ramps the
|
||||
music to silence over ~70 frames (wAudioFadeOutControl = 10;
|
||||
home/fade_audio.asm FadeOutAudio steps rAUDVOL 7→0) rather than
|
||||
hard-stopping.
|
||||
- The 12 disguised static wild battles (Power Plant Voltorb/Electrode +
|
||||
Zapdos, Articuno, Moltres, Mewtwo) follow TalkToTrainer/
|
||||
EndTrainerBattle exactly: cry + battle text, after-battle text without
|
||||
a rematch once EVENT_BEAT_* is set, and the flag/HideObject on any
|
||||
non-blackout result (fleeing loses the legendary, as in Gen 1).
|
||||
Snorlax hides before its battle and only shows the calmed-down/
|
||||
returned line when not caught. Zapdos/Articuno/Moltres/Mewtwo's
|
||||
battle text is a text_far string ending in a bare "...@" terminator
|
||||
(no <DONE>/<PROMPT>) followed by text_asm PlayCry + WaitForSoundToFinish:
|
||||
the box types with no ▼ prompt and auto-closes only once the cry
|
||||
finishes, never on a button press, ported via `Commands.play_cry`
|
||||
stashing the pending cry for the following `Commands.show_text` to
|
||||
consume as the TextBox's auto-close sound. Voltorb/Electrode's battle
|
||||
text has no PlayCry call in the ROM at all and keeps the ordinary
|
||||
button-wait close.
|
||||
- Gym leader repeat dialogue (data/scripts/gyms.lua): each leader's
|
||||
text_asm branches on EVENT_BEAT_<LEADER>, pre-badge talk prints the
|
||||
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,
|
||||
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
|
||||
permanently via TOGGLE_VIRIDIAN_GYM_GIOVANNI in save.objectToggles.
|
||||
- Cable Club receptionists (TX_SCRIPT_CABLE_CLUB_RECEPTIONIST →
|
||||
CableClubNPC, all 12 Pokémon Centers): welcome, pre-Pokédex "making
|
||||
preparations" brush-off, and the apply/save YES-NO are ported;
|
||||
accepting saves the game and opens the link menu, declining prints
|
||||
"Please come again!".
|
||||
- Cinnabar fossil deposit follows GiveFossilToCinnabarLab: a menu of
|
||||
carried fossils (FossilsList order), SeesFossilText with a Yes/No
|
||||
confirm, ComeAgainText on either cancel.
|
||||
- Hall of Fame induction: each party mon's front sprite scrolls in from
|
||||
the left at 4px/frame, matching HoFShowMonOrPlayer's .ScrollPic
|
||||
front-pic phase (engine/movie/hall_of_fame.asm); the back-pic's
|
||||
enlarged/blurred pre-wipe is a VRAM-scroll-register trick not
|
||||
replicated in this sprite-based renderer. The finale
|
||||
(HoFDisplayPlayerStats) shows trainer name, play time, money, POKéDEX
|
||||
seen/owned, and Prof. Oak's rating text (engine/events/
|
||||
pokedex_rating.asm DexRatingsTable) from real save data.
|
||||
- End credits + post-game reset (engine/movie/credits.asm,
|
||||
scripts/HallOfFame.asm): screen-by-screen CreditsOrder pages (hlcoord
|
||||
9,6 + signed columns), FadeInCredits' 4x5-frame ramp, 90/110/120/140-
|
||||
frame holds, DisplayCreditsMon's 27-frame 8px/frame silhouette wipe,
|
||||
LoadCopyrightTiles' three-row block, THE END at (4,8). While THE END
|
||||
is up the HoF script autosaves (wLastBlackoutMap := PALLET_TOWN; the
|
||||
player is saved in the HALL_OF_FAME room), waits 600 frames, then A/B
|
||||
triggers `jp Init`, the boot sequence replays into the title screen.
|
||||
- Victory Road's boulder switches replicate the original's
|
||||
ReplaceTileBlock data: 1F boulder at (17,13) -> block $1D at (4,6);
|
||||
2F boulders at (1,16)/(9,16) -> $15 at (3,4) and $1D at (11,7); 3F
|
||||
boulder at (3,5) -> $1D at (3,5), and the (23,15) hole drops the
|
||||
boulder to 2F (hide/show toggle). Barriers are re-applied from flags
|
||||
on map entry, exactly like the originals' map-load scripts.
|
||||
- Item balls, static legendary encounters and trainer rewards
|
||||
(badges + gym TMs, the Silph Giovanni flag) are generic systems driven
|
||||
by the extracted object args and a hand-ported reward table
|
||||
(data/scripts/victories.lua).
|
||||
- In-game trades use the real data/events/trades.asm table (species in,
|
||||
species out, original nickname).
|
||||
|
||||
## Safari game (engine/events/hidden_events/safari_game.asm + engine/battle)
|
||||
|
||||
- ¥500 buys 30 SAFARI BALLs and 502 steps (scripts/SafariZoneGate.asm
|
||||
sets `wSafariSteps = 502`); steps count down on the four outdoor zone
|
||||
maps and hitting 0 (or throwing the last ball) ends the game at the
|
||||
gate.
|
||||
- Safari battles offer BALL / BAIT / ROCK / RUN; no player Pokémon
|
||||
acts. The working catch rate starts at the species rate; BAIT halves
|
||||
it and adds 1-5 to the bait factor (zeroing the escape factor); ROCK
|
||||
doubles it (cap 255) and adds 1-5 to the escape factor (zeroing bait)
|
||||
-- ItemUseBait/ItemUseRock in engine/items/item_effects.asm.
|
||||
- Each turn one factor decays ("is eating!" / "is angry!"); when the
|
||||
escape factor decays to 0 the catch rate resets to the species rate
|
||||
(PrintSafariZoneBattleText, engine/battle/safari_zone.asm).
|
||||
- Flee check (engine/battle/core.asm): `b = 2 * (speed % 256)`; the mon
|
||||
always flees when speed > 127; while eating `b /= 4`, while angry
|
||||
`b = min(255, 2b)`; it flees when `rand(0,255) < b`.
|
||||
- The SAFARI BALL rolls the ULTRA_BALL rand range (0-150) in the Gen 1
|
||||
catch formula, against the BAIT/ROCK-modified rate.
|
||||
|
||||
## Slot machines (engine/slots/slot_machine.asm)
|
||||
|
||||
- The three reels are the extracted 18-symbol wheel sequences
|
||||
(data/events/slot_machine_wheels.asm); bet 1 plays the middle row,
|
||||
bet 2 adds top+bottom, bet 3 adds both diagonals.
|
||||
- Payouts: 7-7-7 = 300, BAR = 100, CHERRY = 8, MOUSE/FISH/BIRD = 15
|
||||
(SlotRewardPointers).
|
||||
- Per-wheel stop/slip rules ported exactly: wheel 1 spends up to 4 slip
|
||||
charges, slipping past a centred CHERRY (in seven-and-bar mode it
|
||||
always slips all 4 via pokered's `cp HIGH(SLOTS7)` bug); wheel 2 stops
|
||||
as soon as wheels 1+2 line up any potential match (pairs checked b/b,
|
||||
b/m, m/m, t/m, t/t) or, in seven-and-bar mode, on 7/BAR; wheel 3 rolls
|
||||
past forbidden matches free and burns wSlotMachineRerollCounter
|
||||
charges on winnable no-match spins, animated tile-by-tile. Luck flags
|
||||
(SetFlags): seven-and-bar mode is sticky across spins; r==0 arms 60
|
||||
allow-matches charges; a BAR win clears flags; a 300 win zeroes the
|
||||
counter and clears flags with probability 128/256; 8/15 wins burn one
|
||||
charge. Lines are checked in asm order with the first match taken;
|
||||
A-presses are ignored while a prior wheel's slip counter is nonzero.
|
||||
Machine and COIN CASE texts are byte-identical
|
||||
(_GameCorner*Text; AbleToPlaySlotsCheck's no-coins gate included).
|
||||
- Flow brackets: PromptUserToPlaySlots "A slot machine! Want to play?"
|
||||
(YesNoChoice) and MainSlotMachineLoop's "One more go?" (TwoOptionMenu);
|
||||
the x3/x2/x1 coin menu (CoinMultiplierSlotMachineText) defaults its
|
||||
cursor to x3, bet = 3 - menu item. Static frame: the real
|
||||
SlotMachineMap (gfx/slots/slots.tilemap, 20x12 tile ids < $25) blitted
|
||||
from red_slots_1.png, extracted as field.slotSymbols.tilemap
|
||||
(tools/extract/gfx.py extract_slots). Win flash:
|
||||
SlotMachine_CheckForMatches.flashScreenLoop flips rBGP (shade 3->2) b
|
||||
times at 5 frames each, b = 20/8/4/2 for the 300/100/15/8 rewards
|
||||
(SlotReward{300,100,8,15}Func). Payout drip:
|
||||
SlotMachine_PayCoinsToPlayer credits one coin every 8 frames (4 for a
|
||||
7/BAR), SFX_SLOTS_REWARD per coin, rOBP0 symbol flicker every 5 coins.
|
||||
|
||||
## Spinner arrow tiles (scripts/*.asm arrow movement tables)
|
||||
|
||||
- Viridian Gym and Rocket Hideout B2F/B3F keep per-coordinate RLE
|
||||
movement lists (map_coord_movement); each list executes backwards
|
||||
from its terminator (DecodeArrowMovementRLE), sliding the player and
|
||||
chaining onto further arrows.
|
||||
|
||||
## Cries (data/pokemon/cries.asm, audio/engine_1.asm)
|
||||
|
||||
- Each species = a base cry (one of 38 SFX_CryXX streams) + a frequency
|
||||
modifier added to every note's frequency register
|
||||
(Audio1_ApplyFrequencyModifier) + a tempo modifier
|
||||
(`sfx tempo = $80 + length`, Audio1_SetSfxTempo). All 151 cries are
|
||||
rendered offline with those modifiers applied and play on battle
|
||||
entry and Pokédex pages.
|
||||
|
||||
## Hidden events & facility puzzles
|
||||
|
||||
- Card key doors (engine/events/card_key.asm): door tiles $18/$24
|
||||
(SILPH_CO_11F: $5e) replaced with block $0e ($03 on 11F).
|
||||
- Vermilion trash cans
|
||||
(engine/events/hidden_events/vermilion_gym_trash.asm): the first-lock
|
||||
can re-rolls on every Vermilion City map load (VermilionCity_Script's
|
||||
Random & $e, even cans) and after every failed second-can guess; the
|
||||
second lock uses the GymTrashCans table verbatim, including the
|
||||
underflow bug that can place it in can 0 regardless of adjacency; a
|
||||
wrong pick resets EVENT_1ST_LOCK_OPENED and re-rolls immediately; only
|
||||
SuccessText3 prints on completion; the gym door block at (2,2) is
|
||||
$24 closed / $5 open (scripts/VermilionGym.asm). SuccessText1/
|
||||
SuccessText3/FailText play SFX_SWITCH/GO_INSIDE/DENIED from each
|
||||
text's text_asm tail after the text prints (DisplayTextID's
|
||||
WaitForTextScrollButtonPress then holds the box), so the port fires
|
||||
them from an onDone on the TextBox, landing the beep as the box
|
||||
closes rather than as it opens.
|
||||
- Menu close-keys follow pokered's per-menu wMenuWatchedKeys mask, not
|
||||
a single global rule: the shared Menu base (src/ui/Menu.lua) closes
|
||||
on B only, and START-close is opt-in via opts.startCloses. Only the
|
||||
start menu sets it, matching engine/menus/draw_start_menu.asm's
|
||||
PAD_DOWN|PAD_UP|PAD_START|PAD_B|PAD_A; OptionsMenu also closes on
|
||||
START via its own loop, matching engine/menus/main_menu.asm
|
||||
DisplayOptionMenu's explicit B_PAD_B/B_PAD_START checks. Every other
|
||||
menu (bag/PC item lists PAD_A|PAD_B|PAD_SELECT, party menu /
|
||||
BUY-SELL-QUIT / USE-TOSS submenu / PC menus / Pokedex side menu
|
||||
PAD_A|PAD_B) leaves PAD_START unwatched, so START does not close
|
||||
them. START never replays SFX_PRESS_AB (HandleMenuInput_ beeps only
|
||||
for the PAD_A|PAD_B branch).
|
||||
- Old man tutorial hollow cursor: the item list is itself scripted in
|
||||
pokered (DisplayListMenuID's old-man branch, home/list_menu.asm:65-91)
|
||||
, no input is read; the filled '▶' hovers POKé BALL for 80 frames,
|
||||
auto-presses A, then PlaceUnfilledArrowMenuCursor leaves the hollow
|
||||
'▷' on that row until ItemUseBall tears the list down for the throw.
|
||||
Ported via ListMenu's opts.script hook (src/ui/ListMenu.lua) and
|
||||
BattleState:openOldManBag driving the same beats. The MissingNo./
|
||||
wGrassRate side effects of the OLD MAN name swap are not modeled,
|
||||
see docs/gameboy-hardware-limitations.md.
|
||||
- Gym statues (gym_statues.asm): plaque with the city/leader from each
|
||||
gym's script; the player joins WINNING TRAINERS with the badge.
|
||||
- Route 22 gate / Route 23 guards: real trigger rows, badge order
|
||||
(EARTH down to CASCADE) and EVENT_PASSED_*_CHECK skip flags.
|
||||
- Game Corner poster (scripts/GameCorner.asm): block (8,2) $2a -> $43
|
||||
on EVENT_FOUND_ROCKET_HIDEOUT.
|
||||
- Seafoam Islands (scripts/SeafoamIslandsB3F/B4F.asm): reversed-RLE
|
||||
current paths, Seafoam4HolesCoords boulder holes setting the
|
||||
EVENT_SEAFOAM*_BOULDER*_DOWN_HOLE pairs, the forced pool exit rows.
|
||||
- Rock Tunnel darkness: wMapPalOffset = 6 on entry, cleared by Flash
|
||||
(BOULDERBADGE) or leaving (home/overworld.asm).
|
||||
|
||||
## Battle extras
|
||||
|
||||
- GROWL/ROAR (GetMoveSound/IsCryMove, engine/battle/animations.asm
|
||||
~2196): the move's own MoveSoundTable tempo byte (Growl $c0, Roar
|
||||
$40, both pitch $00) layers onto the cry via `Sound.playMoveCry`'s
|
||||
`Source:setPitch(256/(128+tempoMod))`. Transform (engine/gfx/
|
||||
palettes.asm DeterminePaletteID, bit TRANSFORMED): the swapped-in pic
|
||||
is tinted PAL_GRAYMON via `PaletteFX.monPal(data, species,
|
||||
transformed)`, not the copied species' own palette, in
|
||||
`BattleState:speciesSprite`. Growl (DoGrowlSpecialEffects,
|
||||
animations.asm ~928): AnimPlayer's GROWL frame-block branch keeps a
|
||||
`growlNoteTrail` snapshot so each block's emitted sprites include the
|
||||
previous block's note copy alongside the current one (GROWL skips
|
||||
AnimationCleanOAM between blocks per the `cp GROWL` check ~line 145);
|
||||
ROAR is unaffected since the asm never applies this quirk to it.
|
||||
- Master/Ultra ball tosses flicker the OBJ palette: DoBallTossSpecial
|
||||
Effects (engine/battle/animations.asm:685) XORs rOBP0 with %00111100
|
||||
after every frame block while wCurItem <= ULTRA_BALL, so the 11 toss
|
||||
blocks alternate the $F0/$CC shade maps starting normal; PlayAnimation
|
||||
pushes/pops rOBP0 around each subanimation row, so the ambient
|
||||
palette returns when the toss ends. GREAT/POKE/SAFARI balls never
|
||||
flicker, and the toss arc always follows wCurItem via
|
||||
TossBallAnimation, including the ghost-dodge throw.
|
||||
- Anim-layer OBJ colorization is per 8x8 attribute cell: the SGB's
|
||||
ATTR_BLK regions color the composited DMG picture per cell, not per
|
||||
OAM entry, so an anim sprite overlapping a zone boundary takes each
|
||||
cell's palette on the pixels inside it, AnimPlayer samples the zone
|
||||
under every cell an 8x8 tile touches and repaints differing cells
|
||||
through a cell-clipped scissor (aligned tiles stay one draw).
|
||||
- Ball wobbles (ItemUseBall): Z = X*Y/255 + status2 with
|
||||
Y = rate*100/ballFactor2; <10/<30/<70 -> 0/1/2 shakes, else 3, with
|
||||
the matching ItemUseBallText01-04 lines.
|
||||
- Trainer class AI (data/trainers/ai_pointers.asm +
|
||||
engine/battle/trainer_ai.asm): per-class item/switch routines with
|
||||
wAICount uses per Pokémon, ported to data/scripts/ai_classes.lua.
|
||||
- Exp (engine/battle/experience.asm): baseExp*level/7 divided by the
|
||||
participant count, x1.5 for trainers, x1.5 for traded mons; stat exp
|
||||
in full to each participant.
|
||||
- Move sounds: data/moves/sfx.asm (sound + pitch/tempo per move). The
|
||||
pitch/tempo modifiers are applied at synthesis time
|
||||
(Audio2_ApplyFrequencyModifier adds pitch to every frequency write;
|
||||
Audio2_SetSfxTempo scales tone-channel note lengths, noise skips it),
|
||||
128 variant WAVs keyed "<sfx>@<pitch><tempo>" that Sound.playMove
|
||||
selects, exact rather than a playback-rate approximation. Per-row
|
||||
sounds fire as PlayAnimation does; GROWL/ROAR (IsCryMove) play the
|
||||
attacker's cry. Hit sounds by effectiveness (Damage/Super/NotVery).
|
||||
- Screen-effect animations (engine/battle/animations.asm +
|
||||
engine/gfx/screen_effects.asm): every SE_* is implemented per-routine,
|
||||
FlashScreen/FlashScreenLong (the FlashScreenLongSGB 12-entry table),
|
||||
Dark/Light/DarkenMon/Reset palette ops (shade-map permutations of the
|
||||
SGB zone palettes), all SlideMon variants, ShakeBackAndForth,
|
||||
BoundUpAndDown, SquishMonPic, Minimize (real MinimizedMonSprite),
|
||||
spiral/shoot-balls/water-droplets/leaves emitters compiled from the
|
||||
asm trajectories, per-animation-id frame-block flashes (Explosion,
|
||||
Rock Slide's rumbles, Blizzard's cadence...), AnimationWavyScreen with
|
||||
true per-scanline offsets, PredefShakeScreenHorizontally/Vertically
|
||||
and ShakeEnemyHUD. SE rows carry the faithful blocking durations.
|
||||
- SGB battle colorization (SetPal_Battle, BlkPacket_Battle,
|
||||
SetAnimationPalette): the battle screen is colorized by zone, player
|
||||
HUD, enemy HUD, player mon + message box, enemy mon; trainer front
|
||||
pics and the player/old-man back pics take PAL_MEWMON (both species
|
||||
IDs are zero at the intro, so MonsterPalettes[0]); the ghost keeps the
|
||||
disguised species' palette; attack animation sprites and thrown balls
|
||||
are colored through the OBJ palettes (wAnimPalette $F0 on SGB, ambient
|
||||
$E4, OBP1 $6C). Headless/no-shader environments fall back to the flat
|
||||
pipeline.
|
||||
- Mimic resolves mid-move (MimicEffect): accuracy first, then the
|
||||
player's copy menu (enemy/link copy a random slot); the copy
|
||||
overwrites only the slot's move ID, PP is shared with Mimic's slot,
|
||||
and reverts on switch/battle end.
|
||||
- Old man tutorial (DisplayBattleMenu's BATTLE_TYPE_OLD_MAN branch): the
|
||||
real scripted cursor, ▶ beside FIGHT for 80 frames, beside ITEM for
|
||||
50, ITEM force-selected into the POKé BALL x50 list; the throw always
|
||||
catches at full HP (item_effects.asm jumps straight to .captured, 3
|
||||
shakes, no party/dex add, no ball consumed); backing out of the bag
|
||||
replays the script. The old man never attacks, the original tutorial
|
||||
is menu navigation + a guaranteed catch, nothing more.
|
||||
|
||||
## Link battles (lockstep)
|
||||
|
||||
- Both sides simulate with a shared Park-Miller RNG stream (host deals
|
||||
the seed), identical pack/unpack-clamped party copies, no badge
|
||||
boosts, and a mirrored speed-tie roll (the guest inverts it); a
|
||||
canonical host-side-first state hash is exchanged per turn and any
|
||||
mismatch ends the match as a draw.
|
||||
|
||||
## Music (audio/engine_1.asm)
|
||||
|
||||
- Note duration: `frames = length * speed * tempo / 0x100` with
|
||||
fractional carry, at 60 fps (Audio1_note_length / CalculateDelay).
|
||||
- Frequency: `reg = pitches[note] asr (octave - 1)` (CalculateFrequency;
|
||||
the octave byte stores `8 - octave`), `f = 131072/(2048 - reg)` for
|
||||
squares, halved for channel 3.
|
||||
- note_type volume/fade renders as an NRx2-style envelope (step every
|
||||
`fade/64` s); duty_cycle maps to 12.5/25/50/75% pulse widths;
|
||||
sound_call/sound_loop honor the engine's one-level call stack and
|
||||
loop counters.
|
||||
|
||||
## Text & font
|
||||
|
||||
- The Pokédex height row uses the real ′/″ tiles: gfx/pokedex/pokedex.png
|
||||
tiles 0/1 are patched over font-extra slots $60/$61 exactly as
|
||||
engine/gfx/load_pokedex_tiles.asm loads them over vChars2 (they replace
|
||||
glyphs charmap.asm marks unused); ASCII `"` aliases to the closing-
|
||||
quote glyph $73 so stray hand-written quotes render.
|
||||
|
||||
## Validation against the original
|
||||
|
||||
- `tests/run_tests.lua` pins hand-checked values: L5 Bulbasaur 19 HP /
|
||||
9 Atk at 0 DVs, L100 Mewtwo 415 HP / 406 Spc at max DVs+statExp,
|
||||
MEDIUM_SLOW(5) = 135, type chart spot checks, deterministic damage
|
||||
rolls, Route 1 slot 1 = L3 Pidgey.
|
||||
- The autopilot run reproduces the original's early flow on real map
|
||||
data: Pallet sign text, lab door warp target (5,11), Oak's Lab exit by
|
||||
walking off the mat, connection into Route 1 at matching x.
|
||||
@@ -1,50 +0,0 @@
|
||||
# ROM Extraction Notes
|
||||
|
||||
There are two ROM-only extraction paths:
|
||||
|
||||
- The packaged app uses `src/import/RomImporter.lua` and
|
||||
`src/import/RomExtractor.lua` on first boot.
|
||||
- Developers can run `tools/build_data.py --rom <path> [--clean]` to generate
|
||||
data in the source tree for audit and parity work.
|
||||
|
||||
Both paths read only the supplied ROM and the checked-in
|
||||
`tools/rom_manifest.json`. Neither invokes RGBDS, Git, or a disassembly.
|
||||
|
||||
## Validation
|
||||
|
||||
Only the canonical US Pokemon Red ROM is supported. SHA-1 is checked before
|
||||
any cached output is removed or written.
|
||||
|
||||
## Decoded Data
|
||||
|
||||
| Area | ROM data |
|
||||
| --- | --- |
|
||||
| world | map headers, block maps, connections, warps, signs, objects |
|
||||
| tiles | tileset graphics, blocksets, collision, door and warp tile lists |
|
||||
| text | 2,584 text command streams and RAM/number substitutions |
|
||||
| Pokemon | names, stats, evolutions, learnsets, Dex data, compressed pictures |
|
||||
| battle | moves, detailed animations, OAM frames/tiles, effects, type chart, palettes, trainer parties/AI/pictures |
|
||||
| inventory | item names, prices, key-item flags, TM/HM data |
|
||||
| encounters | grass and water wild tables |
|
||||
| UI | fonts, icons, title/intro, trainer card, town map, slots, field effects |
|
||||
| audio | music, SFX and cry headers, channel programs, wave instruments |
|
||||
|
||||
The Python and Lua picture decompressors implement the Gen 1 `pic` format.
|
||||
Graphics are converted to RGBA PNGs. OAM artwork uses transparent color 0;
|
||||
battle pictures use edge-connected white matting so white interior details
|
||||
remain visible.
|
||||
|
||||
The in-app importer stores three audio ROM banks as a 48 KiB
|
||||
`programs.bin`. `src/core/ChipAudio.lua` interprets the channel bytecode and
|
||||
synthesizes music as a queueable stream; SFX and cries are synthesized on
|
||||
demand. This avoids shipping or generating a large WAV/OGG tree.
|
||||
|
||||
## Metadata Boundary
|
||||
|
||||
Names, dimensions, enum ordering, Lua script hooks, and hand-ported field
|
||||
behavior do not survive compilation in a form the Lua runtime can infer.
|
||||
Those relationships are bundled in `rom_manifest.json`. The manifest stores
|
||||
no dialogue strings, images, audio samples, or ROM bytes.
|
||||
|
||||
`tools/make_rom_manifest.py` and `tools/verify_rom_data.py` are developer audit
|
||||
tools. They are not used by the packaged game.
|
||||
@@ -1,48 +0,0 @@
|
||||
# Sideload the iOS build with AltStore
|
||||
|
||||
Every GitHub Release ships an IPA (`gen1recomp-*-ios.ipa`). Install it on
|
||||
your iPhone or iPad with [AltStore Classic](https://altstore.io/) — AltStore
|
||||
re-signs the app with **your** free Apple ID so you do not need a Mac or
|
||||
Xcode.
|
||||
|
||||
## 1. Install AltStore
|
||||
|
||||
Follow the official guide for your computer:
|
||||
|
||||
- [How to Install (Windows)](https://faq.altstore.io/altstore-classic/how-to-install-altstore-windows)
|
||||
- [How to Install (macOS)](https://faq.altstore.io/altstore-classic/how-to-install-altstore-macos)
|
||||
|
||||
You will install **AltServer** on the computer, then use it to put AltStore
|
||||
on the phone. What AltServer is and why it needs to stay running:
|
||||
|
||||
- [AltServer](https://faq.altstore.io/altstore-classic/altserver)
|
||||
|
||||
Stuck? Start here:
|
||||
|
||||
- [Troubleshooting Guide](https://faq.altstore.io/altstore-classic/troubleshooting-guide)
|
||||
|
||||
## 2. Install the game
|
||||
|
||||
1. Download `gen1recomp-*-ios.ipa` from
|
||||
[Releases](https://github.com/bryanthaboi/gen1recomp/releases).
|
||||
2. Open **AltStore** on the phone (AltServer must be running on the same
|
||||
Wi‑Fi, or keep the phone plugged into the computer).
|
||||
3. Tap **My Apps → +** (or share the IPA into AltStore) and pick the file.
|
||||
4. Sign in with your Apple ID when prompted. Wait for the install to finish.
|
||||
5. On first launch: Settings → **Privacy & Security → Developer Mode** (iOS
|
||||
16+), and Settings → **General → VPN & Device Management** → Trust your
|
||||
Apple ID if asked.
|
||||
|
||||
Then open the app, import your own legal `.gb` ROM on the Red/Blue tab, and
|
||||
play.
|
||||
|
||||
## Refresh / 7-day limit
|
||||
|
||||
With a free Apple ID, sideloaded apps stop launching after **7 days**. Keep
|
||||
AltServer running so AltStore can refresh them, or open AltStore and refresh
|
||||
manually before they expire. Saves on the phone are kept across refreshes.
|
||||
|
||||
## Prefer building it yourself?
|
||||
|
||||
Building from source on a Mac (no AltStore) is covered in
|
||||
[ios-install.md](ios-install.md).
|
||||
@@ -1,42 +0,0 @@
|
||||
# Known differences from the original game
|
||||
|
||||
Only genuine remaining divergences live here: behavior that is still
|
||||
**missing, wrong, or approximated for convenience** and would need more
|
||||
work for true parity. Faithfully-ported behavior is documented in
|
||||
docs/behavior-porting-notes.md; deliberate additions beyond the original
|
||||
are in docs/new-features.md.
|
||||
|
||||
## Reimplemented unused Prof. Oak and Rocket Chief battles
|
||||
|
||||
The original ROM defines trainer data for `PROF_OAK` and `CHIEF`
|
||||
(`data/trainers/parties.asm`) but never attaches either to an NPC, so
|
||||
both battles are unreachable in the real game. This project makes them
|
||||
fightable after the Hall of Fame:
|
||||
|
||||
- Prof. Oak battles you in Pallet Town once `EVENT_BEAT_CHAMPION_RIVAL`
|
||||
is set, using `ProfOakData`'s three starter-matched teams (the team is
|
||||
picked by the type that counters your starter, mirroring the rival).
|
||||
- The Celadon Game Corner Chief battles you in his house post-game.
|
||||
`ChiefData` is empty in the ROM, so `OPP_CHIEF` is given a
|
||||
reconstructed party.
|
||||
|
||||
This is an intentional divergence: neither battle can be triggered in the
|
||||
original game.
|
||||
|
||||
## Reimplemented unused Silph Co. card-key doors
|
||||
|
||||
`engine/events/card_key.asm` and the unused `CardKeyTable1/2/3` coordinate
|
||||
lists (`data/events/card_key_coords.asm`) describe locked doors for Silph
|
||||
Co. floors 2F-11F, but no retail `.blk` map layout ever places the closed
|
||||
door block at those coordinates, so the card key check is dead code in
|
||||
the original game. This project stamps the closed door block (`$54`/`$5f`
|
||||
on floors 2F-10F, `$20` on 11F) over each of the 20 door coordinates on
|
||||
map load, and swaps it for the open block once that door's
|
||||
`EVENT_SILPH_CO_n_UNLOCKED_DOORn` flag is set (using the key from a Team
|
||||
Rocket grunt, as in the original's unused design).
|
||||
|
||||
This is an intentional divergence: the doors are not visible or
|
||||
functional in the original game. The door layout lives in
|
||||
`tools/rom_manifest.json` (`field.cardKeyDoors.closedDoors`), hand-ported
|
||||
since no retail ROM data encodes it; `src/import/RomExtractor.lua` copies
|
||||
it straight through on ROM import.
|
||||
@@ -1,294 +0,0 @@
|
||||
# Launcher
|
||||
|
||||
The launcher is `src/import/RomImporter.lua`, the first-run / title screen
|
||||
that runs before `Game:load`. Besides ROM import (see the file's own header)
|
||||
it hosts a tabbed shell covering per-game save slots and a mod manager. This
|
||||
file documents the runtime model; the visual spec lives separately.
|
||||
|
||||
## Android multi-ROM / mod / save import
|
||||
|
||||
On Android, `love.system.pickFile([kind])` opens the Storage Access Framework
|
||||
picker (`GameActivity.showFilePicker`); the chosen file is copied into the app
|
||||
save directory as:
|
||||
|
||||
| `kind` | Destination |
|
||||
| --- | --- |
|
||||
| nil / `"rom"` | `picked_rom.gb` (open) |
|
||||
| `"mod"` | `picked_mod.zip` (open) |
|
||||
| `"sav"` / `"save"` | `picked_save.sav` (open) |
|
||||
|
||||
Export uses a separate API: `love.system.createFile(suggestedName)` →
|
||||
`GameActivity.showCreateDocument` (`ACTION_CREATE_DOCUMENT`), which copies
|
||||
staged `pending_export.sav` to the user-chosen URI and writes `export_done.flag`
|
||||
for the launcher to acknowledge on refocus.
|
||||
|
||||
`RomImporter` then imports on refocus / Choose:
|
||||
|
||||
- **ROMs** via `findPendingRom`: only a 1 MiB `.gb` whose SHA-1 maps to a
|
||||
version that is **not** yet ready counts as pending. A leftover
|
||||
`picked_rom.gb` from Red therefore cannot block Blue's Choose (issue #167).
|
||||
- **Mods** via `findPendingMod`: Prefer `picked_mod.zip`, or (on Choose) any
|
||||
other `.zip` at the save-dir root (USB copy).
|
||||
- **Saves** via `findPendingSav`: Prefer `picked_save.sav`, or (on Choose) any
|
||||
other `.sav` at the save-dir root.
|
||||
|
||||
After a successful import the consumed save-dir file is removed.
|
||||
|
||||
**Manual check (device/emulator):** import Red → switch to Blue → Choose →
|
||||
system file picker must appear (not a silent Red re-extract) → pick Blue →
|
||||
Blue becomes ready beside Red. On the MODS tab, Import mod .zip must open the
|
||||
same system picker and install the chosen archive on return.
|
||||
|
||||
## Tab structure
|
||||
|
||||
`self.tab` is one of `"red"`, `"blue"`, `"yellow"`, `"mods"`. The tab bar
|
||||
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
|
||||
Responsiveness). The MODS tab (`_drawModsPanel`) shows the mod list instead.
|
||||
- The self-updater banner (`self.Check`, see `docs/updater.md`) draws as a
|
||||
centered pill in a reserved band just above the footer, on every tab. That
|
||||
position is unchanged by this redesign, so `docs/updater.md` needed no edits.
|
||||
|
||||
## Save slot model
|
||||
|
||||
All slot I/O lives in `src/core/SaveData.lua` and goes through the same fs
|
||||
abstraction (`persistFs`) every other save/options call uses, so portable
|
||||
mode (an `io.*` filesystem used when `portable.txt` marks the install)
|
||||
keeps working unchanged.
|
||||
|
||||
- **Files.** A version's playthroughs live under `saves/<version>/`, one file
|
||||
per slot: `saves/<version>/slot1.lua` plus a rolling `.bak` and staged
|
||||
`.tmp` witness (`slotNames`), mirroring the write/recovery discipline
|
||||
`SaveData.save`/`load` already use for the flat legacy file. Slot ids match
|
||||
`slot%d+`; `createSlot` allocates one past the highest existing number so a
|
||||
reused id can never collide with a lingering file.
|
||||
- **Registry.** The ordered slot list and which one is active persist in
|
||||
`options.lua` (via the existing `SaveData.loadOptions`/`saveOptions`):
|
||||
`options.saveSlots = { [version] = { list = {"slot1", ...}, active = "slot1" } }`.
|
||||
Custom slot labels (#205) live alongside them in the same registry:
|
||||
`options.saveSlots[version].names = { slot1 = "Nuzlocke" }`, written by
|
||||
`SaveData.renameSlot` (trimmed; an empty label clears it) and surfaced on
|
||||
each `listSlots` row as `label` (the launcher row shows `label`, falling
|
||||
back to the player name). `deleteSlot` drops the label with the slot.
|
||||
Renaming never touches the save file, so an empty slot can be labeled.
|
||||
On desktop, right-clicking a slot row opens the inline rename modal
|
||||
(Enter commits, Esc cancels); touch has no secondary button, so the
|
||||
affordance is desktop-only.
|
||||
- **Active slot resolution.** `saveNames(version)`, the function every
|
||||
existing caller (`TitleState` hasSave/load/save, recovery order) already
|
||||
goes through, now resolves the *active* slot instead of a fixed flat name.
|
||||
Resolved once per version per process (`ensureVersionSlots`, cached in
|
||||
`activeSlotCache`/`slotsChecked`): a registry entry wins; otherwise a lazy
|
||||
legacy migration may create one; otherwise the flat legacy path is used
|
||||
(`save.lua` / `save_blue.lua`), so a pre-slots install keeps working as before.
|
||||
- **Legacy migration.** One-time per version, lazy on first
|
||||
`listSlots`/`load`/`saveNames` call (`tryMigrateLegacy`): if a flat legacy
|
||||
file exists and no `saves/<version>/` registry does, its main + `.bak` are
|
||||
copied into `saves/<version>/slot1.lua(.bak)`, verified readable
|
||||
(`decodeSlot`: main, then `.tmp`, then `.bak`), and only then are the
|
||||
originals removed and `slot1` registered as active. A copy that fails to
|
||||
verify leaves the originals in place; migration never loses data.
|
||||
|
||||
The launcher-facing API:
|
||||
- `SaveData.listSlots(version)` -> array of `{id, exists, name, meta}` for
|
||||
every registered slot. `name` is the save's player name, or `nil` for an
|
||||
empty slot; `meta` is `{badges, timeText, dexCount}` (the same fields the
|
||||
title screen's `ContinueInfo` shows) or `nil`. The pure part,
|
||||
`SaveData.slotSummary(save)`, is unit-testable with no filesystem.
|
||||
- `SaveData.setActiveSlot(version, slotId)` registers the id if new, persists
|
||||
it as active, and updates the process cache so the very next save/load
|
||||
lands there. The launcher calls this the moment a slot row is clicked
|
||||
(`RomImporter:_selectSlot`); pressing Play needs no signature change, since
|
||||
`Game.lua`/`main.lua` still just call `SaveData.load()`/`save()`.
|
||||
- `SaveData.createSlot(version)` -> new slot id, registered but with **no
|
||||
save file written**. An empty slot means the title screen offers NEW GAME
|
||||
only, which needs no further changes.
|
||||
- `SaveData.deleteSlot(version, slotId)` removes the slot's
|
||||
main/`.bak`/`.tmp` files, drops it from the registry, and if it was active
|
||||
points active at another remaining slot (or clears active when the list is
|
||||
empty). The launcher's SAVE SLOT panel Delete control calls this.
|
||||
|
||||
## Launcher mod manager
|
||||
|
||||
`src/mods/LauncherMods.lua` is a launcher-only read of the mod set. It runs
|
||||
before `Game:load`, so **it never loads a mod's entry chunk**; only
|
||||
`manifest.json` is read and validated (`src/mods/Manifest.validate`), the way
|
||||
`Loader:_discover` finds mods without running them. The real loader
|
||||
(`src/mods/Loader.lua`) still owns the actual load at boot.
|
||||
|
||||
- `LauncherMods.list()` scans `mods/` one level deep (first id wins on a
|
||||
duplicate) and returns one row per mod:
|
||||
`{id, name, version, badge, description, enabled, status, statusDetail}`.
|
||||
`badge` is the manifest's `category`, falling back to `profile`, then
|
||||
`"MOD"`, uppercased. `enabled` reads `options.mods[id]` (missing means
|
||||
enabled, matching the loader's own default).
|
||||
- `status` is `"ok"`, `"warn"`, or `"conflict"`, computed by the pure
|
||||
`LauncherMods.deriveList`/`statusFor` against `ManagerState.resolveToggle`
|
||||
and the validated manifests: `conflict` when enabling this mod collides
|
||||
with another enabled one; `warn` for an out-of-range `game_version` or an
|
||||
absent/disabled/wrong-version hard dependency; `ok` otherwise. Having no
|
||||
`love.*` calls, this half is table-driven by the test suite on its own.
|
||||
- `LauncherMods.setEnabled(id, bool)` persists `options.mods[id]` as a plain
|
||||
boolean, the exact shape `Loader:_saveState` writes, so the running game
|
||||
and the in-game `ManagerState` see the change on next boot. The mods panel
|
||||
calls this on every toggle and re-derives the list right away
|
||||
(`RomImporter:_refreshMods`) so a status change (e.g. a new conflict)
|
||||
shows without waiting for a reload.
|
||||
- `LauncherMods.installZip(path)` mounts the archive with
|
||||
`love.filesystem.mount`, locates the mod root via `locateRoot` (manifest at
|
||||
the zip root, or inside one top-level folder), validates its manifest, and
|
||||
copies the tree into the save-dir `mods/<id>/` before unmounting. Rejects a
|
||||
duplicate of an already-installed mod id, and accepts either an external
|
||||
path string or a LOVE `DroppedFile`, staging a dropped file into a save-dir
|
||||
temp first (mount only reaches save-dir-relative paths), the same way
|
||||
`RomImporter` handles a dropped ROM. A failed copy rolls its partial tree
|
||||
back, and every path unmounts and clears the staged temp file.
|
||||
- `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
|
||||
|
||||
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).
|
||||
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
|
||||
a bad main-data checksum), then registers a fresh slot (`SaveData.createSlot`),
|
||||
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. 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/<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
|
||||
(`_savedropTarget`). `.gb` (ROM) and `.zip` (mod) routing is unchanged.
|
||||
- **Failure UX.** Every error path (wrong size, bad checksum, write failure,
|
||||
nothing to export, ROM not imported yet) surfaces as a red notice line on the
|
||||
card. Nothing raises and nothing silently no-ops.
|
||||
|
||||
`SaveFileIO` is love-free enough to unit-test through the same in-memory
|
||||
filesystem stub the slot backend uses (`tests/engine/save_file_io_tests.lua`).
|
||||
|
||||
## Responsiveness
|
||||
|
||||
Every measurement derives from `love.graphics.getDimensions()` each frame
|
||||
plus the existing global scale `s = clamp(height / 768, 0.7, 1.6)`; nothing
|
||||
assumes a fixed window size. The game panel's two-column grid (ROM/SAVE
|
||||
FILES/Play on the left, SAVE SLOT on the right) collapses to one stacked
|
||||
column, slot card below Play, when the window is too narrow for both
|
||||
`~300 * s`-wide columns. The save-slot list and the mod list both scroll
|
||||
(wheel, or drag on touch/desktop) clamped to their own content extent,
|
||||
recomputed every draw. The tab bar labels only the active chip so it stays
|
||||
narrow-safe, and content caps out at `~1440 * s` wide, centered.
|
||||
|
||||
The desktop window has a floor of 480x360 (`conf.lua` `minwidth`/`minheight`),
|
||||
under which the cards stop being readable at all. Mobile ignores it: those
|
||||
windows are fullscreen.
|
||||
|
||||
### Page scroll
|
||||
|
||||
Two columns fit any window the launcher is likely to open in; one stacked
|
||||
column does not. On a phone-shaped window the ROM card, SAVE FILES, Play and
|
||||
SAVE SLOT together run past the bottom, and a footer pinned to the window
|
||||
bottom painted over them with the overflow unreachable.
|
||||
|
||||
So the whole column under the tab bar -- panel, updater banner, footer --
|
||||
scrolls as one page whenever it is taller than the room below the tab bar:
|
||||
|
||||
- The strip, logo and tab bar stay pinned, so navigation is always on screen.
|
||||
Everything else draws at `contentTop - pageScroll` inside a scissor, and the
|
||||
footer is laid out downward from `footerTop` right after the content instead
|
||||
of upward from the window bottom.
|
||||
- `RomImporter.pageScrollFor(naturalH, viewportH, scroll)` is the whole
|
||||
decision, pure and pinned by `tests/engine/launcher_page_scroll.lua`. A
|
||||
window that grows back drags the offset down with it, so the page can never
|
||||
stay parked past its own end.
|
||||
- The panels report their natural height as they draw (`_drawGamePanel` and
|
||||
`_drawModsPanel` return it), so the decision reads the previous frame's
|
||||
measurement -- the same one-frame settle the two lists already rely on.
|
||||
- **One scroll axis at a time.** While the page scrolls, the panels draw
|
||||
`paged`: the slot and mod lists take their natural height, keep no inner
|
||||
scroll region and report a max of 0, so the wheel, the right stick and a drag
|
||||
all move the page and never fight a list for the same gesture. Two-column
|
||||
layouts do not overflow, `paged` stays false, and every one of these behaves
|
||||
exactly as it did before.
|
||||
- Hit testing follows the clip: `inside` (clicks) and `_ptIn` (hover) reject a
|
||||
rect that scrolled out of the viewport, so a control that slid under the tab
|
||||
bar cannot be clicked through it. Tab chips carry `pinned = true` and are
|
||||
exempt. `pageScroll` resets on a tab change, each tab being a different
|
||||
length.
|
||||
- A press on empty background pans the page, resolved in `_updateSlotDrag` like
|
||||
every other drag here.
|
||||
|
||||
### Dragging on Android
|
||||
|
||||
The launcher is handed no move events on any platform: `main.lua` forwards
|
||||
neither `touchmoved` nor `mousemoved` while it is up, which is why every drag
|
||||
here is resolved by polling inside `draw` instead. Desktop polls the mouse;
|
||||
Android used to poll nothing at all ("no reliable pointer polling" meant its
|
||||
mouse emulation), so it had no scroll gesture whatsoever -- fine while every
|
||||
scroll region was an inner list with a wheel alternative, useless the moment
|
||||
the page itself became the thing that scrolls, since a phone is exactly where
|
||||
it overflows.
|
||||
|
||||
`love.touch` is pollable, so `_pointerHold` reads the first active touch there
|
||||
and hands `_updateSlotDrag` the same (held, y) pair the mouse gives on desktop.
|
||||
Consequences:
|
||||
|
||||
- Slot rows and mod toggles ARM on press and commit on release on Android too,
|
||||
matching desktop, so a swipe that starts on a card scrolls instead of
|
||||
selecting the row it started on.
|
||||
- `touchPollable` (set once in `new`) gates all of it. Where `love.touch` is
|
||||
missing, every Android path is exactly what it was: act on press, never arm,
|
||||
no drag.
|
||||
@@ -0,0 +1,120 @@
|
||||
# Link play: threat model and what the code actually guarantees
|
||||
|
||||
Link play is the only part of this game that reads bytes written by
|
||||
somebody else. This is what it defends against, what it does not, and
|
||||
where each guarantee lives.
|
||||
|
||||
## The boundary
|
||||
|
||||
Everything a peer or the relay sends arrives as one JSON object per line.
|
||||
There is exactly one place it becomes a message:
|
||||
|
||||
src/link/Net.lua reads bytes, frames lines, decodes JSON
|
||||
src/link/Wire.lua rebuilds each line as a typed message
|
||||
src/link/Session.lua the only path from a transport into a mode
|
||||
|
||||
`Session:update` runs `Wire.sanitize` on every message before anything
|
||||
else sees it. A schema returns a **new** table holding only the fields it
|
||||
names, at the Lua types it names, so the rest of `src/link/` can read
|
||||
`msg.slot`, `msg.parts.actives` or `msg.mons[i].dvs.hp` directly and be
|
||||
right by construction. A message with no schema (a mod's, or a future
|
||||
build's) keeps a bounded, scalar-only copy of its payload instead of
|
||||
being dropped.
|
||||
|
||||
A message that fails its schema is **dropped and logged**, never fatal.
|
||||
Latching a terminal failure would hand a hostile peer a cheaper
|
||||
disconnect than sending nothing at all.
|
||||
|
||||
### Why the bounds are loose
|
||||
|
||||
Wire's numeric bounds are deliberately wider than the game's own clamps in
|
||||
`Protocol.unpackMon`. Both peers run identical clamps over identical
|
||||
packets; a bound that bit an honest value would change one side's copy of
|
||||
a mon and desync the lockstep. Wire's job is types and sizes. Rules are
|
||||
`Protocol`'s job, and it keeps its own clamps for the callers that reach
|
||||
it without a Session (the mod API, `tests/`).
|
||||
|
||||
### Containment behind it
|
||||
|
||||
Assume something still gets through:
|
||||
|
||||
- `Game:step` pcalls the link pump, and pcalls `stack:update` **only
|
||||
while a link session is active**. On a throw, `Game:breakLink` closes
|
||||
the connection, unwinds to the overworld and says "The link was
|
||||
broken." Outside link play the stack is unguarded on purpose: a blanket
|
||||
pcall would swallow real engine bugs and leave the game silently wrong
|
||||
instead of loudly broken.
|
||||
- `Net` caps `rxBuf` at 256KB and its per-frame read at 512KB, so a peer
|
||||
that never sends a newline ends as a clean disconnect.
|
||||
- `Json.decode` refuses documents nested past 64 levels, and takes an
|
||||
optional length cap that the link path passes and the mod-manifest path
|
||||
does not.
|
||||
|
||||
## The relay (`../pokeserver`)
|
||||
|
||||
- A line that is not a JSON **object** with a string `type` is dropped
|
||||
before any handler runs, and `onLine` is wrapped in try/catch.
|
||||
`server.js` installs `uncaughtException`/`unhandledRejection` handlers:
|
||||
one bad packet must never take every live match down with the process.
|
||||
- Line buffers are capped, lines per second are capped, connections per
|
||||
IP and in total are capped, and an unbound connection that never hosts
|
||||
or joins is swept after 30s.
|
||||
- `SERVER_ONLY` is the set of message types the server is the only
|
||||
legitimate author of (`peer_gone`, `bracket_update`, `match_start`,
|
||||
`tournament_over`, `spectate`, ...). A peer that sends one has them
|
||||
dropped rather than forwarded, so a bracket opponent cannot forge a
|
||||
tournament result or fake "your opponent left".
|
||||
- Trainer names are reduced to a printable subset and capped at the same
|
||||
10 characters the game enforces, on the way in, because they are
|
||||
rendered by the dashboard and broadcast to every participant.
|
||||
|
||||
`pokeserver/test/hostile.js` is the regression net for all of that.
|
||||
|
||||
## What is NOT defended
|
||||
|
||||
**Party legality is trust-the-client.** Online play meets strangers, and
|
||||
`Handshake.onlineAllowed` is a Lua function in the same VM the mods load
|
||||
into. It cannot be made tamper-proof in-process, and pretending otherwise
|
||||
would only cost honest mod authors. What lockstep and
|
||||
`Protocol.unpackMon`'s recompute-from-species-data *do* guarantee is that
|
||||
a cheater cannot invent stats, moves, or a shiny: every derived value is
|
||||
rebuilt locally from real species data. They can send a legal party they
|
||||
farmed or edited. That is the honest boundary.
|
||||
|
||||
What the relay does instead is **observe and record**. It already sees
|
||||
every `hello`, so it keeps each connection's self-reported
|
||||
`engineVersion`, `fingerprint` and `linkModified`, compares the two sides
|
||||
of a room or a live tournament match, and logs and surfaces a
|
||||
`modded` / `fingerprint_mismatch` / `version_skew` flag on the dashboard.
|
||||
A patched client can still lie; what it cannot do is lie without the
|
||||
tournament organizer having a record of it.
|
||||
|
||||
Client-side attestation is deliberately not built. This is an
|
||||
open-source Lua game: it would be theater, and it would break honest
|
||||
mods.
|
||||
|
||||
**The relay has no TLS.** Port 7778 is plaintext, so party contents,
|
||||
trades and trainer names are visible to anyone on the network path. There
|
||||
is nothing secret in a Pokemon party, but it is a real property of the
|
||||
system and not an oversight. Fixing it means a TLS terminator in front of
|
||||
the relay and a client that speaks it, which is a version break for every
|
||||
shipped build.
|
||||
|
||||
**The dashboard has no default password.** `DASHBOARD_PASSWORD` is
|
||||
required; with it unset the relay runs and the dashboard simply does not
|
||||
start. It is still Basic Auth over plain HTTP, so it belongs behind an
|
||||
IP restriction or an SSH tunnel (`pokeserver/DEPLOY.md`).
|
||||
|
||||
## Tests
|
||||
|
||||
luajit tests/link_hostile.lua every message type x every wrong type
|
||||
luajit tests/link_desync_fuzz.lua lockstep fuzz, plus a mutation mode
|
||||
luajit tests/run_link_tests.lua both of the above, plus the rest
|
||||
cd ../pokeserver && npm test relay smoke, 16-player bracket, hostile
|
||||
|
||||
`tests/link_hostile.lua` builds its corpus from a template per message
|
||||
type, replaces each field (and several nested ones) with every wrong Lua
|
||||
type, and drives the survivors through the real trade session, a real
|
||||
lockstep battle, a real spectator battle, and the tournament screen
|
||||
**including its draw** -- because the two nastiest payloads are
|
||||
delayed-fuse ones that crash on render rather than on receipt.
|
||||
@@ -0,0 +1,54 @@
|
||||
# Linux ARM SBC Handhelds (PortMaster)
|
||||
|
||||
Download `gen1recomp-*-sbc-portmaster.zip` from the [Gen1Recomp releases](https://github.com/bryanthaboi/gen1recomp/releases). This build targets 64-bit Linux ARM handhelds with PortMaster, including compatible H700 devices.
|
||||
|
||||
## Install
|
||||
|
||||
1. Unzip the release. It contains `gen1recomp-sbc.sh` and a `gen1recomp-sbc/` folder.
|
||||
2. Copy both as siblings into your device's PortMaster ports directory, commonly `Roms/Ports (PORTS)/` or `Roms/PORTS/`.
|
||||
3. Install PortMaster for your firmware and refresh the Ports list.
|
||||
4. Copy your legally owned canonical US Red or Blue `.gb` file into `gen1recomp-sbc/lovegame/`.
|
||||
5. Launch **gen1recomp-sbc** from Ports and choose the ROM.
|
||||
|
||||
The pack includes `portable.txt`, so saves and ROM-derived cache remain beside the game on the SD card. The build never ships ROM-derived bytes.
|
||||
|
||||
Canonical US cart SHA-1 values:
|
||||
|
||||
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
|
||||
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
|
||||
|
||||
## Controls
|
||||
|
||||
| Input | Action |
|
||||
| --- | --- |
|
||||
| D-pad | Move cursor |
|
||||
| A | Click / confirm |
|
||||
| L1 / R1 | Switch tabs |
|
||||
| Start / Select | Play or choose ROM |
|
||||
|
||||
In-game controls use the normal PortMaster/SDL mapping and can be rebound in **OPTIONS → CONTROLS**.
|
||||
|
||||
## Runtime and suspend
|
||||
|
||||
The package bundles PortMaster's LÖVE 11.5 aarch64 runtime. The launcher sources `control.txt`, calls `get_controls`, applies an optional CFW override, invokes `pm_platform_helper`, and calls `pm_finish` on exit. Paths are relative to the launcher, allowing different firmware mount points.
|
||||
|
||||
Suspend/resume uses the existing LÖVE focus/visibility lifecycle: input is reset on focus loss and the game resumes when the window becomes visible again. Exact power-button behavior remains firmware-dependent; hardware validation has been performed on the TrimUI Brick, not every SBC or H700 device.
|
||||
|
||||
## Building
|
||||
|
||||
Release workflows build this automatically. Standalone builds resolve the latest published Gen1Recomp release by default:
|
||||
|
||||
```sh
|
||||
./build-linux-arm-sbc.sh --version 0.1.75
|
||||
```
|
||||
|
||||
For development, package a local checkout explicitly:
|
||||
|
||||
```sh
|
||||
GEN1RECOMP_SOURCE_DIR="$PWD" ./build-linux-arm-sbc.sh --version 0.1.0
|
||||
# or: ./build-linux-arm-sbc.sh --source "$PWD" --version 0.1.0
|
||||
```
|
||||
|
||||
The generated `port.json` records the source release tag. `install-linux-arm-sbc.sh` is a macOS helper for copying a built pack to a mounted SD card.
|
||||
|
||||
PortMaster device support and runtime integration are maintained in the [PortMaster](https://github.com/PortsMaster/PortMaster-New) ecosystem.
|
||||
@@ -0,0 +1,863 @@
|
||||
# Mods and Gen 2 (Gold)
|
||||
|
||||
The mod API is one API across both generations. Hook names, event names,
|
||||
registry names and the `mod.*` facade are shared on purpose: a mod that runs on
|
||||
Red should be able to run on Gold without learning a second vocabulary.
|
||||
|
||||
What differs is how much of it Gold can actually serve, and that is why Gen 2
|
||||
support is something a mod **declares** rather than something it inherits.
|
||||
|
||||
## What you can rely on today
|
||||
|
||||
The short version, for an author deciding what to write:
|
||||
|
||||
- **Every registry name, hook name and event name means the same thing in both
|
||||
games.** Nothing is prefixed, renamed or repurposed per generation. Where Gen
|
||||
2 genuinely carries more, the record or the payload gains a *field*.
|
||||
- **40 of the 46 registries are available on Gold.** 17 keep their Gen 1 target
|
||||
outright (`commands`, `tokens`, `growth_rates`, `battle_sprite_scales` and
|
||||
`render_pipelines` among them), 16 route to a Gen 2 table under the same
|
||||
name, 6 are Gen 2-only systems Red has no counterpart for, and `migrations`
|
||||
is a code registry with no data target in either game. The other 6 are gated,
|
||||
and are listed below with the consumer change each one still needs.
|
||||
- **A registry with no home in a generation is reported, never silently
|
||||
merged.** The write is taken, dropped, and named once per mod in the same
|
||||
error feed the mod manager shows -- in both directions, so a Red boot writing
|
||||
to `decorations` is told exactly as a Gold boot writing to `map_scripts` is.
|
||||
- **40 event names and 43 hook names have a call site in both generations**, so
|
||||
one subscription serves both games. `tests/engine/gate_gen2_mod_api.lua`
|
||||
reads those names back out of the source and fails if a site is renamed or
|
||||
deleted on either side, and fails again if a new shared site appears without
|
||||
being listed here.
|
||||
- **24 further names are Gen 2-only** (friendship, breeding, the Pokegear, the
|
||||
radio, Pokerus, the roamers, Kurt, the Bug Contest, the Unown puzzle, mail,
|
||||
held items, shininess, gender, and the five cards of the GS boot cinema).
|
||||
They are plain names, not a `gen2.` namespace, so if Red ever grows the
|
||||
system the name is already right.
|
||||
- **Every Gen 2 seam is guarded** by `Runtime.wants` / `Runtime.wantsHook`, so
|
||||
a boot with no mod subscribed allocates nothing at any of them.
|
||||
- **A mod is loaded on Gold only if it says so.** See `gen2compat` below.
|
||||
|
||||
`src/mods/Schemas.lua` is authoritative for routing;
|
||||
`tests/engine/gate_gen2_mod_api.lua` holds this document to it.
|
||||
|
||||
## Declaring which games a mod is for
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my_mod",
|
||||
"name": "My Mod",
|
||||
"version": "1.0.0",
|
||||
"entry": "main.lua",
|
||||
"api": 2,
|
||||
"games": ["gen1", "gen2"]
|
||||
}
|
||||
```
|
||||
|
||||
`games` is an optional array of version ids (`"red"`, `"blue"`, `"yellow"`,
|
||||
`"gold"`), generations (`"gen1"`, `"gen2"`, case-insensitive) or `"all"`.
|
||||
`src/mods/ModTargets.lua` resolves the tokens off `GameVersion.ORDER` and
|
||||
`GameVersion.generation`, so nothing anywhere restates the game list.
|
||||
`Manifest.validate` stores the resolved, ORDER-sorted ids on `manifest.games`
|
||||
and **derives** `manifest.gen2compat` from them, which is the one field the
|
||||
loader's gate reads.
|
||||
|
||||
Nothing moves on disk for any of this. A mod is installed once, into
|
||||
`mods/<id>/`, and that directory serves every game: there is no `mods/gen1/`
|
||||
and no per-generation copy. Targeting is declared, not filed.
|
||||
|
||||
`"gen2compat": true` is the legacy spelling and is still accepted. It is purely
|
||||
additive -- it *adds* the Gen 2 games to whatever `games` says -- so no shipped
|
||||
manifest can lose a game it already ran on. A manifest with neither key is Gen
|
||||
1 only, which is exactly what it always meant. An unknown token warns and is
|
||||
dropped under `api` 1 and refuses the manifest under `api` 2; a `games` array
|
||||
that names no game this engine knows falls back to the default rather than
|
||||
orphaning the mod; a non-array `games` is a hard error.
|
||||
|
||||
Every token is enforced, per game. `Loader:_gateGeneration` gates on
|
||||
`ModTargets.supports(manifest, version, generation)`, the same call both mod
|
||||
surfaces make, so `"games": ["blue"]` really does not load on Red and the
|
||||
loader's skip line is the launcher's line, `For Blue, not Red`. A manifest with
|
||||
no `games` and no `gen2compat` still covers every Gen 1 game, so nothing
|
||||
written before the key existed changes behavior.
|
||||
|
||||
On a Gold boot, a mod claiming no Gen 2 game is **not loaded at all**: no
|
||||
registrations, no subscriptions, no entry chunk. The manager still lists it,
|
||||
showing `ENABLED (NOT THIS GAME)` and the reason, and the player's enable flag
|
||||
is left alone so it comes straight back on Red.
|
||||
|
||||
Both mod surfaces derive what they show from `ModTargets` rather than from
|
||||
their own copy of the rule. The launcher's mod panel carries a `Show for:` game
|
||||
chip row and a per-mod tag (`GEN 1`, `GEN 1+2`, `RED/GOLD`), greyed with `Not
|
||||
for this game` and the detail `For Gen 1, not Gold` when the mod does not run
|
||||
on the selected game; the in-game manager shows the same verdict as
|
||||
`ENABLED (NOT THIS GAME)` plus an inert `FOR GEN 1+2` row on the detail screen.
|
||||
The launcher asks the same question of a mod's dependencies: one whose hard
|
||||
dependency does not run on the selected game reads `Needs <id> (not for Gold)`,
|
||||
matching the loader's contagious skip.
|
||||
|
||||
A separate overlay, `options.modsByVersion[version][id]`, holds each game's
|
||||
enable flag. The launcher shows a coloured Red / Blue / Yellow / Gold checkbox
|
||||
for every installed mod, and the loader and in-game manager read the same
|
||||
game-specific answer on the next boot. On the first launch after this feature,
|
||||
the existing shared state is copied to every game, so a mod that was enabled
|
||||
remains enabled everywhere; after that, changing one checkbox affects only
|
||||
that game. New mods still default to enabled on every game (experimental mods
|
||||
retain their explicit opt-in default).
|
||||
|
||||
That is deliberate. Gold reimplements the battle engine, the overworld, the
|
||||
script VM and the save format, so a Gen 1 mod dropped into a Gold boot would
|
||||
find a small fraction of its call sites live. A mod that half-applies reads to
|
||||
a player as a broken mod. Not running is the honest state, and naming a Gen 2
|
||||
game is the author saying "I have tested this there".
|
||||
|
||||
Adding a Gen 2 game does not opt out of anything on Gen 1, because `games` is a
|
||||
union: `["gen1", "gen2"]` covers everything it covered before. What does change
|
||||
is that the gate now runs on a Gen 1 boot too, so a manifest that names *only*
|
||||
Gen 2 games no longer loads on Red, Blue or Yellow. Say `["all"]` or list both
|
||||
generations if you want both.
|
||||
|
||||
Two riders. **A hard dependency that does not run here takes the dependent down
|
||||
with it** (unless scoped to specific games, e.g.
|
||||
`dependencies: [{ id = "x", games = ["gen2"] }]`), as a skip rather than a
|
||||
failure and carrying the dependency's own wording (`depends on X, which does not
|
||||
run here (For Blue, not Red)`), so the whole chain has to cover the same games.
|
||||
And **the claim is yours, not the last word**: it is the manager's `TRY HERE ANYWAY` row that lets a player run a mod
|
||||
whose author never opted in, which is the only route for a mod written before
|
||||
the field existed. The override is per game -- `options.modsGen2[id]` is a
|
||||
`{ [version] = true }` table, so forcing a mod onto Red does not force it onto
|
||||
Gold, and a legacy `options.modsGen2[id] = true` reads as "the Gen 2 games",
|
||||
the only set it could ever have affected. It applies on the next boot; a forced
|
||||
mod loads normally and keeps a note saying it was never verified here. Where
|
||||
the choice cannot be persisted the manager says `COULD NOT SAVE` instead of
|
||||
promising a restart.
|
||||
|
||||
If you are writing new code, still prefer the API: take the live game from
|
||||
`mod.game` (or the `game.ready` payload, or a `ui.*` hook's first argument) and
|
||||
the world from `mod.world`. Those are the names that mean the same thing in
|
||||
both games. What follows is for the mods that were written before Gold existed
|
||||
and reach past it.
|
||||
|
||||
## Gen 1 module facades
|
||||
|
||||
A mod with `engine_internals` reaches engine modules by name, and under Gold
|
||||
those names used to resolve to Gen 1 modules nothing instantiates -- so the
|
||||
patch landed on dead code and the mod was inert with no symptom but silence.
|
||||
|
||||
On a Gen 2 boot, **a require made from a mod's own chunk is answered by an
|
||||
adapter**: the Gen 1 API, backed by Gen 2 internals. `src/mods/Gen2Compat.lua`
|
||||
is the table, `src/mods/Loader.lua`'s require shim is where the swap happens,
|
||||
and `tests/engine/gate_gen2_mod_facade.lua` holds both to it. Engine code is
|
||||
not affected -- the shim only substitutes when the calling chunk is outside the
|
||||
engine tree, so `src/render/PaletteFX.lua` still gets the real Gen 1 module on
|
||||
both generations.
|
||||
|
||||
Fifteen names are served. **alias** means the adapter *is* the Gen 2 module, so
|
||||
a monkey-patch, a `rawset` sentinel and a `getmetatable(x) == M` check all land
|
||||
on the table Gold runs; **facade** means a translating wrapper over it.
|
||||
|
||||
| the Gen 1 name a mod requires | kind | what it gets on Gold |
|
||||
| --- | --- | --- |
|
||||
| `src.core.Game` | facade | a live proxy onto the Game2 instance |
|
||||
| `src.world.OverworldController` | facade | over `src/world/gen2/World.lua`; `World:step` / `:interact` / `:interactBody` dispatch through it |
|
||||
| `src.world.Map` | alias | `src/world/gen2/Map.lua`, grown Gen 1's statics and instance methods |
|
||||
| `src.world.NPC` | alias | `src/world/gen2/Npc.lua`; `NPC.new` sniffs the Gen 1 argument order |
|
||||
| `src.pokemon.Boxes` | facade | over `src/core/gen2/Boxes.lua`, plus Gen 1's `COUNT` / `CAPACITY` / `ensure` / `active` / `deposit` |
|
||||
| `src.battle.BattleState` | facade | over `src/ui/gen2/BattleState.lua`, write-through |
|
||||
| `src.ui.PartyMenu` | facade | over `src/ui/gen2/PartyMenu.lua`, write-through |
|
||||
| `src.world.WorldAPI` | alias | `src/world/gen2/WorldAPI.lua` |
|
||||
| `src.world.PikachuFollower` | alias | `src/world/gen2/Follower.lua` |
|
||||
| `src.script.ScriptRunner` | facade | over `src/script/gen2/Vm.lua` |
|
||||
| `src.ui.OptionsMenu` | facade | over `src/ui/gen2/OptionsMenu.lua`, write-through |
|
||||
| `src.world.FieldDefaults` | facade | the `playerSprites` answer, and a named refusal for the rest |
|
||||
| `src.world.Collision` | facade | `DELTA` / `target` / `occupied` / `canMove` |
|
||||
| `src.ui.StartMenu` | facade | over `src/ui/gen2/StartMenu.lua`, write-through |
|
||||
| `src.ui.BoxMenu` | alias | `src/ui/gen2/PcMenu.lua` |
|
||||
|
||||
Two entries in that table are not the pairing they look like.
|
||||
`src.ui.BoxMenu` resolves to `PcMenu`, not to `src/ui/gen2/BoxMenu.lua`: Gen 1's
|
||||
`BoxMenu` is Bill's PC *top menu*, whose Gold counterpart is `PcMenu`, while
|
||||
Gold's `BoxMenu` is the withdraw/deposit *list* Gen 1 builds inline. And
|
||||
`src.script.ScriptRunner` is served narrowly rather than fully: `scanLabels`
|
||||
and `validate` forward verbatim, with the default verb lookup swapped to
|
||||
`game.data.commands` so a script of Gen 1 built-ins cannot validate clean and
|
||||
then run as nothing, while the lifecycle half is a thin handle onto the one
|
||||
`world.vm` with `resume` and `update` refused rather than double-driving it.
|
||||
The `script.started` / `script.ended` / `script.command` seams are the
|
||||
supported route and already work on Gold.
|
||||
|
||||
`src.script.Commands` and `src.ui.OptionRows` have **no** adapter and are the
|
||||
two names a require of which still lands in the boot error feed the manager
|
||||
shows, with the module named. Both load fine under Gold and both are traps: the
|
||||
first hands back 61 Gen 1 verbs none of which Gold can run, the second paints
|
||||
Red's four-box options chrome over Gold's single 18x16 one.
|
||||
|
||||
`docs/preparing-your-mod-for-gen2.md` is the migration guide for an author
|
||||
working through this, and `python3 tools/modkit.py gen2check <id>` reports a
|
||||
mod's own findings against the coverage table below.
|
||||
|
||||
Three rules the adapters keep, because a plausible wrong answer is worse than
|
||||
the module being missing:
|
||||
|
||||
- **Live, never a snapshot.** A mod captures `require("src.core.Game")` at file
|
||||
scope, before a save or a world exists. The facade is a proxy that reads the
|
||||
live instance on every touch, so `Game.save` is nil during the entry chunk
|
||||
and correct forever after. It aliases the two names Gold spells differently
|
||||
(`Game.overworld` is `Game2.world`, `Game.writeOptions` is
|
||||
`Game2:persistOptions`) and the one data table that was renamed
|
||||
(`game.data.sprites` is `data.gen2Sprites`).
|
||||
- **A member with no backing says so.** `game.data.field` does not exist on
|
||||
Gold, so it reads nil *and* logs once, naming the mods holding the facade.
|
||||
`BattleState.newWild` is absent rather than invented, because a `newWild`
|
||||
that took a species and a level would be a lie about what Gold's battle
|
||||
screen is.
|
||||
- **One stable table for the run.** Where the Gen 2 arm can serve the name
|
||||
outright the adapter *is* that module, so a mod's monkey-patch, its
|
||||
`rawset` sentinel and its `==` idempotency check all land on the table Gold
|
||||
actually runs.
|
||||
|
||||
### What the adapter says it covers
|
||||
|
||||
The adapter publishes its own coverage, versioned by
|
||||
`Gen2Compat.COVERAGE_VERSION` (1), and `modkit gen2check` consumes that table
|
||||
rather than a second copy of the same knowledge:
|
||||
|
||||
```lua
|
||||
Gen2Compat.modules() -- the 15 names, sorted
|
||||
Gen2Compat.serves(name) -- boolean
|
||||
Gen2Compat.memberStatus(name, member) -- "backed" | "warned" | "absent" | nil
|
||||
Gen2Compat.coverage(name) -- a fresh table per call:
|
||||
-- { module, kind = "facade"|"alias", target, members = { [name] = status },
|
||||
-- notes = { [name-or-topic] = "one line" } }
|
||||
```
|
||||
|
||||
The status vocabulary is frozen at three values, and a member listed as both
|
||||
resolves to the weaker claim:
|
||||
|
||||
| status | means |
|
||||
| --- | --- |
|
||||
| `backed` | present, and it does the Gen 1 job on Gold |
|
||||
| `warned` | present, answers nil or degrades, and names itself once with the mod attributed |
|
||||
| `absent` | deliberately not served; a nil read is the honest failure |
|
||||
|
||||
Today that is 291 backed, 32 warned and 161 absent across the fifteen modules.
|
||||
`notes` keys are documentation topics rather than a member list -- dotted paths
|
||||
(`save.money`), field names (`warpAt`), hook names (`hook ui.pc.items`) and
|
||||
bare topics (`identity`, `iteration`, `rawset`) all appear there. `members` is
|
||||
the authoritative set, and a member it does not record is not a promise either
|
||||
way: on an alias it resolves to whatever the Gen 2 module has, on a
|
||||
write-through facade it falls to the Gen 2 class, on the `src.core.Game` facade
|
||||
it reads nil and says so, and on the `src.world.OverworldController` facade it
|
||||
reads nil silently.
|
||||
|
||||
**The follower.** Gold's cart has no trailing companion at all, so
|
||||
`src/world/gen2/Follower.lua` is new Gen 2 code rather than a facade: the
|
||||
entity, the trail loop, and a `shouldSpawn` a mod replaces. `World:step` calls
|
||||
`Follower.update(game, world)` once per logic frame after the body, and
|
||||
`World:setMap` calls `Follower.onMapEntered` before it emits `map.entered` --
|
||||
the same two call sites `src/world/OverworldController.lua` gives the Gen 1
|
||||
arm, which is what makes a Gen 1 follower mod's wrappers tick.
|
||||
|
||||
Vanilla never spawns one: `shouldSpawn` answers false until something replaces
|
||||
it. `Follower.setShouldSpawn(fn)` is the supported way, and it writes the same
|
||||
file-local the Gen 1 mods reach through `debug.setupvalue` on the upvalue named
|
||||
`shouldSpawn`, so the two cannot disagree.
|
||||
|
||||
Two Gen 2 engine changes came with it, both general rather than follower-only:
|
||||
an entity with `passable` set never blocks a step (the Gen 1 name and meaning,
|
||||
`src/world/Collision.lua`), and `World:rebuildPeople` now preserves **guests** --
|
||||
anything in the people list it did not put there. A rebuild runs on every zoom
|
||||
and every time-of-day roll, so without that a follower vanished at the top of
|
||||
the hour.
|
||||
|
||||
**What the facades cannot fix.** A mod that allow-lists version strings
|
||||
(`GameVersion.get() == "red" or ...`) excludes itself from Gold by construction,
|
||||
and no adapter should special-case it. Neither is a Gen 1 screen id: Gold's
|
||||
builtins carry a `Gen2` prefix, so a mod matching `id == "BoxMenu"` matches
|
||||
nothing. A write to a field on a live Gen 2 menu instance is inert where Gen 1
|
||||
read it back (`menu.onSwitch`, `menu.swapFrom`, `StartMenu`'s box geometry),
|
||||
and `map.warpAt` is a name collision rather than a rename -- Gen 1's is a table
|
||||
keyed by cell, Gold's is a method, so indexing or iterating it raises. All of
|
||||
these are mod-side edits, each with a route that works on both generations;
|
||||
`docs/preparing-your-mod-for-gen2.md` walks through them.
|
||||
|
||||
## What works on Gold today
|
||||
|
||||
**Screens.** The `screens` registry serves both generations. Gold's screens
|
||||
are registered under `Gen2`-prefixed ids so a mod that replaces Gold's party
|
||||
menu does not also replace Red's; `Screens.GEN2_IDS` in `src/ui/Screens.lua`
|
||||
is the full list. Every screen Gold opens goes through an id, including the
|
||||
boot cinema and the START menu.
|
||||
|
||||
**Asset overrides.** `overrides/` shadowing and asset transforms work
|
||||
unchanged: Gold's screens load art through `src/render/Assets.lua`, the same
|
||||
choke point Gen 1 uses.
|
||||
|
||||
**Content registries at the shared path.** `pokemon`, `moves`, `items`,
|
||||
`type_chart`, `strings`, `font`, `screens`, `commands`, `tokens`,
|
||||
`growth_rates`, `battle_sprite_scales`, `render_pipelines`, and the audio
|
||||
family (`audio`, `music`, `sfx`, `cries`, `map_songs`). These keep their Gen 1
|
||||
target path, so one mod source targets both generations.
|
||||
|
||||
The last two are the newest and each carries one caveat worth stating before
|
||||
you write against it:
|
||||
|
||||
- **`battle_sprite_scales`.** `src/ui/gen2/BattleState.lua:imageScale` walks
|
||||
the merged table for a record whose `path` matches the pic being drawn,
|
||||
skipping the registry's own `_owners` row, and `picScale` falls through to
|
||||
the species record's `battleScaleFront` / `battleScaleBack` after it -- the
|
||||
same image-then-species-then-default order Gen 1 resolves in. Because the key
|
||||
is the asset path it also reaches the pics that are nobody's species: the
|
||||
player's trainer back, the DUDE's, an opponent's frontpic. The **default**
|
||||
differs and is not a registry record either side: Red's 32x32 back pics draw
|
||||
at 2x, Gold's 48x48 ones fill their 6x6 box at 1x, so a scale that looks
|
||||
right on Red is twice as large on Gold. At any scale the pic stays centred in
|
||||
its box and standing on the same ground line.
|
||||
- **`render_pipelines`.** `src/core/Game2.lua:load` installs
|
||||
`src/render/Pipelines.lua` on Gold's dataset *after* `mods:load`, so the
|
||||
merged table is the one it walks, and `Game2:draw` composites the
|
||||
whole-frame half through `Pipelines.wantsPresent` / `Pipelines.present` with
|
||||
the Gen 1 ctx keys (`width`, `height`, `scale`, `dpi`, `dpiX`, `dpiY`). The
|
||||
**`drawWorld` half is inert on Gold**: its overworld draws straight to the
|
||||
window rather than into a canvas the way `src/world/OverworldController.lua`
|
||||
hands one to `Pipelines.drawWorld`. A drawWorld-only pipeline is not left
|
||||
switched on and drawing nothing -- `Game2:load` retires a restored level for
|
||||
one, leaving `options.pipelines` untouched so the mode comes back the day
|
||||
Gold grows a world canvas. Gold also has no OPTION row for a pipeline
|
||||
(`Pipelines.rows` is read only from `src/ui/OptionsMenu.lua`), so a Gold
|
||||
player reaches one by its `hotkey`.
|
||||
|
||||
**Content registries at a Gen 2 path.** `maps`, `tilesets`, `sprites`, `text`,
|
||||
`encounters`, `trainers`, `palettes`, `icons`, `battle_anims`, `constants`,
|
||||
`statuses`, `move_effects`, `item_effects`, `balls`, `ai_classes` and
|
||||
`evolution_methods`. Same registry name, same verbs, a Gen 2 table underneath
|
||||
(`data.gen2Maps`, `data.gen2Encounters`, `data.gen2Statuses`, ...).
|
||||
`src/core/Game2.lua` loads the extracted ones into `game.data` before it
|
||||
calls `mods:load`, and every consumer takes them by reference and never
|
||||
copies, so what a mod merges is what the game walks: a registered map is a map
|
||||
Gold can warp into, a patched tileset is the one `Map.new` reads, a patched
|
||||
encounter table is the one the grass rolls.
|
||||
|
||||
The battle-rule six are the newer half and work slightly differently: there is
|
||||
no table on disk for them at all. They come into existence *as* the merge, and
|
||||
each consumer reads a record through a lookup that falls back to its own module
|
||||
records when no loader ran, so a mod-free Gold boot behaves identically:
|
||||
|
||||
| registry | who reads it |
|
||||
| --- | --- |
|
||||
| `statuses` | `Battle.statusRecordFor` / `statusPenaltyFor`, `Catching.statusBonus`, `ItemEffects.healClassOf` |
|
||||
| `move_effects` | `Battle.moveEffectRecordFor` (`useMove`'s dispatch) |
|
||||
| `balls` | `Catching.recordFor` |
|
||||
| `ai_classes` | `Ai.layersFor` (the ten `scoring.asm` passes, plus mod layers) |
|
||||
| `evolution_methods` | `Evolution.methodFor` |
|
||||
| `item_effects` | `ItemEffects.recordFor` / `partyAction` |
|
||||
|
||||
`src/mods/Builtins.lua` seeds those six with **Gold's** records under Gen 2
|
||||
rather than Red's. It has to: both games call it `GREAT_BALL`, and Red's record
|
||||
carries no `multiplier`, so seeding Red's would leave Gold's x1.5 reading nil.
|
||||
|
||||
**Content registries that exist because Gold does.** Six systems Red has no
|
||||
counterpart for, so there is no Gen 1 table to share and none of these carries
|
||||
a Gen 1 target at all. The routed Gen 2 path is their only home, and
|
||||
`Schemas.GEN1` gates them on a Red boot the way `Schemas.GEN2` gates
|
||||
`map_scripts` on a Gold one -- reported, not silently merged.
|
||||
|
||||
| registry | id space | who reads it |
|
||||
| --- | --- | --- |
|
||||
| `held_items` | item ids | `ItemEffects.heldItemFor`; the merged rows are written back onto `data.items` for `Battle:itemDef` |
|
||||
| `phone_contacts` | `PHONE_*` (`data.gen2Constants.phoneContactOrder`) | `Phone.useRegistry`, folded onto the contact table |
|
||||
| `decorations` | `"deco:<n>"` | `Decorations.attributes`, the single read point for an attribute row |
|
||||
| `apricorns` | apricorn item ids | `Apricorns.useRegistry`, which rebuilds all three lookups and Kurt's menu order |
|
||||
| `landmarks` | `LANDMARK_*` | `Nests.landmarkId` / `Nests.landmark`, which resolve a map header's landmark byte |
|
||||
| `radio_channels` | station ids | `MapRadio.channelRecord`, which puts a registered station on the dial |
|
||||
|
||||
`Game2:load` calls `Phone.useRegistry`, `Decorations.useRegistry`,
|
||||
`Apricorns.useRegistry` and `ItemEffects.applyHeldItems` immediately after
|
||||
`mods:load`, so the merge is live before the first frame. `landmarks` and
|
||||
`radio_channels` need no such call: their consumers take `data` at call time.
|
||||
|
||||
`landmarks` merges onto the cache's own `gen2Landmarks.landmarks` and
|
||||
`held_items` onto the view `Game2` builds from `data.items`, so both fold
|
||||
against the vanilla row -- a `register` for an existing id collides, a
|
||||
`patch` stacks. The other four come into existence as the merge, seeded from
|
||||
their module's literals by `src/mods/Builtins.lua`.
|
||||
|
||||
Four honest limits on that surface:
|
||||
|
||||
- `held_items` reaches the battle by being written back onto `data.items`, so a
|
||||
held row for an id with no `data.items` record lands nowhere. To invent a
|
||||
held item, register the `items` record too. The write-back is a diff against
|
||||
a pre-merge snapshot, which is what lets `items` and `held_items` compose
|
||||
instead of one reverting the other.
|
||||
- `decorations` ids are `"deco:<n>"`, not `DECO_*` names: the cart's decoration
|
||||
constants are a bare `const_def` block with no name table behind them, so
|
||||
there is nothing in the ROM to spell them by. `battle_anims` addresses its
|
||||
unnamed rows the same way. `n` is the attribute row's index, which is
|
||||
`wMenuSelection`.
|
||||
- `phone_contacts` does not register the four `PHONE_UNUSED` `const_skip` holes
|
||||
(contact bytes 8, 9, 10 and 25). The manifest gives all four the same id, and
|
||||
one id cannot key four rows. They stay copies of the wrong-number filler,
|
||||
which is what the cart does with them.
|
||||
- `radio_channels` and `phone_contacts` register *content*, not new UI: a
|
||||
registered station gets a dial position and a name, and a registered contact
|
||||
gets a row the Pokegear indexes, but neither invents a screen.
|
||||
|
||||
**Record shapes.** A registry whose Gen 2 records genuinely differ carries a
|
||||
Gen 2 schema beside its Gen 1 one (`gen2Fields` / `gen2Keys` / `gen2Write` in
|
||||
`src/mods/Schemas.lua`, resolved by `Schemas.shapeFor`). The registry name, the
|
||||
verbs and wherever possible the ids stay shared; only the record changes. The
|
||||
differences an author meets:
|
||||
|
||||
- **`pokemon`.** Gen 2 splits `special` into `specialAttack` /
|
||||
`specialDefense`, names the level-up table `levelMoves` and the pic size
|
||||
`picSize`, has no separate `level1Moves`, and points an evolution at `into`
|
||||
rather than `species`. It also carries the breeding block (`eggGroups`,
|
||||
`eggMoves`, `eggSteps`, `genderRatio`) and the wild held-item pair.
|
||||
- **`encounters`.** The id is the encounter *kind*, not the map:
|
||||
`mod.content.encounters:patch("grass", { ROUTE_29 = { rates = { NITE = 40 } } })`.
|
||||
A map's row carries a `rates` set per time of day and one slot list.
|
||||
`fishGroups`, `trees` / `treeSets`, `rocks`, `bugContest` and `roamMaps` are
|
||||
ids of their own.
|
||||
- **`trainers`.** The id is the trainer *class*, and the record is
|
||||
`{ name, index, attributes, baseMoney, encounterMusic, trainers, items }`,
|
||||
with one entry per named trainer of the class. The registry writes one level
|
||||
in, into `data.gen2Trainers.classes`, so the call shape is unchanged.
|
||||
- **`icons`.** Two id forms in one registry, routed by the `ICON_` prefix a
|
||||
sheet name carries: a species id names an assignment (a string, the sheet's
|
||||
name), an `ICON_*` id names a sheet.
|
||||
- **`palettes`, `battle_anims`, `constants`.** The id is a subtable of the
|
||||
target: `pokemon` / `trainers` / `bg` / `objects` / `roofs` for palettes,
|
||||
`scripts` / `moves` / `objects` / `framesets` / `oamsets` / `gfx` for
|
||||
battle_anims, and one of Gold's 42 ordered ROM name lists (plus `mapGroups`,
|
||||
`trainerClassMembers`, `types`) for constants. Those lists are ordered and
|
||||
position *is* the id a script byte resolves through, so they replace rather
|
||||
than append.
|
||||
|
||||
Four more id-space notes, because the records at those paths came out of a
|
||||
Gen 2 ROM:
|
||||
|
||||
- Gold's `text` ids are ROM pointer strings such as `"55:4067"`, not the
|
||||
`TEXT_*` names Red uses. `override` them by pointer; there is no name table.
|
||||
- A Gen 2 tileset carries its walkability as `collision` where Gen 1 says
|
||||
`walkable`. Both fields validate; only `collision` is read on Gold.
|
||||
- A Gen 2 warp row carries `destGroup` / `destMapNum` beside the `destMap` /
|
||||
`destWarp` pair Gen 1 also has. Both are optional in the shared schema, so a
|
||||
Gen 1 warp row and a Gen 2 one both validate, and patching one of Gold's own
|
||||
maps does not mean restating the ROM's map-group numbers.
|
||||
- Gold writes `"burn"` / `"sleep"` into `mon.status` where Red writes `BRN` /
|
||||
`SLP`. The `statuses` registry is the same registry; only the ids differ, and
|
||||
they have to.
|
||||
|
||||
**`mod.commands`.** Works on Gold. `src/script/gen2/Vm.lua` runs the cart's own
|
||||
bytecode, so there is no opcode byte to hand a mod -- the seam is a row the
|
||||
cart cannot write. `Opcodes.MOD_COMMAND` (`"modcommand"`) is an op *name* with
|
||||
no byte behind it, and the VM dispatches it through the same merged
|
||||
`data.commands` table Gen 1's runner resolves by name. Two row shapes reach it:
|
||||
|
||||
```lua
|
||||
{ op = "modcommand", verb = "mymod:shake", args = { 4, 2 } } -- native
|
||||
{ "mymod:shake", 4, 2 } -- Gen 1 row
|
||||
```
|
||||
|
||||
The second is the Gen 1 row shape verbatim, so one row list can serve both
|
||||
games as long as every row in it is the mod's own verb. The handler is called
|
||||
`fn(ctx, unpack(args))` with `ctx.vm` where Gen 1 has `ctx.runner`; it may
|
||||
block on `ctx.vm:showText` / `:waitFrames`, and its return value speaks Gen 1's
|
||||
control vocabulary (`"end"`, a row number, or nil). A missing or raising verb
|
||||
is warned once per name and the rest of the list still runs. The engine's own
|
||||
Gen 1 verbs are **not** seeded on Gold: a row-list verb handed Gold's ctx would
|
||||
find no runner on it, so `data.commands` under Gen 2 is the mod verbs alone.
|
||||
|
||||
**`mod.save`, `mod.options`, `mod.log`, `mod.assets`, `mod.find`, exports.**
|
||||
Generation-agnostic; nothing to adapt.
|
||||
|
||||
**`mod.world`.** Same method set, resolved against Gold's world
|
||||
(`src/world/gen2/WorldAPI.lua`). Two differences show through and are
|
||||
documented on the module: Gold's world is not a stack state, and Gen 2 event
|
||||
flags are numeric ids into `wEventFlags` rather than string keys.
|
||||
`mapOverview` returns the same read-only terrain, tile-shading, and marker
|
||||
shape, using Gold's live object masks and event flags to omit collected items.
|
||||
`spawnNpc` / `removeNpc` append onto the map def's own object list, the way the
|
||||
Gen 1 arm does, so a spawned actor is pooled, drawn, walked and talked to like
|
||||
an extracted one and survives a map reload; it is not serialized, so a mod
|
||||
respawns on `map.entered`. `queueScript` takes a small allowlist of verbs Gold
|
||||
has its own entry points for (`start_battle "wild" species level`, `warp`,
|
||||
`text`, `setflag`, `clearflag`) and refuses a list containing anything else
|
||||
**by name, before the first row runs**, so a mod never gets a half-run queue.
|
||||
`marchInPlace` still has no Gen 2 equivalent (the Gen 2 movement stream has no
|
||||
byte for it) and returns `nil, reason` rather than approximating one.
|
||||
`availableFieldActions` and `useFieldAction` expose the same contextual field
|
||||
item and move records in both games. Gold extends the shared ids with its own
|
||||
`headbutt`, `whirlpool`, `waterfall`, `sweet_scent`, and `squirtbottle`
|
||||
actions. Each engine keeps ownership of its inventory, badges, terrain,
|
||||
surfing, bike, fishing, and field-move rules.
|
||||
|
||||
**Hooks and events that fire on Gold.** Every name below is the Gen 1 name
|
||||
carrying the Gen 1 payload keys, because Gold's call sites reuse them rather
|
||||
than defining a parallel vocabulary; where Gen 2 carries more, the payload
|
||||
gains a field instead of the name gaining a prefix.
|
||||
|
||||
- *Engine-wide, from the shared modules:* `game.ready`, `screen.pushed`,
|
||||
`screen.popped`, `screen.render_visible`, `music.started`, `music.stopped`,
|
||||
`music.select`, `music.volume`, `sound.played`, `zoom.range`,
|
||||
`assets.transformed`, `mods.loaded`, `mod.options_changed`.
|
||||
- *Overworld (`src/world/gen2/`):* `map.entered`, `map.exited`,
|
||||
`map.reloaded`, `player.warped`, `world.stepped`, `world.interacted`,
|
||||
`world.npc_spawned`, `world.trainer_engaged`, `world.blacked_out`,
|
||||
`world.block_replaced`, `world.boulder_moved`, `world.tod_changed`,
|
||||
`world.object_toggled`, `flag.changed`; hooks `warp.destination`,
|
||||
`movement.collision`, `movement.speed`, `encounter.roll`,
|
||||
`encounter.species`, `encounter.fishing`, `world.tod`, `map.palette`,
|
||||
`fieldmove.eligibility`. `flag.changed` carries the numeric `wEventFlags`
|
||||
id under Gen 1's `name` key, which is the one payload difference the
|
||||
numeric flag space forces.
|
||||
- *Menus (`src/ui/gen2/`):* `ui.start_menu.items`, `ui.title_menu.items`,
|
||||
`ui.options.rows`, `ui.party.submenu`, `ui.naming.grid`, `ui.pc.items`,
|
||||
`ui.list_menu`, `transition.style`. `ui.list_menu` covers Gold's script
|
||||
menus (`ScriptMenu.lua`); the `Chrome.List` widget the START and title
|
||||
menus draw with does not raise it yet, so those two are composed through
|
||||
their own hooks only.
|
||||
- *The Oak speech (`src/ui/gen2/OakSpeech.lua`):* `intro.oak_speech.started`,
|
||||
`intro.oak_speech.step`, `intro.oak_speech.answered`,
|
||||
`intro.oak_speech.finished`, and the `intro.oak_speech.build` hook. Gold has
|
||||
a real Oak speech, so it is the same extension point rather than a second
|
||||
one: same names, same payload keys, same moments in the sequence. The beats
|
||||
are a data table with the same step vocabulary (`say` / `pic` / `name` /
|
||||
`choice` / `yesno` / `shrink` / `fn`, plus Gold's own `initclock` and
|
||||
`demo`), and the step *ids* match Gen 1's wherever the moment is the same --
|
||||
`oak_welcome`, `demo_mon`, `world_spiel`, `ask_player_name`, `name_player`,
|
||||
`legend`, `shrink` -- so `ModUI.insertStepBefore(steps, "name_player", ...)`
|
||||
lands in the right place in both games. The two ids with no Gen 1
|
||||
counterpart are Gold's own beats, `init_clock` (the `farcall InitClock` the
|
||||
speech opens with) and `oak_study` (the return to Oak for `_OakText5`). Gold
|
||||
has no rival-naming or name-confirmation beats, so it raises no anchors for
|
||||
them: the rival is named by `CopScript` in `maps/ElmsLab.asm`, hours later.
|
||||
- *Battle (`src/battle/gen2/`):* `battle.started`, `battle.ended`,
|
||||
`battle.turn_started`, `battle.turn_ended`, `battle.move_used`,
|
||||
`battle.damage_dealt`, `battle.fainted`, `battle.status_inflicted`,
|
||||
`battle.battler_switched`, `battle.ball_thrown`, `battle.exp_gained`,
|
||||
`pokemon.level_up`, `pokemon.move_learned`; hooks `battle.damage`,
|
||||
`battle.crit`, `battle.accuracy`, `battle.turn_order`,
|
||||
`battle.enemy_action`, `battle.run`, `battle.exp_award`, `exp.gain`,
|
||||
`catch.rate`, `trainer.party`, `battle.overlay`, `battle.low_health_alarm`,
|
||||
`battle.catch_exp`, `battle.bottom_ui_visible`,
|
||||
`battle.status_hud_visible` and `battle.move_grid_navigation`. One payload
|
||||
difference: Gen 1's vanilla
|
||||
`battle.low_health_alarm` link reads `ctx.battle.data`, and Gold's battle
|
||||
screen has no `.data` field, so the Gen 2 site **adds** `ctx.data` beside the
|
||||
Gen 1 keys. A mod that calls `nextFn` is unaffected; one that reaches through
|
||||
`ctx.battle.data` instead gets nil on Gold.
|
||||
- *The catch and the evolution:* `pokemon.caught`, `pokemon.evolved`; hook
|
||||
`evolution.check`. `src/ui/gen2/BattleState.lua:pushCaught` emits
|
||||
`pokemon.caught` once the mon is in the party or the box, and
|
||||
`src/core/gen2/Evolution.lua` emits `pokemon.evolved` from `apply` and wraps
|
||||
each row's decision in `evolution.check`. The hook passes `data` where Gen 1
|
||||
passes `game`; positions 2-4 (mon, row, trigger) match.
|
||||
- *The frame (`src/core/Game2.lua`):* hooks `input.step`, `input.pointer`,
|
||||
`render.zones`, `render.compose`, `render.output_enabled`, `render.output`,
|
||||
`render.letterbox`, `render.hud`, `render.viewport`, `render.window`. Each sits
|
||||
at the same moment `src/core/Game.lua` and `src/render/Renderer.lua` raise it
|
||||
-- the logic tick before the pad is read, a pointer the touch overlay gets
|
||||
first refusal on, the palette zone list handed to the present pass, the
|
||||
composed frame before GBCFX, the letterbox, and the finished playfield rect
|
||||
-- and carries the same payload.
|
||||
`render.hud`'s `gameX` / `gameY` really is where Gold's dialogue boxes and
|
||||
menus land, because `Chrome.fitScale` / `fitOrigin` and `World:fitScale`
|
||||
compute the same number. `render.zones` is handed `nil` in GBC mode (Gold
|
||||
computes no zone of its own there) and the engine's own one-rect list in
|
||||
CLASSIC mode; a rect that clamps to nothing is skipped rather than throwing,
|
||||
which is what `src/render/Renderer.lua:scissorClamped` does on the Gen 1 side.
|
||||
- *Sprites (`src/pokemon/Sprites.lua`, shared):* `pokemon.sprite`,
|
||||
`pokemon.icon` and `player.sprite`. `pokemon.icon` is reached from
|
||||
`src/ui/gen2/PartyMenu.lua` through the shared module, so it is one call site
|
||||
serving both games. `player.sprite` is raised by `Sprites.playerPic`, which
|
||||
Gold's battle back pic (`src/ui/gen2/BattleState.lua`), Hall of Fame and
|
||||
intro call with an already-resolved path: Gold's trainer art is not in
|
||||
`field.playerPics`, so the path is found first and the hook raised over it,
|
||||
with the Gen 1 `ctx` keys (`side`, `kind`, `demo`, `battle`, `data`)
|
||||
unchanged. The Gen 2 trainer card is the one player-art read still outside
|
||||
it: its portrait is a tile sheet that also carries the frame tiles, not a
|
||||
swappable pic.
|
||||
`pokemon.sprite` has a second site of its own in
|
||||
`src/ui/gen2/BattleState.lua`, which adds `letter` (Unown) and `shiny` to the
|
||||
Gen 1 ctx keys -- both concepts Red does not have.
|
||||
- *Save and the script VM:* `save.created`, `save.loaded`, `save.loading`,
|
||||
`save.writing`; hooks `save.write`, `save.new_game`, `script.command`, and
|
||||
the `script.started` / `script.ended` pair off `src/script/gen2/Vm.lua`.
|
||||
`script.command` reports a mod's own row under the name `"modcommand"` with
|
||||
the row's real operands, and may rewrite them, on the same path it wraps a
|
||||
cart row.
|
||||
|
||||
## New in Gen 2
|
||||
|
||||
These have no Gen 1 analogue -- Red has no friendship byte, no day care egg,
|
||||
no Pokegear, no radio, no held items -- so they are the only places a new name
|
||||
is justified. They are **live**, guarded by `Runtime.wants` /
|
||||
`Runtime.wantsHook`, and each is driven through a real bus by
|
||||
`tests/engine/gen2_new_seams.lua`.
|
||||
|
||||
### Events
|
||||
|
||||
| event | raised from | payload |
|
||||
| --- | --- | --- |
|
||||
| `happiness.changed` | `Happiness` (`ChangeHappiness`, `StepHappiness`) | `mon`, `event`, `reason` (`"event"` / `"step"`), `delta`, `from`, `to` |
|
||||
| `breeding.egg_created` | `Breeding` (`DayCare_InitBreeding`) | `egg`, `mother`, `father`, `compatibility`, `stepsToEgg` |
|
||||
| `egg.hatched` | `Breeding` | `mon`, `egg`, `slot`, `species`, `nickname` |
|
||||
| `phone.call_received` | `PhoneRing.script` | `call`, `contact`, `name`, `className`, `special`, `scriptKey` |
|
||||
| `clock.day_changed` | `Clock` | `day`, `previous`, `reason` |
|
||||
| `pokerus.infected` | `Pokerus` | `party`, `slot`, `mon`, `strain`, `days`, `source` |
|
||||
| `roamer.moved` | `Roamers` | `index`, `slot`, `species`, `from`, `to`, `reason` |
|
||||
| `roamer.encountered` | `Roamers` | `index`, `slot`, `species`, `level`, `mapId` |
|
||||
| `apricorn.converted` | `Apricorns` (Kurt) | `apricorn`, `ball`, `event` |
|
||||
| `bug_contest.scored` | `BugContest` | `mon`, `score`, `place`, `results` |
|
||||
| `unown.unlocked` | `Unown` (`UpdateUnownDex`) | `letter`, `name`, `word`, `count` |
|
||||
| `radio.channel` | `MapRadio` | `station`, `channel`, `name`, `source` |
|
||||
| `mail.written` | `Mail` | `entry`, `slot`, `mon`, `message`, `author`, `source` |
|
||||
| `mail.read` | `Mail` | `entry`, `message`, `author`, `top`, `bottom` |
|
||||
| `intro.boot.copyright` | `CopyrightSplash:enter` | `screen`, `game` |
|
||||
| `intro.boot.gamefreak` | `GameFreakPresents:enter` | `screen`, `game` |
|
||||
| `intro.boot.movie` | `GoldSilverIntro:enter` | `screen`, `game` |
|
||||
| `intro.boot.movie_ended` | `GoldSilverIntro:finish` | `screen`, `game`, `skipped`, `frames` |
|
||||
| `intro.boot.title` | `TitleState:enter` | `screen`, `game` |
|
||||
|
||||
The four `intro.boot.*` cards are the GS boot cinema, and they are the one part
|
||||
of Gold's intro with no Gen 1 moment to share a name with: Red boots into
|
||||
`IntroMovie` with no copyright card, no GAME FREAK splash and no attract movie.
|
||||
The Oak speech immediately after them is the opposite case and reuses
|
||||
`intro.oak_speech.*` verbatim (see the shared table above).
|
||||
|
||||
Each card raises its name the frame it comes up, because that is the moment a
|
||||
mod can act on. Only the movie has an `_ended` name, and only because it
|
||||
carries a fact nothing downstream does -- `skipped` is the difference between a
|
||||
player who watched all 2335 frames and one who pressed START. The other three
|
||||
cards chain straight into the next card, whose own event is their end.
|
||||
|
||||
`delta` on `happiness.changed` is `to - from`, not the table's column, because
|
||||
the 0 and $ff carry clamps are part of what the cart applied: a mon at 254
|
||||
gaining "5" gained 1.
|
||||
|
||||
`clock.day_changed` compares against a process-local latch, so the first read
|
||||
after a boot has nothing to compare against and raises nothing. That is by
|
||||
design; it is a day *change*, not a day report.
|
||||
|
||||
`unown.unlocked` is raised from `UpdateUnownDex` -- a form first entering the
|
||||
`#DEX` list -- not from the four `ENGINE_UNLOCKED_UNOWNS_*` puzzle flags. Those
|
||||
flags are written by the cart's own `setflag`, so there is no Lua transition at
|
||||
the puzzle solve to hang a second event on yet.
|
||||
|
||||
`mail.read` rides `Mail.lines` with a per-struct latch, because the read page
|
||||
redraws every frame. The latch is re-armed by `Mail.get` / `Mail.mailbox`,
|
||||
which is how both readers pick the letter they are about to open, so reopening
|
||||
the same letter raises a second event.
|
||||
|
||||
### Hooks
|
||||
|
||||
| hook | wraps | ctx | vanilla answer |
|
||||
| --- | --- | --- | --- |
|
||||
| `held_item.trigger` | `Battle:heldEffect` | `battle`, `mon`, `item`, `def`, `effect`, `parameter`, `trigger` | `ctx.effect, ctx.parameter` |
|
||||
| `breeding.compatibility` | `Breeding.compatibility` | `data`, `mon1`, `mon2`, `dayCare` | the vanilla byte |
|
||||
| `phone.contact_list` | `Phone`'s `wPhoneList` read | called `(save, list)`, the shape the other list hooks use | the same list |
|
||||
| `shiny.roll` | `Mon` | `dvs`, `species`, `def`, `level` | the DV-derived boolean |
|
||||
| `gender.roll` | `Mon` | `def`, `dvs`, `ratio`, `species`, `level` | the DV-derived gender |
|
||||
|
||||
`held_item.trigger` is one hook over eight call sites, because on the cart
|
||||
those eight *are* one routine (`GetUserItem` / `GetOpponentItem` loading b and
|
||||
c, and the caller comparing b against the `HELD_*` it cares about). `trigger`
|
||||
says which comparison is about to happen: `"priority"` (Quick Claw),
|
||||
`"damage"` (Scope Lens and the type-boost family), `"endure"` (Focus Band),
|
||||
`"flinch"` (King's Rock), `"accuracy"` (BrightPowder), `"confuse"`,
|
||||
`"residual"` (the end-of-turn Leftovers / Berry / cure arm), and `"check"` for
|
||||
any other read. Return nil to make the item do nothing at that trigger, or
|
||||
another `HELD_*` name to substitute one -- every call site compares against a
|
||||
name, so substitution is the whole mechanism.
|
||||
|
||||
`held_item.trigger` wraps the *read*, so a mod can suppress or substitute an
|
||||
effect from any item. Defining a **new** held item is the `held_items`
|
||||
registry's job, and the two compose: register the row, then steer it from the
|
||||
hook.
|
||||
|
||||
`phone.contact_list` refuses an answer of the wrong length or with an unknown
|
||||
contact id (unknown ids blank to 0 on purpose, so the Pokegear never indexes a
|
||||
nil). It reorders and blanks the ten save slots; registering a contact id the
|
||||
game does not know is `phone_contacts`' job.
|
||||
|
||||
`shiny.roll` does not override a forced-shiny battle (`opts.shiny`), which is
|
||||
how the cart's own scripted shiny Gyarados stays shiny.
|
||||
|
||||
## Registries with no Gen 2 home
|
||||
|
||||
Writing to one of these while Gold is running takes the write, drops it, and
|
||||
reports it once per mod into the same error feed the manager shows. It is not
|
||||
fatal: a mod that supports both generations registers its Gen 1 content
|
||||
unconditionally and still loads the half that applies. The report is worded
|
||||
from the boot's own generation, because the gating runs both ways.
|
||||
|
||||
`rulesets`, `transitions`, `field`, `text_pointers`, `link_fields`,
|
||||
`map_scripts`.
|
||||
|
||||
`Schemas.GEN2` in `src/mods/Schemas.lua` is the authoritative table, and
|
||||
`tests/engine/gate_gen2_mod_api.lua` holds it to the catalog.
|
||||
|
||||
The list used to have three causes behind it and now has one. "No Data path
|
||||
exists" closed when the overworld tables stopped loading off disk into World
|
||||
fields. "The shape differs" closed when a registry gained the option of
|
||||
carrying a Gen 2 record schema beside its Gen 1 one. What is left is one cause:
|
||||
|
||||
**Gold reimplements the system without reading a registry.** The Gen 1 target
|
||||
is still built and merged into, but nothing in a Gold boot ever looks at it, so
|
||||
routing the registry would be a merge into a table with no reader -- exactly
|
||||
the silent no-op the gate exists to prevent. Closing one of these is a consumer
|
||||
change in the Gen 2 module first and a routing row second:
|
||||
|
||||
- `rulesets`: no Gen 2 ruleset dispatch exists.
|
||||
- `transitions`: Gold draws its own battle intro
|
||||
(`src/ui/gen2/BattleTransition.lua`), and its `STYLES` is a boolean *set* of
|
||||
the four cart wipes (`spin`, `speckle`, `zoom`, `sine`) rather than the
|
||||
`{ frames, draw, sound, flash }` record this registry carries. There is no
|
||||
styleDef lookup for a registered id to reach, so a mod style would fail the
|
||||
`STYLES` membership test and fall back to vanilla -- routing it would be the
|
||||
silent no-op, not the fix.
|
||||
- `field`: the Gen 1 overworld's data grab bag. Gold's equivalents live in
|
||||
`data.gen2Maps` and the VM's own tables.
|
||||
- `text_pointers`: Gen 1's `TEXT_*` indirection. Gold's text *is* pointers.
|
||||
- `link_fields`: link play is Gen 1 only.
|
||||
- `map_scripts`: `data.gen2Scripts` is the cart's bytecode pool keyed by ROM
|
||||
pointer, and a Lua row list merged into it is not something
|
||||
`src/script/gen2/Vm.lua` can run. Routing it needs a Gen 2 side dispatcher in
|
||||
`World`, not just the verb table `mod.commands` already has. The
|
||||
`script.started` / `script.ended` / `script.command` seams do fire, so a mod
|
||||
observes and can veto a script it cannot yet author whole.
|
||||
|
||||
Four of this list closed after it was written, and how they closed is the
|
||||
pattern for the rest:
|
||||
|
||||
- **`growth_rates`** now routes to the SHARED Gen 1 target. Gold's curves are
|
||||
coefficient rows in the extracted `pokemon.lua`, so `src/mods/Builtins.lua`'s
|
||||
Gen 2 registrant wraps each as the `{ expForLevel }` record Gen 1's registry
|
||||
uses, and `src/battle/gen2/Mon.lua:growthFor` is the one accessor all six
|
||||
readers go through (`Mon` twice, `BattleState`, `SummaryMenu`, `Breeding`,
|
||||
`ItemEffects`). One record shape, one id space, one mod source for both
|
||||
games. Because it is routed, the `pokemon` schema's `growthRate` reference is
|
||||
now checked rather than skipped, and it resolves: both sides say
|
||||
`GROWTH_MEDIUM_SLOW`.
|
||||
- **`tokens`** was on the list by mistake rather than by cause. `TextBox.new`
|
||||
runs `TextBox.substitute` on every box in both generations and `substitute`
|
||||
reads `game.data.tokens`, so the shared target was live on Gold the whole
|
||||
time. A `{NAME}` a mod registers expands in the world, the menus and the VM's
|
||||
pages alike.
|
||||
- **`battle_sprite_scales`** closed consumer-first, the `growth_rates` way:
|
||||
`src/ui/gen2/BattleState.lua` grew `imageScale` / `picScale`, a faithful
|
||||
mirror of Gen 1's `BattleState.imageBattleScale` / `resolveBattleScale` down
|
||||
to skipping `_owners` and the image-then-species-then-default order, so the
|
||||
registry now routes to the SHARED Gen 1 path and one record serves both
|
||||
games. Only the default is generation-specific, and neither side reads that
|
||||
from the registry.
|
||||
- **`render_pipelines`** closed because the reader moved, not the registry:
|
||||
`src/core/Game2.lua:load` installs `src/render/Pipelines.lua` on Gold's
|
||||
merged dataset after `mods:load` and `Game2:draw` composites `present`. The
|
||||
`drawWorld` half is still inert, which is why this one is worth reading the
|
||||
caveat above for -- it is routed on the strength of the half that works, and
|
||||
Gold retires a drawWorld-only level rather than pretending.
|
||||
|
||||
## Hooks and events Gold does not raise yet
|
||||
|
||||
Gold has its own draw path, intro, evolution and sprite lookups, so the call
|
||||
sites in those Gen 1 modules are not on Gold's path. The names are not taken
|
||||
and not reserved for Gen 1: when a Gen 2 call site lands it uses the existing
|
||||
name and the existing payload, plus fields where Gen 2 genuinely carries more
|
||||
(the split special stats, held items on a trainer roster).
|
||||
|
||||
The list is much shorter than it was. What is outstanding, in descending value:
|
||||
|
||||
- `trainer.before_battle`: Gold constructs and pushes its trainer battle in
|
||||
`src/world/gen2/World.lua:startBattle`, which does not yet expose a deferred
|
||||
preparation boundary or a battle-local player-party view. Gen 1 mods can use
|
||||
the hook documented in `docs/modding.md`; do not claim Gold compatibility
|
||||
when that selection is required.
|
||||
- `pokemon.before_give` / `pokemon.received`: Gold has no give-mon seam of its
|
||||
own yet.
|
||||
- `link.*` and `trade.completed`: a Gold boot offers no link menu at all. The
|
||||
Gen 2 fingerprint and handshake exist (`src/link/Fingerprint.lua` hashes a
|
||||
Gen 2 surface and a cross-generation pairing is refused by name), but nothing
|
||||
in `src/ui/gen2/` opens onto the protocol, so these raise nowhere.
|
||||
|
||||
Four groups that used to sit here have since landed and moved to the shared
|
||||
table above: the frame seams (`render.compose` / `render.hud` /
|
||||
`render.letterbox` / `render.zones`, `input.step` / `input.pointer`), the three
|
||||
battle seams (`battle.overlay`, `battle.low_health_alarm`,
|
||||
`battle.catch_exp`), the two sprite lookups (`pokemon.sprite`,
|
||||
`pokemon.icon`), and the catch/evolution trio (`pokemon.caught`,
|
||||
`pokemon.evolved`, `evolution.check` -- `src/ui/gen2/BattleState.lua` emits
|
||||
`pokemon.caught` from `pushCaught` once the mon is in the party or the box, and
|
||||
`src/core/gen2/Evolution.lua` emits `pokemon.evolved` from `apply` and wraps
|
||||
each row's decision in `evolution.check`).
|
||||
|
||||
Three partial coverages worth knowing about, because "the hook exists" is not
|
||||
the same as "the hook sees everything":
|
||||
|
||||
- `encounter.roll` / `encounter.species` are wired into the grass/water step,
|
||||
`randomwildmon`, the Bug Contest and SWEET SCENT, but **not** into
|
||||
`World:tryHeadbutt`, `World:rockMonEncounter` or `Roamers.checkEncounter`.
|
||||
Those three read row shapes that are not `{ species, level }` slot lists, so
|
||||
a mod that reskins encounters misses headbutt trees, rock smash and the
|
||||
roamers.
|
||||
- `src/ui/gen2/BattleState.lua` builds a flat `opts` for `Catching.attempt`
|
||||
with no `data` in it, so a mod-registered ball is readable through
|
||||
`Catching.recordFor` but is not yet resolved at the real throw site.
|
||||
- Three Gold UI files carry their own copy of the status HUD labels the merged
|
||||
`statuses` records now hold as `hudLabel`, so a mod status shows no label in
|
||||
the battle HUD, the party menu or the summary page until they read
|
||||
`Battle.statusRecordFor(data, status).hudLabel`. The values are identical
|
||||
today, so nothing vanilla is affected.
|
||||
|
||||
## Gen 2 tables with no registry
|
||||
|
||||
`Game2:load` assigns 24 `data.gen2*` tables and 12 of them are registry-backed,
|
||||
so twelve sit in `game.data` on a Gold boot with no registry pointing at them:
|
||||
`gen2Marts`, `gen2Roofs`, `gen2StdScripts`, `gen2EventTables` (the phone book,
|
||||
in-game trades, elevator labels, decoration descriptions), `gen2InitialEvents`,
|
||||
`gen2Pokedex`, `gen2MenuGfx`, `gen2Intro`, `gen2Credits`, `gen2Diploma`,
|
||||
`gen2Trade`, and `gen2Scripts` (which the `map_scripts` registry does reach, so
|
||||
it is the one of the twelve that is not out of reach). Naming registries for the
|
||||
rest is new API surface rather than a routing change, so it is deliberately not
|
||||
done yet.
|
||||
|
||||
## Testing a Gen 2 mod
|
||||
|
||||
Static first. `gen2check` reads the manifest, scans every `.lua` the package
|
||||
carries and cross-references what it finds against the coverage table above:
|
||||
|
||||
```sh
|
||||
python3 tools/modkit.py gen2check my_mod # or a path
|
||||
python3 tools/modkit.py gen2check my_mod --notes # + the caveat on each backed member
|
||||
```
|
||||
|
||||
It reports one of `will load`, `will load but degrade` or `will not work`, with
|
||||
a `MK4xx` finding per site and an `unresolved:` note, carrying a file and a
|
||||
line, for every reach a static scan could not follow. Exit 0 clean, 1 on a
|
||||
fatal finding (or any finding under `--strict`), 2 on usage; `--json` emits the
|
||||
whole batch as one document, and `--quiet` prints the findings alone, so a
|
||||
clean mod prints nothing and the exit code is the answer. The rule ladder is
|
||||
`MK400`-`MK410` and is listed in `tools/modkit.py`'s section header.
|
||||
|
||||
Then the headless harness, which takes the generation without booting Gold:
|
||||
|
||||
```lua
|
||||
local run = T.sdk.loadMod("mods/my_mod", { generation = 2 })
|
||||
T.eq(run.mod and run.mod.state, "loaded",
|
||||
"runs on gen 2: " .. tostring(run.mod and run.mod.skipReason))
|
||||
T.eq(#run.errors, 0, "and loads with no boot errors")
|
||||
```
|
||||
|
||||
Everything else is the production path: same loader, same validate, same
|
||||
topological sort, same merge. Assert the state as well as the error count: a
|
||||
gate skip is deliberately not an error, so `#run.errors == 0` passes for a mod
|
||||
that never ran a line.
|
||||
|
||||
Neither substitutes for a real Gold boot, and the two output channels there are
|
||||
not the same. The adapter's own warnings (`Gen2Compat.warnOnce`) go to the log
|
||||
only, each attributed to the mod holding the facade. The boot error feed the
|
||||
manager shows is `loader.errors`: a failed mod, a duplicate id, a registry with
|
||||
no Gen 2 target, a cross-validation problem, and a require for a Gen 1 module
|
||||
the adapter does not serve. A skipped mod and a degraded member are on neither
|
||||
list, by design.
|
||||
@@ -0,0 +1,96 @@
|
||||
# RFC 0008 — Runtime mod option schema export
|
||||
|
||||
## Status
|
||||
|
||||
Proposed. Engine: `src/mods/Loader.lua`. Tests:
|
||||
`tests/mod_loader_tests.lua`. This RFC defines an optional filesystem
|
||||
contract; it does not require a native launcher or any other consumer.
|
||||
|
||||
## Motivation
|
||||
|
||||
A native launcher may want to present settings for installed mods before it
|
||||
starts the game. Running every mod's entry chunk in that launcher just to
|
||||
discover its settings would duplicate engine behavior and give the launcher
|
||||
an unnecessary code-execution surface. The engine already has the authoritative
|
||||
runtime schemas after mod loading, so it can publish a data-only snapshot for
|
||||
platform shells that want one.
|
||||
|
||||
## The exact contract
|
||||
|
||||
After the mod loader has finished running entry chunks, it may write
|
||||
`mod_option_schemas.json` beside `options.lua` in the same filesystem. The
|
||||
document is a snapshot of the current boot; it is not a second settings store
|
||||
and does not change how option values are read or written.
|
||||
|
||||
Version 1 has this shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"mods": {
|
||||
"example": [
|
||||
{"key":"enabled","type":"toggle","label":"Enabled","default":true},
|
||||
{"key":"mode","type":"choice","label":"Mode","default":"safe",
|
||||
"choices":[["Safe","safe"],["Fast","fast"]]},
|
||||
{"key":"rate","type":"number","label":"Rate","default":5,
|
||||
"min":0,"max":10,"step":1},
|
||||
{"key":"name","type":"text","label":"Name","default":"","maxLen":12}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`mods` is keyed by mod id. Its rows come from the runtime
|
||||
`mod.options:define` schema, or from the legacy manifest `options_schema` file
|
||||
when the runtime schema is absent. The supported row types are `toggle`,
|
||||
`choice`, `number`, and `text`. Their optional fields retain the meanings
|
||||
established by the existing in-game option UI: choices are `[label, value]`
|
||||
pairs, numeric rows may provide `min`, `max`, and `step`, and text rows may
|
||||
provide `maxLen`. A row may also use
|
||||
`visible_if = {key = "mode", equals = "compact"}` or replace `equals` with
|
||||
`not_equals`. This only hides the in-game menu row; the schema and stored value
|
||||
remain available, and consumers that do not implement conditions may ignore
|
||||
the field.
|
||||
|
||||
Only mods that are enabled and successfully loaded in the current boot are
|
||||
included. A disabled or failed mod must not contribute rows. If an older
|
||||
snapshot exists and the current boot has no schema-bearing mods, the producer
|
||||
overwrites it with `{"schema_version":1,"mods":{}}`; this prevents stale
|
||||
settings rows from surviving a disable or load failure. A fresh mod-free boot
|
||||
does not create the file, and a filesystem without write support is tolerated.
|
||||
|
||||
The producer writes the snapshot after entry chunks and the final load set
|
||||
have been established. Consumers must treat the file as untrusted input and
|
||||
must not execute anything from it.
|
||||
|
||||
## Compatibility and versioning
|
||||
|
||||
The contract is optional on both sides. A native consumer may be absent, and
|
||||
the engine continues normally if the file cannot be written. A native
|
||||
consumer is not required to render, validate, or persist every supported row;
|
||||
it may ignore an unknown row type or optional field.
|
||||
|
||||
For compatibility with files produced by the original unversioned prototype,
|
||||
a missing `schema_version` means version 1. Consumers must ignore documents
|
||||
with a newer version rather than guessing at their shape. Producers must bump
|
||||
the version whenever they change the document envelope or the meaning of an
|
||||
existing field. New optional row fields that older consumers can safely ignore
|
||||
do not require a bump. Version 1 is therefore the legacy unversioned format as
|
||||
well as the explicitly versioned format shown above.
|
||||
|
||||
## Migration note
|
||||
|
||||
Nothing. Existing mods, option values, and the in-game options UI are
|
||||
unchanged. Platforms that do not consume `mod_option_schemas.json` have no
|
||||
new integration requirement.
|
||||
|
||||
## Parity tests
|
||||
|
||||
`tests/mod_loader_tests.lua` verifies the explicit version, runtime and legacy
|
||||
row round-tripping, enabled/disabled filtering, failed-mod filtering,
|
||||
stale-snapshot clearing, and tolerance of a read-only filesystem.
|
||||
|
||||
## Deprecation etiquette
|
||||
|
||||
Nothing is deprecated. The unversioned file form remains readable as legacy
|
||||
version 1; new producers write the explicit `schema_version` field.
|
||||
@@ -12,12 +12,175 @@ The modding book lives on the
|
||||
- [Registry reference](https://github.com/bryanthaboi/gen1recomp/wiki/Reference-Registries)
|
||||
— every registry, generated from `src/mods/Schemas.lua`.
|
||||
|
||||
Regenerate the reference straight into a wiki checkout:
|
||||
Regenerate the reference. With no argument it writes in-repo, to
|
||||
`docs/modding/reference/registries.md`; name a wiki checkout to write the
|
||||
wiki's own page name into it instead:
|
||||
|
||||
```sh
|
||||
luajit tools/gen_registry_docs.lua
|
||||
luajit tools/gen_registry_docs.lua ../gen1recomp.wiki
|
||||
```
|
||||
|
||||
## Manifest specification (`manifest.json`)
|
||||
|
||||
Every mod contains a root `manifest.json` defining its metadata, supported games, and dependencies for the engine loader.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my_mod",
|
||||
"name": "My Cool Mod",
|
||||
"version": "1.0.0",
|
||||
"api": 2,
|
||||
"entry": "main.lua",
|
||||
"profile": "content",
|
||||
"category": "GAMEPLAY",
|
||||
"games": ["gen1", "gen2"],
|
||||
"game_version": ">=0.0.0-dev <2.0.0",
|
||||
"priority": 100,
|
||||
"dependencies": [
|
||||
"helper_lib@^1.0.0",
|
||||
{ "id": "pokegear_cards", "games": ["gen2"], "range": "^1.0.0", "github": "1jamie/pokegear_cards" }
|
||||
],
|
||||
"optional_dependencies": [
|
||||
"gen1_modern_ui"
|
||||
],
|
||||
"required_imports": [
|
||||
{
|
||||
"id": "stadium2",
|
||||
"name": "Pokemon Stadium 2 ROM",
|
||||
"description": "Pokemon Stadium 2 (USA), any supported N64 byte order",
|
||||
"file": "stadium2.z64",
|
||||
"format": "n64",
|
||||
"size": 67108864,
|
||||
"md5": ["00000000000000000000000000000000"]
|
||||
}
|
||||
],
|
||||
"optional_imports": [
|
||||
{
|
||||
"id": "bonus_source",
|
||||
"name": "Optional bonus source",
|
||||
"file": "bonus.bin",
|
||||
"md5": "00000000000000000000000000000000"
|
||||
}
|
||||
],
|
||||
"conflicts": [],
|
||||
"permissions": ["engine_internals"],
|
||||
"description": "A brief description of the mod.",
|
||||
"github": "author/my_mod"
|
||||
}
|
||||
```
|
||||
|
||||
### Manifest Fields
|
||||
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `id` | `string` | Unique identifier (lowercase alphanumeric, underscores, hyphens). |
|
||||
| `name` | `string` | Human-readable title shown in launcher and manager. |
|
||||
| `version` | `string` | Semantic version string (e.g. `"1.0.0"`). |
|
||||
| `api` | `integer` | Mod API level (`2` for current standard, `1` for legacy). |
|
||||
| `entry` | `string` | Entry Lua file path relative to mod root (usually `"main.lua"`). |
|
||||
| `profile` | `string` | Mod profile: `"content"`, `"overhaul"`, or `"total_conversion"`. |
|
||||
| `category` | `string` | Categorization chip (e.g. `"GAMEPLAY"`, `"CONTENT"`, `"UI"`, `"AUDIO"`). |
|
||||
| `games` | `array` | Supported game versions: `["gen1"]`, `["gen2"]`, `["red"]`, `["blue"]`, `["yellow"]`, `["gold"]`, or `["all"]`. |
|
||||
| `game_version`| `string` | Semver range of required engine version (e.g. `">=0.0.0-dev <2.0.0"`). |
|
||||
| `priority` | `integer` | Load priority order (lower numbers load earlier; dependencies always precede dependents regardless of priority). |
|
||||
| `dependencies` | `array` | Hard required dependencies. A mod will not load if a required dependency is missing or disabled for the active game. |
|
||||
| `optional_dependencies` | `array` | Soft dependencies. Guarantees that if the target mod is present and active, it loads *before* this mod without blocking load if absent. |
|
||||
| `required_imports` | `array` | User-supplied files required by this mod. The launcher validates and copies each file into this mod's `baseroms/` directory; the mod does not load while one is missing. |
|
||||
| `optional_imports` | `array` | User-supplied files that unlock optional mod functionality. They use the same validation and private-copy flow but never block the mod from loading. |
|
||||
| `conflicts` / `incompatible` | `array` | List of mod IDs that cannot run concurrently with this mod. |
|
||||
| `permissions` | `array` | Requested privileges (e.g. `["engine_internals"]`, `["network"]`, `["filesystem"]`). |
|
||||
| `log_url` | `string` | Optional https URL for `mod.postLog` log reporting (api 2; requires the `network` permission). |
|
||||
| `github` | `string` | GitHub repository (`"owner/repo"`) used for update checks and dependency download links. |
|
||||
|
||||
### Declaring Dependencies & Scoping
|
||||
|
||||
Dependencies in `dependencies` and `optional_dependencies` can be declared in several formats:
|
||||
|
||||
1. **Simple string**: `"mod_id"`
|
||||
2. **Version-pinned string**: `"mod_id@^1.2.0"`
|
||||
3. **Repository-hinted string**: `"mod_id#owner/repo"` or `"mod_id@^1.2.0#owner/repo"`
|
||||
4. **Structured object**:
|
||||
```json
|
||||
{
|
||||
"id": "mod_id",
|
||||
"range": "^1.2.0",
|
||||
"games": ["gen2"],
|
||||
"github": "owner/repo"
|
||||
}
|
||||
```
|
||||
|
||||
#### Version-Scoped Dependencies
|
||||
When a mod supports multiple games (`"games": ["gen1", "gen2"]`), a dependency can specify `"games": ["gen2"]` to indicate it is only required when booting Gen 2. When booting Gen 1, the engine will ignore the dependency, preventing unnecessary boot blocks on games that do not need it.
|
||||
|
||||
### Required user-supplied files
|
||||
|
||||
`required_imports` and `optional_imports` keep copyrighted or otherwise user-owned source material
|
||||
out of mod archives while giving every platform the same installation flow.
|
||||
Each object requires a stable `id`, a display `name`, a destination `file`
|
||||
(a filename, never a path), and one MD5 digest or an array of accepted MD5
|
||||
digests. `format` is either `"raw"` (the default) or `"n64"`. An optional
|
||||
`description` gives players dump or region guidance in the import panel.
|
||||
`size` declares the exact canonical byte length; `max_size` declares a smaller
|
||||
per-import ceiling when an exact size is not appropriate. Every import also
|
||||
has an engine-enforced 128 MiB ceiling and is rejected before hashing when its
|
||||
filesystem reports an invalid size.
|
||||
|
||||
For `"n64"`, the launcher recognizes `.z64`, `.v64`, and `.n64` byte orders,
|
||||
strips a recognized 512-byte copier header, converts the bytes to canonical
|
||||
big-endian `.z64` order, and then checks MD5. The canonical bytes are written
|
||||
to `mods/<mod-id>/baseroms/<file>`. Each selection is a private grant to that
|
||||
mod: the launcher never scans or copies another mod's imported files merely
|
||||
because its manifest names the same digest. Mods read the result with their existing scoped `mod:read` API, for
|
||||
example `mod:read("baseroms/stadium2.z64")`; no host path or new filesystem
|
||||
permission is exposed. Missing `required_imports` block the mod before its
|
||||
entry chunk runs; missing `optional_imports` remain visible in the same
|
||||
launcher panel but do not block loading.
|
||||
|
||||
MD5 here identifies a known dump because ROM databases commonly publish it;
|
||||
it is not a security or authenticity guarantee. Do not paste the SHA-1 used by
|
||||
Gen1Recomp's own game-ROM importer into an import's `md5` field. Mod archives
|
||||
must not include anything beneath `baseroms/`. The engine records a validation
|
||||
receipt keyed by file size and modification time so launcher refreshes and
|
||||
later boots do not repeatedly hash an unchanged imported ROM.
|
||||
|
||||
New mobile code should call `love.system.pickFile("required_import")`. The
|
||||
older iOS-only `"stadium"` picker kind remains temporarily for compatibility.
|
||||
Android now returns `false` for unknown picker kinds instead of treating them
|
||||
as game-ROM picks.
|
||||
|
||||
### Platform import flow
|
||||
|
||||
The same per-mod validation and private `mods/<mod-id>/baseroms/` destination
|
||||
applies on every supported platform. Windows, macOS, and Linux use the
|
||||
launcher file chooser. Android uses the Storage Access Framework, and iOS uses
|
||||
the Files document picker; both stage the choice as `picked_required_import.bin`
|
||||
before validation. Xbox/UWP uses its native picker and hands the launcher a
|
||||
temporary path. Switch/NX has no host picker, so the player copies a file to
|
||||
`imports/baseroms/` over MTP and chooses the import again. No platform grants
|
||||
the mod a host filesystem path or bypasses the manifest's size, format, and MD5
|
||||
checks.
|
||||
|
||||
## Mods and Gold (Gen 2)
|
||||
|
||||
The mod API is one API across both generations, but Gold runs its own battle
|
||||
engine, overworld, script VM and save format, so a mod says which games it is
|
||||
for and Gold serves a declared subset of the surface.
|
||||
|
||||
- [`docs/preparing-your-mod-for-gen2.md`](preparing-your-mod-for-gen2.md)
|
||||
the migration guide: what breaks, the `games` manifest key, the module
|
||||
adapter, the patterns no adapter can fix, and a worked before/after.
|
||||
- [`docs/mod-api-gen2-compat.md`](mod-api-gen2-compat.md)
|
||||
the reference: every registry, hook and event, whether Gold serves it, and
|
||||
the record-shape differences where it does.
|
||||
|
||||
Start with the checker, which reads your manifest and scans your Lua against
|
||||
the adapter's own coverage table:
|
||||
|
||||
```sh
|
||||
python3 tools/modkit.py gen2check mods/my_mod
|
||||
```
|
||||
|
||||
## Editing maps in Tiled
|
||||
|
||||
Maps are data, not assets, so they can be authored in a real map editor and
|
||||
@@ -43,7 +206,97 @@ optional visual `tileRows` at 2x resolution, and optional `tileDetailRows` at
|
||||
`"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.
|
||||
read-only snapshots; mods choose which layers to render. Red and Gold expose
|
||||
the same contract while applying their own object and event visibility rules.
|
||||
|
||||
## Party ordering
|
||||
|
||||
Companion UIs and alternate party screens can call
|
||||
`mod.world:canReorderParty()` before offering a reorder action, then
|
||||
`mod.world:reorderParty(fromSlot, toSlot)` with one-based party slots. The
|
||||
operation is accepted only during idle overworld play; menus, movement,
|
||||
scripts, battles, and transitions leave the party untouched.
|
||||
|
||||
## Contextual field actions
|
||||
|
||||
`mod.world:availableFieldActions()` returns the field items and moves that can
|
||||
start at the player's current position. Both games expose `bicycle`, `fish`,
|
||||
`cut`, `surf`, `strength`, `flash`, `dig`, and `teleport`; Gold additionally
|
||||
exposes `headbutt`, `whirlpool`, `waterfall`, `sweet_scent`, and the
|
||||
contextual `squirtbottle` key item. Red additionally exposes `softboiled` with
|
||||
eligible `sources`; each source contains its eligible `targets`. Fishing rows
|
||||
include the owned rods that are valid choices. The list is empty while the
|
||||
world is busy, and omits an action whenever its item, move, badge, terrain, or
|
||||
engine state forbids it.
|
||||
The optional second return is `"world is busy"` during transient input locks
|
||||
or `"no overworld"` before a playable world exists.
|
||||
|
||||
Call `mod.world:useFieldAction(id, opts)` to perform a listed action through
|
||||
the active game's own field-item path. Fishing accepts `{ rod = "OLD_ROD" }`
|
||||
and chooses automatically when only one rod is available. Red's `softboiled`
|
||||
accepts one-based `{ sourceSlot, targetSlot }` values copied from its action
|
||||
record. Invalid, stale, and busy requests return `nil` plus a reason without
|
||||
changing game state. Mods do not need generation-specific badge, terrain,
|
||||
bike, fishing, or field-move
|
||||
logic. Action lists are extensible; callers should render the records they
|
||||
understand and ignore unknown ids rather than assuming a fixed list length.
|
||||
|
||||
Red exposes FLY separately because it requires a destination picker:
|
||||
`mod.world:canFly()` reports whether FLY is eligible at the current location,
|
||||
and `mod.world:flyTo(mapId)` accepts only a visited destination from the native
|
||||
Fly town list. Gold does not expose these two methods yet.
|
||||
|
||||
## Read-only battle snapshots
|
||||
|
||||
`mod.battle:snapshot()` returns `nil` outside a battle and a copied battle
|
||||
record while one is active. Gen 1 (Red, Blue, and Yellow) and Gold expose the
|
||||
same core fields:
|
||||
`revision`, `kind`, `catchable`, `prompt`, `message`, `turn`, `player`,
|
||||
`enemy`, `party`, `moves`, and `items`. Pokémon, moves, messages, and items in
|
||||
the result are detached records; changing them cannot change the battle.
|
||||
`revision` stays stable while the visible battle context is unchanged and
|
||||
advances when it changes, so a UI can skip rebuilding an identical view.
|
||||
|
||||
Pokémon records contain `species`, `name`, `level`, `hp`, `maxHp`, `status`,
|
||||
and `active` (plus `slot` in `party`). Move records contain `slot`, `id`,
|
||||
`name`, `pp`, `maxPp`, `type`, `power`, `accuracy`, and `disabled`. Gen 1 also
|
||||
reports the actual ruleset-aware `displayPower`, `hitChance` percentage, and
|
||||
`effectiveness` multiplier (`10` neutral, `20` super-effective, `5`
|
||||
resisted). Item rows contain `id`, `name`, `count`, `ball`, `needsTarget`, and
|
||||
an optional stock `catchChance` percentage.
|
||||
|
||||
`prompt` describes the currently visible choice (`menu`, `moves`, `party`,
|
||||
`advance`, `safari`, or `mimic`) and is `locked` when another screen or battle
|
||||
phase owns input. Generation-specific features remain optional: Gen 1 includes
|
||||
battle medicine, balls, catch previews, Safari balls, and Mimic choices. Gold
|
||||
exposes balls and their exact stock catch previews; targeted medicine remains
|
||||
screen-owned and is omitted rather than guessing at its pocketed PACK flow.
|
||||
Callers should ignore unknown fields and tolerate absent optional ones.
|
||||
|
||||
## Battle menu intents
|
||||
|
||||
`mod.battle:submit(intent)` applies a validated choice to the snapshot the mod
|
||||
just read. Every intent needs a mod-owned, strictly increasing positive
|
||||
integer `id` and the latest snapshot `revision`. Stale, replayed, covered, or
|
||||
invalid choices return `nil` plus a reason without changing the battle.
|
||||
|
||||
The shared Red, Blue, Yellow, and Gold intents are:
|
||||
|
||||
- `{ kind = "menu", choice = "fight" }` (`party`, `item`, and `run` are the
|
||||
other accepted choices)
|
||||
- `{ kind = "move", slot = 1..4 }`
|
||||
- `{ kind = "back" }` while the move menu is active
|
||||
|
||||
Red, Blue, and Yellow also expose their generation-specific choices:
|
||||
|
||||
- `{ kind = "safari", action = "ball" }` (`bait`, `rock`, and `run` are the
|
||||
other accepted actions)
|
||||
- `{ kind = "mimic", index = 1 }` using an entry's snapshot `index`
|
||||
|
||||
Menu choices and moves use the same engine methods as the native controls;
|
||||
`party` and `item` open the native screens rather than exposing or duplicating
|
||||
their mutable logic. Tutorial, link, forced, stale, and covered battle states
|
||||
refuse core intents. Use `mod.input` for ordinary text advance.
|
||||
|
||||
## Rendering pipelines
|
||||
|
||||
@@ -206,16 +459,49 @@ local keys, code, message = mod.storage:list(game, "history/quick")
|
||||
local deleted, code, message = mod.storage:delete(game, "history/quick/q0001")
|
||||
```
|
||||
|
||||
For independently generated binary data, use the opaque byte methods. They
|
||||
accept and return the exact Lua string of bytes, including NUL bytes and bytes
|
||||
that are not valid text:
|
||||
|
||||
```lua
|
||||
local ok, code, message = mod.storage:writeBytes(
|
||||
game, "cache/maps/pallet/terrain", encodedMesh)
|
||||
local encodedMesh, code, message = mod.storage:readBytes(
|
||||
game, "cache/maps/pallet/terrain")
|
||||
```
|
||||
|
||||
Opaque values are limited to 512 MiB per key. The engine stores them without
|
||||
decoding, compression, or an engine-defined file format, and never executes
|
||||
them. A consuming mod owns validation of its format, fingerprint, checksum,
|
||||
and compression metadata. Byte writes are staged and compared byte-for-byte
|
||||
before replacement, and reads can recover a valid backup after an interrupted
|
||||
write. Existing table values and opaque byte values use one shared logical key
|
||||
space; delete a key before changing its value from one type to the other.
|
||||
|
||||
`context` returns `{ engineVersion, gameVersion, playthroughId }`. The engine
|
||||
version is compatibility metadata; physical launcher-slot and path identity stays
|
||||
private.
|
||||
private. A title-selected context may additionally contain `normalSavedAt`, the
|
||||
validated matching ordinary-save chronology only; it never exposes normal-save
|
||||
progress or a slot/path handle.
|
||||
|
||||
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.
|
||||
At the title screen only, `mod.storage:selected(game)` returns a bound storage
|
||||
facade for the launcher-selected existing playthrough, or `nil, code, message`.
|
||||
Resolving this facade is non-allocating: it never allocates an identity, adopts a
|
||||
fresh New Game, or exposes a slot id/path. Its `context()`, `read(key)`,
|
||||
`write(key, value)`, `readBytes(key)`, `writeBytes(key, bytes)`,
|
||||
`list(prefix)`, and `delete(key)` methods have the same scoped and
|
||||
transactional contract as `mod.storage`, but remain restricted to the calling
|
||||
mod's selected existing namespace. It is intended for title tools that need to
|
||||
browse or manage durable history before the first normal SAVE.
|
||||
|
||||
Table values must contain serializable data only. Opaque values must be Lua
|
||||
strings. Keys are conservative slash-separated segments (letters, digits, `_`,
|
||||
`-`); paths and filesystem handles are never exposed. Table writes are staged
|
||||
and decode-verified; opaque writes are staged and byte-verified; reads recover
|
||||
from a valid staged/backup generation. Methods return structured errors for
|
||||
normal data, byte validation, and 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:
|
||||
|
||||
@@ -227,13 +513,21 @@ if capability.canCapture then
|
||||
end
|
||||
|
||||
local ok, code, message = mod.checkpoints:restore(game, checkpoint)
|
||||
|
||||
-- After the tool has durably committed its first checkpoint, make a
|
||||
-- never-saved playthrough reachable through ordinary title boot exactly once.
|
||||
local anchored, anchorCode, anchorMessage =
|
||||
mod.checkpoints:ensureNormalSave(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
|
||||
player-decision safe points. Ordinary single-player wild/trainer encounters are
|
||||
supported. Scripted story battles are also supported when the engine can detach
|
||||
their current built-in battle command and data-only row continuation, rebind any
|
||||
NPC by stable id, and resume the story through a fresh runner. The suspended Lua
|
||||
coroutine is never serialized. Link, Safari, ghost, demo, opaque callback,
|
||||
non-data-only script, animation, message, queue, concurrent-script, 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;
|
||||
@@ -270,7 +564,74 @@ private state. A mod that deliberately stores progress-coupled truth in
|
||||
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.
|
||||
`mod.checkpoints:resume(game, checkpoint)` is the title-session counterpart to
|
||||
live `restore`. It validates the same data-only checkpoint against the
|
||||
engine-selected existing playthrough, reconstructs only after all validation
|
||||
passes, preserves current options, and verifies by recapture. A title session
|
||||
has no live gameplay rollback state: if reconstruction or verification fails,
|
||||
the engine rebuilds a usable title session and returns `false, code, message`.
|
||||
It never rewrites a normal Pokémon save. It is unavailable outside title and does
|
||||
not broaden capture or arbitrary-frame support.
|
||||
|
||||
`mod.checkpoints:ensureNormalSave(game, checkpoint)` is a separate live-runtime
|
||||
operation for durable checkpoint tools. It creates ordinary progress only when
|
||||
none exists, only after validating that the supplied checkpoint is the exact
|
||||
current safe runtime, and through the normal atomic save lifecycle. Once an
|
||||
ordinary save exists it returns `true, "already_exists"` without writing, so
|
||||
subsequent checkpoints and the player's later SAVE commands remain independent.
|
||||
Call it only after the tool's own checkpoint/index commit; treat an anchoring
|
||||
failure as a failed first checkpoint rather than claiming restart safety.
|
||||
See RFC 0003, RFC 0004, RFC 0005, and RFC 0006 for exact contracts and error
|
||||
codes.
|
||||
|
||||
At that same settled supported wild/trainer decision boundary, a tool may claim
|
||||
START through `battle.menu_auxiliary`. It receives `(next, game, context)`, where
|
||||
`context` is the data-only `{ kind = "wild" }` or `{ kind = "trainer" }`; it
|
||||
never receives the live battle controller. Return `true` to consume START after
|
||||
opening source-owned UI, or call `next(game, context)` to allow lower-priority
|
||||
handlers. With no handler, START remains inert. Ordinary encounters and the
|
||||
validated built-in scripted battle origins described by RFC 0005 are eligible;
|
||||
opaque scripts, link/Safari/ghost/demo battles, action queues,
|
||||
animation/messages, forced choices, and every phase that cannot safely be
|
||||
checkpointed remain excluded. Exceptions are contained by normal hook isolation
|
||||
and fall through without advancing a turn.
|
||||
|
||||
Gen 1 trainer encounters also expose `trainer.before_battle` after the
|
||||
challenge text and immediately before battle construction. This lets a mod
|
||||
defer the encounter while it collects a player choice through a registered
|
||||
screen, then resume with a battle-local view of the save party:
|
||||
|
||||
```lua
|
||||
mod.hooks:wrap("trainer.before_battle", function(next, game, context, continue)
|
||||
-- context = { trainerClass, partyIndex, mapId, npcId }
|
||||
mod.ui.push(game, "party_registration", {
|
||||
onConfirm = function(indices)
|
||||
continue({ playerPartyIndices = indices })
|
||||
end,
|
||||
onCancel = function()
|
||||
continue({ cancel = true })
|
||||
end,
|
||||
})
|
||||
return true
|
||||
end)
|
||||
```
|
||||
|
||||
Return `true` only when retaining `continue` for a later callback. Calling
|
||||
`continue({ cancel = true })` ends the encounter without constructing a battle;
|
||||
the normal encounter completion callback returns control to the overworld and
|
||||
no trainer-defeated state is written. A cancelled sight encounter is suppressed
|
||||
at the current player cell so it cannot immediately reopen; moving one cell or
|
||||
talking to the trainer permits a new challenge. Calling `continue()` uses the
|
||||
full save party; passing
|
||||
`{ playerPartyIndices = { 2, 4, 5 } }` uses those ordered, one-based party
|
||||
members for initial send, switching and forced replacement, exhaustion,
|
||||
experience traversal, and battle party displays. The continuation is one-shot.
|
||||
An empty, duplicate, out-of-range, or otherwise malformed list safely falls
|
||||
back to the full party. The view references the original Pokemon records and
|
||||
never reorders or replaces `game.save.party`; trainer battle checkpoints retain
|
||||
the selected indices. Mods remain responsible for selection policy and should
|
||||
use only public `mod.ui`, hook, and save APIs. See RFC 0010 for the exact
|
||||
contract and compatibility guarantees.
|
||||
|
||||
## Developer console
|
||||
|
||||
@@ -308,11 +669,14 @@ the wrapper is visible during that same fixed step. The callback receives
|
||||
|
||||
`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
|
||||
is `{ phase, source, id, x, y, gameX, gameY, insideGame, 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
|
||||
`x` / `y` are LOVE window units, while `gameX` / `gameY` are local to the
|
||||
active game viewport and `insideGame` says whether the pointer is inside it.
|
||||
Without a custom viewport both coordinate pairs are identical. 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
|
||||
@@ -345,6 +709,22 @@ composited and before touch controls draw. The window-space viewport contains
|
||||
and `dpiY`, so a tool can use the letterbox margins without drawing over the
|
||||
playfield or pushing an updating game state.
|
||||
|
||||
`render.viewport` lets a layout mod reserve the window-space rectangle in which
|
||||
the game renders. It receives `(next, ctx)` with the full window's `width`,
|
||||
`height`, `pixelWidth`, `pixelHeight`, `dpiX`, `dpiY`, and `generation`, and
|
||||
returns `{ x, y, width, height }`. The engine clamps that rectangle to the
|
||||
window and makes game layout, safe-area calculations, and rendering use it as
|
||||
their display. Set `capture = true` to request a composition canvas even when
|
||||
the rectangle fills the window. With no subscriber, no canvas is allocated and
|
||||
the normal presentation path is unchanged.
|
||||
|
||||
When a viewport is active, `render.window` receives `(next, game, ctx)` after
|
||||
the game frame has been captured. `ctx` contains its `canvas`, `x`, `y`,
|
||||
`width`, `height`, the full `windowWidth` / `windowHeight`, `dpiX`, `dpiY`, and
|
||||
`generation`. Calling `next(game, ctx)` draws the game at the requested origin;
|
||||
a wrapper may instead compose that canvas with its own UI. Touch controls remain
|
||||
full-size OS-window chrome and draw after this hook.
|
||||
|
||||
`render.compose` wraps the whole-window composite in `Renderer:endFrame`. It
|
||||
receives `(next, renderer, ctx)`; returning `true` without calling `next` hands
|
||||
the mod full control of the window, while calling `next` runs the engine's
|
||||
@@ -353,11 +733,38 @@ 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)` / `pollTouch()` /
|
||||
`setEnabled`) for driving a second physical display. `pollTouch()` returns the
|
||||
oldest queued event as `"action,x,y"` in submitted-frame coordinates, or `nil`.
|
||||
`secondScreen` bridge (`available()` / `detected()` / `push(...)` /
|
||||
`pollTouch()` / `setEnabled`) for driving a second physical display.
|
||||
`detected()` reports a connected target even while its output is being created;
|
||||
`available()` means it can accept a frame now. `push(imageData, w, h)` retains
|
||||
the original contract. Its optional `background` (`0xRRGGBB`) and `preference`
|
||||
arguments request an extended presentation; a preference ending in `:cover`
|
||||
fills and crops the target, while other values preserve the whole frame.
|
||||
Android also accepts `handheld` or `secondary` (with an optional `:cover`
|
||||
suffix) as routing hints; unsupported or unavailable targets fall back to the
|
||||
other connected 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.
|
||||
On process-capable Windows, Linux and macOS hosts without a native display
|
||||
bridge, enabling this facade opens a second resizable app window instead. It
|
||||
uses the same `available`, `detected`, `push`, `pollTouch` and `setEnabled`
|
||||
contract, so a mod does not need a desktop-specific rendering path.
|
||||
|
||||
`render.output_enabled` and `render.output` are the later, whole-window seam
|
||||
for mods that need the engine's normal composite rather than its separate
|
||||
layers. It runs after registered present pipelines and before GBCFX,
|
||||
`render.hud`, and touch controls. A mod wraps both hooks: the first returns
|
||||
`true` only while output ownership is needed, and the second receives
|
||||
`(next, ctx)` with `canvas`, `width`, `height`, `gameX`, `gameY`, `gameWidth`,
|
||||
`gameHeight`, `scale`, `dpiX`,
|
||||
`dpiY`, and `generation`. Returning `true` from `render.output` takes over the
|
||||
window; calling `next(ctx)` keeps the normal presentation. Both hooks default
|
||||
to `false`. Enabling the seam requires a full-window canvas for that frame.
|
||||
With no `render.output` subscriber, or while `render.output_enabled` is false,
|
||||
the existing presentation path is unchanged. `render.compose` takes precedence
|
||||
when it owns the frame.
|
||||
|
||||
`screen.render_visible` receives `(next, state)` while the main screen is being
|
||||
composed. Return `false` to omit that state from drawing, opacity selection and
|
||||
@@ -374,9 +781,13 @@ identifiers: `pc_box_withdraw`, `pc_box_deposit`, `pc_box_release`,
|
||||
`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.
|
||||
Both hooks apply to Gen 1 and Gen 2 battles.
|
||||
Text boxes and YES/NO prompts pushed above a battle inherit a `false` result
|
||||
for that battle, so hiding the bottom layer cannot leave their white backing
|
||||
behind under another overlay. Text boxes also pass through the hook as their
|
||||
own state, preserving selective control outside a battle; 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
|
||||
@@ -426,3 +837,263 @@ 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.
|
||||
|
||||
## Detached Pokémon icon presentation
|
||||
|
||||
`mod.ui.PokemonIcon.draw(game, summary, x, y, opts)` draws the same party icon
|
||||
the native Party menu would resolve without exposing a live Pokémon record or
|
||||
the private Party menu. `summary` is the detached data-only shape
|
||||
`{ species = string, hp = integer, maxHp = integer }`; `opts.selected` and
|
||||
`opts.counter` optionally request the native selected-icon animation phase.
|
||||
|
||||
The engine retains icon ownership. Content registered through
|
||||
`mod.content.icons`, species `icon` definitions, asset overrides, and the
|
||||
public `pokemon.icon` hook therefore continue to compose. Invalid summaries
|
||||
return `false, code, message` and draw nothing. The helper is presentation
|
||||
only: it does not expose moves, status, checkpoint payloads, or mutable party
|
||||
state.
|
||||
|
||||
## Shared date and time presentation
|
||||
|
||||
The global Options menu owns `DATE FORMAT` (`DEVICE`, `DD-MM-YYYY`,
|
||||
`MM-DD-YYYY`, `YYYY-MM-DD`) and `TIME FORMAT` (`DEVICE`, `24 HOUR`, `12 HOUR`).
|
||||
These preferences live in `options.lua`, so checkpoint restore never rewinds
|
||||
them. `DEVICE` uses the process time locale when the platform provides one;
|
||||
the portable fallback is `DD-MM-YYYY` plus 24-hour time.
|
||||
|
||||
Mods format captured timestamps through the read-only public facade:
|
||||
|
||||
```lua
|
||||
local date = mod.datetime:date(game, createdAt)
|
||||
local time = mod.datetime:time(game, createdAt)
|
||||
local both = mod.datetime:dateTime(game, createdAt)
|
||||
```
|
||||
|
||||
The live `game` supplies only the current option context. Formatting never
|
||||
mutates the save, options, or timestamp, and invalid timestamps return
|
||||
`"----"`.
|
||||
|
||||
## Device power information
|
||||
|
||||
Sandboxed mods can read the host's battery state without receiving the rest
|
||||
of `love.system`:
|
||||
|
||||
```lua
|
||||
local state, percent = mod.device:powerInfo()
|
||||
```
|
||||
|
||||
`state` follows LÖVE's values: `"unknown"`, `"battery"`, `"nobattery"`,
|
||||
`"charging"`, or `"charged"`. `percent` is `0` through `100`, or `nil` when
|
||||
the platform cannot report it. The facade is read-only and does not expose
|
||||
URL launching, clipboard access, or other system operations.
|
||||
|
||||
## Real-world steps
|
||||
|
||||
On iOS and Android the game counts the player's real-world steps natively
|
||||
(HealthKit / the hardware step counter). A mod reaches that bridge through
|
||||
the `steps` permission in `manifest.json`, which the player sees in the
|
||||
mod manager like every other permission:
|
||||
|
||||
```lua
|
||||
if mod.steps:available() then
|
||||
mod.steps:sync() -- async; OS consent sheet on first use
|
||||
end
|
||||
-- later, at a quiet moment:
|
||||
local walk = mod.steps:poll() -- { steps = n, from = ?, to = ? } or nil
|
||||
```
|
||||
|
||||
`available()` is `false` on builds without the bridge (desktop) and for
|
||||
mods without the permission, so a probe is always safe. `sync()` asks the
|
||||
platform to refresh its count and returns whether there was a bridge to
|
||||
ask. `poll()` returns the next delivery for this mod — the engine consumes
|
||||
the native side's pending file itself, each permissioned mod receives its
|
||||
own copy of a delivery, and steps are anchored natively so the same walk
|
||||
is never delivered twice. Without the permission, `sync` and `poll` raise
|
||||
an error naming it.
|
||||
|
||||
## Background HTTP
|
||||
|
||||
`mod.fetch` is how a mod does work off the main thread. It is behind the
|
||||
`network` permission in `manifest.json`, the same one that gates
|
||||
`require("socket")`, and the player sees it in the mod manager.
|
||||
|
||||
```lua
|
||||
-- somewhere once
|
||||
local job = mod.fetch:get("https://example.com/data.json")
|
||||
|
||||
-- in a hook or update, every frame -- poll never blocks
|
||||
if job then
|
||||
local r = mod.fetch:poll(job)
|
||||
if r.status ~= "pending" then
|
||||
if r.status == "ok" then use(r.body) else warn(r.err) end
|
||||
mod.fetch:release(job)
|
||||
job = nil
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
`get(url, opts)` returns an opaque handle, or `nil` plus a reason. `opts`
|
||||
takes `accept` (a request Accept header) and `maxSeconds` (clamped to 30).
|
||||
`poll(handle)` returns `{ status, body, err, progress }` where `status` is
|
||||
`"pending"`, `"ok"`, `"error"` or `"cancelled"`; it is a copy, and it never
|
||||
blocks, so calling it every frame is the intended use. `release(handle)`
|
||||
frees a finished job — do it, or you will hit the ceiling. `cancel(handle)`
|
||||
drops a result you no longer want. `available()` is `false` when the build
|
||||
has no transport and for mods without the permission, so a probe is safe.
|
||||
|
||||
The rules worth knowing before you design around it:
|
||||
|
||||
- **http and https only.** The underlying transport also speaks `file://`,
|
||||
`ftp://` and `scp://`; those are refused, on the initial URL and on any
|
||||
redirect. `mod.fetch` is not a way to read a local file.
|
||||
- **Four requests in flight per mod.** The worker pool is shared with the
|
||||
launcher's own downloads, so one mod cannot fill it. Over the ceiling,
|
||||
`get` returns `nil` and a reason until you release something.
|
||||
- **Handles are yours alone.** A handle from another mod, a fabricated
|
||||
table, or a guessed number all poll as `"error"`.
|
||||
- **Your mod id is in the User-Agent**, so a server operator can see who is
|
||||
calling and a mod cannot pose as the launcher.
|
||||
- Jobs are released when your mod unloads.
|
||||
|
||||
This is deliberately not `love.thread`. A LÖVE thread is a fresh Lua state
|
||||
with a full standard library that the sandbox cannot reach, so handing one
|
||||
to a mod would undo every other rule; `mod.fetch`'s workers run engine
|
||||
code, so a mod gets asynchrony without gaining any new reach.
|
||||
|
||||
## Log reporting
|
||||
|
||||
`mod.postLog(body, opts)` is the one-way exception to the rule that a mod
|
||||
decides where it talks. It reports a debug/crash log to the https URL the
|
||||
manifest declares in `log_url`, and it is the only API that may not be
|
||||
pointed at a caller-chosen address:
|
||||
|
||||
```json
|
||||
{
|
||||
"permissions": ["network"],
|
||||
"log_url": "https://logs.example.com/receive"
|
||||
}
|
||||
```
|
||||
|
||||
The URL is validated at load: it must be `https://`, and declaring it
|
||||
without the `network` permission is a load violation for api 2 mods. The
|
||||
destination is reviewed when the mod ships, not chosen per call, so a mod
|
||||
cannot aim this at arbitrary hosts or read back anything a server replies.
|
||||
|
||||
```lua
|
||||
-- fire and forget; poll() never blocks, same shape as mod.fetch
|
||||
local job = mod:postLog("session crashed at 0x1f3a\n" .. logText)
|
||||
```
|
||||
|
||||
`postLog(body, opts)` returns the same opaque handle as `mod.fetch:get`,
|
||||
polled and released through `mod.fetch:poll` / `mod.fetch:release`. `opts`
|
||||
is a closed list with one switch: `format`, either `"text"` (the default)
|
||||
or `"json"`. `json` wraps the body in an envelope of `{ ts, mod, format,
|
||||
body }` so a server can attribute and sort reports; any other key or value
|
||||
is refused before a job is submitted. The body is capped at 64 KB, the
|
||||
transfer is bounded by the same worker ceilings as `mod.fetch`, and the
|
||||
response body is never returned to the mod.
|
||||
|
||||
## Background jobs
|
||||
|
||||
`mod.fetch` covers work waiting on a server. `mod.job` covers work waiting on
|
||||
the CPU — generating a map, crunching a table, anything that would otherwise
|
||||
stall a frame. It is behind the `background` permission in `manifest.json`.
|
||||
|
||||
Ship the job as its own file inside your mod:
|
||||
|
||||
```lua
|
||||
-- mods/your_mod/jobs/crunch.lua
|
||||
local arg = ...
|
||||
local total = 0
|
||||
for i = 1, arg.n do total = total + i end
|
||||
return { total = total }
|
||||
```
|
||||
|
||||
```lua
|
||||
-- in your entry file
|
||||
local job = mod.job:run("jobs/crunch.lua", { n = 1e6 })
|
||||
|
||||
-- later, in a hook -- poll never blocks
|
||||
local r = mod.job:poll(job)
|
||||
if r.status == "ok" then
|
||||
use(r.result.total)
|
||||
mod.job:release(job)
|
||||
end
|
||||
```
|
||||
|
||||
`run(script, arg, opts)` returns an opaque handle, or `nil` plus a reason.
|
||||
`opts.maxSeconds` sets the job's time budget (default 5, clamped to 30).
|
||||
`poll(handle)` returns `{ status, result, err }` with `status` one of
|
||||
`"pending"`, `"ok"`, `"error"` or `"cancelled"`. `release(handle)` frees it.
|
||||
`available()` is `false` on a host without threads and for mods without the
|
||||
permission, so a probe is always safe.
|
||||
|
||||
**A job is pure compute.** This is the part to design around, not a detail:
|
||||
|
||||
- **Plain data in, plain data out.** Numbers, strings, booleans and tables of
|
||||
them. A function, userdata, a cycle or a table key that is not a string or
|
||||
number is refused at your `run` call with a reason. Nothing is shared —
|
||||
your argument is snapshotted, and mutating the original afterwards does not
|
||||
reach the job.
|
||||
- **No engine API, no game state, no storage.** `require` is refused inside a
|
||||
job, and there is no `mod` object. A job cannot read the party, write
|
||||
`mod.storage`, or touch a registry. Get what it needs into the argument and
|
||||
act on the result back on the main thread.
|
||||
- **Your script is a file in your mod folder.** The path goes through the same
|
||||
rules as `mod:read`; `..`, absolute paths and drive letters are refused.
|
||||
- **Two jobs per mod, four on the machine.** Over the limit, `run` returns
|
||||
`nil` and a reason until you release one.
|
||||
- **The budget bounds how long YOU wait, not how long the work runs.** Past
|
||||
`maxSeconds`, `poll` reports an error and the result is dropped if it ever
|
||||
arrives — but the thread runs to its own end. There is no way to stop a
|
||||
LÖVE thread from outside, and every attempt to stop one from inside was
|
||||
worse than the disease (a debug hook does not reliably interrupt LuaJIT,
|
||||
and raising from one wedged the whole process). `cancel(handle)` is the
|
||||
same deal: it drops the result, it does not stop the work.
|
||||
|
||||
So **write jobs that terminate.** A job with an infinite loop will keep one
|
||||
core busy until the game closes. It will not freeze the game — the main
|
||||
thread stays responsive and quitting still works — but nothing will reclaim
|
||||
that core in the meantime.
|
||||
|
||||
Your job script runs in the same sandbox your entry file does, so `io`, `os`,
|
||||
`debug`, `ffi`, `package` and `love.filesystem` are absent there too. That is
|
||||
the whole reason this exists rather than `love.thread`: a raw LÖVE thread is a
|
||||
fresh Lua state with a full standard library that the sandbox cannot reach, so
|
||||
handing one to a mod would undo every other rule. Here the worker builds your
|
||||
sandbox first and loads your chunk into it.
|
||||
|
||||
## Pre-sandbox globals (compat)
|
||||
|
||||
A mod written before the sandbox landed does not have to be updated to
|
||||
load. `io`, `package`, `dofile`, `loadfile`, `os.getenv`, `love.filesystem`,
|
||||
`love.system` and `love.event` are all present again as compat stand-ins
|
||||
(`src/mods/LegacyCompat.lua`), and assigning a LÖVE callback
|
||||
(`love.mousemoved = fn`) installs on the real table the way it always did.
|
||||
Every stand-in call logs one warning naming its replacement, and
|
||||
`loader:legacyReport(modId)` returns the same list with call counts, which
|
||||
is what a "needs updating" badge should read.
|
||||
|
||||
The stand-ins are not the old globals. Paths are classified rather than
|
||||
passed through:
|
||||
|
||||
- A path inside your own mod directory reads the file you shipped.
|
||||
- Anything else, including an absolute path, resolves into a private
|
||||
per-mod overlay at `mod_compat/<your id>/` under the save directory.
|
||||
Two mods naming the same path never see each other's bytes, and nothing
|
||||
is written outside the game tree.
|
||||
- A read misses through the overlay to your shipped file, then to
|
||||
`mod.storage`, so a half-migrated mod sees both.
|
||||
- A write over a path you shipped shadows it; the packaged file is never
|
||||
modified, and `mod:read` still returns the packaged bytes.
|
||||
- `love.filesystem.getSaveDirectory()` and `os.getenv("HOME")` answer with
|
||||
a virtual root, so a legacy mod that joins its own paths lands back in
|
||||
the same overlay.
|
||||
|
||||
`love.thread` stays refused. A LÖVE thread runs in a separate Lua state
|
||||
with the full standard library, which the sandbox in this state cannot
|
||||
reach, so a stand-in would be a hole rather than a reroute. The same goes
|
||||
for `ffi`, `debug`, `setfenv`, `os.execute`, `io.popen`, `love.run` and
|
||||
`love.errorhandler`. A mod that needs real background work needs an
|
||||
engine-owned facility, not a compat shim -- for HTTP that facility is
|
||||
[`mod.fetch`](#modfetch), which runs on the engine's own worker pool.
|
||||
|
||||
@@ -11,16 +11,10 @@ Features intentionally added beyond the original Pokémon 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
|
||||
* **Touch skins** in RetroArch overlay format, with bezel art, per-button press states, and Super Game Boy borders
|
||||
* **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`.
|
||||
## Gen 2 Specifics
|
||||
|
||||
* **Mod manager** with Gen 1 mod adapters, per-game targeting, and `modkit gen2check`
|
||||
* **Followers** for mods, plus Gen 2-only registries and hooks
|
||||
|
||||
@@ -0,0 +1,798 @@
|
||||
# Preparing your mod for Gen 2 (Gold)
|
||||
|
||||
You have a mod that works on Red, Blue or Yellow, and you want it to work on
|
||||
Gold. This is the migration guide: what breaks, what the engine papers over
|
||||
for you, what it refuses to paper over, and the order to do the work in.
|
||||
|
||||
`docs/mod-api-gen2-compat.md` is the reference for *what Gold serves*. This
|
||||
document is the procedure for *getting your mod there*. Read that one when you
|
||||
need to know whether a registry or a hook exists; read this one first.
|
||||
|
||||
## What actually breaks, and why
|
||||
|
||||
Gold is not a skin over the Gen 1 engine. It is a second engine living beside
|
||||
the first one: `src/core/Game2.lua` owns the boot, `src/world/gen2/World.lua`
|
||||
is the overworld, `src/battle/gen2/Battle.lua` is the battle, and
|
||||
`src/script/gen2/Vm.lua` runs the cart's own bytecode instead of a Lua row
|
||||
list. A Gold boot never loads `src/core/Game.lua`,
|
||||
`src/world/OverworldController.lua` or `src/battle/BattleState.lua` at all.
|
||||
The mod API on top is deliberately one API -- the same registry names, the
|
||||
same hook names, the same event names, the same `mod.*` facade -- so a mod
|
||||
that stays on that surface mostly moves across unchanged. What does not move
|
||||
is everything underneath it.
|
||||
|
||||
The failure that motivated all of this is quiet, which is what makes it worth
|
||||
a whole document. A mod with `engine_internals` writes
|
||||
`local Game = require("src.core.Game")` and patches a method on it. Under Gold
|
||||
that require used to succeed: the file is on disk, `require` finds it, hands
|
||||
back a perfectly good module table, and your patch lands on it. Nothing ever
|
||||
instantiates that table, so the patch runs zero times and the only symptom is
|
||||
that your mod does nothing. No error, no warning, no crash to bisect. Two
|
||||
things fixed that. First, a mod is not loaded on a Gold boot unless it says it
|
||||
is for Gold, so the default outcome is "not running" rather than "running
|
||||
wrong". Second, when it does say so, a require made from your own file is
|
||||
answered by an adapter (`src/mods/Gen2Compat.lua`) that presents the Gen 1 API
|
||||
over Gold's internals, and a member the adapter cannot honestly back reads nil
|
||||
instead of reading plausibly-wrong.
|
||||
|
||||
## Step 1: run the checker before you change anything
|
||||
|
||||
`modkit gen2check` reads your manifest, statically scans every `.lua` the
|
||||
package carries, and cross-references what it finds against the adapter's own
|
||||
coverage table. Run it first, because it tells you the size of the job in a
|
||||
few seconds.
|
||||
|
||||
```sh
|
||||
python3 tools/modkit.py gen2check <id-or-path> [<id-or-path>...]
|
||||
```
|
||||
|
||||
Real output, against a follower mod written for Yellow:
|
||||
|
||||
```
|
||||
-- PokePCFollowers_VoxelMerge: api 1, profile content, no games declared, permissions engine_internals, 0 dependencies, game_version unset
|
||||
MK400 ERROR manifest.json: no Gen 2 game in "games" (and no gen2compat), so a Gen 2 boot skips this mod; the rest of this report is what it would hit once it claims one
|
||||
MK404 ERROR main.lua:575: BattleState.newWild has no Gen 2 backing: Gold has no factory that returns an unpushed battle, and World:startBattle constructs and pushes in one call. A mod that wraps newWild to rewrite the species must be pointed at the encounter.species hook, which Gold raises with the same name and shape (World:rollEncounter); this reads nil
|
||||
MK404 ERROR main.lua:576: BattleState.newWild has no Gen 2 backing: ... ; nothing on a Gen 2 boot reads this write
|
||||
MK409 WARN main.lua:13: allow-lists a Gen 1 version string, which excludes this mod from a Gen 2 game by construction; test for the capability the code needs instead of the version
|
||||
MK409 WARN main.lua:424: ... (same, a second allow-list)
|
||||
MK409 WARN main.lua:565: ... (and a third)
|
||||
modkit: unresolved: 1 site: requires whose result is neither bound to a name nor indexed here, so where the module goes is not followed (main.lua:221)
|
||||
modkit: unresolved: 5 debug upvalue calls whose target function this scan could not tie to an engine module, so the local they reach could not be resolved (main.lua:279, main.lua:285, main.lua:288, main.lua:321 and 1 more)
|
||||
modkit: src.world.PikachuFollower.onMapEntered closes over 'shouldSpawn' on a Gen 2 boot, so the upvalue surgery at main.lua:325 lands as it does on Gen 1
|
||||
FAIL PokePCFollowers_VoxelMerge on gen 2: will not work (3 errors, 3 warnings)
|
||||
```
|
||||
|
||||
Three kinds of line, and the difference matters:
|
||||
|
||||
- **`MK4xx ERROR` / `MK4xx WARN`** are findings with a file and a line. Errors
|
||||
set the exit code; warnings do not unless you pass `--strict`.
|
||||
- **`modkit:` notes** are things the tool derived rather than found, or could
|
||||
not decide at all. They never change the exit code. The `shouldSpawn` note
|
||||
above is the tool resolving that member through the adapter on a Gen 2 boot,
|
||||
enumerating the function's real upvalues, and confirming the surgery lands;
|
||||
the `unresolved:` notes are the tool naming, with file and line, every reach
|
||||
it saw and could not follow.
|
||||
- **The verdict**: `will load`, `will load but degrade`, or `will not work`.
|
||||
|
||||
The rule ladder:
|
||||
|
||||
| rule | what it means |
|
||||
| --- | --- |
|
||||
| `MK400` | the manifest claims no Gen 2 game, so a Gen 2 boot skips the mod |
|
||||
| `MK401` | a dependency claims no Gen 2 game, which takes you down with it |
|
||||
| `MK402` | you require a Gen 1-only module the adapter does not serve |
|
||||
| `MK403` | a Gen 2 boot runs a `gen2/` sibling of the module instead |
|
||||
| `MK404` | a member you touch has no Gen 2 backing (the adapter's own reason is quoted) |
|
||||
| `MK405` | a member you touch degrades and says so once |
|
||||
| `MK406` | the signature moved under an alias |
|
||||
| `MK407` | `debug` upvalue surgery the Gen 2 arm cannot take: the member is not a function there, or the function does not close over that local |
|
||||
| `MK408` | upvalue surgery the scan could not resolve either way |
|
||||
| `MK409` | a version allow-list, or a Gen 1 screen id |
|
||||
| `MK410` | the entry chunk reads a member of a game that is not up yet |
|
||||
|
||||
Flags: `--strict` promotes warnings to failures, `--notes` prints the adapter's
|
||||
note for every *backed* member you touch (worth reading once per mod, because
|
||||
several backed members are backed with a caveat), `--json` emits one document
|
||||
for the whole batch, `--quiet` drops everything except the findings -- no
|
||||
header, no notes, no verdict line, so a clean mod prints nothing at all and the
|
||||
exit code is the whole answer. Exit code is 0 clean, 1 on a fatal finding, 2 on
|
||||
usage.
|
||||
|
||||
Name several mods in one invocation and they are read as one install set, so a
|
||||
mod and its dependencies can answer each other's `MK401`.
|
||||
|
||||
**What the checker cannot see, and now says so.** It is a static scan, not a
|
||||
run. It follows more than it used to -- a require made through your own
|
||||
`tryRequire`-style wrapper, `local ok, M = pcall(require, "...")`, an inline
|
||||
`require("src.world.Map").waterTiles(...)`, a bracket index `M["member"]`, a
|
||||
local hop `local F = M` -- so reaches that used to be invisible now produce
|
||||
real findings, and a mod that passed before can fail now.
|
||||
|
||||
Two places where it used to answer confidently and wrongly now do not.
|
||||
`local A, B = require("src.world.Map")` is read as binding `A`, which is what
|
||||
Lua does; it used to take the name nearest the `=` and pin the module on `B`,
|
||||
so every reach off `A` went unchecked and every reach off `B` was checked
|
||||
against a module that was never there. And a helper of your own is only read as
|
||||
upvalue surgery when the scan can see it forward its own `(function, name)`
|
||||
pair into the `debug` call; a helper that merely mentions `upvalue`, or that
|
||||
finds the slot by walking `debug.getupvalue`, no longer has its call sites
|
||||
read as naming an engine local, because they do not.
|
||||
|
||||
What it still cannot follow it names instead of ignoring. Every unfollowed
|
||||
reach comes back as an `unresolved:` note carrying a file and a line. The scan
|
||||
side raises one for:
|
||||
|
||||
- a require name built at runtime, whether handed in whole or concatenated
|
||||
(`require("src.world." .. name)` is as unfollowable as `require(name)`);
|
||||
- an engine module name handed to a call the scan does not follow;
|
||||
- an engine module name spelled in a literal with no require attached;
|
||||
- a require whose result is neither bound to a name nor indexed on the spot;
|
||||
- a require in a multiple assignment whose value it cannot pair to a name;
|
||||
- a name bound to a *member* of a module rather than the module;
|
||||
- an engine module indexed with a computed key;
|
||||
- `rawget` or `rawset` on a bound module: that goes straight to the table the
|
||||
require shim hands back, so on a Gen 2 boot it reads or writes the
|
||||
Gen2Compat facade and not the module behind it;
|
||||
- an engine module read as a value rather than indexed, so where it goes from
|
||||
there (a table field, a call argument, a metatable's `__index`) is not
|
||||
followed;
|
||||
- a `debug` upvalue call whose target function could not be tied to a module;
|
||||
- a call through one of your own upvalue helpers that the scan could not
|
||||
confirm carries an upvalue name through to the `debug` call.
|
||||
|
||||
Four more come from the coverage side rather than the scan: a dependency that
|
||||
is not installed beside your mod, a required name that is neither an adapter
|
||||
nor a module in this checkout, a module with no coverage row at all, and a Gen
|
||||
1 member the coverage table does not classify.
|
||||
|
||||
The practical consequence is worth stating plainly: an empty finding list
|
||||
*plus* no `unresolved:` notes now means the scan followed everything it saw,
|
||||
and an empty finding list on its own does not.
|
||||
|
||||
It is still silent on any member the adapter's coverage table does not record:
|
||||
the table lists 481 members across the 15 served modules, which is a large
|
||||
majority of what real mods touch and is not the whole Gen 1 API. A clean
|
||||
`gen2check` means "nothing known-broken was found", not "this works". Boot it.
|
||||
|
||||
## Step 2: declare which games the mod is for
|
||||
|
||||
Nothing moves on disk. A mod is installed once, into `mods/<id>/`, and that one
|
||||
directory serves every game. There is no `mods/gen1/`, no `mods/gen2/`, and no
|
||||
per-generation copy: targeting is something the manifest *declares*, not
|
||||
something the filesystem encodes.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my_mod",
|
||||
"name": "My Mod",
|
||||
"version": "1.0.0",
|
||||
"entry": "main.lua",
|
||||
"api": 2,
|
||||
"games": ["gen1", "gen2"]
|
||||
}
|
||||
```
|
||||
|
||||
`games` is an optional array. Each entry is one of:
|
||||
|
||||
| token | means |
|
||||
| --- | --- |
|
||||
| `"red"`, `"blue"`, `"yellow"`, `"gold"` | that one game (a version id from `GameVersion.ORDER`) |
|
||||
| `"gen1"`, `"gen2"` | every game of that generation (case-insensitive; `"gen 2"` also parses) |
|
||||
| `"all"` | every game this engine has |
|
||||
|
||||
`src/mods/ModTargets.lua` is the one place those tokens are resolved, and it
|
||||
derives the list from `GameVersion.ORDER` rather than restating it, so a game
|
||||
added later needs no edit there. The scaffold writes the key for you:
|
||||
|
||||
```sh
|
||||
python3 tools/modkit.py scaffold my_mod --games gen1,gen2
|
||||
```
|
||||
|
||||
**Omitting `games` keeps the old meaning exactly.** No `games` key means Gen 1
|
||||
only, plus Gen 2 if the legacy `"gen2compat": true` flag is set. Every manifest
|
||||
written before the key existed means precisely what it always meant.
|
||||
`gen2compat` is still accepted and is purely additive: it *adds* the Gen 2
|
||||
games to whatever `games` says, so no manifest can lose a game it already ran
|
||||
on. `Manifest.validate` (`src/mods/Manifest.lua:210-224`) resolves the two
|
||||
into one ORDER-sorted `manifest.games` array and derives `manifest.gen2compat`
|
||||
from it, which is why `"games": ["gen2"]` is honoured by the loader's gate
|
||||
today with no other change.
|
||||
|
||||
An unknown token warns and is dropped under `api` 1 and refuses the manifest
|
||||
under `api` 2 (the normal `violation()` rule). A `games` array that names no
|
||||
game this engine knows falls back to the default rather than orphaning the mod.
|
||||
A non-array `games` is a hard error.
|
||||
|
||||
### What you are claiming
|
||||
|
||||
Adding a game to `games` is you saying *I have run this there*. It is not a
|
||||
request for best-effort support and the loader does not treat it as one: a mod
|
||||
that claims a game is loaded on that boot in full, with its registrations, its
|
||||
subscriptions and its entry chunk, exactly like a mod written for it. If it is
|
||||
half-working, the player sees a broken mod, not a partially-supported one. That
|
||||
is the whole reason the key exists rather than being inferred.
|
||||
|
||||
**Every token is enforced, per game.** `Loader:_gateGeneration`
|
||||
(`src/mods/Loader.lua:447`) gates on `ModTargets.supports(manifest, version,
|
||||
generation)` -- the same call both mod surfaces make -- so `"games": ["blue"]`
|
||||
really does not load on Red, and the skip line is the launcher's line, `For
|
||||
Blue, not Red`. `"games": ["gold"]` alone no longer loads on Red either: it
|
||||
names one game, and that game is Gold. A manifest with no `games` and no
|
||||
`gen2compat` still covers every Gen 1 game, so nothing written before the key
|
||||
existed changes behavior; what changed is that a version-id token is now a
|
||||
statement the boot keeps rather than a label the UIs draw. If you want a mod
|
||||
everywhere, say so: `["gen1", "gen2"]` or `["all"]`.
|
||||
|
||||
**Dependencies are contagious.** A mod whose hard dependency does not run here
|
||||
is left out too, carrying the dependency's own wording (`depends on X, which
|
||||
does not run here (For Blue, not Red)`). It is reported as a skip rather than a
|
||||
failure and neither mod lands on the boot error list, but the mod does not run.
|
||||
Every hard dependency in the chain has to cover the same games; `MK401` is the
|
||||
checker's version of this question for the Gen 2 half of it.
|
||||
|
||||
**The player can overrule you, in one direction only.** The in-game mod
|
||||
manager offers `TRY HERE ANYWAY` on the detail pane for any mod that does not
|
||||
claim *this* game (`src/mods/ManagerState.lua:386`), which now includes a Gen 1
|
||||
boot: a Blue-only mod is genuinely skipped on Red, so that row is the only way
|
||||
to run it there. The choice is **per game**: `options.modsGen2[id]` is a
|
||||
`{ [version] = true }` table, so forcing a mod onto Red does not force it onto
|
||||
Gold. A stored legacy `options.modsGen2[id] = true` from before the key was
|
||||
per-game reads as "the Gen 2 games", which is the only set it could ever have
|
||||
affected, and it is expanded in place the next time the player answers. A
|
||||
forced mod loads normally and keeps a note saying its author never verified it
|
||||
here; the launcher shows it as `Forced onto Gold by you (untested)`. If the
|
||||
override cannot be persisted the manager says `COULD NOT SAVE` rather than
|
||||
promising a restart that would change nothing.
|
||||
|
||||
### What the player sees
|
||||
|
||||
All three surfaces read the same derivation -- the two UIs and the loader --
|
||||
so they cannot disagree about your mod. The launcher's mod panel carries a
|
||||
`Show for:` chip row (All games / Red / Gold / ...) and a per-mod tag from
|
||||
`ModTargets.chip` -- `GEN 1`, `GEN 1+2`, `RED/GOLD`, `BLUE` -- greyed out when
|
||||
the mod does not run on the selected game, with the line `Not for this game`
|
||||
(`src/import/LauncherView.lua:320`) and the detail from `ModTargets.detail`,
|
||||
`For Gen 1, not Gold`. The in-game manager shows the same thing as
|
||||
`ENABLED (NOT THIS GAME)` with the skipped glyph, plus an inert `FOR GEN 1+2`
|
||||
row on the detail screen. The launcher's dependency verdict asks the same
|
||||
question of your dependencies: a mod whose hard dependency does not run on the
|
||||
selected game reads `Needs <id> (not for Gold)` rather than `Ready`.
|
||||
|
||||
### Scoping dependencies per game / generation
|
||||
|
||||
For mods targeting multiple generations (`"games": ["gen1", "gen2"]`), a hard
|
||||
dependency can be scoped to specific games so that it is only enforced when
|
||||
booting those games:
|
||||
|
||||
```json
|
||||
"dependencies": [
|
||||
{ "id": "pokegear_cards", "games": ["gen2"], "range": "^1.0.0", "github": "1jamie/pokegear_cards" }
|
||||
]
|
||||
```
|
||||
|
||||
When booting a Gen 1 game (Red, Blue, Yellow), the engine loader sees that
|
||||
`pokegear_cards` is scoped to `"gen2"` and will not skip or block the parent mod
|
||||
on Gen 1. When booting Gen 2 (Gold), `pokegear_cards` is strictly required.
|
||||
|
||||
For conditional integrations where the dependency is optional across the board,
|
||||
`optional_dependencies` remains the standard pattern.
|
||||
|
||||
### One limit worth knowing
|
||||
|
||||
**Enablement is per game.** The overlay
|
||||
`options.modsByVersion[version][id]` is read and written through
|
||||
`SaveData.modEnabled` / `SaveData.setModEnabled` by the launcher, in-game
|
||||
manager, and loader. Existing shared settings are copied to every game the
|
||||
first time this version sees the installed mods; from then on, each coloured
|
||||
game checkbox changes only that game's next boot. Nothing about this affects a
|
||||
mod author; it affects what a player can express.
|
||||
|
||||
Targeting is a different question from enablement and *is* enforced per game,
|
||||
as above. The two do not share a switch.
|
||||
|
||||
## Step 3: prefer the API over the modules
|
||||
|
||||
Before doing any adapter work, check whether you need the modules at all. In
|
||||
new code, take the live game from `mod.game` and the world from `mod.world`.
|
||||
Both resolve per generation inside the loader (`src/mods/Loader.lua:1021`):
|
||||
`mod.game` is `src/core/Game.lua`'s singleton under Gen 1 and the `Game2`
|
||||
*instance* Gold injected under Gen 2, read on every touch rather than cached;
|
||||
`mod.world` is `src/world/WorldAPI.lua` or `src/world/gen2/WorldAPI.lua` behind
|
||||
one method set. Neither needs `engine_internals`. The `game.ready` payload and
|
||||
every `ui.*` hook's first argument carry the same live game.
|
||||
|
||||
Anything you can express as a registry write, a hook or an event subscription
|
||||
is generation-agnostic already and needs nothing from this document. The
|
||||
adapter exists for the code that was written before Gold did, and for the small
|
||||
number of things the API genuinely does not reach.
|
||||
|
||||
## Step 4: the adapter, module by module
|
||||
|
||||
On a Gen 2 boot with mods present, `require` is interposed
|
||||
(`Loader:_installDevShim`, `src/mods/Loader.lua:184`) and a require *made from
|
||||
a mod's own chunk* for one of fifteen Gen 1 names is answered by
|
||||
`src/mods/Gen2Compat.lua`. Engine code is unaffected: the shim compares the
|
||||
caller's chunk name against the engine tree, so `src/render/PaletteFX.lua`
|
||||
requiring `src.core.Game` still gets the real Gen 1 module on both generations.
|
||||
This is not a dev-mode feature; it installs on any Gold boot that has mods.
|
||||
|
||||
| the name you require | kind | what you get | backed / warned / absent |
|
||||
| --- | --- | --- | --- |
|
||||
| `src.core.Game` | facade | a live proxy onto the `Game2` instance | 70 / 9 / 12 |
|
||||
| `src.world.OverworldController` | facade | over `src/world/gen2/World.lua` | 56 / 5 / 68 |
|
||||
| `src.world.Map` | alias | `src/world/gen2/Map.lua` | 28 / 2 / 9 |
|
||||
| `src.world.NPC` | alias | `src/world/gen2/Npc.lua` | 27 / 0 / 1 |
|
||||
| `src.pokemon.Boxes` | facade | over `src/core/gen2/Boxes.lua` | 22 / 0 / 0 |
|
||||
| `src.battle.BattleState` | facade | over `src/ui/gen2/BattleState.lua` | 16 / 2 / 39 |
|
||||
| `src.ui.PartyMenu` | facade | over `src/ui/gen2/PartyMenu.lua` | 15 / 2 / 16 |
|
||||
| `src.world.WorldAPI` | alias | `src/world/gen2/WorldAPI.lua` | 15 / 2 / 0 |
|
||||
| `src.world.PikachuFollower` | alias | `src/world/gen2/Follower.lua` | 10 / 0 / 11 |
|
||||
| `src.script.ScriptRunner` | facade | over `src/script/gen2/Vm.lua` | 10 / 7 / 1 |
|
||||
| `src.ui.OptionsMenu` | facade | over `src/ui/gen2/OptionsMenu.lua` | 8 / 0 / 1 |
|
||||
| `src.world.FieldDefaults` | facade | the `playerSprites` answer and named refusals | 5 / 2 / 3 |
|
||||
| `src.world.Collision` | facade | `DELTA` / `target` / `occupied` / `canMove` | 4 / 1 / 0 |
|
||||
| `src.ui.StartMenu` | facade | over `src/ui/gen2/StartMenu.lua` | 4 / 0 / 0 |
|
||||
| `src.ui.BoxMenu` | alias | `src/ui/gen2/PcMenu.lua` | 1 / 0 / 0 |
|
||||
|
||||
**Alias means the adapter *is* the Gen 2 module.** Your monkey-patch, your
|
||||
`rawset` sentinel and your `==` idempotency check all land on the table Gold
|
||||
actually runs, and `getmetatable(npc) == NPC` is true. Five names are aliases
|
||||
because nothing less would work: mods set their own trailer's metatable to
|
||||
`src.world.NPC`, a mod is handed `world.map` rather than building one, the
|
||||
loader builds every `mod.world` out of `src.world.WorldAPI` so a copy would
|
||||
give two, `src.world.PikachuFollower` is reached with `debug.setupvalue` on a
|
||||
file-local, and `Screens` caches `src.ui.BoxMenu` for `"Gen2PcMenu"` so a
|
||||
`.new` patch has to land there.
|
||||
|
||||
Note that `src.ui.BoxMenu` points at `src/ui/gen2/PcMenu.lua`, not at
|
||||
`src/ui/gen2/BoxMenu.lua`. Gen 1's `BoxMenu` is Bill's PC *top menu*, whose
|
||||
Gold counterpart is `PcMenu`; Gold's `BoxMenu` is the withdraw/deposit *list*
|
||||
that Gen 1 builds inline.
|
||||
|
||||
**Facade means a translating wrapper.** `.overworld` resolves `Game2.world`,
|
||||
`writeOptions` resolves `Game2:persistOptions`, `game.data.sprites` resolves
|
||||
`data.gen2Sprites`, `NPC.new(data, mapId, objDef)` is sniffed apart from
|
||||
`NPC.new(mapId, objDef, spriteDef)` and the movement vocabulary is translated
|
||||
with it. The four UI facades (`PartyMenu`, `StartMenu`, `OptionsMenu`,
|
||||
`BattleState`) are write-through: reads fall to the Gen 2 class and **writes go
|
||||
to the Gen 2 class**, so `PartyMenu.update = wrapper` still patches the live
|
||||
class Gold pushes. Your write also *reads back as your own value* -- after
|
||||
`PartyMenu.new = wrapper`, `PartyMenu.new` is `wrapper` and nothing else, so
|
||||
`rawequal` holds and an idempotency check works. That is what makes the ordinary
|
||||
capture-and-chain idiom safe: a wrapper that calls the value it captured reaches
|
||||
Gold's real constructor rather than re-entering the facade's own override.
|
||||
Writing `nil` clears the member instead of re-exposing the override underneath.
|
||||
|
||||
The `src.world.OverworldController` facade is a facade over the live `World`,
|
||||
not over a class, so seven of its fields (`map`, `player`, `npcs`, `entities`,
|
||||
`ghosts`, `npcPool`, `camera`) read **and write** through to the running world:
|
||||
Gen 1's module *is* the singleton, so a write has to land somewhere real. A
|
||||
write made before a world exists is dropped with a warning rather than
|
||||
shadowing the world it would have applied to.
|
||||
|
||||
### backed, warned, absent
|
||||
|
||||
The adapter publishes what it covers, and the checker consumes that same table
|
||||
rather than a copy of it. Exactly three statuses, and a member listed as both
|
||||
resolves to the weaker one:
|
||||
|
||||
- **`backed`** -- present, and it does the Gen 1 job on Gold. Read the note
|
||||
anyway where there is one: several backed members are backed with a caveat
|
||||
(`Boxes.COUNT` is 14 on Gold and not 12; `BattleState.say` ignores
|
||||
`sayAuto`'s delay because Gold's messages always auto-advance;
|
||||
`Collision.DELTA` is Gold's live table, so adding a key mutates Gold's own
|
||||
movement).
|
||||
- **`warned`** -- present, answers nil or degrades, and names itself once in
|
||||
the log with your mod attributed. `Game.renderer`, `Game.load`,
|
||||
`Game.step`, `game.data.field`, `game.data.constants`,
|
||||
`ScriptRunner.resume` / `.update` / `.parallel`, `PartyMenu.tmhm` and
|
||||
`OverworldController.neighbors` / `.npcByIndex` are here. `neighbors` is the
|
||||
shape of the whole category: Gold's rows are `{ id, ox, oy, image }` where
|
||||
Gen 1's are `{ map = mapDef, ox, oy }`, so the field warns and answers nil
|
||||
rather than handing back a list whose `nb.map` is nil on every row.
|
||||
- **`absent`** -- deliberately not on the table. It reads nil, which is the
|
||||
honest failure. `BattleState.newWild`, `OverworldController.rollEncounter`,
|
||||
`Map.warpPadOrHoleAt`, `PikachuFollower.shouldSpawn` and 157 others are
|
||||
here. (`shouldSpawn` is absent as a *module member* on both generations: it
|
||||
is a file-local, reached through `setShouldSpawn` or the upvalue of that
|
||||
name, and the coverage table says so rather than implying a field exists.)
|
||||
|
||||
"Absent" means *not served*, not *wrong*. Every one of them was left off for a
|
||||
stated reason, and the reason is in the coverage note. `BattleState.newWild` is
|
||||
the clearest case: Gold has no factory that returns an unpushed battle, because
|
||||
`World:startBattle` constructs and pushes in one call, so a `newWild` taking a
|
||||
species and a level would be a lie about what Gold's battle screen is. The
|
||||
route for the thing you were actually doing (rewriting the species of a wild
|
||||
encounter) is the `encounter.species` hook, which Gold raises under the same
|
||||
name with the same shape.
|
||||
|
||||
A member the table does not record is not a guarantee of anything. What it does
|
||||
depends on the adapter: an alias hands you the Gen 2 module's own member,
|
||||
whatever that is; a write-through facade falls to the Gen 2 class; the
|
||||
`src.core.Game` facade names it in the log and reads nil; the
|
||||
`src.world.OverworldController` facade reads nil silently. The checker is
|
||||
silent about it too.
|
||||
|
||||
### Reading the coverage yourself
|
||||
|
||||
The table is queryable, and it is the same query the checker makes:
|
||||
|
||||
```lua
|
||||
local Gen2Compat = require("src.mods.Gen2Compat")
|
||||
|
||||
Gen2Compat.modules() -- the 15 served names, sorted
|
||||
Gen2Compat.serves("src.world.Map") -- true
|
||||
Gen2Compat.memberStatus("src.battle.BattleState", "newWild") -- "absent"
|
||||
|
||||
local c = Gen2Compat.coverage("src.world.Map")
|
||||
-- { module, kind = "facade"|"alias", target, members = { [name] = status },
|
||||
-- notes = { [name-or-topic] = "one line" } }
|
||||
```
|
||||
|
||||
`Gen2Compat.COVERAGE_VERSION` is 1 and `Gen2Compat.STATUS` carries the three
|
||||
status strings. `notes` keys are documentation topics, not a member list:
|
||||
dotted paths (`save.money`), field names (`warpAt`), hook names
|
||||
(`hook ui.pc.items`) and bare topics (`identity`, `iteration`, `rawset`) all
|
||||
appear there. `members` is the authoritative set.
|
||||
|
||||
To dump the lot for one module:
|
||||
|
||||
```sh
|
||||
luajit -e 'package.path="./?.lua;"..package.path
|
||||
local G=require("src.mods.Gen2Compat")
|
||||
local c=G.coverage("src.world.OverworldController")
|
||||
for m,s in pairs(c.members) do print(s,m) end
|
||||
for k,v in pairs(c.notes) do print("note",k,v) end'
|
||||
```
|
||||
|
||||
## The patterns no adapter can fix
|
||||
|
||||
Five shapes come up in nearly every real Gen 1 mod, and none of them can be
|
||||
fixed on the engine side without lying to you. Each one has a route that works
|
||||
on both generations.
|
||||
|
||||
### 1. A hardcoded version allow-list
|
||||
|
||||
```lua
|
||||
local v = GameVersion.get()
|
||||
if v ~= "red" and v ~= "blue" and v ~= "yellow" then return false end
|
||||
```
|
||||
|
||||
This excludes you from Gold by construction, and it does so *after* everything
|
||||
else in your mod has been made to work, which is why it produces the most
|
||||
confusing possible outcome: the adapter resolves, your patches land, and the
|
||||
feature still never appears. `MK409` catches it.
|
||||
|
||||
**Instead**, test for the thing the branch actually depends on. If it is there
|
||||
because a member might be missing, test the member:
|
||||
|
||||
```lua
|
||||
local Follower = require("src.world.PikachuFollower")
|
||||
if Follower.setShouldSpawn then ... end -- present on Gold, absent on Gen 1
|
||||
```
|
||||
|
||||
If it is there because a piece of per-cart content might be missing, test the
|
||||
content -- `mod.find` and the merged data tables answer that in both games.
|
||||
Version tests stay legitimate for genuinely per-cart *content*, which is what
|
||||
Yellow's starter rename is; they are never right as a gate on a whole feature.
|
||||
|
||||
### 2. String-matching a screen id
|
||||
|
||||
```lua
|
||||
if id == "BoxMenu" then ... end
|
||||
```
|
||||
|
||||
Gold's builtin screens are registered under `Gen2`-prefixed ids, so this
|
||||
matches nothing there. `Screens.GEN2_IDS` in `src/ui/Screens.lua` is the full
|
||||
list, 51 ids: `Gen2BoxMenu`, `Gen2PartyMenu`, `Gen2NamingScreen`,
|
||||
`Gen2Credits` and 47 more. `MK409` catches this exact line: it keys off the
|
||||
string literal itself, not off a screen-shaped word elsewhere on the line, so
|
||||
`if id == "BoxMenu" then` is flagged where it used to slip through. The price
|
||||
of that is deliberate breadth -- any literal equal to a Gen 1 screen id with a
|
||||
`Gen2` twin is warned about, wherever it appears -- so the message states what
|
||||
is true of the literal rather than guessing what the surrounding code meant.
|
||||
It is a warn, and reading past a false one costs you nothing.
|
||||
|
||||
**Instead**, either match both ids, or stop matching ids and take the seam the
|
||||
screen offers. Most screens a mod wants to decorate raise a hook whose name is
|
||||
shared across both generations -- `ui.start_menu.items`, `ui.options.rows`,
|
||||
`ui.party.submenu`, `ui.pc.items`, `ui.naming.grid`, `ui.list_menu` -- and a
|
||||
hook subscription needs no id at all. Where you genuinely must key off the id:
|
||||
|
||||
```lua
|
||||
local BOX_IDS = { BoxMenu = true, Gen2PcMenu = true }
|
||||
if BOX_IDS[id] then ... end
|
||||
```
|
||||
|
||||
Watch the pairing. `ui.pc.items` has the same name on both sides but a
|
||||
different menu behind it: Gen 1 raises it over the WHICH-PC list, Gold over
|
||||
Bill's PC's own rows. And Gen 1's `BoxMenu` pairs with `Gen2PcMenu`, not with
|
||||
`Gen2BoxMenu`.
|
||||
|
||||
### 3. `debug.setupvalue` on an engine local
|
||||
|
||||
```lua
|
||||
local idx = findUpvalue(PikachuFollower.update, "shouldSpawn")
|
||||
debug.setupvalue(PikachuFollower.update, idx, myPredicate)
|
||||
```
|
||||
|
||||
This only ever worked because the Gen 1 file happened to hold that predicate in
|
||||
a file-local of that name. Nothing about the engine promises it, and on the Gen
|
||||
2 side the local has to exist under the same name and hold the same thing for
|
||||
the surgery to land. Today it does: `src/world/gen2/Follower.lua:23` declares
|
||||
`local shouldSpawn` for exactly this reason, so follower mods reaching for it
|
||||
work unchanged on Gold. That is a deliberate courtesy, not a contract.
|
||||
|
||||
`MK407` fires in the two cases where the surgery cannot land: when a Gen 2 boot
|
||||
resolves the member to something that is not a function (so `debug.setupvalue`
|
||||
raises), and when the function it does resolve to does not close over that
|
||||
name, in which case the message quotes the upvalues it *does* close over. The
|
||||
check resolves the member through the adapter exactly as the loader does and
|
||||
enumerates the resolved function's real upvalues, so a local that merely
|
||||
appears somewhere in the Gen 2 file is never mistaken for one -- that used to
|
||||
be the check, and it blessed surgery that landed on nothing. `MK408` fires when
|
||||
the scan could not resolve the member either way, which is what you get when
|
||||
`luajit` is not on `PATH`: the check degrades to an honest warn, never to a
|
||||
reassuring note.
|
||||
|
||||
**Instead**, use the named seam when there is one, and fall back only when
|
||||
there is not:
|
||||
|
||||
```lua
|
||||
if Follower.setShouldSpawn then
|
||||
Follower.setShouldSpawn(myPredicate) -- Gen 2, and any future Gen 1 arm
|
||||
else
|
||||
patchUpvalue(Follower.update, "shouldSpawn", myPredicate) -- Gen 1 today
|
||||
end
|
||||
```
|
||||
|
||||
`Follower.setShouldSpawn` writes the same cell `debug.setupvalue` reaches, so
|
||||
the two cannot disagree. Note the presence test is doing real work:
|
||||
`src/world/PikachuFollower.lua` has no `setShouldSpawn`, so this is not a
|
||||
rename you can apply blindly. Note also that the predicate is called
|
||||
`(game, world)` on Gold where Gen 1 passes `(game, ow)` -- the same object under
|
||||
a different name, so a predicate reading `ow.player` or `ow.map` is unchanged.
|
||||
|
||||
### 4. Capturing state off `src.core.Game` at file scope
|
||||
|
||||
```lua
|
||||
local Game = require("src.core.Game")
|
||||
local save = Game.save -- nil forever
|
||||
local party = Game.save.party -- error at load
|
||||
```
|
||||
|
||||
The module require itself is fine and is meant to be: the Gen 2 `src.core.Game`
|
||||
is a proxy that reads the live `Game2` instance on *every* touch, precisely so
|
||||
that a mod capturing it at file scope, before a save or a world exists, keeps
|
||||
working once they do. What does not survive is capturing a *field* off it at
|
||||
file scope, which snapshots nil. This is true on Gen 1 as well; Gold just makes
|
||||
it bite more often because the entry chunk runs earlier relative to the world.
|
||||
`MK410` catches the file-scope read of a member the Gen 1 module only ever
|
||||
writes as `self.<name>`.
|
||||
|
||||
**Instead**, read through the facade at the moment you need the value, or take
|
||||
the live game from the `game.ready` payload:
|
||||
|
||||
```lua
|
||||
local Game = require("src.core.Game")
|
||||
mod.events:on("game.ready", function(ev)
|
||||
local game = ev.game -- the real Game2 instance
|
||||
local party = Game.save.party -- read now, not at file scope
|
||||
end)
|
||||
```
|
||||
|
||||
Three further properties of the proxy that a Gen 1 mod can trip over, all
|
||||
recorded in the coverage notes:
|
||||
|
||||
- **Identity.** The proxy can never compare equal to the `Game2` instance the
|
||||
`game.ready` payload carries. Lua 5.1 fires `__eq` only when both operands
|
||||
share a metatable, so `Game == ev.game` is false on Gold. Do not use it as
|
||||
an idempotency check.
|
||||
- **Iteration.** `pairs`, `next` and `rawget` see an *empty* table, because the
|
||||
proxy holds nothing of its own. Enumerate the `game.ready` payload instead.
|
||||
- **`rawset`.** `rawset(Game, k, v)` lands on the proxy, reads back correctly
|
||||
through the same facade, and is completely invisible to the engine. That
|
||||
read-back is what hides it. Use a plain assignment, which writes through to
|
||||
the live instance.
|
||||
|
||||
The save layout moved too, and those fields are absent rather than aliased so
|
||||
that a wrong read is loud rather than silent: `save.money` is
|
||||
`save.player.money`, `save.player.map` / `.x` / `.y` / `.facing` are
|
||||
`save.position.*`, and `save.player.rival` is `save.rival.name`. `save` itself
|
||||
is a straight pass-through on purpose.
|
||||
|
||||
### 5. Monkey-patching a class, and the two ways it goes wrong
|
||||
|
||||
Patching a shared class method is *supported*, and this is worth stating
|
||||
plainly because it is the thing most authors expect to have to rewrite. The
|
||||
four UI facades write through: `__newindex` forwards to the Gen 2 class, so
|
||||
|
||||
```lua
|
||||
local PartyMenu = require("src.ui.PartyMenu")
|
||||
local origUpdate = PartyMenu.update
|
||||
function PartyMenu.update(self, dt) ... return origUpdate(self, dt) end
|
||||
```
|
||||
|
||||
lands on the class Gold actually pushes. Aliases are the class, so the same
|
||||
holds there.
|
||||
|
||||
Two variants do not work, and neither can be made to.
|
||||
|
||||
**Patching a member the Gen 2 class does not have.** The write succeeds, reads
|
||||
back as your own function, and nothing ever calls it. `BattleState.newWild =
|
||||
wrapper` is the canonical case: the assignment is taken, and no Gold code path
|
||||
reads that name. This is the one place the read-back works against you, which
|
||||
is why `MK404` reports the write site separately from the read site.
|
||||
|
||||
**Patching a field on a live instance.** `menu.onSwitch = fn` writes a field
|
||||
Gen 2 never reads -- Gold takes it as `onChoose` at construction. Same for
|
||||
`menu.swapFrom` (renamed `switchFrom`) and for `StartMenu`'s `tx` / `ty` / `tw`
|
||||
/ `th` / `anchor` / `maxVisible`, which do not exist on Gold at all because the
|
||||
box is fixed at `Chrome.box(10, 0, 10, h)`. A write to any of them is inert.
|
||||
Pass what you need to `.new` instead: `PartyMenu.new(game, { onSwitch = f })`
|
||||
with no `battle`, `pickOnly` or `forceSwitch` opens the plain list and calls
|
||||
`onSwitch(mon, menu)` on A, which is the Gen 1 behavior the facade reproduces.
|
||||
|
||||
A close relative worth calling out because it errors rather than no-ops:
|
||||
`map.warpAt` is a name collision, not a rename. Gen 1's is a *table* keyed by
|
||||
cell; Gold's `Map:warpAt` is a *method* of the same name. `map.warpAt[cell]`
|
||||
and `pairs(map.warpAt)` both raise, which is loud but points at your mod.
|
||||
Enumerate `map.warps`, which Gold carries as an ordered array.
|
||||
|
||||
## A worked migration
|
||||
|
||||
Here is one real one, start to finish. The mod is a follower pack written for
|
||||
Red/Blue/Yellow. `gen2check` reports `MK400` on the manifest, `MK404` twice on
|
||||
`BattleState.newWild` and `MK409` on a version allow-list, plus a note
|
||||
confirming its `shouldSpawn` surgery lands.
|
||||
|
||||
**Before.** Three separate problems in about twenty lines.
|
||||
|
||||
```lua
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local PikachuFollower = require("src.world.PikachuFollower")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
|
||||
return function(mod)
|
||||
-- (1) rewrite the starter encounter's species
|
||||
local origNewWild = BattleState.newWild
|
||||
BattleState.newWild = function(game, species, level, ...)
|
||||
if species == "PIKACHU" and level == 5 then species = "CHARMANDER" end
|
||||
return origNewWild(game, species, level, ...)
|
||||
end
|
||||
|
||||
-- (2) decide whether a follower spawns
|
||||
local newShouldSpawn = function(game, ow)
|
||||
local v = GameVersion.get()
|
||||
if v ~= "red" and v ~= "blue" and v ~= "yellow" then return false end
|
||||
return packSize(game) > 0
|
||||
end
|
||||
|
||||
-- (3) install it
|
||||
patchUpvalue(PikachuFollower.update, "shouldSpawn", newShouldSpawn)
|
||||
patchUpvalue(PikachuFollower.onMapEntered, "shouldSpawn", newShouldSpawn)
|
||||
end
|
||||
```
|
||||
|
||||
On Gold: (1) assigns onto a name nothing reads, so the species rewrite never
|
||||
happens. (2) returns false for every Gold boot, so no follower ever spawns.
|
||||
(3) actually works, and works on a predicate that has already decided to do
|
||||
nothing. Two silent failures and one correct mechanism pointed at them.
|
||||
|
||||
**After.** The manifest gains `"games": ["gen1", "gen2"]`, and:
|
||||
|
||||
```lua
|
||||
local PikachuFollower = require("src.world.PikachuFollower")
|
||||
|
||||
return function(mod)
|
||||
-- (1) the species of a wild encounter is a hook on both generations
|
||||
mod.hooks:wrap("encounter.species", function(next, enc, ctx)
|
||||
local rolled = next(enc, ctx)
|
||||
if rolled and rolled.species == "PIKACHU" and rolled.level == 5 then
|
||||
rolled.species = "CHARMANDER"
|
||||
end
|
||||
return rolled
|
||||
end)
|
||||
|
||||
-- (2) no cart check: whether there is a pack to walk is the whole question
|
||||
local newShouldSpawn = function(game, ow)
|
||||
return packSize(game) > 0
|
||||
end
|
||||
|
||||
-- (3) the named seam where there is one, the upvalue where there is not
|
||||
if PikachuFollower.setShouldSpawn then
|
||||
PikachuFollower.setShouldSpawn(newShouldSpawn)
|
||||
else
|
||||
patchUpvalue(PikachuFollower.update, "shouldSpawn", newShouldSpawn)
|
||||
patchUpvalue(PikachuFollower.onMapEntered, "shouldSpawn", newShouldSpawn)
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
`gen2check` now reports clean, and the mod is shorter than it was on Gen 1
|
||||
alone. That is the usual shape of this work: two of the three fixes replace
|
||||
engine surgery with an API that existed the whole time, and only the third
|
||||
needs a generation branch.
|
||||
|
||||
The one change that is *not* a simplification is the hook's contract. A wrapper
|
||||
takes `(next, ...)` and must call `next` with the arguments it was handed, where
|
||||
the monkey-patch could do as it liked with them. `encounter.species` transforms
|
||||
a rolled `{ species, level }` and gets a `ctx` beside it: Gen 1 fills in
|
||||
`mapId`, `terrain` and `rng`, and Gold adds `daytime`, `environment`, `kind`
|
||||
(`"wild"` / `"contest"` / `"script"` / `"sweet_scent"`), `tables` and `data`.
|
||||
So the same
|
||||
subscription serves both games, and a Gold-only refinement is a field test
|
||||
rather than a second hook. That is the trade: a narrower seam that both engines
|
||||
raise, in exchange for not owning a function neither engine promised you.
|
||||
|
||||
## Testing
|
||||
|
||||
**Headless, without a Gold cache.** The SDK harness takes the generation
|
||||
directly, and everything after that is the production path -- same loader, same
|
||||
validate, same topological sort, same merge:
|
||||
|
||||
```lua
|
||||
local run = T.sdk.loadMod("mods/my_mod", { generation = 2 })
|
||||
T.eq(run.mod and run.mod.state, "loaded",
|
||||
"runs on gen 2: " .. tostring(run.mod and run.mod.skipReason))
|
||||
T.eq(#run.errors, 0, "and loads with no boot errors")
|
||||
run.release()
|
||||
```
|
||||
|
||||
**Assert the state, not just the error count.** A gate skip is deliberately not
|
||||
an error: `Loader:_skip` sets `mod.state` and `mod.skipReason` and stays off
|
||||
`loader.errors`, because neither the mod nor its dependency has a bug. So
|
||||
`T.eq(#run.errors, 0)` on its own passes for a mod that never ran a line, which
|
||||
is the one result you were testing to rule out. `run.mod.state` is `"loaded"`
|
||||
when the entry chunk ran and `"wrong_generation"` when the gate or the
|
||||
dependency contagion took it, with `run.mod.skipReason` carrying the sentence
|
||||
the manager would show. Keep the error assertion too: it is what catches a
|
||||
registry with no Gen 2 home and a require the adapter does not serve, both of
|
||||
which *do* land on `loader.errors`.
|
||||
|
||||
**On a real Gold boot.** Nothing above substitutes for running it. Import Gold
|
||||
in the launcher, enable your mod, and play the part your mod touches. Be
|
||||
precise about where the adapter talks to you, because the two channels are not
|
||||
the same:
|
||||
|
||||
- **The log** carries the adapter's own warnings, each attributed to the mod
|
||||
holding the facade (`[my_mod] Game.renderer has no Gen 2 backing: ...`), so a
|
||||
member that degraded tells you which one and why. `Gen2Compat.warnOnce` goes
|
||||
to `Logger.warn` and nowhere else -- these do **not** appear in the manager.
|
||||
- **The manager's error feed** (`loader.errors`) is a shorter list: a mod that
|
||||
failed validation, a duplicate mod id, a registry with no Gen 2 target, a
|
||||
cross-validation problem, and the one adapter-adjacent case, a require for a
|
||||
Gen 1 module the adapter does not serve. A skipped mod is not on it, and
|
||||
neither is a degraded member.
|
||||
|
||||
So: read the log for coverage problems, and the manager for load problems.
|
||||
|
||||
`POKEPORT_IDENTITY=<name>` sandboxes the save directory if you want a clean
|
||||
profile to test in, and `POKEPORT_DEV=1` adds the console and `F5` hot reload.
|
||||
|
||||
## What this guide does not promise
|
||||
|
||||
- **Coverage is partial and will stay partial.** 15 Gen 1 modules are served
|
||||
out of a much larger engine, and within those 15 the coverage table records
|
||||
291 backed members against 32 warned and 161 absent. The absent ones are not
|
||||
a backlog; most are absent because there is no honest Gen 2 answer, and each
|
||||
one carries its reason. The counts move as the adapter learns something: a
|
||||
member that turns out to answer nil is demoted from backed to warned or
|
||||
absent rather than left flattering the table.
|
||||
- **Absent is not broken, it is not-served.** A nil read is the designed
|
||||
outcome. If you would rather have an error, test for the member before you
|
||||
use it.
|
||||
- **The checker is a static scan.** It cannot follow a require built at
|
||||
runtime, cannot tie every `debug` call to a module, and says nothing at all
|
||||
about a member the coverage table does not record. What it *can* do is admit
|
||||
each of those individually, with a file and a line, as an `unresolved:` note.
|
||||
Read the notes as part of the report: a clean finding list with notes under
|
||||
it means "nothing known-broken was found in the part I could follow", and
|
||||
only a clean finding list with no notes means the scan followed everything.
|
||||
- **A backed member can still surprise you.** `backed` means the adapter took
|
||||
responsibility for the Gen 1 call shape, not that Gold behaves identically.
|
||||
Run `gen2check --notes` once and read the caveats on the members you touch.
|
||||
- **The adapter is not a compatibility layer for new code.** It exists so mods
|
||||
written before Gold existed keep working. If you are writing something now,
|
||||
`mod.game`, `mod.world`, the registries and the hooks mean the same thing in
|
||||
both games and need none of this.
|
||||
@@ -1,96 +0,0 @@
|
||||
# RFC 0001 — Port Yellow's `IsSurfingPikachuInParty` surf sprite
|
||||
|
||||
## Status
|
||||
|
||||
Proposed. Engine: `Player.lua`, `FieldDefaults.lua`,
|
||||
`OverworldController.lua`, `RomExtractor.lua`, `PaletteFX.lua`. Tools:
|
||||
`build_rom_data.py`, `extract/sprites.py`, `make_rom_manifest.py`,
|
||||
`make_yellow_manifest.py`. Tests: `parity_surfing_pikachu_sprite.lua`,
|
||||
`mod_world_tests.lua`.
|
||||
|
||||
**Regeneration required.** The manifest and sprite sheet update by
|
||||
re-running `make_yellow_manifest.py` against a `pret/pokeyellow`
|
||||
checkout, then re-importing the Yellow ROM.
|
||||
|
||||
## Motivation
|
||||
|
||||
Yellow's `IsSurfingPikachuInParty` + `LoadSurfingPlayerSpriteGraphics2`
|
||||
(`home/map_objects.asm`, `home/overworld.asm`) swap the player's
|
||||
overworld sheet to `SurfingPikachuSprite` (`gfx/sprites/
|
||||
surfing_pikachu.2bpp`, a 16×96 walk sheet — not the minigame sheets)
|
||||
when the party mon that knows SURF is a Pikachu. The recomp misses this
|
||||
in two places:
|
||||
|
||||
1. **Extraction.** `SurfingPikachuSprite` is not in
|
||||
`SpriteSheetPointerTable` — loaded by its own `ld de,` like
|
||||
`RedBikeSprite`. The extractor never sees it, and the symbol is not
|
||||
in the Yellow manifest.
|
||||
2. **Engine rule.** `field.playerSprites.surf` is one static
|
||||
(`SPRITE_SEEL`), cached at boot. No seam for "swap when the SURF-mon
|
||||
is a Pikachu."
|
||||
|
||||
## The decision it extends
|
||||
|
||||
No prior D-number. Extends the surf-field-move port in
|
||||
`docs/behavior-porting-notes.md` (the `IsSurfingAllowed` exact port)
|
||||
with the player-sprite swap vanilla runs alongside it.
|
||||
|
||||
## The exact API delta
|
||||
|
||||
Backward-compatible, additive-only.
|
||||
|
||||
### `field.playerSprites.surfPikachu`
|
||||
|
||||
New optional key alongside `walk`/`surf`/`bike`/`fly`, defaults to
|
||||
`SPRITE_SURFING_PIKACHU`. Guarded in `Player.new` so before extraction
|
||||
lands the ride keeps the Seel — no plain on-water Pikachu.
|
||||
|
||||
### `Player.surfPikachuSprite`
|
||||
|
||||
`Player.new` builds a second `SpriteRenderer` when the field resolves.
|
||||
`pose()` picks it when `surfing and surfingPikachu`.
|
||||
|
||||
### `Player.surfingPikachu` (runtime)
|
||||
|
||||
Runtime-only boolean (not persisted); re-derived so a party change
|
||||
between save and load is honored.
|
||||
|
||||
### `OverworldState:syncSurfingPikachu()`
|
||||
|
||||
Sets `player.surfingPikachu` from `partyKnows("SURF")`. Called at every
|
||||
surf-state toggle: trySurf, dismount, flyTo, beginTeleportOut,
|
||||
warpToHealPoint, forced-surf tile, setMap boot-restore.
|
||||
|
||||
### Importer — `SPRITE_SURFING_PIKACHU`
|
||||
|
||||
`make_yellow_manifest.py` adds `SurfingPikachuSprite` to
|
||||
`YELLOW_EXTRA_SYMBOLS`. `make_rom_manifest.py`'s `sprite_metadata()`
|
||||
gains a `surfPikachu` entry (guarded, so Red/Blue unchanged).
|
||||
`RomExtractor.extractSprites` + `build_rom_data.py` + `extract/sprites.py`
|
||||
each gain a parallel extract mirroring `RedBikeSprite`.
|
||||
|
||||
### `PaletteFX.spriteObp`
|
||||
|
||||
`SurfingPikachuSprite` joins `RedBikeSprite` in the no-bracket-index
|
||||
special case, wearing the player's OBP palette so it colors in GBC mode.
|
||||
|
||||
## Migration note for existing mods
|
||||
|
||||
**Nothing.** `surf` still defaults to `SPRITE_SEEL`; `surfPikachu`
|
||||
only resolves on a Yellow import after regeneration. No manifest or
|
||||
`mod.save` shape changes. An eligibility hook that swaps a rental
|
||||
SURF-mon still drives the sprite pick via `partyKnows`.
|
||||
|
||||
## Parity tests
|
||||
|
||||
- **No-mod** (`mod_world_tests.lua`): `surf == "SPRITE_SEEL"`,
|
||||
`surfPikachu == "SPRITE_SURFING_PIKACHU"` seeded at boot. The 19229-check
|
||||
`world & maps v2` suite stays green.
|
||||
- **Mod-API** (`parity_surfing_pikachu_sprite.lua`): `syncSurfingPikachu`
|
||||
+ `Player:pose` across four party shapes (12/12). The existing
|
||||
`parity_cinnabar_east_surf.lua` (24/24) stays green.
|
||||
|
||||
## Deprecation etiquette
|
||||
|
||||
Nothing deprecated. Additive: a new `field.playerSprites` key, a new
|
||||
runtime flag, a new engine method, a new sprite id.
|
||||
@@ -1,54 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,114 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,124 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,138 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,140 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,107 +0,0 @@
|
||||
# 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`.
|
||||
@@ -1,212 +0,0 @@
|
||||
# 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,186 @@
|
||||
# Touch skins and the Skin Studio
|
||||
|
||||
A **skin** replaces the on-screen controls wholesale: a bezel image, a
|
||||
control layout, and the rectangle the Game Boy screen is drawn into. Engine:
|
||||
`src/core/TouchSkin.lua` (model, parsers, zip export), `src/core/TouchControls.lua`
|
||||
(draw and input), `src/render/Renderer.lua` (the screen viewport),
|
||||
`src/ui/SkinStudio.lua` (the desktop editor). Tests:
|
||||
`tests/engine/touch_skin_test.lua`, `tests/engine/skin_studio_test.lua`,
|
||||
`tests/engine/skin_studio_image_import.lua`,
|
||||
`tests/engine/launcher_skins_tab.lua`.
|
||||
|
||||
Skins are picked in the launcher's **Skins** tab, which also imports them and
|
||||
opens the studio. `options.touchControls.skin` holds the folder name.
|
||||
|
||||
## Formats
|
||||
|
||||
Two load. `skin.lua` wins when a folder has both.
|
||||
|
||||
**RetroArch overlay `.cfg`.** The libretro `common-overlays` collection loads
|
||||
as-is. Supported keys:
|
||||
|
||||
| Key | Meaning |
|
||||
| --- | --- |
|
||||
| `overlays` | page count |
|
||||
| `overlayN_name` | page name, the target of `next_target` |
|
||||
| `overlayN_overlay` | bezel image |
|
||||
| `overlayN_full_screen` | stretch the page to the window |
|
||||
| `overlayN_rect` | page placement, default `0,0,1,1` |
|
||||
| `overlayN_aspect_ratio` | fallback aspect when not full screen |
|
||||
| `overlayN_range_mod`, `overlayN_alpha_mod` | desc defaults |
|
||||
| `overlayN_viewport` | `x,y,w,h`, the screen cutout |
|
||||
| `overlayN_viewport_fill` | parsed; the engine always fits, see below |
|
||||
| `overlayN_descM` | `binds,x,y,shape,range_x,range_y` |
|
||||
| `overlayN_descM_overlay` | control art |
|
||||
| `overlayN_descM_next_target` | page to switch to |
|
||||
| `overlayN_descM_range_mod`, `_alpha_mod` | per-control overrides |
|
||||
| `overlayN_descM_reach_x/_y/_up/_down/_left/_right` | hitbox reach |
|
||||
|
||||
`x,y` is the centre and `range_x,range_y` are half extents, both normalized.
|
||||
Hitboxes are `radial` or `rect`. Pipe-separated binds (`left|down`) are one
|
||||
control that holds both. A `nul` desc is decoration: it draws and never
|
||||
captures a touch.
|
||||
|
||||
Alpha follows RetroArch (`input_driver.c`, `input_overlay_post_poll`): every
|
||||
image sits at the overlay opacity, and a pressed control's image swaps to
|
||||
`opacity * alpha_mod`. So `alpha_mod` above 1 lights a control up and below 1
|
||||
fades it out, and both directions read as a press animation.
|
||||
|
||||
**Native `skin.lua`.** This module's own model written back out: one Lua
|
||||
table, no flat key space, and a separate `imagePressed` per control that a
|
||||
`.cfg` cannot express. Loaded with an empty environment, so a skin authored by
|
||||
a stranger cannot reach `love` or `io`. Sizes here are full width and height
|
||||
rather than RetroArch's half extents, because that is what an editor's numeric
|
||||
fields mean.
|
||||
|
||||
```lua
|
||||
return {
|
||||
name = "my_skin",
|
||||
pages = {
|
||||
{
|
||||
name = "main",
|
||||
image = "img/bezel.png",
|
||||
fullScreen = true,
|
||||
viewport = { x = 0.0, y = 0.0, w = 1.0, h = 0.5, fill = false },
|
||||
controls = {
|
||||
{ bind = "a", x = 0.87, y = 0.72, w = 0.18, h = 0.10,
|
||||
shape = "radial", image = "img/a.png", imagePressed = "img/a_down.png" },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Bindable actions
|
||||
|
||||
The eight Game Boy buttons: `a`, `b`, `start`, `select`, `up`, `down`,
|
||||
`left`, `right`.
|
||||
|
||||
Engine hotkeys, handled in `Game:touchSkinHotkey`:
|
||||
|
||||
| Bind | Effect |
|
||||
| --- | --- |
|
||||
| `overlay_next`, `overlay_previous` | switch page, honouring `next_target` |
|
||||
| `hold_fast_forward`, `fast_forward` | fast forward while held |
|
||||
| `toggle_fast_forward` | step the speed option |
|
||||
| `reset` | soft reset to the title |
|
||||
| `menu_toggle` | open OPTIONS |
|
||||
|
||||
`screenshot`, `pause_toggle` and `exit_emulator` are recognised but have no
|
||||
handler yet: a control bound to them draws and does nothing. Anything else,
|
||||
`rewind` included, is not in the bind table at all, so the control falls back
|
||||
to decoration and never captures a touch.
|
||||
|
||||
As an extension to the format, `key:<name>` presses any keyboard key, which is
|
||||
how a skin button reaches a mod hotkey.
|
||||
|
||||
## The screen viewport
|
||||
|
||||
`overlayN_viewport` is the cutout the picture is fitted into. The Game Boy
|
||||
screen keeps its whole-pixel scale and letterboxes inside that rect rather than
|
||||
stretching to it, so a bezel gets an exact 160x144 picture; `viewport_fill` is
|
||||
parsed but does not stretch. `overlayN_viewport_expand = true` is an extension
|
||||
that lets a widescreen bezel take the filling survey-zoom world view instead.
|
||||
|
||||
A viewport also implies the faithful-ratio lock. Without it the world pass
|
||||
expands to fill the cutout and you get more map instead of a Game Boy screen.
|
||||
|
||||
Border art often ships with a transparent hole and no `viewport` key. **Detect
|
||||
screen from bezel** in the studio measures the hole out of the art's alpha
|
||||
channel and writes the rect.
|
||||
|
||||
## Bezels versus pads
|
||||
|
||||
A skin whose active page binds nothing is a frame rather than a pad: a TV
|
||||
surround, a handheld shell, a Super Game Boy border. Those draw on **desktop**
|
||||
as well, where the touch overlay itself does not, and a gamepad does not hide
|
||||
them. Anything that binds a button still follows the usual mobile /
|
||||
`POKEPORT_TOUCH` rule.
|
||||
|
||||
## Installing
|
||||
|
||||
Drop a folder or a `.zip` into `skins/` in the save directory, or drop a zip on
|
||||
the launcher window while the Skins tab is open. A zip is mounted in place, so
|
||||
there is nothing to unpack. The folder needs one `skin.lua` or `.cfg`
|
||||
(`overlay.cfg` is preferred when there are several) and the images it names.
|
||||
|
||||
Two ship bundled, both from libretro's `common-overlays` under CC-BY-4.0:
|
||||
|
||||
| Skin | Source | Shape |
|
||||
| --- | --- | --- |
|
||||
| `gb_anim` | `gamepads/gb_anim_portrait` | handheld shell, working buttons, two pages |
|
||||
| `tv_crt` | `borders/tv-integer` | CRT television frame, no buttons |
|
||||
|
||||
Attribution lives in each folder's `README.md`. `tv_crt` is a photograph of a
|
||||
real television: CC-BY-4.0 upstream, but treat it as a test asset rather than
|
||||
shipping branding.
|
||||
|
||||
## The studio
|
||||
|
||||
Launcher, Skins tab, **Open Skin Studio**, or the gear on any skin row to open
|
||||
that skin. Desktop only: the launcher does not offer it on Android or iOS,
|
||||
because it wants a mouse, typed coordinates and room for an inspector.
|
||||
|
||||
**Canvas.** A mock device at a chosen preset, so a phone skin is authored at
|
||||
phone proportions on a desktop monitor.
|
||||
|
||||
| Preset | Size |
|
||||
| --- | --- |
|
||||
| Phone portrait / landscape | 1080x1920, 1920x1080 |
|
||||
| Tablet portrait / landscape | 1536x2048, 2048x1536 |
|
||||
| Steam Deck | 1280x800 |
|
||||
| Desktop 1080p | 1920x1080 |
|
||||
| Ultrawide 21:9 | 2560x1080 |
|
||||
| Super Game Boy border | 256x224 |
|
||||
|
||||
The Super Game Boy preset locks the viewport to the real screen window,
|
||||
160x144 at (48,40), so an SGB border cannot be drawn out of register.
|
||||
|
||||
**Editing.** Click a control to select it, drag to move, eight handles to
|
||||
resize. X / Y / W / H are in canvas pixels, so a control can be typed to the
|
||||
coordinate its art was drawn at. Bind, hitbox shape, hit reach and idle and
|
||||
pressed images are per control; the bezel, the pages and the screen cutout are
|
||||
per page. The cutout is itself a draggable element with a 10:9 lock.
|
||||
|
||||
**Art.** The **Bezel**, **Idle art** and **Pressed art** rows cycle through the
|
||||
images already in the skin folder; the **Import** button beside each one opens
|
||||
the host file picker (`src/core/FilePicker.lua`: osascript, PowerShell,
|
||||
zenity/kdialog) and copies the chosen PNG or JPG into `img/` under the name in
|
||||
the SKIN field, then assigns it to that slot. Dropping a PNG or JPG on the
|
||||
window does the same for whichever slot was last touched. A new bezel does not
|
||||
move the screen cutout: press **Detect screen from bezel** to measure it out of
|
||||
the art's alpha.
|
||||
|
||||
**Testing.** **Test** makes the canvas live: clicking presses real Game Boy
|
||||
buttons and the footer reports what is held. **Play** saves the skin, selects
|
||||
it, and boots the game with it.
|
||||
|
||||
**Saving.** **Save** writes `skins/<name>/skin.lua` and copies every image the
|
||||
skin names, so the folder stands alone. **Export** packs it as one zip
|
||||
(`src/core/SkinZip.lua`, store-only) carrying the native `skin.lua`, the
|
||||
images, and the original `.cfg` when it came from one. An exported skin drops
|
||||
straight back into `skins/` and still opens in RetroArch.
|
||||
|
||||
## Not implemented
|
||||
|
||||
RetroArch's `analog_*`, `dpad_area`, `abxy_area` and `retrok_*` desc types.
|
||||
@@ -1,65 +0,0 @@
|
||||
# Tiled map editing (mod authoring)
|
||||
|
||||
`tools/tiled_export.py` turns the imported ROM cache into a
|
||||
[Tiled](https://www.mapeditor.org) workspace, so maps can be edited in a
|
||||
real map editor and exported back out as a mod. The original had no map
|
||||
editor at all; the port's own map data is plain Lua, which is what makes
|
||||
this a data path rather than an asset path.
|
||||
|
||||
Editing is done in our own Tiled build,
|
||||
[bryanthaboi/tiled_gen1recomp](https://github.com/bryanthaboi/tiled_gen1recomp/releases),
|
||||
which ships the `gen1-mod-export` extension the workspace relies on. Grab it
|
||||
from that repo's releases; upstream Tiled opens the workspace but cannot
|
||||
export a mod out of it.
|
||||
|
||||
```sh
|
||||
python3 tools/tiled_export.py # -> build/tiled/ (gitignored)
|
||||
```
|
||||
|
||||
Then open `build/tiled/gen1.tiled-project` in that build of Tiled.
|
||||
|
||||
- **The overworld is one surface.** All 222 maps become `maps/*.tmj`, and
|
||||
`kanto.world` places the 36 connected overworld maps at their real
|
||||
connection offsets. That world is pre-loaded (seeded into the workspace's
|
||||
Tiled session), so opening any one overworld map draws its neighbors around
|
||||
it and you scroll and edit straight across the seams. Everything else is a
|
||||
double-click away in Tiled's project panel.
|
||||
- **Extending Kanto wires both ends.** A connection lives on both maps, so
|
||||
hooking a new map onto a base map also emits the return connection as a
|
||||
patch on that base map, keeping its other directions intact. The return
|
||||
offset is derived, not guessed: all 78 vanilla reciprocal pairs satisfy
|
||||
`back.offset == -offset`.
|
||||
- **A Tiled tile is a gen1 block.** Each of the 24 tilesets becomes a Tiled
|
||||
tileset whose tiles are its 32x32 blocks, composited from the 8x8 sheet,
|
||||
so a tile layer *is* the map's `blocks` array. Warps, signs and objects
|
||||
sit on the 16px cell grid in object layers, which is the grid the engine
|
||||
addresses them on.
|
||||
- **Collision is visible.** View > Show Tile Collision Shapes draws the real
|
||||
walkability: a rectangle covers each cell whose feet tile is not in the
|
||||
tileset's `walkable` list, which is the rule `src/world/Map.lua` applies.
|
||||
- **Maps are shown in their real colors.** Each map is atlased in the SGB
|
||||
palette it renders with, so Cerulean is blue and Lavender is purple in the
|
||||
editor exactly as in game. Vanilla resolves that through a cascade with
|
||||
interiors inheriting the last outdoor map, so the workspace mirrors the
|
||||
cascade and walks the warp graph to colour interiors. Changing a map's
|
||||
`palette` exports `palette = "..."` on the record, which beats the cascade,
|
||||
and the editor offers the real palette names as a dropdown.
|
||||
- **New blocks and new tilesets.** `blocksets/*.tmj` show a tileset's blocks
|
||||
as raw 8x8 tiles, four by four, so new blocks can be composed there;
|
||||
per-tile flags on `tilesets/tiles_*.tsj` become `walkable`, `waterTiles`,
|
||||
`doorTiles` and the rest.
|
||||
- **Export is a diff, not a fork of the data.** The `gen1-mod-export`
|
||||
extension (shipped in `tiled_gen1recomp`) writes either one map file or a whole
|
||||
loadable mod folder. An edited vanilla map diffs against the imported data
|
||||
and emits `mod.content.maps:patch` carrying *only* the fields that moved, so
|
||||
a mod covers the parts it changes and leaves the rest to the base game; a
|
||||
new map gets `:register` at an index of 1000 or above. An unchanged map
|
||||
exports nothing at all. Exports pass `tools/modkit.py validate` and `lint`.
|
||||
- **Or the whole record, on request.** Ticking `exactExport` on a map switches
|
||||
it to `mod.content.maps:override`, pinning the map to exactly what the
|
||||
editor shows. It is off by default because an override wins outright over
|
||||
any other mod patching that map, where a patch composes.
|
||||
|
||||
No ROM-derived art travels into an exported mod: a tileset still drawing on
|
||||
the player's own imported sheet references that path rather than shipping the
|
||||
pixels, and only a sheet the author supplied is copied in.
|
||||
@@ -26,7 +26,7 @@ JSON parsing, and sha256 verification run on a background `love.thread`
|
||||
|
||||
## Version.lua fields
|
||||
|
||||
`src/core/Version.lua` carries three fields the updater reads directly (the
|
||||
`src/core/Version.lua` carries four fields the updater reads directly (the
|
||||
existing `modApi`, `linkProtocol`, `saveFormat`, and `cache` fields are
|
||||
untouched):
|
||||
|
||||
@@ -37,6 +37,11 @@ untouched):
|
||||
as a valid payload to chainload).
|
||||
- `shell` - the native-shell contract this build's fused executable
|
||||
implements.
|
||||
- `payloadHost` - the native host family an in-place payload targets. Ordinary
|
||||
LÖVE packages use `"love"`. A specialized native package uses a distinct,
|
||||
stable identifier and accepts only payloads carrying that same identifier.
|
||||
A missing field defaults to `"love"`, preserving compatibility with payloads
|
||||
released before this field existed.
|
||||
- `minShell` - the lowest shell contract required to *run* this payload.
|
||||
|
||||
Bump `minShell` only when a payload needs something the currently-shipped
|
||||
@@ -49,6 +54,12 @@ rather than deleting it, in case a future shell upgrade can run it, and
|
||||
installer instead. Do not bump `minShell` for an ordinary Lua/data release;
|
||||
that is exactly the case the updater exists to avoid a reinstall for.
|
||||
|
||||
Change `payloadHost` only when the packaged Lua depends on a different native
|
||||
host family. This is separate from `minShell`: the host name answers *which*
|
||||
native integration the payload targets, while the shell number answers *which
|
||||
revision* of that integration it requires. A mismatched-host payload is never
|
||||
mounted or deleted as stale; the launcher directs the player to a full package.
|
||||
|
||||
## Release assets
|
||||
|
||||
Each tagged release `vX.Y.Z` carries the existing per-platform archives
|
||||
@@ -109,17 +120,26 @@ bundled game, in that case.
|
||||
|
||||
## Known limitations
|
||||
|
||||
|
||||
- **`love.run` persists across handoff.** By the time `chainload` runs, the
|
||||
bundled `love.run` has already returned its stepper to LOVE; redefining the
|
||||
global `love.run` from the payload's `main.lua` does not affect the loop
|
||||
already driving the frame. A payload that must change `love.run` itself
|
||||
needs a `minShell` bump so an older shell refuses to chainload it rather
|
||||
than running with half its intended behavior.
|
||||
- **Android has no in-app download transport yet.** `check_worker.lua`
|
||||
shells out to curl for both the release check and the download; curl is
|
||||
absent on Android, so `Check` degrades to `status = "error"` there (the
|
||||
launcher UI hides on that status) and the player is directed to the
|
||||
releases page via `Check.releaseUrl()` instead.
|
||||
- **Android and iOS use the native download bridge, not curl.** Neither
|
||||
platform ships curl, so the old `check_worker.lua` path (shell out to curl)
|
||||
always landed on `error` and the launcher chip's "Check for updates" tap
|
||||
was a no-op. The worker now talks through `HostShell`, the same transport
|
||||
as the mod catalog: curl on desktop, `love.system.httpDownload` on mobile.
|
||||
On Android that is the GameActivity JNI/`HttpsURLConnection` bridge; on
|
||||
iOS it is `GRPickerBridge.httpDownload` (`URLSession`). A fused sideloaded
|
||||
APK or IPA can therefore check GitHub and fetch the `.love` payload
|
||||
in-app. If neither transport exists, the worker reports `needs_full` and
|
||||
the launcher chip opens `Check.releaseUrl()`. Native package-only changes
|
||||
still need a full reinstall (`minShell` / `payloadHost` gate →
|
||||
`needs_full`). Applying a downloaded payload on Android relaunches via
|
||||
`love.system.restartApp`; iOS still uses in-process `quit("restart")`.
|
||||
- **Dev/source runs never self-update.** `Boot.run` returns immediately when
|
||||
`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
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env bash
|
||||
# After first boot of a compatible Linux ARM handheld (or when PortMaster is installed), reinsert the
|
||||
# SD card and run this to install gen1recomp-sbc + Red/Blue ROMs into Roms/PORTS.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||
STAGE="$ROOT/.bazinga/work/linux-arm-sbc-install"
|
||||
DECPREP="${DECPREP:-$ROOT/../decprep}"
|
||||
ZIP="$ROOT/dist/linux-arm-sbc/gen1recomp-sbc-portmaster.zip"
|
||||
|
||||
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||
fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
# Find a mounted handheld userdata volume with a ROMs or Apps directory.
|
||||
find_roms_root() {
|
||||
local v candidate
|
||||
for v in /Volumes/*; do
|
||||
[ -d "$v" ] || continue
|
||||
# Prefer a volume that already has Roms/ or Apps/
|
||||
if [ -d "$v/Roms" ] || [ -d "$v/roms" ] || [ -d "$v/PORTS" ] || [ -d "$v/ports" ] || [ -d "$v/Apps" ]; then
|
||||
echo "$v"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
# Fallback: common removable-volume labels
|
||||
for v in /Volumes/SDCARD /Volumes/sdcard /Volumes/NO\ NAME /Volumes/ROMS; do
|
||||
if [ -d "$v" ]; then
|
||||
echo "$v"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
say "looking for handheld SD volume"
|
||||
ROMS_ROOT="$(find_roms_root)" || fail "no SD volume mounted. boot the handheld once, power it off, reinsert the SD, then rerun."
|
||||
|
||||
say "using: $ROMS_ROOT"
|
||||
# Resolve the device PortMaster ports directory
|
||||
if [ -d "$ROMS_ROOT/Roms/PORTS" ]; then
|
||||
PORTS="$ROMS_ROOT/Roms/PORTS"
|
||||
elif [ -d "$ROMS_ROOT/roms/PORTS" ]; then
|
||||
PORTS="$ROMS_ROOT/roms/PORTS"
|
||||
elif [ -d "$ROMS_ROOT/Roms/ports" ]; then
|
||||
PORTS="$ROMS_ROOT/Roms/ports"
|
||||
elif [ -d "$ROMS_ROOT/PORTS" ]; then
|
||||
PORTS="$ROMS_ROOT/PORTS"
|
||||
else
|
||||
mkdir -p "$ROMS_ROOT/Roms/PORTS"
|
||||
PORTS="$ROMS_ROOT/Roms/PORTS"
|
||||
fi
|
||||
say "PORTS: $PORTS"
|
||||
|
||||
# Refresh staged payload
|
||||
mkdir -p "$STAGE/PORTS"
|
||||
if [ -f "$ZIP" ]; then
|
||||
rm -rf "$STAGE/PORTS/gen1recomp-sbc.sh" "$STAGE/PORTS/gen1recomp-sbc" "$STAGE/PORTS/port.json" \
|
||||
"$STAGE/PORTS/gameinfo.xml" "$STAGE/PORTS/README.md"
|
||||
unzip -q -o "$ZIP" -d "$STAGE/PORTS"
|
||||
else
|
||||
fail "missing $ZIP — run ./build-linux-arm-sbc.sh first"
|
||||
fi
|
||||
|
||||
# Ensure ROMs are in lovegame (Choose ROM scans this folder on minimal images)
|
||||
[ -f "$DECPREP/Pokemon - Red Version.gb" ] || fail "missing Red ROM in $DECPREP"
|
||||
[ -f "$DECPREP/Pokemon - Blue Version.gb" ] || fail "missing Blue ROM in $DECPREP"
|
||||
cp -f "$DECPREP/Pokemon - Red Version.gb" "$STAGE/PORTS/gen1recomp-sbc/lovegame/"
|
||||
cp -f "$DECPREP/Pokemon - Blue Version.gb" "$STAGE/PORTS/gen1recomp-sbc/lovegame/"
|
||||
|
||||
say "copying gen1recomp port"
|
||||
rm -rf "$PORTS/gen1recomp-sbc" "$PORTS/gen1recomp-sbc.sh"
|
||||
cp -R "$STAGE/PORTS/gen1recomp-sbc" "$PORTS/"
|
||||
cp -f "$STAGE/PORTS/gen1recomp-sbc.sh" "$PORTS/"
|
||||
cp -f "$STAGE/PORTS/port.json" "$PORTS/"
|
||||
cp -f "$STAGE/PORTS/README.md" "$PORTS/"
|
||||
chmod +x "$PORTS/gen1recomp-sbc.sh" "$PORTS/gen1recomp-sbc/bin/love.aarch64"
|
||||
|
||||
# Also drop carts in the stock GB folder for the emulator library
|
||||
GB_DIR=""
|
||||
for candidate in "$ROMS_ROOT/Roms/GB" "$ROMS_ROOT/roms/GB" "$ROMS_ROOT/Roms/gb"; do
|
||||
if [ -d "$candidate" ]; then GB_DIR="$candidate"; break; fi
|
||||
done
|
||||
if [ -n "$GB_DIR" ]; then
|
||||
say "copying .gb into $GB_DIR"
|
||||
cp -f "$DECPREP/Pokemon - Red Version.gb" "$GB_DIR/"
|
||||
cp -f "$DECPREP/Pokemon - Blue Version.gb" "$GB_DIR/"
|
||||
fi
|
||||
|
||||
sync
|
||||
say "installed:"
|
||||
ls -lh "$PORTS/gen1recomp-sbc.sh"
|
||||
ls -lh "$PORTS/gen1recomp-sbc/lovegame/"*.gb
|
||||
say "eject the SD, insert it in the handheld, open Ports → gen1recomp-sbc, Choose ROM."
|
||||
@@ -8,12 +8,19 @@
|
||||
-- opens the editor on that slot's file, and restores the launcher when
|
||||
-- the editor's Close button is pressed (openEditor / closeEditor below)
|
||||
|
||||
if POKEPORT_DISPLAY_COMPANION then
|
||||
return require("src.render.DesktopCompanion").install(
|
||||
POKEPORT_DISPLAY_COMPANION)
|
||||
end
|
||||
|
||||
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")
|
||||
local HostDisplay = require("src.core.HostDisplay")
|
||||
local GameViewport = require("src.render.GameViewport")
|
||||
|
||||
-- Lua errors: persist a redacted trace in the save dir and surface a hint.
|
||||
do
|
||||
@@ -29,7 +36,7 @@ do
|
||||
end
|
||||
end
|
||||
|
||||
local Game, EditorApp, Importer, TouchEditor
|
||||
local Game, EditorApp, Importer, TouchEditor, Studio
|
||||
|
||||
-- #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).
|
||||
@@ -122,14 +129,15 @@ end
|
||||
-- mounted before the editor's Data:load runs, or a Blue save would be edited
|
||||
-- against Red's species/item tables.
|
||||
local function openEditor(version, slotId)
|
||||
local function refuse(text)
|
||||
if not Importer then return end
|
||||
Importer.saveNotice = Importer.saveNotice or {}
|
||||
Importer.saveNotice[version] = { ok = false, text = text }
|
||||
end
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local path = SaveData.slotDiskPath(version, slotId)
|
||||
if not path then
|
||||
if Importer then
|
||||
Importer.saveNotice = Importer.saveNotice or {}
|
||||
Importer.saveNotice[version] =
|
||||
{ ok = false, text = "Could not resolve that save slot on disk." }
|
||||
end
|
||||
refuse("Could not resolve that save slot on disk.")
|
||||
return
|
||||
end
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
@@ -146,9 +154,44 @@ local function openEditor(version, slotId)
|
||||
editorMode = true
|
||||
resizeForEditor()
|
||||
addEditorRequirePath()
|
||||
EditorApp = require("App")
|
||||
EditorApp.load(path, { version = version, slotId = slotId, embedded = true,
|
||||
onClose = function() closeEditor() end })
|
||||
local okReq, appOrErr = pcall(require, "App")
|
||||
if not okReq then
|
||||
editorMode = false
|
||||
if version then
|
||||
require("src.import.CacheFs").unmountVersion(version)
|
||||
end
|
||||
restoreWindow()
|
||||
Importer = editorHost
|
||||
editorHost = nil
|
||||
editorVersion = nil
|
||||
if Importer and Importer.resumeAfterOverlay then
|
||||
Importer:resumeAfterOverlay()
|
||||
end
|
||||
refuse("Could not open the save editor (" .. tostring(appOrErr) .. ").")
|
||||
return
|
||||
end
|
||||
EditorApp = appOrErr
|
||||
local okLoad, loadErr = pcall(EditorApp.load, path, {
|
||||
version = version, slotId = slotId, embedded = true,
|
||||
onClose = function() closeEditor() end,
|
||||
})
|
||||
if not okLoad then
|
||||
editorMode = false
|
||||
if EditorApp.unload then pcall(EditorApp.unload) end
|
||||
EditorApp = nil
|
||||
if version then
|
||||
require("src.import.CacheFs").unmountVersion(version)
|
||||
require("src.core.Data"):unloadGenerated()
|
||||
end
|
||||
restoreWindow()
|
||||
Importer = editorHost
|
||||
editorHost = nil
|
||||
editorVersion = nil
|
||||
if Importer and Importer.resumeAfterOverlay then
|
||||
Importer:resumeAfterOverlay()
|
||||
end
|
||||
refuse("Could not open the save editor (" .. tostring(loadErr) .. ").")
|
||||
end
|
||||
end
|
||||
|
||||
-- Back to the launcher. Everything the editor mounted or cached has to come
|
||||
@@ -164,6 +207,11 @@ function closeEditor()
|
||||
require("src.import.CacheFs").unmountVersion(version)
|
||||
require("src.core.Data"):unloadGenerated()
|
||||
end
|
||||
for k in pairs(package.loaded) do
|
||||
if type(k) == "string" and (k:find("save%-editor") or k == "App" or k == "Kit" or k == "State" or k == "Catalog" or k == "SaveIO" or k == "Ops" or k == "MonOps" or k == "ItemOps" or k == "PadInput" or k == "Gen" or k == "Theme") then
|
||||
package.loaded[k] = nil
|
||||
end
|
||||
end
|
||||
editorVersion = nil
|
||||
restoreWindow()
|
||||
Importer = editorHost
|
||||
@@ -182,14 +230,19 @@ end
|
||||
local touchEditorHost
|
||||
local closeTouchControlsEditor -- forward declaration
|
||||
|
||||
local function openTouchControlsEditor()
|
||||
-- `version` is the launcher tab the gear was opened on, and it decides which
|
||||
-- option block the layout lands in (src/ui/TouchControlsEditor.lua persist).
|
||||
local function openTouchControlsEditor(version)
|
||||
touchEditorHost = Importer
|
||||
if Importer and Importer.prepareOverlayHandoff then
|
||||
Importer:prepareOverlayHandoff()
|
||||
end
|
||||
Importer = nil
|
||||
TouchEditor = require("src.ui.TouchControlsEditor")
|
||||
TouchEditor.load({ onClose = function() closeTouchControlsEditor() end })
|
||||
TouchEditor.load({
|
||||
version = version,
|
||||
onClose = function() closeTouchControlsEditor() end,
|
||||
})
|
||||
end
|
||||
|
||||
function closeTouchControlsEditor()
|
||||
@@ -202,16 +255,53 @@ function closeTouchControlsEditor()
|
||||
end
|
||||
end
|
||||
|
||||
local function bootGame(version)
|
||||
-- The launcher hands us the chosen game (Red / Blue / Yellow); scripted and
|
||||
-- headless runs fall back to POKEPORT_VERSION, then Red. Set the active
|
||||
-- version and overlay its extracted cache BEFORE anything requires generated
|
||||
-- data, so data/generated + assets/generated resolve to that version's files.
|
||||
-- ------------------------------------------------------------ skin studio
|
||||
local studioHost
|
||||
local closeSkinStudio
|
||||
local bootGame
|
||||
|
||||
local function openSkinStudio(version, skinId)
|
||||
local SkinStudio = require("src.ui.SkinStudio")
|
||||
if not SkinStudio.available_desktop() then return end
|
||||
studioHost = Importer
|
||||
if Importer and Importer.prepareOverlayHandoff then
|
||||
Importer:prepareOverlayHandoff()
|
||||
end
|
||||
Importer = nil
|
||||
Studio = SkinStudio
|
||||
Studio.load({
|
||||
version = version,
|
||||
skinId = skinId,
|
||||
onClose = function() closeSkinStudio() end,
|
||||
onPlay = function(v)
|
||||
closeSkinStudio()
|
||||
Importer = nil
|
||||
bootGame(v or version)
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
function closeSkinStudio()
|
||||
if Studio and Studio.unload then Studio.unload() end
|
||||
Studio = nil
|
||||
Importer = studioHost
|
||||
studioHost = nil
|
||||
if Importer and Importer.resumeAfterOverlay then
|
||||
Importer:resumeAfterOverlay()
|
||||
end
|
||||
end
|
||||
|
||||
function bootGame(version)
|
||||
-- The launcher hands us the chosen game (Red / Blue / Yellow / Gold);
|
||||
-- scripted and headless runs fall back to POKEPORT_VERSION, then Red.
|
||||
-- Set the active version and overlay its extracted cache BEFORE anything
|
||||
-- requires generated 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")
|
||||
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/).
|
||||
-- (Blue/Yellow/Gold caches live under blue/ / yellow/ / gold/).
|
||||
CacheFs.prefix = GameVersion.cachePrefix()
|
||||
CacheFs.mountVersion(GameVersion.get())
|
||||
-- NX: always write nx-asset-probe.log so Yellow/Blue art failures are
|
||||
@@ -224,10 +314,19 @@ local function bootGame(version)
|
||||
love.window.setTitle(Version.title(
|
||||
GameVersion.info().displayName .. " (Gen 1 Recompilation Project)"))
|
||||
end
|
||||
Game = require("src.core.Game")
|
||||
Game:load()
|
||||
if os.getenv("POKEPORT_AUTOPILOT") then
|
||||
autopilot = require("tests.autopilot")
|
||||
-- Gold: Gen 1 Game:load cannot consume a Gen 2 cache -- different generated
|
||||
-- tables, save shape and screen registry -- so Gold boots its own service
|
||||
-- owner, which mounts src/world/gen2 (walk / warps / connections) and the
|
||||
-- Gen 2 screens instead of src/core/Game.lua's Gen 1 wiring.
|
||||
if GameVersion.isGold() then
|
||||
Game = require("src.core.Game2").new()
|
||||
Game:load()
|
||||
else
|
||||
Game = require("src.core.Game")
|
||||
Game:load()
|
||||
if os.getenv("POKEPORT_AUTOPILOT") then
|
||||
autopilot = require("tests.autopilot")
|
||||
end
|
||||
end
|
||||
local driverPath = os.getenv("POKEPORT_DRIVER")
|
||||
if driverPath then
|
||||
@@ -246,6 +345,11 @@ function love.load(args)
|
||||
-- of each flashing their own cmd.exe window (#606). No-op elsewhere.
|
||||
require("src.core.HostShell").hideHostConsole()
|
||||
|
||||
-- Hang gen1tls on love.system before mods boot. Android already has tls*
|
||||
-- from JNI; this is the desktop half. No DLL / no FFI is fine -- ws://
|
||||
-- rooms still work, wss:// just won't.
|
||||
pcall(function() require("src.net.Gen1Tls").install() end)
|
||||
|
||||
-- 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.
|
||||
@@ -300,9 +404,12 @@ function love.load(args)
|
||||
local RomImporter = require("src.import.RomImporter")
|
||||
local forceImport = os.getenv("POKEPORT_FORCE_IMPORT") == "1"
|
||||
local importPath = os.getenv("POKEPORT_IMPORT_ROM")
|
||||
-- Scripted / headless runs pick their game from POKEPORT_VERSION (default
|
||||
-- Red); the launcher's per-column choice does not apply to them.
|
||||
local scriptedVersion = os.getenv("POKEPORT_VERSION") or "red"
|
||||
-- Scripted / headless runs pick their game from POKEPORT_VERSION, then
|
||||
-- POKEPORT_GAME / --game= (LaunchOptions), then Red. Drivers for Gold
|
||||
-- must honor POKEPORT_GAME=gold the same way a desktop shortcut does.
|
||||
local scriptedVersion = os.getenv("POKEPORT_VERSION")
|
||||
or LaunchOptions.resolve(arg)
|
||||
or "red"
|
||||
local ready = RomImporter.isReady(scriptedVersion)
|
||||
-- Scripted / headless runs have to reach the game with no human pressing
|
||||
-- Play: an autopilot, a frame driver, an import-only build step, or an
|
||||
@@ -343,7 +450,7 @@ function love.load(args)
|
||||
end
|
||||
|
||||
-- LAUNCH OPTIONS: skip the launcher and boot a game directly.
|
||||
-- --game red|blue|yellow (or POKEPORT_GAME / POKEPORT_LAUNCH)
|
||||
-- --game red|blue|yellow|gold (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
|
||||
@@ -371,11 +478,12 @@ function love.load(args)
|
||||
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
|
||||
-- SHA-1 (GameVersion.forSha1); pressing Play boots that game. Edit on a
|
||||
-- save row opens the bundled editor on that slot (openEditor).
|
||||
-- Interactive: the launcher always runs. Red, Blue, Yellow, and Gold 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/.gbc is routed
|
||||
-- by its SHA-1 (GameVersion.forSha1); pressing Play boots that game (Gold
|
||||
-- goes to its own service owner, src/core/Game2.lua -- docs/gold-phase1.md).
|
||||
-- Edit on a save row opens the bundled editor on that slot (openEditor).
|
||||
Importer = RomImporter.new(function(version)
|
||||
Importer = nil
|
||||
bootGame(version)
|
||||
@@ -384,16 +492,21 @@ function love.load(args)
|
||||
forceImport = forceImport,
|
||||
onEditSave = openEditor,
|
||||
onEditTouchControls = openTouchControlsEditor,
|
||||
onOpenSkinStudio = require("src.ui.SkinStudio").available_desktop()
|
||||
and openSkinStudio or nil,
|
||||
})
|
||||
end
|
||||
|
||||
function love.update(dt)
|
||||
HostDisplay.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 Studio then return Studio.update(dt) end
|
||||
if Importer then return Importer:update(dt) end
|
||||
if not Game then return end
|
||||
|
||||
-- Scripted runs (autopilot / POKEPORT_DRIVER) observe and act exactly
|
||||
-- once per Game:update, so they must keep a 1:1 relationship with the
|
||||
@@ -435,10 +548,39 @@ function love.update(dt)
|
||||
end
|
||||
|
||||
function love.draw()
|
||||
if editorMode then return EditorApp.draw() end
|
||||
if TouchEditor then return TouchEditor.draw() end
|
||||
if Importer then return Importer:draw() end
|
||||
if editorMode then
|
||||
GameViewport.reset()
|
||||
HostDisplay.beginFrame("editor", EditorApp)
|
||||
local result = EditorApp.draw()
|
||||
HostDisplay.endFrame("editor", EditorApp)
|
||||
return result
|
||||
end
|
||||
if TouchEditor then
|
||||
GameViewport.reset()
|
||||
HostDisplay.beginFrame("touch_editor", TouchEditor)
|
||||
local result = TouchEditor.draw()
|
||||
HostDisplay.endFrame("touch_editor", TouchEditor)
|
||||
return result
|
||||
end
|
||||
if Studio then
|
||||
HostDisplay.beginFrame("skin_studio", Studio)
|
||||
local result = Studio.draw()
|
||||
HostDisplay.endFrame("skin_studio", Studio)
|
||||
return result
|
||||
end
|
||||
if Importer then
|
||||
GameViewport.reset()
|
||||
HostDisplay.beginFrame("launcher", Importer)
|
||||
local result = Importer:draw()
|
||||
HostDisplay.endFrame("launcher", Importer)
|
||||
return result
|
||||
end
|
||||
if not Game then
|
||||
GameViewport.reset()
|
||||
return
|
||||
end
|
||||
|
||||
HostDisplay.beginFrame("game", Game)
|
||||
Game:draw()
|
||||
-- frame capture requested by a driver
|
||||
if Game.capturePath then
|
||||
@@ -453,18 +595,22 @@ function love.draw()
|
||||
end
|
||||
end)
|
||||
end
|
||||
HostDisplay.endFrame("game", Game)
|
||||
end
|
||||
|
||||
function love.keypressed(key, scancode, isrepeat)
|
||||
if editorMode then return EditorApp.keypressed(key) end
|
||||
if TouchEditor then return TouchEditor.keypressed(key) end
|
||||
if Studio then return Studio.keypressed(key) end
|
||||
if Importer then return Importer:keypressed(key) end
|
||||
if not Game then return end
|
||||
Game:keypressed(key)
|
||||
end
|
||||
|
||||
function love.keyreleased(key)
|
||||
if editorMode or TouchEditor then return end
|
||||
if editorMode or TouchEditor or Studio then return end
|
||||
if Importer then return end
|
||||
if not Game then return end
|
||||
Game:keyreleased(key)
|
||||
end
|
||||
|
||||
@@ -482,7 +628,9 @@ function love.gamepadpressed(joystick, button)
|
||||
end
|
||||
return
|
||||
end
|
||||
if Studio then return end
|
||||
if Importer then return Importer:gamepadpressed(joystick, button) end
|
||||
if not Game then return end
|
||||
Game:gamepadpressed(joystick, button)
|
||||
end
|
||||
|
||||
@@ -500,7 +648,9 @@ function love.gamepadreleased(joystick, button)
|
||||
end
|
||||
return
|
||||
end
|
||||
if Studio then return end
|
||||
if Importer then return Importer:gamepadreleased(joystick, button) end
|
||||
if not Game then return end
|
||||
Game:gamepadreleased(joystick, button)
|
||||
end
|
||||
|
||||
@@ -518,7 +668,9 @@ function love.gamepadaxis(joystick, axis, value)
|
||||
end
|
||||
return
|
||||
end
|
||||
if Studio then return end
|
||||
if Importer then return Importer:gamepadaxis(joystick, axis, value) end
|
||||
if not Game then return end
|
||||
Game:gamepadaxis(joystick, axis, value)
|
||||
end
|
||||
|
||||
@@ -536,7 +688,9 @@ function love.joystickpressed(joystick, button)
|
||||
end
|
||||
return
|
||||
end
|
||||
if Studio then return end
|
||||
if Importer then return Importer:joystickpressed(joystick, button) end
|
||||
if not Game then return end
|
||||
Game:joystickpressed(joystick, button)
|
||||
end
|
||||
|
||||
@@ -554,7 +708,9 @@ function love.joystickreleased(joystick, button)
|
||||
end
|
||||
return
|
||||
end
|
||||
if Studio then return end
|
||||
if Importer then return Importer:joystickreleased(joystick, button) end
|
||||
if not Game then return end
|
||||
Game:joystickreleased(joystick, button)
|
||||
end
|
||||
|
||||
@@ -572,7 +728,9 @@ function love.joystickaxis(joystick, axis, value)
|
||||
end
|
||||
return
|
||||
end
|
||||
if Studio then return end
|
||||
if Importer then return Importer:joystickaxis(joystick, axis, value) end
|
||||
if not Game then return end
|
||||
Game:joystickaxis(joystick, axis, value)
|
||||
end
|
||||
|
||||
@@ -590,21 +748,25 @@ function love.joystickhat(joystick, hat, direction)
|
||||
end
|
||||
return
|
||||
end
|
||||
if Studio then return end
|
||||
if Importer then return Importer:joystickhat(joystick, hat, direction) end
|
||||
if not Game then return end
|
||||
Game:joystickhat(joystick, hat, direction)
|
||||
end
|
||||
|
||||
function love.joystickadded(joystick)
|
||||
SwitchDiagnostics.onJoystickEvent("joystickadded", joystick)
|
||||
if editorMode or TouchEditor then return end
|
||||
if editorMode or TouchEditor or Studio then return end
|
||||
if Importer then return end
|
||||
if not Game then return end
|
||||
Game:joystickadded(joystick)
|
||||
end
|
||||
|
||||
function love.joystickremoved(joystick)
|
||||
SwitchDiagnostics.onJoystickEvent("joystickremoved", joystick)
|
||||
if editorMode or TouchEditor then return end
|
||||
if editorMode or TouchEditor or Studio then return end
|
||||
if Importer then return end
|
||||
if not Game then return end
|
||||
Game:joystickremoved(joystick)
|
||||
end
|
||||
|
||||
@@ -613,26 +775,36 @@ end
|
||||
-- unfocused, so reset input on either transition rather than trust it.
|
||||
function love.focus(f)
|
||||
if editorMode or TouchEditor then return end
|
||||
if Studio then
|
||||
if Studio.focus then Studio.focus(f) end
|
||||
return
|
||||
end
|
||||
if Importer then
|
||||
require("src.core.Input"):reset()
|
||||
if Importer.focus then Importer:focus(f) end
|
||||
return
|
||||
end
|
||||
if not Game then return end
|
||||
Game:focus(f)
|
||||
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 Studio then
|
||||
if Studio.visible then Studio.visible(v) end
|
||||
return
|
||||
end
|
||||
if Importer then
|
||||
require("src.core.Input"):reset()
|
||||
return
|
||||
end
|
||||
if not Game then return end
|
||||
Game:visible(v)
|
||||
end
|
||||
|
||||
function love.lowmemory()
|
||||
if editorMode or TouchEditor or Importer then return end
|
||||
if editorMode or TouchEditor or Studio or Importer then return end
|
||||
if Game then Game:onResume() end
|
||||
end
|
||||
|
||||
@@ -653,12 +825,14 @@ function love.touchpressed(id, x, y, dx, dy, pressure)
|
||||
if love.system.getOS() == "iOS" then return end
|
||||
return TouchEditor.touchpressed(id, x, y)
|
||||
end
|
||||
if Studio then return end
|
||||
if Importer then
|
||||
-- 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
|
||||
if not Game then return end
|
||||
Game:touchpressed(id, x, y, dx, dy, pressure)
|
||||
end
|
||||
|
||||
@@ -668,9 +842,11 @@ 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 Studio then return end
|
||||
if Importer then
|
||||
return Importer:touchmoved(id, x, y, dx, dy, pressure)
|
||||
end
|
||||
if not Game then return end
|
||||
Game:touchmoved(id, x, y, dx, dy, pressure)
|
||||
end
|
||||
|
||||
@@ -680,9 +856,11 @@ 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 Studio then return end
|
||||
if Importer then
|
||||
return Importer:touchreleased(id, x, y, dx, dy, pressure)
|
||||
end
|
||||
if not Game then return end
|
||||
Game:touchreleased(id, x, y, dx, dy, pressure)
|
||||
end
|
||||
|
||||
@@ -692,7 +870,9 @@ function love.wheelmoved(x, y)
|
||||
return
|
||||
end
|
||||
if TouchEditor then return end
|
||||
if Studio then return Studio.wheelmoved(x, y) end
|
||||
if Importer then return end
|
||||
if not Game then return end
|
||||
Game:wheelmoved(x, y)
|
||||
end
|
||||
|
||||
@@ -729,6 +909,7 @@ function love.mousepressed(x, y, button, istouch)
|
||||
if love.system.getOS() == "Android" then return end
|
||||
return TouchEditor.mousepressed(x, y, button)
|
||||
end
|
||||
if Studio then return Studio.mousepressed(x, y, button) end
|
||||
if Importer then
|
||||
-- love.touchpressed already forwards the primary touch into FlexLove for
|
||||
-- scroll. LÖVE ALSO synthesizes a mouse press for that same touch; if both
|
||||
@@ -763,6 +944,7 @@ function love.mousereleased(x, y, button, istouch)
|
||||
if love.system.getOS() == "Android" then return end
|
||||
return TouchEditor.mousereleased(x, y, button)
|
||||
end
|
||||
if Studio then return Studio.mousereleased(x, y, button) end
|
||||
if Importer then return end
|
||||
if editorMode and EditorApp.mousereleased then
|
||||
return EditorApp.mousereleased(x, y, button)
|
||||
@@ -780,6 +962,7 @@ function love.mousemoved(x, y, dx, dy, istouch)
|
||||
if love.system.getOS() == "Android" then return end
|
||||
return TouchEditor.mousemoved(x, y)
|
||||
end
|
||||
if Studio then return Studio.mousemoved(x, y) end
|
||||
if editorMode or Importer then return end
|
||||
if mouseTouch then
|
||||
if Game and love.mouse.isDown(1) then Game:touchmoved("mouse", x, y) end
|
||||
@@ -790,6 +973,7 @@ end
|
||||
|
||||
function love.textinput(text)
|
||||
if TouchEditor then return end
|
||||
if Studio then return Studio.textinput(text) end
|
||||
if Importer then return Importer:textinput(text) end
|
||||
if editorMode and EditorApp.textinput then
|
||||
return EditorApp.textinput(text)
|
||||
@@ -867,6 +1051,7 @@ function love.filedropped(file)
|
||||
if editorMode and EditorApp and EditorApp.filedropped then
|
||||
return EditorApp.filedropped(file)
|
||||
end
|
||||
if Studio then return Studio.filedropped(file) end
|
||||
if Importer then Importer:filedropped(file) end
|
||||
end
|
||||
|
||||
|
||||
@@ -31,6 +31,9 @@
|
||||
android:allowBackup="true"
|
||||
android:icon="@drawable/love"
|
||||
android:label="${NAME}" >
|
||||
<meta-data
|
||||
android:name="android.allow_multiple_resumed_activities"
|
||||
android:value="true" />
|
||||
<activity
|
||||
android:name="org.love2d.android.GameActivity"
|
||||
android:exported="true"
|
||||
@@ -49,5 +52,15 @@
|
||||
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name="org.love2d.android.GameActivity$SecondaryActivity"
|
||||
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="false"
|
||||
android:launchMode="singleTask"
|
||||
android:resizeableActivity="false"
|
||||
android:screenOrientation="${ORIENTATION}"
|
||||
android:taskAffinity="${applicationId}.secondary"
|
||||
android:theme="@android:style/Theme.NoTitleBar.Fullscreen" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
#ifdef LOVE_ANDROID
|
||||
|
||||
#include <cerrno>
|
||||
#include <cstring>
|
||||
#include <unordered_map>
|
||||
|
||||
#include <SDL.h>
|
||||
@@ -324,6 +325,231 @@ bool httpDownload(const char *url, const char *destPath, const char *userAgent,
|
||||
return result;
|
||||
}
|
||||
|
||||
bool httpPost(const char *url, const char *body, int bodyLen, const char *contentType, const char *userAgent)
|
||||
{
|
||||
if (url == nullptr || body == nullptr || bodyLen < 0)
|
||||
return false;
|
||||
|
||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||
// Same resolution rule as httpDownload: the activity's own class via
|
||||
// SDL_AndroidGetActivity, never FindClass -- this bridge is called off
|
||||
// the main thread (love.thread workers), whose class loader cannot see
|
||||
// app classes.
|
||||
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).
|
||||
jmethodID method = env->GetStaticMethodID(activity, "httpPost",
|
||||
"(Ljava/lang/String;[BLjava/lang/String;Ljava/lang/String;)Z");
|
||||
if (method == nullptr)
|
||||
{
|
||||
env->ExceptionClear();
|
||||
env->DeleteLocalRef(activity);
|
||||
return false;
|
||||
}
|
||||
|
||||
jstring jurl = env->NewStringUTF(url);
|
||||
// raw bytes across the bridge: a log ring can carry arbitrary UTF-8,
|
||||
// and a jstring would run it through modified UTF-8
|
||||
jbyteArray jbody = env->NewByteArray(bodyLen);
|
||||
if (jbody != nullptr)
|
||||
env->SetByteArrayRegion(jbody, 0, bodyLen, (const jbyte*) body);
|
||||
jstring jct = contentType != nullptr ? env->NewStringUTF(contentType) : nullptr;
|
||||
jstring jua = userAgent != nullptr ? env->NewStringUTF(userAgent) : nullptr;
|
||||
|
||||
jboolean result = env->CallStaticBooleanMethod(activity, method, jurl, jbody, jct, jua);
|
||||
|
||||
env->DeleteLocalRef(jurl);
|
||||
if (jbody != nullptr)
|
||||
env->DeleteLocalRef(jbody);
|
||||
if (jct != nullptr)
|
||||
env->DeleteLocalRef(jct);
|
||||
if (jua != nullptr)
|
||||
env->DeleteLocalRef(jua);
|
||||
env->DeleteLocalRef(activity);
|
||||
return result;
|
||||
}
|
||||
|
||||
/*
|
||||
* TLS sockets. Same resolution rule as httpDownload above -- the activity's
|
||||
* own class, never FindClass -- and the same tolerance for an old APK: a
|
||||
* missing method answers like a platform without TLS instead of aborting.
|
||||
*/
|
||||
static jclass tlsActivityClass(JNIEnv *env)
|
||||
{
|
||||
jobject activityObj = (jobject) SDL_AndroidGetActivity();
|
||||
if (activityObj == nullptr)
|
||||
return nullptr;
|
||||
jclass activity = env->GetObjectClass(activityObj);
|
||||
env->DeleteLocalRef(activityObj);
|
||||
return activity;
|
||||
}
|
||||
|
||||
int tlsOpen(const char *host, int port)
|
||||
{
|
||||
if (host == nullptr)
|
||||
return -1;
|
||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||
jclass activity = tlsActivityClass(env);
|
||||
if (activity == nullptr)
|
||||
return -1;
|
||||
|
||||
jmethodID method = env->GetStaticMethodID(activity, "tlsOpen", "(Ljava/lang/String;I)I");
|
||||
if (method == nullptr)
|
||||
{
|
||||
env->ExceptionClear();
|
||||
env->DeleteLocalRef(activity);
|
||||
return -1;
|
||||
}
|
||||
|
||||
jstring jhost = env->NewStringUTF(host);
|
||||
jint result = env->CallStaticIntMethod(activity, method, jhost, (jint) port);
|
||||
env->DeleteLocalRef(jhost);
|
||||
env->DeleteLocalRef(activity);
|
||||
return (int) result;
|
||||
}
|
||||
|
||||
int tlsStatus(int handle)
|
||||
{
|
||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||
jclass activity = tlsActivityClass(env);
|
||||
if (activity == nullptr)
|
||||
return -1;
|
||||
|
||||
jmethodID method = env->GetStaticMethodID(activity, "tlsStatus", "(I)I");
|
||||
if (method == nullptr)
|
||||
{
|
||||
env->ExceptionClear();
|
||||
env->DeleteLocalRef(activity);
|
||||
return -1;
|
||||
}
|
||||
|
||||
jint result = env->CallStaticIntMethod(activity, method, (jint) handle);
|
||||
env->DeleteLocalRef(activity);
|
||||
return (int) result;
|
||||
}
|
||||
|
||||
int tlsSend(int handle, const char *data, int length)
|
||||
{
|
||||
if (data == nullptr || length <= 0)
|
||||
return 0;
|
||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||
jclass activity = tlsActivityClass(env);
|
||||
if (activity == nullptr)
|
||||
return -1;
|
||||
|
||||
jmethodID method = env->GetStaticMethodID(activity, "tlsSend", "(I[B)I");
|
||||
if (method == nullptr)
|
||||
{
|
||||
env->ExceptionClear();
|
||||
env->DeleteLocalRef(activity);
|
||||
return -1;
|
||||
}
|
||||
|
||||
jbyteArray payload = env->NewByteArray((jsize) length);
|
||||
if (payload == nullptr)
|
||||
{
|
||||
env->ExceptionClear();
|
||||
env->DeleteLocalRef(activity);
|
||||
return -1;
|
||||
}
|
||||
env->SetByteArrayRegion(payload, 0, (jsize) length, (const jbyte*) data);
|
||||
|
||||
jint result = env->CallStaticIntMethod(activity, method, (jint) handle, payload);
|
||||
env->DeleteLocalRef(payload);
|
||||
env->DeleteLocalRef(activity);
|
||||
return (int) result;
|
||||
}
|
||||
|
||||
int tlsReceive(int handle, char *buf, int max)
|
||||
{
|
||||
if (buf == nullptr || max <= 0)
|
||||
return 0;
|
||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||
jclass activity = tlsActivityClass(env);
|
||||
if (activity == nullptr)
|
||||
return -1;
|
||||
|
||||
jmethodID method = env->GetStaticMethodID(activity, "tlsReceive", "(II)[B");
|
||||
if (method == nullptr)
|
||||
{
|
||||
env->ExceptionClear();
|
||||
env->DeleteLocalRef(activity);
|
||||
return -1;
|
||||
}
|
||||
|
||||
jobject result = env->CallStaticObjectMethod(activity, method, (jint) handle, (jint) max);
|
||||
env->DeleteLocalRef(activity);
|
||||
if (result == nullptr)
|
||||
return 0;
|
||||
|
||||
jbyteArray bytes = (jbyteArray) result;
|
||||
jsize length = env->GetArrayLength(bytes);
|
||||
if (length > max)
|
||||
length = max;
|
||||
env->GetByteArrayRegion(bytes, 0, length, (jbyte*) buf);
|
||||
env->DeleteLocalRef(result);
|
||||
return (int) length;
|
||||
}
|
||||
|
||||
bool tlsError(int handle, char *buf, int max)
|
||||
{
|
||||
if (buf == nullptr || max <= 0)
|
||||
return false;
|
||||
buf[0] = '\0';
|
||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||
jclass activity = tlsActivityClass(env);
|
||||
if (activity == nullptr)
|
||||
return false;
|
||||
|
||||
jmethodID method = env->GetStaticMethodID(activity, "tlsError", "(I)Ljava/lang/String;");
|
||||
if (method == nullptr)
|
||||
{
|
||||
env->ExceptionClear();
|
||||
env->DeleteLocalRef(activity);
|
||||
return false;
|
||||
}
|
||||
|
||||
jobject result = env->CallStaticObjectMethod(activity, method, (jint) handle);
|
||||
env->DeleteLocalRef(activity);
|
||||
if (result == nullptr)
|
||||
return false;
|
||||
|
||||
jstring text = (jstring) result;
|
||||
const char *utf = env->GetStringUTFChars(text, nullptr);
|
||||
if (utf != nullptr)
|
||||
{
|
||||
strncpy(buf, utf, (size_t) max - 1);
|
||||
buf[max - 1] = '\0';
|
||||
env->ReleaseStringUTFChars(text, utf);
|
||||
}
|
||||
env->DeleteLocalRef(result);
|
||||
return buf[0] != '\0';
|
||||
}
|
||||
|
||||
void tlsClose(int handle)
|
||||
{
|
||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||
jclass activity = tlsActivityClass(env);
|
||||
if (activity == nullptr)
|
||||
return;
|
||||
|
||||
jmethodID method = env->GetStaticMethodID(activity, "tlsClose", "(I)V");
|
||||
if (method == nullptr)
|
||||
{
|
||||
env->ExceptionClear();
|
||||
env->DeleteLocalRef(activity);
|
||||
return;
|
||||
}
|
||||
|
||||
env->CallStaticVoidMethod(activity, method, (jint) handle);
|
||||
env->DeleteLocalRef(activity);
|
||||
}
|
||||
|
||||
/*
|
||||
* Helper functions for the filesystem module
|
||||
*/
|
||||
@@ -1003,6 +1229,68 @@ void love_android_secondary_enable(int on)
|
||||
env->DeleteLocalRef(activity);
|
||||
}
|
||||
|
||||
extern "C" __attribute__((visibility("default")))
|
||||
void love_android_secondary_target(int target)
|
||||
{
|
||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
||||
jmethodID method = env->GetStaticMethodID(activity,
|
||||
"setSecondaryDisplayTarget", "(I)V");
|
||||
if (method)
|
||||
env->CallStaticVoidMethod(activity, method, target);
|
||||
else
|
||||
env->ExceptionClear();
|
||||
env->DeleteLocalRef(activity);
|
||||
}
|
||||
|
||||
extern "C" __attribute__((visibility("default")))
|
||||
int love_android_secondary_detected()
|
||||
{
|
||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
||||
jmethodID method = env->GetStaticMethodID(activity,
|
||||
"hasSecondaryDisplayCandidate", "()Z");
|
||||
jboolean detected = JNI_FALSE;
|
||||
if (method)
|
||||
detected = env->CallStaticBooleanMethod(activity, method);
|
||||
else
|
||||
env->ExceptionClear();
|
||||
env->DeleteLocalRef(activity);
|
||||
return detected ? 1 : 0;
|
||||
}
|
||||
|
||||
extern "C" __attribute__((visibility("default")))
|
||||
int love_android_present_secondary(const void *rgba, int width, int height,
|
||||
unsigned int background, int cover)
|
||||
{
|
||||
if (!rgba || width <= 0 || height <= 0)
|
||||
return 0;
|
||||
jlong size = (jlong) width * (jlong) height * 4;
|
||||
if (size <= 0)
|
||||
return 0;
|
||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
||||
jmethodID method = env->GetStaticMethodID(activity, "presentSecondaryFrame",
|
||||
"(Ljava/nio/ByteBuffer;IIIZ)Z");
|
||||
if (!method)
|
||||
{
|
||||
env->ExceptionClear();
|
||||
env->DeleteLocalRef(activity);
|
||||
return 0;
|
||||
}
|
||||
jobject frame = env->NewDirectByteBuffer((void *) rgba, size);
|
||||
if (!frame)
|
||||
{
|
||||
env->DeleteLocalRef(activity);
|
||||
return 0;
|
||||
}
|
||||
jboolean shown = env->CallStaticBooleanMethod(activity, method, frame,
|
||||
width, height, (jint) background, cover ? JNI_TRUE : JNI_FALSE);
|
||||
env->DeleteLocalRef(frame);
|
||||
env->DeleteLocalRef(activity);
|
||||
return shown ? 1 : 0;
|
||||
}
|
||||
|
||||
extern "C" __attribute__((visibility("default")))
|
||||
const char *love_android_poll_secondary_touch()
|
||||
{
|
||||
|
||||
@@ -98,6 +98,35 @@ bool restartApp();
|
||||
**/
|
||||
bool httpDownload(const char *url, const char *destPath, const char *userAgent, const char *accept);
|
||||
|
||||
/**
|
||||
* Blocking HTTPS POST of a raw byte body (GameActivity.httpPost). The
|
||||
* mirror of httpDownload for mod.postLog log sends, which need POST and
|
||||
* have no curl on Android. contentType / userAgent may be null. Returns
|
||||
* whether the server accepted the send (2xx).
|
||||
**/
|
||||
bool httpPost(const char *url, const char *body, int bodyLen, const char *contentType, const char *userAgent);
|
||||
|
||||
/**
|
||||
* TLS client sockets (GameActivity.tls*, implemented by TlsSocket.java).
|
||||
* LuaSocket, which is what LOVE ships, does TCP only, so wss:// is otherwise
|
||||
* unreachable -- and an Archipelago room hosted on archipelago.gg accepts a
|
||||
* plain connection only to drop it. The platform has both a TLS stack and the
|
||||
* system trust store, so this borrows them rather than vendoring mbedTLS.
|
||||
*
|
||||
* tlsOpen returns a handle immediately and connects on its own thread: poll
|
||||
* tlsStatus for 0 connecting / 1 open / 2 closed, and -1 for a handle that
|
||||
* does not exist. Bytes given to tlsSend before the handshake finishes are
|
||||
* queued rather than refused. tlsReceive fills buf and returns how much it
|
||||
* took, 0 when nothing is waiting. A closed connection keeps both its reason
|
||||
* (tlsError) and whatever arrived before it closed until tlsClose.
|
||||
**/
|
||||
int tlsOpen(const char *host, int port);
|
||||
int tlsStatus(int handle);
|
||||
int tlsSend(int handle, const char *data, int length);
|
||||
int tlsReceive(int handle, char *buf, int max);
|
||||
bool tlsError(int handle, char *buf, int max);
|
||||
void tlsClose(int handle);
|
||||
|
||||
/*
|
||||
* Helper functions for the filesystem module
|
||||
*/
|
||||
|
||||
@@ -154,6 +154,16 @@ love::image::ImageData *Canvas::newImageData(love::image::Image *module, int sli
|
||||
return module->newImageData(r.w, r.h, dataformat);
|
||||
}
|
||||
|
||||
bool Canvas::requestImageData()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
love::image::ImageData *Canvas::pollImageData(love::image::Image *)
|
||||
{
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void Canvas::draw(Graphics *gfx, Quad *q, const Matrix4 &t)
|
||||
{
|
||||
if (gfx->isCanvasActive(this))
|
||||
@@ -232,4 +242,3 @@ StringMap<Canvas::SettingType, Canvas::SETTING_MAX_ENUM> Canvas::settingTypes(Ca
|
||||
|
||||
} // graphics
|
||||
} // love
|
||||
|
||||
|
||||
@@ -81,6 +81,8 @@ public:
|
||||
int getRequestedMSAA() const;
|
||||
|
||||
virtual love::image::ImageData *newImageData(love::image::Image *module, int slice, int mipmap, const Rect &rect);
|
||||
virtual bool requestImageData();
|
||||
virtual love::image::ImageData *pollImageData(love::image::Image *module);
|
||||
virtual void generateMipmaps() = 0;
|
||||
|
||||
virtual int getMSAA() const = 0;
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
#include "Graphics.h"
|
||||
|
||||
#include <algorithm> // For min/max
|
||||
#include <cstring>
|
||||
|
||||
namespace love
|
||||
{
|
||||
@@ -197,6 +198,9 @@ Canvas::Canvas(const Settings &settings)
|
||||
, texture(0)
|
||||
, renderbuffer(0)
|
||||
, actualSamples(0)
|
||||
, readbackBuffer(0)
|
||||
, readbackFence(nullptr)
|
||||
, readbackSize(0)
|
||||
{
|
||||
format = getSizedFormat(format);
|
||||
|
||||
@@ -314,6 +318,14 @@ bool Canvas::loadVolatile()
|
||||
|
||||
void Canvas::unloadVolatile()
|
||||
{
|
||||
if (readbackFence != nullptr)
|
||||
glDeleteSync(readbackFence);
|
||||
if (readbackBuffer != 0)
|
||||
glDeleteBuffers(1, &readbackBuffer);
|
||||
readbackFence = nullptr;
|
||||
readbackBuffer = 0;
|
||||
readbackSize = 0;
|
||||
|
||||
if (fbo != 0 || renderbuffer != 0 || texture != 0)
|
||||
{
|
||||
// This is a bit ugly, but we need some way to destroy the cached FBO
|
||||
@@ -480,6 +492,68 @@ love::image::ImageData *Canvas::newImageData(love::image::Image *module, int sli
|
||||
return data;
|
||||
}
|
||||
|
||||
bool Canvas::requestImageData()
|
||||
{
|
||||
if (readbackFence != nullptr || !isReadable()
|
||||
|| !(GLAD_ES_VERSION_3_0 || GLAD_VERSION_3_2)
|
||||
|| texType != TEXTURE_2D
|
||||
|| (format != PIXELFORMAT_RGBA8 && format != PIXELFORMAT_sRGBA8)
|
||||
|| actualSamples > 0)
|
||||
return false;
|
||||
|
||||
auto gfx = Module::getInstance<Graphics>(Module::M_GRAPHICS);
|
||||
if (gfx != nullptr && gfx->isCanvasActive(this))
|
||||
throw love::Exception("Canvas:requestImageData cannot be called while that Canvas is active.");
|
||||
|
||||
const size_t size = (size_t) pixelWidth * (size_t) pixelHeight * 4;
|
||||
if (readbackBuffer == 0)
|
||||
glGenBuffers(1, &readbackBuffer);
|
||||
|
||||
glBindBuffer(GL_PIXEL_PACK_BUFFER, readbackBuffer);
|
||||
if (readbackSize != size)
|
||||
{
|
||||
glBufferData(GL_PIXEL_PACK_BUFFER, size, nullptr, GL_STREAM_READ);
|
||||
readbackSize = size;
|
||||
}
|
||||
|
||||
GLuint currentfbo = gl.getFramebuffer(OpenGL::FRAMEBUFFER_ALL);
|
||||
gl.bindFramebuffer(OpenGL::FRAMEBUFFER_ALL, getFBO());
|
||||
glReadPixels(0, 0, pixelWidth, pixelHeight, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
|
||||
readbackFence = glFenceSync(GL_SYNC_GPU_COMMANDS_COMPLETE, 0);
|
||||
gl.bindFramebuffer(OpenGL::FRAMEBUFFER_ALL, currentfbo);
|
||||
glBindBuffer(GL_PIXEL_PACK_BUFFER, 0);
|
||||
return readbackFence != nullptr;
|
||||
}
|
||||
|
||||
love::image::ImageData *Canvas::pollImageData(love::image::Image *module)
|
||||
{
|
||||
if (readbackFence == nullptr)
|
||||
return nullptr;
|
||||
|
||||
GLenum status = glClientWaitSync(readbackFence, 0, 0);
|
||||
if (status == GL_TIMEOUT_EXPIRED)
|
||||
return nullptr;
|
||||
|
||||
glDeleteSync(readbackFence);
|
||||
readbackFence = nullptr;
|
||||
if (status == GL_WAIT_FAILED)
|
||||
return nullptr;
|
||||
|
||||
love::image::ImageData *data = module->newImageData(pixelWidth, pixelHeight, PIXELFORMAT_RGBA8);
|
||||
glBindBuffer(GL_PIXEL_PACK_BUFFER, readbackBuffer);
|
||||
void *pixels = glMapBufferRange(GL_PIXEL_PACK_BUFFER, 0, readbackSize, GL_MAP_READ_BIT);
|
||||
if (pixels == nullptr)
|
||||
{
|
||||
glBindBuffer(GL_PIXEL_PACK_BUFFER, 0);
|
||||
data->release();
|
||||
throw love::Exception("Could not map asynchronous Canvas readback.");
|
||||
}
|
||||
memcpy(data->getData(), pixels, readbackSize);
|
||||
glUnmapBuffer(GL_PIXEL_PACK_BUFFER);
|
||||
glBindBuffer(GL_PIXEL_PACK_BUFFER, 0);
|
||||
return data;
|
||||
}
|
||||
|
||||
void Canvas::generateMipmaps()
|
||||
{
|
||||
if (getMipmapCount() == 1 || getMipmapMode() == MIPMAPS_NONE)
|
||||
|
||||
@@ -54,6 +54,8 @@ public:
|
||||
ptrdiff_t getHandle() const override;
|
||||
|
||||
love::image::ImageData *newImageData(love::image::Image *module, int slice, int mipmap, const Rect &rect) override;
|
||||
bool requestImageData() override;
|
||||
love::image::ImageData *pollImageData(love::image::Image *module) override;
|
||||
void generateMipmaps() override;
|
||||
|
||||
int getMSAA() const override
|
||||
@@ -107,6 +109,9 @@ private:
|
||||
GLenum status;
|
||||
|
||||
int actualSamples;
|
||||
GLuint readbackBuffer;
|
||||
GLsync readbackFence;
|
||||
size_t readbackSize;
|
||||
|
||||
static SupportedFormat supportedFormats[PIXELFORMAT_MAX_ENUM];
|
||||
static SupportedFormat checkedFormats[PIXELFORMAT_MAX_ENUM];
|
||||
|
||||
@@ -116,6 +116,31 @@ int w_Canvas_newImageData(lua_State *L)
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_Canvas_requestImageData(lua_State *L)
|
||||
{
|
||||
Canvas *canvas = luax_checkcanvas(L, 1);
|
||||
bool requested = false;
|
||||
luax_catchexcept(L, [&](){ requested = canvas->requestImageData(); });
|
||||
luax_pushboolean(L, requested);
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_Canvas_pollImageData(lua_State *L)
|
||||
{
|
||||
Canvas *canvas = luax_checkcanvas(L, 1);
|
||||
love::image::Image *image = luax_getmodule<love::image::Image>(L, love::image::Image::type);
|
||||
love::image::ImageData *data = nullptr;
|
||||
luax_catchexcept(L, [&](){ data = canvas->pollImageData(image); });
|
||||
if (data == nullptr)
|
||||
{
|
||||
lua_pushnil(L);
|
||||
return 1;
|
||||
}
|
||||
luax_pushtype(L, data);
|
||||
data->release();
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_Canvas_generateMipmaps(lua_State *L)
|
||||
{
|
||||
Canvas *c = luax_checkcanvas(L, 1);
|
||||
@@ -139,6 +164,8 @@ static const luaL_Reg w_Canvas_functions[] =
|
||||
{ "getMSAA", w_Canvas_getMSAA },
|
||||
{ "renderTo", w_Canvas_renderTo },
|
||||
{ "newImageData", w_Canvas_newImageData },
|
||||
{ "requestImageData", w_Canvas_requestImageData },
|
||||
{ "pollImageData", w_Canvas_pollImageData },
|
||||
{ "generateMipmaps", w_Canvas_generateMipmaps },
|
||||
{ "getMipmapMode", w_Canvas_getMipmapMode },
|
||||
{ 0, 0 }
|
||||
|
||||
@@ -192,8 +192,14 @@ bool System::pickFile(const char *kind) const
|
||||
dest = "picked_mod.zip";
|
||||
else if (strcmp(kind, "sav") == 0 || strcmp(kind, "save") == 0)
|
||||
dest = "picked_save.sav";
|
||||
else if (strcmp(kind, "required_import") == 0)
|
||||
dest = "picked_required_import.bin";
|
||||
else if (strcmp(kind, "rom") == 0)
|
||||
dest = "picked_rom.gb";
|
||||
// Unknown kinds used to fall through to the ROM destination. Refuse them
|
||||
// so a newer Lua caller cannot silently route an unrelated file as a ROM.
|
||||
else
|
||||
return false;
|
||||
}
|
||||
return love::android::showFilePicker(dest);
|
||||
#else
|
||||
@@ -202,6 +208,15 @@ bool System::pickFile(const char *kind) const
|
||||
#endif
|
||||
}
|
||||
|
||||
const char *System::pickFileKinds() const
|
||||
{
|
||||
#ifdef LOVE_ANDROID
|
||||
return "rom,mod,sav,required_import";
|
||||
#else
|
||||
return "";
|
||||
#endif
|
||||
}
|
||||
|
||||
bool System::createFile(const char *suggestedName) const
|
||||
{
|
||||
#ifdef LOVE_ANDROID
|
||||
@@ -244,6 +259,87 @@ bool System::httpDownload(const char *url, const char *destPath,
|
||||
#endif
|
||||
}
|
||||
|
||||
bool System::httpPost(const char *url, const char *body, int bodyLen,
|
||||
const char *contentType, const char *userAgent) const
|
||||
{
|
||||
#ifdef LOVE_ANDROID
|
||||
return love::android::httpPost(url, body, bodyLen, contentType, userAgent);
|
||||
#else
|
||||
LOVE_UNUSED(url);
|
||||
LOVE_UNUSED(body);
|
||||
LOVE_UNUSED(bodyLen);
|
||||
LOVE_UNUSED(contentType);
|
||||
LOVE_UNUSED(userAgent);
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
int System::tlsOpen(const char *host, int port) const
|
||||
{
|
||||
#ifdef LOVE_ANDROID
|
||||
return love::android::tlsOpen(host, port);
|
||||
#else
|
||||
LOVE_UNUSED(host);
|
||||
LOVE_UNUSED(port);
|
||||
return -1;
|
||||
#endif
|
||||
}
|
||||
|
||||
int System::tlsStatus(int handle) const
|
||||
{
|
||||
#ifdef LOVE_ANDROID
|
||||
return love::android::tlsStatus(handle);
|
||||
#else
|
||||
LOVE_UNUSED(handle);
|
||||
return -1;
|
||||
#endif
|
||||
}
|
||||
|
||||
int System::tlsSend(int handle, const char *data, int length) const
|
||||
{
|
||||
#ifdef LOVE_ANDROID
|
||||
return love::android::tlsSend(handle, data, length);
|
||||
#else
|
||||
LOVE_UNUSED(handle);
|
||||
LOVE_UNUSED(data);
|
||||
LOVE_UNUSED(length);
|
||||
return -1;
|
||||
#endif
|
||||
}
|
||||
|
||||
int System::tlsReceive(int handle, char *buf, int max) const
|
||||
{
|
||||
#ifdef LOVE_ANDROID
|
||||
return love::android::tlsReceive(handle, buf, max);
|
||||
#else
|
||||
LOVE_UNUSED(handle);
|
||||
LOVE_UNUSED(buf);
|
||||
LOVE_UNUSED(max);
|
||||
return -1;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool System::tlsError(int handle, char *buf, int max) const
|
||||
{
|
||||
#ifdef LOVE_ANDROID
|
||||
return love::android::tlsError(handle, buf, max);
|
||||
#else
|
||||
LOVE_UNUSED(handle);
|
||||
LOVE_UNUSED(buf);
|
||||
LOVE_UNUSED(max);
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
void System::tlsClose(int handle) const
|
||||
{
|
||||
#ifdef LOVE_ANDROID
|
||||
love::android::tlsClose(handle);
|
||||
#else
|
||||
LOVE_UNUSED(handle);
|
||||
#endif
|
||||
}
|
||||
|
||||
bool System::hasBackgroundMusic() const
|
||||
{
|
||||
#if defined(LOVE_ANDROID)
|
||||
|
||||
@@ -112,10 +112,12 @@ public:
|
||||
* love::android::showFilePicker and src/import/RomImporter.lua.
|
||||
*
|
||||
* @param kind Optional pick kind: nullptr/"rom" -> picked_rom.gb,
|
||||
* "mod" -> picked_mod.zip, "sav"/"save" -> picked_save.sav.
|
||||
* "mod" -> picked_mod.zip, "sav"/"save" -> picked_save.sav,
|
||||
* "required_import" -> picked_required_import.bin.
|
||||
* @return Whether the picker was shown.
|
||||
**/
|
||||
virtual bool pickFile(const char *kind = nullptr) const;
|
||||
virtual const char *pickFileKinds() const;
|
||||
|
||||
/**
|
||||
* Shows the platform's native "create / save a file" UI (Android SAF
|
||||
@@ -149,6 +151,28 @@ public:
|
||||
virtual bool httpDownload(const char *url, const char *destPath,
|
||||
const char *userAgent = nullptr, const char *accept = nullptr) const;
|
||||
|
||||
/**
|
||||
* Blocking HTTPS POST of a raw byte body (Android only; false
|
||||
* elsewhere). The mirror of httpDownload for mod.postLog log sends,
|
||||
* which need POST and have no curl on Android (#597).
|
||||
**/
|
||||
virtual bool httpPost(const char *url, const char *body, int bodyLen,
|
||||
const char *contentType = nullptr, const char *userAgent = nullptr) const;
|
||||
|
||||
/**
|
||||
* TLS client sockets (Android only; every call fails elsewhere, where
|
||||
* LuaSec or another provider is the answer). Non-blocking by contract:
|
||||
* tlsOpen returns a handle and connects on its own thread, tlsStatus
|
||||
* reports 0 connecting / 1 open / 2 closed / -1 unknown, and bytes sent
|
||||
* before the handshake completes are queued rather than refused.
|
||||
**/
|
||||
virtual int tlsOpen(const char *host, int port) const;
|
||||
virtual int tlsStatus(int handle) const;
|
||||
virtual int tlsSend(int handle, const char *data, int length) const;
|
||||
virtual int tlsReceive(int handle, char *buf, int max) const;
|
||||
virtual bool tlsError(int handle, char *buf, int max) const;
|
||||
virtual void tlsClose(int handle) const;
|
||||
|
||||
/**
|
||||
* Gets if the user is playing music on background.
|
||||
* Throws an exception on unsupported platforms.
|
||||
|
||||
@@ -102,6 +102,12 @@ int w_pickFile(lua_State *L)
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_pickFileKinds(lua_State *L)
|
||||
{
|
||||
luax_pushstring(L, instance()->pickFileKinds());
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_createFile(lua_State *L)
|
||||
{
|
||||
const char *suggested = luaL_optstring(L, 1, nullptr);
|
||||
@@ -133,12 +139,96 @@ int w_httpDownload(lua_State *L)
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_httpPost(lua_State *L)
|
||||
{
|
||||
const char *url = luaL_checkstring(L, 1);
|
||||
size_t bodyLen = 0;
|
||||
const char *body = luaL_checklstring(L, 2, &bodyLen);
|
||||
const char *ct = luaL_optstring(L, 3, nullptr);
|
||||
const char *ua = luaL_optstring(L, 4, nullptr);
|
||||
luax_pushboolean(L, instance()->httpPost(url, body, (int) bodyLen, ct, ua));
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_hasBackgroundMusic(lua_State *L)
|
||||
{
|
||||
lua_pushboolean(L, instance()->hasBackgroundMusic());
|
||||
return 1;
|
||||
}
|
||||
|
||||
/*
|
||||
* TLS sockets. Deliberately a handle-and-poll API rather than an object:
|
||||
* the caller is a per-frame pump that must never block, and everything with
|
||||
* a thread behind it lives on the Java side.
|
||||
*/
|
||||
int w_tlsOpen(lua_State *L)
|
||||
{
|
||||
const char *host = luaL_checkstring(L, 1);
|
||||
int port = (int) luaL_checknumber(L, 2);
|
||||
lua_pushnumber(L, instance()->tlsOpen(host, port));
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_tlsStatus(lua_State *L)
|
||||
{
|
||||
int handle = (int) luaL_checknumber(L, 1);
|
||||
lua_pushnumber(L, instance()->tlsStatus(handle));
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_tlsSend(lua_State *L)
|
||||
{
|
||||
int handle = (int) luaL_checknumber(L, 1);
|
||||
size_t length = 0;
|
||||
const char *data = luaL_checklstring(L, 2, &length);
|
||||
lua_pushnumber(L, instance()->tlsSend(handle, data, (int) length));
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_tlsReceive(lua_State *L)
|
||||
{
|
||||
int handle = (int) luaL_checknumber(L, 1);
|
||||
int max = (int) luaL_optnumber(L, 2, 8192);
|
||||
if (max <= 0)
|
||||
{
|
||||
lua_pushliteral(L, "");
|
||||
return 1;
|
||||
}
|
||||
// A frame's worth of a busy room, on the C stack rather than the heap:
|
||||
// this runs every frame and an allocation per poll is not worth it.
|
||||
if (max > 65536)
|
||||
max = 65536;
|
||||
char buf[65536];
|
||||
int got = instance()->tlsReceive(handle, buf, max);
|
||||
if (got < 0)
|
||||
{
|
||||
lua_pushnil(L);
|
||||
return 1;
|
||||
}
|
||||
lua_pushlstring(L, buf, (size_t) got);
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_tlsError(lua_State *L)
|
||||
{
|
||||
int handle = (int) luaL_checknumber(L, 1);
|
||||
char buf[512];
|
||||
if (!instance()->tlsError(handle, buf, (int) sizeof(buf)))
|
||||
{
|
||||
lua_pushnil(L);
|
||||
return 1;
|
||||
}
|
||||
lua_pushstring(L, buf);
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_tlsClose(lua_State *L)
|
||||
{
|
||||
int handle = (int) luaL_checknumber(L, 1);
|
||||
instance()->tlsClose(handle);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static const luaL_Reg functions[] =
|
||||
{
|
||||
{ "getOS", w_getOS },
|
||||
@@ -149,10 +239,18 @@ static const luaL_Reg functions[] =
|
||||
{ "openURL", w_openURL },
|
||||
{ "vibrate", w_vibrate },
|
||||
{ "pickFile", w_pickFile },
|
||||
{ "pickFileKinds", w_pickFileKinds },
|
||||
{ "createFile", w_createFile },
|
||||
{ "syncHealthSteps", w_syncHealthSteps },
|
||||
{ "restartApp", w_restartApp },
|
||||
{ "httpDownload", w_httpDownload },
|
||||
{ "httpPost", w_httpPost },
|
||||
{ "tlsOpen", w_tlsOpen },
|
||||
{ "tlsStatus", w_tlsStatus },
|
||||
{ "tlsSend", w_tlsSend },
|
||||
{ "tlsReceive", w_tlsReceive },
|
||||
{ "tlsError", w_tlsError },
|
||||
{ "tlsClose", w_tlsClose },
|
||||
{ "hasBackgroundMusic", w_hasBackgroundMusic },
|
||||
{ 0, 0 }
|
||||
};
|
||||
|
||||
@@ -60,6 +60,7 @@ import android.os.Environment;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.os.Vibrator;
|
||||
import android.provider.Settings;
|
||||
import android.util.Log;
|
||||
import android.util.DisplayMetrics;
|
||||
import android.view.*;
|
||||
@@ -90,6 +91,9 @@ public class GameActivity extends SDLActivity {
|
||||
private static final String PICKED_ROM_FILENAME = "picked_rom.gb";
|
||||
private static final String PICKED_MOD_FILENAME = "picked_mod.zip";
|
||||
private static final String PICKED_SAVE_FILENAME = "picked_save.sav";
|
||||
// Kept separate from the game-ROM destination so a dependency pick can
|
||||
// never be mistaken for a game import when the picker returns on Android.
|
||||
private static final String PICKED_REQUIRED_IMPORT_FILENAME = "picked_required_import.bin";
|
||||
private static final String PENDING_EXPORT_FILENAME = "pending_export.sav";
|
||||
private static final String EXPORT_DONE_FILENAME = "export_done.flag";
|
||||
// Written when a SAF pick cannot be read at all, with the destination
|
||||
@@ -143,16 +147,36 @@ public class GameActivity extends SDLActivity {
|
||||
|
||||
private static native void nativeSetDefaultStreamValues(int sampleRate, int framesPerBurst);
|
||||
|
||||
/**
|
||||
* Native libraries required by an optional Android host extension.
|
||||
*
|
||||
* Subclasses supplied by another product flavor may override this method.
|
||||
* The libraries are loaded after LÖVE's dependencies and before liblove;
|
||||
* liblove must remain last because SDL treats the final entry as the main
|
||||
* shared object.
|
||||
*/
|
||||
protected String[] getHostLibraries() {
|
||||
return new String[0];
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String[] getLibraries() {
|
||||
return new String[] {
|
||||
"c++_shared",
|
||||
"mpg123",
|
||||
"openal",
|
||||
"love",
|
||||
};
|
||||
String[] hostLibraries = getHostLibraries();
|
||||
String[] libraries = new String[hostLibraries.length + 4];
|
||||
libraries[0] = "c++_shared";
|
||||
libraries[1] = "mpg123";
|
||||
libraries[2] = "openal";
|
||||
System.arraycopy(hostLibraries, 0, libraries, 3, hostLibraries.length);
|
||||
libraries[libraries.length - 1] = "love";
|
||||
return libraries;
|
||||
}
|
||||
|
||||
protected void onHostCreateBeforeSDL(Bundle savedInstanceState) {}
|
||||
protected void onHostCreateAfterSDL(Bundle savedInstanceState) {}
|
||||
protected void onHostResume() {}
|
||||
protected void onHostPause() {}
|
||||
protected void onHostDestroy() {}
|
||||
|
||||
@Override
|
||||
protected String getMainSharedObject() {
|
||||
String[] libs = getLibraries();
|
||||
@@ -192,7 +216,9 @@ public class GameActivity extends SDLActivity {
|
||||
intent.setData(null);
|
||||
}
|
||||
|
||||
onHostCreateBeforeSDL(savedInstanceState);
|
||||
super.onCreate(savedInstanceState);
|
||||
onHostCreateAfterSDL(savedInstanceState);
|
||||
if (savedInstanceState != null) {
|
||||
// Restore the in-flight SAF destinations, so a pick that returns to
|
||||
// a recreated activity still lands under the basename it asked for.
|
||||
@@ -341,6 +367,8 @@ public class GameActivity extends SDLActivity {
|
||||
Log.d("GameActivity", "Cancelling vibration");
|
||||
vibrator.cancel();
|
||||
}
|
||||
unregisterSecondaryDisplayListener();
|
||||
onHostDestroy();
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
@@ -350,16 +378,33 @@ public class GameActivity extends SDLActivity {
|
||||
Log.d("GameActivity", "Cancelling vibration");
|
||||
vibrator.cancel();
|
||||
}
|
||||
unregisterSecondaryDisplayListener();
|
||||
teardownSecondaryDisplay();
|
||||
onHostPause();
|
||||
super.onPause();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
onHostResume();
|
||||
refreshDualScreenDisplayMode();
|
||||
if (secondaryEnabled) registerSecondaryDisplayListener();
|
||||
setupSecondaryDisplay();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean dispatchKeyEvent(KeyEvent event) {
|
||||
// AYN's panel toggle emits virtual Right Shift, which SDL maps to a
|
||||
// gameplay button. The setting is absent on other Android devices.
|
||||
if (secondaryEnabled && dualScreenDisplayMode != -1
|
||||
&& event.getKeyCode() == KeyEvent.KEYCODE_SHIFT_RIGHT
|
||||
&& event.getDeviceId() == KeyCharacterMap.VIRTUAL_KEYBOARD) {
|
||||
return true;
|
||||
}
|
||||
return super.dispatchKeyEvent(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* SDL decides the activity's requested orientation at window creation
|
||||
* (SDLActivity.setOrientationBis). With a resizable window and no
|
||||
@@ -479,7 +524,8 @@ public class GameActivity extends SDLActivity {
|
||||
* picker-agnostic and unchanged.
|
||||
*
|
||||
* @param destFilename basename under the app save identity (e.g.
|
||||
* picked_rom.gb, picked_mod.zip, picked_save.sav)
|
||||
* picked_rom.gb, picked_mod.zip, picked_save.sav, or
|
||||
* picked_required_import.bin)
|
||||
*/
|
||||
/** Legacy single-argument entry; resolves the save dir itself. */
|
||||
@Keep
|
||||
@@ -510,6 +556,11 @@ public class GameActivity extends SDLActivity {
|
||||
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
||||
intent.setType("*/*");
|
||||
// The Storage Access Framework grants the returned content URI
|
||||
// directly to this activity. Request the read grant explicitly as
|
||||
// well: Android 13's scoped storage deliberately does not expose
|
||||
// arbitrary paths or require broad media/storage permissions.
|
||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||
try {
|
||||
self.startActivityForResult(intent, FILE_PICKER_REQUEST_CODE);
|
||||
return true;
|
||||
@@ -522,6 +573,7 @@ public class GameActivity extends SDLActivity {
|
||||
Intent intent = new Intent(Intent.ACTION_GET_CONTENT);
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
||||
intent.setType("*/*");
|
||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||
try {
|
||||
self.startActivityForResult(
|
||||
Intent.createChooser(intent, "Choose a file"),
|
||||
@@ -551,6 +603,12 @@ public class GameActivity extends SDLActivity {
|
||||
return showFilePicker(PICKED_SAVE_FILENAME);
|
||||
}
|
||||
|
||||
/** Required-mod-file wrapper used by love.system.pickFile("required_import"). */
|
||||
@Keep
|
||||
public static boolean showRequiredImportFilePicker() {
|
||||
return showFilePicker(PICKED_REQUIRED_IMPORT_FILENAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Relaunches the whole app for love.system.restartApp, used by
|
||||
* src/core/HostShell.lua when a mod toggle needs a cold boot (#575).
|
||||
@@ -606,6 +664,45 @@ public class GameActivity extends SDLActivity {
|
||||
* The body lands in a .part file and is renamed only once complete, so a
|
||||
* dropped connection can never leave a half file the caller trusts.
|
||||
*/
|
||||
/**
|
||||
* TLS client sockets, exposed as love.system.tls* and used by the
|
||||
* Archipelago mod for wss:// rooms. LuaSocket speaks TCP only, so without
|
||||
* these a hosted room -- every one of which is TLS-only -- is unreachable
|
||||
* from the game. The work is in TlsSocket; these are the static entry
|
||||
* points, because the JNI side resolves methods on the activity's own
|
||||
* class (see love/src/common/android.cpp) and cannot see other classes
|
||||
* from a worker thread.
|
||||
*/
|
||||
@Keep
|
||||
public static int tlsOpen(String host, int port) {
|
||||
return TlsSocket.open(host, port);
|
||||
}
|
||||
|
||||
@Keep
|
||||
public static int tlsStatus(int handle) {
|
||||
return TlsSocket.status(handle);
|
||||
}
|
||||
|
||||
@Keep
|
||||
public static int tlsSend(int handle, byte[] data) {
|
||||
return TlsSocket.send(handle, data);
|
||||
}
|
||||
|
||||
@Keep
|
||||
public static byte[] tlsReceive(int handle, int max) {
|
||||
return TlsSocket.receive(handle, max);
|
||||
}
|
||||
|
||||
@Keep
|
||||
public static String tlsError(int handle) {
|
||||
return TlsSocket.error(handle);
|
||||
}
|
||||
|
||||
@Keep
|
||||
public static void tlsClose(int handle) {
|
||||
TlsSocket.close(handle);
|
||||
}
|
||||
|
||||
@Keep
|
||||
public static boolean httpDownload(String url, String destPath, String userAgent, String accept) {
|
||||
if (url == null || destPath == null) return false;
|
||||
@@ -658,6 +755,76 @@ public class GameActivity extends SDLActivity {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocking HTTPS POST, exposed as love.system.httpPost and used by
|
||||
* src/core/HostShell.lua for mod.postLog. The GET bridge above covers
|
||||
* downloads; log sends need POST, and Android ships no curl, so this is
|
||||
* the only POST transport the platform has. Strictly one-way, matching
|
||||
* the curl branch it mirrors: the response body is drained and
|
||||
* discarded, and only the 2xx verdict comes back.
|
||||
*
|
||||
* Same rules as httpDownload: https only, redirects followed by hand
|
||||
* (re-POSTing the body on each hop, the way curl -X POST behaves), and
|
||||
* the call is blocking on the Lua/worker thread -- never the UI thread.
|
||||
* The body arrives as raw bytes (a jbyteArray across the JNI) because a
|
||||
* log ring can carry arbitrary UTF-8; a String would risk modified-UTF-8
|
||||
* corruption on characters outside the BMP.
|
||||
*/
|
||||
@Keep
|
||||
public static boolean httpPost(String url, byte[] body, String contentType, String userAgent) {
|
||||
if (url == null || body == null) return false;
|
||||
HttpURLConnection conn = null;
|
||||
try {
|
||||
String current = url;
|
||||
for (int hop = 0; hop < 5; hop++) {
|
||||
URL parsed = new URL(current);
|
||||
if (!"https".equalsIgnoreCase(parsed.getProtocol())) return false;
|
||||
conn = (HttpURLConnection) parsed.openConnection();
|
||||
conn.setInstanceFollowRedirects(false);
|
||||
conn.setConnectTimeout(15000);
|
||||
conn.setReadTimeout(60000);
|
||||
conn.setRequestMethod("POST");
|
||||
conn.setDoOutput(true);
|
||||
conn.setRequestProperty("User-Agent",
|
||||
userAgent == null ? "gen1recomp" : userAgent);
|
||||
conn.setRequestProperty("Content-Type",
|
||||
contentType == null ? "text/plain" : contentType);
|
||||
OutputStream out = new BufferedOutputStream(conn.getOutputStream());
|
||||
try {
|
||||
out.write(body);
|
||||
} finally {
|
||||
try { out.close(); } catch (IOException ignored) {}
|
||||
}
|
||||
int code = conn.getResponseCode();
|
||||
if (code == 301 || code == 302 || code == 303 || code == 307 || code == 308) {
|
||||
String next = conn.getHeaderField("Location");
|
||||
conn.disconnect();
|
||||
conn = null;
|
||||
if (next == null) return false;
|
||||
current = new URL(parsed, next).toString();
|
||||
continue;
|
||||
}
|
||||
if (code < 200 || code > 299) return false;
|
||||
// drain and discard, so a slow server cannot wedge the
|
||||
// worker on a full socket buffer
|
||||
InputStream in = new BufferedInputStream(conn.getInputStream());
|
||||
try {
|
||||
byte[] buf = new byte[16384];
|
||||
while (in.read(buf) > 0) {}
|
||||
} finally {
|
||||
try { in.close(); } catch (IOException ignored) {}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (Exception e) {
|
||||
Log.d("GameActivity", "httpPost failed: " + e.getMessage());
|
||||
return false;
|
||||
} finally {
|
||||
if (conn != null) conn.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows ACTION_CREATE_DOCUMENT so the player can save a staged export
|
||||
* (pending_export.sav in the app save identity) to Downloads / Drive /
|
||||
@@ -1323,8 +1490,38 @@ public class GameActivity extends SDLActivity {
|
||||
// Dual-screen: mirror the engine's bottom-screen canvas onto a secondary
|
||||
// physical display. Driven from the engine through love_android_secondary_*
|
||||
// in src/jni/love/src/common/android.cpp.
|
||||
private static final int SECONDARY_TARGET_AUTO = 0;
|
||||
private static final int SECONDARY_TARGET_HANDHELD = 1;
|
||||
private static final int SECONDARY_TARGET_EXTERNAL = 2;
|
||||
// AYN keeps disabled panels registered as ON. This optional setting is the
|
||||
// usable-state signal: 0 = both, 1 = main only, 2 = second only.
|
||||
private static final String DUAL_SCREEN_DISPLAY_MODE = "dual_screen_display_mode";
|
||||
private static final String AYN_SECOND_SCREEN = "Screen-2";
|
||||
private static volatile SecondaryPresentation secondaryPresentation;
|
||||
private static volatile SecondaryActivity secondaryActivity;
|
||||
private static volatile boolean secondaryActivityPending;
|
||||
private static volatile int secondaryActivityTarget = Display.INVALID_DISPLAY;
|
||||
private static volatile long secondaryRetryAfter;
|
||||
private static volatile boolean secondaryEnabled = false;
|
||||
private static volatile int secondaryTarget = SECONDARY_TARGET_AUTO;
|
||||
private static volatile int dualScreenDisplayMode = -1;
|
||||
private static volatile byte[] secondaryFrame;
|
||||
private static volatile int secondaryFrameWidth;
|
||||
private static volatile int secondaryFrameHeight;
|
||||
private static volatile int secondaryBackground;
|
||||
private static volatile boolean secondaryFrameCover;
|
||||
private static final Object secondaryFrameLock = new Object();
|
||||
private static volatile long secondaryDetectionAt;
|
||||
private static volatile boolean secondaryDetected;
|
||||
private SecondaryDisplayMonitor secondaryDisplayMonitor;
|
||||
private boolean dualScreenModeObserverRegistered;
|
||||
private final android.database.ContentObserver dualScreenModeObserver =
|
||||
new android.database.ContentObserver(new Handler(Looper.getMainLooper())) {
|
||||
@Override public void onChange(boolean selfChange, Uri uri) {
|
||||
refreshDualScreenDisplayMode();
|
||||
rebindSecondaryDisplay();
|
||||
}
|
||||
};
|
||||
private static final int MAX_SECONDARY_TOUCHES = 32;
|
||||
private static final java.util.ArrayDeque<String> secondaryTouches =
|
||||
new java.util.ArrayDeque<>();
|
||||
@@ -1336,66 +1533,312 @@ public class GameActivity extends SDLActivity {
|
||||
if (self == null) return;
|
||||
self.runOnUiThread(new Runnable() {
|
||||
@Override public void run() {
|
||||
if (on) setupSecondaryDisplay(); else teardownSecondaryDisplay();
|
||||
if (on) {
|
||||
self.refreshDualScreenDisplayMode();
|
||||
self.registerSecondaryDisplayListener();
|
||||
rebindSecondaryDisplay();
|
||||
} else {
|
||||
self.unregisterSecondaryDisplayListener();
|
||||
teardownSecondaryDisplay();
|
||||
secondaryRetryAfter = 0;
|
||||
synchronized (secondaryFrameLock) { secondaryFrame = null; }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Keep
|
||||
public static void setSecondaryDisplayTarget(int target) {
|
||||
int normalized = target == SECONDARY_TARGET_HANDHELD
|
||||
|| target == SECONDARY_TARGET_EXTERNAL ? target : SECONDARY_TARGET_AUTO;
|
||||
if (secondaryTarget == normalized) return;
|
||||
secondaryTarget = normalized;
|
||||
secondaryDetectionAt = 0;
|
||||
rebindSecondaryDisplay();
|
||||
}
|
||||
|
||||
private void refreshDualScreenDisplayMode() {
|
||||
int mode = Settings.System.getInt(
|
||||
getContentResolver(), DUAL_SCREEN_DISPLAY_MODE, -1);
|
||||
if (dualScreenDisplayMode != mode) secondaryDetectionAt = 0;
|
||||
dualScreenDisplayMode = mode;
|
||||
}
|
||||
|
||||
private void registerSecondaryDisplayListener() {
|
||||
if (secondaryDisplayMonitor == null && android.os.Build.VERSION.SDK_INT >= 17) {
|
||||
SecondaryDisplayMonitor monitor = new SecondaryDisplayMonitor(this);
|
||||
if (monitor.register()) secondaryDisplayMonitor = monitor;
|
||||
}
|
||||
if (dualScreenDisplayMode != -1 && !dualScreenModeObserverRegistered) {
|
||||
getContentResolver().registerContentObserver(
|
||||
Settings.System.getUriFor(DUAL_SCREEN_DISPLAY_MODE), false,
|
||||
dualScreenModeObserver);
|
||||
dualScreenModeObserverRegistered = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void unregisterSecondaryDisplayListener() {
|
||||
SecondaryDisplayMonitor monitor = secondaryDisplayMonitor;
|
||||
secondaryDisplayMonitor = null;
|
||||
if (monitor != null) monitor.unregister();
|
||||
if (dualScreenModeObserverRegistered) {
|
||||
getContentResolver().unregisterContentObserver(dualScreenModeObserver);
|
||||
dualScreenModeObserverRegistered = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean secondaryOutputIsPreferred(GameActivity self) {
|
||||
Display preferred = findSecondaryDisplay(self, false);
|
||||
if (preferred == null) return false;
|
||||
SecondaryPresentation presentation = secondaryPresentation;
|
||||
Display display = presentation == null
|
||||
? null : presentation.getDisplay();
|
||||
if (display == null) {
|
||||
SecondaryActivity activity = secondaryActivity;
|
||||
display = activity == null ? null : getActivityDisplay(activity);
|
||||
}
|
||||
SecondaryDisplayMonitor monitor = self.secondaryDisplayMonitor;
|
||||
if (display == null || (monitor != null
|
||||
&& !monitor.hasDisplay(display.getDisplayId()))) return false;
|
||||
return display.getDisplayId() == preferred.getDisplayId();
|
||||
}
|
||||
|
||||
private static void rebindSecondaryDisplay() {
|
||||
GameActivity self = (GameActivity) mSingleton;
|
||||
if (self == null || !secondaryEnabled || secondaryOutputIsPreferred(self)) return;
|
||||
self.runOnUiThread(() -> {
|
||||
if (!secondaryEnabled || secondaryOutputIsPreferred(self)) return;
|
||||
teardownSecondaryDisplay();
|
||||
setupSecondaryDisplay();
|
||||
});
|
||||
}
|
||||
|
||||
private static void setupSecondaryDisplay() {
|
||||
GameActivity self = (GameActivity) mSingleton;
|
||||
if (self == null || !secondaryEnabled || secondaryPresentation != null) return;
|
||||
if (self == null || !secondaryEnabled || secondaryPresentation != null
|
||||
|| secondaryActivity != null || secondaryActivityPending
|
||||
|| android.os.SystemClock.elapsedRealtime() < secondaryRetryAfter) return;
|
||||
try {
|
||||
android.hardware.display.DisplayManager dm =
|
||||
(android.hardware.display.DisplayManager) self.getSystemService(Context.DISPLAY_SERVICE);
|
||||
if (dm == null) return;
|
||||
Display chosen = null;
|
||||
for (Display d : dm.getDisplays()) {
|
||||
android.graphics.Point size = new android.graphics.Point();
|
||||
d.getRealSize(size);
|
||||
Log.d("GameActivity", "display id=" + d.getDisplayId() + " name=" + d.getName()
|
||||
+ " size=" + size.x + "x" + size.y);
|
||||
if (chosen == null && d.getDisplayId() != Display.DEFAULT_DISPLAY) {
|
||||
chosen = d;
|
||||
}
|
||||
}
|
||||
if (chosen == null) {
|
||||
Display[] pres =
|
||||
dm.getDisplays(android.hardware.display.DisplayManager.DISPLAY_CATEGORY_PRESENTATION);
|
||||
if (pres != null && pres.length > 0) chosen = pres[0];
|
||||
}
|
||||
Display chosen = findSecondaryDisplay(self, true);
|
||||
if (chosen == null) {
|
||||
Log.d("GameActivity", "no secondary display found");
|
||||
return;
|
||||
}
|
||||
if (!isPresentationDisplay(chosen)) {
|
||||
if (android.os.Build.VERSION.SDK_INT < 29) return;
|
||||
secondaryActivityPending = true;
|
||||
secondaryActivityTarget = chosen.getDisplayId();
|
||||
Intent intent = new Intent(self, SecondaryActivity.class)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_NO_ANIMATION);
|
||||
android.app.ActivityOptions options = android.app.ActivityOptions.makeBasic();
|
||||
options.setLaunchDisplayId(secondaryActivityTarget);
|
||||
self.startActivity(intent, options.toBundle());
|
||||
final int requestedDisplay = secondaryActivityTarget;
|
||||
new Handler(Looper.getMainLooper()).postDelayed(() -> {
|
||||
if (secondaryActivityPending
|
||||
&& secondaryActivityTarget == requestedDisplay) {
|
||||
secondaryActivityPending = false;
|
||||
secondaryRetryAfter = android.os.SystemClock.elapsedRealtime() + 1000;
|
||||
}
|
||||
}, 1000);
|
||||
return;
|
||||
}
|
||||
SecondaryPresentation p = new SecondaryPresentation(self, chosen);
|
||||
p.setOnDismissListener(dialog -> {
|
||||
if (secondaryPresentation == p) {
|
||||
secondaryPresentation = null;
|
||||
rebindSecondaryDisplay();
|
||||
}
|
||||
});
|
||||
p.show();
|
||||
secondaryPresentation = p;
|
||||
secondaryRetryAfter = 0;
|
||||
synchronized (secondaryFrameLock) {
|
||||
if (secondaryFrame != null) {
|
||||
p.setBackground(secondaryBackground);
|
||||
p.updateFrame(java.nio.ByteBuffer.wrap(secondaryFrame),
|
||||
secondaryFrameWidth, secondaryFrameHeight, secondaryFrameCover);
|
||||
}
|
||||
}
|
||||
Log.d("GameActivity", "secondary display presentation started on id=" + chosen.getDisplayId());
|
||||
} catch (Throwable t) {
|
||||
Log.d("GameActivity", "secondary display setup failed: " + t);
|
||||
secondaryPresentation = null;
|
||||
secondaryActivityPending = false;
|
||||
secondaryActivityTarget = Display.INVALID_DISPLAY;
|
||||
secondaryRetryAfter = android.os.SystemClock.elapsedRealtime() + 1000;
|
||||
teardownSecondaryDisplay();
|
||||
}
|
||||
}
|
||||
|
||||
private static Display findSecondaryDisplay(GameActivity self, boolean logDisplays) {
|
||||
android.hardware.display.DisplayManager dm =
|
||||
(android.hardware.display.DisplayManager) self.getSystemService(Context.DISPLAY_SERVICE);
|
||||
if (dm == null || android.os.Build.VERSION.SDK_INT < 17) return null;
|
||||
Display gameDisplay = getActivityDisplay(self);
|
||||
int gameDisplayId = gameDisplay == null
|
||||
? Display.DEFAULT_DISPLAY : gameDisplay.getDisplayId();
|
||||
Display handheld = dm.getDisplay(Display.DEFAULT_DISPLAY);
|
||||
boolean handheldAvailable = android.os.Build.VERSION.SDK_INT >= 29
|
||||
&& gameDisplayId != Display.DEFAULT_DISPLAY && isDisplayUsable(handheld);
|
||||
Display external = null;
|
||||
Display[] presentations = dm.getDisplays(
|
||||
android.hardware.display.DisplayManager.DISPLAY_CATEGORY_PRESENTATION);
|
||||
for (Display d : presentations) {
|
||||
if (logDisplays) {
|
||||
android.graphics.Point size = new android.graphics.Point();
|
||||
d.getRealSize(size);
|
||||
Log.d("GameActivity", "display id=" + d.getDisplayId()
|
||||
+ " name=" + d.getName() + " size=" + size.x + "x" + size.y);
|
||||
}
|
||||
if (external == null && d.getDisplayId() != gameDisplayId
|
||||
&& isDisplayUsable(d)) external = d;
|
||||
}
|
||||
if (secondaryTarget == SECONDARY_TARGET_HANDHELD && handheldAvailable) return handheld;
|
||||
if (secondaryTarget == SECONDARY_TARGET_EXTERNAL && external != null) return external;
|
||||
return handheldAvailable ? handheld : external;
|
||||
}
|
||||
|
||||
private static Display getActivityDisplay(android.app.Activity activity) {
|
||||
return android.os.Build.VERSION.SDK_INT >= 30
|
||||
? activity.getDisplay() : activity.getWindowManager().getDefaultDisplay();
|
||||
}
|
||||
|
||||
private static boolean isPresentationDisplay(Display display) {
|
||||
if (display == null || display.getDisplayId() == Display.DEFAULT_DISPLAY) return false;
|
||||
return android.os.Build.VERSION.SDK_INT < 20
|
||||
|| (display.getFlags() & Display.FLAG_PRESENTATION) != 0;
|
||||
}
|
||||
|
||||
private static boolean isDisplayUsable(Display display) {
|
||||
if (display == null) return false;
|
||||
if (android.os.Build.VERSION.SDK_INT >= 20
|
||||
&& display.getState() == Display.STATE_OFF) return false;
|
||||
if (dualScreenDisplayMode == 1 && AYN_SECOND_SCREEN.equals(display.getName())) {
|
||||
return false;
|
||||
}
|
||||
return dualScreenDisplayMode != 2
|
||||
|| display.getDisplayId() != Display.DEFAULT_DISPLAY;
|
||||
}
|
||||
|
||||
private static void teardownSecondaryDisplay() {
|
||||
SecondaryPresentation p = secondaryPresentation;
|
||||
secondaryPresentation = null;
|
||||
SecondaryActivity a = secondaryActivity;
|
||||
secondaryActivity = null;
|
||||
secondaryActivityPending = false;
|
||||
secondaryActivityTarget = Display.INVALID_DISPLAY;
|
||||
synchronized (secondaryTouches) { secondaryTouches.clear(); }
|
||||
if (p != null) {
|
||||
try { p.dismiss(); } catch (Throwable t) {}
|
||||
}
|
||||
if (a != null) {
|
||||
try { a.finish(); } catch (Throwable t) {}
|
||||
}
|
||||
}
|
||||
|
||||
@android.annotation.TargetApi(17)
|
||||
private static class SecondaryDisplayMonitor
|
||||
implements android.hardware.display.DisplayManager.DisplayListener {
|
||||
private final android.hardware.display.DisplayManager manager;
|
||||
|
||||
SecondaryDisplayMonitor(GameActivity activity) {
|
||||
manager = (android.hardware.display.DisplayManager)
|
||||
activity.getSystemService(Context.DISPLAY_SERVICE);
|
||||
}
|
||||
|
||||
boolean register() {
|
||||
if (manager == null) return false;
|
||||
manager.registerDisplayListener(this, new Handler(Looper.getMainLooper()));
|
||||
return true;
|
||||
}
|
||||
|
||||
void unregister() {
|
||||
manager.unregisterDisplayListener(this);
|
||||
}
|
||||
|
||||
boolean hasDisplay(int displayId) {
|
||||
return manager.getDisplay(displayId) != null;
|
||||
}
|
||||
|
||||
private void changed() {
|
||||
secondaryDetectionAt = 0;
|
||||
rebindSecondaryDisplay();
|
||||
}
|
||||
|
||||
@Override public void onDisplayAdded(int displayId) { changed(); }
|
||||
@Override public void onDisplayRemoved(int displayId) { changed(); }
|
||||
@Override public void onDisplayChanged(int displayId) { changed(); }
|
||||
}
|
||||
|
||||
@Keep
|
||||
public static boolean hasSecondaryDisplay() {
|
||||
return secondaryPresentation != null;
|
||||
return secondaryPresentation != null || secondaryActivity != null;
|
||||
}
|
||||
|
||||
@Keep
|
||||
public static boolean hasSecondaryDisplayCandidate() {
|
||||
GameActivity self = (GameActivity) mSingleton;
|
||||
if (self == null) return false;
|
||||
if (secondaryPresentation != null || secondaryActivity != null) return true;
|
||||
self.refreshDualScreenDisplayMode();
|
||||
long now = android.os.SystemClock.uptimeMillis();
|
||||
if (secondaryDetectionAt != 0 && now - secondaryDetectionAt < 500) {
|
||||
return secondaryDetected;
|
||||
}
|
||||
secondaryDetected = findSecondaryDisplay(self, false) != null;
|
||||
secondaryDetectionAt = now;
|
||||
return secondaryDetected;
|
||||
}
|
||||
|
||||
@Keep
|
||||
public static boolean presentSecondaryFrame(
|
||||
java.nio.ByteBuffer rgba, int width, int height,
|
||||
int backgroundColor, boolean cover) {
|
||||
long bytes = (long) width * height * 4;
|
||||
if (rgba == null || width <= 0 || height <= 0
|
||||
|| bytes <= 0 || bytes > Integer.MAX_VALUE
|
||||
|| rgba.capacity() < bytes) return false;
|
||||
synchronized (secondaryFrameLock) {
|
||||
if (secondaryFrame == null || secondaryFrame.length != (int) bytes) {
|
||||
secondaryFrame = new byte[(int) bytes];
|
||||
}
|
||||
rgba.rewind();
|
||||
rgba.get(secondaryFrame, 0, (int) bytes);
|
||||
rgba.rewind();
|
||||
secondaryFrameWidth = width;
|
||||
secondaryFrameHeight = height;
|
||||
secondaryBackground = backgroundColor;
|
||||
secondaryFrameCover = cover;
|
||||
SecondaryPresentation p = secondaryPresentation;
|
||||
SecondaryActivity a = secondaryActivity;
|
||||
if (p == null && a == null) return false;
|
||||
try {
|
||||
if (p != null) {
|
||||
p.setBackground(backgroundColor);
|
||||
p.updateFrame(rgba, width, height, cover);
|
||||
} else {
|
||||
a.setBackground(backgroundColor);
|
||||
a.updateFrame(rgba, width, height, cover);
|
||||
}
|
||||
return true;
|
||||
} catch (Throwable t) {
|
||||
GameActivity self = (GameActivity) mSingleton;
|
||||
if (self != null) self.runOnUiThread(() -> {
|
||||
teardownSecondaryDisplay();
|
||||
setupSecondaryDisplay();
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Keep
|
||||
public static void updateSecondaryFrame(java.nio.ByteBuffer buf, int w, int h) {
|
||||
SecondaryPresentation p = secondaryPresentation;
|
||||
if (p != null && buf != null && w > 0 && h > 0) {
|
||||
p.updateFrame(buf, w, h);
|
||||
SecondaryActivity a = secondaryActivity;
|
||||
if ((p != null || a != null) && buf != null && w > 0 && h > 0) {
|
||||
if (p != null) p.updateFrame(buf, w, h);
|
||||
else a.updateFrame(buf, w, h);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1406,6 +1849,102 @@ public class GameActivity extends SDLActivity {
|
||||
}
|
||||
}
|
||||
|
||||
private static void applySecondaryImmersive(android.view.Window w) {
|
||||
if (w == null) return;
|
||||
if (android.os.Build.VERSION.SDK_INT >= 30) {
|
||||
w.setDecorFitsSystemWindows(false);
|
||||
android.view.WindowInsetsController c = w.getInsetsController();
|
||||
if (c != null) {
|
||||
c.hide(android.view.WindowInsets.Type.systemBars());
|
||||
c.setSystemBarsBehavior(
|
||||
android.view.WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
|
||||
}
|
||||
} else {
|
||||
w.getDecorView().setSystemUiVisibility(
|
||||
android.view.View.SYSTEM_UI_FLAG_LAYOUT_STABLE
|
||||
| android.view.View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
|
||||
| android.view.View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
|
||||
| android.view.View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
|
||||
| android.view.View.SYSTEM_UI_FLAG_FULLSCREEN
|
||||
| android.view.View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY);
|
||||
}
|
||||
}
|
||||
|
||||
public static class SecondaryActivity extends android.app.Activity {
|
||||
private FrameView frameView;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
Display display = getActivityDisplay(this);
|
||||
if (!secondaryEnabled || display == null
|
||||
|| display.getDisplayId() != secondaryActivityTarget) {
|
||||
secondaryActivityPending = false;
|
||||
secondaryActivityTarget = Display.INVALID_DISPLAY;
|
||||
secondaryRetryAfter = android.os.SystemClock.elapsedRealtime() + 1000;
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
frameView = new FrameView(this);
|
||||
android.view.Window w = getWindow();
|
||||
w.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN
|
||||
| WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS,
|
||||
WindowManager.LayoutParams.FLAG_FULLSCREEN
|
||||
| WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS);
|
||||
setContentView(frameView);
|
||||
applySecondaryImmersive(w);
|
||||
secondaryActivity = this;
|
||||
secondaryActivityPending = false;
|
||||
secondaryRetryAfter = 0;
|
||||
synchronized (secondaryFrameLock) {
|
||||
if (secondaryFrame != null) {
|
||||
setBackground(secondaryBackground);
|
||||
updateFrame(java.nio.ByteBuffer.wrap(secondaryFrame),
|
||||
secondaryFrameWidth, secondaryFrameHeight, secondaryFrameCover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
if (secondaryActivity == this) secondaryActivity = null;
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onWindowFocusChanged(boolean hasFocus) {
|
||||
super.onWindowFocusChanged(hasFocus);
|
||||
if (hasFocus) applySecondaryImmersive(getWindow());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean dispatchKeyEvent(android.view.KeyEvent event) {
|
||||
GameActivity activity = (GameActivity) mSingleton;
|
||||
return activity != null
|
||||
? activity.dispatchKeyEvent(event) : super.dispatchKeyEvent(event);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean dispatchGenericMotionEvent(android.view.MotionEvent event) {
|
||||
GameActivity activity = (GameActivity) mSingleton;
|
||||
return activity != null
|
||||
? activity.dispatchGenericMotionEvent(event)
|
||||
: super.dispatchGenericMotionEvent(event);
|
||||
}
|
||||
|
||||
void updateFrame(java.nio.ByteBuffer buf, int w, int h) {
|
||||
frameView.updateFrame(buf, w, h);
|
||||
}
|
||||
|
||||
void updateFrame(java.nio.ByteBuffer buf, int w, int h, boolean cover) {
|
||||
frameView.updateFrame(buf, w, h, cover);
|
||||
}
|
||||
|
||||
void setBackground(int color) {
|
||||
frameView.setFrameBackground(color);
|
||||
}
|
||||
}
|
||||
|
||||
private static class SecondaryPresentation extends android.app.Presentation {
|
||||
private final FrameView frameView;
|
||||
|
||||
@@ -1439,31 +1978,36 @@ public class GameActivity extends SDLActivity {
|
||||
if (hasFocus) applyImmersive();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean dispatchKeyEvent(android.view.KeyEvent event) {
|
||||
GameActivity activity = (GameActivity) mSingleton;
|
||||
return activity != null
|
||||
? activity.dispatchKeyEvent(event) : super.dispatchKeyEvent(event);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean dispatchGenericMotionEvent(android.view.MotionEvent event) {
|
||||
GameActivity activity = (GameActivity) mSingleton;
|
||||
return activity != null
|
||||
? activity.dispatchGenericMotionEvent(event)
|
||||
: super.dispatchGenericMotionEvent(event);
|
||||
}
|
||||
|
||||
private void applyImmersive() {
|
||||
android.view.Window w = getWindow();
|
||||
if (w == null) return;
|
||||
if (android.os.Build.VERSION.SDK_INT >= 30) {
|
||||
w.setDecorFitsSystemWindows(false);
|
||||
android.view.WindowInsetsController c = w.getInsetsController();
|
||||
if (c != null) {
|
||||
c.hide(android.view.WindowInsets.Type.systemBars());
|
||||
c.setSystemBarsBehavior(
|
||||
android.view.WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
|
||||
}
|
||||
} else {
|
||||
w.getDecorView().setSystemUiVisibility(
|
||||
android.view.View.SYSTEM_UI_FLAG_LAYOUT_STABLE
|
||||
| android.view.View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
|
||||
| android.view.View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
|
||||
| android.view.View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
|
||||
| android.view.View.SYSTEM_UI_FLAG_FULLSCREEN
|
||||
| android.view.View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY);
|
||||
}
|
||||
applySecondaryImmersive(getWindow());
|
||||
}
|
||||
|
||||
void updateFrame(java.nio.ByteBuffer buf, int w, int h) {
|
||||
frameView.updateFrame(buf, w, h);
|
||||
}
|
||||
|
||||
void updateFrame(java.nio.ByteBuffer buf, int w, int h, boolean cover) {
|
||||
frameView.updateFrame(buf, w, h, cover);
|
||||
}
|
||||
|
||||
void setBackground(int color) {
|
||||
frameView.setFrameBackground(color);
|
||||
}
|
||||
}
|
||||
|
||||
private static class FrameView extends View {
|
||||
@@ -1472,7 +2016,9 @@ 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 backgroundColor = 0xFF000000;
|
||||
private int activePointer = -1;
|
||||
private boolean cover;
|
||||
|
||||
FrameView(Context context) {
|
||||
super(context);
|
||||
@@ -1482,7 +2028,12 @@ public class GameActivity extends SDLActivity {
|
||||
}
|
||||
|
||||
void updateFrame(java.nio.ByteBuffer buf, int w, int h) {
|
||||
updateFrame(buf, w, h, false);
|
||||
}
|
||||
|
||||
void updateFrame(java.nio.ByteBuffer buf, int w, int h, boolean cover) {
|
||||
synchronized (lock) {
|
||||
this.cover = cover;
|
||||
if (bitmap == null || fw != w || fh != h) {
|
||||
if (bitmap != null) bitmap.recycle();
|
||||
bitmap = android.graphics.Bitmap.createBitmap(w, h, android.graphics.Bitmap.Config.ARGB_8888);
|
||||
@@ -1494,6 +2045,13 @@ public class GameActivity extends SDLActivity {
|
||||
postInvalidate();
|
||||
}
|
||||
|
||||
void setFrameBackground(int color) {
|
||||
synchronized (lock) {
|
||||
backgroundColor = 0xFF000000 | (color & 0x00FFFFFF);
|
||||
}
|
||||
postInvalidate();
|
||||
}
|
||||
|
||||
private void enqueueTouch(String event) {
|
||||
synchronized (secondaryTouches) {
|
||||
if (secondaryTouches.size() >= MAX_SECONDARY_TOUCHES) {
|
||||
@@ -1545,12 +2103,15 @@ public class GameActivity extends SDLActivity {
|
||||
synchronized (lock) {
|
||||
if (bitmap == null || fw == 0 || fh == 0) return;
|
||||
int vw = getWidth(), vh = getHeight();
|
||||
int s = Math.min(vw / fw, vh / fh);
|
||||
if (s < 1) s = 1;
|
||||
int dw = fw * s, dh = fh * s;
|
||||
float fit = Math.min((float) vw / fw, (float) vh / fh);
|
||||
if (fit <= 0) return;
|
||||
float scale = cover
|
||||
? Math.max((float) vw / fw, (float) vh / fh)
|
||||
: fit >= 2f ? (float) Math.floor(fit) : fit;
|
||||
int dw = Math.round(fw * scale), dh = Math.round(fh * scale);
|
||||
int dx = (vw - dw) / 2, dy = (vh - dh) / 2;
|
||||
dst.set(dx, dy, dx + dw, dy + dh);
|
||||
canvas.drawColor(0xFF000000);
|
||||
canvas.drawColor(backgroundColor);
|
||||
canvas.drawBitmap(bitmap, null, dst, paint);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
package org.love2d.android;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.Socket;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import javax.net.ssl.HttpsURLConnection;
|
||||
import javax.net.ssl.SNIHostName;
|
||||
import javax.net.ssl.SNIServerName;
|
||||
import javax.net.ssl.SSLParameters;
|
||||
import javax.net.ssl.SSLSocket;
|
||||
import javax.net.ssl.SSLSocketFactory;
|
||||
|
||||
/**
|
||||
* A TLS client socket that Lua can drive without ever blocking a frame.
|
||||
*
|
||||
* WHY THIS EXISTS. LuaSocket, which is what LOVE ships, speaks TCP and nothing
|
||||
* else, so wss:// was simply unreachable from the game -- and every room hosted
|
||||
* on archipelago.gg is TLS-only, accepting a plain connection just long enough
|
||||
* to drop it. The alternative was vendoring mbedTLS into the NDK build and
|
||||
* carrying a CA bundle in the APK; the platform already has both a TLS stack
|
||||
* and the system trust store, so this asks Android instead.
|
||||
*
|
||||
* THE CONTRACT. Callers get an int handle and poll it. open() returns
|
||||
* immediately and the connect and handshake happen on their own thread, so a
|
||||
* slow or unreachable host costs nothing on the game thread -- which matters
|
||||
* more here than it did for httpDownload, since that runs on a worker and this
|
||||
* is serviced from the frame loop. Bytes handed to send() before the handshake
|
||||
* finishes are queued, not refused, so a caller can write its request the
|
||||
* moment it has a handle and never think about readiness again.
|
||||
*
|
||||
* Reads are drained by a thread into a chunk queue and handed over a copy at a
|
||||
* time; a caller that stops polling stops the connection rather than growing
|
||||
* the heap without limit.
|
||||
*/
|
||||
final class TlsSocket {
|
||||
static final int STATUS_CONNECTING = 0;
|
||||
static final int STATUS_OPEN = 1;
|
||||
static final int STATUS_CLOSED = 2;
|
||||
|
||||
private static final int CONNECT_TIMEOUT_MS = 15000;
|
||||
private static final int READ_CHUNK = 16384;
|
||||
/** Roughly a second of a very chatty room; past this the reader is gone. */
|
||||
private static final int MAX_BUFFERED = 4 * 1024 * 1024;
|
||||
|
||||
private static final ConcurrentHashMap<Integer, TlsSocket> LIVE =
|
||||
new ConcurrentHashMap<Integer, TlsSocket>();
|
||||
private static final AtomicInteger NEXT_HANDLE = new AtomicInteger(1);
|
||||
|
||||
private final String host;
|
||||
private final int port;
|
||||
private final int handle;
|
||||
|
||||
private volatile int status = STATUS_CONNECTING;
|
||||
private volatile String error = null;
|
||||
private volatile boolean closing = false;
|
||||
private volatile SSLSocket socket = null;
|
||||
|
||||
private final Object inLock = new Object();
|
||||
private final ArrayDeque<byte[]> inChunks = new ArrayDeque<byte[]>();
|
||||
private int inHeadOffset = 0;
|
||||
private int inAvailable = 0;
|
||||
|
||||
private final Object outLock = new Object();
|
||||
private final ArrayDeque<byte[]> outChunks = new ArrayDeque<byte[]>();
|
||||
|
||||
private TlsSocket(String host, int port, int handle) {
|
||||
this.host = host;
|
||||
this.port = port;
|
||||
this.handle = handle;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- API
|
||||
|
||||
static int open(String host, int port) {
|
||||
if (host == null || host.length() == 0 || port <= 0 || port > 65535) return -1;
|
||||
final int handle = NEXT_HANDLE.getAndIncrement();
|
||||
final TlsSocket self = new TlsSocket(host, port, handle);
|
||||
LIVE.put(Integer.valueOf(handle), self);
|
||||
Thread dialer = new Thread(new Runnable() {
|
||||
@Override public void run() { self.dial(); }
|
||||
}, "tls-dial-" + handle);
|
||||
dialer.setDaemon(true);
|
||||
dialer.start();
|
||||
return handle;
|
||||
}
|
||||
|
||||
static int status(int handle) {
|
||||
TlsSocket self = LIVE.get(Integer.valueOf(handle));
|
||||
return self == null ? -1 : self.status;
|
||||
}
|
||||
|
||||
static String error(int handle) {
|
||||
TlsSocket self = LIVE.get(Integer.valueOf(handle));
|
||||
return self == null ? null : self.error;
|
||||
}
|
||||
|
||||
static int send(int handle, byte[] data) {
|
||||
TlsSocket self = LIVE.get(Integer.valueOf(handle));
|
||||
if (self == null || data == null) return -1;
|
||||
if (self.status == STATUS_CLOSED) return -1;
|
||||
if (data.length == 0) return 0;
|
||||
synchronized (self.outLock) {
|
||||
self.outChunks.add(data);
|
||||
self.outLock.notifyAll();
|
||||
}
|
||||
return data.length;
|
||||
}
|
||||
|
||||
static byte[] receive(int handle, int max) {
|
||||
TlsSocket self = LIVE.get(Integer.valueOf(handle));
|
||||
if (self == null || max <= 0) return null;
|
||||
return self.take(max);
|
||||
}
|
||||
|
||||
static void close(int handle) {
|
||||
TlsSocket self = LIVE.remove(Integer.valueOf(handle));
|
||||
if (self != null) self.shutdown(null);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ internals
|
||||
|
||||
private void dial() {
|
||||
Socket plain = null;
|
||||
try {
|
||||
plain = new Socket();
|
||||
plain.connect(new InetSocketAddress(host, port), CONNECT_TIMEOUT_MS);
|
||||
plain.setTcpNoDelay(true);
|
||||
|
||||
SSLSocketFactory factory = (SSLSocketFactory) SSLSocketFactory.getDefault();
|
||||
SSLSocket ssl = (SSLSocket) factory.createSocket(plain, host, port, true);
|
||||
|
||||
// Wrapping an already-connected socket skips the SNI and hostname
|
||||
// checking that createSocket(host, port) would have done for us, and
|
||||
// a shared address like archipelago.gg answers with the wrong
|
||||
// certificate without the name in the hello. Both are set through
|
||||
// SSLParameters where the platform has it, with the verifier below
|
||||
// as the floor for anything older.
|
||||
boolean verifiedByPlatform = false;
|
||||
try {
|
||||
SSLParameters params = ssl.getSSLParameters();
|
||||
params.setEndpointIdentificationAlgorithm("HTTPS");
|
||||
List<SNIServerName> names = new ArrayList<SNIServerName>(1);
|
||||
names.add(new SNIHostName(host));
|
||||
params.setServerNames(names);
|
||||
ssl.setSSLParameters(params);
|
||||
verifiedByPlatform = true;
|
||||
} catch (Throwable ignored) {
|
||||
// Older platform: handled after the handshake instead.
|
||||
}
|
||||
|
||||
enableModernProtocols(ssl);
|
||||
ssl.startHandshake();
|
||||
|
||||
if (!verifiedByPlatform
|
||||
&& !HttpsURLConnection.getDefaultHostnameVerifier()
|
||||
.verify(host, ssl.getSession())) {
|
||||
throw new java.io.IOException(
|
||||
"certificate does not match " + host);
|
||||
}
|
||||
|
||||
socket = ssl;
|
||||
if (closing) { shutdown(null); return; }
|
||||
status = STATUS_OPEN;
|
||||
|
||||
Thread writer = new Thread(new Runnable() {
|
||||
@Override public void run() { pumpOut(); }
|
||||
}, "tls-write-" + handle);
|
||||
writer.setDaemon(true);
|
||||
writer.start();
|
||||
|
||||
pumpIn();
|
||||
} catch (Throwable t) {
|
||||
shutdown(describe(t));
|
||||
if (plain != null) {
|
||||
try { plain.close(); } catch (Throwable ignored) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* minSdk is 16, where TLS 1.1/1.2 exist but are off by default. Every
|
||||
* modern server refuses everything older, so switch on whatever the
|
||||
* platform has rather than leaving an old device negotiating TLS 1.0.
|
||||
*/
|
||||
private static void enableModernProtocols(SSLSocket ssl) {
|
||||
try {
|
||||
List<String> wanted = new ArrayList<String>(3);
|
||||
for (String supported : ssl.getSupportedProtocols()) {
|
||||
if (supported.startsWith("TLSv1.1")
|
||||
|| supported.startsWith("TLSv1.2")
|
||||
|| supported.startsWith("TLSv1.3")) {
|
||||
wanted.add(supported);
|
||||
}
|
||||
}
|
||||
if (!wanted.isEmpty()) {
|
||||
ssl.setEnabledProtocols(wanted.toArray(new String[wanted.size()]));
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
private void pumpIn() {
|
||||
try {
|
||||
InputStream in = socket.getInputStream();
|
||||
byte[] buf = new byte[READ_CHUNK];
|
||||
while (!closing) {
|
||||
int n = in.read(buf);
|
||||
if (n < 0) break;
|
||||
if (n == 0) continue;
|
||||
byte[] chunk = new byte[n];
|
||||
System.arraycopy(buf, 0, chunk, 0, n);
|
||||
synchronized (inLock) {
|
||||
if (inAvailable + n > MAX_BUFFERED) {
|
||||
throw new java.io.IOException("read buffer overflow");
|
||||
}
|
||||
inChunks.add(chunk);
|
||||
inAvailable += n;
|
||||
}
|
||||
}
|
||||
shutdown(null);
|
||||
} catch (Throwable t) {
|
||||
shutdown(describe(t));
|
||||
}
|
||||
}
|
||||
|
||||
private void pumpOut() {
|
||||
try {
|
||||
OutputStream out = socket.getOutputStream();
|
||||
while (true) {
|
||||
byte[] chunk;
|
||||
synchronized (outLock) {
|
||||
while (outChunks.isEmpty() && !closing && status != STATUS_CLOSED) {
|
||||
outLock.wait();
|
||||
}
|
||||
if (closing || status == STATUS_CLOSED) return;
|
||||
chunk = outChunks.poll();
|
||||
}
|
||||
if (chunk != null) {
|
||||
out.write(chunk);
|
||||
out.flush();
|
||||
}
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
shutdown(describe(t));
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] take(int max) {
|
||||
synchronized (inLock) {
|
||||
if (inAvailable <= 0) return null;
|
||||
int want = Math.min(max, inAvailable);
|
||||
byte[] out = new byte[want];
|
||||
int filled = 0;
|
||||
while (filled < want) {
|
||||
byte[] head = inChunks.peek();
|
||||
if (head == null) break;
|
||||
int have = head.length - inHeadOffset;
|
||||
int take = Math.min(have, want - filled);
|
||||
System.arraycopy(head, inHeadOffset, out, filled, take);
|
||||
filled += take;
|
||||
inHeadOffset += take;
|
||||
if (inHeadOffset >= head.length) {
|
||||
inChunks.poll();
|
||||
inHeadOffset = 0;
|
||||
}
|
||||
}
|
||||
inAvailable -= filled;
|
||||
if (filled == want) return out;
|
||||
byte[] short_ = new byte[filled];
|
||||
System.arraycopy(out, 0, short_, 0, filled);
|
||||
return short_;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The handle stays registered until the caller closes it, so why the
|
||||
* connection ended and whatever arrived before it did are both still
|
||||
* readable. Dropping it here instead would turn a server that states its
|
||||
* refusal and hangs up into an unknown handle, which is the one failure a
|
||||
* player most needs the reason for.
|
||||
*/
|
||||
private void shutdown(String why) {
|
||||
if (why != null && error == null) error = why;
|
||||
closing = true;
|
||||
status = STATUS_CLOSED;
|
||||
synchronized (outLock) { outLock.notifyAll(); }
|
||||
SSLSocket s = socket;
|
||||
socket = null;
|
||||
if (s != null) {
|
||||
try { s.close(); } catch (Throwable ignored) {}
|
||||
}
|
||||
if (why != null) Log.d("TlsSocket", host + ":" + port + " -- " + why);
|
||||
}
|
||||
|
||||
private static String describe(Throwable t) {
|
||||
String msg = t.getMessage();
|
||||
String name = t.getClass().getSimpleName();
|
||||
if (msg == null || msg.length() == 0) return name;
|
||||
return name + ": " + msg;
|
||||
}
|
||||
}
|
||||
@@ -1,130 +1,113 @@
|
||||
# iOS build (LÖVE 12.0)
|
||||
# iOS build
|
||||
|
||||
> **Native ROM/mod/save import.** The iOS build ships a Swift
|
||||
> document-picker bridge (`native/GRPickerBridge.swift` + `GRBootstrap.m`)
|
||||
> that `patch_love_src.py` wires into the LÖVE tree on every build:
|
||||
>
|
||||
> - `love.system.pickFile("rom"|"mod"|"sav")` and `love.system.createFile`
|
||||
> are exposed to Lua on iOS (same contract as love-android's SAF picker:
|
||||
> picks land in the save dir as `picked_rom.gb` / `picked_mod.zip` /
|
||||
> `picked_save.sav`; exports signal via `export_done.flag`).
|
||||
> - The Info.plist overlay enables `UIFileSharingEnabled` +
|
||||
> `LSSupportsOpeningDocumentsInPlace`, and `GRBootstrap.m` sweeps
|
||||
> `.gb/.gbc/.zip/.sav` files dropped in Documents (Files app / Finder)
|
||||
> into the LÖVE save dir on every activation — drop a ROM, open the app,
|
||||
> and it imports with no taps.
|
||||
> - `src/import/RomImporter.lua` treats iOS as a mobile platform and polls
|
||||
> for picker results (iOS pickers are in-process modals, so Android's
|
||||
> refocus rescan never fires).
|
||||
>
|
||||
> The note below about a missing "UIDocumentPicker handoff" is
|
||||
> resolved by this bridge.
|
||||
This directory contains the macOS/Xcode build used to package Gen1 Recomp as
|
||||
an iOS app with LÖVE 12.0.
|
||||
|
||||
macOS + Xcode only. Fetches the **LÖVE 12.0** source tree and matching Apple
|
||||
dependencies from the official [LÖVE source](https://github.com/love2d/love)
|
||||
and [Apple dependencies](https://github.com/love2d/love-apple-dependencies)
|
||||
repositories. `conf.lua` declares LÖVE 12.0 on iOS and 11.5 elsewhere.
|
||||
## User data location
|
||||
|
||||
Pin file: [`LOVE_VERSION`](./LOVE_VERSION) → `12.0`.
|
||||
The app uses the public iOS Documents directory as its LÖVE save directory.
|
||||
There is no `pokemon-love2d` subdirectory and the app does not create a
|
||||
README file there. When browsing `On My iPhone > gen1recomp++` in Files, the
|
||||
directory contains the app's runtime data directly, including:
|
||||
|
||||
## Quick start (simulator)
|
||||
- installed mods and downloaded ROMs
|
||||
- save files and save-state data
|
||||
- options, caches, logs, and other files created by the game
|
||||
|
||||
The build enables `UIFileSharingEnabled` and
|
||||
`LSSupportsOpeningDocumentsInPlace`, so the same directory is available in
|
||||
Files and Finder. Files copied into the app's Documents directory are used by
|
||||
the game on its next activation.
|
||||
|
||||
Existing installations are migrated automatically. Files from the old
|
||||
private `Application Support/pokemon-love2d` directory are merged into
|
||||
Documents on launch; conflicts are retained with a `.legacy` suffix.
|
||||
|
||||
## Build
|
||||
|
||||
Run these commands from the repository root:
|
||||
|
||||
```bash
|
||||
# Fetch LÖVE 12.0 iOS sources and dependencies (once) + build for Simulator
|
||||
scripts/build_ios.sh --fetch
|
||||
scripts/build_ios.sh
|
||||
```
|
||||
|
||||
The embedded `game.love` contains no ROM or generated game data. The current
|
||||
first-boot importer has desktop file pickers only, so a production iOS release
|
||||
still needs a UIDocumentPicker handoff that passes the selected ROM to LÖVE.
|
||||
`--fetch` downloads the pinned LÖVE source and matching Apple dependencies
|
||||
into the gitignored `love-src/` directory. It is only needed when that tree is
|
||||
missing. The default build targets the iOS Simulator in Debug configuration.
|
||||
|
||||
Default output: an unsigned Simulator `.app` under `mobile/ios/build/`
|
||||
(no Apple Developer account required). A convenience copy also lands under
|
||||
`dist/ios/<Config>-<sdk>/`.
|
||||
|
||||
Install on a booted simulator (example):
|
||||
For a physical device or a release build:
|
||||
|
||||
```bash
|
||||
xcrun simctl install booted mobile/ios/build/Build/Products/Debug-iphonesimulator/PokemonRed.app
|
||||
xcrun simctl launch booted com.theboisclub.pokemonred
|
||||
scripts/build_ios.sh --device --install
|
||||
scripts/build_ios.sh --device --release --install
|
||||
```
|
||||
|
||||
Or open `mobile/ios/love-src/platform/xcode/love.xcodeproj` in Xcode,
|
||||
select the `love-ios` target, and Run on a Simulator after
|
||||
`scripts/build_ios.sh --package-only` (or a full build) has placed `game.love`.
|
||||
Device builds require a paired, unlocked device and a valid Apple signing
|
||||
identity. Set `DEVELOPMENT_TEAM` or `CODE_SIGN_IDENTITY` when automatic
|
||||
signing cannot select the intended account. Add `--ipa` to create
|
||||
`dist/ios/gen1recomp++.ipa`.
|
||||
|
||||
## Device / Release
|
||||
The script verifies the final app before packaging it:
|
||||
|
||||
```bash
|
||||
scripts/build_ios.sh --device # Debug, physical device SDK
|
||||
scripts/build_ios.sh --device --release # Release configuration
|
||||
- the public Documents plist settings are present
|
||||
- the native picker bridge is present
|
||||
- `game.love` exists and is non-empty
|
||||
|
||||
If the payload is missing, the build fails instead of producing a blank app.
|
||||
|
||||
## Useful options
|
||||
|
||||
| Option | Purpose |
|
||||
| --- | --- |
|
||||
| `--fetch` | Fetch LÖVE 12.0 and Apple dependencies when `love-src/` is missing |
|
||||
| `--device` | Build for `iphoneos` instead of the Simulator |
|
||||
| `--release` | Use the Release configuration |
|
||||
| `--install` | Install a device build on the first connected device |
|
||||
| `--ipa` | Create an IPA after a device build |
|
||||
| `--version X.Y.Z` | Stamp the engine and app version |
|
||||
| `--package-only` | Package `game.love` and apply the iOS plist overlay without Xcode |
|
||||
|
||||
`scripts/build.sh ios` delegates to this script and forwards the iOS release
|
||||
option.
|
||||
|
||||
## Output
|
||||
|
||||
Simulator and device app bundles are copied to:
|
||||
|
||||
```text
|
||||
dist/ios/Debug-iphonesimulator/gen1recomp++.app
|
||||
dist/ios/Release-iphonesimulator/gen1recomp++.app
|
||||
dist/ios/Debug-iphoneos/gen1recomp++.app
|
||||
dist/ios/Release-iphoneos/gen1recomp++.app
|
||||
```
|
||||
|
||||
Device builds need a signing identity and provisioning profile configured in
|
||||
Xcode (or via `DEVELOPMENT_TEAM` / `CODE_SIGN_IDENTITY` env vars). This repo
|
||||
does **not** store certificates, profiles, or App Store Connect secrets.
|
||||
The intermediate Xcode products are under `mobile/ios/build/`. Both locations
|
||||
are gitignored.
|
||||
|
||||
Manual out-of-band steps:
|
||||
|
||||
1. Apple Developer account + App ID for `com.theboisclub.pokemonred`
|
||||
2. Development or Distribution certificate + provisioning profile
|
||||
3. In Xcode: open `love.xcodeproj` → target `love-ios` → Signing & Capabilities
|
||||
→ select your Team (or set `DEVELOPMENT_TEAM=XXXXXXXXXX` when invoking
|
||||
`scripts/build_ios.sh --device`)
|
||||
4. Archive / export an `.ipa` from Xcode Organizer for TestFlight / Ad Hoc
|
||||
|
||||
## Layout
|
||||
|
||||
| Path | Role |
|
||||
|------|------|
|
||||
| `LOVE_VERSION` | Engine pin (`12.0`) |
|
||||
| `overlays/love-ios.plist` | Portrait-only Info.plist + display name **Pokemon Red** (copied over the upstream plist every build) |
|
||||
| `love-src/` | Downloaded LÖVE 12.0 source tree (**gitignored**, do not commit) |
|
||||
| `cache/` | Temporary source and dependency checkout data (**gitignored**) |
|
||||
| `build/` | `xcodebuild` derived data (**gitignored**) |
|
||||
|
||||
Game payload lands at:
|
||||
|
||||
`love-src/platform/xcode/ios/resources/game.love`
|
||||
|
||||
and is fused into the built `.app` (LÖVE auto-runs any bundled `*.love`).
|
||||
|
||||
## Apple libraries dependency
|
||||
|
||||
`scripts/build_ios.sh --fetch` retrieves the matching iOS libraries and the
|
||||
SDL3 framework from
|
||||
[love-apple-dependencies](https://github.com/love2d/love-apple-dependencies).
|
||||
Re-run it if either dependency directory is absent.
|
||||
The bundled game payload is staged at
|
||||
`love-src/platform/xcode/ios/resources/game.love` and copied into the final
|
||||
app bundle. The payload contains the game, not user-generated ROMs, mods, or
|
||||
saves; those are created at runtime in Documents.
|
||||
|
||||
## App identity
|
||||
|
||||
| Field | Value |
|
||||
|-------|--------|
|
||||
| Display name | Pokemon Red |
|
||||
| `PRODUCT_NAME` | PokemonRed |
|
||||
| Bundle ID | `com.theboisclub.pokemonred` |
|
||||
| Orientations | Portrait only (`UIInterfaceOrientationPortrait`) |
|
||||
| Field | Default |
|
||||
| --- | --- |
|
||||
| Display name | `gen1recomp++` |
|
||||
| Product name | `gen1recomp++` |
|
||||
| Bundle identifier | `com.theboisclub.gen1recompplusplus` |
|
||||
| Save directory | Public `Documents` root |
|
||||
| Orientation | Portrait |
|
||||
|
||||
Overrides are applied by the build script (`xcodebuild` settings + plist overlay)
|
||||
so refreshing `love-src/` does not lose branding.
|
||||
Set `GEN1_BUNDLE_ID` to use a different bundle identifier for local device
|
||||
builds.
|
||||
|
||||
## Flags (`scripts/build_ios.sh`)
|
||||
## Prerequisites
|
||||
|
||||
| Flag | Meaning |
|
||||
|------|---------|
|
||||
| *(default)* | Simulator, Debug, no signing |
|
||||
| `--fetch` | Fetch the LÖVE 12.0 source tree and Apple dependencies if `love-src/` is missing |
|
||||
| `--device` | Build against `iphoneos` instead of `iphonesimulator` |
|
||||
| `--release` | `Release` configuration instead of `Debug` |
|
||||
| `--package-only` | Zip `game.love` + apply plist overlay; skip `xcodebuild` |
|
||||
- macOS with Xcode and `xcodebuild`
|
||||
- the iOS and iOS Simulator platforms installed in Xcode
|
||||
- a fetched `love-src/` tree, or the `--fetch` option
|
||||
- the matching iOS libraries and SDL3 framework under `love-src/`
|
||||
|
||||
Also: `scripts/build.sh ios` delegates here (`--release` is forwarded).
|
||||
|
||||
## Preconditions
|
||||
|
||||
- macOS (Darwin) with Xcode + `xcodebuild` on `PATH`
|
||||
- iOS platform installed in Xcode (Settings → Platforms). `xcodebuild -showsdks`
|
||||
should list `iphonesimulator` / `iphoneos`. A partial install can fail IB/xib
|
||||
compiles with `iOS … Platform Not Installed` even when the SDK name appears.
|
||||
- `love-src/` present (`--fetch`)
|
||||
- iOS libraries under `love-src/platform/xcode/ios/libraries/` and SDL3 under `love-src/platform/xcode/shared/Frameworks/`
|
||||
Use `xcodebuild -showsdks` to confirm that the required SDKs are installed.
|
||||
|
||||
@@ -1,17 +1,213 @@
|
||||
{
|
||||
"name": "gen1recomp App Repo",
|
||||
"identifier": "com.theboisclub.gen1recomp.repo",
|
||||
"name": "gen1recomp++ App Repo",
|
||||
"identifier": "com.theboisclub.gen1recompplusplus.repo",
|
||||
"iconURL": "https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/assets/logo/gen1recomp_cover.png",
|
||||
"apps": [
|
||||
{
|
||||
"name": "gen1recomp",
|
||||
"bundleIdentifier": "com.theboisclub.gen1recomp",
|
||||
"name": "gen1recomp++",
|
||||
"bundleIdentifier": "com.theboisclub.gen1recompplusplus",
|
||||
"developerName": "bryanthaboi",
|
||||
"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": [
|
||||
{
|
||||
"version": "0.2.3",
|
||||
"date": "2026-08-18",
|
||||
"size": 13579254,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.3/gen1recomp++-0.2.3-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1497 skin studio needs a import file picker babyyyyyy\n\n## Contributors\n\n- @anxiousintrovert\n- @AverageConsumer\n- @bryanthaboi\n- @thibautbus"
|
||||
},
|
||||
{
|
||||
"version": "0.2.2",
|
||||
"date": "2026-08-18",
|
||||
"size": 13575387,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.2/gen1recomp++-0.2.2-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @AverageConsumer\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.2.1",
|
||||
"date": "2026-08-17",
|
||||
"size": 13575320,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.1/gen1recomp++-0.2.1-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"date": "2026-08-17",
|
||||
"size": 13575299,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.0/gen1recomp++-0.2.0-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1396 Nurse dialogue & options\n- #1398 Alignment of options for changing Pokemon\n- #1400 Flying Bug\n- #1401 [Gold] battlergfx $d9/$da load the wrong row count (jumptable crossed vs macro names)\n- #1406 Magikarp salesman dialogue issues\n- #1407 Not able to nickname Magikarp\n- #1411 No indication for stone evolutions\n- #1413 Using a stone closes menu\n- #1415 Super Nerd dialogue issues\n- #1416 (Pokémon Gold) Pokédex doesn't register other trainers' pokémon as seen\n- #1417 (Pokémon Gold) Pokémon you get in trade aren't being registered as caught\n- #1419 (Pokémon Gold) Deposited pokémon don't get healed\n- #1421 (Pokémon Gold) Bad status and catch state appears on the HUD before they should\n- #1422 (Pokémon Gold) Impossible to have the pokédex register Ditto as caught after it transforms\n- #1423 (Pokémon Gold) No save prompt before changing boxes in the PC\n- #1424 (Pokémon Gold) Quantity for owned TMs not being displayed\n- #1425 (Pokémon Gold) Items quantity in your bag should be alligned to the right\n- #1427 (Pokémon Gold) Can't switch items' position in your bag\n- #1428 (Pokémon Gold) Game doesn't show how many pokémon other trainers have\n- #1429 Pikachu not sliding in before its cry. Stuck on standard pokeball release animation.\n- #1431 Shiny sparkle does not play on your sent out shiny pokemon\n- #1432 Experimental marked mods don't install Android\n- #1433 (Pokémon Gold) Missing prompt for depositing pokémon\n- #1435 When npcs stop you to talk or when you walk up to npcs to talk to them sometimes the player has the wrong sprite\n- #1437 Issues with player sprite on map\n- #1440 hold a direction during cutscene and face the wrong way\n- #1441 Magnet Train missing animation\n- #1442 Radio dial is missing in PokeGear radio\n- #1443 Skipping production logo also skips battle scene\n- #1444 Pokemon lack type immunity to status moves\n- #1447 Soft-lock on Cinnabar Island\n- #1449 Visual error on Route 28\n- #1456 Activating all mods doesn't work properly\n- #1461 #1265 didnt got fixed.\n- #1464 Experiance shared in battle\n- #1465 Changing Touch Layout crashes launcher\n- #1466 #1403 Still Happens\n- #1467 A clearer definition of the use of AI for this reconstruction\n- #1468 [Gold] BICYCLE is broken and some pokegear bug\n- #1469 [Gold] status effects aren't shown in the party overlay or the summary screen of the pokemon\n- #1470 Mod updater doesn't work properly when AppImage is running through Steam or Game Mode (Steam Deck)\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.99",
|
||||
"date": "2026-08-17",
|
||||
"size": 11391467,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.99/gen1recomp++-0.1.99-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #597 Pulling mod index fails on Android\n- #1403 Save editor not allowing moves to go past ZAP_CANNON\n\n## Contributors\n\n- @1Jamie\n- @AverageConsumer\n- @bryanthaboi\n- @emre155\n- @sanjinpepic\n- @ShaneMcGovernIE\n- @syybott\n- @thibautbus"
|
||||
},
|
||||
{
|
||||
"version": "0.1.98",
|
||||
"date": "2026-08-16",
|
||||
"size": 11380117,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.98/gen1recomp++-0.1.98-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1181 Poison seems to trigger twice during the poisoned pokemon's turn\n- #1211 S.S. Anne Visual bug when it's sailing away\n- #1212 the move payday does not grant money in gen 2\n- #1214 Title Screen with OG Red is the wrong color\n- #1224 Windowed and borderless toggle in Gold\n- #1228 No option to nickname starter\n- #1229 Encounter rate grace period not working\n- #1230 Couple of sound effects missing\n- #1231 Using Tackle partially distorts battle sprites\n- #1232 Wild pokemon's sprite disappears early when using a pokeball\n- #1249 Cant use stat items IE HP UP PP UP PROTIEN\n- #1251 You don't have a COIN CASE\n- #1265 Major: Regression from #984 (probably?)\n- #1267 [GOLD] POKEDEX didn't show pokemon appear area\n- #1269 [Gold] shadow ball should be invert the screen\n- #1271 [Gold] substitute image broken/not shown\n- #1272 [Gold] swift still checks accuracy and/or evasion\n- #1273 S.S. Anne Issues\n- #1276 Nurse back to not bowing (and turning)\n- #1279 Rival still not looking at player when initiating first fight\n- #1282 PKMN league PC option missing\n- #1293 Dig animation is bugged in-battle\n- #1296 Opponent's moves failing\n- #1298 Gen1 sound tracks have a fade in period, if you enter a route and immediately exit it while this transition is going on it will land on the wrong music\n- #1301 Pixels aren't square\n- #1303 Animation speed of walking NPCs too slow\n- #1305 Wrong Pikachu cry when getting defeated\n- #1307 Rival theme broken after initial fight in Yellow\n- #1318 Thunder Wave works on Ground-types\n- #1328 Message for turning on the PC missing\n- #1329 Name Select Background\n- #1330 Message before looking at map missing\n- #1331 Messages in Oak's lab missing\n- #1333 E-mail in Oak's lab missing\n- #1334 Missing message after picking starter\n- #1335 No Money Box\n- #1338 Rival's sister missing dialogue and roaming\n- #1340 Color palett doesn't affect attack animations\n- #1341 Pokedex entries look wrong\n- #1343 No dashes in empty attack slots during fights\n- #1344 Town Map not showing player sprite\n- #1345 Wrong health color on OG palett\n- #1346 Health still black when viewing stats\n- #1360 No Surfing Music\n- #1362 Poison damage does not flash the screen\n- #1368 Fishing Rods behaving irregularly\n- #1385 Team Rocket Hideouts missing music\n- #1388 Safeguard targets opponent, not user\n- #1389 Gastly unobtainable\n- #1391 NPC not escorting player to museum\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.97",
|
||||
"date": "2026-08-16",
|
||||
"size": 11376813,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.97/gen1recomp++-0.1.97-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1181 Poison seems to trigger twice during the poisoned pokemon's turn\n- #1212 the move payday does not grant money in gen 2\n- #1214 Title Screen with OG Red is the wrong color\n- #1224 Windowed and borderless toggle in Gold\n- #1230 Couple of sound effects missing\n- #1249 Cant use stat items IE HP UP PP UP PROTIEN\n- #1271 [Gold] substitute image broken/not shown\n- #1272 [Gold] swift still checks accuracy and/or evasion\n- #1273 S.S. Anne Issues\n- #1298 Gen1 sound tracks have a fade in period, if you enter a route and immediately exit it while this transition is going on it will land on the wrong music\n- #1305 Wrong Pikachu cry when getting defeated\n- #1307 Rival theme broken after initial fight in Yellow\n- #1318 Thunder Wave works on Ground-types\n- #1328 Message for turning on the PC missing\n- #1330 Message before looking at map missing\n- #1331 Messages in Oak's lab missing\n- #1333 E-mail in Oak's lab missing\n- #1334 Missing message after picking starter\n- #1335 No Money Box\n- #1340 Color palett doesn't affect attack animations\n- #1345 Wrong health color on OG palett\n- #1346 Health still black when viewing stats\n- #1360 No Surfing Music\n- #1362 Poison damage does not flash the screen\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.96",
|
||||
"date": "2026-08-16",
|
||||
"size": 11375356,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.96/gen1recomp++-0.1.96-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @AverageConsumer\n- @bryanthaboi\n- @ShaneMcGovernIE"
|
||||
},
|
||||
{
|
||||
"version": "0.1.95",
|
||||
"date": "2026-08-16",
|
||||
"size": 11373796,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.95/gen1recomp++-0.1.95-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @AverageConsumer\n- @bryanthaboi\n- @ShaneMcGovernIE"
|
||||
},
|
||||
{
|
||||
"version": "0.1.94",
|
||||
"date": "2026-08-15",
|
||||
"size": 11369457,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.94/gen1recomp++-0.1.94-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #694 Missing sound effect when falling from boulder holes\n\n## Contributors\n\n- @anxiousintrovert\n- @bryanthaboi\n- @ShaneMcGovernIE"
|
||||
},
|
||||
{
|
||||
"version": "0.1.93",
|
||||
"date": "2026-08-15",
|
||||
"size": 11366991,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.93/gen1recomp++-0.1.93-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1314 Launcher is a bit buggy\n\n## Contributors\n\n- @1Jamie\n- @anxiousintrovert\n- @bryanthaboi\n- @TheRealSolidusSnake"
|
||||
},
|
||||
{
|
||||
"version": "0.1.92",
|
||||
"date": "2026-08-15",
|
||||
"size": 11364274,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.92/gen1recomp++-0.1.92-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.91",
|
||||
"date": "2026-08-15",
|
||||
"size": 11351477,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.91/gen1recomp++-0.1.91-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.90",
|
||||
"date": "2026-08-15",
|
||||
"size": 11344338,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.90/gen1recomp++-0.1.90-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @1Jamie\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.89",
|
||||
"date": "2026-08-15",
|
||||
"size": 11343841,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.89/gen1recomp++-0.1.89-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @1Jamie\n- @anxiousintrovert\n- @AverageConsumer\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.88",
|
||||
"date": "2026-08-14",
|
||||
"size": 11311237,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.88/gen1recomp++-0.1.88-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.87",
|
||||
"date": "2026-08-14",
|
||||
"size": 11310910,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.87/gen1recomp++-0.1.87-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @AverageConsumer\n- @bryanthaboi\n- @mleo2003\n- @ShaneMcGovernIE\n- MaxTomahawk"
|
||||
},
|
||||
{
|
||||
"version": "0.1.86",
|
||||
"date": "2026-08-14",
|
||||
"size": 11306689,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.86/gen1recomp++-0.1.86-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1283 Intro broken and leading to softlock (R,B,Y currently unplayable without save file)\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.85",
|
||||
"date": "2026-08-14",
|
||||
"size": 11306462,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.85/gen1recomp++-0.1.85-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.84",
|
||||
"date": "2026-08-14",
|
||||
"size": 11306462,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.84/gen1recomp++-0.1.84-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #919 Bills House game save moves strangely after reload\n- #982 Trading QOL aspect missing from OG.\n- #1003 Land Pokemon Encounters while Surfing in Cerulean Cave\n- #1012 Multiple issues with text speed, animations, and audio\n- #1022 Cannot interact with small trees\n- #1028 Trainers not resetting on route change.\n- #1033 Some issues with Link battles.\n- #1243 Can't sell TMs at Poké Marts\n\n## Contributors\n\n- @1Jamie\n- @AverageConsumer\n- @bryanthaboi\n- @TheRealSolidusSnake\n- Myles Resnick\n- ShaneMcGovernIE"
|
||||
},
|
||||
{
|
||||
"version": "0.1.83",
|
||||
"date": "2026-08-13",
|
||||
"size": 11282300,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.83/gen1recomp++-0.1.83-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @AverageConsumer\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.82",
|
||||
"date": "2026-08-13",
|
||||
"size": 11281277,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.82/gen1recomp++-0.1.82-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #914 Immunity steps and no encounter tiles\n- #936 Fly and teleporting allow should block interactions\n- #944 When starting a new game, the character sprite is facing down instead of up\n- #949 Save game import, save game export, \"normal\" save game\n- #964 Menu closing when trying to view Pokemon before picking starter\n- #978 Following up of #847\n- #983 Pikachu incorrectly able to be released\n- #984 EXP calculation after trading a PKMN back isnt right.\n- #987 Celadon Game Corner Display Error (L22680)\n- #988 (Yellow) Missing Pikachu Interaction - Pewter City Pokemon Centre\n- #989 (Yellow) Missing Pikachu Interaction - Vermilion City Pokemon Fan Club\n- #990 (Yellow) Pikachu Missing Hat\n- #999 Pallet Town music while player follows Prof. Oak\n- #1007 Transition into battle state for the 'Catching Pikachu' scene missing\n- #1010 Rival's position resets\n- #1011 Rival dialogue issue\n- #1029 1px black line sits under the title logo at the SGB zone boundary\n- #1035 Alt Music not playing\n- #1041 Mimic text not swoing up\n- #1047 Using a TM or HM closes the menu afterwards.\n- #1048 Cannot select items from the sell menu.\n- #1056 Cursor not jumping when switching moves\n- #1065 Player walks through statue after final battle while walking into Hall Of Fame\n- #1066 Anomalies when opponent switches out Pokemon\n- #1069 missing {RAM:wNameBuffer}\n- #1073 _GotMonText not used\n- #1089 Scripted movement missing for player and rival\n- #1101 Recoil damage calculation bug\n- #1115 Hitting attack does no damage\n- #1119 Wrongs textlines and events\n- #1120 Health is black when viewing stats\n- #1129 Errors in bowing animations of the nurse\n- #1146 [Recomp] Map music glitch\n- #1149 issue that affects tranlations\n- #1175 Pikachu visible too late\n- #1189 Town Map Item\n- #1193 Status moves have (wrong) sound effects when battle animations are turned off\n- #1206 Gen1: Cinnebar island has unfishable tiles north and east\n- #1207 Poison Flicker Visual issue\n- #1214 Title Screen with OG Red is the wrong color\n- #1219 [GOLD] UNABLE TO USE SUN STONE\n- #1220 No screen shaking for hitting moves when battle animations are off\n- #1221 [Gold] Flaafy evolution to at Lvl 30 Ampharos skipped learn thunder punch\n- #1225 Jingles for learning moves missing\n\n## Contributors\n\n- @1Jamie\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.81",
|
||||
"date": "2026-08-13",
|
||||
"size": 9886854,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.81/gen1recomp++-0.1.81-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1091 Encounter Rate is tied to Game Speed\n- #1097 Phase through walls exiting a cave\n- #1100 Allow for control edit and vibration toggle on gold\n- #1104 wrong warp\n- #1135 update for iphone - haptic vibrations\n- #1155 Magnitude always rolls a 4\n- #1157 Sonic Boom doesn't deal set damage\n- #1158 Unable to view opponent's next Pokemon\n- #1159 Dig animation is bugged\n- #1160 64x64 battle sprite followup for mods\n- #1167 [Gold] items in ilex forest missing\n- #1208 Gold Save game not showing properly in LAUNCHER after having multiple saves\n\n## Contributors\n\n- @bryanthaboi\n- @castdrian\n- @MaxTomahawk\n- @Yukitty\n- Codex Agent"
|
||||
},
|
||||
{
|
||||
"version": "0.1.80",
|
||||
"date": "2026-08-12",
|
||||
"size": 10964568,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.80/gen1recomp-0.1.80-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1100 Allow for control edit and vibration toggle on gold\n- #1107 [Gold] When playing without a save slot in the launcher, no save will be created at all when saving ingame\n- #1112 [Gold] In the shop when buying items and pressing \"down\", it should not default to x99\n- #1121 [Gold] NPC trading (probably player too?) animation broken / missing things\n- #1126 [Gold] Jumping off ledge animation missing\n- #1130 A separate program for Gen2 (Gen2 Recomp)\n- #1145 Gen1recomp internal update - Mod didn't filter for Gen1 and Gen2 when click on individual tab\n- #1150 Control in Gold freeze when change\n- #1151 [Gold] Following up of #1127\n- #1152 [Gold] You cannot try to run away from wild battle after one of your Pokemon gets fainted.\n- #1161 window/borderless & resolution selection for gen2\n- #1162 [gold] learning TM/HM doesn't show if possible or not\n- #1164 Goldenrod Tower Softlock\n- #1165 [Gold] NPC trading behavior and ledge jumping missing still there despite fix\n- #1166 [Gold] nickname screen grey\n- #1167 [Gold] items in ilex forest missing\n- #1168 [Gold] multi-hit attacks only hits twice\n- #1169 [Gold] the sales lady in goldenrod underground\n- #1172 [Gold] {last item u found/got} used ROCK SMASH\n- #1173 [Gold] ROCK SMASH rocks keep slide left\n- #1177 [Gold] Touch controls position\n- #1178 No Vibration Setting for Gold\n- #1180 [Gold] something feels off with the DV calculation\n- #1184 [Gold] Kurt is in the wrong spot, blocking you from progressing\n- #1185 [Gold] Text messages is in the wrong order when a Pokemon learns a new skill after a trainer fight\n- #1188 GoldenRod tower softlock\n- #1190 POKEMON GOLD GEN1RECOMP++\n- #1192 cant import gold on RGXX build\n\n## Contributors\n\n- @anxiousintrovert\n- @AverageConsumer\n- @bryanthaboi\n- @MaxTomahawk\n- @ShaneMcGovernIE\n- @thibautbus"
|
||||
},
|
||||
{
|
||||
"version": "0.1.79",
|
||||
"date": "2026-08-12",
|
||||
"size": 10956333,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.79/gen1recomp-0.1.79-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1090 With TILT Option enabled, screen does not correctly render beyond what would be visible with TILT Disabled.\n- #1093 Gen 2 Gold Beta: jingle doesn't play when receiving Elm's phone number\n- #1094 Gen 2 Gold Beta: no battle SFX when animations are Off\n- #1095 Gen 2 Gold Beta: player sprite has white box outline that cuts off Pokémon sprites\n- #1098 [Gold] Can't change controls (no menu for it and it doesn't use the one set in RBY)\n- #1102 [Gold] Shop ui off because of a border\n- #1105 [Gold] Game speed doesn't apply to the intro\n- #1106 [Gold] Games slots don't respect portable.txt\n- #1108 [Gold] Launcher doesn't show any information about the save slot(s)\n- #1109 [Options] text speed doesn't work at all.\n- #1110 [Gold] When catching a Pokemon, the ball disappears and the text doesn't wait for you to progress\n- #1111 [Gold] nurse joy doesn't look left for healing\n- #1113 [Gold] Poke center -> Bills PC -> Deposit Pokemon, screen off\n- #1114 [Gold] Can't release Pokemon, menu doesn't appear\n- #1117 [Gold/Options] sound doesn't change\n- #1118 [Gold] Berrys animation missing and speedup.\n- #1121 [Gold] NPC trading (probably player too?) animation broken / missing things\n- #1122 [Gold] Level up UI.\n- #1123 [Gold] Character stops \"inside door\" when leaving buildings instead of moving one tile down\n- #1124 [Gold] When \"turning\", no animation is played\n- #1126 [Gold] Jumping off ledge animation missing\n- #1127 [Gold] Very minor Pokegear issues.\n- #1128 [Gold] When calling mom, the yes/no dialogue background color doesn't fit\n- #1131 [Gold] Pokegear map bug(s)\n- #1132 [gold] pokedex bugs\n- #1134 Unable to Validate/Pack mod calling Gold's overworld sprite\n- #1137 64x64 sprite support broken for mods on Gen 2\n- #1141 Teleport doesn't end wild battle\n\n## Contributors\n\n- @AverageConsumer\n- @bryanthaboi\n- @dburton95\n- @dlloa\n- @MaxTomahawk\n- @swuff-star\n- @TheRealSolidusSnake\n- @thibautbus"
|
||||
},
|
||||
{
|
||||
"version": "0.1.78",
|
||||
"date": "2026-08-11",
|
||||
"size": 10944298,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.78/gen1recomp-0.1.78-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.77",
|
||||
"date": "2026-08-10",
|
||||
"size": 9611135,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.77/gen1recomp-0.1.77-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #960 Missing sounds for logging off & walking into NPCs\n- #961 Sound effects for entering/leaving a building & using stairs playing too late\n- #968 Caterpie not evolving?\n- #995 Nurse doesn't bow after healing\n- #1006 Minor bug: Prof Oak's aid can't do math\n- #1009 Pikachu follows player right away\n- #1013 Unable to nickname Pikachu in Yellow\n- #1021 Pikachu not moving in Prof. Oak's lab\n- #1031 Not evolving\n- #1044 Saving sound effect not playing when changing box at PC\n- #1045 Menuing sound effects missing in fights\n- #1049 No option to rename Lapras in Silph Co.\n- #1050 Pokemon Fan Club Chairman missing dialogue option.\n\n## Contributors\n\n- @AverageConsumer\n- @bryanthaboi\n- @castdrian\n- @ShaneMcGovernIE"
|
||||
},
|
||||
{
|
||||
"version": "0.1.76",
|
||||
"date": "2026-08-10",
|
||||
"size": 9610081,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.76/gen1recomp-0.1.76-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #617 Old Man in Viridian\n- #889 Pokemon yellow can't export save file right\n- #915 wrong sprite used in opening\n- #916 Trainer sprite at end of fly animation\n- #931 Mod update checks fail\n- #932 Bugs reset settings\n- #933 Add TitleState override for player\n- #945 Cannot edit Trainer Class's Battle Theme\n- #969 Linux x86_64 AppImage Shows up as \"LOVE\" instead of \"gen1recomp\"\n- #1016 Mod API: support variable-size overworld sprites\n- #1039 Encounter rate grace period not working\n- #1040 Player sprite walks right through rival after defeating him at the end of the game\n\n## Contributors\n\n- @ArmstrongThomas\n- @AverageConsumer\n- @Bortlesboat\n- @bryanthaboi\n- @crusty\n- @dlloa\n- @jherediagu\n- @KikiManjaro\n- @martin2844\n- @MaxTomahawk\n- @ShaneMcGovernIE\n- @steve1337\n- @swuff-star\n- @thibautbus\n- @Yukitty\n- hernan"
|
||||
},
|
||||
{
|
||||
"version": "0.1.75",
|
||||
"date": "2026-08-06",
|
||||
|
||||
@@ -16,6 +16,9 @@ static void GRBootstrapInstall(void)
|
||||
queue:[NSOperationQueue mainQueue]
|
||||
usingBlock:^(NSNotification *note) {
|
||||
Class bridge = NSClassFromString(@"GRPickerBridge");
|
||||
if ([bridge respondsToSelector:@selector(preparePublicDocuments)]) {
|
||||
[bridge performSelector:@selector(preparePublicDocuments)];
|
||||
}
|
||||
if ([bridge respondsToSelector:@selector(sweepInbox)]) {
|
||||
[bridge performSelector:@selector(sweepInbox)];
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// without updating that patch (see mobile/ios/patch_love_src.py).
|
||||
//
|
||||
// Contract (mirrors love-android's GameActivity.showFilePicker):
|
||||
// love.system.pickFile("rom"|"mod"|"sav") -> copies the user's pick into
|
||||
// love.system.pickFile("rom"|"mod"|"sav"|"required_import") -> copies the user's pick into
|
||||
// the LÖVE save directory as picked_rom.gb / picked_mod.zip /
|
||||
// picked_save.sav; RomImporter's pending-file scan consumes it.
|
||||
// love.system.createFile(name) -> exports save dir's pending_export.sav
|
||||
@@ -26,8 +26,6 @@ public final class GRPickerBridge: NSObject {
|
||||
// silently doing nothing.
|
||||
private static var liveDelegates: [PickerDelegate] = []
|
||||
|
||||
// conf.lua t.identity — where LÖVE puts the fused save directory on iOS
|
||||
// (<sandbox>/Library/Application Support/<identity>).
|
||||
private static let loveIdentity = "pokemon-love2d"
|
||||
|
||||
@objc(httpDownloadWithUrl:destination:userAgent:accept:)
|
||||
@@ -85,6 +83,8 @@ public final class GRPickerBridge: NSObject {
|
||||
types = [.zip]
|
||||
case "sav":
|
||||
destName = "picked_save.sav"
|
||||
case "required_import":
|
||||
destName = "picked_required_import.bin"
|
||||
// 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
|
||||
@@ -137,7 +137,7 @@ public final class GRPickerBridge: NSObject {
|
||||
// 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
|
||||
return "rom,mod,sav,stadium,required_import" as NSString
|
||||
}
|
||||
|
||||
@objc(presentExportWithName:saveDir:)
|
||||
@@ -177,10 +177,10 @@ public final class GRPickerBridge: NSObject {
|
||||
// UIApplicationDidBecomeActive (see GRBootstrap.m).
|
||||
@objc public static func sweepInbox() {
|
||||
let fm = FileManager.default
|
||||
guard let docs = fm.urls(for: .documentDirectory, in: .userDomainMask).first,
|
||||
let appSupport = fm.urls(for: .applicationSupportDirectory,
|
||||
in: .userDomainMask).first else { return }
|
||||
let saveDir = appSupport.appendingPathComponent(loveIdentity, isDirectory: true)
|
||||
migrateLegacySaveDirectory()
|
||||
guard let docs = documentsDirectory(),
|
||||
let saveDir = publicSaveDirectory() else { return }
|
||||
guard docs.standardizedFileURL != saveDir.standardizedFileURL else { return }
|
||||
let wanted: Set<String> = ["gb", "gbc", "zip", "sav"]
|
||||
guard let items = try? fm.contentsOfDirectory(at: docs,
|
||||
includingPropertiesForKeys: nil) else { return }
|
||||
@@ -197,15 +197,21 @@ public final class GRPickerBridge: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
@objc public static func preparePublicDocuments() {
|
||||
migrateLegacySaveDirectory()
|
||||
if let saveDir = publicSaveDirectory() {
|
||||
ensureDirectory(saveDir)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private static func resolvedSaveDir(_ cstr: UnsafePointer<CChar>?) -> URL? {
|
||||
var dir = cstr.map { String(cString: $0) } ?? ""
|
||||
if dir.isEmpty {
|
||||
guard let appSupport = FileManager.default
|
||||
.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
|
||||
else { return nil }
|
||||
dir = appSupport.appendingPathComponent(loveIdentity).path
|
||||
migrateLegacySaveDirectory()
|
||||
guard let saveDir = publicSaveDirectory() else { return nil }
|
||||
dir = saveDir.path
|
||||
}
|
||||
let url = URL(fileURLWithPath: dir, isDirectory: true)
|
||||
ensureDirectory(url)
|
||||
@@ -217,6 +223,75 @@ public final class GRPickerBridge: NSObject {
|
||||
withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
private static func documentsDirectory() -> URL? {
|
||||
FileManager.default.urls(for: .documentDirectory,
|
||||
in: .userDomainMask).first
|
||||
}
|
||||
|
||||
private static func publicSaveDirectory() -> URL? {
|
||||
documentsDirectory()
|
||||
}
|
||||
|
||||
private static func legacySaveDirectory() -> URL? {
|
||||
FileManager.default.urls(for: .applicationSupportDirectory,
|
||||
in: .userDomainMask).first?
|
||||
.appendingPathComponent(loveIdentity, isDirectory: true)
|
||||
}
|
||||
|
||||
private static func migrateLegacySaveDirectory() {
|
||||
let fm = FileManager.default
|
||||
guard let destination = publicSaveDirectory(),
|
||||
let legacy = legacySaveDirectory(),
|
||||
fm.fileExists(atPath: legacy.path) else {
|
||||
return
|
||||
}
|
||||
ensureDirectory(destination)
|
||||
mergeDirectory(from: legacy, to: destination)
|
||||
try? fm.removeItem(at: legacy)
|
||||
}
|
||||
|
||||
private static func mergeDirectory(from source: URL, to destination: URL) {
|
||||
let fm = FileManager.default
|
||||
ensureDirectory(destination)
|
||||
guard let items = try? fm.contentsOfDirectory(at: source,
|
||||
includingPropertiesForKeys: nil)
|
||||
else { return }
|
||||
for item in items {
|
||||
let target = destination.appendingPathComponent(item.lastPathComponent)
|
||||
var sourceIsDirectory = ObjCBool(false)
|
||||
fm.fileExists(atPath: item.path, isDirectory: &sourceIsDirectory)
|
||||
var targetIsDirectory = ObjCBool(false)
|
||||
let targetExists = fm.fileExists(atPath: target.path,
|
||||
isDirectory: &targetIsDirectory)
|
||||
if sourceIsDirectory.boolValue && targetExists && targetIsDirectory.boolValue {
|
||||
mergeDirectory(from: item, to: target)
|
||||
continue
|
||||
}
|
||||
if targetExists {
|
||||
if !sourceIsDirectory.boolValue && !targetIsDirectory.boolValue &&
|
||||
fm.contentsEqual(atPath: item.path, andPath: target.path) {
|
||||
try? fm.removeItem(at: item)
|
||||
} else {
|
||||
moveToLegacyName(item, in: destination)
|
||||
}
|
||||
continue
|
||||
}
|
||||
try? fm.moveItem(at: item, to: target)
|
||||
}
|
||||
}
|
||||
|
||||
private static func moveToLegacyName(_ item: URL, in destination: URL) {
|
||||
let fm = FileManager.default
|
||||
let base = item.lastPathComponent + ".legacy"
|
||||
var target = destination.appendingPathComponent(base)
|
||||
var suffix = 2
|
||||
while fm.fileExists(atPath: target.path) {
|
||||
target = destination.appendingPathComponent("\(base).\(suffix)")
|
||||
suffix += 1
|
||||
}
|
||||
try? fm.moveItem(at: item, to: target)
|
||||
}
|
||||
|
||||
private static func copyItem(at src: URL, into dir: URL, named name: String) {
|
||||
let scoped = src.startAccessingSecurityScopedResource()
|
||||
defer { if scoped { src.stopAccessingSecurityScopedResource() } }
|
||||
|
||||