Compare commits

..

61 Commits

Author SHA1 Message Date
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
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
bryanthaboi fb738fa1ce path read 2026-08-14 17:51:55 -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
159 changed files with 11434 additions and 2710 deletions
+47 -1
View File
@@ -252,8 +252,40 @@ jobs:
Remove-Item $env:UWP_PFX -Force -ErrorAction SilentlyContinue 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: 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"') }} runs-on: ${{ fromJSON(github.repository == 'bryanthaboi/gen1recomp' && '["self-hosted", "macOS"]' || '"macos-latest"') }}
steps: steps:
@@ -269,6 +301,12 @@ jobs:
fetch-depth: 0 fetch-depth: 0
fetch-tags: true 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 - name: Import signing certificate into a temporary keychain
if: github.repository == 'bryanthaboi/gen1recomp' if: github.repository == 'bryanthaboi/gen1recomp'
run: | run: |
@@ -307,13 +345,21 @@ jobs:
security find-identity -v -p codesigning "$KEYCHAIN_PATH" security find-identity -v -p codesigning "$KEYCHAIN_PATH"
- name: Build macOS + Windows + Linux - name: Build macOS + Windows + Linux
env:
GEN1TLS_DLL: ${{ github.workspace }}/dist/native/win-x64/gen1tls.dll
run: | run: |
set -euo pipefail set -euo pipefail
# Sign in-build (identity auto-detected from the temp keychain); # Sign in-build (identity auto-detected from the temp keychain);
# notarize separately below so it uses secret credentials, not a # notarize separately below so it uses secret credentials, not a
# login-keychain profile. "all" also builds the Linux AppImage, # login-keychain profile. "all" also builds the Linux AppImage,
# which needs no signing/notarization. # 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 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 - name: Build Android
run: | run: |
+11 -1
View File
@@ -3,8 +3,9 @@
data/generated/ data/generated/
assets/generated/ assets/generated/
# LÖVE packages # LÖVE packages & archives
*.love *.love
*.zip
# Local saves (LÖVE writes to its save dir, but keep the repo clean anyway) # Local saves (LÖVE writes to its save dir, but keep the repo clean anyway)
save/ save/
@@ -75,3 +76,12 @@ mobile/ios/bundle_id.local
/dist/native/ /dist/native/
/dist/win/ /dist/win/
/.bazinga/ /.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/
+48 -3
View File
@@ -242,10 +242,55 @@ with a `MK4xx` finding per site and an `unresolved:` note, with a file and a
line, for every reach a static scan could not follow. Neither substitutes for a line, for every reach a static scan could not follow. Neither substitutes for a
real Gold boot. real Gold boot.
### 5. `mod.card` ### 5. What a mod's code can reach
Your code runs in a sandbox (`src/mods/Sandbox.lua`), not against the
engine's globals. Every chunk you author gets it: `main.lua`, your
`options_schema`, and anything you `load()` yourself. What is absent:
| Absent | Use instead |
| --- | --- |
| `io`, and `require("io")` | `mod:read` for your own files, `mod.storage` to persist |
| `os.getenv`, `os.execute`, `os.remove`, `os.rename`, `os.exit` | nothing; `os.time`/`os.date`/`os.clock` still work |
| `package`, `dofile`, `loadfile`, `debug`, `getfenv`, `setfenv` | `require` for the supported engine modules |
| `require("ffi")`, `require("love.*")` | the `love` table you are given |
| `love.filesystem` | `mod.storage` (per-mod, per-playthrough), `mod:read` for a known file, `mod:list` / `mod:info` to iterate your own directory |
| `love.thread`, `love.event` | `mod.events`, `mod.hooks` |
| `love.system` | `mod.device:powerInfo()` for battery information; `mod.steps` (with the `steps` permission) for the step bridge |
The rest of `love` passes through unchanged, so graphics, audio, timers and
input work as they always have.
Three consequences worth knowing before you write against it:
- **Your globals are yours.** `_G` inside a mod is that mod's own table. Two
mods no longer share a namespace, and neither can reach the engine's. To
publish something to another mod, put it on `mod.exports` and let them
`mod.find("your_id").exports` — the channel that was always the intended
one. The same goes for the standard library: `string`, `table` and `math`
are per-mod copies, so patching one is a local decision.
- **Paths cannot climb.** `mod:read`, `mod:list`, `mod:info`, `mod.assets:path`
and `mod.assets:image` join to your own directory, and `..`, absolute paths
and drive letters are refused. So are `entry` and `options_schema` in your
manifest. `mod:list("assets")` is the sandboxed `getDirectoryItems` for a
folder you shipped; `mod:info` tells file from directory so a walk can
recurse.
- **Ship source, not bytecode.** A precompiled entry file is refused.
`permissions` in the manifest is still a disclosure the manager shows the
player, and `network` now gates `require("socket")` and friends. There is no
permission that grants raw filesystem access, because no mod needs one:
everything a mod legitimately writes is already scoped by
`mod.storage` or the asset-transform derived root.
If your mod used one of the absent globals, the fix is almost always
`mod.storage`. Open an issue if you have a case it does not cover.
### 6. `mod.card`
The manifest is the *engine's* contract: identity, load order, dependencies, 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 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 merge — only by tooling and the manager's detail pane — so an absent or
malformed card can never break a load. malformed card can never break a load.
@@ -263,7 +308,7 @@ Two fields deserve their own note:
distributed mod never carries ROM-derived bytes, not even in its preview distributed mod never carries ROM-derived bytes, not even in its preview
images. images.
### 6. Tags ### 7. Tags
Lowercase kebab strings, open vocabulary. The showcase generator Lowercase kebab strings, open vocabulary. The showcase generator
lowercases and de-dupes. A recommended starting set: `beginner`, lowercases and de-dupes. A recommended starting set: `beginner`,
+1 -1
View File
@@ -92,7 +92,7 @@ return {
{ "set_flag", "EVENT_GOT_EEVEE" }, -- 7 { "set_flag", "EVENT_GOT_EEVEE" }, -- 7
{ "hide_object", "CELADON_MANSION_ROOF_HOUSE", { "hide_object", "CELADON_MANSION_ROOF_HOUSE",
"CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" }, -- 8 "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 { "show_text", "_GotMonText", { RAM = "EEVEE" } }, -- 10
{ "jump", 13 }, -- 11 { "jump", 13 }, -- 11
{ "show_text", "_BoxIsFullText" }, -- 12 { "show_text", "_BoxIsFullText" }, -- 12
+6 -5
View File
@@ -10,10 +10,10 @@
local function coinGiver(opts) local function coinGiver(opts)
return function(game, ow, npc, done) return function(game, ow, npc, done)
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
local Sound = require("src.core.Sound")
local t = game.data.text local t = game.data.text
local function push(label, fallback, onDone) local function push(label, fallback, onDone, popts)
game.stack:push(TextBox.new(game, t[label] or fallback, onDone or done)) game.stack:push(TextBox.new(game, t[label] or fallback, onDone or done,
popts))
end end
if game.save.flags[opts.event] then if game.save.flags[opts.event] then
push(opts.alreadyGotLabel, opts.alreadyGotFallback) push(opts.alreadyGotLabel, opts.alreadyGotFallback)
@@ -30,9 +30,10 @@ local function coinGiver(opts)
end end
game.save.coins = math.min(9999, (game.save.coins or 0) + opts.amount) game.save.coins = math.min(9999, (game.save.coins or 0) + opts.amount)
game.save.flags[opts.event] = true game.save.flags[opts.event] = true
Sound.play(game.data, "Get_Item1") -- the ReceivedNCoinsText strings carry sound_get_item_1
push(opts.receivedLabel, 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 end
end end
+5 -4
View File
@@ -22,8 +22,8 @@ return {
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
local Commands = require("src.script.Commands") local Commands = require("src.script.Commands")
local t = game.data.text local t = game.data.text
local function say(label, cb) local function say(label, cb, sopts)
game.stack:push(TextBox.new(game, t[label] or label, cb)) game.stack:push(TextBox.new(game, t[label] or label, cb, sopts))
end end
if game.save.flags.EVENT_GOT_OLD_AMBER then if game.save.flags.EVENT_GOT_OLD_AMBER then
@@ -39,8 +39,9 @@ return {
game.save.flags.EVENT_GOT_OLD_AMBER = true game.save.flags.EVENT_GOT_OLD_AMBER = true
Commands.hide_object({ save = game.save, overworld = ow, game = game }, Commands.hide_object({ save = game.save, overworld = ow, game = game },
"MUSEUM_1F", "MUSEUM1F_OLD_AMBER") "MUSEUM_1F", "MUSEUM1F_OLD_AMBER")
require("src.core.Sound").play(game.data, "Get_Item1") -- .ReceivedOldAmberText carries sound_get_item_1
say("_Museum1FScientist2ReceivedOldAmberText", done) say("_Museum1FScientist2ReceivedOldAmberText", done,
TextBox.soundOpts(game, "Get_Item1"))
end) end)
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 -- inside give_pokemon). Show the received text first so the
-- nickname prompt follows "you got X", matching Gen1. -- nickname prompt follows "you got X", matching Gen1.
-- The received text carries sound_get_key_item (OaksLab.asm -- The received text carries sound_get_key_item (OaksLab.asm
-- OaksLabReceivedMonText); the jingle plays as the box opens -- OaksLabReceivedMonText), so the jingle fires once the box has
-- (same beat as the Yellow port's starter, #668). -- typed out and holds it (same beat as the Yellow port's starter, #668).
{ "play_sound", "Get_Key_Item" }, -- 8 { "text_sound", "Get_Key_Item" }, -- 8
{ "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 9 { "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 9
{ "give_pokemon", species, 5 }, -- 10 { "give_pokemon", species, 5 }, -- 10
{ "set_flag", "EVENT_GOT_STARTER" }, -- 11 { "set_flag", "EVENT_GOT_STARTER" }, -- 11
@@ -54,7 +54,7 @@ local function starterBall(askText, species, choseFlag, ownBall,
{ "face_object", 1, "up" }, -- 15 { "face_object", 1, "up" }, -- 15
{ "show_text", "_OaksLabRivalIllTakeThisOneText" }, -- 16 { "show_text", "_OaksLabRivalIllTakeThisOneText" }, -- 16
{ "hide_object", "OAKS_LAB", rivalBall }, -- 17 { "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", { "show_text", "_OaksLabRivalReceivedMonText",
{ RAM = rivalBall == "OAKSLAB_CHARMANDER_POKE_BALL" and "CHARMANDER" { RAM = rivalBall == "OAKSLAB_CHARMANDER_POKE_BALL" and "CHARMANDER"
or rivalBall == "OAKSLAB_SQUIRTLE_POKE_BALL" and "SQUIRTLE" or rivalBall == "OAKSLAB_SQUIRTLE_POKE_BALL" and "SQUIRTLE"
@@ -106,8 +106,8 @@ return {
{ "check_item", "OAKS_PARCEL" }, { "check_item", "OAKS_PARCEL" },
{ "jump_if_false", "raise_young" }, { "jump_if_false", "raise_young" },
-- OaksLabOak1Text.got_parcel → RivalArrives + OakGivesPokedex -- OaksLabOak1Text.got_parcel → RivalArrives + OakGivesPokedex
{ "text_sound", "Get_Key_Item" },
{ "show_text", "_OaksLabOak1DeliverParcelText" }, { "show_text", "_OaksLabOak1DeliverParcelText" },
{ "play_sound", "Get_Key_Item" },
{ "show_text", "_OaksLabOak1ParcelThanksText" }, { "show_text", "_OaksLabOak1ParcelThanksText" },
{ "take_item", "OAKS_PARCEL", 1 }, { "take_item", "OAKS_PARCEL", 1 },
{ "stop_music" }, { "stop_music" },
@@ -128,8 +128,8 @@ return {
{ "face_object", 1, "up" }, { "face_object", 1, "up" },
{ "face_object", 5, "down" }, { "face_object", 5, "down" },
{ "show_text", "_OaksLabOakMyInventionPokedexText" }, { "show_text", "_OaksLabOakMyInventionPokedexText" },
{ "text_sound", "Get_Key_Item" },
{ "show_text", "_OaksLabOakGotPokedexText" }, { "show_text", "_OaksLabOakGotPokedexText" },
{ "play_sound", "Get_Key_Item" },
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" }, { "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" },
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" }, { "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" },
{ "face_object", 1, "up" }, { "face_object", 1, "up" },
+5 -5
View File
@@ -38,8 +38,8 @@ return {
{ "jump_if_false", "raise_young" }, { "jump_if_false", "raise_young" },
-- .DeliverParcelText: parcel handover, then the Pokédex scene -- .DeliverParcelText: parcel handover, then the Pokédex scene
-- (OaksLabRivalArrivesAtOaksRequestScript -> OakGivesPokedexScript) -- (OaksLabRivalArrivesAtOaksRequestScript -> OakGivesPokedexScript)
{ "text_sound", "Get_Key_Item" },
{ "show_text", "_OaksLabOak1DeliverParcelText" }, { "show_text", "_OaksLabOak1DeliverParcelText" },
{ "play_sound", "Get_Key_Item" },
{ "show_text", "_OaksLabOak1ParcelThanksText" }, { "show_text", "_OaksLabOak1ParcelThanksText" },
{ "take_item", "OAKS_PARCEL", 1 }, { "take_item", "OAKS_PARCEL", 1 },
{ "stop_music" }, { "stop_music" },
@@ -62,8 +62,8 @@ return {
{ "face_object", RIVAL, "up" }, { "face_object", RIVAL, "up" },
{ "face_object", OAK1, "down" }, { "face_object", OAK1, "down" },
{ "show_text", "_OaksLabOakMyInventionPokedexText" }, { "show_text", "_OaksLabOakMyInventionPokedexText" },
{ "text_sound", "Get_Key_Item" },
{ "show_text", "_OaksLabOakGotPokedexText" }, { "show_text", "_OaksLabOakGotPokedexText" },
{ "play_sound", "Get_Key_Item" },
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" }, { "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" },
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" }, { "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" },
{ "face_object", RIVAL, "up" }, { "face_object", RIVAL, "up" },
@@ -113,8 +113,8 @@ return {
{ "jump_if_true", "come_see" }, { "jump_if_true", "come_see" },
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, { "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" },
{ "give_item", "POKE_BALL", 5, false }, { "give_item", "POKE_BALL", 5, false },
{ "text_sound", "Get_Key_Item" },
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" }, { "show_text", "_OaksLabOak1ReceivedPokeballsText" },
{ "play_sound", "Get_Key_Item" },
{ "show_text", "_OaksLabGivePokeballsExplanationText" }, { "show_text", "_OaksLabGivePokeballsExplanationText" },
{ "jump", "end" }, { "jump", "end" },
@@ -176,7 +176,7 @@ return {
-- rival starter baseline (RIVAL_STARTER_JOLTEON) at snatch time -- rival starter baseline (RIVAL_STARTER_JOLTEON) at snatch time
rows[#rows + 1] = { "set_field", "rivalStarter", 1 } rows[#rows + 1] = { "set_field", "rivalStarter", 1 }
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText1" } 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", "_OaksLabRivalTakesText2" }
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText3" } rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText3" }
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText4" } rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText4" }
@@ -196,7 +196,7 @@ return {
rows[#rows + 1] = { "face_object", OAK1, "down" } rows[#rows + 1] = { "face_object", OAK1, "down" }
-- OaksLabPlayerReceivedMonText clears wMonDataLocation, so AskName runs (#1013) -- OaksLabPlayerReceivedMonText clears wMonDataLocation, so AskName runs (#1013)
rows[#rows + 1] = { "show_text", "_OaksLabOakGivesText" } 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] = { "show_text", "_OaksLabReceivedText", { RAM = "PIKACHU" } }
rows[#rows + 1] = { "give_pokemon", "PIKACHU", 5 } rows[#rows + 1] = { "give_pokemon", "PIKACHU", 5 }
-- DisablePikachuOverworldSpriteDrawing keeps it in the ball (#1009) -- DisablePikachuOverworldSpriteDrawing keeps it in the ball (#1009)
+2
View File
@@ -297,6 +297,7 @@ M.BILLS_HOUSE = {
M.ROUTE_25 = { M.ROUTE_25 = {
onEnter = function(game, ow) onEnter = function(game, ow)
game.save.pikachuMapScriptActive = nil
local flags = game.save.flags local flags = game.save.flags
if flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING then return end if flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING then return end
local Commands = require("src.script.Commands") 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 -- only read while EVENT_1ST_LOCK_OPENED is unset (the gym is only
-- reachable through this map, so a fresh visit always re-rolls). -- reachable through this map, so a fresh visit always re-rolls).
onEnter = function(game, ow) onEnter = function(game, ow)
game.save.pikachuMapScriptActive = nil
local puz = game.save.trashPuzzle or {} local puz = game.save.trashPuzzle or {}
game.save.trashPuzzle = puz game.save.trashPuzzle = puz
puz.first = love.math.random(0, 7) * 2 puz.first = love.math.random(0, 7) * 2
+13 -5
View File
@@ -178,7 +178,8 @@ M.PALLET_TOWN = {
end end
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.hide_object(ctx, "PALLET_TOWN", "PALLETTOWN_OAK")
Commands.show_object(ctx, "OAKS_LAB", "OAKSLAB_OAK2") Commands.show_object(ctx, "OAKS_LAB", "OAKSLAB_OAK2")
ow.doorWarp = true ow.doorWarp = true
@@ -187,12 +188,17 @@ M.PALLET_TOWN = {
end end
local function walkToLab(oak) 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 local i = 0
if oak then
oak.stepFrames = ow.player.stepFramesCur or ow.player.stepFrames
end
local function tick() local function tick()
i = i + 1 i = i + 1
local playerStep = escort.playerSteps[i] local playerStep = escort.playerSteps[i]
if not playerStep then if not playerStep then
enterLab() enterLab(oak)
return return
end end
if oak and escort.oakSteps[i] then if oak and escort.oakSteps[i] then
@@ -648,8 +654,10 @@ local function mtMoonFossil(itemId, otherName, gotFlag)
end end
local idef = game.data.items[itemId] local idef = game.data.items[itemId]
game.stringBuffer = idef and idef.name or 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) 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, game.stack:push(TextBox.new(game,
t._MtMoonB2FReceivedFossilText t._MtMoonB2FReceivedFossilText
or ("{PLAYER} got the\n" .. game.stringBuffer .. "!"), or ("{PLAYER} got the\n" .. game.stringBuffer .. "!"),
@@ -662,11 +670,11 @@ local function mtMoonFossil(itemId, otherName, gotFlag)
ow.runner:run({ ow.runner:run({
{ "walk_npc", 1, dirs }, { "walk_npc", 1, dirs },
{ "text_opts", { auto = true } }, { "text_opts", { auto = true } },
{ "text_sound", "Get_Key_Item" },
{ "show_text", "_MtMoonB2FSuperNerdThenThisIsMineText" }, { "show_text", "_MtMoonB2FSuperNerdThenThisIsMineText" },
{ "play_sound", "Get_Key_Item" },
{ "hide_object", "MT_MOON_B2F", otherName }, { "hide_object", "MT_MOON_B2F", otherName },
}, { onDone = done }) }, { onDone = done })
end)) end, TextBox.soundOpts(game, "Get_Key_Item")))
end })) end }))
end end
end end
+7 -11
View File
@@ -8,9 +8,9 @@ local M = {}
local function text(game) return game.data.text end local function text(game) return game.data.text end
local function push(game, s, done) local function push(game, s, done, opts)
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game, s, done)) game.stack:push(TextBox.new(game, s, done, opts))
end end
-- The question stays on screen under the YES/NO menu. The dojo prize -- 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" }, { "jump_if_false", "box_full" },
-- flag ahead of the jingle, like the Celadon EEVEE (#426) -- flag ahead of the jingle, like the Celadon EEVEE (#426)
{ "set_flag", "EVENT_GOT_LAPRAS" }, { "set_flag", "EVENT_GOT_LAPRAS" },
{ "play_sound", "Get_Item1" }, { "text_sound", "Get_Item1" },
{ "show_text", "_GotMonText", { RAM = "LAPRAS" } }, { "show_text", "_GotMonText", { RAM = "LAPRAS" } },
{ "show_text", "_SilphCo7FSilphWorkerM1LaprasDescriptionText" }, { "show_text", "_SilphCo7FSilphWorkerM1LaprasDescriptionText" },
{ "jump", "end" }, { "jump", "end" },
@@ -307,12 +307,11 @@ M.COPYCATS_HOUSE_2F = {
return return
end end
game.stringBuffer = game.data.items.TM_MIMIC.name game.stringBuffer = game.data.items.TM_MIMIC.name
require("src.core.Sound").play(game.data, "Get_Item1")
Bag.remove(game.save, "POKE_DOLL", 1) Bag.remove(game.save, "POKE_DOLL", 1)
game.save.flags.EVENT_GOT_TM31 = true game.save.flags.EVENT_GOT_TM31 = true
push(game, t._CopycatsHouse2FCopycatReceivedTM31Text, function() push(game, t._CopycatsHouse2FCopycatReceivedTM31Text, function()
push(game, t._CopycatsHouse2FCopycatTM31Explanation1Text, done) push(game, t._CopycatsHouse2FCopycatTM31Explanation1Text, done)
end) end, require("src.render.TextBox").soundOpts(game, "Get_Item1"))
end) end)
end) end)
end, end,
@@ -478,7 +477,6 @@ M.CELADON_MART_ROOF = {
return return
end end
game.save.flags[g.flag] = true game.save.flags[g.flag] = true
require("src.core.Sound").play(game.data, "Get_Item1")
local subs = { player = game.save.player.name, local subs = { player = game.save.player.name,
ram = game.data.items[g.tm].name } ram = game.data.items[g.tm].name }
local explain = fill(t[g.explain] or "", subs) local explain = fill(t[g.explain] or "", subs)
@@ -490,7 +488,7 @@ M.CELADON_MART_ROOF = {
else else
done() done()
end end
end) end, require("src.render.TextBox").soundOpts(game, "Get_Item1"))
end) end)
end, end,
onCancel = done, onCancel = done,
@@ -522,7 +520,6 @@ M.ROUTE_24 = {
local t = text(game) local t = text(game)
push(game, t._Route24CooltrainerM1YouBeatOurContestText .. "\f" push(game, t._Route24CooltrainerM1YouBeatOurContestText .. "\f"
.. t._Route24CooltrainerM1YouJustEarnedAPrizeText, function() .. t._Route24CooltrainerM1YouJustEarnedAPrizeText, function()
require("src.core.Sound").play(game.data, "Get_Item1")
if not require("src.inventory.Bag").add(game.save, "NUGGET", 1, if not require("src.inventory.Bag").add(game.save, "NUGGET", 1,
game.data) then game.data) then
push(game, t._Route24CooltrainerM1NoRoomText, done) push(game, t._Route24CooltrainerM1NoRoomText, done)
@@ -531,11 +528,10 @@ M.ROUTE_24 = {
flags.EVENT_GOT_NUGGET = true flags.EVENT_GOT_NUGGET = true
game.stringBuffer = game.data.items.NUGGET.name game.stringBuffer = game.data.items.NUGGET.name
push(game, t._Route24CooltrainerM1ReceivedNuggetText, function() push(game, t._Route24CooltrainerM1ReceivedNuggetText, function()
require("src.core.Sound").play(game.data, "Get_Item1")
push(game, t._Route24CooltrainerM1JoinTeamRocketText, push(game, t._Route24CooltrainerM1JoinTeamRocketText,
battleOrDone) battleOrDone)
end) end, require("src.render.TextBox").soundOpts(game, "Get_Item1"))
end) end, require("src.render.TextBox").soundOpts(game, "Get_Item1"))
return return
end end
battleOrDone() battleOrDone()
+14 -8
View File
@@ -4,9 +4,9 @@ local M = {}
local function text(game) return game.data.text end local function text(game) return game.data.text end
local function push(game, s, done) local function push(game, s, done, opts)
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game, s, done)) game.stack:push(TextBox.new(game, s, done, opts))
end end
-- fill the extracted text placeholders ({RAM:...}, {PLAYER}) -- fill the extracted text placeholders ({RAM:...}, {PLAYER})
@@ -25,8 +25,8 @@ local function gift(opts)
local t = text(game) local t = text(game)
local itemName = game.data.items[opts.item].name local itemName = game.data.items[opts.item].name
local subs = { ram = itemName, player = game.save.player.name } local subs = { ram = itemName, player = game.save.player.name }
local function say(label, fallback, cb) local function say(label, fallback, cb, sopts)
push(game, fill(t[label] or fallback, subs), cb) push(game, fill(t[label] or fallback, subs), cb, sopts)
end end
if game.save.flags[opts.flag] then if game.save.flags[opts.flag] then
say(opts.already or opts.explain, "It's a useful\nitem, isn't it?", done) say(opts.already or opts.explain, "It's a useful\nitem, isn't it?", done)
@@ -39,15 +39,16 @@ local function gift(opts)
end end
game.save.flags[opts.flag] = true game.save.flags[opts.flag] = true
local idef = game.data.items[opts.item] local idef = game.data.items[opts.item]
require("src.core.Sound").play(game.data, -- the received texts carry sound_get_item_1 / sound_get_key_item, so
(idef and idef.keyItem) and "Get_Key_Item" or "Get_Item1") -- the jingle only fires once that box has typed out
say(opts.received, "{PLAYER} received\n{RAM:}!", function() say(opts.received, "{PLAYER} received\n{RAM:}!", function()
if opts.explain then if opts.explain then
say(opts.explain, "", done) say(opts.explain, "", done)
else else
done() done()
end end
end) end, require("src.render.TextBox").soundOpts(game,
(idef and idef.keyItem) and "Get_Key_Item" or "Get_Item1"))
end end
if opts.pre then say(opts.pre, opts.preFallback or "", give) else give() end if opts.pre then say(opts.pre, opts.preFallback or "", give) else give() end
end end
@@ -445,7 +446,7 @@ local function pewterGymEscort(game, ow)
end end
local function afterWalk() 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") Music.playMap(game.data, "PEWTER_CITY")
push(game, t._PewterCityYoungsterGoTakeOnBrockText push(game, t._PewterCityYoungsterGoTakeOnBrockText
or "Go take on BROCK\nat the GYM first!", walkHome) or "Go take on BROCK\nat the GYM first!", walkHome)
@@ -468,6 +469,11 @@ local function pewterGymEscort(game, ow)
end end
local function beginWalk() 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") Music.play(game.data, "Music_MuseumGuy")
if guy and head > 0 then if guy and head > 0 then
local h = 0 local h = 0
+21
View File
@@ -28,6 +28,14 @@
-- is full; `gotFlag` (pokered's EVENT_GOT_TM*) is set only on a -- 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 -- successful give, which is what makes the leader's talk script retry
-- later (gyms.lua). -- 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 function range(prefix, first, last)
local t = {} local t = {}
@@ -50,6 +58,8 @@ return {
{ "PEWTER_CITY", "PEWTERCITY_YOUNGSTER" }, { "PEWTER_CITY", "PEWTERCITY_YOUNGSTER" },
{ "ROUTE_22", "ROUTE22_RIVAL1" }, { "ROUTE_22", "ROUTE22_RIVAL1" },
}, },
badgeSound = "Get_Item1", -- sound_level_up
tmSound = "Get_Item1",
dialogue = { dialogue = {
"_PewterGymBrockReceivedBoulderBadgeText", "_PewterGymBrockReceivedBoulderBadgeText",
"_PewterGymBrockBoulderBadgeInfoText", "_PewterGymBrockBoulderBadgeInfoText",
@@ -64,6 +74,8 @@ return {
gotFlag = "EVENT_GOT_TM11", gotFlag = "EVENT_GOT_TM11",
noRoom = "_CeruleanGymMistyTM11NoRoomText", noRoom = "_CeruleanGymMistyTM11NoRoomText",
deactivate = range("EVENT_BEAT_CERULEAN_GYM_TRAINER_", 0, 1), deactivate = range("EVENT_BEAT_CERULEAN_GYM_TRAINER_", 0, 1),
badgeSound = "Get_Key_Item",
tmSound = "Get_Item1",
dialogue = { dialogue = {
"_CeruleanGymMistyReceivedCascadeBadgeText", "_CeruleanGymMistyReceivedCascadeBadgeText",
}, },
@@ -76,6 +88,7 @@ return {
gotFlag = "EVENT_GOT_TM24", gotFlag = "EVENT_GOT_TM24",
noRoom = "_VermilionGymLTSurgeTM24NoRoomText", noRoom = "_VermilionGymLTSurgeTM24NoRoomText",
deactivate = range("EVENT_BEAT_VERMILION_GYM_TRAINER_", 0, 2), deactivate = range("EVENT_BEAT_VERMILION_GYM_TRAINER_", 0, 2),
tmSound = "Get_Key_Item",
dialogue = { dialogue = {
"_VermilionGymLTSurgeReceivedThunderBadgeText", "_VermilionGymLTSurgeReceivedThunderBadgeText",
}, },
@@ -89,6 +102,7 @@ return {
gotFlag = "EVENT_GOT_TM21", gotFlag = "EVENT_GOT_TM21",
noRoom = "_CeladonGymTM21NoRoomText", noRoom = "_CeladonGymTM21NoRoomText",
deactivate = range("EVENT_BEAT_CELADON_GYM_TRAINER_", 0, 6), deactivate = range("EVENT_BEAT_CELADON_GYM_TRAINER_", 0, 6),
tmSound = "Get_Item1",
dialogue = { dialogue = {
"_CeladonGymErikaReceivedRainbowBadgeText", "_CeladonGymErikaReceivedRainbowBadgeText",
}, },
@@ -102,6 +116,7 @@ return {
gotFlag = "EVENT_GOT_TM06", gotFlag = "EVENT_GOT_TM06",
noRoom = "_FuchsiaGymKogaTM06NoRoomText", noRoom = "_FuchsiaGymKogaTM06NoRoomText",
deactivate = range("EVENT_BEAT_FUCHSIA_GYM_TRAINER_", 0, 5), deactivate = range("EVENT_BEAT_FUCHSIA_GYM_TRAINER_", 0, 5),
tmSound = "Get_Key_Item",
dialogue = { dialogue = {
"_FuchsiaGymKogaReceivedSoulBadgeText", "_FuchsiaGymKogaReceivedSoulBadgeText",
}, },
@@ -115,6 +130,8 @@ return {
gotFlag = "EVENT_GOT_TM46", gotFlag = "EVENT_GOT_TM46",
noRoom = "_SaffronGymSabrinaTM46NoRoomText", noRoom = "_SaffronGymSabrinaTM46NoRoomText",
deactivate = range("EVENT_BEAT_SAFFRON_GYM_TRAINER_", 0, 6), deactivate = range("EVENT_BEAT_SAFFRON_GYM_TRAINER_", 0, 6),
badgeSound = "Get_Key_Item",
tmSound = "Get_Item1",
dialogue = { dialogue = {
"_SaffronGymSabrinaReceivedMarshBadgeText", "_SaffronGymSabrinaReceivedMarshBadgeText",
}, },
@@ -128,6 +145,8 @@ return {
gotFlag = "EVENT_GOT_TM38", gotFlag = "EVENT_GOT_TM38",
noRoom = "_CinnabarGymBlaineTM38NoRoomText", noRoom = "_CinnabarGymBlaineTM38NoRoomText",
deactivate = range("EVENT_BEAT_CINNABAR_GYM_TRAINER_", 0, 6), deactivate = range("EVENT_BEAT_CINNABAR_GYM_TRAINER_", 0, 6),
badgeSound = "Get_Key_Item",
tmSound = "Get_Item1",
dialogue = { dialogue = {
"_CinnabarGymBlaineReceivedVolcanoBadgeText", "_CinnabarGymBlaineReceivedVolcanoBadgeText",
}, },
@@ -141,6 +160,8 @@ return {
gotFlag = "EVENT_GOT_TM27", gotFlag = "EVENT_GOT_TM27",
noRoom = "_ViridianGymGiovanniTM27NoRoomText", noRoom = "_ViridianGymGiovanniTM27NoRoomText",
deactivate = range("EVENT_BEAT_VIRIDIAN_GYM_TRAINER_", 0, 7), deactivate = range("EVENT_BEAT_VIRIDIAN_GYM_TRAINER_", 0, 7),
badgeSound = "Get_Item1", -- sound_level_up
tmSound = "Get_Item1",
dialogue = { dialogue = {
"_ViridianGymGiovanniReceivedEarthBadgeText", "_ViridianGymGiovanniReceivedEarthBadgeText",
}, },
+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. generations if you want both.
Two riders. **A hard dependency that does not run here takes the dependent down 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 with it** (unless scoped to specific games, e.g.
wording (`depends on X, which does not run here (For Blue, not Red)`), so the `dependencies: [{ id = "x", games = ["gen2"] }]`), as a skip rather than a
whole chain has to cover the same games. And **the claim is yours, not the last failure and carrying the dependency's own wording (`depends on X, which does not
word**: it is the manager's `TRY HERE ANYWAY` row that lets a player run a mod 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 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 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 `{ [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 | | `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 | | `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 `notes` keys are documentation topics rather than a member list -- dotted paths
(`save.money`), field names (`warpAt`), hook names (`hook ui.pc.items`) and (`save.money`), field names (`warpAt`), hook names (`hook ui.pc.items`) and
bare topics (`identity`, `iteration`, `rawset`) all appear there. `members` is 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 (`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 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. 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 `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 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 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. **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 `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. 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 **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 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`, `pokemon.level_up`, `pokemon.move_learned`; hooks `battle.damage`,
`battle.crit`, `battle.accuracy`, `battle.turn_order`, `battle.crit`, `battle.accuracy`, `battle.turn_order`,
`battle.enemy_action`, `battle.run`, `battle.exp_award`, `exp.gain`, `battle.enemy_action`, `battle.run`, `battle.exp_award`, `exp.gain`,
`catch.rate`, `trainer.party`, `battle.overlay`, `battle.low_health_alarm` `catch.rate`, `trainer.party`, `battle.overlay`, `battle.low_health_alarm`,
and `battle.catch_exp`. One payload difference: Gen 1's vanilla `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 `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 screen has no `.data` field, so the Gen 2 site **adds** `ctx.data` beside the
Gen 1 keys. A mod that calls `nextFn` is unaffected; one that reaches through Gen 1 keys. A mod that calls `nextFn` is unaffected; one that reaches through
@@ -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 each row's decision in `evolution.check`. The hook passes `data` where Gen 1
passes `game`; positions 2-4 (mon, row, trigger) match. passes `game`; positions 2-4 (mon, row, trigger) match.
- *The frame (`src/core/Game2.lua`):* hooks `input.step`, `input.pointer`, - *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 at the same moment `src/core/Game.lua` and `src/render/Renderer.lua` raise it
-- the logic tick before the pad is read, a pointer the touch overlay gets -- the logic tick before the pad is read, a pointer the touch overlay gets
first refusal on, the palette zone list handed to the present pass, the first refusal on, the palette zone list handed to the present pass, the
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 `render.hud`'s `gameX` / `gameY` really is where Gold's dialogue boxes and
menus land, because `Chrome.fitScale` / `fitOrigin` and `World:fitScale` menus land, because `Chrome.fitScale` / `fitOrigin` and `World:fitScale`
compute the same number. `render.zones` is handed `nil` in GBC mode (Gold 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: 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 - `pokemon.before_give` / `pokemon.received`: Gold has no give-mon seam of its
own yet. own yet.
- `link.*` and `trade.completed`: a Gold boot offers no link menu at all. The - `link.*` and `trade.completed`: a Gold boot offers no link menu at all. The
+270 -12
View File
@@ -21,6 +21,133 @@ luajit tools/gen_registry_docs.lua
luajit tools/gen_registry_docs.lua ../gen1recomp.wiki 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.
## Mods and Gold (Gen 2) ## Mods and Gold (Gen 2)
The mod API is one API across both generations, but Gold runs its own battle The mod API is one API across both generations, but Gold runs its own battle
@@ -66,7 +193,8 @@ optional visual `tileRows` at 2x resolution, and optional `tileDetailRows` at
`"3"` (darkest); their matching width and height fields describe the grid. `"3"` (darkest); their matching width and height fields describe the grid.
`markers` contains active `{ kind, x, y }` points in map-cell coordinates for `markers` contains active `{ kind, x, y }` points in map-cell coordinates for
`warp`, visible `item`, and untaken `hidden` locations. All fields are `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 ## Party ordering
@@ -76,6 +204,24 @@ Companion UIs and alternate party screens can call
operation is accepted only during idle overworld play; menus, movement, operation is accepted only during idle overworld play; menus, movement,
scripts, battles, and transitions leave the party untouched. 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 ## Rendering pipelines
Most registries hand the engine *content*. `render_pipelines` hands it Most registries hand the engine *content*. `render_pipelines` hands it
@@ -237,6 +383,25 @@ local keys, code, message = mod.storage:list(game, "history/quick")
local deleted, code, message = mod.storage:delete(game, "history/quick/q0001") 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 `context` returns `{ engineVersion, gameVersion, playthroughId }`. The engine
version is compatibility metadata; physical launcher-slot and path identity stays version is compatibility metadata; physical launcher-slot and path identity stays
private. A title-selected context may additionally contain `normalSavedAt`, the private. A title-selected context may additionally contain `normalSavedAt`, the
@@ -245,19 +410,22 @@ progress or a slot/path handle.
At the title screen only, `mod.storage:selected(game)` returns a bound storage At the title screen only, `mod.storage:selected(game)` returns a bound storage
facade for the launcher-selected existing playthrough, or `nil, code, message`. 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)`, 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 `write(key, value)`, `readBytes(key)`, `writeBytes(key, bytes)`,
data-only and transaction contract as `mod.storage`, but remain restricted to `list(prefix)`, and `delete(key)` methods have the same scoped and
the calling mod's selected existing namespace. It is intended for title tools transactional contract as `mod.storage`, but remain restricted to the calling
that need to browse or manage durable history before the first normal SAVE. 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 Table values must contain serializable data only. Opaque values must be Lua
slash-separated segments (letters, digits, `_`, `-`); paths and filesystem strings. Keys are conservative slash-separated segments (letters, digits, `_`,
handles are never exposed. Writes are staged and decode-verified, reads recover `-`); paths and filesystem handles are never exposed. Table writes are staged
from a valid staged/backup generation, and methods return structured errors for and decode-verified; opaque writes are staged and byte-verified; reads recover
normal data or I/O failures. The playthrough identity is allocated lazily on the from a valid staged/backup generation. Methods return structured errors for
first storage/checkpoint call, so an unused API changes no save bytes. 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: `mod.checkpoints` captures and reconstructs engine-owned semantic runtime state:
@@ -352,6 +520,43 @@ animation/messages, forced choices, and every phase that cannot safely be
checkpointed remain excluded. Exceptions are contained by normal hook isolation checkpointed remain excluded. Exceptions are contained by normal hook isolation
and fall through without advancing a turn. 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 ## Developer console
Boot with developer mode on to unlock the in-game console and hot-reload Boot with developer mode on to unlock the in-game console and hot-reload
@@ -439,6 +644,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, 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. 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 `screen.render_visible` receives `(next, state)` while the main screen is being
composed. Return `false` to omit that state from drawing, opacity selection and 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 palette-zone ownership. The state remains on the stack and keeps its normal
@@ -454,6 +673,7 @@ identifiers: `pc_box_withdraw`, `pc_box_deposit`, `pc_box_release`,
`battle.bottom_ui_visible` and `battle.status_hud_visible` independently `battle.bottom_ui_visible` and `battle.status_hud_visible` independently
control the battle text/menu layer and the HP/status panels. Both receive control the battle text/menu layer and the HP/status panels. Both receive
`(next, state)` and default to `true`, so vanilla rendering is unchanged. `(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 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 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 behind under another overlay. Text boxes also pass through the hook as their
@@ -544,3 +764,41 @@ local both = mod.datetime:dateTime(game, createdAt)
The live `game` supplies only the current option context. Formatting never The live `game` supplies only the current option context. Formatting never
mutates the save, options, or timestamp, and invalid timestamps return mutates the save, options, or timestamp, and invalid timestamps return
`"----"`. `"----"`.
## Device power information
Sandboxed mods can read the host's battery state without receiving the rest
of `love.system`:
```lua
local state, percent = mod.device:powerInfo()
```
`state` follows LÖVE's values: `"unknown"`, `"battery"`, `"nobattery"`,
`"charging"`, or `"charged"`. `percent` is `0` through `100`, or `nil` when
the platform cannot report it. The facade is read-only and does not expose
URL launching, clipboard access, or other system operations.
## Real-world steps
On iOS and Android the game counts the player's real-world steps natively
(HealthKit / the hardware step counter). A mod reaches that bridge through
the `steps` permission in `manifest.json`, which the player sees in the
mod manager like every other permission:
```lua
if mod.steps:available() then
mod.steps:sync() -- async; OS consent sheet on first use
end
-- later, at a quiet moment:
local walk = mod.steps:poll() -- { steps = n, from = ?, to = ? } or nil
```
`available()` is `false` on builds without the bridge (desktop) and for
mods without the permission, so a probe is always safe. `sync()` asks the
platform to refresh its count and returns whether there was a bridge to
ask. `poll()` returns the next delivery for this mod — the engine consumes
the native side's pending file itself, each permissioned mod receives its
own copy of a delivery, and steps are anchored natively so the same walk
is never delivered twice. Without the permission, `sync` and `poll` raise
an error naming it.
+7
View File
@@ -929,6 +929,10 @@ mod.content.sfx:register("SFX_MOD_CHIME", { file = "chime.ogg" })
| field | type | required | | field | type | required |
|---|---|---| |---|---|---|
| `anchorX` | number | no |
| `anchorY` | number | no |
| `frameHeight` | integer >= 1 | no |
| `frameWidth` | integer >= 1 | no |
| `frames` | integer >= 1 | yes | | `frames` | integer >= 1 | yes |
| `id` | string | no | | `id` | string | no |
| `image` | file path | yes | | `image` | file path | yes |
@@ -1100,6 +1104,7 @@ mod.content.tokens:register("CLOCK", function(game) return "12" end)
| `paletteSource` | string | no | | `paletteSource` | string | no |
| `parties` | list of list of {level, species} | yes | | `parties` | list of list of {level, species} | yes |
| `pic` | file path | no | | `pic` | file path | no |
| `trueColor` | boolean | no |
```lua ```lua
mod.content.trainers:patch("OPP_BROCK", { baseMoney = 99 }) mod.content.trainers:patch("OPP_BROCK", { baseMoney = 99 })
@@ -1122,7 +1127,9 @@ do not.
| `index` | integer 0..255 | no | | `index` | integer 0..255 | no |
| `items` | list of items id | no | | `items` | list of items id | no |
| `name` | string | yes | | `name` | string | yes |
| `pic` | file path | no |
| `trainers` | list of {id?, index?, name, party, trainerType?} | yes | | `trainers` | list of {id?, index?, name, party, trainerType?} | yes |
| `trueColor` | boolean | no |
```lua ```lua
mod.content.trainers:patch("BEAUTY", { baseMoney = 99 }) mod.content.trainers:patch("BEAUTY", { baseMoney = 99 })
+2 -1
View File
@@ -19,13 +19,14 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow
* **Soft reset button combination** * **Soft reset button combination**
* **Keyboard and controller rebinding** * **Keyboard and controller rebinding**
* **Mod profiles** with separate mod settings and save slots * **Mod profiles** with separate mod settings and save slots
* **Sandboxed mods**: an installed mod can read only its own folder and write only its own storage, so it cannot reach the rest of your device
* **Improved launcher and save editor UI**, including background downloads and update checks * **Improved launcher and save editor UI**, including background downloads and update checks
* **Direct-launch options** for shortcuts, Steam entries, and handheld frontends * **Direct-launch options** for shortcuts, Steam entries, and handheld frontends
* **Custom boot branding** * **Custom boot branding**
## Pokémon Gold (Gen 2) ## 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** * **COLOR, zoom, tilt, GBC FX, and quick save/load**
* **UI that stays fixed while the overworld zooms** * **UI that stays fixed while the overworld zooms**
+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 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`. 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 ### One limit worth knowing
**Enablement is per game.** The overlay **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.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.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.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.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.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 | | `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 - **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 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 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 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 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 ## The exact API delta
Backward-compatible, additive-only. `Loader:_api` binds a new `mod.storage` 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 facade to the calling mod id. Mods receive logical keys and either decoded table
filesystem handles or physical paths. values or exact opaque byte strings, never filesystem handles or physical paths.
### Lazy opaque playthrough identity ### 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 then backup data. A valid staged/backup value is returned and promoted
best-effort; corrupt bytes are never executed. 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])` ### `mod.storage:list(game[, prefix])`
Returns sorted logical keys beneath a valid prefix, an exact key when the 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` `persistence root / mod_storage / game version / playthrough id / mod id`
Stable error codes are `not_in_playthrough`, `storage_unavailable`, Stable error codes are `not_in_playthrough`, `storage_unavailable`,
`invalid_key`, `encode_failed`, `write_failed`, `verify_failed`, and `invalid_key`, `encode_failed`, `invalid_bytes`, `size_limit`, `type_conflict`,
`not_found`. Ordinary data and I/O failures are return values, not callback- `type_mismatch`, `write_failed`, `verify_failed`, and `not_found`. Ordinary
terminating errors. data and I/O failures are return values, not callback-terminating errors.
The restricted serializer's recursive writer runs outside LuaJIT traces. A The restricted serializer's recursive writer runs outside LuaJIT traces. A
1,000-process GC stress regression found compiled recursion could intermittently 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 **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 playthrough id is created unless a mod invokes `mod.storage` or
`mod.checkpoints`. Existing save bytes remain unchanged on the no-caller path. `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 ## 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 - **Engine identity:** lazy allocation, save/load preservation, stable legacy
mapping, fresh-playthrough replacement, and version/slot isolation. mapping, fresh-playthrough replacement, and version/slot isolation.
- **Public Mod API:** two real API-2 entry chunks prove data-only roundtrip, - **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, 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 ## 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.
-22
View File
@@ -76,15 +76,6 @@ end
local editorHost, editorVersion, editorWindow local editorHost, editorVersion, editorWindow
local closeEditor -- forward declaration: openEditor hands it to the editor local closeEditor -- forward declaration: openEditor hands it to the editor
-- 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 -- The editor's modules use flat names (require("Kit"), require("Party")), so
-- their directories have to be on the require path. It must be -- their directories have to be on the require path. It must be
-- love.filesystem's path, not package.path: in a packaged build these files -- love.filesystem's path, not package.path: in a packaged build these files
@@ -137,11 +128,6 @@ local function openEditor(version, slotId)
Importer.saveNotice = Importer.saveNotice or {} Importer.saveNotice = Importer.saveNotice or {}
Importer.saveNotice[version] = { ok = false, text = text } Importer.saveNotice[version] = { ok = false, text = text }
end 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 SaveData = require("src.core.SaveData")
local path = SaveData.slotDiskPath(version, slotId) local path = SaveData.slotDiskPath(version, slotId)
if not path then if not path then
@@ -320,14 +306,6 @@ function love.load(args)
-- cache has to be mounted before the editor's Data:load. -- cache has to be mounted before the editor's Data:load.
if editorMode then if editorMode then
local version = os.getenv("POKEPORT_VERSION") or "red" 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.core.GameVersion").set(version)
require("src.import.CacheFs").mountVersion(version) require("src.import.CacheFs").mountVersion(version)
addEditorRequirePath() addEditorRequirePath()
@@ -192,8 +192,14 @@ bool System::pickFile(const char *kind) const
dest = "picked_mod.zip"; dest = "picked_mod.zip";
else if (strcmp(kind, "sav") == 0 || strcmp(kind, "save") == 0) else if (strcmp(kind, "sav") == 0 || strcmp(kind, "save") == 0)
dest = "picked_save.sav"; dest = "picked_save.sav";
else if (strcmp(kind, "required_import") == 0)
dest = "picked_required_import.bin";
else if (strcmp(kind, "rom") == 0) else if (strcmp(kind, "rom") == 0)
dest = "picked_rom.gb"; 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); return love::android::showFilePicker(dest);
#else #else
@@ -202,6 +208,15 @@ bool System::pickFile(const char *kind) const
#endif #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 bool System::createFile(const char *suggestedName) const
{ {
#ifdef LOVE_ANDROID #ifdef LOVE_ANDROID
@@ -112,10 +112,12 @@ public:
* love::android::showFilePicker and src/import/RomImporter.lua. * love::android::showFilePicker and src/import/RomImporter.lua.
* *
* @param kind Optional pick kind: nullptr/"rom" -> picked_rom.gb, * @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. * @return Whether the picker was shown.
**/ **/
virtual bool pickFile(const char *kind = nullptr) const; 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 * Shows the platform's native "create / save a file" UI (Android SAF
@@ -102,6 +102,12 @@ int w_pickFile(lua_State *L)
return 1; return 1;
} }
int w_pickFileKinds(lua_State *L)
{
luax_pushstring(L, instance()->pickFileKinds());
return 1;
}
int w_createFile(lua_State *L) int w_createFile(lua_State *L)
{ {
const char *suggested = luaL_optstring(L, 1, nullptr); const char *suggested = luaL_optstring(L, 1, nullptr);
@@ -222,6 +228,7 @@ static const luaL_Reg functions[] =
{ "openURL", w_openURL }, { "openURL", w_openURL },
{ "vibrate", w_vibrate }, { "vibrate", w_vibrate },
{ "pickFile", w_pickFile }, { "pickFile", w_pickFile },
{ "pickFileKinds", w_pickFileKinds },
{ "createFile", w_createFile }, { "createFile", w_createFile },
{ "syncHealthSteps", w_syncHealthSteps }, { "syncHealthSteps", w_syncHealthSteps },
{ "restartApp", w_restartApp }, { "restartApp", w_restartApp },
+35
View File
@@ -12,6 +12,41 @@
"tintColor": "3b5ca8", "tintColor": "3b5ca8",
"category": "games", "category": "games",
"versions": [ "versions": [
{
"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", "version": "0.1.81",
"date": "2026-08-13", "date": "2026-08-13",
+4 -2
View File
@@ -5,7 +5,7 @@
// without updating that patch (see mobile/ios/patch_love_src.py). // without updating that patch (see mobile/ios/patch_love_src.py).
// //
// Contract (mirrors love-android's GameActivity.showFilePicker): // 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 / // the LÖVE save directory as picked_rom.gb / picked_mod.zip /
// picked_save.sav; RomImporter's pending-file scan consumes it. // picked_save.sav; RomImporter's pending-file scan consumes it.
// love.system.createFile(name) -> exports save dir's pending_export.sav // love.system.createFile(name) -> exports save dir's pending_export.sav
@@ -83,6 +83,8 @@ public final class GRPickerBridge: NSObject {
types = [.zip] types = [.zip]
case "sav": case "sav":
destName = "picked_save.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 -- // A Nintendo 64 cartridge, for mods that build assets out of one --
// the voxel mod's Pokemon Stadium battle models are the caller this // 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 // 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 // Kept beside the switch it describes, because the two drifting apart is
// the only way this can lie. // the only way this can lie.
@objc public static func supportedPickerKinds() -> NSString { @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:) @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); 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. // 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 // 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 fused executable. Mods load it through LuaJIT FFI; no .NET runtime is
required on the player's machine. 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 ## Android
On Android, the matching API is exposed as `love.system.tlsOpen` / 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"/*.dll "$out_dir"/
cp "$love_dir"/license.txt "$out_dir"/ 2>/dev/null || true 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 # 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 # 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, # 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" \ grep -q '^FUSE_PATH="\$APPDIR/game.love"$' "$appdir/AppRun" \
|| fail "failed to enable FUSE_PATH in AppRun (upstream AppRun changed?)" || 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 # Match the upstream image's compression (gzip, 128K blocks) so the
# bundled runtime can read it. # bundled runtime can read it.
local sfs_out="$WORK/game.squashfs" 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 # 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 # version bump that quietly regresses below the host's expectations fails the
# build instead of silently killing audio again. # 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" || fail "bundled libmpg123 lacks mpg123_info2; the host's libsndfile will fail to relocate"
# ------------------------------------------------------------ compile SDL2 # ------------------------------------------------------------ compile SDL2
@@ -128,8 +129,9 @@ fi
# the host having that exact stack -- which is the bug this replaced. # the host having that exact stack -- which is the bug this replaced.
sdl_lib="$PREFIX/lib/libSDL2-2.0.so.0" sdl_lib="$PREFIX/lib/libSDL2-2.0.so.0"
[ -f "$sdl_lib" ] || fail "SDL2 build produced no 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 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)" fail "SDL2 hard-links $forbidden; it must dlopen its backends (--enable-*-shared)"
fi fi
done done
@@ -164,8 +166,9 @@ fi
openal_lib="$PREFIX/lib/libopenal.so.1" openal_lib="$PREFIX/lib/libopenal.so.1"
[ -f "$openal_lib" ] || fail "openal-soft build produced no 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 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" fail "openal hard-links $forbidden; backends must stay behind dlopen"
fi fi
done done
@@ -196,7 +199,8 @@ fi
theora_lib="$PREFIX/lib/libtheoradec.so.1" theora_lib="$PREFIX/lib/libtheoradec.so.1"
[ -f "$theora_lib" ] || fail "libtheora build produced no 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)" fail "libtheoradec still links libcairo (--disable-examples stopped working)"
fi fi
@@ -231,16 +235,18 @@ love_bin="$PREFIX/bin/love"
love_lib="$PREFIX/lib/liblove-$LOVE_VERSION.so" love_lib="$PREFIX/lib/liblove-$LOVE_VERSION.so"
[ -x "$love_bin" ] || fail "LÖVE build produced no bin/love" [ -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" [ -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"))" || 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 # 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. # 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. # 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 \ for soname in libSDL2-2.0.so.0 libopenal.so.1 libfreetype.so.6 \
libmodplug.so.1 libmpg123.so.0 libvorbisfile.so.3 \ libmodplug.so.1 libmpg123.so.0 libvorbisfile.so.3 \
libtheoradec.so.1 libluajit-5.1.so.2; do 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)" || fail "liblove is not linked against $soname (a -dev package went missing)"
done done
@@ -405,6 +411,12 @@ if [ -z "\$LUA_CPATH" ]; then
fi fi
export LUA_CPATH="\$APPDIR/lib/lua/5.1/?.so;\$LUA_CPATH" 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" "\$@" exec "\$APPDIR/bin/love" --fused "\$APPDIR/game.love" "\$@"
EOF EOF
chmod +x "$APPDIR/AppRun" 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.json tools/rom_manifest_blue.json \
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \ tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*') -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 if [ -n "$BUILD_INFO" ]; then
[ -f "$BUILD_INFO" ] || fail "missing build-info: $BUILD_INFO" [ -f "$BUILD_INFO" ] || fail "missing build-info: $BUILD_INFO"
+6
View File
@@ -35,4 +35,10 @@ find_love() {
LOVE_BIN="$(find_love)" \ LOVE_BIN="$(find_love)" \
|| fail "LÖVE not found, run scripts/setup.sh (or install from https://love2d.org)" || 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" "$@" 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: 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: 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: 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: 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 "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 run_tier "T5 link (loopback lockstep)" "$LUA" tests/run_link_tests.lua
+191 -77
View File
@@ -316,6 +316,27 @@ function BattleState.trainerPicPath(data, trainer, oppClass, partyIndex)
return base and base.pic or nil return base and base.pic or nil
end 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 -- The battle-BGP fade variant of a pic (AnimationFlashScreen and the
-- SetAnimationBGPalette effects remap the four BG shades; on the SGB -- SetAnimationBGPalette effects remap the four BG shades; on the SGB
-- the colorizer then colors the REMAPPED shade, so a faded pic shows -- 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, badgeBoosts = badgeBoosts,
statuses = data.statuses, statuses = data.statuses,
shownHP = mon.hp, -- the HP the bar displays (UpdateHPBar drain) 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 -- HUD status label (DrawHUDsAndHPBars); mon.status can land mid-move
-- while the tilemap still shows the prior condition until the next -- while the tilemap still shows the prior condition until the next
-- post-action HUD refresh (core.asm after Execute*Move) -- post-action HUD refresh (core.asm after Execute*Move)
@@ -563,10 +587,6 @@ local function markOwned(game, species)
local dex = game.save.pokedex local dex = game.save.pokedex
if dex then if dex then
dex.seen[species] = true 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 dex.owned[species] = true
end end
end end
@@ -618,6 +638,44 @@ local function newBattle(game)
return self return self
end 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 -- opts.hooked: rod encounter, announced with _HookedMonAttackedText
function BattleState.newWild(game, species, level, opts) function BattleState.newWild(game, species, level, opts)
local self = newBattle(game) local self = newBattle(game)
@@ -693,13 +751,15 @@ local function applySpecialMoves(data, oppClass, partyIndex, party)
end end
end end
function BattleState.newTrainer(game, oppClass, partyIndex) function BattleState.newTrainer(game, oppClass, partyIndex, opts)
local self = newBattle(game) local self = newBattle(game)
self.kind = "trainer" self.kind = "trainer"
self.oppClass = oppClass self.oppClass = oppClass
-- the object_event trainer arg (roster index). computeMusicKind keys -- the object_event trainer arg (roster index). computeMusicKind keys
-- data/scripts/victories.lua on class#party, so keep it on the battle (#782). -- data/scripts/victories.lua on class#party, so keep it on the battle (#782).
self.partyIndex = partyIndex or 1 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] self.trainer = game.data.trainers[oppClass]
assert(self.trainer, "unknown trainer class " .. tostring(oppClass)) assert(self.trainer, "unknown trainer class " .. tostring(oppClass))
-- pret GetTrainerName_: RIVAL1/2/3 copy wRivalName into wTrainerName -- pret GetTrainerName_: RIVAL1/2/3 copy wRivalName into wTrainerName
@@ -743,7 +803,7 @@ function BattleState.newTrainer(game, oppClass, partyIndex)
end end
end end
self.enemyIndex = 1 self.enemyIndex = 1
local playerMon = Party.firstHealthy(game.save.party) local playerMon = Party.firstHealthy(self:playerPartyView())
if not playerMon then if not playerMon then
Logger.warn("trainer battle with no healthy party; skipping") Logger.warn("trainer battle with no healthy party; skipping")
self.dead = true self.dead = true
@@ -757,9 +817,8 @@ function BattleState.newTrainer(game, oppClass, partyIndex)
-- MonsterPalettes[0] = PAL_MEWMON -- InitBattleCommon zeroes -- MonsterPalettes[0] = PAL_MEWMON -- InitBattleCommon zeroes
-- wEnemyMonSpecies2 before the intro's SET_PAL_BATTLE -- wEnemyMonSpecies2 before the intro's SET_PAL_BATTLE
-- (engine/battle/core.asm:6682, engine/gfx/palettes.asm SetPal_Battle) -- (engine/battle/core.asm:6682, engine/gfx/palettes.asm SetPal_Battle)
self.trainerPic = getImage( self.trainerPic = BattleState.trainerSprite(
BattleState.trainerPicPath(game.data, self.trainer, oppClass, partyIndex), game.data, self.trainer, oppClass, partyIndex)
BattleState.trainerPalette(game.data, self.trainer))
self.introText = Strings("%s wants\nto fight!", self.trainer.name) self.introText = Strings("%s wants\nto fight!", self.trainer.name)
return self return self
end end
@@ -935,6 +994,17 @@ function BattleState:sayNextWaitSfx(text, sfx)
table.insert(self.queue, self.nextInsert, { text = text, waitForLearningSfx = sfx }) table.insert(self.queue, self.nextInsert, { text = text, waitForLearningSfx = sfx })
end 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) -- sayNext for a page that ends in `text_end` (see sayAuto) (#765)
function BattleState:sayNextAuto(text, delay) function BattleState:sayNextAuto(text, delay)
self.nextInsert = (self.nextInsert or 0) + 1 self.nextInsert = (self.nextInsert or 0) + 1
@@ -1022,22 +1092,39 @@ function BattleState:stepHPDrain()
and b.shownHP >= b.drainFloor then and b.shownHP >= b.drainFloor then
goal = b.drainFloor goal = b.drainFloor
end 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 if (b.drainHold or 0) > 0 then
b.drainHold = b.drainHold - 1 b.drainHold = b.drainHold - 1
busy = true 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 elseif b.shownHP ~= goal then
local maxHP = math.max(1, b.mon.stats.hp) local spent = 0
local playerSide = (b == self.player)
local cost = 0
-- consume whole HP steps until this frame's budget is spent; on the -- consume whole HP steps until this frame's budget is spent; on the
-- enemy HUD several free steps can land in the same frame -- enemy HUD several free steps can land in the same frame
while b.shownHP ~= goal and cost < 1 do repeat
local nextHP = b.shownHP + ((b.shownHP > goal) and -1 or 1) b.shownHP = b.shownHP + ((b.shownHP > goal) and -1 or 1)
cost = cost + Timing.hpDrainStepFrames(b.shownHP, nextHP, spent = spent + (playerSide and Timing.HP_BAR_HP_STEP or 0)
maxHP, playerSide) targetPx = Timing.hpBarPixels(b.shownHP, maxHP)
b.shownHP = nextHP 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 end
b.drainHold = math.max(0, cost - 1)
b.draining = true b.draining = true
busy = true busy = true
elseif b.draining then elseif b.draining then
@@ -1431,7 +1518,7 @@ end
function BattleState:playEntranceCry(battler) function BattleState:playEntranceCry(battler)
local mon = battler and battler.mon local mon = battler and battler.mon
if not mon then return end 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) mon.status == "SLP" and 37 or 11)
end end
@@ -1555,7 +1642,23 @@ function BattleState:enter()
-- default stays opaque for every other battle and for older saves. -- default stays opaque for every other battle and for older saves.
self.isOpaque = self:bgMode() ~= "world" self.isOpaque = self:bgMode() ~= "world"
self.introSlide = Timing.BATTLE_SLIDE_IN_FRAMES 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 -- DrawAllPokeballs (common_text.asm:27) puts the party ball rows AND the
-- HUD corner/underline tiles under them (PlacePlayerHUDTiles / -- HUD corner/underline tiles under them (PlacePlayerHUDTiles /
-- PlaceEnemyHUDTiles, draw_hud_pokeball_gfx.asm:119-165) on screen with -- 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 -- a different point in each battle kind, so queue it per branch
local function queueEnemyCry() local function queueEnemyCry()
self:act(function() self:act(function()
self:playEntranceCry(self.enemy) self:waitSfxNext(self:playEntranceCry(self.enemy))
end) end)
end end
-- PrintBeginningBattleText (engine/battle/common_text.asm:10-19): a wild -- PrintBeginningBattleText (engine/battle/common_text.asm:10-19): a wild
-- battle calls PlayCry BEFORE PrintText WildMonAppearedText, so the cry -- battle calls PlayCry BEFORE PrintText WildMonAppearedText, and PlayCry
-- sounds with the "Wild X appeared!" box instead of waiting on the A -- ends in WaitForSoundToFinish, so the cry runs to its end and only then
-- press that clears its `prompt` (#303). The Silph-Scope-less tower -- 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 -- ghost gets no cry at all (common_text.asm:43-48), and neither does the
-- unveiled MAROWAK: .isMarowak never reaches PlayCry (#492). -- unveiled MAROWAK: .isMarowak never reaches PlayCry (#492).
if self.kind ~= "trainer" and self.kind ~= "link" 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 -- The sfx is extracted as "Trainer_Appeared" (tools/rom_manifest.json
-- sfxHeaders, bank 8 / $42bb -- the same header pokered names -- sfxHeaders, bank 8 / $42bb -- the same header pokered names
-- SFX_Silph_Scope); nothing had ever played it. -- 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:act(function()
self.introSfx = require("src.core.Sound").play(self.data, self.introSfx = require("src.core.Sound").play(self.data,
"Trainer_Appeared") "Trainer_Appeared")
@@ -1629,13 +1735,17 @@ function BattleState:enter()
-- battle -- not on a switch, and not when the beaten trainer's pic -- battle -- not on a switch, and not when the beaten trainer's pic
-- scrolls back in (#317, #282) -- scrolls back in (#317, #282)
self:act(function() self.introBalls = nil end) self:act(function() self.introBalls = nil end)
if self.kind == "trainer" then 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 -- EnemySendOutFirstMon (core.asm:1308-1310): SlideTrainerPicOffScreen
-- walks the foe's pic off the RIGHT edge (hlcoord 18,0, a = 8 tiles, -- 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 -- one tile every 2 frames) BEFORE TrainerSentOutText -- the pic does
-- not blink out under the text (#317) -- not blink out under the text (#317)
self:act(function() self:slidePic("foe", 0, 64, 4) end) self:act(function() self:slidePic("foe", 0, 64, 4) end)
table.insert(self.queue, { wait = 16 }) table.insert(self.queue, { wait = 16 })
end
self:act(function() self:act(function()
self.showEnemyTrainer = false self.showEnemyTrainer = false
-- the slot is EMPTY from here until AnimateSendingOutMon runs below: -- the slot is EMPTY from here until AnimateSendingOutMon runs below:
@@ -1648,7 +1758,7 @@ function BattleState:enter()
self.enemySendingOut = true self.enemySendingOut = true
self:slidePic("foe") self:slidePic("foe")
end) 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() self:act(function()
-- EnemySendOutFirstMon (core.asm:1421-1434): after the text the -- EnemySendOutFirstMon (core.asm:1421-1434): after the text the
-- pic grows out of the ball (AnimateSendingOutMon), then the cry -- pic grows out of the ball (AnimateSendingOutMon), then the cry
@@ -1656,18 +1766,6 @@ function BattleState:enter()
self:startGrowIn(self.enemy) self:startGrowIn(self.enemy)
end) end)
queueEnemyCry() 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 end
-- StartBattle .foundFirstAliveEnemyMon (core.asm:152-156): the `call nz` -- StartBattle .foundFirstAliveEnemyMon (core.asm:152-156): the `call nz`
-- gates only EnemySendOutFirstMon -- the `ld c, 40 / call DelayFrames` -- 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 -- SendOutMon (core.asm:1757-1762): after the poof the mon grows
-- out of the ball (AnimateSendingOutMon at hlcoord 4,11) -- out of the ball (AnimateSendingOutMon at hlcoord 4,11)
self:startGrowIn(self.player) self:startGrowIn(self.player)
self:playEntranceCry(self.player) self:waitSfxNext(self:playEntranceCry(self.player))
end) end)
self:markParticipant() self:markParticipant()
end end
@@ -1876,7 +1974,10 @@ function BattleState:update(dt)
if self.phase == "menu" then if self.phase == "menu" then
for _, b in ipairs({ self.player, self.enemy }) do for _, b in ipairs({ self.player, self.enemy }) do
if b then 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.drainFloor = nil
b.shownStatus = b.mon.status b.shownStatus = b.mon.status
end end
@@ -1961,7 +2062,7 @@ function BattleState:update(dt)
-- loops the party menu until a healthy mon is picked, so B and -- loops the party menu until a healthy mon is picked, so B and
-- fainted picks land back here and reopen it -- fainted picks land back here and reopen it
if self.player.mon.hp <= 0 then if self.player.mon.hp <= 0 then
if Party.firstHealthy(self.game.save.party) then if Party.firstHealthy(self:playerPartyView()) then
self:openReplacementMenu() self:openReplacementMenu()
end end
return return
@@ -2294,10 +2395,10 @@ function BattleState:oldManThrow()
return return
end end
self:ballChain("TOSS_ANIM", true, 3, "POKE_BALL") self:ballChain("TOSS_ANIM", true, 3, "POKE_BALL")
self:actNext(function() -- ItemUseBallText05: text_far, sound_caught_mon, text_promptbutton --
require("src.core.Sound").play(self.data, "Caught_Mon") -- the fanfare follows the caught text and holds the prompt
end) self:sayNextWaitSfx(Strings("All right!\n%s was\ncaught!", self.enemy.name),
self:sayNext(Strings("All right!\n%s was\ncaught!", self.enemy.name)) function() return require("src.core.Sound").play(self.data, "Caught_Mon") end)
end) end)
end end
@@ -2481,7 +2582,7 @@ function BattleState:resolveSwitch(newMon)
self.sendingOut = false self.sendingOut = false
-- SendOutMon (core.asm:1757-1762): poof, then the grow-in -- SendOutMon (core.asm:1757-1762): poof, then the grow-in
self:startGrowIn(self.player) self:startGrowIn(self.player)
self:playEntranceCry(self.player) self:waitSfxNext(self:playEntranceCry(self.player))
end) end)
end) end)
self:act(function() self:act(function()
@@ -3793,7 +3894,7 @@ function BattleState:onFaint(battler)
if battler.isPlayer then if battler.isPlayer then
-- RemoveFaintedPlayerMon (core.asm:1040-1042): the player mon's -- RemoveFaintedPlayerMon (core.asm:1040-1042): the player mon's
-- faint plays its ordinary species cry -- no Faint_Fall -- 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 elseif self.kind ~= "wild" then
-- FaintEnemyPokemon (core.asm:732-771): the enemy faint plays no -- FaintEnemyPokemon (core.asm:732-771): the enemy faint plays no
-- species cry; trainer battles get SFX_FAINT_FALL, then SFX_FAINT_THUD -- species cry; trainer battles get SFX_FAINT_FALL, then SFX_FAINT_THUD
@@ -3808,6 +3909,11 @@ function BattleState:onFaint(battler)
end) end)
self.nextInsert = (self.nextInsert or 0) + 1 self.nextInsert = (self.nextInsert or 0) + 1
table.insert(self.queue, self.nextInsert, { wait = Timing.FAINT_SLIDE }) 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 if not battler.isPlayer and self.kind ~= "wild" then
-- FaintEnemyPokemon's SFX_FAINT_THUD lands as the slide does (after -- FaintEnemyPokemon's SFX_FAINT_THUD lands as the slide does (after
-- Faint_Fall, before EnemyMonFaintedText) -- Faint_Fall, before EnemyMonFaintedText)
@@ -3844,7 +3950,8 @@ function BattleState:awardExp()
-- (RemoveFaintedPlayerMon), so it drops out of the divisor and only -- (RemoveFaintedPlayerMon), so it drops out of the divisor and only
-- the surviving participants are counted and paid -- the surviving participants are counted and paid
local participants, alive = 0, {} 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 if self.participants and self.participants[mon] then
participants = participants + 1 participants = participants + 1
if mon.hp > 0 then table.insert(alive, mon) end 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 -- experience.asm:248 fires per grew-level text
require("src.world.PikachuFollower") require("src.world.PikachuFollower")
.modifyHappiness(game.save, "LEVELUP", mon) .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() self:uiNext(function()
require("src.core.Sound").play(game.data, "Level_Up")
return StatBox.new(game, mon) return StatBox.new(game, mon)
end) end)
-- After PrintStatsBox, experience.asm reloads the active battler's -- 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 -- experience.asm:9-13); each mon gets its own GainedText with the
-- "with EXP.ALL," tail (wBoostExpByExpAll) -- pokered prints no -- "with EXP.ALL," tail (wBoostExpByExpAll) -- pokered prints no
-- summary line -- summary line
for _, mon in ipairs(self.game.save.party) do for _, mon in ipairs(playerParty) do
if mon.hp > 0 then 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 end
end end
@@ -3979,7 +4088,7 @@ function BattleState:enemyMonFainted()
local nextName = nextMon.nickname or self.data.pokemon[nextMon.species].name local nextName = nextMon.nickname or self.data.pokemon[nextMon.species].name
local style = tostring((self.game.save.options or {}).battleStyle or "shift") local style = tostring((self.game.save.options or {}).battleStyle or "shift")
:lower() :lower()
local partyCount = #self.game.save.party local partyCount = #self:playerPartyView()
-- ReplaceFaintedEnemyMon (core.asm:892-896): DrawEnemyPokeballs puts the -- ReplaceFaintedEnemyMon (core.asm:892-896): DrawEnemyPokeballs puts the
-- foe's party ball row -- and the HUD chrome PlaceEnemyHUDTiles lays -- 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 -- 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 local game = self.game
Screens.push(game, "PartyMenu", { Screens.push(game, "PartyMenu", {
battle = self, battle = self,
party = self:playerPartyView(),
forceSwitch = true, forceSwitch = true,
onSwitch = function(mon) onSwitch = function(mon)
if mon ~= self.player.mon and mon.hp > 0 then if mon ~= self.player.mon and mon.hp > 0 then
@@ -4042,7 +4152,7 @@ function BattleState:enemyMonFainted()
self.enemySendingOut = false self.enemySendingOut = false
self:startGrowIn(self.enemy) self:startGrowIn(self.enemy)
self:actNext(function() self:actNext(function()
self:playEntranceCry(self.enemy) self:waitSfxNext(self:playEntranceCry(self.enemy))
end) end)
end) end)
end) end)
@@ -4081,7 +4191,7 @@ function BattleState:enemyMonFainted()
self:actNext(function() self:actNext(function()
self.sendingOut = false self.sendingOut = false
self:startGrowIn(self.player) self:startGrowIn(self.player)
self:playEntranceCry(self.player) self:waitSfxNext(self:playEntranceCry(self.player))
end) end)
end) end)
return return
@@ -4178,7 +4288,7 @@ function BattleState.isOaksLabStarterRival(self)
end end
function BattleState:playerMonFainted() 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 -- 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 -- 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 -- to residual damage on the turn it lands the KO -- used to hit the
@@ -4251,6 +4361,7 @@ function BattleState:openReplacementMenu()
self:ui(function() self:ui(function()
return self:buildScreen("PartyMenu", { return self:buildScreen("PartyMenu", {
battle = self, battle = self,
party = self:playerPartyView(),
-- ChooseNextMon: pick immediately (no SWITCH/STATS/CANCEL) -- ChooseNextMon: pick immediately (no SWITCH/STATS/CANCEL)
forceSwitch = true, forceSwitch = true,
onSwitch = function(mon) onSwitch = function(mon)
@@ -4277,7 +4388,7 @@ function BattleState:openReplacementMenu()
self.sendingOut = false self.sendingOut = false
-- SendOutMon (core.asm:1757-1762): poof, then the grow-in -- SendOutMon (core.asm:1757-1762): poof, then the grow-in
self:startGrowIn(self.player) self:startGrowIn(self.player)
self:playEntranceCry(self.player) self:waitSfxNext(self:playEntranceCry(self.player))
end) end)
end, end,
}) })
@@ -4321,11 +4432,10 @@ function BattleState:safariAction(choice)
-- above DoBallTossSpecialEffects's <= ULTRA_BALL check) -- above DoBallTossSpecialEffects's <= ULTRA_BALL check)
self:ballChain(self:tossAnimFor("SAFARI_BALL"), caught, shakes, "SAFARI_BALL") self:ballChain(self:tossAnimFor("SAFARI_BALL"), caught, shakes, "SAFARI_BALL")
if caught then if caught then
-- ItemUseBallText05's sound_caught_mon: fanfare with the text -- ItemUseBallText05: text_far, sound_caught_mon, text_promptbutton --
self:actNext(function() -- the fanfare follows the caught text and holds the prompt
require("src.core.Sound").play(self.data, "Caught_Mon") self:sayNextWaitSfx(Strings("All right!\n%s was\ncaught!", self.enemy.name),
end) function() return require("src.core.Sound").play(self.data, "Caught_Mon") end)
self:sayNext(Strings("All right!\n%s was\ncaught!", self.enemy.name))
-- same ItemUseBall .captured flow as a regular ball -- same ItemUseBall .captured flow as a regular ball
self:act(function() self:storeCaughtMon() end) self:act(function() self:storeCaughtMon() end)
else else
@@ -4553,8 +4663,12 @@ function BattleState:storeCaughtMon()
markOwned(game, species) markOwned(game, species)
stampOT(game.save, self.enemy.mon) stampOT(game.save, self.enemy.mon)
if isNew then if isNew then
-- _ItemUseBallText06 + ShowPokedexData -- _ItemUseBallText06 + ShowPokedexData: text_far, sound_dex_page_added,
self:sayNext(Strings("New POKéDEX data\nwill be added for\n%s!", self.enemy.name)) -- 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() self:uiNext(function()
return self:buildScreen("DexEntryMenu", species) return self:buildScreen("DexEntryMenu", species)
end) end)
@@ -4704,12 +4818,10 @@ function BattleState:throwBall(ball)
self:ballChain(self:tossAnimFor(ball), caught, shakes, ball) self:ballChain(self:tossAnimFor(ball), caught, shakes, ball)
if caught then if caught then
-- ItemUseBallText05 carries sound_caught_mon (item_effects.asm: -- ItemUseBallText05 carries sound_caught_mon (item_effects.asm:
-- 608-614): the fanfare sounds with the caught message, before -- 608-614): text_far, sound_caught_mon, text_promptbutton -- the
-- the prompt, not after the text is dismissed -- fanfare follows the caught message and holds the prompt
self:actNext(function() self:sayNextWaitSfx(Strings("All right!\n%s was\ncaught!", self.enemy.name),
require("src.core.Sound").play(self.data, "Caught_Mon") function() return require("src.core.Sound").play(self.data, "Caught_Mon") end)
end)
self:sayNext(Strings("All right!\n%s was\ncaught!", self.enemy.name))
self:act(function() self:storeCaughtMon() end) self:act(function() self:storeCaughtMon() end)
else else
self:sayNext(self:ballMissMessage(shakes)) self:sayNext(self:ballMissMessage(shakes))
@@ -4730,6 +4842,7 @@ function BattleState:openParty()
self:ui(function() self:ui(function()
return self:buildScreen("PartyMenu", { return self:buildScreen("PartyMenu", {
battle = self, battle = self,
party = self:playerPartyView(),
onSwitch = function(mon) onSwitch = function(mon)
if mon == self.player.mon then if mon == self.player.mon then
self:say(Strings("%s is\nalready out!", self.player.name)) 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. -- 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 -- 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). -- (Yellow's Pallet intro runs before the player owns a mon at all).
if self.result ~= "lose" and not self.demo if self.kind ~= "link" and self.result ~= "lose" and not self.demo
and not Party.firstHealthy(self.game.save.party) then and not Party.firstHealthy(self:playerPartyView()) then
Logger.warn("battle finished %s with no healthy party; forcing blackout", Logger.warn("battle finished %s with no healthy party; forcing blackout",
tostring(self.result)) tostring(self.result))
self.result = "lose" self.result = "lose"
@@ -5196,7 +5309,8 @@ function BattleState:sgbBattlePals()
local function bar(b) local function bar(b)
if not b then return pals.GREENBAR end if not b then return pals.GREENBAR end
local hp = b.shownHP or b.mon.hp 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 end
local function mon(b, placeholder) local function mon(b, placeholder)
if placeholder or not b then return pals.MEWMON or pals.GREENBAR end 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) hudTile(0x73, 8, 16)
drawHPBar(barData, 2, 2, drawHPBar(barData, 2, 2,
{ hp = shownHP(self.enemy), stats = self.enemy.mon.stats }, { hp = shownHP(self.enemy), stats = self.enemy.mon.stats },
nil, grayFill) nil, grayFill, nil, self.enemy.shownPx)
hudTile(0x74, 8, 24) hudTile(0x74, 8, 24)
for i = 2, 9 do hudTile(0x76, i * 8, 24) end for i = 2, 9 do hudTile(0x76, i * 8, 24) end
hudTile(0x78, 80, 24) hudTile(0x78, 80, 24)
@@ -5657,7 +5771,7 @@ function BattleState:drawHUDs(slide)
for i = 10, 17 do hudTile(0x76, i * 8, 88) end for i = 10, 17 do hudTile(0x76, i * 8, 88) end
hudTile(0x6F, 72, 88) hudTile(0x6F, 72, 88)
love.graphics.setColor(1, 1, 1, 1) 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 end
local hidePlayer = self.safari or self.demo local hidePlayer = self.safari or self.demo
if showStatus and self.player and not hidePlayer and not self.showPlayerBack if showStatus and self.player and not hidePlayer and not self.showPlayerBack
@@ -5675,7 +5789,7 @@ function BattleState:drawHUDs(slide)
end end
drawHPBar(barData, 10, 9, drawHPBar(barData, 10, 9,
{ hp = shownHP(self.player), stats = self.player.mon.stats }, { 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) Font.draw(("%3d/%3d"):format(shownHP(self.player), self.player.mon.stats.hp), 88, 80)
hudTile(0x73, 144, 80) hudTile(0x73, 144, 80)
hudTile(0x77, 144, 88) 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, { HudTiles.drawHPBar(battle.data, tx + 1, ty + 2, {
hp = shownHP(battler), hp = shownHP(battler),
stats = battler.mon.stats, stats = battler.mon.stats,
}, nil, monoMode(), tw - 5) }, nil, monoMode(), tw - 5, battler.shownPx)
if player then if player then
Font.draw(("%3d/%3d"):format(shownHP(battler), battler.mon.stats.hp), 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 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) function Mon.refreshStats(mon, data)
if type(mon) ~= "table" then return mon end if type(mon) ~= "table" then return mon end
local def = data and data.pokemon and data.pokemon[mon.species] local def = data and data.pokemon and data.pokemon[mon.species]
if not (def and def.baseStats) then return mon end if not (def and def.baseStats) then return mon end
Mon.syncIdentity(mon, data)
-- engine/pokemon/move_mon.asm:1402 -- engine/pokemon/move_mon.asm:1402
local stats = Mon.stats(def.baseStats, mon.dvs, mon.level or 1, mon.statExp) local stats = Mon.stats(def.baseStats, mon.dvs, mon.level or 1, mon.statExp)
mon.stats = stats mon.stats = stats
+43 -4
View File
@@ -44,6 +44,7 @@ local BATTLE_FIELDS = {
"sideToxic", "isGymLeader", "musicKind", "lastBall", "lockedBall", "sideToxic", "isGymLeader", "musicKind", "lastBall", "lockedBall",
"lowHealthAlarmDisabled", "lowHealthAlarmOn", "victoryMusicPlayed", "lowHealthAlarmDisabled", "lowHealthAlarmOn", "victoryMusicPlayed",
"endBattleText", "endBattleText",
"playerPartyIndices",
} }
local function partyIndex(party, mon) 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) and value >= (min or -math.huge) and value <= (max or math.huge)
end 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) local function validateMoveList(data, moves)
if type(moves) ~= "table" then return false end if type(moves) ~= "table" then return false end
for _, move in ipairs(moves) do 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 if type(party) ~= "table" or not validateBattler(game.data, model.player, #party) then
return nil, "invalid_content", "Player battle state is invalid." return nil, "invalid_content", "Player battle state is invalid."
end 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 model.kind == "wild" then
if not validateMon(game.data, model.enemyMon) if not validateMon(game.data, model.enemyMon)
or not validateBattler(game.data, model.enemy, 1) then or not validateBattler(game.data, model.enemy, 1) then
@@ -220,15 +248,18 @@ function BattleCheckpoint.validate(game, checkpoint)
end end
end end
for _, indices in ipairs({ model.participants, model.leveledUp }) do 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." return nil, "invalid_checkpoint", "Battle party reference set is missing."
end end
for _, index in ipairs(indices) do if scopedIndices then
if not integer(index, 1, #party) then for index in pairs(referenced) do
if not scopedIndices[index] then
return nil, "invalid_checkpoint", "Battle party reference is invalid." return nil, "invalid_checkpoint", "Battle party reference is invalid."
end end
end end
end end
end
if type(model.mimicRestores) ~= "table" then if type(model.mimicRestores) ~= "table" then
return nil, "invalid_checkpoint", "Mimic restore state is missing." return nil, "invalid_checkpoint", "Mimic restore state is missing."
end end
@@ -276,7 +307,9 @@ function BattleCheckpoint.restore(game, checkpoint, copy)
local model = checkpoint.runtime.battle local model = checkpoint.runtime.battle
local battle local battle
if model.kind == "trainer" then 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.enemyParty = assert(copy(model.enemyParty))
battle.enemyIndex = model.enemyIndex battle.enemyIndex = model.enemyIndex
else else
@@ -334,6 +367,12 @@ function BattleCheckpoint.restore(game, checkpoint, copy)
battle.player.mon.hp, battle.player.mon.status battle.player.mon.hp, battle.player.mon.status
battle.enemy.shownHP, battle.enemy.shownStatus = battle.enemy.shownHP, battle.enemy.shownStatus =
battle.enemy.mon.hp, battle.enemy.mon.status 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 local ow = game.overworld
if not ow or type(ow.restoreBattleContinuation) ~= "function" if not ow or type(ow.restoreBattleContinuation) ~= "function"
+25 -9
View File
@@ -73,7 +73,7 @@ end
-- --
-- Gold composites its own frame (Game2:draw / drawScene) and pumps its own pad -- 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 -- (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 -- 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 -- 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 -- 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) -- input.pointer uncaptured pointer events (src/core/Game.lua:887)
-- render.zones the palette pass, pre-blit (src/core/Game.lua:505) -- render.zones the palette pass, pre-blit (src/core/Game.lua:505)
-- render.compose the whole-window composite (Renderer.lua:759) -- 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.letterbox the void around the 160x144 blit (Renderer.lua:840)
-- render.hud screen-space UI over the frame (src/core/Game.lua:521) -- 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) local modsDiff = SaveData.modsDiff(save, activeMods)
self.save = save self.save = save
self:adoptSave(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 -- 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 -- 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. -- save written before they moved out must not drag old values back in.
@@ -587,13 +591,13 @@ function Game2:useFieldItem(itemId)
end end
if not allowed then if not allowed then
self:say(("%s can't learn %s!"):format( self:say(("%s can't learn %s!"):format(
mon.nickname or mon.species or "?", moveName)) require("src.battle.gen2.Mon").displayName(mon), moveName))
return return
end end
for _, move in ipairs(mon.moves or {}) do for _, move in ipairs(mon.moves or {}) do
if move.id == moveId then if move.id == moveId then
self:say(("%s already knows %s!"):format( self:say(("%s already knows %s!"):format(
mon.nickname or mon.species or "?", moveName)) require("src.battle.gen2.Mon").displayName(mon), moveName))
return return
end end
end end
@@ -1382,12 +1386,14 @@ function Game2:draw()
local zoned = type(zones) == "table" and zones[1] ~= nil local zoned = type(zones) == "table" and zones[1] ~= nil
-- A present canvas is paid for only when something reads it: the zone pass, -- 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 -- GBC FX, a mod post-process, render.compose, or an enabled render.output
-- handed the finished frame. With none of them the frame draws straight to -- subscriber. With none of them the frame draws straight to the screen
-- the screen exactly as it always did. -- exactly as it always did.
local composing = ModRuntime.wantsHook("render.compose") 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 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) scene = self:presentCanvas(1, w, h)
end end
if not scene then 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 -- untinted one. On its own the tint rides the final blit and no second
-- canvas is paid for. -- canvas is paid for.
local source = scene local source = scene
local reread = fx or Pipelines.wantsPresent() local reread = fx or Pipelines.wantsPresent() or hasOutputHook
if zoned and reread then if zoned and reread then
local tinted = self:presentCanvas(2, w, h) local tinted = self:presentCanvas(2, w, h)
if tinted then if tinted then
@@ -1439,9 +1445,18 @@ function Game2:draw()
-- Post-process pipelines run over the finished composite and before GBC -- Post-process pipelines run over the finished composite and before GBC
-- FX. Each hands back a canvas; with none registered this returns `source` -- FX. Each hands back a canvas; with none registered this returns `source`
-- unchanged and the frame is byte-identical (Renderer.lua:1058). -- 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, source = Pipelines.present(source, { width = w, height = h, scale = scale,
dpi = dpi, dpiX = dpi, dpiY = dpi }) or source dpi = dpi, dpiX = dpi, dpiY = dpi }) or source
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 if fx then
GBCFX.present(source, self:pixelScale(w, h)) GBCFX.present(source, self:pixelScale(w, h))
else else
@@ -1450,6 +1465,7 @@ function Game2:draw()
G.setShader() G.setShader()
end end
end end
end
G.pop() G.pop()
G.setColor(1, 1, 1, 1) G.setColor(1, 1, 1, 1)
self:drawHud(w, h) self:drawHud(w, h)
+49 -2
View File
@@ -832,9 +832,41 @@ local function tryMigrateLegacy(version, fs)
return id return id
end 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 -- Resolve (once per version per process) which slot in-game saves use: an
-- existing registry wins; otherwise a lazy legacy migration may create -- 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) local function ensureVersionSlots(version, fs)
if slotsChecked[version] then return end if slotsChecked[version] then return end
slotsChecked[version] = true slotsChecked[version] = true
@@ -848,7 +880,22 @@ local function ensureVersionSlots(version, fs)
activeSlotCache[version] = reg.active or reg.list[1] activeSlotCache[version] = reg.active or reg.list[1]
return return
end 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 end
-- (body for the forward-declared saveNames.) Resolves the ACTIVE slot for -- (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() local function resolveMount()
if physfsMountFn ~= nil then return physfsMountFn end if physfsMountFn ~= nil then return physfsMountFn end
physfsMountFn = false 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") local ok, ffi = pcall(require, "ffi")
if not ok then return physfsMountFn end if not ok then return physfsMountFn end
pcall(ffi.cdef, pcall(ffi.cdef,
@@ -159,6 +161,8 @@ local physfsUnmountFn = nil
local function resolveUnmount() local function resolveUnmount()
if physfsUnmountFn ~= nil then return physfsUnmountFn end if physfsUnmountFn ~= nil then return physfsUnmountFn end
physfsUnmountFn = false 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") local ok, ffi = pcall(require, "ffi")
if not ok then return physfsUnmountFn end if not ok then return physfsUnmountFn end
pcall(ffi.cdef, "int PHYSFS_unmount(const char *oldDir);") pcall(ffi.cdef, "int PHYSFS_unmount(const char *oldDir);")
+1146 -211
View File
File diff suppressed because it is too large Load Diff
+388 -9
View File
@@ -344,6 +344,14 @@ local function readExternalPath(path)
return data return data
end 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 function readDroppedFile(file)
local ok, openError = file:open("r") local ok, openError = file:open("r")
if not ok then return nil, openError end if not ok then return nil, openError end
@@ -981,6 +989,25 @@ local function findPendingSav(preferAny, skip)
return nil return nil
end 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, -- 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, -- 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 -- so the next tap re-runs the same failing file and the picker never reopens
@@ -1112,6 +1139,39 @@ local function chooseSav()
return nil return nil
end 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, -- The self-updater only surfaces on the real distributed build: a fused,
-- interactive launcher with no scripted-run override. A dev / source checkout -- interactive launcher with no scripted-run override. A dev / source checkout
-- (unfused, where Boot.run already no-ops) or an autopilot / driver / -- (unfused, where Boot.run already no-ops) or an autopilot / driver /
@@ -1228,9 +1288,11 @@ function RomImporter.new(onComplete, opts)
saveNotice = {}, saveNotice = {},
-- MODS panel state (pass 3): mods is the cached LauncherMods.list() array -- MODS panel state (pass 3): mods is the cached LauncherMods.list() array
-- (refreshed lazily on first draw and after any toggle/install/delete); -- (refreshed lazily on first draw and after any toggle/install/delete);
-- modScroll is the list scroll offset (px, clamped in draw); modNotice is -- modScroll is the current paged list's inner scroll offset (px, clamped
-- the last install/delete result { ok, text } shown as a line above the list. -- in draw); modNotice is the last install/delete result { ok, text }.
mods = nil, modScroll = 0, modNotice = nil, -- 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 = -- Which game the MODS panel is answering for (a GameVersion id, nil =
-- every game). Rows resolve their enable-state and their "runs here" -- every game). Rows resolve their enable-state and their "runs here"
-- verdict against it (src/mods/ModTargets.lua). -- verdict against it (src/mods/ModTargets.lua).
@@ -1420,7 +1482,13 @@ function RomImporter:focus(f)
local text = "Could not read the picked file. Reopen the picker and choose " local text = "Could not read the picked file. Reopen the picker and choose "
.. "it with the Files (Documents) app, or copy it into: " .. "it with the Files (Documents) app, or copy it into: "
.. love.filesystem.getSaveDirectory() .. 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 } self.modNotice = { ok = false, text = text }
elseif pickError:find("picked_save", 1, true) then elseif pickError:find("picked_save", 1, true) then
local version = self.androidPendingVersion or self:_savedropTarget() local version = self.androidPendingVersion or self:_savedropTarget()
@@ -1431,6 +1499,20 @@ function RomImporter:focus(f)
end end
return return
end 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) local modName = findPendingMod(false, self.pickSkip)
if modName then if modName then
self:_installMod(modName) self:_installMod(modName)
@@ -1649,7 +1731,7 @@ end
function RomImporter:_installMod(source) function RomImporter:_installMod(source)
if self.workState == "working" then return end if self.workState == "working" then return end
self.tab = "mods" self.tab = "mods"
local ok, installed, res = pcall(function() local ok, installed, res, manifest = pcall(function()
local LauncherMods = require("src.mods.LauncherMods") local LauncherMods = require("src.mods.LauncherMods")
return LauncherMods.installZip(source) return LauncherMods.installZip(source)
end) end)
@@ -1661,6 +1743,17 @@ function RomImporter:_installMod(source)
if installed then if installed then
pcall(self._refreshMods, self) pcall(self._refreshMods, self)
self.modNotice = { ok = true, text = "Installed " .. tostring(res) } 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 else
self.modNotice = { ok = false, text = tostring(res) } self.modNotice = { ok = false, text = tostring(res) }
end end
@@ -1727,6 +1820,152 @@ function RomImporter:chooseMod()
if path then self:_installMod(path) end if path then self:_installMod(path) end
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 -- 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 -- 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 -- showing, default to red -- the always-present first game -- rather than
@@ -2034,7 +2273,8 @@ function RomImporter:_pollPickedFiles(dt)
if not found then if not found then
for _, name in ipairs(love.filesystem.getDirectoryItems("")) do for _, name in ipairs(love.filesystem.getDirectoryItems("")) do
local n = name:lower() 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 found = true
break break
end end
@@ -2163,7 +2403,12 @@ function RomImporter:update(dt)
local version = self.pickerPendingVersion local version = self.pickerPendingVersion
self.pickerPendingKind = nil self.pickerPendingKind = nil
self.pickerPendingVersion = 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) self:_installMod(path)
if Platform.isUWP() and self.modNotice and self.modNotice.ok then if Platform.isUWP() and self.modNotice and self.modNotice.ok then
os.remove(path) os.remove(path)
@@ -2185,7 +2430,10 @@ function RomImporter:update(dt)
local version = self.pickerPendingVersion or self:_savedropTarget() local version = self.pickerPendingVersion or self:_savedropTarget()
self.pickerPendingKind = nil self.pickerPendingKind = nil
self.pickerPendingVersion = 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 } self.modNotice = { ok = false, text = errorText }
elseif kind == "sav" then elseif kind == "sav" then
self.saveNotice[version] = { ok = false, text = errorText } self.saveNotice[version] = { ok = false, text = errorText }
@@ -2345,6 +2593,20 @@ function RomImporter:_updatePadCursor(dt)
local ny = self._padCursor.y + dy * speed * dt local ny = self._padCursor.y + dy * speed * dt
self._padCursor.x = math.max(ox, math.min(ox + w, nx)) self._padCursor.x = math.max(ox, math.min(ox + w, nx))
self._padCursor.y = math.max(oy, math.min(oy + h, ny)) 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. -- Desktop: FlexLove polls the real mouse, so warp it with the pad pointer.
-- NX: the getPosition bridge already returns pad coords — skip setPosition. -- NX: the getPosition bridge already returns pad coords — skip setPosition.
if not self.isNX and love.mouse.setPosition then if not self.isNX and love.mouse.setPosition then
@@ -2716,6 +2978,43 @@ function RomImporter:fileUrl(path)
end end
function RomImporter:keypressed(key) 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 self._settingsText then
if key == "backspace" then if key == "backspace" then
self._settingsText.text = utf8Back(self._settingsText.text) self._settingsText.text = utf8Back(self._settingsText.text)
@@ -2758,7 +3057,7 @@ function RomImporter:keypressed(key)
return return
end end
if self._modConfirm or self._modVersions or self._modReleaseNotes 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 -- Focus navigation belongs to the visible modal as well as the launcher
-- beneath it. Route arrows and an already-armed confirm before this guard -- beneath it. Route arrows and an already-armed confirm before this guard
-- returns; unarmed Enter still falls through to the modal guard. Keep this -- returns; unarmed Enter still falls through to the modal guard. Keep this
@@ -2771,6 +3070,8 @@ function RomImporter:keypressed(key)
self._findDetails = nil self._findDetails = nil
elseif self._modReleaseNotes then elseif self._modReleaseNotes then
self._modReleaseNotes = nil self._modReleaseNotes = nil
elseif self._appPatchNotes then
self._appPatchNotes = nil
else else
self._modConfirm = nil self._modConfirm = nil
self._modVersions = nil self._modVersions = nil
@@ -2885,6 +3186,14 @@ function RomImporter:_commitRename()
end end
function RomImporter:textinput(text) 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 if self._settingsText then
local st = self._settingsText local st = self._settingsText
st.text = utf8Cap(st.text .. text, st.maxLen or MAX_SLOT_LABEL) st.text = utf8Cap(st.text .. text, st.maxLen or MAX_SLOT_LABEL)
@@ -3371,6 +3680,76 @@ function RomImporter:_pumpModInstall()
else else
self.modNotice = { ok = true, text = text } self.modNotice = { ok = true, text = text }
end 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 end
function RomImporter:_confirmModUpdate(modId, release) function RomImporter:_confirmModUpdate(modId, release)
+120 -11
View File
@@ -9,15 +9,16 @@
-- cable pull. -- cable pull.
-- --
-- Cable rules: no experience, no money, no items; either side may RUN -- 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 -- (a draw); a fainted mon is replaced from the party menu and the chosen
-- member (the original prompts; documented divergence). Badge stat -- slot rides the wire, the way ChooseNextMon hands it to
-- boosts don't apply on either side (divergence: Gen 1 famously kept -- LinkBattleExchangeData. Badge stat boosts don't apply on either side
-- them in link battles). -- (divergence: Gen 1 famously kept them in link battles).
local Fingerprint = require("src.link.Fingerprint") local Fingerprint = require("src.link.Fingerprint")
local Font = require("src.render.Font") local Font = require("src.render.Font")
local Handshake = require("src.link.Handshake") local Handshake = require("src.link.Handshake")
local Logger = require("src.core.Logger") local Logger = require("src.core.Logger")
local Party = require("src.pokemon.Party")
local Protocol = require("src.link.Protocol") local Protocol = require("src.link.Protocol")
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
local TurnOrder = require("src.battle.TurnOrder") local TurnOrder = require("src.battle.TurnOrder")
@@ -257,7 +258,9 @@ function LinkBattle.new(game, net, opts)
self.enemyParty = theirParty self.enemyParty = theirParty
self.playerParty = myParty -- intro ball row uses the clamped copies self.playerParty = myParty -- intro ball row uses the clamped copies
self.opponentName = theirName 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.remoteHashes = {}
self.localHashes = {} self.localHashes = {}
self.remoteParts = {} self.remoteParts = {}
@@ -327,6 +330,68 @@ function LinkBattle.new(game, net, opts)
end) end)
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 -- decode a remote action message against the enemy battler
local function decodeTheirAction(s, msg) local function decodeTheirAction(s, msg)
return decodeWireAction(s, msg, s.enemy) return decodeWireAction(s, msg, s.enemy)
@@ -540,12 +605,17 @@ function LinkBattle.new(game, net, opts)
submit(s, { type = "action", kind = "run" }, nil) submit(s, { type = "action", kind = "run" }, nil)
end end
-- fainted mons auto-replace with the next healthy teammate, in party -- linkChoiceQueued: awaitReplacement already pre-empted itself with this
-- order, identically on both machines -- 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) self.playerMonFainted = function(s)
if s.linkChoiceQueued then
s.linkChoiceQueued = nil
return
end
for _, mon in ipairs(myParty) do for _, mon in ipairs(myParty) do
if mon.hp > 0 then if mon.hp > 0 then
s:act(function() sendOutPlayer(s, mon) end) if not s.result then chooseReplacement(s) end
return return
end end
end end
@@ -558,7 +628,7 @@ function LinkBattle.new(game, net, opts)
self.enemyMonFainted = function(s) self.enemyMonFainted = function(s)
for _, mon in ipairs(theirParty) do for _, mon in ipairs(theirParty) do
if mon.hp > 0 then if mon.hp > 0 then
s:act(function() sendOutEnemy(s, mon) end) if not s.result then awaitReplacement(s) end
return return
end end
end end
@@ -579,6 +649,9 @@ function LinkBattle.new(game, net, opts)
s.remoteHashes[msg.turn or 0] = msg.value s.remoteHashes[msg.turn or 0] = msg.value
s.remoteParts[msg.turn or 0] = msg.parts s.remoteParts[msg.turn or 0] = msg.parts
checkHashes(s) 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 elseif msg.type == "bye" then
-- only a draw if our own simulation hasn't already decided -- only a draw if our own simulation hasn't already decided
-- (the winner's bye can arrive while we're still animating) -- (the winner's bye can arrive while we're still animating)
@@ -770,6 +843,34 @@ function LinkBattle.newSpectator(game, net, opts)
end) end)
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) local function resolveSpecTurn(s, hostMsg, guestMsg)
if hostMsg.kind == "run" or guestMsg.kind == "run" then if hostMsg.kind == "run" or guestMsg.kind == "run" then
endSpectate(s, "The match ended.") endSpectate(s, "The match ended.")
@@ -840,7 +941,7 @@ function LinkBattle.newSpectator(game, net, opts)
self.playerMonFainted = function(s) self.playerMonFainted = function(s)
for _, mon in ipairs(hostParty) do for _, mon in ipairs(hostParty) do
if mon.hp > 0 then if mon.hp > 0 then
s:act(function() sendOutHost(s, mon) end) if not s.result then awaitHostReplacement(s) end
return return
end end
end end
@@ -852,7 +953,7 @@ function LinkBattle.newSpectator(game, net, opts)
self.enemyMonFainted = function(s) self.enemyMonFainted = function(s)
for _, mon in ipairs(guestParty) do for _, mon in ipairs(guestParty) do
if mon.hp > 0 then if mon.hp > 0 then
s:act(function() sendOutGuest(s, mon) end) if not s.result then awaitGuestReplacement(s) end
return return
end end
end end
@@ -862,6 +963,7 @@ function LinkBattle.newSpectator(game, net, opts)
end end
self.hostMsg, self.guestMsg = nil, nil self.hostMsg, self.guestMsg = nil, nil
self.hostReplace, self.guestReplace = {}, {}
local baseUpdate = self.update local baseUpdate = self.update
self.update = function(s, dt) self.update = function(s, dt)
net:update() net:update()
@@ -875,6 +977,13 @@ function LinkBattle.newSpectator(game, net, opts)
s.hostMsg, s.guestMsg = nil, nil s.hostMsg, s.guestMsg = nil, nil
resolveSpecTurn(s, h, g) resolveSpecTurn(s, h, g)
end 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 elseif inner.type == "bye" or inner.type == "forfeit" then
if not s.result then endSpectate(s, "The match ended.") end if not s.result then endSpectate(s, "The match ended.") end
end end
+75 -5
View File
@@ -19,6 +19,7 @@ LinkState.__index = LinkState
LinkState.isOpaque = true LinkState.isOpaque = true
local CURSOR = 0xED local CURSOR = 0xED
local CURSOR_HOLLOW = 0xEC
local ANY = "ANY" -- sentinel: a leading nil array entry breaks ipairs under local ANY = "ANY" -- sentinel: a leading nil array entry breaks ipairs under
-- LuaJIT even though # still reports the full size, so -- LuaJIT even though # still reports the full size, so
-- the level picker cycles this string instead of nil, -- the level picker cycles this string instead of nil,
@@ -114,6 +115,7 @@ end
function LinkState:exitWith(message, reason) function LinkState:exitWith(message, reason)
DiscordPresence.setJoinCode(nil) DiscordPresence.setJoinCode(nil)
self.game.linkSession = nil -- back to the player's own GAME SPEED 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") }) Runtime.emit("link.ended", { reason = reason or (message and "error" or "bye") })
if self.net then self.net:close() end if self.net then self.net:close() end
self.game.stack:pop() self.game.stack:pop()
@@ -519,6 +521,9 @@ function LinkState:startMode(mode, isHost)
}) })
self.net:send(self.trade:opening()) self.net:send(self.trade:opening())
self.index = 1 self.index = 1
self.theirIndex = 1
self.side = "mine"
self.pickChoice = nil
else else
self.stage = "battleWait" self.stage = "battleWait"
-- the host deals the shared RNG seed for the lockstep simulation -- the host deals the shared RNG seed for the lockstep simulation
@@ -536,7 +541,14 @@ end
-- trade flow -- 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) function LinkState:updateTrade(input)
if self.game.linkNet == self.net then self.game.linkNet = nil end
for _, msg in ipairs(self.net:poll()) do for _, msg in ipairs(self.net:poll()) do
local reply = self.trade:handle(msg) local reply = self.trade:handle(msg)
if reply then self.net:send(reply) end if reply then self.net:send(reply) end
@@ -591,10 +603,51 @@ function LinkState:updateTrade(input)
return return
end end
if t.stage == "picking" and input:wasPressed("up") then -- 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) self.index = math.max(1, self.index - 1)
end
elseif t.stage == "picking" and input:wasPressed("down") then elseif t.stage == "picking" and input:wasPressed("down") then
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) 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 elseif self.confirmed == nil and input:wasPressed("b") then
-- once confirm=true has been sent to the peer, backing out here -- once confirm=true has been sent to the peer, backing out here
-- would desync the two sides (the peer may already be committing -- 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.net:send({ type = "bye" })
self:exitWith(Strings("The trade was\ncancelled.")) self:exitWith(Strings("The trade was\ncancelled."))
elseif t.stage == "picking" and input:wasPressed("a") then elseif t.stage == "picking" and input:wasPressed("a") then
if t:canPick(self.index) then if self.side == "theirs" then
self.net:send(t:pick(self.index)) self:openStats((t.theirParty or {})[self.theirIndex])
else
self.pickChoice = 1
end end
elseif t.stage == "confirming" and self.confirmed == nil then elseif t.stage == "confirming" and self.confirmed == nil then
if input:wasPressed("a") then if input:wasPressed("a") then
@@ -738,25 +793,40 @@ function LinkState:draw()
local label = (mon.nickname or def.name):sub(1, 8) local label = (mon.nickname or def.name):sub(1, 8)
if not t:canPick(i) then label = label .. "X" end if not t:canPick(i) then label = label .. "X" end
Font.draw(label, 16, 20 + i * 12) 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 end
Font.draw(Strings("THEIRS"), 84, 20) Font.draw(Strings("THEIRS"), 84, 20)
for i, mon in ipairs(t.theirParty or {}) do for i, mon in ipairs(t.theirParty or {}) do
local def = self.game.data.pokemon[mon.species] local def = self.game.data.pokemon[mon.species]
Font.draw((mon.nickname or def.name):sub(1, 8), 92, 20 + i * 12) 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
end
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 local hint
if t.stage == "waitRecords" then hint = "Comparing games..." if t.stage == "waitRecords" then hint = "Comparing games..."
elseif t.stage == "waitParty" then hint = "Exchanging data..." elseif t.stage == "waitParty" then hint = "Exchanging data..."
elseif t.stage == "picking" then 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" hint = t:canPick(self.index) and "Pick one to trade"
or Strings("X: not on theirs") or Strings("X: not on theirs")
end
elseif t.stage == "waitPick" then hint = "Waiting for them..." elseif t.stage == "waitPick" then hint = "Waiting for them..."
elseif t.stage == "confirming" then elseif t.stage == "confirming" then
hint = self.confirmed and "Waiting..." or Strings("A: trade B: cancel") hint = self.confirmed and "Waiting..." or Strings("A: trade B: cancel")
end end
Font.draw(hint or "", 8, 132) Font.draw(hint or "", 8, 132)
end
elseif self.stage == "battleWait" or self.stage == "battleRunning" then elseif self.stage == "battleWait" or self.stage == "battleRunning" then
drawTitle("LINK BATTLE") drawTitle("LINK BATTLE")
+3 -19
View File
@@ -16,6 +16,7 @@
local Logger = require("src.core.Logger") local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
local SafePath = require("src.mods.SafePath")
local unpack = table.unpack or unpack local unpack = table.unpack or unpack
local loadstring = loadstring or load local loadstring = loadstring or load
@@ -31,25 +32,8 @@ AssetTransform.SOURCE_ROOT = SOURCE_ROOT
AssetTransform.DERIVED_ROOT = DERIVED_ROOT AssetTransform.DERIVED_ROOT = DERIVED_ROOT
-- ------- path sandbox -- ------- path sandbox
-- shared with mod:read and the manifest's own paths (src/mods/SafePath.lua)
-- a relative path that cannot climb out of the root it is joined to local requireRelative = SafePath.require
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
-- ------- the restricted context -- ------- the restricted context
+7 -4
View File
@@ -736,8 +736,9 @@ COVERAGE["src.pokemon.Boxes"] = {
COVERAGE["src.world.WorldAPI"] = { COVERAGE["src.world.WorldAPI"] = {
kind = "alias", target = "src.world.gen2.WorldAPI", kind = "alias", target = "src.world.gen2.WorldAPI",
backed = "new __index overworld current warpTo toggleObject replaceBlock " backed = "new __index overworld current mapOverview warpTo toggleObject replaceBlock "
.. "spawnNpc removeNpc npc queueScript invalidateMap", .. "spawnNpc removeNpc npc queueScript invalidateMap "
.. "availableFieldActions useFieldAction",
warned = "setFlag getFlag", warned = "setFlag getFlag",
absent = "", absent = "",
notes = { notes = {
@@ -1797,7 +1798,8 @@ local function buildBattleState()
"throwBall", "ballChain", "tossAnimFor", "ballFlicker", "ballMissMessage", "throwBall", "ballChain", "tossAnimFor", "ballFlicker", "ballMissMessage",
"storeCaughtMon", "safariAction", "safariEnemyTurn", "drawBallRow", "storeCaughtMon", "safariAction", "safariEnemyTurn", "drawBallRow",
"drawClassic", "isWideBattleLayout", "wideLayout", "bgMode", "uiSize", "drawClassic", "isWideBattleLayout", "wideLayout", "bgMode", "uiSize",
"sgbPalettes", "trainerPalette", "trainerPicPath", "invalidate", "sgbPalettes", "trainerPalette", "trainerPicPath", "trainerTrueColor",
"trainerSprite", "invalidate",
"imageBattleScale", "resolveBattleScale", "backPlacement", "imageBattleScale", "resolveBattleScale", "backPlacement",
"frontPlacement", "StatBox", "enter", "exit", "frontPlacement", "StatBox", "enter", "exit",
}) do }) do
@@ -1884,7 +1886,8 @@ COVERAGE["src.battle.BattleState"] = {
absent = "newWild newTrainer makeSafari makeGhost makeBattler resolveTurn " absent = "newWild newTrainer makeSafari makeGhost makeBattler resolveTurn "
.. "computeDamage catchAttempt runRoll enter exit sgbPalettes " .. "computeDamage catchAttempt runRoll enter exit sgbPalettes "
.. "isWideBattleLayout wideLayout bgMode uiSize letterboxWhite " .. "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 " .. "backPlacement frontPlacement StatBox drawClassic drawBallRow "
.. "safariAction safariEnemyTurn throwBall storeCaughtMon field ruleset " .. "safariAction safariEnemyTurn throwBall storeCaughtMon field ruleset "
.. "rng oppClass partyIndex aiUses introText dead", .. "rng oppClass partyIndex aiUses introText dead",
+402 -5
View File
@@ -38,9 +38,12 @@ local Version = require("src.core.Version")
local SaveData = require("src.core.SaveData") local SaveData = require("src.core.SaveData")
local GameVersion = require("src.core.GameVersion") local GameVersion = require("src.core.GameVersion")
local CacheFs = require("src.import.CacheFs") local CacheFs = require("src.import.CacheFs")
local RequiredImports = require("src.mods.RequiredImports")
local LauncherMods = {} local LauncherMods = {}
local discover -- forward declaration for helper functions above line 343
-- ------- pure status derivation -- ------- pure status derivation
-- A hard-dependency / conflict / version verdict for one manifest. mods is -- A hard-dependency / conflict / version verdict for one manifest. mods is
@@ -85,6 +88,7 @@ local function statusFor(mods, id, enabledSet, enabled, version, forcedFor)
-- resolveToggle would cascade-enable a merely-disabled dep rather than flag -- resolveToggle would cascade-enable a merely-disabled dep rather than flag
-- it, so the disabled case is judged straight off the manifest here. -- it, so the disabled case is judged straight off the manifest here.
for _, spec in ipairs(m.dependencySpecs or {}) do for _, spec in ipairs(m.dependencySpecs or {}) do
if ModTargets.specApplies(spec, version) then
local dep = mods[spec.id] local dep = mods[spec.id]
if not dep then if not dep then
return "warn", "Needs " .. spec.id .. " (not installed)" return "warn", "Needs " .. spec.id .. " (not installed)"
@@ -102,9 +106,172 @@ local function statusFor(mods, id, enabledSet, enabled, version, forcedFor)
return "warn", "Needs " .. spec.id .. " " .. spec.range return "warn", "Needs " .. spec.id .. " " .. spec.range
end end
end end
end
return "ok", "Ready" return "ok", "Ready"
end 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. -- deriveList(manifests, options [, version]) -> the panel row list, pure.
-- manifests is an array of validated manifests (Manifest.validate output); -- manifests is an array of validated manifests (Manifest.validate output);
-- options is the options table (options.mods, options.modsByVersion and -- options is the options table (options.mods, options.modsByVersion and
@@ -165,6 +332,8 @@ function LauncherMods.deriveList(manifests, options, version)
statusDetail = detail, statusDetail = detail,
github = m.github, github = m.github,
experimental = m.experimental == true, experimental = m.experimental == true,
dependencySpecs = m.dependencySpecs,
manifest = m,
-- what game this mod is for, and whether it will run on the one the -- what game this mod is for, and whether it will run on the one the
-- panel is showing (src/mods/ModTargets.lua) -- panel is showing (src/mods/ModTargets.lua)
targets = ModTargets.chip(m), targets = ModTargets.chip(m),
@@ -252,7 +421,7 @@ end
-- Scan "mods/" one level deep for valid manifests (mirrors Loader:_discover, -- 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 -- but validates only -- no entry chunk is ever loaded). First id wins on a
-- duplicate. Returns an array of validated manifests. -- duplicate. Returns an array of validated manifests.
local function discover() discover = function()
local fs = love and love.filesystem local fs = love and love.filesystem
local out = {} local out = {}
if not (fs and fs.getInfo and fs.getDirectoryItems) then return out end 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 ok, result = pcall(function()
local options = SaveData.loadOptions() local options = SaveData.loadOptions()
local manifests = discover() 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 -- The first build containing game-specific switches turns the old shared
-- state into one explicit answer per installed mod and game. Saving here -- state into one explicit answer per installed mod and game. Saving here
-- means users who only visit the launcher still receive the migration. -- means users who only visit the launcher still receive the migration.
if SaveData.migrateModEnablement(options, manifests) then if SaveData.migrateModEnablement(options, manifests) then
SaveData.saveOptions(options) SaveData.saveOptions(options)
end 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) end)
if not ok then if not ok then
-- a single bad options/mod file must not blank the launcher -- 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() local options = SaveData.loadOptions()
SaveData.setModEnabled(options, id, enabled, SaveData.modScope(version)) SaveData.setModEnabled(options, id, enabled, SaveData.modScope(version))
SaveData.saveOptions(options) SaveData.saveOptions(options)
LauncherMods.syncActiveProfile(options)
return true return true
end end
@@ -409,6 +604,7 @@ function LauncherMods.setAllEnabled(ids, enabled, version)
end end
end end
SaveData.saveOptions(options) SaveData.saveOptions(options)
LauncherMods.syncActiveProfile(options)
return true return true
end end
@@ -507,6 +703,26 @@ local function copyTree(src, dst)
return true return true
end 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 -- Delete an installed mod subtree. Enumeration stays on love.filesystem (the
-- portable game folder is on its read path), but the deletes go through -- 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 -- 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 / -- opts.replace = true uninstalls an existing same-id mod first (updates /
-- rollbacks). opts.expectId, when set, refuses a zip whose manifest id differs. -- rollbacks). opts.expectId, when set, refuses a zip whose manifest id differs.
function LauncherMods.installZip(source, opts) 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 if not ok then return nil, "import failed: " .. tostring(result) end
return result, err return result, res2, res3
end end
function LauncherMods._installZipInner(source, opts) function LauncherMods._installZipInner(source, opts)
@@ -749,14 +965,28 @@ function LauncherMods._installZipInner(source, opts)
return nil, ("zip is for '%s', expected '%s'") return nil, ("zip is for '%s', expected '%s'")
:format(manifest.id, opts.expectId) :format(manifest.id, opts.expectId)
end 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 dest = "mods/" .. manifest.id
local baseromRecovery = "imports/baseroms-recovery/" .. manifest.id
local existing, installedSomewhere = sameIdTrees(fs, manifest.id) local existing, installedSomewhere = sameIdTrees(fs, manifest.id)
if installedSomewhere and not opts.replace then if installedSomewhere and not opts.replace then
cleanup() cleanup()
return nil, "a mod named '" .. manifest.id .. "' is already installed" return nil, "a mod named '" .. manifest.id .. "' is already installed"
end 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 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 -- drop every old tree before copy -- mods/<id> and any same-id folder
-- under another name, or the survivor keeps winning discover()'s -- under another name, or the survivor keeps winning discover()'s
-- first-id-wins race after the "successful" update (#801). A tree with -- first-id-wins race after the "successful" update (#801). A tree with
@@ -780,13 +1010,35 @@ function LauncherMods._installZipInner(source, opts)
CacheFs.prefix = "" CacheFs.prefix = ""
local copied, copyErr = copyTree(root, dest) local copied, copyErr = copyTree(root, dest)
if not copied then removeTree(dest) end 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 CacheFs.prefix = savedPrefix
if not copied then if not copied then
cleanup() cleanup()
return nil, copyErr or "could not copy the mod files" return nil, copyErr or "could not copy the mod files"
end end
cleanup() cleanup()
return true, manifest.id return true, manifest.id, manifest
end end
-- Install (or replace) a mod from a GitHub release zip URL. -- Install (or replace) a mod from a GitHub release zip URL.
@@ -912,4 +1164,149 @@ function LauncherMods.uninstall(id)
return true return true
end 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 return LauncherMods
+175 -26
View File
@@ -4,6 +4,7 @@ local SaveData = require("src.core.SaveData")
local Data = require("src.core.Data") local Data = require("src.core.Data")
local GameVersion = require("src.core.GameVersion") local GameVersion = require("src.core.GameVersion")
local Version = require("src.core.Version") local Version = require("src.core.Version")
local RequiredImports = require("src.mods.RequiredImports")
local Assets = require("src.render.Assets") local Assets = require("src.render.Assets")
local ModUI = require("src.ui.ModUI") local ModUI = require("src.ui.ModUI")
local DateTime = require("src.core.DateTime") local DateTime = require("src.core.DateTime")
@@ -12,12 +13,15 @@ local Manifest = require("src.mods.Manifest")
local Merge = require("src.mods.Merge") local Merge = require("src.mods.Merge")
local ModTargets = require("src.mods.ModTargets") local ModTargets = require("src.mods.ModTargets")
local Registry = require("src.mods.Registry") 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 Schemas = require("src.mods.Schemas")
local Semver = require("src.mods.Semver") local Semver = require("src.mods.Semver")
local Events = require("src.mods.Events") local Events = require("src.mods.Events")
local Gen2Compat = require("src.mods.Gen2Compat") local Gen2Compat = require("src.mods.Gen2Compat")
local Hooks = require("src.mods.Hooks") local Hooks = require("src.mods.Hooks")
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
local Steps = require("src.mods.Steps")
local Loader = {} local Loader = {}
Loader.__index = Loader Loader.__index = Loader
@@ -72,10 +76,12 @@ local function orderedIds(mods, filter)
return ids return ids
end end
-- ------- dev-mode permissions tripwire -- ------- the require gate
-- Attribution only: the shim delegates unconditionally and blocks nothing. -- Two jobs in one interposition. The engine_internals/network scan is
-- Installed once per process and only when the loader runs in dev mode, so a -- attribution only and stays dev-mode: it warns and delegates. The
-- player build has zero interposition. -- 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 } local devShim = { installed = false, permissions = {}, warned = {}, depth = 0 }
@@ -135,7 +141,8 @@ end
local function scanRequire(name) local function scanRequire(name)
local modId = Runtime.currentMod 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 granted = devShim.permissions[modId] or {}
local function warnOnce(permission) local function warnOnce(permission)
local key = modId .. "|" .. permission .. "|" .. name local key = modId .. "|" .. permission .. "|" .. name
@@ -188,6 +195,7 @@ function Loader:_installDevShim()
for id, mod in pairs(self.mods) do for id, mod in pairs(self.mods) do
devShim.permissions[id] = mod.manifest.permissionSet devShim.permissions[id] = mod.manifest.permissionSet
end end
devShim.dev = self.dev
if devShim.installed then return end if devShim.installed then return end
devShim.installed = true devShim.installed = true
local delegate = require local delegate = require
@@ -195,12 +203,22 @@ function Loader:_installDevShim()
-- only the mod's own call is the mod's doing; whatever that module -- only the mod's own call is the mod's doing; whatever that module
-- requires in turn is the engine wiring itself up -- requires in turn is the engine wiring itself up
if devShim.depth == 0 then 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. -- 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 -- Engine code keeps the real module: src/render/PaletteFX.lua:776
-- requires src.core.Game on both generations and means it. -- requires src.core.Game on both generations and means it.
if devShim.generation ~= 1 and Gen2Compat.serves(name) 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) local adapter = Gen2Compat.resolve(name, Runtime.currentMod)
if adapter then if adapter then
local key = "adapter|" .. name local key = "adapter|" .. name
@@ -235,7 +253,7 @@ function Loader.new(opts)
events = Events.new(), hooks = Hooks.new(), content = {}, assets = {}, events = Events.new(), hooks = Hooks.new(), content = {}, assets = {},
exports = {}, migrations = {}, order = {}, exports = {}, migrations = {}, order = {},
modSave = {}, modOptions = {}, optionSchemas = {}, imageCache = {}, modSave = {}, modOptions = {}, optionSchemas = {}, imageCache = {},
modInput = {}, modInput = {}, modEnv = {}, stepsQueues = {},
fs = (opts and opts.fs) or (love and love.filesystem), fs = (opts and opts.fs) or (love and love.filesystem),
dev = dev, dev = dev,
-- Which generation this boot is (1 or 2). Fixed at construction: the -- Which generation this boot is (1 or 2). Fixed at construction: the
@@ -357,12 +375,14 @@ function Loader:_writeOptionSchemas()
-- demand; using it here means older mods do not need to migrate to -- demand; using it here means older mods do not need to migrate to
-- mod.options:define just to appear in a launcher settings screen. -- mod.options:define just to appear in a launcher settings screen.
if schema == nil and mod.manifest.options_schema and self.fs.load then if schema == nil and mod.manifest.options_schema and self.fs.load then
local chunk = self.fs.load(mod.path .. "/" .. mod.manifest.options_schema) local ok, rows = pcall(function()
if chunk then local path = SafePath.join(mod.path, mod.manifest.options_schema,
local ok, rows = pcall(chunk) "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 if ok and type(rows) == "table" then schema = rows end
end end
end
if schema ~= nil then if schema ~= nil then
mods[id] = schema mods[id] = schema
end end
@@ -546,7 +566,24 @@ function Loader:_validate()
elseif manifest.assets_transforms elseif manifest.assets_transforms
and not self:_exists(mod.path .. "/" .. manifest.assets_transforms) then and not self:_exists(mod.path .. "/" .. manifest.assets_transforms) then
reason = "assets_transforms file missing: " .. manifest.assets_transforms 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) local ok, err = Semver.satisfies(Version.engine, manifest.game_version)
if not ok then if not ok then
reason = ("needs game version %s, engine is %s") reason = ("needs game version %s, engine is %s")
@@ -561,12 +598,15 @@ end
-- hard dependencies must exist, be enabled, have survived, and satisfy their -- hard dependencies must exist, be enabled, have survived, and satisfy their
-- range; run to a fixpoint so failures propagate to dependents transitively -- range; run to a fixpoint so failures propagate to dependents transitively
function Loader:_enforceDependencies() function Loader:_enforceDependencies()
local targetVersion = self:_targetVersion()
local generation = self.generation
local changed = true local changed = true
while changed do while changed do
changed = false changed = false
for _, id in ipairs(orderedIds(self.mods, isActive)) do for _, id in ipairs(orderedIds(self.mods, isActive)) do
local mod = self.mods[id] local mod = self.mods[id]
for _, spec in ipairs(mod.manifest.dependencySpecs) do for _, spec in ipairs(mod.manifest.dependencySpecs) do
if ModTargets.specApplies(spec, targetVersion, generation) then
local dep = self.mods[spec.id] local dep = self.mods[spec.id]
local reason, skip local reason, skip
if not dep then if not dep then
@@ -601,11 +641,14 @@ function Loader:_enforceDependencies()
end end
end end
end end
end
end end
-- Tarjan SCC over the hard-dependency graph: only a cycle's own members -- Tarjan SCC over the hard-dependency graph: only a cycle's own members
-- fail, so an unrelated mod beside a cycle still loads -- fail, so an unrelated mod beside a cycle still loads
function Loader:_failCycles() function Loader:_failCycles()
local targetVersion = self:_targetVersion()
local generation = self.generation
local mods = self.mods local mods = self.mods
local counter, stack, onStack, index, low = 0, {}, {}, {}, {} local counter, stack, onStack, index, low = 0, {}, {}, {}, {}
local cycles = {} local cycles = {}
@@ -616,6 +659,7 @@ function Loader:_failCycles()
onStack[id] = true onStack[id] = true
local selfEdge = false local selfEdge = false
for _, spec in ipairs(mods[id].manifest.dependencySpecs) do for _, spec in ipairs(mods[id].manifest.dependencySpecs) do
if ModTargets.specApplies(spec, targetVersion, generation) then
local dep = mods[spec.id] local dep = mods[spec.id]
if spec.id == id then selfEdge = true end if spec.id == id then selfEdge = true end
if dep and isActive(dep) and spec.id ~= id then if dep and isActive(dep) and spec.id ~= id then
@@ -627,6 +671,7 @@ function Loader:_failCycles()
end end
end end
end end
end
if low[id] == index[id] then if low[id] == index[id] then
local component = {} local component = {}
repeat repeat
@@ -674,6 +719,8 @@ end
-- Kahn over the surviving graph with the ready set kept in (priority, id) -- 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 -- order, so dependencies come first and the rest matches the v1 contract
function Loader:_order() function Loader:_order()
local targetVersion = self:_targetVersion()
local generation = self.generation
local pending, indegree, dependents = {}, {}, {} local pending, indegree, dependents = {}, {}, {}
for _, id in ipairs(orderedIds(self.mods, isActive)) do for _, id in ipairs(orderedIds(self.mods, isActive)) do
pending[id], indegree[id] = true, 0 pending[id], indegree[id] = true, 0
@@ -686,9 +733,17 @@ function Loader:_order()
dependents[depId][#dependents[depId] + 1] = id dependents[depId][#dependents[depId] + 1] = id
indegree[id] = indegree[id] + 1 indegree[id] = indegree[id] + 1
end 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 -- 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 end
local ordered = {} local ordered = {}
local function nextId() local function nextId()
@@ -980,6 +1035,40 @@ function Loader:_api(mod)
return DateTime.dateTime(game, timestamp) return DateTime.dateTime(game, timestamp)
end, end,
}, },
-- The read-only part of love.system that device UIs legitimately need.
-- Do not expose the module: openURL and clipboard access stay sandboxed.
device = {
powerInfo = function()
local getPowerInfo = love and love.system and love.system.getPowerInfo
if not getPowerInfo then return "unknown", nil end
local state, percent = getPowerInfo()
return state, percent
end,
},
-- The native step bridge (#1186), behind the "steps" permission the
-- player sees in the mod manager: sync asks the platform to refresh
-- its count, poll hands this mod its copy of what the bridge
-- delivered. The engine owns the pending file -- a mod never names a
-- path, it only receives { steps, from, to }. available() answers
-- false without the permission (a probe stays quiet); the calls that
-- would do something name the missing permission instead, the way the
-- network gate does.
steps = (function()
if mod.manifest.permissionSet.steps then
loader.stepsQueues[modId] = loader.stepsQueues[modId] or {}
return {
available = function() return Steps.available() end,
sync = function() return Steps.sync() end,
poll = function() return Steps.poll(loader, modId) end,
}
end
local function refuse()
error(('[%s] mod.steps needs the "steps" permission in '
.. "manifest.json"):format(modId), 2)
end
return { available = function() return false end,
sync = refuse, poll = refuse }
end)(),
-- namespaced per mod; M11 backs these with save.modData / -- namespaced per mod; M11 backs these with save.modData /
-- options.modOptions, the shape mods compile against is already final -- options.modOptions, the shape mods compile against is already final
save = { save = {
@@ -998,7 +1087,8 @@ function Loader:_api(mod)
bucket[key] = value bucket[key] = value
end, 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; -- engine binds version/playthrough/mod scope and portable persistence;
-- callers never receive paths or a raw filesystem handle. -- callers never receive paths or a raw filesystem handle.
storage = { storage = {
@@ -1006,6 +1096,10 @@ function Loader:_api(mod)
selected = function(_, game) return storage:selected(game) end, selected = function(_, game) return storage:selected(game) end,
write = function(_, game, key, value) return storage:write(game, key, value) end, write = function(_, game, key, value) return storage:write(game, key, value) end,
read = function(_, game, key) return storage:read(game, key) 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, list = function(_, game, prefix) return storage:list(game, prefix) end,
delete = function(_, game, key) return storage:delete(game, key) end, delete = function(_, game, key) return storage:delete(game, key) end,
}, },
@@ -1087,12 +1181,47 @@ function Loader:_api(mod)
api.content[alias] = self:_contentApi(mod, self.content[canonical], api.content[alias] = self:_contentApi(mod, self.content[canonical],
("the %s registry is deprecated; use %s"):format(alias, canonical)) ("the %s registry is deprecated; use %s"):format(alias, canonical))
end 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 -- 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 -- helpers on top, so mod.assets.pokemon and mod.assets:image both resolve
api.assets = setmetatable({ 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) image = function(_, relative)
local full = mod.path .. "/" .. relative local full = SafePath.join(mod.path, relative, "mod.assets:image")
local cached = loader.imageCache[full] local cached = loader.imageCache[full]
if cached then return cached end if cached then return cached end
assert(love and love.graphics, assert(love and love.graphics,
@@ -1101,11 +1230,16 @@ function Loader:_api(mod)
loader.imageCache[full] = image loader.imageCache[full] = image
return image return image
end, end,
list = listOwn,
info = infoOwn,
}, { __index = api.content }) }, { __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) function api:read(relative)
local path = self.path .. "/" .. relative return loader.fs.read(SafePath.join(self.path, relative, "mod:read"))
return loader.fs.read(path)
end end
api.list = listOwn
api.info = infoOwn
-- mod.world materializes on first touch, like the image helper above: a -- 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 -- 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 -- acts on is still being wired when the entry chunk runs
@@ -1148,9 +1282,21 @@ function Loader:_game()
return engineRequire("src.core.Game") return engineRequire("src.core.Game")
end end
-- The environment every chunk this mod authors runs in, built once per mod so
-- its entry file and its options_schema share one globals table.
function Loader:_modEnv(mod)
local id = mod.manifest.id
local env = self.modEnv[id]
if not env then
env = Sandbox.envFor({ modId = id, permissions = mod.manifest.permissionSet })
self.modEnv[id] = env
end
return env
end
function Loader:_loadMod(mod) function Loader:_loadMod(mod)
local path = mod.path .. "/" .. mod.manifest.entry local path = SafePath.join(mod.path, mod.manifest.entry, "manifest entry")
local chunk, err = self.fs.load(path) local chunk, err = Sandbox.loadFile(self.fs, path, self:_modEnv(mod))
if not chunk then error(err or ("unable to load " .. path)) end if not chunk then error(err or ("unable to load " .. path)) end
local api = self:_api(mod) local api = self:_api(mod)
local result = chunk(api) local result = chunk(api)
@@ -1182,6 +1328,7 @@ function Loader:_rollback(modId)
self.optionSchemas[modId] = nil self.optionSchemas[modId] = nil
self.migrations[modId] = nil self.migrations[modId] = nil
self.modSave[modId] = nil self.modSave[modId] = nil
self.stepsQueues[modId] = nil
end end
-- a mod that explicitly swears it stays link-compatible while writing into a -- a mod that explicitly swears it stays link-compatible while writing into a
@@ -1365,10 +1512,12 @@ function Loader:load(data)
-- every touch: a mod captures the facade at file scope, before Game2 has a -- every touch: a mod captures the facade at file scope, before Game2 has a
-- save or a world (src/mods/Gen2Compat.lua). -- save or a world (src/mods/Gen2Compat.lua).
Gen2Compat.bind(function() return self:_game() end) Gen2Compat.bind(function() return self:_game() end)
-- Dev mode wants the permissions tripwire; a Gold boot with mods on it wants -- Any boot with mods on it needs the gate, because require("io") is how a
-- the Gen 1-only require report, which is the difference between "the mod -- mod would walk out of Sandbox.envFor. Dev mode adds the permissions
-- does nothing" and knowing why. A Gold boot with no mods pays nothing. -- tripwire on top, and a Gold boot the Gen 1-only require report -- the
if self.dev or (self.generation ~= 1 and next(self.mods) ~= nil) then -- 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() self:_installDevShim()
end end
for _, mod in ipairs(ordered) do for _, mod in ipairs(ordered) do
+11 -4
View File
@@ -8,6 +8,8 @@ local Font = require("src.render.Font")
local GameVersion = require("src.core.GameVersion") local GameVersion = require("src.core.GameVersion")
local ModTargets = require("src.mods.ModTargets") local ModTargets = require("src.mods.ModTargets")
local Runtime = require("src.mods.Runtime") 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 SaveData = require("src.core.SaveData")
local Semver = require("src.mods.Semver") local Semver = require("src.mods.Semver")
local Version = require("src.core.Version") local Version = require("src.core.Version")
@@ -935,15 +937,20 @@ function ManagerState:schemaFor(m)
local schema = loader.optionSchemas and loader.optionSchemas[m.id] local schema = loader.optionSchemas and loader.optionSchemas[m.id]
if schema == nil and m.options_schema and m.path if schema == nil and m.options_schema and m.path
and loader.fs and loader.fs.load then and loader.fs and loader.fs.load then
local chunk = loader.fs.load(m.path .. "/" .. m.options_schema) -- mod-authored code, so it runs in the same sandbox the entry chunk does
if chunk then local ok, rows = pcall(function()
local ok, rows = pcall(chunk) 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 if ok and type(rows) == "table" then
schema = rows schema = rows
if loader.optionSchemas then loader.optionSchemas[m.id] = schema end if loader.optionSchemas then loader.optionSchemas[m.id] = schema end
end end
end end
end
return schema return schema
end end
+180 -27
View File
@@ -3,13 +3,15 @@
-- that need to stat a file, this owns shape, vocabulary and range grammar. -- that need to stat a file, this owns shape, vocabulary and range grammar.
local Logger = require("src.core.Logger") local Logger = require("src.core.Logger")
local ModTargets = require("src.mods.ModTargets") local ModTargets = require("src.mods.ModTargets")
local SafePath = require("src.mods.SafePath")
local Semver = require("src.mods.Semver") local Semver = require("src.mods.Semver")
local Version = require("src.core.Version") local Version = require("src.core.Version")
local Manifest = {} local Manifest = {}
Manifest.PROFILES = { content = true, overhaul = true, total_conversion = true } Manifest.PROFILES = { content = true, overhaul = true, total_conversion = true }
Manifest.PERMISSIONS = { network = true, filesystem = true, engine_internals = true } Manifest.PERMISSIONS = { network = true, filesystem = true,
engine_internals = true, steps = true }
-- link-relevant registries; a mod that writes into one of these while -- link-relevant registries; a mod that writes into one of these while
-- declaring affects_link = false gets an attributed warning from the loader -- declaring affects_link = false gets an attributed warning from the loader
@@ -31,26 +33,6 @@ local function violation(strict, id, message)
Logger.warn("[%s] %s", tostring(id), message) Logger.warn("[%s] %s", tostring(id), message)
end 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. -- Optional GitHub repo for launcher auto-update / other-versions.
-- Accepts "owner/repo" or a github.com URL; empty/absent means no updates. -- Accepts "owner/repo" or a github.com URL; empty/absent means no updates.
function Manifest.parseGithub(value) function Manifest.parseGithub(value)
@@ -73,6 +55,69 @@ function Manifest.parseGithub(value)
return owner .. "/" .. repo return owner .. "/" .. repo
end 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 -- conflicts + incompatible (alias) merged, first-wins on duplicate ids
local function mergeConflictLists(conflicts, incompatible) local function mergeConflictLists(conflicts, incompatible)
local seen, out = {}, {} local seen, out = {}, {}
@@ -87,13 +132,97 @@ local function mergeConflictLists(conflicts, incompatible)
return out return out
end 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, -- Drop bytes that are not valid UTF-8 (malformed sequences, overlongs,
-- surrogates, > U+10FFFF) and a leading BOM. LÖVE's text renderer raises -- 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 -- "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 -- panel may draw must be scrubbed here -- the one place every mod manifest
-- passes through -- or a single mangled description crashes the whole MODS -- passes through -- or a single mangled description crashes the whole MODS
-- panel instead of misrendering one card. -- panel instead of misrendering one card.
local function scrubUtf8(s) scrubUtf8 = function(s)
if type(s) ~= "string" then return s end if type(s) ~= "string" then return s end
s = s:gsub("^\239\187\191", "") s = s:gsub("^\239\187\191", "")
local out, i, n = {}, 1, #s local out, i, n = {}, 1, #s
@@ -144,6 +273,9 @@ function Manifest.validate(raw, path)
assert(type(raw.name) == "string" and raw.name ~= "", "manifest name is required") 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.version) == "string" and raw.version ~= "", "manifest version is required")
assert(type(raw.entry) == "string" and raw.entry ~= "", "manifest entry 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 -- 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") assert(raw.api == nil or tonumber(raw.api) ~= nil, "manifest api must be a number")
@@ -229,27 +361,45 @@ function Manifest.validate(raw, path)
local affectsLink = profile ~= "content" and not language local affectsLink = profile ~= "content" and not language
if type(raw.affects_link) == "boolean" then affectsLink = raw.affects_link end 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 if value == nil then return nil end
assert(type(value) == "string" and value ~= "", field .. " must be a file path") assert(type(value) == "string" and value ~= "", field .. " must be a file path")
return value return value
end 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 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 { return {
id = raw.id, id = raw.id,
name = raw.name, name = raw.name,
version = raw.version, version = raw.version,
entry = raw.entry, entry = entry,
api = api, api = api,
priority = tonumber(raw.priority) or 0, priority = tonumber(raw.priority) or 0,
dependencies = array(raw.dependencies), dependencies = array(raw.dependencies),
optional_dependencies = array(raw.optional_dependencies), optional_dependencies = array(raw.optional_dependencies),
conflicts = conflicts, conflicts = conflicts,
incompatible = array(raw.incompatible), incompatible = array(raw.incompatible),
dependencySpecs = parseSpecs(array(raw.dependencies), "dependencies"), dependencySpecs = parseSpecs(array(raw.dependencies), "dependencies", raw.dependency_sources),
optionalSpecs = parseSpecs(array(raw.optional_dependencies), "optional_dependencies"), optionalSpecs = parseSpecs(array(raw.optional_dependencies), "optional_dependencies", raw.dependency_sources),
conflictSpecs = parseSpecs(conflicts, "conflicts"), conflictSpecs = parseSpecs(conflicts, "conflicts"),
category = raw.category or "OTHER", category = raw.category or "OTHER",
game_version = raw.game_version, game_version = raw.game_version,
@@ -265,7 +415,10 @@ function Manifest.validate(raw, path)
permissionSet = permissionSet, permissionSet = permissionSet,
options_schema = optionalFile(raw.options_schema, "options_schema"), options_schema = optionalFile(raw.options_schema, "options_schema"),
assets_transforms = optionalFile(raw.assets_transforms, "assets_transforms"), 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, path = path,
raw = raw, raw = raw,
} }
+8 -2
View File
@@ -334,9 +334,15 @@ function ModIndex.compatIssues(entry, ctx)
local function eachSpec(spec, fn) local function eachSpec(spec, fn)
if type(spec) ~= "table" then return end if type(spec) ~= "table" then return end
for k, v in pairs(spec) do for k, v in pairs(spec) do
if type(k) == "number" and type(v) == "string" then 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("^([^@]+)@(.+)$") local id, range = v:match("^([^@]+)@(.+)$")
fn(id or v, range) fn(id or v, range, hashRepo)
end
elseif type(k) == "string" then elseif type(k) == "string" then
fn(k, type(v) == "string" and v or nil) fn(k, type(v) == "string" and v or nil)
end end
+35
View File
@@ -168,4 +168,39 @@ function ModTargets.detail(manifest, version)
ModTargets.gameLabel(version)) ModTargets.gameLabel(version))
end 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 return ModTargets
+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 -- permissions tripwire knows there is nobody to attribute to
Runtime.currentMod = nil 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) function Runtime.install(events, hooks, errors)
Runtime.events, Runtime.hooks = events, hooks Runtime.events, Runtime.hooks = events, hooks
Runtime.errors = errors Runtime.errors = errors
+38
View File
@@ -0,0 +1,38 @@
-- One relative-path grammar for every path a mod supplies: a mod names files
-- inside its own directory and nowhere else. love.filesystem (PhysFS) already
-- refuses "..", absolute paths and backslashes, but Loader.new takes an
-- injected fs that has no such floor, so the rule lives here and not in
-- whichever filesystem happens to be underneath.
local SafePath = {}
-- The normalized path, or nil when it could climb out of the root it is about
-- to be joined to. "." segments are dropped rather than rejected so a
-- manifest that says "./main.lua" still loads.
function SafePath.safe(rel)
if type(rel) ~= "string" or rel == "" then return nil end
if rel:sub(1, 1) == "/" then return nil end
if rel:find("\\", 1, true) then return nil end
if rel:match("^%a:") then return nil end -- windows drive-relative
local parts = {}
for segment in rel:gmatch("[^/]+") do
if segment == ".." then return nil end
if segment ~= "." then parts[#parts + 1] = segment end
end
if #parts == 0 then return nil end
return table.concat(parts, "/")
end
function SafePath.require(rel, what)
local safe = SafePath.safe(rel)
if not safe then
error(("%s must stay inside its root, got %q"):format(what, tostring(rel)), 0)
end
return safe
end
-- root .. "/" .. rel, with the traversal check in between
function SafePath.join(root, rel, what)
return root .. "/" .. SafePath.require(rel, what or "path")
end
return SafePath
+228
View File
@@ -0,0 +1,228 @@
-- The environment a mod's own code runs in. Every chunk a mod authors -- the
-- entry file, an options_schema, anything it hands to load() -- runs against
-- this table instead of _G, so the only paths it can name are the ones the
-- engine hands it (mod:read, mod.storage, mod.assets).
--
-- What this is and is not: raw io/os/ffi are the only way to name a file
-- outside the game tree at all, and they are absent here, so the reported
-- "any mod can rewrite anything in your home directory" hole closes by
-- construction. Inside the LÖVE tree this is defense in depth, not a security
-- boundary: an engine module reached through require, or ImageData:encode,
-- still writes in the save directory.
--
-- Lua 5.1/LuaJIT is the target, so setfenv is the mechanism; the 5.2+ arm
-- exists because AssetTransform's sandbox needed it and getting this wrong
-- silently hands the chunk the real globals.
local Runtime = require("src.mods.Runtime")
local SafePath = require("src.mods.SafePath")
local Sandbox = {}
-- Modules that hand a mod the disk, a raw socket or a fresh Lua state no
-- matter what this file removes from the environment. package.loaded.io is
-- the one call that would undo every other rule here.
local DENIED = {
io = "the filesystem", os = "the filesystem", debug = "the debug library",
package = "the module loader", ffi = "arbitrary C calls",
}
-- Same idea one level up: love.filesystem is reachable by name, and
-- love.thread starts a Lua state this sandbox has no say over.
local DENIED_PREFIX = { ["love"] = true, ["ffi"] = true }
-- The wire, which is what the network permission governs.
local NETWORK = { socket = true, enet = true, http = true, https = true,
ssl = true, mime = true, ltn12 = true }
local function head(name)
return (name:match("^([^%.]+)")) or name
end
-- nil when the require is allowed, else the message to fail it with.
function Sandbox.moduleDenial(name, permissionSet)
if type(name) ~= "string" then return nil end
local root = head(name)
local reason = DENIED[root]
if reason then
return ("%s is not available to mods (it grants %s); use mod.storage, "
.. "mod:read, mod:list and the engine API instead"):format(name, reason)
end
if DENIED_PREFIX[root] and name ~= root then
return ("%s is not available to mods; use mod.storage, mod:read, mod:list "
.. "and the engine API instead"):format(name)
end
if NETWORK[root] and not (permissionSet or {}).network then
return ("%s needs the \"network\" permission in manifest.json"):format(name)
end
return nil
end
-- ------- the love facade
-- Dropped, not narrowed: filesystem writes anywhere in the save directory
-- (including another mod's storage), thread opens a Lua state with a full
-- standard library, system.openURL launches whatever it is handed, and event
-- lets a mod quit the game out from under the player. Everything else LÖVE
-- exposes passes through, so a new module in a future LÖVE is available
-- without an edit here.
-- value is the replacement to name in the error, or true when there is none
local BLOCKED_LOVE = {
filesystem = "mod.storage, mod:read and mod:list", thread = true,
system = "mod.device:powerInfo() for battery information, mod.steps for "
.. "the step bridge", event = true,
}
local loveProxy
local function loveFacade()
if loveProxy or not _G.love then return loveProxy end
loveProxy = setmetatable({}, {
__index = function(_, key)
local hint = BLOCKED_LOVE[key]
if hint then
error(("love.%s is not available to mods%s"):format(key,
type(hint) == "string" and (", use " .. hint) or ""), 2)
end
return _G.love[key]
end,
__newindex = function(_, key)
error(("mods cannot assign love.%s"):format(tostring(key)), 2)
end,
})
return loveProxy
end
-- ------- the environment
-- Absent on purpose: io, package, dofile, loadfile, getfenv, setfenv, debug,
-- newproxy, module. os keeps only the clock -- getenv is how the reported
-- exploit found the user's home directory.
local SAFE_OS = { time = true, date = true, clock = true, difftime = true }
-- Per-mod copies, not the shared tables: a mod that assigns string.trim or
-- replaces table.insert changes its own view and nobody else's. The functions
-- are the same objects, so state behind them (math.randomseed's RNG) is
-- unaffected -- only the namespace is private.
local function copy(source)
if type(source) ~= "table" then return source end
local out = {}
for key, value in pairs(source) do out[key] = value end
return out
end
local function baseGlobals()
local safeOs = {}
for key in pairs(SAFE_OS) do safeOs[key] = os[key] end
return {
assert = assert, error = error, ipairs = ipairs, next = next,
pairs = pairs, pcall = pcall, xpcall = xpcall, select = select,
tonumber = tonumber, tostring = tostring, type = type, unpack = unpack,
rawequal = rawequal, rawget = rawget, rawset = rawset, rawlen = rawlen,
setmetatable = setmetatable, getmetatable = getmetatable, print = print,
collectgarbage = collectgarbage, _VERSION = _VERSION,
coroutine = copy(coroutine), math = copy(math), string = copy(string),
table = copy(table), bit = copy(bit), jit = jit, os = safeOs,
}
end
-- setfenv on 5.1/LuaJIT; on 5.2+ the env has to be handed to load itself, so
-- a caller there compiles through Sandbox.compile instead.
function Sandbox.bind(chunk, env)
if setfenv then setfenv(chunk, env) end
return chunk
end
-- Bytecode is unreviewable and, on LuaJIT, a way out of any sandbox built out
-- of environments. Mods ship source.
local function rejectBytecode(source, what)
if type(source) == "string" and source:sub(1, 1) == "\27" then
return nil, (what or "chunk") .. ": mods must ship Lua source, not bytecode"
end
return true
end
function Sandbox.compile(source, chunkname, env)
local ok, err = rejectBytecode(source, chunkname)
if not ok then return nil, err end
if setfenv then
local chunk, compileErr = loadstring(source, chunkname)
if not chunk then return nil, compileErr end
return setfenv(chunk, env)
end
return load(source, chunkname, "t", env)
end
-- The load() a mod sees. Lua 5.1 gives a loaded chunk the GLOBAL environment
-- rather than the caller's, so without this every sandboxed mod is one
-- load(mod:read(...)) away from the real _G -- which is exactly how the
-- multi-file mods in mods/ are written.
local function sandboxedLoad(env)
return function(chunk, chunkname)
if type(chunk) == "function" then
local parts = {}
while true do
local piece = chunk()
if piece == nil or piece == "" then break end
parts[#parts + 1] = piece
end
chunk = table.concat(parts)
end
if type(chunk) ~= "string" then return nil, "load expects a string or reader" end
return Sandbox.compile(chunk, chunkname or "=(load)", env)
end
end
-- The require a mod sees: the deny list lives here rather than on a stack
-- walk, because pcall(require, "io") puts a C frame where the walk would look.
-- Runtime.modRequire is how the loader's gate identifies the caller for the
-- Gen 2 facade once Runtime.currentMod has gone back to nil (a mod requiring
-- lazily from an event handler).
local function sandboxedRequire(modId, permissionSet)
return function(name, ...)
local denial = Sandbox.moduleDenial(name, permissionSet)
if denial then error(("[%s] %s"):format(modId or "mod", denial), 2) end
local previous = Runtime.modRequire
Runtime.modRequire = modId or true
local ok, result = pcall(_G.require, name, ...)
Runtime.modRequire = previous
if not ok then error(result, 0) end
return result
end
end
function Sandbox.envFor(opts)
opts = opts or {}
local env = baseGlobals()
env.love = loveFacade()
env.require = sandboxedRequire(opts.modId, opts.permissions)
local loader = sandboxedLoad(env)
env.load = loader
env.loadstring = loader
-- a mod's globals are its own: two mods no longer share a namespace, and
-- neither can reach the engine's
env._G = env
return env
end
-- fs.load keeps the real filesystem's handling of the file; the environment is
-- swapped after the fact. The 5.2+ arm has to go back to source, which is the
-- only reason fs.read is touched here.
function Sandbox.loadFile(fs, path, env)
if fs.read then
local ok, err = rejectBytecode(fs.read(path), path)
if not ok then return nil, err end
end
if setfenv then
local chunk, err = fs.load(path)
if not chunk then return nil, err end
return setfenv(chunk, env)
end
local source = fs.read and fs.read(path)
if not source then return nil, "unable to read " .. path end
return Sandbox.compile(source, "@" .. path, env)
end
Sandbox.safePath = SafePath.safe
Sandbox.requirePath = SafePath.require
return Sandbox
+8
View File
@@ -1017,6 +1017,9 @@ R.trainers = {
index = f.opt(f.int(0, 255)), index = f.opt(f.int(0, 255)),
-- unused vanilla classes ship without a pic, so it cannot be required -- unused vanilla classes ship without a pic, so it cannot be required
pic = f.opt(f.path), 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. -- Optional Advanced-mode OBJ palette source for a custom trainer portrait.
-- It follows the same ROM crosswalk form as sprites.paletteSource. -- It follows the same ROM crosswalk form as sprites.paletteSource.
paletteSource = f.opt(f.str), paletteSource = f.opt(f.str),
@@ -1068,6 +1071,11 @@ R.trainers = {
gen2Fields = { gen2Fields = {
id = f.opt(f.str), name = f.str, id = f.opt(f.str), name = f.str,
index = f.opt(f.int(0, 255)), 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)), baseMoney = f.opt(f.int(0)),
-- the class's battle theme; Gen 1 spells the same idea `battleTheme`, -- 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 -- 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. -- This module is engine-private; Loader exposes only the bound facade methods.
local SaveData = require("src.core.SaveData") local SaveData = require("src.core.SaveData")
@@ -7,6 +8,7 @@ local Version = require("src.core.Version")
local Storage = {} local Storage = {}
Storage.__index = Storage Storage.__index = Storage
Storage.MAX_BYTES = 512 * 1024 * 1024
local ROOT = "mod_storage" local ROOT = "mod_storage"
@@ -49,6 +51,20 @@ local function decodeAt(fs, path)
return data, body return data, body
end 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) function Storage.new(modId, fs)
assert(validSegment(modId), "Storage.new needs a safe mod id") assert(validSegment(modId), "Storage.new needs a safe mod id")
return setmetatable({ modId = modId, injectedFs = fs }, Storage) return setmetatable({ modId = modId, injectedFs = fs }, Storage)
@@ -132,6 +148,10 @@ function Storage:selected(game)
end, end,
read = function(_, key) return self:read(selectedGame, key) end, read = function(_, key) return self:read(selectedGame, key) end,
write = function(_, key, value) return self:write(selectedGame, key, value) 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, list = function(_, prefix) return self:list(selectedGame, prefix) end,
delete = function(_, key) return self:delete(selectedGame, key) end, delete = function(_, key) return self:delete(selectedGame, key) end,
} }
@@ -147,7 +167,7 @@ function Storage:context(game)
} }
end end
function Storage:_names(game, key, allowEmpty) function Storage:_names(game, key, allowEmpty, extension)
if not validKey(key, allowEmpty) then if not validKey(key, allowEmpty) then
return failure("invalid_key", return failure("invalid_key",
"Storage keys use nonempty letters, numbers, underscore, dash and slash segments.") "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) local scope, code, message = self:_scope(game)
if not scope then return nil, code, message end if not scope then return nil, code, message end
local path = scope.base .. (key ~= "" and ("/" .. key) or "") 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 end
function Storage:write(game, key, value) 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 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 if type(value) ~= "table" then
return false, "encode_failed", "Storage values must be data-only tables." return false, "encode_failed", "Storage values must be data-only tables."
end end
@@ -170,7 +197,6 @@ function Storage:write(game, key, value)
.. tostring(encoded) .. tostring(encoded)
end end
local fs = scope.fs
ensureParent(fs, main) ensureParent(fs, main)
local _, previous = decodeAt(fs, main) local _, previous = decodeAt(fs, main)
if not previous then _, previous = decodeAt(fs, bak) end if not previous then _, previous = decodeAt(fs, bak) end
@@ -206,9 +232,13 @@ function Storage:write(game, key, value)
end end
function Storage:read(game, key) 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 if not scope then return nil, main, bak end
local fs = scope.fs 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) local data, body = decodeAt(fs, main)
if data then return data end if data then return data end
@@ -226,6 +256,80 @@ function Storage:read(game, key)
return data return data
end 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) function Storage:list(game, prefix)
prefix = prefix or "" prefix = prefix or ""
local scope, main, codeOrBak = self:_names(game, prefix, true) local scope, main, codeOrBak = self:_names(game, prefix, true)
@@ -237,13 +341,23 @@ function Storage:list(game, prefix)
local base = scope.base local base = scope.base
local start = prefix == "" and base or (base .. "/" .. prefix) 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 function walk(path, logical)
local info = fs.getInfo(path) local info = fs.getInfo(path)
if not info then return end if not info then return end
if info.type == "file" then 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 return
end end
for _, child in ipairs(fs.getDirectoryItems(path) or {}) do 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. -- A prefix may identify one exact key or a directory of keys.
if fs.getInfo(start .. ".lua") then if fs.getInfo(start .. ".lua") then
out[#out + 1] = prefix add(prefix)
elseif fs.getInfo(start .. ".bin") then
add(prefix)
else else
walk(start, prefix) walk(start, prefix)
end end
@@ -263,15 +379,20 @@ function Storage:list(game, prefix)
end end
function Storage:delete(game, key) 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 if not scope then return false, main, bak end
local fs = scope.fs 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." return false, "not_found", "No stored value exists for this key."
end end
remove(fs, main) remove(fs, main)
remove(fs, bak) remove(fs, bak)
remove(fs, tmp) remove(fs, tmp)
remove(fs, byteMain)
remove(fs, byteBak)
remove(fs, byteTmp)
return true return true
end end
+4 -2
View File
@@ -140,10 +140,12 @@ function Evolution.learnEvolutionMoves(game, mon, onDone)
if #mon.moves < 4 then if #mon.moves < 4 then
table.insert(mon.moves, { id = moveId, pp = mdef.pp }) table.insert(mon.moves, { id = moveId, pp = mdef.pp })
Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId }) 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, game.stack:push(TextBox.new(game,
romText(game.data, "_LearnedMove1Text", 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 else
-- LearnMoveFromLevelUp with a full moveset: the forget UI -- LearnMoveFromLevelUp with a full moveset: the forget UI
Screens.push(game, "MoveLearnMenu", mon, moveId, nextStep) 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 -- 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 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. -- 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 local x, y = tx * 8, ty * 8
segments = math.max(1, math.floor(segments or 6)) segments = math.max(1, math.floor(segments or 6))
HudTiles.tile(0x71, x, y) HudTiles.tile(0x71, x, y)
HudTiles.tile(0x62, x + 8, y) HudTiles.tile(0x62, x + 8, y)
local px = 0 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)) px = math.max(1, math.floor(mon.hp * segments * 8 / mon.stats.hp))
end end
local tint local tint
+6 -4
View File
@@ -691,10 +691,12 @@ function PaletteFX.spriteObp(spriteDef, seed)
return PaletteFX.darkObp(w.spritePalettes[group], group) return PaletteFX.darkObp(w.spritePalettes[group], group)
end end
-- GetHealthBarColor (home/palettes.asm) on the standard 48px bar -- GetHealthBarColor (home/palettes.asm) on the standard 48px bar. It reads
function PaletteFX.barPalName(hp, maxHp) -- the bar's own length, so a caller mid-drain passes the animated `pixels`
local px = maxHp > 0 and math.floor(hp * 48 / maxHp) or 0 -- rather than let it be re-derived from hp.
if hp > 0 and px < 1 then px = 1 end 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" return px >= 27 and "GREENBAR" or px >= 10 and "YELLOWBAR" or "REDBAR"
end end
+17 -5
View File
@@ -768,11 +768,12 @@ function Renderer:endFrame(zones, worldZones)
end end
end end
-- A post-process pipeline needs the whole composite in a canvas for the -- Post-process pipelines, GBC FX and an enabled final-output owner need the
-- same reason GBC FX does, so either one alone is enough to take the -- whole composite in a canvas. With none of them, the frame draws straight
-- present path; with neither, the frame draws straight to the screen -- to the screen exactly as it always did.
-- exactly as it always did. local hasOutputHook = Runtime.wantsHook("render.output")
local needPresent = GBCFX.active() or Pipelines.wantsPresent() and Runtime.call("render.output_enabled", function() return false end) == true
local needPresent = GBCFX.active() or Pipelines.wantsPresent() or hasOutputHook
local present = nil local present = nil
if needPresent then if needPresent then
if not self.presentCanvas or self.presentCanvas:getWidth() ~= ww if not self.presentCanvas or self.presentCanvas:getWidth() ~= ww
@@ -1057,6 +1058,16 @@ function Renderer:endFrame(zones, worldZones)
-- this returns `present` unchanged and the frame is byte-identical. -- this returns `present` unchanged and the frame is byte-identical.
local composed = Pipelines.present(present, local composed = Pipelines.present(present,
{ width = ww, height = wh, scale = Sp, dpi = dpiY, dpiX = dpiX, dpiY = dpiY }) or present { width = ww, height = wh, scale = Sp, dpi = dpiY, dpiX = dpiX, dpiY = dpiY }) or present
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 if GBCFX.active() then
-- shader grid/shadow math is in framebuffer pixels -- shader grid/shadow math is in framebuffer pixels
GBCFX.present(composed, Sp) GBCFX.present(composed, Sp)
@@ -1067,6 +1078,7 @@ function Renderer:endFrame(zones, worldZones)
love.graphics.draw(composed, 0, 0) love.graphics.draw(composed, 0, 0)
end end
end end
end
self.worldActive = false self.worldActive = false
self.uprightActive = false self.uprightActive = false
self.worldOverride = nil self.worldOverride = nil
+20
View File
@@ -100,6 +100,26 @@ function TextBox.new(game, text, onDone, opts)
return self return self
end 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 -- The runtime tokens substitute() knows, as handlers the tokens registry
-- serves. Each is fn(game, arg) -> replacement, or nil to drop the token. -- 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 -- 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) require("src.core.Sound").play(ctx.game.data, soundId)
end 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 -- play_once <songId>: one-shot jingle (Music_PkmnHealed, etc.); blocks
-- until it finishes so heal-rest scripts (Mom, captain text_asm) match -- 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 -- 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, onDone = function() runner:resume() end,
}) })
runner:yield() runner:yield()
-- TradedForText (sound_get_key_item) then the dialogset's thanks -- TradedForText carries sound_get_key_item after the text, so the jingle
require("src.core.Sound").play(data, "Get_Key_Item") -- 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.tradedFor or "_TradedForText")
say(texts.thanks or "_Thanks" .. dialogset .. "Text") say(texts.thanks or "_Thanks" .. dialogset .. "Text")
end end
+8 -6
View File
@@ -33,12 +33,12 @@ local function save_name(game)
return game.save.player.name return game.save.player.name
end end
local function showMessages(game, msgs, onDone) local function showMessages(game, msgs, onDone, opts)
if not msgs or #msgs == 0 then if not msgs or #msgs == 0 then
if onDone then onDone() end if onDone then onDone() end
return return
end 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 end
-- run the use-flow for an item on a chosen target. `picker` is the party -- 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 end
if #target.moves < 4 then if #target.moves < 4 then
table.insert(target.moves, { id = moveId, pp = mdef.pp }) 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 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 if result == "learn" then consume(game, id) end
list.items = buildItems(game) list.items = buildItems(game)
list.index = math.min(list.index, math.max(1, #list.items)) 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] local mdef = game.data.moves[moveId]
if #target.moves < 4 then if #target.moves < 4 then
table.insert(target.moves, { id = moveId, pp = mdef.pp }) 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 local name = target.nickname or def.name
showMessages(game, { Strings("%s learned\n%s!", name, mdef.name) }, showMessages(game, { Strings("%s learned\n%s!", name, mdef.name) },
nextStep) nextStep, TextBox.soundOpts(game, "Get_Item1"))
else else
require("src.ui.Screens").push(game, "MoveLearnMenu", require("src.ui.Screens").push(game, "MoveLearnMenu",
target, moveId, nextStep) target, moveId, nextStep)
@@ -382,6 +383,7 @@ local function pickTargetAndUse(game, battle, id, list)
local def = game.data.items[id] local def = game.data.items[id]
local opts = { local opts = {
pickOnly = true, pickOnly = true,
battle = battle,
-- HP medicine animates its bar with the picker still up (#252). Only -- HP medicine animates its bar with the picker still up (#252). Only
-- out of battle: the in-battle tail closes the bag list underneath -- out of battle: the in-battle tail closes the bag list underneath
-- first, which needs the picker already gone. -- first, which needs the picker already gone.
+20 -6
View File
@@ -46,9 +46,27 @@ function EvolutionState:sgbPalettes(game)
return P.wholeNamed(game.data, "MEWMON") return P.wholeNamed(game.data, "MEWMON")
end end
local FLASH_FRAMES = 220
-- evolution.asm EvolveMon delays 80 frames before .animLoop, polling nothing (#968, #1031) -- evolution.asm EvolveMon delays 80 frames before .animLoop, polling nothing (#968, #1031)
local CANCEL_GRACE_FRAMES = 80 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 function frontSprite(game, species, mon)
local path, trueColor = require("src.pokemon.Sprites").path( local path, trueColor = require("src.pokemon.Sprites").path(
@@ -140,15 +158,11 @@ function EvolutionState:draw()
else else
sprite, spriteTrueColor = self.newSprite, self.newSpriteTrueColor sprite, spriteTrueColor = self.newSprite, self.newSpriteTrueColor
end end
else elseif evoShowsNew(self.t - CANCEL_GRACE_FRAMES) then
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 sprite, spriteTrueColor = self.newSprite, self.newSpriteTrueColor
else else
sprite, spriteTrueColor = self.oldSprite, self.oldSpriteTrueColor sprite, spriteTrueColor = self.oldSprite, self.oldSpriteTrueColor
end end
end
if sprite then if sprite then
local x = math.floor((160 - sprite:getWidth()) / 2) local x = math.floor((160 - sprite:getWidth()) / 2)
local y = math.max(8, 64 - sprite:getHeight()) local y = math.max(8, 64 - sprite:getHeight())
+16 -3
View File
@@ -6,6 +6,7 @@ local GameVersion = require("src.core.GameVersion")
local Music = require("src.core.Music") local Music = require("src.core.Music")
local Sound = require("src.core.Sound") local Sound = require("src.core.Sound")
local Strings = require("src.core.Strings") local Strings = require("src.core.Strings")
local Timing = require("src.core.Timing")
local IntroMovie = {} local IntroMovie = {}
IntroMovie.__index = IntroMovie IntroMovie.__index = IntroMovie
@@ -232,6 +233,10 @@ function IntroMovie:fightStep()
self:finish() self:finish()
return return
end 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 if op.sfx then
Sound.play(self.game.data, op.sfx) Sound.play(self.game.data, op.sfx)
elseif op.pose then elseif op.pose then
@@ -250,6 +255,7 @@ function IntroMovie:fightStep()
end end
self.opTimer = self.opTimer + 1 self.opTimer = self.opTimer + 1
if self.opTimer < (op.px or math.abs(op.dx)) then return end if self.opTimer < (op.px or math.abs(op.dx)) then return end
timed = true
elseif op.anim then elseif op.anim then
-- one {dy,dx} delta per 5 frames (AnimateIntroNidorino: DelayFrames 5) -- one {dy,dx} delta per 5 frames (AnimateIntroNidorino: DelayFrames 5)
if self.opTimer % 5 == 0 then if self.opTimer % 5 == 0 then
@@ -259,9 +265,11 @@ function IntroMovie:fightStep()
end end
self.opTimer = self.opTimer + 1 self.opTimer = self.opTimer + 1
if self.opTimer < #ANIM[op.anim] * 5 then return end if self.opTimer < #ANIM[op.anim] * 5 then return end
timed = true
elseif op.wait then elseif op.wait then
self.opTimer = self.opTimer + 1 self.opTimer = self.opTimer + 1
if self.opTimer < op.wait then return end if self.opTimer < op.wait then return end
timed = true
elseif op.fade then elseif op.fade then
self.opTimer = self.opTimer + 1 self.opTimer = self.opTimer + 1
self.fade = self.opTimer / op.fade self.fade = self.opTimer / op.fade
@@ -270,6 +278,7 @@ function IntroMovie:fightStep()
end end
self.opIndex = self.opIndex + 1 self.opIndex = self.opIndex + 1
self.opTimer = 0 self.opTimer = 0
if timed then return end
end end
end end
@@ -284,8 +293,9 @@ function IntroMovie:update(dt)
return return
end end
local input = self.game.input local input = self.game.input
if input:wasPressed("a") or input:wasPressed("b") if input:wasPressed("a") or input:wasPressed("start") then
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 -- PlayIntro still GBFadeOutToWhite's after an interrupted scene; the
-- white hold stands in for that beat before the title is built. -- white hold stands in for that beat before the title is built.
self:exitToTitle() self:exitToTitle()
@@ -300,7 +310,10 @@ function IntroMovie:update(dt)
end end
if self.timer >= SPLASH_FRAMES then self:startPhase(3) end if self.timer >= SPLASH_FRAMES then self:startPhase(3) end
else 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
end end
+7 -3
View File
@@ -98,14 +98,14 @@ function OakSpeech.resolvePic(game, desc, speech)
local t = desc.type local t = desc.type
if t == "trainer" then if t == "trainer" then
if speech and desc.id == "OPP_PROF_OAK" and speech.oakPic 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 end
if speech and desc.id == "OPP_RIVAL1" and speech.rivalPic then 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 end
local trainers = game.data.trainers or {} local trainers = game.data.trainers or {}
local tr = trainers[desc.id] 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 elseif t == "pokemon" then
if speech and desc.id == speech.demoSpecies and speech.demoPic then if speech and desc.id == speech.demoSpecies and speech.demoPic then
return speech.demoPic, desc.flip and true or false, speech.demoTrueColor return speech.demoPic, desc.flip and true or false, speech.demoTrueColor
@@ -241,7 +241,11 @@ function OakSpeech.new(game, onDone)
self.answers = {} self.answers = {}
local trainers = game.data.trainers or {} local trainers = game.data.trainers or {}
self.oakPic = tryImage(trainers.OPP_PROF_OAK and trainers.OPP_PROF_OAK.pic) 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.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 {} local oakGfx = (game.data.field and game.data.field.oakSpeech) or {}
self.cfg = oakGfx self.cfg = oakGfx
-- the show-off mon and the name length cap come from data; the vanilla -- the show-off mon and the name length cap come from data; the vanilla
+94 -80
View File
@@ -87,6 +87,15 @@ local function refuseUnavailable(self)
t._SleepingPikachuText1 or Strings("There isn't any\nresponse..."))) t._SleepingPikachuText1 or Strings("There isn't any\nresponse...")))
end 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 -- where DIG escapes work: escape_rope_tilesets.asm (Agatha's room is
-- excluded by map id in ItemUseEscapeRope) -- excluded by map id in ItemUseEscapeRope)
local DIG_TILESETS = { FOREST = true, CEMETERY = true, CAVERN = true, local DIG_TILESETS = { FOREST = true, CEMETERY = true, CAVERN = true,
@@ -280,6 +289,7 @@ function PartyMenu.new(game, opts)
opts = opts or {} opts = opts or {}
local self = setmetatable({}, PartyMenu) local self = setmetatable({}, PartyMenu)
self.game = game self.game = game
local party = opts.party or (opts.battle and opts.battle.playerParty)
-- PartyMenuInit (home/pokemon.asm) seeds the cursor from -- PartyMenuInit (home/pokemon.asm) seeds the cursor from
-- wPartyAndBillsPCSavedMenuItem rather than from zero, and -- wPartyAndBillsPCSavedMenuItem rather than from zero, and
-- HandlePartyMenuInput writes wCurrentMenuItem back into it on every -- 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 -- both zero the byte, which BattleState mirrors. The clamp covers a
-- party that shrank (deposit / release) while the saved index was -- party that shrank (deposit / release) while the saved index was
-- pointing past the end. #768 -- 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), self.index = math.min(math.max(1, game.partyMenuSavedIndex or 1),
math.max(1, count)) math.max(1, count))
self.onSwitch = opts.onSwitch self.onSwitch = opts.onSwitch
@@ -305,7 +315,7 @@ function PartyMenu.new(game, opts)
self.tmhm = opts.tmhm self.tmhm = opts.tmhm
self.forceSwitch = opts.forceSwitch self.forceSwitch = opts.forceSwitch
self.battle = opts.battle 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.swapFrom = nil
self.submenu = nil self.submenu = nil
self.subIndex = 1 self.subIndex = 1
@@ -411,6 +421,23 @@ function PartyMenu:update(dt)
-- flyTo (OverworldController) validates the fly-warp + runs the -- flyTo (OverworldController) validates the fly-warp + runs the
-- departure/warp, so we just hand it the chosen mapId (#195). -- departure/warp, so we just hand it the chosen mapId (#195).
local ow = self.game.overworld 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 self.game.stack:pop() -- close the party menu
Screens.push(self.game, "TownMap", { fly = true, onFly = function(mapId) Screens.push(self.game, "TownMap", { fly = true, onFly = function(mapId)
if ow then ow:flyTo(mapId) end 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 -- over the menu, and the cave is lit when the blink hands the
-- screen back, never under the text (#385). -- screen back, never under the text (#385).
local ow = self.game.overworld local ow = self.game.overworld
local TextBox = require("src.render.TextBox") if ow and not ow:partyKnows("FLASH") then
local Transition = require("src.render.Transition") refuseBadge(self)
self.game.save.flashLit = true return
self.game.stack:push(TextBox.new(self.game, end
self.game.data.text._FlashLightsAreaText ow:useFlashFieldMove(function() self:close() end)
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))
return return
elseif action == "surf" then elseif action == "surf" then
-- start_sub_menus.asm .surf: SOULBADGE-gated (checked at list time -- start_sub_menus.asm .surf: SOULBADGE-gated (useSurfFieldMove),
-- above), then IsSurfingAllowed (the Cycling Road / Seafoam B4F -- then IsSurfingAllowed (the Cycling Road / Seafoam B4F
-- current refusals, both of which loop back to the submenu), then -- current refusals, both of which loop back to the submenu), then
-- ItemUseSurfboard: while surfing it tries to dismount instead; -- ItemUseSurfboard: while surfing it tries to dismount instead;
-- otherwise it mounts only if the FACING tile is water, else -- 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 -- GBPalWhiteOutWithDelay3 blink, and the simulated pad press
-- steps the player forward onto land (or across a connection -- steps the player forward onto land (or across a connection
-- strip when the shore is the next map's edge) -- strip when the shore is the next map's edge)
self.game.stack:pop() ow:stopSurfing(function() self.game.stack:pop() end)
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))
return return
end end
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
@@ -503,8 +510,8 @@ function PartyMenu:update(dt)
return -- .loop: submenu stays open behind the message return -- .loop: submenu stays open behind the message
elseif action == "cut" then elseif action == "cut" then
-- start_sub_menus.asm .cut -> predef UsedCut (engine/overworld/cut.asm): -- start_sub_menus.asm .cut -> predef UsedCut (engine/overworld/cut.asm):
-- CASCADEBADGE-gated (list time); _NothingToCutText loops back to the -- CASCADEBADGE-gated (useCutFieldMove); _NothingToCutText loops back
-- submenu when the FACING tile isn't a cuttable tree. -- to the submenu when the FACING tile isn't a cuttable tree.
local ow = self.game.overworld local ow = self.game.overworld
local reason = ow:useCutFieldMove() local reason = ow:useCutFieldMove()
if reason == "ok" then if reason == "ok" then
@@ -522,7 +529,7 @@ function PartyMenu:update(dt)
self.game.stack:push(TextBox.new(self.game, txt)) self.game.stack:push(TextBox.new(self.game, txt))
return -- .loop: submenu stays open behind the message return -- .loop: submenu stays open behind the message
elseif action == "strength" then 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 -- predef PrintStrengthText (field_move_messages.asm) sets
-- BIT_STRENGTH_ACTIVE of wStatusFlags1 -- the sole gate -- BIT_STRENGTH_ACTIVE of wStatusFlags1 -- the sole gate
-- push_boulder.asm reads -- then prints _UsedStrengthText (no -- push_boulder.asm reads -- then prints _UsedStrengthText (no
@@ -532,26 +539,18 @@ function PartyMenu:update(dt)
-- .strength, GBPalWhiteOutWithDelay3 blinks the screen white -- .strength, GBPalWhiteOutWithDelay3 blinks the screen white
-- before CloseTextDisplay returns to the map. -- before CloseTextDisplay returns to the map.
local ow = self.game.overworld local ow = self.game.overworld
local TextBox = require("src.render.TextBox") if ow and ow.useStrengthFieldMove then
local Transition = require("src.render.Transition") if not ow:partyKnows("STRENGTH") then
local def = self.game.data.pokemon[mon.species] refuseBadge(self)
local name = mon.nickname or def.name return
ow.strengthActive = true end
local t1 = (self.game.data.text._UsedStrengthText ow:useStrengthFieldMove(mon, function() self:close() end)
or Strings("{RAM:wNameBuffer} used\nSTRENGTH.")):gsub("{RAM:wNameBuffer}", name) return
local t2 = (self.game.data.text._CanMoveBouldersText elseif ow and ow.useFieldMove then
or Strings("{RAM:wNameBuffer} can\nmove boulders.")):gsub("{RAM:wNameBuffer}", name) ow:useFieldMove("STRENGTH", mon)
-- 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:close()
self.game.stack:push(Transition.whiteFlash(self.game)) return
end)) end
end, { auto = { sound = function()
return require("src.core.Sound").playCry(self.game.data, mon.species)
end } }))
return return
elseif action == "softboiled" then elseif action == "softboiled" then
-- field SOFTBOILED (StartMenu_Pokemon .softboiled): transfer -- 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 -- centralizes the spin -> fade -> warp so BagMenu's ESCAPE ROPE shares
-- the exact departure; the fade + warp fire when the spin ends. -- the exact departure; the fade + warp fire when the spin ends.
local ow = self.game.overworld 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() self.game.stack:pop()
if ow then ow:beginTeleportOut() end if ow then ow:beginTeleportOut() end
return return
@@ -658,42 +684,30 @@ function PartyMenu:update(dt)
-- Battle still excludes this list via `not self.battle`. Softboiled -- Battle still excludes this list via `not self.battle`. Softboiled
-- can appear for a fainted user; its heal transfer then no-ops. -- can appear for a fainted user; its heal transfer then no-ops.
if not self.battle and ow then if not self.battle and ow then
-- FLY/TELEPORT: CheckIfInOutsideMap (OVERWORLD + PLATEAU -- -- GetMonFieldMoves (engine/menus/text_box.asm) matches the mon's
-- Route 23 / Indigo Plateau outdoor), not OVERWORLD alone (#83) -- four moves against FieldMoveDisplayData and nothing else -- no
local outside = Map.isOutside(ow.map.def, -- badge, no map, no tileset test. Every one of those lives in
FieldDefaults.field(self.game.data, "outsideTilesets")) -- .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 for _, mv in ipairs(mon.moves) do
if mv.id == "FLY" and outside if mv.id == "FLY" then
and self.game.save.inventory.THUNDERBADGE then
table.insert(items, { label = Strings("FLY"), action = "fly" }) table.insert(items, { label = Strings("FLY"), action = "fly" })
elseif mv.id == "FLASH" and ow.dark elseif mv.id == "FLASH" then
and self.game.save.inventory.BOULDERBADGE then
table.insert(items, { label = Strings("FLASH"), action = "flash" }) table.insert(items, { label = Strings("FLASH"), action = "flash" })
elseif mv.id == "CUT" and self.game.save.inventory.CASCADEBADGE then elseif mv.id == "CUT" 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).
table.insert(items, { label = Strings("CUT"), action = "cut" }) 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" }) 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" }) table.insert(items, { label = Strings("STRENGTH"), action = "strength" })
elseif mv.id == "SOFTBOILED" then elseif mv.id == "SOFTBOILED" then
table.insert(items, { label = Strings("SOFTBOILED"), action = "softboiled" }) table.insert(items, { label = Strings("SOFTBOILED"), action = "softboiled" })
elseif mv.id == "TELEPORT" and outside then elseif mv.id == "TELEPORT" then
-- TELEPORT works only OUTDOORS (start_sub_menus.asm table.insert(items, { label = Strings("TELEPORT"),
-- .teleport -> CheckIfInOutsideMap); dark maps don't action = "escape", move = "TELEPORT" })
-- block it elseif mv.id == "DIG" then
table.insert(items, { label = Strings("TELEPORT"), action = "escape" }) table.insert(items, { label = Strings("DIG"),
elseif mv.id == "DIG" and DIG_TILESETS[ow.map.def.tileset] action = "escape", move = "DIG" })
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" })
end end
end end
end end
+44 -6
View File
@@ -173,6 +173,31 @@ end
function BattleState:wantsFillScale() return true end function BattleState:wantsFillScale() return true end
function BattleState:drawsWidescreen() 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 -- opts: battle (a Battle), onDone(outcome), save
function BattleState.new(game, opts) function BattleState.new(game, opts)
opts = opts or {} 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 -- 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. -- stage has none and the mon stands in for the whole intro.
self.showEnemyTrainer = false self.showEnemyTrainer = false
self.enemyTrainerTrueColor = false
-- The CLASS CONSTANT (BUG_CATCHER), which is what both tables this looks the -- 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 -- pic up in are keyed by: menu_gfx's trainerPics is written out of
-- constants.trainerClassOrder, and palettes.trainers out of the same names. -- 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. -- 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 -- `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. -- 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 local enemyTrainer = self.battle and self.battle.trainer
self.enemyTrainerClass = enemyTrainer self.enemyTrainerClass = enemyTrainer
and (enemyTrainer.classId or enemyTrainer.class) and (enemyTrainer.classId or enemyTrainer.class)
local trainerPics = hudGfx and hudGfx.trainerPics local trainerPath, trainerTrueColor =
local trainerPath = self.enemyTrainerClass and trainerPics BattleState.trainerArt(data, self.enemyTrainerClass)
and trainerPics[self.enemyTrainerClass]
if trainerPath then if trainerPath then
local ok, image = pcall(Assets.image, trainerPath) local ok, image = pcall(Assets.image, trainerPath)
if ok and image then if ok and image then
self.enemyTrainerImage = image self.enemyTrainerImage = image
self.enemyTrainerPath = trainerPath self.enemyTrainerPath = trainerPath
self.enemyTrainerTrueColor = trainerTrueColor and true or false
self.showEnemyTrainer = true self.showEnemyTrainer = true
end end
end end
@@ -576,7 +604,10 @@ function BattleState:drawPic(mon, back)
-- slides it out (InitEnemyTrainer, engine/battle/core.asm:7848). -- slides it out (InitEnemyTrainer, engine/battle/core.asm:7848).
local enemyTrainer = (not back) and self.showEnemyTrainer local enemyTrainer = (not back) and self.showEnemyTrainer
and self.enemyTrainerImage 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 if not image then return end
local side = back and "player" or "enemy" local side = back and "player" or "enemy"
local anim = self:animPicState(side) local anim = self:animPicState(side)
@@ -3079,6 +3110,7 @@ end
function BattleState:drawHud() function BattleState:drawHud()
local wasBattle = Font.useBattleExtra(true) local wasBattle = Font.useBattleExtra(true)
local enemy, player = self:activeMon("enemy"), self:activeMon("player") local enemy, player = self:activeMon("enemy"), self:activeMon("player")
local showStatus = self:statusHUDVisible()
-- Enemy HUD (DrawEnemyHUD clears (1,0) 4 rows x 11 cols): -- 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); -- 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. -- 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 -- And nothing at all before UpdateEnemyHUD has ever run: the intro bands
-- slide in over a blanked tilemap (core.asm:8554/8564). -- 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) Chrome.print(self:name(enemy), 1, 0)
-- PrintLevel writes <LV> at the coordinate it is given and then LEFT-aligns -- 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 -- 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 -- BattleMenu's own tutorial arm skips UpdateBattleHuds as well. The DUDE's
-- half of the screen is his back-pic and nothing more. -- half of the screen is his back-pic and nothing more.
-- Nor before SendOutPlayerMon's own UpdatePlayerHUD (core.asm:3838). -- 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) Font.useBattleExtra(wasBattle)
return return
end end
@@ -3226,6 +3259,11 @@ function BattleState:drawPanel()
end end
self:drawHud() 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 -- -- 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 -- 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. -- 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) put(out, genderGlyph(mon.gender), 18, 13)
-- (8,14) is a bare '/', so the nickname starts at (9,14). -- (8,14) is a bare '/', so the nickname starts at (9,14).
put(out, "/", 8, 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) put(out, levelText(mon.level), 1, 16)
end end
-- '<ID>' '№' '/' at (7,16), (8,16), (9,16), then five digits at (10,16). -- '<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 end
-- ScrollingMenu's .a_button copies the row's own quantity into -- ScrollingMenu's .a_button copies the row's own quantity into
-- wItemQuantity, so the selector's ceiling is how many you hold. -- 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, self.qtyItem = { id = itemId, name = def.name or itemId,
price = def.price or 0 } price = def.price or 0 }
self.qty = 1 self.qty = 1
+14 -11
View File
@@ -354,20 +354,23 @@ function PackMenu:useSelected()
end end
return return
end end
-- engine/items/tmhm.asm:73 -- 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] local def = self.items and self.items[row.id]
if def and def.teaches then if def and def.teaches then
self:openTeachParty(row) self:openTeachParty(row)
return return
end 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.
if world and world.useFieldItem then
local def = self.items and self.items[row.id]
if def and def.fieldMenu == "ITEMMENU_NOUSE" then if def and def.fieldMenu == "ITEMMENU_NOUSE" then
self.message = OAK_THIS_ISNT_THE_TIME self.message = OAK_THIS_ISNT_THE_TIME
return return
@@ -627,7 +630,7 @@ function PackMenu:openTeachParty(row)
if not allowed then if not allowed then
if game.say then if game.say then
game:say(("%s can't learn %s!"):format( game:say(("%s can't learn %s!"):format(
mon.nickname or mon.species or "?", moveName)) require("src.battle.gen2.Mon").displayName(mon), moveName))
end end
return return
end end
@@ -635,7 +638,7 @@ function PackMenu:openTeachParty(row)
if move.id == moveId then if move.id == moveId then
if game.say then if game.say then
game:say(("%s already knows %s!"):format( game:say(("%s already knows %s!"):format(
mon.nickname or mon.species or "?", moveName)) require("src.battle.gen2.Mon").displayName(mon), moveName))
end end
return return
end end
+127 -77
View File
@@ -368,6 +368,7 @@ Kit._navPrevN = 0
Kit._navSeen = {} Kit._navSeen = {}
Kit._navQueue = nil Kit._navQueue = nil
Kit._activateId = nil Kit._activateId = nil
Kit._ringShown = false
-- Register a focusable. Returns true when it currently holds the ring. -- Register a focusable. Returns true when it currently holds the ring.
-- Shielded widgets do not register: while a modal owns the frame the ring -- Shielded widgets do not register: while a modal owns the frame the ring
@@ -383,11 +384,12 @@ function Kit.focusable(id, x, y, w, h)
-- First focusable ever drawn adopts the ring, so keyboard users start -- First focusable ever drawn adopts the ring, so keyboard users start
-- somewhere rather than nowhere. -- somewhere rather than nowhere.
if Kit.focusId == nil then Kit.focusId = id end if Kit.focusId == nil then Kit.focusId = id end
return Kit.focusId == id return Kit._ringShown and Kit.focusId == id
end end
function Kit.navigate(dir) function Kit.navigate(dir)
Kit._navQueue = dir Kit._navQueue = dir
Kit._ringShown = true
end end
function Kit.activateFocused() function Kit.activateFocused()
@@ -396,6 +398,7 @@ end
function Kit.setFocus(id) function Kit.setFocus(id)
Kit.focusId = id Kit.focusId = id
Kit._ringShown = id ~= nil
end end
-- Pick the nearest focusable in `dir` from the current one. Candidates must -- Pick the nearest focusable in `dir` from the current one. Candidates must
@@ -530,13 +533,10 @@ Kit._audit = audit
function Kit.tapMin() return math.floor(30 * Kit.scale) end function Kit.tapMin() return math.floor(30 * Kit.scale) end
-- ---------------------------------------------------------------- surfaces -- ---------------------------------------------------------------- surfaces
function Kit.card(x, y, w, h, emphasis) function Kit.card(x, y, w, h, variant)
Theme.card(x, y, w, h, emphasis) Theme.card(x, y, w, h, variant)
end 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) function Kit.row(x, y, w, h, selected, id)
audit("row", x, y, w, h, id or "row") audit("row", x, y, w, h, id or "row")
local focused = id and Kit.focusable(id, x, y, w, h) or false local focused = id and Kit.focusable(id, x, y, w, h) or false
@@ -560,7 +560,7 @@ end
-- hairline says the same thing for one rect.) -- hairline says the same thing for one rect.)
function Kit.emptyBox(x, y, w, h, message) function Kit.emptyBox(x, y, w, h, message)
if not G then return end 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), Kit.textCenter("button", Kit.ellipsize("button", message, w - 24 * Kit.scale),
x, y + (h - Kit.textHeight("button")) / 2, w, PAL.muted) x, y + (h - Kit.textHeight("button")) / 2, w, PAL.muted)
end end
@@ -598,61 +598,130 @@ local KINDS = {
disabled = { fill = PAL.steel, ink = PAL.inverse, flat = true }, disabled = { fill = PAL.steel, ink = PAL.inverse, flat = true },
} }
Kit.KINDS = KINDS 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) function Kit.button(x, y, w, h, label, opts)
opts = opts or {} opts = opts or NO_OPTS
local enabled = opts.enabled ~= false 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) audit("control", x, y, w, h, label)
local focused = enabled and opts.id local focused = enabled and opts.id
and Kit.focusable(opts.id, x, y, w, h) or false 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) local hot = enabled and Kit.hover(x, y, w, h)
local face = opts.face or "fill"
if G then local B = Theme.BUTTON
-- The fill IS the control: a rounded, embossed, colour-coded key. A local radius = opts.radius or B.radius
-- disabled button keeps its shape in a dead grey rather than local active = opts.active or opts.on
-- disappearing, so a layout never reflows on state. local invert = false
Theme.fillRounded(x, y, w, h, kind.fill, enabled and 1 or 0.45) local fill, ink, stroke, strokeA, doEmboss, doRing, glowA
Theme.emboss(x, y, w, h, enabled and (hot and 1.3 or 1) or 0.4) if face == "invert" then
if hot or focused then invert = hot
-- White ring outside the fill: legible on green, blue, yellow, red and fill = invert and (opts.hotFill or PAL.ink) or (opts.fill or PAL.surface)
-- white alike, which one darker/lighter shade per colour would not be. ink = invert and (opts.hotInk or PAL.inverse) or (opts.ink or PAL.heading)
Theme.strokeRounded(x - 2, y - 2, w + 4, h + 4, PAL.lineStrong, stroke = opts.stroke or PAL.line
Theme.A.focus, 2, Theme.radius() + 2) strokeA = invert and Theme.A.focus or Theme.A.hairline
elseif opts.glow and enabled then doRing = focused and not hot
-- "Something is waiting for you" (the update button): a pulsing ring. elseif face == "tab" then
-- Pure alpha on one existing stroke -- no extra draw calls, no blend invert = active or focused or hot
-- mode change. local tint = opts.color or opts.fill or PAL.ink
local a = 0.25 + 0.75 * (0.5 + 0.5 * math.sin(Kit.time * 3)) fill = invert and tint or PAL.surface
Theme.strokeRounded(x - 2, y - 2, w + 4, h + 4, PAL.lineStrong, a, 2, ink = invert and PAL.inverse or (opts.color or PAL.text)
Theme.radius() + 2) if not invert then
stroke = tint
strokeA = opts.color and Theme.A.hover or Theme.A.hairline
end end
local fname = opts.font or "button" elseif face == "chip" then
local ink = enabled and kind.ink or PAL.inverse local c = opts.color or PAL.line
local ty = y + (h - Kit.textHeight(fname)) / 2 invert = active and true or false
local shown = Kit.ellipsize(fname, label, w - 16 * Kit.scale) if active then
-- Button labels are bold: they are the shortest, most-scanned text on fill = c
-- screen and sit on a saturated fill. ink = PAL.inverse
if opts.align == "left" then doEmboss = true
Kit.textBold(fname, shown, x + 10 * Kit.scale, ty, ink)
else else
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) Kit.textCenterBold(fname, shown, x, ty, w, ink)
else
Kit.textCenter(fname, shown, x, ty, w, ink)
end
end end
end end
if not enabled then return false end if not enabled then return false end
@@ -661,7 +730,6 @@ function Kit.button(x, y, w, h, label, opts)
and Kit._activateId == opts.id) and Kit._activateId == opts.id)
end end
-- A small square control: +/- steppers, arrow cyclers, the row X.
function Kit.stepper(x, y, w, h, glyph, opts) function Kit.stepper(x, y, w, h, glyph, opts)
opts = opts or {} opts = opts or {}
opts.kind = opts.kind or "ghost" opts.kind = opts.kind or "ghost"
@@ -669,31 +737,13 @@ function Kit.stepper(x, y, w, h, glyph, opts)
return Kit.button(x, y, w, h, glyph, opts) return Kit.button(x, y, w, h, glyph, opts)
end 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) function Kit.chip(x, y, w, h, label, on, color, id)
audit("control", x, y, w, h, label) CHIP_OPTS.active = on
local focused = id and Kit.focusable(id, x, y, w, h) or false CHIP_OPTS.color = color
local c = color or PAL.line CHIP_OPTS.id = id
if G then return Kit.button(x, y, w, h, label, CHIP_OPTS)
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)
end end
-- A status label with no interaction: outlined text, the "INSTALLED"/"UPDATE" -- A status label with no interaction: outlined text, the "INSTALLED"/"UPDATE"
+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 -- 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 -- outline drawn on the page. These are still flat fills -- the depth comes
-- from the value step plus Theme.shadow, not from a gradient. -- from the value step plus Theme.shadow, not from a gradient.
field = { 16, 8, 10 }, -- the page BEHIND the cards field = { 20, 20, 20 }, -- the page BEHIND the cards
bg = { 0, 0, 0 }, -- true black: button rests, field interiors bg = { 20, 20, 20 }, -- light black
surface = { 28, 21, 24 }, -- card interiors surface = { 28, 21, 24 }, -- card interiors
rowBg = { 20, 14, 17 }, -- rows inside a card, one step below it rowBg = { 20, 14, 17 }, -- rows inside a card, one step below it
raised = { 44, 34, 38 }, -- hover feedback raised = { 44, 34, 38 }, -- hover feedback
@@ -82,6 +82,67 @@ Theme.A = {
disabled = 0.30, 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 G = love and love.graphics or nil
local has = {} local has = {}
@@ -123,11 +184,11 @@ end
-- Controls get the smaller one, containers the larger, so a button never -- Controls get the smaller one, containers the larger, so a button never
-- looks like a card and a card never looks like a button. -- looks like a card and a card never looks like a button.
function Theme.radius() function Theme.radius()
return 8 return Theme.BUTTON.radius
end end
function Theme.cardRadius() function Theme.cardRadius()
return 14 return Theme.CARD.radius
end end
-- DROP SHADOW. Three stacked rounded rects at low alpha, each one step wider -- 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 -- 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 -- field, its own drop shadow, and a white hairline. `emphasis` raises the
-- outline to full white (used for the focused/active card). -- outline to full white (used for the focused/active card).
function Theme.card(x, y, w, h, emphasis) function Theme.card(x, y, w, h, variant)
local r = Theme.cardRadius() local spec = Theme.CARD
Theme.shadow(x, y, w, h, r) local v
Theme.fillRounded(x, y, w, h, PAL.surface, 1, r) if variant == true then
Theme.strokeRounded(x, y, w, h, PAL.line, v = Theme.CARD_VARIANT.emphasis
emphasis and Theme.A.focus or Theme.A.hairline, 1, r) 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 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) function Theme.row(x, y, w, h, state)
local r = Theme.radius()
if state == "selected" then if state == "selected" then
Theme.fillRounded(x, y, w, h, PAL.ink, 1, r) Theme.card(x, y, w, h, "rowSelected")
return PAL.inverse return PAL.inverse
end end
Theme.fillRounded(x, y, w, h, Theme.card(x, y, w, h, state == "hover" and "rowHover" or "row")
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)
return PAL.text return PAL.text
end end
+12 -3
View File
@@ -71,6 +71,9 @@ function Check.parseRelease(jsonText, Json)
payloadName = payloadName, payloadName = payloadName,
payload = Check.pickAsset(doc.assets, payloadName), payload = Check.pickAsset(doc.assets, payloadName),
sums = Check.pickAsset(doc.assets, "sha256sums.txt"), 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 end
@@ -135,6 +138,10 @@ local function drain()
if stateCh then if stateCh then
local msg = stateCh:pop() local msg = stateCh:pop()
while msg do 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 cache = msg
msg = stateCh:pop() msg = stateCh:pop()
end end
@@ -158,11 +165,11 @@ function Check.start()
return return
end end
requested = true requested = true
cache = { status = "checking" } cache = { status = "checking", notes = cache.notes, latest = cache.latest }
cmdCh:push({ cmd = "check" }) cmdCh:push({ cmd = "check" })
end 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. -- idle | checking | uptodate | available | downloading | ready | needs_full | error.
function Check.state() function Check.state()
drain() drain()
@@ -171,6 +178,7 @@ function Check.state()
latest = cache.latest, latest = cache.latest,
progress = cache.progress, progress = cache.progress,
error = cache.error, error = cache.error,
notes = cache.notes,
} }
end end
@@ -180,7 +188,8 @@ function Check.download()
drain() drain()
if not cmdCh then return end if not cmdCh then return end
if cache.status ~= "available" 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" }) cmdCh:push({ cmd = "download" })
end 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
+6 -1
View File
@@ -45,7 +45,12 @@ local Boot = loadModule("src/update/Boot.lua")
local cmdCh = love.thread.getChannel("update_check_cmd") local cmdCh = love.thread.getChannel("update_check_cmd")
local stateCh = love.thread.getChannel("update_check_state") 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 osName = (love.system and love.system.getOS and love.system.getOS()) or ""
local isWindows = osName == "Windows" local isWindows = osName == "Windows"
+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 = {} local NPC = {}
NPC.__index = NPC NPC.__index = NPC
local STEP_FRAMES = 16 local STEP_FRAMES = 32
local FACING_FROM_RANGE = { local FACING_FROM_RANGE = {
DOWN = "down", UP = "up", LEFT = "left", RIGHT = "right", DOWN = "down", UP = "up", LEFT = "left", RIGHT = "right",
@@ -52,7 +52,16 @@ function NPC:facePlayer(player)
end end
function NPC:update(map, entities) 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 -- 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 -- Pikachu takes the player's own step length, halved while it is more
-- than a cell behind (FastPikachuFollow, engine/pikachu/ -- than a cell behind (FastPikachuFollow, engine/pikachu/
@@ -78,7 +87,7 @@ function NPC:update(map, entities)
return return
end end
local d = Collision.DELTA[self.facing] 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 -- so the cell still lands on a 16px boundary (Player:update does the
-- same for the bicycle) -- same for the bicycle)
local moved = math.floor(self.progress * 16 * span / stepLen) local moved = math.floor(self.progress * 16 * span / stepLen)
@@ -113,8 +122,9 @@ end
function NPC:walkPhase() function NPC:walkPhase()
if not self.moving then return 0 end if not self.moving then return 0 end
local p = self.progress % 16 local stepLen = self.stepFrames or STEP_FRAMES
return (p >= 4 and p < 12) and 1 or 0 local p = self.progress % stepLen
return (p >= stepLen / 4 and p < stepLen * 3 / 4) and 1 or 0
end end
-- Same contract as Player:pose -- the sheet, position, facing and step -- 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 -- a fresh entry, or a stale flag can freeze player input forever
self.engaging = false self.engaging = false
self.emote = nil self.emote = nil
self.cancelledTrainerSight = nil
-- volatile WRAM state in pokered; never serialize across save/load -- volatile WRAM state in pokered; never serialize across save/load
self.wildEncounterGraceSteps = 0 self.wildEncounterGraceSteps = 0
-- survives save/load: a loaded game may start inside a building whose -- 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 -- NPC instances persist across connection crossings in self.npcPool
-- (keyed by NPC.id): a neighbor map's wandering ghosts ARE the -- (keyed by NPC.id): a neighbor map's wandering ghosts ARE the
-- objects that become the real NPCs when the player crosses 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 -- seam, so a map the player is not entering keeps its ghosts alive
-- survey zoom. Warps rebuild from scratch, like the original's -- in view of the survey zoom. The map being entered does not --
-- per-entry sprite init (home/overworld.asm LoadMapHeader -- crossing a seam runs .loadNewMap -> LoadMapHeader, whose
-- .loadSpriteData). -- .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 if not (opts and opts.seamless and self.npcPool) then
self.npcPool = {} 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 end
self.npcs = {} self.npcs = {}
for _, obj in ipairs(self.map.def.objects or {}) do for _, obj in ipairs(self.map.def.objects or {}) do
@@ -753,6 +763,70 @@ function OverworldState:bikeAllowed(mapId)
return false return false
end 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 -- The battle transition's dungeon wipe uses the explicit map lists in
-- data/maps/dungeon_maps.asm (field.dungeonTransitionMaps): singles plus -- data/maps/dungeon_maps.asm (field.dungeonTransitionMaps): singles plus
-- inclusive map-id ranges -- faithful to the original's omissions -- inclusive map-id ranges -- faithful to the original's omissions
@@ -1716,6 +1790,12 @@ function OverworldState:goFishing(rod)
end)) end))
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). -- Fly to a visited town (called from the party menu).
function OverworldState:flyTo(mapId) function OverworldState:flyTo(mapId)
local spot = Game.data.field.flyWarps[mapId] local spot = Game.data.field.flyWarps[mapId]
@@ -1986,10 +2066,12 @@ function OverworldState:tryHiddenObject(fx, fy)
end end
save.hiddenTaken[key] = true save.hiddenTaken[key] = true
local name = Game.data.items[h.item] and Game.data.items[h.item].name or h.item 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) -- hidden items always play SFX_GET_ITEM_2, and FoundHiddenItemText's
require("src.core.Sound").play(Game.data, "Get_Item2") -- text_asm tail runs it as PlaySoundWaitForCurrent +
-- WaitForSoundToFinish once the box has printed (hidden_items.asm)
Game.stack:push(TextBox.new(Game, 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 return true
end end
end end
@@ -2001,9 +2083,9 @@ function OverworldState:tryHiddenObject(fx, fy)
if not save.inventory.COIN_CASE then return false end if not save.inventory.COIN_CASE then return false end
save.hiddenTaken[key] = true save.hiddenTaken[key] = true
save.coins = math.min(9999, (save.coins or 0) + h.coins) 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, 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 return true
end end
end end
@@ -2461,6 +2543,15 @@ function OverworldState:trySurf(fx, fy, onClose)
end)) end))
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) function OverworldState:tryCut(fx, fy)
-- UsedCut (engine/overworld/cut.asm) gates on the TILESET before -- UsedCut (engine/overworld/cut.asm) gates on the TILESET before
-- anything else: only OVERWORLD (tree tile $3d) and GYM (plant tile -- anything else: only OVERWORLD (tree tile $3d) and GYM (plant tile
@@ -2650,10 +2741,11 @@ function OverworldState:talkTo(npc)
end end
local name = Game.data.items[d.item] and Game.data.items[d.item].name or d.item local name = Game.data.items[d.item] and Game.data.items[d.item].name or d.item
local ddef = Game.data.items[d.item] local ddef = Game.data.items[d.item]
require("src.core.Sound").play(Game.data, -- FoundItemText: text_far, sound_get_item_1, text_end (pick_up_item.asm)
(ddef and ddef.keyItem) and "Get_Key_Item" or "Get_Item1")
Game.stack:push(TextBox.new(Game, 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 return
end end
@@ -3080,6 +3172,32 @@ local function meetTrainerTheme(cls)
or "Music_MeetMaleTrainer" or "Music_MeetMaleTrainer"
end 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. -- Run the pre-battle text -> battle -> won text -> flags sequence.
-- skipBattleText is for map scripts shaped like SilphCo11FDefaultScript -- skipBattleText is for map scripts shaped like SilphCo11FDefaultScript
-- (scripts/SilphCo11F.asm), which DisplayTextID the challenge line BEFORE -- (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]) or (header and header.won and Game.data.text[header.won])
local BattleState = require("src.battle.BattleState") 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 -- TalkToTrainer (home/trainers.asm:88) prints the before-battle text
-- FIRST and only then runs `call EngageMapTrainer` / `jp -- FIRST and only then runs `call EngageMapTrainer` / `jp
-- StartTrainerBattle`, so a trainer challenged on foot gets the sting -- 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) local theme = meetTrainerTheme(d.trainerClass)
if theme then require("src.core.Music").play(Game.data, theme) end if theme then require("src.core.Music").play(Game.data, theme) end
end end
local battle = BattleState.newTrainer(Game, d.trainerClass, d.trainerParty) local battle = BattleState.newTrainer(Game, d.trainerClass, d.trainerParty,
options)
battle.checkpointOrigin = { battle.checkpointOrigin = {
kind = "trainer_encounter", kind = "trainer_encounter",
map = self.map.id, map = self.map.id,
@@ -3159,10 +3279,31 @@ function OverworldState:engageTrainer(npc, onDone, endBattleText, skipBattleText
end end
self:pushBattle(battle) self:pushBattle(battle)
end end
if skipBattleText then local function prepareBattle()
if not Runtime.wantsHook("trainer.before_battle") then
startBattle() 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
prepareBattle()
else else
Game.stack:push(TextBox.new(Game, battleText, startBattle)) Game.stack:push(TextBox.new(Game, battleText, prepareBattle))
end end
end end
@@ -3182,6 +3323,58 @@ local function giveVictoryItem(reward)
return true return true
end 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). -- Badges/items awarded after specific battles (data/scripts/victories.lua).
-- `deactivate` retires unfought gym/dojo trainers the way the originals' -- `deactivate` retires unfought gym/dojo trainers the way the originals'
-- SetEvent / SetEventRange do after the leader victory. -- 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) -- retries the hand-over later (offerGymTm via gyms.lua)
tmGiven = giveVictoryItem(reward) tmGiven = giveVictoryItem(reward)
end end
local lines = {} local chain = rewardChain()
if reward.dialogue then if reward.dialogue then
local text = Game.data.text or {} chain.add(reward.dialogue, reward.badgeSound)
for _, label in ipairs(reward.dialogue) do
if text[label] and text[label] ~= "" then
table.insert(lines, text[label])
end
end
if reward.item then if reward.item then
for _, label in ipairs(reward.tmPre or {}) do chain.add(reward.tmPre)
if text[label] and text[label] ~= "" then
table.insert(lines, text[label])
end
end
if tmGiven then if tmGiven then
for _, label in ipairs(reward.tmDialogue or {}) do chain.add(reward.tmDialogue, reward.tmSound)
if text[label] and text[label] ~= "" then else
table.insert(lines, text[label]) chain.add({ reward.noRoom })
end
end
elseif reward.noRoom and text[reward.noRoom] and text[reward.noRoom] ~= "" then
table.insert(lines, text[reward.noRoom])
end end
end end
elseif reward.badge or reward.item then elseif reward.badge or reward.item then
if reward.badge then if reward.badge then
local name = Game.data.items[reward.badge] and Game.data.items[reward.badge].name local name = Game.data.items[reward.badge] and Game.data.items[reward.badge].name
or reward.badge 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 end
if tmGiven then if tmGiven then
local name = Game.stringBuffer or reward.item 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
end end
if #lines > 0 then chain.push()
Game.stack:push(TextBox.new(Game, table.concat(lines, "\f")))
end
self:runVictoryHook() self:runVictoryHook()
end end
@@ -3266,24 +3446,14 @@ end
-- show again, then the same GiveItem check decides between the received -- show again, then the same GiveItem check decides between the received
-- lines and the "make room" text. -- lines and the "make room" text.
function OverworldState:offerGymTm(reward, done) function OverworldState:offerGymTm(reward, done)
local text = Game.data.text or {} local chain = rewardChain()
local lines = {} chain.add(reward.tmPre)
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
if giveVictoryItem(reward) then if giveVictoryItem(reward) then
for _, label in ipairs(reward.tmDialogue or {}) do addLine(label) end chain.add(reward.tmDialogue, reward.tmSound)
else else
addLine(reward.noRoom) chain.add({ reward.noRoom })
end
if #lines > 0 then
Game.stack:push(TextBox.new(Game, table.concat(lines, "\f"), done))
elseif done then
done()
end end
chain.push(done)
end end
-- pokered reloads the map after every battle, re-running the map -- 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 self.player.moving or self.engaging then return end
if Game.stack:top() ~= self then return end if Game.stack:top() ~= self then return end
local p = self.player 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 for _, npc in ipairs(self.npcs) do
local d = npc.def local d = npc.def
-- CheckFightingMapTrainers engages ANY aligned trainer sprite, -- CheckFightingMapTrainers engages ANY aligned trainer sprite,
-- walkers included (they sight between steps) -- walkers included (they sight between steps)
if d.trainerClass and not npc.moving if d.trainerClass and not npc.moving
and not (cancelled and cancelled.npcId == npc.id)
and not self:trainerDefeated(npc) and not self:trainerDefeated(npc)
and not mapScripts.talkScript(self.map.id, d.text) and not mapScripts.talkScript(self.map.id, d.text)
and trainerSpriteOnScreen(npc, p) then and trainerSpriteOnScreen(npc, p) then
@@ -3661,8 +3838,9 @@ function OverworldState:onStepComplete()
end end
end end
-- wild encounters in grass, on water while surfing, or -- on indoor -- wild encounters in grass, on water while surfing (at the map's water
-- maps whose tileset is not FOREST -- on EVERY tile -- 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) -- (wild_encounters.asm: caves, towers, the Mansion, Power Plant)
-- The cooldown is checked after all other step processing so repel and -- The cooldown is checked after all other step processing so repel and
-- movement systems continue to advance during the protected steps. -- 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 encDef = Game.data.encounters[self.map.id]
local enc local enc
local indoor = Game.data.field.indoorEncounters local indoor = Game.data.field.indoorEncounters
if p.surfing and encDef and encDef.water and self.map:isWaterCell(p.cellX, p.cellY) then if self.map:isGrassCell(p.cellX, p.cellY) then
enc = self:rollEncounter({ grass = encDef.water }, "water")
elseif self.map:isGrassCell(p.cellX, p.cellY) then
enc = self:rollEncounter(encDef, "grass") 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 elseif indoor and self.map.def.index >= indoor.firstIndoorMap
and self.map.def.tileset ~= indoor.excludedTileset then and self.map.def.tileset ~= indoor.excludedTileset then
enc = self:rollEncounter(encDef, "indoor") enc = self:rollEncounter(encDef, "indoor")
@@ -3794,10 +3972,10 @@ function OverworldState:checkBadgeGate()
if Game.save.inventory[g.badge] then if Game.save.inventory[g.badge] then
if not Game.save.flags[passedFlag] then if not Game.save.flags[passedFlag] then
Game.save.flags[passedFlag] = true Game.save.flags[passedFlag] = true
-- Route22GateGuardGoRightAheadText plays sound_get_item_1 -- Route22GateGuardGoRightAheadText carries sound_get_item_1
require("src.core.Sound").play(Game.data, "Get_Item1")
Game.stack:push(TextBox.new(Game, 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 end
return false return false
end end
@@ -3822,11 +4000,11 @@ function OverworldState:checkBadgeGate()
and Game.data.items[guard.badge].name or guard.badge and Game.data.items[guard.badge].name or guard.badge
if Game.save.inventory[guard.badge] then if Game.save.inventory[guard.badge] then
Game.save.flags[guard.event] = true Game.save.flags[guard.event] = true
-- Route23OhThatIsTheBadgeText plays sound_get_item_1 -- Route23OhThatIsTheBadgeText carries sound_get_item_1
require("src.core.Sound").play(Game.data, "Get_Item1")
local text = (t["_" .. g.passText] or local text = (t["_" .. g.passText] or
Strings("Oh! That is the\n{RAM}!")):gsub("{RAM:wNameBuffer}", badgeName) 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 return false
end end
-- Route23YouDontHaveTheBadgeYetText plays SFX_DENIED -- Route23YouDontHaveTheBadgeYetText plays SFX_DENIED
@@ -4470,7 +4648,7 @@ function OverworldState:scriptMove(entity, dir, tiles, onDone)
}) })
end 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 -- frames) without translating, keeping its current facing. Ports the
-- NPC_CHANGE_FACING movement byte (engine/overworld/movement.asm -- NPC_CHANGE_FACING movement byte (engine/overworld/movement.asm
-- ChangeFacingDirection -> zero-delta TryWalking), used for Oak marching -- 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 -- 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 -- 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 -- simulated-joypad / NPC scripted movement, with no idle frame between
-- tiles. Phase 1 retires finished moves (which may chain new ones); -- tiles. Phase 1 retires finished moves (which may chain new ones);
-- phase 2 then starts every not-yet-moving move. -- phase 2 then starts every not-yet-moving move.
+10 -1
View File
@@ -821,11 +821,13 @@ end
function PikachuFollower.onFanClubEntered(game, ow) function PikachuFollower.onFanClubEntered(game, ow)
if not (GameVersion.isYellow() and ow.map if not (GameVersion.isYellow() and ow.map
and ow.map.id == "POKEMON_FAN_CLUB") then return end 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 starter = PikachuFollower.starterInParty(game.save)
local npc = findFollower(ow) local npc = findFollower(ow)
if not npc or (starter and starter.status) then return end if not npc or (starter and starter.status) then return end
ow.pikachuFanClubScene = true ow.pikachuFanClubScene = true
ow.pikachuMapScriptActive = true
ow.player.facing = "down" ow.player.facing = "down"
for _, other in ipairs(ow.npcs or {}) do for _, other in ipairs(ow.npcs or {}) do
if other.def and other.def.name == "POKEMONFANCLUB_SEEL" then 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 if not (GameVersion.isYellow() and ow.map and ow.map.id == "BILLS_HOUSE") then
return return
end 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 if game.save.flags.EVENT_MET_BILL_2 then return end
-- BillsHouseScript0 (scripts/BillsHouse.asm:41-47) only runs the confused -- BillsHouseScript0 (scripts/BillsHouse.asm:41-47) only runs the confused
-- walk while CheckPikachuStatusCondition comes back clear -- walk while CheckPikachuStatusCondition comes back clear
+106 -73
View File
@@ -6,8 +6,10 @@
-- stays unsupported; anything a mod legitimately needs belongs here. -- stays unsupported; anything a mod legitimately needs belongs here.
local Logger = require("src.core.Logger") local Logger = require("src.core.Logger")
local Assets = require("src.render.Assets") local FieldDefaults = require("src.world.FieldDefaults")
local Map = require("src.world.Map")
local MapLoader = require("src.world.MapLoader") local MapLoader = require("src.world.MapLoader")
local MapOverview = require("src.world.MapOverview")
local Party = require("src.pokemon.Party") local Party = require("src.pokemon.Party")
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
@@ -15,59 +17,9 @@ local WorldAPI = {}
WorldAPI.__index = WorldAPI WorldAPI.__index = WorldAPI
local NO_OVERWORLD = "no overworld" local NO_OVERWORLD = "no overworld"
local overviewShades = {} local DIG_TILESETS = { FOREST = true, CEMETERY = true, CAVERN = true,
FACILITY = true, INTERIOR = true }
Assets.register(function() overviewShades = {} end) local RODS = { "OLD_ROD", "GOOD_ROD", "SUPER_ROD" }
local function shadeDigit(sum, pixelCount)
return tostring(math.max(0, math.min(3,
math.floor((1 - sum / pixelCount) * 3 + 0.5))))
end
local function mapTileRows(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
local function acceptsMenuInput(game, ow) local function acceptsMenuInput(game, ow)
local stack = game and game.stack local stack = game and game.stack
@@ -139,6 +91,104 @@ function WorldAPI:reorderParty(fromSlot, toSlot)
return true return true
end end
-- Contextual field-item shortcuts. Only actions that can start immediately
-- are listed; callers receive copied labels and never inspect world internals.
function WorldAPI:availableFieldActions()
local game, ow, out = self.game, self:overworld(), {}
if not (game and game.save and ow and ow.map and ow.player)
or not acceptsMenuInput(game, ow) then return out end
local save, inventory = game.save, game.save.inventory or {}
local items = game.data and game.data.items or {}
if (inventory.BICYCLE or 0) > 0 and not ow.player.surfing
and not (save.onBike and save.forcedBike)
and (save.onBike or ow:bikeAllowed(ow.map.id)) then
out[#out + 1] = { id = "bicycle",
label = save.onBike and "BIKE OFF" or "BICYCLE" }
end
if not ow.player.surfing and ow:facingIsShoreOrWater() then
local rods = {}
for _, id in ipairs(RODS) do
if (inventory[id] or 0) > 0 then
local def = items[id]
rods[#rods + 1] = { id = id, label = def and def.name or id }
end
end
if #rods > 0 then
out[#out + 1] = { id = "fish", label = "FISH", rods = rods }
end
end
if ow:useCutFieldMove() == "ok" then
out[#out + 1] = { id = "cut", label = "CUT" }
end
local surf = ow:useSurfFieldMove()
if surf == "ok" or surf == "dismount" then
out[#out + 1] = { id = "surf",
label = surf == "dismount" and "LEAVE WATER" or "SURF" }
end
if not ow.strengthActive and ow:partyKnows("STRENGTH") then
out[#out + 1] = { id = "strength", label = "STRENGTH" }
end
if ow.dark and ow:partyKnows("FLASH") then
out[#out + 1] = { id = "flash", label = "FLASH" }
end
if DIG_TILESETS[ow.map.def.tileset] and ow.map.id ~= "AGATHAS_ROOM"
and ow:partyKnows("DIG") then
out[#out + 1] = { id = "dig", label = "DIG" }
end
if ow:partyKnows("TELEPORT") and Map.isOutside(ow.map.def,
FieldDefaults.field(game.data, "outsideTilesets")) then
out[#out + 1] = { id = "teleport", label = "TELEPORT" }
end
return out
end
function WorldAPI:useFieldAction(id, opts)
local game, ow = self.game, self:overworld()
if not ow then return nil, NO_OVERWORLD end
if not acceptsMenuInput(game, ow) then return nil, "world is busy" end
local found
for _, action in ipairs(self:availableFieldActions()) do
if action.id == id then found = action break end
end
if not found then return nil, "field action unavailable" end
if id == "bicycle" then
if ow:useBicycle() then return true end
elseif id == "cut" then
local x, y = ow.player:facingCell()
if ow:tryCut(x, y) then return true end
elseif id == "surf" then
local mode = ow:useSurfFieldMove()
if mode == "dismount" then
ow:stopSurfing()
return true
elseif mode == "ok" then
local x, y = ow.player:facingCell()
ow:trySurf(x, y)
return true
end
elseif id == "fish" then
local rod = opts and opts.rod
if not rod and #found.rods == 1 then rod = found.rods[1].id end
for _, choice in ipairs(found.rods) do
if choice.id == rod and ow:useFishingRod(rod) then return true end
end
return nil, "fishing rod unavailable"
elseif id == "strength" then
if ow:useStrengthFieldMove() then return true end
elseif id == "flash" then
if ow:useFlashFieldMove() then return true end
elseif id == "dig" or id == "teleport" then
ow:beginTeleportOut()
return true
end
return nil, "field action unavailable"
end
-- A compact, read-only view of the active map for minimaps and companion UIs. -- A compact, read-only view of the active map for minimaps and companion UIs.
-- `rows` describes collision terrain; optional `tileRows` reduces each real -- `rows` describes collision terrain; optional `tileRows` reduces each real
-- 8x8 map tile to its average Game Boy shade ("0" lightest, "3" darkest). -- 8x8 map tile to its average Game Boy shade ("0" lightest, "3" darkest).
@@ -147,16 +197,7 @@ end
function WorldAPI:mapOverview() function WorldAPI:mapOverview()
local ow = self:overworld() local ow = self:overworld()
if not ow or not ow.map then return nil, NO_OVERWORLD end if not ow or not ow.map then return nil, NO_OVERWORLD end
local map, rows, markers = ow.map, {}, {} local map, markers = ow.map, {}
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 def = map.def or {} local def = map.def or {}
for _, warp in ipairs(def.warps or {}) do for _, warp in ipairs(def.warps or {}) do
markers[#markers + 1] = { kind = "warp", x = warp.x, y = warp.y } markers[#markers + 1] = { kind = "warp", x = warp.x, y = warp.y }
@@ -175,15 +216,7 @@ function WorldAPI:mapOverview()
markers[#markers + 1] = { kind = "hidden", x = item.x, y = item.y } markers[#markers + 1] = { kind = "hidden", x = item.x, y = item.y }
end end
end end
local tileRows, tileDetailRows = mapTileRows(map) return MapOverview.build(map, markers)
return { mapId = map.id, width = map.widthCells,
height = map.heightCells, rows = rows, markers = markers,
tileRows = tileRows,
tileWidth = tileRows and map.widthCells * 2,
tileHeight = tileRows and map.heightCells * 2,
tileDetailRows = tileDetailRows,
tileDetailWidth = tileDetailRows and map.widthCells * 4,
tileDetailHeight = tileDetailRows and map.heightCells * 4 }
end end
-- opts.arrive = "fly" | "teleport" picks the arrival FX; anything else -- opts.arrive = "fly" | "teleport" picks the arrival FX; anything else
+186
View File
@@ -0,0 +1,186 @@
-- Bake a Gen 2 map canvas without a World instance. The save editor's map
-- tab uses this so Gold rooms draw the same tiles the overworld would,
-- instead of the checkerboard fallback Map2 has no renderer for.
local Assets = require("src.render.Assets")
local BorderFill = require("src.world.gen2.BorderFill")
local GbcPalette = require("src.render.GbcPalette")
local Palettes = require("src.world.gen2.Palettes")
local MapPreview = {}
local ROOF_TILESETS = {
TILESET_JOHTO = true,
TILESET_JOHTO_MODERN = true,
TILESET_KANTO = true,
}
local function applyRoofOverlay(atlasPath, roofPath, tilesPerRow)
local atlasData = love.image.newImageData(Assets.resolve(atlasPath))
local roofData = love.image.newImageData(Assets.resolve(roofPath))
for t = 0, 8 do
local destId = 0x0a + t
local dx = (destId % tilesPerRow) * 8
local dy = math.floor(destId / tilesPerRow) * 8
local sx = t * 8
for y = 0, 7 do
for x = 0, 7 do
local r, g, b, a = roofData:getPixel(sx + x, y)
atlasData:setPixel(dx + x, dy + y, r, g, b, a)
end
end
end
local image = love.graphics.newImage(atlasData)
image:setFilter("nearest", "nearest")
return image
end
local function optionalTable(data, gen2Key, plainKey, generated)
if data[gen2Key] then return data[gen2Key] end
if data[plainKey] then return data[plainKey] end
local ok, mod = pcall(require, "data.generated." .. generated)
if ok and type(mod) == "table" then return mod end
return nil
end
function MapPreview.baker(data)
data = data or {}
return {
tilesets = data.gen2Tilesets or data.tilesets or {},
-- Data:load does not pull roofs.lua; the Gold cache still has it.
roofs = optionalTable(data, "gen2Roofs", "roofs", "roofs"),
palettes = optionalTable(data, "gen2Palettes", "palettes", "palettes"),
atlasCache = {},
mapImages = {},
}
end
function MapPreview.atlasFor(baker, mapDef)
if not (baker and mapDef) then return nil, nil end
local tileset = baker.tilesets and baker.tilesets[mapDef.tileset]
if not tileset then return nil, nil end
local cacheKey = mapDef.tileset
local roofName = nil
local roofs = baker.roofs
if ROOF_TILESETS[mapDef.tileset] then
roofName = roofs and roofs.mapGroupRoofs and roofs.mapGroupRoofs[mapDef.group]
end
if roofName then cacheKey = cacheKey .. "|" .. roofName end
local cached = baker.atlasCache[cacheKey]
if cached then return cached, tileset end
local tilesPerRow = tileset.tilesPerRow or 16
local atlas
local roofSpec = roofName and roofs and roofs.roofs and roofs.roofs[roofName]
if roofSpec and roofSpec.image and love.image and love.image.newImageData then
local ok, img = pcall(applyRoofOverlay, tileset.image, roofSpec.image, tilesPerRow)
if ok then atlas = img end
end
if not atlas then
if not tileset.image then return nil, tileset end
local ok, img = pcall(Assets.image, tileset.image)
if not ok then return nil, tileset end
atlas = img
if atlas.setFilter then atlas:setFilter("nearest", "nearest") end
end
baker.atlasCache[cacheKey] = atlas
return atlas, tileset
end
-- Same bake as World:bakeMapImage (src/world/gen2/World.lua), minus the
-- World fields the overworld keeps for anim overlays and cave flicker.
function MapPreview.bake(baker, map, daytime)
local atlas, tileset = MapPreview.atlasFor(baker, map.def)
if not atlas or not tileset then return nil end
if not (love.graphics and love.graphics.newCanvas) then return nil end
local blocks = tileset.blocks
local tilesPerRow = tileset.tilesPerRow or 16
local pw, ph = map.width * 32, map.height * 32
local okCanvas, canvas = pcall(love.graphics.newCanvas, pw, ph)
if not okCanvas or not canvas then return nil end
if canvas.setFilter then canvas:setFilter("nearest", "nearest") end
local quads = {}
local function quadFor(tile)
local q = quads[tile]
if q then return q end
local sx = (tile % tilesPerRow) * 8
local sy = math.floor(tile / tilesPerRow) * 8
q = love.graphics.newQuad(sx, sy, 8, 8, atlas:getDimensions())
quads[tile] = q
return q
end
local tilePalettes = tileset.tilePalettes
local bgSet = baker.palettes and daytime
and Palettes.bgSet(baker.palettes, map.def, daytime) or nil
local colored = bgSet and tilePalettes and GbcPalette.available()
local clearColor = { 0.15, 0.55, 0.25 }
if bgSet and bgSet[1] and bgSet[1][1] then
local c = GbcPalette.color(bgSet[1], 1)
clearColor = { c[1] / 255, c[2] / 255, c[3] / 255 }
end
local function drawTiles(slot)
for by = 0, map.height - 1 do
for bx = 0, map.width - 1 do
local blockId = BorderFill.blockFor(
map.blocks[by * map.width + bx + 1], map.borderBlock)
local block = blocks and blocks[(blockId or 0) + 1]
if block then
for i = 0, 15 do
local tile = block[i + 1] or 0
local tileSlot = tilePalettes and tilePalettes[tile + 1] or 1
if not slot or tileSlot == slot then
local tx = bx * 32 + (i % 4) * 8
local ty = by * 32 + math.floor(i / 4) * 8
love.graphics.draw(atlas, quadFor(tile), tx, ty)
end
end
end
end
end
end
local function paint()
love.graphics.clear(clearColor[1], clearColor[2], clearColor[3], 1)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.push()
love.graphics.origin()
if colored then
for slot = 1, 8 do
GbcPalette.with(bgSet[slot], function() drawTiles(slot) end)
end
else
drawTiles(nil)
end
love.graphics.pop()
end
if canvas.renderTo then
canvas:renderTo(paint)
else
paint()
end
return canvas
end
function MapPreview.imageFor(baker, map)
if not (baker and map and map.id) then return nil end
local cached = baker.mapImages[map.id]
if cached then return cached end
local img = MapPreview.bake(baker, map, "DAY")
baker.mapImages[map.id] = img or false
return img
end
function MapPreview.renderer(baker, map)
local img = MapPreview.imageFor(baker, map)
if not img then return nil end
return {
draw = function(_, camX, camY)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(img, -math.floor(camX or 0), -math.floor(camY or 0))
end,
}
end
return MapPreview
+146
View File
@@ -25,11 +25,29 @@
local Logger = require("src.core.Logger") local Logger = require("src.core.Logger")
local Movement = require("src.script.gen2.Movement") local Movement = require("src.script.gen2.Movement")
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
local HiddenItems = require("src.world.gen2.HiddenItems")
local MapOverview = require("src.world.MapOverview")
local Bike = require("src.world.gen2.Bike")
local FieldMoves = require("src.world.gen2.FieldMoves")
local Permissions = require("src.world.gen2.Permissions")
local WorldAPI = {} local WorldAPI = {}
WorldAPI.__index = WorldAPI WorldAPI.__index = WorldAPI
local NO_OVERWORLD = "no overworld" local NO_OVERWORLD = "no overworld"
local RODS = { "OLD_ROD", "GOOD_ROD", "SUPER_ROD" }
local FIELD_ACTIONS = {
{ id = "cut", move = "CUT" },
{ id = "surf", move = "SURF" },
{ id = "strength", move = "STRENGTH" },
{ id = "flash", move = "FLASH" },
{ id = "headbutt", move = "HEADBUTT" },
{ id = "whirlpool", move = "WHIRLPOOL" },
{ id = "waterfall", move = "WATERFALL" },
{ id = "sweet_scent", move = "SWEET_SCENT" },
{ id = "dig", move = "DIG" },
{ id = "teleport", move = "TELEPORT" },
}
function WorldAPI.new(game, modId) function WorldAPI.new(game, modId)
return setmetatable({ game = game, modId = modId }, WorldAPI) return setmetatable({ game = game, modId = modId }, WorldAPI)
@@ -50,6 +68,134 @@ function WorldAPI:current()
facing = p and p.facing } facing = p and p.facing }
end end
local function itemLabel(game, id)
local def = game and game.data and game.data.items
and game.data.items[id]
return (def and def.name) or id
end
-- The same field-item contract as Gen 1, resolved through Gold's own bike,
-- collision and fishing rules.
function WorldAPI:availableFieldActions()
local world, game, out = self:overworld(), self.game, {}
if not (world and game and game.save and world.map and world.player)
or not world:acceptsMenuInput() then return out end
local inventory = game.save.inventory or {}
if (inventory.BICYCLE or 0) > 0 then
local bike = Bike.tryBike({
state = world.playerState,
environment = world.map.def and world.map.def.environment,
collision = world:playerCollision(),
alwaysOnBike = world:alwaysOnBike(),
})
if bike == "mount" or bike == "dismount" then
out[#out + 1] = { id = "bicycle",
label = bike == "dismount" and "BIKE OFF" or "BICYCLE" }
end
end
local context = world:fieldContext()
if not FieldMoves.isSurfing(world.playerState)
and Permissions.isWater(context.facingColl) then
local rods = {}
for _, id in ipairs(RODS) do
if (inventory[id] or 0) > 0 then
rods[#rods + 1] = { id = id, label = itemLabel(game, id) }
end
end
if #rods > 0 then
out[#out + 1] = { id = "fish", label = "FISH", rods = rods }
end
end
for _, row in ipairs(FIELD_ACTIONS) do
if not (row.move == "STRENGTH" and world.strengthActive) then
local mon = FieldMoves.partyMoveUser(context.party, row.move, context)
if mon then
context.mon = mon
local result = FieldMoves.fromMenu(row.move, context)
if result.ok then
out[#out + 1] = { id = row.id,
label = row.move:gsub("_", " ") }
end
end
end
end
if (inventory.SQUIRTBOTTLE or 0) > 0
and world:squirtbottleTreeScript() then
out[#out + 1] = { id = "squirtbottle",
label = itemLabel(game, "SQUIRTBOTTLE") }
end
return out
end
function WorldAPI:useFieldAction(id, opts)
local world = self:overworld()
if not world then return nil, NO_OVERWORLD end
if not world:acceptsMenuInput() then return nil, "world is busy" end
local found
for _, action in ipairs(self:availableFieldActions()) do
if action.id == id then found = action break end
end
if not found then return nil, "field action unavailable" end
if id == "bicycle" then
local outcome = world:useFieldItem("BICYCLE")
if outcome and outcome ~= "nowhere" then return true end
elseif id == "fish" then
local rod = opts and opts.rod
if not rod and #found.rods == 1 then rod = found.rods[1].id end
for _, choice in ipairs(found.rods) do
if choice.id == rod then
local outcome = world:useFieldItem(rod)
if outcome and outcome ~= "nowhere" then return true end
break
end
end
return nil, "fishing rod unavailable"
elseif id == "squirtbottle" then
local outcome = world:useFieldItem("SQUIRTBOTTLE")
if outcome and outcome ~= "nowhere" then return true end
end
for _, row in ipairs(FIELD_ACTIONS) do
if row.id == id then
local context = world:fieldContext()
local mon = FieldMoves.partyMoveUser(context.party, row.move, context)
local result = mon and world:useFieldMove(row.move, mon)
if result and result.ok then return true end
return nil, "field action unavailable"
end
end
return nil, "field action unavailable"
end
-- The same read-only minimap contract as Gen 1, with Gold's object/event
-- visibility rules supplying the semantic markers.
function WorldAPI:mapOverview()
local world = self:overworld()
if not world or not world.map then return nil, NO_OVERWORLD end
local map, def, markers = world.map, world.map.def or {}, {}
for _, warp in ipairs(def.warps or {}) do
markers[#markers + 1] = { kind = "warp", x = warp.x, y = warp.y }
end
local visible = {}
for _, npc in ipairs(world.npcs or {}) do
if npc.def then visible[npc.def] = true end
end
for _, obj in ipairs(def.objects or {}) do
local item = obj.itemball and obj.itemball.item
if item and item ~= "0" and item ~= 0 and visible[obj] then
markers[#markers + 1] = { kind = "item", x = obj.x, y = obj.y }
end
end
for _, item in ipairs(HiddenItems.unfound(def, world.events)) do
markers[#markers + 1] = { kind = "hidden", x = item.x, y = item.y }
end
return MapOverview.build(map, markers)
end
-- opts is accepted for signature parity with the Gen 1 arm; Gold's arrival FX -- opts is accepted for signature parity with the Gen 1 arm; Gold's arrival FX
-- come from the map setup method, so opts.arrive has nothing to select yet. -- come from the map setup method, so opts.arrive has nothing to select yet.
function WorldAPI:warpTo(mapId, x, y, facing, opts) function WorldAPI:warpTo(mapId, x, y, facing, opts)
+40
View File
@@ -0,0 +1,40 @@
-- Driver: Oak's Pallet Town escort. Logs the player/Oak separation the
-- whole way down and shoots the walk mid-street, so the formation is
-- checkable by eye as well as by number (the escort holds one cell,
-- ~16px; a desynced Oak drifts off by a cell per two steps).
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
U.teleport(game, "PALLET_TOWN", 10, 3, "up")
local ow = game.overworld
local function oak()
for _, n in ipairs(ow.npcs or {}) do
if n.def and n.def.name == "PALLETTOWN_OAK" then return n end
end
end
U.hold(game, "up", 40)
local worst, samples = 0, 0
for _ = 1, 1200 do
if game.stack:top() ~= ow then U.tap(game, "a") end
local o = oak()
if o and #ow.scriptMoves > 0 and ow.map.id == "PALLET_TOWN" then
local d = math.abs(o.px - ow.player.px) + math.abs(o.py - ow.player.py)
if samples > 0 or d <= 20 then
samples = samples + 1
if d > worst then worst = d end
if samples == 60 then U.shot(game, DIR .. "/escort_midwalk.png") end
if samples % 24 == 1 then
U.log(("t=%d player=(%d,%d) oak=(%d,%d) dist=%dpx")
:format(samples, ow.player.cellX, ow.player.cellY,
o.cellX, o.cellY, d))
end
end
end
if ow.map.id == "OAKS_LAB" then break end
U.wait(2)
end
U.log(("ESCORT worst separation: %dpx over %d samples"):format(worst, samples))
U.log("map:", ow.map.id, "flag:",
tostring(game.save.flags.EVENT_FOLLOWED_OAK_INTO_LAB))
love.event.quit()
end
@@ -191,12 +191,12 @@ return function(game)
return cond() return cond()
end end
-- Jump the flash clock to just under FLASH_FRAMES (220) once the shot is -- Jump the flash clock to just under FLASH_FRAMES (368) once the shot is
-- taken. EvolutionState:update only compares self.t against that constant, -- taken. EvolutionState:update only compares self.t against that constant,
-- so this ends the animation early without touching the palette logic. -- so this ends the animation early without touching the palette logic.
local function skipToEnd() local function skipToEnd()
local st = evoTop() local st = evoTop()
if st then st.t = 214 end if st then st.t = 362 end
end end
local function startEvo(species, into) local function startEvo(species, into)
@@ -212,7 +212,7 @@ return function(game)
-- ---- case 1: the reported case, SGB, mid-flash -------------------------- -- ---- case 1: the reported case, SGB, mid-flash --------------------------
local weedle = startEvo("WEEDLE", "KAKUNA") local weedle = startEvo("WEEDLE", "KAKUNA")
U.wait(24) -- into the flash, far short of FLASH_FRAMES = 220 U.wait(24) -- into the flash, far short of FLASH_FRAMES = 368
U.shot(game, DIR .. "/evo279_1_flash_sgb.png") U.shot(game, DIR .. "/evo279_1_flash_sgb.png")
local c1 = probe("flash/SGB", { local c1 = probe("flash/SGB", {
monYellow1 = YELLOW[2], monYellow2 = YELLOW[3], monYellow1 = YELLOW[2], monYellow2 = YELLOW[3],
@@ -6,7 +6,7 @@
-- LINK_STATE_TRADING) skip that poll and cannot be cancelled. -- LINK_STATE_TRADING) skip that poll and cannot be cancelled.
-- --
-- Case 1 (level path, cancelable): open EvolutionState directly, wait past -- Case 1 (level path, cancelable): open EvolutionState directly, wait past
-- the 80-frame pre-animLoop delay (still well under FLASH_FRAMES=220), -- the 80-frame pre-animLoop delay (still well under FLASH_FRAMES=368),
-- press B, and assert the mon stays CATERPIE with the "stopped evolving" -- press B, and assert the mon stays CATERPIE with the "stopped evolving"
-- text on screen. -- text on screen.
-- Case 1b: after cancel, checkParty with no level-ups must not re-offer; -- Case 1b: after cancel, checkParty with no level-ups must not re-offer;
@@ -81,7 +81,7 @@ return function(game)
Evolution.evolve(game, mon, "METAPOD", function() done1 = true end) Evolution.evolve(game, mon, "METAPOD", function() done1 = true end)
if not waitFor(evoTop, 300) then error("EvolutionState never opened (case1)") end if not waitFor(evoTop, 300) then error("EvolutionState never opened (case1)") end
U.wait(100) -- past the 80-frame pre-animLoop delay, still under 220 U.wait(100) -- past the 80-frame pre-animLoop delay, still under 368
U.log("case1 flash", "t=", top().t, "species=", mon.species) U.log("case1 flash", "t=", top().t, "species=", mon.species)
U.shot(game, DIR .. "/evo213_1_evolving.png") U.shot(game, DIR .. "/evo213_1_evolving.png")
@@ -133,8 +133,8 @@ return function(game)
local done2 = false local done2 = false
Evolution.evolve(game, mon2, "METAPOD", function() done2 = true end) Evolution.evolve(game, mon2, "METAPOD", function() done2 = true end)
if not waitFor(evoTop, 300) then error("EvolutionState never opened (case2)") end if not waitFor(evoTop, 300) then error("EvolutionState never opened (case2)") end
-- let the full flash run (FLASH_FRAMES=220) without pressing B -- let the full flash run (FLASH_FRAMES=368) without pressing B
waitFor(function() return not evoTop() end, 400) waitFor(function() return not evoTop() end, 500)
if not waitFor(function() return findText("evolved into") ~= nil end, 120) then if not waitFor(function() return findText("evolved into") ~= nil end, 120) then
error("Congratulations text not shown (case2)") error("Congratulations text not shown (case2)")
end end
+3 -3
View File
@@ -89,8 +89,8 @@ return function(game)
Evolution.evolve(game, mon, "GYARADOS", function() done1 = true end) Evolution.evolve(game, mon, "GYARADOS", function() done1 = true end)
if not waitFor(evoTop, 300) then error("EvolutionState never opened (case1)") end if not waitFor(evoTop, 300) then error("EvolutionState never opened (case1)") end
-- let the full flash run (FLASH_FRAMES=220) with no input, then apply -- let the full flash run (FLASH_FRAMES=368) with no input, then apply
waitFor(function() return not evoTop() end, 400) waitFor(function() return not evoTop() end, 500)
if not waitFor(function() return findText("evolved into") ~= nil end, 120) then if not waitFor(function() return findText("evolved into") ~= nil end, 120) then
error("Congratulations text not shown (case1)") error("Congratulations text not shown (case1)")
end end
@@ -124,7 +124,7 @@ return function(game)
local done2 = false local done2 = false
Evolution.evolve(game, mon2, "GYARADOS", function() done2 = true end) Evolution.evolve(game, mon2, "GYARADOS", function() done2 = true end)
if not waitFor(evoTop, 300) then error("EvolutionState never opened (case2)") end if not waitFor(evoTop, 300) then error("EvolutionState never opened (case2)") end
waitFor(function() return not evoTop() end, 400) waitFor(function() return not evoTop() end, 500)
if not waitFor(function() return findText("evolved into") ~= nil end, 120) then if not waitFor(function() return findText("evolved into") ~= nil end, 120) then
error("Congratulations text not shown (case2)") error("Congratulations text not shown (case2)")
end end
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,188 @@
-- Driver: a trainer who walked up to the player must be back on her spawn
-- cell the next time her map loads, connection crossings included (#1028).
-- .loadNewMap calls LoadMapHeader for a seam crossing exactly as a warp does,
-- and .loadSpriteData zeroes the sprite state data and re-seeds every
-- SPRITESTATEDATA2_MAPY/MAPX from the map header's object data
-- (pokered home/overworld.asm), so only the save-side defeat flag survives.
--
-- Route 3's Lass (data/maps/objects/Route3.asm object_event 23, 4,
-- SPRITE_COOLTRAINER_F, STAY, LEFT, ..., OPP_LASS) has sight range 4, so
-- standing above the third Bug Catcher at (19,4) is inside her line and she
-- walks dist-1 = 3 cells west to (20,4) -- the reporter's exact setup.
--
-- POKEPORT_DRIVER=tests/drivers/trainer_reset_bug1028_test.lua \
-- POKEPORT_IDENTITY=bug1028 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
local Zoom = require("src.render.Zoom")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local MAP = "ROUTE_3"
local TARGET = 6
local SPAWN = { x = 23, y = 4 }
local STAND = { x = 19, y = 4 }
local pass = true
local function check(label, ok)
if not ok then pass = false end
U.log(ok and "PASS" or "FAIL", label)
return ok
end
local function lass()
local ow = game.overworld
for _, n in ipairs(ow and ow.npcs or {}) do
if n.def and n.def.index == TARGET then return n end
end
return nil
end
local function at(n) return n and (n.cellX .. "," .. n.cellY) or "absent" end
-- hold a direction until cond() or the budget runs out, then let the
-- half-finished step land
local function holdUntil(btn, cond, budget)
local first = true
for _ = 1, budget or 900 do
if cond() then break end
if first then table.insert(game.input.pressQueue, btn); first = false end
game.input.state[btn] = true
coroutine.yield()
end
game.input.state[btn] = false
for _ = 1, 40 do
local ow = game.overworld
if ow and not ow.player.moving then break end
coroutine.yield()
end
U.wait(4)
return cond()
end
local function walkTo(btn, axis, want, budget)
return holdUntil(btn, function()
local p = game.overworld and game.overworld.player
return p and p[axis] == want and not p.moving
end, budget)
end
local function onMap(id)
return game.overworld and game.overworld.map and game.overworld.map.id == id
end
-- mash A until the battle stack unwinds back to the overworld
local function mashToOverworld(budget)
for _ = 1, budget or 4000 do
if game.stack:top() == game.overworld and not game.overworld.engaging then
return true
end
U.tap(game, "a")
U.wait(3)
end
return false
end
-- absolute zoom: reset first so every shot frames the same amount of world
local function survey(n)
Zoom.reset()
U.wait(2)
for _ = 1, n do game:zoomStep(-1) end
U.wait(30)
end
-- back up to the sighting cell so the before/after shots share a camera
local function returnToStand()
walkTo("right", "cellX", 11)
walkTo("up", "cellY", 4)
walkTo("right", "cellX", STAND.x)
end
game.save.party = {
Pokemon.new(game.data, "CHARIZARD", 80),
Pokemon.new(game.data, "SNORLAX", 80),
}
game.save.player.name = "MATT"
U.teleport(game, MAP, STAND.x, STAND.y, "right")
U.wait(20)
-- only the target Lass may engage: the reporter has already cleared the
-- Bug Catchers and Youngsters ahead of her
local ow = game.overworld
for _, n in ipairs(ow.npcs) do
if n.def.trainerClass and n.def.index ~= TARGET then
game.save.defeatedTrainers[n.id] = true
end
end
local l = lass()
check("Lass starts on her spawn cell " .. SPAWN.x .. "," .. SPAWN.y,
l ~= nil and l.cellX == SPAWN.x and l.cellY == SPAWN.y)
U.log("spawn:", at(l))
-- she sights the player and walks up, then the battle runs
for _ = 1, 600 do
if ow.engaging then break end
coroutine.yield()
end
check("the Lass sighted the player at " .. STAND.x .. "," .. STAND.y, ow.engaging == true)
check("the battle ran to completion", mashToOverworld())
U.wait(30)
l = lass()
local movedX, movedY = l and l.cellX, l and l.cellY
U.log("after the walk-up she stands at", at(l))
check("she is off her spawn cell after walking up",
l ~= nil and not (movedX == SPAWN.x and movedY == SPAWN.y))
check("she is recorded as defeated", game.save.defeatedTrainers[l.id] == true)
survey(4)
U.shot(game, DIR .. "/bug1028_1_walked_up.png")
Zoom.reset()
U.wait(5)
-- west along row 4, down the single gap at column 11, then west on row 9
-- to the Pewter City seam
walkTo("left", "cellX", 11)
walkTo("down", "cellY", 9)
check("reached the descent column", game.overworld.player.cellX == 11
and game.overworld.player.cellY == 9)
-- crossing x=0 westward is the seam: crossConnection, not a warp
holdUntil("left", function() return onMap("PEWTER_CITY") end, 1200)
check("crossed the seam into Pewter City", onMap("PEWTER_CITY"))
survey(12)
U.shot(game, DIR .. "/bug1028_2_pewter_survey.png")
Zoom.reset()
U.wait(5)
-- and back east into Route 3, the map load that must re-seed her
holdUntil("right", function() return onMap(MAP) end, 1200)
check("crossed back into Route 3", onMap(MAP))
U.wait(30)
l = lass()
U.log("after the return crossing she stands at", at(l))
check("she is back on her spawn cell " .. SPAWN.x .. "," .. SPAWN.y,
l ~= nil and l.cellX == SPAWN.x and l.cellY == SPAWN.y)
check("she is still recorded as defeated",
l ~= nil and game.save.defeatedTrainers[l.id] == true)
-- same cell, same zoom as shot 1: she should now be three cells further
-- east, and being defeated she must not re-engage on the way back in
returnToStand()
check("walked back to the sighting cell",
game.overworld.player.cellX == STAND.x
and game.overworld.player.cellY == STAND.y)
check("a defeated trainer does not re-engage", game.overworld.engaging ~= true)
survey(4)
U.shot(game, DIR .. "/bug1028_3_reset.png")
Zoom.reset()
U.log(pass and "RESULT PASS" or "RESULT FAIL")
love.event.quit(pass and 0 or 1)
while true do coroutine.yield() end
end
+3 -1
View File
@@ -420,6 +420,7 @@ local GEN2_HOOKS = {
-- that a Gen 1 mod reaching through ctx.battle.data instead of calling -- that a Gen 1 mod reaching through ctx.battle.data instead of calling
-- nextFn gets nil there). -- nextFn gets nil there).
"battle.catch_exp", "battle.low_health_alarm", "battle.overlay", "battle.catch_exp", "battle.low_health_alarm", "battle.overlay",
"battle.bottom_ui_visible", "battle.status_hud_visible",
-- One pic path resolver for both games: the Gen 1 site is the SHARED -- One pic path resolver for both games: the Gen 1 site is the SHARED
-- src/pokemon/Sprites.lua and Gold's own battle screen calls the same hook -- src/pokemon/Sprites.lua and Gold's own battle screen calls the same hook
-- with the Gen 1 ctx keys plus `letter` and `shiny`, which Red has no -- with the Gen 1 ctx keys plus `letter` and `shiny`, which Red has no
@@ -434,7 +435,8 @@ local GEN2_HOOKS = {
-- pointer with the touch overlay given first refusal, the palette zone list -- pointer with the touch overlay given first refusal, the palette zone list
-- handed to the present pass, the letterbox and the HUD rect. -- handed to the present pass, the letterbox and the HUD rect.
"input.step", "input.pointer", "input.step", "input.pointer",
"render.zones", "render.compose", "render.letterbox", "render.hud", "render.zones", "render.compose", "render.output_enabled", "render.output",
"render.letterbox", "render.hud",
} }
local function assertShared(name, sites, kind) local function assertShared(name, sites, kind)
+47
View File
@@ -0,0 +1,47 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Game2 = require("src.core.Game2")
local Runtime = require("src.mods.Runtime")
local Hooks = require("src.mods.Hooks")
local savedHooks = Runtime.hooks
local hooks = Hooks.new()
Runtime.hooks = hooks
local received
hooks:wrap("render.output", function(_, context)
received = context
return true
end, 0, "test")
hooks:wrap("render.output_enabled", function() return false end, 0, "test")
local canvas = love.graphics.newCanvas(640, 480)
local presentCalls = 0
local game = setmetatable({
frameFit = function() return 3, 80, 24, 1, 480, 432 end,
presentCanvas = function() presentCalls = presentCalls + 1 return canvas end,
drawScene = function() end,
drawHud = function() end,
}, Game2)
Game2.draw(game)
T.eq(received, nil, "a disabled Gold output hook does not run")
T.eq(presentCalls, 0, "a disabled Gold output hook keeps the direct path")
hooks:wrap("render.output_enabled", function() return true end, 10, "test")
Game2.draw(game)
T.check(received ~= nil, "Gold raises render.output for an enabled subscriber")
T.eq(received and received.canvas, canvas,
"Gold hands render.output the finished present canvas")
T.eq(received and received.generation, 2,
"Gold identifies the output context without changing Gen 1")
T.eq(received and received.gameX, 80, "Gold output carries the fitted game X")
T.eq(received and received.gameY, 24, "Gold output carries the fitted game Y")
T.eq(received and received.gameWidth, 480,
"Gold output carries the fitted game width")
T.eq(received and received.gameHeight, 432,
"Gold output carries the fitted game height")
Runtime.hooks = savedHooks
T.finish("gen2 render output seam")
@@ -0,0 +1,62 @@
-- Mods "Show for" chips collapse to a dropdown when they cannot all fit.
-- Landscape / desktop keep the individual game chips. No pokered cite: the
-- launcher is port-only chrome.
-- luajit tests/engine/launcher_mod_scope_dropdown.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check = T.check
love = love or require("tests.love_stub")
love.graphics.setLineJoin = love.graphics.setLineJoin or function() end
love.graphics.newShader = love.graphics.newShader or function() return {} end
local Kit = require("src.ui.kit.Kit")
local RomImporter = require("src.import.RomImporter")
local LauncherView = require("src.import.LauncherView")
local function window(w, h)
love.graphics.getDimensions = function() return w, h end
love.graphics.getPixelDimensions = function() return w, h end
end
local function navIds()
local ids = {}
for i = 1, (Kit._navPrevN or 0) do
local slot = Kit._nav[i]
if slot and slot.id then ids[slot.id] = true end
end
return ids
end
local function drawMods(W, H)
window(W, H)
local imp = RomImporter.new(function() end, { launcher = true })
imp.tab = "mods"
imp.ready = { red = true, blue = true, yellow = true, gold = true }
local ok, err = pcall(LauncherView.draw, imp)
check(ok, ("%dx%d mods draws: %s"):format(W, H, tostring(err)))
return navIds()
end
local portrait = drawMods(360, 780)
check(portrait["mod-scope-menu"] == true,
"portrait: Show-for collapses to a dropdown when the chips cannot all fit")
check(portrait["mod-scope-gold"] == nil
and portrait["mod-scope-red"] == nil
and portrait["mod-scope-blue"] == nil
and portrait["mod-scope-yellow"] == nil,
"portrait: individual version chips are not drawn beside the dropdown")
local desktop = drawMods(1280, 720)
check(desktop["mod-scope-menu"] == nil,
"desktop: chips fit, so there is no dropdown")
check(desktop["mod-scope-all"] == true
and desktop["mod-scope-red"] == true
and desktop["mod-scope-blue"] == true
and desktop["mod-scope-yellow"] == true
and desktop["mod-scope-gold"] == true,
"desktop: every Show-for chip stays reachable")
print("ok launcher mod scope dropdown")
@@ -23,7 +23,7 @@ local function importer(field)
end end
local modalFields = { local modalFields = {
"_modConfirm", "_modVersions", "_modReleaseNotes", "_findDetails", "_modConfirm", "_modVersions", "_modReleaseNotes", "_appPatchNotes", "_findDetails",
} }
for _, field in ipairs(modalFields) do for _, field in ipairs(modalFields) do
@@ -77,6 +77,12 @@ do
imp:keypressed("escape") imp:keypressed("escape")
eq(imp._modReleaseNotes, nil, "Escape closes release notes") eq(imp._modReleaseNotes, nil, "Escape closes release notes")
end end
do
resetFocus()
local imp = importer("_appPatchNotes")
imp:keypressed("escape")
eq(imp._appPatchNotes, nil, "Escape closes patch notes")
end
do do
resetFocus() resetFocus()
local imp = importer("_modVersions") local imp = importer("_modVersions")

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