Compare commits
194 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aea38240b2 | |||
| 0142468125 | |||
| e7d898625d | |||
| 5ee4a3cd35 | |||
| 4794ddcfdf | |||
| 6503e982db | |||
| 72ce1fe33e | |||
| 493fca7297 | |||
| 7d5856f93b | |||
| ef43814efc | |||
| bed1062a5f | |||
| 190c03cfca | |||
| cec264ad81 | |||
| 041051e154 | |||
| 92fd4221a0 | |||
| 07c828cab0 | |||
| b89771508e | |||
| 2c1c2fe080 | |||
| 6f4c59df8b | |||
| 0d14980205 | |||
| 1bd6e35931 | |||
| da73a4fe25 | |||
| cd4ef14f13 | |||
| d575a64287 | |||
| ff826ce01e | |||
| 0ab4ef2755 | |||
| 6f343d9b29 | |||
| 41790637f0 | |||
| 4ab0ac433b | |||
| 44c304574b | |||
| c8b05e7aa1 | |||
| 8c6c360ce0 | |||
| 6b6b8f46a1 | |||
| 2d62805128 | |||
| d27c59b18d | |||
| 1905261c5b | |||
| 333949ce0d | |||
| fb95dc5b6a | |||
| 96ed862b50 | |||
| 58681fe62e | |||
| 2ad2e028d1 | |||
| 654650e3ff | |||
| fad7443f94 | |||
| f895293217 | |||
| ebf44f20d3 | |||
| ca4d3d283c | |||
| 5807b8d836 | |||
| b550db3abb | |||
| 01c5eb2189 | |||
| dd0f0e0892 | |||
| d17f0725d8 | |||
| 3b7fc42858 | |||
| c9221daa90 | |||
| 0147e3d87b | |||
| fb2cebd54f | |||
| cb504e5328 | |||
| bdbdbf2ed6 | |||
| 7ae469603c | |||
| 77e189bc2e | |||
| e88f2ef060 | |||
| 16eefcde68 | |||
| 6d55da0ed2 | |||
| 745374d8ae | |||
| 3470e3c4ae | |||
| f76a8b2f96 | |||
| 24a7ff86d4 | |||
| c2e1d810f9 | |||
| 6d6ccb5778 | |||
| d54f9a02e0 | |||
| e094b73536 | |||
| 6e7b9c0848 | |||
| 116a6ba450 | |||
| 478e3bf8eb | |||
| f009fc856c | |||
| 3abc6ceb15 | |||
| b93d070abc | |||
| 921f565f1d | |||
| 6e624724ca | |||
| 51bc4c7437 | |||
| e13271fc85 | |||
| c23f82efdd | |||
| 15fc04e067 | |||
| 61f42cea2b | |||
| 2c1e411e3c | |||
| c0f4315cd8 | |||
| 8c65e76145 | |||
| ea2cd63395 | |||
| 70d7b6383e | |||
| 5ec9ce5f88 | |||
| 087a275189 | |||
| 63b31d234c | |||
| 44f4680b24 | |||
| 06e06e305b | |||
| 464fb47756 | |||
| fe3746fd1a | |||
| 0e407dca7a | |||
| b0ff1552f2 | |||
| d191aaa34d | |||
| a1a70540b8 | |||
| 5c1837b1eb | |||
| bb0f156497 | |||
| 4b0496bad1 | |||
| a56add6d17 | |||
| 48b39c8519 | |||
| decaa006b2 | |||
| c0bd4a35be | |||
| 8c0d0ace4d | |||
| c11c762f15 | |||
| c465f58006 | |||
| 780246c4f6 | |||
| 5714555847 | |||
| 738f7317d7 | |||
| 5e514335c1 | |||
| f5b8b6c85f | |||
| db25c14dfb | |||
| 90163a3ff2 | |||
| f63707c45b | |||
| 7756fdc3cb | |||
| 4bdb9435a4 | |||
| 5cbde96177 | |||
| 03c5a1eddf | |||
| ada0d8abe1 | |||
| dbecc345e3 | |||
| 0f8f6d0e4f | |||
| 911e11a372 | |||
| 2d28d18bf6 | |||
| 0b70d6c535 | |||
| 1e6613e2de | |||
| debfaf28e6 | |||
| 83463a5a59 | |||
| d66a72ac95 | |||
| 1b659dab01 | |||
| 9e01fe2c2c | |||
| 5fa5005786 | |||
| 1ac5b867bb | |||
| 69ef1bfc77 | |||
| 9ed7e05dc1 | |||
| 25166ff3a1 | |||
| c777e85641 | |||
| 06299328f5 | |||
| 5b19259928 | |||
| c2b6a7b937 | |||
| 7c9c2380d2 | |||
| 2468d5042d | |||
| 51c4766ead | |||
| ec9dc29646 | |||
| 934a4c55ca | |||
| 72592665d7 | |||
| 8f88d01cf2 | |||
| 2279617b29 | |||
| 17fbf6cec4 | |||
| 34c4481f96 | |||
| bff40a5d90 | |||
| 354a8b476d | |||
| f0d3c014a7 | |||
| 0dd889b35b | |||
| 032f894f7f | |||
| 9922e235c6 | |||
| 667267d9bb | |||
| ecdea61cfd | |||
| 872d6b4516 | |||
| def270f7c7 | |||
| 93e336b7cb | |||
| 4c8c1cf36b | |||
| 7d9e99ea18 | |||
| b27e5ab017 | |||
| fd9f3da91a | |||
| a7c19be88f | |||
| 9ab80adaca | |||
| 518d61e039 | |||
| 4349a1142f | |||
| 9713977755 | |||
| 63448ca640 | |||
| b36d38815f | |||
| e24f812475 | |||
| fddf619ed2 | |||
| bf83509ef2 | |||
| 6c05b854c4 | |||
| 2baafab027 | |||
| 813f9d959b | |||
| fba87f028c | |||
| cb4647daf0 | |||
| 93374fbbbb | |||
| abe176b26c | |||
| 085180992d | |||
| 9984958193 | |||
| 5871469002 | |||
| 302b2c9591 | |||
| 67a170fd6e | |||
| 66079686fc | |||
| cc5ff987ac | |||
| f8ba51636b | |||
| 6588901e9a | |||
| 142d1358dd |
@@ -0,0 +1 @@
|
|||||||
|
* @bryanthaboi
|
||||||
@@ -12,10 +12,11 @@ name: ci
|
|||||||
#
|
#
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
# Integration branch + release branch. PRs already run via pull_request
|
|
||||||
# (any base); this list is only for post-merge push runs.
|
|
||||||
branches: [dev, main]
|
branches: [dev, main]
|
||||||
|
# PRs into dev only: a dev -> main ship PR reuses the required checks the
|
||||||
|
# dev push already put on the same head SHA, so it needs no second run.
|
||||||
pull_request:
|
pull_request:
|
||||||
|
branches: [dev]
|
||||||
|
|
||||||
# a force-push while CI is mid-run should cancel the stale run, not queue
|
# a force-push while CI is mid-run should cancel the stale run, not queue
|
||||||
concurrency:
|
concurrency:
|
||||||
@@ -318,52 +319,10 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
scripts/build_linux_arm64.sh --version 0.0.0
|
scripts/build_linux_arm64.sh --version 0.0.0
|
||||||
|
# Shared with the release workflow so shipped images get the same
|
||||||
|
# self-contained / glibc-floor checks as PR builds.
|
||||||
- name: Verify the AppImage is self-contained and bullseye-compatible
|
- name: Verify the AppImage is self-contained and bullseye-compatible
|
||||||
run: |
|
run: bash scripts/linux-arm64/verify_appimage.sh dist/linux-arm64/gen1recomp-0.0.0-linux-arm64.AppImage
|
||||||
set -euo pipefail
|
|
||||||
image="dist/linux-arm64/gen1recomp-0.0.0-linux-arm64.AppImage"
|
|
||||||
|
|
||||||
# --appimage-extract needs no FUSE, so this works on a runner
|
|
||||||
# without /dev/fuse and still exercises the real payload.
|
|
||||||
"$image" --appimage-extract >/dev/null
|
|
||||||
for required in AppRun bin/love game.love lib/liblove-11.5.so; do
|
|
||||||
[ -e "squashfs-root/$required" ] \
|
|
||||||
|| { echo "::error::AppImage is missing $required"; exit 1; }
|
|
||||||
done
|
|
||||||
|
|
||||||
# Every bundled object must resolve once AppRun's LD_LIBRARY_PATH is
|
|
||||||
# applied; an unresolved soname here is a user-visible launch crash.
|
|
||||||
#
|
|
||||||
# This runs on a HEADLESS runner on purpose, and that is the point.
|
|
||||||
# The first version of this build bundled Debian's SDL2, which
|
|
||||||
# hard-links libpulse/libasound/libX11/libwayland, so it only ever
|
|
||||||
# started on a full desktop -- a bare runner is what exposed it.
|
|
||||||
missing="$(LD_LIBRARY_PATH="$PWD/squashfs-root/lib" \
|
|
||||||
ldd squashfs-root/bin/love squashfs-root/lib/*.so* 2>/dev/null \
|
|
||||||
| grep 'not found' || true)"
|
|
||||||
[ -z "$missing" ] || { echo "::error::unresolved deps:"; echo "$missing"; exit 1; }
|
|
||||||
|
|
||||||
# Nothing may hard-link a driver, session or audio-stack library:
|
|
||||||
# those must be reached through dlopen so the AppImage runs on a box
|
|
||||||
# with only ALSA, only Wayland, or only KMSDRM.
|
|
||||||
linked="$(for f in squashfs-root/bin/love squashfs-root/lib/*.so*; do
|
|
||||||
objdump -p "$f" 2>/dev/null | awk '/NEEDED/{print $2}'
|
|
||||||
done | sort -u | grep -E '^lib(pulse|asound|X11|wayland|GL|EGL|drm|gbm|xcb|cairo|sndio|dbus)' || true)"
|
|
||||||
[ -z "$linked" ] \
|
|
||||||
|| { echo "::error::these must be dlopened, not linked:"; echo "$linked"; exit 1; }
|
|
||||||
|
|
||||||
# The whole point of compiling on bullseye. If a future change moves
|
|
||||||
# the builder to a newer base, the glibc floor silently rises and
|
|
||||||
# every user on an older distro gets "GLIBC_2.xx not found" -- catch
|
|
||||||
# it here instead of in a release.
|
|
||||||
floor="$(objdump -T squashfs-root/bin/love squashfs-root/lib/*.so* 2>/dev/null \
|
|
||||||
| grep -o 'GLIBC_[0-9.]*' | sort -V | tail -1)"
|
|
||||||
echo "highest required glibc symbol version: $floor"
|
|
||||||
[ -n "$floor" ] \
|
|
||||||
|| { echo "::error::found no versioned glibc symbols -- objdump read nothing"; exit 1; }
|
|
||||||
highest="$(printf '%s\n' "$floor" "GLIBC_2.31" | sort -V | tail -1)"
|
|
||||||
[ "$highest" = "GLIBC_2.31" ] \
|
|
||||||
|| { echo "::error::AppImage requires $floor, above the bullseye 2.31 floor"; exit 1; }
|
|
||||||
- name: Upload the AppImage
|
- name: Upload the AppImage
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
@@ -400,7 +359,6 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
- run: sudo apt-get update && sudo apt-get install -y luajit
|
|
||||||
- run: python3 -m pip install --upgrade pillow
|
- run: python3 -m pip install --upgrade pillow
|
||||||
|
|
||||||
# the fixture PNGs are committed (they are 8x8 placeholders, not
|
# the fixture PNGs are committed (they are 8x8 placeholders, not
|
||||||
@@ -421,13 +379,8 @@ jobs:
|
|||||||
print(f"\n{len(paths)} fixture assets valid")
|
print(f"\n{len(paths)} fixture assets valid")
|
||||||
PY
|
PY
|
||||||
|
|
||||||
# the fingerprint golden is the parity tripwire; prove it still
|
# the fingerprint parity gates (gate_fingerprint / gate_meta_coverage)
|
||||||
# matches the dataset on a clean checkout
|
# run in the headless job via run_engine; this job only guards the PNGs
|
||||||
- name: fingerprint gate
|
|
||||||
run: luajit tests/engine/gate_fingerprint.lua
|
|
||||||
|
|
||||||
- name: parity-guarantee meta-test
|
|
||||||
run: luajit tests/engine/gate_meta_coverage.lua
|
|
||||||
|
|
||||||
# Only the differ is under test here, and the job is named for that. The
|
# Only the differ is under test here, and the job is named for that. The
|
||||||
# capture half of the golden pipeline does not exist: a POKEPORT_DRIVER
|
# capture half of the golden pipeline does not exist: a POKEPORT_DRIVER
|
||||||
@@ -515,3 +468,24 @@ jobs:
|
|||||||
if [ "$found" = "0" ]; then
|
if [ "$found" = "0" ]; then
|
||||||
echo "no committed mods to lint"
|
echo "no committed mods to lint"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
luacheck:
|
||||||
|
name: engine lint (luacheck)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
|
- name: install luacheck
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y lua5.4 liblua5.4-dev luarocks
|
||||||
|
sudo luarocks install luacheck || sudo apt-get install -y lua-check
|
||||||
|
luacheck --version
|
||||||
|
|
||||||
|
- name: luacheck gate (undefined globals, unreachable code)
|
||||||
|
run: ./scripts/lint.sh --gate
|
||||||
|
|
||||||
|
- name: luacheck full report (advisory)
|
||||||
|
continue-on-error: true
|
||||||
|
run: ./scripts/lint.sh
|
||||||
|
|||||||
@@ -161,6 +161,8 @@ jobs:
|
|||||||
scripts/build_linux_arm64.sh \
|
scripts/build_linux_arm64.sh \
|
||||||
--version "${{ needs.version.outputs.version }}" \
|
--version "${{ needs.version.outputs.version }}" \
|
||||||
--game-love .bazinga/work/game.love
|
--game-love .bazinga/work/game.love
|
||||||
|
- name: Verify the AppImage is self-contained and bullseye-compatible
|
||||||
|
run: bash scripts/linux-arm64/verify_appimage.sh "dist/linux-arm64/gen1recomp-${{ needs.version.outputs.version }}-linux-arm64.AppImage"
|
||||||
- name: Upload Linux arm64 release
|
- name: Upload Linux arm64 release
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
@@ -285,7 +287,7 @@ jobs:
|
|||||||
retention-days: 1
|
retention-days: 1
|
||||||
|
|
||||||
release:
|
release:
|
||||||
needs: [version, xbox-uwp, linux-arm64, native-tls-win]
|
needs: [version, love-payload, 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:
|
||||||
@@ -307,6 +309,15 @@ jobs:
|
|||||||
name: gen1tls-win-x64
|
name: gen1tls-win-x64
|
||||||
path: dist/native/win-x64
|
path: dist/native/win-x64
|
||||||
|
|
||||||
|
# The same game.love the arm64 AppImage and Xbox UWP builds fused, so
|
||||||
|
# every release asset ships one identical payload (build.sh's own pack
|
||||||
|
# would omit PATCH_NOTES.md and mobile/ios/app-repo.json).
|
||||||
|
- name: Download shared payload
|
||||||
|
uses: actions/download-artifact@v8
|
||||||
|
with:
|
||||||
|
name: gen1recomp-release-love
|
||||||
|
path: dist/payload
|
||||||
|
|
||||||
- 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: |
|
||||||
@@ -357,14 +368,36 @@ jobs:
|
|||||||
echo "::error::gen1tls.dll missing at $GEN1TLS_DLL (native-tls-win job)"
|
echo "::error::gen1tls.dll missing at $GEN1TLS_DLL (native-tls-win job)"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
scripts/build.sh all --version "${{ needs.version.outputs.version }}" --no-notarize
|
scripts/build.sh all --version "${{ needs.version.outputs.version }}" --no-notarize \
|
||||||
|
--game-love dist/payload/game.love
|
||||||
unzip -l dist/win/gen1recomp-win64.zip | grep -F gen1tls.dll \
|
unzip -l dist/win/gen1recomp-win64.zip | grep -F gen1tls.dll \
|
||||||
|| { echo "::error::Windows zip is missing gen1tls.dll"; exit 1; }
|
|| { echo "::error::Windows zip is missing gen1tls.dll"; exit 1; }
|
||||||
|
|
||||||
- name: Build Android
|
- name: Materialize Android release signing key
|
||||||
|
env:
|
||||||
|
KEYSTORE_B64: ${{ secrets.ANDROID_RELEASE_KEYSTORE_B64 }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
scripts/build_android.sh --version "${{ needs.version.outputs.version }}"
|
[ -n "$KEYSTORE_B64" ] || {
|
||||||
|
echo "::error::ANDROID_RELEASE_KEYSTORE_B64 is required for a publishable Android update"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
python3 - <<'PY'
|
||||||
|
import base64, os, pathlib
|
||||||
|
encoded = os.environ["KEYSTORE_B64"]
|
||||||
|
path = pathlib.Path(os.environ["RUNNER_TEMP"]) / "gen1recomp-android-release.keystore"
|
||||||
|
path.write_bytes(base64.b64decode(encoded, validate=True))
|
||||||
|
PY
|
||||||
|
|
||||||
|
- name: Build Android
|
||||||
|
env:
|
||||||
|
GEN1RECOMP_ANDROID_KEYSTORE: ${{ runner.temp }}/gen1recomp-android-release.keystore
|
||||||
|
GEN1RECOMP_ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_RELEASE_KEYSTORE_PASSWORD }}
|
||||||
|
GEN1RECOMP_ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_RELEASE_KEY_ALIAS }}
|
||||||
|
GEN1RECOMP_ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_RELEASE_KEY_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
scripts/build_android.sh --release --version "${{ needs.version.outputs.version }}"
|
||||||
|
|
||||||
- name: Install xcbeautify
|
- name: Install xcbeautify
|
||||||
run: |
|
run: |
|
||||||
@@ -486,8 +519,8 @@ jobs:
|
|||||||
[ -f "$arm64_appimage" ] || { echo "::error::$arm64_appimage not found (expected from the linux-arm64 job)"; exit 1; }
|
[ -f "$arm64_appimage" ] || { echo "::error::$arm64_appimage not found (expected from the linux-arm64 job)"; exit 1; }
|
||||||
cp "$arm64_appimage" "$outdir/gen1recomp-${v}-linux-arm64.AppImage"
|
cp "$arm64_appimage" "$outdir/gen1recomp-${v}-linux-arm64.AppImage"
|
||||||
chmod +x "$outdir/gen1recomp-${v}-linux-arm64.AppImage"
|
chmod +x "$outdir/gen1recomp-${v}-linux-arm64.AppImage"
|
||||||
apk="$(find dist/android/debug -name '*.apk' | head -1)"
|
apk="$(find dist/android/release -name '*.apk' | head -1)"
|
||||||
[ -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/release"; 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"
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ read_globals = {
|
|||||||
-- LuaJIT 2.1 ships table.unpack even though the bare 5.1 `table` std lacks
|
-- LuaJIT 2.1 ships table.unpack even though the bare 5.1 `table` std lacks
|
||||||
-- it; without this, every `table.unpack` reads as an undefined field.
|
-- it; without this, every `table.unpack` reads as an undefined field.
|
||||||
table = { fields = { "unpack" } },
|
table = { fields = { "unpack" } },
|
||||||
|
"POKEPORT_DISPLAY_COMPANION",
|
||||||
|
"POKEPORT_EDITOR_MODE",
|
||||||
|
"rawlen",
|
||||||
|
package = { fields = { "searchers" } },
|
||||||
}
|
}
|
||||||
|
|
||||||
-- Vendored/native trees and the test suites have their own conventions.
|
-- Vendored/native trees and the test suites have their own conventions.
|
||||||
@@ -30,6 +34,7 @@ exclude_files = {
|
|||||||
"mobile/",
|
"mobile/",
|
||||||
"tests/",
|
"tests/",
|
||||||
"tools/save-editor/",
|
"tools/save-editor/",
|
||||||
|
"tools/save_convert/vendor/",
|
||||||
}
|
}
|
||||||
|
|
||||||
ignore = {
|
ignore = {
|
||||||
|
|||||||
@@ -140,29 +140,30 @@ ship text.
|
|||||||
|
|
||||||
### 4. `games` (and the legacy `gen2compat`)
|
### 4. `games` (and the legacy `gen2compat`)
|
||||||
|
|
||||||
Pokemon Gold is Gen 2, and it runs its own battle engine, overworld, script
|
Pokemon Gold, Silver and Crystal are Gen 2, and they run their own battle
|
||||||
VM and save format. The mod API is shared across both generations (same hook
|
engine, overworld, script VM and save format. The mod API is shared across both
|
||||||
names, same event names, same registry names) but Gold cannot serve all of it
|
generations (same hook names, same event names, same registry names) but Gen 2
|
||||||
yet, so Gen 2 is opt-in. Say which games the mod is for:
|
cannot serve all of it yet, so it is opt-in. Say which games the mod is for:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
"games": ["gen1", "gen2"]
|
"games": ["gen1", "gen2"]
|
||||||
```
|
```
|
||||||
|
|
||||||
Each entry is a version id (`"red"`, `"blue"`, `"yellow"`, `"gold"`), a
|
Each entry is a version id (`"red"`, `"blue"`, `"yellow"`, `"gold"`,
|
||||||
generation (`"gen1"`, `"gen2"`) or `"all"`;
|
`"silver"`, `"crystal"`), a generation (`"gen1"`, `"gen2"`) or `"all"`;
|
||||||
`src/mods/ModTargets.lua` resolves them off `GameVersion.ORDER` so nothing
|
`src/mods/ModTargets.lua` resolves them off `GameVersion.ORDER` so nothing
|
||||||
restates the game list. `python3 tools/modkit.py scaffold my_mod --games
|
restates the game list, which is why `"gen2"` covers Crystal as well as Gold
|
||||||
gen1,gen2` writes the key for you. The mod still installs to one directory,
|
and Silver. `python3 tools/modkit.py scaffold my_mod --games gen1,gen2` writes
|
||||||
`mods/<id>/`, shared by every game -- targeting is declared, never filed.
|
the key for you. The mod still installs to one directory, `mods/<id>/`, shared
|
||||||
|
by every game -- targeting is declared, never filed.
|
||||||
|
|
||||||
Absent means Gen 1 only, which is what every mod written before the key existed
|
Absent means Gen 1 only, which is what every mod written before the key existed
|
||||||
was tested as. `"gen2compat": true` is the legacy spelling, still accepted and
|
was tested as. `"gen2compat": true` is the legacy spelling, still accepted and
|
||||||
purely additive (it *adds* the Gen 2 games), so no manifest can lose a game it
|
purely additive (it *adds* the Gen 2 games), so no manifest can lose a game it
|
||||||
already ran on. On a Gold boot a mod claiming no Gen 2 game is not loaded at
|
already ran on. On a Gen 2 boot a mod claiming no Gen 2 game is not loaded at
|
||||||
all: the manager lists it as `ENABLED (NOT THIS GAME)` and says why, because a
|
all: the manager lists it as `ENABLED (NOT THIS GAME)` and says why, because a
|
||||||
mod that half-applies reads as a broken mod. Claim Gen 2 once you have actually
|
mod that half-applies reads as a broken mod. Claim Gen 2 once you have actually
|
||||||
run your mod on Gold.
|
run your mod on Gold, Silver or Crystal.
|
||||||
|
|
||||||
Every token is enforced, per game: the loader gates on the same
|
Every token is enforced, per game: the loader gates on the same
|
||||||
`ModTargets.supports` answer both mod surfaces draw, so `"games": ["blue"]`
|
`ModTargets.supports` answer both mod surfaces draw, so `"games": ["blue"]`
|
||||||
@@ -172,10 +173,11 @@ manifest with neither key still covers every Gen 1 game, so nothing written
|
|||||||
before the key existed changes behavior; list both generations or say `"all"`
|
before the key existed changes behavior; list both generations or say `"all"`
|
||||||
when you mean everywhere.
|
when you mean everywhere.
|
||||||
|
|
||||||
`docs/mod-api-gen2-compat.md` is the compatibility matrix: what works on Gold
|
`docs/mod-api-gen2-compat.md` is the compatibility matrix: what works on Gold,
|
||||||
today (40 of the 46 registries, 40 event and 43 hook names shared with Gen 1,
|
Silver and Crystal today (40 of the 46 registries, 40 event and 44 hook names
|
||||||
and 24 Gen 2-only ones), which registries have no Gen 2 home and drop their
|
shared with Gen 1, and 24 Gen 2-only ones), which registries have no Gen 2 home
|
||||||
writes with a report, and which hooks and events are still to come.
|
and drop their writes with a report, and which hooks and events are still to
|
||||||
|
come.
|
||||||
`docs/preparing-your-mod-for-gen2.md` is the step-by-step migration guide for a
|
`docs/preparing-your-mod-for-gen2.md` is the step-by-step migration guide for a
|
||||||
Gen 1 mod, and it is the one to start from.
|
Gen 1 mod, and it is the one to start from.
|
||||||
|
|
||||||
|
|||||||
@@ -53,18 +53,19 @@ And before you say, "that's not a recomp", you're wrong. Recomp is an acronym. *
|
|||||||
|
|
||||||
### Watch the latest update video
|
### Watch the latest update video
|
||||||
|
|
||||||
[](https://www.youtube.com/watch?v=8IOgqbe4YvA)
|
[](https://youtu.be/yi7LkWQPKKM)
|
||||||
|
|
||||||
|
|
||||||
This project does not include a ROM, emulate the Game Boy, transpile assembly,
|
This project does not include a ROM, emulate the Game Boy, transpile assembly,
|
||||||
or download a disassembly. A canonical US Poke Red, Blue, Yellow, or Gold ROM
|
or download a disassembly. A canonical US Poke Red, Blue, Yellow, Gold,
|
||||||
is the only game content input.
|
Silver, or Crystal ROM is the only game content input.
|
||||||
|
|
||||||
The ROM is verified, used during import, and then released from memory. It is
|
The ROM is verified, used during import, and then released from memory. It is
|
||||||
not copied into the cache. Later launches load the private generated cache and
|
not copied into the cache. Later launches load the private generated cache and
|
||||||
do not ask for the ROM again. Red, Blue, Yellow, and Gold can all be imported
|
do not ask for the ROM again. Red, Blue, Yellow, Gold, Silver, and Crystal can
|
||||||
side by side. Gold is Gen 2 Phase 1 (import + launcher; see
|
all be imported side by side. Gold, Silver, and Crystal are Gen 2 Phase 1
|
||||||
`docs/gold-phase1.md`): the Gen 2 engine is still under construction.
|
(import + launcher; see `docs/gold-phase1.md`): the Gen 2 engine is still under
|
||||||
|
construction, and Crystal is the newest of the three, so the launcher lists it
|
||||||
|
as Crystal (Beta).
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -72,13 +73,17 @@ Open the desktop app. On first boot, choose your legally obtained `.gb` /
|
|||||||
`.gbc` file or drop it onto the window. Import takes a few seconds and the
|
`.gbc` file or drop it onto the window. Import takes a few seconds and the
|
||||||
game starts automatically.
|
game starts automatically.
|
||||||
|
|
||||||
Only the canonical US Red, Blue, Yellow (1 MiB), and Gold (2 MiB) ROMs are
|
Only the canonical US Red, Blue, Yellow (1 MiB), Gold, Silver, and Crystal
|
||||||
accepted. The importer verifies SHA-1 before creating any game data:
|
(2 MiB) ROMs are accepted. The importer verifies SHA-1 before creating any
|
||||||
|
game data:
|
||||||
|
|
||||||
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
|
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
|
||||||
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
|
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
|
||||||
- Yellow: `cc7d03262ebfaf2f06772c1a480c7d9d5f4a38e1`
|
- Yellow: `cc7d03262ebfaf2f06772c1a480c7d9d5f4a38e1`
|
||||||
- Gold: `d8b8a3600a465308c9953dfa04f0081c05bdcb94`
|
- Gold: `d8b8a3600a465308c9953dfa04f0081c05bdcb94`
|
||||||
|
- Silver: `49b163f7e57702bc939d642a18f591de55d92dae`
|
||||||
|
- Crystal (1.0): `f4cd194bdee0d04ca4eac29e09b8e4e9d818c133`
|
||||||
|
- Crystal (1.1): `f2f52230b536214ef7c9924f483392993e226cfb`
|
||||||
|
|
||||||
The packaged app contains neither a ROM nor pre-extracted game data. Music,
|
The packaged app contains neither a ROM nor pre-extracted game data. Music,
|
||||||
sound effects, and cries are synthesized while the game runs from compact
|
sound effects, and cries are synthesized while the game runs from compact
|
||||||
@@ -122,24 +127,23 @@ supported out of the box.
|
|||||||
| `2` | Cycle COLORS |
|
| `2` | Cycle COLORS |
|
||||||
| `3` | Cycle TILT (free-roam overworld) |
|
| `3` | Cycle TILT (free-roam overworld) |
|
||||||
| `4` | Cycle ZOOM through every level (free-roam overworld) |
|
| `4` | Cycle ZOOM through every level (free-roam overworld) |
|
||||||
| `5` | Cycle GBC FX |
|
|
||||||
| `F1` | Save |
|
| `F1` | Save |
|
||||||
| `F2` | Load |
|
| `F2` | Load |
|
||||||
| `F10` | Open / close the mod manager |
|
| `F10` | Open / close the mod manager |
|
||||||
|
|
||||||
|
|
||||||
COLORS, TILT, ZOOM, GBC FX, GAME SPEED, and VOID FILL are also in the
|
COLORS, TILT, ZOOM, SHADER FX, GAME SPEED, and VOID FILL are also in the
|
||||||
Options menu and persist in `options.lua`.
|
Options menu and persist in `options.lua`.
|
||||||
|
|
||||||
### Low-end devices
|
### Low-end devices
|
||||||
|
|
||||||
**OPTIONS → PERFORMANCE** scales the port's optional extras for weaker
|
**OPTIONS → PERFORMANCE** scales the port's optional extras for weaker
|
||||||
hardware: **HIGH** (everything on), **BALANCED** (no 3D tilt or GBC FX),
|
hardware: **HIGH** (everything on), **BALANCED** (no 3D tilt),
|
||||||
**LOW** (also no survey zoom, FPS capped), or **AUTO** — the default, which
|
**LOW** (also no survey zoom, FPS capped), or **AUTO** — the default, which
|
||||||
picks a tier from your device (ARM handhelds → LOW, phones → BALANCED,
|
picks a tier from your device (ARM handhelds → LOW, phones → BALANCED,
|
||||||
normal desktops → HIGH, unchanged). It only scales presentation; the
|
normal desktops → HIGH, unchanged). It only scales presentation; the
|
||||||
fixed-step game logic is identical on every tier, and a lower tier hides
|
fixed-step game logic is identical on every tier, and a lower tier hides
|
||||||
your tilt/zoom/GBC-FX preferences without forgetting them. Details in
|
your tilt/zoom preferences without forgetting them. Details in
|
||||||
[docs/new-features.md](docs/new-features.md#performance-tier-low-end-devices).
|
[docs/new-features.md](docs/new-features.md#performance-tier-low-end-devices).
|
||||||
|
|
||||||
### Rulesets
|
### Rulesets
|
||||||
@@ -219,7 +223,7 @@ entry: a desktop shortcut per game, a Steam entry, or a handheld frontend.
|
|||||||
|
|
||||||
| Option | Effect |
|
| Option | Effect |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `--game=red` | boot Red, skipping the launcher (`blue` and `yellow` too, or just `r` / `b` / `y`) |
|
| `--game=red` | boot Red, skipping the launcher (`blue`, `yellow`, `gold`, `silver` and `crystal` too, or just `r` / `b` / `y` / `g` / `s` / `c`) |
|
||||||
| `--slot=2` | load that save slot; takes a slot number or a slot id |
|
| `--slot=2` | load that save slot; takes a slot number or a slot id |
|
||||||
| `--launcher` | open the launcher anyway, so you can edit a shortcut you already made |
|
| `--launcher` | open the launcher anyway, so you can edit a shortcut you already made |
|
||||||
|
|
||||||
|
|||||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -133,6 +133,8 @@ mkdir -p "$GAME_SRC"
|
|||||||
(cd "$SOURCE_DIR" && zip -q -9 -r "$WORK/game-payload.zip" \
|
(cd "$SOURCE_DIR" && 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 \
|
||||||
|
tools/rom_manifest_silver.json tools/rom_manifest_crystal.json \
|
||||||
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
||||||
if unzip -Z1 "$WORK/game-payload.zip" \
|
if unzip -Z1 "$WORK/game-payload.zip" \
|
||||||
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
|
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ mkdir -p "$GAME_SRC"
|
|||||||
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 \
|
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
||||||
|
tools/rom_manifest_silver.json tools/rom_manifest_crystal.json \
|
||||||
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
||||||
payload_list="$(unzip -Z1 "$WORK/game-payload.zip")"
|
payload_list="$(unzip -Z1 "$WORK/game-payload.zip")"
|
||||||
printf '%s\n' "$payload_list" \
|
printf '%s\n' "$payload_list" \
|
||||||
@@ -99,6 +100,10 @@ printf '%s\n' "$payload_list" \
|
|||||||
&& fail "payload unexpectedly contains generated ROM data"
|
&& fail "payload unexpectedly contains generated ROM data"
|
||||||
printf '%s\n' "$payload_list" | grep -qxF "tools/rom_manifest_gold.json" \
|
printf '%s\n' "$payload_list" | grep -qxF "tools/rom_manifest_gold.json" \
|
||||||
|| fail "payload is missing tools/rom_manifest_gold.json"
|
|| fail "payload is missing tools/rom_manifest_gold.json"
|
||||||
|
printf '%s\n' "$payload_list" | grep -qxF "tools/rom_manifest_silver.json" \
|
||||||
|
|| fail "payload is missing tools/rom_manifest_silver.json"
|
||||||
|
printf '%s\n' "$payload_list" | grep -qxF "tools/rom_manifest_crystal.json" \
|
||||||
|
|| fail "payload is missing tools/rom_manifest_crystal.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"
|
||||||
|
|
||||||
@@ -193,6 +198,26 @@ get_controls
|
|||||||
[ -f "${controlfolder}/mod_${CFW_NAME}.txt" ] && source "${controlfolder}/mod_${CFW_NAME}.txt"
|
[ -f "${controlfolder}/mod_${CFW_NAME}.txt" ] && source "${controlfolder}/mod_${CFW_NAME}.txt"
|
||||||
|
|
||||||
GAMEDIR="$SHDIR/gen1recomp"
|
GAMEDIR="$SHDIR/gen1recomp"
|
||||||
|
# Anbernic stock keeps the launcher and the game folder side by side, so the
|
||||||
|
# SHDIR-relative path above is correct there and is tried first.
|
||||||
|
#
|
||||||
|
# Other firmwares (muOS, and PortMaster's layout on several devices) keep
|
||||||
|
# launcher scripts and port data in SEPARATE trees -- scripts under roms/ports,
|
||||||
|
# data under ports -- so the sibling folder holds no game.
|
||||||
|
#
|
||||||
|
# Probe for the BINARY, not the directory: on a split layout this script has
|
||||||
|
# usually already created "$SHDIR/gen1recomp/conf" and log.txt on an earlier
|
||||||
|
# failed run (see mkdir/tee below), so an existence test matches a decoy of our
|
||||||
|
# own making. Stock is unaffected -- its sibling holds the real binary and wins
|
||||||
|
# on the first test.
|
||||||
|
if [ ! -f "$GAMEDIR/bin/love.aarch64" ]; then
|
||||||
|
for candidate in "/$directory/ports/gen1recomp" \
|
||||||
|
"/mnt/sdcard/ports/gen1recomp" \
|
||||||
|
"/mnt/mmc/ports/gen1recomp" \
|
||||||
|
"/roms/ports/gen1recomp"; do
|
||||||
|
if [ -f "$candidate/bin/love.aarch64" ]; then GAMEDIR="$candidate"; break; fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
CONFDIR="$GAMEDIR/conf"
|
CONFDIR="$GAMEDIR/conf"
|
||||||
mkdir -p "$CONFDIR"
|
mkdir -p "$CONFDIR"
|
||||||
|
|
||||||
@@ -205,11 +230,6 @@ export LD_LIBRARY_PATH="$GAMEDIR/libs.aarch64:${LD_LIBRARY_PATH:-}"
|
|||||||
export SDL_GAMECONTROLLERCONFIG="${sdl_controllerconfig:-}"
|
export SDL_GAMECONTROLLERCONFIG="${sdl_controllerconfig:-}"
|
||||||
# Mali / H700: prefer GLES where available
|
# Mali / H700: prefer GLES where available
|
||||||
export LOVE_GRAPHICS_USE_OPENGLES="${LOVE_GRAPHICS_USE_OPENGLES:-1}"
|
export LOVE_GRAPHICS_USE_OPENGLES="${LOVE_GRAPHICS_USE_OPENGLES:-1}"
|
||||||
# Same GPU class as a phone, but getOS() here says "Linux", so the Android
|
|
||||||
# gate (issue #136) would not fire on its own: refuse GBC FX explicitly.
|
|
||||||
# Hides the OPTIONS row, pins the level to OFF, and heals a level already
|
|
||||||
# persisted in options.lua.
|
|
||||||
export POKEPORT_GBCFX="${POKEPORT_GBCFX:-0}"
|
|
||||||
|
|
||||||
$ESUDO chmod a+x ./bin/love.aarch64 2>/dev/null || chmod a+x ./bin/love.aarch64
|
$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
|
$ESUDO chmod 666 /dev/uinput 2>/dev/null || true
|
||||||
@@ -300,13 +320,6 @@ Native LÖVE 11.5 port of gen1recomp for Anbernic RG34XXSP on
|
|||||||
|
|
||||||
In-game controls use the normal PortMaster / SDL pad map (rebind under OPTIONS → CONTROLS).
|
In-game controls use the normal PortMaster / SDL pad map (rebind under OPTIONS → CONTROLS).
|
||||||
|
|
||||||
### Display options
|
|
||||||
|
|
||||||
GBC FX is disabled on this device (the H700's Mali GPU compiles that present
|
|
||||||
pass and then shows a black frame), so the OPTIONS row is hidden. COLORS,
|
|
||||||
TILT, ZOOM, VOID FILL and MAX FPS all work. To try it anyway, launch with
|
|
||||||
`POKEPORT_GBCFX=1`.
|
|
||||||
|
|
||||||
### First run
|
### First run
|
||||||
|
|
||||||
Stock OS has no zenity file picker. Put the `.gb` in `lovegame/`, then press
|
Stock OS has no zenity file picker. Put the `.gb` in `lovegame/`, then press
|
||||||
|
|||||||
@@ -66,6 +66,12 @@ function love.conf(t)
|
|||||||
t.modules.audio = not companion
|
t.modules.audio = not companion
|
||||||
t.modules.joystick = not companion
|
t.modules.joystick = not companion
|
||||||
t.modules.physics = false
|
t.modules.physics = false
|
||||||
|
-- love.sensor exposes raw accelerometer/gyroscope data (love.sensor.getData),
|
||||||
|
-- independent of t.accelerometerjoystick below (which instead maps the
|
||||||
|
-- accelerometer onto joystick axes and stays off -- see that flag's
|
||||||
|
-- comment for why). Explicit here so a future effect (e.g. a tilt-driven
|
||||||
|
-- reflective-screen look) has sensor data available without reviving #468.
|
||||||
|
t.modules.sensor = true
|
||||||
|
|
||||||
-- love.system is not loaded during love.conf; love._os is set by the
|
-- love.system is not loaded during love.conf; love._os is set by the
|
||||||
-- engine before conf runs (LÖVE 11.x / 11.5).
|
-- engine before conf runs (LÖVE 11.x / 11.5).
|
||||||
|
|||||||
@@ -25,8 +25,10 @@
|
|||||||
local Menu = require("src.ui.Menu")
|
local Menu = require("src.ui.Menu")
|
||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
|
|
||||||
-- TMNotebookText (data/text/text_2.asm) has no leading underscore, so the
|
-- TMNotebookText (data/text/text_2.asm) has no leading underscore, but the
|
||||||
-- extractor never collects it and the pamphlet's text is inlined.
|
-- extractor now collects any top-level label in a dedicated text file
|
||||||
|
-- regardless (tools/extract/text.py), so this is the real ROM label --
|
||||||
|
-- the literal below is only the fallback for a catalog without it.
|
||||||
local TM_NOTEBOOK_TEXT = "It's a pamphlet\non TMs.\f...\f"
|
local TM_NOTEBOOK_TEXT = "It's a pamphlet\non TMs.\f...\f"
|
||||||
.. "There are 50 TMs\nin all.\f"
|
.. "There are 50 TMs\nin all.\f"
|
||||||
.. "There are also 5\nHMs that can be\vused repeatedly.\f"
|
.. "There are also 5\nHMs that can be\vused repeatedly.\f"
|
||||||
@@ -70,7 +72,8 @@ return {
|
|||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
if fx == 3 and fy == 4 then
|
if fx == 3 and fy == 4 then
|
||||||
game.stack:push(TextBox.new(game, TM_NOTEBOOK_TEXT))
|
local text = game.data.text or {}
|
||||||
|
game.stack:push(TextBox.new(game, text.TMNotebookText or TM_NOTEBOOK_TEXT))
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -5,8 +5,27 @@
|
|||||||
-- voucher exchange and the BICYCLE/CANCEL price window need more than
|
-- voucher exchange and the BICYCLE/CANCEL price window need more than
|
||||||
-- command rows (#568).
|
-- command rows (#568).
|
||||||
|
|
||||||
|
local TextBox = require("src.render.TextBox")
|
||||||
|
|
||||||
|
-- data/events/hidden_events.asm:542
|
||||||
|
local BIKE_DISPLAYS = {
|
||||||
|
{ 1, 0 }, { 2, 1 }, { 1, 2 }, { 3, 2 }, { 0, 4 }, { 1, 5 },
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
BIKE_SHOP = {
|
BIKE_SHOP = {
|
||||||
|
-- engine/events/hidden_events/new_bike.asm:1
|
||||||
|
onInteract = function(game, ow, fx, fy)
|
||||||
|
for _, c in ipairs(BIKE_DISPLAYS) do
|
||||||
|
if c[1] == fx and c[2] == fy then
|
||||||
|
game.stack:push(TextBox.new(game,
|
||||||
|
(game.data.text or {})._NewBicycleText or "A shiny new\nBICYCLE!"))
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end,
|
||||||
|
|
||||||
talk = {
|
talk = {
|
||||||
-- BikeShopMiddleAgedWomanText (pokered/scripts/BikeShop.asm):
|
-- BikeShopMiddleAgedWomanText (pokered/scripts/BikeShop.asm):
|
||||||
-- always shows the same flavor line, no branching.
|
-- always shows the same flavor line, no branching.
|
||||||
|
|||||||
@@ -36,19 +36,29 @@ M.POKEMON_FAN_CLUB = {
|
|||||||
{ "clear_flag", "EVENT_SEEL_FAN_BOAST" }, -- 8
|
{ "clear_flag", "EVENT_SEEL_FAN_BOAST" }, -- 8
|
||||||
},
|
},
|
||||||
|
|
||||||
-- PokemonFanClubPikachuText (scripts/PokemonFanClub.asm): the
|
-- PokemonFanClubPikachuText (scripts/PokemonFanClub.asm:71): PrintText,
|
||||||
-- PIKACHU itself, just a flavor line (its cry isn't playable in the
|
-- then ld a, PIKACHU / call PlayCry (:76) / call WaitForSoundToFinish.
|
||||||
-- port's talk pipeline, so it's dropped like other cry-only lines).
|
|
||||||
TEXT_POKEMONFANCLUB_PIKACHU = {
|
TEXT_POKEMONFANCLUB_PIKACHU = {
|
||||||
{ "show_text", "_PokemonFanClubPikachuText" },
|
{ "play_cry", "PIKACHU", true }, -- 1 PlayCry (#1649)
|
||||||
|
{ "show_text", "_PokemonFanClubPikachuText" }, -- 2 PrintText
|
||||||
},
|
},
|
||||||
|
|
||||||
-- PokemonFanClubSeelText (scripts/PokemonFanClub.asm): the SEEL
|
-- PokemonFanClubSeelText (scripts/PokemonFanClub.asm:84): the same
|
||||||
-- itself, flavor line only.
|
-- shape with SEEL, PlayCry at :89.
|
||||||
TEXT_POKEMONFANCLUB_SEEL = {
|
TEXT_POKEMONFANCLUB_SEEL = {
|
||||||
{ "show_text", "_PokemonFanClubSeelText" },
|
{ "play_cry", "SEEL", true }, -- 1 PlayCry (#1649)
|
||||||
|
{ "show_text", "_PokemonFanClubSeelText" }, -- 2 PrintText
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
-- pokeyellow/scripts/PokemonFanClub.asm:149 PokemonFanClubClefairyText: Yellow's
|
||||||
|
-- pet is a CLEFAIRY on its own TEXT_POKEMONFANCLUB_CLEFAIRY, PlayCry at :154.
|
||||||
|
if require("src.core.GameVersion").isYellow() then
|
||||||
|
M.POKEMON_FAN_CLUB.talk.TEXT_POKEMONFANCLUB_CLEFAIRY = {
|
||||||
|
{ "play_cry", "CLEFAIRY", true }, -- 1 PlayCry
|
||||||
|
{ "show_text", "_PokemonFanClubClefairyText" }, -- 2 PrintText
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
return M
|
return M
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
-- pokered/scripts/SSAnne1FRooms.asm: SSAnne1FRoomsWigglytuffText
|
-- pokered/scripts/SSAnne1FRooms.asm:66 SSAnne1FRoomsWigglytuffText
|
||||||
-- text_far _SSAnne1FRoomsWigglytuffText; then ld a, WIGGLYTUFF / call PlayCry (cosmetic cry sound, not ported)
|
-- text_far _SSAnne1FRoomsWigglytuffText, then ld a, WIGGLYTUFF / call PlayCry (:70)
|
||||||
return {
|
return {
|
||||||
SS_ANNE_1F_ROOMS = {
|
SS_ANNE_1F_ROOMS = {
|
||||||
talk = {
|
talk = {
|
||||||
TEXT_SSANNE1FROOMS_WIGGLYTUFF = {
|
TEXT_SSANNE1FROOMS_WIGGLYTUFF = {
|
||||||
{"face_player"},
|
{"face_player"},
|
||||||
|
{"play_cry", "WIGGLYTUFF", true}, -- 1 PlayCry (#1687)
|
||||||
{"show_text", "_SSAnne1FRoomsWigglytuffText"},
|
{"show_text", "_SSAnne1FRoomsWigglytuffText"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
-- pokered/scripts/SSAnneB1FRooms.asm: SSAnneB1FRoomsMachokeText
|
-- pokered/scripts/SSAnneB1FRooms.asm:82 SSAnneB1FRoomsMachokeText
|
||||||
-- text_far _SSAnneB1FRoomsMachokeText, then `ld a, MACHOKE / call PlayCry`
|
-- text_far _SSAnneB1FRoomsMachokeText, then ld a, MACHOKE / call PlayCry (:86)
|
||||||
-- (cry playback has no equivalent Commands.lua verb in this port, so only
|
|
||||||
-- the flavor text is ported).
|
|
||||||
return {
|
return {
|
||||||
SS_ANNE_B1F_ROOMS = {
|
SS_ANNE_B1F_ROOMS = {
|
||||||
talk = {
|
talk = {
|
||||||
TEXT_SSANNEB1FROOMS_MACHOKE = {
|
TEXT_SSANNEB1FROOMS_MACHOKE = {
|
||||||
{ "face_player" },
|
{ "face_player" },
|
||||||
|
{ "play_cry", "MACHOKE", true }, -- 1 PlayCry (#1687)
|
||||||
{ "show_text", "_SSAnneB1FRoomsMachokeText" },
|
{ "show_text", "_SSAnneB1FRoomsMachokeText" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,10 +15,10 @@ return {
|
|||||||
-- pick the dish: bit 7 set (~50%) -> Salmon du Salad, else bit 4
|
-- pick the dish: bit 7 set (~50%) -> Salmon du Salad, else bit 4
|
||||||
-- set (~25%) -> Eels au Barbecue, else (~25%) -> Prime Beef Steak.
|
-- set (~25%) -> Eels au Barbecue, else (~25%) -> Prime Beef Steak.
|
||||||
-- The three dish texts (SSAnneKitchenCook7SalmonDuSaladText /
|
-- The three dish texts (SSAnneKitchenCook7SalmonDuSaladText /
|
||||||
-- ...EelsAuBarbecueText / ...PrimeBeefSteakText) aren't extracted
|
-- ...EelsAuBarbecueText / ...PrimeBeefSteakText) have no leading
|
||||||
-- into data/generated/text.lua (no leading underscore in
|
-- underscore in pokered/text/SSAnneKitchen.asm, but the extractor
|
||||||
-- pokered/text/SSAnneKitchen.asm), so their literal strings are
|
-- collects them regardless (tools/extract/text.py); the literals
|
||||||
-- ported here verbatim.
|
-- below are only the fallback for a catalog without them.
|
||||||
TEXT_SSANNEKITCHEN_COOK7 = function(game, ow, npc, done)
|
TEXT_SSANNEKITCHEN_COOK7 = function(game, ow, npc, done)
|
||||||
local t = game.data.text
|
local t = game.data.text
|
||||||
push(game, t._SSAnneKitchenCook7MainCourseIsText
|
push(game, t._SSAnneKitchenCook7MainCourseIsText
|
||||||
@@ -27,13 +27,16 @@ return {
|
|||||||
local dish
|
local dish
|
||||||
if roll <= 2 then
|
if roll <= 2 then
|
||||||
-- bit 7 of hRandomAdd set (~50%)
|
-- bit 7 of hRandomAdd set (~50%)
|
||||||
dish = "Salmon du Salad!\fLes guests may\ngripe it's fish\vagain, however!"
|
dish = t.SSAnneKitchenCook7SalmonDuSaladText
|
||||||
|
or "Salmon du Salad!\fLes guests may\ngripe it's fish\vagain, however!"
|
||||||
elseif roll == 3 then
|
elseif roll == 3 then
|
||||||
-- bit 4 set, bit 7 clear (~25%)
|
-- bit 4 set, bit 7 clear (~25%)
|
||||||
dish = "Eels au Barbecue!\fLes guests will\nmutiny, I fear."
|
dish = t.SSAnneKitchenCook7EelsAuBarbecueText
|
||||||
|
or "Eels au Barbecue!\fLes guests will\nmutiny, I fear."
|
||||||
else
|
else
|
||||||
-- neither bit set (~25%)
|
-- neither bit set (~25%)
|
||||||
dish = "Prime Beef Steak!\fBut, have I enough\nfillets du beef?"
|
dish = t.SSAnneKitchenCook7PrimeBeefSteakText
|
||||||
|
or "Prime Beef Steak!\fBut, have I enough\nfillets du beef?"
|
||||||
end
|
end
|
||||||
push(game, dish, done)
|
push(game, dish, done)
|
||||||
end)
|
end)
|
||||||
|
|||||||
@@ -18,10 +18,11 @@ return {
|
|||||||
|
|
||||||
-- VermilionCityMachopText: cries out, then follows up with a
|
-- VermilionCityMachopText: cries out, then follows up with a
|
||||||
-- second line about stomping the land flat.
|
-- second line about stomping the land flat.
|
||||||
-- (pokered/scripts/VermilionCity.asm)
|
-- (pokered/scripts/VermilionCity.asm:224, PlayCry at :228)
|
||||||
TEXT_VERMILIONCITY_MACHOP = {
|
TEXT_VERMILIONCITY_MACHOP = {
|
||||||
{ "show_text", "_VermilionCityMachopText" }, -- 1
|
{ "play_cry", "MACHOP", true }, -- 1 PlayCry (#1649)
|
||||||
{ "show_text", "_VermilionCityMachopStompingTheLandFlatText" }, -- 2
|
{ "show_text", "_VermilionCityMachopText" }, -- 2
|
||||||
|
{ "show_text", "_VermilionCityMachopStompingTheLandFlatText" }, -- 3
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
-- pokered/scripts/VermilionPidgeyHouse.asm: VermilionPidgeyHousePidgeyText
|
-- pokered/scripts/VermilionPidgeyHouse.asm:15 VermilionPidgeyHousePidgeyText
|
||||||
-- text_far _VermilionPidgeyHousePidgeyText, then text_asm plays the PIDGEY
|
-- text_far, then ld a, PIDGEY / call PlayCry (:19) / call WaitForSoundToFinish
|
||||||
-- cry (ld a, PIDGEY / call PlayCry / call WaitForSoundToFinish) before
|
|
||||||
-- TextScriptEnd. This port has no cry-playback command, so only the
|
|
||||||
-- flavor line is ported.
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
VERMILION_PIDGEY_HOUSE = {
|
VERMILION_PIDGEY_HOUSE = {
|
||||||
talk = {
|
talk = {
|
||||||
TEXT_VERMILIONPIDGEYHOUSE_PIDGEY = {
|
TEXT_VERMILIONPIDGEYHOUSE_PIDGEY = {
|
||||||
{"face_player"},
|
{"face_player"},
|
||||||
|
{"play_cry", "PIDGEY", true}, -- 1 PlayCry (#1649)
|
||||||
{"show_text", "_VermilionPidgeyHousePidgeyText"},
|
{"show_text", "_VermilionPidgeyHousePidgeyText"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -60,22 +60,23 @@ M.VIRIDIAN_CITY = {
|
|||||||
-- you want to know about the two kinds of caterpillar Pokemon;
|
-- you want to know about the two kinds of caterpillar Pokemon;
|
||||||
-- YES -> CATERPIE/WEEDLE description, NO -> "Oh, OK then!".
|
-- YES -> CATERPIE/WEEDLE description, NO -> "Oh, OK then!".
|
||||||
-- ViridianCityYoungster2OkThenText and
|
-- ViridianCityYoungster2OkThenText and
|
||||||
-- ViridianCityYoungster2CaterpieAndWeedleDescriptionText are
|
-- ViridianCityYoungster2CaterpieAndWeedleDescriptionText are defined
|
||||||
-- defined without a leading underscore in pokered/text/ViridianCity.asm
|
-- without a leading underscore in pokered/text/ViridianCity.asm, but
|
||||||
-- and aren't present in data/generated/text.lua, so we fall back to
|
-- tools/extract/text.py now collects them regardless -- the literal
|
||||||
-- the literal strings from pokered. Those fallbacks have to carry the
|
-- strings below are only the fallback for a catalog without them.
|
||||||
-- extractor's markers, not plain newlines: line -> \n, cont -> \v,
|
-- Those fallbacks have to carry the extractor's markers, not plain
|
||||||
-- para -> \f. Spelling cont/para as \n and \n\n put all six lines on
|
-- newlines: line -> \n, cont -> \v, para -> \f. Spelling cont/para as
|
||||||
-- one page with nothing to wait on, so the whole speech scrolled past
|
-- \n and \n\n put all six lines on one page with nothing to wait on,
|
||||||
-- without a button press (#250).
|
-- so the whole speech scrolled past without a button press (#250).
|
||||||
TEXT_VIRIDIANCITY_YOUNGSTER2 = function(game, ow, npc, done)
|
TEXT_VIRIDIANCITY_YOUNGSTER2 = function(game, ow, npc, done)
|
||||||
local t = text(game)
|
local t = text(game)
|
||||||
ask(game, t._ViridianCityYoungster2YouWantToKnowAboutText
|
ask(game, t._ViridianCityYoungster2YouWantToKnowAboutText
|
||||||
or "You want to know\nabout the 2 kinds\vof caterpillar\vPOKéMON?", function(yes)
|
or "You want to know\nabout the 2 kinds\vof caterpillar\vPOKéMON?", function(yes)
|
||||||
if yes then
|
if yes then
|
||||||
push(game, "CATERPIE has no\npoison, but\vWEEDLE does.\fWatch out for its\nPOISON STING!", done)
|
push(game, t.ViridianCityYoungster2CaterpieAndWeedleDescriptionText
|
||||||
|
or "CATERPIE has no\npoison, but\vWEEDLE does.\fWatch out for its\nPOISON STING!", done)
|
||||||
else
|
else
|
||||||
push(game, "Oh, OK then!", done)
|
push(game, t.ViridianCityYoungster2OkThenText or "Oh, OK then!", done)
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
end,
|
end,
|
||||||
|
|||||||
@@ -38,6 +38,22 @@ local function retryTmGive(game, ow, victoryKey, done)
|
|||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- The badge line + its jingle, armed for the battle screen the way
|
||||||
|
-- SaveEndBattleTextPointers does (PewterGym.asm:117-119) (#1606)
|
||||||
|
local function badgeEndBattleText(game, victoryKey)
|
||||||
|
local reward = victoryKey and require("data.scripts.victories")[victoryKey]
|
||||||
|
if not (reward and reward.dialogue) then return nil end
|
||||||
|
local text = game.data.text or {}
|
||||||
|
local pages = {}
|
||||||
|
for _, label in ipairs(reward.dialogue) do
|
||||||
|
if text[label] and text[label] ~= "" then
|
||||||
|
pages[#pages + 1] = text[label]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if #pages == 0 then return nil end
|
||||||
|
return table.concat(pages, "\f"), reward.badgeSound
|
||||||
|
end
|
||||||
|
|
||||||
-- scripts/PewterGym.asm PewterGymBrockText (text_asm): CheckEvent
|
-- scripts/PewterGym.asm PewterGymBrockText (text_asm): CheckEvent
|
||||||
-- EVENT_BEAT_BROCK branches his dialogue. Before the badge he prints
|
-- EVENT_BEAT_BROCK branches his dialogue. Before the badge he prints
|
||||||
-- _PewterGymBrockPreBattleText and engages the leader battle
|
-- _PewterGymBrockPreBattleText and engages the leader battle
|
||||||
@@ -58,7 +74,8 @@ M.PEWTER_GYM.talk = {
|
|||||||
game.data.text._PewterGymBrockPostBattleAdviceText
|
game.data.text._PewterGymBrockPostBattleAdviceText
|
||||||
or "Go to the GYM in\nCERULEAN and test\nyour abilities!", done))
|
or "Go to the GYM in\nCERULEAN and test\nyour abilities!", done))
|
||||||
else
|
else
|
||||||
ow:engageTrainer(npc, done)
|
local text, sound = badgeEndBattleText(game, "OPP_BROCK#1")
|
||||||
|
ow:engageTrainer(npc, done, text, nil, sound)
|
||||||
end
|
end
|
||||||
end,
|
end,
|
||||||
}
|
}
|
||||||
@@ -91,7 +108,8 @@ local function leaderTalk(beatFlag, adviceLabel, fallback, afterAdvice, victoryK
|
|||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
game.data.text[adviceLabel] or fallback, finish))
|
game.data.text[adviceLabel] or fallback, finish))
|
||||||
else
|
else
|
||||||
ow:engageTrainer(npc, done)
|
local text, sound = badgeEndBattleText(game, victoryKey)
|
||||||
|
ow:engageTrainer(npc, done, text, nil, sound)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -168,6 +168,7 @@ 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" }, -- OaksLab.asm:1060
|
||||||
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" },
|
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" },
|
||||||
{ "show_text", "_OaksLabGivePokeballsExplanationText" },
|
{ "show_text", "_OaksLabGivePokeballsExplanationText" },
|
||||||
{ "jump", "end" },
|
{ "jump", "end" },
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ local function joinPrompt(game, ow, done)
|
|||||||
local t = game.data.text
|
local t = game.data.text
|
||||||
local back = function(text)
|
local back = function(text)
|
||||||
game.stack:push(TextBox.new(game, text, function()
|
game.stack:push(TextBox.new(game, text, function()
|
||||||
ow:scriptMove(ow.player, "down", 1, done)
|
ow:scriptMove(ow.player, "down", 1, done, { collide = true })
|
||||||
end))
|
end))
|
||||||
end
|
end
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ M.VIRIDIAN_CITY = {
|
|||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
game.data.text._ViridianCityOldManSleepyPrivatePropertyText
|
game.data.text._ViridianCityOldManSleepyPrivatePropertyText
|
||||||
or "You can't go\nthrough here!\fThis is private\nproperty!",
|
or "You can't go\nthrough here!\fThis is private\nproperty!",
|
||||||
function() ow:scriptMove(ow.player, "down", 1) end))
|
function() ow:scriptMove(ow.player, "down", 1, nil, { collide = true }) end))
|
||||||
return true
|
return true
|
||||||
end,
|
end,
|
||||||
}
|
}
|
||||||
@@ -356,7 +356,7 @@ M.VERMILION_CITY = {
|
|||||||
if shipLeft then
|
if shipLeft then
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
t._VermilionCitySailor1ShipSetSailText or "The ship set sail.",
|
t._VermilionCitySailor1ShipSetSailText or "The ship set sail.",
|
||||||
function() ow:scriptMove(ow.player, "up", 1) end))
|
function() ow:scriptMove(ow.player, "up", 1, nil, { collide = true }) end))
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
-- Walk-past is never facing-right / inFrontOfOrBehindGuardCoords, so
|
-- Walk-past is never facing-right / inFrontOfOrBehindGuardCoords, so
|
||||||
@@ -377,26 +377,39 @@ M.VERMILION_CITY = {
|
|||||||
ask .. "\f"
|
ask .. "\f"
|
||||||
.. (t._VermilionCitySailor1YouNeedATicketText
|
.. (t._VermilionCitySailor1YouNeedATicketText
|
||||||
or "You need a ticket\nto get aboard."),
|
or "You need a ticket\nto get aboard."),
|
||||||
function() ow:scriptMove(ow.player, "up", 1) end))
|
function() ow:scriptMove(ow.player, "up", 1, nil, { collide = true }) end))
|
||||||
return true
|
return true
|
||||||
end,
|
end,
|
||||||
talk = {
|
talk = {
|
||||||
-- the sailor guarding the dock gangway (VermilionCitySailor1Text):
|
-- scripts/VermilionCity.asm:158 (#1651)
|
||||||
-- flashing the ticket just lets you through -- he never hides, and
|
TEXT_VERMILIONCITY_SAILOR1 = function(game, ow, npc, done)
|
||||||
-- once the ship has sailed he only reports it gone
|
local Flags = require("src.script.Flags")
|
||||||
TEXT_VERMILIONCITY_SAILOR1 = {
|
local TextBox = require("src.render.TextBox")
|
||||||
{ "face_player" }, -- 1
|
local t = game.data.text
|
||||||
{ "check_flag", "EVENT_SS_ANNE_LEFT" }, -- 2
|
if Flags.get(game.save, "EVENT_SS_ANNE_LEFT") then
|
||||||
{ "jump_if_true", 11 }, -- 3
|
game.stack:push(TextBox.new(game,
|
||||||
{ "show_text", "_VermilionCitySailor1DoYouHaveATicketText" }, -- 4
|
t._VermilionCitySailor1ShipSetSailText or "The ship set sail.",
|
||||||
{ "check_item", "S_S_TICKET" }, -- 5
|
done))
|
||||||
{ "jump_if_false", 9 }, -- 6
|
return
|
||||||
{ "show_text", "_VermilionCitySailor1FlashedTicketText" }, -- 7
|
end
|
||||||
{ "jump", 12 }, -- 8
|
-- scripts/VermilionCity.asm:195
|
||||||
{ "show_text", "_VermilionCitySailor1YouNeedATicketText" }, -- 9
|
local p = ow and ow.player
|
||||||
{ "jump", 12 }, -- 10
|
if not p or p.facing == "right"
|
||||||
{ "show_text", "_VermilionCitySailor1ShipSetSailText" }, -- 11
|
or (p.cellX == 19 and (p.cellY == 29 or p.cellY == 31)) then
|
||||||
},
|
game.stack:push(TextBox.new(game,
|
||||||
|
t._VermilionCitySailor1WelcomeToSSAnneText
|
||||||
|
or "Welcome to S.S.\nANNE!", done))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local ask = t._VermilionCitySailor1DoYouHaveATicketText
|
||||||
|
or "Welcome to S.S.\nANNE!\fExcuse me, do you\nhave a ticket?"
|
||||||
|
local tail = ((game.save.inventory.S_S_TICKET or 0) > 0)
|
||||||
|
and (t._VermilionCitySailor1FlashedTicketText
|
||||||
|
or "{PLAYER} flashed\nthe S.S.TICKET!")
|
||||||
|
or (t._VermilionCitySailor1YouNeedATicketText
|
||||||
|
or "You need a ticket\nto get aboard.")
|
||||||
|
game.stack:push(TextBox.new(game, ask .. "\f" .. tail, done))
|
||||||
|
end,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -405,13 +418,14 @@ M.SS_ANNE_2F = {
|
|||||||
TEXT_SSANNE2F_RIVAL = {
|
TEXT_SSANNE2F_RIVAL = {
|
||||||
{ "face_player" }, -- 1
|
{ "face_player" }, -- 1
|
||||||
{ "check_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 2
|
{ "check_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 2
|
||||||
{ "jump_if_true", 9 }, -- 3
|
{ "jump_if_true", 9 }, -- 3 (beaten: silent)
|
||||||
{ "show_text", "_SSAnne2FRivalText" }, -- 4
|
{ "show_text", "_SSAnne2FRivalText" }, -- 4
|
||||||
{ "rival_battle", "OPP_RIVAL2", 1 }, -- 5
|
-- SSAnne2FRivalText's text_asm arms SaveEndBattleTextPointers
|
||||||
{ "jump_if_false", 10 }, -- 6
|
-- (scripts/SSAnne2F.asm:199), so the line prints in battle (#1688)
|
||||||
{ "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 7
|
{ "save_end_battle_text", "_SSAnne2FRivalDefeatedText" }, -- 5
|
||||||
{ "show_text", "_SSAnne2FRivalDefeatedText" }, -- 8
|
{ "rival_battle", "OPP_RIVAL2", 1 }, -- 6
|
||||||
{ "jump", 10 }, -- 9 (already beaten: silent)
|
{ "jump_if_false", 9 }, -- 7
|
||||||
|
{ "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 8
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -837,13 +851,14 @@ M.SILPH_CO_11F = {
|
|||||||
-- every Silph rocket leaves off-screen (the street rockets are
|
-- every Silph rocket leaves off-screen (the street rockets are
|
||||||
-- handled by M.SAFFRON_CITY.onEnter in story4.lua). Queued, not
|
-- handled by M.SAFFRON_CITY.onEnter in story4.lua). Queued, not
|
||||||
-- run here: the battle's own callbacks are still unwinding, so
|
-- run here: the battle's own callbacks are still unwinding, so
|
||||||
-- queueScript starts it on the first idle overworld frame --
|
-- queueScript starts it on the first idle overworld frame (#722).
|
||||||
-- after the end-battle "Arrgh!!" box victories.lua OPP_GIOVANNI#2
|
|
||||||
-- pushes (#722).
|
|
||||||
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
|
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
|
||||||
ow:queueScript(silphAftermathRows())
|
ow:queueScript(silphAftermathRows())
|
||||||
end
|
end
|
||||||
end, nil, true)
|
end,
|
||||||
|
-- "Arrgh!!" is armed for the battle screen, not the map
|
||||||
|
-- (scripts/SilphCo11F.asm:264-266 SaveEndBattleTextPointers) #1606
|
||||||
|
game.data.text._SilphCo10FGiovanniILostAgainText, true)
|
||||||
end)
|
end)
|
||||||
end))
|
end))
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -414,7 +414,7 @@ local function saffronGate(guardText, triggers, horizontal)
|
|||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
t._SaffronGateGuardGeeImThirstyText or "Gee, I'm thirsty\nthough!\nThe road's closed.",
|
t._SaffronGateGuardGeeImThirstyText or "Gee, I'm thirsty\nthough!\nThe road's closed.",
|
||||||
function()
|
function()
|
||||||
ow:scriptMove(ow.player, back, 1)
|
ow:scriptMove(ow.player, back, 1, nil, { collide = true })
|
||||||
end))
|
end))
|
||||||
return true
|
return true
|
||||||
end,
|
end,
|
||||||
@@ -731,12 +731,35 @@ M.MT_MOON_B2F = {
|
|||||||
local function museumClerk(game, ow, done, onDecline)
|
local function museumClerk(game, ow, done, onDecline)
|
||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
local t = game.data.text or {}
|
local t = game.data.text or {}
|
||||||
|
local p = ow and ow.player
|
||||||
|
-- scripts/Museum1F.asm:45 (#1690)
|
||||||
|
if p and ((p.cellY == 4 and p.cellX == 13)
|
||||||
|
or (p.cellY == 3 and p.cellX == 12)) then
|
||||||
|
game.stack:push(TextBox.new(game,
|
||||||
|
t._Museum1FScientist1DoYouKnowWhatAmberIsText
|
||||||
|
or "You can't sneak\nin the back way!\fOh, whatever!\nDo you know what\vAMBER is?",
|
||||||
|
nil, { choice = function(yes)
|
||||||
|
game.stack:push(TextBox.new(game, yes
|
||||||
|
and (t._Museum1FScientist1TheresALabSomewhereText
|
||||||
|
or "There's a lab\nsomewhere trying\vto resurrect\vancient POKéMON\vfrom AMBER.")
|
||||||
|
or (t._Museum1FScientist1AmberIsFossilizedTreeSapText
|
||||||
|
or "AMBER is fossil-\nized tree sap."), done))
|
||||||
|
end }))
|
||||||
|
return
|
||||||
|
end
|
||||||
if game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET then
|
if game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET then
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
t._Museum1FScientist1TakePlentyOfTimeText
|
t._Museum1FScientist1TakePlentyOfTimeText
|
||||||
or "Take your time,\nand enjoy it all!", done))
|
or "Take your time,\nand enjoy it all!", done))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
-- scripts/Museum1F.asm:58
|
||||||
|
if p and p.cellY ~= 4 then
|
||||||
|
game.stack:push(TextBox.new(game,
|
||||||
|
t._Museum1FScientist1GoToOtherSideText
|
||||||
|
or "Please go to the\nother side!", done))
|
||||||
|
return
|
||||||
|
end
|
||||||
-- scripts/Museum1F.asm:72
|
-- scripts/Museum1F.asm:72
|
||||||
local money = function() return game.save.money end
|
local money = function() return game.save.money end
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
@@ -769,7 +792,7 @@ M.MUSEUM_1F = {
|
|||||||
if y == 4 and (x == 9 or x == 10)
|
if y == 4 and (x == 9 or x == 10)
|
||||||
and not game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET then
|
and not game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET then
|
||||||
museumClerk(game, ow, nil, function()
|
museumClerk(game, ow, nil, function()
|
||||||
ow:scriptMove(ow.player, "down", 1)
|
ow:scriptMove(ow.player, "down", 1, nil, { collide = true })
|
||||||
end)
|
end)
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
-- ghost, elevators, the Game Corner coins/prizes, the SS Anne departure
|
-- ghost, elevators, the Game Corner coins/prizes, the SS Anne departure
|
||||||
-- and the Hall of Fame record. Each cites its pokered source.
|
-- and the Hall of Fame record. Each cites its pokered source.
|
||||||
|
|
||||||
|
local Runtime = require("src.mods.Runtime")
|
||||||
|
|
||||||
local M = {}
|
local M = {}
|
||||||
|
|
||||||
-- -------------------------------------------------------------------
|
-- -------------------------------------------------------------------
|
||||||
@@ -9,20 +11,30 @@ local M = {}
|
|||||||
-- FuchsiaGoodRodHouse.asm, Route12SuperRodHouse.asm)
|
-- FuchsiaGoodRodHouse.asm, Route12SuperRodHouse.asm)
|
||||||
-- -------------------------------------------------------------------
|
-- -------------------------------------------------------------------
|
||||||
|
|
||||||
local function rodGiver(askText, receivedText, afterText, rodItem, flag)
|
-- refusedText is the .ThatsSoDisappointingText tail on NO; followText is
|
||||||
return {
|
-- the second half of the received chain (scripts/VermilionOldRodHouse.asm:45)
|
||||||
{ "face_player" }, -- 1
|
local function rodGiver(askText, receivedText, afterText, rodItem, flag,
|
||||||
{ "check_flag", flag }, -- 2
|
refusedText, followText)
|
||||||
{ "jump_if_true", 9 }, -- 3
|
local rows = {
|
||||||
{ "ask", askText }, -- 4
|
{ "face_player" },
|
||||||
{ "jump_if_false", 10 }, -- 5
|
{ "check_flag", flag },
|
||||||
|
{ "jump_if_true", "already_got" },
|
||||||
|
{ "ask", askText },
|
||||||
|
{ "jump_if_false", "refused" },
|
||||||
-- give-then-print like the three rod-house scripts (GiveItem fills
|
-- give-then-print like the three rod-house scripts (GiveItem fills
|
||||||
-- wStringBuffer; the received texts read OLD/GOOD/SUPER ROD from it)
|
-- wStringBuffer; the received texts read OLD/GOOD/SUPER ROD from it)
|
||||||
{ "give_item", rodItem, 1, false }, -- 6
|
{ "give_item", rodItem, 1, false },
|
||||||
{ "show_text", receivedText }, -- 7
|
{ "set_flag", flag },
|
||||||
{ "set_flag", flag }, -- 8
|
{ "show_text", receivedText },
|
||||||
{ "jump", 10 }, -- 9 is below
|
|
||||||
}
|
}
|
||||||
|
if followText then rows[#rows + 1] = { "show_text", followText } end
|
||||||
|
rows[#rows + 1] = { "jump", "end" }
|
||||||
|
rows[#rows + 1] = { "label", "refused" }
|
||||||
|
rows[#rows + 1] = { "show_text", refusedText }
|
||||||
|
rows[#rows + 1] = { "jump", "end" }
|
||||||
|
rows[#rows + 1] = { "label", "already_got" }
|
||||||
|
rows[#rows + 1] = { "show_text", afterText }
|
||||||
|
return rows
|
||||||
end
|
end
|
||||||
|
|
||||||
M.VERMILION_OLD_ROD_HOUSE = {
|
M.VERMILION_OLD_ROD_HOUSE = {
|
||||||
@@ -31,11 +43,11 @@ M.VERMILION_OLD_ROD_HOUSE = {
|
|||||||
"_VermilionOldRodHouseFishingGuruDoYouLikeToFishText",
|
"_VermilionOldRodHouseFishingGuruDoYouLikeToFishText",
|
||||||
"_VermilionOldRodHouseFishingGuruTakeThisText",
|
"_VermilionOldRodHouseFishingGuruTakeThisText",
|
||||||
"_VermilionOldRodHouseFishingGuruHowAreTheFishBitingText",
|
"_VermilionOldRodHouseFishingGuruHowAreTheFishBitingText",
|
||||||
"OLD_ROD", "EVENT_GOT_OLD_ROD"),
|
"OLD_ROD", "EVENT_GOT_OLD_ROD",
|
||||||
|
"_VermilionOldRodHouseFishingGuruThatsSoDisappointingText",
|
||||||
|
"_VermilionOldRodHouseFishingGuruFishingIsAWayOfLifeText"),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
M.VERMILION_OLD_ROD_HOUSE.talk.TEXT_VERMILIONOLDRODHOUSE_FISHING_GURU[9] =
|
|
||||||
{ "show_text", "_VermilionOldRodHouseFishingGuruHowAreTheFishBitingText" }
|
|
||||||
|
|
||||||
M.FUCHSIA_GOOD_ROD_HOUSE = {
|
M.FUCHSIA_GOOD_ROD_HOUSE = {
|
||||||
talk = {
|
talk = {
|
||||||
@@ -43,11 +55,10 @@ M.FUCHSIA_GOOD_ROD_HOUSE = {
|
|||||||
"_FuchsiaGoodRodHouseFishingGuruText",
|
"_FuchsiaGoodRodHouseFishingGuruText",
|
||||||
"_FuchsiaGoodRodHouseFishingGuruReceivedGoodRodText",
|
"_FuchsiaGoodRodHouseFishingGuruReceivedGoodRodText",
|
||||||
"_FuchsiaGoodRodHouseFishingGuruHowAreTheFishText",
|
"_FuchsiaGoodRodHouseFishingGuruHowAreTheFishText",
|
||||||
"GOOD_ROD", "EVENT_GOT_GOOD_ROD"),
|
"GOOD_ROD", "EVENT_GOT_GOOD_ROD",
|
||||||
|
"_FuchsiaGoodRodHouseFishingGuruThatsSoDisappointingText"),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
M.FUCHSIA_GOOD_ROD_HOUSE.talk.TEXT_FUCHSIAGOODRODHOUSE_FISHING_GURU[9] =
|
|
||||||
{ "show_text", "_FuchsiaGoodRodHouseFishingGuruHowAreTheFishText" }
|
|
||||||
|
|
||||||
M.ROUTE_12_SUPER_ROD_HOUSE = {
|
M.ROUTE_12_SUPER_ROD_HOUSE = {
|
||||||
talk = {
|
talk = {
|
||||||
@@ -55,11 +66,11 @@ M.ROUTE_12_SUPER_ROD_HOUSE = {
|
|||||||
"_Route12SuperRodHouseFishingGuruDoYouLikeToFishText",
|
"_Route12SuperRodHouseFishingGuruDoYouLikeToFishText",
|
||||||
"_Route12SuperRodHouseFishingGuruReceivedSuperRodText",
|
"_Route12SuperRodHouseFishingGuruReceivedSuperRodText",
|
||||||
"_Route12SuperRodHouseFishingGuruTryFishingText",
|
"_Route12SuperRodHouseFishingGuruTryFishingText",
|
||||||
"SUPER_ROD", "EVENT_GOT_SUPER_ROD"),
|
"SUPER_ROD", "EVENT_GOT_SUPER_ROD",
|
||||||
|
"_Route12SuperRodHouseFishingGuruThatsDisappointingText",
|
||||||
|
"_Route12SuperRodHouseFishingGuruFishingWayOfLifeText"),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
M.ROUTE_12_SUPER_ROD_HOUSE.talk.TEXT_ROUTE12SUPERRODHOUSE_FISHING_GURU[9] =
|
|
||||||
{ "show_text", "_Route12SuperRodHouseFishingGuruTryFishingText" }
|
|
||||||
|
|
||||||
-- -------------------------------------------------------------------
|
-- -------------------------------------------------------------------
|
||||||
-- Pokemon Tower 5F purified zone (scripts/PokemonTower5F.asm
|
-- Pokemon Tower 5F purified zone (scripts/PokemonTower5F.asm
|
||||||
@@ -176,7 +187,7 @@ M.POKEMON_TOWER_6F = {
|
|||||||
-- .did_not_defeat: one simulated step right, off the trigger,
|
-- .did_not_defeat: one simulated step right, off the trigger,
|
||||||
-- so fleeing does not leave you standing on a cell that
|
-- so fleeing does not leave you standing on a cell that
|
||||||
-- immediately re-fires.
|
-- immediately re-fires.
|
||||||
ow:scriptMove(ow.player, "right", 1)
|
ow:scriptMove(ow.player, "right", 1, nil, { collide = true })
|
||||||
end
|
end
|
||||||
ow:afterBattle(result, battle)
|
ow:afterBattle(result, battle)
|
||||||
end
|
end
|
||||||
@@ -893,81 +904,65 @@ local DOCK_SHIP_BLOCKS = {
|
|||||||
{ bx = 7, by = 2, water = 13 }, { bx = 8, by = 2, water = 13 },
|
{ bx = 7, by = 2, water = 13 }, { bx = 8, by = 2, water = 13 },
|
||||||
}
|
}
|
||||||
|
|
||||||
-- her four hull columns bow-to-stern (upper-half / lower-half block ids)
|
|
||||||
-- and the open-water ids of the rows she sits in
|
|
||||||
local DOCK_SHIP_COLUMNS = {
|
|
||||||
{ bx = 5, top = 4, bottom = 8 },
|
|
||||||
{ bx = 6, top = 5, bottom = 9 },
|
|
||||||
{ bx = 7, top = 6, bottom = 10 },
|
|
||||||
{ bx = 8, top = 7, bottom = 11 },
|
|
||||||
}
|
|
||||||
local DOCK_WATER_TOP, DOCK_WATER_BOTTOM = 1, 13
|
|
||||||
|
|
||||||
M.VERMILION_DOCK = {
|
M.VERMILION_DOCK = {
|
||||||
onEnter = function(game, ow)
|
onEnter = function(game, ow)
|
||||||
local Flags = require("src.script.Flags")
|
local Flags = require("src.script.Flags")
|
||||||
local f = game.save.flags
|
local f = game.save.flags
|
||||||
if Flags.get(game.save, "EVENT_SS_ANNE_LEFT") then
|
if Flags.get(game.save, "EVENT_SS_ANNE_LEFT") then
|
||||||
-- the ship is long gone: erase her right away, and anyone who
|
-- the ship is long gone: erase her right away, and anyone who
|
||||||
-- still lands here is sent back out past the guard
|
-- still lands here is sent back out past the guard unless a mod
|
||||||
|
-- explicitly permits this occupied map state. This hook surrounds
|
||||||
|
-- only the ejection decision; map-script registration and dispatch
|
||||||
|
-- stay unchanged, and the departed ship remains erased.
|
||||||
for _, b in ipairs(DOCK_SHIP_BLOCKS) do
|
for _, b in ipairs(DOCK_SHIP_BLOCKS) do
|
||||||
ow.map:setBlock(b.bx, b.by, b.water)
|
ow.map:setBlock(b.bx, b.by, b.water)
|
||||||
end
|
end
|
||||||
ow.map.renderer:rebuild()
|
ow.map.renderer:rebuild()
|
||||||
local TextBox = require("src.render.TextBox")
|
local occupancyAllowed = false
|
||||||
game.stack:push(TextBox.new(game,
|
if Runtime.wantsHook("map.occupancy_allowed") then
|
||||||
game.data.text._VermilionCitySailor1ShipSetSailText
|
local player = ow.player or {}
|
||||||
or "The ship set sail.", function()
|
occupancyAllowed = Runtime.call("map.occupancy_allowed",
|
||||||
ow:startWarpTo("VERMILION_CITY", 18, 29, "up")
|
function() return false end, game, {
|
||||||
end))
|
mapId = "VERMILION_DOCK",
|
||||||
|
reason = "ss_anne_departed",
|
||||||
|
gameVersion = game.save and game.save.version,
|
||||||
|
x = player.cellX,
|
||||||
|
y = player.cellY,
|
||||||
|
}) == true
|
||||||
|
end
|
||||||
|
if not occupancyAllowed then
|
||||||
|
local TextBox = require("src.render.TextBox")
|
||||||
|
game.stack:push(TextBox.new(game,
|
||||||
|
game.data.text._VermilionCitySailor1ShipSetSailText
|
||||||
|
or "The ship set sail.", function()
|
||||||
|
ow:startWarpTo("VERMILION_CITY", 18, 29, "up")
|
||||||
|
end))
|
||||||
|
end
|
||||||
elseif f.EVENT_GOT_HM01 and ow.player.cellY == 2 then
|
elseif f.EVENT_GOT_HM01 and ow.player.cellY == 2 then
|
||||||
-- VermilionDockSSAnneLeavesScript: only stepping OFF the ship
|
-- VermilionDockSSAnneLeavesScript: only stepping OFF the ship
|
||||||
-- triggers the departure (wDestinationWarpID == 1 in pokered) --
|
-- triggers the departure (wDestinationWarpID == 1 in pokered)
|
||||||
-- Music_Surfing plays for the sail-away cutscene, smoke puffs
|
|
||||||
-- drift off the funnel, the horn blows, the ship is erased to
|
|
||||||
-- open water, and the player is walked off the dock into the
|
|
||||||
-- city past the guard (VermilionCity's
|
|
||||||
-- SCRIPT_VERMILIONCITY_PLAYER_EXIT_SHIP walk)
|
|
||||||
Flags.set(game.save, "EVENT_SS_ANNE_LEFT")
|
Flags.set(game.save, "EVENT_SS_ANNE_LEFT")
|
||||||
local Music = require("src.core.Music")
|
local Music = require("src.core.Music")
|
||||||
Music.stop()
|
Music.stop()
|
||||||
Music.play(game.data, "Music_Surfing")
|
Music.play(game.data, "Music_Surfing")
|
||||||
local function puff(n, cx)
|
ow:queueScript({
|
||||||
if n <= 0 then return end
|
-- scripts/VermilionDock.asm:50 zeroes the player image index and
|
||||||
ow:startDustAnim(cx, 1, function() puff(n - 1, cx + 2) end)
|
-- :77 freezes sprite updates, so he faces DOWN throughout (#1689)
|
||||||
end
|
{ "face_player_dir", "down" },
|
||||||
puff(3, 15)
|
{ "wait", 120 },
|
||||||
-- scripts/VermilionDock.asm:182-203
|
{ "play_sound", "SS_Anne_Horn" },
|
||||||
local rows = {}
|
-- scripts/VermilionDock.asm:80 .shift_columns_up
|
||||||
local function setBlock(bx, by, block)
|
{ "ss_anne_departs" },
|
||||||
if bx < 1 or bx > 8 then return end
|
-- scripts/VermilionDock.asm:205 VermilionDock_EraseSSAnne
|
||||||
rows[#rows + 1] = { "replace_block", bx, by, block }
|
{ "play_sound", "SS_Anne_Horn" },
|
||||||
end
|
{ "wait", 120 },
|
||||||
rows[#rows + 1] = { "wait", 120 }
|
{ "move_player", "up", 2 },
|
||||||
rows[#rows + 1] = { "play_sound", "SS_Anne_Horn" }
|
-- no keepMusic on this warp: Music_Surfing belongs to the dock's
|
||||||
-- .shift_columns_up slides her tile columns west behind a mid-frame
|
-- cutscene, and VERMILION_CITY's own theme has to take over as the
|
||||||
-- rSCX split; with no split scroll here she sails one block per beat
|
-- player crosses in (EnterMap's PlayDefaultMusic)
|
||||||
-- and the water closes in astern (#360)
|
{ "warp", "VERMILION_CITY", 18, 31, "up" },
|
||||||
for step = 1, 8 do
|
{ "move_player", "up", 2 },
|
||||||
for _, col in ipairs(DOCK_SHIP_COLUMNS) do
|
})
|
||||||
setBlock(col.bx - step, 1, col.top)
|
|
||||||
setBlock(col.bx - step, 2, col.bottom)
|
|
||||||
end
|
|
||||||
setBlock(9 - step, 1, DOCK_WATER_TOP)
|
|
||||||
setBlock(9 - step, 2, DOCK_WATER_BOTTOM)
|
|
||||||
rows[#rows + 1] = { "wait", 20 }
|
|
||||||
end
|
|
||||||
-- the second horn as she clears the dock, then EraseSSAnne's 120
|
|
||||||
-- frames before the walk out
|
|
||||||
rows[#rows + 1] = { "play_sound", "SS_Anne_Horn" }
|
|
||||||
rows[#rows + 1] = { "wait", 120 }
|
|
||||||
rows[#rows + 1] = { "move_player", "up", 2 }
|
|
||||||
-- no keepMusic on this warp: Music_Surfing belongs to the dock's
|
|
||||||
-- cutscene, and VERMILION_CITY's own theme has to take over as the
|
|
||||||
-- player crosses in (EnterMap's PlayDefaultMusic)
|
|
||||||
rows[#rows + 1] = { "warp", "VERMILION_CITY", 18, 31, "up" }
|
|
||||||
rows[#rows + 1] = { "move_player", "up", 2 }
|
|
||||||
ow:queueScript(rows)
|
|
||||||
end
|
end
|
||||||
end,
|
end,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -216,7 +216,10 @@ local function dojoMasterGate(game, ow, x, y)
|
|||||||
if not master or ow:trainerDefeated(master) then return false end
|
if not master or ow:trainerDefeated(master) then return false end
|
||||||
ow.player.facing = "right"
|
ow.player.facing = "right"
|
||||||
master:facePlayer(ow.player)
|
master:facePlayer(ow.player)
|
||||||
ow:engageTrainer(master)
|
-- scripts/FightingDojo.asm:117-119 SaveEndBattleTextPointers (#1606)
|
||||||
|
ow:engageTrainer(master, nil,
|
||||||
|
((game.data or {}).text or {})._FightingDojoKarateMasterDefeatedText,
|
||||||
|
nil, nil, false)
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -516,7 +519,17 @@ M.ROUTE_24 = {
|
|||||||
push(game, text(game)._Route24CooltrainerM1YouCouldBecomeATopLeaderText,
|
push(game, text(game)._Route24CooltrainerM1YouCouldBecomeATopLeaderText,
|
||||||
done)
|
done)
|
||||||
else
|
else
|
||||||
ow:engageTrainer(npc, done)
|
-- scripts/Route24.asm:125
|
||||||
|
ow:engageTrainer(npc, function()
|
||||||
|
if ow:trainerDefeated(npc) then
|
||||||
|
-- scripts/Route24.asm:62
|
||||||
|
push(game,
|
||||||
|
text(game)._Route24CooltrainerM1YouCouldBecomeATopLeaderText,
|
||||||
|
done)
|
||||||
|
else
|
||||||
|
done()
|
||||||
|
end
|
||||||
|
end, text(game)._Route24CooltrainerM1DefeatedText, true)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
if not flags.EVENT_GOT_NUGGET then
|
if not flags.EVENT_GOT_NUGGET then
|
||||||
@@ -721,7 +734,7 @@ local function e4ExitSeal(flag, closedBlock, openBlock, dontRunText, autoFlag)
|
|||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
game.data.text[dontRunText] or "Don't run away!", function()
|
game.data.text[dontRunText] or "Don't run away!", function()
|
||||||
ow:scriptMove(ow.player, "up", 1)
|
ow:scriptMove(ow.player, "up", 1, nil, { collide = true })
|
||||||
end))
|
end))
|
||||||
return true
|
return true
|
||||||
end,
|
end,
|
||||||
|
|||||||
@@ -122,9 +122,8 @@ M.CINNABAR_LAB_METRONOME_ROOM = {
|
|||||||
-- TM42 Dream Eater (scripts/ViridianCity.asm, the fisher). The fisher's
|
-- TM42 Dream Eater (scripts/ViridianCity.asm, the fisher). The fisher's
|
||||||
-- YouCanHaveThisText prints before GiveItem, so this gift needs a pre
|
-- YouCanHaveThisText prints before GiveItem, so this gift needs a pre
|
||||||
-- text (#775). Like the SilphCo2F worker (#393) that label carries no
|
-- text (#775). Like the SilphCo2F worker (#393) that label carries no
|
||||||
-- leading underscore, and on Red it sits outside the extractor's symbol
|
-- leading underscore; tools/extract/text.py now collects it regardless,
|
||||||
-- set, so the literal from text/ViridianCity.asm rides along as the
|
-- so preFallback below is just the safety net for a catalog without it.
|
||||||
-- fallback; Yellow resolves the ROM string instead.
|
|
||||||
M.VIRIDIAN_CITY = {
|
M.VIRIDIAN_CITY = {
|
||||||
talk = {
|
talk = {
|
||||||
TEXT_VIRIDIANCITY_FISHER = gift({
|
TEXT_VIRIDIANCITY_FISHER = gift({
|
||||||
@@ -146,9 +145,11 @@ M.SILPH_CO_2F = {
|
|||||||
talk = {
|
talk = {
|
||||||
TEXT_SILPHCO2F_SILPH_WORKER_F = gift({
|
TEXT_SILPHCO2F_SILPH_WORKER_F = gift({
|
||||||
flag = "EVENT_GOT_TM36", item = "TM_SELFDESTRUCT",
|
flag = "EVENT_GOT_TM36", item = "TM_SELFDESTRUCT",
|
||||||
-- the label carries no leading underscore: pokered keeps this one in
|
-- the label carries no leading underscore (#393); collected like any
|
||||||
-- the script bank, not the far-text bank (#393)
|
-- other text/*.asm label now, preFallback is just the safety net
|
||||||
pre = "SilphCo2FSilphWorkerFPleaseTakeThisText",
|
pre = "SilphCo2FSilphWorkerFPleaseTakeThisText",
|
||||||
|
preFallback = "Eeek!\nNo! Stop! Help!\fOh, you're not\nwith TEAM ROCKET."
|
||||||
|
.. "\vI thought...\vI'm sorry. Here,\vplease take this!",
|
||||||
received = "_SilphCo2FSilphWorkerFReceivedTM36Text",
|
received = "_SilphCo2FSilphWorkerFReceivedTM36Text",
|
||||||
explain = "_SilphCo2FSilphWorkerFTM36ExplanationText",
|
explain = "_SilphCo2FSilphWorkerFTM36ExplanationText",
|
||||||
noRoom = "_SilphCo2FSilphWorkerFTM36NoRoomText",
|
noRoom = "_SilphCo2FSilphWorkerFTM36NoRoomText",
|
||||||
@@ -239,7 +240,7 @@ local function stepGate(opts)
|
|||||||
push(game, text(game)[opts.text] or opts.fallback, function()
|
push(game, text(game)[opts.text] or opts.fallback, function()
|
||||||
ow.player.facing = opts.push
|
ow.player.facing = opts.push
|
||||||
if not ow:checkLedgeHop(opts.push) then
|
if not ow:checkLedgeHop(opts.push) then
|
||||||
ow:scriptMove(ow.player, opts.push, 1)
|
ow:scriptMove(ow.player, opts.push, 1, nil, { collide = true })
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
return true
|
return true
|
||||||
@@ -646,27 +647,29 @@ end
|
|||||||
local rocketRows = {
|
local rocketRows = {
|
||||||
{ "face_player" }, -- 1
|
{ "face_player" }, -- 1
|
||||||
{ "check_flag", "EVENT_GOT_TM28" }, -- 2
|
{ "check_flag", "EVENT_GOT_TM28" }, -- 2
|
||||||
{ "jump_if_true", 15 }, -- 3 → CeruleanHideRocket
|
{ "jump_if_true", 16 }, -- 3 → CeruleanHideRocket
|
||||||
{ "check_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 4
|
{ "check_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 4
|
||||||
{ "jump_if_true", 9 }, -- 5
|
{ "jump_if_true", 10 }, -- 5
|
||||||
{ "show_text", "_CeruleanCityRocketText" }, -- 6
|
{ "show_text", "_CeruleanCityRocketText" }, -- 6
|
||||||
{ "start_battle", "trainer", "OPP_ROCKET", 5 }, -- 7
|
-- scripts/CeruleanCity.asm:297 SaveEndBattleTextPointers
|
||||||
{ "jump_if_false", "end" }, -- 8
|
{ "save_end_battle_text", "_CeruleanCityRocketIGiveUpText" }, -- 7
|
||||||
{ "show_text", "_CeruleanCityRocketIllReturnTheTMText" }, -- 9
|
{ "start_battle", "trainer", "OPP_ROCKET", 5 }, -- 8
|
||||||
{ "set_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 10
|
{ "jump_if_false", "end" }, -- 9
|
||||||
{ "give_item", "TM_DIG", 1, false }, -- 11 (row 13 prints)
|
{ "show_text", "_CeruleanCityRocketIllReturnTheTMText" }, -- 10
|
||||||
{ "set_flag", "EVENT_GOT_TM28" }, -- 12
|
{ "set_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 11
|
||||||
{ "show_text", "_CeruleanCityRocketReceivedTM28Text" }, -- 13
|
{ "give_item", "TM_DIG", 1, false }, -- 12 (row 14 prints)
|
||||||
{ "show_text", "_CeruleanCityRocketIBetterGetMovingText" }, -- 14
|
{ "set_flag", "EVENT_GOT_TM28" }, -- 13
|
||||||
{ "fade", "out" }, -- 15 GBFadeOutToBlack
|
{ "show_text", "_CeruleanCityRocketReceivedTM28Text" }, -- 14
|
||||||
|
{ "show_text", "_CeruleanCityRocketIBetterGetMovingText" }, -- 15
|
||||||
|
{ "fade", "out" }, -- 16 GBFadeOutToBlack
|
||||||
-- CeruleanHideRocket while black: GUARD1 (28,12) appears, GUARD2
|
-- CeruleanHideRocket while black: GUARD1 (28,12) appears, GUARD2
|
||||||
-- (27,12) and the ROCKET go. GUARD2 blocks the trashed-house south
|
-- (27,12) and the ROCKET go. GUARD2 blocks the trashed-house south
|
||||||
-- door neighbour -- the swap reconnects the city (Bill's ticket does
|
-- door neighbour -- the swap reconnects the city (Bill's ticket does
|
||||||
-- the same in story.lua; either route is enough).
|
-- the same in story.lua; either route is enough).
|
||||||
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 16
|
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 17
|
||||||
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 17
|
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 18
|
||||||
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_ROCKET" }, -- 18
|
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_ROCKET" }, -- 19
|
||||||
{ "fade", "in" }, -- 19 GBFadeInFromBlack
|
{ "fade", "in" }, -- 20 GBFadeInFromBlack
|
||||||
}
|
}
|
||||||
|
|
||||||
M.CERULEAN_CITY = {
|
M.CERULEAN_CITY = {
|
||||||
@@ -817,7 +820,8 @@ M.PEWTER_POKECENTER = {
|
|||||||
-- on the west-side cells and walks you back
|
-- on the west-side cells and walks you back
|
||||||
local function bikeGateGuard(coords, stopText, explainText)
|
local function bikeGateGuard(coords, stopText, explainText)
|
||||||
return function(game, ow, x, y)
|
return function(game, ow, x, y)
|
||||||
if game.save.inventory.BICYCLE then return false end
|
local bike = game.save.inventory.BICYCLE
|
||||||
|
if bike and bike ~= 0 then return false end
|
||||||
if not inCoords(coords, x, y) then return false end
|
if not inCoords(coords, x, y) then return false end
|
||||||
-- walk the player up to the tile beside the counter, no further:
|
-- walk the player up to the tile beside the counter, no further:
|
||||||
-- (matchedY - closestY) tiles, 0 when already next to it
|
-- (matchedY - closestY) tiles, 0 when already next to it
|
||||||
@@ -839,10 +843,10 @@ local function bikeGateGuard(coords, stopText, explainText)
|
|||||||
-- (PlayerMovingRightScript). Without it the player was left
|
-- (PlayerMovingRightScript). Without it the player was left
|
||||||
-- parked beside the guard's counter with no way past. #518
|
-- parked beside the guard's counter with no way past. #518
|
||||||
local function shoveRight()
|
local function shoveRight()
|
||||||
ow:scriptMove(ow.player, "right", 1)
|
ow:scriptMove(ow.player, "right", 1, nil, { collide = true })
|
||||||
end
|
end
|
||||||
if dist > 0 then
|
if dist > 0 then
|
||||||
ow:scriptMove(ow.player, "up", dist, shoveRight)
|
ow:scriptMove(ow.player, "up", dist, shoveRight, { collide = true })
|
||||||
else
|
else
|
||||||
shoveRight()
|
shoveRight()
|
||||||
end
|
end
|
||||||
@@ -918,10 +922,12 @@ M.SS_ANNE_2F = {
|
|||||||
{ "move_npc_to", 2, 36, onLeft and 7 or 8 }, -- 2
|
{ "move_npc_to", 2, 36, onLeft and 7 or 8 }, -- 2
|
||||||
{ "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
|
-- SSAnne2FRivalText's text_asm arms SaveEndBattleTextPointers
|
||||||
{ "jump_if_false", 13 }, -- 6
|
-- (scripts/SSAnne2F.asm:199), so the line prints in battle (#1688)
|
||||||
{ "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 7
|
{ "save_end_battle_text", "_SSAnne2FRivalDefeatedText" }, -- 5
|
||||||
{ "show_text", "_SSAnne2FRivalDefeatedText" }, -- 8
|
{ "rival_battle", "OPP_RIVAL2", 1 }, -- 6
|
||||||
|
{ "jump_if_false", 13 }, -- 7
|
||||||
|
{ "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 8
|
||||||
{ "show_text", "_SSAnne2FRivalCutMasterText" }, -- 9
|
{ "show_text", "_SSAnne2FRivalCutMasterText" }, -- 9
|
||||||
{ "play_music", "Music_MeetRival", { start = "rival" } }, -- 10
|
{ "play_music", "Music_MeetRival", { start = "rival" } }, -- 10
|
||||||
{ "walk_npc", 2, ssAnne2FRivalExitDirs(onLeft) }, -- 11
|
{ "walk_npc", 2, ssAnne2FRivalExitDirs(onLeft) }, -- 11
|
||||||
|
|||||||
@@ -7,9 +7,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
|
||||||
|
|
||||||
-- PrintText on a text_end string returns with the box still drawn and
|
-- PrintText on a text_end string returns with the box still drawn and
|
||||||
@@ -236,7 +236,6 @@ M.CINNABAR_GYM = {
|
|||||||
if yes == machine.yes then
|
if yes == machine.yes then
|
||||||
-- CinnabarGymQuizCorrectText: item jingle, then the gate
|
-- CinnabarGymQuizCorrectText: item jingle, then the gate
|
||||||
-- slides open (SFX_GO_INSIDE) if it was still locked
|
-- slides open (SFX_GO_INSIDE) if it was still locked
|
||||||
Sound.play(game.data, "Get_Item1")
|
|
||||||
push(game, t._CinnabarGymQuizCorrectText
|
push(game, t._CinnabarGymQuizCorrectText
|
||||||
or "You're absolutely\ncorrect!\fGo on through!", function()
|
or "You're absolutely\ncorrect!\fGo on through!", function()
|
||||||
if not game.save.flags[gymGateFlag(index)] then
|
if not game.save.flags[gymGateFlag(index)] then
|
||||||
@@ -244,7 +243,9 @@ M.CINNABAR_GYM = {
|
|||||||
Sound.play(game.data, "Go_Inside")
|
Sound.play(game.data, "Go_Inside")
|
||||||
end
|
end
|
||||||
applyGymGates(game, ow)
|
applyGymGates(game, ow)
|
||||||
end)
|
end, { preSound = function()
|
||||||
|
return Sound.play(game.data, "Get_Item1")
|
||||||
|
end })
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
Sound.play(game.data, "Denied")
|
Sound.play(game.data, "Denied")
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ local function surfingPikachu(game)
|
|||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
local function push(game, text, done)
|
local function push(game, text, done, opts)
|
||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
game.stack:push(TextBox.new(game, text, done))
|
game.stack:push(TextBox.new(game, text, done, opts))
|
||||||
end
|
end
|
||||||
|
|
||||||
-- the two-variant posters: the surf-capable line once a surfing
|
-- the two-variant posters: the surf-capable line once a surfing
|
||||||
@@ -69,11 +69,11 @@ return {
|
|||||||
|
|
||||||
TEXT_SUMMERBEACHHOUSE_PIKACHU = function(game, ow, npc, done)
|
TEXT_SUMMERBEACHHOUSE_PIKACHU = function(game, ow, npc, done)
|
||||||
local t = game.data.text
|
local t = game.data.text
|
||||||
|
-- scripts/SummerBeachHouse.asm:68
|
||||||
push(game, t._SummerBeachHousePikachuText or "PIKACHU: Pikaa!",
|
push(game, t._SummerBeachHousePikachuText or "PIKACHU: Pikaa!",
|
||||||
function()
|
done, { auto = { wait = true, delay = 0, sound = function()
|
||||||
require("src.core.Sound").playCry(game.data, "PIKACHU")
|
return require("src.core.Sound").playCry(game.data, "PIKACHU")
|
||||||
done()
|
end } })
|
||||||
end)
|
|
||||||
end,
|
end,
|
||||||
|
|
||||||
TEXT_SUMMERBEACHHOUSE_POSTER1 = poster(1),
|
TEXT_SUMMERBEACHHOUSE_POSTER1 = poster(1),
|
||||||
|
|||||||
@@ -55,16 +55,6 @@ In-game controls use the normal PortMaster / SDL pad map, rebindable under
|
|||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
**GBC FX is off on this device.** The launcher exports `POKEPORT_GBCFX=0`,
|
|
||||||
which hides the GBC FX row from OPTIONS, pins the level to OFF, and clears a
|
|
||||||
level carried over in an `options.lua` from another machine. The H700's Mali
|
|
||||||
GPU is in the same class as the phone GPUs that compile that present pass and
|
|
||||||
then show a black frame (issue #136), and `love.system.getOS()` reports
|
|
||||||
`"Linux"` here, so the Android gate would not have caught it. Every other
|
|
||||||
display option — COLORS, TILT, ZOOM, VOID FILL, MAX FPS — works normally. If
|
|
||||||
your device turns out to handle the pass, launch with `POKEPORT_GBCFX=1` to
|
|
||||||
put the row back.
|
|
||||||
|
|
||||||
**PERFORMANCE defaults to LOW here.** The OPTIONS → PERFORMANCE tier defaults
|
**PERFORMANCE defaults to LOW here.** The OPTIONS → PERFORMANCE tier defaults
|
||||||
to AUTO, which reads this device as an ARM Linux handheld and resolves to
|
to AUTO, which reads this device as an ARM Linux handheld and resolves to
|
||||||
**LOW**: the 3D tilt and survey zoom stay off and the frame rate is capped,
|
**LOW**: the 3D tilt and survey zoom stay off and the frame rate is capped,
|
||||||
|
|||||||
@@ -105,8 +105,9 @@ trixie.
|
|||||||
This is a statement about the *compile environment*, not about where the
|
This is a statement about the *compile environment*, not about where the
|
||||||
artifact runs — building on your own newer distro would silently raise that
|
artifact runs — building on your own newer distro would silently raise that
|
||||||
floor and strand every user on an older one, with no symptom until they
|
floor and strand every user on an older one, with no symptom until they
|
||||||
download it. CI enforces the floor: `linux-arm64-build` fails if the highest
|
download it. `scripts/linux-arm64/verify_appimage.sh` enforces the floor in
|
||||||
required glibc symbol version climbs above 2.31.
|
both CI (`linux-arm64-build`) and the release workflow: the build fails if
|
||||||
|
the highest required glibc symbol version climbs above 2.31.
|
||||||
|
|
||||||
### Why five libraries are built from source
|
### Why five libraries are built from source
|
||||||
|
|
||||||
@@ -172,13 +173,15 @@ Three jobs, path-gated on `scripts/build_linux_arm64.sh`,
|
|||||||
exclude list still classifies known sonames correctly, that AppRun still
|
exclude list still classifies known sonames correctly, that AppRun still
|
||||||
launches `game.love` with `--fused`, and that the host-arch guard actually
|
launches `game.love` with `--fused`, and that the host-arch guard actually
|
||||||
fires. Needs no container and no arm64 machine.
|
fires. Needs no container and no arm64 machine.
|
||||||
- **`linux-arm64-build`** (`ubuntu-24.04-arm`) — the real build, then extracts
|
- **`linux-arm64-build`** (`ubuntu-24.04-arm`) — the real build, then
|
||||||
the artifact and asserts the layout, that every bundled object resolves
|
`scripts/linux-arm64/verify_appimage.sh` extracts the artifact and asserts
|
||||||
under AppRun's `LD_LIBRARY_PATH`, and that the glibc floor is still ≤ 2.31.
|
the layout, that every bundled object resolves under AppRun's
|
||||||
Uploads the AppImage for 7 days.
|
`LD_LIBRARY_PATH`, and that the glibc floor is still ≤ 2.31. Uploads the
|
||||||
|
AppImage for 7 days.
|
||||||
- **release** — `linux-arm64` runs on `ubuntu-24.04-arm`, reuses the shared
|
- **release** — `linux-arm64` runs on `ubuntu-24.04-arm`, reuses the shared
|
||||||
`game.love` from the `love-payload` job, and the AppImage is staged and
|
`game.love` from the `love-payload` job, runs the same
|
||||||
published like every other release asset.
|
`verify_appimage.sh` checks on the shipped image, and the AppImage is
|
||||||
|
staged and published like every other release asset.
|
||||||
|
|
||||||
Unlike the Switch job, none of this needs secrets or self-hosted hardware, so
|
Unlike the Switch job, none of this needs secrets or self-hosted hardware, so
|
||||||
it runs on fork PRs too.
|
it runs on fork PRs too.
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ The short version, for an author deciding what to write:
|
|||||||
merged.** The write is taken, dropped, and named once per mod in the same
|
merged.** The write is taken, dropped, and named once per mod in the same
|
||||||
error feed the mod manager shows -- in both directions, so a Red boot writing
|
error feed the mod manager shows -- in both directions, so a Red boot writing
|
||||||
to `decorations` is told exactly as a Gold boot writing to `map_scripts` is.
|
to `decorations` is told exactly as a Gold boot writing to `map_scripts` is.
|
||||||
- **40 event names and 43 hook names have a call site in both generations**, so
|
- **40 event names and 44 hook names have a call site in both generations**, so
|
||||||
one subscription serves both games. `tests/engine/gate_gen2_mod_api.lua`
|
one subscription serves both games. `tests/engine/gate_gen2_mod_api.lua`
|
||||||
reads those names back out of the source and fails if a site is renamed or
|
reads those names back out of the source and fails if a site is renamed or
|
||||||
deleted on either side, and fails again if a new shared site appears without
|
deleted on either side, and fails again if a new shared site appears without
|
||||||
@@ -55,9 +55,10 @@ The short version, for an author deciding what to write:
|
|||||||
```
|
```
|
||||||
|
|
||||||
`games` is an optional array of version ids (`"red"`, `"blue"`, `"yellow"`,
|
`games` is an optional array of version ids (`"red"`, `"blue"`, `"yellow"`,
|
||||||
`"gold"`), generations (`"gen1"`, `"gen2"`, case-insensitive) or `"all"`.
|
`"gold"`, `"silver"`, `"crystal"`), generations (`"gen1"`, `"gen2"`,
|
||||||
`src/mods/ModTargets.lua` resolves the tokens off `GameVersion.ORDER` and
|
case-insensitive) or `"all"`. `src/mods/ModTargets.lua` resolves the tokens off
|
||||||
`GameVersion.generation`, so nothing anywhere restates the game list.
|
`GameVersion.ORDER` and `GameVersion.generation`, so nothing anywhere restates
|
||||||
|
the game list. `"gen2"` now expands to Gold, Silver and Crystal.
|
||||||
`Manifest.validate` stores the resolved, ORDER-sorted ids on `manifest.games`
|
`Manifest.validate` stores the resolved, ORDER-sorted ids on `manifest.games`
|
||||||
and **derives** `manifest.gen2compat` from them, which is the one field the
|
and **derives** `manifest.gen2compat` from them, which is the one field the
|
||||||
loader's gate reads.
|
loader's gate reads.
|
||||||
@@ -468,8 +469,11 @@ is warned once per name and the rest of the list still runs. The engine's own
|
|||||||
Gen 1 verbs are **not** seeded on Gold: a row-list verb handed Gold's ctx would
|
Gen 1 verbs are **not** seeded on Gold: a row-list verb handed Gold's ctx would
|
||||||
find no runner on it, so `data.commands` under Gen 2 is the mod verbs alone.
|
find no runner on it, so `data.commands` under Gen 2 is the mod verbs alone.
|
||||||
|
|
||||||
**`mod.save`, `mod.options`, `mod.log`, `mod.assets`, `mod.find`, exports.**
|
**`mod.save`, `mod.options`, `mod.log`, `mod.assets`, `mod.find`,
|
||||||
Generation-agnostic; nothing to adapt.
|
`mod.developer`, exports.** Generation-agnostic; nothing to adapt.
|
||||||
|
`mod.developer` is the same fixed boot-time boolean on both generations and is
|
||||||
|
available while the entry chunk runs. Gold does not gain Gen 1's developer
|
||||||
|
console or F5 hot-reload hotkey; the field reports the loader's mode only.
|
||||||
|
|
||||||
**`mod.world`.** Same method set, resolved against Gold's world
|
**`mod.world`.** Same method set, resolved against Gold's world
|
||||||
(`src/world/gen2/WorldAPI.lua`). Two differences show through and are
|
(`src/world/gen2/WorldAPI.lua`). Two differences show through and are
|
||||||
@@ -512,8 +516,9 @@ gains a field instead of the name gaining a prefix.
|
|||||||
id under Gen 1's `name` key, which is the one payload difference the
|
id under Gen 1's `name` key, which is the one payload difference the
|
||||||
numeric flag space forces.
|
numeric flag space forces.
|
||||||
- *Menus (`src/ui/gen2/`):* `ui.start_menu.items`, `ui.title_menu.items`,
|
- *Menus (`src/ui/gen2/`):* `ui.start_menu.items`, `ui.title_menu.items`,
|
||||||
`ui.options.rows`, `ui.party.submenu`, `ui.naming.grid`, `ui.pc.items`,
|
`ui.options.rows`, `ui.party.submenu`, `ui.party.grid_navigation`,
|
||||||
`ui.list_menu`, `transition.style`. `ui.list_menu` covers Gold's script
|
`ui.naming.grid`, `ui.pc.items`, `ui.list_menu`, `transition.style`.
|
||||||
|
`ui.list_menu` covers Gold's script
|
||||||
menus (`ScriptMenu.lua`); the `Chrome.List` widget the START and title
|
menus (`ScriptMenu.lua`); the `Chrome.List` widget the START and title
|
||||||
menus draw with does not raise it yet, so those two are composed through
|
menus draw with does not raise it yet, so those two are composed through
|
||||||
their own hooks only.
|
their own hooks only.
|
||||||
@@ -537,7 +542,8 @@ gains a field instead of the name gaining a prefix.
|
|||||||
`battle.damage_dealt`, `battle.fainted`, `battle.status_inflicted`,
|
`battle.damage_dealt`, `battle.fainted`, `battle.status_inflicted`,
|
||||||
`battle.battler_switched`, `battle.ball_thrown`, `battle.exp_gained`,
|
`battle.battler_switched`, `battle.ball_thrown`, `battle.exp_gained`,
|
||||||
`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.charge_required`,
|
||||||
|
`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`,
|
||||||
`battle.catch_exp`, `battle.bottom_ui_visible`,
|
`battle.catch_exp`, `battle.bottom_ui_visible`,
|
||||||
@@ -547,6 +553,17 @@ gains a field instead of the name gaining a prefix.
|
|||||||
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
|
||||||
`ctx.battle.data` instead gets nil on Gold.
|
`ctx.battle.data` instead gets nil on Gold.
|
||||||
|
`battle.exp_award`'s `ctx.applyShare(mon, split, announce)` reads its third
|
||||||
|
argument on both generations: truthy prints the mon's GainedText, falsy pays
|
||||||
|
it silently, so one mod source can print a single summary line for a
|
||||||
|
party-wide award instead of a box per recipient. Gold honours it **only when
|
||||||
|
it is passed**, by argument count -- `applyShare(mon, split)` was written
|
||||||
|
against a seam that always announced on Gold and keeps announcing there,
|
||||||
|
while `applyShare(mon, split, nil)` is silent on both. Pass the argument
|
||||||
|
explicitly and the two generations agree; omit it and Gen 1 stays silent
|
||||||
|
where Gold speaks. Only the line is affected: the exp, the stat exp,
|
||||||
|
`battle.exp_gained`, the level-up line, learned moves and the forget prompt
|
||||||
|
happen either way.
|
||||||
- *The catch and the evolution:* `pokemon.caught`, `pokemon.evolved`; hook
|
- *The catch and the evolution:* `pokemon.caught`, `pokemon.evolved`; hook
|
||||||
`evolution.check`. `src/ui/gen2/BattleState.lua:pushCaught` emits
|
`evolution.check`. `src/ui/gen2/BattleState.lua:pushCaught` emits
|
||||||
`pokemon.caught` once the mon is in the party or the box, and
|
`pokemon.caught` once the mon is in the party or the box, and
|
||||||
@@ -559,7 +576,7 @@ gains a field instead of the name gaining a prefix.
|
|||||||
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
|
||||||
composed frame before GBCFX, the letterbox, and the finished playfield rect
|
composed frame before ShaderFX, the letterbox, and the finished playfield rect
|
||||||
-- and carries the same payload.
|
-- 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`
|
||||||
@@ -769,6 +786,9 @@ 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:
|
||||||
|
|
||||||
|
- `battle.field_residual`: the first guarded call site is in Gen 1 end-of-round
|
||||||
|
processing. Gold already has a native weather/between-turn pipeline but does
|
||||||
|
not yet expose the shared data-only descriptor hook.
|
||||||
- `trainer.before_battle`: Gold constructs and pushes its trainer battle in
|
- `trainer.before_battle`: Gold constructs and pushes its trainer battle in
|
||||||
`src/world/gen2/World.lua:startBattle`, which does not yet expose a deferred
|
`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
|
preparation boundary or a battle-local player-party view. Gen 1 mods can use
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ Every mod contains a root `manifest.json` defining its metadata, supported games
|
|||||||
| `entry` | `string` | Entry Lua file path relative to mod root (usually `"main.lua"`). |
|
| `entry` | `string` | Entry Lua file path relative to mod root (usually `"main.lua"`). |
|
||||||
| `profile` | `string` | Mod profile: `"content"`, `"overhaul"`, or `"total_conversion"`. |
|
| `profile` | `string` | Mod profile: `"content"`, `"overhaul"`, or `"total_conversion"`. |
|
||||||
| `category` | `string` | Categorization chip (e.g. `"GAMEPLAY"`, `"CONTENT"`, `"UI"`, `"AUDIO"`). |
|
| `category` | `string` | Categorization chip (e.g. `"GAMEPLAY"`, `"CONTENT"`, `"UI"`, `"AUDIO"`). |
|
||||||
| `games` | `array` | Supported game versions: `["gen1"]`, `["gen2"]`, `["red"]`, `["blue"]`, `["yellow"]`, `["gold"]`, or `["all"]`. |
|
| `games` | `array` | Supported game versions: `["gen1"]`, `["gen2"]`, `["red"]`, `["blue"]`, `["yellow"]`, `["gold"]`, `["silver"]`, or `["all"]`. |
|
||||||
| `game_version`| `string` | Semver range of required engine version (e.g. `">=0.0.0-dev <2.0.0"`). |
|
| `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). |
|
| `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. |
|
| `dependencies` | `array` | Hard required dependencies. A mod will not load if a required dependency is missing or disabled for the active game. |
|
||||||
@@ -122,21 +122,40 @@ Each object requires a stable `id`, a display `name`, a destination `file`
|
|||||||
digests. `format` is either `"raw"` (the default) or `"n64"`. An optional
|
digests. `format` is either `"raw"` (the default) or `"n64"`. An optional
|
||||||
`description` gives players dump or region guidance in the import panel.
|
`description` gives players dump or region guidance in the import panel.
|
||||||
`size` declares the exact canonical byte length; `max_size` declares a smaller
|
`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
|
per-import ceiling when an exact size is not appropriate. The engine hard limit
|
||||||
has an engine-enforced 128 MiB ceiling and is rejected before hashing when its
|
is 2 GiB. Imports above 128 MiB receive an explicit free-space confirmation and
|
||||||
filesystem reports an invalid size.
|
use the launcher's streaming large-file path rather than being materialized as
|
||||||
|
one Lua string.
|
||||||
|
|
||||||
For `"n64"`, the launcher recognizes `.z64`, `.v64`, and `.n64` byte orders,
|
For `"n64"`, the launcher recognizes `.z64`, `.v64`, and `.n64` byte orders,
|
||||||
strips a recognized 512-byte copier header, converts the bytes to canonical
|
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
|
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
|
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
|
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
|
because its manifest names the same digest. Small sources can still be read
|
||||||
example `mod:read("baseroms/stadium2.z64")`; no host path or new filesystem
|
with the existing scoped `mod:read` API, for example
|
||||||
|
`mod:read("baseroms/stadium2.z64")`. For large sources, prefer the bounded
|
||||||
|
`mod.imports` facade described below; no host path or new general filesystem
|
||||||
permission is exposed. Missing `required_imports` block the mod before its
|
permission is exposed. Missing `required_imports` block the mod before its
|
||||||
entry chunk runs; missing `optional_imports` remain visible in the same
|
entry chunk runs; missing `optional_imports` remain visible in the same
|
||||||
launcher panel but do not block loading.
|
launcher panel but do not block loading.
|
||||||
|
|
||||||
|
#### Bounded access to validated imports
|
||||||
|
|
||||||
|
A loaded mod can address only ids declared by its own `required_imports` or
|
||||||
|
`optional_imports` arrays:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local info, err = mod.imports:info("stadium2")
|
||||||
|
local header, err = mod.imports:read("stadium2", 0, 4096)
|
||||||
|
```
|
||||||
|
|
||||||
|
`read` uses zero-based offsets and is capped at 8 MiB per call. The engine
|
||||||
|
rechecks the stored import before exposing it, seeks into the engine-owned
|
||||||
|
copy, and never gives the mod a host path or file handle. This is intended for
|
||||||
|
large source formats whose table/index can be parsed with small reads before
|
||||||
|
selectively reading the payloads a transform actually needs.
|
||||||
|
|
||||||
MD5 here identifies a known dump because ROM databases commonly publish it;
|
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
|
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
|
Gen1Recomp's own game-ROM importer into an import's `md5` field. Mod archives
|
||||||
@@ -209,6 +228,66 @@ optional visual `tileRows` at 2x resolution, and optional `tileDetailRows` at
|
|||||||
read-only snapshots; mods choose which layers to render. Red and Gold expose
|
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.
|
the same contract while applying their own object and event visibility rules.
|
||||||
|
|
||||||
|
### Active Gen 1 block checks
|
||||||
|
|
||||||
|
Red, Blue, and Yellow expose
|
||||||
|
`mod.world:activeBlockAt(mapId, blockX, blockY)`. It returns the numeric block
|
||||||
|
ID at one zero-based block coordinate only when `mapId` is the active map.
|
||||||
|
The value is a scalar snapshot: changing it cannot change the map. This lets a
|
||||||
|
mod compare a small runtime map signature before it applies a lawful authored
|
||||||
|
replacement, without reading the mutable map or ROM cache through engine
|
||||||
|
internals.
|
||||||
|
|
||||||
|
The method fails closed. Before an overworld exists it returns
|
||||||
|
`nil, "no overworld"`; for a different active map it returns
|
||||||
|
`nil, "map is not active"`; non-numeric, non-finite, or fractional coordinates
|
||||||
|
return `nil, "invalid block coordinates"`; and negative or out-of-range
|
||||||
|
coordinates return `nil, "block coordinates out of bounds"`. An unavailable
|
||||||
|
or malformed active block returns `nil, "block unavailable"`. The caller must
|
||||||
|
require every expected cell to match before changing presentation. This method
|
||||||
|
is Gen 1-only; Gold callers receive no parity promise for it.
|
||||||
|
|
||||||
|
The same unavailable result covers missing or sparse active block storage and
|
||||||
|
an accessor result that does not match its validated active block slot.
|
||||||
|
|
||||||
|
### Conditional map occupancy
|
||||||
|
|
||||||
|
`map.occupancy_allowed` is a narrow Gen 1 hook around a map script's vanilla
|
||||||
|
decision to eject the player from an otherwise valid loaded map. Its first
|
||||||
|
call site is the post-departure `VERMILION_DOCK` branch. The ship has already
|
||||||
|
been erased when the hook runs, and the hook does not replace or suppress any
|
||||||
|
base or peer map handler.
|
||||||
|
|
||||||
|
The wrapper receives `(next, game, context)`. The dock context is a copied
|
||||||
|
`{ mapId = "VERMILION_DOCK", reason = "ss_anne_departed", gameVersion, x, y }`
|
||||||
|
record. Vanilla returns `false`. Return exactly `true` to allow the player to
|
||||||
|
remain; every other value denies occupancy and preserves the normal message
|
||||||
|
and warp. A composable wrapper calls downstream first and only adds its own
|
||||||
|
permission:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
mod.hooks:wrap("map.occupancy_allowed", function(next, game, ctx)
|
||||||
|
local allowed = next(game, ctx)
|
||||||
|
local mine = ctx.mapId == "VERMILION_DOCK"
|
||||||
|
and ctx.reason == "ss_anne_departed"
|
||||||
|
and myPublicEligibilityCheck(game)
|
||||||
|
return allowed == true or mine == true
|
||||||
|
end)
|
||||||
|
```
|
||||||
|
|
||||||
|
With no wrapper, the hook allocates no context and vanilla behavior is
|
||||||
|
unchanged. A throwing wrapper is isolated by the normal hook bus. A nil,
|
||||||
|
string, number, table, or other malformed final answer fails closed. Disabling
|
||||||
|
or uninstalling the permitting mod therefore restores vanilla ejection without
|
||||||
|
changing the S.S. Anne story flag or restoring the ship.
|
||||||
|
|
||||||
|
Normal hook-chain ownership applies: a wrapper that does not call `next`
|
||||||
|
intentionally owns the final answer and does not run lower-priority wrappers.
|
||||||
|
Permission wrappers must call `next` as shown above to compose. A noncompliant
|
||||||
|
wrapper that returns false without calling `next` safely denies occupancy and
|
||||||
|
can suppress downstream permission by this standard rule. A malformed answer
|
||||||
|
also fails closed and cannot force occupancy.
|
||||||
|
|
||||||
## Party ordering
|
## Party ordering
|
||||||
|
|
||||||
Companion UIs and alternate party screens can call
|
Companion UIs and alternate party screens can call
|
||||||
@@ -246,6 +325,80 @@ Red exposes FLY separately because it requires a destination picker:
|
|||||||
and `mod.world:flyTo(mapId)` accepts only a visited destination from the native
|
and `mod.world:flyTo(mapId)` accepts only a visited destination from the native
|
||||||
Fly town list. Gold does not expose these two methods yet.
|
Fly town list. Gold does not expose these two methods yet.
|
||||||
|
|
||||||
|
## Self-driven world actors
|
||||||
|
|
||||||
|
`mod.world:spawnNpc()` returns a handle whose `scriptMove` queues onto the
|
||||||
|
overworld's scripted-movement list. A non-empty list is how the overworld
|
||||||
|
knows a cutscene is running, so it gates player input for as long as the
|
||||||
|
actor walks -- right for Oak marching to his lab, wrong for an actor that
|
||||||
|
moves on its own schedule (a networked player's ghost, an ambient walker).
|
||||||
|
Five handle methods drive one without that lockout:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local ghost = mod.world:spawnNpc({ map = "ROUTE_1", x = 5, y = 7,
|
||||||
|
sprite = "SPRITE_RED" })
|
||||||
|
if ghost:canStep("up") then ghost:stepNow("up") end -- one tile, now
|
||||||
|
if not ghost:isMoving() then ghost:placeAt(9, 3, "down") end -- snap, no walk
|
||||||
|
ghost:setPassable(true) -- walk-through
|
||||||
|
```
|
||||||
|
|
||||||
|
`stepNow(dir)` sets the same per-tile state `scriptMove` does, minus the
|
||||||
|
queue. It deliberately does **not** check collision: a caller replaying a
|
||||||
|
move that was already decided elsewhere (validated on a peer's machine, or
|
||||||
|
authored) would let the two copies disagree about where the actor is if this
|
||||||
|
re-judged it. Ask `canStep(dir)` first when you do want the map's opinion.
|
||||||
|
`placeAt(x, y, facing)` snaps with no animation and clears any step in
|
||||||
|
flight, for a warp arrival or a resync too far gone to walk off.
|
||||||
|
`isMoving()` lets a driver pace itself instead of stomping a move already
|
||||||
|
running. `setPassable(flag)` is the flag `Collision.occupied` skips (the
|
||||||
|
engine's own user is Yellow's companion Pikachu); a passable object still
|
||||||
|
draws and can still be talked to.
|
||||||
|
|
||||||
|
An object spawned this way carries no `TEXT_*` id, so the vanilla talk path
|
||||||
|
has nothing to say for it. The **`world.talk`** hook is the A press on an
|
||||||
|
object, raised before the map's text tables get it:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
mod.hooks:wrap("world.talk", function(next, ow, target)
|
||||||
|
if mine(target) then
|
||||||
|
say(target) -- the mod answers for an object it owns
|
||||||
|
return -- ...by not calling next()
|
||||||
|
end
|
||||||
|
return next(ow, target) -- everything else falls through unchanged
|
||||||
|
end)
|
||||||
|
```
|
||||||
|
|
||||||
|
With no subscriber the A press reaches `talkTo` exactly as before. An object
|
||||||
|
mid-step raises no hook, matching the vanilla gate.
|
||||||
|
|
||||||
|
## Adopting an already-paired link session
|
||||||
|
|
||||||
|
`LinkState.newFromSession(game, transport, mode, isHost, opts)` starts a link
|
||||||
|
session on a transport that is *already* paired, skipping the address/code
|
||||||
|
entry UI while keeping the hello and fingerprint compatibility exchange
|
||||||
|
intact. `transport` is anything `Session` accepts, which is what lets a mode
|
||||||
|
tunnel a battle through its own connection rather than opening a second one.
|
||||||
|
|
||||||
|
When the battle finishes, **`link.battle_ended`** reports the outcome:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
mod.events:on("link.battle_ended", function(ev)
|
||||||
|
-- ev = { result, myParty, theirParty, peerName, role }
|
||||||
|
end)
|
||||||
|
```
|
||||||
|
|
||||||
|
The party copies are the point. Cable rules leave the real party untouched,
|
||||||
|
so a mode built on link battles -- a tournament ladder, a battle royale --
|
||||||
|
has no other way to learn what the fight cost, and by the time the state
|
||||||
|
unwinds the battle object is gone. `role` is `"host"` or `"guest"`.
|
||||||
|
|
||||||
|
Two smaller pieces support the same shape of mode. `Game:startNewGame(opts)`
|
||||||
|
is the title screen's NEW GAME closure made callable, with `opts.intro =
|
||||||
|
false` to land straight in the world -- a mode that hands out its own starting
|
||||||
|
state has no use for Oak's speech. `CodeEntry.new` takes an optional
|
||||||
|
`{ length = , charset = }`, so the slot-scrub widget that enters a link code
|
||||||
|
can also carry a room code or an address.
|
||||||
|
|
||||||
## Read-only battle snapshots
|
## Read-only battle snapshots
|
||||||
|
|
||||||
`mod.battle:snapshot()` returns `nil` outside a battle and a copied battle
|
`mod.battle:snapshot()` returns `nil` outside a battle and a copied battle
|
||||||
@@ -441,6 +594,28 @@ default** (1x front, 2x back).
|
|||||||
ball-to-pic grow multiplies your scale through each stage, so a rescaled
|
ball-to-pic grow multiplies your scale through each stage, so a rescaled
|
||||||
mon still grows into place from the ball, grounded the whole way.
|
mon still grows into place from the ball, grounded the whole way.
|
||||||
|
|
||||||
|
## Installation-scoped generated cache
|
||||||
|
|
||||||
|
Generated data derived from a validated user source often belongs to the mod
|
||||||
|
installation rather than to one Pokémon save. `mod.cache` is that namespace:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local ok, err = mod.cache:write("extract/v1/arena.bin", encodedArena)
|
||||||
|
local bytes, err = mod.cache:read("extract/v1/arena.bin")
|
||||||
|
local info = mod.cache:info("extract/v1/arena.bin")
|
||||||
|
mod.cache:delete("extract/v1/arena.bin")
|
||||||
|
```
|
||||||
|
|
||||||
|
The physical root is engine-owned (`mod_cache/<mod-id>/`) and never exposed to
|
||||||
|
the mod. Keys are safe relative paths and a single write is capped at 64 MiB.
|
||||||
|
The cache does not rewind with checkpoints and is not scoped to game version,
|
||||||
|
slot, or playthrough. The mod owns its generated format, fingerprints, rebuild
|
||||||
|
policy, and completion marker; the engine treats the bytes as opaque data.
|
||||||
|
|
||||||
|
Use `mod.storage` instead when the data belongs to one playthrough. Use
|
||||||
|
`mod.cache` when it is a reproducible installation artifact that can be rebuilt
|
||||||
|
from a declared user source.
|
||||||
|
|
||||||
## Durable tool storage and runtime checkpoints
|
## Durable tool storage and runtime checkpoints
|
||||||
|
|
||||||
`mod.save` remains the right place for state that should travel with the next
|
`mod.save` remains the right place for state that should travel with the next
|
||||||
@@ -633,17 +808,51 @@ 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
|
use only public `mod.ui`, hook, and save APIs. See RFC 0010 for the exact
|
||||||
contract and compatibility guarantees.
|
contract and compatibility guarantees.
|
||||||
|
|
||||||
|
Both battle engines expose the guarded `battle.charge_required` hook when a
|
||||||
|
charge-capable move is selected for its initial turn and the active ruleset
|
||||||
|
would otherwise charge it. The wrapper receives `(next, ctx)`, where `ctx` is
|
||||||
|
`{ battle, user, target, move, charge = true, isCalled }`. Return `false` to
|
||||||
|
skip only that initial charge and continue through the ordinary move pipeline;
|
||||||
|
call `next(ctx)` to keep it. The hook does not run for the release turn or when
|
||||||
|
the active ruleset already skips charging (for example, Gold Solarbeam in
|
||||||
|
sun). PP use, accuracy, damage, animation, and secondary effects remain owned
|
||||||
|
by the engine. With no subscriber, the vanilla decision runs without building
|
||||||
|
the hook context.
|
||||||
|
|
||||||
## Developer console
|
## Developer console
|
||||||
|
|
||||||
Boot with developer mode on to unlock the in-game console and hot-reload
|
On a Gen 1 boot, developer mode unlocks the in-game console and hot-reload
|
||||||
hotkeys. Either set `POKEPORT_DEV=1` in the environment or pass
|
hotkeys. Either set `POKEPORT_DEV=1` in the environment or pass `--developer`
|
||||||
`--developer` on the command line:
|
on the command line:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
love . --developer
|
love . --developer
|
||||||
```
|
```
|
||||||
|
|
||||||
While developer mode is active:
|
The mod loader independently derives a matching boolean for every sandboxed
|
||||||
|
entry chunk as `mod.developer`. It is available while the entry file is
|
||||||
|
loading, so a mod can keep diagnostic commands, screens, and verbose tracing
|
||||||
|
out of player builds:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
if mod.developer then
|
||||||
|
mod.commands:register("my_mod:diagnostics", function(ctx)
|
||||||
|
-- open or print this mod's diagnostic view
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
`mod.developer` is a plain boolean snapshot for this boot. It grants no
|
||||||
|
permission and exposes neither the process environment nor the loader. In a
|
||||||
|
normal player boot it is `false`; `POKEPORT_DEV=1` and `--developer` make it
|
||||||
|
`true`. On Gen 1 those inputs separately enable the console and hot-reload
|
||||||
|
hotkeys. The headless loader's `opts.dev` test seam changes only the loader
|
||||||
|
signal and diagnostics; it does not enable the game's console or hot reload.
|
||||||
|
Gold exposes the same `mod.developer` boolean but does not implement the Gen 1
|
||||||
|
console or hotkeys. Use a mod option for player-facing feature toggles rather
|
||||||
|
than treating developer mode as configuration.
|
||||||
|
|
||||||
|
While developer mode is active on Gen 1:
|
||||||
|
|
||||||
- `` ` `` (backtick) opens the console overlay — a Lua REPL with `game`,
|
- `` ` `` (backtick) opens the console overlay — a Lua REPL with `game`,
|
||||||
`data` and `mods` in scope. Press `` ` `` again to close it.
|
`data` and `mods` in scope. Press `` ` `` again to close it.
|
||||||
@@ -754,7 +963,7 @@ contract, so a mod does not need a desktop-specific rendering path.
|
|||||||
|
|
||||||
`render.output_enabled` and `render.output` are the later, whole-window seam
|
`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
|
for mods that need the engine's normal composite rather than its separate
|
||||||
layers. It runs after registered present pipelines and before GBCFX,
|
layers. It runs after registered present pipelines and before ShaderFX,
|
||||||
`render.hud`, and touch controls. A mod wraps both hooks: the first returns
|
`render.hud`, and touch controls. A mod wraps both hooks: the first returns
|
||||||
`true` only while output ownership is needed, and the second receives
|
`true` only while output ownership is needed, and the second receives
|
||||||
`(next, ctx)` with `canvas`, `width`, `height`, `gameX`, `gameY`, `gameWidth`,
|
`(next, ctx)` with `canvas`, `width`, `height`, `gameX`, `gameY`, `gameWidth`,
|
||||||
@@ -805,6 +1014,47 @@ which stay unconditional and are never visible to a subscriber.
|
|||||||
Developer mode also arms the mod loader's dev tripwire, which flags mods
|
Developer mode also arms the mod loader's dev tripwire, which flags mods
|
||||||
that reach outside their permission set.
|
that reach outside their permission set.
|
||||||
|
|
||||||
|
## Battle field residual hook
|
||||||
|
|
||||||
|
`battle.field_residual` lets a Gen 1 battle-rule mod request end-of-round
|
||||||
|
damage without mutating live battlers. It is guarded and runs after vanilla
|
||||||
|
status residuals, before field-token expiry and `battle.turn_ended`. The
|
||||||
|
wrapper receives `(next, context)`, calls `next(context)` for the existing
|
||||||
|
descriptor list, and appends data-only rows:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
mod.hooks:wrap("battle.field_residual", function(next, context)
|
||||||
|
local rows = next(context)
|
||||||
|
rows[#rows + 1] = {
|
||||||
|
side = "enemy", amount = 7,
|
||||||
|
message = context.battlers.enemy.name .. " is buffeted!",
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
end)
|
||||||
|
```
|
||||||
|
|
||||||
|
`context.field` is a detached, data-only view with the same
|
||||||
|
`{ weather, tokens }` shape that battle checkpoints capture; it does not expose
|
||||||
|
`field.sides` or any live battler aliases. The projection recursively retains
|
||||||
|
raw tables and finite numbers, strings, and booleans under scalar keys. It
|
||||||
|
strips metatables and omits functions, userdata, threads, unsupported keys, and
|
||||||
|
cyclic edges. Consequently a wrapper cannot obtain or invoke an engine callback
|
||||||
|
even if a live field token uses one internally, and changing any nested view
|
||||||
|
value cannot change live field state. `context.battlers.player` and `.enemy` are
|
||||||
|
detached `{ side, name, hp, maxHp, types, vanished }` views, and `context.turn`
|
||||||
|
is the current turn number. A descriptor accepts `side`
|
||||||
|
(`player` or `enemy`), a positive, finite integer number `amount`, and an
|
||||||
|
optional string `message`.
|
||||||
|
Numeric strings and invalid rows are ignored; damage is clamped to current HP.
|
||||||
|
The engine retains HP-bar, faint, experience, and replacement authority. If
|
||||||
|
both active battlers take terminal residual damage together and the player has
|
||||||
|
no healthy reserve, this hook batch queues only the player faint authority and
|
||||||
|
resolves as a blackout loss without an enemy-faint EXP award or replacement.
|
||||||
|
That precedence is local to accepted rows from this hook; native faint paths
|
||||||
|
are unchanged when no hook is active. Hook callbacks remain process-local;
|
||||||
|
checkpoints serialize only field data. Gold does not yet raise this hook; its
|
||||||
|
native weather pipeline is documented in `docs/mod-api-gen2-compat.md`.
|
||||||
|
|
||||||
## Process-lifecycle hooks
|
## Process-lifecycle hooks
|
||||||
|
|
||||||
These exist so a platform-specific launcher integration (a native shell
|
These exist so a platform-specific launcher integration (a native shell
|
||||||
|
|||||||
@@ -11,11 +11,17 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow
|
|||||||
* **Persistent custom options** stored separately from game saves
|
* **Persistent custom options** stored separately from game saves
|
||||||
* **Optional widescreen battle layout**
|
* **Optional widescreen battle layout**
|
||||||
* **Mobile touch controls** with editable layouts, vibration, and orientation settings
|
* **Mobile touch controls** with editable layouts, vibration, and orientation settings
|
||||||
* **Touch skins** in RetroArch overlay format, with bezel art, per-button press states, and Super Game Boy borders
|
* **Screen position setting** (center, upper, top) shared across all games, for clamp-on controllers that cover the lower screen
|
||||||
|
* **Touch skins** in RetroArch overlay format and Delta `.deltaskin` (including PDF-wrapped bezel art), with per-button press states and Super Game Boy borders
|
||||||
* **Pokédex diploma and printer image exports**
|
* **Pokédex diploma and printer image exports**
|
||||||
* **Mod download counts** from the index feed, with Most-downloaded and Trending sorts
|
* **Shareable mod lists** over save sync, optionally carrying the options set for those mods, which the receiving device is asked about before anything is changed
|
||||||
|
* **Custom carts**, a named mod set saved from the mods tab and picked from a game's page, with its own shell colour, label art, save slots and export file
|
||||||
|
* **Install required mods**, one press on a cart that will not start, fetching every pinned mod at the pinned version and refusing any archive whose hash is not the one the cart recorded
|
||||||
|
* **Browse carts in Find mods**, a Mods / Carts switch on the same community index, searched and filtered by base game, installing the cart file straight into that game's cart list
|
||||||
|
|
||||||
## Gen 2 Specifics
|
## Gen 2 Specifics
|
||||||
|
|
||||||
|
* **Pokémon Silver** as an importable, launcher-selectable version alongside Gold
|
||||||
|
* **Pokémon Crystal** as an importable, launcher-selectable version alongside Gold and Silver
|
||||||
* **Mod manager** with Gen 1 mod adapters, per-game targeting, and `modkit gen2check`
|
* **Mod manager** with Gen 1 mod adapters, per-game targeting, and `modkit gen2check`
|
||||||
* **Followers** for mods, plus Gen 2-only registries and hooks
|
* **Followers** for mods, plus Gen 2-only registries and hooks
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ something the filesystem encodes.
|
|||||||
|
|
||||||
| token | means |
|
| token | means |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `"red"`, `"blue"`, `"yellow"`, `"gold"` | that one game (a version id from `GameVersion.ORDER`) |
|
| `"red"`, `"blue"`, `"yellow"`, `"gold"`, `"silver"`, `"crystal"` | that one game (a version id from `GameVersion.ORDER`) |
|
||||||
| `"gen1"`, `"gen2"` | every game of that generation (case-insensitive; `"gen 2"` also parses) |
|
| `"gen1"`, `"gen2"` | every game of that generation (case-insensitive; `"gen 2"` also parses) |
|
||||||
| `"all"` | every game this engine has |
|
| `"all"` | every game this engine has |
|
||||||
|
|
||||||
@@ -768,7 +768,8 @@ the same:
|
|||||||
So: read the log for coverage problems, and the manager for load problems.
|
So: read the log for coverage problems, and the manager for load problems.
|
||||||
|
|
||||||
`POKEPORT_IDENTITY=<name>` sandboxes the save directory if you want a clean
|
`POKEPORT_IDENTITY=<name>` sandboxes the save directory if you want a clean
|
||||||
profile to test in, and `POKEPORT_DEV=1` adds the console and `F5` hot reload.
|
profile to test in. `POKEPORT_DEV=1` makes `mod.developer` true for loader-gated
|
||||||
|
diagnostics; Gold does not add Gen 1's console or `F5` hot reload.
|
||||||
|
|
||||||
## What this guide does not promise
|
## What this guide does not promise
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
# What This Port Requires
|
# What This Port Requires
|
||||||
|
|
||||||
The packaged desktop app requires one user-supplied input on first boot: a
|
The packaged desktop app requires one user-supplied input on first boot: a
|
||||||
canonical 1 MiB US Pokemon Red, Blue, or Yellow ROM.
|
canonical 1 MiB US Pokemon Red, Blue, or Yellow ROM, or a canonical 2 MiB US
|
||||||
|
Pokemon Gold or Silver ROM.
|
||||||
|
|
||||||
The importer verifies the SHA-1 for the game (see `src/core/GameVersion.lua`
|
The importer verifies the SHA-1 for the game (see `src/core/GameVersion.lua`
|
||||||
for specific hashes). Other revisions and Virtual Console releases are rejected
|
for specific hashes). Other revisions and Virtual Console releases are rejected
|
||||||
@@ -15,7 +16,8 @@ Python and Pillow are not required by the packaged app.
|
|||||||
|
|
||||||
Assembly removes high-level names and some relationships that the Lua port
|
Assembly removes high-level names and some relationships that the Lua port
|
||||||
needs. The version-specific files `tools/rom_manifest.json`,
|
needs. The version-specific files `tools/rom_manifest.json`,
|
||||||
`tools/rom_manifest_blue.json`, and `tools/rom_manifest_yellow.json` therefore
|
`tools/rom_manifest_blue.json`, `tools/rom_manifest_yellow.json`,
|
||||||
|
`tools/rom_manifest_gold.json`, and `tools/rom_manifest_silver.json` therefore
|
||||||
contain:
|
contain:
|
||||||
|
|
||||||
- the ROM symbol addresses actually read by the extractor
|
- the ROM symbol addresses actually read by the extractor
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
# RFC 0008 — Streamed mod imports and installation-scoped generated cache
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
`required_imports`/`optional_imports` can now describe files up to 2 GiB, but
|
||||||
|
the existing launcher and public mod API still assume imported bytes are small:
|
||||||
|
|
||||||
|
* the Windows desktop picker stages a selected required import through a fixed
|
||||||
|
`%TEMP%/pokeport_required_import.bin` path before validation;
|
||||||
|
* the fallback import path materializes the selected file as one Lua string;
|
||||||
|
* after validation a mod can only use `mod:read("baseroms/...")`, which also
|
||||||
|
materializes the whole file;
|
||||||
|
* `mod.storage` is intentionally scoped to one Pokémon playthrough, so it is
|
||||||
|
not an appropriate home for a one-time generated asset cache shared by every
|
||||||
|
save using the same installed mod.
|
||||||
|
|
||||||
|
This makes optical-disc-sized user sources impractical even though the manifest
|
||||||
|
schema already accepts them. A failed temporary staging copy can also turn a
|
||||||
|
valid large source into a smaller temporary file and produce a misleading
|
||||||
|
"wrong file size" rejection.
|
||||||
|
|
||||||
|
A mod should be able to consume its own already-validated source incrementally
|
||||||
|
and compile derived runtime data once, without receiving a host path or general
|
||||||
|
filesystem access.
|
||||||
|
|
||||||
|
## Decision being extended
|
||||||
|
|
||||||
|
This extends the same legal/sandbox direction as **D11 asset transforms**
|
||||||
|
(`src/mods/AssetTransform.lua`): mods distribute recipes and derive bytes from
|
||||||
|
user-owned sources rather than shipping ROM-derived data. It also follows the
|
||||||
|
**D14 parity-gate** contract referenced by `tests/harness.lua` and
|
||||||
|
`tests/engine/gate_meta_coverage.lua` (the `21-testing-and-ci` plan): additive
|
||||||
|
extension points ship public-API coverage, no-mod parity coverage, and docs in
|
||||||
|
the same change.
|
||||||
|
|
||||||
|
The historical D11 plan document is referenced by source comments but is not
|
||||||
|
present in the current repository tree; this RFC is the checked-in design
|
||||||
|
record for the new surface.
|
||||||
|
|
||||||
|
## Exact API delta
|
||||||
|
|
||||||
|
No manifest field changes. Existing `required_imports` and `optional_imports`
|
||||||
|
remain the declaration/validation authority.
|
||||||
|
|
||||||
|
Two additive facades are added to the `mod` object.
|
||||||
|
|
||||||
|
### `mod.imports`
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local info, err = mod.imports:info("source_id")
|
||||||
|
local bytes, err = mod.imports:read("source_id", offset, length)
|
||||||
|
```
|
||||||
|
|
||||||
|
* `source_id` must name an import declared by the calling mod.
|
||||||
|
* the import is rechecked through `RequiredImports.validateStored` before it is
|
||||||
|
exposed, so missing, replaced, or invalid optional imports are not readable;
|
||||||
|
* `offset` and `length` are zero-based byte coordinates;
|
||||||
|
* one read is capped at 8 MiB;
|
||||||
|
* no host path or file handle is returned;
|
||||||
|
* production reads seek into the engine-owned stored copy instead of reading
|
||||||
|
the whole source.
|
||||||
|
|
||||||
|
`info()` returns declaration metadata plus stored size. It does not expose a
|
||||||
|
host path.
|
||||||
|
|
||||||
|
### `mod.cache`
|
||||||
|
|
||||||
|
```lua
|
||||||
|
mod.cache:write("extract/v1/model.bin", bytes)
|
||||||
|
local bytes = mod.cache:read("extract/v1/model.bin")
|
||||||
|
local info = mod.cache:info("extract/v1/model.bin")
|
||||||
|
mod.cache:delete("extract/v1/model.bin")
|
||||||
|
```
|
||||||
|
|
||||||
|
The cache is rooted at `mod_cache/<mod-id>/`, follows the engine persistence
|
||||||
|
backend, and is independent of game version, launcher slot, and playthrough.
|
||||||
|
Paths are checked with `SafePath`; `..`, absolute paths, drive paths, and other
|
||||||
|
escapes remain unavailable. A single cache write is capped at 64 MiB so large
|
||||||
|
generated datasets are naturally split into independently replaceable files.
|
||||||
|
|
||||||
|
The engine does not interpret cache bytes. Mods own generated-format versioning,
|
||||||
|
fingerprints, transactional completion markers, and rebuild policy.
|
||||||
|
|
||||||
|
## Launcher/import transport delta
|
||||||
|
|
||||||
|
For large raw required imports:
|
||||||
|
|
||||||
|
1. desktop pickers return the original selected path instead of staging it
|
||||||
|
through a fixed temporary file;
|
||||||
|
2. the engine opens that source itself;
|
||||||
|
3. bytes are copied directly to the existing engine-owned
|
||||||
|
`mods/<id>/baseroms/<file>` destination in 4 MiB chunks;
|
||||||
|
4. MD5 is updated incrementally during the copy;
|
||||||
|
5. the normal size/MD5 validation receipt is written only after the complete
|
||||||
|
destination passes validation;
|
||||||
|
6. partial destinations are removed on short reads, write failure, size
|
||||||
|
mismatch, or digest mismatch.
|
||||||
|
|
||||||
|
N64 imports stay on the existing canonicalization path because byte-order and
|
||||||
|
copier-header normalization require transformation rather than a raw copy.
|
||||||
|
|
||||||
|
If a validation receipt for an already-stored large raw import is missing, the
|
||||||
|
engine rebuilds it with streaming MD5 rather than a whole-file read.
|
||||||
|
|
||||||
|
## Backward compatibility / migration
|
||||||
|
|
||||||
|
**Existing mods do nothing.** This is additive.
|
||||||
|
|
||||||
|
* manifest v1/v2 fields are unchanged;
|
||||||
|
* `mod:read`, `mod.storage`, registries, events, hooks, and legacy compatibility
|
||||||
|
retain their existing behavior;
|
||||||
|
* small required imports retain the existing in-memory validation path;
|
||||||
|
* N64 imports retain canonicalization and existing accepted byte orders;
|
||||||
|
* a mod that never touches `mod.imports` or `mod.cache` creates no new cache
|
||||||
|
files and observes no new behavior.
|
||||||
|
|
||||||
|
The mod API integer is not bumped because no existing member changes meaning or
|
||||||
|
shape.
|
||||||
|
|
||||||
|
## Security and legal posture
|
||||||
|
|
||||||
|
The launcher remains the authority that validates user-supplied bytes. The new
|
||||||
|
facade narrows access rather than widening it: a mod can read only ids declared
|
||||||
|
in its own manifest, only after validation, and only in bounded ranges. It does
|
||||||
|
not receive host paths, `io`, or a raw filesystem handle.
|
||||||
|
|
||||||
|
`mod.cache` is writable only beneath the calling mod's generated-cache root.
|
||||||
|
Nothing in this RFC permits packaged ROM-derived bytes; `modkit lint/pack`
|
||||||
|
continue to enforce the existing legal posture.
|
||||||
|
|
||||||
|
## Parity guarantee
|
||||||
|
|
||||||
|
The change ships with:
|
||||||
|
|
||||||
|
* a no-mod/API-v1 parity test proving an empty load and an existing v1-style
|
||||||
|
`mod:read` load do not create cache data or change the old surface;
|
||||||
|
* a public mod-API test that reaches `mod.imports` and `mod.cache` through a
|
||||||
|
real `Loader` load, including bounded reads, undeclared/missing imports,
|
||||||
|
cache isolation, and traversal rejection;
|
||||||
|
* incremental MD5 vectors and a large-import streaming regression test;
|
||||||
|
* the existing engine suite, required-import suite, and mod lint gates.
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# RFC 0011: Charge-required battle hook
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Proposed.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
A battle-mechanics mod can change damage through `battle.damage` and register
|
||||||
|
move effects, but it cannot conditionally skip the first turn of an existing
|
||||||
|
charge move. In Gen 1, the engine decides and stores the charge continuation
|
||||||
|
before any public effect callback can run. Reaching into `user.charging`,
|
||||||
|
`user.chargeReady`, or generation-specific volatile state is private,
|
||||||
|
checkpoint-fragile, and would require a mod to duplicate move-pipeline policy.
|
||||||
|
|
||||||
|
Weather is the immediate example: a portable sun rule needs Solarbeam to
|
||||||
|
resolve on selection while leaving Fly, Dig, PP use, hit resolution, animation,
|
||||||
|
and secondary effects to the engine. The capability is generic and useful to
|
||||||
|
other ruleset and move-mechanics mods.
|
||||||
|
|
||||||
|
## Decision and plan extended
|
||||||
|
|
||||||
|
This implements **D-AT-002: charge-stage policy remains mod authority through a
|
||||||
|
generic guarded engine decision seam**. The consuming design is tracked in the
|
||||||
|
Adaptive Trainers implementation plan,
|
||||||
|
[`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 8. The delta follows the additive, guarded hook convention documented by
|
||||||
|
Route B in `CONTRIBUTING-mods.md`; it contains no weather, move-id, trainer, or
|
||||||
|
Adaptive Trainers policy.
|
||||||
|
|
||||||
|
## Exact API delta
|
||||||
|
|
||||||
|
Both the Gen 1 and Gen 2 battle engines add this guarded hook:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
mod.hooks:wrap("battle.charge_required", function(next, ctx)
|
||||||
|
-- ctx = {
|
||||||
|
-- battle = live battle controller,
|
||||||
|
-- user = attacking battler,
|
||||||
|
-- target = defending battler,
|
||||||
|
-- move = merged move record,
|
||||||
|
-- charge = true,
|
||||||
|
-- isCalled = false,
|
||||||
|
-- }
|
||||||
|
if should_resolve_now(ctx) then return false end
|
||||||
|
return next(ctx)
|
||||||
|
end)
|
||||||
|
```
|
||||||
|
|
||||||
|
The call site is the initial-use charge decision, after announcement and PP
|
||||||
|
handling but before charge state, invulnerability, charge animation, or charge
|
||||||
|
text is created. It runs only when the active engine rules would otherwise
|
||||||
|
require a charge. It does not run on the release turn. Returning exactly
|
||||||
|
`false` skips that initial charge and continues through the engine-owned move
|
||||||
|
pipeline. Any other downstream return preserves the charge. `isCalled` is true
|
||||||
|
when Metronome or Mirror Move selected the move.
|
||||||
|
|
||||||
|
Gold keeps its native sun decision first, so Solarbeam in native sun already
|
||||||
|
requires no charge and does not invoke the hook. Gen 1 link battles use the
|
||||||
|
shared Gen 1 move pipeline and therefore receive the same seam; normal link
|
||||||
|
mod-compatibility rules continue to govern deterministic peers.
|
||||||
|
|
||||||
|
The hot path first calls `Runtime.wantsHook("battle.charge_required")`. With no
|
||||||
|
subscriber, no hook payload table is allocated and the existing branch runs
|
||||||
|
unchanged.
|
||||||
|
|
||||||
|
## Migration and compatibility
|
||||||
|
|
||||||
|
Existing mods change nothing. The hook name and payload are additive. With no
|
||||||
|
wrapper installed, Red, Blue, Yellow, Gold, and Silver retain their previous
|
||||||
|
charge state, PP use, text, animation, accuracy, damage, and native weather
|
||||||
|
behavior. Existing charge-move data and effect records require no migration.
|
||||||
|
|
||||||
|
A mod adopting the seam should call `next(ctx)` unless it deliberately wants to
|
||||||
|
skip this charge. It should not mutate private charge fields or re-run the move.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `tests/engine/battle_charge_required.lua` exercises the real Gen 1 and Gen 2
|
||||||
|
engines through a sandboxed public mod, including false-to-skip, next-to-keep,
|
||||||
|
release-turn behavior, called-move PP semantics, shared payload shape, and
|
||||||
|
native Gold sun behavior.
|
||||||
|
- The same test proves no-mod charge/release parity and replaces
|
||||||
|
`Runtime.call` with a sentinel behind a false `Runtime.wantsHook` guard.
|
||||||
|
- `tests/engine/gate_hooks.lua` discovers the new catalog name and proves empty
|
||||||
|
chains preserve vanilla values and allocation behavior.
|
||||||
|
- `tests/engine/gate_gen2_mod_api.lua` requires a guarded site in both
|
||||||
|
generations and keeps the compatibility reference list complete.
|
||||||
|
|
||||||
|
## Deprecation etiquette
|
||||||
|
|
||||||
|
Nothing is removed, renamed, superseded, or deprecated.
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
# RFC 0012: `applyShare`'s announce argument on Gen 2
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Proposed.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
`battle.exp_award` hands a mod `ctx.applyShare(mon, split, announce)` on both
|
||||||
|
generations. On Gen 1 the third argument decides whether the mon's GainedText
|
||||||
|
box is printed (`src/battle/BattleState.lua`, `if announce then`), which is how
|
||||||
|
a mod that pays the whole party prints **one** summary line instead of a box
|
||||||
|
per recipient.
|
||||||
|
|
||||||
|
Gold accepts the argument and ignores it, as its own comment above the hook
|
||||||
|
call says. So the same mod source, running the same code, prints one line on
|
||||||
|
Red and six on Gold — one for the participant plus one for every bench mon it
|
||||||
|
paid.
|
||||||
|
|
||||||
|
This is not hypothetical. The [Exp Share](https://github.com/ShaneMcGovernIE/exp_share)
|
||||||
|
mod declares `"games": ["gen1", "gen2"]` and its description promises "a single
|
||||||
|
shared-exp line instead of one message per Pokemon". It passes `true` for the
|
||||||
|
fighters and `nil` for the bench, exactly as the Gen 1 seam asks. On Gold every
|
||||||
|
one of those `nil` calls announces anyway, so a five-mon party turns every KO
|
||||||
|
into six boxes to click through.
|
||||||
|
|
||||||
|
There is no mod-side fix. The announcement is emitted inside
|
||||||
|
`Battle:giveExperiencePass`, behind no hook, and a mod cannot ask for silence
|
||||||
|
because the argument that means "quietly" is discarded. The only workaround is
|
||||||
|
to intercept the battle's event queue afterwards and delete the boxes, which is
|
||||||
|
what a mod written for this had to do — a mod reaching into engine internals to
|
||||||
|
undo something the public seam should never have done.
|
||||||
|
|
||||||
|
## Decision and plan extended
|
||||||
|
|
||||||
|
This does not add a seam. It finishes one: `battle.exp_award` is documented as
|
||||||
|
"the same hook `BattleState:awardExp` calls on Gen 1 and with the same ctx",
|
||||||
|
and `docs/mod-api-gen2-compat.md` lists it among the hooks shared with Gen 1.
|
||||||
|
The third `applyShare` argument is the one part of that ctx whose meaning did
|
||||||
|
not survive the crossing, so the promise the catalog already makes is what this
|
||||||
|
change delivers.
|
||||||
|
|
||||||
|
The delta follows Route B's additive, guarded convention in
|
||||||
|
`CONTRIBUTING-mods.md`: nothing is renamed, nothing is removed, and no mod that
|
||||||
|
exists today changes behaviour.
|
||||||
|
|
||||||
|
## Exact API delta
|
||||||
|
|
||||||
|
`ctx.applyShare(mon, split, announce)` on Gen 2 now reads `announce`:
|
||||||
|
|
||||||
|
| Call | Gen 1 | Gen 2 before | Gen 2 after |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `applyShare(mon, split)` | silent | announces | **announces** (unchanged) |
|
||||||
|
| `applyShare(mon, split, nil)` | silent | announces | **silent** |
|
||||||
|
| `applyShare(mon, split, false)` | silent | announces | **silent** |
|
||||||
|
| `applyShare(mon, split, true)` | announces | announces | announces |
|
||||||
|
| `applyShare(mon, split, "expAll")` | announces | announces | announces |
|
||||||
|
|
||||||
|
The argument is honoured **only when it is actually passed**, decided by
|
||||||
|
argument count rather than by value:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local function applyShare(mon, split, ...)
|
||||||
|
local announce = ...
|
||||||
|
local silent = select("#", ...) > 0 and not announce
|
||||||
|
...
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
`select("#", ...)` counts an explicit `nil`, so `applyShare(mon, split)` and
|
||||||
|
`applyShare(mon, split, nil)` are distinguishable — and they have to be, because
|
||||||
|
the first is a Gen 2-era call written against a seam that always announced, and
|
||||||
|
the second is a deliberate "pay this one quietly".
|
||||||
|
|
||||||
|
Only the `{ kind = "experience" }` event is affected. A silent award is still a
|
||||||
|
whole award: the exp, the stat exp, the `battle.exp_gained` event, the
|
||||||
|
`grew to level` line, learned moves and the interactive forget-a-move prompt all
|
||||||
|
happen exactly as before, in the same order.
|
||||||
|
|
||||||
|
Internally `Battle:giveExperiencePass` takes a sixth parameter, `silent`. It
|
||||||
|
defaults to announcing, so both of the cart's own passes are untouched.
|
||||||
|
|
||||||
|
## Migration and compatibility
|
||||||
|
|
||||||
|
**Existing mods change nothing.** A Gen 2 mod calling `applyShare(mon, split)`
|
||||||
|
gets the behaviour it was written against. A Gen 1 mod is untouched: no Gen 1
|
||||||
|
file is modified. The v1 surface — `content.X:register/override/get`,
|
||||||
|
`events:on`, `hooks:wrap`, `mod.log`, `mod:read`, the manifest v1 fields and
|
||||||
|
`pokemon.before_give` — is not involved; `mods/example_mew_starter` neither
|
||||||
|
calls this seam nor loads differently.
|
||||||
|
|
||||||
|
A mod that wants parity passes the argument explicitly, which is what the Gen 1
|
||||||
|
seam has always documented. Exp Share already does, and needs no edit to get
|
||||||
|
its own README's behaviour on Gold.
|
||||||
|
|
||||||
|
One residual difference is deliberate and now documented rather than silent: an
|
||||||
|
**omitted** third argument still means "silent" on Gen 1 and "announce" on
|
||||||
|
Gold. Closing that would change what an existing Gen 2 mod prints, which Route
|
||||||
|
B rejects. Passing the argument makes the two generations agree, so the rule an
|
||||||
|
author needs is one sentence: *say what you mean and both games do the same
|
||||||
|
thing.*
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `tests/gen2_exp_share_test.lua` grows two sections and 14 checks, and the 23
|
||||||
|
checks it already had are unchanged — which is itself the vanilla-parity
|
||||||
|
evidence for this file.
|
||||||
|
- **The no-mod test.** With nothing subscribed to `battle.exp_award`, a solo
|
||||||
|
participant still prints one line and the EXP.SHARE double pass still
|
||||||
|
prints both. The hot path is unchanged: `Runtime.wantsHook` still guards
|
||||||
|
the ctx allocation, and `vanillaAward` never passes `silent`.
|
||||||
|
- **The mod-API test.** The seam is driven through `hooks:wrap` on a real
|
||||||
|
`Runtime.install`ed bus, not by calling internals: the omitted argument
|
||||||
|
announces, an explicit `nil` and an explicit `false` are silent, a truthy
|
||||||
|
value (including Gen 1's `"expAll"`) announces, the exp and stat exp paid
|
||||||
|
are identical either way, and no `experience` event leaks into the queue on
|
||||||
|
a silent pass.
|
||||||
|
- `tests/engine/gate_gen2_mod_api.lua` (943 checks),
|
||||||
|
`tests/engine/gate_hooks.lua` (493) and `tests/engine/gate_events.lua` (529)
|
||||||
|
pass unchanged; `battle.exp_award` was already in the shared catalog, so no
|
||||||
|
gate list moves.
|
||||||
|
- `tests/gen2_battle_test.lua` (690), `gen2_battle_end_test.lua` (32),
|
||||||
|
`gen2_battle_items_test.lua` (99), `gen2_badge_boosts_test.lua` (34) and
|
||||||
|
`gen2_battle_loss_test.lua` (15) pass unchanged.
|
||||||
|
|
||||||
|
## Docs with the change
|
||||||
|
|
||||||
|
`docs/mod-api-gen2-compat.md` gains the `applyShare` reading beside the
|
||||||
|
existing `battle.low_health_alarm` payload note, in the same section that lists
|
||||||
|
`battle.exp_award` as shared — including the argument-count rule and the
|
||||||
|
residual difference above.
|
||||||
|
|
||||||
|
No registry or schema field changes, so `src/mods/Schemas.lua` is untouched and
|
||||||
|
`tools/gen_registry_docs.lua` has nothing new to emit.
|
||||||
|
|
||||||
|
## Deprecation etiquette
|
||||||
|
|
||||||
|
Nothing is removed, renamed, superseded or deprecated. The two-argument call is
|
||||||
|
not deprecated either — it keeps its current Gen 2 meaning permanently, and the
|
||||||
|
docs name the explicit form as the one that behaves the same on both games.
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
# RFC 0013: Conditional map occupancy and active-block reads
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Proposed.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
The Gen 1 Vermilion Dock script correctly ejects a player who enters after the
|
||||||
|
S.S. Anne has departed. A content mod can add a city-side route back to that
|
||||||
|
empty harbor, but it cannot preserve the dock visit: replacing the complete
|
||||||
|
dock handler would discard vanilla behavior and peer handlers, while an added
|
||||||
|
handler cannot cancel the base handler's ejection.
|
||||||
|
|
||||||
|
A mod that changes one active map block also needs to prove that it is looking
|
||||||
|
at the expected Red, Blue, or Yellow layout before it acts. `mapOverview()` is
|
||||||
|
intentionally presentation-oriented and does not expose block identity.
|
||||||
|
Requiring internal `Map` state or generated ROM data would cross the public mod
|
||||||
|
boundary and make a wrong-version edit difficult to fail closed.
|
||||||
|
|
||||||
|
## Decision and plan extended
|
||||||
|
|
||||||
|
This extends Route B in `CONTRIBUTING-mods.md`: new behavior is additive,
|
||||||
|
ordinary hook composition remains the authority, an empty hook chain is a
|
||||||
|
provable no-op, and mods receive copied or scalar data rather than mutable
|
||||||
|
engine state. It supports the approved Mew-under-the-truck implementation plan
|
||||||
|
without adding any Mew-specific rule, asset, flag, or content to the engine.
|
||||||
|
|
||||||
|
## Exact API delta
|
||||||
|
|
||||||
|
### `map.occupancy_allowed`
|
||||||
|
|
||||||
|
The post-departure `VERMILION_DOCK` script calls this hook after it replaces the
|
||||||
|
ship blocks with water and immediately before it would display the departure
|
||||||
|
message and warp the player to Vermilion City.
|
||||||
|
|
||||||
|
A wrapper has this shape:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
function(next, game, context) -> boolean
|
||||||
|
```
|
||||||
|
|
||||||
|
The context is a new table with these fields:
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `mapId` | `"VERMILION_DOCK"` at this call site |
|
||||||
|
| `reason` | Stable reason key `"ss_anne_departed"` |
|
||||||
|
| `gameVersion` | Active save version (`red`, `blue`, or `yellow`) when present |
|
||||||
|
| `x`, `y` | Current player cell coordinates when present |
|
||||||
|
|
||||||
|
Vanilla returns `false`. The player remains only when the final chain result is
|
||||||
|
exactly `true`. Absent, throwing, or malformed wrappers therefore preserve
|
||||||
|
ejection. A wrapper composes by calling `next(game, context)` and returning
|
||||||
|
true when either downstream or its own narrow rule permits occupancy. The hook
|
||||||
|
does not replace `MapScripts` registration, merging, or dispatch, and does not
|
||||||
|
change the departure flag or reconstruct the ship.
|
||||||
|
|
||||||
|
As with every wrapper hook, a callback that does not call `next` intentionally
|
||||||
|
owns the final answer and does not run lower-priority callbacks. Permission
|
||||||
|
wrappers must call downstream to compose. A false, non-forwarding wrapper
|
||||||
|
safely denies occupancy and can suppress downstream permission by this normal
|
||||||
|
rule. A malformed final result also fails closed and cannot permit occupancy.
|
||||||
|
|
||||||
|
The call is guarded by `Runtime.wantsHook`, so an empty chain allocates no
|
||||||
|
context and follows the prior branch exactly.
|
||||||
|
|
||||||
|
### `WorldAPI:activeBlockAt`
|
||||||
|
|
||||||
|
Gen 1's public `mod.world` facade adds:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
activeBlockAt(mapId, blockX, blockY) -> blockId
|
||||||
|
| nil, reason
|
||||||
|
```
|
||||||
|
|
||||||
|
`mapId` must equal the active map ID. Coordinates are finite, integral,
|
||||||
|
zero-based block coordinates. A successful result is a numeric scalar copied
|
||||||
|
from the active runtime map. The method never returns the map's mutable block
|
||||||
|
array and never writes game or save state.
|
||||||
|
|
||||||
|
Failure reasons are stable:
|
||||||
|
|
||||||
|
| Condition | Reason |
|
||||||
|
|---|---|
|
||||||
|
| No active overworld map | `no overworld` |
|
||||||
|
| `mapId` differs from the active map | `map is not active` |
|
||||||
|
| Coordinate has the wrong type, is non-finite, or is fractional | `invalid block coordinates` |
|
||||||
|
| Coordinate is negative or outside the active map | `block coordinates out of bounds` |
|
||||||
|
| Active block data is absent or malformed | `block unavailable` |
|
||||||
|
|
||||||
|
The block slot and the active map accessor must both contain the same valid
|
||||||
|
nonnegative integer. Missing or sparse storage and inconsistent accessor data
|
||||||
|
return `block unavailable`.
|
||||||
|
|
||||||
|
Requiring the expected map ID and rejecting all ambiguous input lets a mod
|
||||||
|
compare every cell in its version-specific signature before it calls an
|
||||||
|
existing mutation API. Red, Blue, and Yellow each use their own loaded map
|
||||||
|
data. Gold does not gain this method in this RFC.
|
||||||
|
|
||||||
|
## Migration and compatibility
|
||||||
|
|
||||||
|
Existing mods change nothing. No hook, event, registry, manifest field, save
|
||||||
|
field, map handler, or WorldAPI method is removed or renamed. With no hook
|
||||||
|
subscriber the departed-dock behavior is unchanged. Existing callers cannot
|
||||||
|
invoke the new WorldAPI method accidentally.
|
||||||
|
|
||||||
|
The occupancy answer is not persisted by the engine. Disabling or uninstalling
|
||||||
|
a mod removes its wrapper through normal owner cleanup, so a later dock entry
|
||||||
|
uses vanilla ejection. The engine writes no new save state and neither API
|
||||||
|
returns or serializes ROM or save data.
|
||||||
|
|
||||||
|
## Verification requirements
|
||||||
|
|
||||||
|
The parity gate must prove:
|
||||||
|
|
||||||
|
- vanilla departed-dock message and warp remain with no subscriber;
|
||||||
|
- one permission wrapper can allow occupancy without removing the ship-erasure
|
||||||
|
work or any map handler;
|
||||||
|
- multiple cooperative wrappers preserve downstream permission;
|
||||||
|
- absent, throwing, nil, false, malformed, and non-forwarding hook answers fail
|
||||||
|
closed according to the normal wrapper-chain rule;
|
||||||
|
- Red, Blue, and Yellow contexts keep their version identity separate;
|
||||||
|
- disabling or removing the owner restores vanilla behavior without a save
|
||||||
|
migration;
|
||||||
|
- `activeBlockAt` accepts only the active map and valid in-range integral
|
||||||
|
coordinates, returns a scalar, and never mutates map or save state; and
|
||||||
|
- the test fixtures and resulting changes contain no ROM or save payload.
|
||||||
|
|
||||||
|
The hook must be driven through a real public `hooks:wrap` chain. The block API
|
||||||
|
must be exercised through a public `WorldAPI` instance. Tests must not replace
|
||||||
|
the production map handler with a test-only implementation.
|
||||||
|
|
||||||
|
## Docs with the change
|
||||||
|
|
||||||
|
`docs/modding.md` documents both contracts, their failure behavior, the
|
||||||
|
cooperative wrapper pattern, and the Gen 1-only block method. No registry or
|
||||||
|
schema changes occur, so generated registry documentation is unchanged.
|
||||||
|
|
||||||
|
## Deprecation etiquette
|
||||||
|
|
||||||
|
Nothing is removed, superseded, or deprecated.
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
# RFC 0014: Mod-driven world actors and adopted link sessions
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Proposed.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
A mod can already spawn a runtime object with `mod.world:spawnNpc` and can
|
||||||
|
already start a link battle. It cannot make either of them behave like
|
||||||
|
something the mod itself owns.
|
||||||
|
|
||||||
|
Three walls, each of which stops a mode rather than inconveniencing it:
|
||||||
|
|
||||||
|
**An actor cannot move on its own schedule.** The only public way to animate a
|
||||||
|
spawned object is `Handle:scriptMove`, which queues onto
|
||||||
|
`OverworldState.scriptMoves`. A non-empty `scriptMoves` is how the overworld
|
||||||
|
knows a cutscene is running -- `handleInput` gates on it -- so anything
|
||||||
|
animated that way freezes the player's controls for as long as it walks. That
|
||||||
|
is correct for Oak marching into his lab and wrong for an ambient walker or a
|
||||||
|
networked player's ghost, which move continuously and must not lock anyone out.
|
||||||
|
|
||||||
|
**A spawned object cannot answer the A press.** Talking is resolved from the
|
||||||
|
map's text tables by `TEXT_*` id. A runtime object has no id, so the vanilla
|
||||||
|
path has nothing to say for it, and the mod that created it has no way to say
|
||||||
|
anything either.
|
||||||
|
|
||||||
|
**A link battle cannot ride a connection the mod already has.** `LinkState`
|
||||||
|
owns pairing, so a mode that already has a socket to its peer must either open
|
||||||
|
a second connection for the battle or reimplement lockstep. And when the
|
||||||
|
battle ends, cable rules leave the real party untouched, so the damage exists
|
||||||
|
only in the battle's own copies -- which are gone by the time the state
|
||||||
|
unwinds. A mode built on link battles cannot learn what the fight cost.
|
||||||
|
|
||||||
|
The immediate consumer is an overworld multiplayer mode, but nothing here is
|
||||||
|
specific to it: the first two are wanted by any mod with an actor that moves
|
||||||
|
itself, and the third by any mode that runs battles over its own transport --
|
||||||
|
a tournament ladder, a draft, a gauntlet.
|
||||||
|
|
||||||
|
## The decision it extends
|
||||||
|
|
||||||
|
This adds nothing to the compatibility surface's shape; it extends the
|
||||||
|
**additive, guarded seam convention** that Route B in `CONTRIBUTING-mods.md`
|
||||||
|
documents, and is gated by the parity guarantee `tests/engine/gate_meta_coverage.lua`
|
||||||
|
enforces ("21-testing-and-ci: a parity gate for every extension point"; M14).
|
||||||
|
|
||||||
|
There is no in-repo D-number registry to amend; the consuming design lives
|
||||||
|
outside this repository, as it did for RFC 0011.
|
||||||
|
|
||||||
|
## Exact API delta
|
||||||
|
|
||||||
|
### New hook: `world.talk`
|
||||||
|
|
||||||
|
```lua
|
||||||
|
mod.hooks:wrap("world.talk", function(next, ow, target)
|
||||||
|
-- ow = the OverworldState raising it
|
||||||
|
-- target = the object on the faced cell
|
||||||
|
if mine(target) then
|
||||||
|
say(target)
|
||||||
|
return -- the mod answered; the text path is skipped
|
||||||
|
end
|
||||||
|
return next(ow, target) -- anything else falls through unchanged
|
||||||
|
end)
|
||||||
|
```
|
||||||
|
|
||||||
|
Call site: `OverworldState:interact`, on the branch that has already resolved
|
||||||
|
an object on the faced cell (including across a counter) and confirmed it is
|
||||||
|
not mid-step and not the Pikachu follower. It runs before the map's text
|
||||||
|
tables are consulted. With no subscriber, `Runtime.call` invokes the vanilla
|
||||||
|
fallthrough, which is a file-local function rather than a per-press closure,
|
||||||
|
so an unhooked A press allocates nothing it did not allocate before.
|
||||||
|
|
||||||
|
### New event: `link.battle_ended`
|
||||||
|
|
||||||
|
```lua
|
||||||
|
mod.events:on("link.battle_ended", function(ev)
|
||||||
|
-- ev = {
|
||||||
|
-- result = "win" | "lose" | "draw" | "ended",
|
||||||
|
-- myParty = lockstep copy of our party,
|
||||||
|
-- theirParty = lockstep copy of theirs,
|
||||||
|
-- peerName = string,
|
||||||
|
-- role = "host" | "guest",
|
||||||
|
-- }
|
||||||
|
end)
|
||||||
|
```
|
||||||
|
|
||||||
|
Call site: `LinkState:update`, in the `battleRunning` stage, once the battle
|
||||||
|
state has been popped and before `exitWith` unwinds the link stack. Guarded by
|
||||||
|
`Runtime.wants("link.battle_ended")`, so with no subscriber no payload table
|
||||||
|
is allocated and the branch runs exactly as before.
|
||||||
|
|
||||||
|
The party copies are the point of the event. They are the lockstep records the
|
||||||
|
battle actually fought with, which is where the damage lives under cable rules.
|
||||||
|
|
||||||
|
### New `WorldAPI` handle methods
|
||||||
|
|
||||||
|
```lua
|
||||||
|
handle:stepNow(dir) --> true when the step started
|
||||||
|
handle:canStep(dir) --> would stepNow land somewhere legal?
|
||||||
|
handle:placeAt(x, y, dir) --> snap, no animation, clears a step in flight
|
||||||
|
handle:isMoving() --> true while a step is animating
|
||||||
|
handle:setPassable(flag) --> may the player walk through this object?
|
||||||
|
```
|
||||||
|
|
||||||
|
`stepNow` sets the same per-tile state `scriptMove` does, without the queue and
|
||||||
|
therefore without the input lockout. It does **not** consult collision: the
|
||||||
|
intended caller is replaying a move already decided elsewhere (validated on a
|
||||||
|
peer, or authored), and re-judging it locally would let two copies of the same
|
||||||
|
actor disagree about where it is. `canStep` is the separate opinion for callers
|
||||||
|
that want it. `setPassable` sets the flag `Collision.occupied` already honours
|
||||||
|
for Yellow's companion Pikachu; a passable object still draws and still talks.
|
||||||
|
|
||||||
|
### Two supporting changes
|
||||||
|
|
||||||
|
`Game:startNewGame(opts)` -- the title screen's NEW GAME closure, made callable,
|
||||||
|
with `opts.intro = false` to land directly in the world. A mode that issues its
|
||||||
|
own starting state has no use for the intro cutscene.
|
||||||
|
|
||||||
|
`CodeEntry.new(shape)` -- accepts an optional `{ length =, charset = }`, so the
|
||||||
|
slot-scrub widget that enters a link code can also carry a room code or a
|
||||||
|
host:port address. Called with no argument it is byte-for-byte the previous
|
||||||
|
widget.
|
||||||
|
|
||||||
|
## Migration and compatibility
|
||||||
|
|
||||||
|
**Existing mods change nothing.** Every item above is a new name or a new
|
||||||
|
optional argument; no existing name, payload, signature, or default changes.
|
||||||
|
|
||||||
|
The v1 surface is unaffected: `content.X:register/override/get`, `events:on`,
|
||||||
|
`hooks:wrap`, `mod.log`, `mod:read`, the manifest v1 fields and
|
||||||
|
`pokemon.before_give` all behave as before. `mods/example_mew_starter` -- api 1,
|
||||||
|
`category = "GAMEPLAY"`, whole-species copy -- loads unchanged, which
|
||||||
|
`tests/run_modkit.lua` proves on every run.
|
||||||
|
|
||||||
|
With no subscriber to either seam, Red, Blue, Yellow, Gold and Silver behave
|
||||||
|
exactly as they did: the A press reaches `talkTo`, the link battle unwinds
|
||||||
|
without building an event payload, and no handle method is reachable unless a
|
||||||
|
mod calls it.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `tests/modkit/cases/world_talk.lua` drives the real `OverworldState:interact`
|
||||||
|
path. It asserts the unhooked build first (the A press reaches `talkTo`),
|
||||||
|
then that a mod owning the object suppresses the text path by not calling
|
||||||
|
`next`, that an object the mod ignores still falls through, and that an
|
||||||
|
object mid-step raises no hook at all.
|
||||||
|
- `tests/modkit/cases/link_battle_ended.lua` drives the real `LinkState:update`
|
||||||
|
path. It asserts that with nothing subscribed the event is not wanted (so no
|
||||||
|
payload is built) and the battle still unwinds, then that a subscriber
|
||||||
|
receives the result, the role, the peer name and both party copies including
|
||||||
|
the damage the real party never took, from both sides of the cable.
|
||||||
|
- `tests/engine/gate_hooks.lua` and `tests/engine/gate_events.lua` walk the live
|
||||||
|
catalog, so both seams are parity-gated structurally; `gate_meta_coverage`
|
||||||
|
passes 208/208 with both names covered and no DEBT entry added.
|
||||||
|
|
||||||
|
## Deprecation etiquette
|
||||||
|
|
||||||
|
Nothing is removed, renamed, superseded or deprecated.
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
# RFC 0016: Engine-owned field residual descriptors
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Proposed.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
Battle-rule mods can keep deterministic data-only state in the public battle
|
||||||
|
field and observe `battle.turn_ended`, but that event fires after the engine's
|
||||||
|
residual and faint pipeline. A listener cannot safely deal end-of-round field
|
||||||
|
damage: directly changing live HP bypasses bar drains, faint messages,
|
||||||
|
experience, replacements, double-faint resolution, and checkpoint continuation.
|
||||||
|
Putting callbacks into `battle.field` is also rejected by the checkpoint
|
||||||
|
serializer, correctly, because executable state is not save-safe.
|
||||||
|
|
||||||
|
## Decision and plan extended
|
||||||
|
|
||||||
|
This implements **D-AT-004: public engine-owned field residual execution**, the
|
||||||
|
consuming decision required by Adaptive Trainers capability `ENGINE-FIELD-
|
||||||
|
RESIDUALS`. The plan 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 8. The engine delta defines only a generic end-of-round extension point;
|
||||||
|
it contains no weather names, immunities, damage formula, trainer identity, or
|
||||||
|
Adaptive Trainers policy.
|
||||||
|
|
||||||
|
## Exact API delta
|
||||||
|
|
||||||
|
Add the guarded Gen 1 hook:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
mod.hooks:wrap("battle.field_residual", function(next, context)
|
||||||
|
local rows = next(context)
|
||||||
|
rows[#rows + 1] = {
|
||||||
|
side = "enemy",
|
||||||
|
amount = 7,
|
||||||
|
message = context.battlers.enemy.name .. " is buffeted!",
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
end)
|
||||||
|
```
|
||||||
|
|
||||||
|
The hook runs once during an undecided battle's end-of-round processing, after
|
||||||
|
vanilla status residuals and before field/side token expiry and
|
||||||
|
`battle.turn_ended`. With no subscriber, the guarded site builds no context and
|
||||||
|
changes nothing. Vanilla contributes an empty list.
|
||||||
|
|
||||||
|
`context` is `{ field, battlers, turn }`. `field` is a strictly data-only view
|
||||||
|
with the same `{ weather, tokens }` shape captured by battle checkpoints. Its
|
||||||
|
recursive projection retains raw tables and finite numbers, strings, and
|
||||||
|
booleans under scalar keys. It strips metatables and omits functions, userdata,
|
||||||
|
threads, unsupported keys, and cyclic edges. Thus it exposes neither
|
||||||
|
`field.sides` nor a graph or executable callback back into live engine state.
|
||||||
|
`battlers.player` and `battlers.enemy` are detached snapshots with `{ side,
|
||||||
|
name, hp, maxHp, types, vanished }`. Changing either detached view cannot
|
||||||
|
change the live battle. `turn` is the current Gen 1 turn counter.
|
||||||
|
|
||||||
|
A result row is `{ side = "player"|"enemy", amount =
|
||||||
|
positive_finite_integer_number, message = optional_string }`. Numeric strings,
|
||||||
|
zero, negatives, fractions, NaN, infinities, malformed sides, and non-string
|
||||||
|
messages fail closed. Damage is clamped to current HP. The engine owns
|
||||||
|
mutation, HP-bar drain rows, and its existing faint pipeline. It applies every
|
||||||
|
accepted row before scheduling newly fainted battlers. If this hook batch
|
||||||
|
terminally faints the player with no healthy reserve, it queues only the player
|
||||||
|
faint authority, so descriptor order cannot race a blackout against an enemy
|
||||||
|
EXP/replacement path. Otherwise it schedules newly fainted battlers in fixed
|
||||||
|
player/enemy order. This precedence is scoped to this hook response; native
|
||||||
|
faint paths, including `enemyMonFainted`, keep their existing no-hook behavior.
|
||||||
|
Wrappers compose by calling `next(context)` and appending their own rows.
|
||||||
|
Callbacks are not part of the descriptor contract and hook functions are never
|
||||||
|
stored in the battle.
|
||||||
|
|
||||||
|
Gold already owns native weather and a generation-specific between-turn order;
|
||||||
|
this first additive call site is Gen 1-only. A future Gold site must keep the
|
||||||
|
same context and descriptor contract and choose its native ordering explicitly.
|
||||||
|
|
||||||
|
## Migration and compatibility
|
||||||
|
|
||||||
|
Existing mods change nothing. No hook name or payload changes. With no wrapper,
|
||||||
|
Gen 1 performs the same residual, token, event, and faint work as before,
|
||||||
|
including native simultaneous-faint resolution, and allocates no context. Gen
|
||||||
|
2 is unchanged. Existing and new checkpoints keep
|
||||||
|
serializing only the data stored in `battle.field`; hook callbacks remain
|
||||||
|
process-local loader state and are never serialized.
|
||||||
|
|
||||||
|
The v1 surface remains unchanged: `content.X:register/override/get`,
|
||||||
|
`events:on`, `hooks:wrap`, `mod.log`, `mod:read`, manifest v1 fields, and
|
||||||
|
`pokemon.before_give` keep their existing behavior.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- The catalog hook parity gate proves null and live-empty buses return the
|
||||||
|
vanilla list unchanged.
|
||||||
|
- A sandboxed fixture mod exercises the seam through `mod.hooks`, verifies the
|
||||||
|
detached checkpoint-shaped context, applies damage, and reaches the engine
|
||||||
|
faint pipeline.
|
||||||
|
- Engine validation tests cover strict number validation (including numeric
|
||||||
|
strings, NaN, infinities, zero, and negatives), nested mutation isolation,
|
||||||
|
omission of functions/userdata/threads/cycles and metatables from the public
|
||||||
|
field projection, optional messages, non-table results, clamping, and
|
||||||
|
settled-battle suppression.
|
||||||
|
- Both descriptor orders are driven through queue completion for a simultaneous
|
||||||
|
terminal residual. Each proves player blackout loss with no EXP event,
|
||||||
|
enemy replacement, or replacement UI.
|
||||||
|
- A disabled-bus sentinel proves the guard performs no `Runtime.call` or field
|
||||||
|
context construction. An ordering probe proves the enabled hook runs after
|
||||||
|
vanilla status residuals and before token expiry and `battle.turn_ended`.
|
||||||
|
- A no-hook native regression proves a simultaneous zero-HP state still enters
|
||||||
|
the pre-existing enemy-faint EXP and win authority outside this hook batch.
|
||||||
|
- Capture/restore/capture evidence proves checkpointed field state round-trips
|
||||||
|
while the enabled process-local hook remains installed and callable.
|
||||||
|
|
||||||
|
## Docs with the change
|
||||||
|
|
||||||
|
`docs/modding.md` documents the Gen 1 timing, detached payload, descriptor
|
||||||
|
validation, simultaneous-terminal result, and checkpoint boundary.
|
||||||
|
`docs/mod-api-gen2-compat.md` records that Gold does not yet expose the hook.
|
||||||
|
No registry or schema changes are involved, so generated registry docs do not
|
||||||
|
change.
|
||||||
|
|
||||||
|
## Deprecation etiquette
|
||||||
|
|
||||||
|
Nothing is deprecated. The hook is additive.
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# RFC 0017: Public mod developer-mode signal
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Proposed.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
The loader already derives a boot-time developer-mode flag for its permission
|
||||||
|
diagnostics and headless test seam. Gen 1's `Game` independently derives a
|
||||||
|
similarly sourced flag for its console and hot reload. A sandboxed mod cannot
|
||||||
|
read either one. `mod.commands` can register a diagnostic command but cannot
|
||||||
|
say whether the current boot is a developer boot. `mod.exports` only publishes
|
||||||
|
values to other mods. `game.ready` fires after entry registration and carries
|
||||||
|
only the game. `mod.options` is player configuration, not engine mode, and
|
||||||
|
`mod.log` logs unconditionally. The pre-sandbox compatibility
|
||||||
|
`os.getenv("POKEPORT_DEV")` deliberately returns `nil`, because the process
|
||||||
|
environment is hidden from mods.
|
||||||
|
|
||||||
|
The concrete consumer is **Adaptive Trainers**. Its approved Chapter 30 and
|
||||||
|
Phase H require trainer, boss, Rival, and League diagnostic views plus
|
||||||
|
seed-label tracing to exist only when `POKEPORT_DEV` is active. Without a
|
||||||
|
public signal, the mod must either ship those registrations in production,
|
||||||
|
misuse a player option, or import loader/Logger internals. All three violate
|
||||||
|
the approved observability boundary or the sandbox/public-API policy.
|
||||||
|
|
||||||
|
## Decision and plan extended
|
||||||
|
|
||||||
|
This implements **D-AT-005: diagnostics and seed tracing are admitted only by
|
||||||
|
the engine's developer-mode decision**. The consuming design is tracked in the
|
||||||
|
Adaptive Trainers implementation plan,
|
||||||
|
[`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 9. The engine delta is generic and contains no trainer, balancing,
|
||||||
|
diagnostic-layout, seed-label, or Adaptive Trainers policy.
|
||||||
|
|
||||||
|
## Exact API delta
|
||||||
|
|
||||||
|
Every sandboxed mod object adds one field:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
mod.developer -- boolean
|
||||||
|
```
|
||||||
|
|
||||||
|
The loader copies its existing `dev` decision into this field before invoking
|
||||||
|
the mod's entry chunk. It is therefore available for load-time registration:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
if mod.developer then
|
||||||
|
mod.commands:register("my_mod:diagnostics", diagnostics_command)
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
The value is a plain boolean snapshot, not a loader reference or environment
|
||||||
|
facade. `false` is the normal player-build answer. `POKEPORT_DEV=1` and the
|
||||||
|
`--developer` command-line path make the loader flag true; on Gen 1 those inputs
|
||||||
|
separately make `Game`'s own developer flag true for its console and hot
|
||||||
|
reload. The loader's existing injected `opts.dev` test seam changes only the
|
||||||
|
loader flag and diagnostics, not `Game` or its hotkeys. It grants no permission
|
||||||
|
and does not expose environment variables. The answer is fixed for the life of
|
||||||
|
that loader; changing a field on a mod's own table cannot change engine mode.
|
||||||
|
|
||||||
|
The field is generation-independent and has identical semantics on Red, Blue,
|
||||||
|
Yellow, Gold, and Silver. Gold and Silver do not gain Gen 1's developer console
|
||||||
|
or hot-reload hotkeys from this field.
|
||||||
|
|
||||||
|
## Migration and compatibility
|
||||||
|
|
||||||
|
Existing mods change nothing. `mod.developer` is additive, requires no
|
||||||
|
permission, and does not bump the integer mod API. Existing API-v1 and API-v2
|
||||||
|
entry chunks receive one extra scalar field and retain all prior fields and
|
||||||
|
methods unchanged. No name is removed or shadowed.
|
||||||
|
|
||||||
|
With no mods installed, `Loader:_api` is never called, so the delta allocates no
|
||||||
|
mod object and changes no data, save, options, event, hook, command, or file.
|
||||||
|
With mods installed in a normal boot, the new field is `false` unless an author
|
||||||
|
explicitly reads it. Existing registration and logging behavior is unchanged.
|
||||||
|
|
||||||
|
An adopting mod should gate developer-only registrations and verbose logging
|
||||||
|
directly on `mod.developer`. Player-facing behavior belongs behind
|
||||||
|
`mod.options`, not this signal.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `tests/engine/mod_developer_mode_test.lua` loads a real sandboxed mod through
|
||||||
|
the public SDK with developer mode both on and off. It proves the boolean is
|
||||||
|
available during entry execution and that the same source registers its
|
||||||
|
diagnostic command only for the developer load. It also covers the
|
||||||
|
command-line global path and a Gen 2 load.
|
||||||
|
- `tests/engine/mod_developer_mode_parity_test.lua` is the separate no-mod
|
||||||
|
parity suite. It proves both developer answers discover no mods, create no
|
||||||
|
files, and leave injected vanilla data unchanged. It also loads an unchanged
|
||||||
|
API-v1 probe and verifies identity, `mod:read`, exports, and options behavior.
|
||||||
|
- The full ROM-free engine and modkit tiers remain the compatibility proof for
|
||||||
|
`content.X:register/override/get`, `events:on`, `hooks:wrap`, `mod.log`,
|
||||||
|
`mod:read`, manifest v1 fields, and `pokemon.before_give`.
|
||||||
|
|
||||||
|
No registry or schema changes are involved, so generated registry documentation
|
||||||
|
is unaffected.
|
||||||
|
|
||||||
|
## Deprecation etiquette
|
||||||
|
|
||||||
|
Nothing is removed, renamed, superseded, or deprecated.
|
||||||
@@ -0,0 +1,673 @@
|
|||||||
|
# ShaderFX: runtime slang shader presets
|
||||||
|
|
||||||
|
ShaderFX plays real libretro `.slangp` shader presets over the finished frame.
|
||||||
|
It replaced `src/render/GBCFX.lua`, a hand-ported fixed four-level effect, with
|
||||||
|
a picker: any preset the player drops in a folder, or any preset pulled from
|
||||||
|
the RetroArch buildbot, can be selected and run. Engine:
|
||||||
|
`src/render/ShaderFX.lua` (discovery, download, translation call, pass-graph
|
||||||
|
runtime, render entry point), `src/render/ShaderFixup.lua` (GLSL rewrites),
|
||||||
|
`src/render/ShaderSourcePatches.lua` (pre-translation source patches),
|
||||||
|
`src/core/Sensors.lua` (accelerometer and gyroscope), `src/ui/ShaderFXScreen.lua`
|
||||||
|
(the picker), `src/ui/ShaderFXParamsScreen.lua` (per-preset parameter editor),
|
||||||
|
`tools/shaderfx-bridge/` (the Rust translator). Call sites:
|
||||||
|
`src/render/Renderer.lua` (Gen 1) and `src/core/Game2.lua` (Gen 2), both at the
|
||||||
|
end of the frame. Drivers: `tests/drivers/gold_shaderfx_zoom_sizing_test.lua`,
|
||||||
|
`tests/drivers/gold_shaderfx_menu_black_crop_test.lua`.
|
||||||
|
|
||||||
|
This is first-party engine code, not a mod. It calls the native translator
|
||||||
|
directly through `ffi.load`, with no `Sandbox.lua`, no `native` permission
|
||||||
|
declaration, and no mod boundary. Mods do not get that, and the distinction is
|
||||||
|
deliberate: the upstream maintainer will not accept `native` in mods.
|
||||||
|
|
||||||
|
## What a player sees
|
||||||
|
|
||||||
|
**OPTIONS** carries two rows, `SHADER FX` and `SHADER FX 2`. Each opens the
|
||||||
|
same pushed list screen (`ShaderFXScreen`) on a different slot. The list is
|
||||||
|
`OFF`, then every `.slangp` found on disk, then a permanent `DOWNLOAD SHADERS`
|
||||||
|
action row at the bottom.
|
||||||
|
|
||||||
|
A preset that has never been translated draws muted with a `CONVERT` hint on
|
||||||
|
the right. `A` on that row translates it in place and stays open: converting is
|
||||||
|
a preparation step, not a selection. `A` on a converted row activates it,
|
||||||
|
persists the choice, and closes. `SELECT` on a converted row opens
|
||||||
|
`ShaderFXParamsScreen`, which lists every `#pragma parameter` the preset
|
||||||
|
declares and lets the player step each one (`A` wraps, Left/Right clamp,
|
||||||
|
`SELECT` resets one row, `START` resets all behind a confirm).
|
||||||
|
|
||||||
|
Presets live in a plain OS folder, `shaders/` under the portable base directory
|
||||||
|
when running portable (`SaveData.portableBaseDir()`, the SD-card convention the
|
||||||
|
Anbernic pack uses, see `docs/anbernic-rg34xxsp.md`) and otherwise under LOVE's
|
||||||
|
save directory. `ShaderFX.list()` scans it recursively, so a shader pack keeps
|
||||||
|
whatever nested layout it shipped with. These are real filesystem paths rather
|
||||||
|
than `love.filesystem` virtual paths on purpose: the native translator does
|
||||||
|
plain `std::fs` reads and knows nothing about LOVE's mounts, and neither do the
|
||||||
|
LUT loads.
|
||||||
|
|
||||||
|
Persisted state, all in `save.options`:
|
||||||
|
|
||||||
|
| Key | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| `shaderfx` | main slot's preset name, or absent for OFF |
|
||||||
|
| `shaderfxSecondary` | secondary slot's preset name |
|
||||||
|
| `shaderfxParams[name][paramId]` | one preset's edited pragma values |
|
||||||
|
|
||||||
|
`POKEPORT_SHADERFX=<name>` activates a preset in the main slot for scratch
|
||||||
|
harnesses that never call `ShaderFX.applyOptions`. It is a stand-in that
|
||||||
|
predates the real OPTIONS row; `applyOptions` marks itself as having run so the
|
||||||
|
env var can never later override a real player choice, including a real choice
|
||||||
|
of OFF.
|
||||||
|
|
||||||
|
One quiet behavior worth knowing about: if a slot wants a real preset while
|
||||||
|
PERFORMANCE is still on AUTO, and AUTO would resolve to a tier that caps
|
||||||
|
ShaderFX off, `ShaderFX.applyOptions` pins PERFORMANCE to `HIGH`. Without it
|
||||||
|
the saved choice was force-deactivated a few lines later on every boot, which
|
||||||
|
looks identical to "the setting does not save" from the player's side. On
|
||||||
|
Android and iOS that is the common case, because AUTO always resolves to
|
||||||
|
`balanced` there. It never touches an already-explicit performance choice and
|
||||||
|
never un-escalates.
|
||||||
|
|
||||||
|
## The five stages
|
||||||
|
|
||||||
|
| Stage | Owner |
|
||||||
|
| --- | --- |
|
||||||
|
| Fetch | `ShaderFX.list`, `ShaderFX.downloadPresets`, `ShaderFX.installDownloaded` |
|
||||||
|
| Translate | `tools/shaderfx-bridge/` via `ShaderFX.translate` |
|
||||||
|
| Fixup | `src/render/ShaderFixup.lua` |
|
||||||
|
| Cache | `ShaderFX.convert` writes, `ShaderFX.load` reads |
|
||||||
|
| Run | `ShaderFX.runChain` / `runPass` / `ShaderFX.render` |
|
||||||
|
|
||||||
|
### Fetch
|
||||||
|
|
||||||
|
Two acquisition paths, and they land in the same place. A player can copy a
|
||||||
|
shader pack into `shaders/` by hand, or press `DOWNLOAD SHADERS`, which fetches
|
||||||
|
`https://buildbot.libretro.com/assets/frontend/shaders_slang.zip` (the same
|
||||||
|
~54 MB archive RetroArch's own "Update Shaders" entry pulls) through
|
||||||
|
`src/net/Fetch.lua`, the curl-on-a-`love.thread` transport the self-updater and
|
||||||
|
mod index already use.
|
||||||
|
|
||||||
|
Downloaded presets are *not* pre-converted. The buildbot is RetroArch's asset
|
||||||
|
mirror and has no notion of this project's cache format, so a downloaded preset
|
||||||
|
goes through the same `CONVERT` row a hand-copied one does. Every platform
|
||||||
|
ships both convert and use; there is no asymmetry to work around.
|
||||||
|
|
||||||
|
Repeat downloads are conditional. The zip itself is deleted right after
|
||||||
|
extraction, so what is cached instead is the buildbot's own ETag
|
||||||
|
(`shaderfx_buildbot.etag`), replayed as `If-None-Match`. The server answers a
|
||||||
|
match with 304 and no body, and curl writes no file at all in that case, so
|
||||||
|
`installDownloaded(notModified)` short-circuits before touching the filesystem
|
||||||
|
and reports "already up to date" rather than "FAILED".
|
||||||
|
|
||||||
|
The interesting part is what gets extracted. `handheld/` is not self-contained:
|
||||||
|
its 78 presets carry 101 references that escape the folder (color-mod LUTs
|
||||||
|
into `../shaders/color/`, console-border helpers into `../../reshade/`, shared
|
||||||
|
motion-blur and misc helpers, a shared `stock.slang`) across roughly 40 of
|
||||||
|
them. Extracting `handheld/` alone silently breaks about a third of its own
|
||||||
|
list; extracting the whole zip drags in ~5600 files of CRT, arcade and console
|
||||||
|
content nobody asked for. So `extractClosure` walks the real file-level
|
||||||
|
dependency closure in Lua before a single file is copied, the same
|
||||||
|
`#reference`-closure idea librashader applies internally, done up front because
|
||||||
|
deciding what to copy has to happen before the translator ever sees these
|
||||||
|
files. Against this zip that is 207 files and about 9.5 MB with zero broken
|
||||||
|
references. The closure seeds from `KEPT_PRESETS`, a curated shortlist rather
|
||||||
|
than all 78, after most of `color-mod/` and `console-border/` turned out either
|
||||||
|
irrelevant (color-only, no LCD effect) or broken for this project. That list
|
||||||
|
is a temporary trim pending wider testing and is expected to change.
|
||||||
|
|
||||||
|
Two mechanical details in that walk that are easy to get wrong a second time.
|
||||||
|
`extractRefs` tries a quoted `key = "path"` match per line first, with an
|
||||||
|
unrestricted `[^"]+` capture, because the closing quote is an unambiguous
|
||||||
|
delimiter and real packs ship paths with spaces and parentheses in them
|
||||||
|
(`"shaders/handheld/color-mod/Game Boy (Color).slang"`); only a line with no
|
||||||
|
quoted match falls back to a conservative character class, since an unquoted
|
||||||
|
path has no delimiter to trust past. And `love.filesystem.write` does not
|
||||||
|
create intermediate directories, so each destination directory is created once
|
||||||
|
before anything is written into it; without that, every file under a subfolder
|
||||||
|
`handheld/` never had before was silently dropped while flat writes succeeded.
|
||||||
|
Cleanup is equally literal: `love.filesystem.unmount()` takes the archive path
|
||||||
|
originally passed to `mount()`, not the mountpoint. Called with the mountpoint
|
||||||
|
it returns false, leaves the zip's handle open, and the following `remove()`
|
||||||
|
silently fails too, so every download used to leave 54 MB on disk forever.
|
||||||
|
|
||||||
|
### Translate: the native bridge
|
||||||
|
|
||||||
|
`tools/shaderfx-bridge/` is a small Rust crate (`spike`) that builds a cdylib
|
||||||
|
named `librashader_bridge`. It wraps `librashader-presets`,
|
||||||
|
`librashader-preprocess` and `librashader-reflect` behind a two-function C ABI:
|
||||||
|
|
||||||
|
```
|
||||||
|
char* librashader_translate_preset(const char* preset_path, int es);
|
||||||
|
void librashader_free_string(char* s);
|
||||||
|
```
|
||||||
|
|
||||||
|
It returns a JSON `TranslateResult`: `pass_count`, a `passes` array (each with
|
||||||
|
its emitted `vertex`/`fragment` GLSL, `filter`, `wrap_mode`, `scale_x`/
|
||||||
|
`scale_y`, its own `#pragma parameter` declarations, a classified `samplers`
|
||||||
|
list and a classified `size_uniforms` list), the preset's `textures` (LUTs,
|
||||||
|
with resolved absolute paths and filter/wrap settings), its
|
||||||
|
`parameter_overrides`, and an `error` field.
|
||||||
|
|
||||||
|
**What the bridge is not.** No librashader runtime backend is linked in, for
|
||||||
|
any API: no GL, Vulkan, D3D or Metal crate is a dependency, and no live
|
||||||
|
graphics context is ever touched. It is the translation step only. LOVE still
|
||||||
|
owns every draw call, every canvas and every shader object. The bridge hands
|
||||||
|
back text and metadata and nothing else.
|
||||||
|
|
||||||
|
**When it is called.** Only from `ShaderFX.convert()`. Translation is ahead of
|
||||||
|
time, not just in time. `ShaderFX.load()`, `ShaderFX.activate()`, boot-time
|
||||||
|
reactivation of a saved choice and every frame of `ShaderFX.render()` read the
|
||||||
|
cached artifact and never call the library. An entry with no cached artifact
|
||||||
|
fails `activate()` loudly instead of silently live-translating.
|
||||||
|
|
||||||
|
The classification is done with librashader's real semantics resolution rather
|
||||||
|
than name matching on the Lua side, and that matters for correctness, not just
|
||||||
|
tidiness. Sampler classification uses `ShaderSemantics::create_pass_semantics`
|
||||||
|
plus the `TextureSemanticMap` lookup (explicit alias and LUT-name entries
|
||||||
|
first, then the built-in `Source`/`Original`/`OriginalHistoryN`/`PassOutputN`/
|
||||||
|
`PassFeedbackN` conventions), so a pass reachable only by its real `.slangp`
|
||||||
|
alias resolves. The per-pass convenience API only registers the alias of the
|
||||||
|
pass being compiled, so the bridge mirrors upstream's `insert_pass_semantics`
|
||||||
|
loop and builds a preset-wide alias map first; without that,
|
||||||
|
`ds-hybrid-scalefx.slangp`'s pass 2 sampling `scalefx_pass0` by alias, with no
|
||||||
|
`PassOutput1`-shaped name anywhere, can never resolve. Size-uniform
|
||||||
|
classification runs the same resolution over the pass's `uniform_semantics`
|
||||||
|
map, which also covers shapes (`PassFeedbackSizeN`, `UserSizeN`) that no
|
||||||
|
present preset uses but that the convention allows.
|
||||||
|
|
||||||
|
The `es` flag picks the emitted dialect: 1 for GLSL ES 1.00 (mobile, LOVE's ES
|
||||||
|
dialect), 0 for GLSL 1.20 (LOVE's desktop dialect). `ShaderFX` picks it from
|
||||||
|
`love.system.getOS()`, and the same function decides which dialect
|
||||||
|
`validateShader` is asked about at run time, so the two always agree. Convert
|
||||||
|
and render always happen on the same device; **artifacts are not portable
|
||||||
|
across platforms**.
|
||||||
|
|
||||||
|
One class of fixup has to happen in the bridge rather than in `ShaderFixup.lua`,
|
||||||
|
on the raw `.slang` text before SPIR-V compilation: `textureSize`,
|
||||||
|
`texelFetchOffset` and `textureOffset` are all ES 3.00+ only, and spirv-cross
|
||||||
|
refuses to *emit* them for an ES 1.00 target, failing the whole pass with
|
||||||
|
`UnsupportedSpirv("textureSize is not supported in ESSL 100.")`. No GLSL text
|
||||||
|
is ever produced for a later pass to patch, so `rewrite_essl100_gaps` rewrites
|
||||||
|
the source first, and only for the ES target:
|
||||||
|
|
||||||
|
- `textureSize(Tex, lod)` becomes a literal `ivec2(w, h)` when `Tex` is one of
|
||||||
|
the preset's declared static textures, with dimensions read straight out of
|
||||||
|
each PNG's IHDR chunk. A texture that is not one of those, or a non-literal
|
||||||
|
`lod`, is left alone so it fails as loudly as before instead of guessing.
|
||||||
|
- `texelFetchOffset` and `textureOffset` become ordinary `texture()` calls at
|
||||||
|
the equivalent texel-centre UV, using the texture's own `<Tex>Size.zw`
|
||||||
|
reciprocal-size uniform. The containing block instance (`params`, `global`,
|
||||||
|
whatever) is discovered by scanning the source's own uniform block bodies,
|
||||||
|
never assumed. When a pass samples another pass purely through these calls it
|
||||||
|
may never declare that `<Tex>Size` uniform at all, so one is injected first,
|
||||||
|
before any byte offsets are computed.
|
||||||
|
|
||||||
|
That last rewrite has an honest limit: `texture()` honours the sampler's wrap
|
||||||
|
mode at out-of-range coordinates, which is not necessarily identical to
|
||||||
|
`texelFetch`'s implementation-defined out-of-bounds behavior. Any edge-of-image
|
||||||
|
discrepancy for passes whose offsets can leave the image is unmeasured.
|
||||||
|
|
||||||
|
**Building it.** `cargo build --release` inside `tools/shaderfx-bridge/`.
|
||||||
|
`ShaderFX` looks for the library, most specific first: the
|
||||||
|
`LIBRASHADER_BRIDGE_DLL` environment variable, the source directory,
|
||||||
|
`<source>/tools/shaderfx-bridge/target/release/`, the save directory, and
|
||||||
|
finally the bare name handed to the system loader. Per-OS names are
|
||||||
|
`librashader_bridge.dll` (Windows), `liblibrashader_bridge.dylib` or
|
||||||
|
`librashader_bridge.dylib` (macOS), and `liblibrashader_bridge.so` or
|
||||||
|
`librashader_bridge.so` (Linux and Android). Android resolves the bare name
|
||||||
|
because the `.so` ships as an ordinary `jniLibs` entry, so `dlopen` finds it
|
||||||
|
without a path. The desktop path is still a developer build sitting in cargo's
|
||||||
|
output directory; nothing packages it next to a shipped game yet.
|
||||||
|
`ShaderFX.canConvert()` reports whether the library resolved on this machine,
|
||||||
|
and `ShaderFX.bridgeError()` says why not. Activating an already-converted
|
||||||
|
preset never needs any of this.
|
||||||
|
|
||||||
|
### Fixup
|
||||||
|
|
||||||
|
`ShaderFixup.lua` mechanically rewrites the emitted GLSL into something LOVE
|
||||||
|
will accept. librashader emits a standalone `void main()` /`gl_FragData[0]` /
|
||||||
|
`gl_Position` shape (translated Vulkan GLSL); LOVE requires the `effect()` and
|
||||||
|
`position()` convention and refuses a raw `main()`-shaped source outright. This
|
||||||
|
is a targeted rewriter, not a GLSL parser, and every rule below exists because a
|
||||||
|
real preset in the corpus failed without it. This list is the least guessable
|
||||||
|
part of the whole feature.
|
||||||
|
|
||||||
|
**`#version` line.** Stripped; LOVE prepends its own.
|
||||||
|
|
||||||
|
**Array constructors.** SPIR-V Cross emits ES 3.0 array-constructor syntax
|
||||||
|
(`const float _17[5] = float[](0.0, 1.0, ...)`) for compile-time array
|
||||||
|
literals, which validation rejects with "arrayed constructor: not supported for
|
||||||
|
this version". GLSL ES 1.00 has no array-constructor syntax at all. There are
|
||||||
|
three real shapes in the corpus and each is handled: a `const` global (declared
|
||||||
|
without an initializer at global scope, with per-element assignments relocated
|
||||||
|
to the top of `main()`), a non-const local declaration with initializer
|
||||||
|
(rewritten in place, since a function body can hold assignment statements where
|
||||||
|
the literal was), and a bare reassignment of an array declared elsewhere (also
|
||||||
|
in place). Splitting the element list needs `splitTopLevelCommas`, because a
|
||||||
|
naive comma split breaks on any element containing its own parentheses
|
||||||
|
(`vec2(-1.0, 0.0)`), and the outer capture needs `%b()` rather than a
|
||||||
|
`[^%)]-` class for the same reason: the class stops at the first inner `)`, the
|
||||||
|
whole match fails, and the literal passes through completely untouched.
|
||||||
|
|
||||||
|
**Whole-array copies.** `float param_1[7] = coeffs;` is how SPIR-V Cross clones
|
||||||
|
a function-parameter array before passing it on, since GLSL array arguments are
|
||||||
|
by value. ES 1.00 has no whole-array assignment either, so it becomes a bare
|
||||||
|
declaration plus an element-by-element copy. The size is known from the
|
||||||
|
declaration, so no comma splitting is involved. This one only became reachable
|
||||||
|
once the other array shapes stopped masking it in the same file.
|
||||||
|
|
||||||
|
**Integer modulo.** ES 1.00 has no `%` operator, and SPIR-V Cross emits it
|
||||||
|
anyway for an upstream integer-modulo op. `%` in GLSL is only defined for
|
||||||
|
integer operands, so routing through float `mod()` and back is exact for the
|
||||||
|
non-negative operands this shader family uses (rotation and orientation enum
|
||||||
|
indices). Four patterns are tried in order (paren/paren, paren/bare,
|
||||||
|
bare/paren, bare/bare) because SPIR-V Cross fully parenthesizes a compound
|
||||||
|
operand and leaves a simple one bare, and the balanced form must be tried
|
||||||
|
before a plain identifier can partially match. Seen live on
|
||||||
|
`authentic_gbc`'s subpixel-rotation math.
|
||||||
|
|
||||||
|
**Precision.** SPIR-V Cross hardcodes an unguarded `precision highp float;` /
|
||||||
|
`precision highp int;` pair with no toggle. That is removed and replaced with
|
||||||
|
the same guard the old hand-written `DotMatrix` port used: claim `highp` only
|
||||||
|
where `GL_FRAGMENT_PRECISION_HIGH` says the driver actually offers fragment
|
||||||
|
highp, and fall through to the stage default otherwise.
|
||||||
|
|
||||||
|
**Struct flattening.** This is the big one. LOVE's `Shader:send` cannot address
|
||||||
|
a member of a custom struct-typed uniform: neither an `INSTANCE.member` dot path
|
||||||
|
nor sending the whole struct as a table works, both raise "Shader uniform '...'
|
||||||
|
does not exist." librashader emits every pass's `#pragma parameter`s and size
|
||||||
|
uniforms as exactly that kind of struct, so as shipped the output is unusable
|
||||||
|
from LOVE, not merely inefficient. `flattenStruct` deletes the struct and its
|
||||||
|
instance uniform and re-declares the members at top level. Scalar members are
|
||||||
|
*packed* four at a time into synthetic `uniform vec4 LIBRA_PACKED_N;` slots,
|
||||||
|
because GLSL ES 1.00 guarantees only 16 fragment uniform *vectors* and every
|
||||||
|
scalar costs a whole one; `gb-pass4`'s pass 0 alone has 14 scalars, already over
|
||||||
|
budget unpacked. Non-scalar members keep their own uniform, since packing an
|
||||||
|
existing `vec4` saves nothing. Every `instance.member` reference is rewritten to
|
||||||
|
`LIBRA_PACKED_N.x` (or `.y`/`.z`/`.w`), and a member whose original declared
|
||||||
|
type was `int` or `bool` gets an explicit cast back on every read, since a
|
||||||
|
packed slot only stores floats. Vertex and fragment declare identically ordered
|
||||||
|
structs for the same parameters, so packing both with the same prefix assigns
|
||||||
|
the same slot and component to the same parameter in both stages, and one
|
||||||
|
`shader:send` reaches whichever stage uses it.
|
||||||
|
|
||||||
|
Two ordering constraints inside that function are load bearing and look
|
||||||
|
arbitrary from the outside. Packing must walk the members in the struct's own
|
||||||
|
declaration order, because that order is what keeps the scalars in one
|
||||||
|
contiguous run; an earlier version sorted them longest-name-first before
|
||||||
|
packing and produced six packed vec4s instead of four on `gb-pass4`, blowing the
|
||||||
|
budget. Substitution, separately, must go longest-name-first, so a replacement
|
||||||
|
can never land as a substring inside a still-pending member name that shares a
|
||||||
|
prefix. The two orders are separate copies of the list for exactly that reason.
|
||||||
|
|
||||||
|
`Fixup.packValues` turns a flat `{name = value}` table back into the
|
||||||
|
`{uniform = value_or_vec4}` shape the packed shader expects, per the manifest
|
||||||
|
`flattenStruct` returned. `Fixup.countUniformSlots` counts declared uniform
|
||||||
|
slots against that same 16-vector budget, samplers excluded. Its pattern uses
|
||||||
|
`[%w_]+` rather than `%w+` because Lua's `%w` does not include underscore
|
||||||
|
unlike regex `\w`, and every generated name here is full of underscores.
|
||||||
|
|
||||||
|
**UBO blocks.** `LIBRA_UBO_FRAGMENT` and `LIBRA_UBO_VERTEX` have the identical
|
||||||
|
problem and are flattened the same way, under a distinct `LIBRA_UBO_PACKED_`
|
||||||
|
prefix so their groups cannot collide with the push block's numbering. `MVP` is
|
||||||
|
the one special member: it is substituted directly to `transform_projection`
|
||||||
|
instead of becoming a uniform, because "multiply the incoming vertex by it" is
|
||||||
|
exactly what LOVE's `transform_projection` already is on a full-screen draw, and
|
||||||
|
nothing would ever supply a value for it. An earlier version assumed the UBO
|
||||||
|
block only ever carried `MVP` and deleted the whole declaration after
|
||||||
|
substituting it. That is true only for presets that declare their parameters in
|
||||||
|
a push-constant block; presets that use a UBO instead (many real handheld and
|
||||||
|
console-border presets do) had every other member reference left dangling, and
|
||||||
|
the driver then read `INSTANCE.PAR` as a swizzle, which is where the "undeclared
|
||||||
|
identifier" and "unknown swizzle selection" errors on real Android hardware came
|
||||||
|
from.
|
||||||
|
|
||||||
|
**Fragment entry point.** `void main()` becomes LOVE's `effect()` signature.
|
||||||
|
The parameter list is qualified by an `EFFECT_PREC` define rather than a literal
|
||||||
|
precision, because LOVE forward-declares `effect()`'s prototype under its own
|
||||||
|
header's precision default before this source runs, which can mismatch whatever
|
||||||
|
the precision guard above raises the default to. `Fixup.PREC_HEADS` holds the
|
||||||
|
two variants (`mediump`, then unqualified) and the caller tries them in order
|
||||||
|
against `validateShader`, taking the first that passes.
|
||||||
|
|
||||||
|
**Fragment output.** `gl_FragData` does not exist in LOVE's `effect()`
|
||||||
|
convention, so every `gl_FragData[0]` occurrence is rewritten to a local
|
||||||
|
`gbFragColor`, declared at the top of the function, with a single `return`
|
||||||
|
appended before the closing brace. Rewriting *every* occurrence regardless of
|
||||||
|
the operator that follows is necessary, not just symmetric with the vertex side:
|
||||||
|
an earlier assign-then-return pair assumed one write at the very end of
|
||||||
|
`main()`, which holds for most presets but not for ones like `ds-hybrid-sabr`
|
||||||
|
that write once with `=` and later accumulate with `+=`. The `+=` statement
|
||||||
|
passed through unconverted and collided with LOVE's own `gl_FragColor` write
|
||||||
|
("Cannot use both gl_FragColor and gl_FragData"). A bare early `return;` is
|
||||||
|
rewritten to `return gbFragColor;`, which holds the value assigned just before
|
||||||
|
it on every real shape seen.
|
||||||
|
|
||||||
|
**Vertex entry point.** `void main()` becomes `position(mat4
|
||||||
|
transform_projection, vec4 vertex_position)`, the source's own `attribute`
|
||||||
|
redeclarations of `Position`/`TexCoord` are dropped since LOVE supplies them,
|
||||||
|
`gl_Position = X;` becomes `gbClipPos = X;` (named to share no substring with
|
||||||
|
`Position`, or the next step would mangle it), and a `return gbClipPos;` is
|
||||||
|
appended. The `Position` and `TexCoord` substitutions are frontier-matched
|
||||||
|
whole identifiers (`%f[%w]...%f[%W]`), not blind substring replacements: real
|
||||||
|
presets declare their own unrelated locals such as `vec2 vTexCoord;`, and a
|
||||||
|
blind `gsub` turned that declaration into the invalid `vec2 vVertexTexCoord.xy;`
|
||||||
|
(`dot.slangp` pass 0, a real driver "unexpected DOT" error).
|
||||||
|
|
||||||
|
### Cache
|
||||||
|
|
||||||
|
`ShaderFX.convert(entry)` is the only path that calls the bridge. It runs
|
||||||
|
`ShaderSourcePatches.apply` first, translates, then serializes the decoded
|
||||||
|
result to `ShaderFX.artifactPath(entry)`: the source `.slangp`'s own absolute
|
||||||
|
path with the extension swapped to `.lua`, so the artifact sits next to the
|
||||||
|
preset it came from. The file is a plain `return { ... }` chunk written by
|
||||||
|
`serializeLua`, which handles the string/number/boolean/nested-table shape
|
||||||
|
`Json.decode` produces. Array detection walks every key rather than trusting
|
||||||
|
`#t`, since `#t` counts a trailing nil as absent and a sparse table can pass a
|
||||||
|
naive length check by accident.
|
||||||
|
|
||||||
|
`ShaderFX.load(entry)` `loadfile`s that chunk and builds a chain state. On
|
||||||
|
failure it says "convert this preset first" rather than falling back to a live
|
||||||
|
translation.
|
||||||
|
|
||||||
|
**AOT rather than JIT** is the whole point of this stage. Translation is a rare,
|
||||||
|
explicit, user-initiated action whose result is stable for a given preset and
|
||||||
|
dialect, so paying for it once and writing the answer to disk keeps `ffi.load`
|
||||||
|
and the native call off every activation, every boot and every frame. The cost
|
||||||
|
is a staleness gap: `ShaderFX.isConverted()` is a plain "does the artifact file
|
||||||
|
exist" check with no version or content stamp, and there is no explicit
|
||||||
|
"reconvert" action in the UI. A preset converted by an older build never picks
|
||||||
|
up a later translator fix on its own. This was seen on a real device, where
|
||||||
|
`sunlight_shimmer.slangp`'s `Accelerometer` uniform never reached the shader
|
||||||
|
because that device's cache predated the fix while `pixel_transparency`'s
|
||||||
|
happened to be fresher. Two places compensate by reconverting unconditionally:
|
||||||
|
`ShaderFXScreen`'s explicit selection of an already-converted row, and
|
||||||
|
`ShaderFX.applyOptions` on every boot and options save. Both are human-paced,
|
||||||
|
CPU-only work with no GPU compile, and neither is on the per-frame path.
|
||||||
|
|
||||||
|
`ShaderSourcePatches.lua` sits just before translation and patches the raw
|
||||||
|
`.slang`/`.inc` files *on disk*, because only librashader's own preset parser,
|
||||||
|
reading the real files, discovers `#pragma parameter` lines and struct members;
|
||||||
|
nothing downstream can add one. Patches are small, explicit, per-preset literal
|
||||||
|
find/replace pairs (plain `find`, not `gsub`, since GLSL source is full of Lua
|
||||||
|
pattern magic), re-applied idempotently on every convert so a buildbot
|
||||||
|
re-download that replaces the upstream file wholesale does not quietly undo
|
||||||
|
them. **The patch table ships empty on purpose and nothing registers one.** Its
|
||||||
|
original use case, wiring gyroscope yaw into `sunlight_shimmer.slangp` as new
|
||||||
|
`PT_YAW_*` pragma parameters, was reverted precisely because of the staleness
|
||||||
|
gap above: a new pragma can only reach an artifact that gets reconverted, and at
|
||||||
|
the time nothing forced one. The mechanism is kept for a future preset that
|
||||||
|
genuinely needs a new declaration, but an already-wired engine-side channel is
|
||||||
|
preferred whenever one exists.
|
||||||
|
|
||||||
|
### Run: the pass graph
|
||||||
|
|
||||||
|
`ShaderFX.activate(slot, entry, paramOverrides)` loads the artifact, layers the
|
||||||
|
player's edited parameters over the artifact's own defaults, loads the preset's
|
||||||
|
LUTs once, and snapshots the accelerometer rest pose. `ShaderFX.render` then
|
||||||
|
runs the chain each frame.
|
||||||
|
|
||||||
|
`newChainState` builds one instance of chain-local state per loaded preset,
|
||||||
|
never module-global, so switching presets cannot leak a previous preset's
|
||||||
|
canvases or dimensions. `ALL_DEFAULTS` is built in layers: each pass's declared
|
||||||
|
`initial`, then the preset's own `parameter_overrides`, then (in `activate`) the
|
||||||
|
player's `shaderfxParams` edits.
|
||||||
|
|
||||||
|
Sizes resolve through `resolveScale`, which handles all four slang scale types
|
||||||
|
(`absolute`, `viewport`, `source`, `original`) against the viewport, the pass's
|
||||||
|
input dimensions and the original frame. Size uniforms are packed as
|
||||||
|
`{w, h, 1/w, 1/h}`, the slang convention.
|
||||||
|
|
||||||
|
`runPass` caches two things per `(state, pass index)`. The **shader** is
|
||||||
|
compiled once for the state's lifetime, along with the fragment manifest it was
|
||||||
|
compiled against, since a pass's GLSL depends only on the preset and never on
|
||||||
|
per-frame input. The **canvas** is reallocated only when the pass's resolved
|
||||||
|
size actually changes, a window resize or a different preset. Every harness this
|
||||||
|
runtime was ported from ran the chain once and quit, so allocating a fresh
|
||||||
|
canvas and compiling a fresh shader on every call was invisible there. On a real
|
||||||
|
per-frame render path it is one GPU allocation per pass per frame, and a shader
|
||||||
|
recompile on top. The same discipline applies to the crop canvas in
|
||||||
|
`cropToGbSource`, which is called once per frame and whose size grows with the
|
||||||
|
world canvas as the player zooms out; leaving it uncached was a real cost that
|
||||||
|
scaled with zoom level even with a single preset active.
|
||||||
|
|
||||||
|
Sampler binding is by semantic, from the bridge's classification, never by a
|
||||||
|
hardcoded per-preset name check: `Source` is the previous pass's output (or the
|
||||||
|
input frame for pass 0), `Original` and `OriginalHistory` are the input frame,
|
||||||
|
`PassOutput` indexes an earlier pass's canvas, and `User` resolves a LUT by the
|
||||||
|
real name the translation reported. A sampler that resolves to nothing asserts
|
||||||
|
rather than drawing garbage.
|
||||||
|
|
||||||
|
**LUTs** are loaded once per `activate`. `ShaderFX.loadImageFromPath` reads the
|
||||||
|
bytes with plain `io.open` and goes through `love.data.newByteData` and
|
||||||
|
`love.image.newImageData`, because a preset's texture paths are arbitrary
|
||||||
|
absolute OS paths outside any LOVE mount and `love.graphics.newImage` refuses
|
||||||
|
those outright ("Could not open file ... Does not exist") even when the file is
|
||||||
|
real. Wrap modes are mapped from librashader's names to LOVE's
|
||||||
|
(`clamp_to_border` to `clampzero`, `clamp_to_edge` to `clamp`, `repeat`,
|
||||||
|
`mirrored_repeat` to `mirroredrepeat`). A LUT that fails to load is logged and
|
||||||
|
left nil; the fail-loud point is the sampler assertion in `runPass` that
|
||||||
|
actually needed it, not the loader.
|
||||||
|
|
||||||
|
**History ring.** `OriginalHistoryN` currently resolves to a steady state: every
|
||||||
|
history slot reads the current frame, both for the sampler binding and for the
|
||||||
|
size uniform. Real per-frame history rotation has been proven out in a desktop
|
||||||
|
harness but is not wired into this path.
|
||||||
|
|
||||||
|
**Feedback.** `PassFeedback` is not implemented. A `PassFeedback` or `User` size
|
||||||
|
uniform raises an explicit "not yet supported" error, and a `PassFeedback`
|
||||||
|
sampler resolves to nothing and trips the binding assertion. No preset in the
|
||||||
|
shipped shortlist uses it.
|
||||||
|
|
||||||
|
**Blending.** Every pass draws with `replace`, and the chain's final pass uses
|
||||||
|
`replace, premultiplied`. Intermediate canvases are `nearest` filtered.
|
||||||
|
|
||||||
|
`ShaderFX.render(canvas, rect, source, dpiX, dpiY)` is the entry point
|
||||||
|
`Renderer:endFrame` and `Game2` call. `canvas` is the finished window-sized
|
||||||
|
composite (world, UI, and any post-process pipeline that already ran); `rect` is
|
||||||
|
this frame's real playfield rectangle in physical framebuffer pixels and
|
||||||
|
`source` is the real pixel size of the content it frames. The sequence is: crop
|
||||||
|
`rect` out of the composite, run whichever slots are active over that crop, draw
|
||||||
|
the untouched composite, then stretch the chain output back over `rect`. UI and
|
||||||
|
letterbox bars outside the playfield pass through untouched. If the chain throws,
|
||||||
|
the frame still shows the unprocessed composite; a broken preset degrades to
|
||||||
|
"shader off", never to a crash or a blank frame.
|
||||||
|
|
||||||
|
Three details in that path are non-obvious:
|
||||||
|
|
||||||
|
- **DPI.** `love.graphics.newCanvas` and `draw` work in LOVE's DPI-aware
|
||||||
|
logical units, not raw pixels, so the viewport handed to the pass graph and
|
||||||
|
the final draw-back position are both converted from `rect`'s physical pixels
|
||||||
|
first. On a `dpiscale = 1` desktop the two are numerically identical and the
|
||||||
|
bug is invisible; at dpiscale 3 on real Android hardware the chain output
|
||||||
|
rendered about three times too large and at a pixel-valued offset in unit
|
||||||
|
space.
|
||||||
|
- **Draw color.** `cropToGbSource` sets `setColor(1, 1, 1, 1)` explicitly. The
|
||||||
|
caller can leave the draw color dirty (a menu's black text leaves it at
|
||||||
|
`(0,0,0,x)`), the crop draw multiplies the canvas texels by the active color,
|
||||||
|
and `push("all")` saves state for `pop()` without resetting it. That was the
|
||||||
|
root cause of the Gen 2 blank-menu bug, confirmed on a desktop repro where
|
||||||
|
`getColor()` read `0,0,0,1` here exactly when a menu was on the stack.
|
||||||
|
`flushBatch()` on the line above is cheap insurance against a read-after-write
|
||||||
|
ordering hazard between this draw and whatever last rendered into the canvas;
|
||||||
|
it was never confirmed to fix anything on its own.
|
||||||
|
- **The final blit stretches.** A slang chain's last pass is not required to
|
||||||
|
land on the viewport size, and most presets (21 of the 78 in the corpus)
|
||||||
|
declare their last pass `scale_type = "source"` and stay at native Game Boy
|
||||||
|
resolution, relying on the frontend's blit exactly as RetroArch does.
|
||||||
|
Requiring an exact size match here used to skip the draw outright for every
|
||||||
|
such preset on every frame, which is a silent total no-op rather than a sizing
|
||||||
|
quirk. The stretch uses the last-run chain's own final-pass `filter` to pick
|
||||||
|
nearest or linear.
|
||||||
|
|
||||||
|
### What this engine feeds shaders that a libretro core does not
|
||||||
|
|
||||||
|
A stock libretro core hands its frontend a raw framebuffer and a frame count.
|
||||||
|
This engine has more context available and passes some of it through.
|
||||||
|
|
||||||
|
| Context | How it reaches the shader |
|
||||||
|
| --- | --- |
|
||||||
|
| Playfield rect and true source size | `rect`/`source` per frame from `Renderer:endFrame` or `Game2`, so the chain sees real on-screen geometry at any survey zoom or Faithful Ratio state rather than a fixed 160x144 assumption that then gets stretched |
|
||||||
|
| Blit scale | `rect.scale`, the crisp integer scale the composite was built at, used to derive the crop's own draw scale |
|
||||||
|
| SGB zone coloring and palette | Baked into the input frame. `PaletteFX` zone passes run before the composite reaches ShaderFX, so a preset shades an already-zone-tinted image |
|
||||||
|
| Performance tier | `chainRenderScale()` reads `Performance.CAPS[tier].shaderfx`, a chain-resolution multiplier; the viewport and the cropped source both shrink by it and the final blit upscales |
|
||||||
|
| Accelerometer | `Sensors.read("accelerometer")`, bound to the `Accelerometer` unique semantic |
|
||||||
|
| Gyroscope | `Sensors.read("gyroscope")`, bound to `Gyroscope`, plus the integrated yaw twist below |
|
||||||
|
|
||||||
|
Two motion semantics are deliberately pinned rather than guessed. `Rotation` is
|
||||||
|
bound to 0 because librashader's own documentation is explicit that it is
|
||||||
|
`retroarch_get_rotation()`, the *content's* requested rotation (a vertically
|
||||||
|
oriented arcade core, say), not device orientation. Nothing here ever rotates
|
||||||
|
Game Boy content, so 0 is the correct answer, not a placeholder.
|
||||||
|
`AccelerometerRest` is bound to `{0, 0, 0}`: it is librashader's "reading at
|
||||||
|
rest" calibration reference, no preset in the corpus reads it, and a fixed
|
||||||
|
placeholder beats an invented value.
|
||||||
|
|
||||||
|
`src/core/Sensors.lua` is what makes the two real motion semantics work.
|
||||||
|
`love.sensor` does not exist in LOVE 11.5, the version this project ships, on
|
||||||
|
any platform including Android; it is a LOVE 12 addition. The working path is
|
||||||
|
raw FFI into the SDL2 that LOVE already links, the same technique
|
||||||
|
`src/core/Orientation.lua` uses, opening the first `SDL_SENSOR_ACCEL` or
|
||||||
|
`SDL_SENSOR_GYRO` device via `SDL_NumSensors`/`SDL_SensorGetDeviceType`/
|
||||||
|
`SDL_SensorOpen`. The `love.sensor` path is kept above it and simply stops being
|
||||||
|
dead code after a future LOVE 12 upgrade. Loading order matters:
|
||||||
|
`ffi.load("SDL2")` first, needed on desktop where SDL2 is a separate DLL, then
|
||||||
|
bare `ffi.C`, needed on Android where love-android links SDL2 statically into
|
||||||
|
`libmain.so` and there is no `libSDL2.so` for `ffi.load` to find by name. A
|
||||||
|
device with no sensor is probed once and then permanently reports zeros, so a
|
||||||
|
desktop run does not pay for it every frame.
|
||||||
|
|
||||||
|
SDL keeps sensor readings in the device's fixed chassis frame regardless of
|
||||||
|
screen orientation, so `rotateForScreen` remaps x and y into
|
||||||
|
"as currently displayed" terms using `SDL_GetDisplayOrientation`. That
|
||||||
|
compensation is mobile-only: a desktop monitor is legitimately and permanently
|
||||||
|
"landscape" to that query, which says something about the monitor's shape and
|
||||||
|
nothing about how a player is holding anything.
|
||||||
|
|
||||||
|
The accelerometer path in `sizeTable` does three things to the raw reading
|
||||||
|
before it becomes a uniform, all of them driven by real on-device data:
|
||||||
|
|
||||||
|
1. **Rest-pose subtraction.** `activate()` snapshots whatever pose the player is
|
||||||
|
actually holding the device in and every later reading is measured relative
|
||||||
|
to that, rather than to an assumed idealized vertical. The shipped tilt maths
|
||||||
|
(`pt_base.inc`'s `getOrientedTilt`) was authored assuming gravity sits almost
|
||||||
|
entirely on one axis at rest; a natural, comfortable hold already puts 56 to
|
||||||
|
66 percent of gravity's magnitude on the axis the shader reads as tilt, so
|
||||||
|
the effect sat near-saturated all the time instead of starting near neutral.
|
||||||
|
2. **Axis swap.** The tilt maths assumes a device resting flat, with gravity
|
||||||
|
dominant on Z, the one axis it never reads. This engine's rest pose is
|
||||||
|
upright portrait, where Y is gravity-dominant, so y and z are swapped to put
|
||||||
|
gravity back on the ignored axis.
|
||||||
|
3. **Denominator stabilization.** `getOrientedTilt` normalizes by the full
|
||||||
|
vector's magnitude. Before calibration that magnitude was a stable ~9.8 that
|
||||||
|
quietly damped tilt and noise alike by the same factor; calibration correctly
|
||||||
|
zeroes x and y at neutral but also shrinks the magnitude near rest, and real
|
||||||
|
logs showed it swinging between 0.65 and 11.4 second to second on ordinary
|
||||||
|
hand jitter, which reads as wildly bouncing. A fixed constant is re-injected
|
||||||
|
on the ignored axis to keep the denominator stable, but only when there is a
|
||||||
|
genuine live reading to calibrate against. An all-zero raw read is
|
||||||
|
`Sensors.lua`'s explicit "no hardware at all" sentinel, never a real value on
|
||||||
|
Earth, and injecting into that case would make the shader believe it had
|
||||||
|
sensor data and silently replace its own static fallback with fake motion.
|
||||||
|
|
||||||
|
Yaw is a separate mechanism. A raw gyroscope reading is angular velocity, not an
|
||||||
|
angle, so it only becomes a usable on-screen offset by integrating over time,
|
||||||
|
and only the per-slot state persists frame to frame to do that. `updateYawTwist`
|
||||||
|
is deliberately a decaying spring rather than a true integrated heading:
|
||||||
|
gyro-only integration drifts without a magnetometer to correct it, so this
|
||||||
|
settles back toward neutral and stays bounded by construction. It is folded onto
|
||||||
|
the accelerometer's x component before the shader's own normalize and clamp,
|
||||||
|
because that is the only already-compiled channel the stock upstream maths
|
||||||
|
reads, and reaching an already-converted artifact with no reconvert was worth
|
||||||
|
the tradeoff that the twist reads as an added simulated tilt rather than a
|
||||||
|
cleanly separate motion. It applies to `sunlight_shimmer.slangp` only, the one
|
||||||
|
preset in the shortlist with a twist-reactive channel. `YAW_GAIN = 0.6` and a
|
||||||
|
clamp of +/-2 were tuned against real device data (a moderate real yaw turn
|
||||||
|
peaks around 1.5 to 1.7 rad/s); a much larger gain was tried on-device and
|
||||||
|
looked worse, because overshooting a comfortable range reads worse than being
|
||||||
|
subtle. Retune in small steps with real device checks, not big jumps.
|
||||||
|
|
||||||
|
## Two slots
|
||||||
|
|
||||||
|
`ShaderFX.SLOTS` is `{"main", "secondary"}` and `ShaderFX.OPTION_KEY` maps each
|
||||||
|
to its save key. The slots are activated and persisted independently, and the
|
||||||
|
same `ShaderFXScreen` serves both, opened with the slot as its argument. Pragma
|
||||||
|
parameter edits are keyed by *preset name*, not by slot, because a preset's
|
||||||
|
values are a property of the preset the same way its cached artifact is;
|
||||||
|
editing them re-activates every slot currently showing that preset and persists
|
||||||
|
for the next load in either.
|
||||||
|
|
||||||
|
When both slots are active, `render` runs main's chain first and hands its
|
||||||
|
finished output to secondary as secondary's own input frame, along with its
|
||||||
|
dimensions, so a secondary preset that scales off its input sees main's real
|
||||||
|
output size rather than the original crop. Either slot alone behaves exactly as
|
||||||
|
a single-preset path; neither active is a plain passthrough.
|
||||||
|
|
||||||
|
**This is not how RetroArch composes multiple presets.** RetroArch merges
|
||||||
|
presets into a *single* pass list through `#reference` and `Append`, producing
|
||||||
|
one pass graph with one shared semantics map, where a later pass can reference
|
||||||
|
an earlier one's output by alias and the whole thing resolves as one unit. Two
|
||||||
|
slots here are two independent librashader chains run back to back, which is
|
||||||
|
what stacking two separate preset chains would give you, not what merging them
|
||||||
|
gives you. Presets that assume merged semantics will not behave the same way.
|
||||||
|
|
||||||
|
## Test seams
|
||||||
|
|
||||||
|
`ShaderFX` exposes a few fields purely so a headless harness can assert on real
|
||||||
|
per-frame values without taking a screenshot: `_lastRect` and `_lastSource`
|
||||||
|
(the rect and source dimensions a caller handed in), `_lastCrop` (the exact crop
|
||||||
|
canvas, which the later unconditional draw-back would otherwise mask),
|
||||||
|
`_lastYawTwist` and `_lastAccelPacked` (the integrated twist and the values that
|
||||||
|
actually reached the packed uniform, per slot). `Sensors.setOverride`,
|
||||||
|
`Sensors.clearOverride` and `Sensors.setOrientationOverride` inject synthetic
|
||||||
|
readings on a machine with no hardware.
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
|
||||||
|
None of these are theoretical.
|
||||||
|
|
||||||
|
- **Tested on very little real hardware.** Essentially one Android phone, one
|
||||||
|
desktop, and the automated harnesses. Anything about how a preset actually
|
||||||
|
looks or performs elsewhere is unverified.
|
||||||
|
- **No performance tier is actually tuned.** The chain-resolution multiplier in
|
||||||
|
`Performance.CAPS` is a working mechanism, but every tier that permits
|
||||||
|
ShaderFX at all sets it to 1.0. Nothing runs at reduced chain resolution
|
||||||
|
today. Picking a real value for weak hardware needs a device this project does
|
||||||
|
not have.
|
||||||
|
- **The dual-slot design does not match RetroArch.** See above. Two chains in
|
||||||
|
sequence is not one merged pass list.
|
||||||
|
- **`OriginalHistoryN` is a steady state.** Real per-frame history rotation
|
||||||
|
falls back to "every slot is the current frame" in the live render path, and
|
||||||
|
is unverified there.
|
||||||
|
- **`PassFeedback` is unimplemented.** Its size uniform raises an explicit
|
||||||
|
error and its sampler trips an assertion.
|
||||||
|
- **`ShaderFXScreen` has a known text-overlap bug on long preset names.**
|
||||||
|
`ListMenu`'s `fitLabel` truncation covers the ordinary case, but a long enough
|
||||||
|
player-supplied filename still collides with the row's right-hand hint.
|
||||||
|
- **`ShaderSourcePatches` ships with an empty patch table and nothing uses it.**
|
||||||
|
Intentional, for the reason given above, but it means the mechanism has no
|
||||||
|
live coverage.
|
||||||
|
- **Cached artifacts have no staleness detection.** Existence is the only check.
|
||||||
|
The two unconditional reconvert points paper over it; anything that does not
|
||||||
|
go through them can be running a stale translation.
|
||||||
|
- **Artifacts are per-device.** The GLSL dialect is baked in at convert time.
|
||||||
|
Copying a converted preset folder between a phone and a desktop copies a
|
||||||
|
wrong artifact along with it.
|
||||||
|
- **The bundled bridge is only as good as the build machine.**
|
||||||
|
`scripts/build.sh` bundles the cdylib for mac, win and linux via
|
||||||
|
`bundle_shader_bridge`, building it with cargo when a prebuilt one is not
|
||||||
|
supplied through `SHADERFX_BRIDGE`. A build host without cargo produces a
|
||||||
|
package that can run converted presets but cannot CONVERT new ones, and says
|
||||||
|
so rather than failing. Android ships the `.so` via `jniLibs`.
|
||||||
|
- **The buildbot shortlist is a temporary trim.** `KEPT_PRESETS` reflects one
|
||||||
|
manual pass over `handheld/` and is expected to change, most likely to shrink.
|
||||||
|
- **Tilt direction is unverified.** Which way forward and back rocking moves the
|
||||||
|
effect was never confirmed on a device; if it feels backwards the fix is a
|
||||||
|
sign flip on the swapped axis, not a deeper bug. Likewise, whether the
|
||||||
|
landscape rotation compensation matches real RetroArch is genuinely unknown:
|
||||||
|
RetroArch's Android input driver computes a screen rotation but does not
|
||||||
|
visibly apply it to the accelerometer values that reach shader uniforms, so
|
||||||
|
this project's compensation may be an improvement over upstream rather than a
|
||||||
|
match to it.
|
||||||
|
- **`texelFetch` wrap behavior at image edges may differ.** The ES 1.00 rewrite
|
||||||
|
in the bridge turns those calls into `texture()`, which honours the sampler's
|
||||||
|
wrap mode out of range where `texelFetch`'s out-of-bounds behavior is
|
||||||
|
implementation defined. Unmeasured.
|
||||||
@@ -1,20 +1,28 @@
|
|||||||
# Touch skins and the Skin Studio
|
# Touch skins and the Skin Studio
|
||||||
|
|
||||||
A **skin** replaces the on-screen controls wholesale: a bezel image, a
|
A **skin** replaces the on-screen controls wholesale: a bezel image, a
|
||||||
control layout, and the rectangle the Game Boy screen is drawn into. Engine:
|
control layout, and a screen-placement anchor. Engine:
|
||||||
`src/core/TouchSkin.lua` (model, parsers, zip export), `src/core/TouchControls.lua`
|
`src/core/TouchSkin.lua` (model, parsers, zip export), `src/core/TouchControls.lua`
|
||||||
(draw and input), `src/render/Renderer.lua` (the screen viewport),
|
(draw and input), `src/render/Renderer.lua` (screen placement),
|
||||||
`src/ui/SkinStudio.lua` (the desktop editor). Tests:
|
`src/core/DeltaSkin.lua` (Delta `.deltaskin` import and export),
|
||||||
|
`src/ui/SkinStudio.lua` (the responsive skin editor). Tests:
|
||||||
`tests/engine/touch_skin_test.lua`, `tests/engine/skin_studio_test.lua`,
|
`tests/engine/touch_skin_test.lua`, `tests/engine/skin_studio_test.lua`,
|
||||||
|
`tests/engine/skin_studio_ux.lua`,
|
||||||
`tests/engine/skin_studio_image_import.lua`,
|
`tests/engine/skin_studio_image_import.lua`,
|
||||||
`tests/engine/launcher_skins_tab.lua`.
|
`tests/engine/skin_format_import_test.lua`,
|
||||||
|
`tests/engine/launcher_skins_tab.lua`,
|
||||||
|
`tests/engine/launcher_skins_ux.lua`.
|
||||||
|
|
||||||
Skins are picked in the launcher's **Skins** tab, which also imports them and
|
The launcher's **Skins** tab imports skins, shows the enabled skin, exports it,
|
||||||
opens the studio. `options.touchControls.skin` holds the folder name.
|
and is the one place that turns skin use off. **My Skins** holds the visual
|
||||||
|
grid, pagination, edit, delete and per-skin export actions.
|
||||||
|
`options.touchControls.skin` holds the folder name.
|
||||||
|
|
||||||
## Formats
|
## Formats
|
||||||
|
|
||||||
Two load. `skin.lua` wins when a folder has both.
|
Three load: the native `skin.lua`, a RetroArch overlay `.cfg`, and a Delta
|
||||||
|
`.deltaskin`. `skin.lua` wins when a folder has more than one. The launcher
|
||||||
|
badges each installed skin with the format it was read from.
|
||||||
|
|
||||||
**RetroArch overlay `.cfg`.** The libretro `common-overlays` collection loads
|
**RetroArch overlay `.cfg`.** The libretro `common-overlays` collection loads
|
||||||
as-is. Supported keys:
|
as-is. Supported keys:
|
||||||
@@ -24,7 +32,7 @@ as-is. Supported keys:
|
|||||||
| `overlays` | page count |
|
| `overlays` | page count |
|
||||||
| `overlayN_name` | page name, the target of `next_target` |
|
| `overlayN_name` | page name, the target of `next_target` |
|
||||||
| `overlayN_overlay` | bezel image |
|
| `overlayN_overlay` | bezel image |
|
||||||
| `overlayN_full_screen` | stretch the page to the window |
|
| `overlayN_full_screen` | cover the window with the page without deforming its artwork |
|
||||||
| `overlayN_rect` | page placement, default `0,0,1,1` |
|
| `overlayN_rect` | page placement, default `0,0,1,1` |
|
||||||
| `overlayN_aspect_ratio` | design aspect; the overlay letterboxes to it even when full screen |
|
| `overlayN_aspect_ratio` | design aspect; the overlay letterboxes to it even when full screen |
|
||||||
| `overlayN_range_mod`, `overlayN_alpha_mod` | desc defaults |
|
| `overlayN_range_mod`, `overlayN_alpha_mod` | desc defaults |
|
||||||
@@ -41,6 +49,15 @@ Hitboxes are `radial` or `rect`. Pipe-separated binds (`left|down`) are one
|
|||||||
control that holds both. A `nul` desc is decoration: it draws and never
|
control that holds both. A `nul` desc is decoration: it draws and never
|
||||||
captures a touch.
|
captures a touch.
|
||||||
|
|
||||||
|
The area desc types are expanded rather than ignored: `dpad_area`,
|
||||||
|
`abxy_area`, `analog_left` and `analog_right` each become eight hitboxes over
|
||||||
|
the same area, one per 45 degree sector measured from its centre, the way
|
||||||
|
RetroArch resolves them: there is no neutral middle, and the four corner
|
||||||
|
sectors fire two inputs. Any `_up` / `_down` / `_left` / `_right` override and
|
||||||
|
the per-side reach are honoured, and the desc's own art is kept as decoration
|
||||||
|
over the top. Exporting a cfg folds the eight back into the one area desc they
|
||||||
|
came from. `retrok_<key>` is a keyboard bind.
|
||||||
|
|
||||||
Alpha follows RetroArch (`input_driver.c`, `input_overlay_post_poll`): every
|
Alpha follows RetroArch (`input_driver.c`, `input_overlay_post_poll`): every
|
||||||
image sits at the overlay opacity, and a pressed control's image swaps to
|
image sits at the overlay opacity, and a pressed control's image swaps to
|
||||||
`opacity * alpha_mod`. So `alpha_mod` above 1 lights a control up and below 1
|
`opacity * alpha_mod`. So `alpha_mod` above 1 lights a control up and below 1
|
||||||
@@ -71,6 +88,32 @@ return {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Delta `.deltaskin`.** A zip (any wrapping folder is stripped) holding an
|
||||||
|
`info.json` plus its art. The `representations` tree is walked
|
||||||
|
device / display type / orientation, and every orientation that exists becomes
|
||||||
|
a page; `page.orient` is the orientation key, so a portrait/landscape pair
|
||||||
|
auto-rotates like a RetroArch one. Item `frame` rects are top-left plus size in
|
||||||
|
`mappingSize` points and are converted to the native centre plus half extent;
|
||||||
|
`extendedEdges` merge per key into the reach fields; `mask: "circle"` becomes a
|
||||||
|
radial hitbox. A `dpad` or `thumbstick` item expands into the 3x3 grid, so the
|
||||||
|
corners fire two directions. `screens[1].outputFrame` (or the legacy
|
||||||
|
`gameScreenFrame`) becomes the screen cutout. A portrait page with neither
|
||||||
|
keeps `mappingSize` as the overlay aspect, sits at the bottom of the
|
||||||
|
window, and puts the Game Boy picture in the leftover space above -- the
|
||||||
|
usual GBA4iOS controller-deck layout. Pages that name a screen rect fit the
|
||||||
|
game into it. Host functions map to
|
||||||
|
engine hotkeys: `menu` to `menu_toggle`, `fastForward` to
|
||||||
|
`hold_fast_forward`, `toggleFastForward` to `toggle_fast_forward`;
|
||||||
|
`quickSave` and `quickLoad` have nothing to bind to and drop to decoration.
|
||||||
|
Both `com.rileytestut.delta.game.*` and Manic's `public.aoshuang.game.*`
|
||||||
|
identifiers are accepted, and a non Game Boy system warns instead of failing.
|
||||||
|
|
||||||
|
PDF artwork is usually a JPEG wrapped so iOS can scale it (Delta's
|
||||||
|
Image-to-PDF skins, Preview exports, and the like). Import extracts that
|
||||||
|
JPEG and draws it; a true vector PDF with no embedded image is still refused,
|
||||||
|
with a message asking for a PNG version. GBA4iOS `.gbcskin` / `.gbaskin` files
|
||||||
|
are an older, incompatible schema and are refused by name.
|
||||||
|
|
||||||
## Bindable actions
|
## Bindable actions
|
||||||
|
|
||||||
The eight Game Boy buttons: `a`, `b`, `start`, `select`, `up`, `down`,
|
The eight Game Boy buttons: `a`, `b`, `start`, `select`, `up`, `down`,
|
||||||
@@ -94,7 +137,7 @@ to decoration and never captures a touch.
|
|||||||
As an extension to the format, `key:<name>` presses any keyboard key, which is
|
As an extension to the format, `key:<name>` presses any keyboard key, which is
|
||||||
how a skin button reaches a mod hotkey.
|
how a skin button reaches a mod hotkey.
|
||||||
|
|
||||||
## The screen viewport
|
## Screen placement
|
||||||
|
|
||||||
`overlayN_viewport` is the cutout the picture is fitted into. The Game Boy
|
`overlayN_viewport` is the cutout the picture is fitted into. The Game Boy
|
||||||
screen keeps its whole-pixel scale and letterboxes inside that rect rather than
|
screen keeps its whole-pixel scale and letterboxes inside that rect rather than
|
||||||
@@ -105,6 +148,17 @@ that lets a widescreen bezel take the filling survey-zoom world view instead.
|
|||||||
A viewport also implies the faithful-ratio lock. Without it the world pass
|
A viewport also implies the faithful-ratio lock. Without it the world pass
|
||||||
expands to fill the cutout and you get more map instead of a Game Boy screen.
|
expands to fill the cutout and you get more map instead of a Game Boy screen.
|
||||||
|
|
||||||
|
Zoom still steps around that hole: OUT shows more map inside it, IN enlarges
|
||||||
|
the world, and the start menu stays at the hole's fit scale instead of
|
||||||
|
shrinking with the map.
|
||||||
|
|
||||||
|
An image-backed portrait overlay that has no explicit vertical anchor is treated
|
||||||
|
as a controller deck: it is contained without deformation and pinned to the
|
||||||
|
bottom on taller screens. The spare space belongs to the game above it.
|
||||||
|
|
||||||
|
When a skin is active, **SCREEN POS** reads **SKIN**: placement comes from the
|
||||||
|
skin rather than the normal Center / Upper / Top setting.
|
||||||
|
|
||||||
Border art often ships with a transparent hole and no `viewport` key. **Detect
|
Border art often ships with a transparent hole and no `viewport` key. **Detect
|
||||||
screen from bezel** in the studio measures the hole out of the art's alpha
|
screen from bezel** in the studio measures the hole out of the art's alpha
|
||||||
channel and writes the rect.
|
channel and writes the rect.
|
||||||
@@ -112,17 +166,27 @@ channel and writes the rect.
|
|||||||
## Bezels versus pads
|
## Bezels versus pads
|
||||||
|
|
||||||
A skin whose active page binds nothing is a frame rather than a pad: a TV
|
A skin whose active page binds nothing is a frame rather than a pad: a TV
|
||||||
surround, a handheld shell, a Super Game Boy border. Those draw on **desktop**
|
surround, a handheld shell, a Super Game Boy border. Selected skins draw on
|
||||||
as well, where the touch overlay itself does not, and a gamepad does not hide
|
**desktop** as well as mobile; a gamepad does not hide them.
|
||||||
them. Anything that binds a button still follows the usual mobile /
|
|
||||||
`POKEPORT_TOUCH` rule.
|
|
||||||
|
|
||||||
## Installing
|
## Installing
|
||||||
|
|
||||||
Drop a folder or a `.zip` into `skins/` in the save directory, or drop a zip on
|
Four roads, all of them landing in `skins/` in the save directory:
|
||||||
the launcher window while the Skins tab is open. A zip is mounted in place, so
|
|
||||||
there is nothing to unpack. The folder needs one `skin.lua` or `.cfg`
|
* **Import** on the Skins tab opens the host file picker for a `.zip` or a
|
||||||
(`overlay.cfg` is preferred when there are several) and the images it names.
|
`.deltaskin`.
|
||||||
|
* **Paste a skin link** in the tab's URL row, then **Add**. The download runs
|
||||||
|
on the fetch pool (`src/net/Fetch.lua`), so the launcher stays live, and the
|
||||||
|
row shows a spinner until it lands. A link to a bare `overlay.cfg` is wrapped
|
||||||
|
into an archive on the way in. This is the road that works on a phone, where
|
||||||
|
there is no file picker to speak of.
|
||||||
|
* Drop a `.zip` or `.deltaskin` on the launcher window while the Skins tab is
|
||||||
|
open.
|
||||||
|
* Copy a folder or archive into `skins/` by hand.
|
||||||
|
|
||||||
|
An archive is mounted in place, so there is nothing to unpack. It needs one
|
||||||
|
`skin.lua`, `.cfg` (`overlay.cfg` is preferred when there are several) or
|
||||||
|
`info.json`, plus the images it names.
|
||||||
|
|
||||||
Two ship bundled, both from libretro's `common-overlays` under CC-BY-4.0:
|
Two ship bundled, both from libretro's `common-overlays` under CC-BY-4.0:
|
||||||
|
|
||||||
@@ -137,9 +201,32 @@ shipping branding.
|
|||||||
|
|
||||||
## The studio
|
## The studio
|
||||||
|
|
||||||
Launcher, Skins tab, **Open Skin Studio**, or the gear on any skin row to open
|
Launcher, Skins tab, **My Skins** opens the Studio library on desktop and
|
||||||
that skin. Desktop only: the launcher does not offer it on Android or iOS,
|
mobile. **My Skins** is the only visual grid: real bezel previews plus create
|
||||||
because it wants a mouse, typed coordinates and room for an inspector.
|
and import actions, with each card owning Edit, Export and (for installed
|
||||||
|
skins) Delete. Choosing Edit opens a separate, canvas-first editor; the old
|
||||||
|
New/Load workspace controls are deliberately not duplicated inside that editor.
|
||||||
|
|
||||||
|
The editor keeps its canvas unobstructed and puts the contextual actions in a
|
||||||
|
compact lower tray: add/control binding, button and bezel artwork, pages,
|
||||||
|
screen placement, freeform/10:9 screen shape and deletion. **Screen** opens
|
||||||
|
cutout, bezel-hole detect, **Detect this screen**, and the canvas presets.
|
||||||
|
**Detect this screen** (also on the tray) sets the mock device to the live
|
||||||
|
window size so a phone skin is authored at that phone's form factor rather
|
||||||
|
than a generic 1080x1920 16:9. **Zoom −** shrinks the mock device inside the
|
||||||
|
workspace so the screen hole can be dragged larger than the bezel while the
|
||||||
|
handles stay grabable; **Fit** restores contain. The mouse wheel over the
|
||||||
|
canvas, and `-` / `=` / `0` on a keyboard, do the same. Touches select, drag and resize the
|
||||||
|
same controls that a mouse edits on desktop.
|
||||||
|
|
||||||
|
My Skins and the editor chrome sit inside the platform safe area (notch,
|
||||||
|
status bar, home indicator), the same inset the launcher uses. The mock
|
||||||
|
device still represents the full window, because a skin covers the whole
|
||||||
|
screen at play time.
|
||||||
|
|
||||||
|
The launcher’s **Turn skins off** button clears the selected skin and disables
|
||||||
|
skin use. With no skin enabled, mobile falls back to the built-in pad; that pad
|
||||||
|
is not itself a skin card.
|
||||||
|
|
||||||
**Canvas.** A mock device at a chosen preset, so a phone skin is authored at
|
**Canvas.** A mock device at a chosen preset, so a phone skin is authored at
|
||||||
phone proportions on a desktop monitor.
|
phone proportions on a desktop monitor.
|
||||||
@@ -147,6 +234,7 @@ phone proportions on a desktop monitor.
|
|||||||
| Preset | Size |
|
| Preset | Size |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Phone portrait / landscape | 1080x1920, 1920x1080 |
|
| Phone portrait / landscape | 1080x1920, 1920x1080 |
|
||||||
|
| This screen | the live window, so a phone is authored at its own height |
|
||||||
| Tablet portrait / landscape | 1536x2048, 2048x1536 |
|
| Tablet portrait / landscape | 1536x2048, 2048x1536 |
|
||||||
| Steam Deck | 1280x800 |
|
| Steam Deck | 1280x800 |
|
||||||
| Desktop 1080p | 1920x1080 |
|
| Desktop 1080p | 1920x1080 |
|
||||||
@@ -157,39 +245,73 @@ The Super Game Boy preset locks the viewport to the real screen window,
|
|||||||
160x144 at (48,40), so an SGB border cannot be drawn out of register.
|
160x144 at (48,40), so an SGB border cannot be drawn out of register.
|
||||||
|
|
||||||
**Editing.** Click a control to select it, drag to move, eight handles to
|
**Editing.** Click a control to select it, drag to move, eight handles to
|
||||||
resize. X / Y / W / H are in canvas pixels, so a control can be typed to the
|
resize. Arrow keys nudge the selection one canvas pixel, shift-arrow ten. While
|
||||||
coordinate its art was drawn at. Bind, hitbox shape, hit reach and idle and
|
a control is dragged it snaps to the centres and edges of the other controls
|
||||||
pressed images are per control; the bezel, the pages and the screen cutout are
|
and of the page itself when it comes within a few pixels, and the guide it
|
||||||
per page. The cutout is itself a draggable element with a 10:9 lock.
|
snapped to is drawn. X / Y / W / H are in canvas pixels, so a control can be
|
||||||
|
typed to the coordinate its art was drawn at. **Back** and **Front** move the
|
||||||
|
selection through the draw order. Bind, hitbox shape, hit reach and idle and
|
||||||
|
pressed images are per control; the bezel, the pages and the screen anchor are
|
||||||
|
per page. The SCREEN anchor is itself draggable and resizable; its default
|
||||||
|
shape is freeform, with an optional 10:9 lock.
|
||||||
|
|
||||||
|
**Bind** opens a grid of every bind the engine understands: the eight Game Boy
|
||||||
|
buttons, the diagonal pairs, every hotkey, desktop hotkeys and decoration.
|
||||||
|
The desktop section exposes `-` / `=`, `1` through `5`, `F1`, `F2` and `F10`
|
||||||
|
as `key:` controls, so a mobile button invokes the exact same game path as
|
||||||
|
its desktop shortcut. The COMBINE chips at the top toggle one part at a time,
|
||||||
|
which is how a pipe bind like `left|down` is built without typing it.
|
||||||
|
|
||||||
|
**Undo** and **Redo** in the top bar cover every edit (ctrl+Z / ctrl+Y, or
|
||||||
|
`u` / shift+`u` without a keyboard modifier). The stack holds the last 50
|
||||||
|
actions. `L` toggles the bind captions drawn on the canvas.
|
||||||
|
|
||||||
Each page can **Lock** to portrait or landscape. With **Match canvas** on
|
Each page can **Lock** to portrait or landscape. With **Match canvas** on
|
||||||
(the default), Next page picks a matching mock device and the canvas preset
|
(the default), the page list picks a matching mock device and the canvas preset
|
||||||
picks a matching page. Turn Match canvas off to look at a portrait page on a
|
picks a matching page. Turn Match canvas off to look at a portrait page on a
|
||||||
landscape device.
|
landscape device. **Pages** opens the page list, where a page is selected,
|
||||||
|
renamed or deleted.
|
||||||
|
|
||||||
|
Starting a new skin, opening another one or closing the studio with unsaved
|
||||||
|
edits prompts first, with Save first / Discard / Cancel.
|
||||||
|
|
||||||
A RetroArch overlay whose pages are already named portrait / landscape
|
A RetroArch overlay whose pages are already named portrait / landscape
|
||||||
(the auto-rotate convention) locks those pages and turns Match canvas on
|
(the auto-rotate convention) locks those pages and turns Match canvas on
|
||||||
when you open it. You do not have to click Lock first.
|
when you open it. You do not have to click Lock first.
|
||||||
|
|
||||||
**Art.** The **Bezel**, **Idle art** and **Pressed art** rows cycle through the
|
**Art.** The **Bezel**, **Idle art** and **Pressed art** rows open a
|
||||||
images already in the skin folder; the **Import** button beside each one opens
|
thumbnail grid of the images already in the skin folder, with `(none)` first;
|
||||||
the host file picker (`src/core/FilePicker.lua`: osascript, PowerShell,
|
the **Import** button there and beside each row opens the host file picker (`src/core/FilePicker.lua`: osascript, PowerShell,
|
||||||
zenity/kdialog) and copies the chosen PNG or JPG into `img/` under the name in
|
zenity/kdialog) and copies the chosen PNG or JPG into `img/` under the name in
|
||||||
the SKIN field, then assigns it to that slot. Dropping a PNG or JPG on the
|
the SKIN field, then assigns it to that slot. Dropping a PNG or JPG on the
|
||||||
window does the same for whichever slot was last touched. A new bezel does not
|
window does the same for whichever slot was last touched. A new bezel does not
|
||||||
move the screen cutout: press **Detect screen from bezel** to measure it out of
|
move the screen anchor: press **Detect screen from bezel** to measure it out of
|
||||||
the art's alpha.
|
the art's alpha.
|
||||||
|
|
||||||
**Testing.** **Test** makes the canvas live: clicking presses real Game Boy
|
**Testing.** **Test** renders a game-composition preview behind the live overlay:
|
||||||
buttons and the footer reports what is held. **Play** saves the skin, selects
|
the 160x144 picture letterboxes inside the screen cutout, matching gameplay.
|
||||||
it, and boots the game with it.
|
Clicking presses real Game Boy buttons and the footer reports what is held.
|
||||||
|
**Play** saves the skin, selects it, and boots the game with it.
|
||||||
|
|
||||||
**Saving.** **Save** writes `skins/<name>/skin.lua` and copies every image the
|
**Saving.** **Save** writes `skins/<name>/skin.lua` and copies every image the
|
||||||
skin names, so the folder stands alone. **Export** packs it as one zip
|
skin names, so the folder stands alone. **Export** offers three formats, and
|
||||||
(`src/core/SkinZip.lua`, store-only) carrying the native `skin.lua`, the
|
the Skins tab's gear offers the same three for any installed skin:
|
||||||
images, and the original `.cfg` when it came from one. An exported skin drops
|
|
||||||
straight back into `skins/` and still opens in RetroArch.
|
| Export | Contents |
|
||||||
|
| --- | --- |
|
||||||
|
| gen1recomp `.zip` | the native `skin.lua`, the images, and the original `.cfg` when it came from one |
|
||||||
|
| RetroArch `.zip` | an `overlay.cfg` generated from the model, plus the images |
|
||||||
|
| Delta `.deltaskin` | an `info.json` generated from the model, plus the images |
|
||||||
|
|
||||||
|
All three are written store-only (`src/core/SkinZip.lua`) into `skins/_export/`
|
||||||
|
in the save directory, which is outside the folder the skin list scans, so an
|
||||||
|
export can never shadow the skin it came from. The notice names the full path
|
||||||
|
so a phone can find the file in its own file manager. On desktop **Show the
|
||||||
|
exported file** opens that folder.
|
||||||
|
|
||||||
## Not implemented
|
## Not implemented
|
||||||
|
|
||||||
RetroArch's `analog_*`, `dpad_area`, `abxy_area` and `retrok_*` desc types.
|
True vector Delta skins (PDF artwork with no embedded JPEG). Those still need
|
||||||
|
a PDF renderer this engine does not carry, so they are refused with a message
|
||||||
|
rather than imported half-drawn. PDF files that wrap a JPEG, the usual Delta
|
||||||
|
skin case, extract on import.
|
||||||
|
|||||||
@@ -91,14 +91,14 @@ Do **not** launch from the Album applet path for normal play.
|
|||||||
|
|
||||||
This project ships **no** game data. On first launch:
|
This project ships **no** game data. On first launch:
|
||||||
|
|
||||||
1. Put your own legally obtained Pokémon Red, Blue (`.gb`), Yellow, or
|
1. Put your own legally obtained Pokémon Red, Blue (`.gb`), Yellow, Gold, or
|
||||||
Gold (`.gbc`) dump into `switch/gen1recomp/pokemon-love2d/imports/` (the
|
Silver (`.gbc`) dump into `switch/gen1recomp/pokemon-love2d/imports/` (the
|
||||||
launcher also shows the live save-dir path). All four can sit in the
|
launcher also shows the live save-dir path). All five can sit in the
|
||||||
same folder.
|
same folder.
|
||||||
2. Use **Scan again** on that game's tab (Red / Blue / Yellow / Gold).
|
2. Use **Scan again** on that game's tab (Red / Blue / Yellow / Gold /
|
||||||
Rescan matches by ROM SHA-1 for the open tab only. A Red dump never
|
Silver). Rescan matches by ROM SHA-1 for the open tab only. A Red dump
|
||||||
imports from the Yellow tab (and vice versa). Gold is Beta in the
|
never imports from the Yellow tab (and vice versa). Gold and Silver are
|
||||||
launcher; a clean US Gold dump is enough to Play.
|
Beta in the launcher; a clean US dump of either is enough to Play.
|
||||||
|
|
||||||
## 5. Import / Export a raw `.sav`
|
## 5. Import / Export a raw `.sav`
|
||||||
|
|
||||||
@@ -111,10 +111,12 @@ SD / FTP, same transfer methods as ROMs. Paths are **per game**:
|
|||||||
| Blue | `imports/saves/blue/` | `exports/blue/` |
|
| Blue | `imports/saves/blue/` | `exports/blue/` |
|
||||||
| Yellow | `imports/saves/yellow/` | `exports/yellow/` |
|
| Yellow | `imports/saves/yellow/` | `exports/yellow/` |
|
||||||
| Gold | `imports/saves/gold/` | `exports/gold/` |
|
| Gold | `imports/saves/gold/` | `exports/gold/` |
|
||||||
|
| Silver | `imports/saves/silver/` | `exports/silver/` |
|
||||||
|
|
||||||
(Under the save dir `pokemon-love2d/`. The zip already creates these folders.
|
(Under the save dir `pokemon-love2d/`. The zip already creates these folders.
|
||||||
Gold cart `.sav` import/export is not supported yet -- the folders exist so
|
Gold and Silver cart `.sav` import/export is not supported yet -- the folders
|
||||||
MTP browsing matches the other games. Gold progress still saves in-engine.)
|
exist so MTP browsing matches the other games. Gold and Silver progress still
|
||||||
|
saves in-engine.)
|
||||||
|
|
||||||
1. Copy a Gen 1 `.sav` (32 KB) into that game's inbox under the save dir
|
1. Copy a Gen 1 `.sav` (32 KB) into that game's inbox under the save dir
|
||||||
([switch-transfer.md](switch-transfer.md)).
|
([switch-transfer.md](switch-transfer.md)).
|
||||||
@@ -181,7 +183,6 @@ hotkeys (`2`/`3`/`5` are claimed before any mod pipeline hotkey runs).
|
|||||||
| ----- | -------------- | ------------------- |
|
| ----- | -------------- | ------------------- |
|
||||||
| Select + **A** | `2` | COLORS |
|
| Select + **A** | `2` | COLORS |
|
||||||
| Select + **B** | `3` | TILT |
|
| Select + **B** | `3` | TILT |
|
||||||
| Select + **Y** | `5` | GBC FX |
|
|
||||||
| Select + **X** | `6` | Mod pipeline hotkey (if a mod registers `6`) |
|
| Select + **X** | `6` | Mod pipeline hotkey (if a mod registers `6`) |
|
||||||
| Select + **L** | `7` | Mod pipeline hotkey (if a mod registers `7`) |
|
| Select + **L** | `7` | Mod pipeline hotkey (if a mod registers `7`) |
|
||||||
|
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ Player install (what to download, title override) stays in
|
|||||||
| Loose iteration pair | `sdmc:/switch/gen1recomp/gen1recomp.nro` **and** `game.love` beside it |
|
| Loose iteration pair | `sdmc:/switch/gen1recomp/gen1recomp.nro` **and** `game.love` beside it |
|
||||||
| ROM inbox | LÖVE save dir → `imports/` (launcher shows the live `getSaveDirectory()` path; under MTP often `1: SD Card/<save identity>/imports/`) |
|
| ROM inbox | LÖVE save dir → `imports/` (launcher shows the live `getSaveDirectory()` path; under MTP often `1: SD Card/<save identity>/imports/`) |
|
||||||
| Mod zip inbox | Same save dir → `imports/mods/` then MODS → **Scan again** |
|
| Mod zip inbox | Same save dir → `imports/mods/` then MODS → **Scan again** |
|
||||||
| Save `.sav` inbox | Same save dir → `imports/saves/red\|blue\|yellow\|gold/` then that game's SAVE FILES → **Import save** (Gold cart `.sav` not supported yet) |
|
| Save `.sav` inbox | Same save dir → `imports/saves/red\|blue\|yellow\|gold\|silver\|crystal/` then that game's SAVE FILES → **Import save** (Gen 2 cart `.sav` not supported yet, on Gold, Silver or Crystal) |
|
||||||
| Save exports | Same save dir → `exports/red\|blue\|yellow\|gold/` (pull after **Export save**; Gold cart `.sav` not supported yet) |
|
| Save exports | Same save dir → `exports/red\|blue\|yellow\|gold\|silver\|crystal/` (pull after **Export save**; Gen 2 cart `.sav` not supported yet, on Gold, Silver or Crystal) |
|
||||||
| Opt-in diagnostics | Empty `switch-debug.txt` in the save dir → `switch.log` |
|
| Opt-in diagnostics | Empty `switch-debug.txt` in the save dir → `switch.log` |
|
||||||
| Lua error log | `lua-error.log` in the save dir |
|
| Lua error log | `lua-error.log` in the save dir |
|
||||||
|
|
||||||
@@ -54,7 +54,8 @@ macOS, not a Mac-only requirement.
|
|||||||
3. Create `switch/gen1recomp/` if needed; extract the release zip at SD root
|
3. Create `switch/gen1recomp/` if needed; extract the release zip at SD root
|
||||||
(or copy NRO / `game.love` for loose).
|
(or copy NRO / `game.love` for loose).
|
||||||
4. For ROMs/mods/saves, open the save-dir `imports/`, `imports/mods/`,
|
4. For ROMs/mods/saves, open the save-dir `imports/`, `imports/mods/`,
|
||||||
`imports/saves/<red|blue|yellow|gold>/`, or `exports/<red|blue|yellow|gold>/`
|
`imports/saves/<red|blue|yellow|gold|silver|crystal>/`, or
|
||||||
|
`exports/<red|blue|yellow|gold|silver|crystal>/`
|
||||||
path the launcher prints.
|
path the launcher prints.
|
||||||
5. Wait for the queue; refresh; exit MTP responder; title-override launch.
|
5. Wait for the queue; refresh; exit MTP responder; title-override launch.
|
||||||
|
|
||||||
|
|||||||
@@ -64,7 +64,8 @@ mounted or deleted as stale; the launcher directs the player to a full package.
|
|||||||
|
|
||||||
Each tagged release `vX.Y.Z` carries the existing per-platform archives
|
Each tagged release `vX.Y.Z` carries the existing per-platform archives
|
||||||
(`gen1recomp-X.Y.Z-macos.zip`, `-windows.zip`, `-linux.zip`,
|
(`gen1recomp-X.Y.Z-macos.zip`, `-windows.zip`, `-linux.zip`,
|
||||||
`-android.apk`) plus two assets the updater itself consumes:
|
`-linux-arm64.AppImage`, `-android.apk`, `-ios.ipa`, `-switch.zip`, Xbox and
|
||||||
|
PortMaster archives) plus two assets the updater itself consumes:
|
||||||
|
|
||||||
- `gen1recomp-X.Y.Z.love` - the payload, matched by the exact pattern
|
- `gen1recomp-X.Y.Z.love` - the payload, matched by the exact pattern
|
||||||
`gen1recomp-<version>.love` (see `isPayloadName` in `Boot.lua` and
|
`gen1recomp-<version>.love` (see `isPayloadName` in `Boot.lua` and
|
||||||
@@ -75,8 +76,9 @@ Each tagged release `vX.Y.Z` carries the existing per-platform archives
|
|||||||
filename otherwise to match the asset name exactly.
|
filename otherwise to match the asset name exactly.
|
||||||
|
|
||||||
A release missing either asset is treated as "no in-place update available":
|
A release missing either asset is treated as "no in-place update available":
|
||||||
`Check` reports `needs_full` and sends the player to `Check.releaseUrl()`
|
`Check` reports `needs_full`. It also selects the exact current platform asset
|
||||||
(`https://github.com/bryanthaboi/gen1recomp/releases/latest`).
|
from the same release and persists the requirement, so it is visible again on
|
||||||
|
every launch, including offline launches.
|
||||||
|
|
||||||
## Save-directory layout
|
## Save-directory layout
|
||||||
|
|
||||||
@@ -85,6 +87,7 @@ Under the save directory (identity `pokemon-love2d`):
|
|||||||
```
|
```
|
||||||
updates/gen1recomp-<X.Y.Z>.love downloaded payload(s)
|
updates/gen1recomp-<X.Y.Z>.love downloaded payload(s)
|
||||||
updates/pending.txt crash-guard marker
|
updates/pending.txt crash-guard marker
|
||||||
|
updates/full-update.json persistent native-package requirement
|
||||||
```
|
```
|
||||||
|
|
||||||
`pending.txt` holds the filename of the payload currently being chainloaded.
|
`pending.txt` holds the filename of the payload currently being chainloaded.
|
||||||
@@ -106,7 +109,8 @@ bundled game, in that case.
|
|||||||
against the GitHub releases API; safe to call every frame, it is a no-op
|
against the GitHub releases API; safe to call every frame, it is a no-op
|
||||||
once a check is in flight or has reached a terminal state. `Check.state()`
|
once a check is in flight or has reached a terminal state. `Check.state()`
|
||||||
reports `idle | checking | uptodate | available | downloading | ready |
|
reports `idle | checking | uptodate | available | downloading | ready |
|
||||||
needs_full | error` plus the latest version and download progress.
|
needs_full | full_downloading | full_ready | error` plus the latest version,
|
||||||
|
download progress, and (when applicable) the selected full-package asset.
|
||||||
3. **Download + verify**: on `available`, `Check.download()` tells the
|
3. **Download + verify**: on `available`, `Check.download()` tells the
|
||||||
worker to fetch the payload, polling the growing `.part` file for
|
worker to fetch the payload, polling the growing `.part` file for
|
||||||
progress. On completion the worker re-fetches `sha256sums.txt`, verifies
|
progress. On completion the worker re-fetches `sha256sums.txt`, verifies
|
||||||
@@ -117,6 +121,17 @@ bundled game, in that case.
|
|||||||
4. **Restart to apply**: a `ready` payload just sits in `updates/` until the
|
4. **Restart to apply**: a `ready` payload just sits in `updates/` until the
|
||||||
player relaunches; the next launch's Boot step (1) is what actually
|
player relaunches; the next launch's Boot step (1) is what actually
|
||||||
mounts and runs it. There is no in-session hot-swap.
|
mounts and runs it. There is no in-session hot-swap.
|
||||||
|
5. **Native-package requirement**: when `minShell` or `payloadHost` is
|
||||||
|
incompatible, the worker writes `full-update.json` and surfaces a
|
||||||
|
persistent launcher control. Android downloads the release APK, verifies
|
||||||
|
its SHA-256 entry from `sha256sums.txt`, then invokes Android's Package
|
||||||
|
Installer. The installer asks the user for consent and enforces package,
|
||||||
|
version-code, and signing-certificate compatibility. A legacy APK without
|
||||||
|
the installer bridge links its full package for one manual bootstrap
|
||||||
|
update, including when its downloaded payload already reports the latest
|
||||||
|
engine version. iOS links the sideload repository for a re-sideload; Xbox,
|
||||||
|
desktop, and PortMaster builds link their correctly named full package.
|
||||||
|
Switch keeps its native OTA flow.
|
||||||
|
|
||||||
## Known limitations
|
## Known limitations
|
||||||
|
|
||||||
@@ -140,6 +155,13 @@ bundled game, in that case.
|
|||||||
still need a full reinstall (`minShell` / `payloadHost` gate →
|
still need a full reinstall (`minShell` / `payloadHost` gate →
|
||||||
`needs_full`). Applying a downloaded payload on Android relaunches via
|
`needs_full`). Applying a downloaded payload on Android relaunches via
|
||||||
`love.system.restartApp`; iOS still uses in-process `quit("restart")`.
|
`love.system.restartApp`; iOS still uses in-process `quit("restart")`.
|
||||||
|
- **Android full updates are user-confirmed and certificate-bound.** The app
|
||||||
|
uses a private `FileProvider` cache path plus
|
||||||
|
`Intent.ACTION_INSTALL_PACKAGE`, checks Android 8+'s per-app
|
||||||
|
"install unknown apps" setting, and never requests a silent install. The
|
||||||
|
release job must use the original long-lived Android signing key; a new key
|
||||||
|
causes Android to reject an in-place update and requires a one-time manual
|
||||||
|
reinstall. See [mobile/ANDROID.md](../mobile/ANDROID.md).
|
||||||
- **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
|
||||||
|
|||||||
@@ -24,10 +24,10 @@ local GameViewport = require("src.render.GameViewport")
|
|||||||
|
|
||||||
-- 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
|
||||||
local defaultErrorHandler = love.errorhandler
|
local defaultErrorHandler = love.errorhandler or love.errhand
|
||||||
function love.errorhandler(msg)
|
function love.errorhandler(msg)
|
||||||
local hint = SwitchDiagnostics.logLuaError(msg)
|
local ok, hint = pcall(SwitchDiagnostics.logLuaError, msg)
|
||||||
if hint and type(msg) == "string" then
|
if ok and hint and type(msg) == "string" then
|
||||||
msg = msg .. "\n\n" .. hint
|
msg = msg .. "\n\n" .. hint
|
||||||
end
|
end
|
||||||
if defaultErrorHandler then
|
if defaultErrorHandler then
|
||||||
@@ -82,6 +82,11 @@ 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
|
||||||
|
|
||||||
|
-- Drop CacheFs / Data / mod Runtime / Assets / LegacyCompat for one mounted
|
||||||
|
-- version session (save editor or game). closeEditor and returnToLauncher
|
||||||
|
-- both go through SessionLifecycle so neither path forgets a singleton.
|
||||||
|
local SessionLifecycle = require("src.core.SessionLifecycle")
|
||||||
|
|
||||||
-- 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
|
||||||
@@ -157,9 +162,7 @@ local function openEditor(version, slotId)
|
|||||||
local okReq, appOrErr = pcall(require, "App")
|
local okReq, appOrErr = pcall(require, "App")
|
||||||
if not okReq then
|
if not okReq then
|
||||||
editorMode = false
|
editorMode = false
|
||||||
if version then
|
SessionLifecycle.endEditorSession({ version = version, app = nil })
|
||||||
require("src.import.CacheFs").unmountVersion(version)
|
|
||||||
end
|
|
||||||
restoreWindow()
|
restoreWindow()
|
||||||
Importer = editorHost
|
Importer = editorHost
|
||||||
editorHost = nil
|
editorHost = nil
|
||||||
@@ -179,10 +182,7 @@ local function openEditor(version, slotId)
|
|||||||
editorMode = false
|
editorMode = false
|
||||||
if EditorApp.unload then pcall(EditorApp.unload) end
|
if EditorApp.unload then pcall(EditorApp.unload) end
|
||||||
EditorApp = nil
|
EditorApp = nil
|
||||||
if version then
|
SessionLifecycle.endEditorSession({ version = version, app = nil })
|
||||||
require("src.import.CacheFs").unmountVersion(version)
|
|
||||||
require("src.core.Data"):unloadGenerated()
|
|
||||||
end
|
|
||||||
restoreWindow()
|
restoreWindow()
|
||||||
Importer = editorHost
|
Importer = editorHost
|
||||||
editorHost = nil
|
editorHost = nil
|
||||||
@@ -197,21 +197,14 @@ 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
|
||||||
-- back out: the version overlay (CacheFs) and the generated modules require
|
-- back out: the version overlay (CacheFs) and the generated modules require
|
||||||
-- cached behind it (Data), or pressing Play on the OTHER game would boot it
|
-- cached behind it (Data), or pressing Play on the OTHER game would boot it
|
||||||
-- with this one's data.
|
-- with this one's data. Also reset Runtime / Assets / LegacyCompat so the
|
||||||
|
-- next Edit or Play does not inherit the editor's dead mod loader.
|
||||||
function closeEditor()
|
function closeEditor()
|
||||||
local version = editorVersion
|
local version = editorVersion
|
||||||
|
local app = EditorApp
|
||||||
editorMode = false
|
editorMode = false
|
||||||
if EditorApp and EditorApp.unload then EditorApp.unload() end
|
|
||||||
EditorApp = nil
|
EditorApp = nil
|
||||||
if version then
|
SessionLifecycle.endEditorSession({ version = version, app = app })
|
||||||
require("src.import.CacheFs").unmountVersion(version)
|
|
||||||
require("src.core.Data"):unloadGenerated()
|
|
||||||
end
|
|
||||||
for k in pairs(package.loaded) do
|
|
||||||
if type(k) == "string" and (k:find("save%-editor") or k == "App" or k == "Kit" or k == "State" or k == "Catalog" or k == "SaveIO" or k == "Ops" or k == "MonOps" or k == "ItemOps" or k == "PadInput" or k == "Gen" or k == "Theme") then
|
|
||||||
package.loaded[k] = nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
editorVersion = nil
|
editorVersion = nil
|
||||||
restoreWindow()
|
restoreWindow()
|
||||||
Importer = editorHost
|
Importer = editorHost
|
||||||
@@ -291,7 +284,56 @@ function closeSkinStudio()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function bootGame(version)
|
local function makeLauncher()
|
||||||
|
local RomImporter = require("src.import.RomImporter")
|
||||||
|
local forceImport = os.getenv("POKEPORT_FORCE_IMPORT") == "1"
|
||||||
|
return RomImporter.new(function(version, cartId)
|
||||||
|
Importer = nil
|
||||||
|
bootGame(version, cartId)
|
||||||
|
end, {
|
||||||
|
launcher = true,
|
||||||
|
forceImport = forceImport,
|
||||||
|
onEditSave = openEditor,
|
||||||
|
onEditTouchControls = openTouchControlsEditor,
|
||||||
|
-- Skin Studio owns a touch-first layout as well as the desktop workspace.
|
||||||
|
-- Keep the compatibility predicate so external hosts using it still work.
|
||||||
|
onOpenSkinStudio = require("src.ui.SkinStudio").available_desktop()
|
||||||
|
and openSkinStudio or nil,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
local function returnToLauncher()
|
||||||
|
if not Game then return end
|
||||||
|
|
||||||
|
local GameVersion = require("src.core.GameVersion")
|
||||||
|
local currentVersion = GameVersion.get()
|
||||||
|
SessionLifecycle.endGameSession(Game)
|
||||||
|
Game = nil
|
||||||
|
autopilot = nil
|
||||||
|
driverCo = nil
|
||||||
|
-- Leave the cart's scope behind: the launcher's own settings and slots are
|
||||||
|
-- the base game's, not the cart's. The speed ladder is cart state too, so
|
||||||
|
-- a 1x/2x cart must not pin the launcher or the next game.
|
||||||
|
require("src.core.SaveData").setCart(nil)
|
||||||
|
require("src.core.GameSpeed").setAllowed(nil)
|
||||||
|
|
||||||
|
SessionLifecycle.endMountedSession(currentVersion)
|
||||||
|
|
||||||
|
require("src.core.Orientation").applyOptions(
|
||||||
|
require("src.core.SaveData").loadOptions())
|
||||||
|
|
||||||
|
local preload = require("src.mods.LauncherMods").translationStrings()
|
||||||
|
if preload then require("src.core.Strings").load({ strings = preload }) end
|
||||||
|
|
||||||
|
if love.window and love.window.setTitle then
|
||||||
|
local Version = require("src.core.Version")
|
||||||
|
love.window.setTitle(Version.title("Gen 1 Recompilation Project"))
|
||||||
|
end
|
||||||
|
|
||||||
|
Importer = makeLauncher()
|
||||||
|
end
|
||||||
|
|
||||||
|
function bootGame(version, cartId)
|
||||||
-- The launcher hands us the chosen game (Red / Blue / Yellow / Gold);
|
-- The launcher hands us the chosen game (Red / Blue / Yellow / Gold);
|
||||||
-- scripted and headless runs fall back to POKEPORT_VERSION, then Red.
|
-- scripted and headless runs fall back to POKEPORT_VERSION, then Red.
|
||||||
-- Set the active version and overlay its extracted cache BEFORE anything
|
-- Set the active version and overlay its extracted cache BEFORE anything
|
||||||
@@ -304,6 +346,25 @@ function bootGame(version)
|
|||||||
-- (Blue/Yellow/Gold caches live under blue/ / yellow/ / gold/).
|
-- (Blue/Yellow/Gold caches live under blue/ / yellow/ / gold/).
|
||||||
CacheFs.prefix = GameVersion.cachePrefix()
|
CacheFs.prefix = GameVersion.cachePrefix()
|
||||||
CacheFs.mountVersion(GameVersion.get())
|
CacheFs.mountVersion(GameVersion.get())
|
||||||
|
local cartHash, cartSpeeds, cartOptions
|
||||||
|
if cartId then
|
||||||
|
local ok, cart, hash = pcall(function()
|
||||||
|
return require("src.carts.CartStore").get(cartId)
|
||||||
|
end)
|
||||||
|
if ok and cart then
|
||||||
|
cartHash, cartSpeeds, cartOptions = hash, cart.speeds, cart.options
|
||||||
|
else
|
||||||
|
cartId = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
SaveData.setCart(cartId, cartHash)
|
||||||
|
-- The author's settings land in the cart's own scope the first time only;
|
||||||
|
-- after that the player owns them.
|
||||||
|
if cartOptions then SaveData.seedCartOptions(cartOptions) end
|
||||||
|
-- A cart may narrow or pin the speed ladder; nil restores the full one.
|
||||||
|
require("src.core.GameSpeed").setAllowed(cartSpeeds)
|
||||||
|
if cartId then SaveData.adoptCartSeal(cartId) end
|
||||||
-- NX: always write nx-asset-probe.log so Yellow/Blue art failures are
|
-- NX: always write nx-asset-probe.log so Yellow/Blue art failures are
|
||||||
-- diagnosable from the SD without enabling switch-debug.txt.
|
-- diagnosable from the SD without enabling switch-debug.txt.
|
||||||
pcall(function()
|
pcall(function()
|
||||||
@@ -314,11 +375,12 @@ function bootGame(version)
|
|||||||
love.window.setTitle(Version.title(
|
love.window.setTitle(Version.title(
|
||||||
GameVersion.info().displayName .. " (Gen 1 Recompilation Project)"))
|
GameVersion.info().displayName .. " (Gen 1 Recompilation Project)"))
|
||||||
end
|
end
|
||||||
-- Gold: Gen 1 Game:load cannot consume a Gen 2 cache -- different generated
|
-- Gen 2: Gen 1 Game:load cannot consume a Gen 2 cache -- different generated
|
||||||
-- tables, save shape and screen registry -- so Gold boots its own service
|
-- tables, save shape and screen registry -- so Gold and Silver boot their
|
||||||
-- owner, which mounts src/world/gen2 (walk / warps / connections) and the
|
-- own service owner, which mounts src/world/gen2 (walk / warps /
|
||||||
-- Gen 2 screens instead of src/core/Game.lua's Gen 1 wiring.
|
-- connections) and the Gen 2 screens instead of src/core/Game.lua's Gen 1
|
||||||
if GameVersion.isGold() then
|
-- wiring.
|
||||||
|
if GameVersion.generation() == 2 then
|
||||||
Game = require("src.core.Game2").new()
|
Game = require("src.core.Game2").new()
|
||||||
Game:load()
|
Game:load()
|
||||||
else
|
else
|
||||||
@@ -382,7 +444,7 @@ function love.load(args)
|
|||||||
|
|
||||||
-- Apply the persisted Android orientation lock (#592) before the launcher
|
-- Apply the persisted Android orientation lock (#592) before the launcher
|
||||||
-- shows: SDL created the window with no orientation hint, so without this
|
-- shows: SDL created the window with no orientation hint, so without this
|
||||||
-- the launcher would rotate freely until Game:applyOptions runs at boot.
|
-- the launcher would rotate freely until options are applied at boot.
|
||||||
-- No-op on desktop / iOS / when options.lua does not exist yet.
|
-- No-op on desktop / iOS / when options.lua does not exist yet.
|
||||||
require("src.core.Orientation").applyOptions(
|
require("src.core.Orientation").applyOptions(
|
||||||
require("src.core.SaveData").loadOptions())
|
require("src.core.SaveData").loadOptions())
|
||||||
@@ -442,8 +504,8 @@ function love.load(args)
|
|||||||
-- (#767) only pays off if something fills that catalog this early, and no
|
-- (#767) only pays off if something fills that catalog this early, and no
|
||||||
-- restart could: the ordering is the same on every launch. Read the
|
-- restart could: the ordering is the same on every launch. Read the
|
||||||
-- enabled mods' string catalogs -- data only, no entry chunk -- so a
|
-- enabled mods' string catalogs -- data only, no entry chunk -- so a
|
||||||
-- translation reaches the launcher too. Game:load replaces this with the
|
-- translation reaches the launcher too. The active game's loader replaces
|
||||||
-- real merged catalog once a version boots.
|
-- this with the real merged catalog once a version boots.
|
||||||
do
|
do
|
||||||
local preload = require("src.mods.LauncherMods").translationStrings()
|
local preload = require("src.mods.LauncherMods").translationStrings()
|
||||||
if preload then require("src.core.Strings").load({ strings = preload }) end
|
if preload then require("src.core.Strings").load({ strings = preload }) end
|
||||||
@@ -484,17 +546,7 @@ function love.load(args)
|
|||||||
-- by its SHA-1 (GameVersion.forSha1); pressing Play boots that game (Gold
|
-- by its SHA-1 (GameVersion.forSha1); pressing Play boots that game (Gold
|
||||||
-- goes to its own service owner, src/core/Game2.lua -- docs/gold-phase1.md).
|
-- goes to its own service owner, src/core/Game2.lua -- docs/gold-phase1.md).
|
||||||
-- Edit on a save row opens the bundled editor on that slot (openEditor).
|
-- Edit on a save row opens the bundled editor on that slot (openEditor).
|
||||||
Importer = RomImporter.new(function(version)
|
Importer = makeLauncher()
|
||||||
Importer = nil
|
|
||||||
bootGame(version)
|
|
||||||
end, {
|
|
||||||
launcher = true,
|
|
||||||
forceImport = forceImport,
|
|
||||||
onEditSave = openEditor,
|
|
||||||
onEditTouchControls = openTouchControlsEditor,
|
|
||||||
onOpenSkinStudio = require("src.ui.SkinStudio").available_desktop()
|
|
||||||
and openSkinStudio or nil,
|
|
||||||
})
|
|
||||||
end
|
end
|
||||||
|
|
||||||
function love.update(dt)
|
function love.update(dt)
|
||||||
@@ -628,7 +680,7 @@ function love.gamepadpressed(joystick, button)
|
|||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if Studio then return end
|
if Studio then return Studio.gamepadpressed(joystick, button) end
|
||||||
if Importer then return Importer:gamepadpressed(joystick, button) end
|
if Importer then return Importer:gamepadpressed(joystick, button) end
|
||||||
if not Game then return end
|
if not Game then return end
|
||||||
Game:gamepadpressed(joystick, button)
|
Game:gamepadpressed(joystick, button)
|
||||||
@@ -648,7 +700,7 @@ function love.gamepadreleased(joystick, button)
|
|||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if Studio then return end
|
if Studio then return Studio.gamepadreleased(joystick, button) end
|
||||||
if Importer then return Importer:gamepadreleased(joystick, button) end
|
if Importer then return Importer:gamepadreleased(joystick, button) end
|
||||||
if not Game then return end
|
if not Game then return end
|
||||||
Game:gamepadreleased(joystick, button)
|
Game:gamepadreleased(joystick, button)
|
||||||
@@ -668,7 +720,7 @@ function love.gamepadaxis(joystick, axis, value)
|
|||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if Studio then return end
|
if Studio then return Studio.gamepadaxis(joystick, axis, value) end
|
||||||
if Importer then return Importer:gamepadaxis(joystick, axis, value) end
|
if Importer then return Importer:gamepadaxis(joystick, axis, value) end
|
||||||
if not Game then return end
|
if not Game then return end
|
||||||
Game:gamepadaxis(joystick, axis, value)
|
Game:gamepadaxis(joystick, axis, value)
|
||||||
@@ -688,7 +740,7 @@ function love.joystickpressed(joystick, button)
|
|||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if Studio then return end
|
if Studio then return Studio.joystickpressed(joystick, button) end
|
||||||
if Importer then return Importer:joystickpressed(joystick, button) end
|
if Importer then return Importer:joystickpressed(joystick, button) end
|
||||||
if not Game then return end
|
if not Game then return end
|
||||||
Game:joystickpressed(joystick, button)
|
Game:joystickpressed(joystick, button)
|
||||||
@@ -708,7 +760,7 @@ function love.joystickreleased(joystick, button)
|
|||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if Studio then return end
|
if Studio then return Studio.joystickreleased(joystick, button) end
|
||||||
if Importer then return Importer:joystickreleased(joystick, button) end
|
if Importer then return Importer:joystickreleased(joystick, button) end
|
||||||
if not Game then return end
|
if not Game then return end
|
||||||
Game:joystickreleased(joystick, button)
|
Game:joystickreleased(joystick, button)
|
||||||
@@ -728,7 +780,7 @@ function love.joystickaxis(joystick, axis, value)
|
|||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if Studio then return end
|
if Studio then return Studio.joystickaxis(joystick, axis, value) end
|
||||||
if Importer then return Importer:joystickaxis(joystick, axis, value) end
|
if Importer then return Importer:joystickaxis(joystick, axis, value) end
|
||||||
if not Game then return end
|
if not Game then return end
|
||||||
Game:joystickaxis(joystick, axis, value)
|
Game:joystickaxis(joystick, axis, value)
|
||||||
@@ -748,7 +800,7 @@ function love.joystickhat(joystick, hat, direction)
|
|||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if Studio then return end
|
if Studio then return Studio.joystickhat(joystick, hat, direction) end
|
||||||
if Importer then return Importer:joystickhat(joystick, hat, direction) end
|
if Importer then return Importer:joystickhat(joystick, hat, direction) end
|
||||||
if not Game then return end
|
if not Game then return end
|
||||||
Game:joystickhat(joystick, hat, direction)
|
Game:joystickhat(joystick, hat, direction)
|
||||||
@@ -813,6 +865,8 @@ love.handlers = love.handlers or {}
|
|||||||
function love.handlers.audiosuspend()
|
function love.handlers.audiosuspend()
|
||||||
local ChipAudio = package.loaded["src.core.ChipAudio"]
|
local ChipAudio = package.loaded["src.core.ChipAudio"]
|
||||||
if ChipAudio then pcall(ChipAudio.setSuspended, true) end
|
if ChipAudio then pcall(ChipAudio.setSuspended, true) end
|
||||||
|
local Sound = package.loaded["src.core.Sound"]
|
||||||
|
if Sound then pcall(Sound.onDeviceReset) end
|
||||||
end
|
end
|
||||||
|
|
||||||
function love.handlers.audioreset()
|
function love.handlers.audioreset()
|
||||||
@@ -827,6 +881,27 @@ function love.handlers.audioreset()
|
|||||||
if Sound then pcall(Sound.onDeviceReset) end
|
if Sound then pcall(Sound.onDeviceReset) end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function love.handlers.intent_game(version)
|
||||||
|
if type(version) ~= "string" or version == "" then return end
|
||||||
|
version = version:lower():gsub("^%s+", ""):gsub("%s+$", "")
|
||||||
|
local GameVersion = require("src.core.GameVersion")
|
||||||
|
if GameVersion.VERSIONS and not GameVersion.VERSIONS[version] then return end
|
||||||
|
|
||||||
|
local RomImporter = require("src.import.RomImporter")
|
||||||
|
if not RomImporter.isReady(version) then return end
|
||||||
|
|
||||||
|
local currentVersion = GameVersion.get()
|
||||||
|
if Game and currentVersion == version then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if Game then
|
||||||
|
returnToLauncher()
|
||||||
|
end
|
||||||
|
Importer = nil
|
||||||
|
bootGame(version)
|
||||||
|
end
|
||||||
|
|
||||||
function love.touchpressed(id, x, y, dx, dy, pressure)
|
function love.touchpressed(id, x, y, dx, dy, pressure)
|
||||||
if editorMode then
|
if editorMode then
|
||||||
-- iOS synthesizes mousepressed for the primary touch; forwarding here
|
-- iOS synthesizes mousepressed for the primary touch; forwarding here
|
||||||
@@ -844,7 +919,7 @@ function love.touchpressed(id, x, y, dx, dy, pressure)
|
|||||||
if love.system.getOS() == "iOS" then return end
|
if love.system.getOS() == "iOS" then return end
|
||||||
return TouchEditor.touchpressed(id, x, y)
|
return TouchEditor.touchpressed(id, x, y)
|
||||||
end
|
end
|
||||||
if Studio then return end
|
if Studio then return Studio.touchpressed(id, x, y) end
|
||||||
if Importer then
|
if Importer then
|
||||||
-- Both mobiles: FlexLove scroll needs the real touch stream. Clicks are
|
-- Both mobiles: FlexLove scroll needs the real touch stream. Clicks are
|
||||||
-- polled inside the view; the istouch filter on mousepressed still drops
|
-- polled inside the view; the istouch filter on mousepressed still drops
|
||||||
@@ -861,7 +936,7 @@ function love.touchmoved(id, x, y, dx, dy, pressure)
|
|||||||
if love.system.getOS() == "iOS" then return end
|
if love.system.getOS() == "iOS" then return end
|
||||||
return TouchEditor.touchmoved(id, x, y)
|
return TouchEditor.touchmoved(id, x, y)
|
||||||
end
|
end
|
||||||
if Studio then return end
|
if Studio then return Studio.touchmoved(id, x, y) end
|
||||||
if Importer then
|
if Importer then
|
||||||
return Importer:touchmoved(id, x, y, dx, dy, pressure)
|
return Importer:touchmoved(id, x, y, dx, dy, pressure)
|
||||||
end
|
end
|
||||||
@@ -875,7 +950,7 @@ function love.touchreleased(id, x, y, dx, dy, pressure)
|
|||||||
if love.system.getOS() == "iOS" then return end
|
if love.system.getOS() == "iOS" then return end
|
||||||
return TouchEditor.touchreleased(id, x, y)
|
return TouchEditor.touchreleased(id, x, y)
|
||||||
end
|
end
|
||||||
if Studio then return end
|
if Studio then return Studio.touchreleased(id, x, y) end
|
||||||
if Importer then
|
if Importer then
|
||||||
return Importer:touchreleased(id, x, y, dx, dy, pressure)
|
return Importer:touchreleased(id, x, y, dx, dy, pressure)
|
||||||
end
|
end
|
||||||
@@ -928,7 +1003,12 @@ function love.mousepressed(x, y, button, istouch)
|
|||||||
if love.system.getOS() == "Android" then return end
|
if love.system.getOS() == "Android" then return end
|
||||||
return TouchEditor.mousepressed(x, y, button)
|
return TouchEditor.mousepressed(x, y, button)
|
||||||
end
|
end
|
||||||
if Studio then return Studio.mousepressed(x, y, button) end
|
if Studio then
|
||||||
|
-- Mobile LÖVE sends both a touch event and an `istouch` mouse twin.
|
||||||
|
-- Studio consumes the real finger stream above, so discard the twin.
|
||||||
|
if istouch and (love.system.getOS() == "Android" or love.system.getOS() == "iOS") then return end
|
||||||
|
return Studio.mousepressed(x, y, button)
|
||||||
|
end
|
||||||
if Importer then
|
if Importer then
|
||||||
-- love.touchpressed already forwards the primary touch into FlexLove for
|
-- love.touchpressed already forwards the primary touch into FlexLove for
|
||||||
-- scroll. LÖVE ALSO synthesizes a mouse press for that same touch; if both
|
-- scroll. LÖVE ALSO synthesizes a mouse press for that same touch; if both
|
||||||
@@ -963,7 +1043,10 @@ function love.mousereleased(x, y, button, istouch)
|
|||||||
if love.system.getOS() == "Android" then return end
|
if love.system.getOS() == "Android" then return end
|
||||||
return TouchEditor.mousereleased(x, y, button)
|
return TouchEditor.mousereleased(x, y, button)
|
||||||
end
|
end
|
||||||
if Studio then return Studio.mousereleased(x, y, button) end
|
if Studio then
|
||||||
|
if istouch and (love.system.getOS() == "Android" or love.system.getOS() == "iOS") then return end
|
||||||
|
return Studio.mousereleased(x, y, button)
|
||||||
|
end
|
||||||
if Importer then return end
|
if Importer then return end
|
||||||
if editorMode and EditorApp.mousereleased then
|
if editorMode and EditorApp.mousereleased then
|
||||||
return EditorApp.mousereleased(x, y, button)
|
return EditorApp.mousereleased(x, y, button)
|
||||||
@@ -981,7 +1064,10 @@ function love.mousemoved(x, y, dx, dy, istouch)
|
|||||||
if love.system.getOS() == "Android" then return end
|
if love.system.getOS() == "Android" then return end
|
||||||
return TouchEditor.mousemoved(x, y)
|
return TouchEditor.mousemoved(x, y)
|
||||||
end
|
end
|
||||||
if Studio then return Studio.mousemoved(x, y) end
|
if Studio then
|
||||||
|
if istouch and (love.system.getOS() == "Android" or love.system.getOS() == "iOS") then return end
|
||||||
|
return Studio.mousemoved(x, y)
|
||||||
|
end
|
||||||
if editorMode or Importer then return end
|
if editorMode or Importer then return end
|
||||||
if mouseTouch then
|
if mouseTouch then
|
||||||
if Game and love.mouse.isDown(1) then Game:touchmoved("mouse", x, y) end
|
if Game and love.mouse.isDown(1) then Game:touchmoved("mouse", x, y) end
|
||||||
@@ -1032,11 +1118,16 @@ function love.quit()
|
|||||||
-- docs/modding.md's core.quit_to_launcher entry) may veto returning to
|
-- docs/modding.md's core.quit_to_launcher entry) may veto returning to
|
||||||
-- this Lua launcher via that hook. Vanilla behavior (used when no mod
|
-- this Lua launcher via that hook. Vanilla behavior (used when no mod
|
||||||
-- claims the hook) is exactly the condition below.
|
-- claims the hook) is exactly the condition below.
|
||||||
|
local isAndroid = (love.system and love.system.getOS and love.system.getOS() == "Android")
|
||||||
local wouldReturnToLauncher = PlatformHooks.quitToLauncher(function()
|
local wouldReturnToLauncher = PlatformHooks.quitToLauncher(function()
|
||||||
return Game and not Importer and not quitToLauncher and not scripted
|
return Game and not Importer and not quitToLauncher and not scripted
|
||||||
and not launchedIntoGame
|
and (isAndroid or not launchedIntoGame)
|
||||||
end)
|
end)
|
||||||
if wouldReturnToLauncher then
|
if wouldReturnToLauncher then
|
||||||
|
if isAndroid then
|
||||||
|
returnToLauncher()
|
||||||
|
return true -- abort this quit; the restart lands back in the launcher
|
||||||
|
end
|
||||||
quitToLauncher = true
|
quitToLauncher = true
|
||||||
-- Tell the fresh boot to ignore any boot-straight-into-a-game option this
|
-- Tell the fresh boot to ignore any boot-straight-into-a-game option this
|
||||||
-- once, so the restart really does land in the launcher (#887). A failed
|
-- once, so the restart really does land in the launcher (#887). A failed
|
||||||
@@ -1048,22 +1139,7 @@ function love.quit()
|
|||||||
pcall(function()
|
pcall(function()
|
||||||
require("src.core.DiscordPresence").shutdown()
|
require("src.core.DiscordPresence").shutdown()
|
||||||
end)
|
end)
|
||||||
-- LOVE waits for every live love.thread before the process exits, and both
|
SessionLifecycle.endProcess()
|
||||||
-- background workers idle in a loop that only a "quit" command breaks, so
|
|
||||||
-- without this the process outlived the window and the next launch re-entered
|
|
||||||
-- the dead one instead of starting fresh (#339)
|
|
||||||
if package.loaded["src.core.ChipAudio"] then
|
|
||||||
pcall(package.loaded["src.core.ChipAudio"].shutdown)
|
|
||||||
end
|
|
||||||
if package.loaded["src.update.Check"] then
|
|
||||||
pcall(package.loaded["src.update.Check"].shutdown)
|
|
||||||
end
|
|
||||||
-- The launcher's fetch pool is the same story: its workers idle in
|
|
||||||
-- Channel:demand(), which never returns on its own, so a launcher that ever
|
|
||||||
-- touched the network would hang the process on exit (#339's shape again).
|
|
||||||
if package.loaded["src.net.Fetch"] then
|
|
||||||
pcall(package.loaded["src.net.Fetch"].shutdown)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
function love.filedropped(file)
|
function love.filedropped(file)
|
||||||
|
|||||||
@@ -93,8 +93,9 @@ transport, exactly as a missing curl does.
|
|||||||
love-android 11.5a expects:
|
love-android 11.5a expects:
|
||||||
|
|
||||||
- **JDK 17**
|
- **JDK 17**
|
||||||
- Android SDK with **API 34**
|
- Android SDK with **API 36** (Android 16; latest 36.x Build-Tools)
|
||||||
- NDK **25.2.9519653** (Apple Silicon host supported)
|
- NDK **25.2.9519653** (Apple Silicon host supported)
|
||||||
|
- **minSdk 19** (Android 4.4), **targetSdk 36** (Android 16)
|
||||||
|
|
||||||
Set `ANDROID_SDK_ROOT` (or `ANDROID_HOME`), or let the script write
|
Set `ANDROID_SDK_ROOT` (or `ANDROID_HOME`), or let the script write
|
||||||
`local.properties` when it finds `~/Library/Android/sdk`.
|
`local.properties` when it finds `~/Library/Android/sdk`.
|
||||||
@@ -109,10 +110,10 @@ The APK lands under `app/build/outputs/apk/embedNoRecord/debug/`.
|
|||||||
|
|
||||||
`app/src/embed/assets/game.love` - zip of `main.lua`, `conf.lua`, `src/`,
|
`app/src/embed/assets/game.love` - zip of `main.lua`, `conf.lua`, `src/`,
|
||||||
`libs/` (the vendored FlexLove toolkit the launcher UI needs), `data/`,
|
`libs/` (the vendored FlexLove toolkit the launcher UI needs), `data/`,
|
||||||
`assets/`, and the Red, Blue, and Yellow ROM manifests. The Android
|
`assets/`, and the Red, Blue, Yellow, Gold, and Silver ROM manifests. The
|
||||||
packer verifies the Yellow manifest before it packages; if a partial source
|
Android packer verifies the Yellow, Gold, and Silver manifests before it
|
||||||
export omitted it, it restores the file from this checkout's Git data and then
|
packages; if a partial source export omitted one, it restores the file from
|
||||||
falls back to the project's GitHub copy. Generated game data,
|
this checkout's Git data and then falls back to the project's GitHub copy. Generated game data,
|
||||||
scripts, tests, and mobile build sources are excluded.
|
scripts, tests, and mobile build sources are excluded.
|
||||||
|
|
||||||
## Branding (applied by the build script)
|
## Branding (applied by the build script)
|
||||||
@@ -122,15 +123,24 @@ scripts, tests, and mobile build sources are excluded.
|
|||||||
| `app.application_id` | `com.theboisclub.pokemonred` |
|
| `app.application_id` | `com.theboisclub.pokemonred` |
|
||||||
| `app.name` | Pokemon Red |
|
| `app.name` | Pokemon Red |
|
||||||
| `app.orientation` | `fullUser`. This is only the manifest default: SDL requests FULL_SENSOR at window creation (resizable window, no `SDL_HINT_ORIENTATIONS`), and `GameActivity.setOrientationBis` remaps that to FULL_USER so the device's rotation lock is honoured. |
|
| `app.orientation` | `fullUser`. This is only the manifest default: SDL requests FULL_SENSOR at window creation (resizable window, no `SDL_HINT_ORIENTATIONS`), and `GameActivity.setOrientationBis` remaps that to FULL_USER so the device's rotation lock is honoured. |
|
||||||
| `app.version_name` / `app.version_code` | set from `--version X.Y.Z` (code = major*10000 + minor*100 + patch); left as-is if `--version` is omitted |
|
| `app.version_name` / `app.version_code` | set from `--version X.Y.Z` (code = major*1,000,000 + minor*1,000 + patch); left as-is if `--version` is omitted |
|
||||||
| Permissions | RECORD_AUDIO / WRITE_EXTERNAL_STORAGE stripped; VIBRATE + BLUETOOTH + INTERNET (link play, mod index) + ACTIVITY_RECOGNITION (step bridge) kept |
|
| Permissions | RECORD_AUDIO / WRITE_EXTERNAL_STORAGE stripped; VIBRATE + BLUETOOTH + INTERNET (link play, mod index) + ACTIVITY_RECOGNITION (step bridge) kept; REQUEST_INSTALL_PACKAGES is limited to the user-confirmed full-update installer |
|
||||||
|
|
||||||
## Releases
|
## Releases
|
||||||
|
|
||||||
`.github/workflows/release.yml` builds the APK with `--version` set to the
|
`.github/workflows/release.yml` builds the APK with `--version` set to the
|
||||||
release version and publishes it alongside the macOS/Windows/Linux builds as
|
release version and publishes it alongside the macOS/Windows/Linux builds as
|
||||||
`PokemonRed-<version>-android.apk`.
|
`gen1recomp-<version>-android.apk`.
|
||||||
|
|
||||||
## Signing
|
## Signing
|
||||||
|
|
||||||
Signed with the default Android keystore (no setup required).
|
Production APKs are built with `scripts/build_android.sh --release`. They must
|
||||||
|
be signed with the same long-lived certificate as the currently installed app:
|
||||||
|
Android's Package Installer rejects an update with a different signing
|
||||||
|
certificate. Store that keystore and its passwords only in CI secrets, expose
|
||||||
|
them as `GEN1RECOMP_ANDROID_KEYSTORE`,
|
||||||
|
`GEN1RECOMP_ANDROID_KEYSTORE_PASSWORD`, `GEN1RECOMP_ANDROID_KEY_ALIAS`, and
|
||||||
|
`GEN1RECOMP_ANDROID_KEY_PASSWORD`, and never commit the keystore. A newly
|
||||||
|
created certificate cannot update users who have an APK signed by a different
|
||||||
|
legacy key; those users need one final manual reinstall before in-app updates
|
||||||
|
can take over.
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ Quick Start:
|
|||||||
Before you start, install JDK 17 (not later not earlier). If you intend to build from Android Studio, skip this step as
|
Before you start, install JDK 17 (not later not earlier). If you intend to build from Android Studio, skip this step as
|
||||||
Android Studio bundles its own JDK 17.
|
Android Studio bundles its own JDK 17.
|
||||||
|
|
||||||
Install Android SDK with SDK API 34 (34.x.y) and Android NDK 25.2.9519653, set the environment variable
|
Install Android SDK with SDK API 36 (latest 36.x Build-Tools) and Android NDK 25.2.9519653, set the environment variable
|
||||||
`ANDROID_SDK_ROOT` to your Android SDK location and run:
|
`ANDROID_SDK_ROOT` to your Android SDK location and run:
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -10,9 +10,12 @@ android {
|
|||||||
applicationId project.properties["app.application_id"]
|
applicationId project.properties["app.application_id"]
|
||||||
versionCode project.properties["app.version_code"].toInteger()
|
versionCode project.properties["app.version_code"].toInteger()
|
||||||
versionName project.properties["app.version_name"]
|
versionName project.properties["app.version_name"]
|
||||||
minSdk 16
|
// NDK r25 no longer supports API 16; API 19 is Android 4.4 and keeps
|
||||||
compileSdk 34
|
// the native toolchain and package-installer bridge on a supported ABI.
|
||||||
targetSdk 34
|
minSdk 19
|
||||||
|
// Android 16 / API 36: current Android distribution target.
|
||||||
|
compileSdk 36
|
||||||
|
targetSdk 36
|
||||||
|
|
||||||
def getAppName = {
|
def getAppName = {
|
||||||
def nameArray = project.properties["app.name_byte_array"]
|
def nameArray = project.properties["app.name_byte_array"]
|
||||||
@@ -38,10 +41,31 @@ android {
|
|||||||
ORIENTATION:project.properties["app.orientation"],
|
ORIENTATION:project.properties["app.orientation"],
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
// Release signing lives outside the repository. The release build script
|
||||||
|
// requires all five values below, while debug builds intentionally remain
|
||||||
|
// usable without them.
|
||||||
|
def releaseStore = System.getenv("GEN1RECOMP_ANDROID_KEYSTORE")
|
||||||
|
def releaseStorePassword = System.getenv("GEN1RECOMP_ANDROID_KEYSTORE_PASSWORD")
|
||||||
|
def releaseKeyAlias = System.getenv("GEN1RECOMP_ANDROID_KEY_ALIAS")
|
||||||
|
def releaseKeyPassword = System.getenv("GEN1RECOMP_ANDROID_KEY_PASSWORD")
|
||||||
|
def hasReleaseSigning = releaseStore && releaseStorePassword && releaseKeyAlias && releaseKeyPassword
|
||||||
|
|
||||||
|
if (hasReleaseSigning) {
|
||||||
|
signingConfigs {
|
||||||
|
release {
|
||||||
|
storeFile file(releaseStore)
|
||||||
|
storePassword releaseStorePassword
|
||||||
|
keyAlias releaseKeyAlias
|
||||||
|
keyPassword releaseKeyPassword
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
minifyEnabled true
|
minifyEnabled true
|
||||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||||
|
if (hasReleaseSigning) signingConfig signingConfigs.release
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
flavorDimensions = ['mode', 'recording']
|
flavorDimensions = ['mode', 'recording']
|
||||||
|
|||||||
@@ -8,6 +8,10 @@
|
|||||||
the link screen shows as "(Operation not permitted)" (issue #287).
|
the link screen shows as "(Operation not permitted)" (issue #287).
|
||||||
scripts/build_android.sh must not strip this again. -->
|
scripts/build_android.sh must not strip this again. -->
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<!-- Required only to hand a checksum-verified, user-selected GitHub release
|
||||||
|
APK to Android's own Package Installer. Android still shows the install
|
||||||
|
confirmation and enforces package/signing-key/version compatibility. -->
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
<!-- Step bridge: love.system.syncHealthSteps reads the hardware step
|
<!-- Step bridge: love.system.syncHealthSteps reads the hardware step
|
||||||
counter, which Android 10+ gates behind this runtime permission.
|
counter, which Android 10+ gates behind this runtime permission.
|
||||||
Requested only on the first sync call (the Pokéwalker mod's SYNC
|
Requested only on the first sync call (the Pokéwalker mod's SYNC
|
||||||
@@ -26,20 +30,37 @@
|
|||||||
<!-- Low latency audio -->
|
<!-- Low latency audio -->
|
||||||
<uses-feature android:name="android.hardware.audio.low_latency" android:required="false" />
|
<uses-feature android:name="android.hardware.audio.low_latency" android:required="false" />
|
||||||
<uses-feature android:name="android.hardware.audio.pro" android:required="false" />
|
<uses-feature android:name="android.hardware.audio.pro" android:required="false" />
|
||||||
|
<!-- love.sensor (accelerometer). No runtime permission needed on Android;
|
||||||
|
this is a Play Store filtering hint only, so required=false like the
|
||||||
|
other optional hardware features above. -->
|
||||||
|
<uses-feature android:name="android.hardware.sensor.accelerometer" android:required="false" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:icon="@drawable/love"
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:label="${NAME}" >
|
android:label="${NAME}" >
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="android.allow_multiple_resumed_activities"
|
android:name="android.allow_multiple_resumed_activities"
|
||||||
android:value="true" />
|
android:value="true" />
|
||||||
|
<!-- The full-update APK is copied into this small cache subdirectory
|
||||||
|
before it is handed to Package Installer. Keep the provider private
|
||||||
|
and expose only that directory, never a storage root. -->
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.full_update_provider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/full_update_paths" />
|
||||||
|
</provider>
|
||||||
<activity
|
<activity
|
||||||
android:name="org.love2d.android.GameActivity"
|
android:name="org.love2d.android.GameActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation"
|
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation|uiMode|density|fontScale|locale|layoutDirection|colorMode"
|
||||||
android:label="${NAME}"
|
android:label="${NAME}"
|
||||||
android:launchMode="singleInstance"
|
android:launchMode="singleTask"
|
||||||
android:screenOrientation="${ORIENTATION}"
|
android:screenOrientation="${ORIENTATION}"
|
||||||
android:resizeableActivity="false"
|
android:resizeableActivity="false"
|
||||||
android:theme="@android:style/Theme.NoTitleBar.Fullscreen" >
|
android:theme="@android:style/Theme.NoTitleBar.Fullscreen" >
|
||||||
@@ -54,7 +75,7 @@
|
|||||||
</activity>
|
</activity>
|
||||||
<activity
|
<activity
|
||||||
android:name="org.love2d.android.GameActivity$SecondaryActivity"
|
android:name="org.love2d.android.GameActivity$SecondaryActivity"
|
||||||
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation"
|
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation|uiMode|density|fontScale|locale|layoutDirection|colorMode"
|
||||||
android:excludeFromRecents="true"
|
android:excludeFromRecents="true"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:launchMode="singleTask"
|
android:launchMode="singleTask"
|
||||||
|
|||||||
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 32 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
@@ -3,4 +3,10 @@
|
|||||||
<color name="colorPrimary">#3F51B5</color>
|
<color name="colorPrimary">#3F51B5</color>
|
||||||
<color name="colorPrimaryDark">#303F9F</color>
|
<color name="colorPrimaryDark">#303F9F</color>
|
||||||
<color name="colorAccent">#FF4081</color>
|
<color name="colorAccent">#FF4081</color>
|
||||||
|
<color name="ic_launcher_background">#FFFFFF</color>
|
||||||
|
<color name="shortcut_red">#E53935</color>
|
||||||
|
<color name="shortcut_blue">#1E88E5</color>
|
||||||
|
<color name="shortcut_yellow">#FDD835</color>
|
||||||
|
<color name="shortcut_gold">#D4AF37</color>
|
||||||
|
<color name="shortcut_silver">#BEC6D2</color>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Deliberately narrow: FileProvider may grant only the staged APK, never
|
||||||
|
arbitrary app, external, or shared storage. -->
|
||||||
|
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<cache-path name="full_update" path="full-update/" />
|
||||||
|
</paths>
|
||||||
@@ -18,7 +18,8 @@ buildscript {
|
|||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
dependencies {
|
dependencies {
|
||||||
classpath 'com.android.tools.build:gradle:8.1.1'
|
// Android 16 / API 36 requires Android Gradle Plugin 8.9+.
|
||||||
|
classpath 'com.android.tools.build:gradle:8.9.2'
|
||||||
|
|
||||||
// NOTE: Do not place your application dependencies here; they belong
|
// NOTE: Do not place your application dependencies here; they belong
|
||||||
// in the individual module build.gradle files
|
// in the individual module build.gradle files
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ app.version_name=11.5a
|
|||||||
# No need to modify anything past this line!
|
# No need to modify anything past this line!
|
||||||
android.enableJetifier=false
|
android.enableJetifier=false
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
android.defaults.buildfeatures.buildconfig=true
|
|
||||||
android.nonTransitiveRClass=true
|
android.nonTransitiveRClass=true
|
||||||
android.nonFinalResIds=true
|
android.nonFinalResIds=true
|
||||||
app.name=gen1recomp
|
app.name=gen1recomp
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.1-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
|
||||||
networkTimeout=10000
|
networkTimeout=10000
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ android {
|
|||||||
ndkVersion '25.2.9519653'
|
ndkVersion '25.2.9519653'
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
minSdk 16
|
minSdk 19
|
||||||
compileSdk 34
|
compileSdk 36
|
||||||
targetSdk 34
|
targetSdk 36
|
||||||
externalNativeBuild {
|
externalNativeBuild {
|
||||||
ndkBuild {
|
ndkBuild {
|
||||||
arguments "-j" + Runtime.runtime.availableProcessors()
|
arguments "-j" + Runtime.runtime.availableProcessors()
|
||||||
@@ -63,8 +63,7 @@ android {
|
|||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
minifyEnabled true
|
minifyEnabled false
|
||||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
|
||||||
}
|
}
|
||||||
debug {
|
debug {
|
||||||
ndk {
|
ndk {
|
||||||
|
|||||||
@@ -48,6 +48,7 @@
|
|||||||
#include "common/Module.h"
|
#include "common/Module.h"
|
||||||
#include "audio/Audio.h"
|
#include "audio/Audio.h"
|
||||||
#include "audio/openal/Audio.h"
|
#include "audio/openal/Audio.h"
|
||||||
|
#include "event/Event.h"
|
||||||
|
|
||||||
namespace love
|
namespace love
|
||||||
{
|
{
|
||||||
@@ -282,6 +283,104 @@ bool restartApp()
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool installApk(const char *path)
|
||||||
|
{
|
||||||
|
if (path == nullptr || path[0] == '\0')
|
||||||
|
return false;
|
||||||
|
|
||||||
|
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||||
|
// This may be called from Lua's main thread, but use the activity object
|
||||||
|
// class just like httpDownload so a future worker caller does not depend on
|
||||||
|
// the system JNI class loader finding the app class.
|
||||||
|
void *rawActivity = SDL_AndroidGetActivity();
|
||||||
|
if (rawActivity == nullptr)
|
||||||
|
return false;
|
||||||
|
jobject activityObj = (jobject) rawActivity;
|
||||||
|
jclass activity = env->GetObjectClass(activityObj);
|
||||||
|
env->DeleteLocalRef(activityObj);
|
||||||
|
|
||||||
|
jmethodID method = env->GetStaticMethodID(activity, "installApk",
|
||||||
|
"(Ljava/lang/String;Ljava/lang/String;)Z");
|
||||||
|
if (method == nullptr)
|
||||||
|
{
|
||||||
|
env->ExceptionClear();
|
||||||
|
env->DeleteLocalRef(activity);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
jstring jpath = env->NewStringUTF(path);
|
||||||
|
jstring jroot = env->NewStringUTF(bridgeSaveDirectory());
|
||||||
|
jboolean result = env->CallStaticBooleanMethod(activity, method, jpath, jroot);
|
||||||
|
env->DeleteLocalRef(jroot);
|
||||||
|
env->DeleteLocalRef(jpath);
|
||||||
|
env->DeleteLocalRef(activity);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool updateAppShortcuts(const std::vector<std::string> &versions)
|
||||||
|
{
|
||||||
|
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||||
|
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
||||||
|
if (activity == nullptr)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
jmethodID method = env->GetStaticMethodID(activity, "updateAppShortcuts", "([Ljava/lang/String;)Z");
|
||||||
|
if (method == nullptr)
|
||||||
|
{
|
||||||
|
env->ExceptionClear();
|
||||||
|
env->DeleteLocalRef(activity);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
jclass stringClass = env->FindClass("java/lang/String");
|
||||||
|
jobjectArray array = env->NewObjectArray((jsize) versions.size(), stringClass, nullptr);
|
||||||
|
for (size_t i = 0; i < versions.size(); ++i)
|
||||||
|
{
|
||||||
|
jstring jstr = env->NewStringUTF(versions[i].c_str());
|
||||||
|
env->SetObjectArrayElement(array, (jsize) i, jstr);
|
||||||
|
env->DeleteLocalRef(jstr);
|
||||||
|
}
|
||||||
|
|
||||||
|
jboolean result = env->CallStaticBooleanMethod(activity, method, array);
|
||||||
|
|
||||||
|
env->DeleteLocalRef(array);
|
||||||
|
env->DeleteLocalRef(stringClass);
|
||||||
|
env->DeleteLocalRef(activity);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string getLaunchGame()
|
||||||
|
{
|
||||||
|
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||||
|
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
||||||
|
if (activity == nullptr)
|
||||||
|
return "";
|
||||||
|
|
||||||
|
jmethodID method = env->GetStaticMethodID(activity, "getLaunchGame", "()Ljava/lang/String;");
|
||||||
|
if (method == nullptr)
|
||||||
|
{
|
||||||
|
env->ExceptionClear();
|
||||||
|
env->DeleteLocalRef(activity);
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
jstring jgame = (jstring) env->CallStaticObjectMethod(activity, method);
|
||||||
|
if (jgame == nullptr)
|
||||||
|
{
|
||||||
|
env->DeleteLocalRef(activity);
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const char *str = env->GetStringUTFChars(jgame, nullptr);
|
||||||
|
std::string result = (str != nullptr) ? str : "";
|
||||||
|
if (str != nullptr)
|
||||||
|
env->ReleaseStringUTFChars(jgame, str);
|
||||||
|
|
||||||
|
env->DeleteLocalRef(jgame);
|
||||||
|
env->DeleteLocalRef(activity);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
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)
|
||||||
{
|
{
|
||||||
if (url == nullptr || destPath == nullptr)
|
if (url == nullptr || destPath == nullptr)
|
||||||
@@ -378,6 +477,104 @@ bool httpPost(const char *url, const char *body, int bodyLen, const char *conten
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool httpRequest(const char *url, const char *method,
|
||||||
|
const char *const *headerPairs, int headerPairCount,
|
||||||
|
const char *body, int bodyLen, const char *userAgent, std::string &out)
|
||||||
|
{
|
||||||
|
out.clear();
|
||||||
|
if (url == nullptr)
|
||||||
|
return false;
|
||||||
|
if (headerPairCount < 0 || (headerPairCount > 0 && headerPairs == nullptr))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||||
|
// Same resolution rule as httpDownload: the activity's own class via
|
||||||
|
// SDL_AndroidGetActivity, never FindClass for an app class -- save sync
|
||||||
|
// runs on a love.thread worker, whose class loader cannot see them.
|
||||||
|
jobject activityObj = (jobject) SDL_AndroidGetActivity();
|
||||||
|
if (activityObj == nullptr)
|
||||||
|
return false;
|
||||||
|
jclass activity = env->GetObjectClass(activityObj);
|
||||||
|
env->DeleteLocalRef(activityObj);
|
||||||
|
|
||||||
|
// Old APK / new liblove skew: report "no transport" instead of aborting
|
||||||
|
// on a missing method (#597).
|
||||||
|
jmethodID method_id = env->GetStaticMethodID(activity, "httpRequest",
|
||||||
|
"(Ljava/lang/String;Ljava/lang/String;[Ljava/lang/String;[BLjava/lang/String;)[B");
|
||||||
|
if (method_id == nullptr)
|
||||||
|
{
|
||||||
|
env->ExceptionClear();
|
||||||
|
env->DeleteLocalRef(activity);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
jobjectArray jheaders = nullptr;
|
||||||
|
if (headerPairCount > 0)
|
||||||
|
{
|
||||||
|
// java/lang/String, unlike an app class, resolves from any thread.
|
||||||
|
jclass stringClass = env->FindClass("java/lang/String");
|
||||||
|
if (stringClass == nullptr)
|
||||||
|
{
|
||||||
|
env->ExceptionClear();
|
||||||
|
env->DeleteLocalRef(activity);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
jheaders = env->NewObjectArray((jsize) headerPairCount, stringClass, nullptr);
|
||||||
|
env->DeleteLocalRef(stringClass);
|
||||||
|
if (jheaders == nullptr)
|
||||||
|
{
|
||||||
|
env->ExceptionClear();
|
||||||
|
env->DeleteLocalRef(activity);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (int i = 0; i < headerPairCount; i++)
|
||||||
|
{
|
||||||
|
jstring field = env->NewStringUTF(headerPairs[i] != nullptr ? headerPairs[i] : "");
|
||||||
|
env->SetObjectArrayElement(jheaders, (jsize) i, field);
|
||||||
|
if (field != nullptr)
|
||||||
|
env->DeleteLocalRef(field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
jstring jurl = env->NewStringUTF(url);
|
||||||
|
jstring jmethod = env->NewStringUTF(method != nullptr ? method : "GET");
|
||||||
|
// raw bytes across the bridge, as httpPost does: a request body is JSON
|
||||||
|
// carrying a base64 save, and a jstring would run it through modified UTF-8
|
||||||
|
jbyteArray jbody = nullptr;
|
||||||
|
if (body != nullptr && bodyLen >= 0)
|
||||||
|
{
|
||||||
|
jbody = env->NewByteArray((jsize) bodyLen);
|
||||||
|
if (jbody != nullptr && bodyLen > 0)
|
||||||
|
env->SetByteArrayRegion(jbody, 0, (jsize) bodyLen, (const jbyte*) body);
|
||||||
|
}
|
||||||
|
jstring jua = env->NewStringUTF(userAgent != nullptr ? userAgent : "gen1recomp");
|
||||||
|
|
||||||
|
jobject result = env->CallStaticObjectMethod(activity, method_id, jurl, jmethod,
|
||||||
|
jheaders, jbody, jua);
|
||||||
|
|
||||||
|
env->DeleteLocalRef(jurl);
|
||||||
|
env->DeleteLocalRef(jmethod);
|
||||||
|
if (jheaders != nullptr)
|
||||||
|
env->DeleteLocalRef(jheaders);
|
||||||
|
if (jbody != nullptr)
|
||||||
|
env->DeleteLocalRef(jbody);
|
||||||
|
env->DeleteLocalRef(jua);
|
||||||
|
env->DeleteLocalRef(activity);
|
||||||
|
|
||||||
|
if (result == nullptr)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
jbyteArray bytes = (jbyteArray) result;
|
||||||
|
jsize length = env->GetArrayLength(bytes);
|
||||||
|
if (length > 0)
|
||||||
|
{
|
||||||
|
out.resize((size_t) length);
|
||||||
|
env->GetByteArrayRegion(bytes, 0, length, (jbyte*) &out[0]);
|
||||||
|
}
|
||||||
|
env->DeleteLocalRef(result);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* TLS sockets. Same resolution rule as httpDownload above -- the activity's
|
* TLS sockets. Same resolution rule as httpDownload above -- the activity's
|
||||||
* own class, never FindClass -- and the same tolerance for an old APK: a
|
* own class, never FindClass -- and the same tolerance for an old APK: a
|
||||||
@@ -1390,4 +1587,32 @@ Java_org_love2d_android_GameActivity_nativeAudioDeviceChanged(JNIEnv *env, jclas
|
|||||||
love::audio::openal::pushAudioResetEvent();
|
love::audio::openal::pushAudioResetEvent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void pushGameIntentEvent(const char *game)
|
||||||
|
{
|
||||||
|
auto eventmodule = love::Module::getInstance<love::event::Event>(love::Module::M_EVENT);
|
||||||
|
if (eventmodule == nullptr || game == nullptr)
|
||||||
|
return;
|
||||||
|
|
||||||
|
std::vector<love::Variant> args;
|
||||||
|
args.push_back(love::Variant(std::string(game)));
|
||||||
|
|
||||||
|
love::event::Message *msg = new love::event::Message("intent_game", args);
|
||||||
|
eventmodule->push(msg);
|
||||||
|
msg->release();
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C" JNIEXPORT void JNICALL
|
||||||
|
Java_org_love2d_android_GameActivity_nativeOnGameIntent(JNIEnv *env, jclass cls, jstring game)
|
||||||
|
{
|
||||||
|
(void) cls;
|
||||||
|
if (game == nullptr)
|
||||||
|
return;
|
||||||
|
const char *str = env->GetStringUTFChars(game, nullptr);
|
||||||
|
if (str != nullptr)
|
||||||
|
{
|
||||||
|
pushGameIntentEvent(str);
|
||||||
|
env->ReleaseStringUTFChars(game, str);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#endif // LOVE_ANDROID
|
#endif // LOVE_ANDROID
|
||||||
|
|||||||
@@ -90,6 +90,22 @@ bool syncHealthSteps();
|
|||||||
**/
|
**/
|
||||||
bool restartApp();
|
bool restartApp();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stages a checksum-verified APK from the current save directory and starts
|
||||||
|
* Android's user-confirmed Package Installer flow. Android-only.
|
||||||
|
**/
|
||||||
|
bool installApk(const char *path);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dynamic App Shortcuts: updates Android ShortcutManager with ready game versions.
|
||||||
|
**/
|
||||||
|
bool updateAppShortcuts(const std::vector<std::string> &versions);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the game version requested via initial launch Intent (if any).
|
||||||
|
**/
|
||||||
|
std::string getLaunchGame();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Blocking HTTPS GET into destPath (GameActivity.httpDownload). Android has
|
* Blocking HTTPS GET into destPath (GameActivity.httpDownload). Android has
|
||||||
* no curl binary, so this is the transport src/core/HostShell.lua uses there
|
* no curl binary, so this is the transport src/core/HostShell.lua uses there
|
||||||
@@ -106,6 +122,21 @@ bool httpDownload(const char *url, const char *destPath, const char *userAgent,
|
|||||||
**/
|
**/
|
||||||
bool httpPost(const char *url, const char *body, int bodyLen, const char *contentType, const char *userAgent);
|
bool httpPost(const char *url, const char *body, int bodyLen, const char *contentType, const char *userAgent);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blocking HTTPS request with a method, headers and a byte body
|
||||||
|
* (GameActivity.httpRequest). What save sync needs and neither of the two
|
||||||
|
* above can give it: PUT, per-request auth headers, and the response body of
|
||||||
|
* a 4xx as well as a 2xx. headerPairs is a flat name, value array of
|
||||||
|
* headerPairCount entries; body/userAgent may be null. `out` receives the
|
||||||
|
* Java side's envelope -- a head line of "STATUS <code>" or "ERROR <text>",
|
||||||
|
* a newline, then the raw response bytes. False means the platform has no
|
||||||
|
* such bridge at all (an old APK under a newer liblove), which the Lua side
|
||||||
|
* reports as "update the app" rather than as a failed request.
|
||||||
|
**/
|
||||||
|
bool httpRequest(const char *url, const char *method,
|
||||||
|
const char *const *headerPairs, int headerPairCount,
|
||||||
|
const char *body, int bodyLen, const char *userAgent, std::string &out);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TLS client sockets (GameActivity.tls*, implemented by TlsSocket.java).
|
* TLS client sockets (GameActivity.tls*, implemented by TlsSocket.java).
|
||||||
* LuaSocket, which is what LOVE ships, does TCP only, so wss:// is otherwise
|
* LuaSocket, which is what LOVE ships, does TCP only, so wss:// is otherwise
|
||||||
|
|||||||
@@ -245,6 +245,35 @@ bool System::restartApp() const
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool System::installApk(const char *path) const
|
||||||
|
{
|
||||||
|
#ifdef LOVE_ANDROID
|
||||||
|
return love::android::installApk(path);
|
||||||
|
#else
|
||||||
|
LOVE_UNUSED(path);
|
||||||
|
return false;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
bool System::updateShortcuts(const std::vector<std::string> &versions) const
|
||||||
|
{
|
||||||
|
#ifdef LOVE_ANDROID
|
||||||
|
return love::android::updateAppShortcuts(versions);
|
||||||
|
#else
|
||||||
|
LOVE_UNUSED(versions);
|
||||||
|
return false;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string System::getLaunchGame() const
|
||||||
|
{
|
||||||
|
#ifdef LOVE_ANDROID
|
||||||
|
return love::android::getLaunchGame();
|
||||||
|
#else
|
||||||
|
return "";
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
bool System::httpDownload(const char *url, const char *destPath,
|
bool System::httpDownload(const char *url, const char *destPath,
|
||||||
const char *userAgent, const char *accept) const
|
const char *userAgent, const char *accept) const
|
||||||
{
|
{
|
||||||
@@ -274,6 +303,27 @@ bool System::httpPost(const char *url, const char *body, int bodyLen,
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool System::httpRequest(const char *url, const char *method,
|
||||||
|
const char *const *headerPairs, int headerPairCount,
|
||||||
|
const char *body, int bodyLen, const char *userAgent,
|
||||||
|
std::string &out) const
|
||||||
|
{
|
||||||
|
#ifdef LOVE_ANDROID
|
||||||
|
return love::android::httpRequest(url, method, headerPairs, headerPairCount,
|
||||||
|
body, bodyLen, userAgent, out);
|
||||||
|
#else
|
||||||
|
LOVE_UNUSED(url);
|
||||||
|
LOVE_UNUSED(method);
|
||||||
|
LOVE_UNUSED(headerPairs);
|
||||||
|
LOVE_UNUSED(headerPairCount);
|
||||||
|
LOVE_UNUSED(body);
|
||||||
|
LOVE_UNUSED(bodyLen);
|
||||||
|
LOVE_UNUSED(userAgent);
|
||||||
|
out.clear();
|
||||||
|
return false;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
int System::tlsOpen(const char *host, int port) const
|
int System::tlsOpen(const char *host, int port) const
|
||||||
{
|
{
|
||||||
#ifdef LOVE_ANDROID
|
#ifdef LOVE_ANDROID
|
||||||
|
|||||||
@@ -143,6 +143,12 @@ public:
|
|||||||
**/
|
**/
|
||||||
virtual bool restartApp() const;
|
virtual bool restartApp() const;
|
||||||
|
|
||||||
|
/** Starts Android's user-confirmed install flow for a verified APK. */
|
||||||
|
virtual bool installApk(const char *path) const;
|
||||||
|
|
||||||
|
virtual bool updateShortcuts(const std::vector<std::string> &versions) const;
|
||||||
|
virtual std::string getLaunchGame() const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Blocking HTTPS GET into an absolute host path (Android only; false
|
* Blocking HTTPS GET into an absolute host path (Android only; false
|
||||||
* elsewhere). Android has no curl, which is what every other platform
|
* elsewhere). Android has no curl, which is what every other platform
|
||||||
@@ -159,6 +165,18 @@ public:
|
|||||||
virtual bool httpPost(const char *url, const char *body, int bodyLen,
|
virtual bool httpPost(const char *url, const char *body, int bodyLen,
|
||||||
const char *contentType = nullptr, const char *userAgent = nullptr) const;
|
const char *contentType = nullptr, const char *userAgent = nullptr) const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blocking HTTPS request with a method, headers and a byte body (Android
|
||||||
|
* only; false elsewhere). Save sync needs PUT, auth headers and the body
|
||||||
|
* of a 4xx, none of which the two bridges above can express. headerPairs
|
||||||
|
* is a flat name, value array; `out` receives the response envelope
|
||||||
|
* ("STATUS <code>" or "ERROR <text>", a newline, then the raw body).
|
||||||
|
**/
|
||||||
|
virtual bool httpRequest(const char *url, const char *method,
|
||||||
|
const char *const *headerPairs, int headerPairCount,
|
||||||
|
const char *body, int bodyLen, const char *userAgent,
|
||||||
|
std::string &out) const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TLS client sockets (Android only; every call fails elsewhere, where
|
* TLS client sockets (Android only; every call fails elsewhere, where
|
||||||
* LuaSec or another provider is the answer). Non-blocking by contract:
|
* LuaSec or another provider is the answer). Non-blocking by contract:
|
||||||
|
|||||||
@@ -22,6 +22,9 @@
|
|||||||
#include "wrap_System.h"
|
#include "wrap_System.h"
|
||||||
#include "sdl/System.h"
|
#include "sdl/System.h"
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
namespace love
|
namespace love
|
||||||
{
|
{
|
||||||
namespace system
|
namespace system
|
||||||
@@ -129,6 +132,13 @@ int w_restartApp(lua_State *L)
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int w_installApk(lua_State *L)
|
||||||
|
{
|
||||||
|
const char *path = luaL_checkstring(L, 1);
|
||||||
|
luax_pushboolean(L, instance()->installApk(path));
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
int w_httpDownload(lua_State *L)
|
int w_httpDownload(lua_State *L)
|
||||||
{
|
{
|
||||||
const char *url = luaL_checkstring(L, 1);
|
const char *url = luaL_checkstring(L, 1);
|
||||||
@@ -150,6 +160,57 @@ int w_httpPost(lua_State *L)
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* love.system.httpRequest(url, method, headers, body, userAgent) -> envelope
|
||||||
|
*
|
||||||
|
* `headers` is a flat array of alternating header name and value strings, so
|
||||||
|
* it maps straight onto the Java bridge's String[] without any parsing here.
|
||||||
|
* The single return is the response envelope -- a head line of
|
||||||
|
* "STATUS <code>" or "ERROR <text>", a newline, then the raw body -- or nil
|
||||||
|
* where the build has no bridge, which src/core/HostShell.lua turns into an
|
||||||
|
* "update the app" notice rather than a failed request.
|
||||||
|
*/
|
||||||
|
int w_httpRequest(lua_State *L)
|
||||||
|
{
|
||||||
|
const char *url = luaL_checkstring(L, 1);
|
||||||
|
const char *method = luaL_optstring(L, 2, "GET");
|
||||||
|
|
||||||
|
std::vector<std::string> fields;
|
||||||
|
if (!lua_isnoneornil(L, 3))
|
||||||
|
{
|
||||||
|
luaL_checktype(L, 3, LUA_TTABLE);
|
||||||
|
size_t count = luax_objlen(L, 3);
|
||||||
|
for (size_t i = 1; i <= count; i++)
|
||||||
|
{
|
||||||
|
lua_rawgeti(L, 3, (int) i);
|
||||||
|
const char *field = lua_tostring(L, -1);
|
||||||
|
fields.push_back(field != nullptr ? field : "");
|
||||||
|
lua_pop(L, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::vector<const char *> pairs;
|
||||||
|
for (size_t i = 0; i < fields.size(); i++)
|
||||||
|
pairs.push_back(fields[i].c_str());
|
||||||
|
|
||||||
|
size_t bodyLen = 0;
|
||||||
|
const char *body = nullptr;
|
||||||
|
if (!lua_isnoneornil(L, 4))
|
||||||
|
body = luaL_checklstring(L, 4, &bodyLen);
|
||||||
|
const char *ua = luaL_optstring(L, 5, nullptr);
|
||||||
|
|
||||||
|
std::string out;
|
||||||
|
bool ok = instance()->httpRequest(url, method,
|
||||||
|
pairs.empty() ? nullptr : &pairs[0], (int) pairs.size(),
|
||||||
|
body, (int) bodyLen, ua, out);
|
||||||
|
if (!ok)
|
||||||
|
{
|
||||||
|
lua_pushnil(L);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
lua_pushlstring(L, out.data(), out.size());
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
int w_hasBackgroundMusic(lua_State *L)
|
int w_hasBackgroundMusic(lua_State *L)
|
||||||
{
|
{
|
||||||
lua_pushboolean(L, instance()->hasBackgroundMusic());
|
lua_pushboolean(L, instance()->hasBackgroundMusic());
|
||||||
@@ -229,6 +290,34 @@ int w_tlsClose(lua_State *L)
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int w_updateShortcuts(lua_State *L)
|
||||||
|
{
|
||||||
|
if (!lua_istable(L, 1))
|
||||||
|
return luaL_error(L, "Expected table of game version strings");
|
||||||
|
|
||||||
|
std::vector<std::string> versions;
|
||||||
|
int len = (int) luax_objlen(L, 1);
|
||||||
|
for (int i = 1; i <= len; ++i)
|
||||||
|
{
|
||||||
|
lua_rawgeti(L, 1, i);
|
||||||
|
if (lua_isstring(L, -1))
|
||||||
|
versions.push_back(lua_tostring(L, -1));
|
||||||
|
lua_pop(L, 1);
|
||||||
|
}
|
||||||
|
luax_pushboolean(L, instance()->updateShortcuts(versions));
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
int w_getLaunchGame(lua_State *L)
|
||||||
|
{
|
||||||
|
std::string game = instance()->getLaunchGame();
|
||||||
|
if (game.empty())
|
||||||
|
lua_pushnil(L);
|
||||||
|
else
|
||||||
|
luax_pushstring(L, game);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
static const luaL_Reg functions[] =
|
static const luaL_Reg functions[] =
|
||||||
{
|
{
|
||||||
{ "getOS", w_getOS },
|
{ "getOS", w_getOS },
|
||||||
@@ -243,8 +332,12 @@ static const luaL_Reg functions[] =
|
|||||||
{ "createFile", w_createFile },
|
{ "createFile", w_createFile },
|
||||||
{ "syncHealthSteps", w_syncHealthSteps },
|
{ "syncHealthSteps", w_syncHealthSteps },
|
||||||
{ "restartApp", w_restartApp },
|
{ "restartApp", w_restartApp },
|
||||||
|
{ "installApk", w_installApk },
|
||||||
|
{ "updateShortcuts", w_updateShortcuts },
|
||||||
|
{ "getLaunchGame", w_getLaunchGame },
|
||||||
{ "httpDownload", w_httpDownload },
|
{ "httpDownload", w_httpDownload },
|
||||||
{ "httpPost", w_httpPost },
|
{ "httpPost", w_httpPost },
|
||||||
|
{ "httpRequest", w_httpRequest },
|
||||||
{ "tlsOpen", w_tlsOpen },
|
{ "tlsOpen", w_tlsOpen },
|
||||||
{ "tlsStatus", w_tlsStatus },
|
{ "tlsStatus", w_tlsStatus },
|
||||||
{ "tlsSend", w_tlsSend },
|
{ "tlsSend", w_tlsSend },
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import org.libsdl.app.SDLActivity;
|
|||||||
|
|
||||||
import java.io.BufferedInputStream;
|
import java.io.BufferedInputStream;
|
||||||
import java.io.BufferedOutputStream;
|
import java.io.BufferedOutputStream;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.FileInputStream;
|
import java.io.FileInputStream;
|
||||||
import java.io.FileNotFoundException;
|
import java.io.FileNotFoundException;
|
||||||
@@ -36,6 +37,7 @@ import java.net.URL;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
import android.Manifest;
|
import android.Manifest;
|
||||||
@@ -43,6 +45,7 @@ import android.app.AlarmManager;
|
|||||||
import android.app.AlertDialog;
|
import android.app.AlertDialog;
|
||||||
import android.app.PendingIntent;
|
import android.app.PendingIntent;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
|
import android.content.ClipData;
|
||||||
import android.content.DialogInterface;
|
import android.content.DialogInterface;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.content.SharedPreferences;
|
import android.content.SharedPreferences;
|
||||||
@@ -67,11 +70,15 @@ import android.os.Vibrator;
|
|||||||
import android.provider.Settings;
|
import android.provider.Settings;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
import android.util.DisplayMetrics;
|
import android.util.DisplayMetrics;
|
||||||
import android.view.*;
|
import android.content.pm.ShortcutInfo;
|
||||||
|
import android.content.pm.ShortcutManager;
|
||||||
import android.content.pm.PackageManager;
|
import android.content.pm.PackageManager;
|
||||||
|
import android.graphics.drawable.Icon;
|
||||||
|
import android.view.*;
|
||||||
|
|
||||||
import androidx.annotation.Keep;
|
import androidx.annotation.Keep;
|
||||||
import androidx.core.app.ActivityCompat;
|
import androidx.core.app.ActivityCompat;
|
||||||
|
import androidx.core.content.FileProvider;
|
||||||
|
|
||||||
public class GameActivity extends SDLActivity {
|
public class GameActivity extends SDLActivity {
|
||||||
private static DisplayMetrics metrics = null;
|
private static DisplayMetrics metrics = null;
|
||||||
@@ -157,6 +164,10 @@ public class GameActivity extends SDLActivity {
|
|||||||
|
|
||||||
private static native void nativeAudioDeviceChanged();
|
private static native void nativeAudioDeviceChanged();
|
||||||
|
|
||||||
|
private static native void nativeOnGameIntent(String game);
|
||||||
|
|
||||||
|
private static String initialGame = "";
|
||||||
|
|
||||||
private AudioManager.OnAudioFocusChangeListener audioFocusListener = null;
|
private AudioManager.OnAudioFocusChangeListener audioFocusListener = null;
|
||||||
private Object audioFocusRequest = null;
|
private Object audioFocusRequest = null;
|
||||||
private Object audioDeviceCallback = null;
|
private Object audioDeviceCallback = null;
|
||||||
@@ -226,6 +237,10 @@ public class GameActivity extends SDLActivity {
|
|||||||
embed = getResources().getBoolean(R.bool.embed);
|
embed = getResources().getBoolean(R.bool.embed);
|
||||||
needToCopyGameInArchive = embed;
|
needToCopyGameInArchive = embed;
|
||||||
|
|
||||||
|
Intent startIntent = getIntent();
|
||||||
|
if (startIntent != null && startIntent.hasExtra("game")) {
|
||||||
|
initialGame = startIntent.getStringExtra("game");
|
||||||
|
}
|
||||||
if (!embed) {
|
if (!embed) {
|
||||||
Intent intent = getIntent();
|
Intent intent = getIntent();
|
||||||
handleIntent(intent);
|
handleIntent(intent);
|
||||||
@@ -259,6 +274,12 @@ public class GameActivity extends SDLActivity {
|
|||||||
@Override
|
@Override
|
||||||
protected void onNewIntent(Intent intent) {
|
protected void onNewIntent(Intent intent) {
|
||||||
Log.d("GameActivity", "onNewIntent() with " + intent);
|
Log.d("GameActivity", "onNewIntent() with " + intent);
|
||||||
|
if (intent != null && intent.hasExtra("game")) {
|
||||||
|
String game = intent.getStringExtra("game");
|
||||||
|
if (game != null && !game.isEmpty()) {
|
||||||
|
nativeOnGameIntent(game);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!embed) {
|
if (!embed) {
|
||||||
handleIntent(intent);
|
handleIntent(intent);
|
||||||
resetNative();
|
resetNative();
|
||||||
@@ -379,11 +400,15 @@ public class GameActivity extends SDLActivity {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onDestroy() {
|
protected void onDestroy() {
|
||||||
|
secondaryHostResumed = false;
|
||||||
if (vibrator != null) {
|
if (vibrator != null) {
|
||||||
Log.d("GameActivity", "Cancelling vibration");
|
Log.d("GameActivity", "Cancelling vibration");
|
||||||
vibrator.cancel();
|
vibrator.cancel();
|
||||||
}
|
}
|
||||||
unregisterSecondaryDisplayListener();
|
unregisterSecondaryDisplayListener();
|
||||||
|
teardownSecondaryDisplay();
|
||||||
|
secondaryEnabled = false;
|
||||||
|
synchronized (secondaryFrameLock) { secondaryFrame = null; }
|
||||||
unregisterAudioDeviceCallback();
|
unregisterAudioDeviceCallback();
|
||||||
abandonAudioFocus();
|
abandonAudioFocus();
|
||||||
onHostDestroy();
|
onHostDestroy();
|
||||||
@@ -392,6 +417,7 @@ public class GameActivity extends SDLActivity {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onPause() {
|
protected void onPause() {
|
||||||
|
secondaryHostResumed = false;
|
||||||
if (vibrator != null) {
|
if (vibrator != null) {
|
||||||
Log.d("GameActivity", "Cancelling vibration");
|
Log.d("GameActivity", "Cancelling vibration");
|
||||||
vibrator.cancel();
|
vibrator.cancel();
|
||||||
@@ -407,6 +433,7 @@ public class GameActivity extends SDLActivity {
|
|||||||
@Override
|
@Override
|
||||||
public void onResume() {
|
public void onResume() {
|
||||||
super.onResume();
|
super.onResume();
|
||||||
|
secondaryHostResumed = true;
|
||||||
onHostResume();
|
onHostResume();
|
||||||
requestGameAudioFocus();
|
requestGameAudioFocus();
|
||||||
registerAudioDeviceCallback();
|
registerAudioDeviceCallback();
|
||||||
@@ -671,6 +698,192 @@ public class GameActivity extends SDLActivity {
|
|||||||
return true; // unreachable, but keeps the JNI signature honest
|
return true; // unreachable, but keeps the JNI signature honest
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stages a verified release APK in cache and asks Android's Package
|
||||||
|
* Installer to update this package. This never silently installs an APK:
|
||||||
|
* the platform owns both the unknown-sources consent and final install
|
||||||
|
* confirmation. `updateRoot` comes from the native save directory and is
|
||||||
|
* checked before any file is read, so a Lua caller cannot turn this into a
|
||||||
|
* general-purpose local-file sharing bridge.
|
||||||
|
*/
|
||||||
|
@Keep
|
||||||
|
public static boolean installApk(final String sourcePath, final String updateRoot) {
|
||||||
|
final GameActivity self = (GameActivity) mSingleton;
|
||||||
|
if (self == null || sourcePath == null || updateRoot == null) return false;
|
||||||
|
final File source;
|
||||||
|
try {
|
||||||
|
source = new File(sourcePath).getCanonicalFile();
|
||||||
|
File root = new File(updateRoot, "updates").getCanonicalFile();
|
||||||
|
String rootPath = root.getPath() + File.separator;
|
||||||
|
if (!source.getPath().startsWith(rootPath)
|
||||||
|
|| !source.isFile() || source.length() == 0
|
||||||
|
|| !source.getName().matches("gen1recomp-[0-9]+\\.[0-9]+\\.[0-9]+-android\\.apk")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
Log.d("GameActivity", "invalid update APK path: " + e.getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Android 8+ lets the user decide whether this app is trusted to
|
||||||
|
// request package installs. Send them to the per-app setting first;
|
||||||
|
// they deliberately tap Install again after granting it.
|
||||||
|
if (android.os.Build.VERSION.SDK_INT >= 26
|
||||||
|
&& !self.getPackageManager().canRequestPackageInstalls()) {
|
||||||
|
try {
|
||||||
|
Intent settings = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
|
||||||
|
Uri.parse("package:" + self.getPackageName()));
|
||||||
|
self.startActivity(settings);
|
||||||
|
return true;
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.d("GameActivity", "could not open install-source settings: " + e.getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copying an APK can be large; keep both I/O and checksum-verified
|
||||||
|
// source access off the UI thread. The FileProvider exposes this cache
|
||||||
|
// child only after it has been fully written and renamed.
|
||||||
|
new Thread(new Runnable() {
|
||||||
|
@Override public void run() {
|
||||||
|
File stagedDir = new File(self.getCacheDir(), "full-update");
|
||||||
|
File partial = new File(stagedDir, "update.apk.part");
|
||||||
|
File staged = new File(stagedDir, "update.apk");
|
||||||
|
try {
|
||||||
|
if (!stagedDir.exists() && !stagedDir.mkdirs()) return;
|
||||||
|
copyFile(source, partial);
|
||||||
|
if (staged.exists() && !staged.delete()) return;
|
||||||
|
if (!partial.renameTo(staged)) return;
|
||||||
|
self.runOnUiThread(new Runnable() {
|
||||||
|
@Override public void run() { launchPackageInstaller(self, staged); }
|
||||||
|
});
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.d("GameActivity", "could not stage update APK: " + e.getMessage());
|
||||||
|
} finally {
|
||||||
|
if (partial.exists()) partial.delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, "gen1recomp-apk-stage").start();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void copyFile(File source, File destination) throws IOException {
|
||||||
|
InputStream in = new BufferedInputStream(new FileInputStream(source));
|
||||||
|
OutputStream out = new BufferedOutputStream(new FileOutputStream(destination));
|
||||||
|
try {
|
||||||
|
byte[] buffer = new byte[32768];
|
||||||
|
int count;
|
||||||
|
while ((count = in.read(buffer)) != -1) out.write(buffer, 0, count);
|
||||||
|
} finally {
|
||||||
|
try { out.close(); } catch (IOException ignored) {}
|
||||||
|
try { in.close(); } catch (IOException ignored) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void launchPackageInstaller(GameActivity activity, File apk) {
|
||||||
|
try {
|
||||||
|
Context context = activity.getApplicationContext();
|
||||||
|
Uri uri = FileProvider.getUriForFile(context,
|
||||||
|
context.getPackageName() + ".full_update_provider", apk);
|
||||||
|
Intent install = new Intent(Intent.ACTION_INSTALL_PACKAGE);
|
||||||
|
install.setData(uri);
|
||||||
|
install.setClipData(ClipData.newRawUri("apk", uri));
|
||||||
|
install.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||||
|
activity.startActivity(install);
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.d("GameActivity", "could not open package installer: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Keep
|
||||||
|
public static String getLaunchGame() {
|
||||||
|
return initialGame != null ? initialGame : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Keep
|
||||||
|
public static boolean updateAppShortcuts(String[] readyVersions) {
|
||||||
|
GameActivity self = (GameActivity) mSingleton;
|
||||||
|
if (self == null) return false;
|
||||||
|
if (android.os.Build.VERSION.SDK_INT < 25) return false;
|
||||||
|
try {
|
||||||
|
Context context = self.getApplicationContext();
|
||||||
|
ShortcutManager shortcutManager = context.getSystemService(ShortcutManager.class);
|
||||||
|
if (shortcutManager == null) return false;
|
||||||
|
|
||||||
|
if (readyVersions == null || readyVersions.length == 0) {
|
||||||
|
shortcutManager.removeAllDynamicShortcuts();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<ShortcutInfo> shortcuts = new ArrayList<>();
|
||||||
|
int maxShortcuts = Math.min(readyVersions.length, 4);
|
||||||
|
|
||||||
|
for (int i = 0; i < maxShortcuts; i++) {
|
||||||
|
String ver = readyVersions[i];
|
||||||
|
if (ver == null || ver.isEmpty()) continue;
|
||||||
|
String lower = ver.toLowerCase();
|
||||||
|
String shortLabel;
|
||||||
|
String longLabel;
|
||||||
|
int iconResId;
|
||||||
|
|
||||||
|
switch (lower) {
|
||||||
|
case "red":
|
||||||
|
shortLabel = "Play Red";
|
||||||
|
longLabel = "Play Red";
|
||||||
|
iconResId = context.getResources().getIdentifier("ic_shortcut_red", "drawable", context.getPackageName());
|
||||||
|
break;
|
||||||
|
case "blue":
|
||||||
|
shortLabel = "Play Blue";
|
||||||
|
longLabel = "Play Blue";
|
||||||
|
iconResId = context.getResources().getIdentifier("ic_shortcut_blue", "drawable", context.getPackageName());
|
||||||
|
break;
|
||||||
|
case "yellow":
|
||||||
|
shortLabel = "Play Yellow";
|
||||||
|
longLabel = "Play Yellow";
|
||||||
|
iconResId = context.getResources().getIdentifier("ic_shortcut_yellow", "drawable", context.getPackageName());
|
||||||
|
break;
|
||||||
|
case "gold":
|
||||||
|
shortLabel = "Play Gold";
|
||||||
|
longLabel = "Play Gold";
|
||||||
|
iconResId = context.getResources().getIdentifier("ic_shortcut_gold", "drawable", context.getPackageName());
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
String capitalized = lower.substring(0, 1).toUpperCase() + lower.substring(1);
|
||||||
|
shortLabel = "Play " + capitalized;
|
||||||
|
longLabel = "Play " + capitalized;
|
||||||
|
iconResId = context.getResources().getIdentifier("ic_shortcut_" + lower, "drawable", context.getPackageName());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (iconResId == 0) {
|
||||||
|
iconResId = context.getResources().getIdentifier("ic_launcher_foreground", "drawable", context.getPackageName());
|
||||||
|
}
|
||||||
|
|
||||||
|
Intent intent = new Intent(context, GameActivity.class);
|
||||||
|
intent.setAction(Intent.ACTION_VIEW);
|
||||||
|
intent.putExtra("game", lower);
|
||||||
|
intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP);
|
||||||
|
|
||||||
|
ShortcutInfo.Builder builder = new ShortcutInfo.Builder(context, "shortcut_" + lower)
|
||||||
|
.setShortLabel(shortLabel)
|
||||||
|
.setLongLabel(longLabel)
|
||||||
|
.setIntent(intent);
|
||||||
|
|
||||||
|
if (iconResId != 0) {
|
||||||
|
builder.setIcon(Icon.createWithResource(context, iconResId));
|
||||||
|
}
|
||||||
|
|
||||||
|
shortcuts.add(builder.build());
|
||||||
|
}
|
||||||
|
|
||||||
|
shortcutManager.setDynamicShortcuts(shortcuts);
|
||||||
|
return true;
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.d("GameActivity", "could not update shortcuts: " + e.getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Blocking HTTPS GET into destPath, exposed as love.system.httpDownload
|
* Blocking HTTPS GET into destPath, exposed as love.system.httpDownload
|
||||||
* and used by src/core/HostShell.lua. Android ships no curl binary, so
|
* and used by src/core/HostShell.lua. Android ships no curl binary, so
|
||||||
@@ -847,6 +1060,149 @@ public class GameActivity extends SDLActivity {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Response ceiling for httpRequest; anything larger is refused, not buffered. */
|
||||||
|
private static final int HTTP_REQUEST_MAX_RESPONSE = 4 * 1024 * 1024;
|
||||||
|
|
||||||
|
/** Builds an httpRequest envelope: one head line, a newline, then the body. */
|
||||||
|
private static byte[] httpEnvelope(String head, byte[] payload) {
|
||||||
|
byte[] prefix;
|
||||||
|
try {
|
||||||
|
prefix = (head + "\n").getBytes("UTF-8");
|
||||||
|
} catch (Exception e) {
|
||||||
|
prefix = (head + "\n").getBytes();
|
||||||
|
}
|
||||||
|
if (payload == null || payload.length == 0) return prefix;
|
||||||
|
byte[] out = new byte[prefix.length + payload.length];
|
||||||
|
System.arraycopy(prefix, 0, out, 0, prefix.length);
|
||||||
|
System.arraycopy(payload, 0, out, prefix.length, payload.length);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One-line, CR/LF-free failure text, so an envelope head stays one line. */
|
||||||
|
private static String httpErrorText(Exception e) {
|
||||||
|
String text = e.getMessage();
|
||||||
|
if (text == null || text.length() == 0) text = e.getClass().getSimpleName();
|
||||||
|
text = text.replace('\r', ' ').replace('\n', ' ');
|
||||||
|
if (text.length() > 160) text = text.substring(0, 160);
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blocking HTTPS request with a chosen method, headers and byte body,
|
||||||
|
* exposed as love.system.httpRequest and used by src/core/HostShell.lua
|
||||||
|
* for save sync. Sync needs PUT, per-request auth headers and the response
|
||||||
|
* body of a 4xx as well as a 2xx (a conflict answers 409 with the save
|
||||||
|
* that won), none of which httpDownload or httpPost above can express.
|
||||||
|
*
|
||||||
|
* Same rules as those two: https only, redirects followed by hand
|
||||||
|
* (re-sending method and body on each hop), 15s connect / 60s read, and
|
||||||
|
* blocking on the Lua/worker thread -- never the UI thread. Headers arrive
|
||||||
|
* as a flat name, value array; a field carrying CR or LF is refused rather
|
||||||
|
* than sent, so a header value can never inject a second header.
|
||||||
|
*
|
||||||
|
* The reply is an envelope: a head line of "STATUS <code>" or
|
||||||
|
* "ERROR <text>", a newline, then the raw response bytes.
|
||||||
|
*/
|
||||||
|
@Keep
|
||||||
|
public static byte[] httpRequest(String url, String method, String[] headerPairs,
|
||||||
|
byte[] body, String userAgent) {
|
||||||
|
if (url == null) return httpEnvelope("ERROR missing url", null);
|
||||||
|
String verb = method == null ? "GET" : method.toUpperCase(Locale.US);
|
||||||
|
if (!"GET".equals(verb) && !"POST".equals(verb)
|
||||||
|
&& !"PUT".equals(verb) && !"DELETE".equals(verb)) {
|
||||||
|
return httpEnvelope("ERROR unsupported request method", null);
|
||||||
|
}
|
||||||
|
if (headerPairs != null) {
|
||||||
|
if ((headerPairs.length % 2) != 0) {
|
||||||
|
return httpEnvelope("ERROR bad request header", null);
|
||||||
|
}
|
||||||
|
for (int i = 0; i < headerPairs.length; i++) {
|
||||||
|
String field = headerPairs[i];
|
||||||
|
if (field == null) return httpEnvelope("ERROR bad request header", null);
|
||||||
|
if (field.indexOf('\r') >= 0 || field.indexOf('\n') >= 0) {
|
||||||
|
return httpEnvelope("ERROR bad request header", null);
|
||||||
|
}
|
||||||
|
if ((i % 2) == 0 && field.length() == 0) {
|
||||||
|
return httpEnvelope("ERROR bad request header", null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HttpURLConnection conn = null;
|
||||||
|
try {
|
||||||
|
String current = url;
|
||||||
|
for (int hop = 0; hop < 5; hop++) {
|
||||||
|
URL parsed = new URL(current);
|
||||||
|
if (!"https".equalsIgnoreCase(parsed.getProtocol())) {
|
||||||
|
return httpEnvelope("ERROR https only", null);
|
||||||
|
}
|
||||||
|
conn = (HttpURLConnection) parsed.openConnection();
|
||||||
|
conn.setInstanceFollowRedirects(false);
|
||||||
|
conn.setConnectTimeout(15000);
|
||||||
|
conn.setReadTimeout(60000);
|
||||||
|
conn.setRequestMethod(verb);
|
||||||
|
conn.setRequestProperty("User-Agent",
|
||||||
|
userAgent == null ? "gen1recomp" : userAgent);
|
||||||
|
if (headerPairs != null) {
|
||||||
|
for (int i = 0; i + 1 < headerPairs.length; i += 2) {
|
||||||
|
conn.setRequestProperty(headerPairs[i], headerPairs[i + 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (body != null && !"GET".equals(verb)) {
|
||||||
|
conn.setDoOutput(true);
|
||||||
|
conn.setFixedLengthStreamingMode(body.length);
|
||||||
|
OutputStream out = new BufferedOutputStream(conn.getOutputStream());
|
||||||
|
try {
|
||||||
|
out.write(body);
|
||||||
|
} finally {
|
||||||
|
try { out.close(); } catch (IOException ignored) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
int code = conn.getResponseCode();
|
||||||
|
if (code == 301 || code == 302 || code == 303 || code == 307 || code == 308) {
|
||||||
|
String next = conn.getHeaderField("Location");
|
||||||
|
conn.disconnect();
|
||||||
|
conn = null;
|
||||||
|
if (next == null) {
|
||||||
|
return httpEnvelope("ERROR redirect without a location", null);
|
||||||
|
}
|
||||||
|
current = new URL(parsed, next).toString();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// A rejection's body is the diagnosis the caller wants, so 4xx
|
||||||
|
// and 5xx are read through getErrorStream rather than dropped.
|
||||||
|
InputStream in;
|
||||||
|
try {
|
||||||
|
in = conn.getInputStream();
|
||||||
|
} catch (IOException e) {
|
||||||
|
in = conn.getErrorStream();
|
||||||
|
}
|
||||||
|
ByteArrayOutputStream sink = new ByteArrayOutputStream();
|
||||||
|
if (in != null) {
|
||||||
|
InputStream reader = new BufferedInputStream(in);
|
||||||
|
try {
|
||||||
|
byte[] buf = new byte[16384];
|
||||||
|
int n;
|
||||||
|
while ((n = reader.read(buf)) > 0) {
|
||||||
|
if (sink.size() + n > HTTP_REQUEST_MAX_RESPONSE) {
|
||||||
|
return httpEnvelope("ERROR the reply was too large", null);
|
||||||
|
}
|
||||||
|
sink.write(buf, 0, n);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
try { reader.close(); } catch (IOException ignored) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return httpEnvelope("STATUS " + code, sink.toByteArray());
|
||||||
|
}
|
||||||
|
return httpEnvelope("ERROR too many redirects", null);
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.d("GameActivity", "httpRequest failed: " + e.getMessage());
|
||||||
|
return httpEnvelope("ERROR " + httpErrorText(e), null);
|
||||||
|
} finally {
|
||||||
|
if (conn != null) conn.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shows ACTION_CREATE_DOCUMENT so the player can save a staged export
|
* Shows ACTION_CREATE_DOCUMENT so the player can save a staged export
|
||||||
* (pending_export.sav in the app save identity) to Downloads / Drive /
|
* (pending_export.sav in the app save identity) to Downloads / Drive /
|
||||||
@@ -1682,6 +2038,7 @@ public class GameActivity extends SDLActivity {
|
|||||||
private static volatile int secondaryActivityTarget = Display.INVALID_DISPLAY;
|
private static volatile int secondaryActivityTarget = Display.INVALID_DISPLAY;
|
||||||
private static volatile long secondaryRetryAfter;
|
private static volatile long secondaryRetryAfter;
|
||||||
private static volatile boolean secondaryEnabled = false;
|
private static volatile boolean secondaryEnabled = false;
|
||||||
|
private static volatile boolean secondaryHostResumed = false;
|
||||||
private static volatile int secondaryTarget = SECONDARY_TARGET_AUTO;
|
private static volatile int secondaryTarget = SECONDARY_TARGET_AUTO;
|
||||||
private static volatile int dualScreenDisplayMode = -1;
|
private static volatile int dualScreenDisplayMode = -1;
|
||||||
private static volatile byte[] secondaryFrame;
|
private static volatile byte[] secondaryFrame;
|
||||||
@@ -1712,7 +2069,7 @@ public class GameActivity extends SDLActivity {
|
|||||||
if (self == null) return;
|
if (self == null) return;
|
||||||
self.runOnUiThread(new Runnable() {
|
self.runOnUiThread(new Runnable() {
|
||||||
@Override public void run() {
|
@Override public void run() {
|
||||||
if (on) {
|
if (on && secondaryHostResumed) {
|
||||||
self.refreshDualScreenDisplayMode();
|
self.refreshDualScreenDisplayMode();
|
||||||
self.registerSecondaryDisplayListener();
|
self.registerSecondaryDisplayListener();
|
||||||
rebindSecondaryDisplay();
|
rebindSecondaryDisplay();
|
||||||
@@ -1784,9 +2141,11 @@ public class GameActivity extends SDLActivity {
|
|||||||
|
|
||||||
private static void rebindSecondaryDisplay() {
|
private static void rebindSecondaryDisplay() {
|
||||||
GameActivity self = (GameActivity) mSingleton;
|
GameActivity self = (GameActivity) mSingleton;
|
||||||
if (self == null || !secondaryEnabled || secondaryOutputIsPreferred(self)) return;
|
if (self == null || !secondaryHostResumed || !secondaryEnabled
|
||||||
|
|| secondaryOutputIsPreferred(self)) return;
|
||||||
self.runOnUiThread(() -> {
|
self.runOnUiThread(() -> {
|
||||||
if (!secondaryEnabled || secondaryOutputIsPreferred(self)) return;
|
if (!secondaryHostResumed || !secondaryEnabled
|
||||||
|
|| secondaryOutputIsPreferred(self)) return;
|
||||||
teardownSecondaryDisplay();
|
teardownSecondaryDisplay();
|
||||||
setupSecondaryDisplay();
|
setupSecondaryDisplay();
|
||||||
});
|
});
|
||||||
@@ -1794,7 +2153,8 @@ public class GameActivity extends SDLActivity {
|
|||||||
|
|
||||||
private static void setupSecondaryDisplay() {
|
private static void setupSecondaryDisplay() {
|
||||||
GameActivity self = (GameActivity) mSingleton;
|
GameActivity self = (GameActivity) mSingleton;
|
||||||
if (self == null || !secondaryEnabled || secondaryPresentation != null
|
if (self == null || !secondaryHostResumed || !secondaryEnabled
|
||||||
|
|| secondaryPresentation != null
|
||||||
|| secondaryActivity != null || secondaryActivityPending
|
|| secondaryActivity != null || secondaryActivityPending
|
||||||
|| android.os.SystemClock.elapsedRealtime() < secondaryRetryAfter) return;
|
|| android.os.SystemClock.elapsedRealtime() < secondaryRetryAfter) return;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -12,6 +12,125 @@
|
|||||||
"tintColor": "3b5ca8",
|
"tintColor": "3b5ca8",
|
||||||
"category": "games",
|
"category": "games",
|
||||||
"versions": [
|
"versions": [
|
||||||
|
{
|
||||||
|
"version": "0.2.23",
|
||||||
|
"date": "2026-08-24",
|
||||||
|
"size": 14060437,
|
||||||
|
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.23/gen1recomp++-0.2.23-ios.ipa",
|
||||||
|
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.2.22",
|
||||||
|
"date": "2026-08-24",
|
||||||
|
"size": 14060365,
|
||||||
|
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.22/gen1recomp++-0.2.22-ios.ipa",
|
||||||
|
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi\n- @ShaneMcGovernIE\n- @thibautbus"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.2.21",
|
||||||
|
"date": "2026-08-24",
|
||||||
|
"size": 14058979,
|
||||||
|
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.21/gen1recomp++-0.2.21-ios.ipa",
|
||||||
|
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1483 (Pokémon Gold) Missing HM/TM fanfare when receiving them\n- #1610 Wrong text when choosing Pokemon to trade\n- #1615 Fishing Guru dialogue issue\n- #1640 Sync doesnt work on XBOX build\n- #1646 Cashier dialogue + sound effects\n- #1649 Pokemon cries missing in Vermilion City\n- #1651 Ticket taker asks for ticket too much\n- #1653 Wrap animation is too fast and has no screen shake\n- #1656 Map visible in between end of fight & evolution\n- #1683 Music not changing when talking to trainer\n- #1685 Cancel option missing in item menu\n- #1686 Issues with teaching TMs & HMs\n- #1687 Pokemon cries missing on S.S. Anne\n- #1688 S.S. Anne rival dialogue\n- #1689 S.S. Anne departing cutscene broken\n- #1690 Wrong dialogue when taking back entrance to museum\n- #1693 (Gold) Issues not properly fixed\n- #1694 (Gold) Arrow when seeing items in the bag should be red, not black\n- #1695 (Gold) Wrong layout in TMs/HMs pocket\n- #1696 (Gold) Caught pokémon don't get healed when automatically sent to the PC\n- #1697 Standardize identification of mod-created options/settings screens\n- #1702 Lt. Surge's door opening isn't synced with the sound effect\n- #1704 Ledge jumping on the bike doesn't slow you down\n- #1705 The bike shouldn't have Use/Toss options\n- #1706 (Gold) Cursor wraps around the edge in the battle interface\n- #1707 (Gold) Pokémon sometimes wake up in the same turn they were put to sleep\n- #1708 (Gold) Missing rod when fishing\n- #1710 (Gold) You shouldn't be able to change boxes by pressing LEFT or RIGHT\n- #1711 (Gold) Missing animation for FLY in the overworld\n- #1712 (Gold) Wrong cursor in the map when using FLY\n- #1713 (Gold) Low framerate when jumping off ledges\n- #1716 (Gold) Player can go through a whirlpool even without having the HM\n- #1717 (Gold) Missing animation and SFX when using whirlpool in the overworld\n- #1718 (Gold) Can't pause in the Cycling Road while moving down\n- #1719 (Gold) Pokémon given by NPCs not being registered in the pokédex\n- #1720 Critical hit on multi-hit moves repeats the message\n- #1721 Rival not visible while talking if you lose first fight\n- #1724 Centralize ROM cache readiness and publication in an engine-owned contract\n- #1725 (Gold) Text not spaced nor centered correctly for secondary actions in the bag\n- #1732 Cursor doesn't stay on last used item\n- #1745 Viewing items makes ingame menu disappear\n- #1748 (Gold) Overworld pokémon sprite are stactic\n- #1749 (Gold) You shouldn't be able to surf on Cycling Road\n- #1751 (Gold) Synthesis and Morning Sun always recover 50% of max HP\n- #1754 Leaving building with bike speed\n- #1756 No cursor or gamepad controls in skin menu, locked out on Xbox\n\n## Contributors\n\n- @1Jamie\n- @BoLayerDev\n- @bryanthaboi\n- @campavao\n- @caorthann-celt\n- @dlloa\n- @syybott\n- @thibautbus\n- MaxTomahawk"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.2.20",
|
||||||
|
"date": "2026-08-22",
|
||||||
|
"size": 13812226,
|
||||||
|
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.20/gen1recomp++-0.2.20-ios.ipa",
|
||||||
|
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1536 Magnet Train Cutscene visual bugs\n- #1597 Link cable looks broken\n- #1643 Pikachu + Title uses an incorrect Colour Palette\n- #1662 The game glitches after exiting, returning to the menu, and choosing another one.\n\n## Contributors\n\n- @1Jamie\n- @bryanthaboi\n- @colsonrice"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.2.19",
|
||||||
|
"date": "2026-08-22",
|
||||||
|
"size": 13778435,
|
||||||
|
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.19/gen1recomp++-0.2.19-ios.ipa",
|
||||||
|
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1274 Choose ROM imports a different version than the one selected when several dumps are pending (Linux, no file picker)\n- #1484 (Pokémon Gold) Bill's PC doesn't show equipped items + missing orange filter on pokémon sprite\n- #1486 (Pokémon Gold) Can't deposit key items\n- #1513 Experience share issue different case from #1464\n- #1517 Game Crash Opening Stats\n- #1548 Colisión\n- #1556 (Gold) Missing SFX for a lot of actions\n- #1564 Bide's storing turn is missing the screen shake\n- #1567 TM/HM case not in order\n- #1663 transition pops the top state, destroying anything pushed from a midpoint callback\n- #1664 gold bide spends pp every turn instead of only the turn it is selected\n- #1665 gold has no bide lock-in, so a mid-bide switch of move strands the counter\n- #1666 sleep talk is inert in gold: EFFECT_SLEEP_TALK has no move effect entry\n- #1667 gold: a fainted party mon keeps its exp participant credit and halves the survivor's exp\n- #1668 summary page 2 formats a move's pp with no numeric guard\n- #1670 gold tm/hm pocket capacity is an invented 64 where the cart has 57 slots\n- #1672 bill's pc move screen is missing the box name arrows\n\n## Contributors\n\n- @AverageConsumer\n- @bryanthaboi\n- @colsonrice\n- @jramiresbrito"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.2.18",
|
||||||
|
"date": "2026-08-21",
|
||||||
|
"size": 13772120,
|
||||||
|
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.18/gen1recomp++-0.2.18-ios.ipa",
|
||||||
|
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.2.17",
|
||||||
|
"date": "2026-08-21",
|
||||||
|
"size": 13771903,
|
||||||
|
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.17/gen1recomp++-0.2.17-ios.ipa",
|
||||||
|
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.2.16",
|
||||||
|
"date": "2026-08-21",
|
||||||
|
"size": 13769300,
|
||||||
|
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.16/gen1recomp++-0.2.16-ios.ipa",
|
||||||
|
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1367 Android auto scrolling down bug\n- #1570 Pokemon Gold isn't noticing my controller inputs on...ANY controller I own, despite all of them working in the other games, and in the main launcher\n- #1585 Power-saving mode or dark mode closes the game on Android.\n- #1611 bug: keyboard gets stuck IOS\n- #1612 Fold 7 infinite scrolling mouse.\n- #1636 Game freezes when trainer battle starts\n- #1638 Game screen orientation on smartphone\n- #1641 [Gold] [Android] PKMN Evolutions sequence freezes the Game \n\n## Contributors\n\n- @1Jamie\n- @bryanthaboi\n- @HighDrexler\n- MaxTomahawk"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.2.15",
|
||||||
|
"date": "2026-08-21",
|
||||||
|
"size": 13753007,
|
||||||
|
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.15/gen1recomp++-0.2.15-ios.ipa",
|
||||||
|
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1589 Player sprite still blinking on the Town Map\n- #1595 Pokedex completion certificate is not displaying correctly\n- #1597 Link cable looks broken\n- #1613 Visual differences in Pokemon trades\n- #1619 Save Menu cutting off and Put into another place\n\n## Contributors\n\n- @1Jamie\n- @bryanthaboi"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.2.14",
|
||||||
|
"date": "2026-08-20",
|
||||||
|
"size": 13749372,
|
||||||
|
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.14/gen1recomp++-0.2.14-ios.ipa",
|
||||||
|
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.2.13",
|
||||||
|
"date": "2026-08-20",
|
||||||
|
"size": 13749377,
|
||||||
|
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.13/gen1recomp++-0.2.13-ios.ipa",
|
||||||
|
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1474 (Pokémon Gold) Pokédex mode not being saved\n- #1475 (Pokémon Gold) Vital Throw attacking before the opponent\n- #1477 (Pokémon Gold) Map when using fly keeps the arrow from the pokégear\n- #1478 (Pokémon Gold) Wrong move layout in battles\n- #1479 [Gold] tiles broken at Route 28 and Mt Silver\n- #1482 (Pokémon Gold) Nothing happens when trying to use Coin Case\n- #1488 Pokemon EXP calculation after trading went back to issue #984\n- #1510 Title Screen Transition is Missing\n- #1511 Broken Trainer Rival Name Layout\n- #1512 Gen 2 Post battle interactions don't exist\n- #1514 Health damage timing\n- #1516 Move learning timing\n- #1521 Different Item Menu\n- #1522 Wrong Save Game Layout\n- #1545 Pikachu not sliding out when switching Pokemon.\n- #1557 (Gold) Impossible to get TM 03 in Celadon Mansion at night\n- #1558 (Gold) Missing colors and symbol in stats screen\n- #1563 Player mon pic cuts away instead of shrinking when recalled\n- #1565 Thrash doesn't lock in when the first use misses\n- #1566 Center PC missing the PKMN LEAGUE entry after the Hall of Fame\n- #1569 Goldenrod Gift Spearow Bugged\n- #1577 Thrash has no animation past the first turn\n- #1578 Hitting yourself in confusion animation missing\n- #1579 Missing dialogue for Cerulean City Rocket\n- #1594 Pokemon menu closing too soon when using rare candy\n- #1596 Evolution dialogue & missing jingle\n- #1606 Misty dialogue issue\n- #1608 \"There's no will to fight!\" message issues\n\n## Contributors\n\n- @bryanthaboi"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.2.12",
|
||||||
|
"date": "2026-08-20",
|
||||||
|
"size": 13737259,
|
||||||
|
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.12/gen1recomp++-0.2.12-ios.ipa",
|
||||||
|
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1582 Sync not working between steamdeck and windows\n- #1583 Can’t sync between iOS and windows\n\n## Contributors\n\n- @AverageConsumer\n- @bryanthaboi\n- @thibautbus"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.2.11",
|
||||||
|
"date": "2026-08-20",
|
||||||
|
"size": 13735190,
|
||||||
|
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.11/gen1recomp++-0.2.11-ios.ipa",
|
||||||
|
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #393 silph co. npc missing some dialogue\n- #1600 allow my uncle's neighbor to sit at the big kids table\n- #1603 pocket taco - type option \"screen position\"\n\n## Contributors\n\n- @1Jamie\n- @bryanthaboi\n- @dburton95\n- @mleo2003\n- @thibautbus"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.2.10",
|
||||||
|
"date": "2026-08-19",
|
||||||
|
"size": 13662645,
|
||||||
|
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.10/gen1recomp++-0.2.10-ios.ipa",
|
||||||
|
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #998 Jingles not finishing before game proceeds\n- #1472 Message for sending out Pokemon not closing automatically\n- #1526 No screen shake when getting poisoned\n- #1529 Messages missing when interacting with PC\n- #1530 No message for interacting with bikes in the bike shop\n- #1532 Thrash animation incomplete\n- #1534 Dialogue missing when switching out Pokemon\n- #1547 Save states can be used to bypass certain NPCs\n- #1549 Menu Cartridge 3D model has visual issues\n- #1550 Nugget Bridge Rocket repeating dialogue\n- #1551 No scripted dialogue after beating Nugget Bridge Rocket\n\n## Contributors\n\n- @bryanthaboi\n- @castdrian"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.2.9",
|
||||||
|
"date": "2026-08-19",
|
||||||
|
"size": 13656293,
|
||||||
|
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.9/gen1recomp++-0.2.9-ios.ipa",
|
||||||
|
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.2.8",
|
||||||
|
"date": "2026-08-19",
|
||||||
|
"size": 13653911,
|
||||||
|
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.8/gen1recomp++-0.2.8-ios.ipa",
|
||||||
|
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1502 Gold doesn't show trainer balls\n- #1533 Retroarch Skin Problem 2 (#1503)\n\n## Contributors\n\n- @1Jamie\n- @AverageConsumer\n- @bryanthaboi\n- @castdrian\n- @thibautbus"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.2.7",
|
||||||
|
"date": "2026-08-18",
|
||||||
|
"size": 13597177,
|
||||||
|
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.7/gen1recomp++-0.2.7-ios.ipa",
|
||||||
|
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1005 (Android) Screen record mutes the game\n- #1291 Audio Crash\n- #1310 Incoming call crashes G1R\n- #1471 [Gold] #1117 still not fixed\n- #1528 Surfing Minigame doesn't play as intended\n- #1537 Shellder and Corsola missing from Rod encounter tables\n\n## Contributors\n\n- @1Jamie\n- @bryanthaboi\n- @castdrian"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.2.6",
|
"version": "0.2.6",
|
||||||
"date": "2026-08-18",
|
"date": "2026-08-18",
|
||||||
|
|||||||
@@ -6,6 +6,27 @@
|
|||||||
// Registered from a constructor so no LÖVE/SDL source needs to know about it.
|
// Registered from a constructor so no LÖVE/SDL source needs to know about it.
|
||||||
|
|
||||||
#import <UIKit/UIKit.h>
|
#import <UIKit/UIKit.h>
|
||||||
|
#import <sys/utsname.h>
|
||||||
|
|
||||||
|
@interface GRDeviceBridge : NSObject
|
||||||
|
+ (NSString *)deviceModel;
|
||||||
|
@end
|
||||||
|
|
||||||
|
@implementation GRDeviceBridge
|
||||||
|
+ (NSString *)deviceModel
|
||||||
|
{
|
||||||
|
#if TARGET_OS_SIMULATOR
|
||||||
|
NSString *simulatorModel = NSProcessInfo.processInfo.environment[@"SIMULATOR_MODEL_IDENTIFIER"];
|
||||||
|
if (simulatorModel.length > 0) return simulatorModel;
|
||||||
|
#endif
|
||||||
|
struct utsname systemInfo;
|
||||||
|
if (uname(&systemInfo) == 0) {
|
||||||
|
NSString *model = [NSString stringWithUTF8String:systemInfo.machine];
|
||||||
|
if (model.length > 0) return model;
|
||||||
|
}
|
||||||
|
return @"";
|
||||||
|
}
|
||||||
|
@end
|
||||||
|
|
||||||
__attribute__((constructor))
|
__attribute__((constructor))
|
||||||
static void GRBootstrapInstall(void)
|
static void GRBootstrapInstall(void)
|
||||||
|
|||||||
@@ -67,6 +67,124 @@ public final class GRPickerBridge: NSObject {
|
|||||||
return succeeded
|
return succeeded
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - General HTTP request (love.system.httpRequest)
|
||||||
|
|
||||||
|
private static let httpMaxResponse = 4 * 1024 * 1024
|
||||||
|
|
||||||
|
// URLSession turns a 301/302/303 POST into a GET on its own. Save sync
|
||||||
|
// signs a method and a body, so every hop re-sends the original request
|
||||||
|
// against the new URL instead, and only over https.
|
||||||
|
private final class GRRedirectKeeper: NSObject, URLSessionTaskDelegate {
|
||||||
|
func urlSession(_ session: URLSession, task: URLSessionTask,
|
||||||
|
willPerformHTTPRedirection response: HTTPURLResponse,
|
||||||
|
newRequest request: URLRequest,
|
||||||
|
completionHandler: @escaping (URLRequest?) -> Void) {
|
||||||
|
guard let original = task.originalRequest,
|
||||||
|
let target = request.url,
|
||||||
|
target.scheme?.lowercased() == "https" else {
|
||||||
|
completionHandler(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var next = original
|
||||||
|
next.url = target
|
||||||
|
completionHandler(next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let httpSession = URLSession(configuration: .ephemeral,
|
||||||
|
delegate: GRRedirectKeeper(),
|
||||||
|
delegateQueue: nil)
|
||||||
|
|
||||||
|
private static func httpEnvelope(_ head: String, _ payload: Data?) -> NSData {
|
||||||
|
var out = Data((head + "\n").utf8)
|
||||||
|
if let payload { out.append(payload) }
|
||||||
|
return out as NSData
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func httpErrorText(_ error: Error) -> String {
|
||||||
|
var text = error.localizedDescription
|
||||||
|
.replacingOccurrences(of: "\r", with: " ")
|
||||||
|
.replacingOccurrences(of: "\n", with: " ")
|
||||||
|
if text.isEmpty { text = "the request failed" }
|
||||||
|
if text.count > 160 { text = String(text.prefix(160)) }
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Blocking HTTPS request with a chosen method, headers and byte body, the
|
||||||
|
/// iOS half of love.system.httpRequest (see the Android GameActivity one).
|
||||||
|
/// Headers arrive as "name: value" lines joined by newlines. The reply is
|
||||||
|
/// an envelope: a head line of "STATUS <code>" or "ERROR <text>", a
|
||||||
|
/// newline, then the raw response bytes -- read for 4xx and 5xx as well,
|
||||||
|
/// because a sync conflict answers 409 with the save that won.
|
||||||
|
@objc(httpRequestWithUrl:method:headers:body:bodyLength:userAgent:)
|
||||||
|
public static func httpRequest(url: UnsafePointer<CChar>?,
|
||||||
|
method: UnsafePointer<CChar>?,
|
||||||
|
headers: UnsafePointer<CChar>?,
|
||||||
|
body: UnsafePointer<UInt8>?,
|
||||||
|
bodyLength: Int32,
|
||||||
|
userAgent: UnsafePointer<CChar>?) -> NSData? {
|
||||||
|
guard let url, let requestURL = URL(string: String(cString: url)) else {
|
||||||
|
return httpEnvelope("ERROR missing url", nil)
|
||||||
|
}
|
||||||
|
guard requestURL.scheme?.lowercased() == "https" else {
|
||||||
|
return httpEnvelope("ERROR https only", nil)
|
||||||
|
}
|
||||||
|
let verb = (method.map { String(cString: $0) } ?? "GET").uppercased()
|
||||||
|
guard ["GET", "POST", "PUT", "DELETE"].contains(verb) else {
|
||||||
|
return httpEnvelope("ERROR unsupported request method", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = URLRequest(url: requestURL)
|
||||||
|
request.httpMethod = verb
|
||||||
|
request.timeoutInterval = 60
|
||||||
|
request.setValue(userAgent.map { String(cString: $0) } ?? "gen1recomp",
|
||||||
|
forHTTPHeaderField: "User-Agent")
|
||||||
|
if let headers, headers.pointee != 0 {
|
||||||
|
for line in String(cString: headers).split(separator: "\n") {
|
||||||
|
guard let colon = line.firstIndex(of: ":") else {
|
||||||
|
return httpEnvelope("ERROR bad request header", nil)
|
||||||
|
}
|
||||||
|
let name = line[line.startIndex..<colon]
|
||||||
|
.trimmingCharacters(in: .whitespaces)
|
||||||
|
let value = line[line.index(after: colon)...]
|
||||||
|
.trimmingCharacters(in: .whitespaces)
|
||||||
|
if name.isEmpty {
|
||||||
|
return httpEnvelope("ERROR bad request header", nil)
|
||||||
|
}
|
||||||
|
request.setValue(value, forHTTPHeaderField: name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if verb != "GET", let body, bodyLength > 0 {
|
||||||
|
request.httpBody = Data(bytes: body, count: Int(bodyLength))
|
||||||
|
}
|
||||||
|
|
||||||
|
let semaphore = DispatchSemaphore(value: 0)
|
||||||
|
var envelope = httpEnvelope("ERROR no response", nil)
|
||||||
|
let task = httpSession.dataTask(with: request) { data, response, error in
|
||||||
|
defer { semaphore.signal() }
|
||||||
|
if let error {
|
||||||
|
envelope = httpEnvelope("ERROR " + httpErrorText(error), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let http = response as? HTTPURLResponse else {
|
||||||
|
envelope = httpEnvelope("ERROR no response", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let payload = data ?? Data()
|
||||||
|
if payload.count > httpMaxResponse {
|
||||||
|
envelope = httpEnvelope("ERROR the reply was too large", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
envelope = httpEnvelope("STATUS \(http.statusCode)", payload)
|
||||||
|
}
|
||||||
|
task.resume()
|
||||||
|
guard semaphore.wait(timeout: .now() + 65) == .success else {
|
||||||
|
task.cancel()
|
||||||
|
return httpEnvelope("ERROR the request timed out", nil)
|
||||||
|
}
|
||||||
|
return envelope
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Entry points called from liblove (C strings on purpose)
|
// MARK: - Entry points called from liblove (C strings on purpose)
|
||||||
|
|
||||||
@objc(presentPickerWithKind:saveDir:)
|
@objc(presentPickerWithKind:saveDir:)
|
||||||
|
|||||||