Compare commits

...

80 Commits

Author SHA1 Message Date
bryanthaboi 1cfd91a503 Merge pull request #1309 from bryanthaboi/dev
path read
2026-08-14 17:54:26 -04:00
bryanthaboi fb738fa1ce path read 2026-08-14 17:51:55 -04:00
github-actions c0ad6d0328 chore(ios): update app-repo.json [skip ci] 2026-08-14 17:19:40 -04:00
bryanthaboi 84de8c9cb1 Merge pull request #1306 from bryanthaboi/dev
for greg
2026-08-14 17:10:29 -04:00
bryanthaboi 6307bc92f6 Merge branch 'main' into dev 2026-08-14 17:07:19 -04:00
bryanthaboi 052dd26b3e Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-08-14 17:07:08 -04:00
bryanthaboi c8f6c7241b i did it for greg 2026-08-14 17:07:06 -04:00
bryanthaboi b3928388ef Merge pull request #1304 from ShaneMcGovernIE/agent/mod-storage-opaque-bytes
Add opaque byte storage to mod API
2026-08-14 16:59:06 -04:00
bryanthaboi a38fae5a97 Merge pull request #1302 from AverageConsumer/feat/mod-field-items
feat(mods): expose contextual field items
2026-08-14 16:57:16 -04:00
Shane McGovern 9fab992d42 Add opaque byte storage to mod API 2026-08-14 21:42:45 +01:00
AverageConsumer dcc388a942 feat(mods): expose contextual field items 2026-08-14 21:39:18 +02:00
bryanthaboi 797a6bebfe Merge pull request #1253 from mleo2003/padcursor-edge-scroll
launcher: pad cursor edge-scroll for stickless handhelds
2026-08-14 15:14:45 -04:00
bryanthaboi 97a9c0f58f Merge pull request #1286 from MaxTomahawk/adaptive-trainers/battle-party-scope
feat(mod-api): add trainer battle party scope
2026-08-14 15:14:31 -04:00
MaxTomahawk 407f649e9d fix(mod-api): harden deferred trainer preparation 2026-08-14 17:57:59 +02:00
github-actions 84635cdfdf chore(ios): update app-repo.json [skip ci] 2026-08-14 11:52:58 -04:00
bryanthaboi 3de45b671c Merge pull request #1288 from bryanthaboi/dev
CLOSES #1283
2026-08-14 11:43:13 -04:00
bryanthaboi 4395792226 CLOSES #1283 2026-08-14 11:41:54 -04:00
MaxTomahawk a77210799f feat(mod-api): add trainer battle party scope 2026-08-14 17:33:17 +02:00
github-actions 6f67292b0e chore(ios): update app-repo.json [skip ci] 2026-08-14 10:53:45 -04:00
bryanthaboi 78e8a31ead Merge pull request #1280 from bryanthaboi/dev
Update build_appimage.sh
2026-08-14 10:44:43 -04:00
bryanthaboi b6388013ec Update build_appimage.sh 2026-08-14 10:40:36 -04:00
github-actions ba7cd8fabf chore(ios): update app-repo.json [skip ci] 2026-08-14 10:28:05 -04:00
bryanthaboi c8f7f396ce Merge pull request #1278 from bryanthaboi/dev
updates changes and modifications
2026-08-14 10:19:00 -04:00
bryanthaboi a94fecfec8 Closes #919, closes #982, closes #1003, closes #1012, closes #1022, closes #1028, closes #1033 2026-08-14 10:05:51 -04:00
bryanthaboi 2010ba71a6 better hover 2026-08-14 07:20:48 -04:00
bryanthaboi e90dff6764 Merge branch 'grandmas-kitchen' into dev 2026-08-14 07:13:00 -04:00
bryanthaboi f4658b89ae Merge pull request #1246 from 1Jamie/fix/gen2-battle-rng-static-damage 2026-08-14 05:57:51 -04:00
bryanthaboi da63163216 Merge pull request #1242 from AverageConsumer/codex/final-frame-output-hooks 2026-08-14 05:55:26 -04:00
bryanthaboi fceaa2b54c Merge pull request #1240 from AverageConsumer/codex/gen2-battle-ui-visibility 2026-08-14 05:54:22 -04:00
bryanthaboi be2ef46ebc Merge pull request #1247 from ShaneMcGovernIE/fix/1243-tm-sell-at-marts 2026-08-14 05:53:30 -04:00
bryanthaboi d8e86e6878 Merge pull request #1258 from TheRealSolidusSnake/feature/ship-gen1tls-windows 2026-08-14 05:52:52 -04:00
Solidus Snake 7e57e3174e Ship gen1tls.dll in Windows release zips.
TLS source landed earlier, but release CI never packaged the dialer, so
hosted archipelago.gg rooms (wss://) failed on stock Windows builds.
Build the Native AOT DLL on windows-2022 and bundle it from build.sh.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 22:38:04 -04:00
mleo2003 5192106730 launcher: pad cursor edge-scroll for stickless handhelds
The launcher's SHORT-WINDOW SCROLL engages correctly on a 480px-tall
panel, but _pageScroll has only three inputs -- mouse wheel, touch drag,
and the right analog stick -- and a device with none of them cannot
reach anything below the fold. On an RG35XXSP (muOS, 640x480)
/proc/bus/input/devices lists one gpio-keys-polled device reporting the
D-pad as a hat: no analog axes exist. There is also no scroll-to-focus
anywhere, so moving the pad cursor never touches _pageScroll.

The pad cursor already computes the motion: it is clamped to the safe
area and discarded. Spend that overshoot on the existing wheel path
instead -- the same one the right stick feeds a few lines below.

Only the overshoot scrolls, so parking the cursor at the edge does
nothing; it has to be actively pushed. The enclosing block runs only on
non-zero pad input, so a real mouse never reaches it and desktop
behaviour is unchanged.

Tested on an RG35XXSP at stock Kit.lua scale (0.9): the pager and footer
become reachable, pushing up at the top scrolls back, and parking at the
edge does nothing. Refs #1142.
2026-08-13 18:14:28 -07:00
1jamie dfeacc36b0 - adding ability to specify gen specific deps in manifest system.
- fixed issue with wayland users drag and drop causing CTD
2026-08-13 18:18:59 -05:00
ShaneMcGovernIE 1586aec9f6 fix(gen2): sell TMs at Poké Marts instead of opening the teach party
Selecting a TM in the mart's SELL pack (and the item PC's DEPOSIT pack) opened the teach-party screen: PackMenu:useSelected taught any item carrying `teaches`, even when the pack was built as a chooser (DepositSellPack, world = {}). On the cart that chooser's jumptable is four ScrollingMenus and never reaches tmhm.asm, so the row must hand back to the caller instead.

Gate the teach and field-NOUSE branches behind world.useFieldItem so only the real field PACK teaches; choosers now hand TMs to onChoose. Selling then prices a TM at half its ItemAttributes price -- exactly half of what the Goldenrod/Celadon TM shelves charge, and half of the hidden price for the rest, the way SelectQuantityToSell -> GetItemPrice -> Sell_HalvePrice does on the cart.

Closes #1243
2026-08-13 23:55:31 +01:00
1jamie 35d44efb8b fixes to the failing test systems 2026-08-13 17:44:45 -05:00
1jamie 500d8c2c07 feat(mods): enhance mod management with profile controls and dependency checks
- Added dedicated profile control in the MODS panel for easier profile management.
- Implemented dependency checking during mod installation and updates to ensure compatibility.
- Improved manifest parsing to support GitHub repository hints for dependencies.
- Enhanced UI interactions for saving and renaming profiles.
- Updated tests to cover new functionality and ensure stability.
2026-08-13 17:33:29 -05:00
AverageConsumer 09204daa28 feat(mods): expose opt-in final-frame output hooks 2026-08-13 23:04:46 +02:00
github-actions 01329758ff chore(ios): update app-repo.json [skip ci] 2026-08-13 16:49:24 -04:00
AverageConsumer 7b8cb127a8 feat(gen2): share battle UI visibility hooks 2026-08-13 22:41:26 +02:00
bryanthaboi 8e4d5b6807 Merge pull request #1239 from bryanthaboi/dev
something for avg consumer and something for everyone else
2026-08-13 16:40:04 -04:00
bryanthaboi 26e9e1d597 Merge pull request #1238 from AverageConsumer/codex/gen2-map-overview-parity
feat(mods): expose map overviews in Gold
2026-08-13 16:30:47 -04:00
bryanthaboi f7f32d2786 scroll list 2026-08-13 16:29:59 -04:00
AverageConsumer c5192eaea4 feat(mods): expose map overviews in Gold 2026-08-13 22:08:00 +02:00
github-actions c2906744ce chore(ios): update app-repo.json [skip ci] 2026-08-13 15:53:20 -04:00
bryanthaboi 8e6b5ae73d Merge pull request #1234 from bryanthaboi/dev
bugs and slugs
2026-08-13 15:44:26 -04:00
bryanthaboi 0b96ac0ab6 fancy cart 2026-08-13 15:36:08 -04:00
bryanthaboi f6a035947f Update OverworldController.lua 2026-08-13 13:31:36 -04:00
bryanthaboi 941181d31c check bonxes 2026-08-13 13:22:18 -04:00
bryanthaboi 9ceb1a8940 Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-08-13 13:07:22 -04:00
bryanthaboi 37051a26b5 CLOSES #1206, CLOSES #1189, CLOSES #1175, CLOSES #1129, CLOSES #1119, CLOSES #1089, CLOSES #1073, CLOSES #1069, CLOSES #1065, CLOSES #1010, CLOSES #990, CLOSES #989, CLOSES #988, CLOSES #978, CLOSES #944, CLOSES #936, CLOSES #1221, CLOSES #1220, CLOSES #1193, CLOSES #1101, CLOSES #1066, CLOSES #1056, CLOSES #1041, CLOSES #984, CLOSES #1225, CLOSES #1214, CLOSES #1011, CLOSES #1035, CLOSES #1219, CLOSES #1048, CLOSES #1047, CLOSES #964, CLOSES #949, CLOSES #1149, CLOSES #1007, CLOSES #914, CLOSES #1115, CLOSES #1146, CLOSES #999, CLOSES #1207, CLOSES #1029, CLOSES #1120, CLOSES #987, CLOSES #983 2026-08-13 13:07:20 -04:00
bryanthaboi b12a0dac91 Merge pull request #1205 from 1Jamie/fix/gen2-battle-rng-static-damage 2026-08-13 11:27:02 -04:00
bryanthaboi e4786a78d9 Merge pull request #1226 from mresnick67/steps-bridge 2026-08-13 11:23:55 -04:00
Myles Resnick bde606f966 Permission-gated step bridge for sandboxed mods
The sandbox blocks love.system and love.filesystem, which orphans the
native step bridge (#452, #489): its one consumer can no longer call
syncHealthSteps or read steps_pending.json (#1186).

Adds a "steps" manifest permission (shown to the player like the
others) gating a mod.steps facade: available() probes the bridge
quietly, sync() forwards the async refresh, poll() hands the mod its
copy of a delivery. The engine owns the pending file -- mods never name
a path and receive only { steps, from, to }. Without the permission the
acting calls name it, following the network gate. No new events, hooks
or registries; nothing removed.

RFC 0009. Tests: tests/modkit/cases/steps_bridge.lua (no-mod cold
bridge, permissioned sync/poll, per-mod copies, contract-field
filtering, malformed-delivery drop, unpermissioned refusal, bridgeless
build).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 11:19:06 -04:00
1jamie a722d24759 fix merge conflicts with dev 2026-08-13 07:39:11 -05:00
github-actions 833388c235 chore(ios): update app-repo.json [skip ci] 2026-08-13 06:22:43 -04:00
bryanthaboi 2ac527ae3c Merge pull request #1222 from bryanthaboi/dev
bug fixing and friends
2026-08-13 06:14:29 -04:00
bryanthaboi df35193ad3 Merge pull request #1204 from castdrian/ios-improvements
feat(ios): improve branding and fix haptic feedback
2026-08-13 06:06:31 -04:00
bryanthaboi 9b6365c9cc Merge pull request #1202 from HimioneGranger/upstream-android-host-extension
Android: add optional native host lifecycle hooks
2026-08-13 06:06:24 -04:00
bryanthaboi 285a98533a Merge pull request #1201 from HimioneGranger/upstream-payload-host-contract
feat(update): gate payloads by native host family
2026-08-13 06:06:18 -04:00
bryanthaboi f952edf77a Merge pull request #1200 from HimioneGranger/upstream-host-display-api
feat(host): add optional display lifecycle backend
2026-08-13 06:06:12 -04:00
bryanthaboi ae8360b2ae Merge pull request #1199 from HimioneGranger/upstream-quest-focus-api
fix(launcher): route focus input through mod dialogs
2026-08-13 06:06:05 -04:00
bryanthaboi 52e36ad7e4 fix testsZ 2026-08-13 06:05:27 -04:00
bryanthaboi 62b9f04191 Merge pull request #1209 from MaxTomahawk/fix/checkpoint-engine-version-restore
fix(mods): allow checkpoint restore across engine versions
2026-08-13 05:35:05 -04:00
bryanthaboi 1010d0584a Merge pull request #1210 from Yukitty/patch-hopFrames
fix(OverworldController): ledge jump arc to match player stepFrames
2026-08-13 05:33:02 -04:00
bryanthaboi 6864173f35 Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-08-13 05:30:35 -04:00
bryanthaboi e9d431b3ff CLOSES #1091, CLOSES #1097, CLOSES #1100, CLOSES #1104, CLOSES #1135, CLOSES #1155, CLOSES #1157, CLOSES #1158, CLOSES #1159, CLOSES #1160, CLOSES #1167, CLOSES #1208 2026-08-13 05:30:33 -04:00
Yukita Mayako f138827917 fix(OverworldController): ledge jump arc to match player stepFrames 2026-08-12 21:23:35 -04:00
Max a68c47e7e1 fix(mods): allow checkpoint restore across engine versions 2026-08-13 02:28:57 +02:00
Adrian Castro d648990a81 feat(ios): brand app and tune haptic feedback 2026-08-13 02:19:45 +02:00
1jamie 6ff7dd0c38 refactor(battle): enhance RNG handling and fixed damage mechanics
- Always supply Gen 2 battle random (BattleRandom) and Gen 1 / love-style rng.
- Add EFFECT_STATIC_DAMAGE for Sonic Boom and Dragon Rage (move power; Ghost
  immunity via resettypematchup for static damage only).
- Keep Magnitude rolling via BattleRandom instead of collapsing to Magnitude 4.
- Cover RNG, static damage, and Magnitude in gen2 battle tests.
2026-08-12 17:52:02 -05:00
Codex Agent ee00728e9e android: add optional native host lifecycle hooks 2026-08-12 17:41:26 -05:00
Codex Agent 0aab11b690 feat(update): gate payloads by native host family 2026-08-12 17:23:25 -05:00
Codex Agent c2d9af693d feat(host): add optional display lifecycle backend 2026-08-12 17:07:32 -05:00
Codex Agent 1642113d1f fix(launcher): route focus input through mod dialogs 2026-08-12 16:48:29 -05:00
github-actions d64627e062 chore(ios): update app-repo.json [skip ci] 2026-08-12 17:16:13 -04:00
bryanthaboi c0f654f8c2 Merge pull request #1195 from AverageConsumer/codex/mod-power-info
feat(mods): expose read-only device power info
2026-08-12 16:55:15 -04:00
AverageConsumer e44769a48a Expose read-only device power info to mods 2026-08-12 22:13:32 +02:00
bryanthaboi d38faab03c Delete mod-sandbox-notice.txt 2026-08-12 09:49:43 -04:00
bryanthaboi 83682f011d grandmas-kitchn 2026-08-12 09:22:30 -04:00
200 changed files with 9658 additions and 1487 deletions
+2 -2
View File
@@ -94,8 +94,8 @@ jobs:
if: github.repository == 'bryanthaboi/gen1recomp'
uses: actions/upload-artifact@v7
with:
name: gen1recomp-ios-ipa
path: dist/ios/gen1recomp.ipa
name: gen1recomp++-ios-ipa
path: dist/ios/gen1recomp++.ipa
if-no-files-found: error
retention-days: 7
- name: clean up signing keychain
+2 -2
View File
@@ -22,7 +22,7 @@ jobs:
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
HEAD_REPOSITORY: ${{ github.event.workflow_run.head_repository.full_name }}
run: |
artifact_id="$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts" --jq '.artifacts[] | select(.name == "gen1recomp-ios-ipa") | .id')"
artifact_id="$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts" --jq '.artifacts[] | select(.name == "gen1recomp++-ios-ipa") | .id')"
[ -n "$artifact_id" ] || exit 0
head_owner="${HEAD_REPOSITORY%%/*}"
pr_number="$(gh api "repos/$GITHUB_REPOSITORY/pulls?state=open&head=$head_owner:$HEAD_BRANCH" --jq '.[0].number // empty')"
@@ -44,7 +44,7 @@ jobs:
uses: thollander/actions-comment-pull-request@v3
with:
message: |
[gen1recomp.ipa](${{ steps.artifact.outputs.artifact_url }})
[gen1recomp++.ipa](${{ steps.artifact.outputs.artifact_url }})
**Commit**: [#${{ steps.build-info.outputs.hash }}](https://github.com/${{ github.event.workflow_run.head_repository.full_name }}/commit/${{ github.event.workflow_run.head_sha }})
**Build Time**: `${{ steps.build-info.outputs.time }}`
+59 -12
View File
@@ -252,8 +252,40 @@ jobs:
Remove-Item $env:UWP_PFX -Force -ErrorAction SilentlyContinue
}
# Windows Native AOT TLS dialer. The Mac release runner fuses the win64 zip
# from LÖVE's prebuilt binaries and cannot cross-compile this DLL, so build
# it here and inject it in the release job before scripts/build.sh win.
native-tls-win:
name: build Windows gen1tls.dll
needs: version
runs-on: windows-2022
steps:
- uses: actions/checkout@v7
- name: Setup .NET 8
uses: actions/setup-dotnet@v4
with:
dotnet-version: "8.0.x"
- name: Publish gen1tls (win-x64 Native AOT)
shell: pwsh
run: |
$out = "dist/native/win-x64"
New-Item -ItemType Directory -Force -Path $out | Out-Null
dotnet publish native/tls_dial/Gen1Tls.csproj `
-c Release -r win-x64 -o $out
if (-not (Test-Path "$out/gen1tls.dll")) {
throw "gen1tls.dll missing after publish"
}
Get-Item "$out/gen1tls.dll" | Format-List Name, Length, LastWriteTime
- name: Upload gen1tls.dll
uses: actions/upload-artifact@v7
with:
name: gen1tls-win-x64
path: dist/native/win-x64/gen1tls.dll
if-no-files-found: error
retention-days: 1
release:
needs: [version, xbox-uwp, linux-arm64]
needs: [version, xbox-uwp, linux-arm64, native-tls-win]
runs-on: ${{ fromJSON(github.repository == 'bryanthaboi/gen1recomp' && '["self-hosted", "macOS"]' || '"macos-latest"') }}
steps:
@@ -269,6 +301,12 @@ jobs:
fetch-depth: 0
fetch-tags: true
- name: Download Windows gen1tls dialer
uses: actions/download-artifact@v8
with:
name: gen1tls-win-x64
path: dist/native/win-x64
- name: Import signing certificate into a temporary keychain
if: github.repository == 'bryanthaboi/gen1recomp'
run: |
@@ -307,13 +345,21 @@ jobs:
security find-identity -v -p codesigning "$KEYCHAIN_PATH"
- name: Build macOS + Windows + Linux
env:
GEN1TLS_DLL: ${{ github.workspace }}/dist/native/win-x64/gen1tls.dll
run: |
set -euo pipefail
# Sign in-build (identity auto-detected from the temp keychain);
# notarize separately below so it uses secret credentials, not a
# login-keychain profile. "all" also builds the Linux AppImage,
# which needs no signing/notarization.
if [ ! -f "$GEN1TLS_DLL" ]; then
echo "::error::gen1tls.dll missing at $GEN1TLS_DLL (native-tls-win job)"
exit 1
fi
scripts/build.sh all --version "${{ needs.version.outputs.version }}" --no-notarize
unzip -l dist/win/gen1recomp-win64.zip | grep -F gen1tls.dll \
|| { echo "::error::Windows zip is missing gen1tls.dll"; exit 1; }
- name: Build Android
run: |
@@ -444,9 +490,9 @@ jobs:
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; }
cp "$apk" "$outdir/gen1recomp-${v}-android.apk"
ipa="dist/ios/gen1recomp.ipa"
ipa="dist/ios/gen1recomp++.ipa"
[ -f "$ipa" ] || { echo "::error::$ipa not found (expected from scripts/build_ios.sh --device)"; exit 1; }
cp "$ipa" "$outdir/gen1recomp-${v}-ios.ipa"
cp "$ipa" "$outdir/gen1recomp++-${v}-ios.ipa"
swzip="dist/switch/gen1recomp-${v}-switch.zip"
[ -f "$swzip" ] || { echo "::error::$swzip not found (expected from scripts/build_switch.sh --fused → pack_sd_zip.sh)"; exit 1; }
@@ -580,7 +626,7 @@ jobs:
"dist/release/gen1recomp-${v}-linux.zip"
"dist/release/gen1recomp-${v}-linux-arm64.AppImage"
"dist/release/gen1recomp-${v}-android.apk"
"dist/release/gen1recomp-${v}-ios.ipa"
"dist/release/gen1recomp++-${v}-ios.ipa"
"dist/release/gen1recomp-${v}-switch.zip"
"dist/release/gen1recomp-${v}-xbox-uwp.zip"
"dist/release/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip"
@@ -602,14 +648,15 @@ jobs:
run: |
set -euo pipefail
v="${{ needs.version.outputs.version }}"
ipa="dist/release/gen1recomp-${v}-ios.ipa"
ipa="dist/release/gen1recomp++-${v}-ios.ipa"
app_repo="mobile/ios/app-repo.json"
[ -f "$ipa" ] || { echo "::error::$ipa not found"; exit 1; }
[ -f "$app_repo" ] || { echo "::error::$app_repo not found"; exit 1; }
date="$(date -u +"%Y-%m-%d")"
size="$(wc -c < "$ipa" | tr -d '[:space:]')"
download_url="https://github.com/${GITHUB_REPOSITORY}/releases/download/v${v}/gen1recomp-${v}-ios.ipa"
download_url="https://github.com/${GITHUB_REPOSITORY}/releases/download/v${v}/gen1recomp++-${v}-ios.ipa"
bundle_id="com.theboisclub.gen1recompplusplus"
localized_description="Gen1Recomp - A native Lua / LÖVE2D recreation of Gen 1 Poke"
release_notes="$(GH_TOKEN="${{ github.token }}" gh release view "v${v}" --json body --jq '.body // ""' 2>/dev/null || true)"
if [ -n "$release_notes" ]; then
@@ -623,15 +670,15 @@ jobs:
--argjson size "$size" \
'{version: $version, date: $date, size: $size, downloadURL: $download_url, localizedDescription: $localized_description}')"
if jq -e --arg version "$v" \
'any(.apps[] | select(.bundleIdentifier == "com.theboisclub.gen1recomp").versions[]?; .version == $version)' \
if jq -e --arg bundle_id "$bundle_id" --arg version "$v" \
'any(.apps[] | select(.bundleIdentifier == $bundle_id).versions[]?; .version == $version)' \
"$app_repo" >/dev/null; then
jq --arg version "$v" --argjson entry "$entry" \
'(.apps[] | select(.bundleIdentifier == "com.theboisclub.gen1recomp").versions) |= map(if .version == $version then $entry else . end)' \
jq --arg bundle_id "$bundle_id" --arg version "$v" --argjson entry "$entry" \
'(.apps[] | select(.bundleIdentifier == $bundle_id).versions) |= map(if .version == $version then $entry else . end)' \
"$app_repo" > "$app_repo.tmp"
else
jq --argjson entry "$entry" \
'(.apps[] | select(.bundleIdentifier == "com.theboisclub.gen1recomp").versions) |= [$entry] + .' \
jq --arg bundle_id "$bundle_id" --argjson entry "$entry" \
'(.apps[] | select(.bundleIdentifier == $bundle_id).versions) |= [$entry] + .' \
"$app_repo" > "$app_repo.tmp"
fi
mv "$app_repo.tmp" "$app_repo"
+5 -1
View File
@@ -3,8 +3,9 @@
data/generated/
assets/generated/
# LÖVE packages
# LÖVE packages & archives
*.love
*.zip
# Local saves (LÖVE writes to its save dir, but keep the repo clean anyway)
save/
@@ -75,3 +76,6 @@ mobile/ios/bundle_id.local
/dist/native/
/dist/win/
/.bazinga/
# Local options / preferences
/options.lua*
+48 -3
View File
@@ -242,10 +242,55 @@ with a `MK4xx` finding per site and an `unresolved:` note, with a file and a
line, for every reach a static scan could not follow. Neither substitutes for a
real Gold boot.
### 5. `mod.card`
### 5. What a mod's code can reach
Your code runs in a sandbox (`src/mods/Sandbox.lua`), not against the
engine's globals. Every chunk you author gets it: `main.lua`, your
`options_schema`, and anything you `load()` yourself. What is absent:
| Absent | Use instead |
| --- | --- |
| `io`, and `require("io")` | `mod:read` for your own files, `mod.storage` to persist |
| `os.getenv`, `os.execute`, `os.remove`, `os.rename`, `os.exit` | nothing; `os.time`/`os.date`/`os.clock` still work |
| `package`, `dofile`, `loadfile`, `debug`, `getfenv`, `setfenv` | `require` for the supported engine modules |
| `require("ffi")`, `require("love.*")` | the `love` table you are given |
| `love.filesystem` | `mod.storage` (per-mod, per-playthrough), `mod:read` for a known file, `mod:list` / `mod:info` to iterate your own directory |
| `love.thread`, `love.event` | `mod.events`, `mod.hooks` |
| `love.system` | `mod.device:powerInfo()` for battery information; `mod.steps` (with the `steps` permission) for the step bridge |
The rest of `love` passes through unchanged, so graphics, audio, timers and
input work as they always have.
Three consequences worth knowing before you write against it:
- **Your globals are yours.** `_G` inside a mod is that mod's own table. Two
mods no longer share a namespace, and neither can reach the engine's. To
publish something to another mod, put it on `mod.exports` and let them
`mod.find("your_id").exports` — the channel that was always the intended
one. The same goes for the standard library: `string`, `table` and `math`
are per-mod copies, so patching one is a local decision.
- **Paths cannot climb.** `mod:read`, `mod:list`, `mod:info`, `mod.assets:path`
and `mod.assets:image` join to your own directory, and `..`, absolute paths
and drive letters are refused. So are `entry` and `options_schema` in your
manifest. `mod:list("assets")` is the sandboxed `getDirectoryItems` for a
folder you shipped; `mod:info` tells file from directory so a walk can
recurse.
- **Ship source, not bytecode.** A precompiled entry file is refused.
`permissions` in the manifest is still a disclosure the manager shows the
player, and `network` now gates `require("socket")` and friends. There is no
permission that grants raw filesystem access, because no mod needs one:
everything a mod legitimately writes is already scoped by
`mod.storage` or the asset-transform derived root.
If your mod used one of the absent globals, the fix is almost always
`mod.storage`. Open an issue if you have a case it does not cover.
### 6. `mod.card`
The manifest is the *engine's* contract: identity, load order, dependencies,
permissions, profile. The card is the *human-facing* one: who made this,
permissions, profile (see [Manifest specification](docs/modding.md#manifest-specification-manifestjson)).
The card is the *human-facing* one: who made this,
what it changes, what it does not do yet. It is never read by the loader's
merge — only by tooling and the manager's detail pane — so an absent or
malformed card can never break a load.
@@ -263,7 +308,7 @@ Two fields deserve their own note:
distributed mod never carries ROM-derived bytes, not even in its preview
images.
### 6. Tags
### 7. Tags
Lowercase kebab strings, open vocabulary. The showcase generator
lowercases and de-dupes. A recommended starting set: `beginner`,
+1 -1
View File
@@ -242,7 +242,7 @@ in [docs/linux-arm64-build.md](docs/linux-arm64-build.md).
## iOS
Every release ships `gen1recomp-*-ios.ipa`. Sideload it with AltStore
Every release ships `gen1recomp++-*-ios.ipa`. Sideload it with AltStore
(Windows or Mac) — see [docs/ios-sideload.md](docs/ios-sideload.md). To
build and install from source on a Mac instead, see
[docs/ios-install.md](docs/ios-install.md).
Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

+1 -1
View File
@@ -92,7 +92,7 @@ return {
{ "set_flag", "EVENT_GOT_EEVEE" }, -- 7
{ "hide_object", "CELADON_MANSION_ROOF_HOUSE",
"CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" }, -- 8
{ "play_sound", "Get_Item1" }, -- 9 (GotMonText jingle)
{ "text_sound", "Get_Item1" }, -- 9 (GotMonText jingle)
{ "show_text", "_GotMonText", { RAM = "EEVEE" } }, -- 10
{ "jump", 13 }, -- 11
{ "show_text", "_BoxIsFullText" }, -- 12
+6 -5
View File
@@ -10,10 +10,10 @@
local function coinGiver(opts)
return function(game, ow, npc, done)
local TextBox = require("src.render.TextBox")
local Sound = require("src.core.Sound")
local t = game.data.text
local function push(label, fallback, onDone)
game.stack:push(TextBox.new(game, t[label] or fallback, onDone or done))
local function push(label, fallback, onDone, popts)
game.stack:push(TextBox.new(game, t[label] or fallback, onDone or done,
popts))
end
if game.save.flags[opts.event] then
push(opts.alreadyGotLabel, opts.alreadyGotFallback)
@@ -30,9 +30,10 @@ local function coinGiver(opts)
end
game.save.coins = math.min(9999, (game.save.coins or 0) + opts.amount)
game.save.flags[opts.event] = true
Sound.play(game.data, "Get_Item1")
-- the ReceivedNCoinsText strings carry sound_get_item_1
push(opts.receivedLabel,
("{PLAYER} received\n%d coins!"):format(opts.amount))
("{PLAYER} received\n%d coins!"):format(opts.amount), nil,
TextBox.soundOpts(game, "Get_Item1"))
end)
end
end
+5 -4
View File
@@ -22,8 +22,8 @@ return {
local TextBox = require("src.render.TextBox")
local Commands = require("src.script.Commands")
local t = game.data.text
local function say(label, cb)
game.stack:push(TextBox.new(game, t[label] or label, cb))
local function say(label, cb, sopts)
game.stack:push(TextBox.new(game, t[label] or label, cb, sopts))
end
if game.save.flags.EVENT_GOT_OLD_AMBER then
@@ -39,8 +39,9 @@ return {
game.save.flags.EVENT_GOT_OLD_AMBER = true
Commands.hide_object({ save = game.save, overworld = ow, game = game },
"MUSEUM_1F", "MUSEUM1F_OLD_AMBER")
require("src.core.Sound").play(game.data, "Get_Item1")
say("_Museum1FScientist2ReceivedOldAmberText", done)
-- .ReceivedOldAmberText carries sound_get_item_1
say("_Museum1FScientist2ReceivedOldAmberText", done,
TextBox.soundOpts(game, "Get_Item1"))
end)
end,
+19 -19
View File
@@ -38,9 +38,9 @@ local function starterBall(askText, species, choseFlag, ownBall,
-- inside give_pokemon). Show the received text first so the
-- nickname prompt follows "you got X", matching Gen1.
-- The received text carries sound_get_key_item (OaksLab.asm
-- OaksLabReceivedMonText); the jingle plays as the box opens
-- (same beat as the Yellow port's starter, #668).
{ "play_sound", "Get_Key_Item" }, -- 8
-- OaksLabReceivedMonText), so the jingle fires once the box has
-- typed out and holds it (same beat as the Yellow port's starter, #668).
{ "text_sound", "Get_Key_Item" }, -- 8
{ "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 9
{ "give_pokemon", species, 5 }, -- 10
{ "set_flag", "EVENT_GOT_STARTER" }, -- 11
@@ -54,7 +54,7 @@ local function starterBall(askText, species, choseFlag, ownBall,
{ "face_object", 1, "up" }, -- 15
{ "show_text", "_OaksLabRivalIllTakeThisOneText" }, -- 16
{ "hide_object", "OAKS_LAB", rivalBall }, -- 17
{ "play_sound", "Get_Key_Item" }, -- 18 (sound_get_key_item)
{ "text_sound", "Get_Key_Item" }, -- 18 (sound_get_key_item)
{ "show_text", "_OaksLabRivalReceivedMonText",
{ RAM = rivalBall == "OAKSLAB_CHARMANDER_POKE_BALL" and "CHARMANDER"
or rivalBall == "OAKSLAB_SQUIRTLE_POKE_BALL" and "SQUIRTLE"
@@ -106,8 +106,8 @@ return {
{ "check_item", "OAKS_PARCEL" },
{ "jump_if_false", "raise_young" },
-- OaksLabOak1Text.got_parcel → RivalArrives + OakGivesPokedex
{ "text_sound", "Get_Key_Item" },
{ "show_text", "_OaksLabOak1DeliverParcelText" },
{ "play_sound", "Get_Key_Item" },
{ "show_text", "_OaksLabOak1ParcelThanksText" },
{ "take_item", "OAKS_PARCEL", 1 },
{ "stop_music" },
@@ -128,8 +128,8 @@ return {
{ "face_object", 1, "up" },
{ "face_object", 5, "down" },
{ "show_text", "_OaksLabOakMyInventionPokedexText" },
{ "text_sound", "Get_Key_Item" },
{ "show_text", "_OaksLabOakGotPokedexText" },
{ "play_sound", "Get_Key_Item" },
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" },
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" },
{ "face_object", 1, "up" },
@@ -246,9 +246,17 @@ return {
-- the table sprites; re-entering the lab applies the same HideObject
-- the gift script now does (OaksLab.asm OakGivesPokedex).
onEnter = function(game, ow)
if not (game.save.flags and game.save.flags.EVENT_GOT_POKEDEX) then
return
local flags = game.save.flags or {}
if flags.EVENT_GOT_STARTER and not flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB then
local rival = ow:npcByIndex(1)
if rival then
rival.cellX = flags.EVENT_CHOSE_CHARMANDER and 7
or flags.EVENT_CHOSE_SQUIRTLE and 8 or 6
rival.cellY = 4
rival.px, rival.py = rival.cellX * 16, rival.cellY * 16
end
end
if not flags.EVENT_GOT_POKEDEX then return end
local Commands = require("src.script.Commands")
local ctx = { save = game.save, game = game, overworld = ow }
Commands.hide_object(ctx, "OAKS_LAB", "OAKSLAB_POKEDEX1")
@@ -286,6 +294,7 @@ return {
-- fanfare for the taunt/challenge exchange, same as the Yellow port
-- (oaks_lab_yellow.lua); it was silently dropped here (#596).
local rows = {
{ "face_player_dir", "up" },
{ "stop_music" },
{ "play_music", "Music_MeetRival" },
{ "show_text", "_OaksLabRivalIllTakeYouOnText" }, -- 1
@@ -309,28 +318,19 @@ return {
local base = #rows
local party = flags.EVENT_CHOSE_BULBASAUR and 3
or flags.EVENT_CHOSE_SQUIRTLE and 2 or 1
table.insert(rows, { "save_end_battle_text", "_OaksLabRivalIPickedTheWrongPokemonText" })
table.insert(rows, { "start_battle", "trainer", "OPP_RIVAL1", party })
-- OaksLabRivalEndBattleScript: heal + flag on win or loss; no blackout
table.insert(rows, { "heal_party" })
table.insert(rows, { "set_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" })
-- OaksLabRivalEndBattleScript: on WIN, print the "picked the wrong
-- POKéMON!" gloat, then BOTH win and loss print the shared exit line
-- _OaksLabRivalSmellYouLaterText ("OK! I'll make my POKéMON fight to
-- toughen it up!\012<PLAYER>! Gramps! Smell you later!") before Blue
-- marches out. A loss skips only the gloat (that taunt was already
-- shown in-battle via Rival1WinText), never the exit line (#231). The
-- jump_if_false convergence point is the exit line: base+6 indexes the
-- SmellYouLater row below, so WIN falls IPicked -> SmellYouLater and
-- LOSS jumps straight to SmellYouLater (both then walk-out + hide).
table.insert(rows, { "jump_if_false", base + 6 })
table.insert(rows, { "show_text", "_OaksLabRivalIPickedTheWrongPokemonText" })
table.insert(rows, { "show_text", "_OaksLabRivalSmellYouLaterText" })
-- OaksLabRivalStartsExitScript: parting shot, rival exit fanfare, then
-- walk out past the player. The fanfare was dropped here (#683) -- the
-- parcel scene above already plays Music_MeetRival on both arrival and
-- departure (lines 144-146), and this exit should match (#596).
table.insert(rows, { "stop_music" })
table.insert(rows, { "play_music", "Music_MeetRival" })
table.insert(rows, { "play_music", "Music_MeetRival", { start = "rival" } })
table.insert(rows, { "move_npc_to", 1, 4, 11 })
table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" })
table.insert(rows, { "play_music", "Music_OaksLab" })
+15 -8
View File
@@ -38,8 +38,8 @@ return {
{ "jump_if_false", "raise_young" },
-- .DeliverParcelText: parcel handover, then the Pokédex scene
-- (OaksLabRivalArrivesAtOaksRequestScript -> OakGivesPokedexScript)
{ "text_sound", "Get_Key_Item" },
{ "show_text", "_OaksLabOak1DeliverParcelText" },
{ "play_sound", "Get_Key_Item" },
{ "show_text", "_OaksLabOak1ParcelThanksText" },
{ "take_item", "OAKS_PARCEL", 1 },
{ "stop_music" },
@@ -62,8 +62,8 @@ return {
{ "face_object", RIVAL, "up" },
{ "face_object", OAK1, "down" },
{ "show_text", "_OaksLabOakMyInventionPokedexText" },
{ "text_sound", "Get_Key_Item" },
{ "show_text", "_OaksLabOakGotPokedexText" },
{ "play_sound", "Get_Key_Item" },
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" },
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" },
{ "face_object", RIVAL, "up" },
@@ -113,8 +113,8 @@ return {
{ "jump_if_true", "come_see" },
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" },
{ "give_item", "POKE_BALL", 5, false },
{ "text_sound", "Get_Key_Item" },
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" },
{ "play_sound", "Get_Key_Item" },
{ "show_text", "_OaksLabGivePokeballsExplanationText" },
{ "jump", "end" },
@@ -176,7 +176,7 @@ return {
-- rival starter baseline (RIVAL_STARTER_JOLTEON) at snatch time
rows[#rows + 1] = { "set_field", "rivalStarter", 1 }
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText1" }
rows[#rows + 1] = { "play_sound", "Get_Key_Item" }
rows[#rows + 1] = { "text_sound", "Get_Key_Item" }
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText2" }
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText3" }
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText4" }
@@ -196,7 +196,7 @@ return {
rows[#rows + 1] = { "face_object", OAK1, "down" }
-- OaksLabPlayerReceivedMonText clears wMonDataLocation, so AskName runs (#1013)
rows[#rows + 1] = { "show_text", "_OaksLabOakGivesText" }
rows[#rows + 1] = { "play_sound", "Get_Key_Item" }
rows[#rows + 1] = { "text_sound", "Get_Key_Item" }
rows[#rows + 1] = { "show_text", "_OaksLabReceivedText", { RAM = "PIKACHU" } }
rows[#rows + 1] = { "give_pokemon", "PIKACHU", 5 }
-- DisablePikachuOverworldSpriteDrawing keeps it in the ball (#1009)
@@ -226,9 +226,15 @@ return {
},
onEnter = function(game, ow)
if not (game.save.flags and game.save.flags.EVENT_GOT_POKEDEX) then
return
local flags = game.save.flags or {}
if flags.EVENT_GOT_STARTER and not flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB then
local rival = ow:npcByIndex(RIVAL)
if rival then
rival.cellX, rival.cellY = 7, 4
rival.px, rival.py = 7 * 16, 4 * 16
end
end
if not flags.EVENT_GOT_POKEDEX then return end
local Commands = require("src.script.Commands")
local ctx = { save = game.save, game = game, overworld = ow }
Commands.hide_object(ctx, "OAKS_LAB", "OAKSLAB_POKEDEX1")
@@ -296,13 +302,14 @@ return {
table.insert(rows, { "wait", 20 })
table.insert(rows, { "show_text", "_OaksLabRivalSmellYouLaterText" })
table.insert(rows, { "stop_music" })
table.insert(rows, { "play_music", "Music_MeetRival" })
table.insert(rows, { "play_music", "Music_MeetRival", { start = "rival" } })
table.insert(rows, { "move_npc_to", RIVAL, 4, 11 })
table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" })
table.insert(rows, { "play_music", "Music_OaksLab" })
-- OaksLabPikachuEscapesPokeballScript: the follower reaches the map (#1009)
table.insert(rows, { "face_player_dir", "up" })
table.insert(rows, { "set_field", "pikachuInBall", false })
table.insert(rows, { "spawn_pikachu_follower" })
table.insert(rows, { "play_cry", "PIKACHU" })
table.insert(rows, { "show_text", "_OaksLabPikachuDislikesPokeballsText1" })
table.insert(rows, { "show_text", "_OaksLabPikachuDislikesPokeballsText2" })
+21 -19
View File
@@ -142,20 +142,23 @@ M.VIRIDIAN_CITY = {
M.BLUES_HOUSE = {
talk = {
TEXT_BLUESHOUSE_DAISY_SITTING = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_GOT_TOWN_MAP" }, -- 2
{ "jump_if_true", 10 }, -- 3
{ "check_flag", "EVENT_GOT_STARTER" }, -- 4
{ "jump_if_false", 12 }, -- 5
{ "show_text", "_BluesHouseDaisyOfferMapText" }, -- 6
{ "face_player" },
{ "check_flag", "EVENT_GOT_TOWN_MAP" },
{ "jump_if_true", "got_map" },
{ "check_flag", "EVENT_GOT_POKEDEX" },
{ "jump_if_false", "too_early" },
{ "show_text", "_BluesHouseDaisyOfferMapText" },
-- _GotMapText: "{PLAYER} got a\n{RAM:wStringBuffer}!" -- the
-- buffer supplies "TOWN MAP" (scripts/BluesHouse.asm GotMapText)
{ "give_item", "TOWN_MAP", 1, "_GotMapText" }, -- 7
{ "set_flag", "EVENT_GOT_TOWN_MAP" }, -- 8
{ "jump", 13 }, -- 9
{ "show_text", "_BluesHouseDaisyUseMapText" }, -- 10
{ "jump", 13 }, -- 11
{ "show_text", "_BluesHouseDaisyRivalAtLabText" }, -- 12
{ "give_item", "TOWN_MAP", 1, "_GotMapText" },
{ "hide_object", "BLUES_HOUSE", "BLUESHOUSE_TOWN_MAP" },
{ "set_flag", "EVENT_GOT_TOWN_MAP" },
{ "jump", "end" },
{ "label", "got_map" },
{ "show_text", "_BluesHouseDaisyUseMapText" },
{ "jump", "end" },
{ "label", "too_early" },
{ "show_text", "_BluesHouseDaisyRivalAtLabText" },
},
},
}
@@ -294,6 +297,7 @@ M.BILLS_HOUSE = {
M.ROUTE_25 = {
onEnter = function(game, ow)
game.save.pikachuMapScriptActive = nil
local flags = game.save.flags
if flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING then return end
local Commands = require("src.script.Commands")
@@ -326,6 +330,7 @@ M.VERMILION_CITY = {
-- only read while EVENT_1ST_LOCK_OPENED is unset (the gym is only
-- reachable through this map, so a fresh visit always re-rolls).
onEnter = function(game, ow)
game.save.pikachuMapScriptActive = nil
local puz = game.save.trashPuzzle or {}
game.save.trashPuzzle = puz
puz.first = love.math.random(0, 7) * 2
@@ -1058,6 +1063,7 @@ local championsRoomRivalScript = {
-- OakCongratulatesPlayerScript: rival faces left, Oak faces down
{ "face_object", 1, "left" }, -- 17
{ "face_object", 2, "down" }, -- 18
{ "load_player_starter_name" },
{ "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 19
-- OakDisappointedWithRivalScript: Oak turns to the rival (right)
{ "face_object", 2, "right" }, -- 20
@@ -1067,13 +1073,8 @@ local championsRoomRivalScript = {
{ "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 23
{ "move_npc", 2, "up", 2 }, -- 24 OakExitChampionsRoomMovement
{ "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 25
-- ChampionsRoomPlayerFollowsOakScript / WalkToHallOfFame_RLEMovement.
-- The player walks out after Oak instead of the screen just fading on the
-- spot (#704). Route one tile right before walking north so the player
-- reaches the north-wall HALL_OF_FAME warp without sharing the rival's
-- (4,2) cell. The original simulated movement bypasses entity collision,
-- but this scene should not visibly walk through the defeated rival.
{ "move_player", "right", 1 }, -- 26
-- scripts/ChampionsRoom.asm WalkToHallOfFame_RLEMovement
{ "move_player", "left", 1 },
{ "move_player", "up", 3 }, -- 27
-- hand the induction off to the HALL_OF_FAME room (consumed by its
-- onEnter), then warp up into it (destWarp 1 lands at (4,7) facing up)
@@ -1238,6 +1239,7 @@ local function pokemonTower2FRivalScript(playerX)
{ "jump_if_false", "end" }, -- 6 loss: stay
{ "set_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 7
{ "show_text", "_PokemonTower2FRivalDefeatedText" }, -- 8
{ "play_music", "Music_MeetRival", { start = "rival" } },
{ "walk_npc", 1, exitDirs }, -- 9
{ "hide_object", "POKEMON_TOWER_2F", "POKEMONTOWER2F_RIVAL" }, -- 10
{ "jump", "end" }, -- 11
+16 -5
View File
@@ -178,7 +178,8 @@ M.PALLET_TOWN = {
end
end
local function enterLab()
local function enterLab(oak)
if oak then oak.stepFrames = nil end
Commands.hide_object(ctx, "PALLET_TOWN", "PALLETTOWN_OAK")
Commands.show_object(ctx, "OAKS_LAB", "OAKSLAB_OAK2")
ow.doorWarp = true
@@ -187,12 +188,17 @@ M.PALLET_TOWN = {
end
local function walkToLab(oak)
-- lockstep half runs Oak on the player's own frames per cell
-- engine/overworld/movement.asm:737 (DoScriptedNPCMovement)
local i = 0
if oak then
oak.stepFrames = ow.player.stepFramesCur or ow.player.stepFrames
end
local function tick()
i = i + 1
local playerStep = escort.playerSteps[i]
if not playerStep then
enterLab()
enterLab(oak)
return
end
if oak and escort.oakSteps[i] then
@@ -413,6 +419,9 @@ M.ROUTE_8_GATE = saffronGate("TEXT_ROUTE8GATE_GUARD", { { 2, 3 }, { 2, 4 } }, tr
-- -------------------------------------------------------------------
M.POKEMON_FAN_CLUB = {
onEnter = function(game, ow)
require("src.world.PikachuFollower").onFanClubEntered(game, ow)
end,
talk = {
TEXT_POKEMONFANCLUB_CHAIRMAN = {
{ "face_player" }, -- 1
@@ -645,8 +654,10 @@ local function mtMoonFossil(itemId, otherName, gotFlag)
end
local idef = game.data.items[itemId]
game.stringBuffer = idef and idef.name or itemId
require("src.core.Sound").play(game.data, "Get_Key_Item")
local dirs = mtMoonNerdWalk(ow.player.cellX, ow.player.cellY, itemId)
-- MtMoonB2FReceivedFossilText: text_far, sound_get_key_item,
-- text_waitbutton -- the jingle plays after the box has typed and
-- the button wait comes after it
game.stack:push(TextBox.new(game,
t._MtMoonB2FReceivedFossilText
or ("{PLAYER} got the\n" .. game.stringBuffer .. "!"),
@@ -659,11 +670,11 @@ local function mtMoonFossil(itemId, otherName, gotFlag)
ow.runner:run({
{ "walk_npc", 1, dirs },
{ "text_opts", { auto = true } },
{ "text_sound", "Get_Key_Item" },
{ "show_text", "_MtMoonB2FSuperNerdThenThisIsMineText" },
{ "play_sound", "Get_Key_Item" },
{ "hide_object", "MT_MOON_B2F", otherName },
}, { onDone = done })
end))
end, TextBox.soundOpts(game, "Get_Key_Item")))
end }))
end
end
+22 -21
View File
@@ -8,9 +8,9 @@ local M = {}
local function text(game) return game.data.text end
local function push(game, s, done)
local function push(game, s, done, opts)
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game, s, done))
game.stack:push(TextBox.new(game, s, done, opts))
end
-- The question stays on screen under the YES/NO menu. The dojo prize
@@ -142,7 +142,7 @@ M.MT_MOON_POKECENTER = {
local Commands = require("src.script.Commands")
Commands.give_pokemon({ save = game.save, game = game, overworld = ow },
"MAGIKARP", 5)
push(game, ("%s got a\nMAGIKARP!"):format(game.save.player.name), done)
push(game, t._GotMonText or "{PLAYER} got\n{RAM:wNameBuffer}!", done)
end)
end,
},
@@ -263,7 +263,7 @@ M.SILPH_CO_7F = {
{ "jump_if_false", "box_full" },
-- flag ahead of the jingle, like the Celadon EEVEE (#426)
{ "set_flag", "EVENT_GOT_LAPRAS" },
{ "play_sound", "Get_Item1" },
{ "text_sound", "Get_Item1" },
{ "show_text", "_GotMonText", { RAM = "LAPRAS" } },
{ "show_text", "_SilphCo7FSilphWorkerM1LaprasDescriptionText" },
{ "jump", "end" },
@@ -307,12 +307,11 @@ M.COPYCATS_HOUSE_2F = {
return
end
game.stringBuffer = game.data.items.TM_MIMIC.name
require("src.core.Sound").play(game.data, "Get_Item1")
Bag.remove(game.save, "POKE_DOLL", 1)
game.save.flags.EVENT_GOT_TM31 = true
push(game, t._CopycatsHouse2FCopycatReceivedTM31Text, function()
push(game, t._CopycatsHouse2FCopycatTM31Explanation1Text, done)
end)
end, require("src.render.TextBox").soundOpts(game, "Get_Item1"))
end)
end)
end,
@@ -478,7 +477,6 @@ M.CELADON_MART_ROOF = {
return
end
game.save.flags[g.flag] = true
require("src.core.Sound").play(game.data, "Get_Item1")
local subs = { player = game.save.player.name,
ram = game.data.items[g.tm].name }
local explain = fill(t[g.explain] or "", subs)
@@ -490,7 +488,7 @@ M.CELADON_MART_ROOF = {
else
done()
end
end)
end, require("src.render.TextBox").soundOpts(game, "Get_Item1"))
end)
end,
onCancel = done,
@@ -512,25 +510,28 @@ M.ROUTE_24 = {
local flags = game.save.flags
local function battleOrDone()
if ow:trainerDefeated(npc) then
push(game, "I hate this!\nMy dreams of\nTEAM ROCKET...", done)
push(game, text(game)._Route24CooltrainerM1YouCouldBecomeATopLeaderText,
done)
else
ow:engageTrainer(npc, done)
end
end
if not flags.EVENT_GOT_NUGGET then
push(game, "Congratulations!\nYou beat our 5\ncontest trainers!\f"
.. "You just earned a\nfabulous prize!", function()
local t = text(game)
push(game, t._Route24CooltrainerM1YouBeatOurContestText .. "\f"
.. t._Route24CooltrainerM1YouJustEarnedAPrizeText, function()
if not require("src.inventory.Bag").add(game.save, "NUGGET", 1,
game.data) then
push(game, t._Route24CooltrainerM1NoRoomText, done)
return
end
flags.EVENT_GOT_NUGGET = true
require("src.inventory.Bag").add(game.save, "NUGGET", 1)
push(game, ("%s received\na NUGGET!"):format(game.save.player.name),
function()
ask(game, "By the way, would\nyou like to join\nTEAM ROCKET?",
function()
push(game, "Arrgh! You are\nnot convinced?\fThen I'll show\n"
.. "you my power!", battleOrDone)
end)
end)
end)
game.stringBuffer = game.data.items.NUGGET.name
push(game, t._Route24CooltrainerM1ReceivedNuggetText, function()
push(game, t._Route24CooltrainerM1JoinTeamRocketText,
battleOrDone)
end, require("src.render.TextBox").soundOpts(game, "Get_Item1"))
end, require("src.render.TextBox").soundOpts(game, "Get_Item1"))
return
end
battleOrDone()
+36 -18
View File
@@ -4,9 +4,9 @@ local M = {}
local function text(game) return game.data.text end
local function push(game, s, done)
local function push(game, s, done, opts)
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game, s, done))
game.stack:push(TextBox.new(game, s, done, opts))
end
-- fill the extracted text placeholders ({RAM:...}, {PLAYER})
@@ -25,8 +25,8 @@ local function gift(opts)
local t = text(game)
local itemName = game.data.items[opts.item].name
local subs = { ram = itemName, player = game.save.player.name }
local function say(label, fallback, cb)
push(game, fill(t[label] or fallback, subs), cb)
local function say(label, fallback, cb, sopts)
push(game, fill(t[label] or fallback, subs), cb, sopts)
end
if game.save.flags[opts.flag] then
say(opts.already or opts.explain, "It's a useful\nitem, isn't it?", done)
@@ -39,15 +39,16 @@ local function gift(opts)
end
game.save.flags[opts.flag] = true
local idef = game.data.items[opts.item]
require("src.core.Sound").play(game.data,
(idef and idef.keyItem) and "Get_Key_Item" or "Get_Item1")
-- the received texts carry sound_get_item_1 / sound_get_key_item, so
-- the jingle only fires once that box has typed out
say(opts.received, "{PLAYER} received\n{RAM:}!", function()
if opts.explain then
say(opts.explain, "", done)
else
done()
end
end)
end, require("src.render.TextBox").soundOpts(game,
(idef and idef.keyItem) and "Get_Key_Item" or "Get_Item1"))
end
if opts.pre then say(opts.pre, opts.preFallback or "", give) else give() end
end
@@ -445,7 +446,7 @@ local function pewterGymEscort(game, ow)
end
local function afterWalk()
if guy then guy.facing = "left" end
if guy then guy.stepFrames, guy.facing = nil, "left" end
Music.playMap(game.data, "PEWTER_CITY")
push(game, t._PewterCityYoungsterGoTakeOnBrockText
or "Go take on BROCK\nat the GYM first!", walkHome)
@@ -468,6 +469,11 @@ local function pewterGymEscort(game, ow)
end
local function beginWalk()
-- the escort runs the youngster on the player's own frames per cell
-- engine/overworld/movement.asm:737 (DoScriptedNPCMovement)
if guy then
guy.stepFrames = ow.player.stepFramesCur or ow.player.stepFrames
end
Music.play(game.data, "Music_MuseumGuy")
if guy and head > 0 then
local h = 0
@@ -508,12 +514,12 @@ M.PEWTER_CITY = {
-- Rival ambush: show the hidden rival, walk him up to the player, run
-- the battle rows, march him back and hide him. On a loss the walk is
-- skipped (the blackout rebuilds the map mid-script).
local function runAmbush(game, ow, rows, playerFacing)
local function runAmbush(game, ow, rows, playerFacing, musicOpts)
if ow.runner:isRunning() then return false end
ow.player.facing = playerFacing
-- the rival encounter sting (MUSIC_MEET_RIVAL); the battle music
-- takes over and the map theme returns after the victory jingle
require("src.core.Music").play(game.data, "Music_MeetRival")
require("src.core.Music").play(game.data, "Music_MeetRival", nil, musicOpts)
ow.runner:run(rows)
return true
end
@@ -567,10 +573,12 @@ local function route22Scene(n, objIndex, objName, oppClass, baseParty, beatFlag,
{ "face_object", objIndex, rivalFacing }, -- 3
{ "show_text", "_Route22RivalBeforeBattleText" .. n }, -- 4
{ "rival_battle", oppClass, baseParty }, -- 5
{ "jump_if_false", 11 }, -- 6
{ "jump_if_false", 12 }, -- 6
{ "set_flag", beatFlag }, -- 7
{ "show_text", "_Route22Rival" .. n .. "DefeatedText" }, -- 8
{ "show_text", "_Route22RivalAfterBattleText" .. n }, -- 9
{ "play_music", "Music_MeetRival", { start = "rival",
tempo = n == 2 and 100 or nil } },
{ "walk_npc", objIndex, route22ExitDirs(n, py) }, -- 10
{ "hide_object", "ROUTE_22", objName }, -- 11
}
@@ -594,7 +602,8 @@ M.ROUTE_22 = {
if f.EVENT_BEAT_GIOVANNI and not f.EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE then
return runAmbush(game, ow,
route22Scene(2, 2, "ROUTE22_RIVAL2", "OPP_RIVAL2", 10,
"EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE", y), playerFacing)
"EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE", y), playerFacing,
{ tempo = 100 })
end
return false
end,
@@ -618,10 +627,11 @@ local function ceruleanRivalScene(px, py)
{ "face_object", 1, "down" }, -- 3
{ "show_text", "_CeruleanCityRivalPreBattleText" }, -- 4
{ "rival_battle", "OPP_RIVAL1", 7 }, -- 5
{ "jump_if_false", 11 }, -- 6
{ "jump_if_false", 12 }, -- 6
{ "set_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 7
{ "show_text", "_CeruleanCityRivalDefeatedText" }, -- 8
{ "show_text", "_CeruleanCityRivalIWentToBillsText" }, -- 9
{ "play_music", "Music_MeetRival", { start = "rival" } },
{ "walk_npc", 1, ceruleanRivalExitDirs(px) }, -- 10
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 11
}
@@ -730,7 +740,7 @@ local JIGGLYPUFF_SILENCE, JIGGLYPUFF_STEP, JIGGLYPUFF_TAIL = 32, 24, 48
-- Built as a TextBox `auto` table: auto.sound fires the frame the last
-- page has typed out (PrintText returning), and auto.tick then runs once
-- per frame while the gate it returns still reads as playing.
local function jigglypuffDance(game, npc)
local function jigglypuffDance(game, npc, ow)
local Music = require("src.core.Music")
-- .findMatchingFacingDirectionLoop: the rotation picks up at the entry
-- matching the sprite's current facing (showMapText has just turned it
@@ -776,7 +786,13 @@ local function jigglypuffDance(game, npc)
if npc then npc.facing = JIGGLYPUFF_SPIN[step] end
return
end
if frames >= JIGGLYPUFF_TAIL then phase = "done" end
if frames >= JIGGLYPUFF_TAIL then
phase = "done"
if require("src.core.GameVersion").isYellow()
and require("src.world.PikachuFollower").starterInParty(game.save) then
ow.pikachuPewterSleepScene = true
end
end
end,
}
end
@@ -789,7 +805,7 @@ M.PEWTER_POKECENTER = {
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game,
text(game)._PewterPokecenterJigglypuffText or "JIGGLYPUFF: Puu\npupuu!",
done, { auto = jigglypuffDance(game, npc) }))
done, { auto = jigglypuffDance(game, npc, ow) }))
end,
},
}
@@ -864,10 +880,11 @@ M.SILPH_CO_7F = {
{ "face_object", 9, "up" }, -- 4
{ "show_text", "_SilphCo7FRivalWaitedHereText" }, -- 5
{ "rival_battle", "OPP_RIVAL2", 7 }, -- 6
{ "jump_if_false", 12 }, -- 7
{ "jump_if_false", 13 }, -- 7
{ "set_flag", "EVENT_BEAT_SILPH_CO_RIVAL" }, -- 8
{ "show_text", "_SilphCo7FRivalDefeatedText" }, -- 9
{ "show_text", "_SilphCo7FRivalGoodLuckToYouText" }, -- 10
{ "play_music", "Music_MeetRival", { start = "rival" } },
{ "move_npc_to", 9, 5, y + 1 }, -- 11
{ "hide_object", "SILPH_CO_7F", "SILPHCO7F_RIVAL" }, -- 12
}, "down")
@@ -899,10 +916,11 @@ M.SS_ANNE_2F = {
{ "face_object", 2, onLeft and "down" or "right" }, -- 3
{ "show_text", "_SSAnne2FRivalText" }, -- 4
{ "rival_battle", "OPP_RIVAL2", 1 }, -- 5
{ "jump_if_false", 11 }, -- 6
{ "jump_if_false", 12 }, -- 6
{ "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 7
{ "show_text", "_SSAnne2FRivalDefeatedText" }, -- 8
{ "show_text", "_SSAnne2FRivalCutMasterText" }, -- 9
{ "play_music", "Music_MeetRival", { start = "rival" } },
{ "walk_npc", 2, ssAnne2FRivalExitDirs(onLeft) }, -- 10
{ "hide_object", "SS_ANNE_2F", "SSANNE2F_RIVAL" }, -- 11
}, onLeft and "up" or "left")
+21
View File
@@ -28,6 +28,14 @@
-- is full; `gotFlag` (pokered's EVENT_GOT_TM*) is set only on a
-- successful give, which is what makes the leader's talk script retry
-- later (gyms.lua).
--
-- `badgeSound` / `tmSound` are the text sound command each gym's reward
-- text carries right after its FIRST label -- home/text.asm TextCommand_SOUND
-- plays it once that page has typed out and then blocks on
-- WaitForSoundToFinish, so the jingle sits between the pages rather than
-- under them. macros/scripts/text.asm defines sound_level_up as
-- sound_get_item_1, so Pewter's and Viridian's badge lines are Get_Item1
-- too. Vermilion, Celadon and Fuchsia carry no sound on the badge text.
local function range(prefix, first, last)
local t = {}
@@ -50,6 +58,8 @@ return {
{ "PEWTER_CITY", "PEWTERCITY_YOUNGSTER" },
{ "ROUTE_22", "ROUTE22_RIVAL1" },
},
badgeSound = "Get_Item1", -- sound_level_up
tmSound = "Get_Item1",
dialogue = {
"_PewterGymBrockReceivedBoulderBadgeText",
"_PewterGymBrockBoulderBadgeInfoText",
@@ -64,6 +74,8 @@ return {
gotFlag = "EVENT_GOT_TM11",
noRoom = "_CeruleanGymMistyTM11NoRoomText",
deactivate = range("EVENT_BEAT_CERULEAN_GYM_TRAINER_", 0, 1),
badgeSound = "Get_Key_Item",
tmSound = "Get_Item1",
dialogue = {
"_CeruleanGymMistyReceivedCascadeBadgeText",
},
@@ -76,6 +88,7 @@ return {
gotFlag = "EVENT_GOT_TM24",
noRoom = "_VermilionGymLTSurgeTM24NoRoomText",
deactivate = range("EVENT_BEAT_VERMILION_GYM_TRAINER_", 0, 2),
tmSound = "Get_Key_Item",
dialogue = {
"_VermilionGymLTSurgeReceivedThunderBadgeText",
},
@@ -89,6 +102,7 @@ return {
gotFlag = "EVENT_GOT_TM21",
noRoom = "_CeladonGymTM21NoRoomText",
deactivate = range("EVENT_BEAT_CELADON_GYM_TRAINER_", 0, 6),
tmSound = "Get_Item1",
dialogue = {
"_CeladonGymErikaReceivedRainbowBadgeText",
},
@@ -102,6 +116,7 @@ return {
gotFlag = "EVENT_GOT_TM06",
noRoom = "_FuchsiaGymKogaTM06NoRoomText",
deactivate = range("EVENT_BEAT_FUCHSIA_GYM_TRAINER_", 0, 5),
tmSound = "Get_Key_Item",
dialogue = {
"_FuchsiaGymKogaReceivedSoulBadgeText",
},
@@ -115,6 +130,8 @@ return {
gotFlag = "EVENT_GOT_TM46",
noRoom = "_SaffronGymSabrinaTM46NoRoomText",
deactivate = range("EVENT_BEAT_SAFFRON_GYM_TRAINER_", 0, 6),
badgeSound = "Get_Key_Item",
tmSound = "Get_Item1",
dialogue = {
"_SaffronGymSabrinaReceivedMarshBadgeText",
},
@@ -128,6 +145,8 @@ return {
gotFlag = "EVENT_GOT_TM38",
noRoom = "_CinnabarGymBlaineTM38NoRoomText",
deactivate = range("EVENT_BEAT_CINNABAR_GYM_TRAINER_", 0, 6),
badgeSound = "Get_Key_Item",
tmSound = "Get_Item1",
dialogue = {
"_CinnabarGymBlaineReceivedVolcanoBadgeText",
},
@@ -141,6 +160,8 @@ return {
gotFlag = "EVENT_GOT_TM27",
noRoom = "_ViridianGymGiovanniTM27NoRoomText",
deactivate = range("EVENT_BEAT_VIRIDIAN_GYM_TRAINER_", 0, 7),
badgeSound = "Get_Item1", -- sound_level_up
tmSound = "Get_Item1",
dialogue = {
"_ViridianGymGiovanniReceivedEarthBadgeText",
},
+2 -2
View File
@@ -1,6 +1,6 @@
# Sideload the iOS build with AltStore
Every GitHub Release ships an IPA (`gen1recomp-*-ios.ipa`). Install it on
Every GitHub Release ships an IPA (`gen1recomp++-*-ios.ipa`). Install it on
your iPhone or iPad with [AltStore Classic](https://altstore.io/) — AltStore
re-signs the app with **your** free Apple ID so you do not need a Mac or
Xcode.
@@ -23,7 +23,7 @@ Stuck? Start here:
## 2. Install the game
1. Download `gen1recomp-*-ios.ipa` from
1. Download `gen1recomp++-*-ios.ipa` from
[Releases](https://github.com/bryanthaboi/gen1recomp/releases).
2. Open **AltStore** on the phone (AltServer must be running on the same
WiFi, or keep the phone plugged into the computer).
+8 -6
View File
@@ -130,18 +130,20 @@ before `Game:load`, so **it never loads a mod's entry chunk**; only
duplicate) and returns one row per mod:
`{id, name, version, badge, description, enabled, status, statusDetail}`.
`badge` is the manifest's `category`, falling back to `profile`, then
`"MOD"`, uppercased. `enabled` reads `options.mods[id]` (missing means
enabled, matching the loader's own default).
`"MOD"`, uppercased. `enabledByVersion` contains an answer for each game;
missing entries default to enabled (except experimental mods), matching the
loader. On the first run with per-game controls, legacy shared choices are
copied to every installed game's answer.
- `status` is `"ok"`, `"warn"`, or `"conflict"`, computed by the pure
`LauncherMods.deriveList`/`statusFor` against `ManagerState.resolveToggle`
and the validated manifests: `conflict` when enabling this mod collides
with another enabled one; `warn` for an out-of-range `game_version` or an
absent/disabled/wrong-version hard dependency; `ok` otherwise. Having no
`love.*` calls, this half is table-driven by the test suite on its own.
- `LauncherMods.setEnabled(id, bool)` persists `options.mods[id]` as a plain
boolean, the exact shape `Loader:_saveState` writes, so the running game
and the in-game `ManagerState` see the change on next boot. The mods panel
calls this on every toggle and re-derives the list right away
- `LauncherMods.setEnabled(id, bool, version)` persists the selected game's
answer, so the running game and the in-game `ManagerState` see the change on
next boot. The MODS panel renders a coloured checkbox for Red, Blue, Yellow,
and Gold on every row and re-derives the list right away
(`RomImporter:_refreshMods`) so a status change (e.g. a new conflict)
shows without waiting for a reload.
- `LauncherMods.installZip(path)` mounts the archive with
+31 -17
View File
@@ -96,14 +96,14 @@ The launcher asks the same question of a mod's dependencies: one whose hard
dependency does not run on the selected game reads `Needs <id> (not for Gold)`,
matching the loader's contagious skip.
A separate overlay, `options.modsByVersion[version][id]`, is where a per-game
enable flag will live. It is a preview: `SaveData.PER_VERSION_MODS` is `false`,
so `SaveData.modScope` answers nil for every caller and the launcher panel, the
in-game manager and the loader all read *and* write the one shared
`options.mods` flag. Nothing consults the overlay for enablement until that
flips, which is deliberate: the overlay is plantable from an imported
`.g1rmodlist`, and a reader scoped differently from the writers would show a
mod set no boot would honour.
A separate overlay, `options.modsByVersion[version][id]`, holds each game's
enable flag. The launcher shows a coloured Red / Blue / Yellow / Gold checkbox
for every installed mod, and the loader and in-game manager read the same
game-specific answer on the next boot. On the first launch after this feature,
the existing shared state is copied to every game, so a mod that was enabled
remains enabled everywhere; after that, changing one checkbox affects only
that game. New mods still default to enabled on every game (experimental mods
retain their explicit opt-in default).
That is deliberate. Gold reimplements the battle engine, the overworld, the
script VM and the save format, so a Gen 1 mod dropped into a Gold boot would
@@ -118,10 +118,11 @@ Gen 2 games no longer loads on Red, Blue or Yellow. Say `["all"]` or list both
generations if you want both.
Two riders. **A hard dependency that does not run here takes the dependent down
with it**, as a skip rather than a failure and carrying the dependency's own
wording (`depends on X, which does not run here (For Blue, not Red)`), so the
whole chain has to cover the same games. And **the claim is yours, not the last
word**: it is the manager's `TRY HERE ANYWAY` row that lets a player run a mod
with it** (unless scoped to specific games, e.g.
`dependencies: [{ id = "x", games = ["gen2"] }]`), as a skip rather than a
failure and carrying the dependency's own wording (`depends on X, which does not
run here (For Blue, not Red)`), so the whole chain has to cover the same games.
And **the claim is yours, not the last word**: it is the manager's `TRY HERE ANYWAY` row that lets a player run a mod
whose author never opted in, which is the only route for a mod written before
the field existed. The override is per game -- `options.modsGen2[id]` is a
`{ [version] = true }` table, so forcing a mod onto Red does not force it onto
@@ -239,7 +240,7 @@ resolves to the weaker claim:
| `warned` | present, answers nil or degrades, and names itself once with the mod attributed |
| `absent` | deliberately not served; a nil read is the honest failure |
Today that is 288 backed, 32 warned and 161 absent across the fifteen modules.
Today that is 291 backed, 32 warned and 161 absent across the fifteen modules.
`notes` keys are documentation topics rather than a member list -- dotted paths
(`save.money`), field names (`warpAt`), hook names (`hook ui.pc.items`) and
bare topics (`identity`, `iteration`, `rawset`) all appear there. `members` is
@@ -474,6 +475,8 @@ Generation-agnostic; nothing to adapt.
(`src/world/gen2/WorldAPI.lua`). Two differences show through and are
documented on the module: Gold's world is not a stack state, and Gen 2 event
flags are numeric ids into `wEventFlags` rather than string keys.
`mapOverview` returns the same read-only terrain, tile-shading, and marker
shape, using Gold's live object masks and event flags to omit collected items.
`spawnNpc` / `removeNpc` append onto the map def's own object list, the way the
Gen 1 arm does, so a spawned actor is pooled, drawn, walked and talked to like
an extracted one and survives a map reload; it is not serialized, so a mod
@@ -483,6 +486,9 @@ has its own entry points for (`start_battle "wild" species level`, `warp`,
**by name, before the first row runs**, so a mod never gets a half-run queue.
`marchInPlace` still has no Gen 2 equivalent (the Gen 2 movement stream has no
byte for it) and returns `nil, reason` rather than approximating one.
`availableFieldActions` and `useFieldAction` expose the same contextual
bicycle and fishing records in both games. Each engine keeps ownership of its
inventory, terrain, surfing, bike, and fishing rules.
**Hooks and events that fire on Gold.** Every name below is the Gen 1 name
carrying the Gen 1 payload keys, because Gold's call sites reuse them rather
@@ -531,8 +537,9 @@ gains a field instead of the name gaining a prefix.
`pokemon.level_up`, `pokemon.move_learned`; hooks `battle.damage`,
`battle.crit`, `battle.accuracy`, `battle.turn_order`,
`battle.enemy_action`, `battle.run`, `battle.exp_award`, `exp.gain`,
`catch.rate`, `trainer.party`, `battle.overlay`, `battle.low_health_alarm`
and `battle.catch_exp`. One payload difference: Gen 1's vanilla
`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.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
@@ -544,11 +551,13 @@ gains a field instead of the name gaining a prefix.
each row's decision in `evolution.check`. The hook passes `data` where Gen 1
passes `game`; positions 2-4 (mon, row, trigger) match.
- *The frame (`src/core/Game2.lua`):* hooks `input.step`, `input.pointer`,
`render.zones`, `render.compose`, `render.letterbox`, `render.hud`. Each sits
`render.zones`, `render.compose`, `render.output_enabled`, `render.output`,
`render.letterbox`, `render.hud`. Each sits
at the same moment `src/core/Game.lua` and `src/render/Renderer.lua` raise it
-- the logic tick before the pad is read, a pointer the touch overlay gets
first refusal on, the palette zone list handed to the present pass, the
letterbox, and the finished playfield rect -- and carries the same payload.
composed frame before GBCFX, the letterbox, and the finished playfield rect
-- and carries the same payload.
`render.hud`'s `gameX` / `gameY` really is where Gold's dialogue boxes and
menus land, because `Chrome.fitScale` / `fitOrigin` and `World:fitScale`
compute the same number. `render.zones` is handed `nil` in GBC mode (Gold
@@ -757,6 +766,11 @@ name and the existing payload, plus fields where Gen 2 genuinely carries more
The list is much shorter than it was. What is outstanding, in descending value:
- `trainer.before_battle`: Gold constructs and pushes its trainer battle in
`src/world/gen2/World.lua:startBattle`, which does not yet expose a deferred
preparation boundary or a battle-local player-party view. Gen 1 mods can use
the hook documented in `docs/modding.md`; do not claim Gold compatibility
when that selection is required.
- `pokemon.before_give` / `pokemon.received`: Gold has no give-mon seam of its
own yet.
- `link.*` and `trade.completed`: a Gold boot offers no link menu at all. The
+209 -12
View File
@@ -21,6 +21,76 @@ luajit tools/gen_registry_docs.lua
luajit tools/gen_registry_docs.lua ../gen1recomp.wiki
```
## Manifest specification (`manifest.json`)
Every mod contains a root `manifest.json` defining its metadata, supported games, and dependencies for the engine loader.
```json
{
"id": "my_mod",
"name": "My Cool Mod",
"version": "1.0.0",
"api": 2,
"entry": "main.lua",
"profile": "content",
"category": "GAMEPLAY",
"games": ["gen1", "gen2"],
"game_version": ">=0.0.0-dev <2.0.0",
"priority": 100,
"dependencies": [
"helper_lib@^1.0.0",
{ "id": "pokegear_cards", "games": ["gen2"], "range": "^1.0.0", "github": "1jamie/pokegear_cards" }
],
"optional_dependencies": [
"gen1_modern_ui"
],
"conflicts": [],
"permissions": ["engine_internals"],
"description": "A brief description of the mod.",
"github": "author/my_mod"
}
```
### Manifest Fields
| Field | Type | Description |
| --- | --- | --- |
| `id` | `string` | Unique identifier (lowercase alphanumeric, underscores, hyphens). |
| `name` | `string` | Human-readable title shown in launcher and manager. |
| `version` | `string` | Semantic version string (e.g. `"1.0.0"`). |
| `api` | `integer` | Mod API level (`2` for current standard, `1` for legacy). |
| `entry` | `string` | Entry Lua file path relative to mod root (usually `"main.lua"`). |
| `profile` | `string` | Mod profile: `"content"`, `"overhaul"`, or `"total_conversion"`. |
| `category` | `string` | Categorization chip (e.g. `"GAMEPLAY"`, `"CONTENT"`, `"UI"`, `"AUDIO"`). |
| `games` | `array` | Supported game versions: `["gen1"]`, `["gen2"]`, `["red"]`, `["blue"]`, `["yellow"]`, `["gold"]`, or `["all"]`. |
| `game_version`| `string` | Semver range of required engine version (e.g. `">=0.0.0-dev <2.0.0"`). |
| `priority` | `integer` | Load priority order (lower numbers load earlier; dependencies always precede dependents regardless of priority). |
| `dependencies` | `array` | Hard required dependencies. A mod will not load if a required dependency is missing or disabled for the active game. |
| `optional_dependencies` | `array` | Soft dependencies. Guarantees that if the target mod is present and active, it loads *before* this mod without blocking load if absent. |
| `conflicts` / `incompatible` | `array` | List of mod IDs that cannot run concurrently with this mod. |
| `permissions` | `array` | Requested privileges (e.g. `["engine_internals"]`, `["network"]`, `["filesystem"]`). |
| `github` | `string` | GitHub repository (`"owner/repo"`) used for update checks and dependency download links. |
### Declaring Dependencies & Scoping
Dependencies in `dependencies` and `optional_dependencies` can be declared in several formats:
1. **Simple string**: `"mod_id"`
2. **Version-pinned string**: `"mod_id@^1.2.0"`
3. **Repository-hinted string**: `"mod_id#owner/repo"` or `"mod_id@^1.2.0#owner/repo"`
4. **Structured object**:
```json
{
"id": "mod_id",
"range": "^1.2.0",
"games": ["gen2"],
"github": "owner/repo"
}
```
#### Version-Scoped Dependencies
When a mod supports multiple games (`"games": ["gen1", "gen2"]`), a dependency can specify `"games": ["gen2"]` to indicate it is only required when booting Gen 2. When booting Gen 1, the engine will ignore the dependency, preventing unnecessary boot blocks on games that do not need it.
## Mods and Gold (Gen 2)
The mod API is one API across both generations, but Gold runs its own battle
@@ -66,7 +136,8 @@ optional visual `tileRows` at 2x resolution, and optional `tileDetailRows` at
`"3"` (darkest); their matching width and height fields describe the grid.
`markers` contains active `{ kind, x, y }` points in map-cell coordinates for
`warp`, visible `item`, and untaken `hidden` locations. All fields are
read-only snapshots; mods choose which layers to render.
read-only snapshots; mods choose which layers to render. Red and Gold expose
the same contract while applying their own object and event visibility rules.
## Party ordering
@@ -76,6 +147,20 @@ Companion UIs and alternate party screens can call
operation is accepted only during idle overworld play; menus, movement,
scripts, battles, and transitions leave the party untouched.
## Contextual field items
`mod.world:availableFieldActions()` returns the field items that can start at
the player's current position. Red and Gold currently expose `bicycle` and
`fish`; fishing rows include the owned rods that are valid choices. The list
is empty while the world is busy, while riding states or terrain forbid an
action, or when the required item is not owned.
Call `mod.world:useFieldAction(id, opts)` to perform a listed action through
the active game's own field-item path. Fishing accepts `{ rod = "OLD_ROD" }`
and chooses automatically when only one rod is available. Invalid, stale, and
busy requests return `nil` plus a reason without changing game state. Mods do
not need generation-specific bike, collision, or fishing logic.
## Rendering pipelines
Most registries hand the engine *content*. `render_pipelines` hands it
@@ -237,6 +322,25 @@ local keys, code, message = mod.storage:list(game, "history/quick")
local deleted, code, message = mod.storage:delete(game, "history/quick/q0001")
```
For independently generated binary data, use the opaque byte methods. They
accept and return the exact Lua string of bytes, including NUL bytes and bytes
that are not valid text:
```lua
local ok, code, message = mod.storage:writeBytes(
game, "cache/maps/pallet/terrain", encodedMesh)
local encodedMesh, code, message = mod.storage:readBytes(
game, "cache/maps/pallet/terrain")
```
Opaque values are limited to 512 MiB per key. The engine stores them without
decoding, compression, or an engine-defined file format, and never executes
them. A consuming mod owns validation of its format, fingerprint, checksum,
and compression metadata. Byte writes are staged and compared byte-for-byte
before replacement, and reads can recover a valid backup after an interrupted
write. Existing table values and opaque byte values use one shared logical key
space; delete a key before changing its value from one type to the other.
`context` returns `{ engineVersion, gameVersion, playthroughId }`. The engine
version is compatibility metadata; physical launcher-slot and path identity stays
private. A title-selected context may additionally contain `normalSavedAt`, the
@@ -245,19 +349,22 @@ progress or a slot/path handle.
At the title screen only, `mod.storage:selected(game)` returns a bound storage
facade for the launcher-selected existing playthrough, or `nil, code, message`.
Resolving this facade is read-only: it never allocates an identity, adopts a
Resolving this facade is non-allocating: it never allocates an identity, adopts a
fresh New Game, or exposes a slot id/path. Its `context()`, `read(key)`,
`write(key, value)`, `list(prefix)`, and `delete(key)` methods have the same
data-only and transaction contract as `mod.storage`, but remain restricted to
the calling mod's selected existing namespace. It is intended for title tools
that need to browse or manage durable history before the first normal SAVE.
`write(key, value)`, `readBytes(key)`, `writeBytes(key, bytes)`,
`list(prefix)`, and `delete(key)` methods have the same scoped and
transactional contract as `mod.storage`, but remain restricted to the calling
mod's selected existing namespace. It is intended for title tools that need to
browse or manage durable history before the first normal SAVE.
Values must be tables containing serializable data only. Keys are conservative
slash-separated segments (letters, digits, `_`, `-`); paths and filesystem
handles are never exposed. Writes are staged and decode-verified, reads recover
from a valid staged/backup generation, and methods return structured errors for
normal data or I/O failures. The playthrough identity is allocated lazily on the
first storage/checkpoint call, so an unused API changes no save bytes.
Table values must contain serializable data only. Opaque values must be Lua
strings. Keys are conservative slash-separated segments (letters, digits, `_`,
`-`); paths and filesystem handles are never exposed. Table writes are staged
and decode-verified; opaque writes are staged and byte-verified; reads recover
from a valid staged/backup generation. Methods return structured errors for
normal data, byte validation, and I/O failures. The playthrough identity is
allocated lazily on the first storage/checkpoint call, so an unused API changes
no save bytes.
`mod.checkpoints` captures and reconstructs engine-owned semantic runtime state:
@@ -352,6 +459,43 @@ animation/messages, forced choices, and every phase that cannot safely be
checkpointed remain excluded. Exceptions are contained by normal hook isolation
and fall through without advancing a turn.
Gen 1 trainer encounters also expose `trainer.before_battle` after the
challenge text and immediately before battle construction. This lets a mod
defer the encounter while it collects a player choice through a registered
screen, then resume with a battle-local view of the save party:
```lua
mod.hooks:wrap("trainer.before_battle", function(next, game, context, continue)
-- context = { trainerClass, partyIndex, mapId, npcId }
mod.ui.push(game, "party_registration", {
onConfirm = function(indices)
continue({ playerPartyIndices = indices })
end,
onCancel = function()
continue({ cancel = true })
end,
})
return true
end)
```
Return `true` only when retaining `continue` for a later callback. Calling
`continue({ cancel = true })` ends the encounter without constructing a battle;
the normal encounter completion callback returns control to the overworld and
no trainer-defeated state is written. A cancelled sight encounter is suppressed
at the current player cell so it cannot immediately reopen; moving one cell or
talking to the trainer permits a new challenge. Calling `continue()` uses the
full save party; passing
`{ playerPartyIndices = { 2, 4, 5 } }` uses those ordered, one-based party
members for initial send, switching and forced replacement, exhaustion,
experience traversal, and battle party displays. The continuation is one-shot.
An empty, duplicate, out-of-range, or otherwise malformed list safely falls
back to the full party. The view references the original Pokemon records and
never reorders or replaces `game.save.party`; trainer battle checkpoints retain
the selected indices. Mods remain responsible for selection policy and should
use only public `mod.ui`, hook, and save APIs. See RFC 0010 for the exact
contract and compatibility guarantees.
## Developer console
Boot with developer mode on to unlock the in-game console and hot-reload
@@ -439,6 +583,20 @@ oldest queued event as `"action,x,y"` in submitted-frame coordinates, or `nil`.
This is what lets a mod lay the two passes out as two stacked Game Boy screens,
or push one onto a second screen, without the engine knowing the layout.
`render.output_enabled` and `render.output` are the later, whole-window seam
for mods that need the engine's normal composite rather than its separate
layers. It runs after registered present pipelines and before GBCFX,
`render.hud`, and touch controls. A mod wraps both hooks: the first returns
`true` only while output ownership is needed, and the second receives
`(next, ctx)` with `canvas`, `width`, `height`, `gameX`, `gameY`, `gameWidth`,
`gameHeight`, `scale`, `dpiX`,
`dpiY`, and `generation`. Returning `true` from `render.output` takes over the
window; calling `next(ctx)` keeps the normal presentation. Both hooks default
to `false`. Enabling the seam requires a full-window canvas for that frame.
With no `render.output` subscriber, or while `render.output_enabled` is false,
the existing presentation path is unchanged. `render.compose` takes precedence
when it owns the frame.
`screen.render_visible` receives `(next, state)` while the main screen is being
composed. Return `false` to omit that state from drawing, opacity selection and
palette-zone ownership. The state remains on the stack and keeps its normal
@@ -454,6 +612,7 @@ identifiers: `pc_box_withdraw`, `pc_box_deposit`, `pc_box_release`,
`battle.bottom_ui_visible` and `battle.status_hud_visible` independently
control the battle text/menu layer and the HP/status panels. Both receive
`(next, state)` and default to `true`, so vanilla rendering is unchanged.
Both hooks apply to Gen 1 and Gen 2 battles.
Text boxes and YES/NO prompts pushed above a battle inherit a `false` result
for that battle, so hiding the bottom layer cannot leave their white backing
behind under another overlay. Text boxes also pass through the hook as their
@@ -544,3 +703,41 @@ local both = mod.datetime:dateTime(game, createdAt)
The live `game` supplies only the current option context. Formatting never
mutates the save, options, or timestamp, and invalid timestamps return
`"----"`.
## Device power information
Sandboxed mods can read the host's battery state without receiving the rest
of `love.system`:
```lua
local state, percent = mod.device:powerInfo()
```
`state` follows LÖVE's values: `"unknown"`, `"battery"`, `"nobattery"`,
`"charging"`, or `"charged"`. `percent` is `0` through `100`, or `nil` when
the platform cannot report it. The facade is read-only and does not expose
URL launching, clipboard access, or other system operations.
## Real-world steps
On iOS and Android the game counts the player's real-world steps natively
(HealthKit / the hardware step counter). A mod reaches that bridge through
the `steps` permission in `manifest.json`, which the player sees in the
mod manager like every other permission:
```lua
if mod.steps:available() then
mod.steps:sync() -- async; OS consent sheet on first use
end
-- later, at a quiet moment:
local walk = mod.steps:poll() -- { steps = n, from = ?, to = ? } or nil
```
`available()` is `false` on builds without the bridge (desktop) and for
mods without the permission, so a probe is always safe. `sync()` asks the
platform to refresh its count and returns whether there was a bridge to
ask. `poll()` returns the next delivery for this mod — the engine consumes
the native side's pending file itself, each permissioned mod receives its
own copy of a delivery, and steps are anchored natively so the same walk
is never delivered twice. Without the permission, `sync` and `poll` raise
an error naming it.
+7
View File
@@ -929,6 +929,10 @@ mod.content.sfx:register("SFX_MOD_CHIME", { file = "chime.ogg" })
| field | type | required |
|---|---|---|
| `anchorX` | number | no |
| `anchorY` | number | no |
| `frameHeight` | integer >= 1 | no |
| `frameWidth` | integer >= 1 | no |
| `frames` | integer >= 1 | yes |
| `id` | string | no |
| `image` | file path | yes |
@@ -1100,6 +1104,7 @@ mod.content.tokens:register("CLOCK", function(game) return "12" end)
| `paletteSource` | string | no |
| `parties` | list of list of {level, species} | yes |
| `pic` | file path | no |
| `trueColor` | boolean | no |
```lua
mod.content.trainers:patch("OPP_BROCK", { baseMoney = 99 })
@@ -1122,7 +1127,9 @@ do not.
| `index` | integer 0..255 | no |
| `items` | list of items id | no |
| `name` | string | yes |
| `pic` | file path | no |
| `trainers` | list of {id?, index?, name, party, trainerType?} | yes |
| `trueColor` | boolean | no |
```lua
mod.content.trainers:patch("BEAUTY", { baseMoney = 99 })
+2 -1
View File
@@ -19,13 +19,14 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow
* **Soft reset button combination**
* **Keyboard and controller rebinding**
* **Mod profiles** with separate mod settings and save slots
* **Sandboxed mods**: an installed mod can read only its own folder and write only its own storage, so it cannot reach the rest of your device
* **Improved launcher and save editor UI**, including background downloads and update checks
* **Direct-launch options** for shortcuts, Steam entries, and handheld frontends
* **Custom boot branding**
## Pokémon Gold (Gen 2)
A fourth game the launcher can import and play, built from pret/pokegold the same way Red/Blue/Yellow are built from pokered. Port extras beyond the cartridge:
A fourth game the launcher can import and play, built from pret/pokegold the same way Red/Blue/Yellow are built from pokered (and pokeyellow). Port extras beyond the cartridge:
* **COLOR, zoom, tilt, GBC FX, and quick save/load**
* **UI that stays fixed while the overworld zooms**
+28 -14
View File
@@ -258,20 +258,34 @@ row on the detail screen. The launcher's dependency verdict asks the same
question of your dependencies: a mod whose hard dependency does not run on the
selected game reads `Needs <id> (not for Gold)` rather than `Ready`.
### Scoping dependencies per game / generation
For mods targeting multiple generations (`"games": ["gen1", "gen2"]`), a hard
dependency can be scoped to specific games so that it is only enforced when
booting those games:
```json
"dependencies": [
{ "id": "pokegear_cards", "games": ["gen2"], "range": "^1.0.0", "github": "1jamie/pokegear_cards" }
]
```
When booting a Gen 1 game (Red, Blue, Yellow), the engine loader sees that
`pokegear_cards` is scoped to `"gen2"` and will not skip or block the parent mod
on Gen 1. When booting Gen 2 (Gold), `pokegear_cards` is strictly required.
For conditional integrations where the dependency is optional across the board,
`optional_dependencies` remains the standard pattern.
### One limit worth knowing
**Per-game enable flags are still a preview.** The overlay
`options.modsByVersion[version][id]` exists and every surface goes through
`SaveData.modEnabled` / `SaveData.setModEnabled`, but
`SaveData.PER_VERSION_MODS` is `false`
(`src/core/SaveData.lua:489`). While it is false, `SaveData.modScope` answers
nil for every caller, so the launcher panel, the in-game manager *and* the
loader all read and write the one shared `options.mods` flag and the overlay is
not consulted for enablement anywhere. That matters because the overlay is
plantable from an imported `.g1rmodlist`: keeping every reader on the same
scope as every writer is what stops a stored per-game flag from showing a mod
set no boot would honour. Nothing about this affects a mod author; it affects
what a player can currently express.
**Enablement is per game.** The overlay
`options.modsByVersion[version][id]` is read and written through
`SaveData.modEnabled` / `SaveData.setModEnabled` by the launcher, in-game
manager, and loader. Existing shared settings are copied to every game the
first time this version sees the installed mods; from then on, each coloured
game checkbox changes only that game's next boot. Nothing about this affects a
mod author; it affects what a player can express.
Targeting is a different question from enablement and *is* enforced per game,
as above. The two do not share a switch.
@@ -311,7 +325,7 @@ This is not a dev-mode feature; it installs on any Gold boot that has mods.
| `src.pokemon.Boxes` | facade | over `src/core/gen2/Boxes.lua` | 22 / 0 / 0 |
| `src.battle.BattleState` | facade | over `src/ui/gen2/BattleState.lua` | 16 / 2 / 39 |
| `src.ui.PartyMenu` | facade | over `src/ui/gen2/PartyMenu.lua` | 15 / 2 / 16 |
| `src.world.WorldAPI` | alias | `src/world/gen2/WorldAPI.lua` | 12 / 2 / 0 |
| `src.world.WorldAPI` | alias | `src/world/gen2/WorldAPI.lua` | 15 / 2 / 0 |
| `src.world.PikachuFollower` | alias | `src/world/gen2/Follower.lua` | 10 / 0 / 11 |
| `src.script.ScriptRunner` | facade | over `src/script/gen2/Vm.lua` | 10 / 7 / 1 |
| `src.ui.OptionsMenu` | facade | over `src/ui/gen2/OptionsMenu.lua` | 8 / 0 / 1 |
@@ -760,7 +774,7 @@ profile to test in, and `POKEPORT_DEV=1` adds the console and `F5` hot reload.
- **Coverage is partial and will stay partial.** 15 Gen 1 modules are served
out of a much larger engine, and within those 15 the coverage table records
288 backed members against 32 warned and 161 absent. The absent ones are not
291 backed members against 32 warned and 161 absent. The absent ones are not
a backlog; most are absent because there is no honest Gen 2 answer, and each
one carries its reason. The counts move as the adapter learns something: a
member that turns out to answer nil is demoted from backed to warned or
+29 -6
View File
@@ -26,8 +26,8 @@ wiki's Save Model. `mod.save` and `mod.options` keep their existing behavior.
## The exact API delta
Backward-compatible, additive-only. `Loader:_api` binds a new `mod.storage`
facade to the calling mod id. Mods receive logical keys and decoded values, never
filesystem handles or physical paths.
facade to the calling mod id. Mods receive logical keys and either decoded table
values or exact opaque byte strings, never filesystem handles or physical paths.
### Lazy opaque playthrough identity
@@ -75,6 +75,25 @@ Returns a freshly decoded table, or `nil, code, message`. It tries main, staged,
then backup data. A valid staged/backup value is returned and promoted
best-effort; corrupt bytes are never executed.
### `mod.storage:writeBytes(game, key, bytes)`
Accepts a Lua string containing opaque bytes and returns `true`, or
`false, code, message`. Empty strings are valid. Payloads are limited to 512
MiB per key. The engine writes the supplied bytes exactly as received, without
decoding, compression, checksums, or an engine-defined envelope. The consuming
mod owns semantic validation of its format.
Byte records use private `.bin`, `.bin.tmp`, and `.bin.bak` witnesses. A staged
and replacement write is read back and compared byte-for-byte before it is
committed. A failed write leaves the previous verified generation readable.
Byte storage never passes its payload to the Lua serializer, loader, or module
resolver.
Table and byte records share one logical key namespace and a key has one type.
Writing one type over the other returns `type_conflict`; callers must delete the
key before changing its type. `mod.storage:selected(game)` exposes the same
`readBytes` and `writeBytes` operations for the selected playthrough facade.
### `mod.storage:list(game[, prefix])`
Returns sorted logical keys beneath a valid prefix, an exact key when the prefix
@@ -92,9 +111,9 @@ Physical records are scoped as:
`persistence root / mod_storage / game version / playthrough id / mod id`
Stable error codes are `not_in_playthrough`, `storage_unavailable`,
`invalid_key`, `encode_failed`, `write_failed`, `verify_failed`, and
`not_found`. Ordinary data and I/O failures are return values, not callback-
terminating errors.
`invalid_key`, `encode_failed`, `invalid_bytes`, `size_limit`, `type_conflict`,
`type_mismatch`, `write_failed`, `verify_failed`, and `not_found`. Ordinary
data and I/O failures are return values, not callback-terminating errors.
The restricted serializer's recursive writer runs outside LuaJIT traces. A
1,000-process GC stress regression found compiled recursion could intermittently
@@ -107,6 +126,8 @@ boundary.
**Nothing.** No API is removed, no manifest field changes, and no storage path or
playthrough id is created unless a mod invokes `mod.storage` or
`mod.checkpoints`. Existing save bytes remain unchanged on the no-caller path.
Existing files outside the scoped storage contract are not imported; a caller
must rebuild them through `writeBytes`.
## Parity tests
@@ -115,8 +136,10 @@ playthrough id is created unless a mod invokes `mod.storage` or
- **Engine identity:** lazy allocation, save/load preservation, stable legacy
mapping, fresh-playthrough replacement, and version/slot isolation.
- **Public Mod API:** two real API-2 entry chunks prove data-only roundtrip,
opaque byte roundtrip including NUL bytes, no execution, size/type rejection,
deterministic listing, key rejection, mod/game/playthrough isolation,
corrupt-main recovery, failure retention, exact delete, and no-mod no-write.
corrupt-main recovery, failure retention, selected-playthrough access, exact
delete, and no-mod no-write.
## Deprecation etiquette
+49
View File
@@ -0,0 +1,49 @@
# RFC 0008 — Read-only device power information for sandboxed mods
## Status
Proposed. Engine: `Loader.lua`, `Sandbox.lua`. Test:
`tests/modkit/cases/device_power_info.lua`.
## Motivation
A handheld UI mod can show the player's battery state and warn before power
loss. The sandbox correctly removes `love.system` because that module also
launches URLs and exposes other host operations, but it leaves no scoped way
to read the harmless power values that LÖVE already provides.
## The decision it extends
Extends the mod sandbox in `src/mods/Sandbox.lua`: blocked host modules stay
blocked while legitimate operations receive narrow engine-owned facades.
## The exact API delta
Add `mod.device:powerInfo() -> state, percent`.
The engine calls `love.system.getPowerInfo()` outside the mod sandbox and
returns only its first two values. `state` is one of LÖVE's standard power
states. `percent` is `0` through `100` or `nil`. When the platform has no
power-information backend, the result is `"unknown", nil`.
No permission grants access to `love.system`; URL launching, clipboard access,
OS identification, and the module table itself remain unavailable.
## Migration note for existing mods
Mods that used `love.system.getPowerInfo()` replace that call with
`mod.device:powerInfo()`. No other mod changes.
## Parity tests
- **No mod:** loading no mods does not call the platform power backend.
- **Mod API:** a fixture mod loaded through the public loader receives state
and percentage through `mod.device`, while the existing sandbox suite keeps
proving that direct `love.system` access is refused.
- **Unavailable backend:** the public facade returns `"unknown", nil` rather
than inventing battery data or failing mod load.
## Deprecation etiquette
Nothing deprecated. The facade is additive; the sandbox's `love.system` block
remains in force.
+68
View File
@@ -0,0 +1,68 @@
# RFC 0009 — Permission-gated step bridge for sandboxed mods
## Status
Proposed. Engine: `Steps.lua` (new), `Loader.lua`, `Manifest.lua`,
`Sandbox.lua`. Test: `tests/modkit/cases/steps_bridge.lua`. Issue: #1186.
## Motivation
The iOS and Android builds count the player's real-world steps natively
(#452, #489), exposed to Lua as `love.system.syncHealthSteps()` and
delivered as `steps_pending.json` in the save-directory root. The sandbox
correctly blocks both — `love.system` also launches URLs, and the file API
names paths — but that leaves the bridge with no consumer: the mod that
step counting was built for (Pokéwalker, steps→EXP) can no longer be
written.
## The decision it extends
Extends the mod sandbox in `src/mods/Sandbox.lua` (blocked host modules
stay blocked; legitimate operations receive narrow engine-owned facades)
and the permission model `network` established: a `manifest.json`
permission the player sees in the mod manager that genuinely gates a
capability.
## The exact API delta
A new manifest permission token, `steps`, and a `mod.steps` facade:
- `mod.steps:available() -> boolean` — whether this build carries the
native bridge. Answers `false` without the permission, so a probe stays
quiet.
- `mod.steps:sync() -> boolean` — asks the platform to refresh its count
(async; the OS consent sheet still appears on first use, exactly as
before the sandbox). `false` when there is no bridge.
- `mod.steps:poll() -> { steps = n, from = iso?, to = iso? } | nil` — the
next delivery for this mod, engine-consumed from the pending file. Each
permissioned mod receives its own copy of a delivery.
Without the permission, `sync` and `poll` raise an error naming the
missing permission, the way the network gate does. The engine owns the
pending file: mods never learn its name or location, and only the three
contract fields travel. No new event, hook, or registry names.
## Migration note for existing mods
Mods that called `love.system.syncHealthSteps()` and read
`steps_pending.json` themselves add `"steps"` to `permissions` and switch
to `mod.steps:sync()` / `mod.steps:poll()`. No other mod changes.
## Parity tests
- **No mod:** with nothing installed the bridge is never called and a
pending file on disk is left untouched.
- **Mod API:** a fixture mod with the permission syncs and receives a
delivery through the public loader; two permissioned mods both receive
the same walk; a second poll returns nil.
- **No permission:** `available()` is false and the acting calls name the
missing permission; the sandbox suite keeps proving direct
`love.system` access is refused.
- **Malformed delivery:** a bad or empty pending file is dropped whole
rather than crashing a poll (the native anchor only advances on a
successful sync, so nothing is lost).
## Deprecation etiquette
Nothing deprecated. The facade is additive; the sandbox's `love.system`
and `love.filesystem` blocks remain in force.
@@ -0,0 +1,92 @@
# RFC 0010: Deferred trainer preparation and battle-local party scope
## Status
Proposed.
## Motivation
Challenge and tournament mods sometimes need a player to choose an eligible
subset of the save party before a trainer battle. The current public surface
can replace the opponent through `trainer.party` and observe
`world.trainer_engaged`, but it cannot pause the engagement before battle
construction or keep unselected save-party members out of initial send,
switch, replacement, exhaustion, experience, and party-menu traversal.
Temporarily rewriting `game.save.party` is not a safe substitute: it changes
authoritative save state, composes poorly with checkpoints and other mods, and
can strand excluded Pokémon if a callback or process fails.
## Decision and plan extended
This implements **D-AT-001: battle-local Gym registration without save-party
mutation**, the consuming design decision tracked as capability `AT-SP-001` in
the Adaptive Trainers implementation plan. The plan file is
[`docs/superpowers/plans/2026-08-14-adaptive-trainers.md`](https://github.com/MaxTomahawk/gen1recomp-adaptive-trainers/blob/main/docs/superpowers/plans/2026-08-14-adaptive-trainers.md),
Task 5. The engine delta also extends the additive, guarded public-hook
decision used by RFC 0007 and the screen facade documented in
`docs/modding.md`; it deliberately contains none of the consuming mod's Gym
or party-size policy.
## Exact API delta
Add the guarded hook:
```lua
mod.hooks:wrap("trainer.before_battle", function(next, game, context, continue)
-- context = { trainerClass, partyIndex, mapId, npcId }
-- Return true only when the battle has been deferred.
-- continue({ cancel = true }) returns without constructing a battle.
-- Call continue() for the full save party, or:
-- continue({ playerPartyIndices = { 2, 4, 5 } })
end)
```
The hook runs after the trainer's challenge text and immediately before the
trainer battle is constructed. A mod may push a registered screen with
`mod.ui.push`, return `true`, and retain `continue` for its confirm/cancel
callback. `continue` is one-shot and returns `false` after the first call.
Returning anything other than `true` without calling it continues immediately
with vanilla scope. With no subscriber, no context or continuation is built.
`playerPartyIndices` is an ordered, one-based list into `game.save.party`.
Valid unique indices create `battle.playerParty` as a battle-local view of the
same Pokémon records; the save party itself is never reordered or replaced.
Malformed or empty scopes degrade to the full party. The view governs initial
send, all battle party menus and targets, voluntary and forced replacement,
exhaustion/blackout checks, participant and EXP.ALL traversal, party counts,
and party-ball presentation. Checkpoints preserve the index list and rebuild
the same view before restoring battlers.
`{ cancel = true }` ends a deferred encounter through its normal completion
callback without constructing a battle or writing trainer-defeated state. A
cancelled sight encounter is suppressed while the player remains on the same
cell, preventing immediate reacquisition; moving or directly talking permits a
new challenge. Cancellation is also one-shot; if supplied alongside a party
index list, cancellation wins.
The API sets no maximum, chooses no members, identifies no boss, and contains
no scaling or challenge policy.
## Migration and compatibility
Existing mods change nothing. `BattleState.newTrainer(game, class, index)`
keeps its current behavior; the optional fourth argument is additive. Existing
battle checkpoints without a party scope restore against the full save party.
Wild, Safari, link, and no-mod battles are unchanged.
## Verification
- The catalog-driven hook gate proves empty-chain parity and the guarded hot
path proves no-mod engagement starts exactly once without allocation.
- A sandboxed fixture mod defers through its public hook facade, inspects the
data-only context, and resumes once with ordered indices.
- Engine tests cover initial send, party menus, replacement/exhaustion,
EXP traversal, invalid-scope fallback, and save-party identity.
- Battle-checkpoint tests prove scoped capture/restore and old-checkpoint
compatibility.
## Deprecation etiquette
Nothing is deprecated. The hook and optional constructor argument are
additive.
+12 -1
View File
@@ -26,7 +26,7 @@ JSON parsing, and sha256 verification run on a background `love.thread`
## Version.lua fields
`src/core/Version.lua` carries three fields the updater reads directly (the
`src/core/Version.lua` carries four fields the updater reads directly (the
existing `modApi`, `linkProtocol`, `saveFormat`, and `cache` fields are
untouched):
@@ -37,6 +37,11 @@ untouched):
as a valid payload to chainload).
- `shell` - the native-shell contract this build's fused executable
implements.
- `payloadHost` - the native host family an in-place payload targets. Ordinary
LÖVE packages use `"love"`. A specialized native package uses a distinct,
stable identifier and accepts only payloads carrying that same identifier.
A missing field defaults to `"love"`, preserving compatibility with payloads
released before this field existed.
- `minShell` - the lowest shell contract required to *run* this payload.
Bump `minShell` only when a payload needs something the currently-shipped
@@ -49,6 +54,12 @@ rather than deleting it, in case a future shell upgrade can run it, and
installer instead. Do not bump `minShell` for an ordinary Lua/data release;
that is exactly the case the updater exists to avoid a reinstall for.
Change `payloadHost` only when the packaged Lua depends on a different native
host family. This is separate from `minShell`: the host name answers *which*
native integration the payload targets, while the shell number answers *which
revision* of that integration it requires. A mismatched-host payload is never
mounted or deleted as stale; the launcher directs the player to a full package.
## Release assets
Each tagged release `vX.Y.Z` carries the existing per-platform archives
+29 -5
View File
@@ -14,6 +14,7 @@ local SwitchDiagnostics = require("src.debug.SwitchDiagnostics")
local LaunchOptions = require("src.core.LaunchOptions")
local NxDisplay = require("src.core.NxDisplay")
local PlatformHooks = require("src.core.PlatformHooks")
local HostDisplay = require("src.core.HostDisplay")
-- Lua errors: persist a redacted trace in the save dir and surface a hint.
do
@@ -197,14 +198,19 @@ end
local touchEditorHost
local closeTouchControlsEditor -- forward declaration
local function openTouchControlsEditor()
-- `version` is the launcher tab the gear was opened on, and it decides which
-- option block the layout lands in (src/ui/TouchControlsEditor.lua persist).
local function openTouchControlsEditor(version)
touchEditorHost = Importer
if Importer and Importer.prepareOverlayHandoff then
Importer:prepareOverlayHandoff()
end
Importer = nil
TouchEditor = require("src.ui.TouchControlsEditor")
TouchEditor.load({ onClose = function() closeTouchControlsEditor() end })
TouchEditor.load({
version = version,
onClose = function() closeTouchControlsEditor() end,
})
end
function closeTouchControlsEditor()
@@ -425,6 +431,7 @@ function love.load(args)
end
function love.update(dt)
HostDisplay.update(dt)
SwitchDiagnostics.maybeFlush(false)
-- NX only (no-op elsewhere): follow dock/undock without waiting for SDL.
NxDisplay.sync()
@@ -472,11 +479,27 @@ function love.update(dt)
end
function love.draw()
if editorMode then return EditorApp.draw() end
if TouchEditor then return TouchEditor.draw() end
if Importer then return Importer:draw() end
if editorMode then
HostDisplay.beginFrame("editor", EditorApp)
local result = EditorApp.draw()
HostDisplay.endFrame("editor", EditorApp)
return result
end
if TouchEditor then
HostDisplay.beginFrame("touch_editor", TouchEditor)
local result = TouchEditor.draw()
HostDisplay.endFrame("touch_editor", TouchEditor)
return result
end
if Importer then
HostDisplay.beginFrame("launcher", Importer)
local result = Importer:draw()
HostDisplay.endFrame("launcher", Importer)
return result
end
if not Game then return end
HostDisplay.beginFrame("game", Game)
Game:draw()
-- frame capture requested by a driver
if Game.capturePath then
@@ -491,6 +514,7 @@ function love.draw()
end
end)
end
HostDisplay.endFrame("game", Game)
end
function love.keypressed(key, scancode, isrepeat)
@@ -143,16 +143,36 @@ public class GameActivity extends SDLActivity {
private static native void nativeSetDefaultStreamValues(int sampleRate, int framesPerBurst);
/**
* Native libraries required by an optional Android host extension.
*
* Subclasses supplied by another product flavor may override this method.
* The libraries are loaded after LÖVE's dependencies and before liblove;
* liblove must remain last because SDL treats the final entry as the main
* shared object.
*/
protected String[] getHostLibraries() {
return new String[0];
}
@Override
protected String[] getLibraries() {
return new String[] {
"c++_shared",
"mpg123",
"openal",
"love",
};
String[] hostLibraries = getHostLibraries();
String[] libraries = new String[hostLibraries.length + 4];
libraries[0] = "c++_shared";
libraries[1] = "mpg123";
libraries[2] = "openal";
System.arraycopy(hostLibraries, 0, libraries, 3, hostLibraries.length);
libraries[libraries.length - 1] = "love";
return libraries;
}
protected void onHostCreateBeforeSDL(Bundle savedInstanceState) {}
protected void onHostCreateAfterSDL(Bundle savedInstanceState) {}
protected void onHostResume() {}
protected void onHostPause() {}
protected void onHostDestroy() {}
@Override
protected String getMainSharedObject() {
String[] libs = getLibraries();
@@ -192,7 +212,9 @@ public class GameActivity extends SDLActivity {
intent.setData(null);
}
onHostCreateBeforeSDL(savedInstanceState);
super.onCreate(savedInstanceState);
onHostCreateAfterSDL(savedInstanceState);
if (savedInstanceState != null) {
// Restore the in-flight SAF destinations, so a pick that returns to
// a recreated activity still lands under the basename it asked for.
@@ -341,6 +363,7 @@ public class GameActivity extends SDLActivity {
Log.d("GameActivity", "Cancelling vibration");
vibrator.cancel();
}
onHostDestroy();
super.onDestroy();
}
@@ -351,12 +374,14 @@ public class GameActivity extends SDLActivity {
vibrator.cancel();
}
teardownSecondaryDisplay();
onHostPause();
super.onPause();
}
@Override
public void onResume() {
super.onResume();
onHostResume();
setupSecondaryDisplay();
}
+9 -9
View File
@@ -7,7 +7,7 @@ an iOS app with LÖVE 12.0.
The app uses the public iOS Documents directory as its LÖVE save directory.
There is no `pokemon-love2d` subdirectory and the app does not create a
README file there. When browsing `On My iPhone > gen1recomp` in Files, the
README file there. When browsing `On My iPhone > gen1recomp++` in Files, the
directory contains the app's runtime data directly, including:
- installed mods and downloaded ROMs
@@ -46,7 +46,7 @@ scripts/build_ios.sh --device --release --install
Device builds require a paired, unlocked device and a valid Apple signing
identity. Set `DEVELOPMENT_TEAM` or `CODE_SIGN_IDENTITY` when automatic
signing cannot select the intended account. Add `--ipa` to create
`dist/ios/gen1recomp.ipa`.
`dist/ios/gen1recomp++.ipa`.
The script verifies the final app before packaging it:
@@ -76,10 +76,10 @@ option.
Simulator and device app bundles are copied to:
```text
dist/ios/Debug-iphonesimulator/gen1recomp.app
dist/ios/Release-iphonesimulator/gen1recomp.app
dist/ios/Debug-iphoneos/gen1recomp.app
dist/ios/Release-iphoneos/gen1recomp.app
dist/ios/Debug-iphonesimulator/gen1recomp++.app
dist/ios/Release-iphonesimulator/gen1recomp++.app
dist/ios/Debug-iphoneos/gen1recomp++.app
dist/ios/Release-iphoneos/gen1recomp++.app
```
The intermediate Xcode products are under `mobile/ios/build/`. Both locations
@@ -94,9 +94,9 @@ saves; those are created at runtime in Documents.
| Field | Default |
| --- | --- |
| Display name | `gen1recomp` |
| Product name | `gen1recomp` |
| Bundle identifier | `com.theboisclub.gen1recomp` |
| Display name | `gen1recomp++` |
| Product name | `gen1recomp++` |
| Bundle identifier | `com.theboisclub.gen1recompplusplus` |
| Save directory | Public `Documents` root |
| Orientation | Portrait |
+60 -4
View File
@@ -1,17 +1,73 @@
{
"name": "gen1recomp App Repo",
"identifier": "com.theboisclub.gen1recomp.repo",
"name": "gen1recomp++ App Repo",
"identifier": "com.theboisclub.gen1recompplusplus.repo",
"iconURL": "https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/assets/logo/gen1recomp_cover.png",
"apps": [
{
"name": "gen1recomp",
"bundleIdentifier": "com.theboisclub.gen1recomp",
"name": "gen1recomp++",
"bundleIdentifier": "com.theboisclub.gen1recompplusplus",
"developerName": "bryanthaboi",
"iconURL": "https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/assets/logo/gen1recomp_cover.png",
"localizedDescription": "Gen1Recomp - A native Lua / LÖVE2D recreation of Gen 1 Poke",
"tintColor": "3b5ca8",
"category": "games",
"versions": [
{
"version": "0.1.87",
"date": "2026-08-14",
"size": 11310910,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.87/gen1recomp++-0.1.87-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @AverageConsumer\n- @bryanthaboi\n- @mleo2003\n- @ShaneMcGovernIE\n- MaxTomahawk"
},
{
"version": "0.1.86",
"date": "2026-08-14",
"size": 11306689,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.86/gen1recomp++-0.1.86-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1283 Intro broken and leading to softlock (R,B,Y currently unplayable without save file)\n\n## Contributors\n\n- @bryanthaboi"
},
{
"version": "0.1.85",
"date": "2026-08-14",
"size": 11306462,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.85/gen1recomp++-0.1.85-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
},
{
"version": "0.1.84",
"date": "2026-08-14",
"size": 11306462,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.84/gen1recomp++-0.1.84-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #919 Bills House game save moves strangely after reload\n- #982 Trading QOL aspect missing from OG.\n- #1003 Land Pokemon Encounters while Surfing in Cerulean Cave\n- #1012 Multiple issues with text speed, animations, and audio\n- #1022 Cannot interact with small trees\n- #1028 Trainers not resetting on route change.\n- #1033 Some issues with Link battles.\n- #1243 Can't sell TMs at Poké Marts\n\n## Contributors\n\n- @1Jamie\n- @AverageConsumer\n- @bryanthaboi\n- @TheRealSolidusSnake\n- Myles Resnick\n- ShaneMcGovernIE"
},
{
"version": "0.1.83",
"date": "2026-08-13",
"size": 11282300,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.83/gen1recomp++-0.1.83-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @AverageConsumer\n- @bryanthaboi"
},
{
"version": "0.1.82",
"date": "2026-08-13",
"size": 11281277,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.82/gen1recomp++-0.1.82-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #914 Immunity steps and no encounter tiles\n- #936 Fly and teleporting allow should block interactions\n- #944 When starting a new game, the character sprite is facing down instead of up\n- #949 Save game import, save game export, \"normal\" save game\n- #964 Menu closing when trying to view Pokemon before picking starter\n- #978 Following up of #847\n- #983 Pikachu incorrectly able to be released\n- #984 EXP calculation after trading a PKMN back isnt right.\n- #987 Celadon Game Corner Display Error (L22680)\n- #988 (Yellow) Missing Pikachu Interaction - Pewter City Pokemon Centre\n- #989 (Yellow) Missing Pikachu Interaction - Vermilion City Pokemon Fan Club\n- #990 (Yellow) Pikachu Missing Hat\n- #999 Pallet Town music while player follows Prof. Oak\n- #1007 Transition into battle state for the 'Catching Pikachu' scene missing\n- #1010 Rival's position resets\n- #1011 Rival dialogue issue\n- #1029 1px black line sits under the title logo at the SGB zone boundary\n- #1035 Alt Music not playing\n- #1041 Mimic text not swoing up\n- #1047 Using a TM or HM closes the menu afterwards.\n- #1048 Cannot select items from the sell menu.\n- #1056 Cursor not jumping when switching moves\n- #1065 Player walks through statue after final battle while walking into Hall Of Fame\n- #1066 Anomalies when opponent switches out Pokemon\n- #1069 missing {RAM:wNameBuffer}\n- #1073 _GotMonText not used\n- #1089 Scripted movement missing for player and rival\n- #1101 Recoil damage calculation bug\n- #1115 Hitting attack does no damage\n- #1119 Wrongs textlines and events\n- #1120 Health is black when viewing stats\n- #1129 Errors in bowing animations of the nurse\n- #1146 [Recomp] Map music glitch\n- #1149 issue that affects tranlations\n- #1175 Pikachu visible too late\n- #1189 Town Map Item\n- #1193 Status moves have (wrong) sound effects when battle animations are turned off\n- #1206 Gen1: Cinnebar island has unfishable tiles north and east\n- #1207 Poison Flicker Visual issue\n- #1214 Title Screen with OG Red is the wrong color\n- #1219 [GOLD] UNABLE TO USE SUN STONE\n- #1220 No screen shaking for hitting moves when battle animations are off\n- #1221 [Gold] Flaafy evolution to at Lvl 30 Ampharos skipped learn thunder punch\n- #1225 Jingles for learning moves missing\n\n## Contributors\n\n- @1Jamie\n- @bryanthaboi"
},
{
"version": "0.1.81",
"date": "2026-08-13",
"size": 9886854,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.81/gen1recomp++-0.1.81-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1091 Encounter Rate is tied to Game Speed\n- #1097 Phase through walls exiting a cave\n- #1100 Allow for control edit and vibration toggle on gold\n- #1104 wrong warp\n- #1135 update for iphone - haptic vibrations\n- #1155 Magnitude always rolls a 4\n- #1157 Sonic Boom doesn't deal set damage\n- #1158 Unable to view opponent's next Pokemon\n- #1159 Dig animation is bugged\n- #1160 64x64 battle sprite followup for mods\n- #1167 [Gold] items in ilex forest missing\n- #1208 Gold Save game not showing properly in LAUNCHER after having multiple saves\n\n## Contributors\n\n- @bryanthaboi\n- @castdrian\n- @MaxTomahawk\n- @Yukitty\n- Codex Agent"
},
{
"version": "0.1.80",
"date": "2026-08-12",
"size": 10964568,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.80/gen1recomp-0.1.80-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1100 Allow for control edit and vibration toggle on gold\n- #1107 [Gold] When playing without a save slot in the launcher, no save will be created at all when saving ingame\n- #1112 [Gold] In the shop when buying items and pressing \"down\", it should not default to x99\n- #1121 [Gold] NPC trading (probably player too?) animation broken / missing things\n- #1126 [Gold] Jumping off ledge animation missing\n- #1130 A separate program for Gen2 (Gen2 Recomp)\n- #1145 Gen1recomp internal update - Mod didn't filter for Gen1 and Gen2 when click on individual tab\n- #1150 Control in Gold freeze when change\n- #1151 [Gold] Following up of #1127\n- #1152 [Gold] You cannot try to run away from wild battle after one of your Pokemon gets fainted.\n- #1161 window/borderless & resolution selection for gen2\n- #1162 [gold] learning TM/HM doesn't show if possible or not\n- #1164 Goldenrod Tower Softlock\n- #1165 [Gold] NPC trading behavior and ledge jumping missing still there despite fix\n- #1166 [Gold] nickname screen grey\n- #1167 [Gold] items in ilex forest missing\n- #1168 [Gold] multi-hit attacks only hits twice\n- #1169 [Gold] the sales lady in goldenrod underground\n- #1172 [Gold] {last item u found/got} used ROCK SMASH\n- #1173 [Gold] ROCK SMASH rocks keep slide left\n- #1177 [Gold] Touch controls position\n- #1178 No Vibration Setting for Gold\n- #1180 [Gold] something feels off with the DV calculation\n- #1184 [Gold] Kurt is in the wrong spot, blocking you from progressing\n- #1185 [Gold] Text messages is in the wrong order when a Pokemon learns a new skill after a trainer fight\n- #1188 GoldenRod tower softlock\n- #1190 POKEMON GOLD GEN1RECOMP++\n- #1192 cant import gold on RGXX build\n\n## Contributors\n\n- @anxiousintrovert\n- @AverageConsumer\n- @bryanthaboi\n- @MaxTomahawk\n- @ShaneMcGovernIE\n- @thibautbus"
},
{
"version": "0.1.79",
"date": "2026-08-12",
+2 -2
View File
@@ -5,7 +5,7 @@
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>gen1recomp</string>
<string>gen1recomp++</string>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
@@ -32,7 +32,7 @@
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>gen1recomp</string>
<string>gen1recomp++</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
+52 -2
View File
@@ -29,6 +29,9 @@ WRAP_SYSTEM = LOVE_SRC / "src" / "modules" / "system" / "wrap_System.cpp"
PBXPROJ = LOVE_SRC / "platform" / "xcode" / "love.xcodeproj" / "project.pbxproj"
APPLE_MM = LOVE_SRC / "src" / "common" / "apple.mm"
FILESYSTEM_CPP = LOVE_SRC / "src" / "modules" / "filesystem" / "physfs" / "Filesystem.cpp"
IOS_MM = LOVE_SRC / "src" / "common" / "ios.mm"
IOS_H = LOVE_SRC / "src" / "common" / "ios.h"
SYSTEM_CPP = LOVE_SRC / "src" / "modules" / "system" / "System.cpp"
ENTITLEMENTS_SRC = IOS_DIR / "overlays" / "love-ios.entitlements"
NATIVE_FILES = ("GRPickerBridge.swift", "GRHealthBridge.swift", "GRBootstrap.m")
@@ -120,7 +123,7 @@ int w_pickFileKinds(lua_State *L)
typedef const char *(*GRUTF8)(id, SEL);
const char *bytes = ((GRUTF8)objc_msgSend)(kinds,
sel_registerName("UTF8String"));
if (bytes == nullptr || bytes[0] == '\0')
if (bytes == nullptr || bytes[0] == '\\0')
{
lua_pushnil(L);
return 1;
@@ -358,6 +361,50 @@ def patch_public_documents():
print("patch_love_src: iOS save directory routed to Documents root")
def patch_ios_haptics():
text = pristine(IOS_MM, ("UIImpactFeedbackGenerator",))
original = """void vibrate()
{
@autoreleasepool
{
AudioServicesPlaySystemSound(kSystemSoundID_Vibrate);
}
}
"""
replacement = """void vibrate(double seconds)
{
@autoreleasepool
{
UIImpactFeedbackStyle style = UIImpactFeedbackStyleLight;
if (seconds >= 0.035)
style = UIImpactFeedbackStyleHeavy;
else if (seconds >= 0.02)
style = UIImpactFeedbackStyleMedium;
UIImpactFeedbackGenerator *generator = [[UIImpactFeedbackGenerator alloc]
initWithStyle:style];
[generator prepare];
[generator impactOccurred];
}
}
"""
if original not in text:
fail(f"iOS haptic anchor not found in {IOS_MM}")
IOS_MM.write_text(text.replace(original, replacement, 1))
header = pristine(IOS_H, ("void vibrate(double seconds);",))
header_original = "void vibrate();"
if header_original not in header:
fail(f"iOS haptic declaration not found in {IOS_H}")
IOS_H.write_text(header.replace(header_original, "void vibrate(double seconds);", 1))
system = pristine(SYSTEM_CPP, ("love::ios::vibrate(seconds)",))
system_original = "love::ios::vibrate();"
if system_original not in system:
fail(f"iOS haptic call site not found in {SYSTEM_CPP}")
SYSTEM_CPP.write_text(system.replace(system_original, "love::ios::vibrate(seconds);", 1))
print("patch_love_src: iOS haptics use Taptic Engine impact presets")
def patch_pbxproj():
text = pristine(PBXPROJ)
@@ -408,7 +455,9 @@ def patch_pbxproj():
fail(f"build configuration {config_id} not found")
settings = (
"\t\t\t\tSWIFT_VERSION = 5.0;\n"
"\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 14.0;\n"
"\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 15.0;\n"
"\t\t\t\tPRODUCT_NAME = \"gen1recomp++\";\n"
"\t\t\t\tEXECUTABLE_NAME = \"gen1recomp++\";\n"
'\t\t\t\tCODE_SIGN_ENTITLEMENTS = "ios/native/love-ios.entitlements";\n'
)
text = text[: m.end()] + settings + text[m.end():]
@@ -422,6 +471,7 @@ def main():
fail("love-src/ missing; run scripts/build_ios.sh --fetch first")
copy_native_files()
patch_public_documents()
patch_ios_haptics()
patch_wrap_system()
patch_pbxproj()
+1
View File
@@ -0,0 +1 @@
/Users/bryanbassett/Documents/development/pokemon-gen1-recomp-project/.bazinga/mods/timekeepers_hut
+4
View File
@@ -23,6 +23,10 @@ Ship `gen1tls.dll` (or `libgen1tls.so` / `libgen1tls.dylib`) **next to** the
fused executable. Mods load it through LuaJIT FFI; no .NET runtime is
required on the player's machine.
Official Windows release zips get `gen1tls.dll` from CI: a `windows-2022`
job publishes this project and `scripts/build.sh`'s Windows packaging copies
it beside `gen1recomp.exe` (see `GEN1TLS_DLL` / `dist/native/win-x64`).
## Android
On Android, the matching API is exposed as `love.system.tlsOpen` /
+19 -1
View File
@@ -13,7 +13,7 @@
# dist/linux/gen1recomp-linux.zip (fused x86_64 AppImage)
# dist/android/debug/*.apk (full gradle output stays under
# mobile/android/app/build/outputs/apk/embedNoRecord/)
# dist/ios/<Config>-<sdk>/gen1recomp.app (full xcodebuild output stays
# dist/ios/<Config>-<sdk>/gen1recomp++.app (full xcodebuild output stays
# under mobile/ios/build/Build/Products/)
set -euo pipefail
@@ -261,6 +261,21 @@ build_win() {
cp "$love_dir"/*.dll "$out_dir"/
cp "$love_dir"/license.txt "$out_dir"/ 2>/dev/null || true
# Native AOT TLS dialer for outbound wss:// (e.g. Archipelago hosted rooms).
# Release CI builds this on windows-2022 (Native AOT can't cross-compile
# win-x64 from the Mac runner) and either exports GEN1TLS_DLL or drops the
# file at dist/native/win-x64/gen1tls.dll before calling build.sh.
local tls_dll="${GEN1TLS_DLL:-}"
if [ -z "$tls_dll" ] && [ -f "$DIST/native/win-x64/gen1tls.dll" ]; then
tls_dll="$DIST/native/win-x64/gen1tls.dll"
fi
if [ -n "$tls_dll" ] && [ -f "$tls_dll" ]; then
cp "$tls_dll" "$out_dir/gen1tls.dll"
say "bundled gen1tls.dll for Windows TLS (wss://)"
else
warn "gen1tls.dll not found — Windows zip will not support wss:// (set GEN1TLS_DLL or build native/tls_dial)"
fi
# The exe's icon lives in love.exe's PE resources, so it must be patched
# BEFORE the .love is appended: peresed rewrites the whole file and would
# drop the fused bytes. peresed (pipx install pe_tools) has no .ico input,
@@ -400,6 +415,9 @@ EOF
grep -q '^FUSE_PATH="\$APPDIR/game.love"$' "$appdir/AppRun" \
|| fail "failed to enable FUSE_PATH in AppRun (upstream AppRun changed?)"
sed -i '' 's|^exec "\$APPDIR/bin/love"|if [ -n "$WAYLAND_DISPLAY" ] \&\& [ -z "$SDL_VIDEODRIVER" ]; then export SDL_VIDEODRIVER=x11; fi\
exec "$APPDIR/bin/love"|' "$appdir/AppRun"
# Match the upstream image's compression (gzip, 128K blocks) so the
# bundled runtime can read it.
local sfs_out="$WORK/game.squashfs"
+43 -25
View File
@@ -20,9 +20,9 @@
# - macOS + Xcode (xcodebuild)
# - mobile/ios/love-src/ (see --fetch / mobile/ios/README.md)
#
# Output: dist/ios/<Config>-<sdk>/gen1recomp.app (convenience copy)
# dist/ios/gen1recomp.ipa (device builds only)
# mobile/ios/build/Build/Products/<Config>-<sdk>/gen1recomp.app
# Output: dist/ios/<Config>-<sdk>/gen1recomp++.app (convenience copy)
# dist/ios/gen1recomp++.ipa (device builds only)
# mobile/ios/build/Build/Products/<Config>-<sdk>/gen1recomp++.app
set -euo pipefail
@@ -39,8 +39,9 @@ RESOURCES_DIR="$XCODE_DIR/ios/resources"
LOVE_FILE="$RESOURCES_DIR/game.love"
LIBS_DIR="$XCODE_DIR/ios/libraries"
APP_NAME="gen1recomp"
DISPLAY_NAME="gen1recomp"
APP_NAME="gen1recomp++"
DISPLAY_NAME="gen1recomp++"
PRODUCT_NAME="gen1recomp++"
# Bundle ID resolution, most specific wins:
# 1. GEN1_BUNDLE_ID env var
# 2. mobile/ios/bundle_id.local (one line, gitignored — pins YOUR install
@@ -50,7 +51,7 @@ DISPLAY_NAME="gen1recomp"
# capabilities like HealthKit are involved), so a per-team default
# lets anyone build without colliding with someone else's app
# 4. simulator: the project default (no App ID registration involved)
BUNDLE_ID="${GEN1_BUNDLE_ID:-com.theboisclub.gen1recomp}"
BUNDLE_ID="${GEN1_BUNDLE_ID:-}"
if [ -z "$BUNDLE_ID" ] && [ -f "$IOS_DIR/bundle_id.local" ]; then
BUNDLE_ID="$(tr -d '[:space:]' < "$IOS_DIR/bundle_id.local")"
fi
@@ -138,11 +139,7 @@ if $DEVICE && [ -z "${DEVELOPMENT_TEAM:-}" ]; then
fi
fi
if [ -z "$BUNDLE_ID" ]; then
if $DEVICE; then
BUNDLE_ID="com.gen1recomp.t$(printf '%s' "$DEVELOPMENT_TEAM" | tr '[:upper:]' '[:lower:]')"
else
BUNDLE_ID="com.theboisclub.pokemonred"
fi
BUNDLE_ID="com.theboisclub.gen1recompplusplus"
fi
# --------------------------------------------------------------- host checks
@@ -226,13 +223,26 @@ verify_documents_overlay() {
}
apply_ios_icon() {
local source="$ROOT/assets/logo/gen1recomp_cover.png"
local source="$ROOT/assets/logo/logo.png"
local target="$XCODE_DIR/Images.xcassets/iOS AppIcon.appiconset"
[ -f "$source" ] || fail "missing iOS icon source: $source"
[ -d "$target" ] || fail "missing iOS app icon set: $target"
local icon="$BUILD_DIR/gen1recomp-ios-icon.png"
mkdir -p "$BUILD_DIR"
if command -v magick >/dev/null 2>&1; then
magick -size 1024x1024 xc:black \
\( "$source" -resize 900x900 \) -gravity center -composite \
-alpha off "$icon" || fail "could not create iOS app icon: $source"
else
local scaled="$BUILD_DIR/gen1recomp-logo.png"
sips -Z 900 "$source" --out "$scaled" >/dev/null \
|| fail "could not resize iOS app icon source: $source"
sips -p 1024 1024 --padColor 000000 "$scaled" --out "$icon" >/dev/null \
|| fail "could not center iOS app icon source: $source"
fi
local entry name size
while IFS=: read -r name size; do
sips -z "$size" "$size" "$source" --out "$target/$name" >/dev/null
sips -z "$size" "$size" "$icon" --out "$target/$name" >/dev/null
done <<'EOF'
icon-1024pt@1x.png:1024
icon-29pt@1x.png:29
@@ -647,6 +657,7 @@ run_xcodebuild() {
CURRENT_PROJECT_VERSION="$project_version"
INFOPLIST_KEY_UIFileSharingEnabled=YES
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace=YES
IPHONEOS_DEPLOYMENT_TARGET=15.0
ONLY_ACTIVE_ARCH=NO
DISABLE_MANUAL_TARGET_ORDER_BUILD_WARNING=YES
)
@@ -666,6 +677,9 @@ run_xcodebuild() {
if [ -n "${CODE_SIGN_IDENTITY:-}" ]; then
args+=(CODE_SIGN_IDENTITY="$CODE_SIGN_IDENTITY")
fi
if [ "${GEN1_DISABLE_HEALTHKIT:-0}" = "1" ]; then
args+=(CODE_SIGN_ENTITLEMENTS=)
fi
fi
if ! xcodebuild -showsdks 2>/dev/null | grep -q "$sdk"; then
@@ -708,18 +722,22 @@ run_xcodebuild() {
fi
local products="$BUILD_DIR/Build/Products/${config}-${sdk}"
local app="$products/$APP_NAME.app"
if [ ! -d "$app" ]; then
# PRODUCT_NAME override can still leave love.app on older projects
if [ -d "$products/love.app" ]; then
app="$products/$APP_NAME.app"
mv "$products/love.app" "$app"
warn "renamed love.app to $APP_NAME.app"
else
warn "xcodebuild finished but no .app under $products"
find "$BUILD_DIR/Build/Products" -name '*.app' 2>/dev/null | head -20 || true
return 0
local app=""
local candidate
for candidate in "$products/$PRODUCT_NAME.app" "$products/$APP_NAME.app" "$products/love.app"; do
if [ -d "$candidate" ]; then
app="$candidate"
break
fi
done
if [ -z "$app" ]; then
warn "xcodebuild finished but no .app under $products"
find "$BUILD_DIR/Build/Products" -name '*.app' 2>/dev/null | head -20 || true
return 0
fi
if [ "$app" != "$products/$APP_NAME.app" ]; then
mv "$app" "$products/$APP_NAME.app"
app="$products/$APP_NAME.app"
fi
verify_documents_configuration "$app"
@@ -760,7 +778,7 @@ run_xcodebuild() {
fi
}
# Pack Payload/<app>.app into dist/ios/gen1recomp.ipa for release / sideload tools.
# Pack Payload/<app>.app into dist/ios/gen1recomp++.ipa for release / sideload tools.
package_ipa() {
local app="$1"
local ipa="$DIST/$APP_NAME.ipa"
+18 -6
View File
@@ -93,7 +93,8 @@ build_autotools mpg123 "$MPG123_VERSION" "$MPG123_TARBALL" libmpg123.so.0 \
# The symbol that was missing when this was bullseye's copy. Assert it, so a
# version bump that quietly regresses below the host's expectations fails the
# build instead of silently killing audio again.
objdump -T "$PREFIX/lib/libmpg123.so.0" | grep -q 'mpg123_info2' \
mpg123_syms="$(objdump -T "$PREFIX/lib/libmpg123.so.0")"
grep -q 'mpg123_info2' <<<"$mpg123_syms" \
|| fail "bundled libmpg123 lacks mpg123_info2; the host's libsndfile will fail to relocate"
# ------------------------------------------------------------ compile SDL2
@@ -128,8 +129,9 @@ fi
# the host having that exact stack -- which is the bug this replaced.
sdl_lib="$PREFIX/lib/libSDL2-2.0.so.0"
[ -f "$sdl_lib" ] || fail "SDL2 build produced no libSDL2-2.0.so.0"
sdl_needed="$(objdump -p "$sdl_lib")"
for forbidden in libpulse libasound libX11 libwayland libdrm libgbm libsndio; do
if objdump -p "$sdl_lib" | grep -q "NEEDED.*$forbidden"; then
if grep -q "NEEDED.*$forbidden" <<<"$sdl_needed"; then
fail "SDL2 hard-links $forbidden; it must dlopen its backends (--enable-*-shared)"
fi
done
@@ -164,8 +166,9 @@ fi
openal_lib="$PREFIX/lib/libopenal.so.1"
[ -f "$openal_lib" ] || fail "openal-soft build produced no libopenal.so.1"
openal_needed="$(objdump -p "$openal_lib")"
for forbidden in libsndio libasound libpulse libjack; do
if objdump -p "$openal_lib" | grep -q "NEEDED.*$forbidden"; then
if grep -q "NEEDED.*$forbidden" <<<"$openal_needed"; then
fail "openal hard-links $forbidden; backends must stay behind dlopen"
fi
done
@@ -196,7 +199,8 @@ fi
theora_lib="$PREFIX/lib/libtheoradec.so.1"
[ -f "$theora_lib" ] || fail "libtheora build produced no libtheoradec.so.1"
if objdump -p "$theora_lib" | grep -q "NEEDED.*libcairo"; then
theora_needed="$(objdump -p "$theora_lib")"
if grep -q "NEEDED.*libcairo" <<<"$theora_needed"; then
fail "libtheoradec still links libcairo (--disable-examples stopped working)"
fi
@@ -231,16 +235,18 @@ love_bin="$PREFIX/bin/love"
love_lib="$PREFIX/lib/liblove-$LOVE_VERSION.so"
[ -x "$love_bin" ] || fail "LÖVE build produced no bin/love"
[ -f "$love_lib" ] || fail "LÖVE build produced no lib/liblove-$LOVE_VERSION.so"
file "$love_bin" | grep -q 'ARM aarch64' \
love_file="$(file "$love_bin")"
grep -q 'ARM aarch64' <<<"$love_file" \
|| fail "built love is not an aarch64 ELF (got: $(file -b "$love_bin"))"
# A configure run that lost an optional dependency still exits 0 and still
# builds -- the loss only shows up as a missing love module at runtime, i.e.
# in a shipped artifact. Assert the decoder/font/video libs really linked.
love_needed="$(objdump -p "$love_lib")"
for soname in libSDL2-2.0.so.0 libopenal.so.1 libfreetype.so.6 \
libmodplug.so.1 libmpg123.so.0 libvorbisfile.so.3 \
libtheoradec.so.1 libluajit-5.1.so.2; do
objdump -p "$love_lib" | grep -q "NEEDED.*$soname" \
grep -q "NEEDED.*$soname" <<<"$love_needed" \
|| fail "liblove is not linked against $soname (a -dev package went missing)"
done
@@ -405,6 +411,12 @@ if [ -z "\$LUA_CPATH" ]; then
fi
export LUA_CPATH="\$APPDIR/lib/lua/5.1/?.so;\$LUA_CPATH"
# SDL2 on Wayland crashes during desktop drag-and-drop in certain compositors;
# default to X11/XWayland when available to ensure rock-solid drag-drop stability.
if [ -n "\$WAYLAND_DISPLAY" ] && [ -z "\$SDL_VIDEODRIVER" ]; then
export SDL_VIDEODRIVER=x11
fi
exec "\$APPDIR/bin/love" --fused "\$APPDIR/game.love" "\$@"
EOF
chmod +x "$APPDIR/AppRun"
+6
View File
@@ -35,4 +35,10 @@ find_love() {
LOVE_BIN="$(find_love)" \
|| fail "LÖVE not found, run scripts/setup.sh (or install from https://love2d.org)"
# SDL2 on Wayland crashes during desktop drag-and-drop in certain compositors;
# default to X11/XWayland when available to ensure rock-solid drag-drop stability.
if [ -n "${WAYLAND_DISPLAY:-}" ] && [ -z "${SDL_VIDEODRIVER:-}" ]; then
export SDL_VIDEODRIVER=x11
fi
exec "$LOVE_BIN" "$ROOT" "$@"
+231 -96
View File
@@ -316,6 +316,27 @@ function BattleState.trainerPicPath(data, trainer, oppClass, partyIndex)
return base and base.pic or nil
end
-- trueColor on the trainer record, or on the basePic it reuses when the
-- subclass does not set the flag itself. Explicit false stays false.
function BattleState.trainerTrueColor(data, trainer)
if not trainer then return false end
if trainer.trueColor ~= nil then
return trainer.trueColor and true or false
end
local base = trainer.basePic and data and data.trainers
and data.trainers[trainer.basePic]
return (base and base.trueColor) and true or false
end
-- Load a trainer frontpic through getImage so a trueColor portrait skips
-- the 4-shade quantize the same way a species pic does.
function BattleState.trainerSprite(data, trainer, oppClass, partyIndex)
return getImage(
BattleState.trainerPicPath(data, trainer, oppClass, partyIndex),
BattleState.trainerPalette(data, trainer),
BattleState.trainerTrueColor(data, trainer))
end
-- The battle-BGP fade variant of a pic (AnimationFlashScreen and the
-- SetAnimationBGPalette effects remap the four BG shades; on the SGB
-- the colorizer then colors the REMAPPED shade, so a faded pic shows
@@ -501,6 +522,9 @@ local function makeBattler(data, mon, isPlayer, save)
badgeBoosts = badgeBoosts,
statuses = data.statuses,
shownHP = mon.hp, -- the HP the bar displays (UpdateHPBar drain)
-- the bar's own length in GetHPBarLength pixels; it trails shownHP
-- because UpdateHPBar_AnimateHPBar slides it one pixel at a time
shownPx = Timing.hpBarPixels(mon.hp, math.max(1, mon.stats.hp)),
-- HUD status label (DrawHUDsAndHPBars); mon.status can land mid-move
-- while the tilemap still shows the prior condition until the next
-- post-action HUD refresh (core.asm after Execute*Move)
@@ -563,10 +587,6 @@ local function markOwned(game, species)
local dex = game.save.pokedex
if dex then
dex.seen[species] = true
if not dex.owned[species] then
-- new dex page registered (SFX_DEX_PAGE_ADDED)
require("src.core.Sound").play(game.data, "Dex_Page_Added")
end
dex.owned[species] = true
end
end
@@ -613,10 +633,49 @@ local function newBattle(game)
self.phase = "intro"
self.menuIndex = 1
self.moveIndex = 1
self.playerMoveListIndex = 1
self.frame = 0
return self
end
local function scopedPlayerParty(game, indices)
if indices == nil then return nil, nil end
if type(indices) ~= "table" then
Logger.warn("trainer battle party scope is not a table; using full party")
return nil, nil
end
local count = #indices
local keyCount = 0
for key in pairs(indices) do
keyCount = keyCount + 1
if type(key) ~= "number" or key % 1 ~= 0 or key < 1 or key > count then
Logger.warn("trainer battle party scope is malformed; using full party")
return nil, nil
end
end
if count == 0 or keyCount ~= count then
Logger.warn("trainer battle party scope is empty or sparse; using full party")
return nil, nil
end
local party, normalized, seen = {}, {}, {}
for i = 1, count do
local index = indices[i]
if type(index) ~= "number" or index % 1 ~= 0
or not game.save.party[index] or seen[index] then
Logger.warn("trainer battle party scope contains an invalid index; using full party")
return nil, nil
end
seen[index] = true
normalized[i] = index
party[i] = game.save.party[index]
end
return party, normalized
end
function BattleState:playerPartyView()
return self.playerParty or self.game.save.party
end
-- opts.hooked: rod encounter, announced with _HookedMonAttackedText
function BattleState.newWild(game, species, level, opts)
local self = newBattle(game)
@@ -692,13 +751,15 @@ local function applySpecialMoves(data, oppClass, partyIndex, party)
end
end
function BattleState.newTrainer(game, oppClass, partyIndex)
function BattleState.newTrainer(game, oppClass, partyIndex, opts)
local self = newBattle(game)
self.kind = "trainer"
self.oppClass = oppClass
-- the object_event trainer arg (roster index). computeMusicKind keys
-- data/scripts/victories.lua on class#party, so keep it on the battle (#782).
self.partyIndex = partyIndex or 1
self.playerParty, self.playerPartyIndices = scopedPlayerParty(game,
type(opts) == "table" and opts.playerPartyIndices or nil)
self.trainer = game.data.trainers[oppClass]
assert(self.trainer, "unknown trainer class " .. tostring(oppClass))
-- pret GetTrainerName_: RIVAL1/2/3 copy wRivalName into wTrainerName
@@ -742,7 +803,7 @@ function BattleState.newTrainer(game, oppClass, partyIndex)
end
end
self.enemyIndex = 1
local playerMon = Party.firstHealthy(game.save.party)
local playerMon = Party.firstHealthy(self:playerPartyView())
if not playerMon then
Logger.warn("trainer battle with no healthy party; skipping")
self.dead = true
@@ -756,9 +817,8 @@ function BattleState.newTrainer(game, oppClass, partyIndex)
-- MonsterPalettes[0] = PAL_MEWMON -- InitBattleCommon zeroes
-- wEnemyMonSpecies2 before the intro's SET_PAL_BATTLE
-- (engine/battle/core.asm:6682, engine/gfx/palettes.asm SetPal_Battle)
self.trainerPic = getImage(
BattleState.trainerPicPath(game.data, self.trainer, oppClass, partyIndex),
BattleState.trainerPalette(game.data, self.trainer))
self.trainerPic = BattleState.trainerSprite(
game.data, self.trainer, oppClass, partyIndex)
self.introText = Strings("%s wants\nto fight!", self.trainer.name)
return self
end
@@ -929,6 +989,22 @@ function BattleState:sayNext(text)
table.insert(self.queue, self.nextInsert, { text = text })
end
function BattleState:sayNextWaitSfx(text, sfx)
self.nextInsert = (self.nextInsert or 0) + 1
table.insert(self.queue, self.nextInsert, { text = text, waitForLearningSfx = sfx })
end
-- WaitForSoundToFinish for a sound an act() has already started: PlayCry
-- ends in `jp WaitForSoundToFinish` (home/pokemon.asm), so every cry in
-- Gen 1 holds whatever the ROM does next. `src` is the audio source, or a
-- getter the row calls at execution time when the source is only known then.
function BattleState:waitSfxNext(src)
self.nextInsert = (self.nextInsert or 0) + 1
table.insert(self.queue, self.nextInsert,
{ waitSound = type(src) == "function" and src
or function() return src end })
end
-- sayNext for a page that ends in `text_end` (see sayAuto) (#765)
function BattleState:sayNextAuto(text, delay)
self.nextInsert = (self.nextInsert or 0) + 1
@@ -1016,22 +1092,39 @@ function BattleState:stepHPDrain()
and b.shownHP >= b.drainFloor then
goal = b.drainFloor
end
local maxHP = math.max(1, b.mon.stats.hp)
local playerSide = (b == self.player)
local targetPx = Timing.hpBarPixels(b.shownHP, maxHP)
if not b.shownPx then b.shownPx = targetPx end
if (b.drainHold or 0) > 0 then
b.drainHold = b.drainHold - 1
busy = true
elseif b.shownPx ~= targetPx then
-- .barAnimationLoop redraws the bar one pixel at a time, `ld c, 2 /
-- call DelayFrames` apiece (:141-148), so a single HP point that
-- spans several pixels still slides instead of jumping
b.shownPx = b.shownPx + ((b.shownPx > targetPx) and -1 or 1)
b.drainHold = Timing.HP_BAR_PIXEL_STEP - 1
busy = true
elseif b.shownHP ~= goal then
local maxHP = math.max(1, b.mon.stats.hp)
local playerSide = (b == self.player)
local cost = 0
local spent = 0
-- consume whole HP steps until this frame's budget is spent; on the
-- enemy HUD several free steps can land in the same frame
while b.shownHP ~= goal and cost < 1 do
local nextHP = b.shownHP + ((b.shownHP > goal) and -1 or 1)
cost = cost + Timing.hpDrainStepFrames(b.shownHP, nextHP,
maxHP, playerSide)
b.shownHP = nextHP
repeat
b.shownHP = b.shownHP + ((b.shownHP > goal) and -1 or 1)
spent = spent + (playerSide and Timing.HP_BAR_HP_STEP or 0)
targetPx = Timing.hpBarPixels(b.shownHP, maxHP)
until b.shownHP == goal or targetPx ~= b.shownPx or spent >= 1
if spent > 0 then
b.drainHold = spent - 1
elseif targetPx ~= b.shownPx then
-- the enemy HUD printed no number, so this frame is already the
-- first of the pixel step the crossing just asked for
b.shownPx = b.shownPx + ((b.shownPx > targetPx) and -1 or 1)
b.drainHold = Timing.HP_BAR_PIXEL_STEP - 1
else
b.drainHold = 0
end
b.drainHold = math.max(0, cost - 1)
b.draining = true
busy = true
elseif b.draining then
@@ -1255,13 +1348,13 @@ function BattleState:updateQueue()
-- no subanimation player: keep the single-sound fallback (with
-- the move's pitch/tempo modifiers; GROWL/ROAR play the
-- attacker's cry -- GetMoveSound/IsCryMove)
if item.anim == "GROWL" or item.anim == "ROAR" then
if self:animationsOn() and (item.anim == "GROWL" or item.anim == "ROAR") then
local attacker = item.attackerIsPlayer and self.player or self.enemy
if attacker then
require("src.core.Sound").playMoveCry(self.data, attacker.mon.species,
anim and anim.tempo)
end
elseif anim and anim.sound then
elseif self:animationsOn() and anim and anim.sound then
local Sound = require("src.core.Sound")
if Sound.playMove then
Sound.playMove(self.data, anim)
@@ -1361,6 +1454,11 @@ function BattleState:updateQueue()
self.current = nil
end
elseif not (item and item.choice) then
if item and item.waitForLearningSfx and not item.soundStarted then
item.soundStarted = true
self.waitingSound = item.waitForLearningSfx()
return true
end
-- The page is typed out and waiting on the player: PromptText
-- (home/text.asm:209-217) writes '▼' at (18,16) and ManualTextScroll
-- blinks it until A/B, so the arrow belongs on a finished page and not
@@ -1420,7 +1518,7 @@ end
function BattleState:playEntranceCry(battler)
local mon = battler and battler.mon
if not mon then return end
require("src.core.Sound").playCry(self.data, mon.species,
return require("src.core.Sound").playCry(self.data, mon.species,
mon.status == "SLP" and 37 or 11)
end
@@ -1544,7 +1642,23 @@ function BattleState:enter()
-- default stays opaque for every other battle and for older saves.
self.isOpaque = self:bgMode() ~= "world"
self.introSlide = Timing.BATTLE_SLIDE_IN_FRAMES
self.showEnemyTrainer = self.kind == "trainer" and self.trainerPic ~= nil
-- GetTrainerInformation .linkBattle (home/trainers2.asm:26-31): the link
-- foe's pic is RedPicFront whatever either save looks like, and
-- InitBattleCommon loads and tilemaps it at hlcoord 12,0 for every
-- wIsInBattle == 2 battle (core.asm:6681-6688), the link one included.
-- wEnemyMonSpecies2 is zeroed under it, so the pic palette is PAL_MEWMON
-- exactly as it is for a trainer.
if self.kind == "link" and not self.trainerPic then
local frontPath, frontTrueColor =
require("src.pokemon.Sprites").playerPath(self.data, "front",
{ kind = "battle", battle = self })
if frontPath and require("src.render.Assets").exists(frontPath) then
self.trainerPic = getImage(frontPath, namedPalette(self.data, "MEWMON"),
frontTrueColor)
end
end
self.showEnemyTrainer = (self.kind == "trainer" or self.kind == "link")
and self.trainerPic ~= nil
-- DrawAllPokeballs (common_text.asm:27) puts the party ball rows AND the
-- HUD corner/underline tiles under them (PlacePlayerHUDTiles /
-- PlaceEnemyHUDTiles, draw_hud_pokeball_gfx.asm:119-165) on screen with
@@ -1576,13 +1690,13 @@ function BattleState:enter()
-- a different point in each battle kind, so queue it per branch
local function queueEnemyCry()
self:act(function()
self:playEntranceCry(self.enemy)
self:waitSfxNext(self:playEntranceCry(self.enemy))
end)
end
-- PrintBeginningBattleText (engine/battle/common_text.asm:10-19): a wild
-- battle calls PlayCry BEFORE PrintText WildMonAppearedText, so the cry
-- sounds with the "Wild X appeared!" box instead of waiting on the A
-- press that clears its `prompt` (#303). The Silph-Scope-less tower
-- battle calls PlayCry BEFORE PrintText WildMonAppearedText, and PlayCry
-- ends in WaitForSoundToFinish, so the cry runs to its end and only then
-- does the "Wild X appeared!" box open (#303). The Silph-Scope-less tower
-- ghost gets no cry at all (common_text.asm:43-48), and neither does the
-- unveiled MAROWAK: .isMarowak never reaches PlayCry (#492).
if self.kind ~= "trainer" and self.kind ~= "link"
@@ -1600,7 +1714,10 @@ function BattleState:enter()
-- The sfx is extracted as "Trainer_Appeared" (tools/rom_manifest.json
-- sfxHeaders, bank 8 / $42bb -- the same header pokered names
-- SFX_Silph_Scope); nothing had ever played it.
if self.kind == "trainer" then
--
-- A link battle is wIsInBattle == 2, so PrintBeginningBattleText takes the
-- same .trainerBattle arm and owes the sfx too (common_text.asm:20-23).
if self.kind == "trainer" or self.kind == "link" then
self:act(function()
self.introSfx = require("src.core.Sound").play(self.data,
"Trainer_Appeared")
@@ -1618,13 +1735,17 @@ function BattleState:enter()
-- battle -- not on a switch, and not when the beaten trainer's pic
-- scrolls back in (#317, #282)
self:act(function() self.introBalls = nil end)
if self.kind == "trainer" then
-- EnemySendOutFirstMon (core.asm:1308-1310): SlideTrainerPicOffScreen
-- walks the foe's pic off the RIGHT edge (hlcoord 18,0, a = 8 tiles,
-- one tile every 2 frames) BEFORE TrainerSentOutText -- the pic does
-- not blink out under the text (#317)
self:act(function() self:slidePic("foe", 0, 64, 4) end)
table.insert(self.queue, { wait = 16 })
if self.kind == "trainer" or self.kind == "link" then
local foeName = self.trainer and self.trainer.name
or self.opponentName or Strings("FOE")
if self.showEnemyTrainer then
-- EnemySendOutFirstMon (core.asm:1308-1310): SlideTrainerPicOffScreen
-- walks the foe's pic off the RIGHT edge (hlcoord 18,0, a = 8 tiles,
-- one tile every 2 frames) BEFORE TrainerSentOutText -- the pic does
-- not blink out under the text (#317)
self:act(function() self:slidePic("foe", 0, 64, 4) end)
table.insert(self.queue, { wait = 16 })
end
self:act(function()
self.showEnemyTrainer = false
-- the slot is EMPTY from here until AnimateSendingOutMon runs below:
@@ -1637,7 +1758,7 @@ function BattleState:enter()
self.enemySendingOut = true
self:slidePic("foe")
end)
self:say(Strings("%s sent\nout %s!", self.trainer.name, self.enemy.name))
self:say(Strings("%s sent\nout %s!", foeName, self.enemy.name))
self:act(function()
-- EnemySendOutFirstMon (core.asm:1421-1434): after the text the
-- pic grows out of the ball (AnimateSendingOutMon), then the cry
@@ -1645,18 +1766,6 @@ function BattleState:enter()
self:startGrowIn(self.enemy)
end)
queueEnemyCry()
elseif self.kind == "link" then
-- Colosseum has no foe trainer pic, but the enemy mon still grows
-- out of the ball after "X sent out Y!" (not the wild "already there"
-- intro that LinkBattle previously inherited from newWild).
self.enemySendingOut = true
self:say(Strings("%s sent\nout %s!", self.opponentName or Strings("FOE"),
self.enemy.name))
self:act(function()
self.enemySendingOut = false
self:startGrowIn(self.enemy)
end)
queueEnemyCry()
end
-- StartBattle .foundFirstAliveEnemyMon (core.asm:152-156): the `call nz`
-- gates only EnemySendOutFirstMon -- the `ld c, 40 / call DelayFrames`
@@ -1687,7 +1796,7 @@ function BattleState:enter()
-- SendOutMon (core.asm:1757-1762): after the poof the mon grows
-- out of the ball (AnimateSendingOutMon at hlcoord 4,11)
self:startGrowIn(self.player)
self:playEntranceCry(self.player)
self:waitSfxNext(self:playEntranceCry(self.player))
end)
self:markParticipant()
end
@@ -1746,6 +1855,7 @@ end
local function sendOutMonCursors(self)
self.menuIndex = 1
self.moveIndex = 1
self.playerMoveListIndex = 1
end
-- core.asm:297-300: both sides' FLINCHED bits are cleared as a turn's move
@@ -1864,7 +1974,10 @@ function BattleState:update(dt)
if self.phase == "menu" then
for _, b in ipairs({ self.player, self.enemy }) do
if b then
if b.shownHP then b.shownHP = b.mon.hp end
if b.shownHP then
b.shownHP = b.mon.hp
b.shownPx = Timing.hpBarPixels(b.mon.hp, math.max(1, b.mon.stats.hp))
end
b.drainFloor = nil
b.shownStatus = b.mon.status
end
@@ -1949,7 +2062,7 @@ function BattleState:update(dt)
-- loops the party menu until a healthy mon is picked, so B and
-- fainted picks land back here and reopen it
if self.player.mon.hp <= 0 then
if Party.firstHealthy(self.game.save.party) then
if Party.firstHealthy(self:playerPartyView()) then
self:openReplacementMenu()
end
return
@@ -2044,8 +2157,10 @@ function BattleState:update(dt)
if self.moveSwapIndex then
self:swapMoves(self.moveSwapIndex, self.moveIndex)
self.moveSwapIndex = nil
self.moveIndex = math.min(self.playerMoveListIndex or 1, #moves)
else
self.moveSwapIndex = self.moveIndex
self.moveIndex = math.min(self.playerMoveListIndex or 1, #moves)
end
elseif input:wasPressed("b") then
require("src.core.Sound").play(self.data, "Press_AB")
@@ -2068,6 +2183,7 @@ function BattleState:update(dt)
self.phase = "messages"
self.afterQueue = "menu"
else
self.playerMoveListIndex = self.moveIndex
self:resolveTurn(mv)
end
end
@@ -2279,10 +2395,10 @@ function BattleState:oldManThrow()
return
end
self:ballChain("TOSS_ANIM", true, 3, "POKE_BALL")
self:actNext(function()
require("src.core.Sound").play(self.data, "Caught_Mon")
end)
self:sayNext(Strings("All right!\n%s was\ncaught!", self.enemy.name))
-- ItemUseBallText05: text_far, sound_caught_mon, text_promptbutton --
-- the fanfare follows the caught text and holds the prompt
self:sayNextWaitSfx(Strings("All right!\n%s was\ncaught!", self.enemy.name),
function() return require("src.core.Sound").play(self.data, "Caught_Mon") end)
end)
end
@@ -2423,11 +2539,9 @@ function BattleState:resolveTurn(playerAction)
end
local order
if pFirst then
order = { { self.player, self.enemy, playerAction },
{ self.enemy, self.player, enemyAction } }
order = { { true, playerAction }, { false, enemyAction } }
else
order = { { self.enemy, self.player, enemyAction },
{ self.player, self.enemy, playerAction } }
order = { { false, enemyAction }, { true, playerAction } }
end
self.phase = "messages"
@@ -2435,7 +2549,9 @@ function BattleState:resolveTurn(playerAction)
for _, entry in ipairs(order) do
self:act(function()
self:executeAction(entry[1], entry[2], entry[3])
local user = entry[1] and self.player or self.enemy
local target = entry[1] and self.enemy or self.player
self:executeAction(user, target, entry[2])
end)
end
self:act(function() self:endOfTurn() end)
@@ -2466,7 +2582,7 @@ function BattleState:resolveSwitch(newMon)
self.sendingOut = false
-- SendOutMon (core.asm:1757-1762): poof, then the grow-in
self:startGrowIn(self.player)
self:playEntranceCry(self.player)
self:waitSfxNext(self:playEntranceCry(self.player))
end)
end)
self:act(function()
@@ -2945,7 +3061,7 @@ function BattleState:applyHitFx(hit)
Sound.play(self.data, hit.sfx)
end
end
if not t or not self:animationsOn() then return end
if not t then return end
if t == 1 then
-- PredefShakeScreenVertically b=8: the window drops by b for 3 frames
-- then home for 3, b counting down
@@ -3778,7 +3894,7 @@ function BattleState:onFaint(battler)
if battler.isPlayer then
-- RemoveFaintedPlayerMon (core.asm:1040-1042): the player mon's
-- faint plays its ordinary species cry -- no Faint_Fall
Sound.playCry(self.data, battler.mon.species)
self.faintCry = Sound.playCry(self.data, battler.mon.species)
elseif self.kind ~= "wild" then
-- FaintEnemyPokemon (core.asm:732-771): the enemy faint plays no
-- species cry; trainer battles get SFX_FAINT_FALL, then SFX_FAINT_THUD
@@ -3793,6 +3909,11 @@ function BattleState:onFaint(battler)
end)
self.nextInsert = (self.nextInsert or 0) + 1
table.insert(self.queue, self.nextInsert, { wait = Timing.FAINT_SLIDE })
if battler.isPlayer then
-- RemoveFaintedPlayerMon ends `call PlayCry / jp PrintText`, and PlayCry
-- is `jp WaitForSoundToFinish`, so "X fainted!" waits out the cry
self:waitSfxNext(function() return self.faintCry end)
end
if not battler.isPlayer and self.kind ~= "wild" then
-- FaintEnemyPokemon's SFX_FAINT_THUD lands as the slide does (after
-- Faint_Fall, before EnemyMonFaintedText)
@@ -3829,7 +3950,8 @@ function BattleState:awardExp()
-- (RemoveFaintedPlayerMon), so it drops out of the divisor and only
-- the surviving participants are counted and paid
local participants, alive = 0, {}
for _, mon in ipairs(self.game.save.party) do
local playerParty = self:playerPartyView()
for _, mon in ipairs(playerParty) do
if self.participants and self.participants[mon] then
participants = participants + 1
if mon.hp > 0 then table.insert(alive, mon) end
@@ -3839,9 +3961,12 @@ function BattleState:awardExp()
participants, alive = 1, { self.player.mon }
end
local function applyShare(mon, split, announce)
local playerId = self.game.save.player and self.game.save.player.id
local traded = mon.otId ~= nil and playerId ~= nil
and mon.otId ~= playerId or mon.traded == true and mon.otId == nil
local levels, gained = Experience.apply(self.data, mon, self.enemy.def,
self.enemy.mon.level, self.kind == "trainer",
split, mon.traded)
split, traded)
-- Track level-ups for EvolveAfterBattle (OverworldState:afterBattle ->
-- Evolution.checkParty). B-cancel leaves the mon at/above threshold;
-- without this gate it re-triggers after every later fight (#213).
@@ -3865,7 +3990,7 @@ function BattleState:awardExp()
local text = Strings.source("%s gained\n%d EXP. Points!")
if announce == "expAll" then
text = Strings.source("%s gained\nwith EXP.ALL,\v%d EXP. Points!")
elseif mon.traded then
elseif traded then
text = Strings.source("%s gained\na boosted\v%d EXP. Points!")
end
self:sayNext(Strings(text, name, gained))
@@ -3877,9 +4002,11 @@ function BattleState:awardExp()
-- experience.asm:248 fires per grew-level text
require("src.world.PikachuFollower")
.modifyHappiness(game.save, "LEVELUP", mon)
self:sayNext(Strings("%s grew\nto level %d!", name, lv))
-- GrewLevelText: text_far, sound_level_up, text_end (experience.asm:
-- 369-372); PrintStatsBox only runs once PrintText has returned
self:sayNextWaitSfx(Strings("%s grew\nto level %d!", name, lv),
function() return require("src.core.Sound").play(game.data, "Level_Up") end)
self:uiNext(function()
require("src.core.Sound").play(game.data, "Level_Up")
return StatBox.new(game, mon)
end)
-- After PrintStatsBox, experience.asm reloads the active battler's
@@ -3918,9 +4045,9 @@ function BattleState:awardExp()
-- experience.asm:9-13); each mon gets its own GainedText with the
-- "with EXP.ALL," tail (wBoostExpByExpAll) -- pokered prints no
-- summary line
for _, mon in ipairs(self.game.save.party) do
for _, mon in ipairs(playerParty) do
if mon.hp > 0 then
ctx.applyShare(mon, math.max(1, ctx.participants) * #self.game.save.party * 2, "expAll")
ctx.applyShare(mon, math.max(1, ctx.participants) * #playerParty * 2, "expAll")
end
end
end
@@ -3961,7 +4088,7 @@ function BattleState:enemyMonFainted()
local nextName = nextMon.nickname or self.data.pokemon[nextMon.species].name
local style = tostring((self.game.save.options or {}).battleStyle or "shift")
:lower()
local partyCount = #self.game.save.party
local partyCount = #self:playerPartyView()
-- ReplaceFaintedEnemyMon (core.asm:892-896): DrawEnemyPokeballs puts the
-- foe's party ball row -- and the HUD chrome PlaceEnemyHUDTiles lays
-- down under it (draw_hud_pokeball_gfx.asm:9-11, 33-45, 134-141) -- into
@@ -3988,6 +4115,7 @@ function BattleState:enemyMonFainted()
local game = self.game
Screens.push(game, "PartyMenu", {
battle = self,
party = self:playerPartyView(),
forceSwitch = true,
onSwitch = function(mon)
if mon ~= self.player.mon and mon.hp > 0 then
@@ -4024,7 +4152,7 @@ function BattleState:enemyMonFainted()
self.enemySendingOut = false
self:startGrowIn(self.enemy)
self:actNext(function()
self:playEntranceCry(self.enemy)
self:waitSfxNext(self:playEntranceCry(self.enemy))
end)
end)
end)
@@ -4063,7 +4191,7 @@ function BattleState:enemyMonFainted()
self:actNext(function()
self.sendingOut = false
self:startGrowIn(self.player)
self:playEntranceCry(self.player)
self:waitSfxNext(self:playEntranceCry(self.player))
end)
end)
return
@@ -4129,15 +4257,17 @@ function BattleState:learnMove(mon, moveId)
if #mon.moves < 4 then
table.insert(mon.moves, { id = moveId, pp = mdef.pp })
Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId })
self:sayNext(self:romText("_MimicLearnedMoveText", "%s learned\n%s!", mon.nickname or self.data.pokemon[mon.species].name,
mdef.name))
self:sayNextWaitSfx(self:romText("_MimicLearnedMoveText", "%s learned\n%s!", mon.nickname or self.data.pokemon[mon.species].name,
mdef.name), function()
return require("src.core.Sound").play(self.data, "Level_Up")
end)
return
end
-- the "trying to learn" preamble lives inside MoveLearnMenu:enter;
-- ordered insert so multi-level gains keep each level's checks
-- between its own stat box and the next "grew to level" text
self:uiNext(function()
return self:buildScreen("MoveLearnMenu", mon, moveId)
return self:buildScreen("MoveLearnMenu", mon, moveId, nil, "Level_Up")
end)
end
@@ -4158,7 +4288,7 @@ function BattleState.isOaksLabStarterRival(self)
end
function BattleState:playerMonFainted()
local nextMon = Party.firstHealthy(self.game.save.party)
local nextMon = Party.firstHealthy(self:playerPartyView())
-- Being out of useable POKéMON blacks you out even when the battle was
-- already decided in our favour. A double faint -- our last mon dying
-- to residual damage on the turn it lands the KO -- used to hit the
@@ -4231,6 +4361,7 @@ function BattleState:openReplacementMenu()
self:ui(function()
return self:buildScreen("PartyMenu", {
battle = self,
party = self:playerPartyView(),
-- ChooseNextMon: pick immediately (no SWITCH/STATS/CANCEL)
forceSwitch = true,
onSwitch = function(mon)
@@ -4257,7 +4388,7 @@ function BattleState:openReplacementMenu()
self.sendingOut = false
-- SendOutMon (core.asm:1757-1762): poof, then the grow-in
self:startGrowIn(self.player)
self:playEntranceCry(self.player)
self:waitSfxNext(self:playEntranceCry(self.player))
end)
end,
})
@@ -4301,11 +4432,10 @@ function BattleState:safariAction(choice)
-- above DoBallTossSpecialEffects's <= ULTRA_BALL check)
self:ballChain(self:tossAnimFor("SAFARI_BALL"), caught, shakes, "SAFARI_BALL")
if caught then
-- ItemUseBallText05's sound_caught_mon: fanfare with the text
self:actNext(function()
require("src.core.Sound").play(self.data, "Caught_Mon")
end)
self:sayNext(Strings("All right!\n%s was\ncaught!", self.enemy.name))
-- ItemUseBallText05: text_far, sound_caught_mon, text_promptbutton --
-- the fanfare follows the caught text and holds the prompt
self:sayNextWaitSfx(Strings("All right!\n%s was\ncaught!", self.enemy.name),
function() return require("src.core.Sound").play(self.data, "Caught_Mon") end)
-- same ItemUseBall .captured flow as a regular ball
self:act(function() self:storeCaughtMon() end)
else
@@ -4533,8 +4663,12 @@ function BattleState:storeCaughtMon()
markOwned(game, species)
stampOT(game.save, self.enemy.mon)
if isNew then
-- _ItemUseBallText06 + ShowPokedexData
self:sayNext(Strings("New POKéDEX data\nwill be added for\n%s!", self.enemy.name))
-- _ItemUseBallText06 + ShowPokedexData: text_far, sound_dex_page_added,
-- text_promptbutton (item_effects.asm:624-629), so the fanfare follows
-- the box rather than firing when the dex bit is set
self:sayNextWaitSfx(
Strings("New POKéDEX data\nwill be added for\n%s!", self.enemy.name),
function() return require("src.core.Sound").play(self.data, "Dex_Page_Added") end)
self:uiNext(function()
return self:buildScreen("DexEntryMenu", species)
end)
@@ -4684,12 +4818,10 @@ function BattleState:throwBall(ball)
self:ballChain(self:tossAnimFor(ball), caught, shakes, ball)
if caught then
-- ItemUseBallText05 carries sound_caught_mon (item_effects.asm:
-- 608-614): the fanfare sounds with the caught message, before
-- the prompt, not after the text is dismissed
self:actNext(function()
require("src.core.Sound").play(self.data, "Caught_Mon")
end)
self:sayNext(Strings("All right!\n%s was\ncaught!", self.enemy.name))
-- 608-614): text_far, sound_caught_mon, text_promptbutton -- the
-- fanfare follows the caught message and holds the prompt
self:sayNextWaitSfx(Strings("All right!\n%s was\ncaught!", self.enemy.name),
function() return require("src.core.Sound").play(self.data, "Caught_Mon") end)
self:act(function() self:storeCaughtMon() end)
else
self:sayNext(self:ballMissMessage(shakes))
@@ -4710,6 +4842,7 @@ function BattleState:openParty()
self:ui(function()
return self:buildScreen("PartyMenu", {
battle = self,
party = self:playerPartyView(),
onSwitch = function(mon)
if mon == self.player.mon then
self:say(Strings("%s is\nalready out!", self.player.name))
@@ -4756,8 +4889,8 @@ function BattleState:finish()
-- here it did not, so say so rather than silently papering over it.
-- The old-man / PROF.OAK demo also skips it: the party never fought
-- (Yellow's Pallet intro runs before the player owns a mon at all).
if self.result ~= "lose" and not self.demo
and not Party.firstHealthy(self.game.save.party) then
if self.kind ~= "link" and self.result ~= "lose" and not self.demo
and not Party.firstHealthy(self:playerPartyView()) then
Logger.warn("battle finished %s with no healthy party; forcing blackout",
tostring(self.result))
self.result = "lose"
@@ -5176,7 +5309,8 @@ function BattleState:sgbBattlePals()
local function bar(b)
if not b then return pals.GREENBAR end
local hp = b.shownHP or b.mon.hp
return pals[PaletteFX.barPalName(hp, b.mon.stats.hp)] or pals.GREENBAR
return pals[PaletteFX.barPalName(hp, b.mon.stats.hp, b.shownPx)]
or pals.GREENBAR
end
local function mon(b, placeholder)
if placeholder or not b then return pals.MEWMON or pals.GREENBAR end
@@ -5574,7 +5708,7 @@ function BattleState:drawHUDs(slide)
hudTile(0x73, 8, 16)
drawHPBar(barData, 2, 2,
{ hp = shownHP(self.enemy), stats = self.enemy.mon.stats },
nil, grayFill)
nil, grayFill, nil, self.enemy.shownPx)
hudTile(0x74, 8, 24)
for i = 2, 9 do hudTile(0x76, i * 8, 24) end
hudTile(0x78, 80, 24)
@@ -5637,7 +5771,7 @@ function BattleState:drawHUDs(slide)
for i = 10, 17 do hudTile(0x76, i * 8, 88) end
hudTile(0x6F, 72, 88)
love.graphics.setColor(1, 1, 1, 1)
self:drawBallRow(self.playerParty or self.game.save.party, 88, 80, 8)
self:drawBallRow(self:playerPartyView(), 88, 80, 8)
end
local hidePlayer = self.safari or self.demo
if showStatus and self.player and not hidePlayer and not self.showPlayerBack
@@ -5655,7 +5789,7 @@ function BattleState:drawHUDs(slide)
end
drawHPBar(barData, 10, 9,
{ hp = shownHP(self.player), stats = self.player.mon.stats },
1, grayFill) -- wHPBarType 1: the $6D cap
1, grayFill, nil, self.player.shownPx) -- wHPBarType 1: the $6D cap
Font.draw(("%3d/%3d"):format(shownHP(self.player), self.player.mon.stats.hp), 88, 80)
hudTile(0x73, 144, 80)
hudTile(0x77, 144, 88)
@@ -5791,6 +5925,7 @@ function BattleState:drawTextArea()
Font.draw(self.data.moves[m.id].name, 16, (7 + i) * 8)
end
Font.drawCode(0xED, 8, (7 + self.mimicIndex) * 8)
Font.draw(Strings("WHICH TECHNIQUE?"), 8, 112)
end
end
+1 -2
View File
@@ -310,8 +310,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
if record and record.afterDamage then
record.afterDamage(ctx, totalDealt)
elseif moveInst.struggle then
-- struggle recoils even when its effect id resolves to no record
local recoil = math.max(1, math.floor(dmg / 2))
local recoil = math.max(1, math.floor(totalDealt / 2))
battle:sayNext(romText(battle.data, "_HitWithRecoilText", "%s's\nhit with recoil!", displayName(user)))
battle:applyDamage(user, recoil)
end
+7 -8
View File
@@ -348,8 +348,9 @@ MoveEffects.primary = {
target.disabledTurns = battle.rng(1, 8)
local id = target.curMoves[slot].id
-- _MoveWasDisabledText: "X's / MOVE was / disabled!"
return { romText(battle.data, "_MoveWasDisabledText", "%s's\n%s was\ndisabled!", displayName(target),
battle.data.moves[id].name) }
return { romText(battle.data, "_MoveWasDisabledText", "%s's\n%s was\ndisabled!",
{ TARGET = displayName(target),
["RAM:wNameBuffer"] = battle.data.moves[id].name }) }
end,
SPLASH_EFFECT = function(battle)
@@ -441,11 +442,10 @@ local function hitsFrom(dist, ctx)
return dist[r + 1]
end
-- drain_hp.asm halves the RAW wDamage IN PLACE (minimum 1) and heals
-- that amount, so Counter would see the halved value
-- engine/battle/core.asm ApplyDamageToEnemyPokemon
local function drainHalf(label, text)
return function(ctx)
local heal = math.max(1, math.floor(ctx.rawDamage / 2))
local heal = math.max(1, math.floor(ctx.totalDealt / 2))
ctx.battle.lastDamage = heal
local mon = ctx.user.mon
mon.hp = math.min(mon.stats.hp, mon.hp + heal)
@@ -529,9 +529,8 @@ MoveEffects.full = {
RECOIL_EFFECT = {
afterDamage = function(ctx)
-- recoil.asm reads the RAW computed wDamage (not the HP actually
-- removed): overkill and substitute hits recoil at full strength
local recoil = math.max(1, math.floor(ctx.rawDamage
-- engine/battle/move_effects/recoil.asm
local recoil = math.max(1, math.floor(ctx.totalDealt
/ (ctx.moveInst.struggle and 2 or 4)))
ctx.say(romText(ctx.battle.data, "_HitWithRecoilText", "%s's\nhit with recoil!", displayName(ctx.user)))
ctx.battle:applyDamage(ctx.user, recoil)
+3 -1
View File
@@ -243,7 +243,9 @@ function Status.beforeMove(battler, rng, battle, selectedMoveId)
local shown = moves and moves[selectedMoveId] and moves[selectedMoveId].name
or tostring(selectedMoveId)
table.insert(msgs, romText(battle and battle.data, "_MoveIsDisabledText",
"%s's\n%s is\ndisabled!", name(battler), shown))
"%s's\n%s is\ndisabled!", {
USER = name(battler), ["RAM:wNameBuffer"] = shown,
}))
return false, msgs
end
end
+1 -1
View File
@@ -108,7 +108,7 @@ local function drawStatusPanel(battle, battler, x, y, player)
HudTiles.drawHPBar(battle.data, tx + 1, ty + 2, {
hp = shownHP(battler),
stats = battler.mon.stats,
}, nil, monoMode(), tw - 5)
}, nil, monoMode(), tw - 5, battler.shownPx)
if player then
Font.draw(("%3d/%3d"):format(shownHP(battler), battler.mon.stats.hp),
+41 -10
View File
@@ -37,8 +37,10 @@ local Prize = require("src.battle.gen2.Prize")
-- * Gen 1's `user` / `target` / `battler` are battler wrappers around a mon
-- ({ mon = , name = , isPlayer = }); Gen 2's engine works on the party mon
-- table directly, so that is what these payloads carry.
-- * Gen 1's `rng` is love.math.random (1..n); Gen 2's injected `random` is
-- the cart's BattleRandom convention (0..n-1). Both keys are present.
-- * Gen 1's `rng` is love.math.random (rng(n) → 1..n, rng(lo,hi) → lo..hi).
-- Gen 2's cart BattleRandom is random(n) → 0..n-1. Both live on the
-- battle: `random` / `roller()` are BattleRandom for damage, accuracy,
-- Magnitude, etc.; `rng` is the Gen 1 / love-style view of the same stream.
local Runtime = require("src.mods.Runtime")
-- The two battle lines that carry the cart's own `line` break: a marker-bearing
-- literal has to stay reachable from a translation mod (#186, #245), which is
@@ -88,6 +90,19 @@ local function rand(random, n)
return math.random(n) - 1
end
-- Gen 1 / love.math view of BattleRandom: rng(n) → 1..n, rng(lo,hi) → lo..hi.
local function loveStyleRng(random)
return function(lo, hi)
if hi == nil then
local n = lo or 1
if n < 1 then n = 1 end
return (rand(random, n) or 0) + 1
end
if hi < lo then lo, hi = hi, lo end
return lo + (rand(random, hi - lo + 1) or 0)
end
end
-- data/trainers/leaders.asm. The two lists are ONE array in the ROM: only
-- KantoGymLeaders carries the -1 terminator, and GymLeaders falls through into
-- it, so IsGymLeader matches all twenty-two classes while IsKantoGymLeader
@@ -212,12 +227,15 @@ Battle.KANTO_BADGE_ORDER = {
-- this wild battle is BATTLETYPE_ROAMING; the caller built `wild`
-- through Roamers.beginBattle and reads Battle.roaming back to
-- bank the beast's HP afterwards
-- random(n) 0..n-1, injected so a test is deterministic
-- random(n) 0..n-1, injected so a test is deterministic (BattleRandom)
-- rng(lo,hi) / rng(n) Gen 1 / love.math contract; defaults over `random`
function Battle.new(opts)
opts = opts or {}
local self = setmetatable({}, Battle)
self.data = opts.data or {}
self.random = opts.random or function(n) return rand(nil, n) end
-- Same stream as `random`, Gen 1 / love.math calling convention.
self.rng = opts.rng or loveStyleRng(self.random)
self.party = opts.party or {}
self.trainer = opts.trainer
self.save = opts.save
@@ -403,11 +421,8 @@ function Battle:endBattle(outcome)
Runtime.emit("battle.ended", { battle = self, result = outcome })
end
-- A never-nil 0..n-1 roller for the hook contexts. `random` is optional on the
-- constructor (a headless test injects one, the game leaves it to love.math),
-- and a mod reaching for ctx.rng must not have to know that. Note the
-- convention: this is the cart's BattleRandom byte (0..n-1), NOT Gen 1's
-- love.math.random (1..n).
-- Never-nil BattleRandom (0..n-1) for call sites that want the cart byte.
-- `battle.rng` is the Gen 1 / love.math view of the same stream.
function Battle:roller()
if not self.rollerFn then
self.rollerFn = function(n) return rand(self.random, n) end
@@ -1604,9 +1619,25 @@ function Battle:useMove(attacker, defender, moveId)
return
end
-- Damage that skips the formula entirely.
local fixed = Effects.fixedDamage(def.effect, attacker, defender, self.random)
-- Damage that skips the formula entirely. The move's own power goes with
-- it: EFFECT_STATIC_DAMAGE's arm of BattleCommand_ConstantDamage reads
-- BATTLE_VARS_MOVE_POWER as the damage (effect_commands.asm:3157-3161).
local fixed = Effects.fixedDamage(def.effect, attacker, defender, self.random,
def.power)
if fixed then
-- The constant-damage effect list carries `resettypematchup` instead of
-- `stab`, and that command misses the move outright when the matchup byte
-- is 0 (effect_commands.asm:1480-1493) -- an immune target is the one
-- thing that stops SONIC BOOM, NIGHT SHADE or SUPER FANG.
local defenderTypes = (self:speciesDef(defender) or {}).types
or defender.types
local matchups = self.data.type_chart and self.data.type_chart.matchups
if Damage.typeMultiplier(def.type, defenderTypes, matchups) == 0 then
self:markMissed()
self:emit({ kind = "message",
text = "It doesn't affect " .. self:monName(defender) .. "..." })
return
end
self:dealDamage(attacker, defender, fixed, { move = def, moveId = moveId })
return
end
+25 -7
View File
@@ -179,9 +179,9 @@ end
-- --------------------------------------------------------------- fixed damage
-- BattleCommand_LevelDamage / SuperFang / Psywave, all of which skip the
-- damage formula entirely.
function Effects.fixedDamage(effect, attacker, defender, random)
-- BattleCommand_ConstantDamage (engine/battle/effect_commands.asm:3131-3205),
-- one command for the whole SuperFang / Psywave / StaticDamage list.
function Effects.fixedDamage(effect, attacker, defender, random, power)
if effect == "EFFECT_LEVEL_DAMAGE" then
return math.max(1, attacker.level or 1)
end
@@ -189,9 +189,17 @@ function Effects.fixedDamage(effect, attacker, defender, random)
return math.max(1, math.floor((defender.hp or 1) / 2))
end
if effect == "EFFECT_PSYWAVE" then
-- 1..(level * 1.5), rerolled until it is in range; one roll is enough here.
local ceiling = math.max(1, math.floor((attacker.level or 1) * 3 / 2))
return math.max(1, (random and random(ceiling) or 0) + 1)
-- .psywave rerolls until the byte is nonzero AND below level * 1.5, so the
-- top of the range is that ceiling minus one (effect_commands.asm:3163).
local ceiling = math.max(2, math.floor((attacker.level or 1) * 3 / 2))
return math.max(1, (random and random(ceiling - 1) or 0) + 1)
end
-- SONIC BOOM and DRAGON RAGE share EFFECT_STATIC_DAMAGE, whose arm reads
-- BATTLE_VARS_MOVE_POWER straight into the damage word: their stored power
-- (20 and 40) IS the damage, never a formula input
-- (effect_commands.asm:3157-3161).
if effect == "EFFECT_STATIC_DAMAGE" then
return math.max(1, math.floor(power or 0))
end
return nil
end
@@ -257,8 +265,18 @@ Effects.MAGNITUDE_POWER = {
-- is what damagecalc reads as the move's power -- data/moves/moves.asm stores
-- MAGNITUDE at power 1 precisely because this overwrites it. Returns the
-- power and the magnitude number the text prints.
--
-- `random` is BattleRandom (0..n-1). If none is supplied, roll via love.math
-- / math.random — never hard-code 0 (that always yields Magnitude 4).
function Effects.magnitudePower(random)
local roll = random and random(256) or 0
local roll
if type(random) == "function" then
roll = random(256) or 0
elseif love and love.math and love.math.random then
roll = love.math.random(256) - 1
else
roll = math.random(256) - 1
end
for _, row in ipairs(Effects.MAGNITUDE_POWER) do
if row[1] >= roll then return row[2], row[3] end
end
+44 -5
View File
@@ -44,6 +44,7 @@ local BATTLE_FIELDS = {
"sideToxic", "isGymLeader", "musicKind", "lastBall", "lockedBall",
"lowHealthAlarmDisabled", "lowHealthAlarmOn", "victoryMusicPlayed",
"endBattleText",
"playerPartyIndices",
}
local function partyIndex(party, mon)
@@ -91,6 +92,23 @@ local function integer(value, min, max)
and value >= (min or -math.huge) and value <= (max or math.huge)
end
local function exactIndexSet(indices, maxIndex, requireMember)
if type(indices) ~= "table" then return nil end
local count, keys = #indices, 0
for key in pairs(indices) do
keys = keys + 1
if not integer(key, 1, count) then return nil end
end
if keys ~= count or (requireMember and count == 0) then return nil end
local seen = {}
for i = 1, count do
local index = indices[i]
if not integer(index, 1, maxIndex) or seen[index] then return nil end
seen[index] = true
end
return seen
end
local function validateMoveList(data, moves)
if type(moves) ~= "table" then return false end
for _, move in ipairs(moves) do
@@ -200,6 +218,16 @@ function BattleCheckpoint.validate(game, checkpoint)
if type(party) ~= "table" or not validateBattler(game.data, model.player, #party) then
return nil, "invalid_content", "Player battle state is invalid."
end
local scopedIndices
if model.playerPartyIndices ~= nil then
if model.kind ~= "trainer" then
return nil, "invalid_checkpoint", "Battle party scope is invalid."
end
scopedIndices = exactIndexSet(model.playerPartyIndices, #party, true)
if not scopedIndices or not scopedIndices[model.player.index] then
return nil, "invalid_checkpoint", "Battle party scope is invalid."
end
end
if model.kind == "wild" then
if not validateMon(game.data, model.enemyMon)
or not validateBattler(game.data, model.enemy, 1) then
@@ -220,12 +248,15 @@ function BattleCheckpoint.validate(game, checkpoint)
end
end
for _, indices in ipairs({ model.participants, model.leveledUp }) do
if type(indices) ~= "table" then
local referenced = exactIndexSet(indices, #party, false)
if not referenced then
return nil, "invalid_checkpoint", "Battle party reference set is missing."
end
for _, index in ipairs(indices) do
if not integer(index, 1, #party) then
return nil, "invalid_checkpoint", "Battle party reference is invalid."
if scopedIndices then
for index in pairs(referenced) do
if not scopedIndices[index] then
return nil, "invalid_checkpoint", "Battle party reference is invalid."
end
end
end
end
@@ -276,7 +307,9 @@ function BattleCheckpoint.restore(game, checkpoint, copy)
local model = checkpoint.runtime.battle
local battle
if model.kind == "trainer" then
battle = BattleState.newTrainer(game, model.oppClass, model.partyIndex)
battle = BattleState.newTrainer(game, model.oppClass, model.partyIndex, {
playerPartyIndices = model.playerPartyIndices,
})
battle.enemyParty = assert(copy(model.enemyParty))
battle.enemyIndex = model.enemyIndex
else
@@ -334,6 +367,12 @@ function BattleCheckpoint.restore(game, checkpoint, copy)
battle.player.mon.hp, battle.player.mon.status
battle.enemy.shownHP, battle.enemy.shownStatus =
battle.enemy.mon.hp, battle.enemy.mon.status
-- the bar's own pixel length is derived HUD state, not captured: it
-- settles with shownHP above (UpdateHPBar_AnimateHPBar's end position)
local Timing = require("src.core.Timing")
for _, b in ipairs({ battle.player, battle.enemy }) do
b.shownPx = Timing.hpBarPixels(b.mon.hp, math.max(1, b.mon.stats.hp))
end
local ow = game.overworld
if not ow or type(ow.restoreBattleContinuation) ~= "function"
+12
View File
@@ -313,6 +313,16 @@ local function equalData(a, b)
return okA and okB and encodedA == encodedB
end
local function normalizeVerificationMetadata(actual, expected)
if type(actual) == "table" and type(actual.identity) == "table"
and type(expected) == "table" and type(expected.identity) == "table" then
-- RFC 0004 treats engineVersion as compatibility metadata, not runtime
-- state. A fresh recapture stamps the running engine, so normalize only
-- that metadata before differential verification.
actual.identity.engineVersion = expected.identity.engineVersion
end
end
local function firstDifference(a, b, path)
path = path or "$"
if type(a) ~= type(b) then return path .. " (type)" end
@@ -394,6 +404,7 @@ function Checkpoint.restore(game, checkpoint)
if ok then
local restored, verifyCode = Checkpoint.capture(game)
if restored and validated.rng == nil then restored.rng = nil end
normalizeVerificationMetadata(restored, validated)
if restored and equalData(restored, validated) then
emitRestored(game, validated)
return true
@@ -474,6 +485,7 @@ function Checkpoint.resume(game, checkpoint)
if ok then
local restored, verifyCode = Checkpoint.capture(game)
if restored and validated.rng == nil then restored.rng = nil end
normalizeVerificationMetadata(restored, validated)
if restored and equalData(restored, validated) then
emitRestored(game, validated)
return true
+11 -2
View File
@@ -1230,8 +1230,17 @@ function Engine.new(data, header, options)
engine.tempo = header.tempo
engine.tempoLocked = true
end
for _, spec in ipairs(chip and chip.channels
or headerChannels(banks, header)) do
local channels = chip and chip.channels or headerChannels(banks, header)
if header.startChannels then
local byNumber = {}
for _, start in ipairs(header.startChannels) do
byNumber[start.number] = start.address
end
for _, spec in ipairs(channels) do
spec.address = byNumber[spec.number] or spec.address
end
end
for _, spec in ipairs(channels) do
local frameTicks = options.frameTicks
local hardware = (spec.number - 1) % 4 + 1
if hardware == 4 then
+44 -15
View File
@@ -73,7 +73,7 @@ end
--
-- Gold composites its own frame (Game2:draw / drawScene) and pumps its own pad
-- (the FixedStep callback in Game2:load), so none of it goes through
-- src/render/Renderer.lua or src/core/Game.lua. That explains why the six
-- src/render/Renderer.lua or src/core/Game.lua. That explains why the eight
-- hooks below never used to fire here; it is not a reason they should not. A
-- hook is a contract about a MOMENT in the frame, and Gold has every one of
-- these moments -- so each is raised under the Gen 1 NAME with the Gen 1
@@ -83,6 +83,7 @@ end
-- input.pointer uncaptured pointer events (src/core/Game.lua:887)
-- render.zones the palette pass, pre-blit (src/core/Game.lua:505)
-- render.compose the whole-window composite (Renderer.lua:759)
-- render.output* the normal composed frame (Renderer.lua:1063)
-- render.letterbox the void around the 160x144 blit (Renderer.lua:840)
-- render.hud screen-space UI over the frame (src/core/Game.lua:521)
--
@@ -689,10 +690,26 @@ function Game2:usePartyItem(itemId)
self:say(result.text)
return
end
self:consumeItem(itemId)
if action == "candy" then
if action == "stone" then
local party = (self.save and self.save.party) or {}
local index
for i, member in ipairs(party) do
if member == mon then index = i break end
end
Screens.push(self, "Gen2EvolutionAnim", {
mon = mon, entry = result.evolution, index = index,
party = party, save = self.save,
force = true,
onDone = function(evolution)
if evolution and evolution.evolved then self:consumeItem(itemId) end
self.stack:pop()
end,
})
elseif action == "candy" then
self:consumeItem(itemId)
self:say(result.text, function() self:afterRareCandy(mon, result) end)
else
self:consumeItem(itemId)
self:say(result.text)
end
end
@@ -1366,12 +1383,14 @@ function Game2:draw()
local zoned = type(zones) == "table" and zones[1] ~= nil
-- A present canvas is paid for only when something reads it: the zone pass,
-- GBC FX, a mod post-process, or a render.compose subscriber about to be
-- handed the finished frame. With none of them the frame draws straight to
-- the screen exactly as it always did.
-- GBC FX, a mod post-process, render.compose, or an enabled render.output
-- subscriber. With none of them the frame draws straight to the screen
-- exactly as it always did.
local composing = ModRuntime.wantsHook("render.compose")
local hasOutputHook = ModRuntime.wantsHook("render.output")
and ModRuntime.call("render.output_enabled", function() return false end) == true
local scene = nil
if zoned or fx or composing or Pipelines.wantsPresent() then
if zoned or fx or composing or Pipelines.wantsPresent() or hasOutputHook then
scene = self:presentCanvas(1, w, h)
end
if not scene then
@@ -1403,7 +1422,7 @@ function Game2:draw()
-- untinted one. On its own the tint rides the final blit and no second
-- canvas is paid for.
local source = scene
local reread = fx or Pipelines.wantsPresent()
local reread = fx or Pipelines.wantsPresent() or hasOutputHook
if zoned and reread then
local tinted = self:presentCanvas(2, w, h)
if tinted then
@@ -1423,15 +1442,25 @@ function Game2:draw()
-- Post-process pipelines run over the finished composite and before GBC
-- FX. Each hands back a canvas; with none registered this returns `source`
-- unchanged and the frame is byte-identical (Renderer.lua:1058).
local scale, _, _, dpi = self:frameFit(w, h)
local scale, ox, oy, dpi = self:frameFit(w, h)
source = Pipelines.present(source, { width = w, height = h, scale = scale,
dpi = dpi, dpiX = dpi, dpiY = dpi }) or source
if fx then
GBCFX.present(source, self:pixelScale(w, h))
else
G.setColor(1, 1, 1, 1)
G.draw(source, 0, 0)
G.setShader()
local outputHandled = hasOutputHook
and ModRuntime.call("render.output", function() return false end, {
canvas = source, width = w, height = h,
gameX = ox, gameY = oy,
gameWidth = 160 * scale, gameHeight = 144 * scale,
scale = scale, dpiX = dpi, dpiY = dpi,
generation = 2,
}) == true
if not outputHandled then
if fx then
GBCFX.present(source, self:pixelScale(w, h))
else
G.setColor(1, 1, 1, 1)
G.draw(source, 0, 0)
G.setShader()
end
end
end
G.pop()
+41
View File
@@ -0,0 +1,41 @@
-- Optional native-host display lifecycle.
--
-- The engine always owns simulation and drawing. A packaged host may install
-- one backend to observe per-frame updates and prepare/finalize a render target
-- around an otherwise unchanged draw. With no backend installed every method
-- is a no-op, which is the normal desktop and mobile path.
--
-- Backend methods are optional:
-- backend:update(dt)
-- backend:beginFrame(kind, subject)
-- backend:endFrame(kind, subject)
--
-- `kind` is "editor", "touch_editor", "launcher", or "game". `subject` is
-- the object whose existing draw method runs between beginFrame and endFrame.
local HostDisplay = {}
local backend
function HostDisplay.setBackend(value)
if value ~= nil and type(value) ~= "table" then
error("host display backend must be a table or nil", 2)
end
backend = value
end
function HostDisplay.update(dt)
local fn = backend and backend.update
if fn then return fn(backend, dt) end
end
function HostDisplay.beginFrame(kind, subject)
local fn = backend and backend.beginFrame
if fn then return fn(backend, kind, subject) end
end
function HostDisplay.endFrame(kind, subject)
local fn = backend and backend.endFrame
if fn then return fn(backend, kind, subject) end
end
return HostDisplay
+17 -2
View File
@@ -64,6 +64,7 @@ state = {
fanfareResume = false, -- start/resume state.source when the fanfare ends
fade = nil, -- active volume-ramp fade-out (see Music.fadeOut)
tempo = nil, -- alternate-tempo override in force for `current`
start = nil,
failed = {}, -- labels whose def could not be started; logged once
}
@@ -215,9 +216,11 @@ function Music.play(data, song, loop, ctx)
song = selectSong(song, ctx)
local tempo = ctx and ctx.tempo or nil
local start = ctx and ctx.start or nil
-- a hook may silence the cue outright, or swap in a label the dedupe
-- below has to compare against
if not song or (song == state.current and tempo == state.tempo) then return end
if not song or (song == state.current and tempo == state.tempo
and start == state.start) then return end
local def = songDef(data, song)
if not def or state.failed[song] then return end
@@ -240,6 +243,17 @@ function Music.play(data, song, loop, ctx)
slowed.tempo = tempo
def = slowed
end
if start == "rival" and song == "Music_MeetRival"
and def.bank == 2 and def.address == 17050 then
local started = {}
for key, value in pairs(def) do started[key] = value end
started.startChannels = {
{ number = 1, address = 0x71a2 },
{ number = 2, address = 0x721d },
{ number = 3, address = 0x72b5 },
}
def = started
end
local wantLoop = loop ~= false
local src, loopSrc, isChip, err = startSong(data, def, wantLoop)
if not src then
@@ -276,6 +290,7 @@ function Music.play(data, song, loop, ctx)
state.source, state.loopSource, state.chip = src, loopSrc, isChip
state.current = song
state.tempo = tempo
state.start = start
if Runtime.wants("music.started") then
Runtime.emit("music.started", {
song = song, previous = previous, chip = isChip,
@@ -290,7 +305,7 @@ function Music.stop()
stopSource(state.loopSource)
require("src.core.ChipAudio").stopMusic()
state.current, state.source, state.loopSource, state.fade = nil, nil, nil, nil
state.tempo = nil
state.tempo, state.start = nil, nil
state.chip = false
state.pendingRestore = nil
if previous and Runtime.wants("music.stopped") then
+8
View File
@@ -33,6 +33,14 @@ return function(data, label, fallback, ...)
local args = { ... }
if #args == 0 then return text end
if #args == 1 and type(args[1]) == "table" then
local values = args[1]
return (text:gsub("%b{}", function(token)
local value = values[token] or values[token:sub(2, -2)]
return value == nil and token or tostring(value)
end))
end
local slots, named = 0, 0
for token in text:gmatch("%b{}") do
slots = slots + 1
+94 -16
View File
@@ -304,6 +304,10 @@ function SaveData.defaultOptions()
-- options.lua written before this key keeps its exact meaning. Read and
-- written through SaveData.modEnabled / SaveData.setModEnabled.
modsByVersion = {},
-- Set after the first per-game enablement migration. Older options files
-- have only `mods`, so the migration copies each installed mod's current
-- answer to every game before game-specific toggles begin changing it.
modsByVersionMigrated = false,
-- Named setups the player can switch between (#593; src/mods/ModProfile.lua
-- owns the shape, src/mods/ManagerState.lua the UI): each row is
-- { name, enabled = {id=bool}, options = {id={k=v}}, slots = {version=slotId} }.
@@ -335,11 +339,11 @@ function SaveData.defaultOptions()
-- TouchControls.normalizeConfig folds it into both orientations on load.
touchControls = { enabled = true },
-- Haptic feedback level for on-screen pad presses (#806):
-- off | light | medium | heavy, mapped to a love.system.vibrate
-- duration in src/core/TouchControls.lua. LIGHT by default, like the
-- overlay itself defaulting on, so an options.lua predating this key
-- gets the tick without going looking for the row. Inert wherever the
-- overlay never appears (desktop) or LOVE has no vibrator.
-- off | light | normal | strong, mapped to a love.system.vibrate duration
-- in src/core/TouchControls.lua. LIGHT by default, so an options.lua
-- predating this key gets the tick without going looking for the row.
-- Inert wherever the overlay never appears (desktop) or LOVE has no
-- vibrator.
haptics = "light",
-- Shared UI/mod timestamp presentation. DEVICE follows the process time
-- locale where the platform exposes one; otherwise DateTime falls back to
@@ -563,19 +567,68 @@ end
-- only holds the games the player actually chose for, so a mod set can differ
-- between Red and Gold without either one owning the other's flags.
-- Whether a per-game answer is honoured at boot. The loader reads the enable
-- flags once, before any entry chunk (src/mods/Loader.lua _loadState), so this
-- flips on with that read and not before: until then every writer keeps to the
-- shared flag and no surface promises what the boot does not do.
SaveData.PER_VERSION_MODS = false
-- Per-game answers are live. Every reader and writer goes through modScope,
-- so a choice made in the launcher is the choice the next boot loads.
SaveData.PER_VERSION_MODS = true
-- The version a write should be scoped to: the game asked for once per-game
-- flags are live, nil (the shared flag) while they are only a preview.
-- The version a write is scoped to: per-game controls name one game; a nil
-- caller still addresses the legacy shared fallback.
function SaveData.modScope(version)
if SaveData.PER_VERSION_MODS then return version end
return nil
end
-- Promote an installation that predates per-game flags. `mods` may contain
-- manifest rows ({ id, experimental }) or bare ids. A mod with no old entry
-- had the loader default: on, except for experimental mods. Copy that answer
-- to every game once, preserving any per-game overlay somebody imported before
-- this feature shipped. New installs need no rows here: an absent answer
-- still defaults to enabled for every game.
function SaveData.migrateModEnablement(options, mods)
if type(options) ~= "table" or options.modsByVersionMigrated then return false end
options.mods = type(options.mods) == "table" and options.mods or {}
options.modsByVersion = type(options.modsByVersion) == "table"
and options.modsByVersion or {}
local known = {}
for id in pairs(options.mods) do
if type(id) == "string" and id ~= "" then known[id] = { id = id } end
end
for version, bucket in pairs(options.modsByVersion) do
if GameVersion.VERSIONS[version] and type(bucket) == "table" then
for id in pairs(bucket) do
if type(id) == "string" and id ~= "" then known[id] = known[id] or { id = id } end
end
end
end
for _, mod in ipairs(mods or {}) do
local id = type(mod) == "table" and mod.id or mod
if type(id) == "string" and id ~= "" then
known[id] = type(mod) == "table" and mod or (known[id] or { id = id })
end
end
-- Do not create options.lua just to record an empty migration on a fresh
-- no-mod boot. Keep it pending until there is a real installed or saved
-- mod answer to preserve.
if next(known) == nil then return false end
for id, mod in pairs(known) do
local shared = options.mods[id]
if type(shared) ~= "boolean" then shared = not (mod.experimental == true) end
for _, version in ipairs(GameVersion.ORDER) do
local bucket = options.modsByVersion[version]
if type(bucket) ~= "table" then
bucket = {}
options.modsByVersion[version] = bucket
end
if type(bucket[id]) ~= "boolean" then bucket[id] = shared end
end
end
options.modsByVersionMigrated = true
return true
end
-- true/false as chosen for `version`, else the shared flag, else nil -- the
-- caller owns the default (the loader enables, the launcher keeps
-- experimental mods off until asked).
@@ -818,9 +871,19 @@ end
function SaveData.slotSummary(save)
if type(save) ~= "table" then return nil, nil end
local name = save.player and save.player.name or nil
-- A Gen 2 slot carries no badge items and fills pokedex.caught, so both
-- counts come off wJohtoBadges/wPokedexCaught (engine/menus/intro_menu.asm:461).
local vinfo = type(save.version) == "string" and GameVersion.info(save.version)
local gen2 = save.generation == 2 or (vinfo and vinfo.generation == 2) or false
local dexCount = 0
for _ in pairs((save.pokedex and save.pokedex.owned) or {}) do
dexCount = dexCount + 1
if gen2 then
for _, has in pairs((save.pokedex and save.pokedex.caught) or {}) do
if has then dexCount = dexCount + 1 end
end
else
for _ in pairs((save.pokedex and save.pokedex.owned) or {}) do
dexCount = dexCount + 1
end
end
-- playTime is a plain seconds count in a Gen 1 save but a
-- { hours, minutes, seconds, frames } table in a Gen 2 (Gold) save, matching
@@ -838,8 +901,21 @@ function SaveData.slotSummary(save)
end
local timeText = ("%d:%02d"):format(math.floor(t / 3600),
math.floor(t / 60) % 60)
local badges
if gen2 then
-- Continue_DisplayBadgeCount walks TWO bytes, Johto then Kanto
-- (engine/menus/intro_menu.asm:461-469).
badges = 0
local p = save.player or {}
for _, has in pairs(p.badges or {}) do if has then badges = badges + 1 end end
for _, has in pairs(p.kantoBadges or {}) do
if has then badges = badges + 1 end
end
else
badges = Badges.count(nil, save)
end
return name, {
badges = Badges.count(nil, save),
badges = badges,
timeText = timeText,
dexCount = dexCount,
}
@@ -1811,6 +1887,8 @@ function SaveData.newGame(boot)
boot = type(boot) == "table" and boot or {}
local map = boot.startMap or "REDS_HOUSE_2F"
local x, y = boot.startX or 3, boot.startY or 6
local facing = boot.startFacing or "down"
if map == "REDS_HOUSE_2F" and boot.version ~= "yellow" then facing = "up" end
local heal = SaveData.defaultHeal(boot)
local save = {
meta = { format = Version.saveFormat, mods = {} },
@@ -1821,7 +1899,7 @@ function SaveData.newGame(boot)
map = map,
x = x,
y = y,
facing = boot.startFacing or "down",
facing = facing,
name = boot.playerName or "RED",
rival = boot.rivalName or "BLUE",
-- 16-bit trainer ID rolled at new game (wPlayerID, filled from
+9 -5
View File
@@ -105,17 +105,21 @@ end
-- would be dropped on every editor save.
-- love.system.vibrate takes a duration and nothing else, so "intensity" is a
-- duration preset: Android runs the platform vibrator for exactly that long,
-- while iOS ignores the duration and fires the fixed system vibration, so
-- there the three levels all read as simply on.
TouchControls.HAPTICS = { "off", "light", "medium", "heavy" }
-- while iOS maps each duration to a matching Taptic Engine impact.
TouchControls.HAPTICS = { "off", "light", "normal", "strong" }
TouchControls.HAPTIC_DEFAULT = "light"
local HAPTIC_SECONDS = { off = 0, light = 0.012, medium = 0.025, heavy = 0.045 }
local HAPTIC_SECONDS = {
off = 0, light = 0.012, normal = 0.025, strong = 0.045,
}
local HAPTIC_LABELS = {
off = "OFF", light = "LIGHT", medium = "MEDIUM", heavy = "HEAVY",
off = "OFF", light = "LIGHT", normal = "NORMAL", strong = "STRONG",
}
function TouchControls.normalizeHaptics(level)
if level == "physical" then return "light" end
if level == "medium" then return "normal" end
if level == "heavy" then return "strong" end
if HAPTIC_SECONDS[level] then return level end
return TouchControls.HAPTIC_DEFAULT
end
+3
View File
@@ -8,6 +8,9 @@ local Version = {
-- "-dev" placeholder; CI stamps the real X.Y.Z into
-- the packed game.love only, never the working tree.
shell = 1, -- native-shell contract this build implements
payloadHost = "love", -- native host family for in-place Lua payloads.
-- A payload must name the same family; this prevents
-- mounting code packaged for a different native host.
minShell = 1, -- lowest shell contract that can RUN this payload.
-- Bump only when a payload needs a newer native
-- binary (e.g. a LOVE version bump); an older shell
+14
View File
@@ -450,6 +450,20 @@ record("RARE_CANDY", "candy", function(ctx)
return rareCandy(ctx.mon, ctx.data)
end)
for _, itemId in ipairs({ "SUN_STONE", "MOON_STONE", "FIRE_STONE",
"THUNDERSTONE", "WATER_STONE", "LEAF_STONE" }) do
record(itemId, "stone", function(ctx)
if ctx.mon.item == "EVERSTONE" then
return { used = false, text = ItemEffects.TEXT_NO_EFFECT }
end
local Evolution = require("src.core.gen2.Evolution")
local entry = Evolution.checkMon(ctx.data, ctx.mon,
{ force = true, item = ctx.item })
if not entry then return { used = false, text = ItemEffects.TEXT_NO_EFFECT } end
return { used = true, evolution = entry }
end)
end
for itemId in pairs(ItemEffects.REVIVE) do
record(itemId, "revive", function(ctx) return revive(ctx.item, ctx.mon) end)
end
+5
View File
@@ -764,9 +764,14 @@ end
function Save.summary(save)
if type(save) ~= "table" then return nil end
local badges = 0
-- Continue_DisplayBadgeCount counts TWO bytes, wJohtoBadges then wKantoBadges
-- (engine/menus/intro_menu.asm:461-469).
for _, has in pairs(save.player and save.player.badges or {}) do
if has then badges = badges + 1 end
end
for _, has in pairs(save.player and save.player.kantoBadges or {}) do
if has then badges = badges + 1 end
end
local caught = 0
for _, has in pairs(save.pokedex and save.pokedex.caught or {}) do
if has then caught = caught + 1 end
+4
View File
@@ -116,6 +116,8 @@ local physfsMountFn = nil
local function resolveMount()
if physfsMountFn ~= nil then return physfsMountFn end
physfsMountFn = false
if Platform.isUWP() then return physfsMountFn end
if love and love.filesystem and love.filesystem._mounts then return physfsMountFn end
local ok, ffi = pcall(require, "ffi")
if not ok then return physfsMountFn end
pcall(ffi.cdef,
@@ -159,6 +161,8 @@ local physfsUnmountFn = nil
local function resolveUnmount()
if physfsUnmountFn ~= nil then return physfsUnmountFn end
physfsUnmountFn = false
if Platform.isUWP() then return physfsUnmountFn end
if love and love.filesystem and love.filesystem._mounts then return physfsUnmountFn end
local ok, ffi = pcall(require, "ffi")
if not ok then return physfsUnmountFn end
pcall(ffi.cdef, "int PHYSFS_unmount(const char *oldDir);")
+67 -56
View File
@@ -62,6 +62,68 @@ local FILTERS = { "OFF", "1X", "2X", "3X" }
-- The core rows. Helper modules are required lazily under pcall: they are
-- pure label/cycle tables, but the launcher must never die because a render
-- module grew a dependency on live game data.
-- TOUCH PAD, VIBRATION and the layout editor, shared by both row sets.
--
-- Gold reads these out of its own `gold` block (src/core/gen2/Save.lua:297),
-- so `opts` is whichever table the gear is editing and the rows never have to
-- know which game they belong to. #1100 / #1135: the Gold gear carried none
-- of them, so a phone player could turn the pad and the buzz off in Red and
-- had no way to reach either in Gold.
local function addTouchRows(rows, add, opts, hooks)
-- TOUCH PAD only where the overlay can appear, mirroring OptionsMenu's
-- gate (mobile, or desktop forced by POKEPORT_TOUCH=1).
local env = os.getenv("POKEPORT_TOUCH")
local osName = love.system and love.system.getOS and love.system.getOS()
local show = env == "1"
or (env ~= "0" and (osName == "Android" or osName == "iOS"))
if show then
add(Strings("TOUCH PAD"),
function()
local tc = opts.touchControls
local on = not (type(tc) == "table" and tc.enabled == false)
return on and Strings("ON") or Strings("OFF")
end,
function()
local tc = type(opts.touchControls) == "table" and opts.touchControls or {}
tc.enabled = tc.enabled == false
opts.touchControls = tc
return true
end)
-- VIBRATION sits with it (#806): same gate, same subsystem. Stepping
-- the row buzzes once at the level being selected.
local okTC, TC = pcall(require, "src.core.TouchControls")
if okTC then
add(Strings("VIBRATION"),
function() return Strings(TC.hapticLabel(opts.haptics)) end,
function(dir)
opts.haptics = TC.cycleHaptics(opts.haptics, dir)
TC.buzz(opts.haptics)
return true
end)
end
end
-- TOUCH CONTROLS, the on-screen pad's layout editor. It used to be a
-- button on the game panel, once per game -- but the overlay layout is
-- global (options.touchControls.layouts), so three tabs offered three
-- buttons that edited the same thing while crowding the column that has to
-- hold Play. It belongs with the other control rows, behind the gear.
-- The host owns the editor screen, so the row only fires when a hook was
-- supplied (the standalone save editor opens this model with none).
if hooks and hooks.editTouchControls then
rows[#rows + 1] = {
label = Strings("TOUCH CONTROLS"),
actionLabel = Strings("Edit"),
action = function()
hooks.editTouchControls()
-- The editor replaces the whole screen: nothing left to persist here
-- beyond what the caller already saved on the way out.
return false
end,
}
end
end
local function coreRows(opts, hooks)
local rows = {}
local function add(label, value, step)
@@ -236,60 +298,7 @@ local function coreRows(opts, hooks)
end)
end
-- TOUCH PAD only where the overlay can appear, mirroring OptionsMenu's
-- gate (mobile, or desktop forced by POKEPORT_TOUCH=1).
do
local env = os.getenv("POKEPORT_TOUCH")
local osName = love.system and love.system.getOS and love.system.getOS()
local show = env == "1"
or (env ~= "0" and (osName == "Android" or osName == "iOS"))
if show then
add(Strings("TOUCH PAD"),
function()
local tc = opts.touchControls
local on = not (type(tc) == "table" and tc.enabled == false)
return on and Strings("ON") or Strings("OFF")
end,
function()
local tc = type(opts.touchControls) == "table" and opts.touchControls or {}
tc.enabled = tc.enabled == false
opts.touchControls = tc
return true
end)
-- VIBRATION sits with it (#806): same gate, same subsystem. Stepping
-- the row buzzes once at the level being selected.
local okTC, TC = pcall(require, "src.core.TouchControls")
if okTC then
add(Strings("VIBRATION"),
function() return Strings(TC.hapticLabel(opts.haptics)) end,
function(dir)
opts.haptics = TC.cycleHaptics(opts.haptics, dir)
TC.buzz(opts.haptics)
return true
end)
end
end
end
-- TOUCH CONTROLS, the on-screen pad's layout editor. It used to be a
-- button on the game panel, once per game -- but the overlay layout is
-- global (options.touchControls.layouts), so three tabs offered three
-- buttons that edited the same thing while crowding the column that has to
-- hold Play. It belongs with the other control rows, behind the gear.
-- The host owns the editor screen, so the row only fires when a hook was
-- supplied (the standalone save editor opens this model with none).
if hooks and hooks.editTouchControls then
rows[#rows + 1] = {
label = Strings("TOUCH CONTROLS"),
actionLabel = Strings("Edit"),
action = function()
hooks.editTouchControls()
-- The editor replaces the whole screen: nothing left to persist here
-- beyond what the caller already saved on the way out.
return false
end,
}
end
addTouchRows(rows, add, opts, hooks)
-- RESET REBINDS, directly under the touch-pad row. Rebinds are additive
-- (src/core/Input.lua:applyBindings layers options.bindings over the
@@ -462,7 +471,7 @@ end
-- src/ui/gen2/OptionsMenu.lua's ROWS; when editing one, keep the two in sync.
local GEN2_KEY = "gold"
local function gen2Rows(opts)
local function gen2Rows(opts, hooks)
local rows = {}
local function add(label, value, step)
rows[#rows + 1] = { label = label, value = value, step = step }
@@ -551,6 +560,8 @@ local function gen2Rows(opts)
end)
end
addTouchRows(rows, add, opts, hooks)
return rows
end
@@ -574,7 +585,7 @@ function LauncherSettings.open(hooks, version)
opts[GEN2_KEY] = block
end
sections = {
{ title = Strings("OPTIONS"), rows = gen2Rows(block) },
{ title = Strings("OPTIONS"), rows = gen2Rows(block, hooks) },
}
else
sections = {
+1136 -193
View File
File diff suppressed because it is too large Load Diff
+181 -12
View File
@@ -1228,8 +1228,8 @@ 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 list scroll offset (px, clamped in draw); modNotice is
-- the last install/delete result { ok, text } shown as a line above the list.
-- modScroll is the current paged list's inner scroll offset (px, clamped
-- in draw); modNotice is the last install/delete result { ok, text }.
mods = nil, modScroll = 0, modNotice = nil,
-- Which game the MODS panel is answering for (a GameVersion id, nil =
-- every game). Rows resolve their enable-state and their "runs here"
@@ -1649,7 +1649,7 @@ end
function RomImporter:_installMod(source)
if self.workState == "working" then return end
self.tab = "mods"
local ok, installed, res = pcall(function()
local ok, installed, res, manifest = pcall(function()
local LauncherMods = require("src.mods.LauncherMods")
return LauncherMods.installZip(source)
end)
@@ -1661,6 +1661,17 @@ function RomImporter:_installMod(source)
if installed then
pcall(self._refreshMods, self)
self.modNotice = { ok = true, text = "Installed " .. tostring(res) }
local LauncherMods = require("src.mods.LauncherMods")
local checkTarget = manifest
if not checkTarget and type(res) == "string" then
checkTarget = { id = res }
end
if checkTarget and LauncherMods.checkDependencies then
local depCheck = LauncherMods.checkDependencies(checkTarget)
if depCheck and depCheck.hasIssues then
self._modDepResolver = depCheck
end
end
else
self.modNotice = { ok = false, text = tostring(res) }
end
@@ -2048,6 +2059,15 @@ end
function RomImporter:update(dt)
self.pulse = self.pulse + dt
if self._launchFade then
self._launchFade.elapsed = self._launchFade.elapsed + dt
if self._launchFade.elapsed >= self._launchFade.duration then
local version = self._launchFade.version
self._launchFade = nil
self:play(version)
return
end
end
self:_updatePadCursor(dt)
self:_stepBaseRomScan()
-- Pump the FlexLove view (input polling + the queued click actions). The
@@ -2336,6 +2356,20 @@ function RomImporter:_updatePadCursor(dt)
local ny = self._padCursor.y + dy * speed * dt
self._padCursor.x = math.max(ox, math.min(ox + w, nx))
self._padCursor.y = math.max(oy, math.min(oy + h, ny))
-- Pushing INTO the top/bottom edge scrolls the page instead of stalling.
-- The cursor is clamped to the safe area above, so on a short window the
-- rows below the fold are unreachable on a stickless handheld: no mouse
-- wheel, no touchscreen, and no right stick to feed the existing wheel
-- path. Only the OVERSHOOT scrolls -- parking the cursor at the edge does
-- nothing, it has to be actively pushed -- and this block only runs on pad
-- input, so a real mouse is unaffected. /48 matches the pixels-per-notch
-- LauncherView.draw multiplies back out.
local overY = 0
if ny > oy + h then overY = ny - (oy + h)
elseif ny < oy then overY = ny - oy end
if overY ~= 0 and self._flex then
require("src.import.LauncherView").wheelmoved(self, 0, -overY / 48)
end
-- Desktop: FlexLove polls the real mouse, so warp it with the pad pointer.
-- NX: the getPosition bridge already returns pad coords — skip setPosition.
if not self.isNX and love.mouse.setPosition then
@@ -2436,9 +2470,15 @@ function RomImporter:joystickhat(joystick, hat, direction)
end
-- Player pressed Play on a game whose ROM is imported: hand off to boot.
function RomImporter:play(version)
function RomImporter:play(version, fade)
if self.workState == "working" then return end
if not self.ready[version] then return end
if fade then
if not self._launchFade then
self._launchFade = { version = version, elapsed = 0, duration = 0.24 }
end
return
end
self._handedOff = true
-- #835: remember the game being launched so the next launcher start opens on
-- its column (_applyLastVersionTab). It rides options.lua rather than a file
@@ -2652,11 +2692,14 @@ function RomImporter:_openSettings()
-- hook rather than reaching for main.lua's handler itself. Closing the
-- settings panel FIRST persists the pending edits (_closeSettings saves)
-- and leaves no modal behind the editor to return to.
-- The tab rides along: the editor persists the layout into that game's own
-- option block, and Gold's is not the flat Gen 1 one (#1100).
local hooks = {}
if self.onEditTouchControls then
local version = self.tab
hooks.editTouchControls = function()
self:_closeSettings()
self.onEditTouchControls()
self.onEditTouchControls(version)
end
end
-- The tab the gear was opened on decides the row set: Gold reads a
@@ -2698,6 +2741,43 @@ function RomImporter:fileUrl(path)
end
function RomImporter:keypressed(key)
if self._profileSavePrompt then
if key == "backspace" then
self._profileSavePrompt.text = utf8Back(self._profileSavePrompt.text or "")
elseif key == "return" or key == "kpenter" then
local txt = self._profileSavePrompt and self._profileSavePrompt.text
if txt and txt ~= "" then
local LauncherMods = require("src.mods.LauncherMods")
LauncherMods.saveProfile(txt)
self._profileSavePrompt = nil
self:_disarmTextInput()
if self._refreshMods then self:_refreshMods() end
end
elseif key == "escape" then
self._profileSavePrompt = nil
self:_disarmTextInput()
end
return
end
if self._profileRenamePrompt then
if key == "backspace" then
self._profileRenamePrompt.text = utf8Back(self._profileRenamePrompt.text or "")
elseif key == "return" or key == "kpenter" then
local txt = self._profileRenamePrompt and self._profileRenamePrompt.text
local old = self._profileRenamePrompt and self._profileRenamePrompt.oldName
if txt and txt ~= "" and old then
local LauncherMods = require("src.mods.LauncherMods")
LauncherMods.renameProfile(old, txt)
self._profileRenamePrompt = nil
self:_disarmTextInput()
if self._refreshMods then self:_refreshMods() end
end
elseif key == "escape" then
self._profileRenamePrompt = nil
self:_disarmTextInput()
end
return
end
if self._settingsText then
if key == "backspace" then
self._settingsText.text = utf8Back(self._settingsText.text)
@@ -2741,6 +2821,13 @@ function RomImporter:keypressed(key)
end
if self._modConfirm or self._modVersions or self._modReleaseNotes
or self._findDetails then
-- Focus navigation belongs to the visible modal as well as the launcher
-- beneath it. Route arrows and an already-armed confirm before this guard
-- returns; unarmed Enter still falls through to the modal guard. Keep this
-- inside the modal branch so text fields retain exclusive keyboard input.
if self._flex and require("src.import.LauncherView").keypressed(self, key) then
return
end
if key == "escape" then
if self._findDetails then
self._findDetails = nil
@@ -2860,6 +2947,14 @@ function RomImporter:_commitRename()
end
function RomImporter:textinput(text)
if self._profileSavePrompt then
self._profileSavePrompt.text = utf8Cap((self._profileSavePrompt.text or "") .. text, MAX_SLOT_LABEL)
return
end
if self._profileRenamePrompt then
self._profileRenamePrompt.text = utf8Cap((self._profileRenamePrompt.text or "") .. text, MAX_SLOT_LABEL)
return
end
if self._settingsText then
local st = self._settingsText
st.text = utf8Cap(st.text .. text, st.maxLen or MAX_SLOT_LABEL)
@@ -3054,15 +3149,19 @@ function RomImporter:_modUpdateInfo(id)
return self.modUpdateInfo and self.modUpdateInfo[id] or nil
end
-- Flip a mod's enabled flag (persisted via LauncherMods.setEnabled) and relist
-- so the toggle, count, and every status chip reflect the new resolution.
-- Enabling an experimental mod arms a confirm first.
function RomImporter:_toggleMod(id, confirmed)
-- Flip one game's mod flag (persisted via LauncherMods.setEnabled) and relist
-- so that game's checkbox and status chips reflect the new resolution.
-- Enabling an experimental mod arms a confirmation for that same game.
function RomImporter:_toggleMod(id, confirmed, version)
local LauncherMods = require("src.mods.LauncherMods")
local cur, experimental = false, false
for _, m in ipairs(self.mods or {}) do
if m.id == id then
cur = m.enabled
if version and m.enabledByVersion then
cur = m.enabledByVersion[version] == true
else
cur = m.enabled
end
experimental = m.experimental == true
break
end
@@ -3070,7 +3169,7 @@ function RomImporter:_toggleMod(id, confirmed)
local want = not cur
if want and experimental and not confirmed then
self._modConfirm = {
kind = "experimental", id = id,
kind = "experimental", id = id, version = version,
title = "Experimental mod",
yesLabel = "Enable",
lines = {
@@ -3082,7 +3181,7 @@ function RomImporter:_toggleMod(id, confirmed)
return
end
self._modConfirm = nil
LauncherMods.setEnabled(id, want, self.modScope)
LauncherMods.setEnabled(id, want, version or self.modScope)
self:_refreshMods()
end
@@ -3342,6 +3441,76 @@ function RomImporter:_pumpModInstall()
else
self.modNotice = { ok = true, text = text }
end
local LauncherMods = require("src.mods.LauncherMods")
local depCheck = LauncherMods.checkDependencies({ id = spec.modId })
if depCheck and depCheck.hasIssues then
self._modDepResolver = depCheck
end
end
-- Start an async pull for a single dependency
function RomImporter:_startDepPull(dep)
if not dep or not dep.github then return end
self._depPullState = self._depPullState or {}
local hFetch = require("src.mods.ModUpdate").beginFetchReleases(dep.github, dep.id, { force = true })
self._depPullState[dep.id] = {
dep = dep,
stage = "fetching",
fetchHandle = hFetch,
}
end
-- Pump all in-flight dependency pulls
function RomImporter:_pumpDepPulls()
if not self._depPullState then return end
local ModUpdate = require("src.mods.ModUpdate")
local LauncherMods = require("src.mods.LauncherMods")
for depId, state in pairs(self._depPullState) do
if state.stage == "fetching" then
local done, releases, err = ModUpdate.pumpFetchReleases(state.fetchHandle)
if done then
if err or not releases or #releases == 0 then
state.stage = "error"
state.err = err or "No downloadable releases found on GitHub"
else
local rel = releases[1]
if not rel or not rel.zip or not rel.zip.url then
state.stage = "error"
state.err = "Latest release has no downloadable .zip asset"
else
local tmpName = ("dep_%s_%s.zip"):format(depId, tostring(rel.version or os.time()))
state.dlHandle = ModUpdate.beginDownloadZip(rel.zip.url, tmpName, rel.zip.size)
state.stage = "downloading"
state.targetVersion = rel.version
end
end
end
elseif state.stage == "downloading" then
local done, localPath, err, progress = ModUpdate.pumpDownloadZip(state.dlHandle)
state.progress = progress
if done then
if err or not localPath then
state.stage = "error"
state.err = err or "Download failed"
else
state.stage = "installing"
local okInst, versionRes = LauncherMods.installDownloadedZip(depId, localPath, state.targetVersion)
if okInst then
state.stage = "done"
pcall(self._refreshMods, self)
if self._modDepResolver and self._modDepResolver.targetMod then
local updated = LauncherMods.checkDependencies(self._modDepResolver.targetMod)
self._modDepResolver = updated
end
else
state.stage = "error"
state.err = tostring(versionRes or "Installation failed")
end
end
end
end
end
end
function RomImporter:_confirmModUpdate(modId, release)
+11
View File
@@ -210,6 +210,17 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
-- (ItemUsePokeFlute, engine/items/item_effects.asm); never consumed.
if itemId == "POKE_FLUTE" then
if not battle then
if ow and ow.map and ow.map.id == "PEWTER_POKECENTER"
and ow.pikachuPewterSleepScene then
local Follower = require("src.world.PikachuFollower")
local pika = Follower.current(ow)
local player = ow.player
if pika and player
and math.abs(pika.cellX - player.cellX) + math.abs(pika.cellY - player.cellY) == 1 then
return "flute_wake_pikachu", { romText(data, "_PlayedFluteHadEffectText",
"{PLAYER} played the\nPOKé FLUTE.") }
end
end
-- standing next to a not-yet-beaten Snorlax: this is the ONLY way
-- Snorlax wakes -- using the flute from the item-use menu, never
-- just talking to it with the flute in the bag (see
+120 -11
View File
@@ -9,15 +9,16 @@
-- cable pull.
--
-- Cable rules: no experience, no money, no items; either side may RUN
-- (a draw); a fainted mon is auto-replaced by the next healthy party
-- member (the original prompts; documented divergence). Badge stat
-- boosts don't apply on either side (divergence: Gen 1 famously kept
-- them in link battles).
-- (a draw); a fainted mon is replaced from the party menu and the chosen
-- slot rides the wire, the way ChooseNextMon hands it to
-- LinkBattleExchangeData. Badge stat boosts don't apply on either side
-- (divergence: Gen 1 famously kept them in link battles).
local Fingerprint = require("src.link.Fingerprint")
local Font = require("src.render.Font")
local Handshake = require("src.link.Handshake")
local Logger = require("src.core.Logger")
local Party = require("src.pokemon.Party")
local Protocol = require("src.link.Protocol")
local Runtime = require("src.mods.Runtime")
local TurnOrder = require("src.battle.TurnOrder")
@@ -257,7 +258,9 @@ function LinkBattle.new(game, net, opts)
self.enemyParty = theirParty
self.playerParty = myParty -- intro ball row uses the clamped copies
self.opponentName = theirName
self.introText = Strings("%s wants\nto battle!", 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.remoteHashes = {}
self.localHashes = {}
self.remoteParts = {}
@@ -327,6 +330,68 @@ function LinkBattle.new(game, net, opts)
end)
end
-- ChooseNextMon (engine/battle/core.asm:1086-1103): the replacement after
-- a faint is a free party-menu pick in a link battle too -- DisplayPartyMenu
-- runs first, a fainted pick or a cancel goes back to it
-- (.goBackToPartyMenu), and only the chosen slot rides
-- LinkBattleExchangeData.
local function chooseReplacement(s)
s.linkReplacement = nil
s:uiNext(function()
return s:buildScreen("PartyMenu", {
battle = s,
party = myParty,
forceSwitch = true,
onSwitch = function(mon)
if mon.hp > 0 then s.linkReplacement = mon end
end,
})
end)
s:actNext(function()
local mon = s.linkReplacement
s.linkReplacement = nil
if s.result then return end
if not mon then
chooseReplacement(s)
return
end
for i, m in ipairs(myParty) do
if m == mon then send({ type = "replace", index = i }) break end
end
sendOutPlayer(s, mon)
end)
end
-- ReplaceFaintedEnemyMon (core.asm:892-905) reads the peer's slot back out
-- of the exchange, and EnemySendOutFirstMon (core.asm:1315-1320) decodes it
-- as wSerialExchangeNybbleReceiveData - 4. HandlePlayerMonFainted
-- (core.asm:989-996) always runs ChooseNextMon BEFORE that read, so on a
-- double faint both machines commit their own slot before they block on the
-- peer's; our queue can reach the enemy's handler first, so the wait
-- pre-empts itself with the local pick rather than deadlocking on a message
-- neither side is going to send.
local function awaitReplacement(s)
s:actNext(function()
if s.result then return end
if s.player.mon.hp <= 0 and not s.linkChoiceQueued
and Party.firstHealthy(myParty) then
s.linkChoiceQueued = true
chooseReplacement(s)
awaitReplacement(s)
return
end
local idx = s.remoteReplace
if not idx then
awaitReplacement(s)
return
end
s.remoteReplace = nil
local mon = theirParty[idx]
if not mon or mon.hp <= 0 then mon = Party.firstHealthy(theirParty) end
sendOutEnemy(s, mon)
end)
end
-- decode a remote action message against the enemy battler
local function decodeTheirAction(s, msg)
return decodeWireAction(s, msg, s.enemy)
@@ -540,12 +605,17 @@ function LinkBattle.new(game, net, opts)
submit(s, { type = "action", kind = "run" }, nil)
end
-- fainted mons auto-replace with the next healthy teammate, in party
-- order, identically on both machines
-- linkChoiceQueued: awaitReplacement already pre-empted itself with this
-- side's ChooseNextMon and put the slot on the wire, so the handler the
-- faint queued has nothing left to do but drop the flag
self.playerMonFainted = function(s)
if s.linkChoiceQueued then
s.linkChoiceQueued = nil
return
end
for _, mon in ipairs(myParty) do
if mon.hp > 0 then
s:act(function() sendOutPlayer(s, mon) end)
if not s.result then chooseReplacement(s) end
return
end
end
@@ -558,7 +628,7 @@ function LinkBattle.new(game, net, opts)
self.enemyMonFainted = function(s)
for _, mon in ipairs(theirParty) do
if mon.hp > 0 then
s:act(function() sendOutEnemy(s, mon) end)
if not s.result then awaitReplacement(s) end
return
end
end
@@ -579,6 +649,9 @@ function LinkBattle.new(game, net, opts)
s.remoteHashes[msg.turn or 0] = msg.value
s.remoteParts[msg.turn or 0] = msg.parts
checkHashes(s)
elseif msg.type == "replace" then
local idx = math.floor(tonumber(msg.index) or 1)
s.remoteReplace = math.max(1, math.min(#theirParty, idx))
elseif msg.type == "bye" then
-- only a draw if our own simulation hasn't already decided
-- (the winner's bye can arrive while we're still animating)
@@ -770,6 +843,34 @@ function LinkBattle.newSpectator(game, net, opts)
end)
end
local function awaitHostReplacement(s)
s:actNext(function()
if s.result then return end
local idx = table.remove(s.hostReplace, 1)
if not idx then
awaitHostReplacement(s)
return
end
local mon = hostParty[idx]
if not mon or mon.hp <= 0 then mon = Party.firstHealthy(hostParty) end
sendOutHost(s, mon)
end)
end
local function awaitGuestReplacement(s)
s:actNext(function()
if s.result then return end
local idx = table.remove(s.guestReplace, 1)
if not idx then
awaitGuestReplacement(s)
return
end
local mon = guestParty[idx]
if not mon or mon.hp <= 0 then mon = Party.firstHealthy(guestParty) end
sendOutGuest(s, mon)
end)
end
local function resolveSpecTurn(s, hostMsg, guestMsg)
if hostMsg.kind == "run" or guestMsg.kind == "run" then
endSpectate(s, "The match ended.")
@@ -840,7 +941,7 @@ function LinkBattle.newSpectator(game, net, opts)
self.playerMonFainted = function(s)
for _, mon in ipairs(hostParty) do
if mon.hp > 0 then
s:act(function() sendOutHost(s, mon) end)
if not s.result then awaitHostReplacement(s) end
return
end
end
@@ -852,7 +953,7 @@ function LinkBattle.newSpectator(game, net, opts)
self.enemyMonFainted = function(s)
for _, mon in ipairs(guestParty) do
if mon.hp > 0 then
s:act(function() sendOutGuest(s, mon) end)
if not s.result then awaitGuestReplacement(s) end
return
end
end
@@ -862,6 +963,7 @@ function LinkBattle.newSpectator(game, net, opts)
end
self.hostMsg, self.guestMsg = nil, nil
self.hostReplace, self.guestReplace = {}, {}
local baseUpdate = self.update
self.update = function(s, dt)
net:update()
@@ -875,6 +977,13 @@ function LinkBattle.newSpectator(game, net, opts)
s.hostMsg, s.guestMsg = nil, nil
resolveSpecTurn(s, h, g)
end
elseif inner.type == "replace" then
local idx = math.floor(tonumber(inner.index) or 1)
if msg.side == "host" then
table.insert(s.hostReplace, math.max(1, math.min(#hostParty, idx)))
else
table.insert(s.guestReplace, math.max(1, math.min(#guestParty, idx)))
end
elseif inner.type == "bye" or inner.type == "forfeit" then
if not s.result then endSpectate(s, "The match ended.") end
end
+87 -17
View File
@@ -19,6 +19,7 @@ LinkState.__index = LinkState
LinkState.isOpaque = true
local CURSOR = 0xED
local CURSOR_HOLLOW = 0xEC
local ANY = "ANY" -- sentinel: a leading nil array entry breaks ipairs under
-- LuaJIT even though # still reports the full size, so
-- the level picker cycles this string instead of nil,
@@ -114,6 +115,7 @@ end
function LinkState:exitWith(message, reason)
DiscordPresence.setJoinCode(nil)
self.game.linkSession = nil -- back to the player's own GAME SPEED
if self.game.linkNet == self.net then self.game.linkNet = nil end
Runtime.emit("link.ended", { reason = reason or (message and "error" or "bye") })
if self.net then self.net:close() end
self.game.stack:pop()
@@ -519,6 +521,9 @@ function LinkState:startMode(mode, isHost)
})
self.net:send(self.trade:opening())
self.index = 1
self.theirIndex = 1
self.side = "mine"
self.pickChoice = nil
else
self.stage = "battleWait"
-- the host deals the shared RNG seed for the lockstep simulation
@@ -536,7 +541,14 @@ end
-- trade flow
-- -------------------------------------------------------------------
function LinkState:openStats(mon)
if not mon then return end
self.game.linkNet = self.net
Screens.push(self.game, "SummaryMenu", mon)
end
function LinkState:updateTrade(input)
if self.game.linkNet == self.net then self.game.linkNet = nil end
for _, msg in ipairs(self.net:poll()) do
local reply = self.trade:handle(msg)
if reply then self.net:send(reply) end
@@ -591,10 +603,51 @@ function LinkState:updateTrade(input)
return
end
if t.stage == "picking" and input:wasPressed("up") then
self.index = math.max(1, self.index - 1)
-- pokered engine/link/cable_club.asm TradeCenter_SelectMon: A on one of
-- your own mons opens the "STATS TRADE" row (.displayStatsTradeMenu)
-- and only TRADE commits the pick, while the enemy list carries its own
-- cursor whose A shows that mon's status pages (.displayEnemyMonStats).
-- The cart's enemy path sets hl but never wMonDataLocation, the way the
-- battle menu's STATS (engine/battle/core.asm) does, so LoadMonData_ reads
-- the player's party and it draws YOUR mon at that slot -- an omission,
-- not behaviour, so we show the peer's mon.
if t.stage == "picking" and self.pickChoice then
if input:wasPressed("left") then
self.pickChoice = 1
elseif input:wasPressed("right") then
self.pickChoice = 2
elseif input:wasPressed("b") then
self.pickChoice = nil -- .cancelPlayerMonChoice: back to the list, not
-- out of the trade
elseif input:wasPressed("a") then
if self.pickChoice == 1 then
self.pickChoice = nil
self:openStats(self.game.save.party[self.index])
elseif t:canPick(self.index) then
self.pickChoice = nil
self.side = "mine"
self.net:send(t:pick(self.index))
end
end
elseif t.stage == "picking" and input:wasPressed("up") then
if self.side == "theirs" then
self.theirIndex = math.max(1, self.theirIndex - 1)
else
self.index = math.max(1, self.index - 1)
end
elseif t.stage == "picking" and input:wasPressed("down") then
self.index = math.min(#self.game.save.party, self.index + 1)
if self.side == "theirs" then
self.theirIndex = math.min(#(t.theirParty or {}), self.theirIndex + 1)
else
self.index = math.min(#self.game.save.party, self.index + 1)
end
elseif t.stage == "picking" and input:wasPressed("right") then
if t.theirParty and #t.theirParty > 0 then
self.side = "theirs"
self.theirIndex = math.min(self.theirIndex, #t.theirParty)
end
elseif t.stage == "picking" and input:wasPressed("left") then
self.side = "mine"
elseif self.confirmed == nil and input:wasPressed("b") then
-- once confirm=true has been sent to the peer, backing out here
-- would desync the two sides (the peer may already be committing
@@ -603,8 +656,10 @@ function LinkState:updateTrade(input)
self.net:send({ type = "bye" })
self:exitWith(Strings("The trade was\ncancelled."))
elseif t.stage == "picking" and input:wasPressed("a") then
if t:canPick(self.index) then
self.net:send(t:pick(self.index))
if self.side == "theirs" then
self:openStats((t.theirParty or {})[self.theirIndex])
else
self.pickChoice = 1
end
elseif t.stage == "confirming" and self.confirmed == nil then
if input:wasPressed("a") then
@@ -738,25 +793,40 @@ function LinkState:draw()
local label = (mon.nickname or def.name):sub(1, 8)
if not t:canPick(i) then label = label .. "X" end
Font.draw(label, 16, 20 + i * 12)
if i == self.index then Font.drawCode(CURSOR, 8, 20 + i * 12) end
if i == self.index and self.side ~= "theirs" then
Font.drawCode(CURSOR, 8, 20 + i * 12)
end
end
Font.draw(Strings("THEIRS"), 84, 20)
for i, mon in ipairs(t.theirParty or {}) do
local def = self.game.data.pokemon[mon.species]
Font.draw((mon.nickname or def.name):sub(1, 8), 92, 20 + i * 12)
if t.theirPick == i then Font.drawCode(CURSOR, 84, 20 + i * 12) end
if self.side == "theirs" and i == self.theirIndex then
Font.drawCode(CURSOR, 84, 20 + i * 12)
elseif t.theirPick == i then
Font.drawCode(CURSOR_HOLLOW, 84, 20 + i * 12)
end
end
local hint
if t.stage == "waitRecords" then hint = "Comparing games..."
elseif t.stage == "waitParty" then hint = "Exchanging data..."
elseif t.stage == "picking" then
hint = t:canPick(self.index) and "Pick one to trade"
or Strings("X: not on theirs")
elseif t.stage == "waitPick" then hint = "Waiting for them..."
elseif t.stage == "confirming" then
hint = self.confirmed and "Waiting..." or Strings("A: trade B: cancel")
if self.pickChoice then
Font.draw(Strings("STATS"), 16, 128)
Font.draw(Strings("TRADE"), 96, 128)
Font.drawCode(CURSOR, self.pickChoice == 1 and 8 or 88, 128)
else
local hint
if t.stage == "waitRecords" then hint = "Comparing games..."
elseif t.stage == "waitParty" then hint = "Exchanging data..."
elseif t.stage == "picking" then
if self.side == "theirs" then hint = Strings("A: stats")
else
hint = t:canPick(self.index) and "Pick one to trade"
or Strings("X: not on theirs")
end
elseif t.stage == "waitPick" then hint = "Waiting for them..."
elseif t.stage == "confirming" then
hint = self.confirmed and "Waiting..." or Strings("A: trade B: cancel")
end
Font.draw(hint or "", 8, 132)
end
Font.draw(hint or "", 8, 132)
elseif self.stage == "battleWait" or self.stage == "battleRunning" then
drawTitle("LINK BATTLE")
+3 -19
View File
@@ -16,6 +16,7 @@
local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime")
local SafePath = require("src.mods.SafePath")
local unpack = table.unpack or unpack
local loadstring = loadstring or load
@@ -31,25 +32,8 @@ AssetTransform.SOURCE_ROOT = SOURCE_ROOT
AssetTransform.DERIVED_ROOT = DERIVED_ROOT
-- ------- path sandbox
-- a relative path that cannot climb out of the root it is joined to
local function safeRelative(rel)
if type(rel) ~= "string" or rel == "" then return nil end
if rel:sub(1, 1) == "/" then return nil end
if rel:find("\\", 1, true) then return nil end
for segment in rel:gmatch("[^/]+") do
if segment == ".." or segment == "." then return nil end
end
return rel
end
local function requireRelative(rel, what)
local safe = safeRelative(rel)
if not safe then
error(("%s must stay inside its root, got %q"):format(what, tostring(rel)), 0)
end
return safe
end
-- shared with mod:read and the manifest's own paths (src/mods/SafePath.lua)
local requireRelative = SafePath.require
-- ------- the restricted context
+7 -4
View File
@@ -736,8 +736,9 @@ COVERAGE["src.pokemon.Boxes"] = {
COVERAGE["src.world.WorldAPI"] = {
kind = "alias", target = "src.world.gen2.WorldAPI",
backed = "new __index overworld current warpTo toggleObject replaceBlock "
.. "spawnNpc removeNpc npc queueScript invalidateMap",
backed = "new __index overworld current mapOverview warpTo toggleObject replaceBlock "
.. "spawnNpc removeNpc npc queueScript invalidateMap "
.. "availableFieldActions useFieldAction",
warned = "setFlag getFlag",
absent = "",
notes = {
@@ -1797,7 +1798,8 @@ local function buildBattleState()
"throwBall", "ballChain", "tossAnimFor", "ballFlicker", "ballMissMessage",
"storeCaughtMon", "safariAction", "safariEnemyTurn", "drawBallRow",
"drawClassic", "isWideBattleLayout", "wideLayout", "bgMode", "uiSize",
"sgbPalettes", "trainerPalette", "trainerPicPath", "invalidate",
"sgbPalettes", "trainerPalette", "trainerPicPath", "trainerTrueColor",
"trainerSprite", "invalidate",
"imageBattleScale", "resolveBattleScale", "backPlacement",
"frontPlacement", "StatBox", "enter", "exit",
}) do
@@ -1884,7 +1886,8 @@ COVERAGE["src.battle.BattleState"] = {
absent = "newWild newTrainer makeSafari makeGhost makeBattler resolveTurn "
.. "computeDamage catchAttempt runRoll enter exit sgbPalettes "
.. "isWideBattleLayout wideLayout bgMode uiSize letterboxWhite "
.. "holdsUIAnchors BG_WORLD_DIM trainerPalette trainerPicPath invalidate "
.. "holdsUIAnchors BG_WORLD_DIM trainerPalette trainerPicPath "
.. "trainerTrueColor trainerSprite invalidate "
.. "backPlacement frontPlacement StatBox drawClassic drawBallRow "
.. "safariAction safariEnemyTurn throwBall storeCaughtMon field ruleset "
.. "rng oppClass partyIndex aiUses introText dead",
+378 -30
View File
@@ -36,10 +36,13 @@ local ModTargets = require("src.mods.ModTargets")
local Semver = require("src.mods.Semver")
local Version = require("src.core.Version")
local SaveData = require("src.core.SaveData")
local GameVersion = require("src.core.GameVersion")
local CacheFs = require("src.import.CacheFs")
local LauncherMods = {}
local discover -- forward declaration for helper functions above line 343
-- ------- pure status derivation
-- A hard-dependency / conflict / version verdict for one manifest. mods is
@@ -84,31 +87,196 @@ local function statusFor(mods, id, enabledSet, enabled, version, forcedFor)
-- resolveToggle would cascade-enable a merely-disabled dep rather than flag
-- it, so the disabled case is judged straight off the manifest here.
for _, spec in ipairs(m.dependencySpecs or {}) do
local dep = mods[spec.id]
if not dep then
return "warn", "Needs " .. spec.id .. " (not installed)"
elseif not enabledSet[spec.id] then
return "warn", "Needs " .. spec.id .. " (disabled)"
-- installed and on, but not for THIS game: the loader skips the
-- dependency and the skip is contagious (Loader:_enforceDependencies),
-- so a mod that runs everywhere still does not run here
elseif version
and not ModTargets.runsHere(dep, version, nil, forcedFor(spec.id)) then
return "warn", "Needs " .. spec.id .. " (not for "
.. ModTargets.gameLabel(version) .. ")"
elseif spec.range
and not Semver.satisfies(dep.version, spec.range) then
return "warn", "Needs " .. spec.id .. " " .. spec.range
if ModTargets.specApplies(spec, version) then
local dep = mods[spec.id]
if not dep then
return "warn", "Needs " .. spec.id .. " (not installed)"
elseif not enabledSet[spec.id] then
return "warn", "Needs " .. spec.id .. " (disabled)"
-- installed and on, but not for THIS game: the loader skips the
-- dependency and the skip is contagious (Loader:_enforceDependencies),
-- so a mod that runs everywhere still does not run here
elseif version
and not ModTargets.runsHere(dep, version, nil, forcedFor(spec.id)) then
return "warn", "Needs " .. spec.id .. " (not for "
.. ModTargets.gameLabel(version) .. ")"
elseif spec.range
and not Semver.satisfies(dep.version, spec.range) then
return "warn", "Needs " .. spec.id .. " " .. spec.range
end
end
end
return "ok", "Ready"
end
-- Resolves the best-known GitHub owner/repo string for a dependency spec, if any.
function LauncherMods.resolveDependencyRepo(depId, parentManifest, installedDep)
if not depId or depId == "" then return nil end
-- 1. Check spec hint if parentManifest dependencySpecs carries it
if parentManifest and parentManifest.dependencySpecs then
for _, spec in ipairs(parentManifest.dependencySpecs) do
if spec.id == depId and spec.github then
return spec.github
end
end
end
-- 2. Check parentManifest raw dependency_sources
if parentManifest and parentManifest.raw and type(parentManifest.raw.dependency_sources) == "table" then
local src = parentManifest.raw.dependency_sources[depId]
if src then
local ok, clean = pcall(Manifest.parseGithub, src)
if ok and clean then return clean end
end
end
-- 3. Check installed dependency manifest
if installedDep and installedDep.github then
return installedDep.github
end
-- 4. Check ModIndex entries if available
local okModIndex, ModIndex = pcall(require, "src.mods.ModIndex")
if okModIndex and ModIndex and type(ModIndex.sources) == "function" then
for _, src in ipairs(ModIndex.sources() or {}) do
local cached = ModIndex.readCache and ModIndex.readCache(src.feed)
if cached and type(cached.index) == "table" then
for _, entry in ipairs(cached.index) do
if type(entry) == "table" and entry.id == depId and entry.github then
local ok, clean = pcall(Manifest.parseGithub, entry.github)
if ok and clean then return clean end
end
end
end
end
end
return nil
end
-- Inspect manifest dependencies and conflicts against installed mods.
-- Returns: { hasIssues = bool, targetMod = {...}, deps = [ { id, name, range, status, kind, installedVersion, github, safeUrl }, ... ] }
function LauncherMods.checkDependencies(manifest, options, version, installedManifests)
if not manifest then
return { hasIssues = false, targetMod = { name = "Unknown" }, deps = {} }
end
local SaveData = require("src.core.SaveData")
local manifests = installedManifests or discover()
local installedMap = {}
for _, m in ipairs(manifests) do
installedMap[m.id] = m
end
local depsResult = {}
local hasIssues = false
-- 1. Hard Dependencies (dependencySpecs)
if type(manifest.dependencySpecs) == "table" then
for _, spec in ipairs(manifest.dependencySpecs) do
if not version or ModTargets.specApplies(spec, version) then
local depId = spec.id
local range = spec.range
local installedDep = installedMap[depId]
local status = "satisfied"
local installedVersion = installedDep and installedDep.version or nil
if not installedDep then
status = "missing"
hasIssues = true
elseif range and not Semver.satisfies(installedDep.version, range) then
status = "incompatible"
hasIssues = true
end
local ghRepo = LauncherMods.resolveDependencyRepo(depId, manifest, installedDep)
local safeUrl = ghRepo and ("https://github.com/" .. ghRepo) or nil
depsResult[#depsResult + 1] = {
id = depId,
name = (installedDep and installedDep.name) or depId,
range = range,
status = status,
kind = "dependency",
installedVersion = installedVersion,
github = ghRepo,
safeUrl = safeUrl,
}
end
end
end
-- 2. Conflicts / Incompatible Mods (conflictSpecs)
local conflictIdsSeen = {}
local scope = SaveData.modScope and SaveData.modScope(version) or version
local isEnabled = function(modId)
if not options then return true end
local dec = SaveData.modEnabled(options, modId, scope)
if dec ~= nil then return dec == true end
local m = installedMap[modId]
return m and not m.experimental
end
-- (a) Conflicts declared by target manifest
if type(manifest.conflictSpecs) == "table" then
for _, spec in ipairs(manifest.conflictSpecs) do
local conflictId = spec.id
local installedOther = installedMap[conflictId]
if installedOther and isEnabled(conflictId) and not conflictIdsSeen[conflictId] then
conflictIdsSeen[conflictId] = true
hasIssues = true
depsResult[#depsResult + 1] = {
id = conflictId,
name = installedOther.name or conflictId,
status = "conflict",
kind = "conflict",
installedVersion = installedOther.version or "?",
github = nil,
safeUrl = nil,
}
end
end
end
-- (b) Reverse conflicts declared by installed mods against target manifest
if manifest.id then
for _, other in ipairs(manifests) do
if other.id ~= manifest.id and isEnabled(other.id) and not conflictIdsSeen[other.id] then
local conflicts = other.conflictSpecs or {}
for _, spec in ipairs(conflicts) do
if spec.id == manifest.id then
conflictIdsSeen[other.id] = true
hasIssues = true
depsResult[#depsResult + 1] = {
id = other.id,
name = other.name or other.id,
status = "conflict",
kind = "conflict",
installedVersion = other.version or "?",
github = nil,
safeUrl = nil,
}
break
end
end
end
end
end
return {
hasIssues = hasIssues,
targetMod = {
id = manifest.id,
name = manifest.name or manifest.id,
version = manifest.version or "?",
},
deps = depsResult,
}
end
-- deriveList(manifests, options [, version]) -> the panel row list, pure.
-- manifests is an array of validated manifests (Manifest.validate output);
-- options is the options table (options.mods, options.modsByVersion and
-- options.modsGen2 are read). `version` is the game the panel is showing:
-- nil keeps the pre-per-game view, where the shared flag is the whole answer.
-- options.modsGen2 are read). `version` is the game the panel is showing;
-- each row also carries its answer for every game so the launcher can render
-- the coloured game checkboxes together.
-- Rows come back sorted by id so the panel order is stable.
function LauncherMods.deriveList(manifests, options, version)
local ordered = {}
@@ -126,8 +294,7 @@ function LauncherMods.deriveList(manifests, options, version)
-- this game's choice, then the shared flag, then the default: enabled,
-- matching the loader -- except experimental mods, which stay off until
-- the player opts in. Scoped through modScope, so this reads exactly what
-- setEnabled writes and the loader loads: while per-game flags are a
-- preview the shared flag is the whole answer on every surface.
-- setEnabled writes and the loader loads for the selected game.
local decided = SaveData.modEnabled(options, m.id, SaveData.modScope(version))
if decided == nil then decided = not m.experimental end
if decided then enabledSet[m.id] = true end
@@ -152,10 +319,20 @@ function LauncherMods.deriveList(manifests, options, version)
badge = badge,
description = m.description or "",
enabled = enabled,
enabledByVersion = (function()
local answers = {}
for _, game in ipairs(GameVersion.ORDER) do
local answer = SaveData.modEnabled(options, m.id, game)
answers[game] = answer == true or (answer == nil and not m.experimental)
end
return answers
end)(),
status = status,
statusDetail = detail,
github = m.github,
experimental = m.experimental == true,
dependencySpecs = m.dependencySpecs,
manifest = m,
-- what game this mod is for, and whether it will run on the one the
-- panel is showing (src/mods/ModTargets.lua)
targets = ModTargets.chip(m),
@@ -243,7 +420,7 @@ end
-- Scan "mods/" one level deep for valid manifests (mirrors Loader:_discover,
-- but validates only -- no entry chunk is ever loaded). First id wins on a
-- duplicate. Returns an array of validated manifests.
local function discover()
discover = function()
local fs = love and love.filesystem
local out = {}
if not (fs and fs.getInfo and fs.getDirectoryItems) then return out end
@@ -281,7 +458,14 @@ end
function LauncherMods.list(version)
local ok, result = pcall(function()
local options = SaveData.loadOptions()
return LauncherMods.deriveList(discover(), options, version)
local manifests = discover()
-- The first build containing game-specific switches turns the old shared
-- state into one explicit answer per installed mod and game. Saving here
-- means users who only visit the launcher still receive the migration.
if SaveData.migrateModEnablement(options, manifests) then
SaveData.saveOptions(options)
end
return LauncherMods.deriveList(manifests, options, version)
end)
if not ok then
-- a single bad options/mod file must not blank the launcher
@@ -364,14 +548,13 @@ function LauncherMods.translationStrings()
return merged
end
-- setEnabled(id, enabled [, version]): persist options.mods[id] in the exact
-- shape Loader:_saveState writes (a plain boolean), so the running game and
-- the in-game ManagerState pick it up unchanged. With `version` the choice
-- lands in that game's overlay instead and no other game moves.
-- setEnabled(id, enabled [, version]): with a game, persist just that game's
-- answer. The loader and the in-game manager use the same scope on next boot.
function LauncherMods.setEnabled(id, enabled, version)
local options = SaveData.loadOptions()
SaveData.setModEnabled(options, id, enabled, SaveData.modScope(version))
SaveData.saveOptions(options)
LauncherMods.syncActiveProfile(options)
return true
end
@@ -384,9 +567,18 @@ function LauncherMods.setAllEnabled(ids, enabled, version)
local options = SaveData.loadOptions()
local scope = SaveData.modScope(version)
for _, id in ipairs(ids or {}) do
SaveData.setModEnabled(options, id, enabled, scope)
if scope then
SaveData.setModEnabled(options, id, enabled, scope)
elseif SaveData.PER_VERSION_MODS then
for _, game in ipairs(GameVersion.ORDER) do
SaveData.setModEnabled(options, id, enabled, game)
end
else
SaveData.setModEnabled(options, id, enabled)
end
end
SaveData.saveOptions(options)
LauncherMods.syncActiveProfile(options)
return true
end
@@ -652,9 +844,9 @@ function LauncherMods.adoptStrays() return scanStrays(true) end
-- opts.replace = true uninstalls an existing same-id mod first (updates /
-- rollbacks). opts.expectId, when set, refuses a zip whose manifest id differs.
function LauncherMods.installZip(source, opts)
local ok, result, err = pcall(LauncherMods._installZipInner, source, opts)
local ok, result, res2, res3 = pcall(LauncherMods._installZipInner, source, opts)
if not ok then return nil, "import failed: " .. tostring(result) end
return result, err
return result, res2, res3
end
function LauncherMods._installZipInner(source, opts)
@@ -764,7 +956,7 @@ function LauncherMods._installZipInner(source, opts)
return nil, copyErr or "could not copy the mod files"
end
cleanup()
return true, manifest.id
return true, manifest.id, manifest
end
-- Install (or replace) a mod from a GitHub release zip URL.
@@ -843,7 +1035,7 @@ end
-- uninstall(id) -> true | nil, errString
-- Removes mods/<id>/ from wherever it was installed (the portable game folder
-- or the save directory, CacheFs decides -- #330) and clears options.mods[id]
-- or the save directory, CacheFs decides -- #330) and clears every enable flag
-- so the loader and in-game manager no longer see it. Rejects missing ids.
-- Does not touch other mods' enable state.
function LauncherMods.uninstall(id)
@@ -872,8 +1064,164 @@ function LauncherMods.uninstall(id)
-- Drop the enable flag so a reinstall of the same id starts from the
-- loader's default (enabled) rather than a stale false.
local options = SaveData.loadOptions()
local changed = false
if options.mods and options.mods[id] ~= nil then
options.mods[id] = nil
changed = true
end
for _, version in ipairs(GameVersion.ORDER) do
local bucket = options.modsByVersion and options.modsByVersion[version]
if type(bucket) == "table" and bucket[id] ~= nil then
bucket[id] = nil
changed = true
end
end
if changed then
SaveData.saveOptions(options)
end
return true
end
-- ----------------------------------------------------------- Mod Profiles (#593)
local ModProfile = require("src.mods.ModProfile")
function LauncherMods.getProfiles(options)
options = options or SaveData.loadOptions()
local manifests = discover()
ModProfile.ensureFirst(options, manifests, options.modOptions)
return options.modProfiles or {}, options.activeProfile or "PROFILE 1"
end
function LauncherMods.applyProfile(profileName, options)
options = options or SaveData.loadOptions()
local profiles = options.modProfiles or {}
local targetProfile
for _, p in ipairs(profiles) do
if p.name == profileName then targetProfile = p; break end
end
if not targetProfile then return false end
ModProfile.restoreVersions(targetProfile, options)
options.activeProfile = profileName
SaveData.saveOptions(options)
return true
end
function LauncherMods.saveProfile(profileName, options)
options = options or SaveData.loadOptions()
local manifests = discover()
options.modProfiles = options.modProfiles or {}
local snap = ModProfile.capture(manifests, options.modOptions, options.modsByVersion)
snap.name = profileName
local existingIdx
for i, p in ipairs(options.modProfiles) do
if p.name == profileName then existingIdx = i; break end
end
if existingIdx then
options.modProfiles[existingIdx] = snap
else
options.modProfiles[#options.modProfiles + 1] = snap
end
options.activeProfile = profileName
SaveData.saveOptions(options)
return snap
end
local function copyTable(tbl)
if type(tbl) ~= "table" then return tbl end
local copy = {}
for k, v in pairs(tbl) do
copy[k] = type(v) == "table" and copyTable(v) or v
end
return copy
end
function LauncherMods.syncActiveProfile(options)
options = options or SaveData.loadOptions()
local activeName = options.activeProfile or "PROFILE 1"
local profiles = options.modProfiles or {}
local manifests = discover()
local snap = ModProfile.capture(manifests, options.modOptions, options.modsByVersion)
snap.name = activeName
local found = false
for i, p in ipairs(profiles) do
if p.name == activeName then
profiles[i] = snap
found = true
break
end
end
if not found then
profiles[#profiles + 1] = snap
end
options.modProfiles = profiles
options.activeProfile = activeName
SaveData.saveOptions(options)
return snap
end
function LauncherMods.duplicateProfile(sourceName, options)
options = options or SaveData.loadOptions()
local profiles = options.modProfiles or {}
local sourceProfile
for _, p in ipairs(profiles) do
if p.name == sourceName then sourceProfile = p; break end
end
if not sourceProfile then return nil end
local baseName = sourceName .. " (Copy)"
local newName = baseName
local n = 1
local taken = {}
for _, p in ipairs(profiles) do taken[p.name] = true end
while taken[newName] do
n = n + 1
newName = sourceName .. " (" .. n .. ")"
end
local snap = {
name = newName,
enabled = copyTable(sourceProfile.enabled),
options = copyTable(sourceProfile.options),
slots = copyTable(sourceProfile.slots),
enabledByVersion = copyTable(sourceProfile.enabledByVersion),
}
profiles[#profiles + 1] = snap
options.modProfiles = profiles
options.activeProfile = newName
SaveData.saveOptions(options)
return snap
end
function LauncherMods.renameProfile(oldName, newName, options)
options = options or SaveData.loadOptions()
if not newName or newName == "" then return false end
local profiles = options.modProfiles or {}
for i, p in ipairs(profiles) do
if p.name == oldName then
p.name = newName
if options.activeProfile == oldName then
options.activeProfile = newName
end
SaveData.saveOptions(options)
return true
end
end
return false
end
function LauncherMods.deleteProfile(profileName, options)
options = options or SaveData.loadOptions()
options.modProfiles = options.modProfiles or {}
local newProfiles = {}
for _, p in ipairs(options.modProfiles) do
if p.name ~= profileName then newProfiles[#newProfiles + 1] = p end
end
options.modProfiles = newProfiles
if options.activeProfile == profileName then
local fallback = newProfiles[1] and newProfiles[1].name or "PROFILE 1"
LauncherMods.applyProfile(fallback, options)
else
SaveData.saveOptions(options)
end
return true
+214 -67
View File
@@ -12,12 +12,15 @@ local Manifest = require("src.mods.Manifest")
local Merge = require("src.mods.Merge")
local ModTargets = require("src.mods.ModTargets")
local Registry = require("src.mods.Registry")
local SafePath = require("src.mods.SafePath")
local Sandbox = require("src.mods.Sandbox")
local Schemas = require("src.mods.Schemas")
local Semver = require("src.mods.Semver")
local Events = require("src.mods.Events")
local Gen2Compat = require("src.mods.Gen2Compat")
local Hooks = require("src.mods.Hooks")
local Runtime = require("src.mods.Runtime")
local Steps = require("src.mods.Steps")
local Loader = {}
Loader.__index = Loader
@@ -72,10 +75,12 @@ local function orderedIds(mods, filter)
return ids
end
-- ------- dev-mode permissions tripwire
-- Attribution only: the shim delegates unconditionally and blocks nothing.
-- Installed once per process and only when the loader runs in dev mode, so a
-- player build has zero interposition.
-- ------- the require gate
-- Two jobs in one interposition. The engine_internals/network scan is
-- attribution only and stays dev-mode: it warns and delegates. The
-- Sandbox.moduleDenial check is not -- require("io") would hand back
-- package.loaded.io and undo the whole mod environment -- so it is installed
-- in player builds too, for any boot that has mods on it.
local devShim = { installed = false, permissions = {}, warned = {}, depth = 0 }
@@ -135,7 +140,8 @@ end
local function scanRequire(name)
local modId = Runtime.currentMod
if not modId or type(name) ~= "string" then return end
if type(modId) ~= "string" then modId = Runtime.modRequire end
if type(modId) ~= "string" or type(name) ~= "string" then return end
local granted = devShim.permissions[modId] or {}
local function warnOnce(permission)
local key = modId .. "|" .. permission .. "|" .. name
@@ -188,6 +194,7 @@ function Loader:_installDevShim()
for id, mod in pairs(self.mods) do
devShim.permissions[id] = mod.manifest.permissionSet
end
devShim.dev = self.dev
if devShim.installed then return end
devShim.installed = true
local delegate = require
@@ -195,12 +202,22 @@ function Loader:_installDevShim()
-- only the mod's own call is the mod's doing; whatever that module
-- requires in turn is the engine wiring itself up
if devShim.depth == 0 then
scanRequire(name)
-- Backstop for the deny list Sandbox.envFor's require already applies:
-- an engine module requiring io is the engine wiring itself up, a mod
-- doing it is the hole this closes, and any future path that runs mod
-- code without a sandbox env still lands here.
local owner = Runtime.currentMod or Runtime.modRequire
if owner or callerIsMod(3) then
local id = type(owner) == "string" and owner or nil
local denial = Sandbox.moduleDenial(name, devShim.permissions[id])
if denial then error(("[%s] %s"):format(id or "mod", denial), 0) end
end
if devShim.dev or devShim.generation ~= 1 then scanRequire(name) end
-- The Gen 1 name a mod asked for, answered by the Gen 2 arm behind it.
-- Engine code keeps the real module: src/render/PaletteFX.lua:776
-- requires src.core.Game on both generations and means it.
if devShim.generation ~= 1 and Gen2Compat.serves(name)
and callerIsMod(3) then
and (owner or callerIsMod(3)) then
local adapter = Gen2Compat.resolve(name, Runtime.currentMod)
if adapter then
local key = "adapter|" .. name
@@ -235,7 +252,7 @@ function Loader.new(opts)
events = Events.new(), hooks = Hooks.new(), content = {}, assets = {},
exports = {}, migrations = {}, order = {},
modSave = {}, modOptions = {}, optionSchemas = {}, imageCache = {},
modInput = {},
modInput = {}, modEnv = {}, stepsQueues = {},
fs = (opts and opts.fs) or (love and love.filesystem),
dev = dev,
-- Which generation this boot is (1 or 2). Fixed at construction: the
@@ -270,8 +287,7 @@ function Loader:_targetVersion()
return version
end
-- The version an enable flag is read and written under: this game once
-- per-game flags are live, nil (the shared flag) while they are a preview.
-- The version an enable flag is read and written under: this running game.
-- Reads and writes go through the same answer so the two can never drift.
function Loader:_enableScope()
return SaveData.modScope(self:_targetVersion())
@@ -358,11 +374,13 @@ function Loader:_writeOptionSchemas()
-- demand; using it here means older mods do not need to migrate to
-- mod.options:define just to appear in a launcher settings screen.
if schema == nil and mod.manifest.options_schema and self.fs.load then
local chunk = self.fs.load(mod.path .. "/" .. mod.manifest.options_schema)
if chunk then
local ok, rows = pcall(chunk)
if ok and type(rows) == "table" then schema = rows end
end
local ok, rows = pcall(function()
local path = SafePath.join(mod.path, mod.manifest.options_schema,
"options_schema")
local chunk = Sandbox.loadFile(self.fs, path, self:_modEnv(mod))
return chunk and chunk()
end)
if ok and type(rows) == "table" then schema = rows end
end
if schema ~= nil then
mods[id] = schema
@@ -562,42 +580,46 @@ end
-- hard dependencies must exist, be enabled, have survived, and satisfy their
-- range; run to a fixpoint so failures propagate to dependents transitively
function Loader:_enforceDependencies()
local targetVersion = self:_targetVersion()
local generation = self.generation
local changed = true
while changed do
changed = false
for _, id in ipairs(orderedIds(self.mods, isActive)) do
local mod = self.mods[id]
for _, spec in ipairs(mod.manifest.dependencySpecs) do
local dep = self.mods[spec.id]
local reason, skip
if not dep then
reason = "missing dependency: " .. spec.id
elseif not dep.enabled then
reason = ("dependency %s is disabled"):format(spec.id)
elseif dep.state == "wrong_generation" then
-- the gate's skip is contagious as a SKIP, not as a failure: the
-- dependency has no bug to report and neither does this mod, so
-- nothing here lands on the boot error list
skip = true
-- carry the dependency's own reason: it names the game or the
-- missing gen2compat, and a guess here would name the wrong one
reason = ("depends on %s, which does not run here (%s)")
:format(spec.id, dep.skipReason or "not made for this game")
elseif dep.failed then
reason = ("dependency %s failed to load"):format(spec.id)
elseif spec.range
and not Semver.satisfies(dep.manifest.version, spec.range) then
reason = ("needs %s@%s, found %s")
:format(spec.id, spec.range, dep.manifest.version)
end
if reason then
if skip then
self:_skip(mod, "wrong_generation", reason)
else
self:_fail(mod, "blocked_dependency", reason)
if ModTargets.specApplies(spec, targetVersion, generation) then
local dep = self.mods[spec.id]
local reason, skip
if not dep then
reason = "missing dependency: " .. spec.id
elseif not dep.enabled then
reason = ("dependency %s is disabled"):format(spec.id)
elseif dep.state == "wrong_generation" then
-- the gate's skip is contagious as a SKIP, not as a failure: the
-- dependency has no bug to report and neither does this mod, so
-- nothing here lands on the boot error list
skip = true
-- carry the dependency's own reason: it names the game or the
-- missing gen2compat, and a guess here would name the wrong one
reason = ("depends on %s, which does not run here (%s)")
:format(spec.id, dep.skipReason or "not made for this game")
elseif dep.failed then
reason = ("dependency %s failed to load"):format(spec.id)
elseif spec.range
and not Semver.satisfies(dep.manifest.version, spec.range) then
reason = ("needs %s@%s, found %s")
:format(spec.id, spec.range, dep.manifest.version)
end
if reason then
if skip then
self:_skip(mod, "wrong_generation", reason)
else
self:_fail(mod, "blocked_dependency", reason)
end
changed = true
break
end
changed = true
break
end
end
end
@@ -607,6 +629,8 @@ end
-- Tarjan SCC over the hard-dependency graph: only a cycle's own members
-- fail, so an unrelated mod beside a cycle still loads
function Loader:_failCycles()
local targetVersion = self:_targetVersion()
local generation = self.generation
local mods = self.mods
local counter, stack, onStack, index, low = 0, {}, {}, {}, {}
local cycles = {}
@@ -617,14 +641,16 @@ function Loader:_failCycles()
onStack[id] = true
local selfEdge = false
for _, spec in ipairs(mods[id].manifest.dependencySpecs) do
local dep = mods[spec.id]
if spec.id == id then selfEdge = true end
if dep and isActive(dep) and spec.id ~= id then
if not index[spec.id] then
connect(spec.id)
if low[spec.id] < low[id] then low[id] = low[spec.id] end
elseif onStack[spec.id] and index[spec.id] < low[id] then
low[id] = index[spec.id]
if ModTargets.specApplies(spec, targetVersion, generation) then
local dep = mods[spec.id]
if spec.id == id then selfEdge = true end
if dep and isActive(dep) and spec.id ~= id then
if not index[spec.id] then
connect(spec.id)
if low[spec.id] < low[id] then low[id] = low[spec.id] end
elseif onStack[spec.id] and index[spec.id] < low[id] then
low[id] = index[spec.id]
end
end
end
end
@@ -675,6 +701,8 @@ end
-- Kahn over the surviving graph with the ready set kept in (priority, id)
-- order, so dependencies come first and the rest matches the v1 contract
function Loader:_order()
local targetVersion = self:_targetVersion()
local generation = self.generation
local pending, indegree, dependents = {}, {}, {}
for _, id in ipairs(orderedIds(self.mods, isActive)) do
pending[id], indegree[id] = true, 0
@@ -687,9 +715,17 @@ function Loader:_order()
dependents[depId][#dependents[depId] + 1] = id
indegree[id] = indegree[id] + 1
end
for _, spec in ipairs(manifest.dependencySpecs) do edge(spec.id) end
for _, spec in ipairs(manifest.dependencySpecs) do
if ModTargets.specApplies(spec, targetVersion, generation) then
edge(spec.id)
end
end
-- optional dependencies order without requiring anything
for _, spec in ipairs(manifest.optionalSpecs) do edge(spec.id) end
for _, spec in ipairs(manifest.optionalSpecs) do
if ModTargets.specApplies(spec, targetVersion, generation) then
edge(spec.id)
end
end
end
local ordered = {}
local function nextId()
@@ -981,6 +1017,40 @@ function Loader:_api(mod)
return DateTime.dateTime(game, timestamp)
end,
},
-- The read-only part of love.system that device UIs legitimately need.
-- Do not expose the module: openURL and clipboard access stay sandboxed.
device = {
powerInfo = function()
local getPowerInfo = love and love.system and love.system.getPowerInfo
if not getPowerInfo then return "unknown", nil end
local state, percent = getPowerInfo()
return state, percent
end,
},
-- The native step bridge (#1186), behind the "steps" permission the
-- player sees in the mod manager: sync asks the platform to refresh
-- its count, poll hands this mod its copy of what the bridge
-- delivered. The engine owns the pending file -- a mod never names a
-- path, it only receives { steps, from, to }. available() answers
-- false without the permission (a probe stays quiet); the calls that
-- would do something name the missing permission instead, the way the
-- network gate does.
steps = (function()
if mod.manifest.permissionSet.steps then
loader.stepsQueues[modId] = loader.stepsQueues[modId] or {}
return {
available = function() return Steps.available() end,
sync = function() return Steps.sync() end,
poll = function() return Steps.poll(loader, modId) end,
}
end
local function refuse()
error(('[%s] mod.steps needs the "steps" permission in '
.. "manifest.json"):format(modId), 2)
end
return { available = function() return false end,
sync = refuse, poll = refuse }
end)(),
-- namespaced per mod; M11 backs these with save.modData /
-- options.modOptions, the shape mods compile against is already final
save = {
@@ -999,7 +1069,8 @@ function Loader:_api(mod)
bucket[key] = value
end,
},
-- Data-only state independent of the vanilla progress checkpoint. The
-- Data-only and opaque-byte state independent of the vanilla progress
-- checkpoint. The
-- engine binds version/playthrough/mod scope and portable persistence;
-- callers never receive paths or a raw filesystem handle.
storage = {
@@ -1007,6 +1078,10 @@ function Loader:_api(mod)
selected = function(_, game) return storage:selected(game) end,
write = function(_, game, key, value) return storage:write(game, key, value) end,
read = function(_, game, key) return storage:read(game, key) end,
writeBytes = function(_, game, key, bytes)
return storage:writeBytes(game, key, bytes)
end,
readBytes = function(_, game, key) return storage:readBytes(game, key) end,
list = function(_, game, prefix) return storage:list(game, prefix) end,
delete = function(_, game, key) return storage:delete(game, key) end,
},
@@ -1088,12 +1163,47 @@ function Loader:_api(mod)
api.content[alias] = self:_contentApi(mod, self.content[canonical],
("the %s registry is deprecated; use %s"):format(alias, canonical))
end
-- A relative path inside this mod, or the mod root when relative is
-- omitted. Empty is the one listing case SafePath.safe rejects on
-- purpose (it is not a file), so it is special-cased here.
local function ownPath(relative, what)
if relative == nil or relative == "" then return mod.path end
return SafePath.join(mod.path, relative, what)
end
-- Shallow directory listing, the sandboxed stand-in for
-- love.filesystem.getDirectoryItems. Names only, sorted, never a host
-- path. A missing directory is an empty list, not an error.
local function listOwn(_, relative)
local dir = ownPath(relative, "mod:list")
local fs = loader.fs
if not (fs and fs.getDirectoryItems) then return {} end
local items = fs.getDirectoryItems(dir) or {}
local out = {}
for i = 1, #items do out[i] = items[i] end
table.sort(out)
return out
end
-- love.filesystem.getInfo for a path inside this mod. type is "file" or
-- "directory"; size is set for files. nil when the path does not exist.
local function infoOwn(_, relative)
local path = ownPath(relative, "mod:info")
local fs = loader.fs
if not (fs and fs.getInfo) then return nil end
local info = fs.getInfo(path)
if not info then return nil end
return { type = info.type, size = info.size }
end
-- assets keeps the v1 alias to the content accessors and adds the file
-- helpers on top, so mod.assets.pokemon and mod.assets:image both resolve
api.assets = setmetatable({
path = function(_, relative) return mod.path .. "/" .. relative end,
path = function(_, relative)
return SafePath.join(mod.path, relative, "mod.assets:path")
end,
image = function(_, relative)
local full = mod.path .. "/" .. relative
local full = SafePath.join(mod.path, relative, "mod.assets:image")
local cached = loader.imageCache[full]
if cached then return cached end
assert(love and love.graphics,
@@ -1102,11 +1212,16 @@ function Loader:_api(mod)
loader.imageCache[full] = image
return image
end,
list = listOwn,
info = infoOwn,
}, { __index = api.content })
-- the mod's own directory and nothing above it: PhysFS already refuses a
-- climb, but loader.fs is injectable and has no such floor
function api:read(relative)
local path = self.path .. "/" .. relative
return loader.fs.read(path)
return loader.fs.read(SafePath.join(self.path, relative, "mod:read"))
end
api.list = listOwn
api.info = infoOwn
-- mod.world materializes on first touch, like the image helper above: a
-- headless load must not drag the world stack in, and the Game the facade
-- acts on is still being wired when the entry chunk runs
@@ -1149,9 +1264,21 @@ function Loader:_game()
return engineRequire("src.core.Game")
end
-- The environment every chunk this mod authors runs in, built once per mod so
-- its entry file and its options_schema share one globals table.
function Loader:_modEnv(mod)
local id = mod.manifest.id
local env = self.modEnv[id]
if not env then
env = Sandbox.envFor({ modId = id, permissions = mod.manifest.permissionSet })
self.modEnv[id] = env
end
return env
end
function Loader:_loadMod(mod)
local path = mod.path .. "/" .. mod.manifest.entry
local chunk, err = self.fs.load(path)
local path = SafePath.join(mod.path, mod.manifest.entry, "manifest entry")
local chunk, err = Sandbox.loadFile(self.fs, path, self:_modEnv(mod))
if not chunk then error(err or ("unable to load " .. path)) end
local api = self:_api(mod)
local result = chunk(api)
@@ -1183,6 +1310,7 @@ function Loader:_rollback(modId)
self.optionSchemas[modId] = nil
self.migrations[modId] = nil
self.modSave[modId] = nil
self.stepsQueues[modId] = nil
end
-- a mod that explicitly swears it stays link-compatible while writing into a
@@ -1306,13 +1434,30 @@ function Loader:load(data)
require("src.mods.Builtins").install(self.content, data, self.generation)
self:_loadState()
self:_discover()
-- Existing installs stored one shared answer. Once their manifests are
-- known, split that answer across every game before the next launcher/game
-- toggle can change one independently. _loadState already used the same
-- fallback, so this write cannot change the current boot's result.
do
local options = SaveData.loadOptions(self.fs)
local installed = {}
for id, mod in pairs(self.mods) do
installed[#installed + 1] = {
id = id,
experimental = mod.manifest and mod.manifest.experimental == true,
}
end
if SaveData.migrateModEnablement(options, installed) and self.fs.write then
SaveData.saveOptions(options, self.fs)
end
end
-- Experimental mods stay off until the player opts in: a missing
-- options.mods entry normally means enabled, but experimental flips that.
do
local options = SaveData.loadOptions(self.fs)
local modsOpt = options.mods or {}
local scope = self:_enableScope()
for id, mod in pairs(self.mods) do
if not self.disabled[id] and modsOpt[id] == nil
if not self.disabled[id] and SaveData.modEnabled(options, id, scope) == nil
and mod.manifest.experimental then
self.disabled[id] = true
end
@@ -1349,10 +1494,12 @@ function Loader:load(data)
-- every touch: a mod captures the facade at file scope, before Game2 has a
-- save or a world (src/mods/Gen2Compat.lua).
Gen2Compat.bind(function() return self:_game() end)
-- Dev mode wants the permissions tripwire; a Gold boot with mods on it wants
-- the Gen 1-only require report, which is the difference between "the mod
-- does nothing" and knowing why. A Gold boot with no mods pays nothing.
if self.dev or (self.generation ~= 1 and next(self.mods) ~= nil) then
-- Any boot with mods on it needs the gate, because require("io") is how a
-- mod would walk out of Sandbox.envFor. Dev mode adds the permissions
-- tripwire on top, and a Gold boot the Gen 1-only require report -- the
-- difference between "the mod does nothing" and knowing why. A boot with no
-- mods pays nothing.
if self.dev or next(self.mods) ~= nil then
self:_installDevShim()
end
for _, mod in ipairs(ordered) do
+15 -9
View File
@@ -8,6 +8,8 @@ local Font = require("src.render.Font")
local GameVersion = require("src.core.GameVersion")
local ModTargets = require("src.mods.ModTargets")
local Runtime = require("src.mods.Runtime")
local SafePath = require("src.mods.SafePath")
local Sandbox = require("src.mods.Sandbox")
local SaveData = require("src.core.SaveData")
local Semver = require("src.mods.Semver")
local Version = require("src.core.Version")
@@ -735,8 +737,7 @@ function ManagerState:toggleGen2Force(m)
}, apply)
end
-- Where the loader persisted an enable flag: this game's slot once it keeps
-- them per game, the shared flag until then (SaveData.modScope).
-- Where the loader persists an enable flag: this running game's slot.
function ManagerState:enableScope()
return SaveData.modScope((self:targetGame()))
end
@@ -936,13 +937,18 @@ function ManagerState:schemaFor(m)
local schema = loader.optionSchemas and loader.optionSchemas[m.id]
if schema == nil and m.options_schema and m.path
and loader.fs and loader.fs.load then
local chunk = loader.fs.load(m.path .. "/" .. m.options_schema)
if chunk then
local ok, rows = pcall(chunk)
if ok and type(rows) == "table" then
schema = rows
if loader.optionSchemas then loader.optionSchemas[m.id] = schema end
end
-- mod-authored code, so it runs in the same sandbox the entry chunk does
local ok, rows = pcall(function()
local path = SafePath.join(m.path, m.options_schema, "options_schema")
local mod = loader.mods and loader.mods[m.id]
local env = mod and loader._modEnv and loader:_modEnv(mod)
or Sandbox.envFor()
local chunk = Sandbox.loadFile(loader.fs, path, env)
return chunk and chunk()
end)
if ok and type(rows) == "table" then
schema = rows
if loader.optionSchemas then loader.optionSchemas[m.id] = schema end
end
end
return schema
+80 -26
View File
@@ -3,13 +3,15 @@
-- that need to stat a file, this owns shape, vocabulary and range grammar.
local Logger = require("src.core.Logger")
local ModTargets = require("src.mods.ModTargets")
local SafePath = require("src.mods.SafePath")
local Semver = require("src.mods.Semver")
local Version = require("src.core.Version")
local Manifest = {}
Manifest.PROFILES = { content = true, overhaul = true, total_conversion = true }
Manifest.PERMISSIONS = { network = true, filesystem = true, engine_internals = true }
Manifest.PERMISSIONS = { network = true, filesystem = true,
engine_internals = true, steps = true }
-- link-relevant registries; a mod that writes into one of these while
-- declaring affects_link = false gets an attributed warning from the loader
@@ -31,26 +33,6 @@ local function violation(strict, id, message)
Logger.warn("[%s] %s", tostring(id), message)
end
-- "id" or "id@<range>"; a malformed id or range fails for every api level
-- because there is no sane fallback reading for it
local function parseSpecs(list, field)
local specs = {}
for _, entry in ipairs(list) do
assert(type(entry) == "string" and entry ~= "",
field .. " entries must be non-empty strings")
local id, range = entry:match("^([%w_%-]+)@(.+)$")
if not id then
id = entry:match("^([%w_%-]+)$")
assert(id, ("malformed %s entry %q"):format(field, entry))
range = nil
end
local ok, err = Semver.validRange(range)
assert(ok, ("malformed %s range in %q: %s"):format(field, entry, tostring(err)))
specs[#specs + 1] = { id = id, range = range }
end
return specs
end
-- Optional GitHub repo for launcher auto-update / other-versions.
-- Accepts "owner/repo" or a github.com URL; empty/absent means no updates.
function Manifest.parseGithub(value)
@@ -73,6 +55,69 @@ function Manifest.parseGithub(value)
return owner .. "/" .. repo
end
-- "id", "id@<range>", "id@<range>#<github>", "id#<github>", or table entry
local function parseSpecs(list, field, sources)
local specs = {}
sources = type(sources) == "table" and sources or {}
for _, entry in ipairs(list) do
local id, range, ghHint, gamesRaw, gameVersion
if type(entry) == "table" then
id = entry.id
range = entry.range or entry.version
ghHint = entry.github or entry.repo
gamesRaw = entry.games or entry.game
gameVersion = entry.game_version
elseif type(entry) == "string" and entry ~= "" then
local main, hashRepo = entry:match("^([^#]+)#(.*)$")
if main then
entry = main
ghHint = hashRepo
end
id, range = entry:match("^([%w_%-]+)@(.+)$")
if not id then
id = entry:match("^([%w_%-]+)$")
assert(id, ("malformed %s entry %q"):format(field, entry))
range = nil
end
else
error(field .. " entries must be non-empty strings or tables")
end
assert(id, ("malformed %s entry"):format(field))
local ok, err = Semver.validRange(range)
assert(ok, ("malformed %s range in %q: %s"):format(field, tostring(entry), tostring(err)))
if gameVersion then
local okV, errV = Semver.validRange(gameVersion)
assert(okV, ("malformed %s game_version range in %q: %s"):format(field, tostring(entry), tostring(errV)))
end
local parsedGames = nil
if gamesRaw ~= nil then
if type(gamesRaw) == "string" then gamesRaw = { gamesRaw } end
assert(type(gamesRaw) == "table", "dependency games must be a string or table")
local normalized, unknown = ModTargets.normalize(gamesRaw)
assert(#unknown == 0, ("unknown game in %s: %s"):format(field, table.concat(unknown, ", ")))
parsedGames = normalized
end
local parsedGh = nil
local rawGh = ghHint or sources[id]
if rawGh then
local okGh, cleanGh = pcall(Manifest.parseGithub, rawGh)
if okGh and cleanGh then parsedGh = cleanGh end
end
specs[#specs + 1] = {
id = id,
range = range,
github = parsedGh,
games = parsedGames,
game_version = gameVersion,
}
end
return specs
end
-- conflicts + incompatible (alias) merged, first-wins on duplicate ids
local function mergeConflictLists(conflicts, incompatible)
local seen, out = {}, {}
@@ -144,6 +189,9 @@ function Manifest.validate(raw, path)
assert(type(raw.name) == "string" and raw.name ~= "", "manifest name is required")
assert(type(raw.version) == "string" and raw.version ~= "", "manifest version is required")
assert(type(raw.entry) == "string" and raw.entry ~= "", "manifest entry is required")
-- every manifest path is joined to the mod's own directory, so none of them
-- may climb out of it (src/mods/SafePath.lua)
local entry = SafePath.require(raw.entry, "manifest entry")
-- absent means 1: full v1 compat, schema violations downgrade to warnings
assert(raw.api == nil or tonumber(raw.api) ~= nil, "manifest api must be a number")
@@ -229,27 +277,32 @@ function Manifest.validate(raw, path)
local affectsLink = profile ~= "content" and not language
if type(raw.affects_link) == "boolean" then affectsLink = raw.affects_link end
local function optionalFile(value, field)
local function optionalString(value, field)
if value == nil then return nil end
assert(type(value) == "string" and value ~= "", field .. " must be a file path")
return value
end
local function optionalFile(value, field)
local text = optionalString(value, field)
return text and SafePath.require(text, field)
end
local conflicts = mergeConflictLists(raw.conflicts, raw.incompatible)
return {
id = raw.id,
name = raw.name,
version = raw.version,
entry = raw.entry,
entry = entry,
api = api,
priority = tonumber(raw.priority) or 0,
dependencies = array(raw.dependencies),
optional_dependencies = array(raw.optional_dependencies),
conflicts = conflicts,
incompatible = array(raw.incompatible),
dependencySpecs = parseSpecs(array(raw.dependencies), "dependencies"),
optionalSpecs = parseSpecs(array(raw.optional_dependencies), "optional_dependencies"),
dependencySpecs = parseSpecs(array(raw.dependencies), "dependencies", raw.dependency_sources),
optionalSpecs = parseSpecs(array(raw.optional_dependencies), "optional_dependencies", raw.dependency_sources),
conflictSpecs = parseSpecs(conflicts, "conflicts"),
category = raw.category or "OTHER",
game_version = raw.game_version,
@@ -265,7 +318,8 @@ function Manifest.validate(raw, path)
permissionSet = permissionSet,
options_schema = optionalFile(raw.options_schema, "options_schema"),
assets_transforms = optionalFile(raw.assets_transforms, "assets_transforms"),
force_enable_env = optionalFile(raw.force_enable_env, "force_enable_env"),
-- an env var name, not a path, so it keeps the plain string check
force_enable_env = optionalString(raw.force_enable_env, "force_enable_env"),
path = path,
raw = raw,
}
+9 -3
View File
@@ -334,9 +334,15 @@ function ModIndex.compatIssues(entry, ctx)
local function eachSpec(spec, fn)
if type(spec) ~= "table" then return end
for k, v in pairs(spec) do
if type(k) == "number" and type(v) == "string" then
local id, range = v:match("^([^@]+)@(.+)$")
fn(id or v, range)
if type(k) == "number" then
if type(v) == "table" and type(v.id) == "string" then
fn(v.id, v.range or v.version, v.github or v.repo)
elseif type(v) == "string" then
local main, hashRepo = v:match("^([^#]+)#(.*)$")
if main then v = main end
local id, range = v:match("^([^@]+)@(.+)$")
fn(id or v, range, hashRepo)
end
elseif type(k) == "string" then
fn(k, type(v) == "string" and v or nil)
end
+35
View File
@@ -168,4 +168,39 @@ function ModTargets.detail(manifest, version)
ModTargets.gameLabel(version))
end
-- Does a dependency spec apply to this game / version?
-- If spec.games is provided, it must match version / generation.
-- If spec.game_version is provided, the engine version must satisfy it.
function ModTargets.specApplies(spec, version, generation)
if not spec then return false end
if type(spec.games) == "table" and #spec.games > 0 then
if version or generation then
local match = false
for _, id in ipairs(spec.games) do
if version and id == version then
match = true
break
end
if generation and GameVersion.generation(id) == generation then
match = true
break
end
end
if not match then return false end
end
end
if spec.game_version then
local ok = pcall(function()
local Semver = require("src.mods.Semver")
local Version = require("src.core.Version")
if Version and Version.engine and Version.engine:match("^0%.0%.0%-") == nil then
return Semver.satisfies(Version.engine, spec.game_version)
end
return true
end)
if not ok then return false end
end
return true
end
return ModTargets
+5
View File
@@ -28,6 +28,11 @@ Runtime.errors = nil
-- permissions tripwire knows there is nobody to attribute to
Runtime.currentMod = nil
-- set by the sandbox's require for the duration of one mod-initiated require,
-- so the loader's gate can still attribute a lazy one made long after
-- currentMod went back to nil (src/mods/Sandbox.lua)
Runtime.modRequire = nil
function Runtime.install(events, hooks, errors)
Runtime.events, Runtime.hooks = events, hooks
Runtime.errors = errors
+38
View File
@@ -0,0 +1,38 @@
-- One relative-path grammar for every path a mod supplies: a mod names files
-- inside its own directory and nowhere else. love.filesystem (PhysFS) already
-- refuses "..", absolute paths and backslashes, but Loader.new takes an
-- injected fs that has no such floor, so the rule lives here and not in
-- whichever filesystem happens to be underneath.
local SafePath = {}
-- The normalized path, or nil when it could climb out of the root it is about
-- to be joined to. "." segments are dropped rather than rejected so a
-- manifest that says "./main.lua" still loads.
function SafePath.safe(rel)
if type(rel) ~= "string" or rel == "" then return nil end
if rel:sub(1, 1) == "/" then return nil end
if rel:find("\\", 1, true) then return nil end
if rel:match("^%a:") then return nil end -- windows drive-relative
local parts = {}
for segment in rel:gmatch("[^/]+") do
if segment == ".." then return nil end
if segment ~= "." then parts[#parts + 1] = segment end
end
if #parts == 0 then return nil end
return table.concat(parts, "/")
end
function SafePath.require(rel, what)
local safe = SafePath.safe(rel)
if not safe then
error(("%s must stay inside its root, got %q"):format(what, tostring(rel)), 0)
end
return safe
end
-- root .. "/" .. rel, with the traversal check in between
function SafePath.join(root, rel, what)
return root .. "/" .. SafePath.require(rel, what or "path")
end
return SafePath
+228
View File
@@ -0,0 +1,228 @@
-- The environment a mod's own code runs in. Every chunk a mod authors -- the
-- entry file, an options_schema, anything it hands to load() -- runs against
-- this table instead of _G, so the only paths it can name are the ones the
-- engine hands it (mod:read, mod.storage, mod.assets).
--
-- What this is and is not: raw io/os/ffi are the only way to name a file
-- outside the game tree at all, and they are absent here, so the reported
-- "any mod can rewrite anything in your home directory" hole closes by
-- construction. Inside the LÖVE tree this is defense in depth, not a security
-- boundary: an engine module reached through require, or ImageData:encode,
-- still writes in the save directory.
--
-- Lua 5.1/LuaJIT is the target, so setfenv is the mechanism; the 5.2+ arm
-- exists because AssetTransform's sandbox needed it and getting this wrong
-- silently hands the chunk the real globals.
local Runtime = require("src.mods.Runtime")
local SafePath = require("src.mods.SafePath")
local Sandbox = {}
-- Modules that hand a mod the disk, a raw socket or a fresh Lua state no
-- matter what this file removes from the environment. package.loaded.io is
-- the one call that would undo every other rule here.
local DENIED = {
io = "the filesystem", os = "the filesystem", debug = "the debug library",
package = "the module loader", ffi = "arbitrary C calls",
}
-- Same idea one level up: love.filesystem is reachable by name, and
-- love.thread starts a Lua state this sandbox has no say over.
local DENIED_PREFIX = { ["love"] = true, ["ffi"] = true }
-- The wire, which is what the network permission governs.
local NETWORK = { socket = true, enet = true, http = true, https = true,
ssl = true, mime = true, ltn12 = true }
local function head(name)
return (name:match("^([^%.]+)")) or name
end
-- nil when the require is allowed, else the message to fail it with.
function Sandbox.moduleDenial(name, permissionSet)
if type(name) ~= "string" then return nil end
local root = head(name)
local reason = DENIED[root]
if reason then
return ("%s is not available to mods (it grants %s); use mod.storage, "
.. "mod:read, mod:list and the engine API instead"):format(name, reason)
end
if DENIED_PREFIX[root] and name ~= root then
return ("%s is not available to mods; use mod.storage, mod:read, mod:list "
.. "and the engine API instead"):format(name)
end
if NETWORK[root] and not (permissionSet or {}).network then
return ("%s needs the \"network\" permission in manifest.json"):format(name)
end
return nil
end
-- ------- the love facade
-- Dropped, not narrowed: filesystem writes anywhere in the save directory
-- (including another mod's storage), thread opens a Lua state with a full
-- standard library, system.openURL launches whatever it is handed, and event
-- lets a mod quit the game out from under the player. Everything else LÖVE
-- exposes passes through, so a new module in a future LÖVE is available
-- without an edit here.
-- value is the replacement to name in the error, or true when there is none
local BLOCKED_LOVE = {
filesystem = "mod.storage, mod:read and mod:list", thread = true,
system = "mod.device:powerInfo() for battery information, mod.steps for "
.. "the step bridge", event = true,
}
local loveProxy
local function loveFacade()
if loveProxy or not _G.love then return loveProxy end
loveProxy = setmetatable({}, {
__index = function(_, key)
local hint = BLOCKED_LOVE[key]
if hint then
error(("love.%s is not available to mods%s"):format(key,
type(hint) == "string" and (", use " .. hint) or ""), 2)
end
return _G.love[key]
end,
__newindex = function(_, key)
error(("mods cannot assign love.%s"):format(tostring(key)), 2)
end,
})
return loveProxy
end
-- ------- the environment
-- Absent on purpose: io, package, dofile, loadfile, getfenv, setfenv, debug,
-- newproxy, module. os keeps only the clock -- getenv is how the reported
-- exploit found the user's home directory.
local SAFE_OS = { time = true, date = true, clock = true, difftime = true }
-- Per-mod copies, not the shared tables: a mod that assigns string.trim or
-- replaces table.insert changes its own view and nobody else's. The functions
-- are the same objects, so state behind them (math.randomseed's RNG) is
-- unaffected -- only the namespace is private.
local function copy(source)
if type(source) ~= "table" then return source end
local out = {}
for key, value in pairs(source) do out[key] = value end
return out
end
local function baseGlobals()
local safeOs = {}
for key in pairs(SAFE_OS) do safeOs[key] = os[key] end
return {
assert = assert, error = error, ipairs = ipairs, next = next,
pairs = pairs, pcall = pcall, xpcall = xpcall, select = select,
tonumber = tonumber, tostring = tostring, type = type, unpack = unpack,
rawequal = rawequal, rawget = rawget, rawset = rawset, rawlen = rawlen,
setmetatable = setmetatable, getmetatable = getmetatable, print = print,
collectgarbage = collectgarbage, _VERSION = _VERSION,
coroutine = copy(coroutine), math = copy(math), string = copy(string),
table = copy(table), bit = copy(bit), jit = jit, os = safeOs,
}
end
-- setfenv on 5.1/LuaJIT; on 5.2+ the env has to be handed to load itself, so
-- a caller there compiles through Sandbox.compile instead.
function Sandbox.bind(chunk, env)
if setfenv then setfenv(chunk, env) end
return chunk
end
-- Bytecode is unreviewable and, on LuaJIT, a way out of any sandbox built out
-- of environments. Mods ship source.
local function rejectBytecode(source, what)
if type(source) == "string" and source:sub(1, 1) == "\27" then
return nil, (what or "chunk") .. ": mods must ship Lua source, not bytecode"
end
return true
end
function Sandbox.compile(source, chunkname, env)
local ok, err = rejectBytecode(source, chunkname)
if not ok then return nil, err end
if setfenv then
local chunk, compileErr = loadstring(source, chunkname)
if not chunk then return nil, compileErr end
return setfenv(chunk, env)
end
return load(source, chunkname, "t", env)
end
-- The load() a mod sees. Lua 5.1 gives a loaded chunk the GLOBAL environment
-- rather than the caller's, so without this every sandboxed mod is one
-- load(mod:read(...)) away from the real _G -- which is exactly how the
-- multi-file mods in mods/ are written.
local function sandboxedLoad(env)
return function(chunk, chunkname)
if type(chunk) == "function" then
local parts = {}
while true do
local piece = chunk()
if piece == nil or piece == "" then break end
parts[#parts + 1] = piece
end
chunk = table.concat(parts)
end
if type(chunk) ~= "string" then return nil, "load expects a string or reader" end
return Sandbox.compile(chunk, chunkname or "=(load)", env)
end
end
-- The require a mod sees: the deny list lives here rather than on a stack
-- walk, because pcall(require, "io") puts a C frame where the walk would look.
-- Runtime.modRequire is how the loader's gate identifies the caller for the
-- Gen 2 facade once Runtime.currentMod has gone back to nil (a mod requiring
-- lazily from an event handler).
local function sandboxedRequire(modId, permissionSet)
return function(name, ...)
local denial = Sandbox.moduleDenial(name, permissionSet)
if denial then error(("[%s] %s"):format(modId or "mod", denial), 2) end
local previous = Runtime.modRequire
Runtime.modRequire = modId or true
local ok, result = pcall(_G.require, name, ...)
Runtime.modRequire = previous
if not ok then error(result, 0) end
return result
end
end
function Sandbox.envFor(opts)
opts = opts or {}
local env = baseGlobals()
env.love = loveFacade()
env.require = sandboxedRequire(opts.modId, opts.permissions)
local loader = sandboxedLoad(env)
env.load = loader
env.loadstring = loader
-- a mod's globals are its own: two mods no longer share a namespace, and
-- neither can reach the engine's
env._G = env
return env
end
-- fs.load keeps the real filesystem's handling of the file; the environment is
-- swapped after the fact. The 5.2+ arm has to go back to source, which is the
-- only reason fs.read is touched here.
function Sandbox.loadFile(fs, path, env)
if fs.read then
local ok, err = rejectBytecode(fs.read(path), path)
if not ok then return nil, err end
end
if setfenv then
local chunk, err = fs.load(path)
if not chunk then return nil, err end
return setfenv(chunk, env)
end
local source = fs.read and fs.read(path)
if not source then return nil, "unable to read " .. path end
return Sandbox.compile(source, "@" .. path, env)
end
Sandbox.safePath = SafePath.safe
Sandbox.requirePath = SafePath.require
return Sandbox
+8
View File
@@ -1017,6 +1017,9 @@ R.trainers = {
index = f.opt(f.int(0, 255)),
-- unused vanilla classes ship without a pic, so it cannot be required
pic = f.opt(f.path),
-- Full-color portrait: skip the 4-shade SGB/GBC remap, same flag pokemon
-- and sprites already carry.
trueColor = f.opt(f.bool),
-- Optional Advanced-mode OBJ palette source for a custom trainer portrait.
-- It follows the same ROM crosswalk form as sprites.paletteSource.
paletteSource = f.opt(f.str),
@@ -1068,6 +1071,11 @@ R.trainers = {
gen2Fields = {
id = f.opt(f.str), name = f.str,
index = f.opt(f.int(0, 255)),
-- class frontpic; when set, this wins over menu_gfx.battleHud.trainerPics
pic = f.opt(f.path),
-- Full-color portrait: skip the GBC 4-shade remap, same flag Gen 1
-- trainers and pokemon already carry.
trueColor = f.opt(f.bool),
baseMoney = f.opt(f.int(0)),
-- the class's battle theme; Gen 1 spells the same idea `battleTheme`,
-- but this is the extractor's own key and a strict rename would reject
+84
View File
@@ -0,0 +1,84 @@
-- The scoped seam for the native step bridge (#1186).
--
-- The iOS/Android builds count the player's real-world steps natively
-- (#452, #489) and deliver them by writing steps_pending.json into the
-- save-directory root. Before the sandbox, the Pokéwalker mod called
-- love.system.syncHealthSteps() and consumed that file itself; the sandbox
-- blocks both, which is correct -- love.system launches URLs and the file
-- API names paths -- but it left the bridge with no consumer at all.
--
-- This module is the narrow replacement, gated by the "steps" permission
-- in manifest.json (the network model: a permission the player sees that
-- genuinely gates a capability). The engine owns the file: mods never
-- learn its name or location, they receive only the three contract fields
-- ({ steps, from, to }), each permissioned mod gets its own copy, and the
-- merge-don't-overwrite anchor semantics stay on the native side where
-- they always lived.
--
-- No frame pump: the file is looked for lazily when a mod polls, so a
-- build with no permissioned mod installed never touches the bridge or
-- the disk.
local Json = require("src.link.Json")
local Steps = {}
-- The native contract's drop point, in the save-directory root (see
-- mobile/ios and mobile/android step bridges).
Steps.PENDING = "steps_pending.json"
local function bridge()
return _G.love and _G.love.system and _G.love.system.syncHealthSteps
end
-- Whether this build carries the native bridge. Desktop builds do not;
-- a mod uses this to stay dormant without probing love.system.
function Steps.available()
return bridge() ~= nil
end
-- Ask the native side to refresh its count. Async: the result lands in
-- the pending file and comes back through a later poll. The platform's
-- own consent sheet (HealthKit / ACTIVITY_RECOGNITION) still appears on
-- first use, exactly as it did pre-sandbox. false when there is no
-- bridge to ask.
function Steps.sync()
local fn = bridge()
if not fn then return false end
fn()
return true
end
-- Consume the pending file, if one has appeared, and fan its payload out
-- to every permissioned mod's queue. Only the contract fields travel;
-- anything else in the file stays in the file's grave. A malformed or
-- empty delivery is dropped whole -- the native anchor only advances on a
-- successful sync, so nothing is lost to a bad write.
function Steps.pump(loader)
local fs = _G.love and _G.love.filesystem
if not (fs and fs.getInfo(Steps.PENDING, "file")) then return end
local raw = fs.read(Steps.PENDING)
fs.remove(Steps.PENDING)
if not raw then return end
local ok, decoded = pcall(Json.decode, raw)
if not ok or type(decoded) ~= "table" then return end
local steps = tonumber(decoded.steps)
if not steps or steps <= 0 then return end
local payload = { steps = steps, from = decoded.from, to = decoded.to }
for _, queue in pairs(loader.stepsQueues) do
queue[#queue + 1] = { steps = payload.steps, from = payload.from,
to = payload.to }
end
end
-- The next delivery for this mod, or nil. Each permissioned mod consumes
-- its own queue, so two mods both see the same walk (pre-sandbox, whoever
-- read the file first won).
function Steps.poll(loader, modId)
Steps.pump(loader)
local queue = loader.stepsQueues[modId]
if not queue then return nil end
return table.remove(queue, 1)
end
return Steps
+132 -11
View File
@@ -1,4 +1,5 @@
-- Data-only per-mod persistence, scoped by game version and opaque playthrough.
-- Data-only and opaque-byte per-mod persistence, scoped by game version and
-- opaque playthrough.
-- This module is engine-private; Loader exposes only the bound facade methods.
local SaveData = require("src.core.SaveData")
@@ -7,6 +8,7 @@ local Version = require("src.core.Version")
local Storage = {}
Storage.__index = Storage
Storage.MAX_BYTES = 512 * 1024 * 1024
local ROOT = "mod_storage"
@@ -49,6 +51,20 @@ local function decodeAt(fs, path)
return data, body
end
local function readOpaqueAt(fs, path)
if not (fs.getInfo and fs.getInfo(path)) then return nil end
local body = fs.read and fs.read(path)
if type(body) ~= "string" then return nil end
return body
end
local function hasAny(fs, paths)
for _, path in ipairs(paths) do
if fs.getInfo(path) then return true end
end
return false
end
function Storage.new(modId, fs)
assert(validSegment(modId), "Storage.new needs a safe mod id")
return setmetatable({ modId = modId, injectedFs = fs }, Storage)
@@ -132,6 +148,10 @@ function Storage:selected(game)
end,
read = function(_, key) return self:read(selectedGame, key) end,
write = function(_, key, value) return self:write(selectedGame, key, value) end,
readBytes = function(_, key) return self:readBytes(selectedGame, key) end,
writeBytes = function(_, key, bytes)
return self:writeBytes(selectedGame, key, bytes)
end,
list = function(_, prefix) return self:list(selectedGame, prefix) end,
delete = function(_, key) return self:delete(selectedGame, key) end,
}
@@ -147,7 +167,7 @@ function Storage:context(game)
}
end
function Storage:_names(game, key, allowEmpty)
function Storage:_names(game, key, allowEmpty, extension)
if not validKey(key, allowEmpty) then
return failure("invalid_key",
"Storage keys use nonempty letters, numbers, underscore, dash and slash segments.")
@@ -155,12 +175,19 @@ function Storage:_names(game, key, allowEmpty)
local scope, code, message = self:_scope(game)
if not scope then return nil, code, message end
local path = scope.base .. (key ~= "" and ("/" .. key) or "")
return scope, path .. ".lua", path .. ".lua.bak", path .. ".lua.tmp"
extension = extension or ".lua"
return scope, path .. extension, path .. extension .. ".bak",
path .. extension .. ".tmp", path
end
function Storage:write(game, key, value)
local scope, main, bak, tmp = self:_names(game, key, false)
local scope, main, bak, tmp, path = self:_names(game, key, false)
if not scope then return false, main, bak end
local fs = scope.fs
if hasAny(fs, { path .. ".bin", path .. ".bin.bak", path .. ".bin.tmp" }) then
return false, "type_conflict",
"A byte value already exists for this storage key; delete it first."
end
if type(value) ~= "table" then
return false, "encode_failed", "Storage values must be data-only tables."
end
@@ -170,7 +197,6 @@ function Storage:write(game, key, value)
.. tostring(encoded)
end
local fs = scope.fs
ensureParent(fs, main)
local _, previous = decodeAt(fs, main)
if not previous then _, previous = decodeAt(fs, bak) end
@@ -206,9 +232,13 @@ function Storage:write(game, key, value)
end
function Storage:read(game, key)
local scope, main, bak, tmp = self:_names(game, key, false)
local scope, main, bak, tmp, path = self:_names(game, key, false)
if not scope then return nil, main, bak end
local fs = scope.fs
if hasAny(fs, { path .. ".bin", path .. ".bin.bak", path .. ".bin.tmp" }) then
return failure("type_mismatch",
"This storage key contains opaque bytes; use readBytes instead.")
end
local data, body = decodeAt(fs, main)
if data then return data end
@@ -226,6 +256,80 @@ function Storage:read(game, key)
return data
end
function Storage:writeBytes(game, key, bytes)
local scope, main, bak, tmp, path = self:_names(game, key, false, ".bin")
if not scope then return false, main, bak end
if type(bytes) ~= "string" then
return false, "invalid_bytes", "Opaque storage values must be strings."
end
if #bytes > Storage.MAX_BYTES then
return false, "size_limit",
("Opaque storage values cannot exceed %d bytes."):format(Storage.MAX_BYTES)
end
local fs = scope.fs
if hasAny(fs, { path .. ".lua", path .. ".lua.bak", path .. ".lua.tmp" }) then
return false, "type_conflict",
"A table value already exists for this storage key; delete it first."
end
ensureParent(fs, main)
local previous = readOpaqueAt(fs, main)
if previous == nil then previous = readOpaqueAt(fs, bak) end
local ok, err = fs.write(tmp, bytes)
if not ok then
return false, "write_failed", "Could not stage opaque storage data: " .. tostring(err)
end
local staged = readOpaqueAt(fs, tmp)
if staged == nil or staged ~= bytes then
remove(fs, tmp)
return false, "verify_failed", "Staged opaque storage data could not be verified."
end
if previous ~= nil then fs.write(bak, previous) end
ok, err = fs.write(main, bytes)
if not ok then
remove(fs, tmp)
return false, "write_failed",
"Could not replace opaque storage data: " .. tostring(err)
end
local verified = readOpaqueAt(fs, main)
if verified == nil or verified ~= bytes then
remove(fs, main)
remove(fs, tmp)
return false, "verify_failed",
"Replacement opaque storage data could not be verified."
end
fs.write(bak, bytes)
remove(fs, tmp)
return true
end
function Storage:readBytes(game, key)
local scope, main, bak, tmp, path = self:_names(game, key, false, ".bin")
if not scope then return nil, main, bak end
local fs = scope.fs
if hasAny(fs, { path .. ".lua", path .. ".lua.bak", path .. ".lua.tmp" }) then
return failure("type_mismatch",
"This storage key contains table data; use read instead.")
end
local bytes = readOpaqueAt(fs, main)
if bytes ~= nil then return bytes end
bytes = readOpaqueAt(fs, tmp)
if bytes == nil then bytes = readOpaqueAt(fs, bak) end
if bytes == nil then
return nil, "not_found", "No valid opaque value exists for this key."
end
ensureParent(fs, main)
if fs.write(main, bytes) then fs.write(bak, bytes) end
remove(fs, tmp)
return bytes
end
function Storage:list(game, prefix)
prefix = prefix or ""
local scope, main, codeOrBak = self:_names(game, prefix, true)
@@ -237,13 +341,23 @@ function Storage:list(game, prefix)
local base = scope.base
local start = prefix == "" and base or (base .. "/" .. prefix)
local out = {}
local out, seen = {}, {}
local function add(logical)
if not seen[logical] then
seen[logical] = true
out[#out + 1] = logical
end
end
local function walk(path, logical)
local info = fs.getInfo(path)
if not info then return end
if info.type == "file" then
if path:sub(-4) == ".lua" then out[#out + 1] = logical:sub(1, -5) end
local suffix = path:sub(-4)
if suffix == ".lua" or suffix == ".bin" then
add(logical:sub(1, -5))
end
return
end
for _, child in ipairs(fs.getDirectoryItems(path) or {}) do
@@ -254,7 +368,9 @@ function Storage:list(game, prefix)
-- A prefix may identify one exact key or a directory of keys.
if fs.getInfo(start .. ".lua") then
out[#out + 1] = prefix
add(prefix)
elseif fs.getInfo(start .. ".bin") then
add(prefix)
else
walk(start, prefix)
end
@@ -263,15 +379,20 @@ function Storage:list(game, prefix)
end
function Storage:delete(game, key)
local scope, main, bak, tmp = self:_names(game, key, false)
local scope, main, bak, tmp, path = self:_names(game, key, false)
if not scope then return false, main, bak end
local fs = scope.fs
if not (fs.getInfo(main) or fs.getInfo(bak) or fs.getInfo(tmp)) then
local byteMain, byteBak, byteTmp = path .. ".bin", path .. ".bin.bak", path .. ".bin.tmp"
if not (fs.getInfo(main) or fs.getInfo(bak) or fs.getInfo(tmp)
or fs.getInfo(byteMain) or fs.getInfo(byteBak) or fs.getInfo(byteTmp)) then
return false, "not_found", "No stored value exists for this key."
end
remove(fs, main)
remove(fs, bak)
remove(fs, tmp)
remove(fs, byteMain)
remove(fs, byteBak)
remove(fs, byteTmp)
return true
end
+4 -1
View File
@@ -140,9 +140,12 @@ function Evolution.learnEvolutionMoves(game, mon, onDone)
if #mon.moves < 4 then
table.insert(mon.moves, { id = moveId, pp = mdef.pp })
Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId })
-- LearnedMove1Text: text_far, sound_get_item_1, text_promptbutton
-- (learn_move.asm), so the jingle rides the box
game.stack:push(TextBox.new(game,
romText(game.data, "_LearnedMove1Text",
"%s learned\n%s!", name, mdef.name), nextStep))
"%s learned\n%s!", name, mdef.name), nextStep,
TextBox.soundOpts(game, "Get_Item1")))
else
-- LearnMoveFromLevelUp with a full moveset: the forget UI
Screens.push(game, "MoveLearnMenu", mon, moveId, nextStep)
+9 -2
View File
@@ -141,13 +141,20 @@ end
-- Tinting first would double-apply the color: GREENBAR's fill {0,189,0} has
-- red channel 0, so the tint zeroes the whole bar's red and the zone's
-- red-channel-keyed shade shader then maps every pixel to color 3 = black.
function HudTiles.drawHPBar(data, tx, ty, mon, barType, grayFill, segments)
--
-- pixels: an explicit 0..48 bar length on GetHPBarLength's scale, for a
-- caller that is animating the bar between two HP values
-- (UpdateHPBar_AnimateHPBar); it scales with `segments` like the color
-- thresholds do. Without it the length comes from mon.hp as before.
function HudTiles.drawHPBar(data, tx, ty, mon, barType, grayFill, segments, pixels)
local x, y = tx * 8, ty * 8
segments = math.max(1, math.floor(segments or 6))
HudTiles.tile(0x71, x, y)
HudTiles.tile(0x62, x + 8, y)
local px = 0
if mon.stats.hp > 0 and mon.hp > 0 then
if pixels then
px = math.max(0, math.floor(pixels * segments / 6))
elseif mon.stats.hp > 0 and mon.hp > 0 then
px = math.max(1, math.floor(mon.hp * segments * 8 / mon.stats.hp))
end
local tint
+6 -4
View File
@@ -691,10 +691,12 @@ function PaletteFX.spriteObp(spriteDef, seed)
return PaletteFX.darkObp(w.spritePalettes[group], group)
end
-- GetHealthBarColor (home/palettes.asm) on the standard 48px bar
function PaletteFX.barPalName(hp, maxHp)
local px = maxHp > 0 and math.floor(hp * 48 / maxHp) or 0
if hp > 0 and px < 1 then px = 1 end
-- GetHealthBarColor (home/palettes.asm) on the standard 48px bar. It reads
-- the bar's own length, so a caller mid-drain passes the animated `pixels`
-- rather than let it be re-derived from hp.
function PaletteFX.barPalName(hp, maxHp, pixels)
local px = pixels or (maxHp > 0 and math.floor(hp * 48 / maxHp) or 0)
if not pixels and hp > 0 and px < 1 then px = 1 end
return px >= 27 and "GREENBAR" or px >= 10 and "YELLOWBAR" or "REDBAR"
end
+25 -13
View File
@@ -768,11 +768,12 @@ function Renderer:endFrame(zones, worldZones)
end
end
-- A post-process pipeline needs the whole composite in a canvas for the
-- same reason GBC FX does, so either one alone is enough to take the
-- present path; with neither, the frame draws straight to the screen
-- exactly as it always did.
local needPresent = GBCFX.active() or Pipelines.wantsPresent()
-- Post-process pipelines, GBC FX and an enabled final-output owner need the
-- whole composite in a canvas. With none of them, the frame draws straight
-- to the screen exactly as it always did.
local hasOutputHook = Runtime.wantsHook("render.output")
and Runtime.call("render.output_enabled", function() return false end) == true
local needPresent = GBCFX.active() or Pipelines.wantsPresent() or hasOutputHook
local present = nil
if needPresent then
if not self.presentCanvas or self.presentCanvas:getWidth() ~= ww
@@ -1057,14 +1058,25 @@ function Renderer:endFrame(zones, worldZones)
-- this returns `present` unchanged and the frame is byte-identical.
local composed = Pipelines.present(present,
{ width = ww, height = wh, scale = Sp, dpi = dpiY, dpiX = dpiX, dpiY = dpiY }) or present
if GBCFX.active() then
-- shader grid/shadow math is in framebuffer pixels
GBCFX.present(composed, Sp)
else
-- the present canvas only existed for the post-process, so put the
-- result on the screen at the same 1:1 unit mapping it was built at
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(composed, 0, 0)
local outputHandled = hasOutputHook
and Runtime.call("render.output", function() return false end, {
canvas = composed,
width = ww, height = wh,
gameX = ox, gameY = oy,
gameWidth = vpw, gameHeight = vph,
scale = Sp, dpiX = dpiX, dpiY = dpiY,
generation = 1,
}) == true
if not outputHandled then
if GBCFX.active() then
-- shader grid/shadow math is in framebuffer pixels
GBCFX.present(composed, Sp)
else
-- the present canvas only existed for the post-process, so put the
-- result on the screen at the same 1:1 unit mapping it was built at
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(composed, 0, 0)
end
end
end
self.worldActive = false
+6 -1
View File
@@ -62,6 +62,8 @@ local function getObpImage(path, colors, group)
return obpCache[key]
end
SpriteRenderer.obpImage = getObpImage
-- hot reload drops the sheets; live instances hold their own image, so
-- the world rebuilds them (MapLoader.invalidateAll) rather than this
function SpriteRenderer.invalidate()
@@ -281,7 +283,7 @@ end
-- swapped and OAM_XFLIP on each (data/sprites/facings.asm:192-197). Optional
-- and trailing, so every existing call site is unchanged.
function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip,
topHalf, forceFlip)
topHalf, forceFlip, frameOverride)
local x, y = self:getScreenOrigin(px, py, camX, camY)
local image = self.image
local redraw = false
@@ -329,6 +331,9 @@ function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip,
-- still 3-frame sprites turn to face (the nurse at her machine,
-- facePlayer on STAY NPCs) but never show walk frames.
local frame, flip = pose(self, facing, walkPhase, stepFlip)
if frameOverride and self.frames[frameOverride] then
frame, flip = frameOverride, false
end
if forceFlip then flip = true end
local quad = self.frames[frame]
local drawHeight = self.frameHeight
+21
View File
@@ -100,6 +100,26 @@ function TextBox.new(game, text, onDone, opts)
return self
end
-- soundOpts: a jingle carried at the end of the string as a trailing text
-- command (sound_get_item_1 and friends -> home/text.asm TextCommand_SOUND),
-- which runs PlaySound then WaitForSoundToFinish once the last page has
-- typed, so the box holds until the fanfare is over. Merges into a caller's
-- opts table; auto.wait keeps the trailing button press the plain A/B path
-- gives every other box.
function TextBox.soundOpts(game, sound, opts)
opts = opts or {}
local auto = opts.auto
-- auto = true is the no-button-wait arming (text_opts); keep that choice
if auto == true then auto = { wait = false } end
auto = auto or {}
if auto.wait == nil then auto.wait = true end
auto.delay = auto.delay or 0
auto.sound = type(sound) == "function" and sound
or function() return require("src.core.Sound").play(game.data, sound) end
opts.auto = auto
return opts
end
-- The runtime tokens substitute() knows, as handlers the tokens registry
-- serves. Each is fn(game, arg) -> replacement, or nil to drop the token.
-- RAM keeps pokered's stale-buffer semantics: give_item copies the item
@@ -130,6 +150,7 @@ TextBox.TOKENS = {
STRBUF = function(game) return game.stringBuffer end,
RAM = function(game, arg)
if arg == "wStringBuffer" then return game.stringBuffer end
if arg == "wNameBuffer" then return game.stringBuffer end
if arg == "wBoxNumString" then return game.boxNumString end
-- SendNewMonToBox / _SentToBoxText reads the deposited nick here
if arg == "wBoxMonNicks" then return game.boxMonNicks end
+18
View File
@@ -100,6 +100,7 @@ O.tradeFlags = O.townVisited + 44 -- 2B (flag_array NUM_
-- Sits 2 bytes (wPlayerCoins) past O.coins per the walk above; absolute
-- 0x2852 (#763, #857).
O.toggleObjectFlags = O.coins + 2 -- 32B
O.hiddenItemFlags = O.townVisited - 27
-- Play time (wPlayTimeHours/Maxed/Minutes/Seconds/Frames) lives INSIDE the
-- sMainData window (wMainDataStart..wMainDataEnd is copied verbatim into
-- SRAM), 1866 bytes past wMainDataStart -- reached from the checksum-verified
@@ -770,6 +771,15 @@ function GenSave.decode(bytes, data, opts)
end
end
if data.hiddenItems then
save.hiddenTaken = {}
for i, row in ipairs(data.hiddenItems) do
if bitGet(bytes, O.hiddenItemFlags, i - 1) then
save.hiddenTaken[row[1] .. "_" .. row[2] .. "_" .. row[3]] = true
end
end
end
-- FLY destinations. wTownVisitedFlag's bit index IS the town's map index:
-- engine/items/town_map.asm BuildFlyLocationsList loads the 16-bit value
-- into de and rotates it right one bit per iteration with b counting up
@@ -953,6 +963,14 @@ function GenSave.encode(save, data, template)
end
end
if data.hiddenItems then
local taken = save.hiddenTaken or {}
for i, row in ipairs(data.hiddenItems) do
local key = row[1] .. "_" .. row[2] .. "_" .. row[3]
bitSet(buf, O.hiddenItemFlags, i - 1, taken[key] and true or false)
end
end
-- FLY destinations back into wTownVisitedFlag (see the decode note), so a
-- save exported from this port is flyable on hardware (#263). A save
-- table with no `visited` key at all says nothing about the set, so leave
+7
View File
@@ -49,6 +49,7 @@ local DATA_MODULES = {
charmap = { "src.save_convert.data.charmap", "src/save_convert/data/charmap.lua" },
eventFlags = { "src.save_convert.data.event_flags", "src/save_convert/data/event_flags.lua" },
toggleObjects = { "src.save_convert.data.toggle_objects", "src/save_convert/data/toggle_objects.lua" },
hiddenItems = { "src.save_convert.data.hidden_items", "src/save_convert/data/hidden_items.lua" },
}
local OPTIONAL_MODULES = { tilesets = true, audio = true }
@@ -64,6 +65,10 @@ local YELLOW_EVENT_FLAGS = {
"src.save_convert.data.event_flags_yellow",
"src/save_convert/data/event_flags_yellow.lua",
}
local YELLOW_HIDDEN_ITEMS = {
"src.save_convert.data.hidden_items_yellow",
"src/save_convert/data/hidden_items_yellow.lua",
}
local function loadTable(requirePath, filePath)
local ok, mod = pcall(require, requirePath)
@@ -133,6 +138,8 @@ local function ensureData(gameVersion)
if name ~= "charmap" then
if name == "eventFlags" and gameVersion == "yellow" then
spec = YELLOW_EVENT_FLAGS -- Yellow's bit numbering differs (#838)
elseif name == "hiddenItems" and gameVersion == "yellow" then
spec = YELLOW_HIDDEN_ITEMS
end
local mod = loadCacheTable(gameVersion, spec[2])
if not mod then
+30
View File
@@ -0,0 +1,30 @@
return {
{ "VIRIDIAN_FOREST", 1, 18 }, { "VIRIDIAN_FOREST", 16, 42 },
{ "MT_MOON_B2F", 18, 12 }, { "ROUTE_25", 38, 3 },
{ "ROUTE_9", 14, 7 }, { "SS_ANNE_KITCHEN", 13, 9 },
{ "SS_ANNE_B1F_ROOMS", 3, 1 }, { "ROUTE_10", 9, 17 },
{ "ROUTE_10", 16, 53 }, { "ROCKET_HIDEOUT_B1F", 21, 15 },
{ "ROCKET_HIDEOUT_B3F", 27, 17 }, { "ROCKET_HIDEOUT_B4F", 25, 1 },
{ "POKEMON_TOWER_5F", 4, 12 }, { "ROUTE_13", 1, 14 },
{ "ROUTE_13", 16, 13 }, { "POKEMON_MANSION_B1F", 1, 9 },
{ "SAFARI_ZONE_GATE", 10, 1 }, { "SAFARI_ZONE_WEST", 6, 5 },
{ "SILPH_CO_5F", 12, 3 }, { "SILPH_CO_9F", 2, 15 },
{ "COPYCATS_HOUSE_2F", 1, 1 }, { "CERULEAN_CAVE_1F", 14, 11 },
{ "CERULEAN_CAVE_B1F", 27, 3 }, { "POWER_PLANT", 17, 16 },
{ "POWER_PLANT", 12, 1 }, { "SEAFOAM_ISLANDS_B2F", 15, 15 },
{ "SEAFOAM_ISLANDS_B4F", 25, 17 }, { "POKEMON_MANSION_1F", 8, 16 },
{ "POKEMON_MANSION_3F", 1, 9 }, { "ROUTE_23", 9, 44 },
{ "ROUTE_23", 19, 70 }, { "ROUTE_23", 8, 90 },
{ "VICTORY_ROAD_2F", 5, 2 }, { "VICTORY_ROAD_2F", 26, 7 },
{ "UNUSED_MAP_6F", 14, 11 }, { "VIRIDIAN_CITY", 14, 4 },
{ "ROUTE_11", 48, 5 }, { "ROUTE_12", 2, 63 },
{ "ROUTE_17", 15, 14 }, { "ROUTE_17", 8, 45 },
{ "ROUTE_17", 17, 72 }, { "ROUTE_17", 4, 91 },
{ "ROUTE_17", 8, 121 }, { "UNDERGROUND_PATH_NORTH_SOUTH", 3, 4 },
{ "UNDERGROUND_PATH_NORTH_SOUTH", 4, 34 },
{ "UNDERGROUND_PATH_WEST_EAST", 12, 2 },
{ "UNDERGROUND_PATH_WEST_EAST", 21, 5 }, { "CELADON_CITY", 48, 15 },
{ "ROUTE_25", 10, 1 }, { "MT_MOON_B2F", 33, 9 },
{ "SEAFOAM_ISLANDS_B3F", 9, 16 }, { "VERMILION_CITY", 14, 11 },
{ "CERULEAN_CITY", 15, 8 }, { "ROUTE_4", 40, 3 },
}
@@ -0,0 +1,31 @@
return {
{ "SILPH_CO_5F", 12, 3 }, { "SILPH_CO_9F", 2, 15 },
{ "POKEMON_MANSION_3F", 1, 9 }, { "POKEMON_MANSION_B1F", 1, 9 },
{ "SAFARI_ZONE_WEST", 6, 5 }, { "CERULEAN_CAVE_2F", 16, 13 },
{ "CERULEAN_CAVE_B1F", 8, 14 }, { "UNUSED_MAP_6F", 14, 11 },
{ "SEAFOAM_ISLANDS_B2F", 15, 15 }, { "SEAFOAM_ISLANDS_B3F", 9, 16 },
{ "SEAFOAM_ISLANDS_B4F", 25, 17 }, { "VIRIDIAN_FOREST", 1, 18 },
{ "VIRIDIAN_FOREST", 16, 42 }, { "MT_MOON_B2F", 18, 12 },
{ "MT_MOON_B2F", 33, 9 }, { "SS_ANNE_B1F_ROOMS", 3, 1 },
{ "SS_ANNE_KITCHEN", 13, 9 }, { "UNDERGROUND_PATH_NORTH_SOUTH", 3, 4 },
{ "UNDERGROUND_PATH_NORTH_SOUTH", 4, 34 },
{ "UNDERGROUND_PATH_WEST_EAST", 12, 2 },
{ "UNDERGROUND_PATH_WEST_EAST", 21, 5 }, { "ROCKET_HIDEOUT_B1F", 21, 15 },
{ "ROCKET_HIDEOUT_B3F", 27, 17 }, { "ROCKET_HIDEOUT_B4F", 25, 1 },
{ "ROUTE_10", 9, 17 }, { "ROUTE_10", 16, 53 },
{ "POWER_PLANT", 17, 16 }, { "POWER_PLANT", 12, 1 },
{ "ROUTE_11", 48, 5 }, { "ROUTE_12", 2, 63 },
{ "ROUTE_13", 1, 14 }, { "ROUTE_13", 16, 13 },
{ "ROUTE_17", 15, 14 }, { "ROUTE_17", 8, 45 },
{ "ROUTE_17", 17, 72 }, { "ROUTE_17", 4, 91 },
{ "ROUTE_17", 8, 121 }, { "ROUTE_23", 9, 44 },
{ "ROUTE_23", 19, 70 }, { "ROUTE_23", 8, 90 },
{ "VICTORY_ROAD_2F", 5, 2 }, { "VICTORY_ROAD_2F", 26, 7 },
{ "ROUTE_25", 38, 3 }, { "ROUTE_25", 10, 1 },
{ "ROUTE_4", 40, 3 }, { "ROUTE_9", 14, 7 },
{ "COPYCATS_HOUSE_2F", 1, 1 }, { "VIRIDIAN_CITY", 14, 4 },
{ "CERULEAN_CITY", 15, 8 }, { "CERULEAN_CAVE_1F", 18, 7 },
{ "POKEMON_TOWER_5F", 4, 12 }, { "VERMILION_CITY", 14, 11 },
{ "CELADON_CITY", 48, 15 }, { "SAFARI_ZONE_GATE", 10, 1 },
{ "POKEMON_MANSION_1F", 8, 16 },
}
+32 -5
View File
@@ -526,6 +526,22 @@ function Commands.set_field(ctx, key, value)
ctx.save[key] = value
end
function Commands.load_player_starter_name(ctx)
local flags = ctx.save.flags or {}
local species = flags.EVENT_CHOSE_PIKACHU and "PIKACHU"
or flags.EVENT_CHOSE_CHARMANDER and "CHARMANDER"
or flags.EVENT_CHOSE_SQUIRTLE and "SQUIRTLE"
or flags.EVENT_CHOSE_BULBASAUR and "BULBASAUR"
or (ctx.save.party and ctx.save.party[1] and ctx.save.party[1].species)
local def = species and ctx.game.data.pokemon[species]
ctx.game.stringBuffer = def and def.name or species or ""
end
function Commands.spawn_pikachu_follower(ctx)
require("src.world.PikachuFollower").onMapEntered(
ctx.game, ctx.overworld, nil, false)
end
local function toggleObject(ctx, mapId, objName, visible)
local save = ctx.save
save.objectToggles = save.objectToggles or {}
@@ -576,6 +592,17 @@ function Commands.play_sound(ctx, soundId)
require("src.core.Sound").play(ctx.game.data, soundId)
end
-- text_sound <soundId>: the jingle the ROM parks at the END of a string as
-- a trailing text command (sound_get_item_1, sound_get_key_item ->
-- home/text.asm TextCommand_SOUND). It arms the NEXT show_text the same
-- way play_cry arms ctx.pendingCry, so the fanfare fires once the last page
-- has typed and the box holds on WaitForSoundToFinish before the button
-- wait. play_sound stays the bare PlaySound used for the non-blocking
-- beats (Bill's teleporter, the S.S. Anne horn).
function Commands.text_sound(ctx, soundId)
ctx.textOpts = TextBox.soundOpts(ctx.game, soundId, ctx.textOpts)
end
-- play_once <songId>: one-shot jingle (Music_PkmnHealed, etc.); blocks
-- until it finishes so heal-rest scripts (Mom, captain text_asm) match
-- Gen1's wait-on-channel loop. The map theme resumes when it ends
@@ -1034,8 +1061,10 @@ function Commands.trade(ctx, tradeIndex, doneFlag)
onDone = function() runner:resume() end,
})
runner:yield()
-- TradedForText (sound_get_key_item) then the dialogset's thanks
require("src.core.Sound").play(data, "Get_Key_Item")
-- TradedForText carries sound_get_key_item after the text, so the jingle
-- rides the box and blocks it (home/text.asm TextCommand_SOUND), then the
-- dialogset's thanks
Commands.text_sound(ctx, "Get_Key_Item")
say(texts.tradedFor or "_TradedForText")
say(texts.thanks or "_Thanks" .. dialogset .. "Text")
end
@@ -1131,9 +1160,7 @@ end
-- opts.tempo is the Music_*AlternateTempo override (audio/alternate_tempo.asm
-- re-points channel 1 at a stub that only changes the song's `tempo`) (#847).
function Commands.play_music(ctx, songId, opts)
local tempo = opts and opts.tempo
require("src.core.Music").play(ctx.game.data, songId, nil,
tempo and { tempo = tempo } or nil)
require("src.core.Music").play(ctx.game.data, songId, nil, opts)
if opts and opts.keep and ctx.overworld then
ctx.overworld.keepMusicOnce = true
end
+25 -11
View File
@@ -33,12 +33,12 @@ local function save_name(game)
return game.save.player.name
end
local function showMessages(game, msgs, onDone)
local function showMessages(game, msgs, onDone, opts)
if not msgs or #msgs == 0 then
if onDone then onDone() end
return
end
game.stack:push(TextBox.new(game, table.concat(msgs, "\f"), onDone))
game.stack:push(TextBox.new(game, table.concat(msgs, "\f"), onDone, opts))
end
-- run the use-flow for an item on a chosen target. `picker` is the party
@@ -60,6 +60,14 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
return
end
if result == "flute_wake_pikachu" then
require("src.core.Sound").play(game.data, "Pokeflute")
showMessages(game, payload, function()
game.overworld.pikachuPewterSleepScene = nil
end)
return
end
-- field POKé FLUTE next to a not-yet-beaten Snorlax: "had effect" text,
-- then the woke-up/battle sequence (data/scripts/story.lua snorlaxWake)
if result == "flute_wake" then
@@ -142,12 +150,9 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
list:close()
local ow = game.overworld
local p = ow and ow.player
if ow and p then
local fx, fy = p:facingCell()
if ow.map:inBounds(fx, fy) and ow.map:isWaterCell(fx, fy) then
ow:goFishing(id)
return
end
if ow and p and ow:facingIsShoreOrWater() then
ow:goFishing(id)
return
end
showMessages(game, { Strings("No good! It's not\neven near water.") })
return
@@ -176,19 +181,27 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
end
if #target.moves < 4 then
table.insert(target.moves, { id = moveId, pp = mdef.pp })
-- LearnedMove1Text: text_far, sound_get_item_1, text_promptbutton
-- (learn_move.asm), so the jingle rides the box
showMessages(game, { Strings("%s learned\n%s!", target.nickname or
game.data.pokemon[target.species].name, mdef.name) })
game.data.pokemon[target.species].name, mdef.name) }, nil,
TextBox.soundOpts(game, "Get_Item1"))
if result == "learn" then consume(game, id) end
list.items = buildItems(game)
list.index = math.min(list.index, math.max(1, #list.items))
taught()
else
require("src.ui.Screens").push(game, "MoveLearnMenu", target, moveId,
function(learned)
if learned and result == "learn" then consume(game, id) end
if learned then
list.items = buildItems(game)
list.index = math.min(list.index, math.max(1, #list.items))
end
if learned then taught() end
end)
end
end
list:close()
teach()
return
end
@@ -324,7 +337,7 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
table.insert(target.moves, { id = moveId, pp = mdef.pp })
local name = target.nickname or def.name
showMessages(game, { Strings("%s learned\n%s!", name, mdef.name) },
nextStep)
nextStep, TextBox.soundOpts(game, "Get_Item1"))
else
require("src.ui.Screens").push(game, "MoveLearnMenu",
target, moveId, nextStep)
@@ -370,6 +383,7 @@ local function pickTargetAndUse(game, battle, id, list)
local def = game.data.items[id]
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.
+17
View File
@@ -119,6 +119,13 @@ local function deposit(game)
onChoose = function(item, list)
local mon = game.save.party[item.value]
if not mon then return end
local Follower = require("src.world.PikachuFollower")
if Follower.isFollowingDisabled(game.overworld)
and Follower.isStarterPikachu(game.save, mon) then
game.stack:push(TextBox.new(game, t._SleepingPikachuText2
or Strings("There isn't any\nresponse...")))
return
end
monSubmenu(game, "DEPOSIT", mon, function()
if #game.save.party <= 1 then
list.footer = Strings("You need at least\none POKéMON!")
@@ -167,6 +174,16 @@ local function release(game)
local mon = box[list.index]
if not mon then return end
local name = monName(game, mon)
if require("src.core.GameVersion").isYellow()
and mon.species == "PIKACHU"
and mon.otId == game.save.player.id
and mon.ot == game.save.player.name then
require("src.core.Sound").playCry(game.data, mon.species)
game.stack:push(TextBox.new(game,
(t._PikachuUnhappyText or Strings("%s looks\nunhappy about it!", name))
:gsub("{RAM:wNameBuffer}", name)))
return
end
game.stack:push(TextBox.new(game,
Strings("Once released,\n%s is\ngone forever. OK?", name), nil, {
defaultNo = true, noSound = true,
+22 -8
View File
@@ -46,9 +46,27 @@ function EvolutionState:sgbPalettes(game)
return P.wholeNamed(game.data, "MEWMON")
end
local FLASH_FRAMES = 220
-- evolution.asm EvolveMon delays 80 frames before .animLoop, polling nothing (#968, #1031)
local CANCEL_GRACE_FRAMES = 80
-- .animLoop starts at `lb bc, $1, $10` and runs 8 iterations: iteration k holds
-- the old pic for c = 18-2k frames inside Evolution_CheckForCancel (72 in all),
-- then Evolution_BackAndForthAnim swaps b = k times, each swap a pair of
-- Evolution_ChangeMonPic that end in Delay3 (6 frames a swap, 216 in all).
local ANIM_LOOP_FRAMES = 288
local FLASH_FRAMES = CANCEL_GRACE_FRAMES + ANIM_LOOP_FRAMES
-- which pic is on screen t frames into .animLoop
local function evoShowsNew(t)
for b = 1, 8 do
local hold = 18 - 2 * b
if t < hold then return false end
t = t - hold
local swap = b * 6
if t < swap then return t % 6 < 3 end
t = t - swap
end
return true
end
local function frontSprite(game, species, mon)
local path, trueColor = require("src.pokemon.Sprites").path(
@@ -140,14 +158,10 @@ function EvolutionState:draw()
else
sprite, spriteTrueColor = self.newSprite, self.newSpriteTrueColor
end
elseif evoShowsNew(self.t - CANCEL_GRACE_FRAMES) then
sprite, spriteTrueColor = self.newSprite, self.newSpriteTrueColor
else
local period = math.max(4, 28 - math.floor(self.t / 40) * 6)
local showNew = math.floor(self.t / period) % 2 == 1
if showNew then
sprite, spriteTrueColor = self.newSprite, self.newSpriteTrueColor
else
sprite, spriteTrueColor = self.oldSprite, self.oldSpriteTrueColor
end
sprite, spriteTrueColor = self.oldSprite, self.oldSpriteTrueColor
end
if sprite then
local x = math.floor((160 - sprite:getWidth()) / 2)

Some files were not shown because too many files have changed in this diff Show More