mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-16 00:02:23 +02:00
Compare commits
192 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 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 | |||
| 9f54734f62 | |||
| eec396e388 | |||
| 1ef0d8c2c0 | |||
| 0f24105686 | |||
| d35b9e5c3e | |||
| ee5c168983 |
@@ -94,8 +94,8 @@ jobs:
|
|||||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: gen1recomp-ios-ipa
|
name: gen1recomp++-ios-ipa
|
||||||
path: dist/ios/gen1recomp.ipa
|
path: dist/ios/gen1recomp++.ipa
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
- name: clean up signing keychain
|
- name: clean up signing keychain
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ jobs:
|
|||||||
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
|
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||||
HEAD_REPOSITORY: ${{ github.event.workflow_run.head_repository.full_name }}
|
HEAD_REPOSITORY: ${{ github.event.workflow_run.head_repository.full_name }}
|
||||||
run: |
|
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
|
[ -n "$artifact_id" ] || exit 0
|
||||||
head_owner="${HEAD_REPOSITORY%%/*}"
|
head_owner="${HEAD_REPOSITORY%%/*}"
|
||||||
pr_number="$(gh api "repos/$GITHUB_REPOSITORY/pulls?state=open&head=$head_owner:$HEAD_BRANCH" --jq '.[0].number // empty')"
|
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
|
uses: thollander/actions-comment-pull-request@v3
|
||||||
with:
|
with:
|
||||||
message: |
|
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 }})
|
**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 }}`
|
**Build Time**: `${{ steps.build-info.outputs.time }}`
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
name: Release
|
name: Release
|
||||||
|
|
||||||
# Builds the macOS, Windows, and Linux desktop apps, an Android APK, an iOS
|
# 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
|
# IPA, a Nintendo Switch SD-ready zip (experimental), Xbox UWP, the Anbernic
|
||||||
# RG34XXSP (Stock OS 64-bit MOD / PortMaster) port, then publishes them as a
|
# 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.
|
# GitHub Release.
|
||||||
#
|
#
|
||||||
# Versioning:
|
# Versioning:
|
||||||
@@ -251,8 +252,40 @@ jobs:
|
|||||||
Remove-Item $env:UWP_PFX -Force -ErrorAction SilentlyContinue
|
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:
|
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"') }}
|
runs-on: ${{ fromJSON(github.repository == 'bryanthaboi/gen1recomp' && '["self-hosted", "macOS"]' || '"macos-latest"') }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -268,6 +301,12 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
fetch-tags: true
|
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
|
- name: Import signing certificate into a temporary keychain
|
||||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||||
run: |
|
run: |
|
||||||
@@ -306,13 +345,21 @@ jobs:
|
|||||||
security find-identity -v -p codesigning "$KEYCHAIN_PATH"
|
security find-identity -v -p codesigning "$KEYCHAIN_PATH"
|
||||||
|
|
||||||
- name: Build macOS + Windows + Linux
|
- name: Build macOS + Windows + Linux
|
||||||
|
env:
|
||||||
|
GEN1TLS_DLL: ${{ github.workspace }}/dist/native/win-x64/gen1tls.dll
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
# Sign in-build (identity auto-detected from the temp keychain);
|
# Sign in-build (identity auto-detected from the temp keychain);
|
||||||
# notarize separately below so it uses secret credentials, not a
|
# notarize separately below so it uses secret credentials, not a
|
||||||
# login-keychain profile. "all" also builds the Linux AppImage,
|
# login-keychain profile. "all" also builds the Linux AppImage,
|
||||||
# which needs no signing/notarization.
|
# 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
|
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
|
- name: Build Android
|
||||||
run: |
|
run: |
|
||||||
@@ -355,6 +402,20 @@ jobs:
|
|||||||
# runtime from PortMaster-GUI, so it needs no signing/notarization.
|
# runtime from PortMaster-GUI, so it needs no signing/notarization.
|
||||||
./build-rg34xxsp.sh --version "${{ needs.version.outputs.version }}"
|
./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
|
- name: Notarize & staple macOS app
|
||||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||||
run: |
|
run: |
|
||||||
@@ -429,9 +490,9 @@ jobs:
|
|||||||
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; }
|
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; }
|
||||||
cp "$apk" "$outdir/gen1recomp-${v}-android.apk"
|
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; }
|
[ -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"
|
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; }
|
[ -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; }
|
[ -f "$rg34" ] || { echo "::error::$rg34 not found (expected from ./build-rg34xxsp.sh)"; exit 1; }
|
||||||
cp "$rg34" "$outdir/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip"
|
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
|
# Platform-independent update payload, built alongside the desktop
|
||||||
# apps above (same game.love that gets fused into each of them).
|
# apps above (same game.love that gets fused into each of them).
|
||||||
love_file=".bazinga/work/game.love"
|
love_file=".bazinga/work/game.love"
|
||||||
@@ -560,10 +626,11 @@ jobs:
|
|||||||
"dist/release/gen1recomp-${v}-linux.zip"
|
"dist/release/gen1recomp-${v}-linux.zip"
|
||||||
"dist/release/gen1recomp-${v}-linux-arm64.AppImage"
|
"dist/release/gen1recomp-${v}-linux-arm64.AppImage"
|
||||||
"dist/release/gen1recomp-${v}-android.apk"
|
"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}-switch.zip"
|
||||||
"dist/release/gen1recomp-${v}-xbox-uwp.zip"
|
"dist/release/gen1recomp-${v}-xbox-uwp.zip"
|
||||||
"dist/release/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip"
|
"dist/release/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip"
|
||||||
|
"dist/release/gen1recomp-${v}-sbc-portmaster.zip"
|
||||||
"dist/release/gen1recomp-${v}.love"
|
"dist/release/gen1recomp-${v}.love"
|
||||||
"dist/release/sha256sums.txt"
|
"dist/release/sha256sums.txt"
|
||||||
)
|
)
|
||||||
@@ -581,14 +648,15 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
v="${{ needs.version.outputs.version }}"
|
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"
|
app_repo="mobile/ios/app-repo.json"
|
||||||
[ -f "$ipa" ] || { echo "::error::$ipa not found"; exit 1; }
|
[ -f "$ipa" ] || { echo "::error::$ipa not found"; exit 1; }
|
||||||
[ -f "$app_repo" ] || { echo "::error::$app_repo not found"; exit 1; }
|
[ -f "$app_repo" ] || { echo "::error::$app_repo not found"; exit 1; }
|
||||||
|
|
||||||
date="$(date -u +"%Y-%m-%d")"
|
date="$(date -u +"%Y-%m-%d")"
|
||||||
size="$(wc -c < "$ipa" | tr -d '[:space:]')"
|
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"
|
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)"
|
release_notes="$(GH_TOKEN="${{ github.token }}" gh release view "v${v}" --json body --jq '.body // ""' 2>/dev/null || true)"
|
||||||
if [ -n "$release_notes" ]; then
|
if [ -n "$release_notes" ]; then
|
||||||
@@ -602,15 +670,15 @@ jobs:
|
|||||||
--argjson size "$size" \
|
--argjson size "$size" \
|
||||||
'{version: $version, date: $date, size: $size, downloadURL: $download_url, localizedDescription: $localized_description}')"
|
'{version: $version, date: $date, size: $size, downloadURL: $download_url, localizedDescription: $localized_description}')"
|
||||||
|
|
||||||
if jq -e --arg version "$v" \
|
if jq -e --arg bundle_id "$bundle_id" --arg version "$v" \
|
||||||
'any(.apps[] | select(.bundleIdentifier == "com.theboisclub.gen1recomp").versions[]?; .version == $version)' \
|
'any(.apps[] | select(.bundleIdentifier == $bundle_id).versions[]?; .version == $version)' \
|
||||||
"$app_repo" >/dev/null; then
|
"$app_repo" >/dev/null; then
|
||||||
jq --arg version "$v" --argjson entry "$entry" \
|
jq --arg bundle_id "$bundle_id" --arg version "$v" --argjson entry "$entry" \
|
||||||
'(.apps[] | select(.bundleIdentifier == "com.theboisclub.gen1recomp").versions) |= map(if .version == $version then $entry else . end)' \
|
'(.apps[] | select(.bundleIdentifier == $bundle_id).versions) |= map(if .version == $version then $entry else . end)' \
|
||||||
"$app_repo" > "$app_repo.tmp"
|
"$app_repo" > "$app_repo.tmp"
|
||||||
else
|
else
|
||||||
jq --argjson entry "$entry" \
|
jq --arg bundle_id "$bundle_id" --argjson entry "$entry" \
|
||||||
'(.apps[] | select(.bundleIdentifier == "com.theboisclub.gen1recomp").versions) |= [$entry] + .' \
|
'(.apps[] | select(.bundleIdentifier == $bundle_id).versions) |= [$entry] + .' \
|
||||||
"$app_repo" > "$app_repo.tmp"
|
"$app_repo" > "$app_repo.tmp"
|
||||||
fi
|
fi
|
||||||
mv "$app_repo.tmp" "$app_repo"
|
mv "$app_repo.tmp" "$app_repo"
|
||||||
|
|||||||
+18
-1
@@ -3,8 +3,9 @@
|
|||||||
data/generated/
|
data/generated/
|
||||||
assets/generated/
|
assets/generated/
|
||||||
|
|
||||||
# LÖVE packages
|
# LÖVE packages & archives
|
||||||
*.love
|
*.love
|
||||||
|
*.zip
|
||||||
|
|
||||||
# Local saves (LÖVE writes to its save dir, but keep the repo clean anyway)
|
# Local saves (LÖVE writes to its save dir, but keep the repo clean anyway)
|
||||||
save/
|
save/
|
||||||
@@ -68,3 +69,19 @@ mobile/ios/bundle_id.local
|
|||||||
/ports/uwp/build/
|
/ports/uwp/build/
|
||||||
/ports/uwp/third_party/*/source/
|
/ports/uwp/third_party/*/source/
|
||||||
/ports/uwp/third_party/angle/depot_tools/
|
/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/
|
||||||
|
|||||||
+75
-3
@@ -242,10 +242,82 @@ 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
|
line, for every reach a static scan could not follow. Neither substitutes for a
|
||||||
real Gold boot.
|
real Gold boot.
|
||||||
|
|
||||||
### 5. `mod.card`
|
### 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,
|
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
|
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
|
merge — only by tooling and the manager's detail pane — so an absent or
|
||||||
malformed card can never break a load.
|
malformed card can never break a load.
|
||||||
@@ -263,7 +335,7 @@ Two fields deserve their own note:
|
|||||||
distributed mod never carries ROM-derived bytes, not even in its preview
|
distributed mod never carries ROM-derived bytes, not even in its preview
|
||||||
images.
|
images.
|
||||||
|
|
||||||
### 6. Tags
|
### 7. Tags
|
||||||
|
|
||||||
Lowercase kebab strings, open vocabulary. The showcase generator
|
Lowercase kebab strings, open vocabulary. The showcase generator
|
||||||
lowercases and de-dupes. A recommended starting set: `beginner`,
|
lowercases and de-dupes. A recommended starting set: `beginner`,
|
||||||
|
|||||||
@@ -1,457 +0,0 @@
|
|||||||
# KANTO-CONTINUE: executing the never-run half of Gold (sections 19-32)
|
|
||||||
|
|
||||||
Deferred from the 2026-08-09 fix campaign by request. This doc is self-contained: the
|
|
||||||
audit documents it was distilled from are being retired, so everything a future session
|
|
||||||
needs is here.
|
|
||||||
|
|
||||||
## What this is
|
|
||||||
|
|
||||||
The Johto main line (walkthrough sections 00-18) is proven: the route bot beats Champion
|
|
||||||
Lance with every row passing. Kanto (sections 19-32) is extracted and wired but has
|
|
||||||
NEVER been executed by any bot row, driver, or human. Every entry below reads clean in
|
|
||||||
static review (cache + code + cart source were cross-checked adversarially); the risk is
|
|
||||||
purely runtime. The job: make each beat actually run, fix what breaks, and leave
|
|
||||||
executable evidence (bot rows or assertion drivers) behind.
|
|
||||||
|
|
||||||
## State after the 2026-08-09 campaign (what changed under you)
|
|
||||||
|
|
||||||
- ~80 defects fixed across the port; 91/91 gen2 suites, engine 128/128, T3 all green.
|
|
||||||
- The post-game handoff WORKS now: post-E4 credits end at the title screen, and
|
|
||||||
CONTINUE consumes the post-game spawn (HallOfFame.consumePostGameSpawn): after Lance
|
|
||||||
you continue in NEW_BARK_TOWN, after Red at SILVER_CAVE_OUTSIDE (23,20). Section 19
|
|
||||||
is reachable by normal play for the first time.
|
|
||||||
- The Snorlax wake chain is fully functional (engine-flag bridge unlocks the Pokegear
|
|
||||||
and EXPN card; the tuned radio song persists after closing the gear; SnorlaxAwake
|
|
||||||
fires; proven live by tests/drivers/gold_radio_persist.lua).
|
|
||||||
- Landed systems Kanto beats depend on: phone random/outgoing calls, field item use
|
|
||||||
(Escape Rope/Dig via save.backupWarp), Repel, swarms, Rock Smash encounters,
|
|
||||||
waterfall current tiles, roamer scatter on CONTINUE, trap volatiles + FORCESHINY
|
|
||||||
battle types (beast catching in section 32 is now mechanically possible), badge
|
|
||||||
boosts, held items, specialty balls (Lure Ball needs battleType 'fish', already
|
|
||||||
stamped by updateFishing).
|
|
||||||
- NPC HOUR WINDOWS are now enforced (they were not when the Johto route was authored).
|
|
||||||
Any Kanto row that talks to a time-gated NPC must pin the clock (POKEPORT_GOLD_HOUR /
|
|
||||||
POKEPORT_GOLD_DAY) or handle absence. Only 11 hour-gated objects exist on the whole
|
|
||||||
cart: CELADON_GAME_CORNER 6/7, GOLDENROD_GAME_CORNER 4/5, MOUNT_MOON_GIFT_SHOP 1-4,
|
|
||||||
and the three Moms in PLAYERS_HOUSE_1F; Celadon and Mt Moon are the Kanto exposures.
|
|
||||||
- The game clock now anchors on every new game (cart InitClock semantics), so time of
|
|
||||||
day no longer follows the host wall clock in driver runs; POKEPORT_GOLD_HOUR pins
|
|
||||||
the anchored base too. Runs are reproducible across day/night; a pre-existing flake
|
|
||||||
class (night runs catching HOOTHOOT where day runs catch PIDGEY, shifting grind
|
|
||||||
levels) died with it.
|
|
||||||
- Battles hit harder both ways since the cart MIN_DAMAGE floor landed; grind budgets
|
|
||||||
tuned for the old numbers may need a nudge.
|
|
||||||
|
|
||||||
## Known bot weaknesses to expect (verified pre-existing, not campaign regressions)
|
|
||||||
|
|
||||||
- Pathfinding pocket: travel into ILEX_FOREST / SLOWPOKE_WELL bounces (probe
|
|
||||||
CHERRYGROVE_CITY>ILEX_FOREST reports no route), and route row 16.54 gives up the
|
|
||||||
walk to VICTORY_ROAD_GATE with the identical TELEPORT signature in every logged run.
|
|
||||||
Kanto routing should waypoint doors explicitly per the ops recipes above.
|
|
||||||
- Optional buy rows 04.5d and 18.g4/18.g5 fail whenever the wallet is empty at that
|
|
||||||
point in the run; they are budgeting gaps in the route, not engine bugs.
|
|
||||||
- Grind row 08.g (Route 41 water) has almost no margin; with the clock now anchored
|
|
||||||
its inputs are stable, but a low-level POLIWAG catch can still stretch it.
|
|
||||||
|
|
||||||
## How to run things
|
|
||||||
|
|
||||||
Identity/cache: gold-dev (~/Library/Application Support/LOVE/gold-dev/gold), rebuilt
|
|
||||||
2026-08-09 with swarm/rock/roam tables. Checkpoints gold-ckpt-NN.lua exist per section;
|
|
||||||
gold-ckpt-18-pristine.lua is the post-E4 seed. ROM: ../decprep/Pokemon - Gold
|
|
||||||
Version.gbc. Cart source: ../pokegold (cite it in any hand-ported code).
|
|
||||||
|
|
||||||
Headless love needs a pty on macOS; wrap every run:
|
|
||||||
perl -e 'alarm 900; exec @ARGV' python3 -c "import pty; pty.spawn(['love','.'])"
|
|
||||||
|
|
||||||
Full bot run: POKEPORT_IDENTITY=<id> POKEPORT_GAME=gold POKEPORT_SPEED=200 \
|
|
||||||
POKEPORT_GOLD_CKPT=1 POKEPORT_GOLD_STALL=20000 POKEPORT_GOLD_LOG=/tmp/b.log \
|
|
||||||
POKEPORT_DRIVER=tests/drivers/gold_bot.lua love .
|
|
||||||
Resume a section: POKEPORT_GOLD_RESUME=NN (same identity, prior CKPT=1 run).
|
|
||||||
Travel probe: POKEPORT_GOLD_PROBE="MAP_A>MAP_B" with tests/drivers/gold_travel_probe.lua.
|
|
||||||
Map graph: luajit tools/goldwalk/mapgraph.lua path|map|reach|audit; regenerate
|
|
||||||
tests/drivers/gold/map_regions.lua with `mapgraph.lua graph >` after ANY extractor map
|
|
||||||
change, then re-check route region indices.
|
|
||||||
Route validation (before any run): luajit tests/gold_route_validate_test.lua and
|
|
||||||
luajit tests/gold_flag_names_test.lua.
|
|
||||||
Suites: GOLD_CACHE="$HOME/Library/Application Support/LOVE/gold-dev/gold" luajit
|
|
||||||
tests/gen2_<name>_test.lua; tiers run_engine/run_modkit/run_tests.
|
|
||||||
Reimport after extractor/manifest changes: python3 tools/make_gold_manifest.py, then
|
|
||||||
POKEPORT_IMPORT_TRACE=1 POKEPORT_IDENTITY=gold-dev POKEPORT_IMPORT_ROM="../decprep/\
|
|
||||||
Pokemon - Gold Version.gbc" POKEPORT_IMPORT_ONLY=1 POKEPORT_FORCE_IMPORT=1 \
|
|
||||||
POKEPORT_GAME=gold love .
|
|
||||||
|
|
||||||
## Recommended shape of the work
|
|
||||||
|
|
||||||
1. Extend tests/drivers/gold/route.lua with sections 19-32 (the row format, ops and
|
|
||||||
evidence rules are documented in the file head and bot.lua). Seed runs from
|
|
||||||
gold-ckpt-18-pristine plus the now-working post-credits CONTINUE. `expect` flags are
|
|
||||||
the only oracles; never make a row load-bearing on a soft-failing map.
|
|
||||||
2. Highest-risk multi-map scripted chains first: S.S. Aqua voyage, Machine Part chain,
|
|
||||||
PASS/Magnet Train chain. Each deserves an assertion driver even if bot rows also
|
|
||||||
cover it.
|
|
||||||
3. Time-gated beats (S.S. Aqua sailing days, Indigo rival Mon/Wed, Dragon's Den
|
|
||||||
Tue/Thu, Clefairy Monday night, TM03 night gate) need POKEPORT_GOLD_DAY/HOUR pins.
|
|
||||||
4. Johto stragglers are folded in below (lighthouse descent, TM08 flake, and the
|
|
||||||
optional gift beats); they ride the same route-extension pass.
|
|
||||||
5. Section 32 (beasts) last: it needs long roaming play; trap volatiles and FORCESHINY
|
|
||||||
landed this campaign, so Mean Look + Heavy/Fast Ball plans are now viable.
|
|
||||||
|
|
||||||
## Also deferred (deliberate stubs, do not re-flag)
|
|
||||||
|
|
||||||
Time Capsule trading, Trainer House CAL2/Mystery Gift visitor, Silver-version import,
|
|
||||||
Cable Club link rooms, and Game Boy Printer output are deliberate product-level stubs,
|
|
||||||
not campaign work.
|
|
||||||
|
|
||||||
## The beats, in walkthrough order
|
|
||||||
|
|
||||||
### Runtime coverage ends at the Hall of Fame: no bot row, driver, or human has run any Kanto beat (sections 19-32) or the section 14 Tin Tower/Whirl Islands content
|
|
||||||
|
|
||||||
walkthrough section(s) 19,20,21,22,23,24,27,28,29,30,31,14 | missing
|
|
||||||
|
|
||||||
One root cause: tests/drivers/gold/route.lua (1264 lines) ends at row 18.20 (settle HALL_OF_FAME, expect EVENT_BEAT_ELITE_FOUR); the campaign covers asm-walk sections 00-18 only and none of the 24/36 gold_* drivers reaches any Kanto map, so sections 19-32 have zero execution proof. Extend route.lua into sections 19-32 and/or add targeted drivers. Per section pair: (s19/20) next rows are the Elm ticket (needs the post-credits spawn fix or a fly detour from Indigo), the ship crossing as far as the docking events, the Surge fight, and a Saffron Gym pad-chain assertion. (s21/22) All 24 route/gym trainers resolve in cache with correct parties: Route 9 six (Edna, Sid, Dean, Sidney, Tim, Heidi); Route 25 seven plus Kevin, whose Nugget-then-battle script 50:488d is hand-shaped (a full bag aborts before the battle, cart behavior); Cerulean's three swimmers; Route 10 South's Jim and Robert (Robert's Quagsire holds a BERRY via TRAINERTYPE_ITEM); Route 8's five; Celadon Gym's five including the twins' shared beat flag. Sight-line engagement is Trainers.sees (proven by the Johto bot); prize money base x level x 4 is Prize.lua; Erika's class item extracts as HYPER_POTION (the walkthrough's 'three Full Restores' is a FAQ error, not a port gap); Gold-only wilds (Mankey line Route 9, Growlithe Routes 7/8) come from the ROM version branch; Route 8's PRZCureBerry fruit tree is a fruittree op (Vm.lua:1218) into Apricorns.lua daily reset. One Kanto smoke-run converts the whole block. (s23/24) Nothing touches Routes 11-19, Fuchsia City/gym, Vermilion Snorlax, Diglett's Cave, Route 2, Pewter; the report's three blocker findings (START menu row, Pokegear card flags, radio persistence) are exactly the UI-plumbing breaks data audits miss: a gold_snorlax_wake.lua that grants the gear and EXPN card the way the game does (setflag 4, setflag 0, setflag 3 through the VM, not by poking save fields), tunes the radio, closes the gear, and talks to the Snorlax would currently fail at three separate steps; gold_cycling_road.lua and gold_fuchsia_gym.lua are the other two cheap proofs (gold_menu_shots.lua:72-73 only ever hand-seeded save fields). (s27/28) Drive first: (1) Cinnabar Blue talk -> disappear -> clearevent 1910 -> Viridian Gym populated -> Blue beaten -> ENGINE_EARTHBADGE set -> VAR_BADGES answers 16 (a break anywhere makes the 16th badge unobtainable and the Mt. Silver gate unpassable); cache chain exists: CINNABAR_ISLAND obj 1 SPRITE_BLUE at (9,6) scriptKey 4e:4985 eventFlag 1909; VIRIDIAN_GYM objs 1-2 gated on 1910; (2) the ROUTE_20 MAPCALLBACK_NEWMAP 4e:4cfa setevent 215 / ROUTE_19 MAPCALLBACK_TILES 4e:4f09 changeblock unseal pair (regression walls off Route 19). Driver: surf Pallet -> Route 21 -> Cinnabar -> talk to Blue -> Route 20 -> beat Blaine -> fly to Viridian -> beat Blue. (s29/30) No rows for Kanto OAKS_LAB, ROUTE_22, ROUTE_28, SILVER_CAVE_OUTSIDE, or the Union Cave B2F Lapras revisit (route.lua's only Union Cave rows are 03.29/04.g); risk concentrates on composites never run together: the 16-badge Oak visit with ProfOaksPCBoot text chain, the double Cut approach to the Route 28 west strip, surf-to-talk on the drifting SWIM_WANDER Lapras (Lake of Rage Gyarados proves the shape via gold_lake_probe.lua), and the daily-flag consume-and-respawn cycle across a save reload; a targeted gold_lapras_probe driver (clockDay=5, walk in, assert object present, catch, walk out, re-enter, assert gone) plus route extension converts inspected to proven. (s31) All six Silver Cave maps decode (warps, item balls flags 1689-1693, hidden items, SILVER_CAVE_OUTSIDE flypoint callback, PALETTE_DARK on Room 1, grass/water tables including Room 2 water); Flash lifts PALETTE_DARK via Palettes.isDarkness + World flashUsed; Surf/Waterfall live in FieldMoves with the badge table; gen2 ledges landed via Permissions.ledgeFacings / World:tryLedgeJump (the asm-walk's 'ledge hop missing' row is stale, GOLD-NEXT-RUN records the fix); the flypoint is FieldMoves.lua:386 flag 75; but no driver has entered Silver Cave, used Waterfall on Room 2's two bands, hopped Room 1's Escape Rope ledge, or talked to Red (closest: gold_halloffame_shots, gold_hm07_probe which proved Waterfall only at Tohjo Falls). (s14) The bot covers sections 10,11,12,13,16,17,18 and never section 14 (Ho-Oh is optional); untested: the 4F-9F ladder/hop mazes (depend on the recently added ledge-hop and one-way-wall support), the Ecruteak entrance sage scene retarget from Morty's setmapscene, the 1F sage unmasking off EVENT_TEAM_ROCKET_DISBANDED (set on the Gold arm of the Radio Tower boss script), the roof save/reset loop, and the entire Gold-side Whirl Islands dive; needs one probe driver carrying a RAINBOW_WING save up the tower and one diving to the Lugia chamber.
|
|
||||||
|
|
||||||
*How to prove it:* Extend tests/drivers/gold/route.lua past row 18.20 into sections 19-32, and/or write the named targeted drivers: gold_snorlax_wake.lua, gold_cycling_road.lua, gold_fuchsia_gym.lua, a gold_lapras_probe (clockDay=5 present/absent cycle), a Cinnabar-to-Viridian 16th-badge driver, a Route 20/19 unseal driver, a Silver Cave/Red driver, a Tin Tower RAINBOW_WING probe, and a Whirl Islands Lugia dive probe.
|
|
||||||
|
|
||||||
*Notes:* Merged from the parent heading plus six 'Also filed as' sub-findings (all one cause). Doc's own ratings: parent missing/minor (s19,20, already in ledger); s21,22 unverified/minor; s23,24 unverified/minor; s27,28 missing/major; s29,30 unverified/minor; s31 unverified/minor; s14 unverified/minor. The adversarial re-check ran on the s27/28 sub-claim, tried to refute and could not; its one correction: tests/drivers/gold/flag_names.lua and map_regions.lua DO name the Kanto maps (map_regions even carries CINNABAR/SEAFOAM/VIRIDIAN region graphs, so bot travel there is data-ready) but they are lookup tables, not executed coverage. 'Implemented statically, executed by nobody' matches the project's Roamers/Events:restore failure mode. GOLD-WALK-HANDOFF sections 1 and 4 already document the Johto-only scope.
|
|
||||||
|
|
||||||
|
|
||||||
### Olivine Port pier and Fast Ship boarding gate: the only road into Kanto, fully extracted, never executed
|
|
||||||
|
|
||||||
walkthrough section(s) 19 | unverified
|
|
||||||
|
|
||||||
Coord event at (7,15) (cache 5b:407d) does the full cart sequence: temporary-event bails, first-time bypass on event 48, readvar VAR_WEEKDAY branch, yesorno, checkitem S_S_TICKET (item 68), setevent 1, 7-step applymovement, sjump into gangway script 5b:401e which plays SFX, disappears/appears the sailor, runs special 46 = FadeOutToWhite (specialOrder[46+1], implemented Specials.lua:998), clears the eight eastbound EVENT_BEAT_* rematch flags on repeat trips, sets FAST_SHIP_1F scene 1, and warps to (25,1). Port sprite toggles for the post-Hall-of-Fame sailor (events 1847/1848) are armed by the HOF script. All data extracted, every op has a proven Johto call site; matched op-for-op against pokegold maps/OlivinePort.asm. Needs one live run or driver.
|
|
||||||
|
|
||||||
*How to prove it:* A driver or bot section that reaches OLIVINE_PORT post-HOF with the S_S_TICKET, boards, and asserts the warp to FAST_SHIP_1F (25,1) with scene 1 set. No gold_* driver currently reaches OLIVINE_PORT post-HOF.
|
|
||||||
|
|
||||||
*Notes:* Doc rating: unverified/blocker, new. If this chain fails, the walkthrough stops here (Kanto unreachable).
|
|
||||||
|
|
||||||
|
|
||||||
### S.S. Aqua maiden voyage: trainer gauntlet, lazy sailor, granddaughter, Metal Coat, docking; extracted end to end, never run
|
|
||||||
|
|
||||||
walkthrough section(s) 19 | unverified
|
|
||||||
|
|
||||||
Verified in cache, never executed: FastShip1F enter scene 5b:48a8 sdefer 5b:48ad (SFX_BOAT, earthquake 30, blackoutmod to cabin map, clearevent 49, scene 2 on first trip); grandpa bump coord events at (24,6)/(25,6); maiden-trip trainer objects behind flag 1849 with parties matching data/trainers/parties.asm exactly (NOLAND Sandslash31/Golem33, LYLE Koffing28/Flareon31/Koffing28, COLIN Delibird32 holding BERRY, MEG&PEG, FRITZ Mr.Mime/Magmar/Machoke, JEFF 2x Raticate32, DEBRA Seaking33, STANLY Machop31/Machoke33/Psyduck26, all in trainers.lua); B1F blocking-sailor coord scripts 5b:5e0c/5b:5e21 plus shared talk script 5b:5e37 clearing event 1837 to spawn the lazy sailor; FastShipLazySailorScript 5b:4d68 (playmusic, loadtrainer SAILOR 9, reloadmap, special 27 = HealParty, setmapscene B1F NOOP, readvar VAR_FACING walk-out); bed script 5b:521b (HealParty, fades, ReloadSpritesNoPalettes, RestartMapMusic, dock check on events 49/50/48 so sleeping cannot dock the maiden voyage); granddaughter payoff 5b:55c6 (fade, disappear/moveobject/appear teleport, showemote, sjump 5b:5633 = Metal Coat verbosegiveitem plus EVENT_FAST_SHIP_HAS_ARRIVED/FOUND_GIRL); door guard 5b:48d2 with arrived arm 5b:48ec (scall facing arm, setmapscene VERMILION_PORT scene 1, warp to VERMILION_PORT 7,17). Specials 27/46/47/49/50/60 resolve to HealParty/FadeOutToWhite/FadeOutToBlack/FadeInFromBlack/ReloadSpritesNoPalettes/RestartMapMusic, all implemented. Nothing in the chain touches a stubbed special, callasm, or VAR. Matched against pokegold maps/FastShip*.asm.
|
|
||||||
|
|
||||||
*How to prove it:* A bot section or driver for the full crossing (board, fight or dodge the gauntlet, sleep check, granddaughter Metal Coat, dock at VERMILION_PORT 7,17).
|
|
||||||
|
|
||||||
*Notes:* Doc rating: unverified/blocker, new. If any link breaks, arrival in Kanto is impossible.
|
|
||||||
|
|
||||||
|
|
||||||
### Machine Part quest chain (Power Plant manager, Cerulean Gym grunt scene, Route 24 grunt, part return, TM07) never executed
|
|
||||||
|
|
||||||
walkthrough section(s) 21,22 | unverified
|
|
||||||
|
|
||||||
The spine of sections 21-22: manager talk sets EVENT_MET_MANAGER_AT_POWER_PLANT 202, clears EVENT_CERULEAN_GYM_ROCKET 1901 and EVENT_FOUND_MACHINE_PART_IN_CERULEAN_GYM 251, arms CERULEAN_GYM scene 1 and the POWER_PLANT guard phone-call scene; grunt runs out of the gym (sets 203, clears 1900/1902, arms ROUTE_25 scene 1); beat the Route 24 grunt (GRUNTM 31, L30 Golbat); return the part for TM07 Zap Cannon plus EVENT_RESTORED_POWER_TO_KANTO 205 and the Saffron station population flip (clears 1906). Statically verified: cache holds full bytecode for PowerPlantManager 54:4dbd with both branches (54:4deb takeitem MACHINE_PART / 54:4e04 TM give guarded by event 223), gym scene script 54:4332 sdefer 54:4336 with all 34 rows, Route 24 grunt script 50:4407; initial_events.lua seeds flags 251/1900/1901/1902/1903/1906/1907 at new game exactly as InitializeEventsScript does (pokegold engine/events/std_scripts.asm:546-550); World.lua:607 applies the seed while EVENT_INITIALIZED_EVENTS is clear. Engine deps all have call sites: World:trySceneScript (World.lua:5369, called at :6893), setmapscene/setscene (Vm.lua:274-284), specials RestartMapMusic/FadeOutToBlack/FadeInFromBlack/ReloadSpritesNoPalettes/FadeOutMusic (Specials.lua:999-1072). Missing only run-time proof: route bot covers sections 00-18, no Kanto driver exists.
|
|
||||||
|
|
||||||
*How to prove it:* Route extension or a driver walking manager -> gym grunt scene -> Route 24 grunt battle -> part return, asserting events 202/203/205 set and 1900/1901/1902/251/1906 cleared and TM07 in bag.
|
|
||||||
|
|
||||||
*Notes:* Doc rating: unverified/blocker, new.
|
|
||||||
|
|
||||||
|
|
||||||
### Route 25 Misty date scene, Cerulean gym population, and Misty fight never executed (and Misty gives no TM, contrary to walkthrough)
|
|
||||||
|
|
||||||
walkthrough section(s) 21 | unverified
|
|
||||||
|
|
||||||
The date cutscene at Route 25 cells (42,6)/(42,7) is what clears EVENT_TRAINERS_IN_CERULEAN_GYM 1903 and populates the gym with Misty, three swimmers, and the guide; without it Misty never appears and the Cascade Badge is unreachable. Cache has both coord events with sceneId=1 (scripts 18305/18369) and full scripts 50:4781/50:47c1 (34-row cutscene: heart emote, boyfriend flees, Misty approach movement, clearevent 1903, setscene 0, special 60=RestartMapMusic), armed by gym grunt script 54:4336 row 27 (setmapscene group=7 map=16 scene=1). Engine deps: World:tryCoordScript (World.lua:5006) filters on the map scene id; mapScenes persist on the save (GOLD-INDEX tier note on World:loadPlayerData). CeruleanGymMistyScript 54:438a is in the cache; MISTY trainer 1 party (L42 Golduck, L42 Quagsire, L44 Lapras, L47 Starmie with full movesets) extracts correctly; baseMoney 25 so the 4700G payout follows from Prize.reward.
|
|
||||||
|
|
||||||
*How to prove it:* Driver: arm the scene via the gym grunt, trigger the date coord event at (42,6)/(42,7), assert 1903 cleared and the gym populated, then fight Misty and assert the Cascade Badge and 4700G.
|
|
||||||
|
|
||||||
*Notes:* Doc rating: unverified/blocker, new. Correction embedded in the doc: Misty gives NO TM on the Gold cart, only the badge plus retroactive swimmer beat flags; the walkthrough and the lead's 'TM from Misty' are wrong about the cart, nothing is missing in the port there.
|
|
||||||
|
|
||||||
|
|
||||||
### PASS quest: Copycat lost item and Fan Club doll chain fully extracted, never run
|
|
||||||
|
|
||||||
walkthrough section(s) 22 | unverified
|
|
||||||
|
|
||||||
Copycat (COPYCATS_HOUSE_2F object 1, variable sprite slot 251) talk after the machine part return sets EVENT_MET_COPYCAT_FOUND_OUT_ABOUT_LOST_ITEM 206; Fan Club Clefairy guy (POKEMON_FAN_CLUB object 3, script 59:437b) then gives LOST_ITEM 130 and disappears the doll (flag 1908, correctly NOT in the initial seed; doc evidence line also cites POKEMON_FAN_CLUB object 5 flag 1908); returning it (Copycat branch 61:528b: takeitem 130, setevent 208, clearevent 1907 to re-show her shelf doll, then PASS 134 via 61:529d) yields the PASS. All bytecode in cache (61:5235/61:5270/61:528b); the mimicry gag's variablesprite slot 11 plus special 93 resolves to LoadUsedSpritesGFX (specialOrder[94], Specials.lua:1033); the Copycat's variable sprite slot is seeded through World.initialSprites/findInitialSprites (World.lua:1530, the fix GOLD-INDEX credits for the Copycat existing at all). Flag 1907 is in the initial seed so the shelf doll is hidden until the return; 1906 is seeded so the Saffron station stays empty until power is restored.
|
|
||||||
|
|
||||||
*How to prove it:* Driver: after machine-part return, talk to Copycat (assert 206), get LOST_ITEM at the Fan Club (assert doll flag 1908 set), return it (assert 208 set, 1907 cleared, PASS 134 in bag).
|
|
||||||
|
|
||||||
*Notes:* Doc rating: unverified/major, new. Correction embedded: the walkthrough's 'Rail Pass then Magnet Train Pass' is one item (PASS) on the cart, not a port gap. Merge-with hint: the s20 entry 'Copycat lost doll quest chain toward the rail PASS' (copycat-doll-pass-s20) describes the same quest from the section-20 audit with slightly different event ids cited (210/201 gate checks there vs 206/208 here).
|
|
||||||
|
|
||||||
|
|
||||||
### Magnet Train ride wired end to end; arrival behavior needs a human eye, never ridden since
|
|
||||||
|
|
||||||
walkthrough section(s) 22 | unverified
|
|
||||||
|
|
||||||
SaffronMagnetTrainStationOfficerScript (cache 61:4bc2/61:4bd0, rows 11-14) checks EVENT_RESTORED_POWER_TO_KANTO 205 then checkitem PASS 134, does setval 1, special id=35 which resolves to MagnetTrain (specialOrder[36]), then warpcheck and newloadmap 249 (MAPSETUP_TRAIN). All implemented: H.MagnetTrain (Specials.lua:2119) reads scriptVar for direction and blocks on World:magnetTrain (World.lua:2393) which pushes Gen2MagnetTrainRide; Vm handles warpcheck (:1135) and newloadmap (:1177); MAPSETUP_TRAIN defined at World.lua:175. Gating events (205 set by the Power Plant manager, PASS from the Copycat) are this section's other findings.
|
|
||||||
|
|
||||||
*How to prove it:* WHATS-NEXT item 8: a human rides both ways and presses a direction on arrival; the conversation must start on the first step because south is the only legal step out of the doorway cell. Nothing has run this since.
|
|
||||||
|
|
||||||
*Notes:* Doc rating: unverified/major, already named in the ledger.
|
|
||||||
|
|
||||||
|
|
||||||
### Silver Wing in Gold: Pewter City gramps script extracted, but Kanto (and the Whirl Islands dive) never run
|
|
||||||
|
|
||||||
walkthrough section(s) 14 | unverified
|
|
||||||
|
|
||||||
For a Gold cart, Lugia needs the SILVER_WING carried in the bag (WhirlIslandLugiaChamberLugiaCallback does checkevent EVENT_FOUGHT_LUGIA then checkitem SILVER_WING), and in Gold the wing comes from the Pewter City gramps (pokegold maps/PewterCity.asm PewterCityGrampsScript: checkver, Silver arm branches away, Gold falls through to verbosegiveitem SILVER_WING + setevent). Verified in cache: PEWTER_CITY object 3 (SPRITE_GRAMPS at 29,17) scriptKey 4d:583e = faceplayer / opentext / checkver / iftrue 4d:585c / checkevent 121 / verbosegiveitem item 71 / setevent 121 / closetext / end, all generic opcodes. The Lugia chamber map, its SPRITE_LUGIA object (scriptKey 47:41a0, eventFlag 1853), and its MAPCALLBACK_OBJECTS callback (47:418c) are in the cache using only implemented machinery. Route bot has no section 14/25 rows (grep 'id = "1[0-9]' shows 10,11,12,13,16,17,18). One mechanical gap on the dive path: the waterfall forced-down item, reported separately in the doc.
|
|
||||||
|
|
||||||
*How to prove it:* Driver or bot rows: reach Pewter in Gold, talk to the gramps (assert SILVER_WING item 71 given, event 121 set), then the full Whirl Islands dive to the Lugia chamber.
|
|
||||||
|
|
||||||
*Notes:* Doc rating: unverified/major, new. The waterfall forced-down item on the dive path is a separate finding elsewhere in the doc, not part of this entry.
|
|
||||||
|
|
||||||
|
|
||||||
### Vermilion Port arrival bookkeeping, Vermilion flypoint, and Kanto music never executed
|
|
||||||
|
|
||||||
walkthrough section(s) 19 | unverified
|
|
||||||
|
|
||||||
Arrival scene (VERMILION_PORT scene 1, sdefer 5b:450e) extracted intact: one step up, appear the gangway sailor, setscene 0, end-of-voyage flags (1841/1840 hide grandpa and granddaughter for good, 1849 retires the maiden-trip trainers, clear 1843 to reveal the Olivine passage Pokefan, set 48 EVENT_FAST_SHIP_FIRST_TIME), and blackoutmod VERMILION_CITY. ENGINE_FLYPOINT_VERMILION comes from MAPCALLBACK_NEWMAP on both VERMILION_PORT and VERMILION_CITY (setflag 57); the port's fly table carries the matching row (FieldMoves.lua:392) and World:runMapCallback has a live call site (World.lua:5659 plus the MAPCALLBACK_OBJECTS call in setMap). Fly deliberately stays region-locked exactly like the cart (FieldMoves.flyPoints, FieldMoves.lua:442: Kanto page only after SPAWN_INDIGO, no cross-region fly, verified against pokegold engine/pokegear/pokegear.asm .KantoFlyMap). Kanto music extracted: audio.lua songs has 92 entries, musicOrder includes Music_VermilionCity and Music_ViridianCity (musicOrder[63]=Music_VermilionCity), mapSongs carries all 362 map rows.
|
|
||||||
|
|
||||||
*How to prove it:* Driver: dock at Vermilion, assert flags 1841/1840/1849/48 set and 1843 cleared, flypoint flag 57 set on map entry, and Vermilion/Viridian city music playing.
|
|
||||||
|
|
||||||
*Notes:* Doc rating: unverified/major, new.
|
|
||||||
|
|
||||||
|
|
||||||
### Pokemon Fan Club chairman Rare Candy give never run
|
|
||||||
|
|
||||||
walkthrough section(s) 19 | unverified
|
|
||||||
|
|
||||||
Chairman script 59:4340: checkevent 212 (already heard), checkevent 211 (bag-full retry), yesorno where answering No gives nothing (cart behavior), two promptbutton speech pages, verbosegiveitem item 32 = RARE_CANDY, setevent 212. The cart quirk that nothing ever sets the bag-full retry event 211 is faithfully carried in the extracted bytecode. Same map also holds the Clefairy doll object (flag 1908) and Clefairy Guy script 59:437b that the later Copycat quest reads. yesorno, verbosegiveitem, and multi-page writetext all have proven Johto call sites. Matched against pokegold maps/PokemonFanClub.asm; RARE_CANDY present in items.lua.
|
|
||||||
|
|
||||||
*How to prove it:* Covered by any Kanto smoke-run that talks to the chairman and asserts RARE_CANDY given and event 212 set.
|
|
||||||
|
|
||||||
*Notes:* Doc rating: unverified/minor, new.
|
|
||||||
|
|
||||||
|
|
||||||
### Lt. Surge gym: trainers, Thunder Badge, AI item use fully extracted, never fought
|
|
||||||
|
|
||||||
walkthrough section(s) 19 | unverified
|
|
||||||
|
|
||||||
Surge script 59:4bfc: checkflag 36 re-fight guard, loadtrainer class 19 member 1, winlosstext, reloadmapafterbattle (whose loss-abort semantics were fixed during the Johto campaign), retroactive setevent of the three gym trainers, setflag 36 = ENGINE_THUNDERBADGE which routes to save.player.kantoBadges.THUNDER. Parties in trainers.lua match data/trainers/parties.asm exactly: Surge is RAICHU44/ELECTRODE40/MAGNETON40/ELECTRODE40/ELECTABUZZ46 with full TRAINERTYPE_MOVES movesets; Vincent, Horton, Gregory all match (GUITARIST 2/JUGGLER 3/GENTLEMAN 3). Trainer AI item use implemented: Ai.ITEM_ORDER/Ai.chooseItem (Ai.lua:1368-1400) dispatched from Battle.lua:1790 (AI_SwitchOrTryItem) off the extracted class attributes. The gym has no puzzle in Gen 2, matching the walkthrough's power-outage line.
|
|
||||||
|
|
||||||
*How to prove it:* Driver or route rows: fight the gym trainers and Surge, assert ENGINE_THUNDERBADGE (flag 36) set into save.player.kantoBadges.THUNDER and the AI HYPER_POTION use path exercised.
|
|
||||||
|
|
||||||
*Notes:* Doc rating: unverified/major, new. Correction embedded: the walkthrough's claim that Surge holds a Full Restore is a FAQ error; pokegold data/trainers/attributes.asm line 114 (block 113-117) gives the Lt Surge class HYPER_POTION and the cache carries exactly that (LT_SURGE items=HYPER_POTION, baseMoney 25).
|
|
||||||
|
|
||||||
|
|
||||||
### Route 6 Underground Path blocker and Saffron gate guard extracted, never walked
|
|
||||||
|
|
||||||
walkthrough section(s) 20 | unverified
|
|
||||||
|
|
||||||
The walkthrough's 'large man who blocks the entire entrance' is the ROUTE6_POKEFAN_M object at (17,4) whose event flag EVENT_ROUTE_5_6_POKEFAN_M_BLOCKS_UNDERGROUND_PATH hides him only when SET (set by the Power Plant script in a later section); the port's object-visibility rule 'flag set hides object' is implemented and documented at Events.lua:1-2, and his script is a bare jumptextfaceplayer about the Power Plant. The Route 6 Saffron gate guard at (0,4) stands off the walking lane and is dialogue only (branches on EVENT_RETURNED_MACHINE_PART). ROUTE_6 grass and water encounter tables (Abra/Magnemite slots) are present in encounters.lua. Nothing gated on engine work; it has just never been walked.
|
|
||||||
|
|
||||||
*How to prove it:* Covered by any Kanto walk through Route 6 before and after the Power Plant script sets the blocker flag.
|
|
||||||
|
|
||||||
*Notes:* Doc rating: unverified/minor, new.
|
|
||||||
|
|
||||||
|
|
||||||
### Mr. Psychic's TM29 Psychic give extracted verbatim, Kanto walk to the door unproven
|
|
||||||
|
|
||||||
walkthrough section(s) 20 | unverified
|
|
||||||
|
|
||||||
Cache 61:4b15: checkevent 227, writetext, promptbutton, verbosegiveitem item 221 = TM_PSYCHIC_M, iffalse full-bag fall-through WITHOUT setting the flag (cart behavior, re-talk after making room), setevent 227. TM_PSYCHIC_M is in items.lua with pocket TM_HM and price 2000; the four-pocket bag and TM teaching were exercised in the Johto run (TM08 ROCK SMASH teach passed on merit per GOLD-WALK-HANDOFF). Matched against pokegold maps/MrPsychicsHouse.asm.
|
|
||||||
|
|
||||||
*How to prove it:* Any Kanto run that walks to the house, talks, and asserts TM29 given and event 227 set (plus the full-bag retry arm).
|
|
||||||
|
|
||||||
*Notes:* Doc rating: unverified/minor, new.
|
|
||||||
|
|
||||||
|
|
||||||
### Silph Co. 1F Up-Grade give extracted, never run
|
|
||||||
|
|
||||||
walkthrough section(s) 20 | unverified
|
|
||||||
|
|
||||||
Cache 61:4f81: same shape as Mr. Psychic, verbosegiveitem item 172 = UP_GRADE guarded by event 222 with the full-bag fall-through. The officer at (13,1) has event flag -1 so he never moves, and SILPH_CO_1F has only the two street-door warps: there is no upstairs in Gen 2, so the 'man blocking the stairway' never opens, matching the cart (verified against pokegold maps/SilphCo1F.asm). Generic mechanisms only. UP_GRADE present in items.lua.
|
|
||||||
|
|
||||||
*How to prove it:* Any Kanto run that enters Silph Co. 1F and asserts UP_GRADE given behind event 222.
|
|
||||||
|
|
||||||
*Notes:* Doc rating: unverified/minor, new.
|
|
||||||
|
|
||||||
|
|
||||||
### Saffron Gym teleporter maze (30 same-map warp pads) never exercised; same-map warp chains untested anywhere
|
|
||||||
|
|
||||||
walkthrough section(s) 20 | unverified
|
|
||||||
|
|
||||||
The gym's whole puzzle is 30 warp pads (cache SAFFRON_GYM warps 3-32) that all target SAFFRON_GYM itself; the only route to Sabrina is entrance pad (11,15) -> ... -> (1,5) -> (11,9). The engine mechanism exists and reads correct: pads are COLL_WARP_PANEL 0x7c, an immediate warp (Permissions.lua:311); World:takeWarp (World.lua:6022-6040) handles a destination equal to the current map through setMap; warpCooldown keyed on the landing cell (World.lua:6135 set, 6217-6222, 6240 suppression check) stops the landing pad from re-firing until the player steps off, exactly what a pad-to-pad maze needs. But the asm-walk's own port-coverage table flags that no driver has ever exercised a same-map warp chain, and no Kanto run exists. If this breaks, Sabrina is unreachable and the 10th badge (and eventually Red's gate) is lost.
|
|
||||||
|
|
||||||
*How to prove it:* Targeted driver: enter the gym, ride pad (11,15), assert arrival at (19,17), then the full 5-hop chain to (11,9).
|
|
||||||
|
|
||||||
*Notes:* Doc rating: unverified/major, new.
|
|
||||||
|
|
||||||
|
|
||||||
### Sabrina battle and Marsh Badge fully extracted, never fought
|
|
||||||
|
|
||||||
walkthrough section(s) 20 | unverified
|
|
||||||
|
|
||||||
Sabrina script (cache 61:40cf): checkflag 39 guard, loadtrainer class 35 member 1, reloadmapafterbattle, force-sets the four gym trainer flags (so the trainers are skippable, as the walkthrough implies), setflag 39 = ENGINE_MARSHBADGE routed to save.player.kantoBadges.MARSH. Party matches the cart exactly (trainers.lua SABRINA 1: ESPEON46 Sand-Attack/Quick Attack/Swift/Psychic, MR__MIME46 Barrier/Reflect/Baton Pass/Psychic, ALAKAZAM48 Recover/Future Sight/Psychic/Reflect; class item HYPER_POTION, base reward 25, AI attribute words extracted; attributes.asm:209-213). The four trainers (Rebecca, Franklin, Doris, Jared; MEDIUM 6,7 / PSYCHIC_T 2,11; parties verified including the cart's L35 third Exeggcute that the FAQ gets wrong) are OBJECTTYPE_TRAINER with SPINRANDOM movement, which Npc.lua supports, and sight/battle handoff is the proven Trainers.lua path. Prize money base x last-level x4 is implemented in Prize.lua (matches the walkthrough's 4800G).
|
|
||||||
|
|
||||||
*How to prove it:* Driver through the warp maze to Sabrina; assert ENGINE_MARSHBADGE (flag 39) into save.player.kantoBadges.MARSH, the four trainer flags force-set, and the 4800G payout.
|
|
||||||
|
|
||||||
*Notes:* Doc rating: unverified/major, new. Corrections embedded: the L35 third Exeggcute is cart-correct (the FAQ is wrong); Sabrina's class item is HYPER_POTION.
|
|
||||||
|
|
||||||
|
|
||||||
### Copycat lost doll quest chain toward the rail PASS (section-20 filing): all pieces present and generic, never run
|
|
||||||
|
|
||||||
walkthrough section(s) 20 | unverified
|
|
||||||
|
|
||||||
Spans this section's maps (Copycat's house is Saffron warp 8, the doll source is the Vermilion Fan Club) though the walkthrough text defers it to after the Power Plant (EVENT_RETURNED_MACHINE_PART gates the dialogue arms). Copycat's 2F object uses variable sprite 251 (SPRITE_COPYCAT), and the new-game variablesprite seeding that used to leave Copycat despawned was fixed during the Johto campaign (tests/gen2_variable_sprites_test.lua, GOLD-WALK-HANDOFF section 5); her script (cache 61:5235) is extracted bytecode whose only special, LoadUsedSpritesGFX, is implemented (Specials.lua:1033); the Fan Club Clefairy Guy (59:437b) checks events 210/201 and gives LOST_ITEM; the doll object (flag 1907) and the PASS handover (verbosegiveitem PASS, setevent EVENT_GOT_PASS_FROM_COPYCAT per pokegold maps/CopycatsHouse2F.asm:61-73) are ordinary ops; PASS is a KEY_ITEM in items.lua. Nothing engine-shaped is missing.
|
|
||||||
|
|
||||||
*How to prove it:* Same driver as the s22 PASS quest entry: full Copycat/Fan Club round trip asserting LOST_ITEM, doll flags, and PASS.
|
|
||||||
|
|
||||||
*Notes:* Doc rating: unverified/minor, new, named in the leads. Merge-with hint: same quest chain as pass-quest-copycat-fanclub (s22); kept separate because the doc filed them as distinct headings from two section audits. This filing cites Clefairy Guy gate events 210/201 and doll flag 1907; the s22 filing cites 206/208 and doll-disappear flag 1908.
|
|
||||||
|
|
||||||
|
|
||||||
### Celadon side items: TM03 Curse night gate, Leftovers trash can, hidden PP Up (plus Berserk Gene, Route 9 Ether, Route 25 Potion) never run
|
|
||||||
|
|
||||||
walkthrough section(s) 22 | unverified
|
|
||||||
|
|
||||||
TM03 Curse: roof house pharmacist script 5e:5083 in cache with checktime mask 4 (NITE) at row 7; the Vm checktime opcode (Vm.lua:788) maps NITE to bit 4 off World:timeOfDayId, so the night-only gate should behave; the give branch 5e:509a is guarded by EVENT_GOT_TM03_CURSE 218. Leftovers: CeladonCafeTrashcan bg event at (7,1) key 5e:648c present, plain giveitem bytecode with the flag only set on success so a full pack retries, exactly the cart. Hidden PP Up at Celadon (37,21): BGEVENT_ITEM row present, handled by HiddenItems (fixed PP_UP; the walkthrough's 'random PP Up' is wrong on the cart too). Berserk Gene at Cerulean (2,12), hidden Ether Route 9 (10,5), and hidden Potion Route 25 (4,5) likewise extract. All generic and statically sound, none ever run.
|
|
||||||
|
|
||||||
*How to prove it:* Kanto run collecting each: night visit for TM03 (checktime NITE), cafe trash can Leftovers with full-pack retry, hidden-item picks via HiddenItems.
|
|
||||||
|
|
||||||
*Notes:* Doc rating: unverified/minor, new. Correction embedded: the PP Up is fixed, not random, on the cart.
|
|
||||||
|
|
||||||
|
|
||||||
### Mt. Moon Square Clefairy dance (Monday night) + hidden Moon Stone + Rock Smash rock: all dependencies present, sequence never executed
|
|
||||||
|
|
||||||
walkthrough section(s) 26 | unverified
|
|
||||||
|
|
||||||
Coord event at (7,11) scene 0 runs 5b:676a, whose 45 rows extract completely: checkflag 87 (ENGINE_MT_MOON_SQUARE_CLEFAIRY), readvar var 11 (VAR_WEEKDAY, real in the port) ifnotequal 1 (MONDAY), checktime NITE, then the appear/follow/applymovement/cry/showemote dance, clearevent 236 (the hidden Moon Stone flag) and setflag 87. Both callbacks extracted (type 5 NEWMAP 5b:6763 re-hides the stone on entry; type 2 OBJECTS 5b:6767 hides the rock). The rock object (SPRITE_ROCK at 7,7, scriptKey 5b:67ee = jumpstd SmashRockScript) resolves to the real SmashRock/AskRockSmash bodies GOLD-INDEX.md:377-382 documents; follow/stopfollow are implemented (Vm.lua:1009,1026). Flag semantics match the cart: ENGINE_MT_MOON_SQUARE_CLEFAIRY lives in wDailyFlags2 (pokegold constants/engine_flags.asm:105-106) and the port clears id 87 in Apricorns.DAILY_ENGINE_FLAGS, so the dance is once per day, i.e. once per Monday night. Unproven in-game: (a) whether the appeared rock correctly blocks/receives the A press on the Moon Stone tile at (7,7) and Rock Smash then exposes the hidden item in the same visit, and (b) the full follow-chain animation. The stubbed RockMonEncounter (CallAsm STUB_ROWS) does NOT affect this rock: MOUNT_MOON_SQUARE is not in the cart's RockMonMaps (pokegold data/wild/treemon_maps.asm:43-48), so no encounter roll is owed here.
|
|
||||||
|
|
||||||
*How to prove it:* Driver: force clockDay=MONDAY clockHour=NITE, walk to (7,11), watch the dance, smash the rock, take the Moon Stone.
|
|
||||||
|
|
||||||
*Notes:* Doc rating: unverified/minor, new.
|
|
||||||
|
|
||||||
|
|
||||||
### Rival re-encounters after Mt Moon (Indigo Plateau Mon/Wed rematch, Dragon's Den Tue/Thu cameo) never run
|
|
||||||
|
|
||||||
walkthrough section(s) 26 | unverified
|
|
||||||
|
|
||||||
Both beats are pure extracted content keyed on EVENT_BEAT_RIVAL_IN_MT_MOON (set by the implemented Mt Moon script) and VAR_WEEKDAY (real in the port; World:readVar VAR_WEEKDAY at World.lua:1289): the cache carries INDIGO_PLATEAU_POKECENTER_1F's NEWMAP callback 5a:48b6 (23 rows of setmapscene/clearevent resets) plus its two coord events, and DRAGONS_DEN_B1F's callback 47:44e1 (checkevent 793 -> disappear object 4). The daily rematch flag ENGINE_INDIGO_PLATEAU_RIVAL_FIGHT (id 92) is in Apricorns.DAILY_ENGINE_FLAGS so it clears on the daily rollover. The rematch parties RIVAL2 members 4-6 (L45-50 with CROBAT) are in trainers.lua. Nothing looks missing, but the coord-event choreography in the Pokecenter (rival walks in as you leave) is the kind of scene worth a driver before trusting; filed unverified rather than implemented for that reason.
|
|
||||||
|
|
||||||
*How to prove it:* Driver: set EVENT_BEAT_RIVAL_IN_MT_MOON via the Mt Moon fight, force weekday Monday/Wednesday, enter the Indigo lobby and assert the rematch triggers; force Tuesday/Thursday and assert the Dragon's Den object 4 state.
|
|
||||||
|
|
||||||
*Notes:* Doc rating: unverified/minor, new. Merge-with hint: overlaps indigo-lobby-rival-rematch (s18), the same Indigo Plateau rematch filed from the section-18 audit; that filing cites World:weekday at World.lua:1247 and scriptKeys 5a:48ff/5a:4940.
|
|
||||||
|
|
||||||
|
|
||||||
### Indigo lobby rival rematch (Monday/Wednesday, RIVAL2, post Mt. Moon) armed on tiles the walkthrough crosses, never run
|
|
||||||
|
|
||||||
walkthrough section(s) 18 | unverified
|
|
||||||
|
|
||||||
Not in the walkthrough text but armed on the lobby tiles it crosses: coord events at (16,4)/(17,4) run PlateauRivalBattle1/2 (cache scriptKeys 5a:48ff/5a:4940), which fight the RIVAL2 second-tier party when EVENT_BEAT_RIVAL_IN_MT_MOON is set, ENGINE_INDIGO_PLATEAU_RIVAL_FIGHT is clear, and VAR_WEEKDAY is Monday or Wednesday (pokegold maps/IndigoPlateauPokecenter1F.asm). Every dependency exists: coord events and scripts extracted, VAR_WEEKDAY answers the real weekday (World:weekday, World.lua:1247), the daily engine flag is registered for midnight reset (Apricorns.lua:88, id 92 ENGINE_INDIGO_PLATEAU_RIVAL_FIGHT), and RIVAL2 parties 4-6 (SNEASEL/45, CROBAT/48, MAGNETON/45, GENGAR/46, ALAKAZAM/46, starter/50) are in the trainers cache. But the gate flag EVENT_BEAT_RIVAL_IN_MT_MOON is Kanto content no run has ever set.
|
|
||||||
|
|
||||||
*How to prove it:* Should be picked up when section 26 is audited/run: same driver as rival-rematch-mtmoon, entering the lobby on Monday/Wednesday with the Mt Moon flag set and asserting the coord-event battle fires.
|
|
||||||
|
|
||||||
*Notes:* Doc rating: unverified/minor, new. Merge-with hint: same beat as rival-rematch-mtmoon (s26); kept separate because the doc filed both headings. Line-cite difference between filings: World.lua:1247 (weekday) here vs World.lua:1289 (readVar VAR_WEEKDAY) there.
|
|
||||||
|
|
||||||
|
|
||||||
### Lighthouse descent and swim to Cianwood (route rows 08.50/08.51) never walked; collision data matches the cart, blame points at the bot's region graph
|
|
||||||
|
|
||||||
walkthrough section(s) 08 | unverified
|
|
||||||
|
|
||||||
The route bot has never completed the descent from Jasmine to Route 40, so the Surf crossing, all fourteen Route 40/41 swimmers, and Cianwood arrival are engine-unwalked (runs reach section 08+ by teleport; run 28 still failed rows 08.50/08.51). The handoff narrows it to the 3F seven-cell pocket: warps 8/9 at (8,3)/(9,3) 'do not take' when stood on. Verified in the current cache: OLIVINE_LIGHTHOUSE_3F (8,3)/(9,3) decode to COLL_FLOOR 0x00, and pokegold data/tilesets/lighthouse_collision.asm block $27 is FLOOR,FLOOR,FLOOR,FLOOR, so those two warp rows are one-way landing anchors for the 4F pits at (8,3)/(9,3) (which decode to COLL_PIT 0x60) and never fire on the cart either. The pocket's real exit is warp 3, the staircase at (9,5), which decodes to 0x72 and does fire ((13,3) also 0x72). Fix shape: correct the bot's region graph, which treats landing-anchor warp coordinates as exits (the exact trap GOLD-WALK-HANDOFF.md:593 documents), not the engine; a human following the walkthrough (drop beside Connie, fight Terrell, take the Ether, head up the stairs) uses the (9,5) staircase and should be fine.
|
|
||||||
|
|
||||||
*How to prove it:* Something must walk 6F down to CIANWOOD_CITY in the engine (bot rows 08.50/08.51 passing, or a driver doing the descent and the Route 40/41 Surf crossing); until then the second half of section 08 stays a beat-completeness check rather than a proven path.
|
|
||||||
|
|
||||||
*Notes:* Doc rating: unverified/major, already named in the ledger. The doc's own re-check corrected the suspicion of an engine defect: the cache collision matches the cart exactly, so the failing rows indict the bot's region graph, not the engine or the warp data.
|
|
||||||
|
|
||||||
|
|
||||||
### TM08 Rock Smash give (route row 06.21) flaky across runs; ledger contradicts itself and the failure is unresolved
|
|
||||||
|
|
||||||
walkthrough section(s) 06 | unverified
|
|
||||||
|
|
||||||
The Route 36 fisher gives TM08 after EVENT_FOUGHT_SUDOWOODO (Route36RockSmashGuyScript, generic extracted bytecode with verbosegiveitem guarded by iffalse .NoRoomForTM). The ledger contradicts itself across runs: GOLD-WALK-HANDOFF section 8 says 'Everything before it now passes on merit: Sudowoodo, TM08, the ROCK SMASH teach', but run 28's failure table lists '06.21 ROUTE_36 EVENT_GOT_TM08_ROCK_SMASH not set' while the Sudowoodo row itself passed. Plausible causes: full TM pocket taking the iffalse arm, or the bot failing to stand adjacent. The script path is generic and the same give shape works everywhere else, so this is most likely route flake, but it is unresolved in the latest continuous run and TM08 gates all Rock Smash content downstream (Burned Tower rocks, Route 40/Cianwood rock items).
|
|
||||||
|
|
||||||
*How to prove it:* Re-run route row 06.21 in a continuous run and assert EVENT_GOT_TM08_ROCK_SMASH gets set; instrument for TM-pocket fullness and adjacency to distinguish the two suspected causes.
|
|
||||||
|
|
||||||
*Notes:* Doc rating: unverified/minor, already named in the ledger. Not given severity unverified-risk because the problem is a reproduced-once run failure (a live flake), not purely never-executed content.
|
|
||||||
|
|
||||||
|
|
||||||
### New Bark rival push scene (talk to rival by the lab) unproven; low risk, zero coverage
|
|
||||||
|
|
||||||
walkthrough section(s) 00 | unverified
|
|
||||||
|
|
||||||
Walkthrough: 'If you talk to him, he'll push you out of the way.' NewBarkTownRivalScript is optional, repeatable (sets no flag) and uses follow PLAYER, applymovement with turn_head/step/fix_facing/jump_step, and SFX_TACKLE. Every opcode involved is implemented (follow/stopfollow in the VM, jump_step in Movement.lua which the ledge fixes exercised), and the sibling teacher-drag scene has a dedicated driver (tests/drivers/gold_teacher_scene.lua), but no driver or test touches this specific scene and the route bot never talks to optional NPCs. Cache maps.lua NEW_BARK_TOWN objects include the rival with a scriptKey and eventFlag.
|
|
||||||
|
|
||||||
*How to prove it:* A short driver that talks to the rival by the lab and observes the push movement chain, mirroring gold_teacher_scene.lua.
|
|
||||||
|
|
||||||
*Notes:* Doc rating: unverified/minor, new.
|
|
||||||
|
|
||||||
|
|
||||||
### Kiyo battle and Tyrogue gift (Mount Mortar B1F): fully wired, never exercised by any driver or bot run
|
|
||||||
|
|
||||||
walkthrough section(s) 15 | unverified
|
|
||||||
|
|
||||||
The game's only Tyrogue. Cache scripts['46:5eec'] holds all 29 rows of MountMortarB1FKiyoScript (checkevent 97 / 1193, winlosstext, loadtrainer class BLACKBELT_T member 6, startbattle, reloadmapafterbattle, setevent 1193, readvar var=1 VAR_PARTYCOUNT, ifequal 6, givepoke species 236 level 10, setevent 97); trainers cache has BLACKBELT_T[6] KIYO = L34 HITMONLEE + L34 HITMONCHAN (KIYO object at 13,4 with scriptKey 46:5eec); readvar VAR_PARTYCOUNT is a real read (World.lua:1271); givepoke is implemented at Vm.lua:443. The full dependency chain has never run: Surf across the 1F Outside lake, the in-cave Waterfall climb to warp 4 at (17,5), the 2F/1F-inside/B1F warp descent, the battle, and the gift. The waterfall climb mechanic itself is bot-verified only at Tohjo Falls; route.lua contains no MORTAR/KIYO/DARK_CAVE rows.
|
|
||||||
|
|
||||||
*How to prove it:* A Mount Mortar driver: Surf the outside lake, Waterfall to warp 4 (17,5), descend to B1F, beat Kiyo, assert Tyrogue (species 236, L10) given with a party of <6 and events 97/1193 set.
|
|
||||||
|
|
||||||
*Notes:* Doc rating: unverified/major, new. Correction embedded: GOLD-INDEX's claim that only VAR_WEEKDAY and VAR_FACING answer readvar is STALE; World.lua:1265-1330 now answers essentially every VAR_*.
|
|
||||||
|
|
||||||
|
|
||||||
### Blackglasses pharmacist in Dark Cave Blackthorn entrance extracted exactly, never run (same for TM13 Snore and Revive balls)
|
|
||||||
|
|
||||||
walkthrough section(s) 15 | unverified
|
|
||||||
|
|
||||||
The script is fully extracted (cache scripts['47:436c'], 13 rows matching pokegold maps/DarkCaveBlackthornEntrance.asm exactly: checkevent / verbosegiveitem / iffalse pack-full arm / setevent), the spinning NPC object (SPRITE_PHARMACIST) is in the cache at (7,3) with that scriptKey, and verbosegiveitem is implemented in the VM. Reaching him also needs Flash plus Surf inside the cave, both implemented. The same unverified state covers the TM13 Snore and Revive item balls beside him (item ball arm is implemented and tested). No driver enters Dark Cave and the bot route bypasses it (grep DARK_CAVE in route.lua is empty).
|
|
||||||
|
|
||||||
*How to prove it:* A Dark Cave driver: Flash + Surf to the Blackthorn entrance, talk to the pharmacist (assert Blackglasses given), pick up TM13 Snore and the Revives.
|
|
||||||
|
|
||||||
*Notes:* Doc rating: unverified/minor, new.
|
|
||||||
|
|
||||||
|
|
||||||
### Master Ball from Elm after eighth badge: route rows exist but optional, no run log confirms the event ever set
|
|
||||||
|
|
||||||
walkthrough section(s) 16 | unverified
|
|
||||||
|
|
||||||
ProfElmScript's ElmCheckMasterBall gate is checkflag ENGINE_RISINGBADGE then verbosegiveitem MASTER_BALL then setevent EVENT_GOT_MASTER_BALL_FROM_ELM (pokegold maps/ElmsLab.asm:57-61). All generic extracted bytecode; checkflag over ENGINE_* badges works since the badge-store fix (GOLD-WALK-HANDOFF.md:502, badges routed into save.player.badges). The route bot has rows 16.24/16.24b for this beat but both are marked optional = true, and no run log or handoff note confirms EVENT_GOT_MASTER_BALL_FROM_ELM was ever actually set in a live run (no MASTER hits in GOLD-NEXT-RUN.md run logs). A full bag silently skips the setevent (verbosegiveitem iffalse), same as the cart.
|
|
||||||
|
|
||||||
*How to prove it:* Make route rows 16.24/16.24b non-optional (or run them) and assert EVENT_GOT_MASTER_BALL_FROM_ELM set with bag space available.
|
|
||||||
|
|
||||||
*Notes:* Doc rating: unverified/major, new.
|
|
||||||
|
|
||||||
|
|
||||||
### Everstone from Elm by showing a self-hatched Togepi: pieces individually proven, full chain never exercised end to end
|
|
||||||
|
|
||||||
walkthrough section(s) 16 | unverified
|
|
||||||
|
|
||||||
The gating special FindPartyMonThatSpeciesYourTrainerID is a real handler with the own-OT check (Specials.lua:1134), and the phone-call prerequisite is NOT required: pokegold maps/ElmsLab.asm ElmCheckTogepiEgg -> EVENT_TOGEPI_HATCHED -> ElmEggHatchedScript re-runs the same species scan, so a hatched Togepi in the party is sufficient (maps/ElmsLab.asm:62-95, engine/phone/scripts/elm.asm:46). Egg hatching has a driver (tests/drivers/gold_egg_hatch.lua) and PC withdraw exists (BoxMenu.lua / PcMenu.lua). The full chain (hatch with your OT, withdraw, talk, EVENT_SHOWED_TOGEPI_TO_ELM, verbosegiveitem EVERSTONE) has never run end to end. The .egghatched arm of ElmPhoneCalleeScript (sets EVENT_TOLD_ELM_ABOUT_TOGEPI_OVER_THE_PHONE) lives in the bank $41 callee scripts and only matters for the alternate entry path. No route row exists for the Everstone.
|
|
||||||
|
|
||||||
*How to prove it:* A driver chaining gold_egg_hatch-style hatching (own OT), PC withdraw, and the Elm talk, asserting EVENT_SHOWED_TOGEPI_TO_ELM and EVERSTONE in bag.
|
|
||||||
|
|
||||||
*Notes:* Doc rating: unverified/minor, new.
|
|
||||||
|
|
||||||
|
|
||||||
### TM37 Sandstorm happiness gate at the Route 27 house never exercised; gate is first-slot happiness >= 150
|
|
||||||
|
|
||||||
walkthrough section(s) 16 | unverified
|
|
||||||
|
|
||||||
The granny's script is extracted (cache scripts['60:6352'], 9 rows: checkevent, special id 88, ifgreater, sjump into the loyal/disloyal arms) and special id 88 resolves through constants.specialOrder with the VM's order[id+1] rule (Vm.lua:1773) to specialOrder[89] = GetFirstPokemonHappiness, a real handler at Specials.lua:1506 reading the first non-egg party member. The cart gate is happiness >= 150 on the FIRST party slot (ifgreater 150-1), not 'one of your Pokemon'. Happiness accrues via Happiness.lua stepped from StepEvents. No driver or bot row enters ROUTE_27_SANDSTORM_HOUSE.
|
|
||||||
|
|
||||||
*How to prove it:* Driver: enter ROUTE_27_SANDSTORM_HOUSE with a first-slot mon at happiness >= 150 and assert TM37 given; also assert the disloyal arm below 150.
|
|
||||||
|
|
||||||
*Notes:* Doc rating: unverified/minor, new.
|
|
||||||
|
|
||||||
|
|
||||||
### Route 27 whirlpool island (Bird Keeper Jose, TM22 Solarbeam, Rare Candy) and Tohjo Falls Moon Stone never run; whirlpool mechanic only proven on Route 41
|
|
||||||
|
|
||||||
walkthrough section(s) 16 | unverified
|
|
||||||
|
|
||||||
The whirlpool mechanic is implemented and driver-verified, but on Route 41, not here: tests/drivers/gold_water_moves.lua:105-124 clears a real whirlpool block with GLACIERBADGE gating and asserts the block replacement. The Route 27 island content is all in the cache (TM_SOLARBEAM ball at (60,12) item id 213 with event flag, Rare Candy ball at (53,12), BIRD_KEEPER[14] JOSE = L35 FARFETCH_D with phone contact rows; maps.ROUTE_27 objects rows 6-8) and the item ball arm is implemented, but the bot route crosses Route 27 only via Tohjo Falls and never surfs south past Gilbert (route rows 16.42-16.43 skip the south water), so this specific whirlpool, the Jose fight, and the TM22 pickup have never run. Same for the Tohjo Falls MOON_STONE ball at (2,6) (maps.TOHJO_FALLS object 1), which the walkthrough itself never mentions.
|
|
||||||
|
|
||||||
*How to prove it:* Driver or route rows: surf south past Gilbert, clear the Route 27 whirlpool, fight Jose, collect TM22 and the Rare Candy; separately pick up the Tohjo Falls Moon Stone.
|
|
||||||
|
|
||||||
*Notes:* Doc rating: unverified/minor, new.
|
|
||||||
|
|
||||||
|
|
||||||
### Route 26 heal house free heal extracted with all specials real, never entered
|
|
||||||
|
|
||||||
walkthrough section(s) 16 | unverified
|
|
||||||
|
|
||||||
The teacher's script is extracted (cache scripts['60:60e6'], 17 rows) and every special id in it resolves via order[id+1] to a real handler: 47 FadeOutToBlack, 50 ReloadSpritesNoPalettes, 27 HealParty, 49 FadeInFromBlack, 60 RestartMapMusic (Specials.lua:450, :999, :1025, and the fade-in/restart pair at :1059). Unconditional and repeatable like the cart. No driver or bot row ever enters ROUTE_26_HEAL_HOUSE; the bot route's Route 26 leg is a single travel row straight to the gate.
|
|
||||||
|
|
||||||
*How to prove it:* Driver: enter ROUTE_26_HEAL_HOUSE with a damaged party, talk, assert full heal and that the scene is repeatable.
|
|
||||||
|
|
||||||
*Notes:* Doc rating: unverified/minor, new.
|
|
||||||
|
|
||||||
|
|
||||||
### Victory Road item pickups (TM26 Earthquake pit, Full Restore shelf, Max Revive, Full Heal, X Special, two hidden items) never exercised
|
|
||||||
|
|
||||||
walkthrough section(s) 17 | unverified
|
|
||||||
|
|
||||||
The walkthrough collects five Poke Ball items (TM26 Earthquake via the one-way pit at (0,11), Full Restore on the ledge shelf via the (17,19) ladder, Max Revive and Full Heal in the entrance region, X Special on the HOP_LEFT column) plus two hidden items (Max Potion at (3,29), Full Heal at (3,65)). All pieces exist: cache maps.VICTORY_ROAD carries 6 objects (5 with itemball item/qty payloads), 2 bgEvents with hiddenItem tables, 10 warps, 2 coord events; World:interact consumes obj.itemball (World.lua:5483-5490, an earlier bot-found fix); HiddenItems handles BGEVENT_ITEM; Permissions models the COLL_PIT one-way warp, the six ladders, ledge hops (isLedge/ledgeFacings, Gold's own direction order where $a0 is HOP_RIGHT), and COLL_UP_WALL side walls (Permissions.lua:170-260 LEDGE_FACINGS/SIDE_BLOCKS/NEIGHBOR_ARM), with a test section at tests/gen2_world_test.lua:2936. But nothing has walked these routes: the bot's section 17 is only travel/walk rows 17.2, 17.11, 17.15 (no pickup rows), so the pit drop, the two HOP exits from the TM pocket, the HOP_DOWN off the shelf, and the X Special ball sitting ON a HOP_LEFT tile (the one placement the asm-walk flags as needing hardware confirmation) are all unproven end to end.
|
|
||||||
|
|
||||||
*How to prove it:* Driver or route rows through Victory Road collecting all five balls and both hidden items, specifically exercising the (0,11) pit drop, the (17,19) ladder shelf with HOP_DOWN exit, the two HOP exits from the TM pocket, and interaction with the X Special ball on its HOP_LEFT tile.
|
|
||||||
|
|
||||||
*Notes:* Doc rating: unverified/minor, new.
|
|
||||||
|
|
||||||
@@ -242,7 +242,7 @@ in [docs/linux-arm64-build.md](docs/linux-arm64-build.md).
|
|||||||
|
|
||||||
## iOS
|
## 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
|
(Windows or Mac) — see [docs/ios-sideload.md](docs/ios-sideload.md). To
|
||||||
build and install from source on a Mac instead, see
|
build and install from source on a Mac instead, see
|
||||||
[docs/ios-install.md](docs/ios-install.md).
|
[docs/ios-install.md](docs/ios-install.md).
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 243 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 217 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 236 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 242 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 166 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 251 KiB |
Executable
+366
@@ -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/"
|
||||||
+7
-4
@@ -91,11 +91,14 @@ mkdir -p "$GAME_SRC"
|
|||||||
(cd "$ROOT" && zip -q -9 -r "$WORK/game-payload.zip" \
|
(cd "$ROOT" && zip -q -9 -r "$WORK/game-payload.zip" \
|
||||||
main.lua conf.lua src libs data assets tools/save-editor \
|
main.lua conf.lua src libs data assets tools/save-editor \
|
||||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||||
|
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
||||||
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
||||||
if unzip -Z1 "$WORK/game-payload.zip" \
|
payload_list="$(unzip -Z1 "$WORK/game-payload.zip")"
|
||||||
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
|
printf '%s\n' "$payload_list" \
|
||||||
fail "payload unexpectedly contains generated ROM data"
|
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/' \
|
||||||
fi
|
&& 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"
|
unzip -q "$WORK/game-payload.zip" -d "$GAME_SRC"
|
||||||
rm -f "$WORK/game-payload.zip"
|
rm -f "$WORK/game-payload.zip"
|
||||||
|
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ return {
|
|||||||
{ "set_flag", "EVENT_GOT_EEVEE" }, -- 7
|
{ "set_flag", "EVENT_GOT_EEVEE" }, -- 7
|
||||||
{ "hide_object", "CELADON_MANSION_ROOF_HOUSE",
|
{ "hide_object", "CELADON_MANSION_ROOF_HOUSE",
|
||||||
"CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" }, -- 8
|
"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
|
{ "show_text", "_GotMonText", { RAM = "EEVEE" } }, -- 10
|
||||||
{ "jump", 13 }, -- 11
|
{ "jump", 13 }, -- 11
|
||||||
{ "show_text", "_BoxIsFullText" }, -- 12
|
{ "show_text", "_BoxIsFullText" }, -- 12
|
||||||
|
|||||||
@@ -10,10 +10,10 @@
|
|||||||
local function coinGiver(opts)
|
local function coinGiver(opts)
|
||||||
return function(game, ow, npc, done)
|
return function(game, ow, npc, done)
|
||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
local Sound = require("src.core.Sound")
|
|
||||||
local t = game.data.text
|
local t = game.data.text
|
||||||
local function push(label, fallback, onDone)
|
local function push(label, fallback, onDone, popts)
|
||||||
game.stack:push(TextBox.new(game, t[label] or fallback, onDone or done))
|
game.stack:push(TextBox.new(game, t[label] or fallback, onDone or done,
|
||||||
|
popts))
|
||||||
end
|
end
|
||||||
if game.save.flags[opts.event] then
|
if game.save.flags[opts.event] then
|
||||||
push(opts.alreadyGotLabel, opts.alreadyGotFallback)
|
push(opts.alreadyGotLabel, opts.alreadyGotFallback)
|
||||||
@@ -30,9 +30,10 @@ local function coinGiver(opts)
|
|||||||
end
|
end
|
||||||
game.save.coins = math.min(9999, (game.save.coins or 0) + opts.amount)
|
game.save.coins = math.min(9999, (game.save.coins or 0) + opts.amount)
|
||||||
game.save.flags[opts.event] = true
|
game.save.flags[opts.event] = true
|
||||||
Sound.play(game.data, "Get_Item1")
|
-- the ReceivedNCoinsText strings carry sound_get_item_1
|
||||||
push(opts.receivedLabel,
|
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
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ return {
|
|||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
local Commands = require("src.script.Commands")
|
local Commands = require("src.script.Commands")
|
||||||
local t = game.data.text
|
local t = game.data.text
|
||||||
local function say(label, cb)
|
local function say(label, cb, sopts)
|
||||||
game.stack:push(TextBox.new(game, t[label] or label, cb))
|
game.stack:push(TextBox.new(game, t[label] or label, cb, sopts))
|
||||||
end
|
end
|
||||||
|
|
||||||
if game.save.flags.EVENT_GOT_OLD_AMBER then
|
if game.save.flags.EVENT_GOT_OLD_AMBER then
|
||||||
@@ -39,8 +39,9 @@ return {
|
|||||||
game.save.flags.EVENT_GOT_OLD_AMBER = true
|
game.save.flags.EVENT_GOT_OLD_AMBER = true
|
||||||
Commands.hide_object({ save = game.save, overworld = ow, game = game },
|
Commands.hide_object({ save = game.save, overworld = ow, game = game },
|
||||||
"MUSEUM_1F", "MUSEUM1F_OLD_AMBER")
|
"MUSEUM_1F", "MUSEUM1F_OLD_AMBER")
|
||||||
require("src.core.Sound").play(game.data, "Get_Item1")
|
-- .ReceivedOldAmberText carries sound_get_item_1
|
||||||
say("_Museum1FScientist2ReceivedOldAmberText", done)
|
say("_Museum1FScientist2ReceivedOldAmberText", done,
|
||||||
|
TextBox.soundOpts(game, "Get_Item1"))
|
||||||
end)
|
end)
|
||||||
end,
|
end,
|
||||||
|
|
||||||
|
|||||||
+19
-19
@@ -38,9 +38,9 @@ local function starterBall(askText, species, choseFlag, ownBall,
|
|||||||
-- inside give_pokemon). Show the received text first so the
|
-- inside give_pokemon). Show the received text first so the
|
||||||
-- nickname prompt follows "you got X", matching Gen1.
|
-- nickname prompt follows "you got X", matching Gen1.
|
||||||
-- The received text carries sound_get_key_item (OaksLab.asm
|
-- The received text carries sound_get_key_item (OaksLab.asm
|
||||||
-- OaksLabReceivedMonText); the jingle plays as the box opens
|
-- OaksLabReceivedMonText), so the jingle fires once the box has
|
||||||
-- (same beat as the Yellow port's starter, #668).
|
-- typed out and holds it (same beat as the Yellow port's starter, #668).
|
||||||
{ "play_sound", "Get_Key_Item" }, -- 8
|
{ "text_sound", "Get_Key_Item" }, -- 8
|
||||||
{ "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 9
|
{ "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 9
|
||||||
{ "give_pokemon", species, 5 }, -- 10
|
{ "give_pokemon", species, 5 }, -- 10
|
||||||
{ "set_flag", "EVENT_GOT_STARTER" }, -- 11
|
{ "set_flag", "EVENT_GOT_STARTER" }, -- 11
|
||||||
@@ -54,7 +54,7 @@ local function starterBall(askText, species, choseFlag, ownBall,
|
|||||||
{ "face_object", 1, "up" }, -- 15
|
{ "face_object", 1, "up" }, -- 15
|
||||||
{ "show_text", "_OaksLabRivalIllTakeThisOneText" }, -- 16
|
{ "show_text", "_OaksLabRivalIllTakeThisOneText" }, -- 16
|
||||||
{ "hide_object", "OAKS_LAB", rivalBall }, -- 17
|
{ "hide_object", "OAKS_LAB", rivalBall }, -- 17
|
||||||
{ "play_sound", "Get_Key_Item" }, -- 18 (sound_get_key_item)
|
{ "text_sound", "Get_Key_Item" }, -- 18 (sound_get_key_item)
|
||||||
{ "show_text", "_OaksLabRivalReceivedMonText",
|
{ "show_text", "_OaksLabRivalReceivedMonText",
|
||||||
{ RAM = rivalBall == "OAKSLAB_CHARMANDER_POKE_BALL" and "CHARMANDER"
|
{ RAM = rivalBall == "OAKSLAB_CHARMANDER_POKE_BALL" and "CHARMANDER"
|
||||||
or rivalBall == "OAKSLAB_SQUIRTLE_POKE_BALL" and "SQUIRTLE"
|
or rivalBall == "OAKSLAB_SQUIRTLE_POKE_BALL" and "SQUIRTLE"
|
||||||
@@ -106,8 +106,8 @@ return {
|
|||||||
{ "check_item", "OAKS_PARCEL" },
|
{ "check_item", "OAKS_PARCEL" },
|
||||||
{ "jump_if_false", "raise_young" },
|
{ "jump_if_false", "raise_young" },
|
||||||
-- OaksLabOak1Text.got_parcel → RivalArrives + OakGivesPokedex
|
-- OaksLabOak1Text.got_parcel → RivalArrives + OakGivesPokedex
|
||||||
|
{ "text_sound", "Get_Key_Item" },
|
||||||
{ "show_text", "_OaksLabOak1DeliverParcelText" },
|
{ "show_text", "_OaksLabOak1DeliverParcelText" },
|
||||||
{ "play_sound", "Get_Key_Item" },
|
|
||||||
{ "show_text", "_OaksLabOak1ParcelThanksText" },
|
{ "show_text", "_OaksLabOak1ParcelThanksText" },
|
||||||
{ "take_item", "OAKS_PARCEL", 1 },
|
{ "take_item", "OAKS_PARCEL", 1 },
|
||||||
{ "stop_music" },
|
{ "stop_music" },
|
||||||
@@ -128,8 +128,8 @@ return {
|
|||||||
{ "face_object", 1, "up" },
|
{ "face_object", 1, "up" },
|
||||||
{ "face_object", 5, "down" },
|
{ "face_object", 5, "down" },
|
||||||
{ "show_text", "_OaksLabOakMyInventionPokedexText" },
|
{ "show_text", "_OaksLabOakMyInventionPokedexText" },
|
||||||
|
{ "text_sound", "Get_Key_Item" },
|
||||||
{ "show_text", "_OaksLabOakGotPokedexText" },
|
{ "show_text", "_OaksLabOakGotPokedexText" },
|
||||||
{ "play_sound", "Get_Key_Item" },
|
|
||||||
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" },
|
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" },
|
||||||
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" },
|
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" },
|
||||||
{ "face_object", 1, "up" },
|
{ "face_object", 1, "up" },
|
||||||
@@ -246,9 +246,17 @@ return {
|
|||||||
-- the table sprites; re-entering the lab applies the same HideObject
|
-- the table sprites; re-entering the lab applies the same HideObject
|
||||||
-- the gift script now does (OaksLab.asm OakGivesPokedex).
|
-- the gift script now does (OaksLab.asm OakGivesPokedex).
|
||||||
onEnter = function(game, ow)
|
onEnter = function(game, ow)
|
||||||
if not (game.save.flags and game.save.flags.EVENT_GOT_POKEDEX) then
|
local flags = game.save.flags or {}
|
||||||
return
|
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
|
end
|
||||||
|
if not flags.EVENT_GOT_POKEDEX then return end
|
||||||
local Commands = require("src.script.Commands")
|
local Commands = require("src.script.Commands")
|
||||||
local ctx = { save = game.save, game = game, overworld = ow }
|
local ctx = { save = game.save, game = game, overworld = ow }
|
||||||
Commands.hide_object(ctx, "OAKS_LAB", "OAKSLAB_POKEDEX1")
|
Commands.hide_object(ctx, "OAKS_LAB", "OAKSLAB_POKEDEX1")
|
||||||
@@ -286,6 +294,7 @@ return {
|
|||||||
-- fanfare for the taunt/challenge exchange, same as the Yellow port
|
-- fanfare for the taunt/challenge exchange, same as the Yellow port
|
||||||
-- (oaks_lab_yellow.lua); it was silently dropped here (#596).
|
-- (oaks_lab_yellow.lua); it was silently dropped here (#596).
|
||||||
local rows = {
|
local rows = {
|
||||||
|
{ "face_player_dir", "up" },
|
||||||
{ "stop_music" },
|
{ "stop_music" },
|
||||||
{ "play_music", "Music_MeetRival" },
|
{ "play_music", "Music_MeetRival" },
|
||||||
{ "show_text", "_OaksLabRivalIllTakeYouOnText" }, -- 1
|
{ "show_text", "_OaksLabRivalIllTakeYouOnText" }, -- 1
|
||||||
@@ -309,28 +318,19 @@ return {
|
|||||||
local base = #rows
|
local base = #rows
|
||||||
local party = flags.EVENT_CHOSE_BULBASAUR and 3
|
local party = flags.EVENT_CHOSE_BULBASAUR and 3
|
||||||
or flags.EVENT_CHOSE_SQUIRTLE and 2 or 1
|
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 })
|
table.insert(rows, { "start_battle", "trainer", "OPP_RIVAL1", party })
|
||||||
-- OaksLabRivalEndBattleScript: heal + flag on win or loss; no blackout
|
-- OaksLabRivalEndBattleScript: heal + flag on win or loss; no blackout
|
||||||
table.insert(rows, { "heal_party" })
|
table.insert(rows, { "heal_party" })
|
||||||
table.insert(rows, { "set_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" })
|
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, { "jump_if_false", base + 6 })
|
||||||
table.insert(rows, { "show_text", "_OaksLabRivalIPickedTheWrongPokemonText" })
|
|
||||||
table.insert(rows, { "show_text", "_OaksLabRivalSmellYouLaterText" })
|
table.insert(rows, { "show_text", "_OaksLabRivalSmellYouLaterText" })
|
||||||
-- OaksLabRivalStartsExitScript: parting shot, rival exit fanfare, then
|
-- OaksLabRivalStartsExitScript: parting shot, rival exit fanfare, then
|
||||||
-- walk out past the player. The fanfare was dropped here (#683) -- the
|
-- walk out past the player. The fanfare was dropped here (#683) -- the
|
||||||
-- parcel scene above already plays Music_MeetRival on both arrival and
|
-- parcel scene above already plays Music_MeetRival on both arrival and
|
||||||
-- departure (lines 144-146), and this exit should match (#596).
|
-- departure (lines 144-146), and this exit should match (#596).
|
||||||
table.insert(rows, { "stop_music" })
|
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, { "move_npc_to", 1, 4, 11 })
|
||||||
table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" })
|
table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" })
|
||||||
table.insert(rows, { "play_music", "Music_OaksLab" })
|
table.insert(rows, { "play_music", "Music_OaksLab" })
|
||||||
|
|||||||
@@ -38,8 +38,8 @@ return {
|
|||||||
{ "jump_if_false", "raise_young" },
|
{ "jump_if_false", "raise_young" },
|
||||||
-- .DeliverParcelText: parcel handover, then the Pokédex scene
|
-- .DeliverParcelText: parcel handover, then the Pokédex scene
|
||||||
-- (OaksLabRivalArrivesAtOaksRequestScript -> OakGivesPokedexScript)
|
-- (OaksLabRivalArrivesAtOaksRequestScript -> OakGivesPokedexScript)
|
||||||
|
{ "text_sound", "Get_Key_Item" },
|
||||||
{ "show_text", "_OaksLabOak1DeliverParcelText" },
|
{ "show_text", "_OaksLabOak1DeliverParcelText" },
|
||||||
{ "play_sound", "Get_Key_Item" },
|
|
||||||
{ "show_text", "_OaksLabOak1ParcelThanksText" },
|
{ "show_text", "_OaksLabOak1ParcelThanksText" },
|
||||||
{ "take_item", "OAKS_PARCEL", 1 },
|
{ "take_item", "OAKS_PARCEL", 1 },
|
||||||
{ "stop_music" },
|
{ "stop_music" },
|
||||||
@@ -62,8 +62,8 @@ return {
|
|||||||
{ "face_object", RIVAL, "up" },
|
{ "face_object", RIVAL, "up" },
|
||||||
{ "face_object", OAK1, "down" },
|
{ "face_object", OAK1, "down" },
|
||||||
{ "show_text", "_OaksLabOakMyInventionPokedexText" },
|
{ "show_text", "_OaksLabOakMyInventionPokedexText" },
|
||||||
|
{ "text_sound", "Get_Key_Item" },
|
||||||
{ "show_text", "_OaksLabOakGotPokedexText" },
|
{ "show_text", "_OaksLabOakGotPokedexText" },
|
||||||
{ "play_sound", "Get_Key_Item" },
|
|
||||||
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" },
|
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" },
|
||||||
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" },
|
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" },
|
||||||
{ "face_object", RIVAL, "up" },
|
{ "face_object", RIVAL, "up" },
|
||||||
@@ -113,8 +113,8 @@ return {
|
|||||||
{ "jump_if_true", "come_see" },
|
{ "jump_if_true", "come_see" },
|
||||||
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" },
|
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" },
|
||||||
{ "give_item", "POKE_BALL", 5, false },
|
{ "give_item", "POKE_BALL", 5, false },
|
||||||
|
{ "text_sound", "Get_Key_Item" },
|
||||||
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" },
|
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" },
|
||||||
{ "play_sound", "Get_Key_Item" },
|
|
||||||
{ "show_text", "_OaksLabGivePokeballsExplanationText" },
|
{ "show_text", "_OaksLabGivePokeballsExplanationText" },
|
||||||
{ "jump", "end" },
|
{ "jump", "end" },
|
||||||
|
|
||||||
@@ -176,7 +176,7 @@ return {
|
|||||||
-- rival starter baseline (RIVAL_STARTER_JOLTEON) at snatch time
|
-- rival starter baseline (RIVAL_STARTER_JOLTEON) at snatch time
|
||||||
rows[#rows + 1] = { "set_field", "rivalStarter", 1 }
|
rows[#rows + 1] = { "set_field", "rivalStarter", 1 }
|
||||||
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText1" }
|
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", "_OaksLabRivalTakesText2" }
|
||||||
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText3" }
|
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText3" }
|
||||||
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText4" }
|
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText4" }
|
||||||
@@ -196,14 +196,15 @@ return {
|
|||||||
rows[#rows + 1] = { "face_object", OAK1, "down" }
|
rows[#rows + 1] = { "face_object", OAK1, "down" }
|
||||||
-- OaksLabPlayerReceivedMonText clears wMonDataLocation, so AskName runs (#1013)
|
-- OaksLabPlayerReceivedMonText clears wMonDataLocation, so AskName runs (#1013)
|
||||||
rows[#rows + 1] = { "show_text", "_OaksLabOakGivesText" }
|
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] = { "show_text", "_OaksLabReceivedText", { RAM = "PIKACHU" } }
|
||||||
rows[#rows + 1] = { "give_pokemon", "PIKACHU", 5 }
|
rows[#rows + 1] = { "give_pokemon", "PIKACHU", 5 }
|
||||||
-- DisablePikachuOverworldSpriteDrawing keeps it in the ball (#1009)
|
-- DisablePikachuOverworldSpriteDrawing keeps it in the ball (#1009)
|
||||||
rows[#rows + 1] = { "set_field", "pikachuInBall", true }
|
rows[#rows + 1] = { "set_field", "pikachuInBall", true }
|
||||||
rows[#rows + 1] = { "set_flag", "EVENT_GOT_STARTER" }
|
rows[#rows + 1] = { "set_flag", "EVENT_GOT_STARTER" }
|
||||||
rows[#rows + 1] = { "set_flag", "EVENT_CHOSE_PIKACHU" }
|
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,
|
end,
|
||||||
|
|
||||||
TEXT_OAKSLAB_RIVAL = {
|
TEXT_OAKSLAB_RIVAL = {
|
||||||
@@ -225,9 +226,15 @@ return {
|
|||||||
},
|
},
|
||||||
|
|
||||||
onEnter = function(game, ow)
|
onEnter = function(game, ow)
|
||||||
if not (game.save.flags and game.save.flags.EVENT_GOT_POKEDEX) then
|
local flags = game.save.flags or {}
|
||||||
return
|
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
|
end
|
||||||
|
if not flags.EVENT_GOT_POKEDEX then return end
|
||||||
local Commands = require("src.script.Commands")
|
local Commands = require("src.script.Commands")
|
||||||
local ctx = { save = game.save, game = game, overworld = ow }
|
local ctx = { save = game.save, game = game, overworld = ow }
|
||||||
Commands.hide_object(ctx, "OAKS_LAB", "OAKSLAB_POKEDEX1")
|
Commands.hide_object(ctx, "OAKS_LAB", "OAKSLAB_POKEDEX1")
|
||||||
@@ -295,13 +302,14 @@ return {
|
|||||||
table.insert(rows, { "wait", 20 })
|
table.insert(rows, { "wait", 20 })
|
||||||
table.insert(rows, { "show_text", "_OaksLabRivalSmellYouLaterText" })
|
table.insert(rows, { "show_text", "_OaksLabRivalSmellYouLaterText" })
|
||||||
table.insert(rows, { "stop_music" })
|
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, { "move_npc_to", RIVAL, 4, 11 })
|
||||||
table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" })
|
table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" })
|
||||||
table.insert(rows, { "play_music", "Music_OaksLab" })
|
table.insert(rows, { "play_music", "Music_OaksLab" })
|
||||||
-- OaksLabPikachuEscapesPokeballScript: the follower reaches the map (#1009)
|
-- OaksLabPikachuEscapesPokeballScript: the follower reaches the map (#1009)
|
||||||
table.insert(rows, { "face_player_dir", "up" })
|
table.insert(rows, { "face_player_dir", "up" })
|
||||||
table.insert(rows, { "set_field", "pikachuInBall", false })
|
table.insert(rows, { "set_field", "pikachuInBall", false })
|
||||||
|
table.insert(rows, { "spawn_pikachu_follower" })
|
||||||
table.insert(rows, { "play_cry", "PIKACHU" })
|
table.insert(rows, { "play_cry", "PIKACHU" })
|
||||||
table.insert(rows, { "show_text", "_OaksLabPikachuDislikesPokeballsText1" })
|
table.insert(rows, { "show_text", "_OaksLabPikachuDislikesPokeballsText1" })
|
||||||
table.insert(rows, { "show_text", "_OaksLabPikachuDislikesPokeballsText2" })
|
table.insert(rows, { "show_text", "_OaksLabPikachuDislikesPokeballsText2" })
|
||||||
|
|||||||
+21
-19
@@ -142,20 +142,23 @@ M.VIRIDIAN_CITY = {
|
|||||||
M.BLUES_HOUSE = {
|
M.BLUES_HOUSE = {
|
||||||
talk = {
|
talk = {
|
||||||
TEXT_BLUESHOUSE_DAISY_SITTING = {
|
TEXT_BLUESHOUSE_DAISY_SITTING = {
|
||||||
{ "face_player" }, -- 1
|
{ "face_player" },
|
||||||
{ "check_flag", "EVENT_GOT_TOWN_MAP" }, -- 2
|
{ "check_flag", "EVENT_GOT_TOWN_MAP" },
|
||||||
{ "jump_if_true", 10 }, -- 3
|
{ "jump_if_true", "got_map" },
|
||||||
{ "check_flag", "EVENT_GOT_STARTER" }, -- 4
|
{ "check_flag", "EVENT_GOT_POKEDEX" },
|
||||||
{ "jump_if_false", 12 }, -- 5
|
{ "jump_if_false", "too_early" },
|
||||||
{ "show_text", "_BluesHouseDaisyOfferMapText" }, -- 6
|
{ "show_text", "_BluesHouseDaisyOfferMapText" },
|
||||||
-- _GotMapText: "{PLAYER} got a\n{RAM:wStringBuffer}!" -- the
|
-- _GotMapText: "{PLAYER} got a\n{RAM:wStringBuffer}!" -- the
|
||||||
-- buffer supplies "TOWN MAP" (scripts/BluesHouse.asm GotMapText)
|
-- buffer supplies "TOWN MAP" (scripts/BluesHouse.asm GotMapText)
|
||||||
{ "give_item", "TOWN_MAP", 1, "_GotMapText" }, -- 7
|
{ "give_item", "TOWN_MAP", 1, "_GotMapText" },
|
||||||
{ "set_flag", "EVENT_GOT_TOWN_MAP" }, -- 8
|
{ "hide_object", "BLUES_HOUSE", "BLUESHOUSE_TOWN_MAP" },
|
||||||
{ "jump", 13 }, -- 9
|
{ "set_flag", "EVENT_GOT_TOWN_MAP" },
|
||||||
{ "show_text", "_BluesHouseDaisyUseMapText" }, -- 10
|
{ "jump", "end" },
|
||||||
{ "jump", 13 }, -- 11
|
{ "label", "got_map" },
|
||||||
{ "show_text", "_BluesHouseDaisyRivalAtLabText" }, -- 12
|
{ "show_text", "_BluesHouseDaisyUseMapText" },
|
||||||
|
{ "jump", "end" },
|
||||||
|
{ "label", "too_early" },
|
||||||
|
{ "show_text", "_BluesHouseDaisyRivalAtLabText" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -294,6 +297,7 @@ M.BILLS_HOUSE = {
|
|||||||
|
|
||||||
M.ROUTE_25 = {
|
M.ROUTE_25 = {
|
||||||
onEnter = function(game, ow)
|
onEnter = function(game, ow)
|
||||||
|
game.save.pikachuMapScriptActive = nil
|
||||||
local flags = game.save.flags
|
local flags = game.save.flags
|
||||||
if flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING then return end
|
if flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING then return end
|
||||||
local Commands = require("src.script.Commands")
|
local Commands = require("src.script.Commands")
|
||||||
@@ -326,6 +330,7 @@ M.VERMILION_CITY = {
|
|||||||
-- only read while EVENT_1ST_LOCK_OPENED is unset (the gym is only
|
-- only read while EVENT_1ST_LOCK_OPENED is unset (the gym is only
|
||||||
-- reachable through this map, so a fresh visit always re-rolls).
|
-- reachable through this map, so a fresh visit always re-rolls).
|
||||||
onEnter = function(game, ow)
|
onEnter = function(game, ow)
|
||||||
|
game.save.pikachuMapScriptActive = nil
|
||||||
local puz = game.save.trashPuzzle or {}
|
local puz = game.save.trashPuzzle or {}
|
||||||
game.save.trashPuzzle = puz
|
game.save.trashPuzzle = puz
|
||||||
puz.first = love.math.random(0, 7) * 2
|
puz.first = love.math.random(0, 7) * 2
|
||||||
@@ -1058,6 +1063,7 @@ local championsRoomRivalScript = {
|
|||||||
-- OakCongratulatesPlayerScript: rival faces left, Oak faces down
|
-- OakCongratulatesPlayerScript: rival faces left, Oak faces down
|
||||||
{ "face_object", 1, "left" }, -- 17
|
{ "face_object", 1, "left" }, -- 17
|
||||||
{ "face_object", 2, "down" }, -- 18
|
{ "face_object", 2, "down" }, -- 18
|
||||||
|
{ "load_player_starter_name" },
|
||||||
{ "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 19
|
{ "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 19
|
||||||
-- OakDisappointedWithRivalScript: Oak turns to the rival (right)
|
-- OakDisappointedWithRivalScript: Oak turns to the rival (right)
|
||||||
{ "face_object", 2, "right" }, -- 20
|
{ "face_object", 2, "right" }, -- 20
|
||||||
@@ -1067,13 +1073,8 @@ local championsRoomRivalScript = {
|
|||||||
{ "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 23
|
{ "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 23
|
||||||
{ "move_npc", 2, "up", 2 }, -- 24 OakExitChampionsRoomMovement
|
{ "move_npc", 2, "up", 2 }, -- 24 OakExitChampionsRoomMovement
|
||||||
{ "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 25
|
{ "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 25
|
||||||
-- ChampionsRoomPlayerFollowsOakScript / WalkToHallOfFame_RLEMovement.
|
-- scripts/ChampionsRoom.asm WalkToHallOfFame_RLEMovement
|
||||||
-- The player walks out after Oak instead of the screen just fading on the
|
{ "move_player", "left", 1 },
|
||||||
-- spot (#704). Route one tile right before walking north so the player
|
|
||||||
-- reaches the north-wall HALL_OF_FAME warp without sharing the rival's
|
|
||||||
-- (4,2) cell. The original simulated movement bypasses entity collision,
|
|
||||||
-- but this scene should not visibly walk through the defeated rival.
|
|
||||||
{ "move_player", "right", 1 }, -- 26
|
|
||||||
{ "move_player", "up", 3 }, -- 27
|
{ "move_player", "up", 3 }, -- 27
|
||||||
-- hand the induction off to the HALL_OF_FAME room (consumed by its
|
-- 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)
|
-- onEnter), then warp up into it (destWarp 1 lands at (4,7) facing up)
|
||||||
@@ -1238,6 +1239,7 @@ local function pokemonTower2FRivalScript(playerX)
|
|||||||
{ "jump_if_false", "end" }, -- 6 loss: stay
|
{ "jump_if_false", "end" }, -- 6 loss: stay
|
||||||
{ "set_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 7
|
{ "set_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 7
|
||||||
{ "show_text", "_PokemonTower2FRivalDefeatedText" }, -- 8
|
{ "show_text", "_PokemonTower2FRivalDefeatedText" }, -- 8
|
||||||
|
{ "play_music", "Music_MeetRival", { start = "rival" } },
|
||||||
{ "walk_npc", 1, exitDirs }, -- 9
|
{ "walk_npc", 1, exitDirs }, -- 9
|
||||||
{ "hide_object", "POKEMON_TOWER_2F", "POKEMONTOWER2F_RIVAL" }, -- 10
|
{ "hide_object", "POKEMON_TOWER_2F", "POKEMONTOWER2F_RIVAL" }, -- 10
|
||||||
{ "jump", "end" }, -- 11
|
{ "jump", "end" }, -- 11
|
||||||
|
|||||||
+40
-12
@@ -178,7 +178,8 @@ M.PALLET_TOWN = {
|
|||||||
end
|
end
|
||||||
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.hide_object(ctx, "PALLET_TOWN", "PALLETTOWN_OAK")
|
||||||
Commands.show_object(ctx, "OAKS_LAB", "OAKSLAB_OAK2")
|
Commands.show_object(ctx, "OAKS_LAB", "OAKSLAB_OAK2")
|
||||||
ow.doorWarp = true
|
ow.doorWarp = true
|
||||||
@@ -187,12 +188,17 @@ M.PALLET_TOWN = {
|
|||||||
end
|
end
|
||||||
|
|
||||||
local function walkToLab(oak)
|
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
|
local i = 0
|
||||||
|
if oak then
|
||||||
|
oak.stepFrames = ow.player.stepFramesCur or ow.player.stepFrames
|
||||||
|
end
|
||||||
local function tick()
|
local function tick()
|
||||||
i = i + 1
|
i = i + 1
|
||||||
local playerStep = escort.playerSteps[i]
|
local playerStep = escort.playerSteps[i]
|
||||||
if not playerStep then
|
if not playerStep then
|
||||||
enterLab()
|
enterLab(oak)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if oak and escort.oakSteps[i] then
|
if oak and escort.oakSteps[i] then
|
||||||
@@ -206,6 +212,13 @@ M.PALLET_TOWN = {
|
|||||||
end
|
end
|
||||||
|
|
||||||
local function escortToLab(oak)
|
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
|
local numSteps = x - 10
|
||||||
if oak and numSteps > 0 then
|
if oak and numSteps > 0 then
|
||||||
ow:scriptMove(oak, "left", numSteps, function()
|
ow:scriptMove(oak, "left", numSteps, function()
|
||||||
@@ -249,14 +262,24 @@ M.PALLET_TOWN = {
|
|||||||
function()
|
function()
|
||||||
-- Oak turns toward the horizontally adjacent grass (left exit
|
-- Oak turns toward the horizontally adjacent grass (left exit
|
||||||
-- looks right, right exit looks left -- the
|
-- 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
|
if oak then oak.facing = x == 10 and "right" or "left" end
|
||||||
local battle = BattleState.newWild(game, "PIKACHU", 5)
|
hold(2, nil, function()
|
||||||
battle:makeOldManDemo("PROF.OAK")
|
local battle = BattleState.newWild(game, "PIKACHU", 5)
|
||||||
battle.onFinish = function()
|
battle:makeOldManDemo("PROF.OAK")
|
||||||
afterPikaBattle()
|
battle.onFinish = function()
|
||||||
end
|
afterPikaBattle()
|
||||||
game.stack:push(battle)
|
end
|
||||||
|
-- Use the standard wild-battle entry transition.
|
||||||
|
Commands.pushBattle(ctx, battle)
|
||||||
|
end)
|
||||||
end))
|
end))
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -396,6 +419,9 @@ M.ROUTE_8_GATE = saffronGate("TEXT_ROUTE8GATE_GUARD", { { 2, 3 }, { 2, 4 } }, tr
|
|||||||
-- -------------------------------------------------------------------
|
-- -------------------------------------------------------------------
|
||||||
|
|
||||||
M.POKEMON_FAN_CLUB = {
|
M.POKEMON_FAN_CLUB = {
|
||||||
|
onEnter = function(game, ow)
|
||||||
|
require("src.world.PikachuFollower").onFanClubEntered(game, ow)
|
||||||
|
end,
|
||||||
talk = {
|
talk = {
|
||||||
TEXT_POKEMONFANCLUB_CHAIRMAN = {
|
TEXT_POKEMONFANCLUB_CHAIRMAN = {
|
||||||
{ "face_player" }, -- 1
|
{ "face_player" }, -- 1
|
||||||
@@ -628,8 +654,10 @@ local function mtMoonFossil(itemId, otherName, gotFlag)
|
|||||||
end
|
end
|
||||||
local idef = game.data.items[itemId]
|
local idef = game.data.items[itemId]
|
||||||
game.stringBuffer = idef and idef.name or 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)
|
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,
|
game.stack:push(TextBox.new(game,
|
||||||
t._MtMoonB2FReceivedFossilText
|
t._MtMoonB2FReceivedFossilText
|
||||||
or ("{PLAYER} got the\n" .. game.stringBuffer .. "!"),
|
or ("{PLAYER} got the\n" .. game.stringBuffer .. "!"),
|
||||||
@@ -642,11 +670,11 @@ local function mtMoonFossil(itemId, otherName, gotFlag)
|
|||||||
ow.runner:run({
|
ow.runner:run({
|
||||||
{ "walk_npc", 1, dirs },
|
{ "walk_npc", 1, dirs },
|
||||||
{ "text_opts", { auto = true } },
|
{ "text_opts", { auto = true } },
|
||||||
|
{ "text_sound", "Get_Key_Item" },
|
||||||
{ "show_text", "_MtMoonB2FSuperNerdThenThisIsMineText" },
|
{ "show_text", "_MtMoonB2FSuperNerdThenThisIsMineText" },
|
||||||
{ "play_sound", "Get_Key_Item" },
|
|
||||||
{ "hide_object", "MT_MOON_B2F", otherName },
|
{ "hide_object", "MT_MOON_B2F", otherName },
|
||||||
}, { onDone = done })
|
}, { onDone = done })
|
||||||
end))
|
end, TextBox.soundOpts(game, "Get_Key_Item")))
|
||||||
end }))
|
end }))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+22
-21
@@ -8,9 +8,9 @@ local M = {}
|
|||||||
|
|
||||||
local function text(game) return game.data.text end
|
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")
|
local TextBox = require("src.render.TextBox")
|
||||||
game.stack:push(TextBox.new(game, s, done))
|
game.stack:push(TextBox.new(game, s, done, opts))
|
||||||
end
|
end
|
||||||
|
|
||||||
-- The question stays on screen under the YES/NO menu. The dojo prize
|
-- The question stays on screen under the YES/NO menu. The dojo prize
|
||||||
@@ -142,7 +142,7 @@ M.MT_MOON_POKECENTER = {
|
|||||||
local Commands = require("src.script.Commands")
|
local Commands = require("src.script.Commands")
|
||||||
Commands.give_pokemon({ save = game.save, game = game, overworld = ow },
|
Commands.give_pokemon({ save = game.save, game = game, overworld = ow },
|
||||||
"MAGIKARP", 5)
|
"MAGIKARP", 5)
|
||||||
push(game, ("%s got a\nMAGIKARP!"):format(game.save.player.name), done)
|
push(game, t._GotMonText or "{PLAYER} got\n{RAM:wNameBuffer}!", done)
|
||||||
end)
|
end)
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
@@ -263,7 +263,7 @@ M.SILPH_CO_7F = {
|
|||||||
{ "jump_if_false", "box_full" },
|
{ "jump_if_false", "box_full" },
|
||||||
-- flag ahead of the jingle, like the Celadon EEVEE (#426)
|
-- flag ahead of the jingle, like the Celadon EEVEE (#426)
|
||||||
{ "set_flag", "EVENT_GOT_LAPRAS" },
|
{ "set_flag", "EVENT_GOT_LAPRAS" },
|
||||||
{ "play_sound", "Get_Item1" },
|
{ "text_sound", "Get_Item1" },
|
||||||
{ "show_text", "_GotMonText", { RAM = "LAPRAS" } },
|
{ "show_text", "_GotMonText", { RAM = "LAPRAS" } },
|
||||||
{ "show_text", "_SilphCo7FSilphWorkerM1LaprasDescriptionText" },
|
{ "show_text", "_SilphCo7FSilphWorkerM1LaprasDescriptionText" },
|
||||||
{ "jump", "end" },
|
{ "jump", "end" },
|
||||||
@@ -307,12 +307,11 @@ M.COPYCATS_HOUSE_2F = {
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
game.stringBuffer = game.data.items.TM_MIMIC.name
|
game.stringBuffer = game.data.items.TM_MIMIC.name
|
||||||
require("src.core.Sound").play(game.data, "Get_Item1")
|
|
||||||
Bag.remove(game.save, "POKE_DOLL", 1)
|
Bag.remove(game.save, "POKE_DOLL", 1)
|
||||||
game.save.flags.EVENT_GOT_TM31 = true
|
game.save.flags.EVENT_GOT_TM31 = true
|
||||||
push(game, t._CopycatsHouse2FCopycatReceivedTM31Text, function()
|
push(game, t._CopycatsHouse2FCopycatReceivedTM31Text, function()
|
||||||
push(game, t._CopycatsHouse2FCopycatTM31Explanation1Text, done)
|
push(game, t._CopycatsHouse2FCopycatTM31Explanation1Text, done)
|
||||||
end)
|
end, require("src.render.TextBox").soundOpts(game, "Get_Item1"))
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
end,
|
end,
|
||||||
@@ -478,7 +477,6 @@ M.CELADON_MART_ROOF = {
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
game.save.flags[g.flag] = true
|
game.save.flags[g.flag] = true
|
||||||
require("src.core.Sound").play(game.data, "Get_Item1")
|
|
||||||
local subs = { player = game.save.player.name,
|
local subs = { player = game.save.player.name,
|
||||||
ram = game.data.items[g.tm].name }
|
ram = game.data.items[g.tm].name }
|
||||||
local explain = fill(t[g.explain] or "", subs)
|
local explain = fill(t[g.explain] or "", subs)
|
||||||
@@ -490,7 +488,7 @@ M.CELADON_MART_ROOF = {
|
|||||||
else
|
else
|
||||||
done()
|
done()
|
||||||
end
|
end
|
||||||
end)
|
end, require("src.render.TextBox").soundOpts(game, "Get_Item1"))
|
||||||
end)
|
end)
|
||||||
end,
|
end,
|
||||||
onCancel = done,
|
onCancel = done,
|
||||||
@@ -512,25 +510,28 @@ M.ROUTE_24 = {
|
|||||||
local flags = game.save.flags
|
local flags = game.save.flags
|
||||||
local function battleOrDone()
|
local function battleOrDone()
|
||||||
if ow:trainerDefeated(npc) then
|
if ow:trainerDefeated(npc) then
|
||||||
push(game, "I hate this!\nMy dreams of\nTEAM ROCKET...", done)
|
push(game, text(game)._Route24CooltrainerM1YouCouldBecomeATopLeaderText,
|
||||||
|
done)
|
||||||
else
|
else
|
||||||
ow:engageTrainer(npc, done)
|
ow:engageTrainer(npc, done)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
if not flags.EVENT_GOT_NUGGET then
|
if not flags.EVENT_GOT_NUGGET then
|
||||||
push(game, "Congratulations!\nYou beat our 5\ncontest trainers!\f"
|
local t = text(game)
|
||||||
.. "You just earned a\nfabulous prize!", function()
|
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
|
flags.EVENT_GOT_NUGGET = true
|
||||||
require("src.inventory.Bag").add(game.save, "NUGGET", 1)
|
game.stringBuffer = game.data.items.NUGGET.name
|
||||||
push(game, ("%s received\na NUGGET!"):format(game.save.player.name),
|
push(game, t._Route24CooltrainerM1ReceivedNuggetText, function()
|
||||||
function()
|
push(game, t._Route24CooltrainerM1JoinTeamRocketText,
|
||||||
ask(game, "By the way, would\nyou like to join\nTEAM ROCKET?",
|
battleOrDone)
|
||||||
function()
|
end, require("src.render.TextBox").soundOpts(game, "Get_Item1"))
|
||||||
push(game, "Arrgh! You are\nnot convinced?\fThen I'll show\n"
|
end, require("src.render.TextBox").soundOpts(game, "Get_Item1"))
|
||||||
.. "you my power!", battleOrDone)
|
|
||||||
end)
|
|
||||||
end)
|
|
||||||
end)
|
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
battleOrDone()
|
battleOrDone()
|
||||||
|
|||||||
+36
-18
@@ -4,9 +4,9 @@ local M = {}
|
|||||||
|
|
||||||
local function text(game) return game.data.text end
|
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")
|
local TextBox = require("src.render.TextBox")
|
||||||
game.stack:push(TextBox.new(game, s, done))
|
game.stack:push(TextBox.new(game, s, done, opts))
|
||||||
end
|
end
|
||||||
|
|
||||||
-- fill the extracted text placeholders ({RAM:...}, {PLAYER})
|
-- fill the extracted text placeholders ({RAM:...}, {PLAYER})
|
||||||
@@ -25,8 +25,8 @@ local function gift(opts)
|
|||||||
local t = text(game)
|
local t = text(game)
|
||||||
local itemName = game.data.items[opts.item].name
|
local itemName = game.data.items[opts.item].name
|
||||||
local subs = { ram = itemName, player = game.save.player.name }
|
local subs = { ram = itemName, player = game.save.player.name }
|
||||||
local function say(label, fallback, cb)
|
local function say(label, fallback, cb, sopts)
|
||||||
push(game, fill(t[label] or fallback, subs), cb)
|
push(game, fill(t[label] or fallback, subs), cb, sopts)
|
||||||
end
|
end
|
||||||
if game.save.flags[opts.flag] then
|
if game.save.flags[opts.flag] then
|
||||||
say(opts.already or opts.explain, "It's a useful\nitem, isn't it?", done)
|
say(opts.already or opts.explain, "It's a useful\nitem, isn't it?", done)
|
||||||
@@ -39,15 +39,16 @@ local function gift(opts)
|
|||||||
end
|
end
|
||||||
game.save.flags[opts.flag] = true
|
game.save.flags[opts.flag] = true
|
||||||
local idef = game.data.items[opts.item]
|
local idef = game.data.items[opts.item]
|
||||||
require("src.core.Sound").play(game.data,
|
-- the received texts carry sound_get_item_1 / sound_get_key_item, so
|
||||||
(idef and idef.keyItem) and "Get_Key_Item" or "Get_Item1")
|
-- the jingle only fires once that box has typed out
|
||||||
say(opts.received, "{PLAYER} received\n{RAM:}!", function()
|
say(opts.received, "{PLAYER} received\n{RAM:}!", function()
|
||||||
if opts.explain then
|
if opts.explain then
|
||||||
say(opts.explain, "", done)
|
say(opts.explain, "", done)
|
||||||
else
|
else
|
||||||
done()
|
done()
|
||||||
end
|
end
|
||||||
end)
|
end, require("src.render.TextBox").soundOpts(game,
|
||||||
|
(idef and idef.keyItem) and "Get_Key_Item" or "Get_Item1"))
|
||||||
end
|
end
|
||||||
if opts.pre then say(opts.pre, opts.preFallback or "", give) else give() end
|
if opts.pre then say(opts.pre, opts.preFallback or "", give) else give() end
|
||||||
end
|
end
|
||||||
@@ -445,7 +446,7 @@ local function pewterGymEscort(game, ow)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local function afterWalk()
|
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")
|
Music.playMap(game.data, "PEWTER_CITY")
|
||||||
push(game, t._PewterCityYoungsterGoTakeOnBrockText
|
push(game, t._PewterCityYoungsterGoTakeOnBrockText
|
||||||
or "Go take on BROCK\nat the GYM first!", walkHome)
|
or "Go take on BROCK\nat the GYM first!", walkHome)
|
||||||
@@ -468,6 +469,11 @@ local function pewterGymEscort(game, ow)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local function beginWalk()
|
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")
|
Music.play(game.data, "Music_MuseumGuy")
|
||||||
if guy and head > 0 then
|
if guy and head > 0 then
|
||||||
local h = 0
|
local h = 0
|
||||||
@@ -508,12 +514,12 @@ M.PEWTER_CITY = {
|
|||||||
-- Rival ambush: show the hidden rival, walk him up to the player, run
|
-- 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
|
-- the battle rows, march him back and hide him. On a loss the walk is
|
||||||
-- skipped (the blackout rebuilds the map mid-script).
|
-- 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
|
if ow.runner:isRunning() then return false end
|
||||||
ow.player.facing = playerFacing
|
ow.player.facing = playerFacing
|
||||||
-- the rival encounter sting (MUSIC_MEET_RIVAL); the battle music
|
-- the rival encounter sting (MUSIC_MEET_RIVAL); the battle music
|
||||||
-- takes over and the map theme returns after the victory jingle
|
-- 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)
|
ow.runner:run(rows)
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
@@ -567,10 +573,12 @@ local function route22Scene(n, objIndex, objName, oppClass, baseParty, beatFlag,
|
|||||||
{ "face_object", objIndex, rivalFacing }, -- 3
|
{ "face_object", objIndex, rivalFacing }, -- 3
|
||||||
{ "show_text", "_Route22RivalBeforeBattleText" .. n }, -- 4
|
{ "show_text", "_Route22RivalBeforeBattleText" .. n }, -- 4
|
||||||
{ "rival_battle", oppClass, baseParty }, -- 5
|
{ "rival_battle", oppClass, baseParty }, -- 5
|
||||||
{ "jump_if_false", 11 }, -- 6
|
{ "jump_if_false", 12 }, -- 6
|
||||||
{ "set_flag", beatFlag }, -- 7
|
{ "set_flag", beatFlag }, -- 7
|
||||||
{ "show_text", "_Route22Rival" .. n .. "DefeatedText" }, -- 8
|
{ "show_text", "_Route22Rival" .. n .. "DefeatedText" }, -- 8
|
||||||
{ "show_text", "_Route22RivalAfterBattleText" .. n }, -- 9
|
{ "show_text", "_Route22RivalAfterBattleText" .. n }, -- 9
|
||||||
|
{ "play_music", "Music_MeetRival", { start = "rival",
|
||||||
|
tempo = n == 2 and 100 or nil } },
|
||||||
{ "walk_npc", objIndex, route22ExitDirs(n, py) }, -- 10
|
{ "walk_npc", objIndex, route22ExitDirs(n, py) }, -- 10
|
||||||
{ "hide_object", "ROUTE_22", objName }, -- 11
|
{ "hide_object", "ROUTE_22", objName }, -- 11
|
||||||
}
|
}
|
||||||
@@ -594,7 +602,8 @@ M.ROUTE_22 = {
|
|||||||
if f.EVENT_BEAT_GIOVANNI and not f.EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE then
|
if f.EVENT_BEAT_GIOVANNI and not f.EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE then
|
||||||
return runAmbush(game, ow,
|
return runAmbush(game, ow,
|
||||||
route22Scene(2, 2, "ROUTE22_RIVAL2", "OPP_RIVAL2", 10,
|
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
|
end
|
||||||
return false
|
return false
|
||||||
end,
|
end,
|
||||||
@@ -618,10 +627,11 @@ local function ceruleanRivalScene(px, py)
|
|||||||
{ "face_object", 1, "down" }, -- 3
|
{ "face_object", 1, "down" }, -- 3
|
||||||
{ "show_text", "_CeruleanCityRivalPreBattleText" }, -- 4
|
{ "show_text", "_CeruleanCityRivalPreBattleText" }, -- 4
|
||||||
{ "rival_battle", "OPP_RIVAL1", 7 }, -- 5
|
{ "rival_battle", "OPP_RIVAL1", 7 }, -- 5
|
||||||
{ "jump_if_false", 11 }, -- 6
|
{ "jump_if_false", 12 }, -- 6
|
||||||
{ "set_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 7
|
{ "set_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 7
|
||||||
{ "show_text", "_CeruleanCityRivalDefeatedText" }, -- 8
|
{ "show_text", "_CeruleanCityRivalDefeatedText" }, -- 8
|
||||||
{ "show_text", "_CeruleanCityRivalIWentToBillsText" }, -- 9
|
{ "show_text", "_CeruleanCityRivalIWentToBillsText" }, -- 9
|
||||||
|
{ "play_music", "Music_MeetRival", { start = "rival" } },
|
||||||
{ "walk_npc", 1, ceruleanRivalExitDirs(px) }, -- 10
|
{ "walk_npc", 1, ceruleanRivalExitDirs(px) }, -- 10
|
||||||
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 11
|
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 11
|
||||||
}
|
}
|
||||||
@@ -730,7 +740,7 @@ local JIGGLYPUFF_SILENCE, JIGGLYPUFF_STEP, JIGGLYPUFF_TAIL = 32, 24, 48
|
|||||||
-- Built as a TextBox `auto` table: auto.sound fires the frame the last
|
-- 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
|
-- page has typed out (PrintText returning), and auto.tick then runs once
|
||||||
-- per frame while the gate it returns still reads as playing.
|
-- 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")
|
local Music = require("src.core.Music")
|
||||||
-- .findMatchingFacingDirectionLoop: the rotation picks up at the entry
|
-- .findMatchingFacingDirectionLoop: the rotation picks up at the entry
|
||||||
-- matching the sprite's current facing (showMapText has just turned it
|
-- matching the sprite's current facing (showMapText has just turned it
|
||||||
@@ -776,7 +786,13 @@ local function jigglypuffDance(game, npc)
|
|||||||
if npc then npc.facing = JIGGLYPUFF_SPIN[step] end
|
if npc then npc.facing = JIGGLYPUFF_SPIN[step] end
|
||||||
return
|
return
|
||||||
end
|
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,
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
@@ -789,7 +805,7 @@ M.PEWTER_POKECENTER = {
|
|||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
text(game)._PewterPokecenterJigglypuffText or "JIGGLYPUFF: Puu\npupuu!",
|
text(game)._PewterPokecenterJigglypuffText or "JIGGLYPUFF: Puu\npupuu!",
|
||||||
done, { auto = jigglypuffDance(game, npc) }))
|
done, { auto = jigglypuffDance(game, npc, ow) }))
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -864,10 +880,11 @@ M.SILPH_CO_7F = {
|
|||||||
{ "face_object", 9, "up" }, -- 4
|
{ "face_object", 9, "up" }, -- 4
|
||||||
{ "show_text", "_SilphCo7FRivalWaitedHereText" }, -- 5
|
{ "show_text", "_SilphCo7FRivalWaitedHereText" }, -- 5
|
||||||
{ "rival_battle", "OPP_RIVAL2", 7 }, -- 6
|
{ "rival_battle", "OPP_RIVAL2", 7 }, -- 6
|
||||||
{ "jump_if_false", 12 }, -- 7
|
{ "jump_if_false", 13 }, -- 7
|
||||||
{ "set_flag", "EVENT_BEAT_SILPH_CO_RIVAL" }, -- 8
|
{ "set_flag", "EVENT_BEAT_SILPH_CO_RIVAL" }, -- 8
|
||||||
{ "show_text", "_SilphCo7FRivalDefeatedText" }, -- 9
|
{ "show_text", "_SilphCo7FRivalDefeatedText" }, -- 9
|
||||||
{ "show_text", "_SilphCo7FRivalGoodLuckToYouText" }, -- 10
|
{ "show_text", "_SilphCo7FRivalGoodLuckToYouText" }, -- 10
|
||||||
|
{ "play_music", "Music_MeetRival", { start = "rival" } },
|
||||||
{ "move_npc_to", 9, 5, y + 1 }, -- 11
|
{ "move_npc_to", 9, 5, y + 1 }, -- 11
|
||||||
{ "hide_object", "SILPH_CO_7F", "SILPHCO7F_RIVAL" }, -- 12
|
{ "hide_object", "SILPH_CO_7F", "SILPHCO7F_RIVAL" }, -- 12
|
||||||
}, "down")
|
}, "down")
|
||||||
@@ -899,10 +916,11 @@ M.SS_ANNE_2F = {
|
|||||||
{ "face_object", 2, onLeft and "down" or "right" }, -- 3
|
{ "face_object", 2, onLeft and "down" or "right" }, -- 3
|
||||||
{ "show_text", "_SSAnne2FRivalText" }, -- 4
|
{ "show_text", "_SSAnne2FRivalText" }, -- 4
|
||||||
{ "rival_battle", "OPP_RIVAL2", 1 }, -- 5
|
{ "rival_battle", "OPP_RIVAL2", 1 }, -- 5
|
||||||
{ "jump_if_false", 11 }, -- 6
|
{ "jump_if_false", 12 }, -- 6
|
||||||
{ "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 7
|
{ "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 7
|
||||||
{ "show_text", "_SSAnne2FRivalDefeatedText" }, -- 8
|
{ "show_text", "_SSAnne2FRivalDefeatedText" }, -- 8
|
||||||
{ "show_text", "_SSAnne2FRivalCutMasterText" }, -- 9
|
{ "show_text", "_SSAnne2FRivalCutMasterText" }, -- 9
|
||||||
|
{ "play_music", "Music_MeetRival", { start = "rival" } },
|
||||||
{ "walk_npc", 2, ssAnne2FRivalExitDirs(onLeft) }, -- 10
|
{ "walk_npc", 2, ssAnne2FRivalExitDirs(onLeft) }, -- 10
|
||||||
{ "hide_object", "SS_ANNE_2F", "SSANNE2F_RIVAL" }, -- 11
|
{ "hide_object", "SS_ANNE_2F", "SSANNE2F_RIVAL" }, -- 11
|
||||||
}, onLeft and "up" or "left")
|
}, onLeft and "up" or "left")
|
||||||
|
|||||||
@@ -28,6 +28,14 @@
|
|||||||
-- is full; `gotFlag` (pokered's EVENT_GOT_TM*) is set only on a
|
-- 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
|
-- successful give, which is what makes the leader's talk script retry
|
||||||
-- later (gyms.lua).
|
-- 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 function range(prefix, first, last)
|
||||||
local t = {}
|
local t = {}
|
||||||
@@ -50,6 +58,8 @@ return {
|
|||||||
{ "PEWTER_CITY", "PEWTERCITY_YOUNGSTER" },
|
{ "PEWTER_CITY", "PEWTERCITY_YOUNGSTER" },
|
||||||
{ "ROUTE_22", "ROUTE22_RIVAL1" },
|
{ "ROUTE_22", "ROUTE22_RIVAL1" },
|
||||||
},
|
},
|
||||||
|
badgeSound = "Get_Item1", -- sound_level_up
|
||||||
|
tmSound = "Get_Item1",
|
||||||
dialogue = {
|
dialogue = {
|
||||||
"_PewterGymBrockReceivedBoulderBadgeText",
|
"_PewterGymBrockReceivedBoulderBadgeText",
|
||||||
"_PewterGymBrockBoulderBadgeInfoText",
|
"_PewterGymBrockBoulderBadgeInfoText",
|
||||||
@@ -64,6 +74,8 @@ return {
|
|||||||
gotFlag = "EVENT_GOT_TM11",
|
gotFlag = "EVENT_GOT_TM11",
|
||||||
noRoom = "_CeruleanGymMistyTM11NoRoomText",
|
noRoom = "_CeruleanGymMistyTM11NoRoomText",
|
||||||
deactivate = range("EVENT_BEAT_CERULEAN_GYM_TRAINER_", 0, 1),
|
deactivate = range("EVENT_BEAT_CERULEAN_GYM_TRAINER_", 0, 1),
|
||||||
|
badgeSound = "Get_Key_Item",
|
||||||
|
tmSound = "Get_Item1",
|
||||||
dialogue = {
|
dialogue = {
|
||||||
"_CeruleanGymMistyReceivedCascadeBadgeText",
|
"_CeruleanGymMistyReceivedCascadeBadgeText",
|
||||||
},
|
},
|
||||||
@@ -76,6 +88,7 @@ return {
|
|||||||
gotFlag = "EVENT_GOT_TM24",
|
gotFlag = "EVENT_GOT_TM24",
|
||||||
noRoom = "_VermilionGymLTSurgeTM24NoRoomText",
|
noRoom = "_VermilionGymLTSurgeTM24NoRoomText",
|
||||||
deactivate = range("EVENT_BEAT_VERMILION_GYM_TRAINER_", 0, 2),
|
deactivate = range("EVENT_BEAT_VERMILION_GYM_TRAINER_", 0, 2),
|
||||||
|
tmSound = "Get_Key_Item",
|
||||||
dialogue = {
|
dialogue = {
|
||||||
"_VermilionGymLTSurgeReceivedThunderBadgeText",
|
"_VermilionGymLTSurgeReceivedThunderBadgeText",
|
||||||
},
|
},
|
||||||
@@ -89,6 +102,7 @@ return {
|
|||||||
gotFlag = "EVENT_GOT_TM21",
|
gotFlag = "EVENT_GOT_TM21",
|
||||||
noRoom = "_CeladonGymTM21NoRoomText",
|
noRoom = "_CeladonGymTM21NoRoomText",
|
||||||
deactivate = range("EVENT_BEAT_CELADON_GYM_TRAINER_", 0, 6),
|
deactivate = range("EVENT_BEAT_CELADON_GYM_TRAINER_", 0, 6),
|
||||||
|
tmSound = "Get_Item1",
|
||||||
dialogue = {
|
dialogue = {
|
||||||
"_CeladonGymErikaReceivedRainbowBadgeText",
|
"_CeladonGymErikaReceivedRainbowBadgeText",
|
||||||
},
|
},
|
||||||
@@ -102,6 +116,7 @@ return {
|
|||||||
gotFlag = "EVENT_GOT_TM06",
|
gotFlag = "EVENT_GOT_TM06",
|
||||||
noRoom = "_FuchsiaGymKogaTM06NoRoomText",
|
noRoom = "_FuchsiaGymKogaTM06NoRoomText",
|
||||||
deactivate = range("EVENT_BEAT_FUCHSIA_GYM_TRAINER_", 0, 5),
|
deactivate = range("EVENT_BEAT_FUCHSIA_GYM_TRAINER_", 0, 5),
|
||||||
|
tmSound = "Get_Key_Item",
|
||||||
dialogue = {
|
dialogue = {
|
||||||
"_FuchsiaGymKogaReceivedSoulBadgeText",
|
"_FuchsiaGymKogaReceivedSoulBadgeText",
|
||||||
},
|
},
|
||||||
@@ -115,6 +130,8 @@ return {
|
|||||||
gotFlag = "EVENT_GOT_TM46",
|
gotFlag = "EVENT_GOT_TM46",
|
||||||
noRoom = "_SaffronGymSabrinaTM46NoRoomText",
|
noRoom = "_SaffronGymSabrinaTM46NoRoomText",
|
||||||
deactivate = range("EVENT_BEAT_SAFFRON_GYM_TRAINER_", 0, 6),
|
deactivate = range("EVENT_BEAT_SAFFRON_GYM_TRAINER_", 0, 6),
|
||||||
|
badgeSound = "Get_Key_Item",
|
||||||
|
tmSound = "Get_Item1",
|
||||||
dialogue = {
|
dialogue = {
|
||||||
"_SaffronGymSabrinaReceivedMarshBadgeText",
|
"_SaffronGymSabrinaReceivedMarshBadgeText",
|
||||||
},
|
},
|
||||||
@@ -128,6 +145,8 @@ return {
|
|||||||
gotFlag = "EVENT_GOT_TM38",
|
gotFlag = "EVENT_GOT_TM38",
|
||||||
noRoom = "_CinnabarGymBlaineTM38NoRoomText",
|
noRoom = "_CinnabarGymBlaineTM38NoRoomText",
|
||||||
deactivate = range("EVENT_BEAT_CINNABAR_GYM_TRAINER_", 0, 6),
|
deactivate = range("EVENT_BEAT_CINNABAR_GYM_TRAINER_", 0, 6),
|
||||||
|
badgeSound = "Get_Key_Item",
|
||||||
|
tmSound = "Get_Item1",
|
||||||
dialogue = {
|
dialogue = {
|
||||||
"_CinnabarGymBlaineReceivedVolcanoBadgeText",
|
"_CinnabarGymBlaineReceivedVolcanoBadgeText",
|
||||||
},
|
},
|
||||||
@@ -141,6 +160,8 @@ return {
|
|||||||
gotFlag = "EVENT_GOT_TM27",
|
gotFlag = "EVENT_GOT_TM27",
|
||||||
noRoom = "_ViridianGymGiovanniTM27NoRoomText",
|
noRoom = "_ViridianGymGiovanniTM27NoRoomText",
|
||||||
deactivate = range("EVENT_BEAT_VIRIDIAN_GYM_TRAINER_", 0, 7),
|
deactivate = range("EVENT_BEAT_VIRIDIAN_GYM_TRAINER_", 0, 7),
|
||||||
|
badgeSound = "Get_Item1", -- sound_level_up
|
||||||
|
tmSound = "Get_Item1",
|
||||||
dialogue = {
|
dialogue = {
|
||||||
"_ViridianGymGiovanniReceivedEarthBadgeText",
|
"_ViridianGymGiovanniReceivedEarthBadgeText",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ M.CERULEAN_MELANIES_HOUSE = {
|
|||||||
rows[#rows + 1] = { "label", "declined" }
|
rows[#rows + 1] = { "label", "declined" }
|
||||||
rows[#rows + 1] = { "show_text", "MelanieText5" }
|
rows[#rows + 1] = { "show_text", "MelanieText5" }
|
||||||
end
|
end
|
||||||
ow.runner:run(rows, { npc = npc, onDone = done })
|
ow.runner:run(rows, { npc = npc, onDone = done,
|
||||||
|
checkpointOnDone = "release_npc" })
|
||||||
end,
|
end,
|
||||||
-- pet flavor: the text with the species' cry over it
|
-- pet flavor: the text with the species' cry over it
|
||||||
TEXT_CERULEANMELANIESHOUSE_BULBASAUR = {
|
TEXT_CERULEANMELANIESHOUSE_BULBASAUR = {
|
||||||
@@ -105,7 +106,8 @@ M.VERMILION_CITY = {
|
|||||||
rows[#rows + 1] = { "label", "declined" }
|
rows[#rows + 1] = { "label", "declined" }
|
||||||
rows[#rows + 1] = { "show_text", "_OfficerJennyText4" }
|
rows[#rows + 1] = { "show_text", "_OfficerJennyText4" }
|
||||||
end
|
end
|
||||||
ow.runner:run(rows, { npc = npc, onDone = done })
|
ow.runner:run(rows, { npc = npc, onDone = done,
|
||||||
|
checkpointOnDone = "release_npc" })
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,7 +100,8 @@ local function oldMan2Talk(game, ow, npc, done)
|
|||||||
game.stack:push(TextBox.new(game, text(game).losingMyTouch, done))
|
game.stack:push(TextBox.new(game, text(game).losingMyTouch, done))
|
||||||
return
|
return
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
M.VIRIDIAN_CITY = {
|
M.VIRIDIAN_CITY = {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Sideload the iOS build with AltStore
|
# Sideload the iOS build with AltStore
|
||||||
|
|
||||||
Every GitHub Release ships an IPA (`gen1recomp-*-ios.ipa`). Install it on
|
Every GitHub Release ships an IPA (`gen1recomp++-*-ios.ipa`). Install it on
|
||||||
your iPhone or iPad with [AltStore Classic](https://altstore.io/) — AltStore
|
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
|
re-signs the app with **your** free Apple ID so you do not need a Mac or
|
||||||
Xcode.
|
Xcode.
|
||||||
@@ -23,7 +23,7 @@ Stuck? Start here:
|
|||||||
|
|
||||||
## 2. Install the game
|
## 2. Install the game
|
||||||
|
|
||||||
1. Download `gen1recomp-*-ios.ipa` from
|
1. Download `gen1recomp++-*-ios.ipa` from
|
||||||
[Releases](https://github.com/bryanthaboi/gen1recomp/releases).
|
[Releases](https://github.com/bryanthaboi/gen1recomp/releases).
|
||||||
2. Open **AltStore** on the phone (AltServer must be running on the same
|
2. Open **AltStore** on the phone (AltServer must be running on the same
|
||||||
Wi‑Fi, or keep the phone plugged into the computer).
|
Wi‑Fi, or keep the phone plugged into the computer).
|
||||||
|
|||||||
+9
-6
@@ -16,6 +16,7 @@ save directory as:
|
|||||||
| nil / `"rom"` | `picked_rom.gb` (open) |
|
| nil / `"rom"` | `picked_rom.gb` (open) |
|
||||||
| `"mod"` | `picked_mod.zip` (open) |
|
| `"mod"` | `picked_mod.zip` (open) |
|
||||||
| `"sav"` / `"save"` | `picked_save.sav` (open) |
|
| `"sav"` / `"save"` | `picked_save.sav` (open) |
|
||||||
|
| `"required_import"` | `picked_required_import.bin` (open) |
|
||||||
|
|
||||||
Export uses a separate API: `love.system.createFile(suggestedName)` →
|
Export uses a separate API: `love.system.createFile(suggestedName)` →
|
||||||
`GameActivity.showCreateDocument` (`ACTION_CREATE_DOCUMENT`), which copies
|
`GameActivity.showCreateDocument` (`ACTION_CREATE_DOCUMENT`), which copies
|
||||||
@@ -130,18 +131,20 @@ before `Game:load`, so **it never loads a mod's entry chunk**; only
|
|||||||
duplicate) and returns one row per mod:
|
duplicate) and returns one row per mod:
|
||||||
`{id, name, version, badge, description, enabled, status, statusDetail}`.
|
`{id, name, version, badge, description, enabled, status, statusDetail}`.
|
||||||
`badge` is the manifest's `category`, falling back to `profile`, then
|
`badge` is the manifest's `category`, falling back to `profile`, then
|
||||||
`"MOD"`, uppercased. `enabled` reads `options.mods[id]` (missing means
|
`"MOD"`, uppercased. `enabledByVersion` contains an answer for each game;
|
||||||
enabled, matching the loader's own default).
|
missing entries default to enabled (except experimental mods), matching the
|
||||||
|
loader. On the first run with per-game controls, legacy shared choices are
|
||||||
|
copied to every installed game's answer.
|
||||||
- `status` is `"ok"`, `"warn"`, or `"conflict"`, computed by the pure
|
- `status` is `"ok"`, `"warn"`, or `"conflict"`, computed by the pure
|
||||||
`LauncherMods.deriveList`/`statusFor` against `ManagerState.resolveToggle`
|
`LauncherMods.deriveList`/`statusFor` against `ManagerState.resolveToggle`
|
||||||
and the validated manifests: `conflict` when enabling this mod collides
|
and the validated manifests: `conflict` when enabling this mod collides
|
||||||
with another enabled one; `warn` for an out-of-range `game_version` or an
|
with another enabled one; `warn` for an out-of-range `game_version` or an
|
||||||
absent/disabled/wrong-version hard dependency; `ok` otherwise. Having no
|
absent/disabled/wrong-version hard dependency; `ok` otherwise. Having no
|
||||||
`love.*` calls, this half is table-driven by the test suite on its own.
|
`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
|
- `LauncherMods.setEnabled(id, bool, version)` persists the selected game's
|
||||||
boolean, the exact shape `Loader:_saveState` writes, so the running game
|
answer, so the running game and the in-game `ManagerState` see the change on
|
||||||
and the in-game `ManagerState` see the change on next boot. The mods panel
|
next boot. The MODS panel renders a coloured checkbox for Red, Blue, Yellow,
|
||||||
calls this on every toggle and re-derives the list right away
|
and Gold on every row and re-derives the list right away
|
||||||
(`RomImporter:_refreshMods`) so a status change (e.g. a new conflict)
|
(`RomImporter:_refreshMods`) so a status change (e.g. a new conflict)
|
||||||
shows without waiting for a reload.
|
shows without waiting for a reload.
|
||||||
- `LauncherMods.installZip(path)` mounts the archive with
|
- `LauncherMods.installZip(path)` mounts the archive with
|
||||||
|
|||||||
@@ -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.
|
||||||
+33
-17
@@ -96,14 +96,14 @@ 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)`,
|
dependency does not run on the selected game reads `Needs <id> (not for Gold)`,
|
||||||
matching the loader's contagious skip.
|
matching the loader's contagious skip.
|
||||||
|
|
||||||
A separate overlay, `options.modsByVersion[version][id]`, is where a per-game
|
A separate overlay, `options.modsByVersion[version][id]`, holds each game's
|
||||||
enable flag will live. It is a preview: `SaveData.PER_VERSION_MODS` is `false`,
|
enable flag. The launcher shows a coloured Red / Blue / Yellow / Gold checkbox
|
||||||
so `SaveData.modScope` answers nil for every caller and the launcher panel, the
|
for every installed mod, and the loader and in-game manager read the same
|
||||||
in-game manager and the loader all read *and* write the one shared
|
game-specific answer on the next boot. On the first launch after this feature,
|
||||||
`options.mods` flag. Nothing consults the overlay for enablement until that
|
the existing shared state is copied to every game, so a mod that was enabled
|
||||||
flips, which is deliberate: the overlay is plantable from an imported
|
remains enabled everywhere; after that, changing one checkbox affects only
|
||||||
`.g1rmodlist`, and a reader scoped differently from the writers would show a
|
that game. New mods still default to enabled on every game (experimental mods
|
||||||
mod set no boot would honour.
|
retain their explicit opt-in default).
|
||||||
|
|
||||||
That is deliberate. Gold reimplements the battle engine, the overworld, the
|
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
|
script VM and the save format, so a Gen 1 mod dropped into a Gold boot would
|
||||||
@@ -118,10 +118,11 @@ Gen 2 games no longer loads on Red, Blue or Yellow. Say `["all"]` or list both
|
|||||||
generations if you want both.
|
generations if you want both.
|
||||||
|
|
||||||
Two riders. **A hard dependency that does not run here takes the dependent down
|
Two riders. **A hard dependency that does not run here takes the dependent down
|
||||||
with it**, as a skip rather than a failure and carrying the dependency's own
|
with it** (unless scoped to specific games, e.g.
|
||||||
wording (`depends on X, which does not run here (For Blue, not Red)`), so the
|
`dependencies: [{ id = "x", games = ["gen2"] }]`), as a skip rather than a
|
||||||
whole chain has to cover the same games. And **the claim is yours, not the last
|
failure and carrying the dependency's own wording (`depends on X, which does not
|
||||||
word**: it is the manager's `TRY HERE ANYWAY` row that lets a player run a mod
|
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
|
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
|
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
|
`{ [version] = true }` table, so forcing a mod onto Red does not force it onto
|
||||||
@@ -239,7 +240,7 @@ resolves to the weaker claim:
|
|||||||
| `warned` | present, answers nil or degrades, and names itself once with the mod attributed |
|
| `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 |
|
| `absent` | deliberately not served; a nil read is the honest failure |
|
||||||
|
|
||||||
Today that is 288 backed, 32 warned and 161 absent across the fifteen modules.
|
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
|
`notes` keys are documentation topics rather than a member list -- dotted paths
|
||||||
(`save.money`), field names (`warpAt`), hook names (`hook ui.pc.items`) and
|
(`save.money`), field names (`warpAt`), hook names (`hook ui.pc.items`) and
|
||||||
bare topics (`identity`, `iteration`, `rawset`) all appear there. `members` is
|
bare topics (`identity`, `iteration`, `rawset`) all appear there. `members` is
|
||||||
@@ -474,6 +475,8 @@ Generation-agnostic; nothing to adapt.
|
|||||||
(`src/world/gen2/WorldAPI.lua`). Two differences show through and are
|
(`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
|
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.
|
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
|
`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
|
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
|
an extracted one and survives a map reload; it is not serialized, so a mod
|
||||||
@@ -483,6 +486,11 @@ has its own entry points for (`start_battle "wild" species level`, `warp`,
|
|||||||
**by name, before the first row runs**, so a mod never gets a half-run queue.
|
**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
|
`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.
|
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
|
**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
|
carrying the Gen 1 payload keys, because Gold's call sites reuse them rather
|
||||||
@@ -531,8 +539,9 @@ gains a field instead of the name gaining a prefix.
|
|||||||
`pokemon.level_up`, `pokemon.move_learned`; hooks `battle.damage`,
|
`pokemon.level_up`, `pokemon.move_learned`; hooks `battle.damage`,
|
||||||
`battle.crit`, `battle.accuracy`, `battle.turn_order`,
|
`battle.crit`, `battle.accuracy`, `battle.turn_order`,
|
||||||
`battle.enemy_action`, `battle.run`, `battle.exp_award`, `exp.gain`,
|
`battle.enemy_action`, `battle.run`, `battle.exp_award`, `exp.gain`,
|
||||||
`catch.rate`, `trainer.party`, `battle.overlay`, `battle.low_health_alarm`
|
`catch.rate`, `trainer.party`, `battle.overlay`, `battle.low_health_alarm`,
|
||||||
and `battle.catch_exp`. One payload difference: Gen 1's vanilla
|
`battle.catch_exp`, `battle.bottom_ui_visible` and
|
||||||
|
`battle.status_hud_visible`. One payload difference: Gen 1's vanilla
|
||||||
`battle.low_health_alarm` link reads `ctx.battle.data`, and Gold's battle
|
`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
|
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
|
Gen 1 keys. A mod that calls `nextFn` is unaffected; one that reaches through
|
||||||
@@ -544,11 +553,13 @@ gains a field instead of the name gaining a prefix.
|
|||||||
each row's decision in `evolution.check`. The hook passes `data` where Gen 1
|
each row's decision in `evolution.check`. The hook passes `data` where Gen 1
|
||||||
passes `game`; positions 2-4 (mon, row, trigger) match.
|
passes `game`; positions 2-4 (mon, row, trigger) match.
|
||||||
- *The frame (`src/core/Game2.lua`):* hooks `input.step`, `input.pointer`,
|
- *The frame (`src/core/Game2.lua`):* hooks `input.step`, `input.pointer`,
|
||||||
`render.zones`, `render.compose`, `render.letterbox`, `render.hud`. Each sits
|
`render.zones`, `render.compose`, `render.output_enabled`, `render.output`,
|
||||||
|
`render.letterbox`, `render.hud`. Each sits
|
||||||
at the same moment `src/core/Game.lua` and `src/render/Renderer.lua` raise it
|
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
|
-- 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
|
first refusal on, the palette zone list handed to the present pass, the
|
||||||
letterbox, and the finished playfield rect -- and carries the same payload.
|
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
|
`render.hud`'s `gameX` / `gameY` really is where Gold's dialogue boxes and
|
||||||
menus land, because `Chrome.fitScale` / `fitOrigin` and `World:fitScale`
|
menus land, because `Chrome.fitScale` / `fitOrigin` and `World:fitScale`
|
||||||
compute the same number. `render.zones` is handed `nil` in GBC mode (Gold
|
compute the same number. `render.zones` is handed `nil` in GBC mode (Gold
|
||||||
@@ -757,6 +768,11 @@ name and the existing payload, plus fields where Gen 2 genuinely carries more
|
|||||||
|
|
||||||
The list is much shorter than it was. What is outstanding, in descending value:
|
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
|
- `pokemon.before_give` / `pokemon.received`: Gold has no give-mon seam of its
|
||||||
own yet.
|
own yet.
|
||||||
- `link.*` and `trade.completed`: a Gold boot offers no link menu at all. The
|
- `link.*` and `trade.completed`: a Gold boot offers no link menu at all. The
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# 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`.
|
||||||
|
|
||||||
|
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 shape or the meaning of an
|
||||||
|
existing field. 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.
|
||||||
+569
-16
@@ -21,6 +21,146 @@ luajit tools/gen_registry_docs.lua
|
|||||||
luajit tools/gen_registry_docs.lua ../gen1recomp.wiki
|
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)
|
## Mods and Gold (Gen 2)
|
||||||
|
|
||||||
The mod API is one API across both generations, but Gold runs its own battle
|
The mod API is one API across both generations, but Gold runs its own battle
|
||||||
@@ -66,7 +206,34 @@ optional visual `tileRows` at 2x resolution, and optional `tileDetailRows` at
|
|||||||
`"3"` (darkest); their matching width and height fields describe the grid.
|
`"3"` (darkest); their matching width and height fields describe the grid.
|
||||||
`markers` contains active `{ kind, x, y }` points in map-cell coordinates for
|
`markers` contains active `{ kind, x, y }` points in map-cell coordinates for
|
||||||
`warp`, visible `item`, and untaken `hidden` locations. All fields are
|
`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. 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.
|
||||||
|
|
||||||
|
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. 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.
|
||||||
|
|
||||||
## Rendering pipelines
|
## Rendering pipelines
|
||||||
|
|
||||||
@@ -229,16 +396,49 @@ local keys, code, message = mod.storage:list(game, "history/quick")
|
|||||||
local deleted, code, message = mod.storage:delete(game, "history/quick/q0001")
|
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
|
`context` returns `{ engineVersion, gameVersion, playthroughId }`. The engine
|
||||||
version is compatibility metadata; physical launcher-slot and path identity stays
|
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
|
At the title screen only, `mod.storage:selected(game)` returns a bound storage
|
||||||
slash-separated segments (letters, digits, `_`, `-`); paths and filesystem
|
facade for the launcher-selected existing playthrough, or `nil, code, message`.
|
||||||
handles are never exposed. Writes are staged and decode-verified, reads recover
|
Resolving this facade is non-allocating: it never allocates an identity, adopts a
|
||||||
from a valid staged/backup generation, and methods return structured errors for
|
fresh New Game, or exposes a slot id/path. Its `context()`, `read(key)`,
|
||||||
normal data or I/O failures. The playthrough identity is allocated lazily on the
|
`write(key, value)`, `readBytes(key)`, `writeBytes(key, bytes)`,
|
||||||
first storage/checkpoint call, so an unused API changes no save 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:
|
`mod.checkpoints` captures and reconstructs engine-owned semantic runtime state:
|
||||||
|
|
||||||
@@ -250,13 +450,21 @@ if capability.canCapture then
|
|||||||
end
|
end
|
||||||
|
|
||||||
local ok, code, message = mod.checkpoints:restore(game, checkpoint)
|
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
|
Checkpoint format 1 supports settled overworld control and proven battle
|
||||||
player-decision safe points. Battle checkpoints are limited to ordinary
|
player-decision safe points. Ordinary single-player wild/trainer encounters are
|
||||||
single-player wild/trainer origins with no suspended script; link, Safari,
|
supported. Scripted story battles are also supported when the engine can detach
|
||||||
ghost, demo, scripted, animation, message, queue, and forced-action phases fail
|
their current built-in battle command and data-only row continuation, rebind any
|
||||||
closed. New checkpoints preserve gameplay RNG, while legacy overworld records
|
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
|
without RNG remain loadable. Capture excludes global options and runtime
|
||||||
objects. Restore validates format, game/playthrough identity, content,
|
objects. Restore validates format, game/playthrough identity, content,
|
||||||
coordinates, battle relationships, continuation, and RNG before mutation;
|
coordinates, battle relationships, continuation, and RNG before mutation;
|
||||||
@@ -293,7 +501,74 @@ private state. A mod that deliberately stores progress-coupled truth in
|
|||||||
cannot distinguish it safely from independent history, configuration, or cache
|
cannot distinguish it safely from independent history, configuration, or cache
|
||||||
data.
|
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
|
## Developer console
|
||||||
|
|
||||||
@@ -382,6 +657,20 @@ 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,
|
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.
|
or push one onto a second screen, without the engine knowing the layout.
|
||||||
|
|
||||||
|
`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
|
`screen.render_visible` receives `(next, state)` while the main screen is being
|
||||||
composed. Return `false` to omit that state from drawing, opacity selection and
|
composed. Return `false` to omit that state from drawing, opacity selection and
|
||||||
palette-zone ownership. The state remains on the stack and keeps its normal
|
palette-zone ownership. The state remains on the stack and keeps its normal
|
||||||
@@ -397,9 +686,13 @@ identifiers: `pc_box_withdraw`, `pc_box_deposit`, `pc_box_release`,
|
|||||||
`battle.bottom_ui_visible` and `battle.status_hud_visible` independently
|
`battle.bottom_ui_visible` and `battle.status_hud_visible` independently
|
||||||
control the battle text/menu layer and the HP/status panels. Both receive
|
control the battle text/menu layer and the HP/status panels. Both receive
|
||||||
`(next, state)` and default to `true`, so vanilla rendering is unchanged.
|
`(next, state)` and default to `true`, so vanilla rendering is unchanged.
|
||||||
Pushed text boxes also pass through `battle.bottom_ui_visible`; a wrapper that
|
Both hooks apply to Gen 1 and Gen 2 battles.
|
||||||
only owns battle presentation should return `false` only for its active battle
|
Text boxes and YES/NO prompts pushed above a battle inherit a `false` result
|
||||||
or text-box state.
|
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
|
`core.logic_speed` receives `(next, game)` once per `Game:logicSpeed()` call
|
||||||
(once per frame). Vanilla behavior resolves the per-category GAME SPEED
|
(once per frame). Vanilla behavior resolves the per-category GAME SPEED
|
||||||
@@ -449,3 +742,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`
|
Neither hook needs a `Runtime.wantsHook` guard before calling it: `Hooks:call`
|
||||||
already falls straight through to the vanilla function when no mod has
|
already falls straight through to the vanilla function when no mod has
|
||||||
wrapped the name, at negligible cost.
|
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.
|
||||||
|
|||||||
@@ -929,6 +929,10 @@ mod.content.sfx:register("SFX_MOD_CHIME", { file = "chime.ogg" })
|
|||||||
|
|
||||||
| field | type | required |
|
| field | type | required |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
| `anchorX` | number | no |
|
||||||
|
| `anchorY` | number | no |
|
||||||
|
| `frameHeight` | integer >= 1 | no |
|
||||||
|
| `frameWidth` | integer >= 1 | no |
|
||||||
| `frames` | integer >= 1 | yes |
|
| `frames` | integer >= 1 | yes |
|
||||||
| `id` | string | no |
|
| `id` | string | no |
|
||||||
| `image` | file path | yes |
|
| `image` | file path | yes |
|
||||||
@@ -1100,6 +1104,7 @@ mod.content.tokens:register("CLOCK", function(game) return "12" end)
|
|||||||
| `paletteSource` | string | no |
|
| `paletteSource` | string | no |
|
||||||
| `parties` | list of list of {level, species} | yes |
|
| `parties` | list of list of {level, species} | yes |
|
||||||
| `pic` | file path | no |
|
| `pic` | file path | no |
|
||||||
|
| `trueColor` | boolean | no |
|
||||||
|
|
||||||
```lua
|
```lua
|
||||||
mod.content.trainers:patch("OPP_BROCK", { baseMoney = 99 })
|
mod.content.trainers:patch("OPP_BROCK", { baseMoney = 99 })
|
||||||
@@ -1122,7 +1127,9 @@ do not.
|
|||||||
| `index` | integer 0..255 | no |
|
| `index` | integer 0..255 | no |
|
||||||
| `items` | list of items id | no |
|
| `items` | list of items id | no |
|
||||||
| `name` | string | yes |
|
| `name` | string | yes |
|
||||||
|
| `pic` | file path | no |
|
||||||
| `trainers` | list of {id?, index?, name, party, trainerType?} | yes |
|
| `trainers` | list of {id?, index?, name, party, trainerType?} | yes |
|
||||||
|
| `trueColor` | boolean | no |
|
||||||
|
|
||||||
```lua
|
```lua
|
||||||
mod.content.trainers:patch("BEAUTY", { baseMoney = 99 })
|
mod.content.trainers:patch("BEAUTY", { baseMoney = 99 })
|
||||||
|
|||||||
@@ -19,16 +19,16 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow
|
|||||||
* **Soft reset button combination**
|
* **Soft reset button combination**
|
||||||
* **Keyboard and controller rebinding**
|
* **Keyboard and controller rebinding**
|
||||||
* **Mod profiles** with separate mod settings and save slots
|
* **Mod profiles** with separate mod settings and save slots
|
||||||
|
* **Sandboxed mods**: an installed mod can read only its own folder and write only its own storage, so it cannot reach the rest of your device, and mods that need the internet or heavy background work do it through permissions the mod manager shows you, without freezing the game
|
||||||
* **Improved launcher and save editor UI**, including background downloads and update checks
|
* **Improved launcher and save editor UI**, including background downloads and update checks
|
||||||
* **Direct-launch options** for shortcuts, Steam entries, and handheld frontends
|
* **Direct-launch options** for shortcuts, Steam entries, and handheld frontends
|
||||||
* **Custom boot branding**
|
* **Custom boot branding**
|
||||||
|
|
||||||
## Pokémon Gold (Gen 2)
|
## Pokémon Gold (Gen 2)
|
||||||
|
|
||||||
A fourth game the launcher can import and play, built from pret/pokegold the same way Red/Blue/Yellow are built from pokered. Port extras beyond the cartridge:
|
A fourth game the launcher can import and play, built from pret/pokegold the same way Red/Blue/Yellow are built from pokered (and pokeyellow). Port extras beyond the cartridge:
|
||||||
|
|
||||||
* **Separate Gold save file** beside the Gen 1 ones
|
* **COLOR, zoom, tilt, GBC FX, and quick save/load**
|
||||||
* **COLOR, zoom, tilt, GBC FX, and quick save/load** on the same keys as Gen 1
|
|
||||||
* **UI that stays fixed while the overworld zooms**
|
* **UI that stays fixed while the overworld zooms**
|
||||||
* **Border-block surrounds** for maps smaller than the screen
|
* **Border-block surrounds** for maps smaller than the screen
|
||||||
* **Gold-specific launcher options**
|
* **Gold-specific launcher options**
|
||||||
@@ -39,5 +39,5 @@ A fourth game the launcher can import and play, built from pret/pokegold the sam
|
|||||||
* **Followers** for mods, plus Gen 2-only registries and hooks
|
* **Followers** for mods, plus Gen 2-only registries and hooks
|
||||||
* **On-screen touch pad** and controller SELECT for registered items
|
* **On-screen touch pad** and controller SELECT for registered items
|
||||||
|
|
||||||
Actual approximations, and missing original behavior are documented separately in `docs/known-differences.md`.
|
|
||||||
|
|
||||||
|
* **Older mods keep loading** after the sandbox change, through per-mod compat stand-ins for the pre-sandbox globals
|
||||||
|
|||||||
@@ -258,20 +258,34 @@ 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
|
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`.
|
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
|
### One limit worth knowing
|
||||||
|
|
||||||
**Per-game enable flags are still a preview.** The overlay
|
**Enablement is per game.** The overlay
|
||||||
`options.modsByVersion[version][id]` exists and every surface goes through
|
`options.modsByVersion[version][id]` is read and written through
|
||||||
`SaveData.modEnabled` / `SaveData.setModEnabled`, but
|
`SaveData.modEnabled` / `SaveData.setModEnabled` by the launcher, in-game
|
||||||
`SaveData.PER_VERSION_MODS` is `false`
|
manager, and loader. Existing shared settings are copied to every game the
|
||||||
(`src/core/SaveData.lua:489`). While it is false, `SaveData.modScope` answers
|
first time this version sees the installed mods; from then on, each coloured
|
||||||
nil for every caller, so the launcher panel, the in-game manager *and* the
|
game checkbox changes only that game's next boot. Nothing about this affects a
|
||||||
loader all read and write the one shared `options.mods` flag and the overlay is
|
mod author; it affects what a player can express.
|
||||||
not consulted for enablement anywhere. That matters because the overlay is
|
|
||||||
plantable from an imported `.g1rmodlist`: keeping every reader on the same
|
|
||||||
scope as every writer is what stops a stored per-game flag from showing a mod
|
|
||||||
set no boot would honour. Nothing about this affects a mod author; it affects
|
|
||||||
what a player can currently express.
|
|
||||||
|
|
||||||
Targeting is a different question from enablement and *is* enforced per game,
|
Targeting is a different question from enablement and *is* enforced per game,
|
||||||
as above. The two do not share a switch.
|
as above. The two do not share a switch.
|
||||||
@@ -311,7 +325,7 @@ This is not a dev-mode feature; it installs on any Gold boot that has mods.
|
|||||||
| `src.pokemon.Boxes` | facade | over `src/core/gen2/Boxes.lua` | 22 / 0 / 0 |
|
| `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.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.ui.PartyMenu` | facade | over `src/ui/gen2/PartyMenu.lua` | 15 / 2 / 16 |
|
||||||
| `src.world.WorldAPI` | alias | `src/world/gen2/WorldAPI.lua` | 12 / 2 / 0 |
|
| `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.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.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.ui.OptionsMenu` | facade | over `src/ui/gen2/OptionsMenu.lua` | 8 / 0 / 1 |
|
||||||
@@ -760,7 +774,7 @@ profile to test in, and `POKEPORT_DEV=1` adds the console and `F5` hot reload.
|
|||||||
|
|
||||||
- **Coverage is partial and will stay partial.** 15 Gen 1 modules are served
|
- **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
|
out of a much larger engine, and within those 15 the coverage table records
|
||||||
288 backed members against 32 warned and 161 absent. The absent ones are not
|
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
|
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
|
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
|
member that turns out to answer nil is demoted from backed to warned or
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ wiki's Save Model. `mod.save` and `mod.options` keep their existing behavior.
|
|||||||
## The exact API delta
|
## The exact API delta
|
||||||
|
|
||||||
Backward-compatible, additive-only. `Loader:_api` binds a new `mod.storage`
|
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
|
facade to the calling mod id. Mods receive logical keys and either decoded table
|
||||||
filesystem handles or physical paths.
|
values or exact opaque byte strings, never filesystem handles or physical paths.
|
||||||
|
|
||||||
### Lazy opaque playthrough identity
|
### Lazy opaque playthrough identity
|
||||||
|
|
||||||
@@ -75,6 +75,25 @@ 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
|
then backup data. A valid staged/backup value is returned and promoted
|
||||||
best-effort; corrupt bytes are never executed.
|
best-effort; corrupt bytes are never executed.
|
||||||
|
|
||||||
|
### `mod.storage:writeBytes(game, key, bytes)`
|
||||||
|
|
||||||
|
Accepts a Lua string containing opaque bytes and returns `true`, or
|
||||||
|
`false, code, message`. Empty strings are valid. Payloads are limited to 512
|
||||||
|
MiB per key. The engine writes the supplied bytes exactly as received, without
|
||||||
|
decoding, compression, checksums, or an engine-defined envelope. The consuming
|
||||||
|
mod owns semantic validation of its format.
|
||||||
|
|
||||||
|
Byte records use private `.bin`, `.bin.tmp`, and `.bin.bak` witnesses. A staged
|
||||||
|
and replacement write is read back and compared byte-for-byte before it is
|
||||||
|
committed. A failed write leaves the previous verified generation readable.
|
||||||
|
Byte storage never passes its payload to the Lua serializer, loader, or module
|
||||||
|
resolver.
|
||||||
|
|
||||||
|
Table and byte records share one logical key namespace and a key has one type.
|
||||||
|
Writing one type over the other returns `type_conflict`; callers must delete the
|
||||||
|
key before changing its type. `mod.storage:selected(game)` exposes the same
|
||||||
|
`readBytes` and `writeBytes` operations for the selected playthrough facade.
|
||||||
|
|
||||||
### `mod.storage:list(game[, prefix])`
|
### `mod.storage:list(game[, prefix])`
|
||||||
|
|
||||||
Returns sorted logical keys beneath a valid prefix, an exact key when the prefix
|
Returns sorted logical keys beneath a valid prefix, an exact key when the prefix
|
||||||
@@ -92,9 +111,9 @@ Physical records are scoped as:
|
|||||||
`persistence root / mod_storage / game version / playthrough id / mod id`
|
`persistence root / mod_storage / game version / playthrough id / mod id`
|
||||||
|
|
||||||
Stable error codes are `not_in_playthrough`, `storage_unavailable`,
|
Stable error codes are `not_in_playthrough`, `storage_unavailable`,
|
||||||
`invalid_key`, `encode_failed`, `write_failed`, `verify_failed`, and
|
`invalid_key`, `encode_failed`, `invalid_bytes`, `size_limit`, `type_conflict`,
|
||||||
`not_found`. Ordinary data and I/O failures are return values, not callback-
|
`type_mismatch`, `write_failed`, `verify_failed`, and `not_found`. Ordinary
|
||||||
terminating errors.
|
data and I/O failures are return values, not callback-terminating errors.
|
||||||
|
|
||||||
The restricted serializer's recursive writer runs outside LuaJIT traces. A
|
The restricted serializer's recursive writer runs outside LuaJIT traces. A
|
||||||
1,000-process GC stress regression found compiled recursion could intermittently
|
1,000-process GC stress regression found compiled recursion could intermittently
|
||||||
@@ -107,6 +126,8 @@ boundary.
|
|||||||
**Nothing.** No API is removed, no manifest field changes, and no storage path or
|
**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
|
playthrough id is created unless a mod invokes `mod.storage` or
|
||||||
`mod.checkpoints`. Existing save bytes remain unchanged on the no-caller path.
|
`mod.checkpoints`. Existing save bytes remain unchanged on the no-caller path.
|
||||||
|
Existing files outside the scoped storage contract are not imported; a caller
|
||||||
|
must rebuild them through `writeBytes`.
|
||||||
|
|
||||||
## Parity tests
|
## Parity tests
|
||||||
|
|
||||||
@@ -115,8 +136,10 @@ playthrough id is created unless a mod invokes `mod.storage` or
|
|||||||
- **Engine identity:** lazy allocation, save/load preservation, stable legacy
|
- **Engine identity:** lazy allocation, save/load preservation, stable legacy
|
||||||
mapping, fresh-playthrough replacement, and version/slot isolation.
|
mapping, fresh-playthrough replacement, and version/slot isolation.
|
||||||
- **Public Mod API:** two real API-2 entry chunks prove data-only roundtrip,
|
- **Public Mod API:** two real API-2 entry chunks prove data-only roundtrip,
|
||||||
|
opaque byte roundtrip including NUL bytes, no execution, size/type rejection,
|
||||||
deterministic listing, key rejection, mod/game/playthrough isolation,
|
deterministic listing, key rejection, mod/game/playthrough isolation,
|
||||||
corrupt-main recovery, failure retention, exact delete, and no-mod no-write.
|
corrupt-main recovery, failure retention, selected-playthrough access, exact
|
||||||
|
delete, and no-mod no-write.
|
||||||
|
|
||||||
## Deprecation etiquette
|
## Deprecation etiquette
|
||||||
|
|
||||||
|
|||||||
@@ -15,9 +15,12 @@ references, completion is currently an `onFinish` closure, and scripted battles
|
|||||||
resume a suspended `ScriptRunner` coroutine. Copying the controller would create
|
resume a suspended `ScriptRunner` coroutine. Copying the controller would create
|
||||||
a record that is neither data-only nor process-independent.
|
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 engine can instead expose a narrow semantic safe point. Ordinary encounters
|
||||||
the strongest persistent battle checkpoint the current architecture can prove,
|
use fixed engine-owned completion descriptors. A scripted story encounter may
|
||||||
without claiming mid-animation or suspended-script support.
|
also participate when its active command row and remaining row-list are
|
||||||
|
detached data, its NPC can be rebound by stable object id, and its completion is
|
||||||
|
one of the engine-declared semantic forms. The suspended coroutine itself is
|
||||||
|
never captured.
|
||||||
|
|
||||||
## API delta
|
## API delta
|
||||||
|
|
||||||
@@ -26,7 +29,7 @@ second format-1 runtime kind.
|
|||||||
|
|
||||||
### Capability
|
### Capability
|
||||||
|
|
||||||
`mod.checkpoints:inspect(game)` returns this only when an ordinary single-player
|
`mod.checkpoints:inspect(game)` returns this only when a supported single-player
|
||||||
wild or trainer battle is settled at the player command menu:
|
wild or trainer battle is settled at the player command menu:
|
||||||
|
|
||||||
```lua
|
```lua
|
||||||
@@ -35,13 +38,15 @@ wild or trainer battle is settled at the player command menu:
|
|||||||
|
|
||||||
The action/message queue, waits, UI, animations, HP/status presentation, and
|
The action/message queue, waits, UI, animations, HP/status presentation, and
|
||||||
faint processing must be settled. The player must actually control the menu.
|
faint processing must be settled. The player must actually control the menu.
|
||||||
The underlying overworld must have no running/queued script or scripted move,
|
The battle must carry an engine-owned semantic continuation descriptor. An
|
||||||
and the battle must carry an engine-owned semantic continuation descriptor.
|
ordinary encounter requires an idle overworld. A scripted story encounter may
|
||||||
|
have exactly its originating foreground runner suspended at the battle command;
|
||||||
|
queued/parallel scripts and scripted movement remain unsafe.
|
||||||
|
|
||||||
Additional refusal codes are `battle_phase_busy`, `battle_origin_unsupported`,
|
Additional refusal codes are `battle_phase_busy`, `battle_origin_unsupported`,
|
||||||
`battle_variant_unsupported`, and `link_battle_unsupported`. Link, Safari,
|
`battle_variant_unsupported`, and `link_battle_unsupported`. Link, Safari,
|
||||||
ghost, old-man/demo, fishing, static-object, script-suspended, and mod-created
|
ghost, old-man/demo, fishing, opaque callback continuations, non-data-only
|
||||||
closure continuations remain rejected.
|
scripts, and unsupported concurrent script work remain rejected.
|
||||||
|
|
||||||
### Capture
|
### Capture
|
||||||
|
|
||||||
@@ -107,10 +112,20 @@ trainer class/party, and optional header event; a win reapplies the same defeate
|
|||||||
flag, event, reward, and `afterBattle` path. Reconstructed overworld input and
|
flag, event, reward, and `afterBattle` path. Reconstructed overworld input and
|
||||||
NPC freeze state are normalized instead of reviving the old closure.
|
NPC freeze state are normalized instead of reviving the old closure.
|
||||||
|
|
||||||
`Commands.start_battle` is deliberately unsupported: its completion closure
|
For `start_battle`, `rival_battle`, and `static_battle`, the runner records the
|
||||||
mutates script context and resumes a coroutine whose program counter and Lua
|
detached row list and current command program counter plus stable source/NPC
|
||||||
stack cannot be serialized. Existing script rejection remains the correct safe
|
identity where present. On restore, battle completion starts a fresh runner at
|
||||||
contract until a separate semantic ScriptRunner checkpoint RFC exists.
|
that command with a one-use semantic battle result. Replaying the current
|
||||||
|
command (rather than skipping to the next row) preserves wrapper behavior such
|
||||||
|
as rival-party consequences, static-object removal, `lastCheck`, and deferred
|
||||||
|
`afterBattle` evolution ordering. The reconstructed overworld starts with
|
||||||
|
normalized input/NPC freeze state, so an engine-marked `release_npc` callback
|
||||||
|
needs no closure revival.
|
||||||
|
|
||||||
|
Arbitrary `onDone` callbacks, function-bearing rows, unknown commands, missing
|
||||||
|
NPC identities, old-man/demo flows, and concurrent scripts fail closed. This is
|
||||||
|
not a general ScriptRunner snapshot: no coroutine, Lua stack, local variable,
|
||||||
|
function, or runtime object enters the checkpoint.
|
||||||
|
|
||||||
## Migration note
|
## Migration note
|
||||||
|
|
||||||
@@ -138,3 +153,6 @@ No-mod behavior is unchanged when checkpoints are unused.
|
|||||||
- exactly one post-verification `checkpoint.restored` event and none on failure;
|
- exactly one post-verification `checkpoint.restored` event and none on failure;
|
||||||
- legacy overworld checkpoint compatibility;
|
- legacy overworld checkpoint compatibility;
|
||||||
- complete ROM-free engine and public mod-API suites.
|
- complete ROM-free engine and public mod-API suites.
|
||||||
|
- scripted trainer and static/wild story continuation, including wrapper-row
|
||||||
|
replay, cold reconstruction, malformed row rejection, and opaque callback
|
||||||
|
refusal.
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# RFC 0006 — Selected title playthrough storage and checkpoint resume
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Proposed. Engine: `SaveData.lua`, `Storage.lua`, `Checkpoint.lua`, and
|
||||||
|
`Loader.lua`. Tests: `title_playthrough_context.lua`, existing storage,
|
||||||
|
checkpoint, title, save-slot, and no-mod parity suites.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
A tool checkpoint may be the first durable record of a new playthrough. The
|
||||||
|
engine intentionally keeps normal Pokémon SAVE independent: before the first
|
||||||
|
normal write, identity is retained by the engine-owned selected-slot mapping,
|
||||||
|
while title starts with a fresh New Game skeleton. A durable checkpoint tool may
|
||||||
|
explicitly create one ordinary progress anchor after its first checkpoint has
|
||||||
|
committed; later tool writes must remain independent. Calling ordinary active
|
||||||
|
`mod.storage` there would allocate/adopt an identity, and live
|
||||||
|
`mod.checkpoints:restore` correctly refuses title because it has no gameplay
|
||||||
|
rollback state. Generic public capabilities are required; a tool must not use
|
||||||
|
private storage paths, slot ids, or simulate the player's SAVE menu flow.
|
||||||
|
|
||||||
|
## Additive public API
|
||||||
|
|
||||||
|
### `mod.storage:selected(game)`
|
||||||
|
|
||||||
|
Available only while the engine is in a title session. Returns an opaque bound
|
||||||
|
facade or `nil, code, message`:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local selected = mod.storage:selected(game)
|
||||||
|
local context = selected:context()
|
||||||
|
local history = selected:read("history/index")
|
||||||
|
```
|
||||||
|
|
||||||
|
The facade exposes `context()`, `read(key)`, `write(key, value)`,
|
||||||
|
`list(prefix)`, and `delete(key)`. It is bound internally to the launcher-
|
||||||
|
selected existing game-version/playthrough and the calling mod id. It neither
|
||||||
|
accepts an arbitrary playthrough id nor reveals a slot id, filesystem path, or
|
||||||
|
another mod namespace. Resolution is read-only; no selected mapping means
|
||||||
|
`no_selected_playthrough`, and opening a title browser never mints an identity.
|
||||||
|
Its detached context can include only `normalSavedAt` from a matching ordinary
|
||||||
|
save, so title tools can apply their own resume policy without receiving the
|
||||||
|
canonical normal-save record.
|
||||||
|
|
||||||
|
### `mod.checkpoints:ensureNormalSave(game, checkpoint)`
|
||||||
|
|
||||||
|
Available only at a live checkpoint-safe boundary. After a tool has durably
|
||||||
|
committed the supplied current checkpoint, it may request an ordinary progress
|
||||||
|
anchor for a playthrough that has never had one. The engine validates the
|
||||||
|
checkpoint, proves it exactly matches a fresh capture of the live runtime, and
|
||||||
|
uses the normal atomic save path including `save.write` lifecycle/veto hooks.
|
||||||
|
|
||||||
|
The operation is idempotent. It returns `true, "already_exists"` without writing
|
||||||
|
when matching normal progress already exists, so later checkpoints never move
|
||||||
|
the vanilla CONTINUE target. A stale/non-current checkpoint, unsafe runtime,
|
||||||
|
write veto/failure, or failed readback returns a structured failure. A tool
|
||||||
|
should call it only after its own checkpoint and index are durable and must not
|
||||||
|
report that first checkpoint as successful if the required anchor fails.
|
||||||
|
|
||||||
|
### `mod.checkpoints:resume(game, checkpoint)`
|
||||||
|
|
||||||
|
Available only from title. It validates format, data-only structure, selected
|
||||||
|
game/playthrough identity, canonical save/content, overworld/battle runtime, and
|
||||||
|
RNG exactly as `restore` does. It then reconstructs semantic overworld or a
|
||||||
|
supported battle continuation, preserves current options, and differentially
|
||||||
|
recaptures before committing. On success it emits `checkpoint.restored` once.
|
||||||
|
|
||||||
|
Title has no live runtime rollback. A reconstruction or verification failure
|
||||||
|
therefore rebuilds a clean title session from the pre-operation title save and
|
||||||
|
RNG; it emits no success event and never rewrites normal progress. Validation
|
||||||
|
failure leaves the existing title session untouched. Stable errors include
|
||||||
|
`not_at_title`, `no_selected_playthrough`, normal checkpoint validation codes,
|
||||||
|
`resume_failed`, and `title_recovery_failed`.
|
||||||
|
|
||||||
|
## Isolation and migration
|
||||||
|
|
||||||
|
Explicit NEW GAME retains its existing fresh-identity rule. It does not reuse a
|
||||||
|
previous selected mapping and cannot see old tool history. Existing mods change
|
||||||
|
nothing: no identity, storage, title reconstruction, or event is created unless
|
||||||
|
the new methods are called. `mod.storage` remains independent durable data and
|
||||||
|
does not rewind with a checkpoint; canonical `game.save` / `mod.save` does.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
The public SDK test starts a fresh playthrough, stores tool history, creates and
|
||||||
|
readback-verifies exactly one normal anchor, proves subsequent calls do not
|
||||||
|
rewrite it, simulates title/restart, reads the selected binding without
|
||||||
|
allocating title identity, resumes an overworld checkpoint, preserves options,
|
||||||
|
differentially recaptures, and confirms a later explicit NEW GAME receives
|
||||||
|
another identity. A separate two-process disk test proves cold-start routing and
|
||||||
|
reconstruction. Existing no-mod, storage, checkpoint, battle, and title suites
|
||||||
|
prove additive parity.
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# RFC 0007: Battle menu auxiliary actions
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Proposed.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Tool mods can inspect/capture a persistent checkpoint only at a settled
|
||||||
|
ordinary wild/trainer player-decision boundary. Before this proposal, that
|
||||||
|
boundary had no public semantic input/action seam: `BattleState` consumed the
|
||||||
|
command loop directly. A mod could reach it only through private battle/input
|
||||||
|
internals, which would be unsafe and incompatible with controller/touch input.
|
||||||
|
|
||||||
|
## Contract
|
||||||
|
|
||||||
|
`mod.hooks:wrap("battle.menu_auxiliary", callback)` is called only when START
|
||||||
|
is pressed at the existing checkpoint-safe player-decision boundary. The
|
||||||
|
callback signature is:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
function callback(next, game, context)
|
||||||
|
-- context is { kind = "wild" } or { kind = "trainer" }
|
||||||
|
-- return true after claiming START, otherwise return next(game, context)
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
The context is data-only. No live battle controller, input object, serializer,
|
||||||
|
or restoration primitive is exposed. A `true` result consumes START for that
|
||||||
|
fixed step without selecting a battle command. With no installed handler,
|
||||||
|
START is inert exactly as before. Hook priorities and error isolation are the
|
||||||
|
existing generic wrapper semantics: a throwing handler is skipped and cannot
|
||||||
|
advance battle state.
|
||||||
|
|
||||||
|
The engine reuses the same internal safety predicate as battle checkpoint
|
||||||
|
capture. Link, Safari, ghost/demo, unsupported origins, scripts, queues,
|
||||||
|
animations, messages, forced replacement/locked actions, and unsettled HP or
|
||||||
|
status presentation never invoke the hook.
|
||||||
|
|
||||||
|
## Compatibility and verification
|
||||||
|
|
||||||
|
The call is additive and no-op with no handler. ROM-free engine tests prove
|
||||||
|
wild/trainer delivery, cursor/turn preservation, and unsafe-phase refusal;
|
||||||
|
the mod-SDK fixture proves a loaded mod can consume the semantic action using
|
||||||
|
only its public hook facade. `gate_hooks` automatically includes the new call
|
||||||
|
site in no-mod parity coverage.
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# RFC 0008 — Read-only device power information for sandboxed mods
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Proposed. Engine: `Loader.lua`, `Sandbox.lua`. Test:
|
||||||
|
`tests/modkit/cases/device_power_info.lua`.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
A handheld UI mod can show the player's battery state and warn before power
|
||||||
|
loss. The sandbox correctly removes `love.system` because that module also
|
||||||
|
launches URLs and exposes other host operations, but it leaves no scoped way
|
||||||
|
to read the harmless power values that LÖVE already provides.
|
||||||
|
|
||||||
|
## The decision it extends
|
||||||
|
|
||||||
|
Extends the mod sandbox in `src/mods/Sandbox.lua`: blocked host modules stay
|
||||||
|
blocked while legitimate operations receive narrow engine-owned facades.
|
||||||
|
|
||||||
|
## The exact API delta
|
||||||
|
|
||||||
|
Add `mod.device:powerInfo() -> state, percent`.
|
||||||
|
|
||||||
|
The engine calls `love.system.getPowerInfo()` outside the mod sandbox and
|
||||||
|
returns only its first two values. `state` is one of LÖVE's standard power
|
||||||
|
states. `percent` is `0` through `100` or `nil`. When the platform has no
|
||||||
|
power-information backend, the result is `"unknown", nil`.
|
||||||
|
|
||||||
|
No permission grants access to `love.system`; URL launching, clipboard access,
|
||||||
|
OS identification, and the module table itself remain unavailable.
|
||||||
|
|
||||||
|
## Migration note for existing mods
|
||||||
|
|
||||||
|
Mods that used `love.system.getPowerInfo()` replace that call with
|
||||||
|
`mod.device:powerInfo()`. No other mod changes.
|
||||||
|
|
||||||
|
## Parity tests
|
||||||
|
|
||||||
|
- **No mod:** loading no mods does not call the platform power backend.
|
||||||
|
- **Mod API:** a fixture mod loaded through the public loader receives state
|
||||||
|
and percentage through `mod.device`, while the existing sandbox suite keeps
|
||||||
|
proving that direct `love.system` access is refused.
|
||||||
|
- **Unavailable backend:** the public facade returns `"unknown", nil` rather
|
||||||
|
than inventing battery data or failing mod load.
|
||||||
|
|
||||||
|
## Deprecation etiquette
|
||||||
|
|
||||||
|
Nothing deprecated. The facade is additive; the sandbox's `love.system` block
|
||||||
|
remains in force.
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# RFC 0009 — Permission-gated step bridge for sandboxed mods
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Proposed. Engine: `Steps.lua` (new), `Loader.lua`, `Manifest.lua`,
|
||||||
|
`Sandbox.lua`. Test: `tests/modkit/cases/steps_bridge.lua`. Issue: #1186.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
The iOS and Android builds count the player's real-world steps natively
|
||||||
|
(#452, #489), exposed to Lua as `love.system.syncHealthSteps()` and
|
||||||
|
delivered as `steps_pending.json` in the save-directory root. The sandbox
|
||||||
|
correctly blocks both — `love.system` also launches URLs, and the file API
|
||||||
|
names paths — but that leaves the bridge with no consumer: the mod that
|
||||||
|
step counting was built for (Pokéwalker, steps→EXP) can no longer be
|
||||||
|
written.
|
||||||
|
|
||||||
|
## The decision it extends
|
||||||
|
|
||||||
|
Extends the mod sandbox in `src/mods/Sandbox.lua` (blocked host modules
|
||||||
|
stay blocked; legitimate operations receive narrow engine-owned facades)
|
||||||
|
and the permission model `network` established: a `manifest.json`
|
||||||
|
permission the player sees in the mod manager that genuinely gates a
|
||||||
|
capability.
|
||||||
|
|
||||||
|
## The exact API delta
|
||||||
|
|
||||||
|
A new manifest permission token, `steps`, and a `mod.steps` facade:
|
||||||
|
|
||||||
|
- `mod.steps:available() -> boolean` — whether this build carries the
|
||||||
|
native bridge. Answers `false` without the permission, so a probe stays
|
||||||
|
quiet.
|
||||||
|
- `mod.steps:sync() -> boolean` — asks the platform to refresh its count
|
||||||
|
(async; the OS consent sheet still appears on first use, exactly as
|
||||||
|
before the sandbox). `false` when there is no bridge.
|
||||||
|
- `mod.steps:poll() -> { steps = n, from = iso?, to = iso? } | nil` — the
|
||||||
|
next delivery for this mod, engine-consumed from the pending file. Each
|
||||||
|
permissioned mod receives its own copy of a delivery.
|
||||||
|
|
||||||
|
Without the permission, `sync` and `poll` raise an error naming the
|
||||||
|
missing permission, the way the network gate does. The engine owns the
|
||||||
|
pending file: mods never learn its name or location, and only the three
|
||||||
|
contract fields travel. No new event, hook, or registry names.
|
||||||
|
|
||||||
|
## Migration note for existing mods
|
||||||
|
|
||||||
|
Mods that called `love.system.syncHealthSteps()` and read
|
||||||
|
`steps_pending.json` themselves add `"steps"` to `permissions` and switch
|
||||||
|
to `mod.steps:sync()` / `mod.steps:poll()`. No other mod changes.
|
||||||
|
|
||||||
|
## Parity tests
|
||||||
|
|
||||||
|
- **No mod:** with nothing installed the bridge is never called and a
|
||||||
|
pending file on disk is left untouched.
|
||||||
|
- **Mod API:** a fixture mod with the permission syncs and receives a
|
||||||
|
delivery through the public loader; two permissioned mods both receive
|
||||||
|
the same walk; a second poll returns nil.
|
||||||
|
- **No permission:** `available()` is false and the acting calls name the
|
||||||
|
missing permission; the sandbox suite keeps proving direct
|
||||||
|
`love.system` access is refused.
|
||||||
|
- **Malformed delivery:** a bad or empty pending file is dropped whole
|
||||||
|
rather than crashing a poll (the native anchor only advances on a
|
||||||
|
successful sync, so nothing is lost).
|
||||||
|
|
||||||
|
## Deprecation etiquette
|
||||||
|
|
||||||
|
Nothing deprecated. The facade is additive; the sandbox's `love.system`
|
||||||
|
and `love.filesystem` blocks remain in force.
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# RFC 0010: Deferred trainer preparation and battle-local party scope
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Proposed.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
Challenge and tournament mods sometimes need a player to choose an eligible
|
||||||
|
subset of the save party before a trainer battle. The current public surface
|
||||||
|
can replace the opponent through `trainer.party` and observe
|
||||||
|
`world.trainer_engaged`, but it cannot pause the engagement before battle
|
||||||
|
construction or keep unselected save-party members out of initial send,
|
||||||
|
switch, replacement, exhaustion, experience, and party-menu traversal.
|
||||||
|
|
||||||
|
Temporarily rewriting `game.save.party` is not a safe substitute: it changes
|
||||||
|
authoritative save state, composes poorly with checkpoints and other mods, and
|
||||||
|
can strand excluded Pokémon if a callback or process fails.
|
||||||
|
|
||||||
|
## Decision and plan extended
|
||||||
|
|
||||||
|
This implements **D-AT-001: battle-local Gym registration without save-party
|
||||||
|
mutation**, the consuming design decision tracked as capability `AT-SP-001` in
|
||||||
|
the Adaptive Trainers implementation plan. The plan file is
|
||||||
|
[`docs/superpowers/plans/2026-08-14-adaptive-trainers.md`](https://github.com/MaxTomahawk/gen1recomp-adaptive-trainers/blob/main/docs/superpowers/plans/2026-08-14-adaptive-trainers.md),
|
||||||
|
Task 5. The engine delta also extends the additive, guarded public-hook
|
||||||
|
decision used by RFC 0007 and the screen facade documented in
|
||||||
|
`docs/modding.md`; it deliberately contains none of the consuming mod's Gym
|
||||||
|
or party-size policy.
|
||||||
|
|
||||||
|
## Exact API delta
|
||||||
|
|
||||||
|
Add the guarded hook:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
mod.hooks:wrap("trainer.before_battle", function(next, game, context, continue)
|
||||||
|
-- context = { trainerClass, partyIndex, mapId, npcId }
|
||||||
|
-- Return true only when the battle has been deferred.
|
||||||
|
-- continue({ cancel = true }) returns without constructing a battle.
|
||||||
|
-- Call continue() for the full save party, or:
|
||||||
|
-- continue({ playerPartyIndices = { 2, 4, 5 } })
|
||||||
|
end)
|
||||||
|
```
|
||||||
|
|
||||||
|
The hook runs after the trainer's challenge text and immediately before the
|
||||||
|
trainer battle is constructed. A mod may push a registered screen with
|
||||||
|
`mod.ui.push`, return `true`, and retain `continue` for its confirm/cancel
|
||||||
|
callback. `continue` is one-shot and returns `false` after the first call.
|
||||||
|
Returning anything other than `true` without calling it continues immediately
|
||||||
|
with vanilla scope. With no subscriber, no context or continuation is built.
|
||||||
|
|
||||||
|
`playerPartyIndices` is an ordered, one-based list into `game.save.party`.
|
||||||
|
Valid unique indices create `battle.playerParty` as a battle-local view of the
|
||||||
|
same Pokémon records; the save party itself is never reordered or replaced.
|
||||||
|
Malformed or empty scopes degrade to the full party. The view governs initial
|
||||||
|
send, all battle party menus and targets, voluntary and forced replacement,
|
||||||
|
exhaustion/blackout checks, participant and EXP.ALL traversal, party counts,
|
||||||
|
and party-ball presentation. Checkpoints preserve the index list and rebuild
|
||||||
|
the same view before restoring battlers.
|
||||||
|
|
||||||
|
`{ cancel = true }` ends a deferred encounter through its normal completion
|
||||||
|
callback without constructing a battle or writing trainer-defeated state. A
|
||||||
|
cancelled sight encounter is suppressed while the player remains on the same
|
||||||
|
cell, preventing immediate reacquisition; moving or directly talking permits a
|
||||||
|
new challenge. Cancellation is also one-shot; if supplied alongside a party
|
||||||
|
index list, cancellation wins.
|
||||||
|
|
||||||
|
The API sets no maximum, chooses no members, identifies no boss, and contains
|
||||||
|
no scaling or challenge policy.
|
||||||
|
|
||||||
|
## Migration and compatibility
|
||||||
|
|
||||||
|
Existing mods change nothing. `BattleState.newTrainer(game, class, index)`
|
||||||
|
keeps its current behavior; the optional fourth argument is additive. Existing
|
||||||
|
battle checkpoints without a party scope restore against the full save party.
|
||||||
|
Wild, Safari, link, and no-mod battles are unchanged.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- The catalog-driven hook gate proves empty-chain parity and the guarded hot
|
||||||
|
path proves no-mod engagement starts exactly once without allocation.
|
||||||
|
- A sandboxed fixture mod defers through its public hook facade, inspects the
|
||||||
|
data-only context, and resumes once with ordered indices.
|
||||||
|
- Engine tests cover initial send, party menus, replacement/exhaustion,
|
||||||
|
EXP traversal, invalid-scope fallback, and save-party identity.
|
||||||
|
- Battle-checkpoint tests prove scoped capture/restore and old-checkpoint
|
||||||
|
compatibility.
|
||||||
|
|
||||||
|
## Deprecation etiquette
|
||||||
|
|
||||||
|
Nothing is deprecated. The hook and optional constructor argument are
|
||||||
|
additive.
|
||||||
+25
-6
@@ -26,7 +26,7 @@ JSON parsing, and sha256 verification run on a background `love.thread`
|
|||||||
|
|
||||||
## Version.lua fields
|
## 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
|
existing `modApi`, `linkProtocol`, `saveFormat`, and `cache` fields are
|
||||||
untouched):
|
untouched):
|
||||||
|
|
||||||
@@ -37,6 +37,11 @@ untouched):
|
|||||||
as a valid payload to chainload).
|
as a valid payload to chainload).
|
||||||
- `shell` - the native-shell contract this build's fused executable
|
- `shell` - the native-shell contract this build's fused executable
|
||||||
implements.
|
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.
|
- `minShell` - the lowest shell contract required to *run* this payload.
|
||||||
|
|
||||||
Bump `minShell` only when a payload needs something the currently-shipped
|
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;
|
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.
|
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
|
## Release assets
|
||||||
|
|
||||||
Each tagged release `vX.Y.Z` carries the existing per-platform archives
|
Each tagged release `vX.Y.Z` carries the existing per-platform archives
|
||||||
@@ -115,11 +126,19 @@ bundled game, in that case.
|
|||||||
already driving the frame. A payload that must change `love.run` itself
|
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
|
needs a `minShell` bump so an older shell refuses to chainload it rather
|
||||||
than running with half its intended behavior.
|
than running with half its intended behavior.
|
||||||
- **Android has no in-app download transport yet.** `check_worker.lua`
|
- **Android and iOS use the native download bridge, not curl.** Neither
|
||||||
shells out to curl for both the release check and the download; curl is
|
platform ships curl, so the old `check_worker.lua` path (shell out to curl)
|
||||||
absent on Android, so `Check` degrades to `status = "error"` there (the
|
always landed on `error` and the launcher chip's "Check for updates" tap
|
||||||
launcher UI hides on that status) and the player is directed to the
|
was a no-op. The worker now talks through `HostShell`, the same transport
|
||||||
releases page via `Check.releaseUrl()` instead.
|
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
|
- **Dev/source runs never self-update.** `Boot.run` returns immediately when
|
||||||
`love.filesystem.isFused()` is false, and a working tree's `engine` is the
|
`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.0-dev"` placeholder that always reports up to date, so a source
|
||||||
|
|||||||
Executable
+93
@@ -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."
|
||||||
@@ -14,6 +14,7 @@ local SwitchDiagnostics = require("src.debug.SwitchDiagnostics")
|
|||||||
local LaunchOptions = require("src.core.LaunchOptions")
|
local LaunchOptions = require("src.core.LaunchOptions")
|
||||||
local NxDisplay = require("src.core.NxDisplay")
|
local NxDisplay = require("src.core.NxDisplay")
|
||||||
local PlatformHooks = require("src.core.PlatformHooks")
|
local PlatformHooks = require("src.core.PlatformHooks")
|
||||||
|
local HostDisplay = require("src.core.HostDisplay")
|
||||||
|
|
||||||
-- Lua errors: persist a redacted trace in the save dir and surface a hint.
|
-- Lua errors: persist a redacted trace in the save dir and surface a hint.
|
||||||
do
|
do
|
||||||
@@ -75,15 +76,6 @@ end
|
|||||||
local editorHost, editorVersion, editorWindow
|
local editorHost, editorVersion, editorWindow
|
||||||
local closeEditor -- forward declaration: openEditor hands it to the editor
|
local closeEditor -- forward declaration: openEditor hands it to the editor
|
||||||
|
|
||||||
-- tools/save-editor/ models the Gen 1 save and nothing else: a Gen 2 party row
|
|
||||||
-- carries fields its MonOps and panels have no idea about (dvs, statExp,
|
|
||||||
-- happiness, pokerus, caughtLevel), and SaveIO.save writes the WHOLE table
|
|
||||||
-- back, so a Gold slot opened here comes out in a shape src/core/gen2/Save.lua
|
|
||||||
-- then has to quarantine on the next boot. Refuse by name, the same way the
|
|
||||||
-- .sav paths do (src/save_convert/SaveConvert.lua GEN2_SAV_UNSUPPORTED), so
|
|
||||||
-- Red/Blue/Yellow slots are untouched.
|
|
||||||
local GEN2_NO_EDITOR = { gold = "Pokemon Gold" }
|
|
||||||
|
|
||||||
-- The editor's modules use flat names (require("Kit"), require("Party")), so
|
-- The editor's modules use flat names (require("Kit"), require("Party")), so
|
||||||
-- their directories have to be on the require path. It must be
|
-- their directories have to be on the require path. It must be
|
||||||
-- love.filesystem's path, not package.path: in a packaged build these files
|
-- love.filesystem's path, not package.path: in a packaged build these files
|
||||||
@@ -136,11 +128,6 @@ local function openEditor(version, slotId)
|
|||||||
Importer.saveNotice = Importer.saveNotice or {}
|
Importer.saveNotice = Importer.saveNotice or {}
|
||||||
Importer.saveNotice[version] = { ok = false, text = text }
|
Importer.saveNotice[version] = { ok = false, text = text }
|
||||||
end
|
end
|
||||||
local gen2Name = GEN2_NO_EDITOR[version]
|
|
||||||
if gen2Name then
|
|
||||||
refuse(gen2Name .. " uses a Gen 2 save; the save editor does not read one yet.")
|
|
||||||
return
|
|
||||||
end
|
|
||||||
local SaveData = require("src.core.SaveData")
|
local SaveData = require("src.core.SaveData")
|
||||||
local path = SaveData.slotDiskPath(version, slotId)
|
local path = SaveData.slotDiskPath(version, slotId)
|
||||||
if not path then
|
if not path then
|
||||||
@@ -161,9 +148,44 @@ local function openEditor(version, slotId)
|
|||||||
editorMode = true
|
editorMode = true
|
||||||
resizeForEditor()
|
resizeForEditor()
|
||||||
addEditorRequirePath()
|
addEditorRequirePath()
|
||||||
EditorApp = require("App")
|
local okReq, appOrErr = pcall(require, "App")
|
||||||
EditorApp.load(path, { version = version, slotId = slotId, embedded = true,
|
if not okReq then
|
||||||
onClose = function() closeEditor() end })
|
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
|
end
|
||||||
|
|
||||||
-- Back to the launcher. Everything the editor mounted or cached has to come
|
-- Back to the launcher. Everything the editor mounted or cached has to come
|
||||||
@@ -179,6 +201,11 @@ function closeEditor()
|
|||||||
require("src.import.CacheFs").unmountVersion(version)
|
require("src.import.CacheFs").unmountVersion(version)
|
||||||
require("src.core.Data"):unloadGenerated()
|
require("src.core.Data"):unloadGenerated()
|
||||||
end
|
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
|
editorVersion = nil
|
||||||
restoreWindow()
|
restoreWindow()
|
||||||
Importer = editorHost
|
Importer = editorHost
|
||||||
@@ -197,14 +224,19 @@ end
|
|||||||
local touchEditorHost
|
local touchEditorHost
|
||||||
local closeTouchControlsEditor -- forward declaration
|
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
|
touchEditorHost = Importer
|
||||||
if Importer and Importer.prepareOverlayHandoff then
|
if Importer and Importer.prepareOverlayHandoff then
|
||||||
Importer:prepareOverlayHandoff()
|
Importer:prepareOverlayHandoff()
|
||||||
end
|
end
|
||||||
Importer = nil
|
Importer = nil
|
||||||
TouchEditor = require("src.ui.TouchControlsEditor")
|
TouchEditor = require("src.ui.TouchControlsEditor")
|
||||||
TouchEditor.load({ onClose = function() closeTouchControlsEditor() end })
|
TouchEditor.load({
|
||||||
|
version = version,
|
||||||
|
onClose = function() closeTouchControlsEditor() end,
|
||||||
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
function closeTouchControlsEditor()
|
function closeTouchControlsEditor()
|
||||||
@@ -271,6 +303,11 @@ function love.load(args)
|
|||||||
-- of each flashing their own cmd.exe window (#606). No-op elsewhere.
|
-- of each flashing their own cmd.exe window (#606). No-op elsewhere.
|
||||||
require("src.core.HostShell").hideHostConsole()
|
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
|
-- 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
|
-- the love loaders once so every generated-asset read falls back to the
|
||||||
-- versioned save-dir copy. Never installed on desktop/Android/iOS.
|
-- versioned save-dir copy. Never installed on desktop/Android/iOS.
|
||||||
@@ -314,14 +351,6 @@ function love.load(args)
|
|||||||
-- cache has to be mounted before the editor's Data:load.
|
-- cache has to be mounted before the editor's Data:load.
|
||||||
if editorMode then
|
if editorMode then
|
||||||
local version = os.getenv("POKEPORT_VERSION") or "red"
|
local version = os.getenv("POKEPORT_VERSION") or "red"
|
||||||
local gen2Name = GEN2_NO_EDITOR[version]
|
|
||||||
if gen2Name then
|
|
||||||
-- No launcher behind this run to carry a notice, so say it and stop
|
|
||||||
-- rather than open a Gen 2 slot on Gen 1 panels.
|
|
||||||
print(gen2Name .. " uses a Gen 2 save; the save editor does not read one yet.")
|
|
||||||
love.event.quit(1)
|
|
||||||
return
|
|
||||||
end
|
|
||||||
require("src.core.GameVersion").set(version)
|
require("src.core.GameVersion").set(version)
|
||||||
require("src.import.CacheFs").mountVersion(version)
|
require("src.import.CacheFs").mountVersion(version)
|
||||||
addEditorRequirePath()
|
addEditorRequirePath()
|
||||||
@@ -425,6 +454,7 @@ function love.load(args)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function love.update(dt)
|
function love.update(dt)
|
||||||
|
HostDisplay.update(dt)
|
||||||
SwitchDiagnostics.maybeFlush(false)
|
SwitchDiagnostics.maybeFlush(false)
|
||||||
-- NX only (no-op elsewhere): follow dock/undock without waiting for SDL.
|
-- NX only (no-op elsewhere): follow dock/undock without waiting for SDL.
|
||||||
NxDisplay.sync()
|
NxDisplay.sync()
|
||||||
@@ -472,11 +502,27 @@ function love.update(dt)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function love.draw()
|
function love.draw()
|
||||||
if editorMode then return EditorApp.draw() end
|
if editorMode then
|
||||||
if TouchEditor then return TouchEditor.draw() end
|
HostDisplay.beginFrame("editor", EditorApp)
|
||||||
if Importer then return Importer:draw() end
|
local result = EditorApp.draw()
|
||||||
|
HostDisplay.endFrame("editor", EditorApp)
|
||||||
|
return result
|
||||||
|
end
|
||||||
|
if TouchEditor then
|
||||||
|
HostDisplay.beginFrame("touch_editor", TouchEditor)
|
||||||
|
local result = TouchEditor.draw()
|
||||||
|
HostDisplay.endFrame("touch_editor", TouchEditor)
|
||||||
|
return result
|
||||||
|
end
|
||||||
|
if Importer then
|
||||||
|
HostDisplay.beginFrame("launcher", Importer)
|
||||||
|
local result = Importer:draw()
|
||||||
|
HostDisplay.endFrame("launcher", Importer)
|
||||||
|
return result
|
||||||
|
end
|
||||||
if not Game then return end
|
if not Game then return end
|
||||||
|
|
||||||
|
HostDisplay.beginFrame("game", Game)
|
||||||
Game:draw()
|
Game:draw()
|
||||||
-- frame capture requested by a driver
|
-- frame capture requested by a driver
|
||||||
if Game.capturePath then
|
if Game.capturePath then
|
||||||
@@ -491,6 +537,7 @@ function love.draw()
|
|||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
HostDisplay.endFrame("game", Game)
|
||||||
end
|
end
|
||||||
|
|
||||||
function love.keypressed(key, scancode, isrepeat)
|
function love.keypressed(key, scancode, isrepeat)
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
#ifdef LOVE_ANDROID
|
#ifdef LOVE_ANDROID
|
||||||
|
|
||||||
#include <cerrno>
|
#include <cerrno>
|
||||||
|
#include <cstring>
|
||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
|
|
||||||
#include <SDL.h>
|
#include <SDL.h>
|
||||||
@@ -324,6 +325,182 @@ bool httpDownload(const char *url, const char *destPath, const char *userAgent,
|
|||||||
return result;
|
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
|
* Helper functions for the filesystem module
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -98,6 +98,27 @@ bool restartApp();
|
|||||||
**/
|
**/
|
||||||
bool httpDownload(const char *url, const char *destPath, const char *userAgent, const char *accept);
|
bool httpDownload(const char *url, const char *destPath, const char *userAgent, const char *accept);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
* Helper functions for the filesystem module
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -192,8 +192,14 @@ bool System::pickFile(const char *kind) const
|
|||||||
dest = "picked_mod.zip";
|
dest = "picked_mod.zip";
|
||||||
else if (strcmp(kind, "sav") == 0 || strcmp(kind, "save") == 0)
|
else if (strcmp(kind, "sav") == 0 || strcmp(kind, "save") == 0)
|
||||||
dest = "picked_save.sav";
|
dest = "picked_save.sav";
|
||||||
|
else if (strcmp(kind, "required_import") == 0)
|
||||||
|
dest = "picked_required_import.bin";
|
||||||
else if (strcmp(kind, "rom") == 0)
|
else if (strcmp(kind, "rom") == 0)
|
||||||
dest = "picked_rom.gb";
|
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);
|
return love::android::showFilePicker(dest);
|
||||||
#else
|
#else
|
||||||
@@ -202,6 +208,15 @@ bool System::pickFile(const char *kind) const
|
|||||||
#endif
|
#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
|
bool System::createFile(const char *suggestedName) const
|
||||||
{
|
{
|
||||||
#ifdef LOVE_ANDROID
|
#ifdef LOVE_ANDROID
|
||||||
@@ -244,6 +259,72 @@ bool System::httpDownload(const char *url, const char *destPath,
|
|||||||
#endif
|
#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
|
bool System::hasBackgroundMusic() const
|
||||||
{
|
{
|
||||||
#if defined(LOVE_ANDROID)
|
#if defined(LOVE_ANDROID)
|
||||||
|
|||||||
@@ -112,10 +112,12 @@ public:
|
|||||||
* love::android::showFilePicker and src/import/RomImporter.lua.
|
* love::android::showFilePicker and src/import/RomImporter.lua.
|
||||||
*
|
*
|
||||||
* @param kind Optional pick kind: nullptr/"rom" -> picked_rom.gb,
|
* @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.
|
* @return Whether the picker was shown.
|
||||||
**/
|
**/
|
||||||
virtual bool pickFile(const char *kind = nullptr) const;
|
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
|
* Shows the platform's native "create / save a file" UI (Android SAF
|
||||||
@@ -149,6 +151,20 @@ public:
|
|||||||
virtual bool httpDownload(const char *url, const char *destPath,
|
virtual bool httpDownload(const char *url, const char *destPath,
|
||||||
const char *userAgent = nullptr, const char *accept = nullptr) const;
|
const char *userAgent = nullptr, const char *accept = 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.
|
* Gets if the user is playing music on background.
|
||||||
* Throws an exception on unsupported platforms.
|
* Throws an exception on unsupported platforms.
|
||||||
|
|||||||
@@ -102,6 +102,12 @@ int w_pickFile(lua_State *L)
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int w_pickFileKinds(lua_State *L)
|
||||||
|
{
|
||||||
|
luax_pushstring(L, instance()->pickFileKinds());
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
int w_createFile(lua_State *L)
|
int w_createFile(lua_State *L)
|
||||||
{
|
{
|
||||||
const char *suggested = luaL_optstring(L, 1, nullptr);
|
const char *suggested = luaL_optstring(L, 1, nullptr);
|
||||||
@@ -139,6 +145,79 @@ int w_hasBackgroundMusic(lua_State *L)
|
|||||||
return 1;
|
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[] =
|
static const luaL_Reg functions[] =
|
||||||
{
|
{
|
||||||
{ "getOS", w_getOS },
|
{ "getOS", w_getOS },
|
||||||
@@ -149,10 +228,17 @@ static const luaL_Reg functions[] =
|
|||||||
{ "openURL", w_openURL },
|
{ "openURL", w_openURL },
|
||||||
{ "vibrate", w_vibrate },
|
{ "vibrate", w_vibrate },
|
||||||
{ "pickFile", w_pickFile },
|
{ "pickFile", w_pickFile },
|
||||||
|
{ "pickFileKinds", w_pickFileKinds },
|
||||||
{ "createFile", w_createFile },
|
{ "createFile", w_createFile },
|
||||||
{ "syncHealthSteps", w_syncHealthSteps },
|
{ "syncHealthSteps", w_syncHealthSteps },
|
||||||
{ "restartApp", w_restartApp },
|
{ "restartApp", w_restartApp },
|
||||||
{ "httpDownload", w_httpDownload },
|
{ "httpDownload", w_httpDownload },
|
||||||
|
{ "tlsOpen", w_tlsOpen },
|
||||||
|
{ "tlsStatus", w_tlsStatus },
|
||||||
|
{ "tlsSend", w_tlsSend },
|
||||||
|
{ "tlsReceive", w_tlsReceive },
|
||||||
|
{ "tlsError", w_tlsError },
|
||||||
|
{ "tlsClose", w_tlsClose },
|
||||||
{ "hasBackgroundMusic", w_hasBackgroundMusic },
|
{ "hasBackgroundMusic", w_hasBackgroundMusic },
|
||||||
{ 0, 0 }
|
{ 0, 0 }
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -90,6 +90,9 @@ public class GameActivity extends SDLActivity {
|
|||||||
private static final String PICKED_ROM_FILENAME = "picked_rom.gb";
|
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_MOD_FILENAME = "picked_mod.zip";
|
||||||
private static final String PICKED_SAVE_FILENAME = "picked_save.sav";
|
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 PENDING_EXPORT_FILENAME = "pending_export.sav";
|
||||||
private static final String EXPORT_DONE_FILENAME = "export_done.flag";
|
private static final String EXPORT_DONE_FILENAME = "export_done.flag";
|
||||||
// Written when a SAF pick cannot be read at all, with the destination
|
// Written when a SAF pick cannot be read at all, with the destination
|
||||||
@@ -143,16 +146,36 @@ public class GameActivity extends SDLActivity {
|
|||||||
|
|
||||||
private static native void nativeSetDefaultStreamValues(int sampleRate, int framesPerBurst);
|
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
|
@Override
|
||||||
protected String[] getLibraries() {
|
protected String[] getLibraries() {
|
||||||
return new String[] {
|
String[] hostLibraries = getHostLibraries();
|
||||||
"c++_shared",
|
String[] libraries = new String[hostLibraries.length + 4];
|
||||||
"mpg123",
|
libraries[0] = "c++_shared";
|
||||||
"openal",
|
libraries[1] = "mpg123";
|
||||||
"love",
|
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
|
@Override
|
||||||
protected String getMainSharedObject() {
|
protected String getMainSharedObject() {
|
||||||
String[] libs = getLibraries();
|
String[] libs = getLibraries();
|
||||||
@@ -192,7 +215,9 @@ public class GameActivity extends SDLActivity {
|
|||||||
intent.setData(null);
|
intent.setData(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onHostCreateBeforeSDL(savedInstanceState);
|
||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState);
|
||||||
|
onHostCreateAfterSDL(savedInstanceState);
|
||||||
if (savedInstanceState != null) {
|
if (savedInstanceState != null) {
|
||||||
// Restore the in-flight SAF destinations, so a pick that returns to
|
// Restore the in-flight SAF destinations, so a pick that returns to
|
||||||
// a recreated activity still lands under the basename it asked for.
|
// a recreated activity still lands under the basename it asked for.
|
||||||
@@ -341,6 +366,7 @@ public class GameActivity extends SDLActivity {
|
|||||||
Log.d("GameActivity", "Cancelling vibration");
|
Log.d("GameActivity", "Cancelling vibration");
|
||||||
vibrator.cancel();
|
vibrator.cancel();
|
||||||
}
|
}
|
||||||
|
onHostDestroy();
|
||||||
super.onDestroy();
|
super.onDestroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,12 +377,14 @@ public class GameActivity extends SDLActivity {
|
|||||||
vibrator.cancel();
|
vibrator.cancel();
|
||||||
}
|
}
|
||||||
teardownSecondaryDisplay();
|
teardownSecondaryDisplay();
|
||||||
|
onHostPause();
|
||||||
super.onPause();
|
super.onPause();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onResume() {
|
public void onResume() {
|
||||||
super.onResume();
|
super.onResume();
|
||||||
|
onHostResume();
|
||||||
setupSecondaryDisplay();
|
setupSecondaryDisplay();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -479,7 +507,8 @@ public class GameActivity extends SDLActivity {
|
|||||||
* picker-agnostic and unchanged.
|
* picker-agnostic and unchanged.
|
||||||
*
|
*
|
||||||
* @param destFilename basename under the app save identity (e.g.
|
* @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. */
|
/** Legacy single-argument entry; resolves the save dir itself. */
|
||||||
@Keep
|
@Keep
|
||||||
@@ -510,6 +539,11 @@ public class GameActivity extends SDLActivity {
|
|||||||
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
|
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
|
||||||
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
||||||
intent.setType("*/*");
|
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 {
|
try {
|
||||||
self.startActivityForResult(intent, FILE_PICKER_REQUEST_CODE);
|
self.startActivityForResult(intent, FILE_PICKER_REQUEST_CODE);
|
||||||
return true;
|
return true;
|
||||||
@@ -522,6 +556,7 @@ public class GameActivity extends SDLActivity {
|
|||||||
Intent intent = new Intent(Intent.ACTION_GET_CONTENT);
|
Intent intent = new Intent(Intent.ACTION_GET_CONTENT);
|
||||||
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
||||||
intent.setType("*/*");
|
intent.setType("*/*");
|
||||||
|
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||||
try {
|
try {
|
||||||
self.startActivityForResult(
|
self.startActivityForResult(
|
||||||
Intent.createChooser(intent, "Choose a file"),
|
Intent.createChooser(intent, "Choose a file"),
|
||||||
@@ -551,6 +586,12 @@ public class GameActivity extends SDLActivity {
|
|||||||
return showFilePicker(PICKED_SAVE_FILENAME);
|
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
|
* Relaunches the whole app for love.system.restartApp, used by
|
||||||
* src/core/HostShell.lua when a mod toggle needs a cold boot (#575).
|
* src/core/HostShell.lua when a mod toggle needs a cold boot (#575).
|
||||||
@@ -606,6 +647,45 @@ public class GameActivity extends SDLActivity {
|
|||||||
* The body lands in a .part file and is renamed only once complete, so a
|
* 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.
|
* 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
|
@Keep
|
||||||
public static boolean httpDownload(String url, String destPath, String userAgent, String accept) {
|
public static boolean httpDownload(String url, String destPath, String userAgent, String accept) {
|
||||||
if (url == null || destPath == null) return false;
|
if (url == null || destPath == null) return false;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ an iOS app with LÖVE 12.0.
|
|||||||
|
|
||||||
The app uses the public iOS Documents directory as its LÖVE save directory.
|
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
|
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
|
README file there. When browsing `On My iPhone > gen1recomp++` in Files, the
|
||||||
directory contains the app's runtime data directly, including:
|
directory contains the app's runtime data directly, including:
|
||||||
|
|
||||||
- installed mods and downloaded ROMs
|
- installed mods and downloaded ROMs
|
||||||
@@ -46,7 +46,7 @@ scripts/build_ios.sh --device --release --install
|
|||||||
Device builds require a paired, unlocked device and a valid Apple signing
|
Device builds require a paired, unlocked device and a valid Apple signing
|
||||||
identity. Set `DEVELOPMENT_TEAM` or `CODE_SIGN_IDENTITY` when automatic
|
identity. Set `DEVELOPMENT_TEAM` or `CODE_SIGN_IDENTITY` when automatic
|
||||||
signing cannot select the intended account. Add `--ipa` to create
|
signing cannot select the intended account. Add `--ipa` to create
|
||||||
`dist/ios/gen1recomp.ipa`.
|
`dist/ios/gen1recomp++.ipa`.
|
||||||
|
|
||||||
The script verifies the final app before packaging it:
|
The script verifies the final app before packaging it:
|
||||||
|
|
||||||
@@ -76,10 +76,10 @@ option.
|
|||||||
Simulator and device app bundles are copied to:
|
Simulator and device app bundles are copied to:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
dist/ios/Debug-iphonesimulator/gen1recomp.app
|
dist/ios/Debug-iphonesimulator/gen1recomp++.app
|
||||||
dist/ios/Release-iphonesimulator/gen1recomp.app
|
dist/ios/Release-iphonesimulator/gen1recomp++.app
|
||||||
dist/ios/Debug-iphoneos/gen1recomp.app
|
dist/ios/Debug-iphoneos/gen1recomp++.app
|
||||||
dist/ios/Release-iphoneos/gen1recomp.app
|
dist/ios/Release-iphoneos/gen1recomp++.app
|
||||||
```
|
```
|
||||||
|
|
||||||
The intermediate Xcode products are under `mobile/ios/build/`. Both locations
|
The intermediate Xcode products are under `mobile/ios/build/`. Both locations
|
||||||
@@ -94,9 +94,9 @@ saves; those are created at runtime in Documents.
|
|||||||
|
|
||||||
| Field | Default |
|
| Field | Default |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Display name | `gen1recomp` |
|
| Display name | `gen1recomp++` |
|
||||||
| Product name | `gen1recomp` |
|
| Product name | `gen1recomp++` |
|
||||||
| Bundle identifier | `com.theboisclub.gen1recomp` |
|
| Bundle identifier | `com.theboisclub.gen1recompplusplus` |
|
||||||
| Save directory | Public `Documents` root |
|
| Save directory | Public `Documents` root |
|
||||||
| Orientation | Portrait |
|
| Orientation | Portrait |
|
||||||
|
|
||||||
|
|||||||
+123
-4
@@ -1,17 +1,136 @@
|
|||||||
{
|
{
|
||||||
"name": "gen1recomp App Repo",
|
"name": "gen1recomp++ App Repo",
|
||||||
"identifier": "com.theboisclub.gen1recomp.repo",
|
"identifier": "com.theboisclub.gen1recompplusplus.repo",
|
||||||
"iconURL": "https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/assets/logo/gen1recomp_cover.png",
|
"iconURL": "https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/assets/logo/gen1recomp_cover.png",
|
||||||
"apps": [
|
"apps": [
|
||||||
{
|
{
|
||||||
"name": "gen1recomp",
|
"name": "gen1recomp++",
|
||||||
"bundleIdentifier": "com.theboisclub.gen1recomp",
|
"bundleIdentifier": "com.theboisclub.gen1recompplusplus",
|
||||||
"developerName": "bryanthaboi",
|
"developerName": "bryanthaboi",
|
||||||
"iconURL": "https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/assets/logo/gen1recomp_cover.png",
|
"iconURL": "https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/assets/logo/gen1recomp_cover.png",
|
||||||
"localizedDescription": "Gen1Recomp - A native Lua / LÖVE2D recreation of Gen 1 Poke",
|
"localizedDescription": "Gen1Recomp - A native Lua / LÖVE2D recreation of Gen 1 Poke",
|
||||||
"tintColor": "3b5ca8",
|
"tintColor": "3b5ca8",
|
||||||
"category": "games",
|
"category": "games",
|
||||||
"versions": [
|
"versions": [
|
||||||
|
{
|
||||||
|
"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",
|
"version": "0.1.77",
|
||||||
"date": "2026-08-10",
|
"date": "2026-08-10",
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
// without updating that patch (see mobile/ios/patch_love_src.py).
|
// without updating that patch (see mobile/ios/patch_love_src.py).
|
||||||
//
|
//
|
||||||
// Contract (mirrors love-android's GameActivity.showFilePicker):
|
// 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 /
|
// the LÖVE save directory as picked_rom.gb / picked_mod.zip /
|
||||||
// picked_save.sav; RomImporter's pending-file scan consumes it.
|
// picked_save.sav; RomImporter's pending-file scan consumes it.
|
||||||
// love.system.createFile(name) -> exports save dir's pending_export.sav
|
// love.system.createFile(name) -> exports save dir's pending_export.sav
|
||||||
@@ -83,6 +83,8 @@ public final class GRPickerBridge: NSObject {
|
|||||||
types = [.zip]
|
types = [.zip]
|
||||||
case "sav":
|
case "sav":
|
||||||
destName = "picked_save.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 --
|
// A Nintendo 64 cartridge, for mods that build assets out of one --
|
||||||
// the voxel mod's Pokemon Stadium battle models are the caller this
|
// 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
|
// was added for. Its own filename on purpose: an N64 ROM landing on
|
||||||
@@ -135,7 +137,7 @@ public final class GRPickerBridge: NSObject {
|
|||||||
// Kept beside the switch it describes, because the two drifting apart is
|
// Kept beside the switch it describes, because the two drifting apart is
|
||||||
// the only way this can lie.
|
// the only way this can lie.
|
||||||
@objc public static func supportedPickerKinds() -> NSString {
|
@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:)
|
@objc(presentExportWithName:saveDir:)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>en</string>
|
<string>en</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>gen1recomp</string>
|
<string>gen1recomp++</string>
|
||||||
<key>CFBundleDocumentTypes</key>
|
<key>CFBundleDocumentTypes</key>
|
||||||
<array>
|
<array>
|
||||||
<dict>
|
<dict>
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
<key>CFBundleInfoDictionaryVersion</key>
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
<string>6.0</string>
|
<string>6.0</string>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
<string>gen1recomp</string>
|
<string>gen1recomp++</string>
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ WRAP_SYSTEM = LOVE_SRC / "src" / "modules" / "system" / "wrap_System.cpp"
|
|||||||
PBXPROJ = LOVE_SRC / "platform" / "xcode" / "love.xcodeproj" / "project.pbxproj"
|
PBXPROJ = LOVE_SRC / "platform" / "xcode" / "love.xcodeproj" / "project.pbxproj"
|
||||||
APPLE_MM = LOVE_SRC / "src" / "common" / "apple.mm"
|
APPLE_MM = LOVE_SRC / "src" / "common" / "apple.mm"
|
||||||
FILESYSTEM_CPP = LOVE_SRC / "src" / "modules" / "filesystem" / "physfs" / "Filesystem.cpp"
|
FILESYSTEM_CPP = LOVE_SRC / "src" / "modules" / "filesystem" / "physfs" / "Filesystem.cpp"
|
||||||
|
IOS_MM = LOVE_SRC / "src" / "common" / "ios.mm"
|
||||||
|
IOS_H = LOVE_SRC / "src" / "common" / "ios.h"
|
||||||
|
SYSTEM_CPP = LOVE_SRC / "src" / "modules" / "system" / "System.cpp"
|
||||||
ENTITLEMENTS_SRC = IOS_DIR / "overlays" / "love-ios.entitlements"
|
ENTITLEMENTS_SRC = IOS_DIR / "overlays" / "love-ios.entitlements"
|
||||||
|
|
||||||
NATIVE_FILES = ("GRPickerBridge.swift", "GRHealthBridge.swift", "GRBootstrap.m")
|
NATIVE_FILES = ("GRPickerBridge.swift", "GRHealthBridge.swift", "GRBootstrap.m")
|
||||||
@@ -86,7 +89,8 @@ int w_pickFile(lua_State *L)
|
|||||||
return gr_callBridge(L, "GRPickerBridge", "presentPickerWithKind:saveDir:", kind);
|
return gr_callBridge(L, "GRPickerBridge", "presentPickerWithKind:saveDir:", kind);
|
||||||
}
|
}
|
||||||
|
|
||||||
// love.system.pickFileKinds() -> "rom,mod,sav,stadium", or nil off iOS.
|
// love.system.pickFileKinds() -> the comma-separated kinds supported by the
|
||||||
|
// Swift bridge (including required_import), or nil off iOS.
|
||||||
//
|
//
|
||||||
// So a caller can ask what this build's picker understands BEFORE opening it.
|
// So a caller can ask what this build's picker understands BEFORE opening it.
|
||||||
// An unknown kind is refused (GRPickerBridge), and a refusal looks exactly
|
// An unknown kind is refused (GRPickerBridge), and a refusal looks exactly
|
||||||
@@ -120,7 +124,7 @@ int w_pickFileKinds(lua_State *L)
|
|||||||
typedef const char *(*GRUTF8)(id, SEL);
|
typedef const char *(*GRUTF8)(id, SEL);
|
||||||
const char *bytes = ((GRUTF8)objc_msgSend)(kinds,
|
const char *bytes = ((GRUTF8)objc_msgSend)(kinds,
|
||||||
sel_registerName("UTF8String"));
|
sel_registerName("UTF8String"));
|
||||||
if (bytes == nullptr || bytes[0] == '\0')
|
if (bytes == nullptr || bytes[0] == '\\0')
|
||||||
{
|
{
|
||||||
lua_pushnil(L);
|
lua_pushnil(L);
|
||||||
return 1;
|
return 1;
|
||||||
@@ -358,6 +362,50 @@ def patch_public_documents():
|
|||||||
print("patch_love_src: iOS save directory routed to Documents root")
|
print("patch_love_src: iOS save directory routed to Documents root")
|
||||||
|
|
||||||
|
|
||||||
|
def patch_ios_haptics():
|
||||||
|
text = pristine(IOS_MM, ("UIImpactFeedbackGenerator",))
|
||||||
|
original = """void vibrate()
|
||||||
|
{
|
||||||
|
@autoreleasepool
|
||||||
|
{
|
||||||
|
AudioServicesPlaySystemSound(kSystemSoundID_Vibrate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
replacement = """void vibrate(double seconds)
|
||||||
|
{
|
||||||
|
@autoreleasepool
|
||||||
|
{
|
||||||
|
UIImpactFeedbackStyle style = UIImpactFeedbackStyleLight;
|
||||||
|
if (seconds >= 0.035)
|
||||||
|
style = UIImpactFeedbackStyleHeavy;
|
||||||
|
else if (seconds >= 0.02)
|
||||||
|
style = UIImpactFeedbackStyleMedium;
|
||||||
|
UIImpactFeedbackGenerator *generator = [[UIImpactFeedbackGenerator alloc]
|
||||||
|
initWithStyle:style];
|
||||||
|
[generator prepare];
|
||||||
|
[generator impactOccurred];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
if original not in text:
|
||||||
|
fail(f"iOS haptic anchor not found in {IOS_MM}")
|
||||||
|
IOS_MM.write_text(text.replace(original, replacement, 1))
|
||||||
|
|
||||||
|
header = pristine(IOS_H, ("void vibrate(double seconds);",))
|
||||||
|
header_original = "void vibrate();"
|
||||||
|
if header_original not in header:
|
||||||
|
fail(f"iOS haptic declaration not found in {IOS_H}")
|
||||||
|
IOS_H.write_text(header.replace(header_original, "void vibrate(double seconds);", 1))
|
||||||
|
|
||||||
|
system = pristine(SYSTEM_CPP, ("love::ios::vibrate(seconds)",))
|
||||||
|
system_original = "love::ios::vibrate();"
|
||||||
|
if system_original not in system:
|
||||||
|
fail(f"iOS haptic call site not found in {SYSTEM_CPP}")
|
||||||
|
SYSTEM_CPP.write_text(system.replace(system_original, "love::ios::vibrate(seconds);", 1))
|
||||||
|
print("patch_love_src: iOS haptics use Taptic Engine impact presets")
|
||||||
|
|
||||||
|
|
||||||
def patch_pbxproj():
|
def patch_pbxproj():
|
||||||
text = pristine(PBXPROJ)
|
text = pristine(PBXPROJ)
|
||||||
|
|
||||||
@@ -408,7 +456,9 @@ def patch_pbxproj():
|
|||||||
fail(f"build configuration {config_id} not found")
|
fail(f"build configuration {config_id} not found")
|
||||||
settings = (
|
settings = (
|
||||||
"\t\t\t\tSWIFT_VERSION = 5.0;\n"
|
"\t\t\t\tSWIFT_VERSION = 5.0;\n"
|
||||||
"\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 14.0;\n"
|
"\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 15.0;\n"
|
||||||
|
"\t\t\t\tPRODUCT_NAME = \"gen1recomp++\";\n"
|
||||||
|
"\t\t\t\tEXECUTABLE_NAME = \"gen1recomp++\";\n"
|
||||||
'\t\t\t\tCODE_SIGN_ENTITLEMENTS = "ios/native/love-ios.entitlements";\n'
|
'\t\t\t\tCODE_SIGN_ENTITLEMENTS = "ios/native/love-ios.entitlements";\n'
|
||||||
)
|
)
|
||||||
text = text[: m.end()] + settings + text[m.end():]
|
text = text[: m.end()] + settings + text[m.end():]
|
||||||
@@ -422,6 +472,7 @@ def main():
|
|||||||
fail("love-src/ missing; run scripts/build_ios.sh --fetch first")
|
fail("love-src/ missing; run scripts/build_ios.sh --fetch first")
|
||||||
copy_native_files()
|
copy_native_files()
|
||||||
patch_public_documents()
|
patch_public_documents()
|
||||||
|
patch_ios_haptics()
|
||||||
patch_wrap_system()
|
patch_wrap_system()
|
||||||
patch_pbxproj()
|
patch_pbxproj()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<!--
|
||||||
|
Desktop TLS dialer for mods that need outbound WSS/TLS (e.g. multiworld
|
||||||
|
clients). Native AOT so the game gets a plain C ABI DLL with no .NET
|
||||||
|
runtime to install. Windows uses Schannel via SslStream; Linux and macOS
|
||||||
|
use their platform backends the same way.
|
||||||
|
|
||||||
|
Publish examples:
|
||||||
|
dotnet publish -c Release -r win-x64 -o ../../dist/native/win-x64
|
||||||
|
dotnet publish -c Release -r linux-x64 -o ../../dist/native/linux-x64
|
||||||
|
dotnet publish -c Release -r osx-x64 -o ../../dist/native/osx-x64
|
||||||
|
-->
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||||
|
<PublishAot>true</PublishAot>
|
||||||
|
<NativeLib>Shared</NativeLib>
|
||||||
|
<InvariantGlobalization>true</InvariantGlobalization>
|
||||||
|
<StripSymbols>true</StripSymbols>
|
||||||
|
<AssemblyName>gen1tls</AssemblyName>
|
||||||
|
<RootNamespace>Gen1Tls</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# gen1tls — desktop TLS dialer
|
||||||
|
|
||||||
|
Native AOT library that gives mods a non-blocking TLS client with the same
|
||||||
|
handle/poll contract as the Android `TlsSocket` / `love.system.tls*` bridge.
|
||||||
|
|
||||||
|
- **Windows:** Schannel via `SslStream` (system trust store, SNI)
|
||||||
|
- **Linux / macOS:** same project, publish with `-r linux-x64` / `osx-x64` /
|
||||||
|
`osx-arm64` when those builds are wired up
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet publish native/tls_dial/Gen1Tls.csproj -c Release -r win-x64 -o dist/native/win-x64
|
||||||
|
```
|
||||||
|
|
||||||
|
The Windows game zip script already does this:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell -ExecutionPolicy Bypass -File scripts/build_windows.ps1 -Version 0.1.77
|
||||||
|
```
|
||||||
|
|
||||||
|
Ship `gen1tls.dll` (or `libgen1tls.so` / `libgen1tls.dylib`) **next to** the
|
||||||
|
fused executable. Mods load it through LuaJIT FFI; no .NET runtime is
|
||||||
|
required on the player's machine.
|
||||||
|
|
||||||
|
Official Windows release zips get `gen1tls.dll` from CI: a `windows-2022`
|
||||||
|
job publishes this project and `scripts/build.sh`'s Windows packaging copies
|
||||||
|
it beside `gen1recomp.exe` (see `GEN1TLS_DLL` / `dist/native/win-x64`).
|
||||||
|
|
||||||
|
## Android
|
||||||
|
|
||||||
|
On Android, the matching API is exposed as `love.system.tlsOpen` /
|
||||||
|
`tlsStatus` / `tlsSend` / `tlsReceive` / `tlsError` / `tlsClose`, backed by
|
||||||
|
`org.love2d.android.TlsSocket`.
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Net.Security;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Security.Authentication;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading;
|
||||||
|
|
||||||
|
namespace Gen1Tls;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Non-blocking TLS client with the same handle/poll contract as the Android
|
||||||
|
/// TlsSocket bridge. Connect and handshake run on a background thread; send
|
||||||
|
/// queues until the stream is ready; receive drains a chunk queue. Certificate
|
||||||
|
/// validation and SNI are SslStream's defaults (platform trust store).
|
||||||
|
/// </summary>
|
||||||
|
public static class TlsDialer
|
||||||
|
{
|
||||||
|
public const int StatusConnecting = 0;
|
||||||
|
public const int StatusOpen = 1;
|
||||||
|
public const int StatusClosed = 2;
|
||||||
|
|
||||||
|
const int ConnectTimeoutMs = 15000;
|
||||||
|
const int ReadChunk = 16384;
|
||||||
|
const int MaxBuffered = 4 * 1024 * 1024;
|
||||||
|
|
||||||
|
static readonly ConcurrentDictionary<int, Conn> Live = new();
|
||||||
|
static int NextHandle = 1;
|
||||||
|
|
||||||
|
sealed class Conn
|
||||||
|
{
|
||||||
|
public required string Host;
|
||||||
|
public required int Port;
|
||||||
|
public int Status = StatusConnecting;
|
||||||
|
public string? Error;
|
||||||
|
public bool Closing;
|
||||||
|
public SslStream? Stream;
|
||||||
|
public TcpClient? Client;
|
||||||
|
|
||||||
|
public readonly object InLock = new();
|
||||||
|
public readonly Queue<byte[]> InChunks = new();
|
||||||
|
public int InHeadOffset;
|
||||||
|
public int InAvailable;
|
||||||
|
|
||||||
|
public readonly object OutLock = new();
|
||||||
|
public readonly Queue<byte[]> OutChunks = new();
|
||||||
|
public readonly ManualResetEventSlim OutPulse = new(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- C ABI
|
||||||
|
|
||||||
|
[UnmanagedCallersOnly(EntryPoint = "gen1tls_open")]
|
||||||
|
public static int Open(nint hostPtr, int port)
|
||||||
|
{
|
||||||
|
if (hostPtr == 0 || port <= 0 || port > 65535) return -1;
|
||||||
|
string? host = Marshal.PtrToStringUTF8(hostPtr);
|
||||||
|
if (string.IsNullOrEmpty(host)) return -1;
|
||||||
|
|
||||||
|
int handle = Interlocked.Increment(ref NextHandle);
|
||||||
|
var conn = new Conn { Host = host, Port = port };
|
||||||
|
if (!Live.TryAdd(handle, conn)) return -1;
|
||||||
|
|
||||||
|
var dialer = new Thread(() => Dial(conn))
|
||||||
|
{
|
||||||
|
IsBackground = true,
|
||||||
|
Name = "gen1tls-dial-" + handle,
|
||||||
|
};
|
||||||
|
dialer.Start();
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
[UnmanagedCallersOnly(EntryPoint = "gen1tls_status")]
|
||||||
|
public static int Status(int handle)
|
||||||
|
=> Live.TryGetValue(handle, out var conn) ? conn.Status : -1;
|
||||||
|
|
||||||
|
[UnmanagedCallersOnly(EntryPoint = "gen1tls_send")]
|
||||||
|
public static int Send(int handle, nint dataPtr, int length)
|
||||||
|
{
|
||||||
|
if (!Live.TryGetValue(handle, out var conn) || dataPtr == 0) return -1;
|
||||||
|
if (conn.Status == StatusClosed) return -1;
|
||||||
|
if (length <= 0) return 0;
|
||||||
|
|
||||||
|
var copy = new byte[length];
|
||||||
|
Marshal.Copy(dataPtr, copy, 0, length);
|
||||||
|
lock (conn.OutLock)
|
||||||
|
{
|
||||||
|
conn.OutChunks.Enqueue(copy);
|
||||||
|
conn.OutPulse.Set();
|
||||||
|
}
|
||||||
|
return length;
|
||||||
|
}
|
||||||
|
|
||||||
|
[UnmanagedCallersOnly(EntryPoint = "gen1tls_receive")]
|
||||||
|
public static int Receive(int handle, nint bufPtr, int max)
|
||||||
|
{
|
||||||
|
if (!Live.TryGetValue(handle, out var conn) || bufPtr == 0 || max <= 0)
|
||||||
|
return 0;
|
||||||
|
return Take(conn, bufPtr, max);
|
||||||
|
}
|
||||||
|
|
||||||
|
[UnmanagedCallersOnly(EntryPoint = "gen1tls_error")]
|
||||||
|
public static int Error(int handle, nint bufPtr, int max)
|
||||||
|
{
|
||||||
|
if (!Live.TryGetValue(handle, out var conn)) return 0;
|
||||||
|
string? err = conn.Error;
|
||||||
|
if (string.IsNullOrEmpty(err) || bufPtr == 0 || max <= 1) return 0;
|
||||||
|
|
||||||
|
byte[] utf8 = Encoding.UTF8.GetBytes(err);
|
||||||
|
int n = Math.Min(utf8.Length, max - 1);
|
||||||
|
Marshal.Copy(utf8, 0, bufPtr, n);
|
||||||
|
Marshal.WriteByte(bufPtr, n, 0);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
[UnmanagedCallersOnly(EntryPoint = "gen1tls_close")]
|
||||||
|
public static void Close(int handle)
|
||||||
|
{
|
||||||
|
if (Live.TryRemove(handle, out var conn))
|
||||||
|
Shutdown(conn, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ internals
|
||||||
|
|
||||||
|
static void Dial(Conn conn)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var client = new TcpClient();
|
||||||
|
var connect = client.ConnectAsync(conn.Host, conn.Port);
|
||||||
|
if (!connect.Wait(ConnectTimeoutMs))
|
||||||
|
throw new TimeoutException($"connect to {conn.Host}:{conn.Port} timed out");
|
||||||
|
connect.GetAwaiter().GetResult();
|
||||||
|
client.NoDelay = true;
|
||||||
|
conn.Client = client;
|
||||||
|
|
||||||
|
var ssl = new SslStream(client.GetStream(), leaveInnerStreamOpen: false);
|
||||||
|
// AuthenticateAsClient sets SNI from targetHost and validates against
|
||||||
|
// the platform trust store -- the whole reason this dialer exists.
|
||||||
|
var auth = ssl.AuthenticateAsClientAsync(conn.Host);
|
||||||
|
if (!auth.Wait(ConnectTimeoutMs))
|
||||||
|
throw new TimeoutException($"TLS handshake with {conn.Host} timed out");
|
||||||
|
auth.GetAwaiter().GetResult();
|
||||||
|
|
||||||
|
conn.Stream = ssl;
|
||||||
|
if (conn.Closing) { Shutdown(conn, null); return; }
|
||||||
|
conn.Status = StatusOpen;
|
||||||
|
|
||||||
|
var writer = new Thread(() => PumpOut(conn))
|
||||||
|
{
|
||||||
|
IsBackground = true,
|
||||||
|
Name = "gen1tls-write",
|
||||||
|
};
|
||||||
|
writer.Start();
|
||||||
|
PumpIn(conn);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Shutdown(conn, Describe(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void PumpIn(Conn conn)
|
||||||
|
{
|
||||||
|
var stream = conn.Stream;
|
||||||
|
if (stream == null) return;
|
||||||
|
var buf = new byte[ReadChunk];
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (!conn.Closing)
|
||||||
|
{
|
||||||
|
int n = stream.Read(buf, 0, buf.Length);
|
||||||
|
if (n <= 0) break;
|
||||||
|
var chunk = new byte[n];
|
||||||
|
Buffer.BlockCopy(buf, 0, chunk, 0, n);
|
||||||
|
lock (conn.InLock)
|
||||||
|
{
|
||||||
|
// A stalled Lua pump must not grow forever; drop the
|
||||||
|
// connection rather than the room's backlog.
|
||||||
|
if (conn.InAvailable + n > MaxBuffered)
|
||||||
|
throw new InvalidOperationException("TLS receive buffer overflow");
|
||||||
|
conn.InChunks.Enqueue(chunk);
|
||||||
|
conn.InAvailable += n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
if (!conn.Closing) Shutdown(conn, Describe(ex));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Shutdown(conn, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void PumpOut(Conn conn)
|
||||||
|
{
|
||||||
|
var stream = conn.Stream;
|
||||||
|
if (stream == null) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (!conn.Closing)
|
||||||
|
{
|
||||||
|
byte[]? chunk = null;
|
||||||
|
lock (conn.OutLock)
|
||||||
|
{
|
||||||
|
if (conn.OutChunks.Count == 0)
|
||||||
|
{
|
||||||
|
conn.OutPulse.Reset();
|
||||||
|
// fall through to wait outside the lock
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
chunk = conn.OutChunks.Dequeue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (chunk == null)
|
||||||
|
{
|
||||||
|
conn.OutPulse.Wait(250);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
stream.Write(chunk, 0, chunk.Length);
|
||||||
|
stream.Flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
if (!conn.Closing) Shutdown(conn, Describe(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static int Take(Conn conn, nint bufPtr, int max)
|
||||||
|
{
|
||||||
|
lock (conn.InLock)
|
||||||
|
{
|
||||||
|
if (conn.InAvailable == 0 || conn.InChunks.Count == 0) return 0;
|
||||||
|
int copied = 0;
|
||||||
|
while (copied < max && conn.InChunks.Count > 0)
|
||||||
|
{
|
||||||
|
byte[] head = conn.InChunks.Peek();
|
||||||
|
int avail = head.Length - conn.InHeadOffset;
|
||||||
|
int n = Math.Min(avail, max - copied);
|
||||||
|
Marshal.Copy(head, conn.InHeadOffset, bufPtr + copied, n);
|
||||||
|
copied += n;
|
||||||
|
conn.InHeadOffset += n;
|
||||||
|
conn.InAvailable -= n;
|
||||||
|
if (conn.InHeadOffset >= head.Length)
|
||||||
|
{
|
||||||
|
conn.InChunks.Dequeue();
|
||||||
|
conn.InHeadOffset = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return copied;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void Shutdown(Conn conn, string? why)
|
||||||
|
{
|
||||||
|
if (conn.Closing && why == null && conn.Status == StatusClosed) return;
|
||||||
|
conn.Closing = true;
|
||||||
|
if (why != null) conn.Error = why;
|
||||||
|
conn.Status = StatusClosed;
|
||||||
|
conn.OutPulse.Set();
|
||||||
|
try { conn.Stream?.Dispose(); } catch { /* ignore */ }
|
||||||
|
try { conn.Client?.Dispose(); } catch { /* ignore */ }
|
||||||
|
conn.Stream = null;
|
||||||
|
conn.Client = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static string Describe(Exception ex)
|
||||||
|
{
|
||||||
|
for (Exception? e = ex; e != null; e = e.InnerException)
|
||||||
|
{
|
||||||
|
if (e is AuthenticationException) return e.Message;
|
||||||
|
if (e is SocketException se) return se.Message;
|
||||||
|
if (e is TimeoutException) return e.Message;
|
||||||
|
if (e is IOException) return e.Message;
|
||||||
|
}
|
||||||
|
return ex.GetBaseException().Message;
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
-1
@@ -13,7 +13,7 @@
|
|||||||
# dist/linux/gen1recomp-linux.zip (fused x86_64 AppImage)
|
# dist/linux/gen1recomp-linux.zip (fused x86_64 AppImage)
|
||||||
# dist/android/debug/*.apk (full gradle output stays under
|
# dist/android/debug/*.apk (full gradle output stays under
|
||||||
# mobile/android/app/build/outputs/apk/embedNoRecord/)
|
# mobile/android/app/build/outputs/apk/embedNoRecord/)
|
||||||
# dist/ios/<Config>-<sdk>/gen1recomp.app (full xcodebuild output stays
|
# dist/ios/<Config>-<sdk>/gen1recomp++.app (full xcodebuild output stays
|
||||||
# under mobile/ios/build/Build/Products/)
|
# under mobile/ios/build/Build/Products/)
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -261,6 +261,21 @@ build_win() {
|
|||||||
cp "$love_dir"/*.dll "$out_dir"/
|
cp "$love_dir"/*.dll "$out_dir"/
|
||||||
cp "$love_dir"/license.txt "$out_dir"/ 2>/dev/null || true
|
cp "$love_dir"/license.txt "$out_dir"/ 2>/dev/null || true
|
||||||
|
|
||||||
|
# Native AOT TLS dialer for outbound wss:// (e.g. Archipelago hosted rooms).
|
||||||
|
# Release CI builds this on windows-2022 (Native AOT can't cross-compile
|
||||||
|
# win-x64 from the Mac runner) and either exports GEN1TLS_DLL or drops the
|
||||||
|
# file at dist/native/win-x64/gen1tls.dll before calling build.sh.
|
||||||
|
local tls_dll="${GEN1TLS_DLL:-}"
|
||||||
|
if [ -z "$tls_dll" ] && [ -f "$DIST/native/win-x64/gen1tls.dll" ]; then
|
||||||
|
tls_dll="$DIST/native/win-x64/gen1tls.dll"
|
||||||
|
fi
|
||||||
|
if [ -n "$tls_dll" ] && [ -f "$tls_dll" ]; then
|
||||||
|
cp "$tls_dll" "$out_dir/gen1tls.dll"
|
||||||
|
say "bundled gen1tls.dll for Windows TLS (wss://)"
|
||||||
|
else
|
||||||
|
warn "gen1tls.dll not found — Windows zip will not support wss:// (set GEN1TLS_DLL or build native/tls_dial)"
|
||||||
|
fi
|
||||||
|
|
||||||
# The exe's icon lives in love.exe's PE resources, so it must be patched
|
# The exe's icon lives in love.exe's PE resources, so it must be patched
|
||||||
# BEFORE the .love is appended: peresed rewrites the whole file and would
|
# BEFORE the .love is appended: peresed rewrites the whole file and would
|
||||||
# drop the fused bytes. peresed (pipx install pe_tools) has no .ico input,
|
# drop the fused bytes. peresed (pipx install pe_tools) has no .ico input,
|
||||||
@@ -400,6 +415,9 @@ EOF
|
|||||||
grep -q '^FUSE_PATH="\$APPDIR/game.love"$' "$appdir/AppRun" \
|
grep -q '^FUSE_PATH="\$APPDIR/game.love"$' "$appdir/AppRun" \
|
||||||
|| fail "failed to enable FUSE_PATH in AppRun (upstream AppRun changed?)"
|
|| fail "failed to enable FUSE_PATH in AppRun (upstream AppRun changed?)"
|
||||||
|
|
||||||
|
sed -i '' 's|^exec "\$APPDIR/bin/love"|if [ -n "$WAYLAND_DISPLAY" ] \&\& [ -z "$SDL_VIDEODRIVER" ]; then export SDL_VIDEODRIVER=x11; fi\
|
||||||
|
exec "$APPDIR/bin/love"|' "$appdir/AppRun"
|
||||||
|
|
||||||
# Match the upstream image's compression (gzip, 128K blocks) so the
|
# Match the upstream image's compression (gzip, 128K blocks) so the
|
||||||
# bundled runtime can read it.
|
# bundled runtime can read it.
|
||||||
local sfs_out="$WORK/game.squashfs"
|
local sfs_out="$WORK/game.squashfs"
|
||||||
|
|||||||
@@ -27,10 +27,9 @@ APPLICATION_ID="com.theboisclub.pokemonred"
|
|||||||
LOVE_ANDROID_VERSION="11.5a"
|
LOVE_ANDROID_VERSION="11.5a"
|
||||||
NDK_VERSION="25.2.9519653"
|
NDK_VERSION="25.2.9519653"
|
||||||
YELLOW_MANIFEST_RELATIVE="tools/rom_manifest_yellow.json"
|
YELLOW_MANIFEST_RELATIVE="tools/rom_manifest_yellow.json"
|
||||||
# A fresh source checkout normally supplies this through Git. This URL is
|
|
||||||
# deliberately only a last resort for incomplete source exports: the manifest
|
|
||||||
# contains extraction metadata, never a ROM or extracted game data.
|
|
||||||
YELLOW_MANIFEST_URL="${YELLOW_MANIFEST_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/tools/rom_manifest_yellow.json}"
|
YELLOW_MANIFEST_URL="${YELLOW_MANIFEST_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/tools/rom_manifest_yellow.json}"
|
||||||
|
GOLD_MANIFEST_RELATIVE="tools/rom_manifest_gold.json"
|
||||||
|
GOLD_MANIFEST_URL="${GOLD_MANIFEST_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/tools/rom_manifest_gold.json}"
|
||||||
|
|
||||||
VERSION=""
|
VERSION=""
|
||||||
PACKAGE_ONLY=false
|
PACKAGE_ONLY=false
|
||||||
@@ -130,6 +129,54 @@ ensure_yellow_manifest() {
|
|||||||
fail "Yellow import manifest is unavailable. Git recovery failed and could not download $YELLOW_MANIFEST_URL"
|
fail "Yellow import manifest is unavailable. Git recovery failed and could not download $YELLOW_MANIFEST_URL"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
gold_manifest_is_valid() {
|
||||||
|
local path="$1"
|
||||||
|
python3 - "$path" <<'PY'
|
||||||
|
import json, pathlib, sys
|
||||||
|
|
||||||
|
try:
|
||||||
|
manifest = json.loads(pathlib.Path(sys.argv[1]).read_text())
|
||||||
|
except (OSError, ValueError):
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
raise SystemExit(0 if manifest.get("romSha1") ==
|
||||||
|
"d8b8a3600a465308c9953dfa04f0081c05bdcb94" else 1)
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_gold_manifest() {
|
||||||
|
local manifest="$ROOT/$GOLD_MANIFEST_RELATIVE"
|
||||||
|
local staged
|
||||||
|
staged="$(mktemp)"
|
||||||
|
|
||||||
|
if gold_manifest_is_valid "$manifest"; then
|
||||||
|
rm -f "$staged"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
warn "Gold import manifest is missing or invalid; recovering it before packaging"
|
||||||
|
if git -C "$ROOT" show "HEAD:$GOLD_MANIFEST_RELATIVE" > "$staged" 2>/dev/null \
|
||||||
|
&& gold_manifest_is_valid "$staged"; then
|
||||||
|
mkdir -p "$(dirname "$manifest")"
|
||||||
|
mv "$staged" "$manifest"
|
||||||
|
say "restored Gold import manifest from this checkout's Git data"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v curl >/dev/null 2>&1 \
|
||||||
|
&& curl --fail --location --retry 2 --connect-timeout 15 \
|
||||||
|
--output "$staged" "$GOLD_MANIFEST_URL" \
|
||||||
|
&& gold_manifest_is_valid "$staged"; then
|
||||||
|
mkdir -p "$(dirname "$manifest")"
|
||||||
|
mv "$staged" "$manifest"
|
||||||
|
say "downloaded Gold import manifest from the project repository"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -f "$staged"
|
||||||
|
fail "Gold import manifest is unavailable. Git recovery failed and could not download $GOLD_MANIFEST_URL"
|
||||||
|
}
|
||||||
|
|
||||||
# --------------------------------------------------------------- branding
|
# --------------------------------------------------------------- branding
|
||||||
# love-android 11.5+ reads app id / name / orientation from gradle.properties.
|
# love-android 11.5+ reads app id / name / orientation from gradle.properties.
|
||||||
# Manifest still gets permission trims. Re-applied every build so refreshing
|
# Manifest still gets permission trims. Re-applied every build so refreshing
|
||||||
@@ -194,6 +241,7 @@ PY
|
|||||||
pack_game_love() {
|
pack_game_love() {
|
||||||
say "packing game.love for love-android embed flavor"
|
say "packing game.love for love-android embed flavor"
|
||||||
ensure_yellow_manifest
|
ensure_yellow_manifest
|
||||||
|
ensure_gold_manifest
|
||||||
mkdir -p "$EMBED_ASSETS"
|
mkdir -p "$EMBED_ASSETS"
|
||||||
rm -f "$LOVE_FILE"
|
rm -f "$LOVE_FILE"
|
||||||
# tools/save-editor ships with the app: the launcher's Edit button on a save
|
# tools/save-editor ships with the app: the launcher's Edit button on a save
|
||||||
|
|||||||
+43
-25
@@ -20,9 +20,9 @@
|
|||||||
# - macOS + Xcode (xcodebuild)
|
# - macOS + Xcode (xcodebuild)
|
||||||
# - mobile/ios/love-src/ (see --fetch / mobile/ios/README.md)
|
# - mobile/ios/love-src/ (see --fetch / mobile/ios/README.md)
|
||||||
#
|
#
|
||||||
# Output: dist/ios/<Config>-<sdk>/gen1recomp.app (convenience copy)
|
# Output: dist/ios/<Config>-<sdk>/gen1recomp++.app (convenience copy)
|
||||||
# dist/ios/gen1recomp.ipa (device builds only)
|
# dist/ios/gen1recomp++.ipa (device builds only)
|
||||||
# mobile/ios/build/Build/Products/<Config>-<sdk>/gen1recomp.app
|
# mobile/ios/build/Build/Products/<Config>-<sdk>/gen1recomp++.app
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -39,8 +39,9 @@ RESOURCES_DIR="$XCODE_DIR/ios/resources"
|
|||||||
LOVE_FILE="$RESOURCES_DIR/game.love"
|
LOVE_FILE="$RESOURCES_DIR/game.love"
|
||||||
LIBS_DIR="$XCODE_DIR/ios/libraries"
|
LIBS_DIR="$XCODE_DIR/ios/libraries"
|
||||||
|
|
||||||
APP_NAME="gen1recomp"
|
APP_NAME="gen1recomp++"
|
||||||
DISPLAY_NAME="gen1recomp"
|
DISPLAY_NAME="gen1recomp++"
|
||||||
|
PRODUCT_NAME="gen1recomp++"
|
||||||
# Bundle ID resolution, most specific wins:
|
# Bundle ID resolution, most specific wins:
|
||||||
# 1. GEN1_BUNDLE_ID env var
|
# 1. GEN1_BUNDLE_ID env var
|
||||||
# 2. mobile/ios/bundle_id.local (one line, gitignored — pins YOUR install
|
# 2. mobile/ios/bundle_id.local (one line, gitignored — pins YOUR install
|
||||||
@@ -50,7 +51,7 @@ DISPLAY_NAME="gen1recomp"
|
|||||||
# capabilities like HealthKit are involved), so a per-team default
|
# capabilities like HealthKit are involved), so a per-team default
|
||||||
# lets anyone build without colliding with someone else's app
|
# lets anyone build without colliding with someone else's app
|
||||||
# 4. simulator: the project default (no App ID registration involved)
|
# 4. simulator: the project default (no App ID registration involved)
|
||||||
BUNDLE_ID="${GEN1_BUNDLE_ID:-com.theboisclub.gen1recomp}"
|
BUNDLE_ID="${GEN1_BUNDLE_ID:-}"
|
||||||
if [ -z "$BUNDLE_ID" ] && [ -f "$IOS_DIR/bundle_id.local" ]; then
|
if [ -z "$BUNDLE_ID" ] && [ -f "$IOS_DIR/bundle_id.local" ]; then
|
||||||
BUNDLE_ID="$(tr -d '[:space:]' < "$IOS_DIR/bundle_id.local")"
|
BUNDLE_ID="$(tr -d '[:space:]' < "$IOS_DIR/bundle_id.local")"
|
||||||
fi
|
fi
|
||||||
@@ -138,11 +139,7 @@ if $DEVICE && [ -z "${DEVELOPMENT_TEAM:-}" ]; then
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
if [ -z "$BUNDLE_ID" ]; then
|
if [ -z "$BUNDLE_ID" ]; then
|
||||||
if $DEVICE; then
|
BUNDLE_ID="com.theboisclub.gen1recompplusplus"
|
||||||
BUNDLE_ID="com.gen1recomp.t$(printf '%s' "$DEVELOPMENT_TEAM" | tr '[:upper:]' '[:lower:]')"
|
|
||||||
else
|
|
||||||
BUNDLE_ID="com.theboisclub.pokemonred"
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --------------------------------------------------------------- host checks
|
# --------------------------------------------------------------- host checks
|
||||||
@@ -226,13 +223,26 @@ verify_documents_overlay() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
apply_ios_icon() {
|
apply_ios_icon() {
|
||||||
local source="$ROOT/assets/logo/gen1recomp_cover.png"
|
local source="$ROOT/assets/logo/logo.png"
|
||||||
local target="$XCODE_DIR/Images.xcassets/iOS AppIcon.appiconset"
|
local target="$XCODE_DIR/Images.xcassets/iOS AppIcon.appiconset"
|
||||||
[ -f "$source" ] || fail "missing iOS icon source: $source"
|
[ -f "$source" ] || fail "missing iOS icon source: $source"
|
||||||
[ -d "$target" ] || fail "missing iOS app icon set: $target"
|
[ -d "$target" ] || fail "missing iOS app icon set: $target"
|
||||||
|
local icon="$BUILD_DIR/gen1recomp-ios-icon.png"
|
||||||
|
mkdir -p "$BUILD_DIR"
|
||||||
|
if command -v magick >/dev/null 2>&1; then
|
||||||
|
magick -size 1024x1024 xc:black \
|
||||||
|
\( "$source" -resize 900x900 \) -gravity center -composite \
|
||||||
|
-alpha off "$icon" || fail "could not create iOS app icon: $source"
|
||||||
|
else
|
||||||
|
local scaled="$BUILD_DIR/gen1recomp-logo.png"
|
||||||
|
sips -Z 900 "$source" --out "$scaled" >/dev/null \
|
||||||
|
|| fail "could not resize iOS app icon source: $source"
|
||||||
|
sips -p 1024 1024 --padColor 000000 "$scaled" --out "$icon" >/dev/null \
|
||||||
|
|| fail "could not center iOS app icon source: $source"
|
||||||
|
fi
|
||||||
local entry name size
|
local entry name size
|
||||||
while IFS=: read -r name size; do
|
while IFS=: read -r name size; do
|
||||||
sips -z "$size" "$size" "$source" --out "$target/$name" >/dev/null
|
sips -z "$size" "$size" "$icon" --out "$target/$name" >/dev/null
|
||||||
done <<'EOF'
|
done <<'EOF'
|
||||||
icon-1024pt@1x.png:1024
|
icon-1024pt@1x.png:1024
|
||||||
icon-29pt@1x.png:29
|
icon-29pt@1x.png:29
|
||||||
@@ -647,6 +657,7 @@ run_xcodebuild() {
|
|||||||
CURRENT_PROJECT_VERSION="$project_version"
|
CURRENT_PROJECT_VERSION="$project_version"
|
||||||
INFOPLIST_KEY_UIFileSharingEnabled=YES
|
INFOPLIST_KEY_UIFileSharingEnabled=YES
|
||||||
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace=YES
|
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace=YES
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET=15.0
|
||||||
ONLY_ACTIVE_ARCH=NO
|
ONLY_ACTIVE_ARCH=NO
|
||||||
DISABLE_MANUAL_TARGET_ORDER_BUILD_WARNING=YES
|
DISABLE_MANUAL_TARGET_ORDER_BUILD_WARNING=YES
|
||||||
)
|
)
|
||||||
@@ -666,6 +677,9 @@ run_xcodebuild() {
|
|||||||
if [ -n "${CODE_SIGN_IDENTITY:-}" ]; then
|
if [ -n "${CODE_SIGN_IDENTITY:-}" ]; then
|
||||||
args+=(CODE_SIGN_IDENTITY="$CODE_SIGN_IDENTITY")
|
args+=(CODE_SIGN_IDENTITY="$CODE_SIGN_IDENTITY")
|
||||||
fi
|
fi
|
||||||
|
if [ "${GEN1_DISABLE_HEALTHKIT:-0}" = "1" ]; then
|
||||||
|
args+=(CODE_SIGN_ENTITLEMENTS=)
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! xcodebuild -showsdks 2>/dev/null | grep -q "$sdk"; then
|
if ! xcodebuild -showsdks 2>/dev/null | grep -q "$sdk"; then
|
||||||
@@ -708,18 +722,22 @@ run_xcodebuild() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
local products="$BUILD_DIR/Build/Products/${config}-${sdk}"
|
local products="$BUILD_DIR/Build/Products/${config}-${sdk}"
|
||||||
local app="$products/$APP_NAME.app"
|
local app=""
|
||||||
if [ ! -d "$app" ]; then
|
local candidate
|
||||||
# PRODUCT_NAME override can still leave love.app on older projects
|
for candidate in "$products/$PRODUCT_NAME.app" "$products/$APP_NAME.app" "$products/love.app"; do
|
||||||
if [ -d "$products/love.app" ]; then
|
if [ -d "$candidate" ]; then
|
||||||
app="$products/$APP_NAME.app"
|
app="$candidate"
|
||||||
mv "$products/love.app" "$app"
|
break
|
||||||
warn "renamed love.app to $APP_NAME.app"
|
|
||||||
else
|
|
||||||
warn "xcodebuild finished but no .app under $products"
|
|
||||||
find "$BUILD_DIR/Build/Products" -name '*.app' 2>/dev/null | head -20 || true
|
|
||||||
return 0
|
|
||||||
fi
|
fi
|
||||||
|
done
|
||||||
|
if [ -z "$app" ]; then
|
||||||
|
warn "xcodebuild finished but no .app under $products"
|
||||||
|
find "$BUILD_DIR/Build/Products" -name '*.app' 2>/dev/null | head -20 || true
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [ "$app" != "$products/$APP_NAME.app" ]; then
|
||||||
|
mv "$app" "$products/$APP_NAME.app"
|
||||||
|
app="$products/$APP_NAME.app"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
verify_documents_configuration "$app"
|
verify_documents_configuration "$app"
|
||||||
@@ -760,7 +778,7 @@ run_xcodebuild() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Pack Payload/<app>.app into dist/ios/gen1recomp.ipa for release / sideload tools.
|
# Pack Payload/<app>.app into dist/ios/gen1recomp++.ipa for release / sideload tools.
|
||||||
package_ipa() {
|
package_ipa() {
|
||||||
local app="$1"
|
local app="$1"
|
||||||
local ipa="$DIST/$APP_NAME.ipa"
|
local ipa="$DIST/$APP_NAME.ipa"
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# Build a Windows win64 Gen1Recomp zip with native TLS (gen1tls.dll).
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# powershell -ExecutionPolicy Bypass -File scripts\build_windows.ps1 [-Version 0.1.77]
|
||||||
|
#
|
||||||
|
# Produces:
|
||||||
|
# dist\win\gen1recomp-<version>-windows.zip
|
||||||
|
# gen1recomp.exe fused LÖVE + game.love
|
||||||
|
# gen1tls.dll Native AOT TLS dialer (Schannel via SslStream)
|
||||||
|
#
|
||||||
|
# Requires: .NET 8 SDK (for Native AOT), Git Bash, and tools/winbuild on PATH
|
||||||
|
# for pack_love.sh under Git Bash on Windows.
|
||||||
|
|
||||||
|
param(
|
||||||
|
[string]$Version = "0.1.77"
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$Root = Split-Path -Parent $PSScriptRoot
|
||||||
|
if (-not (Test-Path (Join-Path $Root "main.lua"))) {
|
||||||
|
$Root = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($Version -notmatch '^\d+\.\d+\.\d+$') {
|
||||||
|
throw "Version must be X.Y.Z (got '$Version')"
|
||||||
|
}
|
||||||
|
|
||||||
|
$Cache = Join-Path $Root ".bazinga\cache"
|
||||||
|
$Work = Join-Path $Root ".bazinga\work"
|
||||||
|
$OutDir = Join-Path $Work "gen1recomp-win64"
|
||||||
|
$DistDir = Join-Path $Root "dist\win"
|
||||||
|
$NativeOut = Join-Path $Root "dist\native\win-x64"
|
||||||
|
$LoveZip = Join-Path $Cache "love-11.5-win64.zip"
|
||||||
|
$LoveUrl = "https://github.com/love2d/love/releases/download/11.5/love-11.5-win64.zip"
|
||||||
|
$Bash = "C:\Program Files\Git\bin\bash.exe"
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Force -Path $Cache, $Work, $DistDir, $NativeOut | Out-Null
|
||||||
|
|
||||||
|
# Put zip/python shims first so pack_love.sh works in Git Bash on Windows.
|
||||||
|
$env:PATH = "$(Join-Path $Root 'tools\winbuild');$env:PATH"
|
||||||
|
|
||||||
|
Write-Host "==> building gen1tls.dll (Native AOT)"
|
||||||
|
dotnet publish (Join-Path $Root "native\tls_dial\Gen1Tls.csproj") `
|
||||||
|
-c Release -r win-x64 -o $NativeOut
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "gen1tls publish failed" }
|
||||||
|
$TlsDll = Join-Path $NativeOut "gen1tls.dll"
|
||||||
|
if (-not (Test-Path $TlsDll)) { throw "gen1tls.dll missing after publish" }
|
||||||
|
|
||||||
|
if (-not (Test-Path $LoveZip) -or (Get-Item $LoveZip).Length -lt 1MB) {
|
||||||
|
Write-Host "==> downloading love-11.5-win64.zip"
|
||||||
|
Invoke-WebRequest -Uri $LoveUrl -OutFile $LoveZip -UseBasicParsing
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "==> packing game.love (engine $Version)"
|
||||||
|
if (-not (Test-Path $Bash)) { throw "Git Bash not found at $Bash" }
|
||||||
|
$RootPosix = ($Root -replace '\\', '/') -replace '^([A-Za-z]):', '/$1'
|
||||||
|
& $Bash -lc "cd '$RootPosix' && PATH=`"`$PWD/tools/winbuild:`$PATH`" scripts/pack_love.sh --output .bazinga/work/game.love --listing .bazinga/work/love-listing.txt --version $Version"
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "pack_love failed" }
|
||||||
|
|
||||||
|
Write-Host "==> fusing gen1recomp.exe"
|
||||||
|
$Extract = Join-Path $Work "love-win64"
|
||||||
|
Remove-Item -Recurse -Force $Extract, $OutDir -ErrorAction SilentlyContinue
|
||||||
|
New-Item -ItemType Directory -Force -Path $Extract, $OutDir | Out-Null
|
||||||
|
Expand-Archive -Path $LoveZip -DestinationPath $Extract -Force
|
||||||
|
$LoveDir = Get-ChildItem $Extract -Directory | Select-Object -First 1
|
||||||
|
Copy-Item (Join-Path $LoveDir.FullName "*.dll") $OutDir
|
||||||
|
Copy-Item (Join-Path $LoveDir.FullName "license.txt") $OutDir -ErrorAction SilentlyContinue
|
||||||
|
Copy-Item $TlsDll $OutDir
|
||||||
|
|
||||||
|
$out = [IO.File]::Create((Join-Path $OutDir "gen1recomp.exe"))
|
||||||
|
try {
|
||||||
|
$a = [IO.File]::OpenRead((Join-Path $LoveDir.FullName "love.exe"))
|
||||||
|
try { $a.CopyTo($out) } finally { $a.Dispose() }
|
||||||
|
$b = [IO.File]::OpenRead((Join-Path $Work "game.love"))
|
||||||
|
try { $b.CopyTo($out) } finally { $b.Dispose() }
|
||||||
|
} finally { $out.Dispose() }
|
||||||
|
|
||||||
|
@"
|
||||||
|
Gen1Recomp Windows (engine $Version) — native TLS
|
||||||
|
|
||||||
|
gen1tls.dll sits next to gen1recomp.exe and exposes a non-blocking TLS
|
||||||
|
client (Windows Schannel via .NET SslStream, Native AOT). Mods can load it
|
||||||
|
through LuaJIT FFI, or call love.system.tls* on Android.
|
||||||
|
|
||||||
|
Keep gen1tls.dll beside the executable when you redistribute the zip.
|
||||||
|
"@ | Set-Content (Join-Path $OutDir "README-TLS.txt") -Encoding UTF8
|
||||||
|
|
||||||
|
$ZipOut = Join-Path $DistDir "gen1recomp-$Version-windows.zip"
|
||||||
|
Remove-Item $ZipOut -ErrorAction SilentlyContinue
|
||||||
|
Compress-Archive -Path $OutDir -DestinationPath $ZipOut -Force
|
||||||
|
|
||||||
|
Write-Host "==> built $ZipOut"
|
||||||
|
Get-Item $ZipOut | ForEach-Object { " {0:N1} MB" -f ($_.Length / 1MB) }
|
||||||
|
Get-ChildItem $OutDir | ForEach-Object { " $($_.Name)" }
|
||||||
@@ -93,7 +93,8 @@ build_autotools mpg123 "$MPG123_VERSION" "$MPG123_TARBALL" libmpg123.so.0 \
|
|||||||
# The symbol that was missing when this was bullseye's copy. Assert it, so a
|
# The symbol that was missing when this was bullseye's copy. Assert it, so a
|
||||||
# version bump that quietly regresses below the host's expectations fails the
|
# version bump that quietly regresses below the host's expectations fails the
|
||||||
# build instead of silently killing audio again.
|
# build instead of silently killing audio again.
|
||||||
objdump -T "$PREFIX/lib/libmpg123.so.0" | grep -q 'mpg123_info2' \
|
mpg123_syms="$(objdump -T "$PREFIX/lib/libmpg123.so.0")"
|
||||||
|
grep -q 'mpg123_info2' <<<"$mpg123_syms" \
|
||||||
|| fail "bundled libmpg123 lacks mpg123_info2; the host's libsndfile will fail to relocate"
|
|| fail "bundled libmpg123 lacks mpg123_info2; the host's libsndfile will fail to relocate"
|
||||||
|
|
||||||
# ------------------------------------------------------------ compile SDL2
|
# ------------------------------------------------------------ compile SDL2
|
||||||
@@ -128,8 +129,9 @@ fi
|
|||||||
# the host having that exact stack -- which is the bug this replaced.
|
# the host having that exact stack -- which is the bug this replaced.
|
||||||
sdl_lib="$PREFIX/lib/libSDL2-2.0.so.0"
|
sdl_lib="$PREFIX/lib/libSDL2-2.0.so.0"
|
||||||
[ -f "$sdl_lib" ] || fail "SDL2 build produced no libSDL2-2.0.so.0"
|
[ -f "$sdl_lib" ] || fail "SDL2 build produced no libSDL2-2.0.so.0"
|
||||||
|
sdl_needed="$(objdump -p "$sdl_lib")"
|
||||||
for forbidden in libpulse libasound libX11 libwayland libdrm libgbm libsndio; do
|
for forbidden in libpulse libasound libX11 libwayland libdrm libgbm libsndio; do
|
||||||
if objdump -p "$sdl_lib" | grep -q "NEEDED.*$forbidden"; then
|
if grep -q "NEEDED.*$forbidden" <<<"$sdl_needed"; then
|
||||||
fail "SDL2 hard-links $forbidden; it must dlopen its backends (--enable-*-shared)"
|
fail "SDL2 hard-links $forbidden; it must dlopen its backends (--enable-*-shared)"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
@@ -164,8 +166,9 @@ fi
|
|||||||
|
|
||||||
openal_lib="$PREFIX/lib/libopenal.so.1"
|
openal_lib="$PREFIX/lib/libopenal.so.1"
|
||||||
[ -f "$openal_lib" ] || fail "openal-soft build produced no libopenal.so.1"
|
[ -f "$openal_lib" ] || fail "openal-soft build produced no libopenal.so.1"
|
||||||
|
openal_needed="$(objdump -p "$openal_lib")"
|
||||||
for forbidden in libsndio libasound libpulse libjack; do
|
for forbidden in libsndio libasound libpulse libjack; do
|
||||||
if objdump -p "$openal_lib" | grep -q "NEEDED.*$forbidden"; then
|
if grep -q "NEEDED.*$forbidden" <<<"$openal_needed"; then
|
||||||
fail "openal hard-links $forbidden; backends must stay behind dlopen"
|
fail "openal hard-links $forbidden; backends must stay behind dlopen"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
@@ -196,7 +199,8 @@ fi
|
|||||||
|
|
||||||
theora_lib="$PREFIX/lib/libtheoradec.so.1"
|
theora_lib="$PREFIX/lib/libtheoradec.so.1"
|
||||||
[ -f "$theora_lib" ] || fail "libtheora build produced no libtheoradec.so.1"
|
[ -f "$theora_lib" ] || fail "libtheora build produced no libtheoradec.so.1"
|
||||||
if objdump -p "$theora_lib" | grep -q "NEEDED.*libcairo"; then
|
theora_needed="$(objdump -p "$theora_lib")"
|
||||||
|
if grep -q "NEEDED.*libcairo" <<<"$theora_needed"; then
|
||||||
fail "libtheoradec still links libcairo (--disable-examples stopped working)"
|
fail "libtheoradec still links libcairo (--disable-examples stopped working)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -231,16 +235,18 @@ love_bin="$PREFIX/bin/love"
|
|||||||
love_lib="$PREFIX/lib/liblove-$LOVE_VERSION.so"
|
love_lib="$PREFIX/lib/liblove-$LOVE_VERSION.so"
|
||||||
[ -x "$love_bin" ] || fail "LÖVE build produced no bin/love"
|
[ -x "$love_bin" ] || fail "LÖVE build produced no bin/love"
|
||||||
[ -f "$love_lib" ] || fail "LÖVE build produced no lib/liblove-$LOVE_VERSION.so"
|
[ -f "$love_lib" ] || fail "LÖVE build produced no lib/liblove-$LOVE_VERSION.so"
|
||||||
file "$love_bin" | grep -q 'ARM aarch64' \
|
love_file="$(file "$love_bin")"
|
||||||
|
grep -q 'ARM aarch64' <<<"$love_file" \
|
||||||
|| fail "built love is not an aarch64 ELF (got: $(file -b "$love_bin"))"
|
|| fail "built love is not an aarch64 ELF (got: $(file -b "$love_bin"))"
|
||||||
|
|
||||||
# A configure run that lost an optional dependency still exits 0 and still
|
# A configure run that lost an optional dependency still exits 0 and still
|
||||||
# builds -- the loss only shows up as a missing love module at runtime, i.e.
|
# builds -- the loss only shows up as a missing love module at runtime, i.e.
|
||||||
# in a shipped artifact. Assert the decoder/font/video libs really linked.
|
# in a shipped artifact. Assert the decoder/font/video libs really linked.
|
||||||
|
love_needed="$(objdump -p "$love_lib")"
|
||||||
for soname in libSDL2-2.0.so.0 libopenal.so.1 libfreetype.so.6 \
|
for soname in libSDL2-2.0.so.0 libopenal.so.1 libfreetype.so.6 \
|
||||||
libmodplug.so.1 libmpg123.so.0 libvorbisfile.so.3 \
|
libmodplug.so.1 libmpg123.so.0 libvorbisfile.so.3 \
|
||||||
libtheoradec.so.1 libluajit-5.1.so.2; do
|
libtheoradec.so.1 libluajit-5.1.so.2; do
|
||||||
objdump -p "$love_lib" | grep -q "NEEDED.*$soname" \
|
grep -q "NEEDED.*$soname" <<<"$love_needed" \
|
||||||
|| fail "liblove is not linked against $soname (a -dev package went missing)"
|
|| fail "liblove is not linked against $soname (a -dev package went missing)"
|
||||||
done
|
done
|
||||||
|
|
||||||
@@ -357,6 +363,9 @@ cp -R "$jit_share/jit" "$APPDIR/share/$LUAJIT_SHARE_DIR/"
|
|||||||
|
|
||||||
# --------------------------------------------------------------- branding
|
# --------------------------------------------------------------- branding
|
||||||
cp "$IN/game.love" "$APPDIR/game.love"
|
cp "$IN/game.love" "$APPDIR/game.love"
|
||||||
|
unzip -Z1 "$APPDIR/game.love" > "$WORK/love-listing.txt"
|
||||||
|
grep -qxF "tools/rom_manifest_gold.json" "$WORK/love-listing.txt" \
|
||||||
|
|| fail "game.love is missing tools/rom_manifest_gold.json"
|
||||||
# The .desktop's Icon= resolves against the AppDir root by basename, and
|
# The .desktop's Icon= resolves against the AppDir root by basename, and
|
||||||
# .DirIcon is what appimaged and file-manager thumbnailers read.
|
# .DirIcon is what appimaged and file-manager thumbnailers read.
|
||||||
cp "$IN/icon.png" "$APPDIR/$APP_NAME.png"
|
cp "$IN/icon.png" "$APPDIR/$APP_NAME.png"
|
||||||
@@ -402,6 +411,12 @@ if [ -z "\$LUA_CPATH" ]; then
|
|||||||
fi
|
fi
|
||||||
export LUA_CPATH="\$APPDIR/lib/lua/5.1/?.so;\$LUA_CPATH"
|
export LUA_CPATH="\$APPDIR/lib/lua/5.1/?.so;\$LUA_CPATH"
|
||||||
|
|
||||||
|
# SDL2 on Wayland crashes during desktop drag-and-drop in certain compositors;
|
||||||
|
# default to X11/XWayland when available to ensure rock-solid drag-drop stability.
|
||||||
|
if [ -n "\$WAYLAND_DISPLAY" ] && [ -z "\$SDL_VIDEODRIVER" ]; then
|
||||||
|
export SDL_VIDEODRIVER=x11
|
||||||
|
fi
|
||||||
|
|
||||||
exec "\$APPDIR/bin/love" --fused "\$APPDIR/game.love" "\$@"
|
exec "\$APPDIR/bin/love" --fused "\$APPDIR/game.love" "\$@"
|
||||||
EOF
|
EOF
|
||||||
chmod +x "$APPDIR/AppRun"
|
chmod +x "$APPDIR/AppRun"
|
||||||
|
|||||||
@@ -167,5 +167,7 @@ trap 'rm -rf "$temp_dir"' EXIT
|
|||||||
unzip -p "$temp_dir/game.love" src/core/Version.lua \
|
unzip -p "$temp_dir/game.love" src/core/Version.lua \
|
||||||
| grep -Eq 'engine[[:space:]]*=[[:space:]]*"1\.2\.3"' \
|
| grep -Eq 'engine[[:space:]]*=[[:space:]]*"1\.2\.3"' \
|
||||||
|| fail "shared payload version was not stamped"
|
|| fail "shared payload version was not stamped"
|
||||||
|
grep -qxF "tools/rom_manifest_gold.json" "$temp_dir/love-listing.txt" \
|
||||||
|
|| fail "shared payload is missing tools/rom_manifest_gold.json"
|
||||||
|
|
||||||
say "Linux arm64 self-test passed"
|
say "Linux arm64 self-test passed"
|
||||||
|
|||||||
@@ -51,6 +51,12 @@ rm -f "$OUTPUT"
|
|||||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||||
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
||||||
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
||||||
|
if [ -f "$ROOT/PATCH_NOTES.md" ]; then
|
||||||
|
(cd "$ROOT" && zip -q "$OUTPUT" PATCH_NOTES.md)
|
||||||
|
fi
|
||||||
|
if [ -f "$ROOT/mobile/ios/app-repo.json" ]; then
|
||||||
|
(cd "$ROOT" && zip -q "$OUTPUT" mobile/ios/app-repo.json)
|
||||||
|
fi
|
||||||
|
|
||||||
if [ -n "$BUILD_INFO" ]; then
|
if [ -n "$BUILD_INFO" ]; then
|
||||||
[ -f "$BUILD_INFO" ] || fail "missing build-info: $BUILD_INFO"
|
[ -f "$BUILD_INFO" ] || fail "missing build-info: $BUILD_INFO"
|
||||||
|
|||||||
@@ -35,4 +35,10 @@ find_love() {
|
|||||||
LOVE_BIN="$(find_love)" \
|
LOVE_BIN="$(find_love)" \
|
||||||
|| fail "LÖVE not found, run scripts/setup.sh (or install from https://love2d.org)"
|
|| fail "LÖVE not found, run scripts/setup.sh (or install from https://love2d.org)"
|
||||||
|
|
||||||
|
# SDL2 on Wayland crashes during desktop drag-and-drop in certain compositors;
|
||||||
|
# default to X11/XWayland when available to ensure rock-solid drag-drop stability.
|
||||||
|
if [ -n "${WAYLAND_DISPLAY:-}" ] && [ -z "${SDL_VIDEODRIVER:-}" ]; then
|
||||||
|
export SDL_VIDEODRIVER=x11
|
||||||
|
fi
|
||||||
|
|
||||||
exec "$LOVE_BIN" "$ROOT" "$@"
|
exec "$LOVE_BIN" "$ROOT" "$@"
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ run_tier "T0 NX Yellow/Blue boot (dynamic paths)" "$LUA" tests/engine/nx_yellow_
|
|||||||
run_tier "T0 touch-controls pad cursor" "$LUA" tests/engine/touch_controls_pad_cursor_test.lua
|
run_tier "T0 touch-controls pad cursor" "$LUA" tests/engine/touch_controls_pad_cursor_test.lua
|
||||||
run_tier "T1/T2 engine invariants + parity gates" "$LUA" tests/run_engine.lua
|
run_tier "T1/T2 engine invariants + parity gates" "$LUA" tests/run_engine.lua
|
||||||
run_tier "T4 mod-SDK" "$LUA" tests/run_modkit.lua
|
run_tier "T4 mod-SDK" "$LUA" tests/run_modkit.lua
|
||||||
|
run_tier "T4 title checkpoint cold restart" \
|
||||||
|
bash tests/integration/title_checkpoint_cold_start.sh
|
||||||
|
|
||||||
# The modded-link desync suite (symmetric mod, handshake fail-closed,
|
# The modded-link desync suite (symmetric mod, handshake fail-closed,
|
||||||
# extra-bag round trip) is ROM-free and runs inside the T4 tier above, as
|
# extra-bag round trip) is ROM-free and runs inside the T4 tier above, as
|
||||||
@@ -133,6 +135,7 @@ if [ -f data/generated/maps.lua ]; then
|
|||||||
run_tier "T3 save editor: events + dex" "$LUA" tests/save_editor_task7_tests.lua
|
run_tier "T3 save editor: events + dex" "$LUA" tests/save_editor_task7_tests.lua
|
||||||
run_tier "T3 save editor: map browser" "$LUA" tests/save_editor_task8_tests.lua
|
run_tier "T3 save editor: map browser" "$LUA" tests/save_editor_task8_tests.lua
|
||||||
run_tier "T3 save editor: mod awareness" "$LUA" tests/save_editor_mod_tests.lua
|
run_tier "T3 save editor: mod awareness" "$LUA" tests/save_editor_mod_tests.lua
|
||||||
|
run_tier "T3 save editor: gold / gen2" "$LUA" tests/save_editor_gen2_tests.lua
|
||||||
run_tier "T3 save editor: wheel scrolling" "$LUA" tests/save_editor_wheel_bug595_test.lua
|
run_tier "T3 save editor: wheel scrolling" "$LUA" tests/save_editor_wheel_bug595_test.lua
|
||||||
run_tier "T3 save editor: pad / NX input" "$LUA" tests/save_editor_pad_input_test.lua
|
run_tier "T3 save editor: pad / NX input" "$LUA" tests/save_editor_pad_input_test.lua
|
||||||
run_tier "T5 link (loopback lockstep)" "$LUA" tests/run_link_tests.lua
|
run_tier "T5 link (loopback lockstep)" "$LUA" tests/run_link_tests.lua
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
-- Shared settled supported player-decision predicate. Checkpoint capture and
|
||||||
|
-- the public auxiliary action deliberately use this one engine-owned rule so
|
||||||
|
-- a tool cannot open at a phase that it could not subsequently checkpoint.
|
||||||
|
-- It exposes no controller; callers receive only the result/reason.
|
||||||
|
|
||||||
|
local BattleSafety = {}
|
||||||
|
|
||||||
|
local BATTLE_BUSY_FIELDS = {
|
||||||
|
"current", "afterQueue", "nextInsert", "pendingHit", "waitingUI",
|
||||||
|
"waitingSound", "waitFrames", "draining", "animPlaying", "growIn",
|
||||||
|
"introSlide", "ghostReveal", "mimicCtx", "mimicMoves", "result",
|
||||||
|
}
|
||||||
|
|
||||||
|
local function nonempty(value)
|
||||||
|
return type(value) == "table" and next(value) ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function running(runner)
|
||||||
|
return runner and runner.isRunning and runner:isRunning()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function scriptsBusy(overworld)
|
||||||
|
return running(overworld and overworld.runner)
|
||||||
|
or nonempty(overworld and overworld.parallelRunners)
|
||||||
|
or nonempty(overworld and overworld.pendingScripts)
|
||||||
|
or nonempty(overworld and overworld.parallelQueue)
|
||||||
|
or nonempty(overworld and overworld.scriptMoves)
|
||||||
|
end
|
||||||
|
|
||||||
|
function BattleSafety.inspect(game, battle)
|
||||||
|
if type(game) ~= "table" or type(game.save) ~= "table"
|
||||||
|
or type(game.save.version) ~= "string" then
|
||||||
|
return nil, "not_in_playthrough", "A checkpoint requires an identified active playthrough."
|
||||||
|
end
|
||||||
|
if type(battle) ~= "table" then
|
||||||
|
return nil, "not_battle", "No battle is active."
|
||||||
|
end
|
||||||
|
if battle.kind == "link" then
|
||||||
|
return nil, "link_battle_unsupported", "Network battles cannot be checkpointed."
|
||||||
|
end
|
||||||
|
if battle.safari or battle.ghost or battle.scopeReveal or battle.demo or battle.noCatch then
|
||||||
|
return nil, "battle_variant_unsupported",
|
||||||
|
"This battle variant does not have a checkpoint contract."
|
||||||
|
end
|
||||||
|
if battle.kind ~= "wild" and battle.kind ~= "trainer" then
|
||||||
|
return nil, "battle_variant_unsupported",
|
||||||
|
"This battle kind does not have a checkpoint contract."
|
||||||
|
end
|
||||||
|
local origin = battle.checkpointOrigin
|
||||||
|
local ordinaryOrigin = battle.kind == "wild" and "wild_encounter"
|
||||||
|
or "trainer_encounter"
|
||||||
|
local scriptedOrigin = type(origin) == "table"
|
||||||
|
and origin.kind == "script_battle"
|
||||||
|
if type(origin) ~= "table"
|
||||||
|
or (origin.kind ~= ordinaryOrigin and not scriptedOrigin) then
|
||||||
|
return nil, "battle_origin_unsupported",
|
||||||
|
"The battle completion path cannot be reconstructed safely."
|
||||||
|
end
|
||||||
|
local overworld = game.overworld or {}
|
||||||
|
local scriptedRunner = scriptedOrigin and (battle.checkpointScriptContinuation
|
||||||
|
or (overworld.runner
|
||||||
|
and overworld.runner.isCheckpointBattle
|
||||||
|
and overworld.runner:isCheckpointBattle(battle)))
|
||||||
|
local otherScriptWork = nonempty(overworld.parallelRunners)
|
||||||
|
or nonempty(overworld.pendingScripts) or nonempty(overworld.parallelQueue)
|
||||||
|
or nonempty(overworld.scriptMoves)
|
||||||
|
if (scriptedOrigin and (not scriptedRunner or otherScriptWork))
|
||||||
|
or (not scriptedOrigin and scriptsBusy(overworld)) then
|
||||||
|
return nil, "script_busy", "A suspended or queued script cannot be checkpointed."
|
||||||
|
end
|
||||||
|
if battle.phase ~= "menu" or nonempty(battle.queue) then
|
||||||
|
return nil, "battle_phase_busy",
|
||||||
|
"Wait for the player command menu before creating a checkpoint."
|
||||||
|
end
|
||||||
|
for _, field in ipairs(BATTLE_BUSY_FIELDS) do
|
||||||
|
if battle[field] ~= nil and battle[field] ~= false then
|
||||||
|
return nil, "battle_phase_busy", "Wait for the current battle action to finish."
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if not battle.player or not battle.enemy or not battle.player.mon
|
||||||
|
or battle.player.mon.hp <= 0
|
||||||
|
or (battle.menuLockedAction and battle:menuLockedAction(battle.player)) then
|
||||||
|
return nil, "battle_phase_busy",
|
||||||
|
"Wait for a supported player decision before creating a checkpoint."
|
||||||
|
end
|
||||||
|
for _, battler in ipairs({ battle.player, battle.enemy }) do
|
||||||
|
if not battler.mon or battler.shownHP ~= battler.mon.hp
|
||||||
|
or battler.shownStatus ~= battler.mon.status
|
||||||
|
or battler.drainFloor ~= nil or battler.drainHold ~= nil
|
||||||
|
or battler.faintQueued then
|
||||||
|
return nil, "battle_phase_busy",
|
||||||
|
"Wait for battle status and HP presentation to settle."
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
return BattleSafety
|
||||||
+294
-106
@@ -21,12 +21,14 @@ local MoveEffects = require("src.battle.MoveEffects")
|
|||||||
local Party = require("src.pokemon.Party")
|
local Party = require("src.pokemon.Party")
|
||||||
local Pokemon = require("src.pokemon.Pokemon")
|
local Pokemon = require("src.pokemon.Pokemon")
|
||||||
local Runtime = require("src.mods.Runtime")
|
local Runtime = require("src.mods.Runtime")
|
||||||
|
local BattleSafety = require("src.battle.BattleSafety")
|
||||||
local Screens = require("src.ui.Screens")
|
local Screens = require("src.ui.Screens")
|
||||||
local Status = require("src.battle.Status")
|
local Status = require("src.battle.Status")
|
||||||
local Timing = require("src.core.Timing")
|
local Timing = require("src.core.Timing")
|
||||||
local TrainerAI = require("src.battle.TrainerAI")
|
local TrainerAI = require("src.battle.TrainerAI")
|
||||||
local TurnOrder = require("src.battle.TurnOrder")
|
local TurnOrder = require("src.battle.TurnOrder")
|
||||||
local TypeChart = require("src.battle.TypeChart")
|
local TypeChart = require("src.battle.TypeChart")
|
||||||
|
local UIVisibility = require("src.battle.UIVisibility")
|
||||||
local RomText = require("src.core.RomText")
|
local RomText = require("src.core.RomText")
|
||||||
local Strings = require("src.core.Strings")
|
local Strings = require("src.core.Strings")
|
||||||
local WideBattle = require("src.battle.WideBattle")
|
local WideBattle = require("src.battle.WideBattle")
|
||||||
@@ -134,9 +136,7 @@ function BattleState:sgbPalettes()
|
|||||||
end
|
end
|
||||||
|
|
||||||
function BattleState:bottomUIVisible()
|
function BattleState:bottomUIVisible()
|
||||||
if not Runtime.wantsHook("battle.bottom_ui_visible") then return true end
|
return UIVisibility.bottomVisible(self, true)
|
||||||
return Runtime.call("battle.bottom_ui_visible", function() return true end,
|
|
||||||
self) ~= false
|
|
||||||
end
|
end
|
||||||
|
|
||||||
function BattleState:statusHUDVisible()
|
function BattleState:statusHUDVisible()
|
||||||
@@ -145,6 +145,17 @@ function BattleState:statusHUDVisible()
|
|||||||
self) ~= false
|
self) ~= false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function BattleState:caughtMarkerVisible()
|
||||||
|
local dex = self.game and self.game.save and self.game.save.pokedex
|
||||||
|
if not self.enemy or (self.kind ~= "wild" and self.kind ~= "safari")
|
||||||
|
or not (dex and dex.owned and dex.owned[self.enemy.mon.species]) then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
if not Runtime.wantsHook("battle.caught_marker_visible") then return false end
|
||||||
|
return Runtime.call("battle.caught_marker_visible",
|
||||||
|
function() return false end, self) == true
|
||||||
|
end
|
||||||
|
|
||||||
function BattleState:moveGridNavigation()
|
function BattleState:moveGridNavigation()
|
||||||
if self:wideLayout() then return true end
|
if self:wideLayout() then return true end
|
||||||
if not Runtime.wantsHook("battle.move_grid_navigation") then return false end
|
if not Runtime.wantsHook("battle.move_grid_navigation") then return false end
|
||||||
@@ -305,6 +316,27 @@ function BattleState.trainerPicPath(data, trainer, oppClass, partyIndex)
|
|||||||
return base and base.pic or nil
|
return base and base.pic or nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- trueColor on the trainer record, or on the basePic it reuses when the
|
||||||
|
-- subclass does not set the flag itself. Explicit false stays false.
|
||||||
|
function BattleState.trainerTrueColor(data, trainer)
|
||||||
|
if not trainer then return false end
|
||||||
|
if trainer.trueColor ~= nil then
|
||||||
|
return trainer.trueColor and true or false
|
||||||
|
end
|
||||||
|
local base = trainer.basePic and data and data.trainers
|
||||||
|
and data.trainers[trainer.basePic]
|
||||||
|
return (base and base.trueColor) and true or false
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Load a trainer frontpic through getImage so a trueColor portrait skips
|
||||||
|
-- the 4-shade quantize the same way a species pic does.
|
||||||
|
function BattleState.trainerSprite(data, trainer, oppClass, partyIndex)
|
||||||
|
return getImage(
|
||||||
|
BattleState.trainerPicPath(data, trainer, oppClass, partyIndex),
|
||||||
|
BattleState.trainerPalette(data, trainer),
|
||||||
|
BattleState.trainerTrueColor(data, trainer))
|
||||||
|
end
|
||||||
|
|
||||||
-- The battle-BGP fade variant of a pic (AnimationFlashScreen and the
|
-- The battle-BGP fade variant of a pic (AnimationFlashScreen and the
|
||||||
-- SetAnimationBGPalette effects remap the four BG shades; on the SGB
|
-- SetAnimationBGPalette effects remap the four BG shades; on the SGB
|
||||||
-- the colorizer then colors the REMAPPED shade, so a faded pic shows
|
-- the colorizer then colors the REMAPPED shade, so a faded pic shows
|
||||||
@@ -490,6 +522,9 @@ local function makeBattler(data, mon, isPlayer, save)
|
|||||||
badgeBoosts = badgeBoosts,
|
badgeBoosts = badgeBoosts,
|
||||||
statuses = data.statuses,
|
statuses = data.statuses,
|
||||||
shownHP = mon.hp, -- the HP the bar displays (UpdateHPBar drain)
|
shownHP = mon.hp, -- the HP the bar displays (UpdateHPBar drain)
|
||||||
|
-- the bar's own length in GetHPBarLength pixels; it trails shownHP
|
||||||
|
-- because UpdateHPBar_AnimateHPBar slides it one pixel at a time
|
||||||
|
shownPx = Timing.hpBarPixels(mon.hp, math.max(1, mon.stats.hp)),
|
||||||
-- HUD status label (DrawHUDsAndHPBars); mon.status can land mid-move
|
-- HUD status label (DrawHUDsAndHPBars); mon.status can land mid-move
|
||||||
-- while the tilemap still shows the prior condition until the next
|
-- while the tilemap still shows the prior condition until the next
|
||||||
-- post-action HUD refresh (core.asm after Execute*Move)
|
-- post-action HUD refresh (core.asm after Execute*Move)
|
||||||
@@ -552,10 +587,6 @@ local function markOwned(game, species)
|
|||||||
local dex = game.save.pokedex
|
local dex = game.save.pokedex
|
||||||
if dex then
|
if dex then
|
||||||
dex.seen[species] = true
|
dex.seen[species] = true
|
||||||
if not dex.owned[species] then
|
|
||||||
-- new dex page registered (SFX_DEX_PAGE_ADDED)
|
|
||||||
require("src.core.Sound").play(game.data, "Dex_Page_Added")
|
|
||||||
end
|
|
||||||
dex.owned[species] = true
|
dex.owned[species] = true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -602,10 +633,49 @@ local function newBattle(game)
|
|||||||
self.phase = "intro"
|
self.phase = "intro"
|
||||||
self.menuIndex = 1
|
self.menuIndex = 1
|
||||||
self.moveIndex = 1
|
self.moveIndex = 1
|
||||||
|
self.playerMoveListIndex = 1
|
||||||
self.frame = 0
|
self.frame = 0
|
||||||
return self
|
return self
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function scopedPlayerParty(game, indices)
|
||||||
|
if indices == nil then return nil, nil end
|
||||||
|
if type(indices) ~= "table" then
|
||||||
|
Logger.warn("trainer battle party scope is not a table; using full party")
|
||||||
|
return nil, nil
|
||||||
|
end
|
||||||
|
local count = #indices
|
||||||
|
local keyCount = 0
|
||||||
|
for key in pairs(indices) do
|
||||||
|
keyCount = keyCount + 1
|
||||||
|
if type(key) ~= "number" or key % 1 ~= 0 or key < 1 or key > count then
|
||||||
|
Logger.warn("trainer battle party scope is malformed; using full party")
|
||||||
|
return nil, nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if count == 0 or keyCount ~= count then
|
||||||
|
Logger.warn("trainer battle party scope is empty or sparse; using full party")
|
||||||
|
return nil, nil
|
||||||
|
end
|
||||||
|
local party, normalized, seen = {}, {}, {}
|
||||||
|
for i = 1, count do
|
||||||
|
local index = indices[i]
|
||||||
|
if type(index) ~= "number" or index % 1 ~= 0
|
||||||
|
or not game.save.party[index] or seen[index] then
|
||||||
|
Logger.warn("trainer battle party scope contains an invalid index; using full party")
|
||||||
|
return nil, nil
|
||||||
|
end
|
||||||
|
seen[index] = true
|
||||||
|
normalized[i] = index
|
||||||
|
party[i] = game.save.party[index]
|
||||||
|
end
|
||||||
|
return party, normalized
|
||||||
|
end
|
||||||
|
|
||||||
|
function BattleState:playerPartyView()
|
||||||
|
return self.playerParty or self.game.save.party
|
||||||
|
end
|
||||||
|
|
||||||
-- opts.hooked: rod encounter, announced with _HookedMonAttackedText
|
-- opts.hooked: rod encounter, announced with _HookedMonAttackedText
|
||||||
function BattleState.newWild(game, species, level, opts)
|
function BattleState.newWild(game, species, level, opts)
|
||||||
local self = newBattle(game)
|
local self = newBattle(game)
|
||||||
@@ -681,13 +751,15 @@ local function applySpecialMoves(data, oppClass, partyIndex, party)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function BattleState.newTrainer(game, oppClass, partyIndex)
|
function BattleState.newTrainer(game, oppClass, partyIndex, opts)
|
||||||
local self = newBattle(game)
|
local self = newBattle(game)
|
||||||
self.kind = "trainer"
|
self.kind = "trainer"
|
||||||
self.oppClass = oppClass
|
self.oppClass = oppClass
|
||||||
-- the object_event trainer arg (roster index). computeMusicKind keys
|
-- the object_event trainer arg (roster index). computeMusicKind keys
|
||||||
-- data/scripts/victories.lua on class#party, so keep it on the battle (#782).
|
-- data/scripts/victories.lua on class#party, so keep it on the battle (#782).
|
||||||
self.partyIndex = partyIndex or 1
|
self.partyIndex = partyIndex or 1
|
||||||
|
self.playerParty, self.playerPartyIndices = scopedPlayerParty(game,
|
||||||
|
type(opts) == "table" and opts.playerPartyIndices or nil)
|
||||||
self.trainer = game.data.trainers[oppClass]
|
self.trainer = game.data.trainers[oppClass]
|
||||||
assert(self.trainer, "unknown trainer class " .. tostring(oppClass))
|
assert(self.trainer, "unknown trainer class " .. tostring(oppClass))
|
||||||
-- pret GetTrainerName_: RIVAL1/2/3 copy wRivalName into wTrainerName
|
-- pret GetTrainerName_: RIVAL1/2/3 copy wRivalName into wTrainerName
|
||||||
@@ -731,7 +803,7 @@ function BattleState.newTrainer(game, oppClass, partyIndex)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
self.enemyIndex = 1
|
self.enemyIndex = 1
|
||||||
local playerMon = Party.firstHealthy(game.save.party)
|
local playerMon = Party.firstHealthy(self:playerPartyView())
|
||||||
if not playerMon then
|
if not playerMon then
|
||||||
Logger.warn("trainer battle with no healthy party; skipping")
|
Logger.warn("trainer battle with no healthy party; skipping")
|
||||||
self.dead = true
|
self.dead = true
|
||||||
@@ -745,9 +817,8 @@ function BattleState.newTrainer(game, oppClass, partyIndex)
|
|||||||
-- MonsterPalettes[0] = PAL_MEWMON -- InitBattleCommon zeroes
|
-- MonsterPalettes[0] = PAL_MEWMON -- InitBattleCommon zeroes
|
||||||
-- wEnemyMonSpecies2 before the intro's SET_PAL_BATTLE
|
-- wEnemyMonSpecies2 before the intro's SET_PAL_BATTLE
|
||||||
-- (engine/battle/core.asm:6682, engine/gfx/palettes.asm SetPal_Battle)
|
-- (engine/battle/core.asm:6682, engine/gfx/palettes.asm SetPal_Battle)
|
||||||
self.trainerPic = getImage(
|
self.trainerPic = BattleState.trainerSprite(
|
||||||
BattleState.trainerPicPath(game.data, self.trainer, oppClass, partyIndex),
|
game.data, self.trainer, oppClass, partyIndex)
|
||||||
BattleState.trainerPalette(game.data, self.trainer))
|
|
||||||
self.introText = Strings("%s wants\nto fight!", self.trainer.name)
|
self.introText = Strings("%s wants\nto fight!", self.trainer.name)
|
||||||
return self
|
return self
|
||||||
end
|
end
|
||||||
@@ -918,6 +989,22 @@ function BattleState:sayNext(text)
|
|||||||
table.insert(self.queue, self.nextInsert, { text = text })
|
table.insert(self.queue, self.nextInsert, { text = text })
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function BattleState:sayNextWaitSfx(text, sfx)
|
||||||
|
self.nextInsert = (self.nextInsert or 0) + 1
|
||||||
|
table.insert(self.queue, self.nextInsert, { text = text, waitForLearningSfx = sfx })
|
||||||
|
end
|
||||||
|
|
||||||
|
-- WaitForSoundToFinish for a sound an act() has already started: PlayCry
|
||||||
|
-- ends in `jp WaitForSoundToFinish` (home/pokemon.asm), so every cry in
|
||||||
|
-- Gen 1 holds whatever the ROM does next. `src` is the audio source, or a
|
||||||
|
-- getter the row calls at execution time when the source is only known then.
|
||||||
|
function BattleState:waitSfxNext(src)
|
||||||
|
self.nextInsert = (self.nextInsert or 0) + 1
|
||||||
|
table.insert(self.queue, self.nextInsert,
|
||||||
|
{ waitSound = type(src) == "function" and src
|
||||||
|
or function() return src end })
|
||||||
|
end
|
||||||
|
|
||||||
-- sayNext for a page that ends in `text_end` (see sayAuto) (#765)
|
-- sayNext for a page that ends in `text_end` (see sayAuto) (#765)
|
||||||
function BattleState:sayNextAuto(text, delay)
|
function BattleState:sayNextAuto(text, delay)
|
||||||
self.nextInsert = (self.nextInsert or 0) + 1
|
self.nextInsert = (self.nextInsert or 0) + 1
|
||||||
@@ -1005,22 +1092,39 @@ function BattleState:stepHPDrain()
|
|||||||
and b.shownHP >= b.drainFloor then
|
and b.shownHP >= b.drainFloor then
|
||||||
goal = b.drainFloor
|
goal = b.drainFloor
|
||||||
end
|
end
|
||||||
|
local maxHP = math.max(1, b.mon.stats.hp)
|
||||||
|
local playerSide = (b == self.player)
|
||||||
|
local targetPx = Timing.hpBarPixels(b.shownHP, maxHP)
|
||||||
|
if not b.shownPx then b.shownPx = targetPx end
|
||||||
if (b.drainHold or 0) > 0 then
|
if (b.drainHold or 0) > 0 then
|
||||||
b.drainHold = b.drainHold - 1
|
b.drainHold = b.drainHold - 1
|
||||||
busy = true
|
busy = true
|
||||||
|
elseif b.shownPx ~= targetPx then
|
||||||
|
-- .barAnimationLoop redraws the bar one pixel at a time, `ld c, 2 /
|
||||||
|
-- call DelayFrames` apiece (:141-148), so a single HP point that
|
||||||
|
-- spans several pixels still slides instead of jumping
|
||||||
|
b.shownPx = b.shownPx + ((b.shownPx > targetPx) and -1 or 1)
|
||||||
|
b.drainHold = Timing.HP_BAR_PIXEL_STEP - 1
|
||||||
|
busy = true
|
||||||
elseif b.shownHP ~= goal then
|
elseif b.shownHP ~= goal then
|
||||||
local maxHP = math.max(1, b.mon.stats.hp)
|
local spent = 0
|
||||||
local playerSide = (b == self.player)
|
|
||||||
local cost = 0
|
|
||||||
-- consume whole HP steps until this frame's budget is spent; on the
|
-- consume whole HP steps until this frame's budget is spent; on the
|
||||||
-- enemy HUD several free steps can land in the same frame
|
-- enemy HUD several free steps can land in the same frame
|
||||||
while b.shownHP ~= goal and cost < 1 do
|
repeat
|
||||||
local nextHP = b.shownHP + ((b.shownHP > goal) and -1 or 1)
|
b.shownHP = b.shownHP + ((b.shownHP > goal) and -1 or 1)
|
||||||
cost = cost + Timing.hpDrainStepFrames(b.shownHP, nextHP,
|
spent = spent + (playerSide and Timing.HP_BAR_HP_STEP or 0)
|
||||||
maxHP, playerSide)
|
targetPx = Timing.hpBarPixels(b.shownHP, maxHP)
|
||||||
b.shownHP = nextHP
|
until b.shownHP == goal or targetPx ~= b.shownPx or spent >= 1
|
||||||
|
if spent > 0 then
|
||||||
|
b.drainHold = spent - 1
|
||||||
|
elseif targetPx ~= b.shownPx then
|
||||||
|
-- the enemy HUD printed no number, so this frame is already the
|
||||||
|
-- first of the pixel step the crossing just asked for
|
||||||
|
b.shownPx = b.shownPx + ((b.shownPx > targetPx) and -1 or 1)
|
||||||
|
b.drainHold = Timing.HP_BAR_PIXEL_STEP - 1
|
||||||
|
else
|
||||||
|
b.drainHold = 0
|
||||||
end
|
end
|
||||||
b.drainHold = math.max(0, cost - 1)
|
|
||||||
b.draining = true
|
b.draining = true
|
||||||
busy = true
|
busy = true
|
||||||
elseif b.draining then
|
elseif b.draining then
|
||||||
@@ -1244,13 +1348,13 @@ function BattleState:updateQueue()
|
|||||||
-- no subanimation player: keep the single-sound fallback (with
|
-- no subanimation player: keep the single-sound fallback (with
|
||||||
-- the move's pitch/tempo modifiers; GROWL/ROAR play the
|
-- the move's pitch/tempo modifiers; GROWL/ROAR play the
|
||||||
-- attacker's cry -- GetMoveSound/IsCryMove)
|
-- attacker's cry -- GetMoveSound/IsCryMove)
|
||||||
if item.anim == "GROWL" or item.anim == "ROAR" then
|
if self:animationsOn() and (item.anim == "GROWL" or item.anim == "ROAR") then
|
||||||
local attacker = item.attackerIsPlayer and self.player or self.enemy
|
local attacker = item.attackerIsPlayer and self.player or self.enemy
|
||||||
if attacker then
|
if attacker then
|
||||||
require("src.core.Sound").playMoveCry(self.data, attacker.mon.species,
|
require("src.core.Sound").playMoveCry(self.data, attacker.mon.species,
|
||||||
anim and anim.tempo)
|
anim and anim.tempo)
|
||||||
end
|
end
|
||||||
elseif anim and anim.sound then
|
elseif self:animationsOn() and anim and anim.sound then
|
||||||
local Sound = require("src.core.Sound")
|
local Sound = require("src.core.Sound")
|
||||||
if Sound.playMove then
|
if Sound.playMove then
|
||||||
Sound.playMove(self.data, anim)
|
Sound.playMove(self.data, anim)
|
||||||
@@ -1350,6 +1454,11 @@ function BattleState:updateQueue()
|
|||||||
self.current = nil
|
self.current = nil
|
||||||
end
|
end
|
||||||
elseif not (item and item.choice) then
|
elseif not (item and item.choice) then
|
||||||
|
if item and item.waitForLearningSfx and not item.soundStarted then
|
||||||
|
item.soundStarted = true
|
||||||
|
self.waitingSound = item.waitForLearningSfx()
|
||||||
|
return true
|
||||||
|
end
|
||||||
-- The page is typed out and waiting on the player: PromptText
|
-- The page is typed out and waiting on the player: PromptText
|
||||||
-- (home/text.asm:209-217) writes '▼' at (18,16) and ManualTextScroll
|
-- (home/text.asm:209-217) writes '▼' at (18,16) and ManualTextScroll
|
||||||
-- blinks it until A/B, so the arrow belongs on a finished page and not
|
-- blinks it until A/B, so the arrow belongs on a finished page and not
|
||||||
@@ -1409,7 +1518,7 @@ end
|
|||||||
function BattleState:playEntranceCry(battler)
|
function BattleState:playEntranceCry(battler)
|
||||||
local mon = battler and battler.mon
|
local mon = battler and battler.mon
|
||||||
if not mon then return end
|
if not mon then return end
|
||||||
require("src.core.Sound").playCry(self.data, mon.species,
|
return require("src.core.Sound").playCry(self.data, mon.species,
|
||||||
mon.status == "SLP" and 37 or 11)
|
mon.status == "SLP" and 37 or 11)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -1533,7 +1642,23 @@ function BattleState:enter()
|
|||||||
-- default stays opaque for every other battle and for older saves.
|
-- default stays opaque for every other battle and for older saves.
|
||||||
self.isOpaque = self:bgMode() ~= "world"
|
self.isOpaque = self:bgMode() ~= "world"
|
||||||
self.introSlide = Timing.BATTLE_SLIDE_IN_FRAMES
|
self.introSlide = Timing.BATTLE_SLIDE_IN_FRAMES
|
||||||
self.showEnemyTrainer = self.kind == "trainer" and self.trainerPic ~= nil
|
-- GetTrainerInformation .linkBattle (home/trainers2.asm:26-31): the link
|
||||||
|
-- foe's pic is RedPicFront whatever either save looks like, and
|
||||||
|
-- InitBattleCommon loads and tilemaps it at hlcoord 12,0 for every
|
||||||
|
-- wIsInBattle == 2 battle (core.asm:6681-6688), the link one included.
|
||||||
|
-- wEnemyMonSpecies2 is zeroed under it, so the pic palette is PAL_MEWMON
|
||||||
|
-- exactly as it is for a trainer.
|
||||||
|
if self.kind == "link" and not self.trainerPic then
|
||||||
|
local frontPath, frontTrueColor =
|
||||||
|
require("src.pokemon.Sprites").playerPath(self.data, "front",
|
||||||
|
{ kind = "battle", battle = self })
|
||||||
|
if frontPath and require("src.render.Assets").exists(frontPath) then
|
||||||
|
self.trainerPic = getImage(frontPath, namedPalette(self.data, "MEWMON"),
|
||||||
|
frontTrueColor)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
self.showEnemyTrainer = (self.kind == "trainer" or self.kind == "link")
|
||||||
|
and self.trainerPic ~= nil
|
||||||
-- DrawAllPokeballs (common_text.asm:27) puts the party ball rows AND the
|
-- DrawAllPokeballs (common_text.asm:27) puts the party ball rows AND the
|
||||||
-- HUD corner/underline tiles under them (PlacePlayerHUDTiles /
|
-- HUD corner/underline tiles under them (PlacePlayerHUDTiles /
|
||||||
-- PlaceEnemyHUDTiles, draw_hud_pokeball_gfx.asm:119-165) on screen with
|
-- PlaceEnemyHUDTiles, draw_hud_pokeball_gfx.asm:119-165) on screen with
|
||||||
@@ -1565,13 +1690,13 @@ function BattleState:enter()
|
|||||||
-- a different point in each battle kind, so queue it per branch
|
-- a different point in each battle kind, so queue it per branch
|
||||||
local function queueEnemyCry()
|
local function queueEnemyCry()
|
||||||
self:act(function()
|
self:act(function()
|
||||||
self:playEntranceCry(self.enemy)
|
self:waitSfxNext(self:playEntranceCry(self.enemy))
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
-- PrintBeginningBattleText (engine/battle/common_text.asm:10-19): a wild
|
-- PrintBeginningBattleText (engine/battle/common_text.asm:10-19): a wild
|
||||||
-- battle calls PlayCry BEFORE PrintText WildMonAppearedText, so the cry
|
-- battle calls PlayCry BEFORE PrintText WildMonAppearedText, and PlayCry
|
||||||
-- sounds with the "Wild X appeared!" box instead of waiting on the A
|
-- ends in WaitForSoundToFinish, so the cry runs to its end and only then
|
||||||
-- press that clears its `prompt` (#303). The Silph-Scope-less tower
|
-- does the "Wild X appeared!" box open (#303). The Silph-Scope-less tower
|
||||||
-- ghost gets no cry at all (common_text.asm:43-48), and neither does the
|
-- ghost gets no cry at all (common_text.asm:43-48), and neither does the
|
||||||
-- unveiled MAROWAK: .isMarowak never reaches PlayCry (#492).
|
-- unveiled MAROWAK: .isMarowak never reaches PlayCry (#492).
|
||||||
if self.kind ~= "trainer" and self.kind ~= "link"
|
if self.kind ~= "trainer" and self.kind ~= "link"
|
||||||
@@ -1589,7 +1714,10 @@ function BattleState:enter()
|
|||||||
-- The sfx is extracted as "Trainer_Appeared" (tools/rom_manifest.json
|
-- The sfx is extracted as "Trainer_Appeared" (tools/rom_manifest.json
|
||||||
-- sfxHeaders, bank 8 / $42bb -- the same header pokered names
|
-- sfxHeaders, bank 8 / $42bb -- the same header pokered names
|
||||||
-- SFX_Silph_Scope); nothing had ever played it.
|
-- SFX_Silph_Scope); nothing had ever played it.
|
||||||
if self.kind == "trainer" then
|
--
|
||||||
|
-- A link battle is wIsInBattle == 2, so PrintBeginningBattleText takes the
|
||||||
|
-- same .trainerBattle arm and owes the sfx too (common_text.asm:20-23).
|
||||||
|
if self.kind == "trainer" or self.kind == "link" then
|
||||||
self:act(function()
|
self:act(function()
|
||||||
self.introSfx = require("src.core.Sound").play(self.data,
|
self.introSfx = require("src.core.Sound").play(self.data,
|
||||||
"Trainer_Appeared")
|
"Trainer_Appeared")
|
||||||
@@ -1607,13 +1735,17 @@ function BattleState:enter()
|
|||||||
-- battle -- not on a switch, and not when the beaten trainer's pic
|
-- battle -- not on a switch, and not when the beaten trainer's pic
|
||||||
-- scrolls back in (#317, #282)
|
-- scrolls back in (#317, #282)
|
||||||
self:act(function() self.introBalls = nil end)
|
self:act(function() self.introBalls = nil end)
|
||||||
if self.kind == "trainer" then
|
if self.kind == "trainer" or self.kind == "link" then
|
||||||
-- EnemySendOutFirstMon (core.asm:1308-1310): SlideTrainerPicOffScreen
|
local foeName = self.trainer and self.trainer.name
|
||||||
-- walks the foe's pic off the RIGHT edge (hlcoord 18,0, a = 8 tiles,
|
or self.opponentName or Strings("FOE")
|
||||||
-- one tile every 2 frames) BEFORE TrainerSentOutText -- the pic does
|
if self.showEnemyTrainer then
|
||||||
-- not blink out under the text (#317)
|
-- EnemySendOutFirstMon (core.asm:1308-1310): SlideTrainerPicOffScreen
|
||||||
self:act(function() self:slidePic("foe", 0, 64, 4) end)
|
-- walks the foe's pic off the RIGHT edge (hlcoord 18,0, a = 8 tiles,
|
||||||
table.insert(self.queue, { wait = 16 })
|
-- one tile every 2 frames) BEFORE TrainerSentOutText -- the pic does
|
||||||
|
-- not blink out under the text (#317)
|
||||||
|
self:act(function() self:slidePic("foe", 0, 64, 4) end)
|
||||||
|
table.insert(self.queue, { wait = 16 })
|
||||||
|
end
|
||||||
self:act(function()
|
self:act(function()
|
||||||
self.showEnemyTrainer = false
|
self.showEnemyTrainer = false
|
||||||
-- the slot is EMPTY from here until AnimateSendingOutMon runs below:
|
-- the slot is EMPTY from here until AnimateSendingOutMon runs below:
|
||||||
@@ -1626,7 +1758,7 @@ function BattleState:enter()
|
|||||||
self.enemySendingOut = true
|
self.enemySendingOut = true
|
||||||
self:slidePic("foe")
|
self:slidePic("foe")
|
||||||
end)
|
end)
|
||||||
self:say(Strings("%s sent\nout %s!", self.trainer.name, self.enemy.name))
|
self:say(Strings("%s sent\nout %s!", foeName, self.enemy.name))
|
||||||
self:act(function()
|
self:act(function()
|
||||||
-- EnemySendOutFirstMon (core.asm:1421-1434): after the text the
|
-- EnemySendOutFirstMon (core.asm:1421-1434): after the text the
|
||||||
-- pic grows out of the ball (AnimateSendingOutMon), then the cry
|
-- pic grows out of the ball (AnimateSendingOutMon), then the cry
|
||||||
@@ -1634,18 +1766,6 @@ function BattleState:enter()
|
|||||||
self:startGrowIn(self.enemy)
|
self:startGrowIn(self.enemy)
|
||||||
end)
|
end)
|
||||||
queueEnemyCry()
|
queueEnemyCry()
|
||||||
elseif self.kind == "link" then
|
|
||||||
-- Colosseum has no foe trainer pic, but the enemy mon still grows
|
|
||||||
-- out of the ball after "X sent out Y!" (not the wild "already there"
|
|
||||||
-- intro that LinkBattle previously inherited from newWild).
|
|
||||||
self.enemySendingOut = true
|
|
||||||
self:say(Strings("%s sent\nout %s!", self.opponentName or Strings("FOE"),
|
|
||||||
self.enemy.name))
|
|
||||||
self:act(function()
|
|
||||||
self.enemySendingOut = false
|
|
||||||
self:startGrowIn(self.enemy)
|
|
||||||
end)
|
|
||||||
queueEnemyCry()
|
|
||||||
end
|
end
|
||||||
-- StartBattle .foundFirstAliveEnemyMon (core.asm:152-156): the `call nz`
|
-- StartBattle .foundFirstAliveEnemyMon (core.asm:152-156): the `call nz`
|
||||||
-- gates only EnemySendOutFirstMon -- the `ld c, 40 / call DelayFrames`
|
-- gates only EnemySendOutFirstMon -- the `ld c, 40 / call DelayFrames`
|
||||||
@@ -1676,7 +1796,7 @@ function BattleState:enter()
|
|||||||
-- SendOutMon (core.asm:1757-1762): after the poof the mon grows
|
-- SendOutMon (core.asm:1757-1762): after the poof the mon grows
|
||||||
-- out of the ball (AnimateSendingOutMon at hlcoord 4,11)
|
-- out of the ball (AnimateSendingOutMon at hlcoord 4,11)
|
||||||
self:startGrowIn(self.player)
|
self:startGrowIn(self.player)
|
||||||
self:playEntranceCry(self.player)
|
self:waitSfxNext(self:playEntranceCry(self.player))
|
||||||
end)
|
end)
|
||||||
self:markParticipant()
|
self:markParticipant()
|
||||||
end
|
end
|
||||||
@@ -1735,6 +1855,7 @@ end
|
|||||||
local function sendOutMonCursors(self)
|
local function sendOutMonCursors(self)
|
||||||
self.menuIndex = 1
|
self.menuIndex = 1
|
||||||
self.moveIndex = 1
|
self.moveIndex = 1
|
||||||
|
self.playerMoveListIndex = 1
|
||||||
end
|
end
|
||||||
|
|
||||||
-- core.asm:297-300: both sides' FLINCHED bits are cleared as a turn's move
|
-- core.asm:297-300: both sides' FLINCHED bits are cleared as a turn's move
|
||||||
@@ -1853,7 +1974,10 @@ function BattleState:update(dt)
|
|||||||
if self.phase == "menu" then
|
if self.phase == "menu" then
|
||||||
for _, b in ipairs({ self.player, self.enemy }) do
|
for _, b in ipairs({ self.player, self.enemy }) do
|
||||||
if b then
|
if b then
|
||||||
if b.shownHP then b.shownHP = b.mon.hp end
|
if b.shownHP then
|
||||||
|
b.shownHP = b.mon.hp
|
||||||
|
b.shownPx = Timing.hpBarPixels(b.mon.hp, math.max(1, b.mon.stats.hp))
|
||||||
|
end
|
||||||
b.drainFloor = nil
|
b.drainFloor = nil
|
||||||
b.shownStatus = b.mon.status
|
b.shownStatus = b.mon.status
|
||||||
end
|
end
|
||||||
@@ -1871,9 +1995,18 @@ function BattleState:update(dt)
|
|||||||
-- its own has no slide to wait for.
|
-- its own has no slide to wait for.
|
||||||
if (self.introSlide or 0) > 0 then return end
|
if (self.introSlide or 0) > 0 then return end
|
||||||
if not self:updateQueue() then
|
if not self:updateQueue() then
|
||||||
if self.afterQueue == "menu" then
|
local destination = self.afterQueue
|
||||||
|
-- These fields are queue/presentation cursors, not durable battle
|
||||||
|
-- state. Once the queue has drained, keeping their terminal values
|
||||||
|
-- makes the real command menu look busy to BattleSafety even though
|
||||||
|
-- every message, wait and intro animation has settled.
|
||||||
|
self.afterQueue = nil
|
||||||
|
self.nextInsert = nil
|
||||||
|
self.waitFrames = nil
|
||||||
|
if destination == "menu" then
|
||||||
|
self.introSlide = nil
|
||||||
self.phase = "menu"
|
self.phase = "menu"
|
||||||
elseif self.afterQueue == "finish" then
|
elseif destination == "finish" then
|
||||||
self:finish()
|
self:finish()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -1929,7 +2062,7 @@ function BattleState:update(dt)
|
|||||||
-- loops the party menu until a healthy mon is picked, so B and
|
-- loops the party menu until a healthy mon is picked, so B and
|
||||||
-- fainted picks land back here and reopen it
|
-- fainted picks land back here and reopen it
|
||||||
if self.player.mon.hp <= 0 then
|
if self.player.mon.hp <= 0 then
|
||||||
if Party.firstHealthy(self.game.save.party) then
|
if Party.firstHealthy(self:playerPartyView()) then
|
||||||
self:openReplacementMenu()
|
self:openReplacementMenu()
|
||||||
end
|
end
|
||||||
return
|
return
|
||||||
@@ -1942,6 +2075,17 @@ function BattleState:update(dt)
|
|||||||
self:resolveTurn(locked)
|
self:resolveTurn(locked)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
-- START has no vanilla action at a settled supported player-decision
|
||||||
|
-- boundary. A tool mod may claim this semantic auxiliary action through
|
||||||
|
-- the public hook, receiving only game plus a data-only kind. The shared
|
||||||
|
-- safety predicate keeps every unsupported/forced/animated phase inert.
|
||||||
|
if input:wasPressed("start") and Runtime.wantsHook("battle.menu_auxiliary") then
|
||||||
|
local safe = BattleSafety.inspect(self.game, self)
|
||||||
|
if safe and Runtime.call("battle.menu_auxiliary", function() return false end,
|
||||||
|
self.game, { kind = self.kind }) == true then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
local col = (self.menuIndex - 1) % 2
|
local col = (self.menuIndex - 1) % 2
|
||||||
local row = math.floor((self.menuIndex - 1) / 2)
|
local row = math.floor((self.menuIndex - 1) / 2)
|
||||||
if input:wasPressed("left") then
|
if input:wasPressed("left") then
|
||||||
@@ -2013,8 +2157,10 @@ function BattleState:update(dt)
|
|||||||
if self.moveSwapIndex then
|
if self.moveSwapIndex then
|
||||||
self:swapMoves(self.moveSwapIndex, self.moveIndex)
|
self:swapMoves(self.moveSwapIndex, self.moveIndex)
|
||||||
self.moveSwapIndex = nil
|
self.moveSwapIndex = nil
|
||||||
|
self.moveIndex = math.min(self.playerMoveListIndex or 1, #moves)
|
||||||
else
|
else
|
||||||
self.moveSwapIndex = self.moveIndex
|
self.moveSwapIndex = self.moveIndex
|
||||||
|
self.moveIndex = math.min(self.playerMoveListIndex or 1, #moves)
|
||||||
end
|
end
|
||||||
elseif input:wasPressed("b") then
|
elseif input:wasPressed("b") then
|
||||||
require("src.core.Sound").play(self.data, "Press_AB")
|
require("src.core.Sound").play(self.data, "Press_AB")
|
||||||
@@ -2037,6 +2183,7 @@ function BattleState:update(dt)
|
|||||||
self.phase = "messages"
|
self.phase = "messages"
|
||||||
self.afterQueue = "menu"
|
self.afterQueue = "menu"
|
||||||
else
|
else
|
||||||
|
self.playerMoveListIndex = self.moveIndex
|
||||||
self:resolveTurn(mv)
|
self:resolveTurn(mv)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -2194,8 +2341,13 @@ function BattleState:openOldManBag()
|
|||||||
self.afterQueue = "menu"
|
self.afterQueue = "menu"
|
||||||
self:ui(function()
|
self:ui(function()
|
||||||
local list
|
local list
|
||||||
|
-- The canned bag (POKE_BALL, not read from the player's real
|
||||||
|
-- inventory) differs by version: pokered's OldManItemList has 50
|
||||||
|
-- POKé BALLs; pokeyellow's SimulatedInputBattleItemList, shared by
|
||||||
|
-- the Viridian tutorial and Oak's catch, has one.
|
||||||
|
local qty = require("src.core.GameVersion").isYellow() and "x1" or "x50"
|
||||||
list = ListMenu.new(game, "ITEMS", {
|
list = ListMenu.new(game, "ITEMS", {
|
||||||
{ value = "POKE_BALL", label = Strings("POKé BALL"), right = "x50" },
|
{ value = "POKE_BALL", label = Strings("POKé BALL"), right = qty },
|
||||||
}, {
|
}, {
|
||||||
script = function(l)
|
script = function(l)
|
||||||
l.scriptTimer = (l.scriptTimer or 0) + 1
|
l.scriptTimer = (l.scriptTimer or 0) + 1
|
||||||
@@ -2243,10 +2395,10 @@ function BattleState:oldManThrow()
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
self:ballChain("TOSS_ANIM", true, 3, "POKE_BALL")
|
self:ballChain("TOSS_ANIM", true, 3, "POKE_BALL")
|
||||||
self:actNext(function()
|
-- ItemUseBallText05: text_far, sound_caught_mon, text_promptbutton --
|
||||||
require("src.core.Sound").play(self.data, "Caught_Mon")
|
-- the fanfare follows the caught text and holds the prompt
|
||||||
end)
|
self:sayNextWaitSfx(Strings("All right!\n%s was\ncaught!", self.enemy.name),
|
||||||
self:sayNext(Strings("All right!\n%s was\ncaught!", self.enemy.name))
|
function() return require("src.core.Sound").play(self.data, "Caught_Mon") end)
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -2387,11 +2539,9 @@ function BattleState:resolveTurn(playerAction)
|
|||||||
end
|
end
|
||||||
local order
|
local order
|
||||||
if pFirst then
|
if pFirst then
|
||||||
order = { { self.player, self.enemy, playerAction },
|
order = { { true, playerAction }, { false, enemyAction } }
|
||||||
{ self.enemy, self.player, enemyAction } }
|
|
||||||
else
|
else
|
||||||
order = { { self.enemy, self.player, enemyAction },
|
order = { { false, enemyAction }, { true, playerAction } }
|
||||||
{ self.player, self.enemy, playerAction } }
|
|
||||||
end
|
end
|
||||||
|
|
||||||
self.phase = "messages"
|
self.phase = "messages"
|
||||||
@@ -2399,7 +2549,9 @@ function BattleState:resolveTurn(playerAction)
|
|||||||
|
|
||||||
for _, entry in ipairs(order) do
|
for _, entry in ipairs(order) do
|
||||||
self:act(function()
|
self:act(function()
|
||||||
self:executeAction(entry[1], entry[2], entry[3])
|
local user = entry[1] and self.player or self.enemy
|
||||||
|
local target = entry[1] and self.enemy or self.player
|
||||||
|
self:executeAction(user, target, entry[2])
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
self:act(function() self:endOfTurn() end)
|
self:act(function() self:endOfTurn() end)
|
||||||
@@ -2430,7 +2582,7 @@ function BattleState:resolveSwitch(newMon)
|
|||||||
self.sendingOut = false
|
self.sendingOut = false
|
||||||
-- SendOutMon (core.asm:1757-1762): poof, then the grow-in
|
-- SendOutMon (core.asm:1757-1762): poof, then the grow-in
|
||||||
self:startGrowIn(self.player)
|
self:startGrowIn(self.player)
|
||||||
self:playEntranceCry(self.player)
|
self:waitSfxNext(self:playEntranceCry(self.player))
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
self:act(function()
|
self:act(function()
|
||||||
@@ -2909,7 +3061,7 @@ function BattleState:applyHitFx(hit)
|
|||||||
Sound.play(self.data, hit.sfx)
|
Sound.play(self.data, hit.sfx)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
if not t or not self:animationsOn() then return end
|
if not t then return end
|
||||||
if t == 1 then
|
if t == 1 then
|
||||||
-- PredefShakeScreenVertically b=8: the window drops by b for 3 frames
|
-- PredefShakeScreenVertically b=8: the window drops by b for 3 frames
|
||||||
-- then home for 3, b counting down
|
-- then home for 3, b counting down
|
||||||
@@ -3742,7 +3894,7 @@ function BattleState:onFaint(battler)
|
|||||||
if battler.isPlayer then
|
if battler.isPlayer then
|
||||||
-- RemoveFaintedPlayerMon (core.asm:1040-1042): the player mon's
|
-- RemoveFaintedPlayerMon (core.asm:1040-1042): the player mon's
|
||||||
-- faint plays its ordinary species cry -- no Faint_Fall
|
-- faint plays its ordinary species cry -- no Faint_Fall
|
||||||
Sound.playCry(self.data, battler.mon.species)
|
self.faintCry = Sound.playCry(self.data, battler.mon.species)
|
||||||
elseif self.kind ~= "wild" then
|
elseif self.kind ~= "wild" then
|
||||||
-- FaintEnemyPokemon (core.asm:732-771): the enemy faint plays no
|
-- FaintEnemyPokemon (core.asm:732-771): the enemy faint plays no
|
||||||
-- species cry; trainer battles get SFX_FAINT_FALL, then SFX_FAINT_THUD
|
-- species cry; trainer battles get SFX_FAINT_FALL, then SFX_FAINT_THUD
|
||||||
@@ -3757,6 +3909,11 @@ function BattleState:onFaint(battler)
|
|||||||
end)
|
end)
|
||||||
self.nextInsert = (self.nextInsert or 0) + 1
|
self.nextInsert = (self.nextInsert or 0) + 1
|
||||||
table.insert(self.queue, self.nextInsert, { wait = Timing.FAINT_SLIDE })
|
table.insert(self.queue, self.nextInsert, { wait = Timing.FAINT_SLIDE })
|
||||||
|
if battler.isPlayer then
|
||||||
|
-- RemoveFaintedPlayerMon ends `call PlayCry / jp PrintText`, and PlayCry
|
||||||
|
-- is `jp WaitForSoundToFinish`, so "X fainted!" waits out the cry
|
||||||
|
self:waitSfxNext(function() return self.faintCry end)
|
||||||
|
end
|
||||||
if not battler.isPlayer and self.kind ~= "wild" then
|
if not battler.isPlayer and self.kind ~= "wild" then
|
||||||
-- FaintEnemyPokemon's SFX_FAINT_THUD lands as the slide does (after
|
-- FaintEnemyPokemon's SFX_FAINT_THUD lands as the slide does (after
|
||||||
-- Faint_Fall, before EnemyMonFaintedText)
|
-- Faint_Fall, before EnemyMonFaintedText)
|
||||||
@@ -3793,7 +3950,8 @@ function BattleState:awardExp()
|
|||||||
-- (RemoveFaintedPlayerMon), so it drops out of the divisor and only
|
-- (RemoveFaintedPlayerMon), so it drops out of the divisor and only
|
||||||
-- the surviving participants are counted and paid
|
-- the surviving participants are counted and paid
|
||||||
local participants, alive = 0, {}
|
local participants, alive = 0, {}
|
||||||
for _, mon in ipairs(self.game.save.party) do
|
local playerParty = self:playerPartyView()
|
||||||
|
for _, mon in ipairs(playerParty) do
|
||||||
if self.participants and self.participants[mon] then
|
if self.participants and self.participants[mon] then
|
||||||
participants = participants + 1
|
participants = participants + 1
|
||||||
if mon.hp > 0 then table.insert(alive, mon) end
|
if mon.hp > 0 then table.insert(alive, mon) end
|
||||||
@@ -3803,9 +3961,12 @@ function BattleState:awardExp()
|
|||||||
participants, alive = 1, { self.player.mon }
|
participants, alive = 1, { self.player.mon }
|
||||||
end
|
end
|
||||||
local function applyShare(mon, split, announce)
|
local function applyShare(mon, split, announce)
|
||||||
|
local playerId = self.game.save.player and self.game.save.player.id
|
||||||
|
local traded = mon.otId ~= nil and playerId ~= nil
|
||||||
|
and mon.otId ~= playerId or mon.traded == true and mon.otId == nil
|
||||||
local levels, gained = Experience.apply(self.data, mon, self.enemy.def,
|
local levels, gained = Experience.apply(self.data, mon, self.enemy.def,
|
||||||
self.enemy.mon.level, self.kind == "trainer",
|
self.enemy.mon.level, self.kind == "trainer",
|
||||||
split, mon.traded)
|
split, traded)
|
||||||
-- Track level-ups for EvolveAfterBattle (OverworldState:afterBattle ->
|
-- Track level-ups for EvolveAfterBattle (OverworldState:afterBattle ->
|
||||||
-- Evolution.checkParty). B-cancel leaves the mon at/above threshold;
|
-- Evolution.checkParty). B-cancel leaves the mon at/above threshold;
|
||||||
-- without this gate it re-triggers after every later fight (#213).
|
-- without this gate it re-triggers after every later fight (#213).
|
||||||
@@ -3829,7 +3990,7 @@ function BattleState:awardExp()
|
|||||||
local text = Strings.source("%s gained\n%d EXP. Points!")
|
local text = Strings.source("%s gained\n%d EXP. Points!")
|
||||||
if announce == "expAll" then
|
if announce == "expAll" then
|
||||||
text = Strings.source("%s gained\nwith EXP.ALL,\v%d EXP. Points!")
|
text = Strings.source("%s gained\nwith EXP.ALL,\v%d EXP. Points!")
|
||||||
elseif mon.traded then
|
elseif traded then
|
||||||
text = Strings.source("%s gained\na boosted\v%d EXP. Points!")
|
text = Strings.source("%s gained\na boosted\v%d EXP. Points!")
|
||||||
end
|
end
|
||||||
self:sayNext(Strings(text, name, gained))
|
self:sayNext(Strings(text, name, gained))
|
||||||
@@ -3841,9 +4002,11 @@ function BattleState:awardExp()
|
|||||||
-- experience.asm:248 fires per grew-level text
|
-- experience.asm:248 fires per grew-level text
|
||||||
require("src.world.PikachuFollower")
|
require("src.world.PikachuFollower")
|
||||||
.modifyHappiness(game.save, "LEVELUP", mon)
|
.modifyHappiness(game.save, "LEVELUP", mon)
|
||||||
self:sayNext(Strings("%s grew\nto level %d!", name, lv))
|
-- GrewLevelText: text_far, sound_level_up, text_end (experience.asm:
|
||||||
|
-- 369-372); PrintStatsBox only runs once PrintText has returned
|
||||||
|
self:sayNextWaitSfx(Strings("%s grew\nto level %d!", name, lv),
|
||||||
|
function() return require("src.core.Sound").play(game.data, "Level_Up") end)
|
||||||
self:uiNext(function()
|
self:uiNext(function()
|
||||||
require("src.core.Sound").play(game.data, "Level_Up")
|
|
||||||
return StatBox.new(game, mon)
|
return StatBox.new(game, mon)
|
||||||
end)
|
end)
|
||||||
-- After PrintStatsBox, experience.asm reloads the active battler's
|
-- After PrintStatsBox, experience.asm reloads the active battler's
|
||||||
@@ -3882,9 +4045,9 @@ function BattleState:awardExp()
|
|||||||
-- experience.asm:9-13); each mon gets its own GainedText with the
|
-- experience.asm:9-13); each mon gets its own GainedText with the
|
||||||
-- "with EXP.ALL," tail (wBoostExpByExpAll) -- pokered prints no
|
-- "with EXP.ALL," tail (wBoostExpByExpAll) -- pokered prints no
|
||||||
-- summary line
|
-- summary line
|
||||||
for _, mon in ipairs(self.game.save.party) do
|
for _, mon in ipairs(playerParty) do
|
||||||
if mon.hp > 0 then
|
if mon.hp > 0 then
|
||||||
ctx.applyShare(mon, math.max(1, ctx.participants) * #self.game.save.party * 2, "expAll")
|
ctx.applyShare(mon, math.max(1, ctx.participants) * #playerParty * 2, "expAll")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -3925,7 +4088,7 @@ function BattleState:enemyMonFainted()
|
|||||||
local nextName = nextMon.nickname or self.data.pokemon[nextMon.species].name
|
local nextName = nextMon.nickname or self.data.pokemon[nextMon.species].name
|
||||||
local style = tostring((self.game.save.options or {}).battleStyle or "shift")
|
local style = tostring((self.game.save.options or {}).battleStyle or "shift")
|
||||||
:lower()
|
:lower()
|
||||||
local partyCount = #self.game.save.party
|
local partyCount = #self:playerPartyView()
|
||||||
-- ReplaceFaintedEnemyMon (core.asm:892-896): DrawEnemyPokeballs puts the
|
-- ReplaceFaintedEnemyMon (core.asm:892-896): DrawEnemyPokeballs puts the
|
||||||
-- foe's party ball row -- and the HUD chrome PlaceEnemyHUDTiles lays
|
-- foe's party ball row -- and the HUD chrome PlaceEnemyHUDTiles lays
|
||||||
-- down under it (draw_hud_pokeball_gfx.asm:9-11, 33-45, 134-141) -- into
|
-- down under it (draw_hud_pokeball_gfx.asm:9-11, 33-45, 134-141) -- into
|
||||||
@@ -3952,6 +4115,7 @@ function BattleState:enemyMonFainted()
|
|||||||
local game = self.game
|
local game = self.game
|
||||||
Screens.push(game, "PartyMenu", {
|
Screens.push(game, "PartyMenu", {
|
||||||
battle = self,
|
battle = self,
|
||||||
|
party = self:playerPartyView(),
|
||||||
forceSwitch = true,
|
forceSwitch = true,
|
||||||
onSwitch = function(mon)
|
onSwitch = function(mon)
|
||||||
if mon ~= self.player.mon and mon.hp > 0 then
|
if mon ~= self.player.mon and mon.hp > 0 then
|
||||||
@@ -3988,7 +4152,7 @@ function BattleState:enemyMonFainted()
|
|||||||
self.enemySendingOut = false
|
self.enemySendingOut = false
|
||||||
self:startGrowIn(self.enemy)
|
self:startGrowIn(self.enemy)
|
||||||
self:actNext(function()
|
self:actNext(function()
|
||||||
self:playEntranceCry(self.enemy)
|
self:waitSfxNext(self:playEntranceCry(self.enemy))
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
@@ -4027,7 +4191,7 @@ function BattleState:enemyMonFainted()
|
|||||||
self:actNext(function()
|
self:actNext(function()
|
||||||
self.sendingOut = false
|
self.sendingOut = false
|
||||||
self:startGrowIn(self.player)
|
self:startGrowIn(self.player)
|
||||||
self:playEntranceCry(self.player)
|
self:waitSfxNext(self:playEntranceCry(self.player))
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
return
|
return
|
||||||
@@ -4093,15 +4257,17 @@ function BattleState:learnMove(mon, moveId)
|
|||||||
if #mon.moves < 4 then
|
if #mon.moves < 4 then
|
||||||
table.insert(mon.moves, { id = moveId, pp = mdef.pp })
|
table.insert(mon.moves, { id = moveId, pp = mdef.pp })
|
||||||
Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId })
|
Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId })
|
||||||
self:sayNext(self:romText("_MimicLearnedMoveText", "%s learned\n%s!", mon.nickname or self.data.pokemon[mon.species].name,
|
self:sayNextWaitSfx(self:romText("_MimicLearnedMoveText", "%s learned\n%s!", mon.nickname or self.data.pokemon[mon.species].name,
|
||||||
mdef.name))
|
mdef.name), function()
|
||||||
|
return require("src.core.Sound").play(self.data, "Level_Up")
|
||||||
|
end)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
-- the "trying to learn" preamble lives inside MoveLearnMenu:enter;
|
-- the "trying to learn" preamble lives inside MoveLearnMenu:enter;
|
||||||
-- ordered insert so multi-level gains keep each level's checks
|
-- ordered insert so multi-level gains keep each level's checks
|
||||||
-- between its own stat box and the next "grew to level" text
|
-- between its own stat box and the next "grew to level" text
|
||||||
self:uiNext(function()
|
self:uiNext(function()
|
||||||
return self:buildScreen("MoveLearnMenu", mon, moveId)
|
return self:buildScreen("MoveLearnMenu", mon, moveId, nil, "Level_Up")
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -4122,7 +4288,7 @@ function BattleState.isOaksLabStarterRival(self)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function BattleState:playerMonFainted()
|
function BattleState:playerMonFainted()
|
||||||
local nextMon = Party.firstHealthy(self.game.save.party)
|
local nextMon = Party.firstHealthy(self:playerPartyView())
|
||||||
-- Being out of useable POKéMON blacks you out even when the battle was
|
-- Being out of useable POKéMON blacks you out even when the battle was
|
||||||
-- already decided in our favour. A double faint -- our last mon dying
|
-- already decided in our favour. A double faint -- our last mon dying
|
||||||
-- to residual damage on the turn it lands the KO -- used to hit the
|
-- to residual damage on the turn it lands the KO -- used to hit the
|
||||||
@@ -4195,6 +4361,7 @@ function BattleState:openReplacementMenu()
|
|||||||
self:ui(function()
|
self:ui(function()
|
||||||
return self:buildScreen("PartyMenu", {
|
return self:buildScreen("PartyMenu", {
|
||||||
battle = self,
|
battle = self,
|
||||||
|
party = self:playerPartyView(),
|
||||||
-- ChooseNextMon: pick immediately (no SWITCH/STATS/CANCEL)
|
-- ChooseNextMon: pick immediately (no SWITCH/STATS/CANCEL)
|
||||||
forceSwitch = true,
|
forceSwitch = true,
|
||||||
onSwitch = function(mon)
|
onSwitch = function(mon)
|
||||||
@@ -4221,7 +4388,7 @@ function BattleState:openReplacementMenu()
|
|||||||
self.sendingOut = false
|
self.sendingOut = false
|
||||||
-- SendOutMon (core.asm:1757-1762): poof, then the grow-in
|
-- SendOutMon (core.asm:1757-1762): poof, then the grow-in
|
||||||
self:startGrowIn(self.player)
|
self:startGrowIn(self.player)
|
||||||
self:playEntranceCry(self.player)
|
self:waitSfxNext(self:playEntranceCry(self.player))
|
||||||
end)
|
end)
|
||||||
end,
|
end,
|
||||||
})
|
})
|
||||||
@@ -4265,11 +4432,10 @@ function BattleState:safariAction(choice)
|
|||||||
-- above DoBallTossSpecialEffects's <= ULTRA_BALL check)
|
-- above DoBallTossSpecialEffects's <= ULTRA_BALL check)
|
||||||
self:ballChain(self:tossAnimFor("SAFARI_BALL"), caught, shakes, "SAFARI_BALL")
|
self:ballChain(self:tossAnimFor("SAFARI_BALL"), caught, shakes, "SAFARI_BALL")
|
||||||
if caught then
|
if caught then
|
||||||
-- ItemUseBallText05's sound_caught_mon: fanfare with the text
|
-- ItemUseBallText05: text_far, sound_caught_mon, text_promptbutton --
|
||||||
self:actNext(function()
|
-- the fanfare follows the caught text and holds the prompt
|
||||||
require("src.core.Sound").play(self.data, "Caught_Mon")
|
self:sayNextWaitSfx(Strings("All right!\n%s was\ncaught!", self.enemy.name),
|
||||||
end)
|
function() return require("src.core.Sound").play(self.data, "Caught_Mon") end)
|
||||||
self:sayNext(Strings("All right!\n%s was\ncaught!", self.enemy.name))
|
|
||||||
-- same ItemUseBall .captured flow as a regular ball
|
-- same ItemUseBall .captured flow as a regular ball
|
||||||
self:act(function() self:storeCaughtMon() end)
|
self:act(function() self:storeCaughtMon() end)
|
||||||
else
|
else
|
||||||
@@ -4497,8 +4663,12 @@ function BattleState:storeCaughtMon()
|
|||||||
markOwned(game, species)
|
markOwned(game, species)
|
||||||
stampOT(game.save, self.enemy.mon)
|
stampOT(game.save, self.enemy.mon)
|
||||||
if isNew then
|
if isNew then
|
||||||
-- _ItemUseBallText06 + ShowPokedexData
|
-- _ItemUseBallText06 + ShowPokedexData: text_far, sound_dex_page_added,
|
||||||
self:sayNext(Strings("New POKéDEX data\nwill be added for\n%s!", self.enemy.name))
|
-- text_promptbutton (item_effects.asm:624-629), so the fanfare follows
|
||||||
|
-- the box rather than firing when the dex bit is set
|
||||||
|
self:sayNextWaitSfx(
|
||||||
|
Strings("New POKéDEX data\nwill be added for\n%s!", self.enemy.name),
|
||||||
|
function() return require("src.core.Sound").play(self.data, "Dex_Page_Added") end)
|
||||||
self:uiNext(function()
|
self:uiNext(function()
|
||||||
return self:buildScreen("DexEntryMenu", species)
|
return self:buildScreen("DexEntryMenu", species)
|
||||||
end)
|
end)
|
||||||
@@ -4648,12 +4818,10 @@ function BattleState:throwBall(ball)
|
|||||||
self:ballChain(self:tossAnimFor(ball), caught, shakes, ball)
|
self:ballChain(self:tossAnimFor(ball), caught, shakes, ball)
|
||||||
if caught then
|
if caught then
|
||||||
-- ItemUseBallText05 carries sound_caught_mon (item_effects.asm:
|
-- ItemUseBallText05 carries sound_caught_mon (item_effects.asm:
|
||||||
-- 608-614): the fanfare sounds with the caught message, before
|
-- 608-614): text_far, sound_caught_mon, text_promptbutton -- the
|
||||||
-- the prompt, not after the text is dismissed
|
-- fanfare follows the caught message and holds the prompt
|
||||||
self:actNext(function()
|
self:sayNextWaitSfx(Strings("All right!\n%s was\ncaught!", self.enemy.name),
|
||||||
require("src.core.Sound").play(self.data, "Caught_Mon")
|
function() return require("src.core.Sound").play(self.data, "Caught_Mon") end)
|
||||||
end)
|
|
||||||
self:sayNext(Strings("All right!\n%s was\ncaught!", self.enemy.name))
|
|
||||||
self:act(function() self:storeCaughtMon() end)
|
self:act(function() self:storeCaughtMon() end)
|
||||||
else
|
else
|
||||||
self:sayNext(self:ballMissMessage(shakes))
|
self:sayNext(self:ballMissMessage(shakes))
|
||||||
@@ -4674,6 +4842,7 @@ function BattleState:openParty()
|
|||||||
self:ui(function()
|
self:ui(function()
|
||||||
return self:buildScreen("PartyMenu", {
|
return self:buildScreen("PartyMenu", {
|
||||||
battle = self,
|
battle = self,
|
||||||
|
party = self:playerPartyView(),
|
||||||
onSwitch = function(mon)
|
onSwitch = function(mon)
|
||||||
if mon == self.player.mon then
|
if mon == self.player.mon then
|
||||||
self:say(Strings("%s is\nalready out!", self.player.name))
|
self:say(Strings("%s is\nalready out!", self.player.name))
|
||||||
@@ -4720,8 +4889,8 @@ function BattleState:finish()
|
|||||||
-- here it did not, so say so rather than silently papering over it.
|
-- here it did not, so say so rather than silently papering over it.
|
||||||
-- The old-man / PROF.OAK demo also skips it: the party never fought
|
-- The old-man / PROF.OAK demo also skips it: the party never fought
|
||||||
-- (Yellow's Pallet intro runs before the player owns a mon at all).
|
-- (Yellow's Pallet intro runs before the player owns a mon at all).
|
||||||
if self.result ~= "lose" and not self.demo
|
if self.kind ~= "link" and self.result ~= "lose" and not self.demo
|
||||||
and not Party.firstHealthy(self.game.save.party) then
|
and not Party.firstHealthy(self:playerPartyView()) then
|
||||||
Logger.warn("battle finished %s with no healthy party; forcing blackout",
|
Logger.warn("battle finished %s with no healthy party; forcing blackout",
|
||||||
tostring(self.result))
|
tostring(self.result))
|
||||||
self.result = "lose"
|
self.result = "lose"
|
||||||
@@ -4779,7 +4948,7 @@ end
|
|||||||
-- Party pokeball row (SetupPokeballs tiles: ball / status ball /
|
-- Party pokeball row (SetupPokeballs tiles: ball / status ball /
|
||||||
-- fainted ball / empty), 6 slots stepping dx from (x,y).
|
-- fainted ball / empty), 6 slots stepping dx from (x,y).
|
||||||
local ballQuads
|
local ballQuads
|
||||||
function BattleState:drawBallRow(party, x, y, dx)
|
local function balls()
|
||||||
if ballQuads == nil then
|
if ballQuads == nil then
|
||||||
local ok, img = pcall(love.graphics.newImage, "assets/generated/battle/balls.png")
|
local ok, img = pcall(love.graphics.newImage, "assets/generated/battle/balls.png")
|
||||||
if ok then
|
if ok then
|
||||||
@@ -4791,11 +4960,23 @@ function BattleState:drawBallRow(party, x, y, dx)
|
|||||||
ballQuads = false
|
ballQuads = false
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
if not ballQuads then return end
|
return ballQuads or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
function BattleState:drawCaughtBall(x, y)
|
||||||
|
local quads = balls()
|
||||||
|
if not quads then return end
|
||||||
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
love.graphics.draw(quads.img, quads[0], x, y)
|
||||||
|
end
|
||||||
|
|
||||||
|
function BattleState:drawBallRow(party, x, y, dx)
|
||||||
|
local quads = balls()
|
||||||
|
if not quads then return end
|
||||||
for i = 1, 6 do
|
for i = 1, 6 do
|
||||||
local mon = party[i]
|
local mon = party[i]
|
||||||
local tile = not mon and 3 or mon.hp <= 0 and 2 or mon.status and 1 or 0
|
local tile = not mon and 3 or mon.hp <= 0 and 2 or mon.status and 1 or 0
|
||||||
love.graphics.draw(ballQuads.img, ballQuads[tile], x + (i - 1) * dx, y)
|
love.graphics.draw(quads.img, quads[tile], x + (i - 1) * dx, y)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -5128,7 +5309,8 @@ function BattleState:sgbBattlePals()
|
|||||||
local function bar(b)
|
local function bar(b)
|
||||||
if not b then return pals.GREENBAR end
|
if not b then return pals.GREENBAR end
|
||||||
local hp = b.shownHP or b.mon.hp
|
local hp = b.shownHP or b.mon.hp
|
||||||
return pals[PaletteFX.barPalName(hp, b.mon.stats.hp)] or pals.GREENBAR
|
return pals[PaletteFX.barPalName(hp, b.mon.stats.hp, b.shownPx)]
|
||||||
|
or pals.GREENBAR
|
||||||
end
|
end
|
||||||
local function mon(b, placeholder)
|
local function mon(b, placeholder)
|
||||||
if placeholder or not b then return pals.MEWMON or pals.GREENBAR end
|
if placeholder or not b then return pals.MEWMON or pals.GREENBAR end
|
||||||
@@ -5511,7 +5693,12 @@ function BattleState:drawHUDs(slide)
|
|||||||
love.graphics.translate(hudShake, 0)
|
love.graphics.translate(hudShake, 0)
|
||||||
end
|
end
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
Font.draw(self.enemy.name, nameX(1, self.enemy.name), 0)
|
local enemyNameX = nameX(1, self.enemy.name)
|
||||||
|
local enemyNameWidth = Font.draw(self.enemy.name, enemyNameX, 0)
|
||||||
|
if self:caughtMarkerVisible() then
|
||||||
|
self:drawCaughtBall(enemyNameX + enemyNameWidth, 0)
|
||||||
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
|
end
|
||||||
if self.enemy.shownStatus then
|
if self.enemy.shownStatus then
|
||||||
Font.draw(self:statusLabel({ status = self.enemy.shownStatus }), 40, 8)
|
Font.draw(self:statusLabel({ status = self.enemy.shownStatus }), 40, 8)
|
||||||
else
|
else
|
||||||
@@ -5521,7 +5708,7 @@ function BattleState:drawHUDs(slide)
|
|||||||
hudTile(0x73, 8, 16)
|
hudTile(0x73, 8, 16)
|
||||||
drawHPBar(barData, 2, 2,
|
drawHPBar(barData, 2, 2,
|
||||||
{ hp = shownHP(self.enemy), stats = self.enemy.mon.stats },
|
{ hp = shownHP(self.enemy), stats = self.enemy.mon.stats },
|
||||||
nil, grayFill)
|
nil, grayFill, nil, self.enemy.shownPx)
|
||||||
hudTile(0x74, 8, 24)
|
hudTile(0x74, 8, 24)
|
||||||
for i = 2, 9 do hudTile(0x76, i * 8, 24) end
|
for i = 2, 9 do hudTile(0x76, i * 8, 24) end
|
||||||
hudTile(0x78, 80, 24)
|
hudTile(0x78, 80, 24)
|
||||||
@@ -5584,7 +5771,7 @@ function BattleState:drawHUDs(slide)
|
|||||||
for i = 10, 17 do hudTile(0x76, i * 8, 88) end
|
for i = 10, 17 do hudTile(0x76, i * 8, 88) end
|
||||||
hudTile(0x6F, 72, 88)
|
hudTile(0x6F, 72, 88)
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
self:drawBallRow(self.playerParty or self.game.save.party, 88, 80, 8)
|
self:drawBallRow(self:playerPartyView(), 88, 80, 8)
|
||||||
end
|
end
|
||||||
local hidePlayer = self.safari or self.demo
|
local hidePlayer = self.safari or self.demo
|
||||||
if showStatus and self.player and not hidePlayer and not self.showPlayerBack
|
if showStatus and self.player and not hidePlayer and not self.showPlayerBack
|
||||||
@@ -5602,7 +5789,7 @@ function BattleState:drawHUDs(slide)
|
|||||||
end
|
end
|
||||||
drawHPBar(barData, 10, 9,
|
drawHPBar(barData, 10, 9,
|
||||||
{ hp = shownHP(self.player), stats = self.player.mon.stats },
|
{ hp = shownHP(self.player), stats = self.player.mon.stats },
|
||||||
1, grayFill) -- wHPBarType 1: the $6D cap
|
1, grayFill, nil, self.player.shownPx) -- wHPBarType 1: the $6D cap
|
||||||
Font.draw(("%3d/%3d"):format(shownHP(self.player), self.player.mon.stats.hp), 88, 80)
|
Font.draw(("%3d/%3d"):format(shownHP(self.player), self.player.mon.stats.hp), 88, 80)
|
||||||
hudTile(0x73, 144, 80)
|
hudTile(0x73, 144, 80)
|
||||||
hudTile(0x77, 144, 88)
|
hudTile(0x77, 144, 88)
|
||||||
@@ -5738,6 +5925,7 @@ function BattleState:drawTextArea()
|
|||||||
Font.draw(self.data.moves[m.id].name, 16, (7 + i) * 8)
|
Font.draw(self.data.moves[m.id].name, 16, (7 + i) * 8)
|
||||||
end
|
end
|
||||||
Font.drawCode(0xED, 8, (7 + self.mimicIndex) * 8)
|
Font.drawCode(0xED, 8, (7 + self.mimicIndex) * 8)
|
||||||
|
Font.draw(Strings("WHICH TECHNIQUE?"), 8, 112)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -310,8 +310,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
|||||||
if record and record.afterDamage then
|
if record and record.afterDamage then
|
||||||
record.afterDamage(ctx, totalDealt)
|
record.afterDamage(ctx, totalDealt)
|
||||||
elseif moveInst.struggle then
|
elseif moveInst.struggle then
|
||||||
-- struggle recoils even when its effect id resolves to no record
|
local recoil = math.max(1, math.floor(totalDealt / 2))
|
||||||
local recoil = math.max(1, math.floor(dmg / 2))
|
|
||||||
battle:sayNext(romText(battle.data, "_HitWithRecoilText", "%s's\nhit with recoil!", displayName(user)))
|
battle:sayNext(romText(battle.data, "_HitWithRecoilText", "%s's\nhit with recoil!", displayName(user)))
|
||||||
battle:applyDamage(user, recoil)
|
battle:applyDamage(user, recoil)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -348,8 +348,9 @@ MoveEffects.primary = {
|
|||||||
target.disabledTurns = battle.rng(1, 8)
|
target.disabledTurns = battle.rng(1, 8)
|
||||||
local id = target.curMoves[slot].id
|
local id = target.curMoves[slot].id
|
||||||
-- _MoveWasDisabledText: "X's / MOVE was / disabled!"
|
-- _MoveWasDisabledText: "X's / MOVE was / disabled!"
|
||||||
return { romText(battle.data, "_MoveWasDisabledText", "%s's\n%s was\ndisabled!", displayName(target),
|
return { romText(battle.data, "_MoveWasDisabledText", "%s's\n%s was\ndisabled!",
|
||||||
battle.data.moves[id].name) }
|
{ TARGET = displayName(target),
|
||||||
|
["RAM:wNameBuffer"] = battle.data.moves[id].name }) }
|
||||||
end,
|
end,
|
||||||
|
|
||||||
SPLASH_EFFECT = function(battle)
|
SPLASH_EFFECT = function(battle)
|
||||||
@@ -441,11 +442,10 @@ local function hitsFrom(dist, ctx)
|
|||||||
return dist[r + 1]
|
return dist[r + 1]
|
||||||
end
|
end
|
||||||
|
|
||||||
-- drain_hp.asm halves the RAW wDamage IN PLACE (minimum 1) and heals
|
-- engine/battle/core.asm ApplyDamageToEnemyPokemon
|
||||||
-- that amount, so Counter would see the halved value
|
|
||||||
local function drainHalf(label, text)
|
local function drainHalf(label, text)
|
||||||
return function(ctx)
|
return function(ctx)
|
||||||
local heal = math.max(1, math.floor(ctx.rawDamage / 2))
|
local heal = math.max(1, math.floor(ctx.totalDealt / 2))
|
||||||
ctx.battle.lastDamage = heal
|
ctx.battle.lastDamage = heal
|
||||||
local mon = ctx.user.mon
|
local mon = ctx.user.mon
|
||||||
mon.hp = math.min(mon.stats.hp, mon.hp + heal)
|
mon.hp = math.min(mon.stats.hp, mon.hp + heal)
|
||||||
@@ -529,9 +529,8 @@ MoveEffects.full = {
|
|||||||
|
|
||||||
RECOIL_EFFECT = {
|
RECOIL_EFFECT = {
|
||||||
afterDamage = function(ctx)
|
afterDamage = function(ctx)
|
||||||
-- recoil.asm reads the RAW computed wDamage (not the HP actually
|
-- engine/battle/move_effects/recoil.asm
|
||||||
-- removed): overkill and substitute hits recoil at full strength
|
local recoil = math.max(1, math.floor(ctx.totalDealt
|
||||||
local recoil = math.max(1, math.floor(ctx.rawDamage
|
|
||||||
/ (ctx.moveInst.struggle and 2 or 4)))
|
/ (ctx.moveInst.struggle and 2 or 4)))
|
||||||
ctx.say(romText(ctx.battle.data, "_HitWithRecoilText", "%s's\nhit with recoil!", displayName(ctx.user)))
|
ctx.say(romText(ctx.battle.data, "_HitWithRecoilText", "%s's\nhit with recoil!", displayName(ctx.user)))
|
||||||
ctx.battle:applyDamage(ctx.user, recoil)
|
ctx.battle:applyDamage(ctx.user, recoil)
|
||||||
|
|||||||
@@ -243,7 +243,9 @@ function Status.beforeMove(battler, rng, battle, selectedMoveId)
|
|||||||
local shown = moves and moves[selectedMoveId] and moves[selectedMoveId].name
|
local shown = moves and moves[selectedMoveId] and moves[selectedMoveId].name
|
||||||
or tostring(selectedMoveId)
|
or tostring(selectedMoveId)
|
||||||
table.insert(msgs, romText(battle and battle.data, "_MoveIsDisabledText",
|
table.insert(msgs, romText(battle and battle.data, "_MoveIsDisabledText",
|
||||||
"%s's\n%s is\ndisabled!", name(battler), shown))
|
"%s's\n%s is\ndisabled!", {
|
||||||
|
USER = name(battler), ["RAM:wNameBuffer"] = shown,
|
||||||
|
}))
|
||||||
return false, msgs
|
return false, msgs
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
-- Shared visibility rules for battle-owned UI states. Text and choice
|
||||||
|
-- overlays live above BattleState on the state stack, but they are still part
|
||||||
|
-- of its bottom UI layer and must inherit that layer's visibility.
|
||||||
|
|
||||||
|
local Runtime = require("src.mods.Runtime")
|
||||||
|
|
||||||
|
local UIVisibility = {}
|
||||||
|
|
||||||
|
local function enclosingBattle(state)
|
||||||
|
local stack = state and state.game and state.game.stack
|
||||||
|
local states = stack and stack.states
|
||||||
|
local found = false
|
||||||
|
for i = #(states or {}), 1, -1 do
|
||||||
|
local candidate = states[i]
|
||||||
|
if candidate == state then found = true end
|
||||||
|
if found and candidate and candidate.isBattle then return candidate end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- queryState keeps the existing TextBox contract: a mod may still decide
|
||||||
|
-- visibility for that individual box. ChoiceBox only inherits the enclosing
|
||||||
|
-- battle decision, so field YES/NO prompts never become battle-hook states.
|
||||||
|
function UIVisibility.bottomVisible(state, queryState)
|
||||||
|
if not Runtime.wantsHook("battle.bottom_ui_visible") then return true end
|
||||||
|
local battle = enclosingBattle(state)
|
||||||
|
if battle and battle ~= state
|
||||||
|
and Runtime.call("battle.bottom_ui_visible",
|
||||||
|
function() return true end, battle) == false then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
if queryState or battle == state then
|
||||||
|
return Runtime.call("battle.bottom_ui_visible",
|
||||||
|
function() return true end, state) ~= false
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
return UIVisibility
|
||||||
@@ -108,7 +108,7 @@ local function drawStatusPanel(battle, battler, x, y, player)
|
|||||||
HudTiles.drawHPBar(battle.data, tx + 1, ty + 2, {
|
HudTiles.drawHPBar(battle.data, tx + 1, ty + 2, {
|
||||||
hp = shownHP(battler),
|
hp = shownHP(battler),
|
||||||
stats = battler.mon.stats,
|
stats = battler.mon.stats,
|
||||||
}, nil, monoMode(), tw - 5)
|
}, nil, monoMode(), tw - 5, battler.shownPx)
|
||||||
|
|
||||||
if player then
|
if player then
|
||||||
Font.draw(("%3d/%3d"):format(shownHP(battler), battler.mon.stats.hp),
|
Font.draw(("%3d/%3d"):format(shownHP(battler), battler.mon.stats.hp),
|
||||||
|
|||||||
+88
-18
@@ -37,8 +37,10 @@ local Prize = require("src.battle.gen2.Prize")
|
|||||||
-- * Gen 1's `user` / `target` / `battler` are battler wrappers around a mon
|
-- * Gen 1's `user` / `target` / `battler` are battler wrappers around a mon
|
||||||
-- ({ mon = , name = , isPlayer = }); Gen 2's engine works on the party mon
|
-- ({ mon = , name = , isPlayer = }); Gen 2's engine works on the party mon
|
||||||
-- table directly, so that is what these payloads carry.
|
-- table directly, so that is what these payloads carry.
|
||||||
-- * Gen 1's `rng` is love.math.random (1..n); Gen 2's injected `random` is
|
-- * Gen 1's `rng` is love.math.random (rng(n) → 1..n, rng(lo,hi) → lo..hi).
|
||||||
-- the cart's BattleRandom convention (0..n-1). Both keys are present.
|
-- Gen 2's cart BattleRandom is random(n) → 0..n-1. Both live on the
|
||||||
|
-- battle: `random` / `roller()` are BattleRandom for damage, accuracy,
|
||||||
|
-- Magnitude, etc.; `rng` is the Gen 1 / love-style view of the same stream.
|
||||||
local Runtime = require("src.mods.Runtime")
|
local Runtime = require("src.mods.Runtime")
|
||||||
-- The two battle lines that carry the cart's own `line` break: a marker-bearing
|
-- The two battle lines that carry the cart's own `line` break: a marker-bearing
|
||||||
-- literal has to stay reachable from a translation mod (#186, #245), which is
|
-- literal has to stay reachable from a translation mod (#186, #245), which is
|
||||||
@@ -88,6 +90,19 @@ local function rand(random, n)
|
|||||||
return math.random(n) - 1
|
return math.random(n) - 1
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Gen 1 / love.math view of BattleRandom: rng(n) → 1..n, rng(lo,hi) → lo..hi.
|
||||||
|
local function loveStyleRng(random)
|
||||||
|
return function(lo, hi)
|
||||||
|
if hi == nil then
|
||||||
|
local n = lo or 1
|
||||||
|
if n < 1 then n = 1 end
|
||||||
|
return (rand(random, n) or 0) + 1
|
||||||
|
end
|
||||||
|
if hi < lo then lo, hi = hi, lo end
|
||||||
|
return lo + (rand(random, hi - lo + 1) or 0)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
-- data/trainers/leaders.asm. The two lists are ONE array in the ROM: only
|
-- data/trainers/leaders.asm. The two lists are ONE array in the ROM: only
|
||||||
-- KantoGymLeaders carries the -1 terminator, and GymLeaders falls through into
|
-- KantoGymLeaders carries the -1 terminator, and GymLeaders falls through into
|
||||||
-- it, so IsGymLeader matches all twenty-two classes while IsKantoGymLeader
|
-- it, so IsGymLeader matches all twenty-two classes while IsKantoGymLeader
|
||||||
@@ -212,12 +227,15 @@ Battle.KANTO_BADGE_ORDER = {
|
|||||||
-- this wild battle is BATTLETYPE_ROAMING; the caller built `wild`
|
-- this wild battle is BATTLETYPE_ROAMING; the caller built `wild`
|
||||||
-- through Roamers.beginBattle and reads Battle.roaming back to
|
-- through Roamers.beginBattle and reads Battle.roaming back to
|
||||||
-- bank the beast's HP afterwards
|
-- bank the beast's HP afterwards
|
||||||
-- random(n) 0..n-1, injected so a test is deterministic
|
-- random(n) 0..n-1, injected so a test is deterministic (BattleRandom)
|
||||||
|
-- rng(lo,hi) / rng(n) Gen 1 / love.math contract; defaults over `random`
|
||||||
function Battle.new(opts)
|
function Battle.new(opts)
|
||||||
opts = opts or {}
|
opts = opts or {}
|
||||||
local self = setmetatable({}, Battle)
|
local self = setmetatable({}, Battle)
|
||||||
self.data = opts.data or {}
|
self.data = opts.data or {}
|
||||||
self.random = opts.random
|
self.random = opts.random or function(n) return rand(nil, n) end
|
||||||
|
-- Same stream as `random`, Gen 1 / love.math calling convention.
|
||||||
|
self.rng = opts.rng or loveStyleRng(self.random)
|
||||||
self.party = opts.party or {}
|
self.party = opts.party or {}
|
||||||
self.trainer = opts.trainer
|
self.trainer = opts.trainer
|
||||||
self.save = opts.save
|
self.save = opts.save
|
||||||
@@ -271,6 +289,13 @@ function Battle.new(opts)
|
|||||||
self.enemy = self.enemyParty[self.enemyIndex]
|
self.enemy = self.enemyParty[self.enemyIndex]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
for _, mon in ipairs(self.party) do
|
||||||
|
Mon.refreshStats(mon, self.data)
|
||||||
|
end
|
||||||
|
for _, mon in ipairs(self.enemyParty or {}) do
|
||||||
|
Mon.refreshStats(mon, self.data)
|
||||||
|
end
|
||||||
|
|
||||||
-- Battle RAM opens empty on both sides: NewBattleMonStatus and
|
-- Battle RAM opens empty on both sides: NewBattleMonStatus and
|
||||||
-- NewEnemyMonStatus run at the first send-out of every battle.
|
-- NewEnemyMonStatus run at the first send-out of every battle.
|
||||||
self:clearAllVolatiles()
|
self:clearAllVolatiles()
|
||||||
@@ -396,11 +421,8 @@ function Battle:endBattle(outcome)
|
|||||||
Runtime.emit("battle.ended", { battle = self, result = outcome })
|
Runtime.emit("battle.ended", { battle = self, result = outcome })
|
||||||
end
|
end
|
||||||
|
|
||||||
-- A never-nil 0..n-1 roller for the hook contexts. `random` is optional on the
|
-- Never-nil BattleRandom (0..n-1) for call sites that want the cart byte.
|
||||||
-- constructor (a headless test injects one, the game leaves it to love.math),
|
-- `battle.rng` is the Gen 1 / love.math view of the same stream.
|
||||||
-- and a mod reaching for ctx.rng must not have to know that. Note the
|
|
||||||
-- convention: this is the cart's BattleRandom byte (0..n-1), NOT Gen 1's
|
|
||||||
-- love.math.random (1..n).
|
|
||||||
function Battle:roller()
|
function Battle:roller()
|
||||||
if not self.rollerFn then
|
if not self.rollerFn then
|
||||||
self.rollerFn = function(n) return rand(self.random, n) end
|
self.rollerFn = function(n) return rand(self.random, n) end
|
||||||
@@ -1246,14 +1268,14 @@ function Battle:dealDamage(attacker, defender, damage, opts)
|
|||||||
return damage
|
return damage
|
||||||
end
|
end
|
||||||
|
|
||||||
function Battle:heal(mon, amount)
|
function Battle:heal(mon, amount, opts)
|
||||||
local maxHp = mon.maxHp or (mon.stats and mon.stats.hp) or 1
|
local maxHp = mon.maxHp or (mon.stats and mon.stats.hp) or 1
|
||||||
local before = mon.hp or 0
|
local before = mon.hp or 0
|
||||||
mon.hp = math.min(maxHp, before + math.max(0, math.floor(amount or 0)))
|
mon.hp = math.min(maxHp, before + math.max(0, math.floor(amount or 0)))
|
||||||
local healed = mon.hp - before
|
local healed = mon.hp - before
|
||||||
if healed > 0 then
|
if healed > 0 then
|
||||||
self:emit({ kind = "heal", side = self:sideOf(mon), amount = healed,
|
self:emit({ kind = "heal", side = self:sideOf(mon), amount = healed,
|
||||||
hp = mon.hp })
|
hp = mon.hp, anim = opts and opts.anim })
|
||||||
end
|
end
|
||||||
return healed
|
return healed
|
||||||
end
|
end
|
||||||
@@ -1597,9 +1619,25 @@ function Battle:useMove(attacker, defender, moveId)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Damage that skips the formula entirely.
|
-- Damage that skips the formula entirely. The move's own power goes with
|
||||||
local fixed = Effects.fixedDamage(def.effect, attacker, defender, self.random)
|
-- it: EFFECT_STATIC_DAMAGE's arm of BattleCommand_ConstantDamage reads
|
||||||
|
-- BATTLE_VARS_MOVE_POWER as the damage (effect_commands.asm:3157-3161).
|
||||||
|
local fixed = Effects.fixedDamage(def.effect, attacker, defender, self.random,
|
||||||
|
def.power)
|
||||||
if fixed then
|
if fixed then
|
||||||
|
-- The constant-damage effect list carries `resettypematchup` instead of
|
||||||
|
-- `stab`, and that command misses the move outright when the matchup byte
|
||||||
|
-- is 0 (effect_commands.asm:1480-1493) -- an immune target is the one
|
||||||
|
-- thing that stops SONIC BOOM, NIGHT SHADE or SUPER FANG.
|
||||||
|
local defenderTypes = (self:speciesDef(defender) or {}).types
|
||||||
|
or defender.types
|
||||||
|
local matchups = self.data.type_chart and self.data.type_chart.matchups
|
||||||
|
if Damage.typeMultiplier(def.type, defenderTypes, matchups) == 0 then
|
||||||
|
self:markMissed()
|
||||||
|
self:emit({ kind = "message",
|
||||||
|
text = "It doesn't affect " .. self:monName(defender) .. "..." })
|
||||||
|
return
|
||||||
|
end
|
||||||
self:dealDamage(attacker, defender, fixed, { move = def, moveId = moveId })
|
self:dealDamage(attacker, defender, fixed, { move = def, moveId = moveId })
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -1655,7 +1693,7 @@ function Battle:useMove(attacker, defender, moveId)
|
|||||||
state.rolloutLock = nil
|
state.rolloutLock = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
local hits = Effects.hitCount(def.effect, self.random)
|
local hits = Effects.hitCount(def.effect, self:roller())
|
||||||
local landed = 0
|
local landed = 0
|
||||||
for hit = 1, hits do
|
for hit = 1, hits do
|
||||||
if (defender.hp or 0) <= 0 then break end
|
if (defender.hp or 0) <= 0 then break end
|
||||||
@@ -2600,6 +2638,35 @@ Battle.MOVE_EFFECTS.EFFECT_FORCE_SWITCH = function(self, attacker, defender,
|
|||||||
self.forcedSwitch = true
|
self.forcedSwitch = true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- BattleCommand_Teleport (engine/battle/move_effects/teleport.asm). Fails
|
||||||
|
-- outright for BATTLETYPE_FORCESHINY/TRAP, for a trapped user, and in any
|
||||||
|
-- TRAINER battle; in a WILD battle the level ladder is identical to
|
||||||
|
-- EFFECT_FORCE_SWITCH's. Without an entry here TELEPORT fell through to
|
||||||
|
-- the (0-power) damage path and never ended the battle.
|
||||||
|
Battle.MOVE_EFFECTS.EFFECT_TELEPORT = function(self, attacker, defender)
|
||||||
|
if self.battleType == Battle.BATTLETYPE_FORCESHINY
|
||||||
|
or self.battleType == Battle.BATTLETYPE_TRAP
|
||||||
|
or self:volatile(defender).trapsTarget then
|
||||||
|
return fail(self)
|
||||||
|
end
|
||||||
|
if not self.wild then return fail(self) end
|
||||||
|
|
||||||
|
local userLevel = attacker.level or 1
|
||||||
|
local targetLevel = defender.level or 1
|
||||||
|
local succeeds = userLevel >= targetLevel
|
||||||
|
if not succeeds then
|
||||||
|
local roll = self:rollBelow(math.min(256, userLevel + targetLevel + 1))
|
||||||
|
succeeds = roll >= math.floor(targetLevel / 4)
|
||||||
|
end
|
||||||
|
if not succeeds then return fail(self) end
|
||||||
|
|
||||||
|
self.over = true
|
||||||
|
self.outcome = "fled"
|
||||||
|
self.forcedSwitch = true
|
||||||
|
self:emit({ kind = "run", side = self:sideOf(attacker),
|
||||||
|
text = self:monName(attacker) .. " fled from battle!" })
|
||||||
|
end
|
||||||
|
|
||||||
-- -------------------------------------------------------- the move effects
|
-- -------------------------------------------------------- the move effects
|
||||||
--
|
--
|
||||||
-- The three tables above as records, in the shape src/mods/Schemas.lua's
|
-- The three tables above as records, in the shape src/mods/Schemas.lua's
|
||||||
@@ -3149,7 +3216,8 @@ function Battle:giveExperiencePass(loser, def, recipients, count, halved)
|
|||||||
-- ChangeHappiness is outside the level loop.
|
-- ChangeHappiness is outside the level loop.
|
||||||
Happiness.change(mon, "GAINLEVEL")
|
Happiness.change(mon, "GAINLEVEL")
|
||||||
self:emit({ kind = "level", index = index, level = mon.level,
|
self:emit({ kind = "level", index = index, level = mon.level,
|
||||||
text = self:monName(mon) .. " grew to level " .. mon.level .. "!" })
|
text = self:monName(mon) .. " grew to level " .. mon.level .. "!",
|
||||||
|
sfx = "Sfx_DexFanfare5079", waitSfx = true })
|
||||||
for _, moveId in ipairs(result.learned) do
|
for _, moveId in ipairs(result.learned) do
|
||||||
local ok, reason, entry = Mon.learnMove(mon, moveId, self.data)
|
local ok, reason, entry = Mon.learnMove(mon, moveId, self.data)
|
||||||
local moveDef = self:moveDef(moveId)
|
local moveDef = self:moveDef(moveId)
|
||||||
@@ -3615,7 +3683,7 @@ end
|
|||||||
-- Running: Gen 2's odds (engine/battle/core.asm TryToRunAwayFromBattle) are
|
-- Running: Gen 2's odds (engine/battle/core.asm TryToRunAwayFromBattle) are
|
||||||
-- based on the speed ratio and how many times you have tried this battle.
|
-- based on the speed ratio and how many times you have tried this battle.
|
||||||
-- Trainers never let you run.
|
-- Trainers never let you run.
|
||||||
function Battle:tryRun()
|
function Battle:tryRun(pSpd)
|
||||||
-- .cant_escape and .cant_run_from_trainer leave wBattlePlayerAction alone,
|
-- .cant_escape and .cant_run_from_trainer leave wBattlePlayerAction alone,
|
||||||
-- which is what BattleMenu_Run reads to decide whether the turn was spent
|
-- which is what BattleMenu_Run reads to decide whether the turn was spent
|
||||||
-- (engine/battle/core.asm:5035); only .cant_escape_2, the failed roll at the
|
-- (engine/battle/core.asm:5035); only .cant_escape_2, the failed roll at the
|
||||||
@@ -3647,7 +3715,8 @@ function Battle:tryRun()
|
|||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
self.runAttempts = (self.runAttempts or 0) + 1
|
self.runAttempts = (self.runAttempts or 0) + 1
|
||||||
if self:runRoll(self:effectiveSpeed(self.player),
|
-- engine/battle/core.asm:2614
|
||||||
|
if self:runRoll(pSpd or self:effectiveSpeed(self.player),
|
||||||
self:effectiveSpeed(self.enemy)) then
|
self:effectiveSpeed(self.enemy)) then
|
||||||
self:emit({ kind = "run", text = "Got away safely!" })
|
self:emit({ kind = "run", text = "Got away safely!" })
|
||||||
self:endBattle("run")
|
self:endBattle("run")
|
||||||
@@ -4442,7 +4511,8 @@ function Battle:tickHeldItem(mon)
|
|||||||
end
|
end
|
||||||
|
|
||||||
if effect == "HELD_BERRY" and (mon.hp or 0) * 2 <= maxHp then
|
if effect == "HELD_BERRY" and (mon.hp or 0) * 2 <= maxHp then
|
||||||
self:heal(mon, parameter > 0 and parameter or 10)
|
-- pokegold engine/battle/core.asm:4074 ItemRecoveryAnim
|
||||||
|
self:heal(mon, parameter > 0 and parameter or 10, { anim = "RECOVER" })
|
||||||
mon.item = nil
|
mon.item = nil
|
||||||
self:emit({ kind = "message",
|
self:emit({ kind = "message",
|
||||||
text = name .. " ate the " .. (def.name or "BERRY") .. "!" })
|
text = name .. " ate the " .. (def.name or "BERRY") .. "!" })
|
||||||
|
|||||||
+38
-13
@@ -100,21 +100,28 @@ end
|
|||||||
-- are 1/8 each, which is what the `and 3` on a 0-3 roll plus the two-step
|
-- are 1/8 each, which is what the `and 3` on a 0-3 roll plus the two-step
|
||||||
-- fallthrough in .DetermineNumberOfHits produces.
|
-- fallthrough in .DetermineNumberOfHits produces.
|
||||||
function Effects.multiHitCount(random)
|
function Effects.multiHitCount(random)
|
||||||
local roll = random and random(4) or 0
|
local function roll(n)
|
||||||
if roll < 2 then return roll + 2 end
|
if random then return random(n) end
|
||||||
-- Hits 4 and 5 take a second roll, so each ends up half as likely.
|
if love and love.math and love.math.random then
|
||||||
local second = random and random(2) or 0
|
return love.math.random(n) - 1
|
||||||
return second + 4
|
end
|
||||||
|
return math.random(n) - 1
|
||||||
|
end
|
||||||
|
-- engine/battle/effect_commands.asm:5228
|
||||||
|
local first = roll(4)
|
||||||
|
if first < 2 then return first + 2 end
|
||||||
|
return roll(4) + 2
|
||||||
end
|
end
|
||||||
|
|
||||||
Effects.HIT_COUNTS = {
|
Effects.HIT_COUNTS = {
|
||||||
EFFECT_DOUBLE_HIT = 2,
|
EFFECT_DOUBLE_HIT = 2,
|
||||||
|
EFFECT_POISON_MULTI_HIT = 2,
|
||||||
-- Triple Kick stops early if a hit misses; Battle rolls that per hit.
|
-- Triple Kick stops early if a hit misses; Battle rolls that per hit.
|
||||||
EFFECT_TRIPLE_KICK = 3,
|
EFFECT_TRIPLE_KICK = 3,
|
||||||
}
|
}
|
||||||
|
|
||||||
function Effects.hitCount(effect, random)
|
function Effects.hitCount(effect, random)
|
||||||
if effect == "EFFECT_MULTI_HIT" or effect == "EFFECT_POISON_MULTI_HIT" then
|
if effect == "EFFECT_MULTI_HIT" then
|
||||||
return Effects.multiHitCount(random)
|
return Effects.multiHitCount(random)
|
||||||
end
|
end
|
||||||
return Effects.HIT_COUNTS[effect] or 1
|
return Effects.HIT_COUNTS[effect] or 1
|
||||||
@@ -172,9 +179,9 @@ end
|
|||||||
|
|
||||||
-- --------------------------------------------------------------- fixed damage
|
-- --------------------------------------------------------------- fixed damage
|
||||||
|
|
||||||
-- BattleCommand_LevelDamage / SuperFang / Psywave, all of which skip the
|
-- BattleCommand_ConstantDamage (engine/battle/effect_commands.asm:3131-3205),
|
||||||
-- damage formula entirely.
|
-- one command for the whole SuperFang / Psywave / StaticDamage list.
|
||||||
function Effects.fixedDamage(effect, attacker, defender, random)
|
function Effects.fixedDamage(effect, attacker, defender, random, power)
|
||||||
if effect == "EFFECT_LEVEL_DAMAGE" then
|
if effect == "EFFECT_LEVEL_DAMAGE" then
|
||||||
return math.max(1, attacker.level or 1)
|
return math.max(1, attacker.level or 1)
|
||||||
end
|
end
|
||||||
@@ -182,9 +189,17 @@ function Effects.fixedDamage(effect, attacker, defender, random)
|
|||||||
return math.max(1, math.floor((defender.hp or 1) / 2))
|
return math.max(1, math.floor((defender.hp or 1) / 2))
|
||||||
end
|
end
|
||||||
if effect == "EFFECT_PSYWAVE" then
|
if effect == "EFFECT_PSYWAVE" then
|
||||||
-- 1..(level * 1.5), rerolled until it is in range; one roll is enough here.
|
-- .psywave rerolls until the byte is nonzero AND below level * 1.5, so the
|
||||||
local ceiling = math.max(1, math.floor((attacker.level or 1) * 3 / 2))
|
-- top of the range is that ceiling minus one (effect_commands.asm:3163).
|
||||||
return math.max(1, (random and random(ceiling) or 0) + 1)
|
local ceiling = math.max(2, math.floor((attacker.level or 1) * 3 / 2))
|
||||||
|
return math.max(1, (random and random(ceiling - 1) or 0) + 1)
|
||||||
|
end
|
||||||
|
-- SONIC BOOM and DRAGON RAGE share EFFECT_STATIC_DAMAGE, whose arm reads
|
||||||
|
-- BATTLE_VARS_MOVE_POWER straight into the damage word: their stored power
|
||||||
|
-- (20 and 40) IS the damage, never a formula input
|
||||||
|
-- (effect_commands.asm:3157-3161).
|
||||||
|
if effect == "EFFECT_STATIC_DAMAGE" then
|
||||||
|
return math.max(1, math.floor(power or 0))
|
||||||
end
|
end
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
@@ -250,8 +265,18 @@ Effects.MAGNITUDE_POWER = {
|
|||||||
-- is what damagecalc reads as the move's power -- data/moves/moves.asm stores
|
-- is what damagecalc reads as the move's power -- data/moves/moves.asm stores
|
||||||
-- MAGNITUDE at power 1 precisely because this overwrites it. Returns the
|
-- MAGNITUDE at power 1 precisely because this overwrites it. Returns the
|
||||||
-- power and the magnitude number the text prints.
|
-- power and the magnitude number the text prints.
|
||||||
|
--
|
||||||
|
-- `random` is BattleRandom (0..n-1). If none is supplied, roll via love.math
|
||||||
|
-- / math.random — never hard-code 0 (that always yields Magnitude 4).
|
||||||
function Effects.magnitudePower(random)
|
function Effects.magnitudePower(random)
|
||||||
local roll = random and random(256) or 0
|
local roll
|
||||||
|
if type(random) == "function" then
|
||||||
|
roll = random(256) or 0
|
||||||
|
elseif love and love.math and love.math.random then
|
||||||
|
roll = love.math.random(256) - 1
|
||||||
|
else
|
||||||
|
roll = math.random(256) - 1
|
||||||
|
end
|
||||||
for _, row in ipairs(Effects.MAGNITUDE_POWER) do
|
for _, row in ipairs(Effects.MAGNITUDE_POWER) do
|
||||||
if row[1] >= roll then return row[2], row[3] end
|
if row[1] >= roll then return row[2], row[3] end
|
||||||
end
|
end
|
||||||
|
|||||||
+85
-3
@@ -68,7 +68,16 @@ function Mon.stats(baseStats, dvs, level, statExp)
|
|||||||
baseStats = baseStats or {}
|
baseStats = baseStats or {}
|
||||||
dvs = dvs or {}
|
dvs = dvs or {}
|
||||||
statExp = statExp or {}
|
statExp = statExp or {}
|
||||||
local hpDv = dvs.hp or Mon.hpDV(dvs)
|
-- engine/pokemon/move_mon.asm:1540
|
||||||
|
local specialDv = dvs.special
|
||||||
|
if specialDv == nil then
|
||||||
|
specialDv = dvs.specialAttack or dvs.specialDefense
|
||||||
|
end
|
||||||
|
-- engine/pokemon/move_mon.asm:1496
|
||||||
|
local hpDv = Mon.hpDV({
|
||||||
|
attack = dvs.attack, defense = dvs.defense,
|
||||||
|
speed = dvs.speed, special = specialDv,
|
||||||
|
})
|
||||||
local hp = math.floor((((baseStats.hp or 1) * 2 + hpDv * 2
|
local hp = math.floor((((baseStats.hp or 1) * 2 + hpDv * 2
|
||||||
+ math.floor(math.sqrt(statExp.hp or 0) / 4)) * level) / 100)
|
+ math.floor(math.sqrt(statExp.hp or 0) / 4)) * level) / 100)
|
||||||
+ level + 10
|
+ level + 10
|
||||||
@@ -82,13 +91,86 @@ function Mon.stats(baseStats, dvs, level, statExp)
|
|||||||
-- box_struct ends them at SpcExp), so SpA and SpD grow together. The
|
-- box_struct ends them at SpcExp), so SpA and SpD grow together. The
|
||||||
-- per-stat keys are still read as a fallback for a record written before
|
-- per-stat keys are still read as a fallback for a record written before
|
||||||
-- the shared word existed.
|
-- the shared word existed.
|
||||||
specialAttack = statValue(baseStats.specialAttack, dvs.special, level,
|
specialAttack = statValue(baseStats.specialAttack, specialDv, level,
|
||||||
statExp.special or statExp.specialAttack),
|
statExp.special or statExp.specialAttack),
|
||||||
specialDefense = statValue(baseStats.specialDefense, dvs.special, level,
|
specialDefense = statValue(baseStats.specialDefense, specialDv, level,
|
||||||
statExp.special or statExp.specialDefense),
|
statExp.special or statExp.specialDefense),
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- The species string is the source of truth. `mon.name` is a copy of that
|
||||||
|
-- species' display name (GetPokemonName), kept so menus can print without a
|
||||||
|
-- Data lookup. It is NOT the nickname: an un-nicknamed mon has nickname nil
|
||||||
|
-- and prints this copy. Changing species without rewriting it leaves the
|
||||||
|
-- previous species' name on the party list and the SUMMARY's top line.
|
||||||
|
function Mon.syncIdentity(mon, data)
|
||||||
|
if type(mon) ~= "table" then return mon end
|
||||||
|
local def = data and data.pokemon and data.pokemon[mon.species]
|
||||||
|
if not def then return mon end
|
||||||
|
mon.name = def.name or mon.species
|
||||||
|
if def.types then mon.types = def.types end
|
||||||
|
if mon.dvs then
|
||||||
|
mon.gender = Mon.gender(def, mon.dvs,
|
||||||
|
{ species = mon.species, level = mon.level })
|
||||||
|
mon.shiny = Mon.isShiny(mon.dvs,
|
||||||
|
{ species = mon.species, def = def, level = mon.level })
|
||||||
|
if mon.species == Unown.SPECIES then
|
||||||
|
mon.unownLetter = Unown.letterFromDVs(mon.dvs)
|
||||||
|
else
|
||||||
|
mon.unownLetter = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return mon
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Every screen that prints a mon without a Data lookup should go through here:
|
||||||
|
-- nickname if the player set one, otherwise the species display copy `name`.
|
||||||
|
-- Skipping `name` and jumping to `species` is how a swapped mon can still
|
||||||
|
-- read as ABRA on one menu and RAYQUAZA on another.
|
||||||
|
function Mon.displayName(mon)
|
||||||
|
if type(mon) ~= "table" then return "?" end
|
||||||
|
return mon.nickname or mon.name or mon.species or "?"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Party, boxes, both Day-Care sides, and a pending egg. Editor CONTINUE and
|
||||||
|
-- hydrate have to walk the same set: leaving dayCare.man.mon on the old
|
||||||
|
-- `name` is the ABRA bug in a second closet.
|
||||||
|
function Mon.eachSaveMon(save, fn)
|
||||||
|
if type(save) ~= "table" or type(fn) ~= "function" then return end
|
||||||
|
for _, mon in ipairs(save.party or {}) do fn(mon) end
|
||||||
|
for _, box in pairs(save.boxes or {}) do
|
||||||
|
if type(box) == "table" then
|
||||||
|
for _, mon in ipairs(box) do fn(mon) end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local dc = save.dayCare
|
||||||
|
if type(dc) == "table" then
|
||||||
|
if dc.man and dc.man.mon then fn(dc.man.mon) end
|
||||||
|
if dc.lady and dc.lady.mon then fn(dc.lady.mon) end
|
||||||
|
if dc.egg then fn(dc.egg) end
|
||||||
|
end
|
||||||
|
if save.daycare and save.daycare.mon then fn(save.daycare.mon) end
|
||||||
|
end
|
||||||
|
|
||||||
|
function Mon.syncSaveIdentity(save, data)
|
||||||
|
Mon.eachSaveMon(save, function(mon) Mon.syncIdentity(mon, data) end)
|
||||||
|
end
|
||||||
|
|
||||||
|
function Mon.refreshStats(mon, data)
|
||||||
|
if type(mon) ~= "table" then return mon end
|
||||||
|
local def = data and data.pokemon and data.pokemon[mon.species]
|
||||||
|
if not (def and def.baseStats) then return mon end
|
||||||
|
Mon.syncIdentity(mon, data)
|
||||||
|
-- engine/pokemon/move_mon.asm:1402
|
||||||
|
local stats = Mon.stats(def.baseStats, mon.dvs, mon.level or 1, mon.statExp)
|
||||||
|
mon.stats = stats
|
||||||
|
mon.maxHp = stats.hp
|
||||||
|
if mon.hp == nil or mon.hp > stats.hp then
|
||||||
|
mon.hp = stats.hp
|
||||||
|
end
|
||||||
|
return mon
|
||||||
|
end
|
||||||
|
|
||||||
-- The five stat exp words, in struct order. There is no sixth: see Mon.stats.
|
-- The five stat exp words, in struct order. There is no sixth: see Mon.stats.
|
||||||
Mon.STAT_EXP_ORDER = { "hp", "attack", "defense", "speed", "special" }
|
Mon.STAT_EXP_ORDER = { "hp", "attack", "defense", "speed", "special" }
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
local BattleCheckpoint = {}
|
local BattleCheckpoint = {}
|
||||||
local BattleState = require("src.battle.BattleState")
|
local BattleState = require("src.battle.BattleState")
|
||||||
|
local ScriptRunner = require("src.script.ScriptRunner")
|
||||||
local BUILTIN_RULESETS = {
|
local BUILTIN_RULESETS = {
|
||||||
gen1_faithful = require("src.battle.rulesets.gen1_faithful"),
|
gen1_faithful = require("src.battle.rulesets.gen1_faithful"),
|
||||||
modern_clean = require("src.battle.rulesets.modern_clean"),
|
modern_clean = require("src.battle.rulesets.modern_clean"),
|
||||||
@@ -43,6 +44,7 @@ local BATTLE_FIELDS = {
|
|||||||
"sideToxic", "isGymLeader", "musicKind", "lastBall", "lockedBall",
|
"sideToxic", "isGymLeader", "musicKind", "lastBall", "lockedBall",
|
||||||
"lowHealthAlarmDisabled", "lowHealthAlarmOn", "victoryMusicPlayed",
|
"lowHealthAlarmDisabled", "lowHealthAlarmOn", "victoryMusicPlayed",
|
||||||
"endBattleText",
|
"endBattleText",
|
||||||
|
"playerPartyIndices",
|
||||||
}
|
}
|
||||||
|
|
||||||
local function partyIndex(party, mon)
|
local function partyIndex(party, mon)
|
||||||
@@ -90,6 +92,23 @@ local function integer(value, min, max)
|
|||||||
and value >= (min or -math.huge) and value <= (max or math.huge)
|
and value >= (min or -math.huge) and value <= (max or math.huge)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function exactIndexSet(indices, maxIndex, requireMember)
|
||||||
|
if type(indices) ~= "table" then return nil end
|
||||||
|
local count, keys = #indices, 0
|
||||||
|
for key in pairs(indices) do
|
||||||
|
keys = keys + 1
|
||||||
|
if not integer(key, 1, count) then return nil end
|
||||||
|
end
|
||||||
|
if keys ~= count or (requireMember and count == 0) then return nil end
|
||||||
|
local seen = {}
|
||||||
|
for i = 1, count do
|
||||||
|
local index = indices[i]
|
||||||
|
if not integer(index, 1, maxIndex) or seen[index] then return nil end
|
||||||
|
seen[index] = true
|
||||||
|
end
|
||||||
|
return seen
|
||||||
|
end
|
||||||
|
|
||||||
local function validateMoveList(data, moves)
|
local function validateMoveList(data, moves)
|
||||||
if type(moves) ~= "table" then return false end
|
if type(moves) ~= "table" then return false end
|
||||||
for _, move in ipairs(moves) do
|
for _, move in ipairs(moves) do
|
||||||
@@ -158,7 +177,9 @@ function BattleCheckpoint.validate(game, checkpoint)
|
|||||||
end
|
end
|
||||||
local expectedOrigin = model.kind == "wild" and "wild_encounter"
|
local expectedOrigin = model.kind == "wild" and "wild_encounter"
|
||||||
or model.kind == "trainer" and "trainer_encounter" or nil
|
or model.kind == "trainer" and "trainer_encounter" or nil
|
||||||
if not expectedOrigin or model.origin.kind ~= expectedOrigin
|
local scripted = model.origin.kind == "script_battle"
|
||||||
|
if not expectedOrigin
|
||||||
|
or (model.origin.kind ~= expectedOrigin and not scripted)
|
||||||
or model.origin.map ~= checkpoint.runtime.overworld.map then
|
or model.origin.map ~= checkpoint.runtime.overworld.map then
|
||||||
return nil, "battle_origin_unsupported",
|
return nil, "battle_origin_unsupported",
|
||||||
"Battle continuation data is unsupported or inconsistent."
|
"Battle continuation data is unsupported or inconsistent."
|
||||||
@@ -167,7 +188,27 @@ function BattleCheckpoint.validate(game, checkpoint)
|
|||||||
or type(rulesets(game)[model.rulesetId]) ~= "table" then
|
or type(rulesets(game)[model.rulesetId]) ~= "table" then
|
||||||
return nil, "invalid_content", "Battle ruleset is unavailable."
|
return nil, "invalid_content", "Battle ruleset is unavailable."
|
||||||
end
|
end
|
||||||
if model.kind == "trainer" and (type(model.origin.npcId) ~= "string"
|
if scripted then
|
||||||
|
local origin = model.origin
|
||||||
|
local row = type(origin.script) == "table" and origin.script[origin.pc]
|
||||||
|
local allowed = { start_battle = true, static_battle = true, rival_battle = true }
|
||||||
|
if type(origin.pc) ~= "number" or origin.pc % 1 ~= 0
|
||||||
|
or type(row) ~= "table" or row[1] ~= origin.command
|
||||||
|
or not allowed[origin.command] or origin.battleKind ~= model.kind
|
||||||
|
or (model.kind == "trainer" and (origin.trainerClass ~= model.oppClass
|
||||||
|
or origin.partyIndex ~= (model.partyIndex or 1)))
|
||||||
|
or (model.kind == "wild" and (origin.wildSpecies ~= model.enemyMon.species
|
||||||
|
or origin.wildLevel ~= model.enemyMon.level))
|
||||||
|
or (origin.npcId ~= nil and type(origin.npcId) ~= "string") then
|
||||||
|
return nil, "battle_origin_unsupported",
|
||||||
|
"Script battle continuation data is incomplete or inconsistent."
|
||||||
|
end
|
||||||
|
local problems = ScriptRunner.validate(origin.script)
|
||||||
|
if #problems > 0 then
|
||||||
|
return nil, "battle_origin_unsupported",
|
||||||
|
"Script battle continuation commands are unavailable."
|
||||||
|
end
|
||||||
|
elseif model.kind == "trainer" and (type(model.origin.npcId) ~= "string"
|
||||||
or model.origin.trainerClass ~= model.oppClass
|
or model.origin.trainerClass ~= model.oppClass
|
||||||
or model.origin.partyIndex ~= (model.partyIndex or 1)) then
|
or model.origin.partyIndex ~= (model.partyIndex or 1)) then
|
||||||
return nil, "battle_origin_unsupported",
|
return nil, "battle_origin_unsupported",
|
||||||
@@ -177,6 +218,16 @@ function BattleCheckpoint.validate(game, checkpoint)
|
|||||||
if type(party) ~= "table" or not validateBattler(game.data, model.player, #party) then
|
if type(party) ~= "table" or not validateBattler(game.data, model.player, #party) then
|
||||||
return nil, "invalid_content", "Player battle state is invalid."
|
return nil, "invalid_content", "Player battle state is invalid."
|
||||||
end
|
end
|
||||||
|
local scopedIndices
|
||||||
|
if model.playerPartyIndices ~= nil then
|
||||||
|
if model.kind ~= "trainer" then
|
||||||
|
return nil, "invalid_checkpoint", "Battle party scope is invalid."
|
||||||
|
end
|
||||||
|
scopedIndices = exactIndexSet(model.playerPartyIndices, #party, true)
|
||||||
|
if not scopedIndices or not scopedIndices[model.player.index] then
|
||||||
|
return nil, "invalid_checkpoint", "Battle party scope is invalid."
|
||||||
|
end
|
||||||
|
end
|
||||||
if model.kind == "wild" then
|
if model.kind == "wild" then
|
||||||
if not validateMon(game.data, model.enemyMon)
|
if not validateMon(game.data, model.enemyMon)
|
||||||
or not validateBattler(game.data, model.enemy, 1) then
|
or not validateBattler(game.data, model.enemy, 1) then
|
||||||
@@ -197,12 +248,15 @@ function BattleCheckpoint.validate(game, checkpoint)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
for _, indices in ipairs({ model.participants, model.leveledUp }) do
|
for _, indices in ipairs({ model.participants, model.leveledUp }) do
|
||||||
if type(indices) ~= "table" then
|
local referenced = exactIndexSet(indices, #party, false)
|
||||||
|
if not referenced then
|
||||||
return nil, "invalid_checkpoint", "Battle party reference set is missing."
|
return nil, "invalid_checkpoint", "Battle party reference set is missing."
|
||||||
end
|
end
|
||||||
for _, index in ipairs(indices) do
|
if scopedIndices then
|
||||||
if not integer(index, 1, #party) then
|
for index in pairs(referenced) do
|
||||||
return nil, "invalid_checkpoint", "Battle party reference is invalid."
|
if not scopedIndices[index] then
|
||||||
|
return nil, "invalid_checkpoint", "Battle party reference is invalid."
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -253,7 +307,9 @@ function BattleCheckpoint.restore(game, checkpoint, copy)
|
|||||||
local model = checkpoint.runtime.battle
|
local model = checkpoint.runtime.battle
|
||||||
local battle
|
local battle
|
||||||
if model.kind == "trainer" then
|
if model.kind == "trainer" then
|
||||||
battle = BattleState.newTrainer(game, model.oppClass, model.partyIndex)
|
battle = BattleState.newTrainer(game, model.oppClass, model.partyIndex, {
|
||||||
|
playerPartyIndices = model.playerPartyIndices,
|
||||||
|
})
|
||||||
battle.enemyParty = assert(copy(model.enemyParty))
|
battle.enemyParty = assert(copy(model.enemyParty))
|
||||||
battle.enemyIndex = model.enemyIndex
|
battle.enemyIndex = model.enemyIndex
|
||||||
else
|
else
|
||||||
@@ -311,6 +367,12 @@ function BattleCheckpoint.restore(game, checkpoint, copy)
|
|||||||
battle.player.mon.hp, battle.player.mon.status
|
battle.player.mon.hp, battle.player.mon.status
|
||||||
battle.enemy.shownHP, battle.enemy.shownStatus =
|
battle.enemy.shownHP, battle.enemy.shownStatus =
|
||||||
battle.enemy.mon.hp, battle.enemy.mon.status
|
battle.enemy.mon.hp, battle.enemy.mon.status
|
||||||
|
-- the bar's own pixel length is derived HUD state, not captured: it
|
||||||
|
-- settles with shownHP above (UpdateHPBar_AnimateHPBar's end position)
|
||||||
|
local Timing = require("src.core.Timing")
|
||||||
|
for _, b in ipairs({ battle.player, battle.enemy }) do
|
||||||
|
b.shownPx = Timing.hpBarPixels(b.mon.hp, math.max(1, b.mon.stats.hp))
|
||||||
|
end
|
||||||
|
|
||||||
local ow = game.overworld
|
local ow = game.overworld
|
||||||
if not ow or type(ow.restoreBattleContinuation) ~= "function"
|
if not ow or type(ow.restoreBattleContinuation) ~= "function"
|
||||||
|
|||||||
+147
-65
@@ -7,6 +7,7 @@ local Version = require("src.core.Version")
|
|||||||
local BattleState = require("src.battle.BattleState")
|
local BattleState = require("src.battle.BattleState")
|
||||||
local BattleCheckpoint = require("src.core.BattleCheckpoint")
|
local BattleCheckpoint = require("src.core.BattleCheckpoint")
|
||||||
local ModRuntime = require("src.mods.Runtime")
|
local ModRuntime = require("src.mods.Runtime")
|
||||||
|
local BattleSafety = require("src.battle.BattleSafety")
|
||||||
|
|
||||||
local Checkpoint = {}
|
local Checkpoint = {}
|
||||||
|
|
||||||
@@ -36,61 +37,9 @@ local function scriptsBusy(ow)
|
|||||||
or nonempty(ow.scriptMoves)
|
or nonempty(ow.scriptMoves)
|
||||||
end
|
end
|
||||||
|
|
||||||
local BATTLE_BUSY_FIELDS = {
|
local function inspectBattle(game, battle)
|
||||||
"current", "afterQueue", "nextInsert", "pendingHit", "waitingUI",
|
local allowed, reason, message = BattleSafety.inspect(game, battle)
|
||||||
"waitingSound", "waitFrames", "draining", "animPlaying", "growIn",
|
if not allowed then return refusal("battle", reason, message) end
|
||||||
"introSlide", "ghostReveal", "mimicCtx", "mimicMoves", "result",
|
|
||||||
}
|
|
||||||
|
|
||||||
local function inspectBattle(ow, battle)
|
|
||||||
if battle.kind == "link" then
|
|
||||||
return refusal("battle", "link_battle_unsupported",
|
|
||||||
"Network battles cannot be checkpointed.")
|
|
||||||
end
|
|
||||||
if battle.safari or battle.ghost or battle.scopeReveal or battle.demo
|
|
||||||
or battle.noCatch then
|
|
||||||
return refusal("battle", "battle_variant_unsupported",
|
|
||||||
"This battle variant does not have a checkpoint contract.")
|
|
||||||
end
|
|
||||||
if battle.kind ~= "wild" and battle.kind ~= "trainer" then
|
|
||||||
return refusal("battle", "battle_variant_unsupported",
|
|
||||||
"This battle kind does not have a checkpoint contract.")
|
|
||||||
end
|
|
||||||
local origin = battle.checkpointOrigin
|
|
||||||
local expectedOrigin = battle.kind == "wild" and "wild_encounter"
|
|
||||||
or "trainer_encounter"
|
|
||||||
if type(origin) ~= "table" or origin.kind ~= expectedOrigin then
|
|
||||||
return refusal("battle", "battle_origin_unsupported",
|
|
||||||
"The battle completion path cannot be reconstructed safely.")
|
|
||||||
end
|
|
||||||
if scriptsBusy(ow) then
|
|
||||||
return refusal("battle", "script_busy",
|
|
||||||
"A suspended or queued script cannot be checkpointed.")
|
|
||||||
end
|
|
||||||
if battle.phase ~= "menu" or nonempty(battle.queue) then
|
|
||||||
return refusal("battle", "battle_phase_busy",
|
|
||||||
"Wait for the player command menu before creating a checkpoint.")
|
|
||||||
end
|
|
||||||
for _, field in ipairs(BATTLE_BUSY_FIELDS) do
|
|
||||||
if battle[field] ~= nil and battle[field] ~= false then
|
|
||||||
return refusal("battle", "battle_phase_busy",
|
|
||||||
"Wait for the current battle action to finish.")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
if not battle.player or not battle.enemy or battle.player.mon.hp <= 0
|
|
||||||
or (battle.menuLockedAction and battle:menuLockedAction(battle.player)) then
|
|
||||||
return refusal("battle", "battle_phase_busy",
|
|
||||||
"Wait for an ordinary player decision before creating a checkpoint.")
|
|
||||||
end
|
|
||||||
for _, battler in ipairs({ battle.player, battle.enemy }) do
|
|
||||||
if battler.shownHP ~= battler.mon.hp
|
|
||||||
or battler.shownStatus ~= battler.mon.status
|
|
||||||
or battler.drainFloor ~= nil or battler.drainHold ~= nil
|
|
||||||
or battler.faintQueued then
|
|
||||||
return refusal("battle", "battle_phase_busy",
|
|
||||||
"Wait for battle status and HP presentation to settle.")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
return { canCapture = true, canRestore = true, kind = "battle" }
|
return { canCapture = true, canRestore = true, kind = "battle" }
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -109,7 +58,7 @@ function Checkpoint.inspect(game)
|
|||||||
end
|
end
|
||||||
local top = game.stack and game.stack.top and game.stack:top()
|
local top = game.stack and game.stack.top and game.stack:top()
|
||||||
if getmetatable(top) == BattleState then
|
if getmetatable(top) == BattleState then
|
||||||
return inspectBattle(ow, top)
|
return inspectBattle(game, top)
|
||||||
end
|
end
|
||||||
if top ~= ow then
|
if top ~= ow then
|
||||||
return refusal("overworld", "screen_busy",
|
return refusal("overworld", "screen_busy",
|
||||||
@@ -246,7 +195,7 @@ end
|
|||||||
|
|
||||||
local FACINGS = { up = true, down = true, left = true, right = true }
|
local FACINGS = { up = true, down = true, left = true, right = true }
|
||||||
|
|
||||||
local function validate(game, checkpoint)
|
local function validate(game, checkpoint, expectedIdentity)
|
||||||
if type(checkpoint) ~= "table" then
|
if type(checkpoint) ~= "table" then
|
||||||
return nil, "invalid_checkpoint", "Checkpoint root must be a table."
|
return nil, "invalid_checkpoint", "Checkpoint root must be a table."
|
||||||
end
|
end
|
||||||
@@ -264,13 +213,16 @@ local function validate(game, checkpoint)
|
|||||||
end
|
end
|
||||||
local identity = copy.identity
|
local identity = copy.identity
|
||||||
local current = game and game.save
|
local current = game and game.save
|
||||||
local currentId = current and current.meta and current.meta.playthroughId
|
local currentId = expectedIdentity and expectedIdentity.playthroughId
|
||||||
|
or (current and current.meta and current.meta.playthroughId)
|
||||||
|
local currentVersion = expectedIdentity and expectedIdentity.gameVersion
|
||||||
|
or (current and current.version)
|
||||||
if type(identity) ~= "table" or type(identity.engineVersion) ~= "string"
|
if type(identity) ~= "table" or type(identity.engineVersion) ~= "string"
|
||||||
or type(identity.gameVersion) ~= "string"
|
or type(identity.gameVersion) ~= "string"
|
||||||
or type(identity.playthroughId) ~= "string" then
|
or type(identity.playthroughId) ~= "string" then
|
||||||
return nil, "invalid_checkpoint", "Checkpoint identity is missing or corrupt."
|
return nil, "invalid_checkpoint", "Checkpoint identity is missing or corrupt."
|
||||||
end
|
end
|
||||||
if identity.gameVersion ~= current.version then
|
if identity.gameVersion ~= currentVersion then
|
||||||
return nil, "wrong_game", "Checkpoint belongs to another game version."
|
return nil, "wrong_game", "Checkpoint belongs to another game version."
|
||||||
end
|
end
|
||||||
if identity.playthroughId ~= currentId then
|
if identity.playthroughId ~= currentId then
|
||||||
@@ -361,6 +313,16 @@ local function equalData(a, b)
|
|||||||
return okA and okB and encodedA == encodedB
|
return okA and okB and encodedA == encodedB
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function normalizeVerificationMetadata(actual, expected)
|
||||||
|
if type(actual) == "table" and type(actual.identity) == "table"
|
||||||
|
and type(expected) == "table" and type(expected.identity) == "table" then
|
||||||
|
-- RFC 0004 treats engineVersion as compatibility metadata, not runtime
|
||||||
|
-- state. A fresh recapture stamps the running engine, so normalize only
|
||||||
|
-- that metadata before differential verification.
|
||||||
|
actual.identity.engineVersion = expected.identity.engineVersion
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
local function firstDifference(a, b, path)
|
local function firstDifference(a, b, path)
|
||||||
path = path or "$"
|
path = path or "$"
|
||||||
if type(a) ~= type(b) then return path .. " (type)" end
|
if type(a) ~= type(b) then return path .. " (type)" end
|
||||||
@@ -383,6 +345,49 @@ local function firstDifference(a, b, path)
|
|||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local emitRestored
|
||||||
|
|
||||||
|
-- Persist the current verified checkpoint as the ordinary progress anchor only
|
||||||
|
-- when this playthrough has never had one. This is intentionally idempotent:
|
||||||
|
-- durable checkpoint tools can make a first session resumable without turning
|
||||||
|
-- every later checkpoint into a hidden normal SAVE. The live runtime must still
|
||||||
|
-- match the supplied checkpoint, and the ordinary save.write veto/lifecycle
|
||||||
|
-- remains authoritative through Game:writeSave().
|
||||||
|
function Checkpoint.ensureNormalSave(game, checkpoint, injectedFs)
|
||||||
|
local capability = Checkpoint.inspect(game)
|
||||||
|
if not capability.canCapture then
|
||||||
|
return false, capability.reason, capability.message
|
||||||
|
end
|
||||||
|
local validated, code, message = validate(game, checkpoint)
|
||||||
|
if not validated then return false, code, message end
|
||||||
|
|
||||||
|
local info, infoCode, infoMessage =
|
||||||
|
SaveData.selectedNormalSaveInfo(game.save, injectedFs)
|
||||||
|
if not info then return false, infoCode, infoMessage end
|
||||||
|
if info.exists then return true, "already_exists" end
|
||||||
|
|
||||||
|
local current, captureCode, captureMessage = Checkpoint.capture(game)
|
||||||
|
if not current then return false, captureCode, captureMessage end
|
||||||
|
if not equalData(current, validated) then
|
||||||
|
return false, "checkpoint_not_current",
|
||||||
|
"The active runtime changed after this checkpoint was captured."
|
||||||
|
end
|
||||||
|
if type(game.writeSave) ~= "function" then
|
||||||
|
return false, "save_unavailable",
|
||||||
|
"The active runtime cannot persist ordinary progress."
|
||||||
|
end
|
||||||
|
local ok, saved = pcall(game.writeSave, game)
|
||||||
|
if not ok or saved == false then
|
||||||
|
return false, "save_failed", "Could not create the first ordinary progress save."
|
||||||
|
end
|
||||||
|
local verified = SaveData.selectedNormalSaveInfo(game.save, injectedFs)
|
||||||
|
if type(verified) ~= "table" or not verified.exists then
|
||||||
|
return false, "save_verify_failed",
|
||||||
|
"The first ordinary progress save could not be verified."
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
function Checkpoint.restore(game, checkpoint)
|
function Checkpoint.restore(game, checkpoint)
|
||||||
local capability = Checkpoint.inspect(game)
|
local capability = Checkpoint.inspect(game)
|
||||||
if not capability.canRestore then
|
if not capability.canRestore then
|
||||||
@@ -399,13 +404,9 @@ function Checkpoint.restore(game, checkpoint)
|
|||||||
if ok then
|
if ok then
|
||||||
local restored, verifyCode = Checkpoint.capture(game)
|
local restored, verifyCode = Checkpoint.capture(game)
|
||||||
if restored and validated.rng == nil then restored.rng = nil end
|
if restored and validated.rng == nil then restored.rng = nil end
|
||||||
|
normalizeVerificationMetadata(restored, validated)
|
||||||
if restored and equalData(restored, validated) then
|
if restored and equalData(restored, validated) then
|
||||||
if ModRuntime.wants("checkpoint.restored") then
|
emitRestored(game, validated)
|
||||||
ModRuntime.emit("checkpoint.restored", {
|
|
||||||
game = game,
|
|
||||||
kind = validated.kind,
|
|
||||||
})
|
|
||||||
end
|
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
err = restored and ("restored state differed at "
|
err = restored and ("restored state differed at "
|
||||||
@@ -421,4 +422,85 @@ function Checkpoint.restore(game, checkpoint)
|
|||||||
return false, "restore_failed", "Checkpoint restoration failed: " .. tostring(err)
|
return false, "restore_failed", "Checkpoint restoration failed: " .. tostring(err)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
emitRestored = function(game, checkpoint)
|
||||||
|
if ModRuntime.wants("checkpoint.restored") then
|
||||||
|
ModRuntime.emit("checkpoint.restored", {
|
||||||
|
game = game,
|
||||||
|
kind = checkpoint.kind,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function isTitleSession(game)
|
||||||
|
local states = game and game.stack and game.stack.states
|
||||||
|
if type(states) ~= "table" then return false end
|
||||||
|
for _, state in ipairs(states) do
|
||||||
|
if type(state) == "table" and state.screenId == "TitleState" then return true end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function rebuildTitle(game, savedTitle, rng)
|
||||||
|
local save, err = dataCopy(savedTitle)
|
||||||
|
if not save then error("title rollback decode failed: " .. tostring(err), 0) end
|
||||||
|
game.save = save
|
||||||
|
if type(game.adoptSave) == "function" then game:adoptSave(save) end
|
||||||
|
restoreRng(rng)
|
||||||
|
if not (game.stack and game.stack.top and game.stack.pop and game.stack.push
|
||||||
|
and type(game.makeTitleState) == "function") then
|
||||||
|
error("title recovery is unavailable", 0)
|
||||||
|
end
|
||||||
|
while game.stack:top() do game.stack:pop() end
|
||||||
|
game.stack:push(game:makeTitleState())
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Reconstruct a validated persistent checkpoint from the title session. This
|
||||||
|
-- is intentionally separate from restore(): title has no live gameplay state
|
||||||
|
-- to capture for rollback. Validation happens before any mutation; a failed
|
||||||
|
-- reconstruction rebuilds a fresh usable title session instead of exposing a
|
||||||
|
-- half-installed overworld or battle.
|
||||||
|
function Checkpoint.resume(game, checkpoint)
|
||||||
|
if not isTitleSession(game) then
|
||||||
|
return false, "not_at_title",
|
||||||
|
"Checkpoint resume is available only from the title session."
|
||||||
|
end
|
||||||
|
local save = game and game.save
|
||||||
|
local playthroughId, identityCode, identityMessage =
|
||||||
|
SaveData.selectedPlaythroughId(save)
|
||||||
|
if type(playthroughId) ~= "string" or playthroughId == "" then
|
||||||
|
return false, identityCode, identityMessage
|
||||||
|
end
|
||||||
|
local expected = { gameVersion = save and save.version, playthroughId = playthroughId }
|
||||||
|
local validated, code, message = validate(game, checkpoint, expected)
|
||||||
|
if not validated then return false, code, message end
|
||||||
|
|
||||||
|
local titleSave, titleErr = dataCopy(save)
|
||||||
|
if not titleSave then
|
||||||
|
return false, "title_recovery_unavailable",
|
||||||
|
"Could not preserve the title session: " .. tostring(titleErr)
|
||||||
|
end
|
||||||
|
local titleRng = captureRng()
|
||||||
|
local currentOptions = save.options
|
||||||
|
local ok, err = pcall(apply, game, validated, currentOptions)
|
||||||
|
if ok then
|
||||||
|
local restored, verifyCode = Checkpoint.capture(game)
|
||||||
|
if restored and validated.rng == nil then restored.rng = nil end
|
||||||
|
normalizeVerificationMetadata(restored, validated)
|
||||||
|
if restored and equalData(restored, validated) then
|
||||||
|
emitRestored(game, validated)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
err = restored and ("resumed state differed at "
|
||||||
|
.. tostring(firstDifference(validated, restored) or "canonical encoding"))
|
||||||
|
or ("resumed state could not be captured: " .. tostring(verifyCode))
|
||||||
|
end
|
||||||
|
|
||||||
|
local recovered, recoveryErr = pcall(rebuildTitle, game, titleSave, titleRng)
|
||||||
|
if not recovered then
|
||||||
|
return false, "title_recovery_failed",
|
||||||
|
"Checkpoint resume failed and title recovery failed: " .. tostring(recoveryErr)
|
||||||
|
end
|
||||||
|
return false, "resume_failed", "Checkpoint resume failed: " .. tostring(err)
|
||||||
|
end
|
||||||
|
|
||||||
return Checkpoint
|
return Checkpoint
|
||||||
|
|||||||
+13
-2
@@ -203,7 +203,8 @@ function ChipAudio.playMusic(data, header, allowLoops)
|
|||||||
cmdCh:push({ cmd = "play", gen = gen, header = header,
|
cmdCh:push({ cmd = "play", gen = gen, header = header,
|
||||||
allowLoops = allowLoops, audio = slimAudio(data),
|
allowLoops = allowLoops, audio = slimAudio(data),
|
||||||
channelVolumes = ChipSynth.getChannelVolumes(),
|
channelVolumes = ChipSynth.getChannelVolumes(),
|
||||||
channelPitches = ChipSynth.getChannelPitches() })
|
channelPitches = ChipSynth.getChannelPitches(),
|
||||||
|
stereo = ChipSynth.getStereo() })
|
||||||
currentMusic = { source = source, gen = gen, threaded = true,
|
currentMusic = { source = source, gen = gen, threaded = true,
|
||||||
started = false, finished = false }
|
started = false, finished = false }
|
||||||
-- playback starts in update() once the first buffer arrives (~1 frame)
|
-- playback starts in update() once the first buffer arrives (~1 frame)
|
||||||
@@ -214,7 +215,8 @@ local function pushChannelMix()
|
|||||||
if workerReady and cmdCh then
|
if workerReady and cmdCh then
|
||||||
cmdCh:push({ cmd = "channelMix",
|
cmdCh:push({ cmd = "channelMix",
|
||||||
volumes = ChipSynth.getChannelVolumes(),
|
volumes = ChipSynth.getChannelVolumes(),
|
||||||
pitches = ChipSynth.getChannelPitches() })
|
pitches = ChipSynth.getChannelPitches(),
|
||||||
|
stereo = ChipSynth.getStereo() })
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -352,6 +354,15 @@ function ChipAudio.shutdown()
|
|||||||
workerReady = false
|
workerReady = false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function ChipAudio.setStereo(enabled)
|
||||||
|
ChipSynth.setStereo(enabled)
|
||||||
|
pushChannelMix()
|
||||||
|
end
|
||||||
|
|
||||||
|
function ChipAudio.getStereo()
|
||||||
|
return ChipSynth.getStereo()
|
||||||
|
end
|
||||||
|
|
||||||
-- Runtime mix for one hardware channel (1..4). Takes effect on the next
|
-- Runtime mix for one hardware channel (1..4). Takes effect on the next
|
||||||
-- synthesized buffer (live music) and on any SFX/cry rendered after the call.
|
-- synthesized buffer (live music) and on any SFX/cry rendered after the call.
|
||||||
function ChipAudio.setChannelVolume(hw, scale)
|
function ChipAudio.setChannelVolume(hw, scale)
|
||||||
|
|||||||
+30
-6
@@ -29,6 +29,18 @@ ChipSynth.SAMPLE_RATE = SAMPLE_RATE
|
|||||||
ChipSynth.MUSIC_BUFFER_SAMPLES = MUSIC_BUFFER_SAMPLES
|
ChipSynth.MUSIC_BUFFER_SAMPLES = MUSIC_BUFFER_SAMPLES
|
||||||
ChipSynth.MUSIC_BUFFER_COUNT = MUSIC_BUFFER_COUNT
|
ChipSynth.MUSIC_BUFFER_COUNT = MUSIC_BUFFER_COUNT
|
||||||
|
|
||||||
|
-- Gen 2 SOUND option (MONO/STEREO): gates Music_StereoPanning's per-song
|
||||||
|
-- panning byte (audio/engine.asm:1987 wOptions STEREO bit).
|
||||||
|
local stereoEnabled = false
|
||||||
|
|
||||||
|
function ChipSynth.setStereo(enabled)
|
||||||
|
stereoEnabled = not not enabled
|
||||||
|
end
|
||||||
|
|
||||||
|
function ChipSynth.getStereo()
|
||||||
|
return stereoEnabled
|
||||||
|
end
|
||||||
|
|
||||||
-- Runtime mix per hardware channel (1 pulse, 2 pulse, 3 wave, 4 noise).
|
-- Runtime mix per hardware channel (1 pulse, 2 pulse, 3 wave, 4 noise).
|
||||||
-- Volume: 1 = authentic GB, 0 = mute. Pitch: 1 = authentic, 2 = +1 octave,
|
-- Volume: 1 = authentic GB, 0 = mute. Pitch: 1 = authentic, 2 = +1 octave,
|
||||||
-- 0.5 = -1 octave. Applied at sample time so a live change reaches the next
|
-- 0.5 = -1 octave. Applied at sample time so a live change reaches the next
|
||||||
@@ -760,11 +772,14 @@ function Channel:nextEventGen2()
|
|||||||
-- no-op for the PCM renderer
|
-- no-op for the PCM renderer
|
||||||
elseif command == 0xEE then -- unknownmusic0xee
|
elseif command == 0xEE then -- unknownmusic0xee
|
||||||
self:word()
|
self:word()
|
||||||
elseif command == 0xEF then -- stereo_panning (honor always; options.stereo)
|
elseif command == 0xEF then
|
||||||
|
-- audio/engine.asm:1987 Music_StereoPanning: apply only when STEREO is on
|
||||||
local packed = self:byte()
|
local packed = self:byte()
|
||||||
local mask = bit.lshift(1, self.hardware - 1)
|
if stereoEnabled then
|
||||||
local default = bit.bor(bit.lshift(mask, 4), mask)
|
local mask = bit.lshift(1, self.hardware - 1)
|
||||||
self.tracks = bit.band(packed, default)
|
local default = bit.bor(bit.lshift(mask, 4), mask)
|
||||||
|
self.tracks = bit.band(packed, default)
|
||||||
|
end
|
||||||
elseif command == 0xF0 then -- sfx_toggle_noise
|
elseif command == 0xF0 then -- sfx_toggle_noise
|
||||||
if self.noiseSampling then
|
if self.noiseSampling then
|
||||||
self.noiseSampling = false
|
self.noiseSampling = false
|
||||||
@@ -1215,8 +1230,17 @@ function Engine.new(data, header, options)
|
|||||||
engine.tempo = header.tempo
|
engine.tempo = header.tempo
|
||||||
engine.tempoLocked = true
|
engine.tempoLocked = true
|
||||||
end
|
end
|
||||||
for _, spec in ipairs(chip and chip.channels
|
local channels = chip and chip.channels or headerChannels(banks, header)
|
||||||
or headerChannels(banks, header)) do
|
if header.startChannels then
|
||||||
|
local byNumber = {}
|
||||||
|
for _, start in ipairs(header.startChannels) do
|
||||||
|
byNumber[start.number] = start.address
|
||||||
|
end
|
||||||
|
for _, spec in ipairs(channels) do
|
||||||
|
spec.address = byNumber[spec.number] or spec.address
|
||||||
|
end
|
||||||
|
end
|
||||||
|
for _, spec in ipairs(channels) do
|
||||||
local frameTicks = options.frameTicks
|
local frameTicks = options.frameTicks
|
||||||
local hardware = (spec.number - 1) % 4 + 1
|
local hardware = (spec.number - 1) % 4 + 1
|
||||||
if hardware == 4 then
|
if hardware == 4 then
|
||||||
|
|||||||
+39
-15
@@ -14,6 +14,14 @@ local MODULES = {
|
|||||||
-- Optional for compatibility with developer and stale caches.
|
-- Optional for compatibility with developer and stale caches.
|
||||||
local OPTIONAL = { "audio", "palettes", "icons" }
|
local OPTIONAL = { "audio", "palettes", "icons" }
|
||||||
|
|
||||||
|
-- Gold's extractor never writes these Gen 1 tables (RomExtractorGen2 has
|
||||||
|
-- maps/text/pokemon/items, not text_pointers / trainer_headers / field).
|
||||||
|
-- Desktop can still `require` Red's copies from the source tree, so Gold
|
||||||
|
-- Edit appeared to work there; an Android APK has only the per-version
|
||||||
|
-- cache, so Data:load used to throw on the first Gold Edit and take the
|
||||||
|
-- activity down. Empty tables are enough for seedDefaults / the editor.
|
||||||
|
local GEN2_OPTIONAL = { text_pointers = true, trainer_headers = true, field = true }
|
||||||
|
|
||||||
-- Vanilla defaults for rules exposed through the constants registry. A
|
-- Vanilla defaults for rules exposed through the constants registry. A
|
||||||
-- value has to exist before a mod can patch it; each one matches the
|
-- value has to exist before a mod can patch it; each one matches the
|
||||||
-- engine's no-mod behavior, so seeding them changes nothing on a vanilla
|
-- engine's no-mod behavior, so seeding them changes nothing on a vanilla
|
||||||
@@ -99,7 +107,11 @@ end
|
|||||||
-- Fills only what the cache is missing, so an importer that learns to
|
-- Fills only what the cache is missing, so an importer that learns to
|
||||||
-- stamp one of these keys silently takes over from the engine.
|
-- stamp one of these keys silently takes over from the engine.
|
||||||
function Data:seedDefaults()
|
function Data:seedDefaults()
|
||||||
local constants = self.constants
|
local constants = self.constants or {}
|
||||||
|
self.constants = constants
|
||||||
|
self.field = self.field or {}
|
||||||
|
self.maps = self.maps or {}
|
||||||
|
self.pokemon = self.pokemon or {}
|
||||||
for key, value in pairs(CONSTANT_DEFAULTS) do
|
for key, value in pairs(CONSTANT_DEFAULTS) do
|
||||||
if constants[key] == nil then constants[key] = copy(value) end
|
if constants[key] == nil then constants[key] = copy(value) end
|
||||||
end
|
end
|
||||||
@@ -108,7 +120,11 @@ function Data:seedDefaults()
|
|||||||
if constants.dexSize == nil then
|
if constants.dexSize == nil then
|
||||||
local highest = 0
|
local highest = 0
|
||||||
for _, def in pairs(self.pokemon) do
|
for _, def in pairs(self.pokemon) do
|
||||||
if def.dex and def.dex > highest then highest = def.dex end
|
-- Gold's pokemon.lua also carries growthRates / tmhmMoves / generation
|
||||||
|
-- scalars beside species rows.
|
||||||
|
if type(def) == "table" and def.dex and def.dex > highest then
|
||||||
|
highest = def.dex
|
||||||
|
end
|
||||||
end
|
end
|
||||||
constants.dexSize = highest
|
constants.dexSize = highest
|
||||||
end
|
end
|
||||||
@@ -212,36 +228,41 @@ local function loadModule(dir, name)
|
|||||||
if not chunk then return false, err end
|
if not chunk then return false, err end
|
||||||
return pcall(chunk)
|
return pcall(chunk)
|
||||||
end
|
end
|
||||||
local ok, mod = pcall(require, "data.generated." .. name)
|
|
||||||
if ok then return true, mod end
|
|
||||||
-- Fused PhysFS / Blue|Yellow prefix: load bytes from the active version's
|
|
||||||
-- cache explicitly when require cannot see the mounted tree.
|
|
||||||
local CacheFs = require("src.import.CacheFs")
|
local CacheFs = require("src.import.CacheFs")
|
||||||
local GameVersion = require("src.core.GameVersion")
|
local GameVersion = require("src.core.GameVersion")
|
||||||
local path = "data/generated/" .. name .. ".lua"
|
local path = "data/generated/" .. name .. ".lua"
|
||||||
local bytes = CacheFs.readActive(path)
|
local bytes = CacheFs.readActive(path)
|
||||||
if type(bytes) == "string" then
|
if type(bytes) == "string" then
|
||||||
local chunk, err = loadstring(bytes, "@" .. GameVersion.cachePrefix() .. path)
|
local chunk = loadstring(bytes, "@" .. GameVersion.cachePrefix() .. path)
|
||||||
if not chunk then return false, err or mod end
|
if chunk then
|
||||||
return pcall(chunk)
|
local ok, res = pcall(chunk)
|
||||||
|
if ok then return true, res end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
return false, mod
|
local ok, mod = pcall(require, "data.generated." .. name)
|
||||||
|
if ok then return true, mod end
|
||||||
|
return false, nil
|
||||||
end
|
end
|
||||||
|
|
||||||
function Data:load()
|
function Data:load()
|
||||||
local dir = os.getenv("POKEPORT_DATA_DIR")
|
local dir = os.getenv("POKEPORT_DATA_DIR")
|
||||||
|
local gen2 = require("src.core.GameVersion").generation() == 2
|
||||||
for _, name in ipairs(MODULES) do
|
for _, name in ipairs(MODULES) do
|
||||||
local ok, mod = loadModule(dir, name)
|
local ok, mod = loadModule(dir, name)
|
||||||
if not ok then
|
if not ok then
|
||||||
if dir then
|
if gen2 and GEN2_OPTIONAL[name] then
|
||||||
|
self[name] = {}
|
||||||
|
elseif dir then
|
||||||
error(("missing data module '%s/%s.lua' (POKEPORT_DATA_DIR).\n(%s)")
|
error(("missing data module '%s/%s.lua' (POKEPORT_DATA_DIR).\n(%s)")
|
||||||
:format(dir, name, mod))
|
:format(dir, name, mod))
|
||||||
|
else
|
||||||
|
error(("missing generated data module 'data/generated/%s.lua'.\n" ..
|
||||||
|
"Import the ROM again or rebuild developer data.\n(%s)")
|
||||||
|
:format(name, mod))
|
||||||
end
|
end
|
||||||
error(("missing generated data module 'data/generated/%s.lua'.\n" ..
|
else
|
||||||
"Import the ROM again or rebuild developer data.\n(%s)")
|
self[name] = mod
|
||||||
:format(name, mod))
|
|
||||||
end
|
end
|
||||||
self[name] = mod
|
|
||||||
end
|
end
|
||||||
for _, name in ipairs(OPTIONAL) do
|
for _, name in ipairs(OPTIONAL) do
|
||||||
local ok, mod = loadModule(dir, name)
|
local ok, mod = loadModule(dir, name)
|
||||||
@@ -280,11 +301,14 @@ function Data:unloadGenerated()
|
|||||||
if not pristine[key] then self[key] = nil end
|
if not pristine[key] then self[key] = nil end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
self._pristineKeys = nil
|
||||||
for _, name in ipairs(MODULES) do
|
for _, name in ipairs(MODULES) do
|
||||||
package.loaded["data.generated." .. name] = nil
|
package.loaded["data.generated." .. name] = nil
|
||||||
|
self[name] = nil
|
||||||
end
|
end
|
||||||
for _, name in ipairs(OPTIONAL) do
|
for _, name in ipairs(OPTIONAL) do
|
||||||
package.loaded["data.generated." .. name] = nil
|
package.loaded["data.generated." .. name] = nil
|
||||||
|
self[name] = nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
-- Shared local date/time presentation for engine UI and mods. Preferences
|
||||||
|
-- live in options.lua, never checkpoint progress. "device" uses the process
|
||||||
|
-- time locale when the platform supplies one and otherwise falls back to the
|
||||||
|
-- deterministic DD-MM-YYYY / 24-hour convention.
|
||||||
|
|
||||||
|
local DateTime = {}
|
||||||
|
|
||||||
|
local DATE_FORMATS = {
|
||||||
|
dmy = "%d-%m-%Y",
|
||||||
|
mdy = "%m-%d-%Y",
|
||||||
|
ymd = "%Y-%m-%d",
|
||||||
|
}
|
||||||
|
local TIME_FORMATS = {
|
||||||
|
["24h"] = "%H:%M",
|
||||||
|
["12h"] = "%I:%M %p",
|
||||||
|
}
|
||||||
|
|
||||||
|
local function validTimestamp(value)
|
||||||
|
return type(value) == "number" and value == value and value >= 0
|
||||||
|
and value ~= math.huge and value ~= -math.huge
|
||||||
|
end
|
||||||
|
|
||||||
|
local function render(timestamp, pattern)
|
||||||
|
local ok, value = pcall(os.date, pattern, math.floor(timestamp))
|
||||||
|
if not ok or type(value) ~= "string" or value == "" then return nil end
|
||||||
|
return value
|
||||||
|
end
|
||||||
|
|
||||||
|
local function currentLocale()
|
||||||
|
if not os.setlocale then return nil end
|
||||||
|
local ok, value = pcall(os.setlocale, nil, "time")
|
||||||
|
if not ok or type(value) ~= "string" then return nil end
|
||||||
|
return value
|
||||||
|
end
|
||||||
|
|
||||||
|
local function deviceAvailable(localeName)
|
||||||
|
return type(localeName) == "string" and localeName ~= ""
|
||||||
|
and localeName ~= "C" and localeName ~= "POSIX"
|
||||||
|
end
|
||||||
|
|
||||||
|
function DateTime.formatWithLocale(timestamp, datePreference, timePreference, localeName)
|
||||||
|
if not validTimestamp(timestamp) then return { date = "----", time = "----" } end
|
||||||
|
local datePattern = DATE_FORMATS[datePreference]
|
||||||
|
local timePattern = TIME_FORMATS[timePreference]
|
||||||
|
if not datePattern then
|
||||||
|
datePattern = deviceAvailable(localeName) and "%x" or DATE_FORMATS.dmy
|
||||||
|
end
|
||||||
|
if not timePattern then
|
||||||
|
if deviceAvailable(localeName) then
|
||||||
|
local sample = render(timestamp, "%X") or ""
|
||||||
|
local marker = render(timestamp, "%p") or ""
|
||||||
|
timePattern = marker ~= "" and sample:find(marker, 1, true)
|
||||||
|
and TIME_FORMATS["12h"] or TIME_FORMATS["24h"]
|
||||||
|
else
|
||||||
|
timePattern = TIME_FORMATS["24h"]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return {
|
||||||
|
date = render(timestamp, datePattern) or "----",
|
||||||
|
time = render(timestamp, timePattern) or "----",
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function preferences(game)
|
||||||
|
local options = game and game.save and game.save.options
|
||||||
|
options = type(options) == "table" and options or {}
|
||||||
|
return options.dateFormat or "device", options.timeFormat or "device"
|
||||||
|
end
|
||||||
|
|
||||||
|
function DateTime.date(game, timestamp)
|
||||||
|
local datePreference, timePreference = preferences(game)
|
||||||
|
return DateTime.formatWithLocale(timestamp, datePreference, timePreference,
|
||||||
|
currentLocale()).date
|
||||||
|
end
|
||||||
|
|
||||||
|
function DateTime.time(game, timestamp)
|
||||||
|
local datePreference, timePreference = preferences(game)
|
||||||
|
return DateTime.formatWithLocale(timestamp, datePreference, timePreference,
|
||||||
|
currentLocale()).time
|
||||||
|
end
|
||||||
|
|
||||||
|
function DateTime.dateTime(game, timestamp)
|
||||||
|
local datePreference, timePreference = preferences(game)
|
||||||
|
local value = DateTime.formatWithLocale(timestamp, datePreference, timePreference,
|
||||||
|
currentLocale())
|
||||||
|
if value.date == "----" or value.time == "----" then return "----" end
|
||||||
|
return value.date .. " " .. value.time
|
||||||
|
end
|
||||||
|
|
||||||
|
return DateTime
|
||||||
+19
-6
@@ -159,14 +159,15 @@ function Game:makeTitleState()
|
|||||||
self:applyOptions(self.save.options)
|
self:applyOptions(self.save.options)
|
||||||
self.stack:push(OverworldState, self.save.player.map,
|
self.stack:push(OverworldState, self.save.player.map,
|
||||||
self.save.player.x, self.save.player.y,
|
self.save.player.x, self.save.player.y,
|
||||||
self.save.player.facing)
|
self.save.player.facing,
|
||||||
|
{ via = "boot", freshBoot = true })
|
||||||
Screens.push(self, bootScreens(self).newGame or "OakSpeech",
|
Screens.push(self, bootScreens(self).newGame or "OakSpeech",
|
||||||
function() end)
|
function() end)
|
||||||
end,
|
end,
|
||||||
onContinue = function()
|
onContinue = function()
|
||||||
local loaded, recovered = SaveData.load()
|
local loaded, recovered = SaveData.load()
|
||||||
if loaded then
|
if loaded then
|
||||||
self:restoreSave(loaded, recovered)
|
self:restoreSave(loaded, recovered, { freshBoot = true })
|
||||||
end
|
end
|
||||||
end,
|
end,
|
||||||
})
|
})
|
||||||
@@ -639,7 +640,12 @@ function Game:keypressed(key)
|
|||||||
return
|
return
|
||||||
elseif key == "f2" then
|
elseif key == "f2" then
|
||||||
local loaded, recovered = SaveData.load()
|
local loaded, recovered = SaveData.load()
|
||||||
if loaded then self:restoreSave(loaded, recovered) end
|
if loaded then
|
||||||
|
-- F2 jumps straight to the loaded save's map/position, with no
|
||||||
|
-- walking transition -- a hard state teleport like Continue, not a
|
||||||
|
-- smooth warp -- whether pressed at the title screen or mid-session.
|
||||||
|
self:restoreSave(loaded, recovered, { freshBoot = true })
|
||||||
|
end
|
||||||
return
|
return
|
||||||
elseif key == "-" then
|
elseif key == "-" then
|
||||||
self:zoomStep(-1)
|
self:zoomStep(-1)
|
||||||
@@ -1124,7 +1130,7 @@ function Game:applyOptions(opts)
|
|||||||
if gbcCleared then self:writeOptions() end
|
if gbcCleared then self:writeOptions() end
|
||||||
end
|
end
|
||||||
|
|
||||||
function Game:restoreSave(loaded, recovered)
|
function Game:restoreSave(loaded, recovered, opts)
|
||||||
if ModRuntime.wants("save.loading") then
|
if ModRuntime.wants("save.loading") then
|
||||||
ModRuntime.emit("save.loading", { raw = loaded })
|
ModRuntime.emit("save.loading", { raw = loaded })
|
||||||
end
|
end
|
||||||
@@ -1157,8 +1163,12 @@ function Game:restoreSave(loaded, recovered)
|
|||||||
end
|
end
|
||||||
-- rebuild the state stack from the save
|
-- rebuild the state stack from the save
|
||||||
while self.stack:top() do self.stack:pop() end
|
while self.stack:top() do self.stack:pop() end
|
||||||
|
-- freshBoot threads through from the caller (onContinue and F2 both set
|
||||||
|
-- it); a future caller that doesn't ask for it keeps the ordinary
|
||||||
|
-- crossfade by default.
|
||||||
self.stack:push(self.overworld, loaded.player.map,
|
self.stack:push(self.overworld, loaded.player.map,
|
||||||
loaded.player.x, loaded.player.y, loaded.player.facing)
|
loaded.player.x, loaded.player.y, loaded.player.facing,
|
||||||
|
{ via = "boot", freshBoot = opts and opts.freshBoot })
|
||||||
self.saveReport = report
|
self.saveReport = report
|
||||||
if not SaveData.emptyReport(report) then
|
if not SaveData.emptyReport(report) then
|
||||||
-- the report screen is a Screens id so mods (or the ui milestone) own
|
-- the report screen is a Screens id so mods (or the ui milestone) own
|
||||||
@@ -1187,9 +1197,12 @@ function Game:restoreCheckpointSave(loaded)
|
|||||||
self.save = loaded
|
self.save = loaded
|
||||||
self:adoptSave(loaded)
|
self:adoptSave(loaded)
|
||||||
while self.stack:top() do self.stack:pop() end
|
while self.stack:top() do self.stack:pop() end
|
||||||
|
-- freshBoot unconditionally: Checkpoint.resume (src/core/Checkpoint.lua)
|
||||||
|
-- is this method's only caller, and it is itself gated to the title
|
||||||
|
-- session (isTitleSession).
|
||||||
self.stack:push(self.overworld, loaded.player.map,
|
self.stack:push(self.overworld, loaded.player.map,
|
||||||
loaded.player.x, loaded.player.y, loaded.player.facing,
|
loaded.player.x, loaded.player.y, loaded.player.facing,
|
||||||
{ via = "checkpoint", checkpoint = true })
|
{ via = "checkpoint", checkpoint = true, freshBoot = true })
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Install a reconstructed battle without calling BattleState:enter(), whose
|
-- Install a reconstructed battle without calling BattleState:enter(), whose
|
||||||
|
|||||||
+79
-56
@@ -73,7 +73,7 @@ end
|
|||||||
--
|
--
|
||||||
-- Gold composites its own frame (Game2:draw / drawScene) and pumps its own pad
|
-- Gold composites its own frame (Game2:draw / drawScene) and pumps its own pad
|
||||||
-- (the FixedStep callback in Game2:load), so none of it goes through
|
-- (the FixedStep callback in Game2:load), so none of it goes through
|
||||||
-- src/render/Renderer.lua or src/core/Game.lua. That explains why the six
|
-- src/render/Renderer.lua or src/core/Game.lua. That explains why the eight
|
||||||
-- hooks below never used to fire here; it is not a reason they should not. A
|
-- hooks below never used to fire here; it is not a reason they should not. A
|
||||||
-- hook is a contract about a MOMENT in the frame, and Gold has every one of
|
-- hook is a contract about a MOMENT in the frame, and Gold has every one of
|
||||||
-- these moments -- so each is raised under the Gen 1 NAME with the Gen 1
|
-- these moments -- so each is raised under the Gen 1 NAME with the Gen 1
|
||||||
@@ -83,6 +83,7 @@ end
|
|||||||
-- input.pointer uncaptured pointer events (src/core/Game.lua:887)
|
-- input.pointer uncaptured pointer events (src/core/Game.lua:887)
|
||||||
-- render.zones the palette pass, pre-blit (src/core/Game.lua:505)
|
-- render.zones the palette pass, pre-blit (src/core/Game.lua:505)
|
||||||
-- render.compose the whole-window composite (Renderer.lua:759)
|
-- render.compose the whole-window composite (Renderer.lua:759)
|
||||||
|
-- render.output* the normal composed frame (Renderer.lua:1063)
|
||||||
-- render.letterbox the void around the 160x144 blit (Renderer.lua:840)
|
-- render.letterbox the void around the 160x144 blit (Renderer.lua:840)
|
||||||
-- render.hud screen-space UI over the frame (src/core/Game.lua:521)
|
-- render.hud screen-space UI over the frame (src/core/Game.lua:521)
|
||||||
--
|
--
|
||||||
@@ -109,6 +110,12 @@ local function makeStack()
|
|||||||
return StateStack
|
return StateStack
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function visibleBaseState(stack)
|
||||||
|
if not stack then return nil end
|
||||||
|
local state = stack.states[stack:visibleBase()]
|
||||||
|
return stack:renderVisible(state) and state or nil
|
||||||
|
end
|
||||||
|
|
||||||
local function loadGenerated(path)
|
local function loadGenerated(path)
|
||||||
local chunk = love.filesystem.load(path)
|
local chunk = love.filesystem.load(path)
|
||||||
if not chunk then return nil end
|
if not chunk then return nil end
|
||||||
@@ -140,7 +147,7 @@ Game2.anchorNewGameClock = anchorNewGameClock
|
|||||||
|
|
||||||
function Game2.new()
|
function Game2.new()
|
||||||
local self = setmetatable({
|
local self = setmetatable({
|
||||||
speedOverride = 1,
|
speedOverride = nil,
|
||||||
capturePath = nil,
|
capturePath = nil,
|
||||||
world = nil,
|
world = nil,
|
||||||
status = nil,
|
status = nil,
|
||||||
@@ -276,6 +283,9 @@ function Game2:continueGame(save)
|
|||||||
local modsDiff = SaveData.modsDiff(save, activeMods)
|
local modsDiff = SaveData.modsDiff(save, activeMods)
|
||||||
self.save = save
|
self.save = save
|
||||||
self:adoptSave(save)
|
self:adoptSave(save)
|
||||||
|
-- Editor species swaps used to leave mon.name on the previous species.
|
||||||
|
-- CONTINUE rewrites party, boxes, and Day-Care copies from the live record.
|
||||||
|
require("src.battle.gen2.Mon").syncSaveIdentity(save, self.data)
|
||||||
-- options.lua wins over anything a save file carries: options are a display
|
-- options.lua wins over anything a save file carries: options are a display
|
||||||
-- preference that survives New Game and is edited from the launcher, so a
|
-- preference that survives New Game and is edited from the launcher, so a
|
||||||
-- save written before they moved out must not drag old values back in.
|
-- save written before they moved out must not drag old values back in.
|
||||||
@@ -581,13 +591,13 @@ function Game2:useFieldItem(itemId)
|
|||||||
end
|
end
|
||||||
if not allowed then
|
if not allowed then
|
||||||
self:say(("%s can't learn %s!"):format(
|
self:say(("%s can't learn %s!"):format(
|
||||||
mon.nickname or mon.species or "?", moveName))
|
require("src.battle.gen2.Mon").displayName(mon), moveName))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
for _, move in ipairs(mon.moves or {}) do
|
for _, move in ipairs(mon.moves or {}) do
|
||||||
if move.id == moveId then
|
if move.id == moveId then
|
||||||
self:say(("%s already knows %s!"):format(
|
self:say(("%s already knows %s!"):format(
|
||||||
mon.nickname or mon.species or "?", moveName))
|
require("src.battle.gen2.Mon").displayName(mon), moveName))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -683,10 +693,26 @@ function Game2:usePartyItem(itemId)
|
|||||||
self:say(result.text)
|
self:say(result.text)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
self:consumeItem(itemId)
|
if action == "stone" then
|
||||||
if action == "candy" then
|
local party = (self.save and self.save.party) or {}
|
||||||
|
local index
|
||||||
|
for i, member in ipairs(party) do
|
||||||
|
if member == mon then index = i break end
|
||||||
|
end
|
||||||
|
Screens.push(self, "Gen2EvolutionAnim", {
|
||||||
|
mon = mon, entry = result.evolution, index = index,
|
||||||
|
party = party, save = self.save,
|
||||||
|
force = true,
|
||||||
|
onDone = function(evolution)
|
||||||
|
if evolution and evolution.evolved then self:consumeItem(itemId) end
|
||||||
|
self.stack:pop()
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
elseif action == "candy" then
|
||||||
|
self:consumeItem(itemId)
|
||||||
self:say(result.text, function() self:afterRareCandy(mon, result) end)
|
self:say(result.text, function() self:afterRareCandy(mon, result) end)
|
||||||
else
|
else
|
||||||
|
self:consumeItem(itemId)
|
||||||
self:say(result.text)
|
self:say(result.text)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -975,27 +1001,9 @@ function Game2:load()
|
|||||||
local Pipelines = require("src.render.Pipelines")
|
local Pipelines = require("src.render.Pipelines")
|
||||||
Pipelines.install(self.data)
|
Pipelines.install(self.data)
|
||||||
Pipelines.applyOptions(self.options)
|
Pipelines.applyOptions(self.options)
|
||||||
-- Gold composites the WHOLE-FRAME half of a pipeline (`present`) and not the
|
-- Both halves run on Gold now: `present` folds over the composite in
|
||||||
-- world half: its overworld draws straight to the window rather than into a
|
-- Game2:draw, `drawWorld` owns the world pass in World:drawPipeline. So a
|
||||||
-- canvas the way src/world/OverworldController.lua:4827 hands one to
|
-- restored world level stays switched on, as it does for Gen 1.
|
||||||
-- Pipelines.drawWorld, so there is nothing here for drawWorld to replace yet.
|
|
||||||
-- A restored level for a world-only pipeline is retired rather than left
|
|
||||||
-- switched on, because on Gold it would render nothing AND hold TILT off
|
|
||||||
-- (Pipelines.setLevel's tilt exclusion). The stored level in
|
|
||||||
-- options.pipelines is left untouched, so the mode comes back the day Gold
|
|
||||||
-- grows a world canvas; Tilt is re-applied from the option the exclusion just
|
|
||||||
-- cleared.
|
|
||||||
local retired = false
|
|
||||||
for _, entry in ipairs(Pipelines.list()) do
|
|
||||||
if entry.def.drawWorld and not entry.def.present
|
|
||||||
and Pipelines.level(entry.id) > 0 then
|
|
||||||
Pipelines.setLevel(entry.id, 0)
|
|
||||||
retired = true
|
|
||||||
end
|
|
||||||
end
|
|
||||||
if retired then
|
|
||||||
require("src.render.Tilt").applyOptions(self.options)
|
|
||||||
end
|
|
||||||
|
|
||||||
-- After the merge, so a font override and a translation mod's catalog
|
-- After the merge, so a font override and a translation mod's catalog
|
||||||
-- (#501) are both in Data before the first screen draws a glyph. Gen 1
|
-- (#501) are both in Data before the first screen draws a glyph. Gen 1
|
||||||
@@ -1112,19 +1120,20 @@ function Game2:update(dt)
|
|||||||
-- reason and at the same place Gen 1 ticks them (src/core/Game.lua:265):
|
-- reason and at the same place Gen 1 ticks them (src/core/Game.lua:265):
|
||||||
-- they are presentational, so fast-forward must not speed them up.
|
-- they are presentational, so fast-forward must not speed them up.
|
||||||
require("src.render.Pipelines").update(dt)
|
require("src.render.Pipelines").update(dt)
|
||||||
if self.phase == "boot" then
|
|
||||||
FixedStep.maxAccum = 0.25
|
|
||||||
FixedStep:update(dt)
|
|
||||||
return
|
|
||||||
end
|
|
||||||
if not self.world or not self.world.map then return end
|
|
||||||
-- GAME SPEED scales the logic clock only, exactly as the Gen 1 path does:
|
-- GAME SPEED scales the logic clock only, exactly as the Gen 1 path does:
|
||||||
-- audio runs off its own real-time accumulator, so music and sfx keep their
|
-- audio runs off its own real-time accumulator, so music and sfx keep their
|
||||||
-- tempo at every multiplier. speedOverride is the driver/CLI hook and wins
|
-- tempo at every multiplier. speedOverride is the driver/CLI hook and wins
|
||||||
-- over the saved option.
|
-- over the saved option.
|
||||||
|
-- pokegold engine/menus/intro_menu.asm:848 IntroSequence: boot cinema runs on the same clock as the overworld
|
||||||
local speed = math.max(1,
|
local speed = math.max(1,
|
||||||
tonumber(self.speedOverride) or tonumber(self.options and self.options.speed)
|
tonumber(self.speedOverride) or tonumber(self.options and self.options.speed)
|
||||||
or 1)
|
or 1)
|
||||||
|
if self.phase == "boot" then
|
||||||
|
FixedStep.maxAccum = math.max(0.25, speed / 60 + 0.05)
|
||||||
|
FixedStep:update(dt * speed)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if not self.world or not self.world.map then return end
|
||||||
FixedStep.maxAccum = math.max(0.25, speed / 60 + 0.05)
|
FixedStep.maxAccum = math.max(0.25, speed / 60 + 0.05)
|
||||||
FixedStep:update(dt * speed)
|
FixedStep:update(dt * speed)
|
||||||
end
|
end
|
||||||
@@ -1377,12 +1386,14 @@ function Game2:draw()
|
|||||||
local zoned = type(zones) == "table" and zones[1] ~= nil
|
local zoned = type(zones) == "table" and zones[1] ~= nil
|
||||||
|
|
||||||
-- A present canvas is paid for only when something reads it: the zone pass,
|
-- A present canvas is paid for only when something reads it: the zone pass,
|
||||||
-- GBC FX, a mod post-process, or a render.compose subscriber about to be
|
-- GBC FX, a mod post-process, render.compose, or an enabled render.output
|
||||||
-- handed the finished frame. With none of them the frame draws straight to
|
-- subscriber. With none of them the frame draws straight to the screen
|
||||||
-- the screen exactly as it always did.
|
-- exactly as it always did.
|
||||||
local composing = ModRuntime.wantsHook("render.compose")
|
local composing = ModRuntime.wantsHook("render.compose")
|
||||||
|
local hasOutputHook = ModRuntime.wantsHook("render.output")
|
||||||
|
and ModRuntime.call("render.output_enabled", function() return false end) == true
|
||||||
local scene = nil
|
local scene = nil
|
||||||
if zoned or fx or composing or Pipelines.wantsPresent() then
|
if zoned or fx or composing or Pipelines.wantsPresent() or hasOutputHook then
|
||||||
scene = self:presentCanvas(1, w, h)
|
scene = self:presentCanvas(1, w, h)
|
||||||
end
|
end
|
||||||
if not scene then
|
if not scene then
|
||||||
@@ -1414,7 +1425,7 @@ function Game2:draw()
|
|||||||
-- untinted one. On its own the tint rides the final blit and no second
|
-- untinted one. On its own the tint rides the final blit and no second
|
||||||
-- canvas is paid for.
|
-- canvas is paid for.
|
||||||
local source = scene
|
local source = scene
|
||||||
local reread = fx or Pipelines.wantsPresent()
|
local reread = fx or Pipelines.wantsPresent() or hasOutputHook
|
||||||
if zoned and reread then
|
if zoned and reread then
|
||||||
local tinted = self:presentCanvas(2, w, h)
|
local tinted = self:presentCanvas(2, w, h)
|
||||||
if tinted then
|
if tinted then
|
||||||
@@ -1434,15 +1445,25 @@ function Game2:draw()
|
|||||||
-- Post-process pipelines run over the finished composite and before GBC
|
-- Post-process pipelines run over the finished composite and before GBC
|
||||||
-- FX. Each hands back a canvas; with none registered this returns `source`
|
-- FX. Each hands back a canvas; with none registered this returns `source`
|
||||||
-- unchanged and the frame is byte-identical (Renderer.lua:1058).
|
-- unchanged and the frame is byte-identical (Renderer.lua:1058).
|
||||||
local scale, _, _, dpi = self:frameFit(w, h)
|
local scale, ox, oy, dpi = self:frameFit(w, h)
|
||||||
source = Pipelines.present(source, { width = w, height = h, scale = scale,
|
source = Pipelines.present(source, { width = w, height = h, scale = scale,
|
||||||
dpi = dpi, dpiX = dpi, dpiY = dpi }) or source
|
dpi = dpi, dpiX = dpi, dpiY = dpi }) or source
|
||||||
if fx then
|
local outputHandled = hasOutputHook
|
||||||
GBCFX.present(source, self:pixelScale(w, h))
|
and ModRuntime.call("render.output", function() return false end, {
|
||||||
else
|
canvas = source, width = w, height = h,
|
||||||
G.setColor(1, 1, 1, 1)
|
gameX = ox, gameY = oy,
|
||||||
G.draw(source, 0, 0)
|
gameWidth = 160 * scale, gameHeight = 144 * scale,
|
||||||
G.setShader()
|
scale = scale, dpiX = dpi, dpiY = dpi,
|
||||||
|
generation = 2,
|
||||||
|
}) == true
|
||||||
|
if not outputHandled then
|
||||||
|
if fx then
|
||||||
|
GBCFX.present(source, self:pixelScale(w, h))
|
||||||
|
else
|
||||||
|
G.setColor(1, 1, 1, 1)
|
||||||
|
G.draw(source, 0, 0)
|
||||||
|
G.setShader()
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
G.pop()
|
G.pop()
|
||||||
@@ -1458,8 +1479,7 @@ end
|
|||||||
-- them but the Pokegear, whose paper is RGB 28,31,20. Nil means white, which
|
-- them but the Pokegear, whose paper is RGB 28,31,20. Nil means white, which
|
||||||
-- is what Font.drawBox does by default.
|
-- is what Font.drawBox does by default.
|
||||||
function Game2:textboxPaper()
|
function Game2:textboxPaper()
|
||||||
local base = self.stack and self.stack.states
|
local base = visibleBaseState(self.stack)
|
||||||
and self.stack.states[self.stack:visibleBase()]
|
|
||||||
if base and base.paperColor then return base:paperColor() end
|
if base and base.paperColor then return base:paperColor() end
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
@@ -1472,10 +1492,11 @@ function Game2:drawScene(w, h)
|
|||||||
|
|
||||||
if self:inFillBoot() then
|
if self:inFillBoot() then
|
||||||
local top = self.stack:top()
|
local top = self.stack:top()
|
||||||
local base = self.stack.states[self.stack:visibleBase()]
|
local base = visibleBaseState(self.stack)
|
||||||
-- Title (and friends) paint sky/clouds edge-to-edge; Oak speech and
|
-- Title (and friends) paint sky/clouds edge-to-edge; Oak speech and
|
||||||
-- name pick paint a paper-white surround via drawWidescreen.
|
-- name pick paint a paper-white surround via drawWidescreen.
|
||||||
local wide = (top and top.drawsWidescreen and top:drawsWidescreen()
|
local wide = (self.stack:renderVisible(top)
|
||||||
|
and top.drawsWidescreen and top:drawsWidescreen()
|
||||||
and top.drawWidescreen) and top
|
and top.drawWidescreen) and top
|
||||||
or (base and base.drawsWidescreen and base:drawsWidescreen()
|
or (base and base.drawsWidescreen and base:drawsWidescreen()
|
||||||
and base.drawWidescreen and base)
|
and base.drawWidescreen and base)
|
||||||
@@ -1524,8 +1545,9 @@ function Game2:drawScene(w, h)
|
|||||||
-- PARTY, PACK, #DEX, PC, DAY-CARE, MAILBOX and TRADE screens whenever a
|
-- PARTY, PACK, #DEX, PC, DAY-CARE, MAILBOX and TRADE screens whenever a
|
||||||
-- TextBox goes up over them.
|
-- TextBox goes up over them.
|
||||||
local top = self.stack:top()
|
local top = self.stack:top()
|
||||||
local base = self.stack.states[self.stack:visibleBase()]
|
local base = visibleBaseState(self.stack)
|
||||||
local wide = (top and top.drawsWidescreen and top:drawsWidescreen()
|
local wide = (self.stack:renderVisible(top)
|
||||||
|
and top.drawsWidescreen and top:drawsWidescreen()
|
||||||
and top.drawWidescreen) and top
|
and top.drawWidescreen) and top
|
||||||
or (base and base.drawsWidescreen and base:drawsWidescreen()
|
or (base and base.drawsWidescreen and base:drawsWidescreen()
|
||||||
and base.drawWidescreen and base)
|
and base.drawWidescreen and base)
|
||||||
@@ -1921,11 +1943,12 @@ function Game2:applyOptions()
|
|||||||
-- the mod pipeline ladder rides options.pipelines and restores with the rest
|
-- the mod pipeline ladder rides options.pipelines and restores with the rest
|
||||||
-- of the display block, as it does in src/core/Game.lua:1041
|
-- of the display block, as it does in src/core/Game.lua:1041
|
||||||
require("src.render.Pipelines").applyOptions(options)
|
require("src.render.Pipelines").applyOptions(options)
|
||||||
-- options.touchControls (the launcher editor's per-orientation layouts) and
|
-- src/core/Game.lua:1121 mirrors this call for Gen 1
|
||||||
-- options.haptics, the same two keys Gen 1 hands over here
|
Input:applyBindings(options.bindings)
|
||||||
-- (src/core/Game.lua:1073). One options.lua serves both games, so the pad a
|
TouchControls:applyOptions({
|
||||||
-- player laid out for Red is already the pad Gold draws.
|
touchControls = options.touchControls,
|
||||||
TouchControls:applyOptions(options)
|
haptics = options.haptics,
|
||||||
|
})
|
||||||
local GBCFX = require("src.render.GBCFX")
|
local GBCFX = require("src.render.GBCFX")
|
||||||
if GBCFX.applyOptions(options) and self.save then
|
if GBCFX.applyOptions(options) and self.save then
|
||||||
-- applyOptions returns true when it had to clear an unsupported level.
|
-- applyOptions returns true when it had to clear an unsupported level.
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
-- Optional native-host display lifecycle.
|
||||||
|
--
|
||||||
|
-- The engine always owns simulation and drawing. A packaged host may install
|
||||||
|
-- one backend to observe per-frame updates and prepare/finalize a render target
|
||||||
|
-- around an otherwise unchanged draw. With no backend installed every method
|
||||||
|
-- is a no-op, which is the normal desktop and mobile path.
|
||||||
|
--
|
||||||
|
-- Backend methods are optional:
|
||||||
|
-- backend:update(dt)
|
||||||
|
-- backend:beginFrame(kind, subject)
|
||||||
|
-- backend:endFrame(kind, subject)
|
||||||
|
--
|
||||||
|
-- `kind` is "editor", "touch_editor", "launcher", or "game". `subject` is
|
||||||
|
-- the object whose existing draw method runs between beginFrame and endFrame.
|
||||||
|
local HostDisplay = {}
|
||||||
|
|
||||||
|
local backend
|
||||||
|
|
||||||
|
function HostDisplay.setBackend(value)
|
||||||
|
if value ~= nil and type(value) ~= "table" then
|
||||||
|
error("host display backend must be a table or nil", 2)
|
||||||
|
end
|
||||||
|
backend = value
|
||||||
|
end
|
||||||
|
|
||||||
|
function HostDisplay.update(dt)
|
||||||
|
local fn = backend and backend.update
|
||||||
|
if fn then return fn(backend, dt) end
|
||||||
|
end
|
||||||
|
|
||||||
|
function HostDisplay.beginFrame(kind, subject)
|
||||||
|
local fn = backend and backend.beginFrame
|
||||||
|
if fn then return fn(backend, kind, subject) end
|
||||||
|
end
|
||||||
|
|
||||||
|
function HostDisplay.endFrame(kind, subject)
|
||||||
|
local fn = backend and backend.endFrame
|
||||||
|
if fn then return fn(backend, kind, subject) end
|
||||||
|
end
|
||||||
|
|
||||||
|
return HostDisplay
|
||||||
+56
-2
@@ -325,7 +325,8 @@ function HostShell.httpDownload(url, absPath, userAgent, accept, maxTime)
|
|||||||
if type(absPath) ~= "string" or absPath == "" then return nil, "missing path" end
|
if type(absPath) ~= "string" or absPath == "" then return nil, "missing path" end
|
||||||
userAgent = userAgent or "gen1recomp"
|
userAgent = userAgent or "gen1recomp"
|
||||||
if HostShell.haveCurl() then
|
if HostShell.haveCurl() then
|
||||||
local cmd = ("curl -fsSL --connect-timeout 15 --max-time %d ")
|
local cmd = ("curl -fsSL --proto =http,https --proto-redir =http,https "
|
||||||
|
.. "--connect-timeout 15 --max-time %d ")
|
||||||
:format(tonumber(maxTime) or 300)
|
:format(tonumber(maxTime) or 300)
|
||||||
.. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " "
|
.. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " "
|
||||||
if accept then
|
if accept then
|
||||||
@@ -370,7 +371,8 @@ function HostShell.httpGet(url, userAgent, accept, maxTime)
|
|||||||
-- BODY, and on the two services this talks to that body is the whole
|
-- BODY, and on the two services this talks to that body is the whole
|
||||||
-- diagnosis: GitHub's 403 says "API rate limit exceeded for <ip>", which
|
-- diagnosis: GitHub's 403 says "API rate limit exceeded for <ip>", which
|
||||||
-- tells a user to wait rather than to go hunting for a broken index.
|
-- tells a user to wait rather than to go hunting for a broken index.
|
||||||
local cmd = ("curl -sSL --connect-timeout 10 --max-time %d ")
|
local cmd = ("curl -sSL --proto =http,https --proto-redir =http,https "
|
||||||
|
.. "--connect-timeout 10 --max-time %d ")
|
||||||
:format(tonumber(maxTime) or 40)
|
:format(tonumber(maxTime) or 40)
|
||||||
.. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " "
|
.. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " "
|
||||||
if accept then
|
if accept then
|
||||||
@@ -415,4 +417,56 @@ function HostShell.httpGet(url, userAgent, accept, maxTime)
|
|||||||
return body
|
return body
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- POST returning success/failure. Strictly one-way: the response body is
|
||||||
|
-- discarded, only the HTTP status class is surfaced (postLog callers never
|
||||||
|
-- trust the reply). curl --data-binary reads the payload from a pipe, so a
|
||||||
|
-- large body never lands in the command line; the Android bridge has no POST
|
||||||
|
-- transport, and httpPost reports that instead of half-working through
|
||||||
|
-- httpDownload (a GET round-trip to a POST endpoint would be a lie).
|
||||||
|
function HostShell.httpPost(url, body, contentType, userAgent, maxTime)
|
||||||
|
if type(url) ~= "string" or url == "" then return nil, "missing url" end
|
||||||
|
if type(body) ~= "string" then return nil, "missing body" end
|
||||||
|
userAgent = userAgent or "gen1recomp"
|
||||||
|
if HostShell.haveCurl() then
|
||||||
|
-- --data-binary @- keeps the payload out of argv (command-line length
|
||||||
|
-- limits on Windows) and preserves every byte including trailing
|
||||||
|
-- newlines. No -f, matching httpGet: the response body is discarded
|
||||||
|
-- anyway, and curl's stderr carries the real diagnosis on failure.
|
||||||
|
local cmd = ("curl -sSL --proto =http,https --proto-redir =http,https "
|
||||||
|
.. "--connect-timeout 10 --max-time %d ")
|
||||||
|
:format(tonumber(maxTime) or 40)
|
||||||
|
.. "-X POST "
|
||||||
|
.. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " "
|
||||||
|
if contentType then
|
||||||
|
cmd = cmd .. "-H " .. HostShell.quote("Content-Type: " .. contentType) .. " "
|
||||||
|
end
|
||||||
|
cmd = cmd .. "-H " .. HostShell.quote("Content-Length: " .. tostring(#body)) .. " "
|
||||||
|
.. "--data-binary @- "
|
||||||
|
.. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " "
|
||||||
|
.. HostShell.quote(url) .. " 2>&1"
|
||||||
|
local pipe = HostShell.popen(cmd, "rw")
|
||||||
|
if not pipe then return nil, "could not run curl" end
|
||||||
|
local writeOk, werr = pcall(pipe.write, pipe, body)
|
||||||
|
if not writeOk then
|
||||||
|
HostShell.pclose(pipe)
|
||||||
|
return nil, "could not write body: " .. tostring(werr)
|
||||||
|
end
|
||||||
|
local readOk, out = pcall(function() return pipe:read("*a") end)
|
||||||
|
HostShell.pclose(pipe)
|
||||||
|
if not readOk then
|
||||||
|
return nil, fetchError(url, nil, tostring(out))
|
||||||
|
end
|
||||||
|
local _, status, noise = splitCurlOutput(out)
|
||||||
|
if not status then return nil, fetchError(url, nil, noise) end
|
||||||
|
if status < 200 or status >= 300 then
|
||||||
|
return nil, fetchError(url, status, "log post rejected")
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
if not haveBridge() then
|
||||||
|
return nil, "no network transport on this platform"
|
||||||
|
end
|
||||||
|
return nil, "no POST transport on this platform"
|
||||||
|
end
|
||||||
|
|
||||||
return HostShell
|
return HostShell
|
||||||
|
|||||||
@@ -119,6 +119,34 @@ function Input:reset()
|
|||||||
self.stickAxis = { x = 0, y = 0 }
|
self.stickAxis = { x = 0, y = 0 }
|
||||||
self.stickDir = nil
|
self.stickDir = nil
|
||||||
self.hatDirs = {}
|
self.hatDirs = {}
|
||||||
|
self.captureArmed = false
|
||||||
|
self.captureEvents = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
function Input:armCapture()
|
||||||
|
self.captureArmed = true
|
||||||
|
self.captureEvents = {}
|
||||||
|
end
|
||||||
|
|
||||||
|
function Input:disarmCapture()
|
||||||
|
self.captureArmed = false
|
||||||
|
self.captureEvents = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
function Input:takeCaptureEvents()
|
||||||
|
local ev = self.captureEvents
|
||||||
|
self.captureEvents = self.captureArmed and {} or nil
|
||||||
|
return ev
|
||||||
|
end
|
||||||
|
|
||||||
|
local function noteCapture(self, kind, phase, value)
|
||||||
|
if not self.captureArmed then return end
|
||||||
|
local ev = self.captureEvents
|
||||||
|
if not ev then
|
||||||
|
ev = {}
|
||||||
|
self.captureEvents = ev
|
||||||
|
end
|
||||||
|
ev[#ev + 1] = { kind = kind, phase = phase, value = value }
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Multiple physical sources (W + Up, d-pad + stick, etc.) can claim the
|
-- Multiple physical sources (W + Up, d-pad + stick, etc.) can claim the
|
||||||
@@ -154,6 +182,7 @@ local function release(self, btn, source)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function Input:keypressed(key)
|
function Input:keypressed(key)
|
||||||
|
noteCapture(self, "key", "pressed", key)
|
||||||
local btn = self.keyBindings[key]
|
local btn = self.keyBindings[key]
|
||||||
if btn then
|
if btn then
|
||||||
press(self, btn, "key:" .. key)
|
press(self, btn, "key:" .. key)
|
||||||
@@ -161,6 +190,7 @@ function Input:keypressed(key)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function Input:keyreleased(key)
|
function Input:keyreleased(key)
|
||||||
|
noteCapture(self, "key", "released", key)
|
||||||
local btn = self.keyBindings[key]
|
local btn = self.keyBindings[key]
|
||||||
if btn then
|
if btn then
|
||||||
release(self, btn, "key:" .. key)
|
release(self, btn, "key:" .. key)
|
||||||
@@ -221,6 +251,7 @@ function Input:sourceRelease(btn, source)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function Input:gamepadpressed(joystick, button)
|
function Input:gamepadpressed(joystick, button)
|
||||||
|
noteCapture(self, "pad", "pressed", button)
|
||||||
local btn = self.padBindings[button]
|
local btn = self.padBindings[button]
|
||||||
if btn then
|
if btn then
|
||||||
press(self, btn, "pad:" .. button)
|
press(self, btn, "pad:" .. button)
|
||||||
@@ -228,6 +259,7 @@ function Input:gamepadpressed(joystick, button)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function Input:gamepadreleased(joystick, button)
|
function Input:gamepadreleased(joystick, button)
|
||||||
|
noteCapture(self, "pad", "released", button)
|
||||||
local btn = self.padBindings[button]
|
local btn = self.padBindings[button]
|
||||||
if btn then
|
if btn then
|
||||||
release(self, btn, "pad:" .. button)
|
release(self, btn, "pad:" .. button)
|
||||||
@@ -249,12 +281,14 @@ end
|
|||||||
|
|
||||||
function Input:joystickpressed(joystick, button)
|
function Input:joystickpressed(joystick, button)
|
||||||
if GamepadMap.ignoreRawForJoystick(joystick) then return end
|
if GamepadMap.ignoreRawForJoystick(joystick) then return end
|
||||||
|
noteCapture(self, "joy", "pressed", button)
|
||||||
local btn = self.joyBindings[button]
|
local btn = self.joyBindings[button]
|
||||||
if btn then press(self, btn, "joy:" .. button) end
|
if btn then press(self, btn, "joy:" .. button) end
|
||||||
end
|
end
|
||||||
|
|
||||||
function Input:joystickreleased(joystick, button)
|
function Input:joystickreleased(joystick, button)
|
||||||
if GamepadMap.ignoreRawForJoystick(joystick) then return end
|
if GamepadMap.ignoreRawForJoystick(joystick) then return end
|
||||||
|
noteCapture(self, "joy", "released", button)
|
||||||
local btn = self.joyBindings[button]
|
local btn = self.joyBindings[button]
|
||||||
if btn then release(self, btn, "joy:" .. button) end
|
if btn then release(self, btn, "joy:" .. button) end
|
||||||
end
|
end
|
||||||
|
|||||||
+19
-2
@@ -64,6 +64,7 @@ state = {
|
|||||||
fanfareResume = false, -- start/resume state.source when the fanfare ends
|
fanfareResume = false, -- start/resume state.source when the fanfare ends
|
||||||
fade = nil, -- active volume-ramp fade-out (see Music.fadeOut)
|
fade = nil, -- active volume-ramp fade-out (see Music.fadeOut)
|
||||||
tempo = nil, -- alternate-tempo override in force for `current`
|
tempo = nil, -- alternate-tempo override in force for `current`
|
||||||
|
start = nil,
|
||||||
failed = {}, -- labels whose def could not be started; logged once
|
failed = {}, -- labels whose def could not be started; logged once
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,9 +216,11 @@ function Music.play(data, song, loop, ctx)
|
|||||||
song = selectSong(song, ctx)
|
song = selectSong(song, ctx)
|
||||||
|
|
||||||
local tempo = ctx and ctx.tempo or nil
|
local tempo = ctx and ctx.tempo or nil
|
||||||
|
local start = ctx and ctx.start or nil
|
||||||
-- a hook may silence the cue outright, or swap in a label the dedupe
|
-- a hook may silence the cue outright, or swap in a label the dedupe
|
||||||
-- below has to compare against
|
-- below has to compare against
|
||||||
if not song or (song == state.current and tempo == state.tempo) then return end
|
if not song or (song == state.current and tempo == state.tempo
|
||||||
|
and start == state.start) then return end
|
||||||
local def = songDef(data, song)
|
local def = songDef(data, song)
|
||||||
if not def or state.failed[song] then return end
|
if not def or state.failed[song] then return end
|
||||||
|
|
||||||
@@ -240,6 +243,17 @@ function Music.play(data, song, loop, ctx)
|
|||||||
slowed.tempo = tempo
|
slowed.tempo = tempo
|
||||||
def = slowed
|
def = slowed
|
||||||
end
|
end
|
||||||
|
if start == "rival" and song == "Music_MeetRival"
|
||||||
|
and def.bank == 2 and def.address == 17050 then
|
||||||
|
local started = {}
|
||||||
|
for key, value in pairs(def) do started[key] = value end
|
||||||
|
started.startChannels = {
|
||||||
|
{ number = 1, address = 0x71a2 },
|
||||||
|
{ number = 2, address = 0x721d },
|
||||||
|
{ number = 3, address = 0x72b5 },
|
||||||
|
}
|
||||||
|
def = started
|
||||||
|
end
|
||||||
local wantLoop = loop ~= false
|
local wantLoop = loop ~= false
|
||||||
local src, loopSrc, isChip, err = startSong(data, def, wantLoop)
|
local src, loopSrc, isChip, err = startSong(data, def, wantLoop)
|
||||||
if not src then
|
if not src then
|
||||||
@@ -276,6 +290,7 @@ function Music.play(data, song, loop, ctx)
|
|||||||
state.source, state.loopSource, state.chip = src, loopSrc, isChip
|
state.source, state.loopSource, state.chip = src, loopSrc, isChip
|
||||||
state.current = song
|
state.current = song
|
||||||
state.tempo = tempo
|
state.tempo = tempo
|
||||||
|
state.start = start
|
||||||
if Runtime.wants("music.started") then
|
if Runtime.wants("music.started") then
|
||||||
Runtime.emit("music.started", {
|
Runtime.emit("music.started", {
|
||||||
song = song, previous = previous, chip = isChip,
|
song = song, previous = previous, chip = isChip,
|
||||||
@@ -290,7 +305,7 @@ function Music.stop()
|
|||||||
stopSource(state.loopSource)
|
stopSource(state.loopSource)
|
||||||
require("src.core.ChipAudio").stopMusic()
|
require("src.core.ChipAudio").stopMusic()
|
||||||
state.current, state.source, state.loopSource, state.fade = nil, nil, nil, nil
|
state.current, state.source, state.loopSource, state.fade = nil, nil, nil, nil
|
||||||
state.tempo = nil
|
state.tempo, state.start = nil, nil
|
||||||
state.chip = false
|
state.chip = false
|
||||||
state.pendingRestore = nil
|
state.pendingRestore = nil
|
||||||
if previous and Runtime.wants("music.stopped") then
|
if previous and Runtime.wants("music.stopped") then
|
||||||
@@ -460,6 +475,8 @@ end
|
|||||||
function Music.applyOptions(opts)
|
function Music.applyOptions(opts)
|
||||||
Music.setVolumeLevel(opts and opts.musicVol or 7)
|
Music.setVolumeLevel(opts and opts.musicVol or 7)
|
||||||
Music.setFilterLevel(opts and opts.musicFilter or 0)
|
Music.setFilterLevel(opts and opts.musicFilter or 0)
|
||||||
|
-- engine/menus/options_menu.asm SOUND row (wOptions STEREO bit)
|
||||||
|
require("src.core.ChipAudio").setStereo(opts and opts.sound == "STEREO")
|
||||||
end
|
end
|
||||||
|
|
||||||
local function sourceStopped(src)
|
local function sourceStopped(src)
|
||||||
|
|||||||
@@ -33,6 +33,14 @@ return function(data, label, fallback, ...)
|
|||||||
local args = { ... }
|
local args = { ... }
|
||||||
if #args == 0 then return text end
|
if #args == 0 then return text end
|
||||||
|
|
||||||
|
if #args == 1 and type(args[1]) == "table" then
|
||||||
|
local values = args[1]
|
||||||
|
return (text:gsub("%b{}", function(token)
|
||||||
|
local value = values[token] or values[token:sub(2, -2)]
|
||||||
|
return value == nil and token or tostring(value)
|
||||||
|
end))
|
||||||
|
end
|
||||||
|
|
||||||
local slots, named = 0, 0
|
local slots, named = 0, 0
|
||||||
for token in text:gmatch("%b{}") do
|
for token in text:gmatch("%b{}") do
|
||||||
slots = slots + 1
|
slots = slots + 1
|
||||||
|
|||||||
+230
-21
@@ -304,6 +304,10 @@ function SaveData.defaultOptions()
|
|||||||
-- options.lua written before this key keeps its exact meaning. Read and
|
-- options.lua written before this key keeps its exact meaning. Read and
|
||||||
-- written through SaveData.modEnabled / SaveData.setModEnabled.
|
-- written through SaveData.modEnabled / SaveData.setModEnabled.
|
||||||
modsByVersion = {},
|
modsByVersion = {},
|
||||||
|
-- Set after the first per-game enablement migration. Older options files
|
||||||
|
-- have only `mods`, so the migration copies each installed mod's current
|
||||||
|
-- answer to every game before game-specific toggles begin changing it.
|
||||||
|
modsByVersionMigrated = false,
|
||||||
-- Named setups the player can switch between (#593; src/mods/ModProfile.lua
|
-- Named setups the player can switch between (#593; src/mods/ModProfile.lua
|
||||||
-- owns the shape, src/mods/ManagerState.lua the UI): each row is
|
-- owns the shape, src/mods/ManagerState.lua the UI): each row is
|
||||||
-- { name, enabled = {id=bool}, options = {id={k=v}}, slots = {version=slotId} }.
|
-- { name, enabled = {id=bool}, options = {id={k=v}}, slots = {version=slotId} }.
|
||||||
@@ -335,12 +339,18 @@ function SaveData.defaultOptions()
|
|||||||
-- TouchControls.normalizeConfig folds it into both orientations on load.
|
-- TouchControls.normalizeConfig folds it into both orientations on load.
|
||||||
touchControls = { enabled = true },
|
touchControls = { enabled = true },
|
||||||
-- Haptic feedback level for on-screen pad presses (#806):
|
-- Haptic feedback level for on-screen pad presses (#806):
|
||||||
-- off | light | medium | heavy, mapped to a love.system.vibrate
|
-- off | light | normal | strong, mapped to a love.system.vibrate duration
|
||||||
-- duration in src/core/TouchControls.lua. LIGHT by default, like the
|
-- in src/core/TouchControls.lua. LIGHT by default, so an options.lua
|
||||||
-- overlay itself defaulting on, so an options.lua predating this key
|
-- predating this key gets the tick without going looking for the row.
|
||||||
-- gets the tick without going looking for the row. Inert wherever the
|
-- Inert wherever the overlay never appears (desktop) or LOVE has no
|
||||||
-- overlay never appears (desktop) or LOVE has no vibrator.
|
-- vibrator.
|
||||||
haptics = "light",
|
haptics = "light",
|
||||||
|
-- Shared UI/mod timestamp presentation. DEVICE follows the process time
|
||||||
|
-- locale where the platform exposes one; otherwise DateTime falls back to
|
||||||
|
-- DD-MM-YYYY and 24-hour time. Kept in options.lua so checkpoints never
|
||||||
|
-- rewind presentation preferences.
|
||||||
|
dateFormat = "device", -- device | dmy | mdy | ymd
|
||||||
|
timeFormat = "device", -- device | 24h | 12h
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -557,19 +567,68 @@ end
|
|||||||
-- only holds the games the player actually chose for, so a mod set can differ
|
-- only holds the games the player actually chose for, so a mod set can differ
|
||||||
-- between Red and Gold without either one owning the other's flags.
|
-- between Red and Gold without either one owning the other's flags.
|
||||||
|
|
||||||
-- Whether a per-game answer is honoured at boot. The loader reads the enable
|
-- Per-game answers are live. Every reader and writer goes through modScope,
|
||||||
-- flags once, before any entry chunk (src/mods/Loader.lua _loadState), so this
|
-- so a choice made in the launcher is the choice the next boot loads.
|
||||||
-- flips on with that read and not before: until then every writer keeps to the
|
SaveData.PER_VERSION_MODS = true
|
||||||
-- shared flag and no surface promises what the boot does not do.
|
|
||||||
SaveData.PER_VERSION_MODS = false
|
|
||||||
|
|
||||||
-- The version a write should be scoped to: the game asked for once per-game
|
-- The version a write is scoped to: per-game controls name one game; a nil
|
||||||
-- flags are live, nil (the shared flag) while they are only a preview.
|
-- caller still addresses the legacy shared fallback.
|
||||||
function SaveData.modScope(version)
|
function SaveData.modScope(version)
|
||||||
if SaveData.PER_VERSION_MODS then return version end
|
if SaveData.PER_VERSION_MODS then return version end
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Promote an installation that predates per-game flags. `mods` may contain
|
||||||
|
-- manifest rows ({ id, experimental }) or bare ids. A mod with no old entry
|
||||||
|
-- had the loader default: on, except for experimental mods. Copy that answer
|
||||||
|
-- to every game once, preserving any per-game overlay somebody imported before
|
||||||
|
-- this feature shipped. New installs need no rows here: an absent answer
|
||||||
|
-- still defaults to enabled for every game.
|
||||||
|
function SaveData.migrateModEnablement(options, mods)
|
||||||
|
if type(options) ~= "table" or options.modsByVersionMigrated then return false end
|
||||||
|
options.mods = type(options.mods) == "table" and options.mods or {}
|
||||||
|
options.modsByVersion = type(options.modsByVersion) == "table"
|
||||||
|
and options.modsByVersion or {}
|
||||||
|
|
||||||
|
local known = {}
|
||||||
|
for id in pairs(options.mods) do
|
||||||
|
if type(id) == "string" and id ~= "" then known[id] = { id = id } end
|
||||||
|
end
|
||||||
|
for version, bucket in pairs(options.modsByVersion) do
|
||||||
|
if GameVersion.VERSIONS[version] and type(bucket) == "table" then
|
||||||
|
for id in pairs(bucket) do
|
||||||
|
if type(id) == "string" and id ~= "" then known[id] = known[id] or { id = id } end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
for _, mod in ipairs(mods or {}) do
|
||||||
|
local id = type(mod) == "table" and mod.id or mod
|
||||||
|
if type(id) == "string" and id ~= "" then
|
||||||
|
known[id] = type(mod) == "table" and mod or (known[id] or { id = id })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Do not create options.lua just to record an empty migration on a fresh
|
||||||
|
-- no-mod boot. Keep it pending until there is a real installed or saved
|
||||||
|
-- mod answer to preserve.
|
||||||
|
if next(known) == nil then return false end
|
||||||
|
|
||||||
|
for id, mod in pairs(known) do
|
||||||
|
local shared = options.mods[id]
|
||||||
|
if type(shared) ~= "boolean" then shared = not (mod.experimental == true) end
|
||||||
|
for _, version in ipairs(GameVersion.ORDER) do
|
||||||
|
local bucket = options.modsByVersion[version]
|
||||||
|
if type(bucket) ~= "table" then
|
||||||
|
bucket = {}
|
||||||
|
options.modsByVersion[version] = bucket
|
||||||
|
end
|
||||||
|
if type(bucket[id]) ~= "boolean" then bucket[id] = shared end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
options.modsByVersionMigrated = true
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
-- true/false as chosen for `version`, else the shared flag, else nil -- the
|
-- true/false as chosen for `version`, else the shared flag, else nil -- the
|
||||||
-- caller owns the default (the loader enables, the launcher keeps
|
-- caller owns the default (the loader enables, the launcher keeps
|
||||||
-- experimental mods off until asked).
|
-- experimental mods off until asked).
|
||||||
@@ -760,13 +819,54 @@ local function tryMigrateLegacy(version, fs)
|
|||||||
local opts = SaveData.loadOptions(fs)
|
local opts = SaveData.loadOptions(fs)
|
||||||
opts.saveSlots = opts.saveSlots or {}
|
opts.saveSlots = opts.saveSlots or {}
|
||||||
opts.saveSlots[version] = { list = { id }, active = id }
|
opts.saveSlots[version] = { list = { id }, active = id }
|
||||||
|
-- A tool may have allocated the legacy scope before the player made their
|
||||||
|
-- first ordinary SAVE. Promoting that flat save into slot1 must preserve the
|
||||||
|
-- same opaque identity; otherwise title-selected mod storage becomes
|
||||||
|
-- unreachable after the migration even though every durable record exists.
|
||||||
|
local ids = opts.playthroughIds and opts.playthroughIds[version]
|
||||||
|
if type(ids) == "table" and type(ids.legacy) == "string" and ids.legacy ~= "" then
|
||||||
|
if type(ids[id]) ~= "string" or ids[id] == "" then ids[id] = ids.legacy end
|
||||||
|
ids.legacy = nil
|
||||||
|
end
|
||||||
SaveData.saveOptions(opts, fs)
|
SaveData.saveOptions(opts, fs)
|
||||||
return id
|
return id
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Scan the filesystem for orphaned slot files under saves/<version>/ when options.lua
|
||||||
|
-- has no registered slots for this version (e.g. options.lua was reset or lost).
|
||||||
|
local function scanDiskSlots(version, fs)
|
||||||
|
if not fs then return nil end
|
||||||
|
local dir = "saves/" .. version
|
||||||
|
local slots = {}
|
||||||
|
if fs.getDirectoryItems and fs.getInfo and fs.getInfo(dir) then
|
||||||
|
local items = pcall(fs.getDirectoryItems, dir) and fs.getDirectoryItems(dir) or {}
|
||||||
|
local numbers = {}
|
||||||
|
for _, item in ipairs(items) do
|
||||||
|
local slotId = item:match("^(slot%d+)%.lua$")
|
||||||
|
if slotId then
|
||||||
|
local n = tonumber(slotId:match("%d+"))
|
||||||
|
table.insert(numbers, { id = slotId, num = n or 0 })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
table.sort(numbers, function(a, b) return a.num < b.num end)
|
||||||
|
for _, item in ipairs(numbers) do
|
||||||
|
table.insert(slots, item.id)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
for i = 1, 30 do
|
||||||
|
local slotId = "slot" .. i
|
||||||
|
local path = dir .. "/" .. slotId .. ".lua"
|
||||||
|
if fs.getInfo and fs.getInfo(path) then
|
||||||
|
table.insert(slots, slotId)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return #slots > 0 and slots or nil
|
||||||
|
end
|
||||||
|
|
||||||
-- Resolve (once per version per process) which slot in-game saves use: an
|
-- Resolve (once per version per process) which slot in-game saves use: an
|
||||||
-- existing registry wins; otherwise a lazy legacy migration may create
|
-- existing registry wins; otherwise a lazy legacy migration may create
|
||||||
-- slot1; otherwise false, meaning the flat legacy path.
|
-- slot1; otherwise auto-recover disk slots; otherwise false (flat legacy path).
|
||||||
local function ensureVersionSlots(version, fs)
|
local function ensureVersionSlots(version, fs)
|
||||||
if slotsChecked[version] then return end
|
if slotsChecked[version] then return end
|
||||||
slotsChecked[version] = true
|
slotsChecked[version] = true
|
||||||
@@ -780,14 +880,29 @@ local function ensureVersionSlots(version, fs)
|
|||||||
activeSlotCache[version] = reg.active or reg.list[1]
|
activeSlotCache[version] = reg.active or reg.list[1]
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
activeSlotCache[version] = tryMigrateLegacy(version, fs) or false
|
local migrated = tryMigrateLegacy(version, fs)
|
||||||
|
if migrated then
|
||||||
|
activeSlotCache[version] = migrated
|
||||||
|
return
|
||||||
|
end
|
||||||
|
-- Auto-recovery: if options.lua lost its slot registry, scan disk for orphaned slot files
|
||||||
|
local recovered = scanDiskSlots(version, fs)
|
||||||
|
if recovered and #recovered > 0 then
|
||||||
|
opts.saveSlots = opts.saveSlots or {}
|
||||||
|
opts.saveSlots[version] = { list = recovered, active = recovered[1] }
|
||||||
|
SaveData.saveOptions(opts, fs)
|
||||||
|
activeSlotCache[version] = recovered[1]
|
||||||
|
Logger.info("auto-recovered %d save slot(s) for %s from disk", #recovered, version)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
activeSlotCache[version] = false
|
||||||
end
|
end
|
||||||
|
|
||||||
-- (body for the forward-declared saveNames.) Resolves the ACTIVE slot for
|
-- (body for the forward-declared saveNames.) Resolves the ACTIVE slot for
|
||||||
-- the version, falling back to the flat legacy names when no slot is in use.
|
-- the version, falling back to the flat legacy names when no slot is in use.
|
||||||
function saveNames(version)
|
function saveNames(version, injectedFs)
|
||||||
version = version or GameVersion.get()
|
version = version or GameVersion.get()
|
||||||
local fs = persistFs(nil)
|
local fs = persistFs(injectedFs)
|
||||||
ensureVersionSlots(version, fs)
|
ensureVersionSlots(version, fs)
|
||||||
local slot = activeSlotCache[version]
|
local slot = activeSlotCache[version]
|
||||||
if slot then return slotNames(version, slot) end
|
if slot then return slotNames(version, slot) end
|
||||||
@@ -803,9 +918,19 @@ end
|
|||||||
function SaveData.slotSummary(save)
|
function SaveData.slotSummary(save)
|
||||||
if type(save) ~= "table" then return nil, nil end
|
if type(save) ~= "table" then return nil, nil end
|
||||||
local name = save.player and save.player.name or nil
|
local name = save.player and save.player.name or nil
|
||||||
|
-- A Gen 2 slot carries no badge items and fills pokedex.caught, so both
|
||||||
|
-- counts come off wJohtoBadges/wPokedexCaught (engine/menus/intro_menu.asm:461).
|
||||||
|
local vinfo = type(save.version) == "string" and GameVersion.info(save.version)
|
||||||
|
local gen2 = save.generation == 2 or (vinfo and vinfo.generation == 2) or false
|
||||||
local dexCount = 0
|
local dexCount = 0
|
||||||
for _ in pairs((save.pokedex and save.pokedex.owned) or {}) do
|
if gen2 then
|
||||||
dexCount = dexCount + 1
|
for _, has in pairs((save.pokedex and save.pokedex.caught) or {}) do
|
||||||
|
if has then dexCount = dexCount + 1 end
|
||||||
|
end
|
||||||
|
else
|
||||||
|
for _ in pairs((save.pokedex and save.pokedex.owned) or {}) do
|
||||||
|
dexCount = dexCount + 1
|
||||||
|
end
|
||||||
end
|
end
|
||||||
-- playTime is a plain seconds count in a Gen 1 save but a
|
-- playTime is a plain seconds count in a Gen 1 save but a
|
||||||
-- { hours, minutes, seconds, frames } table in a Gen 2 (Gold) save, matching
|
-- { hours, minutes, seconds, frames } table in a Gen 2 (Gold) save, matching
|
||||||
@@ -823,8 +948,21 @@ function SaveData.slotSummary(save)
|
|||||||
end
|
end
|
||||||
local timeText = ("%d:%02d"):format(math.floor(t / 3600),
|
local timeText = ("%d:%02d"):format(math.floor(t / 3600),
|
||||||
math.floor(t / 60) % 60)
|
math.floor(t / 60) % 60)
|
||||||
|
local badges
|
||||||
|
if gen2 then
|
||||||
|
-- Continue_DisplayBadgeCount walks TWO bytes, Johto then Kanto
|
||||||
|
-- (engine/menus/intro_menu.asm:461-469).
|
||||||
|
badges = 0
|
||||||
|
local p = save.player or {}
|
||||||
|
for _, has in pairs(p.badges or {}) do if has then badges = badges + 1 end end
|
||||||
|
for _, has in pairs(p.kantoBadges or {}) do
|
||||||
|
if has then badges = badges + 1 end
|
||||||
|
end
|
||||||
|
else
|
||||||
|
badges = Badges.count(nil, save)
|
||||||
|
end
|
||||||
return name, {
|
return name, {
|
||||||
badges = Badges.count(nil, save),
|
badges = badges,
|
||||||
timeText = timeText,
|
timeText = timeText,
|
||||||
dexCount = dexCount,
|
dexCount = dexCount,
|
||||||
}
|
}
|
||||||
@@ -1075,7 +1213,17 @@ local function rememberPlaythroughId(save, opts, injectedFs)
|
|||||||
if type(id) ~= "string" or id == "" then return opts, false end
|
if type(id) ~= "string" or id == "" then return opts, false end
|
||||||
local version = save.version or GameVersion.get()
|
local version = save.version or GameVersion.get()
|
||||||
local scope = playthroughScope(version, injectedFs)
|
local scope = playthroughScope(version, injectedFs)
|
||||||
opts = opts or SaveData.loadOptions(injectedFs)
|
local persisted = SaveData.loadOptions(injectedFs)
|
||||||
|
if opts then
|
||||||
|
-- Slot selection and opaque playthrough routing are engine-owned launcher
|
||||||
|
-- state. A live game may carry an options snapshot from before a legacy
|
||||||
|
-- save was promoted to slot1; writing that stale snapshot must not erase
|
||||||
|
-- the freshly persisted routing and strand tool storage on next boot.
|
||||||
|
opts.saveSlots = deepCopy(persisted.saveSlots)
|
||||||
|
opts.playthroughIds = deepCopy(persisted.playthroughIds)
|
||||||
|
else
|
||||||
|
opts = persisted
|
||||||
|
end
|
||||||
opts.playthroughIds = opts.playthroughIds or {}
|
opts.playthroughIds = opts.playthroughIds or {}
|
||||||
opts.playthroughIds[version] = opts.playthroughIds[version] or {}
|
opts.playthroughIds[version] = opts.playthroughIds[version] or {}
|
||||||
local changed = opts.playthroughIds[version][scope] ~= id
|
local changed = opts.playthroughIds[version][scope] ~= id
|
||||||
@@ -1110,6 +1258,65 @@ function SaveData.ensurePlaythroughId(save, injectedFs)
|
|||||||
return id
|
return id
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Resolve the already-selected playthrough without changing the supplied save
|
||||||
|
-- or allocating a replacement id. Title tools use this before a normal SAVE:
|
||||||
|
-- Game:load intentionally owns a fresh skeleton there, while the selected
|
||||||
|
-- launcher slot's durable tool data remains bound by the engine-owned mapping.
|
||||||
|
-- This does not make arbitrary identities addressable; callers still need a
|
||||||
|
-- higher-level engine capability that decides when selected resolution is safe.
|
||||||
|
function SaveData.selectedPlaythroughId(save, injectedFs)
|
||||||
|
if type(save) ~= "table" then
|
||||||
|
return nil, "not_in_playthrough", "No selected playthrough is available."
|
||||||
|
end
|
||||||
|
local version = save.version or GameVersion.get()
|
||||||
|
if not knownVersion(version) then
|
||||||
|
return nil, "unknown_game", "The selected game version is unavailable."
|
||||||
|
end
|
||||||
|
local id = save.meta and save.meta.playthroughId
|
||||||
|
if type(id) == "string" and id ~= "" then return id end
|
||||||
|
|
||||||
|
-- Resolve the selected scope first. That may perform the one-time legacy
|
||||||
|
-- save-to-slot migration, which also moves the opaque identity mapping; only
|
||||||
|
-- then read options so this lookup never observes the pre-migration table.
|
||||||
|
local scope = playthroughScope(version, injectedFs)
|
||||||
|
local opts = SaveData.loadOptions(injectedFs)
|
||||||
|
local byVersion = opts.playthroughIds and opts.playthroughIds[version]
|
||||||
|
id = byVersion and byVersion[scope] or nil
|
||||||
|
if type(id) ~= "string" or id == "" then
|
||||||
|
return nil, "no_selected_playthrough",
|
||||||
|
"The selected playthrough has no durable tool state."
|
||||||
|
end
|
||||||
|
return id
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Read only the chronology of the ordinary selected save for title tools.
|
||||||
|
-- This intentionally returns no canonical progress, slot id, path, or raw
|
||||||
|
-- save handle. A legacy pre-id normal save is valid when the selected scope's
|
||||||
|
-- engine-owned mapping identifies it; a stamped id must match exactly.
|
||||||
|
function SaveData.selectedNormalSaveInfo(save, injectedFs)
|
||||||
|
local playthroughId, code, message = SaveData.selectedPlaythroughId(save, injectedFs)
|
||||||
|
if not playthroughId then return nil, code, message end
|
||||||
|
local version = save and (save.version or GameVersion.get())
|
||||||
|
local fs = persistFs(injectedFs)
|
||||||
|
local main, backup, staged = saveNames(version, injectedFs)
|
||||||
|
local normal = readTable(fs, main)
|
||||||
|
or readTable(fs, staged)
|
||||||
|
or readTable(fs, backup)
|
||||||
|
if type(normal) ~= "table" or normal.version ~= version then
|
||||||
|
return { exists = false, savedAt = nil }
|
||||||
|
end
|
||||||
|
local normalId = normal.meta and normal.meta.playthroughId
|
||||||
|
if type(normalId) == "string" and normalId ~= "" and normalId ~= playthroughId then
|
||||||
|
return { exists = false, savedAt = nil }
|
||||||
|
end
|
||||||
|
local savedAt = normal.meta and normal.meta.savedAt
|
||||||
|
if type(savedAt) ~= "number" or savedAt < 0 or savedAt ~= savedAt
|
||||||
|
or savedAt == math.huge or savedAt == -math.huge then
|
||||||
|
savedAt = nil
|
||||||
|
end
|
||||||
|
return { exists = true, savedAt = savedAt }
|
||||||
|
end
|
||||||
|
|
||||||
-- ------- meta
|
-- ------- meta
|
||||||
|
|
||||||
-- the version/engine/mod-set stamp every v2 save carries; mods is the
|
-- the version/engine/mod-set stamp every v2 save carries; mods is the
|
||||||
@@ -1727,6 +1934,8 @@ function SaveData.newGame(boot)
|
|||||||
boot = type(boot) == "table" and boot or {}
|
boot = type(boot) == "table" and boot or {}
|
||||||
local map = boot.startMap or "REDS_HOUSE_2F"
|
local map = boot.startMap or "REDS_HOUSE_2F"
|
||||||
local x, y = boot.startX or 3, boot.startY or 6
|
local x, y = boot.startX or 3, boot.startY or 6
|
||||||
|
local facing = boot.startFacing or "down"
|
||||||
|
if map == "REDS_HOUSE_2F" and boot.version ~= "yellow" then facing = "up" end
|
||||||
local heal = SaveData.defaultHeal(boot)
|
local heal = SaveData.defaultHeal(boot)
|
||||||
local save = {
|
local save = {
|
||||||
meta = { format = Version.saveFormat, mods = {} },
|
meta = { format = Version.saveFormat, mods = {} },
|
||||||
@@ -1737,7 +1946,7 @@ function SaveData.newGame(boot)
|
|||||||
map = map,
|
map = map,
|
||||||
x = x,
|
x = x,
|
||||||
y = y,
|
y = y,
|
||||||
facing = boot.startFacing or "down",
|
facing = facing,
|
||||||
name = boot.playerName or "RED",
|
name = boot.playerName or "RED",
|
||||||
rival = boot.rivalName or "BLUE",
|
rival = boot.rivalName or "BLUE",
|
||||||
-- 16-bit trainer ID rolled at new game (wPlayerID, filled from
|
-- 16-bit trainer ID rolled at new game (wPlayerID, filled from
|
||||||
|
|||||||
@@ -105,17 +105,21 @@ end
|
|||||||
-- would be dropped on every editor save.
|
-- would be dropped on every editor save.
|
||||||
-- love.system.vibrate takes a duration and nothing else, so "intensity" is a
|
-- love.system.vibrate takes a duration and nothing else, so "intensity" is a
|
||||||
-- duration preset: Android runs the platform vibrator for exactly that long,
|
-- duration preset: Android runs the platform vibrator for exactly that long,
|
||||||
-- while iOS ignores the duration and fires the fixed system vibration, so
|
-- while iOS maps each duration to a matching Taptic Engine impact.
|
||||||
-- there the three levels all read as simply on.
|
TouchControls.HAPTICS = { "off", "light", "normal", "strong" }
|
||||||
TouchControls.HAPTICS = { "off", "light", "medium", "heavy" }
|
|
||||||
TouchControls.HAPTIC_DEFAULT = "light"
|
TouchControls.HAPTIC_DEFAULT = "light"
|
||||||
|
|
||||||
local HAPTIC_SECONDS = { off = 0, light = 0.012, medium = 0.025, heavy = 0.045 }
|
local HAPTIC_SECONDS = {
|
||||||
|
off = 0, light = 0.012, normal = 0.025, strong = 0.045,
|
||||||
|
}
|
||||||
local HAPTIC_LABELS = {
|
local HAPTIC_LABELS = {
|
||||||
off = "OFF", light = "LIGHT", medium = "MEDIUM", heavy = "HEAVY",
|
off = "OFF", light = "LIGHT", normal = "NORMAL", strong = "STRONG",
|
||||||
}
|
}
|
||||||
|
|
||||||
function TouchControls.normalizeHaptics(level)
|
function TouchControls.normalizeHaptics(level)
|
||||||
|
if level == "physical" then return "light" end
|
||||||
|
if level == "medium" then return "normal" end
|
||||||
|
if level == "heavy" then return "strong" end
|
||||||
if HAPTIC_SECONDS[level] then return level end
|
if HAPTIC_SECONDS[level] then return level end
|
||||||
return TouchControls.HAPTIC_DEFAULT
|
return TouchControls.HAPTIC_DEFAULT
|
||||||
end
|
end
|
||||||
@@ -211,6 +215,10 @@ function TouchControls.defaultLayout(ww, wh, ox, oy, scale)
|
|||||||
local abW = dpadW * 0.46
|
local abW = dpadW * 0.46
|
||||||
local ssW = dpadW * 0.30
|
local ssW = dpadW * 0.30
|
||||||
local margin = dpadW * 0.12
|
local margin = dpadW * 0.12
|
||||||
|
local ok, GameVersion = pcall(require, "src.core.GameVersion")
|
||||||
|
if ok and GameVersion.isGold and GameVersion.isGold() then
|
||||||
|
margin = math.max(margin, math.min(ww * 0.10, 72))
|
||||||
|
end
|
||||||
return {
|
return {
|
||||||
dpad = { cx = ox + margin + dpadW / 2, cy = oy + wh - margin - dpadW / 2, w = dpadW },
|
dpad = { cx = ox + margin + dpadW / 2, cy = oy + wh - margin - dpadW / 2, w = dpadW },
|
||||||
a = { cx = ox + ww - margin - abW * 0.55, cy = oy + wh - margin - abW * 1.75, w = abW },
|
a = { cx = ox + ww - margin - abW * 0.55, cy = oy + wh - margin - abW * 1.75, w = abW },
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ local Version = {
|
|||||||
-- "-dev" placeholder; CI stamps the real X.Y.Z into
|
-- "-dev" placeholder; CI stamps the real X.Y.Z into
|
||||||
-- the packed game.love only, never the working tree.
|
-- the packed game.love only, never the working tree.
|
||||||
shell = 1, -- native-shell contract this build implements
|
shell = 1, -- native-shell contract this build implements
|
||||||
|
payloadHost = "love", -- native host family for in-place Lua payloads.
|
||||||
|
-- A payload must name the same family; this prevents
|
||||||
|
-- mounting code packaged for a different native host.
|
||||||
minShell = 1, -- lowest shell contract that can RUN this payload.
|
minShell = 1, -- lowest shell contract that can RUN this payload.
|
||||||
-- Bump only when a payload needs a newer native
|
-- Bump only when a payload needs a newer native
|
||||||
-- binary (e.g. a LOVE version bump); an older shell
|
-- binary (e.g. a LOVE version bump); an older shell
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ local function handle(cmd)
|
|||||||
if cmd.channelPitches ~= nil then
|
if cmd.channelPitches ~= nil then
|
||||||
ChipSynth.setChannelPitches(cmd.channelPitches)
|
ChipSynth.setChannelPitches(cmd.channelPitches)
|
||||||
end
|
end
|
||||||
|
if cmd.stereo ~= nil then
|
||||||
|
ChipSynth.setStereo(cmd.stereo)
|
||||||
|
end
|
||||||
local ok, eng = pcall(ChipSynth.newEngine, data, cmd.header,
|
local ok, eng = pcall(ChipSynth.newEngine, data, cmd.header,
|
||||||
{ allowLoops = cmd.allowLoops })
|
{ allowLoops = cmd.allowLoops })
|
||||||
if ok then
|
if ok then
|
||||||
@@ -69,6 +72,7 @@ local function handle(cmd)
|
|||||||
elseif cmd.cmd == "channelMix" then
|
elseif cmd.cmd == "channelMix" then
|
||||||
if cmd.volumes ~= nil then ChipSynth.setChannelVolumes(cmd.volumes) end
|
if cmd.volumes ~= nil then ChipSynth.setChannelVolumes(cmd.volumes) end
|
||||||
if cmd.pitches ~= nil then ChipSynth.setChannelPitches(cmd.pitches) end
|
if cmd.pitches ~= nil then ChipSynth.setChannelPitches(cmd.pitches) end
|
||||||
|
if cmd.stereo ~= nil then ChipSynth.setStereo(cmd.stereo) end
|
||||||
elseif cmd.cmd == "invalidate" then
|
elseif cmd.cmd == "invalidate" then
|
||||||
ChipSynth.invalidateBanks()
|
ChipSynth.invalidateBanks()
|
||||||
elseif cmd.cmd == "quit" then
|
elseif cmd.cmd == "quit" then
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -450,6 +450,20 @@ record("RARE_CANDY", "candy", function(ctx)
|
|||||||
return rareCandy(ctx.mon, ctx.data)
|
return rareCandy(ctx.mon, ctx.data)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
for _, itemId in ipairs({ "SUN_STONE", "MOON_STONE", "FIRE_STONE",
|
||||||
|
"THUNDERSTONE", "WATER_STONE", "LEAF_STONE" }) do
|
||||||
|
record(itemId, "stone", function(ctx)
|
||||||
|
if ctx.mon.item == "EVERSTONE" then
|
||||||
|
return { used = false, text = ItemEffects.TEXT_NO_EFFECT }
|
||||||
|
end
|
||||||
|
local Evolution = require("src.core.gen2.Evolution")
|
||||||
|
local entry = Evolution.checkMon(ctx.data, ctx.mon,
|
||||||
|
{ force = true, item = ctx.item })
|
||||||
|
if not entry then return { used = false, text = ItemEffects.TEXT_NO_EFFECT } end
|
||||||
|
return { used = true, evolution = entry }
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
for itemId in pairs(ItemEffects.REVIVE) do
|
for itemId in pairs(ItemEffects.REVIVE) do
|
||||||
record(itemId, "revive", function(ctx) return revive(ctx.item, ctx.mon) end)
|
record(itemId, "revive", function(ctx) return revive(ctx.item, ctx.mon) end)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ function Nests.landmark(data, index)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local function landmarkOfMap(data, mapId)
|
local function landmarkOfMap(data, mapId)
|
||||||
local def = data and data.maps and data.maps[mapId]
|
local def = data and data.gen2Maps and data.gen2Maps[mapId]
|
||||||
return def and def.landmark
|
return def and def.landmark
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -131,7 +131,7 @@ function Nests.find(data, species, region, save)
|
|||||||
out[#out + 1] = landmark
|
out[#out + 1] = landmark
|
||||||
end
|
end
|
||||||
|
|
||||||
local enc = data and data.encounters
|
local enc = data and data.gen2Encounters
|
||||||
for _, key in ipairs({ "grass", "water" }) do
|
for _, key in ipairs({ "grass", "water" }) do
|
||||||
for mapId, entry in pairs((enc and enc[key]) or {}) do
|
for mapId, entry in pairs((enc and enc[key]) or {}) do
|
||||||
if tableHasSpecies(entry, species) then
|
if tableHasSpecies(entry, species) then
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user