Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f5b8b6c85f | |||
| db25c14dfb | |||
| 90163a3ff2 | |||
| 4bdb9435a4 | |||
| ada0d8abe1 | |||
| dbecc345e3 | |||
| 0f8f6d0e4f | |||
| 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 | |||
| 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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -140,17 +140,17 @@ 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 and Silver are Gen 2, and they run their own battle engine,
|
||||||
VM and save format. The mod API is shared across both generations (same hook
|
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"`), 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. `python3 tools/modkit.py scaffold my_mod --games
|
||||||
gen1,gen2` writes the key for you. The mod still installs to one directory,
|
gen1,gen2` writes the key for you. The mod still installs to one directory,
|
||||||
@@ -159,10 +159,10 @@ gen1,gen2` writes the key for you. The mod still installs to one directory,
|
|||||||
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 Gold or Silver boot a mod claiming no Gen 2 game is not
|
||||||
all: the manager lists it as `ENABLED (NOT THIS GAME)` and says why, because a
|
loaded at all: the manager lists it as `ENABLED (NOT THIS GAME)` and says why,
|
||||||
mod that half-applies reads as a broken mod. Claim Gen 2 once you have actually
|
because a mod that half-applies reads as a broken mod. Claim Gen 2 once you
|
||||||
run your mod on Gold.
|
have actually run your mod on Gold or Silver.
|
||||||
|
|
||||||
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"]`
|
||||||
@@ -173,7 +173,7 @@ 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,
|
and Silver today (40 of the 46 registries, 40 event and 43 hook names shared with Gen 1,
|
||||||
and 24 Gen 2-only ones), which registries have no Gen 2 home and drop their
|
and 24 Gen 2-only ones), which registries have no Gen 2 home and drop their
|
||||||
writes with a report, and which hooks and events are still to come.
|
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
|
||||||
|
|||||||
@@ -53,18 +53,17 @@ 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, or
|
||||||
is the only game content input.
|
Silver 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, and Silver can all be
|
||||||
side by side. Gold is Gen 2 Phase 1 (import + launcher; see
|
imported side by side. Gold and Silver are Gen 2 Phase 1 (import + launcher;
|
||||||
`docs/gold-phase1.md`): the Gen 2 engine is still under construction.
|
see `docs/gold-phase1.md`): the Gen 2 engine is still under construction.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -72,13 +71,14 @@ 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, and Silver (2 MiB)
|
||||||
accepted. The importer verifies SHA-1 before creating any game data:
|
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`
|
||||||
|
|
||||||
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
|
||||||
@@ -219,7 +219,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` and `silver` too, or just `r` / `b` / `y` / `g` / `s`) |
|
||||||
| `--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 \
|
||||||
-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 \
|
||||||
-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,8 @@ 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"
|
||||||
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 +196,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"
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
@@ -837,13 +837,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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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"`), generations (`"gen1"`, `"gen2"`, case-insensitive) or
|
||||||
`src/mods/ModTargets.lua` resolves the tokens off `GameVersion.ORDER` and
|
`"all"`. `src/mods/ModTargets.lua` resolves the tokens off `GameVersion.ORDER`
|
||||||
`GameVersion.generation`, so nothing anywhere restates the game list.
|
and `GameVersion.generation`, so nothing anywhere restates the game list.
|
||||||
|
`"gen2"` now expands to both Gold and Silver.
|
||||||
`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.
|
||||||
|
|||||||
@@ -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. |
|
||||||
|
|||||||
@@ -11,10 +11,13 @@ 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**
|
||||||
|
* **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
|
||||||
|
|
||||||
## Gen 2 Specifics
|
## Gen 2 Specifics
|
||||||
|
|
||||||
|
* **Pokémon Silver** as an importable, launcher-selectable version alongside Gold
|
||||||
* **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"` | 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 |
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -95,18 +95,22 @@ auto-rotates like a RetroArch one. Item `frame` rects are top-left plus size in
|
|||||||
`extendedEdges` merge per key into the reach fields; `mask: "circle"` becomes a
|
`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
|
radial hitbox. A `dpad` or `thumbstick` item expands into the 3x3 grid, so the
|
||||||
corners fire two directions. `screens[1].outputFrame` (or the legacy
|
corners fire two directions. `screens[1].outputFrame` (or the legacy
|
||||||
`gameScreenFrame`) becomes the screen cutout, and the skin stretches to the
|
`gameScreenFrame`) becomes the screen cutout. A portrait page with neither
|
||||||
window the way Delta does rather than letterboxing. Host functions map to
|
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 still
|
||||||
|
stretch to the window the way Delta does. Host functions map to
|
||||||
engine hotkeys: `menu` to `menu_toggle`, `fastForward` to
|
engine hotkeys: `menu` to `menu_toggle`, `fastForward` to
|
||||||
`hold_fast_forward`, `toggleFastForward` to `toggle_fast_forward`;
|
`hold_fast_forward`, `toggleFastForward` to `toggle_fast_forward`;
|
||||||
`quickSave` and `quickLoad` have nothing to bind to and drop to decoration.
|
`quickSave` and `quickLoad` have nothing to bind to and drop to decoration.
|
||||||
Both `com.rileytestut.delta.game.*` and Manic's `public.aoshuang.game.*`
|
Both `com.rileytestut.delta.game.*` and Manic's `public.aoshuang.game.*`
|
||||||
identifiers are accepted, and a non Game Boy system warns instead of failing.
|
identifiers are accepted, and a non Game Boy system warns instead of failing.
|
||||||
|
|
||||||
PDF artwork is the one thing that does not come across: Delta's own templates
|
PDF artwork is usually a JPEG wrapped so iOS can scale it (Delta's
|
||||||
are all-PDF and this engine has no rasterizer, so such a skin is refused with
|
Image-to-PDF skins, Preview exports, and the like). Import extracts that
|
||||||
the message asking for a PNG version. GBA4iOS `.gbcskin` / `.gbaskin` files are
|
JPEG and draws it; a true vector PDF with no embedded image is still refused,
|
||||||
an older, incompatible schema and are refused by name.
|
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
|
||||||
|
|
||||||
@@ -268,6 +272,7 @@ exported file** opens that folder.
|
|||||||
|
|
||||||
## Not implemented
|
## Not implemented
|
||||||
|
|
||||||
Delta skins whose art is PDF only. Rasterizing them needs a PDF renderer this
|
True vector Delta skins (PDF artwork with no embedded JPEG). Those still need
|
||||||
engine does not carry, so they are refused with a message rather than imported
|
a PDF renderer this engine does not carry, so they are refused with a message
|
||||||
half-drawn.
|
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)).
|
||||||
|
|||||||
@@ -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/` then that game's SAVE FILES → **Import save** (Gold / Silver cart `.sav` not supported yet) |
|
||||||
| 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/` (pull after **Export save**; Gold / Silver cart `.sav` not supported yet) |
|
||||||
| 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>/`, or
|
||||||
|
`exports/<red|blue|yellow|gold|silver>/`
|
||||||
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,14 @@ 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. 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 +152,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
|
||||||
|
|||||||
@@ -327,6 +327,7 @@ local function returnToLauncher()
|
|||||||
if love.audio and love.audio.stop then
|
if love.audio and love.audio.stop then
|
||||||
pcall(love.audio.stop)
|
pcall(love.audio.stop)
|
||||||
end
|
end
|
||||||
|
pcall(function() require("src.render.SecondScreen").setEnabled(false) end)
|
||||||
|
|
||||||
local GameVersion = require("src.core.GameVersion")
|
local GameVersion = require("src.core.GameVersion")
|
||||||
local currentVersion = GameVersion.get()
|
local currentVersion = GameVersion.get()
|
||||||
@@ -386,11 +387,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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -35,6 +39,18 @@
|
|||||||
<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"
|
||||||
|
|||||||
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 24 KiB |
@@ -8,4 +8,5 @@
|
|||||||
<color name="shortcut_blue">#1E88E5</color>
|
<color name="shortcut_blue">#1E88E5</color>
|
||||||
<color name="shortcut_yellow">#FDD835</color>
|
<color name="shortcut_yellow">#FDD835</color>
|
||||||
<color name="shortcut_gold">#D4AF37</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 {
|
||||||
|
|||||||
@@ -283,6 +283,40 @@ 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)
|
bool updateAppShortcuts(const std::vector<std::string> &versions)
|
||||||
{
|
{
|
||||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||||
|
|||||||
@@ -90,6 +90,12 @@ 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.
|
* Dynamic App Shortcuts: updates Android ShortcutManager with ready game versions.
|
||||||
**/
|
**/
|
||||||
|
|||||||
@@ -245,6 +245,16 @@ 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
|
bool System::updateShortcuts(const std::vector<std::string> &versions) const
|
||||||
{
|
{
|
||||||
#ifdef LOVE_ANDROID
|
#ifdef LOVE_ANDROID
|
||||||
|
|||||||
@@ -143,6 +143,9 @@ 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 bool updateShortcuts(const std::vector<std::string> &versions) const;
|
||||||
virtual std::string getLaunchGame() const;
|
virtual std::string getLaunchGame() const;
|
||||||
|
|
||||||
|
|||||||
@@ -132,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);
|
||||||
@@ -325,6 +332,7 @@ 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 },
|
{ "updateShortcuts", w_updateShortcuts },
|
||||||
{ "getLaunchGame", w_getLaunchGame },
|
{ "getLaunchGame", w_getLaunchGame },
|
||||||
{ "httpDownload", w_httpDownload },
|
{ "httpDownload", w_httpDownload },
|
||||||
|
|||||||
@@ -45,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;
|
||||||
@@ -77,6 +78,7 @@ 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;
|
||||||
@@ -398,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();
|
||||||
@@ -411,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();
|
||||||
@@ -426,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();
|
||||||
@@ -690,6 +698,103 @@ 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
|
@Keep
|
||||||
public static String getLaunchGame() {
|
public static String getLaunchGame() {
|
||||||
return initialGame != null ? initialGame : "";
|
return initialGame != null ? initialGame : "";
|
||||||
@@ -1933,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;
|
||||||
@@ -1963,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();
|
||||||
@@ -2035,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();
|
||||||
});
|
});
|
||||||
@@ -2045,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,34 @@
|
|||||||
"tintColor": "3b5ca8",
|
"tintColor": "3b5ca8",
|
||||||
"category": "games",
|
"category": "games",
|
||||||
"versions": [
|
"versions": [
|
||||||
|
{
|
||||||
|
"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",
|
"version": "0.2.7",
|
||||||
"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)
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ int w_syncHealthSteps(lua_State *L)
|
|||||||
""" % MARKER
|
""" % MARKER
|
||||||
|
|
||||||
WRAP_REGISTRATION = """#ifdef LOVE_IOS
|
WRAP_REGISTRATION = """#ifdef LOVE_IOS
|
||||||
|
{ "getDeviceModel", w_getDeviceModel },
|
||||||
{ "pickFile", w_pickFile },
|
{ "pickFile", w_pickFile },
|
||||||
{ "pickFileKinds", w_pickFileKinds },
|
{ "pickFileKinds", w_pickFileKinds },
|
||||||
{ "createFile", w_createFile },
|
{ "createFile", w_createFile },
|
||||||
@@ -202,6 +203,7 @@ int w_syncHealthSteps(lua_State *L)
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
WRAP_SYNC_REGISTRATION = """#ifdef LOVE_IOS
|
WRAP_SYNC_REGISTRATION = """#ifdef LOVE_IOS
|
||||||
|
{ "getDeviceModel", w_getDeviceModel },
|
||||||
{ "syncHealthSteps", w_syncHealthSteps },
|
{ "syncHealthSteps", w_syncHealthSteps },
|
||||||
{ "httpDownload", w_httpDownload },
|
{ "httpDownload", w_httpDownload },
|
||||||
{ "httpRequest", w_httpRequest },
|
{ "httpRequest", w_httpRequest },
|
||||||
@@ -210,6 +212,33 @@ WRAP_SYNC_REGISTRATION = """#ifdef LOVE_IOS
|
|||||||
|
|
||||||
BRIDGE_EXTRA_FUNCS = """
|
BRIDGE_EXTRA_FUNCS = """
|
||||||
#ifdef LOVE_IOS
|
#ifdef LOVE_IOS
|
||||||
|
int w_getDeviceModel(lua_State *L)
|
||||||
|
{
|
||||||
|
Class cls = objc_getClass("GRDeviceBridge");
|
||||||
|
if (cls == nullptr)
|
||||||
|
{
|
||||||
|
lua_pushnil(L);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
typedef id (*GRObj)(Class, SEL);
|
||||||
|
id value = ((GRObj)objc_msgSend)(cls, sel_registerName("deviceModel"));
|
||||||
|
if (value == nullptr)
|
||||||
|
{
|
||||||
|
lua_pushnil(L);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
typedef const char *(*GRUTF8)(id, SEL);
|
||||||
|
const char *bytes = ((GRUTF8)objc_msgSend)(value,
|
||||||
|
sel_registerName("UTF8String"));
|
||||||
|
if (bytes == nullptr || bytes[0] == '\\0')
|
||||||
|
{
|
||||||
|
lua_pushnil(L);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
lua_pushstring(L, bytes);
|
||||||
|
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);
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 318 B After Width: | Height: | Size: 318 B |
|
Before Width: | Height: | Size: 687 B After Width: | Height: | Size: 687 B |
@@ -0,0 +1,12 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
Format: [keep a changelog](https://keepachangelog.com/en/1.1.0/).
|
||||||
|
Version headings match `manifest.json`'s `version`.
|
||||||
|
|
||||||
|
## 1.0.0
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `intro.oak_speech.build` wrap that injects toast / MEW / snack / rival-trust / pineapple beats.
|
||||||
|
- Answers written to `mod.save` via `intro.oak_speech.answered`.
|
||||||
|
- Custom `toast_kid.png` sprite shown mid-speech.
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Silly Oak Intro Example
|
||||||
|
|
||||||
|
Hooks Oak's NEW GAME speech: extra questions, sprite swaps (Oak, rival,
|
||||||
|
player, MEW, and a custom Toast Kid pic), and answers stored in `mod.save`.
|
||||||
|
|
||||||
|
## Try it (play through yourself)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
rm -rf mods/example_silly_oak
|
||||||
|
cp -r mods/examples/example_silly_oak mods/
|
||||||
|
love .
|
||||||
|
```
|
||||||
|
|
||||||
|
Then **NEW GAME** and mash A / pick the menus. Disable or delete
|
||||||
|
`mods/example_silly_oak` when you're done so vanilla boots clean.
|
||||||
|
|
||||||
|
## Headless check
|
||||||
|
|
||||||
|
```sh
|
||||||
|
luajit mods/examples/example_silly_oak/tests/example_silly_oak_test.lua
|
||||||
|
```
|
||||||
|
|
||||||
|
## Auto driver (screenshots + save asserts)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
rm -rf mods/example_silly_oak
|
||||||
|
cp -r mods/examples/example_silly_oak mods/
|
||||||
|
SHOT_DIR=/tmp/silly_oak POKEPORT_IDENTITY=silly_oak_driver_test \
|
||||||
|
POKEPORT_DRIVER=tests/drivers/silly_oak_intro_test.lua POKEPORT_SPEED=8 love .
|
||||||
|
```
|
||||||
|
|
||||||
|
`POKEPORT_IDENTITY` keeps this run's save out of your normal slot.
|
||||||
|
|
||||||
|
## What it demonstrates
|
||||||
|
|
||||||
|
| Seam | Where |
|
||||||
|
|---|---|
|
||||||
|
| `hooks:wrap("intro.oak_speech.build")` | `main.lua` -- reshape the step list |
|
||||||
|
| `mod.ui.insertStepAfter` / `insertStepBefore` | `main.lua` -- anchored on vanilla step ids |
|
||||||
|
| step kinds `say` / `yesno` / `choice` | `main.lua` |
|
||||||
|
| pics: `"oak"`, `"rival"`, `"player"`, pokemon, custom image | `main.lua` |
|
||||||
|
| `events:on("intro.oak_speech.answered")` | `main.lua` → `mod.save` |
|
||||||
|
| `events:on("intro.oak_speech.finished")` | `main.lua` |
|
||||||
|
After Width: | Height: | Size: 245 B |
@@ -0,0 +1,107 @@
|
|||||||
|
-- Gallery entry: reshape Oak's intro speech with extra questions, sprite
|
||||||
|
-- swaps (oak / rival / player / pokemon / a custom image), and answers
|
||||||
|
-- that land in mod.save.
|
||||||
|
--
|
||||||
|
-- Uses hooks:wrap("intro.oak_speech.build") plus intro.oak_speech.answered.
|
||||||
|
|
||||||
|
return function(mod)
|
||||||
|
local toastPic = mod.path .. "/assets/toast_kid.png"
|
||||||
|
|
||||||
|
mod.hooks:wrap("intro.oak_speech.build", function(next, steps, speech)
|
||||||
|
steps = next(steps, speech)
|
||||||
|
|
||||||
|
-- after oak says hello, immediately derail
|
||||||
|
mod.ui.insertStepAfter(steps, "oak_welcome", {
|
||||||
|
id = "silly_quiz_intro",
|
||||||
|
kind = "say",
|
||||||
|
pic = "oak",
|
||||||
|
text = "Before we start,\nI have a few\vquestions.\fImportant ones.\nScientific ones.",
|
||||||
|
})
|
||||||
|
|
||||||
|
mod.ui.insertStepAfter(steps, "silly_quiz_intro", {
|
||||||
|
id = "silly_toast",
|
||||||
|
kind = "yesno",
|
||||||
|
pic = "oak",
|
||||||
|
saveKey = "likes_toast",
|
||||||
|
text = "Do you like\ntoast?",
|
||||||
|
})
|
||||||
|
|
||||||
|
-- brand new sprite mid-speech
|
||||||
|
mod.ui.insertStepAfter(steps, "silly_toast", {
|
||||||
|
id = "silly_toast_kid",
|
||||||
|
kind = "say",
|
||||||
|
pic = { type = "image", path = toastPic },
|
||||||
|
reveal = "fade",
|
||||||
|
saveKey = nil,
|
||||||
|
text = "This is Toast Kid.\nHe is not a\vPOKéMON.\fHe just showed up\none day.\fAnyway.",
|
||||||
|
})
|
||||||
|
|
||||||
|
-- existing mon with a wipe + cry, parked after the real demo mon
|
||||||
|
mod.ui.insertStepAfter(steps, "demo_mon", {
|
||||||
|
id = "silly_mew",
|
||||||
|
kind = "say",
|
||||||
|
pic = { type = "pokemon", id = "MEW" },
|
||||||
|
reveal = "wipe",
|
||||||
|
cry = "MEW",
|
||||||
|
text = "This is MEW.\nPlease do not\vtell anyone\vI showed you.",
|
||||||
|
})
|
||||||
|
|
||||||
|
mod.ui.insertStepAfter(steps, "silly_mew", {
|
||||||
|
id = "silly_snack",
|
||||||
|
kind = "choice",
|
||||||
|
pic = "oak",
|
||||||
|
saveKey = "snack",
|
||||||
|
text = "Pick a snack.\nThis goes on\vyour permanent\vrecord.",
|
||||||
|
choices = { "BERRIES", "LEFTOVERS", "OLD ROD" },
|
||||||
|
})
|
||||||
|
|
||||||
|
-- swap to rival pic for a loaded question before naming him
|
||||||
|
mod.ui.insertStepBefore(steps, "ask_rival_name", {
|
||||||
|
id = "silly_trust",
|
||||||
|
kind = "choice",
|
||||||
|
pic = "rival",
|
||||||
|
reveal = "fade",
|
||||||
|
saveKey = "trusts_rival",
|
||||||
|
text = "Look at this kid.\nTrustworthy?",
|
||||||
|
choices = { "SURE", "NO" },
|
||||||
|
values = { true, false },
|
||||||
|
})
|
||||||
|
|
||||||
|
-- player pic for one last bit after both names are set
|
||||||
|
mod.ui.insertStepAfter(steps, "name_rival", {
|
||||||
|
id = "silly_pineapple",
|
||||||
|
kind = "yesno",
|
||||||
|
pic = "player",
|
||||||
|
saveKey = "pineapple_on_pizza",
|
||||||
|
text = "{PLAYER}. Be honest.\nPineapple on\vpizza?",
|
||||||
|
})
|
||||||
|
|
||||||
|
mod.ui.insertStepAfter(steps, "silly_pineapple", {
|
||||||
|
id = "silly_closing",
|
||||||
|
kind = "say",
|
||||||
|
pic = "oak",
|
||||||
|
text = "Great. Terrible.\nI have notes.\fLet's pretend this\nwas normal.",
|
||||||
|
})
|
||||||
|
|
||||||
|
return steps
|
||||||
|
end)
|
||||||
|
|
||||||
|
-- every answered step with a saveKey lands in mod.save (and therefore
|
||||||
|
-- save.modData[mod.id] once the slot is written)
|
||||||
|
mod.events:on("intro.oak_speech.answered", function(ev)
|
||||||
|
if not ev.saveKey then return end
|
||||||
|
mod.save:set(ev.saveKey, ev.value)
|
||||||
|
mod.log:info("intro answer %s = %s", tostring(ev.saveKey), tostring(ev.value))
|
||||||
|
end)
|
||||||
|
|
||||||
|
mod.events:on("intro.oak_speech.finished", function(ev)
|
||||||
|
local answers = ev.answers or {}
|
||||||
|
for key, value in pairs(answers) do
|
||||||
|
if mod.save:get(key) == nil then
|
||||||
|
mod.save:set(key, value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
mod.save:set("quiz_done", true)
|
||||||
|
mod.log:info("silly oak quiz done")
|
||||||
|
end)
|
||||||
|
end
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"id": "example_silly_oak",
|
||||||
|
"name": "Silly Oak Intro Example",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"api": 2,
|
||||||
|
"entry": "main.lua",
|
||||||
|
"profile": "content",
|
||||||
|
"category": "UI",
|
||||||
|
"game_version": ">=0.0.0-0 <2.0.0",
|
||||||
|
"priority": 100,
|
||||||
|
"dependencies": [],
|
||||||
|
"optional_dependencies": [],
|
||||||
|
"conflicts": [],
|
||||||
|
"description": "Reshapes Oak's intro speech with extra questions, sprite swaps, and answers saved to mod.save."
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- Sharing metadata for the manager detail pane.
|
||||||
|
return {
|
||||||
|
summary = "Oak asks dumb questions during the intro and remembers your answers.",
|
||||||
|
author = "Pokemon Gen 1 Recompilation Project",
|
||||||
|
contact = "https://github.com/bryanthaboi/gen1recomp",
|
||||||
|
tags = { "intro", "ui", "oak", "hooks" },
|
||||||
|
differences = {
|
||||||
|
changed = {
|
||||||
|
"Oak's NEW GAME speech gains extra questions and sprite beats",
|
||||||
|
},
|
||||||
|
added = {
|
||||||
|
"mod.save keys: likes_toast, snack, trusts_rival, pineapple_on_pizza, quiz_done",
|
||||||
|
"Custom Toast Kid pic mid-intro",
|
||||||
|
},
|
||||||
|
known = { "vanilla naming and the shrink-away still run" },
|
||||||
|
},
|
||||||
|
credits = {
|
||||||
|
{ who = "Pokemon Gen 1 Recompilation Project", for_ = "the intro.oak_speech hooks" },
|
||||||
|
},
|
||||||
|
compat = { engine = ">=0.0.0 <2.0.0", modApi = 2 },
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
-- Standalone: luajit mods/examples/example_silly_oak/tests/example_silly_oak_test.lua
|
||||||
|
-- Covers the intro.oak_speech build hook, step helpers, sprite descriptors,
|
||||||
|
-- and answers landing in mod.save.
|
||||||
|
--
|
||||||
|
-- Needs an imported ROM dataset (data/generated/). Headless CI and a
|
||||||
|
-- fresh checkout without a ROM skip cleanly -- the gallery is also
|
||||||
|
-- covered by tests/mod_examples_tests.lua when generated data is present.
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local function hasGenerated()
|
||||||
|
local handle = io.open("data/generated/constants.lua", "r")
|
||||||
|
if handle then handle:close() return true end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
if not hasGenerated() then
|
||||||
|
print("example_silly_oak_test skipped (needs data/generated/)")
|
||||||
|
os.exit(0)
|
||||||
|
end
|
||||||
|
|
||||||
|
local T = require("tests.modkit")
|
||||||
|
local Runtime = require("src.mods.Runtime")
|
||||||
|
local OakSpeech = require("src.ui.OakSpeech")
|
||||||
|
local Data = require("src.core.Data")
|
||||||
|
Data:load()
|
||||||
|
|
||||||
|
local run = T.sdk.loadMod("mods/examples/example_silly_oak", { data = Data })
|
||||||
|
T.eq(#run.errors, 0, "loads clean (" .. tostring(run.errors[1]) .. ")")
|
||||||
|
|
||||||
|
local mod = run.mod
|
||||||
|
T.check(mod ~= nil and mod.state == "loaded", "mod reached the loaded state")
|
||||||
|
local ModUI = require("src.ui.ModUI")
|
||||||
|
local bucket = function()
|
||||||
|
return run.loader.modSave.example_silly_oak or {}
|
||||||
|
end
|
||||||
|
local toastPath = (mod.path or "mods/examples/example_silly_oak")
|
||||||
|
.. "/assets/toast_kid.png"
|
||||||
|
|
||||||
|
-- ------- build hook injects every silly beat around vanilla anchors
|
||||||
|
|
||||||
|
local speech = OakSpeech.new({
|
||||||
|
data = Data,
|
||||||
|
save = { player = { name = "RED", rival = "BLUE" } },
|
||||||
|
stack = { push = function() end, pop = function() end },
|
||||||
|
}, nil)
|
||||||
|
local steps = speech:buildSteps()
|
||||||
|
|
||||||
|
local ids = {}
|
||||||
|
for _, step in ipairs(steps) do ids[#ids + 1] = step.id end
|
||||||
|
local function has(id)
|
||||||
|
for _, x in ipairs(ids) do if x == id then return true end end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
T.check(has("oak_welcome") and has("name_player") and has("shrink"),
|
||||||
|
"vanilla anchors still present")
|
||||||
|
T.check(has("silly_quiz_intro") and has("silly_toast") and has("silly_toast_kid"),
|
||||||
|
"toast quiz beats injected")
|
||||||
|
T.check(has("silly_mew") and has("silly_snack"),
|
||||||
|
"MEW reveal and snack choice injected")
|
||||||
|
T.check(has("silly_trust") and has("silly_pineapple") and has("silly_closing"),
|
||||||
|
"rival trust + pineapple beats injected")
|
||||||
|
|
||||||
|
-- order: toast kid before demo_mon, mew after demo_mon, trust before rival ask
|
||||||
|
local function indexOf(id)
|
||||||
|
for i, x in ipairs(ids) do if x == id then return i end end
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
T.check(indexOf("silly_toast_kid") < indexOf("demo_mon"),
|
||||||
|
"Toast Kid shows before the demo mon")
|
||||||
|
T.check(indexOf("demo_mon") < indexOf("silly_mew"),
|
||||||
|
"MEW shows after the demo mon")
|
||||||
|
T.check(indexOf("silly_trust") < indexOf("ask_rival_name"),
|
||||||
|
"trust question is before rival naming")
|
||||||
|
T.check(indexOf("name_rival") < indexOf("silly_pineapple")
|
||||||
|
and indexOf("silly_pineapple") < indexOf("legend"),
|
||||||
|
"pineapple lands between rival name and the legend beat")
|
||||||
|
|
||||||
|
-- ------- step shapes cover choice / yesno / custom image / pokemon
|
||||||
|
|
||||||
|
local byId = {}
|
||||||
|
for _, step in ipairs(steps) do byId[step.id] = step end
|
||||||
|
|
||||||
|
T.eq(byId.silly_toast.kind, "yesno", "toast is a yes/no")
|
||||||
|
T.eq(byId.silly_toast.saveKey, "likes_toast", "toast writes likes_toast")
|
||||||
|
T.eq(byId.silly_snack.kind, "choice", "snack is a multi choice")
|
||||||
|
T.eq(#byId.silly_snack.choices, 3, "snack has three options")
|
||||||
|
T.check(byId.silly_toast_kid.pic and byId.silly_toast_kid.pic.type == "image",
|
||||||
|
"Toast Kid uses a custom image pic")
|
||||||
|
T.check(byId.silly_mew.pic and byId.silly_mew.pic.type == "pokemon"
|
||||||
|
and byId.silly_mew.pic.id == "MEW" and byId.silly_mew.cry == "MEW",
|
||||||
|
"MEW beat uses pokemon pic + cry")
|
||||||
|
T.eq(byId.silly_trust.pic, "rival", "trust question shows the rival pic")
|
||||||
|
|
||||||
|
-- ------- resolvePic covers trainer / pokemon / player / image shorthand
|
||||||
|
|
||||||
|
local oakImg = OakSpeech.resolvePic({ data = Data }, "oak", speech)
|
||||||
|
local rivalImg = OakSpeech.resolvePic({ data = Data }, "rival", speech)
|
||||||
|
local playerImg = OakSpeech.resolvePic({ data = Data }, "player", speech)
|
||||||
|
local mewImg, mewFlip = OakSpeech.resolvePic({ data = Data },
|
||||||
|
{ type = "pokemon", id = "MEW", flip = true }, speech)
|
||||||
|
local customImg = OakSpeech.resolvePic({ data = Data },
|
||||||
|
{ type = "image", path = toastPath }, speech)
|
||||||
|
-- headless love stub may return nil images; the call itself must not throw
|
||||||
|
T.check(oakImg == speech.oakPic or oakImg == nil or type(oakImg) == "userdata"
|
||||||
|
or type(oakImg) == "table",
|
||||||
|
"oak shorthand resolves without error")
|
||||||
|
T.check(rivalImg == speech.rivalPic or rivalImg == nil or type(rivalImg) == "userdata"
|
||||||
|
or type(rivalImg) == "table",
|
||||||
|
"rival shorthand resolves without error")
|
||||||
|
T.check(playerImg == speech.playerPic or playerImg == nil
|
||||||
|
or type(playerImg) == "userdata" or type(playerImg) == "table",
|
||||||
|
"player shorthand resolves without error")
|
||||||
|
T.check(mewFlip == true, "pokemon flip flag is honored")
|
||||||
|
T.check(customImg ~= nil or true, "custom image path is accepted")
|
||||||
|
|
||||||
|
-- ------- answered event writes mod.save (loader.modSave bucket)
|
||||||
|
|
||||||
|
Runtime.emit("intro.oak_speech.answered", {
|
||||||
|
saveKey = "likes_toast", value = true, label = "YES", index = 1,
|
||||||
|
step = byId.silly_toast, speech = speech,
|
||||||
|
})
|
||||||
|
Runtime.emit("intro.oak_speech.answered", {
|
||||||
|
saveKey = "snack", value = "OLD ROD", label = "OLD ROD", index = 3,
|
||||||
|
step = byId.silly_snack, speech = speech,
|
||||||
|
})
|
||||||
|
Runtime.emit("intro.oak_speech.answered", {
|
||||||
|
saveKey = "trusts_rival", value = false, label = "NO", index = 2,
|
||||||
|
step = byId.silly_trust, speech = speech,
|
||||||
|
})
|
||||||
|
Runtime.emit("intro.oak_speech.answered", {
|
||||||
|
saveKey = "pineapple_on_pizza", value = true, label = "YES", index = 1,
|
||||||
|
step = byId.silly_pineapple, speech = speech,
|
||||||
|
})
|
||||||
|
Runtime.emit("intro.oak_speech.finished", {
|
||||||
|
speech = speech, answers = speech.answers,
|
||||||
|
})
|
||||||
|
|
||||||
|
local saved = bucket()
|
||||||
|
T.eq(saved.likes_toast, true, "likes_toast saved")
|
||||||
|
T.eq(saved.snack, "OLD ROD", "snack saved")
|
||||||
|
T.eq(saved.trusts_rival, false, "trusts_rival saved")
|
||||||
|
T.eq(saved.pineapple_on_pizza, true, "pineapple_on_pizza saved")
|
||||||
|
T.eq(saved.quiz_done, true, "quiz_done stamped on finish")
|
||||||
|
|
||||||
|
-- ------- ModUI step helpers (public surface)
|
||||||
|
|
||||||
|
local tiny = {
|
||||||
|
{ id = "a", kind = "say" },
|
||||||
|
{ id = "b", kind = "say" },
|
||||||
|
}
|
||||||
|
ModUI.insertStepAfter(tiny, "a", { id = "mid", kind = "choice" })
|
||||||
|
T.eq(tiny[2].id, "mid", "insertStepAfter lands behind the anchor")
|
||||||
|
ModUI.insertStepBefore(tiny, "b", { id = "pre_b", kind = "yesno" })
|
||||||
|
T.eq(tiny[3].id, "pre_b", "insertStepBefore lands ahead of the anchor")
|
||||||
|
ModUI.removeStep(tiny, "mid")
|
||||||
|
T.check(tiny[2].id ~= "mid", "removeStep drops by id")
|
||||||
|
|
||||||
|
run.release()
|
||||||
|
T.finish("example_silly_oak")
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
#
|
#
|
||||||
# Usage: scripts/build.sh [mac|win|linux|android|ios|all] [--version X.Y.Z] [--identity "Developer ID Application: ..."]
|
# Usage: scripts/build.sh [mac|win|linux|android|ios|all] [--version X.Y.Z] [--identity "Developer ID Application: ..."]
|
||||||
# [--notary-profile NAME] [--no-notarize]
|
# [--notary-profile NAME] [--no-notarize]
|
||||||
|
# [--game-love PATH] # fuse a prebuilt payload (scripts/pack_love.sh) instead of packing one
|
||||||
# [--release] # ios only: release config instead of debug
|
# [--release] # ios only: release config instead of debug
|
||||||
#
|
#
|
||||||
# Output: dist/mac/gen1recomp-macos.zip
|
# Output: dist/mac/gen1recomp-macos.zip
|
||||||
@@ -36,6 +37,7 @@ NOTARY_PROFILE="notary-profile"
|
|||||||
NOTARIZE=true
|
NOTARIZE=true
|
||||||
IOS_RELEASE=false
|
IOS_RELEASE=false
|
||||||
IOS_IPA=false
|
IOS_IPA=false
|
||||||
|
GAME_LOVE_IN=""
|
||||||
|
|
||||||
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||||
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
|
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
|
||||||
@@ -48,6 +50,7 @@ while [ $# -gt 0 ]; do
|
|||||||
--identity) IDENTITY="$2"; shift ;;
|
--identity) IDENTITY="$2"; shift ;;
|
||||||
--notary-profile) NOTARY_PROFILE="$2"; shift ;;
|
--notary-profile) NOTARY_PROFILE="$2"; shift ;;
|
||||||
--no-notarize) NOTARIZE=false ;;
|
--no-notarize) NOTARIZE=false ;;
|
||||||
|
--game-love) GAME_LOVE_IN="${2:?--game-love needs a path}"; shift ;;
|
||||||
--release) IOS_RELEASE=true ;;
|
--release) IOS_RELEASE=true ;;
|
||||||
--ipa) IOS_IPA=true ;;
|
--ipa) IOS_IPA=true ;;
|
||||||
*) fail "unknown argument: $1" ;;
|
*) fail "unknown argument: $1" ;;
|
||||||
@@ -62,16 +65,23 @@ mkdir -p "$CACHE" "$WORK" "$DIST/mac" "$DIST/win" "$DIST/linux"
|
|||||||
# launcher's Edit button on a save row opens it in-process (main.lua), and
|
# launcher's Edit button on a save row opens it in-process (main.lua), and
|
||||||
# `--editor` / POKEPORT_EDITOR=1 opens it standalone. It is required through
|
# `--editor` / POKEPORT_EDITOR=1 opens it standalone. It is required through
|
||||||
# love.filesystem's require path, so it has to live inside the archive.
|
# love.filesystem's require path, so it has to live inside the archive.
|
||||||
say "packing game.love"
|
|
||||||
LOVE_FILE="$WORK/game.love"
|
LOVE_FILE="$WORK/game.love"
|
||||||
rm -f "$LOVE_FILE"
|
rm -f "$LOVE_FILE"
|
||||||
# The launcher UI kit lives at src/ui/kit (inside src/, packed wholesale);
|
if [ -n "$GAME_LOVE_IN" ]; then
|
||||||
# the vendored libs/flexlove tree it replaced is gone.
|
[ -f "$GAME_LOVE_IN" ] || fail "--game-love: no such file: $GAME_LOVE_IN"
|
||||||
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
|
say "using prebuilt payload: $GAME_LOVE_IN"
|
||||||
|
cp "$GAME_LOVE_IN" "$LOVE_FILE"
|
||||||
|
else
|
||||||
|
say "packing game.love"
|
||||||
|
# The launcher UI kit lives at src/ui/kit (inside src/, packed wholesale);
|
||||||
|
# the vendored libs/flexlove tree it replaced is gone.
|
||||||
|
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
|
||||||
main.lua conf.lua src data assets tools/save-editor \
|
main.lua conf.lua src 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 \
|
||||||
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
||||||
|
fi
|
||||||
# Materialize the listing once and grep the file: piping unzip straight into
|
# Materialize the listing once and grep the file: piping unzip straight into
|
||||||
# grep -q under `set -o pipefail` SIGPIPEs unzip when grep exits early on a
|
# grep -q under `set -o pipefail` SIGPIPEs unzip when grep exits early on a
|
||||||
# match, and the pipeline's failure reads as "missing <file>" for whichever
|
# match, and the pipeline's failure reads as "missing <file>" for whichever
|
||||||
@@ -90,7 +100,8 @@ for required in tools/save-editor/App.lua tools/save-editor/Kit.lua \
|
|||||||
tools/save-editor/panels/Party.lua \
|
tools/save-editor/panels/Party.lua \
|
||||||
src/ui/kit/Kit.lua \
|
src/ui/kit/Kit.lua \
|
||||||
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; do
|
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
||||||
|
tools/rom_manifest_silver.json; do
|
||||||
grep -qxF "$required" "$LOVE_LISTING" \
|
grep -qxF "$required" "$LOVE_LISTING" \
|
||||||
|| fail "game.love is missing $required"
|
|| fail "game.love is missing $required"
|
||||||
done
|
done
|
||||||
@@ -105,6 +116,13 @@ say "game.love: $(du -h "$LOVE_FILE" | cut -f1)"
|
|||||||
# mistaken for a release. The stamp is then read back out of the archive and the
|
# mistaken for a release. The stamp is then read back out of the archive and the
|
||||||
# build fails if it did not take.
|
# build fails if it did not take.
|
||||||
if printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
if printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||||
|
if [ -n "$GAME_LOVE_IN" ]; then
|
||||||
|
version_re="$(printf '%s' "$VERSION" | sed 's/\./\\./g')"
|
||||||
|
unzip -p "$LOVE_FILE" src/core/Version.lua \
|
||||||
|
| grep -Eq "engine[[:space:]]*=[[:space:]]*\"$version_re\"" \
|
||||||
|
|| fail "prebuilt payload does not report engine $VERSION (pack it with pack_love.sh --version $VERSION)"
|
||||||
|
say "prebuilt payload already stamped: $VERSION"
|
||||||
|
else
|
||||||
say "stamping engine version $VERSION into game.love"
|
say "stamping engine version $VERSION into game.love"
|
||||||
stamp_dir="$WORK/stamp"
|
stamp_dir="$WORK/stamp"
|
||||||
rm -rf "$stamp_dir"
|
rm -rf "$stamp_dir"
|
||||||
@@ -117,6 +135,7 @@ if printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
|||||||
| grep -Eq "engine[[:space:]]*=[[:space:]]*\"$version_re\"" \
|
| grep -Eq "engine[[:space:]]*=[[:space:]]*\"$version_re\"" \
|
||||||
|| fail "version stamp failed: game.love does not report engine $VERSION"
|
|| fail "version stamp failed: game.love does not report engine $VERSION"
|
||||||
say "stamped engine version: $VERSION"
|
say "stamped engine version: $VERSION"
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
say "version '$VERSION' is not X.Y.Z, shipping default engine (no stamp)"
|
say "version '$VERSION' is not X.Y.Z, shipping default engine (no stamp)"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Packages the LÖVE2D Pokémon Red port into an Android APK via love-android 11.5a.
|
# Packages the LÖVE2D Pokémon Red port into an Android APK via love-android 11.5a.
|
||||||
#
|
#
|
||||||
# Usage: scripts/build_android.sh [--version X.Y.Z] [--package-only]
|
# Usage: scripts/build_android.sh [--version X.Y.Z] [--release] [--package-only]
|
||||||
#
|
#
|
||||||
# --version X.Y.Z set app.version_name / app.version_code (else left as-is)
|
# --version X.Y.Z set app.version_name / app.version_code (else left as-is)
|
||||||
|
# --release build the production-signed release APK (requires the
|
||||||
|
# GEN1RECOMP_ANDROID_* signing environment variables)
|
||||||
# --package-only zip game.love + apply branding; skip gradle
|
# --package-only zip game.love + apply branding; skip gradle
|
||||||
#
|
#
|
||||||
# Prerequisites:
|
# Prerequisites:
|
||||||
# - mobile/android vendored love-android tree at tag 11.5a (in-repo; see mobile/ANDROID.md)
|
# - mobile/android vendored love-android tree at tag 11.5a (in-repo; see mobile/ANDROID.md)
|
||||||
# - Android SDK + NDK (SDK API 34, NDK 25.2.9519653)
|
# - Android SDK + NDK (SDK API 36, NDK 25.2.9519653)
|
||||||
# - JDK 17
|
# - JDK 17
|
||||||
#
|
#
|
||||||
# Output (after gradle):
|
# Output (after gradle):
|
||||||
# dist/android/debug/*.apk (convenience copy)
|
# dist/android/debug/*.apk (normal local build) or dist/android/release/*.apk
|
||||||
# mobile/android/app/build/outputs/apk/embedNoRecord/debug/*.apk
|
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -26,13 +27,17 @@ APP_NAME="gen1recomp"
|
|||||||
APPLICATION_ID="com.theboisclub.pokemonred"
|
APPLICATION_ID="com.theboisclub.pokemonred"
|
||||||
LOVE_ANDROID_VERSION="11.5a"
|
LOVE_ANDROID_VERSION="11.5a"
|
||||||
NDK_VERSION="25.2.9519653"
|
NDK_VERSION="25.2.9519653"
|
||||||
|
ANDROID_API="36"
|
||||||
YELLOW_MANIFEST_RELATIVE="tools/rom_manifest_yellow.json"
|
YELLOW_MANIFEST_RELATIVE="tools/rom_manifest_yellow.json"
|
||||||
YELLOW_MANIFEST_URL="${YELLOW_MANIFEST_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/tools/rom_manifest_yellow.json}"
|
YELLOW_MANIFEST_URL="${YELLOW_MANIFEST_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/tools/rom_manifest_yellow.json}"
|
||||||
GOLD_MANIFEST_RELATIVE="tools/rom_manifest_gold.json"
|
GOLD_MANIFEST_RELATIVE="tools/rom_manifest_gold.json"
|
||||||
GOLD_MANIFEST_URL="${GOLD_MANIFEST_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/tools/rom_manifest_gold.json}"
|
GOLD_MANIFEST_URL="${GOLD_MANIFEST_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/tools/rom_manifest_gold.json}"
|
||||||
|
SILVER_MANIFEST_RELATIVE="tools/rom_manifest_silver.json"
|
||||||
|
SILVER_MANIFEST_URL="${SILVER_MANIFEST_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/tools/rom_manifest_silver.json}"
|
||||||
|
|
||||||
VERSION=""
|
VERSION=""
|
||||||
PACKAGE_ONLY=false
|
PACKAGE_ONLY=false
|
||||||
|
RELEASE=false
|
||||||
|
|
||||||
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||||
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
|
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
|
||||||
@@ -42,11 +47,12 @@ while [ $# -gt 0 ]; do
|
|||||||
case "$1" in
|
case "$1" in
|
||||||
--version) VERSION="$2"; shift ;;
|
--version) VERSION="$2"; shift ;;
|
||||||
--package-only) PACKAGE_ONLY=true ;;
|
--package-only) PACKAGE_ONLY=true ;;
|
||||||
|
--release) RELEASE=true ;;
|
||||||
-h|--help)
|
-h|--help)
|
||||||
sed -n '2,20p' "$0"
|
sed -n '2,20p' "$0"
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
*) fail "unknown argument: $1 (try --version X.Y.Z or --package-only)" ;;
|
*) fail "unknown argument: $1 (try --version X.Y.Z, --release, or --package-only)" ;;
|
||||||
esac
|
esac
|
||||||
shift
|
shift
|
||||||
done
|
done
|
||||||
@@ -60,7 +66,22 @@ if [ -n "$VERSION" ]; then
|
|||||||
rest="${VERSION#*.}"
|
rest="${VERSION#*.}"
|
||||||
minor="${rest%%.*}"
|
minor="${rest%%.*}"
|
||||||
patch="${rest##*.}"
|
patch="${rest##*.}"
|
||||||
VERSION_CODE=$((major * 10000 + minor * 100 + patch))
|
# Reserve three digits for each lower component. This stays monotonic across
|
||||||
|
# 1.0.100 -> 1.1.0, unlike the old two-digit encoding, and remains inside
|
||||||
|
# Android's signed 32-bit versionCode range for normal release versions.
|
||||||
|
if [ "$minor" -gt 999 ] || [ "$patch" -gt 999 ] || [ "$major" -gt 2099 ]; then
|
||||||
|
fail "--version components exceed Android versionCode limits"
|
||||||
|
fi
|
||||||
|
VERSION_CODE=$((major * 1000000 + minor * 1000 + patch))
|
||||||
|
fi
|
||||||
|
|
||||||
|
if $RELEASE; then
|
||||||
|
for var in GEN1RECOMP_ANDROID_KEYSTORE GEN1RECOMP_ANDROID_KEYSTORE_PASSWORD \
|
||||||
|
GEN1RECOMP_ANDROID_KEY_ALIAS GEN1RECOMP_ANDROID_KEY_PASSWORD; do
|
||||||
|
[ -n "${!var:-}" ] || fail "--release requires $var"
|
||||||
|
done
|
||||||
|
[ -f "$GEN1RECOMP_ANDROID_KEYSTORE" ] \
|
||||||
|
|| fail "Android signing keystore does not exist: $GEN1RECOMP_ANDROID_KEYSTORE"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --------------------------------------------------------------- preconditions
|
# --------------------------------------------------------------- preconditions
|
||||||
@@ -177,6 +198,54 @@ ensure_gold_manifest() {
|
|||||||
fail "Gold import manifest is unavailable. Git recovery failed and could not download $GOLD_MANIFEST_URL"
|
fail "Gold import manifest is unavailable. Git recovery failed and could not download $GOLD_MANIFEST_URL"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
silver_manifest_is_valid() {
|
||||||
|
local path="$1"
|
||||||
|
python3 - "$path" <<'PY'
|
||||||
|
import json, pathlib, sys
|
||||||
|
|
||||||
|
try:
|
||||||
|
manifest = json.loads(pathlib.Path(sys.argv[1]).read_text())
|
||||||
|
except (OSError, ValueError):
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
raise SystemExit(0 if manifest.get("romSha1") ==
|
||||||
|
"49b163f7e57702bc939d642a18f591de55d92dae" else 1)
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_silver_manifest() {
|
||||||
|
local manifest="$ROOT/$SILVER_MANIFEST_RELATIVE"
|
||||||
|
local staged
|
||||||
|
staged="$(mktemp)"
|
||||||
|
|
||||||
|
if silver_manifest_is_valid "$manifest"; then
|
||||||
|
rm -f "$staged"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
warn "Silver import manifest is missing or invalid; recovering it before packaging"
|
||||||
|
if git -C "$ROOT" show "HEAD:$SILVER_MANIFEST_RELATIVE" > "$staged" 2>/dev/null \
|
||||||
|
&& silver_manifest_is_valid "$staged"; then
|
||||||
|
mkdir -p "$(dirname "$manifest")"
|
||||||
|
mv "$staged" "$manifest"
|
||||||
|
say "restored Silver import manifest from this checkout's Git data"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v curl >/dev/null 2>&1 \
|
||||||
|
&& curl --fail --location --retry 2 --connect-timeout 15 \
|
||||||
|
--output "$staged" "$SILVER_MANIFEST_URL" \
|
||||||
|
&& silver_manifest_is_valid "$staged"; then
|
||||||
|
mkdir -p "$(dirname "$manifest")"
|
||||||
|
mv "$staged" "$manifest"
|
||||||
|
say "downloaded Silver import manifest from the project repository"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -f "$staged"
|
||||||
|
fail "Silver import manifest is unavailable. Git recovery failed and could not download $SILVER_MANIFEST_URL"
|
||||||
|
}
|
||||||
|
|
||||||
# --------------------------------------------------------------- branding
|
# --------------------------------------------------------------- branding
|
||||||
# love-android 11.5+ reads app id / name / orientation from gradle.properties.
|
# love-android 11.5+ reads app id / name / orientation from gradle.properties.
|
||||||
# Manifest still gets permission trims. Re-applied every build so refreshing
|
# Manifest still gets permission trims. Re-applied every build so refreshing
|
||||||
@@ -242,6 +311,7 @@ pack_game_love() {
|
|||||||
say "packing game.love for love-android embed flavor"
|
say "packing game.love for love-android embed flavor"
|
||||||
ensure_yellow_manifest
|
ensure_yellow_manifest
|
||||||
ensure_gold_manifest
|
ensure_gold_manifest
|
||||||
|
ensure_silver_manifest
|
||||||
mkdir -p "$EMBED_ASSETS"
|
mkdir -p "$EMBED_ASSETS"
|
||||||
rm -f "$LOVE_FILE"
|
rm -f "$LOVE_FILE"
|
||||||
# tools/save-editor ships with the app: the launcher's Edit button on a save
|
# tools/save-editor ships with the app: the launcher's Edit button on a save
|
||||||
@@ -256,6 +326,7 @@ pack_game_love() {
|
|||||||
main.lua conf.lua src data assets tools/save-editor \
|
main.lua conf.lua src 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 \
|
||||||
-x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
|
-x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
|
||||||
-x 'data/generated/*' -x 'assets/generated/*')
|
-x 'data/generated/*' -x 'assets/generated/*')
|
||||||
# List once and match against the captured text: piping unzip straight into
|
# List once and match against the captured text: piping unzip straight into
|
||||||
@@ -277,6 +348,8 @@ pack_game_love() {
|
|||||||
|| fail "game.love is missing the Yellow ROM import manifest"
|
|| fail "game.love is missing the Yellow ROM import manifest"
|
||||||
grep -qx 'tools/rom_manifest_gold.json' <<< "$archive_entries" \
|
grep -qx 'tools/rom_manifest_gold.json' <<< "$archive_entries" \
|
||||||
|| fail "game.love is missing the Gold ROM import manifest"
|
|| fail "game.love is missing the Gold ROM import manifest"
|
||||||
|
grep -qx 'tools/rom_manifest_silver.json' <<< "$archive_entries" \
|
||||||
|
|| fail "game.love is missing the Silver ROM import manifest"
|
||||||
# This gate exists because the launcher's UI toolkit once lived outside
|
# This gate exists because the launcher's UI toolkit once lived outside
|
||||||
# src/ (libs/flexlove) and was added to scripts/build.sh's payload and to
|
# src/ (libs/flexlove) and was added to scripts/build.sh's payload and to
|
||||||
# no other packager, so Android and iOS built an APK/IPA whose launcher
|
# no other packager, so Android and iOS built an APK/IPA whose launcher
|
||||||
@@ -335,13 +408,18 @@ require_android_sdk() {
|
|||||||
export ANDROID_SDK_ROOT=\$HOME/Library/Android/sdk
|
export ANDROID_SDK_ROOT=\$HOME/Library/Android/sdk
|
||||||
or create mobile/android/local.properties with:
|
or create mobile/android/local.properties with:
|
||||||
sdk.dir=/path/to/Android/sdk
|
sdk.dir=/path/to/Android/sdk
|
||||||
love-android $LOVE_ANDROID_VERSION expects SDK API 34 and NDK $NDK_VERSION
|
love-android $LOVE_ANDROID_VERSION expects SDK API $ANDROID_API and NDK $NDK_VERSION
|
||||||
(see mobile/ANDROID.md)."
|
(see mobile/ANDROID.md)."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
export ANDROID_SDK_ROOT="$sdk"
|
export ANDROID_SDK_ROOT="$sdk"
|
||||||
export ANDROID_HOME="$sdk"
|
export ANDROID_HOME="$sdk"
|
||||||
|
|
||||||
|
if [ ! -d "$sdk/platforms/android-$ANDROID_API" ]; then
|
||||||
|
fail "Android SDK platform android-$ANDROID_API is not installed.
|
||||||
|
Install Android $ANDROID_API (and the latest 36.x Build-Tools) in SDK Manager."
|
||||||
|
fi
|
||||||
|
|
||||||
local props="$ANDROID_DIR/local.properties"
|
local props="$ANDROID_DIR/local.properties"
|
||||||
# Always rewrite so a leftover Docker sdk.dir=/opt/android-sdk cannot stick.
|
# Always rewrite so a leftover Docker sdk.dir=/opt/android-sdk cannot stick.
|
||||||
printf 'sdk.dir=%s\n' "$sdk" > "$props"
|
printf 'sdk.dir=%s\n' "$sdk" > "$props"
|
||||||
@@ -358,7 +436,12 @@ require_android_sdk() {
|
|||||||
|
|
||||||
# --------------------------------------------------------------- gradle
|
# --------------------------------------------------------------- gradle
|
||||||
run_gradle() {
|
run_gradle() {
|
||||||
local task="assembleEmbedNoRecordDebug"
|
local variant="debug"
|
||||||
|
$RELEASE && variant="release"
|
||||||
|
# Keep this compatible with macOS's bundled Bash 3.2 (no ${var^}).
|
||||||
|
local variant_title="Debug"
|
||||||
|
$RELEASE && variant_title="Release"
|
||||||
|
local task="assembleEmbedNoRecord$variant_title"
|
||||||
local build_dir="$ANDROID_DIR"
|
local build_dir="$ANDROID_DIR"
|
||||||
|
|
||||||
# ndk-build is GNU make underneath and cannot cope with spaces anywhere in
|
# ndk-build is GNU make underneath and cannot cope with spaces anywhere in
|
||||||
@@ -393,12 +476,12 @@ run_gradle() {
|
|||||||
You can still iterate on the .love payload with: scripts/build_android.sh --package-only"
|
You can still iterate on the .love payload with: scripts/build_android.sh --package-only"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local out_dir="$build_dir/app/build/outputs/apk/embedNoRecord/debug"
|
local out_dir="$build_dir/app/build/outputs/apk/embedNoRecord/$variant"
|
||||||
if [ -d "$out_dir" ]; then
|
if [ -d "$out_dir" ]; then
|
||||||
say "APK output:"
|
say "APK output:"
|
||||||
find "$out_dir" -name '*.apk' -exec ls -lh {} \;
|
find "$out_dir" -name '*.apk' -exec ls -lh {} \;
|
||||||
|
|
||||||
local dist_dir="$DIST/debug"
|
local dist_dir="$DIST/$variant"
|
||||||
rm -rf "$dist_dir"
|
rm -rf "$dist_dir"
|
||||||
mkdir -p "$dist_dir"
|
mkdir -p "$dist_dir"
|
||||||
find "$out_dir" -name '*.apk' -exec cp {} "$dist_dir/" \;
|
find "$out_dir" -name '*.apk' -exec cp {} "$dist_dir/" \;
|
||||||
|
|||||||
@@ -366,6 +366,8 @@ cp "$IN/game.love" "$APPDIR/game.love"
|
|||||||
unzip -Z1 "$APPDIR/game.love" > "$WORK/love-listing.txt"
|
unzip -Z1 "$APPDIR/game.love" > "$WORK/love-listing.txt"
|
||||||
grep -qxF "tools/rom_manifest_gold.json" "$WORK/love-listing.txt" \
|
grep -qxF "tools/rom_manifest_gold.json" "$WORK/love-listing.txt" \
|
||||||
|| fail "game.love is missing tools/rom_manifest_gold.json"
|
|| fail "game.love is missing tools/rom_manifest_gold.json"
|
||||||
|
grep -qxF "tools/rom_manifest_silver.json" "$WORK/love-listing.txt" \
|
||||||
|
|| fail "game.love is missing tools/rom_manifest_silver.json"
|
||||||
# The .desktop's Icon= resolves against the AppDir root by basename, and
|
# The .desktop's Icon= resolves against the AppDir root by basename, and
|
||||||
# .DirIcon is what appimaged and file-manager thumbnailers read.
|
# .DirIcon is what appimaged and file-manager thumbnailers read.
|
||||||
cp "$IN/icon.png" "$APPDIR/$APP_NAME.png"
|
cp "$IN/icon.png" "$APPDIR/$APP_NAME.png"
|
||||||
|
|||||||
@@ -169,5 +169,7 @@ unzip -p "$temp_dir/game.love" src/core/Version.lua \
|
|||||||
|| fail "shared payload version was not stamped"
|
|| fail "shared payload version was not stamped"
|
||||||
grep -qxF "tools/rom_manifest_gold.json" "$temp_dir/love-listing.txt" \
|
grep -qxF "tools/rom_manifest_gold.json" "$temp_dir/love-listing.txt" \
|
||||||
|| fail "shared payload is missing tools/rom_manifest_gold.json"
|
|| fail "shared payload is missing tools/rom_manifest_gold.json"
|
||||||
|
grep -qxF "tools/rom_manifest_silver.json" "$temp_dir/love-listing.txt" \
|
||||||
|
|| fail "shared payload is missing tools/rom_manifest_silver.json"
|
||||||
|
|
||||||
say "Linux arm64 self-test passed"
|
say "Linux arm64 self-test passed"
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Verifies a built arm64 AppImage is self-contained and bullseye-compatible.
|
||||||
|
# Usage: scripts/linux-arm64/verify_appimage.sh <AppImage>
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
image="${1:?usage: verify_appimage.sh <AppImage>}"
|
||||||
|
[ -f "$image" ] || { echo "::error::no such AppImage: $image"; exit 1; }
|
||||||
|
image="$(cd "$(dirname "$image")" && pwd)/$(basename "$image")"
|
||||||
|
|
||||||
|
workdir="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$workdir"' EXIT
|
||||||
|
cd "$workdir"
|
||||||
|
|
||||||
|
# --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; }
|
||||||
|
|
||||||
|
echo "AppImage verified: $image"
|
||||||
@@ -50,6 +50,7 @@ rm -f "$OUTPUT"
|
|||||||
main.lua conf.lua src data assets tools/save-editor \
|
main.lua conf.lua src 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 \
|
||||||
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
||||||
if [ -f "$ROOT/PATCH_NOTES.md" ]; then
|
if [ -f "$ROOT/PATCH_NOTES.md" ]; then
|
||||||
(cd "$ROOT" && zip -q "$OUTPUT" PATCH_NOTES.md)
|
(cd "$ROOT" && zip -q "$OUTPUT" PATCH_NOTES.md)
|
||||||
@@ -94,6 +95,7 @@ for required in tools/save-editor/App.lua tools/save-editor/Kit.lua \
|
|||||||
tools/save-editor/panels/Party.lua \
|
tools/save-editor/panels/Party.lua \
|
||||||
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 \
|
||||||
src/ui/kit/Kit.lua \
|
src/ui/kit/Kit.lua \
|
||||||
src/import/LauncherView.lua; do
|
src/import/LauncherView.lua; do
|
||||||
grep -qxF "$required" "$LISTING" \
|
grep -qxF "$required" "$LISTING" \
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
|
|||||||
|
|
||||||
if [ ! -f "$ROOT/data/generated/maps.lua" ] \
|
if [ ! -f "$ROOT/data/generated/maps.lua" ] \
|
||||||
&& [ ! -f "$ROOT/blue/data/generated/maps.lua" ] \
|
&& [ ! -f "$ROOT/blue/data/generated/maps.lua" ] \
|
||||||
&& [ ! -f "$ROOT/yellow/data/generated/maps.lua" ]; then
|
&& [ ! -f "$ROOT/yellow/data/generated/maps.lua" ] \
|
||||||
|
&& [ ! -f "$ROOT/gold/data/generated/maps.lua" ] \
|
||||||
|
&& [ ! -f "$ROOT/silver/data/generated/maps.lua" ]; then
|
||||||
fail "generated data missing, run scripts/setup.sh first"
|
fail "generated data missing, run scripts/setup.sh first"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -71,15 +71,15 @@ First install or update (same steps):
|
|||||||
your saves, imported ROMs, mods, and options. Re-extracting only
|
your saves, imported ROMs, mods, and options. Re-extracting only
|
||||||
replaces the NRO(s) and these help files.
|
replaces the NRO(s) and these help files.
|
||||||
3. Launch with title override (hold R on HOME, open any title → hbmenu).
|
3. Launch with title override (hold R on HOME, open any title → hbmenu).
|
||||||
4. Copy a legal Pokemon Red/Blue .gb or Yellow/Gold .gbc into:
|
4. Copy a legal Pokemon Red/Blue .gb or Yellow/Gold/Silver .gbc into:
|
||||||
switch/gen1recomp/pokemon-love2d/imports/
|
switch/gen1recomp/pokemon-love2d/imports/
|
||||||
then use Scan again in the launcher if needed.
|
then use Scan again in the launcher if needed.
|
||||||
|
|
||||||
Inboxes (drop files here via MTP / SD / FTP):
|
Inboxes (drop files here via MTP / SD / FTP):
|
||||||
imports/ — ROM .gb / .gbc
|
imports/ — ROM .gb / .gbc
|
||||||
imports/mods/ — community mod .zip
|
imports/mods/ — community mod .zip
|
||||||
imports/saves/red|blue|yellow|gold/ — raw .sav import (Gold cart .sav not yet)
|
imports/saves/red|blue|yellow|gold|silver/ — raw .sav import (Gold/Silver cart .sav not yet)
|
||||||
exports/red|blue|yellow|gold/ — pull after Export save (Gold not yet)
|
exports/red|blue|yellow|gold|silver/ — pull after Export save (Gold/Silver not yet)
|
||||||
|
|
||||||
Full guide: https://github.com/bryanthaboi/gen1recomp/blob/main/docs/switch-install.md
|
Full guide: https://github.com/bryanthaboi/gen1recomp/blob/main/docs/switch-install.md
|
||||||
EOF
|
EOF
|
||||||
@@ -92,7 +92,7 @@ write_readme() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
write_readme "$SAVE_ROOT/imports/README.txt" \
|
write_readme "$SAVE_ROOT/imports/README.txt" \
|
||||||
"Put a legal Pokemon Red/Blue .gb or Yellow/Gold .gbc here, then Scan again in the launcher."
|
"Put a legal Pokemon Red/Blue .gb or Yellow/Gold/Silver .gbc here, then Scan again in the launcher."
|
||||||
write_readme "$SAVE_ROOT/imports/mods/README.txt" \
|
write_readme "$SAVE_ROOT/imports/mods/README.txt" \
|
||||||
"Put community mod .zip files here, then MODS → Scan again."
|
"Put community mod .zip files here, then MODS → Scan again."
|
||||||
write_readme "$SAVE_ROOT/imports/saves/red/README.txt" \
|
write_readme "$SAVE_ROOT/imports/saves/red/README.txt" \
|
||||||
@@ -103,6 +103,8 @@ write_readme "$SAVE_ROOT/imports/saves/yellow/README.txt" \
|
|||||||
"Put a Yellow .sav (32 KB) here, then Yellow tab → SAVE FILES → Import save."
|
"Put a Yellow .sav (32 KB) here, then Yellow tab → SAVE FILES → Import save."
|
||||||
write_readme "$SAVE_ROOT/imports/saves/gold/README.txt" \
|
write_readme "$SAVE_ROOT/imports/saves/gold/README.txt" \
|
||||||
"Gold cart .sav import is not supported yet. Folder reserved so MTP matches the other games."
|
"Gold cart .sav import is not supported yet. Folder reserved so MTP matches the other games."
|
||||||
|
write_readme "$SAVE_ROOT/imports/saves/silver/README.txt" \
|
||||||
|
"Silver cart .sav import is not supported yet. Folder reserved so MTP matches the other games."
|
||||||
write_readme "$SAVE_ROOT/exports/red/README.txt" \
|
write_readme "$SAVE_ROOT/exports/red/README.txt" \
|
||||||
"After Export save (Red), copy the .sav out of this folder via MTP / SD / FTP."
|
"After Export save (Red), copy the .sav out of this folder via MTP / SD / FTP."
|
||||||
write_readme "$SAVE_ROOT/exports/blue/README.txt" \
|
write_readme "$SAVE_ROOT/exports/blue/README.txt" \
|
||||||
@@ -111,6 +113,8 @@ write_readme "$SAVE_ROOT/exports/yellow/README.txt" \
|
|||||||
"After Export save (Yellow), copy the .sav out of this folder via MTP / SD / FTP."
|
"After Export save (Yellow), copy the .sav out of this folder via MTP / SD / FTP."
|
||||||
write_readme "$SAVE_ROOT/exports/gold/README.txt" \
|
write_readme "$SAVE_ROOT/exports/gold/README.txt" \
|
||||||
"Gold cart .sav export is not supported yet. Folder reserved so MTP matches the other games."
|
"Gold cart .sav export is not supported yet. Folder reserved so MTP matches the other games."
|
||||||
|
write_readme "$SAVE_ROOT/exports/silver/README.txt" \
|
||||||
|
"Silver cart .sav export is not supported yet. Folder reserved so MTP matches the other games."
|
||||||
|
|
||||||
rm -f "$OUT_ZIP"
|
rm -f "$OUT_ZIP"
|
||||||
(
|
(
|
||||||
@@ -140,10 +144,12 @@ REQUIRED=(
|
|||||||
"switch/gen1recomp/pokemon-love2d/imports/saves/blue/README.txt"
|
"switch/gen1recomp/pokemon-love2d/imports/saves/blue/README.txt"
|
||||||
"switch/gen1recomp/pokemon-love2d/imports/saves/yellow/README.txt"
|
"switch/gen1recomp/pokemon-love2d/imports/saves/yellow/README.txt"
|
||||||
"switch/gen1recomp/pokemon-love2d/imports/saves/gold/README.txt"
|
"switch/gen1recomp/pokemon-love2d/imports/saves/gold/README.txt"
|
||||||
|
"switch/gen1recomp/pokemon-love2d/imports/saves/silver/README.txt"
|
||||||
"switch/gen1recomp/pokemon-love2d/exports/red/README.txt"
|
"switch/gen1recomp/pokemon-love2d/exports/red/README.txt"
|
||||||
"switch/gen1recomp/pokemon-love2d/exports/blue/README.txt"
|
"switch/gen1recomp/pokemon-love2d/exports/blue/README.txt"
|
||||||
"switch/gen1recomp/pokemon-love2d/exports/yellow/README.txt"
|
"switch/gen1recomp/pokemon-love2d/exports/yellow/README.txt"
|
||||||
"switch/gen1recomp/pokemon-love2d/exports/gold/README.txt"
|
"switch/gen1recomp/pokemon-love2d/exports/gold/README.txt"
|
||||||
|
"switch/gen1recomp/pokemon-love2d/exports/silver/README.txt"
|
||||||
)
|
)
|
||||||
for rel in "${REQUIRED[@]}"; do
|
for rel in "${REQUIRED[@]}"; do
|
||||||
printf '%s\n' "$LISTING" | grep -Fq "$rel" || fail "zip missing $rel"
|
printf '%s\n' "$LISTING" | grep -Fq "$rel" || fail "zip missing $rel"
|
||||||
|
|||||||
@@ -271,10 +271,12 @@ for rel in \
|
|||||||
"switch/gen1recomp/pokemon-love2d/imports/saves/blue/README.txt" \
|
"switch/gen1recomp/pokemon-love2d/imports/saves/blue/README.txt" \
|
||||||
"switch/gen1recomp/pokemon-love2d/imports/saves/yellow/README.txt" \
|
"switch/gen1recomp/pokemon-love2d/imports/saves/yellow/README.txt" \
|
||||||
"switch/gen1recomp/pokemon-love2d/imports/saves/gold/README.txt" \
|
"switch/gen1recomp/pokemon-love2d/imports/saves/gold/README.txt" \
|
||||||
|
"switch/gen1recomp/pokemon-love2d/imports/saves/silver/README.txt" \
|
||||||
"switch/gen1recomp/pokemon-love2d/exports/red/README.txt" \
|
"switch/gen1recomp/pokemon-love2d/exports/red/README.txt" \
|
||||||
"switch/gen1recomp/pokemon-love2d/exports/blue/README.txt" \
|
"switch/gen1recomp/pokemon-love2d/exports/blue/README.txt" \
|
||||||
"switch/gen1recomp/pokemon-love2d/exports/yellow/README.txt" \
|
"switch/gen1recomp/pokemon-love2d/exports/yellow/README.txt" \
|
||||||
"switch/gen1recomp/pokemon-love2d/exports/gold/README.txt"
|
"switch/gen1recomp/pokemon-love2d/exports/gold/README.txt" \
|
||||||
|
"switch/gen1recomp/pokemon-love2d/exports/silver/README.txt"
|
||||||
do
|
do
|
||||||
printf '%s\n' "$ZIP_LIST" | grep -Fq "$rel" || PACK_MISSING="${PACK_MISSING} ${rel}"
|
printf '%s\n' "$ZIP_LIST" | grep -Fq "$rel" || PACK_MISSING="${PACK_MISSING} ${rel}"
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ run_tier() {
|
|||||||
# ------- ROM-free tiers: these are what CI runs
|
# ------- ROM-free tiers: these are what CI runs
|
||||||
|
|
||||||
run_tier "T0 ROM builder version routing" python3 tests/build_rom_data_cli_test.py
|
run_tier "T0 ROM builder version routing" python3 tests/build_rom_data_cli_test.py
|
||||||
|
run_tier "T0 ROM manifest generator pin/overrides" python3 tests/rom_manifest_generator_test.py
|
||||||
run_tier "T0 switch CI workflow content gate" "$LUA" tests/switch_ci_workflows_test.lua
|
run_tier "T0 switch CI workflow content gate" "$LUA" tests/switch_ci_workflows_test.lua
|
||||||
run_tier "T0 switch transfer docs gate" "$LUA" tests/switch_transfer_docs_test.lua
|
run_tier "T0 switch transfer docs gate" "$LUA" tests/switch_transfer_docs_test.lua
|
||||||
# NX Blue/Yellow asset overlay: ROM-free, must run on every checkout so a
|
# NX Blue/Yellow asset overlay: ROM-free, must run on every checkout so a
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ local BattleSafety = {}
|
|||||||
local BATTLE_BUSY_FIELDS = {
|
local BATTLE_BUSY_FIELDS = {
|
||||||
"current", "afterQueue", "nextInsert", "pendingHit", "waitingUI",
|
"current", "afterQueue", "nextInsert", "pendingHit", "waitingUI",
|
||||||
"waitingSound", "waitFrames", "draining", "animPlaying", "growIn",
|
"waitingSound", "waitFrames", "draining", "animPlaying", "growIn",
|
||||||
|
"shrinkOut",
|
||||||
"introSlide", "ghostReveal", "mimicCtx", "mimicMoves", "result",
|
"introSlide", "ghostReveal", "mimicCtx", "mimicMoves", "result",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1008,9 +1008,23 @@ end
|
|||||||
-- flickers the OBJ palette, DoBallTossSpecialEffects)
|
-- flickers the OBJ palette, DoBallTossSpecialEffects)
|
||||||
function BattleState:animNext(name, isPlayer, shakes, ball)
|
function BattleState:animNext(name, isPlayer, shakes, ball)
|
||||||
self.nextInsert = (self.nextInsert or 0) + 1
|
self.nextInsert = (self.nextInsert or 0) + 1
|
||||||
table.insert(self.queue, self.nextInsert,
|
local row = { anim = name, attackerIsPlayer = isPlayer, shakes = shakes,
|
||||||
{ anim = name, attackerIsPlayer = isPlayer, shakes = shakes,
|
ball = ball }
|
||||||
ball = ball })
|
table.insert(self.queue, self.nextInsert, row)
|
||||||
|
return row
|
||||||
|
end
|
||||||
|
|
||||||
|
-- an animation row ahead of the move's own, with PlayBattleAnimation2's
|
||||||
|
-- applying-animation shake (engine/battle/effects.asm:1461-1471)
|
||||||
|
function BattleState:animBeforeMove(name, isPlayer)
|
||||||
|
local at
|
||||||
|
for i, item in ipairs(self.queue) do
|
||||||
|
if item == self.moveAnimRow then at = i break end
|
||||||
|
end
|
||||||
|
self.nextInsert = (self.nextInsert or 0) + 1
|
||||||
|
table.insert(self.queue, at or self.nextInsert,
|
||||||
|
{ anim = name, attackerIsPlayer = isPlayer, animDelayed = true,
|
||||||
|
hit = { animType = isPlayer and 6 or 3 } })
|
||||||
end
|
end
|
||||||
|
|
||||||
-- insert an act right after the current queue item
|
-- insert an act right after the current queue item
|
||||||
@@ -1350,7 +1364,7 @@ function BattleState:updateQueue()
|
|||||||
-- subanimation (or just the coarse fx when animations are off).
|
-- subanimation (or just the coarse fx when animations are off).
|
||||||
-- item.hit carries the target's blink + damage sound, applied when
|
-- item.hit carries the target's blink + damage sound, applied when
|
||||||
-- the animation ends (hitRow rows carry a hit with no animation --
|
-- the animation ends (hitRow rows carry a hit with no animation --
|
||||||
-- thrash/rage continuation turns that skip the announcement).
|
-- Mimic, whose animation waits on a successful copy).
|
||||||
if item.anim or item.hitRow then
|
if item.anim or item.hitRow then
|
||||||
-- PlayMoveAnimation writes wAnimationID, calls Delay3, and only then
|
-- PlayMoveAnimation writes wAnimationID, calls Delay3, and only then
|
||||||
-- jumps to MoveAnimation (core.asm:6635-6640), so three frames pass
|
-- jumps to MoveAnimation (core.asm:6635-6640), so three frames pass
|
||||||
@@ -1554,15 +1568,41 @@ end
|
|||||||
function BattleState:sendOutText(name)
|
function BattleState:sendOutText(name)
|
||||||
local e = self.enemy and self.enemy.mon
|
local e = self.enemy and self.enemy.mon
|
||||||
local pct = 100
|
local pct = 100
|
||||||
if e and e.hp > 0 and math.floor(e.stats.hp / 4) > 0 then
|
if e and e.hp > 0 then
|
||||||
|
-- the same routine stamps wLastSwitchInEnemyMonHP
|
||||||
|
-- (engine/battle/common_text.asm:105-110)
|
||||||
|
self.lastSwitchInEnemyHP = e.hp
|
||||||
|
if math.floor(e.stats.hp / 4) > 0 then
|
||||||
pct = math.floor(e.hp * 25 / math.floor(e.stats.hp / 4))
|
pct = math.floor(e.hp * 25 / math.floor(e.stats.hp / 4))
|
||||||
end
|
end
|
||||||
|
end
|
||||||
if pct >= 70 then return Strings("Go! %s!", name) end
|
if pct >= 70 then return Strings("Go! %s!", name) end
|
||||||
if pct >= 40 then return Strings("Do it! %s!", name) end
|
if pct >= 40 then return Strings("Do it! %s!", name) end
|
||||||
if pct >= 10 then return Strings("Get'm! %s!", name) end
|
if pct >= 10 then return Strings("Get'm! %s!", name) end
|
||||||
return self:romText("_EnemysWeakText", "The enemy's weak!\nGet'm! %s!", name)
|
return self:romText("_EnemysWeakText", "The enemy's weak!\nGet'm! %s!", name)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- RetreatMon / PlayerMon2Text (engine/battle/common_text.asm:167-243): the
|
||||||
|
-- adjective reads the enemy HP lost since this mon switched in
|
||||||
|
function BattleState:withdrawText(name)
|
||||||
|
local e = self.enemy and self.enemy.mon
|
||||||
|
local drop = 0
|
||||||
|
if e and self.lastSwitchInEnemyHP and math.floor(e.stats.hp / 4) > 0 then
|
||||||
|
drop = math.floor((self.lastSwitchInEnemyHP - e.hp) * 25
|
||||||
|
/ math.floor(e.stats.hp / 4))
|
||||||
|
end
|
||||||
|
local word = ""
|
||||||
|
if drop <= 0 then
|
||||||
|
word = self:romText("_EnoughText", "enough!")
|
||||||
|
elseif drop >= 70 then
|
||||||
|
word = self:romText("_GoodText", "good!")
|
||||||
|
elseif drop >= 30 then
|
||||||
|
word = self:romText("_OKExclamationText", "OK!")
|
||||||
|
end
|
||||||
|
return self:romText("_PlayerMon2Text", "%s ", name) .. word
|
||||||
|
.. self:romText("_ComeBackText", "\nCome back!")
|
||||||
|
end
|
||||||
|
|
||||||
-- The cry a mon makes as it takes the field. Yellow does not run its
|
-- The cry a mon makes as it takes the field. Yellow does not run its
|
||||||
-- starter Pikachu through PlayCry at all: SendOutMon branches to
|
-- starter Pikachu through PlayCry at all: SendOutMon branches to
|
||||||
-- .starterPikachu (engine/battle/core.asm:1807-1817) and voices PCM
|
-- .starterPikachu (engine/battle/core.asm:1807-1817) and voices PCM
|
||||||
@@ -1678,9 +1718,14 @@ function BattleState:enter()
|
|||||||
-- _PlayerBlackedOutText2 (data/text/text_2.asm:896): the two paragraphs
|
-- _PlayerBlackedOutText2 (data/text/text_2.asm:896): the two paragraphs
|
||||||
-- playerMonFainted queues on the battle screen; there is no battle
|
-- playerMonFainted queues on the battle screen; there is no battle
|
||||||
-- screen to queue them on here, so they print over the map.
|
-- screen to queue them on here, so they print over the map.
|
||||||
|
-- _PlayerBlackedOutText (no "2") extracts to the identical wording from
|
||||||
|
-- a different ROM address and is unused anywhere in this engine -- not
|
||||||
|
-- a fallback for this one, just pokered printing the same paragraph
|
||||||
|
-- from a second call site elsewhere.
|
||||||
self.game.stack:push(require("src.render.TextBox").new(self.game,
|
self.game.stack:push(require("src.render.TextBox").new(self.game,
|
||||||
Strings("%s is out of\nuseable POKéMON!", name) .. "\f"
|
self:romText("_PlayerBlackedOutText2",
|
||||||
.. Strings("%s blacked\nout!", name), blackedOut))
|
"%s is out of\nuseable POKéMON!\f%s blacked\nout!", name, name),
|
||||||
|
blackedOut))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
self.musicKind = self:computeMusicKind()
|
self.musicKind = self:computeMusicKind()
|
||||||
@@ -1816,7 +1861,9 @@ function BattleState:enter()
|
|||||||
self.enemySendingOut = true
|
self.enemySendingOut = true
|
||||||
self:slidePic("foe")
|
self:slidePic("foe")
|
||||||
end)
|
end)
|
||||||
self:say(Strings("%s sent\nout %s!", foeName, self.enemy.name))
|
-- _TrainerSentOutText ends `done`, not `prompt` (data/text/text_2.asm:923)
|
||||||
|
self:sayAuto(self:romText("_TrainerSentOutText", "%s sent\nout %s!",
|
||||||
|
foeName, self.enemy.name))
|
||||||
self:act(function()
|
self:act(function()
|
||||||
-- EnemySendOutFirstMon (core.asm:1421-1434): after the text the
|
-- EnemySendOutFirstMon (core.asm:1421-1434): after the text the
|
||||||
-- pic grows out of the ball (AnimateSendingOutMon), then the cry
|
-- pic grows out of the ball (AnimateSendingOutMon), then the cry
|
||||||
@@ -1845,7 +1892,8 @@ function BattleState:enter()
|
|||||||
self.sendingOut = true
|
self.sendingOut = true
|
||||||
self:slidePic("back")
|
self:slidePic("back")
|
||||||
end)
|
end)
|
||||||
self:say(self:sendOutText(self.player.name))
|
-- _GoText.._PlayerMon1Text carry no prompt (data/text/text_2.asm:1274-1294)
|
||||||
|
self:sayAuto(self:sendOutText(self.player.name))
|
||||||
-- then the POOF plays and the mon appears with its cry
|
-- then the POOF plays and the mon appears with its cry
|
||||||
-- (SendOutMon: message -> AnimateSendingOutMon -> PlayCry)
|
-- (SendOutMon: message -> AnimateSendingOutMon -> PlayCry)
|
||||||
self:queueSendOutAnim(true)
|
self:queueSendOutAnim(true)
|
||||||
@@ -2447,9 +2495,12 @@ function BattleState:openOldManBag()
|
|||||||
-- POKé BALLs; pokeyellow's SimulatedInputBattleItemList, shared by
|
-- POKé BALLs; pokeyellow's SimulatedInputBattleItemList, shared by
|
||||||
-- the Viridian tutorial and Oak's catch, has one.
|
-- the Viridian tutorial and Oak's catch, has one.
|
||||||
local qty = require("src.core.GameVersion").isYellow() and "x1" or "x50"
|
local qty = require("src.core.GameVersion").isYellow() and "x1" or "x50"
|
||||||
|
-- the tutorial bag rides DisplayBagMenu's LIST_MENU_BOX over the battle
|
||||||
|
-- screen (engine/battle/core.asm:2210)
|
||||||
list = ListMenu.new(game, "ITEMS", {
|
list = ListMenu.new(game, "ITEMS", {
|
||||||
{ value = "POKE_BALL", label = Strings("POKé BALL"), right = qty },
|
{ value = "POKE_BALL", label = Strings("POKé BALL"), right = qty },
|
||||||
}, {
|
}, {
|
||||||
|
itemBox = true,
|
||||||
script = function(l)
|
script = function(l)
|
||||||
l.scriptTimer = (l.scriptTimer or 0) + 1
|
l.scriptTimer = (l.scriptTimer or 0) + 1
|
||||||
if l.scriptTimer == 81 then
|
if l.scriptTimer == 81 then
|
||||||
@@ -2659,6 +2710,13 @@ function BattleState:resolveSwitch(newMon)
|
|||||||
self.phase = "messages"
|
self.phase = "messages"
|
||||||
self.afterQueue = "menu"
|
self.afterQueue = "menu"
|
||||||
self:act(function()
|
self:act(function()
|
||||||
|
-- SwitchPlayerMon (core.asm:2419-2423): RetreatMon prints over the
|
||||||
|
-- outgoing pic and holds 50 frames, then AnimateRetreatingPlayerMon
|
||||||
|
-- runs before the mon is recalled
|
||||||
|
self:sayNextAuto(self:withdrawText(self.player.name),
|
||||||
|
Timing.SWITCH_PLAYER_MON)
|
||||||
|
self:queueRetreatAnim()
|
||||||
|
self:actNext(function()
|
||||||
self:restoreMimicked(self.player) -- the battle copy leaves with it
|
self:restoreMimicked(self.player) -- the battle copy leaves with it
|
||||||
local previous = self.player
|
local previous = self.player
|
||||||
self.player = makeBattler(self.data, newMon, true, self.game.save)
|
self.player = makeBattler(self.data, newMon, true, self.game.save)
|
||||||
@@ -2673,9 +2731,10 @@ function BattleState:resolveSwitch(newMon)
|
|||||||
self:markParticipant()
|
self:markParticipant()
|
||||||
sendOutMonCursors(self)
|
sendOutMonCursors(self)
|
||||||
self.sendingOut = true
|
self.sendingOut = true
|
||||||
self:sayNext(self:sendOutText(self.player.name))
|
self:sayNextAuto(self:sendOutText(self.player.name))
|
||||||
self:queueSendOutAnim(false)
|
self:queueSendOutAnim(false)
|
||||||
end)
|
end)
|
||||||
|
end)
|
||||||
self:act(function()
|
self:act(function()
|
||||||
self:executeAction(self.enemy, self.player, self:enemyAction())
|
self:executeAction(self.enemy, self.player, self:enemyAction())
|
||||||
end)
|
end)
|
||||||
@@ -2703,7 +2762,12 @@ function BattleState:residualFor(b, opp)
|
|||||||
if b.residualDone then return end
|
if b.residualDone then return end
|
||||||
b.residualDone = true
|
b.residualDone = true
|
||||||
local msgs = Status.residual(b, opp, self)
|
local msgs = Status.residual(b, opp, self)
|
||||||
|
local rec = Status.recordFor(self.data and self.data.statuses, b.mon.status)
|
||||||
for _, m in ipairs(msgs) do self:sayNext(prefixEnemy(m, b)) end
|
for _, m in ipairs(msgs) do self:sayNext(prefixEnemy(m, b)) end
|
||||||
|
-- engine/battle/core.asm:490-493
|
||||||
|
if rec and rec.residual then
|
||||||
|
self:animNext("BURN_PSN_ANIM", b.isPlayer)
|
||||||
|
end
|
||||||
if b.leechSeeded and b.mon.hp > 0 then
|
if b.leechSeeded and b.mon.hp > 0 then
|
||||||
-- the drain plays the ABSORB animation from the healing side
|
-- the drain plays the ABSORB animation from the healing side
|
||||||
-- (core.asm:506-517 flips hWhoseTurn before PlayMoveAnimation)
|
-- (core.asm:506-517 flips hWhoseTurn before PlayMoveAnimation)
|
||||||
@@ -3263,6 +3327,26 @@ function BattleState:queueSendOutAnim(append)
|
|||||||
if append then self:act(fn) else self:actNext(fn) end
|
if append then self:act(fn) else self:actNext(fn) end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- AnimateRetreatingPlayerMon (core.asm:1769-1796); the Yellow starter Pikachu
|
||||||
|
-- slides off instead (pokeyellow core.asm:1862-1866, animations.asm:1259)
|
||||||
|
function BattleState:queueRetreatAnim()
|
||||||
|
if self:starterPikachuSendOut() then
|
||||||
|
self:actNext(function() self:slidePic("playerMon", 0, -64, 8, 3) end)
|
||||||
|
self:waitNext(24)
|
||||||
|
self:actNext(function()
|
||||||
|
-- .clearScreenArea keeps the 7x7 area blank until the swap
|
||||||
|
-- (pokeyellow core.asm:1867-1871) (#1545)
|
||||||
|
self.sendingOut = true
|
||||||
|
self:slidePic("playerMon")
|
||||||
|
end)
|
||||||
|
else
|
||||||
|
self:actNext(function()
|
||||||
|
self.shrinkOut = { battler = self.player, frame = 0 }
|
||||||
|
end)
|
||||||
|
self:waitNext(7)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
-- Should the low-health alarm sound this frame? pokered keys it off
|
-- Should the low-health alarm sound this frame? pokered keys it off
|
||||||
-- the drawn bar color: DrawPlayerHUDAndHPBar (core.asm:1846-1875) sets
|
-- the drawn bar color: DrawPlayerHUDAndHPBar (core.asm:1846-1875) sets
|
||||||
-- wLowHealthAlarm bit 7 when GetHealthBarColor says the player bar is
|
-- wLowHealthAlarm bit 7 when GetHealthBarColor says the player bar is
|
||||||
@@ -3479,6 +3563,12 @@ function BattleState:updateFx()
|
|||||||
self.growIn.frame = self.growIn.frame + 1
|
self.growIn.frame = self.growIn.frame + 1
|
||||||
if self.growIn.frame >= 12 then self.growIn = nil end
|
if self.growIn.frame >= 12 then self.growIn = nil end
|
||||||
end
|
end
|
||||||
|
-- the retreat shrink (AnimateRetreatingPlayerMon): 4+3 frames, then the
|
||||||
|
-- 7x7 area holds cleared (scale 0) until the swap replaces the battler
|
||||||
|
if self.shrinkOut then
|
||||||
|
self.shrinkOut.frame = self.shrinkOut.frame + 1
|
||||||
|
if self.shrinkOut.battler ~= self.player then self.shrinkOut = nil end
|
||||||
|
end
|
||||||
-- low-HP alarm (audio/low_health_alarm.asm): the two-tone siren
|
-- low-HP alarm (audio/low_health_alarm.asm): the two-tone siren
|
||||||
-- loops while the player's bar is red; see lowHealthAlarmActive
|
-- loops while the player's bar is red; see lowHealthAlarmActive
|
||||||
local Sound = require("src.core.Sound")
|
local Sound = require("src.core.Sound")
|
||||||
@@ -3585,9 +3675,20 @@ function BattleState:executeAction(user, target, action)
|
|||||||
})
|
})
|
||||||
self.aiUses = self:aiUsesFor()
|
self.aiUses = self:aiUsesFor()
|
||||||
markSeen(self.game, self.enemy.mon.species)
|
markSeen(self.game, self.enemy.mon.species)
|
||||||
-- _AIBattleWithdrawText: "X with-/drew Y!"
|
self:sayNext(self:romText("_AIBattleWithdrawText", "%s with-\ndrew %s!",
|
||||||
self:sayNext(Strings("%s with-\ndrew %s!", self.trainer.name, oldName))
|
self.trainer.name, oldName))
|
||||||
self:sayNext(Strings("%s sent\nout %s!", self.trainer.name, self.enemy.name))
|
-- EnemySendOut falls into EnemySendOutFirstMon: TrainerSentOutText,
|
||||||
|
-- then AnimateSendingOutMon and PlayCry (core.asm:1276-1434)
|
||||||
|
self.enemySendingOut = true
|
||||||
|
self:sayNextAuto(self:romText("_TrainerSentOutText", "%s sent\nout %s!",
|
||||||
|
self.trainer.name, self.enemy.name))
|
||||||
|
self:actNext(function()
|
||||||
|
self.enemySendingOut = false
|
||||||
|
self:startGrowIn(self.enemy)
|
||||||
|
self:actNext(function()
|
||||||
|
self:waitSfxNext(self:playEntranceCry(self.enemy))
|
||||||
|
end)
|
||||||
|
end)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -3732,6 +3833,8 @@ function BattleState:statusInterrupt(user, target, selectedId)
|
|||||||
{ rng = self.rng, forceCrit = false, typeless = true,
|
{ rng = self.rng, forceCrit = false, typeless = true,
|
||||||
screens = target })
|
screens = target })
|
||||||
self:sayNext(self:romText("_HurtItselfText", "It hurt itself in\nits confusion!"))
|
self:sayNext(self:romText("_HurtItselfText", "It hurt itself in\nits confusion!"))
|
||||||
|
-- HandleSelfConfusionDamage (core.asm:3706-3714, enemy side :5807-5811)
|
||||||
|
self:animNext("POUND", not user.isPlayer)
|
||||||
self:clearVolatiles(user, true)
|
self:clearVolatiles(user, true)
|
||||||
self:applyDamage(user, dmg)
|
self:applyDamage(user, dmg)
|
||||||
if user.mon.hp <= 0 then self:onFaint(user) end
|
if user.mon.hp <= 0 then self:onFaint(user) end
|
||||||
@@ -3824,25 +3927,38 @@ function BattleState:performMove(user, target, moveInst, isCalled)
|
|||||||
end
|
end
|
||||||
|
|
||||||
self.moveAnimRow = nil
|
self.moveAnimRow = nil
|
||||||
if not (user.thrashTurns and moveInst == user.thrashMove and user.thrashAnnounced) then
|
local thrashing = user.thrashTurns and moveInst == user.thrashMove
|
||||||
|
and user.thrashAnnounced or false
|
||||||
|
if thrashing then
|
||||||
|
-- .ThrashingAboutCheck (core.asm:3531-3552)
|
||||||
|
self:sayNextAuto(self:romText("_ThrashingAboutText", "%s's\nthrashing about!",
|
||||||
|
displayName(user)))
|
||||||
|
user.thrashTurns = user.thrashTurns - 1
|
||||||
|
if user.thrashTurns <= 0 then
|
||||||
|
user.thrashTurns, user.thrashMove, user.thrashAnnounced = nil, nil, nil
|
||||||
|
if not user.confusedTurns then user.confusedTurns = self.rng(2, 5) end
|
||||||
|
end
|
||||||
|
else
|
||||||
self:sayNextAuto(self:romText("_ItemUseText001", "%s\nused %s!", displayName(user), move.name))
|
self:sayNextAuto(self:romText("_ItemUseText001", "%s\nused %s!", displayName(user), move.name))
|
||||||
-- the move's animation plays right after the announcement; the
|
end
|
||||||
-- damage path attaches the target's hit blink to this row so the
|
-- PlayCurrentMoveAnimation follows the announcement; Mimic (announceAnim
|
||||||
-- blink follows the animation (pokered's order). Mimic is the
|
-- = false) queues it from applyMimic after a successful copy
|
||||||
-- exception (announceAnim = false): PlayCurrentMoveAnimation runs
|
|
||||||
-- only after a successful copy, never on a miss -- applyMimic queues it
|
|
||||||
if not (record and record.announceAnim == false) then
|
if not (record and record.announceAnim == false) then
|
||||||
self.nextInsert = (self.nextInsert or 0) + 1
|
self.nextInsert = (self.nextInsert or 0) + 1
|
||||||
self.moveAnimRow = { anim = move.id, attackerIsPlayer = user.isPlayer }
|
-- ld a, THRASH / ld [wPlayerMoveNum] (core.asm:3534-3535, :5909-5910) #1577
|
||||||
|
self.moveAnimRow = { anim = thrashing and "THRASH" or move.id,
|
||||||
|
attackerIsPlayer = user.isPlayer }
|
||||||
table.insert(self.queue, self.nextInsert, self.moveAnimRow)
|
table.insert(self.queue, self.nextInsert, self.moveAnimRow)
|
||||||
end
|
end
|
||||||
end
|
|
||||||
Runtime.emit("battle.move_used", {
|
Runtime.emit("battle.move_used", {
|
||||||
battle = self, user = user, target = target, move = move,
|
battle = self, user = user, target = target, move = move,
|
||||||
isCalled = isCalled or false,
|
isCalled = isCalled or false,
|
||||||
})
|
})
|
||||||
|
|
||||||
local ctx = EffectRegistry.makeCtx(self, user, target, move, moveInst, isCalled)
|
local ctx = EffectRegistry.makeCtx(self, user, target, move, moveInst, isCalled)
|
||||||
|
-- .ThrashingAboutCheck jumps past JumpMoveEffect into PlayerCalcMoveDamage
|
||||||
|
-- (core.asm:3540), so SpecialEffectsCont never re-runs on a locked turn
|
||||||
|
ctx.thrashing = thrashing
|
||||||
|
|
||||||
-- Metronome / Mirror Move re-entry; a nil pick means the record
|
-- Metronome / Mirror Move re-entry; a nil pick means the record
|
||||||
-- already said its failure text
|
-- already said its failure text
|
||||||
@@ -4079,8 +4195,12 @@ function BattleState:onFaint(battler)
|
|||||||
-- acknowledged core.asm:797-798 bug.)
|
-- acknowledged core.asm:797-798 bug.)
|
||||||
self:actNext(function() self:playVictoryMusic() end)
|
self:actNext(function() self:playVictoryMusic() end)
|
||||||
end
|
end
|
||||||
-- _EnemyMonFaintedText "Enemy X fainted!" / _PlayerMonFaintedText
|
-- _EnemyMonFaintedText already carries its own "Enemy" wording, so this
|
||||||
self:sayNext(Strings("%s\nfainted!", displayName(battler)))
|
-- passes the raw name -- displayName's separate Strings("Enemy %s", ...)
|
||||||
|
-- would double it up
|
||||||
|
self:sayNext(battler.isPlayer
|
||||||
|
and self:romText("_PlayerMonFaintedText", "%s\nfainted!", battler.name)
|
||||||
|
or self:romText("_EnemyMonFaintedText", "Enemy %s\nfainted!", battler.name))
|
||||||
if battler.isPlayer then
|
if battler.isPlayer then
|
||||||
self:act(function() self:playerMonFainted() end)
|
self:act(function() self:playerMonFainted() end)
|
||||||
else
|
else
|
||||||
@@ -4111,8 +4231,11 @@ function BattleState:awardExp()
|
|||||||
end
|
end
|
||||||
local function applyShare(mon, split, announce)
|
local function applyShare(mon, split, announce)
|
||||||
local playerId = self.game.save.player and self.game.save.player.id
|
local playerId = self.game.save.player and self.game.save.player.id
|
||||||
local traded = mon.traded == true
|
-- GainExperience (engine/battle/experience.asm:69-88) compares the
|
||||||
or (mon.otId ~= nil and playerId ~= nil and mon.otId ~= playerId)
|
-- stored MON_OTID against wPlayerID every award; no persistent flag
|
||||||
|
-- mon.traded covers otId-less mons (repairTradedOtIds, old link peers) #1488
|
||||||
|
local traded = playerId ~= nil and ((mon.otId ~= nil and mon.otId ~= playerId)
|
||||||
|
or (mon.otId == nil and mon.traded == true))
|
||||||
local levels, gained = Experience.apply(self.data, mon, self.enemy.def,
|
local levels, gained = Experience.apply(self.data, mon, self.enemy.def,
|
||||||
self.enemy.mon.level, self.kind == "trainer",
|
self.enemy.mon.level, self.kind == "trainer",
|
||||||
split, traded)
|
split, traded)
|
||||||
@@ -4256,27 +4379,52 @@ function BattleState:enemyMonFainted()
|
|||||||
-- "X is" off so "about to use" stays above the name, instead of the
|
-- "X is" off so "about to use" stays above the name, instead of the
|
||||||
-- page ending on a bare nick (#565). Then para "Will PLAYER" /
|
-- page ending on a bare nick (#565). Then para "Will PLAYER" /
|
||||||
-- "change POKéMON?" with YES/NO.
|
-- "change POKéMON?" with YES/NO.
|
||||||
|
--
|
||||||
|
-- _TrainerAboutToUseText combines both \f-paged, but unlike
|
||||||
|
-- _ItemUseBallText00's say()+say() merge above, this is say()+
|
||||||
|
-- sayChoice(): tried merging into one romText/sayChoice call and
|
||||||
|
-- confirmed via tests/engine/trainer_shift_prompt_bug565.lua that
|
||||||
|
-- the battle queue's own \f handling (not TextBox.lua's) does not
|
||||||
|
-- page a sayChoice string the same way -- left as two calls.
|
||||||
self:say(Strings("%s is\nabout to use\v%s!", self.trainer.name, nextName))
|
self:say(Strings("%s is\nabout to use\v%s!", self.trainer.name, nextName))
|
||||||
|
-- EnemySendOutFirstMon .next9/.next8 (core.asm:1390-1409) and
|
||||||
|
-- HasMonFainted's NoWillText (core.asm:1473-1488)
|
||||||
self:sayChoice(
|
self:sayChoice(
|
||||||
Strings("Will %s\nchange POKéMON?", self.game.save.player.name),
|
Strings("Will %s\nchange POKéMON?", self.game.save.player.name),
|
||||||
function(yes)
|
function(yes)
|
||||||
if not yes then return end
|
if not yes then return end
|
||||||
local game = self.game
|
local game = self.game
|
||||||
Screens.push(game, "PartyMenu", {
|
local shiftOpts, reopenShift
|
||||||
|
reopenShift = function(text)
|
||||||
|
table.insert(self.queue, 1, { ui = function()
|
||||||
|
return self:buildScreen("PartyMenu", shiftOpts)
|
||||||
|
end })
|
||||||
|
table.insert(self.queue, 1, { text = text })
|
||||||
|
end
|
||||||
|
shiftOpts = {
|
||||||
battle = self,
|
battle = self,
|
||||||
party = self:playerPartyView(),
|
party = self:playerPartyView(),
|
||||||
forceSwitch = true,
|
forceSwitch = true,
|
||||||
onSwitch = function(mon)
|
onSwitch = function(mon)
|
||||||
if mon ~= self.player.mon and mon.hp > 0 then
|
if mon == self.player.mon then
|
||||||
|
reopenShift(self:romText("_AlreadyOutText",
|
||||||
|
"%s is\nalready out!", self.player.name))
|
||||||
|
elseif mon.hp <= 0 then
|
||||||
|
reopenShift(self:romText("_NoWillText", "There's no will\nto fight!"))
|
||||||
|
else
|
||||||
shiftSwitchMon = mon
|
shiftSwitchMon = mon
|
||||||
end
|
end
|
||||||
end,
|
end,
|
||||||
})
|
}
|
||||||
|
Screens.push(game, "PartyMenu", shiftOpts)
|
||||||
end, { box = Theme.trainerSwitchBox })
|
end, { box = Theme.trainerSwitchBox })
|
||||||
end
|
end
|
||||||
self:act(function()
|
self:act(function()
|
||||||
local previous = self.enemy
|
local previous = self.enemy
|
||||||
self.enemy = makeBattler(self.data, self.enemyParty[self.enemyIndex], false)
|
self.enemy = makeBattler(self.data, self.enemyParty[self.enemyIndex], false)
|
||||||
|
-- EnemySendOutFirstMon (core.asm:1359-1363): the fresh foe's HP is the
|
||||||
|
-- new wLastSwitchInEnemyMonHP baseline RetreatMon measures from
|
||||||
|
self.lastSwitchInEnemyHP = self.enemy.mon.hp
|
||||||
-- EnemySendOutFirstMon (core.asm:1314-1315): clears player's trap
|
-- EnemySendOutFirstMon (core.asm:1314-1315): clears player's trap
|
||||||
clearTrapping(self.player)
|
clearTrapping(self.player)
|
||||||
self:syncSides()
|
self:syncSides()
|
||||||
@@ -4296,7 +4444,8 @@ function BattleState:enemyMonFainted()
|
|||||||
-- (AnimateSendingOutMon) with the cry; no POOF -- that animation
|
-- (AnimateSendingOutMon) with the cry; no POOF -- that animation
|
||||||
-- belongs to the player-side SendOutMon (core.asm:1757-1762)
|
-- belongs to the player-side SendOutMon (core.asm:1757-1762)
|
||||||
self.enemySendingOut = true
|
self.enemySendingOut = true
|
||||||
self:sayNext(Strings("%s sent\nout %s!", self.trainer.name, self.enemy.name))
|
self:sayNextAuto(self:romText("_TrainerSentOutText", "%s sent\nout %s!",
|
||||||
|
self.trainer.name, self.enemy.name))
|
||||||
self:actNext(function()
|
self:actNext(function()
|
||||||
self.enemySendingOut = false
|
self.enemySendingOut = false
|
||||||
self:startGrowIn(self.enemy)
|
self:startGrowIn(self.enemy)
|
||||||
@@ -4309,6 +4458,13 @@ function BattleState:enemyMonFainted()
|
|||||||
self:act(function()
|
self:act(function()
|
||||||
local mon = shiftSwitchMon
|
local mon = shiftSwitchMon
|
||||||
if not mon then return end
|
if not mon then return end
|
||||||
|
-- SwitchPlayerMon (core.asm:2419-2423): RetreatMon, the 50-frame
|
||||||
|
-- hold, AnimateRetreatingPlayerMon, then the recall and the send-out
|
||||||
|
self.nextInsert = 0
|
||||||
|
self:sayNextAuto(self:withdrawText(self.player.name),
|
||||||
|
Timing.SWITCH_PLAYER_MON)
|
||||||
|
self:queueRetreatAnim()
|
||||||
|
self:actNext(function()
|
||||||
local previous = self.player
|
local previous = self.player
|
||||||
self.player = makeBattler(self.data, mon, true, self.game.save)
|
self.player = makeBattler(self.data, mon, true, self.game.save)
|
||||||
clearTrapping(self.enemy)
|
clearTrapping(self.enemy)
|
||||||
@@ -4335,9 +4491,10 @@ function BattleState:enemyMonFainted()
|
|||||||
self.nextInsert = 0
|
self.nextInsert = 0
|
||||||
sendOutMonCursors(self)
|
sendOutMonCursors(self)
|
||||||
self.sendingOut = true
|
self.sendingOut = true
|
||||||
self:sayNext(self:sendOutText(self.player.name))
|
self:sayNextAuto(self:sendOutText(self.player.name))
|
||||||
self:queueSendOutAnim(false)
|
self:queueSendOutAnim(false)
|
||||||
end)
|
end)
|
||||||
|
end)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local prize = (self.trainer.baseMoney or 0) * self.enemy.mon.level
|
local prize = (self.trainer.baseMoney or 0) * self.enemy.mon.level
|
||||||
@@ -4378,9 +4535,22 @@ function BattleState:enemyMonFainted()
|
|||||||
-- TrainerNamePointers aims those entries at wTrainerName). The tag
|
-- TrainerNamePointers aims those entries at wTrainerName). The tag
|
||||||
-- prints once, so a `para` page carries no second copy (#566).
|
-- prints once, so a `para` page carries no second copy (#566).
|
||||||
local tag = self.trainer and self.trainer.name
|
local tag = self.trainer and self.trainer.name
|
||||||
|
-- the badge jingle (sound_get_item_1 and friends) rides the armed
|
||||||
|
-- line's first page, as the script's text command would (#1606)
|
||||||
|
local sfx = self.endBattleSound
|
||||||
|
local data = self.data
|
||||||
for page in (self.endBattleText .. "\f"):gmatch("(.-)\f") do
|
for page in (self.endBattleText .. "\f"):gmatch("(.-)\f") do
|
||||||
if page ~= "" then
|
if page ~= "" then
|
||||||
self:sayNext(tag and (tag .. ": " .. page) or page)
|
local line = tag and (tag .. ": " .. page) or page
|
||||||
|
if sfx then
|
||||||
|
local id = sfx
|
||||||
|
self:sayNextWaitSfx(line, function()
|
||||||
|
return require("src.core.Sound").play(data, id)
|
||||||
|
end)
|
||||||
|
sfx = nil
|
||||||
|
else
|
||||||
|
self:sayNext(line)
|
||||||
|
end
|
||||||
tag = nil
|
tag = nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -4526,7 +4696,7 @@ function BattleState:openReplacementMenu()
|
|||||||
self.nextInsert = 0
|
self.nextInsert = 0
|
||||||
sendOutMonCursors(self)
|
sendOutMonCursors(self)
|
||||||
self.sendingOut = true
|
self.sendingOut = true
|
||||||
self:sayNext(self:sendOutText(self.player.name))
|
self:sayNextAuto(self:sendOutText(self.player.name))
|
||||||
self:queueSendOutAnim(false)
|
self:queueSendOutAnim(false)
|
||||||
end,
|
end,
|
||||||
})
|
})
|
||||||
@@ -4805,7 +4975,8 @@ function BattleState:storeCaughtMon()
|
|||||||
-- text_promptbutton (item_effects.asm:624-629), so the fanfare follows
|
-- text_promptbutton (item_effects.asm:624-629), so the fanfare follows
|
||||||
-- the box rather than firing when the dex bit is set
|
-- the box rather than firing when the dex bit is set
|
||||||
self:sayNextWaitSfx(
|
self:sayNextWaitSfx(
|
||||||
Strings("New POKéDEX data\nwill be added for\n%s!", self.enemy.name),
|
self:romText("_ItemUseBallText06",
|
||||||
|
"New POKéDEX data\nwill be added for\n%s!", self.enemy.name),
|
||||||
function() return require("src.core.Sound").play(self.data, "Dex_Page_Added") end)
|
function() return require("src.core.Sound").play(self.data, "Dex_Page_Added") end)
|
||||||
self:uiNext(function()
|
self:uiNext(function()
|
||||||
return self:buildScreen("DexEntryMenu", species)
|
return self:buildScreen("DexEntryMenu", species)
|
||||||
@@ -4826,9 +4997,12 @@ function BattleState:storeCaughtMon()
|
|||||||
if boxNum then
|
if boxNum then
|
||||||
askCaughtNickname()
|
askCaughtNickname()
|
||||||
-- _ItemUseBallText07/08 keyed on EVENT_MET_BILL
|
-- _ItemUseBallText07/08 keyed on EVENT_MET_BILL
|
||||||
local pc = (game.save.flags and game.save.flags.EVENT_MET_BILL)
|
local metBill = game.save.flags and game.save.flags.EVENT_MET_BILL
|
||||||
and "BILL's PC" or Strings("someone's PC")
|
self:sayNext(self:romText(
|
||||||
self:sayNext(Strings("%s was\ntransferred to\n%s!", self.enemy.name, pc))
|
metBill and "_ItemUseBallText07" or "_ItemUseBallText08",
|
||||||
|
metBill and "%s was\ntransferred to\nBILL's PC!"
|
||||||
|
or "%s was\ntransferred to\nsomeone's PC!",
|
||||||
|
self.enemy.name))
|
||||||
else
|
else
|
||||||
self:sayNext(Strings("But every BOX\nis full!"))
|
self:sayNext(Strings("But every BOX\nis full!"))
|
||||||
end
|
end
|
||||||
@@ -4934,8 +5108,18 @@ function BattleState:throwBall(ball)
|
|||||||
-- RESTLESS SOUL dodges balls even once the scope has revealed it,
|
-- RESTLESS SOUL dodges balls even once the scope has revealed it,
|
||||||
-- so it is not a ghost battle any more (#444)
|
-- so it is not a ghost battle any more (#444)
|
||||||
self:animNext(self:tossAnimFor(ball), true, nil, ball)
|
self:animNext(self:tossAnimFor(ball), true, nil, ball)
|
||||||
self:sayNext(Strings("It dodged the\nthrown BALL!"))
|
-- _ItemUseBallText00 is one label for both lines, \f-paged. Unlike
|
||||||
self:sayNext(Strings("This POKéMON\ncan't be caught!"))
|
-- TextBox.new() (which splits \f itself), the battle queue's own
|
||||||
|
-- startMessage() only splits on \n/\v -- confirmed live: the \f
|
||||||
|
-- landed mid-line and the second sentence overflowed off the box
|
||||||
|
-- instead of starting a fresh page. Resolve the label once, then
|
||||||
|
-- split it the same way TextBox.lua does and queue one sayNext per
|
||||||
|
-- page, so the two ROM sentences still render as two pages.
|
||||||
|
local dodgeText = self:romText("_ItemUseBallText00",
|
||||||
|
"It dodged the\nthrown BALL!\fThis POKéMON\ncan't be caught!")
|
||||||
|
for page in (dodgeText .. "\f"):gmatch("(.-)\f") do
|
||||||
|
self:sayNext(page)
|
||||||
|
end
|
||||||
self:act(function()
|
self:act(function()
|
||||||
self:executeAction(self.enemy, self.player, self:enemyAction())
|
self:executeAction(self.enemy, self.player, self:enemyAction())
|
||||||
end)
|
end)
|
||||||
@@ -4982,10 +5166,14 @@ function BattleState:openParty()
|
|||||||
battle = self,
|
battle = self,
|
||||||
party = self:playerPartyView(),
|
party = self:playerPartyView(),
|
||||||
onSwitch = function(mon)
|
onSwitch = function(mon)
|
||||||
|
-- PartyMenuOrRockOrRun's SWITCH .partyMonDeselected (core.asm:2396-2408)
|
||||||
if mon == self.player.mon then
|
if mon == self.player.mon then
|
||||||
self:say(Strings("%s is\nalready out!", self.player.name))
|
self:say(self:romText("_AlreadyOutText",
|
||||||
|
"%s is\nalready out!", self.player.name))
|
||||||
|
self:act(function() self:openParty() end)
|
||||||
elseif mon.hp <= 0 then
|
elseif mon.hp <= 0 then
|
||||||
self:say(self:romText("_NoWillText", "There's no will\nto fight!"))
|
self:say(self:romText("_NoWillText", "There's no will\nto fight!"))
|
||||||
|
self:act(function() self:openParty() end)
|
||||||
else
|
else
|
||||||
self:resolveSwitch(mon)
|
self:resolveSwitch(mon)
|
||||||
end
|
end
|
||||||
@@ -5129,6 +5317,16 @@ function BattleState:growInScale(battler)
|
|||||||
return f < 3 and 0 or f < 7 and 3 / 7 or 5 / 7
|
return f < 3 and 0 or f < 7 and 3 / 7 or 5 / 7
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- AnimateRetreatingPlayerMon's CopyDownscaledMonTiles stages
|
||||||
|
-- (core.asm:1769-1796)
|
||||||
|
function BattleState:shrinkOutScale(battler)
|
||||||
|
local shrink = self.shrinkOut
|
||||||
|
if not shrink or shrink.battler ~= battler then return nil end
|
||||||
|
-- scale 0 past Delay3: the area stays cleared until the swap
|
||||||
|
-- (core.asm:1790-1796) (#1563)
|
||||||
|
return shrink.frame < 4 and 5 / 7 or shrink.frame < 7 and 3 / 7 or 0
|
||||||
|
end
|
||||||
|
|
||||||
-- battler hidden this frame? (damage blink)
|
-- battler hidden this frame? (damage blink)
|
||||||
--
|
--
|
||||||
-- AnimationBlinkMon hides the pic, waits DelayFrames 5, shows it, waits
|
-- AnimationBlinkMon hides the pic, waits DelayFrames 5, shows it, waits
|
||||||
@@ -5765,15 +5963,18 @@ function BattleState:drawPicsLayer(slide, sx, sy, onlySide, skipMenuClip)
|
|||||||
local s = BattleState.resolveBattleScale(self.data, "back",
|
local s = BattleState.resolveBattleScale(self.data, "back",
|
||||||
imagePathOf(self.player.sprite),
|
imagePathOf(self.player.sprite),
|
||||||
self.player.mon and self.player.mon.species)
|
self.player.mon and self.player.mon.species)
|
||||||
local gs = self:growInScale(self.player)
|
local gs = self:growInScale(self.player) or self:shrinkOutScale(self.player)
|
||||||
if gs then
|
if gs then
|
||||||
-- the player-side AnimateSendingOutMon grow (after the poof,
|
-- the player-side AnimateSendingOutMon grow (core.asm:1757-1762) and
|
||||||
-- core.asm:1757-1762): feet pinned at y=96, horizontal centre
|
-- the AnimateRetreatingPlayerMon shrink (core.asm:1769-1796)
|
||||||
-- pinned, mod scale composed with the grow stage
|
|
||||||
local eff = s * gs
|
local eff = s * gs
|
||||||
if eff > 0 then
|
if eff > 0 then
|
||||||
|
-- the retreat stages sit one tile right of the grow-in's
|
||||||
|
-- (hlcoord 3,7 / 4,9 vs 2,7 / 3,9, core.asm:1770-1788) (#1563)
|
||||||
|
local shrinkX = self.shrinkOut
|
||||||
|
and self.shrinkOut.battler == self.player and 8 or 0
|
||||||
love.graphics.draw(img,
|
love.graphics.draw(img,
|
||||||
8 - padL * s + img:getWidth() * s * (1 - gs) / 2 + sx,
|
8 + shrinkX - padL * s + img:getWidth() * s * (1 - gs) / 2 + sx,
|
||||||
96 - (img:getHeight() - pad) * eff + sy, 0, eff, eff)
|
96 - (img:getHeight() - pad) * eff + sy, 0, eff, eff)
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -92,15 +92,33 @@ local function hitCount(ctx, record)
|
|||||||
return dist[r + 1]
|
return dist[r + 1]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- engine/battle/effects.asm:119-151 (poison), :194-255 (burn/freeze/paralyze)
|
||||||
|
local FBP_SIDE_STATUS = { BRN = true, FRZ = true, PAR = true }
|
||||||
|
|
||||||
|
local function secondaryStatusFx(battle, user, status)
|
||||||
|
if status == "PSN" then
|
||||||
|
local row = battle:animNext(user.isPlayer and "ENEMY_HUD_SHAKE_ANIM"
|
||||||
|
or "SHAKE_SCREEN_ANIM", user.isPlayer)
|
||||||
|
row.animDelayed = true
|
||||||
|
row.hit = { animType = user.isPlayer and 6 or 3 }
|
||||||
|
elseif FBP_SIDE_STATUS[status] and user.isPlayer then
|
||||||
|
battle:animNext("ENEMY_HUD_SHAKE_ANIM", true).animDelayed = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
-- The damaging pipeline, extracted from the performMove monolith: every
|
-- The damaging pipeline, extracted from the performMove monolith: every
|
||||||
-- stage keeps the original's exact check order and rng consumption
|
-- stage keeps the original's exact check order and rng consumption
|
||||||
-- (invulnerability -> gate -> hit count -> pre-accuracy -> accuracy ->
|
-- (pre-accuracy -> invulnerability -> gate -> hit count -> accuracy ->
|
||||||
-- damage choice -> hits -> messages -> after-damage -> secondary run).
|
-- damage choice -> hits -> messages -> after-damage -> secondary run).
|
||||||
function EffectRegistry.runDamaging(battle, ctx, record)
|
function EffectRegistry.runDamaging(battle, ctx, record)
|
||||||
local user, target = ctx.user, ctx.target
|
local user, target = ctx.user, ctx.target
|
||||||
local move, moveInst = ctx.move, ctx.moveInst
|
local move, moveInst = ctx.move, ctx.moveInst
|
||||||
local neverMiss = record and record.neverMiss
|
local neverMiss = record and record.neverMiss
|
||||||
|
|
||||||
|
-- SpecialEffectsCont's JumpMoveEffect (core.asm:3129-3133) runs before
|
||||||
|
-- MoveHitTest's INVULNERABLE test (:3150), mid-Fly/Dig included (#1565)
|
||||||
|
if record and record.beforeAccuracy then record.beforeAccuracy(ctx) end
|
||||||
|
|
||||||
-- Swift ignores semi-invulnerability (MoveHitTest returns hit for
|
-- Swift ignores semi-invulnerability (MoveHitTest returns hit for
|
||||||
-- SWIFT_EFFECT before the INVULNERABLE check)
|
-- SWIFT_EFFECT before the INVULNERABLE check)
|
||||||
if target.invulnerable and not neverMiss then
|
if target.invulnerable and not neverMiss then
|
||||||
@@ -129,8 +147,6 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
|||||||
|
|
||||||
local hits = hitCount(ctx, record)
|
local hits = hitCount(ctx, record)
|
||||||
|
|
||||||
if record and record.beforeAccuracy then record.beforeAccuracy(ctx) end
|
|
||||||
|
|
||||||
if not neverMiss then
|
if not neverMiss then
|
||||||
if not battle:accuracyRoll(move, user, target) then
|
if not battle:accuracyRoll(move, user, target) then
|
||||||
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
|
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
|
||||||
@@ -207,7 +223,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
|||||||
-- replay PlayMoveAnimation per strike (pokered: GetPlayerAnimationType
|
-- replay PlayMoveAnimation per strike (pokered: GetPlayerAnimationType
|
||||||
-- / GetEnemyAnimationType loop on wNumAttacksLeft); hit 1 reuses the
|
-- / GetEnemyAnimationType loop on wNumAttacksLeft); hit 1 reuses the
|
||||||
-- announcement-time moveAnimRow, later hits queue fresh anim rows.
|
-- announcement-time moveAnimRow, later hits queue fresh anim rows.
|
||||||
-- Thrash/rage continuations have no announcement anim -- a bare
|
-- Mimic queues no announcement anim (announceAnim = false) -- a bare
|
||||||
-- hitRow carries the blink instead.
|
-- hitRow carries the blink instead.
|
||||||
-- PlayApplyingAttackSound (engine/battle/animations.asm, the routine after
|
-- PlayApplyingAttackSound (engine/battle/animations.asm, the routine after
|
||||||
-- PlayApplyingAttackAnimation) picks the sound off wDamageMultipliers -- 10
|
-- PlayApplyingAttackAnimation) picks the sound off wDamageMultipliers -- 10
|
||||||
@@ -318,7 +334,12 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
|||||||
-- secondary side effects (blocked by fainting)
|
-- secondary side effects (blocked by fainting)
|
||||||
if record and record.run and record.kind ~= "primary"
|
if record and record.run and record.kind ~= "primary"
|
||||||
and target.mon.hp > 0 and totalDealt > 0 then
|
and target.mon.hp > 0 and totalDealt > 0 then
|
||||||
for _, m in ipairs(record.run(ctx)) do
|
local hadStatus = target.mon.status
|
||||||
|
local msgs = record.run(ctx)
|
||||||
|
if target.mon.status and target.mon.status ~= hadStatus then
|
||||||
|
secondaryStatusFx(battle, user, target.mon.status)
|
||||||
|
end
|
||||||
|
for _, m in ipairs(msgs) do
|
||||||
battle:sayNext(m)
|
battle:sayNext(m)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -581,22 +581,16 @@ MoveEffects.full = {
|
|||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
THRASH_PETAL_DANCE_EFFECT = {
|
THRASH_PETAL_DANCE_EFFECT = {
|
||||||
afterDamage = function(ctx)
|
-- ThrashPetalDanceEffect (effects.asm:791-808) runs before damage
|
||||||
|
-- (data/battle/special_effects.asm:22, core.asm:3531-3552)
|
||||||
|
beforeAccuracy = function(ctx)
|
||||||
local user = ctx.user
|
local user = ctx.user
|
||||||
if not user.thrashTurns then
|
if ctx.thrashing or user.thrashTurns then return end
|
||||||
user.thrashTurns = ctx.rng(2, 3) -- 3-4 attacks total, then confusion
|
user.thrashTurns = ctx.rng(2, 3) -- 3-4 attacks total, then confusion
|
||||||
user.thrashMove = ctx.moveInst
|
user.thrashMove = ctx.moveInst
|
||||||
user.thrashAnnounced = true
|
user.thrashAnnounced = true
|
||||||
else
|
ctx.battle:animBeforeMove(
|
||||||
user.thrashTurns = user.thrashTurns - 1
|
user.isPlayer and "SHRINKING_SQUARE_ANIM" or "ANIM_B1", user.isPlayer)
|
||||||
if user.thrashTurns <= 0 then
|
|
||||||
user.thrashTurns, user.thrashMove, user.thrashAnnounced = nil, nil, nil
|
|
||||||
if not user.confusedTurns then
|
|
||||||
user.confusedTurns = ctx.rng(2, 5)
|
|
||||||
ctx.say(romText(ctx.battle.data, "_BecameConfusedText", "%s\nbecame confused!", displayName(user)))
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
JUMP_KICK_EFFECT = {
|
JUMP_KICK_EFFECT = {
|
||||||
|
|||||||
@@ -166,6 +166,9 @@ Battle.SUBSTATUS_ITEMS = {
|
|||||||
-- be run from or Roared away.
|
-- be run from or Roared away.
|
||||||
Battle.BATTLETYPE_FORCESHINY = 7
|
Battle.BATTLETYPE_FORCESHINY = 7
|
||||||
Battle.BATTLETYPE_TRAP = 9
|
Battle.BATTLETYPE_TRAP = 9
|
||||||
|
-- LostBattle's .canlose arm (engine/battle/core.asm:2766): the only battle
|
||||||
|
-- type whose loss still prints the trainer's own line instead of a whiteout.
|
||||||
|
Battle.BATTLETYPE_CANLOSE = 1
|
||||||
|
|
||||||
-- BadgeStatBoosts (engine/battle/core.asm:6534): each of these Johto badges
|
-- BadgeStatBoosts (engine/battle/core.asm:6534): each of these Johto badges
|
||||||
-- raises the PLAYER's in-battle stat by 1/8. The routine walks every other
|
-- raises the PLAYER's in-battle stat by 1/8. The routine walks every other
|
||||||
@@ -889,10 +892,13 @@ Battle.PRIORITY = {
|
|||||||
EFFECT_ENDURE = 3,
|
EFFECT_ENDURE = 3,
|
||||||
EFFECT_COUNTER = -1,
|
EFFECT_COUNTER = -1,
|
||||||
EFFECT_MIRROR_COAT = -1,
|
EFFECT_MIRROR_COAT = -1,
|
||||||
EFFECT_VITAL_THROW = -1,
|
EFFECT_FORCE_SWITCH = -1, -- Whirlwind, Roar: priority 0, below BASE
|
||||||
}
|
}
|
||||||
|
|
||||||
function Battle:movePriority(moveId)
|
function Battle:movePriority(moveId)
|
||||||
|
-- GetMovePriority `cp VITAL_THROW / ld a, 0 / ret z`
|
||||||
|
-- (engine/battle/core.asm:787-789).
|
||||||
|
if moveId == "VITAL_THROW" then return -1 end
|
||||||
local def = self:moveDef(moveId)
|
local def = self:moveDef(moveId)
|
||||||
return (def and Battle.PRIORITY[def.effect]) or 0
|
return (def and Battle.PRIORITY[def.effect]) or 0
|
||||||
end
|
end
|
||||||
@@ -2403,10 +2409,11 @@ Battle.MOVE_EFFECTS.EFFECT_BATON_PASS = function(self, attacker)
|
|||||||
self.enemy = party[target]
|
self.enemy = party[target]
|
||||||
self.enemy.volatile = carried
|
self.enemy.volatile = carried
|
||||||
end
|
end
|
||||||
self:emit({ kind = "send", side = side,
|
local sent = side == "player" and self.player or self.enemy
|
||||||
mon = side == "player" and self.player or self.enemy,
|
self:emit({ kind = "send", side = side, mon = sent,
|
||||||
text = "Go! " .. self:monName(side == "player" and self.player
|
hp = sent.hp or 0, status = sent.status or false,
|
||||||
or self.enemy) .. "!" })
|
level = sent.level, experience = sent.experience,
|
||||||
|
text = "Go! " .. self:monName(sent) .. "!" })
|
||||||
end
|
end
|
||||||
|
|
||||||
-- BattleCommand_TrapTarget's .Traps table, one line per move: target first,
|
-- BattleCommand_TrapTarget's .Traps table, one line per move: target first,
|
||||||
@@ -2665,6 +2672,8 @@ Battle.MOVE_EFFECTS.EFFECT_FORCE_SWITCH = function(self, attacker, defender,
|
|||||||
self.stages.enemy = Battle.newStages()
|
self.stages.enemy = Battle.newStages()
|
||||||
end
|
end
|
||||||
self:emit({ kind = "send", side = self:sideOf(incoming), mon = incoming,
|
self:emit({ kind = "send", side = self:sideOf(incoming), mon = incoming,
|
||||||
|
hp = incoming.hp or 0, status = incoming.status or false,
|
||||||
|
level = incoming.level, experience = incoming.experience,
|
||||||
text = self:monName(incoming) .. " was dragged out!" })
|
text = self:monName(incoming) .. " was dragged out!" })
|
||||||
self:breakTrapsOnSend(incoming)
|
self:breakTrapsOnSend(incoming)
|
||||||
self:spikesDamage(incoming)
|
self:spikesDamage(incoming)
|
||||||
@@ -3084,6 +3093,7 @@ function Battle:resolveFaints()
|
|||||||
if self.trainer then
|
if self.trainer then
|
||||||
self:emit({ kind = "message",
|
self:emit({ kind = "message",
|
||||||
text = (self.trainer.name or "TRAINER") .. " was defeated!" })
|
text = (self.trainer.name or "TRAINER") .. " was defeated!" })
|
||||||
|
self:printWinLossText("win")
|
||||||
self:awardPrizeMoney()
|
self:awardPrizeMoney()
|
||||||
end
|
end
|
||||||
-- CheckPayDay, on the win arm only (engine/battle/core.asm:7971-7976,
|
-- CheckPayDay, on the win arm only (engine/battle/core.asm:7971-7976,
|
||||||
@@ -3110,6 +3120,8 @@ function Battle:resolveFaints()
|
|||||||
-- can offer a shift on (engine/battle/core.asm:2241-2278).
|
-- can offer a shift on (engine/battle/core.asm:2241-2278).
|
||||||
self:emit({ kind = "send", side = "enemy", mon = self.enemy,
|
self:emit({ kind = "send", side = "enemy", mon = self.enemy,
|
||||||
replacement = true,
|
replacement = true,
|
||||||
|
hp = self.enemy.hp or 0, status = self.enemy.status or false,
|
||||||
|
level = self.enemy.level, experience = self.enemy.experience,
|
||||||
text = (self.trainer and self.trainer.name or "Foe") .. " sent out "
|
text = (self.trainer and self.trainer.name or "Foe") .. " sent out "
|
||||||
.. self:monName(self.enemy) .. "!" })
|
.. self:monName(self.enemy) .. "!" })
|
||||||
Runtime.emit("battle.battler_switched", {
|
Runtime.emit("battle.battler_switched", {
|
||||||
@@ -3152,6 +3164,11 @@ function Battle:resolveFaints()
|
|||||||
local nextIndex = Battle.firstHealthy(self.party)
|
local nextIndex = Battle.firstHealthy(self.party)
|
||||||
if not nextIndex then
|
if not nextIndex then
|
||||||
self:emit({ kind = "message", text = "You have no more POKéMON!" })
|
self:emit({ kind = "message", text = "You have no more POKéMON!" })
|
||||||
|
-- LostBattle (engine/battle/core.asm:2763-2782): only BATTLETYPE_CANLOSE
|
||||||
|
-- reaches PrintWinLossText on a loss; every other loss whites out.
|
||||||
|
if self.battleType == Battle.BATTLETYPE_CANLOSE then
|
||||||
|
self:printWinLossText("lose")
|
||||||
|
end
|
||||||
self:endBattle("lose")
|
self:endBattle("lose")
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
@@ -3177,14 +3194,23 @@ function Battle:resolveFaints()
|
|||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
-- WinTrainerBattle's money arm, which runs after BattleText_EnemyWasDefeated
|
-- WinTrainerBattle (engine/battle/core.asm:2310-2323), LostBattle's .canlose
|
||||||
-- and the frontpic slide: the four quarters are dealt between the wallet and
|
-- arm (:2769-2782), PrintWinLossText (home/trainers.asm:230)
|
||||||
-- Mom's savings and then one StdBattleTextbox names the figure.
|
function Battle:printWinLossText(result)
|
||||||
--
|
local trainer = self.trainer
|
||||||
-- The `ld a, [wDebugFlags] / bit DEBUG_BATTLE_F` skip in front of
|
if not trainer then return end
|
||||||
-- PrintWinLossText is the trainer's own after-battle line, which this port
|
-- The DEBUG_BATTLE_F skip sits in front of PrintWinLossText alone, behind
|
||||||
-- runs from the script on the way out of the battle rather than from here.
|
-- the slide (engine/battle/core.asm:2310, :2320-2323).
|
||||||
-- The payout is not gated on it either way.
|
-- The CANLOSE loss arm runs ClearBox first (:2770-2773).
|
||||||
|
self:emit({ kind = "trainer-return", cleared = result == "lose" or nil })
|
||||||
|
local text = (result == "lose") and trainer.lossText or trainer.winText
|
||||||
|
if type(text) ~= "string" or text == "" then return end
|
||||||
|
-- FarPrintText prints the pointer alone: no trainer-name tag in front of
|
||||||
|
-- it, unlike Gen 1's TrainerEndBattleText (pokered home/trainers.asm:355).
|
||||||
|
self:emit({ kind = "win-text", text = text })
|
||||||
|
end
|
||||||
|
|
||||||
|
-- WinTrainerBattle's money arm (engine/battle/core.asm:2310-2323)
|
||||||
function Battle:awardPrizeMoney()
|
function Battle:awardPrizeMoney()
|
||||||
local save = self.save
|
local save = self.save
|
||||||
if not (save and save.player) then return nil end
|
if not (save and save.player) then return nil end
|
||||||
@@ -3505,6 +3531,8 @@ function Battle:switch(index)
|
|||||||
self.participants[index] = true
|
self.participants[index] = true
|
||||||
self.stages.player = Battle.newStages()
|
self.stages.player = Battle.newStages()
|
||||||
self:emit({ kind = "send", side = "player", mon = mon,
|
self:emit({ kind = "send", side = "player", mon = mon,
|
||||||
|
hp = mon.hp or 0, status = mon.status or false,
|
||||||
|
level = mon.level, experience = mon.experience,
|
||||||
text = "Go! " .. self:monName(mon) .. "!" })
|
text = "Go! " .. self:monName(mon) .. "!" })
|
||||||
-- battle.battler_switched, the payload BattleState:resolveSwitch emits on
|
-- battle.battler_switched, the payload BattleState:resolveSwitch emits on
|
||||||
-- Gen 1: the side record, whoever walked in, and whoever walked out.
|
-- Gen 1: the side record, whoever walked in, and whoever walked out.
|
||||||
@@ -3969,6 +3997,8 @@ function Battle:enemyTrySwitchOrItem()
|
|||||||
self:clearVolatile(self.enemy)
|
self:clearVolatile(self.enemy)
|
||||||
self.stages.enemy = Battle.newStages()
|
self.stages.enemy = Battle.newStages()
|
||||||
self:emit({ kind = "send", side = "enemy", mon = self.enemy,
|
self:emit({ kind = "send", side = "enemy", mon = self.enemy,
|
||||||
|
hp = self.enemy.hp or 0, status = self.enemy.status or false,
|
||||||
|
level = self.enemy.level, experience = self.enemy.experience,
|
||||||
text = (self.trainer.name or "TRAINER") .. " sent out "
|
text = (self.trainer.name or "TRAINER") .. " sent out "
|
||||||
.. self:monName(self.enemy) .. "!" })
|
.. self:monName(self.enemy) .. "!" })
|
||||||
Runtime.emit("battle.battler_switched", {
|
Runtime.emit("battle.battler_switched", {
|
||||||
|
|||||||
@@ -116,11 +116,13 @@ function DeltaSkin.pickAsset(assets, opts, pdfFiles)
|
|||||||
if type(assets) ~= "table" then return nil end
|
if type(assets) ~= "table" then return nil end
|
||||||
pdfFiles = pdfFiles or {}
|
pdfFiles = pdfFiles or {}
|
||||||
local raster = {}
|
local raster = {}
|
||||||
|
local pdfName
|
||||||
for _, key in ipairs(DeltaSkin.ASSET_LADDER) do
|
for _, key in ipairs(DeltaSkin.ASSET_LADDER) do
|
||||||
local name = pick(assets, key)
|
local name = pick(assets, key)
|
||||||
if key == "medium" and type(name) ~= "string" then name = pick(assets, "normal") end
|
if key == "medium" and type(name) ~= "string" then name = pick(assets, "normal") end
|
||||||
if type(name) == "string" and name ~= "" then
|
if type(name) == "string" and name ~= "" then
|
||||||
if name:lower():match("%.pdf$") then
|
if name:lower():match("%.pdf$") then
|
||||||
|
pdfName = name
|
||||||
pdfFiles[#pdfFiles + 1] = name
|
pdfFiles[#pdfFiles + 1] = name
|
||||||
else
|
else
|
||||||
raster[#raster + 1] = { key = key, name = name }
|
raster[#raster + 1] = { key = key, name = name }
|
||||||
@@ -130,12 +132,14 @@ function DeltaSkin.pickAsset(assets, opts, pdfFiles)
|
|||||||
local resizable = pick(assets, "resizable")
|
local resizable = pick(assets, "resizable")
|
||||||
if type(resizable) == "string" and resizable ~= "" then
|
if type(resizable) == "string" and resizable ~= "" then
|
||||||
if resizable:lower():match("%.pdf$") then
|
if resizable:lower():match("%.pdf$") then
|
||||||
|
pdfName = resizable
|
||||||
pdfFiles[#pdfFiles + 1] = resizable
|
pdfFiles[#pdfFiles + 1] = resizable
|
||||||
else
|
else
|
||||||
raster[#raster + 1] = { key = "large", name = resizable }
|
raster[#raster + 1] = { key = "large", name = resizable }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
if #raster == 0 then return nil end
|
local pdfPath = pdfName and DeltaSkin.resolveName(pdfName, opts) or nil
|
||||||
|
if #raster == 0 then return nil, pdfPath end
|
||||||
|
|
||||||
local target = numOr(opts and opts.targetWidth, DeltaSkin.DEFAULT_TARGET_WIDTH)
|
local target = numOr(opts and opts.targetWidth, DeltaSkin.DEFAULT_TARGET_WIDTH)
|
||||||
local chosen
|
local chosen
|
||||||
@@ -145,7 +149,7 @@ function DeltaSkin.pickAsset(assets, opts, pdfFiles)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
if not chosen then chosen = raster[#raster].name end
|
if not chosen then chosen = raster[#raster].name end
|
||||||
return DeltaSkin.resolveName(chosen, opts)
|
return DeltaSkin.resolveName(chosen, opts), nil
|
||||||
end
|
end
|
||||||
|
|
||||||
function DeltaSkin.mergeEdges(base, item)
|
function DeltaSkin.mergeEdges(base, item)
|
||||||
@@ -288,10 +292,12 @@ function DeltaSkin.buildPage(obj, orient, opts, warnings, pdfFiles)
|
|||||||
addWarning(warnings, orient .. " has no mappingSize; assuming 320x240")
|
addWarning(warnings, orient .. " has no mappingSize; assuming 320x240")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local imagePath, pdfPath = DeltaSkin.pickAsset(pick(obj, "assets"), opts, pdfFiles)
|
||||||
local page = {
|
local page = {
|
||||||
name = orient,
|
name = orient,
|
||||||
orient = orient,
|
orient = orient,
|
||||||
imagePath = DeltaSkin.pickAsset(pick(obj, "assets"), opts, pdfFiles),
|
imagePath = imagePath,
|
||||||
|
pdfPath = pdfPath,
|
||||||
fullScreen = true,
|
fullScreen = true,
|
||||||
normalized = true,
|
normalized = true,
|
||||||
pixelCoords = false,
|
pixelCoords = false,
|
||||||
@@ -309,6 +315,14 @@ function DeltaSkin.buildPage(obj, orient, opts, warnings, pdfFiles)
|
|||||||
if screen then
|
if screen then
|
||||||
page.viewport = screen
|
page.viewport = screen
|
||||||
page.viewportFill = false
|
page.viewportFill = false
|
||||||
|
else
|
||||||
|
-- mappingSize is the overlay, not the device. Portrait controller
|
||||||
|
-- skins (GBA4iOS-era 320x240 decks, this Pikachu skin, etc.) keep
|
||||||
|
-- that aspect, sit at the bottom, and leave the leftover for the
|
||||||
|
-- Game Boy picture. A screens/gameScreenFrame rect still fills.
|
||||||
|
page.aspectFromCfg = true
|
||||||
|
page.screenFit = "remainder"
|
||||||
|
if orient == "portrait" then page.anchor = "bottom" end
|
||||||
end
|
end
|
||||||
|
|
||||||
local baseEdges = pick(obj, "extendedEdges")
|
local baseEdges = pick(obj, "extendedEdges")
|
||||||
@@ -368,9 +382,6 @@ function DeltaSkin.parse(text, opts)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
if #pages == 0 then return nil, "info.json has no usable representation" end
|
if #pages == 0 then return nil, "info.json has no usable representation" end
|
||||||
if #pdfFiles > 0 then
|
|
||||||
addWarning(warnings, "PDF artwork cannot be imported yet")
|
|
||||||
end
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pages = pages,
|
pages = pages,
|
||||||
@@ -390,6 +401,10 @@ function DeltaSkin.needsConversion(skin)
|
|||||||
local files = skin.pdfFiles
|
local files = skin.pdfFiles
|
||||||
if type(files) ~= "table" or #files == 0 then return nil end
|
if type(files) ~= "table" or #files == 0 then return nil end
|
||||||
for _, page in ipairs(skin.pages or {}) do
|
for _, page in ipairs(skin.pages or {}) do
|
||||||
|
-- A raster asset, or a JPEG recovered from the PDF at load, means the
|
||||||
|
-- skin can draw. Parse-only callers still see pdfOnly because they
|
||||||
|
-- have not run extract yet.
|
||||||
|
if page.rasterData then return nil end
|
||||||
if page.imagePath then return nil end
|
if page.imagePath then return nil end
|
||||||
end
|
end
|
||||||
return { pdfOnly = true, files = files }
|
return { pdfOnly = true, files = files }
|
||||||
|
|||||||
@@ -326,6 +326,9 @@ function Game:logicSpeed()
|
|||||||
if self.linkSession or (self.linkNet and not self.linkNet.closed) then
|
if self.linkSession or (self.linkNet and not self.linkNet.closed) then
|
||||||
return 1
|
return 1
|
||||||
end
|
end
|
||||||
|
if Game.isFixedSpeedInStack and Game.isFixedSpeedInStack(self.stack) then
|
||||||
|
return 1
|
||||||
|
end
|
||||||
if self.speedOverride then return GameSpeed.clamp(self.speedOverride) end
|
if self.speedOverride then return GameSpeed.clamp(self.speedOverride) end
|
||||||
-- Clamp here too, not just in _resolveLogicSpeed's vanilla path: a mod's
|
-- Clamp here too, not just in _resolveLogicSpeed's vanilla path: a mod's
|
||||||
-- core.logic_speed hook can return anything (0, negative, nil, NaN) and
|
-- core.logic_speed hook can return anything (0, negative, nil, NaN) and
|
||||||
@@ -475,6 +478,15 @@ function Game.speedCategoryInStack(stack)
|
|||||||
return "menu"
|
return "menu"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function Game.isFixedSpeedInStack(stack)
|
||||||
|
local states = stack and stack.states
|
||||||
|
for i = #(states or {}), 1, -1 do
|
||||||
|
local state = states[i]
|
||||||
|
if state and (state.isFixedSpeed or state.isMinigame) then return true end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
-- Whether a state on the stack composes its own screen and so wants the
|
-- Whether a state on the stack composes its own screen and so wants the
|
||||||
-- edge anchors held off (BattleState.holdsUIAnchors). Whole-stack, like
|
-- edge anchors held off (BattleState.holdsUIAnchors). Whole-stack, like
|
||||||
-- everything else here: the text box and YES/NO a battle puts up are states
|
-- everything else here: the text box and YES/NO a battle puts up are states
|
||||||
@@ -597,9 +609,10 @@ function Game:draw()
|
|||||||
-- ...and for the same reason the UI's own scale has to know the world is
|
-- ...and for the same reason the UI's own scale has to know the world is
|
||||||
-- still the backdrop while an opaque menu covers it. Renderer:uiScale
|
-- still the backdrop while an opaque menu covers it. Renderer:uiScale
|
||||||
-- steps the UI down with the survey zoom only while a world is behind it,
|
-- steps the UI down with the survey zoom only while a world is behind it,
|
||||||
-- gated on this frame's world pass -- which the party menu and the bag end
|
-- gated on this frame's world pass -- which the party menu ends by being
|
||||||
-- by being opaque. Without this hold they lose the step-down and blit at
|
-- opaque (the bag's item box shows the map around it, #1521). Without
|
||||||
-- full fit scale over a battle drawn at the zoomed-out one.
|
-- this hold it loses the step-down and blits at full fit scale over a
|
||||||
|
-- battle drawn at the zoomed-out one.
|
||||||
Renderer.uiWorldHold = Renderer.battleDim ~= nil
|
Renderer.uiWorldHold = Renderer.battleDim ~= nil
|
||||||
-- ...and a battle keeps its dialogue box and YES/NO inside its own screen
|
-- ...and a battle keeps its dialogue box and YES/NO inside its own screen
|
||||||
-- instead of letting them dock to the window edge.
|
-- instead of letting them dock to the window edge.
|
||||||
@@ -970,7 +983,11 @@ end
|
|||||||
-- parked the player until every direction was re-pressed (#799).
|
-- parked the player until every direction was re-pressed (#799).
|
||||||
function Game:focus(f)
|
function Game:focus(f)
|
||||||
Input:reset()
|
Input:reset()
|
||||||
if f then Input:reconcile() end
|
if f then
|
||||||
|
Input:reconcile()
|
||||||
|
local eng = self:syncEngine()
|
||||||
|
if eng then pcall(eng.noteResumed, eng) end
|
||||||
|
end
|
||||||
TouchControls:reset()
|
TouchControls:reset()
|
||||||
self:cancelPointers()
|
self:cancelPointers()
|
||||||
end
|
end
|
||||||
@@ -990,6 +1007,8 @@ function Game:onResume()
|
|||||||
Input:reconcile()
|
Input:reconcile()
|
||||||
TouchControls:reset()
|
TouchControls:reset()
|
||||||
self:cancelPointers()
|
self:cancelPointers()
|
||||||
|
local eng = self:syncEngine()
|
||||||
|
if eng then pcall(eng.noteResumed, eng) end
|
||||||
-- Chip music may survive NX suspend as a duplicate stream; stop it and let
|
-- Chip music may survive NX suspend as a duplicate stream; stop it and let
|
||||||
-- the active screen re-cue on the next frame (hardware audio check: T19).
|
-- the active screen re-cue on the next frame (hardware audio check: T19).
|
||||||
-- Desktop/mobile window-visible flips must not kill overworld music.
|
-- Desktop/mobile window-visible flips must not kill overworld music.
|
||||||
@@ -1197,18 +1216,26 @@ end
|
|||||||
|
|
||||||
function Game:syncEngine()
|
function Game:syncEngine()
|
||||||
if self._syncOff then return nil end
|
if self._syncOff then return nil end
|
||||||
if self._syncEngineRef then return self._syncEngineRef end
|
local eng = self._syncEngineRef
|
||||||
|
if not eng then
|
||||||
local ok, SyncEngine = pcall(require, "src.sync.SyncEngine")
|
local ok, SyncEngine = pcall(require, "src.sync.SyncEngine")
|
||||||
if not ok or type(SyncEngine) ~= "table" then
|
if not ok or type(SyncEngine) ~= "table" then
|
||||||
self._syncOff = true
|
self._syncOff = true
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
local eng = SyncEngine.shared()
|
eng = SyncEngine.shared()
|
||||||
if not eng then
|
if not eng then
|
||||||
self._syncOff = true
|
self._syncOff = true
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
self._syncEngineRef = eng
|
self._syncEngineRef = eng
|
||||||
|
end
|
||||||
|
if type(eng.protectPlaythrough) == "function" then
|
||||||
|
local meta = self.save and self.save.meta
|
||||||
|
eng:protectPlaythrough(
|
||||||
|
(self.save and self.save.version) or require("src.core.GameVersion").get(),
|
||||||
|
type(meta) == "table" and meta.playthroughId or nil)
|
||||||
|
end
|
||||||
return eng
|
return eng
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -1248,6 +1275,7 @@ function Game:applyOptions(opts)
|
|||||||
-- after VideoMode: a faithful-resolution lock is an exact window size, so
|
-- after VideoMode: a faithful-resolution lock is an exact window size, so
|
||||||
-- it has to be the last word on the window (it drops fullscreen to hold)
|
-- it has to be the last word on the window (it drops fullscreen to hold)
|
||||||
require("src.core.FaithfulRes").applyOptions(opts)
|
require("src.core.FaithfulRes").applyOptions(opts)
|
||||||
|
require("src.core.ScreenPosition").applyOptions(opts)
|
||||||
-- normalizes a nil/garbage cap to the 60 default, so old saves with no
|
-- normalizes a nil/garbage cap to the 60 default, so old saves with no
|
||||||
-- fpsCap key pace at the standard rate (issue #88)
|
-- fpsCap key pace at the standard rate (issue #88)
|
||||||
require("src.core.FrameCap").applyOptions(opts)
|
require("src.core.FrameCap").applyOptions(opts)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
-- everything under src/*/gen2 reaches shared services through here. Gen 1
|
-- everything under src/*/gen2 reaches shared services through here. Gen 1
|
||||||
-- Game:load cannot consume a Gen 2 cache -- different generated tables, save
|
-- Game:load cannot consume a Gen 2 cache -- different generated tables, save
|
||||||
-- shape and screen registry -- so main.lua's bootGame picks this owner when
|
-- shape and screen registry -- so main.lua's bootGame picks this owner when
|
||||||
-- GameVersion.isGold(), and the two never branch into each other.
|
-- GameVersion.generation() == 2, and the two never branch into each other.
|
||||||
--
|
--
|
||||||
-- Boot: copyright → GameFreak Presents → GS intro stub → title
|
-- Boot: copyright → GameFreak Presents → GS intro stub → title
|
||||||
-- (tilemap + Ho-Oh flap / clouds / trails) → Oak speech (Marill + shrink)
|
-- (tilemap + Ho-Oh flap / clouds / trails) → Oak speech (Marill + shrink)
|
||||||
@@ -1660,8 +1660,9 @@ function Game2:drawScene(w, h)
|
|||||||
-- row to opt into the step-down half, so CENTERED is the whole rule
|
-- row to opt into the step-down half, so CENTERED is the whole rule
|
||||||
-- here.
|
-- here.
|
||||||
local s = self.world:fitScale()
|
local s = self.world:fitScale()
|
||||||
|
local ox, oy = Chrome.fitOrigin(w, h, s)
|
||||||
G.push()
|
G.push()
|
||||||
G.translate(math.floor((w - 160 * s) / 2), math.floor((h - 144 * s) / 2))
|
G.translate(ox, oy)
|
||||||
G.scale(s, s)
|
G.scale(s, s)
|
||||||
self.stack:draw()
|
self.stack:draw()
|
||||||
G.pop()
|
G.pop()
|
||||||
@@ -1702,7 +1703,7 @@ function Game2:hotkey(key)
|
|||||||
self:writeSave()
|
self:writeSave()
|
||||||
return true
|
return true
|
||||||
elseif key == "f2" then
|
elseif key == "f2" then
|
||||||
local loaded = Save.load("gold")
|
local loaded = Save.load()
|
||||||
if loaded then self:continueGame(loaded) end
|
if loaded then self:continueGame(loaded) end
|
||||||
return true
|
return true
|
||||||
elseif key == "1" then
|
elseif key == "1" then
|
||||||
@@ -1982,6 +1983,7 @@ function Game2:applyOptions()
|
|||||||
haptics = options.haptics,
|
haptics = options.haptics,
|
||||||
})
|
})
|
||||||
require("src.core.VideoMode").applyOptions(options)
|
require("src.core.VideoMode").applyOptions(options)
|
||||||
|
require("src.core.ScreenPosition").applyOptions(options)
|
||||||
require("src.core.FrameCap").applyOptions(options)
|
require("src.core.FrameCap").applyOptions(options)
|
||||||
require("src.world.gen2.BorderFill").applyOptions(options)
|
require("src.world.gen2.BorderFill").applyOptions(options)
|
||||||
local GBCFX = require("src.render.GBCFX")
|
local GBCFX = require("src.render.GBCFX")
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
-- Which game this process is running: Red (the historical default), Blue,
|
-- Which game this process is running: Red (the historical default), Blue,
|
||||||
-- Yellow, or Gold. One source of truth for everything that differs by
|
-- Yellow, Gold, or Silver. One source of truth for everything that differs by
|
||||||
-- version -- the accepted ROM hash, the import manifest, where the
|
-- version -- the accepted ROM hash, the import manifest, where the
|
||||||
-- extracted cache lives, and the save-file suffix -- so the importer,
|
-- extracted cache lives, and the save-file suffix -- so the importer,
|
||||||
-- cache mount, SaveData, title screen and palette all agree.
|
-- cache mount, SaveData, title screen and palette all agree.
|
||||||
@@ -8,7 +8,8 @@
|
|||||||
-- saves are untouched, but its extracted cache lives under red/ like Blue,
|
-- saves are untouched, but its extracted cache lives under red/ like Blue,
|
||||||
-- Yellow, and Gold (issue #899); a legacy root cache is moved into red/ once
|
-- Yellow, and Gold (issue #899); a legacy root cache is moved into red/ once
|
||||||
-- by CacheFs.migrateLegacyRedCache. All supported versions can be imported
|
-- by CacheFs.migrateLegacyRedCache. All supported versions can be imported
|
||||||
-- and selected side by side. Gold is Gen 2 (see docs/gold-phase1.md).
|
-- and selected side by side. Gold and Silver are Gen 2 (see
|
||||||
|
-- docs/gold-phase1.md).
|
||||||
--
|
--
|
||||||
-- Zero requires, so it loads during love.conf and under plain Lua for tools
|
-- Zero requires, so it loads during love.conf and under plain Lua for tools
|
||||||
-- and tests. The active version is a process-global set once at boot from
|
-- and tests. The active version is a process-global set once at boot from
|
||||||
@@ -61,13 +62,26 @@ GameVersion.VERSIONS = {
|
|||||||
manifest = "tools/rom_manifest_gold.json",
|
manifest = "tools/rom_manifest_gold.json",
|
||||||
cachePrefix = "gold/", -- gold/data/generated, gold/assets/generated
|
cachePrefix = "gold/", -- gold/data/generated, gold/assets/generated
|
||||||
saveSuffix = "_gold", -- save_gold.lua / .bak / .tmp
|
saveSuffix = "_gold", -- save_gold.lua / .bak / .tmp
|
||||||
-- The only row that carries one; absent reads as 1 (GameVersion.generation)
|
-- Absent reads as 1 (GameVersion.generation)
|
||||||
|
generation = 2,
|
||||||
|
},
|
||||||
|
-- Gold's engine with edition-selected data; the manifest is derived from
|
||||||
|
-- Gold's by tools/make_silver_manifest.py.
|
||||||
|
silver = {
|
||||||
|
id = "silver",
|
||||||
|
label = "Silver",
|
||||||
|
displayName = "Pokemon Silver",
|
||||||
|
launcherName = "Silver (Beta)",
|
||||||
|
sha1 = "49b163f7e57702bc939d642a18f591de55d92dae",
|
||||||
|
manifest = "tools/rom_manifest_silver.json",
|
||||||
|
cachePrefix = "silver/", -- silver/data/generated, silver/assets/generated
|
||||||
|
saveSuffix = "_silver", -- save_silver.lua / .bak / .tmp
|
||||||
generation = 2,
|
generation = 2,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
-- Launcher column order.
|
-- Launcher column order.
|
||||||
GameVersion.ORDER = { "red", "blue", "yellow", "gold" }
|
GameVersion.ORDER = { "red", "blue", "yellow", "gold", "silver" }
|
||||||
|
|
||||||
GameVersion.current = "red"
|
GameVersion.current = "red"
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,49 @@ local IssueReport = {}
|
|||||||
local FORM_URL = "https://github.com/bryanthaboi/gen1recomp/issues/new"
|
local FORM_URL = "https://github.com/bryanthaboi/gen1recomp/issues/new"
|
||||||
local TEMPLATE = "bug_report.yml"
|
local TEMPLATE = "bug_report.yml"
|
||||||
|
|
||||||
|
local APPLE_MODELS = {
|
||||||
|
["iPhone14,2"] = "iPhone 13 Pro",
|
||||||
|
["iPhone14,3"] = "iPhone 13 Pro Max",
|
||||||
|
["iPhone14,4"] = "iPhone 13 mini",
|
||||||
|
["iPhone14,5"] = "iPhone 13",
|
||||||
|
["iPhone14,7"] = "iPhone 14",
|
||||||
|
["iPhone14,8"] = "iPhone 14 Plus",
|
||||||
|
["iPhone15,2"] = "iPhone 14 Pro",
|
||||||
|
["iPhone15,3"] = "iPhone 14 Pro Max",
|
||||||
|
["iPhone15,4"] = "iPhone 15",
|
||||||
|
["iPhone15,5"] = "iPhone 15 Plus",
|
||||||
|
["iPhone16,1"] = "iPhone 15 Pro",
|
||||||
|
["iPhone16,2"] = "iPhone 15 Pro Max",
|
||||||
|
["iPhone17,1"] = "iPhone 16 Pro",
|
||||||
|
["iPhone17,2"] = "iPhone 16 Pro Max",
|
||||||
|
["iPhone17,3"] = "iPhone 16",
|
||||||
|
["iPhone17,4"] = "iPhone 16 Plus",
|
||||||
|
["iPhone17,5"] = "iPhone 16e",
|
||||||
|
["Mac14,2"] = "MacBook Air (13-inch, M2)",
|
||||||
|
["Mac14,3"] = "Mac mini (M2)",
|
||||||
|
["Mac14,5"] = "MacBook Pro (14-inch, M2 Max)",
|
||||||
|
["Mac14,6"] = "MacBook Pro (16-inch, M2 Max)",
|
||||||
|
["Mac14,7"] = "MacBook Pro (13-inch, M2)",
|
||||||
|
["Mac14,9"] = "MacBook Pro (14-inch, M2 Pro)",
|
||||||
|
["Mac14,10"] = "MacBook Pro (16-inch, M2 Pro)",
|
||||||
|
["Mac14,12"] = "Mac mini (M2 Pro)",
|
||||||
|
["Mac14,13"] = "Mac Studio (M2 Max)",
|
||||||
|
["Mac14,14"] = "Mac Studio (M2 Ultra)",
|
||||||
|
["Mac14,15"] = "MacBook Air (15-inch, M2)",
|
||||||
|
["Mac15,3"] = "MacBook Pro (14-inch, M3)",
|
||||||
|
["Mac15,6"] = "MacBook Pro (14-inch, M3 Pro)",
|
||||||
|
["Mac15,7"] = "MacBook Pro (16-inch, M3 Pro)",
|
||||||
|
["Mac15,12"] = "MacBook Air (13-inch, M3)",
|
||||||
|
["Mac15,13"] = "MacBook Air (15-inch, M3)",
|
||||||
|
["Mac16,1"] = "MacBook Pro (14-inch, M4)",
|
||||||
|
["Mac16,5"] = "MacBook Pro (16-inch, M4 Max)",
|
||||||
|
["Mac16,6"] = "MacBook Pro (14-inch, M4 Max)",
|
||||||
|
["Mac16,7"] = "MacBook Pro (16-inch, M4 Pro)",
|
||||||
|
["Mac16,8"] = "MacBook Pro (14-inch, M4 Pro)",
|
||||||
|
["Mac16,10"] = "Mac mini (M4)",
|
||||||
|
["Mac16,11"] = "Mac mini (M4 Pro)",
|
||||||
|
}
|
||||||
|
|
||||||
local function clean(value)
|
local function clean(value)
|
||||||
if value == nil then return nil end
|
if value == nil then return nil end
|
||||||
local text = tostring(value):gsub("^%s+", ""):gsub("%s+$", "")
|
local text = tostring(value):gsub("^%s+", ""):gsub("%s+$", "")
|
||||||
@@ -36,6 +79,16 @@ local function commandValue(command)
|
|||||||
return clean(value)
|
return clean(value)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function commandText(command)
|
||||||
|
if not io or type(io.popen) ~= "function" then return nil end
|
||||||
|
local ok, pipe = pcall(io.popen, command, "r")
|
||||||
|
if not ok or not pipe then return nil end
|
||||||
|
local readOK, value = pcall(pipe.read, pipe, "*a")
|
||||||
|
pcall(pipe.close, pipe)
|
||||||
|
if not readOK then return nil end
|
||||||
|
return clean(value)
|
||||||
|
end
|
||||||
|
|
||||||
local function percentEncode(value)
|
local function percentEncode(value)
|
||||||
local text = tostring(value or "")
|
local text = tostring(value or "")
|
||||||
return (text:gsub("([^%w%-_%.~])", function(char)
|
return (text:gsub("([^%w%-_%.~])", function(char)
|
||||||
@@ -66,6 +119,25 @@ local function loveVersion()
|
|||||||
return result
|
return result
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function friendlyModel(identifier)
|
||||||
|
identifier = clean(identifier)
|
||||||
|
if not identifier then return nil end
|
||||||
|
return APPLE_MODELS[identifier] or identifier
|
||||||
|
end
|
||||||
|
|
||||||
|
local function macModel()
|
||||||
|
local details = commandText("system_profiler SPHardwareDataType 2>/dev/null")
|
||||||
|
if details then
|
||||||
|
local name = clean(details:match("Model Name:%s*([^\r\n]+)"))
|
||||||
|
local chip = clean(details:match("Chip:%s*([^\r\n]+)"))
|
||||||
|
if name and chip and not name:find(chip, 1, true) then
|
||||||
|
return name .. " (" .. chip .. ")"
|
||||||
|
end
|
||||||
|
if name then return name end
|
||||||
|
end
|
||||||
|
return friendlyModel(commandValue("sysctl -n hw.model 2>/dev/null"))
|
||||||
|
end
|
||||||
|
|
||||||
local function appVersion()
|
local function appVersion()
|
||||||
local version = clean(Version.engine)
|
local version = clean(Version.engine)
|
||||||
if not version or version == "0.0.0" or version == "0.0.0-dev" then return "" end
|
if not version or version == "0.0.0" or version == "0.0.0-dev" then return "" end
|
||||||
@@ -73,20 +145,25 @@ local function appVersion()
|
|||||||
end
|
end
|
||||||
|
|
||||||
local function deviceModel(rawOS, system)
|
local function deviceModel(rawOS, system)
|
||||||
local model = clean(call(system.getModel))
|
local nativeModel = clean(call(system.getDeviceModel))
|
||||||
if model then return model end
|
if nativeModel then return friendlyModel(nativeModel) end
|
||||||
if rawOS == "OS X" or rawOS == "macOS" then
|
if rawOS == "OS X" or rawOS == "macOS" then
|
||||||
return commandValue("sysctl -n hw.model 2>/dev/null")
|
return macModel()
|
||||||
|
end
|
||||||
|
local model = clean(call(system.getModel))
|
||||||
|
if model and not model:lower():find("gpu", 1, true)
|
||||||
|
and not model:lower():find("renderer", 1, true) then
|
||||||
|
return friendlyModel(model)
|
||||||
end
|
end
|
||||||
if rawOS == "Windows" then
|
if rawOS == "Windows" then
|
||||||
return commandValue("powershell.exe -NoProfile -NonInteractive -Command \"(Get-CimInstance Win32_ComputerSystem).Model\" 2>NUL")
|
return friendlyModel(commandValue("powershell.exe -NoProfile -NonInteractive -Command \"(Get-CimInstance Win32_ComputerSystem).Model\" 2>NUL"))
|
||||||
end
|
end
|
||||||
if rawOS == "Linux" then
|
if rawOS == "Linux" then
|
||||||
return commandValue("cat /sys/devices/virtual/dmi/id/product_name 2>/dev/null")
|
return friendlyModel(commandValue("cat /sys/devices/virtual/dmi/id/product_name 2>/dev/null")
|
||||||
or commandValue("cat /sys/devices/virtual/dmi/id/model 2>/dev/null")
|
or commandValue("cat /sys/devices/virtual/dmi/id/model 2>/dev/null"))
|
||||||
end
|
end
|
||||||
if rawOS == "Android" then
|
if rawOS == "Android" then
|
||||||
return commandValue("getprop ro.product.model 2>/dev/null")
|
return friendlyModel(commandValue("getprop ro.product.model 2>/dev/null"))
|
||||||
end
|
end
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
@@ -121,7 +198,7 @@ local function metadata(options, context)
|
|||||||
local window = love and love.window or {}
|
local window = love and love.window or {}
|
||||||
local rawOS = clean(call(system.getOS))
|
local rawOS = clean(call(system.getOS))
|
||||||
local model = deviceModel(rawOS, system)
|
local model = deviceModel(rawOS, system)
|
||||||
local renderer, rendererVersion, _, rendererDevice = call(graphics.getRendererInfo)
|
local renderer, rendererVersion = call(graphics.getRendererInfo)
|
||||||
local width, height = call(graphics.getDimensions)
|
local width, height = call(graphics.getDimensions)
|
||||||
local pixelWidth, pixelHeight = call(graphics.getPixelDimensions)
|
local pixelWidth, pixelHeight = call(graphics.getPixelDimensions)
|
||||||
local modeWidth, modeHeight, flags = call(window.getMode)
|
local modeWidth, modeHeight, flags = call(window.getMode)
|
||||||
@@ -134,11 +211,7 @@ local function metadata(options, context)
|
|||||||
if value then lines[#lines + 1] = "- " .. label .. ": " .. value end
|
if value then lines[#lines + 1] = "- " .. label .. ": " .. value end
|
||||||
end
|
end
|
||||||
add("Platform", formOS(rawOS))
|
add("Platform", formOS(rawOS))
|
||||||
local hardware = model
|
add("Device", model)
|
||||||
if rendererDevice and rendererDevice ~= model then
|
|
||||||
hardware = hardware and (hardware .. " (" .. rendererDevice .. ")") or rendererDevice
|
|
||||||
end
|
|
||||||
add("Device", hardware)
|
|
||||||
local rendererDetails = clean(renderer)
|
local rendererDetails = clean(renderer)
|
||||||
if rendererDetails and clean(rendererVersion) then
|
if rendererDetails and clean(rendererVersion) then
|
||||||
rendererDetails = rendererDetails .. " " .. clean(rendererVersion)
|
rendererDetails = rendererDetails .. " " .. clean(rendererVersion)
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ local function normalizeVersion(v)
|
|||||||
b = "blue", blue = "blue",
|
b = "blue", blue = "blue",
|
||||||
y = "yellow", yellow = "yellow",
|
y = "yellow", yellow = "yellow",
|
||||||
g = "gold", gold = "gold",
|
g = "gold", gold = "gold",
|
||||||
|
s = "silver", silver = "silver",
|
||||||
}
|
}
|
||||||
v = alias[v] or v
|
v = alias[v] or v
|
||||||
if GameVersion.VERSIONS and not GameVersion.VERSIONS[v] then return nil end
|
if GameVersion.VERSIONS and not GameVersion.VERSIONS[v] then return nil end
|
||||||
|
|||||||
@@ -230,7 +230,7 @@ end
|
|||||||
|
|
||||||
function Music.play(data, song, loop, ctx)
|
function Music.play(data, song, loop, ctx)
|
||||||
if not song then return end
|
if not song then return end
|
||||||
if not love.audio then return end -- headless test stub
|
if not (love and love.audio) then return end -- headless test stub
|
||||||
ctx = ctx or {}
|
ctx = ctx or {}
|
||||||
song = selectSong(song, ctx)
|
song = selectSong(song, ctx)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
-- Recover a raster from a PDF that is really a wrapped JPEG. Delta skins
|
||||||
|
-- ship artwork that way so iOS can scale it; LOVE has no PDF renderer, so
|
||||||
|
-- import pulls the embedded image out instead of refusing the skin. True
|
||||||
|
-- vector PDFs (no Image XObject, no JPEG) still fail.
|
||||||
|
|
||||||
|
local PdfImage = {}
|
||||||
|
|
||||||
|
local function isPdf(bytes)
|
||||||
|
return type(bytes) == "string" and bytes:sub(1, 5) == "%PDF-"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- After the `stream` keyword the spec allows \n or \r\n before the bytes.
|
||||||
|
-- `endstream` also contains the letters "stream", so skip that match.
|
||||||
|
local function streamDataStart(bytes, from)
|
||||||
|
local s, e = bytes:find("stream", from, true)
|
||||||
|
while s do
|
||||||
|
if s == 1 or bytes:sub(s - 3, s - 1) ~= "end" then
|
||||||
|
local p = e + 1
|
||||||
|
if bytes:sub(p, p) == "\r" then p = p + 1 end
|
||||||
|
if bytes:sub(p, p) == "\n" then p = p + 1 end
|
||||||
|
return p, s
|
||||||
|
end
|
||||||
|
s, e = bytes:find("stream", e + 1, true)
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function dictWindow(bytes, imageAt)
|
||||||
|
local from = imageAt > 400 and (imageAt - 400) or 1
|
||||||
|
local to = math.min(#bytes, imageAt + 800)
|
||||||
|
return bytes:sub(from, to)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function dictNumber(window, key)
|
||||||
|
-- Prefer an indirect ref so `/Length 5 0 R` is not read as length 5.
|
||||||
|
if window:find("/" .. key .. "%s+%d+%s+%d+%s+R") then return nil end
|
||||||
|
return tonumber(window:match("/" .. key .. "%s+(%d+)"))
|
||||||
|
end
|
||||||
|
|
||||||
|
local function dictFilter(window)
|
||||||
|
local named = window:match("/Filter%s*/(%w+)")
|
||||||
|
if named then return named end
|
||||||
|
return window:match("/Filter%s*%[%s*/(%w+)")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function jpegIn(bytes, from, to)
|
||||||
|
if from < 1 then from = 1 end
|
||||||
|
if not to or to > #bytes then to = #bytes end
|
||||||
|
if to < from then return nil end
|
||||||
|
local region = bytes:sub(from, to)
|
||||||
|
local soi = region:find("\255\216\255", 1, true)
|
||||||
|
if not soi then return nil end
|
||||||
|
local eoi = region:find("\255\217", soi + 3, true)
|
||||||
|
if not eoi then return nil end
|
||||||
|
return region:sub(soi, eoi + 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function candidate(data, width, height, ext)
|
||||||
|
if not data or data == "" then return nil end
|
||||||
|
return {
|
||||||
|
data = data,
|
||||||
|
ext = ext or "jpg",
|
||||||
|
width = width or 0,
|
||||||
|
height = height or 0,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function bigger(a, b)
|
||||||
|
if not a then return b end
|
||||||
|
if not b then return a end
|
||||||
|
local as = (a.width or 0) * (a.height or 0)
|
||||||
|
local bs = (b.width or 0) * (b.height or 0)
|
||||||
|
if bs ~= as then return bs > as and b or a end
|
||||||
|
return #b.data > #a.data and b or a
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Walk Image XObjects and take the largest DCTDecode (JPEG) stream.
|
||||||
|
local function fromImageXObjects(bytes)
|
||||||
|
local best
|
||||||
|
local i = 1
|
||||||
|
while true do
|
||||||
|
local s, e = bytes:find("/Subtype%s*/Image", i)
|
||||||
|
if not s then break end
|
||||||
|
local window = dictWindow(bytes, s)
|
||||||
|
local filter = dictFilter(window)
|
||||||
|
local width = dictNumber(window, "Width")
|
||||||
|
local height = dictNumber(window, "Height")
|
||||||
|
local dataStart = streamDataStart(bytes, e)
|
||||||
|
i = e + 1
|
||||||
|
if dataStart and filter == "DCTDecode" then
|
||||||
|
local es = bytes:find("endstream", dataStart, true)
|
||||||
|
local jpeg = jpegIn(bytes, dataStart, es and (es - 1) or nil)
|
||||||
|
best = bigger(best, candidate(jpeg, width, height, "jpg"))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return best
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Image-to-PDF converters (3-Heights, Preview, etc.) leave a single JPEG
|
||||||
|
-- body even when /Length is an indirect object we do not resolve.
|
||||||
|
local function fromBareJpeg(bytes)
|
||||||
|
local jpeg = jpegIn(bytes, 1, #bytes)
|
||||||
|
if not jpeg then return nil end
|
||||||
|
return candidate(jpeg, 0, 0, "jpg")
|
||||||
|
end
|
||||||
|
|
||||||
|
function PdfImage.extract(bytes)
|
||||||
|
if not isPdf(bytes) then return nil, "not a pdf" end
|
||||||
|
local best = fromImageXObjects(bytes)
|
||||||
|
if not best then best = fromBareJpeg(bytes) end
|
||||||
|
if not best then return nil, "no extractable image" end
|
||||||
|
return best
|
||||||
|
end
|
||||||
|
|
||||||
|
return PdfImage
|
||||||
@@ -285,6 +285,7 @@ function SaveData.defaultOptions()
|
|||||||
-- lock the window to an exact 160x144 multiple, 1..4 (0 = OFF); see
|
-- lock the window to an exact 160x144 multiple, 1..4 (0 = OFF); see
|
||||||
-- src/core/FaithfulRes.lua. Ignored on mobile.
|
-- src/core/FaithfulRes.lua. Ignored on mobile.
|
||||||
faithfulRes = 0,
|
faithfulRes = 0,
|
||||||
|
screenPos = "center",
|
||||||
-- hard render frame-rate cap; render-only pacing (issue #88, FrameCap.lua)
|
-- hard render frame-rate cap; render-only pacing (issue #88, FrameCap.lua)
|
||||||
fpsCap = 60,
|
fpsCap = 60,
|
||||||
-- graphics performance tier: auto | high | balanced | low. "auto"
|
-- graphics performance tier: auto | high | balanced | low. "auto"
|
||||||
@@ -1279,14 +1280,27 @@ function SaveData.ensurePlaythroughId(save, injectedFs)
|
|||||||
local isFresh = save == freshPlaythrough
|
local isFresh = save == freshPlaythrough
|
||||||
if isFresh then freshPlaythrough = nil end
|
if isFresh then freshPlaythrough = nil end
|
||||||
local byVersion = opts.playthroughIds and opts.playthroughIds[version]
|
local byVersion = opts.playthroughIds and opts.playthroughIds[version]
|
||||||
id = not isFresh and byVersion and byVersion[scope] or nil
|
local existing = byVersion and byVersion[scope]
|
||||||
|
id = not isFresh and existing or nil
|
||||||
if type(id) ~= "string" or id == "" then
|
if type(id) ~= "string" or id == "" then
|
||||||
id = SaveData.newPlaythroughId()
|
id = SaveData.newPlaythroughId()
|
||||||
|
-- A fresh skeleton still gets its own id (two unsaved New Games sharing a
|
||||||
|
-- slot must stay distinct), and it is still persisted when the slot has no
|
||||||
|
-- binding yet -- that is the contract a tool relies on to resolve
|
||||||
|
-- `selected` at the title after a restart, before any normal SAVE.
|
||||||
|
--
|
||||||
|
-- What it must NOT do is OVERWRITE a binding that already exists. newGame()
|
||||||
|
-- marks a skeleton on the boot frame, before any save is loaded, and mods
|
||||||
|
-- initialise inside that window -- so a mod touching storage at init
|
||||||
|
-- replaced the real save's id with a throwaway, stranding that save's mod
|
||||||
|
-- storage and repeating on every launch.
|
||||||
|
if not (isFresh and type(existing) == "string" and existing ~= "") then
|
||||||
opts.playthroughIds = opts.playthroughIds or {}
|
opts.playthroughIds = opts.playthroughIds or {}
|
||||||
opts.playthroughIds[version] = opts.playthroughIds[version] or {}
|
opts.playthroughIds[version] = opts.playthroughIds[version] or {}
|
||||||
opts.playthroughIds[version][scope] = id
|
opts.playthroughIds[version][scope] = id
|
||||||
SaveData.saveOptions(opts, injectedFs)
|
SaveData.saveOptions(opts, injectedFs)
|
||||||
end
|
end
|
||||||
|
end
|
||||||
save.meta.playthroughId = id
|
save.meta.playthroughId = id
|
||||||
return id
|
return id
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
local ScreenPosition = {}
|
||||||
|
|
||||||
|
ScreenPosition.MODES = { "center", "upper", "top" }
|
||||||
|
ScreenPosition.DEFAULT = "center"
|
||||||
|
ScreenPosition.mode = ScreenPosition.DEFAULT
|
||||||
|
|
||||||
|
local LABELS = { center = "CENTER", upper = "UPPER", top = "TOP" }
|
||||||
|
|
||||||
|
function ScreenPosition.normalize(v)
|
||||||
|
if LABELS[v] then return v end
|
||||||
|
return ScreenPosition.DEFAULT
|
||||||
|
end
|
||||||
|
|
||||||
|
function ScreenPosition.label(v)
|
||||||
|
return LABELS[ScreenPosition.normalize(v)]
|
||||||
|
end
|
||||||
|
|
||||||
|
function ScreenPosition.cycle(v, dir)
|
||||||
|
v = ScreenPosition.normalize(v)
|
||||||
|
local modes = ScreenPosition.MODES
|
||||||
|
local cur = 1
|
||||||
|
for i, mode in ipairs(modes) do
|
||||||
|
if mode == v then cur = i break end
|
||||||
|
end
|
||||||
|
return modes[(cur - 1 + (dir or 1)) % #modes + 1]
|
||||||
|
end
|
||||||
|
|
||||||
|
function ScreenPosition.setMode(v)
|
||||||
|
ScreenPosition.mode = ScreenPosition.normalize(v)
|
||||||
|
end
|
||||||
|
|
||||||
|
function ScreenPosition.applyOptions(opts)
|
||||||
|
ScreenPosition.setMode(opts and opts.screenPos)
|
||||||
|
end
|
||||||
|
|
||||||
|
function ScreenPosition.safeTop()
|
||||||
|
local ok, SafeArea = pcall(require, "src.core.SafeArea")
|
||||||
|
if not ok then return 0 end
|
||||||
|
local okr, _, y = pcall(SafeArea.rect)
|
||||||
|
if not okr then return 0 end
|
||||||
|
return math.max(0, tonumber(y) or 0)
|
||||||
|
end
|
||||||
|
|
||||||
|
function ScreenPosition.skinActive(w, h)
|
||||||
|
local ok, TouchSkin = pcall(require, "src.core.TouchSkin")
|
||||||
|
if not ok or type(TouchSkin.viewport) ~= "function" then return false end
|
||||||
|
local okv, x = pcall(TouchSkin.viewport, w, h)
|
||||||
|
return okv and x ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
function ScreenPosition.lift(viewH, contentH, safeTop)
|
||||||
|
if ScreenPosition.mode == "center" then return 0 end
|
||||||
|
viewH = tonumber(viewH) or 0
|
||||||
|
contentH = tonumber(contentH) or 0
|
||||||
|
local slack = viewH - contentH
|
||||||
|
if slack <= 0 then return 0 end
|
||||||
|
local centered = math.floor(slack / 2)
|
||||||
|
local target = ScreenPosition.mode == "top" and 0 or math.floor(slack / 4)
|
||||||
|
safeTop = math.floor(tonumber(safeTop) or 0)
|
||||||
|
if safeTop > 0 and target < safeTop then
|
||||||
|
target = math.min(safeTop, centered)
|
||||||
|
end
|
||||||
|
return centered - target
|
||||||
|
end
|
||||||
|
|
||||||
|
return ScreenPosition
|
||||||
@@ -218,7 +218,7 @@ function TouchControls.defaultLayout(ww, wh, ox, oy, scale)
|
|||||||
local ssW = dpadW * 0.30
|
local ssW = dpadW * 0.30
|
||||||
local margin = dpadW * 0.12
|
local margin = dpadW * 0.12
|
||||||
local ok, GameVersion = pcall(require, "src.core.GameVersion")
|
local ok, GameVersion = pcall(require, "src.core.GameVersion")
|
||||||
if ok and GameVersion.isGold and GameVersion.isGold() then
|
if ok and GameVersion.generation and GameVersion.generation() == 2 then
|
||||||
margin = math.max(margin, math.min(ww * 0.10, 72))
|
margin = math.max(margin, math.min(ww * 0.10, 72))
|
||||||
end
|
end
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -432,6 +432,10 @@ function TouchSkin.parseNative(text)
|
|||||||
aspectFromCfg = raw.fitAspect == true,
|
aspectFromCfg = raw.fitAspect == true,
|
||||||
orient = (raw.orient == "portrait" or raw.orient == "landscape"
|
orient = (raw.orient == "portrait" or raw.orient == "landscape"
|
||||||
or raw.orient == "any") and raw.orient or nil,
|
or raw.orient == "any") and raw.orient or nil,
|
||||||
|
screenFit = raw.screenFit == "remainder" and "remainder" or nil,
|
||||||
|
anchor = (raw.anchor == "top" or raw.anchor == "bottom"
|
||||||
|
or raw.anchor == "left" or raw.anchor == "right")
|
||||||
|
and raw.anchor or nil,
|
||||||
rect = { x = 0, y = 0, w = 1, h = 1 },
|
rect = { x = 0, y = 0, w = 1, h = 1 },
|
||||||
controls = {},
|
controls = {},
|
||||||
}
|
}
|
||||||
@@ -507,6 +511,8 @@ function TouchSkin.toNative(skin)
|
|||||||
alphaMod = page.alphaMod,
|
alphaMod = page.alphaMod,
|
||||||
aspect = page.aspect,
|
aspect = page.aspect,
|
||||||
fitAspect = page.aspectFromCfg or nil,
|
fitAspect = page.aspectFromCfg or nil,
|
||||||
|
screenFit = page.screenFit == "remainder" and "remainder" or nil,
|
||||||
|
anchor = page.anchor,
|
||||||
orient = (page.orient == "portrait" or page.orient == "landscape"
|
orient = (page.orient == "portrait" or page.orient == "landscape"
|
||||||
or page.orient == "any") and page.orient or nil,
|
or page.orient == "any") and page.orient or nil,
|
||||||
controls = {},
|
controls = {},
|
||||||
@@ -570,6 +576,40 @@ local function loadImage(path)
|
|||||||
return img
|
return img
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- FileData so LOVE sniffs JPEG/PNG from the name, not a path inside the zip.
|
||||||
|
local function loadImageFromBytes(bytes, name)
|
||||||
|
if not bytes or bytes == "" then return nil end
|
||||||
|
if not (love and love.graphics and love.graphics.newImage) then return nil end
|
||||||
|
if not (love.filesystem and love.filesystem.newFileData) then return nil end
|
||||||
|
local key = "bytes:" .. tostring(name) .. ":" .. tostring(#bytes)
|
||||||
|
local cached = imageCache[key]
|
||||||
|
if cached then return cached end
|
||||||
|
local okFd, fd = pcall(love.filesystem.newFileData, bytes, name or "bezel.jpg")
|
||||||
|
if not okFd or not fd then return nil end
|
||||||
|
local ok, img = pcall(love.graphics.newImage, fd)
|
||||||
|
if not ok or not img then
|
||||||
|
if love.image and love.image.newImageData then
|
||||||
|
local okData, data = pcall(love.image.newImageData, fd)
|
||||||
|
if okData and data then ok, img = pcall(love.graphics.newImage, data) end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if not ok or not img then return nil end
|
||||||
|
if img.setFilter then img:setFilter("linear", "linear") end
|
||||||
|
imageCache[key] = img
|
||||||
|
return img
|
||||||
|
end
|
||||||
|
|
||||||
|
local function rasterizePdfPage(page, root)
|
||||||
|
if not page or page.image or not page.pdfPath then return end
|
||||||
|
local pdf = readFile(joinPath(root, page.pdfPath))
|
||||||
|
local raster = require("src.core.PdfImage").extract(pdf)
|
||||||
|
if not raster then return end
|
||||||
|
local name = tostring(page.pdfPath):gsub("%.[Pp][Dd][Ff]$", "") .. "." .. raster.ext
|
||||||
|
page.rasterData = raster.data
|
||||||
|
page.rasterName = name:match("([^/]+)$") or name
|
||||||
|
page.image = loadImageFromBytes(raster.data, page.rasterName)
|
||||||
|
end
|
||||||
|
|
||||||
local function pixelScalePending(page)
|
local function pixelScalePending(page)
|
||||||
if page.pixelCoords then return true end
|
if page.pixelCoords then return true end
|
||||||
for _, ctl in ipairs(page.controls or {}) do
|
for _, ctl in ipairs(page.controls or {}) do
|
||||||
@@ -622,6 +662,8 @@ function TouchSkin.load(root, id)
|
|||||||
for _, page in ipairs(skin.pages) do
|
for _, page in ipairs(skin.pages) do
|
||||||
if page.imagePath then
|
if page.imagePath then
|
||||||
page.image = loadImage(joinPath(root, page.imagePath))
|
page.image = loadImage(joinPath(root, page.imagePath))
|
||||||
|
elseif page.pdfPath then
|
||||||
|
rasterizePdfPage(page, root)
|
||||||
end
|
end
|
||||||
if not applyPixelScale(page) then
|
if not applyPixelScale(page) then
|
||||||
return nil, "could not read " .. tostring(page.imagePath)
|
return nil, "could not read " .. tostring(page.imagePath)
|
||||||
@@ -646,7 +688,7 @@ end
|
|||||||
TouchSkin.ARCHIVE_EXTS = { zip = true, deltaskin = true }
|
TouchSkin.ARCHIVE_EXTS = { zip = true, deltaskin = true }
|
||||||
TouchSkin.LEGACY_EXTS = { gbcskin = true, gbaskin = true, gbskin = true }
|
TouchSkin.LEGACY_EXTS = { gbcskin = true, gbaskin = true, gbskin = true }
|
||||||
TouchSkin.PDF_ONLY_MESSAGE =
|
TouchSkin.PDF_ONLY_MESSAGE =
|
||||||
"This skin uses PDF artwork, which cannot be imported yet. "
|
"This skin uses PDF artwork with no extractable image. "
|
||||||
.. "Ask the author for a PNG version."
|
.. "Ask the author for a PNG version."
|
||||||
|
|
||||||
function TouchSkin.archiveId(name)
|
function TouchSkin.archiveId(name)
|
||||||
@@ -1246,12 +1288,27 @@ function TouchSkin.pageBox(page, w, h, ox, oy)
|
|||||||
and page.aspect and page.aspect > 0 and h > 0
|
and page.aspect and page.aspect > 0 and h > 0
|
||||||
if fit then
|
if fit then
|
||||||
local displayAspect = w / h
|
local displayAspect = w / h
|
||||||
|
local anchor = page.anchor
|
||||||
if displayAspect > page.aspect then
|
if displayAspect > page.aspect then
|
||||||
bw = h * page.aspect
|
bw = h * page.aspect
|
||||||
bx = ox + (w - bw) * 0.5
|
local extra = w - bw
|
||||||
|
if anchor == "right" then
|
||||||
|
bx = ox + extra
|
||||||
|
elseif anchor == "left" then
|
||||||
|
bx = ox
|
||||||
|
else
|
||||||
|
bx = ox + extra * 0.5
|
||||||
|
end
|
||||||
else
|
else
|
||||||
bh = w / page.aspect
|
bh = w / page.aspect
|
||||||
by = oy + (h - bh) * 0.5
|
local extra = h - bh
|
||||||
|
if anchor == "bottom" then
|
||||||
|
by = oy + extra
|
||||||
|
elseif anchor == "top" then
|
||||||
|
by = oy
|
||||||
|
else
|
||||||
|
by = oy + extra * 0.5
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
local r = page.rect
|
local r = page.rect
|
||||||
@@ -1308,18 +1365,55 @@ end
|
|||||||
|
|
||||||
function TouchSkin.hasViewport()
|
function TouchSkin.hasViewport()
|
||||||
local page = TouchSkin.page()
|
local page = TouchSkin.page()
|
||||||
return page ~= nil and page.viewport ~= nil and TouchSkin.drawable()
|
if not page or not TouchSkin.drawable() then return false end
|
||||||
|
return page.viewport ~= nil or page.screenFit == "remainder"
|
||||||
end
|
end
|
||||||
|
|
||||||
function TouchSkin.viewport(w, h, ox, oy)
|
-- Largest strip of (ox,oy,w,h) that does not overlap the overlay box.
|
||||||
local page = TouchSkin.page()
|
local function remainderBox(ox, oy, w, h, bx, by, bw, bh)
|
||||||
if not page or not page.viewport or not TouchSkin.drawable() then return nil end
|
local right, bottom = ox + w, oy + h
|
||||||
local v = page.viewport
|
local cand = {
|
||||||
|
{ ox, oy, w, by - oy },
|
||||||
|
{ ox, by + bh, w, bottom - (by + bh) },
|
||||||
|
{ ox, oy, bx - ox, h },
|
||||||
|
{ bx + bw, oy, right - (bx + bw), h },
|
||||||
|
}
|
||||||
|
local best, bestArea
|
||||||
|
for _, r in ipairs(cand) do
|
||||||
|
if r[3] > 1 and r[4] > 1 then
|
||||||
|
local area = r[3] * r[4]
|
||||||
|
if not best or area > bestArea then
|
||||||
|
best, bestArea = r, area
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if not best then return nil end
|
||||||
|
return best[1], best[2], best[3], best[4]
|
||||||
|
end
|
||||||
|
|
||||||
|
function TouchSkin.pageViewport(page, w, h, ox, oy)
|
||||||
|
if not page then return nil end
|
||||||
|
ox, oy = ox or 0, oy or 0
|
||||||
local bx, by, bw, bh = TouchSkin.pageBox(page, w, h, ox, oy)
|
local bx, by, bw, bh = TouchSkin.pageBox(page, w, h, ox, oy)
|
||||||
|
if page.viewport then
|
||||||
|
local v = page.viewport
|
||||||
local x, y = bx + v.x * bw, by + v.y * bh
|
local x, y = bx + v.x * bw, by + v.y * bh
|
||||||
local vw, vh = v.w * bw, v.h * bh
|
local vw, vh = v.w * bw, v.h * bh
|
||||||
if vw <= 0 or vh <= 0 then return nil end
|
if vw <= 0 or vh <= 0 then return nil end
|
||||||
return x, y, vw, vh, page.viewportFill == true, page.viewportExpand == true
|
return x, y, vw, vh, page.viewportFill == true, page.viewportExpand == true
|
||||||
|
end
|
||||||
|
if page.screenFit == "remainder" then
|
||||||
|
local x, y, vw, vh = remainderBox(ox, oy, w, h, bx, by, bw, bh)
|
||||||
|
if not x then return nil end
|
||||||
|
return x, y, vw, vh, false, false
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
function TouchSkin.viewport(w, h, ox, oy)
|
||||||
|
local page = TouchSkin.page()
|
||||||
|
if not page or not TouchSkin.drawable() then return nil end
|
||||||
|
return TouchSkin.pageViewport(page, w, h, ox, oy)
|
||||||
end
|
end
|
||||||
|
|
||||||
return TouchSkin
|
return TouchSkin
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ Save.PLAYER_STATES = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
local function saveNames(version)
|
local function saveNames(version)
|
||||||
version = version or "gold"
|
version = version or GameVersion.get()
|
||||||
-- Resolve the ACTIVE SLOT the same way SaveData does, and only fall back to
|
-- Resolve the ACTIVE SLOT the same way SaveData does, and only fall back to
|
||||||
-- the flat save_<version>.lua when no slot is registered.
|
-- the flat save_<version>.lua when no slot is registered.
|
||||||
--
|
--
|
||||||
@@ -133,16 +133,22 @@ local function fs()
|
|||||||
return love.filesystem
|
return love.filesystem
|
||||||
end
|
end
|
||||||
|
|
||||||
-- A fresh Gold save. `opts` carries what the intro collected: player name,
|
-- The blank-name fallback is the first PlayerNameArray row, which differs
|
||||||
|
-- per edition -- data/player_names.asm:12-23.
|
||||||
|
function Save.defaultPlayerName(version)
|
||||||
|
return (version or GameVersion.get()) == "silver" and "SILVER" or "GOLD"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- A fresh Gen 2 save. `opts` carries what the intro collected: player name,
|
||||||
-- rival name, and the options the OPTION screen was left on.
|
-- rival name, and the options the OPTION screen was left on.
|
||||||
function Save.newGame(opts)
|
function Save.newGame(opts)
|
||||||
opts = opts or {}
|
opts = opts or {}
|
||||||
local save = {
|
local save = {
|
||||||
format = Save.FORMAT,
|
format = Save.FORMAT,
|
||||||
version = "gold",
|
version = GameVersion.get(),
|
||||||
generation = 2,
|
generation = 2,
|
||||||
player = {
|
player = {
|
||||||
name = opts.playerName or "GOLD",
|
name = opts.playerName or Save.defaultPlayerName(),
|
||||||
-- _ResetWRAM rolls wPlayerID out of hRandomSub/hRandomAdd
|
-- _ResetWRAM rolls wPlayerID out of hRandomSub/hRandomAdd
|
||||||
-- (engine/menus/intro_menu.asm:41-49).
|
-- (engine/menus/intro_menu.asm:41-49).
|
||||||
id = opts.trainerId or rand(0, 65535),
|
id = opts.trainerId or rand(0, 65535),
|
||||||
@@ -214,6 +220,9 @@ function Save.newGame(opts)
|
|||||||
phoneContacts = {},
|
phoneContacts = {},
|
||||||
tradeFlags = {},
|
tradeFlags = {},
|
||||||
pokedex = { seen = {}, caught = {} },
|
pokedex = { seen = {}, caught = {} },
|
||||||
|
-- wLastDexMode (engine/pokedex/pokedex.asm:59-61): the sort mode the
|
||||||
|
-- #DEX reopens in. NEW_MODE is the cart's zero byte.
|
||||||
|
lastDexMode = "NEW",
|
||||||
-- wUnownDex: the distinct Unown FORMS caught, in catching order. A second
|
-- wUnownDex: the distinct Unown FORMS caught, in catching order. A second
|
||||||
-- record beside the #DEX because the #DEX knows only the species
|
-- record beside the #DEX because the #DEX knows only the species
|
||||||
-- (src/core/gen2/Unown.lua).
|
-- (src/core/gen2/Unown.lua).
|
||||||
@@ -282,6 +291,7 @@ Save.DEFAULT_OPTIONS = {
|
|||||||
musicFilter = 0, -- low-pass steps, 0 = off
|
musicFilter = 0, -- low-pass steps, 0 = off
|
||||||
haptics = "light",
|
haptics = "light",
|
||||||
touchControls = { enabled = true },
|
touchControls = { enabled = true },
|
||||||
|
screenPos = "center",
|
||||||
}
|
}
|
||||||
|
|
||||||
function Save.defaultOptions()
|
function Save.defaultOptions()
|
||||||
@@ -302,7 +312,7 @@ end
|
|||||||
Save.OPTIONS_KEY = "gold"
|
Save.OPTIONS_KEY = "gold"
|
||||||
|
|
||||||
local SHARED_KEYS = {
|
local SHARED_KEYS = {
|
||||||
touchControls = true, haptics = true,
|
touchControls = true, haptics = true, screenPos = true,
|
||||||
mods = true, modsByVersion = true, modsGen2 = true,
|
mods = true, modsByVersion = true, modsGen2 = true,
|
||||||
modOptions = true, modProfiles = true, modProfilesSeeded = true,
|
modOptions = true, modProfiles = true, modProfilesSeeded = true,
|
||||||
activeProfile = true,
|
activeProfile = true,
|
||||||
@@ -374,10 +384,13 @@ end
|
|||||||
function Save.normalize(save)
|
function Save.normalize(save)
|
||||||
if type(save) ~= "table" then return nil end
|
if type(save) ~= "table" then return nil end
|
||||||
save.format = save.format or Save.FORMAT
|
save.format = save.format or Save.FORMAT
|
||||||
save.version = "gold"
|
if not (GameVersion.VERSIONS[save.version]
|
||||||
|
and GameVersion.generation(save.version) == 2) then
|
||||||
|
save.version = GameVersion.get()
|
||||||
|
end
|
||||||
save.generation = 2
|
save.generation = 2
|
||||||
save.player = save.player or {}
|
save.player = save.player or {}
|
||||||
save.player.name = save.player.name or "GOLD"
|
save.player.name = save.player.name or Save.defaultPlayerName(save.version)
|
||||||
save.player.id = save.player.id or rand(0, 65535)
|
save.player.id = save.player.id or rand(0, 65535)
|
||||||
save.player.money = math.max(0, math.min(save.player.money or 0, Save.MAX_MONEY))
|
save.player.money = math.max(0, math.min(save.player.money or 0, Save.MAX_MONEY))
|
||||||
save.player.coins = math.max(0, math.min(save.player.coins or 0, Save.MAX_COINS))
|
save.player.coins = math.max(0, math.min(save.player.coins or 0, Save.MAX_COINS))
|
||||||
@@ -675,6 +688,12 @@ function Save.validate(save)
|
|||||||
scrubEvents(save, report)
|
scrubEvents(save, report)
|
||||||
scrubMapScenes(save, report)
|
scrubMapScenes(save, report)
|
||||||
scrubPlayerState(save, report)
|
scrubPlayerState(save, report)
|
||||||
|
-- wLastDexMode: only the three modes the #DEX has (PokedexMenu MODES);
|
||||||
|
-- a hand-edited value falls back to NEW_MODE, the cart's zero byte
|
||||||
|
if save.lastDexMode ~= "NEW" and save.lastDexMode ~= "OLD"
|
||||||
|
and save.lastDexMode ~= "A-Z" then
|
||||||
|
save.lastDexMode = "NEW"
|
||||||
|
end
|
||||||
-- The `mailmsg` structs get the same treatment for the same reason: their
|
-- The `mailmsg` structs get the same treatment for the same reason: their
|
||||||
-- `type` byte is an item id nothing else in the save vouches for, and a
|
-- `type` byte is an item id nothing else in the save vouches for, and a
|
||||||
-- party key outside 1..6 or a MAILBOX past MAILBOX_CAPACITY is a region the
|
-- party key outside 1..6 or a MAILBOX past MAILBOX_CAPACITY is a region the
|
||||||
@@ -753,21 +772,24 @@ end
|
|||||||
-- copy is the witness that survives a crash mid-replace.
|
-- copy is the witness that survives a crash mid-replace.
|
||||||
function Save.save(save)
|
function Save.save(save)
|
||||||
if type(save) ~= "table" then return false, "no save" end
|
if type(save) ~= "table" then return false, "no save" end
|
||||||
if (save.version or "gold") == "gold" then
|
Save.normalize(save)
|
||||||
|
local version = save.version
|
||||||
|
do
|
||||||
local ok, SaveData = pcall(require, "src.core.SaveData")
|
local ok, SaveData = pcall(require, "src.core.SaveData")
|
||||||
if ok and SaveData.activeSlot and not SaveData.activeSlot("gold") then
|
if ok and SaveData.activeSlot and not SaveData.activeSlot(version) then
|
||||||
local id = SaveData.createSlot and SaveData.createSlot("gold")
|
local id = SaveData.createSlot and SaveData.createSlot(version)
|
||||||
if id and SaveData.setActiveSlot then SaveData.setActiveSlot("gold", id) end
|
if id and SaveData.setActiveSlot then
|
||||||
|
SaveData.setActiveSlot(version, id)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
local main, backup, tmp = saveNames(save.version)
|
end
|
||||||
|
local main, backup, tmp = saveNames(version)
|
||||||
local f = fs()
|
local f = fs()
|
||||||
if not f then return false, "no filesystem" end
|
if not f then return false, "no filesystem" end
|
||||||
-- saveNames may now return a saves/<version>/<slot>.lua path, and
|
-- saveNames may now return a saves/<version>/<slot>.lua path, and
|
||||||
-- love.filesystem.write does not create missing parent directories.
|
-- love.filesystem.write does not create missing parent directories.
|
||||||
local dir = main:match("^(.*)/[^/]+$")
|
local dir = main:match("^(.*)/[^/]+$")
|
||||||
if dir and f.createDirectory then f.createDirectory(dir) end
|
if dir and f.createDirectory then f.createDirectory(dir) end
|
||||||
Save.normalize(save)
|
|
||||||
save.savedAt = os.time()
|
save.savedAt = os.time()
|
||||||
local encoded = SaveSerializer.encode(save)
|
local encoded = SaveSerializer.encode(save)
|
||||||
if f.getInfo(main) then
|
if f.getInfo(main) then
|
||||||
|
|||||||
@@ -249,10 +249,12 @@ function SwitchDiagnostics.probeAssets(version)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- Shallow listing so we can see if the extract tree exists at all.
|
-- Shallow listing so we can see if the extract tree exists at all.
|
||||||
local roots = { "yellow", "blue", "gold", "assets", "yellow/assets/generated",
|
local roots = { "yellow", "blue", "gold", "silver", "assets",
|
||||||
|
"yellow/assets/generated",
|
||||||
"yellow/assets/generated/sprites", "blue/assets/generated/sprites",
|
"yellow/assets/generated/sprites", "blue/assets/generated/sprites",
|
||||||
"gold/assets/generated", "gold/assets/generated/sprites",
|
"gold/assets/generated", "gold/assets/generated/sprites",
|
||||||
"gold/data/generated" }
|
"gold/data/generated", "silver/assets/generated",
|
||||||
|
"silver/data/generated" }
|
||||||
for _, dir in ipairs(roots) do
|
for _, dir in ipairs(roots) do
|
||||||
local info = filesystem.getInfo(dir)
|
local info = filesystem.getInfo(dir)
|
||||||
if info and info.type == "directory" and filesystem.getDirectoryItems then
|
if info and info.type == "directory" and filesystem.getDirectoryItems then
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ local ok, err = pcall(function()
|
|||||||
CacheFs.prefix = prefix
|
CacheFs.prefix = prefix
|
||||||
|
|
||||||
local manifest = require("src.import.RomManifest").decode(version)
|
local manifest = require("src.import.RomManifest").decode(version)
|
||||||
local RomExtractor = version == "gold"
|
local RomExtractor =
|
||||||
|
require("src.core.GameVersion").generation(version) == 2
|
||||||
and require("src.import.RomExtractorGen2")
|
and require("src.import.RomExtractorGen2")
|
||||||
or require("src.import.RomExtractor")
|
or require("src.import.RomExtractor")
|
||||||
|
|
||||||
|
|||||||
@@ -332,6 +332,17 @@ local function coreRows(opts, hooks)
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local okSp, ScreenPos = pcall(require, "src.core.ScreenPosition")
|
||||||
|
if okSp then
|
||||||
|
add(Strings("SCREEN POS"),
|
||||||
|
function() return Strings(ScreenPos.label(opts.screenPos)) end,
|
||||||
|
function(dir)
|
||||||
|
opts.screenPos = ScreenPos.cycle(opts.screenPos, dir)
|
||||||
|
ScreenPos.setMode(opts.screenPos)
|
||||||
|
return true
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
local okCap, FrameCap = pcall(require, "src.core.FrameCap")
|
local okCap, FrameCap = pcall(require, "src.core.FrameCap")
|
||||||
if okCap then
|
if okCap then
|
||||||
add(Strings("MAX FPS"),
|
add(Strings("MAX FPS"),
|
||||||
@@ -541,29 +552,6 @@ local function modRows(opts, mod)
|
|||||||
return rows
|
return rows
|
||||||
end
|
end
|
||||||
|
|
||||||
local function troubleshootingRows(opts, hooks)
|
|
||||||
return {
|
|
||||||
{
|
|
||||||
label = Strings("SAFE MODE"),
|
|
||||||
actionLabel = function()
|
|
||||||
return SaveData.isSafeMode(opts) and Strings("Turn off") or Strings("Turn on")
|
|
||||||
end,
|
|
||||||
action = function()
|
|
||||||
SaveData.setSafeMode(opts, not SaveData.isSafeMode(opts))
|
|
||||||
return true
|
|
||||||
end,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label = Strings("REPORT ISSUE"),
|
|
||||||
actionLabel = Strings("Report bug"),
|
|
||||||
action = function()
|
|
||||||
if hooks and hooks.reportIssue then hooks.reportIssue(opts) end
|
|
||||||
return false
|
|
||||||
end,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
-- ------- Gen 2 (Gold)
|
-- ------- Gen 2 (Gold)
|
||||||
--
|
--
|
||||||
-- Gold reads NONE of the rows above. Its OPTION screen writes a different
|
-- Gold reads NONE of the rows above. Its OPTION screen writes a different
|
||||||
@@ -573,9 +561,10 @@ end
|
|||||||
-- opened on the Gold tab used to offer a dozen controls that did nothing
|
-- opened on the Gold tab used to offer a dozen controls that did nothing
|
||||||
-- and hide the seven that the cart itself has.
|
-- and hide the seven that the cart itself has.
|
||||||
--
|
--
|
||||||
-- The block lives in options.lua under `gold`, which is exactly where
|
-- The block lives in options.lua under `gold` (the historical key; Gold and
|
||||||
-- src/core/gen2/Save.lua loadOptions reads it, so an edit here is live on the
|
-- Silver share it the way the Gen 1 games share the flat namespace), which is
|
||||||
-- next boot the same way a Gen 1 edit is. Ladders mirror
|
-- exactly where src/core/gen2/Save.lua loadOptions reads it, so an edit here
|
||||||
|
-- is live on the next boot the same way a Gen 1 edit is. Ladders mirror
|
||||||
-- src/ui/gen2/OptionsMenu.lua's ROWS; when editing one, keep the two in sync.
|
-- src/ui/gen2/OptionsMenu.lua's ROWS; when editing one, keep the two in sync.
|
||||||
local GEN2_KEY = "gold"
|
local GEN2_KEY = "gold"
|
||||||
|
|
||||||
@@ -722,7 +711,9 @@ end
|
|||||||
function LauncherSettings.open(hooks, version)
|
function LauncherSettings.open(hooks, version)
|
||||||
local opts = SaveData.loadOptions()
|
local opts = SaveData.loadOptions()
|
||||||
local sections
|
local sections
|
||||||
if version == "gold" then
|
local GameVersion = require("src.core.GameVersion")
|
||||||
|
if GameVersion.VERSIONS[version]
|
||||||
|
and GameVersion.generation(version) == 2 then
|
||||||
local block = opts[GEN2_KEY]
|
local block = opts[GEN2_KEY]
|
||||||
if type(block) ~= "table" then
|
if type(block) ~= "table" then
|
||||||
block = {}
|
block = {}
|
||||||
@@ -744,10 +735,6 @@ function LauncherSettings.open(hooks, version)
|
|||||||
sections[#sections + 1] = { title = mod.name, rows = rows }
|
sections[#sections + 1] = { title = mod.name, rows = rows }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
sections[#sections + 1] = {
|
|
||||||
title = Strings("TROUBLESHOOTING"),
|
|
||||||
rows = troubleshootingRows(opts, hooks),
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
opts = opts,
|
opts = opts,
|
||||||
version = version,
|
version = version,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
-- (species order IS dex order, pics/tilesets are lz3-compressed rather than
|
-- (species order IS dex order, pics/tilesets are lz3-compressed rather than
|
||||||
-- pkmncompress'd, maps are grouped instead of flat). See docs/gold-phase1.md.
|
-- pkmncompress'd, maps are grouped instead of flat). See docs/gold-phase1.md.
|
||||||
local bit = require("bit")
|
local bit = require("bit")
|
||||||
|
local GameVersion = require("src.core.GameVersion")
|
||||||
local ImageWriter = require("src.import.ImageWriter")
|
local ImageWriter = require("src.import.ImageWriter")
|
||||||
local LuaWriter = require("src.import.LuaWriter")
|
local LuaWriter = require("src.import.LuaWriter")
|
||||||
local Rom = require("src.import.Rom")
|
local Rom = require("src.import.Rom")
|
||||||
@@ -168,6 +169,10 @@ function RomExtractorGen2.new(romData, manifest, progress)
|
|||||||
symbols = manifest.symbols,
|
symbols = manifest.symbols,
|
||||||
progress = progress,
|
progress = progress,
|
||||||
stage = 0,
|
stage = 0,
|
||||||
|
-- _GOLD / _SILVER: the labels are shared, the data behind a handful of
|
||||||
|
-- them is not (gfx/misc.asm:9-20 vs :46-57).
|
||||||
|
edition = GameVersion.forSha1(manifest.romSha1) == "silver"
|
||||||
|
and "silver" or "gold",
|
||||||
}, RomExtractorGen2)
|
}, RomExtractorGen2)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -1641,22 +1646,40 @@ function RomExtractorGen2:extractTitle()
|
|||||||
return 3
|
return 3
|
||||||
end
|
end
|
||||||
|
|
||||||
-- pret gfx/title/title_bg_gold.pal / title_fg.pal (5 BG pals, 2 OBJ pals).
|
local silver = self.edition == "silver"
|
||||||
local BG_PALS = {
|
|
||||||
|
-- pret gfx/title/title_bg_gold.pal / title_bg_silver.pal (5 BG pals);
|
||||||
|
-- GSTitleBGPals is the edition-selected include (engine/gfx/color.asm:1234).
|
||||||
|
local BG_PALS = silver and {
|
||||||
|
{ { 31, 31, 31 }, { 0, 12, 15 }, { 4, 8, 21 }, { 0, 0, 0 } },
|
||||||
|
{ { 31, 21, 0 }, { 15, 17, 15 }, { 4, 8, 21 }, { 0, 0, 17 } },
|
||||||
|
{ { 31, 31, 31 }, { 31, 0, 0 }, { 4, 8, 21 }, { 0, 0, 0 } },
|
||||||
|
{ { 31, 31, 31 }, { 24, 23, 25 }, { 4, 8, 21 }, { 8, 8, 9 } },
|
||||||
|
{ { 31, 31, 31 }, { 5, 10, 11 }, { 0, 12, 15 }, { 0, 0, 0 } },
|
||||||
|
} or {
|
||||||
{ { 31, 31, 31 }, { 18, 23, 31 }, { 15, 20, 31 }, { 0, 0, 0 } },
|
{ { 31, 31, 31 }, { 18, 23, 31 }, { 15, 20, 31 }, { 0, 0, 0 } },
|
||||||
{ { 31, 21, 0 }, { 12, 14, 12 }, { 15, 20, 31 }, { 0, 0, 17 } },
|
{ { 31, 21, 0 }, { 12, 14, 12 }, { 15, 20, 31 }, { 0, 0, 17 } },
|
||||||
{ { 31, 31, 31 }, { 31, 0, 0 }, { 15, 20, 31 }, { 0, 0, 0 } },
|
{ { 31, 31, 31 }, { 31, 0, 0 }, { 15, 20, 31 }, { 0, 0, 0 } },
|
||||||
{ { 31, 31, 31 }, { 29, 25, 0 }, { 15, 20, 31 }, { 17, 10, 1 } },
|
{ { 31, 31, 31 }, { 29, 25, 0 }, { 15, 20, 31 }, { 17, 10, 1 } },
|
||||||
{ { 31, 31, 31 }, { 23, 26, 31 }, { 18, 23, 31 }, { 0, 0, 0 } },
|
{ { 31, 31, 31 }, { 23, 26, 31 }, { 18, 23, 31 }, { 0, 0, 0 } },
|
||||||
}
|
}
|
||||||
-- title_fg.pal: pal 0 = Ho-Oh silhouette (shades 1-3 are the same brown);
|
-- title_fg.pal, shared (GSTitleOBPals, engine/gfx/color.asm:1241): pal 0 =
|
||||||
-- pal 1 = gold trail sparks (OAM_PAL1 on GSTitleTrail).
|
-- Ho-Oh silhouette; pal 1 = gold trail sparks (OAM_PAL1 on GSTitleTrail).
|
||||||
local OBJ_HOOH = {
|
local OBJ_HOOH = {
|
||||||
{ 31, 31, 31 }, { 7, 6, 3 }, { 7, 6, 3 }, { 7, 6, 3 },
|
{ 31, 31, 31 }, { 7, 6, 3 }, { 7, 6, 3 }, { 7, 6, 3 },
|
||||||
}
|
}
|
||||||
local OBJ_TRAIL = {
|
local OBJ_TRAIL = {
|
||||||
{ 31, 31, 31 }, { 31, 31, 0 }, { 26, 22, 0 }, { 0, 0, 0 },
|
{ 31, 31, 31 }, { 31, 31, 0 }, { 26, 22, 0 }, { 0, 0, 0 },
|
||||||
}
|
}
|
||||||
|
-- engine/movie/title.asm:134-141 + CopyPals (home/palettes.asm:190):
|
||||||
|
-- DmgToCgbObjPal0 %11100000 makes Silver's OBJ pal 0 {c0, c0, c2, c3}.
|
||||||
|
if silver then
|
||||||
|
OBJ_HOOH = {
|
||||||
|
OBJ_HOOH[1], OBJ_HOOH[1], OBJ_HOOH[3], OBJ_HOOH[4],
|
||||||
|
}
|
||||||
|
-- .OAMData_GSTitleTrail is attribute 0, not OAM_PAL1 (oam.asm:834-837).
|
||||||
|
OBJ_TRAIL = OBJ_HOOH
|
||||||
|
end
|
||||||
|
|
||||||
local function palColor(pal, shade)
|
local function palColor(pal, shade)
|
||||||
local c = pal[shade + 1] or pal[4]
|
local c = pal[shade + 1] or pal[4]
|
||||||
@@ -1671,8 +1694,9 @@ function RomExtractorGen2:extractTitle()
|
|||||||
-- solid BLACK silhouette on a monochrome screen rather than the shaded pose
|
-- solid BLACK silhouette on a monochrome screen rather than the shaded pose
|
||||||
-- a straight decode gives. rOBP1 (%11111000) carries the gold trail.
|
-- a straight decode gives. rOBP1 (%11111000) carries the gold trail.
|
||||||
local DMG_BGP = { 0, 2, 1, 3 }
|
local DMG_BGP = { 0, 2, 1, 3 }
|
||||||
local DMG_OBP0 = { 3, 3, 3, 3 }
|
-- engine/movie/title.asm:105-115: Silver writes %11110000 to both OBPs.
|
||||||
local DMG_OBP1 = { 0, 2, 3, 3 }
|
local DMG_OBP0 = silver and { 0, 0, 3, 3 } or { 3, 3, 3, 3 }
|
||||||
|
local DMG_OBP1 = silver and { 0, 0, 3, 3 } or { 0, 2, 3, 3 }
|
||||||
-- ImageWriter's four hardware shades, by shade number.
|
-- ImageWriter's four hardware shades, by shade number.
|
||||||
local DMG_SHADE = { 1, 2 / 3, 1 / 3, 0 }
|
local DMG_SHADE = { 1, 2 / 3, 1 / 3, 0 }
|
||||||
|
|
||||||
@@ -1776,6 +1800,28 @@ function RomExtractorGen2:extractTitle()
|
|||||||
|
|
||||||
-- Ho-Oh frames from OAMData_GSIntroHoOh1..5 (data/sprite_anims/oam.asm).
|
-- Ho-Oh frames from OAMData_GSIntroHoOh1..5 (data/sprite_anims/oam.asm).
|
||||||
local hoohTiles = tilesFrom2bpp(self:decompressLz3Symbol("TitleScreenGFX4"), true)
|
local hoohTiles = tilesFrom2bpp(self:decompressLz3Symbol("TitleScreenGFX4"), true)
|
||||||
|
-- .OAMData_GSIntroLugia1 / 2 (data/sprite_anims/oam.asm:736-773); the
|
||||||
|
-- spriteanimoam vtile offset is added per frame (core.asm:224-227).
|
||||||
|
local LUGIA_1 = {
|
||||||
|
{ -5, -2, 0, 0, 0x00 }, { -5, 0, 0, 0, 0x02 },
|
||||||
|
{ -4, -2, 0, 0, 0x04 }, { -4, 0, 0, 0, 0x06 },
|
||||||
|
{ -3, -1, 0, 0, 0x08 }, { -2, -1, 0, 0, 0x0a },
|
||||||
|
{ -1, -2, 0, 0, 0x0c }, { -1, 0, 0, 0, 0x0e },
|
||||||
|
{ 0, -2, 0, 0, 0x10 }, { 0, 0, 0, 0, 0x12 },
|
||||||
|
{ 1, -2, 0, 0, 0x14 }, { 1, 0, 0, 0, 0x16 },
|
||||||
|
{ 2, -2, 0, 0, 0x18 }, { 2, 0, 0, 0, 0x1a },
|
||||||
|
{ 3, -1, 0, 0, 0x1c }, { 4, -1, 0, 0, 0x1e },
|
||||||
|
}
|
||||||
|
local LUGIA_2 = {
|
||||||
|
{ -5, -2, 0, 0, 0x00 }, { -5, 0, 0, 0, 0x02 },
|
||||||
|
{ -4, -2, 0, 0, 0x04 }, { -4, 0, 0, 0, 0x06 },
|
||||||
|
{ -3, -1, 0, 0, 0x08 }, { -2, -1, 0, 0, 0x0a },
|
||||||
|
{ -1, -2, 0, 0, 0x0c }, { -1, 0, 0, 0, 0x0e },
|
||||||
|
{ 0, -2, 0, 0, 0x10 }, { 0, 0, 0, 0, 0x12 },
|
||||||
|
{ 1, -2, 0, 0, 0x14 }, { 1, 0, 0, 0, 0x16 },
|
||||||
|
{ 2, -2, 0, 0, 0x18 }, { 2, 0, 0, 0, 0x1a },
|
||||||
|
{ 3, -2, 0, 0, 0x1c }, { 4, -2, 0, 0, 0x1e },
|
||||||
|
}
|
||||||
local HOOH_FRAMES = {
|
local HOOH_FRAMES = {
|
||||||
{ -- 1
|
{ -- 1
|
||||||
{ -4, -1, 0, 0, 0x00 }, { -3, -2, 0, 0, 0x02 }, { -3, 0, 0, 0, 0x04 },
|
{ -4, -1, 0, 0, 0x00 }, { -3, -2, 0, 0, 0x02 }, { -3, 0, 0, 0, 0x04 },
|
||||||
@@ -1823,22 +1869,36 @@ function RomExtractorGen2:extractTitle()
|
|||||||
{ 3, -2, 0, 0, 0x22 }, { 3, 0, 0, 0, 0x24 },
|
{ 3, -2, 0, 0, 0x22 }, { 3, 0, 0, 0, 0x24 },
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
-- Frameset_GSIntroHoOhLugia (Gold): 1,2,3,4,3,5 with these durations.
|
-- Silver's five oamsets, as {layout, vtile base} (oam.asm:103-107).
|
||||||
local HOOH_SEQUENCE = {
|
local LUGIA_FRAMES = {
|
||||||
|
{ LUGIA_1, 0x00 }, { LUGIA_1, 0x20 }, { LUGIA_2, 0x40 },
|
||||||
|
{ LUGIA_2, 0x60 }, { LUGIA_1, 0x00 },
|
||||||
|
}
|
||||||
|
-- Frameset_GSIntroHoOhLugia (data/sprite_anims/framesets.asm:376-396):
|
||||||
|
-- Gold 1,2,3,4,3,5; Silver 2,1,2,3,3,4,4,3,2 on a faster clock.
|
||||||
|
local HOOH_SEQUENCE = silver and {
|
||||||
|
{ 2, 3 }, { 1, 7 }, { 2, 7 }, { 3, 7 }, { 3, 7 },
|
||||||
|
{ 4, 7 }, { 4, 7 }, { 3, 7 }, { 2, 3 },
|
||||||
|
} or {
|
||||||
{ 1, 10 }, { 2, 9 }, { 3, 10 }, { 4, 10 }, { 3, 9 }, { 5, 10 },
|
{ 1, 10 }, { 2, 9 }, { 3, 10 }, { 4, 10 }, { 3, 9 }, { 5, 10 },
|
||||||
}
|
}
|
||||||
local hoohPaths, hoohGrayPaths = {}, {}
|
local hoohPaths, hoohGrayPaths = {}, {}
|
||||||
local originX, originY = 32, 24
|
-- Lugia1/2 span x tiles -5..4, four tiles wider than Ho-Oh's -4..3.
|
||||||
for fi, oam in ipairs(HOOH_FRAMES) do
|
local originX, originY = silver and 40 or 32, 24
|
||||||
|
local poseW = silver and 80 or 64
|
||||||
|
local frames = silver and LUGIA_FRAMES or HOOH_FRAMES
|
||||||
|
for fi, entry in ipairs(frames) do
|
||||||
|
local oam = silver and entry[1] or entry
|
||||||
|
local base = silver and entry[2] or 0
|
||||||
-- The pose starts EMPTY, not white: an OBJ's colour 0 is transparent
|
-- The pose starts EMPTY, not white: an OBJ's colour 0 is transparent
|
||||||
-- wherever it falls, so a gap enclosed by the bird shows the sky through
|
-- wherever it falls, so a gap enclosed by the bird shows the sky through
|
||||||
-- exactly like one outside it, and there is no matte to flood-fill.
|
-- exactly like one outside it, and there is no matte to flood-fill.
|
||||||
local pose = ImageWriter.blank(64, 64, 0, 0, 0, 0)
|
local pose = ImageWriter.blank(poseW, 64, 0, 0, 0, 0)
|
||||||
for _, spr in ipairs(oam) do
|
for _, spr in ipairs(oam) do
|
||||||
local px = originX + spr[1] * 8 + spr[3]
|
local px = originX + spr[1] * 8 + spr[3]
|
||||||
local py = originY + spr[2] * 8 + spr[4]
|
local py = originY + spr[2] * 8 + spr[4]
|
||||||
blitSprite(pose, hoohTiles[spr[5] + 1], px, py)
|
blitSprite(pose, hoohTiles[base + spr[5] + 1], px, py)
|
||||||
blitSprite(pose, hoohTiles[spr[5] + 2], px, py + 8)
|
blitSprite(pose, hoohTiles[base + spr[5] + 2], px, py + 8)
|
||||||
end
|
end
|
||||||
local tinted = colorize(pose, function() return OBJ_HOOH end)
|
local tinted = colorize(pose, function() return OBJ_HOOH end)
|
||||||
local rel = ("title/hooh_%d.png"):format(fi)
|
local rel = ("title/hooh_%d.png"):format(fi)
|
||||||
@@ -1855,14 +1915,20 @@ function RomExtractorGen2:extractTitle()
|
|||||||
end
|
end
|
||||||
self:tick("Title screen", 3, 5)
|
self:tick("Title screen", 3, 5)
|
||||||
|
|
||||||
-- Trail: TitleScreenGFX3 is raw 2bpp (8 tiles); Gold OAM uses one 8x16
|
-- Trail: TitleScreenGFX3 is raw 2bpp; Gold's OAM is one 8x16, Silver's two
|
||||||
-- on OAM_PAL1 (gold), not the Ho-Oh silhouette pal.
|
-- side by side, and only 4 of Silver's 8 copied tiles exist (title.asm:43).
|
||||||
local trailSym = self:symbol("TitleScreenGFX3")
|
local trailSym = self:symbol("TitleScreenGFX3")
|
||||||
local trailRaw = self.rom:bytes(trailSym.bank, trailSym.address, 8 * 16)
|
local trailTileCount = silver and 4 or 8
|
||||||
|
local trailRaw =
|
||||||
|
self.rom:bytes(trailSym.bank, trailSym.address, trailTileCount * 16)
|
||||||
local trailTiles = tilesFrom2bpp(trailRaw, true)
|
local trailTiles = tilesFrom2bpp(trailRaw, true)
|
||||||
local trail = ImageWriter.blank(8, 16, 0, 0, 0, 0)
|
local trail = ImageWriter.blank(silver and 16 or 8, 16, 0, 0, 0, 0)
|
||||||
blitSprite(trail, trailTiles[1], 0, 0)
|
blitSprite(trail, trailTiles[1], 0, 0)
|
||||||
blitSprite(trail, trailTiles[2], 0, 8)
|
blitSprite(trail, trailTiles[2], 0, 8)
|
||||||
|
if silver then
|
||||||
|
blitSprite(trail, trailTiles[3], 8, 0)
|
||||||
|
blitSprite(trail, trailTiles[4], 8, 8)
|
||||||
|
end
|
||||||
local trailTint = colorize(trail, function() return OBJ_TRAIL end)
|
local trailTint = colorize(trail, function() return OBJ_TRAIL end)
|
||||||
self:save(trailTint, "title/trail.png")
|
self:save(trailTint, "title/trail.png")
|
||||||
self:save(throughRegister(trail, DMG_OBP1), "title/trail_gray.png")
|
self:save(throughRegister(trail, DMG_OBP1), "title/trail_gray.png")
|
||||||
@@ -1908,8 +1974,11 @@ function RomExtractorGen2:extractTitle()
|
|||||||
cloudsGray = "assets/generated/title/clouds_gray.png",
|
cloudsGray = "assets/generated/title/clouds_gray.png",
|
||||||
hoohFramesGray = hoohGrayPaths,
|
hoohFramesGray = hoohGrayPaths,
|
||||||
trailGray = "assets/generated/title/trail_gray.png",
|
trailGray = "assets/generated/title/trail_gray.png",
|
||||||
-- Frameset_GSIntroHoOhLugia (Gold), frame index 1-based + duration frames.
|
-- Frameset_GSIntroHoOhLugia, frame index 1-based + duration frames.
|
||||||
hoohSequence = HOOH_SEQUENCE,
|
hoohSequence = HOOH_SEQUENCE,
|
||||||
|
-- AnimSeq_GSIntroHoOhLugia (engine/sprite_anims/functions.asm:820-838).
|
||||||
|
hoohBobAmplitude = silver and 8 or 2,
|
||||||
|
hoohBobStep = silver and -1 or 1,
|
||||||
-- `depixel 12, 11` (engine/movie/title.asm). Two traps, and the port had
|
-- `depixel 12, 11` (engine/movie/title.asm). Two traps, and the port had
|
||||||
-- fallen into both, which is what put Ho-Oh off-centre:
|
-- fallen into both, which is what put Ho-Oh off-centre:
|
||||||
-- * ldpixel's own comment calls its first tile argument the X one and is
|
-- * ldpixel's own comment calls its first tile argument the X one and is
|
||||||
@@ -1919,19 +1988,45 @@ function RomExtractorGen2:extractTitle()
|
|||||||
-- the cursor two rows up on the box screen. So this is x 88, y 96.
|
-- the cursor two rows up on the box screen. So this is x 88, y 96.
|
||||||
-- * those are OAM coordinates, which are biased; a drawn object sits at
|
-- * those are OAM coordinates, which are biased; a drawn object sits at
|
||||||
-- (x - 8, y - 16) on screen.
|
-- (x - 8, y - 16) on screen.
|
||||||
-- The pose canvas holds its own origin at (32, 24), so the sheet's corner
|
-- The pose canvas holds its own origin, so the sheet's corner is
|
||||||
-- is (88 - 8 - 32, 96 - 16 - 24) -- and the bird's 64px width then lands
|
-- (88 - 8 - originX, 96 - 16 - originY) -- and the pose's width then lands
|
||||||
-- centred on the screen, 48 to 112.
|
-- centred on the screen (Ho-Oh 48..112, Lugia 40..120).
|
||||||
hoohX = 48,
|
hoohX = 80 - originX,
|
||||||
hoohY = 56,
|
hoohY = 80 - originY,
|
||||||
trail = "assets/generated/title/trail.png",
|
trail = "assets/generated/title/trail.png",
|
||||||
copyright = "assets/generated/title/copyright.png",
|
copyright = "assets/generated/title/copyright.png",
|
||||||
copyrightSplash = "assets/generated/title/copyright_splash.png",
|
copyrightSplash = "assets/generated/title/copyright_splash.png",
|
||||||
-- ScrollTitleScreenClouds: Gold decrements the cloud-band SCX every
|
-- ScrollTitleScreenClouds (engine/menus/intro_menu.asm:917-928): Gold
|
||||||
-- 8 vblanks, so the strip slides 1px right. Silver does the same
|
-- decrements the cloud-band SCX every 8 vblanks, so the strip slides 1px
|
||||||
-- decrement every frame.
|
-- right. Silver does the same decrement every frame.
|
||||||
cloudScrollEvery = 8,
|
cloudScrollEvery = silver and 1 or 8,
|
||||||
cloudY = 88,
|
cloudY = 88,
|
||||||
|
-- BG pal 0 colour 2: the sky the widescreen bands have to match.
|
||||||
|
sky = {
|
||||||
|
BG_PALS[1][3][1] / 31, BG_PALS[1][3][2] / 31, BG_PALS[1][3][3] / 31,
|
||||||
|
},
|
||||||
|
-- The fill under the cloud/wave band: Gold's cloud field is BG pal 0
|
||||||
|
-- colour 0 (white), Silver's sea floor is colour 3 (black).
|
||||||
|
below = silver and {
|
||||||
|
BG_PALS[1][4][1] / 31, BG_PALS[1][4][2] / 31, BG_PALS[1][4][3] / 31,
|
||||||
|
} or {
|
||||||
|
BG_PALS[1][1][1] / 31, BG_PALS[1][1][2] / 31, BG_PALS[1][1][3] / 31,
|
||||||
|
},
|
||||||
|
-- UpdateTitleTrailSprite (engine/menus/intro_menu.asm:1069-1124). Silver's
|
||||||
|
-- `depixel 15, 11, 4, 0` is OAM (88, 124), less the bias and the (-16, -8)
|
||||||
|
-- corner .OAMData_GSTitleTrail draws from.
|
||||||
|
trailMode = silver and "silver" or "gold",
|
||||||
|
trailSpawns = silver and { { 72, 100 } } or {
|
||||||
|
{ 80, 88 }, { 104, 88 }, { 104, 88 }, { 120, 88 },
|
||||||
|
{ 120, 88 }, { 88, 88 },
|
||||||
|
},
|
||||||
|
trailSpawnEvery = 4,
|
||||||
|
trailStepX = 4,
|
||||||
|
trailStepY = silver and 0 or 1,
|
||||||
|
-- AnimSeq_GSTitleTrail (functions.asm:784-813) with wIntroSceneTimer 0.
|
||||||
|
trailBobAmplitude = silver and 3 or 2,
|
||||||
|
trailPhaseStep = silver and 7 or 3,
|
||||||
|
trailPhase = silver and 0 or nil,
|
||||||
}
|
}
|
||||||
self:write("title", data)
|
self:write("title", data)
|
||||||
return data
|
return data
|
||||||
@@ -3344,6 +3439,18 @@ function RomExtractorGen2:extractScriptsAndText(maps, stdScripts)
|
|||||||
elseif info.name == "givepoke" then
|
elseif info.name == "givepoke" then
|
||||||
cmd.species, cmd.level, cmd.item, cmd.trainer =
|
cmd.species, cmd.level, cmd.item, cmd.trainer =
|
||||||
args[1], args[2], args[3], args[4]
|
args[1], args[2], args[3], args[4]
|
||||||
|
-- Script_givepoke (engine/overworld/scripting.asm:1806)
|
||||||
|
if size == 8 then
|
||||||
|
local function readAt(lo, hi)
|
||||||
|
local addr = (args[lo] or 0) + (args[hi] or 0) * 0x100
|
||||||
|
if not romAddrOk(bank, addr) then return nil end
|
||||||
|
local okStr, str = pcall(self.rom.readString, self.rom,
|
||||||
|
bank, addr, charmap, 0x50, 16)
|
||||||
|
return okStr and str or nil
|
||||||
|
end
|
||||||
|
cmd.name = readAt(5, 6)
|
||||||
|
cmd.otName = readAt(7, 8)
|
||||||
|
end
|
||||||
elseif info.name == "pokepic" or info.name == "disappear" then
|
elseif info.name == "pokepic" or info.name == "disappear" then
|
||||||
cmd.species = args[1] -- pokepic
|
cmd.species = args[1] -- pokepic
|
||||||
cmd.object = args[1] -- disappear (same byte)
|
cmd.object = args[1] -- disappear (same byte)
|
||||||
@@ -5136,6 +5243,96 @@ function RomExtractorGen2:extractMenuGfx()
|
|||||||
end
|
end
|
||||||
if eggHatch.egg or eggHatch.shell then out.eggHatch = eggHatch end
|
if eggHatch.egg or eggHatch.shell then out.eggHatch = eggHatch end
|
||||||
|
|
||||||
|
-- StatsScreenPageTilesGFX (gfx/font.asm:23), the 17 tiles
|
||||||
|
-- LoadStatsScreenPageTilesGFX lands at vTiles2 $31 (engine/gfx/load_font.asm:90).
|
||||||
|
local hpBarBorder = self.symbols["EnemyHPBarBorderGFX"]
|
||||||
|
if hpBarBorder then
|
||||||
|
local address = hpBarBorder[2] - 17 * 16
|
||||||
|
self:write2bpp(self.rom:bytes(hpBarBorder[1], address, 17 * 16),
|
||||||
|
17 * 8, 8, "menu/stats_tiles.png")
|
||||||
|
out.stats = {
|
||||||
|
sheet = "assets/generated/menu/stats_tiles.png",
|
||||||
|
tiles = 17,
|
||||||
|
firstTile = 0x31,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Goldenrod Game Corner: Slot Machine graphics assets
|
||||||
|
if self.symbols["Slots1LZ"] then
|
||||||
|
local raw1 = self:decompressLz3Symbol("Slots1LZ")
|
||||||
|
self:write2bpp(raw1, 16, #raw1 / 4, "slots/gold_slots_1.png")
|
||||||
|
end
|
||||||
|
if self.symbols["Slots2LZ"] then
|
||||||
|
local raw2 = self:decompressLz3Symbol("Slots2LZ")
|
||||||
|
-- In Pokemon Gold ROM, Seven symbol (first 4 tiles = 64 bytes) has inverted bit polarity
|
||||||
|
for i = 1, math.min(64, #raw2) do
|
||||||
|
raw2[i] = bit.band(bit.bnot(raw2[i]), 0xFF)
|
||||||
|
end
|
||||||
|
self:write2bpp(raw2, 16, #raw2 / 4, "slots/gold_slots_2.png")
|
||||||
|
end
|
||||||
|
if self.symbols["Slots3LZ"] then
|
||||||
|
local raw3 = self:decompressLz3Symbol("Slots3LZ")
|
||||||
|
self:write2bpp(raw3, 24, #raw3 / 6, "slots/gold_slots_3.png", true)
|
||||||
|
-- Slots3LZ is a 24px-wide (3 tiles), 240px-tall (30 tiles) sprite sheet containing:
|
||||||
|
-- Y=0: Golem 1 (Standing, 24x32)
|
||||||
|
-- Y=32: Golem 2 (Ball, 24x32)
|
||||||
|
-- Y=64: Chansey 1 (Standing / Step 1, 24x32)
|
||||||
|
-- Y=96: Chansey 2 (Step 2, 24x32)
|
||||||
|
-- Y=128: Chansey 3 (Step 3, 24x32)
|
||||||
|
-- Y=160: Chansey 4 (Arm raised / Step 4, 24x32)
|
||||||
|
-- Y=192: Chansey 5 (Egg Drop pose, 24x32)
|
||||||
|
-- Y=224: Egg (8x16 at X=0)
|
||||||
|
self:write2bpp(raw3, 24, #raw3 / 6, "slots/gold_slots_actors.png", true)
|
||||||
|
end
|
||||||
|
if self.symbols["SlotsTilemap"] then
|
||||||
|
local symbol = self:symbol("SlotsTilemap")
|
||||||
|
local tm = self.rom:bytes(symbol.bank, symbol.address, 20 * 12)
|
||||||
|
self:save(tm, "slots/gold_slots.tilemap")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Goldenrod Game Corner: Card Flip graphics assets
|
||||||
|
if self.symbols["CardFlipLZ01"] then
|
||||||
|
local raw1 = self:decompressLz3Symbol("CardFlipLZ01")
|
||||||
|
self:write2bpp(raw1, 128, #raw1 / 32, "card_flip/card_flip_1.png")
|
||||||
|
end
|
||||||
|
if self.symbols["CardFlipLZ02"] then
|
||||||
|
local raw2 = self:decompressLz3Symbol("CardFlipLZ02")
|
||||||
|
self:write2bpp(raw2, 24, #raw2 / 6, "card_flip/card_flip_2.png")
|
||||||
|
end
|
||||||
|
if self.symbols["CardFlipLZ03"] then
|
||||||
|
local raw3 = self:decompressLz3Symbol("CardFlipLZ03")
|
||||||
|
self:write2bpp(raw3, 8, #raw3 / 2, "card_flip/card_flip_3.png")
|
||||||
|
end
|
||||||
|
if self.symbols["CardFlipOnButtonGFX"] then
|
||||||
|
local symbol = self:symbol("CardFlipOnButtonGFX")
|
||||||
|
self:write2bpp(self.rom:bytes(symbol.bank, symbol.address, 16), 8, 8, "card_flip/on.png")
|
||||||
|
end
|
||||||
|
if self.symbols["CardFlipOffButtonGFX"] then
|
||||||
|
local symbol = self:symbol("CardFlipOffButtonGFX")
|
||||||
|
self:write2bpp(self.rom:bytes(symbol.bank, symbol.address, 16), 8, 8, "card_flip/off.png")
|
||||||
|
end
|
||||||
|
if self.symbols["CardFlipTilemap"] then
|
||||||
|
local symbol = self:symbol("CardFlipTilemap")
|
||||||
|
local tm = self.rom:bytes(symbol.bank, symbol.address, 11 * 12)
|
||||||
|
self:save(tm, "card_flip/card_flip.tilemap")
|
||||||
|
end
|
||||||
|
|
||||||
|
out.slots = {
|
||||||
|
sheet1 = "assets/generated/slots/gold_slots_1.png",
|
||||||
|
sheet2 = "assets/generated/slots/gold_slots_2.png",
|
||||||
|
sheet3 = "assets/generated/slots/gold_slots_3.png",
|
||||||
|
tilemap = "assets/generated/slots/gold_slots.tilemap",
|
||||||
|
}
|
||||||
|
|
||||||
|
out.cardFlip = {
|
||||||
|
sheet1 = "assets/generated/card_flip/card_flip_1.png",
|
||||||
|
sheet2 = "assets/generated/card_flip/card_flip_2.png",
|
||||||
|
sheet3 = "assets/generated/card_flip/card_flip_3.png",
|
||||||
|
on = "assets/generated/card_flip/on.png",
|
||||||
|
off = "assets/generated/card_flip/off.png",
|
||||||
|
tilemap = "assets/generated/card_flip/card_flip.tilemap",
|
||||||
|
}
|
||||||
|
|
||||||
self:write("menu_gfx", out)
|
self:write("menu_gfx", out)
|
||||||
self:tick("Menu graphics", 1, 1)
|
self:tick("Menu graphics", 1, 1)
|
||||||
return out
|
return out
|
||||||
|
|||||||