mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-16 00:02:23 +02:00
Compare commits
98 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 10314bcdcf | |||
| 099a4266a8 | |||
| cec1f196be | |||
| e24410f0fb | |||
| b29b6fd7bd | |||
| f06c4d4584 | |||
| 3a997e8a62 | |||
| e6ccdd57eb | |||
| 927507f8f7 | |||
| 9bf15c33fd | |||
| 52efdabf61 | |||
| ef208035ec | |||
| 18d61779eb | |||
| d573878a2f | |||
| a66efe207d | |||
| 5198b35945 | |||
| 40977337b1 | |||
| 1598f34954 | |||
| 43cbc554c3 | |||
| 3c3e2c54c5 | |||
| 00c72c441b | |||
| 7804ef9793 | |||
| 673d8b3ad8 | |||
| 62e1296ced | |||
| a3bbd78e7b | |||
| 8dfbd1daae | |||
| 4046b28a8c | |||
| 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/
|
||||
|
||||
+75
-3
@@ -242,10 +242,82 @@ with a `MK4xx` finding per site and an `unresolved:` note, with a file and a
|
||||
line, for every reach a static scan could not follow. Neither substitutes for a
|
||||
real Gold boot.
|
||||
|
||||
### 5. `mod.card`
|
||||
### 5. What a mod's code can reach
|
||||
|
||||
Your code runs in a sandbox (`src/mods/Sandbox.lua`), not against the
|
||||
engine's globals. Every chunk you author gets it: `main.lua`, your
|
||||
`options_schema`, and anything you `load()` yourself.
|
||||
|
||||
The globals the sandbox took away are still *reachable*, as compat
|
||||
stand-ins (`src/mods/LegacyCompat.lua`) that answer with the new API
|
||||
underneath. A mod written before the sandbox keeps working; it logs one
|
||||
warning per call it should migrate, and the mod manager lists them. What
|
||||
each stand-in actually does:
|
||||
|
||||
| Pre-sandbox call | What it does now | Migrate to |
|
||||
| --- | --- | --- |
|
||||
| `io.open`, `io.lines`, `love.filesystem.read`/`lines`/`newFile` | reads your own shipped files, then your overlay, then `mod.storage` | `mod:read`, `mod.storage` |
|
||||
| `love.filesystem.write`/`append`, `io.open(…, "w")`, `os.remove`, `os.rename` | writes to a private per-mod overlay under `mod_compat/<your id>/` | `mod.storage` |
|
||||
| `love.filesystem.getDirectoryItems`/`getInfo` | your own directory plus your overlay | `mod:list`, `mod:info` |
|
||||
| `love.filesystem.getSaveDirectory` and friends | a virtual root; anything joined to it lands in your overlay | `mod.storage` |
|
||||
| `os.getenv` | `nil`, except home-like names, which answer with that same virtual root | nothing |
|
||||
| `love.filesystem.load`, `dofile`, `loadfile` | compiles the chunk into your sandbox | `require`, `mod:read` plus `load` |
|
||||
| `love.system` | `getOS`/`getPowerInfo`/`getProcessorCount` read through; clipboard and `openURL` do nothing | `mod.device:powerInfo()`, `mod.steps` |
|
||||
| `love.event` | passes through, except `quit`, which does nothing | `mod.events`, `mod.hooks` |
|
||||
| `love.mousemoved = fn` and the other callbacks | installs on the real `love` table, the way it always did | `mod.hooks`, `mod.events` |
|
||||
| `package` | an inert stub, so `package.path = …` does not crash | `require` |
|
||||
|
||||
What has no stand-in, because there is nothing honest to reroute it to:
|
||||
|
||||
| Still refused | Why |
|
||||
| --- | --- |
|
||||
| `love.thread` | a LÖVE thread is a fresh Lua state with the full standard library, which no environment-based sandbox in this state can reach. Use `mod.fetch` for background HTTP (`network`) or `mod.job` for background compute (`background`) — both run your code inside the sandbox instead of outside it |
|
||||
| `require("ffi")` | arbitrary C |
|
||||
| `debug`, `getfenv`, `setfenv` | each one undoes the sandbox from inside |
|
||||
| `io.popen`, `os.execute` | spawning a process |
|
||||
| `love.run`, `love.errorhandler` | the engine's own loop and its crash path |
|
||||
| replacing a `love` module table (`love.filesystem = {}`) | the engine reads those tables too |
|
||||
|
||||
The rest of `love` passes through unchanged, so graphics, audio, timers and
|
||||
input work as they always have.
|
||||
|
||||
Three consequences worth knowing before you write against it:
|
||||
|
||||
- **Your globals are yours.** `_G` inside a mod is that mod's own table. Two
|
||||
mods no longer share a namespace, and neither can reach the engine's. To
|
||||
publish something to another mod, put it on `mod.exports` and let them
|
||||
`mod.find("your_id").exports` — the channel that was always the intended
|
||||
one. The same goes for the standard library: `string`, `table` and `math`
|
||||
are per-mod copies, so patching one is a local decision.
|
||||
- **Paths cannot climb.** `mod:read`, `mod:list`, `mod:info`, `mod.assets:path`
|
||||
and `mod.assets:image` join to your own directory, and `..`, absolute paths
|
||||
and drive letters are refused. So are `entry` and `options_schema` in your
|
||||
manifest. `mod:list("assets")` is the sandboxed `getDirectoryItems` for a
|
||||
folder you shipped; `mod:info` tells file from directory so a walk can
|
||||
recurse.
|
||||
- **Ship source, not bytecode.** A precompiled entry file is refused.
|
||||
|
||||
`permissions` in the manifest is still a disclosure the manager shows the
|
||||
player. `network` gates `require("socket")` and friends plus `mod.fetch`
|
||||
(non-blocking HTTP), and `background` gates `mod.job` (compute on a worker
|
||||
thread). Those two are the sanctioned ways to work off the main thread now
|
||||
that `love.thread` is refused. There is no
|
||||
permission that grants raw filesystem access, because no mod needs one:
|
||||
everything a mod legitimately writes is already scoped by
|
||||
`mod.storage` or the asset-transform derived root.
|
||||
|
||||
If your mod used one of the rerouted globals, the fix is almost always
|
||||
`mod.storage`. The overlay is a compatibility floor, not a second storage
|
||||
system: it is not scoped per playthrough, it does not migrate, and it is
|
||||
the first thing that will be dropped once the mods on the index have
|
||||
moved off it. Open an issue if you have a case `mod.storage` does not
|
||||
cover.
|
||||
|
||||
### 6. `mod.card`
|
||||
|
||||
The manifest is the *engine's* contract: identity, load order, dependencies,
|
||||
permissions, profile. The card is the *human-facing* one: who made this,
|
||||
permissions, profile (see [Manifest specification](docs/modding.md#manifest-specification-manifestjson)).
|
||||
The card is the *human-facing* one: who made this,
|
||||
what it changes, what it does not do yet. It is never read by the loader's
|
||||
merge — only by tooling and the manager's detail pane — so an absent or
|
||||
malformed card can never break a load.
|
||||
@@ -263,7 +335,7 @@ Two fields deserve their own note:
|
||||
distributed mod never carries ROM-derived bytes, not even in its preview
|
||||
images.
|
||||
|
||||
### 6. Tags
|
||||
### 7. Tags
|
||||
|
||||
Lowercase kebab strings, open vocabulary. The showcase generator
|
||||
lowercases and de-dupes. A recommended starting set: `beginner`,
|
||||
|
||||
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",
|
||||
},
|
||||
|
||||
+9
-6
@@ -16,6 +16,7 @@ save directory as:
|
||||
| nil / `"rom"` | `picked_rom.gb` (open) |
|
||||
| `"mod"` | `picked_mod.zip` (open) |
|
||||
| `"sav"` / `"save"` | `picked_save.sav` (open) |
|
||||
| `"required_import"` | `picked_required_import.bin` (open) |
|
||||
|
||||
Export uses a separate API: `love.system.createFile(suggestedName)` →
|
||||
`GameActivity.showCreateDocument` (`ACTION_CREATE_DOCUMENT`), which copies
|
||||
@@ -130,18 +131,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
|
||||
|
||||
+436
-12
@@ -21,6 +21,145 @@ luajit tools/gen_registry_docs.lua
|
||||
luajit tools/gen_registry_docs.lua ../gen1recomp.wiki
|
||||
```
|
||||
|
||||
## Manifest specification (`manifest.json`)
|
||||
|
||||
Every mod contains a root `manifest.json` defining its metadata, supported games, and dependencies for the engine loader.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my_mod",
|
||||
"name": "My Cool Mod",
|
||||
"version": "1.0.0",
|
||||
"api": 2,
|
||||
"entry": "main.lua",
|
||||
"profile": "content",
|
||||
"category": "GAMEPLAY",
|
||||
"games": ["gen1", "gen2"],
|
||||
"game_version": ">=0.0.0-dev <2.0.0",
|
||||
"priority": 100,
|
||||
"dependencies": [
|
||||
"helper_lib@^1.0.0",
|
||||
{ "id": "pokegear_cards", "games": ["gen2"], "range": "^1.0.0", "github": "1jamie/pokegear_cards" }
|
||||
],
|
||||
"optional_dependencies": [
|
||||
"gen1_modern_ui"
|
||||
],
|
||||
"required_imports": [
|
||||
{
|
||||
"id": "stadium2",
|
||||
"name": "Pokemon Stadium 2 ROM",
|
||||
"description": "Pokemon Stadium 2 (USA), any supported N64 byte order",
|
||||
"file": "stadium2.z64",
|
||||
"format": "n64",
|
||||
"size": 67108864,
|
||||
"md5": ["00000000000000000000000000000000"]
|
||||
}
|
||||
],
|
||||
"optional_imports": [
|
||||
{
|
||||
"id": "bonus_source",
|
||||
"name": "Optional bonus source",
|
||||
"file": "bonus.bin",
|
||||
"md5": "00000000000000000000000000000000"
|
||||
}
|
||||
],
|
||||
"conflicts": [],
|
||||
"permissions": ["engine_internals"],
|
||||
"description": "A brief description of the mod.",
|
||||
"github": "author/my_mod"
|
||||
}
|
||||
```
|
||||
|
||||
### Manifest Fields
|
||||
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `id` | `string` | Unique identifier (lowercase alphanumeric, underscores, hyphens). |
|
||||
| `name` | `string` | Human-readable title shown in launcher and manager. |
|
||||
| `version` | `string` | Semantic version string (e.g. `"1.0.0"`). |
|
||||
| `api` | `integer` | Mod API level (`2` for current standard, `1` for legacy). |
|
||||
| `entry` | `string` | Entry Lua file path relative to mod root (usually `"main.lua"`). |
|
||||
| `profile` | `string` | Mod profile: `"content"`, `"overhaul"`, or `"total_conversion"`. |
|
||||
| `category` | `string` | Categorization chip (e.g. `"GAMEPLAY"`, `"CONTENT"`, `"UI"`, `"AUDIO"`). |
|
||||
| `games` | `array` | Supported game versions: `["gen1"]`, `["gen2"]`, `["red"]`, `["blue"]`, `["yellow"]`, `["gold"]`, or `["all"]`. |
|
||||
| `game_version`| `string` | Semver range of required engine version (e.g. `">=0.0.0-dev <2.0.0"`). |
|
||||
| `priority` | `integer` | Load priority order (lower numbers load earlier; dependencies always precede dependents regardless of priority). |
|
||||
| `dependencies` | `array` | Hard required dependencies. A mod will not load if a required dependency is missing or disabled for the active game. |
|
||||
| `optional_dependencies` | `array` | Soft dependencies. Guarantees that if the target mod is present and active, it loads *before* this mod without blocking load if absent. |
|
||||
| `required_imports` | `array` | User-supplied files required by this mod. The launcher validates and copies each file into this mod's `baseroms/` directory; the mod does not load while one is missing. |
|
||||
| `optional_imports` | `array` | User-supplied files that unlock optional mod functionality. They use the same validation and private-copy flow but never block the mod from loading. |
|
||||
| `conflicts` / `incompatible` | `array` | List of mod IDs that cannot run concurrently with this mod. |
|
||||
| `permissions` | `array` | Requested privileges (e.g. `["engine_internals"]`, `["network"]`, `["filesystem"]`). |
|
||||
| `github` | `string` | GitHub repository (`"owner/repo"`) used for update checks and dependency download links. |
|
||||
|
||||
### Declaring Dependencies & Scoping
|
||||
|
||||
Dependencies in `dependencies` and `optional_dependencies` can be declared in several formats:
|
||||
|
||||
1. **Simple string**: `"mod_id"`
|
||||
2. **Version-pinned string**: `"mod_id@^1.2.0"`
|
||||
3. **Repository-hinted string**: `"mod_id#owner/repo"` or `"mod_id@^1.2.0#owner/repo"`
|
||||
4. **Structured object**:
|
||||
```json
|
||||
{
|
||||
"id": "mod_id",
|
||||
"range": "^1.2.0",
|
||||
"games": ["gen2"],
|
||||
"github": "owner/repo"
|
||||
}
|
||||
```
|
||||
|
||||
#### Version-Scoped Dependencies
|
||||
When a mod supports multiple games (`"games": ["gen1", "gen2"]`), a dependency can specify `"games": ["gen2"]` to indicate it is only required when booting Gen 2. When booting Gen 1, the engine will ignore the dependency, preventing unnecessary boot blocks on games that do not need it.
|
||||
|
||||
### Required user-supplied files
|
||||
|
||||
`required_imports` and `optional_imports` keep copyrighted or otherwise user-owned source material
|
||||
out of mod archives while giving every platform the same installation flow.
|
||||
Each object requires a stable `id`, a display `name`, a destination `file`
|
||||
(a filename, never a path), and one MD5 digest or an array of accepted MD5
|
||||
digests. `format` is either `"raw"` (the default) or `"n64"`. An optional
|
||||
`description` gives players dump or region guidance in the import panel.
|
||||
`size` declares the exact canonical byte length; `max_size` declares a smaller
|
||||
per-import ceiling when an exact size is not appropriate. Every import also
|
||||
has an engine-enforced 128 MiB ceiling and is rejected before hashing when its
|
||||
filesystem reports an invalid size.
|
||||
|
||||
For `"n64"`, the launcher recognizes `.z64`, `.v64`, and `.n64` byte orders,
|
||||
strips a recognized 512-byte copier header, converts the bytes to canonical
|
||||
big-endian `.z64` order, and then checks MD5. The canonical bytes are written
|
||||
to `mods/<mod-id>/baseroms/<file>`. Each selection is a private grant to that
|
||||
mod: the launcher never scans or copies another mod's imported files merely
|
||||
because its manifest names the same digest. Mods read the result with their existing scoped `mod:read` API, for
|
||||
example `mod:read("baseroms/stadium2.z64")`; no host path or new filesystem
|
||||
permission is exposed. Missing `required_imports` block the mod before its
|
||||
entry chunk runs; missing `optional_imports` remain visible in the same
|
||||
launcher panel but do not block loading.
|
||||
|
||||
MD5 here identifies a known dump because ROM databases commonly publish it;
|
||||
it is not a security or authenticity guarantee. Do not paste the SHA-1 used by
|
||||
Gen1Recomp's own game-ROM importer into an import's `md5` field. Mod archives
|
||||
must not include anything beneath `baseroms/`. The engine records a validation
|
||||
receipt keyed by file size and modification time so launcher refreshes and
|
||||
later boots do not repeatedly hash an unchanged imported ROM.
|
||||
|
||||
New mobile code should call `love.system.pickFile("required_import")`. The
|
||||
older iOS-only `"stadium"` picker kind remains temporarily for compatibility.
|
||||
Android now returns `false` for unknown picker kinds instead of treating them
|
||||
as game-ROM picks.
|
||||
|
||||
### Platform import flow
|
||||
|
||||
The same per-mod validation and private `mods/<mod-id>/baseroms/` destination
|
||||
applies on every supported platform. Windows, macOS, and Linux use the
|
||||
launcher file chooser. Android uses the Storage Access Framework, and iOS uses
|
||||
the Files document picker; both stage the choice as `picked_required_import.bin`
|
||||
before validation. Xbox/UWP uses its native picker and hands the launcher a
|
||||
temporary path. Switch/NX has no host picker, so the player copies a file to
|
||||
`imports/baseroms/` over MTP and chooses the import again. No platform grants
|
||||
the mod a host filesystem path or bypasses the manifest's size, format, and MD5
|
||||
checks.
|
||||
|
||||
## Mods and Gold (Gen 2)
|
||||
|
||||
The mod API is one API across both generations, but Gold runs its own battle
|
||||
@@ -66,7 +205,8 @@ optional visual `tileRows` at 2x resolution, and optional `tileDetailRows` at
|
||||
`"3"` (darkest); their matching width and height fields describe the grid.
|
||||
`markers` contains active `{ kind, x, y }` points in map-cell coordinates for
|
||||
`warp`, visible `item`, and untaken `hidden` locations. All fields are
|
||||
read-only snapshots; mods choose which layers to render.
|
||||
read-only snapshots; mods choose which layers to render. Red and Gold expose
|
||||
the same contract while applying their own object and event visibility rules.
|
||||
|
||||
## Party ordering
|
||||
|
||||
@@ -76,6 +216,24 @@ Companion UIs and alternate party screens can call
|
||||
operation is accepted only during idle overworld play; menus, movement,
|
||||
scripts, battles, and transitions leave the party untouched.
|
||||
|
||||
## Contextual field actions
|
||||
|
||||
`mod.world:availableFieldActions()` returns the field items and moves that can
|
||||
start at the player's current position. Both games expose `bicycle`, `fish`,
|
||||
`cut`, `surf`, `strength`, `flash`, `dig`, and `teleport`; Gold additionally
|
||||
exposes `headbutt`, `whirlpool`, `waterfall`, `sweet_scent`, and the
|
||||
contextual `squirtbottle` key item. Fishing rows include the owned rods that
|
||||
are valid choices. The list is empty while the world is busy, and omits an
|
||||
action whenever its item, move, badge, terrain, or engine state forbids it.
|
||||
|
||||
Call `mod.world:useFieldAction(id, opts)` to perform a listed action through
|
||||
the active game's own field-item path. Fishing accepts `{ rod = "OLD_ROD" }`
|
||||
and chooses automatically when only one rod is available. Invalid, stale, and
|
||||
busy requests return `nil` plus a reason without changing game state. Mods do
|
||||
not need generation-specific badge, terrain, bike, fishing, or field-move
|
||||
logic. Action lists are extensible; callers should render the records they
|
||||
understand and ignore unknown ids rather than assuming a fixed list length.
|
||||
|
||||
## Rendering pipelines
|
||||
|
||||
Most registries hand the engine *content*. `render_pipelines` hands it
|
||||
@@ -237,6 +395,25 @@ local keys, code, message = mod.storage:list(game, "history/quick")
|
||||
local deleted, code, message = mod.storage:delete(game, "history/quick/q0001")
|
||||
```
|
||||
|
||||
For independently generated binary data, use the opaque byte methods. They
|
||||
accept and return the exact Lua string of bytes, including NUL bytes and bytes
|
||||
that are not valid text:
|
||||
|
||||
```lua
|
||||
local ok, code, message = mod.storage:writeBytes(
|
||||
game, "cache/maps/pallet/terrain", encodedMesh)
|
||||
local encodedMesh, code, message = mod.storage:readBytes(
|
||||
game, "cache/maps/pallet/terrain")
|
||||
```
|
||||
|
||||
Opaque values are limited to 512 MiB per key. The engine stores them without
|
||||
decoding, compression, or an engine-defined file format, and never executes
|
||||
them. A consuming mod owns validation of its format, fingerprint, checksum,
|
||||
and compression metadata. Byte writes are staged and compared byte-for-byte
|
||||
before replacement, and reads can recover a valid backup after an interrupted
|
||||
write. Existing table values and opaque byte values use one shared logical key
|
||||
space; delete a key before changing its value from one type to the other.
|
||||
|
||||
`context` returns `{ engineVersion, gameVersion, playthroughId }`. The engine
|
||||
version is compatibility metadata; physical launcher-slot and path identity stays
|
||||
private. A title-selected context may additionally contain `normalSavedAt`, the
|
||||
@@ -245,19 +422,22 @@ progress or a slot/path handle.
|
||||
|
||||
At the title screen only, `mod.storage:selected(game)` returns a bound storage
|
||||
facade for the launcher-selected existing playthrough, or `nil, code, message`.
|
||||
Resolving this facade is read-only: it never allocates an identity, adopts a
|
||||
Resolving this facade is non-allocating: it never allocates an identity, adopts a
|
||||
fresh New Game, or exposes a slot id/path. Its `context()`, `read(key)`,
|
||||
`write(key, value)`, `list(prefix)`, and `delete(key)` methods have the same
|
||||
data-only and transaction contract as `mod.storage`, but remain restricted to
|
||||
the calling mod's selected existing namespace. It is intended for title tools
|
||||
that need to browse or manage durable history before the first normal SAVE.
|
||||
`write(key, value)`, `readBytes(key)`, `writeBytes(key, bytes)`,
|
||||
`list(prefix)`, and `delete(key)` methods have the same scoped and
|
||||
transactional contract as `mod.storage`, but remain restricted to the calling
|
||||
mod's selected existing namespace. It is intended for title tools that need to
|
||||
browse or manage durable history before the first normal SAVE.
|
||||
|
||||
Values must be tables containing serializable data only. Keys are conservative
|
||||
slash-separated segments (letters, digits, `_`, `-`); paths and filesystem
|
||||
handles are never exposed. Writes are staged and decode-verified, reads recover
|
||||
from a valid staged/backup generation, and methods return structured errors for
|
||||
normal data or I/O failures. The playthrough identity is allocated lazily on the
|
||||
first storage/checkpoint call, so an unused API changes no save bytes.
|
||||
Table values must contain serializable data only. Opaque values must be Lua
|
||||
strings. Keys are conservative slash-separated segments (letters, digits, `_`,
|
||||
`-`); paths and filesystem handles are never exposed. Table writes are staged
|
||||
and decode-verified; opaque writes are staged and byte-verified; reads recover
|
||||
from a valid staged/backup generation. Methods return structured errors for
|
||||
normal data, byte validation, and I/O failures. The playthrough identity is
|
||||
allocated lazily on the first storage/checkpoint call, so an unused API changes
|
||||
no save bytes.
|
||||
|
||||
`mod.checkpoints` captures and reconstructs engine-owned semantic runtime state:
|
||||
|
||||
@@ -352,6 +532,43 @@ animation/messages, forced choices, and every phase that cannot safely be
|
||||
checkpointed remain excluded. Exceptions are contained by normal hook isolation
|
||||
and fall through without advancing a turn.
|
||||
|
||||
Gen 1 trainer encounters also expose `trainer.before_battle` after the
|
||||
challenge text and immediately before battle construction. This lets a mod
|
||||
defer the encounter while it collects a player choice through a registered
|
||||
screen, then resume with a battle-local view of the save party:
|
||||
|
||||
```lua
|
||||
mod.hooks:wrap("trainer.before_battle", function(next, game, context, continue)
|
||||
-- context = { trainerClass, partyIndex, mapId, npcId }
|
||||
mod.ui.push(game, "party_registration", {
|
||||
onConfirm = function(indices)
|
||||
continue({ playerPartyIndices = indices })
|
||||
end,
|
||||
onCancel = function()
|
||||
continue({ cancel = true })
|
||||
end,
|
||||
})
|
||||
return true
|
||||
end)
|
||||
```
|
||||
|
||||
Return `true` only when retaining `continue` for a later callback. Calling
|
||||
`continue({ cancel = true })` ends the encounter without constructing a battle;
|
||||
the normal encounter completion callback returns control to the overworld and
|
||||
no trainer-defeated state is written. A cancelled sight encounter is suppressed
|
||||
at the current player cell so it cannot immediately reopen; moving one cell or
|
||||
talking to the trainer permits a new challenge. Calling `continue()` uses the
|
||||
full save party; passing
|
||||
`{ playerPartyIndices = { 2, 4, 5 } }` uses those ordered, one-based party
|
||||
members for initial send, switching and forced replacement, exhaustion,
|
||||
experience traversal, and battle party displays. The continuation is one-shot.
|
||||
An empty, duplicate, out-of-range, or otherwise malformed list safely falls
|
||||
back to the full party. The view references the original Pokemon records and
|
||||
never reorders or replaces `game.save.party`; trainer battle checkpoints retain
|
||||
the selected indices. Mods remain responsible for selection policy and should
|
||||
use only public `mod.ui`, hook, and save APIs. See RFC 0010 for the exact
|
||||
contract and compatibility guarantees.
|
||||
|
||||
## Developer console
|
||||
|
||||
Boot with developer mode on to unlock the in-game console and hot-reload
|
||||
@@ -439,6 +656,20 @@ oldest queued event as `"action,x,y"` in submitted-frame coordinates, or `nil`.
|
||||
This is what lets a mod lay the two passes out as two stacked Game Boy screens,
|
||||
or push one onto a second screen, without the engine knowing the layout.
|
||||
|
||||
`render.output_enabled` and `render.output` are the later, whole-window seam
|
||||
for mods that need the engine's normal composite rather than its separate
|
||||
layers. It runs after registered present pipelines and before GBCFX,
|
||||
`render.hud`, and touch controls. A mod wraps both hooks: the first returns
|
||||
`true` only while output ownership is needed, and the second receives
|
||||
`(next, ctx)` with `canvas`, `width`, `height`, `gameX`, `gameY`, `gameWidth`,
|
||||
`gameHeight`, `scale`, `dpiX`,
|
||||
`dpiY`, and `generation`. Returning `true` from `render.output` takes over the
|
||||
window; calling `next(ctx)` keeps the normal presentation. Both hooks default
|
||||
to `false`. Enabling the seam requires a full-window canvas for that frame.
|
||||
With no `render.output` subscriber, or while `render.output_enabled` is false,
|
||||
the existing presentation path is unchanged. `render.compose` takes precedence
|
||||
when it owns the frame.
|
||||
|
||||
`screen.render_visible` receives `(next, state)` while the main screen is being
|
||||
composed. Return `false` to omit that state from drawing, opacity selection and
|
||||
palette-zone ownership. The state remains on the stack and keeps its normal
|
||||
@@ -454,6 +685,7 @@ identifiers: `pc_box_withdraw`, `pc_box_deposit`, `pc_box_release`,
|
||||
`battle.bottom_ui_visible` and `battle.status_hud_visible` independently
|
||||
control the battle text/menu layer and the HP/status panels. Both receive
|
||||
`(next, state)` and default to `true`, so vanilla rendering is unchanged.
|
||||
Both hooks apply to Gen 1 and Gen 2 battles.
|
||||
Text boxes and YES/NO prompts pushed above a battle inherit a `false` result
|
||||
for that battle, so hiding the bottom layer cannot leave their white backing
|
||||
behind under another overlay. Text boxes also pass through the hook as their
|
||||
@@ -544,3 +776,195 @@ local both = mod.datetime:dateTime(game, createdAt)
|
||||
The live `game` supplies only the current option context. Formatting never
|
||||
mutates the save, options, or timestamp, and invalid timestamps return
|
||||
`"----"`.
|
||||
|
||||
## Device power information
|
||||
|
||||
Sandboxed mods can read the host's battery state without receiving the rest
|
||||
of `love.system`:
|
||||
|
||||
```lua
|
||||
local state, percent = mod.device:powerInfo()
|
||||
```
|
||||
|
||||
`state` follows LÖVE's values: `"unknown"`, `"battery"`, `"nobattery"`,
|
||||
`"charging"`, or `"charged"`. `percent` is `0` through `100`, or `nil` when
|
||||
the platform cannot report it. The facade is read-only and does not expose
|
||||
URL launching, clipboard access, or other system operations.
|
||||
|
||||
## Real-world steps
|
||||
|
||||
On iOS and Android the game counts the player's real-world steps natively
|
||||
(HealthKit / the hardware step counter). A mod reaches that bridge through
|
||||
the `steps` permission in `manifest.json`, which the player sees in the
|
||||
mod manager like every other permission:
|
||||
|
||||
```lua
|
||||
if mod.steps:available() then
|
||||
mod.steps:sync() -- async; OS consent sheet on first use
|
||||
end
|
||||
-- later, at a quiet moment:
|
||||
local walk = mod.steps:poll() -- { steps = n, from = ?, to = ? } or nil
|
||||
```
|
||||
|
||||
`available()` is `false` on builds without the bridge (desktop) and for
|
||||
mods without the permission, so a probe is always safe. `sync()` asks the
|
||||
platform to refresh its count and returns whether there was a bridge to
|
||||
ask. `poll()` returns the next delivery for this mod — the engine consumes
|
||||
the native side's pending file itself, each permissioned mod receives its
|
||||
own copy of a delivery, and steps are anchored natively so the same walk
|
||||
is never delivered twice. Without the permission, `sync` and `poll` raise
|
||||
an error naming it.
|
||||
|
||||
## Background HTTP
|
||||
|
||||
`mod.fetch` is how a mod does work off the main thread. It is behind the
|
||||
`network` permission in `manifest.json`, the same one that gates
|
||||
`require("socket")`, and the player sees it in the mod manager.
|
||||
|
||||
```lua
|
||||
-- somewhere once
|
||||
local job = mod.fetch:get("https://example.com/data.json")
|
||||
|
||||
-- in a hook or update, every frame -- poll never blocks
|
||||
if job then
|
||||
local r = mod.fetch:poll(job)
|
||||
if r.status ~= "pending" then
|
||||
if r.status == "ok" then use(r.body) else warn(r.err) end
|
||||
mod.fetch:release(job)
|
||||
job = nil
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
`get(url, opts)` returns an opaque handle, or `nil` plus a reason. `opts`
|
||||
takes `accept` (a request Accept header) and `maxSeconds` (clamped to 30).
|
||||
`poll(handle)` returns `{ status, body, err, progress }` where `status` is
|
||||
`"pending"`, `"ok"`, `"error"` or `"cancelled"`; it is a copy, and it never
|
||||
blocks, so calling it every frame is the intended use. `release(handle)`
|
||||
frees a finished job — do it, or you will hit the ceiling. `cancel(handle)`
|
||||
drops a result you no longer want. `available()` is `false` when the build
|
||||
has no transport and for mods without the permission, so a probe is safe.
|
||||
|
||||
The rules worth knowing before you design around it:
|
||||
|
||||
- **http and https only.** The underlying transport also speaks `file://`,
|
||||
`ftp://` and `scp://`; those are refused, on the initial URL and on any
|
||||
redirect. `mod.fetch` is not a way to read a local file.
|
||||
- **Four requests in flight per mod.** The worker pool is shared with the
|
||||
launcher's own downloads, so one mod cannot fill it. Over the ceiling,
|
||||
`get` returns `nil` and a reason until you release something.
|
||||
- **Handles are yours alone.** A handle from another mod, a fabricated
|
||||
table, or a guessed number all poll as `"error"`.
|
||||
- **Your mod id is in the User-Agent**, so a server operator can see who is
|
||||
calling and a mod cannot pose as the launcher.
|
||||
- Jobs are released when your mod unloads.
|
||||
|
||||
This is deliberately not `love.thread`. A LÖVE thread is a fresh Lua state
|
||||
with a full standard library that the sandbox cannot reach, so handing one
|
||||
to a mod would undo every other rule; `mod.fetch`'s workers run engine
|
||||
code, so a mod gets asynchrony without gaining any new reach.
|
||||
|
||||
## Background jobs
|
||||
|
||||
`mod.fetch` covers work waiting on a server. `mod.job` covers work waiting on
|
||||
the CPU — generating a map, crunching a table, anything that would otherwise
|
||||
stall a frame. It is behind the `background` permission in `manifest.json`.
|
||||
|
||||
Ship the job as its own file inside your mod:
|
||||
|
||||
```lua
|
||||
-- mods/your_mod/jobs/crunch.lua
|
||||
local arg = ...
|
||||
local total = 0
|
||||
for i = 1, arg.n do total = total + i end
|
||||
return { total = total }
|
||||
```
|
||||
|
||||
```lua
|
||||
-- in your entry file
|
||||
local job = mod.job:run("jobs/crunch.lua", { n = 1e6 })
|
||||
|
||||
-- later, in a hook -- poll never blocks
|
||||
local r = mod.job:poll(job)
|
||||
if r.status == "ok" then
|
||||
use(r.result.total)
|
||||
mod.job:release(job)
|
||||
end
|
||||
```
|
||||
|
||||
`run(script, arg, opts)` returns an opaque handle, or `nil` plus a reason.
|
||||
`opts.maxSeconds` sets the job's time budget (default 5, clamped to 30).
|
||||
`poll(handle)` returns `{ status, result, err }` with `status` one of
|
||||
`"pending"`, `"ok"`, `"error"` or `"cancelled"`. `release(handle)` frees it.
|
||||
`available()` is `false` on a host without threads and for mods without the
|
||||
permission, so a probe is always safe.
|
||||
|
||||
**A job is pure compute.** This is the part to design around, not a detail:
|
||||
|
||||
- **Plain data in, plain data out.** Numbers, strings, booleans and tables of
|
||||
them. A function, userdata, a cycle or a table key that is not a string or
|
||||
number is refused at your `run` call with a reason. Nothing is shared —
|
||||
your argument is snapshotted, and mutating the original afterwards does not
|
||||
reach the job.
|
||||
- **No engine API, no game state, no storage.** `require` is refused inside a
|
||||
job, and there is no `mod` object. A job cannot read the party, write
|
||||
`mod.storage`, or touch a registry. Get what it needs into the argument and
|
||||
act on the result back on the main thread.
|
||||
- **Your script is a file in your mod folder.** The path goes through the same
|
||||
rules as `mod:read`; `..`, absolute paths and drive letters are refused.
|
||||
- **Two jobs per mod, four on the machine.** Over the limit, `run` returns
|
||||
`nil` and a reason until you release one.
|
||||
- **The budget bounds how long YOU wait, not how long the work runs.** Past
|
||||
`maxSeconds`, `poll` reports an error and the result is dropped if it ever
|
||||
arrives — but the thread runs to its own end. There is no way to stop a
|
||||
LÖVE thread from outside, and every attempt to stop one from inside was
|
||||
worse than the disease (a debug hook does not reliably interrupt LuaJIT,
|
||||
and raising from one wedged the whole process). `cancel(handle)` is the
|
||||
same deal: it drops the result, it does not stop the work.
|
||||
|
||||
So **write jobs that terminate.** A job with an infinite loop will keep one
|
||||
core busy until the game closes. It will not freeze the game — the main
|
||||
thread stays responsive and quitting still works — but nothing will reclaim
|
||||
that core in the meantime.
|
||||
|
||||
Your job script runs in the same sandbox your entry file does, so `io`, `os`,
|
||||
`debug`, `ffi`, `package` and `love.filesystem` are absent there too. That is
|
||||
the whole reason this exists rather than `love.thread`: a raw LÖVE thread is a
|
||||
fresh Lua state with a full standard library that the sandbox cannot reach, so
|
||||
handing one to a mod would undo every other rule. Here the worker builds your
|
||||
sandbox first and loads your chunk into it.
|
||||
|
||||
## Pre-sandbox globals (compat)
|
||||
|
||||
A mod written before the sandbox landed does not have to be updated to
|
||||
load. `io`, `package`, `dofile`, `loadfile`, `os.getenv`, `love.filesystem`,
|
||||
`love.system` and `love.event` are all present again as compat stand-ins
|
||||
(`src/mods/LegacyCompat.lua`), and assigning a LÖVE callback
|
||||
(`love.mousemoved = fn`) installs on the real table the way it always did.
|
||||
Every stand-in call logs one warning naming its replacement, and
|
||||
`loader:legacyReport(modId)` returns the same list with call counts, which
|
||||
is what a "needs updating" badge should read.
|
||||
|
||||
The stand-ins are not the old globals. Paths are classified rather than
|
||||
passed through:
|
||||
|
||||
- A path inside your own mod directory reads the file you shipped.
|
||||
- Anything else, including an absolute path, resolves into a private
|
||||
per-mod overlay at `mod_compat/<your id>/` under the save directory.
|
||||
Two mods naming the same path never see each other's bytes, and nothing
|
||||
is written outside the game tree.
|
||||
- A read misses through the overlay to your shipped file, then to
|
||||
`mod.storage`, so a half-migrated mod sees both.
|
||||
- A write over a path you shipped shadows it; the packaged file is never
|
||||
modified, and `mod:read` still returns the packaged bytes.
|
||||
- `love.filesystem.getSaveDirectory()` and `os.getenv("HOME")` answer with
|
||||
a virtual root, so a legacy mod that joins its own paths lands back in
|
||||
the same overlay.
|
||||
|
||||
`love.thread` stays refused. A LÖVE thread runs in a separate Lua state
|
||||
with the full standard library, which the sandbox in this state cannot
|
||||
reach, so a stand-in would be a hole rather than a reroute. The same goes
|
||||
for `ffi`, `debug`, `setfenv`, `os.execute`, `io.popen`, `love.run` and
|
||||
`love.errorhandler`. A mod that needs real background work needs an
|
||||
engine-owned facility, not a compat shim -- for HTTP that facility is
|
||||
[`mod.fetch`](#modfetch), which runs on the engine's own worker pool.
|
||||
|
||||
@@ -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, and mods that need the internet or heavy background work do it through permissions the mod manager shows you, without freezing the game
|
||||
* **Improved launcher and save editor UI**, including background downloads and update checks
|
||||
* **Direct-launch options** for shortcuts, Steam entries, and handheld frontends
|
||||
* **Custom boot branding**
|
||||
|
||||
## Pokémon Gold (Gen 2)
|
||||
|
||||
A fourth game the launcher can import and play, built from pret/pokegold the same way Red/Blue/Yellow are built from pokered. Port extras beyond the cartridge:
|
||||
A fourth game the launcher can import and play, built from pret/pokegold the same way Red/Blue/Yellow are built from pokered (and pokeyellow). Port extras beyond the cartridge:
|
||||
|
||||
* **COLOR, zoom, tilt, GBC FX, and quick save/load**
|
||||
* **UI that stays fixed while the overworld zooms**
|
||||
@@ -39,3 +40,4 @@ A fourth game the launcher can import and play, built from pret/pokegold the sam
|
||||
* **On-screen touch pad** and controller SELECT for registered items
|
||||
|
||||
|
||||
* **Older mods keep loading** after the sandbox change, through per-mod compat stand-ins for the pre-sandbox globals
|
||||
|
||||
@@ -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.
|
||||
+13
-5
@@ -126,11 +126,19 @@ bundled game, in that case.
|
||||
already driving the frame. A payload that must change `love.run` itself
|
||||
needs a `minShell` bump so an older shell refuses to chainload it rather
|
||||
than running with half its intended behavior.
|
||||
- **Android has no in-app download transport yet.** `check_worker.lua`
|
||||
shells out to curl for both the release check and the download; curl is
|
||||
absent on Android, so `Check` degrades to `status = "error"` there (the
|
||||
launcher UI hides on that status) and the player is directed to the
|
||||
releases page via `Check.releaseUrl()` instead.
|
||||
- **Android and iOS use the native download bridge, not curl.** Neither
|
||||
platform ships curl, so the old `check_worker.lua` path (shell out to curl)
|
||||
always landed on `error` and the launcher chip's "Check for updates" tap
|
||||
was a no-op. The worker now talks through `HostShell`, the same transport
|
||||
as the mod catalog: curl on desktop, `love.system.httpDownload` on mobile.
|
||||
On Android that is the GameActivity JNI/`HttpsURLConnection` bridge; on
|
||||
iOS it is `GRPickerBridge.httpDownload` (`URLSession`). A fused sideloaded
|
||||
APK or IPA can therefore check GitHub and fetch the `.love` payload
|
||||
in-app. If neither transport exists, the worker reports `needs_full` and
|
||||
the launcher chip opens `Check.releaseUrl()`. Native package-only changes
|
||||
still need a full reinstall (`minShell` / `payloadHost` gate →
|
||||
`needs_full`). Applying a downloaded payload on Android relaunches via
|
||||
`love.system.restartApp`; iOS still uses in-process `quit("restart")`.
|
||||
- **Dev/source runs never self-update.** `Boot.run` returns immediately when
|
||||
`love.filesystem.isFused()` is false, and a working tree's `engine` is the
|
||||
`"0.0.0-dev"` placeholder that always reports up to date, so a source
|
||||
|
||||
@@ -76,15 +76,6 @@ end
|
||||
local editorHost, editorVersion, editorWindow
|
||||
local closeEditor -- forward declaration: openEditor hands it to the editor
|
||||
|
||||
-- tools/save-editor/ models the Gen 1 save and nothing else: a Gen 2 party row
|
||||
-- carries fields its MonOps and panels have no idea about (dvs, statExp,
|
||||
-- happiness, pokerus, caughtLevel), and SaveIO.save writes the WHOLE table
|
||||
-- back, so a Gold slot opened here comes out in a shape src/core/gen2/Save.lua
|
||||
-- then has to quarantine on the next boot. Refuse by name, the same way the
|
||||
-- .sav paths do (src/save_convert/SaveConvert.lua GEN2_SAV_UNSUPPORTED), so
|
||||
-- Red/Blue/Yellow slots are untouched.
|
||||
local GEN2_NO_EDITOR = { gold = "Pokemon Gold" }
|
||||
|
||||
-- The editor's modules use flat names (require("Kit"), require("Party")), so
|
||||
-- their directories have to be on the require path. It must be
|
||||
-- love.filesystem's path, not package.path: in a packaged build these files
|
||||
@@ -137,11 +128,6 @@ local function openEditor(version, slotId)
|
||||
Importer.saveNotice = Importer.saveNotice or {}
|
||||
Importer.saveNotice[version] = { ok = false, text = text }
|
||||
end
|
||||
local gen2Name = GEN2_NO_EDITOR[version]
|
||||
if gen2Name then
|
||||
refuse(gen2Name .. " uses a Gen 2 save; the save editor does not read one yet.")
|
||||
return
|
||||
end
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local path = SaveData.slotDiskPath(version, slotId)
|
||||
if not path then
|
||||
@@ -162,9 +148,44 @@ local function openEditor(version, slotId)
|
||||
editorMode = true
|
||||
resizeForEditor()
|
||||
addEditorRequirePath()
|
||||
EditorApp = require("App")
|
||||
EditorApp.load(path, { version = version, slotId = slotId, embedded = true,
|
||||
onClose = function() closeEditor() end })
|
||||
local okReq, appOrErr = pcall(require, "App")
|
||||
if not okReq then
|
||||
editorMode = false
|
||||
if version then
|
||||
require("src.import.CacheFs").unmountVersion(version)
|
||||
end
|
||||
restoreWindow()
|
||||
Importer = editorHost
|
||||
editorHost = nil
|
||||
editorVersion = nil
|
||||
if Importer and Importer.resumeAfterOverlay then
|
||||
Importer:resumeAfterOverlay()
|
||||
end
|
||||
refuse("Could not open the save editor (" .. tostring(appOrErr) .. ").")
|
||||
return
|
||||
end
|
||||
EditorApp = appOrErr
|
||||
local okLoad, loadErr = pcall(EditorApp.load, path, {
|
||||
version = version, slotId = slotId, embedded = true,
|
||||
onClose = function() closeEditor() end,
|
||||
})
|
||||
if not okLoad then
|
||||
editorMode = false
|
||||
if EditorApp.unload then pcall(EditorApp.unload) end
|
||||
EditorApp = nil
|
||||
if version then
|
||||
require("src.import.CacheFs").unmountVersion(version)
|
||||
require("src.core.Data"):unloadGenerated()
|
||||
end
|
||||
restoreWindow()
|
||||
Importer = editorHost
|
||||
editorHost = nil
|
||||
editorVersion = nil
|
||||
if Importer and Importer.resumeAfterOverlay then
|
||||
Importer:resumeAfterOverlay()
|
||||
end
|
||||
refuse("Could not open the save editor (" .. tostring(loadErr) .. ").")
|
||||
end
|
||||
end
|
||||
|
||||
-- Back to the launcher. Everything the editor mounted or cached has to come
|
||||
@@ -180,6 +201,11 @@ function closeEditor()
|
||||
require("src.import.CacheFs").unmountVersion(version)
|
||||
require("src.core.Data"):unloadGenerated()
|
||||
end
|
||||
for k in pairs(package.loaded) do
|
||||
if type(k) == "string" and (k:find("save%-editor") or k == "App" or k == "Kit" or k == "State" or k == "Catalog" or k == "SaveIO" or k == "Ops" or k == "MonOps" or k == "ItemOps" or k == "PadInput" or k == "Gen" or k == "Theme") then
|
||||
package.loaded[k] = nil
|
||||
end
|
||||
end
|
||||
editorVersion = nil
|
||||
restoreWindow()
|
||||
Importer = editorHost
|
||||
@@ -277,6 +303,11 @@ function love.load(args)
|
||||
-- of each flashing their own cmd.exe window (#606). No-op elsewhere.
|
||||
require("src.core.HostShell").hideHostConsole()
|
||||
|
||||
-- Hang gen1tls on love.system before mods boot. Android already has tls*
|
||||
-- from JNI; this is the desktop half. No DLL / no FFI is fine -- ws://
|
||||
-- rooms still work, wss:// just won't.
|
||||
pcall(function() require("src.net.Gen1Tls").install() end)
|
||||
|
||||
-- NX fused mounts are unreliable for the blue|yellow cache overlay: wrap
|
||||
-- the love loaders once so every generated-asset read falls back to the
|
||||
-- versioned save-dir copy. Never installed on desktop/Android/iOS.
|
||||
@@ -320,14 +351,6 @@ function love.load(args)
|
||||
-- cache has to be mounted before the editor's Data:load.
|
||||
if editorMode then
|
||||
local version = os.getenv("POKEPORT_VERSION") or "red"
|
||||
local gen2Name = GEN2_NO_EDITOR[version]
|
||||
if gen2Name then
|
||||
-- No launcher behind this run to carry a notice, so say it and stop
|
||||
-- rather than open a Gen 2 slot on Gen 1 panels.
|
||||
print(gen2Name .. " uses a Gen 2 save; the save editor does not read one yet.")
|
||||
love.event.quit(1)
|
||||
return
|
||||
end
|
||||
require("src.core.GameVersion").set(version)
|
||||
require("src.import.CacheFs").mountVersion(version)
|
||||
addEditorRequirePath()
|
||||
|
||||
@@ -192,8 +192,14 @@ bool System::pickFile(const char *kind) const
|
||||
dest = "picked_mod.zip";
|
||||
else if (strcmp(kind, "sav") == 0 || strcmp(kind, "save") == 0)
|
||||
dest = "picked_save.sav";
|
||||
else if (strcmp(kind, "required_import") == 0)
|
||||
dest = "picked_required_import.bin";
|
||||
else if (strcmp(kind, "rom") == 0)
|
||||
dest = "picked_rom.gb";
|
||||
// Unknown kinds used to fall through to the ROM destination. Refuse them
|
||||
// so a newer Lua caller cannot silently route an unrelated file as a ROM.
|
||||
else
|
||||
return false;
|
||||
}
|
||||
return love::android::showFilePicker(dest);
|
||||
#else
|
||||
@@ -202,6 +208,15 @@ bool System::pickFile(const char *kind) const
|
||||
#endif
|
||||
}
|
||||
|
||||
const char *System::pickFileKinds() const
|
||||
{
|
||||
#ifdef LOVE_ANDROID
|
||||
return "rom,mod,sav,required_import";
|
||||
#else
|
||||
return "";
|
||||
#endif
|
||||
}
|
||||
|
||||
bool System::createFile(const char *suggestedName) const
|
||||
{
|
||||
#ifdef LOVE_ANDROID
|
||||
|
||||
@@ -112,10 +112,12 @@ public:
|
||||
* love::android::showFilePicker and src/import/RomImporter.lua.
|
||||
*
|
||||
* @param kind Optional pick kind: nullptr/"rom" -> picked_rom.gb,
|
||||
* "mod" -> picked_mod.zip, "sav"/"save" -> picked_save.sav.
|
||||
* "mod" -> picked_mod.zip, "sav"/"save" -> picked_save.sav,
|
||||
* "required_import" -> picked_required_import.bin.
|
||||
* @return Whether the picker was shown.
|
||||
**/
|
||||
virtual bool pickFile(const char *kind = nullptr) const;
|
||||
virtual const char *pickFileKinds() const;
|
||||
|
||||
/**
|
||||
* Shows the platform's native "create / save a file" UI (Android SAF
|
||||
|
||||
@@ -102,6 +102,12 @@ int w_pickFile(lua_State *L)
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_pickFileKinds(lua_State *L)
|
||||
{
|
||||
luax_pushstring(L, instance()->pickFileKinds());
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_createFile(lua_State *L)
|
||||
{
|
||||
const char *suggested = luaL_optstring(L, 1, nullptr);
|
||||
@@ -222,6 +228,7 @@ static const luaL_Reg functions[] =
|
||||
{ "openURL", w_openURL },
|
||||
{ "vibrate", w_vibrate },
|
||||
{ "pickFile", w_pickFile },
|
||||
{ "pickFileKinds", w_pickFileKinds },
|
||||
{ "createFile", w_createFile },
|
||||
{ "syncHealthSteps", w_syncHealthSteps },
|
||||
{ "restartApp", w_restartApp },
|
||||
|
||||
@@ -90,6 +90,9 @@ public class GameActivity extends SDLActivity {
|
||||
private static final String PICKED_ROM_FILENAME = "picked_rom.gb";
|
||||
private static final String PICKED_MOD_FILENAME = "picked_mod.zip";
|
||||
private static final String PICKED_SAVE_FILENAME = "picked_save.sav";
|
||||
// Kept separate from the game-ROM destination so a dependency pick can
|
||||
// never be mistaken for a game import when the picker returns on Android.
|
||||
private static final String PICKED_REQUIRED_IMPORT_FILENAME = "picked_required_import.bin";
|
||||
private static final String PENDING_EXPORT_FILENAME = "pending_export.sav";
|
||||
private static final String EXPORT_DONE_FILENAME = "export_done.flag";
|
||||
// Written when a SAF pick cannot be read at all, with the destination
|
||||
@@ -504,7 +507,8 @@ public class GameActivity extends SDLActivity {
|
||||
* picker-agnostic and unchanged.
|
||||
*
|
||||
* @param destFilename basename under the app save identity (e.g.
|
||||
* picked_rom.gb, picked_mod.zip, picked_save.sav)
|
||||
* picked_rom.gb, picked_mod.zip, picked_save.sav, or
|
||||
* picked_required_import.bin)
|
||||
*/
|
||||
/** Legacy single-argument entry; resolves the save dir itself. */
|
||||
@Keep
|
||||
@@ -535,6 +539,11 @@ public class GameActivity extends SDLActivity {
|
||||
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
||||
intent.setType("*/*");
|
||||
// The Storage Access Framework grants the returned content URI
|
||||
// directly to this activity. Request the read grant explicitly as
|
||||
// well: Android 13's scoped storage deliberately does not expose
|
||||
// arbitrary paths or require broad media/storage permissions.
|
||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||
try {
|
||||
self.startActivityForResult(intent, FILE_PICKER_REQUEST_CODE);
|
||||
return true;
|
||||
@@ -547,6 +556,7 @@ public class GameActivity extends SDLActivity {
|
||||
Intent intent = new Intent(Intent.ACTION_GET_CONTENT);
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
||||
intent.setType("*/*");
|
||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||
try {
|
||||
self.startActivityForResult(
|
||||
Intent.createChooser(intent, "Choose a file"),
|
||||
@@ -576,6 +586,12 @@ public class GameActivity extends SDLActivity {
|
||||
return showFilePicker(PICKED_SAVE_FILENAME);
|
||||
}
|
||||
|
||||
/** Required-mod-file wrapper used by love.system.pickFile("required_import"). */
|
||||
@Keep
|
||||
public static boolean showRequiredImportFilePicker() {
|
||||
return showFilePicker(PICKED_REQUIRED_IMPORT_FILENAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Relaunches the whole app for love.system.restartApp, used by
|
||||
* src/core/HostShell.lua when a mod toggle needs a cold boot (#575).
|
||||
|
||||
@@ -12,6 +12,90 @@
|
||||
"tintColor": "3b5ca8",
|
||||
"category": "games",
|
||||
"versions": [
|
||||
{
|
||||
"version": "0.1.92",
|
||||
"date": "2026-08-15",
|
||||
"size": 11364274,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.92/gen1recomp++-0.1.92-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.91",
|
||||
"date": "2026-08-15",
|
||||
"size": 11351477,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.91/gen1recomp++-0.1.91-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.90",
|
||||
"date": "2026-08-15",
|
||||
"size": 11344338,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.90/gen1recomp++-0.1.90-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @1Jamie\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.89",
|
||||
"date": "2026-08-15",
|
||||
"size": 11343841,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.89/gen1recomp++-0.1.89-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @1Jamie\n- @anxiousintrovert\n- @AverageConsumer\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.88",
|
||||
"date": "2026-08-14",
|
||||
"size": 11311237,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.88/gen1recomp++-0.1.88-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.87",
|
||||
"date": "2026-08-14",
|
||||
"size": 11310910,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.87/gen1recomp++-0.1.87-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @AverageConsumer\n- @bryanthaboi\n- @mleo2003\n- @ShaneMcGovernIE\n- MaxTomahawk"
|
||||
},
|
||||
{
|
||||
"version": "0.1.86",
|
||||
"date": "2026-08-14",
|
||||
"size": 11306689,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.86/gen1recomp++-0.1.86-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1283 Intro broken and leading to softlock (R,B,Y currently unplayable without save file)\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.85",
|
||||
"date": "2026-08-14",
|
||||
"size": 11306462,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.85/gen1recomp++-0.1.85-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.84",
|
||||
"date": "2026-08-14",
|
||||
"size": 11306462,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.84/gen1recomp++-0.1.84-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #919 Bills House game save moves strangely after reload\n- #982 Trading QOL aspect missing from OG.\n- #1003 Land Pokemon Encounters while Surfing in Cerulean Cave\n- #1012 Multiple issues with text speed, animations, and audio\n- #1022 Cannot interact with small trees\n- #1028 Trainers not resetting on route change.\n- #1033 Some issues with Link battles.\n- #1243 Can't sell TMs at Poké Marts\n\n## Contributors\n\n- @1Jamie\n- @AverageConsumer\n- @bryanthaboi\n- @TheRealSolidusSnake\n- Myles Resnick\n- ShaneMcGovernIE"
|
||||
},
|
||||
{
|
||||
"version": "0.1.83",
|
||||
"date": "2026-08-13",
|
||||
"size": 11282300,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.83/gen1recomp++-0.1.83-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @AverageConsumer\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.82",
|
||||
"date": "2026-08-13",
|
||||
"size": 11281277,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.82/gen1recomp++-0.1.82-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #914 Immunity steps and no encounter tiles\n- #936 Fly and teleporting allow should block interactions\n- #944 When starting a new game, the character sprite is facing down instead of up\n- #949 Save game import, save game export, \"normal\" save game\n- #964 Menu closing when trying to view Pokemon before picking starter\n- #978 Following up of #847\n- #983 Pikachu incorrectly able to be released\n- #984 EXP calculation after trading a PKMN back isnt right.\n- #987 Celadon Game Corner Display Error (L22680)\n- #988 (Yellow) Missing Pikachu Interaction - Pewter City Pokemon Centre\n- #989 (Yellow) Missing Pikachu Interaction - Vermilion City Pokemon Fan Club\n- #990 (Yellow) Pikachu Missing Hat\n- #999 Pallet Town music while player follows Prof. Oak\n- #1007 Transition into battle state for the 'Catching Pikachu' scene missing\n- #1010 Rival's position resets\n- #1011 Rival dialogue issue\n- #1029 1px black line sits under the title logo at the SGB zone boundary\n- #1035 Alt Music not playing\n- #1041 Mimic text not swoing up\n- #1047 Using a TM or HM closes the menu afterwards.\n- #1048 Cannot select items from the sell menu.\n- #1056 Cursor not jumping when switching moves\n- #1065 Player walks through statue after final battle while walking into Hall Of Fame\n- #1066 Anomalies when opponent switches out Pokemon\n- #1069 missing {RAM:wNameBuffer}\n- #1073 _GotMonText not used\n- #1089 Scripted movement missing for player and rival\n- #1101 Recoil damage calculation bug\n- #1115 Hitting attack does no damage\n- #1119 Wrongs textlines and events\n- #1120 Health is black when viewing stats\n- #1129 Errors in bowing animations of the nurse\n- #1146 [Recomp] Map music glitch\n- #1149 issue that affects tranlations\n- #1175 Pikachu visible too late\n- #1189 Town Map Item\n- #1193 Status moves have (wrong) sound effects when battle animations are turned off\n- #1206 Gen1: Cinnebar island has unfishable tiles north and east\n- #1207 Poison Flicker Visual issue\n- #1214 Title Screen with OG Red is the wrong color\n- #1219 [GOLD] UNABLE TO USE SUN STONE\n- #1220 No screen shaking for hitting moves when battle animations are off\n- #1221 [Gold] Flaafy evolution to at Lvl 30 Ampharos skipped learn thunder punch\n- #1225 Jingles for learning moves missing\n\n## Contributors\n\n- @1Jamie\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.81",
|
||||
"date": "2026-08-13",
|
||||
"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
|
||||
|
||||
+39
-15
@@ -14,6 +14,14 @@ local MODULES = {
|
||||
-- Optional for compatibility with developer and stale caches.
|
||||
local OPTIONAL = { "audio", "palettes", "icons" }
|
||||
|
||||
-- Gold's extractor never writes these Gen 1 tables (RomExtractorGen2 has
|
||||
-- maps/text/pokemon/items, not text_pointers / trainer_headers / field).
|
||||
-- Desktop can still `require` Red's copies from the source tree, so Gold
|
||||
-- Edit appeared to work there; an Android APK has only the per-version
|
||||
-- cache, so Data:load used to throw on the first Gold Edit and take the
|
||||
-- activity down. Empty tables are enough for seedDefaults / the editor.
|
||||
local GEN2_OPTIONAL = { text_pointers = true, trainer_headers = true, field = true }
|
||||
|
||||
-- Vanilla defaults for rules exposed through the constants registry. A
|
||||
-- value has to exist before a mod can patch it; each one matches the
|
||||
-- engine's no-mod behavior, so seeding them changes nothing on a vanilla
|
||||
@@ -99,7 +107,11 @@ end
|
||||
-- Fills only what the cache is missing, so an importer that learns to
|
||||
-- stamp one of these keys silently takes over from the engine.
|
||||
function Data:seedDefaults()
|
||||
local constants = self.constants
|
||||
local constants = self.constants or {}
|
||||
self.constants = constants
|
||||
self.field = self.field or {}
|
||||
self.maps = self.maps or {}
|
||||
self.pokemon = self.pokemon or {}
|
||||
for key, value in pairs(CONSTANT_DEFAULTS) do
|
||||
if constants[key] == nil then constants[key] = copy(value) end
|
||||
end
|
||||
@@ -108,7 +120,11 @@ function Data:seedDefaults()
|
||||
if constants.dexSize == nil then
|
||||
local highest = 0
|
||||
for _, def in pairs(self.pokemon) do
|
||||
if def.dex and def.dex > highest then highest = def.dex end
|
||||
-- Gold's pokemon.lua also carries growthRates / tmhmMoves / generation
|
||||
-- scalars beside species rows.
|
||||
if type(def) == "table" and def.dex and def.dex > highest then
|
||||
highest = def.dex
|
||||
end
|
||||
end
|
||||
constants.dexSize = highest
|
||||
end
|
||||
@@ -212,36 +228,41 @@ local function loadModule(dir, name)
|
||||
if not chunk then return false, err end
|
||||
return pcall(chunk)
|
||||
end
|
||||
local ok, mod = pcall(require, "data.generated." .. name)
|
||||
if ok then return true, mod end
|
||||
-- Fused PhysFS / Blue|Yellow prefix: load bytes from the active version's
|
||||
-- cache explicitly when require cannot see the mounted tree.
|
||||
local CacheFs = require("src.import.CacheFs")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local path = "data/generated/" .. name .. ".lua"
|
||||
local bytes = CacheFs.readActive(path)
|
||||
if type(bytes) == "string" then
|
||||
local chunk, err = loadstring(bytes, "@" .. GameVersion.cachePrefix() .. path)
|
||||
if not chunk then return false, err or mod end
|
||||
return pcall(chunk)
|
||||
local chunk = loadstring(bytes, "@" .. GameVersion.cachePrefix() .. path)
|
||||
if chunk then
|
||||
local ok, res = pcall(chunk)
|
||||
if ok then return true, res end
|
||||
end
|
||||
end
|
||||
return false, mod
|
||||
local ok, mod = pcall(require, "data.generated." .. name)
|
||||
if ok then return true, mod end
|
||||
return false, nil
|
||||
end
|
||||
|
||||
function Data:load()
|
||||
local dir = os.getenv("POKEPORT_DATA_DIR")
|
||||
local gen2 = require("src.core.GameVersion").generation() == 2
|
||||
for _, name in ipairs(MODULES) do
|
||||
local ok, mod = loadModule(dir, name)
|
||||
if not ok then
|
||||
if dir then
|
||||
if gen2 and GEN2_OPTIONAL[name] then
|
||||
self[name] = {}
|
||||
elseif dir then
|
||||
error(("missing data module '%s/%s.lua' (POKEPORT_DATA_DIR).\n(%s)")
|
||||
:format(dir, name, mod))
|
||||
else
|
||||
error(("missing generated data module 'data/generated/%s.lua'.\n" ..
|
||||
"Import the ROM again or rebuild developer data.\n(%s)")
|
||||
:format(name, mod))
|
||||
end
|
||||
error(("missing generated data module 'data/generated/%s.lua'.\n" ..
|
||||
"Import the ROM again or rebuild developer data.\n(%s)")
|
||||
:format(name, mod))
|
||||
else
|
||||
self[name] = mod
|
||||
end
|
||||
self[name] = mod
|
||||
end
|
||||
for _, name in ipairs(OPTIONAL) do
|
||||
local ok, mod = loadModule(dir, name)
|
||||
@@ -280,11 +301,14 @@ function Data:unloadGenerated()
|
||||
if not pristine[key] then self[key] = nil end
|
||||
end
|
||||
end
|
||||
self._pristineKeys = nil
|
||||
for _, name in ipairs(MODULES) do
|
||||
package.loaded["data.generated." .. name] = nil
|
||||
self[name] = nil
|
||||
end
|
||||
for _, name in ipairs(OPTIONAL) do
|
||||
package.loaded["data.generated." .. name] = nil
|
||||
self[name] = nil
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
+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()
|
||||
|
||||
@@ -325,7 +325,8 @@ function HostShell.httpDownload(url, absPath, userAgent, accept, maxTime)
|
||||
if type(absPath) ~= "string" or absPath == "" then return nil, "missing path" end
|
||||
userAgent = userAgent or "gen1recomp"
|
||||
if HostShell.haveCurl() then
|
||||
local cmd = ("curl -fsSL --connect-timeout 15 --max-time %d ")
|
||||
local cmd = ("curl -fsSL --proto =http,https --proto-redir =http,https "
|
||||
.. "--connect-timeout 15 --max-time %d ")
|
||||
:format(tonumber(maxTime) or 300)
|
||||
.. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " "
|
||||
if accept then
|
||||
@@ -370,7 +371,8 @@ function HostShell.httpGet(url, userAgent, accept, maxTime)
|
||||
-- BODY, and on the two services this talks to that body is the whole
|
||||
-- diagnosis: GitHub's 403 says "API rate limit exceeded for <ip>", which
|
||||
-- tells a user to wait rather than to go hunting for a broken index.
|
||||
local cmd = ("curl -sSL --connect-timeout 10 --max-time %d ")
|
||||
local cmd = ("curl -sSL --proto =http,https --proto-redir =http,https "
|
||||
.. "--connect-timeout 10 --max-time %d ")
|
||||
:format(tonumber(maxTime) or 40)
|
||||
.. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " "
|
||||
if accept then
|
||||
|
||||
+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
|
||||
|
||||
+112
-10
@@ -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).
|
||||
@@ -779,9 +832,41 @@ local function tryMigrateLegacy(version, fs)
|
||||
return id
|
||||
end
|
||||
|
||||
-- Scan the filesystem for orphaned slot files under saves/<version>/ when options.lua
|
||||
-- has no registered slots for this version (e.g. options.lua was reset or lost).
|
||||
local function scanDiskSlots(version, fs)
|
||||
if not fs then return nil end
|
||||
local dir = "saves/" .. version
|
||||
local slots = {}
|
||||
if fs.getDirectoryItems and fs.getInfo and fs.getInfo(dir) then
|
||||
local items = pcall(fs.getDirectoryItems, dir) and fs.getDirectoryItems(dir) or {}
|
||||
local numbers = {}
|
||||
for _, item in ipairs(items) do
|
||||
local slotId = item:match("^(slot%d+)%.lua$")
|
||||
if slotId then
|
||||
local n = tonumber(slotId:match("%d+"))
|
||||
table.insert(numbers, { id = slotId, num = n or 0 })
|
||||
end
|
||||
end
|
||||
table.sort(numbers, function(a, b) return a.num < b.num end)
|
||||
for _, item in ipairs(numbers) do
|
||||
table.insert(slots, item.id)
|
||||
end
|
||||
else
|
||||
for i = 1, 30 do
|
||||
local slotId = "slot" .. i
|
||||
local path = dir .. "/" .. slotId .. ".lua"
|
||||
if fs.getInfo and fs.getInfo(path) then
|
||||
table.insert(slots, slotId)
|
||||
end
|
||||
end
|
||||
end
|
||||
return #slots > 0 and slots or nil
|
||||
end
|
||||
|
||||
-- Resolve (once per version per process) which slot in-game saves use: an
|
||||
-- existing registry wins; otherwise a lazy legacy migration may create
|
||||
-- slot1; otherwise false, meaning the flat legacy path.
|
||||
-- slot1; otherwise auto-recover disk slots; otherwise false (flat legacy path).
|
||||
local function ensureVersionSlots(version, fs)
|
||||
if slotsChecked[version] then return end
|
||||
slotsChecked[version] = true
|
||||
@@ -795,7 +880,22 @@ local function ensureVersionSlots(version, fs)
|
||||
activeSlotCache[version] = reg.active or reg.list[1]
|
||||
return
|
||||
end
|
||||
activeSlotCache[version] = tryMigrateLegacy(version, fs) or false
|
||||
local migrated = tryMigrateLegacy(version, fs)
|
||||
if migrated then
|
||||
activeSlotCache[version] = migrated
|
||||
return
|
||||
end
|
||||
-- Auto-recovery: if options.lua lost its slot registry, scan disk for orphaned slot files
|
||||
local recovered = scanDiskSlots(version, fs)
|
||||
if recovered and #recovered > 0 then
|
||||
opts.saveSlots = opts.saveSlots or {}
|
||||
opts.saveSlots[version] = { list = recovered, active = recovered[1] }
|
||||
SaveData.saveOptions(opts, fs)
|
||||
activeSlotCache[version] = recovered[1]
|
||||
Logger.info("auto-recovered %d save slot(s) for %s from disk", #recovered, version)
|
||||
return
|
||||
end
|
||||
activeSlotCache[version] = false
|
||||
end
|
||||
|
||||
-- (body for the forward-declared saveNames.) Resolves the ACTIVE slot for
|
||||
@@ -1834,6 +1934,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 +1946,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);")
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
-- ROM extraction, off the main thread. The extractor's require closure needs
|
||||
-- only love.filesystem, love.image, love.math and love.system, none of which
|
||||
-- are main-thread-only, so it runs here instead of as a coroutine the frame
|
||||
-- loop resumed for 8ms out of every 16.7ms.
|
||||
--
|
||||
-- The caller clears the stale cache and writes the completion marker itself;
|
||||
-- this only fills the tree between those two steps, so the "marker appears
|
||||
-- last" order isReady() depends on stays on one thread.
|
||||
|
||||
require("love.filesystem")
|
||||
require("love.image")
|
||||
require("love.math")
|
||||
require("love.system")
|
||||
require("love.timer")
|
||||
|
||||
local version, prefix, romData, progressName, resultName = ...
|
||||
|
||||
local progressChannel = love.thread.getChannel(progressName)
|
||||
local resultChannel = love.thread.getChannel(resultName)
|
||||
|
||||
-- RomExtractor:tick fires per item, thousands of times per import; a channel
|
||||
-- push each would cost more than the work it reports.
|
||||
local PROGRESS_HZ = 20
|
||||
|
||||
local ok, err = pcall(function()
|
||||
local CacheFs = require("src.import.CacheFs")
|
||||
CacheFs.prefix = prefix
|
||||
|
||||
local manifest = require("src.import.RomManifest").decode(version)
|
||||
local RomExtractor = version == "gold"
|
||||
and require("src.import.RomExtractorGen2")
|
||||
or require("src.import.RomExtractor")
|
||||
|
||||
local lastPush, lastStage = 0, nil
|
||||
local extractor = RomExtractor.new(romData, manifest,
|
||||
function(progress, total, stage, current, stageTotal)
|
||||
local now = love.timer.getTime()
|
||||
-- Stage changes always go through, or the caption goes stale.
|
||||
if stage ~= lastStage or now - lastPush >= 1 / PROGRESS_HZ then
|
||||
lastPush, lastStage = now, stage
|
||||
progressChannel:push({
|
||||
progress = progress, total = total, stage = stage,
|
||||
current = current, stageTotal = stageTotal,
|
||||
})
|
||||
end
|
||||
end)
|
||||
extractor:run()
|
||||
end)
|
||||
|
||||
resultChannel:push({ ok = ok, error = ok and nil or tostring(err) })
|
||||
+1615
-295
File diff suppressed because it is too large
Load Diff
+615
-90
@@ -316,18 +316,6 @@ function RomImporter.isReady(version)
|
||||
end
|
||||
|
||||
-- Load the import manifest for a version and confirm it matches that ROM.
|
||||
local function decodeManifest(version)
|
||||
local path = GameVersion.info(version).manifest
|
||||
local raw, readError = love.filesystem.read(path)
|
||||
if not raw then error("ROM import metadata is missing: " .. tostring(readError)) end
|
||||
local Json = require("src.link.Json")
|
||||
local manifest, decodeError = Json.decode(raw)
|
||||
if not manifest then error("ROM import metadata is invalid: " .. tostring(decodeError)) end
|
||||
assert(manifest.romSha1 == GameVersion.info(version).sha1,
|
||||
"ROM import metadata version mismatch")
|
||||
return manifest
|
||||
end
|
||||
|
||||
local function sha1(data)
|
||||
local digest = love.data.hash("sha1", data)
|
||||
if type(digest) == "userdata" and digest.getString then
|
||||
@@ -344,6 +332,14 @@ local function readExternalPath(path)
|
||||
return data
|
||||
end
|
||||
|
||||
local function externalFileSize(path)
|
||||
local file = io.open(path, "rb")
|
||||
if not file then return nil end
|
||||
local size = file:seek("end")
|
||||
file:close()
|
||||
return size
|
||||
end
|
||||
|
||||
local function readDroppedFile(file)
|
||||
local ok, openError = file:open("r")
|
||||
if not ok then return nil, openError end
|
||||
@@ -981,6 +977,25 @@ local function findPendingSav(preferAny, skip)
|
||||
return nil
|
||||
end
|
||||
|
||||
local function pickerHasKind(kind)
|
||||
local fn = love.system.pickFileKinds
|
||||
if type(fn) ~= "function" then return false end
|
||||
local ok, kinds = pcall(fn)
|
||||
if not ok or type(kinds) ~= "string" then return false end
|
||||
for token in kinds:gmatch("[^,%s]+") do
|
||||
if token == kind then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function findPendingRequiredImport()
|
||||
local names = { "picked_required_import.bin", "picked_stadium.z64" }
|
||||
for _, name in ipairs(names) do
|
||||
if love.filesystem.getInfo(name, "file") then return name end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Retire an Android pick once it has been through the installer / importer,
|
||||
-- whether or not it worked: a pick left on disk wins the scans above forever,
|
||||
-- so the next tap re-runs the same failing file and the picker never reopens
|
||||
@@ -1112,6 +1127,39 @@ local function chooseSav()
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Generic user-supplied dependency picker. Keep the native prompt entirely
|
||||
-- engine-owned: manifest labels are untrusted and must never enter shell
|
||||
-- command templates. The LÖVE modal already shows the specific import name.
|
||||
local function chooseRequiredFile()
|
||||
local prompt = shellSafe(Strings("Choose required mod file"))
|
||||
local platform = love.system.getOS()
|
||||
if platform == "OS X" then
|
||||
return commandOutput(
|
||||
([[osascript -e 'POSIX path of (choose file with prompt "%s")' 2>/dev/null]])
|
||||
:format(prompt))
|
||||
elseif platform == "Windows" then
|
||||
local script = table.concat({
|
||||
"Add-Type -AssemblyName System.Windows.Forms;",
|
||||
"$d=New-Object System.Windows.Forms.OpenFileDialog;",
|
||||
"$d.Title='" .. prompt .. "';",
|
||||
"$d.Filter='All files (*.*)|*.*';",
|
||||
"if($d.ShowDialog() -eq 'OK'){",
|
||||
"$t=Join-Path $env:TEMP 'pokeport_required_import.bin';",
|
||||
"Copy-Item -LiteralPath $d.FileName -Destination $t -Force;",
|
||||
"[Console]::OutputEncoding=[Text.Encoding]::UTF8;",
|
||||
"[Console]::Write($t)}",
|
||||
})
|
||||
return commandOutput(
|
||||
'powershell -NoProfile -STA -Command "' .. script .. '"')
|
||||
elseif platform == "Linux" then
|
||||
local path = commandOutput(
|
||||
([[zenity --file-selection --title="%s" 2>/dev/null]]):format(prompt))
|
||||
if path then return path end
|
||||
return commandOutput([[kdialog --getopenfilename "$HOME" 2>/dev/null]])
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- The self-updater only surfaces on the real distributed build: a fused,
|
||||
-- interactive launcher with no scripted-run override. A dev / source checkout
|
||||
-- (unfused, where Boot.run already no-ops) or an autopilot / driver /
|
||||
@@ -1228,9 +1276,11 @@ function RomImporter.new(onComplete, opts)
|
||||
saveNotice = {},
|
||||
-- MODS panel state (pass 3): mods is the cached LauncherMods.list() array
|
||||
-- (refreshed lazily on first draw and after any toggle/install/delete);
|
||||
-- modScroll is the list scroll offset (px, clamped in draw); modNotice is
|
||||
-- the last install/delete result { ok, text } shown as a line above the list.
|
||||
mods = nil, modScroll = 0, modNotice = nil,
|
||||
-- modScroll is the current paged list's inner scroll offset (px, clamped
|
||||
-- in draw); modNotice is the last install/delete result { ok, text }.
|
||||
-- requiredImportNotice stays inside the imported-files modal so validation
|
||||
-- failures are visible beside the file picker that caused them.
|
||||
mods = nil, modScroll = 0, modNotice = nil, requiredImportNotice = nil,
|
||||
-- Which game the MODS panel is answering for (a GameVersion id, nil =
|
||||
-- every game). Rows resolve their enable-state and their "runs here"
|
||||
-- verdict against it (src/mods/ModTargets.lua).
|
||||
@@ -1420,7 +1470,13 @@ function RomImporter:focus(f)
|
||||
local text = "Could not read the picked file. Reopen the picker and choose "
|
||||
.. "it with the Files (Documents) app, or copy it into: "
|
||||
.. love.filesystem.getSaveDirectory()
|
||||
if pickError:find("picked_mod", 1, true) then
|
||||
if pickError:find("picked_required_import", 1, true)
|
||||
or pickError:find("picked_stadium", 1, true) then
|
||||
self.modNotice = { ok = false, text = text }
|
||||
self.pickerPendingKind = nil
|
||||
self.pickerPendingModId = nil
|
||||
self.pickerPendingImportId = nil
|
||||
elseif pickError:find("picked_mod", 1, true) then
|
||||
self.modNotice = { ok = false, text = text }
|
||||
elseif pickError:find("picked_save", 1, true) then
|
||||
local version = self.androidPendingVersion or self:_savedropTarget()
|
||||
@@ -1431,6 +1487,20 @@ function RomImporter:focus(f)
|
||||
end
|
||||
return
|
||||
end
|
||||
local requiredName = findPendingRequiredImport()
|
||||
if requiredName then
|
||||
local modId, importId = self.pickerPendingModId, self.pickerPendingImportId
|
||||
self.pickerPendingKind = nil
|
||||
self.pickerPendingModId, self.pickerPendingImportId = nil, nil
|
||||
local imported = modId and importId
|
||||
and self:_importRequiredSource(modId, importId, requiredName)
|
||||
consumePick(self, requiredName, requiredName, imported)
|
||||
if not modId or not importId then
|
||||
self.modNotice = { ok = false,
|
||||
text = "A picked dependency file had no pending mod request and was discarded." }
|
||||
end
|
||||
return
|
||||
end
|
||||
local modName = findPendingMod(false, self.pickSkip)
|
||||
if modName then
|
||||
self:_installMod(modName)
|
||||
@@ -1470,6 +1540,8 @@ function RomImporter:setError(message, version)
|
||||
self.detail = tostring(message)
|
||||
self.progress = 0
|
||||
self.worker = nil
|
||||
-- Dropping the job stops collection; the next import clears the channels.
|
||||
self._extract = nil
|
||||
self.romData = nil
|
||||
-- A headless import has no launcher to read this off: POKEPORT_IMPORT_ONLY
|
||||
-- only ever quits from onComplete, so an import that fails here would sit in
|
||||
@@ -1536,23 +1608,65 @@ function RomImporter:startData(data, displayName)
|
||||
self.detail = displayName or info.displayName
|
||||
self.progress = 0
|
||||
self.romData = data
|
||||
self.worker = coroutine.create(function()
|
||||
self.status = "Preparing private game data"
|
||||
coroutine.yield()
|
||||
-- Redirect every cache write to this version's subtree, then clear only
|
||||
-- that version's previous cache from both homes (save directory and, for
|
||||
-- a portable install, the game folder). The other version is untouched.
|
||||
local CacheFs = require("src.import.CacheFs")
|
||||
local prefix = info.cachePrefix
|
||||
CacheFs.prefix = prefix
|
||||
self.status = "Preparing private game data"
|
||||
|
||||
-- Clear this version's previous cache from both homes before anything
|
||||
-- writes. Stays on the main thread so delete-then-fill-then-mark keeps one
|
||||
-- owner; the prefix is restored at once because the worker sets its own.
|
||||
local CacheFs = require("src.import.CacheFs")
|
||||
local prefix = info.cachePrefix
|
||||
local savedPrefix = CacheFs.prefix
|
||||
CacheFs.prefix = prefix
|
||||
local cleared, clearError = pcall(function()
|
||||
removeTree(prefix .. "data/generated")
|
||||
removeTree(prefix .. "assets/generated")
|
||||
love.filesystem.remove(prefix .. MARKER_PATH)
|
||||
CacheFs.removeTree("data/generated")
|
||||
CacheFs.removeTree("assets/generated")
|
||||
CacheFs.remove(MARKER_PATH)
|
||||
end)
|
||||
CacheFs.prefix = savedPrefix
|
||||
if not cleared then
|
||||
self:setError(tostring(clearError), version)
|
||||
return
|
||||
end
|
||||
|
||||
local manifest = decodeManifest(version)
|
||||
if self:_startExtractThread(version, prefix, data, displayName) then return end
|
||||
self:_startExtractCoroutine(version, info, prefix, displayName)
|
||||
end
|
||||
|
||||
-- False when threads are unavailable, so the coroutine path still covers
|
||||
-- that host. POKEPORT_NO_THREAD=1 forces it, which is the only way to
|
||||
-- exercise the fallback on a desktop.
|
||||
function RomImporter:_startExtractThread(version, prefix, data, displayName)
|
||||
if os.getenv("POKEPORT_NO_THREAD") == "1" then return false end
|
||||
if not (love.thread and love.thread.newThread) then return false end
|
||||
local ok, thread = pcall(love.thread.newThread, "src/import/ExtractThread.lua")
|
||||
if not ok or not thread then return false end
|
||||
local progressName = "rom_import_progress"
|
||||
local resultName = "rom_import_result"
|
||||
love.thread.getChannel(progressName):clear()
|
||||
love.thread.getChannel(resultName):clear()
|
||||
local started = pcall(thread.start, thread, version, prefix, data,
|
||||
progressName, resultName)
|
||||
if not started then return false end
|
||||
self._extract = {
|
||||
thread = thread, version = version, prefix = prefix,
|
||||
displayName = displayName,
|
||||
progress = love.thread.getChannel(progressName),
|
||||
result = love.thread.getChannel(resultName),
|
||||
}
|
||||
-- The worker owns the bytes now; drop ours so the 1-2 MiB string can go.
|
||||
self.romData = nil
|
||||
return true
|
||||
end
|
||||
|
||||
function RomImporter:_startExtractCoroutine(version, info, prefix, displayName)
|
||||
self.worker = coroutine.create(function()
|
||||
coroutine.yield()
|
||||
local CacheFs = require("src.import.CacheFs")
|
||||
CacheFs.prefix = prefix
|
||||
local manifest = require("src.import.RomManifest").decode(version)
|
||||
local RomExtractor = version == "gold"
|
||||
and require("src.import.RomExtractorGen2")
|
||||
or require("src.import.RomExtractor")
|
||||
@@ -1565,47 +1679,93 @@ function RomImporter:startData(data, displayName)
|
||||
coroutine.yield()
|
||||
end)
|
||||
extractor:run()
|
||||
CacheFs.prefix = "" -- restore the default so later writes stay at the root
|
||||
self.romData = nil
|
||||
collectgarbage("collect")
|
||||
-- Written last: the marker is what isReady() checks, so it must only
|
||||
-- appear once every required file is in place.
|
||||
local ok, writeError = CacheFs.write(MARKER_PATH, markerFor(version))
|
||||
CacheFs.prefix = "" -- restore the default so later writes stay at the root
|
||||
if not ok then error("could not finish the private cache: " .. tostring(writeError)) end
|
||||
self.ready[version] = true
|
||||
self.returning[version] = false
|
||||
self.romName[version] = (displayName
|
||||
and (displayName:match("[^/\\]+$") or displayName)) or self.romName[version]
|
||||
-- Android: drop the consumed save-dir .gb/.gbc (picked_rom.gb or a USB copy)
|
||||
-- so the next Choose / focus cannot treat it as a fresh pending ROM.
|
||||
if self.mobileFileBridge and type(displayName) == "string"
|
||||
and not displayName:find("[/\\]") then
|
||||
love.filesystem.remove(displayName)
|
||||
end
|
||||
self.importing = nil
|
||||
self.workState = "complete"
|
||||
self.completeVersion = version
|
||||
self.status = "Ready"
|
||||
-- NX launcher stays put: keep the imports/ cleanup hint instead of
|
||||
-- overwriting it with a "Starting…" line that never boots from here.
|
||||
if self.launcher and self.isNX and type(displayName) == "string" then
|
||||
self.detail = Strings("%s imported. You may delete the copy from "
|
||||
.. "imports/ when finished.", displayName)
|
||||
else
|
||||
self.detail = "Starting " .. info.displayName .. "..."
|
||||
end
|
||||
self.progress = 1
|
||||
if self.launcher then
|
||||
-- Stay on the launcher; the player presses Play to boot the new game.
|
||||
return
|
||||
end
|
||||
self._handedOff = true
|
||||
resetPointerCursor(self)
|
||||
if self._flex then require("src.import.LauncherView").detach(self) end
|
||||
if self.onComplete then self.onComplete(version) end
|
||||
self:_completeImport(version, prefix, displayName)
|
||||
end)
|
||||
end
|
||||
|
||||
-- Everything after the tree is filled, shared by both worker paths. Raises
|
||||
-- on a failed marker write; the thread path calls it inside a pcall.
|
||||
function RomImporter:_completeImport(version, prefix, displayName)
|
||||
local info = GameVersion.info(version)
|
||||
local CacheFs = require("src.import.CacheFs")
|
||||
-- Written last: the marker is what isReady() checks, so it must only
|
||||
-- appear once every required file is in place.
|
||||
local savedPrefix = CacheFs.prefix
|
||||
CacheFs.prefix = prefix
|
||||
local ok, writeError = CacheFs.write(MARKER_PATH, markerFor(version))
|
||||
CacheFs.prefix = savedPrefix
|
||||
if not ok then
|
||||
error("could not finish the private cache: " .. tostring(writeError))
|
||||
end
|
||||
self.ready[version] = true
|
||||
self.returning[version] = false
|
||||
self.romName[version] = (displayName
|
||||
and (displayName:match("[^/\\]+$") or displayName)) or self.romName[version]
|
||||
-- Android: drop the consumed save-dir .gb/.gbc (picked_rom.gb or a USB copy)
|
||||
-- so the next Choose / focus cannot treat it as a fresh pending ROM.
|
||||
if self.mobileFileBridge and type(displayName) == "string"
|
||||
and not displayName:find("[/\\]") then
|
||||
love.filesystem.remove(displayName)
|
||||
end
|
||||
self.importing = nil
|
||||
self.workState = "complete"
|
||||
self.completeVersion = version
|
||||
self.status = "Ready"
|
||||
-- NX launcher stays put: keep the imports/ cleanup hint instead of
|
||||
-- overwriting it with a "Starting…" line that never boots from here.
|
||||
if self.launcher and self.isNX and type(displayName) == "string" then
|
||||
self.detail = Strings("%s imported. You may delete the copy from "
|
||||
.. "imports/ when finished.", displayName)
|
||||
else
|
||||
self.detail = "Starting " .. info.displayName .. "..."
|
||||
end
|
||||
self.progress = 1
|
||||
if self.launcher then
|
||||
-- Stay on the launcher; the player presses Play to boot the new game.
|
||||
return
|
||||
end
|
||||
self._handedOff = true
|
||||
resetPointerCursor(self)
|
||||
if self._flex then require("src.import.LauncherView").detach(self) end
|
||||
if self.onComplete then self.onComplete(version) end
|
||||
end
|
||||
|
||||
-- Drain the worker's progress and finish when it reports done. One
|
||||
-- non-blocking poll per frame, like the other _pump* collectors above.
|
||||
function RomImporter:_pumpExtract()
|
||||
local job = self._extract
|
||||
if not job then return end
|
||||
local msg = job.progress:pop()
|
||||
while msg do
|
||||
self.status = msg.stage
|
||||
self.progress = msg.progress / msg.total
|
||||
self.stageCurrent = msg.current
|
||||
self.stageTotal = msg.stageTotal
|
||||
msg = job.progress:pop()
|
||||
end
|
||||
local res = job.result:pop()
|
||||
if not res then
|
||||
-- A thread that died before pushing a result would strand the loader.
|
||||
local threadError = job.thread.getError and job.thread:getError()
|
||||
if threadError then
|
||||
self._extract = nil
|
||||
self:setError(tostring(threadError), job.version)
|
||||
end
|
||||
return
|
||||
end
|
||||
self._extract = nil
|
||||
if not res.ok then
|
||||
self:setError(tostring(res.error), job.version)
|
||||
return
|
||||
end
|
||||
local ok, err = pcall(self._completeImport, self, job.version, job.prefix,
|
||||
job.displayName)
|
||||
if not ok then self:setError(tostring(err), job.version) end
|
||||
end
|
||||
|
||||
function RomImporter:startPath(path)
|
||||
if not path then return end
|
||||
local data, readError = readExternalPath(path)
|
||||
@@ -1649,7 +1809,7 @@ end
|
||||
function RomImporter:_installMod(source)
|
||||
if self.workState == "working" then return end
|
||||
self.tab = "mods"
|
||||
local ok, installed, res = pcall(function()
|
||||
local ok, installed, res, manifest = pcall(function()
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
return LauncherMods.installZip(source)
|
||||
end)
|
||||
@@ -1661,6 +1821,17 @@ function RomImporter:_installMod(source)
|
||||
if installed then
|
||||
pcall(self._refreshMods, self)
|
||||
self.modNotice = { ok = true, text = "Installed " .. tostring(res) }
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
local checkTarget = manifest
|
||||
if not checkTarget and type(res) == "string" then
|
||||
checkTarget = { id = res }
|
||||
end
|
||||
if checkTarget and LauncherMods.checkDependencies then
|
||||
local depCheck = LauncherMods.checkDependencies(checkTarget)
|
||||
if depCheck and depCheck.hasIssues then
|
||||
self._modDepResolver = depCheck
|
||||
end
|
||||
end
|
||||
else
|
||||
self.modNotice = { ok = false, text = tostring(res) }
|
||||
end
|
||||
@@ -1727,6 +1898,152 @@ function RomImporter:chooseMod()
|
||||
if path then self:_installMod(path) end
|
||||
end
|
||||
|
||||
local function requiredManifest(self, modId)
|
||||
for _, row in ipairs(self.mods or {}) do
|
||||
if row.id == modId then return row.manifest, row end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function requiredSpec(manifest, importId)
|
||||
for _, candidate in ipairs(require("src.mods.RequiredImports").specs(manifest)) do
|
||||
if candidate.id == importId then return candidate end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function requiredImportNotice(self, modId, importId, text)
|
||||
self.requiredImportNotice = {
|
||||
modId = modId,
|
||||
importId = importId,
|
||||
text = tostring(text),
|
||||
}
|
||||
end
|
||||
|
||||
function RomImporter:_importRequiredData(modId, importId, data)
|
||||
local manifest = requiredManifest(self, modId)
|
||||
if not manifest then
|
||||
self.modNotice = { ok = false, text = "Required import failed: mod not found." }
|
||||
return nil
|
||||
end
|
||||
local ok, result = require("src.mods.RequiredImports")
|
||||
.importData(manifest, importId, data)
|
||||
if ok then
|
||||
self.requiredImportNotice = nil
|
||||
self.modNotice = { ok = true, text = "Imported " .. tostring(importId)
|
||||
.. " for " .. tostring(manifest.name or manifest.id) .. "." }
|
||||
self:_refreshMods()
|
||||
return true
|
||||
end
|
||||
-- Keep validation feedback on the imported-files page. A general Mods-page
|
||||
-- notice is hidden by this modal and made MD5 failures especially easy to miss.
|
||||
requiredImportNotice(self, modId, importId, result)
|
||||
self.modNotice = nil
|
||||
return nil
|
||||
end
|
||||
|
||||
function RomImporter:_importRequiredSource(modId, importId, source)
|
||||
local manifest = requiredManifest(self, modId)
|
||||
local spec = manifest and requiredSpec(manifest, importId)
|
||||
if not spec then
|
||||
requiredImportNotice(self, modId, importId, "Import declaration was not found.")
|
||||
self.modNotice = nil
|
||||
return nil
|
||||
end
|
||||
local info = love.filesystem.getInfo(source, "file")
|
||||
local size = info and info.size or externalFileSize(source)
|
||||
local sizeErr = require("src.mods.RequiredImports").sizeError(spec, size, false)
|
||||
if sizeErr then
|
||||
requiredImportNotice(self, modId, importId, sizeErr)
|
||||
self.modNotice = nil
|
||||
return nil
|
||||
end
|
||||
local data = love.filesystem.read(source)
|
||||
if not data then data = readExternalPath(source) end
|
||||
if not data then
|
||||
requiredImportNotice(self, modId, importId, "Could not read the selected file.")
|
||||
self.modNotice = nil
|
||||
return nil
|
||||
end
|
||||
return self:_importRequiredData(modId, importId, data)
|
||||
end
|
||||
|
||||
function RomImporter:_removeRequiredImport(modId, importId)
|
||||
local manifest = requiredManifest(self, modId)
|
||||
if not manifest then return end
|
||||
local ok, err = require("src.mods.RequiredImports").remove(manifest, importId)
|
||||
if ok then
|
||||
self.requiredImportNotice = nil
|
||||
self.modNotice = { ok = true, text = "Deleted " .. tostring(importId) .. "." }
|
||||
self:_refreshMods()
|
||||
else
|
||||
requiredImportNotice(self, modId, importId, err)
|
||||
self.modNotice = nil
|
||||
end
|
||||
end
|
||||
|
||||
-- Select and validate one manifest-declared file. NX has no host picker, so
|
||||
-- its equivalent is an engine-owned imports/baseroms inbox that can be filled
|
||||
-- over MTP; every other native/mobile picker lands on the same validation path.
|
||||
function RomImporter:chooseRequiredImport(modId, importId)
|
||||
if self.workState == "working" then return end
|
||||
local manifest = requiredManifest(self, modId)
|
||||
if not manifest then return end
|
||||
local spec = requiredSpec(manifest, importId)
|
||||
if not spec then return end
|
||||
|
||||
if self.isNX then
|
||||
local inbox = "imports/baseroms"
|
||||
love.filesystem.createDirectory(inbox)
|
||||
local lastError
|
||||
for _, name in ipairs(love.filesystem.getDirectoryItems(inbox) or {}) do
|
||||
if name:sub(1, 1) ~= "." then
|
||||
local path = inbox .. "/" .. name
|
||||
local info = love.filesystem.getInfo(path, "file")
|
||||
local sizeErr = info and require("src.mods.RequiredImports")
|
||||
.sizeError(spec, info.size, false)
|
||||
local data = not sizeErr and love.filesystem.read(path) or nil
|
||||
if data and self:_importRequiredData(modId, importId, data) then return end
|
||||
if sizeErr then lastError = sizeErr
|
||||
elseif self.requiredImportNotice
|
||||
and self.requiredImportNotice.modId == modId
|
||||
and self.requiredImportNotice.importId == importId then
|
||||
lastError = self.requiredImportNotice.text
|
||||
end
|
||||
end
|
||||
end
|
||||
requiredImportNotice(self, modId, importId, lastError
|
||||
or "No matching file in imports/baseroms/. Copy it there over MTP, then try again.")
|
||||
self.modNotice = nil
|
||||
return
|
||||
end
|
||||
if self.nativePicker then
|
||||
if self.mobileFileBridge and not pickerHasKind("required_import") then
|
||||
requiredImportNotice(self, modId, importId,
|
||||
"This app build cannot pick required mod files yet. Update the app and try again.")
|
||||
self.modNotice = nil
|
||||
return
|
||||
end
|
||||
self.pickerPendingKind = "required_import"
|
||||
self.pickerPendingModId = modId
|
||||
self.pickerPendingImportId = importId
|
||||
if not pickFile("required_import") then
|
||||
self.pickerPendingKind = nil
|
||||
self.pickerPendingModId = nil
|
||||
self.pickerPendingImportId = nil
|
||||
requiredImportNotice(self, modId, importId, "Could not open the file picker.")
|
||||
self.modNotice = nil
|
||||
elseif self.android then
|
||||
self.pickPending = true
|
||||
self.pickTimer = 0
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
local path = chooseRequiredFile()
|
||||
if path then self:_importRequiredSource(modId, importId, path) end
|
||||
end
|
||||
|
||||
-- Which game a dropped .sav imports into: a .sav has no version signature of
|
||||
-- its own, so it lands on the active game tab. When a non-game tab (mods) is
|
||||
-- showing, default to red -- the always-present first game -- rather than
|
||||
@@ -2034,7 +2351,8 @@ function RomImporter:_pollPickedFiles(dt)
|
||||
if not found then
|
||||
for _, name in ipairs(love.filesystem.getDirectoryItems("")) do
|
||||
local n = name:lower()
|
||||
if n:match("%.gbc?$") or n == "picked_mod.zip" or n == "picked_save.sav" then
|
||||
if n:match("%.gbc?$") or n == "picked_mod.zip" or n == "picked_save.sav"
|
||||
or n == "picked_required_import.bin" or n == "picked_stadium.z64" then
|
||||
found = true
|
||||
break
|
||||
end
|
||||
@@ -2048,6 +2366,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
|
||||
@@ -2067,6 +2394,7 @@ function RomImporter:update(dt)
|
||||
self:_pumpFindThumbs()
|
||||
self:_pumpModCheck()
|
||||
self:_pumpModInstall()
|
||||
self:_pumpExtract()
|
||||
-- Dev harness: POKEPORT_LAUNCHER_SHOT=/path.png resizes the window from
|
||||
-- POKEPORT_WIN=WxH, lets the view settle, then captures one frame and
|
||||
-- quits, so a scripted run can see the real launcher at any window shape
|
||||
@@ -2154,7 +2482,12 @@ function RomImporter:update(dt)
|
||||
local version = self.pickerPendingVersion
|
||||
self.pickerPendingKind = nil
|
||||
self.pickerPendingVersion = nil
|
||||
if kind == "mod" then
|
||||
if kind == "required_import" then
|
||||
local modId, importId = self.pickerPendingModId, self.pickerPendingImportId
|
||||
self.pickerPendingModId, self.pickerPendingImportId = nil, nil
|
||||
if modId and importId then self:_importRequiredSource(modId, importId, path) end
|
||||
if Platform.isUWP() then os.remove(path) end
|
||||
elseif kind == "mod" then
|
||||
self:_installMod(path)
|
||||
if Platform.isUWP() and self.modNotice and self.modNotice.ok then
|
||||
os.remove(path)
|
||||
@@ -2176,7 +2509,10 @@ function RomImporter:update(dt)
|
||||
local version = self.pickerPendingVersion or self:_savedropTarget()
|
||||
self.pickerPendingKind = nil
|
||||
self.pickerPendingVersion = nil
|
||||
if kind == "mod" then
|
||||
if kind == "required_import" then
|
||||
self.modNotice = { ok = false, text = errorText }
|
||||
self.pickerPendingModId, self.pickerPendingImportId = nil, nil
|
||||
elseif kind == "mod" then
|
||||
self.modNotice = { ok = false, text = errorText }
|
||||
elseif kind == "sav" then
|
||||
self.saveNotice[version] = { ok = false, text = errorText }
|
||||
@@ -2336,6 +2672,20 @@ function RomImporter:_updatePadCursor(dt)
|
||||
local ny = self._padCursor.y + dy * speed * dt
|
||||
self._padCursor.x = math.max(ox, math.min(ox + w, nx))
|
||||
self._padCursor.y = math.max(oy, math.min(oy + h, ny))
|
||||
-- Pushing INTO the top/bottom edge scrolls the page instead of stalling.
|
||||
-- The cursor is clamped to the safe area above, so on a short window the
|
||||
-- rows below the fold are unreachable on a stickless handheld: no mouse
|
||||
-- wheel, no touchscreen, and no right stick to feed the existing wheel
|
||||
-- path. Only the OVERSHOOT scrolls -- parking the cursor at the edge does
|
||||
-- nothing, it has to be actively pushed -- and this block only runs on pad
|
||||
-- input, so a real mouse is unaffected. /48 matches the pixels-per-notch
|
||||
-- LauncherView.draw multiplies back out.
|
||||
local overY = 0
|
||||
if ny > oy + h then overY = ny - (oy + h)
|
||||
elseif ny < oy then overY = ny - oy end
|
||||
if overY ~= 0 and self._flex then
|
||||
require("src.import.LauncherView").wheelmoved(self, 0, -overY / 48)
|
||||
end
|
||||
-- Desktop: FlexLove polls the real mouse, so warp it with the pad pointer.
|
||||
-- NX: the getPosition bridge already returns pad coords — skip setPosition.
|
||||
if not self.isNX and love.mouse.setPosition then
|
||||
@@ -2436,9 +2786,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 +3057,43 @@ function RomImporter:fileUrl(path)
|
||||
end
|
||||
|
||||
function RomImporter:keypressed(key)
|
||||
if self._profileSavePrompt then
|
||||
if key == "backspace" then
|
||||
self._profileSavePrompt.text = utf8Back(self._profileSavePrompt.text or "")
|
||||
elseif key == "return" or key == "kpenter" then
|
||||
local txt = self._profileSavePrompt and self._profileSavePrompt.text
|
||||
if txt and txt ~= "" then
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
LauncherMods.saveProfile(txt)
|
||||
self._profileSavePrompt = nil
|
||||
self:_disarmTextInput()
|
||||
if self._refreshMods then self:_refreshMods() end
|
||||
end
|
||||
elseif key == "escape" then
|
||||
self._profileSavePrompt = nil
|
||||
self:_disarmTextInput()
|
||||
end
|
||||
return
|
||||
end
|
||||
if self._profileRenamePrompt then
|
||||
if key == "backspace" then
|
||||
self._profileRenamePrompt.text = utf8Back(self._profileRenamePrompt.text or "")
|
||||
elseif key == "return" or key == "kpenter" then
|
||||
local txt = self._profileRenamePrompt and self._profileRenamePrompt.text
|
||||
local old = self._profileRenamePrompt and self._profileRenamePrompt.oldName
|
||||
if txt and txt ~= "" and old then
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
LauncherMods.renameProfile(old, txt)
|
||||
self._profileRenamePrompt = nil
|
||||
self:_disarmTextInput()
|
||||
if self._refreshMods then self:_refreshMods() end
|
||||
end
|
||||
elseif key == "escape" then
|
||||
self._profileRenamePrompt = nil
|
||||
self:_disarmTextInput()
|
||||
end
|
||||
return
|
||||
end
|
||||
if self._settingsText then
|
||||
if key == "backspace" then
|
||||
self._settingsText.text = utf8Back(self._settingsText.text)
|
||||
@@ -2743,7 +3136,7 @@ function RomImporter:keypressed(key)
|
||||
return
|
||||
end
|
||||
if self._modConfirm or self._modVersions or self._modReleaseNotes
|
||||
or self._findDetails then
|
||||
or self._findDetails or self._appPatchNotes then
|
||||
-- Focus navigation belongs to the visible modal as well as the launcher
|
||||
-- beneath it. Route arrows and an already-armed confirm before this guard
|
||||
-- returns; unarmed Enter still falls through to the modal guard. Keep this
|
||||
@@ -2756,6 +3149,8 @@ function RomImporter:keypressed(key)
|
||||
self._findDetails = nil
|
||||
elseif self._modReleaseNotes then
|
||||
self._modReleaseNotes = nil
|
||||
elseif self._appPatchNotes then
|
||||
self._appPatchNotes = nil
|
||||
else
|
||||
self._modConfirm = nil
|
||||
self._modVersions = nil
|
||||
@@ -2870,6 +3265,14 @@ function RomImporter:_commitRename()
|
||||
end
|
||||
|
||||
function RomImporter:textinput(text)
|
||||
if self._profileSavePrompt then
|
||||
self._profileSavePrompt.text = utf8Cap((self._profileSavePrompt.text or "") .. text, MAX_SLOT_LABEL)
|
||||
return
|
||||
end
|
||||
if self._profileRenamePrompt then
|
||||
self._profileRenamePrompt.text = utf8Cap((self._profileRenamePrompt.text or "") .. text, MAX_SLOT_LABEL)
|
||||
return
|
||||
end
|
||||
if self._settingsText then
|
||||
local st = self._settingsText
|
||||
st.text = utf8Cap(st.text .. text, st.maxLen or MAX_SLOT_LABEL)
|
||||
@@ -3064,15 +3467,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 +3487,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 +3499,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 +3759,76 @@ function RomImporter:_pumpModInstall()
|
||||
else
|
||||
self.modNotice = { ok = true, text = text }
|
||||
end
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
local depCheck = LauncherMods.checkDependencies({ id = spec.modId })
|
||||
if depCheck and depCheck.hasIssues then
|
||||
self._modDepResolver = depCheck
|
||||
end
|
||||
end
|
||||
|
||||
-- Start an async pull for a single dependency
|
||||
function RomImporter:_startDepPull(dep)
|
||||
if not dep or not dep.github then return end
|
||||
self._depPullState = self._depPullState or {}
|
||||
local hFetch = require("src.mods.ModUpdate").beginFetchReleases(dep.github, dep.id, { force = true })
|
||||
self._depPullState[dep.id] = {
|
||||
dep = dep,
|
||||
stage = "fetching",
|
||||
fetchHandle = hFetch,
|
||||
}
|
||||
end
|
||||
|
||||
-- Pump all in-flight dependency pulls
|
||||
function RomImporter:_pumpDepPulls()
|
||||
if not self._depPullState then return end
|
||||
local ModUpdate = require("src.mods.ModUpdate")
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
|
||||
for depId, state in pairs(self._depPullState) do
|
||||
if state.stage == "fetching" then
|
||||
local done, releases, err = ModUpdate.pumpFetchReleases(state.fetchHandle)
|
||||
if done then
|
||||
if err or not releases or #releases == 0 then
|
||||
state.stage = "error"
|
||||
state.err = err or "No downloadable releases found on GitHub"
|
||||
else
|
||||
local rel = releases[1]
|
||||
if not rel or not rel.zip or not rel.zip.url then
|
||||
state.stage = "error"
|
||||
state.err = "Latest release has no downloadable .zip asset"
|
||||
else
|
||||
local tmpName = ("dep_%s_%s.zip"):format(depId, tostring(rel.version or os.time()))
|
||||
state.dlHandle = ModUpdate.beginDownloadZip(rel.zip.url, tmpName, rel.zip.size)
|
||||
state.stage = "downloading"
|
||||
state.targetVersion = rel.version
|
||||
end
|
||||
end
|
||||
end
|
||||
elseif state.stage == "downloading" then
|
||||
local done, localPath, err, progress = ModUpdate.pumpDownloadZip(state.dlHandle)
|
||||
state.progress = progress
|
||||
if done then
|
||||
if err or not localPath then
|
||||
state.stage = "error"
|
||||
state.err = err or "Download failed"
|
||||
else
|
||||
state.stage = "installing"
|
||||
local okInst, versionRes = LauncherMods.installDownloadedZip(depId, localPath, state.targetVersion)
|
||||
if okInst then
|
||||
state.stage = "done"
|
||||
pcall(self._refreshMods, self)
|
||||
if self._modDepResolver and self._modDepResolver.targetMod then
|
||||
local updated = LauncherMods.checkDependencies(self._modDepResolver.targetMod)
|
||||
self._modDepResolver = updated
|
||||
end
|
||||
else
|
||||
state.stage = "error"
|
||||
state.err = tostring(versionRes or "Installation failed")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function RomImporter:_confirmModUpdate(modId, release)
|
||||
@@ -3692,11 +4169,19 @@ end
|
||||
|
||||
-- Turn finished thumbnail downloads into images. Called from update(), so
|
||||
-- love.graphics.newImage runs on the render thread where it belongs.
|
||||
-- love.graphics.newImage decodes the PNG and uploads it, both on the render
|
||||
-- thread. A page's worth of thumbnails landing in the same frame did that
|
||||
-- many times back to back and dropped the frame, so only this many are
|
||||
-- decoded per pass; the rest keep their spinner one frame longer.
|
||||
local THUMB_DECODES_PER_FRAME = 2
|
||||
|
||||
function RomImporter:_pumpFindThumbs()
|
||||
local pending = self._findThumbFetch
|
||||
if not pending then return end
|
||||
local Fetch = require("src.net.Fetch")
|
||||
local decoded = 0
|
||||
for id, item in pairs(pending) do
|
||||
if decoded >= THUMB_DECODES_PER_FRAME then break end
|
||||
local st = Fetch.poll(item.job)
|
||||
if st.status ~= "pending" then
|
||||
Fetch.release(item.job)
|
||||
@@ -3705,6 +4190,7 @@ function RomImporter:_pumpFindThumbs()
|
||||
if st.status == "ok" and st.path then
|
||||
local ok, img = pcall(love.graphics.newImage, st.path)
|
||||
image = ok and img or nil
|
||||
decoded = decoded + 1
|
||||
end
|
||||
self._findThumbs = self._findThumbs or {}
|
||||
self._findThumbs[id] = image or false
|
||||
@@ -3721,14 +4207,21 @@ end
|
||||
-- entry per frame so opening the tab cannot stall for the whole listing.
|
||||
-- The result is memoized per id for the session; a repo with no releases
|
||||
-- or a failed fetch resolves to an empty table so it is tried once.
|
||||
function RomImporter:_findStats(entry)
|
||||
-- PURE read: whatever is already known for a row, or nil. Resolving a
|
||||
-- feed-published stat or a repo-less entry is memoization, not network, so it
|
||||
-- stays here; nothing in this function can start a fetch. That matters
|
||||
-- because the sort comparator calls it for EVERY entry -- when queueing lived
|
||||
-- in here, sorting a 500-mod index by Popularity queued 500 GitHub requests
|
||||
-- on the first frame, blew the hourly rate limit, and the failures then
|
||||
-- re-queued together every 60s for as long as the tab was open.
|
||||
function RomImporter:_findStatsCached(entry)
|
||||
self._findStatsCache = self._findStatsCache or {}
|
||||
local cached = self._findStatsCache[entry.id]
|
||||
if cached then
|
||||
if cached.done or (cached.retryAt and os.time() < cached.retryAt) then
|
||||
return cached
|
||||
end
|
||||
self._findStatsCache[entry.id] = nil -- retry window open, refetch
|
||||
return nil -- retry window open; _requestFindStats decides what to do
|
||||
end
|
||||
if entry.downloads ~= nil or entry.first_release or entry.last_release then
|
||||
cached = { total = entry.downloads, first = entry.first_release,
|
||||
@@ -3741,22 +4234,54 @@ function RomImporter:_findStats(entry)
|
||||
self._findStatsCache[entry.id] = cached
|
||||
return cached
|
||||
end
|
||||
-- ASYNC (was a blocking fetch, one row per frame). "One per frame" bounded
|
||||
-- how many stalls happened at once, not how long each one lasted: every
|
||||
-- frame that started a fetch blocked for the whole round trip, so scrolling
|
||||
-- a listing juddered once per row. Rows now queue a handle and fill in
|
||||
-- when it lands; until then the row simply has no stats line.
|
||||
self._findStatsPending = self._findStatsPending or {}
|
||||
if not self._findStatsPending[entry.id] then
|
||||
local ModUpdate = require("src.mods.ModUpdate")
|
||||
self._findStatsPending[entry.id] = {
|
||||
id = entry.id,
|
||||
h = ModUpdate.beginFetchReleases(entry.github, entry.id, {}),
|
||||
}
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Queue one row's release fetch. Only rows actually on the page call this --
|
||||
-- the rule _findThumb already follows -- so the fan-out is a page, not the
|
||||
-- whole index.
|
||||
function RomImporter:_requestFindStats(entry)
|
||||
if self:_findStatsCached(entry) then return end
|
||||
if not entry.github or entry.github == "" then return end
|
||||
local cached = self._findStatsCache[entry.id]
|
||||
if cached then
|
||||
if cached.retryAt and os.time() >= cached.retryAt then
|
||||
self._findStatsCache[entry.id] = nil -- retry window open, refetch
|
||||
else
|
||||
return
|
||||
end
|
||||
end
|
||||
self._findStatsPending = self._findStatsPending or {}
|
||||
if self._findStatsPending[entry.id] then return end
|
||||
local ModUpdate = require("src.mods.ModUpdate")
|
||||
self._findStatsPending[entry.id] = {
|
||||
id = entry.id,
|
||||
h = ModUpdate.beginFetchReleases(entry.github, entry.id, {}),
|
||||
}
|
||||
end
|
||||
|
||||
-- Request-and-read, for a row that is being drawn and for the detail modal.
|
||||
function RomImporter:_findStats(entry)
|
||||
self:_requestFindStats(entry)
|
||||
return self:_findStatsCached(entry)
|
||||
end
|
||||
|
||||
-- How many rows are still waiting on a release check, for the panel's
|
||||
-- progress line.
|
||||
function RomImporter:_findStatsPendingCount()
|
||||
local n = 0
|
||||
for _ in pairs(self._findStatsPending or {}) do n = n + 1 end
|
||||
return n
|
||||
end
|
||||
|
||||
function RomImporter:_findStatsPendingFor(id)
|
||||
return (self._findStatsPending and self._findStatsPending[id]) ~= nil
|
||||
end
|
||||
|
||||
function RomImporter:_findThumbPending(id)
|
||||
return (self._findThumbFetch and self._findThumbFetch[id]) ~= nil
|
||||
end
|
||||
|
||||
-- Drive in-flight FIND MODS stats lookups. Called from update().
|
||||
function RomImporter:_pumpFindStats()
|
||||
local pending = self._findStatsPending
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
-- The per-version import metadata (symbol table + ROM hash) that drives
|
||||
-- RomExtractor. Split out of RomImporter so the extraction worker thread can
|
||||
-- decode it itself: shipping the decoded table across a love.thread channel
|
||||
-- would deep-copy every symbol for nothing.
|
||||
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
|
||||
local RomManifest = {}
|
||||
|
||||
function RomManifest.decode(version)
|
||||
local info = GameVersion.info(version)
|
||||
local raw, readError = love.filesystem.read(info.manifest)
|
||||
if not raw then
|
||||
error("ROM import metadata is missing: " .. tostring(readError))
|
||||
end
|
||||
local Json = require("src.link.Json")
|
||||
local manifest, decodeError = Json.decode(raw)
|
||||
if not manifest then
|
||||
error("ROM import metadata is invalid: " .. tostring(decodeError))
|
||||
end
|
||||
assert(manifest.romSha1 == info.sha1, "ROM import metadata version mismatch")
|
||||
return manifest
|
||||
end
|
||||
|
||||
return RomManifest
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
-- Background compute for sandboxed mods, behind the "background" permission.
|
||||
--
|
||||
-- mod.fetch covers work that is waiting on a server. This covers work that is
|
||||
-- waiting on the CPU: a mod hands over a script from its own folder plus a
|
||||
-- table of plain data, and gets the return value back through the same
|
||||
-- handle/poll/release shape mod.fetch uses.
|
||||
--
|
||||
-- The worker (src/mods/job_worker.lua) builds the SAME Sandbox.envFor
|
||||
-- environment the main thread does before it loads the mod's chunk, so this
|
||||
-- is not the love.thread hole reopened: the mod's code still cannot see
|
||||
-- io, os, debug, ffi, package or love.filesystem, and require is refused
|
||||
-- outright inside a job.
|
||||
--
|
||||
-- One thread per job rather than a pool. A pooled state would carry one
|
||||
-- mod's globals into the next mod's job, and resetting it properly is the
|
||||
-- same work as making a new one.
|
||||
|
||||
local SafePath = require("src.mods.SafePath")
|
||||
|
||||
local Job = {}
|
||||
|
||||
Job.MAX_INFLIGHT = 2 -- per mod
|
||||
Job.MAX_GLOBAL = 4 -- across all mods, so jobs cannot eat every core
|
||||
Job.DEFAULT_SECONDS = 5
|
||||
Job.MAX_SECONDS = 30
|
||||
-- Depth cap on the data crossing the channel. A cycle is caught by the seen
|
||||
-- set; this catches the merely absurd.
|
||||
Job.MAX_DEPTH = 16
|
||||
|
||||
local nextId = 0
|
||||
local liveGlobal = 0
|
||||
|
||||
-- Only plain data crosses a thread boundary: a function or userdata cannot be
|
||||
-- serialised, and letting one through would fail deep inside LÖVE instead of
|
||||
-- at the call the mod made.
|
||||
local function plain(value, depth, seen)
|
||||
local t = type(value)
|
||||
if t == "nil" or t == "boolean" or t == "number" or t == "string" then
|
||||
return value
|
||||
end
|
||||
if t ~= "table" then
|
||||
return nil, ("a job cannot carry a %s, only plain data"):format(t)
|
||||
end
|
||||
depth = (depth or 0) + 1
|
||||
if depth > Job.MAX_DEPTH then
|
||||
return nil, "a job's data is nested too deeply"
|
||||
end
|
||||
seen = seen or {}
|
||||
if seen[value] then return nil, "a job cannot carry a cycle" end
|
||||
seen[value] = true
|
||||
local out = {}
|
||||
for k, v in pairs(value) do
|
||||
local kt = type(k)
|
||||
if kt ~= "string" and kt ~= "number" then
|
||||
return nil, ("a job cannot carry a %s key"):format(kt)
|
||||
end
|
||||
local copied, err = plain(v, depth, seen)
|
||||
if err then return nil, err end
|
||||
out[k] = copied
|
||||
end
|
||||
seen[value] = nil
|
||||
return out
|
||||
end
|
||||
Job.plain = plain
|
||||
|
||||
function Job.available()
|
||||
return (love and love.thread and love.thread.newThread) ~= nil
|
||||
end
|
||||
|
||||
local function bucket(loader, modId)
|
||||
loader.jobs = loader.jobs or {}
|
||||
local b = loader.jobs[modId]
|
||||
if not b then b = {}; loader.jobs[modId] = b end
|
||||
return b
|
||||
end
|
||||
|
||||
local function inflight(b)
|
||||
local n = 0
|
||||
for _, job in pairs(b) do
|
||||
if job.status == "pending" then n = n + 1 end
|
||||
end
|
||||
return n
|
||||
end
|
||||
|
||||
-- `script` is relative to the mod's own folder, and goes through the same
|
||||
-- SafePath rules mod:read does -- a job is not a way to name a path.
|
||||
-- Argument checks come BEFORE the host check: a bad path or an unserialisable
|
||||
-- argument is the mod author's bug and should read the same on every machine,
|
||||
-- not be masked into "unavailable" on a build without threads.
|
||||
function Job.run(loader, modId, modPath, script, arg, opts)
|
||||
if type(script) ~= "string" or script == "" then
|
||||
return nil, "a job needs a script path inside your mod"
|
||||
end
|
||||
-- SafePath.require raises rather than returning, so the mod's bad path
|
||||
-- comes back as a value here instead of unwinding its caller.
|
||||
local okPath, safe = pcall(SafePath.join, modPath, script, "a job script")
|
||||
if not okPath then return nil, tostring(safe) end
|
||||
local payload, dataErr = plain(arg)
|
||||
if dataErr then return nil, dataErr end
|
||||
if not Job.available() then return nil, "background jobs are unavailable" end
|
||||
|
||||
local b = bucket(loader, modId)
|
||||
if inflight(b) >= Job.MAX_INFLIGHT then
|
||||
return nil, ("too many jobs in flight (limit %d); poll and release the "
|
||||
.. "ones you have"):format(Job.MAX_INFLIGHT)
|
||||
end
|
||||
if liveGlobal >= Job.MAX_GLOBAL then
|
||||
return nil, "the machine is already running as many jobs as it will"
|
||||
end
|
||||
|
||||
opts = type(opts) == "table" and opts or {}
|
||||
local seconds = tonumber(opts.maxSeconds) or Job.DEFAULT_SECONDS
|
||||
if seconds > Job.MAX_SECONDS then seconds = Job.MAX_SECONDS end
|
||||
if seconds < 1 then seconds = 1 end
|
||||
|
||||
nextId = nextId + 1
|
||||
local argName = "modjob_arg_" .. nextId
|
||||
local resultName = "modjob_result_" .. nextId
|
||||
local argCh = love.thread.getChannel(argName)
|
||||
local resCh = love.thread.getChannel(resultName)
|
||||
argCh:clear()
|
||||
resCh:clear()
|
||||
argCh:push(payload == nil and false or payload)
|
||||
|
||||
local okNew, thread = pcall(love.thread.newThread, "src/mods/job_worker.lua")
|
||||
if not okNew or not thread then return nil, "could not start a job thread" end
|
||||
local Json = require("src.link.Json")
|
||||
local permissions = select(2, pcall(Json.encode,
|
||||
loader.mods and loader.mods[modId]
|
||||
and loader.mods[modId].manifest.permissionSet or {})) or "{}"
|
||||
local started = pcall(thread.start, thread, modId, safe, argName, resultName,
|
||||
permissions)
|
||||
if not started then return nil, "could not start a job thread" end
|
||||
|
||||
liveGlobal = liveGlobal + 1
|
||||
local handle = {}
|
||||
b[handle] = { thread = thread, resultCh = resCh, status = "pending",
|
||||
deadline = love.timer.getTime() + seconds, seconds = seconds }
|
||||
return handle
|
||||
end
|
||||
|
||||
local function settle(job, status, value, err)
|
||||
if job.status == "pending" then liveGlobal = math.max(0, liveGlobal - 1) end
|
||||
job.status, job.value, job.err = status, value, err
|
||||
end
|
||||
|
||||
function Job.poll(loader, modId, handle)
|
||||
local job = bucket(loader, modId)[handle]
|
||||
if not job then return { status = "error", err = "unknown job" } end
|
||||
if job.status == "pending" then
|
||||
local msg = job.resultCh:pop()
|
||||
if msg then
|
||||
if msg.ok then settle(job, "ok", msg.result)
|
||||
else settle(job, "error", nil, msg.err) end
|
||||
else
|
||||
-- A worker that died before pushing anything (an error outside its own
|
||||
-- pcall) would otherwise leave the mod polling forever.
|
||||
local threadErr = job.thread.getError and job.thread:getError()
|
||||
if threadErr then
|
||||
settle(job, "error", nil, tostring(threadErr))
|
||||
elseif love.timer.getTime() > job.deadline then
|
||||
-- The budget bounds how long the MOD waits, not how long the work
|
||||
-- runs: there is no way to stop a LÖVE thread, and every in-worker
|
||||
-- attempt made things worse (see job_worker.lua). A job that
|
||||
-- overruns is reported here and its result dropped if it ever lands.
|
||||
settle(job, "error", nil, ("job exceeded its %gs budget")
|
||||
:format(job.seconds))
|
||||
end
|
||||
end
|
||||
end
|
||||
if job.status == "ok" then
|
||||
-- A copy, so a mod cannot edit what a later poll returns.
|
||||
return { status = "ok", result = (plain(job.value)) }
|
||||
end
|
||||
return { status = job.status, err = job.err }
|
||||
end
|
||||
|
||||
function Job.release(loader, modId, handle)
|
||||
local b = bucket(loader, modId)
|
||||
local job = b[handle]
|
||||
if not job then return false end
|
||||
if job.status == "pending" then liveGlobal = math.max(0, liveGlobal - 1) end
|
||||
b[handle] = nil
|
||||
return true
|
||||
end
|
||||
|
||||
-- There is no way to kill a LÖVE thread, so cancelling drops the result
|
||||
-- rather than stopping the work; the worker's own time budget is what bounds
|
||||
-- how long an abandoned job can run.
|
||||
function Job.cancel(loader, modId, handle)
|
||||
local job = bucket(loader, modId)[handle]
|
||||
if not job then return false end
|
||||
if job.status == "pending" then
|
||||
settle(job, "cancelled")
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
function Job.releaseAll(loader, modId)
|
||||
local b = loader.jobs and loader.jobs[modId]
|
||||
if not b then return end
|
||||
for handle, job in pairs(b) do
|
||||
if job.status == "pending" then liveGlobal = math.max(0, liveGlobal - 1) end
|
||||
b[handle] = nil
|
||||
end
|
||||
loader.jobs[modId] = nil
|
||||
end
|
||||
|
||||
return Job
|
||||
+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
|
||||
|
||||
@@ -0,0 +1,956 @@
|
||||
local Logger = require("src.core.Logger")
|
||||
local SafePath = require("src.mods.SafePath")
|
||||
|
||||
local okSave, SaveData = pcall(require, "src.core.SaveData")
|
||||
if not okSave then SaveData = nil end
|
||||
local okStorage, Storage = pcall(require, "src.mods.Storage")
|
||||
if not okStorage then Storage = nil end
|
||||
|
||||
local LegacyCompat = {}
|
||||
|
||||
local ROOT = "mod_compat"
|
||||
local OWN = "_own"
|
||||
local MAX_SEGMENTS = 24
|
||||
|
||||
LegacyCompat.reports = {}
|
||||
|
||||
function LegacyCompat.reset()
|
||||
LegacyCompat.reports = {}
|
||||
end
|
||||
|
||||
function LegacyCompat.report(modId)
|
||||
if modId then
|
||||
local entry = LegacyCompat.reports[modId]
|
||||
return entry and entry.order or {}
|
||||
end
|
||||
local out = {}
|
||||
for id, entry in pairs(LegacyCompat.reports) do
|
||||
out[id] = entry.order
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
local function note(ctx, call, advice)
|
||||
local entry = LegacyCompat.reports[ctx.modId]
|
||||
if not entry then
|
||||
entry = { calls = {}, order = {} }
|
||||
LegacyCompat.reports[ctx.modId] = entry
|
||||
end
|
||||
local row = entry.calls[call]
|
||||
if row then
|
||||
row.count = row.count + 1
|
||||
return
|
||||
end
|
||||
row = { call = call, advice = advice, count = 1 }
|
||||
entry.calls[call] = row
|
||||
entry.order[#entry.order + 1] = row
|
||||
Logger.warn("[%s] %s was removed from the mod sandbox; %s", ctx.modId, call,
|
||||
advice)
|
||||
end
|
||||
|
||||
local function refuse(ctx, call, advice)
|
||||
note(ctx, call, advice)
|
||||
return nil, ("%s is not available to mods; %s"):format(call, advice)
|
||||
end
|
||||
|
||||
-- ------- path routing
|
||||
|
||||
local function normalize(path)
|
||||
if type(path) ~= "string" or path == "" then return nil end
|
||||
path = path:gsub("\\", "/")
|
||||
while path:find("//", 1, true) do path = (path:gsub("//", "/")) end
|
||||
if path ~= "/" then path = (path:gsub("/$", "")) end
|
||||
return path
|
||||
end
|
||||
|
||||
local function flatten(path)
|
||||
local parts = {}
|
||||
for segment in path:gmatch("[^/]+") do
|
||||
if segment ~= "." and segment ~= ".." then
|
||||
parts[#parts + 1] = (segment:gsub("[^%w_%.%-]", "_"))
|
||||
end
|
||||
end
|
||||
if #parts == 0 then return nil end
|
||||
while #parts > MAX_SEGMENTS do table.remove(parts, 1) end
|
||||
return table.concat(parts, "/")
|
||||
end
|
||||
|
||||
local function storageKey(key)
|
||||
local parts = {}
|
||||
for segment in key:gmatch("[^/]+") do parts[#parts + 1] = segment end
|
||||
if #parts == 0 then return nil end
|
||||
parts[#parts] = (parts[#parts]:gsub("%.[^%.]*$", ""))
|
||||
for i = 1, #parts do
|
||||
local cleaned = (parts[i]:gsub("[^%w_%-]", "_"))
|
||||
if cleaned == "" then return nil end
|
||||
parts[i] = cleaned
|
||||
end
|
||||
return table.concat(parts, "/")
|
||||
end
|
||||
|
||||
local function ownRelative(ctx, path)
|
||||
if not ctx.modPath then return nil end
|
||||
if path == ctx.modPath then return "" end
|
||||
if path:sub(1, #ctx.modPath + 1) == ctx.modPath .. "/" then
|
||||
return path:sub(#ctx.modPath + 2)
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function ownFull(ctx, rel)
|
||||
if rel == "" then return ctx.modPath end
|
||||
local safe = SafePath.safe(rel)
|
||||
if not safe then return nil end
|
||||
return ctx.modPath .. "/" .. safe
|
||||
end
|
||||
|
||||
local function ownExists(ctx, rel)
|
||||
local full = ownFull(ctx, rel)
|
||||
local fs = ctx.fs
|
||||
if not (full and fs and fs.getInfo) then return nil end
|
||||
return fs.getInfo(full)
|
||||
end
|
||||
|
||||
local function classify(ctx, path)
|
||||
path = normalize(path)
|
||||
if not path then return nil end
|
||||
|
||||
local root = ctx.virtualRoot
|
||||
if path == root then return { key = "", dir = true } end
|
||||
if path:sub(1, #root + 1) == root .. "/" then
|
||||
return { key = flatten(path:sub(#root + 2)) }
|
||||
end
|
||||
|
||||
local rel = ownRelative(ctx, path)
|
||||
if not rel and path:sub(1, 1) ~= "/" and not path:match("^%a:")
|
||||
and ownExists(ctx, path) then
|
||||
rel = path
|
||||
end
|
||||
if rel then
|
||||
if rel == "" then return { key = OWN, rel = "", dir = true } end
|
||||
local flat = flatten(rel)
|
||||
return { key = flat and (OWN .. "/" .. flat) or nil, rel = rel }
|
||||
end
|
||||
|
||||
return { key = flatten(path) }
|
||||
end
|
||||
|
||||
-- ------- the overlay
|
||||
|
||||
local function persistFs(ctx)
|
||||
if SaveData and SaveData.persistenceFs then
|
||||
return SaveData.persistenceFs(ctx.fs)
|
||||
end
|
||||
return ctx.fs
|
||||
end
|
||||
|
||||
local function overlayPath(ctx, key)
|
||||
if key == nil or key == "" then return ROOT .. "/" .. ctx.modId end
|
||||
return ROOT .. "/" .. ctx.modId .. "/" .. key
|
||||
end
|
||||
|
||||
local function ensureParent(fs, path)
|
||||
if not fs.createDirectory then return end
|
||||
local dir = path:match("^(.*)/[^/]+$")
|
||||
if not dir then return end
|
||||
local built = nil
|
||||
for segment in dir:gmatch("[^/]+") do
|
||||
built = built and (built .. "/" .. segment) or segment
|
||||
fs.createDirectory(built)
|
||||
end
|
||||
end
|
||||
|
||||
local function overlayRead(ctx, key)
|
||||
local fs = persistFs(ctx)
|
||||
if not (fs and fs.read and fs.getInfo) then return nil end
|
||||
local path = overlayPath(ctx, key)
|
||||
local info = fs.getInfo(path)
|
||||
if not info or info.type == "directory" then return nil end
|
||||
local body = fs.read(path)
|
||||
if type(body) ~= "string" then return nil end
|
||||
return body
|
||||
end
|
||||
|
||||
local function overlayWrite(ctx, key, data)
|
||||
local fs = persistFs(ctx)
|
||||
if not (fs and fs.write) then
|
||||
return false, "no writable filesystem is available"
|
||||
end
|
||||
local path = overlayPath(ctx, key)
|
||||
ensureParent(fs, path)
|
||||
local ok, err = fs.write(path, data)
|
||||
if ok == false then return false, err or "write failed" end
|
||||
return true
|
||||
end
|
||||
|
||||
local function overlayRemove(ctx, key)
|
||||
local fs = persistFs(ctx)
|
||||
if not (fs and fs.remove and fs.getInfo) then return false end
|
||||
local path = overlayPath(ctx, key)
|
||||
if not fs.getInfo(path) then return false end
|
||||
fs.remove(path)
|
||||
return true
|
||||
end
|
||||
|
||||
local function overlayInfo(ctx, key)
|
||||
local fs = persistFs(ctx)
|
||||
if not (fs and fs.getInfo) then return nil end
|
||||
return fs.getInfo(overlayPath(ctx, key))
|
||||
end
|
||||
|
||||
local function storageRead(ctx, key)
|
||||
if not (Storage and key and key ~= "") then return nil end
|
||||
if key:sub(1, #OWN + 1) == OWN .. "/" then return nil end
|
||||
local game = ctx.game and ctx.game()
|
||||
if not game then return nil end
|
||||
local sk = storageKey(key)
|
||||
if not sk then return nil end
|
||||
ctx.storage = ctx.storage or Storage.new(ctx.modId, ctx.fs)
|
||||
local ok, bytes = pcall(ctx.storage.readBytes, ctx.storage, game, sk)
|
||||
if ok and type(bytes) == "string" then return bytes end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function readPath(ctx, path)
|
||||
local at = classify(ctx, path)
|
||||
if not at then return nil, "invalid path" end
|
||||
local body = at.key and overlayRead(ctx, at.key)
|
||||
if body then return body end
|
||||
if at.rel then
|
||||
local full = ownFull(ctx, at.rel)
|
||||
local fs = ctx.fs
|
||||
if full and fs and fs.read then
|
||||
local packaged = fs.read(full)
|
||||
if type(packaged) == "string" then return packaged end
|
||||
end
|
||||
end
|
||||
body = at.key and storageRead(ctx, at.key)
|
||||
if body then return body end
|
||||
return nil, "could not open " .. tostring(path)
|
||||
end
|
||||
|
||||
local function writePath(ctx, path, data)
|
||||
local at = classify(ctx, path)
|
||||
if not (at and at.key) then return false, "invalid path" end
|
||||
return overlayWrite(ctx, at.key, data)
|
||||
end
|
||||
|
||||
local function infoPath(ctx, path)
|
||||
local at = classify(ctx, path)
|
||||
if not at then return nil end
|
||||
if at.key then
|
||||
local info = overlayInfo(ctx, at.key)
|
||||
if info then
|
||||
return { type = info.type, size = info.size, modtime = info.modtime }
|
||||
end
|
||||
end
|
||||
if at.rel then
|
||||
local info = ownExists(ctx, at.rel)
|
||||
if info then
|
||||
return { type = info.type, size = info.size, modtime = info.modtime }
|
||||
end
|
||||
end
|
||||
local stored = at.key and storageRead(ctx, at.key)
|
||||
if stored then return { type = "file", size = #stored } end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function listPath(ctx, path)
|
||||
local at = classify(ctx, path)
|
||||
local seen, out = {}, {}
|
||||
local function add(name)
|
||||
if name and name ~= "" and not seen[name] then
|
||||
seen[name] = true
|
||||
out[#out + 1] = name
|
||||
end
|
||||
end
|
||||
if at and at.rel then
|
||||
local full = ownFull(ctx, at.rel)
|
||||
local fs = ctx.fs
|
||||
if full and fs and fs.getDirectoryItems then
|
||||
for _, name in ipairs(fs.getDirectoryItems(full) or {}) do add(name) end
|
||||
end
|
||||
end
|
||||
if at and at.key then
|
||||
local fs = persistFs(ctx)
|
||||
if fs and fs.getDirectoryItems then
|
||||
for _, name in ipairs(fs.getDirectoryItems(overlayPath(ctx, at.key)) or {}) do
|
||||
add(name)
|
||||
end
|
||||
end
|
||||
end
|
||||
table.sort(out)
|
||||
return out
|
||||
end
|
||||
|
||||
-- ------- buffered file handles
|
||||
|
||||
local function openBuffer(ctx, path, mode)
|
||||
mode = tostring(mode or "r"):gsub("b", "")
|
||||
local writable = mode:find("[wa+]") ~= nil
|
||||
local body
|
||||
if mode:sub(1, 1) == "w" then
|
||||
body = ""
|
||||
else
|
||||
body = readPath(ctx, path)
|
||||
if not body then
|
||||
if not writable then return nil, "could not open " .. tostring(path) end
|
||||
body = ""
|
||||
end
|
||||
end
|
||||
local state = {
|
||||
ctx = ctx, path = path, buf = body, pos = 1,
|
||||
writable = writable, closed = false, dirty = false,
|
||||
}
|
||||
if mode:sub(1, 1) == "a" then state.pos = #body + 1 end
|
||||
return state
|
||||
end
|
||||
|
||||
local function bufferFlush(state)
|
||||
if not (state.writable and state.dirty) then return true end
|
||||
local ok, err = writePath(state.ctx, state.path, state.buf)
|
||||
if ok then state.dirty = false end
|
||||
return ok, err
|
||||
end
|
||||
|
||||
local function bufferWrite(state, text)
|
||||
if not state.writable then return false, "file is not open for writing" end
|
||||
local head = state.buf:sub(1, state.pos - 1)
|
||||
if #head < state.pos - 1 then head = head .. string.rep("\0", state.pos - 1 - #head) end
|
||||
local tail = state.buf:sub(state.pos + #text)
|
||||
state.buf = head .. text .. tail
|
||||
state.pos = state.pos + #text
|
||||
state.dirty = true
|
||||
return true
|
||||
end
|
||||
|
||||
local function bufferRead(state, fmt)
|
||||
if fmt == nil then fmt = "*l" end
|
||||
if type(fmt) == "number" then
|
||||
if fmt == 0 then return state.pos <= #state.buf and "" or nil end
|
||||
if state.pos > #state.buf then return nil end
|
||||
local chunk = state.buf:sub(state.pos, state.pos + fmt - 1)
|
||||
state.pos = state.pos + #chunk
|
||||
return chunk
|
||||
end
|
||||
fmt = tostring(fmt):gsub("^%*", "")
|
||||
if fmt == "a" then
|
||||
local rest = state.buf:sub(state.pos)
|
||||
state.pos = #state.buf + 1
|
||||
return rest
|
||||
end
|
||||
if fmt == "l" or fmt == "L" then
|
||||
if state.pos > #state.buf then return nil end
|
||||
local nl = state.buf:find("\n", state.pos, true)
|
||||
local line
|
||||
if nl then
|
||||
line = state.buf:sub(state.pos, fmt == "L" and nl or nl - 1)
|
||||
state.pos = nl + 1
|
||||
else
|
||||
line = state.buf:sub(state.pos)
|
||||
state.pos = #state.buf + 1
|
||||
end
|
||||
return line
|
||||
end
|
||||
if fmt == "n" then
|
||||
local rest = state.buf:sub(state.pos)
|
||||
local text, after = rest:match("^%s*(%-?%d+%.?%d*[eE]?[-+]?%d*)()")
|
||||
if not text then return nil end
|
||||
state.pos = state.pos + after - 1
|
||||
return tonumber(text)
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function bufferSeek(state, whence, offset)
|
||||
whence = whence or "cur"
|
||||
offset = offset or 0
|
||||
if whence == "set" then state.pos = offset + 1
|
||||
elseif whence == "cur" then state.pos = state.pos + offset
|
||||
elseif whence == "end" then state.pos = #state.buf + offset + 1
|
||||
else return nil, "bad seek base" end
|
||||
if state.pos < 1 then state.pos = 1 end
|
||||
return state.pos - 1
|
||||
end
|
||||
|
||||
local function bufferClose(state)
|
||||
if state.closed then return true end
|
||||
local ok, err = bufferFlush(state)
|
||||
state.closed = true
|
||||
return ok, err
|
||||
end
|
||||
|
||||
local function ioFile(ctx, path, mode)
|
||||
local state, err = openBuffer(ctx, path, mode)
|
||||
if not state then return nil, err end
|
||||
local file = {}
|
||||
function file:read(...)
|
||||
local count = select("#", ...)
|
||||
if count == 0 then return bufferRead(state, "*l") end
|
||||
local out = {}
|
||||
for i = 1, count do out[i] = bufferRead(state, (select(i, ...))) end
|
||||
return unpack(out, 1, count)
|
||||
end
|
||||
function file:write(...)
|
||||
for i = 1, select("#", ...) do
|
||||
local value = select(i, ...)
|
||||
local ok, writeErr = bufferWrite(state, tostring(value))
|
||||
if not ok then return nil, writeErr end
|
||||
end
|
||||
return file
|
||||
end
|
||||
function file:lines(fmt)
|
||||
return function() return bufferRead(state, fmt or "*l") end
|
||||
end
|
||||
function file:seek(whence, offset) return bufferSeek(state, whence, offset) end
|
||||
function file:flush() bufferFlush(state) return file end
|
||||
function file:close() return bufferClose(state) end
|
||||
function file:setvbuf() return true end
|
||||
return file
|
||||
end
|
||||
|
||||
local function loveFile(ctx, path, mode)
|
||||
local state = nil
|
||||
local file = {}
|
||||
function file:open(openMode)
|
||||
local opened, err = openBuffer(ctx, path, openMode or mode or "r")
|
||||
if not opened then return false, err end
|
||||
state = opened
|
||||
return true
|
||||
end
|
||||
function file:isOpen() return state ~= nil and not state.closed end
|
||||
function file:read(bytes)
|
||||
if not state and not file:open("r") then return nil, 0 end
|
||||
local body = bytes and bufferRead(state, bytes) or bufferRead(state, "*a")
|
||||
if not body then return nil, 0 end
|
||||
return body, #body
|
||||
end
|
||||
function file:write(data, size)
|
||||
if not state and not file:open(mode or "w") then return false end
|
||||
data = tostring(data)
|
||||
if size then data = data:sub(1, size) end
|
||||
local ok, err = bufferWrite(state, data)
|
||||
if not ok then return false, err end
|
||||
bufferFlush(state)
|
||||
return true
|
||||
end
|
||||
function file:lines()
|
||||
if not state then file:open("r") end
|
||||
return function() return state and bufferRead(state, "*l") or nil end
|
||||
end
|
||||
function file:seek(offset)
|
||||
if not state then return false end
|
||||
bufferSeek(state, "set", offset or 0)
|
||||
return true
|
||||
end
|
||||
function file:tell() return state and (state.pos - 1) or 0 end
|
||||
function file:getSize()
|
||||
if state then return #state.buf end
|
||||
local body = readPath(ctx, path)
|
||||
return body and #body or 0
|
||||
end
|
||||
function file:flush() if state then bufferFlush(state) end return true end
|
||||
function file:close()
|
||||
if state then bufferClose(state) end
|
||||
state = nil
|
||||
return true
|
||||
end
|
||||
function file:getFilename() return path end
|
||||
function file:getMode() return mode or "c" end
|
||||
function file:setBuffer() return true end
|
||||
if mode and mode ~= "c" then file:open(mode) end
|
||||
return file
|
||||
end
|
||||
|
||||
-- ------- the love.filesystem stand-in
|
||||
|
||||
local function realFilesystem()
|
||||
return _G.love and _G.love.filesystem or nil
|
||||
end
|
||||
|
||||
local function filesystemShim(ctx)
|
||||
local fsShim = {}
|
||||
|
||||
local function readAdvice() return "reads now come from mod:read and mod.storage" end
|
||||
|
||||
function fsShim.read(a, b, c)
|
||||
local path, size = a, b
|
||||
if (a == "string" or a == "data") and type(b) == "string" then
|
||||
path, size = b, c
|
||||
end
|
||||
note(ctx, "love.filesystem.read", readAdvice())
|
||||
local body, err = readPath(ctx, path)
|
||||
if not body then return nil, err end
|
||||
if size and size >= 0 then body = body:sub(1, size) end
|
||||
return body, #body
|
||||
end
|
||||
|
||||
function fsShim.write(path, data, size)
|
||||
note(ctx, "love.filesystem.write",
|
||||
"writes are rerouted to this mod's private compat storage; migrate to mod.storage")
|
||||
data = tostring(data)
|
||||
if size then data = data:sub(1, size) end
|
||||
local ok, err = writePath(ctx, path, data)
|
||||
if not ok then return false, err end
|
||||
return true
|
||||
end
|
||||
|
||||
function fsShim.append(path, data, size)
|
||||
note(ctx, "love.filesystem.append",
|
||||
"writes are rerouted to this mod's private compat storage; migrate to mod.storage")
|
||||
data = tostring(data)
|
||||
if size then data = data:sub(1, size) end
|
||||
local existing = readPath(ctx, path) or ""
|
||||
local ok, err = writePath(ctx, path, existing .. data)
|
||||
if not ok then return false, err end
|
||||
return true
|
||||
end
|
||||
|
||||
function fsShim.lines(path)
|
||||
note(ctx, "love.filesystem.lines", readAdvice())
|
||||
local body = readPath(ctx, path)
|
||||
if not body then error("could not open " .. tostring(path), 2) end
|
||||
local pos = 1
|
||||
return function()
|
||||
if pos > #body then return nil end
|
||||
local nl = body:find("\n", pos, true)
|
||||
local line
|
||||
if nl then
|
||||
line = body:sub(pos, nl - 1)
|
||||
pos = nl + 1
|
||||
else
|
||||
line = body:sub(pos)
|
||||
pos = #body + 1
|
||||
end
|
||||
return line
|
||||
end
|
||||
end
|
||||
|
||||
function fsShim.load(path)
|
||||
note(ctx, "love.filesystem.load",
|
||||
"the chunk is compiled into this mod's sandbox; prefer require or mod:read plus load")
|
||||
local body = readPath(ctx, path)
|
||||
if not body then return nil, "could not open " .. tostring(path) end
|
||||
if not ctx.compile then return nil, "no sandbox is bound yet" end
|
||||
return ctx.compile(body, "@" .. tostring(path))
|
||||
end
|
||||
|
||||
function fsShim.getInfo(path, a, b)
|
||||
local info = infoPath(ctx, path)
|
||||
local filter = type(a) == "string" and a or nil
|
||||
local into = type(a) == "table" and a or (type(b) == "table" and b or nil)
|
||||
if not info then return nil end
|
||||
if filter and info.type ~= filter then return nil end
|
||||
if into then
|
||||
into.type, into.size, into.modtime = info.type, info.size, info.modtime
|
||||
return into
|
||||
end
|
||||
return info
|
||||
end
|
||||
|
||||
function fsShim.getDirectoryItems(path)
|
||||
note(ctx, "love.filesystem.getDirectoryItems", "use mod.assets:list")
|
||||
return listPath(ctx, path)
|
||||
end
|
||||
|
||||
function fsShim.createDirectory(path)
|
||||
local at = classify(ctx, path)
|
||||
if not (at and at.key) then return false end
|
||||
local fs = persistFs(ctx)
|
||||
if not (fs and fs.createDirectory) then return false end
|
||||
ensureParent(fs, overlayPath(ctx, at.key) .. "/.")
|
||||
fs.createDirectory(overlayPath(ctx, at.key))
|
||||
return true
|
||||
end
|
||||
|
||||
function fsShim.remove(path)
|
||||
local at = classify(ctx, path)
|
||||
if not (at and at.key) then return false end
|
||||
return overlayRemove(ctx, at.key)
|
||||
end
|
||||
|
||||
function fsShim.exists(path) return infoPath(ctx, path) ~= nil end
|
||||
|
||||
function fsShim.isFile(path)
|
||||
local info = infoPath(ctx, path)
|
||||
return info ~= nil and info.type == "file"
|
||||
end
|
||||
|
||||
function fsShim.isDirectory(path)
|
||||
local info = infoPath(ctx, path)
|
||||
return info ~= nil and info.type == "directory"
|
||||
end
|
||||
|
||||
function fsShim.getSize(path)
|
||||
local info = infoPath(ctx, path)
|
||||
if not info then return nil, "could not open " .. tostring(path) end
|
||||
return info.size or 0
|
||||
end
|
||||
|
||||
function fsShim.getLastModified(path)
|
||||
local info = infoPath(ctx, path)
|
||||
if not info then return nil, "could not open " .. tostring(path) end
|
||||
return info.modtime or 0
|
||||
end
|
||||
|
||||
local function virtual(call)
|
||||
note(ctx, call, "paths are virtual now; everything under the returned root "
|
||||
.. "lands in this mod's private compat storage")
|
||||
return ctx.virtualRoot
|
||||
end
|
||||
|
||||
function fsShim.getSaveDirectory() return virtual("love.filesystem.getSaveDirectory") end
|
||||
function fsShim.getWorkingDirectory() return virtual("love.filesystem.getWorkingDirectory") end
|
||||
function fsShim.getUserDirectory() return virtual("love.filesystem.getUserDirectory") end
|
||||
function fsShim.getAppdataDirectory() return virtual("love.filesystem.getAppdataDirectory") end
|
||||
function fsShim.getSourceBaseDirectory() return virtual("love.filesystem.getSourceBaseDirectory") end
|
||||
function fsShim.getRealDirectory() return ctx.virtualRoot end
|
||||
|
||||
function fsShim.getIdentity() return ctx.modId end
|
||||
|
||||
function fsShim.setIdentity()
|
||||
note(ctx, "love.filesystem.setIdentity",
|
||||
"a mod cannot repoint the save directory; the call does nothing")
|
||||
return false
|
||||
end
|
||||
|
||||
function fsShim.getRequirePath() return "" end
|
||||
function fsShim.setRequirePath() return false end
|
||||
function fsShim.getCRequirePath() return "" end
|
||||
function fsShim.setCRequirePath() return false end
|
||||
|
||||
function fsShim.mount()
|
||||
note(ctx, "love.filesystem.mount",
|
||||
"mounting is refused; ship the files inside your mod and use mod:read")
|
||||
return false
|
||||
end
|
||||
fsShim.unmount = fsShim.mount
|
||||
|
||||
function fsShim.newFile(path, mode) return loveFile(ctx, path, mode) end
|
||||
|
||||
function fsShim.newFileData(a, b)
|
||||
local real = realFilesystem()
|
||||
if type(a) == "string" and b == nil then
|
||||
note(ctx, "love.filesystem.newFileData", readAdvice())
|
||||
local body = readPath(ctx, a)
|
||||
if not body then return nil, "could not open " .. tostring(a) end
|
||||
if real and real.newFileData then return real.newFileData(body, a) end
|
||||
return nil, "no filesystem"
|
||||
end
|
||||
if real and real.newFileData then return real.newFileData(a, b) end
|
||||
return nil, "no filesystem"
|
||||
end
|
||||
|
||||
function fsShim.isFused()
|
||||
local real = realFilesystem()
|
||||
return real and real.isFused and real.isFused() or false
|
||||
end
|
||||
|
||||
function fsShim.areSymlinksEnabled() return false end
|
||||
function fsShim.setSymlinksEnabled() return false end
|
||||
function fsShim.init() return false end
|
||||
function fsShim.setSource() return false end
|
||||
|
||||
return setmetatable(fsShim, { __index = function(_, key)
|
||||
note(ctx, "love.filesystem." .. tostring(key),
|
||||
"there is no compat stand-in for it; use mod.storage or mod:read")
|
||||
return nil
|
||||
end })
|
||||
end
|
||||
|
||||
-- ------- the rest of the removed surface
|
||||
|
||||
local function systemShim(ctx)
|
||||
local function real() return _G.love and _G.love.system or nil end
|
||||
-- tls* comes from the engine (Android JNI, or desktop gen1tls hung on
|
||||
-- love.system at boot). Forward those; keep clipboard / openURL stubbed.
|
||||
local TLS = {
|
||||
tlsOpen = true, tlsStatus = true, tlsSend = true,
|
||||
tlsReceive = true, tlsError = true, tlsClose = true,
|
||||
}
|
||||
local shim = {
|
||||
getOS = function()
|
||||
local sys = real()
|
||||
return sys and sys.getOS and sys.getOS() or "Unknown"
|
||||
end,
|
||||
getPowerInfo = function()
|
||||
local sys = real()
|
||||
if not (sys and sys.getPowerInfo) then return "unknown", nil, nil end
|
||||
return sys.getPowerInfo()
|
||||
end,
|
||||
getProcessorCount = function()
|
||||
local sys = real()
|
||||
return sys and sys.getProcessorCount and sys.getProcessorCount() or 1
|
||||
end,
|
||||
getClipboardText = function()
|
||||
note(ctx, "love.system.getClipboardText",
|
||||
"clipboard access stays sandboxed; the call returns an empty string")
|
||||
return ""
|
||||
end,
|
||||
setClipboardText = function()
|
||||
note(ctx, "love.system.setClipboardText",
|
||||
"clipboard access stays sandboxed; the call does nothing")
|
||||
return false
|
||||
end,
|
||||
openURL = function()
|
||||
note(ctx, "love.system.openURL",
|
||||
"launching a URL stays sandboxed; the call does nothing")
|
||||
return false
|
||||
end,
|
||||
vibrate = function(...)
|
||||
local sys = real()
|
||||
if sys and sys.vibrate then return sys.vibrate(...) end
|
||||
return false
|
||||
end,
|
||||
}
|
||||
return setmetatable(shim, {
|
||||
__index = function(_, key)
|
||||
if not TLS[key] then return nil end
|
||||
local sys = real()
|
||||
return sys and sys[key]
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
local function eventShim(ctx)
|
||||
local function real() return _G.love and _G.love.event or nil end
|
||||
local shim = {
|
||||
quit = function()
|
||||
note(ctx, "love.event.quit",
|
||||
"a mod cannot close the game out from under the player; the call does nothing")
|
||||
return false
|
||||
end,
|
||||
}
|
||||
shim.push = function(name, ...)
|
||||
if name == "quit" then return shim.quit() end
|
||||
local ev = real()
|
||||
if ev and ev.push then return ev.push(name, ...) end
|
||||
return false
|
||||
end
|
||||
return setmetatable(shim, { __index = function(_, key)
|
||||
local ev = real()
|
||||
return ev and ev[key] or nil
|
||||
end })
|
||||
end
|
||||
|
||||
local function ioShim(ctx)
|
||||
local stream = {
|
||||
write = function(self, ...)
|
||||
for i = 1, select("#", ...) do io.write(tostring((select(i, ...)))) end
|
||||
return self
|
||||
end,
|
||||
flush = function(self) return self end,
|
||||
close = function() return true end,
|
||||
read = function() return nil end,
|
||||
lines = function() return function() return nil end end,
|
||||
seek = function() return 0 end,
|
||||
setvbuf = function() return true end,
|
||||
}
|
||||
local out = setmetatable({}, { __index = stream })
|
||||
local shim = {
|
||||
open = function(path, mode)
|
||||
note(ctx, "io.open",
|
||||
"the handle is backed by mod:read and this mod's private compat storage")
|
||||
local file, err = ioFile(ctx, path, mode)
|
||||
if not file then return nil, err, 2 end
|
||||
return file
|
||||
end,
|
||||
lines = function(path, fmt)
|
||||
if path == nil then return function() return nil end end
|
||||
note(ctx, "io.lines",
|
||||
"the handle is backed by mod:read and this mod's private compat storage")
|
||||
local file, err = ioFile(ctx, path, "r")
|
||||
if not file then error(err, 2) end
|
||||
return file:lines(fmt)
|
||||
end,
|
||||
close = function(file) if file and file.close then return file:close() end return true end,
|
||||
type = function(file)
|
||||
if type(file) == "table" and file.read and file.close then return "file" end
|
||||
return nil
|
||||
end,
|
||||
read = function() return nil end,
|
||||
write = function(...) return out:write(...) end,
|
||||
input = function() return out end,
|
||||
output = function() return out end,
|
||||
stdout = out,
|
||||
stderr = out,
|
||||
stdin = setmetatable({}, { __index = stream }),
|
||||
popen = function()
|
||||
return refuse(ctx, "io.popen", "spawning a process is refused")
|
||||
end,
|
||||
tmpfile = function()
|
||||
return ioFile(ctx, ctx.virtualRoot .. "/io_tmpfile", "w+")
|
||||
end,
|
||||
}
|
||||
return shim
|
||||
end
|
||||
|
||||
local function osShim(ctx)
|
||||
local HOME = { HOME = true, APPDATA = true, LOCALAPPDATA = true,
|
||||
USERPROFILE = true, XDG_DATA_HOME = true,
|
||||
XDG_CONFIG_HOME = true, TMPDIR = true, TEMP = true, TMP = true }
|
||||
return {
|
||||
getenv = function(name)
|
||||
note(ctx, "os.getenv",
|
||||
"the environment is hidden; home-like names answer with this mod's virtual root")
|
||||
if type(name) == "string" and HOME[name:upper()] then
|
||||
return ctx.virtualRoot
|
||||
end
|
||||
return nil
|
||||
end,
|
||||
remove = function(path)
|
||||
note(ctx, "os.remove", "the delete lands in this mod's private compat storage")
|
||||
local at = classify(ctx, path)
|
||||
if not (at and at.key and overlayRemove(ctx, at.key)) then
|
||||
return nil, tostring(path) .. ": no such file"
|
||||
end
|
||||
return true
|
||||
end,
|
||||
rename = function(from, to)
|
||||
note(ctx, "os.rename", "the move lands in this mod's private compat storage")
|
||||
local body = readPath(ctx, from)
|
||||
if not body then return nil, tostring(from) .. ": no such file" end
|
||||
local ok, err = writePath(ctx, to, body)
|
||||
if not ok then return nil, err end
|
||||
local at = classify(ctx, from)
|
||||
if at and at.key then overlayRemove(ctx, at.key) end
|
||||
return true
|
||||
end,
|
||||
tmpname = function()
|
||||
return ctx.virtualRoot .. "/tmp/os_tmpname"
|
||||
end,
|
||||
execute = function()
|
||||
note(ctx, "os.execute", "running a command is refused")
|
||||
return false
|
||||
end,
|
||||
exit = function()
|
||||
note(ctx, "os.exit",
|
||||
"a mod cannot end the process; the call does nothing")
|
||||
return false
|
||||
end,
|
||||
setlocale = function() return "C" end,
|
||||
}
|
||||
end
|
||||
|
||||
local function packageShim(ctx)
|
||||
local shim = { path = "", cpath = "", preload = {}, loaded = {}, loaders = {} }
|
||||
return setmetatable(shim, {
|
||||
__index = function(_, key)
|
||||
note(ctx, "package." .. tostring(key),
|
||||
"the module loader is sandboxed; use require for the supported engine modules")
|
||||
return nil
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
-- ------- love callbacks
|
||||
|
||||
local CALLBACKS = {
|
||||
load = true, update = true, draw = true, quit = true, lowmemory = true,
|
||||
threaderror = true, keypressed = true, keyreleased = true,
|
||||
textinput = true, textedited = true, mousemoved = true,
|
||||
mousepressed = true, mousereleased = true, wheelmoved = true,
|
||||
mousefocus = true, touchpressed = true, touchreleased = true,
|
||||
touchmoved = true, joystickadded = true, joystickremoved = true,
|
||||
joystickpressed = true, joystickreleased = true, joystickaxis = true,
|
||||
joystickhat = true, gamepadpressed = true, gamepadreleased = true,
|
||||
gamepadaxis = true, focus = true, visible = true, resize = true,
|
||||
filedropped = true, directorydropped = true, displayrotated = true,
|
||||
audiodevicechanged = true, localechanged = true,
|
||||
}
|
||||
|
||||
local REFUSED = {
|
||||
run = "love.run is the engine's fixed-step loop; use mod.hooks and mod.events",
|
||||
errorhandler = "love.errorhandler is how a crash reaches the player; "
|
||||
.. "use mod.events",
|
||||
}
|
||||
|
||||
-- ------- assembly
|
||||
|
||||
function LegacyCompat.new(opts)
|
||||
opts = opts or {}
|
||||
local ctx = {
|
||||
modId = opts.modId or "mod",
|
||||
modPath = opts.modPath,
|
||||
fs = opts.fs,
|
||||
game = opts.game,
|
||||
compile = nil,
|
||||
}
|
||||
ctx.virtualRoot = "/pokeport/" .. ctx.modId
|
||||
|
||||
local filesystem = filesystemShim(ctx)
|
||||
local io_ = ioShim(ctx)
|
||||
local system = systemShim(ctx)
|
||||
local event = eventShim(ctx)
|
||||
|
||||
local compat = {
|
||||
ctx = ctx,
|
||||
love = { filesystem = filesystem, system = system, event = event },
|
||||
os = osShim(ctx),
|
||||
globals = {
|
||||
io = io_,
|
||||
package = packageShim(ctx),
|
||||
loadfile = function(path)
|
||||
note(ctx, "loadfile",
|
||||
"the chunk is compiled into this mod's sandbox; prefer require or mod:read")
|
||||
local body = readPath(ctx, path)
|
||||
if not body then return nil, "could not open " .. tostring(path) end
|
||||
if not ctx.compile then return nil, "no sandbox is bound yet" end
|
||||
return ctx.compile(body, "@" .. tostring(path))
|
||||
end,
|
||||
},
|
||||
modules = {
|
||||
io = io_,
|
||||
["love.filesystem"] = filesystem,
|
||||
["love.system"] = system,
|
||||
["love.event"] = event,
|
||||
},
|
||||
}
|
||||
|
||||
compat.globals.dofile = function(path)
|
||||
local chunk, err = compat.globals.loadfile(path)
|
||||
if not chunk then error(err or ("could not open " .. tostring(path)), 2) end
|
||||
return chunk()
|
||||
end
|
||||
|
||||
function compat.module(name)
|
||||
if type(name) ~= "string" then return nil end
|
||||
local substitute = compat.modules[name]
|
||||
if substitute then
|
||||
note(ctx, ('require("%s")'):format(name),
|
||||
"it now answers with the compat stand-in, not the real module")
|
||||
return substitute
|
||||
end
|
||||
if name == "os" then
|
||||
note(ctx, 'require("os")',
|
||||
"it now answers with the compat stand-in, not the real module")
|
||||
return compat.osTable
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- true when the assignment is a callback chain, which is what a mod
|
||||
-- wrapping love.mousemoved did before the sandbox; false plus a reason for
|
||||
-- the two names the engine owns, false alone for a module table.
|
||||
function compat.assign(key, value)
|
||||
if REFUSED[key] then
|
||||
note(ctx, ("love.%s assignment"):format(key), REFUSED[key])
|
||||
return false, ("[%s] %s"):format(ctx.modId, REFUSED[key])
|
||||
end
|
||||
if not CALLBACKS[key] then return false end
|
||||
if value ~= nil and type(value) ~= "function" then return false end
|
||||
note(ctx, ("love.%s assignment"):format(key),
|
||||
"the callback lands on the real love table the way it did before the "
|
||||
.. "sandbox; prefer mod.hooks and mod.events")
|
||||
return true
|
||||
end
|
||||
|
||||
function compat.bind(env, compile)
|
||||
ctx.compile = compile
|
||||
compat.osTable = env and env.os or nil
|
||||
end
|
||||
|
||||
return compat
|
||||
end
|
||||
|
||||
return LegacyCompat
|
||||
+295
-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,18 @@ local Manifest = require("src.mods.Manifest")
|
||||
local Merge = require("src.mods.Merge")
|
||||
local ModTargets = require("src.mods.ModTargets")
|
||||
local Registry = require("src.mods.Registry")
|
||||
local SafePath = require("src.mods.SafePath")
|
||||
local Sandbox = require("src.mods.Sandbox")
|
||||
local Schemas = require("src.mods.Schemas")
|
||||
local Semver = require("src.mods.Semver")
|
||||
local Events = require("src.mods.Events")
|
||||
local Gen2Compat = require("src.mods.Gen2Compat")
|
||||
local Hooks = require("src.mods.Hooks")
|
||||
local LegacyCompat = require("src.mods.LegacyCompat")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Steps = require("src.mods.Steps")
|
||||
local Net = require("src.mods.Net")
|
||||
local Job = require("src.mods.Job")
|
||||
|
||||
local Loader = {}
|
||||
Loader.__index = Loader
|
||||
@@ -72,10 +79,12 @@ local function orderedIds(mods, filter)
|
||||
return ids
|
||||
end
|
||||
|
||||
-- ------- dev-mode permissions tripwire
|
||||
-- Attribution only: the shim delegates unconditionally and blocks nothing.
|
||||
-- Installed once per process and only when the loader runs in dev mode, so a
|
||||
-- player build has zero interposition.
|
||||
-- ------- the require gate
|
||||
-- Two jobs in one interposition. The engine_internals/network scan is
|
||||
-- attribution only and stays dev-mode: it warns and delegates. The
|
||||
-- Sandbox.moduleDenial check is not -- require("io") would hand back
|
||||
-- package.loaded.io and undo the whole mod environment -- so it is installed
|
||||
-- in player builds too, for any boot that has mods on it.
|
||||
|
||||
local devShim = { installed = false, permissions = {}, warned = {}, depth = 0 }
|
||||
|
||||
@@ -135,7 +144,8 @@ end
|
||||
|
||||
local function scanRequire(name)
|
||||
local modId = Runtime.currentMod
|
||||
if not modId or type(name) ~= "string" then return end
|
||||
if type(modId) ~= "string" then modId = Runtime.modRequire end
|
||||
if type(modId) ~= "string" or type(name) ~= "string" then return end
|
||||
local granted = devShim.permissions[modId] or {}
|
||||
local function warnOnce(permission)
|
||||
local key = modId .. "|" .. permission .. "|" .. name
|
||||
@@ -188,6 +198,7 @@ function Loader:_installDevShim()
|
||||
for id, mod in pairs(self.mods) do
|
||||
devShim.permissions[id] = mod.manifest.permissionSet
|
||||
end
|
||||
devShim.dev = self.dev
|
||||
if devShim.installed then return end
|
||||
devShim.installed = true
|
||||
local delegate = require
|
||||
@@ -195,12 +206,22 @@ function Loader:_installDevShim()
|
||||
-- only the mod's own call is the mod's doing; whatever that module
|
||||
-- requires in turn is the engine wiring itself up
|
||||
if devShim.depth == 0 then
|
||||
scanRequire(name)
|
||||
-- Backstop for the deny list Sandbox.envFor's require already applies:
|
||||
-- an engine module requiring io is the engine wiring itself up, a mod
|
||||
-- doing it is the hole this closes, and any future path that runs mod
|
||||
-- code without a sandbox env still lands here.
|
||||
local owner = Runtime.currentMod or Runtime.modRequire
|
||||
if owner or callerIsMod(3) then
|
||||
local id = type(owner) == "string" and owner or nil
|
||||
local denial = Sandbox.moduleDenial(name, devShim.permissions[id])
|
||||
if denial then error(("[%s] %s"):format(id or "mod", denial), 0) end
|
||||
end
|
||||
if devShim.dev or devShim.generation ~= 1 then scanRequire(name) end
|
||||
-- The Gen 1 name a mod asked for, answered by the Gen 2 arm behind it.
|
||||
-- Engine code keeps the real module: src/render/PaletteFX.lua:776
|
||||
-- requires src.core.Game on both generations and means it.
|
||||
if devShim.generation ~= 1 and Gen2Compat.serves(name)
|
||||
and callerIsMod(3) then
|
||||
and (owner or callerIsMod(3)) then
|
||||
local adapter = Gen2Compat.resolve(name, Runtime.currentMod)
|
||||
if adapter then
|
||||
local key = "adapter|" .. name
|
||||
@@ -235,7 +256,7 @@ function Loader.new(opts)
|
||||
events = Events.new(), hooks = Hooks.new(), content = {}, assets = {},
|
||||
exports = {}, migrations = {}, order = {},
|
||||
modSave = {}, modOptions = {}, optionSchemas = {}, imageCache = {},
|
||||
modInput = {},
|
||||
modInput = {}, modEnv = {}, stepsQueues = {},
|
||||
fs = (opts and opts.fs) or (love and love.filesystem),
|
||||
dev = dev,
|
||||
-- Which generation this boot is (1 or 2). Fixed at construction: the
|
||||
@@ -270,8 +291,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 +378,13 @@ function Loader:_writeOptionSchemas()
|
||||
-- demand; using it here means older mods do not need to migrate to
|
||||
-- mod.options:define just to appear in a launcher settings screen.
|
||||
if schema == nil and mod.manifest.options_schema and self.fs.load then
|
||||
local chunk = self.fs.load(mod.path .. "/" .. mod.manifest.options_schema)
|
||||
if chunk then
|
||||
local ok, rows = pcall(chunk)
|
||||
if ok and type(rows) == "table" then schema = rows end
|
||||
end
|
||||
local ok, rows = pcall(function()
|
||||
local path = SafePath.join(mod.path, mod.manifest.options_schema,
|
||||
"options_schema")
|
||||
local chunk = Sandbox.loadFile(self.fs, path, self:_modEnv(mod))
|
||||
return chunk and chunk()
|
||||
end)
|
||||
if ok and type(rows) == "table" then schema = rows end
|
||||
end
|
||||
if schema ~= nil then
|
||||
mods[id] = schema
|
||||
@@ -547,7 +569,24 @@ function Loader:_validate()
|
||||
elseif manifest.assets_transforms
|
||||
and not self:_exists(mod.path .. "/" .. manifest.assets_transforms) then
|
||||
reason = "assets_transforms file missing: " .. manifest.assets_transforms
|
||||
elseif manifest.game_version and not devEngine() then
|
||||
end
|
||||
if not reason and #(manifest.required_imports or {}) > 0 then
|
||||
for _, import in ipairs(manifest.required_imports) do
|
||||
local path = mod.path .. "/baseroms/" .. import.file
|
||||
if not self:_exists(path) then
|
||||
reason = "required import missing: " .. import.name
|
||||
break
|
||||
end
|
||||
local valid, importErr = RequiredImports.validateStored(
|
||||
manifest, import, self.fs)
|
||||
if not valid then
|
||||
reason = "required import invalid: " .. import.name
|
||||
.. " (" .. tostring(importErr) .. ")"
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
if not reason and manifest.game_version and not devEngine() then
|
||||
local ok, err = Semver.satisfies(Version.engine, manifest.game_version)
|
||||
if not ok then
|
||||
reason = ("needs game version %s, engine is %s")
|
||||
@@ -562,42 +601,46 @@ end
|
||||
-- hard dependencies must exist, be enabled, have survived, and satisfy their
|
||||
-- range; run to a fixpoint so failures propagate to dependents transitively
|
||||
function Loader:_enforceDependencies()
|
||||
local targetVersion = self:_targetVersion()
|
||||
local generation = self.generation
|
||||
local changed = true
|
||||
while changed do
|
||||
changed = false
|
||||
for _, id in ipairs(orderedIds(self.mods, isActive)) do
|
||||
local mod = self.mods[id]
|
||||
for _, spec in ipairs(mod.manifest.dependencySpecs) do
|
||||
local dep = self.mods[spec.id]
|
||||
local reason, skip
|
||||
if not dep then
|
||||
reason = "missing dependency: " .. spec.id
|
||||
elseif not dep.enabled then
|
||||
reason = ("dependency %s is disabled"):format(spec.id)
|
||||
elseif dep.state == "wrong_generation" then
|
||||
-- the gate's skip is contagious as a SKIP, not as a failure: the
|
||||
-- dependency has no bug to report and neither does this mod, so
|
||||
-- nothing here lands on the boot error list
|
||||
skip = true
|
||||
-- carry the dependency's own reason: it names the game or the
|
||||
-- missing gen2compat, and a guess here would name the wrong one
|
||||
reason = ("depends on %s, which does not run here (%s)")
|
||||
:format(spec.id, dep.skipReason or "not made for this game")
|
||||
elseif dep.failed then
|
||||
reason = ("dependency %s failed to load"):format(spec.id)
|
||||
elseif spec.range
|
||||
and not Semver.satisfies(dep.manifest.version, spec.range) then
|
||||
reason = ("needs %s@%s, found %s")
|
||||
:format(spec.id, spec.range, dep.manifest.version)
|
||||
end
|
||||
if reason then
|
||||
if skip then
|
||||
self:_skip(mod, "wrong_generation", reason)
|
||||
else
|
||||
self:_fail(mod, "blocked_dependency", reason)
|
||||
if ModTargets.specApplies(spec, targetVersion, generation) then
|
||||
local dep = self.mods[spec.id]
|
||||
local reason, skip
|
||||
if not dep then
|
||||
reason = "missing dependency: " .. spec.id
|
||||
elseif not dep.enabled then
|
||||
reason = ("dependency %s is disabled"):format(spec.id)
|
||||
elseif dep.state == "wrong_generation" then
|
||||
-- the gate's skip is contagious as a SKIP, not as a failure: the
|
||||
-- dependency has no bug to report and neither does this mod, so
|
||||
-- nothing here lands on the boot error list
|
||||
skip = true
|
||||
-- carry the dependency's own reason: it names the game or the
|
||||
-- missing gen2compat, and a guess here would name the wrong one
|
||||
reason = ("depends on %s, which does not run here (%s)")
|
||||
:format(spec.id, dep.skipReason or "not made for this game")
|
||||
elseif dep.failed then
|
||||
reason = ("dependency %s failed to load"):format(spec.id)
|
||||
elseif spec.range
|
||||
and not Semver.satisfies(dep.manifest.version, spec.range) then
|
||||
reason = ("needs %s@%s, found %s")
|
||||
:format(spec.id, spec.range, dep.manifest.version)
|
||||
end
|
||||
if reason then
|
||||
if skip then
|
||||
self:_skip(mod, "wrong_generation", reason)
|
||||
else
|
||||
self:_fail(mod, "blocked_dependency", reason)
|
||||
end
|
||||
changed = true
|
||||
break
|
||||
end
|
||||
changed = true
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -607,6 +650,8 @@ end
|
||||
-- Tarjan SCC over the hard-dependency graph: only a cycle's own members
|
||||
-- fail, so an unrelated mod beside a cycle still loads
|
||||
function Loader:_failCycles()
|
||||
local targetVersion = self:_targetVersion()
|
||||
local generation = self.generation
|
||||
local mods = self.mods
|
||||
local counter, stack, onStack, index, low = 0, {}, {}, {}, {}
|
||||
local cycles = {}
|
||||
@@ -617,14 +662,16 @@ function Loader:_failCycles()
|
||||
onStack[id] = true
|
||||
local selfEdge = false
|
||||
for _, spec in ipairs(mods[id].manifest.dependencySpecs) do
|
||||
local dep = mods[spec.id]
|
||||
if spec.id == id then selfEdge = true end
|
||||
if dep and isActive(dep) and spec.id ~= id then
|
||||
if not index[spec.id] then
|
||||
connect(spec.id)
|
||||
if low[spec.id] < low[id] then low[id] = low[spec.id] end
|
||||
elseif onStack[spec.id] and index[spec.id] < low[id] then
|
||||
low[id] = index[spec.id]
|
||||
if ModTargets.specApplies(spec, targetVersion, generation) then
|
||||
local dep = mods[spec.id]
|
||||
if spec.id == id then selfEdge = true end
|
||||
if dep and isActive(dep) and spec.id ~= id then
|
||||
if not index[spec.id] then
|
||||
connect(spec.id)
|
||||
if low[spec.id] < low[id] then low[id] = low[spec.id] end
|
||||
elseif onStack[spec.id] and index[spec.id] < low[id] then
|
||||
low[id] = index[spec.id]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -675,6 +722,8 @@ end
|
||||
-- Kahn over the surviving graph with the ready set kept in (priority, id)
|
||||
-- order, so dependencies come first and the rest matches the v1 contract
|
||||
function Loader:_order()
|
||||
local targetVersion = self:_targetVersion()
|
||||
local generation = self.generation
|
||||
local pending, indegree, dependents = {}, {}, {}
|
||||
for _, id in ipairs(orderedIds(self.mods, isActive)) do
|
||||
pending[id], indegree[id] = true, 0
|
||||
@@ -687,9 +736,17 @@ function Loader:_order()
|
||||
dependents[depId][#dependents[depId] + 1] = id
|
||||
indegree[id] = indegree[id] + 1
|
||||
end
|
||||
for _, spec in ipairs(manifest.dependencySpecs) do edge(spec.id) end
|
||||
for _, spec in ipairs(manifest.dependencySpecs) do
|
||||
if ModTargets.specApplies(spec, targetVersion, generation) then
|
||||
edge(spec.id)
|
||||
end
|
||||
end
|
||||
-- optional dependencies order without requiring anything
|
||||
for _, spec in ipairs(manifest.optionalSpecs) do edge(spec.id) end
|
||||
for _, spec in ipairs(manifest.optionalSpecs) do
|
||||
if ModTargets.specApplies(spec, targetVersion, generation) then
|
||||
edge(spec.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
local ordered = {}
|
||||
local function nextId()
|
||||
@@ -981,6 +1038,85 @@ function Loader:_api(mod)
|
||||
return DateTime.dateTime(game, timestamp)
|
||||
end,
|
||||
},
|
||||
-- The read-only part of love.system that device UIs legitimately need.
|
||||
-- Do not expose the module: openURL and clipboard access stay sandboxed.
|
||||
device = {
|
||||
powerInfo = function()
|
||||
local getPowerInfo = love and love.system and love.system.getPowerInfo
|
||||
if not getPowerInfo then return "unknown", nil end
|
||||
local state, percent = getPowerInfo()
|
||||
return state, percent
|
||||
end,
|
||||
},
|
||||
-- The native step bridge (#1186), behind the "steps" permission the
|
||||
-- player sees in the mod manager: sync asks the platform to refresh
|
||||
-- its count, poll hands this mod its copy of what the bridge
|
||||
-- delivered. The engine owns the pending file -- a mod never names a
|
||||
-- path, it only receives { steps, from, to }. available() answers
|
||||
-- false without the permission (a probe stays quiet); the calls that
|
||||
-- would do something name the missing permission instead, the way the
|
||||
-- network gate does.
|
||||
steps = (function()
|
||||
if mod.manifest.permissionSet.steps then
|
||||
loader.stepsQueues[modId] = loader.stepsQueues[modId] or {}
|
||||
return {
|
||||
available = function() return Steps.available() end,
|
||||
sync = function() return Steps.sync() end,
|
||||
poll = function() return Steps.poll(loader, modId) end,
|
||||
}
|
||||
end
|
||||
local function refuse()
|
||||
error(('[%s] mod.steps needs the "steps" permission in '
|
||||
.. "manifest.json"):format(modId), 2)
|
||||
end
|
||||
return { available = function() return false end,
|
||||
sync = refuse, poll = refuse }
|
||||
end)(),
|
||||
-- Background HTTP, behind the "network" permission the player already
|
||||
-- sees. This is what love.thread is NOT: the worker runs engine code in
|
||||
-- an engine-owned pool, so a mod gets asynchrony without getting a Lua
|
||||
-- state the sandbox cannot reach. get() hands back an opaque handle;
|
||||
-- poll() is non-blocking, so nothing here can hang a frame.
|
||||
fetch = (function()
|
||||
if mod.manifest.permissionSet.network then
|
||||
return {
|
||||
available = function() return Net.available() end,
|
||||
get = function(_, url, opts) return Net.get(loader, modId, url, opts) end,
|
||||
poll = function(_, handle) return Net.poll(loader, modId, handle) end,
|
||||
release = function(_, handle) return Net.release(loader, modId, handle) end,
|
||||
cancel = function(_, handle) return Net.cancel(loader, modId, handle) end,
|
||||
}
|
||||
end
|
||||
local function refuse()
|
||||
error(('[%s] mod.fetch needs the "network" permission in '
|
||||
.. "manifest.json"):format(modId), 2)
|
||||
end
|
||||
return { available = function() return false end,
|
||||
get = refuse, poll = refuse, release = refuse, cancel = refuse }
|
||||
end)(),
|
||||
-- Background compute, behind the "background" permission. The worker
|
||||
-- rebuilds this mod's sandbox before loading the script, so a job is the
|
||||
-- one thing love.thread is not: off the main thread without a Lua state
|
||||
-- that escapes the sandbox. Plain data in, plain data out.
|
||||
job = (function()
|
||||
if mod.manifest.permissionSet.background then
|
||||
return {
|
||||
available = function() return Job.available() end,
|
||||
run = function(_, script, arg, opts)
|
||||
return Job.run(loader, modId, mod.path, script, arg, opts)
|
||||
end,
|
||||
poll = function(_, handle) return Job.poll(loader, modId, handle) end,
|
||||
release = function(_, handle) return Job.release(loader, modId, handle) end,
|
||||
cancel = function(_, handle) return Job.cancel(loader, modId, handle) end,
|
||||
}
|
||||
end
|
||||
local function refuse()
|
||||
error(('[%s] mod.job needs the "background" permission in '
|
||||
.. "manifest.json"):format(modId), 2)
|
||||
end
|
||||
return { available = function() return false end,
|
||||
run = refuse, poll = refuse, release = refuse, cancel = refuse }
|
||||
end)(),
|
||||
-- namespaced per mod; M11 backs these with save.modData /
|
||||
-- options.modOptions, the shape mods compile against is already final
|
||||
save = {
|
||||
@@ -999,7 +1135,8 @@ function Loader:_api(mod)
|
||||
bucket[key] = value
|
||||
end,
|
||||
},
|
||||
-- Data-only state independent of the vanilla progress checkpoint. The
|
||||
-- Data-only and opaque-byte state independent of the vanilla progress
|
||||
-- checkpoint. The
|
||||
-- engine binds version/playthrough/mod scope and portable persistence;
|
||||
-- callers never receive paths or a raw filesystem handle.
|
||||
storage = {
|
||||
@@ -1007,6 +1144,10 @@ function Loader:_api(mod)
|
||||
selected = function(_, game) return storage:selected(game) end,
|
||||
write = function(_, game, key, value) return storage:write(game, key, value) end,
|
||||
read = function(_, game, key) return storage:read(game, key) end,
|
||||
writeBytes = function(_, game, key, bytes)
|
||||
return storage:writeBytes(game, key, bytes)
|
||||
end,
|
||||
readBytes = function(_, game, key) return storage:readBytes(game, key) end,
|
||||
list = function(_, game, prefix) return storage:list(game, prefix) end,
|
||||
delete = function(_, game, key) return storage:delete(game, key) end,
|
||||
},
|
||||
@@ -1088,12 +1229,47 @@ function Loader:_api(mod)
|
||||
api.content[alias] = self:_contentApi(mod, self.content[canonical],
|
||||
("the %s registry is deprecated; use %s"):format(alias, canonical))
|
||||
end
|
||||
-- A relative path inside this mod, or the mod root when relative is
|
||||
-- omitted. Empty is the one listing case SafePath.safe rejects on
|
||||
-- purpose (it is not a file), so it is special-cased here.
|
||||
local function ownPath(relative, what)
|
||||
if relative == nil or relative == "" then return mod.path end
|
||||
return SafePath.join(mod.path, relative, what)
|
||||
end
|
||||
|
||||
-- Shallow directory listing, the sandboxed stand-in for
|
||||
-- love.filesystem.getDirectoryItems. Names only, sorted, never a host
|
||||
-- path. A missing directory is an empty list, not an error.
|
||||
local function listOwn(_, relative)
|
||||
local dir = ownPath(relative, "mod:list")
|
||||
local fs = loader.fs
|
||||
if not (fs and fs.getDirectoryItems) then return {} end
|
||||
local items = fs.getDirectoryItems(dir) or {}
|
||||
local out = {}
|
||||
for i = 1, #items do out[i] = items[i] end
|
||||
table.sort(out)
|
||||
return out
|
||||
end
|
||||
|
||||
-- love.filesystem.getInfo for a path inside this mod. type is "file" or
|
||||
-- "directory"; size is set for files. nil when the path does not exist.
|
||||
local function infoOwn(_, relative)
|
||||
local path = ownPath(relative, "mod:info")
|
||||
local fs = loader.fs
|
||||
if not (fs and fs.getInfo) then return nil end
|
||||
local info = fs.getInfo(path)
|
||||
if not info then return nil end
|
||||
return { type = info.type, size = info.size }
|
||||
end
|
||||
|
||||
-- assets keeps the v1 alias to the content accessors and adds the file
|
||||
-- helpers on top, so mod.assets.pokemon and mod.assets:image both resolve
|
||||
api.assets = setmetatable({
|
||||
path = function(_, relative) return mod.path .. "/" .. relative end,
|
||||
path = function(_, relative)
|
||||
return SafePath.join(mod.path, relative, "mod.assets:path")
|
||||
end,
|
||||
image = function(_, relative)
|
||||
local full = mod.path .. "/" .. relative
|
||||
local full = SafePath.join(mod.path, relative, "mod.assets:image")
|
||||
local cached = loader.imageCache[full]
|
||||
if cached then return cached end
|
||||
assert(love and love.graphics,
|
||||
@@ -1102,11 +1278,16 @@ function Loader:_api(mod)
|
||||
loader.imageCache[full] = image
|
||||
return image
|
||||
end,
|
||||
list = listOwn,
|
||||
info = infoOwn,
|
||||
}, { __index = api.content })
|
||||
-- the mod's own directory and nothing above it: PhysFS already refuses a
|
||||
-- climb, but loader.fs is injectable and has no such floor
|
||||
function api:read(relative)
|
||||
local path = self.path .. "/" .. relative
|
||||
return loader.fs.read(path)
|
||||
return loader.fs.read(SafePath.join(self.path, relative, "mod:read"))
|
||||
end
|
||||
api.list = listOwn
|
||||
api.info = infoOwn
|
||||
-- mod.world materializes on first touch, like the image helper above: a
|
||||
-- headless load must not drag the world stack in, and the Game the facade
|
||||
-- acts on is still being wired when the entry chunk runs
|
||||
@@ -1149,9 +1330,33 @@ function Loader:_game()
|
||||
return engineRequire("src.core.Game")
|
||||
end
|
||||
|
||||
-- The environment every chunk this mod authors runs in, built once per mod so
|
||||
-- its entry file and its options_schema share one globals table.
|
||||
function Loader:_modEnv(mod)
|
||||
local id = mod.manifest.id
|
||||
local env = self.modEnv[id]
|
||||
if not env then
|
||||
local loader = self
|
||||
local compat = LegacyCompat.new({
|
||||
modId = id, modPath = mod.path, fs = self.fs,
|
||||
game = function() return loader:_game() end,
|
||||
})
|
||||
env = Sandbox.envFor({ modId = id, permissions = mod.manifest.permissionSet,
|
||||
compat = compat })
|
||||
self.modEnv[id] = env
|
||||
end
|
||||
return env
|
||||
end
|
||||
|
||||
-- Which pre-sandbox calls each loaded mod actually took, for the manager's
|
||||
-- "needs updating" badge; nil id answers for every mod.
|
||||
function Loader:legacyReport(modId)
|
||||
return LegacyCompat.report(modId)
|
||||
end
|
||||
|
||||
function Loader:_loadMod(mod)
|
||||
local path = mod.path .. "/" .. mod.manifest.entry
|
||||
local chunk, err = self.fs.load(path)
|
||||
local path = SafePath.join(mod.path, mod.manifest.entry, "manifest entry")
|
||||
local chunk, err = Sandbox.loadFile(self.fs, path, self:_modEnv(mod))
|
||||
if not chunk then error(err or ("unable to load " .. path)) end
|
||||
local api = self:_api(mod)
|
||||
local result = chunk(api)
|
||||
@@ -1183,6 +1388,9 @@ function Loader:_rollback(modId)
|
||||
self.optionSchemas[modId] = nil
|
||||
self.migrations[modId] = nil
|
||||
self.modSave[modId] = nil
|
||||
self.stepsQueues[modId] = nil
|
||||
Net.releaseAll(self, modId)
|
||||
Job.releaseAll(self, modId)
|
||||
end
|
||||
|
||||
-- a mod that explicitly swears it stays link-compatible while writing into a
|
||||
@@ -1306,13 +1514,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 +1574,12 @@ function Loader:load(data)
|
||||
-- every touch: a mod captures the facade at file scope, before Game2 has a
|
||||
-- save or a world (src/mods/Gen2Compat.lua).
|
||||
Gen2Compat.bind(function() return self:_game() end)
|
||||
-- Dev mode wants the permissions tripwire; a Gold boot with mods on it wants
|
||||
-- the Gen 1-only require report, which is the difference between "the mod
|
||||
-- does nothing" and knowing why. A Gold boot with no mods pays nothing.
|
||||
if self.dev or (self.generation ~= 1 and next(self.mods) ~= nil) then
|
||||
-- Any boot with mods on it needs the gate, because require("io") is how a
|
||||
-- mod would walk out of Sandbox.envFor. Dev mode adds the permissions
|
||||
-- tripwire on top, and a Gold boot the Gen 1-only require report -- the
|
||||
-- difference between "the mod does nothing" and knowing why. A boot with no
|
||||
-- mods pays nothing.
|
||||
if self.dev or next(self.mods) ~= nil then
|
||||
self:_installDevShim()
|
||||
end
|
||||
for _, mod in ipairs(ordered) do
|
||||
|
||||
@@ -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
|
||||
|
||||
+181
-27
@@ -3,13 +3,16 @@
|
||||
-- that need to stat a file, this owns shape, vocabulary and range grammar.
|
||||
local Logger = require("src.core.Logger")
|
||||
local ModTargets = require("src.mods.ModTargets")
|
||||
local SafePath = require("src.mods.SafePath")
|
||||
local Semver = require("src.mods.Semver")
|
||||
local Version = require("src.core.Version")
|
||||
|
||||
local Manifest = {}
|
||||
|
||||
Manifest.PROFILES = { content = true, overhaul = true, total_conversion = true }
|
||||
Manifest.PERMISSIONS = { network = true, filesystem = true, engine_internals = true }
|
||||
Manifest.PERMISSIONS = { network = true, filesystem = true,
|
||||
engine_internals = true, steps = true,
|
||||
background = true }
|
||||
|
||||
-- link-relevant registries; a mod that writes into one of these while
|
||||
-- declaring affects_link = false gets an attributed warning from the loader
|
||||
@@ -31,26 +34,6 @@ local function violation(strict, id, message)
|
||||
Logger.warn("[%s] %s", tostring(id), message)
|
||||
end
|
||||
|
||||
-- "id" or "id@<range>"; a malformed id or range fails for every api level
|
||||
-- because there is no sane fallback reading for it
|
||||
local function parseSpecs(list, field)
|
||||
local specs = {}
|
||||
for _, entry in ipairs(list) do
|
||||
assert(type(entry) == "string" and entry ~= "",
|
||||
field .. " entries must be non-empty strings")
|
||||
local id, range = entry:match("^([%w_%-]+)@(.+)$")
|
||||
if not id then
|
||||
id = entry:match("^([%w_%-]+)$")
|
||||
assert(id, ("malformed %s entry %q"):format(field, entry))
|
||||
range = nil
|
||||
end
|
||||
local ok, err = Semver.validRange(range)
|
||||
assert(ok, ("malformed %s range in %q: %s"):format(field, entry, tostring(err)))
|
||||
specs[#specs + 1] = { id = id, range = range }
|
||||
end
|
||||
return specs
|
||||
end
|
||||
|
||||
-- Optional GitHub repo for launcher auto-update / other-versions.
|
||||
-- Accepts "owner/repo" or a github.com URL; empty/absent means no updates.
|
||||
function Manifest.parseGithub(value)
|
||||
@@ -73,6 +56,69 @@ function Manifest.parseGithub(value)
|
||||
return owner .. "/" .. repo
|
||||
end
|
||||
|
||||
-- "id", "id@<range>", "id@<range>#<github>", "id#<github>", or table entry
|
||||
local function parseSpecs(list, field, sources)
|
||||
local specs = {}
|
||||
sources = type(sources) == "table" and sources or {}
|
||||
for _, entry in ipairs(list) do
|
||||
local id, range, ghHint, gamesRaw, gameVersion
|
||||
if type(entry) == "table" then
|
||||
id = entry.id
|
||||
range = entry.range or entry.version
|
||||
ghHint = entry.github or entry.repo
|
||||
gamesRaw = entry.games or entry.game
|
||||
gameVersion = entry.game_version
|
||||
elseif type(entry) == "string" and entry ~= "" then
|
||||
local main, hashRepo = entry:match("^([^#]+)#(.*)$")
|
||||
if main then
|
||||
entry = main
|
||||
ghHint = hashRepo
|
||||
end
|
||||
id, range = entry:match("^([%w_%-]+)@(.+)$")
|
||||
if not id then
|
||||
id = entry:match("^([%w_%-]+)$")
|
||||
assert(id, ("malformed %s entry %q"):format(field, entry))
|
||||
range = nil
|
||||
end
|
||||
else
|
||||
error(field .. " entries must be non-empty strings or tables")
|
||||
end
|
||||
assert(id, ("malformed %s entry"):format(field))
|
||||
local ok, err = Semver.validRange(range)
|
||||
assert(ok, ("malformed %s range in %q: %s"):format(field, tostring(entry), tostring(err)))
|
||||
|
||||
if gameVersion then
|
||||
local okV, errV = Semver.validRange(gameVersion)
|
||||
assert(okV, ("malformed %s game_version range in %q: %s"):format(field, tostring(entry), tostring(errV)))
|
||||
end
|
||||
|
||||
local parsedGames = nil
|
||||
if gamesRaw ~= nil then
|
||||
if type(gamesRaw) == "string" then gamesRaw = { gamesRaw } end
|
||||
assert(type(gamesRaw) == "table", "dependency games must be a string or table")
|
||||
local normalized, unknown = ModTargets.normalize(gamesRaw)
|
||||
assert(#unknown == 0, ("unknown game in %s: %s"):format(field, table.concat(unknown, ", ")))
|
||||
parsedGames = normalized
|
||||
end
|
||||
|
||||
local parsedGh = nil
|
||||
local rawGh = ghHint or sources[id]
|
||||
if rawGh then
|
||||
local okGh, cleanGh = pcall(Manifest.parseGithub, rawGh)
|
||||
if okGh and cleanGh then parsedGh = cleanGh end
|
||||
end
|
||||
|
||||
specs[#specs + 1] = {
|
||||
id = id,
|
||||
range = range,
|
||||
github = parsedGh,
|
||||
games = parsedGames,
|
||||
game_version = gameVersion,
|
||||
}
|
||||
end
|
||||
return specs
|
||||
end
|
||||
|
||||
-- conflicts + incompatible (alias) merged, first-wins on duplicate ids
|
||||
local function mergeConflictLists(conflicts, incompatible)
|
||||
local seen, out = {}, {}
|
||||
@@ -87,13 +133,97 @@ local function mergeConflictLists(conflicts, incompatible)
|
||||
return out
|
||||
end
|
||||
|
||||
local scrubUtf8 -- shared by required-import labels and top-level strings
|
||||
|
||||
-- User-supplied files a mod needs beside its own source. The launcher owns
|
||||
-- the picker and the copy; the mod only reads the resulting
|
||||
-- <mod>/baseroms/<file> through its existing scoped mod:read surface. MD5 is
|
||||
-- deliberately the manifest vocabulary here: ROM preservation databases and
|
||||
-- the mods consuming these files commonly identify dumps by MD5, and the
|
||||
-- digest is an identity check rather than a security boundary.
|
||||
local function parseImports(value, field, required)
|
||||
field = field or "required_imports"
|
||||
local out, ids, files = {}, {}, {}
|
||||
for _, entry in ipairs(array(value)) do
|
||||
assert(type(entry) == "table", field .. " entries must be objects")
|
||||
local id = entry.id
|
||||
assert(type(id) == "string" and id:match("^[%w_%-]+$"),
|
||||
field .. " id must contain only letters, numbers, _ or -")
|
||||
assert(not ids[id], "duplicate " .. field .. " id: " .. id)
|
||||
ids[id] = true
|
||||
|
||||
local file = entry.file
|
||||
assert(type(file) == "string" and file ~= "",
|
||||
field .. " file is required")
|
||||
file = SafePath.require(file, field .. " file")
|
||||
assert(not file:find("/", 1, true),
|
||||
field .. " file must be a filename inside baseroms")
|
||||
assert(file:sub(1, 1) ~= ".",
|
||||
field .. " file must not use a hidden metadata filename")
|
||||
assert(not files[file], "duplicate " .. field .. " file: " .. file)
|
||||
files[file] = true
|
||||
|
||||
local hashes = entry.md5
|
||||
if type(hashes) == "string" then hashes = { hashes } end
|
||||
assert(type(hashes) == "table" and #hashes > 0,
|
||||
field .. " md5 must be a hash or non-empty array")
|
||||
local accepted, seen = {}, {}
|
||||
for _, digest in ipairs(hashes) do
|
||||
assert(type(digest) == "string" and digest:match("^[%x]+$")
|
||||
and #digest == 32, field .. " md5 values must be 32 hex characters")
|
||||
digest = digest:lower()
|
||||
if not seen[digest] then
|
||||
seen[digest] = true
|
||||
accepted[#accepted + 1] = digest
|
||||
end
|
||||
end
|
||||
|
||||
local format = entry.format or "raw"
|
||||
assert(format == "raw" or format == "n64",
|
||||
field .. " format must be raw or n64")
|
||||
local name = entry.name or id
|
||||
assert(type(name) == "string" and name ~= "",
|
||||
field .. " name must be a non-empty string")
|
||||
local description = entry.description or entry.hint
|
||||
if description ~= nil then
|
||||
assert(type(description) == "string" and description ~= "",
|
||||
field .. " description must be a non-empty string")
|
||||
end
|
||||
local size = entry.size
|
||||
local maxSize = entry.max_size
|
||||
local function validateSize(value, label)
|
||||
if value == nil then return end
|
||||
assert(type(value) == "number" and value > 0 and value % 1 == 0,
|
||||
field .. " " .. label .. " must be a positive integer")
|
||||
assert(value <= 128 * 1024 * 1024,
|
||||
field .. " " .. label .. " exceeds the 128 MiB hard limit")
|
||||
end
|
||||
validateSize(size, "size")
|
||||
validateSize(maxSize, "max_size")
|
||||
assert(not (size and maxSize) or size <= maxSize,
|
||||
field .. " size must not exceed max_size")
|
||||
out[#out + 1] = {
|
||||
id = id,
|
||||
name = scrubUtf8(name),
|
||||
description = scrubUtf8(description),
|
||||
file = file,
|
||||
md5 = accepted,
|
||||
format = format,
|
||||
size = size,
|
||||
max_size = maxSize,
|
||||
required = required ~= false,
|
||||
}
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- Drop bytes that are not valid UTF-8 (malformed sequences, overlongs,
|
||||
-- surrogates, > U+10FFFF) and a leading BOM. LÖVE's text renderer raises
|
||||
-- "Invalid UTF-8" from love.graphics.print/printf, so any manifest string a
|
||||
-- panel may draw must be scrubbed here -- the one place every mod manifest
|
||||
-- passes through -- or a single mangled description crashes the whole MODS
|
||||
-- panel instead of misrendering one card.
|
||||
local function scrubUtf8(s)
|
||||
scrubUtf8 = function(s)
|
||||
if type(s) ~= "string" then return s end
|
||||
s = s:gsub("^\239\187\191", "")
|
||||
local out, i, n = {}, 1, #s
|
||||
@@ -144,6 +274,9 @@ function Manifest.validate(raw, path)
|
||||
assert(type(raw.name) == "string" and raw.name ~= "", "manifest name is required")
|
||||
assert(type(raw.version) == "string" and raw.version ~= "", "manifest version is required")
|
||||
assert(type(raw.entry) == "string" and raw.entry ~= "", "manifest entry is required")
|
||||
-- every manifest path is joined to the mod's own directory, so none of them
|
||||
-- may climb out of it (src/mods/SafePath.lua)
|
||||
local entry = SafePath.require(raw.entry, "manifest entry")
|
||||
|
||||
-- absent means 1: full v1 compat, schema violations downgrade to warnings
|
||||
assert(raw.api == nil or tonumber(raw.api) ~= nil, "manifest api must be a number")
|
||||
@@ -229,27 +362,45 @@ function Manifest.validate(raw, path)
|
||||
local affectsLink = profile ~= "content" and not language
|
||||
if type(raw.affects_link) == "boolean" then affectsLink = raw.affects_link end
|
||||
|
||||
local function optionalFile(value, field)
|
||||
local function optionalString(value, field)
|
||||
if value == nil then return nil end
|
||||
assert(type(value) == "string" and value ~= "", field .. " must be a file path")
|
||||
return value
|
||||
end
|
||||
|
||||
local function optionalFile(value, field)
|
||||
local text = optionalString(value, field)
|
||||
return text and SafePath.require(text, field)
|
||||
end
|
||||
|
||||
local conflicts = mergeConflictLists(raw.conflicts, raw.incompatible)
|
||||
|
||||
local requiredImports = parseImports(raw.required_imports,
|
||||
"required_imports", true)
|
||||
local optionalImports = parseImports(raw.optional_imports,
|
||||
"optional_imports", false)
|
||||
local importIds, importFiles = {}, {}
|
||||
for _, list in ipairs({ requiredImports, optionalImports }) do
|
||||
for _, import in ipairs(list) do
|
||||
assert(not importIds[import.id], "duplicate import id: " .. import.id)
|
||||
assert(not importFiles[import.file], "duplicate import file: " .. import.file)
|
||||
importIds[import.id], importFiles[import.file] = true, true
|
||||
end
|
||||
end
|
||||
|
||||
return {
|
||||
id = raw.id,
|
||||
name = raw.name,
|
||||
version = raw.version,
|
||||
entry = raw.entry,
|
||||
entry = entry,
|
||||
api = api,
|
||||
priority = tonumber(raw.priority) or 0,
|
||||
dependencies = array(raw.dependencies),
|
||||
optional_dependencies = array(raw.optional_dependencies),
|
||||
conflicts = conflicts,
|
||||
incompatible = array(raw.incompatible),
|
||||
dependencySpecs = parseSpecs(array(raw.dependencies), "dependencies"),
|
||||
optionalSpecs = parseSpecs(array(raw.optional_dependencies), "optional_dependencies"),
|
||||
dependencySpecs = parseSpecs(array(raw.dependencies), "dependencies", raw.dependency_sources),
|
||||
optionalSpecs = parseSpecs(array(raw.optional_dependencies), "optional_dependencies", raw.dependency_sources),
|
||||
conflictSpecs = parseSpecs(conflicts, "conflicts"),
|
||||
category = raw.category or "OTHER",
|
||||
game_version = raw.game_version,
|
||||
@@ -265,7 +416,10 @@ function Manifest.validate(raw, path)
|
||||
permissionSet = permissionSet,
|
||||
options_schema = optionalFile(raw.options_schema, "options_schema"),
|
||||
assets_transforms = optionalFile(raw.assets_transforms, "assets_transforms"),
|
||||
force_enable_env = optionalFile(raw.force_enable_env, "force_enable_env"),
|
||||
required_imports = requiredImports,
|
||||
optional_imports = optionalImports,
|
||||
-- an env var name, not a path, so it keeps the plain string check
|
||||
force_enable_env = optionalString(raw.force_enable_env, "force_enable_env"),
|
||||
path = path,
|
||||
raw = raw,
|
||||
}
|
||||
|
||||
@@ -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,143 @@
|
||||
-- Background HTTP for sandboxed mods, behind the "network" permission.
|
||||
--
|
||||
-- The sandbox blocks love.thread because newThread boots a Lua state with a
|
||||
-- full standard library that none of the sandbox's rules reach -- one call and
|
||||
-- a mod has io back. That is correct, but it left mods with no way to do
|
||||
-- anything off the main thread at all: the only reachable transports
|
||||
-- (socket, http) block, so a mod that wanted to fetch something had to hang
|
||||
-- the game to do it.
|
||||
--
|
||||
-- This is the narrow replacement. src/net/Fetch.lua already runs a pool of
|
||||
-- engine-owned worker threads, and those workers run OUR code, not the mod's,
|
||||
-- so handing a mod a job in that pool grants no new reach. A mod submits a
|
||||
-- URL and polls for the body; it never gets a thread, a path, or a raw handle
|
||||
-- into the shared job table.
|
||||
--
|
||||
-- WHAT THIS FILE HAS TO GET RIGHT, because Fetch itself is shared with the
|
||||
-- launcher:
|
||||
-- * Handles are opaque tables owned per mod. Fetch keys jobs by integer,
|
||||
-- and the launcher's own ROM download and index fetches live in the same
|
||||
-- table; an integer handed to a mod would let it poll (or cancel) work
|
||||
-- that is not its own. A forged table simply misses the lookup.
|
||||
-- * Only http and https. The transport is curl, which also speaks file://,
|
||||
-- scp:// and ftp://; without this check mod.fetch would be a filesystem
|
||||
-- read and the sandbox would be back to square one.
|
||||
-- * A per-mod ceiling on jobs in flight, so one mod cannot fill the shared
|
||||
-- three-worker pool and starve the launcher's own fetches.
|
||||
|
||||
local Net = {}
|
||||
|
||||
-- Per mod, not global: the pool is shared with the launcher and a mod should
|
||||
-- never be able to monopolise it.
|
||||
Net.MAX_INFLIGHT = 4
|
||||
-- Clamp on the caller's timeout, so a mod cannot pin a worker indefinitely.
|
||||
Net.MAX_SECONDS = 30
|
||||
|
||||
local function fetch()
|
||||
return require("src.net.Fetch")
|
||||
end
|
||||
|
||||
-- http/https only, and a host must actually be present -- "http://" alone
|
||||
-- reaches curl as a malformed URL rather than being refused here.
|
||||
function Net.urlDenial(url)
|
||||
if type(url) ~= "string" or url == "" then return "url must be a string" end
|
||||
local scheme, rest = url:match("^(%a[%w+.-]*)://(.*)$")
|
||||
if not scheme then return "url must start with http:// or https://" end
|
||||
scheme = scheme:lower()
|
||||
if scheme ~= "http" and scheme ~= "https" then
|
||||
return ("%s:// is not allowed; mod.fetch speaks http and https only")
|
||||
:format(scheme)
|
||||
end
|
||||
if rest == "" or rest:match("^/") then return "url has no host" end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function bucket(loader, modId)
|
||||
loader.netJobs = loader.netJobs or {}
|
||||
local b = loader.netJobs[modId]
|
||||
if not b then b = {}; loader.netJobs[modId] = b end
|
||||
return b
|
||||
end
|
||||
|
||||
local function inflight(b)
|
||||
local n = 0
|
||||
for _, id in pairs(b) do
|
||||
if fetch().isPending(id) then n = n + 1 end
|
||||
end
|
||||
return n
|
||||
end
|
||||
|
||||
function Net.available()
|
||||
local ok, F = pcall(fetch)
|
||||
if not ok then return false end
|
||||
local okAvail, avail = pcall(F.available)
|
||||
return okAvail and avail and true or false
|
||||
end
|
||||
|
||||
-- Returns an opaque handle, or nil plus a reason.
|
||||
function Net.get(loader, modId, url, opts)
|
||||
local denial = Net.urlDenial(url)
|
||||
if denial then return nil, denial end
|
||||
opts = type(opts) == "table" and opts or {}
|
||||
local b = bucket(loader, modId)
|
||||
if inflight(b) >= Net.MAX_INFLIGHT then
|
||||
return nil, ("too many requests in flight (limit %d); poll and release "
|
||||
.. "the ones you have"):format(Net.MAX_INFLIGHT)
|
||||
end
|
||||
local maxSeconds = tonumber(opts.maxSeconds) or Net.MAX_SECONDS
|
||||
if maxSeconds > Net.MAX_SECONDS then maxSeconds = Net.MAX_SECONDS end
|
||||
if maxSeconds < 1 then maxSeconds = 1 end
|
||||
-- The mod is named in the agent string so a server operator can see which
|
||||
-- mod is calling them, and a mod cannot pretend to be the launcher.
|
||||
local id = fetch().get(url, {
|
||||
userAgent = "gen1recomp-mod/" .. tostring(modId),
|
||||
accept = type(opts.accept) == "string" and opts.accept or nil,
|
||||
maxSeconds = maxSeconds,
|
||||
})
|
||||
local handle = {}
|
||||
b[handle] = id
|
||||
return handle
|
||||
end
|
||||
|
||||
-- A copy of the job's state, never the engine's own table. An unknown or
|
||||
-- forged handle reads as an error rather than nil, so a mod that lost track of
|
||||
-- one cannot spin waiting on it forever.
|
||||
function Net.poll(loader, modId, handle)
|
||||
local id = bucket(loader, modId)[handle]
|
||||
if not id then return { status = "error", err = "unknown request" } end
|
||||
local st = fetch().poll(id)
|
||||
return { status = st.status, body = st.body, err = st.err,
|
||||
progress = st.progress }
|
||||
end
|
||||
|
||||
function Net.release(loader, modId, handle)
|
||||
local b = bucket(loader, modId)
|
||||
local id = b[handle]
|
||||
if not id then return false end
|
||||
fetch().release(id)
|
||||
b[handle] = nil
|
||||
return true
|
||||
end
|
||||
|
||||
function Net.cancel(loader, modId, handle)
|
||||
local id = bucket(loader, modId)[handle]
|
||||
if not id then return false end
|
||||
fetch().cancel(id)
|
||||
return true
|
||||
end
|
||||
|
||||
-- Drop everything this mod still holds. Called when a mod unloads, so a
|
||||
-- disabled mod cannot leave jobs accumulating in the shared table.
|
||||
function Net.releaseAll(loader, modId)
|
||||
local b = loader.netJobs and loader.netJobs[modId]
|
||||
if not b then return end
|
||||
local F = fetch()
|
||||
for handle, id in pairs(b) do
|
||||
pcall(F.cancel, id)
|
||||
pcall(F.release, id)
|
||||
b[handle] = nil
|
||||
end
|
||||
loader.netJobs[modId] = nil
|
||||
end
|
||||
|
||||
return Net
|
||||
@@ -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,257 @@
|
||||
-- The environment a mod's own code runs in. Every chunk a mod authors -- the
|
||||
-- entry file, an options_schema, anything it hands to load() -- runs against
|
||||
-- this table instead of _G, so the only paths it can name are the ones the
|
||||
-- engine hands it (mod:read, mod.storage, mod.assets).
|
||||
--
|
||||
-- What this is and is not: raw io/os/ffi are the only way to name a file
|
||||
-- outside the game tree at all, and they are absent here, so the reported
|
||||
-- "any mod can rewrite anything in your home directory" hole closes by
|
||||
-- construction. Inside the LÖVE tree this is defense in depth, not a security
|
||||
-- boundary: an engine module reached through require, or ImageData:encode,
|
||||
-- still writes in the save directory.
|
||||
--
|
||||
-- Lua 5.1/LuaJIT is the target, so setfenv is the mechanism; the 5.2+ arm
|
||||
-- exists because AssetTransform's sandbox needed it and getting this wrong
|
||||
-- silently hands the chunk the real globals.
|
||||
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local SafePath = require("src.mods.SafePath")
|
||||
|
||||
local Sandbox = {}
|
||||
|
||||
-- Modules that hand a mod the disk, a raw socket or a fresh Lua state no
|
||||
-- matter what this file removes from the environment. package.loaded.io is
|
||||
-- the one call that would undo every other rule here.
|
||||
local DENIED = {
|
||||
io = "the filesystem", os = "the filesystem", debug = "the debug library",
|
||||
package = "the module loader", ffi = "arbitrary C calls",
|
||||
}
|
||||
|
||||
-- Same idea one level up: love.filesystem is reachable by name, and
|
||||
-- love.thread starts a Lua state this sandbox has no say over.
|
||||
local DENIED_PREFIX = { ["love"] = true, ["ffi"] = true }
|
||||
|
||||
-- The wire, which is what the network permission governs.
|
||||
local NETWORK = { socket = true, enet = true, http = true, https = true,
|
||||
ssl = true, mime = true, ltn12 = true }
|
||||
|
||||
local function head(name)
|
||||
return (name:match("^([^%.]+)")) or name
|
||||
end
|
||||
|
||||
-- nil when the require is allowed, else the message to fail it with.
|
||||
function Sandbox.moduleDenial(name, permissionSet)
|
||||
if type(name) ~= "string" then return nil end
|
||||
local root = head(name)
|
||||
local reason = DENIED[root]
|
||||
if reason then
|
||||
return ("%s is not available to mods (it grants %s); use mod.storage, "
|
||||
.. "mod:read, mod:list and the engine API instead"):format(name, reason)
|
||||
end
|
||||
if DENIED_PREFIX[root] and name ~= root then
|
||||
return ("%s is not available to mods; use mod.storage, mod:read, mod:list "
|
||||
.. "and the engine API instead"):format(name)
|
||||
end
|
||||
if NETWORK[root] and not (permissionSet or {}).network then
|
||||
return ("%s needs the \"network\" permission in manifest.json"):format(name)
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- ------- the love facade
|
||||
|
||||
-- Dropped, not narrowed: filesystem writes anywhere in the save directory
|
||||
-- (including another mod's storage), thread opens a Lua state with a full
|
||||
-- standard library, system.openURL launches whatever it is handed, and event
|
||||
-- lets a mod quit the game out from under the player. Everything else LÖVE
|
||||
-- exposes passes through, so a new module in a future LÖVE is available
|
||||
-- without an edit here.
|
||||
-- value is the replacement to name in the error, or true when there is none
|
||||
local BLOCKED_LOVE = {
|
||||
filesystem = "mod.storage, mod:read and mod:list",
|
||||
-- newThread's state has a full standard library and none of this file's
|
||||
-- rules, so it stays blocked -- but the reason mods reached for it was
|
||||
-- background work, and mod.fetch is that without the escape.
|
||||
thread = 'mod.fetch for background HTTP (needs the "network" permission)',
|
||||
system = "mod.device:powerInfo() for battery information, mod.steps for "
|
||||
.. "the step bridge", event = true,
|
||||
}
|
||||
|
||||
-- Per-mod, because the compat overrides (src/mods/LegacyCompat.lua) are backed
|
||||
-- by that mod's own overlay and must not be shared.
|
||||
local function loveFacade(compat)
|
||||
if not _G.love then return nil end
|
||||
local overrides = compat and compat.love
|
||||
return setmetatable({}, {
|
||||
__index = function(_, key)
|
||||
local override = overrides and overrides[key]
|
||||
if override ~= nil then return override end
|
||||
local hint = BLOCKED_LOVE[key]
|
||||
if hint then
|
||||
error(("love.%s is not available to mods%s"):format(key,
|
||||
type(hint) == "string" and (", use " .. hint) or ""), 2)
|
||||
end
|
||||
return _G.love[key]
|
||||
end,
|
||||
-- a callback chain lands on the real table (compat.assign decides which
|
||||
-- names qualify); a module table never does
|
||||
__newindex = function(_, key, value)
|
||||
if compat then
|
||||
local allowed, reason = compat.assign(key, value)
|
||||
if allowed then
|
||||
_G.love[key] = value
|
||||
return
|
||||
end
|
||||
if reason then error(reason, 2) end
|
||||
end
|
||||
error(("mods cannot assign love.%s"):format(tostring(key)), 2)
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
-- ------- the environment
|
||||
|
||||
-- Absent on purpose: io, package, dofile, loadfile, getfenv, setfenv, debug,
|
||||
-- newproxy, module. os keeps only the clock -- getenv is how the reported
|
||||
-- exploit found the user's home directory.
|
||||
local SAFE_OS = { time = true, date = true, clock = true, difftime = true }
|
||||
|
||||
-- Per-mod copies, not the shared tables: a mod that assigns string.trim or
|
||||
-- replaces table.insert changes its own view and nobody else's. The functions
|
||||
-- are the same objects, so state behind them (math.randomseed's RNG) is
|
||||
-- unaffected -- only the namespace is private.
|
||||
local function copy(source)
|
||||
if type(source) ~= "table" then return source end
|
||||
local out = {}
|
||||
for key, value in pairs(source) do out[key] = value end
|
||||
return out
|
||||
end
|
||||
|
||||
local function baseGlobals()
|
||||
local safeOs = {}
|
||||
for key in pairs(SAFE_OS) do safeOs[key] = os[key] end
|
||||
return {
|
||||
assert = assert, error = error, ipairs = ipairs, next = next,
|
||||
pairs = pairs, pcall = pcall, xpcall = xpcall, select = select,
|
||||
tonumber = tonumber, tostring = tostring, type = type, unpack = unpack,
|
||||
rawequal = rawequal, rawget = rawget, rawset = rawset, rawlen = rawlen,
|
||||
setmetatable = setmetatable, getmetatable = getmetatable, print = print,
|
||||
collectgarbage = collectgarbage, _VERSION = _VERSION,
|
||||
coroutine = copy(coroutine), math = copy(math), string = copy(string),
|
||||
table = copy(table), bit = copy(bit), jit = jit, os = safeOs,
|
||||
}
|
||||
end
|
||||
|
||||
-- setfenv on 5.1/LuaJIT; on 5.2+ the env has to be handed to load itself, so
|
||||
-- a caller there compiles through Sandbox.compile instead.
|
||||
function Sandbox.bind(chunk, env)
|
||||
if setfenv then setfenv(chunk, env) end
|
||||
return chunk
|
||||
end
|
||||
|
||||
-- Bytecode is unreviewable and, on LuaJIT, a way out of any sandbox built out
|
||||
-- of environments. Mods ship source.
|
||||
local function rejectBytecode(source, what)
|
||||
if type(source) == "string" and source:sub(1, 1) == "\27" then
|
||||
return nil, (what or "chunk") .. ": mods must ship Lua source, not bytecode"
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
function Sandbox.compile(source, chunkname, env)
|
||||
local ok, err = rejectBytecode(source, chunkname)
|
||||
if not ok then return nil, err end
|
||||
if setfenv then
|
||||
local chunk, compileErr = loadstring(source, chunkname)
|
||||
if not chunk then return nil, compileErr end
|
||||
return setfenv(chunk, env)
|
||||
end
|
||||
return load(source, chunkname, "t", env)
|
||||
end
|
||||
|
||||
-- The load() a mod sees. Lua 5.1 gives a loaded chunk the GLOBAL environment
|
||||
-- rather than the caller's, so without this every sandboxed mod is one
|
||||
-- load(mod:read(...)) away from the real _G -- which is exactly how the
|
||||
-- multi-file mods in mods/ are written.
|
||||
local function sandboxedLoad(env)
|
||||
return function(chunk, chunkname)
|
||||
if type(chunk) == "function" then
|
||||
local parts = {}
|
||||
while true do
|
||||
local piece = chunk()
|
||||
if piece == nil or piece == "" then break end
|
||||
parts[#parts + 1] = piece
|
||||
end
|
||||
chunk = table.concat(parts)
|
||||
end
|
||||
if type(chunk) ~= "string" then return nil, "load expects a string or reader" end
|
||||
return Sandbox.compile(chunk, chunkname or "=(load)", env)
|
||||
end
|
||||
end
|
||||
|
||||
-- The require a mod sees: the deny list lives here rather than on a stack
|
||||
-- walk, because pcall(require, "io") puts a C frame where the walk would look.
|
||||
-- Runtime.modRequire is how the loader's gate identifies the caller for the
|
||||
-- Gen 2 facade once Runtime.currentMod has gone back to nil (a mod requiring
|
||||
-- lazily from an event handler).
|
||||
local function sandboxedRequire(modId, permissionSet, compat)
|
||||
return function(name, ...)
|
||||
-- the compat stand-in answers first, so a legacy require("io") gets the
|
||||
-- rerouted table instead of the denial below (src/mods/LegacyCompat.lua)
|
||||
local substitute = compat and compat.module(name)
|
||||
if substitute ~= nil then return substitute end
|
||||
local denial = Sandbox.moduleDenial(name, permissionSet)
|
||||
if denial then error(("[%s] %s"):format(modId or "mod", denial), 2) end
|
||||
local previous = Runtime.modRequire
|
||||
Runtime.modRequire = modId or true
|
||||
local ok, result = pcall(_G.require, name, ...)
|
||||
Runtime.modRequire = previous
|
||||
if not ok then error(result, 0) end
|
||||
return result
|
||||
end
|
||||
end
|
||||
|
||||
function Sandbox.envFor(opts)
|
||||
opts = opts or {}
|
||||
local compat = opts.compat
|
||||
local env = baseGlobals()
|
||||
env.love = loveFacade(compat)
|
||||
env.require = sandboxedRequire(opts.modId, opts.permissions, compat)
|
||||
local loader = sandboxedLoad(env)
|
||||
env.load = loader
|
||||
env.loadstring = loader
|
||||
-- a mod's globals are its own: two mods no longer share a namespace, and
|
||||
-- neither can reach the engine's
|
||||
env._G = env
|
||||
if compat then
|
||||
for key, value in pairs(compat.globals) do env[key] = value end
|
||||
for key, value in pairs(compat.os) do env.os[key] = value end
|
||||
compat.bind(env, function(source, chunkname)
|
||||
return Sandbox.compile(source, chunkname, env)
|
||||
end)
|
||||
end
|
||||
return env
|
||||
end
|
||||
|
||||
-- fs.load keeps the real filesystem's handling of the file; the environment is
|
||||
-- swapped after the fact. The 5.2+ arm has to go back to source, which is the
|
||||
-- only reason fs.read is touched here.
|
||||
function Sandbox.loadFile(fs, path, env)
|
||||
if fs.read then
|
||||
local ok, err = rejectBytecode(fs.read(path), path)
|
||||
if not ok then return nil, err end
|
||||
end
|
||||
if setfenv then
|
||||
local chunk, err = fs.load(path)
|
||||
if not chunk then return nil, err end
|
||||
return setfenv(chunk, env)
|
||||
end
|
||||
local source = fs.read and fs.read(path)
|
||||
if not source then return nil, "unable to read " .. path end
|
||||
return Sandbox.compile(source, "@" .. path, env)
|
||||
end
|
||||
|
||||
Sandbox.safePath = SafePath.safe
|
||||
Sandbox.requirePath = SafePath.require
|
||||
|
||||
return Sandbox
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
-- Worker state behind src/mods/Job.lua. One per job, not a pool: a reused
|
||||
-- state would carry one mod's globals into another mod's job.
|
||||
--
|
||||
-- This is the file that makes running mod Lua off the main thread safe. The
|
||||
-- mod's chunk is loaded into the SAME sandbox environment the main thread
|
||||
-- builds (Sandbox.envFor), so love.filesystem, io, os, debug, ffi and package
|
||||
-- are as absent here as they are there -- even though this state required
|
||||
-- love.filesystem to bootstrap itself.
|
||||
--
|
||||
-- A job is pure compute: plain data in, plain data out, no engine API, no
|
||||
-- game state, no storage. require is refused outright rather than reaching
|
||||
-- src.* -- an engine module loaded in a second state would be a second
|
||||
-- instance writing the same files as the main thread's.
|
||||
|
||||
require("love.thread")
|
||||
require("love.filesystem")
|
||||
require("love.timer")
|
||||
|
||||
local modId, scriptPath, argChannel, resultChannel, permissionsJson = ...
|
||||
|
||||
-- Fresh love threads have no "src.*" searcher (see src/net/fetch_worker.lua),
|
||||
-- so install one before Sandbox's own requires run.
|
||||
table.insert(package.loaders or package.searchers, function(name)
|
||||
local path = name:gsub("%.", "/") .. ".lua"
|
||||
if not love.filesystem.getInfo(path) then return nil end
|
||||
return love.filesystem.load(path)
|
||||
end)
|
||||
|
||||
local resCh = love.thread.getChannel(resultChannel)
|
||||
|
||||
local function fail(err)
|
||||
resCh:push({ ok = false, err = tostring(err) })
|
||||
end
|
||||
|
||||
local ok, err = pcall(function()
|
||||
local Sandbox = require("src.mods.Sandbox")
|
||||
local Json = require("src.link.Json")
|
||||
|
||||
local permissions = {}
|
||||
if type(permissionsJson) == "string" and permissionsJson ~= "" then
|
||||
local decoded = select(2, pcall(Json.decode, permissionsJson))
|
||||
if type(decoded) == "table" then permissions = decoded end
|
||||
end
|
||||
|
||||
local env = Sandbox.envFor({ modId = modId, permissions = permissions })
|
||||
-- A job cannot reach the engine. Anything it needs comes in through its
|
||||
-- argument and goes back through its return value.
|
||||
env.require = function(name)
|
||||
error(("[%s] require(%q) is not available inside a background job; a job "
|
||||
.. "takes plain data and returns plain data"):format(modId,
|
||||
tostring(name)), 2)
|
||||
end
|
||||
|
||||
local chunk, loadErr = Sandbox.loadFile(love.filesystem, scriptPath, env)
|
||||
if not chunk then error(loadErr or ("could not load " .. scriptPath), 0) end
|
||||
|
||||
local arg = love.thread.getChannel(argChannel):pop()
|
||||
|
||||
-- NO in-worker time budget, deliberately. A debug count hook was the
|
||||
-- obvious way to stop a runaway, and it does not work: LuaJIT swallows an
|
||||
-- error raised from a hook (measured: ~5000 raises a second, the loop
|
||||
-- running straight through them), and the raising itself wedged the whole
|
||||
-- process -- the main thread stopped being scheduled at all. Without the
|
||||
-- hook a runaway job simply spins on its own core, the game stays
|
||||
-- responsive, and it quits normally. Job.poll enforces maxSeconds on the
|
||||
-- main thread so the MOD is never left waiting; the work itself runs to its
|
||||
-- own end.
|
||||
local ranOk, result = pcall(chunk, arg)
|
||||
if not ranOk then error(result, 0) end
|
||||
resCh:push({ ok = true, result = result })
|
||||
end)
|
||||
|
||||
if not ok then fail(err) end
|
||||
@@ -0,0 +1,100 @@
|
||||
-- Load gen1tls from next to the exe and put its poll API on love.system --
|
||||
-- same tlsOpen / tlsSend / ... shape Android already exposes through JNI.
|
||||
--
|
||||
-- Mods can't require("ffi") under the sandbox, so they can't load the DLL
|
||||
-- themselves even when it's sitting right there. We do it here, before any
|
||||
-- mod runs. LegacyCompat's love.system shim forwards the tls* keys through
|
||||
-- (clipboard / openURL stay stubbed).
|
||||
--
|
||||
-- true = tlsOpen is ready. Already present (Android), no FFI, or no DLL:
|
||||
-- just return false and move on; plain ws:// rooms don't care.
|
||||
|
||||
local Gen1Tls = {}
|
||||
|
||||
local function exeDir()
|
||||
if love and love.filesystem and love.filesystem.getSourceBaseDirectory then
|
||||
local base = love.filesystem.getSourceBaseDirectory()
|
||||
if type(base) == "string" and base ~= "" then return base end
|
||||
end
|
||||
if type(arg) == "table" and type(arg[0]) == "string" then
|
||||
local dir = arg[0]:match("^(.*)[/\\]")
|
||||
if dir and dir ~= "" then return dir end
|
||||
end
|
||||
return "."
|
||||
end
|
||||
|
||||
local function fileReadable(path)
|
||||
local f = io.open(path, "rb")
|
||||
if not f then return false end
|
||||
f:close()
|
||||
return true
|
||||
end
|
||||
|
||||
local function libNames()
|
||||
local osName = (love and love.system and love.system.getOS and love.system.getOS()) or ""
|
||||
if osName == "Windows" then return { "gen1tls.dll" } end
|
||||
if osName == "OS X" then return { "libgen1tls.dylib", "gen1tls.dylib" } end
|
||||
if osName == "Linux" then return { "libgen1tls.so", "gen1tls.so" } end
|
||||
return { "gen1tls.dll", "libgen1tls.so", "libgen1tls.dylib" }
|
||||
end
|
||||
|
||||
function Gen1Tls.install()
|
||||
if not (love and love.system) then return false end
|
||||
if type(love.system.tlsOpen) == "function" then return true end
|
||||
|
||||
local okFfi, ffi = pcall(require, "ffi")
|
||||
if not okFfi or type(ffi) ~= "table" then return false end
|
||||
|
||||
ffi.cdef[[
|
||||
int gen1tls_open(const char *host, int port);
|
||||
int gen1tls_status(int handle);
|
||||
int gen1tls_send(int handle, const char *data, int length);
|
||||
int gen1tls_receive(int handle, char *buf, int max);
|
||||
int gen1tls_error(int handle, char *buf, int max);
|
||||
void gen1tls_close(int handle);
|
||||
]]
|
||||
|
||||
local dir = exeDir()
|
||||
local lib
|
||||
for _, name in ipairs(libNames()) do
|
||||
local path = dir .. "/" .. name
|
||||
if fileReadable(path) then
|
||||
local ok, loaded = pcall(ffi.load, path)
|
||||
if ok then lib = loaded; break end
|
||||
end
|
||||
local ok, loaded = pcall(ffi.load, name)
|
||||
if ok then lib = loaded; break end
|
||||
end
|
||||
if not lib then return false end
|
||||
|
||||
local errBuf = ffi.new("char[512]")
|
||||
local recvBuf = ffi.new("char[65536]")
|
||||
|
||||
love.system.tlsOpen = function(host, port)
|
||||
return lib.gen1tls_open(host, tonumber(port) or 0)
|
||||
end
|
||||
love.system.tlsStatus = function(handle)
|
||||
return lib.gen1tls_status(handle)
|
||||
end
|
||||
love.system.tlsSend = function(handle, data)
|
||||
data = data or ""
|
||||
return lib.gen1tls_send(handle, data, #data)
|
||||
end
|
||||
love.system.tlsReceive = function(handle, max)
|
||||
max = math.min(tonumber(max) or 8192, 65536)
|
||||
if max <= 0 then return "" end
|
||||
local n = lib.gen1tls_receive(handle, recvBuf, max)
|
||||
if n <= 0 then return "" end
|
||||
return ffi.string(recvBuf, n)
|
||||
end
|
||||
love.system.tlsError = function(handle)
|
||||
if lib.gen1tls_error(handle, errBuf, 512) == 0 then return nil end
|
||||
return ffi.string(errBuf)
|
||||
end
|
||||
love.system.tlsClose = function(handle)
|
||||
lib.gen1tls_close(handle)
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
return Gen1Tls
|
||||
@@ -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 },
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user