mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-15 15:51:17 +02:00
Compare commits
71 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a86d57ac44 | |||
| 72f126ae26 | |||
| 5d7a8c9291 | |||
| c48fc578ca | |||
| dfc216f974 | |||
| 542856c83d | |||
| 545a99d86d | |||
| 2b6473ae03 | |||
| df0be1cba6 | |||
| c9d67582ae | |||
| 1cfd91a503 | |||
| fb738fa1ce | |||
| c0ad6d0328 | |||
| 84de8c9cb1 | |||
| 6307bc92f6 | |||
| 052dd26b3e | |||
| c8f6c7241b | |||
| b3928388ef | |||
| a38fae5a97 | |||
| 9fab992d42 | |||
| dcc388a942 | |||
| 797a6bebfe | |||
| 97a9c0f58f | |||
| 407f649e9d | |||
| 84635cdfdf | |||
| 3de45b671c | |||
| 4395792226 | |||
| a77210799f | |||
| 6f67292b0e | |||
| 78e8a31ead | |||
| b6388013ec | |||
| ba7cd8fabf | |||
| c8f7f396ce | |||
| a94fecfec8 | |||
| 2010ba71a6 | |||
| e90dff6764 | |||
| f4658b89ae | |||
| da63163216 | |||
| fceaa2b54c | |||
| be2ef46ebc | |||
| d8e86e6878 | |||
| 7e57e3174e | |||
| 5192106730 | |||
| dfeacc36b0 | |||
| 1586aec9f6 | |||
| 35d44efb8b | |||
| 500d8c2c07 | |||
| 09204daa28 | |||
| 01329758ff | |||
| 7b8cb127a8 | |||
| 8e4d5b6807 | |||
| 26e9e1d597 | |||
| f7f32d2786 | |||
| c5192eaea4 | |||
| c2906744ce | |||
| 8e6b5ae73d | |||
| 0b96ac0ab6 | |||
| f6a035947f | |||
| 941181d31c | |||
| 9ceb1a8940 | |||
| 37051a26b5 | |||
| b12a0dac91 | |||
| e4786a78d9 | |||
| bde606f966 | |||
| a722d24759 | |||
| 833388c235 | |||
| 6ff7dd0c38 | |||
| c0f654f8c2 | |||
| e44769a48a | |||
| d38faab03c | |||
| 83682f011d |
@@ -252,8 +252,40 @@ jobs:
|
||||
Remove-Item $env:UWP_PFX -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# Windows Native AOT TLS dialer. The Mac release runner fuses the win64 zip
|
||||
# from LÖVE's prebuilt binaries and cannot cross-compile this DLL, so build
|
||||
# it here and inject it in the release job before scripts/build.sh win.
|
||||
native-tls-win:
|
||||
name: build Windows gen1tls.dll
|
||||
needs: version
|
||||
runs-on: windows-2022
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Setup .NET 8
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: "8.0.x"
|
||||
- name: Publish gen1tls (win-x64 Native AOT)
|
||||
shell: pwsh
|
||||
run: |
|
||||
$out = "dist/native/win-x64"
|
||||
New-Item -ItemType Directory -Force -Path $out | Out-Null
|
||||
dotnet publish native/tls_dial/Gen1Tls.csproj `
|
||||
-c Release -r win-x64 -o $out
|
||||
if (-not (Test-Path "$out/gen1tls.dll")) {
|
||||
throw "gen1tls.dll missing after publish"
|
||||
}
|
||||
Get-Item "$out/gen1tls.dll" | Format-List Name, Length, LastWriteTime
|
||||
- name: Upload gen1tls.dll
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: gen1tls-win-x64
|
||||
path: dist/native/win-x64/gen1tls.dll
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
release:
|
||||
needs: [version, xbox-uwp, linux-arm64]
|
||||
needs: [version, xbox-uwp, linux-arm64, native-tls-win]
|
||||
runs-on: ${{ fromJSON(github.repository == 'bryanthaboi/gen1recomp' && '["self-hosted", "macOS"]' || '"macos-latest"') }}
|
||||
|
||||
steps:
|
||||
@@ -269,6 +301,12 @@ jobs:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- name: Download Windows gen1tls dialer
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: gen1tls-win-x64
|
||||
path: dist/native/win-x64
|
||||
|
||||
- name: Import signing certificate into a temporary keychain
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
run: |
|
||||
@@ -307,13 +345,21 @@ jobs:
|
||||
security find-identity -v -p codesigning "$KEYCHAIN_PATH"
|
||||
|
||||
- name: Build macOS + Windows + Linux
|
||||
env:
|
||||
GEN1TLS_DLL: ${{ github.workspace }}/dist/native/win-x64/gen1tls.dll
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Sign in-build (identity auto-detected from the temp keychain);
|
||||
# notarize separately below so it uses secret credentials, not a
|
||||
# login-keychain profile. "all" also builds the Linux AppImage,
|
||||
# which needs no signing/notarization.
|
||||
if [ ! -f "$GEN1TLS_DLL" ]; then
|
||||
echo "::error::gen1tls.dll missing at $GEN1TLS_DLL (native-tls-win job)"
|
||||
exit 1
|
||||
fi
|
||||
scripts/build.sh all --version "${{ needs.version.outputs.version }}" --no-notarize
|
||||
unzip -l dist/win/gen1recomp-win64.zip | grep -F gen1tls.dll \
|
||||
|| { echo "::error::Windows zip is missing gen1tls.dll"; exit 1; }
|
||||
|
||||
- name: Build Android
|
||||
run: |
|
||||
|
||||
+11
-1
@@ -3,8 +3,9 @@
|
||||
data/generated/
|
||||
assets/generated/
|
||||
|
||||
# LÖVE packages
|
||||
# LÖVE packages & archives
|
||||
*.love
|
||||
*.zip
|
||||
|
||||
# Local saves (LÖVE writes to its save dir, but keep the repo clean anyway)
|
||||
save/
|
||||
@@ -75,3 +76,12 @@ mobile/ios/bundle_id.local
|
||||
/dist/native/
|
||||
/dist/win/
|
||||
/.bazinga/
|
||||
|
||||
# Local options / preferences
|
||||
/options.lua*
|
||||
|
||||
# User-owned ROMs imported for individual mods. Manifests declare the
|
||||
# destinations, but source checkouts and packaged mods never ship the files.
|
||||
/mods/*/baseroms/
|
||||
/imports/baseroms/
|
||||
/imports/baseroms-recovery/
|
||||
|
||||
+48
-3
@@ -242,10 +242,55 @@ with a `MK4xx` finding per site and an `unresolved:` note, with a file and a
|
||||
line, for every reach a static scan could not follow. Neither substitutes for a
|
||||
real Gold boot.
|
||||
|
||||
### 5. `mod.card`
|
||||
### 5. What a mod's code can reach
|
||||
|
||||
Your code runs in a sandbox (`src/mods/Sandbox.lua`), not against the
|
||||
engine's globals. Every chunk you author gets it: `main.lua`, your
|
||||
`options_schema`, and anything you `load()` yourself. What is absent:
|
||||
|
||||
| Absent | Use instead |
|
||||
| --- | --- |
|
||||
| `io`, and `require("io")` | `mod:read` for your own files, `mod.storage` to persist |
|
||||
| `os.getenv`, `os.execute`, `os.remove`, `os.rename`, `os.exit` | nothing; `os.time`/`os.date`/`os.clock` still work |
|
||||
| `package`, `dofile`, `loadfile`, `debug`, `getfenv`, `setfenv` | `require` for the supported engine modules |
|
||||
| `require("ffi")`, `require("love.*")` | the `love` table you are given |
|
||||
| `love.filesystem` | `mod.storage` (per-mod, per-playthrough), `mod:read` for a known file, `mod:list` / `mod:info` to iterate your own directory |
|
||||
| `love.thread`, `love.event` | `mod.events`, `mod.hooks` |
|
||||
| `love.system` | `mod.device:powerInfo()` for battery information; `mod.steps` (with the `steps` permission) for the step bridge |
|
||||
|
||||
The rest of `love` passes through unchanged, so graphics, audio, timers and
|
||||
input work as they always have.
|
||||
|
||||
Three consequences worth knowing before you write against it:
|
||||
|
||||
- **Your globals are yours.** `_G` inside a mod is that mod's own table. Two
|
||||
mods no longer share a namespace, and neither can reach the engine's. To
|
||||
publish something to another mod, put it on `mod.exports` and let them
|
||||
`mod.find("your_id").exports` — the channel that was always the intended
|
||||
one. The same goes for the standard library: `string`, `table` and `math`
|
||||
are per-mod copies, so patching one is a local decision.
|
||||
- **Paths cannot climb.** `mod:read`, `mod:list`, `mod:info`, `mod.assets:path`
|
||||
and `mod.assets:image` join to your own directory, and `..`, absolute paths
|
||||
and drive letters are refused. So are `entry` and `options_schema` in your
|
||||
manifest. `mod:list("assets")` is the sandboxed `getDirectoryItems` for a
|
||||
folder you shipped; `mod:info` tells file from directory so a walk can
|
||||
recurse.
|
||||
- **Ship source, not bytecode.** A precompiled entry file is refused.
|
||||
|
||||
`permissions` in the manifest is still a disclosure the manager shows the
|
||||
player, and `network` now gates `require("socket")` and friends. There is no
|
||||
permission that grants raw filesystem access, because no mod needs one:
|
||||
everything a mod legitimately writes is already scoped by
|
||||
`mod.storage` or the asset-transform derived root.
|
||||
|
||||
If your mod used one of the absent globals, the fix is almost always
|
||||
`mod.storage`. Open an issue if you have a case it does not cover.
|
||||
|
||||
### 6. `mod.card`
|
||||
|
||||
The manifest is the *engine's* contract: identity, load order, dependencies,
|
||||
permissions, profile. The card is the *human-facing* one: who made this,
|
||||
permissions, profile (see [Manifest specification](docs/modding.md#manifest-specification-manifestjson)).
|
||||
The card is the *human-facing* one: who made this,
|
||||
what it changes, what it does not do yet. It is never read by the loader's
|
||||
merge — only by tooling and the manager's detail pane — so an absent or
|
||||
malformed card can never break a load.
|
||||
@@ -263,7 +308,7 @@ Two fields deserve their own note:
|
||||
distributed mod never carries ROM-derived bytes, not even in its preview
|
||||
images.
|
||||
|
||||
### 6. Tags
|
||||
### 7. Tags
|
||||
|
||||
Lowercase kebab strings, open vocabulary. The showcase generator
|
||||
lowercases and de-dupes. A recommended starting set: `beginner`,
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 243 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 217 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 236 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 242 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 166 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 251 KiB |
@@ -92,7 +92,7 @@ return {
|
||||
{ "set_flag", "EVENT_GOT_EEVEE" }, -- 7
|
||||
{ "hide_object", "CELADON_MANSION_ROOF_HOUSE",
|
||||
"CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" }, -- 8
|
||||
{ "play_sound", "Get_Item1" }, -- 9 (GotMonText jingle)
|
||||
{ "text_sound", "Get_Item1" }, -- 9 (GotMonText jingle)
|
||||
{ "show_text", "_GotMonText", { RAM = "EEVEE" } }, -- 10
|
||||
{ "jump", 13 }, -- 11
|
||||
{ "show_text", "_BoxIsFullText" }, -- 12
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
local function coinGiver(opts)
|
||||
return function(game, ow, npc, done)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Sound = require("src.core.Sound")
|
||||
local t = game.data.text
|
||||
local function push(label, fallback, onDone)
|
||||
game.stack:push(TextBox.new(game, t[label] or fallback, onDone or done))
|
||||
local function push(label, fallback, onDone, popts)
|
||||
game.stack:push(TextBox.new(game, t[label] or fallback, onDone or done,
|
||||
popts))
|
||||
end
|
||||
if game.save.flags[opts.event] then
|
||||
push(opts.alreadyGotLabel, opts.alreadyGotFallback)
|
||||
@@ -30,9 +30,10 @@ local function coinGiver(opts)
|
||||
end
|
||||
game.save.coins = math.min(9999, (game.save.coins or 0) + opts.amount)
|
||||
game.save.flags[opts.event] = true
|
||||
Sound.play(game.data, "Get_Item1")
|
||||
-- the ReceivedNCoinsText strings carry sound_get_item_1
|
||||
push(opts.receivedLabel,
|
||||
("{PLAYER} received\n%d coins!"):format(opts.amount))
|
||||
("{PLAYER} received\n%d coins!"):format(opts.amount), nil,
|
||||
TextBox.soundOpts(game, "Get_Item1"))
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -22,8 +22,8 @@ return {
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Commands = require("src.script.Commands")
|
||||
local t = game.data.text
|
||||
local function say(label, cb)
|
||||
game.stack:push(TextBox.new(game, t[label] or label, cb))
|
||||
local function say(label, cb, sopts)
|
||||
game.stack:push(TextBox.new(game, t[label] or label, cb, sopts))
|
||||
end
|
||||
|
||||
if game.save.flags.EVENT_GOT_OLD_AMBER then
|
||||
@@ -39,8 +39,9 @@ return {
|
||||
game.save.flags.EVENT_GOT_OLD_AMBER = true
|
||||
Commands.hide_object({ save = game.save, overworld = ow, game = game },
|
||||
"MUSEUM_1F", "MUSEUM1F_OLD_AMBER")
|
||||
require("src.core.Sound").play(game.data, "Get_Item1")
|
||||
say("_Museum1FScientist2ReceivedOldAmberText", done)
|
||||
-- .ReceivedOldAmberText carries sound_get_item_1
|
||||
say("_Museum1FScientist2ReceivedOldAmberText", done,
|
||||
TextBox.soundOpts(game, "Get_Item1"))
|
||||
end)
|
||||
end,
|
||||
|
||||
|
||||
+19
-19
@@ -38,9 +38,9 @@ local function starterBall(askText, species, choseFlag, ownBall,
|
||||
-- inside give_pokemon). Show the received text first so the
|
||||
-- nickname prompt follows "you got X", matching Gen1.
|
||||
-- The received text carries sound_get_key_item (OaksLab.asm
|
||||
-- OaksLabReceivedMonText); the jingle plays as the box opens
|
||||
-- (same beat as the Yellow port's starter, #668).
|
||||
{ "play_sound", "Get_Key_Item" }, -- 8
|
||||
-- OaksLabReceivedMonText), so the jingle fires once the box has
|
||||
-- typed out and holds it (same beat as the Yellow port's starter, #668).
|
||||
{ "text_sound", "Get_Key_Item" }, -- 8
|
||||
{ "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 9
|
||||
{ "give_pokemon", species, 5 }, -- 10
|
||||
{ "set_flag", "EVENT_GOT_STARTER" }, -- 11
|
||||
@@ -54,7 +54,7 @@ local function starterBall(askText, species, choseFlag, ownBall,
|
||||
{ "face_object", 1, "up" }, -- 15
|
||||
{ "show_text", "_OaksLabRivalIllTakeThisOneText" }, -- 16
|
||||
{ "hide_object", "OAKS_LAB", rivalBall }, -- 17
|
||||
{ "play_sound", "Get_Key_Item" }, -- 18 (sound_get_key_item)
|
||||
{ "text_sound", "Get_Key_Item" }, -- 18 (sound_get_key_item)
|
||||
{ "show_text", "_OaksLabRivalReceivedMonText",
|
||||
{ RAM = rivalBall == "OAKSLAB_CHARMANDER_POKE_BALL" and "CHARMANDER"
|
||||
or rivalBall == "OAKSLAB_SQUIRTLE_POKE_BALL" and "SQUIRTLE"
|
||||
@@ -106,8 +106,8 @@ return {
|
||||
{ "check_item", "OAKS_PARCEL" },
|
||||
{ "jump_if_false", "raise_young" },
|
||||
-- OaksLabOak1Text.got_parcel → RivalArrives + OakGivesPokedex
|
||||
{ "text_sound", "Get_Key_Item" },
|
||||
{ "show_text", "_OaksLabOak1DeliverParcelText" },
|
||||
{ "play_sound", "Get_Key_Item" },
|
||||
{ "show_text", "_OaksLabOak1ParcelThanksText" },
|
||||
{ "take_item", "OAKS_PARCEL", 1 },
|
||||
{ "stop_music" },
|
||||
@@ -128,8 +128,8 @@ return {
|
||||
{ "face_object", 1, "up" },
|
||||
{ "face_object", 5, "down" },
|
||||
{ "show_text", "_OaksLabOakMyInventionPokedexText" },
|
||||
{ "text_sound", "Get_Key_Item" },
|
||||
{ "show_text", "_OaksLabOakGotPokedexText" },
|
||||
{ "play_sound", "Get_Key_Item" },
|
||||
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" },
|
||||
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" },
|
||||
{ "face_object", 1, "up" },
|
||||
@@ -246,9 +246,17 @@ return {
|
||||
-- the table sprites; re-entering the lab applies the same HideObject
|
||||
-- the gift script now does (OaksLab.asm OakGivesPokedex).
|
||||
onEnter = function(game, ow)
|
||||
if not (game.save.flags and game.save.flags.EVENT_GOT_POKEDEX) then
|
||||
return
|
||||
local flags = game.save.flags or {}
|
||||
if flags.EVENT_GOT_STARTER and not flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB then
|
||||
local rival = ow:npcByIndex(1)
|
||||
if rival then
|
||||
rival.cellX = flags.EVENT_CHOSE_CHARMANDER and 7
|
||||
or flags.EVENT_CHOSE_SQUIRTLE and 8 or 6
|
||||
rival.cellY = 4
|
||||
rival.px, rival.py = rival.cellX * 16, rival.cellY * 16
|
||||
end
|
||||
end
|
||||
if not flags.EVENT_GOT_POKEDEX then return end
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { save = game.save, game = game, overworld = ow }
|
||||
Commands.hide_object(ctx, "OAKS_LAB", "OAKSLAB_POKEDEX1")
|
||||
@@ -286,6 +294,7 @@ return {
|
||||
-- fanfare for the taunt/challenge exchange, same as the Yellow port
|
||||
-- (oaks_lab_yellow.lua); it was silently dropped here (#596).
|
||||
local rows = {
|
||||
{ "face_player_dir", "up" },
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetRival" },
|
||||
{ "show_text", "_OaksLabRivalIllTakeYouOnText" }, -- 1
|
||||
@@ -309,28 +318,19 @@ return {
|
||||
local base = #rows
|
||||
local party = flags.EVENT_CHOSE_BULBASAUR and 3
|
||||
or flags.EVENT_CHOSE_SQUIRTLE and 2 or 1
|
||||
table.insert(rows, { "save_end_battle_text", "_OaksLabRivalIPickedTheWrongPokemonText" })
|
||||
table.insert(rows, { "start_battle", "trainer", "OPP_RIVAL1", party })
|
||||
-- OaksLabRivalEndBattleScript: heal + flag on win or loss; no blackout
|
||||
table.insert(rows, { "heal_party" })
|
||||
table.insert(rows, { "set_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" })
|
||||
-- OaksLabRivalEndBattleScript: on WIN, print the "picked the wrong
|
||||
-- POKéMON!" gloat, then BOTH win and loss print the shared exit line
|
||||
-- _OaksLabRivalSmellYouLaterText ("OK! I'll make my POKéMON fight to
|
||||
-- toughen it up!\012<PLAYER>! Gramps! Smell you later!") before Blue
|
||||
-- marches out. A loss skips only the gloat (that taunt was already
|
||||
-- shown in-battle via Rival1WinText), never the exit line (#231). The
|
||||
-- jump_if_false convergence point is the exit line: base+6 indexes the
|
||||
-- SmellYouLater row below, so WIN falls IPicked -> SmellYouLater and
|
||||
-- LOSS jumps straight to SmellYouLater (both then walk-out + hide).
|
||||
table.insert(rows, { "jump_if_false", base + 6 })
|
||||
table.insert(rows, { "show_text", "_OaksLabRivalIPickedTheWrongPokemonText" })
|
||||
table.insert(rows, { "show_text", "_OaksLabRivalSmellYouLaterText" })
|
||||
-- OaksLabRivalStartsExitScript: parting shot, rival exit fanfare, then
|
||||
-- walk out past the player. The fanfare was dropped here (#683) -- the
|
||||
-- parcel scene above already plays Music_MeetRival on both arrival and
|
||||
-- departure (lines 144-146), and this exit should match (#596).
|
||||
table.insert(rows, { "stop_music" })
|
||||
table.insert(rows, { "play_music", "Music_MeetRival" })
|
||||
table.insert(rows, { "play_music", "Music_MeetRival", { start = "rival" } })
|
||||
table.insert(rows, { "move_npc_to", 1, 4, 11 })
|
||||
table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" })
|
||||
table.insert(rows, { "play_music", "Music_OaksLab" })
|
||||
|
||||
@@ -38,8 +38,8 @@ return {
|
||||
{ "jump_if_false", "raise_young" },
|
||||
-- .DeliverParcelText: parcel handover, then the Pokédex scene
|
||||
-- (OaksLabRivalArrivesAtOaksRequestScript -> OakGivesPokedexScript)
|
||||
{ "text_sound", "Get_Key_Item" },
|
||||
{ "show_text", "_OaksLabOak1DeliverParcelText" },
|
||||
{ "play_sound", "Get_Key_Item" },
|
||||
{ "show_text", "_OaksLabOak1ParcelThanksText" },
|
||||
{ "take_item", "OAKS_PARCEL", 1 },
|
||||
{ "stop_music" },
|
||||
@@ -62,8 +62,8 @@ return {
|
||||
{ "face_object", RIVAL, "up" },
|
||||
{ "face_object", OAK1, "down" },
|
||||
{ "show_text", "_OaksLabOakMyInventionPokedexText" },
|
||||
{ "text_sound", "Get_Key_Item" },
|
||||
{ "show_text", "_OaksLabOakGotPokedexText" },
|
||||
{ "play_sound", "Get_Key_Item" },
|
||||
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" },
|
||||
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" },
|
||||
{ "face_object", RIVAL, "up" },
|
||||
@@ -113,8 +113,8 @@ return {
|
||||
{ "jump_if_true", "come_see" },
|
||||
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" },
|
||||
{ "give_item", "POKE_BALL", 5, false },
|
||||
{ "text_sound", "Get_Key_Item" },
|
||||
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" },
|
||||
{ "play_sound", "Get_Key_Item" },
|
||||
{ "show_text", "_OaksLabGivePokeballsExplanationText" },
|
||||
{ "jump", "end" },
|
||||
|
||||
@@ -176,7 +176,7 @@ return {
|
||||
-- rival starter baseline (RIVAL_STARTER_JOLTEON) at snatch time
|
||||
rows[#rows + 1] = { "set_field", "rivalStarter", 1 }
|
||||
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText1" }
|
||||
rows[#rows + 1] = { "play_sound", "Get_Key_Item" }
|
||||
rows[#rows + 1] = { "text_sound", "Get_Key_Item" }
|
||||
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText2" }
|
||||
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText3" }
|
||||
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText4" }
|
||||
@@ -196,7 +196,7 @@ return {
|
||||
rows[#rows + 1] = { "face_object", OAK1, "down" }
|
||||
-- OaksLabPlayerReceivedMonText clears wMonDataLocation, so AskName runs (#1013)
|
||||
rows[#rows + 1] = { "show_text", "_OaksLabOakGivesText" }
|
||||
rows[#rows + 1] = { "play_sound", "Get_Key_Item" }
|
||||
rows[#rows + 1] = { "text_sound", "Get_Key_Item" }
|
||||
rows[#rows + 1] = { "show_text", "_OaksLabReceivedText", { RAM = "PIKACHU" } }
|
||||
rows[#rows + 1] = { "give_pokemon", "PIKACHU", 5 }
|
||||
-- DisablePikachuOverworldSpriteDrawing keeps it in the ball (#1009)
|
||||
@@ -226,9 +226,15 @@ return {
|
||||
},
|
||||
|
||||
onEnter = function(game, ow)
|
||||
if not (game.save.flags and game.save.flags.EVENT_GOT_POKEDEX) then
|
||||
return
|
||||
local flags = game.save.flags or {}
|
||||
if flags.EVENT_GOT_STARTER and not flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB then
|
||||
local rival = ow:npcByIndex(RIVAL)
|
||||
if rival then
|
||||
rival.cellX, rival.cellY = 7, 4
|
||||
rival.px, rival.py = 7 * 16, 4 * 16
|
||||
end
|
||||
end
|
||||
if not flags.EVENT_GOT_POKEDEX then return end
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { save = game.save, game = game, overworld = ow }
|
||||
Commands.hide_object(ctx, "OAKS_LAB", "OAKSLAB_POKEDEX1")
|
||||
@@ -296,13 +302,14 @@ return {
|
||||
table.insert(rows, { "wait", 20 })
|
||||
table.insert(rows, { "show_text", "_OaksLabRivalSmellYouLaterText" })
|
||||
table.insert(rows, { "stop_music" })
|
||||
table.insert(rows, { "play_music", "Music_MeetRival" })
|
||||
table.insert(rows, { "play_music", "Music_MeetRival", { start = "rival" } })
|
||||
table.insert(rows, { "move_npc_to", RIVAL, 4, 11 })
|
||||
table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" })
|
||||
table.insert(rows, { "play_music", "Music_OaksLab" })
|
||||
-- OaksLabPikachuEscapesPokeballScript: the follower reaches the map (#1009)
|
||||
table.insert(rows, { "face_player_dir", "up" })
|
||||
table.insert(rows, { "set_field", "pikachuInBall", false })
|
||||
table.insert(rows, { "spawn_pikachu_follower" })
|
||||
table.insert(rows, { "play_cry", "PIKACHU" })
|
||||
table.insert(rows, { "show_text", "_OaksLabPikachuDislikesPokeballsText1" })
|
||||
table.insert(rows, { "show_text", "_OaksLabPikachuDislikesPokeballsText2" })
|
||||
|
||||
+21
-19
@@ -142,20 +142,23 @@ M.VIRIDIAN_CITY = {
|
||||
M.BLUES_HOUSE = {
|
||||
talk = {
|
||||
TEXT_BLUESHOUSE_DAISY_SITTING = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_GOT_TOWN_MAP" }, -- 2
|
||||
{ "jump_if_true", 10 }, -- 3
|
||||
{ "check_flag", "EVENT_GOT_STARTER" }, -- 4
|
||||
{ "jump_if_false", 12 }, -- 5
|
||||
{ "show_text", "_BluesHouseDaisyOfferMapText" }, -- 6
|
||||
{ "face_player" },
|
||||
{ "check_flag", "EVENT_GOT_TOWN_MAP" },
|
||||
{ "jump_if_true", "got_map" },
|
||||
{ "check_flag", "EVENT_GOT_POKEDEX" },
|
||||
{ "jump_if_false", "too_early" },
|
||||
{ "show_text", "_BluesHouseDaisyOfferMapText" },
|
||||
-- _GotMapText: "{PLAYER} got a\n{RAM:wStringBuffer}!" -- the
|
||||
-- buffer supplies "TOWN MAP" (scripts/BluesHouse.asm GotMapText)
|
||||
{ "give_item", "TOWN_MAP", 1, "_GotMapText" }, -- 7
|
||||
{ "set_flag", "EVENT_GOT_TOWN_MAP" }, -- 8
|
||||
{ "jump", 13 }, -- 9
|
||||
{ "show_text", "_BluesHouseDaisyUseMapText" }, -- 10
|
||||
{ "jump", 13 }, -- 11
|
||||
{ "show_text", "_BluesHouseDaisyRivalAtLabText" }, -- 12
|
||||
{ "give_item", "TOWN_MAP", 1, "_GotMapText" },
|
||||
{ "hide_object", "BLUES_HOUSE", "BLUESHOUSE_TOWN_MAP" },
|
||||
{ "set_flag", "EVENT_GOT_TOWN_MAP" },
|
||||
{ "jump", "end" },
|
||||
{ "label", "got_map" },
|
||||
{ "show_text", "_BluesHouseDaisyUseMapText" },
|
||||
{ "jump", "end" },
|
||||
{ "label", "too_early" },
|
||||
{ "show_text", "_BluesHouseDaisyRivalAtLabText" },
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -294,6 +297,7 @@ M.BILLS_HOUSE = {
|
||||
|
||||
M.ROUTE_25 = {
|
||||
onEnter = function(game, ow)
|
||||
game.save.pikachuMapScriptActive = nil
|
||||
local flags = game.save.flags
|
||||
if flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING then return end
|
||||
local Commands = require("src.script.Commands")
|
||||
@@ -326,6 +330,7 @@ M.VERMILION_CITY = {
|
||||
-- only read while EVENT_1ST_LOCK_OPENED is unset (the gym is only
|
||||
-- reachable through this map, so a fresh visit always re-rolls).
|
||||
onEnter = function(game, ow)
|
||||
game.save.pikachuMapScriptActive = nil
|
||||
local puz = game.save.trashPuzzle or {}
|
||||
game.save.trashPuzzle = puz
|
||||
puz.first = love.math.random(0, 7) * 2
|
||||
@@ -1058,6 +1063,7 @@ local championsRoomRivalScript = {
|
||||
-- OakCongratulatesPlayerScript: rival faces left, Oak faces down
|
||||
{ "face_object", 1, "left" }, -- 17
|
||||
{ "face_object", 2, "down" }, -- 18
|
||||
{ "load_player_starter_name" },
|
||||
{ "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 19
|
||||
-- OakDisappointedWithRivalScript: Oak turns to the rival (right)
|
||||
{ "face_object", 2, "right" }, -- 20
|
||||
@@ -1067,13 +1073,8 @@ local championsRoomRivalScript = {
|
||||
{ "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 23
|
||||
{ "move_npc", 2, "up", 2 }, -- 24 OakExitChampionsRoomMovement
|
||||
{ "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 25
|
||||
-- ChampionsRoomPlayerFollowsOakScript / WalkToHallOfFame_RLEMovement.
|
||||
-- The player walks out after Oak instead of the screen just fading on the
|
||||
-- spot (#704). Route one tile right before walking north so the player
|
||||
-- reaches the north-wall HALL_OF_FAME warp without sharing the rival's
|
||||
-- (4,2) cell. The original simulated movement bypasses entity collision,
|
||||
-- but this scene should not visibly walk through the defeated rival.
|
||||
{ "move_player", "right", 1 }, -- 26
|
||||
-- scripts/ChampionsRoom.asm WalkToHallOfFame_RLEMovement
|
||||
{ "move_player", "left", 1 },
|
||||
{ "move_player", "up", 3 }, -- 27
|
||||
-- hand the induction off to the HALL_OF_FAME room (consumed by its
|
||||
-- onEnter), then warp up into it (destWarp 1 lands at (4,7) facing up)
|
||||
@@ -1238,6 +1239,7 @@ local function pokemonTower2FRivalScript(playerX)
|
||||
{ "jump_if_false", "end" }, -- 6 loss: stay
|
||||
{ "set_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 7
|
||||
{ "show_text", "_PokemonTower2FRivalDefeatedText" }, -- 8
|
||||
{ "play_music", "Music_MeetRival", { start = "rival" } },
|
||||
{ "walk_npc", 1, exitDirs }, -- 9
|
||||
{ "hide_object", "POKEMON_TOWER_2F", "POKEMONTOWER2F_RIVAL" }, -- 10
|
||||
{ "jump", "end" }, -- 11
|
||||
|
||||
+16
-5
@@ -178,7 +178,8 @@ M.PALLET_TOWN = {
|
||||
end
|
||||
end
|
||||
|
||||
local function enterLab()
|
||||
local function enterLab(oak)
|
||||
if oak then oak.stepFrames = nil end
|
||||
Commands.hide_object(ctx, "PALLET_TOWN", "PALLETTOWN_OAK")
|
||||
Commands.show_object(ctx, "OAKS_LAB", "OAKSLAB_OAK2")
|
||||
ow.doorWarp = true
|
||||
@@ -187,12 +188,17 @@ M.PALLET_TOWN = {
|
||||
end
|
||||
|
||||
local function walkToLab(oak)
|
||||
-- lockstep half runs Oak on the player's own frames per cell
|
||||
-- engine/overworld/movement.asm:737 (DoScriptedNPCMovement)
|
||||
local i = 0
|
||||
if oak then
|
||||
oak.stepFrames = ow.player.stepFramesCur or ow.player.stepFrames
|
||||
end
|
||||
local function tick()
|
||||
i = i + 1
|
||||
local playerStep = escort.playerSteps[i]
|
||||
if not playerStep then
|
||||
enterLab()
|
||||
enterLab(oak)
|
||||
return
|
||||
end
|
||||
if oak and escort.oakSteps[i] then
|
||||
@@ -413,6 +419,9 @@ M.ROUTE_8_GATE = saffronGate("TEXT_ROUTE8GATE_GUARD", { { 2, 3 }, { 2, 4 } }, tr
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
M.POKEMON_FAN_CLUB = {
|
||||
onEnter = function(game, ow)
|
||||
require("src.world.PikachuFollower").onFanClubEntered(game, ow)
|
||||
end,
|
||||
talk = {
|
||||
TEXT_POKEMONFANCLUB_CHAIRMAN = {
|
||||
{ "face_player" }, -- 1
|
||||
@@ -645,8 +654,10 @@ local function mtMoonFossil(itemId, otherName, gotFlag)
|
||||
end
|
||||
local idef = game.data.items[itemId]
|
||||
game.stringBuffer = idef and idef.name or itemId
|
||||
require("src.core.Sound").play(game.data, "Get_Key_Item")
|
||||
local dirs = mtMoonNerdWalk(ow.player.cellX, ow.player.cellY, itemId)
|
||||
-- MtMoonB2FReceivedFossilText: text_far, sound_get_key_item,
|
||||
-- text_waitbutton -- the jingle plays after the box has typed and
|
||||
-- the button wait comes after it
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._MtMoonB2FReceivedFossilText
|
||||
or ("{PLAYER} got the\n" .. game.stringBuffer .. "!"),
|
||||
@@ -659,11 +670,11 @@ local function mtMoonFossil(itemId, otherName, gotFlag)
|
||||
ow.runner:run({
|
||||
{ "walk_npc", 1, dirs },
|
||||
{ "text_opts", { auto = true } },
|
||||
{ "text_sound", "Get_Key_Item" },
|
||||
{ "show_text", "_MtMoonB2FSuperNerdThenThisIsMineText" },
|
||||
{ "play_sound", "Get_Key_Item" },
|
||||
{ "hide_object", "MT_MOON_B2F", otherName },
|
||||
}, { onDone = done })
|
||||
end))
|
||||
end, TextBox.soundOpts(game, "Get_Key_Item")))
|
||||
end }))
|
||||
end
|
||||
end
|
||||
|
||||
+22
-21
@@ -8,9 +8,9 @@ local M = {}
|
||||
|
||||
local function text(game) return game.data.text end
|
||||
|
||||
local function push(game, s, done)
|
||||
local function push(game, s, done, opts)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game, s, done))
|
||||
game.stack:push(TextBox.new(game, s, done, opts))
|
||||
end
|
||||
|
||||
-- The question stays on screen under the YES/NO menu. The dojo prize
|
||||
@@ -142,7 +142,7 @@ M.MT_MOON_POKECENTER = {
|
||||
local Commands = require("src.script.Commands")
|
||||
Commands.give_pokemon({ save = game.save, game = game, overworld = ow },
|
||||
"MAGIKARP", 5)
|
||||
push(game, ("%s got a\nMAGIKARP!"):format(game.save.player.name), done)
|
||||
push(game, t._GotMonText or "{PLAYER} got\n{RAM:wNameBuffer}!", done)
|
||||
end)
|
||||
end,
|
||||
},
|
||||
@@ -263,7 +263,7 @@ M.SILPH_CO_7F = {
|
||||
{ "jump_if_false", "box_full" },
|
||||
-- flag ahead of the jingle, like the Celadon EEVEE (#426)
|
||||
{ "set_flag", "EVENT_GOT_LAPRAS" },
|
||||
{ "play_sound", "Get_Item1" },
|
||||
{ "text_sound", "Get_Item1" },
|
||||
{ "show_text", "_GotMonText", { RAM = "LAPRAS" } },
|
||||
{ "show_text", "_SilphCo7FSilphWorkerM1LaprasDescriptionText" },
|
||||
{ "jump", "end" },
|
||||
@@ -307,12 +307,11 @@ M.COPYCATS_HOUSE_2F = {
|
||||
return
|
||||
end
|
||||
game.stringBuffer = game.data.items.TM_MIMIC.name
|
||||
require("src.core.Sound").play(game.data, "Get_Item1")
|
||||
Bag.remove(game.save, "POKE_DOLL", 1)
|
||||
game.save.flags.EVENT_GOT_TM31 = true
|
||||
push(game, t._CopycatsHouse2FCopycatReceivedTM31Text, function()
|
||||
push(game, t._CopycatsHouse2FCopycatTM31Explanation1Text, done)
|
||||
end)
|
||||
end, require("src.render.TextBox").soundOpts(game, "Get_Item1"))
|
||||
end)
|
||||
end)
|
||||
end,
|
||||
@@ -478,7 +477,6 @@ M.CELADON_MART_ROOF = {
|
||||
return
|
||||
end
|
||||
game.save.flags[g.flag] = true
|
||||
require("src.core.Sound").play(game.data, "Get_Item1")
|
||||
local subs = { player = game.save.player.name,
|
||||
ram = game.data.items[g.tm].name }
|
||||
local explain = fill(t[g.explain] or "", subs)
|
||||
@@ -490,7 +488,7 @@ M.CELADON_MART_ROOF = {
|
||||
else
|
||||
done()
|
||||
end
|
||||
end)
|
||||
end, require("src.render.TextBox").soundOpts(game, "Get_Item1"))
|
||||
end)
|
||||
end,
|
||||
onCancel = done,
|
||||
@@ -512,25 +510,28 @@ M.ROUTE_24 = {
|
||||
local flags = game.save.flags
|
||||
local function battleOrDone()
|
||||
if ow:trainerDefeated(npc) then
|
||||
push(game, "I hate this!\nMy dreams of\nTEAM ROCKET...", done)
|
||||
push(game, text(game)._Route24CooltrainerM1YouCouldBecomeATopLeaderText,
|
||||
done)
|
||||
else
|
||||
ow:engageTrainer(npc, done)
|
||||
end
|
||||
end
|
||||
if not flags.EVENT_GOT_NUGGET then
|
||||
push(game, "Congratulations!\nYou beat our 5\ncontest trainers!\f"
|
||||
.. "You just earned a\nfabulous prize!", function()
|
||||
local t = text(game)
|
||||
push(game, t._Route24CooltrainerM1YouBeatOurContestText .. "\f"
|
||||
.. t._Route24CooltrainerM1YouJustEarnedAPrizeText, function()
|
||||
if not require("src.inventory.Bag").add(game.save, "NUGGET", 1,
|
||||
game.data) then
|
||||
push(game, t._Route24CooltrainerM1NoRoomText, done)
|
||||
return
|
||||
end
|
||||
flags.EVENT_GOT_NUGGET = true
|
||||
require("src.inventory.Bag").add(game.save, "NUGGET", 1)
|
||||
push(game, ("%s received\na NUGGET!"):format(game.save.player.name),
|
||||
function()
|
||||
ask(game, "By the way, would\nyou like to join\nTEAM ROCKET?",
|
||||
function()
|
||||
push(game, "Arrgh! You are\nnot convinced?\fThen I'll show\n"
|
||||
.. "you my power!", battleOrDone)
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
game.stringBuffer = game.data.items.NUGGET.name
|
||||
push(game, t._Route24CooltrainerM1ReceivedNuggetText, function()
|
||||
push(game, t._Route24CooltrainerM1JoinTeamRocketText,
|
||||
battleOrDone)
|
||||
end, require("src.render.TextBox").soundOpts(game, "Get_Item1"))
|
||||
end, require("src.render.TextBox").soundOpts(game, "Get_Item1"))
|
||||
return
|
||||
end
|
||||
battleOrDone()
|
||||
|
||||
+36
-18
@@ -4,9 +4,9 @@ local M = {}
|
||||
|
||||
local function text(game) return game.data.text end
|
||||
|
||||
local function push(game, s, done)
|
||||
local function push(game, s, done, opts)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game, s, done))
|
||||
game.stack:push(TextBox.new(game, s, done, opts))
|
||||
end
|
||||
|
||||
-- fill the extracted text placeholders ({RAM:...}, {PLAYER})
|
||||
@@ -25,8 +25,8 @@ local function gift(opts)
|
||||
local t = text(game)
|
||||
local itemName = game.data.items[opts.item].name
|
||||
local subs = { ram = itemName, player = game.save.player.name }
|
||||
local function say(label, fallback, cb)
|
||||
push(game, fill(t[label] or fallback, subs), cb)
|
||||
local function say(label, fallback, cb, sopts)
|
||||
push(game, fill(t[label] or fallback, subs), cb, sopts)
|
||||
end
|
||||
if game.save.flags[opts.flag] then
|
||||
say(opts.already or opts.explain, "It's a useful\nitem, isn't it?", done)
|
||||
@@ -39,15 +39,16 @@ local function gift(opts)
|
||||
end
|
||||
game.save.flags[opts.flag] = true
|
||||
local idef = game.data.items[opts.item]
|
||||
require("src.core.Sound").play(game.data,
|
||||
(idef and idef.keyItem) and "Get_Key_Item" or "Get_Item1")
|
||||
-- the received texts carry sound_get_item_1 / sound_get_key_item, so
|
||||
-- the jingle only fires once that box has typed out
|
||||
say(opts.received, "{PLAYER} received\n{RAM:}!", function()
|
||||
if opts.explain then
|
||||
say(opts.explain, "", done)
|
||||
else
|
||||
done()
|
||||
end
|
||||
end)
|
||||
end, require("src.render.TextBox").soundOpts(game,
|
||||
(idef and idef.keyItem) and "Get_Key_Item" or "Get_Item1"))
|
||||
end
|
||||
if opts.pre then say(opts.pre, opts.preFallback or "", give) else give() end
|
||||
end
|
||||
@@ -445,7 +446,7 @@ local function pewterGymEscort(game, ow)
|
||||
end
|
||||
|
||||
local function afterWalk()
|
||||
if guy then guy.facing = "left" end
|
||||
if guy then guy.stepFrames, guy.facing = nil, "left" end
|
||||
Music.playMap(game.data, "PEWTER_CITY")
|
||||
push(game, t._PewterCityYoungsterGoTakeOnBrockText
|
||||
or "Go take on BROCK\nat the GYM first!", walkHome)
|
||||
@@ -468,6 +469,11 @@ local function pewterGymEscort(game, ow)
|
||||
end
|
||||
|
||||
local function beginWalk()
|
||||
-- the escort runs the youngster on the player's own frames per cell
|
||||
-- engine/overworld/movement.asm:737 (DoScriptedNPCMovement)
|
||||
if guy then
|
||||
guy.stepFrames = ow.player.stepFramesCur or ow.player.stepFrames
|
||||
end
|
||||
Music.play(game.data, "Music_MuseumGuy")
|
||||
if guy and head > 0 then
|
||||
local h = 0
|
||||
@@ -508,12 +514,12 @@ M.PEWTER_CITY = {
|
||||
-- Rival ambush: show the hidden rival, walk him up to the player, run
|
||||
-- the battle rows, march him back and hide him. On a loss the walk is
|
||||
-- skipped (the blackout rebuilds the map mid-script).
|
||||
local function runAmbush(game, ow, rows, playerFacing)
|
||||
local function runAmbush(game, ow, rows, playerFacing, musicOpts)
|
||||
if ow.runner:isRunning() then return false end
|
||||
ow.player.facing = playerFacing
|
||||
-- the rival encounter sting (MUSIC_MEET_RIVAL); the battle music
|
||||
-- takes over and the map theme returns after the victory jingle
|
||||
require("src.core.Music").play(game.data, "Music_MeetRival")
|
||||
require("src.core.Music").play(game.data, "Music_MeetRival", nil, musicOpts)
|
||||
ow.runner:run(rows)
|
||||
return true
|
||||
end
|
||||
@@ -567,10 +573,12 @@ local function route22Scene(n, objIndex, objName, oppClass, baseParty, beatFlag,
|
||||
{ "face_object", objIndex, rivalFacing }, -- 3
|
||||
{ "show_text", "_Route22RivalBeforeBattleText" .. n }, -- 4
|
||||
{ "rival_battle", oppClass, baseParty }, -- 5
|
||||
{ "jump_if_false", 11 }, -- 6
|
||||
{ "jump_if_false", 12 }, -- 6
|
||||
{ "set_flag", beatFlag }, -- 7
|
||||
{ "show_text", "_Route22Rival" .. n .. "DefeatedText" }, -- 8
|
||||
{ "show_text", "_Route22RivalAfterBattleText" .. n }, -- 9
|
||||
{ "play_music", "Music_MeetRival", { start = "rival",
|
||||
tempo = n == 2 and 100 or nil } },
|
||||
{ "walk_npc", objIndex, route22ExitDirs(n, py) }, -- 10
|
||||
{ "hide_object", "ROUTE_22", objName }, -- 11
|
||||
}
|
||||
@@ -594,7 +602,8 @@ M.ROUTE_22 = {
|
||||
if f.EVENT_BEAT_GIOVANNI and not f.EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE then
|
||||
return runAmbush(game, ow,
|
||||
route22Scene(2, 2, "ROUTE22_RIVAL2", "OPP_RIVAL2", 10,
|
||||
"EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE", y), playerFacing)
|
||||
"EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE", y), playerFacing,
|
||||
{ tempo = 100 })
|
||||
end
|
||||
return false
|
||||
end,
|
||||
@@ -618,10 +627,11 @@ local function ceruleanRivalScene(px, py)
|
||||
{ "face_object", 1, "down" }, -- 3
|
||||
{ "show_text", "_CeruleanCityRivalPreBattleText" }, -- 4
|
||||
{ "rival_battle", "OPP_RIVAL1", 7 }, -- 5
|
||||
{ "jump_if_false", 11 }, -- 6
|
||||
{ "jump_if_false", 12 }, -- 6
|
||||
{ "set_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 7
|
||||
{ "show_text", "_CeruleanCityRivalDefeatedText" }, -- 8
|
||||
{ "show_text", "_CeruleanCityRivalIWentToBillsText" }, -- 9
|
||||
{ "play_music", "Music_MeetRival", { start = "rival" } },
|
||||
{ "walk_npc", 1, ceruleanRivalExitDirs(px) }, -- 10
|
||||
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 11
|
||||
}
|
||||
@@ -730,7 +740,7 @@ local JIGGLYPUFF_SILENCE, JIGGLYPUFF_STEP, JIGGLYPUFF_TAIL = 32, 24, 48
|
||||
-- Built as a TextBox `auto` table: auto.sound fires the frame the last
|
||||
-- page has typed out (PrintText returning), and auto.tick then runs once
|
||||
-- per frame while the gate it returns still reads as playing.
|
||||
local function jigglypuffDance(game, npc)
|
||||
local function jigglypuffDance(game, npc, ow)
|
||||
local Music = require("src.core.Music")
|
||||
-- .findMatchingFacingDirectionLoop: the rotation picks up at the entry
|
||||
-- matching the sprite's current facing (showMapText has just turned it
|
||||
@@ -776,7 +786,13 @@ local function jigglypuffDance(game, npc)
|
||||
if npc then npc.facing = JIGGLYPUFF_SPIN[step] end
|
||||
return
|
||||
end
|
||||
if frames >= JIGGLYPUFF_TAIL then phase = "done" end
|
||||
if frames >= JIGGLYPUFF_TAIL then
|
||||
phase = "done"
|
||||
if require("src.core.GameVersion").isYellow()
|
||||
and require("src.world.PikachuFollower").starterInParty(game.save) then
|
||||
ow.pikachuPewterSleepScene = true
|
||||
end
|
||||
end
|
||||
end,
|
||||
}
|
||||
end
|
||||
@@ -789,7 +805,7 @@ M.PEWTER_POKECENTER = {
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game,
|
||||
text(game)._PewterPokecenterJigglypuffText or "JIGGLYPUFF: Puu\npupuu!",
|
||||
done, { auto = jigglypuffDance(game, npc) }))
|
||||
done, { auto = jigglypuffDance(game, npc, ow) }))
|
||||
end,
|
||||
},
|
||||
}
|
||||
@@ -864,10 +880,11 @@ M.SILPH_CO_7F = {
|
||||
{ "face_object", 9, "up" }, -- 4
|
||||
{ "show_text", "_SilphCo7FRivalWaitedHereText" }, -- 5
|
||||
{ "rival_battle", "OPP_RIVAL2", 7 }, -- 6
|
||||
{ "jump_if_false", 12 }, -- 7
|
||||
{ "jump_if_false", 13 }, -- 7
|
||||
{ "set_flag", "EVENT_BEAT_SILPH_CO_RIVAL" }, -- 8
|
||||
{ "show_text", "_SilphCo7FRivalDefeatedText" }, -- 9
|
||||
{ "show_text", "_SilphCo7FRivalGoodLuckToYouText" }, -- 10
|
||||
{ "play_music", "Music_MeetRival", { start = "rival" } },
|
||||
{ "move_npc_to", 9, 5, y + 1 }, -- 11
|
||||
{ "hide_object", "SILPH_CO_7F", "SILPHCO7F_RIVAL" }, -- 12
|
||||
}, "down")
|
||||
@@ -899,10 +916,11 @@ M.SS_ANNE_2F = {
|
||||
{ "face_object", 2, onLeft and "down" or "right" }, -- 3
|
||||
{ "show_text", "_SSAnne2FRivalText" }, -- 4
|
||||
{ "rival_battle", "OPP_RIVAL2", 1 }, -- 5
|
||||
{ "jump_if_false", 11 }, -- 6
|
||||
{ "jump_if_false", 12 }, -- 6
|
||||
{ "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 7
|
||||
{ "show_text", "_SSAnne2FRivalDefeatedText" }, -- 8
|
||||
{ "show_text", "_SSAnne2FRivalCutMasterText" }, -- 9
|
||||
{ "play_music", "Music_MeetRival", { start = "rival" } },
|
||||
{ "walk_npc", 2, ssAnne2FRivalExitDirs(onLeft) }, -- 10
|
||||
{ "hide_object", "SS_ANNE_2F", "SSANNE2F_RIVAL" }, -- 11
|
||||
}, onLeft and "up" or "left")
|
||||
|
||||
@@ -28,6 +28,14 @@
|
||||
-- is full; `gotFlag` (pokered's EVENT_GOT_TM*) is set only on a
|
||||
-- successful give, which is what makes the leader's talk script retry
|
||||
-- later (gyms.lua).
|
||||
--
|
||||
-- `badgeSound` / `tmSound` are the text sound command each gym's reward
|
||||
-- text carries right after its FIRST label -- home/text.asm TextCommand_SOUND
|
||||
-- plays it once that page has typed out and then blocks on
|
||||
-- WaitForSoundToFinish, so the jingle sits between the pages rather than
|
||||
-- under them. macros/scripts/text.asm defines sound_level_up as
|
||||
-- sound_get_item_1, so Pewter's and Viridian's badge lines are Get_Item1
|
||||
-- too. Vermilion, Celadon and Fuchsia carry no sound on the badge text.
|
||||
|
||||
local function range(prefix, first, last)
|
||||
local t = {}
|
||||
@@ -50,6 +58,8 @@ return {
|
||||
{ "PEWTER_CITY", "PEWTERCITY_YOUNGSTER" },
|
||||
{ "ROUTE_22", "ROUTE22_RIVAL1" },
|
||||
},
|
||||
badgeSound = "Get_Item1", -- sound_level_up
|
||||
tmSound = "Get_Item1",
|
||||
dialogue = {
|
||||
"_PewterGymBrockReceivedBoulderBadgeText",
|
||||
"_PewterGymBrockBoulderBadgeInfoText",
|
||||
@@ -64,6 +74,8 @@ return {
|
||||
gotFlag = "EVENT_GOT_TM11",
|
||||
noRoom = "_CeruleanGymMistyTM11NoRoomText",
|
||||
deactivate = range("EVENT_BEAT_CERULEAN_GYM_TRAINER_", 0, 1),
|
||||
badgeSound = "Get_Key_Item",
|
||||
tmSound = "Get_Item1",
|
||||
dialogue = {
|
||||
"_CeruleanGymMistyReceivedCascadeBadgeText",
|
||||
},
|
||||
@@ -76,6 +88,7 @@ return {
|
||||
gotFlag = "EVENT_GOT_TM24",
|
||||
noRoom = "_VermilionGymLTSurgeTM24NoRoomText",
|
||||
deactivate = range("EVENT_BEAT_VERMILION_GYM_TRAINER_", 0, 2),
|
||||
tmSound = "Get_Key_Item",
|
||||
dialogue = {
|
||||
"_VermilionGymLTSurgeReceivedThunderBadgeText",
|
||||
},
|
||||
@@ -89,6 +102,7 @@ return {
|
||||
gotFlag = "EVENT_GOT_TM21",
|
||||
noRoom = "_CeladonGymTM21NoRoomText",
|
||||
deactivate = range("EVENT_BEAT_CELADON_GYM_TRAINER_", 0, 6),
|
||||
tmSound = "Get_Item1",
|
||||
dialogue = {
|
||||
"_CeladonGymErikaReceivedRainbowBadgeText",
|
||||
},
|
||||
@@ -102,6 +116,7 @@ return {
|
||||
gotFlag = "EVENT_GOT_TM06",
|
||||
noRoom = "_FuchsiaGymKogaTM06NoRoomText",
|
||||
deactivate = range("EVENT_BEAT_FUCHSIA_GYM_TRAINER_", 0, 5),
|
||||
tmSound = "Get_Key_Item",
|
||||
dialogue = {
|
||||
"_FuchsiaGymKogaReceivedSoulBadgeText",
|
||||
},
|
||||
@@ -115,6 +130,8 @@ return {
|
||||
gotFlag = "EVENT_GOT_TM46",
|
||||
noRoom = "_SaffronGymSabrinaTM46NoRoomText",
|
||||
deactivate = range("EVENT_BEAT_SAFFRON_GYM_TRAINER_", 0, 6),
|
||||
badgeSound = "Get_Key_Item",
|
||||
tmSound = "Get_Item1",
|
||||
dialogue = {
|
||||
"_SaffronGymSabrinaReceivedMarshBadgeText",
|
||||
},
|
||||
@@ -128,6 +145,8 @@ return {
|
||||
gotFlag = "EVENT_GOT_TM38",
|
||||
noRoom = "_CinnabarGymBlaineTM38NoRoomText",
|
||||
deactivate = range("EVENT_BEAT_CINNABAR_GYM_TRAINER_", 0, 6),
|
||||
badgeSound = "Get_Key_Item",
|
||||
tmSound = "Get_Item1",
|
||||
dialogue = {
|
||||
"_CinnabarGymBlaineReceivedVolcanoBadgeText",
|
||||
},
|
||||
@@ -141,6 +160,8 @@ return {
|
||||
gotFlag = "EVENT_GOT_TM27",
|
||||
noRoom = "_ViridianGymGiovanniTM27NoRoomText",
|
||||
deactivate = range("EVENT_BEAT_VIRIDIAN_GYM_TRAINER_", 0, 7),
|
||||
badgeSound = "Get_Item1", -- sound_level_up
|
||||
tmSound = "Get_Item1",
|
||||
dialogue = {
|
||||
"_ViridianGymGiovanniReceivedEarthBadgeText",
|
||||
},
|
||||
|
||||
+8
-6
@@ -130,18 +130,20 @@ before `Game:load`, so **it never loads a mod's entry chunk**; only
|
||||
duplicate) and returns one row per mod:
|
||||
`{id, name, version, badge, description, enabled, status, statusDetail}`.
|
||||
`badge` is the manifest's `category`, falling back to `profile`, then
|
||||
`"MOD"`, uppercased. `enabled` reads `options.mods[id]` (missing means
|
||||
enabled, matching the loader's own default).
|
||||
`"MOD"`, uppercased. `enabledByVersion` contains an answer for each game;
|
||||
missing entries default to enabled (except experimental mods), matching the
|
||||
loader. On the first run with per-game controls, legacy shared choices are
|
||||
copied to every installed game's answer.
|
||||
- `status` is `"ok"`, `"warn"`, or `"conflict"`, computed by the pure
|
||||
`LauncherMods.deriveList`/`statusFor` against `ManagerState.resolveToggle`
|
||||
and the validated manifests: `conflict` when enabling this mod collides
|
||||
with another enabled one; `warn` for an out-of-range `game_version` or an
|
||||
absent/disabled/wrong-version hard dependency; `ok` otherwise. Having no
|
||||
`love.*` calls, this half is table-driven by the test suite on its own.
|
||||
- `LauncherMods.setEnabled(id, bool)` persists `options.mods[id]` as a plain
|
||||
boolean, the exact shape `Loader:_saveState` writes, so the running game
|
||||
and the in-game `ManagerState` see the change on next boot. The mods panel
|
||||
calls this on every toggle and re-derives the list right away
|
||||
- `LauncherMods.setEnabled(id, bool, version)` persists the selected game's
|
||||
answer, so the running game and the in-game `ManagerState` see the change on
|
||||
next boot. The MODS panel renders a coloured checkbox for Red, Blue, Yellow,
|
||||
and Gold on every row and re-derives the list right away
|
||||
(`RomImporter:_refreshMods`) so a status change (e.g. a new conflict)
|
||||
shows without waiting for a reload.
|
||||
- `LauncherMods.installZip(path)` mounts the archive with
|
||||
|
||||
+33
-17
@@ -96,14 +96,14 @@ The launcher asks the same question of a mod's dependencies: one whose hard
|
||||
dependency does not run on the selected game reads `Needs <id> (not for Gold)`,
|
||||
matching the loader's contagious skip.
|
||||
|
||||
A separate overlay, `options.modsByVersion[version][id]`, is where a per-game
|
||||
enable flag will live. It is a preview: `SaveData.PER_VERSION_MODS` is `false`,
|
||||
so `SaveData.modScope` answers nil for every caller and the launcher panel, the
|
||||
in-game manager and the loader all read *and* write the one shared
|
||||
`options.mods` flag. Nothing consults the overlay for enablement until that
|
||||
flips, which is deliberate: the overlay is plantable from an imported
|
||||
`.g1rmodlist`, and a reader scoped differently from the writers would show a
|
||||
mod set no boot would honour.
|
||||
A separate overlay, `options.modsByVersion[version][id]`, holds each game's
|
||||
enable flag. The launcher shows a coloured Red / Blue / Yellow / Gold checkbox
|
||||
for every installed mod, and the loader and in-game manager read the same
|
||||
game-specific answer on the next boot. On the first launch after this feature,
|
||||
the existing shared state is copied to every game, so a mod that was enabled
|
||||
remains enabled everywhere; after that, changing one checkbox affects only
|
||||
that game. New mods still default to enabled on every game (experimental mods
|
||||
retain their explicit opt-in default).
|
||||
|
||||
That is deliberate. Gold reimplements the battle engine, the overworld, the
|
||||
script VM and the save format, so a Gen 1 mod dropped into a Gold boot would
|
||||
@@ -118,10 +118,11 @@ Gen 2 games no longer loads on Red, Blue or Yellow. Say `["all"]` or list both
|
||||
generations if you want both.
|
||||
|
||||
Two riders. **A hard dependency that does not run here takes the dependent down
|
||||
with it**, as a skip rather than a failure and carrying the dependency's own
|
||||
wording (`depends on X, which does not run here (For Blue, not Red)`), so the
|
||||
whole chain has to cover the same games. And **the claim is yours, not the last
|
||||
word**: it is the manager's `TRY HERE ANYWAY` row that lets a player run a mod
|
||||
with it** (unless scoped to specific games, e.g.
|
||||
`dependencies: [{ id = "x", games = ["gen2"] }]`), as a skip rather than a
|
||||
failure and carrying the dependency's own wording (`depends on X, which does not
|
||||
run here (For Blue, not Red)`), so the whole chain has to cover the same games.
|
||||
And **the claim is yours, not the last word**: it is the manager's `TRY HERE ANYWAY` row that lets a player run a mod
|
||||
whose author never opted in, which is the only route for a mod written before
|
||||
the field existed. The override is per game -- `options.modsGen2[id]` is a
|
||||
`{ [version] = true }` table, so forcing a mod onto Red does not force it onto
|
||||
@@ -239,7 +240,7 @@ resolves to the weaker claim:
|
||||
| `warned` | present, answers nil or degrades, and names itself once with the mod attributed |
|
||||
| `absent` | deliberately not served; a nil read is the honest failure |
|
||||
|
||||
Today that is 288 backed, 32 warned and 161 absent across the fifteen modules.
|
||||
Today that is 291 backed, 32 warned and 161 absent across the fifteen modules.
|
||||
`notes` keys are documentation topics rather than a member list -- dotted paths
|
||||
(`save.money`), field names (`warpAt`), hook names (`hook ui.pc.items`) and
|
||||
bare topics (`identity`, `iteration`, `rawset`) all appear there. `members` is
|
||||
@@ -474,6 +475,8 @@ Generation-agnostic; nothing to adapt.
|
||||
(`src/world/gen2/WorldAPI.lua`). Two differences show through and are
|
||||
documented on the module: Gold's world is not a stack state, and Gen 2 event
|
||||
flags are numeric ids into `wEventFlags` rather than string keys.
|
||||
`mapOverview` returns the same read-only terrain, tile-shading, and marker
|
||||
shape, using Gold's live object masks and event flags to omit collected items.
|
||||
`spawnNpc` / `removeNpc` append onto the map def's own object list, the way the
|
||||
Gen 1 arm does, so a spawned actor is pooled, drawn, walked and talked to like
|
||||
an extracted one and survives a map reload; it is not serialized, so a mod
|
||||
@@ -483,6 +486,11 @@ has its own entry points for (`start_battle "wild" species level`, `warp`,
|
||||
**by name, before the first row runs**, so a mod never gets a half-run queue.
|
||||
`marchInPlace` still has no Gen 2 equivalent (the Gen 2 movement stream has no
|
||||
byte for it) and returns `nil, reason` rather than approximating one.
|
||||
`availableFieldActions` and `useFieldAction` expose the same contextual field
|
||||
item and move records in both games. Gold extends the shared ids with its own
|
||||
`headbutt`, `whirlpool`, `waterfall`, `sweet_scent`, and `squirtbottle`
|
||||
actions. Each engine keeps ownership of its inventory, badges, terrain,
|
||||
surfing, bike, fishing, and field-move rules.
|
||||
|
||||
**Hooks and events that fire on Gold.** Every name below is the Gen 1 name
|
||||
carrying the Gen 1 payload keys, because Gold's call sites reuse them rather
|
||||
@@ -531,8 +539,9 @@ gains a field instead of the name gaining a prefix.
|
||||
`pokemon.level_up`, `pokemon.move_learned`; hooks `battle.damage`,
|
||||
`battle.crit`, `battle.accuracy`, `battle.turn_order`,
|
||||
`battle.enemy_action`, `battle.run`, `battle.exp_award`, `exp.gain`,
|
||||
`catch.rate`, `trainer.party`, `battle.overlay`, `battle.low_health_alarm`
|
||||
and `battle.catch_exp`. One payload difference: Gen 1's vanilla
|
||||
`catch.rate`, `trainer.party`, `battle.overlay`, `battle.low_health_alarm`,
|
||||
`battle.catch_exp`, `battle.bottom_ui_visible` and
|
||||
`battle.status_hud_visible`. One payload difference: Gen 1's vanilla
|
||||
`battle.low_health_alarm` link reads `ctx.battle.data`, and Gold's battle
|
||||
screen has no `.data` field, so the Gen 2 site **adds** `ctx.data` beside the
|
||||
Gen 1 keys. A mod that calls `nextFn` is unaffected; one that reaches through
|
||||
@@ -544,11 +553,13 @@ gains a field instead of the name gaining a prefix.
|
||||
each row's decision in `evolution.check`. The hook passes `data` where Gen 1
|
||||
passes `game`; positions 2-4 (mon, row, trigger) match.
|
||||
- *The frame (`src/core/Game2.lua`):* hooks `input.step`, `input.pointer`,
|
||||
`render.zones`, `render.compose`, `render.letterbox`, `render.hud`. Each sits
|
||||
`render.zones`, `render.compose`, `render.output_enabled`, `render.output`,
|
||||
`render.letterbox`, `render.hud`. Each sits
|
||||
at the same moment `src/core/Game.lua` and `src/render/Renderer.lua` raise it
|
||||
-- the logic tick before the pad is read, a pointer the touch overlay gets
|
||||
first refusal on, the palette zone list handed to the present pass, the
|
||||
letterbox, and the finished playfield rect -- and carries the same payload.
|
||||
composed frame before GBCFX, the letterbox, and the finished playfield rect
|
||||
-- and carries the same payload.
|
||||
`render.hud`'s `gameX` / `gameY` really is where Gold's dialogue boxes and
|
||||
menus land, because `Chrome.fitScale` / `fitOrigin` and `World:fitScale`
|
||||
compute the same number. `render.zones` is handed `nil` in GBC mode (Gold
|
||||
@@ -757,6 +768,11 @@ name and the existing payload, plus fields where Gen 2 genuinely carries more
|
||||
|
||||
The list is much shorter than it was. What is outstanding, in descending value:
|
||||
|
||||
- `trainer.before_battle`: Gold constructs and pushes its trainer battle in
|
||||
`src/world/gen2/World.lua:startBattle`, which does not yet expose a deferred
|
||||
preparation boundary or a battle-local player-party view. Gen 1 mods can use
|
||||
the hook documented in `docs/modding.md`; do not claim Gold compatibility
|
||||
when that selection is required.
|
||||
- `pokemon.before_give` / `pokemon.received`: Gold has no give-mon seam of its
|
||||
own yet.
|
||||
- `link.*` and `trade.completed`: a Gold boot offers no link menu at all. The
|
||||
|
||||
+270
-12
@@ -21,6 +21,133 @@ luajit tools/gen_registry_docs.lua
|
||||
luajit tools/gen_registry_docs.lua ../gen1recomp.wiki
|
||||
```
|
||||
|
||||
## Manifest specification (`manifest.json`)
|
||||
|
||||
Every mod contains a root `manifest.json` defining its metadata, supported games, and dependencies for the engine loader.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my_mod",
|
||||
"name": "My Cool Mod",
|
||||
"version": "1.0.0",
|
||||
"api": 2,
|
||||
"entry": "main.lua",
|
||||
"profile": "content",
|
||||
"category": "GAMEPLAY",
|
||||
"games": ["gen1", "gen2"],
|
||||
"game_version": ">=0.0.0-dev <2.0.0",
|
||||
"priority": 100,
|
||||
"dependencies": [
|
||||
"helper_lib@^1.0.0",
|
||||
{ "id": "pokegear_cards", "games": ["gen2"], "range": "^1.0.0", "github": "1jamie/pokegear_cards" }
|
||||
],
|
||||
"optional_dependencies": [
|
||||
"gen1_modern_ui"
|
||||
],
|
||||
"required_imports": [
|
||||
{
|
||||
"id": "stadium2",
|
||||
"name": "Pokemon Stadium 2 ROM",
|
||||
"description": "Pokemon Stadium 2 (USA), any supported N64 byte order",
|
||||
"file": "stadium2.z64",
|
||||
"format": "n64",
|
||||
"size": 67108864,
|
||||
"md5": ["00000000000000000000000000000000"]
|
||||
}
|
||||
],
|
||||
"optional_imports": [
|
||||
{
|
||||
"id": "bonus_source",
|
||||
"name": "Optional bonus source",
|
||||
"file": "bonus.bin",
|
||||
"md5": "00000000000000000000000000000000"
|
||||
}
|
||||
],
|
||||
"conflicts": [],
|
||||
"permissions": ["engine_internals"],
|
||||
"description": "A brief description of the mod.",
|
||||
"github": "author/my_mod"
|
||||
}
|
||||
```
|
||||
|
||||
### Manifest Fields
|
||||
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `id` | `string` | Unique identifier (lowercase alphanumeric, underscores, hyphens). |
|
||||
| `name` | `string` | Human-readable title shown in launcher and manager. |
|
||||
| `version` | `string` | Semantic version string (e.g. `"1.0.0"`). |
|
||||
| `api` | `integer` | Mod API level (`2` for current standard, `1` for legacy). |
|
||||
| `entry` | `string` | Entry Lua file path relative to mod root (usually `"main.lua"`). |
|
||||
| `profile` | `string` | Mod profile: `"content"`, `"overhaul"`, or `"total_conversion"`. |
|
||||
| `category` | `string` | Categorization chip (e.g. `"GAMEPLAY"`, `"CONTENT"`, `"UI"`, `"AUDIO"`). |
|
||||
| `games` | `array` | Supported game versions: `["gen1"]`, `["gen2"]`, `["red"]`, `["blue"]`, `["yellow"]`, `["gold"]`, or `["all"]`. |
|
||||
| `game_version`| `string` | Semver range of required engine version (e.g. `">=0.0.0-dev <2.0.0"`). |
|
||||
| `priority` | `integer` | Load priority order (lower numbers load earlier; dependencies always precede dependents regardless of priority). |
|
||||
| `dependencies` | `array` | Hard required dependencies. A mod will not load if a required dependency is missing or disabled for the active game. |
|
||||
| `optional_dependencies` | `array` | Soft dependencies. Guarantees that if the target mod is present and active, it loads *before* this mod without blocking load if absent. |
|
||||
| `required_imports` | `array` | User-supplied files required by this mod. The launcher validates and copies each file into this mod's `baseroms/` directory; the mod does not load while one is missing. |
|
||||
| `optional_imports` | `array` | User-supplied files that unlock optional mod functionality. They use the same validation and private-copy flow but never block the mod from loading. |
|
||||
| `conflicts` / `incompatible` | `array` | List of mod IDs that cannot run concurrently with this mod. |
|
||||
| `permissions` | `array` | Requested privileges (e.g. `["engine_internals"]`, `["network"]`, `["filesystem"]`). |
|
||||
| `github` | `string` | GitHub repository (`"owner/repo"`) used for update checks and dependency download links. |
|
||||
|
||||
### Declaring Dependencies & Scoping
|
||||
|
||||
Dependencies in `dependencies` and `optional_dependencies` can be declared in several formats:
|
||||
|
||||
1. **Simple string**: `"mod_id"`
|
||||
2. **Version-pinned string**: `"mod_id@^1.2.0"`
|
||||
3. **Repository-hinted string**: `"mod_id#owner/repo"` or `"mod_id@^1.2.0#owner/repo"`
|
||||
4. **Structured object**:
|
||||
```json
|
||||
{
|
||||
"id": "mod_id",
|
||||
"range": "^1.2.0",
|
||||
"games": ["gen2"],
|
||||
"github": "owner/repo"
|
||||
}
|
||||
```
|
||||
|
||||
#### Version-Scoped Dependencies
|
||||
When a mod supports multiple games (`"games": ["gen1", "gen2"]`), a dependency can specify `"games": ["gen2"]` to indicate it is only required when booting Gen 2. When booting Gen 1, the engine will ignore the dependency, preventing unnecessary boot blocks on games that do not need it.
|
||||
|
||||
### Required user-supplied files
|
||||
|
||||
`required_imports` and `optional_imports` keep copyrighted or otherwise user-owned source material
|
||||
out of mod archives while giving every platform the same installation flow.
|
||||
Each object requires a stable `id`, a display `name`, a destination `file`
|
||||
(a filename, never a path), and one MD5 digest or an array of accepted MD5
|
||||
digests. `format` is either `"raw"` (the default) or `"n64"`. An optional
|
||||
`description` gives players dump or region guidance in the import panel.
|
||||
`size` declares the exact canonical byte length; `max_size` declares a smaller
|
||||
per-import ceiling when an exact size is not appropriate. Every import also
|
||||
has an engine-enforced 128 MiB ceiling and is rejected before hashing when its
|
||||
filesystem reports an invalid size.
|
||||
|
||||
For `"n64"`, the launcher recognizes `.z64`, `.v64`, and `.n64` byte orders,
|
||||
strips a recognized 512-byte copier header, converts the bytes to canonical
|
||||
big-endian `.z64` order, and then checks MD5. The canonical bytes are written
|
||||
to `mods/<mod-id>/baseroms/<file>`. Each selection is a private grant to that
|
||||
mod: the launcher never scans or copies another mod's imported files merely
|
||||
because its manifest names the same digest. Mods read the result with their existing scoped `mod:read` API, for
|
||||
example `mod:read("baseroms/stadium2.z64")`; no host path or new filesystem
|
||||
permission is exposed. Missing `required_imports` block the mod before its
|
||||
entry chunk runs; missing `optional_imports` remain visible in the same
|
||||
launcher panel but do not block loading.
|
||||
|
||||
MD5 here identifies a known dump because ROM databases commonly publish it;
|
||||
it is not a security or authenticity guarantee. Do not paste the SHA-1 used by
|
||||
Gen1Recomp's own game-ROM importer into an import's `md5` field. Mod archives
|
||||
must not include anything beneath `baseroms/`. The engine records a validation
|
||||
receipt keyed by file size and modification time so launcher refreshes and
|
||||
later boots do not repeatedly hash an unchanged imported ROM.
|
||||
|
||||
New mobile code should call `love.system.pickFile("required_import")`. The
|
||||
older iOS-only `"stadium"` picker kind remains temporarily for compatibility.
|
||||
Android now returns `false` for unknown picker kinds instead of treating them
|
||||
as game-ROM picks.
|
||||
|
||||
## Mods and Gold (Gen 2)
|
||||
|
||||
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.
|
||||
`markers` contains active `{ kind, x, y }` points in map-cell coordinates for
|
||||
`warp`, visible `item`, and untaken `hidden` locations. All fields are
|
||||
read-only snapshots; mods choose which layers to render.
|
||||
read-only snapshots; mods choose which layers to render. Red and Gold expose
|
||||
the same contract while applying their own object and event visibility rules.
|
||||
|
||||
## Party ordering
|
||||
|
||||
@@ -76,6 +204,24 @@ Companion UIs and alternate party screens can call
|
||||
operation is accepted only during idle overworld play; menus, movement,
|
||||
scripts, battles, and transitions leave the party untouched.
|
||||
|
||||
## Contextual field actions
|
||||
|
||||
`mod.world:availableFieldActions()` returns the field items and moves that can
|
||||
start at the player's current position. Both games expose `bicycle`, `fish`,
|
||||
`cut`, `surf`, `strength`, `flash`, `dig`, and `teleport`; Gold additionally
|
||||
exposes `headbutt`, `whirlpool`, `waterfall`, `sweet_scent`, and the
|
||||
contextual `squirtbottle` key item. Fishing rows include the owned rods that
|
||||
are valid choices. The list is empty while the world is busy, and omits an
|
||||
action whenever its item, move, badge, terrain, or engine state forbids it.
|
||||
|
||||
Call `mod.world:useFieldAction(id, opts)` to perform a listed action through
|
||||
the active game's own field-item path. Fishing accepts `{ rod = "OLD_ROD" }`
|
||||
and chooses automatically when only one rod is available. Invalid, stale, and
|
||||
busy requests return `nil` plus a reason without changing game state. Mods do
|
||||
not need generation-specific badge, terrain, bike, fishing, or field-move
|
||||
logic. Action lists are extensible; callers should render the records they
|
||||
understand and ignore unknown ids rather than assuming a fixed list length.
|
||||
|
||||
## Rendering pipelines
|
||||
|
||||
Most registries hand the engine *content*. `render_pipelines` hands it
|
||||
@@ -237,6 +383,25 @@ local keys, code, message = mod.storage:list(game, "history/quick")
|
||||
local deleted, code, message = mod.storage:delete(game, "history/quick/q0001")
|
||||
```
|
||||
|
||||
For independently generated binary data, use the opaque byte methods. They
|
||||
accept and return the exact Lua string of bytes, including NUL bytes and bytes
|
||||
that are not valid text:
|
||||
|
||||
```lua
|
||||
local ok, code, message = mod.storage:writeBytes(
|
||||
game, "cache/maps/pallet/terrain", encodedMesh)
|
||||
local encodedMesh, code, message = mod.storage:readBytes(
|
||||
game, "cache/maps/pallet/terrain")
|
||||
```
|
||||
|
||||
Opaque values are limited to 512 MiB per key. The engine stores them without
|
||||
decoding, compression, or an engine-defined file format, and never executes
|
||||
them. A consuming mod owns validation of its format, fingerprint, checksum,
|
||||
and compression metadata. Byte writes are staged and compared byte-for-byte
|
||||
before replacement, and reads can recover a valid backup after an interrupted
|
||||
write. Existing table values and opaque byte values use one shared logical key
|
||||
space; delete a key before changing its value from one type to the other.
|
||||
|
||||
`context` returns `{ engineVersion, gameVersion, playthroughId }`. The engine
|
||||
version is compatibility metadata; physical launcher-slot and path identity stays
|
||||
private. A title-selected context may additionally contain `normalSavedAt`, the
|
||||
@@ -245,19 +410,22 @@ progress or a slot/path handle.
|
||||
|
||||
At the title screen only, `mod.storage:selected(game)` returns a bound storage
|
||||
facade for the launcher-selected existing playthrough, or `nil, code, message`.
|
||||
Resolving this facade is read-only: it never allocates an identity, adopts a
|
||||
Resolving this facade is non-allocating: it never allocates an identity, adopts a
|
||||
fresh New Game, or exposes a slot id/path. Its `context()`, `read(key)`,
|
||||
`write(key, value)`, `list(prefix)`, and `delete(key)` methods have the same
|
||||
data-only and transaction contract as `mod.storage`, but remain restricted to
|
||||
the calling mod's selected existing namespace. It is intended for title tools
|
||||
that need to browse or manage durable history before the first normal SAVE.
|
||||
`write(key, value)`, `readBytes(key)`, `writeBytes(key, bytes)`,
|
||||
`list(prefix)`, and `delete(key)` methods have the same scoped and
|
||||
transactional contract as `mod.storage`, but remain restricted to the calling
|
||||
mod's selected existing namespace. It is intended for title tools that need to
|
||||
browse or manage durable history before the first normal SAVE.
|
||||
|
||||
Values must be tables containing serializable data only. Keys are conservative
|
||||
slash-separated segments (letters, digits, `_`, `-`); paths and filesystem
|
||||
handles are never exposed. Writes are staged and decode-verified, reads recover
|
||||
from a valid staged/backup generation, and methods return structured errors for
|
||||
normal data or I/O failures. The playthrough identity is allocated lazily on the
|
||||
first storage/checkpoint call, so an unused API changes no save bytes.
|
||||
Table values must contain serializable data only. Opaque values must be Lua
|
||||
strings. Keys are conservative slash-separated segments (letters, digits, `_`,
|
||||
`-`); paths and filesystem handles are never exposed. Table writes are staged
|
||||
and decode-verified; opaque writes are staged and byte-verified; reads recover
|
||||
from a valid staged/backup generation. Methods return structured errors for
|
||||
normal data, byte validation, and I/O failures. The playthrough identity is
|
||||
allocated lazily on the first storage/checkpoint call, so an unused API changes
|
||||
no save bytes.
|
||||
|
||||
`mod.checkpoints` captures and reconstructs engine-owned semantic runtime state:
|
||||
|
||||
@@ -352,6 +520,43 @@ animation/messages, forced choices, and every phase that cannot safely be
|
||||
checkpointed remain excluded. Exceptions are contained by normal hook isolation
|
||||
and fall through without advancing a turn.
|
||||
|
||||
Gen 1 trainer encounters also expose `trainer.before_battle` after the
|
||||
challenge text and immediately before battle construction. This lets a mod
|
||||
defer the encounter while it collects a player choice through a registered
|
||||
screen, then resume with a battle-local view of the save party:
|
||||
|
||||
```lua
|
||||
mod.hooks:wrap("trainer.before_battle", function(next, game, context, continue)
|
||||
-- context = { trainerClass, partyIndex, mapId, npcId }
|
||||
mod.ui.push(game, "party_registration", {
|
||||
onConfirm = function(indices)
|
||||
continue({ playerPartyIndices = indices })
|
||||
end,
|
||||
onCancel = function()
|
||||
continue({ cancel = true })
|
||||
end,
|
||||
})
|
||||
return true
|
||||
end)
|
||||
```
|
||||
|
||||
Return `true` only when retaining `continue` for a later callback. Calling
|
||||
`continue({ cancel = true })` ends the encounter without constructing a battle;
|
||||
the normal encounter completion callback returns control to the overworld and
|
||||
no trainer-defeated state is written. A cancelled sight encounter is suppressed
|
||||
at the current player cell so it cannot immediately reopen; moving one cell or
|
||||
talking to the trainer permits a new challenge. Calling `continue()` uses the
|
||||
full save party; passing
|
||||
`{ playerPartyIndices = { 2, 4, 5 } }` uses those ordered, one-based party
|
||||
members for initial send, switching and forced replacement, exhaustion,
|
||||
experience traversal, and battle party displays. The continuation is one-shot.
|
||||
An empty, duplicate, out-of-range, or otherwise malformed list safely falls
|
||||
back to the full party. The view references the original Pokemon records and
|
||||
never reorders or replaces `game.save.party`; trainer battle checkpoints retain
|
||||
the selected indices. Mods remain responsible for selection policy and should
|
||||
use only public `mod.ui`, hook, and save APIs. See RFC 0010 for the exact
|
||||
contract and compatibility guarantees.
|
||||
|
||||
## Developer console
|
||||
|
||||
Boot with developer mode on to unlock the in-game console and hot-reload
|
||||
@@ -439,6 +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,
|
||||
or push one onto a second screen, without the engine knowing the layout.
|
||||
|
||||
`render.output_enabled` and `render.output` are the later, whole-window seam
|
||||
for mods that need the engine's normal composite rather than its separate
|
||||
layers. It runs after registered present pipelines and before GBCFX,
|
||||
`render.hud`, and touch controls. A mod wraps both hooks: the first returns
|
||||
`true` only while output ownership is needed, and the second receives
|
||||
`(next, ctx)` with `canvas`, `width`, `height`, `gameX`, `gameY`, `gameWidth`,
|
||||
`gameHeight`, `scale`, `dpiX`,
|
||||
`dpiY`, and `generation`. Returning `true` from `render.output` takes over the
|
||||
window; calling `next(ctx)` keeps the normal presentation. Both hooks default
|
||||
to `false`. Enabling the seam requires a full-window canvas for that frame.
|
||||
With no `render.output` subscriber, or while `render.output_enabled` is false,
|
||||
the existing presentation path is unchanged. `render.compose` takes precedence
|
||||
when it owns the frame.
|
||||
|
||||
`screen.render_visible` receives `(next, state)` while the main screen is being
|
||||
composed. Return `false` to omit that state from drawing, opacity selection and
|
||||
palette-zone ownership. The state remains on the stack and keeps its normal
|
||||
@@ -454,6 +673,7 @@ identifiers: `pc_box_withdraw`, `pc_box_deposit`, `pc_box_release`,
|
||||
`battle.bottom_ui_visible` and `battle.status_hud_visible` independently
|
||||
control the battle text/menu layer and the HP/status panels. Both receive
|
||||
`(next, state)` and default to `true`, so vanilla rendering is unchanged.
|
||||
Both hooks apply to Gen 1 and Gen 2 battles.
|
||||
Text boxes and YES/NO prompts pushed above a battle inherit a `false` result
|
||||
for that battle, so hiding the bottom layer cannot leave their white backing
|
||||
behind under another overlay. Text boxes also pass through the hook as their
|
||||
@@ -544,3 +764,41 @@ local both = mod.datetime:dateTime(game, createdAt)
|
||||
The live `game` supplies only the current option context. Formatting never
|
||||
mutates the save, options, or timestamp, and invalid timestamps return
|
||||
`"----"`.
|
||||
|
||||
## Device power information
|
||||
|
||||
Sandboxed mods can read the host's battery state without receiving the rest
|
||||
of `love.system`:
|
||||
|
||||
```lua
|
||||
local state, percent = mod.device:powerInfo()
|
||||
```
|
||||
|
||||
`state` follows LÖVE's values: `"unknown"`, `"battery"`, `"nobattery"`,
|
||||
`"charging"`, or `"charged"`. `percent` is `0` through `100`, or `nil` when
|
||||
the platform cannot report it. The facade is read-only and does not expose
|
||||
URL launching, clipboard access, or other system operations.
|
||||
|
||||
## Real-world steps
|
||||
|
||||
On iOS and Android the game counts the player's real-world steps natively
|
||||
(HealthKit / the hardware step counter). A mod reaches that bridge through
|
||||
the `steps` permission in `manifest.json`, which the player sees in the
|
||||
mod manager like every other permission:
|
||||
|
||||
```lua
|
||||
if mod.steps:available() then
|
||||
mod.steps:sync() -- async; OS consent sheet on first use
|
||||
end
|
||||
-- later, at a quiet moment:
|
||||
local walk = mod.steps:poll() -- { steps = n, from = ?, to = ? } or nil
|
||||
```
|
||||
|
||||
`available()` is `false` on builds without the bridge (desktop) and for
|
||||
mods without the permission, so a probe is always safe. `sync()` asks the
|
||||
platform to refresh its count and returns whether there was a bridge to
|
||||
ask. `poll()` returns the next delivery for this mod — the engine consumes
|
||||
the native side's pending file itself, each permissioned mod receives its
|
||||
own copy of a delivery, and steps are anchored natively so the same walk
|
||||
is never delivered twice. Without the permission, `sync` and `poll` raise
|
||||
an error naming it.
|
||||
|
||||
@@ -929,6 +929,10 @@ mod.content.sfx:register("SFX_MOD_CHIME", { file = "chime.ogg" })
|
||||
|
||||
| field | type | required |
|
||||
|---|---|---|
|
||||
| `anchorX` | number | no |
|
||||
| `anchorY` | number | no |
|
||||
| `frameHeight` | integer >= 1 | no |
|
||||
| `frameWidth` | integer >= 1 | no |
|
||||
| `frames` | integer >= 1 | yes |
|
||||
| `id` | string | no |
|
||||
| `image` | file path | yes |
|
||||
@@ -1100,6 +1104,7 @@ mod.content.tokens:register("CLOCK", function(game) return "12" end)
|
||||
| `paletteSource` | string | no |
|
||||
| `parties` | list of list of {level, species} | yes |
|
||||
| `pic` | file path | no |
|
||||
| `trueColor` | boolean | no |
|
||||
|
||||
```lua
|
||||
mod.content.trainers:patch("OPP_BROCK", { baseMoney = 99 })
|
||||
@@ -1122,7 +1127,9 @@ do not.
|
||||
| `index` | integer 0..255 | no |
|
||||
| `items` | list of items id | no |
|
||||
| `name` | string | yes |
|
||||
| `pic` | file path | no |
|
||||
| `trainers` | list of {id?, index?, name, party, trainerType?} | yes |
|
||||
| `trueColor` | boolean | no |
|
||||
|
||||
```lua
|
||||
mod.content.trainers:patch("BEAUTY", { baseMoney = 99 })
|
||||
|
||||
@@ -19,13 +19,14 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow
|
||||
* **Soft reset button combination**
|
||||
* **Keyboard and controller rebinding**
|
||||
* **Mod profiles** with separate mod settings and save slots
|
||||
* **Sandboxed mods**: an installed mod can read only its own folder and write only its own storage, so it cannot reach the rest of your device
|
||||
* **Improved launcher and save editor UI**, including background downloads and update checks
|
||||
* **Direct-launch options** for shortcuts, Steam entries, and handheld frontends
|
||||
* **Custom boot branding**
|
||||
|
||||
## Pokémon Gold (Gen 2)
|
||||
|
||||
A fourth game the launcher can import and play, built from pret/pokegold the same way Red/Blue/Yellow are built from pokered. Port extras beyond the cartridge:
|
||||
A fourth game the launcher can import and play, built from pret/pokegold the same way Red/Blue/Yellow are built from pokered (and pokeyellow). Port extras beyond the cartridge:
|
||||
|
||||
* **COLOR, zoom, tilt, GBC FX, and quick save/load**
|
||||
* **UI that stays fixed while the overworld zooms**
|
||||
|
||||
@@ -258,20 +258,34 @@ row on the detail screen. The launcher's dependency verdict asks the same
|
||||
question of your dependencies: a mod whose hard dependency does not run on the
|
||||
selected game reads `Needs <id> (not for Gold)` rather than `Ready`.
|
||||
|
||||
### Scoping dependencies per game / generation
|
||||
|
||||
For mods targeting multiple generations (`"games": ["gen1", "gen2"]`), a hard
|
||||
dependency can be scoped to specific games so that it is only enforced when
|
||||
booting those games:
|
||||
|
||||
```json
|
||||
"dependencies": [
|
||||
{ "id": "pokegear_cards", "games": ["gen2"], "range": "^1.0.0", "github": "1jamie/pokegear_cards" }
|
||||
]
|
||||
```
|
||||
|
||||
When booting a Gen 1 game (Red, Blue, Yellow), the engine loader sees that
|
||||
`pokegear_cards` is scoped to `"gen2"` and will not skip or block the parent mod
|
||||
on Gen 1. When booting Gen 2 (Gold), `pokegear_cards` is strictly required.
|
||||
|
||||
For conditional integrations where the dependency is optional across the board,
|
||||
`optional_dependencies` remains the standard pattern.
|
||||
|
||||
### One limit worth knowing
|
||||
|
||||
**Per-game enable flags are still a preview.** The overlay
|
||||
`options.modsByVersion[version][id]` exists and every surface goes through
|
||||
`SaveData.modEnabled` / `SaveData.setModEnabled`, but
|
||||
`SaveData.PER_VERSION_MODS` is `false`
|
||||
(`src/core/SaveData.lua:489`). While it is false, `SaveData.modScope` answers
|
||||
nil for every caller, so the launcher panel, the in-game manager *and* the
|
||||
loader all read and write the one shared `options.mods` flag and the overlay is
|
||||
not consulted for enablement anywhere. That matters because the overlay is
|
||||
plantable from an imported `.g1rmodlist`: keeping every reader on the same
|
||||
scope as every writer is what stops a stored per-game flag from showing a mod
|
||||
set no boot would honour. Nothing about this affects a mod author; it affects
|
||||
what a player can currently express.
|
||||
**Enablement is per game.** The overlay
|
||||
`options.modsByVersion[version][id]` is read and written through
|
||||
`SaveData.modEnabled` / `SaveData.setModEnabled` by the launcher, in-game
|
||||
manager, and loader. Existing shared settings are copied to every game the
|
||||
first time this version sees the installed mods; from then on, each coloured
|
||||
game checkbox changes only that game's next boot. Nothing about this affects a
|
||||
mod author; it affects what a player can express.
|
||||
|
||||
Targeting is a different question from enablement and *is* enforced per game,
|
||||
as above. The two do not share a switch.
|
||||
@@ -311,7 +325,7 @@ This is not a dev-mode feature; it installs on any Gold boot that has mods.
|
||||
| `src.pokemon.Boxes` | facade | over `src/core/gen2/Boxes.lua` | 22 / 0 / 0 |
|
||||
| `src.battle.BattleState` | facade | over `src/ui/gen2/BattleState.lua` | 16 / 2 / 39 |
|
||||
| `src.ui.PartyMenu` | facade | over `src/ui/gen2/PartyMenu.lua` | 15 / 2 / 16 |
|
||||
| `src.world.WorldAPI` | alias | `src/world/gen2/WorldAPI.lua` | 12 / 2 / 0 |
|
||||
| `src.world.WorldAPI` | alias | `src/world/gen2/WorldAPI.lua` | 15 / 2 / 0 |
|
||||
| `src.world.PikachuFollower` | alias | `src/world/gen2/Follower.lua` | 10 / 0 / 11 |
|
||||
| `src.script.ScriptRunner` | facade | over `src/script/gen2/Vm.lua` | 10 / 7 / 1 |
|
||||
| `src.ui.OptionsMenu` | facade | over `src/ui/gen2/OptionsMenu.lua` | 8 / 0 / 1 |
|
||||
@@ -760,7 +774,7 @@ profile to test in, and `POKEPORT_DEV=1` adds the console and `F5` hot reload.
|
||||
|
||||
- **Coverage is partial and will stay partial.** 15 Gen 1 modules are served
|
||||
out of a much larger engine, and within those 15 the coverage table records
|
||||
288 backed members against 32 warned and 161 absent. The absent ones are not
|
||||
291 backed members against 32 warned and 161 absent. The absent ones are not
|
||||
a backlog; most are absent because there is no honest Gen 2 answer, and each
|
||||
one carries its reason. The counts move as the adapter learns something: a
|
||||
member that turns out to answer nil is demoted from backed to warned or
|
||||
|
||||
@@ -26,8 +26,8 @@ wiki's Save Model. `mod.save` and `mod.options` keep their existing behavior.
|
||||
## The exact API delta
|
||||
|
||||
Backward-compatible, additive-only. `Loader:_api` binds a new `mod.storage`
|
||||
facade to the calling mod id. Mods receive logical keys and decoded values, never
|
||||
filesystem handles or physical paths.
|
||||
facade to the calling mod id. Mods receive logical keys and either decoded table
|
||||
values or exact opaque byte strings, never filesystem handles or physical paths.
|
||||
|
||||
### Lazy opaque playthrough identity
|
||||
|
||||
@@ -75,6 +75,25 @@ Returns a freshly decoded table, or `nil, code, message`. It tries main, staged,
|
||||
then backup data. A valid staged/backup value is returned and promoted
|
||||
best-effort; corrupt bytes are never executed.
|
||||
|
||||
### `mod.storage:writeBytes(game, key, bytes)`
|
||||
|
||||
Accepts a Lua string containing opaque bytes and returns `true`, or
|
||||
`false, code, message`. Empty strings are valid. Payloads are limited to 512
|
||||
MiB per key. The engine writes the supplied bytes exactly as received, without
|
||||
decoding, compression, checksums, or an engine-defined envelope. The consuming
|
||||
mod owns semantic validation of its format.
|
||||
|
||||
Byte records use private `.bin`, `.bin.tmp`, and `.bin.bak` witnesses. A staged
|
||||
and replacement write is read back and compared byte-for-byte before it is
|
||||
committed. A failed write leaves the previous verified generation readable.
|
||||
Byte storage never passes its payload to the Lua serializer, loader, or module
|
||||
resolver.
|
||||
|
||||
Table and byte records share one logical key namespace and a key has one type.
|
||||
Writing one type over the other returns `type_conflict`; callers must delete the
|
||||
key before changing its type. `mod.storage:selected(game)` exposes the same
|
||||
`readBytes` and `writeBytes` operations for the selected playthrough facade.
|
||||
|
||||
### `mod.storage:list(game[, prefix])`
|
||||
|
||||
Returns sorted logical keys beneath a valid prefix, an exact key when the prefix
|
||||
@@ -92,9 +111,9 @@ Physical records are scoped as:
|
||||
`persistence root / mod_storage / game version / playthrough id / mod id`
|
||||
|
||||
Stable error codes are `not_in_playthrough`, `storage_unavailable`,
|
||||
`invalid_key`, `encode_failed`, `write_failed`, `verify_failed`, and
|
||||
`not_found`. Ordinary data and I/O failures are return values, not callback-
|
||||
terminating errors.
|
||||
`invalid_key`, `encode_failed`, `invalid_bytes`, `size_limit`, `type_conflict`,
|
||||
`type_mismatch`, `write_failed`, `verify_failed`, and `not_found`. Ordinary
|
||||
data and I/O failures are return values, not callback-terminating errors.
|
||||
|
||||
The restricted serializer's recursive writer runs outside LuaJIT traces. A
|
||||
1,000-process GC stress regression found compiled recursion could intermittently
|
||||
@@ -107,6 +126,8 @@ boundary.
|
||||
**Nothing.** No API is removed, no manifest field changes, and no storage path or
|
||||
playthrough id is created unless a mod invokes `mod.storage` or
|
||||
`mod.checkpoints`. Existing save bytes remain unchanged on the no-caller path.
|
||||
Existing files outside the scoped storage contract are not imported; a caller
|
||||
must rebuild them through `writeBytes`.
|
||||
|
||||
## Parity tests
|
||||
|
||||
@@ -115,8 +136,10 @@ playthrough id is created unless a mod invokes `mod.storage` or
|
||||
- **Engine identity:** lazy allocation, save/load preservation, stable legacy
|
||||
mapping, fresh-playthrough replacement, and version/slot isolation.
|
||||
- **Public Mod API:** two real API-2 entry chunks prove data-only roundtrip,
|
||||
opaque byte roundtrip including NUL bytes, no execution, size/type rejection,
|
||||
deterministic listing, key rejection, mod/game/playthrough isolation,
|
||||
corrupt-main recovery, failure retention, exact delete, and no-mod no-write.
|
||||
corrupt-main recovery, failure retention, selected-playthrough access, exact
|
||||
delete, and no-mod no-write.
|
||||
|
||||
## Deprecation etiquette
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -76,15 +76,6 @@ end
|
||||
local editorHost, editorVersion, editorWindow
|
||||
local closeEditor -- forward declaration: openEditor hands it to the editor
|
||||
|
||||
-- tools/save-editor/ models the Gen 1 save and nothing else: a Gen 2 party row
|
||||
-- carries fields its MonOps and panels have no idea about (dvs, statExp,
|
||||
-- happiness, pokerus, caughtLevel), and SaveIO.save writes the WHOLE table
|
||||
-- back, so a Gold slot opened here comes out in a shape src/core/gen2/Save.lua
|
||||
-- then has to quarantine on the next boot. Refuse by name, the same way the
|
||||
-- .sav paths do (src/save_convert/SaveConvert.lua GEN2_SAV_UNSUPPORTED), so
|
||||
-- Red/Blue/Yellow slots are untouched.
|
||||
local GEN2_NO_EDITOR = { gold = "Pokemon Gold" }
|
||||
|
||||
-- The editor's modules use flat names (require("Kit"), require("Party")), so
|
||||
-- their directories have to be on the require path. It must be
|
||||
-- love.filesystem's path, not package.path: in a packaged build these files
|
||||
@@ -137,11 +128,6 @@ local function openEditor(version, slotId)
|
||||
Importer.saveNotice = Importer.saveNotice or {}
|
||||
Importer.saveNotice[version] = { ok = false, text = text }
|
||||
end
|
||||
local gen2Name = GEN2_NO_EDITOR[version]
|
||||
if gen2Name then
|
||||
refuse(gen2Name .. " uses a Gen 2 save; the save editor does not read one yet.")
|
||||
return
|
||||
end
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local path = SaveData.slotDiskPath(version, slotId)
|
||||
if not path then
|
||||
@@ -320,14 +306,6 @@ function love.load(args)
|
||||
-- cache has to be mounted before the editor's Data:load.
|
||||
if editorMode then
|
||||
local version = os.getenv("POKEPORT_VERSION") or "red"
|
||||
local gen2Name = GEN2_NO_EDITOR[version]
|
||||
if gen2Name then
|
||||
-- No launcher behind this run to carry a notice, so say it and stop
|
||||
-- rather than open a Gen 2 slot on Gen 1 panels.
|
||||
print(gen2Name .. " uses a Gen 2 save; the save editor does not read one yet.")
|
||||
love.event.quit(1)
|
||||
return
|
||||
end
|
||||
require("src.core.GameVersion").set(version)
|
||||
require("src.import.CacheFs").mountVersion(version)
|
||||
addEditorRequirePath()
|
||||
|
||||
@@ -192,8 +192,14 @@ bool System::pickFile(const char *kind) const
|
||||
dest = "picked_mod.zip";
|
||||
else if (strcmp(kind, "sav") == 0 || strcmp(kind, "save") == 0)
|
||||
dest = "picked_save.sav";
|
||||
else if (strcmp(kind, "required_import") == 0)
|
||||
dest = "picked_required_import.bin";
|
||||
else if (strcmp(kind, "rom") == 0)
|
||||
dest = "picked_rom.gb";
|
||||
// Unknown kinds used to fall through to the ROM destination. Refuse them
|
||||
// so a newer Lua caller cannot silently route an unrelated file as a ROM.
|
||||
else
|
||||
return false;
|
||||
}
|
||||
return love::android::showFilePicker(dest);
|
||||
#else
|
||||
@@ -202,6 +208,15 @@ bool System::pickFile(const char *kind) const
|
||||
#endif
|
||||
}
|
||||
|
||||
const char *System::pickFileKinds() const
|
||||
{
|
||||
#ifdef LOVE_ANDROID
|
||||
return "rom,mod,sav,required_import";
|
||||
#else
|
||||
return "";
|
||||
#endif
|
||||
}
|
||||
|
||||
bool System::createFile(const char *suggestedName) const
|
||||
{
|
||||
#ifdef LOVE_ANDROID
|
||||
|
||||
@@ -112,10 +112,12 @@ public:
|
||||
* love::android::showFilePicker and src/import/RomImporter.lua.
|
||||
*
|
||||
* @param kind Optional pick kind: nullptr/"rom" -> picked_rom.gb,
|
||||
* "mod" -> picked_mod.zip, "sav"/"save" -> picked_save.sav.
|
||||
* "mod" -> picked_mod.zip, "sav"/"save" -> picked_save.sav,
|
||||
* "required_import" -> picked_required_import.bin.
|
||||
* @return Whether the picker was shown.
|
||||
**/
|
||||
virtual bool pickFile(const char *kind = nullptr) const;
|
||||
virtual const char *pickFileKinds() const;
|
||||
|
||||
/**
|
||||
* Shows the platform's native "create / save a file" UI (Android SAF
|
||||
|
||||
@@ -102,6 +102,12 @@ int w_pickFile(lua_State *L)
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_pickFileKinds(lua_State *L)
|
||||
{
|
||||
luax_pushstring(L, instance()->pickFileKinds());
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_createFile(lua_State *L)
|
||||
{
|
||||
const char *suggested = luaL_optstring(L, 1, nullptr);
|
||||
@@ -222,6 +228,7 @@ static const luaL_Reg functions[] =
|
||||
{ "openURL", w_openURL },
|
||||
{ "vibrate", w_vibrate },
|
||||
{ "pickFile", w_pickFile },
|
||||
{ "pickFileKinds", w_pickFileKinds },
|
||||
{ "createFile", w_createFile },
|
||||
{ "syncHealthSteps", w_syncHealthSteps },
|
||||
{ "restartApp", w_restartApp },
|
||||
|
||||
@@ -12,6 +12,62 @@
|
||||
"tintColor": "3b5ca8",
|
||||
"category": "games",
|
||||
"versions": [
|
||||
{
|
||||
"version": "0.1.88",
|
||||
"date": "2026-08-14",
|
||||
"size": 11311237,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.88/gen1recomp++-0.1.88-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.87",
|
||||
"date": "2026-08-14",
|
||||
"size": 11310910,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.87/gen1recomp++-0.1.87-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @AverageConsumer\n- @bryanthaboi\n- @mleo2003\n- @ShaneMcGovernIE\n- MaxTomahawk"
|
||||
},
|
||||
{
|
||||
"version": "0.1.86",
|
||||
"date": "2026-08-14",
|
||||
"size": 11306689,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.86/gen1recomp++-0.1.86-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1283 Intro broken and leading to softlock (R,B,Y currently unplayable without save file)\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.85",
|
||||
"date": "2026-08-14",
|
||||
"size": 11306462,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.85/gen1recomp++-0.1.85-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.84",
|
||||
"date": "2026-08-14",
|
||||
"size": 11306462,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.84/gen1recomp++-0.1.84-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #919 Bills House game save moves strangely after reload\n- #982 Trading QOL aspect missing from OG.\n- #1003 Land Pokemon Encounters while Surfing in Cerulean Cave\n- #1012 Multiple issues with text speed, animations, and audio\n- #1022 Cannot interact with small trees\n- #1028 Trainers not resetting on route change.\n- #1033 Some issues with Link battles.\n- #1243 Can't sell TMs at Poké Marts\n\n## Contributors\n\n- @1Jamie\n- @AverageConsumer\n- @bryanthaboi\n- @TheRealSolidusSnake\n- Myles Resnick\n- ShaneMcGovernIE"
|
||||
},
|
||||
{
|
||||
"version": "0.1.83",
|
||||
"date": "2026-08-13",
|
||||
"size": 11282300,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.83/gen1recomp++-0.1.83-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @AverageConsumer\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.82",
|
||||
"date": "2026-08-13",
|
||||
"size": 11281277,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.82/gen1recomp++-0.1.82-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #914 Immunity steps and no encounter tiles\n- #936 Fly and teleporting allow should block interactions\n- #944 When starting a new game, the character sprite is facing down instead of up\n- #949 Save game import, save game export, \"normal\" save game\n- #964 Menu closing when trying to view Pokemon before picking starter\n- #978 Following up of #847\n- #983 Pikachu incorrectly able to be released\n- #984 EXP calculation after trading a PKMN back isnt right.\n- #987 Celadon Game Corner Display Error (L22680)\n- #988 (Yellow) Missing Pikachu Interaction - Pewter City Pokemon Centre\n- #989 (Yellow) Missing Pikachu Interaction - Vermilion City Pokemon Fan Club\n- #990 (Yellow) Pikachu Missing Hat\n- #999 Pallet Town music while player follows Prof. Oak\n- #1007 Transition into battle state for the 'Catching Pikachu' scene missing\n- #1010 Rival's position resets\n- #1011 Rival dialogue issue\n- #1029 1px black line sits under the title logo at the SGB zone boundary\n- #1035 Alt Music not playing\n- #1041 Mimic text not swoing up\n- #1047 Using a TM or HM closes the menu afterwards.\n- #1048 Cannot select items from the sell menu.\n- #1056 Cursor not jumping when switching moves\n- #1065 Player walks through statue after final battle while walking into Hall Of Fame\n- #1066 Anomalies when opponent switches out Pokemon\n- #1069 missing {RAM:wNameBuffer}\n- #1073 _GotMonText not used\n- #1089 Scripted movement missing for player and rival\n- #1101 Recoil damage calculation bug\n- #1115 Hitting attack does no damage\n- #1119 Wrongs textlines and events\n- #1120 Health is black when viewing stats\n- #1129 Errors in bowing animations of the nurse\n- #1146 [Recomp] Map music glitch\n- #1149 issue that affects tranlations\n- #1175 Pikachu visible too late\n- #1189 Town Map Item\n- #1193 Status moves have (wrong) sound effects when battle animations are turned off\n- #1206 Gen1: Cinnebar island has unfishable tiles north and east\n- #1207 Poison Flicker Visual issue\n- #1214 Title Screen with OG Red is the wrong color\n- #1219 [GOLD] UNABLE TO USE SUN STONE\n- #1220 No screen shaking for hitting moves when battle animations are off\n- #1221 [Gold] Flaafy evolution to at Lvl 30 Ampharos skipped learn thunder punch\n- #1225 Jingles for learning moves missing\n\n## Contributors\n\n- @1Jamie\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.81",
|
||||
"date": "2026-08-13",
|
||||
"size": 9886854,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.81/gen1recomp++-0.1.81-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1091 Encounter Rate is tied to Game Speed\n- #1097 Phase through walls exiting a cave\n- #1100 Allow for control edit and vibration toggle on gold\n- #1104 wrong warp\n- #1135 update for iphone - haptic vibrations\n- #1155 Magnitude always rolls a 4\n- #1157 Sonic Boom doesn't deal set damage\n- #1158 Unable to view opponent's next Pokemon\n- #1159 Dig animation is bugged\n- #1160 64x64 battle sprite followup for mods\n- #1167 [Gold] items in ilex forest missing\n- #1208 Gold Save game not showing properly in LAUNCHER after having multiple saves\n\n## Contributors\n\n- @bryanthaboi\n- @castdrian\n- @MaxTomahawk\n- @Yukitty\n- Codex Agent"
|
||||
},
|
||||
{
|
||||
"version": "0.1.80",
|
||||
"date": "2026-08-12",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// without updating that patch (see mobile/ios/patch_love_src.py).
|
||||
//
|
||||
// Contract (mirrors love-android's GameActivity.showFilePicker):
|
||||
// love.system.pickFile("rom"|"mod"|"sav") -> copies the user's pick into
|
||||
// love.system.pickFile("rom"|"mod"|"sav"|"required_import") -> copies the user's pick into
|
||||
// the LÖVE save directory as picked_rom.gb / picked_mod.zip /
|
||||
// picked_save.sav; RomImporter's pending-file scan consumes it.
|
||||
// love.system.createFile(name) -> exports save dir's pending_export.sav
|
||||
@@ -83,6 +83,8 @@ public final class GRPickerBridge: NSObject {
|
||||
types = [.zip]
|
||||
case "sav":
|
||||
destName = "picked_save.sav"
|
||||
case "required_import":
|
||||
destName = "picked_required_import.bin"
|
||||
// A Nintendo 64 cartridge, for mods that build assets out of one --
|
||||
// the voxel mod's Pokemon Stadium battle models are the caller this
|
||||
// was added for. Its own filename on purpose: an N64 ROM landing on
|
||||
@@ -135,7 +137,7 @@ public final class GRPickerBridge: NSObject {
|
||||
// Kept beside the switch it describes, because the two drifting apart is
|
||||
// the only way this can lie.
|
||||
@objc public static func supportedPickerKinds() -> NSString {
|
||||
return "rom,mod,sav,stadium" as NSString
|
||||
return "rom,mod,sav,stadium,required_import" as NSString
|
||||
}
|
||||
|
||||
@objc(presentExportWithName:saveDir:)
|
||||
|
||||
@@ -89,7 +89,8 @@ int w_pickFile(lua_State *L)
|
||||
return gr_callBridge(L, "GRPickerBridge", "presentPickerWithKind:saveDir:", kind);
|
||||
}
|
||||
|
||||
// love.system.pickFileKinds() -> "rom,mod,sav,stadium", or nil off iOS.
|
||||
// love.system.pickFileKinds() -> the comma-separated kinds supported by the
|
||||
// Swift bridge (including required_import), or nil off iOS.
|
||||
//
|
||||
// So a caller can ask what this build's picker understands BEFORE opening it.
|
||||
// An unknown kind is refused (GRPickerBridge), and a refusal looks exactly
|
||||
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
/Users/bryanbassett/Documents/development/pokemon-gen1-recomp-project/.bazinga/mods/timekeepers_hut
|
||||
@@ -23,6 +23,10 @@ Ship `gen1tls.dll` (or `libgen1tls.so` / `libgen1tls.dylib`) **next to** the
|
||||
fused executable. Mods load it through LuaJIT FFI; no .NET runtime is
|
||||
required on the player's machine.
|
||||
|
||||
Official Windows release zips get `gen1tls.dll` from CI: a `windows-2022`
|
||||
job publishes this project and `scripts/build.sh`'s Windows packaging copies
|
||||
it beside `gen1recomp.exe` (see `GEN1TLS_DLL` / `dist/native/win-x64`).
|
||||
|
||||
## Android
|
||||
|
||||
On Android, the matching API is exposed as `love.system.tlsOpen` /
|
||||
|
||||
@@ -261,6 +261,21 @@ build_win() {
|
||||
cp "$love_dir"/*.dll "$out_dir"/
|
||||
cp "$love_dir"/license.txt "$out_dir"/ 2>/dev/null || true
|
||||
|
||||
# Native AOT TLS dialer for outbound wss:// (e.g. Archipelago hosted rooms).
|
||||
# Release CI builds this on windows-2022 (Native AOT can't cross-compile
|
||||
# win-x64 from the Mac runner) and either exports GEN1TLS_DLL or drops the
|
||||
# file at dist/native/win-x64/gen1tls.dll before calling build.sh.
|
||||
local tls_dll="${GEN1TLS_DLL:-}"
|
||||
if [ -z "$tls_dll" ] && [ -f "$DIST/native/win-x64/gen1tls.dll" ]; then
|
||||
tls_dll="$DIST/native/win-x64/gen1tls.dll"
|
||||
fi
|
||||
if [ -n "$tls_dll" ] && [ -f "$tls_dll" ]; then
|
||||
cp "$tls_dll" "$out_dir/gen1tls.dll"
|
||||
say "bundled gen1tls.dll for Windows TLS (wss://)"
|
||||
else
|
||||
warn "gen1tls.dll not found — Windows zip will not support wss:// (set GEN1TLS_DLL or build native/tls_dial)"
|
||||
fi
|
||||
|
||||
# The exe's icon lives in love.exe's PE resources, so it must be patched
|
||||
# BEFORE the .love is appended: peresed rewrites the whole file and would
|
||||
# drop the fused bytes. peresed (pipx install pe_tools) has no .ico input,
|
||||
@@ -400,6 +415,9 @@ EOF
|
||||
grep -q '^FUSE_PATH="\$APPDIR/game.love"$' "$appdir/AppRun" \
|
||||
|| fail "failed to enable FUSE_PATH in AppRun (upstream AppRun changed?)"
|
||||
|
||||
sed -i '' 's|^exec "\$APPDIR/bin/love"|if [ -n "$WAYLAND_DISPLAY" ] \&\& [ -z "$SDL_VIDEODRIVER" ]; then export SDL_VIDEODRIVER=x11; fi\
|
||||
exec "$APPDIR/bin/love"|' "$appdir/AppRun"
|
||||
|
||||
# Match the upstream image's compression (gzip, 128K blocks) so the
|
||||
# bundled runtime can read it.
|
||||
local sfs_out="$WORK/game.squashfs"
|
||||
|
||||
@@ -93,7 +93,8 @@ build_autotools mpg123 "$MPG123_VERSION" "$MPG123_TARBALL" libmpg123.so.0 \
|
||||
# The symbol that was missing when this was bullseye's copy. Assert it, so a
|
||||
# version bump that quietly regresses below the host's expectations fails the
|
||||
# build instead of silently killing audio again.
|
||||
objdump -T "$PREFIX/lib/libmpg123.so.0" | grep -q 'mpg123_info2' \
|
||||
mpg123_syms="$(objdump -T "$PREFIX/lib/libmpg123.so.0")"
|
||||
grep -q 'mpg123_info2' <<<"$mpg123_syms" \
|
||||
|| fail "bundled libmpg123 lacks mpg123_info2; the host's libsndfile will fail to relocate"
|
||||
|
||||
# ------------------------------------------------------------ compile SDL2
|
||||
@@ -128,8 +129,9 @@ fi
|
||||
# the host having that exact stack -- which is the bug this replaced.
|
||||
sdl_lib="$PREFIX/lib/libSDL2-2.0.so.0"
|
||||
[ -f "$sdl_lib" ] || fail "SDL2 build produced no libSDL2-2.0.so.0"
|
||||
sdl_needed="$(objdump -p "$sdl_lib")"
|
||||
for forbidden in libpulse libasound libX11 libwayland libdrm libgbm libsndio; do
|
||||
if objdump -p "$sdl_lib" | grep -q "NEEDED.*$forbidden"; then
|
||||
if grep -q "NEEDED.*$forbidden" <<<"$sdl_needed"; then
|
||||
fail "SDL2 hard-links $forbidden; it must dlopen its backends (--enable-*-shared)"
|
||||
fi
|
||||
done
|
||||
@@ -164,8 +166,9 @@ fi
|
||||
|
||||
openal_lib="$PREFIX/lib/libopenal.so.1"
|
||||
[ -f "$openal_lib" ] || fail "openal-soft build produced no libopenal.so.1"
|
||||
openal_needed="$(objdump -p "$openal_lib")"
|
||||
for forbidden in libsndio libasound libpulse libjack; do
|
||||
if objdump -p "$openal_lib" | grep -q "NEEDED.*$forbidden"; then
|
||||
if grep -q "NEEDED.*$forbidden" <<<"$openal_needed"; then
|
||||
fail "openal hard-links $forbidden; backends must stay behind dlopen"
|
||||
fi
|
||||
done
|
||||
@@ -196,7 +199,8 @@ fi
|
||||
|
||||
theora_lib="$PREFIX/lib/libtheoradec.so.1"
|
||||
[ -f "$theora_lib" ] || fail "libtheora build produced no libtheoradec.so.1"
|
||||
if objdump -p "$theora_lib" | grep -q "NEEDED.*libcairo"; then
|
||||
theora_needed="$(objdump -p "$theora_lib")"
|
||||
if grep -q "NEEDED.*libcairo" <<<"$theora_needed"; then
|
||||
fail "libtheoradec still links libcairo (--disable-examples stopped working)"
|
||||
fi
|
||||
|
||||
@@ -231,16 +235,18 @@ love_bin="$PREFIX/bin/love"
|
||||
love_lib="$PREFIX/lib/liblove-$LOVE_VERSION.so"
|
||||
[ -x "$love_bin" ] || fail "LÖVE build produced no bin/love"
|
||||
[ -f "$love_lib" ] || fail "LÖVE build produced no lib/liblove-$LOVE_VERSION.so"
|
||||
file "$love_bin" | grep -q 'ARM aarch64' \
|
||||
love_file="$(file "$love_bin")"
|
||||
grep -q 'ARM aarch64' <<<"$love_file" \
|
||||
|| fail "built love is not an aarch64 ELF (got: $(file -b "$love_bin"))"
|
||||
|
||||
# A configure run that lost an optional dependency still exits 0 and still
|
||||
# builds -- the loss only shows up as a missing love module at runtime, i.e.
|
||||
# in a shipped artifact. Assert the decoder/font/video libs really linked.
|
||||
love_needed="$(objdump -p "$love_lib")"
|
||||
for soname in libSDL2-2.0.so.0 libopenal.so.1 libfreetype.so.6 \
|
||||
libmodplug.so.1 libmpg123.so.0 libvorbisfile.so.3 \
|
||||
libtheoradec.so.1 libluajit-5.1.so.2; do
|
||||
objdump -p "$love_lib" | grep -q "NEEDED.*$soname" \
|
||||
grep -q "NEEDED.*$soname" <<<"$love_needed" \
|
||||
|| fail "liblove is not linked against $soname (a -dev package went missing)"
|
||||
done
|
||||
|
||||
@@ -405,6 +411,12 @@ if [ -z "\$LUA_CPATH" ]; then
|
||||
fi
|
||||
export LUA_CPATH="\$APPDIR/lib/lua/5.1/?.so;\$LUA_CPATH"
|
||||
|
||||
# SDL2 on Wayland crashes during desktop drag-and-drop in certain compositors;
|
||||
# default to X11/XWayland when available to ensure rock-solid drag-drop stability.
|
||||
if [ -n "\$WAYLAND_DISPLAY" ] && [ -z "\$SDL_VIDEODRIVER" ]; then
|
||||
export SDL_VIDEODRIVER=x11
|
||||
fi
|
||||
|
||||
exec "\$APPDIR/bin/love" --fused "\$APPDIR/game.love" "\$@"
|
||||
EOF
|
||||
chmod +x "$APPDIR/AppRun"
|
||||
|
||||
@@ -51,6 +51,12 @@ rm -f "$OUTPUT"
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
||||
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
||||
if [ -f "$ROOT/PATCH_NOTES.md" ]; then
|
||||
(cd "$ROOT" && zip -q "$OUTPUT" PATCH_NOTES.md)
|
||||
fi
|
||||
if [ -f "$ROOT/mobile/ios/app-repo.json" ]; then
|
||||
(cd "$ROOT" && zip -q "$OUTPUT" mobile/ios/app-repo.json)
|
||||
fi
|
||||
|
||||
if [ -n "$BUILD_INFO" ]; then
|
||||
[ -f "$BUILD_INFO" ] || fail "missing build-info: $BUILD_INFO"
|
||||
|
||||
@@ -35,4 +35,10 @@ find_love() {
|
||||
LOVE_BIN="$(find_love)" \
|
||||
|| fail "LÖVE not found, run scripts/setup.sh (or install from https://love2d.org)"
|
||||
|
||||
# SDL2 on Wayland crashes during desktop drag-and-drop in certain compositors;
|
||||
# default to X11/XWayland when available to ensure rock-solid drag-drop stability.
|
||||
if [ -n "${WAYLAND_DISPLAY:-}" ] && [ -z "${SDL_VIDEODRIVER:-}" ]; then
|
||||
export SDL_VIDEODRIVER=x11
|
||||
fi
|
||||
|
||||
exec "$LOVE_BIN" "$ROOT" "$@"
|
||||
|
||||
@@ -135,6 +135,7 @@ if [ -f data/generated/maps.lua ]; then
|
||||
run_tier "T3 save editor: events + dex" "$LUA" tests/save_editor_task7_tests.lua
|
||||
run_tier "T3 save editor: map browser" "$LUA" tests/save_editor_task8_tests.lua
|
||||
run_tier "T3 save editor: mod awareness" "$LUA" tests/save_editor_mod_tests.lua
|
||||
run_tier "T3 save editor: gold / gen2" "$LUA" tests/save_editor_gen2_tests.lua
|
||||
run_tier "T3 save editor: wheel scrolling" "$LUA" tests/save_editor_wheel_bug595_test.lua
|
||||
run_tier "T3 save editor: pad / NX input" "$LUA" tests/save_editor_pad_input_test.lua
|
||||
run_tier "T5 link (loopback lockstep)" "$LUA" tests/run_link_tests.lua
|
||||
|
||||
+231
-96
@@ -316,6 +316,27 @@ function BattleState.trainerPicPath(data, trainer, oppClass, partyIndex)
|
||||
return base and base.pic or nil
|
||||
end
|
||||
|
||||
-- trueColor on the trainer record, or on the basePic it reuses when the
|
||||
-- subclass does not set the flag itself. Explicit false stays false.
|
||||
function BattleState.trainerTrueColor(data, trainer)
|
||||
if not trainer then return false end
|
||||
if trainer.trueColor ~= nil then
|
||||
return trainer.trueColor and true or false
|
||||
end
|
||||
local base = trainer.basePic and data and data.trainers
|
||||
and data.trainers[trainer.basePic]
|
||||
return (base and base.trueColor) and true or false
|
||||
end
|
||||
|
||||
-- Load a trainer frontpic through getImage so a trueColor portrait skips
|
||||
-- the 4-shade quantize the same way a species pic does.
|
||||
function BattleState.trainerSprite(data, trainer, oppClass, partyIndex)
|
||||
return getImage(
|
||||
BattleState.trainerPicPath(data, trainer, oppClass, partyIndex),
|
||||
BattleState.trainerPalette(data, trainer),
|
||||
BattleState.trainerTrueColor(data, trainer))
|
||||
end
|
||||
|
||||
-- The battle-BGP fade variant of a pic (AnimationFlashScreen and the
|
||||
-- SetAnimationBGPalette effects remap the four BG shades; on the SGB
|
||||
-- the colorizer then colors the REMAPPED shade, so a faded pic shows
|
||||
@@ -501,6 +522,9 @@ local function makeBattler(data, mon, isPlayer, save)
|
||||
badgeBoosts = badgeBoosts,
|
||||
statuses = data.statuses,
|
||||
shownHP = mon.hp, -- the HP the bar displays (UpdateHPBar drain)
|
||||
-- the bar's own length in GetHPBarLength pixels; it trails shownHP
|
||||
-- because UpdateHPBar_AnimateHPBar slides it one pixel at a time
|
||||
shownPx = Timing.hpBarPixels(mon.hp, math.max(1, mon.stats.hp)),
|
||||
-- HUD status label (DrawHUDsAndHPBars); mon.status can land mid-move
|
||||
-- while the tilemap still shows the prior condition until the next
|
||||
-- post-action HUD refresh (core.asm after Execute*Move)
|
||||
@@ -563,10 +587,6 @@ local function markOwned(game, species)
|
||||
local dex = game.save.pokedex
|
||||
if dex then
|
||||
dex.seen[species] = true
|
||||
if not dex.owned[species] then
|
||||
-- new dex page registered (SFX_DEX_PAGE_ADDED)
|
||||
require("src.core.Sound").play(game.data, "Dex_Page_Added")
|
||||
end
|
||||
dex.owned[species] = true
|
||||
end
|
||||
end
|
||||
@@ -613,10 +633,49 @@ local function newBattle(game)
|
||||
self.phase = "intro"
|
||||
self.menuIndex = 1
|
||||
self.moveIndex = 1
|
||||
self.playerMoveListIndex = 1
|
||||
self.frame = 0
|
||||
return self
|
||||
end
|
||||
|
||||
local function scopedPlayerParty(game, indices)
|
||||
if indices == nil then return nil, nil end
|
||||
if type(indices) ~= "table" then
|
||||
Logger.warn("trainer battle party scope is not a table; using full party")
|
||||
return nil, nil
|
||||
end
|
||||
local count = #indices
|
||||
local keyCount = 0
|
||||
for key in pairs(indices) do
|
||||
keyCount = keyCount + 1
|
||||
if type(key) ~= "number" or key % 1 ~= 0 or key < 1 or key > count then
|
||||
Logger.warn("trainer battle party scope is malformed; using full party")
|
||||
return nil, nil
|
||||
end
|
||||
end
|
||||
if count == 0 or keyCount ~= count then
|
||||
Logger.warn("trainer battle party scope is empty or sparse; using full party")
|
||||
return nil, nil
|
||||
end
|
||||
local party, normalized, seen = {}, {}, {}
|
||||
for i = 1, count do
|
||||
local index = indices[i]
|
||||
if type(index) ~= "number" or index % 1 ~= 0
|
||||
or not game.save.party[index] or seen[index] then
|
||||
Logger.warn("trainer battle party scope contains an invalid index; using full party")
|
||||
return nil, nil
|
||||
end
|
||||
seen[index] = true
|
||||
normalized[i] = index
|
||||
party[i] = game.save.party[index]
|
||||
end
|
||||
return party, normalized
|
||||
end
|
||||
|
||||
function BattleState:playerPartyView()
|
||||
return self.playerParty or self.game.save.party
|
||||
end
|
||||
|
||||
-- opts.hooked: rod encounter, announced with _HookedMonAttackedText
|
||||
function BattleState.newWild(game, species, level, opts)
|
||||
local self = newBattle(game)
|
||||
@@ -692,13 +751,15 @@ local function applySpecialMoves(data, oppClass, partyIndex, party)
|
||||
end
|
||||
end
|
||||
|
||||
function BattleState.newTrainer(game, oppClass, partyIndex)
|
||||
function BattleState.newTrainer(game, oppClass, partyIndex, opts)
|
||||
local self = newBattle(game)
|
||||
self.kind = "trainer"
|
||||
self.oppClass = oppClass
|
||||
-- the object_event trainer arg (roster index). computeMusicKind keys
|
||||
-- data/scripts/victories.lua on class#party, so keep it on the battle (#782).
|
||||
self.partyIndex = partyIndex or 1
|
||||
self.playerParty, self.playerPartyIndices = scopedPlayerParty(game,
|
||||
type(opts) == "table" and opts.playerPartyIndices or nil)
|
||||
self.trainer = game.data.trainers[oppClass]
|
||||
assert(self.trainer, "unknown trainer class " .. tostring(oppClass))
|
||||
-- pret GetTrainerName_: RIVAL1/2/3 copy wRivalName into wTrainerName
|
||||
@@ -742,7 +803,7 @@ function BattleState.newTrainer(game, oppClass, partyIndex)
|
||||
end
|
||||
end
|
||||
self.enemyIndex = 1
|
||||
local playerMon = Party.firstHealthy(game.save.party)
|
||||
local playerMon = Party.firstHealthy(self:playerPartyView())
|
||||
if not playerMon then
|
||||
Logger.warn("trainer battle with no healthy party; skipping")
|
||||
self.dead = true
|
||||
@@ -756,9 +817,8 @@ function BattleState.newTrainer(game, oppClass, partyIndex)
|
||||
-- MonsterPalettes[0] = PAL_MEWMON -- InitBattleCommon zeroes
|
||||
-- wEnemyMonSpecies2 before the intro's SET_PAL_BATTLE
|
||||
-- (engine/battle/core.asm:6682, engine/gfx/palettes.asm SetPal_Battle)
|
||||
self.trainerPic = getImage(
|
||||
BattleState.trainerPicPath(game.data, self.trainer, oppClass, partyIndex),
|
||||
BattleState.trainerPalette(game.data, self.trainer))
|
||||
self.trainerPic = BattleState.trainerSprite(
|
||||
game.data, self.trainer, oppClass, partyIndex)
|
||||
self.introText = Strings("%s wants\nto fight!", self.trainer.name)
|
||||
return self
|
||||
end
|
||||
@@ -929,6 +989,22 @@ function BattleState:sayNext(text)
|
||||
table.insert(self.queue, self.nextInsert, { text = text })
|
||||
end
|
||||
|
||||
function BattleState:sayNextWaitSfx(text, sfx)
|
||||
self.nextInsert = (self.nextInsert or 0) + 1
|
||||
table.insert(self.queue, self.nextInsert, { text = text, waitForLearningSfx = sfx })
|
||||
end
|
||||
|
||||
-- WaitForSoundToFinish for a sound an act() has already started: PlayCry
|
||||
-- ends in `jp WaitForSoundToFinish` (home/pokemon.asm), so every cry in
|
||||
-- Gen 1 holds whatever the ROM does next. `src` is the audio source, or a
|
||||
-- getter the row calls at execution time when the source is only known then.
|
||||
function BattleState:waitSfxNext(src)
|
||||
self.nextInsert = (self.nextInsert or 0) + 1
|
||||
table.insert(self.queue, self.nextInsert,
|
||||
{ waitSound = type(src) == "function" and src
|
||||
or function() return src end })
|
||||
end
|
||||
|
||||
-- sayNext for a page that ends in `text_end` (see sayAuto) (#765)
|
||||
function BattleState:sayNextAuto(text, delay)
|
||||
self.nextInsert = (self.nextInsert or 0) + 1
|
||||
@@ -1016,22 +1092,39 @@ function BattleState:stepHPDrain()
|
||||
and b.shownHP >= b.drainFloor then
|
||||
goal = b.drainFloor
|
||||
end
|
||||
local maxHP = math.max(1, b.mon.stats.hp)
|
||||
local playerSide = (b == self.player)
|
||||
local targetPx = Timing.hpBarPixels(b.shownHP, maxHP)
|
||||
if not b.shownPx then b.shownPx = targetPx end
|
||||
if (b.drainHold or 0) > 0 then
|
||||
b.drainHold = b.drainHold - 1
|
||||
busy = true
|
||||
elseif b.shownPx ~= targetPx then
|
||||
-- .barAnimationLoop redraws the bar one pixel at a time, `ld c, 2 /
|
||||
-- call DelayFrames` apiece (:141-148), so a single HP point that
|
||||
-- spans several pixels still slides instead of jumping
|
||||
b.shownPx = b.shownPx + ((b.shownPx > targetPx) and -1 or 1)
|
||||
b.drainHold = Timing.HP_BAR_PIXEL_STEP - 1
|
||||
busy = true
|
||||
elseif b.shownHP ~= goal then
|
||||
local maxHP = math.max(1, b.mon.stats.hp)
|
||||
local playerSide = (b == self.player)
|
||||
local cost = 0
|
||||
local spent = 0
|
||||
-- consume whole HP steps until this frame's budget is spent; on the
|
||||
-- enemy HUD several free steps can land in the same frame
|
||||
while b.shownHP ~= goal and cost < 1 do
|
||||
local nextHP = b.shownHP + ((b.shownHP > goal) and -1 or 1)
|
||||
cost = cost + Timing.hpDrainStepFrames(b.shownHP, nextHP,
|
||||
maxHP, playerSide)
|
||||
b.shownHP = nextHP
|
||||
repeat
|
||||
b.shownHP = b.shownHP + ((b.shownHP > goal) and -1 or 1)
|
||||
spent = spent + (playerSide and Timing.HP_BAR_HP_STEP or 0)
|
||||
targetPx = Timing.hpBarPixels(b.shownHP, maxHP)
|
||||
until b.shownHP == goal or targetPx ~= b.shownPx or spent >= 1
|
||||
if spent > 0 then
|
||||
b.drainHold = spent - 1
|
||||
elseif targetPx ~= b.shownPx then
|
||||
-- the enemy HUD printed no number, so this frame is already the
|
||||
-- first of the pixel step the crossing just asked for
|
||||
b.shownPx = b.shownPx + ((b.shownPx > targetPx) and -1 or 1)
|
||||
b.drainHold = Timing.HP_BAR_PIXEL_STEP - 1
|
||||
else
|
||||
b.drainHold = 0
|
||||
end
|
||||
b.drainHold = math.max(0, cost - 1)
|
||||
b.draining = true
|
||||
busy = true
|
||||
elseif b.draining then
|
||||
@@ -1255,13 +1348,13 @@ function BattleState:updateQueue()
|
||||
-- no subanimation player: keep the single-sound fallback (with
|
||||
-- the move's pitch/tempo modifiers; GROWL/ROAR play the
|
||||
-- attacker's cry -- GetMoveSound/IsCryMove)
|
||||
if item.anim == "GROWL" or item.anim == "ROAR" then
|
||||
if self:animationsOn() and (item.anim == "GROWL" or item.anim == "ROAR") then
|
||||
local attacker = item.attackerIsPlayer and self.player or self.enemy
|
||||
if attacker then
|
||||
require("src.core.Sound").playMoveCry(self.data, attacker.mon.species,
|
||||
anim and anim.tempo)
|
||||
end
|
||||
elseif anim and anim.sound then
|
||||
elseif self:animationsOn() and anim and anim.sound then
|
||||
local Sound = require("src.core.Sound")
|
||||
if Sound.playMove then
|
||||
Sound.playMove(self.data, anim)
|
||||
@@ -1361,6 +1454,11 @@ function BattleState:updateQueue()
|
||||
self.current = nil
|
||||
end
|
||||
elseif not (item and item.choice) then
|
||||
if item and item.waitForLearningSfx and not item.soundStarted then
|
||||
item.soundStarted = true
|
||||
self.waitingSound = item.waitForLearningSfx()
|
||||
return true
|
||||
end
|
||||
-- The page is typed out and waiting on the player: PromptText
|
||||
-- (home/text.asm:209-217) writes '▼' at (18,16) and ManualTextScroll
|
||||
-- blinks it until A/B, so the arrow belongs on a finished page and not
|
||||
@@ -1420,7 +1518,7 @@ end
|
||||
function BattleState:playEntranceCry(battler)
|
||||
local mon = battler and battler.mon
|
||||
if not mon then return end
|
||||
require("src.core.Sound").playCry(self.data, mon.species,
|
||||
return require("src.core.Sound").playCry(self.data, mon.species,
|
||||
mon.status == "SLP" and 37 or 11)
|
||||
end
|
||||
|
||||
@@ -1544,7 +1642,23 @@ function BattleState:enter()
|
||||
-- default stays opaque for every other battle and for older saves.
|
||||
self.isOpaque = self:bgMode() ~= "world"
|
||||
self.introSlide = Timing.BATTLE_SLIDE_IN_FRAMES
|
||||
self.showEnemyTrainer = self.kind == "trainer" and self.trainerPic ~= nil
|
||||
-- GetTrainerInformation .linkBattle (home/trainers2.asm:26-31): the link
|
||||
-- foe's pic is RedPicFront whatever either save looks like, and
|
||||
-- InitBattleCommon loads and tilemaps it at hlcoord 12,0 for every
|
||||
-- wIsInBattle == 2 battle (core.asm:6681-6688), the link one included.
|
||||
-- wEnemyMonSpecies2 is zeroed under it, so the pic palette is PAL_MEWMON
|
||||
-- exactly as it is for a trainer.
|
||||
if self.kind == "link" and not self.trainerPic then
|
||||
local frontPath, frontTrueColor =
|
||||
require("src.pokemon.Sprites").playerPath(self.data, "front",
|
||||
{ kind = "battle", battle = self })
|
||||
if frontPath and require("src.render.Assets").exists(frontPath) then
|
||||
self.trainerPic = getImage(frontPath, namedPalette(self.data, "MEWMON"),
|
||||
frontTrueColor)
|
||||
end
|
||||
end
|
||||
self.showEnemyTrainer = (self.kind == "trainer" or self.kind == "link")
|
||||
and self.trainerPic ~= nil
|
||||
-- DrawAllPokeballs (common_text.asm:27) puts the party ball rows AND the
|
||||
-- HUD corner/underline tiles under them (PlacePlayerHUDTiles /
|
||||
-- PlaceEnemyHUDTiles, draw_hud_pokeball_gfx.asm:119-165) on screen with
|
||||
@@ -1576,13 +1690,13 @@ function BattleState:enter()
|
||||
-- a different point in each battle kind, so queue it per branch
|
||||
local function queueEnemyCry()
|
||||
self:act(function()
|
||||
self:playEntranceCry(self.enemy)
|
||||
self:waitSfxNext(self:playEntranceCry(self.enemy))
|
||||
end)
|
||||
end
|
||||
-- PrintBeginningBattleText (engine/battle/common_text.asm:10-19): a wild
|
||||
-- battle calls PlayCry BEFORE PrintText WildMonAppearedText, so the cry
|
||||
-- sounds with the "Wild X appeared!" box instead of waiting on the A
|
||||
-- press that clears its `prompt` (#303). The Silph-Scope-less tower
|
||||
-- battle calls PlayCry BEFORE PrintText WildMonAppearedText, and PlayCry
|
||||
-- ends in WaitForSoundToFinish, so the cry runs to its end and only then
|
||||
-- does the "Wild X appeared!" box open (#303). The Silph-Scope-less tower
|
||||
-- ghost gets no cry at all (common_text.asm:43-48), and neither does the
|
||||
-- unveiled MAROWAK: .isMarowak never reaches PlayCry (#492).
|
||||
if self.kind ~= "trainer" and self.kind ~= "link"
|
||||
@@ -1600,7 +1714,10 @@ function BattleState:enter()
|
||||
-- The sfx is extracted as "Trainer_Appeared" (tools/rom_manifest.json
|
||||
-- sfxHeaders, bank 8 / $42bb -- the same header pokered names
|
||||
-- SFX_Silph_Scope); nothing had ever played it.
|
||||
if self.kind == "trainer" then
|
||||
--
|
||||
-- A link battle is wIsInBattle == 2, so PrintBeginningBattleText takes the
|
||||
-- same .trainerBattle arm and owes the sfx too (common_text.asm:20-23).
|
||||
if self.kind == "trainer" or self.kind == "link" then
|
||||
self:act(function()
|
||||
self.introSfx = require("src.core.Sound").play(self.data,
|
||||
"Trainer_Appeared")
|
||||
@@ -1618,13 +1735,17 @@ function BattleState:enter()
|
||||
-- battle -- not on a switch, and not when the beaten trainer's pic
|
||||
-- scrolls back in (#317, #282)
|
||||
self:act(function() self.introBalls = nil end)
|
||||
if self.kind == "trainer" then
|
||||
-- EnemySendOutFirstMon (core.asm:1308-1310): SlideTrainerPicOffScreen
|
||||
-- walks the foe's pic off the RIGHT edge (hlcoord 18,0, a = 8 tiles,
|
||||
-- one tile every 2 frames) BEFORE TrainerSentOutText -- the pic does
|
||||
-- not blink out under the text (#317)
|
||||
self:act(function() self:slidePic("foe", 0, 64, 4) end)
|
||||
table.insert(self.queue, { wait = 16 })
|
||||
if self.kind == "trainer" or self.kind == "link" then
|
||||
local foeName = self.trainer and self.trainer.name
|
||||
or self.opponentName or Strings("FOE")
|
||||
if self.showEnemyTrainer then
|
||||
-- EnemySendOutFirstMon (core.asm:1308-1310): SlideTrainerPicOffScreen
|
||||
-- walks the foe's pic off the RIGHT edge (hlcoord 18,0, a = 8 tiles,
|
||||
-- one tile every 2 frames) BEFORE TrainerSentOutText -- the pic does
|
||||
-- not blink out under the text (#317)
|
||||
self:act(function() self:slidePic("foe", 0, 64, 4) end)
|
||||
table.insert(self.queue, { wait = 16 })
|
||||
end
|
||||
self:act(function()
|
||||
self.showEnemyTrainer = false
|
||||
-- the slot is EMPTY from here until AnimateSendingOutMon runs below:
|
||||
@@ -1637,7 +1758,7 @@ function BattleState:enter()
|
||||
self.enemySendingOut = true
|
||||
self:slidePic("foe")
|
||||
end)
|
||||
self:say(Strings("%s sent\nout %s!", self.trainer.name, self.enemy.name))
|
||||
self:say(Strings("%s sent\nout %s!", foeName, self.enemy.name))
|
||||
self:act(function()
|
||||
-- EnemySendOutFirstMon (core.asm:1421-1434): after the text the
|
||||
-- pic grows out of the ball (AnimateSendingOutMon), then the cry
|
||||
@@ -1645,18 +1766,6 @@ function BattleState:enter()
|
||||
self:startGrowIn(self.enemy)
|
||||
end)
|
||||
queueEnemyCry()
|
||||
elseif self.kind == "link" then
|
||||
-- Colosseum has no foe trainer pic, but the enemy mon still grows
|
||||
-- out of the ball after "X sent out Y!" (not the wild "already there"
|
||||
-- intro that LinkBattle previously inherited from newWild).
|
||||
self.enemySendingOut = true
|
||||
self:say(Strings("%s sent\nout %s!", self.opponentName or Strings("FOE"),
|
||||
self.enemy.name))
|
||||
self:act(function()
|
||||
self.enemySendingOut = false
|
||||
self:startGrowIn(self.enemy)
|
||||
end)
|
||||
queueEnemyCry()
|
||||
end
|
||||
-- StartBattle .foundFirstAliveEnemyMon (core.asm:152-156): the `call nz`
|
||||
-- gates only EnemySendOutFirstMon -- the `ld c, 40 / call DelayFrames`
|
||||
@@ -1687,7 +1796,7 @@ function BattleState:enter()
|
||||
-- SendOutMon (core.asm:1757-1762): after the poof the mon grows
|
||||
-- out of the ball (AnimateSendingOutMon at hlcoord 4,11)
|
||||
self:startGrowIn(self.player)
|
||||
self:playEntranceCry(self.player)
|
||||
self:waitSfxNext(self:playEntranceCry(self.player))
|
||||
end)
|
||||
self:markParticipant()
|
||||
end
|
||||
@@ -1746,6 +1855,7 @@ end
|
||||
local function sendOutMonCursors(self)
|
||||
self.menuIndex = 1
|
||||
self.moveIndex = 1
|
||||
self.playerMoveListIndex = 1
|
||||
end
|
||||
|
||||
-- core.asm:297-300: both sides' FLINCHED bits are cleared as a turn's move
|
||||
@@ -1864,7 +1974,10 @@ function BattleState:update(dt)
|
||||
if self.phase == "menu" then
|
||||
for _, b in ipairs({ self.player, self.enemy }) do
|
||||
if b then
|
||||
if b.shownHP then b.shownHP = b.mon.hp end
|
||||
if b.shownHP then
|
||||
b.shownHP = b.mon.hp
|
||||
b.shownPx = Timing.hpBarPixels(b.mon.hp, math.max(1, b.mon.stats.hp))
|
||||
end
|
||||
b.drainFloor = nil
|
||||
b.shownStatus = b.mon.status
|
||||
end
|
||||
@@ -1949,7 +2062,7 @@ function BattleState:update(dt)
|
||||
-- loops the party menu until a healthy mon is picked, so B and
|
||||
-- fainted picks land back here and reopen it
|
||||
if self.player.mon.hp <= 0 then
|
||||
if Party.firstHealthy(self.game.save.party) then
|
||||
if Party.firstHealthy(self:playerPartyView()) then
|
||||
self:openReplacementMenu()
|
||||
end
|
||||
return
|
||||
@@ -2044,8 +2157,10 @@ function BattleState:update(dt)
|
||||
if self.moveSwapIndex then
|
||||
self:swapMoves(self.moveSwapIndex, self.moveIndex)
|
||||
self.moveSwapIndex = nil
|
||||
self.moveIndex = math.min(self.playerMoveListIndex or 1, #moves)
|
||||
else
|
||||
self.moveSwapIndex = self.moveIndex
|
||||
self.moveIndex = math.min(self.playerMoveListIndex or 1, #moves)
|
||||
end
|
||||
elseif input:wasPressed("b") then
|
||||
require("src.core.Sound").play(self.data, "Press_AB")
|
||||
@@ -2068,6 +2183,7 @@ function BattleState:update(dt)
|
||||
self.phase = "messages"
|
||||
self.afterQueue = "menu"
|
||||
else
|
||||
self.playerMoveListIndex = self.moveIndex
|
||||
self:resolveTurn(mv)
|
||||
end
|
||||
end
|
||||
@@ -2279,10 +2395,10 @@ function BattleState:oldManThrow()
|
||||
return
|
||||
end
|
||||
self:ballChain("TOSS_ANIM", true, 3, "POKE_BALL")
|
||||
self:actNext(function()
|
||||
require("src.core.Sound").play(self.data, "Caught_Mon")
|
||||
end)
|
||||
self:sayNext(Strings("All right!\n%s was\ncaught!", self.enemy.name))
|
||||
-- ItemUseBallText05: text_far, sound_caught_mon, text_promptbutton --
|
||||
-- the fanfare follows the caught text and holds the prompt
|
||||
self:sayNextWaitSfx(Strings("All right!\n%s was\ncaught!", self.enemy.name),
|
||||
function() return require("src.core.Sound").play(self.data, "Caught_Mon") end)
|
||||
end)
|
||||
end
|
||||
|
||||
@@ -2423,11 +2539,9 @@ function BattleState:resolveTurn(playerAction)
|
||||
end
|
||||
local order
|
||||
if pFirst then
|
||||
order = { { self.player, self.enemy, playerAction },
|
||||
{ self.enemy, self.player, enemyAction } }
|
||||
order = { { true, playerAction }, { false, enemyAction } }
|
||||
else
|
||||
order = { { self.enemy, self.player, enemyAction },
|
||||
{ self.player, self.enemy, playerAction } }
|
||||
order = { { false, enemyAction }, { true, playerAction } }
|
||||
end
|
||||
|
||||
self.phase = "messages"
|
||||
@@ -2435,7 +2549,9 @@ function BattleState:resolveTurn(playerAction)
|
||||
|
||||
for _, entry in ipairs(order) do
|
||||
self:act(function()
|
||||
self:executeAction(entry[1], entry[2], entry[3])
|
||||
local user = entry[1] and self.player or self.enemy
|
||||
local target = entry[1] and self.enemy or self.player
|
||||
self:executeAction(user, target, entry[2])
|
||||
end)
|
||||
end
|
||||
self:act(function() self:endOfTurn() end)
|
||||
@@ -2466,7 +2582,7 @@ function BattleState:resolveSwitch(newMon)
|
||||
self.sendingOut = false
|
||||
-- SendOutMon (core.asm:1757-1762): poof, then the grow-in
|
||||
self:startGrowIn(self.player)
|
||||
self:playEntranceCry(self.player)
|
||||
self:waitSfxNext(self:playEntranceCry(self.player))
|
||||
end)
|
||||
end)
|
||||
self:act(function()
|
||||
@@ -2945,7 +3061,7 @@ function BattleState:applyHitFx(hit)
|
||||
Sound.play(self.data, hit.sfx)
|
||||
end
|
||||
end
|
||||
if not t or not self:animationsOn() then return end
|
||||
if not t then return end
|
||||
if t == 1 then
|
||||
-- PredefShakeScreenVertically b=8: the window drops by b for 3 frames
|
||||
-- then home for 3, b counting down
|
||||
@@ -3778,7 +3894,7 @@ function BattleState:onFaint(battler)
|
||||
if battler.isPlayer then
|
||||
-- RemoveFaintedPlayerMon (core.asm:1040-1042): the player mon's
|
||||
-- faint plays its ordinary species cry -- no Faint_Fall
|
||||
Sound.playCry(self.data, battler.mon.species)
|
||||
self.faintCry = Sound.playCry(self.data, battler.mon.species)
|
||||
elseif self.kind ~= "wild" then
|
||||
-- FaintEnemyPokemon (core.asm:732-771): the enemy faint plays no
|
||||
-- species cry; trainer battles get SFX_FAINT_FALL, then SFX_FAINT_THUD
|
||||
@@ -3793,6 +3909,11 @@ function BattleState:onFaint(battler)
|
||||
end)
|
||||
self.nextInsert = (self.nextInsert or 0) + 1
|
||||
table.insert(self.queue, self.nextInsert, { wait = Timing.FAINT_SLIDE })
|
||||
if battler.isPlayer then
|
||||
-- RemoveFaintedPlayerMon ends `call PlayCry / jp PrintText`, and PlayCry
|
||||
-- is `jp WaitForSoundToFinish`, so "X fainted!" waits out the cry
|
||||
self:waitSfxNext(function() return self.faintCry end)
|
||||
end
|
||||
if not battler.isPlayer and self.kind ~= "wild" then
|
||||
-- FaintEnemyPokemon's SFX_FAINT_THUD lands as the slide does (after
|
||||
-- Faint_Fall, before EnemyMonFaintedText)
|
||||
@@ -3829,7 +3950,8 @@ function BattleState:awardExp()
|
||||
-- (RemoveFaintedPlayerMon), so it drops out of the divisor and only
|
||||
-- the surviving participants are counted and paid
|
||||
local participants, alive = 0, {}
|
||||
for _, mon in ipairs(self.game.save.party) do
|
||||
local playerParty = self:playerPartyView()
|
||||
for _, mon in ipairs(playerParty) do
|
||||
if self.participants and self.participants[mon] then
|
||||
participants = participants + 1
|
||||
if mon.hp > 0 then table.insert(alive, mon) end
|
||||
@@ -3839,9 +3961,12 @@ function BattleState:awardExp()
|
||||
participants, alive = 1, { self.player.mon }
|
||||
end
|
||||
local function applyShare(mon, split, announce)
|
||||
local playerId = self.game.save.player and self.game.save.player.id
|
||||
local traded = mon.otId ~= nil and playerId ~= nil
|
||||
and mon.otId ~= playerId or mon.traded == true and mon.otId == nil
|
||||
local levels, gained = Experience.apply(self.data, mon, self.enemy.def,
|
||||
self.enemy.mon.level, self.kind == "trainer",
|
||||
split, mon.traded)
|
||||
split, traded)
|
||||
-- Track level-ups for EvolveAfterBattle (OverworldState:afterBattle ->
|
||||
-- Evolution.checkParty). B-cancel leaves the mon at/above threshold;
|
||||
-- without this gate it re-triggers after every later fight (#213).
|
||||
@@ -3865,7 +3990,7 @@ function BattleState:awardExp()
|
||||
local text = Strings.source("%s gained\n%d EXP. Points!")
|
||||
if announce == "expAll" then
|
||||
text = Strings.source("%s gained\nwith EXP.ALL,\v%d EXP. Points!")
|
||||
elseif mon.traded then
|
||||
elseif traded then
|
||||
text = Strings.source("%s gained\na boosted\v%d EXP. Points!")
|
||||
end
|
||||
self:sayNext(Strings(text, name, gained))
|
||||
@@ -3877,9 +4002,11 @@ function BattleState:awardExp()
|
||||
-- experience.asm:248 fires per grew-level text
|
||||
require("src.world.PikachuFollower")
|
||||
.modifyHappiness(game.save, "LEVELUP", mon)
|
||||
self:sayNext(Strings("%s grew\nto level %d!", name, lv))
|
||||
-- GrewLevelText: text_far, sound_level_up, text_end (experience.asm:
|
||||
-- 369-372); PrintStatsBox only runs once PrintText has returned
|
||||
self:sayNextWaitSfx(Strings("%s grew\nto level %d!", name, lv),
|
||||
function() return require("src.core.Sound").play(game.data, "Level_Up") end)
|
||||
self:uiNext(function()
|
||||
require("src.core.Sound").play(game.data, "Level_Up")
|
||||
return StatBox.new(game, mon)
|
||||
end)
|
||||
-- After PrintStatsBox, experience.asm reloads the active battler's
|
||||
@@ -3918,9 +4045,9 @@ function BattleState:awardExp()
|
||||
-- experience.asm:9-13); each mon gets its own GainedText with the
|
||||
-- "with EXP.ALL," tail (wBoostExpByExpAll) -- pokered prints no
|
||||
-- summary line
|
||||
for _, mon in ipairs(self.game.save.party) do
|
||||
for _, mon in ipairs(playerParty) do
|
||||
if mon.hp > 0 then
|
||||
ctx.applyShare(mon, math.max(1, ctx.participants) * #self.game.save.party * 2, "expAll")
|
||||
ctx.applyShare(mon, math.max(1, ctx.participants) * #playerParty * 2, "expAll")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -3961,7 +4088,7 @@ function BattleState:enemyMonFainted()
|
||||
local nextName = nextMon.nickname or self.data.pokemon[nextMon.species].name
|
||||
local style = tostring((self.game.save.options or {}).battleStyle or "shift")
|
||||
:lower()
|
||||
local partyCount = #self.game.save.party
|
||||
local partyCount = #self:playerPartyView()
|
||||
-- ReplaceFaintedEnemyMon (core.asm:892-896): DrawEnemyPokeballs puts the
|
||||
-- foe's party ball row -- and the HUD chrome PlaceEnemyHUDTiles lays
|
||||
-- down under it (draw_hud_pokeball_gfx.asm:9-11, 33-45, 134-141) -- into
|
||||
@@ -3988,6 +4115,7 @@ function BattleState:enemyMonFainted()
|
||||
local game = self.game
|
||||
Screens.push(game, "PartyMenu", {
|
||||
battle = self,
|
||||
party = self:playerPartyView(),
|
||||
forceSwitch = true,
|
||||
onSwitch = function(mon)
|
||||
if mon ~= self.player.mon and mon.hp > 0 then
|
||||
@@ -4024,7 +4152,7 @@ function BattleState:enemyMonFainted()
|
||||
self.enemySendingOut = false
|
||||
self:startGrowIn(self.enemy)
|
||||
self:actNext(function()
|
||||
self:playEntranceCry(self.enemy)
|
||||
self:waitSfxNext(self:playEntranceCry(self.enemy))
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
@@ -4063,7 +4191,7 @@ function BattleState:enemyMonFainted()
|
||||
self:actNext(function()
|
||||
self.sendingOut = false
|
||||
self:startGrowIn(self.player)
|
||||
self:playEntranceCry(self.player)
|
||||
self:waitSfxNext(self:playEntranceCry(self.player))
|
||||
end)
|
||||
end)
|
||||
return
|
||||
@@ -4129,15 +4257,17 @@ function BattleState:learnMove(mon, moveId)
|
||||
if #mon.moves < 4 then
|
||||
table.insert(mon.moves, { id = moveId, pp = mdef.pp })
|
||||
Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId })
|
||||
self:sayNext(self:romText("_MimicLearnedMoveText", "%s learned\n%s!", mon.nickname or self.data.pokemon[mon.species].name,
|
||||
mdef.name))
|
||||
self:sayNextWaitSfx(self:romText("_MimicLearnedMoveText", "%s learned\n%s!", mon.nickname or self.data.pokemon[mon.species].name,
|
||||
mdef.name), function()
|
||||
return require("src.core.Sound").play(self.data, "Level_Up")
|
||||
end)
|
||||
return
|
||||
end
|
||||
-- the "trying to learn" preamble lives inside MoveLearnMenu:enter;
|
||||
-- ordered insert so multi-level gains keep each level's checks
|
||||
-- between its own stat box and the next "grew to level" text
|
||||
self:uiNext(function()
|
||||
return self:buildScreen("MoveLearnMenu", mon, moveId)
|
||||
return self:buildScreen("MoveLearnMenu", mon, moveId, nil, "Level_Up")
|
||||
end)
|
||||
end
|
||||
|
||||
@@ -4158,7 +4288,7 @@ function BattleState.isOaksLabStarterRival(self)
|
||||
end
|
||||
|
||||
function BattleState:playerMonFainted()
|
||||
local nextMon = Party.firstHealthy(self.game.save.party)
|
||||
local nextMon = Party.firstHealthy(self:playerPartyView())
|
||||
-- Being out of useable POKéMON blacks you out even when the battle was
|
||||
-- already decided in our favour. A double faint -- our last mon dying
|
||||
-- to residual damage on the turn it lands the KO -- used to hit the
|
||||
@@ -4231,6 +4361,7 @@ function BattleState:openReplacementMenu()
|
||||
self:ui(function()
|
||||
return self:buildScreen("PartyMenu", {
|
||||
battle = self,
|
||||
party = self:playerPartyView(),
|
||||
-- ChooseNextMon: pick immediately (no SWITCH/STATS/CANCEL)
|
||||
forceSwitch = true,
|
||||
onSwitch = function(mon)
|
||||
@@ -4257,7 +4388,7 @@ function BattleState:openReplacementMenu()
|
||||
self.sendingOut = false
|
||||
-- SendOutMon (core.asm:1757-1762): poof, then the grow-in
|
||||
self:startGrowIn(self.player)
|
||||
self:playEntranceCry(self.player)
|
||||
self:waitSfxNext(self:playEntranceCry(self.player))
|
||||
end)
|
||||
end,
|
||||
})
|
||||
@@ -4301,11 +4432,10 @@ function BattleState:safariAction(choice)
|
||||
-- above DoBallTossSpecialEffects's <= ULTRA_BALL check)
|
||||
self:ballChain(self:tossAnimFor("SAFARI_BALL"), caught, shakes, "SAFARI_BALL")
|
||||
if caught then
|
||||
-- ItemUseBallText05's sound_caught_mon: fanfare with the text
|
||||
self:actNext(function()
|
||||
require("src.core.Sound").play(self.data, "Caught_Mon")
|
||||
end)
|
||||
self:sayNext(Strings("All right!\n%s was\ncaught!", self.enemy.name))
|
||||
-- ItemUseBallText05: text_far, sound_caught_mon, text_promptbutton --
|
||||
-- the fanfare follows the caught text and holds the prompt
|
||||
self:sayNextWaitSfx(Strings("All right!\n%s was\ncaught!", self.enemy.name),
|
||||
function() return require("src.core.Sound").play(self.data, "Caught_Mon") end)
|
||||
-- same ItemUseBall .captured flow as a regular ball
|
||||
self:act(function() self:storeCaughtMon() end)
|
||||
else
|
||||
@@ -4533,8 +4663,12 @@ function BattleState:storeCaughtMon()
|
||||
markOwned(game, species)
|
||||
stampOT(game.save, self.enemy.mon)
|
||||
if isNew then
|
||||
-- _ItemUseBallText06 + ShowPokedexData
|
||||
self:sayNext(Strings("New POKéDEX data\nwill be added for\n%s!", self.enemy.name))
|
||||
-- _ItemUseBallText06 + ShowPokedexData: text_far, sound_dex_page_added,
|
||||
-- text_promptbutton (item_effects.asm:624-629), so the fanfare follows
|
||||
-- the box rather than firing when the dex bit is set
|
||||
self:sayNextWaitSfx(
|
||||
Strings("New POKéDEX data\nwill be added for\n%s!", self.enemy.name),
|
||||
function() return require("src.core.Sound").play(self.data, "Dex_Page_Added") end)
|
||||
self:uiNext(function()
|
||||
return self:buildScreen("DexEntryMenu", species)
|
||||
end)
|
||||
@@ -4684,12 +4818,10 @@ function BattleState:throwBall(ball)
|
||||
self:ballChain(self:tossAnimFor(ball), caught, shakes, ball)
|
||||
if caught then
|
||||
-- ItemUseBallText05 carries sound_caught_mon (item_effects.asm:
|
||||
-- 608-614): the fanfare sounds with the caught message, before
|
||||
-- the prompt, not after the text is dismissed
|
||||
self:actNext(function()
|
||||
require("src.core.Sound").play(self.data, "Caught_Mon")
|
||||
end)
|
||||
self:sayNext(Strings("All right!\n%s was\ncaught!", self.enemy.name))
|
||||
-- 608-614): text_far, sound_caught_mon, text_promptbutton -- the
|
||||
-- fanfare follows the caught message and holds the prompt
|
||||
self:sayNextWaitSfx(Strings("All right!\n%s was\ncaught!", self.enemy.name),
|
||||
function() return require("src.core.Sound").play(self.data, "Caught_Mon") end)
|
||||
self:act(function() self:storeCaughtMon() end)
|
||||
else
|
||||
self:sayNext(self:ballMissMessage(shakes))
|
||||
@@ -4710,6 +4842,7 @@ function BattleState:openParty()
|
||||
self:ui(function()
|
||||
return self:buildScreen("PartyMenu", {
|
||||
battle = self,
|
||||
party = self:playerPartyView(),
|
||||
onSwitch = function(mon)
|
||||
if mon == self.player.mon then
|
||||
self:say(Strings("%s is\nalready out!", self.player.name))
|
||||
@@ -4756,8 +4889,8 @@ function BattleState:finish()
|
||||
-- here it did not, so say so rather than silently papering over it.
|
||||
-- The old-man / PROF.OAK demo also skips it: the party never fought
|
||||
-- (Yellow's Pallet intro runs before the player owns a mon at all).
|
||||
if self.result ~= "lose" and not self.demo
|
||||
and not Party.firstHealthy(self.game.save.party) then
|
||||
if self.kind ~= "link" and self.result ~= "lose" and not self.demo
|
||||
and not Party.firstHealthy(self:playerPartyView()) then
|
||||
Logger.warn("battle finished %s with no healthy party; forcing blackout",
|
||||
tostring(self.result))
|
||||
self.result = "lose"
|
||||
@@ -5176,7 +5309,8 @@ function BattleState:sgbBattlePals()
|
||||
local function bar(b)
|
||||
if not b then return pals.GREENBAR end
|
||||
local hp = b.shownHP or b.mon.hp
|
||||
return pals[PaletteFX.barPalName(hp, b.mon.stats.hp)] or pals.GREENBAR
|
||||
return pals[PaletteFX.barPalName(hp, b.mon.stats.hp, b.shownPx)]
|
||||
or pals.GREENBAR
|
||||
end
|
||||
local function mon(b, placeholder)
|
||||
if placeholder or not b then return pals.MEWMON or pals.GREENBAR end
|
||||
@@ -5574,7 +5708,7 @@ function BattleState:drawHUDs(slide)
|
||||
hudTile(0x73, 8, 16)
|
||||
drawHPBar(barData, 2, 2,
|
||||
{ hp = shownHP(self.enemy), stats = self.enemy.mon.stats },
|
||||
nil, grayFill)
|
||||
nil, grayFill, nil, self.enemy.shownPx)
|
||||
hudTile(0x74, 8, 24)
|
||||
for i = 2, 9 do hudTile(0x76, i * 8, 24) end
|
||||
hudTile(0x78, 80, 24)
|
||||
@@ -5637,7 +5771,7 @@ function BattleState:drawHUDs(slide)
|
||||
for i = 10, 17 do hudTile(0x76, i * 8, 88) end
|
||||
hudTile(0x6F, 72, 88)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
self:drawBallRow(self.playerParty or self.game.save.party, 88, 80, 8)
|
||||
self:drawBallRow(self:playerPartyView(), 88, 80, 8)
|
||||
end
|
||||
local hidePlayer = self.safari or self.demo
|
||||
if showStatus and self.player and not hidePlayer and not self.showPlayerBack
|
||||
@@ -5655,7 +5789,7 @@ function BattleState:drawHUDs(slide)
|
||||
end
|
||||
drawHPBar(barData, 10, 9,
|
||||
{ hp = shownHP(self.player), stats = self.player.mon.stats },
|
||||
1, grayFill) -- wHPBarType 1: the $6D cap
|
||||
1, grayFill, nil, self.player.shownPx) -- wHPBarType 1: the $6D cap
|
||||
Font.draw(("%3d/%3d"):format(shownHP(self.player), self.player.mon.stats.hp), 88, 80)
|
||||
hudTile(0x73, 144, 80)
|
||||
hudTile(0x77, 144, 88)
|
||||
@@ -5791,6 +5925,7 @@ function BattleState:drawTextArea()
|
||||
Font.draw(self.data.moves[m.id].name, 16, (7 + i) * 8)
|
||||
end
|
||||
Font.drawCode(0xED, 8, (7 + self.mimicIndex) * 8)
|
||||
Font.draw(Strings("WHICH TECHNIQUE?"), 8, 112)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -310,8 +310,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
if record and record.afterDamage then
|
||||
record.afterDamage(ctx, totalDealt)
|
||||
elseif moveInst.struggle then
|
||||
-- struggle recoils even when its effect id resolves to no record
|
||||
local recoil = math.max(1, math.floor(dmg / 2))
|
||||
local recoil = math.max(1, math.floor(totalDealt / 2))
|
||||
battle:sayNext(romText(battle.data, "_HitWithRecoilText", "%s's\nhit with recoil!", displayName(user)))
|
||||
battle:applyDamage(user, recoil)
|
||||
end
|
||||
|
||||
@@ -348,8 +348,9 @@ MoveEffects.primary = {
|
||||
target.disabledTurns = battle.rng(1, 8)
|
||||
local id = target.curMoves[slot].id
|
||||
-- _MoveWasDisabledText: "X's / MOVE was / disabled!"
|
||||
return { romText(battle.data, "_MoveWasDisabledText", "%s's\n%s was\ndisabled!", displayName(target),
|
||||
battle.data.moves[id].name) }
|
||||
return { romText(battle.data, "_MoveWasDisabledText", "%s's\n%s was\ndisabled!",
|
||||
{ TARGET = displayName(target),
|
||||
["RAM:wNameBuffer"] = battle.data.moves[id].name }) }
|
||||
end,
|
||||
|
||||
SPLASH_EFFECT = function(battle)
|
||||
@@ -441,11 +442,10 @@ local function hitsFrom(dist, ctx)
|
||||
return dist[r + 1]
|
||||
end
|
||||
|
||||
-- drain_hp.asm halves the RAW wDamage IN PLACE (minimum 1) and heals
|
||||
-- that amount, so Counter would see the halved value
|
||||
-- engine/battle/core.asm ApplyDamageToEnemyPokemon
|
||||
local function drainHalf(label, text)
|
||||
return function(ctx)
|
||||
local heal = math.max(1, math.floor(ctx.rawDamage / 2))
|
||||
local heal = math.max(1, math.floor(ctx.totalDealt / 2))
|
||||
ctx.battle.lastDamage = heal
|
||||
local mon = ctx.user.mon
|
||||
mon.hp = math.min(mon.stats.hp, mon.hp + heal)
|
||||
@@ -529,9 +529,8 @@ MoveEffects.full = {
|
||||
|
||||
RECOIL_EFFECT = {
|
||||
afterDamage = function(ctx)
|
||||
-- recoil.asm reads the RAW computed wDamage (not the HP actually
|
||||
-- removed): overkill and substitute hits recoil at full strength
|
||||
local recoil = math.max(1, math.floor(ctx.rawDamage
|
||||
-- engine/battle/move_effects/recoil.asm
|
||||
local recoil = math.max(1, math.floor(ctx.totalDealt
|
||||
/ (ctx.moveInst.struggle and 2 or 4)))
|
||||
ctx.say(romText(ctx.battle.data, "_HitWithRecoilText", "%s's\nhit with recoil!", displayName(ctx.user)))
|
||||
ctx.battle:applyDamage(ctx.user, recoil)
|
||||
|
||||
@@ -243,7 +243,9 @@ function Status.beforeMove(battler, rng, battle, selectedMoveId)
|
||||
local shown = moves and moves[selectedMoveId] and moves[selectedMoveId].name
|
||||
or tostring(selectedMoveId)
|
||||
table.insert(msgs, romText(battle and battle.data, "_MoveIsDisabledText",
|
||||
"%s's\n%s is\ndisabled!", name(battler), shown))
|
||||
"%s's\n%s is\ndisabled!", {
|
||||
USER = name(battler), ["RAM:wNameBuffer"] = shown,
|
||||
}))
|
||||
return false, msgs
|
||||
end
|
||||
end
|
||||
|
||||
@@ -108,7 +108,7 @@ local function drawStatusPanel(battle, battler, x, y, player)
|
||||
HudTiles.drawHPBar(battle.data, tx + 1, ty + 2, {
|
||||
hp = shownHP(battler),
|
||||
stats = battler.mon.stats,
|
||||
}, nil, monoMode(), tw - 5)
|
||||
}, nil, monoMode(), tw - 5, battler.shownPx)
|
||||
|
||||
if player then
|
||||
Font.draw(("%3d/%3d"):format(shownHP(battler), battler.mon.stats.hp),
|
||||
|
||||
@@ -37,8 +37,10 @@ local Prize = require("src.battle.gen2.Prize")
|
||||
-- * Gen 1's `user` / `target` / `battler` are battler wrappers around a mon
|
||||
-- ({ mon = , name = , isPlayer = }); Gen 2's engine works on the party mon
|
||||
-- table directly, so that is what these payloads carry.
|
||||
-- * Gen 1's `rng` is love.math.random (1..n); Gen 2's injected `random` is
|
||||
-- the cart's BattleRandom convention (0..n-1). Both keys are present.
|
||||
-- * Gen 1's `rng` is love.math.random (rng(n) → 1..n, rng(lo,hi) → lo..hi).
|
||||
-- Gen 2's cart BattleRandom is random(n) → 0..n-1. Both live on the
|
||||
-- battle: `random` / `roller()` are BattleRandom for damage, accuracy,
|
||||
-- Magnitude, etc.; `rng` is the Gen 1 / love-style view of the same stream.
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
-- The two battle lines that carry the cart's own `line` break: a marker-bearing
|
||||
-- literal has to stay reachable from a translation mod (#186, #245), which is
|
||||
@@ -88,6 +90,19 @@ local function rand(random, n)
|
||||
return math.random(n) - 1
|
||||
end
|
||||
|
||||
-- Gen 1 / love.math view of BattleRandom: rng(n) → 1..n, rng(lo,hi) → lo..hi.
|
||||
local function loveStyleRng(random)
|
||||
return function(lo, hi)
|
||||
if hi == nil then
|
||||
local n = lo or 1
|
||||
if n < 1 then n = 1 end
|
||||
return (rand(random, n) or 0) + 1
|
||||
end
|
||||
if hi < lo then lo, hi = hi, lo end
|
||||
return lo + (rand(random, hi - lo + 1) or 0)
|
||||
end
|
||||
end
|
||||
|
||||
-- data/trainers/leaders.asm. The two lists are ONE array in the ROM: only
|
||||
-- KantoGymLeaders carries the -1 terminator, and GymLeaders falls through into
|
||||
-- it, so IsGymLeader matches all twenty-two classes while IsKantoGymLeader
|
||||
@@ -212,12 +227,15 @@ Battle.KANTO_BADGE_ORDER = {
|
||||
-- this wild battle is BATTLETYPE_ROAMING; the caller built `wild`
|
||||
-- through Roamers.beginBattle and reads Battle.roaming back to
|
||||
-- bank the beast's HP afterwards
|
||||
-- random(n) 0..n-1, injected so a test is deterministic
|
||||
-- random(n) 0..n-1, injected so a test is deterministic (BattleRandom)
|
||||
-- rng(lo,hi) / rng(n) Gen 1 / love.math contract; defaults over `random`
|
||||
function Battle.new(opts)
|
||||
opts = opts or {}
|
||||
local self = setmetatable({}, Battle)
|
||||
self.data = opts.data or {}
|
||||
self.random = opts.random or function(n) return rand(nil, n) end
|
||||
-- Same stream as `random`, Gen 1 / love.math calling convention.
|
||||
self.rng = opts.rng or loveStyleRng(self.random)
|
||||
self.party = opts.party or {}
|
||||
self.trainer = opts.trainer
|
||||
self.save = opts.save
|
||||
@@ -403,11 +421,8 @@ function Battle:endBattle(outcome)
|
||||
Runtime.emit("battle.ended", { battle = self, result = outcome })
|
||||
end
|
||||
|
||||
-- A never-nil 0..n-1 roller for the hook contexts. `random` is optional on the
|
||||
-- constructor (a headless test injects one, the game leaves it to love.math),
|
||||
-- and a mod reaching for ctx.rng must not have to know that. Note the
|
||||
-- convention: this is the cart's BattleRandom byte (0..n-1), NOT Gen 1's
|
||||
-- love.math.random (1..n).
|
||||
-- Never-nil BattleRandom (0..n-1) for call sites that want the cart byte.
|
||||
-- `battle.rng` is the Gen 1 / love.math view of the same stream.
|
||||
function Battle:roller()
|
||||
if not self.rollerFn then
|
||||
self.rollerFn = function(n) return rand(self.random, n) end
|
||||
|
||||
@@ -265,8 +265,18 @@ Effects.MAGNITUDE_POWER = {
|
||||
-- is what damagecalc reads as the move's power -- data/moves/moves.asm stores
|
||||
-- MAGNITUDE at power 1 precisely because this overwrites it. Returns the
|
||||
-- power and the magnitude number the text prints.
|
||||
--
|
||||
-- `random` is BattleRandom (0..n-1). If none is supplied, roll via love.math
|
||||
-- / math.random — never hard-code 0 (that always yields Magnitude 4).
|
||||
function Effects.magnitudePower(random)
|
||||
local roll = random and random(256) or 0
|
||||
local roll
|
||||
if type(random) == "function" then
|
||||
roll = random(256) or 0
|
||||
elseif love and love.math and love.math.random then
|
||||
roll = love.math.random(256) - 1
|
||||
else
|
||||
roll = math.random(256) - 1
|
||||
end
|
||||
for _, row in ipairs(Effects.MAGNITUDE_POWER) do
|
||||
if row[1] >= roll then return row[2], row[3] end
|
||||
end
|
||||
|
||||
@@ -98,10 +98,69 @@ function Mon.stats(baseStats, dvs, level, statExp)
|
||||
}
|
||||
end
|
||||
|
||||
-- The species string is the source of truth. `mon.name` is a copy of that
|
||||
-- species' display name (GetPokemonName), kept so menus can print without a
|
||||
-- Data lookup. It is NOT the nickname: an un-nicknamed mon has nickname nil
|
||||
-- and prints this copy. Changing species without rewriting it leaves the
|
||||
-- previous species' name on the party list and the SUMMARY's top line.
|
||||
function Mon.syncIdentity(mon, data)
|
||||
if type(mon) ~= "table" then return mon end
|
||||
local def = data and data.pokemon and data.pokemon[mon.species]
|
||||
if not def then return mon end
|
||||
mon.name = def.name or mon.species
|
||||
if def.types then mon.types = def.types end
|
||||
if mon.dvs then
|
||||
mon.gender = Mon.gender(def, mon.dvs,
|
||||
{ species = mon.species, level = mon.level })
|
||||
mon.shiny = Mon.isShiny(mon.dvs,
|
||||
{ species = mon.species, def = def, level = mon.level })
|
||||
if mon.species == Unown.SPECIES then
|
||||
mon.unownLetter = Unown.letterFromDVs(mon.dvs)
|
||||
else
|
||||
mon.unownLetter = nil
|
||||
end
|
||||
end
|
||||
return mon
|
||||
end
|
||||
|
||||
-- Every screen that prints a mon without a Data lookup should go through here:
|
||||
-- nickname if the player set one, otherwise the species display copy `name`.
|
||||
-- Skipping `name` and jumping to `species` is how a swapped mon can still
|
||||
-- read as ABRA on one menu and RAYQUAZA on another.
|
||||
function Mon.displayName(mon)
|
||||
if type(mon) ~= "table" then return "?" end
|
||||
return mon.nickname or mon.name or mon.species or "?"
|
||||
end
|
||||
|
||||
-- Party, boxes, both Day-Care sides, and a pending egg. Editor CONTINUE and
|
||||
-- hydrate have to walk the same set: leaving dayCare.man.mon on the old
|
||||
-- `name` is the ABRA bug in a second closet.
|
||||
function Mon.eachSaveMon(save, fn)
|
||||
if type(save) ~= "table" or type(fn) ~= "function" then return end
|
||||
for _, mon in ipairs(save.party or {}) do fn(mon) end
|
||||
for _, box in pairs(save.boxes or {}) do
|
||||
if type(box) == "table" then
|
||||
for _, mon in ipairs(box) do fn(mon) end
|
||||
end
|
||||
end
|
||||
local dc = save.dayCare
|
||||
if type(dc) == "table" then
|
||||
if dc.man and dc.man.mon then fn(dc.man.mon) end
|
||||
if dc.lady and dc.lady.mon then fn(dc.lady.mon) end
|
||||
if dc.egg then fn(dc.egg) end
|
||||
end
|
||||
if save.daycare and save.daycare.mon then fn(save.daycare.mon) end
|
||||
end
|
||||
|
||||
function Mon.syncSaveIdentity(save, data)
|
||||
Mon.eachSaveMon(save, function(mon) Mon.syncIdentity(mon, data) end)
|
||||
end
|
||||
|
||||
function Mon.refreshStats(mon, data)
|
||||
if type(mon) ~= "table" then return mon end
|
||||
local def = data and data.pokemon and data.pokemon[mon.species]
|
||||
if not (def and def.baseStats) then return mon end
|
||||
Mon.syncIdentity(mon, data)
|
||||
-- engine/pokemon/move_mon.asm:1402
|
||||
local stats = Mon.stats(def.baseStats, mon.dvs, mon.level or 1, mon.statExp)
|
||||
mon.stats = stats
|
||||
|
||||
@@ -44,6 +44,7 @@ local BATTLE_FIELDS = {
|
||||
"sideToxic", "isGymLeader", "musicKind", "lastBall", "lockedBall",
|
||||
"lowHealthAlarmDisabled", "lowHealthAlarmOn", "victoryMusicPlayed",
|
||||
"endBattleText",
|
||||
"playerPartyIndices",
|
||||
}
|
||||
|
||||
local function partyIndex(party, mon)
|
||||
@@ -91,6 +92,23 @@ local function integer(value, min, max)
|
||||
and value >= (min or -math.huge) and value <= (max or math.huge)
|
||||
end
|
||||
|
||||
local function exactIndexSet(indices, maxIndex, requireMember)
|
||||
if type(indices) ~= "table" then return nil end
|
||||
local count, keys = #indices, 0
|
||||
for key in pairs(indices) do
|
||||
keys = keys + 1
|
||||
if not integer(key, 1, count) then return nil end
|
||||
end
|
||||
if keys ~= count or (requireMember and count == 0) then return nil end
|
||||
local seen = {}
|
||||
for i = 1, count do
|
||||
local index = indices[i]
|
||||
if not integer(index, 1, maxIndex) or seen[index] then return nil end
|
||||
seen[index] = true
|
||||
end
|
||||
return seen
|
||||
end
|
||||
|
||||
local function validateMoveList(data, moves)
|
||||
if type(moves) ~= "table" then return false end
|
||||
for _, move in ipairs(moves) do
|
||||
@@ -200,6 +218,16 @@ function BattleCheckpoint.validate(game, checkpoint)
|
||||
if type(party) ~= "table" or not validateBattler(game.data, model.player, #party) then
|
||||
return nil, "invalid_content", "Player battle state is invalid."
|
||||
end
|
||||
local scopedIndices
|
||||
if model.playerPartyIndices ~= nil then
|
||||
if model.kind ~= "trainer" then
|
||||
return nil, "invalid_checkpoint", "Battle party scope is invalid."
|
||||
end
|
||||
scopedIndices = exactIndexSet(model.playerPartyIndices, #party, true)
|
||||
if not scopedIndices or not scopedIndices[model.player.index] then
|
||||
return nil, "invalid_checkpoint", "Battle party scope is invalid."
|
||||
end
|
||||
end
|
||||
if model.kind == "wild" then
|
||||
if not validateMon(game.data, model.enemyMon)
|
||||
or not validateBattler(game.data, model.enemy, 1) then
|
||||
@@ -220,12 +248,15 @@ function BattleCheckpoint.validate(game, checkpoint)
|
||||
end
|
||||
end
|
||||
for _, indices in ipairs({ model.participants, model.leveledUp }) do
|
||||
if type(indices) ~= "table" then
|
||||
local referenced = exactIndexSet(indices, #party, false)
|
||||
if not referenced then
|
||||
return nil, "invalid_checkpoint", "Battle party reference set is missing."
|
||||
end
|
||||
for _, index in ipairs(indices) do
|
||||
if not integer(index, 1, #party) then
|
||||
return nil, "invalid_checkpoint", "Battle party reference is invalid."
|
||||
if scopedIndices then
|
||||
for index in pairs(referenced) do
|
||||
if not scopedIndices[index] then
|
||||
return nil, "invalid_checkpoint", "Battle party reference is invalid."
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -276,7 +307,9 @@ function BattleCheckpoint.restore(game, checkpoint, copy)
|
||||
local model = checkpoint.runtime.battle
|
||||
local battle
|
||||
if model.kind == "trainer" then
|
||||
battle = BattleState.newTrainer(game, model.oppClass, model.partyIndex)
|
||||
battle = BattleState.newTrainer(game, model.oppClass, model.partyIndex, {
|
||||
playerPartyIndices = model.playerPartyIndices,
|
||||
})
|
||||
battle.enemyParty = assert(copy(model.enemyParty))
|
||||
battle.enemyIndex = model.enemyIndex
|
||||
else
|
||||
@@ -334,6 +367,12 @@ function BattleCheckpoint.restore(game, checkpoint, copy)
|
||||
battle.player.mon.hp, battle.player.mon.status
|
||||
battle.enemy.shownHP, battle.enemy.shownStatus =
|
||||
battle.enemy.mon.hp, battle.enemy.mon.status
|
||||
-- the bar's own pixel length is derived HUD state, not captured: it
|
||||
-- settles with shownHP above (UpdateHPBar_AnimateHPBar's end position)
|
||||
local Timing = require("src.core.Timing")
|
||||
for _, b in ipairs({ battle.player, battle.enemy }) do
|
||||
b.shownPx = Timing.hpBarPixels(b.mon.hp, math.max(1, b.mon.stats.hp))
|
||||
end
|
||||
|
||||
local ow = game.overworld
|
||||
if not ow or type(ow.restoreBattleContinuation) ~= "function"
|
||||
|
||||
+11
-2
@@ -1230,8 +1230,17 @@ function Engine.new(data, header, options)
|
||||
engine.tempo = header.tempo
|
||||
engine.tempoLocked = true
|
||||
end
|
||||
for _, spec in ipairs(chip and chip.channels
|
||||
or headerChannels(banks, header)) do
|
||||
local channels = chip and chip.channels or headerChannels(banks, header)
|
||||
if header.startChannels then
|
||||
local byNumber = {}
|
||||
for _, start in ipairs(header.startChannels) do
|
||||
byNumber[start.number] = start.address
|
||||
end
|
||||
for _, spec in ipairs(channels) do
|
||||
spec.address = byNumber[spec.number] or spec.address
|
||||
end
|
||||
end
|
||||
for _, spec in ipairs(channels) do
|
||||
local frameTicks = options.frameTicks
|
||||
local hardware = (spec.number - 1) % 4 + 1
|
||||
if hardware == 4 then
|
||||
|
||||
+49
-17
@@ -73,7 +73,7 @@ end
|
||||
--
|
||||
-- Gold composites its own frame (Game2:draw / drawScene) and pumps its own pad
|
||||
-- (the FixedStep callback in Game2:load), so none of it goes through
|
||||
-- src/render/Renderer.lua or src/core/Game.lua. That explains why the six
|
||||
-- src/render/Renderer.lua or src/core/Game.lua. That explains why the eight
|
||||
-- hooks below never used to fire here; it is not a reason they should not. A
|
||||
-- hook is a contract about a MOMENT in the frame, and Gold has every one of
|
||||
-- these moments -- so each is raised under the Gen 1 NAME with the Gen 1
|
||||
@@ -83,6 +83,7 @@ end
|
||||
-- input.pointer uncaptured pointer events (src/core/Game.lua:887)
|
||||
-- render.zones the palette pass, pre-blit (src/core/Game.lua:505)
|
||||
-- render.compose the whole-window composite (Renderer.lua:759)
|
||||
-- render.output* the normal composed frame (Renderer.lua:1063)
|
||||
-- render.letterbox the void around the 160x144 blit (Renderer.lua:840)
|
||||
-- render.hud screen-space UI over the frame (src/core/Game.lua:521)
|
||||
--
|
||||
@@ -282,6 +283,9 @@ function Game2:continueGame(save)
|
||||
local modsDiff = SaveData.modsDiff(save, activeMods)
|
||||
self.save = save
|
||||
self:adoptSave(save)
|
||||
-- Editor species swaps used to leave mon.name on the previous species.
|
||||
-- CONTINUE rewrites party, boxes, and Day-Care copies from the live record.
|
||||
require("src.battle.gen2.Mon").syncSaveIdentity(save, self.data)
|
||||
-- options.lua wins over anything a save file carries: options are a display
|
||||
-- preference that survives New Game and is edited from the launcher, so a
|
||||
-- save written before they moved out must not drag old values back in.
|
||||
@@ -587,13 +591,13 @@ function Game2:useFieldItem(itemId)
|
||||
end
|
||||
if not allowed then
|
||||
self:say(("%s can't learn %s!"):format(
|
||||
mon.nickname or mon.species or "?", moveName))
|
||||
require("src.battle.gen2.Mon").displayName(mon), moveName))
|
||||
return
|
||||
end
|
||||
for _, move in ipairs(mon.moves or {}) do
|
||||
if move.id == moveId then
|
||||
self:say(("%s already knows %s!"):format(
|
||||
mon.nickname or mon.species or "?", moveName))
|
||||
require("src.battle.gen2.Mon").displayName(mon), moveName))
|
||||
return
|
||||
end
|
||||
end
|
||||
@@ -689,10 +693,26 @@ function Game2:usePartyItem(itemId)
|
||||
self:say(result.text)
|
||||
return
|
||||
end
|
||||
self:consumeItem(itemId)
|
||||
if action == "candy" then
|
||||
if action == "stone" then
|
||||
local party = (self.save and self.save.party) or {}
|
||||
local index
|
||||
for i, member in ipairs(party) do
|
||||
if member == mon then index = i break end
|
||||
end
|
||||
Screens.push(self, "Gen2EvolutionAnim", {
|
||||
mon = mon, entry = result.evolution, index = index,
|
||||
party = party, save = self.save,
|
||||
force = true,
|
||||
onDone = function(evolution)
|
||||
if evolution and evolution.evolved then self:consumeItem(itemId) end
|
||||
self.stack:pop()
|
||||
end,
|
||||
})
|
||||
elseif action == "candy" then
|
||||
self:consumeItem(itemId)
|
||||
self:say(result.text, function() self:afterRareCandy(mon, result) end)
|
||||
else
|
||||
self:consumeItem(itemId)
|
||||
self:say(result.text)
|
||||
end
|
||||
end
|
||||
@@ -1366,12 +1386,14 @@ function Game2:draw()
|
||||
local zoned = type(zones) == "table" and zones[1] ~= nil
|
||||
|
||||
-- A present canvas is paid for only when something reads it: the zone pass,
|
||||
-- GBC FX, a mod post-process, or a render.compose subscriber about to be
|
||||
-- handed the finished frame. With none of them the frame draws straight to
|
||||
-- the screen exactly as it always did.
|
||||
-- GBC FX, a mod post-process, render.compose, or an enabled render.output
|
||||
-- subscriber. With none of them the frame draws straight to the screen
|
||||
-- exactly as it always did.
|
||||
local composing = ModRuntime.wantsHook("render.compose")
|
||||
local hasOutputHook = ModRuntime.wantsHook("render.output")
|
||||
and ModRuntime.call("render.output_enabled", function() return false end) == true
|
||||
local scene = nil
|
||||
if zoned or fx or composing or Pipelines.wantsPresent() then
|
||||
if zoned or fx or composing or Pipelines.wantsPresent() or hasOutputHook then
|
||||
scene = self:presentCanvas(1, w, h)
|
||||
end
|
||||
if not scene then
|
||||
@@ -1403,7 +1425,7 @@ function Game2:draw()
|
||||
-- untinted one. On its own the tint rides the final blit and no second
|
||||
-- canvas is paid for.
|
||||
local source = scene
|
||||
local reread = fx or Pipelines.wantsPresent()
|
||||
local reread = fx or Pipelines.wantsPresent() or hasOutputHook
|
||||
if zoned and reread then
|
||||
local tinted = self:presentCanvas(2, w, h)
|
||||
if tinted then
|
||||
@@ -1423,15 +1445,25 @@ function Game2:draw()
|
||||
-- Post-process pipelines run over the finished composite and before GBC
|
||||
-- FX. Each hands back a canvas; with none registered this returns `source`
|
||||
-- unchanged and the frame is byte-identical (Renderer.lua:1058).
|
||||
local scale, _, _, dpi = self:frameFit(w, h)
|
||||
local scale, ox, oy, dpi = self:frameFit(w, h)
|
||||
source = Pipelines.present(source, { width = w, height = h, scale = scale,
|
||||
dpi = dpi, dpiX = dpi, dpiY = dpi }) or source
|
||||
if fx then
|
||||
GBCFX.present(source, self:pixelScale(w, h))
|
||||
else
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.draw(source, 0, 0)
|
||||
G.setShader()
|
||||
local outputHandled = hasOutputHook
|
||||
and ModRuntime.call("render.output", function() return false end, {
|
||||
canvas = source, width = w, height = h,
|
||||
gameX = ox, gameY = oy,
|
||||
gameWidth = 160 * scale, gameHeight = 144 * scale,
|
||||
scale = scale, dpiX = dpi, dpiY = dpi,
|
||||
generation = 2,
|
||||
}) == true
|
||||
if not outputHandled then
|
||||
if fx then
|
||||
GBCFX.present(source, self:pixelScale(w, h))
|
||||
else
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.draw(source, 0, 0)
|
||||
G.setShader()
|
||||
end
|
||||
end
|
||||
end
|
||||
G.pop()
|
||||
|
||||
+17
-2
@@ -64,6 +64,7 @@ state = {
|
||||
fanfareResume = false, -- start/resume state.source when the fanfare ends
|
||||
fade = nil, -- active volume-ramp fade-out (see Music.fadeOut)
|
||||
tempo = nil, -- alternate-tempo override in force for `current`
|
||||
start = nil,
|
||||
failed = {}, -- labels whose def could not be started; logged once
|
||||
}
|
||||
|
||||
@@ -215,9 +216,11 @@ function Music.play(data, song, loop, ctx)
|
||||
song = selectSong(song, ctx)
|
||||
|
||||
local tempo = ctx and ctx.tempo or nil
|
||||
local start = ctx and ctx.start or nil
|
||||
-- a hook may silence the cue outright, or swap in a label the dedupe
|
||||
-- below has to compare against
|
||||
if not song or (song == state.current and tempo == state.tempo) then return end
|
||||
if not song or (song == state.current and tempo == state.tempo
|
||||
and start == state.start) then return end
|
||||
local def = songDef(data, song)
|
||||
if not def or state.failed[song] then return end
|
||||
|
||||
@@ -240,6 +243,17 @@ function Music.play(data, song, loop, ctx)
|
||||
slowed.tempo = tempo
|
||||
def = slowed
|
||||
end
|
||||
if start == "rival" and song == "Music_MeetRival"
|
||||
and def.bank == 2 and def.address == 17050 then
|
||||
local started = {}
|
||||
for key, value in pairs(def) do started[key] = value end
|
||||
started.startChannels = {
|
||||
{ number = 1, address = 0x71a2 },
|
||||
{ number = 2, address = 0x721d },
|
||||
{ number = 3, address = 0x72b5 },
|
||||
}
|
||||
def = started
|
||||
end
|
||||
local wantLoop = loop ~= false
|
||||
local src, loopSrc, isChip, err = startSong(data, def, wantLoop)
|
||||
if not src then
|
||||
@@ -276,6 +290,7 @@ function Music.play(data, song, loop, ctx)
|
||||
state.source, state.loopSource, state.chip = src, loopSrc, isChip
|
||||
state.current = song
|
||||
state.tempo = tempo
|
||||
state.start = start
|
||||
if Runtime.wants("music.started") then
|
||||
Runtime.emit("music.started", {
|
||||
song = song, previous = previous, chip = isChip,
|
||||
@@ -290,7 +305,7 @@ function Music.stop()
|
||||
stopSource(state.loopSource)
|
||||
require("src.core.ChipAudio").stopMusic()
|
||||
state.current, state.source, state.loopSource, state.fade = nil, nil, nil, nil
|
||||
state.tempo = nil
|
||||
state.tempo, state.start = nil, nil
|
||||
state.chip = false
|
||||
state.pendingRestore = nil
|
||||
if previous and Runtime.wants("music.stopped") then
|
||||
|
||||
@@ -33,6 +33,14 @@ return function(data, label, fallback, ...)
|
||||
local args = { ... }
|
||||
if #args == 0 then return text end
|
||||
|
||||
if #args == 1 and type(args[1]) == "table" then
|
||||
local values = args[1]
|
||||
return (text:gsub("%b{}", function(token)
|
||||
local value = values[token] or values[token:sub(2, -2)]
|
||||
return value == nil and token or tostring(value)
|
||||
end))
|
||||
end
|
||||
|
||||
local slots, named = 0, 0
|
||||
for token in text:gmatch("%b{}") do
|
||||
slots = slots + 1
|
||||
|
||||
+63
-8
@@ -304,6 +304,10 @@ function SaveData.defaultOptions()
|
||||
-- options.lua written before this key keeps its exact meaning. Read and
|
||||
-- written through SaveData.modEnabled / SaveData.setModEnabled.
|
||||
modsByVersion = {},
|
||||
-- Set after the first per-game enablement migration. Older options files
|
||||
-- have only `mods`, so the migration copies each installed mod's current
|
||||
-- answer to every game before game-specific toggles begin changing it.
|
||||
modsByVersionMigrated = false,
|
||||
-- Named setups the player can switch between (#593; src/mods/ModProfile.lua
|
||||
-- owns the shape, src/mods/ManagerState.lua the UI): each row is
|
||||
-- { name, enabled = {id=bool}, options = {id={k=v}}, slots = {version=slotId} }.
|
||||
@@ -563,19 +567,68 @@ end
|
||||
-- only holds the games the player actually chose for, so a mod set can differ
|
||||
-- between Red and Gold without either one owning the other's flags.
|
||||
|
||||
-- Whether a per-game answer is honoured at boot. The loader reads the enable
|
||||
-- flags once, before any entry chunk (src/mods/Loader.lua _loadState), so this
|
||||
-- flips on with that read and not before: until then every writer keeps to the
|
||||
-- shared flag and no surface promises what the boot does not do.
|
||||
SaveData.PER_VERSION_MODS = false
|
||||
-- Per-game answers are live. Every reader and writer goes through modScope,
|
||||
-- so a choice made in the launcher is the choice the next boot loads.
|
||||
SaveData.PER_VERSION_MODS = true
|
||||
|
||||
-- The version a write should be scoped to: the game asked for once per-game
|
||||
-- flags are live, nil (the shared flag) while they are only a preview.
|
||||
-- The version a write is scoped to: per-game controls name one game; a nil
|
||||
-- caller still addresses the legacy shared fallback.
|
||||
function SaveData.modScope(version)
|
||||
if SaveData.PER_VERSION_MODS then return version end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Promote an installation that predates per-game flags. `mods` may contain
|
||||
-- manifest rows ({ id, experimental }) or bare ids. A mod with no old entry
|
||||
-- had the loader default: on, except for experimental mods. Copy that answer
|
||||
-- to every game once, preserving any per-game overlay somebody imported before
|
||||
-- this feature shipped. New installs need no rows here: an absent answer
|
||||
-- still defaults to enabled for every game.
|
||||
function SaveData.migrateModEnablement(options, mods)
|
||||
if type(options) ~= "table" or options.modsByVersionMigrated then return false end
|
||||
options.mods = type(options.mods) == "table" and options.mods or {}
|
||||
options.modsByVersion = type(options.modsByVersion) == "table"
|
||||
and options.modsByVersion or {}
|
||||
|
||||
local known = {}
|
||||
for id in pairs(options.mods) do
|
||||
if type(id) == "string" and id ~= "" then known[id] = { id = id } end
|
||||
end
|
||||
for version, bucket in pairs(options.modsByVersion) do
|
||||
if GameVersion.VERSIONS[version] and type(bucket) == "table" then
|
||||
for id in pairs(bucket) do
|
||||
if type(id) == "string" and id ~= "" then known[id] = known[id] or { id = id } end
|
||||
end
|
||||
end
|
||||
end
|
||||
for _, mod in ipairs(mods or {}) do
|
||||
local id = type(mod) == "table" and mod.id or mod
|
||||
if type(id) == "string" and id ~= "" then
|
||||
known[id] = type(mod) == "table" and mod or (known[id] or { id = id })
|
||||
end
|
||||
end
|
||||
|
||||
-- Do not create options.lua just to record an empty migration on a fresh
|
||||
-- no-mod boot. Keep it pending until there is a real installed or saved
|
||||
-- mod answer to preserve.
|
||||
if next(known) == nil then return false end
|
||||
|
||||
for id, mod in pairs(known) do
|
||||
local shared = options.mods[id]
|
||||
if type(shared) ~= "boolean" then shared = not (mod.experimental == true) end
|
||||
for _, version in ipairs(GameVersion.ORDER) do
|
||||
local bucket = options.modsByVersion[version]
|
||||
if type(bucket) ~= "table" then
|
||||
bucket = {}
|
||||
options.modsByVersion[version] = bucket
|
||||
end
|
||||
if type(bucket[id]) ~= "boolean" then bucket[id] = shared end
|
||||
end
|
||||
end
|
||||
options.modsByVersionMigrated = true
|
||||
return true
|
||||
end
|
||||
|
||||
-- true/false as chosen for `version`, else the shared flag, else nil -- the
|
||||
-- caller owns the default (the loader enables, the launcher keeps
|
||||
-- experimental mods off until asked).
|
||||
@@ -1834,6 +1887,8 @@ function SaveData.newGame(boot)
|
||||
boot = type(boot) == "table" and boot or {}
|
||||
local map = boot.startMap or "REDS_HOUSE_2F"
|
||||
local x, y = boot.startX or 3, boot.startY or 6
|
||||
local facing = boot.startFacing or "down"
|
||||
if map == "REDS_HOUSE_2F" and boot.version ~= "yellow" then facing = "up" end
|
||||
local heal = SaveData.defaultHeal(boot)
|
||||
local save = {
|
||||
meta = { format = Version.saveFormat, mods = {} },
|
||||
@@ -1844,7 +1899,7 @@ function SaveData.newGame(boot)
|
||||
map = map,
|
||||
x = x,
|
||||
y = y,
|
||||
facing = boot.startFacing or "down",
|
||||
facing = facing,
|
||||
name = boot.playerName or "RED",
|
||||
rival = boot.rivalName or "BLUE",
|
||||
-- 16-bit trainer ID rolled at new game (wPlayerID, filled from
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -450,6 +450,20 @@ record("RARE_CANDY", "candy", function(ctx)
|
||||
return rareCandy(ctx.mon, ctx.data)
|
||||
end)
|
||||
|
||||
for _, itemId in ipairs({ "SUN_STONE", "MOON_STONE", "FIRE_STONE",
|
||||
"THUNDERSTONE", "WATER_STONE", "LEAF_STONE" }) do
|
||||
record(itemId, "stone", function(ctx)
|
||||
if ctx.mon.item == "EVERSTONE" then
|
||||
return { used = false, text = ItemEffects.TEXT_NO_EFFECT }
|
||||
end
|
||||
local Evolution = require("src.core.gen2.Evolution")
|
||||
local entry = Evolution.checkMon(ctx.data, ctx.mon,
|
||||
{ force = true, item = ctx.item })
|
||||
if not entry then return { used = false, text = ItemEffects.TEXT_NO_EFFECT } end
|
||||
return { used = true, evolution = entry }
|
||||
end)
|
||||
end
|
||||
|
||||
for itemId in pairs(ItemEffects.REVIVE) do
|
||||
record(itemId, "revive", function(ctx) return revive(ctx.item, ctx.mon) end)
|
||||
end
|
||||
|
||||
@@ -116,6 +116,8 @@ local physfsMountFn = nil
|
||||
local function resolveMount()
|
||||
if physfsMountFn ~= nil then return physfsMountFn end
|
||||
physfsMountFn = false
|
||||
if Platform.isUWP() then return physfsMountFn end
|
||||
if love and love.filesystem and love.filesystem._mounts then return physfsMountFn end
|
||||
local ok, ffi = pcall(require, "ffi")
|
||||
if not ok then return physfsMountFn end
|
||||
pcall(ffi.cdef,
|
||||
@@ -159,6 +161,8 @@ local physfsUnmountFn = nil
|
||||
local function resolveUnmount()
|
||||
if physfsUnmountFn ~= nil then return physfsUnmountFn end
|
||||
physfsUnmountFn = false
|
||||
if Platform.isUWP() then return physfsUnmountFn end
|
||||
if love and love.filesystem and love.filesystem._mounts then return physfsUnmountFn end
|
||||
local ok, ffi = pcall(require, "ffi")
|
||||
if not ok then return physfsUnmountFn end
|
||||
pcall(ffi.cdef, "int PHYSFS_unmount(const char *oldDir);")
|
||||
|
||||
+1449
-246
File diff suppressed because it is too large
Load Diff
+415
-17
@@ -344,6 +344,14 @@ local function readExternalPath(path)
|
||||
return data
|
||||
end
|
||||
|
||||
local function externalFileSize(path)
|
||||
local file = io.open(path, "rb")
|
||||
if not file then return nil end
|
||||
local size = file:seek("end")
|
||||
file:close()
|
||||
return size
|
||||
end
|
||||
|
||||
local function readDroppedFile(file)
|
||||
local ok, openError = file:open("r")
|
||||
if not ok then return nil, openError end
|
||||
@@ -981,6 +989,25 @@ local function findPendingSav(preferAny, skip)
|
||||
return nil
|
||||
end
|
||||
|
||||
local function pickerHasKind(kind)
|
||||
local fn = love.system.pickFileKinds
|
||||
if type(fn) ~= "function" then return false end
|
||||
local ok, kinds = pcall(fn)
|
||||
if not ok or type(kinds) ~= "string" then return false end
|
||||
for token in kinds:gmatch("[^,%s]+") do
|
||||
if token == kind then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function findPendingRequiredImport()
|
||||
local names = { "picked_required_import.bin", "picked_stadium.z64" }
|
||||
for _, name in ipairs(names) do
|
||||
if love.filesystem.getInfo(name, "file") then return name end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Retire an Android pick once it has been through the installer / importer,
|
||||
-- whether or not it worked: a pick left on disk wins the scans above forever,
|
||||
-- so the next tap re-runs the same failing file and the picker never reopens
|
||||
@@ -1112,6 +1139,39 @@ local function chooseSav()
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Generic user-supplied dependency picker. Keep the native prompt entirely
|
||||
-- engine-owned: manifest labels are untrusted and must never enter shell
|
||||
-- command templates. The LÖVE modal already shows the specific import name.
|
||||
local function chooseRequiredFile()
|
||||
local prompt = shellSafe(Strings("Choose required mod file"))
|
||||
local platform = love.system.getOS()
|
||||
if platform == "OS X" then
|
||||
return commandOutput(
|
||||
([[osascript -e 'POSIX path of (choose file with prompt "%s")' 2>/dev/null]])
|
||||
:format(prompt))
|
||||
elseif platform == "Windows" then
|
||||
local script = table.concat({
|
||||
"Add-Type -AssemblyName System.Windows.Forms;",
|
||||
"$d=New-Object System.Windows.Forms.OpenFileDialog;",
|
||||
"$d.Title='" .. prompt .. "';",
|
||||
"$d.Filter='All files (*.*)|*.*';",
|
||||
"if($d.ShowDialog() -eq 'OK'){",
|
||||
"$t=Join-Path $env:TEMP 'pokeport_required_import.bin';",
|
||||
"Copy-Item -LiteralPath $d.FileName -Destination $t -Force;",
|
||||
"[Console]::OutputEncoding=[Text.Encoding]::UTF8;",
|
||||
"[Console]::Write($t)}",
|
||||
})
|
||||
return commandOutput(
|
||||
'powershell -NoProfile -STA -Command "' .. script .. '"')
|
||||
elseif platform == "Linux" then
|
||||
local path = commandOutput(
|
||||
([[zenity --file-selection --title="%s" 2>/dev/null]]):format(prompt))
|
||||
if path then return path end
|
||||
return commandOutput([[kdialog --getopenfilename "$HOME" 2>/dev/null]])
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- The self-updater only surfaces on the real distributed build: a fused,
|
||||
-- interactive launcher with no scripted-run override. A dev / source checkout
|
||||
-- (unfused, where Boot.run already no-ops) or an autopilot / driver /
|
||||
@@ -1228,9 +1288,11 @@ function RomImporter.new(onComplete, opts)
|
||||
saveNotice = {},
|
||||
-- MODS panel state (pass 3): mods is the cached LauncherMods.list() array
|
||||
-- (refreshed lazily on first draw and after any toggle/install/delete);
|
||||
-- modScroll is the list scroll offset (px, clamped in draw); modNotice is
|
||||
-- the last install/delete result { ok, text } shown as a line above the list.
|
||||
mods = nil, modScroll = 0, modNotice = nil,
|
||||
-- modScroll is the current paged list's inner scroll offset (px, clamped
|
||||
-- in draw); modNotice is the last install/delete result { ok, text }.
|
||||
-- requiredImportNotice stays inside the imported-files modal so validation
|
||||
-- failures are visible beside the file picker that caused them.
|
||||
mods = nil, modScroll = 0, modNotice = nil, requiredImportNotice = nil,
|
||||
-- Which game the MODS panel is answering for (a GameVersion id, nil =
|
||||
-- every game). Rows resolve their enable-state and their "runs here"
|
||||
-- verdict against it (src/mods/ModTargets.lua).
|
||||
@@ -1420,7 +1482,13 @@ function RomImporter:focus(f)
|
||||
local text = "Could not read the picked file. Reopen the picker and choose "
|
||||
.. "it with the Files (Documents) app, or copy it into: "
|
||||
.. love.filesystem.getSaveDirectory()
|
||||
if pickError:find("picked_mod", 1, true) then
|
||||
if pickError:find("picked_required_import", 1, true)
|
||||
or pickError:find("picked_stadium", 1, true) then
|
||||
self.modNotice = { ok = false, text = text }
|
||||
self.pickerPendingKind = nil
|
||||
self.pickerPendingModId = nil
|
||||
self.pickerPendingImportId = nil
|
||||
elseif pickError:find("picked_mod", 1, true) then
|
||||
self.modNotice = { ok = false, text = text }
|
||||
elseif pickError:find("picked_save", 1, true) then
|
||||
local version = self.androidPendingVersion or self:_savedropTarget()
|
||||
@@ -1431,6 +1499,20 @@ function RomImporter:focus(f)
|
||||
end
|
||||
return
|
||||
end
|
||||
local requiredName = findPendingRequiredImport()
|
||||
if requiredName then
|
||||
local modId, importId = self.pickerPendingModId, self.pickerPendingImportId
|
||||
self.pickerPendingKind = nil
|
||||
self.pickerPendingModId, self.pickerPendingImportId = nil, nil
|
||||
local imported = modId and importId
|
||||
and self:_importRequiredSource(modId, importId, requiredName)
|
||||
consumePick(self, requiredName, requiredName, imported)
|
||||
if not modId or not importId then
|
||||
self.modNotice = { ok = false,
|
||||
text = "A picked dependency file had no pending mod request and was discarded." }
|
||||
end
|
||||
return
|
||||
end
|
||||
local modName = findPendingMod(false, self.pickSkip)
|
||||
if modName then
|
||||
self:_installMod(modName)
|
||||
@@ -1649,7 +1731,7 @@ end
|
||||
function RomImporter:_installMod(source)
|
||||
if self.workState == "working" then return end
|
||||
self.tab = "mods"
|
||||
local ok, installed, res = pcall(function()
|
||||
local ok, installed, res, manifest = pcall(function()
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
return LauncherMods.installZip(source)
|
||||
end)
|
||||
@@ -1661,6 +1743,17 @@ function RomImporter:_installMod(source)
|
||||
if installed then
|
||||
pcall(self._refreshMods, self)
|
||||
self.modNotice = { ok = true, text = "Installed " .. tostring(res) }
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
local checkTarget = manifest
|
||||
if not checkTarget and type(res) == "string" then
|
||||
checkTarget = { id = res }
|
||||
end
|
||||
if checkTarget and LauncherMods.checkDependencies then
|
||||
local depCheck = LauncherMods.checkDependencies(checkTarget)
|
||||
if depCheck and depCheck.hasIssues then
|
||||
self._modDepResolver = depCheck
|
||||
end
|
||||
end
|
||||
else
|
||||
self.modNotice = { ok = false, text = tostring(res) }
|
||||
end
|
||||
@@ -1727,6 +1820,152 @@ function RomImporter:chooseMod()
|
||||
if path then self:_installMod(path) end
|
||||
end
|
||||
|
||||
local function requiredManifest(self, modId)
|
||||
for _, row in ipairs(self.mods or {}) do
|
||||
if row.id == modId then return row.manifest, row end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function requiredSpec(manifest, importId)
|
||||
for _, candidate in ipairs(require("src.mods.RequiredImports").specs(manifest)) do
|
||||
if candidate.id == importId then return candidate end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function requiredImportNotice(self, modId, importId, text)
|
||||
self.requiredImportNotice = {
|
||||
modId = modId,
|
||||
importId = importId,
|
||||
text = tostring(text),
|
||||
}
|
||||
end
|
||||
|
||||
function RomImporter:_importRequiredData(modId, importId, data)
|
||||
local manifest = requiredManifest(self, modId)
|
||||
if not manifest then
|
||||
self.modNotice = { ok = false, text = "Required import failed: mod not found." }
|
||||
return nil
|
||||
end
|
||||
local ok, result = require("src.mods.RequiredImports")
|
||||
.importData(manifest, importId, data)
|
||||
if ok then
|
||||
self.requiredImportNotice = nil
|
||||
self.modNotice = { ok = true, text = "Imported " .. tostring(importId)
|
||||
.. " for " .. tostring(manifest.name or manifest.id) .. "." }
|
||||
self:_refreshMods()
|
||||
return true
|
||||
end
|
||||
-- Keep validation feedback on the imported-files page. A general Mods-page
|
||||
-- notice is hidden by this modal and made MD5 failures especially easy to miss.
|
||||
requiredImportNotice(self, modId, importId, result)
|
||||
self.modNotice = nil
|
||||
return nil
|
||||
end
|
||||
|
||||
function RomImporter:_importRequiredSource(modId, importId, source)
|
||||
local manifest = requiredManifest(self, modId)
|
||||
local spec = manifest and requiredSpec(manifest, importId)
|
||||
if not spec then
|
||||
requiredImportNotice(self, modId, importId, "Import declaration was not found.")
|
||||
self.modNotice = nil
|
||||
return nil
|
||||
end
|
||||
local info = love.filesystem.getInfo(source, "file")
|
||||
local size = info and info.size or externalFileSize(source)
|
||||
local sizeErr = require("src.mods.RequiredImports").sizeError(spec, size, false)
|
||||
if sizeErr then
|
||||
requiredImportNotice(self, modId, importId, sizeErr)
|
||||
self.modNotice = nil
|
||||
return nil
|
||||
end
|
||||
local data = love.filesystem.read(source)
|
||||
if not data then data = readExternalPath(source) end
|
||||
if not data then
|
||||
requiredImportNotice(self, modId, importId, "Could not read the selected file.")
|
||||
self.modNotice = nil
|
||||
return nil
|
||||
end
|
||||
return self:_importRequiredData(modId, importId, data)
|
||||
end
|
||||
|
||||
function RomImporter:_removeRequiredImport(modId, importId)
|
||||
local manifest = requiredManifest(self, modId)
|
||||
if not manifest then return end
|
||||
local ok, err = require("src.mods.RequiredImports").remove(manifest, importId)
|
||||
if ok then
|
||||
self.requiredImportNotice = nil
|
||||
self.modNotice = { ok = true, text = "Deleted " .. tostring(importId) .. "." }
|
||||
self:_refreshMods()
|
||||
else
|
||||
requiredImportNotice(self, modId, importId, err)
|
||||
self.modNotice = nil
|
||||
end
|
||||
end
|
||||
|
||||
-- Select and validate one manifest-declared file. NX has no host picker, so
|
||||
-- its equivalent is an engine-owned imports/baseroms inbox that can be filled
|
||||
-- over MTP; every other native/mobile picker lands on the same validation path.
|
||||
function RomImporter:chooseRequiredImport(modId, importId)
|
||||
if self.workState == "working" then return end
|
||||
local manifest = requiredManifest(self, modId)
|
||||
if not manifest then return end
|
||||
local spec = requiredSpec(manifest, importId)
|
||||
if not spec then return end
|
||||
|
||||
if self.isNX then
|
||||
local inbox = "imports/baseroms"
|
||||
love.filesystem.createDirectory(inbox)
|
||||
local lastError
|
||||
for _, name in ipairs(love.filesystem.getDirectoryItems(inbox) or {}) do
|
||||
if name:sub(1, 1) ~= "." then
|
||||
local path = inbox .. "/" .. name
|
||||
local info = love.filesystem.getInfo(path, "file")
|
||||
local sizeErr = info and require("src.mods.RequiredImports")
|
||||
.sizeError(spec, info.size, false)
|
||||
local data = not sizeErr and love.filesystem.read(path) or nil
|
||||
if data and self:_importRequiredData(modId, importId, data) then return end
|
||||
if sizeErr then lastError = sizeErr
|
||||
elseif self.requiredImportNotice
|
||||
and self.requiredImportNotice.modId == modId
|
||||
and self.requiredImportNotice.importId == importId then
|
||||
lastError = self.requiredImportNotice.text
|
||||
end
|
||||
end
|
||||
end
|
||||
requiredImportNotice(self, modId, importId, lastError
|
||||
or "No matching file in imports/baseroms/. Copy it there over MTP, then try again.")
|
||||
self.modNotice = nil
|
||||
return
|
||||
end
|
||||
if self.nativePicker then
|
||||
if self.mobileFileBridge and not pickerHasKind("required_import") then
|
||||
requiredImportNotice(self, modId, importId,
|
||||
"This app build cannot pick required mod files yet. Update the app and try again.")
|
||||
self.modNotice = nil
|
||||
return
|
||||
end
|
||||
self.pickerPendingKind = "required_import"
|
||||
self.pickerPendingModId = modId
|
||||
self.pickerPendingImportId = importId
|
||||
if not pickFile("required_import") then
|
||||
self.pickerPendingKind = nil
|
||||
self.pickerPendingModId = nil
|
||||
self.pickerPendingImportId = nil
|
||||
requiredImportNotice(self, modId, importId, "Could not open the file picker.")
|
||||
self.modNotice = nil
|
||||
elseif self.android then
|
||||
self.pickPending = true
|
||||
self.pickTimer = 0
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
local path = chooseRequiredFile()
|
||||
if path then self:_importRequiredSource(modId, importId, path) end
|
||||
end
|
||||
|
||||
-- Which game a dropped .sav imports into: a .sav has no version signature of
|
||||
-- its own, so it lands on the active game tab. When a non-game tab (mods) is
|
||||
-- showing, default to red -- the always-present first game -- rather than
|
||||
@@ -2034,7 +2273,8 @@ function RomImporter:_pollPickedFiles(dt)
|
||||
if not found then
|
||||
for _, name in ipairs(love.filesystem.getDirectoryItems("")) do
|
||||
local n = name:lower()
|
||||
if n:match("%.gbc?$") or n == "picked_mod.zip" or n == "picked_save.sav" then
|
||||
if n:match("%.gbc?$") or n == "picked_mod.zip" or n == "picked_save.sav"
|
||||
or n == "picked_required_import.bin" or n == "picked_stadium.z64" then
|
||||
found = true
|
||||
break
|
||||
end
|
||||
@@ -2048,6 +2288,15 @@ end
|
||||
|
||||
function RomImporter:update(dt)
|
||||
self.pulse = self.pulse + dt
|
||||
if self._launchFade then
|
||||
self._launchFade.elapsed = self._launchFade.elapsed + dt
|
||||
if self._launchFade.elapsed >= self._launchFade.duration then
|
||||
local version = self._launchFade.version
|
||||
self._launchFade = nil
|
||||
self:play(version)
|
||||
return
|
||||
end
|
||||
end
|
||||
self:_updatePadCursor(dt)
|
||||
self:_stepBaseRomScan()
|
||||
-- Pump the FlexLove view (input polling + the queued click actions). The
|
||||
@@ -2154,7 +2403,12 @@ function RomImporter:update(dt)
|
||||
local version = self.pickerPendingVersion
|
||||
self.pickerPendingKind = nil
|
||||
self.pickerPendingVersion = nil
|
||||
if kind == "mod" then
|
||||
if kind == "required_import" then
|
||||
local modId, importId = self.pickerPendingModId, self.pickerPendingImportId
|
||||
self.pickerPendingModId, self.pickerPendingImportId = nil, nil
|
||||
if modId and importId then self:_importRequiredSource(modId, importId, path) end
|
||||
if Platform.isUWP() then os.remove(path) end
|
||||
elseif kind == "mod" then
|
||||
self:_installMod(path)
|
||||
if Platform.isUWP() and self.modNotice and self.modNotice.ok then
|
||||
os.remove(path)
|
||||
@@ -2176,7 +2430,10 @@ function RomImporter:update(dt)
|
||||
local version = self.pickerPendingVersion or self:_savedropTarget()
|
||||
self.pickerPendingKind = nil
|
||||
self.pickerPendingVersion = nil
|
||||
if kind == "mod" then
|
||||
if kind == "required_import" then
|
||||
self.modNotice = { ok = false, text = errorText }
|
||||
self.pickerPendingModId, self.pickerPendingImportId = nil, nil
|
||||
elseif kind == "mod" then
|
||||
self.modNotice = { ok = false, text = errorText }
|
||||
elseif kind == "sav" then
|
||||
self.saveNotice[version] = { ok = false, text = errorText }
|
||||
@@ -2336,6 +2593,20 @@ function RomImporter:_updatePadCursor(dt)
|
||||
local ny = self._padCursor.y + dy * speed * dt
|
||||
self._padCursor.x = math.max(ox, math.min(ox + w, nx))
|
||||
self._padCursor.y = math.max(oy, math.min(oy + h, ny))
|
||||
-- Pushing INTO the top/bottom edge scrolls the page instead of stalling.
|
||||
-- The cursor is clamped to the safe area above, so on a short window the
|
||||
-- rows below the fold are unreachable on a stickless handheld: no mouse
|
||||
-- wheel, no touchscreen, and no right stick to feed the existing wheel
|
||||
-- path. Only the OVERSHOOT scrolls -- parking the cursor at the edge does
|
||||
-- nothing, it has to be actively pushed -- and this block only runs on pad
|
||||
-- input, so a real mouse is unaffected. /48 matches the pixels-per-notch
|
||||
-- LauncherView.draw multiplies back out.
|
||||
local overY = 0
|
||||
if ny > oy + h then overY = ny - (oy + h)
|
||||
elseif ny < oy then overY = ny - oy end
|
||||
if overY ~= 0 and self._flex then
|
||||
require("src.import.LauncherView").wheelmoved(self, 0, -overY / 48)
|
||||
end
|
||||
-- Desktop: FlexLove polls the real mouse, so warp it with the pad pointer.
|
||||
-- NX: the getPosition bridge already returns pad coords — skip setPosition.
|
||||
if not self.isNX and love.mouse.setPosition then
|
||||
@@ -2436,9 +2707,15 @@ function RomImporter:joystickhat(joystick, hat, direction)
|
||||
end
|
||||
|
||||
-- Player pressed Play on a game whose ROM is imported: hand off to boot.
|
||||
function RomImporter:play(version)
|
||||
function RomImporter:play(version, fade)
|
||||
if self.workState == "working" then return end
|
||||
if not self.ready[version] then return end
|
||||
if fade then
|
||||
if not self._launchFade then
|
||||
self._launchFade = { version = version, elapsed = 0, duration = 0.24 }
|
||||
end
|
||||
return
|
||||
end
|
||||
self._handedOff = true
|
||||
-- #835: remember the game being launched so the next launcher start opens on
|
||||
-- its column (_applyLastVersionTab). It rides options.lua rather than a file
|
||||
@@ -2701,6 +2978,43 @@ function RomImporter:fileUrl(path)
|
||||
end
|
||||
|
||||
function RomImporter:keypressed(key)
|
||||
if self._profileSavePrompt then
|
||||
if key == "backspace" then
|
||||
self._profileSavePrompt.text = utf8Back(self._profileSavePrompt.text or "")
|
||||
elseif key == "return" or key == "kpenter" then
|
||||
local txt = self._profileSavePrompt and self._profileSavePrompt.text
|
||||
if txt and txt ~= "" then
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
LauncherMods.saveProfile(txt)
|
||||
self._profileSavePrompt = nil
|
||||
self:_disarmTextInput()
|
||||
if self._refreshMods then self:_refreshMods() end
|
||||
end
|
||||
elseif key == "escape" then
|
||||
self._profileSavePrompt = nil
|
||||
self:_disarmTextInput()
|
||||
end
|
||||
return
|
||||
end
|
||||
if self._profileRenamePrompt then
|
||||
if key == "backspace" then
|
||||
self._profileRenamePrompt.text = utf8Back(self._profileRenamePrompt.text or "")
|
||||
elseif key == "return" or key == "kpenter" then
|
||||
local txt = self._profileRenamePrompt and self._profileRenamePrompt.text
|
||||
local old = self._profileRenamePrompt and self._profileRenamePrompt.oldName
|
||||
if txt and txt ~= "" and old then
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
LauncherMods.renameProfile(old, txt)
|
||||
self._profileRenamePrompt = nil
|
||||
self:_disarmTextInput()
|
||||
if self._refreshMods then self:_refreshMods() end
|
||||
end
|
||||
elseif key == "escape" then
|
||||
self._profileRenamePrompt = nil
|
||||
self:_disarmTextInput()
|
||||
end
|
||||
return
|
||||
end
|
||||
if self._settingsText then
|
||||
if key == "backspace" then
|
||||
self._settingsText.text = utf8Back(self._settingsText.text)
|
||||
@@ -2743,7 +3057,7 @@ function RomImporter:keypressed(key)
|
||||
return
|
||||
end
|
||||
if self._modConfirm or self._modVersions or self._modReleaseNotes
|
||||
or self._findDetails then
|
||||
or self._findDetails or self._appPatchNotes then
|
||||
-- Focus navigation belongs to the visible modal as well as the launcher
|
||||
-- beneath it. Route arrows and an already-armed confirm before this guard
|
||||
-- returns; unarmed Enter still falls through to the modal guard. Keep this
|
||||
@@ -2756,6 +3070,8 @@ function RomImporter:keypressed(key)
|
||||
self._findDetails = nil
|
||||
elseif self._modReleaseNotes then
|
||||
self._modReleaseNotes = nil
|
||||
elseif self._appPatchNotes then
|
||||
self._appPatchNotes = nil
|
||||
else
|
||||
self._modConfirm = nil
|
||||
self._modVersions = nil
|
||||
@@ -2870,6 +3186,14 @@ function RomImporter:_commitRename()
|
||||
end
|
||||
|
||||
function RomImporter:textinput(text)
|
||||
if self._profileSavePrompt then
|
||||
self._profileSavePrompt.text = utf8Cap((self._profileSavePrompt.text or "") .. text, MAX_SLOT_LABEL)
|
||||
return
|
||||
end
|
||||
if self._profileRenamePrompt then
|
||||
self._profileRenamePrompt.text = utf8Cap((self._profileRenamePrompt.text or "") .. text, MAX_SLOT_LABEL)
|
||||
return
|
||||
end
|
||||
if self._settingsText then
|
||||
local st = self._settingsText
|
||||
st.text = utf8Cap(st.text .. text, st.maxLen or MAX_SLOT_LABEL)
|
||||
@@ -3064,15 +3388,19 @@ function RomImporter:_modUpdateInfo(id)
|
||||
return self.modUpdateInfo and self.modUpdateInfo[id] or nil
|
||||
end
|
||||
|
||||
-- Flip a mod's enabled flag (persisted via LauncherMods.setEnabled) and relist
|
||||
-- so the toggle, count, and every status chip reflect the new resolution.
|
||||
-- Enabling an experimental mod arms a confirm first.
|
||||
function RomImporter:_toggleMod(id, confirmed)
|
||||
-- Flip one game's mod flag (persisted via LauncherMods.setEnabled) and relist
|
||||
-- so that game's checkbox and status chips reflect the new resolution.
|
||||
-- Enabling an experimental mod arms a confirmation for that same game.
|
||||
function RomImporter:_toggleMod(id, confirmed, version)
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
local cur, experimental = false, false
|
||||
for _, m in ipairs(self.mods or {}) do
|
||||
if m.id == id then
|
||||
cur = m.enabled
|
||||
if version and m.enabledByVersion then
|
||||
cur = m.enabledByVersion[version] == true
|
||||
else
|
||||
cur = m.enabled
|
||||
end
|
||||
experimental = m.experimental == true
|
||||
break
|
||||
end
|
||||
@@ -3080,7 +3408,7 @@ function RomImporter:_toggleMod(id, confirmed)
|
||||
local want = not cur
|
||||
if want and experimental and not confirmed then
|
||||
self._modConfirm = {
|
||||
kind = "experimental", id = id,
|
||||
kind = "experimental", id = id, version = version,
|
||||
title = "Experimental mod",
|
||||
yesLabel = "Enable",
|
||||
lines = {
|
||||
@@ -3092,7 +3420,7 @@ function RomImporter:_toggleMod(id, confirmed)
|
||||
return
|
||||
end
|
||||
self._modConfirm = nil
|
||||
LauncherMods.setEnabled(id, want, self.modScope)
|
||||
LauncherMods.setEnabled(id, want, version or self.modScope)
|
||||
self:_refreshMods()
|
||||
end
|
||||
|
||||
@@ -3352,6 +3680,76 @@ function RomImporter:_pumpModInstall()
|
||||
else
|
||||
self.modNotice = { ok = true, text = text }
|
||||
end
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
local depCheck = LauncherMods.checkDependencies({ id = spec.modId })
|
||||
if depCheck and depCheck.hasIssues then
|
||||
self._modDepResolver = depCheck
|
||||
end
|
||||
end
|
||||
|
||||
-- Start an async pull for a single dependency
|
||||
function RomImporter:_startDepPull(dep)
|
||||
if not dep or not dep.github then return end
|
||||
self._depPullState = self._depPullState or {}
|
||||
local hFetch = require("src.mods.ModUpdate").beginFetchReleases(dep.github, dep.id, { force = true })
|
||||
self._depPullState[dep.id] = {
|
||||
dep = dep,
|
||||
stage = "fetching",
|
||||
fetchHandle = hFetch,
|
||||
}
|
||||
end
|
||||
|
||||
-- Pump all in-flight dependency pulls
|
||||
function RomImporter:_pumpDepPulls()
|
||||
if not self._depPullState then return end
|
||||
local ModUpdate = require("src.mods.ModUpdate")
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
|
||||
for depId, state in pairs(self._depPullState) do
|
||||
if state.stage == "fetching" then
|
||||
local done, releases, err = ModUpdate.pumpFetchReleases(state.fetchHandle)
|
||||
if done then
|
||||
if err or not releases or #releases == 0 then
|
||||
state.stage = "error"
|
||||
state.err = err or "No downloadable releases found on GitHub"
|
||||
else
|
||||
local rel = releases[1]
|
||||
if not rel or not rel.zip or not rel.zip.url then
|
||||
state.stage = "error"
|
||||
state.err = "Latest release has no downloadable .zip asset"
|
||||
else
|
||||
local tmpName = ("dep_%s_%s.zip"):format(depId, tostring(rel.version or os.time()))
|
||||
state.dlHandle = ModUpdate.beginDownloadZip(rel.zip.url, tmpName, rel.zip.size)
|
||||
state.stage = "downloading"
|
||||
state.targetVersion = rel.version
|
||||
end
|
||||
end
|
||||
end
|
||||
elseif state.stage == "downloading" then
|
||||
local done, localPath, err, progress = ModUpdate.pumpDownloadZip(state.dlHandle)
|
||||
state.progress = progress
|
||||
if done then
|
||||
if err or not localPath then
|
||||
state.stage = "error"
|
||||
state.err = err or "Download failed"
|
||||
else
|
||||
state.stage = "installing"
|
||||
local okInst, versionRes = LauncherMods.installDownloadedZip(depId, localPath, state.targetVersion)
|
||||
if okInst then
|
||||
state.stage = "done"
|
||||
pcall(self._refreshMods, self)
|
||||
if self._modDepResolver and self._modDepResolver.targetMod then
|
||||
local updated = LauncherMods.checkDependencies(self._modDepResolver.targetMod)
|
||||
self._modDepResolver = updated
|
||||
end
|
||||
else
|
||||
state.stage = "error"
|
||||
state.err = tostring(versionRes or "Installation failed")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function RomImporter:_confirmModUpdate(modId, release)
|
||||
|
||||
@@ -210,6 +210,17 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
-- (ItemUsePokeFlute, engine/items/item_effects.asm); never consumed.
|
||||
if itemId == "POKE_FLUTE" then
|
||||
if not battle then
|
||||
if ow and ow.map and ow.map.id == "PEWTER_POKECENTER"
|
||||
and ow.pikachuPewterSleepScene then
|
||||
local Follower = require("src.world.PikachuFollower")
|
||||
local pika = Follower.current(ow)
|
||||
local player = ow.player
|
||||
if pika and player
|
||||
and math.abs(pika.cellX - player.cellX) + math.abs(pika.cellY - player.cellY) == 1 then
|
||||
return "flute_wake_pikachu", { romText(data, "_PlayedFluteHadEffectText",
|
||||
"{PLAYER} played the\nPOKé FLUTE.") }
|
||||
end
|
||||
end
|
||||
-- standing next to a not-yet-beaten Snorlax: this is the ONLY way
|
||||
-- Snorlax wakes -- using the flute from the item-use menu, never
|
||||
-- just talking to it with the flute in the bag (see
|
||||
|
||||
+120
-11
@@ -9,15 +9,16 @@
|
||||
-- cable pull.
|
||||
--
|
||||
-- Cable rules: no experience, no money, no items; either side may RUN
|
||||
-- (a draw); a fainted mon is auto-replaced by the next healthy party
|
||||
-- member (the original prompts; documented divergence). Badge stat
|
||||
-- boosts don't apply on either side (divergence: Gen 1 famously kept
|
||||
-- them in link battles).
|
||||
-- (a draw); a fainted mon is replaced from the party menu and the chosen
|
||||
-- slot rides the wire, the way ChooseNextMon hands it to
|
||||
-- LinkBattleExchangeData. Badge stat boosts don't apply on either side
|
||||
-- (divergence: Gen 1 famously kept them in link battles).
|
||||
|
||||
local Fingerprint = require("src.link.Fingerprint")
|
||||
local Font = require("src.render.Font")
|
||||
local Handshake = require("src.link.Handshake")
|
||||
local Logger = require("src.core.Logger")
|
||||
local Party = require("src.pokemon.Party")
|
||||
local Protocol = require("src.link.Protocol")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local TurnOrder = require("src.battle.TurnOrder")
|
||||
@@ -257,7 +258,9 @@ function LinkBattle.new(game, net, opts)
|
||||
self.enemyParty = theirParty
|
||||
self.playerParty = myParty -- intro ball row uses the clamped copies
|
||||
self.opponentName = theirName
|
||||
self.introText = Strings("%s wants\nto battle!", theirName)
|
||||
-- _TrainerWantsToFightText (data/text/text_2.asm:1257): wIsInBattle == 2
|
||||
-- takes PrintBeginningBattleText's .trainerBattle arm, link included
|
||||
self.introText = Strings("%s wants\nto fight!", theirName)
|
||||
self.remoteHashes = {}
|
||||
self.localHashes = {}
|
||||
self.remoteParts = {}
|
||||
@@ -327,6 +330,68 @@ function LinkBattle.new(game, net, opts)
|
||||
end)
|
||||
end
|
||||
|
||||
-- ChooseNextMon (engine/battle/core.asm:1086-1103): the replacement after
|
||||
-- a faint is a free party-menu pick in a link battle too -- DisplayPartyMenu
|
||||
-- runs first, a fainted pick or a cancel goes back to it
|
||||
-- (.goBackToPartyMenu), and only the chosen slot rides
|
||||
-- LinkBattleExchangeData.
|
||||
local function chooseReplacement(s)
|
||||
s.linkReplacement = nil
|
||||
s:uiNext(function()
|
||||
return s:buildScreen("PartyMenu", {
|
||||
battle = s,
|
||||
party = myParty,
|
||||
forceSwitch = true,
|
||||
onSwitch = function(mon)
|
||||
if mon.hp > 0 then s.linkReplacement = mon end
|
||||
end,
|
||||
})
|
||||
end)
|
||||
s:actNext(function()
|
||||
local mon = s.linkReplacement
|
||||
s.linkReplacement = nil
|
||||
if s.result then return end
|
||||
if not mon then
|
||||
chooseReplacement(s)
|
||||
return
|
||||
end
|
||||
for i, m in ipairs(myParty) do
|
||||
if m == mon then send({ type = "replace", index = i }) break end
|
||||
end
|
||||
sendOutPlayer(s, mon)
|
||||
end)
|
||||
end
|
||||
|
||||
-- ReplaceFaintedEnemyMon (core.asm:892-905) reads the peer's slot back out
|
||||
-- of the exchange, and EnemySendOutFirstMon (core.asm:1315-1320) decodes it
|
||||
-- as wSerialExchangeNybbleReceiveData - 4. HandlePlayerMonFainted
|
||||
-- (core.asm:989-996) always runs ChooseNextMon BEFORE that read, so on a
|
||||
-- double faint both machines commit their own slot before they block on the
|
||||
-- peer's; our queue can reach the enemy's handler first, so the wait
|
||||
-- pre-empts itself with the local pick rather than deadlocking on a message
|
||||
-- neither side is going to send.
|
||||
local function awaitReplacement(s)
|
||||
s:actNext(function()
|
||||
if s.result then return end
|
||||
if s.player.mon.hp <= 0 and not s.linkChoiceQueued
|
||||
and Party.firstHealthy(myParty) then
|
||||
s.linkChoiceQueued = true
|
||||
chooseReplacement(s)
|
||||
awaitReplacement(s)
|
||||
return
|
||||
end
|
||||
local idx = s.remoteReplace
|
||||
if not idx then
|
||||
awaitReplacement(s)
|
||||
return
|
||||
end
|
||||
s.remoteReplace = nil
|
||||
local mon = theirParty[idx]
|
||||
if not mon or mon.hp <= 0 then mon = Party.firstHealthy(theirParty) end
|
||||
sendOutEnemy(s, mon)
|
||||
end)
|
||||
end
|
||||
|
||||
-- decode a remote action message against the enemy battler
|
||||
local function decodeTheirAction(s, msg)
|
||||
return decodeWireAction(s, msg, s.enemy)
|
||||
@@ -540,12 +605,17 @@ function LinkBattle.new(game, net, opts)
|
||||
submit(s, { type = "action", kind = "run" }, nil)
|
||||
end
|
||||
|
||||
-- fainted mons auto-replace with the next healthy teammate, in party
|
||||
-- order, identically on both machines
|
||||
-- linkChoiceQueued: awaitReplacement already pre-empted itself with this
|
||||
-- side's ChooseNextMon and put the slot on the wire, so the handler the
|
||||
-- faint queued has nothing left to do but drop the flag
|
||||
self.playerMonFainted = function(s)
|
||||
if s.linkChoiceQueued then
|
||||
s.linkChoiceQueued = nil
|
||||
return
|
||||
end
|
||||
for _, mon in ipairs(myParty) do
|
||||
if mon.hp > 0 then
|
||||
s:act(function() sendOutPlayer(s, mon) end)
|
||||
if not s.result then chooseReplacement(s) end
|
||||
return
|
||||
end
|
||||
end
|
||||
@@ -558,7 +628,7 @@ function LinkBattle.new(game, net, opts)
|
||||
self.enemyMonFainted = function(s)
|
||||
for _, mon in ipairs(theirParty) do
|
||||
if mon.hp > 0 then
|
||||
s:act(function() sendOutEnemy(s, mon) end)
|
||||
if not s.result then awaitReplacement(s) end
|
||||
return
|
||||
end
|
||||
end
|
||||
@@ -579,6 +649,9 @@ function LinkBattle.new(game, net, opts)
|
||||
s.remoteHashes[msg.turn or 0] = msg.value
|
||||
s.remoteParts[msg.turn or 0] = msg.parts
|
||||
checkHashes(s)
|
||||
elseif msg.type == "replace" then
|
||||
local idx = math.floor(tonumber(msg.index) or 1)
|
||||
s.remoteReplace = math.max(1, math.min(#theirParty, idx))
|
||||
elseif msg.type == "bye" then
|
||||
-- only a draw if our own simulation hasn't already decided
|
||||
-- (the winner's bye can arrive while we're still animating)
|
||||
@@ -770,6 +843,34 @@ function LinkBattle.newSpectator(game, net, opts)
|
||||
end)
|
||||
end
|
||||
|
||||
local function awaitHostReplacement(s)
|
||||
s:actNext(function()
|
||||
if s.result then return end
|
||||
local idx = table.remove(s.hostReplace, 1)
|
||||
if not idx then
|
||||
awaitHostReplacement(s)
|
||||
return
|
||||
end
|
||||
local mon = hostParty[idx]
|
||||
if not mon or mon.hp <= 0 then mon = Party.firstHealthy(hostParty) end
|
||||
sendOutHost(s, mon)
|
||||
end)
|
||||
end
|
||||
|
||||
local function awaitGuestReplacement(s)
|
||||
s:actNext(function()
|
||||
if s.result then return end
|
||||
local idx = table.remove(s.guestReplace, 1)
|
||||
if not idx then
|
||||
awaitGuestReplacement(s)
|
||||
return
|
||||
end
|
||||
local mon = guestParty[idx]
|
||||
if not mon or mon.hp <= 0 then mon = Party.firstHealthy(guestParty) end
|
||||
sendOutGuest(s, mon)
|
||||
end)
|
||||
end
|
||||
|
||||
local function resolveSpecTurn(s, hostMsg, guestMsg)
|
||||
if hostMsg.kind == "run" or guestMsg.kind == "run" then
|
||||
endSpectate(s, "The match ended.")
|
||||
@@ -840,7 +941,7 @@ function LinkBattle.newSpectator(game, net, opts)
|
||||
self.playerMonFainted = function(s)
|
||||
for _, mon in ipairs(hostParty) do
|
||||
if mon.hp > 0 then
|
||||
s:act(function() sendOutHost(s, mon) end)
|
||||
if not s.result then awaitHostReplacement(s) end
|
||||
return
|
||||
end
|
||||
end
|
||||
@@ -852,7 +953,7 @@ function LinkBattle.newSpectator(game, net, opts)
|
||||
self.enemyMonFainted = function(s)
|
||||
for _, mon in ipairs(guestParty) do
|
||||
if mon.hp > 0 then
|
||||
s:act(function() sendOutGuest(s, mon) end)
|
||||
if not s.result then awaitGuestReplacement(s) end
|
||||
return
|
||||
end
|
||||
end
|
||||
@@ -862,6 +963,7 @@ function LinkBattle.newSpectator(game, net, opts)
|
||||
end
|
||||
|
||||
self.hostMsg, self.guestMsg = nil, nil
|
||||
self.hostReplace, self.guestReplace = {}, {}
|
||||
local baseUpdate = self.update
|
||||
self.update = function(s, dt)
|
||||
net:update()
|
||||
@@ -875,6 +977,13 @@ function LinkBattle.newSpectator(game, net, opts)
|
||||
s.hostMsg, s.guestMsg = nil, nil
|
||||
resolveSpecTurn(s, h, g)
|
||||
end
|
||||
elseif inner.type == "replace" then
|
||||
local idx = math.floor(tonumber(inner.index) or 1)
|
||||
if msg.side == "host" then
|
||||
table.insert(s.hostReplace, math.max(1, math.min(#hostParty, idx)))
|
||||
else
|
||||
table.insert(s.guestReplace, math.max(1, math.min(#guestParty, idx)))
|
||||
end
|
||||
elseif inner.type == "bye" or inner.type == "forfeit" then
|
||||
if not s.result then endSpectate(s, "The match ended.") end
|
||||
end
|
||||
|
||||
+87
-17
@@ -19,6 +19,7 @@ LinkState.__index = LinkState
|
||||
LinkState.isOpaque = true
|
||||
|
||||
local CURSOR = 0xED
|
||||
local CURSOR_HOLLOW = 0xEC
|
||||
local ANY = "ANY" -- sentinel: a leading nil array entry breaks ipairs under
|
||||
-- LuaJIT even though # still reports the full size, so
|
||||
-- the level picker cycles this string instead of nil,
|
||||
@@ -114,6 +115,7 @@ end
|
||||
function LinkState:exitWith(message, reason)
|
||||
DiscordPresence.setJoinCode(nil)
|
||||
self.game.linkSession = nil -- back to the player's own GAME SPEED
|
||||
if self.game.linkNet == self.net then self.game.linkNet = nil end
|
||||
Runtime.emit("link.ended", { reason = reason or (message and "error" or "bye") })
|
||||
if self.net then self.net:close() end
|
||||
self.game.stack:pop()
|
||||
@@ -519,6 +521,9 @@ function LinkState:startMode(mode, isHost)
|
||||
})
|
||||
self.net:send(self.trade:opening())
|
||||
self.index = 1
|
||||
self.theirIndex = 1
|
||||
self.side = "mine"
|
||||
self.pickChoice = nil
|
||||
else
|
||||
self.stage = "battleWait"
|
||||
-- the host deals the shared RNG seed for the lockstep simulation
|
||||
@@ -536,7 +541,14 @@ end
|
||||
-- trade flow
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
function LinkState:openStats(mon)
|
||||
if not mon then return end
|
||||
self.game.linkNet = self.net
|
||||
Screens.push(self.game, "SummaryMenu", mon)
|
||||
end
|
||||
|
||||
function LinkState:updateTrade(input)
|
||||
if self.game.linkNet == self.net then self.game.linkNet = nil end
|
||||
for _, msg in ipairs(self.net:poll()) do
|
||||
local reply = self.trade:handle(msg)
|
||||
if reply then self.net:send(reply) end
|
||||
@@ -591,10 +603,51 @@ function LinkState:updateTrade(input)
|
||||
return
|
||||
end
|
||||
|
||||
if t.stage == "picking" and input:wasPressed("up") then
|
||||
self.index = math.max(1, self.index - 1)
|
||||
-- pokered engine/link/cable_club.asm TradeCenter_SelectMon: A on one of
|
||||
-- your own mons opens the "STATS TRADE" row (.displayStatsTradeMenu)
|
||||
-- and only TRADE commits the pick, while the enemy list carries its own
|
||||
-- cursor whose A shows that mon's status pages (.displayEnemyMonStats).
|
||||
-- The cart's enemy path sets hl but never wMonDataLocation, the way the
|
||||
-- battle menu's STATS (engine/battle/core.asm) does, so LoadMonData_ reads
|
||||
-- the player's party and it draws YOUR mon at that slot -- an omission,
|
||||
-- not behaviour, so we show the peer's mon.
|
||||
if t.stage == "picking" and self.pickChoice then
|
||||
if input:wasPressed("left") then
|
||||
self.pickChoice = 1
|
||||
elseif input:wasPressed("right") then
|
||||
self.pickChoice = 2
|
||||
elseif input:wasPressed("b") then
|
||||
self.pickChoice = nil -- .cancelPlayerMonChoice: back to the list, not
|
||||
-- out of the trade
|
||||
elseif input:wasPressed("a") then
|
||||
if self.pickChoice == 1 then
|
||||
self.pickChoice = nil
|
||||
self:openStats(self.game.save.party[self.index])
|
||||
elseif t:canPick(self.index) then
|
||||
self.pickChoice = nil
|
||||
self.side = "mine"
|
||||
self.net:send(t:pick(self.index))
|
||||
end
|
||||
end
|
||||
elseif t.stage == "picking" and input:wasPressed("up") then
|
||||
if self.side == "theirs" then
|
||||
self.theirIndex = math.max(1, self.theirIndex - 1)
|
||||
else
|
||||
self.index = math.max(1, self.index - 1)
|
||||
end
|
||||
elseif t.stage == "picking" and input:wasPressed("down") then
|
||||
self.index = math.min(#self.game.save.party, self.index + 1)
|
||||
if self.side == "theirs" then
|
||||
self.theirIndex = math.min(#(t.theirParty or {}), self.theirIndex + 1)
|
||||
else
|
||||
self.index = math.min(#self.game.save.party, self.index + 1)
|
||||
end
|
||||
elseif t.stage == "picking" and input:wasPressed("right") then
|
||||
if t.theirParty and #t.theirParty > 0 then
|
||||
self.side = "theirs"
|
||||
self.theirIndex = math.min(self.theirIndex, #t.theirParty)
|
||||
end
|
||||
elseif t.stage == "picking" and input:wasPressed("left") then
|
||||
self.side = "mine"
|
||||
elseif self.confirmed == nil and input:wasPressed("b") then
|
||||
-- once confirm=true has been sent to the peer, backing out here
|
||||
-- would desync the two sides (the peer may already be committing
|
||||
@@ -603,8 +656,10 @@ function LinkState:updateTrade(input)
|
||||
self.net:send({ type = "bye" })
|
||||
self:exitWith(Strings("The trade was\ncancelled."))
|
||||
elseif t.stage == "picking" and input:wasPressed("a") then
|
||||
if t:canPick(self.index) then
|
||||
self.net:send(t:pick(self.index))
|
||||
if self.side == "theirs" then
|
||||
self:openStats((t.theirParty or {})[self.theirIndex])
|
||||
else
|
||||
self.pickChoice = 1
|
||||
end
|
||||
elseif t.stage == "confirming" and self.confirmed == nil then
|
||||
if input:wasPressed("a") then
|
||||
@@ -738,25 +793,40 @@ function LinkState:draw()
|
||||
local label = (mon.nickname or def.name):sub(1, 8)
|
||||
if not t:canPick(i) then label = label .. "X" end
|
||||
Font.draw(label, 16, 20 + i * 12)
|
||||
if i == self.index then Font.drawCode(CURSOR, 8, 20 + i * 12) end
|
||||
if i == self.index and self.side ~= "theirs" then
|
||||
Font.drawCode(CURSOR, 8, 20 + i * 12)
|
||||
end
|
||||
end
|
||||
Font.draw(Strings("THEIRS"), 84, 20)
|
||||
for i, mon in ipairs(t.theirParty or {}) do
|
||||
local def = self.game.data.pokemon[mon.species]
|
||||
Font.draw((mon.nickname or def.name):sub(1, 8), 92, 20 + i * 12)
|
||||
if t.theirPick == i then Font.drawCode(CURSOR, 84, 20 + i * 12) end
|
||||
if self.side == "theirs" and i == self.theirIndex then
|
||||
Font.drawCode(CURSOR, 84, 20 + i * 12)
|
||||
elseif t.theirPick == i then
|
||||
Font.drawCode(CURSOR_HOLLOW, 84, 20 + i * 12)
|
||||
end
|
||||
end
|
||||
local hint
|
||||
if t.stage == "waitRecords" then hint = "Comparing games..."
|
||||
elseif t.stage == "waitParty" then hint = "Exchanging data..."
|
||||
elseif t.stage == "picking" then
|
||||
hint = t:canPick(self.index) and "Pick one to trade"
|
||||
or Strings("X: not on theirs")
|
||||
elseif t.stage == "waitPick" then hint = "Waiting for them..."
|
||||
elseif t.stage == "confirming" then
|
||||
hint = self.confirmed and "Waiting..." or Strings("A: trade B: cancel")
|
||||
if self.pickChoice then
|
||||
Font.draw(Strings("STATS"), 16, 128)
|
||||
Font.draw(Strings("TRADE"), 96, 128)
|
||||
Font.drawCode(CURSOR, self.pickChoice == 1 and 8 or 88, 128)
|
||||
else
|
||||
local hint
|
||||
if t.stage == "waitRecords" then hint = "Comparing games..."
|
||||
elseif t.stage == "waitParty" then hint = "Exchanging data..."
|
||||
elseif t.stage == "picking" then
|
||||
if self.side == "theirs" then hint = Strings("A: stats")
|
||||
else
|
||||
hint = t:canPick(self.index) and "Pick one to trade"
|
||||
or Strings("X: not on theirs")
|
||||
end
|
||||
elseif t.stage == "waitPick" then hint = "Waiting for them..."
|
||||
elseif t.stage == "confirming" then
|
||||
hint = self.confirmed and "Waiting..." or Strings("A: trade B: cancel")
|
||||
end
|
||||
Font.draw(hint or "", 8, 132)
|
||||
end
|
||||
Font.draw(hint or "", 8, 132)
|
||||
|
||||
elseif self.stage == "battleWait" or self.stage == "battleRunning" then
|
||||
drawTitle("LINK BATTLE")
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
local Logger = require("src.core.Logger")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local SafePath = require("src.mods.SafePath")
|
||||
|
||||
local unpack = table.unpack or unpack
|
||||
local loadstring = loadstring or load
|
||||
@@ -31,25 +32,8 @@ AssetTransform.SOURCE_ROOT = SOURCE_ROOT
|
||||
AssetTransform.DERIVED_ROOT = DERIVED_ROOT
|
||||
|
||||
-- ------- path sandbox
|
||||
|
||||
-- a relative path that cannot climb out of the root it is joined to
|
||||
local function safeRelative(rel)
|
||||
if type(rel) ~= "string" or rel == "" then return nil end
|
||||
if rel:sub(1, 1) == "/" then return nil end
|
||||
if rel:find("\\", 1, true) then return nil end
|
||||
for segment in rel:gmatch("[^/]+") do
|
||||
if segment == ".." or segment == "." then return nil end
|
||||
end
|
||||
return rel
|
||||
end
|
||||
|
||||
local function requireRelative(rel, what)
|
||||
local safe = safeRelative(rel)
|
||||
if not safe then
|
||||
error(("%s must stay inside its root, got %q"):format(what, tostring(rel)), 0)
|
||||
end
|
||||
return safe
|
||||
end
|
||||
-- shared with mod:read and the manifest's own paths (src/mods/SafePath.lua)
|
||||
local requireRelative = SafePath.require
|
||||
|
||||
-- ------- the restricted context
|
||||
|
||||
|
||||
@@ -736,8 +736,9 @@ COVERAGE["src.pokemon.Boxes"] = {
|
||||
|
||||
COVERAGE["src.world.WorldAPI"] = {
|
||||
kind = "alias", target = "src.world.gen2.WorldAPI",
|
||||
backed = "new __index overworld current warpTo toggleObject replaceBlock "
|
||||
.. "spawnNpc removeNpc npc queueScript invalidateMap",
|
||||
backed = "new __index overworld current mapOverview warpTo toggleObject replaceBlock "
|
||||
.. "spawnNpc removeNpc npc queueScript invalidateMap "
|
||||
.. "availableFieldActions useFieldAction",
|
||||
warned = "setFlag getFlag",
|
||||
absent = "",
|
||||
notes = {
|
||||
@@ -1797,7 +1798,8 @@ local function buildBattleState()
|
||||
"throwBall", "ballChain", "tossAnimFor", "ballFlicker", "ballMissMessage",
|
||||
"storeCaughtMon", "safariAction", "safariEnemyTurn", "drawBallRow",
|
||||
"drawClassic", "isWideBattleLayout", "wideLayout", "bgMode", "uiSize",
|
||||
"sgbPalettes", "trainerPalette", "trainerPicPath", "invalidate",
|
||||
"sgbPalettes", "trainerPalette", "trainerPicPath", "trainerTrueColor",
|
||||
"trainerSprite", "invalidate",
|
||||
"imageBattleScale", "resolveBattleScale", "backPlacement",
|
||||
"frontPlacement", "StatBox", "enter", "exit",
|
||||
}) do
|
||||
@@ -1884,7 +1886,8 @@ COVERAGE["src.battle.BattleState"] = {
|
||||
absent = "newWild newTrainer makeSafari makeGhost makeBattler resolveTurn "
|
||||
.. "computeDamage catchAttempt runRoll enter exit sgbPalettes "
|
||||
.. "isWideBattleLayout wideLayout bgMode uiSize letterboxWhite "
|
||||
.. "holdsUIAnchors BG_WORLD_DIM trainerPalette trainerPicPath invalidate "
|
||||
.. "holdsUIAnchors BG_WORLD_DIM trainerPalette trainerPicPath "
|
||||
.. "trainerTrueColor trainerSprite invalidate "
|
||||
.. "backPlacement frontPlacement StatBox drawClassic drawBallRow "
|
||||
.. "safariAction safariEnemyTurn throwBall storeCaughtMon field ruleset "
|
||||
.. "rng oppClass partyIndex aiUses introText dead",
|
||||
|
||||
+460
-30
@@ -36,10 +36,14 @@ local ModTargets = require("src.mods.ModTargets")
|
||||
local Semver = require("src.mods.Semver")
|
||||
local Version = require("src.core.Version")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local CacheFs = require("src.import.CacheFs")
|
||||
local RequiredImports = require("src.mods.RequiredImports")
|
||||
|
||||
local LauncherMods = {}
|
||||
|
||||
local discover -- forward declaration for helper functions above line 343
|
||||
|
||||
-- ------- pure status derivation
|
||||
|
||||
-- A hard-dependency / conflict / version verdict for one manifest. mods is
|
||||
@@ -84,31 +88,196 @@ local function statusFor(mods, id, enabledSet, enabled, version, forcedFor)
|
||||
-- resolveToggle would cascade-enable a merely-disabled dep rather than flag
|
||||
-- it, so the disabled case is judged straight off the manifest here.
|
||||
for _, spec in ipairs(m.dependencySpecs or {}) do
|
||||
local dep = mods[spec.id]
|
||||
if not dep then
|
||||
return "warn", "Needs " .. spec.id .. " (not installed)"
|
||||
elseif not enabledSet[spec.id] then
|
||||
return "warn", "Needs " .. spec.id .. " (disabled)"
|
||||
-- installed and on, but not for THIS game: the loader skips the
|
||||
-- dependency and the skip is contagious (Loader:_enforceDependencies),
|
||||
-- so a mod that runs everywhere still does not run here
|
||||
elseif version
|
||||
and not ModTargets.runsHere(dep, version, nil, forcedFor(spec.id)) then
|
||||
return "warn", "Needs " .. spec.id .. " (not for "
|
||||
.. ModTargets.gameLabel(version) .. ")"
|
||||
elseif spec.range
|
||||
and not Semver.satisfies(dep.version, spec.range) then
|
||||
return "warn", "Needs " .. spec.id .. " " .. spec.range
|
||||
if ModTargets.specApplies(spec, version) then
|
||||
local dep = mods[spec.id]
|
||||
if not dep then
|
||||
return "warn", "Needs " .. spec.id .. " (not installed)"
|
||||
elseif not enabledSet[spec.id] then
|
||||
return "warn", "Needs " .. spec.id .. " (disabled)"
|
||||
-- installed and on, but not for THIS game: the loader skips the
|
||||
-- dependency and the skip is contagious (Loader:_enforceDependencies),
|
||||
-- so a mod that runs everywhere still does not run here
|
||||
elseif version
|
||||
and not ModTargets.runsHere(dep, version, nil, forcedFor(spec.id)) then
|
||||
return "warn", "Needs " .. spec.id .. " (not for "
|
||||
.. ModTargets.gameLabel(version) .. ")"
|
||||
elseif spec.range
|
||||
and not Semver.satisfies(dep.version, spec.range) then
|
||||
return "warn", "Needs " .. spec.id .. " " .. spec.range
|
||||
end
|
||||
end
|
||||
end
|
||||
return "ok", "Ready"
|
||||
end
|
||||
|
||||
-- Resolves the best-known GitHub owner/repo string for a dependency spec, if any.
|
||||
function LauncherMods.resolveDependencyRepo(depId, parentManifest, installedDep)
|
||||
if not depId or depId == "" then return nil end
|
||||
-- 1. Check spec hint if parentManifest dependencySpecs carries it
|
||||
if parentManifest and parentManifest.dependencySpecs then
|
||||
for _, spec in ipairs(parentManifest.dependencySpecs) do
|
||||
if spec.id == depId and spec.github then
|
||||
return spec.github
|
||||
end
|
||||
end
|
||||
end
|
||||
-- 2. Check parentManifest raw dependency_sources
|
||||
if parentManifest and parentManifest.raw and type(parentManifest.raw.dependency_sources) == "table" then
|
||||
local src = parentManifest.raw.dependency_sources[depId]
|
||||
if src then
|
||||
local ok, clean = pcall(Manifest.parseGithub, src)
|
||||
if ok and clean then return clean end
|
||||
end
|
||||
end
|
||||
-- 3. Check installed dependency manifest
|
||||
if installedDep and installedDep.github then
|
||||
return installedDep.github
|
||||
end
|
||||
-- 4. Check ModIndex entries if available
|
||||
local okModIndex, ModIndex = pcall(require, "src.mods.ModIndex")
|
||||
if okModIndex and ModIndex and type(ModIndex.sources) == "function" then
|
||||
for _, src in ipairs(ModIndex.sources() or {}) do
|
||||
local cached = ModIndex.readCache and ModIndex.readCache(src.feed)
|
||||
if cached and type(cached.index) == "table" then
|
||||
for _, entry in ipairs(cached.index) do
|
||||
if type(entry) == "table" and entry.id == depId and entry.github then
|
||||
local ok, clean = pcall(Manifest.parseGithub, entry.github)
|
||||
if ok and clean then return clean end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Inspect manifest dependencies and conflicts against installed mods.
|
||||
-- Returns: { hasIssues = bool, targetMod = {...}, deps = [ { id, name, range, status, kind, installedVersion, github, safeUrl }, ... ] }
|
||||
function LauncherMods.checkDependencies(manifest, options, version, installedManifests)
|
||||
if not manifest then
|
||||
return { hasIssues = false, targetMod = { name = "Unknown" }, deps = {} }
|
||||
end
|
||||
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local manifests = installedManifests or discover()
|
||||
local installedMap = {}
|
||||
for _, m in ipairs(manifests) do
|
||||
installedMap[m.id] = m
|
||||
end
|
||||
|
||||
local depsResult = {}
|
||||
local hasIssues = false
|
||||
|
||||
-- 1. Hard Dependencies (dependencySpecs)
|
||||
if type(manifest.dependencySpecs) == "table" then
|
||||
for _, spec in ipairs(manifest.dependencySpecs) do
|
||||
if not version or ModTargets.specApplies(spec, version) then
|
||||
local depId = spec.id
|
||||
local range = spec.range
|
||||
local installedDep = installedMap[depId]
|
||||
local status = "satisfied"
|
||||
local installedVersion = installedDep and installedDep.version or nil
|
||||
|
||||
if not installedDep then
|
||||
status = "missing"
|
||||
hasIssues = true
|
||||
elseif range and not Semver.satisfies(installedDep.version, range) then
|
||||
status = "incompatible"
|
||||
hasIssues = true
|
||||
end
|
||||
|
||||
local ghRepo = LauncherMods.resolveDependencyRepo(depId, manifest, installedDep)
|
||||
local safeUrl = ghRepo and ("https://github.com/" .. ghRepo) or nil
|
||||
|
||||
depsResult[#depsResult + 1] = {
|
||||
id = depId,
|
||||
name = (installedDep and installedDep.name) or depId,
|
||||
range = range,
|
||||
status = status,
|
||||
kind = "dependency",
|
||||
installedVersion = installedVersion,
|
||||
github = ghRepo,
|
||||
safeUrl = safeUrl,
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- 2. Conflicts / Incompatible Mods (conflictSpecs)
|
||||
local conflictIdsSeen = {}
|
||||
local scope = SaveData.modScope and SaveData.modScope(version) or version
|
||||
|
||||
local isEnabled = function(modId)
|
||||
if not options then return true end
|
||||
local dec = SaveData.modEnabled(options, modId, scope)
|
||||
if dec ~= nil then return dec == true end
|
||||
local m = installedMap[modId]
|
||||
return m and not m.experimental
|
||||
end
|
||||
|
||||
-- (a) Conflicts declared by target manifest
|
||||
if type(manifest.conflictSpecs) == "table" then
|
||||
for _, spec in ipairs(manifest.conflictSpecs) do
|
||||
local conflictId = spec.id
|
||||
local installedOther = installedMap[conflictId]
|
||||
if installedOther and isEnabled(conflictId) and not conflictIdsSeen[conflictId] then
|
||||
conflictIdsSeen[conflictId] = true
|
||||
hasIssues = true
|
||||
depsResult[#depsResult + 1] = {
|
||||
id = conflictId,
|
||||
name = installedOther.name or conflictId,
|
||||
status = "conflict",
|
||||
kind = "conflict",
|
||||
installedVersion = installedOther.version or "?",
|
||||
github = nil,
|
||||
safeUrl = nil,
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- (b) Reverse conflicts declared by installed mods against target manifest
|
||||
if manifest.id then
|
||||
for _, other in ipairs(manifests) do
|
||||
if other.id ~= manifest.id and isEnabled(other.id) and not conflictIdsSeen[other.id] then
|
||||
local conflicts = other.conflictSpecs or {}
|
||||
for _, spec in ipairs(conflicts) do
|
||||
if spec.id == manifest.id then
|
||||
conflictIdsSeen[other.id] = true
|
||||
hasIssues = true
|
||||
depsResult[#depsResult + 1] = {
|
||||
id = other.id,
|
||||
name = other.name or other.id,
|
||||
status = "conflict",
|
||||
kind = "conflict",
|
||||
installedVersion = other.version or "?",
|
||||
github = nil,
|
||||
safeUrl = nil,
|
||||
}
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return {
|
||||
hasIssues = hasIssues,
|
||||
targetMod = {
|
||||
id = manifest.id,
|
||||
name = manifest.name or manifest.id,
|
||||
version = manifest.version or "?",
|
||||
},
|
||||
deps = depsResult,
|
||||
}
|
||||
end
|
||||
|
||||
-- deriveList(manifests, options [, version]) -> the panel row list, pure.
|
||||
-- manifests is an array of validated manifests (Manifest.validate output);
|
||||
-- options is the options table (options.mods, options.modsByVersion and
|
||||
-- options.modsGen2 are read). `version` is the game the panel is showing:
|
||||
-- nil keeps the pre-per-game view, where the shared flag is the whole answer.
|
||||
-- options.modsGen2 are read). `version` is the game the panel is showing;
|
||||
-- each row also carries its answer for every game so the launcher can render
|
||||
-- the coloured game checkboxes together.
|
||||
-- Rows come back sorted by id so the panel order is stable.
|
||||
function LauncherMods.deriveList(manifests, options, version)
|
||||
local ordered = {}
|
||||
@@ -126,8 +295,7 @@ function LauncherMods.deriveList(manifests, options, version)
|
||||
-- this game's choice, then the shared flag, then the default: enabled,
|
||||
-- matching the loader -- except experimental mods, which stay off until
|
||||
-- the player opts in. Scoped through modScope, so this reads exactly what
|
||||
-- setEnabled writes and the loader loads: while per-game flags are a
|
||||
-- preview the shared flag is the whole answer on every surface.
|
||||
-- setEnabled writes and the loader loads for the selected game.
|
||||
local decided = SaveData.modEnabled(options, m.id, SaveData.modScope(version))
|
||||
if decided == nil then decided = not m.experimental end
|
||||
if decided then enabledSet[m.id] = true end
|
||||
@@ -152,10 +320,20 @@ function LauncherMods.deriveList(manifests, options, version)
|
||||
badge = badge,
|
||||
description = m.description or "",
|
||||
enabled = enabled,
|
||||
enabledByVersion = (function()
|
||||
local answers = {}
|
||||
for _, game in ipairs(GameVersion.ORDER) do
|
||||
local answer = SaveData.modEnabled(options, m.id, game)
|
||||
answers[game] = answer == true or (answer == nil and not m.experimental)
|
||||
end
|
||||
return answers
|
||||
end)(),
|
||||
status = status,
|
||||
statusDetail = detail,
|
||||
github = m.github,
|
||||
experimental = m.experimental == true,
|
||||
dependencySpecs = m.dependencySpecs,
|
||||
manifest = m,
|
||||
-- what game this mod is for, and whether it will run on the one the
|
||||
-- panel is showing (src/mods/ModTargets.lua)
|
||||
targets = ModTargets.chip(m),
|
||||
@@ -243,7 +421,7 @@ end
|
||||
-- Scan "mods/" one level deep for valid manifests (mirrors Loader:_discover,
|
||||
-- but validates only -- no entry chunk is ever loaded). First id wins on a
|
||||
-- duplicate. Returns an array of validated manifests.
|
||||
local function discover()
|
||||
discover = function()
|
||||
local fs = love and love.filesystem
|
||||
local out = {}
|
||||
if not (fs and fs.getInfo and fs.getDirectoryItems) then return out end
|
||||
@@ -281,7 +459,39 @@ end
|
||||
function LauncherMods.list(version)
|
||||
local ok, result = pcall(function()
|
||||
local options = SaveData.loadOptions()
|
||||
return LauncherMods.deriveList(discover(), options, version)
|
||||
local manifests = discover()
|
||||
-- Imports are private player grants. Never scan or copy another mod's
|
||||
-- baseroms: a matching public digest is not permission to share the file.
|
||||
local importState = {}
|
||||
for _, manifest in ipairs(manifests) do
|
||||
local rows, missing, missingOptional = RequiredImports.inspect(manifest)
|
||||
importState[manifest.id] = { rows = rows, missing = missing,
|
||||
missingOptional = missingOptional }
|
||||
end
|
||||
-- The first build containing game-specific switches turns the old shared
|
||||
-- state into one explicit answer per installed mod and game. Saving here
|
||||
-- means users who only visit the launcher still receive the migration.
|
||||
if SaveData.migrateModEnablement(options, manifests) then
|
||||
SaveData.saveOptions(options)
|
||||
end
|
||||
local rows = LauncherMods.deriveList(manifests, options, version)
|
||||
for _, row in ipairs(rows) do
|
||||
local state = importState[row.id]
|
||||
or { rows = {}, missing = 0, missingOptional = 0 }
|
||||
local imports, missing = state.rows, state.missing
|
||||
row.requiredImports, row.missingRequiredImports = imports, missing
|
||||
row.imports = imports
|
||||
row.missingOptionalImports = state.missingOptional or 0
|
||||
if missing > 0 and row.status == "ok" then
|
||||
row.status = "needs_import"
|
||||
local first
|
||||
for _, import in ipairs(imports) do
|
||||
if not import.present then first = import break end
|
||||
end
|
||||
row.statusDetail = "Needs import: " .. (first and first.name or "required file")
|
||||
end
|
||||
end
|
||||
return rows
|
||||
end)
|
||||
if not ok then
|
||||
-- a single bad options/mod file must not blank the launcher
|
||||
@@ -364,14 +574,13 @@ function LauncherMods.translationStrings()
|
||||
return merged
|
||||
end
|
||||
|
||||
-- setEnabled(id, enabled [, version]): persist options.mods[id] in the exact
|
||||
-- shape Loader:_saveState writes (a plain boolean), so the running game and
|
||||
-- the in-game ManagerState pick it up unchanged. With `version` the choice
|
||||
-- lands in that game's overlay instead and no other game moves.
|
||||
-- setEnabled(id, enabled [, version]): with a game, persist just that game's
|
||||
-- answer. The loader and the in-game manager use the same scope on next boot.
|
||||
function LauncherMods.setEnabled(id, enabled, version)
|
||||
local options = SaveData.loadOptions()
|
||||
SaveData.setModEnabled(options, id, enabled, SaveData.modScope(version))
|
||||
SaveData.saveOptions(options)
|
||||
LauncherMods.syncActiveProfile(options)
|
||||
return true
|
||||
end
|
||||
|
||||
@@ -384,9 +593,18 @@ function LauncherMods.setAllEnabled(ids, enabled, version)
|
||||
local options = SaveData.loadOptions()
|
||||
local scope = SaveData.modScope(version)
|
||||
for _, id in ipairs(ids or {}) do
|
||||
SaveData.setModEnabled(options, id, enabled, scope)
|
||||
if scope then
|
||||
SaveData.setModEnabled(options, id, enabled, scope)
|
||||
elseif SaveData.PER_VERSION_MODS then
|
||||
for _, game in ipairs(GameVersion.ORDER) do
|
||||
SaveData.setModEnabled(options, id, enabled, game)
|
||||
end
|
||||
else
|
||||
SaveData.setModEnabled(options, id, enabled)
|
||||
end
|
||||
end
|
||||
SaveData.saveOptions(options)
|
||||
LauncherMods.syncActiveProfile(options)
|
||||
return true
|
||||
end
|
||||
|
||||
@@ -485,6 +703,26 @@ local function copyTree(src, dst)
|
||||
return true
|
||||
end
|
||||
|
||||
-- User-supplied baseroms are install state, not package content. Snapshot
|
||||
-- them before replacing a mod tree so an update cannot make the player select
|
||||
-- the same cartridge again (or destroy their only reusable copy if the new
|
||||
-- archive later fails to copy).
|
||||
local function snapshotTree(path, into, relative)
|
||||
local fs = love.filesystem
|
||||
into, relative = into or {}, relative or ""
|
||||
local info = fs.getInfo(path)
|
||||
if not info then return into end
|
||||
if info.type == "directory" then
|
||||
for _, name in ipairs(fs.getDirectoryItems(path) or {}) do
|
||||
local rel = relative == "" and name or (relative .. "/" .. name)
|
||||
snapshotTree(path .. "/" .. name, into, rel)
|
||||
end
|
||||
elseif relative ~= "" and into[relative] == nil then
|
||||
into[relative] = fs.read(path)
|
||||
end
|
||||
return into
|
||||
end
|
||||
|
||||
-- Delete an installed mod subtree. Enumeration stays on love.filesystem (the
|
||||
-- portable game folder is on its read path), but the deletes go through
|
||||
-- CacheFs so a portable install's real files actually go away instead of
|
||||
@@ -652,9 +890,9 @@ function LauncherMods.adoptStrays() return scanStrays(true) end
|
||||
-- opts.replace = true uninstalls an existing same-id mod first (updates /
|
||||
-- rollbacks). opts.expectId, when set, refuses a zip whose manifest id differs.
|
||||
function LauncherMods.installZip(source, opts)
|
||||
local ok, result, err = pcall(LauncherMods._installZipInner, source, opts)
|
||||
local ok, result, res2, res3 = pcall(LauncherMods._installZipInner, source, opts)
|
||||
if not ok then return nil, "import failed: " .. tostring(result) end
|
||||
return result, err
|
||||
return result, res2, res3
|
||||
end
|
||||
|
||||
function LauncherMods._installZipInner(source, opts)
|
||||
@@ -727,14 +965,28 @@ function LauncherMods._installZipInner(source, opts)
|
||||
return nil, ("zip is for '%s', expected '%s'")
|
||||
:format(manifest.id, opts.expectId)
|
||||
end
|
||||
local packagedBaseroms = root .. "/baseroms"
|
||||
if fs.getInfo(packagedBaseroms, "directory")
|
||||
and #(fs.getDirectoryItems(packagedBaseroms) or {}) > 0 then
|
||||
cleanup()
|
||||
return nil, "mod archives must not include user-supplied baseroms/ files"
|
||||
end
|
||||
|
||||
local dest = "mods/" .. manifest.id
|
||||
local baseromRecovery = "imports/baseroms-recovery/" .. manifest.id
|
||||
local existing, installedSomewhere = sameIdTrees(fs, manifest.id)
|
||||
if installedSomewhere and not opts.replace then
|
||||
cleanup()
|
||||
return nil, "a mod named '" .. manifest.id .. "' is already installed"
|
||||
end
|
||||
local preservedBaseroms = {}
|
||||
-- A previous failed update may have staged the user's files outside mods/ so
|
||||
-- discovery cannot mistake recovery debris for an installed mod.
|
||||
snapshotTree(baseromRecovery, preservedBaseroms)
|
||||
if #existing > 0 then
|
||||
for _, path in ipairs(existing) do
|
||||
snapshotTree(path .. "/baseroms", preservedBaseroms)
|
||||
end
|
||||
-- drop every old tree before copy -- mods/<id> and any same-id folder
|
||||
-- under another name, or the survivor keeps winning discover()'s
|
||||
-- first-id-wins race after the "successful" update (#801). A tree with
|
||||
@@ -758,13 +1010,35 @@ function LauncherMods._installZipInner(source, opts)
|
||||
CacheFs.prefix = ""
|
||||
local copied, copyErr = copyTree(root, dest)
|
||||
if not copied then removeTree(dest) end
|
||||
local preserveErr
|
||||
for rel, bytes in pairs(preservedBaseroms) do
|
||||
if bytes ~= nil then
|
||||
local restored, restoreErr = CacheFs.write(dest .. "/baseroms/" .. rel, bytes)
|
||||
if not restored and not preserveErr then
|
||||
preserveErr = "could not preserve baseroms/" .. rel .. ": "
|
||||
.. tostring(restoreErr)
|
||||
end
|
||||
end
|
||||
end
|
||||
if preserveErr then
|
||||
-- Do not report a successful update that discarded user-owned input, and
|
||||
-- do not leave a manifest-less baseroms tree that resembles an install.
|
||||
removeTree(dest)
|
||||
removeTree(baseromRecovery)
|
||||
for rel, bytes in pairs(preservedBaseroms) do
|
||||
if bytes ~= nil then CacheFs.write(baseromRecovery .. "/" .. rel, bytes) end
|
||||
end
|
||||
copied, copyErr = nil, preserveErr
|
||||
elseif copied then
|
||||
removeTree(baseromRecovery)
|
||||
end
|
||||
CacheFs.prefix = savedPrefix
|
||||
if not copied then
|
||||
cleanup()
|
||||
return nil, copyErr or "could not copy the mod files"
|
||||
end
|
||||
cleanup()
|
||||
return true, manifest.id
|
||||
return true, manifest.id, manifest
|
||||
end
|
||||
|
||||
-- Install (or replace) a mod from a GitHub release zip URL.
|
||||
@@ -843,7 +1117,7 @@ end
|
||||
|
||||
-- uninstall(id) -> true | nil, errString
|
||||
-- Removes mods/<id>/ from wherever it was installed (the portable game folder
|
||||
-- or the save directory, CacheFs decides -- #330) and clears options.mods[id]
|
||||
-- or the save directory, CacheFs decides -- #330) and clears every enable flag
|
||||
-- so the loader and in-game manager no longer see it. Rejects missing ids.
|
||||
-- Does not touch other mods' enable state.
|
||||
function LauncherMods.uninstall(id)
|
||||
@@ -872,8 +1146,164 @@ function LauncherMods.uninstall(id)
|
||||
-- Drop the enable flag so a reinstall of the same id starts from the
|
||||
-- loader's default (enabled) rather than a stale false.
|
||||
local options = SaveData.loadOptions()
|
||||
local changed = false
|
||||
if options.mods and options.mods[id] ~= nil then
|
||||
options.mods[id] = nil
|
||||
changed = true
|
||||
end
|
||||
for _, version in ipairs(GameVersion.ORDER) do
|
||||
local bucket = options.modsByVersion and options.modsByVersion[version]
|
||||
if type(bucket) == "table" and bucket[id] ~= nil then
|
||||
bucket[id] = nil
|
||||
changed = true
|
||||
end
|
||||
end
|
||||
if changed then
|
||||
SaveData.saveOptions(options)
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------- Mod Profiles (#593)
|
||||
local ModProfile = require("src.mods.ModProfile")
|
||||
|
||||
function LauncherMods.getProfiles(options)
|
||||
options = options or SaveData.loadOptions()
|
||||
local manifests = discover()
|
||||
ModProfile.ensureFirst(options, manifests, options.modOptions)
|
||||
return options.modProfiles or {}, options.activeProfile or "PROFILE 1"
|
||||
end
|
||||
|
||||
function LauncherMods.applyProfile(profileName, options)
|
||||
options = options or SaveData.loadOptions()
|
||||
local profiles = options.modProfiles or {}
|
||||
local targetProfile
|
||||
for _, p in ipairs(profiles) do
|
||||
if p.name == profileName then targetProfile = p; break end
|
||||
end
|
||||
if not targetProfile then return false end
|
||||
ModProfile.restoreVersions(targetProfile, options)
|
||||
options.activeProfile = profileName
|
||||
SaveData.saveOptions(options)
|
||||
return true
|
||||
end
|
||||
|
||||
function LauncherMods.saveProfile(profileName, options)
|
||||
options = options or SaveData.loadOptions()
|
||||
local manifests = discover()
|
||||
options.modProfiles = options.modProfiles or {}
|
||||
local snap = ModProfile.capture(manifests, options.modOptions, options.modsByVersion)
|
||||
snap.name = profileName
|
||||
local existingIdx
|
||||
for i, p in ipairs(options.modProfiles) do
|
||||
if p.name == profileName then existingIdx = i; break end
|
||||
end
|
||||
if existingIdx then
|
||||
options.modProfiles[existingIdx] = snap
|
||||
else
|
||||
options.modProfiles[#options.modProfiles + 1] = snap
|
||||
end
|
||||
options.activeProfile = profileName
|
||||
SaveData.saveOptions(options)
|
||||
return snap
|
||||
end
|
||||
|
||||
local function copyTable(tbl)
|
||||
if type(tbl) ~= "table" then return tbl end
|
||||
local copy = {}
|
||||
for k, v in pairs(tbl) do
|
||||
copy[k] = type(v) == "table" and copyTable(v) or v
|
||||
end
|
||||
return copy
|
||||
end
|
||||
|
||||
function LauncherMods.syncActiveProfile(options)
|
||||
options = options or SaveData.loadOptions()
|
||||
local activeName = options.activeProfile or "PROFILE 1"
|
||||
local profiles = options.modProfiles or {}
|
||||
local manifests = discover()
|
||||
local snap = ModProfile.capture(manifests, options.modOptions, options.modsByVersion)
|
||||
snap.name = activeName
|
||||
|
||||
local found = false
|
||||
for i, p in ipairs(profiles) do
|
||||
if p.name == activeName then
|
||||
profiles[i] = snap
|
||||
found = true
|
||||
break
|
||||
end
|
||||
end
|
||||
if not found then
|
||||
profiles[#profiles + 1] = snap
|
||||
end
|
||||
options.modProfiles = profiles
|
||||
options.activeProfile = activeName
|
||||
SaveData.saveOptions(options)
|
||||
return snap
|
||||
end
|
||||
|
||||
function LauncherMods.duplicateProfile(sourceName, options)
|
||||
options = options or SaveData.loadOptions()
|
||||
local profiles = options.modProfiles or {}
|
||||
local sourceProfile
|
||||
for _, p in ipairs(profiles) do
|
||||
if p.name == sourceName then sourceProfile = p; break end
|
||||
end
|
||||
if not sourceProfile then return nil end
|
||||
|
||||
local baseName = sourceName .. " (Copy)"
|
||||
local newName = baseName
|
||||
local n = 1
|
||||
local taken = {}
|
||||
for _, p in ipairs(profiles) do taken[p.name] = true end
|
||||
while taken[newName] do
|
||||
n = n + 1
|
||||
newName = sourceName .. " (" .. n .. ")"
|
||||
end
|
||||
|
||||
local snap = {
|
||||
name = newName,
|
||||
enabled = copyTable(sourceProfile.enabled),
|
||||
options = copyTable(sourceProfile.options),
|
||||
slots = copyTable(sourceProfile.slots),
|
||||
enabledByVersion = copyTable(sourceProfile.enabledByVersion),
|
||||
}
|
||||
profiles[#profiles + 1] = snap
|
||||
options.modProfiles = profiles
|
||||
options.activeProfile = newName
|
||||
SaveData.saveOptions(options)
|
||||
return snap
|
||||
end
|
||||
|
||||
function LauncherMods.renameProfile(oldName, newName, options)
|
||||
options = options or SaveData.loadOptions()
|
||||
if not newName or newName == "" then return false end
|
||||
local profiles = options.modProfiles or {}
|
||||
for i, p in ipairs(profiles) do
|
||||
if p.name == oldName then
|
||||
p.name = newName
|
||||
if options.activeProfile == oldName then
|
||||
options.activeProfile = newName
|
||||
end
|
||||
SaveData.saveOptions(options)
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
function LauncherMods.deleteProfile(profileName, options)
|
||||
options = options or SaveData.loadOptions()
|
||||
options.modProfiles = options.modProfiles or {}
|
||||
local newProfiles = {}
|
||||
for _, p in ipairs(options.modProfiles) do
|
||||
if p.name ~= profileName then newProfiles[#newProfiles + 1] = p end
|
||||
end
|
||||
options.modProfiles = newProfiles
|
||||
if options.activeProfile == profileName then
|
||||
local fallback = newProfiles[1] and newProfiles[1].name or "PROFILE 1"
|
||||
LauncherMods.applyProfile(fallback, options)
|
||||
else
|
||||
SaveData.saveOptions(options)
|
||||
end
|
||||
return true
|
||||
|
||||
+233
-68
@@ -4,6 +4,7 @@ local SaveData = require("src.core.SaveData")
|
||||
local Data = require("src.core.Data")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Version = require("src.core.Version")
|
||||
local RequiredImports = require("src.mods.RequiredImports")
|
||||
local Assets = require("src.render.Assets")
|
||||
local ModUI = require("src.ui.ModUI")
|
||||
local DateTime = require("src.core.DateTime")
|
||||
@@ -12,12 +13,15 @@ local Manifest = require("src.mods.Manifest")
|
||||
local Merge = require("src.mods.Merge")
|
||||
local ModTargets = require("src.mods.ModTargets")
|
||||
local Registry = require("src.mods.Registry")
|
||||
local SafePath = require("src.mods.SafePath")
|
||||
local Sandbox = require("src.mods.Sandbox")
|
||||
local Schemas = require("src.mods.Schemas")
|
||||
local Semver = require("src.mods.Semver")
|
||||
local Events = require("src.mods.Events")
|
||||
local Gen2Compat = require("src.mods.Gen2Compat")
|
||||
local Hooks = require("src.mods.Hooks")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Steps = require("src.mods.Steps")
|
||||
|
||||
local Loader = {}
|
||||
Loader.__index = Loader
|
||||
@@ -72,10 +76,12 @@ local function orderedIds(mods, filter)
|
||||
return ids
|
||||
end
|
||||
|
||||
-- ------- dev-mode permissions tripwire
|
||||
-- Attribution only: the shim delegates unconditionally and blocks nothing.
|
||||
-- Installed once per process and only when the loader runs in dev mode, so a
|
||||
-- player build has zero interposition.
|
||||
-- ------- the require gate
|
||||
-- Two jobs in one interposition. The engine_internals/network scan is
|
||||
-- attribution only and stays dev-mode: it warns and delegates. The
|
||||
-- Sandbox.moduleDenial check is not -- require("io") would hand back
|
||||
-- package.loaded.io and undo the whole mod environment -- so it is installed
|
||||
-- in player builds too, for any boot that has mods on it.
|
||||
|
||||
local devShim = { installed = false, permissions = {}, warned = {}, depth = 0 }
|
||||
|
||||
@@ -135,7 +141,8 @@ end
|
||||
|
||||
local function scanRequire(name)
|
||||
local modId = Runtime.currentMod
|
||||
if not modId or type(name) ~= "string" then return end
|
||||
if type(modId) ~= "string" then modId = Runtime.modRequire end
|
||||
if type(modId) ~= "string" or type(name) ~= "string" then return end
|
||||
local granted = devShim.permissions[modId] or {}
|
||||
local function warnOnce(permission)
|
||||
local key = modId .. "|" .. permission .. "|" .. name
|
||||
@@ -188,6 +195,7 @@ function Loader:_installDevShim()
|
||||
for id, mod in pairs(self.mods) do
|
||||
devShim.permissions[id] = mod.manifest.permissionSet
|
||||
end
|
||||
devShim.dev = self.dev
|
||||
if devShim.installed then return end
|
||||
devShim.installed = true
|
||||
local delegate = require
|
||||
@@ -195,12 +203,22 @@ function Loader:_installDevShim()
|
||||
-- only the mod's own call is the mod's doing; whatever that module
|
||||
-- requires in turn is the engine wiring itself up
|
||||
if devShim.depth == 0 then
|
||||
scanRequire(name)
|
||||
-- Backstop for the deny list Sandbox.envFor's require already applies:
|
||||
-- an engine module requiring io is the engine wiring itself up, a mod
|
||||
-- doing it is the hole this closes, and any future path that runs mod
|
||||
-- code without a sandbox env still lands here.
|
||||
local owner = Runtime.currentMod or Runtime.modRequire
|
||||
if owner or callerIsMod(3) then
|
||||
local id = type(owner) == "string" and owner or nil
|
||||
local denial = Sandbox.moduleDenial(name, devShim.permissions[id])
|
||||
if denial then error(("[%s] %s"):format(id or "mod", denial), 0) end
|
||||
end
|
||||
if devShim.dev or devShim.generation ~= 1 then scanRequire(name) end
|
||||
-- The Gen 1 name a mod asked for, answered by the Gen 2 arm behind it.
|
||||
-- Engine code keeps the real module: src/render/PaletteFX.lua:776
|
||||
-- requires src.core.Game on both generations and means it.
|
||||
if devShim.generation ~= 1 and Gen2Compat.serves(name)
|
||||
and callerIsMod(3) then
|
||||
and (owner or callerIsMod(3)) then
|
||||
local adapter = Gen2Compat.resolve(name, Runtime.currentMod)
|
||||
if adapter then
|
||||
local key = "adapter|" .. name
|
||||
@@ -235,7 +253,7 @@ function Loader.new(opts)
|
||||
events = Events.new(), hooks = Hooks.new(), content = {}, assets = {},
|
||||
exports = {}, migrations = {}, order = {},
|
||||
modSave = {}, modOptions = {}, optionSchemas = {}, imageCache = {},
|
||||
modInput = {},
|
||||
modInput = {}, modEnv = {}, stepsQueues = {},
|
||||
fs = (opts and opts.fs) or (love and love.filesystem),
|
||||
dev = dev,
|
||||
-- Which generation this boot is (1 or 2). Fixed at construction: the
|
||||
@@ -270,8 +288,7 @@ function Loader:_targetVersion()
|
||||
return version
|
||||
end
|
||||
|
||||
-- The version an enable flag is read and written under: this game once
|
||||
-- per-game flags are live, nil (the shared flag) while they are a preview.
|
||||
-- The version an enable flag is read and written under: this running game.
|
||||
-- Reads and writes go through the same answer so the two can never drift.
|
||||
function Loader:_enableScope()
|
||||
return SaveData.modScope(self:_targetVersion())
|
||||
@@ -358,11 +375,13 @@ function Loader:_writeOptionSchemas()
|
||||
-- demand; using it here means older mods do not need to migrate to
|
||||
-- mod.options:define just to appear in a launcher settings screen.
|
||||
if schema == nil and mod.manifest.options_schema and self.fs.load then
|
||||
local chunk = self.fs.load(mod.path .. "/" .. mod.manifest.options_schema)
|
||||
if chunk then
|
||||
local ok, rows = pcall(chunk)
|
||||
if ok and type(rows) == "table" then schema = rows end
|
||||
end
|
||||
local ok, rows = pcall(function()
|
||||
local path = SafePath.join(mod.path, mod.manifest.options_schema,
|
||||
"options_schema")
|
||||
local chunk = Sandbox.loadFile(self.fs, path, self:_modEnv(mod))
|
||||
return chunk and chunk()
|
||||
end)
|
||||
if ok and type(rows) == "table" then schema = rows end
|
||||
end
|
||||
if schema ~= nil then
|
||||
mods[id] = schema
|
||||
@@ -547,7 +566,24 @@ function Loader:_validate()
|
||||
elseif manifest.assets_transforms
|
||||
and not self:_exists(mod.path .. "/" .. manifest.assets_transforms) then
|
||||
reason = "assets_transforms file missing: " .. manifest.assets_transforms
|
||||
elseif manifest.game_version and not devEngine() then
|
||||
end
|
||||
if not reason and #(manifest.required_imports or {}) > 0 then
|
||||
for _, import in ipairs(manifest.required_imports) do
|
||||
local path = mod.path .. "/baseroms/" .. import.file
|
||||
if not self:_exists(path) then
|
||||
reason = "required import missing: " .. import.name
|
||||
break
|
||||
end
|
||||
local valid, importErr = RequiredImports.validateStored(
|
||||
manifest, import, self.fs)
|
||||
if not valid then
|
||||
reason = "required import invalid: " .. import.name
|
||||
.. " (" .. tostring(importErr) .. ")"
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
if not reason and manifest.game_version and not devEngine() then
|
||||
local ok, err = Semver.satisfies(Version.engine, manifest.game_version)
|
||||
if not ok then
|
||||
reason = ("needs game version %s, engine is %s")
|
||||
@@ -562,42 +598,46 @@ end
|
||||
-- hard dependencies must exist, be enabled, have survived, and satisfy their
|
||||
-- range; run to a fixpoint so failures propagate to dependents transitively
|
||||
function Loader:_enforceDependencies()
|
||||
local targetVersion = self:_targetVersion()
|
||||
local generation = self.generation
|
||||
local changed = true
|
||||
while changed do
|
||||
changed = false
|
||||
for _, id in ipairs(orderedIds(self.mods, isActive)) do
|
||||
local mod = self.mods[id]
|
||||
for _, spec in ipairs(mod.manifest.dependencySpecs) do
|
||||
local dep = self.mods[spec.id]
|
||||
local reason, skip
|
||||
if not dep then
|
||||
reason = "missing dependency: " .. spec.id
|
||||
elseif not dep.enabled then
|
||||
reason = ("dependency %s is disabled"):format(spec.id)
|
||||
elseif dep.state == "wrong_generation" then
|
||||
-- the gate's skip is contagious as a SKIP, not as a failure: the
|
||||
-- dependency has no bug to report and neither does this mod, so
|
||||
-- nothing here lands on the boot error list
|
||||
skip = true
|
||||
-- carry the dependency's own reason: it names the game or the
|
||||
-- missing gen2compat, and a guess here would name the wrong one
|
||||
reason = ("depends on %s, which does not run here (%s)")
|
||||
:format(spec.id, dep.skipReason or "not made for this game")
|
||||
elseif dep.failed then
|
||||
reason = ("dependency %s failed to load"):format(spec.id)
|
||||
elseif spec.range
|
||||
and not Semver.satisfies(dep.manifest.version, spec.range) then
|
||||
reason = ("needs %s@%s, found %s")
|
||||
:format(spec.id, spec.range, dep.manifest.version)
|
||||
end
|
||||
if reason then
|
||||
if skip then
|
||||
self:_skip(mod, "wrong_generation", reason)
|
||||
else
|
||||
self:_fail(mod, "blocked_dependency", reason)
|
||||
if ModTargets.specApplies(spec, targetVersion, generation) then
|
||||
local dep = self.mods[spec.id]
|
||||
local reason, skip
|
||||
if not dep then
|
||||
reason = "missing dependency: " .. spec.id
|
||||
elseif not dep.enabled then
|
||||
reason = ("dependency %s is disabled"):format(spec.id)
|
||||
elseif dep.state == "wrong_generation" then
|
||||
-- the gate's skip is contagious as a SKIP, not as a failure: the
|
||||
-- dependency has no bug to report and neither does this mod, so
|
||||
-- nothing here lands on the boot error list
|
||||
skip = true
|
||||
-- carry the dependency's own reason: it names the game or the
|
||||
-- missing gen2compat, and a guess here would name the wrong one
|
||||
reason = ("depends on %s, which does not run here (%s)")
|
||||
:format(spec.id, dep.skipReason or "not made for this game")
|
||||
elseif dep.failed then
|
||||
reason = ("dependency %s failed to load"):format(spec.id)
|
||||
elseif spec.range
|
||||
and not Semver.satisfies(dep.manifest.version, spec.range) then
|
||||
reason = ("needs %s@%s, found %s")
|
||||
:format(spec.id, spec.range, dep.manifest.version)
|
||||
end
|
||||
if reason then
|
||||
if skip then
|
||||
self:_skip(mod, "wrong_generation", reason)
|
||||
else
|
||||
self:_fail(mod, "blocked_dependency", reason)
|
||||
end
|
||||
changed = true
|
||||
break
|
||||
end
|
||||
changed = true
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -607,6 +647,8 @@ end
|
||||
-- Tarjan SCC over the hard-dependency graph: only a cycle's own members
|
||||
-- fail, so an unrelated mod beside a cycle still loads
|
||||
function Loader:_failCycles()
|
||||
local targetVersion = self:_targetVersion()
|
||||
local generation = self.generation
|
||||
local mods = self.mods
|
||||
local counter, stack, onStack, index, low = 0, {}, {}, {}, {}
|
||||
local cycles = {}
|
||||
@@ -617,14 +659,16 @@ function Loader:_failCycles()
|
||||
onStack[id] = true
|
||||
local selfEdge = false
|
||||
for _, spec in ipairs(mods[id].manifest.dependencySpecs) do
|
||||
local dep = mods[spec.id]
|
||||
if spec.id == id then selfEdge = true end
|
||||
if dep and isActive(dep) and spec.id ~= id then
|
||||
if not index[spec.id] then
|
||||
connect(spec.id)
|
||||
if low[spec.id] < low[id] then low[id] = low[spec.id] end
|
||||
elseif onStack[spec.id] and index[spec.id] < low[id] then
|
||||
low[id] = index[spec.id]
|
||||
if ModTargets.specApplies(spec, targetVersion, generation) then
|
||||
local dep = mods[spec.id]
|
||||
if spec.id == id then selfEdge = true end
|
||||
if dep and isActive(dep) and spec.id ~= id then
|
||||
if not index[spec.id] then
|
||||
connect(spec.id)
|
||||
if low[spec.id] < low[id] then low[id] = low[spec.id] end
|
||||
elseif onStack[spec.id] and index[spec.id] < low[id] then
|
||||
low[id] = index[spec.id]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -675,6 +719,8 @@ end
|
||||
-- Kahn over the surviving graph with the ready set kept in (priority, id)
|
||||
-- order, so dependencies come first and the rest matches the v1 contract
|
||||
function Loader:_order()
|
||||
local targetVersion = self:_targetVersion()
|
||||
local generation = self.generation
|
||||
local pending, indegree, dependents = {}, {}, {}
|
||||
for _, id in ipairs(orderedIds(self.mods, isActive)) do
|
||||
pending[id], indegree[id] = true, 0
|
||||
@@ -687,9 +733,17 @@ function Loader:_order()
|
||||
dependents[depId][#dependents[depId] + 1] = id
|
||||
indegree[id] = indegree[id] + 1
|
||||
end
|
||||
for _, spec in ipairs(manifest.dependencySpecs) do edge(spec.id) end
|
||||
for _, spec in ipairs(manifest.dependencySpecs) do
|
||||
if ModTargets.specApplies(spec, targetVersion, generation) then
|
||||
edge(spec.id)
|
||||
end
|
||||
end
|
||||
-- optional dependencies order without requiring anything
|
||||
for _, spec in ipairs(manifest.optionalSpecs) do edge(spec.id) end
|
||||
for _, spec in ipairs(manifest.optionalSpecs) do
|
||||
if ModTargets.specApplies(spec, targetVersion, generation) then
|
||||
edge(spec.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
local ordered = {}
|
||||
local function nextId()
|
||||
@@ -981,6 +1035,40 @@ function Loader:_api(mod)
|
||||
return DateTime.dateTime(game, timestamp)
|
||||
end,
|
||||
},
|
||||
-- The read-only part of love.system that device UIs legitimately need.
|
||||
-- Do not expose the module: openURL and clipboard access stay sandboxed.
|
||||
device = {
|
||||
powerInfo = function()
|
||||
local getPowerInfo = love and love.system and love.system.getPowerInfo
|
||||
if not getPowerInfo then return "unknown", nil end
|
||||
local state, percent = getPowerInfo()
|
||||
return state, percent
|
||||
end,
|
||||
},
|
||||
-- The native step bridge (#1186), behind the "steps" permission the
|
||||
-- player sees in the mod manager: sync asks the platform to refresh
|
||||
-- its count, poll hands this mod its copy of what the bridge
|
||||
-- delivered. The engine owns the pending file -- a mod never names a
|
||||
-- path, it only receives { steps, from, to }. available() answers
|
||||
-- false without the permission (a probe stays quiet); the calls that
|
||||
-- would do something name the missing permission instead, the way the
|
||||
-- network gate does.
|
||||
steps = (function()
|
||||
if mod.manifest.permissionSet.steps then
|
||||
loader.stepsQueues[modId] = loader.stepsQueues[modId] or {}
|
||||
return {
|
||||
available = function() return Steps.available() end,
|
||||
sync = function() return Steps.sync() end,
|
||||
poll = function() return Steps.poll(loader, modId) end,
|
||||
}
|
||||
end
|
||||
local function refuse()
|
||||
error(('[%s] mod.steps needs the "steps" permission in '
|
||||
.. "manifest.json"):format(modId), 2)
|
||||
end
|
||||
return { available = function() return false end,
|
||||
sync = refuse, poll = refuse }
|
||||
end)(),
|
||||
-- namespaced per mod; M11 backs these with save.modData /
|
||||
-- options.modOptions, the shape mods compile against is already final
|
||||
save = {
|
||||
@@ -999,7 +1087,8 @@ function Loader:_api(mod)
|
||||
bucket[key] = value
|
||||
end,
|
||||
},
|
||||
-- Data-only state independent of the vanilla progress checkpoint. The
|
||||
-- Data-only and opaque-byte state independent of the vanilla progress
|
||||
-- checkpoint. The
|
||||
-- engine binds version/playthrough/mod scope and portable persistence;
|
||||
-- callers never receive paths or a raw filesystem handle.
|
||||
storage = {
|
||||
@@ -1007,6 +1096,10 @@ function Loader:_api(mod)
|
||||
selected = function(_, game) return storage:selected(game) end,
|
||||
write = function(_, game, key, value) return storage:write(game, key, value) end,
|
||||
read = function(_, game, key) return storage:read(game, key) end,
|
||||
writeBytes = function(_, game, key, bytes)
|
||||
return storage:writeBytes(game, key, bytes)
|
||||
end,
|
||||
readBytes = function(_, game, key) return storage:readBytes(game, key) end,
|
||||
list = function(_, game, prefix) return storage:list(game, prefix) end,
|
||||
delete = function(_, game, key) return storage:delete(game, key) end,
|
||||
},
|
||||
@@ -1088,12 +1181,47 @@ function Loader:_api(mod)
|
||||
api.content[alias] = self:_contentApi(mod, self.content[canonical],
|
||||
("the %s registry is deprecated; use %s"):format(alias, canonical))
|
||||
end
|
||||
-- A relative path inside this mod, or the mod root when relative is
|
||||
-- omitted. Empty is the one listing case SafePath.safe rejects on
|
||||
-- purpose (it is not a file), so it is special-cased here.
|
||||
local function ownPath(relative, what)
|
||||
if relative == nil or relative == "" then return mod.path end
|
||||
return SafePath.join(mod.path, relative, what)
|
||||
end
|
||||
|
||||
-- Shallow directory listing, the sandboxed stand-in for
|
||||
-- love.filesystem.getDirectoryItems. Names only, sorted, never a host
|
||||
-- path. A missing directory is an empty list, not an error.
|
||||
local function listOwn(_, relative)
|
||||
local dir = ownPath(relative, "mod:list")
|
||||
local fs = loader.fs
|
||||
if not (fs and fs.getDirectoryItems) then return {} end
|
||||
local items = fs.getDirectoryItems(dir) or {}
|
||||
local out = {}
|
||||
for i = 1, #items do out[i] = items[i] end
|
||||
table.sort(out)
|
||||
return out
|
||||
end
|
||||
|
||||
-- love.filesystem.getInfo for a path inside this mod. type is "file" or
|
||||
-- "directory"; size is set for files. nil when the path does not exist.
|
||||
local function infoOwn(_, relative)
|
||||
local path = ownPath(relative, "mod:info")
|
||||
local fs = loader.fs
|
||||
if not (fs and fs.getInfo) then return nil end
|
||||
local info = fs.getInfo(path)
|
||||
if not info then return nil end
|
||||
return { type = info.type, size = info.size }
|
||||
end
|
||||
|
||||
-- assets keeps the v1 alias to the content accessors and adds the file
|
||||
-- helpers on top, so mod.assets.pokemon and mod.assets:image both resolve
|
||||
api.assets = setmetatable({
|
||||
path = function(_, relative) return mod.path .. "/" .. relative end,
|
||||
path = function(_, relative)
|
||||
return SafePath.join(mod.path, relative, "mod.assets:path")
|
||||
end,
|
||||
image = function(_, relative)
|
||||
local full = mod.path .. "/" .. relative
|
||||
local full = SafePath.join(mod.path, relative, "mod.assets:image")
|
||||
local cached = loader.imageCache[full]
|
||||
if cached then return cached end
|
||||
assert(love and love.graphics,
|
||||
@@ -1102,11 +1230,16 @@ function Loader:_api(mod)
|
||||
loader.imageCache[full] = image
|
||||
return image
|
||||
end,
|
||||
list = listOwn,
|
||||
info = infoOwn,
|
||||
}, { __index = api.content })
|
||||
-- the mod's own directory and nothing above it: PhysFS already refuses a
|
||||
-- climb, but loader.fs is injectable and has no such floor
|
||||
function api:read(relative)
|
||||
local path = self.path .. "/" .. relative
|
||||
return loader.fs.read(path)
|
||||
return loader.fs.read(SafePath.join(self.path, relative, "mod:read"))
|
||||
end
|
||||
api.list = listOwn
|
||||
api.info = infoOwn
|
||||
-- mod.world materializes on first touch, like the image helper above: a
|
||||
-- headless load must not drag the world stack in, and the Game the facade
|
||||
-- acts on is still being wired when the entry chunk runs
|
||||
@@ -1149,9 +1282,21 @@ function Loader:_game()
|
||||
return engineRequire("src.core.Game")
|
||||
end
|
||||
|
||||
-- The environment every chunk this mod authors runs in, built once per mod so
|
||||
-- its entry file and its options_schema share one globals table.
|
||||
function Loader:_modEnv(mod)
|
||||
local id = mod.manifest.id
|
||||
local env = self.modEnv[id]
|
||||
if not env then
|
||||
env = Sandbox.envFor({ modId = id, permissions = mod.manifest.permissionSet })
|
||||
self.modEnv[id] = env
|
||||
end
|
||||
return env
|
||||
end
|
||||
|
||||
function Loader:_loadMod(mod)
|
||||
local path = mod.path .. "/" .. mod.manifest.entry
|
||||
local chunk, err = self.fs.load(path)
|
||||
local path = SafePath.join(mod.path, mod.manifest.entry, "manifest entry")
|
||||
local chunk, err = Sandbox.loadFile(self.fs, path, self:_modEnv(mod))
|
||||
if not chunk then error(err or ("unable to load " .. path)) end
|
||||
local api = self:_api(mod)
|
||||
local result = chunk(api)
|
||||
@@ -1183,6 +1328,7 @@ function Loader:_rollback(modId)
|
||||
self.optionSchemas[modId] = nil
|
||||
self.migrations[modId] = nil
|
||||
self.modSave[modId] = nil
|
||||
self.stepsQueues[modId] = nil
|
||||
end
|
||||
|
||||
-- a mod that explicitly swears it stays link-compatible while writing into a
|
||||
@@ -1306,13 +1452,30 @@ function Loader:load(data)
|
||||
require("src.mods.Builtins").install(self.content, data, self.generation)
|
||||
self:_loadState()
|
||||
self:_discover()
|
||||
-- Existing installs stored one shared answer. Once their manifests are
|
||||
-- known, split that answer across every game before the next launcher/game
|
||||
-- toggle can change one independently. _loadState already used the same
|
||||
-- fallback, so this write cannot change the current boot's result.
|
||||
do
|
||||
local options = SaveData.loadOptions(self.fs)
|
||||
local installed = {}
|
||||
for id, mod in pairs(self.mods) do
|
||||
installed[#installed + 1] = {
|
||||
id = id,
|
||||
experimental = mod.manifest and mod.manifest.experimental == true,
|
||||
}
|
||||
end
|
||||
if SaveData.migrateModEnablement(options, installed) and self.fs.write then
|
||||
SaveData.saveOptions(options, self.fs)
|
||||
end
|
||||
end
|
||||
-- Experimental mods stay off until the player opts in: a missing
|
||||
-- options.mods entry normally means enabled, but experimental flips that.
|
||||
do
|
||||
local options = SaveData.loadOptions(self.fs)
|
||||
local modsOpt = options.mods or {}
|
||||
local scope = self:_enableScope()
|
||||
for id, mod in pairs(self.mods) do
|
||||
if not self.disabled[id] and modsOpt[id] == nil
|
||||
if not self.disabled[id] and SaveData.modEnabled(options, id, scope) == nil
|
||||
and mod.manifest.experimental then
|
||||
self.disabled[id] = true
|
||||
end
|
||||
@@ -1349,10 +1512,12 @@ function Loader:load(data)
|
||||
-- every touch: a mod captures the facade at file scope, before Game2 has a
|
||||
-- save or a world (src/mods/Gen2Compat.lua).
|
||||
Gen2Compat.bind(function() return self:_game() end)
|
||||
-- Dev mode wants the permissions tripwire; a Gold boot with mods on it wants
|
||||
-- the Gen 1-only require report, which is the difference between "the mod
|
||||
-- does nothing" and knowing why. A Gold boot with no mods pays nothing.
|
||||
if self.dev or (self.generation ~= 1 and next(self.mods) ~= nil) then
|
||||
-- Any boot with mods on it needs the gate, because require("io") is how a
|
||||
-- mod would walk out of Sandbox.envFor. Dev mode adds the permissions
|
||||
-- tripwire on top, and a Gold boot the Gen 1-only require report -- the
|
||||
-- difference between "the mod does nothing" and knowing why. A boot with no
|
||||
-- mods pays nothing.
|
||||
if self.dev or next(self.mods) ~= nil then
|
||||
self:_installDevShim()
|
||||
end
|
||||
for _, mod in ipairs(ordered) do
|
||||
|
||||
@@ -8,6 +8,8 @@ local Font = require("src.render.Font")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local ModTargets = require("src.mods.ModTargets")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local SafePath = require("src.mods.SafePath")
|
||||
local Sandbox = require("src.mods.Sandbox")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local Semver = require("src.mods.Semver")
|
||||
local Version = require("src.core.Version")
|
||||
@@ -735,8 +737,7 @@ function ManagerState:toggleGen2Force(m)
|
||||
}, apply)
|
||||
end
|
||||
|
||||
-- Where the loader persisted an enable flag: this game's slot once it keeps
|
||||
-- them per game, the shared flag until then (SaveData.modScope).
|
||||
-- Where the loader persists an enable flag: this running game's slot.
|
||||
function ManagerState:enableScope()
|
||||
return SaveData.modScope((self:targetGame()))
|
||||
end
|
||||
@@ -936,13 +937,18 @@ function ManagerState:schemaFor(m)
|
||||
local schema = loader.optionSchemas and loader.optionSchemas[m.id]
|
||||
if schema == nil and m.options_schema and m.path
|
||||
and loader.fs and loader.fs.load then
|
||||
local chunk = loader.fs.load(m.path .. "/" .. m.options_schema)
|
||||
if chunk then
|
||||
local ok, rows = pcall(chunk)
|
||||
if ok and type(rows) == "table" then
|
||||
schema = rows
|
||||
if loader.optionSchemas then loader.optionSchemas[m.id] = schema end
|
||||
end
|
||||
-- mod-authored code, so it runs in the same sandbox the entry chunk does
|
||||
local ok, rows = pcall(function()
|
||||
local path = SafePath.join(m.path, m.options_schema, "options_schema")
|
||||
local mod = loader.mods and loader.mods[m.id]
|
||||
local env = mod and loader._modEnv and loader:_modEnv(mod)
|
||||
or Sandbox.envFor()
|
||||
local chunk = Sandbox.loadFile(loader.fs, path, env)
|
||||
return chunk and chunk()
|
||||
end)
|
||||
if ok and type(rows) == "table" then
|
||||
schema = rows
|
||||
if loader.optionSchemas then loader.optionSchemas[m.id] = schema end
|
||||
end
|
||||
end
|
||||
return schema
|
||||
|
||||
+180
-27
@@ -3,13 +3,15 @@
|
||||
-- that need to stat a file, this owns shape, vocabulary and range grammar.
|
||||
local Logger = require("src.core.Logger")
|
||||
local ModTargets = require("src.mods.ModTargets")
|
||||
local SafePath = require("src.mods.SafePath")
|
||||
local Semver = require("src.mods.Semver")
|
||||
local Version = require("src.core.Version")
|
||||
|
||||
local Manifest = {}
|
||||
|
||||
Manifest.PROFILES = { content = true, overhaul = true, total_conversion = true }
|
||||
Manifest.PERMISSIONS = { network = true, filesystem = true, engine_internals = true }
|
||||
Manifest.PERMISSIONS = { network = true, filesystem = true,
|
||||
engine_internals = true, steps = true }
|
||||
|
||||
-- link-relevant registries; a mod that writes into one of these while
|
||||
-- declaring affects_link = false gets an attributed warning from the loader
|
||||
@@ -31,26 +33,6 @@ local function violation(strict, id, message)
|
||||
Logger.warn("[%s] %s", tostring(id), message)
|
||||
end
|
||||
|
||||
-- "id" or "id@<range>"; a malformed id or range fails for every api level
|
||||
-- because there is no sane fallback reading for it
|
||||
local function parseSpecs(list, field)
|
||||
local specs = {}
|
||||
for _, entry in ipairs(list) do
|
||||
assert(type(entry) == "string" and entry ~= "",
|
||||
field .. " entries must be non-empty strings")
|
||||
local id, range = entry:match("^([%w_%-]+)@(.+)$")
|
||||
if not id then
|
||||
id = entry:match("^([%w_%-]+)$")
|
||||
assert(id, ("malformed %s entry %q"):format(field, entry))
|
||||
range = nil
|
||||
end
|
||||
local ok, err = Semver.validRange(range)
|
||||
assert(ok, ("malformed %s range in %q: %s"):format(field, entry, tostring(err)))
|
||||
specs[#specs + 1] = { id = id, range = range }
|
||||
end
|
||||
return specs
|
||||
end
|
||||
|
||||
-- Optional GitHub repo for launcher auto-update / other-versions.
|
||||
-- Accepts "owner/repo" or a github.com URL; empty/absent means no updates.
|
||||
function Manifest.parseGithub(value)
|
||||
@@ -73,6 +55,69 @@ function Manifest.parseGithub(value)
|
||||
return owner .. "/" .. repo
|
||||
end
|
||||
|
||||
-- "id", "id@<range>", "id@<range>#<github>", "id#<github>", or table entry
|
||||
local function parseSpecs(list, field, sources)
|
||||
local specs = {}
|
||||
sources = type(sources) == "table" and sources or {}
|
||||
for _, entry in ipairs(list) do
|
||||
local id, range, ghHint, gamesRaw, gameVersion
|
||||
if type(entry) == "table" then
|
||||
id = entry.id
|
||||
range = entry.range or entry.version
|
||||
ghHint = entry.github or entry.repo
|
||||
gamesRaw = entry.games or entry.game
|
||||
gameVersion = entry.game_version
|
||||
elseif type(entry) == "string" and entry ~= "" then
|
||||
local main, hashRepo = entry:match("^([^#]+)#(.*)$")
|
||||
if main then
|
||||
entry = main
|
||||
ghHint = hashRepo
|
||||
end
|
||||
id, range = entry:match("^([%w_%-]+)@(.+)$")
|
||||
if not id then
|
||||
id = entry:match("^([%w_%-]+)$")
|
||||
assert(id, ("malformed %s entry %q"):format(field, entry))
|
||||
range = nil
|
||||
end
|
||||
else
|
||||
error(field .. " entries must be non-empty strings or tables")
|
||||
end
|
||||
assert(id, ("malformed %s entry"):format(field))
|
||||
local ok, err = Semver.validRange(range)
|
||||
assert(ok, ("malformed %s range in %q: %s"):format(field, tostring(entry), tostring(err)))
|
||||
|
||||
if gameVersion then
|
||||
local okV, errV = Semver.validRange(gameVersion)
|
||||
assert(okV, ("malformed %s game_version range in %q: %s"):format(field, tostring(entry), tostring(errV)))
|
||||
end
|
||||
|
||||
local parsedGames = nil
|
||||
if gamesRaw ~= nil then
|
||||
if type(gamesRaw) == "string" then gamesRaw = { gamesRaw } end
|
||||
assert(type(gamesRaw) == "table", "dependency games must be a string or table")
|
||||
local normalized, unknown = ModTargets.normalize(gamesRaw)
|
||||
assert(#unknown == 0, ("unknown game in %s: %s"):format(field, table.concat(unknown, ", ")))
|
||||
parsedGames = normalized
|
||||
end
|
||||
|
||||
local parsedGh = nil
|
||||
local rawGh = ghHint or sources[id]
|
||||
if rawGh then
|
||||
local okGh, cleanGh = pcall(Manifest.parseGithub, rawGh)
|
||||
if okGh and cleanGh then parsedGh = cleanGh end
|
||||
end
|
||||
|
||||
specs[#specs + 1] = {
|
||||
id = id,
|
||||
range = range,
|
||||
github = parsedGh,
|
||||
games = parsedGames,
|
||||
game_version = gameVersion,
|
||||
}
|
||||
end
|
||||
return specs
|
||||
end
|
||||
|
||||
-- conflicts + incompatible (alias) merged, first-wins on duplicate ids
|
||||
local function mergeConflictLists(conflicts, incompatible)
|
||||
local seen, out = {}, {}
|
||||
@@ -87,13 +132,97 @@ local function mergeConflictLists(conflicts, incompatible)
|
||||
return out
|
||||
end
|
||||
|
||||
local scrubUtf8 -- shared by required-import labels and top-level strings
|
||||
|
||||
-- User-supplied files a mod needs beside its own source. The launcher owns
|
||||
-- the picker and the copy; the mod only reads the resulting
|
||||
-- <mod>/baseroms/<file> through its existing scoped mod:read surface. MD5 is
|
||||
-- deliberately the manifest vocabulary here: ROM preservation databases and
|
||||
-- the mods consuming these files commonly identify dumps by MD5, and the
|
||||
-- digest is an identity check rather than a security boundary.
|
||||
local function parseImports(value, field, required)
|
||||
field = field or "required_imports"
|
||||
local out, ids, files = {}, {}, {}
|
||||
for _, entry in ipairs(array(value)) do
|
||||
assert(type(entry) == "table", field .. " entries must be objects")
|
||||
local id = entry.id
|
||||
assert(type(id) == "string" and id:match("^[%w_%-]+$"),
|
||||
field .. " id must contain only letters, numbers, _ or -")
|
||||
assert(not ids[id], "duplicate " .. field .. " id: " .. id)
|
||||
ids[id] = true
|
||||
|
||||
local file = entry.file
|
||||
assert(type(file) == "string" and file ~= "",
|
||||
field .. " file is required")
|
||||
file = SafePath.require(file, field .. " file")
|
||||
assert(not file:find("/", 1, true),
|
||||
field .. " file must be a filename inside baseroms")
|
||||
assert(file:sub(1, 1) ~= ".",
|
||||
field .. " file must not use a hidden metadata filename")
|
||||
assert(not files[file], "duplicate " .. field .. " file: " .. file)
|
||||
files[file] = true
|
||||
|
||||
local hashes = entry.md5
|
||||
if type(hashes) == "string" then hashes = { hashes } end
|
||||
assert(type(hashes) == "table" and #hashes > 0,
|
||||
field .. " md5 must be a hash or non-empty array")
|
||||
local accepted, seen = {}, {}
|
||||
for _, digest in ipairs(hashes) do
|
||||
assert(type(digest) == "string" and digest:match("^[%x]+$")
|
||||
and #digest == 32, field .. " md5 values must be 32 hex characters")
|
||||
digest = digest:lower()
|
||||
if not seen[digest] then
|
||||
seen[digest] = true
|
||||
accepted[#accepted + 1] = digest
|
||||
end
|
||||
end
|
||||
|
||||
local format = entry.format or "raw"
|
||||
assert(format == "raw" or format == "n64",
|
||||
field .. " format must be raw or n64")
|
||||
local name = entry.name or id
|
||||
assert(type(name) == "string" and name ~= "",
|
||||
field .. " name must be a non-empty string")
|
||||
local description = entry.description or entry.hint
|
||||
if description ~= nil then
|
||||
assert(type(description) == "string" and description ~= "",
|
||||
field .. " description must be a non-empty string")
|
||||
end
|
||||
local size = entry.size
|
||||
local maxSize = entry.max_size
|
||||
local function validateSize(value, label)
|
||||
if value == nil then return end
|
||||
assert(type(value) == "number" and value > 0 and value % 1 == 0,
|
||||
field .. " " .. label .. " must be a positive integer")
|
||||
assert(value <= 128 * 1024 * 1024,
|
||||
field .. " " .. label .. " exceeds the 128 MiB hard limit")
|
||||
end
|
||||
validateSize(size, "size")
|
||||
validateSize(maxSize, "max_size")
|
||||
assert(not (size and maxSize) or size <= maxSize,
|
||||
field .. " size must not exceed max_size")
|
||||
out[#out + 1] = {
|
||||
id = id,
|
||||
name = scrubUtf8(name),
|
||||
description = scrubUtf8(description),
|
||||
file = file,
|
||||
md5 = accepted,
|
||||
format = format,
|
||||
size = size,
|
||||
max_size = maxSize,
|
||||
required = required ~= false,
|
||||
}
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- Drop bytes that are not valid UTF-8 (malformed sequences, overlongs,
|
||||
-- surrogates, > U+10FFFF) and a leading BOM. LÖVE's text renderer raises
|
||||
-- "Invalid UTF-8" from love.graphics.print/printf, so any manifest string a
|
||||
-- panel may draw must be scrubbed here -- the one place every mod manifest
|
||||
-- passes through -- or a single mangled description crashes the whole MODS
|
||||
-- panel instead of misrendering one card.
|
||||
local function scrubUtf8(s)
|
||||
scrubUtf8 = function(s)
|
||||
if type(s) ~= "string" then return s end
|
||||
s = s:gsub("^\239\187\191", "")
|
||||
local out, i, n = {}, 1, #s
|
||||
@@ -144,6 +273,9 @@ function Manifest.validate(raw, path)
|
||||
assert(type(raw.name) == "string" and raw.name ~= "", "manifest name is required")
|
||||
assert(type(raw.version) == "string" and raw.version ~= "", "manifest version is required")
|
||||
assert(type(raw.entry) == "string" and raw.entry ~= "", "manifest entry is required")
|
||||
-- every manifest path is joined to the mod's own directory, so none of them
|
||||
-- may climb out of it (src/mods/SafePath.lua)
|
||||
local entry = SafePath.require(raw.entry, "manifest entry")
|
||||
|
||||
-- absent means 1: full v1 compat, schema violations downgrade to warnings
|
||||
assert(raw.api == nil or tonumber(raw.api) ~= nil, "manifest api must be a number")
|
||||
@@ -229,27 +361,45 @@ function Manifest.validate(raw, path)
|
||||
local affectsLink = profile ~= "content" and not language
|
||||
if type(raw.affects_link) == "boolean" then affectsLink = raw.affects_link end
|
||||
|
||||
local function optionalFile(value, field)
|
||||
local function optionalString(value, field)
|
||||
if value == nil then return nil end
|
||||
assert(type(value) == "string" and value ~= "", field .. " must be a file path")
|
||||
return value
|
||||
end
|
||||
|
||||
local function optionalFile(value, field)
|
||||
local text = optionalString(value, field)
|
||||
return text and SafePath.require(text, field)
|
||||
end
|
||||
|
||||
local conflicts = mergeConflictLists(raw.conflicts, raw.incompatible)
|
||||
|
||||
local requiredImports = parseImports(raw.required_imports,
|
||||
"required_imports", true)
|
||||
local optionalImports = parseImports(raw.optional_imports,
|
||||
"optional_imports", false)
|
||||
local importIds, importFiles = {}, {}
|
||||
for _, list in ipairs({ requiredImports, optionalImports }) do
|
||||
for _, import in ipairs(list) do
|
||||
assert(not importIds[import.id], "duplicate import id: " .. import.id)
|
||||
assert(not importFiles[import.file], "duplicate import file: " .. import.file)
|
||||
importIds[import.id], importFiles[import.file] = true, true
|
||||
end
|
||||
end
|
||||
|
||||
return {
|
||||
id = raw.id,
|
||||
name = raw.name,
|
||||
version = raw.version,
|
||||
entry = raw.entry,
|
||||
entry = entry,
|
||||
api = api,
|
||||
priority = tonumber(raw.priority) or 0,
|
||||
dependencies = array(raw.dependencies),
|
||||
optional_dependencies = array(raw.optional_dependencies),
|
||||
conflicts = conflicts,
|
||||
incompatible = array(raw.incompatible),
|
||||
dependencySpecs = parseSpecs(array(raw.dependencies), "dependencies"),
|
||||
optionalSpecs = parseSpecs(array(raw.optional_dependencies), "optional_dependencies"),
|
||||
dependencySpecs = parseSpecs(array(raw.dependencies), "dependencies", raw.dependency_sources),
|
||||
optionalSpecs = parseSpecs(array(raw.optional_dependencies), "optional_dependencies", raw.dependency_sources),
|
||||
conflictSpecs = parseSpecs(conflicts, "conflicts"),
|
||||
category = raw.category or "OTHER",
|
||||
game_version = raw.game_version,
|
||||
@@ -265,7 +415,10 @@ function Manifest.validate(raw, path)
|
||||
permissionSet = permissionSet,
|
||||
options_schema = optionalFile(raw.options_schema, "options_schema"),
|
||||
assets_transforms = optionalFile(raw.assets_transforms, "assets_transforms"),
|
||||
force_enable_env = optionalFile(raw.force_enable_env, "force_enable_env"),
|
||||
required_imports = requiredImports,
|
||||
optional_imports = optionalImports,
|
||||
-- an env var name, not a path, so it keeps the plain string check
|
||||
force_enable_env = optionalString(raw.force_enable_env, "force_enable_env"),
|
||||
path = path,
|
||||
raw = raw,
|
||||
}
|
||||
|
||||
@@ -334,9 +334,15 @@ function ModIndex.compatIssues(entry, ctx)
|
||||
local function eachSpec(spec, fn)
|
||||
if type(spec) ~= "table" then return end
|
||||
for k, v in pairs(spec) do
|
||||
if type(k) == "number" and type(v) == "string" then
|
||||
local id, range = v:match("^([^@]+)@(.+)$")
|
||||
fn(id or v, range)
|
||||
if type(k) == "number" then
|
||||
if type(v) == "table" and type(v.id) == "string" then
|
||||
fn(v.id, v.range or v.version, v.github or v.repo)
|
||||
elseif type(v) == "string" then
|
||||
local main, hashRepo = v:match("^([^#]+)#(.*)$")
|
||||
if main then v = main end
|
||||
local id, range = v:match("^([^@]+)@(.+)$")
|
||||
fn(id or v, range, hashRepo)
|
||||
end
|
||||
elseif type(k) == "string" then
|
||||
fn(k, type(v) == "string" and v or nil)
|
||||
end
|
||||
|
||||
@@ -168,4 +168,39 @@ function ModTargets.detail(manifest, version)
|
||||
ModTargets.gameLabel(version))
|
||||
end
|
||||
|
||||
-- Does a dependency spec apply to this game / version?
|
||||
-- If spec.games is provided, it must match version / generation.
|
||||
-- If spec.game_version is provided, the engine version must satisfy it.
|
||||
function ModTargets.specApplies(spec, version, generation)
|
||||
if not spec then return false end
|
||||
if type(spec.games) == "table" and #spec.games > 0 then
|
||||
if version or generation then
|
||||
local match = false
|
||||
for _, id in ipairs(spec.games) do
|
||||
if version and id == version then
|
||||
match = true
|
||||
break
|
||||
end
|
||||
if generation and GameVersion.generation(id) == generation then
|
||||
match = true
|
||||
break
|
||||
end
|
||||
end
|
||||
if not match then return false end
|
||||
end
|
||||
end
|
||||
if spec.game_version then
|
||||
local ok = pcall(function()
|
||||
local Semver = require("src.mods.Semver")
|
||||
local Version = require("src.core.Version")
|
||||
if Version and Version.engine and Version.engine:match("^0%.0%.0%-") == nil then
|
||||
return Semver.satisfies(Version.engine, spec.game_version)
|
||||
end
|
||||
return true
|
||||
end)
|
||||
if not ok then return false end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
return ModTargets
|
||||
|
||||
@@ -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
|
||||
@@ -28,6 +28,11 @@ Runtime.errors = nil
|
||||
-- permissions tripwire knows there is nobody to attribute to
|
||||
Runtime.currentMod = nil
|
||||
|
||||
-- set by the sandbox's require for the duration of one mod-initiated require,
|
||||
-- so the loader's gate can still attribute a lazy one made long after
|
||||
-- currentMod went back to nil (src/mods/Sandbox.lua)
|
||||
Runtime.modRequire = nil
|
||||
|
||||
function Runtime.install(events, hooks, errors)
|
||||
Runtime.events, Runtime.hooks = events, hooks
|
||||
Runtime.errors = errors
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1017,6 +1017,9 @@ R.trainers = {
|
||||
index = f.opt(f.int(0, 255)),
|
||||
-- unused vanilla classes ship without a pic, so it cannot be required
|
||||
pic = f.opt(f.path),
|
||||
-- Full-color portrait: skip the 4-shade SGB/GBC remap, same flag pokemon
|
||||
-- and sprites already carry.
|
||||
trueColor = f.opt(f.bool),
|
||||
-- Optional Advanced-mode OBJ palette source for a custom trainer portrait.
|
||||
-- It follows the same ROM crosswalk form as sprites.paletteSource.
|
||||
paletteSource = f.opt(f.str),
|
||||
@@ -1068,6 +1071,11 @@ R.trainers = {
|
||||
gen2Fields = {
|
||||
id = f.opt(f.str), name = f.str,
|
||||
index = f.opt(f.int(0, 255)),
|
||||
-- class frontpic; when set, this wins over menu_gfx.battleHud.trainerPics
|
||||
pic = f.opt(f.path),
|
||||
-- Full-color portrait: skip the GBC 4-shade remap, same flag Gen 1
|
||||
-- trainers and pokemon already carry.
|
||||
trueColor = f.opt(f.bool),
|
||||
baseMoney = f.opt(f.int(0)),
|
||||
-- the class's battle theme; Gen 1 spells the same idea `battleTheme`,
|
||||
-- but this is the extractor's own key and a strict rename would reject
|
||||
|
||||
@@ -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
@@ -1,4 +1,5 @@
|
||||
-- Data-only per-mod persistence, scoped by game version and opaque playthrough.
|
||||
-- Data-only and opaque-byte per-mod persistence, scoped by game version and
|
||||
-- opaque playthrough.
|
||||
-- This module is engine-private; Loader exposes only the bound facade methods.
|
||||
|
||||
local SaveData = require("src.core.SaveData")
|
||||
@@ -7,6 +8,7 @@ local Version = require("src.core.Version")
|
||||
|
||||
local Storage = {}
|
||||
Storage.__index = Storage
|
||||
Storage.MAX_BYTES = 512 * 1024 * 1024
|
||||
|
||||
local ROOT = "mod_storage"
|
||||
|
||||
@@ -49,6 +51,20 @@ local function decodeAt(fs, path)
|
||||
return data, body
|
||||
end
|
||||
|
||||
local function readOpaqueAt(fs, path)
|
||||
if not (fs.getInfo and fs.getInfo(path)) then return nil end
|
||||
local body = fs.read and fs.read(path)
|
||||
if type(body) ~= "string" then return nil end
|
||||
return body
|
||||
end
|
||||
|
||||
local function hasAny(fs, paths)
|
||||
for _, path in ipairs(paths) do
|
||||
if fs.getInfo(path) then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
function Storage.new(modId, fs)
|
||||
assert(validSegment(modId), "Storage.new needs a safe mod id")
|
||||
return setmetatable({ modId = modId, injectedFs = fs }, Storage)
|
||||
@@ -132,6 +148,10 @@ function Storage:selected(game)
|
||||
end,
|
||||
read = function(_, key) return self:read(selectedGame, key) end,
|
||||
write = function(_, key, value) return self:write(selectedGame, key, value) end,
|
||||
readBytes = function(_, key) return self:readBytes(selectedGame, key) end,
|
||||
writeBytes = function(_, key, bytes)
|
||||
return self:writeBytes(selectedGame, key, bytes)
|
||||
end,
|
||||
list = function(_, prefix) return self:list(selectedGame, prefix) end,
|
||||
delete = function(_, key) return self:delete(selectedGame, key) end,
|
||||
}
|
||||
@@ -147,7 +167,7 @@ function Storage:context(game)
|
||||
}
|
||||
end
|
||||
|
||||
function Storage:_names(game, key, allowEmpty)
|
||||
function Storage:_names(game, key, allowEmpty, extension)
|
||||
if not validKey(key, allowEmpty) then
|
||||
return failure("invalid_key",
|
||||
"Storage keys use nonempty letters, numbers, underscore, dash and slash segments.")
|
||||
@@ -155,12 +175,19 @@ function Storage:_names(game, key, allowEmpty)
|
||||
local scope, code, message = self:_scope(game)
|
||||
if not scope then return nil, code, message end
|
||||
local path = scope.base .. (key ~= "" and ("/" .. key) or "")
|
||||
return scope, path .. ".lua", path .. ".lua.bak", path .. ".lua.tmp"
|
||||
extension = extension or ".lua"
|
||||
return scope, path .. extension, path .. extension .. ".bak",
|
||||
path .. extension .. ".tmp", path
|
||||
end
|
||||
|
||||
function Storage:write(game, key, value)
|
||||
local scope, main, bak, tmp = self:_names(game, key, false)
|
||||
local scope, main, bak, tmp, path = self:_names(game, key, false)
|
||||
if not scope then return false, main, bak end
|
||||
local fs = scope.fs
|
||||
if hasAny(fs, { path .. ".bin", path .. ".bin.bak", path .. ".bin.tmp" }) then
|
||||
return false, "type_conflict",
|
||||
"A byte value already exists for this storage key; delete it first."
|
||||
end
|
||||
if type(value) ~= "table" then
|
||||
return false, "encode_failed", "Storage values must be data-only tables."
|
||||
end
|
||||
@@ -170,7 +197,6 @@ function Storage:write(game, key, value)
|
||||
.. tostring(encoded)
|
||||
end
|
||||
|
||||
local fs = scope.fs
|
||||
ensureParent(fs, main)
|
||||
local _, previous = decodeAt(fs, main)
|
||||
if not previous then _, previous = decodeAt(fs, bak) end
|
||||
@@ -206,9 +232,13 @@ function Storage:write(game, key, value)
|
||||
end
|
||||
|
||||
function Storage:read(game, key)
|
||||
local scope, main, bak, tmp = self:_names(game, key, false)
|
||||
local scope, main, bak, tmp, path = self:_names(game, key, false)
|
||||
if not scope then return nil, main, bak end
|
||||
local fs = scope.fs
|
||||
if hasAny(fs, { path .. ".bin", path .. ".bin.bak", path .. ".bin.tmp" }) then
|
||||
return failure("type_mismatch",
|
||||
"This storage key contains opaque bytes; use readBytes instead.")
|
||||
end
|
||||
local data, body = decodeAt(fs, main)
|
||||
if data then return data end
|
||||
|
||||
@@ -226,6 +256,80 @@ function Storage:read(game, key)
|
||||
return data
|
||||
end
|
||||
|
||||
function Storage:writeBytes(game, key, bytes)
|
||||
local scope, main, bak, tmp, path = self:_names(game, key, false, ".bin")
|
||||
if not scope then return false, main, bak end
|
||||
if type(bytes) ~= "string" then
|
||||
return false, "invalid_bytes", "Opaque storage values must be strings."
|
||||
end
|
||||
if #bytes > Storage.MAX_BYTES then
|
||||
return false, "size_limit",
|
||||
("Opaque storage values cannot exceed %d bytes."):format(Storage.MAX_BYTES)
|
||||
end
|
||||
|
||||
local fs = scope.fs
|
||||
if hasAny(fs, { path .. ".lua", path .. ".lua.bak", path .. ".lua.tmp" }) then
|
||||
return false, "type_conflict",
|
||||
"A table value already exists for this storage key; delete it first."
|
||||
end
|
||||
|
||||
ensureParent(fs, main)
|
||||
local previous = readOpaqueAt(fs, main)
|
||||
if previous == nil then previous = readOpaqueAt(fs, bak) end
|
||||
|
||||
local ok, err = fs.write(tmp, bytes)
|
||||
if not ok then
|
||||
return false, "write_failed", "Could not stage opaque storage data: " .. tostring(err)
|
||||
end
|
||||
local staged = readOpaqueAt(fs, tmp)
|
||||
if staged == nil or staged ~= bytes then
|
||||
remove(fs, tmp)
|
||||
return false, "verify_failed", "Staged opaque storage data could not be verified."
|
||||
end
|
||||
|
||||
if previous ~= nil then fs.write(bak, previous) end
|
||||
ok, err = fs.write(main, bytes)
|
||||
if not ok then
|
||||
remove(fs, tmp)
|
||||
return false, "write_failed",
|
||||
"Could not replace opaque storage data: " .. tostring(err)
|
||||
end
|
||||
local verified = readOpaqueAt(fs, main)
|
||||
if verified == nil or verified ~= bytes then
|
||||
remove(fs, main)
|
||||
remove(fs, tmp)
|
||||
return false, "verify_failed",
|
||||
"Replacement opaque storage data could not be verified."
|
||||
end
|
||||
|
||||
fs.write(bak, bytes)
|
||||
remove(fs, tmp)
|
||||
return true
|
||||
end
|
||||
|
||||
function Storage:readBytes(game, key)
|
||||
local scope, main, bak, tmp, path = self:_names(game, key, false, ".bin")
|
||||
if not scope then return nil, main, bak end
|
||||
local fs = scope.fs
|
||||
if hasAny(fs, { path .. ".lua", path .. ".lua.bak", path .. ".lua.tmp" }) then
|
||||
return failure("type_mismatch",
|
||||
"This storage key contains table data; use read instead.")
|
||||
end
|
||||
|
||||
local bytes = readOpaqueAt(fs, main)
|
||||
if bytes ~= nil then return bytes end
|
||||
bytes = readOpaqueAt(fs, tmp)
|
||||
if bytes == nil then bytes = readOpaqueAt(fs, bak) end
|
||||
if bytes == nil then
|
||||
return nil, "not_found", "No valid opaque value exists for this key."
|
||||
end
|
||||
|
||||
ensureParent(fs, main)
|
||||
if fs.write(main, bytes) then fs.write(bak, bytes) end
|
||||
remove(fs, tmp)
|
||||
return bytes
|
||||
end
|
||||
|
||||
function Storage:list(game, prefix)
|
||||
prefix = prefix or ""
|
||||
local scope, main, codeOrBak = self:_names(game, prefix, true)
|
||||
@@ -237,13 +341,23 @@ function Storage:list(game, prefix)
|
||||
|
||||
local base = scope.base
|
||||
local start = prefix == "" and base or (base .. "/" .. prefix)
|
||||
local out = {}
|
||||
local out, seen = {}, {}
|
||||
|
||||
local function add(logical)
|
||||
if not seen[logical] then
|
||||
seen[logical] = true
|
||||
out[#out + 1] = logical
|
||||
end
|
||||
end
|
||||
|
||||
local function walk(path, logical)
|
||||
local info = fs.getInfo(path)
|
||||
if not info then return end
|
||||
if info.type == "file" then
|
||||
if path:sub(-4) == ".lua" then out[#out + 1] = logical:sub(1, -5) end
|
||||
local suffix = path:sub(-4)
|
||||
if suffix == ".lua" or suffix == ".bin" then
|
||||
add(logical:sub(1, -5))
|
||||
end
|
||||
return
|
||||
end
|
||||
for _, child in ipairs(fs.getDirectoryItems(path) or {}) do
|
||||
@@ -254,7 +368,9 @@ function Storage:list(game, prefix)
|
||||
|
||||
-- A prefix may identify one exact key or a directory of keys.
|
||||
if fs.getInfo(start .. ".lua") then
|
||||
out[#out + 1] = prefix
|
||||
add(prefix)
|
||||
elseif fs.getInfo(start .. ".bin") then
|
||||
add(prefix)
|
||||
else
|
||||
walk(start, prefix)
|
||||
end
|
||||
@@ -263,15 +379,20 @@ function Storage:list(game, prefix)
|
||||
end
|
||||
|
||||
function Storage:delete(game, key)
|
||||
local scope, main, bak, tmp = self:_names(game, key, false)
|
||||
local scope, main, bak, tmp, path = self:_names(game, key, false)
|
||||
if not scope then return false, main, bak end
|
||||
local fs = scope.fs
|
||||
if not (fs.getInfo(main) or fs.getInfo(bak) or fs.getInfo(tmp)) then
|
||||
local byteMain, byteBak, byteTmp = path .. ".bin", path .. ".bin.bak", path .. ".bin.tmp"
|
||||
if not (fs.getInfo(main) or fs.getInfo(bak) or fs.getInfo(tmp)
|
||||
or fs.getInfo(byteMain) or fs.getInfo(byteBak) or fs.getInfo(byteTmp)) then
|
||||
return false, "not_found", "No stored value exists for this key."
|
||||
end
|
||||
remove(fs, main)
|
||||
remove(fs, bak)
|
||||
remove(fs, tmp)
|
||||
remove(fs, byteMain)
|
||||
remove(fs, byteBak)
|
||||
remove(fs, byteTmp)
|
||||
return true
|
||||
end
|
||||
|
||||
|
||||
@@ -140,9 +140,12 @@ function Evolution.learnEvolutionMoves(game, mon, onDone)
|
||||
if #mon.moves < 4 then
|
||||
table.insert(mon.moves, { id = moveId, pp = mdef.pp })
|
||||
Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId })
|
||||
-- LearnedMove1Text: text_far, sound_get_item_1, text_promptbutton
|
||||
-- (learn_move.asm), so the jingle rides the box
|
||||
game.stack:push(TextBox.new(game,
|
||||
romText(game.data, "_LearnedMove1Text",
|
||||
"%s learned\n%s!", name, mdef.name), nextStep))
|
||||
"%s learned\n%s!", name, mdef.name), nextStep,
|
||||
TextBox.soundOpts(game, "Get_Item1")))
|
||||
else
|
||||
-- LearnMoveFromLevelUp with a full moveset: the forget UI
|
||||
Screens.push(game, "MoveLearnMenu", mon, moveId, nextStep)
|
||||
|
||||
@@ -141,13 +141,20 @@ end
|
||||
-- Tinting first would double-apply the color: GREENBAR's fill {0,189,0} has
|
||||
-- red channel 0, so the tint zeroes the whole bar's red and the zone's
|
||||
-- red-channel-keyed shade shader then maps every pixel to color 3 = black.
|
||||
function HudTiles.drawHPBar(data, tx, ty, mon, barType, grayFill, segments)
|
||||
--
|
||||
-- pixels: an explicit 0..48 bar length on GetHPBarLength's scale, for a
|
||||
-- caller that is animating the bar between two HP values
|
||||
-- (UpdateHPBar_AnimateHPBar); it scales with `segments` like the color
|
||||
-- thresholds do. Without it the length comes from mon.hp as before.
|
||||
function HudTiles.drawHPBar(data, tx, ty, mon, barType, grayFill, segments, pixels)
|
||||
local x, y = tx * 8, ty * 8
|
||||
segments = math.max(1, math.floor(segments or 6))
|
||||
HudTiles.tile(0x71, x, y)
|
||||
HudTiles.tile(0x62, x + 8, y)
|
||||
local px = 0
|
||||
if mon.stats.hp > 0 and mon.hp > 0 then
|
||||
if pixels then
|
||||
px = math.max(0, math.floor(pixels * segments / 6))
|
||||
elseif mon.stats.hp > 0 and mon.hp > 0 then
|
||||
px = math.max(1, math.floor(mon.hp * segments * 8 / mon.stats.hp))
|
||||
end
|
||||
local tint
|
||||
|
||||
@@ -691,10 +691,12 @@ function PaletteFX.spriteObp(spriteDef, seed)
|
||||
return PaletteFX.darkObp(w.spritePalettes[group], group)
|
||||
end
|
||||
|
||||
-- GetHealthBarColor (home/palettes.asm) on the standard 48px bar
|
||||
function PaletteFX.barPalName(hp, maxHp)
|
||||
local px = maxHp > 0 and math.floor(hp * 48 / maxHp) or 0
|
||||
if hp > 0 and px < 1 then px = 1 end
|
||||
-- GetHealthBarColor (home/palettes.asm) on the standard 48px bar. It reads
|
||||
-- the bar's own length, so a caller mid-drain passes the animated `pixels`
|
||||
-- rather than let it be re-derived from hp.
|
||||
function PaletteFX.barPalName(hp, maxHp, pixels)
|
||||
local px = pixels or (maxHp > 0 and math.floor(hp * 48 / maxHp) or 0)
|
||||
if not pixels and hp > 0 and px < 1 then px = 1 end
|
||||
return px >= 27 and "GREENBAR" or px >= 10 and "YELLOWBAR" or "REDBAR"
|
||||
end
|
||||
|
||||
|
||||
+25
-13
@@ -768,11 +768,12 @@ function Renderer:endFrame(zones, worldZones)
|
||||
end
|
||||
end
|
||||
|
||||
-- A post-process pipeline needs the whole composite in a canvas for the
|
||||
-- same reason GBC FX does, so either one alone is enough to take the
|
||||
-- present path; with neither, the frame draws straight to the screen
|
||||
-- exactly as it always did.
|
||||
local needPresent = GBCFX.active() or Pipelines.wantsPresent()
|
||||
-- Post-process pipelines, GBC FX and an enabled final-output owner need the
|
||||
-- whole composite in a canvas. With none of them, the frame draws straight
|
||||
-- to the screen exactly as it always did.
|
||||
local hasOutputHook = Runtime.wantsHook("render.output")
|
||||
and Runtime.call("render.output_enabled", function() return false end) == true
|
||||
local needPresent = GBCFX.active() or Pipelines.wantsPresent() or hasOutputHook
|
||||
local present = nil
|
||||
if needPresent then
|
||||
if not self.presentCanvas or self.presentCanvas:getWidth() ~= ww
|
||||
@@ -1057,14 +1058,25 @@ function Renderer:endFrame(zones, worldZones)
|
||||
-- this returns `present` unchanged and the frame is byte-identical.
|
||||
local composed = Pipelines.present(present,
|
||||
{ width = ww, height = wh, scale = Sp, dpi = dpiY, dpiX = dpiX, dpiY = dpiY }) or present
|
||||
if GBCFX.active() then
|
||||
-- shader grid/shadow math is in framebuffer pixels
|
||||
GBCFX.present(composed, Sp)
|
||||
else
|
||||
-- the present canvas only existed for the post-process, so put the
|
||||
-- result on the screen at the same 1:1 unit mapping it was built at
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(composed, 0, 0)
|
||||
local outputHandled = hasOutputHook
|
||||
and Runtime.call("render.output", function() return false end, {
|
||||
canvas = composed,
|
||||
width = ww, height = wh,
|
||||
gameX = ox, gameY = oy,
|
||||
gameWidth = vpw, gameHeight = vph,
|
||||
scale = Sp, dpiX = dpiX, dpiY = dpiY,
|
||||
generation = 1,
|
||||
}) == true
|
||||
if not outputHandled then
|
||||
if GBCFX.active() then
|
||||
-- shader grid/shadow math is in framebuffer pixels
|
||||
GBCFX.present(composed, Sp)
|
||||
else
|
||||
-- the present canvas only existed for the post-process, so put the
|
||||
-- result on the screen at the same 1:1 unit mapping it was built at
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(composed, 0, 0)
|
||||
end
|
||||
end
|
||||
end
|
||||
self.worldActive = false
|
||||
|
||||
@@ -62,6 +62,8 @@ local function getObpImage(path, colors, group)
|
||||
return obpCache[key]
|
||||
end
|
||||
|
||||
SpriteRenderer.obpImage = getObpImage
|
||||
|
||||
-- hot reload drops the sheets; live instances hold their own image, so
|
||||
-- the world rebuilds them (MapLoader.invalidateAll) rather than this
|
||||
function SpriteRenderer.invalidate()
|
||||
@@ -281,7 +283,7 @@ end
|
||||
-- swapped and OAM_XFLIP on each (data/sprites/facings.asm:192-197). Optional
|
||||
-- and trailing, so every existing call site is unchanged.
|
||||
function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip,
|
||||
topHalf, forceFlip)
|
||||
topHalf, forceFlip, frameOverride)
|
||||
local x, y = self:getScreenOrigin(px, py, camX, camY)
|
||||
local image = self.image
|
||||
local redraw = false
|
||||
@@ -329,6 +331,9 @@ function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip,
|
||||
-- still 3-frame sprites turn to face (the nurse at her machine,
|
||||
-- facePlayer on STAY NPCs) but never show walk frames.
|
||||
local frame, flip = pose(self, facing, walkPhase, stepFlip)
|
||||
if frameOverride and self.frames[frameOverride] then
|
||||
frame, flip = frameOverride, false
|
||||
end
|
||||
if forceFlip then flip = true end
|
||||
local quad = self.frames[frame]
|
||||
local drawHeight = self.frameHeight
|
||||
|
||||
@@ -100,6 +100,26 @@ function TextBox.new(game, text, onDone, opts)
|
||||
return self
|
||||
end
|
||||
|
||||
-- soundOpts: a jingle carried at the end of the string as a trailing text
|
||||
-- command (sound_get_item_1 and friends -> home/text.asm TextCommand_SOUND),
|
||||
-- which runs PlaySound then WaitForSoundToFinish once the last page has
|
||||
-- typed, so the box holds until the fanfare is over. Merges into a caller's
|
||||
-- opts table; auto.wait keeps the trailing button press the plain A/B path
|
||||
-- gives every other box.
|
||||
function TextBox.soundOpts(game, sound, opts)
|
||||
opts = opts or {}
|
||||
local auto = opts.auto
|
||||
-- auto = true is the no-button-wait arming (text_opts); keep that choice
|
||||
if auto == true then auto = { wait = false } end
|
||||
auto = auto or {}
|
||||
if auto.wait == nil then auto.wait = true end
|
||||
auto.delay = auto.delay or 0
|
||||
auto.sound = type(sound) == "function" and sound
|
||||
or function() return require("src.core.Sound").play(game.data, sound) end
|
||||
opts.auto = auto
|
||||
return opts
|
||||
end
|
||||
|
||||
-- The runtime tokens substitute() knows, as handlers the tokens registry
|
||||
-- serves. Each is fn(game, arg) -> replacement, or nil to drop the token.
|
||||
-- RAM keeps pokered's stale-buffer semantics: give_item copies the item
|
||||
@@ -130,6 +150,7 @@ TextBox.TOKENS = {
|
||||
STRBUF = function(game) return game.stringBuffer end,
|
||||
RAM = function(game, arg)
|
||||
if arg == "wStringBuffer" then return game.stringBuffer end
|
||||
if arg == "wNameBuffer" then return game.stringBuffer end
|
||||
if arg == "wBoxNumString" then return game.boxNumString end
|
||||
-- SendNewMonToBox / _SentToBoxText reads the deposited nick here
|
||||
if arg == "wBoxMonNicks" then return game.boxMonNicks end
|
||||
|
||||
@@ -100,6 +100,7 @@ O.tradeFlags = O.townVisited + 44 -- 2B (flag_array NUM_
|
||||
-- Sits 2 bytes (wPlayerCoins) past O.coins per the walk above; absolute
|
||||
-- 0x2852 (#763, #857).
|
||||
O.toggleObjectFlags = O.coins + 2 -- 32B
|
||||
O.hiddenItemFlags = O.townVisited - 27
|
||||
-- Play time (wPlayTimeHours/Maxed/Minutes/Seconds/Frames) lives INSIDE the
|
||||
-- sMainData window (wMainDataStart..wMainDataEnd is copied verbatim into
|
||||
-- SRAM), 1866 bytes past wMainDataStart -- reached from the checksum-verified
|
||||
@@ -770,6 +771,15 @@ function GenSave.decode(bytes, data, opts)
|
||||
end
|
||||
end
|
||||
|
||||
if data.hiddenItems then
|
||||
save.hiddenTaken = {}
|
||||
for i, row in ipairs(data.hiddenItems) do
|
||||
if bitGet(bytes, O.hiddenItemFlags, i - 1) then
|
||||
save.hiddenTaken[row[1] .. "_" .. row[2] .. "_" .. row[3]] = true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- FLY destinations. wTownVisitedFlag's bit index IS the town's map index:
|
||||
-- engine/items/town_map.asm BuildFlyLocationsList loads the 16-bit value
|
||||
-- into de and rotates it right one bit per iteration with b counting up
|
||||
@@ -953,6 +963,14 @@ function GenSave.encode(save, data, template)
|
||||
end
|
||||
end
|
||||
|
||||
if data.hiddenItems then
|
||||
local taken = save.hiddenTaken or {}
|
||||
for i, row in ipairs(data.hiddenItems) do
|
||||
local key = row[1] .. "_" .. row[2] .. "_" .. row[3]
|
||||
bitSet(buf, O.hiddenItemFlags, i - 1, taken[key] and true or false)
|
||||
end
|
||||
end
|
||||
|
||||
-- FLY destinations back into wTownVisitedFlag (see the decode note), so a
|
||||
-- save exported from this port is flyable on hardware (#263). A save
|
||||
-- table with no `visited` key at all says nothing about the set, so leave
|
||||
|
||||
@@ -49,6 +49,7 @@ local DATA_MODULES = {
|
||||
charmap = { "src.save_convert.data.charmap", "src/save_convert/data/charmap.lua" },
|
||||
eventFlags = { "src.save_convert.data.event_flags", "src/save_convert/data/event_flags.lua" },
|
||||
toggleObjects = { "src.save_convert.data.toggle_objects", "src/save_convert/data/toggle_objects.lua" },
|
||||
hiddenItems = { "src.save_convert.data.hidden_items", "src/save_convert/data/hidden_items.lua" },
|
||||
}
|
||||
|
||||
local OPTIONAL_MODULES = { tilesets = true, audio = true }
|
||||
@@ -64,6 +65,10 @@ local YELLOW_EVENT_FLAGS = {
|
||||
"src.save_convert.data.event_flags_yellow",
|
||||
"src/save_convert/data/event_flags_yellow.lua",
|
||||
}
|
||||
local YELLOW_HIDDEN_ITEMS = {
|
||||
"src.save_convert.data.hidden_items_yellow",
|
||||
"src/save_convert/data/hidden_items_yellow.lua",
|
||||
}
|
||||
|
||||
local function loadTable(requirePath, filePath)
|
||||
local ok, mod = pcall(require, requirePath)
|
||||
@@ -133,6 +138,8 @@ local function ensureData(gameVersion)
|
||||
if name ~= "charmap" then
|
||||
if name == "eventFlags" and gameVersion == "yellow" then
|
||||
spec = YELLOW_EVENT_FLAGS -- Yellow's bit numbering differs (#838)
|
||||
elseif name == "hiddenItems" and gameVersion == "yellow" then
|
||||
spec = YELLOW_HIDDEN_ITEMS
|
||||
end
|
||||
local mod = loadCacheTable(gameVersion, spec[2])
|
||||
if not mod then
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
return {
|
||||
{ "VIRIDIAN_FOREST", 1, 18 }, { "VIRIDIAN_FOREST", 16, 42 },
|
||||
{ "MT_MOON_B2F", 18, 12 }, { "ROUTE_25", 38, 3 },
|
||||
{ "ROUTE_9", 14, 7 }, { "SS_ANNE_KITCHEN", 13, 9 },
|
||||
{ "SS_ANNE_B1F_ROOMS", 3, 1 }, { "ROUTE_10", 9, 17 },
|
||||
{ "ROUTE_10", 16, 53 }, { "ROCKET_HIDEOUT_B1F", 21, 15 },
|
||||
{ "ROCKET_HIDEOUT_B3F", 27, 17 }, { "ROCKET_HIDEOUT_B4F", 25, 1 },
|
||||
{ "POKEMON_TOWER_5F", 4, 12 }, { "ROUTE_13", 1, 14 },
|
||||
{ "ROUTE_13", 16, 13 }, { "POKEMON_MANSION_B1F", 1, 9 },
|
||||
{ "SAFARI_ZONE_GATE", 10, 1 }, { "SAFARI_ZONE_WEST", 6, 5 },
|
||||
{ "SILPH_CO_5F", 12, 3 }, { "SILPH_CO_9F", 2, 15 },
|
||||
{ "COPYCATS_HOUSE_2F", 1, 1 }, { "CERULEAN_CAVE_1F", 14, 11 },
|
||||
{ "CERULEAN_CAVE_B1F", 27, 3 }, { "POWER_PLANT", 17, 16 },
|
||||
{ "POWER_PLANT", 12, 1 }, { "SEAFOAM_ISLANDS_B2F", 15, 15 },
|
||||
{ "SEAFOAM_ISLANDS_B4F", 25, 17 }, { "POKEMON_MANSION_1F", 8, 16 },
|
||||
{ "POKEMON_MANSION_3F", 1, 9 }, { "ROUTE_23", 9, 44 },
|
||||
{ "ROUTE_23", 19, 70 }, { "ROUTE_23", 8, 90 },
|
||||
{ "VICTORY_ROAD_2F", 5, 2 }, { "VICTORY_ROAD_2F", 26, 7 },
|
||||
{ "UNUSED_MAP_6F", 14, 11 }, { "VIRIDIAN_CITY", 14, 4 },
|
||||
{ "ROUTE_11", 48, 5 }, { "ROUTE_12", 2, 63 },
|
||||
{ "ROUTE_17", 15, 14 }, { "ROUTE_17", 8, 45 },
|
||||
{ "ROUTE_17", 17, 72 }, { "ROUTE_17", 4, 91 },
|
||||
{ "ROUTE_17", 8, 121 }, { "UNDERGROUND_PATH_NORTH_SOUTH", 3, 4 },
|
||||
{ "UNDERGROUND_PATH_NORTH_SOUTH", 4, 34 },
|
||||
{ "UNDERGROUND_PATH_WEST_EAST", 12, 2 },
|
||||
{ "UNDERGROUND_PATH_WEST_EAST", 21, 5 }, { "CELADON_CITY", 48, 15 },
|
||||
{ "ROUTE_25", 10, 1 }, { "MT_MOON_B2F", 33, 9 },
|
||||
{ "SEAFOAM_ISLANDS_B3F", 9, 16 }, { "VERMILION_CITY", 14, 11 },
|
||||
{ "CERULEAN_CITY", 15, 8 }, { "ROUTE_4", 40, 3 },
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
return {
|
||||
{ "SILPH_CO_5F", 12, 3 }, { "SILPH_CO_9F", 2, 15 },
|
||||
{ "POKEMON_MANSION_3F", 1, 9 }, { "POKEMON_MANSION_B1F", 1, 9 },
|
||||
{ "SAFARI_ZONE_WEST", 6, 5 }, { "CERULEAN_CAVE_2F", 16, 13 },
|
||||
{ "CERULEAN_CAVE_B1F", 8, 14 }, { "UNUSED_MAP_6F", 14, 11 },
|
||||
{ "SEAFOAM_ISLANDS_B2F", 15, 15 }, { "SEAFOAM_ISLANDS_B3F", 9, 16 },
|
||||
{ "SEAFOAM_ISLANDS_B4F", 25, 17 }, { "VIRIDIAN_FOREST", 1, 18 },
|
||||
{ "VIRIDIAN_FOREST", 16, 42 }, { "MT_MOON_B2F", 18, 12 },
|
||||
{ "MT_MOON_B2F", 33, 9 }, { "SS_ANNE_B1F_ROOMS", 3, 1 },
|
||||
{ "SS_ANNE_KITCHEN", 13, 9 }, { "UNDERGROUND_PATH_NORTH_SOUTH", 3, 4 },
|
||||
{ "UNDERGROUND_PATH_NORTH_SOUTH", 4, 34 },
|
||||
{ "UNDERGROUND_PATH_WEST_EAST", 12, 2 },
|
||||
{ "UNDERGROUND_PATH_WEST_EAST", 21, 5 }, { "ROCKET_HIDEOUT_B1F", 21, 15 },
|
||||
{ "ROCKET_HIDEOUT_B3F", 27, 17 }, { "ROCKET_HIDEOUT_B4F", 25, 1 },
|
||||
{ "ROUTE_10", 9, 17 }, { "ROUTE_10", 16, 53 },
|
||||
{ "POWER_PLANT", 17, 16 }, { "POWER_PLANT", 12, 1 },
|
||||
{ "ROUTE_11", 48, 5 }, { "ROUTE_12", 2, 63 },
|
||||
{ "ROUTE_13", 1, 14 }, { "ROUTE_13", 16, 13 },
|
||||
{ "ROUTE_17", 15, 14 }, { "ROUTE_17", 8, 45 },
|
||||
{ "ROUTE_17", 17, 72 }, { "ROUTE_17", 4, 91 },
|
||||
{ "ROUTE_17", 8, 121 }, { "ROUTE_23", 9, 44 },
|
||||
{ "ROUTE_23", 19, 70 }, { "ROUTE_23", 8, 90 },
|
||||
{ "VICTORY_ROAD_2F", 5, 2 }, { "VICTORY_ROAD_2F", 26, 7 },
|
||||
{ "ROUTE_25", 38, 3 }, { "ROUTE_25", 10, 1 },
|
||||
{ "ROUTE_4", 40, 3 }, { "ROUTE_9", 14, 7 },
|
||||
{ "COPYCATS_HOUSE_2F", 1, 1 }, { "VIRIDIAN_CITY", 14, 4 },
|
||||
{ "CERULEAN_CITY", 15, 8 }, { "CERULEAN_CAVE_1F", 18, 7 },
|
||||
{ "POKEMON_TOWER_5F", 4, 12 }, { "VERMILION_CITY", 14, 11 },
|
||||
{ "CELADON_CITY", 48, 15 }, { "SAFARI_ZONE_GATE", 10, 1 },
|
||||
{ "POKEMON_MANSION_1F", 8, 16 },
|
||||
}
|
||||
+32
-5
@@ -526,6 +526,22 @@ function Commands.set_field(ctx, key, value)
|
||||
ctx.save[key] = value
|
||||
end
|
||||
|
||||
function Commands.load_player_starter_name(ctx)
|
||||
local flags = ctx.save.flags or {}
|
||||
local species = flags.EVENT_CHOSE_PIKACHU and "PIKACHU"
|
||||
or flags.EVENT_CHOSE_CHARMANDER and "CHARMANDER"
|
||||
or flags.EVENT_CHOSE_SQUIRTLE and "SQUIRTLE"
|
||||
or flags.EVENT_CHOSE_BULBASAUR and "BULBASAUR"
|
||||
or (ctx.save.party and ctx.save.party[1] and ctx.save.party[1].species)
|
||||
local def = species and ctx.game.data.pokemon[species]
|
||||
ctx.game.stringBuffer = def and def.name or species or ""
|
||||
end
|
||||
|
||||
function Commands.spawn_pikachu_follower(ctx)
|
||||
require("src.world.PikachuFollower").onMapEntered(
|
||||
ctx.game, ctx.overworld, nil, false)
|
||||
end
|
||||
|
||||
local function toggleObject(ctx, mapId, objName, visible)
|
||||
local save = ctx.save
|
||||
save.objectToggles = save.objectToggles or {}
|
||||
@@ -576,6 +592,17 @@ function Commands.play_sound(ctx, soundId)
|
||||
require("src.core.Sound").play(ctx.game.data, soundId)
|
||||
end
|
||||
|
||||
-- text_sound <soundId>: the jingle the ROM parks at the END of a string as
|
||||
-- a trailing text command (sound_get_item_1, sound_get_key_item ->
|
||||
-- home/text.asm TextCommand_SOUND). It arms the NEXT show_text the same
|
||||
-- way play_cry arms ctx.pendingCry, so the fanfare fires once the last page
|
||||
-- has typed and the box holds on WaitForSoundToFinish before the button
|
||||
-- wait. play_sound stays the bare PlaySound used for the non-blocking
|
||||
-- beats (Bill's teleporter, the S.S. Anne horn).
|
||||
function Commands.text_sound(ctx, soundId)
|
||||
ctx.textOpts = TextBox.soundOpts(ctx.game, soundId, ctx.textOpts)
|
||||
end
|
||||
|
||||
-- play_once <songId>: one-shot jingle (Music_PkmnHealed, etc.); blocks
|
||||
-- until it finishes so heal-rest scripts (Mom, captain text_asm) match
|
||||
-- Gen1's wait-on-channel loop. The map theme resumes when it ends
|
||||
@@ -1034,8 +1061,10 @@ function Commands.trade(ctx, tradeIndex, doneFlag)
|
||||
onDone = function() runner:resume() end,
|
||||
})
|
||||
runner:yield()
|
||||
-- TradedForText (sound_get_key_item) then the dialogset's thanks
|
||||
require("src.core.Sound").play(data, "Get_Key_Item")
|
||||
-- TradedForText carries sound_get_key_item after the text, so the jingle
|
||||
-- rides the box and blocks it (home/text.asm TextCommand_SOUND), then the
|
||||
-- dialogset's thanks
|
||||
Commands.text_sound(ctx, "Get_Key_Item")
|
||||
say(texts.tradedFor or "_TradedForText")
|
||||
say(texts.thanks or "_Thanks" .. dialogset .. "Text")
|
||||
end
|
||||
@@ -1131,9 +1160,7 @@ end
|
||||
-- opts.tempo is the Music_*AlternateTempo override (audio/alternate_tempo.asm
|
||||
-- re-points channel 1 at a stub that only changes the song's `tempo`) (#847).
|
||||
function Commands.play_music(ctx, songId, opts)
|
||||
local tempo = opts and opts.tempo
|
||||
require("src.core.Music").play(ctx.game.data, songId, nil,
|
||||
tempo and { tempo = tempo } or nil)
|
||||
require("src.core.Music").play(ctx.game.data, songId, nil, opts)
|
||||
if opts and opts.keep and ctx.overworld then
|
||||
ctx.overworld.keepMusicOnce = true
|
||||
end
|
||||
|
||||
+25
-11
@@ -33,12 +33,12 @@ local function save_name(game)
|
||||
return game.save.player.name
|
||||
end
|
||||
|
||||
local function showMessages(game, msgs, onDone)
|
||||
local function showMessages(game, msgs, onDone, opts)
|
||||
if not msgs or #msgs == 0 then
|
||||
if onDone then onDone() end
|
||||
return
|
||||
end
|
||||
game.stack:push(TextBox.new(game, table.concat(msgs, "\f"), onDone))
|
||||
game.stack:push(TextBox.new(game, table.concat(msgs, "\f"), onDone, opts))
|
||||
end
|
||||
|
||||
-- run the use-flow for an item on a chosen target. `picker` is the party
|
||||
@@ -60,6 +60,14 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
|
||||
return
|
||||
end
|
||||
|
||||
if result == "flute_wake_pikachu" then
|
||||
require("src.core.Sound").play(game.data, "Pokeflute")
|
||||
showMessages(game, payload, function()
|
||||
game.overworld.pikachuPewterSleepScene = nil
|
||||
end)
|
||||
return
|
||||
end
|
||||
|
||||
-- field POKé FLUTE next to a not-yet-beaten Snorlax: "had effect" text,
|
||||
-- then the woke-up/battle sequence (data/scripts/story.lua snorlaxWake)
|
||||
if result == "flute_wake" then
|
||||
@@ -142,12 +150,9 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
|
||||
list:close()
|
||||
local ow = game.overworld
|
||||
local p = ow and ow.player
|
||||
if ow and p then
|
||||
local fx, fy = p:facingCell()
|
||||
if ow.map:inBounds(fx, fy) and ow.map:isWaterCell(fx, fy) then
|
||||
ow:goFishing(id)
|
||||
return
|
||||
end
|
||||
if ow and p and ow:facingIsShoreOrWater() then
|
||||
ow:goFishing(id)
|
||||
return
|
||||
end
|
||||
showMessages(game, { Strings("No good! It's not\neven near water.") })
|
||||
return
|
||||
@@ -176,19 +181,27 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
|
||||
end
|
||||
if #target.moves < 4 then
|
||||
table.insert(target.moves, { id = moveId, pp = mdef.pp })
|
||||
-- LearnedMove1Text: text_far, sound_get_item_1, text_promptbutton
|
||||
-- (learn_move.asm), so the jingle rides the box
|
||||
showMessages(game, { Strings("%s learned\n%s!", target.nickname or
|
||||
game.data.pokemon[target.species].name, mdef.name) })
|
||||
game.data.pokemon[target.species].name, mdef.name) }, nil,
|
||||
TextBox.soundOpts(game, "Get_Item1"))
|
||||
if result == "learn" then consume(game, id) end
|
||||
list.items = buildItems(game)
|
||||
list.index = math.min(list.index, math.max(1, #list.items))
|
||||
taught()
|
||||
else
|
||||
require("src.ui.Screens").push(game, "MoveLearnMenu", target, moveId,
|
||||
function(learned)
|
||||
if learned and result == "learn" then consume(game, id) end
|
||||
if learned then
|
||||
list.items = buildItems(game)
|
||||
list.index = math.min(list.index, math.max(1, #list.items))
|
||||
end
|
||||
if learned then taught() end
|
||||
end)
|
||||
end
|
||||
end
|
||||
list:close()
|
||||
teach()
|
||||
return
|
||||
end
|
||||
@@ -324,7 +337,7 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
|
||||
table.insert(target.moves, { id = moveId, pp = mdef.pp })
|
||||
local name = target.nickname or def.name
|
||||
showMessages(game, { Strings("%s learned\n%s!", name, mdef.name) },
|
||||
nextStep)
|
||||
nextStep, TextBox.soundOpts(game, "Get_Item1"))
|
||||
else
|
||||
require("src.ui.Screens").push(game, "MoveLearnMenu",
|
||||
target, moveId, nextStep)
|
||||
@@ -370,6 +383,7 @@ local function pickTargetAndUse(game, battle, id, list)
|
||||
local def = game.data.items[id]
|
||||
local opts = {
|
||||
pickOnly = true,
|
||||
battle = battle,
|
||||
-- HP medicine animates its bar with the picker still up (#252). Only
|
||||
-- out of battle: the in-battle tail closes the bag list underneath
|
||||
-- first, which needs the picker already gone.
|
||||
|
||||
@@ -119,6 +119,13 @@ local function deposit(game)
|
||||
onChoose = function(item, list)
|
||||
local mon = game.save.party[item.value]
|
||||
if not mon then return end
|
||||
local Follower = require("src.world.PikachuFollower")
|
||||
if Follower.isFollowingDisabled(game.overworld)
|
||||
and Follower.isStarterPikachu(game.save, mon) then
|
||||
game.stack:push(TextBox.new(game, t._SleepingPikachuText2
|
||||
or Strings("There isn't any\nresponse...")))
|
||||
return
|
||||
end
|
||||
monSubmenu(game, "DEPOSIT", mon, function()
|
||||
if #game.save.party <= 1 then
|
||||
list.footer = Strings("You need at least\none POKéMON!")
|
||||
@@ -167,6 +174,16 @@ local function release(game)
|
||||
local mon = box[list.index]
|
||||
if not mon then return end
|
||||
local name = monName(game, mon)
|
||||
if require("src.core.GameVersion").isYellow()
|
||||
and mon.species == "PIKACHU"
|
||||
and mon.otId == game.save.player.id
|
||||
and mon.ot == game.save.player.name then
|
||||
require("src.core.Sound").playCry(game.data, mon.species)
|
||||
game.stack:push(TextBox.new(game,
|
||||
(t._PikachuUnhappyText or Strings("%s looks\nunhappy about it!", name))
|
||||
:gsub("{RAM:wNameBuffer}", name)))
|
||||
return
|
||||
end
|
||||
game.stack:push(TextBox.new(game,
|
||||
Strings("Once released,\n%s is\ngone forever. OK?", name), nil, {
|
||||
defaultNo = true, noSound = true,
|
||||
|
||||
@@ -46,9 +46,27 @@ function EvolutionState:sgbPalettes(game)
|
||||
return P.wholeNamed(game.data, "MEWMON")
|
||||
end
|
||||
|
||||
local FLASH_FRAMES = 220
|
||||
-- evolution.asm EvolveMon delays 80 frames before .animLoop, polling nothing (#968, #1031)
|
||||
local CANCEL_GRACE_FRAMES = 80
|
||||
-- .animLoop starts at `lb bc, $1, $10` and runs 8 iterations: iteration k holds
|
||||
-- the old pic for c = 18-2k frames inside Evolution_CheckForCancel (72 in all),
|
||||
-- then Evolution_BackAndForthAnim swaps b = k times, each swap a pair of
|
||||
-- Evolution_ChangeMonPic that end in Delay3 (6 frames a swap, 216 in all).
|
||||
local ANIM_LOOP_FRAMES = 288
|
||||
local FLASH_FRAMES = CANCEL_GRACE_FRAMES + ANIM_LOOP_FRAMES
|
||||
|
||||
-- which pic is on screen t frames into .animLoop
|
||||
local function evoShowsNew(t)
|
||||
for b = 1, 8 do
|
||||
local hold = 18 - 2 * b
|
||||
if t < hold then return false end
|
||||
t = t - hold
|
||||
local swap = b * 6
|
||||
if t < swap then return t % 6 < 3 end
|
||||
t = t - swap
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local function frontSprite(game, species, mon)
|
||||
local path, trueColor = require("src.pokemon.Sprites").path(
|
||||
@@ -140,14 +158,10 @@ function EvolutionState:draw()
|
||||
else
|
||||
sprite, spriteTrueColor = self.newSprite, self.newSpriteTrueColor
|
||||
end
|
||||
elseif evoShowsNew(self.t - CANCEL_GRACE_FRAMES) then
|
||||
sprite, spriteTrueColor = self.newSprite, self.newSpriteTrueColor
|
||||
else
|
||||
local period = math.max(4, 28 - math.floor(self.t / 40) * 6)
|
||||
local showNew = math.floor(self.t / period) % 2 == 1
|
||||
if showNew then
|
||||
sprite, spriteTrueColor = self.newSprite, self.newSpriteTrueColor
|
||||
else
|
||||
sprite, spriteTrueColor = self.oldSprite, self.oldSpriteTrueColor
|
||||
end
|
||||
sprite, spriteTrueColor = self.oldSprite, self.oldSpriteTrueColor
|
||||
end
|
||||
if sprite then
|
||||
local x = math.floor((160 - sprite:getWidth()) / 2)
|
||||
|
||||
+16
-3
@@ -6,6 +6,7 @@ local GameVersion = require("src.core.GameVersion")
|
||||
local Music = require("src.core.Music")
|
||||
local Sound = require("src.core.Sound")
|
||||
local Strings = require("src.core.Strings")
|
||||
local Timing = require("src.core.Timing")
|
||||
|
||||
local IntroMovie = {}
|
||||
IntroMovie.__index = IntroMovie
|
||||
@@ -232,6 +233,10 @@ function IntroMovie:fightStep()
|
||||
self:finish()
|
||||
return
|
||||
end
|
||||
-- an op that consumed frames ends ON its last frame; only the instant
|
||||
-- ops (sfx / pose / frame, which are plain writes between DelayFrames
|
||||
-- calls in PlayIntroScene) chain into the next op the same frame
|
||||
local timed = false
|
||||
if op.sfx then
|
||||
Sound.play(self.game.data, op.sfx)
|
||||
elseif op.pose then
|
||||
@@ -250,6 +255,7 @@ function IntroMovie:fightStep()
|
||||
end
|
||||
self.opTimer = self.opTimer + 1
|
||||
if self.opTimer < (op.px or math.abs(op.dx)) then return end
|
||||
timed = true
|
||||
elseif op.anim then
|
||||
-- one {dy,dx} delta per 5 frames (AnimateIntroNidorino: DelayFrames 5)
|
||||
if self.opTimer % 5 == 0 then
|
||||
@@ -259,9 +265,11 @@ function IntroMovie:fightStep()
|
||||
end
|
||||
self.opTimer = self.opTimer + 1
|
||||
if self.opTimer < #ANIM[op.anim] * 5 then return end
|
||||
timed = true
|
||||
elseif op.wait then
|
||||
self.opTimer = self.opTimer + 1
|
||||
if self.opTimer < op.wait then return end
|
||||
timed = true
|
||||
elseif op.fade then
|
||||
self.opTimer = self.opTimer + 1
|
||||
self.fade = self.opTimer / op.fade
|
||||
@@ -270,6 +278,7 @@ function IntroMovie:fightStep()
|
||||
end
|
||||
self.opIndex = self.opIndex + 1
|
||||
self.opTimer = 0
|
||||
if timed then return end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -284,8 +293,9 @@ function IntroMovie:update(dt)
|
||||
return
|
||||
end
|
||||
local input = self.game.input
|
||||
if input:wasPressed("a") or input:wasPressed("b")
|
||||
or input:wasPressed("start") then
|
||||
if input:wasPressed("a") or input:wasPressed("start") then
|
||||
-- CheckForUserInterruption (home/overworld.asm:2395) returns carry only
|
||||
-- on a fresh START or A -- B alone never skips the intro.
|
||||
-- PlayIntro still GBFadeOutToWhite's after an interrupted scene; the
|
||||
-- white hold stands in for that beat before the title is built.
|
||||
self:exitToTitle()
|
||||
@@ -300,7 +310,10 @@ function IntroMovie:update(dt)
|
||||
end
|
||||
if self.timer >= SPLASH_FRAMES then self:startPhase(3) end
|
||||
else
|
||||
self:fightStep()
|
||||
-- PlayShootingStar ends `jp Delay3` once Music_IntroBattle is playing
|
||||
-- (intro.asm:337), so PlayIntroScene's first op is not on the music's
|
||||
-- own frame
|
||||
if self.timer > Timing.DELAY3 then self:fightStep() end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -17,12 +17,13 @@ local HM_MOVES = {
|
||||
CUT = true, FLY = true, SURF = true, STRENGTH = true, FLASH = true,
|
||||
}
|
||||
|
||||
function MoveLearnMenu.new(game, mon, newMoveId, onDone)
|
||||
function MoveLearnMenu.new(game, mon, newMoveId, onDone, learnedSound)
|
||||
local self = setmetatable({}, MoveLearnMenu)
|
||||
self.game = game
|
||||
self.mon = mon
|
||||
self.newMoveId = newMoveId
|
||||
self.onDone = onDone
|
||||
self.learnedSound = learnedSound or "Get_Item1"
|
||||
self.index = 1
|
||||
-- forget-list UI only after TryingToLearn YES (learn_move.asm .loop)
|
||||
self.selecting = false
|
||||
@@ -118,6 +119,7 @@ function MoveLearnMenu:finish(learned)
|
||||
self.selecting = false
|
||||
game.stack:pop()
|
||||
local msg
|
||||
local opts
|
||||
if learned then
|
||||
-- pokered pages this as four texts in a row; _ForgotAndText carries
|
||||
-- the "And..." tail
|
||||
@@ -127,13 +129,16 @@ function MoveLearnMenu:finish(learned)
|
||||
"\f%s forgot\n%s!\fAnd...", name, self.forgot)
|
||||
.. "\f" .. romText(game.data, "_LearnedMove1Text",
|
||||
"%s learned\n%s!", name, mdef.name)
|
||||
opts = { auto = { sound = function()
|
||||
return require("src.core.Sound").play(game.data, self.learnedSound)
|
||||
end, wait = true } }
|
||||
else
|
||||
msg = romText(game.data, "_DidNotLearnText",
|
||||
"%s\ndid not learn\v%s!", name, mdef.name)
|
||||
end
|
||||
game.stack:push(TextBox.new(game, msg, function()
|
||||
if self.onDone then self.onDone(learned) end
|
||||
end))
|
||||
end, opts))
|
||||
end
|
||||
|
||||
function MoveLearnMenu:draw()
|
||||
|
||||
@@ -98,14 +98,14 @@ function OakSpeech.resolvePic(game, desc, speech)
|
||||
local t = desc.type
|
||||
if t == "trainer" then
|
||||
if speech and desc.id == "OPP_PROF_OAK" and speech.oakPic then
|
||||
return speech.oakPic, false, false
|
||||
return speech.oakPic, false, speech.oakTrueColor or false
|
||||
end
|
||||
if speech and desc.id == "OPP_RIVAL1" and speech.rivalPic then
|
||||
return speech.rivalPic, false, false
|
||||
return speech.rivalPic, false, speech.rivalTrueColor or false
|
||||
end
|
||||
local trainers = game.data.trainers or {}
|
||||
local tr = trainers[desc.id]
|
||||
return tryImage(tr and tr.pic), false, false
|
||||
return tryImage(tr and tr.pic), false, tr and tr.trueColor or false
|
||||
elseif t == "pokemon" then
|
||||
if speech and desc.id == speech.demoSpecies and speech.demoPic then
|
||||
return speech.demoPic, desc.flip and true or false, speech.demoTrueColor
|
||||
@@ -241,7 +241,11 @@ function OakSpeech.new(game, onDone)
|
||||
self.answers = {}
|
||||
local trainers = game.data.trainers or {}
|
||||
self.oakPic = tryImage(trainers.OPP_PROF_OAK and trainers.OPP_PROF_OAK.pic)
|
||||
self.oakTrueColor = self.oakPic
|
||||
and trainers.OPP_PROF_OAK and trainers.OPP_PROF_OAK.trueColor or false
|
||||
self.rivalPic = tryImage(trainers.OPP_RIVAL1 and trainers.OPP_RIVAL1.pic)
|
||||
self.rivalTrueColor = self.rivalPic
|
||||
and trainers.OPP_RIVAL1 and trainers.OPP_RIVAL1.trueColor or false
|
||||
local oakGfx = (game.data.field and game.data.field.oakSpeech) or {}
|
||||
self.cfg = oakGfx
|
||||
-- the show-off mon and the name length cap come from data; the vanilla
|
||||
|
||||
+111
-81
@@ -72,6 +72,30 @@ end
|
||||
|
||||
local function sameItems(_, items) return items end
|
||||
|
||||
local function followerUnavailable(game, mon)
|
||||
local ow = game.overworld
|
||||
local Follower = require("src.world.PikachuFollower")
|
||||
return Follower.isFollowingDisabled(ow)
|
||||
and Follower.isStarterPikachu(game.save, mon)
|
||||
end
|
||||
|
||||
local function refuseUnavailable(self)
|
||||
self.swapFrom = nil
|
||||
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._SleepingPikachuText1 or Strings("There isn't any\nresponse...")))
|
||||
end
|
||||
|
||||
-- .newBadgeRequired (start_sub_menus.asm): every badge-gated arm of
|
||||
-- .outOfBattleMovePointers prints this and jumps back to the open submenu
|
||||
local function refuseBadge(self)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local t = self.game.data and self.game.data.text or {}
|
||||
self.game.stack:push(TextBox.new(self.game,
|
||||
t._NewBadgeRequiredText or Strings("No! A new BADGE\nis required.")))
|
||||
end
|
||||
|
||||
-- where DIG escapes work: escape_rope_tilesets.asm (Agatha's room is
|
||||
-- excluded by map id in ItemUseEscapeRope)
|
||||
local DIG_TILESETS = { FOREST = true, CEMETERY = true, CAVERN = true,
|
||||
@@ -265,6 +289,7 @@ function PartyMenu.new(game, opts)
|
||||
opts = opts or {}
|
||||
local self = setmetatable({}, PartyMenu)
|
||||
self.game = game
|
||||
local party = opts.party or (opts.battle and opts.battle.playerParty)
|
||||
-- PartyMenuInit (home/pokemon.asm) seeds the cursor from
|
||||
-- wPartyAndBillsPCSavedMenuItem rather than from zero, and
|
||||
-- HandlePartyMenuInput writes wCurrentMenuItem back into it on every
|
||||
@@ -273,7 +298,7 @@ function PartyMenu.new(game, opts)
|
||||
-- both zero the byte, which BattleState mirrors. The clamp covers a
|
||||
-- party that shrank (deposit / release) while the saved index was
|
||||
-- pointing past the end. #768
|
||||
local count = #(opts.party or (game.save and game.save.party) or {})
|
||||
local count = #(party or (game.save and game.save.party) or {})
|
||||
self.index = math.min(math.max(1, game.partyMenuSavedIndex or 1),
|
||||
math.max(1, count))
|
||||
self.onSwitch = opts.onSwitch
|
||||
@@ -290,7 +315,7 @@ function PartyMenu.new(game, opts)
|
||||
self.tmhm = opts.tmhm
|
||||
self.forceSwitch = opts.forceSwitch
|
||||
self.battle = opts.battle
|
||||
self.party = opts.party -- link battles pass their clamped copies
|
||||
self.party = party -- link/scoped battles pass their local party view
|
||||
self.swapFrom = nil
|
||||
self.submenu = nil
|
||||
self.subIndex = 1
|
||||
@@ -367,6 +392,10 @@ function PartyMenu:update(dt)
|
||||
self.submenu = nil
|
||||
elseif input:wasPressed("a") then
|
||||
local mon = party[self.index]
|
||||
if followerUnavailable(self.game, mon) then
|
||||
refuseUnavailable(self)
|
||||
return
|
||||
end
|
||||
local entry = self.subItems[self.subIndex]
|
||||
local action = entry.action
|
||||
if not action and entry.onSelect then
|
||||
@@ -392,6 +421,23 @@ function PartyMenu:update(dt)
|
||||
-- flyTo (OverworldController) validates the fly-warp + runs the
|
||||
-- departure/warp, so we just hand it the chosen mapId (#195).
|
||||
local ow = self.game.overworld
|
||||
-- .fly checks THUNDERBADGE first, then CheckIfInOutsideMap
|
||||
-- (OVERWORLD + PLATEAU -- Route 23 / Indigo Plateau outdoor -- not
|
||||
-- OVERWORLD alone, #83); both refusals loop back to the submenu
|
||||
if ow and not ow:partyKnows("FLY") then
|
||||
refuseBadge(self)
|
||||
return
|
||||
end
|
||||
if ow and not Map.isOutside(ow.map.def,
|
||||
FieldDefaults.field(self.game.data, "outsideTilesets")) then
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local def = self.game.data.pokemon[mon.species]
|
||||
local txt = (self.game.data.text._CannotFlyHereText
|
||||
or Strings("{RAM:wNameBuffer} can't\nFLY here."))
|
||||
:gsub("{RAM:wNameBuffer}", mon.nickname or def.name)
|
||||
self.game.stack:push(TextBox.new(self.game, txt))
|
||||
return -- .loop: submenu stays open behind the message
|
||||
end
|
||||
self.game.stack:pop() -- close the party menu
|
||||
Screens.push(self.game, "TownMap", { fly = true, onFly = function(mapId)
|
||||
if ow then ow:flyTo(mapId) end
|
||||
@@ -404,30 +450,15 @@ function PartyMenu:update(dt)
|
||||
-- over the menu, and the cave is lit when the blink hands the
|
||||
-- screen back, never under the text (#385).
|
||||
local ow = self.game.overworld
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Transition = require("src.render.Transition")
|
||||
self.game.save.flashLit = true
|
||||
self.game.stack:push(TextBox.new(self.game,
|
||||
self.game.data.text._FlashLightsAreaText
|
||||
or Strings("A blinding FLASH\nlights the area!"), function()
|
||||
self:close()
|
||||
-- setDark, not a bare field write: ADVANCED carries the darkness
|
||||
-- in a baked atlas, so lighting the cave drops every resident map
|
||||
-- and rebakes this one (#383). It runs HERE, before the blink,
|
||||
-- because start_sub_menus.asm .flash clears wMapPalOffset before
|
||||
-- PrintText and blinks last of all: the cave is already lit by the
|
||||
-- time GBPalWhiteOutWithDelay3 runs. Hanging the rebuild off the
|
||||
-- blink's completion instead left that rebuild's whole cost --
|
||||
-- seconds of per-pixel atlas baking on a phone -- on screen as a
|
||||
-- solid white frame with nothing under it, which reads as a
|
||||
-- lockup (#610).
|
||||
ow:setDark(false)
|
||||
self.game.stack:push(Transition.whiteFlash(self.game))
|
||||
end))
|
||||
if ow and not ow:partyKnows("FLASH") then
|
||||
refuseBadge(self)
|
||||
return
|
||||
end
|
||||
ow:useFlashFieldMove(function() self:close() end)
|
||||
return
|
||||
elseif action == "surf" then
|
||||
-- start_sub_menus.asm .surf: SOULBADGE-gated (checked at list time
|
||||
-- above), then IsSurfingAllowed (the Cycling Road / Seafoam B4F
|
||||
-- start_sub_menus.asm .surf: SOULBADGE-gated (useSurfFieldMove),
|
||||
-- then IsSurfingAllowed (the Cycling Road / Seafoam B4F
|
||||
-- current refusals, both of which loop back to the submenu), then
|
||||
-- ItemUseSurfboard: while surfing it tries to dismount instead;
|
||||
-- otherwise it mounts only if the FACING tile is water, else
|
||||
@@ -451,12 +482,7 @@ function PartyMenu:update(dt)
|
||||
-- GBPalWhiteOutWithDelay3 blink, and the simulated pad press
|
||||
-- steps the player forward onto land (or across a connection
|
||||
-- strip when the shore is the next map's edge)
|
||||
self.game.stack:pop()
|
||||
ow.player.surfing = false
|
||||
require("src.core.Music").setSurfing(self.game.data, false)
|
||||
self.game.stack:push(Transition.whiteFlash(self.game, nil, function()
|
||||
ow:stepForwardOrCrossEdge(ow.player.facing)
|
||||
end))
|
||||
ow:stopSurfing(function() self.game.stack:pop() end)
|
||||
return
|
||||
end
|
||||
local TextBox = require("src.render.TextBox")
|
||||
@@ -484,8 +510,8 @@ function PartyMenu:update(dt)
|
||||
return -- .loop: submenu stays open behind the message
|
||||
elseif action == "cut" then
|
||||
-- start_sub_menus.asm .cut -> predef UsedCut (engine/overworld/cut.asm):
|
||||
-- CASCADEBADGE-gated (list time); _NothingToCutText loops back to the
|
||||
-- submenu when the FACING tile isn't a cuttable tree.
|
||||
-- CASCADEBADGE-gated (useCutFieldMove); _NothingToCutText loops back
|
||||
-- to the submenu when the FACING tile isn't a cuttable tree.
|
||||
local ow = self.game.overworld
|
||||
local reason = ow:useCutFieldMove()
|
||||
if reason == "ok" then
|
||||
@@ -503,7 +529,7 @@ function PartyMenu:update(dt)
|
||||
self.game.stack:push(TextBox.new(self.game, txt))
|
||||
return -- .loop: submenu stays open behind the message
|
||||
elseif action == "strength" then
|
||||
-- start_sub_menus.asm .strength: RAINBOWBADGE-gated (list time);
|
||||
-- start_sub_menus.asm .strength: RAINBOWBADGE-gated;
|
||||
-- predef PrintStrengthText (field_move_messages.asm) sets
|
||||
-- BIT_STRENGTH_ACTIVE of wStatusFlags1 -- the sole gate
|
||||
-- push_boulder.asm reads -- then prints _UsedStrengthText (no
|
||||
@@ -513,26 +539,11 @@ function PartyMenu:update(dt)
|
||||
-- .strength, GBPalWhiteOutWithDelay3 blinks the screen white
|
||||
-- before CloseTextDisplay returns to the map.
|
||||
local ow = self.game.overworld
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Transition = require("src.render.Transition")
|
||||
local def = self.game.data.pokemon[mon.species]
|
||||
local name = mon.nickname or def.name
|
||||
ow.strengthActive = true
|
||||
local t1 = (self.game.data.text._UsedStrengthText
|
||||
or Strings("{RAM:wNameBuffer} used\nSTRENGTH.")):gsub("{RAM:wNameBuffer}", name)
|
||||
local t2 = (self.game.data.text._CanMoveBouldersText
|
||||
or Strings("{RAM:wNameBuffer} can\nmove boulders.")):gsub("{RAM:wNameBuffer}", name)
|
||||
-- like surf (#320, #385): both texts print with the party menu
|
||||
-- still on screen, and the blink IS the menu closing afterwards,
|
||||
-- not a flashbang on the empty map
|
||||
self.game.stack:push(TextBox.new(self.game, t1, function()
|
||||
self.game.stack:push(TextBox.new(self.game, t2, function()
|
||||
self:close()
|
||||
self.game.stack:push(Transition.whiteFlash(self.game))
|
||||
end))
|
||||
end, { auto = { sound = function()
|
||||
return require("src.core.Sound").playCry(self.game.data, mon.species)
|
||||
end } }))
|
||||
if ow and not ow:partyKnows("STRENGTH") then
|
||||
refuseBadge(self)
|
||||
return
|
||||
end
|
||||
ow:useStrengthFieldMove(mon, function() self:close() end)
|
||||
return
|
||||
elseif action == "softboiled" then
|
||||
-- field SOFTBOILED (StartMenu_Pokemon .softboiled): transfer
|
||||
@@ -546,6 +557,33 @@ function PartyMenu:update(dt)
|
||||
-- centralizes the spin -> fade -> warp so BagMenu's ESCAPE ROPE shares
|
||||
-- the exact departure; the fade + warp fire when the spin ends.
|
||||
local ow = self.game.overworld
|
||||
if entry.move == "TELEPORT" then
|
||||
-- .teleport: TELEPORT works only OUTDOORS (CheckIfInOutsideMap --
|
||||
-- OVERWORLD + PLATEAU, #83); dark maps don't block it
|
||||
if ow and not Map.isOutside(ow.map.def,
|
||||
FieldDefaults.field(self.game.data, "outsideTilesets")) then
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local def = self.game.data.pokemon[mon.species]
|
||||
local txt = (self.game.data.text._CannotUseTeleportNowText
|
||||
or Strings("{RAM:wNameBuffer} can't\nuse TELEPORT now."))
|
||||
:gsub("{RAM:wNameBuffer}", mon.nickname or def.name)
|
||||
self.game.stack:push(TextBox.new(self.game, txt))
|
||||
return -- .loop: submenu stays open behind the message
|
||||
end
|
||||
elseif ow and not (DIG_TILESETS[ow.map.def.tileset]
|
||||
and ow.map.id ~= "AGATHAS_ROOM") then
|
||||
-- .dig runs ItemUseEscapeRope (it sets wCurItem = ESCAPE_ROPE):
|
||||
-- usable in the dungeon tilesets of escape_rope_tilesets.asm minus
|
||||
-- Agatha's room, even in the dark (Rock Tunnel); anywhere else
|
||||
-- .notUsable -> ItemUseNotTime, the same line BagMenu prints for a
|
||||
-- bagged ESCAPE ROPE
|
||||
local TextBox = require("src.render.TextBox")
|
||||
self.game.stack:push(TextBox.new(self.game,
|
||||
self.game.data.text._ItemUseNotTimeText
|
||||
or Strings("OAK: %s!\nThis isn't the\ntime to use that!",
|
||||
self.game.save.player.name)))
|
||||
return -- .loop: submenu stays open behind the message
|
||||
end
|
||||
self.game.stack:pop()
|
||||
if ow then ow:beginTeleportOut() end
|
||||
return
|
||||
@@ -577,6 +615,10 @@ function PartyMenu:update(dt)
|
||||
if self.onCancel then self.onCancel() end
|
||||
elseif input:wasPressed("a") and #party > 0 then
|
||||
local mon = party[self.index]
|
||||
if followerUnavailable(self.game, mon) then
|
||||
refuseUnavailable(self)
|
||||
return
|
||||
end
|
||||
if self.softboiledFrom then
|
||||
local user = party[self.softboiledFrom]
|
||||
local heal = math.floor(user.stats.hp / 5)
|
||||
@@ -635,42 +677,30 @@ function PartyMenu:update(dt)
|
||||
-- Battle still excludes this list via `not self.battle`. Softboiled
|
||||
-- can appear for a fainted user; its heal transfer then no-ops.
|
||||
if not self.battle and ow then
|
||||
-- FLY/TELEPORT: CheckIfInOutsideMap (OVERWORLD + PLATEAU --
|
||||
-- Route 23 / Indigo Plateau outdoor), not OVERWORLD alone (#83)
|
||||
local outside = Map.isOutside(ow.map.def,
|
||||
FieldDefaults.field(self.game.data, "outsideTilesets"))
|
||||
-- GetMonFieldMoves (engine/menus/text_box.asm) matches the mon's
|
||||
-- four moves against FieldMoveDisplayData and nothing else -- no
|
||||
-- badge, no map, no tileset test. Every one of those lives in
|
||||
-- .outOfBattleMovePointers, i.e. on selection, where the refusal
|
||||
-- prints and .loop returns to this still-open submenu (#1022).
|
||||
for _, mv in ipairs(mon.moves) do
|
||||
if mv.id == "FLY" and outside
|
||||
and self.game.save.inventory.THUNDERBADGE then
|
||||
if mv.id == "FLY" then
|
||||
table.insert(items, { label = Strings("FLY"), action = "fly" })
|
||||
elseif mv.id == "FLASH" and ow.dark
|
||||
and self.game.save.inventory.BOULDERBADGE then
|
||||
elseif mv.id == "FLASH" then
|
||||
table.insert(items, { label = Strings("FLASH"), action = "flash" })
|
||||
elseif mv.id == "CUT" and self.game.save.inventory.CASCADEBADGE then
|
||||
-- CUT/SURF/STRENGTH are party-menu field moves too
|
||||
-- (start_sub_menus.asm .outOfBattleMovePointers); listed here
|
||||
-- with the same list-time badge filter this file already uses
|
||||
-- for FLY/FLASH. The facing-tile/activation check happens on
|
||||
-- selection (useCutFieldMove/useSurfFieldMove).
|
||||
elseif mv.id == "CUT" then
|
||||
table.insert(items, { label = Strings("CUT"), action = "cut" })
|
||||
elseif mv.id == "SURF" and self.game.save.inventory.SOULBADGE then
|
||||
elseif mv.id == "SURF" then
|
||||
table.insert(items, { label = Strings("SURF"), action = "surf" })
|
||||
elseif mv.id == "STRENGTH" and self.game.save.inventory.RAINBOWBADGE then
|
||||
elseif mv.id == "STRENGTH" then
|
||||
table.insert(items, { label = Strings("STRENGTH"), action = "strength" })
|
||||
elseif mv.id == "SOFTBOILED" then
|
||||
table.insert(items, { label = Strings("SOFTBOILED"), action = "softboiled" })
|
||||
elseif mv.id == "TELEPORT" and outside then
|
||||
-- TELEPORT works only OUTDOORS (start_sub_menus.asm
|
||||
-- .teleport -> CheckIfInOutsideMap); dark maps don't
|
||||
-- block it
|
||||
table.insert(items, { label = Strings("TELEPORT"), action = "escape" })
|
||||
elseif mv.id == "DIG" and DIG_TILESETS[ow.map.def.tileset]
|
||||
and ow.map.id ~= "AGATHAS_ROOM" then
|
||||
-- DIG runs ItemUseEscapeRope (.dig sets wCurItem =
|
||||
-- ESCAPE_ROPE): usable in the dungeon tilesets of
|
||||
-- escape_rope_tilesets.asm minus Agatha's room, even in
|
||||
-- the dark (Rock Tunnel)
|
||||
table.insert(items, { label = Strings("DIG"), action = "escape" })
|
||||
elseif mv.id == "TELEPORT" then
|
||||
table.insert(items, { label = Strings("TELEPORT"),
|
||||
action = "escape", move = "TELEPORT" })
|
||||
elseif mv.id == "DIG" then
|
||||
table.insert(items, { label = Strings("DIG"),
|
||||
action = "escape", move = "DIG" })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -104,6 +104,27 @@ local function sell(game)
|
||||
dialogue = true,
|
||||
money = function() return game.save.money end,
|
||||
footer = greet,
|
||||
onSelectKey = function(item, l)
|
||||
if not item then return end
|
||||
if not l.swapIndex then
|
||||
l.swapIndex = l.index
|
||||
return
|
||||
end
|
||||
local order = Bag.order(game.save)
|
||||
order[l.swapIndex], order[l.index] = order[l.index], order[l.swapIndex]
|
||||
l.swapIndex = nil
|
||||
require("src.core.Sound").play(game.data, "Swap")
|
||||
local rebuilt = {}
|
||||
for _, id in ipairs(order) do
|
||||
local def = game.data.items[id]
|
||||
rebuilt[#rebuilt + 1] = {
|
||||
value = id,
|
||||
label = def and def.name or id,
|
||||
right = "x" .. game.save.inventory[id],
|
||||
}
|
||||
end
|
||||
l.items = rebuilt
|
||||
end,
|
||||
onChoose = function(item)
|
||||
local def = game.data.items[item.value]
|
||||
-- only key items and HMs are unsellable (pokemart.asm IsKeyItem /
|
||||
|
||||
@@ -34,7 +34,8 @@ function StartMenu.new(game)
|
||||
|
||||
-- POKéMON is always listed (draw_start_menu.asm prints it even with
|
||||
-- an empty party; selecting it then just no-ops)
|
||||
table.insert(items, { label = Strings("POKéMON"), onSelect = function()
|
||||
table.insert(items, { label = Strings("POKéMON"),
|
||||
keepOpen = #game.save.party == 0, onSelect = function()
|
||||
if #game.save.party == 0 then return end
|
||||
Screens.push(game, "PartyMenu", { onCancel = reopen })
|
||||
end })
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user