Compare commits
74 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 738f7317d7 | |||
| 5e514335c1 | |||
| f5b8b6c85f | |||
| db25c14dfb | |||
| 90163a3ff2 | |||
| f63707c45b | |||
| 7756fdc3cb | |||
| 4bdb9435a4 | |||
| 5cbde96177 | |||
| 03c5a1eddf | |||
| ada0d8abe1 | |||
| dbecc345e3 | |||
| 0f8f6d0e4f | |||
| 1e6613e2de | |||
| debfaf28e6 | |||
| 83463a5a59 | |||
| d66a72ac95 | |||
| 1b659dab01 | |||
| 9e01fe2c2c | |||
| 5fa5005786 | |||
| 1ac5b867bb | |||
| 69ef1bfc77 | |||
| 9ed7e05dc1 | |||
| 25166ff3a1 | |||
| c777e85641 | |||
| 06299328f5 | |||
| 5b19259928 | |||
| c2b6a7b937 | |||
| 7c9c2380d2 | |||
| 2468d5042d | |||
| 51c4766ead | |||
| ec9dc29646 | |||
| 934a4c55ca | |||
| 72592665d7 | |||
| 8f88d01cf2 | |||
| 2279617b29 | |||
| 17fbf6cec4 | |||
| 34c4481f96 | |||
| bff40a5d90 | |||
| 354a8b476d | |||
| f0d3c014a7 | |||
| 0dd889b35b | |||
| 032f894f7f | |||
| 9922e235c6 | |||
| 667267d9bb | |||
| ecdea61cfd | |||
| 872d6b4516 | |||
| 518d61e039 | |||
| 4349a1142f | |||
| b36d38815f | |||
| e24f812475 | |||
| f8ba51636b | |||
| fb97318e87 | |||
| fc83ecd52f | |||
| 5ba49bdf3f | |||
| c01bda3570 | |||
| 74e04cb086 | |||
| 6588901e9a | |||
| 0ea224d5db | |||
| c2e1db0f89 | |||
| 4c13770e70 | |||
| cf45cbbf92 | |||
| 7d1ddf9b7c | |||
| faf82c2cec | |||
| 142d1358dd | |||
| e0e030003b | |||
| ce2afb83f1 | |||
| 28f741f72f | |||
| ea28f886f3 | |||
| 6cd8f0ddea | |||
| fb4eaeda10 | |||
| 69100301a1 | |||
| 114352b75f | |||
| 0e40a7a1f4 |
@@ -12,10 +12,11 @@ name: ci
|
||||
#
|
||||
on:
|
||||
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]
|
||||
# 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:
|
||||
branches: [dev]
|
||||
|
||||
# a force-push while CI is mid-run should cancel the stale run, not queue
|
||||
concurrency:
|
||||
@@ -318,52 +319,10 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
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
|
||||
run: |
|
||||
set -euo pipefail
|
||||
image="dist/linux-arm64/gen1recomp-0.0.0-linux-arm64.AppImage"
|
||||
|
||||
# --appimage-extract needs no FUSE, so this works on a runner
|
||||
# without /dev/fuse and still exercises the real payload.
|
||||
"$image" --appimage-extract >/dev/null
|
||||
for required in AppRun bin/love game.love lib/liblove-11.5.so; do
|
||||
[ -e "squashfs-root/$required" ] \
|
||||
|| { echo "::error::AppImage is missing $required"; exit 1; }
|
||||
done
|
||||
|
||||
# Every bundled object must resolve once AppRun's LD_LIBRARY_PATH is
|
||||
# applied; an unresolved soname here is a user-visible launch crash.
|
||||
#
|
||||
# This runs on a HEADLESS runner on purpose, and that is the point.
|
||||
# The first version of this build bundled Debian's SDL2, which
|
||||
# hard-links libpulse/libasound/libX11/libwayland, so it only ever
|
||||
# started on a full desktop -- a bare runner is what exposed it.
|
||||
missing="$(LD_LIBRARY_PATH="$PWD/squashfs-root/lib" \
|
||||
ldd squashfs-root/bin/love squashfs-root/lib/*.so* 2>/dev/null \
|
||||
| grep 'not found' || true)"
|
||||
[ -z "$missing" ] || { echo "::error::unresolved deps:"; echo "$missing"; exit 1; }
|
||||
|
||||
# Nothing may hard-link a driver, session or audio-stack library:
|
||||
# those must be reached through dlopen so the AppImage runs on a box
|
||||
# with only ALSA, only Wayland, or only KMSDRM.
|
||||
linked="$(for f in squashfs-root/bin/love squashfs-root/lib/*.so*; do
|
||||
objdump -p "$f" 2>/dev/null | awk '/NEEDED/{print $2}'
|
||||
done | sort -u | grep -E '^lib(pulse|asound|X11|wayland|GL|EGL|drm|gbm|xcb|cairo|sndio|dbus)' || true)"
|
||||
[ -z "$linked" ] \
|
||||
|| { echo "::error::these must be dlopened, not linked:"; echo "$linked"; exit 1; }
|
||||
|
||||
# The whole point of compiling on bullseye. If a future change moves
|
||||
# the builder to a newer base, the glibc floor silently rises and
|
||||
# every user on an older distro gets "GLIBC_2.xx not found" -- catch
|
||||
# it here instead of in a release.
|
||||
floor="$(objdump -T squashfs-root/bin/love squashfs-root/lib/*.so* 2>/dev/null \
|
||||
| grep -o 'GLIBC_[0-9.]*' | sort -V | tail -1)"
|
||||
echo "highest required glibc symbol version: $floor"
|
||||
[ -n "$floor" ] \
|
||||
|| { echo "::error::found no versioned glibc symbols -- objdump read nothing"; exit 1; }
|
||||
highest="$(printf '%s\n' "$floor" "GLIBC_2.31" | sort -V | tail -1)"
|
||||
[ "$highest" = "GLIBC_2.31" ] \
|
||||
|| { echo "::error::AppImage requires $floor, above the bullseye 2.31 floor"; exit 1; }
|
||||
run: bash scripts/linux-arm64/verify_appimage.sh dist/linux-arm64/gen1recomp-0.0.0-linux-arm64.AppImage
|
||||
- name: Upload the AppImage
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
@@ -400,7 +359,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- run: sudo apt-get update && sudo apt-get install -y luajit
|
||||
- run: python3 -m pip install --upgrade pillow
|
||||
|
||||
# the fixture PNGs are committed (they are 8x8 placeholders, not
|
||||
@@ -421,13 +379,8 @@ jobs:
|
||||
print(f"\n{len(paths)} fixture assets valid")
|
||||
PY
|
||||
|
||||
# the fingerprint golden is the parity tripwire; prove it still
|
||||
# matches the dataset on a clean checkout
|
||||
- name: fingerprint gate
|
||||
run: luajit tests/engine/gate_fingerprint.lua
|
||||
|
||||
- name: parity-guarantee meta-test
|
||||
run: luajit tests/engine/gate_meta_coverage.lua
|
||||
# the fingerprint parity gates (gate_fingerprint / gate_meta_coverage)
|
||||
# run in the headless job via run_engine; this job only guards the PNGs
|
||||
|
||||
# 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
|
||||
|
||||
@@ -161,6 +161,8 @@ jobs:
|
||||
scripts/build_linux_arm64.sh \
|
||||
--version "${{ needs.version.outputs.version }}" \
|
||||
--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
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
@@ -285,7 +287,7 @@ jobs:
|
||||
retention-days: 1
|
||||
|
||||
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"') }}
|
||||
|
||||
steps:
|
||||
@@ -307,6 +309,15 @@ jobs:
|
||||
name: gen1tls-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
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
run: |
|
||||
@@ -357,14 +368,36 @@ jobs:
|
||||
echo "::error::gen1tls.dll missing at $GEN1TLS_DLL (native-tls-win job)"
|
||||
exit 1
|
||||
fi
|
||||
scripts/build.sh all --version "${{ needs.version.outputs.version }}" --no-notarize
|
||||
scripts/build.sh all --version "${{ needs.version.outputs.version }}" --no-notarize \
|
||||
--game-love dist/payload/game.love
|
||||
unzip -l dist/win/gen1recomp-win64.zip | grep -F gen1tls.dll \
|
||||
|| { echo "::error::Windows zip is missing gen1tls.dll"; exit 1; }
|
||||
|
||||
- name: Build Android
|
||||
- name: Materialize Android release signing key
|
||||
env:
|
||||
KEYSTORE_B64: ${{ secrets.ANDROID_RELEASE_KEYSTORE_B64 }}
|
||||
run: |
|
||||
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
|
||||
run: |
|
||||
@@ -486,8 +519,8 @@ jobs:
|
||||
[ -f "$arm64_appimage" ] || { echo "::error::$arm64_appimage not found (expected from the linux-arm64 job)"; exit 1; }
|
||||
cp "$arm64_appimage" "$outdir/gen1recomp-${v}-linux-arm64.AppImage"
|
||||
chmod +x "$outdir/gen1recomp-${v}-linux-arm64.AppImage"
|
||||
apk="$(find dist/android/debug -name '*.apk' | head -1)"
|
||||
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; }
|
||||
apk="$(find dist/android/release -name '*.apk' | head -1)"
|
||||
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/release"; exit 1; }
|
||||
cp "$apk" "$outdir/gen1recomp-${v}-android.apk"
|
||||
|
||||
ipa="dist/ios/gen1recomp++.ipa"
|
||||
|
||||
@@ -140,17 +140,17 @@ ship text.
|
||||
|
||||
### 4. `games` (and the legacy `gen2compat`)
|
||||
|
||||
Pokemon Gold is Gen 2, and it runs its own battle engine, overworld, script
|
||||
VM and save format. The mod API is shared across both generations (same hook
|
||||
names, same event names, same registry names) but Gold cannot serve all of it
|
||||
yet, so Gen 2 is opt-in. Say which games the mod is for:
|
||||
Pokemon Gold and Silver are Gen 2, and they run their own battle engine,
|
||||
overworld, script VM and save format. The mod API is shared across both
|
||||
generations (same hook names, same event names, same registry names) but Gen 2
|
||||
cannot serve all of it yet, so it is opt-in. Say which games the mod is for:
|
||||
|
||||
```json
|
||||
"games": ["gen1", "gen2"]
|
||||
```
|
||||
|
||||
Each entry is a version id (`"red"`, `"blue"`, `"yellow"`, `"gold"`), a
|
||||
generation (`"gen1"`, `"gen2"`) or `"all"`;
|
||||
Each entry is a version id (`"red"`, `"blue"`, `"yellow"`, `"gold"`,
|
||||
`"silver"`), a generation (`"gen1"`, `"gen2"`) or `"all"`;
|
||||
`src/mods/ModTargets.lua` resolves them off `GameVersion.ORDER` so nothing
|
||||
restates the game list. `python3 tools/modkit.py scaffold my_mod --games
|
||||
gen1,gen2` writes the key for you. The mod still installs to one directory,
|
||||
@@ -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
|
||||
was tested as. `"gen2compat": true` is the legacy spelling, still accepted and
|
||||
purely additive (it *adds* the Gen 2 games), so no manifest can lose a game it
|
||||
already ran on. On a Gold boot a mod claiming no Gen 2 game is not loaded at
|
||||
all: the manager lists it as `ENABLED (NOT THIS GAME)` and says why, because a
|
||||
mod that half-applies reads as a broken mod. Claim Gen 2 once you have actually
|
||||
run your mod on Gold.
|
||||
already ran on. On a Gold or Silver boot a mod claiming no Gen 2 game is not
|
||||
loaded at all: the manager lists it as `ENABLED (NOT THIS GAME)` and says why,
|
||||
because a mod that half-applies reads as a broken mod. Claim Gen 2 once you
|
||||
have actually run your mod on Gold or Silver.
|
||||
|
||||
Every token is enforced, per game: the loader gates on the same
|
||||
`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.
|
||||
|
||||
`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
|
||||
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
|
||||
|
||||
@@ -55,16 +55,15 @@ And before you say, "that's not a recomp", you're wrong. Recomp is an acronym. *
|
||||
|
||||
[](https://youtu.be/yi7LkWQPKKM)
|
||||
|
||||
|
||||
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
|
||||
is the only game content input.
|
||||
or download a disassembly. A canonical US Poke Red, Blue, Yellow, Gold, or
|
||||
Silver ROM is the only game content input.
|
||||
|
||||
The ROM is verified, used during import, and then released from memory. It is
|
||||
not copied into the cache. Later launches load the private generated cache and
|
||||
do not ask for the ROM again. Red, Blue, Yellow, and Gold can all be imported
|
||||
side by side. Gold is Gen 2 Phase 1 (import + launcher; see
|
||||
`docs/gold-phase1.md`): the Gen 2 engine is still under construction.
|
||||
do not ask for the ROM again. Red, Blue, Yellow, Gold, and Silver can all be
|
||||
imported side by side. Gold and Silver are Gen 2 Phase 1 (import + launcher;
|
||||
see `docs/gold-phase1.md`): the Gen 2 engine is still under construction.
|
||||
|
||||
## 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
|
||||
game starts automatically.
|
||||
|
||||
Only the canonical US Red, Blue, Yellow (1 MiB), and Gold (2 MiB) ROMs are
|
||||
accepted. The importer verifies SHA-1 before creating any game data:
|
||||
Only the canonical US Red, Blue, Yellow (1 MiB), Gold, and Silver (2 MiB)
|
||||
ROMs are accepted. The importer verifies SHA-1 before creating any game data:
|
||||
|
||||
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
|
||||
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
|
||||
- Yellow: `cc7d03262ebfaf2f06772c1a480c7d9d5f4a38e1`
|
||||
- Gold: `d8b8a3600a465308c9953dfa04f0081c05bdcb94`
|
||||
- Silver: `49b163f7e57702bc939d642a18f591de55d92dae`
|
||||
|
||||
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
|
||||
@@ -219,7 +219,7 @@ entry: a desktop shortcut per game, a Steam entry, or a handheld frontend.
|
||||
|
||||
| Option | Effect |
|
||||
| --- | --- |
|
||||
| `--game=red` | boot Red, skipping the launcher (`blue` and `yellow` too, or just `r` / `b` / `y`) |
|
||||
| `--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 |
|
||||
| `--launcher` | open the launcher anyway, so you can edit a shortcut you already made |
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 3.1 KiB 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" \
|
||||
main.lua conf.lua src libs data assets tools/save-editor \
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
||||
tools/rom_manifest_silver.json \
|
||||
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
||||
if unzip -Z1 "$WORK/game-payload.zip" \
|
||||
| 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 \
|
||||
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/*')
|
||||
payload_list="$(unzip -Z1 "$WORK/game-payload.zip")"
|
||||
printf '%s\n' "$payload_list" \
|
||||
@@ -99,6 +100,8 @@ printf '%s\n' "$payload_list" \
|
||||
&& fail "payload unexpectedly contains generated ROM data"
|
||||
printf '%s\n' "$payload_list" | grep -qxF "tools/rom_manifest_gold.json" \
|
||||
|| fail "payload is missing tools/rom_manifest_gold.json"
|
||||
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"
|
||||
rm -f "$WORK/game-payload.zip"
|
||||
|
||||
@@ -193,6 +196,26 @@ get_controls
|
||||
[ -f "${controlfolder}/mod_${CFW_NAME}.txt" ] && source "${controlfolder}/mod_${CFW_NAME}.txt"
|
||||
|
||||
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"
|
||||
mkdir -p "$CONFDIR"
|
||||
|
||||
|
||||
@@ -25,8 +25,10 @@
|
||||
local Menu = require("src.ui.Menu")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
|
||||
-- TMNotebookText (data/text/text_2.asm) has no leading underscore, so the
|
||||
-- extractor never collects it and the pamphlet's text is inlined.
|
||||
-- TMNotebookText (data/text/text_2.asm) has no leading underscore, but the
|
||||
-- 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"
|
||||
.. "There are 50 TMs\nin all.\f"
|
||||
.. "There are also 5\nHMs that can be\vused repeatedly.\f"
|
||||
@@ -70,7 +72,8 @@ return {
|
||||
return true
|
||||
end
|
||||
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
|
||||
end
|
||||
return false
|
||||
|
||||
@@ -15,10 +15,10 @@ return {
|
||||
-- pick the dish: bit 7 set (~50%) -> Salmon du Salad, else bit 4
|
||||
-- set (~25%) -> Eels au Barbecue, else (~25%) -> Prime Beef Steak.
|
||||
-- The three dish texts (SSAnneKitchenCook7SalmonDuSaladText /
|
||||
-- ...EelsAuBarbecueText / ...PrimeBeefSteakText) aren't extracted
|
||||
-- into data/generated/text.lua (no leading underscore in
|
||||
-- pokered/text/SSAnneKitchen.asm), so their literal strings are
|
||||
-- ported here verbatim.
|
||||
-- ...EelsAuBarbecueText / ...PrimeBeefSteakText) have no leading
|
||||
-- underscore in pokered/text/SSAnneKitchen.asm, but the extractor
|
||||
-- collects them regardless (tools/extract/text.py); the literals
|
||||
-- below are only the fallback for a catalog without them.
|
||||
TEXT_SSANNEKITCHEN_COOK7 = function(game, ow, npc, done)
|
||||
local t = game.data.text
|
||||
push(game, t._SSAnneKitchenCook7MainCourseIsText
|
||||
@@ -27,13 +27,16 @@ return {
|
||||
local dish
|
||||
if roll <= 2 then
|
||||
-- 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
|
||||
-- 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
|
||||
-- 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
|
||||
push(game, dish, done)
|
||||
end)
|
||||
|
||||
@@ -60,22 +60,23 @@ M.VIRIDIAN_CITY = {
|
||||
-- you want to know about the two kinds of caterpillar Pokemon;
|
||||
-- YES -> CATERPIE/WEEDLE description, NO -> "Oh, OK then!".
|
||||
-- ViridianCityYoungster2OkThenText and
|
||||
-- ViridianCityYoungster2CaterpieAndWeedleDescriptionText are
|
||||
-- defined without a leading underscore in pokered/text/ViridianCity.asm
|
||||
-- and aren't present in data/generated/text.lua, so we fall back to
|
||||
-- the literal strings from pokered. Those fallbacks have to carry the
|
||||
-- extractor's markers, not plain newlines: line -> \n, cont -> \v,
|
||||
-- para -> \f. Spelling cont/para as \n and \n\n put all six lines on
|
||||
-- one page with nothing to wait on, so the whole speech scrolled past
|
||||
-- without a button press (#250).
|
||||
-- ViridianCityYoungster2CaterpieAndWeedleDescriptionText are defined
|
||||
-- without a leading underscore in pokered/text/ViridianCity.asm, but
|
||||
-- tools/extract/text.py now collects them regardless -- the literal
|
||||
-- strings below are only the fallback for a catalog without them.
|
||||
-- Those fallbacks have to carry the extractor's markers, not plain
|
||||
-- newlines: line -> \n, cont -> \v, para -> \f. Spelling cont/para as
|
||||
-- \n and \n\n put all six lines on one page with nothing to wait on,
|
||||
-- so the whole speech scrolled past without a button press (#250).
|
||||
TEXT_VIRIDIANCITY_YOUNGSTER2 = function(game, ow, npc, done)
|
||||
local t = text(game)
|
||||
ask(game, t._ViridianCityYoungster2YouWantToKnowAboutText
|
||||
or "You want to know\nabout the 2 kinds\vof caterpillar\vPOKéMON?", function(yes)
|
||||
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
|
||||
push(game, "Oh, OK then!", done)
|
||||
push(game, t.ViridianCityYoungster2OkThenText or "Oh, OK then!", done)
|
||||
end
|
||||
end)
|
||||
end,
|
||||
|
||||
@@ -38,6 +38,22 @@ local function retryTmGive(game, ow, victoryKey, done)
|
||||
return true
|
||||
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
|
||||
-- EVENT_BEAT_BROCK branches his dialogue. Before the badge he prints
|
||||
-- _PewterGymBrockPreBattleText and engages the leader battle
|
||||
@@ -58,7 +74,8 @@ M.PEWTER_GYM.talk = {
|
||||
game.data.text._PewterGymBrockPostBattleAdviceText
|
||||
or "Go to the GYM in\nCERULEAN and test\nyour abilities!", done))
|
||||
else
|
||||
ow:engageTrainer(npc, done)
|
||||
local text, sound = badgeEndBattleText(game, "OPP_BROCK#1")
|
||||
ow:engageTrainer(npc, done, text, nil, sound)
|
||||
end
|
||||
end,
|
||||
}
|
||||
@@ -91,7 +108,8 @@ local function leaderTalk(beatFlag, adviceLabel, fallback, afterAdvice, victoryK
|
||||
game.stack:push(TextBox.new(game,
|
||||
game.data.text[adviceLabel] or fallback, finish))
|
||||
else
|
||||
ow:engageTrainer(npc, done)
|
||||
local text, sound = badgeEndBattleText(game, victoryKey)
|
||||
ow:engageTrainer(npc, done, text, nil, sound)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -837,13 +837,14 @@ M.SILPH_CO_11F = {
|
||||
-- every Silph rocket leaves off-screen (the street rockets are
|
||||
-- handled by M.SAFFRON_CITY.onEnter in story4.lua). Queued, not
|
||||
-- run here: the battle's own callbacks are still unwinding, so
|
||||
-- queueScript starts it on the first idle overworld frame --
|
||||
-- after the end-battle "Arrgh!!" box victories.lua OPP_GIOVANNI#2
|
||||
-- pushes (#722).
|
||||
-- queueScript starts it on the first idle overworld frame (#722).
|
||||
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
|
||||
ow:queueScript(silphAftermathRows())
|
||||
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))
|
||||
return true
|
||||
|
||||
@@ -216,7 +216,10 @@ local function dojoMasterGate(game, ow, x, y)
|
||||
if not master or ow:trainerDefeated(master) then return false end
|
||||
ow.player.facing = "right"
|
||||
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
|
||||
end
|
||||
|
||||
|
||||
@@ -122,9 +122,8 @@ M.CINNABAR_LAB_METRONOME_ROOM = {
|
||||
-- TM42 Dream Eater (scripts/ViridianCity.asm, the fisher). The fisher's
|
||||
-- YouCanHaveThisText prints before GiveItem, so this gift needs a pre
|
||||
-- text (#775). Like the SilphCo2F worker (#393) that label carries no
|
||||
-- leading underscore, and on Red it sits outside the extractor's symbol
|
||||
-- set, so the literal from text/ViridianCity.asm rides along as the
|
||||
-- fallback; Yellow resolves the ROM string instead.
|
||||
-- leading underscore; tools/extract/text.py now collects it regardless,
|
||||
-- so preFallback below is just the safety net for a catalog without it.
|
||||
M.VIRIDIAN_CITY = {
|
||||
talk = {
|
||||
TEXT_VIRIDIANCITY_FISHER = gift({
|
||||
@@ -146,9 +145,11 @@ M.SILPH_CO_2F = {
|
||||
talk = {
|
||||
TEXT_SILPHCO2F_SILPH_WORKER_F = gift({
|
||||
flag = "EVENT_GOT_TM36", item = "TM_SELFDESTRUCT",
|
||||
-- the label carries no leading underscore: pokered keeps this one in
|
||||
-- the script bank, not the far-text bank (#393)
|
||||
-- the label carries no leading underscore (#393); collected like any
|
||||
-- other text/*.asm label now, preFallback is just the safety net
|
||||
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",
|
||||
explain = "_SilphCo2FSilphWorkerFTM36ExplanationText",
|
||||
noRoom = "_SilphCo2FSilphWorkerFTM36NoRoomText",
|
||||
@@ -646,27 +647,29 @@ end
|
||||
local rocketRows = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "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
|
||||
{ "jump_if_true", 9 }, -- 5
|
||||
{ "jump_if_true", 10 }, -- 5
|
||||
{ "show_text", "_CeruleanCityRocketText" }, -- 6
|
||||
{ "start_battle", "trainer", "OPP_ROCKET", 5 }, -- 7
|
||||
{ "jump_if_false", "end" }, -- 8
|
||||
{ "show_text", "_CeruleanCityRocketIllReturnTheTMText" }, -- 9
|
||||
{ "set_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 10
|
||||
{ "give_item", "TM_DIG", 1, false }, -- 11 (row 13 prints)
|
||||
{ "set_flag", "EVENT_GOT_TM28" }, -- 12
|
||||
{ "show_text", "_CeruleanCityRocketReceivedTM28Text" }, -- 13
|
||||
{ "show_text", "_CeruleanCityRocketIBetterGetMovingText" }, -- 14
|
||||
{ "fade", "out" }, -- 15 GBFadeOutToBlack
|
||||
-- scripts/CeruleanCity.asm:297 SaveEndBattleTextPointers
|
||||
{ "save_end_battle_text", "_CeruleanCityRocketIGiveUpText" }, -- 7
|
||||
{ "start_battle", "trainer", "OPP_ROCKET", 5 }, -- 8
|
||||
{ "jump_if_false", "end" }, -- 9
|
||||
{ "show_text", "_CeruleanCityRocketIllReturnTheTMText" }, -- 10
|
||||
{ "set_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 11
|
||||
{ "give_item", "TM_DIG", 1, false }, -- 12 (row 14 prints)
|
||||
{ "set_flag", "EVENT_GOT_TM28" }, -- 13
|
||||
{ "show_text", "_CeruleanCityRocketReceivedTM28Text" }, -- 14
|
||||
{ "show_text", "_CeruleanCityRocketIBetterGetMovingText" }, -- 15
|
||||
{ "fade", "out" }, -- 16 GBFadeOutToBlack
|
||||
-- CeruleanHideRocket while black: GUARD1 (28,12) appears, GUARD2
|
||||
-- (27,12) and the ROCKET go. GUARD2 blocks the trashed-house south
|
||||
-- door neighbour -- the swap reconnects the city (Bill's ticket does
|
||||
-- the same in story.lua; either route is enough).
|
||||
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 16
|
||||
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 17
|
||||
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_ROCKET" }, -- 18
|
||||
{ "fade", "in" }, -- 19 GBFadeInFromBlack
|
||||
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 17
|
||||
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 18
|
||||
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_ROCKET" }, -- 19
|
||||
{ "fade", "in" }, -- 20 GBFadeInFromBlack
|
||||
}
|
||||
|
||||
M.CERULEAN_CITY = {
|
||||
|
||||
@@ -105,8 +105,9 @@ trixie.
|
||||
This is a statement about the *compile environment*, not about where the
|
||||
artifact runs — building on your own newer distro would silently raise that
|
||||
floor and strand every user on an older one, with no symptom until they
|
||||
download it. CI enforces the floor: `linux-arm64-build` fails if the highest
|
||||
required glibc symbol version climbs above 2.31.
|
||||
download it. `scripts/linux-arm64/verify_appimage.sh` enforces the floor in
|
||||
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
|
||||
|
||||
@@ -172,13 +173,15 @@ Three jobs, path-gated on `scripts/build_linux_arm64.sh`,
|
||||
exclude list still classifies known sonames correctly, that AppRun still
|
||||
launches `game.love` with `--fused`, and that the host-arch guard actually
|
||||
fires. Needs no container and no arm64 machine.
|
||||
- **`linux-arm64-build`** (`ubuntu-24.04-arm`) — the real build, then extracts
|
||||
the artifact and asserts the layout, that every bundled object resolves
|
||||
under AppRun's `LD_LIBRARY_PATH`, and that the glibc floor is still ≤ 2.31.
|
||||
Uploads the AppImage for 7 days.
|
||||
- **`linux-arm64-build`** (`ubuntu-24.04-arm`) — the real build, then
|
||||
`scripts/linux-arm64/verify_appimage.sh` extracts the artifact and asserts
|
||||
the layout, that every bundled object resolves under AppRun's
|
||||
`LD_LIBRARY_PATH`, and that the glibc floor is still ≤ 2.31. Uploads the
|
||||
AppImage for 7 days.
|
||||
- **release** — `linux-arm64` runs on `ubuntu-24.04-arm`, reuses the shared
|
||||
`game.love` from the `love-payload` job, and the AppImage is staged and
|
||||
published like every other release asset.
|
||||
`game.love` from the `love-payload` job, runs the same
|
||||
`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
|
||||
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"`,
|
||||
`"gold"`), generations (`"gen1"`, `"gen2"`, case-insensitive) or `"all"`.
|
||||
`src/mods/ModTargets.lua` resolves the tokens off `GameVersion.ORDER` and
|
||||
`GameVersion.generation`, so nothing anywhere restates the game list.
|
||||
`"gold"`, `"silver"`), generations (`"gen1"`, `"gen2"`, case-insensitive) or
|
||||
`"all"`. `src/mods/ModTargets.lua` resolves the tokens off `GameVersion.ORDER`
|
||||
and `GameVersion.generation`, so nothing anywhere restates the game list.
|
||||
`"gen2"` now expands to both Gold and Silver.
|
||||
`Manifest.validate` stores the resolved, ORDER-sorted ids on `manifest.games`
|
||||
and **derives** `manifest.gen2compat` from them, which is the one field the
|
||||
loader's gate reads.
|
||||
@@ -541,8 +542,9 @@ gains a field instead of the name gaining a prefix.
|
||||
`battle.crit`, `battle.accuracy`, `battle.turn_order`,
|
||||
`battle.enemy_action`, `battle.run`, `battle.exp_award`, `exp.gain`,
|
||||
`catch.rate`, `trainer.party`, `battle.overlay`, `battle.low_health_alarm`,
|
||||
`battle.catch_exp`, `battle.bottom_ui_visible` and
|
||||
`battle.status_hud_visible`. One payload difference: Gen 1's vanilla
|
||||
`battle.catch_exp`, `battle.bottom_ui_visible`,
|
||||
`battle.status_hud_visible` and `battle.move_grid_navigation`. One payload
|
||||
difference: Gen 1's vanilla
|
||||
`battle.low_health_alarm` link reads `ctx.battle.data`, and Gold's battle
|
||||
screen has no `.data` field, so the Gen 2 site **adds** `ctx.data` beside the
|
||||
Gen 1 keys. A mod that calls `nextFn` is unaffected; one that reaches through
|
||||
|
||||
@@ -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"`). |
|
||||
| `profile` | `string` | Mod profile: `"content"`, `"overhaul"`, or `"total_conversion"`. |
|
||||
| `category` | `string` | Categorization chip (e.g. `"GAMEPLAY"`, `"CONTENT"`, `"UI"`, `"AUDIO"`). |
|
||||
| `games` | `array` | Supported game versions: `["gen1"]`, `["gen2"]`, `["red"]`, `["blue"]`, `["yellow"]`, `["gold"]`, or `["all"]`. |
|
||||
| `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"`). |
|
||||
| `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. |
|
||||
|
||||
@@ -11,10 +11,13 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow
|
||||
* **Persistent custom options** stored separately from game saves
|
||||
* **Optional widescreen battle layout**
|
||||
* **Mobile touch controls** with editable layouts, vibration, and orientation settings
|
||||
* **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**
|
||||
* **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
|
||||
|
||||
* **Pokémon Silver** as an importable, launcher-selectable version alongside Gold
|
||||
* **Mod manager** with Gen 1 mod adapters, per-game targeting, and `modkit gen2check`
|
||||
* **Followers** for mods, plus Gen 2-only registries and hooks
|
||||
|
||||
@@ -176,7 +176,7 @@ something the filesystem encodes.
|
||||
|
||||
| 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) |
|
||||
| `"all"` | every game this engine has |
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# What This Port Requires
|
||||
|
||||
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`
|
||||
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
|
||||
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:
|
||||
|
||||
- the ROM symbol addresses actually read by the extractor
|
||||
|
||||
@@ -91,14 +91,14 @@ Do **not** launch from the Album applet path for normal play.
|
||||
|
||||
This project ships **no** game data. On first launch:
|
||||
|
||||
1. Put your own legally obtained Pokémon Red, Blue (`.gb`), Yellow, or
|
||||
Gold (`.gbc`) dump into `switch/gen1recomp/pokemon-love2d/imports/` (the
|
||||
launcher also shows the live save-dir path). All four can sit in the
|
||||
1. Put your own legally obtained Pokémon Red, Blue (`.gb`), Yellow, Gold, or
|
||||
Silver (`.gbc`) dump into `switch/gen1recomp/pokemon-love2d/imports/` (the
|
||||
launcher also shows the live save-dir path). All five can sit in the
|
||||
same folder.
|
||||
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
|
||||
imports from the Yellow tab (and vice versa). Gold is Beta in the
|
||||
launcher; a clean US Gold dump is enough to Play.
|
||||
2. Use **Scan again** on that game's tab (Red / Blue / Yellow / Gold /
|
||||
Silver). Rescan matches by ROM SHA-1 for the open tab only. A Red dump
|
||||
never imports from the Yellow tab (and vice versa). Gold and Silver are
|
||||
Beta in the launcher; a clean US dump of either is enough to Play.
|
||||
|
||||
## 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/` |
|
||||
| Yellow | `imports/saves/yellow/` | `exports/yellow/` |
|
||||
| Gold | `imports/saves/gold/` | `exports/gold/` |
|
||||
| Silver | `imports/saves/silver/` | `exports/silver/` |
|
||||
|
||||
(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
|
||||
MTP browsing matches the other games. Gold progress still saves in-engine.)
|
||||
Gold and Silver cart `.sav` import/export is not supported yet -- the folders
|
||||
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
|
||||
([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 |
|
||||
| ROM inbox | LÖVE save dir → `imports/` (launcher shows the live `getSaveDirectory()` path; under MTP often `1: SD Card/<save identity>/imports/`) |
|
||||
| Mod zip inbox | Same save dir → `imports/mods/` then MODS → **Scan again** |
|
||||
| Save `.sav` inbox | Same save dir → `imports/saves/red\|blue\|yellow\|gold/` then that game's SAVE FILES → **Import save** (Gold 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 `.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\|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` |
|
||||
| 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
|
||||
(or copy NRO / `game.love` for loose).
|
||||
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.
|
||||
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
|
||||
(`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-<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.
|
||||
|
||||
A release missing either asset is treated as "no in-place update available":
|
||||
`Check` reports `needs_full` and sends the player to `Check.releaseUrl()`
|
||||
(`https://github.com/bryanthaboi/gen1recomp/releases/latest`).
|
||||
`Check` reports `needs_full`. It also selects the exact current platform asset
|
||||
from the same release and persists the requirement, so it is visible again on
|
||||
every launch, including offline launches.
|
||||
|
||||
## Save-directory layout
|
||||
|
||||
@@ -85,6 +87,7 @@ Under the save directory (identity `pokemon-love2d`):
|
||||
```
|
||||
updates/gen1recomp-<X.Y.Z>.love downloaded payload(s)
|
||||
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.
|
||||
@@ -106,7 +109,8 @@ bundled game, in that case.
|
||||
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()`
|
||||
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
|
||||
worker to fetch the payload, polling the growing `.part` file for
|
||||
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
|
||||
player relaunches; the next launch's Boot step (1) is what actually
|
||||
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
|
||||
|
||||
@@ -140,6 +152,13 @@ bundled game, in that case.
|
||||
still need a full reinstall (`minShell` / `payloadHost` gate →
|
||||
`needs_full`). Applying a downloaded payload on Android relaunches via
|
||||
`love.system.restartApp`; iOS still uses in-process `quit("restart")`.
|
||||
- **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
|
||||
`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
|
||||
|
||||
@@ -327,6 +327,7 @@ local function returnToLauncher()
|
||||
if love.audio and love.audio.stop then
|
||||
pcall(love.audio.stop)
|
||||
end
|
||||
pcall(function() require("src.render.SecondScreen").setEnabled(false) end)
|
||||
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local currentVersion = GameVersion.get()
|
||||
@@ -386,11 +387,12 @@ function bootGame(version)
|
||||
love.window.setTitle(Version.title(
|
||||
GameVersion.info().displayName .. " (Gen 1 Recompilation Project)"))
|
||||
end
|
||||
-- Gold: Gen 1 Game:load cannot consume a Gen 2 cache -- different generated
|
||||
-- tables, save shape and screen registry -- so Gold boots its own service
|
||||
-- owner, which mounts src/world/gen2 (walk / warps / connections) and the
|
||||
-- Gen 2 screens instead of src/core/Game.lua's Gen 1 wiring.
|
||||
if GameVersion.isGold() then
|
||||
-- Gen 2: Gen 1 Game:load cannot consume a Gen 2 cache -- different generated
|
||||
-- tables, save shape and screen registry -- so Gold and Silver boot their
|
||||
-- own service owner, which mounts src/world/gen2 (walk / warps /
|
||||
-- connections) and the Gen 2 screens instead of src/core/Game.lua's Gen 1
|
||||
-- wiring.
|
||||
if GameVersion.generation() == 2 then
|
||||
Game = require("src.core.Game2").new()
|
||||
Game:load()
|
||||
else
|
||||
|
||||
@@ -93,8 +93,9 @@ transport, exactly as a missing curl does.
|
||||
love-android 11.5a expects:
|
||||
|
||||
- **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)
|
||||
- **minSdk 19** (Android 4.4), **targetSdk 36** (Android 16)
|
||||
|
||||
Set `ANDROID_SDK_ROOT` (or `ANDROID_HOME`), or let the script write
|
||||
`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/`,
|
||||
`libs/` (the vendored FlexLove toolkit the launcher UI needs), `data/`,
|
||||
`assets/`, and the Red, Blue, and Yellow ROM manifests. The Android
|
||||
packer verifies the Yellow manifest before it packages; if a partial source
|
||||
export omitted it, it restores the file from this checkout's Git data and then
|
||||
falls back to the project's GitHub copy. Generated game data,
|
||||
`assets/`, and the Red, Blue, Yellow, Gold, and Silver ROM manifests. The
|
||||
Android packer verifies the Yellow, Gold, and Silver manifests before it
|
||||
packages; if a partial source export omitted one, it restores the file from
|
||||
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.
|
||||
|
||||
## 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.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.version_name` / `app.version_code` | set from `--version X.Y.Z` (code = major*10000 + minor*100 + 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 |
|
||||
| `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; REQUEST_INSTALL_PACKAGES is limited to the user-confirmed full-update installer |
|
||||
|
||||
## Releases
|
||||
|
||||
`.github/workflows/release.yml` builds the APK with `--version` set to the
|
||||
release version and publishes it alongside the macOS/Windows/Linux builds as
|
||||
`PokemonRed-<version>-android.apk`.
|
||||
`gen1recomp-<version>-android.apk`.
|
||||
|
||||
## 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
|
||||
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:
|
||||
|
||||
```
|
||||
|
||||
@@ -10,9 +10,12 @@ android {
|
||||
applicationId project.properties["app.application_id"]
|
||||
versionCode project.properties["app.version_code"].toInteger()
|
||||
versionName project.properties["app.version_name"]
|
||||
minSdk 16
|
||||
compileSdk 34
|
||||
targetSdk 34
|
||||
// NDK r25 no longer supports API 16; API 19 is Android 4.4 and keeps
|
||||
// the native toolchain and package-installer bridge on a supported ABI.
|
||||
minSdk 19
|
||||
// Android 16 / API 36: current Android distribution target.
|
||||
compileSdk 36
|
||||
targetSdk 36
|
||||
|
||||
def getAppName = {
|
||||
def nameArray = project.properties["app.name_byte_array"]
|
||||
@@ -38,10 +41,31 @@ android {
|
||||
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 {
|
||||
release {
|
||||
minifyEnabled true
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
if (hasReleaseSigning) signingConfig signingConfigs.release
|
||||
}
|
||||
}
|
||||
flavorDimensions = ['mode', 'recording']
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
the link screen shows as "(Operation not permitted)" (issue #287).
|
||||
scripts/build_android.sh must not strip this again. -->
|
||||
<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
|
||||
counter, which Android 10+ gates behind this runtime permission.
|
||||
Requested only on the first sync call (the Pokéwalker mod's SYNC
|
||||
@@ -35,6 +39,18 @@
|
||||
<meta-data
|
||||
android:name="android.allow_multiple_resumed_activities"
|
||||
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
|
||||
android:name="org.love2d.android.GameActivity"
|
||||
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_yellow">#FDD835</color>
|
||||
<color name="shortcut_gold">#D4AF37</color>
|
||||
<color name="shortcut_silver">#BEC6D2</color>
|
||||
</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()
|
||||
}
|
||||
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
|
||||
// in the individual module build.gradle files
|
||||
|
||||
@@ -15,7 +15,6 @@ app.version_name=11.5a
|
||||
# No need to modify anything past this line!
|
||||
android.enableJetifier=false
|
||||
android.useAndroidX=true
|
||||
android.defaults.buildfeatures.buildconfig=true
|
||||
android.nonTransitiveRClass=true
|
||||
android.nonFinalResIds=true
|
||||
app.name=gen1recomp
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
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
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
|
||||
@@ -10,9 +10,9 @@ android {
|
||||
ndkVersion '25.2.9519653'
|
||||
|
||||
defaultConfig {
|
||||
minSdk 16
|
||||
compileSdk 34
|
||||
targetSdk 34
|
||||
minSdk 19
|
||||
compileSdk 36
|
||||
targetSdk 36
|
||||
externalNativeBuild {
|
||||
ndkBuild {
|
||||
arguments "-j" + Runtime.runtime.availableProcessors()
|
||||
@@ -63,8 +63,7 @@ android {
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled true
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
minifyEnabled false
|
||||
}
|
||||
debug {
|
||||
ndk {
|
||||
|
||||
@@ -283,6 +283,40 @@ bool restartApp()
|
||||
return result;
|
||||
}
|
||||
|
||||
bool installApk(const char *path)
|
||||
{
|
||||
if (path == nullptr || path[0] == '\0')
|
||||
return false;
|
||||
|
||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||
// This may be called from Lua's main thread, but use the activity object
|
||||
// class just like httpDownload so a future worker caller does not depend on
|
||||
// the system JNI class loader finding the app class.
|
||||
void *rawActivity = SDL_AndroidGetActivity();
|
||||
if (rawActivity == nullptr)
|
||||
return false;
|
||||
jobject activityObj = (jobject) rawActivity;
|
||||
jclass activity = env->GetObjectClass(activityObj);
|
||||
env->DeleteLocalRef(activityObj);
|
||||
|
||||
jmethodID method = env->GetStaticMethodID(activity, "installApk",
|
||||
"(Ljava/lang/String;Ljava/lang/String;)Z");
|
||||
if (method == nullptr)
|
||||
{
|
||||
env->ExceptionClear();
|
||||
env->DeleteLocalRef(activity);
|
||||
return false;
|
||||
}
|
||||
|
||||
jstring jpath = env->NewStringUTF(path);
|
||||
jstring jroot = env->NewStringUTF(bridgeSaveDirectory());
|
||||
jboolean result = env->CallStaticBooleanMethod(activity, method, jpath, jroot);
|
||||
env->DeleteLocalRef(jroot);
|
||||
env->DeleteLocalRef(jpath);
|
||||
env->DeleteLocalRef(activity);
|
||||
return result;
|
||||
}
|
||||
|
||||
bool updateAppShortcuts(const std::vector<std::string> &versions)
|
||||
{
|
||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||
|
||||
@@ -90,6 +90,12 @@ bool syncHealthSteps();
|
||||
**/
|
||||
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.
|
||||
**/
|
||||
|
||||
@@ -245,6 +245,16 @@ bool System::restartApp() const
|
||||
#endif
|
||||
}
|
||||
|
||||
bool System::installApk(const char *path) const
|
||||
{
|
||||
#ifdef LOVE_ANDROID
|
||||
return love::android::installApk(path);
|
||||
#else
|
||||
LOVE_UNUSED(path);
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool System::updateShortcuts(const std::vector<std::string> &versions) const
|
||||
{
|
||||
#ifdef LOVE_ANDROID
|
||||
|
||||
@@ -143,6 +143,9 @@ public:
|
||||
**/
|
||||
virtual bool restartApp() const;
|
||||
|
||||
/** Starts Android's user-confirmed install flow for a verified APK. */
|
||||
virtual bool installApk(const char *path) const;
|
||||
|
||||
virtual bool updateShortcuts(const std::vector<std::string> &versions) const;
|
||||
virtual std::string getLaunchGame() const;
|
||||
|
||||
|
||||
@@ -132,6 +132,13 @@ int w_restartApp(lua_State *L)
|
||||
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)
|
||||
{
|
||||
const char *url = luaL_checkstring(L, 1);
|
||||
@@ -325,6 +332,7 @@ static const luaL_Reg functions[] =
|
||||
{ "createFile", w_createFile },
|
||||
{ "syncHealthSteps", w_syncHealthSteps },
|
||||
{ "restartApp", w_restartApp },
|
||||
{ "installApk", w_installApk },
|
||||
{ "updateShortcuts", w_updateShortcuts },
|
||||
{ "getLaunchGame", w_getLaunchGame },
|
||||
{ "httpDownload", w_httpDownload },
|
||||
|
||||
@@ -45,6 +45,7 @@ import android.app.AlarmManager;
|
||||
import android.app.AlertDialog;
|
||||
import android.app.PendingIntent;
|
||||
import android.content.Context;
|
||||
import android.content.ClipData;
|
||||
import android.content.DialogInterface;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
@@ -77,6 +78,7 @@ import android.view.*;
|
||||
|
||||
import androidx.annotation.Keep;
|
||||
import androidx.core.app.ActivityCompat;
|
||||
import androidx.core.content.FileProvider;
|
||||
|
||||
public class GameActivity extends SDLActivity {
|
||||
private static DisplayMetrics metrics = null;
|
||||
@@ -398,11 +400,15 @@ public class GameActivity extends SDLActivity {
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
secondaryHostResumed = false;
|
||||
if (vibrator != null) {
|
||||
Log.d("GameActivity", "Cancelling vibration");
|
||||
vibrator.cancel();
|
||||
}
|
||||
unregisterSecondaryDisplayListener();
|
||||
teardownSecondaryDisplay();
|
||||
secondaryEnabled = false;
|
||||
synchronized (secondaryFrameLock) { secondaryFrame = null; }
|
||||
unregisterAudioDeviceCallback();
|
||||
abandonAudioFocus();
|
||||
onHostDestroy();
|
||||
@@ -411,6 +417,7 @@ public class GameActivity extends SDLActivity {
|
||||
|
||||
@Override
|
||||
protected void onPause() {
|
||||
secondaryHostResumed = false;
|
||||
if (vibrator != null) {
|
||||
Log.d("GameActivity", "Cancelling vibration");
|
||||
vibrator.cancel();
|
||||
@@ -426,6 +433,7 @@ public class GameActivity extends SDLActivity {
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
secondaryHostResumed = true;
|
||||
onHostResume();
|
||||
requestGameAudioFocus();
|
||||
registerAudioDeviceCallback();
|
||||
@@ -690,6 +698,103 @@ public class GameActivity extends SDLActivity {
|
||||
return true; // unreachable, but keeps the JNI signature honest
|
||||
}
|
||||
|
||||
/**
|
||||
* Stages a verified release APK in cache and asks Android's Package
|
||||
* Installer to update this package. This never silently installs an APK:
|
||||
* the platform owns both the unknown-sources consent and final install
|
||||
* confirmation. `updateRoot` comes from the native save directory and is
|
||||
* checked before any file is read, so a Lua caller cannot turn this into a
|
||||
* general-purpose local-file sharing bridge.
|
||||
*/
|
||||
@Keep
|
||||
public static boolean installApk(final String sourcePath, final String updateRoot) {
|
||||
final GameActivity self = (GameActivity) mSingleton;
|
||||
if (self == null || sourcePath == null || updateRoot == null) return false;
|
||||
final File source;
|
||||
try {
|
||||
source = new File(sourcePath).getCanonicalFile();
|
||||
File root = new File(updateRoot, "updates").getCanonicalFile();
|
||||
String rootPath = root.getPath() + File.separator;
|
||||
if (!source.getPath().startsWith(rootPath)
|
||||
|| !source.isFile() || source.length() == 0
|
||||
|| !source.getName().matches("gen1recomp-[0-9]+\\.[0-9]+\\.[0-9]+-android\\.apk")) {
|
||||
return false;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
Log.d("GameActivity", "invalid update APK path: " + e.getMessage());
|
||||
return false;
|
||||
}
|
||||
|
||||
// Android 8+ lets the user decide whether this app is trusted to
|
||||
// request package installs. Send them to the per-app setting first;
|
||||
// they deliberately tap Install again after granting it.
|
||||
if (android.os.Build.VERSION.SDK_INT >= 26
|
||||
&& !self.getPackageManager().canRequestPackageInstalls()) {
|
||||
try {
|
||||
Intent settings = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
|
||||
Uri.parse("package:" + self.getPackageName()));
|
||||
self.startActivity(settings);
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
Log.d("GameActivity", "could not open install-source settings: " + e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Copying an APK can be large; keep both I/O and checksum-verified
|
||||
// source access off the UI thread. The FileProvider exposes this cache
|
||||
// child only after it has been fully written and renamed.
|
||||
new Thread(new Runnable() {
|
||||
@Override public void run() {
|
||||
File stagedDir = new File(self.getCacheDir(), "full-update");
|
||||
File partial = new File(stagedDir, "update.apk.part");
|
||||
File staged = new File(stagedDir, "update.apk");
|
||||
try {
|
||||
if (!stagedDir.exists() && !stagedDir.mkdirs()) return;
|
||||
copyFile(source, partial);
|
||||
if (staged.exists() && !staged.delete()) return;
|
||||
if (!partial.renameTo(staged)) return;
|
||||
self.runOnUiThread(new Runnable() {
|
||||
@Override public void run() { launchPackageInstaller(self, staged); }
|
||||
});
|
||||
} catch (Exception e) {
|
||||
Log.d("GameActivity", "could not stage update APK: " + e.getMessage());
|
||||
} finally {
|
||||
if (partial.exists()) partial.delete();
|
||||
}
|
||||
}
|
||||
}, "gen1recomp-apk-stage").start();
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void copyFile(File source, File destination) throws IOException {
|
||||
InputStream in = new BufferedInputStream(new FileInputStream(source));
|
||||
OutputStream out = new BufferedOutputStream(new FileOutputStream(destination));
|
||||
try {
|
||||
byte[] buffer = new byte[32768];
|
||||
int count;
|
||||
while ((count = in.read(buffer)) != -1) out.write(buffer, 0, count);
|
||||
} finally {
|
||||
try { out.close(); } catch (IOException ignored) {}
|
||||
try { in.close(); } catch (IOException ignored) {}
|
||||
}
|
||||
}
|
||||
|
||||
private static void launchPackageInstaller(GameActivity activity, File apk) {
|
||||
try {
|
||||
Context context = activity.getApplicationContext();
|
||||
Uri uri = FileProvider.getUriForFile(context,
|
||||
context.getPackageName() + ".full_update_provider", apk);
|
||||
Intent install = new Intent(Intent.ACTION_INSTALL_PACKAGE);
|
||||
install.setData(uri);
|
||||
install.setClipData(ClipData.newRawUri("apk", uri));
|
||||
install.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||
activity.startActivity(install);
|
||||
} catch (Exception e) {
|
||||
Log.d("GameActivity", "could not open package installer: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Keep
|
||||
public static String getLaunchGame() {
|
||||
return initialGame != null ? initialGame : "";
|
||||
@@ -1933,6 +2038,7 @@ public class GameActivity extends SDLActivity {
|
||||
private static volatile int secondaryActivityTarget = Display.INVALID_DISPLAY;
|
||||
private static volatile long secondaryRetryAfter;
|
||||
private static volatile boolean secondaryEnabled = false;
|
||||
private static volatile boolean secondaryHostResumed = false;
|
||||
private static volatile int secondaryTarget = SECONDARY_TARGET_AUTO;
|
||||
private static volatile int dualScreenDisplayMode = -1;
|
||||
private static volatile byte[] secondaryFrame;
|
||||
@@ -1963,7 +2069,7 @@ public class GameActivity extends SDLActivity {
|
||||
if (self == null) return;
|
||||
self.runOnUiThread(new Runnable() {
|
||||
@Override public void run() {
|
||||
if (on) {
|
||||
if (on && secondaryHostResumed) {
|
||||
self.refreshDualScreenDisplayMode();
|
||||
self.registerSecondaryDisplayListener();
|
||||
rebindSecondaryDisplay();
|
||||
@@ -2035,9 +2141,11 @@ public class GameActivity extends SDLActivity {
|
||||
|
||||
private static void rebindSecondaryDisplay() {
|
||||
GameActivity self = (GameActivity) mSingleton;
|
||||
if (self == null || !secondaryEnabled || secondaryOutputIsPreferred(self)) return;
|
||||
if (self == null || !secondaryHostResumed || !secondaryEnabled
|
||||
|| secondaryOutputIsPreferred(self)) return;
|
||||
self.runOnUiThread(() -> {
|
||||
if (!secondaryEnabled || secondaryOutputIsPreferred(self)) return;
|
||||
if (!secondaryHostResumed || !secondaryEnabled
|
||||
|| secondaryOutputIsPreferred(self)) return;
|
||||
teardownSecondaryDisplay();
|
||||
setupSecondaryDisplay();
|
||||
});
|
||||
@@ -2045,7 +2153,8 @@ public class GameActivity extends SDLActivity {
|
||||
|
||||
private static void setupSecondaryDisplay() {
|
||||
GameActivity self = (GameActivity) mSingleton;
|
||||
if (self == null || !secondaryEnabled || secondaryPresentation != null
|
||||
if (self == null || !secondaryHostResumed || !secondaryEnabled
|
||||
|| secondaryPresentation != null
|
||||
|| secondaryActivity != null || secondaryActivityPending
|
||||
|| android.os.SystemClock.elapsedRealtime() < secondaryRetryAfter) return;
|
||||
try {
|
||||
|
||||
@@ -12,6 +12,132 @@
|
||||
"tintColor": "3b5ca8",
|
||||
"category": "games",
|
||||
"versions": [
|
||||
{
|
||||
"version": "0.2.15",
|
||||
"date": "2026-08-21",
|
||||
"size": 13753007,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.15/gen1recomp++-0.2.15-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1589 Player sprite still blinking on the Town Map\n- #1595 Pokedex completion certificate is not displaying correctly\n- #1597 Link cable looks broken\n- #1613 Visual differences in Pokemon trades\n- #1619 Save Menu cutting off and Put into another place\n\n## Contributors\n\n- @1Jamie\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.2.14",
|
||||
"date": "2026-08-20",
|
||||
"size": 13749372,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.14/gen1recomp++-0.2.14-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.2.13",
|
||||
"date": "2026-08-20",
|
||||
"size": 13749377,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.13/gen1recomp++-0.2.13-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1474 (Pokémon Gold) Pokédex mode not being saved\n- #1475 (Pokémon Gold) Vital Throw attacking before the opponent\n- #1477 (Pokémon Gold) Map when using fly keeps the arrow from the pokégear\n- #1478 (Pokémon Gold) Wrong move layout in battles\n- #1479 [Gold] tiles broken at Route 28 and Mt Silver\n- #1482 (Pokémon Gold) Nothing happens when trying to use Coin Case\n- #1488 Pokemon EXP calculation after trading went back to issue #984\n- #1510 Title Screen Transition is Missing\n- #1511 Broken Trainer Rival Name Layout\n- #1512 Gen 2 Post battle interactions don't exist\n- #1514 Health damage timing\n- #1516 Move learning timing\n- #1521 Different Item Menu\n- #1522 Wrong Save Game Layout\n- #1545 Pikachu not sliding out when switching Pokemon.\n- #1557 (Gold) Impossible to get TM 03 in Celadon Mansion at night\n- #1558 (Gold) Missing colors and symbol in stats screen\n- #1563 Player mon pic cuts away instead of shrinking when recalled\n- #1565 Thrash doesn't lock in when the first use misses\n- #1566 Center PC missing the PKMN LEAGUE entry after the Hall of Fame\n- #1569 Goldenrod Gift Spearow Bugged\n- #1577 Thrash has no animation past the first turn\n- #1578 Hitting yourself in confusion animation missing\n- #1579 Missing dialogue for Cerulean City Rocket\n- #1594 Pokemon menu closing too soon when using rare candy\n- #1596 Evolution dialogue & missing jingle\n- #1606 Misty dialogue issue\n- #1608 \"There's no will to fight!\" message issues\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.2.12",
|
||||
"date": "2026-08-20",
|
||||
"size": 13737259,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.12/gen1recomp++-0.2.12-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1582 Sync not working between steamdeck and windows\n- #1583 Can’t sync between iOS and windows\n\n## Contributors\n\n- @AverageConsumer\n- @bryanthaboi\n- @thibautbus"
|
||||
},
|
||||
{
|
||||
"version": "0.2.11",
|
||||
"date": "2026-08-20",
|
||||
"size": 13735190,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.11/gen1recomp++-0.2.11-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #393 silph co. npc missing some dialogue\n- #1600 allow my uncle's neighbor to sit at the big kids table\n- #1603 pocket taco - type option \"screen position\"\n\n## Contributors\n\n- @1Jamie\n- @bryanthaboi\n- @dburton95\n- @mleo2003\n- @thibautbus"
|
||||
},
|
||||
{
|
||||
"version": "0.2.10",
|
||||
"date": "2026-08-19",
|
||||
"size": 13662645,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.10/gen1recomp++-0.2.10-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #998 Jingles not finishing before game proceeds\n- #1472 Message for sending out Pokemon not closing automatically\n- #1526 No screen shake when getting poisoned\n- #1529 Messages missing when interacting with PC\n- #1530 No message for interacting with bikes in the bike shop\n- #1532 Thrash animation incomplete\n- #1534 Dialogue missing when switching out Pokemon\n- #1547 Save states can be used to bypass certain NPCs\n- #1549 Menu Cartridge 3D model has visual issues\n- #1550 Nugget Bridge Rocket repeating dialogue\n- #1551 No scripted dialogue after beating Nugget Bridge Rocket\n\n## Contributors\n\n- @bryanthaboi\n- @castdrian"
|
||||
},
|
||||
{
|
||||
"version": "0.2.9",
|
||||
"date": "2026-08-19",
|
||||
"size": 13656293,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.9/gen1recomp++-0.2.9-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.2.8",
|
||||
"date": "2026-08-19",
|
||||
"size": 13653911,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.8/gen1recomp++-0.2.8-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1502 Gold doesn't show trainer balls\n- #1533 Retroarch Skin Problem 2 (#1503)\n\n## Contributors\n\n- @1Jamie\n- @AverageConsumer\n- @bryanthaboi\n- @castdrian\n- @thibautbus"
|
||||
},
|
||||
{
|
||||
"version": "0.2.7",
|
||||
"date": "2026-08-18",
|
||||
"size": 13597177,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.7/gen1recomp++-0.2.7-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1005 (Android) Screen record mutes the game\n- #1291 Audio Crash\n- #1310 Incoming call crashes G1R\n- #1471 [Gold] #1117 still not fixed\n- #1528 Surfing Minigame doesn't play as intended\n- #1537 Shellder and Corsola missing from Rod encounter tables\n\n## Contributors\n\n- @1Jamie\n- @bryanthaboi\n- @castdrian"
|
||||
},
|
||||
{
|
||||
"version": "0.2.6",
|
||||
"date": "2026-08-18",
|
||||
"size": 13589036,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.6/gen1recomp++-0.2.6-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1393 [Launcher -> Mods] Only the pages that you manually clicked to are used for sorting\n- #1418 (Pokémon Gold) Framerate and void fill options missing\n- #1430 [Gold] Shop ui off because of a border\n- #1519 Poison damage after battle inconsistent\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.2.5",
|
||||
"date": "2026-08-18",
|
||||
"size": 13586158,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.5/gen1recomp++-0.2.5-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1390 switch and gold\n- #1503 Retroarch Skin Problem\n- #1508 Please check #1412 & #1414 again, we had a misunderstanding\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.2.4",
|
||||
"date": "2026-08-18",
|
||||
"size": 13582747,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.4/gen1recomp++-0.2.4-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1496 Investigate Security according to https://hdbreaker.github.io/blog/pokemon-gen1recomp-hate-cheat/\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.2.3",
|
||||
"date": "2026-08-18",
|
||||
"size": 13579254,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.3/gen1recomp++-0.2.3-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1497 skin studio needs a import file picker babyyyyyy\n\n## Contributors\n\n- @anxiousintrovert\n- @AverageConsumer\n- @bryanthaboi\n- @thibautbus"
|
||||
},
|
||||
{
|
||||
"version": "0.2.2",
|
||||
"date": "2026-08-18",
|
||||
"size": 13575387,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.2/gen1recomp++-0.2.2-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @AverageConsumer\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.2.1",
|
||||
"date": "2026-08-17",
|
||||
"size": 13575320,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.1/gen1recomp++-0.2.1-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"date": "2026-08-17",
|
||||
"size": 13575299,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.0/gen1recomp++-0.2.0-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1396 Nurse dialogue & options\n- #1398 Alignment of options for changing Pokemon\n- #1400 Flying Bug\n- #1401 [Gold] battlergfx $d9/$da load the wrong row count (jumptable crossed vs macro names)\n- #1406 Magikarp salesman dialogue issues\n- #1407 Not able to nickname Magikarp\n- #1411 No indication for stone evolutions\n- #1413 Using a stone closes menu\n- #1415 Super Nerd dialogue issues\n- #1416 (Pokémon Gold) Pokédex doesn't register other trainers' pokémon as seen\n- #1417 (Pokémon Gold) Pokémon you get in trade aren't being registered as caught\n- #1419 (Pokémon Gold) Deposited pokémon don't get healed\n- #1421 (Pokémon Gold) Bad status and catch state appears on the HUD before they should\n- #1422 (Pokémon Gold) Impossible to have the pokédex register Ditto as caught after it transforms\n- #1423 (Pokémon Gold) No save prompt before changing boxes in the PC\n- #1424 (Pokémon Gold) Quantity for owned TMs not being displayed\n- #1425 (Pokémon Gold) Items quantity in your bag should be alligned to the right\n- #1427 (Pokémon Gold) Can't switch items' position in your bag\n- #1428 (Pokémon Gold) Game doesn't show how many pokémon other trainers have\n- #1429 Pikachu not sliding in before its cry. Stuck on standard pokeball release animation.\n- #1431 Shiny sparkle does not play on your sent out shiny pokemon\n- #1432 Experimental marked mods don't install Android\n- #1433 (Pokémon Gold) Missing prompt for depositing pokémon\n- #1435 When npcs stop you to talk or when you walk up to npcs to talk to them sometimes the player has the wrong sprite\n- #1437 Issues with player sprite on map\n- #1440 hold a direction during cutscene and face the wrong way\n- #1441 Magnet Train missing animation\n- #1442 Radio dial is missing in PokeGear radio\n- #1443 Skipping production logo also skips battle scene\n- #1444 Pokemon lack type immunity to status moves\n- #1447 Soft-lock on Cinnabar Island\n- #1449 Visual error on Route 28\n- #1456 Activating all mods doesn't work properly\n- #1461 #1265 didnt got fixed.\n- #1464 Experiance shared in battle\n- #1465 Changing Touch Layout crashes launcher\n- #1466 #1403 Still Happens\n- #1467 A clearer definition of the use of AI for this reconstruction\n- #1468 [Gold] BICYCLE is broken and some pokegear bug\n- #1469 [Gold] status effects aren't shown in the party overlay or the summary screen of the pokemon\n- #1470 Mod updater doesn't work properly when AppImage is running through Steam or Game Mode (Steam Deck)\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.99",
|
||||
"date": "2026-08-17",
|
||||
"size": 11391467,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.99/gen1recomp++-0.1.99-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #597 Pulling mod index fails on Android\n- #1403 Save editor not allowing moves to go past ZAP_CANNON\n\n## Contributors\n\n- @1Jamie\n- @AverageConsumer\n- @bryanthaboi\n- @emre155\n- @sanjinpepic\n- @ShaneMcGovernIE\n- @syybott\n- @thibautbus"
|
||||
},
|
||||
{
|
||||
"version": "0.1.98",
|
||||
"date": "2026-08-16",
|
||||
"size": 11380117,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.98/gen1recomp++-0.1.98-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1181 Poison seems to trigger twice during the poisoned pokemon's turn\n- #1211 S.S. Anne Visual bug when it's sailing away\n- #1212 the move payday does not grant money in gen 2\n- #1214 Title Screen with OG Red is the wrong color\n- #1224 Windowed and borderless toggle in Gold\n- #1228 No option to nickname starter\n- #1229 Encounter rate grace period not working\n- #1230 Couple of sound effects missing\n- #1231 Using Tackle partially distorts battle sprites\n- #1232 Wild pokemon's sprite disappears early when using a pokeball\n- #1249 Cant use stat items IE HP UP PP UP PROTIEN\n- #1251 You don't have a COIN CASE\n- #1265 Major: Regression from #984 (probably?)\n- #1267 [GOLD] POKEDEX didn't show pokemon appear area\n- #1269 [Gold] shadow ball should be invert the screen\n- #1271 [Gold] substitute image broken/not shown\n- #1272 [Gold] swift still checks accuracy and/or evasion\n- #1273 S.S. Anne Issues\n- #1276 Nurse back to not bowing (and turning)\n- #1279 Rival still not looking at player when initiating first fight\n- #1282 PKMN league PC option missing\n- #1293 Dig animation is bugged in-battle\n- #1296 Opponent's moves failing\n- #1298 Gen1 sound tracks have a fade in period, if you enter a route and immediately exit it while this transition is going on it will land on the wrong music\n- #1301 Pixels aren't square\n- #1303 Animation speed of walking NPCs too slow\n- #1305 Wrong Pikachu cry when getting defeated\n- #1307 Rival theme broken after initial fight in Yellow\n- #1318 Thunder Wave works on Ground-types\n- #1328 Message for turning on the PC missing\n- #1329 Name Select Background\n- #1330 Message before looking at map missing\n- #1331 Messages in Oak's lab missing\n- #1333 E-mail in Oak's lab missing\n- #1334 Missing message after picking starter\n- #1335 No Money Box\n- #1338 Rival's sister missing dialogue and roaming\n- #1340 Color palett doesn't affect attack animations\n- #1341 Pokedex entries look wrong\n- #1343 No dashes in empty attack slots during fights\n- #1344 Town Map not showing player sprite\n- #1345 Wrong health color on OG palett\n- #1346 Health still black when viewing stats\n- #1360 No Surfing Music\n- #1362 Poison damage does not flash the screen\n- #1368 Fishing Rods behaving irregularly\n- #1385 Team Rocket Hideouts missing music\n- #1388 Safeguard targets opponent, not user\n- #1389 Gastly unobtainable\n- #1391 NPC not escorting player to museum\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.97",
|
||||
"date": "2026-08-16",
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#
|
||||
# Usage: scripts/build.sh [mac|win|linux|android|ios|all] [--version X.Y.Z] [--identity "Developer ID Application: ..."]
|
||||
# [--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
|
||||
#
|
||||
# Output: dist/mac/gen1recomp-macos.zip
|
||||
@@ -36,6 +37,7 @@ NOTARY_PROFILE="notary-profile"
|
||||
NOTARIZE=true
|
||||
IOS_RELEASE=false
|
||||
IOS_IPA=false
|
||||
GAME_LOVE_IN=""
|
||||
|
||||
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
|
||||
@@ -48,6 +50,7 @@ while [ $# -gt 0 ]; do
|
||||
--identity) IDENTITY="$2"; shift ;;
|
||||
--notary-profile) NOTARY_PROFILE="$2"; shift ;;
|
||||
--no-notarize) NOTARIZE=false ;;
|
||||
--game-love) GAME_LOVE_IN="${2:?--game-love needs a path}"; shift ;;
|
||||
--release) IOS_RELEASE=true ;;
|
||||
--ipa) IOS_IPA=true ;;
|
||||
*) 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
|
||||
# `--editor` / POKEPORT_EDITOR=1 opens it standalone. It is required through
|
||||
# love.filesystem's require path, so it has to live inside the archive.
|
||||
say "packing game.love"
|
||||
LOVE_FILE="$WORK/game.love"
|
||||
rm -f "$LOVE_FILE"
|
||||
# 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" \
|
||||
if [ -n "$GAME_LOVE_IN" ]; then
|
||||
[ -f "$GAME_LOVE_IN" ] || fail "--game-love: no such file: $GAME_LOVE_IN"
|
||||
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 \
|
||||
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/*')
|
||||
fi
|
||||
# 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
|
||||
# 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 \
|
||||
src/ui/kit/Kit.lua \
|
||||
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" \
|
||||
|| fail "game.love is missing $required"
|
||||
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
|
||||
# build fails if it did not take.
|
||||
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"
|
||||
stamp_dir="$WORK/stamp"
|
||||
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\"" \
|
||||
|| fail "version stamp failed: game.love does not report engine $VERSION"
|
||||
say "stamped engine version: $VERSION"
|
||||
fi
|
||||
else
|
||||
say "version '$VERSION' is not X.Y.Z, shipping default engine (no stamp)"
|
||||
fi
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
# 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)
|
||||
# --release build the production-signed release APK (requires the
|
||||
# GEN1RECOMP_ANDROID_* signing environment variables)
|
||||
# --package-only zip game.love + apply branding; skip gradle
|
||||
#
|
||||
# Prerequisites:
|
||||
# - 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
|
||||
#
|
||||
# Output (after gradle):
|
||||
# dist/android/debug/*.apk (convenience copy)
|
||||
# mobile/android/app/build/outputs/apk/embedNoRecord/debug/*.apk
|
||||
# dist/android/debug/*.apk (normal local build) or dist/android/release/*.apk
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -26,13 +27,17 @@ APP_NAME="gen1recomp"
|
||||
APPLICATION_ID="com.theboisclub.pokemonred"
|
||||
LOVE_ANDROID_VERSION="11.5a"
|
||||
NDK_VERSION="25.2.9519653"
|
||||
ANDROID_API="36"
|
||||
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}"
|
||||
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}"
|
||||
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=""
|
||||
PACKAGE_ONLY=false
|
||||
RELEASE=false
|
||||
|
||||
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
|
||||
@@ -42,11 +47,12 @@ while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--version) VERSION="$2"; shift ;;
|
||||
--package-only) PACKAGE_ONLY=true ;;
|
||||
--release) RELEASE=true ;;
|
||||
-h|--help)
|
||||
sed -n '2,20p' "$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
|
||||
shift
|
||||
done
|
||||
@@ -60,7 +66,22 @@ if [ -n "$VERSION" ]; then
|
||||
rest="${VERSION#*.}"
|
||||
minor="${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
|
||||
|
||||
# --------------------------------------------------------------- preconditions
|
||||
@@ -177,6 +198,54 @@ ensure_gold_manifest() {
|
||||
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
|
||||
# love-android 11.5+ reads app id / name / orientation from gradle.properties.
|
||||
# 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"
|
||||
ensure_yellow_manifest
|
||||
ensure_gold_manifest
|
||||
ensure_silver_manifest
|
||||
mkdir -p "$EMBED_ASSETS"
|
||||
rm -f "$LOVE_FILE"
|
||||
# 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 \
|
||||
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' -x '*/.git/*' -x '*/.DS_Store' \
|
||||
-x 'data/generated/*' -x 'assets/generated/*')
|
||||
# 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"
|
||||
grep -qx 'tools/rom_manifest_gold.json' <<< "$archive_entries" \
|
||||
|| 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
|
||||
# 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
|
||||
@@ -335,13 +408,18 @@ require_android_sdk() {
|
||||
export ANDROID_SDK_ROOT=\$HOME/Library/Android/sdk
|
||||
or create mobile/android/local.properties with:
|
||||
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)."
|
||||
fi
|
||||
|
||||
export ANDROID_SDK_ROOT="$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"
|
||||
# Always rewrite so a leftover Docker sdk.dir=/opt/android-sdk cannot stick.
|
||||
printf 'sdk.dir=%s\n' "$sdk" > "$props"
|
||||
@@ -358,7 +436,12 @@ require_android_sdk() {
|
||||
|
||||
# --------------------------------------------------------------- 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"
|
||||
|
||||
# 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"
|
||||
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
|
||||
say "APK output:"
|
||||
find "$out_dir" -name '*.apk' -exec ls -lh {} \;
|
||||
|
||||
local dist_dir="$DIST/debug"
|
||||
local dist_dir="$DIST/$variant"
|
||||
rm -rf "$dist_dir"
|
||||
mkdir -p "$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"
|
||||
grep -qxF "tools/rom_manifest_gold.json" "$WORK/love-listing.txt" \
|
||||
|| 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
|
||||
# .DirIcon is what appimaged and file-manager thumbnailers read.
|
||||
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"
|
||||
grep -qxF "tools/rom_manifest_gold.json" "$temp_dir/love-listing.txt" \
|
||||
|| 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"
|
||||
|
||||
@@ -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 \
|
||||
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/*')
|
||||
if [ -f "$ROOT/PATCH_NOTES.md" ]; then
|
||||
(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/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
||||
tools/rom_manifest_silver.json \
|
||||
src/ui/kit/Kit.lua \
|
||||
src/import/LauncherView.lua; do
|
||||
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" ] \
|
||||
&& [ ! -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"
|
||||
fi
|
||||
|
||||
|
||||
@@ -71,15 +71,15 @@ First install or update (same steps):
|
||||
your saves, imported ROMs, mods, and options. Re-extracting only
|
||||
replaces the NRO(s) and these help files.
|
||||
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/
|
||||
then use Scan again in the launcher if needed.
|
||||
|
||||
Inboxes (drop files here via MTP / SD / FTP):
|
||||
imports/ — ROM .gb / .gbc
|
||||
imports/mods/ — community mod .zip
|
||||
imports/saves/red|blue|yellow|gold/ — raw .sav import (Gold cart .sav not yet)
|
||||
exports/red|blue|yellow|gold/ — pull after Export save (Gold not yet)
|
||||
imports/saves/red|blue|yellow|gold|silver/ — raw .sav import (Gold/Silver cart .sav 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
|
||||
EOF
|
||||
@@ -92,7 +92,7 @@ write_readme() {
|
||||
}
|
||||
|
||||
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" \
|
||||
"Put community mod .zip files here, then MODS → Scan again."
|
||||
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."
|
||||
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."
|
||||
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" \
|
||||
"After Export save (Red), copy the .sav out of this folder via MTP / SD / FTP."
|
||||
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."
|
||||
write_readme "$SAVE_ROOT/exports/gold/README.txt" \
|
||||
"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"
|
||||
(
|
||||
@@ -140,10 +144,12 @@ REQUIRED=(
|
||||
"switch/gen1recomp/pokemon-love2d/imports/saves/blue/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/silver/README.txt"
|
||||
"switch/gen1recomp/pokemon-love2d/exports/red/README.txt"
|
||||
"switch/gen1recomp/pokemon-love2d/exports/blue/README.txt"
|
||||
"switch/gen1recomp/pokemon-love2d/exports/yellow/README.txt"
|
||||
"switch/gen1recomp/pokemon-love2d/exports/gold/README.txt"
|
||||
"switch/gen1recomp/pokemon-love2d/exports/silver/README.txt"
|
||||
)
|
||||
for rel in "${REQUIRED[@]}"; do
|
||||
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/yellow/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/blue/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
|
||||
printf '%s\n' "$ZIP_LIST" | grep -Fq "$rel" || PACK_MISSING="${PACK_MISSING} ${rel}"
|
||||
done
|
||||
|
||||
@@ -67,6 +67,7 @@ run_tier() {
|
||||
# ------- 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 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 transfer docs gate" "$LUA" tests/switch_transfer_docs_test.lua
|
||||
# NX Blue/Yellow asset overlay: ROM-free, must run on every checkout so a
|
||||
|
||||
@@ -8,6 +8,7 @@ local BattleSafety = {}
|
||||
local BATTLE_BUSY_FIELDS = {
|
||||
"current", "afterQueue", "nextInsert", "pendingHit", "waitingUI",
|
||||
"waitingSound", "waitFrames", "draining", "animPlaying", "growIn",
|
||||
"shrinkOut",
|
||||
"introSlide", "ghostReveal", "mimicCtx", "mimicMoves", "result",
|
||||
}
|
||||
|
||||
|
||||
@@ -1364,7 +1364,7 @@ function BattleState:updateQueue()
|
||||
-- subanimation (or just the coarse fx when animations are off).
|
||||
-- item.hit carries the target's blink + damage sound, applied when
|
||||
-- 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
|
||||
-- PlayMoveAnimation writes wAnimationID, calls Delay3, and only then
|
||||
-- jumps to MoveAnimation (core.asm:6635-6640), so three frames pass
|
||||
@@ -1718,9 +1718,14 @@ function BattleState:enter()
|
||||
-- _PlayerBlackedOutText2 (data/text/text_2.asm:896): the two paragraphs
|
||||
-- playerMonFainted queues on the battle screen; there is no battle
|
||||
-- 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,
|
||||
Strings("%s is out of\nuseable POKéMON!", name) .. "\f"
|
||||
.. Strings("%s blacked\nout!", name), blackedOut))
|
||||
self:romText("_PlayerBlackedOutText2",
|
||||
"%s is out of\nuseable POKéMON!\f%s blacked\nout!", name, name),
|
||||
blackedOut))
|
||||
return
|
||||
end
|
||||
self.musicKind = self:computeMusicKind()
|
||||
@@ -1857,7 +1862,8 @@ function BattleState:enter()
|
||||
self:slidePic("foe")
|
||||
end)
|
||||
-- _TrainerSentOutText ends `done`, not `prompt` (data/text/text_2.asm:923)
|
||||
self:sayAuto(Strings("%s sent\nout %s!", foeName, self.enemy.name))
|
||||
self:sayAuto(self:romText("_TrainerSentOutText", "%s sent\nout %s!",
|
||||
foeName, self.enemy.name))
|
||||
self:act(function()
|
||||
-- EnemySendOutFirstMon (core.asm:1421-1434): after the text the
|
||||
-- pic grows out of the ball (AnimateSendingOutMon), then the cry
|
||||
@@ -2489,9 +2495,12 @@ function BattleState:openOldManBag()
|
||||
-- POKé BALLs; pokeyellow's SimulatedInputBattleItemList, shared by
|
||||
-- the Viridian tutorial and Oak's catch, has one.
|
||||
local qty = require("src.core.GameVersion").isYellow() and "x1" or "x50"
|
||||
-- the tutorial bag rides DisplayBagMenu's LIST_MENU_BOX over the battle
|
||||
-- screen (engine/battle/core.asm:2210)
|
||||
list = ListMenu.new(game, "ITEMS", {
|
||||
{ value = "POKE_BALL", label = Strings("POKé BALL"), right = qty },
|
||||
}, {
|
||||
itemBox = true,
|
||||
script = function(l)
|
||||
l.scriptTimer = (l.scriptTimer or 0) + 1
|
||||
if l.scriptTimer == 81 then
|
||||
@@ -2702,9 +2711,11 @@ function BattleState:resolveSwitch(newMon)
|
||||
self.afterQueue = "menu"
|
||||
self:act(function()
|
||||
-- SwitchPlayerMon (core.asm:2419-2423): RetreatMon prints over the
|
||||
-- outgoing pic and holds 50 frames before the mon is recalled
|
||||
-- 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
|
||||
local previous = self.player
|
||||
@@ -3316,6 +3327,26 @@ function BattleState:queueSendOutAnim(append)
|
||||
if append then self:act(fn) else self:actNext(fn) 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
|
||||
-- the drawn bar color: DrawPlayerHUDAndHPBar (core.asm:1846-1875) sets
|
||||
-- wLowHealthAlarm bit 7 when GetHealthBarColor says the player bar is
|
||||
@@ -3532,6 +3563,12 @@ function BattleState:updateFx()
|
||||
self.growIn.frame = self.growIn.frame + 1
|
||||
if self.growIn.frame >= 12 then self.growIn = nil 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
|
||||
-- loops while the player's bar is red; see lowHealthAlarmActive
|
||||
local Sound = require("src.core.Sound")
|
||||
@@ -3638,12 +3675,13 @@ function BattleState:executeAction(user, target, action)
|
||||
})
|
||||
self.aiUses = self:aiUsesFor()
|
||||
markSeen(self.game, self.enemy.mon.species)
|
||||
-- _AIBattleWithdrawText: "X with-/drew Y!"
|
||||
self:sayNext(Strings("%s with-\ndrew %s!", self.trainer.name, oldName))
|
||||
self:sayNext(self:romText("_AIBattleWithdrawText", "%s with-\ndrew %s!",
|
||||
self.trainer.name, oldName))
|
||||
-- EnemySendOut falls into EnemySendOutFirstMon: TrainerSentOutText,
|
||||
-- then AnimateSendingOutMon and PlayCry (core.asm:1276-1434)
|
||||
self.enemySendingOut = true
|
||||
self:sayNextAuto(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.enemySendingOut = false
|
||||
self:startGrowIn(self.enemy)
|
||||
@@ -3795,6 +3833,8 @@ function BattleState:statusInterrupt(user, target, selectedId)
|
||||
{ rng = self.rng, forceCrit = false, typeless = true,
|
||||
screens = target })
|
||||
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:applyDamage(user, dmg)
|
||||
if user.mon.hp <= 0 then self:onFaint(user) end
|
||||
@@ -3887,25 +3927,38 @@ function BattleState:performMove(user, target, moveInst, isCalled)
|
||||
end
|
||||
|
||||
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))
|
||||
-- the move's animation plays right after the announcement; the
|
||||
-- damage path attaches the target's hit blink to this row so the
|
||||
-- blink follows the animation (pokered's order). Mimic is the
|
||||
-- exception (announceAnim = false): PlayCurrentMoveAnimation runs
|
||||
-- only after a successful copy, never on a miss -- applyMimic queues it
|
||||
end
|
||||
-- PlayCurrentMoveAnimation follows the announcement; Mimic (announceAnim
|
||||
-- = false) queues it from applyMimic after a successful copy
|
||||
if not (record and record.announceAnim == false) then
|
||||
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)
|
||||
end
|
||||
end
|
||||
Runtime.emit("battle.move_used", {
|
||||
battle = self, user = user, target = target, move = move,
|
||||
isCalled = isCalled or false,
|
||||
})
|
||||
|
||||
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
|
||||
-- already said its failure text
|
||||
@@ -4142,8 +4195,12 @@ function BattleState:onFaint(battler)
|
||||
-- acknowledged core.asm:797-798 bug.)
|
||||
self:actNext(function() self:playVictoryMusic() end)
|
||||
end
|
||||
-- _EnemyMonFaintedText "Enemy X fainted!" / _PlayerMonFaintedText
|
||||
self:sayNext(Strings("%s\nfainted!", displayName(battler)))
|
||||
-- _EnemyMonFaintedText already carries its own "Enemy" wording, so this
|
||||
-- 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
|
||||
self:act(function() self:playerMonFainted() end)
|
||||
else
|
||||
@@ -4174,8 +4231,11 @@ function BattleState:awardExp()
|
||||
end
|
||||
local function applyShare(mon, split, announce)
|
||||
local playerId = self.game.save.player and self.game.save.player.id
|
||||
local traded = mon.traded == true
|
||||
or (mon.otId ~= nil and playerId ~= nil and mon.otId ~= playerId)
|
||||
-- GainExperience (engine/battle/experience.asm:69-88) compares the
|
||||
-- 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,
|
||||
self.enemy.mon.level, self.kind == "trainer",
|
||||
split, traded)
|
||||
@@ -4319,22 +4379,44 @@ function BattleState:enemyMonFainted()
|
||||
-- "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" /
|
||||
-- "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))
|
||||
-- EnemySendOutFirstMon .next9/.next8 (core.asm:1390-1409) and
|
||||
-- HasMonFainted's NoWillText (core.asm:1473-1488)
|
||||
self:sayChoice(
|
||||
Strings("Will %s\nchange POKéMON?", self.game.save.player.name),
|
||||
function(yes)
|
||||
if not yes then return end
|
||||
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,
|
||||
party = self:playerPartyView(),
|
||||
forceSwitch = true,
|
||||
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
|
||||
end
|
||||
end,
|
||||
})
|
||||
}
|
||||
Screens.push(game, "PartyMenu", shiftOpts)
|
||||
end, { box = Theme.trainerSwitchBox })
|
||||
end
|
||||
self:act(function()
|
||||
@@ -4362,7 +4444,8 @@ function BattleState:enemyMonFainted()
|
||||
-- (AnimateSendingOutMon) with the cry; no POOF -- that animation
|
||||
-- belongs to the player-side SendOutMon (core.asm:1757-1762)
|
||||
self.enemySendingOut = true
|
||||
self:sayNextAuto(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.enemySendingOut = false
|
||||
self:startGrowIn(self.enemy)
|
||||
@@ -4376,10 +4459,11 @@ function BattleState:enemyMonFainted()
|
||||
local mon = shiftSwitchMon
|
||||
if not mon then return end
|
||||
-- SwitchPlayerMon (core.asm:2419-2423): RetreatMon, the 50-frame
|
||||
-- hold, then the recall and the send-out
|
||||
-- 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
|
||||
self.player = makeBattler(self.data, mon, true, self.game.save)
|
||||
@@ -4451,9 +4535,22 @@ function BattleState:enemyMonFainted()
|
||||
-- TrainerNamePointers aims those entries at wTrainerName). The tag
|
||||
-- prints once, so a `para` page carries no second copy (#566).
|
||||
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
|
||||
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
|
||||
end
|
||||
end
|
||||
@@ -4878,7 +4975,8 @@ function BattleState:storeCaughtMon()
|
||||
-- text_promptbutton (item_effects.asm:624-629), so the fanfare follows
|
||||
-- the box rather than firing when the dex bit is set
|
||||
self:sayNextWaitSfx(
|
||||
Strings("New POKéDEX data\nwill be added for\n%s!", self.enemy.name),
|
||||
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)
|
||||
self:uiNext(function()
|
||||
return self:buildScreen("DexEntryMenu", species)
|
||||
@@ -4899,9 +4997,12 @@ function BattleState:storeCaughtMon()
|
||||
if boxNum then
|
||||
askCaughtNickname()
|
||||
-- _ItemUseBallText07/08 keyed on EVENT_MET_BILL
|
||||
local pc = (game.save.flags and game.save.flags.EVENT_MET_BILL)
|
||||
and "BILL's PC" or Strings("someone's PC")
|
||||
self:sayNext(Strings("%s was\ntransferred to\n%s!", self.enemy.name, pc))
|
||||
local metBill = game.save.flags and game.save.flags.EVENT_MET_BILL
|
||||
self:sayNext(self:romText(
|
||||
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
|
||||
self:sayNext(Strings("But every BOX\nis full!"))
|
||||
end
|
||||
@@ -5007,8 +5108,18 @@ function BattleState:throwBall(ball)
|
||||
-- RESTLESS SOUL dodges balls even once the scope has revealed it,
|
||||
-- so it is not a ghost battle any more (#444)
|
||||
self:animNext(self:tossAnimFor(ball), true, nil, ball)
|
||||
self:sayNext(Strings("It dodged the\nthrown BALL!"))
|
||||
self:sayNext(Strings("This POKéMON\ncan't be caught!"))
|
||||
-- _ItemUseBallText00 is one label for both lines, \f-paged. Unlike
|
||||
-- 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:executeAction(self.enemy, self.player, self:enemyAction())
|
||||
end)
|
||||
@@ -5055,10 +5166,14 @@ function BattleState:openParty()
|
||||
battle = self,
|
||||
party = self:playerPartyView(),
|
||||
onSwitch = function(mon)
|
||||
-- PartyMenuOrRockOrRun's SWITCH .partyMonDeselected (core.asm:2396-2408)
|
||||
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
|
||||
self:say(self:romText("_NoWillText", "There's no will\nto fight!"))
|
||||
self:act(function() self:openParty() end)
|
||||
else
|
||||
self:resolveSwitch(mon)
|
||||
end
|
||||
@@ -5202,6 +5317,16 @@ function BattleState:growInScale(battler)
|
||||
return f < 3 and 0 or f < 7 and 3 / 7 or 5 / 7
|
||||
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)
|
||||
--
|
||||
-- AnimationBlinkMon hides the pic, waits DelayFrames 5, shows it, waits
|
||||
@@ -5838,15 +5963,18 @@ function BattleState:drawPicsLayer(slide, sx, sy, onlySide, skipMenuClip)
|
||||
local s = BattleState.resolveBattleScale(self.data, "back",
|
||||
imagePathOf(self.player.sprite),
|
||||
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
|
||||
-- the player-side AnimateSendingOutMon grow (after the poof,
|
||||
-- core.asm:1757-1762): feet pinned at y=96, horizontal centre
|
||||
-- pinned, mod scale composed with the grow stage
|
||||
-- the player-side AnimateSendingOutMon grow (core.asm:1757-1762) and
|
||||
-- the AnimateRetreatingPlayerMon shrink (core.asm:1769-1796)
|
||||
local eff = s * gs
|
||||
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,
|
||||
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)
|
||||
end
|
||||
else
|
||||
|
||||
@@ -108,13 +108,17 @@ end
|
||||
|
||||
-- The damaging pipeline, extracted from the performMove monolith: every
|
||||
-- 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).
|
||||
function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
local user, target = ctx.user, ctx.target
|
||||
local move, moveInst = ctx.move, ctx.moveInst
|
||||
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_EFFECT before the INVULNERABLE check)
|
||||
if target.invulnerable and not neverMiss then
|
||||
@@ -143,8 +147,6 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
|
||||
local hits = hitCount(ctx, record)
|
||||
|
||||
if record and record.beforeAccuracy then record.beforeAccuracy(ctx) end
|
||||
|
||||
if not neverMiss then
|
||||
if not battle:accuracyRoll(move, user, target) then
|
||||
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
|
||||
@@ -221,7 +223,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
-- replay PlayMoveAnimation per strike (pokered: GetPlayerAnimationType
|
||||
-- / GetEnemyAnimationType loop on wNumAttacksLeft); hit 1 reuses the
|
||||
-- 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.
|
||||
-- PlayApplyingAttackSound (engine/battle/animations.asm, the routine after
|
||||
-- PlayApplyingAttackAnimation) picks the sound off wDamageMultipliers -- 10
|
||||
|
||||
@@ -582,30 +582,15 @@ MoveEffects.full = {
|
||||
},
|
||||
THRASH_PETAL_DANCE_EFFECT = {
|
||||
-- ThrashPetalDanceEffect (effects.asm:791-808) runs before damage
|
||||
-- (data/battle/special_effects.asm:22) and animates the setup turn
|
||||
-- (data/battle/special_effects.asm:22, core.asm:3531-3552)
|
||||
beforeAccuracy = function(ctx)
|
||||
local user = ctx.user
|
||||
if not user.thrashTurns then
|
||||
ctx.battle:animBeforeMove(
|
||||
user.isPlayer and "SHRINKING_SQUARE_ANIM" or "ANIM_B1", user.isPlayer)
|
||||
end
|
||||
end,
|
||||
afterDamage = function(ctx)
|
||||
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.thrashMove = ctx.moveInst
|
||||
user.thrashAnnounced = true
|
||||
else
|
||||
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 = ctx.rng(2, 5)
|
||||
ctx.say(romText(ctx.battle.data, "_BecameConfusedText", "%s\nbecame confused!", displayName(user)))
|
||||
end
|
||||
end
|
||||
end
|
||||
ctx.battle:animBeforeMove(
|
||||
user.isPlayer and "SHRINKING_SQUARE_ANIM" or "ANIM_B1", user.isPlayer)
|
||||
end,
|
||||
},
|
||||
JUMP_KICK_EFFECT = {
|
||||
|
||||
@@ -166,6 +166,9 @@ Battle.SUBSTATUS_ITEMS = {
|
||||
-- be run from or Roared away.
|
||||
Battle.BATTLETYPE_FORCESHINY = 7
|
||||
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
|
||||
-- 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_COUNTER = -1,
|
||||
EFFECT_MIRROR_COAT = -1,
|
||||
EFFECT_VITAL_THROW = -1,
|
||||
EFFECT_FORCE_SWITCH = -1, -- Whirlwind, Roar: priority 0, below BASE
|
||||
}
|
||||
|
||||
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)
|
||||
return (def and Battle.PRIORITY[def.effect]) or 0
|
||||
end
|
||||
@@ -2403,10 +2409,11 @@ Battle.MOVE_EFFECTS.EFFECT_BATON_PASS = function(self, attacker)
|
||||
self.enemy = party[target]
|
||||
self.enemy.volatile = carried
|
||||
end
|
||||
self:emit({ kind = "send", side = side,
|
||||
mon = side == "player" and self.player or self.enemy,
|
||||
text = "Go! " .. self:monName(side == "player" and self.player
|
||||
or self.enemy) .. "!" })
|
||||
local sent = side == "player" and self.player or self.enemy
|
||||
self:emit({ kind = "send", side = side, mon = sent,
|
||||
hp = sent.hp or 0, status = sent.status or false,
|
||||
level = sent.level, experience = sent.experience,
|
||||
text = "Go! " .. self:monName(sent) .. "!" })
|
||||
end
|
||||
|
||||
-- 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()
|
||||
end
|
||||
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!" })
|
||||
self:breakTrapsOnSend(incoming)
|
||||
self:spikesDamage(incoming)
|
||||
@@ -3084,6 +3093,7 @@ function Battle:resolveFaints()
|
||||
if self.trainer then
|
||||
self:emit({ kind = "message",
|
||||
text = (self.trainer.name or "TRAINER") .. " was defeated!" })
|
||||
self:printWinLossText("win")
|
||||
self:awardPrizeMoney()
|
||||
end
|
||||
-- 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).
|
||||
self:emit({ kind = "send", side = "enemy", mon = self.enemy,
|
||||
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 "
|
||||
.. self:monName(self.enemy) .. "!" })
|
||||
Runtime.emit("battle.battler_switched", {
|
||||
@@ -3152,6 +3164,11 @@ function Battle:resolveFaints()
|
||||
local nextIndex = Battle.firstHealthy(self.party)
|
||||
if not nextIndex then
|
||||
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")
|
||||
return true
|
||||
end
|
||||
@@ -3177,14 +3194,23 @@ function Battle:resolveFaints()
|
||||
return false
|
||||
end
|
||||
|
||||
-- WinTrainerBattle's money arm, which runs after BattleText_EnemyWasDefeated
|
||||
-- and the frontpic slide: the four quarters are dealt between the wallet and
|
||||
-- Mom's savings and then one StdBattleTextbox names the figure.
|
||||
--
|
||||
-- The `ld a, [wDebugFlags] / bit DEBUG_BATTLE_F` skip in front of
|
||||
-- PrintWinLossText is the trainer's own after-battle line, which this port
|
||||
-- runs from the script on the way out of the battle rather than from here.
|
||||
-- The payout is not gated on it either way.
|
||||
-- WinTrainerBattle (engine/battle/core.asm:2310-2323), LostBattle's .canlose
|
||||
-- arm (:2769-2782), PrintWinLossText (home/trainers.asm:230)
|
||||
function Battle:printWinLossText(result)
|
||||
local trainer = self.trainer
|
||||
if not trainer then return end
|
||||
-- The DEBUG_BATTLE_F skip sits in front of PrintWinLossText alone, behind
|
||||
-- the slide (engine/battle/core.asm:2310, :2320-2323).
|
||||
-- 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()
|
||||
local save = self.save
|
||||
if not (save and save.player) then return nil end
|
||||
@@ -3505,6 +3531,8 @@ function Battle:switch(index)
|
||||
self.participants[index] = true
|
||||
self.stages.player = Battle.newStages()
|
||||
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) .. "!" })
|
||||
-- battle.battler_switched, the payload BattleState:resolveSwitch emits on
|
||||
-- Gen 1: the side record, whoever walked in, and whoever walked out.
|
||||
@@ -3969,6 +3997,8 @@ function Battle:enemyTrySwitchOrItem()
|
||||
self:clearVolatile(self.enemy)
|
||||
self.stages.enemy = Battle.newStages()
|
||||
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 "
|
||||
.. self:monName(self.enemy) .. "!" })
|
||||
Runtime.emit("battle.battler_switched", {
|
||||
|
||||
@@ -326,6 +326,9 @@ function Game:logicSpeed()
|
||||
if self.linkSession or (self.linkNet and not self.linkNet.closed) then
|
||||
return 1
|
||||
end
|
||||
if Game.isFixedSpeedInStack and Game.isFixedSpeedInStack(self.stack) then
|
||||
return 1
|
||||
end
|
||||
if self.speedOverride then return GameSpeed.clamp(self.speedOverride) end
|
||||
-- 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
|
||||
@@ -475,6 +478,15 @@ function Game.speedCategoryInStack(stack)
|
||||
return "menu"
|
||||
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
|
||||
-- edge anchors held off (BattleState.holdsUIAnchors). Whole-stack, like
|
||||
-- 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
|
||||
-- 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,
|
||||
-- gated on this frame's world pass -- which the party menu and the bag end
|
||||
-- by being opaque. Without this hold they lose the step-down and blit at
|
||||
-- full fit scale over a battle drawn at the zoomed-out one.
|
||||
-- gated on this frame's world pass -- which the party menu ends by being
|
||||
-- opaque (the bag's item box shows the map around it, #1521). Without
|
||||
-- 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
|
||||
-- ...and a battle keeps its dialogue box and YES/NO inside its own screen
|
||||
-- instead of letting them dock to the window edge.
|
||||
@@ -970,7 +983,11 @@ end
|
||||
-- parked the player until every direction was re-pressed (#799).
|
||||
function Game:focus(f)
|
||||
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()
|
||||
self:cancelPointers()
|
||||
end
|
||||
@@ -990,6 +1007,8 @@ function Game:onResume()
|
||||
Input:reconcile()
|
||||
TouchControls:reset()
|
||||
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
|
||||
-- the active screen re-cue on the next frame (hardware audio check: T19).
|
||||
-- Desktop/mobile window-visible flips must not kill overworld music.
|
||||
@@ -1197,18 +1216,26 @@ end
|
||||
|
||||
function Game:syncEngine()
|
||||
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")
|
||||
if not ok or type(SyncEngine) ~= "table" then
|
||||
self._syncOff = true
|
||||
return nil
|
||||
end
|
||||
local eng = SyncEngine.shared()
|
||||
eng = SyncEngine.shared()
|
||||
if not eng then
|
||||
self._syncOff = true
|
||||
return nil
|
||||
end
|
||||
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
|
||||
end
|
||||
|
||||
@@ -1248,6 +1275,7 @@ function Game:applyOptions(opts)
|
||||
-- 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)
|
||||
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
|
||||
-- fpsCap key pace at the standard rate (issue #88)
|
||||
require("src.core.FrameCap").applyOptions(opts)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
-- everything under src/*/gen2 reaches shared services through here. Gen 1
|
||||
-- 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
|
||||
-- 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
|
||||
-- (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
|
||||
-- here.
|
||||
local s = self.world:fitScale()
|
||||
local ox, oy = Chrome.fitOrigin(w, h, s)
|
||||
G.push()
|
||||
G.translate(math.floor((w - 160 * s) / 2), math.floor((h - 144 * s) / 2))
|
||||
G.translate(ox, oy)
|
||||
G.scale(s, s)
|
||||
self.stack:draw()
|
||||
G.pop()
|
||||
@@ -1702,7 +1703,7 @@ function Game2:hotkey(key)
|
||||
self:writeSave()
|
||||
return true
|
||||
elseif key == "f2" then
|
||||
local loaded = Save.load("gold")
|
||||
local loaded = Save.load()
|
||||
if loaded then self:continueGame(loaded) end
|
||||
return true
|
||||
elseif key == "1" then
|
||||
@@ -1982,6 +1983,7 @@ function Game2:applyOptions()
|
||||
haptics = options.haptics,
|
||||
})
|
||||
require("src.core.VideoMode").applyOptions(options)
|
||||
require("src.core.ScreenPosition").applyOptions(options)
|
||||
require("src.core.FrameCap").applyOptions(options)
|
||||
require("src.world.gen2.BorderFill").applyOptions(options)
|
||||
local GBCFX = require("src.render.GBCFX")
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
-- 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
|
||||
-- extracted cache lives, and the save-file suffix -- so the importer,
|
||||
-- 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,
|
||||
-- Yellow, and Gold (issue #899); a legacy root cache is moved into red/ once
|
||||
-- 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
|
||||
-- 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",
|
||||
cachePrefix = "gold/", -- gold/data/generated, gold/assets/generated
|
||||
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,
|
||||
},
|
||||
}
|
||||
|
||||
-- Launcher column order.
|
||||
GameVersion.ORDER = { "red", "blue", "yellow", "gold" }
|
||||
GameVersion.ORDER = { "red", "blue", "yellow", "gold", "silver" }
|
||||
|
||||
GameVersion.current = "red"
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ local function normalizeVersion(v)
|
||||
b = "blue", blue = "blue",
|
||||
y = "yellow", yellow = "yellow",
|
||||
g = "gold", gold = "gold",
|
||||
s = "silver", silver = "silver",
|
||||
}
|
||||
v = alias[v] or v
|
||||
if GameVersion.VERSIONS and not GameVersion.VERSIONS[v] then return nil end
|
||||
|
||||
@@ -230,7 +230,7 @@ end
|
||||
|
||||
function Music.play(data, song, loop, ctx)
|
||||
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 {}
|
||||
song = selectSong(song, ctx)
|
||||
|
||||
|
||||
@@ -285,6 +285,7 @@ function SaveData.defaultOptions()
|
||||
-- lock the window to an exact 160x144 multiple, 1..4 (0 = OFF); see
|
||||
-- src/core/FaithfulRes.lua. Ignored on mobile.
|
||||
faithfulRes = 0,
|
||||
screenPos = "center",
|
||||
-- hard render frame-rate cap; render-only pacing (issue #88, FrameCap.lua)
|
||||
fpsCap = 60,
|
||||
-- graphics performance tier: auto | high | balanced | low. "auto"
|
||||
@@ -1279,14 +1280,27 @@ function SaveData.ensurePlaythroughId(save, injectedFs)
|
||||
local isFresh = save == freshPlaythrough
|
||||
if isFresh then freshPlaythrough = nil end
|
||||
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
|
||||
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[version] = opts.playthroughIds[version] or {}
|
||||
opts.playthroughIds[version][scope] = id
|
||||
SaveData.saveOptions(opts, injectedFs)
|
||||
end
|
||||
end
|
||||
save.meta.playthroughId = id
|
||||
return id
|
||||
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 margin = dpadW * 0.12
|
||||
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))
|
||||
end
|
||||
return {
|
||||
|
||||
@@ -103,7 +103,7 @@ Save.PLAYER_STATES = {
|
||||
}
|
||||
|
||||
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
|
||||
-- the flat save_<version>.lua when no slot is registered.
|
||||
--
|
||||
@@ -133,16 +133,22 @@ local function fs()
|
||||
return love.filesystem
|
||||
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.
|
||||
function Save.newGame(opts)
|
||||
opts = opts or {}
|
||||
local save = {
|
||||
format = Save.FORMAT,
|
||||
version = "gold",
|
||||
version = GameVersion.get(),
|
||||
generation = 2,
|
||||
player = {
|
||||
name = opts.playerName or "GOLD",
|
||||
name = opts.playerName or Save.defaultPlayerName(),
|
||||
-- _ResetWRAM rolls wPlayerID out of hRandomSub/hRandomAdd
|
||||
-- (engine/menus/intro_menu.asm:41-49).
|
||||
id = opts.trainerId or rand(0, 65535),
|
||||
@@ -214,6 +220,9 @@ function Save.newGame(opts)
|
||||
phoneContacts = {},
|
||||
tradeFlags = {},
|
||||
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
|
||||
-- record beside the #DEX because the #DEX knows only the species
|
||||
-- (src/core/gen2/Unown.lua).
|
||||
@@ -282,6 +291,7 @@ Save.DEFAULT_OPTIONS = {
|
||||
musicFilter = 0, -- low-pass steps, 0 = off
|
||||
haptics = "light",
|
||||
touchControls = { enabled = true },
|
||||
screenPos = "center",
|
||||
}
|
||||
|
||||
function Save.defaultOptions()
|
||||
@@ -302,7 +312,7 @@ end
|
||||
Save.OPTIONS_KEY = "gold"
|
||||
|
||||
local SHARED_KEYS = {
|
||||
touchControls = true, haptics = true,
|
||||
touchControls = true, haptics = true, screenPos = true,
|
||||
mods = true, modsByVersion = true, modsGen2 = true,
|
||||
modOptions = true, modProfiles = true, modProfilesSeeded = true,
|
||||
activeProfile = true,
|
||||
@@ -374,10 +384,13 @@ end
|
||||
function Save.normalize(save)
|
||||
if type(save) ~= "table" then return nil end
|
||||
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.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.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))
|
||||
@@ -675,6 +688,12 @@ function Save.validate(save)
|
||||
scrubEvents(save, report)
|
||||
scrubMapScenes(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
|
||||
-- `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
|
||||
@@ -753,21 +772,24 @@ end
|
||||
-- copy is the witness that survives a crash mid-replace.
|
||||
function Save.save(save)
|
||||
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")
|
||||
if ok and SaveData.activeSlot and not SaveData.activeSlot("gold") then
|
||||
local id = SaveData.createSlot and SaveData.createSlot("gold")
|
||||
if id and SaveData.setActiveSlot then SaveData.setActiveSlot("gold", id) end
|
||||
if ok and SaveData.activeSlot and not SaveData.activeSlot(version) then
|
||||
local id = SaveData.createSlot and SaveData.createSlot(version)
|
||||
if id and SaveData.setActiveSlot then
|
||||
SaveData.setActiveSlot(version, id)
|
||||
end
|
||||
end
|
||||
local main, backup, tmp = saveNames(save.version)
|
||||
end
|
||||
local main, backup, tmp = saveNames(version)
|
||||
local f = fs()
|
||||
if not f then return false, "no filesystem" end
|
||||
-- saveNames may now return a saves/<version>/<slot>.lua path, and
|
||||
-- love.filesystem.write does not create missing parent directories.
|
||||
local dir = main:match("^(.*)/[^/]+$")
|
||||
if dir and f.createDirectory then f.createDirectory(dir) end
|
||||
Save.normalize(save)
|
||||
save.savedAt = os.time()
|
||||
local encoded = SaveSerializer.encode(save)
|
||||
if f.getInfo(main) then
|
||||
|
||||
@@ -249,10 +249,12 @@ function SwitchDiagnostics.probeAssets(version)
|
||||
end
|
||||
|
||||
-- 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",
|
||||
"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
|
||||
local info = filesystem.getInfo(dir)
|
||||
if info and info.type == "directory" and filesystem.getDirectoryItems then
|
||||
|
||||
@@ -27,7 +27,8 @@ local ok, err = pcall(function()
|
||||
CacheFs.prefix = prefix
|
||||
|
||||
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")
|
||||
or require("src.import.RomExtractor")
|
||||
|
||||
|
||||
@@ -332,6 +332,17 @@ local function coreRows(opts, hooks)
|
||||
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")
|
||||
if okCap then
|
||||
add(Strings("MAX FPS"),
|
||||
@@ -550,9 +561,10 @@ end
|
||||
-- opened on the Gold tab used to offer a dozen controls that did nothing
|
||||
-- and hide the seven that the cart itself has.
|
||||
--
|
||||
-- The block lives in options.lua under `gold`, which is 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
|
||||
-- The block lives in options.lua under `gold` (the historical key; Gold and
|
||||
-- Silver share it the way the Gen 1 games share the flat namespace), which is
|
||||
-- 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.
|
||||
local GEN2_KEY = "gold"
|
||||
|
||||
@@ -699,7 +711,9 @@ end
|
||||
function LauncherSettings.open(hooks, version)
|
||||
local opts = SaveData.loadOptions()
|
||||
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]
|
||||
if type(block) ~= "table" then
|
||||
block = {}
|
||||
|
||||
@@ -14,9 +14,9 @@
|
||||
-- height, and flex-shrink compressing text until it overlapped.
|
||||
--
|
||||
-- THE RULES THIS FILE FOLLOWS:
|
||||
-- * Lists paginate (Kit.pager). The installed-mod list also scrolls inside
|
||||
-- its viewport, so each of its pages can hold at least ten entries without
|
||||
-- requiring a tall window. Pages still bound how many mod rows we visit.
|
||||
-- * Short lists paginate (Kit.pager, perPage from Kit.rowsThatFit); the
|
||||
-- installed-mod list is one continuous scroll instead, drawing only the
|
||||
-- rows inside the region viewport so the window bounds the frame cost.
|
||||
-- * Every click handler only QUEUES work (imp._uiActions); update() drains
|
||||
-- the queue, so an action that tears the view down (Play, Edit save)
|
||||
-- never runs inside the frame that dispatched it.
|
||||
@@ -45,9 +45,6 @@ local COMMUNITY_URL = "https://bois.icu"
|
||||
local ACT_DEDUP = 0.35
|
||||
-- Finger travel past this (px) is a drag, not a tap.
|
||||
local TAP_SLOP2 = 16 * 16
|
||||
-- Installed mods should not turn into a one- or two-item pager on a compact
|
||||
-- display. Keep a useful page size, then let the list viewport scroll.
|
||||
local MIN_MODS_PER_PAGE = 10
|
||||
local MIN_SKIN_ROWS = 4
|
||||
local SKIN_FORMAT_LABEL = {
|
||||
native = "GEN1",
|
||||
@@ -81,15 +78,6 @@ local function setTabScroll(imp, value)
|
||||
imp._tabScroll[tabKeyOf(imp)] = clamp(value, 0, tabScrollMax(imp))
|
||||
end
|
||||
|
||||
local function modListWantsWheel(imp, wheel)
|
||||
if imp.tab ~= "mods" or (imp._modScrollMax or 0) <= 0 then return false end
|
||||
if not inRect(imp._modListRect, Kit.mouseX, Kit.mouseY) then return false end
|
||||
if not inRect(imp._tabRegionRect, Kit.mouseX, Kit.mouseY) then return false end
|
||||
local at = clamp(imp.modScroll or 0, 0, imp._modScrollMax)
|
||||
if wheel < 0 then return at < imp._modScrollMax end
|
||||
return at > 0
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------- lifecycle
|
||||
|
||||
local function ensureState(imp)
|
||||
@@ -136,12 +124,48 @@ function LauncherView.detach(imp)
|
||||
Kit.clearCaches()
|
||||
end
|
||||
|
||||
local function markNoDrag(imp, x, y, w, h)
|
||||
if Kit.blockClicks then return end
|
||||
local t = imp._noDragRects
|
||||
if not t then t = {}; imp._noDragRects = t end
|
||||
local n = (imp._noDragN or 0) + 1
|
||||
imp._noDragN = n
|
||||
local r = t[n]
|
||||
if not r then r = {}; t[n] = r end
|
||||
r.x, r.y, r.w, r.h = x, y, w, h
|
||||
end
|
||||
|
||||
local function noDragAt(imp, x, y)
|
||||
local rects = imp._noDragRects
|
||||
for i = 1, imp._noDragN or 0 do
|
||||
if inRect(rects[i], x, y) then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function armMouse(imp, x, y)
|
||||
if noDragAt(imp, x, y) then
|
||||
imp._clickPt = { x = x, y = y }
|
||||
return
|
||||
end
|
||||
local shielded = imp._modalUpNow
|
||||
imp._mouseAt = {
|
||||
x = x, y = y,
|
||||
region = not shielded and tabScrollMax(imp) > 0
|
||||
and inRect(imp._tabRegionRect, x, y) or false,
|
||||
page = not shielded and (imp._pageScrollMax or 0) > 0 or false,
|
||||
}
|
||||
Kit.dragBegin(x, y)
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------- input
|
||||
-- The kit is polled, not evented: update() samples the mouse and turns a
|
||||
-- rising edge into a click point that the next draw consumes. Host-forwarded
|
||||
-- mousepressed stays unused, exactly as before, so Android's synthesized
|
||||
-- mouse path cannot double-fire a tap (#553) -- the dedup window below is the
|
||||
-- other half of that guarantee.
|
||||
-- The kit is polled, not evented: update() samples the mouse. A press arms
|
||||
-- a drag that scrolls like a finger, and the click dispatches on RELEASE so
|
||||
-- the drag can disqualify it, exactly like the touch path below; only the
|
||||
-- cartridge (which owns its own spin-drag) keeps the press-down click.
|
||||
-- Host-forwarded mousepressed stays unused, exactly as before, so Android's
|
||||
-- synthesized mouse path cannot double-fire a tap (#553) -- the dedup window
|
||||
-- below is the other half of that guarantee.
|
||||
function LauncherView.update(imp, dt)
|
||||
if not imp._flex then return end
|
||||
if imp._launchFade then return end
|
||||
@@ -166,7 +190,39 @@ function LauncherView.update(imp, dt)
|
||||
if not touching and now >= (imp._suppressMouseUntil or 0)
|
||||
and now >= (imp._suppressClickUntil or 0) then
|
||||
local mx, my = love.mouse.getPosition()
|
||||
imp._clickPt = { x = mx, y = my }
|
||||
armMouse(imp, mx, my)
|
||||
end
|
||||
elseif down and imp._mouseAt then
|
||||
local start = imp._mouseAt
|
||||
local mx, my = love.mouse.getPosition()
|
||||
local ddx, ddy = mx - start.x, my - start.y
|
||||
if ddx * ddx + ddy * ddy > TAP_SLOP2 then
|
||||
start.dragged = true
|
||||
end
|
||||
if start.dragged then
|
||||
local last = start.lastY or start.y
|
||||
local move = -(my - last)
|
||||
if move ~= 0 and start.region then
|
||||
local at, leftover = Kit.scrollHandoff(tabScrollAt(imp),
|
||||
tabScrollMax(imp), move)
|
||||
setTabScroll(imp, at)
|
||||
move = leftover
|
||||
end
|
||||
if move ~= 0 and start.page and (imp._pageScrollMax or 0) > 0 then
|
||||
local at, leftover = Kit.scrollHandoff(imp._pageScroll or 0,
|
||||
imp._pageScrollMax, move)
|
||||
imp._pageScroll = at
|
||||
move = leftover
|
||||
end
|
||||
if move ~= 0 then Kit.dragAdd(move) end
|
||||
end
|
||||
start.lastY = my
|
||||
elseif not down and imp._mouseAt then
|
||||
local start = imp._mouseAt
|
||||
imp._mouseAt = nil
|
||||
Kit.dragEnd()
|
||||
if not start.dragged then
|
||||
imp._clickPt = { x = start.x, y = start.y }
|
||||
end
|
||||
end
|
||||
imp._prevMouseDown = down
|
||||
@@ -192,9 +248,6 @@ function LauncherView.touchpressed(imp, id, x, y)
|
||||
imp._touchAt = imp._touchAt or {}
|
||||
imp._touchAt[tostring(id)] = {
|
||||
x = x, y = y,
|
||||
modsList = imp.tab == "mods" and (imp._modScrollMax or 0) > 0
|
||||
and inRect(imp._modListRect, x, y)
|
||||
and inRect(imp._tabRegionRect, x, y),
|
||||
region = tabScrollMax(imp) > 0 and inRect(imp._tabRegionRect, x, y),
|
||||
}
|
||||
end
|
||||
@@ -207,19 +260,9 @@ function LauncherView.touchmoved(imp, id, x, y)
|
||||
if ddx * ddx + ddy * ddy > TAP_SLOP2 then
|
||||
start.dragged = true
|
||||
end
|
||||
-- A drag that began in the installed-mod viewport scrolls that page's
|
||||
-- rows. Its pager remains available for moving to the next ten-plus
|
||||
-- entries; a drag elsewhere keeps the normal short-window page scroll.
|
||||
if start.dragged then
|
||||
local last = start.lastY or start.y
|
||||
local move = -(y - last)
|
||||
if start.modsList then
|
||||
local listMax = imp._modScrollMax or 0
|
||||
local at, leftover = Kit.scrollHandoff(
|
||||
clamp(imp.modScroll or 0, 0, listMax), listMax, move)
|
||||
imp.modScroll = at
|
||||
move = leftover
|
||||
end
|
||||
if move ~= 0 and start.region then
|
||||
local at, leftover = Kit.scrollHandoff(tabScrollAt(imp),
|
||||
tabScrollMax(imp), move)
|
||||
@@ -258,6 +301,24 @@ function LauncherView.clickAt(imp, x, y)
|
||||
imp._clickPt = { x = x, y = y }
|
||||
end
|
||||
|
||||
-- Event-driven press: a macOS trackpad tap delivers press+release inside one
|
||||
-- frame, so update()'s love.mouse.isDown poll never sees it. Arm the drag
|
||||
-- from the press event under the poll's own suppression rules -- the poll's
|
||||
-- release branch then mints the tap, still within the same frame for a
|
||||
-- one-frame tap -- and mark the press seen so the poll cannot arm a second
|
||||
-- one when isDown does catch it.
|
||||
function LauncherView.mousepressed(imp, x, y)
|
||||
if not imp._flex then return end
|
||||
local now = love.timer.getTime()
|
||||
local touching = imp._touchAt ~= nil and next(imp._touchAt) ~= nil
|
||||
if touching or now < (imp._suppressMouseUntil or 0)
|
||||
or now < (imp._suppressClickUntil or 0) then
|
||||
return
|
||||
end
|
||||
if not imp._mouseAt then armMouse(imp, x, y) end
|
||||
imp._prevMouseDown = true
|
||||
end
|
||||
|
||||
-- Keyboard focus ring. Returns true when the key was consumed. Arrows arm
|
||||
-- the ring; Enter only activates a focused control once the user has actually
|
||||
-- used the arrows this session, so the long-standing "Enter plays the visible
|
||||
@@ -343,7 +404,7 @@ end
|
||||
|
||||
local CART_COLOR = {
|
||||
red = PAL.railRed, blue = PAL.railBlue, yellow = PAL.railGold,
|
||||
gold = PAL.railAmber,
|
||||
gold = PAL.railAmber, silver = PAL.railSilver,
|
||||
}
|
||||
local function cartColor(version)
|
||||
return CART_COLOR[version] or PAL.green
|
||||
@@ -418,13 +479,13 @@ end
|
||||
local function cartPill(project, x, y, w, h, z, color, alpha)
|
||||
local points, radius = {}, h / 2
|
||||
for i = 0, 10 do
|
||||
local a = math.pi + math.pi * i / 10
|
||||
points[#points + 1] = { project(x + radius + math.cos(a) * radius,
|
||||
local a = -math.pi / 2 + math.pi * i / 10
|
||||
points[#points + 1] = { project(x + w - radius + math.cos(a) * radius,
|
||||
y + radius + math.sin(a) * radius, z) }
|
||||
end
|
||||
for i = 0, 10 do
|
||||
local a = math.pi * i / 10
|
||||
points[#points + 1] = { project(x + w - radius + math.cos(a) * radius,
|
||||
local a = math.pi / 2 + math.pi * i / 10
|
||||
points[#points + 1] = { project(x + radius + math.cos(a) * radius,
|
||||
y + radius + math.sin(a) * radius, z) }
|
||||
end
|
||||
cartPolygon(points, color, alpha)
|
||||
@@ -504,6 +565,7 @@ end
|
||||
|
||||
local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action)
|
||||
local state = cartridgeState(imp, version)
|
||||
markNoDrag(imp, x, y, w, h)
|
||||
local focused = Kit.focusable(key, x, y, w, h)
|
||||
local hot = Kit.hover(x, y, w, h)
|
||||
local active = state.active
|
||||
@@ -605,7 +667,7 @@ local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action)
|
||||
end
|
||||
|
||||
local halfW, halfH = w / 2, h / 2
|
||||
local depth = math.max(8, w * 0.14)
|
||||
local depth = math.max(6, w * 0.10)
|
||||
local project = function(px, py, pz)
|
||||
return cartProject(cx + pressX, cy + pressY, yaw, pitch,
|
||||
px * pressedScale, py * pressedScale, pz * pressedScale)
|
||||
@@ -648,6 +710,7 @@ local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action)
|
||||
cartPolygon({ mainFront[2], mainFront[3], mainBack[3], mainBack[2] }, side, 1)
|
||||
cartPolygon({ mainFront[3], mainFront[4], mainBack[4], mainBack[3] }, side, 1)
|
||||
cartPolygon({ mainFront[1], mainFront[2], mainBack[2], mainBack[1] }, side, 1)
|
||||
cartPolygon({ mainFront[4], mainFront[1], mainBack[1], mainBack[4] }, side, 1)
|
||||
cartPolygon({ capFront[2], capFront[3], capBack[3], capBack[2] }, side, 1)
|
||||
cartPolygon({ capFront[1], capFront[2], capBack[2], capBack[1] }, side, 1)
|
||||
cartPolygon({ capFront[4], capFront[1], capBack[1], capBack[4] }, side, 1)
|
||||
@@ -657,22 +720,72 @@ local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action)
|
||||
else
|
||||
cartPolygon(mainBack, side, 1)
|
||||
cartPolygon(capBack, side, 1)
|
||||
-- The tri-wing security screw: a domed brass head with three teardrop
|
||||
-- recesses pinwheeled at 120 degrees.
|
||||
local backZ = -(depth + 0.8)
|
||||
local sd = math.min(w, h) * 0.11
|
||||
cartPill(project, -sd * 0.62, -sd * 0.62, sd * 1.24, sd * 1.24, backZ,
|
||||
{ math.floor(shell[1] * 0.4), math.floor(shell[2] * 0.4),
|
||||
math.floor(shell[3] * 0.4) }, 0.9)
|
||||
cartPill(project, -sd / 2, -sd / 2, sd, sd, backZ - 0.4,
|
||||
{ 196, 186, 148 }, 1)
|
||||
cartPill(project, -sd * 0.32, -sd * 0.32, sd * 0.64, sd * 0.64,
|
||||
backZ - 0.6, { 220, 212, 178 }, 0.8)
|
||||
local r = sd / 2
|
||||
for k = 0, 2 do
|
||||
local a = -math.pi / 2 + k * (2 * math.pi / 3)
|
||||
local ux, uy = math.cos(a), math.sin(a)
|
||||
local vx, vy = -uy, ux
|
||||
local r0, r1 = r * 0.16, r * 0.82
|
||||
local w0, w1 = r * 0.13, r * 0.3
|
||||
cartPolygon({
|
||||
{ project(ux * r0 + vx * w0, uy * r0 + vy * w0, backZ - 0.8) },
|
||||
{ project(ux * r0 - vx * w0, uy * r0 - vy * w0, backZ - 0.8) },
|
||||
{ project(ux * r1 - vx * w1, uy * r1 - vy * w1, backZ - 0.8) },
|
||||
{ project(ux * r1 + vx * w1, uy * r1 + vy * w1, backZ - 0.8) },
|
||||
}, { 112, 104, 76 }, 1)
|
||||
end
|
||||
end
|
||||
|
||||
if frontFacing then
|
||||
local faceZ = depth + 0.8
|
||||
for i = 0, 4 do
|
||||
local ry = mainTop + 7 + i * h * 0.025
|
||||
cartPolygon(cartQuad(project, -halfW + 2, ry, w * 0.13, 2, faceZ), side, 0.7)
|
||||
cartPolygon(cartQuad(project, halfW - w * 0.13 - 2, ry, w * 0.13, 2, faceZ), side, 0.7)
|
||||
-- The shell's grip grooves: a stack beside the label recess on the left,
|
||||
-- and one below the top-right corner notch, like the DMG cart.
|
||||
local grooveW = w * 0.115
|
||||
local grooveH = math.max(1, h * 0.009)
|
||||
for i = 0, 5 do
|
||||
local ry = mainTop + h * 0.014 + i * h * 0.021
|
||||
cartPolygon(cartQuad(project, -halfW + w * 0.02, ry,
|
||||
grooveW, grooveH, faceZ), side, 0.7)
|
||||
cartPolygon(cartQuad(project, halfW - grooveW - w * 0.02, ry,
|
||||
grooveW, grooveH, faceZ), side, 0.7)
|
||||
end
|
||||
local recessX, recessY = -w * 0.32, mainTop + h * 0.023
|
||||
local recessW, recessH = w * 0.64, h * 0.24
|
||||
cartPolygon(cartQuad(project, recessX, recessY, recessW, recessH, faceZ), shell, 0.88)
|
||||
cartPill(project, recessX + w * 0.025, recessY + h * 0.025,
|
||||
recessW - w * 0.05, h * 0.12, faceZ + 0.5, shell, 0.7)
|
||||
cartPill(project, recessX + w * 0.045, recessY + h * 0.043,
|
||||
recessW - w * 0.09, h * 0.083, faceZ + 0.8, side, 0.42)
|
||||
-- The thin diagonal mold ridge cut into each long side a little below
|
||||
-- the grip grooves, mirrored left/right.
|
||||
local function diagonal(x0, y0, x1, y1)
|
||||
local dx, dy = x1 - x0, y1 - y0
|
||||
local len = math.sqrt(dx * dx + dy * dy)
|
||||
local nx, ny = -dy / len, dx / len
|
||||
local t = math.max(0.6, h * 0.004)
|
||||
cartPolygon({
|
||||
{ project(x0 + nx * t, y0 + ny * t, faceZ) },
|
||||
{ project(x0 - nx * t, y0 - ny * t, faceZ) },
|
||||
{ project(x1 - nx * t, y1 - ny * t, faceZ) },
|
||||
{ project(x1 + nx * t, y1 + ny * t, faceZ) },
|
||||
}, side, 0.7)
|
||||
end
|
||||
local dgY = mainTop + h * 0.20
|
||||
diagonal(-halfW + w * 0.006, dgY, -halfW + w * 0.085, dgY + h * 0.038)
|
||||
diagonal(halfW - w * 0.006, dgY, halfW - w * 0.085, dgY + h * 0.038)
|
||||
-- The Nintendo GAME BOY recess: one stadium pill sunk into the shell.
|
||||
local pillX, pillW = -halfW + w * 0.17, w * 0.62
|
||||
local pillY, pillH = mainTop + h * 0.024, h * 0.115
|
||||
cartPill(project, pillX, pillY, pillW, pillH, faceZ + 0.5, side, 0.55)
|
||||
local inX, inY = w * 0.008, h * 0.008
|
||||
cartPill(project, pillX + inX, pillY + inY,
|
||||
pillW - 2 * inX, pillH - 2 * inY, faceZ + 0.8,
|
||||
{ math.floor(shell[1] * 0.92), math.floor(shell[2] * 0.92),
|
||||
math.floor(shell[3] * 0.92) }, 1)
|
||||
|
||||
local labelX, labelY = -w * 0.33, -h * 0.20
|
||||
local labelW, labelH = w * 0.66, h * 0.55
|
||||
@@ -942,6 +1055,8 @@ local GAME_TABS = {
|
||||
label = "Yellow" },
|
||||
{ id = "gold", key = "tab-gold", letter = "G", color = PAL.railAmber,
|
||||
label = "Gold" },
|
||||
{ id = "silver", key = "tab-silver", letter = "S", color = PAL.railSilver,
|
||||
label = "Silver" },
|
||||
}
|
||||
|
||||
local HEADER_TABS = {
|
||||
@@ -1114,16 +1229,11 @@ local function buildHeader(imp, m)
|
||||
local tabGap = math.floor(6 * m.s)
|
||||
local tabRowGap = math.floor(4 * m.s)
|
||||
|
||||
-- the cartridge dropdown, sized to its longest label so switching games
|
||||
-- never reflows the row
|
||||
-- the cartridge dropdown: just the game's initial and the caret; the
|
||||
-- popup list carries the full names
|
||||
local chrome0 = headerChrome(imp)
|
||||
local game = currentGame(imp)
|
||||
local labelW = 0
|
||||
for _, g in ipairs(GAME_TABS) do
|
||||
labelW = math.max(labelW, Kit.textWidth("tab", Strings(g.label)))
|
||||
end
|
||||
local dropW = math.min(tabRight - tabLeft,
|
||||
tabH + labelW + math.floor(34 * m.s))
|
||||
local dropW = math.min(tabRight - tabLeft, tabH + math.floor(24 * m.s))
|
||||
chrome0.game.color = game.color
|
||||
chrome0.game.letter = game.letter
|
||||
chrome0.game.active = imp.tab == game.id
|
||||
@@ -1133,7 +1243,7 @@ local function buildHeader(imp, m)
|
||||
-- flip with it or it vanishes into the cartridge colour
|
||||
local gameInvert = chrome0.game.active or gameHot
|
||||
chrome0.game.ring = gameHot and not chrome0.game.active or nil
|
||||
btn(imp, tx, ty, dropW, tabH, "tab-game", Strings(game.label), chrome0.game)
|
||||
btn(imp, tx, ty, dropW, tabH, "tab-game", "", chrome0.game)
|
||||
do
|
||||
local cw = math.floor(7 * m.s)
|
||||
local ccx = tx + dropW - math.floor(14 * m.s)
|
||||
@@ -1147,7 +1257,7 @@ local function buildHeader(imp, m)
|
||||
end
|
||||
tx = tx + dropW + tabGap
|
||||
|
||||
for _, t in ipairs(tabs) do
|
||||
local function headerTab(t)
|
||||
local w = tabH
|
||||
if tx > tabLeft and tx + w > tabRight then
|
||||
tx = tabLeft
|
||||
@@ -1160,6 +1270,11 @@ local function buildHeader(imp, m)
|
||||
btn(imp, tx, ty, w, tabH, t.key, "", o)
|
||||
tx = tx + w + tabGap
|
||||
end
|
||||
-- The bug-report chip sits LAST, past the sync chip.
|
||||
local bugTab
|
||||
for _, t in ipairs(tabs) do
|
||||
if t.id == "bug" then bugTab = t else headerTab(t) end
|
||||
end
|
||||
|
||||
do
|
||||
local w = tabH
|
||||
@@ -1181,6 +1296,7 @@ local function buildHeader(imp, m)
|
||||
end
|
||||
tx = tx + w + tabGap
|
||||
end
|
||||
if bugTab then headerTab(bugTab) end
|
||||
|
||||
-- `ty` has walked down with the wraps, so this stays correct at one row too.
|
||||
y = ty + tabH + math.floor(8 * m.s)
|
||||
@@ -1200,15 +1316,27 @@ function LauncherView._updateControl(imp)
|
||||
elseif status == "downloading" then
|
||||
local pct = st.progress and math.floor(st.progress * 100) or 0
|
||||
return status, Strings("Updating %d%%", pct), nil, false
|
||||
elseif status == "full_downloading" then
|
||||
local pct = st.progress and math.floor(st.progress * 100) or 0
|
||||
return status, Strings("Downloading app %d%%", pct), nil, false
|
||||
elseif status == "available" then
|
||||
return status, st.latest and (Strings("Update v") .. st.latest)
|
||||
or Strings("Update"), function() pcall(imp.Check.download) end, true
|
||||
elseif status == "ready" then
|
||||
return status, Strings("Restart to update"),
|
||||
function() require("src.core.HostShell").restart() end, true
|
||||
elseif status == "needs_full" then
|
||||
return status, Strings("Open releases"),
|
||||
function() love.system.openURL(imp.Check.releaseUrl()) end, true
|
||||
elseif status == "needs_full" or status == "full_ready" then
|
||||
local action = imp.Check.fullUpdateAction and imp.Check.fullUpdateAction()
|
||||
local label = action and action.label or "Open releases"
|
||||
local url = action and action.url or imp.Check.releaseUrl()
|
||||
return status, Strings(label),
|
||||
function()
|
||||
if action and action.kind and imp.Check.performFullUpdate then
|
||||
pcall(imp.Check.performFullUpdate)
|
||||
else
|
||||
love.system.openURL(url)
|
||||
end
|
||||
end, true
|
||||
end
|
||||
-- idle / uptodate / error: offer a manual check, with no glow.
|
||||
return status, Strings("Check for updates"),
|
||||
@@ -1964,7 +2092,6 @@ local function buildModsPanel(imp, x, y, w, availH, m)
|
||||
cy = cy + buildModScopeRow(imp, x, cy, w, m)
|
||||
|
||||
if #mods == 0 then
|
||||
imp.modScroll, imp._modScrollMax, imp._modListRect = 0, 0, nil
|
||||
Kit.emptyBox(x, cy, w, math.floor(110 * m.s), imp:_modsEmptyHint())
|
||||
return (cy - y) + math.floor(110 * m.s)
|
||||
end
|
||||
@@ -2005,43 +2132,26 @@ local function buildModsPanel(imp, x, y, w, availH, m)
|
||||
end
|
||||
|
||||
-- A mod row is a fixed height: its details first, then a dedicated second
|
||||
-- line of per-game checkboxes. Fixed because a page of uniform rows is
|
||||
-- what lets perPage come from the viewport.
|
||||
-- line of per-game checkboxes. Fixed row heights are what make the
|
||||
-- cull below plain arithmetic.
|
||||
local togH = math.floor(26 * m.s)
|
||||
local gamesLabel = Strings("Enable for:")
|
||||
local textH = Kit.textHeight("button") + math.floor(4 * m.s)
|
||||
+ Kit.textHeight("small") + math.floor(2 * m.s) + Kit.textHeight("small")
|
||||
local rowH = math.floor(8 * m.s) + textH + math.floor(8 * m.s) + togH
|
||||
+ math.floor(8 * m.s)
|
||||
local pagerH = math.max(Kit.tapMin(), math.floor(30 * m.s))
|
||||
local listH = availH - (cy - y) - pagerH - gap
|
||||
local perPage = Kit.rowsThatFit(listH, rowH, gap, MIN_MODS_PER_PAGE, 20)
|
||||
local first, last, cur, pages = Kit.pageBounds(page(imp, "mods"), #mods, perPage)
|
||||
setPage(imp, "mods", cur)
|
||||
local listTop = cy
|
||||
local shown = math.max(0, last - first + 1)
|
||||
local contentH = shown * rowH + math.max(0, shown - 1) * gap
|
||||
local scrollMax = math.max(0, contentH - listH)
|
||||
local scroll = clamp(imp.modScroll or 0, 0, scrollMax)
|
||||
local lr = imp._modListRect
|
||||
if not lr then lr = {}; imp._modListRect = lr end
|
||||
lr.x, lr.y, lr.w, lr.h = x, listTop, w, listH
|
||||
imp._modScrollMax = scrollMax
|
||||
if scrollMax > 0 and (Kit.wheelY or 0) ~= 0 and not Kit.blockClicks
|
||||
and Kit.hit(x, listTop, w, listH) then
|
||||
scroll = clamp(scroll - Kit.wheelY * math.floor(48 * m.s), 0, scrollMax)
|
||||
Kit.wheelY = 0
|
||||
elseif scrollMax == 0 then
|
||||
local wheelPage = Kit.wheelPage(x, listTop, w, listH, cur, #mods, perPage)
|
||||
if wheelPage ~= cur then imp.modScroll = 0 end
|
||||
setPage(imp, "mods", wheelPage)
|
||||
end
|
||||
imp.modScroll = scroll
|
||||
|
||||
Kit.pushClip(x, listTop, w, listH)
|
||||
for i = first, last do
|
||||
-- One continuous list: every row is laid out, the region scroll moves
|
||||
-- through all of it, and only rows inside the region's viewport draw --
|
||||
-- so the per-frame cost stays bounded by the window, not the list.
|
||||
local view = imp._tabRegionRect
|
||||
local viewTop = view and view.y or listTop
|
||||
local viewBot = view and (view.y + view.h) or (listTop + availH)
|
||||
for i = 1, #mods do
|
||||
local mod = mods[i]
|
||||
local ry = listTop + (i - first) * (rowH + gap) - scroll
|
||||
local ry = listTop + (i - 1) * (rowH + gap)
|
||||
if ry + rowH >= viewTop and ry <= viewBot then
|
||||
local rowKey = rowKeyFor(imp, "mod-row-", mod.id)
|
||||
local isFullyDisabled = true
|
||||
if mod.enabledByVersion then
|
||||
@@ -2151,13 +2261,10 @@ local function buildModsPanel(imp, x, y, w, availH, m)
|
||||
px, ly, PAL.detail)
|
||||
end
|
||||
end
|
||||
Kit.popClip()
|
||||
end
|
||||
|
||||
local pagerY = listTop + listH + gap
|
||||
local newPage, newPagerH = Kit.pager(x, pagerY, w, cur, #mods, perPage, "mods")
|
||||
if newPage ~= cur then imp.modScroll = 0 end
|
||||
setPage(imp, "mods", newPage)
|
||||
return pagerY + newPagerH - y
|
||||
local contentH = #mods * rowH + (#mods - 1) * gap
|
||||
return (listTop + contentH + gap) - y
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------- find mods panel
|
||||
@@ -2889,6 +2996,8 @@ local function buildConfirmModal(imp, m)
|
||||
imp:_setAllMods(true, true)
|
||||
elseif c.kind == "importOversize" then
|
||||
imp:_importSave(c.version, c.source, true)
|
||||
elseif c.kind == "largeImport" then
|
||||
imp:_importRequiredSource(c.modId, c.importId, c.source, true)
|
||||
else
|
||||
imp:_toggleMod(c.id, true, c.version)
|
||||
end
|
||||
@@ -4253,21 +4362,70 @@ local function syncTitle(imp, m, px, py, pw, pad)
|
||||
return py + Kit.textHeight("button") + math.floor(12 * m.s)
|
||||
end
|
||||
|
||||
local function syncStatus(imp, m, x, y, w, eng)
|
||||
local function syncWidth(m, want)
|
||||
return math.floor(math.min(want, m.W - 2 * m.pad))
|
||||
end
|
||||
|
||||
local function syncFit(m, fixed, rows, gaps, texts)
|
||||
local fit = { btnH = m.btnH, gap = math.floor(8 * m.s), lines = {} }
|
||||
local avail = m.H - 2 * m.pad
|
||||
texts = texts or {}
|
||||
for i, blk in ipairs(texts) do fit.lines[i] = blk.max end
|
||||
local function total()
|
||||
local t = fixed + rows * fit.btnH + gaps * fit.gap
|
||||
for i, blk in ipairs(texts) do
|
||||
t = t + Kit.wrapHeight(blk.font, blk.str, blk.w, fit.lines[i])
|
||||
end
|
||||
return t
|
||||
end
|
||||
while total() > avail do
|
||||
local worst, worstH = nil, 0
|
||||
for i, blk in ipairs(texts) do
|
||||
if fit.lines[i] > 1 then
|
||||
local hgt = Kit.wrapHeight(blk.font, blk.str, blk.w, fit.lines[i])
|
||||
if hgt > worstH then worst, worstH = i, hgt end
|
||||
end
|
||||
end
|
||||
if not worst then break end
|
||||
fit.lines[worst] = fit.lines[worst] - 1
|
||||
end
|
||||
if total() > avail and gaps > 0 then
|
||||
fit.gap = math.max(math.max(2, math.floor(3 * m.s)),
|
||||
fit.gap - math.ceil((total() - avail) / gaps))
|
||||
end
|
||||
if total() > avail and rows > 0 then
|
||||
fit.btnH = math.max(Kit.tapMin(),
|
||||
fit.btnH - math.ceil((total() - avail) / rows))
|
||||
end
|
||||
fit.over = total() - avail
|
||||
fit.h = math.min(total(), avail)
|
||||
return fit
|
||||
end
|
||||
|
||||
local function syncStatus(imp, m, x, y, w, eng, fit)
|
||||
local bh = (fit and fit.btnH) or m.btnH
|
||||
local gap = (fit and fit.gap) or math.floor(8 * m.s)
|
||||
if eng:busy() then
|
||||
Loader.inline(x, y, w, m.btnH, eng.status)
|
||||
return m.btnH + math.floor(8 * m.s)
|
||||
Loader.inline(x, y, w, bh, eng.status)
|
||||
return bh + gap
|
||||
end
|
||||
Kit.text("small", Kit.ellipsize("small", eng.status or "", w), x, y,
|
||||
eng.phase == "error" and PAL.red or PAL.muted)
|
||||
return Kit.textHeight("small") + gap + math.floor(2 * m.s)
|
||||
end
|
||||
|
||||
local function syncReserve(m, eng)
|
||||
if eng:busy() then return m.btnH + math.floor(8 * m.s) end
|
||||
return Kit.textHeight("small") + math.floor(10 * m.s)
|
||||
end
|
||||
|
||||
local function syncRow(imp, m, x, y, w, key, label, opts)
|
||||
local function syncRow(imp, m, x, y, w, key, label, opts, fit)
|
||||
opts = opts or {}
|
||||
opts.font = "small"
|
||||
btn(imp, x, y, w, m.btnH, key, label, opts)
|
||||
return y + m.btnH + math.floor(8 * m.s)
|
||||
local bh = (fit and fit.btnH) or m.btnH
|
||||
local gap = (fit and fit.gap) or math.floor(8 * m.s)
|
||||
btn(imp, x, y, w, bh, key, label, opts)
|
||||
return y + bh + gap
|
||||
end
|
||||
|
||||
function LauncherView.syncSideText(meta)
|
||||
@@ -4299,91 +4457,99 @@ end
|
||||
local function buildSyncConflict(imp, m, eng)
|
||||
local row = eng.conflicts[1]
|
||||
local pad = math.floor(18 * m.s)
|
||||
local w = math.floor(520 * m.s)
|
||||
local w = syncWidth(m, math.floor(520 * m.s))
|
||||
local innerW = w - 2 * pad
|
||||
local lead = row.overlap
|
||||
and Strings("These saves were played at the same time.")
|
||||
or Strings("This save also changed on another device.")
|
||||
local leadH = Kit.wrapHeight("small", lead, innerW, 2)
|
||||
local sideH = Kit.textHeight("small") + math.floor(2 * m.s)
|
||||
+ Kit.wrapHeight("micro", "x", innerW, 2)
|
||||
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s) + leadH
|
||||
+ math.floor(10 * m.s) + 2 * (sideH + math.floor(10 * m.s))
|
||||
+ 4 * (m.btnH + math.floor(8 * m.s)) + pad
|
||||
local px, py, pw = modalPanel(m, w, h)
|
||||
local mine = LauncherView.syncSideText(row.localMeta)
|
||||
local theirs = LauncherView.syncSideText(row.remoteMeta)
|
||||
local fit = syncFit(m,
|
||||
2 * pad + Kit.textHeight("button") + math.floor(22 * m.s)
|
||||
+ 2 * (Kit.textHeight("small") + math.floor(12 * m.s)),
|
||||
4, 4, {
|
||||
{ font = "small", str = lead, w = innerW, max = 2 },
|
||||
{ font = "micro", str = mine, w = innerW, max = 2 },
|
||||
{ font = "micro", str = theirs, w = innerW, max = 2 },
|
||||
})
|
||||
local px, py, pw = modalPanel(m, w, fit.h)
|
||||
local cy = syncTitle(imp, m, px, py + pad, pw, pad)
|
||||
cy = cy + Kit.textWrapped("small", lead, px + pad, cy, pw - 2 * pad,
|
||||
PAL.detail, 2) + math.floor(10 * m.s)
|
||||
cy = cy + Kit.textWrapped("small", lead, px + pad, cy, innerW,
|
||||
PAL.detail, fit.lines[1]) + math.floor(10 * m.s)
|
||||
|
||||
local function side(title, meta)
|
||||
local function side(title, text, lines)
|
||||
Kit.text("small", title, px + pad, cy, PAL.heading)
|
||||
cy = cy + Kit.textHeight("small") + math.floor(2 * m.s)
|
||||
cy = cy + Kit.textWrapped("micro", LauncherView.syncSideText(meta),
|
||||
px + pad, cy, pw - 2 * pad, PAL.muted, 2) + math.floor(10 * m.s)
|
||||
cy = cy + Kit.textWrapped("micro", text, px + pad, cy, innerW,
|
||||
PAL.muted, lines) + math.floor(10 * m.s)
|
||||
end
|
||||
side(Strings("This device") .. " \194\183 " .. tostring(row.version or "?"),
|
||||
row.localMeta)
|
||||
side(Strings("Other device"), row.remoteMeta)
|
||||
mine, fit.lines[2])
|
||||
side(Strings("Other device"), theirs, fit.lines[3])
|
||||
|
||||
local key = row.key
|
||||
cy = syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-keep-this",
|
||||
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-keep-this",
|
||||
Strings("Keep this device"), { kind = "primary",
|
||||
action = function() imp:_syncResolve(key, "local") end })
|
||||
cy = syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-keep-other",
|
||||
action = function() imp:_syncResolve(key, "local") end }, fit)
|
||||
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-keep-other",
|
||||
Strings("Keep the other device"), { kind = "accent",
|
||||
action = function() imp:_syncResolve(key, "remote") end })
|
||||
cy = syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-keep-both",
|
||||
action = function() imp:_syncResolve(key, "remote") end }, fit)
|
||||
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-keep-both",
|
||||
Strings("Keep both"), {
|
||||
action = function() imp:_syncResolve(key, "both") end })
|
||||
syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-conflict-close",
|
||||
Strings("Close"), { action = function() imp:_closeSync() end })
|
||||
action = function() imp:_syncResolve(key, "both") end }, fit)
|
||||
syncRow(imp, m, px + pad, cy, innerW, "sync-conflict-close",
|
||||
Strings("Close"), { action = function() imp:_closeSync() end }, fit)
|
||||
end
|
||||
|
||||
local function buildSyncLink(imp, m, eng)
|
||||
local mo = imp._syncModal
|
||||
local pad = math.floor(18 * m.s)
|
||||
local w = math.floor(460 * m.s)
|
||||
local fieldH = math.max(Kit.tapMin(), math.floor(36 * m.s))
|
||||
local w = syncWidth(m, math.floor(460 * m.s))
|
||||
local innerW = w - 2 * pad
|
||||
local hint = Strings("Enter the two codes the other device is showing.")
|
||||
local hintH = Kit.wrapHeight("small", hint, w - 2 * pad, 2)
|
||||
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s) + hintH
|
||||
+ math.floor(10 * m.s) + 2 * (fieldH + math.floor(8 * m.s))
|
||||
+ Kit.textHeight("small") + math.floor(10 * m.s)
|
||||
+ 2 * (m.btnH + math.floor(8 * m.s)) + pad
|
||||
local px, py, pw = modalPanel(m, w, h)
|
||||
local fieldH = math.max(Kit.tapMin(), math.floor(36 * m.s))
|
||||
local fit = syncFit(m,
|
||||
2 * pad + Kit.textHeight("button") + math.floor(22 * m.s)
|
||||
+ 2 * (fieldH + math.floor(8 * m.s)) + syncReserve(m, eng),
|
||||
2, 2, { { font = "small", str = hint, w = innerW, max = 2 } })
|
||||
local px, py, pw = modalPanel(m, w, fit.h)
|
||||
local cy = syncTitle(imp, m, px, py + pad, pw, pad)
|
||||
cy = cy + Kit.textWrapped("small", hint, px + pad, cy, pw - 2 * pad,
|
||||
PAL.detail, 2) + math.floor(10 * m.s)
|
||||
textField(imp, px + pad, cy, pw - 2 * pad, fieldH, "sync-code1",
|
||||
cy = cy + Kit.textWrapped("small", hint, px + pad, cy, innerW,
|
||||
PAL.detail, fit.lines[1]) + math.floor(10 * m.s)
|
||||
textField(imp, px + pad, cy, innerW, fieldH, "sync-code1",
|
||||
mo.code1 or "", Strings("First code"), imp._syncFocus == "code1",
|
||||
function() imp:_syncFocusField("code1") end)
|
||||
cy = cy + fieldH + math.floor(8 * m.s)
|
||||
textField(imp, px + pad, cy, pw - 2 * pad, fieldH, "sync-code2",
|
||||
cy = cy + fieldH + fit.gap
|
||||
textField(imp, px + pad, cy, innerW, fieldH, "sync-code2",
|
||||
mo.code2 or "", Strings("Second code"), imp._syncFocus == "code2",
|
||||
function() imp:_syncFocusField("code2") end)
|
||||
cy = cy + fieldH + math.floor(8 * m.s)
|
||||
cy = cy + syncStatus(imp, m, px + pad, cy, pw - 2 * pad, eng)
|
||||
cy = syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-link-go",
|
||||
cy = cy + fieldH + fit.gap
|
||||
cy = cy + syncStatus(imp, m, px + pad, cy, innerW, eng, fit)
|
||||
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-link-go",
|
||||
Strings("Link this device"), { kind = "primary", enabled = not eng:busy(),
|
||||
action = function() imp:_syncLink() end })
|
||||
syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-link-back",
|
||||
Strings("Back"), { action = function() imp:_syncView("home") end })
|
||||
action = function() imp:_syncLink() end }, fit)
|
||||
syncRow(imp, m, px + pad, cy, innerW, "sync-link-back",
|
||||
Strings("Back"), { action = function() imp:_syncView("home") end }, fit)
|
||||
end
|
||||
|
||||
local function buildSyncMods(imp, m, eng)
|
||||
local mo = imp._syncModal
|
||||
local pad = math.floor(18 * m.s)
|
||||
local w = math.floor(500 * m.s)
|
||||
local w = syncWidth(m, math.floor(500 * m.s))
|
||||
local innerW = w - 2 * pad
|
||||
local fieldH = math.max(Kit.tapMin(), math.floor(36 * m.s))
|
||||
local plan = eng.modPlan
|
||||
local rows = 4 + (plan and 1 or 0)
|
||||
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s)
|
||||
+ 3 * (Kit.textHeight("small") + math.floor(8 * m.s))
|
||||
+ fieldH + math.floor(8 * m.s)
|
||||
+ rows * (m.btnH + math.floor(8 * m.s)) + pad
|
||||
local px, py, pw = modalPanel(m, w, h)
|
||||
local codeH = eng.shareCode and (Kit.textHeight("small")
|
||||
+ Kit.textHeight("stat") + Kit.textHeight("micro")
|
||||
+ math.floor(18 * m.s)) or 0
|
||||
local planH = plan and (Kit.textHeight("small") + math.floor(8 * m.s)) or 0
|
||||
local fit = syncFit(m,
|
||||
2 * pad + Kit.textHeight("button") + math.floor(12 * m.s) + codeH + planH
|
||||
+ fieldH + math.floor(8 * m.s) + syncReserve(m, eng),
|
||||
rows, rows, {})
|
||||
local px, py, pw = modalPanel(m, w, fit.h)
|
||||
local cy = syncTitle(imp, m, px, py + pad, pw, pad)
|
||||
local innerW = pw - 2 * pad
|
||||
|
||||
if eng.shareCode then
|
||||
Kit.text("small", Strings("Share this code:"), px + pad, cy, PAL.muted)
|
||||
@@ -4395,17 +4561,23 @@ local function buildSyncMods(imp, m, eng)
|
||||
px + pad, cy, PAL.muted)
|
||||
cy = cy + Kit.textHeight("micro") + math.floor(10 * m.s)
|
||||
end
|
||||
local withOptions = mo.withOptions ~= false
|
||||
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-share-options",
|
||||
Strings("Include my mod options") .. " \194\183 "
|
||||
.. (withOptions and Strings("ON") or Strings("OFF")),
|
||||
{ kind = withOptions and "accent" or nil, enabled = not eng:busy(),
|
||||
action = function() imp:_syncToggleShareOptions() end }, fit)
|
||||
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-share-mods",
|
||||
Strings("Share mod list"), { kind = "accent", enabled = not eng:busy(),
|
||||
action = function() imp:_syncShareMods() end })
|
||||
action = function() imp:_syncShareMods() end }, fit)
|
||||
|
||||
textField(imp, px + pad, cy, innerW, fieldH, "sync-share-code",
|
||||
mo.share or "", Strings("Paste a 6-character mod code"),
|
||||
imp._syncFocus == "share", function() imp:_syncFocusField("share") end)
|
||||
cy = cy + fieldH + math.floor(8 * m.s)
|
||||
cy = cy + fieldH + fit.gap
|
||||
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-get-mods",
|
||||
Strings("Get mod list"), { kind = "accent", enabled = not eng:busy(),
|
||||
action = function() imp:_syncGetShare() end })
|
||||
action = function() imp:_syncGetShare() end }, fit)
|
||||
|
||||
if plan then
|
||||
local line = Strings("%d mods, %d indexes to add",
|
||||
@@ -4414,24 +4586,29 @@ local function buildSyncMods(imp, m, eng)
|
||||
line = line .. " \194\183 " .. Strings("%d not in your indexes",
|
||||
#plan.missing)
|
||||
end
|
||||
if #(plan.options or {}) > 0 then
|
||||
line = line .. " \194\183 " .. (plan.applyOptions
|
||||
and Strings("options for %d mods", #plan.options)
|
||||
or Strings("their options skipped"))
|
||||
end
|
||||
Kit.text("small", Kit.ellipsize("small", line, innerW), px + pad, cy,
|
||||
PAL.detail)
|
||||
cy = cy + Kit.textHeight("small") + math.floor(8 * m.s)
|
||||
local prog = mo.progress
|
||||
if prog then
|
||||
Loader.inline(px + pad, cy, innerW, m.btnH,
|
||||
Loader.inline(px + pad, cy, innerW, fit.btnH,
|
||||
Strings("%d of %d", prog.done or 0, prog.total or 0))
|
||||
cy = cy + m.btnH + math.floor(8 * m.s)
|
||||
cy = cy + fit.btnH + fit.gap
|
||||
else
|
||||
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-apply-mods",
|
||||
Strings("Apply these mods"), { kind = "primary",
|
||||
enabled = not eng:busy(),
|
||||
action = function() imp:_syncApplyMods() end })
|
||||
action = function() imp:_syncApplyMods() end }, fit)
|
||||
end
|
||||
end
|
||||
cy = cy + syncStatus(imp, m, px + pad, cy, innerW, eng)
|
||||
cy = cy + syncStatus(imp, m, px + pad, cy, innerW, eng, fit)
|
||||
syncRow(imp, m, px + pad, cy, innerW, "sync-mods-back", Strings("Back"),
|
||||
{ action = function() imp:_syncView("home") end })
|
||||
{ action = function() imp:_syncView("home") end }, fit)
|
||||
end
|
||||
|
||||
function LauncherView.syncDeviceRows(eng, limit)
|
||||
@@ -4453,29 +4630,35 @@ end
|
||||
|
||||
local function buildSyncHome(imp, m, eng)
|
||||
local pad = math.floor(18 * m.s)
|
||||
local w = math.floor(460 * m.s)
|
||||
local w = syncWidth(m, math.floor(460 * m.s))
|
||||
local linked = eng:linked()
|
||||
local codes = eng.codes
|
||||
local body = linked
|
||||
and Strings("This device is linked. Saves and the mod list sync when the launcher opens and a few seconds after each save.")
|
||||
and Strings("This device is linked. Saves sync when the launcher opens, a few seconds after each save, and every few minutes while the app is running.")
|
||||
or Strings(SYNC_HINT)
|
||||
local innerW = w - 2 * pad
|
||||
local hintH = Kit.wrapHeight("small", body, innerW, 5)
|
||||
local codesH = codes
|
||||
and (Kit.textHeight("small") + math.floor(6 * m.s)
|
||||
+ 2 * (Kit.textHeight("title") + math.floor(4 * m.s))
|
||||
+ math.floor(8 * m.s)) or 0
|
||||
local devices = linked and LauncherView.syncDeviceRows(eng) or {}
|
||||
local hidden, fit = 0, nil
|
||||
repeat
|
||||
local devicesH = #devices > 0
|
||||
and (Kit.textHeight("small") + math.floor(6 * m.s)) or 0
|
||||
local rows = (linked and 5 or 3) + #devices
|
||||
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s) + hintH
|
||||
+ math.floor(10 * m.s) + codesH + devicesH + m.btnH + math.floor(10 * m.s)
|
||||
+ rows * (m.btnH + math.floor(8 * m.s)) + pad
|
||||
local px, py, pw = modalPanel(m, w, h)
|
||||
fit = syncFit(m,
|
||||
2 * pad + Kit.textHeight("button") + math.floor(22 * m.s) + codesH
|
||||
+ devicesH + syncReserve(m, eng),
|
||||
(linked and 4 or 3) + #devices, (linked and 4 or 3) + #devices,
|
||||
{ { font = "small", str = body, w = innerW, max = 5 } })
|
||||
if fit.over <= 0 or #devices == 0 then break end
|
||||
table.remove(devices)
|
||||
hidden = hidden + 1
|
||||
until false
|
||||
local px, py, pw = modalPanel(m, w, fit.h)
|
||||
local cy = syncTitle(imp, m, px, py + pad, pw, pad)
|
||||
cy = cy + Kit.textWrapped("small", body, px + pad, cy, innerW, PAL.detail, 5)
|
||||
+ math.floor(10 * m.s)
|
||||
cy = cy + Kit.textWrapped("small", body, px + pad, cy, innerW, PAL.detail,
|
||||
fit.lines[1]) + math.floor(10 * m.s)
|
||||
|
||||
if codes then
|
||||
Kit.text("small", Strings("Enter these on your other device:"), px + pad,
|
||||
@@ -4486,23 +4669,24 @@ local function buildSyncHome(imp, m, eng)
|
||||
Kit.text("title", codes.code2, px + pad, cy, PAL.heading)
|
||||
cy = cy + Kit.textHeight("title") + math.floor(8 * m.s)
|
||||
end
|
||||
cy = cy + syncStatus(imp, m, px + pad, cy, innerW, eng)
|
||||
cy = cy + syncStatus(imp, m, px + pad, cy, innerW, eng, fit)
|
||||
|
||||
if #devices > 0 then
|
||||
Kit.text("small", Strings("Devices on this account:"), px + pad, cy,
|
||||
PAL.muted)
|
||||
Kit.text("small", hidden > 0
|
||||
and Strings("Devices on this account (%d more)", hidden)
|
||||
or Strings("Devices on this account:"), px + pad, cy, PAL.muted)
|
||||
cy = cy + Kit.textHeight("small") + math.floor(6 * m.s)
|
||||
for i, device in ipairs(devices) do
|
||||
local id = device.id
|
||||
if device.current then
|
||||
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-device-" .. i,
|
||||
device.label .. " \194\183 " .. Strings("this device"),
|
||||
{ enabled = false })
|
||||
{ enabled = false }, fit)
|
||||
else
|
||||
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-device-" .. i,
|
||||
Strings("Unlink %s", device.label), { kind = "danger",
|
||||
enabled = not eng:busy(),
|
||||
action = function() imp:_syncUnlinkDevice(id) end })
|
||||
action = function() imp:_syncUnlinkDevice(id) end }, fit)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -4510,38 +4694,69 @@ local function buildSyncHome(imp, m, eng)
|
||||
if linked then
|
||||
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-now", Strings("Sync now"),
|
||||
{ kind = "primary", enabled = not eng:busy(),
|
||||
action = function() imp:_syncNow() end })
|
||||
action = function() imp:_syncNow() end }, fit)
|
||||
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-mods",
|
||||
Strings("Share or get a mod list"), { kind = "accent",
|
||||
action = function() imp:_syncView("mods") end })
|
||||
action = function() imp:_syncView("mods") end }, fit)
|
||||
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-unlink",
|
||||
Strings("Unlink this device"), { kind = "danger",
|
||||
action = function() imp:_syncUnlink() end })
|
||||
action = function() imp:_syncUnlink() end }, fit)
|
||||
else
|
||||
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-create",
|
||||
Strings("Create sync account"), { kind = "primary",
|
||||
enabled = not eng:busy(),
|
||||
action = function() imp:_syncCreate() end })
|
||||
action = function() imp:_syncCreate() end }, fit)
|
||||
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-link",
|
||||
Strings("Link this device"), { kind = "accent",
|
||||
action = function() imp:_syncView("link") end })
|
||||
action = function() imp:_syncView("link") end }, fit)
|
||||
end
|
||||
syncRow(imp, m, px + pad, cy, innerW, "sync-close", Strings("Close"),
|
||||
{ action = function() imp:_closeSync() end })
|
||||
{ action = function() imp:_closeSync() end }, fit)
|
||||
end
|
||||
|
||||
local function buildSyncModOptions(imp, m, eng)
|
||||
local plan = eng.modPlan
|
||||
local ids = {}
|
||||
for _, row in ipairs(plan.options or {}) do ids[#ids + 1] = row.id end
|
||||
local pad = math.floor(18 * m.s)
|
||||
local w = syncWidth(m, math.floor(480 * m.s))
|
||||
local innerW = w - 2 * pad
|
||||
local lead = Strings(
|
||||
"This mod list also carries the options its owner set for %d mods. Import their options, or keep the ones you have?",
|
||||
#ids)
|
||||
local names = table.concat(ids, ", ")
|
||||
local fit = syncFit(m,
|
||||
2 * pad + Kit.textHeight("button") + math.floor(32 * m.s), 2, 2, {
|
||||
{ font = "small", str = lead, w = innerW, max = 4 },
|
||||
{ font = "micro", str = names, w = innerW, max = 3 },
|
||||
})
|
||||
local px, py, pw = modalPanel(m, w, fit.h)
|
||||
local cy = syncTitle(imp, m, px, py + pad, pw, pad)
|
||||
cy = cy + Kit.textWrapped("small", lead, px + pad, cy, innerW, PAL.detail,
|
||||
fit.lines[1]) + math.floor(8 * m.s)
|
||||
cy = cy + Kit.textWrapped("micro", names, px + pad, cy, innerW, PAL.muted,
|
||||
fit.lines[2]) + math.floor(12 * m.s)
|
||||
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-options-import",
|
||||
Strings("Import their options"), { kind = "primary",
|
||||
action = function() imp:_syncAnswerModOptions(true) end }, fit)
|
||||
syncRow(imp, m, px + pad, cy, innerW, "sync-options-skip",
|
||||
Strings("Keep my options"), {
|
||||
action = function() imp:_syncAnswerModOptions(false) end }, fit)
|
||||
end
|
||||
|
||||
local function buildSyncUnavailable(imp, m, msg)
|
||||
local pad = math.floor(18 * m.s)
|
||||
local w = math.floor(420 * m.s)
|
||||
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s)
|
||||
+ Kit.wrapHeight("small", msg, w - 2 * pad, 4) + math.floor(10 * m.s)
|
||||
+ m.btnH + pad
|
||||
local px, py, pw = modalPanel(m, w, h)
|
||||
local w = syncWidth(m, math.floor(420 * m.s))
|
||||
local innerW = w - 2 * pad
|
||||
local fit = syncFit(m,
|
||||
2 * pad + Kit.textHeight("button") + math.floor(22 * m.s), 1, 0,
|
||||
{ { font = "small", str = msg, w = innerW, max = 4 } })
|
||||
local px, py, pw = modalPanel(m, w, fit.h)
|
||||
local cy = syncTitle(imp, m, px, py + pad, pw, pad)
|
||||
cy = cy + Kit.textWrapped("small", msg, px + pad, cy, pw - 2 * pad,
|
||||
PAL.detail, 4) + math.floor(10 * m.s)
|
||||
syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-close",
|
||||
Strings("Close"), { action = function() imp:_closeSync() end })
|
||||
cy = cy + Kit.textWrapped("small", msg, px + pad, cy, innerW,
|
||||
PAL.detail, fit.lines[1]) + math.floor(10 * m.s)
|
||||
syncRow(imp, m, px + pad, cy, innerW, "sync-close",
|
||||
Strings("Close"), { action = function() imp:_closeSync() end }, fit)
|
||||
end
|
||||
|
||||
local function buildSyncModal(imp, m)
|
||||
@@ -4560,6 +4775,12 @@ local function buildSyncModal(imp, m)
|
||||
buildSyncConflict(imp, m, eng)
|
||||
return
|
||||
end
|
||||
local plan = eng.modPlan
|
||||
if type(plan) == "table" and #(plan.options or {}) > 0
|
||||
and plan.applyOptions == nil then
|
||||
buildSyncModOptions(imp, m, eng)
|
||||
return
|
||||
end
|
||||
local view = imp._syncModal and imp._syncModal.view or "home"
|
||||
if view == "link" then
|
||||
buildSyncLink(imp, m, eng)
|
||||
@@ -4842,17 +5063,18 @@ function LauncherView.draw(imp)
|
||||
Kit.beginFrame(mx, my, click ~= nil, imp._wheelY or 0)
|
||||
imp._clickPt = nil
|
||||
imp._wheelY = 0
|
||||
imp._noDragN = 0
|
||||
|
||||
Theme.field()
|
||||
|
||||
-- Everything from here to buildModals sits UNDER any open modal, so the
|
||||
-- whole stage draws shielded (no clicks, no hover, no focus ring) while
|
||||
-- one is up; buildModals lowers the shield for the modal's own controls.
|
||||
Kit.blockClicks = modalUp(imp)
|
||||
imp._modalUpNow = modalUp(imp)
|
||||
Kit.blockClicks = imp._modalUpNow
|
||||
|
||||
local step = Kit.scrollStep(m.s)
|
||||
local nested = modListWantsWheel(imp, Kit.wheelY or 0)
|
||||
if not nested then
|
||||
do
|
||||
local rect = imp._tabRegionRect
|
||||
if rect then
|
||||
setTabScroll(imp, (Kit.scrollWheel(tabScrollAt(imp), tabScrollMax(imp),
|
||||
@@ -4860,8 +5082,7 @@ function LauncherView.draw(imp)
|
||||
end
|
||||
end
|
||||
local scroll = math.max(0, math.min(imp._pageScroll or 0, scrollMax))
|
||||
if scrollMax > 0 and (Kit.wheelY or 0) ~= 0 and not nested
|
||||
and not Kit.blockClicks then
|
||||
if scrollMax > 0 and (Kit.wheelY or 0) ~= 0 and not Kit.blockClicks then
|
||||
local moved = math.max(0, math.min(scroll - Kit.wheelY * step, scrollMax))
|
||||
if moved ~= scroll then
|
||||
scroll = moved
|
||||
@@ -4869,7 +5090,7 @@ function LauncherView.draw(imp)
|
||||
end
|
||||
end
|
||||
imp._pageScroll, imp._pageScrollMax = scroll, scrollMax
|
||||
if (Kit.wheelY or 0) ~= 0 and not nested and not Kit.blockClicks
|
||||
if (Kit.wheelY or 0) ~= 0 and not Kit.blockClicks
|
||||
and tabScrollMax(imp) > 0 then
|
||||
local was = tabScrollAt(imp)
|
||||
local to = Kit.scrollClamp(was - Kit.wheelY * step, tabScrollMax(imp))
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
-- (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.
|
||||
local bit = require("bit")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local ImageWriter = require("src.import.ImageWriter")
|
||||
local LuaWriter = require("src.import.LuaWriter")
|
||||
local Rom = require("src.import.Rom")
|
||||
@@ -168,6 +169,10 @@ function RomExtractorGen2.new(romData, manifest, progress)
|
||||
symbols = manifest.symbols,
|
||||
progress = progress,
|
||||
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)
|
||||
end
|
||||
|
||||
@@ -1641,22 +1646,40 @@ function RomExtractorGen2:extractTitle()
|
||||
return 3
|
||||
end
|
||||
|
||||
-- pret gfx/title/title_bg_gold.pal / title_fg.pal (5 BG pals, 2 OBJ pals).
|
||||
local BG_PALS = {
|
||||
local silver = self.edition == "silver"
|
||||
|
||||
-- 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, 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 }, { 29, 25, 0 }, { 15, 20, 31 }, { 17, 10, 1 } },
|
||||
{ { 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);
|
||||
-- pal 1 = gold trail sparks (OAM_PAL1 on GSTitleTrail).
|
||||
-- title_fg.pal, shared (GSTitleOBPals, engine/gfx/color.asm:1241): pal 0 =
|
||||
-- Ho-Oh silhouette; pal 1 = gold trail sparks (OAM_PAL1 on GSTitleTrail).
|
||||
local OBJ_HOOH = {
|
||||
{ 31, 31, 31 }, { 7, 6, 3 }, { 7, 6, 3 }, { 7, 6, 3 },
|
||||
}
|
||||
local OBJ_TRAIL = {
|
||||
{ 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 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
|
||||
-- a straight decode gives. rOBP1 (%11111000) carries the gold trail.
|
||||
local DMG_BGP = { 0, 2, 1, 3 }
|
||||
local DMG_OBP0 = { 3, 3, 3, 3 }
|
||||
local DMG_OBP1 = { 0, 2, 3, 3 }
|
||||
-- engine/movie/title.asm:105-115: Silver writes %11110000 to both OBPs.
|
||||
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.
|
||||
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).
|
||||
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 = {
|
||||
{ -- 1
|
||||
{ -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 },
|
||||
},
|
||||
}
|
||||
-- Frameset_GSIntroHoOhLugia (Gold): 1,2,3,4,3,5 with these durations.
|
||||
local HOOH_SEQUENCE = {
|
||||
-- Silver's five oamsets, as {layout, vtile base} (oam.asm:103-107).
|
||||
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 },
|
||||
}
|
||||
local hoohPaths, hoohGrayPaths = {}, {}
|
||||
local originX, originY = 32, 24
|
||||
for fi, oam in ipairs(HOOH_FRAMES) do
|
||||
-- Lugia1/2 span x tiles -5..4, four tiles wider than Ho-Oh's -4..3.
|
||||
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
|
||||
-- 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.
|
||||
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
|
||||
local px = originX + spr[1] * 8 + spr[3]
|
||||
local py = originY + spr[2] * 8 + spr[4]
|
||||
blitSprite(pose, hoohTiles[spr[5] + 1], px, py)
|
||||
blitSprite(pose, hoohTiles[spr[5] + 2], px, py + 8)
|
||||
blitSprite(pose, hoohTiles[base + spr[5] + 1], px, py)
|
||||
blitSprite(pose, hoohTiles[base + spr[5] + 2], px, py + 8)
|
||||
end
|
||||
local tinted = colorize(pose, function() return OBJ_HOOH end)
|
||||
local rel = ("title/hooh_%d.png"):format(fi)
|
||||
@@ -1855,14 +1915,20 @@ function RomExtractorGen2:extractTitle()
|
||||
end
|
||||
self:tick("Title screen", 3, 5)
|
||||
|
||||
-- Trail: TitleScreenGFX3 is raw 2bpp (8 tiles); Gold OAM uses one 8x16
|
||||
-- on OAM_PAL1 (gold), not the Ho-Oh silhouette pal.
|
||||
-- Trail: TitleScreenGFX3 is raw 2bpp; Gold's OAM is one 8x16, Silver's two
|
||||
-- side by side, and only 4 of Silver's 8 copied tiles exist (title.asm:43).
|
||||
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 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[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)
|
||||
self:save(trailTint, "title/trail.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",
|
||||
hoohFramesGray = hoohGrayPaths,
|
||||
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,
|
||||
-- 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
|
||||
-- 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
|
||||
@@ -1919,19 +1988,45 @@ function RomExtractorGen2:extractTitle()
|
||||
-- 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
|
||||
-- (x - 8, y - 16) on screen.
|
||||
-- The pose canvas holds its own origin at (32, 24), so the sheet's corner
|
||||
-- is (88 - 8 - 32, 96 - 16 - 24) -- and the bird's 64px width then lands
|
||||
-- centred on the screen, 48 to 112.
|
||||
hoohX = 48,
|
||||
hoohY = 56,
|
||||
-- The pose canvas holds its own origin, so the sheet's corner is
|
||||
-- (88 - 8 - originX, 96 - 16 - originY) -- and the pose's width then lands
|
||||
-- centred on the screen (Ho-Oh 48..112, Lugia 40..120).
|
||||
hoohX = 80 - originX,
|
||||
hoohY = 80 - originY,
|
||||
trail = "assets/generated/title/trail.png",
|
||||
copyright = "assets/generated/title/copyright.png",
|
||||
copyrightSplash = "assets/generated/title/copyright_splash.png",
|
||||
-- ScrollTitleScreenClouds: Gold decrements the cloud-band SCX every
|
||||
-- 8 vblanks, so the strip slides 1px right. Silver does the same
|
||||
-- decrement every frame.
|
||||
cloudScrollEvery = 8,
|
||||
-- ScrollTitleScreenClouds (engine/menus/intro_menu.asm:917-928): Gold
|
||||
-- decrements the cloud-band SCX every 8 vblanks, so the strip slides 1px
|
||||
-- right. Silver does the same decrement every frame.
|
||||
cloudScrollEvery = silver and 1 or 8,
|
||||
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)
|
||||
return data
|
||||
@@ -3344,6 +3439,18 @@ function RomExtractorGen2:extractScriptsAndText(maps, stdScripts)
|
||||
elseif info.name == "givepoke" then
|
||||
cmd.species, cmd.level, cmd.item, cmd.trainer =
|
||||
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
|
||||
cmd.species = args[1] -- pokepic
|
||||
cmd.object = args[1] -- disappear (same byte)
|
||||
@@ -5136,6 +5243,96 @@ function RomExtractorGen2:extractMenuGfx()
|
||||
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:tick("Menu graphics", 1, 1)
|
||||
return out
|
||||
|
||||
@@ -142,6 +142,8 @@ local VERSION_REQUIRED_FILES_OVERRIDE = {
|
||||
"assets/generated/audio/programs.bin",
|
||||
},
|
||||
}
|
||||
-- Same Gen 2 extract, so a Silver cache is complete when the same files exist.
|
||||
VERSION_REQUIRED_FILES_OVERRIDE.silver = VERSION_REQUIRED_FILES_OVERRIDE.gold
|
||||
|
||||
-- "Split-screen ROM selector" first-run palette (matches the FirstRun mockup):
|
||||
-- a dark neon arcade panel, one column per game.
|
||||
@@ -335,7 +337,7 @@ function RomImporter.syncAndroidShortcuts(activeVersion)
|
||||
return false
|
||||
end
|
||||
|
||||
local allVersions = { "red", "blue", "yellow", "gold" }
|
||||
local allVersions = GameVersion.ORDER
|
||||
local ready = {}
|
||||
local seen = {}
|
||||
|
||||
@@ -1361,11 +1363,10 @@ function RomImporter.new(onComplete, opts)
|
||||
saveNotice = {},
|
||||
-- MODS panel state (pass 3): mods is the cached LauncherMods.list() array
|
||||
-- (refreshed lazily on first draw and after any toggle/install/delete);
|
||||
-- modScroll is the current paged list's inner scroll offset (px, clamped
|
||||
-- in draw); modNotice is the last install/delete result { ok, text }.
|
||||
-- modNotice is the last install/delete result { ok, text }.
|
||||
-- requiredImportNotice stays inside the imported-files modal so validation
|
||||
-- failures are visible beside the file picker that caused them.
|
||||
mods = nil, modScroll = 0, modNotice = nil, issueNotice = nil,
|
||||
mods = nil, modNotice = nil, issueNotice = nil,
|
||||
requiredImportNotice = nil,
|
||||
-- Which game the MODS panel is answering for (a GameVersion id, nil =
|
||||
-- every game). Rows resolve their enable-state and their "runs here"
|
||||
@@ -1422,7 +1423,8 @@ function RomImporter.new(onComplete, opts)
|
||||
self.returning[version] =
|
||||
(not ready) and marker ~= nil and marker ~= markerFor(version)
|
||||
self.romName[version] = "pokemon_" .. info.id
|
||||
.. ((info.id == "yellow" or info.id == "gold") and ".gbc" or ".gb")
|
||||
.. ((info.id == "yellow" or GameVersion.generation(version) == 2)
|
||||
and ".gbc" or ".gb")
|
||||
end
|
||||
RomImporter.syncAndroidShortcuts()
|
||||
self:_applyLastVersionTab()
|
||||
@@ -1535,6 +1537,9 @@ function RomImporter:focus(f)
|
||||
self._modPress = nil
|
||||
return
|
||||
end
|
||||
if type(self._sync) == "table" then
|
||||
pcall(self._sync.noteResumed, self._sync)
|
||||
end
|
||||
if not (f and self.android and self.workState ~= "working") then return end
|
||||
-- SAF create-document finished: GameActivity wrote export_done.flag.
|
||||
if love.filesystem.getInfo("export_done.flag", "file") then
|
||||
@@ -1687,7 +1692,7 @@ function RomImporter:startData(data, displayName)
|
||||
end
|
||||
if not isAcceptedRomSize(#data) then
|
||||
self:setError(("Expected a 1 MiB Game Boy ROM (Red/Blue/Yellow) or a "
|
||||
.. "2 MiB Game Boy Color ROM (Gold); this file is %.2f MiB.")
|
||||
.. "2 MiB Game Boy Color ROM (Gold/Silver); this file is %.2f MiB.")
|
||||
:format(#data / 1024 / 1024))
|
||||
return
|
||||
end
|
||||
@@ -1695,7 +1700,8 @@ function RomImporter:startData(data, displayName)
|
||||
local version = GameVersion.forSha1(actualHash)
|
||||
if not version then
|
||||
self:setError(("Unsupported ROM (SHA-1 %s). This needs a clean US Pokemon "
|
||||
.. "Red, Blue, Yellow, or Gold dump; patched, trimmed or \"fixed\" dumps "
|
||||
.. "Red, Blue, Yellow, Gold, or Silver dump; patched, trimmed or "
|
||||
.. "\"fixed\" dumps "
|
||||
.. "(tagged [b] or [BF]) never verify."):format(actualHash))
|
||||
return
|
||||
end
|
||||
@@ -1772,7 +1778,7 @@ function RomImporter:_startExtractCoroutine(version, info, prefix, displayName)
|
||||
local CacheFs = require("src.import.CacheFs")
|
||||
CacheFs.prefix = prefix
|
||||
local manifest = require("src.import.RomManifest").decode(version)
|
||||
local RomExtractor = version == "gold"
|
||||
local RomExtractor = GameVersion.generation(version) == 2
|
||||
and require("src.import.RomExtractorGen2")
|
||||
or require("src.import.RomExtractor")
|
||||
local extractor = RomExtractor.new(self.romData, manifest,
|
||||
@@ -2057,7 +2063,7 @@ function RomImporter:_importRequiredData(modId, importId, data)
|
||||
return nil
|
||||
end
|
||||
|
||||
function RomImporter:_importRequiredSource(modId, importId, source)
|
||||
function RomImporter:_importRequiredSource(modId, importId, source, confirmed)
|
||||
local manifest = requiredManifest(self, modId)
|
||||
local spec = manifest and requiredSpec(manifest, importId)
|
||||
if not spec then
|
||||
@@ -2065,14 +2071,31 @@ function RomImporter:_importRequiredSource(modId, importId, source)
|
||||
self.modNotice = nil
|
||||
return nil
|
||||
end
|
||||
local RequiredImports = require("src.mods.RequiredImports")
|
||||
local info = love.filesystem.getInfo(source, "file")
|
||||
local size = info and info.size or externalFileSize(source)
|
||||
local sizeErr = require("src.mods.RequiredImports").sizeError(spec, size, false)
|
||||
local sizeErr = RequiredImports.sizeError(spec, size, false)
|
||||
if sizeErr then
|
||||
requiredImportNotice(self, modId, importId, sizeErr)
|
||||
self.modNotice = nil
|
||||
return nil
|
||||
end
|
||||
if not confirmed and type(size) == "number"
|
||||
and size > RequiredImports.LARGE_WARN_BYTES then
|
||||
self._modConfirm = {
|
||||
kind = "largeImport",
|
||||
modId = modId, importId = importId, source = source,
|
||||
title = Strings("Large import"),
|
||||
lines = {
|
||||
Strings("This is a large import (%s).",
|
||||
RequiredImports.sizeLabel(size)),
|
||||
Strings("Please ensure you have enough space on your"),
|
||||
Strings("device before doing this."),
|
||||
},
|
||||
yesLabel = Strings("I understand"),
|
||||
}
|
||||
return nil
|
||||
end
|
||||
local data = love.filesystem.read(source)
|
||||
if not data then data = readExternalPath(source) end
|
||||
if not data then
|
||||
@@ -2115,8 +2138,13 @@ function RomImporter:chooseRequiredImport(modId, importId)
|
||||
if name:sub(1, 1) ~= "." then
|
||||
local path = inbox .. "/" .. name
|
||||
local info = love.filesystem.getInfo(path, "file")
|
||||
local sizeErr = info and require("src.mods.RequiredImports")
|
||||
.sizeError(spec, info.size, false)
|
||||
local RequiredImports = require("src.mods.RequiredImports")
|
||||
local sizeErr = info
|
||||
and RequiredImports.sizeError(spec, info.size, false)
|
||||
if info and not sizeErr
|
||||
and info.size > RequiredImports.LARGE_WARN_BYTES then
|
||||
return self:_importRequiredSource(modId, importId, path)
|
||||
end
|
||||
local data = not sizeErr and love.filesystem.read(path) or nil
|
||||
if data and self:_importRequiredData(modId, importId, data) then return end
|
||||
if sizeErr then lastError = sizeErr
|
||||
@@ -2757,7 +2785,8 @@ function RomImporter:resumeAfterOverlay()
|
||||
end
|
||||
|
||||
function RomImporter:_cycleTab(delta)
|
||||
local order = { "red", "blue", "yellow", "gold", "mods", "find", "skins", "bug" }
|
||||
local order = { "red", "blue", "yellow", "gold", "silver",
|
||||
"mods", "find", "skins", "bug" }
|
||||
local idx = 1
|
||||
for i, id in ipairs(order) do
|
||||
if id == self.tab then idx = i; break end
|
||||
@@ -3094,8 +3123,10 @@ end
|
||||
-- multi-monitor coords). Same contract as PadCursor.yieldToPointer for
|
||||
-- the overlay hosts. Touch move/press/release must still reach
|
||||
-- FlexLove.touch* or scroll containers never drag on phones.
|
||||
function RomImporter:mousepressed()
|
||||
function RomImporter:mousepressed(x, y, button)
|
||||
self._padCursorActive = false
|
||||
if button ~= 1 or not self._flex then return end
|
||||
require("src.import.LauncherView").mousepressed(self, x, y)
|
||||
end
|
||||
|
||||
function RomImporter:touchpressed(id, x, y, dx, dy, pressure)
|
||||
@@ -3123,7 +3154,6 @@ function RomImporter:_switchTab(id)
|
||||
self.tab = id
|
||||
self._findSearchFocus = false
|
||||
self._skinUrlFocus = false
|
||||
self._modScrollMax, self._modListRect = 0, nil
|
||||
self:_disarmTextInput()
|
||||
-- the skins list is cheap and can change behind the launcher's back
|
||||
-- (an export, a hand-dropped folder), so re-read it on every visit
|
||||
@@ -3448,7 +3478,7 @@ end
|
||||
function RomImporter:_openSync()
|
||||
self:_syncEngine()
|
||||
self._syncModal = self._syncModal
|
||||
or { view = "home", code1 = "", code2 = "", share = "" }
|
||||
or { view = "home", code1 = "", code2 = "", share = "", withOptions = true }
|
||||
self._syncFocus = nil
|
||||
self:_disarmTextInput()
|
||||
end
|
||||
@@ -3533,9 +3563,22 @@ function RomImporter:_syncUnlinkDevice(deviceId)
|
||||
end
|
||||
|
||||
function RomImporter:_syncShareMods()
|
||||
local eng = self:_syncEngine()
|
||||
local eng, mo = self:_syncEngine(), self._syncModal
|
||||
if not eng then return false end
|
||||
return eng:shareMods()
|
||||
return eng:shareMods(mo and mo.withOptions ~= false)
|
||||
end
|
||||
|
||||
function RomImporter:_syncToggleShareOptions()
|
||||
local mo = self._syncModal
|
||||
if not mo then return false end
|
||||
mo.withOptions = not (mo.withOptions ~= false)
|
||||
return mo.withOptions
|
||||
end
|
||||
|
||||
function RomImporter:_syncAnswerModOptions(importThem)
|
||||
local eng = self:_syncEngine()
|
||||
if not eng or type(eng.answerModOptions) ~= "function" then return false end
|
||||
return eng:answerModOptions(importThem)
|
||||
end
|
||||
|
||||
function RomImporter:_syncGetShare()
|
||||
|
||||
@@ -93,6 +93,12 @@ function ItemEffects.healsHP(id)
|
||||
or id == "REVIVE" or id == "MAX_REVIVE"
|
||||
end
|
||||
|
||||
-- .useRareCandy prints over the still-drawn party menu
|
||||
-- (engine/items/item_effects.asm:1392-1418)
|
||||
function ItemEffects.keepsPartyMenuOpen(id)
|
||||
return ItemEffects.healsHP(id) or id == "RARE_CANDY"
|
||||
end
|
||||
|
||||
function ItemEffects.isBattleMedicine(id)
|
||||
return HEAL_AMOUNT[id] ~= nil or STATUS_HEAL[id] ~= nil
|
||||
or id == "MAX_POTION" or id == "FULL_RESTORE"
|
||||
|
||||
@@ -262,7 +262,8 @@ function LinkBattle.new(game, net, opts)
|
||||
self.opponentName = theirName
|
||||
-- _TrainerWantsToFightText (data/text/text_2.asm:1257): wIsInBattle == 2
|
||||
-- takes PrintBeginningBattleText's .trainerBattle arm, link included
|
||||
self.introText = Strings("%s wants\nto fight!", theirName)
|
||||
self.introText = self:romText("_TrainerWantsToFightText",
|
||||
"%s wants\nto fight!", theirName)
|
||||
self.remoteHashes = {}
|
||||
self.localHashes = {}
|
||||
self.remoteParts = {}
|
||||
|
||||
@@ -603,6 +603,33 @@ function LauncherMods.setEnabled(id, enabled, version)
|
||||
return true
|
||||
end
|
||||
|
||||
function LauncherMods.modOptions()
|
||||
local ok, options = pcall(SaveData.loadOptions)
|
||||
if not ok or type(options) ~= "table" then return {} end
|
||||
return options.modOptions or {}
|
||||
end
|
||||
|
||||
function LauncherMods.setModOptions(id, values)
|
||||
if type(id) ~= "string" or id == "" or type(values) ~= "table" then
|
||||
return false
|
||||
end
|
||||
local options = SaveData.loadOptions()
|
||||
if SaveData.isSafeMode(options) then return false end
|
||||
options.modOptions = options.modOptions or {}
|
||||
local bucket = options.modOptions[id] or {}
|
||||
for key, value in pairs(values) do
|
||||
local t = type(value)
|
||||
if type(key) == "string" and key ~= ""
|
||||
and (t == "string" or t == "number" or t == "boolean") then
|
||||
bucket[key] = value
|
||||
end
|
||||
end
|
||||
options.modOptions[id] = bucket
|
||||
SaveData.saveOptions(options)
|
||||
LauncherMods.syncActiveProfile(options)
|
||||
return true
|
||||
end
|
||||
|
||||
-- setAllEnabled(ids, enabled [, version]): the launcher's Enable all / Disable
|
||||
-- all buttons (#647). Writes what setEnabled writes, but loads and
|
||||
-- saves once for the whole list: saveOptions rewrites the whole options file per
|
||||
|
||||
@@ -195,8 +195,8 @@ local function parseImports(value, field, required)
|
||||
if value == nil then return end
|
||||
assert(type(value) == "number" and value > 0 and value % 1 == 0,
|
||||
field .. " " .. label .. " must be a positive integer")
|
||||
assert(value <= 128 * 1024 * 1024,
|
||||
field .. " " .. label .. " exceeds the 128 MiB hard limit")
|
||||
assert(value <= 2 * 1024 * 1024 * 1024,
|
||||
field .. " " .. label .. " exceeds the 2 GiB hard limit")
|
||||
end
|
||||
validateSize(size, "size")
|
||||
validateSize(maxSize, "max_size")
|
||||
|
||||
@@ -23,11 +23,17 @@ local function isRequired(spec)
|
||||
end
|
||||
|
||||
RequiredImports.specs = allSpecs
|
||||
RequiredImports.MAX_BYTES = 128 * 1024 * 1024
|
||||
RequiredImports.MAX_BYTES = 2 * 1024 * 1024 * 1024
|
||||
-- Past this, the launcher interposes a free-space warning before importing.
|
||||
RequiredImports.LARGE_WARN_BYTES = 128 * 1024 * 1024
|
||||
|
||||
local function sizeLabel(bytes)
|
||||
if bytes >= 1024 * 1024 * 1024 then
|
||||
return ("%.1f GiB"):format(bytes / (1024 * 1024 * 1024))
|
||||
end
|
||||
return ("%.1f MiB"):format(bytes / (1024 * 1024))
|
||||
end
|
||||
RequiredImports.sizeLabel = sizeLabel
|
||||
|
||||
-- Check size before a caller reads an external or stored file into one large
|
||||
-- Lua string. N64 sources may carry a 512-byte copier header, while stored
|
||||
|
||||
@@ -19,6 +19,9 @@ local romText = require("src.core.RomText")
|
||||
|
||||
local Evolution = {}
|
||||
|
||||
-- engine/pokemon/evos_moves.asm:122-123 (ld c, 50 / call DelayFrames)
|
||||
local EVOLVING_TEXT_FRAMES = 50
|
||||
|
||||
Evolution.METHODS = {
|
||||
LEVEL = {
|
||||
check = function(game, mon, evo, trigger)
|
||||
@@ -157,27 +160,50 @@ end
|
||||
-- Play the evolution movie (flashing forms), then apply + text.
|
||||
-- Headless (no real graphics) falls back to the plain text flow.
|
||||
function Evolution.evolve(game, mon, newSpecies, onDone, via)
|
||||
local oldName = mon.nickname or game.data.pokemon[mon.species].name
|
||||
-- IsEvolvingText, DelayFrames 50; ClearScreenArea then wipes rows 0-11
|
||||
-- ONLY, so the box rides through EvolveMon (evos_moves.asm:120-134)
|
||||
local isEvolving = romText(game.data, "_IsEvolvingText",
|
||||
"What?\n%s is\nevolving!", oldName)
|
||||
if love.image and love.image.newImageData then
|
||||
-- forward `via` so EvolutionState can keep trade evolutions
|
||||
-- non-cancelable (LINK_STATE_TRADING) while others accept B (#213)
|
||||
Screens.push(game, "EvolutionState", mon, newSpecies, onDone, via)
|
||||
local intro
|
||||
intro = TextBox.new(game, isEvolving, nil, { stay = {
|
||||
onShown = function()
|
||||
-- DelayFrames 50 with the box and the old screen still up
|
||||
-- (evos_moves.asm:122-123)
|
||||
local hold = { t = 0 }
|
||||
hold.update = function()
|
||||
hold.t = hold.t + 1
|
||||
if hold.t < EVOLVING_TEXT_FRAMES then return end
|
||||
game.stack:pop() -- this hold
|
||||
-- forward `via` so trade evolutions stay non-cancelable while
|
||||
-- others accept B (evos_moves.asm:72-75) (#213)
|
||||
Screens.push(game, "EvolutionState", mon, newSpecies, function()
|
||||
-- the result/cancel box owns the intro box's pop (#1596)
|
||||
if game.stack:top() == intro then game.stack:pop() end
|
||||
if onDone then onDone() end
|
||||
end, via)
|
||||
end
|
||||
hold.draw = function() end
|
||||
game.stack:push(hold)
|
||||
end,
|
||||
} })
|
||||
game.stack:push(intro)
|
||||
return
|
||||
end
|
||||
Music.play(game.data, Music.special(game.data, "evolution"))
|
||||
local oldName = mon.nickname or game.data.pokemon[mon.species].name
|
||||
Evolution.apply(game, mon, newSpecies, via)
|
||||
-- the congrats page keeps the engine wording: _EvolvedText extracts
|
||||
-- truncated (it stops at a dynamic marker the decoder does not follow)
|
||||
local msg = romText(game.data, "_IsEvolvingText",
|
||||
"What?\n%s is\nevolving!", oldName)
|
||||
.. "\f" .. Strings("Congratulations!\nYour %s\nevolved into\n%s!",
|
||||
oldName, game.data.pokemon[newSpecies].name)
|
||||
-- EvolvedText then IntoText in the same box (evos_moves.asm:136-150)
|
||||
local msg = isEvolving .. "\f"
|
||||
.. romText(game.data, "_EvolvedText", "%s evolved", oldName)
|
||||
.. romText(game.data, "_IntoText", "\ninto %s!",
|
||||
game.data.pokemon[newSpecies].name)
|
||||
game.stack:push(TextBox.new(game, msg, function()
|
||||
Music.restoreMap(game.data)
|
||||
-- re-run the evolved species' level-up learn check before onDone
|
||||
-- (evos_moves.asm EvolveMon -> learn_move.asm LearnMoveFromLevelUp, #12)
|
||||
Evolution.learnEvolutionMoves(game, mon, onDone)
|
||||
end))
|
||||
end, TextBox.soundOpts(game, "Get_Item2")))
|
||||
end
|
||||
|
||||
-- Entry point for mods whose methods fire outside the vanilla moments
|
||||
|
||||
@@ -454,7 +454,7 @@ function PaletteFX.pal(data, name)
|
||||
if fromCgb then return fromCgb end
|
||||
end
|
||||
local p = PaletteFX.pack(data)
|
||||
local c = p and p.palettes[name]
|
||||
local c = p and p.palettes and p.palettes[name]
|
||||
if c then return c end
|
||||
if GameVersion.isYellow() then
|
||||
local y = PaletteFX.yellowPack()
|
||||
|
||||
@@ -15,6 +15,7 @@ local Runtime = require("src.mods.Runtime")
|
||||
local GameViewport = require("src.render.GameViewport")
|
||||
-- leaf module (no renderer dependency), so requiring it here cannot cycle
|
||||
local FaithfulRes = require("src.core.FaithfulRes")
|
||||
local ScreenPosition = require("src.core.ScreenPosition")
|
||||
local Playfield = require("src.render.Playfield")
|
||||
|
||||
local Renderer = {}
|
||||
@@ -94,6 +95,11 @@ local function displayMetrics()
|
||||
return ww, wh, pw, ph, dpiX, dpiY, vx, vy, cut, grow
|
||||
end
|
||||
|
||||
local function positionLift(ph, contentPx, dpiY, cut)
|
||||
if cut then return 0 end
|
||||
return ScreenPosition.lift(ph, contentPx, ScreenPosition.safeTop() * dpiY)
|
||||
end
|
||||
|
||||
function Renderer:init()
|
||||
-- 160x144 real pixels, never DPI-scaled: see src/render/PixelCanvas.lua
|
||||
-- (#208). Every canvas below is sized in framebuffer pixels for the same
|
||||
@@ -269,7 +275,7 @@ end
|
||||
-- corners; flat mode returns exactly today's size (growth factor is 1 when
|
||||
-- tilt is inactive).
|
||||
function Renderer:worldViewSize()
|
||||
local _, _, pw, ph, _, _, _, _, cut, grow = displayMetrics()
|
||||
local _, _, pw, ph, _, dpiY, _, _, cut, grow = displayMetrics()
|
||||
-- FAITHFUL RATIO on mobile. The world pass deliberately expands to cover the
|
||||
-- WHOLE display, so letterbox voids become more map instead of black bars.
|
||||
-- That is why the lock appeared to do nothing in the overworld: it shrank
|
||||
@@ -293,6 +299,9 @@ function Renderer:worldViewSize()
|
||||
-- so unfloored FX/sprite math cannot phase-shimmer against the tile layer.
|
||||
if vw % 2 ~= 0 then vw = vw + 1 end
|
||||
if vh % 2 ~= 0 then vh = vh + 1 end
|
||||
local _, uih = self:uiSize()
|
||||
local lift = positionLift(ph, uih * self:fitScale(), dpiY, cut)
|
||||
if lift > 0 then vh = vh + 2 * math.ceil(lift / sp) end
|
||||
if Tilt.active() then
|
||||
local g = Tilt.viewGrowth()
|
||||
vw, vh = math.ceil(vw * g), math.ceil(vh * g)
|
||||
@@ -774,8 +783,9 @@ function Renderer:frameRects()
|
||||
r.uiw, r.uih = uiw, uih
|
||||
r.vpw, r.vph = uiw * r.Sx, uih * r.Sy
|
||||
-- Snap the letterbox origin to a framebuffer pixel, then convert to units.
|
||||
r.lift = positionLift(ph, uih * Sp, dpiY, cut)
|
||||
r.ox = (vx + math.floor((pw - uiw * Sp) / 2)) / dpiX
|
||||
r.oy = (vy + math.floor((ph - uih * Sp) / 2)) / dpiY
|
||||
r.oy = (vy + math.floor((ph - uih * Sp) / 2) - r.lift) / dpiY
|
||||
-- The UI has its own scale: it steps down as the survey zoom goes out (see
|
||||
-- uiScale), so it can be smaller than the world letterbox. Un-zoomed these
|
||||
-- are identical to Sp/ox/oy and every rect below is what it always was.
|
||||
@@ -795,7 +805,7 @@ function Renderer:frameRects()
|
||||
r.Up, r.Ux, r.Uy = Up, Up / dpiX, Up / dpiY
|
||||
r.uvpw, r.uvph = uiw * r.Ux, uih * r.Uy
|
||||
r.uox = (vx + math.floor((pw - uiw * Up) / 2)) / dpiX
|
||||
r.uoy = (vy + math.floor((ph - uih * Up) / 2)) / dpiY
|
||||
r.uoy = (vy + math.max(0, math.floor((ph - uih * Up) / 2) - r.lift)) / dpiY
|
||||
return r
|
||||
end
|
||||
|
||||
@@ -1002,7 +1012,7 @@ function Renderer:endFrame(zones, worldZones)
|
||||
local wvw = self.worldCanvas:getWidth()
|
||||
local wvh = self.worldCanvas:getHeight()
|
||||
local wox = (vx + math.floor((pw - wvw * sp) / 2)) / dpiX
|
||||
local woy = (vy + math.floor((ph - wvh * sp) / 2)) / dpiY
|
||||
local woy = (vy + math.floor((ph - wvh * sp) / 2) - R.lift) / dpiY
|
||||
-- Tilt mode projects the ground world pass through the perspective mesh
|
||||
-- (SGB zones baked in beforehand -- see drawTiltedWorld -- so no zone
|
||||
-- scissoring here). drawTiltedWorld returns false when tilt is off or
|
||||
|
||||
@@ -44,7 +44,8 @@ local NAME_DELAYS = { FAST = 1, MID = 3, SLOW = 5 }
|
||||
-- waits for nothing, shows no blinking arrow, and never pops itself --
|
||||
-- whoever pushed it owns the pop. stay.onShown fires once, on the frame
|
||||
-- the last page finishes typing, which is where the caller pushes whatever
|
||||
-- goes on top of it (#591).
|
||||
-- goes on top of it (#591). stay.prompt waits out one arrowed A/B press
|
||||
-- first (TextCommand_PROMPT_BUTTON, home/text.asm:434-444) (#1511).
|
||||
function TextBox.new(game, text, onDone, opts)
|
||||
local self = setmetatable({}, TextBox)
|
||||
self.game = game
|
||||
@@ -296,6 +297,15 @@ function TextBox:update(dt)
|
||||
-- exactly once (#591)
|
||||
if self.stay then
|
||||
if not self.stayShown then
|
||||
-- stay.prompt: arrowed A/B wait, then the box stays up
|
||||
-- (TextCommand_PROMPT_BUTTON, home/text.asm:434-444)
|
||||
if self.stay.prompt
|
||||
and not (input:wasPressed("a") or input:wasPressed("b")) then
|
||||
return
|
||||
end
|
||||
if self.stay.prompt then
|
||||
require("src.core.Sound").play(self.game.data, "Press_AB")
|
||||
end
|
||||
self.stayShown = true
|
||||
if self.stay.onShown then self.stay.onShown() end
|
||||
end
|
||||
@@ -494,7 +504,8 @@ function TextBox:draw()
|
||||
Font.draw(money, 152 - Font.width(money), 8)
|
||||
end
|
||||
if (self.waiting or (self.done and not self.choice and not self.auto
|
||||
and not self.stay))
|
||||
and (not self.stay
|
||||
or (self.stay.prompt and not self.stayShown))))
|
||||
and self.blink < 30 then
|
||||
-- page-advance cursor: glyph $EE by default, the blinking down arrow
|
||||
-- the original prints via `ld a, "▼"` (home/text.asm)
|
||||
|
||||
@@ -228,7 +228,10 @@ SaveConvert.mergeDefaults = mergeDefaults
|
||||
-- codec exists yet. Both directions answer with a plain message the
|
||||
-- launcher's save card renders as-is, instead of pushing a Gen 2 save
|
||||
-- table through Gen 1 offsets and surfacing a codec traceback.
|
||||
local GEN2_SAV_UNSUPPORTED = { gold = "Pokemon Gold" }
|
||||
local GEN2_SAV_UNSUPPORTED = {
|
||||
gold = "Pokemon Gold",
|
||||
silver = "Pokemon Silver",
|
||||
}
|
||||
|
||||
-- importSav(bytes, version, gameVersion) -> saveTable, err
|
||||
-- bytes: the raw 32768-byte SRAM string. Validates size and the main-data
|
||||
|
||||
@@ -1694,7 +1694,8 @@ H.RandomPhoneWildMon = function(vm)
|
||||
local entry = contact and contact.map and grass and grass[contact.map]
|
||||
local slots = entry and entry.slots
|
||||
if not slots then return end
|
||||
local daytime = (w and w.daytime) or "DAY"
|
||||
-- wTimeOfDay, not the palette pin (wildmons.asm:861)
|
||||
local daytime = (w and (w.tod or w.daytime)) or "DAY"
|
||||
if daytime == "DARK" then daytime = "NITE" end
|
||||
local slot = (slots[daytime] or slots.DAY or {})[Specials.random(4)]
|
||||
if slot and slot.species then nameSpecies(vm, slot.species) end
|
||||
|
||||
@@ -543,10 +543,14 @@ local function runCmd(self, cmd, op)
|
||||
local species = cmd.species or arg1(cmd)
|
||||
local level = cmd.level or (cmd.args and cmd.args[2]) or 5
|
||||
local item = cmd.item or (cmd.args and cmd.args[3]) or 0
|
||||
if self.givePokeFn then
|
||||
local mon = self.givePokeFn(species, level, item)
|
||||
-- engine/pokemon/move_mon.asm:1753-1757
|
||||
local trainer = cmd.trainer or (cmd.args and cmd.args[4]) or 0
|
||||
if self.givePokeFn then
|
||||
-- engine/pokemon/move_mon.asm:1695-1736: the trainer arm copies the
|
||||
-- script's own nickname and OT name in instead of asking for one.
|
||||
local named = trainer ~= 0
|
||||
and { nickname = cmd.name, otName = cmd.otName } or nil
|
||||
local mon = self.givePokeFn(species, level, item, named)
|
||||
-- engine/pokemon/move_mon.asm:1753-1757
|
||||
if mon and trainer == 0 then
|
||||
Specials.askNickname(self, mon)
|
||||
end
|
||||
@@ -1114,7 +1118,9 @@ local function runCmd(self, cmd, op)
|
||||
-- really does run here.
|
||||
if self.reloadMapFn then self.reloadMapFn(true) end
|
||||
elseif op == "winlosstext" then
|
||||
-- Overrides the struct's win/loss text for this battle only.
|
||||
-- Overrides the struct's win/loss text for this battle only; a 0
|
||||
-- argument zeroes that pointer (engine/overworld/scripting.asm:651)
|
||||
self.winLossArmed = true
|
||||
self.winTextOverride = cmd.winText
|
||||
self.lossTextOverride = cmd.lossText
|
||||
elseif op == "trainertext" then
|
||||
@@ -1122,9 +1128,11 @@ local function runCmd(self, cmd, op)
|
||||
local obj = self.trainerObject or {}
|
||||
local key
|
||||
if which == 1 then
|
||||
key = self.winTextOverride or obj.winText
|
||||
key = self.winLossArmed and self.winTextOverride
|
||||
or (not self.winLossArmed and obj.winText or nil)
|
||||
elseif which == 2 then
|
||||
key = self.lossTextOverride or obj.lossText
|
||||
key = self.winLossArmed and self.lossTextOverride
|
||||
or (not self.winLossArmed and obj.lossText or nil)
|
||||
else
|
||||
key = obj.seenText
|
||||
end
|
||||
@@ -2425,6 +2433,7 @@ function Vm:start(scriptKey)
|
||||
self.battleOutcome = nil
|
||||
self.winTextOverride = nil
|
||||
self.lossTextOverride = nil
|
||||
self.winLossArmed = nil
|
||||
-- The whiteout abort is per-run too: a script that ended because the player
|
||||
-- was wiped must not stop the next one before it starts.
|
||||
self.aborted = false
|
||||
|
||||
@@ -7,6 +7,7 @@ SyncEngine.__index = SyncEngine
|
||||
|
||||
SyncEngine.UPLOAD_DEBOUNCE = 5
|
||||
SyncEngine.AUTO_INTERVAL = 300
|
||||
SyncEngine.RESUME_MIN_GAP = 60
|
||||
SyncEngine.MAX_STEPS_PER_UPDATE = 8
|
||||
|
||||
local IDLE_STATUS = "Ready"
|
||||
@@ -133,6 +134,7 @@ function SyncEngine.new(opts)
|
||||
eng.modPlan = nil
|
||||
eng.shareCode = nil
|
||||
eng.clock = 0
|
||||
eng.autoAt = SyncEngine.AUTO_INTERVAL
|
||||
eng.queue = {}
|
||||
eng.pending = nil
|
||||
eng.uploadAt = nil
|
||||
@@ -258,6 +260,11 @@ function SyncEngine:update(dt)
|
||||
self.uploadAt = nil
|
||||
if self.state.enabled and self:linked() then self:syncNow() end
|
||||
end
|
||||
if self.clock >= self.autoAt and not self:busy()
|
||||
and (self.phase == "idle" or self.phase == "error")
|
||||
and self.state.enabled and self:linked() then
|
||||
self:syncNow()
|
||||
end
|
||||
local steps = 0
|
||||
while not self.pending and #self.queue > 0
|
||||
and steps < SyncEngine.MAX_STEPS_PER_UPDATE do
|
||||
@@ -296,6 +303,7 @@ function SyncEngine:createAccount(label)
|
||||
eng.phase = "idle"
|
||||
eng.status = "Sync account created"
|
||||
eng:_persist()
|
||||
eng:syncNow()
|
||||
end)
|
||||
end
|
||||
|
||||
@@ -385,9 +393,24 @@ function SyncEngine:setEnabled(enabled)
|
||||
return self.state.enabled
|
||||
end
|
||||
|
||||
function SyncEngine:protectPlaythrough(version, playthroughId)
|
||||
self.protectedKey = SyncState.key(version, playthroughId)
|
||||
end
|
||||
|
||||
function SyncEngine:noteResumed()
|
||||
if not (self.state.enabled and self:linked()) then return end
|
||||
if self:busy() or self.phase == "conflict" then return end
|
||||
if self.now() - (tonumber(self.state.lastSyncAt) or 0)
|
||||
< SyncEngine.RESUME_MIN_GAP then
|
||||
return
|
||||
end
|
||||
self:syncNow()
|
||||
end
|
||||
|
||||
function SyncEngine:syncNow()
|
||||
if not self:linked() then return false, "this device is not linked" end
|
||||
if self.pending then return false, "sync is busy" end
|
||||
self.autoAt = self.clock + SyncEngine.AUTO_INTERVAL
|
||||
self.queue = {}
|
||||
self.conflicts = {}
|
||||
self.state.pendingConflicts = {}
|
||||
@@ -437,13 +460,13 @@ function SyncEngine:_planFrom(remoteState)
|
||||
self:_addConflict(entry, key, row)
|
||||
elseif localChanged then
|
||||
self:_queueUpload(entry, key, false)
|
||||
elseif remoteChanged then
|
||||
elseif remoteChanged and key ~= self.protectedKey then
|
||||
self:_queueDownload(key, entry.version, entry.playthroughId, "replace")
|
||||
end
|
||||
end
|
||||
end
|
||||
for key, row in pairs(remote) do
|
||||
if not seen[key] then
|
||||
if not seen[key] and key ~= self.protectedKey then
|
||||
local version, id = SyncState.splitKey(key)
|
||||
if version and id then
|
||||
self:_queueDownload(key, version, id, "replace", tonumber(row.rev))
|
||||
@@ -573,12 +596,13 @@ function SyncEngine:resolveConflict(key, choice)
|
||||
return true
|
||||
end
|
||||
|
||||
function SyncEngine:uploadMods()
|
||||
function SyncEngine:uploadMods(includeOptions)
|
||||
if not self:linked() then return false, "this device is not linked" end
|
||||
if self:busy() then return false, "sync is busy" end
|
||||
local manifest = SyncMods.build(self.modDeps)
|
||||
local manifest = SyncMods.build(self.modDeps, includeOptions)
|
||||
self.phase = "uploading"
|
||||
self.status = "Uploading the mod list..."
|
||||
self.status = includeOptions and "Uploading the mod list and options..."
|
||||
or "Uploading the mod list..."
|
||||
local handle, err = self.client:putMods(manifest)
|
||||
return self:_request(handle, err, function(eng)
|
||||
eng.phase = "idle"
|
||||
@@ -595,19 +619,17 @@ function SyncEngine:fetchModPlan()
|
||||
return self:_request(handle, err, function(eng, res)
|
||||
local data = res.data or {}
|
||||
local manifest = type(data.manifest) == "table" and data.manifest or data
|
||||
eng.modPlan = SyncMods.plan(manifest, eng.modDeps)
|
||||
eng.phase = "idle"
|
||||
eng.status = SyncMods.planEmpty(eng.modPlan)
|
||||
and "Mods already match" or "Mod changes ready to apply"
|
||||
eng:_takeModPlan(SyncMods.plan(manifest, eng.modDeps))
|
||||
end)
|
||||
end
|
||||
|
||||
function SyncEngine:shareMods()
|
||||
function SyncEngine:shareMods(includeOptions)
|
||||
if not self:linked() then return false, "this device is not linked" end
|
||||
if self:busy() then return false, "sync is busy" end
|
||||
local manifest = SyncMods.build(self.modDeps)
|
||||
local manifest = SyncMods.build(self.modDeps, includeOptions)
|
||||
self.phase = "uploading"
|
||||
self.status = "Sharing the mod list..."
|
||||
self.status = includeOptions and "Sharing the mod list and options..."
|
||||
or "Sharing the mod list..."
|
||||
local handle, err = self.client:shareMods(manifest)
|
||||
return self:_request(handle, err, function(eng, res)
|
||||
local data = res.data or {}
|
||||
@@ -626,16 +648,44 @@ function SyncEngine:fetchShare(code)
|
||||
return self:_request(handle, err, function(eng, res)
|
||||
local data = res.data or {}
|
||||
local manifest = type(data.manifest) == "table" and data.manifest or data
|
||||
eng.modPlan = SyncMods.plan(manifest, eng.modDeps)
|
||||
eng.phase = "idle"
|
||||
eng.status = SyncMods.planEmpty(eng.modPlan)
|
||||
and "Mods already match" or "Mod changes ready to apply"
|
||||
eng:_takeModPlan(SyncMods.plan(manifest, eng.modDeps))
|
||||
end)
|
||||
end
|
||||
|
||||
function SyncEngine:_takeModPlan(plan)
|
||||
self.modPlan = plan
|
||||
self.phase = "idle"
|
||||
if SyncMods.planHasOptions(plan) then
|
||||
self.status = ("This list carries options for %d mods.")
|
||||
:format(#plan.options)
|
||||
elseif SyncMods.planEmpty(plan) then
|
||||
self.status = "Mods already match"
|
||||
else
|
||||
self.status = "Mod changes ready to apply"
|
||||
end
|
||||
end
|
||||
|
||||
function SyncEngine:modOptionsAsk()
|
||||
local plan = self.modPlan
|
||||
if not SyncMods.planHasOptions(plan) then return nil end
|
||||
if plan.applyOptions ~= nil then return nil end
|
||||
return SyncMods.optionModIds(plan)
|
||||
end
|
||||
|
||||
function SyncEngine:answerModOptions(importThem)
|
||||
local plan = self.modPlan
|
||||
if not SyncMods.planHasOptions(plan) then return false end
|
||||
SyncMods.answerOptions(plan, importThem)
|
||||
self.status = plan.applyOptions
|
||||
and "Their mod options will be imported too"
|
||||
or "Their mod options will be skipped"
|
||||
return plan.applyOptions
|
||||
end
|
||||
|
||||
function SyncEngine:applyModPlan(progress)
|
||||
if not self.modPlan then return false, "no mod plan" end
|
||||
if self.modApply then return false, "the mods are already being applied" end
|
||||
self.modPlan.applyOptions = self.modPlan.applyOptions == true
|
||||
local steps = SyncMods.steps(self.modPlan, self.modDeps)
|
||||
if #steps == 0 then
|
||||
self.modPlan = nil
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
local SyncMods = {}
|
||||
|
||||
SyncMods.REV = 1
|
||||
SyncMods.REV = 2
|
||||
SyncMods.MAX_OPTION_KEYS = 64
|
||||
SyncMods.MAX_OPTION_TEXT = 256
|
||||
|
||||
local function versions()
|
||||
local ok, GameVersion = pcall(require, "src.core.GameVersion")
|
||||
if ok and GameVersion and GameVersion.ORDER then return GameVersion.ORDER end
|
||||
return { "red", "blue", "yellow", "gold" }
|
||||
return { "red", "blue", "yellow", "gold", "silver" }
|
||||
end
|
||||
|
||||
local function defaultDeps()
|
||||
@@ -35,6 +37,12 @@ local function defaultDeps()
|
||||
setEnabled = function(id, enabled, version)
|
||||
return require("src.mods.LauncherMods").setEnabled(id, enabled, version)
|
||||
end,
|
||||
modOptions = function()
|
||||
return require("src.mods.LauncherMods").modOptions()
|
||||
end,
|
||||
setOptions = function(id, values)
|
||||
return require("src.mods.LauncherMods").setModOptions(id, values)
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
@@ -46,6 +54,41 @@ local function deps(given)
|
||||
return out
|
||||
end
|
||||
|
||||
local function sanitizeOptions(bucket)
|
||||
if type(bucket) ~= "table" then return nil end
|
||||
local keys = {}
|
||||
for k, v in pairs(bucket) do
|
||||
local t = type(v)
|
||||
if type(k) == "string" and k ~= ""
|
||||
and (t == "string" or t == "number" or t == "boolean") then
|
||||
keys[#keys + 1] = k
|
||||
end
|
||||
end
|
||||
if #keys == 0 then return nil end
|
||||
table.sort(keys)
|
||||
local out, n = {}, 0
|
||||
for _, k in ipairs(keys) do
|
||||
if n >= SyncMods.MAX_OPTION_KEYS then break end
|
||||
local v = bucket[k]
|
||||
if type(v) == "string" then v = v:sub(1, SyncMods.MAX_OPTION_TEXT) end
|
||||
local finite = type(v) ~= "number"
|
||||
or (v == v and v ~= math.huge and v ~= -math.huge)
|
||||
if finite then
|
||||
out[k] = v
|
||||
n = n + 1
|
||||
end
|
||||
end
|
||||
if n == 0 then return nil end
|
||||
return out
|
||||
end
|
||||
|
||||
local function sameOptions(a, b)
|
||||
for k, v in pairs(a) do
|
||||
if (b or {})[k] ~= v then return false end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local function sourceOf(row)
|
||||
local github = row.github
|
||||
or (type(row.manifest) == "table" and row.manifest.github)
|
||||
@@ -55,9 +98,14 @@ local function sourceOf(row)
|
||||
return "local"
|
||||
end
|
||||
|
||||
function SyncMods.build(given)
|
||||
function SyncMods.build(given, includeOptions)
|
||||
local d = deps(given)
|
||||
local manifest = { rev = SyncMods.REV, indexes = {}, mods = {} }
|
||||
local stored = {}
|
||||
if includeOptions then
|
||||
local ok, live = pcall(d.modOptions)
|
||||
if ok and type(live) == "table" then stored = live end
|
||||
end
|
||||
for _, row in ipairs(d.indexes() or {}) do
|
||||
local url = row.url or row.feed
|
||||
if type(url) == "string" and url ~= "" then
|
||||
@@ -72,11 +120,14 @@ function SyncMods.build(given)
|
||||
for _, version in ipairs(versions()) do
|
||||
if answers[version] then enabledFor[#enabledFor + 1] = version end
|
||||
end
|
||||
local options = includeOptions and sanitizeOptions(stored[row.id]) or nil
|
||||
if options then manifest.hasOptions = true end
|
||||
manifest.mods[#manifest.mods + 1] = {
|
||||
id = row.id,
|
||||
version = row.version,
|
||||
source = sourceOf(row),
|
||||
enabledFor = enabledFor,
|
||||
options = options,
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -86,9 +137,16 @@ end
|
||||
|
||||
function SyncMods.plan(manifest, given)
|
||||
local d = deps(given)
|
||||
local plan = { indexes = {}, toInstall = {}, toEnable = {}, missing = {} }
|
||||
local plan = { indexes = {}, toInstall = {}, toEnable = {}, missing = {},
|
||||
options = {}, applyOptions = nil }
|
||||
if type(manifest) ~= "table" then return plan end
|
||||
|
||||
local liveOptions = {}
|
||||
do
|
||||
local ok, live = pcall(d.modOptions)
|
||||
if ok and type(live) == "table" then liveOptions = live end
|
||||
end
|
||||
|
||||
local haveIndex = {}
|
||||
for _, row in ipairs(d.indexes() or {}) do
|
||||
if type(row.url) == "string" then haveIndex[row.url] = true end
|
||||
@@ -130,6 +188,10 @@ function SyncMods.plan(manifest, given)
|
||||
plan.toEnable[#plan.toEnable + 1] = { id = mod.id, version = version }
|
||||
end
|
||||
end
|
||||
local wanted = sanitizeOptions(mod.options)
|
||||
if wanted and not sameOptions(wanted, liveOptions[mod.id]) then
|
||||
plan.options[#plan.options + 1] = { id = mod.id, values = wanted }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -138,10 +200,33 @@ end
|
||||
|
||||
function SyncMods.planEmpty(plan)
|
||||
if type(plan) ~= "table" then return true end
|
||||
if plan.applyOptions and #(plan.options or {}) > 0 then return false end
|
||||
return #(plan.indexes or {}) == 0 and #(plan.toInstall or {}) == 0
|
||||
and #(plan.toEnable or {}) == 0
|
||||
end
|
||||
|
||||
function SyncMods.planHasOptions(plan)
|
||||
return type(plan) == "table" and #(plan.options or {}) > 0
|
||||
end
|
||||
|
||||
function SyncMods.optionsAnswered(plan)
|
||||
return not SyncMods.planHasOptions(plan) or plan.applyOptions ~= nil
|
||||
end
|
||||
|
||||
function SyncMods.answerOptions(plan, importThem)
|
||||
if type(plan) ~= "table" then return false end
|
||||
plan.applyOptions = importThem and true or false
|
||||
return plan.applyOptions
|
||||
end
|
||||
|
||||
function SyncMods.optionModIds(plan)
|
||||
local out = {}
|
||||
for _, row in ipairs((type(plan) == "table" and plan.options) or {}) do
|
||||
out[#out + 1] = row.id
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function SyncMods.steps(plan, given)
|
||||
local d = deps(given)
|
||||
local out = {}
|
||||
@@ -175,6 +260,19 @@ function SyncMods.steps(plan, given)
|
||||
return true
|
||||
end }
|
||||
end
|
||||
if plan.applyOptions then
|
||||
for _, want in ipairs(plan.options or {}) do
|
||||
out[#out + 1] = { label = want.id, run = function()
|
||||
if broken[want.id] then return true end
|
||||
local ok, err = d.setOptions(want.id, want.values)
|
||||
if ok == false then
|
||||
return nil, want.id .. ": "
|
||||
.. tostring(err or "could not set the mod options")
|
||||
end
|
||||
return true
|
||||
end }
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
|
||||
@@ -17,10 +17,13 @@ local function buildItems(game)
|
||||
local items = {}
|
||||
for _, id in ipairs(Bag.order(game.save)) do
|
||||
local def = game.data.items[id]
|
||||
-- PrintListMenuEntries skips the quantity for anything IsKeyItem_ owns:
|
||||
-- the KeyItemFlags bitfield plus the HMs (item_effects.asm:2616-2641)
|
||||
local unsellable = (def and def.keyItem) or id:find("^HM_") ~= nil
|
||||
table.insert(items, {
|
||||
value = id,
|
||||
label = def and def.name or id,
|
||||
right = "x" .. game.save.inventory[id],
|
||||
right = (not unsellable) and ("x" .. game.save.inventory[id]) or nil,
|
||||
})
|
||||
end
|
||||
return items
|
||||
@@ -334,8 +337,13 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
|
||||
local Evolution = require("src.pokemon.Evolution")
|
||||
local evoTo, evo = Evolution.pendingFor(game, target,
|
||||
{ kind = "levelup" })
|
||||
-- the party menu stays up through TryEvolvingMon and only
|
||||
-- comes down at RemoveUsedItem (item_effects.asm:1392-1418)
|
||||
if evoTo then
|
||||
Evolution.evolve(game, target, evoTo, nil, evo and evo.method)
|
||||
Evolution.evolve(game, target, evoTo, closePicker,
|
||||
evo and evo.method)
|
||||
else
|
||||
closePicker()
|
||||
end
|
||||
return
|
||||
end
|
||||
@@ -399,10 +407,9 @@ local function pickTargetAndUse(game, battle, id, list)
|
||||
local opts = {
|
||||
pickOnly = true,
|
||||
battle = battle,
|
||||
-- HP medicine animates its bar with the picker still up (#252). Only
|
||||
-- out of battle: the in-battle tail closes the bag list underneath
|
||||
-- first, which needs the picker already gone.
|
||||
keepOpen = (not battle) and ItemEffects.healsHP(id),
|
||||
-- HP medicine animates with the picker up (#252), RARE CANDY prints over
|
||||
-- the party menu (item_effects.asm:1392-1418)
|
||||
keepOpen = (not battle) and ItemEffects.keepsPartyMenuOpen(id),
|
||||
onSwitch = function(mon, picker)
|
||||
if not wantsMove then
|
||||
useOn(game, battle, id, mon, list, nil, picker)
|
||||
@@ -470,7 +477,9 @@ function BagMenu.new(game, opts)
|
||||
local list
|
||||
list = ListMenu.new(game, "ITEMS", buildItems(game), {
|
||||
kind = "bag",
|
||||
footer = ("¥%d"):format(game.save.money),
|
||||
-- StartMenu_Item zeroes wPrintItemPrices and draws no money box: the
|
||||
-- LIST_MENU_BOX floats over the map (engine/menus/start_sub_menus.asm)
|
||||
itemBox = true,
|
||||
-- B returns to the start menu when the bag was opened from it
|
||||
onCancel = opts.onCancel,
|
||||
-- SELECT reorders items like the original bag (swap_items.asm)
|
||||
|
||||
@@ -185,14 +185,16 @@ local function release(game)
|
||||
return
|
||||
end
|
||||
game.stack:push(TextBox.new(game,
|
||||
Strings("Once released,\n%s is\ngone forever. OK?", name), nil, {
|
||||
(t._OnceReleasedText or Strings("Once released,\n%s is\ngone forever. OK?", name))
|
||||
:gsub("{RAM:wStringBuffer}", name), nil, {
|
||||
defaultNo = true, noSound = true,
|
||||
choice = function(yes)
|
||||
if not yes then return end
|
||||
table.remove(box, list.index)
|
||||
require("src.core.Sound").playCry(game.data, mon.species)
|
||||
game.stack:push(TextBox.new(game,
|
||||
Strings("%s was\nreleased outside.\fBye %s!", name, name)))
|
||||
((t._MonWasReleasedText or Strings("%s was\nreleased outside.\fBye %s!", name, name))
|
||||
:gsub("{RAM:wStringBuffer}", name))))
|
||||
list:removeCurrent()
|
||||
end,
|
||||
}))
|
||||
|
||||
@@ -1,18 +1,77 @@
|
||||
-- The dex-completion diploma (engine/events/diploma.asm DisplayDiploma /
|
||||
-- diploma2.asm DisplayDiplomaTop): a bordered certificate page with the
|
||||
-- player's name, shown by the Celadon Mansion 3F game designer once 150
|
||||
-- species are owned. Diploma.render also backs the Yellow-only printed
|
||||
-- copy (engine/printer/printer.asm PrintDiploma -> src/core/Printer.lua).
|
||||
-- player's name and character sprite, shown by the Celadon Mansion 3F game designer
|
||||
-- once 150 species are owned. Diploma.render also backs the printed copy
|
||||
-- (engine/printer/printer.asm PrintDiploma -> src/core/Printer.lua).
|
||||
|
||||
local Assets = require("src.render.Assets")
|
||||
local Font = require("src.render.Font")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local Sprites = require("src.pokemon.Sprites")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local Diploma = {}
|
||||
Diploma.__index = Diploma
|
||||
Diploma.isOpaque = true
|
||||
|
||||
-- SGB: PalPacket_Generic (MEWMON), whole screen (engine/events/diploma.asm:67)
|
||||
function Diploma:sgbPalettes(game)
|
||||
return PaletteFX.wholeNamed(game.data, "MEWMON")
|
||||
end
|
||||
|
||||
local function tryImage(path)
|
||||
if not path then return nil end
|
||||
local ok, img = pcall(Assets.image, path)
|
||||
if ok and img then return img end
|
||||
local ok2, img2 = pcall(love.graphics.newImage, path)
|
||||
return ok2 and img2 or nil
|
||||
end
|
||||
|
||||
local function loadFrame()
|
||||
local frame = tryImage("assets/generated/trainer_card/trainer_info.png")
|
||||
if not frame then return nil end
|
||||
local quads = {}
|
||||
for i = 0, 8 do
|
||||
quads[i] = love.graphics.newQuad((i % 3) * 8,
|
||||
math.floor(i / 3) * 8,
|
||||
8, 8, frame:getDimensions())
|
||||
end
|
||||
return { img = frame, quads = quads }
|
||||
end
|
||||
|
||||
local function drawFrameBox(frame, tx, ty, tw, th)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", tx * 8, ty * 8, tw * 8, th * 8)
|
||||
if not frame then
|
||||
Font.drawBox(tx, ty, tw, th)
|
||||
return
|
||||
end
|
||||
local img = frame.img
|
||||
local q = frame.quads
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
-- corners
|
||||
love.graphics.draw(img, q[0], tx * 8, ty * 8)
|
||||
love.graphics.draw(img, q[2], (tx + tw - 1) * 8, ty * 8)
|
||||
love.graphics.draw(img, q[6], tx * 8, (ty + th - 1) * 8)
|
||||
love.graphics.draw(img, q[8], (tx + tw - 1) * 8, (ty + th - 1) * 8)
|
||||
-- horizontal edges
|
||||
for x = 1, tw - 2 do
|
||||
love.graphics.draw(img, q[1], (tx + x) * 8, ty * 8)
|
||||
love.graphics.draw(img, q[7], (tx + x) * 8, (ty + th - 1) * 8)
|
||||
end
|
||||
-- vertical edges
|
||||
for y = 1, th - 2 do
|
||||
love.graphics.draw(img, q[3], tx * 8, (ty + y) * 8)
|
||||
love.graphics.draw(img, q[5], (tx + tw - 1) * 8, (ty + y) * 8)
|
||||
end
|
||||
end
|
||||
|
||||
function Diploma.new(game, onDone)
|
||||
return setmetatable({ game = game, onDone = onDone }, Diploma)
|
||||
local self = setmetatable({
|
||||
game = game,
|
||||
onDone = onDone,
|
||||
}, Diploma)
|
||||
return self
|
||||
end
|
||||
|
||||
function Diploma:update()
|
||||
@@ -23,23 +82,55 @@ function Diploma:update()
|
||||
end
|
||||
end
|
||||
|
||||
-- the DisplayDiplomaTop layout, hlcoord tiles kept as x*8 / y*8 pixels
|
||||
-- the DisplayDiploma / DisplayDiplomaTop layout (hlcoord tiles -> x*8, y*8)
|
||||
function Diploma.render(game)
|
||||
local frame = loadFrame()
|
||||
local circle = tryImage("assets/generated/trainer_card/circle_tile.png")
|
||||
|
||||
-- 1. Outer ornate frame border: hlcoord 0, 0 / bc 16, 18 -> (0, 0, 20, 18)
|
||||
drawFrameBox(frame, 0, 0, 20, 18)
|
||||
|
||||
-- 2. Draw Player character sprite: farcall DrawPlayerCharacter
|
||||
-- Shifted +33 px right from title screen base (82 + 33 = 115, y = 80)
|
||||
local picPath, picTrueColor = Sprites.playerPath(
|
||||
game.data, "front", { kind = "diploma" })
|
||||
local pic = tryImage(picPath)
|
||||
if pic then
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
love.graphics.rectangle("line", 2.5, 2.5, 155, 139)
|
||||
Font.draw(Strings("<Diploma>"), 40, 16) -- hlcoord 5,2
|
||||
Font.draw(Strings("Player"), 24, 32) -- hlcoord 3,4
|
||||
Font.draw(game.save.player.name or "RED", 80, 32) -- hlcoord 10,4
|
||||
local congrats = { -- hlcoord 2,6
|
||||
"Congrats! This", "diploma certifies", "that you have",
|
||||
"completed your", "POKéDEX.",
|
||||
}
|
||||
for i, line in ipairs(congrats) do
|
||||
Font.draw(Strings(line), 16, 48 + (i - 1) * 10)
|
||||
love.graphics.draw(pic, 115, 80)
|
||||
if picTrueColor then
|
||||
PaletteFX.markTrueColor(115, 80, pic:getDimensions())
|
||||
end
|
||||
Font.draw(Strings("GAME FREAK"), 72, 128) -- hlcoord 9,16
|
||||
end
|
||||
|
||||
-- 3. Header: hlcoord 5, 2 with flanking circle tiles ($70)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
if circle then
|
||||
love.graphics.draw(circle, 40, 16) -- hlcoord 5, 2
|
||||
love.graphics.draw(circle, 104, 16) -- hlcoord 13, 2
|
||||
end
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(Strings("Diploma"), 48, 16) -- hlcoord 6, 2
|
||||
|
||||
-- 4. Player info: hlcoord 3, 4 ("PLAYER" / "Player") and hlcoord 10, 4 (name)
|
||||
Font.draw(Strings("Player"), 24, 32)
|
||||
local playerName = (game.save.player and game.save.player.name) or "RED"
|
||||
Font.draw(playerName, 80, 32)
|
||||
|
||||
-- 5. Congratulations text: hlcoord 2, 6 double-spaced lines (rows 6, 8, 10, 12, 14)
|
||||
local congrats = {
|
||||
{ text = "Congrats! This", y = 48 }, -- hlcoord 2, 6
|
||||
{ text = "diploma certifies", y = 64 }, -- hlcoord 2, 8
|
||||
{ text = "that you have", y = 80 }, -- hlcoord 2, 10
|
||||
{ text = "completed your", y = 96 }, -- hlcoord 2, 12
|
||||
{ text = "POKéDEX.", y = 112 }, -- hlcoord 2, 14
|
||||
}
|
||||
for _, line in ipairs(congrats) do
|
||||
Font.draw(Strings(line.text), 16, line.y)
|
||||
end
|
||||
|
||||
-- 6. Developer signature: hlcoord 9, 16
|
||||
Font.draw(Strings("GAME FREAK"), 72, 128)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
-- The evolution movie (engine/movie/evolution.asm): the mon's pic
|
||||
-- flashes back and forth with the evolved form, speeding up, then the
|
||||
-- new form appears with its cry and the congratulations text.
|
||||
-- new form appears with its cry and the "evolved into" text
|
||||
-- (engine/pokemon/evos_moves.asm:120-128).
|
||||
-- pokered engine/movie/evolution.asm (Evolution_CheckForCancel) polls the
|
||||
-- joypad during the flash: a fresh B press aborts the evolution -- the mon
|
||||
-- keeps its species and _StoppedEvolvingText ("Huh? MON stopped evolving!")
|
||||
@@ -9,14 +10,13 @@
|
||||
-- and stone evolutions, where the B press is read but thrown away because
|
||||
-- ItemUseEvoStone left wForceEvolution set (#290).
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local Music = require("src.core.Music")
|
||||
local Strings = require("src.core.Strings")
|
||||
local romText = require("src.core.RomText")
|
||||
|
||||
-- Not opaque: ClearScreenArea wipes rows 0-11 only (evos_moves.asm:126-128),
|
||||
-- so the "is evolving!" box beneath stays visible through the flash (#1596).
|
||||
local EvolutionState = {}
|
||||
EvolutionState.__index = EvolutionState
|
||||
EvolutionState.isOpaque = true
|
||||
|
||||
-- SGB: SetPal_PokemonWholeScreen for the mon on display
|
||||
function EvolutionState:sgbPalettes(game)
|
||||
@@ -127,11 +127,11 @@ function EvolutionState:update(dt)
|
||||
require("src.core.Sound").playCry(game.data, self.newSpecies)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local newName = game.data.pokemon[self.newSpecies].name
|
||||
-- _EvolvedText extracts truncated (it stops at a dynamic marker the
|
||||
-- decoder does not follow), so the engine's wording stands here
|
||||
game.stack:push(TextBox.new(game,
|
||||
Strings("Congratulations!\nYour %s\nevolved into\n%s!",
|
||||
self.oldName, newName),
|
||||
-- EvolvedText then IntoText in the same box (PrintText_NoCreatingTextBox),
|
||||
-- then SFX_GET_ITEM_2 (engine/pokemon/evos_moves.asm:136-153)
|
||||
local msg = romText(game.data, "_EvolvedText", "%s evolved", self.oldName)
|
||||
.. romText(game.data, "_IntoText", "\ninto %s!", newName)
|
||||
game.stack:push(TextBox.new(game, msg,
|
||||
function()
|
||||
Music.restoreMap(game.data)
|
||||
game.stack:pop() -- the evolution screen itself
|
||||
@@ -141,13 +141,15 @@ function EvolutionState:update(dt)
|
||||
-- first so the "learned MOVE!" text / forget prompt push onto the
|
||||
-- overworld / battle-return, not this state.
|
||||
Evolution.learnEvolutionMoves(game, self.mon, self.onDone)
|
||||
end))
|
||||
end,
|
||||
TextBox.soundOpts(game, "Get_Item2")))
|
||||
end
|
||||
end
|
||||
|
||||
function EvolutionState:draw()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
-- rows 0-11 only (hlcoord 0,0 / lb bc, 12, 20, evos_moves.asm:126-128)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 96)
|
||||
|
||||
-- accelerating flash between the two forms
|
||||
local sprite, spriteTrueColor
|
||||
@@ -172,14 +174,6 @@ function EvolutionState:draw()
|
||||
require("src.render.PaletteFX").markTrueColor(x, y, sprite:getDimensions())
|
||||
end
|
||||
end
|
||||
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
if not self.done then
|
||||
Font.draw(Strings("What?"), 8, 104)
|
||||
Font.draw(self.oldName .. " is", 8, 114)
|
||||
Font.draw(Strings("evolving!"), 8, 124)
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
return EvolutionState
|
||||
|
||||
@@ -17,6 +17,14 @@ function ListMenu:sgbPalettes(game)
|
||||
end
|
||||
|
||||
local ROWS = 7
|
||||
-- LIST_MENU_BOX 4,2 - 19,12 (data/text_boxes.asm:13); 4 names from
|
||||
-- hlcoord 6,4 two rows apart (home/list_menu.asm:51-52, 364-365, 471-479)
|
||||
local ITEM_BOX = { tx = 4, ty = 2, tw = 16, th = 11 }
|
||||
local ITEM_ROWS = 4
|
||||
local ITEM_NAME_X, ITEM_TOP_Y = 48, 32
|
||||
local ITEM_CURSOR_X = 40
|
||||
local ITEM_QTY_X, ITEM_QTY_END = 112, 136
|
||||
local ITEM_MORE_X, ITEM_MORE_Y = 144, 88
|
||||
-- frames to wait before key-repeat kicks in, then between repeats
|
||||
local REPEAT_DELAY = 16
|
||||
local REPEAT_RATE = 4
|
||||
@@ -83,7 +91,20 @@ function ListMenu.new(game, title, items, opts)
|
||||
-- for their whole run (engine/menus/pc.asm, engine/menus/players_pc.asm),
|
||||
-- so their lists opt out of the A/B beep the same way Menu's noSound does
|
||||
self.noSound = opts.noSound or false
|
||||
self.rows = opts.rows or ((opts.dialogue or opts.messageBox) and 4 or ROWS)
|
||||
-- the bag's item list: a partial box the map stays visible around, not a
|
||||
-- screen of its own (home/list_menu.asm:29-31)
|
||||
self.itemBox = opts.itemBox or false
|
||||
if self.itemBox then
|
||||
self.isOpaque = false
|
||||
-- keep RunDefaultPaletteCommand's last palette: ItemMenuLoop never sets
|
||||
-- its own (engine/menus/start_sub_menus.asm:300)
|
||||
self.sgbPalettes = false
|
||||
-- wMaxMenuItem is 2 for item lists; the fourth printed row is a
|
||||
-- look-ahead the cursor cannot reach (home/list_menu.asm:46-48)
|
||||
self.cursorRows = 3
|
||||
end
|
||||
self.rows = opts.rows or (self.itemBox and ITEM_ROWS)
|
||||
or ((opts.dialogue or opts.messageBox) and 4 or ROWS)
|
||||
return self
|
||||
end
|
||||
|
||||
@@ -100,8 +121,9 @@ local function moveIndex(self, delta)
|
||||
end
|
||||
|
||||
local function syncScroll(self)
|
||||
if self.index - self.scroll > self.rows then
|
||||
self.scroll = self.index - self.rows
|
||||
local maxRow = self.cursorRows or self.rows
|
||||
if self.index - self.scroll > maxRow then
|
||||
self.scroll = self.index - maxRow
|
||||
end
|
||||
if self.index - self.scroll < 1 then self.scroll = self.index - 1 end
|
||||
end
|
||||
@@ -206,7 +228,48 @@ function ListMenu:close()
|
||||
if top == self then self.game.stack:pop() end
|
||||
end
|
||||
|
||||
-- PrintListMenuEntries, minus the price column StartMenu_Item never asks for
|
||||
-- (wPrintItemPrices = 0, engine/menus/start_sub_menus.asm)
|
||||
function ListMenu:drawItemBox()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
Font.drawBox(ITEM_BOX.tx, ITEM_BOX.ty, ITEM_BOX.tw, ITEM_BOX.th)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
if #self.items == 0 then
|
||||
Font.draw(Strings("Nothing here."), ITEM_NAME_X, ITEM_TOP_Y)
|
||||
end
|
||||
local shown = 0
|
||||
for row = 1, self.rows do
|
||||
local i = self.scroll + row
|
||||
local item = self.items[i]
|
||||
if not item then break end
|
||||
shown = shown + 1
|
||||
local y = ITEM_TOP_Y + (row - 1) * 16
|
||||
Font.draw(item.label, ITEM_NAME_X, y)
|
||||
if item.right then
|
||||
-- '×' at column 14, PrintNumber's two right-aligned digits after it
|
||||
-- (home/list_menu.asm:479-490)
|
||||
local count = item.right:sub(2)
|
||||
Font.draw(item.right:sub(1, 1), ITEM_QTY_X, y + 8)
|
||||
Font.draw(count, ITEM_QTY_END - Font.width(count), y + 8)
|
||||
end
|
||||
if i == self.index then
|
||||
Font.drawCode(self.hollowIndex == i
|
||||
and Theme.cursorHollow or Theme.cursor, ITEM_CURSOR_X, y)
|
||||
end
|
||||
if self.swapIndex == i and i ~= self.index then
|
||||
Font.drawCode(Theme.cursorHollow, ITEM_CURSOR_X, y)
|
||||
end
|
||||
end
|
||||
-- the terminator prints CANCEL and returns before the '▼'
|
||||
-- (home/list_menu.asm:372, 518-524)
|
||||
if shown == self.rows then
|
||||
Font.drawCode(Theme.moreArrow, ITEM_MORE_X, ITEM_MORE_Y)
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
function ListMenu:draw()
|
||||
if self.itemBox then return self:drawItemBox() end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
|
||||
@@ -34,6 +34,9 @@ function Menu.new(game, items, opts)
|
||||
if self.tx + self.tw > 20 then self.tx = math.max(0, 20 - self.tw) end
|
||||
end
|
||||
self.rowStep = opts.rowStep or 2
|
||||
-- engine/movie/oak_speech/oak_speech2.asm:162 (DisplayIntroNameTextBox)
|
||||
self.title = opts.title
|
||||
self.itemY = opts.itemY
|
||||
-- maxVisible: cap the box to this many rows and scroll the rest instead
|
||||
-- of growing past it (e.g. the start menu, whose row count varies with
|
||||
-- save state and mod hooks); nil/unset keeps every caller's old
|
||||
@@ -116,7 +119,17 @@ function Menu:draw()
|
||||
self.tw * 8, self.th * 8, self.anchor)
|
||||
end
|
||||
Font.drawBox(self.tx, self.ty, self.tw, self.th)
|
||||
-- PlaceString at hlcoord 3,0 writes over the border row it was just
|
||||
-- drawn on (oak_speech2.asm:162-170)
|
||||
if self.title then
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", (self.tx + 3) * 8, self.ty * 8,
|
||||
#Font.split(self.title) * 8, 8)
|
||||
end
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
if self.title then
|
||||
Font.draw(self.title, (self.tx + 3) * 8, self.ty * 8)
|
||||
end
|
||||
local visible = (self.maxVisible and math.min(self.maxVisible, #self.items))
|
||||
or #self.items
|
||||
-- Row Y: pokered's boxed menus anchor the choices to the BOTTOM interior
|
||||
@@ -130,15 +143,18 @@ function Menu:draw()
|
||||
-- USE/TOSS is th = 5 for two choices (#284, matching text_boxes.asm's
|
||||
-- USE_TOSS_MENU_TEMPLATE rows 10..14), and a top anchor pushed TOSS onto
|
||||
-- the bottom border (#564, #572).
|
||||
local function rowY(row)
|
||||
if self.itemY then
|
||||
return (self.ty + self.itemY + (row - 1) * self.rowStep) * 8
|
||||
end
|
||||
return (self.ty + self.th - 2 - (visible - row) * self.rowStep) * 8
|
||||
end
|
||||
for row = 1, visible do
|
||||
local item = self.items[self.scroll + row]
|
||||
if not item then break end
|
||||
Font.draw(item.label, (self.tx + 2) * 8,
|
||||
(self.ty + self.th - 2 - (visible - row) * self.rowStep) * 8)
|
||||
Font.draw(item.label, (self.tx + 2) * 8, rowY(row))
|
||||
end
|
||||
local cursorRow = self.index - self.scroll
|
||||
Font.drawCode(Theme.cursor, (self.tx + 1) * 8,
|
||||
(self.ty + self.th - 2 - (visible - cursorRow) * self.rowStep) * 8)
|
||||
Font.drawCode(Theme.cursor, (self.tx + 1) * 8, rowY(self.index - self.scroll))
|
||||
-- moreArrow ($EE): the same "more below" glyph OptionRows/ManagerState
|
||||
-- use, sat on the bottom border like TextBox's page-advance cursor. It
|
||||
-- has to be the border row, not ty + th - 2: that is the last interior
|
||||
|
||||
@@ -67,6 +67,7 @@ function NamingScreen.new(game, opts)
|
||||
self.game = game
|
||||
self.title = opts.title or Strings("YOUR NAME?")
|
||||
self.presets = opts.presets
|
||||
self.introBox = opts.introBox
|
||||
self.maxLen = opts.maxLen or 7
|
||||
self.default = opts.default
|
||||
self.onDone = opts.onDone
|
||||
@@ -95,14 +96,25 @@ function NamingScreen:enter()
|
||||
onSelect = function()
|
||||
-- the menu already popped itself; pop the naming screen too
|
||||
self.game.stack:pop()
|
||||
if self.onDone then self.onDone(preset) end
|
||||
if self.onDone then self.onDone(preset, false) end
|
||||
end,
|
||||
})
|
||||
end
|
||||
if self.introBox then
|
||||
-- DisplayIntroNameTextBox (oak_speech2.asm:162): TextBoxBorder at
|
||||
-- hlcoord 0,0 with b=$a c=$9, "NAME" at hlcoord 3,0, list at hlcoord 2,2
|
||||
-- TextBoxBorder's b = $a is a fixed 12-row box, whatever the preset
|
||||
-- list's length (oak_speech2.asm:163-166)
|
||||
self.game.stack:push(Menu.new(self.game, items, {
|
||||
tx = 0, ty = 0, tw = 11, th = 12,
|
||||
itemY = 2, title = Strings("NAME"), cancelable = false,
|
||||
}))
|
||||
else
|
||||
self.game.stack:push(Menu.new(self.game, items, {
|
||||
tx = 4, ty = 0, tw = 12, th = #items * 2 + 2, cancelable = false,
|
||||
}))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function NamingScreen:confirm()
|
||||
@@ -125,7 +137,7 @@ function NamingScreen:confirm()
|
||||
end
|
||||
Sound.play(self.game.data, "Press_AB")
|
||||
self.game.stack:pop()
|
||||
if self.onDone then self.onDone(name) end
|
||||
if self.onDone then self.onDone(name, true) end
|
||||
end
|
||||
|
||||
function NamingScreen:grid()
|
||||
|
||||
@@ -35,6 +35,29 @@ OakSpeech.letterboxWhite = true
|
||||
local FADE_FRAMES = 24
|
||||
local WIPE_FRAMES = 32
|
||||
|
||||
-- OakSpeechSlidePicRight / OakSpeechSlidePicLeft (oak_speech2.asm:67-89)
|
||||
local SLIDE_TILES = 6
|
||||
local SLIDE_FRAMES = 3
|
||||
|
||||
local PicSlide = {}
|
||||
PicSlide.__index = PicSlide
|
||||
|
||||
function PicSlide:update(dt)
|
||||
-- OakSpeechSlidePicLeft: ClearScreenArea, ld c, 10 / DelayFrames, Delay3
|
||||
-- before the first slide step (oak_speech2.asm:69-78)
|
||||
if (self.delay or 0) > 0 then
|
||||
self.delay = self.delay - 1
|
||||
return
|
||||
end
|
||||
self.t = self.t + 1
|
||||
local tiles = math.min(SLIDE_TILES, math.floor(self.t / SLIDE_FRAMES))
|
||||
self.speech.picSlide = (self.dir > 0 and tiles or (SLIDE_TILES - tiles)) * 8
|
||||
if tiles >= SLIDE_TILES then
|
||||
self.game.stack:pop()
|
||||
if self.onDone then self.onDone() end
|
||||
end
|
||||
end
|
||||
|
||||
-- naming presets are boot config (field.boot.namePresets), which a total
|
||||
-- conversion replaces; the Red/Blue lists remain the fallback
|
||||
local function namePresets(game, who, fallback)
|
||||
@@ -155,6 +178,10 @@ function OakSpeech.defaultSteps(speech)
|
||||
kind = "say",
|
||||
textKey = "_IntroducePlayerText",
|
||||
pic = "player",
|
||||
-- oak_speech.asm:89-92: MovePicLeft, then IntroducePlayerText's
|
||||
-- `prompt` (text_2.asm:1730) waits for A and leaves the box up
|
||||
reveal = "wipe",
|
||||
stay = true,
|
||||
},
|
||||
{
|
||||
id = "name_player",
|
||||
@@ -171,12 +198,19 @@ function OakSpeech.defaultSteps(speech)
|
||||
id = "confirm_player_name",
|
||||
kind = "say",
|
||||
textKey = "_YourNameIsText",
|
||||
-- _YourNameIsText's `prompt` (text_2.asm:1766), then GBFadeOutToWhite
|
||||
-- / ClearScreen with the box still up (oak_speech.asm:93-94)
|
||||
fadeOut = true,
|
||||
},
|
||||
{
|
||||
id = "ask_rival_name",
|
||||
kind = "say",
|
||||
textKey = "_IntroduceRivalText",
|
||||
pic = "rival",
|
||||
-- oak_speech.asm:98-101: FadeInIntroPic, then IntroduceRivalText's
|
||||
-- `prompt` (text_2.asm:1740) leaves the box up for ChooseRivalName
|
||||
reveal = "fade",
|
||||
stay = true,
|
||||
},
|
||||
{
|
||||
id = "name_rival",
|
||||
@@ -191,6 +225,9 @@ function OakSpeech.defaultSteps(speech)
|
||||
id = "confirm_rival_name",
|
||||
kind = "say",
|
||||
textKey = "_HisNameIsText",
|
||||
-- _HisNameIsText's `prompt` (text_2.asm:1772) then the .skipSpeech
|
||||
-- fade with the box up (oak_speech.asm:103-104)
|
||||
fadeOut = true,
|
||||
},
|
||||
{
|
||||
id = "legend",
|
||||
@@ -378,7 +415,25 @@ function OakSpeech:runStep(step)
|
||||
self:applyPic(step)
|
||||
self:afterReveal(step, function()
|
||||
self:runCry(step)
|
||||
if step.stay or step.fadeOut then
|
||||
local box = TextBox.new(self.game, self:stepText(step), nil,
|
||||
{ stay = { prompt = true, onShown = function()
|
||||
if step.fadeOut then
|
||||
-- GBFadeOutToWhite / ClearScreen (oak_speech.asm:93-94)
|
||||
self.game.stack:push(require("src.render.Transition")
|
||||
.whiteFlash(self.game, nil, function()
|
||||
self:closeHoldBox()
|
||||
self:advance()
|
||||
end))
|
||||
else
|
||||
self:advance()
|
||||
end
|
||||
end } })
|
||||
self.holdBox = box
|
||||
self.game.stack:push(box)
|
||||
else
|
||||
self:sayText(self:stepText(step), function() self:advance() end)
|
||||
end
|
||||
end)
|
||||
elseif kind == "demo" then
|
||||
-- NIDORINO show-off: mirrored front sprite + wipe + cry + text 2A
|
||||
@@ -394,20 +449,36 @@ function OakSpeech:runStep(step)
|
||||
local presets = step.presets
|
||||
or namePresets(self.game, step.presetsWho or who,
|
||||
step.presetsFallback or { "RED" })
|
||||
local function openNaming()
|
||||
require("src.ui.Screens").push(self.game, "NamingScreen", {
|
||||
title = step.title or (who == "rival" and "HIS NAME?" or Strings("YOUR NAME?")),
|
||||
presets = presets,
|
||||
introBox = true,
|
||||
maxLen = step.maxLen or self.nameLen,
|
||||
onDone = function(name)
|
||||
onDone = function(name, custom)
|
||||
if who == "rival" then
|
||||
self.game.save.player.rival = name
|
||||
else
|
||||
self.game.save.player.name = name
|
||||
end
|
||||
self:recordAnswer(step, 1, name, name)
|
||||
-- YourNameIsText / HisNameIsText print into the box this one
|
||||
-- held (oak_speech2.asm:26-28, :59-61)
|
||||
self:closeHoldBox()
|
||||
if custom then
|
||||
-- .customName: ClearScreen / Delay3 / pic recentered, no
|
||||
-- slide-back (oak_speech2.asm:21-25)
|
||||
self.picSlide = 0
|
||||
self:advance()
|
||||
else
|
||||
-- OakSpeechSlidePicLeft's 13-frame pre-slide beat
|
||||
-- (oak_speech2.asm:69-78)
|
||||
self:slidePic(-1, function() self:advance() end, 13)
|
||||
end
|
||||
end,
|
||||
})
|
||||
end
|
||||
self:slidePic(1, openNaming)
|
||||
elseif kind == "choice" then
|
||||
self:applyPic(step)
|
||||
self:afterReveal(step, function()
|
||||
@@ -518,6 +589,22 @@ function OakSpeech:revealPic(kind, next)
|
||||
}
|
||||
end
|
||||
|
||||
-- ..(engine/movie/oak_speech/oak_speech2.asm ln 67)
|
||||
function OakSpeech:slidePic(dir, onDone, delay)
|
||||
self.picSlide = (dir > 0 and 0 or SLIDE_TILES * 8)
|
||||
self.game.stack:push(setmetatable({
|
||||
game = self.game, speech = self, dir = dir, t = 0, onDone = onDone,
|
||||
delay = delay,
|
||||
}, PicSlide))
|
||||
end
|
||||
|
||||
-- IntroducePlayerText's text_end box (oak_speech.asm:90) is ours to close
|
||||
function OakSpeech:closeHoldBox()
|
||||
local box = self.holdBox
|
||||
self.holdBox = nil
|
||||
if box and self.game.stack:top() == box then self.game.stack:pop() end
|
||||
end
|
||||
|
||||
function OakSpeech:advance()
|
||||
self.step = self.step + 1
|
||||
-- picFlip belongs to the pic, not to the step: OakSpeechText2 prints 2A
|
||||
@@ -615,7 +702,7 @@ function OakSpeech:draw()
|
||||
-- it like the sprite buffer does ((8 - w) >> 1) tiles across,
|
||||
-- bottom-aligned
|
||||
local w, h = self.pic:getDimensions()
|
||||
local x = 48 + math.floor((8 - w / 8) / 2) * 8
|
||||
local x = 48 + math.floor((8 - w / 8) / 2) * 8 + (self.picSlide or 0)
|
||||
local y = 32 + (7 - h / 8) * 8
|
||||
local reveal = self.picReveal
|
||||
local off = 0
|
||||
|
||||
@@ -21,6 +21,7 @@ local GameVersion = require("src.core.GameVersion")
|
||||
local VideoMode = require("src.core.VideoMode")
|
||||
local Orientation = require("src.core.Orientation")
|
||||
local FaithfulRes = require("src.core.FaithfulRes")
|
||||
local ScreenPosition = require("src.core.ScreenPosition")
|
||||
local FrameCap = require("src.core.FrameCap")
|
||||
local Performance = require("src.core.Performance")
|
||||
local Logger = require("src.core.Logger")
|
||||
@@ -443,6 +444,16 @@ local function buildRows(game)
|
||||
FaithfulRes.apply(o.faithfulRes)
|
||||
return true
|
||||
end },
|
||||
{ id = "screenPos", label = Strings("SCREEN POS"),
|
||||
value = function(g)
|
||||
return Strings(ScreenPosition.label(g.save.options.screenPos))
|
||||
end,
|
||||
step = function(g, dir)
|
||||
local o = g.save.options
|
||||
o.screenPos = ScreenPosition.cycle(o.screenPos, dir)
|
||||
ScreenPosition.setMode(o.screenPos)
|
||||
return true
|
||||
end },
|
||||
-- hard render cap (issue #88): bounds the present rate so a
|
||||
-- driver-forced vsync-off run cannot spin at thousands of FPS. Logic
|
||||
-- is fixed-step off dt, so this touches presentation only.
|
||||
|
||||
@@ -13,6 +13,7 @@ local ListMenu = require("src.ui.ListMenu")
|
||||
local Menu = require("src.ui.Menu")
|
||||
local QuantityBox = require("src.ui.QuantityBox")
|
||||
local Strings = require("src.core.Strings")
|
||||
local romText = require("src.core.RomText")
|
||||
|
||||
local ShopMenu = {}
|
||||
|
||||
@@ -57,7 +58,8 @@ local function buy(game, stock)
|
||||
end
|
||||
local cost = qty * def.price
|
||||
-- _PokemartTellBuyPriceText + yes/no confirm
|
||||
list.footer = Strings("%s?\nThat will be\n¥%d. OK?", def.name, cost)
|
||||
list.footer = romText(game.data, "_PokemartTellBuyPriceText",
|
||||
"%s?\nThat will be\n¥%d. OK?", def.name, cost)
|
||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||
if not yes then
|
||||
list.footer = greet
|
||||
@@ -147,7 +149,8 @@ local function sell(game)
|
||||
return
|
||||
end
|
||||
-- _PokemartTellSellPriceText + yes/no confirm
|
||||
list.footer = Strings("I can pay you\n¥%d for that.", unit * qty)
|
||||
list.footer = romText(game.data, "_PokemartTellSellPriceText",
|
||||
"I can pay you\n¥%d for that.", unit * qty)
|
||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||
if not yes then
|
||||
list.footer = greet
|
||||
|
||||