Compare commits

...

88 Commits

Author SHA1 Message Date
bryanthaboi 10314bcdcf Merge pull request #1355 from bryanthaboi/dev 2026-08-15 14:29:27 -04:00
bryanthaboi 099a4266a8 Merge pull request #1354 from 1Jamie/fix/save-editor-lifecycle 2026-08-15 13:59:32 -04:00
1jamie cec1f196be refactor: implement HostShell transport abstraction for multi-platform update fetching and downloads so the "check for updates" button works on mobile os 2026-08-15 12:48:36 -05:00
1jamie e24410f0fb fix: improve Gen2 data loading and error handling to prevent crashes when cache modules are missing on android 2026-08-15 12:48:36 -05:00
1jamie b29b6fd7bd fix(save-editor): reload Data and mods if App.dataVersion differs from active session 2026-08-15 12:48:36 -05:00
1jamie f06c4d4584 fix(save-editor): guard event scraping for mobile environments and fix editor state reset on close 2026-08-15 12:48:36 -05:00
1jamie 3a997e8a62 fix(save-editor): clean up module registry and pristine Data state on editor unload so subsequent edits perform safely 2026-08-15 12:48:36 -05:00
bryanthaboi e6ccdd57eb Merge pull request #1353 from anxiousintrovert/fix/required-import-platform-pickers 2026-08-15 13:37:51 -04:00
bryanthaboi 927507f8f7 Merge pull request #1351 from TheRealSolidusSnake/feature/sandbox-tls-legacycompat 2026-08-15 13:36:44 -04:00
anxiousintrovert 9bf15c33fd Cover required imports across platforms 2026-08-15 12:20:21 -05:00
anxiousintrovert 52efdabf61 Harden required import picker for Android 13 2026-08-15 12:20:21 -05:00
Solidus Snake ef208035ec Fix Gen1Tls.lua encoding (was UTF-16).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-15 12:33:22 -04:00
Solidus Snake 18d61779eb Hang gen1tls on love.system and forward tls* through LegacyCompat.
We already ship the DLL, but mods can't ffi-load it under the sandbox, and
the compat love.system shim wasn't passing tls* through either. So wss://
still died on stock builds. Engine loads the dialer at boot; compat forwards
those keys; clipboard/openURL stay stubbed.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-15 12:32:37 -04:00
github-actions d573878a2f chore(ios): update app-repo.json [skip ci] 2026-08-15 10:55:33 -04:00
bryanthaboi a66efe207d Merge pull request #1347 from bryanthaboi/dev
bazinga performance and addl hooks
2026-08-15 10:46:39 -04:00
bryanthaboi 5198b35945 bazinga 2026-08-15 10:44:20 -04:00
github-actions 40977337b1 chore(ios): update app-repo.json [skip ci] 2026-08-15 06:31:55 -04:00
bryanthaboi 1598f34954 Merge pull request #1337 from bryanthaboi/dev
legcompat
2026-08-15 06:23:05 -04:00
bryanthaboi 43cbc554c3 legcompat 2026-08-15 06:04:04 -04:00
github-actions 3c3e2c54c5 chore(ios): update app-repo.json [skip ci] 2026-08-14 23:48:42 -04:00
bryanthaboi 00c72c441b Merge pull request #1322 from bryanthaboi/dev 2026-08-14 23:40:02 -04:00
bryanthaboi 7804ef9793 Merge pull request #1323 from 1Jamie/fix/patch-notes-ci-resilience 2026-08-14 23:37:59 -04:00
1jamie 673d8b3ad8 fix(tests): make launcher_patch_notes resilient to release note wording changes 2026-08-14 22:36:24 -05:00
bryanthaboi 62e1296ced Merge pull request #1321 from 1Jamie/fix/gold-save-bug 2026-08-14 23:20:49 -04:00
1jamie a3bbd78e7b Remove androidcrash.log from project root 2026-08-14 22:17:33 -05:00
1jamie 8dfbd1daae fix(save/gen2): auto-recover orphaned save slots and fix PartyMenu field moves
- Auto-recover save slots: Scan saves/<version>/ on boot to re-index orphaned slot files if options.lua is reset or loses its slot registry.
- Fix Gen 2 field moves: Make PartyMenu field actions (Strength, Surf, Cut, Flash) generation-aware so they execute safely in Gold.
- Fortify platform stability: Guard native controller/hidapi init against Android 14 receiver exceptions and ensure safe Windows file replacement.
- Add unit test coverage for slot auto-recovery in save_editor_gen2_tests.lua.
2026-08-14 22:16:17 -05:00
github-actions 4046b28a8c chore(ios): update app-repo.json [skip ci] 2026-08-14 21:53:50 -04:00
bryanthaboi a86d57ac44 Merge pull request #1316 from bryanthaboi/dev 2026-08-14 21:44:05 -04:00
bryanthaboi 72f126ae26 Merge pull request #1312 from AverageConsumer/feat/contextual-field-moves 2026-08-14 21:42:22 -04:00
bryanthaboi 5d7a8c9291 Merge pull request #1300 from anxiousintrovert/feature/required-mod-imports 2026-08-14 21:41:49 -04:00
anxiousintrovert c48fc578ca Address required import review feedback 2026-08-14 20:34:14 -05:00
anxiousintrovert dfc216f974 Remove required imports RFC 2026-08-14 20:34:14 -05:00
anxiousintrovert 542856c83d Add manifest-driven required mod imports 2026-08-14 20:34:14 -05:00
bryanthaboi 545a99d86d Merge pull request #1311 from 1Jamie/feat/gold-save-editor 2026-08-14 21:15:07 -04:00
AverageConsumer 2b6473ae03 feat(mods): extend contextual field actions 2026-08-15 02:01:20 +02:00
1jamie df0be1cba6 Add Gold save-editor support, rearrange the launcher, and ship a patch-notes modal.
The save editor now routes Gold vs RBY through a generation adapter so boxes, items, events, and maps write the right fields. The launcher layout is shuffled a bit, and patch notes can come from the updater, a packed file, or the iOS sidecar.
2026-08-14 18:48:06 -05:00
github-actions c9d67582ae chore(ios): update app-repo.json [skip ci] 2026-08-14 18:02:54 -04:00
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 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
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
178 changed files with 14613 additions and 2951 deletions
+47 -1
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: |
+11 -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,12 @@ mobile/ios/bundle_id.local
/dist/native/
/dist/win/
/.bazinga/
# Local options / preferences
/options.lua*
# User-owned ROMs imported for individual mods. Manifests declare the
# destinations, but source checkouts and packaged mods never ship the files.
/mods/*/baseroms/
/imports/baseroms/
/imports/baseroms-recovery/
+75 -3
View File
@@ -242,10 +242,82 @@ with a `MK4xx` finding per site and an `unresolved:` note, with a file and a
line, for every reach a static scan could not follow. Neither substitutes for a
real Gold boot.
### 5. `mod.card`
### 5. What a mod's code can reach
Your code runs in a sandbox (`src/mods/Sandbox.lua`), not against the
engine's globals. Every chunk you author gets it: `main.lua`, your
`options_schema`, and anything you `load()` yourself.
The globals the sandbox took away are still *reachable*, as compat
stand-ins (`src/mods/LegacyCompat.lua`) that answer with the new API
underneath. A mod written before the sandbox keeps working; it logs one
warning per call it should migrate, and the mod manager lists them. What
each stand-in actually does:
| Pre-sandbox call | What it does now | Migrate to |
| --- | --- | --- |
| `io.open`, `io.lines`, `love.filesystem.read`/`lines`/`newFile` | reads your own shipped files, then your overlay, then `mod.storage` | `mod:read`, `mod.storage` |
| `love.filesystem.write`/`append`, `io.open(…, "w")`, `os.remove`, `os.rename` | writes to a private per-mod overlay under `mod_compat/<your id>/` | `mod.storage` |
| `love.filesystem.getDirectoryItems`/`getInfo` | your own directory plus your overlay | `mod:list`, `mod:info` |
| `love.filesystem.getSaveDirectory` and friends | a virtual root; anything joined to it lands in your overlay | `mod.storage` |
| `os.getenv` | `nil`, except home-like names, which answer with that same virtual root | nothing |
| `love.filesystem.load`, `dofile`, `loadfile` | compiles the chunk into your sandbox | `require`, `mod:read` plus `load` |
| `love.system` | `getOS`/`getPowerInfo`/`getProcessorCount` read through; clipboard and `openURL` do nothing | `mod.device:powerInfo()`, `mod.steps` |
| `love.event` | passes through, except `quit`, which does nothing | `mod.events`, `mod.hooks` |
| `love.mousemoved = fn` and the other callbacks | installs on the real `love` table, the way it always did | `mod.hooks`, `mod.events` |
| `package` | an inert stub, so `package.path = …` does not crash | `require` |
What has no stand-in, because there is nothing honest to reroute it to:
| Still refused | Why |
| --- | --- |
| `love.thread` | a LÖVE thread is a fresh Lua state with the full standard library, which no environment-based sandbox in this state can reach. Use `mod.fetch` for background HTTP (`network`) or `mod.job` for background compute (`background`) — both run your code inside the sandbox instead of outside it |
| `require("ffi")` | arbitrary C |
| `debug`, `getfenv`, `setfenv` | each one undoes the sandbox from inside |
| `io.popen`, `os.execute` | spawning a process |
| `love.run`, `love.errorhandler` | the engine's own loop and its crash path |
| replacing a `love` module table (`love.filesystem = {}`) | the engine reads those tables too |
The rest of `love` passes through unchanged, so graphics, audio, timers and
input work as they always have.
Three consequences worth knowing before you write against it:
- **Your globals are yours.** `_G` inside a mod is that mod's own table. Two
mods no longer share a namespace, and neither can reach the engine's. To
publish something to another mod, put it on `mod.exports` and let them
`mod.find("your_id").exports` — the channel that was always the intended
one. The same goes for the standard library: `string`, `table` and `math`
are per-mod copies, so patching one is a local decision.
- **Paths cannot climb.** `mod:read`, `mod:list`, `mod:info`, `mod.assets:path`
and `mod.assets:image` join to your own directory, and `..`, absolute paths
and drive letters are refused. So are `entry` and `options_schema` in your
manifest. `mod:list("assets")` is the sandboxed `getDirectoryItems` for a
folder you shipped; `mod:info` tells file from directory so a walk can
recurse.
- **Ship source, not bytecode.** A precompiled entry file is refused.
`permissions` in the manifest is still a disclosure the manager shows the
player. `network` gates `require("socket")` and friends plus `mod.fetch`
(non-blocking HTTP), and `background` gates `mod.job` (compute on a worker
thread). Those two are the sanctioned ways to work off the main thread now
that `love.thread` is refused. There is no
permission that grants raw filesystem access, because no mod needs one:
everything a mod legitimately writes is already scoped by
`mod.storage` or the asset-transform derived root.
If your mod used one of the rerouted globals, the fix is almost always
`mod.storage`. The overlay is a compatibility floor, not a second storage
system: it is not scoped per playthrough, it does not migrate, and it is
the first thing that will be dropped once the mods on the index have
moved off it. Open an issue if you have a case `mod.storage` does not
cover.
### 6. `mod.card`
The manifest is the *engine's* contract: identity, load order, dependencies,
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 +335,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
@@ -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,
+6 -6
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" },
+5 -5
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)
+2
View File
@@ -297,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")
@@ -329,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
+13 -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
@@ -648,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 .. "!"),
@@ -662,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
+7 -11
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
@@ -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,
@@ -522,7 +520,6 @@ M.ROUTE_24 = {
local t = text(game)
push(game, t._Route24CooltrainerM1YouBeatOurContestText .. "\f"
.. t._Route24CooltrainerM1YouJustEarnedAPrizeText, function()
require("src.core.Sound").play(game.data, "Get_Item1")
if not require("src.inventory.Bag").add(game.save, "NUGGET", 1,
game.data) then
push(game, t._Route24CooltrainerM1NoRoomText, done)
@@ -531,11 +528,10 @@ M.ROUTE_24 = {
flags.EVENT_GOT_NUGGET = true
game.stringBuffer = game.data.items.NUGGET.name
push(game, t._Route24CooltrainerM1ReceivedNuggetText, function()
require("src.core.Sound").play(game.data, "Get_Item1")
push(game, t._Route24CooltrainerM1JoinTeamRocketText,
battleOrDone)
end)
end)
end, require("src.render.TextBox").soundOpts(game, "Get_Item1"))
end, require("src.render.TextBox").soundOpts(game, "Get_Item1"))
return
end
battleOrDone()
+14 -8
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
+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",
},
+1
View File
@@ -16,6 +16,7 @@ save directory as:
| nil / `"rom"` | `picked_rom.gb` (open) |
| `"mod"` | `picked_mod.zip` (open) |
| `"sav"` / `"save"` | `picked_save.sav` (open) |
| `"required_import"` | `picked_required_import.bin` (open) |
Export uses a separate API: `love.system.createFile(suggestedName)`
`GameActivity.showCreateDocument` (`ACTION_CREATE_DOCUMENT`), which copies
+25 -9
View File
@@ -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,11 @@ has its own entry points for (`start_battle "wild" species level`, `warp`,
**by name, before the first row runs**, so a mod never gets a half-run queue.
`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 field
item and move records in both games. Gold extends the shared ids with its own
`headbutt`, `whirlpool`, `waterfall`, `sweet_scent`, and `squirtbottle`
actions. Each engine keeps ownership of its inventory, badges, terrain,
surfing, bike, fishing, and field-move rules.
**Hooks and events that fire on Gold.** Every name below is the Gen 1 name
carrying the Gen 1 payload keys, because Gold's call sites reuse them rather
@@ -531,8 +539,9 @@ gains a field instead of the name gaining a prefix.
`pokemon.level_up`, `pokemon.move_learned`; hooks `battle.damage`,
`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 +553,13 @@ gains a field instead of the name gaining a prefix.
each row's decision in `evolution.check`. The hook passes `data` where Gen 1
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 +768,11 @@ name and the existing payload, plus fields where Gen 2 genuinely carries more
The list is much shorter than it was. What is outstanding, in descending value:
- `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
+436 -12
View File
@@ -21,6 +21,145 @@ 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"
],
"required_imports": [
{
"id": "stadium2",
"name": "Pokemon Stadium 2 ROM",
"description": "Pokemon Stadium 2 (USA), any supported N64 byte order",
"file": "stadium2.z64",
"format": "n64",
"size": 67108864,
"md5": ["00000000000000000000000000000000"]
}
],
"optional_imports": [
{
"id": "bonus_source",
"name": "Optional bonus source",
"file": "bonus.bin",
"md5": "00000000000000000000000000000000"
}
],
"conflicts": [],
"permissions": ["engine_internals"],
"description": "A brief description of the mod.",
"github": "author/my_mod"
}
```
### Manifest Fields
| Field | Type | Description |
| --- | --- | --- |
| `id` | `string` | Unique identifier (lowercase alphanumeric, underscores, hyphens). |
| `name` | `string` | Human-readable title shown in launcher and manager. |
| `version` | `string` | Semantic version string (e.g. `"1.0.0"`). |
| `api` | `integer` | Mod API level (`2` for current standard, `1` for legacy). |
| `entry` | `string` | Entry Lua file path relative to mod root (usually `"main.lua"`). |
| `profile` | `string` | Mod profile: `"content"`, `"overhaul"`, or `"total_conversion"`. |
| `category` | `string` | Categorization chip (e.g. `"GAMEPLAY"`, `"CONTENT"`, `"UI"`, `"AUDIO"`). |
| `games` | `array` | Supported game versions: `["gen1"]`, `["gen2"]`, `["red"]`, `["blue"]`, `["yellow"]`, `["gold"]`, or `["all"]`. |
| `game_version`| `string` | Semver range of required engine version (e.g. `">=0.0.0-dev <2.0.0"`). |
| `priority` | `integer` | Load priority order (lower numbers load earlier; dependencies always precede dependents regardless of priority). |
| `dependencies` | `array` | Hard required dependencies. A mod will not load if a required dependency is missing or disabled for the active game. |
| `optional_dependencies` | `array` | Soft dependencies. Guarantees that if the target mod is present and active, it loads *before* this mod without blocking load if absent. |
| `required_imports` | `array` | User-supplied files required by this mod. The launcher validates and copies each file into this mod's `baseroms/` directory; the mod does not load while one is missing. |
| `optional_imports` | `array` | User-supplied files that unlock optional mod functionality. They use the same validation and private-copy flow but never block the mod from loading. |
| `conflicts` / `incompatible` | `array` | List of mod IDs that cannot run concurrently with this mod. |
| `permissions` | `array` | Requested privileges (e.g. `["engine_internals"]`, `["network"]`, `["filesystem"]`). |
| `github` | `string` | GitHub repository (`"owner/repo"`) used for update checks and dependency download links. |
### Declaring Dependencies & Scoping
Dependencies in `dependencies` and `optional_dependencies` can be declared in several formats:
1. **Simple string**: `"mod_id"`
2. **Version-pinned string**: `"mod_id@^1.2.0"`
3. **Repository-hinted string**: `"mod_id#owner/repo"` or `"mod_id@^1.2.0#owner/repo"`
4. **Structured object**:
```json
{
"id": "mod_id",
"range": "^1.2.0",
"games": ["gen2"],
"github": "owner/repo"
}
```
#### Version-Scoped Dependencies
When a mod supports multiple games (`"games": ["gen1", "gen2"]`), a dependency can specify `"games": ["gen2"]` to indicate it is only required when booting Gen 2. When booting Gen 1, the engine will ignore the dependency, preventing unnecessary boot blocks on games that do not need it.
### Required user-supplied files
`required_imports` and `optional_imports` keep copyrighted or otherwise user-owned source material
out of mod archives while giving every platform the same installation flow.
Each object requires a stable `id`, a display `name`, a destination `file`
(a filename, never a path), and one MD5 digest or an array of accepted MD5
digests. `format` is either `"raw"` (the default) or `"n64"`. An optional
`description` gives players dump or region guidance in the import panel.
`size` declares the exact canonical byte length; `max_size` declares a smaller
per-import ceiling when an exact size is not appropriate. Every import also
has an engine-enforced 128 MiB ceiling and is rejected before hashing when its
filesystem reports an invalid size.
For `"n64"`, the launcher recognizes `.z64`, `.v64`, and `.n64` byte orders,
strips a recognized 512-byte copier header, converts the bytes to canonical
big-endian `.z64` order, and then checks MD5. The canonical bytes are written
to `mods/<mod-id>/baseroms/<file>`. Each selection is a private grant to that
mod: the launcher never scans or copies another mod's imported files merely
because its manifest names the same digest. Mods read the result with their existing scoped `mod:read` API, for
example `mod:read("baseroms/stadium2.z64")`; no host path or new filesystem
permission is exposed. Missing `required_imports` block the mod before its
entry chunk runs; missing `optional_imports` remain visible in the same
launcher panel but do not block loading.
MD5 here identifies a known dump because ROM databases commonly publish it;
it is not a security or authenticity guarantee. Do not paste the SHA-1 used by
Gen1Recomp's own game-ROM importer into an import's `md5` field. Mod archives
must not include anything beneath `baseroms/`. The engine records a validation
receipt keyed by file size and modification time so launcher refreshes and
later boots do not repeatedly hash an unchanged imported ROM.
New mobile code should call `love.system.pickFile("required_import")`. The
older iOS-only `"stadium"` picker kind remains temporarily for compatibility.
Android now returns `false` for unknown picker kinds instead of treating them
as game-ROM picks.
### Platform import flow
The same per-mod validation and private `mods/<mod-id>/baseroms/` destination
applies on every supported platform. Windows, macOS, and Linux use the
launcher file chooser. Android uses the Storage Access Framework, and iOS uses
the Files document picker; both stage the choice as `picked_required_import.bin`
before validation. Xbox/UWP uses its native picker and hands the launcher a
temporary path. Switch/NX has no host picker, so the player copies a file to
`imports/baseroms/` over MTP and chooses the import again. No platform grants
the mod a host filesystem path or bypasses the manifest's size, format, and MD5
checks.
## Mods and Gold (Gen 2)
The mod API is one API across both generations, but Gold runs its own battle
@@ -66,7 +205,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 +216,24 @@ 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 actions
`mod.world:availableFieldActions()` returns the field items and moves that can
start at the player's current position. Both games expose `bicycle`, `fish`,
`cut`, `surf`, `strength`, `flash`, `dig`, and `teleport`; Gold additionally
exposes `headbutt`, `whirlpool`, `waterfall`, `sweet_scent`, and the
contextual `squirtbottle` key item. Fishing rows include the owned rods that
are valid choices. The list is empty while the world is busy, and omits an
action whenever its item, move, badge, terrain, or engine state forbids it.
Call `mod.world:useFieldAction(id, opts)` to perform a listed action through
the active game's own field-item path. Fishing accepts `{ rod = "OLD_ROD" }`
and chooses automatically when only one rod is available. Invalid, stale, and
busy requests return `nil` plus a reason without changing game state. Mods do
not need generation-specific badge, terrain, bike, fishing, or field-move
logic. Action lists are extensible; callers should render the records they
understand and ignore unknown ids rather than assuming a fixed list length.
## Rendering pipelines
Most registries hand the engine *content*. `render_pipelines` hands it
@@ -237,6 +395,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 +422,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 +532,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 +656,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 +685,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 +776,195 @@ local both = mod.datetime:dateTime(game, createdAt)
The live `game` supplies only the current option context. Formatting never
mutates the save, options, or timestamp, and invalid timestamps return
`"----"`.
## Device power information
Sandboxed mods can read the host's battery state without receiving the rest
of `love.system`:
```lua
local state, percent = mod.device:powerInfo()
```
`state` follows LÖVE's values: `"unknown"`, `"battery"`, `"nobattery"`,
`"charging"`, or `"charged"`. `percent` is `0` through `100`, or `nil` when
the platform cannot report it. The facade is read-only and does not expose
URL launching, clipboard access, or other system operations.
## Real-world steps
On iOS and Android the game counts the player's real-world steps natively
(HealthKit / the hardware step counter). A mod reaches that bridge through
the `steps` permission in `manifest.json`, which the player sees in the
mod manager like every other permission:
```lua
if mod.steps:available() then
mod.steps:sync() -- async; OS consent sheet on first use
end
-- later, at a quiet moment:
local walk = mod.steps:poll() -- { steps = n, from = ?, to = ? } or nil
```
`available()` is `false` on builds without the bridge (desktop) and for
mods without the permission, so a probe is always safe. `sync()` asks the
platform to refresh its count and returns whether there was a bridge to
ask. `poll()` returns the next delivery for this mod — the engine consumes
the native side's pending file itself, each permissioned mod receives its
own copy of a delivery, and steps are anchored natively so the same walk
is never delivered twice. Without the permission, `sync` and `poll` raise
an error naming it.
## Background HTTP
`mod.fetch` is how a mod does work off the main thread. It is behind the
`network` permission in `manifest.json`, the same one that gates
`require("socket")`, and the player sees it in the mod manager.
```lua
-- somewhere once
local job = mod.fetch:get("https://example.com/data.json")
-- in a hook or update, every frame -- poll never blocks
if job then
local r = mod.fetch:poll(job)
if r.status ~= "pending" then
if r.status == "ok" then use(r.body) else warn(r.err) end
mod.fetch:release(job)
job = nil
end
end
```
`get(url, opts)` returns an opaque handle, or `nil` plus a reason. `opts`
takes `accept` (a request Accept header) and `maxSeconds` (clamped to 30).
`poll(handle)` returns `{ status, body, err, progress }` where `status` is
`"pending"`, `"ok"`, `"error"` or `"cancelled"`; it is a copy, and it never
blocks, so calling it every frame is the intended use. `release(handle)`
frees a finished job — do it, or you will hit the ceiling. `cancel(handle)`
drops a result you no longer want. `available()` is `false` when the build
has no transport and for mods without the permission, so a probe is safe.
The rules worth knowing before you design around it:
- **http and https only.** The underlying transport also speaks `file://`,
`ftp://` and `scp://`; those are refused, on the initial URL and on any
redirect. `mod.fetch` is not a way to read a local file.
- **Four requests in flight per mod.** The worker pool is shared with the
launcher's own downloads, so one mod cannot fill it. Over the ceiling,
`get` returns `nil` and a reason until you release something.
- **Handles are yours alone.** A handle from another mod, a fabricated
table, or a guessed number all poll as `"error"`.
- **Your mod id is in the User-Agent**, so a server operator can see who is
calling and a mod cannot pose as the launcher.
- Jobs are released when your mod unloads.
This is deliberately not `love.thread`. A LÖVE thread is a fresh Lua state
with a full standard library that the sandbox cannot reach, so handing one
to a mod would undo every other rule; `mod.fetch`'s workers run engine
code, so a mod gets asynchrony without gaining any new reach.
## Background jobs
`mod.fetch` covers work waiting on a server. `mod.job` covers work waiting on
the CPU — generating a map, crunching a table, anything that would otherwise
stall a frame. It is behind the `background` permission in `manifest.json`.
Ship the job as its own file inside your mod:
```lua
-- mods/your_mod/jobs/crunch.lua
local arg = ...
local total = 0
for i = 1, arg.n do total = total + i end
return { total = total }
```
```lua
-- in your entry file
local job = mod.job:run("jobs/crunch.lua", { n = 1e6 })
-- later, in a hook -- poll never blocks
local r = mod.job:poll(job)
if r.status == "ok" then
use(r.result.total)
mod.job:release(job)
end
```
`run(script, arg, opts)` returns an opaque handle, or `nil` plus a reason.
`opts.maxSeconds` sets the job's time budget (default 5, clamped to 30).
`poll(handle)` returns `{ status, result, err }` with `status` one of
`"pending"`, `"ok"`, `"error"` or `"cancelled"`. `release(handle)` frees it.
`available()` is `false` on a host without threads and for mods without the
permission, so a probe is always safe.
**A job is pure compute.** This is the part to design around, not a detail:
- **Plain data in, plain data out.** Numbers, strings, booleans and tables of
them. A function, userdata, a cycle or a table key that is not a string or
number is refused at your `run` call with a reason. Nothing is shared —
your argument is snapshotted, and mutating the original afterwards does not
reach the job.
- **No engine API, no game state, no storage.** `require` is refused inside a
job, and there is no `mod` object. A job cannot read the party, write
`mod.storage`, or touch a registry. Get what it needs into the argument and
act on the result back on the main thread.
- **Your script is a file in your mod folder.** The path goes through the same
rules as `mod:read`; `..`, absolute paths and drive letters are refused.
- **Two jobs per mod, four on the machine.** Over the limit, `run` returns
`nil` and a reason until you release one.
- **The budget bounds how long YOU wait, not how long the work runs.** Past
`maxSeconds`, `poll` reports an error and the result is dropped if it ever
arrives — but the thread runs to its own end. There is no way to stop a
LÖVE thread from outside, and every attempt to stop one from inside was
worse than the disease (a debug hook does not reliably interrupt LuaJIT,
and raising from one wedged the whole process). `cancel(handle)` is the
same deal: it drops the result, it does not stop the work.
So **write jobs that terminate.** A job with an infinite loop will keep one
core busy until the game closes. It will not freeze the game — the main
thread stays responsive and quitting still works — but nothing will reclaim
that core in the meantime.
Your job script runs in the same sandbox your entry file does, so `io`, `os`,
`debug`, `ffi`, `package` and `love.filesystem` are absent there too. That is
the whole reason this exists rather than `love.thread`: a raw LÖVE thread is a
fresh Lua state with a full standard library that the sandbox cannot reach, so
handing one to a mod would undo every other rule. Here the worker builds your
sandbox first and loads your chunk into it.
## Pre-sandbox globals (compat)
A mod written before the sandbox landed does not have to be updated to
load. `io`, `package`, `dofile`, `loadfile`, `os.getenv`, `love.filesystem`,
`love.system` and `love.event` are all present again as compat stand-ins
(`src/mods/LegacyCompat.lua`), and assigning a LÖVE callback
(`love.mousemoved = fn`) installs on the real table the way it always did.
Every stand-in call logs one warning naming its replacement, and
`loader:legacyReport(modId)` returns the same list with call counts, which
is what a "needs updating" badge should read.
The stand-ins are not the old globals. Paths are classified rather than
passed through:
- A path inside your own mod directory reads the file you shipped.
- Anything else, including an absolute path, resolves into a private
per-mod overlay at `mod_compat/<your id>/` under the save directory.
Two mods naming the same path never see each other's bytes, and nothing
is written outside the game tree.
- A read misses through the overlay to your shipped file, then to
`mod.storage`, so a half-migrated mod sees both.
- A write over a path you shipped shadows it; the packaged file is never
modified, and `mod:read` still returns the packaged bytes.
- `love.filesystem.getSaveDirectory()` and `os.getenv("HOME")` answer with
a virtual root, so a legacy mod that joins its own paths lands back in
the same overlay.
`love.thread` stays refused. A LÖVE thread runs in a separate Lua state
with the full standard library, which the sandbox in this state cannot
reach, so a stand-in would be a hole rather than a reroute. The same goes
for `ffi`, `debug`, `setfenv`, `os.execute`, `io.popen`, `love.run` and
`love.errorhandler`. A mod that needs real background work needs an
engine-owned facility, not a compat shim -- for HTTP that facility is
[`mod.fetch`](#modfetch), which runs on the engine's own worker pool.
+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 })
+3 -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, and mods that need the internet or heavy background work do it through permissions the mod manager shows you, without freezing the game
* **Improved launcher and save editor UI**, including background downloads and update checks
* **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**
@@ -39,3 +40,4 @@ A fourth game the launcher can import and play, built from pret/pokegold the sam
* **On-screen touch pad** and controller SELECT for registered items
* **Older mods keep loading** after the sandbox change, through per-mod compat stand-ins for the pre-sandbox globals
+21 -2
View File
@@ -258,6 +258,25 @@ 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
**Enablement is per game.** The overlay
@@ -306,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 |
@@ -755,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.
+13 -5
View File
@@ -126,11 +126,19 @@ bundled game, in that case.
already driving the frame. A payload that must change `love.run` itself
needs a `minShell` bump so an older shell refuses to chainload it rather
than running with half its intended behavior.
- **Android has no in-app download transport yet.** `check_worker.lua`
shells out to curl for both the release check and the download; curl is
absent on Android, so `Check` degrades to `status = "error"` there (the
launcher UI hides on that status) and the player is directed to the
releases page via `Check.releaseUrl()` instead.
- **Android and iOS use the native download bridge, not curl.** Neither
platform ships curl, so the old `check_worker.lua` path (shell out to curl)
always landed on `error` and the launcher chip's "Check for updates" tap
was a no-op. The worker now talks through `HostShell`, the same transport
as the mod catalog: curl on desktop, `love.system.httpDownload` on mobile.
On Android that is the GameActivity JNI/`HttpsURLConnection` bridge; on
iOS it is `GRPickerBridge.httpDownload` (`URLSession`). A fused sideloaded
APK or IPA can therefore check GitHub and fetch the `.love` payload
in-app. If neither transport exists, the worker reports `needs_full` and
the launcher chip opens `Check.releaseUrl()`. Native package-only changes
still need a full reinstall (`minShell` / `payloadHost` gate →
`needs_full`). Applying a downloaded payload on Android relaunches via
`love.system.restartApp`; iOS still uses in-process `quit("restart")`.
- **Dev/source runs never self-update.** `Boot.run` returns immediately when
`love.filesystem.isFused()` is false, and a working tree's `engine` is the
`"0.0.0-dev"` placeholder that always reports up to date, so a source
+48 -25
View File
@@ -76,15 +76,6 @@ end
local editorHost, editorVersion, editorWindow
local closeEditor -- forward declaration: openEditor hands it to the editor
-- tools/save-editor/ models the Gen 1 save and nothing else: a Gen 2 party row
-- carries fields its MonOps and panels have no idea about (dvs, statExp,
-- happiness, pokerus, caughtLevel), and SaveIO.save writes the WHOLE table
-- back, so a Gold slot opened here comes out in a shape src/core/gen2/Save.lua
-- then has to quarantine on the next boot. Refuse by name, the same way the
-- .sav paths do (src/save_convert/SaveConvert.lua GEN2_SAV_UNSUPPORTED), so
-- Red/Blue/Yellow slots are untouched.
local GEN2_NO_EDITOR = { gold = "Pokemon Gold" }
-- The editor's modules use flat names (require("Kit"), require("Party")), so
-- their directories have to be on the require path. It must be
-- love.filesystem's path, not package.path: in a packaged build these files
@@ -137,11 +128,6 @@ local function openEditor(version, slotId)
Importer.saveNotice = Importer.saveNotice or {}
Importer.saveNotice[version] = { ok = false, text = text }
end
local gen2Name = GEN2_NO_EDITOR[version]
if gen2Name then
refuse(gen2Name .. " uses a Gen 2 save; the save editor does not read one yet.")
return
end
local SaveData = require("src.core.SaveData")
local path = SaveData.slotDiskPath(version, slotId)
if not path then
@@ -162,9 +148,44 @@ local function openEditor(version, slotId)
editorMode = true
resizeForEditor()
addEditorRequirePath()
EditorApp = require("App")
EditorApp.load(path, { version = version, slotId = slotId, embedded = true,
onClose = function() closeEditor() end })
local okReq, appOrErr = pcall(require, "App")
if not okReq then
editorMode = false
if version then
require("src.import.CacheFs").unmountVersion(version)
end
restoreWindow()
Importer = editorHost
editorHost = nil
editorVersion = nil
if Importer and Importer.resumeAfterOverlay then
Importer:resumeAfterOverlay()
end
refuse("Could not open the save editor (" .. tostring(appOrErr) .. ").")
return
end
EditorApp = appOrErr
local okLoad, loadErr = pcall(EditorApp.load, path, {
version = version, slotId = slotId, embedded = true,
onClose = function() closeEditor() end,
})
if not okLoad then
editorMode = false
if EditorApp.unload then pcall(EditorApp.unload) end
EditorApp = nil
if version then
require("src.import.CacheFs").unmountVersion(version)
require("src.core.Data"):unloadGenerated()
end
restoreWindow()
Importer = editorHost
editorHost = nil
editorVersion = nil
if Importer and Importer.resumeAfterOverlay then
Importer:resumeAfterOverlay()
end
refuse("Could not open the save editor (" .. tostring(loadErr) .. ").")
end
end
-- Back to the launcher. Everything the editor mounted or cached has to come
@@ -180,6 +201,11 @@ function closeEditor()
require("src.import.CacheFs").unmountVersion(version)
require("src.core.Data"):unloadGenerated()
end
for k in pairs(package.loaded) do
if type(k) == "string" and (k:find("save%-editor") or k == "App" or k == "Kit" or k == "State" or k == "Catalog" or k == "SaveIO" or k == "Ops" or k == "MonOps" or k == "ItemOps" or k == "PadInput" or k == "Gen" or k == "Theme") then
package.loaded[k] = nil
end
end
editorVersion = nil
restoreWindow()
Importer = editorHost
@@ -277,6 +303,11 @@ function love.load(args)
-- of each flashing their own cmd.exe window (#606). No-op elsewhere.
require("src.core.HostShell").hideHostConsole()
-- Hang gen1tls on love.system before mods boot. Android already has tls*
-- from JNI; this is the desktop half. No DLL / no FFI is fine -- ws://
-- rooms still work, wss:// just won't.
pcall(function() require("src.net.Gen1Tls").install() end)
-- NX fused mounts are unreliable for the blue|yellow cache overlay: wrap
-- the love loaders once so every generated-asset read falls back to the
-- versioned save-dir copy. Never installed on desktop/Android/iOS.
@@ -320,14 +351,6 @@ function love.load(args)
-- cache has to be mounted before the editor's Data:load.
if editorMode then
local version = os.getenv("POKEPORT_VERSION") or "red"
local gen2Name = GEN2_NO_EDITOR[version]
if gen2Name then
-- No launcher behind this run to carry a notice, so say it and stop
-- rather than open a Gen 2 slot on Gen 1 panels.
print(gen2Name .. " uses a Gen 2 save; the save editor does not read one yet.")
love.event.quit(1)
return
end
require("src.core.GameVersion").set(version)
require("src.import.CacheFs").mountVersion(version)
addEditorRequirePath()
@@ -192,8 +192,14 @@ bool System::pickFile(const char *kind) const
dest = "picked_mod.zip";
else if (strcmp(kind, "sav") == 0 || strcmp(kind, "save") == 0)
dest = "picked_save.sav";
else if (strcmp(kind, "required_import") == 0)
dest = "picked_required_import.bin";
else if (strcmp(kind, "rom") == 0)
dest = "picked_rom.gb";
// Unknown kinds used to fall through to the ROM destination. Refuse them
// so a newer Lua caller cannot silently route an unrelated file as a ROM.
else
return false;
}
return love::android::showFilePicker(dest);
#else
@@ -202,6 +208,15 @@ bool System::pickFile(const char *kind) const
#endif
}
const char *System::pickFileKinds() const
{
#ifdef LOVE_ANDROID
return "rom,mod,sav,required_import";
#else
return "";
#endif
}
bool System::createFile(const char *suggestedName) const
{
#ifdef LOVE_ANDROID
@@ -112,10 +112,12 @@ public:
* love::android::showFilePicker and src/import/RomImporter.lua.
*
* @param kind Optional pick kind: nullptr/"rom" -> picked_rom.gb,
* "mod" -> picked_mod.zip, "sav"/"save" -> picked_save.sav.
* "mod" -> picked_mod.zip, "sav"/"save" -> picked_save.sav,
* "required_import" -> picked_required_import.bin.
* @return Whether the picker was shown.
**/
virtual bool pickFile(const char *kind = nullptr) const;
virtual const char *pickFileKinds() const;
/**
* Shows the platform's native "create / save a file" UI (Android SAF
@@ -102,6 +102,12 @@ int w_pickFile(lua_State *L)
return 1;
}
int w_pickFileKinds(lua_State *L)
{
luax_pushstring(L, instance()->pickFileKinds());
return 1;
}
int w_createFile(lua_State *L)
{
const char *suggested = luaL_optstring(L, 1, nullptr);
@@ -222,6 +228,7 @@ static const luaL_Reg functions[] =
{ "openURL", w_openURL },
{ "vibrate", w_vibrate },
{ "pickFile", w_pickFile },
{ "pickFileKinds", w_pickFileKinds },
{ "createFile", w_createFile },
{ "syncHealthSteps", w_syncHealthSteps },
{ "restartApp", w_restartApp },
@@ -90,6 +90,9 @@ public class GameActivity extends SDLActivity {
private static final String PICKED_ROM_FILENAME = "picked_rom.gb";
private static final String PICKED_MOD_FILENAME = "picked_mod.zip";
private static final String PICKED_SAVE_FILENAME = "picked_save.sav";
// Kept separate from the game-ROM destination so a dependency pick can
// never be mistaken for a game import when the picker returns on Android.
private static final String PICKED_REQUIRED_IMPORT_FILENAME = "picked_required_import.bin";
private static final String PENDING_EXPORT_FILENAME = "pending_export.sav";
private static final String EXPORT_DONE_FILENAME = "export_done.flag";
// Written when a SAF pick cannot be read at all, with the destination
@@ -504,7 +507,8 @@ public class GameActivity extends SDLActivity {
* picker-agnostic and unchanged.
*
* @param destFilename basename under the app save identity (e.g.
* picked_rom.gb, picked_mod.zip, picked_save.sav)
* picked_rom.gb, picked_mod.zip, picked_save.sav, or
* picked_required_import.bin)
*/
/** Legacy single-argument entry; resolves the save dir itself. */
@Keep
@@ -535,6 +539,11 @@ public class GameActivity extends SDLActivity {
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("*/*");
// The Storage Access Framework grants the returned content URI
// directly to this activity. Request the read grant explicitly as
// well: Android 13's scoped storage deliberately does not expose
// arbitrary paths or require broad media/storage permissions.
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
try {
self.startActivityForResult(intent, FILE_PICKER_REQUEST_CODE);
return true;
@@ -547,6 +556,7 @@ public class GameActivity extends SDLActivity {
Intent intent = new Intent(Intent.ACTION_GET_CONTENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("*/*");
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
try {
self.startActivityForResult(
Intent.createChooser(intent, "Choose a file"),
@@ -576,6 +586,12 @@ public class GameActivity extends SDLActivity {
return showFilePicker(PICKED_SAVE_FILENAME);
}
/** Required-mod-file wrapper used by love.system.pickFile("required_import"). */
@Keep
public static boolean showRequiredImportFilePicker() {
return showFilePicker(PICKED_REQUIRED_IMPORT_FILENAME);
}
/**
* Relaunches the whole app for love.system.restartApp, used by
* src/core/HostShell.lua when a mod toggle needs a cold boot (#575).
+77
View File
@@ -12,6 +12,83 @@
"tintColor": "3b5ca8",
"category": "games",
"versions": [
{
"version": "0.1.92",
"date": "2026-08-15",
"size": 11364274,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.92/gen1recomp++-0.1.92-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
},
{
"version": "0.1.91",
"date": "2026-08-15",
"size": 11351477,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.91/gen1recomp++-0.1.91-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
},
{
"version": "0.1.90",
"date": "2026-08-15",
"size": 11344338,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.90/gen1recomp++-0.1.90-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @1Jamie\n- @bryanthaboi"
},
{
"version": "0.1.89",
"date": "2026-08-15",
"size": 11343841,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.89/gen1recomp++-0.1.89-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @1Jamie\n- @anxiousintrovert\n- @AverageConsumer\n- @bryanthaboi"
},
{
"version": "0.1.88",
"date": "2026-08-14",
"size": 11311237,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.88/gen1recomp++-0.1.88-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
},
{
"version": "0.1.87",
"date": "2026-08-14",
"size": 11310910,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.87/gen1recomp++-0.1.87-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @AverageConsumer\n- @bryanthaboi\n- @mleo2003\n- @ShaneMcGovernIE\n- MaxTomahawk"
},
{
"version": "0.1.86",
"date": "2026-08-14",
"size": 11306689,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.86/gen1recomp++-0.1.86-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1283 Intro broken and leading to softlock (R,B,Y currently unplayable without save file)\n\n## Contributors\n\n- @bryanthaboi"
},
{
"version": "0.1.85",
"date": "2026-08-14",
"size": 11306462,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.85/gen1recomp++-0.1.85-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
},
{
"version": "0.1.84",
"date": "2026-08-14",
"size": 11306462,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.84/gen1recomp++-0.1.84-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #919 Bills House game save moves strangely after reload\n- #982 Trading QOL aspect missing from OG.\n- #1003 Land Pokemon Encounters while Surfing in Cerulean Cave\n- #1012 Multiple issues with text speed, animations, and audio\n- #1022 Cannot interact with small trees\n- #1028 Trainers not resetting on route change.\n- #1033 Some issues with Link battles.\n- #1243 Can't sell TMs at Poké Marts\n\n## Contributors\n\n- @1Jamie\n- @AverageConsumer\n- @bryanthaboi\n- @TheRealSolidusSnake\n- Myles Resnick\n- ShaneMcGovernIE"
},
{
"version": "0.1.83",
"date": "2026-08-13",
"size": 11282300,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.83/gen1recomp++-0.1.83-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @AverageConsumer\n- @bryanthaboi"
},
{
"version": "0.1.82",
"date": "2026-08-13",
"size": 11281277,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.82/gen1recomp++-0.1.82-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #914 Immunity steps and no encounter tiles\n- #936 Fly and teleporting allow should block interactions\n- #944 When starting a new game, the character sprite is facing down instead of up\n- #949 Save game import, save game export, \"normal\" save game\n- #964 Menu closing when trying to view Pokemon before picking starter\n- #978 Following up of #847\n- #983 Pikachu incorrectly able to be released\n- #984 EXP calculation after trading a PKMN back isnt right.\n- #987 Celadon Game Corner Display Error (L22680)\n- #988 (Yellow) Missing Pikachu Interaction - Pewter City Pokemon Centre\n- #989 (Yellow) Missing Pikachu Interaction - Vermilion City Pokemon Fan Club\n- #990 (Yellow) Pikachu Missing Hat\n- #999 Pallet Town music while player follows Prof. Oak\n- #1007 Transition into battle state for the 'Catching Pikachu' scene missing\n- #1010 Rival's position resets\n- #1011 Rival dialogue issue\n- #1029 1px black line sits under the title logo at the SGB zone boundary\n- #1035 Alt Music not playing\n- #1041 Mimic text not swoing up\n- #1047 Using a TM or HM closes the menu afterwards.\n- #1048 Cannot select items from the sell menu.\n- #1056 Cursor not jumping when switching moves\n- #1065 Player walks through statue after final battle while walking into Hall Of Fame\n- #1066 Anomalies when opponent switches out Pokemon\n- #1069 missing {RAM:wNameBuffer}\n- #1073 _GotMonText not used\n- #1089 Scripted movement missing for player and rival\n- #1101 Recoil damage calculation bug\n- #1115 Hitting attack does no damage\n- #1119 Wrongs textlines and events\n- #1120 Health is black when viewing stats\n- #1129 Errors in bowing animations of the nurse\n- #1146 [Recomp] Map music glitch\n- #1149 issue that affects tranlations\n- #1175 Pikachu visible too late\n- #1189 Town Map Item\n- #1193 Status moves have (wrong) sound effects when battle animations are turned off\n- #1206 Gen1: Cinnebar island has unfishable tiles north and east\n- #1207 Poison Flicker Visual issue\n- #1214 Title Screen with OG Red is the wrong color\n- #1219 [GOLD] UNABLE TO USE SUN STONE\n- #1220 No screen shaking for hitting moves when battle animations are off\n- #1221 [Gold] Flaafy evolution to at Lvl 30 Ampharos skipped learn thunder punch\n- #1225 Jingles for learning moves missing\n\n## Contributors\n\n- @1Jamie\n- @bryanthaboi"
},
{
"version": "0.1.81",
"date": "2026-08-13",
+4 -2
View File
@@ -5,7 +5,7 @@
// without updating that patch (see mobile/ios/patch_love_src.py).
//
// Contract (mirrors love-android's GameActivity.showFilePicker):
// love.system.pickFile("rom"|"mod"|"sav") -> copies the user's pick into
// love.system.pickFile("rom"|"mod"|"sav"|"required_import") -> copies the user's pick into
// the LÖVE save directory as picked_rom.gb / picked_mod.zip /
// picked_save.sav; RomImporter's pending-file scan consumes it.
// love.system.createFile(name) -> exports save dir's pending_export.sav
@@ -83,6 +83,8 @@ public final class GRPickerBridge: NSObject {
types = [.zip]
case "sav":
destName = "picked_save.sav"
case "required_import":
destName = "picked_required_import.bin"
// A Nintendo 64 cartridge, for mods that build assets out of one --
// the voxel mod's Pokemon Stadium battle models are the caller this
// was added for. Its own filename on purpose: an N64 ROM landing on
@@ -135,7 +137,7 @@ public final class GRPickerBridge: NSObject {
// Kept beside the switch it describes, because the two drifting apart is
// the only way this can lie.
@objc public static func supportedPickerKinds() -> NSString {
return "rom,mod,sav,stadium" as NSString
return "rom,mod,sav,stadium,required_import" as NSString
}
@objc(presentExportWithName:saveDir:)
+2 -1
View File
@@ -89,7 +89,8 @@ int w_pickFile(lua_State *L)
return gr_callBridge(L, "GRPickerBridge", "presentPickerWithKind:saveDir:", kind);
}
// love.system.pickFileKinds() -> "rom,mod,sav,stadium", or nil off iOS.
// love.system.pickFileKinds() -> the comma-separated kinds supported by the
// Swift bridge (including required_import), or nil off iOS.
//
// So a caller can ask what this build's picker understands BEFORE opening it.
// An unknown kind is refused (GRPickerBridge), and a refusal looks exactly
+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` /
+18
View File
@@ -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"
+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
@@ -51,6 +51,12 @@ rm -f "$OUTPUT"
tools/rom_manifest.json tools/rom_manifest_blue.json \
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
if [ -f "$ROOT/PATCH_NOTES.md" ]; then
(cd "$ROOT" && zip -q "$OUTPUT" PATCH_NOTES.md)
fi
if [ -f "$ROOT/mobile/ios/app-repo.json" ]; then
(cd "$ROOT" && zip -q "$OUTPUT" mobile/ios/app-repo.json)
fi
if [ -n "$BUILD_INFO" ]; then
[ -f "$BUILD_INFO" ] || fail "missing build-info: $BUILD_INFO"
+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" "$@"
+1
View File
@@ -135,6 +135,7 @@ if [ -f data/generated/maps.lua ]; then
run_tier "T3 save editor: events + dex" "$LUA" tests/save_editor_task7_tests.lua
run_tier "T3 save editor: map browser" "$LUA" tests/save_editor_task8_tests.lua
run_tier "T3 save editor: mod awareness" "$LUA" tests/save_editor_mod_tests.lua
run_tier "T3 save editor: gold / gen2" "$LUA" tests/save_editor_gen2_tests.lua
run_tier "T3 save editor: wheel scrolling" "$LUA" tests/save_editor_wheel_bug595_test.lua
run_tier "T3 save editor: pad / NX input" "$LUA" tests/save_editor_pad_input_test.lua
run_tier "T5 link (loopback lockstep)" "$LUA" tests/run_link_tests.lua
+197 -83
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
@@ -618,6 +638,44 @@ local function newBattle(game)
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)
@@ -693,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
@@ -743,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
@@ -757,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
@@ -935,6 +994,17 @@ function BattleState:sayNextWaitSfx(text, sfx)
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
@@ -1022,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
@@ -1431,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
@@ -1555,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
@@ -1587,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"
@@ -1611,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")
@@ -1629,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:
@@ -1648,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
@@ -1656,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`
@@ -1698,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
@@ -1876,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
@@ -1961,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
@@ -2294,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
@@ -2481,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()
@@ -3793,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
@@ -3808,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)
@@ -3844,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
@@ -3895,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
@@ -3936,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
@@ -3979,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
@@ -4006,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
@@ -4042,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)
@@ -4081,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
@@ -4178,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
@@ -4251,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)
@@ -4277,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,
})
@@ -4321,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
@@ -4553,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)
@@ -4704,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))
@@ -4730,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))
@@ -4776,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"
@@ -5196,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
@@ -5594,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)
@@ -5657,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
@@ -5675,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)
+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),
+59
View File
@@ -98,10 +98,69 @@ function Mon.stats(baseStats, dvs, level, statExp)
}
end
-- The species string is the source of truth. `mon.name` is a copy of that
-- species' display name (GetPokemonName), kept so menus can print without a
-- Data lookup. It is NOT the nickname: an un-nicknamed mon has nickname nil
-- and prints this copy. Changing species without rewriting it leaves the
-- previous species' name on the party list and the SUMMARY's top line.
function Mon.syncIdentity(mon, data)
if type(mon) ~= "table" then return mon end
local def = data and data.pokemon and data.pokemon[mon.species]
if not def then return mon end
mon.name = def.name or mon.species
if def.types then mon.types = def.types end
if mon.dvs then
mon.gender = Mon.gender(def, mon.dvs,
{ species = mon.species, level = mon.level })
mon.shiny = Mon.isShiny(mon.dvs,
{ species = mon.species, def = def, level = mon.level })
if mon.species == Unown.SPECIES then
mon.unownLetter = Unown.letterFromDVs(mon.dvs)
else
mon.unownLetter = nil
end
end
return mon
end
-- Every screen that prints a mon without a Data lookup should go through here:
-- nickname if the player set one, otherwise the species display copy `name`.
-- Skipping `name` and jumping to `species` is how a swapped mon can still
-- read as ABRA on one menu and RAYQUAZA on another.
function Mon.displayName(mon)
if type(mon) ~= "table" then return "?" end
return mon.nickname or mon.name or mon.species or "?"
end
-- Party, boxes, both Day-Care sides, and a pending egg. Editor CONTINUE and
-- hydrate have to walk the same set: leaving dayCare.man.mon on the old
-- `name` is the ABRA bug in a second closet.
function Mon.eachSaveMon(save, fn)
if type(save) ~= "table" or type(fn) ~= "function" then return end
for _, mon in ipairs(save.party or {}) do fn(mon) end
for _, box in pairs(save.boxes or {}) do
if type(box) == "table" then
for _, mon in ipairs(box) do fn(mon) end
end
end
local dc = save.dayCare
if type(dc) == "table" then
if dc.man and dc.man.mon then fn(dc.man.mon) end
if dc.lady and dc.lady.mon then fn(dc.lady.mon) end
if dc.egg then fn(dc.egg) end
end
if save.daycare and save.daycare.mon then fn(save.daycare.mon) end
end
function Mon.syncSaveIdentity(save, data)
Mon.eachSaveMon(save, function(mon) Mon.syncIdentity(mon, data) end)
end
function Mon.refreshStats(mon, data)
if type(mon) ~= "table" then return mon end
local def = data and data.pokemon and data.pokemon[mon.species]
if not (def and def.baseStats) then return mon end
Mon.syncIdentity(mon, data)
-- engine/pokemon/move_mon.asm:1402
local stats = Mon.stats(def.baseStats, mon.dvs, mon.level or 1, mon.statExp)
mon.stats = stats
+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"
+39 -15
View File
@@ -14,6 +14,14 @@ local MODULES = {
-- Optional for compatibility with developer and stale caches.
local OPTIONAL = { "audio", "palettes", "icons" }
-- Gold's extractor never writes these Gen 1 tables (RomExtractorGen2 has
-- maps/text/pokemon/items, not text_pointers / trainer_headers / field).
-- Desktop can still `require` Red's copies from the source tree, so Gold
-- Edit appeared to work there; an Android APK has only the per-version
-- cache, so Data:load used to throw on the first Gold Edit and take the
-- activity down. Empty tables are enough for seedDefaults / the editor.
local GEN2_OPTIONAL = { text_pointers = true, trainer_headers = true, field = true }
-- Vanilla defaults for rules exposed through the constants registry. A
-- value has to exist before a mod can patch it; each one matches the
-- engine's no-mod behavior, so seeding them changes nothing on a vanilla
@@ -99,7 +107,11 @@ end
-- Fills only what the cache is missing, so an importer that learns to
-- stamp one of these keys silently takes over from the engine.
function Data:seedDefaults()
local constants = self.constants
local constants = self.constants or {}
self.constants = constants
self.field = self.field or {}
self.maps = self.maps or {}
self.pokemon = self.pokemon or {}
for key, value in pairs(CONSTANT_DEFAULTS) do
if constants[key] == nil then constants[key] = copy(value) end
end
@@ -108,7 +120,11 @@ function Data:seedDefaults()
if constants.dexSize == nil then
local highest = 0
for _, def in pairs(self.pokemon) do
if def.dex and def.dex > highest then highest = def.dex end
-- Gold's pokemon.lua also carries growthRates / tmhmMoves / generation
-- scalars beside species rows.
if type(def) == "table" and def.dex and def.dex > highest then
highest = def.dex
end
end
constants.dexSize = highest
end
@@ -212,36 +228,41 @@ local function loadModule(dir, name)
if not chunk then return false, err end
return pcall(chunk)
end
local ok, mod = pcall(require, "data.generated." .. name)
if ok then return true, mod end
-- Fused PhysFS / Blue|Yellow prefix: load bytes from the active version's
-- cache explicitly when require cannot see the mounted tree.
local CacheFs = require("src.import.CacheFs")
local GameVersion = require("src.core.GameVersion")
local path = "data/generated/" .. name .. ".lua"
local bytes = CacheFs.readActive(path)
if type(bytes) == "string" then
local chunk, err = loadstring(bytes, "@" .. GameVersion.cachePrefix() .. path)
if not chunk then return false, err or mod end
return pcall(chunk)
local chunk = loadstring(bytes, "@" .. GameVersion.cachePrefix() .. path)
if chunk then
local ok, res = pcall(chunk)
if ok then return true, res end
end
end
return false, mod
local ok, mod = pcall(require, "data.generated." .. name)
if ok then return true, mod end
return false, nil
end
function Data:load()
local dir = os.getenv("POKEPORT_DATA_DIR")
local gen2 = require("src.core.GameVersion").generation() == 2
for _, name in ipairs(MODULES) do
local ok, mod = loadModule(dir, name)
if not ok then
if dir then
if gen2 and GEN2_OPTIONAL[name] then
self[name] = {}
elseif dir then
error(("missing data module '%s/%s.lua' (POKEPORT_DATA_DIR).\n(%s)")
:format(dir, name, mod))
else
error(("missing generated data module 'data/generated/%s.lua'.\n" ..
"Import the ROM again or rebuild developer data.\n(%s)")
:format(name, mod))
end
error(("missing generated data module 'data/generated/%s.lua'.\n" ..
"Import the ROM again or rebuild developer data.\n(%s)")
:format(name, mod))
else
self[name] = mod
end
self[name] = mod
end
for _, name in ipairs(OPTIONAL) do
local ok, mod = loadModule(dir, name)
@@ -280,11 +301,14 @@ function Data:unloadGenerated()
if not pristine[key] then self[key] = nil end
end
end
self._pristineKeys = nil
for _, name in ipairs(MODULES) do
package.loaded["data.generated." .. name] = nil
self[name] = nil
end
for _, name in ipairs(OPTIONAL) do
package.loaded["data.generated." .. name] = nil
self[name] = nil
end
end
+31 -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)
--
@@ -282,6 +283,9 @@ function Game2:continueGame(save)
local modsDiff = SaveData.modsDiff(save, activeMods)
self.save = save
self:adoptSave(save)
-- Editor species swaps used to leave mon.name on the previous species.
-- CONTINUE rewrites party, boxes, and Day-Care copies from the live record.
require("src.battle.gen2.Mon").syncSaveIdentity(save, self.data)
-- options.lua wins over anything a save file carries: options are a display
-- preference that survives New Game and is edited from the launcher, so a
-- save written before they moved out must not drag old values back in.
@@ -587,13 +591,13 @@ function Game2:useFieldItem(itemId)
end
if not allowed then
self:say(("%s can't learn %s!"):format(
mon.nickname or mon.species or "?", moveName))
require("src.battle.gen2.Mon").displayName(mon), moveName))
return
end
for _, move in ipairs(mon.moves or {}) do
if move.id == moveId then
self:say(("%s already knows %s!"):format(
mon.nickname or mon.species or "?", moveName))
require("src.battle.gen2.Mon").displayName(mon), moveName))
return
end
end
@@ -1382,12 +1386,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
@@ -1419,7 +1425,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
@@ -1439,15 +1445,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()
+4 -2
View File
@@ -325,7 +325,8 @@ function HostShell.httpDownload(url, absPath, userAgent, accept, maxTime)
if type(absPath) ~= "string" or absPath == "" then return nil, "missing path" end
userAgent = userAgent or "gen1recomp"
if HostShell.haveCurl() then
local cmd = ("curl -fsSL --connect-timeout 15 --max-time %d ")
local cmd = ("curl -fsSL --proto =http,https --proto-redir =http,https "
.. "--connect-timeout 15 --max-time %d ")
:format(tonumber(maxTime) or 300)
.. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " "
if accept then
@@ -370,7 +371,8 @@ function HostShell.httpGet(url, userAgent, accept, maxTime)
-- BODY, and on the two services this talks to that body is the whole
-- diagnosis: GitHub's 403 says "API rate limit exceeded for <ip>", which
-- tells a user to wait rather than to go hunting for a broken index.
local cmd = ("curl -sSL --connect-timeout 10 --max-time %d ")
local cmd = ("curl -sSL --proto =http,https --proto-redir =http,https "
.. "--connect-timeout 10 --max-time %d ")
:format(tonumber(maxTime) or 40)
.. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " "
if accept then
+49 -2
View File
@@ -832,9 +832,41 @@ local function tryMigrateLegacy(version, fs)
return id
end
-- Scan the filesystem for orphaned slot files under saves/<version>/ when options.lua
-- has no registered slots for this version (e.g. options.lua was reset or lost).
local function scanDiskSlots(version, fs)
if not fs then return nil end
local dir = "saves/" .. version
local slots = {}
if fs.getDirectoryItems and fs.getInfo and fs.getInfo(dir) then
local items = pcall(fs.getDirectoryItems, dir) and fs.getDirectoryItems(dir) or {}
local numbers = {}
for _, item in ipairs(items) do
local slotId = item:match("^(slot%d+)%.lua$")
if slotId then
local n = tonumber(slotId:match("%d+"))
table.insert(numbers, { id = slotId, num = n or 0 })
end
end
table.sort(numbers, function(a, b) return a.num < b.num end)
for _, item in ipairs(numbers) do
table.insert(slots, item.id)
end
else
for i = 1, 30 do
local slotId = "slot" .. i
local path = dir .. "/" .. slotId .. ".lua"
if fs.getInfo and fs.getInfo(path) then
table.insert(slots, slotId)
end
end
end
return #slots > 0 and slots or nil
end
-- Resolve (once per version per process) which slot in-game saves use: an
-- existing registry wins; otherwise a lazy legacy migration may create
-- slot1; otherwise false, meaning the flat legacy path.
-- slot1; otherwise auto-recover disk slots; otherwise false (flat legacy path).
local function ensureVersionSlots(version, fs)
if slotsChecked[version] then return end
slotsChecked[version] = true
@@ -848,7 +880,22 @@ local function ensureVersionSlots(version, fs)
activeSlotCache[version] = reg.active or reg.list[1]
return
end
activeSlotCache[version] = tryMigrateLegacy(version, fs) or false
local migrated = tryMigrateLegacy(version, fs)
if migrated then
activeSlotCache[version] = migrated
return
end
-- Auto-recovery: if options.lua lost its slot registry, scan disk for orphaned slot files
local recovered = scanDiskSlots(version, fs)
if recovered and #recovered > 0 then
opts.saveSlots = opts.saveSlots or {}
opts.saveSlots[version] = { list = recovered, active = recovered[1] }
SaveData.saveOptions(opts, fs)
activeSlotCache[version] = recovered[1]
Logger.info("auto-recovered %d save slot(s) for %s from disk", #recovered, version)
return
end
activeSlotCache[version] = false
end
-- (body for the forward-declared saveNames.) Resolves the ACTIVE slot for
File diff suppressed because it is too large Load Diff
+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);")
+50
View File
@@ -0,0 +1,50 @@
-- ROM extraction, off the main thread. The extractor's require closure needs
-- only love.filesystem, love.image, love.math and love.system, none of which
-- are main-thread-only, so it runs here instead of as a coroutine the frame
-- loop resumed for 8ms out of every 16.7ms.
--
-- The caller clears the stale cache and writes the completion marker itself;
-- this only fills the tree between those two steps, so the "marker appears
-- last" order isReady() depends on stays on one thread.
require("love.filesystem")
require("love.image")
require("love.math")
require("love.system")
require("love.timer")
local version, prefix, romData, progressName, resultName = ...
local progressChannel = love.thread.getChannel(progressName)
local resultChannel = love.thread.getChannel(resultName)
-- RomExtractor:tick fires per item, thousands of times per import; a channel
-- push each would cost more than the work it reports.
local PROGRESS_HZ = 20
local ok, err = pcall(function()
local CacheFs = require("src.import.CacheFs")
CacheFs.prefix = prefix
local manifest = require("src.import.RomManifest").decode(version)
local RomExtractor = version == "gold"
and require("src.import.RomExtractorGen2")
or require("src.import.RomExtractor")
local lastPush, lastStage = 0, nil
local extractor = RomExtractor.new(romData, manifest,
function(progress, total, stage, current, stageTotal)
local now = love.timer.getTime()
-- Stage changes always go through, or the caption goes stale.
if stage ~= lastStage or now - lastPush >= 1 / PROGRESS_HZ then
lastPush, lastStage = now, stage
progressChannel:push({
progress = progress, total = total, stage = stage,
current = current, stageTotal = stageTotal,
})
end
end)
extractor:run()
end)
resultChannel:push({ ok = ok, error = ok and nil or tostring(err) })
+1325 -273
View File
File diff suppressed because it is too large Load Diff
+588 -82
View File
@@ -316,18 +316,6 @@ function RomImporter.isReady(version)
end
-- Load the import manifest for a version and confirm it matches that ROM.
local function decodeManifest(version)
local path = GameVersion.info(version).manifest
local raw, readError = love.filesystem.read(path)
if not raw then error("ROM import metadata is missing: " .. tostring(readError)) end
local Json = require("src.link.Json")
local manifest, decodeError = Json.decode(raw)
if not manifest then error("ROM import metadata is invalid: " .. tostring(decodeError)) end
assert(manifest.romSha1 == GameVersion.info(version).sha1,
"ROM import metadata version mismatch")
return manifest
end
local function sha1(data)
local digest = love.data.hash("sha1", data)
if type(digest) == "userdata" and digest.getString then
@@ -344,6 +332,14 @@ local function readExternalPath(path)
return data
end
local function externalFileSize(path)
local file = io.open(path, "rb")
if not file then return nil end
local size = file:seek("end")
file:close()
return size
end
local function readDroppedFile(file)
local ok, openError = file:open("r")
if not ok then return nil, openError end
@@ -981,6 +977,25 @@ local function findPendingSav(preferAny, skip)
return nil
end
local function pickerHasKind(kind)
local fn = love.system.pickFileKinds
if type(fn) ~= "function" then return false end
local ok, kinds = pcall(fn)
if not ok or type(kinds) ~= "string" then return false end
for token in kinds:gmatch("[^,%s]+") do
if token == kind then return true end
end
return false
end
local function findPendingRequiredImport()
local names = { "picked_required_import.bin", "picked_stadium.z64" }
for _, name in ipairs(names) do
if love.filesystem.getInfo(name, "file") then return name end
end
return nil
end
-- Retire an Android pick once it has been through the installer / importer,
-- whether or not it worked: a pick left on disk wins the scans above forever,
-- so the next tap re-runs the same failing file and the picker never reopens
@@ -1112,6 +1127,39 @@ local function chooseSav()
return nil
end
-- Generic user-supplied dependency picker. Keep the native prompt entirely
-- engine-owned: manifest labels are untrusted and must never enter shell
-- command templates. The LÖVE modal already shows the specific import name.
local function chooseRequiredFile()
local prompt = shellSafe(Strings("Choose required mod file"))
local platform = love.system.getOS()
if platform == "OS X" then
return commandOutput(
([[osascript -e 'POSIX path of (choose file with prompt "%s")' 2>/dev/null]])
:format(prompt))
elseif platform == "Windows" then
local script = table.concat({
"Add-Type -AssemblyName System.Windows.Forms;",
"$d=New-Object System.Windows.Forms.OpenFileDialog;",
"$d.Title='" .. prompt .. "';",
"$d.Filter='All files (*.*)|*.*';",
"if($d.ShowDialog() -eq 'OK'){",
"$t=Join-Path $env:TEMP 'pokeport_required_import.bin';",
"Copy-Item -LiteralPath $d.FileName -Destination $t -Force;",
"[Console]::OutputEncoding=[Text.Encoding]::UTF8;",
"[Console]::Write($t)}",
})
return commandOutput(
'powershell -NoProfile -STA -Command "' .. script .. '"')
elseif platform == "Linux" then
local path = commandOutput(
([[zenity --file-selection --title="%s" 2>/dev/null]]):format(prompt))
if path then return path end
return commandOutput([[kdialog --getopenfilename "$HOME" 2>/dev/null]])
end
return nil
end
-- The self-updater only surfaces on the real distributed build: a fused,
-- interactive launcher with no scripted-run override. A dev / source checkout
-- (unfused, where Boot.run already no-ops) or an autopilot / driver /
@@ -1228,9 +1276,11 @@ 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.
mods = nil, modScroll = 0, modNotice = nil,
-- modScroll is the current paged list's inner scroll offset (px, clamped
-- in draw); modNotice is the last install/delete result { ok, text }.
-- requiredImportNotice stays inside the imported-files modal so validation
-- failures are visible beside the file picker that caused them.
mods = nil, modScroll = 0, modNotice = nil, requiredImportNotice = nil,
-- Which game the MODS panel is answering for (a GameVersion id, nil =
-- every game). Rows resolve their enable-state and their "runs here"
-- verdict against it (src/mods/ModTargets.lua).
@@ -1420,7 +1470,13 @@ function RomImporter:focus(f)
local text = "Could not read the picked file. Reopen the picker and choose "
.. "it with the Files (Documents) app, or copy it into: "
.. love.filesystem.getSaveDirectory()
if pickError:find("picked_mod", 1, true) then
if pickError:find("picked_required_import", 1, true)
or pickError:find("picked_stadium", 1, true) then
self.modNotice = { ok = false, text = text }
self.pickerPendingKind = nil
self.pickerPendingModId = nil
self.pickerPendingImportId = nil
elseif pickError:find("picked_mod", 1, true) then
self.modNotice = { ok = false, text = text }
elseif pickError:find("picked_save", 1, true) then
local version = self.androidPendingVersion or self:_savedropTarget()
@@ -1431,6 +1487,20 @@ function RomImporter:focus(f)
end
return
end
local requiredName = findPendingRequiredImport()
if requiredName then
local modId, importId = self.pickerPendingModId, self.pickerPendingImportId
self.pickerPendingKind = nil
self.pickerPendingModId, self.pickerPendingImportId = nil, nil
local imported = modId and importId
and self:_importRequiredSource(modId, importId, requiredName)
consumePick(self, requiredName, requiredName, imported)
if not modId or not importId then
self.modNotice = { ok = false,
text = "A picked dependency file had no pending mod request and was discarded." }
end
return
end
local modName = findPendingMod(false, self.pickSkip)
if modName then
self:_installMod(modName)
@@ -1470,6 +1540,8 @@ function RomImporter:setError(message, version)
self.detail = tostring(message)
self.progress = 0
self.worker = nil
-- Dropping the job stops collection; the next import clears the channels.
self._extract = nil
self.romData = nil
-- A headless import has no launcher to read this off: POKEPORT_IMPORT_ONLY
-- only ever quits from onComplete, so an import that fails here would sit in
@@ -1536,23 +1608,65 @@ function RomImporter:startData(data, displayName)
self.detail = displayName or info.displayName
self.progress = 0
self.romData = data
self.worker = coroutine.create(function()
self.status = "Preparing private game data"
coroutine.yield()
-- Redirect every cache write to this version's subtree, then clear only
-- that version's previous cache from both homes (save directory and, for
-- a portable install, the game folder). The other version is untouched.
local CacheFs = require("src.import.CacheFs")
local prefix = info.cachePrefix
CacheFs.prefix = prefix
self.status = "Preparing private game data"
-- Clear this version's previous cache from both homes before anything
-- writes. Stays on the main thread so delete-then-fill-then-mark keeps one
-- owner; the prefix is restored at once because the worker sets its own.
local CacheFs = require("src.import.CacheFs")
local prefix = info.cachePrefix
local savedPrefix = CacheFs.prefix
CacheFs.prefix = prefix
local cleared, clearError = pcall(function()
removeTree(prefix .. "data/generated")
removeTree(prefix .. "assets/generated")
love.filesystem.remove(prefix .. MARKER_PATH)
CacheFs.removeTree("data/generated")
CacheFs.removeTree("assets/generated")
CacheFs.remove(MARKER_PATH)
end)
CacheFs.prefix = savedPrefix
if not cleared then
self:setError(tostring(clearError), version)
return
end
local manifest = decodeManifest(version)
if self:_startExtractThread(version, prefix, data, displayName) then return end
self:_startExtractCoroutine(version, info, prefix, displayName)
end
-- False when threads are unavailable, so the coroutine path still covers
-- that host. POKEPORT_NO_THREAD=1 forces it, which is the only way to
-- exercise the fallback on a desktop.
function RomImporter:_startExtractThread(version, prefix, data, displayName)
if os.getenv("POKEPORT_NO_THREAD") == "1" then return false end
if not (love.thread and love.thread.newThread) then return false end
local ok, thread = pcall(love.thread.newThread, "src/import/ExtractThread.lua")
if not ok or not thread then return false end
local progressName = "rom_import_progress"
local resultName = "rom_import_result"
love.thread.getChannel(progressName):clear()
love.thread.getChannel(resultName):clear()
local started = pcall(thread.start, thread, version, prefix, data,
progressName, resultName)
if not started then return false end
self._extract = {
thread = thread, version = version, prefix = prefix,
displayName = displayName,
progress = love.thread.getChannel(progressName),
result = love.thread.getChannel(resultName),
}
-- The worker owns the bytes now; drop ours so the 1-2 MiB string can go.
self.romData = nil
return true
end
function RomImporter:_startExtractCoroutine(version, info, prefix, displayName)
self.worker = coroutine.create(function()
coroutine.yield()
local CacheFs = require("src.import.CacheFs")
CacheFs.prefix = prefix
local manifest = require("src.import.RomManifest").decode(version)
local RomExtractor = version == "gold"
and require("src.import.RomExtractorGen2")
or require("src.import.RomExtractor")
@@ -1565,47 +1679,93 @@ function RomImporter:startData(data, displayName)
coroutine.yield()
end)
extractor:run()
CacheFs.prefix = "" -- restore the default so later writes stay at the root
self.romData = nil
collectgarbage("collect")
-- Written last: the marker is what isReady() checks, so it must only
-- appear once every required file is in place.
local ok, writeError = CacheFs.write(MARKER_PATH, markerFor(version))
CacheFs.prefix = "" -- restore the default so later writes stay at the root
if not ok then error("could not finish the private cache: " .. tostring(writeError)) end
self.ready[version] = true
self.returning[version] = false
self.romName[version] = (displayName
and (displayName:match("[^/\\]+$") or displayName)) or self.romName[version]
-- Android: drop the consumed save-dir .gb/.gbc (picked_rom.gb or a USB copy)
-- so the next Choose / focus cannot treat it as a fresh pending ROM.
if self.mobileFileBridge and type(displayName) == "string"
and not displayName:find("[/\\]") then
love.filesystem.remove(displayName)
end
self.importing = nil
self.workState = "complete"
self.completeVersion = version
self.status = "Ready"
-- NX launcher stays put: keep the imports/ cleanup hint instead of
-- overwriting it with a "Starting…" line that never boots from here.
if self.launcher and self.isNX and type(displayName) == "string" then
self.detail = Strings("%s imported. You may delete the copy from "
.. "imports/ when finished.", displayName)
else
self.detail = "Starting " .. info.displayName .. "..."
end
self.progress = 1
if self.launcher then
-- Stay on the launcher; the player presses Play to boot the new game.
return
end
self._handedOff = true
resetPointerCursor(self)
if self._flex then require("src.import.LauncherView").detach(self) end
if self.onComplete then self.onComplete(version) end
self:_completeImport(version, prefix, displayName)
end)
end
-- Everything after the tree is filled, shared by both worker paths. Raises
-- on a failed marker write; the thread path calls it inside a pcall.
function RomImporter:_completeImport(version, prefix, displayName)
local info = GameVersion.info(version)
local CacheFs = require("src.import.CacheFs")
-- Written last: the marker is what isReady() checks, so it must only
-- appear once every required file is in place.
local savedPrefix = CacheFs.prefix
CacheFs.prefix = prefix
local ok, writeError = CacheFs.write(MARKER_PATH, markerFor(version))
CacheFs.prefix = savedPrefix
if not ok then
error("could not finish the private cache: " .. tostring(writeError))
end
self.ready[version] = true
self.returning[version] = false
self.romName[version] = (displayName
and (displayName:match("[^/\\]+$") or displayName)) or self.romName[version]
-- Android: drop the consumed save-dir .gb/.gbc (picked_rom.gb or a USB copy)
-- so the next Choose / focus cannot treat it as a fresh pending ROM.
if self.mobileFileBridge and type(displayName) == "string"
and not displayName:find("[/\\]") then
love.filesystem.remove(displayName)
end
self.importing = nil
self.workState = "complete"
self.completeVersion = version
self.status = "Ready"
-- NX launcher stays put: keep the imports/ cleanup hint instead of
-- overwriting it with a "Starting…" line that never boots from here.
if self.launcher and self.isNX and type(displayName) == "string" then
self.detail = Strings("%s imported. You may delete the copy from "
.. "imports/ when finished.", displayName)
else
self.detail = "Starting " .. info.displayName .. "..."
end
self.progress = 1
if self.launcher then
-- Stay on the launcher; the player presses Play to boot the new game.
return
end
self._handedOff = true
resetPointerCursor(self)
if self._flex then require("src.import.LauncherView").detach(self) end
if self.onComplete then self.onComplete(version) end
end
-- Drain the worker's progress and finish when it reports done. One
-- non-blocking poll per frame, like the other _pump* collectors above.
function RomImporter:_pumpExtract()
local job = self._extract
if not job then return end
local msg = job.progress:pop()
while msg do
self.status = msg.stage
self.progress = msg.progress / msg.total
self.stageCurrent = msg.current
self.stageTotal = msg.stageTotal
msg = job.progress:pop()
end
local res = job.result:pop()
if not res then
-- A thread that died before pushing a result would strand the loader.
local threadError = job.thread.getError and job.thread:getError()
if threadError then
self._extract = nil
self:setError(tostring(threadError), job.version)
end
return
end
self._extract = nil
if not res.ok then
self:setError(tostring(res.error), job.version)
return
end
local ok, err = pcall(self._completeImport, self, job.version, job.prefix,
job.displayName)
if not ok then self:setError(tostring(err), job.version) end
end
function RomImporter:startPath(path)
if not path then return end
local data, readError = readExternalPath(path)
@@ -1649,7 +1809,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 +1821,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
@@ -1727,6 +1898,152 @@ function RomImporter:chooseMod()
if path then self:_installMod(path) end
end
local function requiredManifest(self, modId)
for _, row in ipairs(self.mods or {}) do
if row.id == modId then return row.manifest, row end
end
return nil
end
local function requiredSpec(manifest, importId)
for _, candidate in ipairs(require("src.mods.RequiredImports").specs(manifest)) do
if candidate.id == importId then return candidate end
end
return nil
end
local function requiredImportNotice(self, modId, importId, text)
self.requiredImportNotice = {
modId = modId,
importId = importId,
text = tostring(text),
}
end
function RomImporter:_importRequiredData(modId, importId, data)
local manifest = requiredManifest(self, modId)
if not manifest then
self.modNotice = { ok = false, text = "Required import failed: mod not found." }
return nil
end
local ok, result = require("src.mods.RequiredImports")
.importData(manifest, importId, data)
if ok then
self.requiredImportNotice = nil
self.modNotice = { ok = true, text = "Imported " .. tostring(importId)
.. " for " .. tostring(manifest.name or manifest.id) .. "." }
self:_refreshMods()
return true
end
-- Keep validation feedback on the imported-files page. A general Mods-page
-- notice is hidden by this modal and made MD5 failures especially easy to miss.
requiredImportNotice(self, modId, importId, result)
self.modNotice = nil
return nil
end
function RomImporter:_importRequiredSource(modId, importId, source)
local manifest = requiredManifest(self, modId)
local spec = manifest and requiredSpec(manifest, importId)
if not spec then
requiredImportNotice(self, modId, importId, "Import declaration was not found.")
self.modNotice = nil
return nil
end
local info = love.filesystem.getInfo(source, "file")
local size = info and info.size or externalFileSize(source)
local sizeErr = require("src.mods.RequiredImports").sizeError(spec, size, false)
if sizeErr then
requiredImportNotice(self, modId, importId, sizeErr)
self.modNotice = nil
return nil
end
local data = love.filesystem.read(source)
if not data then data = readExternalPath(source) end
if not data then
requiredImportNotice(self, modId, importId, "Could not read the selected file.")
self.modNotice = nil
return nil
end
return self:_importRequiredData(modId, importId, data)
end
function RomImporter:_removeRequiredImport(modId, importId)
local manifest = requiredManifest(self, modId)
if not manifest then return end
local ok, err = require("src.mods.RequiredImports").remove(manifest, importId)
if ok then
self.requiredImportNotice = nil
self.modNotice = { ok = true, text = "Deleted " .. tostring(importId) .. "." }
self:_refreshMods()
else
requiredImportNotice(self, modId, importId, err)
self.modNotice = nil
end
end
-- Select and validate one manifest-declared file. NX has no host picker, so
-- its equivalent is an engine-owned imports/baseroms inbox that can be filled
-- over MTP; every other native/mobile picker lands on the same validation path.
function RomImporter:chooseRequiredImport(modId, importId)
if self.workState == "working" then return end
local manifest = requiredManifest(self, modId)
if not manifest then return end
local spec = requiredSpec(manifest, importId)
if not spec then return end
if self.isNX then
local inbox = "imports/baseroms"
love.filesystem.createDirectory(inbox)
local lastError
for _, name in ipairs(love.filesystem.getDirectoryItems(inbox) or {}) do
if name:sub(1, 1) ~= "." then
local path = inbox .. "/" .. name
local info = love.filesystem.getInfo(path, "file")
local sizeErr = info and require("src.mods.RequiredImports")
.sizeError(spec, info.size, false)
local data = not sizeErr and love.filesystem.read(path) or nil
if data and self:_importRequiredData(modId, importId, data) then return end
if sizeErr then lastError = sizeErr
elseif self.requiredImportNotice
and self.requiredImportNotice.modId == modId
and self.requiredImportNotice.importId == importId then
lastError = self.requiredImportNotice.text
end
end
end
requiredImportNotice(self, modId, importId, lastError
or "No matching file in imports/baseroms/. Copy it there over MTP, then try again.")
self.modNotice = nil
return
end
if self.nativePicker then
if self.mobileFileBridge and not pickerHasKind("required_import") then
requiredImportNotice(self, modId, importId,
"This app build cannot pick required mod files yet. Update the app and try again.")
self.modNotice = nil
return
end
self.pickerPendingKind = "required_import"
self.pickerPendingModId = modId
self.pickerPendingImportId = importId
if not pickFile("required_import") then
self.pickerPendingKind = nil
self.pickerPendingModId = nil
self.pickerPendingImportId = nil
requiredImportNotice(self, modId, importId, "Could not open the file picker.")
self.modNotice = nil
elseif self.android then
self.pickPending = true
self.pickTimer = 0
end
return
end
local path = chooseRequiredFile()
if path then self:_importRequiredSource(modId, importId, path) end
end
-- Which game a dropped .sav imports into: a .sav has no version signature of
-- its own, so it lands on the active game tab. When a non-game tab (mods) is
-- showing, default to red -- the always-present first game -- rather than
@@ -2034,7 +2351,8 @@ function RomImporter:_pollPickedFiles(dt)
if not found then
for _, name in ipairs(love.filesystem.getDirectoryItems("")) do
local n = name:lower()
if n:match("%.gbc?$") or n == "picked_mod.zip" or n == "picked_save.sav" then
if n:match("%.gbc?$") or n == "picked_mod.zip" or n == "picked_save.sav"
or n == "picked_required_import.bin" or n == "picked_stadium.z64" then
found = true
break
end
@@ -2076,6 +2394,7 @@ function RomImporter:update(dt)
self:_pumpFindThumbs()
self:_pumpModCheck()
self:_pumpModInstall()
self:_pumpExtract()
-- Dev harness: POKEPORT_LAUNCHER_SHOT=/path.png resizes the window from
-- POKEPORT_WIN=WxH, lets the view settle, then captures one frame and
-- quits, so a scripted run can see the real launcher at any window shape
@@ -2163,7 +2482,12 @@ function RomImporter:update(dt)
local version = self.pickerPendingVersion
self.pickerPendingKind = nil
self.pickerPendingVersion = nil
if kind == "mod" then
if kind == "required_import" then
local modId, importId = self.pickerPendingModId, self.pickerPendingImportId
self.pickerPendingModId, self.pickerPendingImportId = nil, nil
if modId and importId then self:_importRequiredSource(modId, importId, path) end
if Platform.isUWP() then os.remove(path) end
elseif kind == "mod" then
self:_installMod(path)
if Platform.isUWP() and self.modNotice and self.modNotice.ok then
os.remove(path)
@@ -2185,7 +2509,10 @@ function RomImporter:update(dt)
local version = self.pickerPendingVersion or self:_savedropTarget()
self.pickerPendingKind = nil
self.pickerPendingVersion = nil
if kind == "mod" then
if kind == "required_import" then
self.modNotice = { ok = false, text = errorText }
self.pickerPendingModId, self.pickerPendingImportId = nil, nil
elseif kind == "mod" then
self.modNotice = { ok = false, text = errorText }
elseif kind == "sav" then
self.saveNotice[version] = { ok = false, text = errorText }
@@ -2345,6 +2672,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
@@ -2716,6 +3057,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)
@@ -2758,7 +3136,7 @@ function RomImporter:keypressed(key)
return
end
if self._modConfirm or self._modVersions or self._modReleaseNotes
or self._findDetails then
or self._findDetails or self._appPatchNotes 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
@@ -2771,6 +3149,8 @@ function RomImporter:keypressed(key)
self._findDetails = nil
elseif self._modReleaseNotes then
self._modReleaseNotes = nil
elseif self._appPatchNotes then
self._appPatchNotes = nil
else
self._modConfirm = nil
self._modVersions = nil
@@ -2885,6 +3265,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)
@@ -3371,6 +3759,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)
@@ -3711,11 +4169,19 @@ end
-- Turn finished thumbnail downloads into images. Called from update(), so
-- love.graphics.newImage runs on the render thread where it belongs.
-- love.graphics.newImage decodes the PNG and uploads it, both on the render
-- thread. A page's worth of thumbnails landing in the same frame did that
-- many times back to back and dropped the frame, so only this many are
-- decoded per pass; the rest keep their spinner one frame longer.
local THUMB_DECODES_PER_FRAME = 2
function RomImporter:_pumpFindThumbs()
local pending = self._findThumbFetch
if not pending then return end
local Fetch = require("src.net.Fetch")
local decoded = 0
for id, item in pairs(pending) do
if decoded >= THUMB_DECODES_PER_FRAME then break end
local st = Fetch.poll(item.job)
if st.status ~= "pending" then
Fetch.release(item.job)
@@ -3724,6 +4190,7 @@ function RomImporter:_pumpFindThumbs()
if st.status == "ok" and st.path then
local ok, img = pcall(love.graphics.newImage, st.path)
image = ok and img or nil
decoded = decoded + 1
end
self._findThumbs = self._findThumbs or {}
self._findThumbs[id] = image or false
@@ -3740,14 +4207,21 @@ end
-- entry per frame so opening the tab cannot stall for the whole listing.
-- The result is memoized per id for the session; a repo with no releases
-- or a failed fetch resolves to an empty table so it is tried once.
function RomImporter:_findStats(entry)
-- PURE read: whatever is already known for a row, or nil. Resolving a
-- feed-published stat or a repo-less entry is memoization, not network, so it
-- stays here; nothing in this function can start a fetch. That matters
-- because the sort comparator calls it for EVERY entry -- when queueing lived
-- in here, sorting a 500-mod index by Popularity queued 500 GitHub requests
-- on the first frame, blew the hourly rate limit, and the failures then
-- re-queued together every 60s for as long as the tab was open.
function RomImporter:_findStatsCached(entry)
self._findStatsCache = self._findStatsCache or {}
local cached = self._findStatsCache[entry.id]
if cached then
if cached.done or (cached.retryAt and os.time() < cached.retryAt) then
return cached
end
self._findStatsCache[entry.id] = nil -- retry window open, refetch
return nil -- retry window open; _requestFindStats decides what to do
end
if entry.downloads ~= nil or entry.first_release or entry.last_release then
cached = { total = entry.downloads, first = entry.first_release,
@@ -3760,22 +4234,54 @@ function RomImporter:_findStats(entry)
self._findStatsCache[entry.id] = cached
return cached
end
-- ASYNC (was a blocking fetch, one row per frame). "One per frame" bounded
-- how many stalls happened at once, not how long each one lasted: every
-- frame that started a fetch blocked for the whole round trip, so scrolling
-- a listing juddered once per row. Rows now queue a handle and fill in
-- when it lands; until then the row simply has no stats line.
self._findStatsPending = self._findStatsPending or {}
if not self._findStatsPending[entry.id] then
local ModUpdate = require("src.mods.ModUpdate")
self._findStatsPending[entry.id] = {
id = entry.id,
h = ModUpdate.beginFetchReleases(entry.github, entry.id, {}),
}
end
return nil
end
-- Queue one row's release fetch. Only rows actually on the page call this --
-- the rule _findThumb already follows -- so the fan-out is a page, not the
-- whole index.
function RomImporter:_requestFindStats(entry)
if self:_findStatsCached(entry) then return end
if not entry.github or entry.github == "" then return end
local cached = self._findStatsCache[entry.id]
if cached then
if cached.retryAt and os.time() >= cached.retryAt then
self._findStatsCache[entry.id] = nil -- retry window open, refetch
else
return
end
end
self._findStatsPending = self._findStatsPending or {}
if self._findStatsPending[entry.id] then return end
local ModUpdate = require("src.mods.ModUpdate")
self._findStatsPending[entry.id] = {
id = entry.id,
h = ModUpdate.beginFetchReleases(entry.github, entry.id, {}),
}
end
-- Request-and-read, for a row that is being drawn and for the detail modal.
function RomImporter:_findStats(entry)
self:_requestFindStats(entry)
return self:_findStatsCached(entry)
end
-- How many rows are still waiting on a release check, for the panel's
-- progress line.
function RomImporter:_findStatsPendingCount()
local n = 0
for _ in pairs(self._findStatsPending or {}) do n = n + 1 end
return n
end
function RomImporter:_findStatsPendingFor(id)
return (self._findStatsPending and self._findStatsPending[id]) ~= nil
end
function RomImporter:_findThumbPending(id)
return (self._findThumbFetch and self._findThumbFetch[id]) ~= nil
end
-- Drive in-flight FIND MODS stats lookups. Called from update().
function RomImporter:_pumpFindStats()
local pending = self._findStatsPending
+25
View File
@@ -0,0 +1,25 @@
-- The per-version import metadata (symbol table + ROM hash) that drives
-- RomExtractor. Split out of RomImporter so the extraction worker thread can
-- decode it itself: shipping the decoded table across a love.thread channel
-- would deep-copy every symbol for nothing.
local GameVersion = require("src.core.GameVersion")
local RomManifest = {}
function RomManifest.decode(version)
local info = GameVersion.info(version)
local raw, readError = love.filesystem.read(info.manifest)
if not raw then
error("ROM import metadata is missing: " .. tostring(readError))
end
local Json = require("src.link.Json")
local manifest, decodeError = Json.decode(raw)
if not manifest then
error("ROM import metadata is invalid: " .. tostring(decodeError))
end
assert(manifest.romSha1 == info.sha1, "ROM import metadata version mismatch")
return manifest
end
return RomManifest
+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",
+209
View File
@@ -0,0 +1,209 @@
-- Background compute for sandboxed mods, behind the "background" permission.
--
-- mod.fetch covers work that is waiting on a server. This covers work that is
-- waiting on the CPU: a mod hands over a script from its own folder plus a
-- table of plain data, and gets the return value back through the same
-- handle/poll/release shape mod.fetch uses.
--
-- The worker (src/mods/job_worker.lua) builds the SAME Sandbox.envFor
-- environment the main thread does before it loads the mod's chunk, so this
-- is not the love.thread hole reopened: the mod's code still cannot see
-- io, os, debug, ffi, package or love.filesystem, and require is refused
-- outright inside a job.
--
-- One thread per job rather than a pool. A pooled state would carry one
-- mod's globals into the next mod's job, and resetting it properly is the
-- same work as making a new one.
local SafePath = require("src.mods.SafePath")
local Job = {}
Job.MAX_INFLIGHT = 2 -- per mod
Job.MAX_GLOBAL = 4 -- across all mods, so jobs cannot eat every core
Job.DEFAULT_SECONDS = 5
Job.MAX_SECONDS = 30
-- Depth cap on the data crossing the channel. A cycle is caught by the seen
-- set; this catches the merely absurd.
Job.MAX_DEPTH = 16
local nextId = 0
local liveGlobal = 0
-- Only plain data crosses a thread boundary: a function or userdata cannot be
-- serialised, and letting one through would fail deep inside LÖVE instead of
-- at the call the mod made.
local function plain(value, depth, seen)
local t = type(value)
if t == "nil" or t == "boolean" or t == "number" or t == "string" then
return value
end
if t ~= "table" then
return nil, ("a job cannot carry a %s, only plain data"):format(t)
end
depth = (depth or 0) + 1
if depth > Job.MAX_DEPTH then
return nil, "a job's data is nested too deeply"
end
seen = seen or {}
if seen[value] then return nil, "a job cannot carry a cycle" end
seen[value] = true
local out = {}
for k, v in pairs(value) do
local kt = type(k)
if kt ~= "string" and kt ~= "number" then
return nil, ("a job cannot carry a %s key"):format(kt)
end
local copied, err = plain(v, depth, seen)
if err then return nil, err end
out[k] = copied
end
seen[value] = nil
return out
end
Job.plain = plain
function Job.available()
return (love and love.thread and love.thread.newThread) ~= nil
end
local function bucket(loader, modId)
loader.jobs = loader.jobs or {}
local b = loader.jobs[modId]
if not b then b = {}; loader.jobs[modId] = b end
return b
end
local function inflight(b)
local n = 0
for _, job in pairs(b) do
if job.status == "pending" then n = n + 1 end
end
return n
end
-- `script` is relative to the mod's own folder, and goes through the same
-- SafePath rules mod:read does -- a job is not a way to name a path.
-- Argument checks come BEFORE the host check: a bad path or an unserialisable
-- argument is the mod author's bug and should read the same on every machine,
-- not be masked into "unavailable" on a build without threads.
function Job.run(loader, modId, modPath, script, arg, opts)
if type(script) ~= "string" or script == "" then
return nil, "a job needs a script path inside your mod"
end
-- SafePath.require raises rather than returning, so the mod's bad path
-- comes back as a value here instead of unwinding its caller.
local okPath, safe = pcall(SafePath.join, modPath, script, "a job script")
if not okPath then return nil, tostring(safe) end
local payload, dataErr = plain(arg)
if dataErr then return nil, dataErr end
if not Job.available() then return nil, "background jobs are unavailable" end
local b = bucket(loader, modId)
if inflight(b) >= Job.MAX_INFLIGHT then
return nil, ("too many jobs in flight (limit %d); poll and release the "
.. "ones you have"):format(Job.MAX_INFLIGHT)
end
if liveGlobal >= Job.MAX_GLOBAL then
return nil, "the machine is already running as many jobs as it will"
end
opts = type(opts) == "table" and opts or {}
local seconds = tonumber(opts.maxSeconds) or Job.DEFAULT_SECONDS
if seconds > Job.MAX_SECONDS then seconds = Job.MAX_SECONDS end
if seconds < 1 then seconds = 1 end
nextId = nextId + 1
local argName = "modjob_arg_" .. nextId
local resultName = "modjob_result_" .. nextId
local argCh = love.thread.getChannel(argName)
local resCh = love.thread.getChannel(resultName)
argCh:clear()
resCh:clear()
argCh:push(payload == nil and false or payload)
local okNew, thread = pcall(love.thread.newThread, "src/mods/job_worker.lua")
if not okNew or not thread then return nil, "could not start a job thread" end
local Json = require("src.link.Json")
local permissions = select(2, pcall(Json.encode,
loader.mods and loader.mods[modId]
and loader.mods[modId].manifest.permissionSet or {})) or "{}"
local started = pcall(thread.start, thread, modId, safe, argName, resultName,
permissions)
if not started then return nil, "could not start a job thread" end
liveGlobal = liveGlobal + 1
local handle = {}
b[handle] = { thread = thread, resultCh = resCh, status = "pending",
deadline = love.timer.getTime() + seconds, seconds = seconds }
return handle
end
local function settle(job, status, value, err)
if job.status == "pending" then liveGlobal = math.max(0, liveGlobal - 1) end
job.status, job.value, job.err = status, value, err
end
function Job.poll(loader, modId, handle)
local job = bucket(loader, modId)[handle]
if not job then return { status = "error", err = "unknown job" } end
if job.status == "pending" then
local msg = job.resultCh:pop()
if msg then
if msg.ok then settle(job, "ok", msg.result)
else settle(job, "error", nil, msg.err) end
else
-- A worker that died before pushing anything (an error outside its own
-- pcall) would otherwise leave the mod polling forever.
local threadErr = job.thread.getError and job.thread:getError()
if threadErr then
settle(job, "error", nil, tostring(threadErr))
elseif love.timer.getTime() > job.deadline then
-- The budget bounds how long the MOD waits, not how long the work
-- runs: there is no way to stop a LÖVE thread, and every in-worker
-- attempt made things worse (see job_worker.lua). A job that
-- overruns is reported here and its result dropped if it ever lands.
settle(job, "error", nil, ("job exceeded its %gs budget")
:format(job.seconds))
end
end
end
if job.status == "ok" then
-- A copy, so a mod cannot edit what a later poll returns.
return { status = "ok", result = (plain(job.value)) }
end
return { status = job.status, err = job.err }
end
function Job.release(loader, modId, handle)
local b = bucket(loader, modId)
local job = b[handle]
if not job then return false end
if job.status == "pending" then liveGlobal = math.max(0, liveGlobal - 1) end
b[handle] = nil
return true
end
-- There is no way to kill a LÖVE thread, so cancelling drops the result
-- rather than stopping the work; the worker's own time budget is what bounds
-- how long an abandoned job can run.
function Job.cancel(loader, modId, handle)
local job = bucket(loader, modId)[handle]
if not job then return false end
if job.status == "pending" then
settle(job, "cancelled")
end
return true
end
function Job.releaseAll(loader, modId)
local b = loader.jobs and loader.jobs[modId]
if not b then return end
for handle, job in pairs(b) do
if job.status == "pending" then liveGlobal = math.max(0, liveGlobal - 1) end
b[handle] = nil
end
loader.jobs[modId] = nil
end
return Job
+417 -20
View File
@@ -38,9 +38,12 @@ 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 RequiredImports = require("src.mods.RequiredImports")
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
@@ -85,26 +88,190 @@ 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
@@ -165,6 +332,8 @@ function LauncherMods.deriveList(manifests, options, version)
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),
@@ -252,7 +421,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
@@ -291,13 +460,38 @@ function LauncherMods.list(version)
local ok, result = pcall(function()
local options = SaveData.loadOptions()
local manifests = discover()
-- Imports are private player grants. Never scan or copy another mod's
-- baseroms: a matching public digest is not permission to share the file.
local importState = {}
for _, manifest in ipairs(manifests) do
local rows, missing, missingOptional = RequiredImports.inspect(manifest)
importState[manifest.id] = { rows = rows, missing = missing,
missingOptional = missingOptional }
end
-- 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)
local rows = LauncherMods.deriveList(manifests, options, version)
for _, row in ipairs(rows) do
local state = importState[row.id]
or { rows = {}, missing = 0, missingOptional = 0 }
local imports, missing = state.rows, state.missing
row.requiredImports, row.missingRequiredImports = imports, missing
row.imports = imports
row.missingOptionalImports = state.missingOptional or 0
if missing > 0 and row.status == "ok" then
row.status = "needs_import"
local first
for _, import in ipairs(imports) do
if not import.present then first = import break end
end
row.statusDetail = "Needs import: " .. (first and first.name or "required file")
end
end
return rows
end)
if not ok then
-- a single bad options/mod file must not blank the launcher
@@ -386,6 +580,7 @@ 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
@@ -409,6 +604,7 @@ function LauncherMods.setAllEnabled(ids, enabled, version)
end
end
SaveData.saveOptions(options)
LauncherMods.syncActiveProfile(options)
return true
end
@@ -507,6 +703,26 @@ local function copyTree(src, dst)
return true
end
-- User-supplied baseroms are install state, not package content. Snapshot
-- them before replacing a mod tree so an update cannot make the player select
-- the same cartridge again (or destroy their only reusable copy if the new
-- archive later fails to copy).
local function snapshotTree(path, into, relative)
local fs = love.filesystem
into, relative = into or {}, relative or ""
local info = fs.getInfo(path)
if not info then return into end
if info.type == "directory" then
for _, name in ipairs(fs.getDirectoryItems(path) or {}) do
local rel = relative == "" and name or (relative .. "/" .. name)
snapshotTree(path .. "/" .. name, into, rel)
end
elseif relative ~= "" and into[relative] == nil then
into[relative] = fs.read(path)
end
return into
end
-- Delete an installed mod subtree. Enumeration stays on love.filesystem (the
-- portable game folder is on its read path), but the deletes go through
-- CacheFs so a portable install's real files actually go away instead of
@@ -674,9 +890,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)
@@ -749,14 +965,28 @@ function LauncherMods._installZipInner(source, opts)
return nil, ("zip is for '%s', expected '%s'")
:format(manifest.id, opts.expectId)
end
local packagedBaseroms = root .. "/baseroms"
if fs.getInfo(packagedBaseroms, "directory")
and #(fs.getDirectoryItems(packagedBaseroms) or {}) > 0 then
cleanup()
return nil, "mod archives must not include user-supplied baseroms/ files"
end
local dest = "mods/" .. manifest.id
local baseromRecovery = "imports/baseroms-recovery/" .. manifest.id
local existing, installedSomewhere = sameIdTrees(fs, manifest.id)
if installedSomewhere and not opts.replace then
cleanup()
return nil, "a mod named '" .. manifest.id .. "' is already installed"
end
local preservedBaseroms = {}
-- A previous failed update may have staged the user's files outside mods/ so
-- discovery cannot mistake recovery debris for an installed mod.
snapshotTree(baseromRecovery, preservedBaseroms)
if #existing > 0 then
for _, path in ipairs(existing) do
snapshotTree(path .. "/baseroms", preservedBaseroms)
end
-- drop every old tree before copy -- mods/<id> and any same-id folder
-- under another name, or the survivor keeps winning discover()'s
-- first-id-wins race after the "successful" update (#801). A tree with
@@ -780,13 +1010,35 @@ function LauncherMods._installZipInner(source, opts)
CacheFs.prefix = ""
local copied, copyErr = copyTree(root, dest)
if not copied then removeTree(dest) end
local preserveErr
for rel, bytes in pairs(preservedBaseroms) do
if bytes ~= nil then
local restored, restoreErr = CacheFs.write(dest .. "/baseroms/" .. rel, bytes)
if not restored and not preserveErr then
preserveErr = "could not preserve baseroms/" .. rel .. ": "
.. tostring(restoreErr)
end
end
end
if preserveErr then
-- Do not report a successful update that discarded user-owned input, and
-- do not leave a manifest-less baseroms tree that resembles an install.
removeTree(dest)
removeTree(baseromRecovery)
for rel, bytes in pairs(preservedBaseroms) do
if bytes ~= nil then CacheFs.write(baseromRecovery .. "/" .. rel, bytes) end
end
copied, copyErr = nil, preserveErr
elseif copied then
removeTree(baseromRecovery)
end
CacheFs.prefix = savedPrefix
if not copied then
cleanup()
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.
@@ -912,4 +1164,149 @@ function LauncherMods.uninstall(id)
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
end
return LauncherMods
+956
View File
@@ -0,0 +1,956 @@
local Logger = require("src.core.Logger")
local SafePath = require("src.mods.SafePath")
local okSave, SaveData = pcall(require, "src.core.SaveData")
if not okSave then SaveData = nil end
local okStorage, Storage = pcall(require, "src.mods.Storage")
if not okStorage then Storage = nil end
local LegacyCompat = {}
local ROOT = "mod_compat"
local OWN = "_own"
local MAX_SEGMENTS = 24
LegacyCompat.reports = {}
function LegacyCompat.reset()
LegacyCompat.reports = {}
end
function LegacyCompat.report(modId)
if modId then
local entry = LegacyCompat.reports[modId]
return entry and entry.order or {}
end
local out = {}
for id, entry in pairs(LegacyCompat.reports) do
out[id] = entry.order
end
return out
end
local function note(ctx, call, advice)
local entry = LegacyCompat.reports[ctx.modId]
if not entry then
entry = { calls = {}, order = {} }
LegacyCompat.reports[ctx.modId] = entry
end
local row = entry.calls[call]
if row then
row.count = row.count + 1
return
end
row = { call = call, advice = advice, count = 1 }
entry.calls[call] = row
entry.order[#entry.order + 1] = row
Logger.warn("[%s] %s was removed from the mod sandbox; %s", ctx.modId, call,
advice)
end
local function refuse(ctx, call, advice)
note(ctx, call, advice)
return nil, ("%s is not available to mods; %s"):format(call, advice)
end
-- ------- path routing
local function normalize(path)
if type(path) ~= "string" or path == "" then return nil end
path = path:gsub("\\", "/")
while path:find("//", 1, true) do path = (path:gsub("//", "/")) end
if path ~= "/" then path = (path:gsub("/$", "")) end
return path
end
local function flatten(path)
local parts = {}
for segment in path:gmatch("[^/]+") do
if segment ~= "." and segment ~= ".." then
parts[#parts + 1] = (segment:gsub("[^%w_%.%-]", "_"))
end
end
if #parts == 0 then return nil end
while #parts > MAX_SEGMENTS do table.remove(parts, 1) end
return table.concat(parts, "/")
end
local function storageKey(key)
local parts = {}
for segment in key:gmatch("[^/]+") do parts[#parts + 1] = segment end
if #parts == 0 then return nil end
parts[#parts] = (parts[#parts]:gsub("%.[^%.]*$", ""))
for i = 1, #parts do
local cleaned = (parts[i]:gsub("[^%w_%-]", "_"))
if cleaned == "" then return nil end
parts[i] = cleaned
end
return table.concat(parts, "/")
end
local function ownRelative(ctx, path)
if not ctx.modPath then return nil end
if path == ctx.modPath then return "" end
if path:sub(1, #ctx.modPath + 1) == ctx.modPath .. "/" then
return path:sub(#ctx.modPath + 2)
end
return nil
end
local function ownFull(ctx, rel)
if rel == "" then return ctx.modPath end
local safe = SafePath.safe(rel)
if not safe then return nil end
return ctx.modPath .. "/" .. safe
end
local function ownExists(ctx, rel)
local full = ownFull(ctx, rel)
local fs = ctx.fs
if not (full and fs and fs.getInfo) then return nil end
return fs.getInfo(full)
end
local function classify(ctx, path)
path = normalize(path)
if not path then return nil end
local root = ctx.virtualRoot
if path == root then return { key = "", dir = true } end
if path:sub(1, #root + 1) == root .. "/" then
return { key = flatten(path:sub(#root + 2)) }
end
local rel = ownRelative(ctx, path)
if not rel and path:sub(1, 1) ~= "/" and not path:match("^%a:")
and ownExists(ctx, path) then
rel = path
end
if rel then
if rel == "" then return { key = OWN, rel = "", dir = true } end
local flat = flatten(rel)
return { key = flat and (OWN .. "/" .. flat) or nil, rel = rel }
end
return { key = flatten(path) }
end
-- ------- the overlay
local function persistFs(ctx)
if SaveData and SaveData.persistenceFs then
return SaveData.persistenceFs(ctx.fs)
end
return ctx.fs
end
local function overlayPath(ctx, key)
if key == nil or key == "" then return ROOT .. "/" .. ctx.modId end
return ROOT .. "/" .. ctx.modId .. "/" .. key
end
local function ensureParent(fs, path)
if not fs.createDirectory then return end
local dir = path:match("^(.*)/[^/]+$")
if not dir then return end
local built = nil
for segment in dir:gmatch("[^/]+") do
built = built and (built .. "/" .. segment) or segment
fs.createDirectory(built)
end
end
local function overlayRead(ctx, key)
local fs = persistFs(ctx)
if not (fs and fs.read and fs.getInfo) then return nil end
local path = overlayPath(ctx, key)
local info = fs.getInfo(path)
if not info or info.type == "directory" then return nil end
local body = fs.read(path)
if type(body) ~= "string" then return nil end
return body
end
local function overlayWrite(ctx, key, data)
local fs = persistFs(ctx)
if not (fs and fs.write) then
return false, "no writable filesystem is available"
end
local path = overlayPath(ctx, key)
ensureParent(fs, path)
local ok, err = fs.write(path, data)
if ok == false then return false, err or "write failed" end
return true
end
local function overlayRemove(ctx, key)
local fs = persistFs(ctx)
if not (fs and fs.remove and fs.getInfo) then return false end
local path = overlayPath(ctx, key)
if not fs.getInfo(path) then return false end
fs.remove(path)
return true
end
local function overlayInfo(ctx, key)
local fs = persistFs(ctx)
if not (fs and fs.getInfo) then return nil end
return fs.getInfo(overlayPath(ctx, key))
end
local function storageRead(ctx, key)
if not (Storage and key and key ~= "") then return nil end
if key:sub(1, #OWN + 1) == OWN .. "/" then return nil end
local game = ctx.game and ctx.game()
if not game then return nil end
local sk = storageKey(key)
if not sk then return nil end
ctx.storage = ctx.storage or Storage.new(ctx.modId, ctx.fs)
local ok, bytes = pcall(ctx.storage.readBytes, ctx.storage, game, sk)
if ok and type(bytes) == "string" then return bytes end
return nil
end
local function readPath(ctx, path)
local at = classify(ctx, path)
if not at then return nil, "invalid path" end
local body = at.key and overlayRead(ctx, at.key)
if body then return body end
if at.rel then
local full = ownFull(ctx, at.rel)
local fs = ctx.fs
if full and fs and fs.read then
local packaged = fs.read(full)
if type(packaged) == "string" then return packaged end
end
end
body = at.key and storageRead(ctx, at.key)
if body then return body end
return nil, "could not open " .. tostring(path)
end
local function writePath(ctx, path, data)
local at = classify(ctx, path)
if not (at and at.key) then return false, "invalid path" end
return overlayWrite(ctx, at.key, data)
end
local function infoPath(ctx, path)
local at = classify(ctx, path)
if not at then return nil end
if at.key then
local info = overlayInfo(ctx, at.key)
if info then
return { type = info.type, size = info.size, modtime = info.modtime }
end
end
if at.rel then
local info = ownExists(ctx, at.rel)
if info then
return { type = info.type, size = info.size, modtime = info.modtime }
end
end
local stored = at.key and storageRead(ctx, at.key)
if stored then return { type = "file", size = #stored } end
return nil
end
local function listPath(ctx, path)
local at = classify(ctx, path)
local seen, out = {}, {}
local function add(name)
if name and name ~= "" and not seen[name] then
seen[name] = true
out[#out + 1] = name
end
end
if at and at.rel then
local full = ownFull(ctx, at.rel)
local fs = ctx.fs
if full and fs and fs.getDirectoryItems then
for _, name in ipairs(fs.getDirectoryItems(full) or {}) do add(name) end
end
end
if at and at.key then
local fs = persistFs(ctx)
if fs and fs.getDirectoryItems then
for _, name in ipairs(fs.getDirectoryItems(overlayPath(ctx, at.key)) or {}) do
add(name)
end
end
end
table.sort(out)
return out
end
-- ------- buffered file handles
local function openBuffer(ctx, path, mode)
mode = tostring(mode or "r"):gsub("b", "")
local writable = mode:find("[wa+]") ~= nil
local body
if mode:sub(1, 1) == "w" then
body = ""
else
body = readPath(ctx, path)
if not body then
if not writable then return nil, "could not open " .. tostring(path) end
body = ""
end
end
local state = {
ctx = ctx, path = path, buf = body, pos = 1,
writable = writable, closed = false, dirty = false,
}
if mode:sub(1, 1) == "a" then state.pos = #body + 1 end
return state
end
local function bufferFlush(state)
if not (state.writable and state.dirty) then return true end
local ok, err = writePath(state.ctx, state.path, state.buf)
if ok then state.dirty = false end
return ok, err
end
local function bufferWrite(state, text)
if not state.writable then return false, "file is not open for writing" end
local head = state.buf:sub(1, state.pos - 1)
if #head < state.pos - 1 then head = head .. string.rep("\0", state.pos - 1 - #head) end
local tail = state.buf:sub(state.pos + #text)
state.buf = head .. text .. tail
state.pos = state.pos + #text
state.dirty = true
return true
end
local function bufferRead(state, fmt)
if fmt == nil then fmt = "*l" end
if type(fmt) == "number" then
if fmt == 0 then return state.pos <= #state.buf and "" or nil end
if state.pos > #state.buf then return nil end
local chunk = state.buf:sub(state.pos, state.pos + fmt - 1)
state.pos = state.pos + #chunk
return chunk
end
fmt = tostring(fmt):gsub("^%*", "")
if fmt == "a" then
local rest = state.buf:sub(state.pos)
state.pos = #state.buf + 1
return rest
end
if fmt == "l" or fmt == "L" then
if state.pos > #state.buf then return nil end
local nl = state.buf:find("\n", state.pos, true)
local line
if nl then
line = state.buf:sub(state.pos, fmt == "L" and nl or nl - 1)
state.pos = nl + 1
else
line = state.buf:sub(state.pos)
state.pos = #state.buf + 1
end
return line
end
if fmt == "n" then
local rest = state.buf:sub(state.pos)
local text, after = rest:match("^%s*(%-?%d+%.?%d*[eE]?[-+]?%d*)()")
if not text then return nil end
state.pos = state.pos + after - 1
return tonumber(text)
end
return nil
end
local function bufferSeek(state, whence, offset)
whence = whence or "cur"
offset = offset or 0
if whence == "set" then state.pos = offset + 1
elseif whence == "cur" then state.pos = state.pos + offset
elseif whence == "end" then state.pos = #state.buf + offset + 1
else return nil, "bad seek base" end
if state.pos < 1 then state.pos = 1 end
return state.pos - 1
end
local function bufferClose(state)
if state.closed then return true end
local ok, err = bufferFlush(state)
state.closed = true
return ok, err
end
local function ioFile(ctx, path, mode)
local state, err = openBuffer(ctx, path, mode)
if not state then return nil, err end
local file = {}
function file:read(...)
local count = select("#", ...)
if count == 0 then return bufferRead(state, "*l") end
local out = {}
for i = 1, count do out[i] = bufferRead(state, (select(i, ...))) end
return unpack(out, 1, count)
end
function file:write(...)
for i = 1, select("#", ...) do
local value = select(i, ...)
local ok, writeErr = bufferWrite(state, tostring(value))
if not ok then return nil, writeErr end
end
return file
end
function file:lines(fmt)
return function() return bufferRead(state, fmt or "*l") end
end
function file:seek(whence, offset) return bufferSeek(state, whence, offset) end
function file:flush() bufferFlush(state) return file end
function file:close() return bufferClose(state) end
function file:setvbuf() return true end
return file
end
local function loveFile(ctx, path, mode)
local state = nil
local file = {}
function file:open(openMode)
local opened, err = openBuffer(ctx, path, openMode or mode or "r")
if not opened then return false, err end
state = opened
return true
end
function file:isOpen() return state ~= nil and not state.closed end
function file:read(bytes)
if not state and not file:open("r") then return nil, 0 end
local body = bytes and bufferRead(state, bytes) or bufferRead(state, "*a")
if not body then return nil, 0 end
return body, #body
end
function file:write(data, size)
if not state and not file:open(mode or "w") then return false end
data = tostring(data)
if size then data = data:sub(1, size) end
local ok, err = bufferWrite(state, data)
if not ok then return false, err end
bufferFlush(state)
return true
end
function file:lines()
if not state then file:open("r") end
return function() return state and bufferRead(state, "*l") or nil end
end
function file:seek(offset)
if not state then return false end
bufferSeek(state, "set", offset or 0)
return true
end
function file:tell() return state and (state.pos - 1) or 0 end
function file:getSize()
if state then return #state.buf end
local body = readPath(ctx, path)
return body and #body or 0
end
function file:flush() if state then bufferFlush(state) end return true end
function file:close()
if state then bufferClose(state) end
state = nil
return true
end
function file:getFilename() return path end
function file:getMode() return mode or "c" end
function file:setBuffer() return true end
if mode and mode ~= "c" then file:open(mode) end
return file
end
-- ------- the love.filesystem stand-in
local function realFilesystem()
return _G.love and _G.love.filesystem or nil
end
local function filesystemShim(ctx)
local fsShim = {}
local function readAdvice() return "reads now come from mod:read and mod.storage" end
function fsShim.read(a, b, c)
local path, size = a, b
if (a == "string" or a == "data") and type(b) == "string" then
path, size = b, c
end
note(ctx, "love.filesystem.read", readAdvice())
local body, err = readPath(ctx, path)
if not body then return nil, err end
if size and size >= 0 then body = body:sub(1, size) end
return body, #body
end
function fsShim.write(path, data, size)
note(ctx, "love.filesystem.write",
"writes are rerouted to this mod's private compat storage; migrate to mod.storage")
data = tostring(data)
if size then data = data:sub(1, size) end
local ok, err = writePath(ctx, path, data)
if not ok then return false, err end
return true
end
function fsShim.append(path, data, size)
note(ctx, "love.filesystem.append",
"writes are rerouted to this mod's private compat storage; migrate to mod.storage")
data = tostring(data)
if size then data = data:sub(1, size) end
local existing = readPath(ctx, path) or ""
local ok, err = writePath(ctx, path, existing .. data)
if not ok then return false, err end
return true
end
function fsShim.lines(path)
note(ctx, "love.filesystem.lines", readAdvice())
local body = readPath(ctx, path)
if not body then error("could not open " .. tostring(path), 2) end
local pos = 1
return function()
if pos > #body then return nil end
local nl = body:find("\n", pos, true)
local line
if nl then
line = body:sub(pos, nl - 1)
pos = nl + 1
else
line = body:sub(pos)
pos = #body + 1
end
return line
end
end
function fsShim.load(path)
note(ctx, "love.filesystem.load",
"the chunk is compiled into this mod's sandbox; prefer require or mod:read plus load")
local body = readPath(ctx, path)
if not body then return nil, "could not open " .. tostring(path) end
if not ctx.compile then return nil, "no sandbox is bound yet" end
return ctx.compile(body, "@" .. tostring(path))
end
function fsShim.getInfo(path, a, b)
local info = infoPath(ctx, path)
local filter = type(a) == "string" and a or nil
local into = type(a) == "table" and a or (type(b) == "table" and b or nil)
if not info then return nil end
if filter and info.type ~= filter then return nil end
if into then
into.type, into.size, into.modtime = info.type, info.size, info.modtime
return into
end
return info
end
function fsShim.getDirectoryItems(path)
note(ctx, "love.filesystem.getDirectoryItems", "use mod.assets:list")
return listPath(ctx, path)
end
function fsShim.createDirectory(path)
local at = classify(ctx, path)
if not (at and at.key) then return false end
local fs = persistFs(ctx)
if not (fs and fs.createDirectory) then return false end
ensureParent(fs, overlayPath(ctx, at.key) .. "/.")
fs.createDirectory(overlayPath(ctx, at.key))
return true
end
function fsShim.remove(path)
local at = classify(ctx, path)
if not (at and at.key) then return false end
return overlayRemove(ctx, at.key)
end
function fsShim.exists(path) return infoPath(ctx, path) ~= nil end
function fsShim.isFile(path)
local info = infoPath(ctx, path)
return info ~= nil and info.type == "file"
end
function fsShim.isDirectory(path)
local info = infoPath(ctx, path)
return info ~= nil and info.type == "directory"
end
function fsShim.getSize(path)
local info = infoPath(ctx, path)
if not info then return nil, "could not open " .. tostring(path) end
return info.size or 0
end
function fsShim.getLastModified(path)
local info = infoPath(ctx, path)
if not info then return nil, "could not open " .. tostring(path) end
return info.modtime or 0
end
local function virtual(call)
note(ctx, call, "paths are virtual now; everything under the returned root "
.. "lands in this mod's private compat storage")
return ctx.virtualRoot
end
function fsShim.getSaveDirectory() return virtual("love.filesystem.getSaveDirectory") end
function fsShim.getWorkingDirectory() return virtual("love.filesystem.getWorkingDirectory") end
function fsShim.getUserDirectory() return virtual("love.filesystem.getUserDirectory") end
function fsShim.getAppdataDirectory() return virtual("love.filesystem.getAppdataDirectory") end
function fsShim.getSourceBaseDirectory() return virtual("love.filesystem.getSourceBaseDirectory") end
function fsShim.getRealDirectory() return ctx.virtualRoot end
function fsShim.getIdentity() return ctx.modId end
function fsShim.setIdentity()
note(ctx, "love.filesystem.setIdentity",
"a mod cannot repoint the save directory; the call does nothing")
return false
end
function fsShim.getRequirePath() return "" end
function fsShim.setRequirePath() return false end
function fsShim.getCRequirePath() return "" end
function fsShim.setCRequirePath() return false end
function fsShim.mount()
note(ctx, "love.filesystem.mount",
"mounting is refused; ship the files inside your mod and use mod:read")
return false
end
fsShim.unmount = fsShim.mount
function fsShim.newFile(path, mode) return loveFile(ctx, path, mode) end
function fsShim.newFileData(a, b)
local real = realFilesystem()
if type(a) == "string" and b == nil then
note(ctx, "love.filesystem.newFileData", readAdvice())
local body = readPath(ctx, a)
if not body then return nil, "could not open " .. tostring(a) end
if real and real.newFileData then return real.newFileData(body, a) end
return nil, "no filesystem"
end
if real and real.newFileData then return real.newFileData(a, b) end
return nil, "no filesystem"
end
function fsShim.isFused()
local real = realFilesystem()
return real and real.isFused and real.isFused() or false
end
function fsShim.areSymlinksEnabled() return false end
function fsShim.setSymlinksEnabled() return false end
function fsShim.init() return false end
function fsShim.setSource() return false end
return setmetatable(fsShim, { __index = function(_, key)
note(ctx, "love.filesystem." .. tostring(key),
"there is no compat stand-in for it; use mod.storage or mod:read")
return nil
end })
end
-- ------- the rest of the removed surface
local function systemShim(ctx)
local function real() return _G.love and _G.love.system or nil end
-- tls* comes from the engine (Android JNI, or desktop gen1tls hung on
-- love.system at boot). Forward those; keep clipboard / openURL stubbed.
local TLS = {
tlsOpen = true, tlsStatus = true, tlsSend = true,
tlsReceive = true, tlsError = true, tlsClose = true,
}
local shim = {
getOS = function()
local sys = real()
return sys and sys.getOS and sys.getOS() or "Unknown"
end,
getPowerInfo = function()
local sys = real()
if not (sys and sys.getPowerInfo) then return "unknown", nil, nil end
return sys.getPowerInfo()
end,
getProcessorCount = function()
local sys = real()
return sys and sys.getProcessorCount and sys.getProcessorCount() or 1
end,
getClipboardText = function()
note(ctx, "love.system.getClipboardText",
"clipboard access stays sandboxed; the call returns an empty string")
return ""
end,
setClipboardText = function()
note(ctx, "love.system.setClipboardText",
"clipboard access stays sandboxed; the call does nothing")
return false
end,
openURL = function()
note(ctx, "love.system.openURL",
"launching a URL stays sandboxed; the call does nothing")
return false
end,
vibrate = function(...)
local sys = real()
if sys and sys.vibrate then return sys.vibrate(...) end
return false
end,
}
return setmetatable(shim, {
__index = function(_, key)
if not TLS[key] then return nil end
local sys = real()
return sys and sys[key]
end,
})
end
local function eventShim(ctx)
local function real() return _G.love and _G.love.event or nil end
local shim = {
quit = function()
note(ctx, "love.event.quit",
"a mod cannot close the game out from under the player; the call does nothing")
return false
end,
}
shim.push = function(name, ...)
if name == "quit" then return shim.quit() end
local ev = real()
if ev and ev.push then return ev.push(name, ...) end
return false
end
return setmetatable(shim, { __index = function(_, key)
local ev = real()
return ev and ev[key] or nil
end })
end
local function ioShim(ctx)
local stream = {
write = function(self, ...)
for i = 1, select("#", ...) do io.write(tostring((select(i, ...)))) end
return self
end,
flush = function(self) return self end,
close = function() return true end,
read = function() return nil end,
lines = function() return function() return nil end end,
seek = function() return 0 end,
setvbuf = function() return true end,
}
local out = setmetatable({}, { __index = stream })
local shim = {
open = function(path, mode)
note(ctx, "io.open",
"the handle is backed by mod:read and this mod's private compat storage")
local file, err = ioFile(ctx, path, mode)
if not file then return nil, err, 2 end
return file
end,
lines = function(path, fmt)
if path == nil then return function() return nil end end
note(ctx, "io.lines",
"the handle is backed by mod:read and this mod's private compat storage")
local file, err = ioFile(ctx, path, "r")
if not file then error(err, 2) end
return file:lines(fmt)
end,
close = function(file) if file and file.close then return file:close() end return true end,
type = function(file)
if type(file) == "table" and file.read and file.close then return "file" end
return nil
end,
read = function() return nil end,
write = function(...) return out:write(...) end,
input = function() return out end,
output = function() return out end,
stdout = out,
stderr = out,
stdin = setmetatable({}, { __index = stream }),
popen = function()
return refuse(ctx, "io.popen", "spawning a process is refused")
end,
tmpfile = function()
return ioFile(ctx, ctx.virtualRoot .. "/io_tmpfile", "w+")
end,
}
return shim
end
local function osShim(ctx)
local HOME = { HOME = true, APPDATA = true, LOCALAPPDATA = true,
USERPROFILE = true, XDG_DATA_HOME = true,
XDG_CONFIG_HOME = true, TMPDIR = true, TEMP = true, TMP = true }
return {
getenv = function(name)
note(ctx, "os.getenv",
"the environment is hidden; home-like names answer with this mod's virtual root")
if type(name) == "string" and HOME[name:upper()] then
return ctx.virtualRoot
end
return nil
end,
remove = function(path)
note(ctx, "os.remove", "the delete lands in this mod's private compat storage")
local at = classify(ctx, path)
if not (at and at.key and overlayRemove(ctx, at.key)) then
return nil, tostring(path) .. ": no such file"
end
return true
end,
rename = function(from, to)
note(ctx, "os.rename", "the move lands in this mod's private compat storage")
local body = readPath(ctx, from)
if not body then return nil, tostring(from) .. ": no such file" end
local ok, err = writePath(ctx, to, body)
if not ok then return nil, err end
local at = classify(ctx, from)
if at and at.key then overlayRemove(ctx, at.key) end
return true
end,
tmpname = function()
return ctx.virtualRoot .. "/tmp/os_tmpname"
end,
execute = function()
note(ctx, "os.execute", "running a command is refused")
return false
end,
exit = function()
note(ctx, "os.exit",
"a mod cannot end the process; the call does nothing")
return false
end,
setlocale = function() return "C" end,
}
end
local function packageShim(ctx)
local shim = { path = "", cpath = "", preload = {}, loaded = {}, loaders = {} }
return setmetatable(shim, {
__index = function(_, key)
note(ctx, "package." .. tostring(key),
"the module loader is sandboxed; use require for the supported engine modules")
return nil
end,
})
end
-- ------- love callbacks
local CALLBACKS = {
load = true, update = true, draw = true, quit = true, lowmemory = true,
threaderror = true, keypressed = true, keyreleased = true,
textinput = true, textedited = true, mousemoved = true,
mousepressed = true, mousereleased = true, wheelmoved = true,
mousefocus = true, touchpressed = true, touchreleased = true,
touchmoved = true, joystickadded = true, joystickremoved = true,
joystickpressed = true, joystickreleased = true, joystickaxis = true,
joystickhat = true, gamepadpressed = true, gamepadreleased = true,
gamepadaxis = true, focus = true, visible = true, resize = true,
filedropped = true, directorydropped = true, displayrotated = true,
audiodevicechanged = true, localechanged = true,
}
local REFUSED = {
run = "love.run is the engine's fixed-step loop; use mod.hooks and mod.events",
errorhandler = "love.errorhandler is how a crash reaches the player; "
.. "use mod.events",
}
-- ------- assembly
function LegacyCompat.new(opts)
opts = opts or {}
local ctx = {
modId = opts.modId or "mod",
modPath = opts.modPath,
fs = opts.fs,
game = opts.game,
compile = nil,
}
ctx.virtualRoot = "/pokeport/" .. ctx.modId
local filesystem = filesystemShim(ctx)
local io_ = ioShim(ctx)
local system = systemShim(ctx)
local event = eventShim(ctx)
local compat = {
ctx = ctx,
love = { filesystem = filesystem, system = system, event = event },
os = osShim(ctx),
globals = {
io = io_,
package = packageShim(ctx),
loadfile = function(path)
note(ctx, "loadfile",
"the chunk is compiled into this mod's sandbox; prefer require or mod:read")
local body = readPath(ctx, path)
if not body then return nil, "could not open " .. tostring(path) end
if not ctx.compile then return nil, "no sandbox is bound yet" end
return ctx.compile(body, "@" .. tostring(path))
end,
},
modules = {
io = io_,
["love.filesystem"] = filesystem,
["love.system"] = system,
["love.event"] = event,
},
}
compat.globals.dofile = function(path)
local chunk, err = compat.globals.loadfile(path)
if not chunk then error(err or ("could not open " .. tostring(path)), 2) end
return chunk()
end
function compat.module(name)
if type(name) ~= "string" then return nil end
local substitute = compat.modules[name]
if substitute then
note(ctx, ('require("%s")'):format(name),
"it now answers with the compat stand-in, not the real module")
return substitute
end
if name == "os" then
note(ctx, 'require("os")',
"it now answers with the compat stand-in, not the real module")
return compat.osTable
end
return nil
end
-- true when the assignment is a callback chain, which is what a mod
-- wrapping love.mousemoved did before the sandbox; false plus a reason for
-- the two names the engine owns, false alone for a module table.
function compat.assign(key, value)
if REFUSED[key] then
note(ctx, ("love.%s assignment"):format(key), REFUSED[key])
return false, ("[%s] %s"):format(ctx.modId, REFUSED[key])
end
if not CALLBACKS[key] then return false end
if value ~= nil and type(value) ~= "function" then return false end
note(ctx, ("love.%s assignment"):format(key),
"the callback lands on the real love table the way it did before the "
.. "sandbox; prefer mod.hooks and mod.events")
return true
end
function compat.bind(env, compile)
ctx.compile = compile
compat.osTable = env and env.os or nil
end
return compat
end
return LegacyCompat
+275 -64
View File
@@ -4,6 +4,7 @@ local SaveData = require("src.core.SaveData")
local Data = require("src.core.Data")
local GameVersion = require("src.core.GameVersion")
local Version = require("src.core.Version")
local RequiredImports = require("src.mods.RequiredImports")
local Assets = require("src.render.Assets")
local ModUI = require("src.ui.ModUI")
local DateTime = require("src.core.DateTime")
@@ -12,12 +13,18 @@ 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 LegacyCompat = require("src.mods.LegacyCompat")
local Runtime = require("src.mods.Runtime")
local Steps = require("src.mods.Steps")
local Net = require("src.mods.Net")
local Job = require("src.mods.Job")
local Loader = {}
Loader.__index = Loader
@@ -72,10 +79,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 +144,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 +198,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 +206,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 +256,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
@@ -357,11 +378,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
@@ -546,7 +569,24 @@ function Loader:_validate()
elseif manifest.assets_transforms
and not self:_exists(mod.path .. "/" .. manifest.assets_transforms) then
reason = "assets_transforms file missing: " .. manifest.assets_transforms
elseif manifest.game_version and not devEngine() then
end
if not reason and #(manifest.required_imports or {}) > 0 then
for _, import in ipairs(manifest.required_imports) do
local path = mod.path .. "/baseroms/" .. import.file
if not self:_exists(path) then
reason = "required import missing: " .. import.name
break
end
local valid, importErr = RequiredImports.validateStored(
manifest, import, self.fs)
if not valid then
reason = "required import invalid: " .. import.name
.. " (" .. tostring(importErr) .. ")"
break
end
end
end
if not reason and manifest.game_version and not devEngine() then
local ok, err = Semver.satisfies(Version.engine, manifest.game_version)
if not ok then
reason = ("needs game version %s, engine is %s")
@@ -561,42 +601,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
@@ -606,6 +650,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 = {}
@@ -616,14 +662,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
@@ -674,6 +722,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
@@ -686,9 +736,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()
@@ -980,6 +1038,85 @@ 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)(),
-- Background HTTP, behind the "network" permission the player already
-- sees. This is what love.thread is NOT: the worker runs engine code in
-- an engine-owned pool, so a mod gets asynchrony without getting a Lua
-- state the sandbox cannot reach. get() hands back an opaque handle;
-- poll() is non-blocking, so nothing here can hang a frame.
fetch = (function()
if mod.manifest.permissionSet.network then
return {
available = function() return Net.available() end,
get = function(_, url, opts) return Net.get(loader, modId, url, opts) end,
poll = function(_, handle) return Net.poll(loader, modId, handle) end,
release = function(_, handle) return Net.release(loader, modId, handle) end,
cancel = function(_, handle) return Net.cancel(loader, modId, handle) end,
}
end
local function refuse()
error(('[%s] mod.fetch needs the "network" permission in '
.. "manifest.json"):format(modId), 2)
end
return { available = function() return false end,
get = refuse, poll = refuse, release = refuse, cancel = refuse }
end)(),
-- Background compute, behind the "background" permission. The worker
-- rebuilds this mod's sandbox before loading the script, so a job is the
-- one thing love.thread is not: off the main thread without a Lua state
-- that escapes the sandbox. Plain data in, plain data out.
job = (function()
if mod.manifest.permissionSet.background then
return {
available = function() return Job.available() end,
run = function(_, script, arg, opts)
return Job.run(loader, modId, mod.path, script, arg, opts)
end,
poll = function(_, handle) return Job.poll(loader, modId, handle) end,
release = function(_, handle) return Job.release(loader, modId, handle) end,
cancel = function(_, handle) return Job.cancel(loader, modId, handle) end,
}
end
local function refuse()
error(('[%s] mod.job needs the "background" permission in '
.. "manifest.json"):format(modId), 2)
end
return { available = function() return false end,
run = refuse, poll = refuse, release = refuse, cancel = refuse }
end)(),
-- namespaced per mod; M11 backs these with save.modData /
-- options.modOptions, the shape mods compile against is already final
save = {
@@ -998,7 +1135,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 = {
@@ -1006,6 +1144,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,
},
@@ -1087,12 +1229,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,
@@ -1101,11 +1278,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
@@ -1148,9 +1330,33 @@ 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
local loader = self
local compat = LegacyCompat.new({
modId = id, modPath = mod.path, fs = self.fs,
game = function() return loader:_game() end,
})
env = Sandbox.envFor({ modId = id, permissions = mod.manifest.permissionSet,
compat = compat })
self.modEnv[id] = env
end
return env
end
-- Which pre-sandbox calls each loaded mod actually took, for the manager's
-- "needs updating" badge; nil id answers for every mod.
function Loader:legacyReport(modId)
return LegacyCompat.report(modId)
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)
@@ -1182,6 +1388,9 @@ function Loader:_rollback(modId)
self.optionSchemas[modId] = nil
self.migrations[modId] = nil
self.modSave[modId] = nil
self.stepsQueues[modId] = nil
Net.releaseAll(self, modId)
Job.releaseAll(self, modId)
end
-- a mod that explicitly swears it stays link-compatible while writing into a
@@ -1365,10 +1574,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
+14 -7
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")
@@ -935,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
+181 -27
View File
@@ -3,13 +3,16 @@
-- 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,
background = 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 +34,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 +56,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 = {}, {}
@@ -87,13 +133,97 @@ local function mergeConflictLists(conflicts, incompatible)
return out
end
local scrubUtf8 -- shared by required-import labels and top-level strings
-- User-supplied files a mod needs beside its own source. The launcher owns
-- the picker and the copy; the mod only reads the resulting
-- <mod>/baseroms/<file> through its existing scoped mod:read surface. MD5 is
-- deliberately the manifest vocabulary here: ROM preservation databases and
-- the mods consuming these files commonly identify dumps by MD5, and the
-- digest is an identity check rather than a security boundary.
local function parseImports(value, field, required)
field = field or "required_imports"
local out, ids, files = {}, {}, {}
for _, entry in ipairs(array(value)) do
assert(type(entry) == "table", field .. " entries must be objects")
local id = entry.id
assert(type(id) == "string" and id:match("^[%w_%-]+$"),
field .. " id must contain only letters, numbers, _ or -")
assert(not ids[id], "duplicate " .. field .. " id: " .. id)
ids[id] = true
local file = entry.file
assert(type(file) == "string" and file ~= "",
field .. " file is required")
file = SafePath.require(file, field .. " file")
assert(not file:find("/", 1, true),
field .. " file must be a filename inside baseroms")
assert(file:sub(1, 1) ~= ".",
field .. " file must not use a hidden metadata filename")
assert(not files[file], "duplicate " .. field .. " file: " .. file)
files[file] = true
local hashes = entry.md5
if type(hashes) == "string" then hashes = { hashes } end
assert(type(hashes) == "table" and #hashes > 0,
field .. " md5 must be a hash or non-empty array")
local accepted, seen = {}, {}
for _, digest in ipairs(hashes) do
assert(type(digest) == "string" and digest:match("^[%x]+$")
and #digest == 32, field .. " md5 values must be 32 hex characters")
digest = digest:lower()
if not seen[digest] then
seen[digest] = true
accepted[#accepted + 1] = digest
end
end
local format = entry.format or "raw"
assert(format == "raw" or format == "n64",
field .. " format must be raw or n64")
local name = entry.name or id
assert(type(name) == "string" and name ~= "",
field .. " name must be a non-empty string")
local description = entry.description or entry.hint
if description ~= nil then
assert(type(description) == "string" and description ~= "",
field .. " description must be a non-empty string")
end
local size = entry.size
local maxSize = entry.max_size
local function validateSize(value, label)
if value == nil then return end
assert(type(value) == "number" and value > 0 and value % 1 == 0,
field .. " " .. label .. " must be a positive integer")
assert(value <= 128 * 1024 * 1024,
field .. " " .. label .. " exceeds the 128 MiB hard limit")
end
validateSize(size, "size")
validateSize(maxSize, "max_size")
assert(not (size and maxSize) or size <= maxSize,
field .. " size must not exceed max_size")
out[#out + 1] = {
id = id,
name = scrubUtf8(name),
description = scrubUtf8(description),
file = file,
md5 = accepted,
format = format,
size = size,
max_size = maxSize,
required = required ~= false,
}
end
return out
end
-- Drop bytes that are not valid UTF-8 (malformed sequences, overlongs,
-- surrogates, > U+10FFFF) and a leading BOM. LÖVE's text renderer raises
-- "Invalid UTF-8" from love.graphics.print/printf, so any manifest string a
-- panel may draw must be scrubbed here -- the one place every mod manifest
-- passes through -- or a single mangled description crashes the whole MODS
-- panel instead of misrendering one card.
local function scrubUtf8(s)
scrubUtf8 = function(s)
if type(s) ~= "string" then return s end
s = s:gsub("^\239\187\191", "")
local out, i, n = {}, 1, #s
@@ -144,6 +274,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 +362,45 @@ 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)
local requiredImports = parseImports(raw.required_imports,
"required_imports", true)
local optionalImports = parseImports(raw.optional_imports,
"optional_imports", false)
local importIds, importFiles = {}, {}
for _, list in ipairs({ requiredImports, optionalImports }) do
for _, import in ipairs(list) do
assert(not importIds[import.id], "duplicate import id: " .. import.id)
assert(not importFiles[import.file], "duplicate import file: " .. import.file)
importIds[import.id], importFiles[import.file] = true, true
end
end
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 +416,10 @@ 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"),
required_imports = requiredImports,
optional_imports = optionalImports,
-- 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
+143
View File
@@ -0,0 +1,143 @@
-- Background HTTP for sandboxed mods, behind the "network" permission.
--
-- The sandbox blocks love.thread because newThread boots a Lua state with a
-- full standard library that none of the sandbox's rules reach -- one call and
-- a mod has io back. That is correct, but it left mods with no way to do
-- anything off the main thread at all: the only reachable transports
-- (socket, http) block, so a mod that wanted to fetch something had to hang
-- the game to do it.
--
-- This is the narrow replacement. src/net/Fetch.lua already runs a pool of
-- engine-owned worker threads, and those workers run OUR code, not the mod's,
-- so handing a mod a job in that pool grants no new reach. A mod submits a
-- URL and polls for the body; it never gets a thread, a path, or a raw handle
-- into the shared job table.
--
-- WHAT THIS FILE HAS TO GET RIGHT, because Fetch itself is shared with the
-- launcher:
-- * Handles are opaque tables owned per mod. Fetch keys jobs by integer,
-- and the launcher's own ROM download and index fetches live in the same
-- table; an integer handed to a mod would let it poll (or cancel) work
-- that is not its own. A forged table simply misses the lookup.
-- * Only http and https. The transport is curl, which also speaks file://,
-- scp:// and ftp://; without this check mod.fetch would be a filesystem
-- read and the sandbox would be back to square one.
-- * A per-mod ceiling on jobs in flight, so one mod cannot fill the shared
-- three-worker pool and starve the launcher's own fetches.
local Net = {}
-- Per mod, not global: the pool is shared with the launcher and a mod should
-- never be able to monopolise it.
Net.MAX_INFLIGHT = 4
-- Clamp on the caller's timeout, so a mod cannot pin a worker indefinitely.
Net.MAX_SECONDS = 30
local function fetch()
return require("src.net.Fetch")
end
-- http/https only, and a host must actually be present -- "http://" alone
-- reaches curl as a malformed URL rather than being refused here.
function Net.urlDenial(url)
if type(url) ~= "string" or url == "" then return "url must be a string" end
local scheme, rest = url:match("^(%a[%w+.-]*)://(.*)$")
if not scheme then return "url must start with http:// or https://" end
scheme = scheme:lower()
if scheme ~= "http" and scheme ~= "https" then
return ("%s:// is not allowed; mod.fetch speaks http and https only")
:format(scheme)
end
if rest == "" or rest:match("^/") then return "url has no host" end
return nil
end
local function bucket(loader, modId)
loader.netJobs = loader.netJobs or {}
local b = loader.netJobs[modId]
if not b then b = {}; loader.netJobs[modId] = b end
return b
end
local function inflight(b)
local n = 0
for _, id in pairs(b) do
if fetch().isPending(id) then n = n + 1 end
end
return n
end
function Net.available()
local ok, F = pcall(fetch)
if not ok then return false end
local okAvail, avail = pcall(F.available)
return okAvail and avail and true or false
end
-- Returns an opaque handle, or nil plus a reason.
function Net.get(loader, modId, url, opts)
local denial = Net.urlDenial(url)
if denial then return nil, denial end
opts = type(opts) == "table" and opts or {}
local b = bucket(loader, modId)
if inflight(b) >= Net.MAX_INFLIGHT then
return nil, ("too many requests in flight (limit %d); poll and release "
.. "the ones you have"):format(Net.MAX_INFLIGHT)
end
local maxSeconds = tonumber(opts.maxSeconds) or Net.MAX_SECONDS
if maxSeconds > Net.MAX_SECONDS then maxSeconds = Net.MAX_SECONDS end
if maxSeconds < 1 then maxSeconds = 1 end
-- The mod is named in the agent string so a server operator can see which
-- mod is calling them, and a mod cannot pretend to be the launcher.
local id = fetch().get(url, {
userAgent = "gen1recomp-mod/" .. tostring(modId),
accept = type(opts.accept) == "string" and opts.accept or nil,
maxSeconds = maxSeconds,
})
local handle = {}
b[handle] = id
return handle
end
-- A copy of the job's state, never the engine's own table. An unknown or
-- forged handle reads as an error rather than nil, so a mod that lost track of
-- one cannot spin waiting on it forever.
function Net.poll(loader, modId, handle)
local id = bucket(loader, modId)[handle]
if not id then return { status = "error", err = "unknown request" } end
local st = fetch().poll(id)
return { status = st.status, body = st.body, err = st.err,
progress = st.progress }
end
function Net.release(loader, modId, handle)
local b = bucket(loader, modId)
local id = b[handle]
if not id then return false end
fetch().release(id)
b[handle] = nil
return true
end
function Net.cancel(loader, modId, handle)
local id = bucket(loader, modId)[handle]
if not id then return false end
fetch().cancel(id)
return true
end
-- Drop everything this mod still holds. Called when a mod unloads, so a
-- disabled mod cannot leave jobs accumulating in the shared table.
function Net.releaseAll(loader, modId)
local b = loader.netJobs and loader.netJobs[modId]
if not b then return end
local F = fetch()
for handle, id in pairs(b) do
pcall(F.cancel, id)
pcall(F.release, id)
b[handle] = nil
end
loader.netJobs[modId] = nil
end
return Net
+295
View File
@@ -0,0 +1,295 @@
-- Engine-owned import handling for files declared by a mod's required or
-- optional import arrays. Mods never receive host paths or broader
-- filesystem access: accepted bytes are copied into their own
-- mods/<id>/baseroms/ tree, where the existing mod:read sandbox can see them.
local CacheFs = require("src.import.CacheFs")
local RequiredImports = {}
local function allSpecs(manifest)
local out = {}
for _, spec in ipairs((manifest and manifest.required_imports) or {}) do
out[#out + 1] = spec
end
for _, spec in ipairs((manifest and manifest.optional_imports) or {}) do
out[#out + 1] = spec
end
return out
end
local function isRequired(spec)
return spec.required ~= false
end
RequiredImports.specs = allSpecs
RequiredImports.MAX_BYTES = 128 * 1024 * 1024
local function sizeLabel(bytes)
return ("%.1f MiB"):format(bytes / (1024 * 1024))
end
-- Check size before a caller reads an external or stored file into one large
-- Lua string. N64 sources may carry a 512-byte copier header, while stored
-- files are always canonical and therefore must match the declared size.
function RequiredImports.sizeError(spec, size, stored)
if type(size) ~= "number" then return nil end
if size > RequiredImports.MAX_BYTES then
return ("file is too large (%s; hard limit is %s)")
:format(sizeLabel(size), sizeLabel(RequiredImports.MAX_BYTES))
end
local headerAllowance = not stored and spec and spec.format == "n64" and 512 or 0
if spec and spec.size then
if size ~= spec.size and size ~= spec.size + headerAllowance then
return ("wrong file size (expected %d bytes%s, got %d)")
:format(spec.size, headerAllowance > 0 and " or a 512-byte header" or "", size)
end
end
if spec and spec.max_size and size > spec.max_size + headerAllowance then
return ("file is too large for this import (maximum %d bytes, got %d)")
:format(spec.max_size, size)
end
return nil
end
local N64_MAGIC = {
["\128\55\18\64"] = "z64", -- big endian / canonical
["\55\128\64\18"] = "v64", -- byte-swapped
["\64\18\55\128"] = "n64", -- little endian words
}
local function n64KindAt(data, offset)
return N64_MAGIC[data:sub(offset, offset + 3)]
end
-- Return canonical big-endian N64 bytes. A 512-byte copier header is
-- recognized only when valid N64 magic follows it, so arbitrary data is never
-- shortened just because its size happens to line up.
function RequiredImports.normalizeN64(data)
if type(data) ~= "string" then return nil, "selected file could not be read" end
local offset, kind = 1, n64KindAt(data, 1)
if not kind then
kind = n64KindAt(data, 513)
if kind then offset = 513 end
end
if not kind then
return nil, "expected an N64 ROM (.z64/.v64/.n64); file signature was not recognized"
end
data = data:sub(offset)
if kind == "z64" then return data end
if kind == "v64" then
if #data % 2 ~= 0 then return nil, "byte-swapped N64 ROM has an odd size" end
return (data:gsub("(.)(.)", "%2%1"))
else
if #data % 4 ~= 0 then return nil, "little-endian N64 ROM size is not word aligned" end
return (data:gsub("(.)(.)(.)(.)", "%4%3%2%1"))
end
end
function RequiredImports.normalize(spec, data)
if spec and spec.format == "n64" then
return RequiredImports.normalizeN64(data)
end
if type(data) ~= "string" then return nil, "selected file could not be read" end
return data
end
local function hexDigest(data, hashFn)
if hashFn then return hashFn(data):lower() end
if not (love and love.data and love.data.hash and love.data.encode) then
return nil, "MD5 support is unavailable in this build"
end
local digest = love.data.hash("md5", data)
if type(digest) == "userdata" and digest.getString then
digest = digest:getString()
end
return love.data.encode("string", "hex", digest):lower()
end
local function accepts(spec, digest)
for _, wanted in ipairs((spec and spec.md5) or {}) do
if wanted == digest then return true end
end
return false
end
function RequiredImports.path(manifest, spec)
return manifest.path .. "/baseroms/" .. spec.file
end
local function removedMarker(manifest, spec)
return manifest.path .. "/baseroms/.required-import-" .. spec.id .. ".removed"
end
local function receiptPath(manifest, spec)
return manifest.path .. "/baseroms/.required-import-" .. spec.id .. ".validated"
end
RequiredImports.receiptPath = receiptPath
local function parseReceipt(raw)
if type(raw) ~= "string" then return nil end
local digest, size, modtime = raw:match("^v1\n([%x]+)\n(%d+)\n([^\n]+)\n?$")
if not digest then return nil end
return digest:lower(), tonumber(size), tonumber(modtime)
end
local function cachedDigest(manifest, spec, fs, info)
-- A size alone cannot detect a same-length replacement. Require modtime as
-- well; filesystems that do not expose it simply take the safe hash path.
if not (fs and fs.read and info and info.size and info.modtime) then return nil end
local digest, size, modtime = parseReceipt(fs.read(receiptPath(manifest, spec)))
if digest and size == info.size and modtime == info.modtime
and accepts(spec, digest) then
return digest
end
return nil
end
local function writeReceipt(manifest, spec, digest, info, fs)
if not (digest and info and info.size and info.modtime) then return end
local path = receiptPath(manifest, spec)
local body = ("v1\n%s\n%d\n%s\n")
:format(digest, info.size, tostring(info.modtime))
if love and fs == love.filesystem then
local savedPrefix = CacheFs.prefix
CacheFs.prefix = ""
CacheFs.write(path, body)
CacheFs.prefix = savedPrefix
elseif fs and fs.write then
fs.write(path, body)
end
end
local function removeReceipt(manifest, spec, fs)
local path = receiptPath(manifest, spec)
if love and fs == love.filesystem then
local savedPrefix = CacheFs.prefix
CacheFs.prefix = ""
CacheFs.remove(path)
CacheFs.prefix = savedPrefix
elseif fs and fs.remove then
fs.remove(path)
end
end
-- Validate bytes against a declaration. The returned data is canonicalized
-- (notably for N64 byte order/header variants) and is what must be stored.
function RequiredImports.validateData(spec, data, hashFn)
local sourceSizeErr = type(data) == "string"
and RequiredImports.sizeError(spec, #data, false)
if sourceSizeErr then return nil, sourceSizeErr end
local normalized, normalizeErr = RequiredImports.normalize(spec, data)
if not normalized then return nil, normalizeErr end
local storedSizeErr = RequiredImports.sizeError(spec, #normalized, true)
if storedSizeErr then return nil, storedSizeErr end
local digest, hashErr = hexDigest(normalized, hashFn)
if not digest then return nil, hashErr end
if not accepts(spec, digest) then
return nil, ("MD5 mismatch (got %s)"):format(digest)
end
return normalized, digest
end
-- Validate one installed import without reading it when the engine-authored
-- receipt still matches the file's size and modification time.
function RequiredImports.validateStored(manifest, spec, fs, hashFn)
fs = fs or (love and love.filesystem)
if not (fs and fs.getInfo) then return nil, "filesystem is unavailable" end
local path = RequiredImports.path(manifest, spec)
local info = fs.getInfo(path, "file")
if not info then
removeReceipt(manifest, spec, fs)
return nil, "file is missing"
end
local sizeErr = RequiredImports.sizeError(spec, info.size, true)
if sizeErr then
removeReceipt(manifest, spec, fs)
return nil, sizeErr
end
local cached = cachedDigest(manifest, spec, fs, info)
if cached then return true, cached, true end
removeReceipt(manifest, spec, fs)
if not fs.read then return nil, "file could not be read" end
local data = fs.read(path)
local normalized, detail = RequiredImports.validateStoredData(spec, data, hashFn)
if not normalized then return nil, detail end
info = fs.getInfo(path, "file") or info
info.size = info.size or #data
writeReceipt(manifest, spec, detail, info, fs)
return true, detail, false
end
function RequiredImports.validateStoredData(spec, data, hashFn)
local normalized, detail = RequiredImports.validateData(spec, data, hashFn)
if not normalized then return nil, detail end
if normalized ~= data then
return nil, "stored N64 ROM is not canonical; choose the source file again"
end
return normalized, detail
end
function RequiredImports.inspect(manifest, fs, hashFn)
fs = fs or (love and love.filesystem)
local rows, missing, missingOptional = {}, 0, 0
for _, spec in ipairs(allSpecs(manifest)) do
local path = RequiredImports.path(manifest, spec)
local suppressed = fs and fs.getInfo
and fs.getInfo(removedMarker(manifest, spec), "file") ~= nil
local exists = fs and fs.getInfo and fs.getInfo(path, "file") ~= nil
local valid, detail = RequiredImports.validateStored(manifest, spec, fs, hashFn)
local row = { id = spec.id, name = spec.name, file = spec.file,
description = spec.description, format = spec.format, path = path,
present = valid == true, digest = valid and detail or nil,
error = exists and not valid and detail or nil,
suppressed = suppressed, required = isRequired(spec), spec = spec }
if not row.present then
if row.required then missing = missing + 1
else missingOptional = missingOptional + 1 end
end
rows[#rows + 1] = row
end
return rows, missing, missingOptional
end
function RequiredImports.importData(manifest, importId, data, opts)
opts = opts or {}
local spec
for _, candidate in ipairs(allSpecs(manifest)) do
if candidate.id == importId then spec = candidate break end
end
if not spec then return nil, "unknown required import: " .. tostring(importId) end
local normalized, digest = RequiredImports.validateData(spec, data, opts.hash)
if not normalized then return nil, digest end
local savedPrefix = CacheFs.prefix
CacheFs.prefix = ""
CacheFs.remove(receiptPath(manifest, spec))
local ok, err = CacheFs.write(RequiredImports.path(manifest, spec), normalized)
if ok then CacheFs.remove(removedMarker(manifest, spec)) end
if ok and love and love.filesystem and love.filesystem.getInfo then
local info = love.filesystem.getInfo(RequiredImports.path(manifest, spec), "file")
writeReceipt(manifest, spec, digest, info, love.filesystem)
end
CacheFs.prefix = savedPrefix
if not ok then return nil, "could not copy import: " .. tostring(err) end
return true, digest
end
function RequiredImports.remove(manifest, importId)
for _, spec in ipairs(allSpecs(manifest)) do
if spec.id == importId then
local savedPrefix = CacheFs.prefix
CacheFs.prefix = ""
CacheFs.remove(RequiredImports.path(manifest, spec))
CacheFs.remove(receiptPath(manifest, spec))
local marked, markErr = CacheFs.write(removedMarker(manifest, spec), "removed\n")
CacheFs.prefix = savedPrefix
if not marked then return nil, "could not remember removal: " .. tostring(markErr) end
return true
end
end
return nil, "unknown required import: " .. tostring(importId)
end
return RequiredImports
+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
+257
View File
@@ -0,0 +1,257 @@
-- 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",
-- newThread's state has a full standard library and none of this file's
-- rules, so it stays blocked -- but the reason mods reached for it was
-- background work, and mod.fetch is that without the escape.
thread = 'mod.fetch for background HTTP (needs the "network" permission)',
system = "mod.device:powerInfo() for battery information, mod.steps for "
.. "the step bridge", event = true,
}
-- Per-mod, because the compat overrides (src/mods/LegacyCompat.lua) are backed
-- by that mod's own overlay and must not be shared.
local function loveFacade(compat)
if not _G.love then return nil end
local overrides = compat and compat.love
return setmetatable({}, {
__index = function(_, key)
local override = overrides and overrides[key]
if override ~= nil then return override end
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,
-- a callback chain lands on the real table (compat.assign decides which
-- names qualify); a module table never does
__newindex = function(_, key, value)
if compat then
local allowed, reason = compat.assign(key, value)
if allowed then
_G.love[key] = value
return
end
if reason then error(reason, 2) end
end
error(("mods cannot assign love.%s"):format(tostring(key)), 2)
end,
})
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, compat)
return function(name, ...)
-- the compat stand-in answers first, so a legacy require("io") gets the
-- rerouted table instead of the denial below (src/mods/LegacyCompat.lua)
local substitute = compat and compat.module(name)
if substitute ~= nil then return substitute end
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 compat = opts.compat
local env = baseGlobals()
env.love = loveFacade(compat)
env.require = sandboxedRequire(opts.modId, opts.permissions, compat)
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
if compat then
for key, value in pairs(compat.globals) do env[key] = value end
for key, value in pairs(compat.os) do env.os[key] = value end
compat.bind(env, function(source, chunkname)
return Sandbox.compile(source, chunkname, env)
end)
end
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
+73
View File
@@ -0,0 +1,73 @@
-- Worker state behind src/mods/Job.lua. One per job, not a pool: a reused
-- state would carry one mod's globals into another mod's job.
--
-- This is the file that makes running mod Lua off the main thread safe. The
-- mod's chunk is loaded into the SAME sandbox environment the main thread
-- builds (Sandbox.envFor), so love.filesystem, io, os, debug, ffi and package
-- are as absent here as they are there -- even though this state required
-- love.filesystem to bootstrap itself.
--
-- A job is pure compute: plain data in, plain data out, no engine API, no
-- game state, no storage. require is refused outright rather than reaching
-- src.* -- an engine module loaded in a second state would be a second
-- instance writing the same files as the main thread's.
require("love.thread")
require("love.filesystem")
require("love.timer")
local modId, scriptPath, argChannel, resultChannel, permissionsJson = ...
-- Fresh love threads have no "src.*" searcher (see src/net/fetch_worker.lua),
-- so install one before Sandbox's own requires run.
table.insert(package.loaders or package.searchers, function(name)
local path = name:gsub("%.", "/") .. ".lua"
if not love.filesystem.getInfo(path) then return nil end
return love.filesystem.load(path)
end)
local resCh = love.thread.getChannel(resultChannel)
local function fail(err)
resCh:push({ ok = false, err = tostring(err) })
end
local ok, err = pcall(function()
local Sandbox = require("src.mods.Sandbox")
local Json = require("src.link.Json")
local permissions = {}
if type(permissionsJson) == "string" and permissionsJson ~= "" then
local decoded = select(2, pcall(Json.decode, permissionsJson))
if type(decoded) == "table" then permissions = decoded end
end
local env = Sandbox.envFor({ modId = modId, permissions = permissions })
-- A job cannot reach the engine. Anything it needs comes in through its
-- argument and goes back through its return value.
env.require = function(name)
error(("[%s] require(%q) is not available inside a background job; a job "
.. "takes plain data and returns plain data"):format(modId,
tostring(name)), 2)
end
local chunk, loadErr = Sandbox.loadFile(love.filesystem, scriptPath, env)
if not chunk then error(loadErr or ("could not load " .. scriptPath), 0) end
local arg = love.thread.getChannel(argChannel):pop()
-- NO in-worker time budget, deliberately. A debug count hook was the
-- obvious way to stop a runaway, and it does not work: LuaJIT swallows an
-- error raised from a hook (measured: ~5000 raises a second, the loop
-- running straight through them), and the raising itself wedged the whole
-- process -- the main thread stopped being scheduled at all. Without the
-- hook a runaway job simply spins on its own core, the game stays
-- responsive, and it quits normally. Job.poll enforces maxSeconds on the
-- main thread so the MOD is never left waiting; the work itself runs to its
-- own end.
local ranOk, result = pcall(chunk, arg)
if not ranOk then error(result, 0) end
resCh:push({ ok = true, result = result })
end)
if not ok then fail(err) end
+100
View File
@@ -0,0 +1,100 @@
-- Load gen1tls from next to the exe and put its poll API on love.system --
-- same tlsOpen / tlsSend / ... shape Android already exposes through JNI.
--
-- Mods can't require("ffi") under the sandbox, so they can't load the DLL
-- themselves even when it's sitting right there. We do it here, before any
-- mod runs. LegacyCompat's love.system shim forwards the tls* keys through
-- (clipboard / openURL stay stubbed).
--
-- true = tlsOpen is ready. Already present (Android), no FFI, or no DLL:
-- just return false and move on; plain ws:// rooms don't care.
local Gen1Tls = {}
local function exeDir()
if love and love.filesystem and love.filesystem.getSourceBaseDirectory then
local base = love.filesystem.getSourceBaseDirectory()
if type(base) == "string" and base ~= "" then return base end
end
if type(arg) == "table" and type(arg[0]) == "string" then
local dir = arg[0]:match("^(.*)[/\\]")
if dir and dir ~= "" then return dir end
end
return "."
end
local function fileReadable(path)
local f = io.open(path, "rb")
if not f then return false end
f:close()
return true
end
local function libNames()
local osName = (love and love.system and love.system.getOS and love.system.getOS()) or ""
if osName == "Windows" then return { "gen1tls.dll" } end
if osName == "OS X" then return { "libgen1tls.dylib", "gen1tls.dylib" } end
if osName == "Linux" then return { "libgen1tls.so", "gen1tls.so" } end
return { "gen1tls.dll", "libgen1tls.so", "libgen1tls.dylib" }
end
function Gen1Tls.install()
if not (love and love.system) then return false end
if type(love.system.tlsOpen) == "function" then return true end
local okFfi, ffi = pcall(require, "ffi")
if not okFfi or type(ffi) ~= "table" then return false end
ffi.cdef[[
int gen1tls_open(const char *host, int port);
int gen1tls_status(int handle);
int gen1tls_send(int handle, const char *data, int length);
int gen1tls_receive(int handle, char *buf, int max);
int gen1tls_error(int handle, char *buf, int max);
void gen1tls_close(int handle);
]]
local dir = exeDir()
local lib
for _, name in ipairs(libNames()) do
local path = dir .. "/" .. name
if fileReadable(path) then
local ok, loaded = pcall(ffi.load, path)
if ok then lib = loaded; break end
end
local ok, loaded = pcall(ffi.load, name)
if ok then lib = loaded; break end
end
if not lib then return false end
local errBuf = ffi.new("char[512]")
local recvBuf = ffi.new("char[65536]")
love.system.tlsOpen = function(host, port)
return lib.gen1tls_open(host, tonumber(port) or 0)
end
love.system.tlsStatus = function(handle)
return lib.gen1tls_status(handle)
end
love.system.tlsSend = function(handle, data)
data = data or ""
return lib.gen1tls_send(handle, data, #data)
end
love.system.tlsReceive = function(handle, max)
max = math.min(tonumber(max) or 8192, 65536)
if max <= 0 then return "" end
local n = lib.gen1tls_receive(handle, recvBuf, max)
if n <= 0 then return "" end
return ffi.string(recvBuf, n)
end
love.system.tlsError = function(handle)
if lib.gen1tls_error(handle, errBuf, 512) == 0 then return nil end
return ffi.string(errBuf)
end
love.system.tlsClose = function(handle)
lib.gen1tls_close(handle)
end
return true
end
return Gen1Tls
+4 -2
View File
@@ -140,10 +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 })
require("src.core.Sound").play(game.data, "Get_Item1")
-- 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
+20
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
+15 -2
View File
@@ -592,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
@@ -1050,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
+8 -6
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
@@ -181,9 +181,11 @@ 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 })
require("src.core.Sound").play(game.data, "Get_Item1")
-- 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))
@@ -333,10 +335,9 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
local mdef = game.data.moves[moveId]
if #target.moves < 4 then
table.insert(target.moves, { id = moveId, pp = mdef.pp })
require("src.core.Sound").play(game.data, "Get_Item1")
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)
@@ -382,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.
+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)
+16 -3
View File
@@ -6,6 +6,7 @@ local GameVersion = require("src.core.GameVersion")
local Music = require("src.core.Music")
local Sound = require("src.core.Sound")
local Strings = require("src.core.Strings")
local Timing = require("src.core.Timing")
local IntroMovie = {}
IntroMovie.__index = IntroMovie
@@ -232,6 +233,10 @@ function IntroMovie:fightStep()
self:finish()
return
end
-- an op that consumed frames ends ON its last frame; only the instant
-- ops (sfx / pose / frame, which are plain writes between DelayFrames
-- calls in PlayIntroScene) chain into the next op the same frame
local timed = false
if op.sfx then
Sound.play(self.game.data, op.sfx)
elseif op.pose then
@@ -250,6 +255,7 @@ function IntroMovie:fightStep()
end
self.opTimer = self.opTimer + 1
if self.opTimer < (op.px or math.abs(op.dx)) then return end
timed = true
elseif op.anim then
-- one {dy,dx} delta per 5 frames (AnimateIntroNidorino: DelayFrames 5)
if self.opTimer % 5 == 0 then
@@ -259,9 +265,11 @@ function IntroMovie:fightStep()
end
self.opTimer = self.opTimer + 1
if self.opTimer < #ANIM[op.anim] * 5 then return end
timed = true
elseif op.wait then
self.opTimer = self.opTimer + 1
if self.opTimer < op.wait then return end
timed = true
elseif op.fade then
self.opTimer = self.opTimer + 1
self.fade = self.opTimer / op.fade
@@ -270,6 +278,7 @@ function IntroMovie:fightStep()
end
self.opIndex = self.opIndex + 1
self.opTimer = 0
if timed then return end
end
end
@@ -284,8 +293,9 @@ function IntroMovie:update(dt)
return
end
local input = self.game.input
if input:wasPressed("a") or input:wasPressed("b")
or input:wasPressed("start") then
if input:wasPressed("a") or input:wasPressed("start") then
-- CheckForUserInterruption (home/overworld.asm:2395) returns carry only
-- on a fresh START or A -- B alone never skips the intro.
-- PlayIntro still GBFadeOutToWhite's after an interrupted scene; the
-- white hold stands in for that beat before the title is built.
self:exitToTitle()
@@ -300,7 +310,10 @@ function IntroMovie:update(dt)
end
if self.timer >= SPLASH_FRAMES then self:startPhase(3) end
else
self:fightStep()
-- PlayShootingStar ends `jp Delay3` once Music_IntroBattle is playing
-- (intro.asm:337), so PlayIntroScene's first op is not on the music's
-- own frame
if self.timer > Timing.DELAY3 then self:fightStep() end
end
end
+7 -3
View File
@@ -98,14 +98,14 @@ function OakSpeech.resolvePic(game, desc, speech)
local t = desc.type
if t == "trainer" then
if speech and desc.id == "OPP_PROF_OAK" and speech.oakPic then
return speech.oakPic, false, false
return speech.oakPic, false, speech.oakTrueColor or false
end
if speech and desc.id == "OPP_RIVAL1" and speech.rivalPic then
return speech.rivalPic, false, false
return speech.rivalPic, false, speech.rivalTrueColor or false
end
local trainers = game.data.trainers or {}
local tr = trainers[desc.id]
return tryImage(tr and tr.pic), false, false
return tryImage(tr and tr.pic), false, tr and tr.trueColor or false
elseif t == "pokemon" then
if speech and desc.id == speech.demoSpecies and speech.demoPic then
return speech.demoPic, desc.flip and true or false, speech.demoTrueColor
@@ -241,7 +241,11 @@ function OakSpeech.new(game, onDone)
self.answers = {}
local trainers = game.data.trainers or {}
self.oakPic = tryImage(trainers.OPP_PROF_OAK and trainers.OPP_PROF_OAK.pic)
self.oakTrueColor = self.oakPic
and trainers.OPP_PROF_OAK and trainers.OPP_PROF_OAK.trueColor or false
self.rivalPic = tryImage(trainers.OPP_RIVAL1 and trainers.OPP_RIVAL1.pic)
self.rivalTrueColor = self.rivalPic
and trainers.OPP_RIVAL1 and trainers.OPP_RIVAL1.trueColor or false
local oakGfx = (game.data.field and game.data.field.oakSpeech) or {}
self.cfg = oakGfx
-- the show-off mon and the name length cap come from data; the vanilla
+95 -81
View File
@@ -87,6 +87,15 @@ local function refuseUnavailable(self)
t._SleepingPikachuText1 or Strings("There isn't any\nresponse...")))
end
-- .newBadgeRequired (start_sub_menus.asm): every badge-gated arm of
-- .outOfBattleMovePointers prints this and jumps back to the open submenu
local function refuseBadge(self)
local TextBox = require("src.render.TextBox")
local t = self.game.data and self.game.data.text or {}
self.game.stack:push(TextBox.new(self.game,
t._NewBadgeRequiredText or Strings("No! A new BADGE\nis required.")))
end
-- where DIG escapes work: escape_rope_tilesets.asm (Agatha's room is
-- excluded by map id in ItemUseEscapeRope)
local DIG_TILESETS = { FOREST = true, CEMETERY = true, CAVERN = true,
@@ -280,6 +289,7 @@ function PartyMenu.new(game, opts)
opts = opts or {}
local self = setmetatable({}, PartyMenu)
self.game = game
local party = opts.party or (opts.battle and opts.battle.playerParty)
-- PartyMenuInit (home/pokemon.asm) seeds the cursor from
-- wPartyAndBillsPCSavedMenuItem rather than from zero, and
-- HandlePartyMenuInput writes wCurrentMenuItem back into it on every
@@ -288,7 +298,7 @@ function PartyMenu.new(game, opts)
-- both zero the byte, which BattleState mirrors. The clamp covers a
-- party that shrank (deposit / release) while the saved index was
-- pointing past the end. #768
local count = #(opts.party or (game.save and game.save.party) or {})
local count = #(party or (game.save and game.save.party) or {})
self.index = math.min(math.max(1, game.partyMenuSavedIndex or 1),
math.max(1, count))
self.onSwitch = opts.onSwitch
@@ -305,7 +315,7 @@ function PartyMenu.new(game, opts)
self.tmhm = opts.tmhm
self.forceSwitch = opts.forceSwitch
self.battle = opts.battle
self.party = opts.party -- link battles pass their clamped copies
self.party = party -- link/scoped battles pass their local party view
self.swapFrom = nil
self.submenu = nil
self.subIndex = 1
@@ -411,6 +421,23 @@ function PartyMenu:update(dt)
-- flyTo (OverworldController) validates the fly-warp + runs the
-- departure/warp, so we just hand it the chosen mapId (#195).
local ow = self.game.overworld
-- .fly checks THUNDERBADGE first, then CheckIfInOutsideMap
-- (OVERWORLD + PLATEAU -- Route 23 / Indigo Plateau outdoor -- not
-- OVERWORLD alone, #83); both refusals loop back to the submenu
if ow and not ow:partyKnows("FLY") then
refuseBadge(self)
return
end
if ow and not Map.isOutside(ow.map.def,
FieldDefaults.field(self.game.data, "outsideTilesets")) then
local TextBox = require("src.render.TextBox")
local def = self.game.data.pokemon[mon.species]
local txt = (self.game.data.text._CannotFlyHereText
or Strings("{RAM:wNameBuffer} can't\nFLY here."))
:gsub("{RAM:wNameBuffer}", mon.nickname or def.name)
self.game.stack:push(TextBox.new(self.game, txt))
return -- .loop: submenu stays open behind the message
end
self.game.stack:pop() -- close the party menu
Screens.push(self.game, "TownMap", { fly = true, onFly = function(mapId)
if ow then ow:flyTo(mapId) end
@@ -423,30 +450,15 @@ function PartyMenu:update(dt)
-- over the menu, and the cave is lit when the blink hands the
-- screen back, never under the text (#385).
local ow = self.game.overworld
local TextBox = require("src.render.TextBox")
local Transition = require("src.render.Transition")
self.game.save.flashLit = true
self.game.stack:push(TextBox.new(self.game,
self.game.data.text._FlashLightsAreaText
or Strings("A blinding FLASH\nlights the area!"), function()
self:close()
-- setDark, not a bare field write: ADVANCED carries the darkness
-- in a baked atlas, so lighting the cave drops every resident map
-- and rebakes this one (#383). It runs HERE, before the blink,
-- because start_sub_menus.asm .flash clears wMapPalOffset before
-- PrintText and blinks last of all: the cave is already lit by the
-- time GBPalWhiteOutWithDelay3 runs. Hanging the rebuild off the
-- blink's completion instead left that rebuild's whole cost --
-- seconds of per-pixel atlas baking on a phone -- on screen as a
-- solid white frame with nothing under it, which reads as a
-- lockup (#610).
ow:setDark(false)
self.game.stack:push(Transition.whiteFlash(self.game))
end))
if ow and not ow:partyKnows("FLASH") then
refuseBadge(self)
return
end
ow:useFlashFieldMove(function() self:close() end)
return
elseif action == "surf" then
-- start_sub_menus.asm .surf: SOULBADGE-gated (checked at list time
-- above), then IsSurfingAllowed (the Cycling Road / Seafoam B4F
-- start_sub_menus.asm .surf: SOULBADGE-gated (useSurfFieldMove),
-- then IsSurfingAllowed (the Cycling Road / Seafoam B4F
-- current refusals, both of which loop back to the submenu), then
-- ItemUseSurfboard: while surfing it tries to dismount instead;
-- otherwise it mounts only if the FACING tile is water, else
@@ -470,12 +482,7 @@ function PartyMenu:update(dt)
-- GBPalWhiteOutWithDelay3 blink, and the simulated pad press
-- steps the player forward onto land (or across a connection
-- strip when the shore is the next map's edge)
self.game.stack:pop()
ow.player.surfing = false
require("src.core.Music").setSurfing(self.game.data, false)
self.game.stack:push(Transition.whiteFlash(self.game, nil, function()
ow:stepForwardOrCrossEdge(ow.player.facing)
end))
ow:stopSurfing(function() self.game.stack:pop() end)
return
end
local TextBox = require("src.render.TextBox")
@@ -503,8 +510,8 @@ function PartyMenu:update(dt)
return -- .loop: submenu stays open behind the message
elseif action == "cut" then
-- start_sub_menus.asm .cut -> predef UsedCut (engine/overworld/cut.asm):
-- CASCADEBADGE-gated (list time); _NothingToCutText loops back to the
-- submenu when the FACING tile isn't a cuttable tree.
-- CASCADEBADGE-gated (useCutFieldMove); _NothingToCutText loops back
-- to the submenu when the FACING tile isn't a cuttable tree.
local ow = self.game.overworld
local reason = ow:useCutFieldMove()
if reason == "ok" then
@@ -522,7 +529,7 @@ function PartyMenu:update(dt)
self.game.stack:push(TextBox.new(self.game, txt))
return -- .loop: submenu stays open behind the message
elseif action == "strength" then
-- start_sub_menus.asm .strength: RAINBOWBADGE-gated (list time);
-- start_sub_menus.asm .strength: RAINBOWBADGE-gated;
-- predef PrintStrengthText (field_move_messages.asm) sets
-- BIT_STRENGTH_ACTIVE of wStatusFlags1 -- the sole gate
-- push_boulder.asm reads -- then prints _UsedStrengthText (no
@@ -532,26 +539,18 @@ function PartyMenu:update(dt)
-- .strength, GBPalWhiteOutWithDelay3 blinks the screen white
-- before CloseTextDisplay returns to the map.
local ow = self.game.overworld
local TextBox = require("src.render.TextBox")
local Transition = require("src.render.Transition")
local def = self.game.data.pokemon[mon.species]
local name = mon.nickname or def.name
ow.strengthActive = true
local t1 = (self.game.data.text._UsedStrengthText
or Strings("{RAM:wNameBuffer} used\nSTRENGTH.")):gsub("{RAM:wNameBuffer}", name)
local t2 = (self.game.data.text._CanMoveBouldersText
or Strings("{RAM:wNameBuffer} can\nmove boulders.")):gsub("{RAM:wNameBuffer}", name)
-- like surf (#320, #385): both texts print with the party menu
-- still on screen, and the blink IS the menu closing afterwards,
-- not a flashbang on the empty map
self.game.stack:push(TextBox.new(self.game, t1, function()
self.game.stack:push(TextBox.new(self.game, t2, function()
self:close()
self.game.stack:push(Transition.whiteFlash(self.game))
end))
end, { auto = { sound = function()
return require("src.core.Sound").playCry(self.game.data, mon.species)
end } }))
if ow and ow.useStrengthFieldMove then
if not ow:partyKnows("STRENGTH") then
refuseBadge(self)
return
end
ow:useStrengthFieldMove(mon, function() self:close() end)
return
elseif ow and ow.useFieldMove then
ow:useFieldMove("STRENGTH", mon)
self:close()
return
end
return
elseif action == "softboiled" then
-- field SOFTBOILED (StartMenu_Pokemon .softboiled): transfer
@@ -565,6 +564,33 @@ function PartyMenu:update(dt)
-- centralizes the spin -> fade -> warp so BagMenu's ESCAPE ROPE shares
-- the exact departure; the fade + warp fire when the spin ends.
local ow = self.game.overworld
if entry.move == "TELEPORT" then
-- .teleport: TELEPORT works only OUTDOORS (CheckIfInOutsideMap --
-- OVERWORLD + PLATEAU, #83); dark maps don't block it
if ow and not Map.isOutside(ow.map.def,
FieldDefaults.field(self.game.data, "outsideTilesets")) then
local TextBox = require("src.render.TextBox")
local def = self.game.data.pokemon[mon.species]
local txt = (self.game.data.text._CannotUseTeleportNowText
or Strings("{RAM:wNameBuffer} can't\nuse TELEPORT now."))
:gsub("{RAM:wNameBuffer}", mon.nickname or def.name)
self.game.stack:push(TextBox.new(self.game, txt))
return -- .loop: submenu stays open behind the message
end
elseif ow and not (DIG_TILESETS[ow.map.def.tileset]
and ow.map.id ~= "AGATHAS_ROOM") then
-- .dig runs ItemUseEscapeRope (it sets wCurItem = ESCAPE_ROPE):
-- usable in the dungeon tilesets of escape_rope_tilesets.asm minus
-- Agatha's room, even in the dark (Rock Tunnel); anywhere else
-- .notUsable -> ItemUseNotTime, the same line BagMenu prints for a
-- bagged ESCAPE ROPE
local TextBox = require("src.render.TextBox")
self.game.stack:push(TextBox.new(self.game,
self.game.data.text._ItemUseNotTimeText
or Strings("OAK: %s!\nThis isn't the\ntime to use that!",
self.game.save.player.name)))
return -- .loop: submenu stays open behind the message
end
self.game.stack:pop()
if ow then ow:beginTeleportOut() end
return
@@ -658,42 +684,30 @@ function PartyMenu:update(dt)
-- Battle still excludes this list via `not self.battle`. Softboiled
-- can appear for a fainted user; its heal transfer then no-ops.
if not self.battle and ow then
-- FLY/TELEPORT: CheckIfInOutsideMap (OVERWORLD + PLATEAU --
-- Route 23 / Indigo Plateau outdoor), not OVERWORLD alone (#83)
local outside = Map.isOutside(ow.map.def,
FieldDefaults.field(self.game.data, "outsideTilesets"))
-- GetMonFieldMoves (engine/menus/text_box.asm) matches the mon's
-- four moves against FieldMoveDisplayData and nothing else -- no
-- badge, no map, no tileset test. Every one of those lives in
-- .outOfBattleMovePointers, i.e. on selection, where the refusal
-- prints and .loop returns to this still-open submenu (#1022).
for _, mv in ipairs(mon.moves) do
if mv.id == "FLY" and outside
and self.game.save.inventory.THUNDERBADGE then
if mv.id == "FLY" then
table.insert(items, { label = Strings("FLY"), action = "fly" })
elseif mv.id == "FLASH" and ow.dark
and self.game.save.inventory.BOULDERBADGE then
elseif mv.id == "FLASH" then
table.insert(items, { label = Strings("FLASH"), action = "flash" })
elseif mv.id == "CUT" and self.game.save.inventory.CASCADEBADGE then
-- CUT/SURF/STRENGTH are party-menu field moves too
-- (start_sub_menus.asm .outOfBattleMovePointers); listed here
-- with the same list-time badge filter this file already uses
-- for FLY/FLASH. The facing-tile/activation check happens on
-- selection (useCutFieldMove/useSurfFieldMove).
elseif mv.id == "CUT" then
table.insert(items, { label = Strings("CUT"), action = "cut" })
elseif mv.id == "SURF" and self.game.save.inventory.SOULBADGE then
elseif mv.id == "SURF" then
table.insert(items, { label = Strings("SURF"), action = "surf" })
elseif mv.id == "STRENGTH" and self.game.save.inventory.RAINBOWBADGE then
elseif mv.id == "STRENGTH" then
table.insert(items, { label = Strings("STRENGTH"), action = "strength" })
elseif mv.id == "SOFTBOILED" then
table.insert(items, { label = Strings("SOFTBOILED"), action = "softboiled" })
elseif mv.id == "TELEPORT" and outside then
-- TELEPORT works only OUTDOORS (start_sub_menus.asm
-- .teleport -> CheckIfInOutsideMap); dark maps don't
-- block it
table.insert(items, { label = Strings("TELEPORT"), action = "escape" })
elseif mv.id == "DIG" and DIG_TILESETS[ow.map.def.tileset]
and ow.map.id ~= "AGATHAS_ROOM" then
-- DIG runs ItemUseEscapeRope (.dig sets wCurItem =
-- ESCAPE_ROPE): usable in the dungeon tilesets of
-- escape_rope_tilesets.asm minus Agatha's room, even in
-- the dark (Rock Tunnel)
table.insert(items, { label = Strings("DIG"), action = "escape" })
elseif mv.id == "TELEPORT" then
table.insert(items, { label = Strings("TELEPORT"),
action = "escape", move = "TELEPORT" })
elseif mv.id == "DIG" then
table.insert(items, { label = Strings("DIG"),
action = "escape", move = "DIG" })
end
end
end
+44 -6
View File
@@ -173,6 +173,31 @@ end
function BattleState:wantsFillScale() return true end
function BattleState:drawsWidescreen() return true end
function BattleState:bottomUIVisible()
if not Runtime.wantsHook("battle.bottom_ui_visible") then return true end
return Runtime.call("battle.bottom_ui_visible", function() return true end,
self) ~= false
end
function BattleState:statusHUDVisible()
if not Runtime.wantsHook("battle.status_hud_visible") then return true end
return Runtime.call("battle.status_hud_visible", function() return true end,
self) ~= false
end
-- Class frontpic for the battle intro. A trainers-registry `pic` wins over
-- the extracted menu_gfx sheet; `trueColor` skips the GBC 4-shade remap.
-- Returns path, trueColor.
function BattleState.trainerArt(data, classId)
if not classId then return nil, false end
local classes = data and data.gen2Trainers and data.gen2Trainers.classes
local classDef = classes and classes[classId]
local hud = data and data.gen2MenuGfx and data.gen2MenuGfx.battleHud
local path = (classDef and classDef.pic)
or (hud and hud.trainerPics and hud.trainerPics[classId])
return path, (classDef and classDef.trueColor) and true or false
end
-- opts: battle (a Battle), onDone(outcome), save
function BattleState.new(game, opts)
opts = opts or {}
@@ -296,6 +321,7 @@ function BattleState.new(game, opts)
-- pic is a cache asset, so an import made before the extractor grew that
-- stage has none and the mon stands in for the whole intro.
self.showEnemyTrainer = false
self.enemyTrainerTrueColor = false
-- The CLASS CONSTANT (BUG_CATCHER), which is what both tables this looks the
-- pic up in are keyed by: menu_gfx's trainerPics is written out of
-- constants.trainerClassOrder, and palettes.trainers out of the same names.
@@ -305,17 +331,19 @@ function BattleState.new(game, opts)
-- no palette for every trainer the world starts, which is all of them.
-- `classId` is the trainers.lua key, i.e. the constant; `className` is the
-- DISPLAY name ("BUG CATCHER", with the space) and is not a key at all.
-- A class record's own `pic` / `trueColor` (the trainers registry) wins
-- over the extracted sheet, so a mod can drop in full-color art.
local enemyTrainer = self.battle and self.battle.trainer
self.enemyTrainerClass = enemyTrainer
and (enemyTrainer.classId or enemyTrainer.class)
local trainerPics = hudGfx and hudGfx.trainerPics
local trainerPath = self.enemyTrainerClass and trainerPics
and trainerPics[self.enemyTrainerClass]
local trainerPath, trainerTrueColor =
BattleState.trainerArt(data, self.enemyTrainerClass)
if trainerPath then
local ok, image = pcall(Assets.image, trainerPath)
if ok and image then
self.enemyTrainerImage = image
self.enemyTrainerPath = trainerPath
self.enemyTrainerTrueColor = trainerTrueColor and true or false
self.showEnemyTrainer = true
end
end
@@ -576,7 +604,10 @@ function BattleState:drawPic(mon, back)
-- slides it out (InitEnemyTrainer, engine/battle/core.asm:7848).
local enemyTrainer = (not back) and self.showEnemyTrainer
and self.enemyTrainerImage
if enemyTrainer then image, path = enemyTrainer, self.enemyTrainerPath end
if enemyTrainer then
image, path = enemyTrainer, self.enemyTrainerPath
trueColor = self.enemyTrainerTrueColor
end
if not image then return end
local side = back and "player" or "enemy"
local anim = self:animPicState(side)
@@ -3079,6 +3110,7 @@ end
function BattleState:drawHud()
local wasBattle = Font.useBattleExtra(true)
local enemy, player = self:activeMon("enemy"), self:activeMon("player")
local showStatus = self:statusHUDVisible()
-- Enemy HUD (DrawEnemyHUD clears (1,0) 4 rows x 11 cols):
-- name at (1,0); PrintLevel at (6,1) with the gender symbol at (9,1);
@@ -3087,7 +3119,7 @@ function BattleState:drawHud()
-- runs, so a shake or a slide does not drag the HP bar with it.
-- And nothing at all before UpdateEnemyHUD has ever run: the intro bands
-- slide in over a blanked tilemap (core.asm:8554/8564).
if self.showEnemyHud and not self:hudCleared("enemy") then
if showStatus and self.showEnemyHud and not self:hudCleared("enemy") then
Chrome.print(self:name(enemy), 1, 0)
-- PrintLevel writes <LV> at the coordinate it is given and then LEFT-aligns
-- the digits after it, so the glyph is pinned to column 6 whether the level
@@ -3127,7 +3159,8 @@ function BattleState:drawHud()
-- BattleMenu's own tutorial arm skips UpdateBattleHuds as well. The DUDE's
-- half of the screen is his back-pic and nothing more.
-- Nor before SendOutPlayerMon's own UpdatePlayerHUD (core.asm:3838).
if not player or not self.showPlayerHud or self:hudCleared("player") then
if not showStatus or not player or not self.showPlayerHud
or self:hudCleared("player") then
Font.useBattleExtra(wasBattle)
return
end
@@ -3226,6 +3259,11 @@ function BattleState:drawPanel()
end
self:drawHud()
if not self:bottomUIVisible() then
love.graphics.setColor(1, 1, 1, 1)
return
end
-- Message box across the bottom, with the menu window over its right half --
-- the cart draws the prompt into the full-width box and then opens the menu
-- on top, so the tail of a long name is simply covered.
+1 -1
View File
@@ -154,7 +154,7 @@ function HallOfFame.monPlacements(mon, def)
put(out, genderGlyph(mon.gender), 18, 13)
-- (8,14) is a bare '/', so the nickname starts at (9,14).
put(out, "/", 8, 14)
put(out, mon.nickname or mon.species, 9, 14)
put(out, mon.nickname or mon.name or mon.species, 9, 14)
put(out, levelText(mon.level), 1, 16)
end
-- '<ID>' '№' '/' at (7,16), (8,16), (9,16), then five digits at (10,16).
+5
View File
@@ -780,6 +780,11 @@ function MartMenu:offerToSell(itemId, count)
end
-- ScrollingMenu's .a_button copies the row's own quantity into
-- wItemQuantity, so the selector's ceiling is how many you hold.
-- The unit price is the item's own ItemAttributes price: GetItemPrice is
-- what SelectQuantityToSell halves AND what the buy list's
-- GetMartItemPrice charges, so a TM sells for exactly half of what the
-- Goldenrod/Celadon TM shelves ask for it, and half of its hidden price
-- (usually ¥1,500) anywhere else (issue #1243).
self.qtyItem = { id = itemId, name = def.name or itemId,
price = def.price or 0 }
self.qty = 1
+17 -14
View File
@@ -354,20 +354,23 @@ function PackMenu:useSelected()
end
return
end
-- engine/items/tmhm.asm:73
local def = self.items and self.items[row.id]
if def and def.teaches then
self:openTeachParty(row)
return
end
-- UseItem's jumptable runs off ITEMATTR's field-menu nibble, and the first
-- four entries are all .Oak -- an X ATTACK or a POKé DOLL used from the
-- field PACK prints OakThisIsntTheTimeText and goes nowhere. Only the
-- FIELD pack owns that refusal: the battle pack returned above, and the
-- catch tutorial's DUDE pack carries a stub world with no useFieldItem at
-- all -- its POKE BALL is field-NOUSE and must still reach the throw.
-- UseItem's FIELD-pack tail (engine/items/tmhm.asm:73): a TM/HM row opens
-- the party to teach, and a field-NOUSE item -- an X ATTACK or a POKé DOLL
-- used from the field PACK -- prints OakThisIsntTheTimeText and goes
-- nowhere (UseItem's jumptable's first four entries are all .Oak). Both
-- checks live behind `world.useFieldItem` on purpose: DepositSellPack (the
-- mart's SELL, the item PC's DEPOSIT) is a chooser whose jumptable is four
-- ScrollingMenus and never reaches tmhm.asm, so a TM picked there hands
-- its row to onChoose like every other item instead of opening the teach
-- party. The battle pack returned above, and the catch tutorial's DUDE
-- pack carries a stub world with no useFieldItem at all -- its POKE BALL
-- is field-NOUSE and must still reach the throw.
if world and world.useFieldItem then
local def = self.items and self.items[row.id]
if def and def.teaches then
self:openTeachParty(row)
return
end
if def and def.fieldMenu == "ITEMMENU_NOUSE" then
self.message = OAK_THIS_ISNT_THE_TIME
return
@@ -627,7 +630,7 @@ function PackMenu:openTeachParty(row)
if not allowed then
if game.say then
game:say(("%s can't learn %s!"):format(
mon.nickname or mon.species or "?", moveName))
require("src.battle.gen2.Mon").displayName(mon), moveName))
end
return
end
@@ -635,7 +638,7 @@ function PackMenu:openTeachParty(row)
if move.id == moveId then
if game.say then
game:say(("%s already knows %s!"):format(
mon.nickname or mon.species or "?", moveName))
require("src.battle.gen2.Mon").displayName(mon), moveName))
end
return
end
+157 -84
View File
@@ -132,9 +132,11 @@ local UI_SCALE = 1.3
function Kit.layout(width, height)
local s = Theme.clamp(math.min(width / 640, height / 768), 0.9, 1.6) * UI_SCALE
local key = ("%dx%d"):format(math.floor(width), math.floor(height))
if Kit._fontKey ~= key then
Kit._fontKey = key
-- Two numbers, not a formatted key: this runs once per frame and the
-- string:format allocated on every one of them.
local kw, kh = math.floor(width), math.floor(height)
if Kit._fontW ~= kw or Kit._fontH ~= kh then
Kit._fontW, Kit._fontH = kw, kh
Kit.fonts = Theme.fonts(s)
clearCaches() -- every cached Text/width belongs to the old font set
end
@@ -368,6 +370,7 @@ Kit._navPrevN = 0
Kit._navSeen = {}
Kit._navQueue = nil
Kit._activateId = nil
Kit._ringShown = false
-- Register a focusable. Returns true when it currently holds the ring.
-- Shielded widgets do not register: while a modal owns the frame the ring
@@ -383,11 +386,12 @@ function Kit.focusable(id, x, y, w, h)
-- First focusable ever drawn adopts the ring, so keyboard users start
-- somewhere rather than nowhere.
if Kit.focusId == nil then Kit.focusId = id end
return Kit.focusId == id
return Kit._ringShown and Kit.focusId == id
end
function Kit.navigate(dir)
Kit._navQueue = dir
Kit._ringShown = true
end
function Kit.activateFocused()
@@ -396,6 +400,7 @@ end
function Kit.setFocus(id)
Kit.focusId = id
Kit._ringShown = id ~= nil
end
-- Pick the nearest focusable in `dir` from the current one. Candidates must
@@ -530,13 +535,10 @@ Kit._audit = audit
function Kit.tapMin() return math.floor(30 * Kit.scale) end
-- ---------------------------------------------------------------- surfaces
function Kit.card(x, y, w, h, emphasis)
Theme.card(x, y, w, h, emphasis)
function Kit.card(x, y, w, h, variant)
Theme.card(x, y, w, h, variant)
end
-- A list row. `id` opts it into the focus ring; pass nil for decorative
-- rows. Returns (clicked, inkColor) -- a selected row fills white, so the
-- caller must print with the returned ink or it will draw white on white.
function Kit.row(x, y, w, h, selected, id)
audit("row", x, y, w, h, id or "row")
local focused = id and Kit.focusable(id, x, y, w, h) or false
@@ -560,7 +562,7 @@ end
-- hairline says the same thing for one rect.)
function Kit.emptyBox(x, y, w, h, message)
if not G then return end
Theme.strokeRounded(x, y, w, h, PAL.line, 0.22, 1, Theme.radius())
Theme.card(x, y, w, h, "empty")
Kit.textCenter("button", Kit.ellipsize("button", message, w - 24 * Kit.scale),
x, y + (h - Kit.textHeight("button")) / 2, w, PAL.muted)
end
@@ -598,61 +600,130 @@ local KINDS = {
disabled = { fill = PAL.steel, ink = PAL.inverse, flat = true },
}
Kit.KINDS = KINDS
local NO_OPTS = {}
-- opts: { kind, font, enabled, align, id, glow, fill, ink }
-- id -- opts into the focus ring (give every real control one)
-- glow -- a pulsing outline for "something is waiting for you" (the
-- update button). No blend-mode change: the alpha of the
-- existing outline is animated instead.
-- fill/ink -- override the kind's colours. The ONE caller is the
-- launcher's Play button, which wears its cartridge colour
-- (red/blue/gold) rather than a semantic one: on that screen
-- "which game am I launching" outranks "what kind of verb is
-- this", and the colour is already the tab's identity.
-- Returns true when activated, by click OR by the focus ring's Enter/A.
function Kit.button(x, y, w, h, label, opts)
opts = opts or {}
opts = opts or NO_OPTS
local enabled = opts.enabled ~= false
-- Disabled buttons audit too: they stay visible, so they still must not
-- paint over a neighbour.
audit("control", x, y, w, h, label)
local focused = enabled and opts.id
and Kit.focusable(opts.id, x, y, w, h) or false
local kind = KINDS[enabled and (opts.kind or "ghost") or "disabled"]
if enabled and opts.fill then
kind = { fill = opts.fill, ink = opts.ink or PAL.inverse }
end
local hot = enabled and Kit.hover(x, y, w, h)
if G then
-- The fill IS the control: a rounded, embossed, colour-coded key. A
-- disabled button keeps its shape in a dead grey rather than
-- disappearing, so a layout never reflows on state.
Theme.fillRounded(x, y, w, h, kind.fill, enabled and 1 or 0.45)
Theme.emboss(x, y, w, h, enabled and (hot and 1.3 or 1) or 0.4)
if hot or focused then
-- White ring outside the fill: legible on green, blue, yellow, red and
-- white alike, which one darker/lighter shade per colour would not be.
Theme.strokeRounded(x - 2, y - 2, w + 4, h + 4, PAL.lineStrong,
Theme.A.focus, 2, Theme.radius() + 2)
elseif opts.glow and enabled then
-- "Something is waiting for you" (the update button): a pulsing ring.
-- Pure alpha on one existing stroke -- no extra draw calls, no blend
-- mode change.
local a = 0.25 + 0.75 * (0.5 + 0.5 * math.sin(Kit.time * 3))
Theme.strokeRounded(x - 2, y - 2, w + 4, h + 4, PAL.lineStrong, a, 2,
Theme.radius() + 2)
local face = opts.face or "fill"
local B = Theme.BUTTON
local radius = opts.radius or B.radius
local active = opts.active or opts.on
local invert = false
local fill, ink, stroke, strokeA, doEmboss, doRing, glowA
if face == "invert" then
invert = hot
fill = invert and (opts.hotFill or PAL.ink) or (opts.fill or PAL.surface)
ink = invert and (opts.hotInk or PAL.inverse) or (opts.ink or PAL.heading)
stroke = opts.stroke or PAL.line
strokeA = invert and Theme.A.focus or Theme.A.hairline
doRing = focused and not hot
elseif face == "tab" then
invert = active or focused or hot
local tint = opts.color or opts.fill or PAL.ink
fill = invert and tint or PAL.surface
ink = invert and PAL.inverse or (opts.color or PAL.text)
if not invert then
stroke = tint
strokeA = opts.color and Theme.A.hover or Theme.A.hairline
end
local fname = opts.font or "button"
local ink = enabled and kind.ink or PAL.inverse
local ty = y + (h - Kit.textHeight(fname)) / 2
local shown = Kit.ellipsize(fname, label, w - 16 * Kit.scale)
-- Button labels are bold: they are the shortest, most-scanned text on
-- screen and sit on a saturated fill.
if opts.align == "left" then
Kit.textBold(fname, shown, x + 10 * Kit.scale, ty, ink)
elseif face == "chip" then
local c = opts.color or PAL.line
invert = active and true or false
if active then
fill = c
ink = PAL.inverse
doEmboss = true
else
Kit.textCenterBold(fname, shown, x, ty, w, ink)
fill = PAL.bg
ink = c
stroke = c
strokeA = (focused or hot) and Theme.A.focus or Theme.A.hover
end
doRing = focused or hot
else
local kind = KINDS[enabled and (opts.kind or "ghost") or "disabled"]
fill = (enabled and opts.fill) or kind.fill
ink = (enabled and opts.ink) or kind.ink
doEmboss = true
doRing = hot or focused
if opts.glow and enabled and not doRing then
glowA = B.glowBase + B.glowAmp * (0.5 + 0.5 * math.sin(Kit.time * B.glowHz))
end
end
if opts.emboss ~= nil then doEmboss = opts.emboss end
if opts.ring ~= nil then doRing = opts.ring end
if G then
Theme.fillRounded(x, y, w, h, fill, enabled and 1 or B.disabledA, radius)
if doEmboss then
local es = enabled and ((hot or focused) and B.embossHot or B.embossRest)
or B.embossDisabled
Theme.emboss(x, y, w, h, es)
end
if strokeA then
Theme.strokeRounded(x, y, w, h, stroke, strokeA, 1, radius)
end
if doRing then
Theme.strokeRounded(x - B.ringPad, y - B.ringPad,
w + 2 * B.ringPad, h + 2 * B.ringPad, PAL.lineStrong,
Theme.A.focus, B.ringWidth, radius + B.ringPad)
elseif glowA then
Theme.strokeRounded(x - B.ringPad, y - B.ringPad,
w + 2 * B.ringPad, h + 2 * B.ringPad, PAL.lineStrong,
glowA, B.ringWidth, radius + B.ringPad)
end
local fname = opts.font or ((face == "chip") and "micro" or "button")
local ty = y + (h - Kit.textHeight(fname)) / 2
local image = opts.image
local drawFn = opts.drawFn
local letter = opts.letter
local hasLabel = label and label ~= ""
local bold = opts.bold
if bold == nil then bold = face ~= "tab" end
if image then
local box = h
local boxX, boxY = x, y
if not hasLabel then
box = math.min(w, h)
boxX = x + (w - box) / 2
boxY = y + (h - box) / 2
end
local iw, ih = image:getDimensions()
local pad = math.floor(box * (opts.iconPad or B.iconPad))
local s = math.min((box - 2 * pad) / iw, (box - 2 * pad) / ih)
if invert then Theme.col(PAL.inverse, 1)
else Theme.col(PAL.ink, B.iconRestA) end
G.draw(image, Theme.snap(boxX + (box - iw * s) / 2),
Theme.snap(boxY + (box - ih * s) / 2), 0, s, s)
if hasLabel then
local lx = x + h + B.letterGap * Kit.scale
if bold then Kit.textBold(fname, label, lx, ty, ink)
else Kit.text(fname, label, lx, ty, ink) end
end
elseif drawFn then
drawFn(x, y, w, h, invert or hot or focused)
elseif letter then
Kit.textCenter(fname, letter, x, ty, h, ink)
if hasLabel then
local lx = x + h + B.letterGap * Kit.scale
if bold then Kit.textBold(fname, label, lx, ty, ink)
else Kit.text(fname, label, lx, ty, ink) end
end
elseif hasLabel then
local shown = Kit.ellipsize(fname, label, w - B.labelInset * Kit.scale)
if opts.align == "left" then
local lx = x + B.labelPad * Kit.scale
if bold then Kit.textBold(fname, shown, lx, ty, ink)
else Kit.text(fname, shown, lx, ty, ink) end
elseif bold then
Kit.textCenterBold(fname, shown, x, ty, w, ink)
else
Kit.textCenter(fname, shown, x, ty, w, ink)
end
end
end
if not enabled then return false end
@@ -661,7 +732,6 @@ function Kit.button(x, y, w, h, label, opts)
and Kit._activateId == opts.id)
end
-- A small square control: +/- steppers, arrow cyclers, the row X.
function Kit.stepper(x, y, w, h, glyph, opts)
opts = opts or {}
opts.kind = opts.kind or "ghost"
@@ -669,31 +739,13 @@ function Kit.stepper(x, y, w, h, glyph, opts)
return Kit.button(x, y, w, h, glyph, opts)
end
-- A pill toggle (badges, dex SEEN/OWN, sub-tabs). `on` inverts it.
local CHIP_OPTS = { face = "chip", font = "micro" }
function Kit.chip(x, y, w, h, label, on, color, id)
audit("control", x, y, w, h, label)
local focused = id and Kit.focusable(id, x, y, w, h) or false
local c = color or PAL.line
if G then
local hot = focused or Kit.hover(x, y, w, h)
if on then
Theme.fillRounded(x, y, w, h, c, 1)
Theme.emboss(x, y, w, h, 1)
Kit.textCenterBold("micro", label, x,
y + (h - Kit.textHeight("micro")) / 2, w, PAL.inverse)
else
Theme.fillRounded(x, y, w, h, PAL.bg, 1)
Theme.strokeRounded(x, y, w, h, c,
hot and Theme.A.focus or Theme.A.hover, 1)
Kit.textCenterBold("micro", label, x,
y + (h - Kit.textHeight("micro")) / 2, w, c)
end
if hot then
Theme.strokeRounded(x - 2, y - 2, w + 4, h + 4, PAL.lineStrong,
Theme.A.focus, 2, Theme.radius() + 2)
end
end
return Kit.press(x, y, w, h) or (id ~= nil and Kit._activateId == id)
CHIP_OPTS.active = on
CHIP_OPTS.color = color
CHIP_OPTS.id = id
return Kit.button(x, y, w, h, label, CHIP_OPTS)
end
-- A status label with no interaction: outlined text, the "INSTALLED"/"UPDATE"
@@ -807,6 +859,8 @@ end
-- never silently truncated. This is the ONLY way the launcher moves through
-- a long list: no scrollbars, no momentum, bounded row count per frame.
-- Returns the new page (1-based) and the row height consumed.
local pagerLabels = {}
function Kit.pager(x, y, w, page, total, perPage, idPrefix)
local h = math.max(Kit.tapMin(), 30 * Kit.scale)
local bw = 74 * Kit.scale
@@ -826,7 +880,19 @@ function Kit.pager(x, y, w, page, total, perPage, idPrefix)
local first = total > 0 and ((page - 1) * perPage + 1) or 0
local last = math.min(total, page * perPage)
local label = ("%d-%d of %d (page %d/%d)"):format(first, last, total, page, pages)
-- One memo per pager id. The counts only change when the user pages or the
-- list does; formatting them every frame minted a new string that then
-- missed the width / ellipsis / Text caches by content.
local memo = pagerLabels[idPrefix]
if not memo then memo = {}; pagerLabels[idPrefix] = memo end
if memo.first ~= first or memo.last ~= last or memo.total ~= total
or memo.page ~= page or memo.pages ~= pages then
memo.first, memo.last, memo.total = first, last, total
memo.page, memo.pages = page, pages
memo.label = ("%d-%d of %d (page %d/%d)")
:format(first, last, total, page, pages)
end
local label = memo.label
local labelX = x + 2 * bw + 2 * gap + gap
Kit.text("mono", Kit.ellipsize("mono", label, math.max(0, x + w - labelX)),
labelX, y + (h - Kit.textHeight("mono")) / 2, PAL.caption)
@@ -892,7 +958,10 @@ end
-- region can never unclip its parent. The tracked rect also bounds Kit.hit,
-- so a widget clipped out of view is inert instead of taking taps aimed at
-- whatever is drawn where it left.
-- The stack rects are pooled by depth and fully overwritten on every push,
-- so a frame that clips a dozen lists allocates nothing.
local clipStack = {}
local clipPool = {}
local function applyClip(rect)
Kit._clipRect = rect
@@ -917,8 +986,12 @@ function Kit.pushClip(x, y, w, h)
x2 = math.min(x2, prev.x + prev.w)
y2 = math.min(y2, prev.y + prev.h)
end
local rect = { x = x, y = y, w = math.max(0, x2 - x), h = math.max(0, y2 - y) }
clipStack[#clipStack + 1] = rect
local n = #clipStack + 1
local rect = clipPool[n]
if not rect then rect = {}; clipPool[n] = rect end
rect.x, rect.y = x, y
rect.w, rect.h = math.max(0, x2 - x), math.max(0, y2 - y)
clipStack[n] = rect
applyClip(rect)
end
+29 -15
View File
@@ -29,6 +29,15 @@ Layout.BP = {
-- Build the frame's metrics. `maxAppW` caps the content column on an
-- ultrawide monitor so the UI stays a readable measure instead of stretching.
-- One metrics table, reused. Every field is a pure function of the window
-- size, the safe area and maxAppW, so the table only has to be rebuilt when
-- one of those changes; the launcher asked for a fresh one 60 times a second
-- and threw all of them away. Callers must treat `m` as read-only (nothing
-- writes to it today) -- a caller that needs a shifted field should save,
-- assign and restore it around the call, not wrap `m` in a proxy.
local M = {}
local lastW, lastH, lastOx, lastOy, lastSw, lastSh, lastMax
function Layout.metrics(maxAppW)
local W, H = 0, 0
if love and love.graphics and love.graphics.getDimensions then
@@ -36,23 +45,28 @@ function Layout.metrics(maxAppW)
end
local ox, oy, sw, sh = SafeArea.rect()
local s = Kit.layout(sw, sh)
if W == lastW and H == lastH and ox == lastOx and oy == lastOy
and sw == lastSw and sh == lastSh and maxAppW == lastMax then
return M
end
lastW, lastH, lastOx, lastOy = W, H, ox, oy
lastSw, lastSh, lastMax = sw, sh, maxAppW
local appW = math.min(sw, (maxAppW or 1200) * s)
local m = {
W = W, H = H, s = s,
x = math.floor(ox + (sw - appW) / 2),
top = math.floor(oy),
w = math.floor(appW),
h = math.floor(sh),
pad = math.floor(Theme.clamp(appW * 0.03, 10, 24)),
gap = math.floor(12 * s),
colGap = math.floor(16 * s),
rowH = math.max(Kit.tapMin(), math.floor(44 * s)),
btnH = math.max(Kit.tapMin(), math.floor(38 * s)),
chip = math.max(Kit.tapMin(), math.floor(40 * s)),
railH = math.max(3, math.floor(4 * s)),
logoH = math.floor(Theme.clamp(sh * 0.10, 36, 84)),
}
local m = M
m.W, m.H, m.s = W, H, s
m.x = math.floor(ox + (sw - appW) / 2)
m.top = math.floor(oy)
m.w = math.floor(appW)
m.h = math.floor(sh)
m.pad = math.floor(Theme.clamp(appW * 0.03, 10, 24))
m.gap = math.floor(12 * s)
m.colGap = math.floor(16 * s)
m.rowH = math.max(Kit.tapMin(), math.floor(44 * s))
m.btnH = math.max(Kit.tapMin(), math.floor(38 * s))
m.chip = math.max(Kit.tapMin(), math.floor(40 * s))
m.railH = math.max(3, math.floor(4 * s))
m.logoH = math.floor(Theme.clamp(sh * 0.10, 36, 84))
m.cols = (appW >= Layout.BP.threeCol * s and 3)
or (appW >= Layout.BP.twoCol * s and 2)
or 1
+95 -20
View File
@@ -35,8 +35,8 @@ local PAL = {
-- it in the same hue, so a card reads as a raised object rather than as an
-- outline drawn on the page. These are still flat fills -- the depth comes
-- from the value step plus Theme.shadow, not from a gradient.
field = { 16, 8, 10 }, -- the page BEHIND the cards
bg = { 0, 0, 0 }, -- true black: button rests, field interiors
field = { 20, 20, 20 }, -- the page BEHIND the cards
bg = { 20, 20, 20 }, -- light black
surface = { 28, 21, 24 }, -- card interiors
rowBg = { 20, 14, 17 }, -- rows inside a card, one step below it
raised = { 44, 34, 38 }, -- hover feedback
@@ -82,6 +82,67 @@ Theme.A = {
disabled = 0.30,
}
Theme.BUTTON = {
radius = 8,
ringPad = 2,
ringWidth = 2,
labelInset = 16,
labelPad = 10,
iconPad = 0.24,
letterGap = 4,
disabledA = 0.45,
embossHot = 1.3,
embossRest = 1,
embossDisabled = 0.4,
glowHz = 3,
glowBase = 0.25,
glowAmp = 0.75,
iconRestA = 0.85,
}
Theme.CARD = {
radius = 14,
shadow = true,
fill = PAL.surface,
fillA = 1,
stroke = PAL.line,
strokeA = Theme.A.hairline,
}
Theme.CARD_VARIANT = {
emphasis = { strokeA = Theme.A.focus },
muted = {
fill = PAL.bg, fillA = 0.8, stroke = PAL.muted, strokeA = 0.25, shadow = false,
},
mutedHot = {
fill = PAL.bg, fillA = 0.8, stroke = PAL.muted, strokeA = Theme.A.hover, shadow = false,
},
empty = { fillA = 0, strokeA = 0.22, shadow = false, radius = "ctl" },
warn = {
fill = PAL.rowBg, stroke = PAL.yellow, strokeA = Theme.A.hover,
shadow = false, radius = "ctl",
},
row = {
fill = PAL.rowBg, stroke = PAL.line, strokeA = Theme.A.hairline,
shadow = false, radius = "ctl",
},
rowHover = {
fill = PAL.raised, stroke = PAL.line, strokeA = Theme.A.hover,
shadow = false, radius = "ctl",
},
rowSelected = {
fill = PAL.ink, strokeA = 0, shadow = false, radius = "ctl",
},
hairline = {
fillA = 0, stroke = PAL.line, strokeA = Theme.A.hairline,
shadow = false, radius = "ctl",
},
badge = {
fill = PAL.bg, stroke = PAL.yellow, strokeA = Theme.A.hover,
shadow = false, radius = 0,
},
}
local G = love and love.graphics or nil
local has = {}
@@ -123,11 +184,11 @@ end
-- Controls get the smaller one, containers the larger, so a button never
-- looks like a card and a card never looks like a button.
function Theme.radius()
return 8
return Theme.BUTTON.radius
end
function Theme.cardRadius()
return 14
return Theme.CARD.radius
end
-- DROP SHADOW. Three stacked rounded rects at low alpha, each one step wider
@@ -206,28 +267,42 @@ end
-- The design's only container: a rounded surface a few values above the
-- field, its own drop shadow, and a white hairline. `emphasis` raises the
-- outline to full white (used for the focused/active card).
function Theme.card(x, y, w, h, emphasis)
local r = Theme.cardRadius()
Theme.shadow(x, y, w, h, r)
Theme.fillRounded(x, y, w, h, PAL.surface, 1, r)
Theme.strokeRounded(x, y, w, h, PAL.line,
emphasis and Theme.A.focus or Theme.A.hairline, 1, r)
function Theme.card(x, y, w, h, variant)
local spec = Theme.CARD
local v
if variant == true then
v = Theme.CARD_VARIANT.emphasis
elseif type(variant) == "string" then
v = Theme.CARD_VARIANT[variant]
elseif type(variant) == "table" then
v = variant
end
local radius = (v and v.radius) or spec.radius
if radius == "ctl" then radius = Theme.BUTTON.radius end
local fill = (v and v.fill) or spec.fill
local fillA = spec.fillA
if v and v.fillA ~= nil then fillA = v.fillA end
local stroke = (v and v.stroke) or spec.stroke
local strokeA = spec.strokeA
if v and v.strokeA ~= nil then strokeA = v.strokeA end
local shadow = spec.shadow
if v and v.shadow ~= nil then shadow = v.shadow end
local strokeW = (v and v.strokeW) or 1
if fillA > 0 and fill then
if shadow then Theme.shadow(x, y, w, h, radius) end
Theme.fillRounded(x, y, w, h, fill, fillA, radius)
end
if strokeA > 0 and stroke then
Theme.strokeRounded(x, y, w, h, stroke, strokeA, strokeW, radius)
end
end
-- A list row. Three states, each one rect plus one outline:
-- normal one value below the card it sits in, hairline
-- hover lifted fill, brighter hairline
-- selected WHITE fill (callers print ink = PAL.inverse over it)
function Theme.row(x, y, w, h, state)
local r = Theme.radius()
if state == "selected" then
Theme.fillRounded(x, y, w, h, PAL.ink, 1, r)
Theme.card(x, y, w, h, "rowSelected")
return PAL.inverse
end
Theme.fillRounded(x, y, w, h,
state == "hover" and PAL.raised or PAL.rowBg, 1, r)
Theme.strokeRounded(x, y, w, h, PAL.line,
state == "hover" and Theme.A.hover or Theme.A.hairline, 1, r)
Theme.card(x, y, w, h, state == "hover" and "rowHover" or "row")
return PAL.text
end
+15 -6
View File
@@ -8,9 +8,9 @@
-- "update_check_state" worker -> main: { status, latest, progress, error }
--
-- Nothing here ever blocks or throws into the game loop: when love.thread is
-- absent (the headless test stub) or the worker cannot run (no curl, Android),
-- state() simply reports "error" and the UI hides itself. See the shared
-- contract in the task brief for the status vocabulary and the file layout.
-- absent (the headless test stub) or the worker cannot run, state() reports
-- "error" (or the worker reports "needs_full" when there is no transport).
-- See the shared contract in the task brief for the status vocabulary.
--
-- The release-JSON extraction and the sums parsing are exported as pure
-- functions (no love.* calls) so plain-Lua tests can cover them, and so the
@@ -71,6 +71,9 @@ function Check.parseRelease(jsonText, Json)
payloadName = payloadName,
payload = Check.pickAsset(doc.assets, payloadName),
sums = Check.pickAsset(doc.assets, "sha256sums.txt"),
-- GitHub release body: already fetched with the update check, shown by
-- the launcher's Patch notes footer button.
notes = type(doc.body) == "string" and doc.body or "",
}
end
@@ -135,6 +138,10 @@ local function drain()
if stateCh then
local msg = stateCh:pop()
while msg do
if type(msg) == "table" and type(msg.notes) ~= "string"
and type(cache.notes) == "string" then
msg.notes = cache.notes
end
cache = msg
msg = stateCh:pop()
end
@@ -158,11 +165,11 @@ function Check.start()
return
end
requested = true
cache = { status = "checking" }
cache = { status = "checking", notes = cache.notes, latest = cache.latest }
cmdCh:push({ cmd = "check" })
end
-- Current snapshot: { status, latest, progress, error }. status is one of
-- Current snapshot: { status, latest, progress, error, notes }. status is one of
-- idle | checking | uptodate | available | downloading | ready | needs_full | error.
function Check.state()
drain()
@@ -171,6 +178,7 @@ function Check.state()
latest = cache.latest,
progress = cache.progress,
error = cache.error,
notes = cache.notes,
}
end
@@ -180,7 +188,8 @@ function Check.download()
drain()
if not cmdCh then return end
if cache.status ~= "available" then return end
cache = { status = "downloading", latest = cache.latest, progress = 0 }
cache = { status = "downloading", latest = cache.latest, progress = 0,
notes = cache.notes }
cmdCh:push({ cmd = "download" })
end
+112
View File
@@ -0,0 +1,112 @@
-- Resolve the game's patch notes for the launcher footer modal.
--
-- Sources, first hit wins:
-- 1. The GitHub body the self-updater already fetched (Check.parseRelease)
-- 2. PATCH_NOTES.md, if a build packed one
-- 3. mobile/ios/app-repo.json -- CI copies each release's notes into
-- versions[].localizedDescription, so a checkout already has them
-- on disk even when the updater has not run
local Json = require("src.link.Json")
local PatchNotes = {}
PatchNotes.FILES = {
"PATCH_NOTES.md",
"assets/PATCH_NOTES.md",
}
PatchNotes.REPO_FILES = {
"mobile/ios/app-repo.json",
}
local function nonempty(s)
return type(s) == "string" and s:find("%S")
end
function PatchNotes.fromCheck(Check)
if not (Check and Check.state) then return nil, nil end
local ok, st = pcall(Check.state)
st = (ok and type(st) == "table") and st or nil
if not st then return nil, nil end
if nonempty(st.notes) then
return st.notes, st.latest
end
return nil, st.latest
end
local function readPath(path)
local fs = love and love.filesystem
if fs and fs.read then
local ok, text = pcall(fs.read, path)
if ok and nonempty(text) then return text end
end
local f = io.open(path, "rb")
if not f then return nil end
local text = f:read("*a")
f:close()
return nonempty(text) and text or nil
end
function PatchNotes.fromFile()
for _, path in ipairs(PatchNotes.FILES) do
local text = readPath(path)
if text then return text end
end
return nil
end
-- Newest-first list of { version, notes } from the iOS sidecar.
function PatchNotes.parseRepo(jsonText)
local doc = Json.decode(jsonText)
if type(doc) ~= "table" or type(doc.apps) ~= "table" then return {} end
local out = {}
for _, app in ipairs(doc.apps) do
if type(app) == "table" and type(app.versions) == "table" then
for _, row in ipairs(app.versions) do
if type(row) == "table" and nonempty(row.localizedDescription) then
out[#out + 1] = {
version = row.version,
notes = row.localizedDescription,
}
end
end
end
end
return out
end
function PatchNotes.fromRepo(engine)
for _, path in ipairs(PatchNotes.REPO_FILES) do
local text = readPath(path)
if text then
local list = PatchNotes.parseRepo(text)
if #list == 0 then return nil, nil end
if engine and engine ~= "0.0.0-dev" then
for _, row in ipairs(list) do
if row.version == engine then
return row.notes, row.version
end
end
end
return list[1].notes, list[1].version
end
end
return nil, nil
end
function PatchNotes.body(Check)
local notes, ver = PatchNotes.fromCheck(Check)
if notes then return notes, ver end
notes = PatchNotes.fromFile()
if notes then return notes, ver end
local Version = require("src.core.Version")
local engine = (Version and Version.engine) or "?"
notes, ver = PatchNotes.fromRepo(engine)
if notes then return notes, ver end
return "No patch notes loaded yet for gen1recomp v" .. engine .. ".\n\n"
.. "They appear here after the launcher checks GitHub for the latest "
.. "release.", engine
end
return PatchNotes
+71 -63
View File
@@ -5,11 +5,10 @@
-- "update_check_cmd" in: { cmd = "check" | "download" | "quit" }
-- "update_check_state" out: { status, latest, progress, error }
--
-- Transport is curl shelled out via io.popen (curl ships on macOS, Windows 10+
-- and desktop Linux). Everything is wrapped so a missing curl, an HTTP error,
-- or a hung download degrades to a "error"/"needs_full" state rather than
-- blocking or crashing the game. On Android curl is absent and the check
-- soft-fails to "error", which the UI hides.
-- Transport is HostShell: curl via io.popen on desktop, the JNI
-- love.system.httpDownload bridge on Android (same path the mod catalog
-- already uses). A missing transport, an HTTP error, or a hung download
-- degrades to "error"/"needs_full" rather than blocking or crashing the game.
--
-- Fresh love threads do not carry the "src.*" package searcher, so sibling
-- modules are pulled in with love.filesystem.load exactly like
@@ -45,7 +44,12 @@ local Boot = loadModule("src/update/Boot.lua")
local cmdCh = love.thread.getChannel("update_check_cmd")
local stateCh = love.thread.getChannel("update_check_state")
local function post(t) stateCh:push(t) end
local function post(t)
if pending and type(t) == "table" and t.notes == nil then
t.notes = pending.notes
end
stateCh:push(t)
end
local osName = (love.system and love.system.getOS and love.system.getOS()) or ""
local isWindows = osName == "Windows"
@@ -58,9 +62,12 @@ local API_URL = "https://api.github.com/repos/bryanthaboi/gen1recomp/releases/la
local pending = nil
-- ---------------------------------------------------------------------------
-- shell / curl
-- shell / fetch
-- ---------------------------------------------------------------------------
local UA = "gen1recomp-updater"
local GH_ACCEPT = "application/vnd.github+json"
local function shq(s)
s = tostring(s)
if isWindows then
@@ -69,31 +76,17 @@ local function shq(s)
return "'" .. s:gsub("'", "'\\''") .. "'"
end
-- run curl and return its response body (text), or nil on any failure. Used
-- for the small text resources (release JSON, sums file); -f makes curl exit
-- non-zero and emit nothing on an HTTP error, so an empty read is a failure.
local function curlCapture(url)
local cmd = "curl -fsSL --connect-timeout 10 --max-time 40 "
.. "-H " .. shq("User-Agent: gen1recomp-updater") .. " "
.. "-H " .. shq("Accept: application/vnd.github+json") .. " "
.. shq(url)
local pipe = HostShell.popen(cmd)
if not pipe then return nil end
local out = pipe:read("*a")
-- HostShell.pclose, not pipe:close(): a close outside the spawn lock can
-- free a FILE while another thread's popen walks the stream list, which
-- deadlocks that thread permanently (see HostShell's popen notes).
HostShell.pclose(pipe)
if not out or out == "" then return nil end
return out
-- Small text resources (release JSON, sums file) through HostShell so Android
-- hits the JNI bridge instead of a curl binary that is never on the device.
local function fetchText(url, accept)
if not HostShell then return nil end
local body = HostShell.httpGet(url, UA, accept)
if type(body) ~= "string" or body == "" then return nil end
return body
end
local function haveCurl()
local pipe = HostShell.popen("curl --version")
if not pipe then return false end
local out = pipe:read("*a")
HostShell.pclose(pipe)
return out ~= nil and out:find("curl", 1, true) ~= nil
local function canFetch()
return HostShell and HostShell.canFetch()
end
-- ---------------------------------------------------------------------------
@@ -165,12 +158,14 @@ end
local function doCheck()
post({ status = "checking" })
if not haveCurl() then
post({ status = "error", error = "curl not available" })
if not canFetch() then
-- No curl and no JNI bridge: the chip becomes "Open releases" so a tap
-- still does something instead of retrying a check that cannot succeed.
post({ status = "needs_full" })
return
end
local body = curlCapture(API_URL)
local body = fetchText(API_URL, GH_ACCEPT)
if not body then
post({ status = "error", error = "release check failed" })
return
@@ -207,7 +202,7 @@ local function doCheck()
-- pulling the bytes again.
local finalRel = "updates/" .. rel.payloadName
if love.filesystem.getInfo(finalRel) then
local sums = curlCapture(rel.sums.url)
local sums = fetchText(rel.sums.url)
if sums and verifyPayload(finalRel, rel.payloadName, sums) then
if gatePasses(finalRel) == false then
love.filesystem.remove(finalRel)
@@ -274,39 +269,52 @@ local function doDownload()
local doneAbs = saveDir .. "/updates/" .. rel.payloadName .. ".done"
local size = rel.payload.size or 0
launchDownload(rel.payload.url, partAbs, doneAbs)
if HostShell and HostShell.haveCurl() then
launchDownload(rel.payload.url, partAbs, doneAbs)
-- poll the .part size for progress until curl drops the done-marker; a
-- stalled or run-away transfer breaks out and lets verification fail cleanly
local waited, lastSize, lastChange = 0, -1, 0
while true do
-- A queued quit means the window already closed. Bail so the join in
-- Check.shutdown does not hold the dead window's process (and, on
-- Windows, its folder) open for up to the whole transfer (#727). The
-- quit stays on the channel for the command loop; the detached curl
-- times out on its own and the next launch's doCheck verifies and
-- re-offers whatever landed.
local peeked = cmdCh:peek()
if type(peeked) == "table" and peeked.cmd == "quit" then return end
if love.filesystem.getInfo(doneRel) then break end
local pinfo = love.filesystem.getInfo(partRel)
local cur = (pinfo and pinfo.size) or 0
if size > 0 then
local p = cur / size
if p > 0.999 then p = 0.999 end -- 1.0 is reserved for "ready"
post({ status = "downloading", latest = rel.version, progress = p })
else
post({ status = "downloading", latest = rel.version })
-- poll the .part size for progress until curl drops the done-marker; a
-- stalled or run-away transfer breaks out and lets verification fail cleanly
local waited, lastSize, lastChange = 0, -1, 0
while true do
-- A queued quit means the window already closed. Bail so the join in
-- Check.shutdown does not hold the dead window's process (and, on
-- Windows, its folder) open for up to the whole transfer (#727). The
-- quit stays on the channel for the command loop; the detached curl
-- times out on its own and the next launch's doCheck verifies and
-- re-offers whatever landed.
local peeked = cmdCh:peek()
if type(peeked) == "table" and peeked.cmd == "quit" then return end
if love.filesystem.getInfo(doneRel) then break end
local pinfo = love.filesystem.getInfo(partRel)
local cur = (pinfo and pinfo.size) or 0
if size > 0 then
local p = cur / size
if p > 0.999 then p = 0.999 end -- 1.0 is reserved for "ready"
post({ status = "downloading", latest = rel.version, progress = p })
else
post({ status = "downloading", latest = rel.version })
end
if cur ~= lastSize then lastSize, lastChange = cur, waited end
if waited - lastChange > 60 then break end -- 60s with no growth: give up
if waited > 960 then break end -- absolute ceiling
love.timer.sleep(0.25)
waited = waited + 0.25
end
if cur ~= lastSize then lastSize, lastChange = cur, waited end
if waited - lastChange > 60 then break end -- 60s with no growth: give up
if waited > 960 then break end -- absolute ceiling
love.timer.sleep(0.25)
waited = waited + 0.25
love.filesystem.remove(doneRel)
else
-- Android JNI bridge: blocking write, same as fetch_worker. Progress
-- cannot be sampled from inside httpDownload.
local ok = HostShell and HostShell.httpDownload(
rel.payload.url, partAbs, UA, nil, 900)
if not ok then
love.filesystem.remove(partRel)
post({ status = "error", error = "download failed" })
return
end
post({ status = "downloading", latest = rel.version, progress = 0.999 })
end
love.filesystem.remove(doneRel)
local sums = curlCapture(rel.sums and rel.sums.url or "")
local sums = fetchText(rel.sums and rel.sums.url or "")
if not sums then
love.filesystem.remove(partRel)
post({ status = "error", error = "checksum fetch failed" })
+84
View File
@@ -0,0 +1,84 @@
-- Generation-neutral minimap rasterization. WorldAPI arms own semantic
-- markers because object/event visibility differs; terrain and tile shading
-- share one output contract.
local Assets = require("src.render.Assets")
local MapOverview = {}
local overviewShades = {}
Assets.register(function() overviewShades = {} end)
local function shadeDigit(sum, pixelCount)
return tostring(math.max(0, math.min(3,
math.floor((1 - sum / pixelCount) * 3 + 0.5))))
end
local function tileRows(map)
local tileset = map.tileset
if not (tileset and tileset.image and tileset.tilesPerRow) then return nil end
local cached = overviewShades[tileset.image]
if not cached then
local ok, pixels = pcall(Assets.imageData, tileset.image)
if not ok then return nil end
cached = { pixels = pixels, shades = {} }
overviewShades[tileset.image] = cached
end
local rows, detailRows, perRow = {}, {}, tileset.tilesPerRow
for ty = 0, map.heightCells * 2 - 1 do
local row, detailTop, detailBottom = {}, {}, {}
for tx = 0, map.widthCells * 2 - 1 do
local tile = map:tileAt(tx, ty)
local shades = cached.shades[tile]
if shades == nil then
local sums = { 0, 0, 0, 0 }
local ox, oy = (tile % perRow) * 8, math.floor(tile / perRow) * 8
for py = 0, 7 do
for px = 0, 7 do
local r, g, b = cached.pixels:getPixel(ox + px, oy + py)
local quadrant = math.floor(py / 4) * 2 + math.floor(px / 4) + 1
sums[quadrant] = sums[quadrant]
+ r * 0.2126 + g * 0.7152 + b * 0.0722
end
end
shades = {
shadeDigit(sums[1] + sums[2] + sums[3] + sums[4], 64),
shadeDigit(sums[1], 16), shadeDigit(sums[2], 16),
shadeDigit(sums[3], 16), shadeDigit(sums[4], 16),
}
cached.shades[tile] = shades
end
row[#row + 1] = shades[1]
detailTop[#detailTop + 1] = shades[2] .. shades[3]
detailBottom[#detailBottom + 1] = shades[4] .. shades[5]
end
rows[#rows + 1] = table.concat(row)
detailRows[#detailRows + 1] = table.concat(detailTop)
detailRows[#detailRows + 1] = table.concat(detailBottom)
end
return rows, detailRows
end
function MapOverview.build(map, markers)
local rows = {}
for y = 0, map.heightCells - 1 do
local row = {}
for x = 0, map.widthCells - 1 do
row[#row + 1] = map:isWarpTileCell(x, y) and "+"
or map:isWaterCell(x, y) and "~"
or map:isWalkableCell(x, y) and "." or " "
end
rows[#rows + 1] = table.concat(row)
end
local tiles, detail = tileRows(map)
return { mapId = map.id, width = map.widthCells,
height = map.heightCells, rows = rows, markers = markers,
tileRows = tiles,
tileWidth = tiles and map.widthCells * 2,
tileHeight = tiles and map.heightCells * 2,
tileDetailRows = detail,
tileDetailWidth = detail and map.widthCells * 4,
tileDetailHeight = detail and map.heightCells * 4 }
end
return MapOverview
+15 -5
View File
@@ -8,7 +8,7 @@ local SpriteRenderer = require("src.render.SpriteRenderer")
local NPC = {}
NPC.__index = NPC
local STEP_FRAMES = 16
local STEP_FRAMES = 32
local FACING_FROM_RANGE = {
DOWN = "down", UP = "up", LEFT = "left", RIGHT = "right",
@@ -52,7 +52,16 @@ function NPC:facePlayer(player)
end
function NPC:update(map, entities)
-- self.stepFrames overrides the shared 16-frame walk for an object whose
-- An NPC tile is 32 frames, half the player's rate: TryWalking loads
-- WALKANIMATIONCOUNTER with $10 and UpdateSpriteInWalkingAnimation adds
-- the 1px step vector once per call (engine/overworld/movement.asm), but
-- UpdateSprites runs once per OverworldLoop pass and every pass opens
-- with two DelayFrame calls (home/overworld.asm) -- so those 16 ticks
-- cost 32 frames for one 16px cell, against AdvancePlayerSprite's 8
-- ticks of 2px. That halving is why pokeyellow's NormalPikachuFollow
-- needs TryDoubleAddPikachuStepVectorToScreenPixelCoords to keep up.
--
-- self.stepFrames overrides the shared walk for an object whose
-- step has to stay in phase with something else: Yellow's follower
-- Pikachu takes the player's own step length, halved while it is more
-- than a cell behind (FastPikachuFollow, engine/pikachu/
@@ -78,7 +87,7 @@ function NPC:update(map, entities)
return
end
local d = Collision.DELTA[self.facing]
-- 1px per frame at the default length; a shortened step scales instead,
-- 1px per 2 frames at the default length; a shortened step scales instead,
-- so the cell still lands on a 16px boundary (Player:update does the
-- same for the bicycle)
local moved = math.floor(self.progress * 16 * span / stepLen)
@@ -113,8 +122,9 @@ end
function NPC:walkPhase()
if not self.moving then return 0 end
local p = self.progress % 16
return (p >= 4 and p < 12) and 1 or 0
local stepLen = self.stepFrames or STEP_FRAMES
local p = self.progress % stepLen
return (p >= stepLen / 4 and p < stepLen * 3 / 4) and 1 or 0
end
-- Same contract as Player:pose -- the sheet, position, facing and step
+246 -68
View File
@@ -230,6 +230,7 @@ function OverworldState:enter(mapId, x, y, facing, opts)
-- a fresh entry, or a stale flag can freeze player input forever
self.engaging = false
self.emote = nil
self.cancelledTrainerSight = nil
-- volatile WRAM state in pokered; never serialize across save/load
self.wildEncounterGraceSteps = 0
-- survives save/load: a loaded game may start inside a building whose
@@ -384,12 +385,21 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
-- NPC instances persist across connection crossings in self.npcPool
-- (keyed by NPC.id): a neighbor map's wandering ghosts ARE the
-- objects that become the real NPCs when the player crosses the
-- seam, so nothing snaps back to its spawn point in view of the
-- survey zoom. Warps rebuild from scratch, like the original's
-- per-entry sprite init (home/overworld.asm LoadMapHeader
-- .loadSpriteData).
-- seam, so a map the player is not entering keeps its ghosts alive
-- in view of the survey zoom. The map being entered does not --
-- crossing a seam runs .loadNewMap -> LoadMapHeader, whose
-- .loadSpriteData zeroes the sprite state data and re-seeds every
-- SPRITESTATEDATA2_MAPY/MAPX from the map header's object data
-- (home/overworld.asm), so an NPC who walked up to the player stands
-- on her spawn cell again the next time that map loads (#1028). Only
-- the save-side spawn flags survive, and those live in Game.save, not
-- here. Warps rebuild the whole pool from scratch.
if not (opts and opts.seamless and self.npcPool) then
self.npcPool = {}
elseif fromMapId ~= mapId then
for _, obj in ipairs(self.map.def.objects or {}) do
self.npcPool[mapId .. "_obj_" .. obj.index] = nil
end
end
self.npcs = {}
for _, obj in ipairs(self.map.def.objects or {}) do
@@ -753,6 +763,70 @@ function OverworldState:bikeAllowed(mapId)
return false
end
-- Field-item entry points keep presentation and state transitions in the
-- owning world instead of asking a supported facade to reproduce either one.
function OverworldState:useBicycle()
local name = Game.save.player.name
if Game.save.onBike then
if Game.save.forcedBike then return false end
Game.save.onBike = false
require("src.core.Music").playMap(Game.data, self.map.id, false)
Game.stack:push(TextBox.new(Game,
Strings("%s got off\nthe BICYCLE.", name)))
elseif self:bikeAllowed(self.map.id) and not self.player.surfing then
Game.save.onBike = true
require("src.core.Music").playMap(Game.data, self.map.id, true)
Game.stack:push(TextBox.new(Game,
Strings("%s got on\nthe BICYCLE!", name)))
else
return false
end
return true
end
-- Field-move entry points keep presentation and state transitions in the
-- overworld. The party menu and supported mod facade both call these, so a
-- shortcut cannot drift from the game's own move flow.
function OverworldState:useFlashFieldMove(onClose)
-- A FLASH used in daylight must not carry into the next dark map. Lighting
-- also happens before the blink: setDark may rebake an ADVANCED atlas, and
-- putting that work in WhiteFlash:onDone caused the frozen white frame in
-- #610.
local wasDark = self.dark
if wasDark then Game.save.flashLit = true end
Game.stack:push(TextBox.new(Game,
Game.data.text._FlashLightsAreaText
or Strings("A blinding FLASH\nlights the area!"), function()
if onClose then onClose() end
if wasDark then self:setDark(false) end
Game.stack:push(Transition.whiteFlash(Game))
end))
return true
end
function OverworldState:useStrengthFieldMove(mon, onClose)
mon = mon or self:partyKnows("STRENGTH")
if not mon then return false end
local def = Game.data.pokemon[mon.species]
local name = mon.nickname or def.name
self.strengthActive = true
local first = (Game.data.text._UsedStrengthText
or Strings("{RAM:wNameBuffer} used\nSTRENGTH."))
:gsub("{RAM:wNameBuffer}", name)
local second = (Game.data.text._CanMoveBouldersText
or Strings("{RAM:wNameBuffer} can\nmove boulders."))
:gsub("{RAM:wNameBuffer}", name)
Game.stack:push(TextBox.new(Game, first, function()
Game.stack:push(TextBox.new(Game, second, function()
if onClose then onClose() end
Game.stack:push(Transition.whiteFlash(Game))
end))
end, { auto = { sound = function()
return require("src.core.Sound").playCry(Game.data, mon.species)
end } }))
return true
end
-- The battle transition's dungeon wipe uses the explicit map lists in
-- data/maps/dungeon_maps.asm (field.dungeonTransitionMaps): singles plus
-- inclusive map-id ranges -- faithful to the original's omissions
@@ -1716,6 +1790,12 @@ function OverworldState:goFishing(rod)
end))
end
function OverworldState:useFishingRod(rod)
if self.player.surfing or not self:facingIsShoreOrWater() then return false end
self:goFishing(rod)
return true
end
-- Fly to a visited town (called from the party menu).
function OverworldState:flyTo(mapId)
local spot = Game.data.field.flyWarps[mapId]
@@ -1986,10 +2066,12 @@ function OverworldState:tryHiddenObject(fx, fy)
end
save.hiddenTaken[key] = true
local name = Game.data.items[h.item] and Game.data.items[h.item].name or h.item
-- hidden items always play SFX_GET_ITEM_2 (hidden_items.asm)
require("src.core.Sound").play(Game.data, "Get_Item2")
-- hidden items always play SFX_GET_ITEM_2, and FoundHiddenItemText's
-- text_asm tail runs it as PlaySoundWaitForCurrent +
-- WaitForSoundToFinish once the box has printed (hidden_items.asm)
Game.stack:push(TextBox.new(Game,
Strings("%s found\n%s!", save.player.name, name)))
Strings("%s found\n%s!", save.player.name, name),
nil, TextBox.soundOpts(Game, "Get_Item2")))
return true
end
end
@@ -2001,9 +2083,9 @@ function OverworldState:tryHiddenObject(fx, fy)
if not save.inventory.COIN_CASE then return false end
save.hiddenTaken[key] = true
save.coins = math.min(9999, (save.coins or 0) + h.coins)
require("src.core.Sound").play(Game.data, "Get_Item2")
Game.stack:push(TextBox.new(Game,
Strings("%s found\n%d coins!", save.player.name, h.coins)))
Strings("%s found\n%d coins!", save.player.name, h.coins),
nil, TextBox.soundOpts(Game, "Get_Item2")))
return true
end
end
@@ -2461,6 +2543,15 @@ function OverworldState:trySurf(fx, fy, onClose)
end))
end
function OverworldState:stopSurfing(onClose)
if onClose then onClose() end
self.player.surfing = false
require("src.core.Music").setSurfing(Game.data, false)
Game.stack:push(Transition.whiteFlash(Game, nil, function()
self:stepForwardOrCrossEdge(self.player.facing)
end))
end
function OverworldState:tryCut(fx, fy)
-- UsedCut (engine/overworld/cut.asm) gates on the TILESET before
-- anything else: only OVERWORLD (tree tile $3d) and GYM (plant tile
@@ -2650,10 +2741,11 @@ function OverworldState:talkTo(npc)
end
local name = Game.data.items[d.item] and Game.data.items[d.item].name or d.item
local ddef = Game.data.items[d.item]
require("src.core.Sound").play(Game.data,
(ddef and ddef.keyItem) and "Get_Key_Item" or "Get_Item1")
-- FoundItemText: text_far, sound_get_item_1, text_end (pick_up_item.asm)
Game.stack:push(TextBox.new(Game,
Strings("%s found\n%s!", Game.save.player.name, name)))
Strings("%s found\n%s!", Game.save.player.name, name), nil,
TextBox.soundOpts(Game,
(ddef and ddef.keyItem) and "Get_Key_Item" or "Get_Item1")))
return
end
@@ -3080,6 +3172,32 @@ local function meetTrainerTheme(cls)
or "Music_MeetMaleTrainer"
end
-- Public pre-trainer gate. A mod may retain continueBattle while a registered
-- preparation screen is on top, then resume once with an optional ordered
-- save-party index scope. The hook is cold on a no-mod boot.
function OverworldState.prepareTrainerBattle(game, context, startBattle,
cancelBattle)
if not Runtime.wantsHook("trainer.before_battle") then
startBattle()
return false
end
local started = false
local function continueBattle(options)
if started then return false end
started = true
if type(options) == "table" and options.cancel == true then
if cancelBattle then cancelBattle() end
else
startBattle(options)
end
return true
end
local deferred = Runtime.call("trainer.before_battle",
function() return false end, game, context, continueBattle)
if deferred ~= true and not started then continueBattle() end
return deferred == true
end
-- Run the pre-battle text -> battle -> won text -> flags sequence.
-- skipBattleText is for map scripts shaped like SilphCo11FDefaultScript
-- (scripts/SilphCo11F.asm), which DisplayTextID the challenge line BEFORE
@@ -3107,7 +3225,8 @@ function OverworldState:engageTrainer(npc, onDone, endBattleText, skipBattleText
or (header and header.won and Game.data.text[header.won])
local BattleState = require("src.battle.BattleState")
local function startBattle()
local function startBattle(options)
self.cancelledTrainerSight = nil
-- TalkToTrainer (home/trainers.asm:88) prints the before-battle text
-- FIRST and only then runs `call EngageMapTrainer` / `jp
-- StartTrainerBattle`, so a trainer challenged on foot gets the sting
@@ -3122,7 +3241,8 @@ function OverworldState:engageTrainer(npc, onDone, endBattleText, skipBattleText
local theme = meetTrainerTheme(d.trainerClass)
if theme then require("src.core.Music").play(Game.data, theme) end
end
local battle = BattleState.newTrainer(Game, d.trainerClass, d.trainerParty)
local battle = BattleState.newTrainer(Game, d.trainerClass, d.trainerParty,
options)
battle.checkpointOrigin = {
kind = "trainer_encounter",
map = self.map.id,
@@ -3159,10 +3279,31 @@ function OverworldState:engageTrainer(npc, onDone, endBattleText, skipBattleText
end
self:pushBattle(battle)
end
local function prepareBattle()
if not Runtime.wantsHook("trainer.before_battle") then
startBattle()
return
end
OverworldState.prepareTrainerBattle(Game, {
trainerClass = d.trainerClass,
partyIndex = d.trainerParty or 1,
mapId = self.map.id,
npcId = npc.id,
}, startBattle, function()
if self.player then
self.cancelledTrainerSight = {
npcId = npc.id,
playerX = self.player.cellX,
playerY = self.player.cellY,
}
end
if onDone then onDone() end
end)
end
if skipBattleText then
startBattle()
prepareBattle()
else
Game.stack:push(TextBox.new(Game, battleText, startBattle))
Game.stack:push(TextBox.new(Game, battleText, prepareBattle))
end
end
@@ -3182,6 +3323,58 @@ local function giveVictoryItem(reward)
return true
end
-- A gym's reward text is not one box. Every gym script carries a sound
-- command right after the FIRST label of each reward group
-- (scripts/PewterGym.asm PewterGymBrockReceivedBoulderBadgeText's
-- sound_level_up, PewterGymReceivedTM34Text's sound_get_item_1, and the
-- equivalents in the other seven), and home/text.asm TextCommand_SOUND
-- plays it only once that page has typed out, then blocks on
-- WaitForSoundToFinish before the next page prints. So the pages
-- accumulate and split into separate boxes at the sound points, each box
-- chained off the previous one's button press.
local function rewardChain()
local chain = { boxes = {}, pending = {} }
function chain.flush(sound)
if #chain.pending > 0 then
table.insert(chain.boxes,
{ text = table.concat(chain.pending, "\f"), sound = sound })
chain.pending = {}
end
end
-- one reward group; `sound` rides its first page, where the scripts put it
function chain.add(labels, sound)
local text = Game.data.text or {}
local n = 0
for _, label in ipairs(labels or {}) do
if text[label] and text[label] ~= "" then
table.insert(chain.pending, text[label])
n = n + 1
if n == 1 and sound then chain.flush(sound) end
end
end
end
-- the synthetic stand-in line a reward with no `dialogue` shows
function chain.line(str, sound)
table.insert(chain.pending, str)
if sound then chain.flush(sound) end
end
function chain.push(done)
chain.flush()
local function step(i)
local box = chain.boxes[i]
if not box then
if done then done() end
return
end
local opts = box.sound and TextBox.soundOpts(Game, box.sound) or nil
Game.stack:push(TextBox.new(Game, box.text,
function() step(i + 1) end, opts))
end
step(1)
end
return chain
end
-- Badges/items awarded after specific battles (data/scripts/victories.lua).
-- `deactivate` retires unfought gym/dojo trainers the way the originals'
-- SetEvent / SetEventRange do after the leader victory.
@@ -3218,44 +3411,31 @@ function OverworldState:checkVictoryRewards(trainerClass, partyIndex)
-- retries the hand-over later (offerGymTm via gyms.lua)
tmGiven = giveVictoryItem(reward)
end
local lines = {}
local chain = rewardChain()
if reward.dialogue then
local text = Game.data.text or {}
for _, label in ipairs(reward.dialogue) do
if text[label] and text[label] ~= "" then
table.insert(lines, text[label])
end
end
chain.add(reward.dialogue, reward.badgeSound)
if reward.item then
for _, label in ipairs(reward.tmPre or {}) do
if text[label] and text[label] ~= "" then
table.insert(lines, text[label])
end
end
chain.add(reward.tmPre)
if tmGiven then
for _, label in ipairs(reward.tmDialogue or {}) do
if text[label] and text[label] ~= "" then
table.insert(lines, text[label])
end
end
elseif reward.noRoom and text[reward.noRoom] and text[reward.noRoom] ~= "" then
table.insert(lines, text[reward.noRoom])
chain.add(reward.tmDialogue, reward.tmSound)
else
chain.add({ reward.noRoom })
end
end
elseif reward.badge or reward.item then
if reward.badge then
local name = Game.data.items[reward.badge] and Game.data.items[reward.badge].name
or reward.badge
table.insert(lines, Strings("%s received\nthe %s!", Game.save.player.name, name))
chain.line(Strings("%s received\nthe %s!", Game.save.player.name, name),
reward.badgeSound)
end
if tmGiven then
local name = Game.stringBuffer or reward.item
table.insert(lines, Strings("%s received\n%s!", Game.save.player.name, name))
chain.line(Strings("%s received\n%s!", Game.save.player.name, name),
reward.tmSound)
end
end
if #lines > 0 then
Game.stack:push(TextBox.new(Game, table.concat(lines, "\f")))
end
chain.push()
self:runVictoryHook()
end
@@ -3266,24 +3446,14 @@ end
-- show again, then the same GiveItem check decides between the received
-- lines and the "make room" text.
function OverworldState:offerGymTm(reward, done)
local text = Game.data.text or {}
local lines = {}
local function addLine(label)
if label and text[label] and text[label] ~= "" then
table.insert(lines, text[label])
end
end
for _, label in ipairs(reward.tmPre or {}) do addLine(label) end
local chain = rewardChain()
chain.add(reward.tmPre)
if giveVictoryItem(reward) then
for _, label in ipairs(reward.tmDialogue or {}) do addLine(label) end
chain.add(reward.tmDialogue, reward.tmSound)
else
addLine(reward.noRoom)
end
if #lines > 0 then
Game.stack:push(TextBox.new(Game, table.concat(lines, "\f"), done))
elseif done then
done()
chain.add({ reward.noRoom })
end
chain.push(done)
end
-- pokered reloads the map after every battle, re-running the map
@@ -3331,11 +3501,18 @@ function OverworldState:checkTrainerSight()
if self.player.moving or self.engaging then return end
if Game.stack:top() ~= self then return end
local p = self.player
local cancelled = self.cancelledTrainerSight
if cancelled and (cancelled.playerX ~= p.cellX
or cancelled.playerY ~= p.cellY) then
self.cancelledTrainerSight = nil
cancelled = nil
end
for _, npc in ipairs(self.npcs) do
local d = npc.def
-- CheckFightingMapTrainers engages ANY aligned trainer sprite,
-- walkers included (they sight between steps)
if d.trainerClass and not npc.moving
and not (cancelled and cancelled.npcId == npc.id)
and not self:trainerDefeated(npc)
and not mapScripts.talkScript(self.map.id, d.text)
and trainerSpriteOnScreen(npc, p) then
@@ -3661,8 +3838,9 @@ function OverworldState:onStepComplete()
end
end
-- wild encounters in grass, on water while surfing, or -- on indoor
-- maps whose tileset is not FOREST -- on EVERY tile
-- wild encounters in grass, on water while surfing (at the map's water
-- rate, which is 0 on every indoor map), or -- on indoor maps whose
-- tileset is not FOREST -- on every other tile
-- (wild_encounters.asm: caves, towers, the Mansion, Power Plant)
-- The cooldown is checked after all other step processing so repel and
-- movement systems continue to advance during the protected steps.
@@ -3670,10 +3848,10 @@ function OverworldState:onStepComplete()
local encDef = Game.data.encounters[self.map.id]
local enc
local indoor = Game.data.field.indoorEncounters
if p.surfing and encDef and encDef.water and self.map:isWaterCell(p.cellX, p.cellY) then
enc = self:rollEncounter({ grass = encDef.water }, "water")
elseif self.map:isGrassCell(p.cellX, p.cellY) then
if self.map:isGrassCell(p.cellX, p.cellY) then
enc = self:rollEncounter(encDef, "grass")
elseif p.surfing and self.map:isWaterCell(p.cellX, p.cellY) then
enc = self:rollEncounter({ grass = encDef and encDef.water }, "water")
elseif indoor and self.map.def.index >= indoor.firstIndoorMap
and self.map.def.tileset ~= indoor.excludedTileset then
enc = self:rollEncounter(encDef, "indoor")
@@ -3794,10 +3972,10 @@ function OverworldState:checkBadgeGate()
if Game.save.inventory[g.badge] then
if not Game.save.flags[passedFlag] then
Game.save.flags[passedFlag] = true
-- Route22GateGuardGoRightAheadText plays sound_get_item_1
require("src.core.Sound").play(Game.data, "Get_Item1")
-- Route22GateGuardGoRightAheadText carries sound_get_item_1
Game.stack:push(TextBox.new(Game,
t["_" .. g.passText] or Strings("Go right ahead!")))
t["_" .. g.passText] or Strings("Go right ahead!"),
nil, TextBox.soundOpts(Game, "Get_Item1")))
end
return false
end
@@ -3822,11 +4000,11 @@ function OverworldState:checkBadgeGate()
and Game.data.items[guard.badge].name or guard.badge
if Game.save.inventory[guard.badge] then
Game.save.flags[guard.event] = true
-- Route23OhThatIsTheBadgeText plays sound_get_item_1
require("src.core.Sound").play(Game.data, "Get_Item1")
-- Route23OhThatIsTheBadgeText carries sound_get_item_1
local text = (t["_" .. g.passText] or
Strings("Oh! That is the\n{RAM}!")):gsub("{RAM:wNameBuffer}", badgeName)
Game.stack:push(TextBox.new(Game, text))
Game.stack:push(TextBox.new(Game, text,
nil, TextBox.soundOpts(Game, "Get_Item1")))
return false
end
-- Route23YouDontHaveTheBadgeYetText plays SFX_DENIED
@@ -4470,7 +4648,7 @@ function OverworldState:scriptMove(entity, dir, tiles, onDone)
})
end
-- A step-in-place beat: the entity plays one walk-cycle animation (16
-- A step-in-place beat: the entity plays one walk-cycle animation (32
-- frames) without translating, keeping its current facing. Ports the
-- NPC_CHANGE_FACING movement byte (engine/overworld/movement.asm
-- ChangeFacingDirection -> zero-delta TryWalking), used for Oak marching
@@ -4483,7 +4661,7 @@ end
-- Advance scripted moves in two phases so a chained step (a new move
-- queued by a completing move's onDone) begins the SAME frame the
-- previous one ends -- back-to-back 16-frame tiles like the GB's
-- previous one ends -- back-to-back 32-frame tiles like the GB's
-- simulated-joypad / NPC scripted movement, with no idle frame between
-- tiles. Phase 1 retires finished moves (which may chain new ones);
-- phase 2 then starts every not-yet-moving move.
+10 -1
View File
@@ -821,11 +821,13 @@ end
function PikachuFollower.onFanClubEntered(game, ow)
if not (GameVersion.isYellow() and ow.map
and ow.map.id == "POKEMON_FAN_CLUB") then return end
local active = game.save.pikachuMapScriptActive
game.save.pikachuMapScriptActive = true
if active then return end
local starter = PikachuFollower.starterInParty(game.save)
local npc = findFollower(ow)
if not npc or (starter and starter.status) then return end
ow.pikachuFanClubScene = true
ow.pikachuMapScriptActive = true
ow.player.facing = "down"
for _, other in ipairs(ow.npcs or {}) do
if other.def and other.def.name == "POKEMONFANCLUB_SEEL" then
@@ -844,6 +846,13 @@ function PikachuFollower.onBillsHouseEnter(game, ow)
if not (GameVersion.isYellow() and ow.map and ow.map.id == "BILLS_HOUSE") then
return
end
-- BillsHouse_CheckMetBill (scripts/BillsHouse.asm:22-40) sets
-- BIT_PIKACHU_MAP_SCRIPT_ACTIVE and rets nz before it looks at
-- EVENT_MET_BILL_2; the bit rides sMainData, so a reload inside the house
-- resumes at BillsHouseScript1's bare ret (#919).
local active = game.save.pikachuMapScriptActive
game.save.pikachuMapScriptActive = true
if active then return end
if game.save.flags.EVENT_MET_BILL_2 then return end
-- BillsHouseScript0 (scripts/BillsHouse.asm:41-47) only runs the confused
-- walk while CheckPikachuStatusCondition comes back clear

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