Compare commits

...

89 Commits

Author SHA1 Message Date
bryanthaboi 73fbaaa250 Merge pull request #1383 from bryanthaboi/dev
two absolut hogs doing hog level sh
2026-08-15 23:28:08 -04:00
bryanthaboi 9469e39926 Merge pull request #1379 from AverageConsumer/codex/mod-battle-intents
mods: add validated battle menu intents
2026-08-15 23:11:21 -04:00
bryanthaboi 179048a58e Merge pull request #1380 from AverageConsumer/codex/android-secondary-hotplug
android: rebind secondary displays after hotplug
2026-08-15 23:11:13 -04:00
bryanthaboi e1f5c2b217 Merge pull request #1382 from ShaneMcGovernIE/chore/postlog-body-limit
Raise postLog body ceiling to 512 KiB
2026-08-15 23:11:06 -04:00
bryanthaboi 1d8ac1e692 Merge pull request #1381 from ShaneMcGovernIE/fix/postlog-staging-temp-env
HostShell: stage postLog bodies via OS temp env, not tmpnam
2026-08-15 23:10:59 -04:00
Shane McGovern 39df5bdfa6 Raise postLog body ceiling to 512 KiB
A diagnostic ring (boot evidence + recent lines + status) routinely exceeds 64 KiB on a long session: a 651-line evidence ring measured ~90 KB and was rejected with "log body too large" (mod.postLog returned nil and the send was dropped).

The transport stages the body to a file and streams it via curl, so the ceiling is a budget, not a memory spike. 512 KiB is generous for real support logs while staying far under the 5 MiB the reference loghook endpoint accepts.
2026-08-16 03:31:36 +01:00
Shane McGovern 4b7a4daf2c HostShell: stage postLog bodies via OS temp env, not tmpnam
tmpnam() on the Windows CRT returns a bare, CWD-relative name (e.g. \sb4c.2), and io.open on it fails with Permission denied when the game's working directory is not writable -- a Program Files (or otherwise protected) install. postLog then dies before curl runs: the mod reports a send failure and no bytes leave the machine (confirmed on a Windows install: "could not create request body: \sb4c.2: Permission denied").

Stage the request body under the OS temp contract instead: TEMP/TMP on Windows (always set, always per-user writable), TMPDIR with a /tmp fallback on POSIX. The transport stays on plain io/os -- no love.filesystem dependency.

Tests updated to mock os.getenv and assert the staged path sits under the temp dir; 10/10 checks pass.
2026-08-16 03:29:33 +01:00
AverageConsumer e1d233d026 fix(android): rebind secondary displays after hotplug 2026-08-16 03:07:54 +02:00
AverageConsumer c22888a7fd feat(mods): add validated battle menu intents 2026-08-16 02:56:03 +02:00
github-actions 0e4fc3c54a chore(ios): update app-repo.json [skip ci] 2026-08-15 20:31:09 -04:00
bryanthaboi 82b91e36ca Merge pull request #1377 from bryanthaboi/dev 2026-08-15 20:22:42 -04:00
bryanthaboi 3588a5f3fe Merge pull request #1369 from AverageConsumer/codex/mod-option-conditional-rows 2026-08-15 20:20:26 -04:00
bryanthaboi 992dc80aa7 Merge pull request #1370 from AverageConsumer/codex/mod-field-action-busy 2026-08-15 20:20:12 -04:00
bryanthaboi 6e7073d32b Merge pull request #1372 from AverageConsumer/codex/android-async-canvas-readback 2026-08-15 20:19:58 -04:00
bryanthaboi 000e691966 Merge pull request #1374 from AverageConsumer/codex/mod-battle-snapshot 2026-08-15 20:19:45 -04:00
bryanthaboi fdbc51c5d1 Merge pull request #1375 from ShaneMcGovernIE/fix/postlog-pipe-mode 2026-08-15 20:19:29 -04:00
Shane McGovern 3e3566d1b4 Fix desktop postLog transport 2026-08-16 01:04:01 +01:00
AverageConsumer fcb5d1d348 feat(mods): expose read-only battle snapshots 2026-08-16 01:32:23 +02:00
AverageConsumer b45d783dee feat(android): add asynchronous canvas readback 2026-08-16 01:09:10 +02:00
AverageConsumer d99072b44e feat(mods): report field action availability state 2026-08-16 01:01:16 +02:00
AverageConsumer 0ac55b0f9e feat(mods): support conditional option rows 2026-08-16 00:54:52 +02:00
github-actions ab48572852 chore(ios): update app-repo.json [skip ci] 2026-08-15 17:06:08 -04:00
bryanthaboi 5d2c13ed2b Merge pull request #1366 from bryanthaboi/dev
baloney
2026-08-15 16:57:46 -04:00
bryanthaboi d87f6b8ad1 Merge pull request #1357 from anxiousintrovert/agent/fix-launcher-versioned-conflicts
Fix versioned mod conflicts in launcher
2026-08-15 16:51:52 -04:00
bryanthaboi 24cf367758 Merge pull request #1363 from ShaneMcGovernIE/feat/fetch-postlog
feat(mods): mod.postLog — one-way log reporting for mod owners
2026-08-15 16:51:33 -04:00
bryanthaboi 829d398a94 Merge pull request #1365 from bryanthaboi/dependabot/github_actions/actions/setup-dotnet-6
build(deps): bump actions/setup-dotnet from 4 to 6
2026-08-15 16:51:14 -04:00
bryanthaboi 3ee50a27c5 Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-08-15 16:50:43 -04:00
bryanthaboi 871087a16b Delete timekeepers_hut 2026-08-15 16:50:40 -04:00
dependabot[bot] 180ce6b2e7 build(deps): bump actions/setup-dotnet from 4 to 6
Bumps [actions/setup-dotnet](https://github.com/actions/setup-dotnet) from 4 to 6.
- [Release notes](https://github.com/actions/setup-dotnet/releases)
- [Commits](https://github.com/actions/setup-dotnet/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/setup-dotnet
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-15 12:32:37 -04:00
github-actions d573878a2f chore(ios): update app-repo.json [skip ci] 2026-08-15 10:55:33 -04:00
bryanthaboi a66efe207d Merge pull request #1347 from bryanthaboi/dev
bazinga performance and addl hooks
2026-08-15 10:46:39 -04:00
bryanthaboi 5198b35945 bazinga 2026-08-15 10:44:20 -04:00
github-actions 40977337b1 chore(ios): update app-repo.json [skip ci] 2026-08-15 06:31:55 -04:00
bryanthaboi 1598f34954 Merge pull request #1337 from bryanthaboi/dev
legcompat
2026-08-15 06:23:05 -04:00
bryanthaboi 43cbc554c3 legcompat 2026-08-15 06:04:04 -04:00
github-actions 3c3e2c54c5 chore(ios): update app-repo.json [skip ci] 2026-08-14 23:48:42 -04:00
bryanthaboi 00c72c441b Merge pull request #1322 from bryanthaboi/dev 2026-08-14 23:40:02 -04:00
bryanthaboi 7804ef9793 Merge pull request #1323 from 1Jamie/fix/patch-notes-ci-resilience 2026-08-14 23:37:59 -04:00
1jamie 673d8b3ad8 fix(tests): make launcher_patch_notes resilient to release note wording changes 2026-08-14 22:36:24 -05:00
bryanthaboi 62e1296ced Merge pull request #1321 from 1Jamie/fix/gold-save-bug 2026-08-14 23:20:49 -04:00
1jamie a3bbd78e7b Remove androidcrash.log from project root 2026-08-14 22:17:33 -05:00
1jamie 8dfbd1daae fix(save/gen2): auto-recover orphaned save slots and fix PartyMenu field moves
- Auto-recover save slots: Scan saves/<version>/ on boot to re-index orphaned slot files if options.lua is reset or loses its slot registry.
- Fix Gen 2 field moves: Make PartyMenu field actions (Strength, Surf, Cut, Flash) generation-aware so they execute safely in Gold.
- Fortify platform stability: Guard native controller/hidapi init against Android 14 receiver exceptions and ensure safe Windows file replacement.
- Add unit test coverage for slot auto-recovery in save_editor_gen2_tests.lua.
2026-08-14 22:16:17 -05:00
github-actions 4046b28a8c chore(ios): update app-repo.json [skip ci] 2026-08-14 21:53:50 -04:00
bryanthaboi a86d57ac44 Merge pull request #1316 from bryanthaboi/dev 2026-08-14 21:44:05 -04:00
bryanthaboi 72f126ae26 Merge pull request #1312 from AverageConsumer/feat/contextual-field-moves 2026-08-14 21:42:22 -04:00
bryanthaboi 5d7a8c9291 Merge pull request #1300 from anxiousintrovert/feature/required-mod-imports 2026-08-14 21:41:49 -04:00
anxiousintrovert c48fc578ca Address required import review feedback 2026-08-14 20:34:14 -05:00
anxiousintrovert dfc216f974 Remove required imports RFC 2026-08-14 20:34:14 -05:00
anxiousintrovert 542856c83d Add manifest-driven required mod imports 2026-08-14 20:34:14 -05:00
bryanthaboi 545a99d86d Merge pull request #1311 from 1Jamie/feat/gold-save-editor 2026-08-14 21:15:07 -04:00
AverageConsumer 2b6473ae03 feat(mods): extend contextual field actions 2026-08-15 02:01:20 +02:00
1jamie df0be1cba6 Add Gold save-editor support, rearrange the launcher, and ship a patch-notes modal.
The save editor now routes Gold vs RBY through a generation adapter so boxes, items, events, and maps write the right fields. The launcher layout is shuffled a bit, and patch notes can come from the updater, a packed file, or the iOS sidecar.
2026-08-14 18:48:06 -05:00
github-actions c9d67582ae chore(ios): update app-repo.json [skip ci] 2026-08-14 18:02:54 -04:00
bryanthaboi 1cfd91a503 Merge pull request #1309 from bryanthaboi/dev
path read
2026-08-14 17:54:26 -04:00
bryanthaboi fb738fa1ce path read 2026-08-14 17:51:55 -04:00
github-actions c0ad6d0328 chore(ios): update app-repo.json [skip ci] 2026-08-14 17:19:40 -04:00
bryanthaboi 84de8c9cb1 Merge pull request #1306 from bryanthaboi/dev
for greg
2026-08-14 17:10:29 -04:00
bryanthaboi 6307bc92f6 Merge branch 'main' into dev 2026-08-14 17:07:19 -04:00
bryanthaboi 052dd26b3e Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-08-14 17:07:08 -04:00
bryanthaboi c8f6c7241b i did it for greg 2026-08-14 17:07:06 -04:00
bryanthaboi b3928388ef Merge pull request #1304 from ShaneMcGovernIE/agent/mod-storage-opaque-bytes
Add opaque byte storage to mod API
2026-08-14 16:59:06 -04:00
bryanthaboi a38fae5a97 Merge pull request #1302 from AverageConsumer/feat/mod-field-items
feat(mods): expose contextual field items
2026-08-14 16:57:16 -04:00
Shane McGovern 9fab992d42 Add opaque byte storage to mod API 2026-08-14 21:42:45 +01:00
AverageConsumer dcc388a942 feat(mods): expose contextual field items 2026-08-14 21:39:18 +02:00
bryanthaboi 797a6bebfe Merge pull request #1253 from mleo2003/padcursor-edge-scroll
launcher: pad cursor edge-scroll for stickless handhelds
2026-08-14 15:14:45 -04:00
bryanthaboi 97a9c0f58f Merge pull request #1286 from MaxTomahawk/adaptive-trainers/battle-party-scope
feat(mod-api): add trainer battle party scope
2026-08-14 15:14:31 -04:00
MaxTomahawk 407f649e9d fix(mod-api): harden deferred trainer preparation 2026-08-14 17:57:59 +02:00
github-actions 84635cdfdf chore(ios): update app-repo.json [skip ci] 2026-08-14 11:52:58 -04:00
bryanthaboi 3de45b671c Merge pull request #1288 from bryanthaboi/dev
CLOSES #1283
2026-08-14 11:43:13 -04:00
bryanthaboi 4395792226 CLOSES #1283 2026-08-14 11:41:54 -04:00
MaxTomahawk a77210799f feat(mod-api): add trainer battle party scope 2026-08-14 17:33:17 +02:00
github-actions 6f67292b0e chore(ios): update app-repo.json [skip ci] 2026-08-14 10:53:45 -04:00
mleo2003 5192106730 launcher: pad cursor edge-scroll for stickless handhelds
The launcher's SHORT-WINDOW SCROLL engages correctly on a 480px-tall
panel, but _pageScroll has only three inputs -- mouse wheel, touch drag,
and the right analog stick -- and a device with none of them cannot
reach anything below the fold. On an RG35XXSP (muOS, 640x480)
/proc/bus/input/devices lists one gpio-keys-polled device reporting the
D-pad as a hat: no analog axes exist. There is also no scroll-to-focus
anywhere, so moving the pad cursor never touches _pageScroll.

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

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

Tested on an RG35XXSP at stock Kit.lua scale (0.9): the pager and footer
become reachable, pushing up at the top scrolls back, and parking at the
edge does nothing. Refs #1142.
2026-08-13 18:14:28 -07:00
131 changed files with 11920 additions and 2261 deletions
+1 -1
View File
@@ -262,7 +262,7 @@ jobs:
steps:
- uses: actions/checkout@v7
- name: Setup .NET 8
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@v6
with:
dotnet-version: "8.0.x"
- name: Publish gen1tls (win-x64 Native AOT)
+6
View File
@@ -79,3 +79,9 @@ mobile/ios/bundle_id.local
# 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/
+45 -15
View File
@@ -246,17 +246,37 @@ real Gold boot.
Your code runs in a sandbox (`src/mods/Sandbox.lua`), not against the
engine's globals. Every chunk you author gets it: `main.lua`, your
`options_schema`, and anything you `load()` yourself. What is absent:
`options_schema`, and anything you `load()` yourself.
| Absent | Use instead |
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 |
| --- | --- |
| `io`, and `require("io")` | `mod:read` for your own files, `mod.storage` to persist |
| `os.getenv`, `os.execute`, `os.remove`, `os.rename`, `os.exit` | nothing; `os.time`/`os.date`/`os.clock` still work |
| `package`, `dofile`, `loadfile`, `debug`, `getfenv`, `setfenv` | `require` for the supported engine modules |
| `require("ffi")`, `require("love.*")` | the `love` table you are given |
| `love.filesystem` | `mod.storage` (per-mod, per-playthrough) and `mod:read` |
| `love.thread`, `love.event` | `mod.events`, `mod.hooks` |
| `love.system` | `mod.device:powerInfo()` for battery information; `mod.steps` (with the `steps` permission) for the step bridge |
| `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.
@@ -269,19 +289,29 @@ Three consequences worth knowing before you write against it:
`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.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.
- **Paths cannot climb.** `mod:read`, `mod:list`, `mod:info`, `mod.assets:path`
and `mod.assets:image` join to your own directory, and `..`, absolute paths
and drive letters are refused. So are `entry` and `options_schema` in your
manifest. `mod:list("assets")` is the sandboxed `getDirectoryItems` for a
folder you shipped; `mod:info` tells file from directory so a walk can
recurse.
- **Ship source, not bytecode.** A precompiled entry file is refused.
`permissions` in the manifest is still a disclosure the manager shows the
player, and `network` now gates `require("socket")` and friends. There is no
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 absent globals, the fix is almost always
`mod.storage`. Open an issue if you have a case it does not cover.
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`
+8 -2
View File
@@ -178,7 +178,8 @@ M.PALLET_TOWN = {
end
end
local function enterLab()
local function enterLab(oak)
if oak then oak.stepFrames = nil end
Commands.hide_object(ctx, "PALLET_TOWN", "PALLETTOWN_OAK")
Commands.show_object(ctx, "OAKS_LAB", "OAKSLAB_OAK2")
ow.doorWarp = true
@@ -187,12 +188,17 @@ M.PALLET_TOWN = {
end
local function walkToLab(oak)
-- lockstep half runs Oak on the player's own frames per cell
-- engine/overworld/movement.asm:737 (DoScriptedNPCMovement)
local i = 0
if oak then
oak.stepFrames = ow.player.stepFramesCur or ow.player.stepFrames
end
local function tick()
i = i + 1
local playerStep = escort.playerSteps[i]
if not playerStep then
enterLab()
enterLab(oak)
return
end
if oak and escort.oakSteps[i] then
+6 -1
View File
@@ -446,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)
@@ -469,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
+1
View File
@@ -16,6 +16,7 @@ save directory as:
| nil / `"rom"` | `picked_rom.gb` (open) |
| `"mod"` | `picked_mod.zip` (open) |
| `"sav"` / `"save"` | `picked_save.sav` (open) |
| `"required_import"` | `picked_required_import.bin` (open) |
Export uses a separate API: `love.system.createFile(suggestedName)`
`GameActivity.showCreateDocument` (`ACTION_CREATE_DOCUMENT`), which copies
+11 -1
View File
@@ -240,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
@@ -486,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
@@ -763,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
+9 -4
View File
@@ -46,7 +46,11 @@ when the runtime schema is absent. The supported row types are `toggle`,
`choice`, `number`, and `text`. Their optional fields retain the meanings
established by the existing in-game option UI: choices are `[label, value]`
pairs, numeric rows may provide `min`, `max`, and `step`, and text rows may
provide `maxLen`.
provide `maxLen`. A row may also use
`visible_if = {key = "mode", equals = "compact"}` or replace `equals` with
`not_equals`. This only hides the in-game menu row; the schema and stored value
remain available, and consumers that do not implement conditions may ignore
the field.
Only mods that are enabled and successfully loaded in the current boot are
included. A disabled or failed mod must not contribute rows. If an older
@@ -69,9 +73,10 @@ it may ignore an unknown row type or optional field.
For compatibility with files produced by the original unversioned prototype,
a missing `schema_version` means version 1. Consumers must ignore documents
with a newer version rather than guessing at their shape. Producers must bump
the version whenever they change the document shape or the meaning of an
existing field. Version 1 is therefore the legacy unversioned format as well
as the explicitly versioned format shown above.
the version whenever they change the document envelope or the meaning of an
existing field. New optional row fields that older consumers can safely ignore
do not require a bump. Version 1 is therefore the legacy unversioned format as
well as the explicitly versioned format shown above.
## Migration note
+393 -11
View File
@@ -44,6 +44,25 @@ Every mod contains a root `manifest.json` defining its metadata, supported games
"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.",
@@ -67,8 +86,11 @@ Every mod contains a root `manifest.json` defining its metadata, supported games
| `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"]`). |
| `log_url` | `string` | Optional https URL for `mod.postLog` log reporting (api 2; requires the `network` permission). |
| `github` | `string` | GitHub repository (`"owner/repo"`) used for update checks and dependency download links. |
### Declaring Dependencies & Scoping
@@ -91,6 +113,54 @@ Dependencies in `dependencies` and `optional_dependencies` can be declared in se
#### 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
@@ -147,6 +217,72 @@ 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.
The optional second return is `"world is busy"` during transient input locks
or `"no overworld"` before a playable world exists.
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.
## Read-only battle snapshots
`mod.battle:snapshot()` returns `nil` outside a battle and a copied battle
record while one is active. Gen 1 (Red, Blue, and Yellow) and Gold expose the
same core fields:
`revision`, `kind`, `catchable`, `prompt`, `message`, `turn`, `player`,
`enemy`, `party`, `moves`, and `items`. Pokémon, moves, messages, and items in
the result are detached records; changing them cannot change the battle.
`revision` stays stable while the visible battle context is unchanged and
advances when it changes, so a UI can skip rebuilding an identical view.
Pokémon records contain `species`, `name`, `level`, `hp`, `maxHp`, `status`,
and `active` (plus `slot` in `party`). Move records contain `slot`, `id`,
`name`, `pp`, `maxPp`, `type`, `power`, `accuracy`, and `disabled`. Gen 1 also
reports the actual ruleset-aware `displayPower`, `hitChance` percentage, and
`effectiveness` multiplier (`10` neutral, `20` super-effective, `5`
resisted). Item rows contain `id`, `name`, `count`, `ball`, `needsTarget`, and
an optional stock `catchChance` percentage.
`prompt` describes the currently visible choice (`menu`, `moves`, `party`,
`advance`, `safari`, or `mimic`) and is `locked` when another screen or battle
phase owns input. Generation-specific features remain optional: Gen 1 includes
battle medicine, balls, catch previews, Safari balls, and Mimic choices;
Gold currently returns an empty `items` list rather than guessing at its
pocketed PACK flow. Callers should ignore unknown fields and tolerate absent
optional ones.
## Battle menu intents
`mod.battle:submit(intent)` applies a validated choice to the snapshot the mod
just read. Every intent needs a mod-owned, strictly increasing positive
integer `id` and the latest snapshot `revision`. Stale, replayed, covered, or
invalid choices return `nil` plus a reason without changing the battle.
The shared Red, Blue, Yellow, and Gold intents are:
- `{ kind = "menu", choice = "fight" }` (`party`, `item`, and `run` are the
other accepted choices)
- `{ kind = "move", slot = 1..4 }`
- `{ kind = "back" }` while the move menu is active
Menu choices and moves use the same engine methods as the native controls;
`party` and `item` open the native screens rather than exposing or duplicating
their mutable logic. Tutorial, link, Safari, forced, stale, and covered battle
states refuse these core intents. Use `mod.input` for ordinary text advance.
## Rendering pipelines
Most registries hand the engine *content*. `render_pipelines` hands it
@@ -308,6 +444,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
@@ -316,19 +471,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:
@@ -423,6 +581,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
@@ -668,3 +863,190 @@ 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.
## Log reporting
`mod.postLog(body, opts)` is the one-way exception to the rule that a mod
decides where it talks. It reports a debug/crash log to the https URL the
manifest declares in `log_url`, and it is the only API that may not be
pointed at a caller-chosen address:
```json
{
"permissions": ["network"],
"log_url": "https://logs.example.com/receive"
}
```
The URL is validated at load: it must be `https://`, and declaring it
without the `network` permission is a load violation for api 2 mods. The
destination is reviewed when the mod ships, not chosen per call, so a mod
cannot aim this at arbitrary hosts or read back anything a server replies.
```lua
-- fire and forget; poll() never blocks, same shape as mod.fetch
local job = mod:postLog("session crashed at 0x1f3a\n" .. logText)
```
`postLog(body, opts)` returns the same opaque handle as `mod.fetch:get`,
polled and released through `mod.fetch:poll` / `mod.fetch:release`. `opts`
is a closed list with one switch: `format`, either `"text"` (the default)
or `"json"`. `json` wraps the body in an envelope of `{ ts, mod, format,
body }` so a server can attribute and sort reports; any other key or value
is refused before a job is submitted. The body is capped at 64 KB, the
transfer is bounded by the same worker ceilings as `mod.fetch`, and the
response body is never returned to the mod.
## Background jobs
`mod.fetch` covers work waiting on a server. `mod.job` covers work waiting on
the CPU — generating a map, crunching a table, anything that would otherwise
stall a frame. It is behind the `background` permission in `manifest.json`.
Ship the job as its own file inside your mod:
```lua
-- mods/your_mod/jobs/crunch.lua
local arg = ...
local total = 0
for i = 1, arg.n do total = total + i end
return { total = total }
```
```lua
-- in your entry file
local job = mod.job:run("jobs/crunch.lua", { n = 1e6 })
-- later, in a hook -- poll never blocks
local r = mod.job:poll(job)
if r.status == "ok" then
use(r.result.total)
mod.job:release(job)
end
```
`run(script, arg, opts)` returns an opaque handle, or `nil` plus a reason.
`opts.maxSeconds` sets the job's time budget (default 5, clamped to 30).
`poll(handle)` returns `{ status, result, err }` with `status` one of
`"pending"`, `"ok"`, `"error"` or `"cancelled"`. `release(handle)` frees it.
`available()` is `false` on a host without threads and for mods without the
permission, so a probe is always safe.
**A job is pure compute.** This is the part to design around, not a detail:
- **Plain data in, plain data out.** Numbers, strings, booleans and tables of
them. A function, userdata, a cycle or a table key that is not a string or
number is refused at your `run` call with a reason. Nothing is shared —
your argument is snapshotted, and mutating the original afterwards does not
reach the job.
- **No engine API, no game state, no storage.** `require` is refused inside a
job, and there is no `mod` object. A job cannot read the party, write
`mod.storage`, or touch a registry. Get what it needs into the argument and
act on the result back on the main thread.
- **Your script is a file in your mod folder.** The path goes through the same
rules as `mod:read`; `..`, absolute paths and drive letters are refused.
- **Two jobs per mod, four on the machine.** Over the limit, `run` returns
`nil` and a reason until you release one.
- **The budget bounds how long YOU wait, not how long the work runs.** Past
`maxSeconds`, `poll` reports an error and the result is dropped if it ever
arrives — but the thread runs to its own end. There is no way to stop a
LÖVE thread from outside, and every attempt to stop one from inside was
worse than the disease (a debug hook does not reliably interrupt LuaJIT,
and raising from one wedged the whole process). `cancel(handle)` is the
same deal: it drops the result, it does not stop the work.
So **write jobs that terminate.** A job with an infinite loop will keep one
core busy until the game closes. It will not freeze the game — the main
thread stays responsive and quitting still works — but nothing will reclaim
that core in the meantime.
Your job script runs in the same sandbox your entry file does, so `io`, `os`,
`debug`, `ffi`, `package` and `love.filesystem` are absent there too. That is
the whole reason this exists rather than `love.thread`: a raw LÖVE thread is a
fresh Lua state with a full standard library that the sandbox cannot reach, so
handing one to a mod would undo every other rule. Here the worker builds your
sandbox first and loads your chunk into it.
## Pre-sandbox globals (compat)
A mod written before the sandbox landed does not have to be updated to
load. `io`, `package`, `dofile`, `loadfile`, `os.getenv`, `love.filesystem`,
`love.system` and `love.event` are all present again as compat stand-ins
(`src/mods/LegacyCompat.lua`), and assigning a LÖVE callback
(`love.mousemoved = fn`) installs on the real table the way it always did.
Every stand-in call logs one warning naming its replacement, and
`loader:legacyReport(modId)` returns the same list with call counts, which
is what a "needs updating" badge should read.
The stand-ins are not the old globals. Paths are classified rather than
passed through:
- A path inside your own mod directory reads the file you shipped.
- Anything else, including an absolute path, resolves into a private
per-mod overlay at `mod_compat/<your id>/` under the save directory.
Two mods naming the same path never see each other's bytes, and nothing
is written outside the game tree.
- A read misses through the overlay to your shipped file, then to
`mod.storage`, so a half-migrated mod sees both.
- A write over a path you shipped shadows it; the packaged file is never
modified, and `mod:read` still returns the packaged bytes.
- `love.filesystem.getSaveDirectory()` and `os.getenv("HOME")` answer with
a virtual root, so a legacy mod that joins its own paths lands back in
the same overlay.
`love.thread` stays refused. A LÖVE thread runs in a separate Lua state
with the full standard library, which the sandbox in this state cannot
reach, so a stand-in would be a hole rather than a reroute. The same goes
for `ffi`, `debug`, `setfenv`, `os.execute`, `io.popen`, `love.run` and
`love.errorhandler`. A mod that needs real background work needs an
engine-owned facility, not a compat shim -- for HTTP that facility is
[`mod.fetch`](#modfetch), which runs on the engine's own worker pool.
+7
View File
@@ -929,6 +929,10 @@ mod.content.sfx:register("SFX_MOD_CHIME", { file = "chime.ogg" })
| field | type | required |
|---|---|---|
| `anchorX` | number | no |
| `anchorY` | number | no |
| `frameHeight` | integer >= 1 | no |
| `frameWidth` | integer >= 1 | no |
| `frames` | integer >= 1 | yes |
| `id` | string | no |
| `image` | file path | yes |
@@ -1100,6 +1104,7 @@ mod.content.tokens:register("CLOCK", function(game) return "12" end)
| `paletteSource` | string | no |
| `parties` | list of list of {level, species} | yes |
| `pic` | file path | no |
| `trueColor` | boolean | no |
```lua
mod.content.trainers:patch("OPP_BROCK", { baseMoney = 99 })
@@ -1122,7 +1127,9 @@ do not.
| `index` | integer 0..255 | no |
| `items` | list of items id | no |
| `name` | string | yes |
| `pic` | file path | no |
| `trainers` | list of {id?, index?, name, party, trainerType?} | yes |
| `trueColor` | boolean | no |
```lua
mod.content.trainers:patch("BEAUTY", { baseMoney = 99 })
+2 -1
View File
@@ -19,7 +19,7 @@ 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
* **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**
@@ -40,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
+2 -2
View File
@@ -325,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 |
@@ -774,7 +774,7 @@ profile to test in, and `POKEPORT_DEV=1` adds the console and `F5` hot reload.
- **Coverage is partial and will stay partial.** 15 Gen 1 modules are served
out of a much larger engine, and within those 15 the coverage table records
288 backed members against 32 warned and 161 absent. The absent ones are not
291 backed members against 32 warned and 161 absent. The absent ones are not
a backlog; most are absent because there is no honest Gen 2 answer, and each
one carries its reason. The counts move as the adapter learns something: a
member that turns out to answer nil is demoted from backed to warned or
+29 -6
View File
@@ -26,8 +26,8 @@ wiki's Save Model. `mod.save` and `mod.options` keep their existing behavior.
## The exact API delta
Backward-compatible, additive-only. `Loader:_api` binds a new `mod.storage`
facade to the calling mod id. Mods receive logical keys and decoded values, never
filesystem handles or physical paths.
facade to the calling mod id. Mods receive logical keys and either decoded table
values or exact opaque byte strings, never filesystem handles or physical paths.
### Lazy opaque playthrough identity
@@ -75,6 +75,25 @@ Returns a freshly decoded table, or `nil, code, message`. It tries main, staged,
then backup data. A valid staged/backup value is returned and promoted
best-effort; corrupt bytes are never executed.
### `mod.storage:writeBytes(game, key, bytes)`
Accepts a Lua string containing opaque bytes and returns `true`, or
`false, code, message`. Empty strings are valid. Payloads are limited to 512
MiB per key. The engine writes the supplied bytes exactly as received, without
decoding, compression, checksums, or an engine-defined envelope. The consuming
mod owns semantic validation of its format.
Byte records use private `.bin`, `.bin.tmp`, and `.bin.bak` witnesses. A staged
and replacement write is read back and compared byte-for-byte before it is
committed. A failed write leaves the previous verified generation readable.
Byte storage never passes its payload to the Lua serializer, loader, or module
resolver.
Table and byte records share one logical key namespace and a key has one type.
Writing one type over the other returns `type_conflict`; callers must delete the
key before changing its type. `mod.storage:selected(game)` exposes the same
`readBytes` and `writeBytes` operations for the selected playthrough facade.
### `mod.storage:list(game[, prefix])`
Returns sorted logical keys beneath a valid prefix, an exact key when the prefix
@@ -92,9 +111,9 @@ Physical records are scoped as:
`persistence root / mod_storage / game version / playthrough id / mod id`
Stable error codes are `not_in_playthrough`, `storage_unavailable`,
`invalid_key`, `encode_failed`, `write_failed`, `verify_failed`, and
`not_found`. Ordinary data and I/O failures are return values, not callback-
terminating errors.
`invalid_key`, `encode_failed`, `invalid_bytes`, `size_limit`, `type_conflict`,
`type_mismatch`, `write_failed`, `verify_failed`, and `not_found`. Ordinary
data and I/O failures are return values, not callback-terminating errors.
The restricted serializer's recursive writer runs outside LuaJIT traces. A
1,000-process GC stress regression found compiled recursion could intermittently
@@ -107,6 +126,8 @@ boundary.
**Nothing.** No API is removed, no manifest field changes, and no storage path or
playthrough id is created unless a mod invokes `mod.storage` or
`mod.checkpoints`. Existing save bytes remain unchanged on the no-caller path.
Existing files outside the scoped storage contract are not imported; a caller
must rebuild them through `writeBytes`.
## Parity tests
@@ -115,8 +136,10 @@ playthrough id is created unless a mod invokes `mod.storage` or
- **Engine identity:** lazy allocation, save/load preservation, stable legacy
mapping, fresh-playthrough replacement, and version/slot isolation.
- **Public Mod API:** two real API-2 entry chunks prove data-only roundtrip,
opaque byte roundtrip including NUL bytes, no execution, size/type rejection,
deterministic listing, key rejection, mod/game/playthrough isolation,
corrupt-main recovery, failure retention, exact delete, and no-mod no-write.
corrupt-main recovery, failure retention, selected-playthrough access, exact
delete, and no-mod no-write.
## Deprecation etiquette
@@ -0,0 +1,92 @@
# RFC 0010: Deferred trainer preparation and battle-local party scope
## Status
Proposed.
## Motivation
Challenge and tournament mods sometimes need a player to choose an eligible
subset of the save party before a trainer battle. The current public surface
can replace the opponent through `trainer.party` and observe
`world.trainer_engaged`, but it cannot pause the engagement before battle
construction or keep unselected save-party members out of initial send,
switch, replacement, exhaustion, experience, and party-menu traversal.
Temporarily rewriting `game.save.party` is not a safe substitute: it changes
authoritative save state, composes poorly with checkpoints and other mods, and
can strand excluded Pokémon if a callback or process fails.
## Decision and plan extended
This implements **D-AT-001: battle-local Gym registration without save-party
mutation**, the consuming design decision tracked as capability `AT-SP-001` in
the Adaptive Trainers implementation plan. The plan file is
[`docs/superpowers/plans/2026-08-14-adaptive-trainers.md`](https://github.com/MaxTomahawk/gen1recomp-adaptive-trainers/blob/main/docs/superpowers/plans/2026-08-14-adaptive-trainers.md),
Task 5. The engine delta also extends the additive, guarded public-hook
decision used by RFC 0007 and the screen facade documented in
`docs/modding.md`; it deliberately contains none of the consuming mod's Gym
or party-size policy.
## Exact API delta
Add the guarded hook:
```lua
mod.hooks:wrap("trainer.before_battle", function(next, game, context, continue)
-- context = { trainerClass, partyIndex, mapId, npcId }
-- Return true only when the battle has been deferred.
-- continue({ cancel = true }) returns without constructing a battle.
-- Call continue() for the full save party, or:
-- continue({ playerPartyIndices = { 2, 4, 5 } })
end)
```
The hook runs after the trainer's challenge text and immediately before the
trainer battle is constructed. A mod may push a registered screen with
`mod.ui.push`, return `true`, and retain `continue` for its confirm/cancel
callback. `continue` is one-shot and returns `false` after the first call.
Returning anything other than `true` without calling it continues immediately
with vanilla scope. With no subscriber, no context or continuation is built.
`playerPartyIndices` is an ordered, one-based list into `game.save.party`.
Valid unique indices create `battle.playerParty` as a battle-local view of the
same Pokémon records; the save party itself is never reordered or replaced.
Malformed or empty scopes degrade to the full party. The view governs initial
send, all battle party menus and targets, voluntary and forced replacement,
exhaustion/blackout checks, participant and EXP.ALL traversal, party counts,
and party-ball presentation. Checkpoints preserve the index list and rebuild
the same view before restoring battlers.
`{ cancel = true }` ends a deferred encounter through its normal completion
callback without constructing a battle or writing trainer-defeated state. A
cancelled sight encounter is suppressed while the player remains on the same
cell, preventing immediate reacquisition; moving or directly talking permits a
new challenge. Cancellation is also one-shot; if supplied alongside a party
index list, cancellation wins.
The API sets no maximum, chooses no members, identifies no boss, and contains
no scaling or challenge policy.
## Migration and compatibility
Existing mods change nothing. `BattleState.newTrainer(game, class, index)`
keeps its current behavior; the optional fourth argument is additive. Existing
battle checkpoints without a party scope restore against the full save party.
Wild, Safari, link, and no-mod battles are unchanged.
## Verification
- The catalog-driven hook gate proves empty-chain parity and the guarded hot
path proves no-mod engagement starts exactly once without allocation.
- A sandboxed fixture mod defers through its public hook facade, inspects the
data-only context, and resumes once with ordered indices.
- Engine tests cover initial send, party menus, replacement/exhaustion,
EXP traversal, invalid-scope fallback, and save-party identity.
- Battle-checkpoint tests prove scoped capture/restore and old-checkpoint
compatibility.
## Deprecation etiquette
Nothing is deprecated. The hook and optional constructor argument are
additive.
+13 -5
View File
@@ -126,11 +126,19 @@ bundled game, in that case.
already driving the frame. A payload that must change `love.run` itself
needs a `minShell` bump so an older shell refuses to chainload it rather
than running with half its intended behavior.
- **Android has no in-app download transport yet.** `check_worker.lua`
shells out to curl for both the release check and the download; curl is
absent on Android, so `Check` degrades to `status = "error"` there (the
launcher UI hides on that status) and the player is directed to the
releases page via `Check.releaseUrl()` instead.
- **Android and iOS use the native download bridge, not curl.** Neither
platform ships curl, so the old `check_worker.lua` path (shell out to curl)
always landed on `error` and the launcher chip's "Check for updates" tap
was a no-op. The worker now talks through `HostShell`, the same transport
as the mod catalog: curl on desktop, `love.system.httpDownload` on mobile.
On Android that is the GameActivity JNI/`HttpsURLConnection` bridge; on
iOS it is `GRPickerBridge.httpDownload` (`URLSession`). A fused sideloaded
APK or IPA can therefore check GitHub and fetch the `.love` payload
in-app. If neither transport exists, the worker reports `needs_full` and
the launcher chip opens `Check.releaseUrl()`. Native package-only changes
still need a full reinstall (`minShell` / `payloadHost` gate →
`needs_full`). Applying a downloaded payload on Android relaunches via
`love.system.restartApp`; iOS still uses in-process `quit("restart")`.
- **Dev/source runs never self-update.** `Boot.run` returns immediately when
`love.filesystem.isFused()` is false, and a working tree's `engine` is the
`"0.0.0-dev"` placeholder that always reports up to date, so a source
+48 -25
View File
@@ -76,15 +76,6 @@ end
local editorHost, editorVersion, editorWindow
local closeEditor -- forward declaration: openEditor hands it to the editor
-- tools/save-editor/ models the Gen 1 save and nothing else: a Gen 2 party row
-- carries fields its MonOps and panels have no idea about (dvs, statExp,
-- happiness, pokerus, caughtLevel), and SaveIO.save writes the WHOLE table
-- back, so a Gold slot opened here comes out in a shape src/core/gen2/Save.lua
-- then has to quarantine on the next boot. Refuse by name, the same way the
-- .sav paths do (src/save_convert/SaveConvert.lua GEN2_SAV_UNSUPPORTED), so
-- Red/Blue/Yellow slots are untouched.
local GEN2_NO_EDITOR = { gold = "Pokemon Gold" }
-- The editor's modules use flat names (require("Kit"), require("Party")), so
-- their directories have to be on the require path. It must be
-- love.filesystem's path, not package.path: in a packaged build these files
@@ -137,11 +128,6 @@ local function openEditor(version, slotId)
Importer.saveNotice = Importer.saveNotice or {}
Importer.saveNotice[version] = { ok = false, text = text }
end
local gen2Name = GEN2_NO_EDITOR[version]
if gen2Name then
refuse(gen2Name .. " uses a Gen 2 save; the save editor does not read one yet.")
return
end
local SaveData = require("src.core.SaveData")
local path = SaveData.slotDiskPath(version, slotId)
if not path then
@@ -162,9 +148,44 @@ local function openEditor(version, slotId)
editorMode = true
resizeForEditor()
addEditorRequirePath()
EditorApp = require("App")
EditorApp.load(path, { version = version, slotId = slotId, embedded = true,
onClose = function() closeEditor() end })
local okReq, appOrErr = pcall(require, "App")
if not okReq then
editorMode = false
if version then
require("src.import.CacheFs").unmountVersion(version)
end
restoreWindow()
Importer = editorHost
editorHost = nil
editorVersion = nil
if Importer and Importer.resumeAfterOverlay then
Importer:resumeAfterOverlay()
end
refuse("Could not open the save editor (" .. tostring(appOrErr) .. ").")
return
end
EditorApp = appOrErr
local okLoad, loadErr = pcall(EditorApp.load, path, {
version = version, slotId = slotId, embedded = true,
onClose = function() closeEditor() end,
})
if not okLoad then
editorMode = false
if EditorApp.unload then pcall(EditorApp.unload) end
EditorApp = nil
if version then
require("src.import.CacheFs").unmountVersion(version)
require("src.core.Data"):unloadGenerated()
end
restoreWindow()
Importer = editorHost
editorHost = nil
editorVersion = nil
if Importer and Importer.resumeAfterOverlay then
Importer:resumeAfterOverlay()
end
refuse("Could not open the save editor (" .. tostring(loadErr) .. ").")
end
end
-- Back to the launcher. Everything the editor mounted or cached has to come
@@ -180,6 +201,11 @@ function closeEditor()
require("src.import.CacheFs").unmountVersion(version)
require("src.core.Data"):unloadGenerated()
end
for k in pairs(package.loaded) do
if type(k) == "string" and (k:find("save%-editor") or k == "App" or k == "Kit" or k == "State" or k == "Catalog" or k == "SaveIO" or k == "Ops" or k == "MonOps" or k == "ItemOps" or k == "PadInput" or k == "Gen" or k == "Theme") then
package.loaded[k] = nil
end
end
editorVersion = nil
restoreWindow()
Importer = editorHost
@@ -277,6 +303,11 @@ function love.load(args)
-- of each flashing their own cmd.exe window (#606). No-op elsewhere.
require("src.core.HostShell").hideHostConsole()
-- Hang gen1tls on love.system before mods boot. Android already has tls*
-- from JNI; this is the desktop half. No DLL / no FFI is fine -- ws://
-- rooms still work, wss:// just won't.
pcall(function() require("src.net.Gen1Tls").install() end)
-- NX fused mounts are unreliable for the blue|yellow cache overlay: wrap
-- the love loaders once so every generated-asset read falls back to the
-- versioned save-dir copy. Never installed on desktop/Android/iOS.
@@ -320,14 +351,6 @@ function love.load(args)
-- cache has to be mounted before the editor's Data:load.
if editorMode then
local version = os.getenv("POKEPORT_VERSION") or "red"
local gen2Name = GEN2_NO_EDITOR[version]
if gen2Name then
-- No launcher behind this run to carry a notice, so say it and stop
-- rather than open a Gen 2 slot on Gen 1 panels.
print(gen2Name .. " uses a Gen 2 save; the save editor does not read one yet.")
love.event.quit(1)
return
end
require("src.core.GameVersion").set(version)
require("src.import.CacheFs").mountVersion(version)
addEditorRequirePath()
@@ -154,6 +154,16 @@ love::image::ImageData *Canvas::newImageData(love::image::Image *module, int sli
return module->newImageData(r.w, r.h, dataformat);
}
bool Canvas::requestImageData()
{
return false;
}
love::image::ImageData *Canvas::pollImageData(love::image::Image *)
{
return nullptr;
}
void Canvas::draw(Graphics *gfx, Quad *q, const Matrix4 &t)
{
if (gfx->isCanvasActive(this))
@@ -232,4 +242,3 @@ StringMap<Canvas::SettingType, Canvas::SETTING_MAX_ENUM> Canvas::settingTypes(Ca
} // graphics
} // love
@@ -81,6 +81,8 @@ public:
int getRequestedMSAA() const;
virtual love::image::ImageData *newImageData(love::image::Image *module, int slice, int mipmap, const Rect &rect);
virtual bool requestImageData();
virtual love::image::ImageData *pollImageData(love::image::Image *module);
virtual void generateMipmaps() = 0;
virtual int getMSAA() const = 0;
@@ -23,6 +23,7 @@
#include "Graphics.h"
#include <algorithm> // For min/max
#include <cstring>
namespace love
{
@@ -197,6 +198,9 @@ Canvas::Canvas(const Settings &settings)
, texture(0)
, renderbuffer(0)
, actualSamples(0)
, readbackBuffer(0)
, readbackFence(nullptr)
, readbackSize(0)
{
format = getSizedFormat(format);
@@ -314,6 +318,14 @@ bool Canvas::loadVolatile()
void Canvas::unloadVolatile()
{
if (readbackFence != nullptr)
glDeleteSync(readbackFence);
if (readbackBuffer != 0)
glDeleteBuffers(1, &readbackBuffer);
readbackFence = nullptr;
readbackBuffer = 0;
readbackSize = 0;
if (fbo != 0 || renderbuffer != 0 || texture != 0)
{
// This is a bit ugly, but we need some way to destroy the cached FBO
@@ -480,6 +492,68 @@ love::image::ImageData *Canvas::newImageData(love::image::Image *module, int sli
return data;
}
bool Canvas::requestImageData()
{
if (readbackFence != nullptr || !isReadable()
|| !(GLAD_ES_VERSION_3_0 || GLAD_VERSION_3_2)
|| texType != TEXTURE_2D
|| (format != PIXELFORMAT_RGBA8 && format != PIXELFORMAT_sRGBA8)
|| actualSamples > 0)
return false;
auto gfx = Module::getInstance<Graphics>(Module::M_GRAPHICS);
if (gfx != nullptr && gfx->isCanvasActive(this))
throw love::Exception("Canvas:requestImageData cannot be called while that Canvas is active.");
const size_t size = (size_t) pixelWidth * (size_t) pixelHeight * 4;
if (readbackBuffer == 0)
glGenBuffers(1, &readbackBuffer);
glBindBuffer(GL_PIXEL_PACK_BUFFER, readbackBuffer);
if (readbackSize != size)
{
glBufferData(GL_PIXEL_PACK_BUFFER, size, nullptr, GL_STREAM_READ);
readbackSize = size;
}
GLuint currentfbo = gl.getFramebuffer(OpenGL::FRAMEBUFFER_ALL);
gl.bindFramebuffer(OpenGL::FRAMEBUFFER_ALL, getFBO());
glReadPixels(0, 0, pixelWidth, pixelHeight, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
readbackFence = glFenceSync(GL_SYNC_GPU_COMMANDS_COMPLETE, 0);
gl.bindFramebuffer(OpenGL::FRAMEBUFFER_ALL, currentfbo);
glBindBuffer(GL_PIXEL_PACK_BUFFER, 0);
return readbackFence != nullptr;
}
love::image::ImageData *Canvas::pollImageData(love::image::Image *module)
{
if (readbackFence == nullptr)
return nullptr;
GLenum status = glClientWaitSync(readbackFence, 0, 0);
if (status == GL_TIMEOUT_EXPIRED)
return nullptr;
glDeleteSync(readbackFence);
readbackFence = nullptr;
if (status == GL_WAIT_FAILED)
return nullptr;
love::image::ImageData *data = module->newImageData(pixelWidth, pixelHeight, PIXELFORMAT_RGBA8);
glBindBuffer(GL_PIXEL_PACK_BUFFER, readbackBuffer);
void *pixels = glMapBufferRange(GL_PIXEL_PACK_BUFFER, 0, readbackSize, GL_MAP_READ_BIT);
if (pixels == nullptr)
{
glBindBuffer(GL_PIXEL_PACK_BUFFER, 0);
data->release();
throw love::Exception("Could not map asynchronous Canvas readback.");
}
memcpy(data->getData(), pixels, readbackSize);
glUnmapBuffer(GL_PIXEL_PACK_BUFFER);
glBindBuffer(GL_PIXEL_PACK_BUFFER, 0);
return data;
}
void Canvas::generateMipmaps()
{
if (getMipmapCount() == 1 || getMipmapMode() == MIPMAPS_NONE)
@@ -54,6 +54,8 @@ public:
ptrdiff_t getHandle() const override;
love::image::ImageData *newImageData(love::image::Image *module, int slice, int mipmap, const Rect &rect) override;
bool requestImageData() override;
love::image::ImageData *pollImageData(love::image::Image *module) override;
void generateMipmaps() override;
int getMSAA() const override
@@ -107,6 +109,9 @@ private:
GLenum status;
int actualSamples;
GLuint readbackBuffer;
GLsync readbackFence;
size_t readbackSize;
static SupportedFormat supportedFormats[PIXELFORMAT_MAX_ENUM];
static SupportedFormat checkedFormats[PIXELFORMAT_MAX_ENUM];
@@ -116,6 +116,31 @@ int w_Canvas_newImageData(lua_State *L)
return 1;
}
int w_Canvas_requestImageData(lua_State *L)
{
Canvas *canvas = luax_checkcanvas(L, 1);
bool requested = false;
luax_catchexcept(L, [&](){ requested = canvas->requestImageData(); });
luax_pushboolean(L, requested);
return 1;
}
int w_Canvas_pollImageData(lua_State *L)
{
Canvas *canvas = luax_checkcanvas(L, 1);
love::image::Image *image = luax_getmodule<love::image::Image>(L, love::image::Image::type);
love::image::ImageData *data = nullptr;
luax_catchexcept(L, [&](){ data = canvas->pollImageData(image); });
if (data == nullptr)
{
lua_pushnil(L);
return 1;
}
luax_pushtype(L, data);
data->release();
return 1;
}
int w_Canvas_generateMipmaps(lua_State *L)
{
Canvas *c = luax_checkcanvas(L, 1);
@@ -139,6 +164,8 @@ static const luaL_Reg w_Canvas_functions[] =
{ "getMSAA", w_Canvas_getMSAA },
{ "renderTo", w_Canvas_renderTo },
{ "newImageData", w_Canvas_newImageData },
{ "requestImageData", w_Canvas_requestImageData },
{ "pollImageData", w_Canvas_pollImageData },
{ "generateMipmaps", w_Canvas_generateMipmaps },
{ "getMipmapMode", w_Canvas_getMipmapMode },
{ 0, 0 }
@@ -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
@@ -363,6 +366,7 @@ public class GameActivity extends SDLActivity {
Log.d("GameActivity", "Cancelling vibration");
vibrator.cancel();
}
unregisterSecondaryDisplayListener();
onHostDestroy();
super.onDestroy();
}
@@ -373,6 +377,7 @@ public class GameActivity extends SDLActivity {
Log.d("GameActivity", "Cancelling vibration");
vibrator.cancel();
}
unregisterSecondaryDisplayListener();
teardownSecondaryDisplay();
onHostPause();
super.onPause();
@@ -382,6 +387,7 @@ public class GameActivity extends SDLActivity {
public void onResume() {
super.onResume();
onHostResume();
if (secondaryEnabled) registerSecondaryDisplayListener();
setupSecondaryDisplay();
}
@@ -504,7 +510,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 +542,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 +559,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 +589,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).
@@ -1389,6 +1408,7 @@ public class GameActivity extends SDLActivity {
// in src/jni/love/src/common/android.cpp.
private static volatile SecondaryPresentation secondaryPresentation;
private static volatile boolean secondaryEnabled = false;
private SecondaryDisplayMonitor secondaryDisplayMonitor;
private static final int MAX_SECONDARY_TOUCHES = 32;
private static final java.util.ArrayDeque<String> secondaryTouches =
new java.util.ArrayDeque<>();
@@ -1400,11 +1420,44 @@ public class GameActivity extends SDLActivity {
if (self == null) return;
self.runOnUiThread(new Runnable() {
@Override public void run() {
if (on) setupSecondaryDisplay(); else teardownSecondaryDisplay();
if (on) {
self.registerSecondaryDisplayListener();
setupSecondaryDisplay();
} else {
self.unregisterSecondaryDisplayListener();
teardownSecondaryDisplay();
}
}
});
}
private void registerSecondaryDisplayListener() {
if (secondaryDisplayMonitor != null || android.os.Build.VERSION.SDK_INT < 17) return;
SecondaryDisplayMonitor monitor = new SecondaryDisplayMonitor(this);
if (monitor.register()) secondaryDisplayMonitor = monitor;
}
private void unregisterSecondaryDisplayListener() {
SecondaryDisplayMonitor monitor = secondaryDisplayMonitor;
secondaryDisplayMonitor = null;
if (monitor != null) monitor.unregister();
}
private static void refreshSecondaryDisplay() {
GameActivity self = (GameActivity) mSingleton;
if (self == null || !secondaryEnabled) return;
SecondaryPresentation current = secondaryPresentation;
Display display = current == null ? null : current.getDisplay();
SecondaryDisplayMonitor monitor = self.secondaryDisplayMonitor;
if (current == null) {
setupSecondaryDisplay();
} else if (display == null || monitor == null
|| !monitor.hasDisplay(display.getDisplayId())) {
teardownSecondaryDisplay();
setupSecondaryDisplay();
}
}
private static void setupSecondaryDisplay() {
GameActivity self = (GameActivity) mSingleton;
if (self == null || !secondaryEnabled || secondaryPresentation != null) return;
@@ -1450,6 +1503,35 @@ public class GameActivity extends SDLActivity {
}
}
@android.annotation.TargetApi(17)
private static class SecondaryDisplayMonitor
implements android.hardware.display.DisplayManager.DisplayListener {
private final android.hardware.display.DisplayManager manager;
SecondaryDisplayMonitor(GameActivity activity) {
manager = (android.hardware.display.DisplayManager)
activity.getSystemService(Context.DISPLAY_SERVICE);
}
boolean register() {
if (manager == null) return false;
manager.registerDisplayListener(this, new Handler(Looper.getMainLooper()));
return true;
}
void unregister() {
manager.unregisterDisplayListener(this);
}
boolean hasDisplay(int displayId) {
return manager.getDisplay(displayId) != null;
}
@Override public void onDisplayAdded(int displayId) { refreshSecondaryDisplay(); }
@Override public void onDisplayRemoved(int displayId) { refreshSecondaryDisplay(); }
@Override public void onDisplayChanged(int displayId) { refreshSecondaryDisplay(); }
}
@Keep
public static boolean hasSecondaryDisplay() {
return secondaryPresentation != null;
+77
View File
@@ -12,6 +12,83 @@
"tintColor": "3b5ca8",
"category": "games",
"versions": [
{
"version": "0.1.95",
"date": "2026-08-16",
"size": 11373796,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.95/gen1recomp++-0.1.95-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @AverageConsumer\n- @bryanthaboi\n- @ShaneMcGovernIE"
},
{
"version": "0.1.94",
"date": "2026-08-15",
"size": 11369457,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.94/gen1recomp++-0.1.94-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #694 Missing sound effect when falling from boulder holes\n\n## Contributors\n\n- @anxiousintrovert\n- @bryanthaboi\n- @ShaneMcGovernIE"
},
{
"version": "0.1.93",
"date": "2026-08-15",
"size": 11366991,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.93/gen1recomp++-0.1.93-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1314 Launcher is a bit buggy\n\n## Contributors\n\n- @1Jamie\n- @anxiousintrovert\n- @bryanthaboi\n- @TheRealSolidusSnake"
},
{
"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",
+4 -2
View File
@@ -5,7 +5,7 @@
// without updating that patch (see mobile/ios/patch_love_src.py).
//
// Contract (mirrors love-android's GameActivity.showFilePicker):
// love.system.pickFile("rom"|"mod"|"sav") -> copies the user's pick into
// love.system.pickFile("rom"|"mod"|"sav"|"required_import") -> copies the user's pick into
// the LÖVE save directory as picked_rom.gb / picked_mod.zip /
// picked_save.sav; RomImporter's pending-file scan consumes it.
// love.system.createFile(name) -> exports save dir's pending_export.sav
@@ -83,6 +83,8 @@ public final class GRPickerBridge: NSObject {
types = [.zip]
case "sav":
destName = "picked_save.sav"
case "required_import":
destName = "picked_required_import.bin"
// A Nintendo 64 cartridge, for mods that build assets out of one --
// the voxel mod's Pokemon Stadium battle models are the caller this
// was added for. Its own filename on purpose: an N64 ROM landing on
@@ -135,7 +137,7 @@ public final class GRPickerBridge: NSObject {
// Kept beside the switch it describes, because the two drifting apart is
// the only way this can lie.
@objc public static func supportedPickerKinds() -> NSString {
return "rom,mod,sav,stadium" as NSString
return "rom,mod,sav,stadium,required_import" as NSString
}
@objc(presentExportWithName:saveDir:)
+2 -1
View File
@@ -89,7 +89,8 @@ int w_pickFile(lua_State *L)
return gr_callBridge(L, "GRPickerBridge", "presentPickerWithKind:saveDir:", kind);
}
// love.system.pickFileKinds() -> "rom,mod,sav,stadium", or nil off iOS.
// love.system.pickFileKinds() -> the comma-separated kinds supported by the
// Swift bridge (including required_import), or nil off iOS.
//
// So a caller can ask what this build's picker understands BEFORE opening it.
// An unknown kind is refused (GRPickerBridge), and a refusal looks exactly
-1
View File
@@ -1 +0,0 @@
/Users/bryanbassett/Documents/development/pokemon-gen1-recomp-project/.bazinga/mods/timekeepers_hut
+6
View File
@@ -51,6 +51,12 @@ rm -f "$OUTPUT"
tools/rom_manifest.json tools/rom_manifest_blue.json \
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
if [ -f "$ROOT/PATCH_NOTES.md" ]; then
(cd "$ROOT" && zip -q "$OUTPUT" PATCH_NOTES.md)
fi
if [ -f "$ROOT/mobile/ios/app-repo.json" ]; then
(cd "$ROOT" && zip -q "$OUTPUT" mobile/ios/app-repo.json)
fi
if [ -n "$BUILD_INFO" ]; then
[ -f "$BUILD_INFO" ] || fail "missing build-info: $BUILD_INFO"
+1
View File
@@ -135,6 +135,7 @@ if [ -f data/generated/maps.lua ]; then
run_tier "T3 save editor: events + dex" "$LUA" tests/save_editor_task7_tests.lua
run_tier "T3 save editor: map browser" "$LUA" tests/save_editor_task8_tests.lua
run_tier "T3 save editor: mod awareness" "$LUA" tests/save_editor_mod_tests.lua
run_tier "T3 save editor: gold / gen2" "$LUA" tests/save_editor_gen2_tests.lua
run_tier "T3 save editor: wheel scrolling" "$LUA" tests/save_editor_wheel_bug595_test.lua
run_tier "T3 save editor: pad / NX input" "$LUA" tests/save_editor_pad_input_test.lua
run_tier "T5 link (loopback lockstep)" "$LUA" tests/run_link_tests.lua
+257
View File
@@ -0,0 +1,257 @@
-- Read-only Gen 1 battle state for companion UIs and accessibility mods.
local Damage = require("src.battle.Damage")
local ItemEffects = require("src.inventory.ItemEffects")
local TypeChart = require("src.battle.TypeChart")
local BattleAPI = {}
BattleAPI.__index = BattleAPI
function BattleAPI.new(game)
return setmetatable({ game = game, revision = 0, signature = nil }, BattleAPI)
end
local function activeBattle(game)
local states = game and game.stack and game.stack.states or {}
for i = #states, 1, -1 do
if states[i].isBattleState then return states[i], states[#states] end
end
end
local function monCopy(data, mon, active)
if not mon then return nil end
local def = data.pokemon[mon.species]
return {
species = mon.species,
name = mon.nickname or (def and def.name) or mon.species,
level = mon.level, hp = mon.hp,
maxHp = mon.stats and mon.stats.hp or mon.hp,
status = mon.status, active = active and true or false,
}
end
local function visibleMessage(battle, top)
local source = top and top.isTextBox and top or top == battle and battle
local lines = source and source.visibleText and source:visibleText()
if not lines then return nil end
local copy = {}
for i, line in ipairs(lines) do copy[i] = tostring(line) end
return copy
end
local function signature(game, battle, top)
if not battle then return "none" end
local parts = { tostring(battle), tostring(top), battle.phase or "",
tostring(battle.turnCount or 0), tostring(#(battle.queue or {})),
tostring(battle.current), tostring(battle.msgWaiting),
tostring(battle.msgPrompt), tostring(battle.menuIndex),
tostring(battle.mimicIndex),
tostring(battle.safari and battle.safari.balls),
tostring(battle.ghost), tostring(battle.noCatch),
table.concat(visibleMessage(battle, top) or {}, "\n") }
for _, battler in ipairs({ battle.player, battle.enemy }) do
local mon = battler and battler.mon
parts[#parts + 1] = tostring(mon)
parts[#parts + 1] = tostring(mon and mon.hp)
parts[#parts + 1] = tostring(mon and mon.status)
parts[#parts + 1] = table.concat(battler and battler.curTypes or {}, ",")
end
for _, mon in ipairs(game.save.party or {}) do
parts[#parts + 1] = tostring(mon)
parts[#parts + 1] = tostring(mon.hp)
parts[#parts + 1] = tostring(mon.status)
end
local inventory = {}
for id, count in pairs(game.save.inventory or {}) do
if ItemEffects.isBall(id) or ItemEffects.isBattleMedicine(id) then
inventory[#inventory + 1] = id .. "=" .. tostring(count)
end
end
table.sort(inventory)
for _, item in ipairs(inventory) do parts[#parts + 1] = item end
return table.concat(parts, "|")
end
function BattleAPI:_revision(battle, top)
local nextSignature = signature(self.game, battle, top)
if nextSignature ~= self.signature then
self.signature = nextSignature
self.revision = self.revision + 1
end
return self.revision
end
local IMMUNITY_ONLY = { SPECIAL_DAMAGE_EFFECT = true,
SUPER_FANG_EFFECT = true, OHKO_EFFECT = true }
local function movePreview(battle, move, def)
local record = battle.effectRecord and battle:effectRecord(def.effect)
local power, typeMult = def.power or 0
if power > 0 and move.id ~= "COUNTER" then
local raw = TypeChart.effectiveness(def.type, battle.enemy.curTypes or {})
if IMMUNITY_ONLY[def.effect] then
typeMult = raw == 0 and 0 or 10
elseif not (record and record.chooseDamage) then
typeMult = raw
end
end
local hitChance
if record and record.neverMiss then
hitChance = 100
elseif not (record and record.gate)
and (power > 0 or (record and record.accuracyChecked)) then
hitChance = battle.enemy.invulnerable and 0
or Damage.accuracyChance(battle.ruleset, def,
battle.player, battle.enemy)
end
local displayPower = power > 0 and move.id ~= "COUNTER"
and not IMMUNITY_ONLY[def.effect]
and not (record and record.chooseDamage) and power or nil
return typeMult, hitChance, displayPower
end
local function moveCopies(game, battle)
local out = {}
for slot, move in ipairs((battle.player and battle.player.curMoves) or {}) do
local def = game.data.moves[move.id] or {}
local mult, hitChance, displayPower = movePreview(battle, move, def)
out[slot] = { slot = slot, id = move.id, name = def.name or move.id,
pp = move.pp,
maxPp = (def.pp or move.pp or 0)
+ (move.ppUps or 0) * math.floor((def.pp or 0) / 5),
type = def.type, power = def.power, accuracy = def.accuracy,
displayPower = displayPower, hitChance = hitChance,
effectiveness = mult,
disabled = battle.player.disabledSlot == slot }
end
return out
end
local function itemCopies(game, battle, catchable)
local out = {}
for id, count in pairs(game.save.inventory or {}) do
if count > 0
and (ItemEffects.isBall(id) or ItemEffects.isBattleMedicine(id)) then
local def = game.data.items[id] or {}
local ball = ItemEffects.isBall(id)
out[#out + 1] = { id = id, name = def.name or id, count = count,
ball = ball, needsTarget = not ball,
catchChance = ball and catchable and battle.catchChance
and battle:catchChance(id) or nil }
end
end
table.sort(out, function(a, b) return a.name < b.name end)
return out
end
local function mimicCopies(game, battle)
local out = {}
for i, move in ipairs(battle.mimicMoves or {}) do
local def = game.data.moves[move.id] or {}
out[i] = { index = i, slot = move.slot, id = move.id,
name = def.name or move.id }
end
return out
end
function BattleAPI:snapshot()
local game = self.game
local battle, top = activeBattle(game)
if not battle then return nil end
local kind = battle:battleKind()
local supported = kind ~= "oldman" and kind ~= "link"
local catchable = kind == "wild" and not battle.ghost and not battle.noCatch
local forcedParty = top and top.isPartyMenu and top.battle == battle
and top.forceSwitch
local canAdvance = supported and ((top == battle
and battle.phase == "messages" and battle.current
and (battle.msgWaiting or battle.msgPrompt))
or (top and top.isTextBox and not top.choice
and (top.waiting or top.done)))
local prompt = "locked"
if canAdvance then prompt = "advance"
elseif supported and forcedParty then prompt = "party"
elseif supported and top == battle and kind == "safari"
and battle.phase == "menu" then prompt = "safari"
elseif supported and top == battle and battle.phase == "mimicSelect" then
prompt = "mimic"
elseif supported and top == battle and battle.phase == "menu" then
prompt = "menu"
elseif supported and top == battle and battle.phase == "moveSelect" then
prompt = "moves"
end
local party = {}
for i, mon in ipairs(game.save.party or {}) do
party[i] = monCopy(game.data, mon,
battle.player and battle.player.mon == mon)
party[i].slot = i
end
return { revision = self:_revision(battle, top), kind = kind,
catchable = catchable, prompt = prompt,
message = visibleMessage(battle, top), turn = battle.turnCount or 0,
player = monCopy(game.data, battle.player and battle.player.mon, true),
enemy = monCopy(game.data, battle.enemy and battle.enemy.mon, true),
party = party, moves = moveCopies(game, battle),
items = itemCopies(game, battle, catchable),
safariBalls = battle.safari and battle.safari.balls or nil,
mimicMoves = mimicCopies(game, battle), mimicIndex = battle.mimicIndex }
end
local MENU_CHOICES = { fight = true, party = true, item = true, run = true }
local function validSlot(slot)
return type(slot) == "number" and slot % 1 == 0 and slot >= 1
end
function BattleAPI:submit(intent)
if type(intent) ~= "table" then return nil, "intent must be a table" end
if type(intent.id) ~= "number" or intent.id % 1 ~= 0 or intent.id < 1 then
return nil, "intent id must be a positive integer"
end
if self.lastIntentId and intent.id <= self.lastIntentId then
return nil, "replayed intent"
end
local battle, top = activeBattle(self.game)
if not battle then return nil, "no battle" end
if intent.revision ~= self:_revision(battle, top) then
return nil, "stale battle context"
end
local kind = battle:battleKind()
if kind == "oldman" or kind == "link" or kind == "safari" then
return nil, "battle kind is not controllable"
end
if top ~= battle then return nil, "battle menu is covered" end
local ok, err
if intent.kind == "menu" then
if battle.phase ~= "menu" then return nil, "battle menu is not active" end
if not MENU_CHOICES[intent.choice] then
return nil, "unknown battle menu choice"
end
ok, err = battle:chooseMenu(intent.choice)
elseif intent.kind == "move" then
if battle.phase ~= "moveSelect" then
return nil, "move menu is not active"
end
if battle.moveSwapIndex then return nil, "move reorder is active" end
local move = validSlot(intent.slot) and battle.player
and battle.player.curMoves[intent.slot]
if not move then return nil, "invalid move slot" end
if (move.pp or 0) <= 0 then return nil, "move has no PP" end
if battle.player.disabledSlot == intent.slot then
return nil, "move is disabled"
end
ok, err = battle:chooseMove(intent.slot)
elseif intent.kind == "back" then
ok, err = battle:cancelMove()
else
return nil, "unknown battle intent"
end
if not ok then return nil, err end
self.lastIntentId = intent.id
self.signature = nil
return true
end
return BattleAPI
+173 -66
View File
@@ -38,6 +38,7 @@ local romText = RomText
local BattleState = {}
BattleState.__index = BattleState
BattleState.isOpaque = true
BattleState.isBattleState = true
-- Category identity for per-category GAME SPEED (RFC 0007), the same
-- style OverworldController.isOverworld already uses. Every battle --
@@ -156,6 +157,13 @@ function BattleState:caughtMarkerVisible()
function() return false end, self) == true
end
function BattleState:catchChance(ball, rateOverride)
if Runtime.wantsHook("catch.rate") then return nil end
return Catching.chance(ball, self.enemy.mon, self.enemy.def, rateOverride,
{ ballDef = self:ballDef(ball), statuses = self.data.statuses,
battle = self })
end
function BattleState:moveGridNavigation()
if self:wideLayout() then return true end
if not Runtime.wantsHook("battle.move_grid_navigation") then return false end
@@ -316,6 +324,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
@@ -617,6 +646,44 @@ local function newBattle(game)
return self
end
local function scopedPlayerParty(game, indices)
if indices == nil then return nil, nil end
if type(indices) ~= "table" then
Logger.warn("trainer battle party scope is not a table; using full party")
return nil, nil
end
local count = #indices
local keyCount = 0
for key in pairs(indices) do
keyCount = keyCount + 1
if type(key) ~= "number" or key % 1 ~= 0 or key < 1 or key > count then
Logger.warn("trainer battle party scope is malformed; using full party")
return nil, nil
end
end
if count == 0 or keyCount ~= count then
Logger.warn("trainer battle party scope is empty or sparse; using full party")
return nil, nil
end
local party, normalized, seen = {}, {}, {}
for i = 1, count do
local index = indices[i]
if type(index) ~= "number" or index % 1 ~= 0
or not game.save.party[index] or seen[index] then
Logger.warn("trainer battle party scope contains an invalid index; using full party")
return nil, nil
end
seen[index] = true
normalized[i] = index
party[i] = game.save.party[index]
end
return party, normalized
end
function BattleState:playerPartyView()
return self.playerParty or self.game.save.party
end
-- opts.hooked: rod encounter, announced with _HookedMonAttackedText
function BattleState.newWild(game, species, level, opts)
local self = newBattle(game)
@@ -692,13 +759,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 +811,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 +825,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
@@ -1106,7 +1174,7 @@ function BattleState:startMessage(item)
local npos = text:find("[\n\v]", pos)
local chunk = npos and text:sub(pos, npos - 1) or text:sub(pos)
local codes = Font.encode(chunk)
self.lines[#self.lines + 1] = { codes = codes, cont = cont }
self.lines[#self.lines + 1] = { codes = codes, cont = cont, text = chunk }
self.total = self.total + #codes
if not npos then break end
cont = text:sub(npos, npos) == "\v"
@@ -1139,6 +1207,18 @@ function BattleState:beginMsgLine()
self.shown[#self.shown + 1] = {}
end
function BattleState:visibleText()
if self.phase ~= "messages" or not (self.current or self.animPlaying) then
return nil
end
local out, count = {}, #(self.shown or {})
for i = math.max(1, self.lineIndex - count + 1), self.lineIndex do
local line = self.lines and self.lines[i]
if line then out[#out + 1] = line.text or "" end
end
return #out > 0 and out or nil
end
function BattleState:updateQueue()
if self.waitingUI then
if self.game.stack:top() ~= self then return true end
@@ -1871,6 +1951,77 @@ function BattleState:playerHasPP()
return false
end
-- One semantic path for the native command menu and mod.battle intents.
function BattleState:chooseMenu(choice)
if self.phase ~= "menu" then return nil, "battle menu is not active" end
if not self.player or not self.player.mon or self.player.mon.hp <= 0
or self:menuLockedAction(self.player) then
return nil, "battle menu is not ready"
end
self:clearTurnFlinches()
if choice == "fight" and self.ghost then
self:say(Strings("%s is too\nscared to move!", self.player.name))
self.phase = "messages"
self.afterQueue = "menu"
self:act(function()
self:executeAction(self.enemy, self.player, self:enemyAction())
end)
-- A scared turn still ticks the player's residual effects.
self:queueResidual(self.player, self.enemy)
self:act(function() self:endOfTurn() end)
elseif choice == "fight" then
-- Trapping, Bide, and similar locks skip the move list.
local fightLock = self:fightLockedAction(self.player)
if fightLock then
self:resolveTurn(fightLock)
elseif not self:playerHasPP() then
-- No usable PP goes straight to Struggle.
self:say(Strings("%s has no\nmoves left!", self.player.name))
self:resolveTurn({ id = "STRUGGLE", pp = 1, struggle = true })
else
self.phase = "moveSelect"
self.moveIndex = math.min(self.moveIndex, #self.player.curMoves)
self.moveSwapIndex = nil
end
elseif choice == "run" then
self:tryRun()
elseif choice == "item" then
self:openItems()
elseif choice == "party" then
self:openParty()
else
return nil, "unknown battle menu choice"
end
return true
end
function BattleState:chooseMove(index)
if self.phase ~= "moveSelect" then return nil, "move menu is not active" end
local move = self.player.curMoves[index]
if not move then return nil, "invalid move slot" end
self.moveIndex = index
if self.player.disabledSlot == index then
self:say(self:romText("_MoveDisabledText", "The move is\ndisabled!"))
self.phase = "messages"
self.afterQueue = "menu"
elseif move.pp <= 0 then
self:say(self:romText("_MoveNoPPText", "No PP left for\nthis move!"))
self.phase = "messages"
self.afterQueue = "menu"
else
self.playerMoveListIndex = index
self:resolveTurn(move)
end
return true
end
function BattleState:cancelMove()
if self.phase ~= "moveSelect" then return nil, "move menu is not active" end
self.moveSwapIndex = nil
self.phase = "menu"
return true
end
function BattleState:swapMoves(i, j)
if i == j then return end
local moves = self.player.curMoves
@@ -2002,7 +2153,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
@@ -2040,42 +2191,7 @@ function BattleState:update(dt)
self.menuIndex = row * 2 + col + 1
if input:wasPressed("a") then
require("src.core.Sound").play(self.data, "Press_AB")
local choice = ({ "fight", "pkmn", "item", "run" })[self.menuIndex]
if choice == "fight" and self.ghost then
self:say(Strings("%s is too\nscared to move!", self.player.name))
self.phase = "messages"
self.afterQueue = "menu"
self:act(function()
self:executeAction(self.enemy, self.player, self:enemyAction())
end)
-- the scared turn still ticks the player's residual (PrintGhostText
-- -> ExecutePlayerMoveDone, core.asm:3056, 3275-3279)
self:queueResidual(self.player, self.enemy)
self:act(function() self:endOfTurn() end)
elseif choice == "fight" then
-- After the menu: own trapping/Bide or foe Wrap skips the move
-- list and forces the locked action (core.asm:320-329)
local fightLock = self:fightLockedAction(self.player)
if fightLock then
self:resolveTurn(fightLock)
return
end
if not self:playerHasPP() then
-- _NoMovesLeftText, then Struggle engages
self:say(Strings("%s has no\nmoves left!", self.player.name))
self:resolveTurn({ id = "STRUGGLE", pp = 1, struggle = true })
return
end
self.phase = "moveSelect"
self.moveIndex = math.min(self.moveIndex, #self.player.curMoves)
self.moveSwapIndex = nil
elseif choice == "run" then
self:tryRun()
elseif choice == "item" then
self:openItems()
else
self:openParty()
end
self:chooseMenu(({ "fight", "party", "item", "run" })[self.menuIndex])
end
return
end
@@ -2104,8 +2220,7 @@ function BattleState:update(dt)
end
elseif input:wasPressed("b") then
require("src.core.Sound").play(self.data, "Press_AB")
self.moveSwapIndex = nil
self.phase = "menu"
self:cancelMove()
elseif input:wasPressed("a") then
require("src.core.Sound").play(self.data, "Press_AB")
if self.moveSwapIndex then
@@ -2113,19 +2228,7 @@ function BattleState:update(dt)
self.moveSwapIndex = nil
return
end
local mv = moves[self.moveIndex]
if self.player.disabledSlot == self.moveIndex then
self:say(self:romText("_MoveDisabledText", "The move is\ndisabled!"))
self.phase = "messages"
self.afterQueue = "menu"
elseif mv.pp <= 0 then
self:say(self:romText("_MoveNoPPText", "No PP left for\nthis move!"))
self.phase = "messages"
self.afterQueue = "menu"
else
self.playerMoveListIndex = self.moveIndex
self:resolveTurn(mv)
end
self:chooseMove(self.moveIndex)
end
return
end
@@ -3890,7 +3993,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
@@ -3984,9 +4088,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
@@ -4027,7 +4131,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
@@ -4054,6 +4158,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
@@ -4226,7 +4331,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
@@ -4299,6 +4404,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)
@@ -4779,6 +4885,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))
@@ -4825,8 +4932,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"
@@ -5707,7 +5814,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
+29 -14
View File
@@ -27,6 +27,17 @@ Catching.BALLS = BALLS
-- divisor, which is what the old per-field `or` defaults resolved to
local DEFAULT_BALL = { randMax = 255, hpFactor = 12, wobbleFactor = 150 }
local function stockFactors(def, targetMon, targetDef, rateOverride, statuses)
local rate = rateOverride or targetDef.catchRate
local record = Status.recordFor(statuses, targetMon.status)
local statusBonus = record and record.catchBonus or 0
local hpQuarter = math.max(1, math.floor(targetMon.hp / 4))
local factor = def.hpFactor or DEFAULT_BALL.hpFactor
local f = math.min(255, math.floor(math.floor(
targetMon.stats.hp * 255 / factor) / hpQuarter))
return rate, statusBonus, f, record
end
function Catching.registerInto(registry, _, owner)
for id, record in pairs(BALLS) do
registry:register(id, record, owner)
@@ -41,21 +52,9 @@ end
local function stockAttempt(def, targetMon, targetDef, rng, rateOverride, statuses)
if def.autoCatch then return true, 3 end
local randMax = def.randMax
local rate = rateOverride or targetDef.catchRate
-- the status subtraction and the wobble bonus come off the merged
-- status record (SLP/FRZ 25 and +10, the rest 12 and +5)
local s = targetMon.status
local record = Status.recordFor(statuses, s)
local statusBonus = record and record.catchBonus or 0
-- HP factor (X)
local maxhp = targetMon.stats.hp
local hpQuarter = math.max(1, math.floor(targetMon.hp / 4))
local factor = def.hpFactor or DEFAULT_BALL.hpFactor
-- the 255 cap applies only after BOTH divisions (ItemUseBall keeps
-- the intermediate in 16 bits); capping early collapses the value
local f = math.min(255, math.floor(math.floor(maxhp * 255 / factor) / hpQuarter))
local rate, statusBonus, f, record = stockFactors(
def, targetMon, targetDef, rateOverride, statuses)
local function shakes()
local ballFactor2 = def.wobbleFactor or DEFAULT_BALL.wobbleFactor
@@ -80,6 +79,22 @@ local function stockAttempt(def, targetMon, targetDef, rng, rateOverride, status
return false, shakes()
end
-- Exact stock catch probability for read-only previews. A custom attempt
-- function may do anything, so nil is safer than presenting a plausible lie.
function Catching.chance(ball, targetMon, targetDef, rateOverride, opts)
opts = opts or {}
local def = opts.ballDef or BALLS[ball] or DEFAULT_BALL
if def.attempt then return nil end
if def.autoCatch then return 100 end
local rate, statusBonus, f = stockFactors(
def, targetMon, targetDef, rateOverride, opts.statuses)
local outcomes = def.randMax + 1
local automatic = math.min(outcomes, math.max(0, statusBonus))
local passed = math.min(outcomes, math.max(0, rate + statusBonus + 1))
return (automatic + (passed - automatic) * (f + 1) / 256)
* 100 / outcomes
end
-- Returns caught, shakes (0-3). rateOverride replaces the species catch
-- rate (the Safari game's BAIT/ROCK-modified wEnemyMonActualCatchRate).
-- opts (all optional): ballDef = the merged ball record, statuses = the
+22 -12
View File
@@ -83,25 +83,35 @@ function Damage.critRoll(ruleset, attacker, moveId, rng, highCrit)
return rng(0, 255) < b
end
-- Accuracy test: rand(0..255) < floor(accuracy * 255 / 100) adjusted by
-- accuracy/evasion stages. With oneIn256Miss a max-accuracy move still
-- misses on 255.
function Damage.accuracyRoll(ruleset, move, attacker, defender, rng)
rng = rng or love.math.random
-- Exact number of the 256 RNG outcomes that pass MoveHitTest. Keeping the
-- threshold public lets read-only UIs preview the same rules the roll uses.
function Damage.accuracyThreshold(ruleset, move, attacker, defender)
-- X ACCURACY sets USING_X_ACCURACY: the move simply never misses
-- (MoveHitTest returns before any accuracy math, 1/256 included)
if attacker.xAccuracy then return true end
if attacker.xAccuracy then return 256 end
local acc = math.floor(move.accuracy * 255 / 100)
local accuracyStage = attacker.stages and attacker.stages.accuracy or 0
local evasionStage = defender.stages and defender.stages.evasion or 0
-- CalcHitChance scales by the accuracy stage and the evasion stage as
-- two separate ratio multiplications, clamping each result
acc = math.min(255, Stats.applyStage(acc,
attacker.stages and attacker.stages.accuracy or 0))
acc = math.min(255, Stats.applyStage(acc,
-(defender.stages and defender.stages.evasion or 0)))
acc = math.min(255, Stats.applyStage(acc, accuracyStage))
acc = math.min(255, Stats.applyStage(acc, -evasionStage))
if not ruleset.oneIn256Miss and move.accuracy >= 100
and (attacker.stages.accuracy or 0) >= (defender.stages.evasion or 0) then
return true
and accuracyStage >= evasionStage then
return 256
end
return acc
end
function Damage.accuracyChance(ruleset, move, attacker, defender)
return Damage.accuracyThreshold(ruleset, move, attacker, defender) * 100 / 256
end
-- Accuracy test: rand(0..255) < the shared ruleset-aware threshold.
function Damage.accuracyRoll(ruleset, move, attacker, defender, rng)
rng = rng or love.math.random
local acc = Damage.accuracyThreshold(ruleset, move, attacker, defender)
if acc == 256 then return true end
return rng(0, 255) < acc
end
+172
View File
@@ -0,0 +1,172 @@
-- Read-only Gen 2 battle state with the same shape as mod.battle on Gen 1.
local BattleAPI = {}
BattleAPI.__index = BattleAPI
function BattleAPI.new(game)
return setmetatable({ game = game, revision = 0, signature = nil }, BattleAPI)
end
local function activeBattle(game)
local states = game and game.stack and game.stack.states or {}
local battle
for i = #states, 1, -1 do
local state = states[i]
if state.screenId == "Gen2BattleState" or state.isGen2BattleState then
battle = state
break
end
end
return battle, states[#states]
end
local function monCopy(data, mon, active)
if not mon then return nil end
local def = data and data.pokemon and data.pokemon[mon.species]
return { species = mon.species,
name = mon.nickname or (def and def.name) or mon.species,
level = mon.level, hp = mon.hp,
maxHp = mon.maxHp or (mon.stats and mon.stats.hp) or mon.hp,
status = mon.status, active = active and true or false }
end
local function messageCopy(screen)
if not screen.message then return nil end
local lines = {}
for line in tostring(screen.message):gmatch("[^\n]+") do
lines[#lines + 1] = line
end
return #lines > 0 and lines or nil
end
local function signature(game, screen, top)
if not screen then return "none" end
local battle = screen.battle or {}
local parts = { tostring(screen), tostring(top), tostring(screen.phase),
tostring(screen.message), tostring(screen.messageTimer),
tostring(screen.menuIndex), tostring(screen.moveIndex),
tostring(battle.turn), tostring(battle.over), tostring(battle.outcome) }
for _, mon in ipairs({ battle.player, battle.enemy }) do
parts[#parts + 1] = tostring(mon)
parts[#parts + 1] = tostring(mon and mon.hp)
parts[#parts + 1] = tostring(mon and mon.status)
end
for _, mon in ipairs((game.save and game.save.party) or battle.party or {}) do
parts[#parts + 1] = tostring(mon)
parts[#parts + 1] = tostring(mon.hp)
parts[#parts + 1] = tostring(mon.status)
end
return table.concat(parts, "|")
end
function BattleAPI:_revision(screen, top)
local nextSignature = signature(self.game, screen, top)
if nextSignature ~= self.signature then
self.signature = nextSignature
self.revision = self.revision + 1
end
return self.revision
end
local function moveCopies(game, battle)
local out = {}
for slot, move in ipairs((battle.player and battle.player.moves) or {}) do
local def = (game.data.moves or {})[move.id] or {}
out[slot] = { slot = slot, id = move.id, name = def.name or move.id,
pp = move.pp, maxPp = move.maxPp or def.pp or move.pp,
type = def.type, power = def.power, accuracy = def.accuracy,
disabled = battle:moveDisabled(battle.player, move.id) }
end
return out
end
local function battleKind(screen)
if screen.tutorial then return "oldman" end
return screen.battle and screen.battle.wild and "wild" or "trainer"
end
function BattleAPI:snapshot()
local game = self.game
local screen, top = activeBattle(game)
if not screen or not screen.battle then return nil end
local battle = screen.battle
local prompt = "locked"
if top == screen and screen.phase == "menu" then
prompt = "menu"
elseif top == screen and screen.phase == "moves" then
prompt = "moves"
elseif top == screen and screen.message then
prompt = "advance"
elseif top and top.screenId == "Gen2PartyMenu" then
prompt = "party"
end
local party = {}
for i, mon in ipairs((game.save and game.save.party) or battle.party or {}) do
party[i] = monCopy(game.data, mon, mon == battle.player)
party[i].slot = i
end
return { revision = self:_revision(screen, top), kind = battleKind(screen),
catchable = battle.wild and not screen.tutorial, prompt = prompt,
message = messageCopy(screen), turn = battle.turn or 0,
player = monCopy(game.data, battle.player, true),
enemy = monCopy(game.data, battle.enemy, true),
party = party, moves = moveCopies(game, battle),
-- Gold's PACK is pocketed and target selection is screen-owned. Omit it
-- until the engine can expose the same semantic item records as Gen 1.
items = {} }
end
local MENU_CHOICES = { fight = true, party = true, item = true, run = true }
local function validSlot(slot)
return type(slot) == "number" and slot % 1 == 0 and slot >= 1
end
function BattleAPI:submit(intent)
if type(intent) ~= "table" then return nil, "intent must be a table" end
if type(intent.id) ~= "number" or intent.id % 1 ~= 0 or intent.id < 1 then
return nil, "intent id must be a positive integer"
end
if self.lastIntentId and intent.id <= self.lastIntentId then
return nil, "replayed intent"
end
local screen, top = activeBattle(self.game)
if not screen or not screen.battle then return nil, "no battle" end
if intent.revision ~= self:_revision(screen, top) then
return nil, "stale battle context"
end
if screen.tutorial then return nil, "battle kind is not controllable" end
if top ~= screen then return nil, "battle menu is covered" end
local battle = screen.battle
local ok, err
if intent.kind == "menu" then
if screen.phase ~= "menu" then return nil, "battle menu is not active" end
if not MENU_CHOICES[intent.choice] then
return nil, "unknown battle menu choice"
end
ok, err = screen:chooseMenu(intent.choice)
elseif intent.kind == "move" then
if screen.phase ~= "moves" then return nil, "move menu is not active" end
if screen.moveSwapIndex then return nil, "move reorder is active" end
local move = validSlot(intent.slot) and battle.player
and battle.player.moves and battle.player.moves[intent.slot]
if not move then return nil, "invalid move slot" end
if (move.pp or 0) <= 0 then return nil, "move has no PP" end
if battle:moveDisabled(battle.player, move.id) then
return nil, "move is disabled"
end
ok, err = screen:chooseMove(intent.slot)
elseif intent.kind == "back" then
ok, err = screen:cancelMove()
else
return nil, "unknown battle intent"
end
if not ok then return nil, err end
self.lastIntentId = intent.id
self.signature = nil
return true
end
return BattleAPI
+59
View File
@@ -98,10 +98,69 @@ function Mon.stats(baseStats, dvs, level, statExp)
}
end
-- The species string is the source of truth. `mon.name` is a copy of that
-- species' display name (GetPokemonName), kept so menus can print without a
-- Data lookup. It is NOT the nickname: an un-nicknamed mon has nickname nil
-- and prints this copy. Changing species without rewriting it leaves the
-- previous species' name on the party list and the SUMMARY's top line.
function Mon.syncIdentity(mon, data)
if type(mon) ~= "table" then return mon end
local def = data and data.pokemon and data.pokemon[mon.species]
if not def then return mon end
mon.name = def.name or mon.species
if def.types then mon.types = def.types end
if mon.dvs then
mon.gender = Mon.gender(def, mon.dvs,
{ species = mon.species, level = mon.level })
mon.shiny = Mon.isShiny(mon.dvs,
{ species = mon.species, def = def, level = mon.level })
if mon.species == Unown.SPECIES then
mon.unownLetter = Unown.letterFromDVs(mon.dvs)
else
mon.unownLetter = nil
end
end
return mon
end
-- Every screen that prints a mon without a Data lookup should go through here:
-- nickname if the player set one, otherwise the species display copy `name`.
-- Skipping `name` and jumping to `species` is how a swapped mon can still
-- read as ABRA on one menu and RAYQUAZA on another.
function Mon.displayName(mon)
if type(mon) ~= "table" then return "?" end
return mon.nickname or mon.name or mon.species or "?"
end
-- Party, boxes, both Day-Care sides, and a pending egg. Editor CONTINUE and
-- hydrate have to walk the same set: leaving dayCare.man.mon on the old
-- `name` is the ABRA bug in a second closet.
function Mon.eachSaveMon(save, fn)
if type(save) ~= "table" or type(fn) ~= "function" then return end
for _, mon in ipairs(save.party or {}) do fn(mon) end
for _, box in pairs(save.boxes or {}) do
if type(box) == "table" then
for _, mon in ipairs(box) do fn(mon) end
end
end
local dc = save.dayCare
if type(dc) == "table" then
if dc.man and dc.man.mon then fn(dc.man.mon) end
if dc.lady and dc.lady.mon then fn(dc.lady.mon) end
if dc.egg then fn(dc.egg) end
end
if save.daycare and save.daycare.mon then fn(save.daycare.mon) end
end
function Mon.syncSaveIdentity(save, data)
Mon.eachSaveMon(save, function(mon) Mon.syncIdentity(mon, data) end)
end
function Mon.refreshStats(mon, data)
if type(mon) ~= "table" then return mon end
local def = data and data.pokemon and data.pokemon[mon.species]
if not (def and def.baseStats) then return mon end
Mon.syncIdentity(mon, data)
-- engine/pokemon/move_mon.asm:1402
local stats = Mon.stats(def.baseStats, mon.dvs, mon.level or 1, mon.statExp)
mon.stats = stats
+38 -5
View File
@@ -44,6 +44,7 @@ local BATTLE_FIELDS = {
"sideToxic", "isGymLeader", "musicKind", "lastBall", "lockedBall",
"lowHealthAlarmDisabled", "lowHealthAlarmOn", "victoryMusicPlayed",
"endBattleText",
"playerPartyIndices",
}
local function partyIndex(party, mon)
@@ -91,6 +92,23 @@ local function integer(value, min, max)
and value >= (min or -math.huge) and value <= (max or math.huge)
end
local function exactIndexSet(indices, maxIndex, requireMember)
if type(indices) ~= "table" then return nil end
local count, keys = #indices, 0
for key in pairs(indices) do
keys = keys + 1
if not integer(key, 1, count) then return nil end
end
if keys ~= count or (requireMember and count == 0) then return nil end
local seen = {}
for i = 1, count do
local index = indices[i]
if not integer(index, 1, maxIndex) or seen[index] then return nil end
seen[index] = true
end
return seen
end
local function validateMoveList(data, moves)
if type(moves) ~= "table" then return false end
for _, move in ipairs(moves) do
@@ -200,6 +218,16 @@ function BattleCheckpoint.validate(game, checkpoint)
if type(party) ~= "table" or not validateBattler(game.data, model.player, #party) then
return nil, "invalid_content", "Player battle state is invalid."
end
local scopedIndices
if model.playerPartyIndices ~= nil then
if model.kind ~= "trainer" then
return nil, "invalid_checkpoint", "Battle party scope is invalid."
end
scopedIndices = exactIndexSet(model.playerPartyIndices, #party, true)
if not scopedIndices or not scopedIndices[model.player.index] then
return nil, "invalid_checkpoint", "Battle party scope is invalid."
end
end
if model.kind == "wild" then
if not validateMon(game.data, model.enemyMon)
or not validateBattler(game.data, model.enemy, 1) then
@@ -220,12 +248,15 @@ function BattleCheckpoint.validate(game, checkpoint)
end
end
for _, indices in ipairs({ model.participants, model.leveledUp }) do
if type(indices) ~= "table" then
local referenced = exactIndexSet(indices, #party, false)
if not referenced then
return nil, "invalid_checkpoint", "Battle party reference set is missing."
end
for _, index in ipairs(indices) do
if not integer(index, 1, #party) then
return nil, "invalid_checkpoint", "Battle party reference is invalid."
if scopedIndices then
for index in pairs(referenced) do
if not scopedIndices[index] then
return nil, "invalid_checkpoint", "Battle party reference is invalid."
end
end
end
end
@@ -276,7 +307,9 @@ function BattleCheckpoint.restore(game, checkpoint, copy)
local model = checkpoint.runtime.battle
local battle
if model.kind == "trainer" then
battle = BattleState.newTrainer(game, model.oppClass, model.partyIndex)
battle = BattleState.newTrainer(game, model.oppClass, model.partyIndex, {
playerPartyIndices = model.playerPartyIndices,
})
battle.enemyParty = assert(copy(model.enemyParty))
battle.enemyIndex = model.enemyIndex
else
+39 -15
View File
@@ -14,6 +14,14 @@ local MODULES = {
-- Optional for compatibility with developer and stale caches.
local OPTIONAL = { "audio", "palettes", "icons" }
-- Gold's extractor never writes these Gen 1 tables (RomExtractorGen2 has
-- maps/text/pokemon/items, not text_pointers / trainer_headers / field).
-- Desktop can still `require` Red's copies from the source tree, so Gold
-- Edit appeared to work there; an Android APK has only the per-version
-- cache, so Data:load used to throw on the first Gold Edit and take the
-- activity down. Empty tables are enough for seedDefaults / the editor.
local GEN2_OPTIONAL = { text_pointers = true, trainer_headers = true, field = true }
-- Vanilla defaults for rules exposed through the constants registry. A
-- value has to exist before a mod can patch it; each one matches the
-- engine's no-mod behavior, so seeding them changes nothing on a vanilla
@@ -99,7 +107,11 @@ end
-- Fills only what the cache is missing, so an importer that learns to
-- stamp one of these keys silently takes over from the engine.
function Data:seedDefaults()
local constants = self.constants
local constants = self.constants or {}
self.constants = constants
self.field = self.field or {}
self.maps = self.maps or {}
self.pokemon = self.pokemon or {}
for key, value in pairs(CONSTANT_DEFAULTS) do
if constants[key] == nil then constants[key] = copy(value) end
end
@@ -108,7 +120,11 @@ function Data:seedDefaults()
if constants.dexSize == nil then
local highest = 0
for _, def in pairs(self.pokemon) do
if def.dex and def.dex > highest then highest = def.dex end
-- Gold's pokemon.lua also carries growthRates / tmhmMoves / generation
-- scalars beside species rows.
if type(def) == "table" and def.dex and def.dex > highest then
highest = def.dex
end
end
constants.dexSize = highest
end
@@ -212,36 +228,41 @@ local function loadModule(dir, name)
if not chunk then return false, err end
return pcall(chunk)
end
local ok, mod = pcall(require, "data.generated." .. name)
if ok then return true, mod end
-- Fused PhysFS / Blue|Yellow prefix: load bytes from the active version's
-- cache explicitly when require cannot see the mounted tree.
local CacheFs = require("src.import.CacheFs")
local GameVersion = require("src.core.GameVersion")
local path = "data/generated/" .. name .. ".lua"
local bytes = CacheFs.readActive(path)
if type(bytes) == "string" then
local chunk, err = loadstring(bytes, "@" .. GameVersion.cachePrefix() .. path)
if not chunk then return false, err or mod end
return pcall(chunk)
local chunk = loadstring(bytes, "@" .. GameVersion.cachePrefix() .. path)
if chunk then
local ok, res = pcall(chunk)
if ok then return true, res end
end
end
return false, mod
local ok, mod = pcall(require, "data.generated." .. name)
if ok then return true, mod end
return false, nil
end
function Data:load()
local dir = os.getenv("POKEPORT_DATA_DIR")
local gen2 = require("src.core.GameVersion").generation() == 2
for _, name in ipairs(MODULES) do
local ok, mod = loadModule(dir, name)
if not ok then
if dir then
if gen2 and GEN2_OPTIONAL[name] then
self[name] = {}
elseif dir then
error(("missing data module '%s/%s.lua' (POKEPORT_DATA_DIR).\n(%s)")
:format(dir, name, mod))
else
error(("missing generated data module 'data/generated/%s.lua'.\n" ..
"Import the ROM again or rebuild developer data.\n(%s)")
:format(name, mod))
end
error(("missing generated data module 'data/generated/%s.lua'.\n" ..
"Import the ROM again or rebuild developer data.\n(%s)")
:format(name, mod))
else
self[name] = mod
end
self[name] = mod
end
for _, name in ipairs(OPTIONAL) do
local ok, mod = loadModule(dir, name)
@@ -280,11 +301,14 @@ function Data:unloadGenerated()
if not pristine[key] then self[key] = nil end
end
end
self._pristineKeys = nil
for _, name in ipairs(MODULES) do
package.loaded["data.generated." .. name] = nil
self[name] = nil
end
for _, name in ipairs(OPTIONAL) do
package.loaded["data.generated." .. name] = nil
self[name] = nil
end
end
+5 -2
View File
@@ -283,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.
@@ -588,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
+88 -2
View File
@@ -325,7 +325,8 @@ function HostShell.httpDownload(url, absPath, userAgent, accept, maxTime)
if type(absPath) ~= "string" or absPath == "" then return nil, "missing path" end
userAgent = userAgent or "gen1recomp"
if HostShell.haveCurl() then
local cmd = ("curl -fsSL --connect-timeout 15 --max-time %d ")
local cmd = ("curl -fsSL --proto =http,https --proto-redir =http,https "
.. "--connect-timeout 15 --max-time %d ")
:format(tonumber(maxTime) or 300)
.. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " "
if accept then
@@ -370,7 +371,8 @@ function HostShell.httpGet(url, userAgent, accept, maxTime)
-- BODY, and on the two services this talks to that body is the whole
-- diagnosis: GitHub's 403 says "API rate limit exceeded for <ip>", which
-- tells a user to wait rather than to go hunting for a broken index.
local cmd = ("curl -sSL --connect-timeout 10 --max-time %d ")
local cmd = ("curl -sSL --proto =http,https --proto-redir =http,https "
.. "--connect-timeout 10 --max-time %d ")
:format(tonumber(maxTime) or 40)
.. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " "
if accept then
@@ -415,4 +417,88 @@ function HostShell.httpGet(url, userAgent, accept, maxTime)
return body
end
-- POST returning success/failure. Strictly one-way: the response body is
-- discarded, only the HTTP status class is surfaced (postLog callers never
-- trust the reply). curl --data-binary reads the payload from a pipe, so a
-- large body never lands in the command line; the Android bridge has no POST
-- transport, and httpPost reports that instead of half-working through
-- httpDownload (a GET round-trip to a POST endpoint would be a lie).
function HostShell.httpPost(url, body, contentType, userAgent, maxTime)
if type(url) ~= "string" or url == "" then return nil, "missing url" end
if type(body) ~= "string" then return nil, "missing body" end
userAgent = userAgent or "gen1recomp"
if HostShell.haveCurl() then
-- io.popen is one-way on Lua/LuaJIT: its mode is "r" or "w", never
-- "rw". Stage the request body so the response can stay on a read
-- pipe. The staging directory comes from the OS temp contract, never
-- tmpnam(): the CRT's tmpnam() can return a name relative to the process
-- working directory, and a game installed under Program Files has no
-- writable CWD -- io.open would fail before curl ever runs and postLog
-- would silently drop the send. TEMP/TMP are per-user writable on
-- Windows; TMPDIR (with /tmp fallback) covers POSIX. No love.filesystem:
-- the sandbox-era transport stays on plain io/os.
local function stagingPath()
local dir = os.getenv("TEMP") or os.getenv("TMP")
if not dir or dir == "" then dir = os.getenv("TMPDIR") or "/tmp" end
local sep = dir:find("\\") and "\\" or "/"
return dir .. sep .. ("gen1recomp-post-%d.tmp"):format(
(os.time() % 1000000) * 100 + math.random(0, 99))
end
local bodyPath = stagingPath()
local bodyFile, bodyOpenErr = io.open(bodyPath, "wb")
if not bodyFile then
pcall(os.remove, bodyPath)
return nil, "could not create request body: " .. tostring(bodyOpenErr)
end
local bodyOk, bodyErr = pcall(function()
assert(bodyFile:write(body))
assert(bodyFile:close())
end)
if not bodyOk then
pcall(function() bodyFile:close() end)
pcall(os.remove, bodyPath)
return nil, "could not write body: " .. tostring(bodyErr)
end
-- --data-binary @<file> keeps the payload out of argv (command-line length
-- limits on Windows) and preserves every byte including trailing
-- newlines. The body is staged above because io.popen cannot be opened
-- for both writing and reading. No -f, matching httpGet: the response
-- body is discarded anyway, and curl's stderr carries the diagnosis.
local cmd = ("curl -sSL --proto =http,https --proto-redir =http,https "
.. "--connect-timeout 10 --max-time %d ")
:format(tonumber(maxTime) or 40)
.. "-X POST "
.. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " "
if contentType then
cmd = cmd .. "-H " .. HostShell.quote("Content-Type: " .. contentType) .. " "
end
cmd = cmd .. "-H " .. HostShell.quote("Content-Length: " .. tostring(#body)) .. " "
.. "--data-binary " .. HostShell.quote("@" .. bodyPath) .. " "
.. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " "
.. HostShell.quote(url) .. " 2>&1"
local pipe = HostShell.popen(cmd)
if not pipe then
pcall(os.remove, bodyPath)
return nil, "could not run curl"
end
local readOk, out = pcall(function() return pipe:read("*a") end)
HostShell.pclose(pipe)
pcall(os.remove, bodyPath)
if not readOk then
return nil, fetchError(url, nil, tostring(out))
end
local _, status, noise = splitCurlOutput(out)
if not status then return nil, fetchError(url, nil, noise) end
if status < 200 or status >= 300 then
return nil, fetchError(url, status, "log post rejected")
end
return true
end
if not haveBridge() then
return nil, "no network transport on this platform"
end
return nil, "no POST transport on this platform"
end
return HostShell
+49 -2
View File
@@ -832,9 +832,41 @@ local function tryMigrateLegacy(version, fs)
return id
end
-- Scan the filesystem for orphaned slot files under saves/<version>/ when options.lua
-- has no registered slots for this version (e.g. options.lua was reset or lost).
local function scanDiskSlots(version, fs)
if not fs then return nil end
local dir = "saves/" .. version
local slots = {}
if fs.getDirectoryItems and fs.getInfo and fs.getInfo(dir) then
local items = pcall(fs.getDirectoryItems, dir) and fs.getDirectoryItems(dir) or {}
local numbers = {}
for _, item in ipairs(items) do
local slotId = item:match("^(slot%d+)%.lua$")
if slotId then
local n = tonumber(slotId:match("%d+"))
table.insert(numbers, { id = slotId, num = n or 0 })
end
end
table.sort(numbers, function(a, b) return a.num < b.num end)
for _, item in ipairs(numbers) do
table.insert(slots, item.id)
end
else
for i = 1, 30 do
local slotId = "slot" .. i
local path = dir .. "/" .. slotId .. ".lua"
if fs.getInfo and fs.getInfo(path) then
table.insert(slots, slotId)
end
end
end
return #slots > 0 and slots or nil
end
-- Resolve (once per version per process) which slot in-game saves use: an
-- existing registry wins; otherwise a lazy legacy migration may create
-- slot1; otherwise false, meaning the flat legacy path.
-- slot1; otherwise auto-recover disk slots; otherwise false (flat legacy path).
local function ensureVersionSlots(version, fs)
if slotsChecked[version] then return end
slotsChecked[version] = true
@@ -848,7 +880,22 @@ local function ensureVersionSlots(version, fs)
activeSlotCache[version] = reg.active or reg.list[1]
return
end
activeSlotCache[version] = tryMigrateLegacy(version, fs) or false
local migrated = tryMigrateLegacy(version, fs)
if migrated then
activeSlotCache[version] = migrated
return
end
-- Auto-recovery: if options.lua lost its slot registry, scan disk for orphaned slot files
local recovered = scanDiskSlots(version, fs)
if recovered and #recovered > 0 then
opts.saveSlots = opts.saveSlots or {}
opts.saveSlots[version] = { list = recovered, active = recovered[1] }
SaveData.saveOptions(opts, fs)
activeSlotCache[version] = recovered[1]
Logger.info("auto-recovered %d save slot(s) for %s from disk", #recovered, version)
return
end
activeSlotCache[version] = false
end
-- (body for the forward-declared saveNames.) Resolves the ACTIVE slot for
File diff suppressed because it is too large Load Diff
+50
View File
@@ -0,0 +1,50 @@
-- ROM extraction, off the main thread. The extractor's require closure needs
-- only love.filesystem, love.image, love.math and love.system, none of which
-- are main-thread-only, so it runs here instead of as a coroutine the frame
-- loop resumed for 8ms out of every 16.7ms.
--
-- The caller clears the stale cache and writes the completion marker itself;
-- this only fills the tree between those two steps, so the "marker appears
-- last" order isReady() depends on stays on one thread.
require("love.filesystem")
require("love.image")
require("love.math")
require("love.system")
require("love.timer")
local version, prefix, romData, progressName, resultName = ...
local progressChannel = love.thread.getChannel(progressName)
local resultChannel = love.thread.getChannel(resultName)
-- RomExtractor:tick fires per item, thousands of times per import; a channel
-- push each would cost more than the work it reports.
local PROGRESS_HZ = 20
local ok, err = pcall(function()
local CacheFs = require("src.import.CacheFs")
CacheFs.prefix = prefix
local manifest = require("src.import.RomManifest").decode(version)
local RomExtractor = version == "gold"
and require("src.import.RomExtractorGen2")
or require("src.import.RomExtractor")
local lastPush, lastStage = 0, nil
local extractor = RomExtractor.new(romData, manifest,
function(progress, total, stage, current, stageTotal)
local now = love.timer.getTime()
-- Stage changes always go through, or the caption goes stale.
if stage ~= lastStage or now - lastPush >= 1 / PROGRESS_HZ then
lastPush, lastStage = now, stage
progressChannel:push({
progress = progress, total = total, stage = stage,
current = current, stageTotal = stageTotal,
})
end
end)
extractor:run()
end)
resultChannel:push({ ok = ok, error = ok and nil or tostring(err) })
+516 -139
View File
@@ -630,6 +630,7 @@ end
local function modStatusColor(status)
if status == "ok" then return Strings("Ready"), PAL.green end
if status == "needs_import" then return Strings("Import required"), PAL.yellow end
if status == "conflict" then return Strings("Conflict"), PAL.red end
-- not a fault: the mod is intact, this is simply not a game it is for
-- (src/mods/ModTargets.lua)
@@ -638,14 +639,8 @@ local function modStatusColor(status)
end
-- MODS panel scope row: which game the list is answering for, plus dedicated Profile control (cycle + gear).
local function buildModScopeRow(imp, x, y, w, m)
local function modScopeOptions(imp)
local GameVersion = require("src.core.GameVersion")
local LauncherMods = require("src.mods.LauncherMods")
local h = math.max(Kit.tapMin(), math.floor(26 * m.s))
local gap = math.floor(6 * m.s)
local label = Strings("Show for:")
Kit.text("small", label, x, y + (h - Kit.textHeight("small")) / 2, PAL.muted)
local cx = x + Kit.textWidth("small", label) + math.floor(10 * m.s)
local options = { { id = nil, label = Strings("All games") } }
for _, version in ipairs(GameVersion.ORDER) do
if imp.ready and imp.ready[version] then
@@ -653,6 +648,33 @@ local function buildModScopeRow(imp, x, y, w, m)
{ id = version, label = GameVersion.info(version).label }
end
end
return options
end
local function modScopeCurrentLabel(imp, options)
for _, opt in ipairs(options) do
if imp.modScope == opt.id then return opt.label end
end
return options[1] and options[1].label or Strings("All games")
end
local function modScopeChipsWidth(options, gap, m)
local need = 0
for i, opt in ipairs(options) do
need = need + Kit.textWidth("micro", opt.label) + math.floor(18 * m.s)
if i < #options then need = need + gap end
end
return need
end
local function buildModScopeRow(imp, x, y, w, m)
local LauncherMods = require("src.mods.LauncherMods")
local h = math.max(Kit.tapMin(), math.floor(26 * m.s))
local gap = math.floor(6 * m.s)
local label = Strings("Show for:")
Kit.text("small", label, x, y + (h - Kit.textHeight("small")) / 2, PAL.muted)
local cx = x + Kit.textWidth("small", label) + math.floor(10 * m.s)
local options = modScopeOptions(imp)
-- Dedicated Profile control section (cycle button + gear icon button) on right side of Scope Bar
local _, activeProf = LauncherMods.getProfiles()
@@ -690,9 +712,12 @@ local function buildModScopeRow(imp, x, y, w, m)
})
if #options >= 2 then
for _, opt in ipairs(options) do
local cw = Kit.textWidth("micro", opt.label) + math.floor(18 * m.s)
if cx + cw <= profX - gap then
local avail = profX - gap - cx
-- Chips stay when they all fit; otherwise they used to be skipped and
-- vanish off the portrait edge. Collapse to one menu in that case only.
if modScopeChipsWidth(options, gap, m) <= avail then
for _, opt in ipairs(options) do
local cw = Kit.textWidth("micro", opt.label) + math.floor(18 * m.s)
if Kit.chip(cx, y, cw, h, opt.label, imp.modScope == opt.id, PAL.lineStrong,
"mod-scope-" .. tostring(opt.id or "all")) then
local want = opt.id
@@ -701,6 +726,15 @@ local function buildModScopeRow(imp, x, y, w, m)
end
cx = cx + cw + gap
end
elseif avail > 0 then
local shown = Kit.ellipsize("micro", modScopeCurrentLabel(imp, options),
math.max(0, avail - math.floor(18 * m.s)))
local cw = math.min(avail,
Kit.textWidth("micro", shown) + math.floor(18 * m.s))
if Kit.chip(cx, y, cw, h, shown, true, PAL.lineStrong, "mod-scope-menu") then
queueAction(imp, "mod-scope-menu",
function() imp._modScopePopup = true end)
end
end
end
return h + math.floor(8 * m.s)
@@ -740,8 +774,8 @@ local function setPage(imp, key, v)
imp._pages[key] = v
end
-- A hand-drawn X, for the same reason drawCheck exists below: the UI font has
-- no guaranteed glyph, and the launcher ships no icon asset for it.
-- A hand-drawn X / check: the UI font has no guaranteed glyph for either,
-- and the launcher ships no icon asset for them.
local function drawCross(x, y, size, color)
love.graphics.push("all")
love.graphics.setColor(color)
@@ -752,11 +786,66 @@ local function drawCross(x, y, size, color)
love.graphics.pop()
end
local function drawCheck(x, y, size, color)
love.graphics.push("all")
love.graphics.setColor(color)
love.graphics.setLineWidth(math.max(2.2, size * 0.17))
love.graphics.setLineJoin("bevel")
love.graphics.line(
x + size * 0.02, y + size * 0.52,
x + size * 0.38, y + size * 0.80,
x + size * 1.015, y + size * 0.18)
love.graphics.pop()
end
-- ------------------------------------------------------------- header
-- Rail, logo row (settings and quit on the right), tab bar.
-- Returns the y at which content may start. Its vertical arithmetic is
-- mirrored by headerHeight() at the bottom of this file (the short-window
-- scroll decision needs the height before anything draws) -- keep in sync.
-- Header chrome is fixed: the same six tabs, the same gear and Quit, every
-- frame. Their tab rows, opts tables and action closures are built once
-- instead of 60 times a second -- only `active`, `image` and the queued
-- action are written per frame.
local HEADER_TABS = {
{ id = "red", key = "tab-red", letter = "R", color = PAL.railRed },
{ id = "blue", key = "tab-blue", letter = "B", color = PAL.railBlue },
{ id = "yellow", key = "tab-yellow", letter = "Y", color = PAL.railGold },
{ id = "gold", key = "tab-gold", letter = "G", color = PAL.railAmber },
{ id = "mods", key = "tab-mods" },
{ id = "find", key = "tab-find" },
}
for _, t in ipairs(HEADER_TABS) do
t.opts = { face = "tab", font = "tab", color = t.color, letter = t.letter }
end
local QUIT_INK_HOT = { 0, 0, 0, 1 }
local QUIT_INK_REST = { 1, 1, 1, 0.85 }
-- Keyed off the launcher instance so the closures die with it.
local function headerChrome(imp)
local c = imp._headerChrome
if c then return c end
c = {
gear = { face = "invert",
action = function() imp:_openSettings() end },
quit = { face = "invert",
action = function() imp:_quitApp() end,
drawFn = function(x, y, w, h, hot)
local pad = math.floor(w * 0.32)
drawCross(x + pad, y + pad, w - 2 * pad,
hot and QUIT_INK_HOT or QUIT_INK_REST)
end },
tab = {},
}
for _, t in ipairs(HEADER_TABS) do
local id = t.id
c.tab[id] = function() imp:_switchTab(id) end
end
imp._headerChrome = c
return c
end
local function buildHeader(imp, m)
local y = m.top
Theme.versionRail(m.x, y, m.w, m.railH)
@@ -815,20 +904,11 @@ local function buildHeader(imp, m)
imp._gearIcon = imp._gearIcon
or love.graphics.newImage("assets/launcher/gear.png")
rx = rx - gear
btn(imp, rx, by, gear, gear, "gear", "", {
face = "invert", image = imp._gearIcon,
action = function() imp:_openSettings() end,
})
local chrome = headerChrome(imp)
chrome.gear.image = imp._gearIcon
btn(imp, rx, by, gear, gear, "gear", "", chrome.gear)
btn(imp, quitX, by, gear, gear, "quit", "", {
face = "invert",
action = function() imp:_quitApp() end,
drawFn = function(x, y, w, h, hot)
local pad = math.floor(w * 0.32)
drawCross(x + pad, y + pad, w - 2 * pad,
hot and { 0, 0, 0, 1 } or { 1, 1, 1, 0.85 })
end,
})
btn(imp, quitX, by, gear, gear, "quit", "", chrome.quit)
-- The self-update control lives in the FOOTER next to the BCG mark (small,
-- out of the wordmark's way -- it used to overlap the logo on a phone). It
@@ -846,14 +926,8 @@ local function buildHeader(imp, m)
-- the fill when active, the same rule the buttons follow. Yellow stays the
-- bright cart gold; Gold (Gen 2) uses the deeper amber so the two do not
-- collide.
local tabs = {
{ id = "red", letter = "R", color = PAL.railRed },
{ id = "blue", letter = "B", color = PAL.railBlue },
{ id = "yellow", letter = "Y", color = PAL.railGold },
{ id = "gold", letter = "G", color = PAL.railAmber },
{ id = "mods", icon = imp._modsIcon },
{ id = "find", icon = imp._findIcon },
}
local tabs = HEADER_TABS
tabs[5].icon, tabs[6].icon = imp._modsIcon, imp._findIcon
local tabH = m.chip
local tx = m.x + m.pad
local ty = y + math.floor(6 * m.s)
@@ -862,18 +936,16 @@ local function buildHeader(imp, m)
local tabGap = math.floor(6 * m.s)
local tabRowGap = math.floor(4 * m.s)
for _, t in ipairs(tabs) do
local active = imp.tab == t.id
local key = "tab-" .. t.id
local w = tabH
if tx > tabLeft and tx + w > tabRight then
tx = tabLeft
ty = ty + tabH + tabRowGap
end
btn(imp, tx, ty, w, tabH, key, "", {
face = "tab", font = "tab", color = t.color, active = active,
image = t.icon, letter = t.letter,
action = function() imp:_switchTab(t.id) end,
})
local o = t.opts
o.active = imp.tab == t.id
o.image = t.icon
o.action = chrome.tab[t.id]
btn(imp, tx, ty, w, tabH, t.key, "", o)
tx = tx + w + tabGap
end
@@ -1316,20 +1388,32 @@ local function buildGamePanel(imp, x, y, w, availH, m, version)
or tostring(version)
local ready = (not locked) and imp.ready[version] or false
-- title + status tag
-- title + status tag. Ready is a check chip (the font has no tick glyph);
-- missing ROM stays a yellow "ROM REQUIRED" tag so it still reads as an action.
local titleH = Kit.textHeight("title")
Kit.text("title", Kit.ellipsize("title", gameName, w * 0.6), x, y, PAL.heading)
local tagText, tagCol
if ready then tagText, tagCol = Strings("GOOD TO GO"), PAL.green
elseif imp.baseRoms and imp.baseRoms[version] then
tagText, tagCol = Strings("ROM FOUND"), PAL.green
elseif locked then tagText, tagCol = Strings("COMING SOON"), PAL.steel
else tagText, tagCol = Strings("ROM REQUIRED"), PAL.yellow end
local tagW = Kit.textWidth("micro", tagText) + math.floor(18 * m.s)
local tagH = Kit.textHeight("micro") + math.floor(10 * m.s)
local tagX = x + Kit.textWidth("title", Kit.ellipsize("title", gameName, w * 0.6))
+ math.floor(12 * m.s)
Kit.tag(tagX, y + (titleH - tagH) / 2, tagW, tagH, tagText, tagCol)
local tagY = y + (titleH - tagH) / 2
local tagW, tagCol
if ready then
tagCol = PAL.green
tagW = tagH
if love.graphics then
Theme.strokeRounded(tagX, tagY, tagW, tagH, tagCol, 0.7, 1)
local ck = math.floor(tagH * 0.55)
drawCheck(tagX + (tagW - ck) / 2, tagY + (tagH - ck) / 2, ck, tagCol)
end
else
local tagText
if imp.baseRoms and imp.baseRoms[version] then
tagText, tagCol = Strings("ROM FOUND"), PAL.green
elseif locked then tagText, tagCol = Strings("COMING SOON"), PAL.steel
else tagText, tagCol = Strings("ROM REQUIRED"), PAL.yellow end
tagW = Kit.textWidth("micro", tagText) + math.floor(18 * m.s)
Kit.tag(tagX, tagY, tagW, tagH, tagText, tagCol)
end
if ready then
local hint = Strings("(PRESS THE CART TO PLAY)")
local hintX = tagX + tagW + math.floor(10 * m.s)
@@ -1337,8 +1421,11 @@ local function buildGamePanel(imp, x, y, w, availH, m, version)
Kit.text("micro", Kit.ellipsize("micro", hint, hintW), hintX,
y + (titleH - Kit.textHeight("micro")) / 2, PAL.heading)
end
local cy = y + titleH + math.floor(12 * m.s)
local remaining = availH - (titleH + math.floor(12 * m.s))
-- Extra gap under the title when the cart is showing: 12px left the 3D
-- shell sitting on the hairline. Scaled, and still small on a phone.
local afterTitle = math.floor((ready and 22 or 12) * m.s)
local cy = y + titleH + afterTitle
local remaining = availH - (titleH + afterTitle)
local gap = m.gap
local lx, lw, rx2, rw
@@ -1408,6 +1495,20 @@ end
-- gets whatever width the previous ones left, and the first segment that has
-- to ellipsize ends the line. Lets the download count sit green inside an
-- otherwise muted stats line without two competing ellipsis passes.
-- A row's control key is a pure function of its id, but concatenating it per
-- visible row per frame is ~1200 strings a second. Memoised on the launcher,
-- NOT on the entry: index entries are the same tables ModIndex.writeCache
-- persists into options.modIndexCache, and view state must not ride along.
local function rowKeyFor(imp, prefix, id)
local keys = imp._rowKeys
if not keys then keys = {}; imp._rowKeys = keys end
local byPrefix = keys[prefix]
if not byPrefix then byPrefix = {}; keys[prefix] = byPrefix end
local key = byPrefix[id]
if not key then key = prefix .. tostring(id); byPrefix[id] = key end
return key
end
local function segLine(fontName, segs, x, y, maxW)
local sx = x
for _, seg in ipairs(segs) do
@@ -1433,6 +1534,55 @@ local function sortDefs()
}
end
-- Sorting is decorate-sort-undecorate: the key is computed once per entry
-- instead of the 2*n*log(n) times a comparator that derives it would, and the
-- comparator itself is a module-level function so no closure is allocated per
-- comparison. Measured on a synthetic index: 500 entries went from 8,964 key
-- computations and 4,482 closures to 500 and none.
local sortAsc = true
local function decCompare(a, b)
if a.k ~= b.k then
if sortAsc then return a.k < b.k end
return a.k > b.k -- data sorts newest / most popular first
end
return a.tie < b.tie
end
-- Fill `scratch` with one { e, k, tie } slot per entry, reusing the slots.
local function decorate(scratch, src, keyOf, tieOf)
local n = #src
for i = 1, n do
local e = src[i]
local slot = scratch[i]
if not slot then slot = {}; scratch[i] = slot end
slot.e, slot.tie = e, tieOf(e)
slot.k = keyOf(e, slot.tie)
end
for i = #scratch, n + 1, -1 do scratch[i] = nil end
return n
end
local function undecorate(scratch, n)
local out = {}
for i = 1, n do out[i] = scratch[i].e end
return out
end
-- While results are still streaming in, re-ordering on every arrival re-sorts
-- the whole list every frame and makes rows jump under the reader. Hold the
-- current order this long and take the change in one pass.
local RESORT_DEBOUNCE = 0.25
-- True when the cached order is still good. `rev` is only part of the key
-- for a stats-dependent sort: Name order does not depend on release data, so
-- a stats arrival used to invalidate a sort whose result could not change.
local function sortCacheOk(cache, src, key, rev, pending)
if not (cache and cache.src == src and cache.key == key) then return false end
if cache.rev == rev then return true end
return pending and (Kit.time - (cache.at or 0)) < RESORT_DEBOUNCE
end
local function currentSort(imp)
local sortKey = imp.modSort
if sortKey == nil then
@@ -1446,20 +1596,6 @@ local function currentSort(imp)
return sortKey
end
-- A hand-drawn check mark: the UI font has no guaranteed glyph for one, and
-- a tofu box on the "you already have this" signal would be worse than none.
local function drawCheck(x, y, size, color)
love.graphics.push("all")
love.graphics.setColor(color)
love.graphics.setLineWidth(math.max(2.2, size * 0.17))
love.graphics.setLineJoin("bevel")
love.graphics.line(
x + size * 0.02, y + size * 0.52,
x + size * 0.38, y + size * 0.80,
x + size * 1.015, y + size * 0.18)
love.graphics.pop()
end
-- One compact coloured checkbox for each game. The cartridge colour carries
-- the game identity even when the row is narrow.
local function modGameCheckbox(x, y, size, checked, game, id)
@@ -1588,16 +1724,18 @@ local function buildModsPanel(imp, x, y, w, availH, m)
-- per frame (with lowercased-string allocations in the comparator) fed the
-- GC for nothing. Cache the sorted array, keyed on the list identity, the
-- sort mode, and the update-info revision the fetch pump bumps.
local statsSort = sortKey ~= "name"
local rev = statsSort and (imp._modUpdateRev or 0) or 0
local cache = imp._modSortCache
if cache and cache.src == mods and cache.n == #mods
and cache.key == sortKey and cache.rev == (imp._modUpdateRev or 0) then
if cache and cache.n == #mods
and sortCacheOk(cache, mods, sortKey, rev, imp._modInfoFetch ~= nil) then
mods = cache.list
else
local sorted = {}
for i, v in ipairs(mods) do sorted[i] = v end
table.sort(sorted, function(a, b)
local function value(mod)
if sortKey == "name" then return (mod.name or ""):lower() end
local scratch = imp._modSortScratch or {}
imp._modSortScratch = scratch
local n = decorate(scratch, mods,
function(mod, tie)
if sortKey == "name" then return tie end
local info = mod.github and mod.github ~= "" and imp:_modUpdateInfo(mod.id)
if sortKey == "popularity" then
return info and info.downloads and info.downloads.total or -1
@@ -1605,16 +1743,13 @@ local function buildModsPanel(imp, x, y, w, availH, m)
local date = info and info.dates
if sortKey == "release" then return date and date.first or "0000-00-00" end
return date and date.latest or "0000-00-00"
end
local va, vb = value(a), value(b)
if va ~= vb then
if sortKey == "name" then return va < vb end
return va > vb -- data sorts newest / most popular first
end
return (a.name or ""):lower() < (b.name or ""):lower()
end)
imp._modSortCache = { src = imp.mods, n = #mods, key = sortKey,
rev = imp._modUpdateRev or 0, list = sorted }
end,
function(mod) return (mod.name or ""):lower() end)
sortAsc = sortKey == "name"
table.sort(scratch, decCompare)
local sorted = undecorate(scratch, n)
imp._modSortCache = { src = mods, n = #mods, key = sortKey,
rev = rev, at = Kit.time, list = sorted }
mods = sorted
end
@@ -1637,7 +1772,9 @@ local function buildModsPanel(imp, x, y, w, availH, m)
local contentH = shown * rowH + math.max(0, shown - 1) * gap
local scrollMax = math.max(0, contentH - listH)
local scroll = clamp(imp.modScroll or 0, 0, scrollMax)
imp._modListRect = { x = x, y = listTop, w = w, h = listH }
local lr = imp._modListRect
if not lr then lr = {}; imp._modListRect = lr end
lr.x, lr.y, lr.w, lr.h = x, listTop, w, listH
imp._modScrollMax = scrollMax
if scrollMax > 0 and (Kit.wheelY or 0) ~= 0 and Kit.hit(x, listTop, w, listH) then
scroll = clamp(scroll - Kit.wheelY * math.floor(48 * m.s), 0, scrollMax)
@@ -1653,7 +1790,7 @@ local function buildModsPanel(imp, x, y, w, availH, m)
for i = first, last do
local mod = mods[i]
local ry = listTop + (i - first) * (rowH + gap) - scroll
local rowKey = "mod-row-" .. mod.id
local rowKey = rowKeyFor(imp, "mod-row-", mod.id)
local isFullyDisabled = true
if mod.enabledByVersion then
for _, on in pairs(mod.enabledByVersion) do
@@ -1845,30 +1982,30 @@ local function buildFindPanel(imp, x, y, w, availH, m)
-- Same caching rule as the MODS tab: the comparator allocates, so only
-- re-sort when the inputs actually change.
local statsSort = sortKey ~= "name"
local rev = statsSort and (imp._findStatsRev or 0) or 0
local fcache = imp._findSortCache
if fcache and fcache.src == rows and fcache.key == sortKey
and fcache.rev == (imp._findStatsRev or 0) then
if sortCacheOk(fcache, rows, sortKey, rev, imp._findStatsPending ~= nil) then
rows = fcache.list
else
local sorted = {}
for i, v in ipairs(rows) do sorted[i] = v end
table.sort(sorted, function(a, b)
local function value(entry)
if sortKey == "name" then return (entry.title or entry.id or ""):lower() end
local stats = imp:_findStats(entry)
local scratch = imp._findSortScratch or {}
imp._findSortScratch = scratch
local n = decorate(scratch, rows,
function(entry, tie)
if sortKey == "name" then return tie end
-- The CACHED read, never the requesting one: a sort must not queue a
-- fetch for every entry in the index (see _findStatsCached).
local stats = imp:_findStatsCached(entry)
if sortKey == "popularity" then return stats and stats.total or -1 end
if sortKey == "release" then return stats and stats.first or "0000-00-00" end
return stats and stats.latest or "0000-00-00"
end
local va, vb = value(a), value(b)
if va ~= vb then
if sortKey == "name" then return va < vb end
return va > vb
end
return (a.title or a.id or ""):lower() < (b.title or b.id or ""):lower()
end)
imp._findSortCache = { src = rows, key = sortKey,
rev = imp._findStatsRev or 0, list = sorted }
end,
function(entry) return (entry.title or entry.id or ""):lower() end)
sortAsc = sortKey == "name"
table.sort(scratch, decCompare)
local sorted = undecorate(scratch, n)
imp._findSortCache = { src = rows, key = sortKey, rev = rev,
at = Kit.time, list = sorted }
rows = sorted
end
@@ -1897,7 +2034,7 @@ local function buildFindPanel(imp, x, y, w, availH, m)
for i = first, last do
local entry = rows[i]
local ry = listTop + (i - first) * (rowH + gap)
local rowKey = "find-row-" .. entry.id
local rowKey = rowKeyFor(imp, "find-row-", entry.id)
-- The whole row is the control: it opens the per-mod popup where
-- Install / Details / Source moved. The only inline signal left is a
-- green check when the mod is already installed.
@@ -1930,8 +2067,15 @@ local function buildFindPanel(imp, x, y, w, availH, m)
love.graphics.draw(image, Theme.snap(px), Theme.snap(ly), 0, s, s)
else
Theme.stroke(px, ly, thumb, thumb, PAL.line, Theme.A.hairline, 1)
Kit.textCenter("micro", "MOD", px,
ly + (thumb - Kit.textHeight("micro")) / 2, thumb, PAL.faint)
-- A thumbnail still downloading and one that will never arrive drew the
-- same dead box, so a slow index looked broken. Spin while it is in
-- flight; only fall back to the wordmark once it has resolved.
if imp:_findThumbPending(entry.id) then
Kit.spinner(px + thumb / 2, ly + thumb / 2, thumb * 0.28)
else
Kit.textCenter("micro", "MOD", px,
ly + (thumb - Kit.textHeight("micro")) / 2, thumb, PAL.faint)
end
end
local bx = px + thumb + math.floor(10 * m.s)
@@ -1965,10 +2109,35 @@ local function buildFindPanel(imp, x, y, w, availH, m)
segs[#segs + 1] = { " - " .. table.concat(rest, " - "), baseCol }
end
segLine("small", segs, bx, by2, bw)
-- The stats line used to simply be absent until the release check landed,
-- so rows silently changed under the reader and a slow check was
-- indistinguishable from a mod with no data. Say which it is, the way
-- the MODS tab already does on its own rows.
if not stats and imp:_findStatsPendingFor(entry.id) then
local sw = Kit.textWidth("small", segs[1][1]) + math.floor(12 * m.s)
local dh = Kit.textHeight("small")
Loader.dot(bx + sw, by2, dh)
Kit.text("small", Strings("Checking..."),
bx + sw + dh + math.floor(6 * m.s), by2, PAL.muted)
end
end
local pagerY = listTop + (last - first + 1) * (rowH + gap)
setPage(imp, "find", Kit.pager(x, pagerY, w, cur, #rows, perPage, "find"))
-- Aggregate progress. Enrichment happens a page at a time and each row says
-- so for itself, but with nothing summarising it the panel looked idle while
-- work was in flight. Only drawn while something is actually pending.
local waiting = imp:_findStatsPendingCount()
if waiting > 0 then
local py = pagerY + math.max(Kit.tapMin(), math.floor(30 * m.s))
+ math.floor(4 * m.s)
local dh = Kit.textHeight("micro")
Loader.dot(x, py, dh)
Kit.text("micro", Strings("Checking %d of %d on this page...",
waiting, last - first + 1),
x + dh + math.floor(6 * m.s), py, PAL.muted)
end
end
-- ------------------------------------------------------------------ footer
@@ -1978,21 +2147,47 @@ local TRUST_WARNING = "if you did not get this from bryanthaboi's github "
.. "it might have been tampered with. go to the discord to verify "
.. COMMUNITY_URL .. " (or click the logo above)"
-- Mark + optional updater + Patch notes. Chips match the mark's 22px
-- height so they do not read as bigger than the logo; the row can still
-- be tapMin tall for spacing. On a phone the notes chip drops onto a
-- second row rather than overflowing the mark.
local function footerLayout(imp, m, markW)
local markH = math.floor(22 * m.s)
local rowH = math.max(markH, Kit.tapMin())
local notesLabel = Strings("Patch notes")
-- Tight chip, still enough for Kit.button's labelInset so the words survive.
local chipPad = math.floor(24 * m.s)
local nw = Kit.textWidth("micro", notesLabel) + chipPad
local upStatus, upLabel, upAction, upGlow = LauncherView._updateControl(imp)
local uw = upStatus
and (Kit.textWidth("micro", upLabel) + chipPad) or 0
local gap = math.floor(10 * m.s)
local inner = m.w - 2 * m.pad
local topW = (markW or 0) + (upStatus and (gap + uw) or 0) + gap + nw
return {
rowH = rowH, chipH = markH, gap = gap,
notesLabel = notesLabel, nw = nw,
upStatus = upStatus, upLabel = upLabel, upAction = upAction, upGlow = upGlow,
uw = uw, wrap = topW > inner,
}
end
-- Pinned to the bottom of the window; returns the y it starts at, so the
-- panels above know how much room they have.
-- Deliberately compact: at a large UI scale the footer is pure overhead
-- competing with the panel for a short window's height, so the mark and the
-- link share one line and the trust warning is capped at a single line.
local function footerHeight(imp, m)
-- Top pad + mark/update row + gap + the FULL wrapped trust message +
-- bottom pad. The message wraps to as many lines as it needs: truncating
-- a trust warning defeats its purpose, and the bottom pad is not optional
-- either (without it the last line sits flush on the window edge and its
-- lower half clips off). The row is tapMin tall because the small update
-- button rides beside the mark.
local rowH = math.max(math.floor(22 * m.s), Kit.tapMin())
return math.floor(8 * m.s) + rowH + math.floor(6 * m.s)
+ Kit.wrapHeight("micro", TRUST_WARNING, m.contentW)
-- Top pad + mark/update row + optional notes wrap row + gap + the FULL
-- wrapped trust message + bottom pad. The message wraps to as many lines
-- as it needs: truncating a trust warning defeats its purpose, and the
-- bottom pad is not optional either (without it the last line sits flush
-- on the window edge and its lower half clips off). The row is tapMin
-- tall because the small update button rides beside the mark.
local f = footerLayout(imp, m, math.floor(130 * m.s))
local h = math.floor(8 * m.s) + f.rowH + math.floor(6 * m.s)
if f.wrap then h = h + f.chipH + math.floor(6 * m.s) end
return h + Kit.wrapHeight("micro", TRUST_WARNING, m.contentW)
+ math.floor(8 * m.s)
end
@@ -2009,19 +2204,17 @@ local function buildFooter(imp, m, y)
local bw, bh = imp.bcg:getDimensions()
local scale = math.min((130 * m.s) / bw, (22 * m.s) / bh)
local dw, dh = bw * scale, bh * scale
local rowH = math.max(math.floor(22 * m.s), Kit.tapMin())
-- The mark and the small self-update control share the row, centred as a
-- group. The updater moved down here from the header, where it overlapped
-- the wordmark on a phone; small on purpose, its glow still carries the
-- "act on me" signal.
local upStatus, upLabel, upAction, upGlow = LauncherView._updateControl(imp)
-- Kit.button insets its label 16*scale per side, so the width must budget
-- more than that or the label ellipsizes ("Check for updat...").
local uw = upStatus
and (Kit.textWidth("micro", upLabel) + math.floor(36 * m.s)) or 0
local groupW = dw + (upStatus and (math.floor(10 * m.s) + uw) or 0)
local bx = m.x + math.floor((m.w - groupW) / 2)
local f = footerLayout(imp, m, dw)
local rowH, gap, chipH = f.rowH, f.gap, f.chipH
-- The mark, the small self-update control, and Patch notes share the row,
-- centred as a group. The updater moved down here from the header, where
-- it overlapped the wordmark on a phone; small on purpose, its glow still
-- carries the "act on me" signal. Notes wrap under the mark on a phone.
local topW = dw + (f.upStatus and (gap + f.uw) or 0)
if not f.wrap then topW = topW + gap + f.nw end
local bx = m.x + math.floor((m.w - topW) / 2)
local my = cy + math.floor((rowH - dh) / 2)
local chipY = cy + math.floor((rowH - chipH) / 2)
local hot = Kit.hover(bx, my, dw, dh)
love.graphics.setShader(imp.invertShader)
love.graphics.setColor(1, 1, 1, hot and 1 or 0.85)
@@ -2031,14 +2224,30 @@ local function buildFooter(imp, m, y)
if Kit.press(bx, my, dw, dh) then
queueAction(imp, "bcg", function() love.system.openURL(COMMUNITY_URL) end)
end
if upStatus then
btn(imp, bx + dw + math.floor(10 * m.s), cy, uw, rowH, "updater",
upLabel, {
kind = upGlow and "warn" or "ghost", font = "micro",
glow = upGlow, action = upAction,
local cx = bx + dw
if f.upStatus then
cx = cx + gap
btn(imp, cx, chipY, f.uw, chipH, "updater",
f.upLabel, {
kind = f.upGlow and "warn" or "ghost", font = "micro",
glow = f.upGlow, action = f.upAction,
})
cx = cx + f.uw
end
local function notesBtn(x, y)
btn(imp, x, y, f.nw, chipH, "patch-notes", f.notesLabel, {
kind = "ghost", font = "micro",
action = function() imp._appPatchNotes = true end,
})
end
if f.wrap then
cy = cy + rowH + gap
notesBtn(m.x + math.floor((m.w - f.nw) / 2), cy)
cy = cy + chipH + math.floor(6 * m.s)
else
notesBtn(cx + gap, chipY)
cy = cy + rowH + math.floor(6 * m.s)
end
cy = cy + rowH + math.floor(6 * m.s)
-- The trust message wraps in full, each line centred under the mark, and
-- the URL inside it IS the link -- no separate link floating elsewhere.
-- font:getWrap never splits an unspaced word, so the URL stays whole on
@@ -2543,6 +2752,34 @@ local function buildSortModal(imp, m)
action = function() imp._sortPopup = nil end })
end
-- Game-scope chooser used when the Show-for chips cannot all fit on the
-- mods toolbar (portrait phones). Same options as the chip row.
local function buildModScopeModal(imp, m)
local options = modScopeOptions(imp)
local pad = math.floor(18 * m.s)
local w = math.floor(360 * m.s)
local gap = math.floor(8 * m.s)
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s)
+ #options * (m.btnH + gap) + m.btnH + pad
local px, py, pw = modalPanel(m, w, h)
local cy = py + pad
Kit.text("button", Strings("Show for"), px + pad, cy, PAL.heading)
cy = cy + Kit.textHeight("button") + math.floor(12 * m.s)
for _, opt in ipairs(options) do
local key = tostring(opt.id or "all")
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "scopepop-" .. key, opt.label, {
kind = (imp.modScope == opt.id) and "primary" or "ghost", font = "small",
action = function()
imp:_setModScope(opt.id)
imp._modScopePopup = nil
end })
cy = cy + m.btnH + gap
end
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "scopepop-close",
Strings("Close"), { font = "small",
action = function() imp._modScopePopup = nil end })
end
-- Category filter for FIND MODS. Two columns, because an index can list
-- enough categories to overflow a single stacked column on a short window.
local function buildFilterModal(imp, m)
@@ -2645,11 +2882,14 @@ local function buildModActionsModal(imp, m)
local hasGit = mod.github and mod.github ~= ""
local depSpecs = mod.dependencySpecs or (mod.manifest and mod.manifest.dependencySpecs)
local hasDeps = depSpecs and #depSpecs > 0
local imports = mod.imports or mod.requiredImports
local hasImports = imports and #imports > 0
local info = hasGit and imp:_modUpdateInfo(mod.id)
local pad = math.floor(18 * m.s)
local w = math.floor(440 * m.s)
local gap = math.floor(8 * m.s)
local nBtns = (hasGit and 2 or 0) + (hasDeps and 1 or 0) + 2
local nBtns = (hasGit and 2 or 0) + (hasDeps and 1 or 0)
+ (hasImports and 1 or 0) + 2
local h = pad + Kit.textHeight("button") + math.floor(4 * m.s)
+ Kit.textHeight("small") + math.floor(12 * m.s)
+ nBtns * (m.btnH + gap) - gap + pad
@@ -2699,6 +2939,19 @@ local function buildModActionsModal(imp, m)
end })
cy = cy + m.btnH + gap
end
if hasImports then
local missing = tonumber(mod.missingRequiredImports) or 0
local label = missing > 0
and Strings("Imported files (%d required)", missing)
or Strings("Imported files")
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "modact-imports",
label, { kind = missing > 0 and "warn" or "accent", font = "small",
action = function()
imp._modImports = id
imp._modActions = nil
end })
cy = cy + m.btnH + gap
end
local armed = deleteArmed(imp, "mod", id, nil)
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "modact-del",
DELETE_LABEL(armed), {
@@ -2715,6 +2968,109 @@ local function buildModActionsModal(imp, m)
action = function() imp._modActions = nil end })
end
-- Imported files declared by one installed mod. The engine picks, validates,
-- canonicalizes and copies; this surface never exposes a host path to mod code.
local function buildRequiredImportsModal(imp, m)
local mod
for _, candidate in ipairs(imp.mods or {}) do
if candidate.id == imp._modImports then mod = candidate break end
end
if not mod then imp._modImports = nil return end
local imports = mod.imports or mod.requiredImports or {}
local pad, gap = math.floor(18 * m.s), math.floor(8 * m.s)
local w = math.floor(540 * m.s)
local notice = imp.requiredImportNotice
if not notice or notice.modId ~= mod.id then notice = nil end
local noticeText
if notice then
local importName = notice.importId
for _, row in ipairs(imports) do
if row.id == notice.importId then importName = row.name break end
end
noticeText = Strings("%s rejected: %s", importName, notice.text)
end
local noticeW = w - 2 * pad
local noticeH = noticeText and Kit.wrapHeight("small", noticeText, noticeW, 2) or 0
local rowH = math.max(math.floor(70 * m.s), m.btnH)
local perPage = math.min(4, math.max(1, #imports))
local pagerH = #imports > perPage and math.max(Kit.tapMin(), math.floor(30 * m.s)) or 0
local h = pad + Kit.textHeight("button") + math.floor(4 * m.s)
+ Kit.textHeight("small") + math.floor(12 * m.s)
+ noticeH + (noticeH > 0 and gap or 0)
+ perPage * rowH + math.max(0, perPage - 1) * gap
+ (pagerH > 0 and (gap + pagerH) or 0) + gap + m.btnH + pad
local px, py, pw = modalPanel(m, w, h)
local cy = py + pad
Kit.text("button", Kit.ellipsize("button", mod.name, pw - 2 * pad),
px + pad, cy, PAL.heading)
cy = cy + Kit.textHeight("button") + math.floor(4 * m.s)
Kit.text("small", Strings("User-supplied files are validated by MD5 and copied into this mod only."),
px + pad, cy, PAL.muted)
cy = cy + Kit.textHeight("small") + math.floor(12 * m.s)
if noticeText then
cy = cy + Kit.textWrapped("small", noticeText, px + pad, cy,
pw - 2 * pad, PAL.red, 2) + gap
end
local pageKey = "required-imports-" .. mod.id
local cur = page(imp, pageKey)
local first, last, bounded = Kit.pageBounds(cur, #imports, perPage)
setPage(imp, pageKey, bounded)
for i = first, last do
local row = imports[i]
local importId = row.id
Kit.card(px + pad, cy, pw - 2 * pad, rowH, row.present and "muted" or false)
local innerX = px + pad + math.floor(12 * m.s)
local actionW = math.floor(108 * m.s)
local removeW = row.present and math.floor(86 * m.s) or 0
local actionX = px + pw - pad - math.floor(10 * m.s) - actionW
if removeW > 0 then actionX = actionX - removeW - math.floor(6 * m.s) end
local textW = actionX - innerX - math.floor(8 * m.s)
Kit.text("small", Kit.ellipsize("small", row.name, textW), innerX,
cy + math.floor(8 * m.s), PAL.heading)
local stateY = cy + math.floor(8 * m.s) + Kit.textHeight("small")
+ math.floor(3 * m.s)
if row.description and row.description ~= "" then
Kit.text("micro", Kit.ellipsize("micro", row.description, textW),
innerX, stateY, PAL.muted)
stateY = stateY + Kit.textHeight("micro") + math.floor(2 * m.s)
end
local state = row.present and Strings("Ready - %s", row.file)
or (row.error and Strings("Invalid file - choose again")
or (row.required and Strings("Required - %s", row.file)
or Strings("Optional - %s", row.file)))
Kit.text("micro", Kit.ellipsize("micro", state, textW), innerX, stateY,
row.present and PAL.green or (row.required and PAL.yellow or PAL.muted))
btn(imp, actionX, cy + (rowH - m.btnH) / 2, actionW, m.btnH,
"req-pick-" .. mod.id .. "-" .. importId,
row.present and Strings("Replace") or Strings("Choose file"), {
kind = row.present and "ghost" or "accent", font = "small",
action = function() imp:chooseRequiredImport(mod.id, importId) end })
if row.present then
local deleteId = mod.id .. ":" .. importId
local armed = deleteArmed(imp, "required-import", deleteId, nil)
btn(imp, actionX + actionW + math.floor(6 * m.s),
cy + (rowH - m.btnH) / 2, removeW, m.btnH,
"req-remove-" .. mod.id .. "-" .. row.id, DELETE_LABEL(armed), {
kind = "danger", font = "small", keepArm = true,
action = function()
imp:pressDelete("required-import", deleteId, nil, function()
imp:_removeRequiredImport(mod.id, importId)
end)
end })
end
cy = cy + rowH + gap
end
if pagerH > 0 then
local newPage = Kit.pager(px + pad, cy, pw - 2 * pad, bounded,
#imports, perPage, pageKey)
setPage(imp, pageKey, newPage)
cy = cy + pagerH + gap
end
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "req-close", Strings("Close"), {
font = "small", action = function() imp._modImports = nil end })
end
-- Per-mod popup for FIND MODS: the row is a plain click, and Install /
-- Details / Source live here instead of crowding every row.
local function buildFindEntryModal(imp, m)
@@ -3253,8 +3609,10 @@ end
local function modalUp(imp)
return (imp._settingsText or imp._settings or imp._rename
or imp._indexPrompt or imp._modConfirm or imp._modReleaseNotes
or imp._appPatchNotes
or imp._findDetails or imp._modVersions or imp._modDepResolver or imp._sortPopup
or imp._filterPopup or imp._indexManage or imp._modActions
or imp._filterPopup or imp._modScopePopup or imp._indexManage
or imp._modActions or imp._modImports
or imp._modHeaderActionsPopup or imp._profilesPopup or imp._singleProfileActions or imp._profileSavePrompt
or imp._profileRenamePrompt or imp._findEntry or imp._gameManage) ~= nil
end
@@ -3351,6 +3709,20 @@ local function buildModals(imp, m)
return true
end
if imp._modConfirm then buildConfirmModal(imp, m) return true end
if imp._appPatchNotes then
local PatchNotes = require("src.update.PatchNotes")
local ModUpdate = require("src.mods.ModUpdate")
local raw, ver = PatchNotes.body(imp.Check)
local body = ModUpdate.cleanBody(raw or "", 0)
if body == "" then body = Strings("(No patch notes.)") end
local title = Strings("Patch notes")
if ver and ver ~= "" then
title = title .. " v" .. tostring(ver)
end
buildTextModal(imp, m, "patch-notes-modal", title, body,
function() imp._appPatchNotes = nil end)
return true
end
if imp._modReleaseNotes then
local ModUpdate = require("src.mods.ModUpdate")
local n = imp._modReleaseNotes
@@ -3372,6 +3744,7 @@ local function buildModals(imp, m)
end
if imp._modVersions then buildVersionsModal(imp, m) return true end
if imp._modDepResolver then buildDepResolverModal(imp, m) return true end
if imp._modImports then buildRequiredImportsModal(imp, m) return true end
-- The lighter popups come after the deep ones on purpose: opening
-- Versions or Details from inside an actions popup draws the deeper modal
-- while the popup's own state stays set, so closing the deep one drops
@@ -3380,6 +3753,7 @@ local function buildModals(imp, m)
if imp._profilesPopup then buildProfilesModal(imp, m) return true end
if imp._modHeaderActionsPopup then buildModHeaderActionsModal(imp, m) return true end
if imp._sortPopup then buildSortModal(imp, m) return true end
if imp._modScopePopup then buildModScopeModal(imp, m) return true end
if imp._filterPopup then buildFilterModal(imp, m) return true end
if imp._indexManage then buildIndexesModal(imp, m) return true end
if imp._modActions then buildModActionsModal(imp, m) return true end
@@ -3510,11 +3884,14 @@ function LauncherView.draw(imp)
-- one is up; buildModals lowers the shield for the modal's own controls.
Kit.blockClicks = modalUp(imp)
local ms = m
if scroll > 0 then
ms = setmetatable({ top = m.top - scroll }, { __index = m })
end
local contentY = buildHeader(imp, ms)
-- The header is the only block that moves with the page scroll, so shift
-- m.top across the call and put it back rather than wrapping `m` in a
-- proxy: the proxy cost two tables a frame and put a metatable lookup on
-- every m.* read for the rest of the frame.
local baseTop = m.top
if scroll > 0 then m.top = baseTop - scroll end
local contentY = buildHeader(imp, m)
m.top = baseTop
local footY, availH
if scrollMax > 0 then
availH = minPanelHeight(m)
+459 -79
View File
@@ -316,18 +316,6 @@ function RomImporter.isReady(version)
end
-- Load the import manifest for a version and confirm it matches that ROM.
local function decodeManifest(version)
local path = GameVersion.info(version).manifest
local raw, readError = love.filesystem.read(path)
if not raw then error("ROM import metadata is missing: " .. tostring(readError)) end
local Json = require("src.link.Json")
local manifest, decodeError = Json.decode(raw)
if not manifest then error("ROM import metadata is invalid: " .. tostring(decodeError)) end
assert(manifest.romSha1 == GameVersion.info(version).sha1,
"ROM import metadata version mismatch")
return manifest
end
local function sha1(data)
local digest = love.data.hash("sha1", data)
if type(digest) == "userdata" and digest.getString then
@@ -344,6 +332,14 @@ local function readExternalPath(path)
return data
end
local function externalFileSize(path)
local file = io.open(path, "rb")
if not file then return nil end
local size = file:seek("end")
file:close()
return size
end
local function readDroppedFile(file)
local ok, openError = file:open("r")
if not ok then return nil, openError end
@@ -981,6 +977,25 @@ local function findPendingSav(preferAny, skip)
return nil
end
local function pickerHasKind(kind)
local fn = love.system.pickFileKinds
if type(fn) ~= "function" then return false end
local ok, kinds = pcall(fn)
if not ok or type(kinds) ~= "string" then return false end
for token in kinds:gmatch("[^,%s]+") do
if token == kind then return true end
end
return false
end
local function findPendingRequiredImport()
local names = { "picked_required_import.bin", "picked_stadium.z64" }
for _, name in ipairs(names) do
if love.filesystem.getInfo(name, "file") then return name end
end
return nil
end
-- Retire an Android pick once it has been through the installer / importer,
-- whether or not it worked: a pick left on disk wins the scans above forever,
-- so the next tap re-runs the same failing file and the picker never reopens
@@ -1112,6 +1127,39 @@ local function chooseSav()
return nil
end
-- Generic user-supplied dependency picker. Keep the native prompt entirely
-- engine-owned: manifest labels are untrusted and must never enter shell
-- command templates. The LÖVE modal already shows the specific import name.
local function chooseRequiredFile()
local prompt = shellSafe(Strings("Choose required mod file"))
local platform = love.system.getOS()
if platform == "OS X" then
return commandOutput(
([[osascript -e 'POSIX path of (choose file with prompt "%s")' 2>/dev/null]])
:format(prompt))
elseif platform == "Windows" then
local script = table.concat({
"Add-Type -AssemblyName System.Windows.Forms;",
"$d=New-Object System.Windows.Forms.OpenFileDialog;",
"$d.Title='" .. prompt .. "';",
"$d.Filter='All files (*.*)|*.*';",
"if($d.ShowDialog() -eq 'OK'){",
"$t=Join-Path $env:TEMP 'pokeport_required_import.bin';",
"Copy-Item -LiteralPath $d.FileName -Destination $t -Force;",
"[Console]::OutputEncoding=[Text.Encoding]::UTF8;",
"[Console]::Write($t)}",
})
return commandOutput(
'powershell -NoProfile -STA -Command "' .. script .. '"')
elseif platform == "Linux" then
local path = commandOutput(
([[zenity --file-selection --title="%s" 2>/dev/null]]):format(prompt))
if path then return path end
return commandOutput([[kdialog --getopenfilename "$HOME" 2>/dev/null]])
end
return nil
end
-- The self-updater only surfaces on the real distributed build: a fused,
-- interactive launcher with no scripted-run override. A dev / source checkout
-- (unfused, where Boot.run already no-ops) or an autopilot / driver /
@@ -1230,7 +1278,9 @@ function RomImporter.new(onComplete, opts)
-- (refreshed lazily on first draw and after any toggle/install/delete);
-- modScroll is the current paged list's inner scroll offset (px, clamped
-- in draw); modNotice is the last install/delete result { ok, text }.
mods = nil, modScroll = 0, modNotice = nil,
-- 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)
@@ -1738,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
@@ -2045,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
@@ -2087,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
@@ -2174,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)
@@ -2196,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 }
@@ -2356,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
@@ -2806,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
@@ -2819,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
@@ -3837,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)
@@ -3850,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
@@ -3866,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,
@@ -3886,22 +4234,54 @@ function RomImporter:_findStats(entry)
self._findStatsCache[entry.id] = cached
return cached
end
-- ASYNC (was a blocking fetch, one row per frame). "One per frame" bounded
-- how many stalls happened at once, not how long each one lasted: every
-- frame that started a fetch blocked for the whole round trip, so scrolling
-- a listing juddered once per row. Rows now queue a handle and fill in
-- when it lands; until then the row simply has no stats line.
self._findStatsPending = self._findStatsPending or {}
if not self._findStatsPending[entry.id] then
local ModUpdate = require("src.mods.ModUpdate")
self._findStatsPending[entry.id] = {
id = entry.id,
h = ModUpdate.beginFetchReleases(entry.github, entry.id, {}),
}
end
return nil
end
-- Queue one row's release fetch. Only rows actually on the page call this --
-- the rule _findThumb already follows -- so the fan-out is a page, not the
-- whole index.
function RomImporter:_requestFindStats(entry)
if self:_findStatsCached(entry) then return end
if not entry.github or entry.github == "" then return end
local cached = self._findStatsCache[entry.id]
if cached then
if cached.retryAt and os.time() >= cached.retryAt then
self._findStatsCache[entry.id] = nil -- retry window open, refetch
else
return
end
end
self._findStatsPending = self._findStatsPending or {}
if self._findStatsPending[entry.id] then return end
local ModUpdate = require("src.mods.ModUpdate")
self._findStatsPending[entry.id] = {
id = entry.id,
h = ModUpdate.beginFetchReleases(entry.github, entry.id, {}),
}
end
-- Request-and-read, for a row that is being drawn and for the detail modal.
function RomImporter:_findStats(entry)
self:_requestFindStats(entry)
return self:_findStatsCached(entry)
end
-- How many rows are still waiting on a release check, for the panel's
-- progress line.
function RomImporter:_findStatsPendingCount()
local n = 0
for _ in pairs(self._findStatsPending or {}) do n = n + 1 end
return n
end
function RomImporter:_findStatsPendingFor(id)
return (self._findStatsPending and self._findStatsPending[id]) ~= nil
end
function RomImporter:_findThumbPending(id)
return (self._findThumbFetch and self._findThumbFetch[id]) ~= nil
end
-- Drive in-flight FIND MODS stats lookups. Called from update().
function RomImporter:_pumpFindStats()
local pending = self._findStatsPending
+25
View File
@@ -0,0 +1,25 @@
-- The per-version import metadata (symbol table + ROM hash) that drives
-- RomExtractor. Split out of RomImporter so the extraction worker thread can
-- decode it itself: shipping the decoded table across a love.thread channel
-- would deep-copy every symbol for nothing.
local GameVersion = require("src.core.GameVersion")
local RomManifest = {}
function RomManifest.decode(version)
local info = GameVersion.info(version)
local raw, readError = love.filesystem.read(info.manifest)
if not raw then
error("ROM import metadata is missing: " .. tostring(readError))
end
local Json = require("src.link.Json")
local manifest, decodeError = Json.decode(raw)
if not manifest then
error("ROM import metadata is invalid: " .. tostring(decodeError))
end
assert(manifest.romSha1 == info.sha1, "ROM import metadata version mismatch")
return manifest
end
return RomManifest
+6
View File
@@ -83,6 +83,12 @@ function ItemEffects.healsHP(id)
or id == "REVIVE" or id == "MAX_REVIVE"
end
function ItemEffects.isBattleMedicine(id)
return HEAL_AMOUNT[id] ~= nil or STATUS_HEAL[id] ~= nil
or id == "MAX_POTION" or id == "FULL_RESTORE"
or id == "REVIVE" or id == "MAX_REVIVE"
end
-- Does this item need a party-member target?
-- 'data' is optional for compat purposes; targeting falls back to itemDef/vanilla detection
function ItemEffects.needsTarget(id, itemDef, data)
+6 -3
View File
@@ -737,7 +737,8 @@ COVERAGE["src.pokemon.Boxes"] = {
COVERAGE["src.world.WorldAPI"] = {
kind = "alias", target = "src.world.gen2.WorldAPI",
backed = "new __index overworld current mapOverview warpTo toggleObject replaceBlock "
.. "spawnNpc removeNpc npc queueScript invalidateMap",
.. "spawnNpc removeNpc npc queueScript invalidateMap "
.. "availableFieldActions useFieldAction",
warned = "setFlag getFlag",
absent = "",
notes = {
@@ -1797,7 +1798,8 @@ local function buildBattleState()
"throwBall", "ballChain", "tossAnimFor", "ballFlicker", "ballMissMessage",
"storeCaughtMon", "safariAction", "safariEnemyTurn", "drawBallRow",
"drawClassic", "isWideBattleLayout", "wideLayout", "bgMode", "uiSize",
"sgbPalettes", "trainerPalette", "trainerPicPath", "invalidate",
"sgbPalettes", "trainerPalette", "trainerPicPath", "trainerTrueColor",
"trainerSprite", "invalidate",
"imageBattleScale", "resolveBattleScale", "backPlacement",
"frontPlacement", "StatBox", "enter", "exit",
}) do
@@ -1884,7 +1886,8 @@ COVERAGE["src.battle.BattleState"] = {
absent = "newWild newTrainer makeSafari makeGhost makeBattler resolveTurn "
.. "computeDamage catchAttempt runRoll enter exit sgbPalettes "
.. "isWideBattleLayout wideLayout bgMode uiSize letterboxWhite "
.. "holdsUIAnchors BG_WORLD_DIM trainerPalette trainerPicPath invalidate "
.. "holdsUIAnchors BG_WORLD_DIM trainerPalette trainerPicPath "
.. "trainerTrueColor trainerSprite invalidate "
.. "backPlacement frontPlacement StatBox drawClassic drawBallRow "
.. "safariAction safariEnemyTurn throwBall storeCaughtMon field ruleset "
.. "rng oppClass partyIndex aiUses introText dead",
+209
View File
@@ -0,0 +1,209 @@
-- Background compute for sandboxed mods, behind the "background" permission.
--
-- mod.fetch covers work that is waiting on a server. This covers work that is
-- waiting on the CPU: a mod hands over a script from its own folder plus a
-- table of plain data, and gets the return value back through the same
-- handle/poll/release shape mod.fetch uses.
--
-- The worker (src/mods/job_worker.lua) builds the SAME Sandbox.envFor
-- environment the main thread does before it loads the mod's chunk, so this
-- is not the love.thread hole reopened: the mod's code still cannot see
-- io, os, debug, ffi, package or love.filesystem, and require is refused
-- outright inside a job.
--
-- One thread per job rather than a pool. A pooled state would carry one
-- mod's globals into the next mod's job, and resetting it properly is the
-- same work as making a new one.
local SafePath = require("src.mods.SafePath")
local Job = {}
Job.MAX_INFLIGHT = 2 -- per mod
Job.MAX_GLOBAL = 4 -- across all mods, so jobs cannot eat every core
Job.DEFAULT_SECONDS = 5
Job.MAX_SECONDS = 30
-- Depth cap on the data crossing the channel. A cycle is caught by the seen
-- set; this catches the merely absurd.
Job.MAX_DEPTH = 16
local nextId = 0
local liveGlobal = 0
-- Only plain data crosses a thread boundary: a function or userdata cannot be
-- serialised, and letting one through would fail deep inside LÖVE instead of
-- at the call the mod made.
local function plain(value, depth, seen)
local t = type(value)
if t == "nil" or t == "boolean" or t == "number" or t == "string" then
return value
end
if t ~= "table" then
return nil, ("a job cannot carry a %s, only plain data"):format(t)
end
depth = (depth or 0) + 1
if depth > Job.MAX_DEPTH then
return nil, "a job's data is nested too deeply"
end
seen = seen or {}
if seen[value] then return nil, "a job cannot carry a cycle" end
seen[value] = true
local out = {}
for k, v in pairs(value) do
local kt = type(k)
if kt ~= "string" and kt ~= "number" then
return nil, ("a job cannot carry a %s key"):format(kt)
end
local copied, err = plain(v, depth, seen)
if err then return nil, err end
out[k] = copied
end
seen[value] = nil
return out
end
Job.plain = plain
function Job.available()
return (love and love.thread and love.thread.newThread) ~= nil
end
local function bucket(loader, modId)
loader.jobs = loader.jobs or {}
local b = loader.jobs[modId]
if not b then b = {}; loader.jobs[modId] = b end
return b
end
local function inflight(b)
local n = 0
for _, job in pairs(b) do
if job.status == "pending" then n = n + 1 end
end
return n
end
-- `script` is relative to the mod's own folder, and goes through the same
-- SafePath rules mod:read does -- a job is not a way to name a path.
-- Argument checks come BEFORE the host check: a bad path or an unserialisable
-- argument is the mod author's bug and should read the same on every machine,
-- not be masked into "unavailable" on a build without threads.
function Job.run(loader, modId, modPath, script, arg, opts)
if type(script) ~= "string" or script == "" then
return nil, "a job needs a script path inside your mod"
end
-- SafePath.require raises rather than returning, so the mod's bad path
-- comes back as a value here instead of unwinding its caller.
local okPath, safe = pcall(SafePath.join, modPath, script, "a job script")
if not okPath then return nil, tostring(safe) end
local payload, dataErr = plain(arg)
if dataErr then return nil, dataErr end
if not Job.available() then return nil, "background jobs are unavailable" end
local b = bucket(loader, modId)
if inflight(b) >= Job.MAX_INFLIGHT then
return nil, ("too many jobs in flight (limit %d); poll and release the "
.. "ones you have"):format(Job.MAX_INFLIGHT)
end
if liveGlobal >= Job.MAX_GLOBAL then
return nil, "the machine is already running as many jobs as it will"
end
opts = type(opts) == "table" and opts or {}
local seconds = tonumber(opts.maxSeconds) or Job.DEFAULT_SECONDS
if seconds > Job.MAX_SECONDS then seconds = Job.MAX_SECONDS end
if seconds < 1 then seconds = 1 end
nextId = nextId + 1
local argName = "modjob_arg_" .. nextId
local resultName = "modjob_result_" .. nextId
local argCh = love.thread.getChannel(argName)
local resCh = love.thread.getChannel(resultName)
argCh:clear()
resCh:clear()
argCh:push(payload == nil and false or payload)
local okNew, thread = pcall(love.thread.newThread, "src/mods/job_worker.lua")
if not okNew or not thread then return nil, "could not start a job thread" end
local Json = require("src.link.Json")
local permissions = select(2, pcall(Json.encode,
loader.mods and loader.mods[modId]
and loader.mods[modId].manifest.permissionSet or {})) or "{}"
local started = pcall(thread.start, thread, modId, safe, argName, resultName,
permissions)
if not started then return nil, "could not start a job thread" end
liveGlobal = liveGlobal + 1
local handle = {}
b[handle] = { thread = thread, resultCh = resCh, status = "pending",
deadline = love.timer.getTime() + seconds, seconds = seconds }
return handle
end
local function settle(job, status, value, err)
if job.status == "pending" then liveGlobal = math.max(0, liveGlobal - 1) end
job.status, job.value, job.err = status, value, err
end
function Job.poll(loader, modId, handle)
local job = bucket(loader, modId)[handle]
if not job then return { status = "error", err = "unknown job" } end
if job.status == "pending" then
local msg = job.resultCh:pop()
if msg then
if msg.ok then settle(job, "ok", msg.result)
else settle(job, "error", nil, msg.err) end
else
-- A worker that died before pushing anything (an error outside its own
-- pcall) would otherwise leave the mod polling forever.
local threadErr = job.thread.getError and job.thread:getError()
if threadErr then
settle(job, "error", nil, tostring(threadErr))
elseif love.timer.getTime() > job.deadline then
-- The budget bounds how long the MOD waits, not how long the work
-- runs: there is no way to stop a LÖVE thread, and every in-worker
-- attempt made things worse (see job_worker.lua). A job that
-- overruns is reported here and its result dropped if it ever lands.
settle(job, "error", nil, ("job exceeded its %gs budget")
:format(job.seconds))
end
end
end
if job.status == "ok" then
-- A copy, so a mod cannot edit what a later poll returns.
return { status = "ok", result = (plain(job.value)) }
end
return { status = job.status, err = job.err }
end
function Job.release(loader, modId, handle)
local b = bucket(loader, modId)
local job = b[handle]
if not job then return false end
if job.status == "pending" then liveGlobal = math.max(0, liveGlobal - 1) end
b[handle] = nil
return true
end
-- There is no way to kill a LÖVE thread, so cancelling drops the result
-- rather than stopping the work; the worker's own time budget is what bounds
-- how long an abandoned job can run.
function Job.cancel(loader, modId, handle)
local job = bucket(loader, modId)[handle]
if not job then return false end
if job.status == "pending" then
settle(job, "cancelled")
end
return true
end
function Job.releaseAll(loader, modId)
local b = loader.jobs and loader.jobs[modId]
if not b then return end
for handle, job in pairs(b) do
if job.status == "pending" then liveGlobal = math.max(0, liveGlobal - 1) end
b[handle] = nil
end
loader.jobs[modId] = nil
end
return Job
+93 -3
View File
@@ -38,6 +38,7 @@ 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 = {}
@@ -214,12 +215,19 @@ function LauncherMods.checkDependencies(manifest, options, version, installedMan
return m and not m.experimental
end
local function conflictApplies(spec, other)
return not spec.range or (other and other.version
and Semver.satisfies(other.version, spec.range))
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
if installedOther and isEnabled(conflictId)
and conflictApplies(spec, installedOther)
and not conflictIdsSeen[conflictId] then
conflictIdsSeen[conflictId] = true
hasIssues = true
depsResult[#depsResult + 1] = {
@@ -237,11 +245,12 @@ function LauncherMods.checkDependencies(manifest, options, version, installedMan
-- (b) Reverse conflicts declared by installed mods against target manifest
if manifest.id then
local installedTarget = installedMap[manifest.id] or manifest
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
if spec.id == manifest.id and conflictApplies(spec, installedTarget) then
conflictIdsSeen[other.id] = true
hasIssues = true
depsResult[#depsResult + 1] = {
@@ -459,13 +468,38 @@ function LauncherMods.list(version)
local ok, result = pcall(function()
local options = SaveData.loadOptions()
local manifests = discover()
-- Imports are private player grants. Never scan or copy another mod's
-- baseroms: a matching public digest is not permission to share the file.
local importState = {}
for _, manifest in ipairs(manifests) do
local rows, missing, missingOptional = RequiredImports.inspect(manifest)
importState[manifest.id] = { rows = rows, missing = missing,
missingOptional = missingOptional }
end
-- The first build containing game-specific switches turns the old shared
-- state into one explicit answer per installed mod and game. Saving here
-- means users who only visit the launcher still receive the migration.
if SaveData.migrateModEnablement(options, manifests) then
SaveData.saveOptions(options)
end
return LauncherMods.deriveList(manifests, options, version)
local rows = LauncherMods.deriveList(manifests, options, version)
for _, row in ipairs(rows) do
local state = importState[row.id]
or { rows = {}, missing = 0, missingOptional = 0 }
local imports, missing = state.rows, state.missing
row.requiredImports, row.missingRequiredImports = imports, missing
row.imports = imports
row.missingOptionalImports = state.missingOptional or 0
if missing > 0 and row.status == "ok" then
row.status = "needs_import"
local first
for _, import in ipairs(imports) do
if not import.present then first = import break end
end
row.statusDetail = "Needs import: " .. (first and first.name or "required file")
end
end
return rows
end)
if not ok then
-- a single bad options/mod file must not blank the launcher
@@ -677,6 +711,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
@@ -919,14 +973,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
@@ -950,6 +1018,28 @@ 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()
+956
View File
@@ -0,0 +1,956 @@
local Logger = require("src.core.Logger")
local SafePath = require("src.mods.SafePath")
local okSave, SaveData = pcall(require, "src.core.SaveData")
if not okSave then SaveData = nil end
local okStorage, Storage = pcall(require, "src.mods.Storage")
if not okStorage then Storage = nil end
local LegacyCompat = {}
local ROOT = "mod_compat"
local OWN = "_own"
local MAX_SEGMENTS = 24
LegacyCompat.reports = {}
function LegacyCompat.reset()
LegacyCompat.reports = {}
end
function LegacyCompat.report(modId)
if modId then
local entry = LegacyCompat.reports[modId]
return entry and entry.order or {}
end
local out = {}
for id, entry in pairs(LegacyCompat.reports) do
out[id] = entry.order
end
return out
end
local function note(ctx, call, advice)
local entry = LegacyCompat.reports[ctx.modId]
if not entry then
entry = { calls = {}, order = {} }
LegacyCompat.reports[ctx.modId] = entry
end
local row = entry.calls[call]
if row then
row.count = row.count + 1
return
end
row = { call = call, advice = advice, count = 1 }
entry.calls[call] = row
entry.order[#entry.order + 1] = row
Logger.warn("[%s] %s was removed from the mod sandbox; %s", ctx.modId, call,
advice)
end
local function refuse(ctx, call, advice)
note(ctx, call, advice)
return nil, ("%s is not available to mods; %s"):format(call, advice)
end
-- ------- path routing
local function normalize(path)
if type(path) ~= "string" or path == "" then return nil end
path = path:gsub("\\", "/")
while path:find("//", 1, true) do path = (path:gsub("//", "/")) end
if path ~= "/" then path = (path:gsub("/$", "")) end
return path
end
local function flatten(path)
local parts = {}
for segment in path:gmatch("[^/]+") do
if segment ~= "." and segment ~= ".." then
parts[#parts + 1] = (segment:gsub("[^%w_%.%-]", "_"))
end
end
if #parts == 0 then return nil end
while #parts > MAX_SEGMENTS do table.remove(parts, 1) end
return table.concat(parts, "/")
end
local function storageKey(key)
local parts = {}
for segment in key:gmatch("[^/]+") do parts[#parts + 1] = segment end
if #parts == 0 then return nil end
parts[#parts] = (parts[#parts]:gsub("%.[^%.]*$", ""))
for i = 1, #parts do
local cleaned = (parts[i]:gsub("[^%w_%-]", "_"))
if cleaned == "" then return nil end
parts[i] = cleaned
end
return table.concat(parts, "/")
end
local function ownRelative(ctx, path)
if not ctx.modPath then return nil end
if path == ctx.modPath then return "" end
if path:sub(1, #ctx.modPath + 1) == ctx.modPath .. "/" then
return path:sub(#ctx.modPath + 2)
end
return nil
end
local function ownFull(ctx, rel)
if rel == "" then return ctx.modPath end
local safe = SafePath.safe(rel)
if not safe then return nil end
return ctx.modPath .. "/" .. safe
end
local function ownExists(ctx, rel)
local full = ownFull(ctx, rel)
local fs = ctx.fs
if not (full and fs and fs.getInfo) then return nil end
return fs.getInfo(full)
end
local function classify(ctx, path)
path = normalize(path)
if not path then return nil end
local root = ctx.virtualRoot
if path == root then return { key = "", dir = true } end
if path:sub(1, #root + 1) == root .. "/" then
return { key = flatten(path:sub(#root + 2)) }
end
local rel = ownRelative(ctx, path)
if not rel and path:sub(1, 1) ~= "/" and not path:match("^%a:")
and ownExists(ctx, path) then
rel = path
end
if rel then
if rel == "" then return { key = OWN, rel = "", dir = true } end
local flat = flatten(rel)
return { key = flat and (OWN .. "/" .. flat) or nil, rel = rel }
end
return { key = flatten(path) }
end
-- ------- the overlay
local function persistFs(ctx)
if SaveData and SaveData.persistenceFs then
return SaveData.persistenceFs(ctx.fs)
end
return ctx.fs
end
local function overlayPath(ctx, key)
if key == nil or key == "" then return ROOT .. "/" .. ctx.modId end
return ROOT .. "/" .. ctx.modId .. "/" .. key
end
local function ensureParent(fs, path)
if not fs.createDirectory then return end
local dir = path:match("^(.*)/[^/]+$")
if not dir then return end
local built = nil
for segment in dir:gmatch("[^/]+") do
built = built and (built .. "/" .. segment) or segment
fs.createDirectory(built)
end
end
local function overlayRead(ctx, key)
local fs = persistFs(ctx)
if not (fs and fs.read and fs.getInfo) then return nil end
local path = overlayPath(ctx, key)
local info = fs.getInfo(path)
if not info or info.type == "directory" then return nil end
local body = fs.read(path)
if type(body) ~= "string" then return nil end
return body
end
local function overlayWrite(ctx, key, data)
local fs = persistFs(ctx)
if not (fs and fs.write) then
return false, "no writable filesystem is available"
end
local path = overlayPath(ctx, key)
ensureParent(fs, path)
local ok, err = fs.write(path, data)
if ok == false then return false, err or "write failed" end
return true
end
local function overlayRemove(ctx, key)
local fs = persistFs(ctx)
if not (fs and fs.remove and fs.getInfo) then return false end
local path = overlayPath(ctx, key)
if not fs.getInfo(path) then return false end
fs.remove(path)
return true
end
local function overlayInfo(ctx, key)
local fs = persistFs(ctx)
if not (fs and fs.getInfo) then return nil end
return fs.getInfo(overlayPath(ctx, key))
end
local function storageRead(ctx, key)
if not (Storage and key and key ~= "") then return nil end
if key:sub(1, #OWN + 1) == OWN .. "/" then return nil end
local game = ctx.game and ctx.game()
if not game then return nil end
local sk = storageKey(key)
if not sk then return nil end
ctx.storage = ctx.storage or Storage.new(ctx.modId, ctx.fs)
local ok, bytes = pcall(ctx.storage.readBytes, ctx.storage, game, sk)
if ok and type(bytes) == "string" then return bytes end
return nil
end
local function readPath(ctx, path)
local at = classify(ctx, path)
if not at then return nil, "invalid path" end
local body = at.key and overlayRead(ctx, at.key)
if body then return body end
if at.rel then
local full = ownFull(ctx, at.rel)
local fs = ctx.fs
if full and fs and fs.read then
local packaged = fs.read(full)
if type(packaged) == "string" then return packaged end
end
end
body = at.key and storageRead(ctx, at.key)
if body then return body end
return nil, "could not open " .. tostring(path)
end
local function writePath(ctx, path, data)
local at = classify(ctx, path)
if not (at and at.key) then return false, "invalid path" end
return overlayWrite(ctx, at.key, data)
end
local function infoPath(ctx, path)
local at = classify(ctx, path)
if not at then return nil end
if at.key then
local info = overlayInfo(ctx, at.key)
if info then
return { type = info.type, size = info.size, modtime = info.modtime }
end
end
if at.rel then
local info = ownExists(ctx, at.rel)
if info then
return { type = info.type, size = info.size, modtime = info.modtime }
end
end
local stored = at.key and storageRead(ctx, at.key)
if stored then return { type = "file", size = #stored } end
return nil
end
local function listPath(ctx, path)
local at = classify(ctx, path)
local seen, out = {}, {}
local function add(name)
if name and name ~= "" and not seen[name] then
seen[name] = true
out[#out + 1] = name
end
end
if at and at.rel then
local full = ownFull(ctx, at.rel)
local fs = ctx.fs
if full and fs and fs.getDirectoryItems then
for _, name in ipairs(fs.getDirectoryItems(full) or {}) do add(name) end
end
end
if at and at.key then
local fs = persistFs(ctx)
if fs and fs.getDirectoryItems then
for _, name in ipairs(fs.getDirectoryItems(overlayPath(ctx, at.key)) or {}) do
add(name)
end
end
end
table.sort(out)
return out
end
-- ------- buffered file handles
local function openBuffer(ctx, path, mode)
mode = tostring(mode or "r"):gsub("b", "")
local writable = mode:find("[wa+]") ~= nil
local body
if mode:sub(1, 1) == "w" then
body = ""
else
body = readPath(ctx, path)
if not body then
if not writable then return nil, "could not open " .. tostring(path) end
body = ""
end
end
local state = {
ctx = ctx, path = path, buf = body, pos = 1,
writable = writable, closed = false, dirty = false,
}
if mode:sub(1, 1) == "a" then state.pos = #body + 1 end
return state
end
local function bufferFlush(state)
if not (state.writable and state.dirty) then return true end
local ok, err = writePath(state.ctx, state.path, state.buf)
if ok then state.dirty = false end
return ok, err
end
local function bufferWrite(state, text)
if not state.writable then return false, "file is not open for writing" end
local head = state.buf:sub(1, state.pos - 1)
if #head < state.pos - 1 then head = head .. string.rep("\0", state.pos - 1 - #head) end
local tail = state.buf:sub(state.pos + #text)
state.buf = head .. text .. tail
state.pos = state.pos + #text
state.dirty = true
return true
end
local function bufferRead(state, fmt)
if fmt == nil then fmt = "*l" end
if type(fmt) == "number" then
if fmt == 0 then return state.pos <= #state.buf and "" or nil end
if state.pos > #state.buf then return nil end
local chunk = state.buf:sub(state.pos, state.pos + fmt - 1)
state.pos = state.pos + #chunk
return chunk
end
fmt = tostring(fmt):gsub("^%*", "")
if fmt == "a" then
local rest = state.buf:sub(state.pos)
state.pos = #state.buf + 1
return rest
end
if fmt == "l" or fmt == "L" then
if state.pos > #state.buf then return nil end
local nl = state.buf:find("\n", state.pos, true)
local line
if nl then
line = state.buf:sub(state.pos, fmt == "L" and nl or nl - 1)
state.pos = nl + 1
else
line = state.buf:sub(state.pos)
state.pos = #state.buf + 1
end
return line
end
if fmt == "n" then
local rest = state.buf:sub(state.pos)
local text, after = rest:match("^%s*(%-?%d+%.?%d*[eE]?[-+]?%d*)()")
if not text then return nil end
state.pos = state.pos + after - 1
return tonumber(text)
end
return nil
end
local function bufferSeek(state, whence, offset)
whence = whence or "cur"
offset = offset or 0
if whence == "set" then state.pos = offset + 1
elseif whence == "cur" then state.pos = state.pos + offset
elseif whence == "end" then state.pos = #state.buf + offset + 1
else return nil, "bad seek base" end
if state.pos < 1 then state.pos = 1 end
return state.pos - 1
end
local function bufferClose(state)
if state.closed then return true end
local ok, err = bufferFlush(state)
state.closed = true
return ok, err
end
local function ioFile(ctx, path, mode)
local state, err = openBuffer(ctx, path, mode)
if not state then return nil, err end
local file = {}
function file:read(...)
local count = select("#", ...)
if count == 0 then return bufferRead(state, "*l") end
local out = {}
for i = 1, count do out[i] = bufferRead(state, (select(i, ...))) end
return unpack(out, 1, count)
end
function file:write(...)
for i = 1, select("#", ...) do
local value = select(i, ...)
local ok, writeErr = bufferWrite(state, tostring(value))
if not ok then return nil, writeErr end
end
return file
end
function file:lines(fmt)
return function() return bufferRead(state, fmt or "*l") end
end
function file:seek(whence, offset) return bufferSeek(state, whence, offset) end
function file:flush() bufferFlush(state) return file end
function file:close() return bufferClose(state) end
function file:setvbuf() return true end
return file
end
local function loveFile(ctx, path, mode)
local state = nil
local file = {}
function file:open(openMode)
local opened, err = openBuffer(ctx, path, openMode or mode or "r")
if not opened then return false, err end
state = opened
return true
end
function file:isOpen() return state ~= nil and not state.closed end
function file:read(bytes)
if not state and not file:open("r") then return nil, 0 end
local body = bytes and bufferRead(state, bytes) or bufferRead(state, "*a")
if not body then return nil, 0 end
return body, #body
end
function file:write(data, size)
if not state and not file:open(mode or "w") then return false end
data = tostring(data)
if size then data = data:sub(1, size) end
local ok, err = bufferWrite(state, data)
if not ok then return false, err end
bufferFlush(state)
return true
end
function file:lines()
if not state then file:open("r") end
return function() return state and bufferRead(state, "*l") or nil end
end
function file:seek(offset)
if not state then return false end
bufferSeek(state, "set", offset or 0)
return true
end
function file:tell() return state and (state.pos - 1) or 0 end
function file:getSize()
if state then return #state.buf end
local body = readPath(ctx, path)
return body and #body or 0
end
function file:flush() if state then bufferFlush(state) end return true end
function file:close()
if state then bufferClose(state) end
state = nil
return true
end
function file:getFilename() return path end
function file:getMode() return mode or "c" end
function file:setBuffer() return true end
if mode and mode ~= "c" then file:open(mode) end
return file
end
-- ------- the love.filesystem stand-in
local function realFilesystem()
return _G.love and _G.love.filesystem or nil
end
local function filesystemShim(ctx)
local fsShim = {}
local function readAdvice() return "reads now come from mod:read and mod.storage" end
function fsShim.read(a, b, c)
local path, size = a, b
if (a == "string" or a == "data") and type(b) == "string" then
path, size = b, c
end
note(ctx, "love.filesystem.read", readAdvice())
local body, err = readPath(ctx, path)
if not body then return nil, err end
if size and size >= 0 then body = body:sub(1, size) end
return body, #body
end
function fsShim.write(path, data, size)
note(ctx, "love.filesystem.write",
"writes are rerouted to this mod's private compat storage; migrate to mod.storage")
data = tostring(data)
if size then data = data:sub(1, size) end
local ok, err = writePath(ctx, path, data)
if not ok then return false, err end
return true
end
function fsShim.append(path, data, size)
note(ctx, "love.filesystem.append",
"writes are rerouted to this mod's private compat storage; migrate to mod.storage")
data = tostring(data)
if size then data = data:sub(1, size) end
local existing = readPath(ctx, path) or ""
local ok, err = writePath(ctx, path, existing .. data)
if not ok then return false, err end
return true
end
function fsShim.lines(path)
note(ctx, "love.filesystem.lines", readAdvice())
local body = readPath(ctx, path)
if not body then error("could not open " .. tostring(path), 2) end
local pos = 1
return function()
if pos > #body then return nil end
local nl = body:find("\n", pos, true)
local line
if nl then
line = body:sub(pos, nl - 1)
pos = nl + 1
else
line = body:sub(pos)
pos = #body + 1
end
return line
end
end
function fsShim.load(path)
note(ctx, "love.filesystem.load",
"the chunk is compiled into this mod's sandbox; prefer require or mod:read plus load")
local body = readPath(ctx, path)
if not body then return nil, "could not open " .. tostring(path) end
if not ctx.compile then return nil, "no sandbox is bound yet" end
return ctx.compile(body, "@" .. tostring(path))
end
function fsShim.getInfo(path, a, b)
local info = infoPath(ctx, path)
local filter = type(a) == "string" and a or nil
local into = type(a) == "table" and a or (type(b) == "table" and b or nil)
if not info then return nil end
if filter and info.type ~= filter then return nil end
if into then
into.type, into.size, into.modtime = info.type, info.size, info.modtime
return into
end
return info
end
function fsShim.getDirectoryItems(path)
note(ctx, "love.filesystem.getDirectoryItems", "use mod.assets:list")
return listPath(ctx, path)
end
function fsShim.createDirectory(path)
local at = classify(ctx, path)
if not (at and at.key) then return false end
local fs = persistFs(ctx)
if not (fs and fs.createDirectory) then return false end
ensureParent(fs, overlayPath(ctx, at.key) .. "/.")
fs.createDirectory(overlayPath(ctx, at.key))
return true
end
function fsShim.remove(path)
local at = classify(ctx, path)
if not (at and at.key) then return false end
return overlayRemove(ctx, at.key)
end
function fsShim.exists(path) return infoPath(ctx, path) ~= nil end
function fsShim.isFile(path)
local info = infoPath(ctx, path)
return info ~= nil and info.type == "file"
end
function fsShim.isDirectory(path)
local info = infoPath(ctx, path)
return info ~= nil and info.type == "directory"
end
function fsShim.getSize(path)
local info = infoPath(ctx, path)
if not info then return nil, "could not open " .. tostring(path) end
return info.size or 0
end
function fsShim.getLastModified(path)
local info = infoPath(ctx, path)
if not info then return nil, "could not open " .. tostring(path) end
return info.modtime or 0
end
local function virtual(call)
note(ctx, call, "paths are virtual now; everything under the returned root "
.. "lands in this mod's private compat storage")
return ctx.virtualRoot
end
function fsShim.getSaveDirectory() return virtual("love.filesystem.getSaveDirectory") end
function fsShim.getWorkingDirectory() return virtual("love.filesystem.getWorkingDirectory") end
function fsShim.getUserDirectory() return virtual("love.filesystem.getUserDirectory") end
function fsShim.getAppdataDirectory() return virtual("love.filesystem.getAppdataDirectory") end
function fsShim.getSourceBaseDirectory() return virtual("love.filesystem.getSourceBaseDirectory") end
function fsShim.getRealDirectory() return ctx.virtualRoot end
function fsShim.getIdentity() return ctx.modId end
function fsShim.setIdentity()
note(ctx, "love.filesystem.setIdentity",
"a mod cannot repoint the save directory; the call does nothing")
return false
end
function fsShim.getRequirePath() return "" end
function fsShim.setRequirePath() return false end
function fsShim.getCRequirePath() return "" end
function fsShim.setCRequirePath() return false end
function fsShim.mount()
note(ctx, "love.filesystem.mount",
"mounting is refused; ship the files inside your mod and use mod:read")
return false
end
fsShim.unmount = fsShim.mount
function fsShim.newFile(path, mode) return loveFile(ctx, path, mode) end
function fsShim.newFileData(a, b)
local real = realFilesystem()
if type(a) == "string" and b == nil then
note(ctx, "love.filesystem.newFileData", readAdvice())
local body = readPath(ctx, a)
if not body then return nil, "could not open " .. tostring(a) end
if real and real.newFileData then return real.newFileData(body, a) end
return nil, "no filesystem"
end
if real and real.newFileData then return real.newFileData(a, b) end
return nil, "no filesystem"
end
function fsShim.isFused()
local real = realFilesystem()
return real and real.isFused and real.isFused() or false
end
function fsShim.areSymlinksEnabled() return false end
function fsShim.setSymlinksEnabled() return false end
function fsShim.init() return false end
function fsShim.setSource() return false end
return setmetatable(fsShim, { __index = function(_, key)
note(ctx, "love.filesystem." .. tostring(key),
"there is no compat stand-in for it; use mod.storage or mod:read")
return nil
end })
end
-- ------- the rest of the removed surface
local function systemShim(ctx)
local function real() return _G.love and _G.love.system or nil end
-- tls* comes from the engine (Android JNI, or desktop gen1tls hung on
-- love.system at boot). Forward those; keep clipboard / openURL stubbed.
local TLS = {
tlsOpen = true, tlsStatus = true, tlsSend = true,
tlsReceive = true, tlsError = true, tlsClose = true,
}
local shim = {
getOS = function()
local sys = real()
return sys and sys.getOS and sys.getOS() or "Unknown"
end,
getPowerInfo = function()
local sys = real()
if not (sys and sys.getPowerInfo) then return "unknown", nil, nil end
return sys.getPowerInfo()
end,
getProcessorCount = function()
local sys = real()
return sys and sys.getProcessorCount and sys.getProcessorCount() or 1
end,
getClipboardText = function()
note(ctx, "love.system.getClipboardText",
"clipboard access stays sandboxed; the call returns an empty string")
return ""
end,
setClipboardText = function()
note(ctx, "love.system.setClipboardText",
"clipboard access stays sandboxed; the call does nothing")
return false
end,
openURL = function()
note(ctx, "love.system.openURL",
"launching a URL stays sandboxed; the call does nothing")
return false
end,
vibrate = function(...)
local sys = real()
if sys and sys.vibrate then return sys.vibrate(...) end
return false
end,
}
return setmetatable(shim, {
__index = function(_, key)
if not TLS[key] then return nil end
local sys = real()
return sys and sys[key]
end,
})
end
local function eventShim(ctx)
local function real() return _G.love and _G.love.event or nil end
local shim = {
quit = function()
note(ctx, "love.event.quit",
"a mod cannot close the game out from under the player; the call does nothing")
return false
end,
}
shim.push = function(name, ...)
if name == "quit" then return shim.quit() end
local ev = real()
if ev and ev.push then return ev.push(name, ...) end
return false
end
return setmetatable(shim, { __index = function(_, key)
local ev = real()
return ev and ev[key] or nil
end })
end
local function ioShim(ctx)
local stream = {
write = function(self, ...)
for i = 1, select("#", ...) do io.write(tostring((select(i, ...)))) end
return self
end,
flush = function(self) return self end,
close = function() return true end,
read = function() return nil end,
lines = function() return function() return nil end end,
seek = function() return 0 end,
setvbuf = function() return true end,
}
local out = setmetatable({}, { __index = stream })
local shim = {
open = function(path, mode)
note(ctx, "io.open",
"the handle is backed by mod:read and this mod's private compat storage")
local file, err = ioFile(ctx, path, mode)
if not file then return nil, err, 2 end
return file
end,
lines = function(path, fmt)
if path == nil then return function() return nil end end
note(ctx, "io.lines",
"the handle is backed by mod:read and this mod's private compat storage")
local file, err = ioFile(ctx, path, "r")
if not file then error(err, 2) end
return file:lines(fmt)
end,
close = function(file) if file and file.close then return file:close() end return true end,
type = function(file)
if type(file) == "table" and file.read and file.close then return "file" end
return nil
end,
read = function() return nil end,
write = function(...) return out:write(...) end,
input = function() return out end,
output = function() return out end,
stdout = out,
stderr = out,
stdin = setmetatable({}, { __index = stream }),
popen = function()
return refuse(ctx, "io.popen", "spawning a process is refused")
end,
tmpfile = function()
return ioFile(ctx, ctx.virtualRoot .. "/io_tmpfile", "w+")
end,
}
return shim
end
local function osShim(ctx)
local HOME = { HOME = true, APPDATA = true, LOCALAPPDATA = true,
USERPROFILE = true, XDG_DATA_HOME = true,
XDG_CONFIG_HOME = true, TMPDIR = true, TEMP = true, TMP = true }
return {
getenv = function(name)
note(ctx, "os.getenv",
"the environment is hidden; home-like names answer with this mod's virtual root")
if type(name) == "string" and HOME[name:upper()] then
return ctx.virtualRoot
end
return nil
end,
remove = function(path)
note(ctx, "os.remove", "the delete lands in this mod's private compat storage")
local at = classify(ctx, path)
if not (at and at.key and overlayRemove(ctx, at.key)) then
return nil, tostring(path) .. ": no such file"
end
return true
end,
rename = function(from, to)
note(ctx, "os.rename", "the move lands in this mod's private compat storage")
local body = readPath(ctx, from)
if not body then return nil, tostring(from) .. ": no such file" end
local ok, err = writePath(ctx, to, body)
if not ok then return nil, err end
local at = classify(ctx, from)
if at and at.key then overlayRemove(ctx, at.key) end
return true
end,
tmpname = function()
return ctx.virtualRoot .. "/tmp/os_tmpname"
end,
execute = function()
note(ctx, "os.execute", "running a command is refused")
return false
end,
exit = function()
note(ctx, "os.exit",
"a mod cannot end the process; the call does nothing")
return false
end,
setlocale = function() return "C" end,
}
end
local function packageShim(ctx)
local shim = { path = "", cpath = "", preload = {}, loaded = {}, loaders = {} }
return setmetatable(shim, {
__index = function(_, key)
note(ctx, "package." .. tostring(key),
"the module loader is sandboxed; use require for the supported engine modules")
return nil
end,
})
end
-- ------- love callbacks
local CALLBACKS = {
load = true, update = true, draw = true, quit = true, lowmemory = true,
threaderror = true, keypressed = true, keyreleased = true,
textinput = true, textedited = true, mousemoved = true,
mousepressed = true, mousereleased = true, wheelmoved = true,
mousefocus = true, touchpressed = true, touchreleased = true,
touchmoved = true, joystickadded = true, joystickremoved = true,
joystickpressed = true, joystickreleased = true, joystickaxis = true,
joystickhat = true, gamepadpressed = true, gamepadreleased = true,
gamepadaxis = true, focus = true, visible = true, resize = true,
filedropped = true, directorydropped = true, displayrotated = true,
audiodevicechanged = true, localechanged = true,
}
local REFUSED = {
run = "love.run is the engine's fixed-step loop; use mod.hooks and mod.events",
errorhandler = "love.errorhandler is how a crash reaches the player; "
.. "use mod.events",
}
-- ------- assembly
function LegacyCompat.new(opts)
opts = opts or {}
local ctx = {
modId = opts.modId or "mod",
modPath = opts.modPath,
fs = opts.fs,
game = opts.game,
compile = nil,
}
ctx.virtualRoot = "/pokeport/" .. ctx.modId
local filesystem = filesystemShim(ctx)
local io_ = ioShim(ctx)
local system = systemShim(ctx)
local event = eventShim(ctx)
local compat = {
ctx = ctx,
love = { filesystem = filesystem, system = system, event = event },
os = osShim(ctx),
globals = {
io = io_,
package = packageShim(ctx),
loadfile = function(path)
note(ctx, "loadfile",
"the chunk is compiled into this mod's sandbox; prefer require or mod:read")
local body = readPath(ctx, path)
if not body then return nil, "could not open " .. tostring(path) end
if not ctx.compile then return nil, "no sandbox is bound yet" end
return ctx.compile(body, "@" .. tostring(path))
end,
},
modules = {
io = io_,
["love.filesystem"] = filesystem,
["love.system"] = system,
["love.event"] = event,
},
}
compat.globals.dofile = function(path)
local chunk, err = compat.globals.loadfile(path)
if not chunk then error(err or ("could not open " .. tostring(path)), 2) end
return chunk()
end
function compat.module(name)
if type(name) ~= "string" then return nil end
local substitute = compat.modules[name]
if substitute then
note(ctx, ('require("%s")'):format(name),
"it now answers with the compat stand-in, not the real module")
return substitute
end
if name == "os" then
note(ctx, 'require("os")',
"it now answers with the compat stand-in, not the real module")
return compat.osTable
end
return nil
end
-- true when the assignment is a callback chain, which is what a mod
-- wrapping love.mousemoved did before the sandbox; false plus a reason for
-- the two names the engine owns, false alone for a module table.
function compat.assign(key, value)
if REFUSED[key] then
note(ctx, ("love.%s assignment"):format(key), REFUSED[key])
return false, ("[%s] %s"):format(ctx.modId, REFUSED[key])
end
if not CALLBACKS[key] then return false end
if value ~= nil and type(value) ~= "function" then return false end
note(ctx, ("love.%s assignment"):format(key),
"the callback lands on the real love table the way it did before the "
.. "sandbox; prefer mod.hooks and mod.events")
return true
end
function compat.bind(env, compile)
ctx.compile = compile
compat.osTable = env and env.os or nil
end
return compat
end
return LegacyCompat
+152 -5
View File
@@ -4,6 +4,7 @@ local SaveData = require("src.core.SaveData")
local Data = require("src.core.Data")
local GameVersion = require("src.core.GameVersion")
local Version = require("src.core.Version")
local RequiredImports = require("src.mods.RequiredImports")
local Assets = require("src.render.Assets")
local ModUI = require("src.ui.ModUI")
local DateTime = require("src.core.DateTime")
@@ -19,8 +20,11 @@ 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
@@ -565,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")
@@ -1051,6 +1072,68 @@ function Loader:_api(mod)
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)(),
-- One-way crash-log reporting to the https URL the manifest declares in
-- log_url. The destination is reviewed at load, not chosen per call, so
-- a mod cannot aim this at arbitrary hosts; the response body is never
-- returned, and the worker pool bounds the transfer. Same handle/poll/
-- release shape as mod.fetch, so mod.job's sibling patterns carry over.
postLog = (function()
if mod.manifest.permissionSet.network and mod.manifest.log_url then
return function(_, body, opts)
return Net.postLog(loader, modId, mod.manifest.log_url, body, opts)
end
end
local function refuse()
error(('[%s] mod.postLog needs the "network" permission and a '
.. "log_url in manifest.json"):format(modId), 2)
end
return 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 = {
@@ -1069,7 +1152,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 = {
@@ -1077,6 +1161,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,
},
@@ -1158,6 +1246,39 @@ 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({
@@ -1174,16 +1295,20 @@ 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)
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
local world
local world, battle
setmetatable(api, { __index = function(_, key)
-- mod.game is the live service owner, resolved per generation the way
-- mod.world is: src/core/Game.lua's singleton under Gen 1, the Game2
@@ -1192,9 +1317,17 @@ function Loader:_api(mod)
-- entry chunk runs. This is what a mod should hold instead of requiring
-- src.core.Game, which under Gold hands back a table nothing instantiated.
if key == "game" then return loader:_game() end
local game = loader:_game()
if key == "battle" then
if battle then return battle end
local module = game and engineRequire(loader.generation == 2
and "src.battle.gen2.BattleAPI" or "src.battle.BattleAPI")
if not module then return nil end
battle = module.new(game)
return battle
end
if key ~= "world" then return nil end
if world then return world end
local game = loader:_game()
-- one facade name, one arm per generation: Gold's world is not a stack
-- state and its flags are a bitfield, so the resolution differs even
-- where the method set does not (src/world/gen2/WorldAPI.lua)
@@ -1228,12 +1361,24 @@ function Loader:_modEnv(mod)
local id = mod.manifest.id
local env = self.modEnv[id]
if not env then
env = Sandbox.envFor({ modId = id, permissions = mod.manifest.permissionSet })
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 = SafePath.join(mod.path, mod.manifest.entry, "manifest entry")
local chunk, err = Sandbox.loadFile(self.fs, path, self:_modEnv(mod))
@@ -1269,6 +1414,8 @@ function Loader:_rollback(modId)
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
+43 -1
View File
@@ -986,12 +986,45 @@ end
function ManagerState:buildOptionRows(m, schema)
local rows = {}
local modId = m.id
local byKey, visibilityKeys = {}, {}
for _, row in ipairs(schema) do
if type(row) == "table" and type(row.key) == "string" then
byKey[row.key] = row
local condition = row.visible_if
if type(condition) == "table" and type(condition.key) == "string" then
visibilityKeys[condition.key] = true
end
end
end
local function visible(row)
local condition = row.visible_if
if condition == nil then return true end
if type(condition) ~= "table" or type(condition.key) ~= "string" then
return false
end
local dependency = byKey[condition.key] or { key = condition.key }
local value = self:optionValue(modId, dependency)
if condition.equals ~= nil then return value == condition.equals end
if condition.not_equals ~= nil then return value ~= condition.not_equals end
return false
end
local function refresh(key)
if not visibilityKeys[key] then return end
local preferred = rows[self.cursor] and rows[self.cursor].id
self.optionRows = self:buildOptionRows(m, schema)
for index, candidate in ipairs(self.optionRows) do
if candidate.id == preferred then self.cursor = index break end
end
self.cursor = clampIndex(self.cursor, #self.optionRows)
end
for _, row in ipairs(schema) do
if type(row) ~= "table" or type(row.key) ~= "string" or row.key == ""
or not OPTION_TYPES[row.type] then
-- malformed rows are skipped, reported where the errors screen reads
Runtime.reportError(modId, "options row skipped: "
.. tostring(type(row) == "table" and (row.key or row.type) or row))
elseif not visible(row) then
-- Keep the row in the schema and stored options, only hide its menu row.
elseif row.type == "toggle" then
rows[#rows + 1] = { id = row.key, label = row.label or row.key,
value = function()
@@ -999,6 +1032,7 @@ function ManagerState:buildOptionRows(m, schema)
end,
step = function()
self:setOption(modId, row.key, not self:optionValue(modId, row))
refresh(row.key)
return true
end }
elseif row.type == "choice" then
@@ -1021,6 +1055,7 @@ function ManagerState:buildOptionRows(m, schema)
end
index = clampIndex(index + dir, #choices)
self:setOption(modId, row.key, choices[index][2])
refresh(row.key)
return true
end }
elseif row.type == "number" then
@@ -1036,6 +1071,7 @@ function ManagerState:buildOptionRows(m, schema)
step = function(_, dir)
local cur = tonumber(self:optionValue(modId, row)) or 0
self:setOption(modId, row.key, clamp(cur + dir * (row.step or 1)))
refresh(row.key)
return true
end,
activate = function()
@@ -1044,7 +1080,10 @@ function ManagerState:buildOptionRows(m, schema)
max = row.max or 99,
start = math.max(1, tonumber(self:optionValue(modId, row)) or 1),
onDone = function(qty)
if qty then self:setOption(modId, row.key, clamp(qty)) end
if qty then
self:setOption(modId, row.key, clamp(qty))
refresh(row.key)
end
end,
}))
end }
@@ -1061,6 +1100,7 @@ function ManagerState:buildOptionRows(m, schema)
default = self:optionValue(modId, row),
onDone = function(name)
self:setOption(modId, row.key, name)
refresh(row.key)
end,
}))
end }
@@ -1075,6 +1115,8 @@ function ManagerState:buildOptionRows(m, schema)
self:setOption(modId, row.key, row.default)
end
end
self.optionRows = self:buildOptionRows(m, schema)
self.cursor = clampIndex(self.cursor, #self.optionRows)
self:notify("DEFAULTS RESTORED")
end }
return rows
+121 -2
View File
@@ -11,7 +11,8 @@ local Manifest = {}
Manifest.PROFILES = { content = true, overhaul = true, total_conversion = true }
Manifest.PERMISSIONS = { network = true, filesystem = true,
engine_internals = true, steps = 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
@@ -132,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
@@ -223,6 +308,24 @@ function Manifest.validate(raw, path)
local github = Manifest.parseGithub(raw.github)
-- log_url: the mod's one-way crash-log reporting destination. https-only,
-- declared in the manifest so the engine reviews the target at load instead
-- of trusting per-call URLs from gameplay code, and gated on the `network`
-- permission the mod must also declare. api 1 mods never carry it: it is a
-- load violation, not a warning, because a postLog-capable mod that does not
-- opt in to networking is a bug in the manifest itself.
local logUrl = nil
if raw.log_url ~= nil then
if strict and not permissionSet.network then
violation(strict, raw.id, "log_url requires the network permission")
elseif strict and (type(raw.log_url) ~= "string"
or not raw.log_url:match("^https://")) then
violation(strict, raw.id, "log_url must be an https:// URL")
elseif strict then
logUrl = raw.log_url
end
end
assert(raw.experimental == nil or type(raw.experimental) == "boolean",
"experimental must be a boolean")
local experimental = raw.experimental == true
@@ -290,6 +393,19 @@ function Manifest.validate(raw, path)
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,
@@ -316,8 +432,11 @@ function Manifest.validate(raw, path)
affects_link = affectsLink,
permissions = permissions,
permissionSet = permissionSet,
log_url = logUrl,
options_schema = optionalFile(raw.options_schema, "options_schema"),
assets_transforms = optionalFile(raw.assets_transforms, "assets_transforms"),
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,
+207
View File
@@ -0,0 +1,207 @@
-- 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
-- A log body ceiling. A diagnostic ring (boot evidence + recent lines +
-- status) routinely exceeds 64 KiB on a long session, so the ceiling is
-- 512 KiB: generous for real support logs, still far under the 5 MiB the
-- reference loghook endpoint accepts, and small enough that a misbehaving
-- mod cannot upload arbitrary megabytes. The body is staged to a file and
-- streamed by the transport, so the ceiling is a budget, not a memory
-- spike; callers that stay under it never notice it.
Net.MAX_BODY = 512 * 1024
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
-- The closed list of postLog format switches. Anything outside it is a
-- caller bug, rejected before a job is submitted, so the surface stays
-- exactly two shapes on the wire.
local POST_FORMATS = { text = true, json = true }
-- A one-way log POST to the mod's manifest-declared log_url (https only,
-- validated in Manifest.lua). Same shape as get(): opaque handle, per-mod
-- in-flight ceiling, user agent naming the mod. The response body is never
-- returned -- a postLog is fire-and-forget reporting, and the engine has no
-- reason to hand a mod a server's reply.
function Net.postLog(loader, modId, logUrl, body, opts)
if type(body) ~= "string" or body == "" then
return nil, "log body must be a non-empty string"
end
if #body > Net.MAX_BODY then
return nil, ("log body too large (%d bytes, limit %d)"):format(#body, Net.MAX_BODY)
end
opts = type(opts) == "table" and opts or {}
for key in pairs(opts) do
if key ~= "format" then
return nil, ("unknown log option %q (format is the only switch)"):format(tostring(key))
end
end
local format = opts.format or "text"
if not POST_FORMATS[format] then
return nil, ("unknown log format %q (text and json only)"):format(tostring(format))
end
local denial = Net.urlDenial(logUrl)
if denial then return nil, denial end
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 payload = body
local contentType = "text/plain"
if format == "json" then
local Json = require("src.link.Json")
payload = Json.encode({
ts = os.time(),
mod = modId,
format = "json",
body = body,
})
contentType = "application/json"
end
local id = fetch().post(logUrl, payload, {
userAgent = "gen1recomp-mod/" .. tostring(modId),
contentType = contentType,
maxSeconds = Net.MAX_SECONDS,
})
local handle = {}
b[handle] = id
return handle
end
-- A copy of the job's state, never the engine's own table. An unknown or
-- forged handle reads as an error rather than nil, so a mod that lost track of
-- one cannot spin waiting on it forever.
function Net.poll(loader, modId, handle)
local id = bucket(loader, modId)[handle]
if not id then return { status = "error", err = "unknown request" } end
local st = fetch().poll(id)
return { status = st.status, body = st.body, err = st.err,
progress = st.progress }
end
function Net.release(loader, modId, handle)
local b = bucket(loader, modId)
local id = b[handle]
if not id then return false end
fetch().release(id)
b[handle] = nil
return true
end
function Net.cancel(loader, modId, handle)
local id = bucket(loader, modId)[handle]
if not id then return false end
fetch().cancel(id)
return true
end
-- Drop everything this mod still holds. Called when a mod unloads, so a
-- disabled mod cannot leave jobs accumulating in the shared table.
function Net.releaseAll(loader, modId)
local b = loader.netJobs and loader.netJobs[modId]
if not b then return end
local F = fetch()
for handle, id in pairs(b) do
pcall(F.cancel, id)
pcall(F.release, id)
b[handle] = nil
end
loader.netJobs[modId] = nil
end
return Net
+295
View File
@@ -0,0 +1,295 @@
-- Engine-owned import handling for files declared by a mod's required or
-- optional import arrays. Mods never receive host paths or broader
-- filesystem access: accepted bytes are copied into their own
-- mods/<id>/baseroms/ tree, where the existing mod:read sandbox can see them.
local CacheFs = require("src.import.CacheFs")
local RequiredImports = {}
local function allSpecs(manifest)
local out = {}
for _, spec in ipairs((manifest and manifest.required_imports) or {}) do
out[#out + 1] = spec
end
for _, spec in ipairs((manifest and manifest.optional_imports) or {}) do
out[#out + 1] = spec
end
return out
end
local function isRequired(spec)
return spec.required ~= false
end
RequiredImports.specs = allSpecs
RequiredImports.MAX_BYTES = 128 * 1024 * 1024
local function sizeLabel(bytes)
return ("%.1f MiB"):format(bytes / (1024 * 1024))
end
-- Check size before a caller reads an external or stored file into one large
-- Lua string. N64 sources may carry a 512-byte copier header, while stored
-- files are always canonical and therefore must match the declared size.
function RequiredImports.sizeError(spec, size, stored)
if type(size) ~= "number" then return nil end
if size > RequiredImports.MAX_BYTES then
return ("file is too large (%s; hard limit is %s)")
:format(sizeLabel(size), sizeLabel(RequiredImports.MAX_BYTES))
end
local headerAllowance = not stored and spec and spec.format == "n64" and 512 or 0
if spec and spec.size then
if size ~= spec.size and size ~= spec.size + headerAllowance then
return ("wrong file size (expected %d bytes%s, got %d)")
:format(spec.size, headerAllowance > 0 and " or a 512-byte header" or "", size)
end
end
if spec and spec.max_size and size > spec.max_size + headerAllowance then
return ("file is too large for this import (maximum %d bytes, got %d)")
:format(spec.max_size, size)
end
return nil
end
local N64_MAGIC = {
["\128\55\18\64"] = "z64", -- big endian / canonical
["\55\128\64\18"] = "v64", -- byte-swapped
["\64\18\55\128"] = "n64", -- little endian words
}
local function n64KindAt(data, offset)
return N64_MAGIC[data:sub(offset, offset + 3)]
end
-- Return canonical big-endian N64 bytes. A 512-byte copier header is
-- recognized only when valid N64 magic follows it, so arbitrary data is never
-- shortened just because its size happens to line up.
function RequiredImports.normalizeN64(data)
if type(data) ~= "string" then return nil, "selected file could not be read" end
local offset, kind = 1, n64KindAt(data, 1)
if not kind then
kind = n64KindAt(data, 513)
if kind then offset = 513 end
end
if not kind then
return nil, "expected an N64 ROM (.z64/.v64/.n64); file signature was not recognized"
end
data = data:sub(offset)
if kind == "z64" then return data end
if kind == "v64" then
if #data % 2 ~= 0 then return nil, "byte-swapped N64 ROM has an odd size" end
return (data:gsub("(.)(.)", "%2%1"))
else
if #data % 4 ~= 0 then return nil, "little-endian N64 ROM size is not word aligned" end
return (data:gsub("(.)(.)(.)(.)", "%4%3%2%1"))
end
end
function RequiredImports.normalize(spec, data)
if spec and spec.format == "n64" then
return RequiredImports.normalizeN64(data)
end
if type(data) ~= "string" then return nil, "selected file could not be read" end
return data
end
local function hexDigest(data, hashFn)
if hashFn then return hashFn(data):lower() end
if not (love and love.data and love.data.hash and love.data.encode) then
return nil, "MD5 support is unavailable in this build"
end
local digest = love.data.hash("md5", data)
if type(digest) == "userdata" and digest.getString then
digest = digest:getString()
end
return love.data.encode("string", "hex", digest):lower()
end
local function accepts(spec, digest)
for _, wanted in ipairs((spec and spec.md5) or {}) do
if wanted == digest then return true end
end
return false
end
function RequiredImports.path(manifest, spec)
return manifest.path .. "/baseroms/" .. spec.file
end
local function removedMarker(manifest, spec)
return manifest.path .. "/baseroms/.required-import-" .. spec.id .. ".removed"
end
local function receiptPath(manifest, spec)
return manifest.path .. "/baseroms/.required-import-" .. spec.id .. ".validated"
end
RequiredImports.receiptPath = receiptPath
local function parseReceipt(raw)
if type(raw) ~= "string" then return nil end
local digest, size, modtime = raw:match("^v1\n([%x]+)\n(%d+)\n([^\n]+)\n?$")
if not digest then return nil end
return digest:lower(), tonumber(size), tonumber(modtime)
end
local function cachedDigest(manifest, spec, fs, info)
-- A size alone cannot detect a same-length replacement. Require modtime as
-- well; filesystems that do not expose it simply take the safe hash path.
if not (fs and fs.read and info and info.size and info.modtime) then return nil end
local digest, size, modtime = parseReceipt(fs.read(receiptPath(manifest, spec)))
if digest and size == info.size and modtime == info.modtime
and accepts(spec, digest) then
return digest
end
return nil
end
local function writeReceipt(manifest, spec, digest, info, fs)
if not (digest and info and info.size and info.modtime) then return end
local path = receiptPath(manifest, spec)
local body = ("v1\n%s\n%d\n%s\n")
:format(digest, info.size, tostring(info.modtime))
if love and fs == love.filesystem then
local savedPrefix = CacheFs.prefix
CacheFs.prefix = ""
CacheFs.write(path, body)
CacheFs.prefix = savedPrefix
elseif fs and fs.write then
fs.write(path, body)
end
end
local function removeReceipt(manifest, spec, fs)
local path = receiptPath(manifest, spec)
if love and fs == love.filesystem then
local savedPrefix = CacheFs.prefix
CacheFs.prefix = ""
CacheFs.remove(path)
CacheFs.prefix = savedPrefix
elseif fs and fs.remove then
fs.remove(path)
end
end
-- Validate bytes against a declaration. The returned data is canonicalized
-- (notably for N64 byte order/header variants) and is what must be stored.
function RequiredImports.validateData(spec, data, hashFn)
local sourceSizeErr = type(data) == "string"
and RequiredImports.sizeError(spec, #data, false)
if sourceSizeErr then return nil, sourceSizeErr end
local normalized, normalizeErr = RequiredImports.normalize(spec, data)
if not normalized then return nil, normalizeErr end
local storedSizeErr = RequiredImports.sizeError(spec, #normalized, true)
if storedSizeErr then return nil, storedSizeErr end
local digest, hashErr = hexDigest(normalized, hashFn)
if not digest then return nil, hashErr end
if not accepts(spec, digest) then
return nil, ("MD5 mismatch (got %s)"):format(digest)
end
return normalized, digest
end
-- Validate one installed import without reading it when the engine-authored
-- receipt still matches the file's size and modification time.
function RequiredImports.validateStored(manifest, spec, fs, hashFn)
fs = fs or (love and love.filesystem)
if not (fs and fs.getInfo) then return nil, "filesystem is unavailable" end
local path = RequiredImports.path(manifest, spec)
local info = fs.getInfo(path, "file")
if not info then
removeReceipt(manifest, spec, fs)
return nil, "file is missing"
end
local sizeErr = RequiredImports.sizeError(spec, info.size, true)
if sizeErr then
removeReceipt(manifest, spec, fs)
return nil, sizeErr
end
local cached = cachedDigest(manifest, spec, fs, info)
if cached then return true, cached, true end
removeReceipt(manifest, spec, fs)
if not fs.read then return nil, "file could not be read" end
local data = fs.read(path)
local normalized, detail = RequiredImports.validateStoredData(spec, data, hashFn)
if not normalized then return nil, detail end
info = fs.getInfo(path, "file") or info
info.size = info.size or #data
writeReceipt(manifest, spec, detail, info, fs)
return true, detail, false
end
function RequiredImports.validateStoredData(spec, data, hashFn)
local normalized, detail = RequiredImports.validateData(spec, data, hashFn)
if not normalized then return nil, detail end
if normalized ~= data then
return nil, "stored N64 ROM is not canonical; choose the source file again"
end
return normalized, detail
end
function RequiredImports.inspect(manifest, fs, hashFn)
fs = fs or (love and love.filesystem)
local rows, missing, missingOptional = {}, 0, 0
for _, spec in ipairs(allSpecs(manifest)) do
local path = RequiredImports.path(manifest, spec)
local suppressed = fs and fs.getInfo
and fs.getInfo(removedMarker(manifest, spec), "file") ~= nil
local exists = fs and fs.getInfo and fs.getInfo(path, "file") ~= nil
local valid, detail = RequiredImports.validateStored(manifest, spec, fs, hashFn)
local row = { id = spec.id, name = spec.name, file = spec.file,
description = spec.description, format = spec.format, path = path,
present = valid == true, digest = valid and detail or nil,
error = exists and not valid and detail or nil,
suppressed = suppressed, required = isRequired(spec), spec = spec }
if not row.present then
if row.required then missing = missing + 1
else missingOptional = missingOptional + 1 end
end
rows[#rows + 1] = row
end
return rows, missing, missingOptional
end
function RequiredImports.importData(manifest, importId, data, opts)
opts = opts or {}
local spec
for _, candidate in ipairs(allSpecs(manifest)) do
if candidate.id == importId then spec = candidate break end
end
if not spec then return nil, "unknown required import: " .. tostring(importId) end
local normalized, digest = RequiredImports.validateData(spec, data, opts.hash)
if not normalized then return nil, digest end
local savedPrefix = CacheFs.prefix
CacheFs.prefix = ""
CacheFs.remove(receiptPath(manifest, spec))
local ok, err = CacheFs.write(RequiredImports.path(manifest, spec), normalized)
if ok then CacheFs.remove(removedMarker(manifest, spec)) end
if ok and love and love.filesystem and love.filesystem.getInfo then
local info = love.filesystem.getInfo(RequiredImports.path(manifest, spec), "file")
writeReceipt(manifest, spec, digest, info, love.filesystem)
end
CacheFs.prefix = savedPrefix
if not ok then return nil, "could not copy import: " .. tostring(err) end
return true, digest
end
function RequiredImports.remove(manifest, importId)
for _, spec in ipairs(allSpecs(manifest)) do
if spec.id == importId then
local savedPrefix = CacheFs.prefix
CacheFs.prefix = ""
CacheFs.remove(RequiredImports.path(manifest, spec))
CacheFs.remove(receiptPath(manifest, spec))
local marked, markErr = CacheFs.write(removedMarker(manifest, spec), "removed\n")
CacheFs.prefix = savedPrefix
if not marked then return nil, "could not remember removal: " .. tostring(markErr) end
return true
end
end
return nil, "unknown required import: " .. tostring(importId)
end
return RequiredImports
+42 -13
View File
@@ -46,11 +46,11 @@ function Sandbox.moduleDenial(name, permissionSet)
local reason = DENIED[root]
if reason then
return ("%s is not available to mods (it grants %s); use mod.storage, "
.. "mod:read and the engine API instead"):format(name, reason)
.. "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 and the "
.. "engine API instead"):format(name)
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)
@@ -68,16 +68,24 @@ end
-- 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 and mod:read", thread = true,
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,
}
local loveProxy
local function loveFacade()
if loveProxy or not _G.love then return loveProxy end
loveProxy = setmetatable({}, {
-- 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,
@@ -85,11 +93,20 @@ local function loveFacade()
end
return _G.love[key]
end,
__newindex = function(_, key)
-- 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,
})
return loveProxy
end
-- ------- the environment
@@ -177,8 +194,12 @@ end
-- 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)
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
@@ -192,15 +213,23 @@ end
function Sandbox.envFor(opts)
opts = opts or {}
local compat = opts.compat
local env = baseGlobals()
env.love = loveFacade()
env.require = sandboxedRequire(opts.modId, opts.permissions)
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
+8
View File
@@ -1017,6 +1017,9 @@ R.trainers = {
index = f.opt(f.int(0, 255)),
-- unused vanilla classes ship without a pic, so it cannot be required
pic = f.opt(f.path),
-- Full-color portrait: skip the 4-shade SGB/GBC remap, same flag pokemon
-- and sprites already carry.
trueColor = f.opt(f.bool),
-- Optional Advanced-mode OBJ palette source for a custom trainer portrait.
-- It follows the same ROM crosswalk form as sprites.paletteSource.
paletteSource = f.opt(f.str),
@@ -1068,6 +1071,11 @@ R.trainers = {
gen2Fields = {
id = f.opt(f.str), name = f.str,
index = f.opt(f.int(0, 255)),
-- class frontpic; when set, this wins over menu_gfx.battleHud.trainerPics
pic = f.opt(f.path),
-- Full-color portrait: skip the GBC 4-shade remap, same flag Gen 1
-- trainers and pokemon already carry.
trueColor = f.opt(f.bool),
baseMoney = f.opt(f.int(0)),
-- the class's battle theme; Gen 1 spells the same idea `battleTheme`,
-- but this is the extractor's own key and a strict rename would reject
+132 -11
View File
@@ -1,4 +1,5 @@
-- Data-only per-mod persistence, scoped by game version and opaque playthrough.
-- Data-only and opaque-byte per-mod persistence, scoped by game version and
-- opaque playthrough.
-- This module is engine-private; Loader exposes only the bound facade methods.
local SaveData = require("src.core.SaveData")
@@ -7,6 +8,7 @@ local Version = require("src.core.Version")
local Storage = {}
Storage.__index = Storage
Storage.MAX_BYTES = 512 * 1024 * 1024
local ROOT = "mod_storage"
@@ -49,6 +51,20 @@ local function decodeAt(fs, path)
return data, body
end
local function readOpaqueAt(fs, path)
if not (fs.getInfo and fs.getInfo(path)) then return nil end
local body = fs.read and fs.read(path)
if type(body) ~= "string" then return nil end
return body
end
local function hasAny(fs, paths)
for _, path in ipairs(paths) do
if fs.getInfo(path) then return true end
end
return false
end
function Storage.new(modId, fs)
assert(validSegment(modId), "Storage.new needs a safe mod id")
return setmetatable({ modId = modId, injectedFs = fs }, Storage)
@@ -132,6 +148,10 @@ function Storage:selected(game)
end,
read = function(_, key) return self:read(selectedGame, key) end,
write = function(_, key, value) return self:write(selectedGame, key, value) end,
readBytes = function(_, key) return self:readBytes(selectedGame, key) end,
writeBytes = function(_, key, bytes)
return self:writeBytes(selectedGame, key, bytes)
end,
list = function(_, prefix) return self:list(selectedGame, prefix) end,
delete = function(_, key) return self:delete(selectedGame, key) end,
}
@@ -147,7 +167,7 @@ function Storage:context(game)
}
end
function Storage:_names(game, key, allowEmpty)
function Storage:_names(game, key, allowEmpty, extension)
if not validKey(key, allowEmpty) then
return failure("invalid_key",
"Storage keys use nonempty letters, numbers, underscore, dash and slash segments.")
@@ -155,12 +175,19 @@ function Storage:_names(game, key, allowEmpty)
local scope, code, message = self:_scope(game)
if not scope then return nil, code, message end
local path = scope.base .. (key ~= "" and ("/" .. key) or "")
return scope, path .. ".lua", path .. ".lua.bak", path .. ".lua.tmp"
extension = extension or ".lua"
return scope, path .. extension, path .. extension .. ".bak",
path .. extension .. ".tmp", path
end
function Storage:write(game, key, value)
local scope, main, bak, tmp = self:_names(game, key, false)
local scope, main, bak, tmp, path = self:_names(game, key, false)
if not scope then return false, main, bak end
local fs = scope.fs
if hasAny(fs, { path .. ".bin", path .. ".bin.bak", path .. ".bin.tmp" }) then
return false, "type_conflict",
"A byte value already exists for this storage key; delete it first."
end
if type(value) ~= "table" then
return false, "encode_failed", "Storage values must be data-only tables."
end
@@ -170,7 +197,6 @@ function Storage:write(game, key, value)
.. tostring(encoded)
end
local fs = scope.fs
ensureParent(fs, main)
local _, previous = decodeAt(fs, main)
if not previous then _, previous = decodeAt(fs, bak) end
@@ -206,9 +232,13 @@ function Storage:write(game, key, value)
end
function Storage:read(game, key)
local scope, main, bak, tmp = self:_names(game, key, false)
local scope, main, bak, tmp, path = self:_names(game, key, false)
if not scope then return nil, main, bak end
local fs = scope.fs
if hasAny(fs, { path .. ".bin", path .. ".bin.bak", path .. ".bin.tmp" }) then
return failure("type_mismatch",
"This storage key contains opaque bytes; use readBytes instead.")
end
local data, body = decodeAt(fs, main)
if data then return data end
@@ -226,6 +256,80 @@ function Storage:read(game, key)
return data
end
function Storage:writeBytes(game, key, bytes)
local scope, main, bak, tmp, path = self:_names(game, key, false, ".bin")
if not scope then return false, main, bak end
if type(bytes) ~= "string" then
return false, "invalid_bytes", "Opaque storage values must be strings."
end
if #bytes > Storage.MAX_BYTES then
return false, "size_limit",
("Opaque storage values cannot exceed %d bytes."):format(Storage.MAX_BYTES)
end
local fs = scope.fs
if hasAny(fs, { path .. ".lua", path .. ".lua.bak", path .. ".lua.tmp" }) then
return false, "type_conflict",
"A table value already exists for this storage key; delete it first."
end
ensureParent(fs, main)
local previous = readOpaqueAt(fs, main)
if previous == nil then previous = readOpaqueAt(fs, bak) end
local ok, err = fs.write(tmp, bytes)
if not ok then
return false, "write_failed", "Could not stage opaque storage data: " .. tostring(err)
end
local staged = readOpaqueAt(fs, tmp)
if staged == nil or staged ~= bytes then
remove(fs, tmp)
return false, "verify_failed", "Staged opaque storage data could not be verified."
end
if previous ~= nil then fs.write(bak, previous) end
ok, err = fs.write(main, bytes)
if not ok then
remove(fs, tmp)
return false, "write_failed",
"Could not replace opaque storage data: " .. tostring(err)
end
local verified = readOpaqueAt(fs, main)
if verified == nil or verified ~= bytes then
remove(fs, main)
remove(fs, tmp)
return false, "verify_failed",
"Replacement opaque storage data could not be verified."
end
fs.write(bak, bytes)
remove(fs, tmp)
return true
end
function Storage:readBytes(game, key)
local scope, main, bak, tmp, path = self:_names(game, key, false, ".bin")
if not scope then return nil, main, bak end
local fs = scope.fs
if hasAny(fs, { path .. ".lua", path .. ".lua.bak", path .. ".lua.tmp" }) then
return failure("type_mismatch",
"This storage key contains table data; use read instead.")
end
local bytes = readOpaqueAt(fs, main)
if bytes ~= nil then return bytes end
bytes = readOpaqueAt(fs, tmp)
if bytes == nil then bytes = readOpaqueAt(fs, bak) end
if bytes == nil then
return nil, "not_found", "No valid opaque value exists for this key."
end
ensureParent(fs, main)
if fs.write(main, bytes) then fs.write(bak, bytes) end
remove(fs, tmp)
return bytes
end
function Storage:list(game, prefix)
prefix = prefix or ""
local scope, main, codeOrBak = self:_names(game, prefix, true)
@@ -237,13 +341,23 @@ function Storage:list(game, prefix)
local base = scope.base
local start = prefix == "" and base or (base .. "/" .. prefix)
local out = {}
local out, seen = {}, {}
local function add(logical)
if not seen[logical] then
seen[logical] = true
out[#out + 1] = logical
end
end
local function walk(path, logical)
local info = fs.getInfo(path)
if not info then return end
if info.type == "file" then
if path:sub(-4) == ".lua" then out[#out + 1] = logical:sub(1, -5) end
local suffix = path:sub(-4)
if suffix == ".lua" or suffix == ".bin" then
add(logical:sub(1, -5))
end
return
end
for _, child in ipairs(fs.getDirectoryItems(path) or {}) do
@@ -254,7 +368,9 @@ function Storage:list(game, prefix)
-- A prefix may identify one exact key or a directory of keys.
if fs.getInfo(start .. ".lua") then
out[#out + 1] = prefix
add(prefix)
elseif fs.getInfo(start .. ".bin") then
add(prefix)
else
walk(start, prefix)
end
@@ -263,15 +379,20 @@ function Storage:list(game, prefix)
end
function Storage:delete(game, key)
local scope, main, bak, tmp = self:_names(game, key, false)
local scope, main, bak, tmp, path = self:_names(game, key, false)
if not scope then return false, main, bak end
local fs = scope.fs
if not (fs.getInfo(main) or fs.getInfo(bak) or fs.getInfo(tmp)) then
local byteMain, byteBak, byteTmp = path .. ".bin", path .. ".bin.bak", path .. ".bin.tmp"
if not (fs.getInfo(main) or fs.getInfo(bak) or fs.getInfo(tmp)
or fs.getInfo(byteMain) or fs.getInfo(byteBak) or fs.getInfo(byteTmp)) then
return false, "not_found", "No stored value exists for this key."
end
remove(fs, main)
remove(fs, bak)
remove(fs, tmp)
remove(fs, byteMain)
remove(fs, byteBak)
remove(fs, byteTmp)
return true
end
+73
View File
@@ -0,0 +1,73 @@
-- Worker state behind src/mods/Job.lua. One per job, not a pool: a reused
-- state would carry one mod's globals into another mod's job.
--
-- This is the file that makes running mod Lua off the main thread safe. The
-- mod's chunk is loaded into the SAME sandbox environment the main thread
-- builds (Sandbox.envFor), so love.filesystem, io, os, debug, ffi and package
-- are as absent here as they are there -- even though this state required
-- love.filesystem to bootstrap itself.
--
-- A job is pure compute: plain data in, plain data out, no engine API, no
-- game state, no storage. require is refused outright rather than reaching
-- src.* -- an engine module loaded in a second state would be a second
-- instance writing the same files as the main thread's.
require("love.thread")
require("love.filesystem")
require("love.timer")
local modId, scriptPath, argChannel, resultChannel, permissionsJson = ...
-- Fresh love threads have no "src.*" searcher (see src/net/fetch_worker.lua),
-- so install one before Sandbox's own requires run.
table.insert(package.loaders or package.searchers, function(name)
local path = name:gsub("%.", "/") .. ".lua"
if not love.filesystem.getInfo(path) then return nil end
return love.filesystem.load(path)
end)
local resCh = love.thread.getChannel(resultChannel)
local function fail(err)
resCh:push({ ok = false, err = tostring(err) })
end
local ok, err = pcall(function()
local Sandbox = require("src.mods.Sandbox")
local Json = require("src.link.Json")
local permissions = {}
if type(permissionsJson) == "string" and permissionsJson ~= "" then
local decoded = select(2, pcall(Json.decode, permissionsJson))
if type(decoded) == "table" then permissions = decoded end
end
local env = Sandbox.envFor({ modId = modId, permissions = permissions })
-- A job cannot reach the engine. Anything it needs comes in through its
-- argument and goes back through its return value.
env.require = function(name)
error(("[%s] require(%q) is not available inside a background job; a job "
.. "takes plain data and returns plain data"):format(modId,
tostring(name)), 2)
end
local chunk, loadErr = Sandbox.loadFile(love.filesystem, scriptPath, env)
if not chunk then error(loadErr or ("could not load " .. scriptPath), 0) end
local arg = love.thread.getChannel(argChannel):pop()
-- NO in-worker time budget, deliberately. A debug count hook was the
-- obvious way to stop a runaway, and it does not work: LuaJIT swallows an
-- error raised from a hook (measured: ~5000 raises a second, the loop
-- running straight through them), and the raising itself wedged the whole
-- process -- the main thread stopped being scheduled at all. Without the
-- hook a runaway job simply spins on its own core, the game stays
-- responsive, and it quits normally. Job.poll enforces maxSeconds on the
-- main thread so the MOD is never left waiting; the work itself runs to its
-- own end.
local ranOk, result = pcall(chunk, arg)
if not ranOk then error(result, 0) end
resCh:push({ ok = true, result = result })
end)
if not ok then fail(err) end
+11
View File
@@ -132,6 +132,17 @@ function Fetch.get(url, opts)
accept = opts.accept, maxSeconds = opts.maxSeconds })
end
-- POST a body to a URL, one-way. The result carries no body: postLog
-- reporting never trusts a server's reply, so the worker surfaces only
-- ok/error and the transport's complaint.
-- opts: { userAgent, contentType, maxSeconds }
function Fetch.post(url, body, opts)
opts = opts or {}
return submit({ kind = "post", url = url, body = body,
userAgent = opts.userAgent or "gen1recomp",
contentType = opts.contentType, maxSeconds = opts.maxSeconds })
end
-- Download a URL to `saveRel`, a path relative to the LOVE save directory.
-- Progress is reported as a 0..1 fraction when `size` is known.
function Fetch.download(url, saveRel, opts)
+100
View File
@@ -0,0 +1,100 @@
-- Load gen1tls from next to the exe and put its poll API on love.system --
-- same tlsOpen / tlsSend / ... shape Android already exposes through JNI.
--
-- Mods can't require("ffi") under the sandbox, so they can't load the DLL
-- themselves even when it's sitting right there. We do it here, before any
-- mod runs. LegacyCompat's love.system shim forwards the tls* keys through
-- (clipboard / openURL stay stubbed).
--
-- true = tlsOpen is ready. Already present (Android), no FFI, or no DLL:
-- just return false and move on; plain ws:// rooms don't care.
local Gen1Tls = {}
local function exeDir()
if love and love.filesystem and love.filesystem.getSourceBaseDirectory then
local base = love.filesystem.getSourceBaseDirectory()
if type(base) == "string" and base ~= "" then return base end
end
if type(arg) == "table" and type(arg[0]) == "string" then
local dir = arg[0]:match("^(.*)[/\\]")
if dir and dir ~= "" then return dir end
end
return "."
end
local function fileReadable(path)
local f = io.open(path, "rb")
if not f then return false end
f:close()
return true
end
local function libNames()
local osName = (love and love.system and love.system.getOS and love.system.getOS()) or ""
if osName == "Windows" then return { "gen1tls.dll" } end
if osName == "OS X" then return { "libgen1tls.dylib", "gen1tls.dylib" } end
if osName == "Linux" then return { "libgen1tls.so", "gen1tls.so" } end
return { "gen1tls.dll", "libgen1tls.so", "libgen1tls.dylib" }
end
function Gen1Tls.install()
if not (love and love.system) then return false end
if type(love.system.tlsOpen) == "function" then return true end
local okFfi, ffi = pcall(require, "ffi")
if not okFfi or type(ffi) ~= "table" then return false end
ffi.cdef[[
int gen1tls_open(const char *host, int port);
int gen1tls_status(int handle);
int gen1tls_send(int handle, const char *data, int length);
int gen1tls_receive(int handle, char *buf, int max);
int gen1tls_error(int handle, char *buf, int max);
void gen1tls_close(int handle);
]]
local dir = exeDir()
local lib
for _, name in ipairs(libNames()) do
local path = dir .. "/" .. name
if fileReadable(path) then
local ok, loaded = pcall(ffi.load, path)
if ok then lib = loaded; break end
end
local ok, loaded = pcall(ffi.load, name)
if ok then lib = loaded; break end
end
if not lib then return false end
local errBuf = ffi.new("char[512]")
local recvBuf = ffi.new("char[65536]")
love.system.tlsOpen = function(host, port)
return lib.gen1tls_open(host, tonumber(port) or 0)
end
love.system.tlsStatus = function(handle)
return lib.gen1tls_status(handle)
end
love.system.tlsSend = function(handle, data)
data = data or ""
return lib.gen1tls_send(handle, data, #data)
end
love.system.tlsReceive = function(handle, max)
max = math.min(tonumber(max) or 8192, 65536)
if max <= 0 then return "" end
local n = lib.gen1tls_receive(handle, recvBuf, max)
if n <= 0 then return "" end
return ffi.string(recvBuf, n)
end
love.system.tlsError = function(handle)
if lib.gen1tls_error(handle, errBuf, 512) == 0 then return nil end
return ffi.string(errBuf)
end
love.system.tlsClose = function(handle)
lib.gen1tls_close(handle)
end
return true
end
return Gen1Tls
+17
View File
@@ -99,6 +99,20 @@ local function doDownload(job)
post({ id = job.id, ok = true, path = rel, done = true })
end
local function doPost(job)
if not HostShell then
post({ id = job.id, ok = false, err = "no transport" })
return
end
local ok, err = HostShell.httpPost(job.url, job.body, job.contentType,
job.userAgent, tonumber(job.maxSeconds) or GET_MAX_SECONDS)
if not ok then
post({ id = job.id, ok = false, err = err or "post failed" })
return
end
post({ id = job.id, ok = true, done = true })
end
while true do
local job = cmdCh:demand()
-- The flag is checked before the job's KIND, so a worker woken by a
@@ -114,6 +128,9 @@ while true do
elseif job.kind == "get" then
local ok, err = pcall(doGet, job)
if not ok then post({ id = job.id, ok = false, err = tostring(err) }) end
elseif job.kind == "post" then
local ok, err = pcall(doPost, job)
if not ok then post({ id = job.id, ok = false, err = tostring(err) }) end
elseif job.kind == "download" then
local ok, err = pcall(doDownload, job)
if not ok then post({ id = job.id, ok = false, err = tostring(err) }) end
+10
View File
@@ -253,6 +253,16 @@ function TextBox:beginLine()
table.insert(self.shown, {})
end
function TextBox:visibleText()
local page = self.pages[self.pageIndex]
if not page then return nil end
local out, count = {}, #(self.shown or {})
for i = math.max(1, self.lineIndex - count + 1), self.lineIndex do
if page[i] ~= nil then out[#out + 1] = page[i] end
end
return #out > 0 and out or nil
end
function TextBox:update(dt)
local input = self.game.input
self.blink = (self.blink + 1) % 60
+1
View File
@@ -383,6 +383,7 @@ local function pickTargetAndUse(game, battle, id, list)
local def = game.data.items[id]
local opts = {
pickOnly = true,
battle = battle,
-- HP medicine animates its bar with the picker still up (#252). Only
-- out of battle: the in-battle tail closes the bag list underneath
-- first, which needs the picker already gone.
+7 -3
View File
@@ -98,14 +98,14 @@ function OakSpeech.resolvePic(game, desc, speech)
local t = desc.type
if t == "trainer" then
if speech and desc.id == "OPP_PROF_OAK" and speech.oakPic then
return speech.oakPic, false, false
return speech.oakPic, false, speech.oakTrueColor or false
end
if speech and desc.id == "OPP_RIVAL1" and speech.rivalPic then
return speech.rivalPic, false, false
return speech.rivalPic, false, speech.rivalTrueColor or false
end
local trainers = game.data.trainers or {}
local tr = trainers[desc.id]
return tryImage(tr and tr.pic), false, false
return tryImage(tr and tr.pic), false, tr and tr.trueColor or false
elseif t == "pokemon" then
if speech and desc.id == speech.demoSpecies and speech.demoPic then
return speech.demoPic, desc.flip and true or false, speech.demoTrueColor
@@ -241,7 +241,11 @@ function OakSpeech.new(game, onDone)
self.answers = {}
local trainers = game.data.trainers or {}
self.oakPic = tryImage(trainers.OPP_PROF_OAK and trainers.OPP_PROF_OAK.pic)
self.oakTrueColor = self.oakPic
and trainers.OPP_PROF_OAK and trainers.OPP_PROF_OAK.trueColor or false
self.rivalPic = tryImage(trainers.OPP_RIVAL1 and trainers.OPP_RIVAL1.pic)
self.rivalTrueColor = self.rivalPic
and trainers.OPP_RIVAL1 and trainers.OPP_RIVAL1.trueColor or false
local oakGfx = (game.data.field and game.data.field.oakSpeech) or {}
self.cfg = oakGfx
-- the show-off mon and the name length cap come from data; the vanilla
+15 -54
View File
@@ -289,6 +289,7 @@ function PartyMenu.new(game, opts)
opts = opts or {}
local self = setmetatable({}, PartyMenu)
self.game = game
local party = opts.party or (opts.battle and opts.battle.playerParty)
-- PartyMenuInit (home/pokemon.asm) seeds the cursor from
-- wPartyAndBillsPCSavedMenuItem rather than from zero, and
-- HandlePartyMenuInput writes wCurrentMenuItem back into it on every
@@ -297,7 +298,7 @@ function PartyMenu.new(game, opts)
-- both zero the byte, which BattleState mirrors. The clamp covers a
-- party that shrank (deposit / release) while the saved index was
-- pointing past the end. #768
local count = #(opts.party or (game.save and game.save.party) or {})
local count = #(party or (game.save and game.save.party) or {})
self.index = math.min(math.max(1, game.partyMenuSavedIndex or 1),
math.max(1, count))
self.onSwitch = opts.onSwitch
@@ -314,7 +315,7 @@ function PartyMenu.new(game, opts)
self.tmhm = opts.tmhm
self.forceSwitch = opts.forceSwitch
self.battle = opts.battle
self.party = opts.party -- link battles pass their clamped copies
self.party = party -- link/scoped battles pass their local party view
self.swapFrom = nil
self.submenu = nil
self.subIndex = 1
@@ -453,30 +454,7 @@ function PartyMenu:update(dt)
refuseBadge(self)
return
end
local TextBox = require("src.render.TextBox")
local Transition = require("src.render.Transition")
-- .flash prints on any map, but the light it records is this map's:
-- home/overworld.asm re-arms wMapPalOffset on the next dark map, so
-- a FLASH used in daylight must not carry into Rock Tunnel
local wasDark = ow and ow.dark
if wasDark then self.game.save.flashLit = true end
self.game.stack:push(TextBox.new(self.game,
self.game.data.text._FlashLightsAreaText
or Strings("A blinding FLASH\nlights the area!"), function()
self:close()
-- setDark, not a bare field write: ADVANCED carries the darkness
-- in a baked atlas, so lighting the cave drops every resident map
-- and rebakes this one (#383). It runs HERE, before the blink,
-- because start_sub_menus.asm .flash clears wMapPalOffset before
-- PrintText and blinks last of all: the cave is already lit by the
-- time GBPalWhiteOutWithDelay3 runs. Hanging the rebuild off the
-- blink's completion instead left that rebuild's whole cost --
-- seconds of per-pixel atlas baking on a phone -- on screen as a
-- solid white frame with nothing under it, which reads as a
-- lockup (#610).
if wasDark then ow:setDark(false) end
self.game.stack:push(Transition.whiteFlash(self.game))
end))
ow:useFlashFieldMove(function() self:close() end)
return
elseif action == "surf" then
-- start_sub_menus.asm .surf: SOULBADGE-gated (useSurfFieldMove),
@@ -504,12 +482,7 @@ function PartyMenu:update(dt)
-- GBPalWhiteOutWithDelay3 blink, and the simulated pad press
-- steps the player forward onto land (or across a connection
-- strip when the shore is the next map's edge)
self.game.stack:pop()
ow.player.surfing = false
require("src.core.Music").setSurfing(self.game.data, false)
self.game.stack:push(Transition.whiteFlash(self.game, nil, function()
ow:stepForwardOrCrossEdge(ow.player.facing)
end))
ow:stopSurfing(function() self.game.stack:pop() end)
return
end
local TextBox = require("src.render.TextBox")
@@ -566,30 +539,18 @@ function PartyMenu:update(dt)
-- .strength, GBPalWhiteOutWithDelay3 blinks the screen white
-- before CloseTextDisplay returns to the map.
local ow = self.game.overworld
if ow and not ow:partyKnows("STRENGTH") then
refuseBadge(self)
if ow and ow.useStrengthFieldMove then
if not ow:partyKnows("STRENGTH") then
refuseBadge(self)
return
end
ow:useStrengthFieldMove(mon, function() self:close() end)
return
elseif ow and ow.useFieldMove then
ow:useFieldMove("STRENGTH", mon)
self:close()
return
end
local TextBox = require("src.render.TextBox")
local Transition = require("src.render.Transition")
local def = self.game.data.pokemon[mon.species]
local name = mon.nickname or def.name
ow.strengthActive = true
local t1 = (self.game.data.text._UsedStrengthText
or Strings("{RAM:wNameBuffer} used\nSTRENGTH.")):gsub("{RAM:wNameBuffer}", name)
local t2 = (self.game.data.text._CanMoveBouldersText
or Strings("{RAM:wNameBuffer} can\nmove boulders.")):gsub("{RAM:wNameBuffer}", name)
-- like surf (#320, #385): both texts print with the party menu
-- still on screen, and the blink IS the menu closing afterwards,
-- not a flashbang on the empty map
self.game.stack:push(TextBox.new(self.game, t1, function()
self.game.stack:push(TextBox.new(self.game, t2, function()
self:close()
self.game.stack:push(Transition.whiteFlash(self.game))
end))
end, { auto = { sound = function()
return require("src.core.Sound").playCry(self.game.data, mon.species)
end } }))
return
elseif action == "softboiled" then
-- field SOFTBOILED (StartMenu_Pokemon .softboiled): transfer
+86 -47
View File
@@ -121,6 +121,8 @@ local TEXT_ASK_FORGET_MOVE = Strings.source(
-- Gen 1 uses. The second label is the two-glyph <PK><MN> ligature (charmap
-- $e1/$e2), which is what makes it fit a six-tile column.
local MENU = { "FIGHT", "<PK><MN>", "PACK", "RUN" }
local MENU_ACTION = { FIGHT = "fight", ["<PK><MN>"] = "party",
PACK = "item", RUN = "run" }
local MENU_BOX_X = 8
local MENU_COL_SPACING = 6
@@ -185,6 +187,19 @@ function BattleState:statusHUDVisible()
self) ~= false
end
-- Class frontpic for the battle intro. A trainers-registry `pic` wins over
-- the extracted menu_gfx sheet; `trueColor` skips the GBC 4-shade remap.
-- Returns path, trueColor.
function BattleState.trainerArt(data, classId)
if not classId then return nil, false end
local classes = data and data.gen2Trainers and data.gen2Trainers.classes
local classDef = classes and classes[classId]
local hud = data and data.gen2MenuGfx and data.gen2MenuGfx.battleHud
local path = (classDef and classDef.pic)
or (hud and hud.trainerPics and hud.trainerPics[classId])
return path, (classDef and classDef.trueColor) and true or false
end
-- opts: battle (a Battle), onDone(outcome), save
function BattleState.new(game, opts)
opts = opts or {}
@@ -308,6 +323,7 @@ function BattleState.new(game, opts)
-- pic is a cache asset, so an import made before the extractor grew that
-- stage has none and the mon stands in for the whole intro.
self.showEnemyTrainer = false
self.enemyTrainerTrueColor = false
-- The CLASS CONSTANT (BUG_CATCHER), which is what both tables this looks the
-- pic up in are keyed by: menu_gfx's trainerPics is written out of
-- constants.trainerClassOrder, and palettes.trainers out of the same names.
@@ -317,17 +333,19 @@ function BattleState.new(game, opts)
-- no palette for every trainer the world starts, which is all of them.
-- `classId` is the trainers.lua key, i.e. the constant; `className` is the
-- DISPLAY name ("BUG CATCHER", with the space) and is not a key at all.
-- A class record's own `pic` / `trueColor` (the trainers registry) wins
-- over the extracted sheet, so a mod can drop in full-color art.
local enemyTrainer = self.battle and self.battle.trainer
self.enemyTrainerClass = enemyTrainer
and (enemyTrainer.classId or enemyTrainer.class)
local trainerPics = hudGfx and hudGfx.trainerPics
local trainerPath = self.enemyTrainerClass and trainerPics
and trainerPics[self.enemyTrainerClass]
local trainerPath, trainerTrueColor =
BattleState.trainerArt(data, self.enemyTrainerClass)
if trainerPath then
local ok, image = pcall(Assets.image, trainerPath)
if ok and image then
self.enemyTrainerImage = image
self.enemyTrainerPath = trainerPath
self.enemyTrainerTrueColor = trainerTrueColor and true or false
self.showEnemyTrainer = true
end
end
@@ -588,7 +606,10 @@ function BattleState:drawPic(mon, back)
-- slides it out (InitEnemyTrainer, engine/battle/core.asm:7848).
local enemyTrainer = (not back) and self.showEnemyTrainer
and self.enemyTrainerImage
if enemyTrainer then image, path = enemyTrainer, self.enemyTrainerPath end
if enemyTrainer then
image, path = enemyTrainer, self.enemyTrainerPath
trueColor = self.enemyTrainerTrueColor
end
if not image then return end
local side = back and "player" or "enemy"
local anim = self:animPicState(side)
@@ -1641,6 +1662,64 @@ function BattleState:playerMoves()
return (self.battle and self.battle.player and self.battle.player.moves) or {}
end
-- One semantic path for the native command menu and mod.battle intents.
function BattleState:chooseMenu(choice)
if self.phase ~= "menu" then return nil, "battle menu is not active" end
if choice == "fight" then
-- CheckPlayerHasUsableMoves skips MoveSelectionScreen and uses Struggle.
local fighter = self.battle and self.battle.player
if fighter and #self:playerMoves() > 0
and not self.battle:hasUsableMoves(fighter) then
self:submit({ kind = "move", move = Battle.STRUGGLE })
else
self.phase = "moves"
-- MoveSelectionScreen reopens on the last used move, clamped if the
-- moveset shrank since then.
local moves = self:playerMoves()
self.moveIndex = math.max(1,
math.min(self.moveIndex or 1, math.max(1, #moves)))
end
elseif choice == "run" then
self:submit({ kind = "run" })
elseif choice == "item" then
if self.tutorial then
self:openTutorialPack()
elseif self.contest then
self:throwParkBall()
else
self:openPack()
end
elseif choice == "party" then
self:openParty()
else
return nil, "unknown battle menu choice"
end
return true
end
function BattleState:chooseMove(index)
if self.phase ~= "moves" then return nil, "move menu is not active" end
local move = self:playerMoves()[index]
if not move then return nil, "invalid move slot" end
self.moveIndex = index
self.moveSwapIndex = nil
if (move.pp or 0) <= 0 then
self:refuseMove(TEXT_NO_PP_LEFT)
elseif self.battle:moveDisabled(self.battle.player, move.id) then
self:refuseMove(TEXT_MOVE_DISABLED)
else
self:submit({ kind = "move", move = move.id })
end
return true
end
function BattleState:cancelMove()
if self.phase ~= "moves" then return nil, "move menu is not active" end
self.moveSwapIndex = nil
self.phase = "menu"
return true
end
-- MoveSelectionScreen's `.pressed_select` (engine/battle/core.asm:5320-5374).
-- SELECT marks a slot, SELECT again swaps the marked slot with the one under
-- the cursor, and A or B clears the mark without swapping (the A arm opens
@@ -1814,37 +1893,7 @@ function BattleState:update(_dt)
or self.menuIndex - 2
elseif input:wasPressed("a") then
self:playSfx("Sfx_ReadText2")
local choice = MENU[self.menuIndex]
if choice == "FIGHT" then
-- `call .CheckPlayerHasUsableMoves / ret z` (engine/battle/core.asm
-- :5058-5059): a mon with nothing to spend never sees the list.
local fighter = self.battle and self.battle.player
if fighter and #self:playerMoves() > 0
and not self.battle:hasUsableMoves(fighter) then
return self:submit({ kind = "move", move = Battle.STRUGGLE })
end
self.phase = "moves"
-- MoveSelectionScreen seeds wMenuCursorY from wCurMoveNum + 1
-- (engine/battle/core.asm:5111) and the A-press writes the picked row
-- back, so the list reopens on the move used last turn; only
-- SendOutPlayerMon and CleanUpBattleRAM zero it. Clamp rather than
-- reset, for a moveset that shrank (Mimic, a forgotten slot).
local moves = self:playerMoves()
self.moveIndex = math.max(1,
math.min(self.moveIndex or 1, math.max(1, #moves)))
elseif choice == "RUN" then
self:submit({ kind = "run" })
elseif choice == "PACK" then
if self.tutorial then
self:openTutorialPack()
elseif self.contest then
self:throwParkBall()
else
self:openPack()
end
else
self:openParty()
end
self:chooseMenu(MENU_ACTION[MENU[self.menuIndex]])
end
return
end
@@ -1865,22 +1914,12 @@ function BattleState:update(_dt)
elseif input:wasPressed("b") then
-- B leaves the list, and a mark never survives it
self:playSfx("Sfx_ReadText2")
self.moveSwapIndex = nil
self.phase = "menu"
self:cancelMove()
elseif input:wasPressed("a") then
-- `xor a / ld [wSwappingMove], a` opens the A arm: choosing a move
-- cancels a pending swap rather than performing it
self:playSfx("Sfx_ReadText2")
self.moveSwapIndex = nil
local move = moves[self.moveIndex]
if not move then return end
-- `.no_pp_left` and `.move_disabled` both end on `jp MoveSelectionScreen`
-- (engine/battle/core.asm:5213-5246): neither spends the turn.
if (move.pp or 0) <= 0 then return self:refuseMove(TEXT_NO_PP_LEFT) end
if self.battle:moveDisabled(self.battle.player, move.id) then
return self:refuseMove(TEXT_MOVE_DISABLED)
end
self:submit({ kind = "move", move = move.id })
self:chooseMove(self.moveIndex)
end
return
end
+1 -1
View File
@@ -154,7 +154,7 @@ function HallOfFame.monPlacements(mon, def)
put(out, genderGlyph(mon.gender), 18, 13)
-- (8,14) is a bare '/', so the nickname starts at (9,14).
put(out, "/", 8, 14)
put(out, mon.nickname or mon.species, 9, 14)
put(out, mon.nickname or mon.name or mon.species, 9, 14)
put(out, levelText(mon.level), 1, 16)
end
-- '<ID>' '№' '/' at (7,16), (8,16), (9,16), then five digits at (10,16).
+2 -2
View File
@@ -630,7 +630,7 @@ function PackMenu:openTeachParty(row)
if not allowed then
if game.say then
game:say(("%s can't learn %s!"):format(
mon.nickname or mon.species or "?", moveName))
require("src.battle.gen2.Mon").displayName(mon), moveName))
end
return
end
@@ -638,7 +638,7 @@ function PackMenu:openTeachParty(row)
if move.id == moveId then
if game.say then
game:say(("%s already knows %s!"):format(
mon.nickname or mon.species or "?", moveName))
require("src.battle.gen2.Mon").displayName(mon), moveName))
end
return
end
+29 -6
View File
@@ -132,9 +132,11 @@ local UI_SCALE = 1.3
function Kit.layout(width, height)
local s = Theme.clamp(math.min(width / 640, height / 768), 0.9, 1.6) * UI_SCALE
local key = ("%dx%d"):format(math.floor(width), math.floor(height))
if Kit._fontKey ~= key then
Kit._fontKey = key
-- Two numbers, not a formatted key: this runs once per frame and the
-- string:format allocated on every one of them.
local kw, kh = math.floor(width), math.floor(height)
if Kit._fontW ~= kw or Kit._fontH ~= kh then
Kit._fontW, Kit._fontH = kw, kh
Kit.fonts = Theme.fonts(s)
clearCaches() -- every cached Text/width belongs to the old font set
end
@@ -857,6 +859,8 @@ end
-- never silently truncated. This is the ONLY way the launcher moves through
-- a long list: no scrollbars, no momentum, bounded row count per frame.
-- Returns the new page (1-based) and the row height consumed.
local pagerLabels = {}
function Kit.pager(x, y, w, page, total, perPage, idPrefix)
local h = math.max(Kit.tapMin(), 30 * Kit.scale)
local bw = 74 * Kit.scale
@@ -876,7 +880,19 @@ function Kit.pager(x, y, w, page, total, perPage, idPrefix)
local first = total > 0 and ((page - 1) * perPage + 1) or 0
local last = math.min(total, page * perPage)
local label = ("%d-%d of %d (page %d/%d)"):format(first, last, total, page, pages)
-- One memo per pager id. The counts only change when the user pages or the
-- list does; formatting them every frame minted a new string that then
-- missed the width / ellipsis / Text caches by content.
local memo = pagerLabels[idPrefix]
if not memo then memo = {}; pagerLabels[idPrefix] = memo end
if memo.first ~= first or memo.last ~= last or memo.total ~= total
or memo.page ~= page or memo.pages ~= pages then
memo.first, memo.last, memo.total = first, last, total
memo.page, memo.pages = page, pages
memo.label = ("%d-%d of %d (page %d/%d)")
:format(first, last, total, page, pages)
end
local label = memo.label
local labelX = x + 2 * bw + 2 * gap + gap
Kit.text("mono", Kit.ellipsize("mono", label, math.max(0, x + w - labelX)),
labelX, y + (h - Kit.textHeight("mono")) / 2, PAL.caption)
@@ -942,7 +958,10 @@ end
-- region can never unclip its parent. The tracked rect also bounds Kit.hit,
-- so a widget clipped out of view is inert instead of taking taps aimed at
-- whatever is drawn where it left.
-- The stack rects are pooled by depth and fully overwritten on every push,
-- so a frame that clips a dozen lists allocates nothing.
local clipStack = {}
local clipPool = {}
local function applyClip(rect)
Kit._clipRect = rect
@@ -967,8 +986,12 @@ function Kit.pushClip(x, y, w, h)
x2 = math.min(x2, prev.x + prev.w)
y2 = math.min(y2, prev.y + prev.h)
end
local rect = { x = x, y = y, w = math.max(0, x2 - x), h = math.max(0, y2 - y) }
clipStack[#clipStack + 1] = rect
local n = #clipStack + 1
local rect = clipPool[n]
if not rect then rect = {}; clipPool[n] = rect end
rect.x, rect.y = x, y
rect.w, rect.h = math.max(0, x2 - x), math.max(0, y2 - y)
clipStack[n] = rect
applyClip(rect)
end
+29 -15
View File
@@ -29,6 +29,15 @@ Layout.BP = {
-- Build the frame's metrics. `maxAppW` caps the content column on an
-- ultrawide monitor so the UI stays a readable measure instead of stretching.
-- One metrics table, reused. Every field is a pure function of the window
-- size, the safe area and maxAppW, so the table only has to be rebuilt when
-- one of those changes; the launcher asked for a fresh one 60 times a second
-- and threw all of them away. Callers must treat `m` as read-only (nothing
-- writes to it today) -- a caller that needs a shifted field should save,
-- assign and restore it around the call, not wrap `m` in a proxy.
local M = {}
local lastW, lastH, lastOx, lastOy, lastSw, lastSh, lastMax
function Layout.metrics(maxAppW)
local W, H = 0, 0
if love and love.graphics and love.graphics.getDimensions then
@@ -36,23 +45,28 @@ function Layout.metrics(maxAppW)
end
local ox, oy, sw, sh = SafeArea.rect()
local s = Kit.layout(sw, sh)
if W == lastW and H == lastH and ox == lastOx and oy == lastOy
and sw == lastSw and sh == lastSh and maxAppW == lastMax then
return M
end
lastW, lastH, lastOx, lastOy = W, H, ox, oy
lastSw, lastSh, lastMax = sw, sh, maxAppW
local appW = math.min(sw, (maxAppW or 1200) * s)
local m = {
W = W, H = H, s = s,
x = math.floor(ox + (sw - appW) / 2),
top = math.floor(oy),
w = math.floor(appW),
h = math.floor(sh),
pad = math.floor(Theme.clamp(appW * 0.03, 10, 24)),
gap = math.floor(12 * s),
colGap = math.floor(16 * s),
rowH = math.max(Kit.tapMin(), math.floor(44 * s)),
btnH = math.max(Kit.tapMin(), math.floor(38 * s)),
chip = math.max(Kit.tapMin(), math.floor(40 * s)),
railH = math.max(3, math.floor(4 * s)),
logoH = math.floor(Theme.clamp(sh * 0.10, 36, 84)),
}
local m = M
m.W, m.H, m.s = W, H, s
m.x = math.floor(ox + (sw - appW) / 2)
m.top = math.floor(oy)
m.w = math.floor(appW)
m.h = math.floor(sh)
m.pad = math.floor(Theme.clamp(appW * 0.03, 10, 24))
m.gap = math.floor(12 * s)
m.colGap = math.floor(16 * s)
m.rowH = math.max(Kit.tapMin(), math.floor(44 * s))
m.btnH = math.max(Kit.tapMin(), math.floor(38 * s))
m.chip = math.max(Kit.tapMin(), math.floor(40 * s))
m.railH = math.max(3, math.floor(4 * s))
m.logoH = math.floor(Theme.clamp(sh * 0.10, 36, 84))
m.cols = (appW >= Layout.BP.threeCol * s and 3)
or (appW >= Layout.BP.twoCol * s and 2)
or 1
+15 -6
View File
@@ -8,9 +8,9 @@
-- "update_check_state" worker -> main: { status, latest, progress, error }
--
-- Nothing here ever blocks or throws into the game loop: when love.thread is
-- absent (the headless test stub) or the worker cannot run (no curl, Android),
-- state() simply reports "error" and the UI hides itself. See the shared
-- contract in the task brief for the status vocabulary and the file layout.
-- absent (the headless test stub) or the worker cannot run, state() reports
-- "error" (or the worker reports "needs_full" when there is no transport).
-- See the shared contract in the task brief for the status vocabulary.
--
-- The release-JSON extraction and the sums parsing are exported as pure
-- functions (no love.* calls) so plain-Lua tests can cover them, and so the
@@ -71,6 +71,9 @@ function Check.parseRelease(jsonText, Json)
payloadName = payloadName,
payload = Check.pickAsset(doc.assets, payloadName),
sums = Check.pickAsset(doc.assets, "sha256sums.txt"),
-- GitHub release body: already fetched with the update check, shown by
-- the launcher's Patch notes footer button.
notes = type(doc.body) == "string" and doc.body or "",
}
end
@@ -135,6 +138,10 @@ local function drain()
if stateCh then
local msg = stateCh:pop()
while msg do
if type(msg) == "table" and type(msg.notes) ~= "string"
and type(cache.notes) == "string" then
msg.notes = cache.notes
end
cache = msg
msg = stateCh:pop()
end
@@ -158,11 +165,11 @@ function Check.start()
return
end
requested = true
cache = { status = "checking" }
cache = { status = "checking", notes = cache.notes, latest = cache.latest }
cmdCh:push({ cmd = "check" })
end
-- Current snapshot: { status, latest, progress, error }. status is one of
-- Current snapshot: { status, latest, progress, error, notes }. status is one of
-- idle | checking | uptodate | available | downloading | ready | needs_full | error.
function Check.state()
drain()
@@ -171,6 +178,7 @@ function Check.state()
latest = cache.latest,
progress = cache.progress,
error = cache.error,
notes = cache.notes,
}
end
@@ -180,7 +188,8 @@ function Check.download()
drain()
if not cmdCh then return end
if cache.status ~= "available" then return end
cache = { status = "downloading", latest = cache.latest, progress = 0 }
cache = { status = "downloading", latest = cache.latest, progress = 0,
notes = cache.notes }
cmdCh:push({ cmd = "download" })
end
+112
View File
@@ -0,0 +1,112 @@
-- Resolve the game's patch notes for the launcher footer modal.
--
-- Sources, first hit wins:
-- 1. The GitHub body the self-updater already fetched (Check.parseRelease)
-- 2. PATCH_NOTES.md, if a build packed one
-- 3. mobile/ios/app-repo.json -- CI copies each release's notes into
-- versions[].localizedDescription, so a checkout already has them
-- on disk even when the updater has not run
local Json = require("src.link.Json")
local PatchNotes = {}
PatchNotes.FILES = {
"PATCH_NOTES.md",
"assets/PATCH_NOTES.md",
}
PatchNotes.REPO_FILES = {
"mobile/ios/app-repo.json",
}
local function nonempty(s)
return type(s) == "string" and s:find("%S")
end
function PatchNotes.fromCheck(Check)
if not (Check and Check.state) then return nil, nil end
local ok, st = pcall(Check.state)
st = (ok and type(st) == "table") and st or nil
if not st then return nil, nil end
if nonempty(st.notes) then
return st.notes, st.latest
end
return nil, st.latest
end
local function readPath(path)
local fs = love and love.filesystem
if fs and fs.read then
local ok, text = pcall(fs.read, path)
if ok and nonempty(text) then return text end
end
local f = io.open(path, "rb")
if not f then return nil end
local text = f:read("*a")
f:close()
return nonempty(text) and text or nil
end
function PatchNotes.fromFile()
for _, path in ipairs(PatchNotes.FILES) do
local text = readPath(path)
if text then return text end
end
return nil
end
-- Newest-first list of { version, notes } from the iOS sidecar.
function PatchNotes.parseRepo(jsonText)
local doc = Json.decode(jsonText)
if type(doc) ~= "table" or type(doc.apps) ~= "table" then return {} end
local out = {}
for _, app in ipairs(doc.apps) do
if type(app) == "table" and type(app.versions) == "table" then
for _, row in ipairs(app.versions) do
if type(row) == "table" and nonempty(row.localizedDescription) then
out[#out + 1] = {
version = row.version,
notes = row.localizedDescription,
}
end
end
end
end
return out
end
function PatchNotes.fromRepo(engine)
for _, path in ipairs(PatchNotes.REPO_FILES) do
local text = readPath(path)
if text then
local list = PatchNotes.parseRepo(text)
if #list == 0 then return nil, nil end
if engine and engine ~= "0.0.0-dev" then
for _, row in ipairs(list) do
if row.version == engine then
return row.notes, row.version
end
end
end
return list[1].notes, list[1].version
end
end
return nil, nil
end
function PatchNotes.body(Check)
local notes, ver = PatchNotes.fromCheck(Check)
if notes then return notes, ver end
notes = PatchNotes.fromFile()
if notes then return notes, ver end
local Version = require("src.core.Version")
local engine = (Version and Version.engine) or "?"
notes, ver = PatchNotes.fromRepo(engine)
if notes then return notes, ver end
return "No patch notes loaded yet for gen1recomp v" .. engine .. ".\n\n"
.. "They appear here after the launcher checks GitHub for the latest "
.. "release.", engine
end
return PatchNotes
+71 -63
View File
@@ -5,11 +5,10 @@
-- "update_check_cmd" in: { cmd = "check" | "download" | "quit" }
-- "update_check_state" out: { status, latest, progress, error }
--
-- Transport is curl shelled out via io.popen (curl ships on macOS, Windows 10+
-- and desktop Linux). Everything is wrapped so a missing curl, an HTTP error,
-- or a hung download degrades to a "error"/"needs_full" state rather than
-- blocking or crashing the game. On Android curl is absent and the check
-- soft-fails to "error", which the UI hides.
-- Transport is HostShell: curl via io.popen on desktop, the JNI
-- love.system.httpDownload bridge on Android (same path the mod catalog
-- already uses). A missing transport, an HTTP error, or a hung download
-- degrades to "error"/"needs_full" rather than blocking or crashing the game.
--
-- Fresh love threads do not carry the "src.*" package searcher, so sibling
-- modules are pulled in with love.filesystem.load exactly like
@@ -45,7 +44,12 @@ local Boot = loadModule("src/update/Boot.lua")
local cmdCh = love.thread.getChannel("update_check_cmd")
local stateCh = love.thread.getChannel("update_check_state")
local function post(t) stateCh:push(t) end
local function post(t)
if pending and type(t) == "table" and t.notes == nil then
t.notes = pending.notes
end
stateCh:push(t)
end
local osName = (love.system and love.system.getOS and love.system.getOS()) or ""
local isWindows = osName == "Windows"
@@ -58,9 +62,12 @@ local API_URL = "https://api.github.com/repos/bryanthaboi/gen1recomp/releases/la
local pending = nil
-- ---------------------------------------------------------------------------
-- shell / curl
-- shell / fetch
-- ---------------------------------------------------------------------------
local UA = "gen1recomp-updater"
local GH_ACCEPT = "application/vnd.github+json"
local function shq(s)
s = tostring(s)
if isWindows then
@@ -69,31 +76,17 @@ local function shq(s)
return "'" .. s:gsub("'", "'\\''") .. "'"
end
-- run curl and return its response body (text), or nil on any failure. Used
-- for the small text resources (release JSON, sums file); -f makes curl exit
-- non-zero and emit nothing on an HTTP error, so an empty read is a failure.
local function curlCapture(url)
local cmd = "curl -fsSL --connect-timeout 10 --max-time 40 "
.. "-H " .. shq("User-Agent: gen1recomp-updater") .. " "
.. "-H " .. shq("Accept: application/vnd.github+json") .. " "
.. shq(url)
local pipe = HostShell.popen(cmd)
if not pipe then return nil end
local out = pipe:read("*a")
-- HostShell.pclose, not pipe:close(): a close outside the spawn lock can
-- free a FILE while another thread's popen walks the stream list, which
-- deadlocks that thread permanently (see HostShell's popen notes).
HostShell.pclose(pipe)
if not out or out == "" then return nil end
return out
-- Small text resources (release JSON, sums file) through HostShell so Android
-- hits the JNI bridge instead of a curl binary that is never on the device.
local function fetchText(url, accept)
if not HostShell then return nil end
local body = HostShell.httpGet(url, UA, accept)
if type(body) ~= "string" or body == "" then return nil end
return body
end
local function haveCurl()
local pipe = HostShell.popen("curl --version")
if not pipe then return false end
local out = pipe:read("*a")
HostShell.pclose(pipe)
return out ~= nil and out:find("curl", 1, true) ~= nil
local function canFetch()
return HostShell and HostShell.canFetch()
end
-- ---------------------------------------------------------------------------
@@ -165,12 +158,14 @@ end
local function doCheck()
post({ status = "checking" })
if not haveCurl() then
post({ status = "error", error = "curl not available" })
if not canFetch() then
-- No curl and no JNI bridge: the chip becomes "Open releases" so a tap
-- still does something instead of retrying a check that cannot succeed.
post({ status = "needs_full" })
return
end
local body = curlCapture(API_URL)
local body = fetchText(API_URL, GH_ACCEPT)
if not body then
post({ status = "error", error = "release check failed" })
return
@@ -207,7 +202,7 @@ local function doCheck()
-- pulling the bytes again.
local finalRel = "updates/" .. rel.payloadName
if love.filesystem.getInfo(finalRel) then
local sums = curlCapture(rel.sums.url)
local sums = fetchText(rel.sums.url)
if sums and verifyPayload(finalRel, rel.payloadName, sums) then
if gatePasses(finalRel) == false then
love.filesystem.remove(finalRel)
@@ -274,39 +269,52 @@ local function doDownload()
local doneAbs = saveDir .. "/updates/" .. rel.payloadName .. ".done"
local size = rel.payload.size or 0
launchDownload(rel.payload.url, partAbs, doneAbs)
if HostShell and HostShell.haveCurl() then
launchDownload(rel.payload.url, partAbs, doneAbs)
-- poll the .part size for progress until curl drops the done-marker; a
-- stalled or run-away transfer breaks out and lets verification fail cleanly
local waited, lastSize, lastChange = 0, -1, 0
while true do
-- A queued quit means the window already closed. Bail so the join in
-- Check.shutdown does not hold the dead window's process (and, on
-- Windows, its folder) open for up to the whole transfer (#727). The
-- quit stays on the channel for the command loop; the detached curl
-- times out on its own and the next launch's doCheck verifies and
-- re-offers whatever landed.
local peeked = cmdCh:peek()
if type(peeked) == "table" and peeked.cmd == "quit" then return end
if love.filesystem.getInfo(doneRel) then break end
local pinfo = love.filesystem.getInfo(partRel)
local cur = (pinfo and pinfo.size) or 0
if size > 0 then
local p = cur / size
if p > 0.999 then p = 0.999 end -- 1.0 is reserved for "ready"
post({ status = "downloading", latest = rel.version, progress = p })
else
post({ status = "downloading", latest = rel.version })
-- poll the .part size for progress until curl drops the done-marker; a
-- stalled or run-away transfer breaks out and lets verification fail cleanly
local waited, lastSize, lastChange = 0, -1, 0
while true do
-- A queued quit means the window already closed. Bail so the join in
-- Check.shutdown does not hold the dead window's process (and, on
-- Windows, its folder) open for up to the whole transfer (#727). The
-- quit stays on the channel for the command loop; the detached curl
-- times out on its own and the next launch's doCheck verifies and
-- re-offers whatever landed.
local peeked = cmdCh:peek()
if type(peeked) == "table" and peeked.cmd == "quit" then return end
if love.filesystem.getInfo(doneRel) then break end
local pinfo = love.filesystem.getInfo(partRel)
local cur = (pinfo and pinfo.size) or 0
if size > 0 then
local p = cur / size
if p > 0.999 then p = 0.999 end -- 1.0 is reserved for "ready"
post({ status = "downloading", latest = rel.version, progress = p })
else
post({ status = "downloading", latest = rel.version })
end
if cur ~= lastSize then lastSize, lastChange = cur, waited end
if waited - lastChange > 60 then break end -- 60s with no growth: give up
if waited > 960 then break end -- absolute ceiling
love.timer.sleep(0.25)
waited = waited + 0.25
end
if cur ~= lastSize then lastSize, lastChange = cur, waited end
if waited - lastChange > 60 then break end -- 60s with no growth: give up
if waited > 960 then break end -- absolute ceiling
love.timer.sleep(0.25)
waited = waited + 0.25
love.filesystem.remove(doneRel)
else
-- Android JNI bridge: blocking write, same as fetch_worker. Progress
-- cannot be sampled from inside httpDownload.
local ok = HostShell and HostShell.httpDownload(
rel.payload.url, partAbs, UA, nil, 900)
if not ok then
love.filesystem.remove(partRel)
post({ status = "error", error = "download failed" })
return
end
post({ status = "downloading", latest = rel.version, progress = 0.999 })
end
love.filesystem.remove(doneRel)
local sums = curlCapture(rel.sums and rel.sums.url or "")
local sums = fetchText(rel.sums and rel.sums.url or "")
if not sums then
love.filesystem.remove(partRel)
post({ status = "error", error = "checksum fetch failed" })
+140 -4
View File
@@ -230,6 +230,7 @@ function OverworldState:enter(mapId, x, y, facing, opts)
-- a fresh entry, or a stale flag can freeze player input forever
self.engaging = false
self.emote = nil
self.cancelledTrainerSight = nil
-- volatile WRAM state in pokered; never serialize across save/load
self.wildEncounterGraceSteps = 0
-- survives save/load: a loaded game may start inside a building whose
@@ -762,6 +763,70 @@ function OverworldState:bikeAllowed(mapId)
return false
end
-- Field-item entry points keep presentation and state transitions in the
-- owning world instead of asking a supported facade to reproduce either one.
function OverworldState:useBicycle()
local name = Game.save.player.name
if Game.save.onBike then
if Game.save.forcedBike then return false end
Game.save.onBike = false
require("src.core.Music").playMap(Game.data, self.map.id, false)
Game.stack:push(TextBox.new(Game,
Strings("%s got off\nthe BICYCLE.", name)))
elseif self:bikeAllowed(self.map.id) and not self.player.surfing then
Game.save.onBike = true
require("src.core.Music").playMap(Game.data, self.map.id, true)
Game.stack:push(TextBox.new(Game,
Strings("%s got on\nthe BICYCLE!", name)))
else
return false
end
return true
end
-- Field-move entry points keep presentation and state transitions in the
-- overworld. The party menu and supported mod facade both call these, so a
-- shortcut cannot drift from the game's own move flow.
function OverworldState:useFlashFieldMove(onClose)
-- A FLASH used in daylight must not carry into the next dark map. Lighting
-- also happens before the blink: setDark may rebake an ADVANCED atlas, and
-- putting that work in WhiteFlash:onDone caused the frozen white frame in
-- #610.
local wasDark = self.dark
if wasDark then Game.save.flashLit = true end
Game.stack:push(TextBox.new(Game,
Game.data.text._FlashLightsAreaText
or Strings("A blinding FLASH\nlights the area!"), function()
if onClose then onClose() end
if wasDark then self:setDark(false) end
Game.stack:push(Transition.whiteFlash(Game))
end))
return true
end
function OverworldState:useStrengthFieldMove(mon, onClose)
mon = mon or self:partyKnows("STRENGTH")
if not mon then return false end
local def = Game.data.pokemon[mon.species]
local name = mon.nickname or def.name
self.strengthActive = true
local first = (Game.data.text._UsedStrengthText
or Strings("{RAM:wNameBuffer} used\nSTRENGTH."))
:gsub("{RAM:wNameBuffer}", name)
local second = (Game.data.text._CanMoveBouldersText
or Strings("{RAM:wNameBuffer} can\nmove boulders."))
:gsub("{RAM:wNameBuffer}", name)
Game.stack:push(TextBox.new(Game, first, function()
Game.stack:push(TextBox.new(Game, second, function()
if onClose then onClose() end
Game.stack:push(Transition.whiteFlash(Game))
end))
end, { auto = { sound = function()
return require("src.core.Sound").playCry(Game.data, mon.species)
end } }))
return true
end
-- The battle transition's dungeon wipe uses the explicit map lists in
-- data/maps/dungeon_maps.asm (field.dungeonTransitionMaps): singles plus
-- inclusive map-id ranges -- faithful to the original's omissions
@@ -1725,6 +1790,12 @@ function OverworldState:goFishing(rod)
end))
end
function OverworldState:useFishingRod(rod)
if self.player.surfing or not self:facingIsShoreOrWater() then return false end
self:goFishing(rod)
return true
end
-- Fly to a visited town (called from the party menu).
function OverworldState:flyTo(mapId)
local spot = Game.data.field.flyWarps[mapId]
@@ -2472,6 +2543,15 @@ function OverworldState:trySurf(fx, fy, onClose)
end))
end
function OverworldState:stopSurfing(onClose)
if onClose then onClose() end
self.player.surfing = false
require("src.core.Music").setSurfing(Game.data, false)
Game.stack:push(Transition.whiteFlash(Game, nil, function()
self:stepForwardOrCrossEdge(self.player.facing)
end))
end
function OverworldState:tryCut(fx, fy)
-- UsedCut (engine/overworld/cut.asm) gates on the TILESET before
-- anything else: only OVERWORLD (tree tile $3d) and GYM (plant tile
@@ -3092,6 +3172,32 @@ local function meetTrainerTheme(cls)
or "Music_MeetMaleTrainer"
end
-- Public pre-trainer gate. A mod may retain continueBattle while a registered
-- preparation screen is on top, then resume once with an optional ordered
-- save-party index scope. The hook is cold on a no-mod boot.
function OverworldState.prepareTrainerBattle(game, context, startBattle,
cancelBattle)
if not Runtime.wantsHook("trainer.before_battle") then
startBattle()
return false
end
local started = false
local function continueBattle(options)
if started then return false end
started = true
if type(options) == "table" and options.cancel == true then
if cancelBattle then cancelBattle() end
else
startBattle(options)
end
return true
end
local deferred = Runtime.call("trainer.before_battle",
function() return false end, game, context, continueBattle)
if deferred ~= true and not started then continueBattle() end
return deferred == true
end
-- Run the pre-battle text -> battle -> won text -> flags sequence.
-- skipBattleText is for map scripts shaped like SilphCo11FDefaultScript
-- (scripts/SilphCo11F.asm), which DisplayTextID the challenge line BEFORE
@@ -3119,7 +3225,8 @@ function OverworldState:engageTrainer(npc, onDone, endBattleText, skipBattleText
or (header and header.won and Game.data.text[header.won])
local BattleState = require("src.battle.BattleState")
local function startBattle()
local function startBattle(options)
self.cancelledTrainerSight = nil
-- TalkToTrainer (home/trainers.asm:88) prints the before-battle text
-- FIRST and only then runs `call EngageMapTrainer` / `jp
-- StartTrainerBattle`, so a trainer challenged on foot gets the sting
@@ -3134,7 +3241,8 @@ function OverworldState:engageTrainer(npc, onDone, endBattleText, skipBattleText
local theme = meetTrainerTheme(d.trainerClass)
if theme then require("src.core.Music").play(Game.data, theme) end
end
local battle = BattleState.newTrainer(Game, d.trainerClass, d.trainerParty)
local battle = BattleState.newTrainer(Game, d.trainerClass, d.trainerParty,
options)
battle.checkpointOrigin = {
kind = "trainer_encounter",
map = self.map.id,
@@ -3171,10 +3279,31 @@ function OverworldState:engageTrainer(npc, onDone, endBattleText, skipBattleText
end
self:pushBattle(battle)
end
local function prepareBattle()
if not Runtime.wantsHook("trainer.before_battle") then
startBattle()
return
end
OverworldState.prepareTrainerBattle(Game, {
trainerClass = d.trainerClass,
partyIndex = d.trainerParty or 1,
mapId = self.map.id,
npcId = npc.id,
}, startBattle, function()
if self.player then
self.cancelledTrainerSight = {
npcId = npc.id,
playerX = self.player.cellX,
playerY = self.player.cellY,
}
end
if onDone then onDone() end
end)
end
if skipBattleText then
startBattle()
prepareBattle()
else
Game.stack:push(TextBox.new(Game, battleText, startBattle))
Game.stack:push(TextBox.new(Game, battleText, prepareBattle))
end
end
@@ -3372,11 +3501,18 @@ function OverworldState:checkTrainerSight()
if self.player.moving or self.engaging then return end
if Game.stack:top() ~= self then return end
local p = self.player
local cancelled = self.cancelledTrainerSight
if cancelled and (cancelled.playerX ~= p.cellX
or cancelled.playerY ~= p.cellY) then
self.cancelledTrainerSight = nil
cancelled = nil
end
for _, npc in ipairs(self.npcs) do
local d = npc.def
-- CheckFightingMapTrainers engages ANY aligned trainer sprite,
-- walkers included (they sight between steps)
if d.trainerClass and not npc.moving
and not (cancelled and cancelled.npcId == npc.id)
and not self:trainerDefeated(npc)
and not mapScripts.talkScript(self.map.id, d.text)
and trainerSpriteOnScreen(npc, p) then
+105
View File
@@ -6,6 +6,8 @@
-- stays unsupported; anything a mod legitimately needs belongs here.
local Logger = require("src.core.Logger")
local FieldDefaults = require("src.world.FieldDefaults")
local Map = require("src.world.Map")
local MapLoader = require("src.world.MapLoader")
local MapOverview = require("src.world.MapOverview")
local Party = require("src.pokemon.Party")
@@ -15,6 +17,9 @@ local WorldAPI = {}
WorldAPI.__index = WorldAPI
local NO_OVERWORLD = "no overworld"
local DIG_TILESETS = { FOREST = true, CEMETERY = true, CAVERN = true,
FACILITY = true, INTERIOR = true }
local RODS = { "OLD_ROD", "GOOD_ROD", "SUPER_ROD" }
local function acceptsMenuInput(game, ow)
local stack = game and game.stack
@@ -86,6 +91,106 @@ function WorldAPI:reorderParty(fromSlot, toSlot)
return true
end
-- Contextual field-item shortcuts. Only actions that can start immediately
-- are listed; callers receive copied labels and never inspect world internals.
function WorldAPI:availableFieldActions()
local game, ow, out = self.game, self:overworld(), {}
if not (game and game.save and ow and ow.map and ow.player) then
return out, NO_OVERWORLD
end
if not acceptsMenuInput(game, ow) then return out, "world is busy" end
local save, inventory = game.save, game.save.inventory or {}
local items = game.data and game.data.items or {}
if (inventory.BICYCLE or 0) > 0 and not ow.player.surfing
and not (save.onBike and save.forcedBike)
and (save.onBike or ow:bikeAllowed(ow.map.id)) then
out[#out + 1] = { id = "bicycle",
label = save.onBike and "BIKE OFF" or "BICYCLE" }
end
if not ow.player.surfing and ow:facingIsShoreOrWater() then
local rods = {}
for _, id in ipairs(RODS) do
if (inventory[id] or 0) > 0 then
local def = items[id]
rods[#rods + 1] = { id = id, label = def and def.name or id }
end
end
if #rods > 0 then
out[#out + 1] = { id = "fish", label = "FISH", rods = rods }
end
end
if ow:useCutFieldMove() == "ok" then
out[#out + 1] = { id = "cut", label = "CUT" }
end
local surf = ow:useSurfFieldMove()
if surf == "ok" or surf == "dismount" then
out[#out + 1] = { id = "surf",
label = surf == "dismount" and "LEAVE WATER" or "SURF" }
end
if not ow.strengthActive and ow:partyKnows("STRENGTH") then
out[#out + 1] = { id = "strength", label = "STRENGTH" }
end
if ow.dark and ow:partyKnows("FLASH") then
out[#out + 1] = { id = "flash", label = "FLASH" }
end
if DIG_TILESETS[ow.map.def.tileset] and ow.map.id ~= "AGATHAS_ROOM"
and ow:partyKnows("DIG") then
out[#out + 1] = { id = "dig", label = "DIG" }
end
if ow:partyKnows("TELEPORT") and Map.isOutside(ow.map.def,
FieldDefaults.field(game.data, "outsideTilesets")) then
out[#out + 1] = { id = "teleport", label = "TELEPORT" }
end
return out
end
function WorldAPI:useFieldAction(id, opts)
local game, ow = self.game, self:overworld()
if not ow then return nil, NO_OVERWORLD end
if not acceptsMenuInput(game, ow) then return nil, "world is busy" end
local found
for _, action in ipairs(self:availableFieldActions()) do
if action.id == id then found = action break end
end
if not found then return nil, "field action unavailable" end
if id == "bicycle" then
if ow:useBicycle() then return true end
elseif id == "cut" then
local x, y = ow.player:facingCell()
if ow:tryCut(x, y) then return true end
elseif id == "surf" then
local mode = ow:useSurfFieldMove()
if mode == "dismount" then
ow:stopSurfing()
return true
elseif mode == "ok" then
local x, y = ow.player:facingCell()
ow:trySurf(x, y)
return true
end
elseif id == "fish" then
local rod = opts and opts.rod
if not rod and #found.rods == 1 then rod = found.rods[1].id end
for _, choice in ipairs(found.rods) do
if choice.id == rod and ow:useFishingRod(rod) then return true end
end
return nil, "fishing rod unavailable"
elseif id == "strength" then
if ow:useStrengthFieldMove() then return true end
elseif id == "flash" then
if ow:useFlashFieldMove() then return true end
elseif id == "dig" or id == "teleport" then
ow:beginTeleportOut()
return true
end
return nil, "field action unavailable"
end
-- A compact, read-only view of the active map for minimaps and companion UIs.
-- `rows` describes collision terrain; optional `tileRows` reduces each real
-- 8x8 map tile to its average Game Boy shade ("0" lightest, "3" darkest).
+186
View File
@@ -0,0 +1,186 @@
-- Bake a Gen 2 map canvas without a World instance. The save editor's map
-- tab uses this so Gold rooms draw the same tiles the overworld would,
-- instead of the checkerboard fallback Map2 has no renderer for.
local Assets = require("src.render.Assets")
local BorderFill = require("src.world.gen2.BorderFill")
local GbcPalette = require("src.render.GbcPalette")
local Palettes = require("src.world.gen2.Palettes")
local MapPreview = {}
local ROOF_TILESETS = {
TILESET_JOHTO = true,
TILESET_JOHTO_MODERN = true,
TILESET_KANTO = true,
}
local function applyRoofOverlay(atlasPath, roofPath, tilesPerRow)
local atlasData = love.image.newImageData(Assets.resolve(atlasPath))
local roofData = love.image.newImageData(Assets.resolve(roofPath))
for t = 0, 8 do
local destId = 0x0a + t
local dx = (destId % tilesPerRow) * 8
local dy = math.floor(destId / tilesPerRow) * 8
local sx = t * 8
for y = 0, 7 do
for x = 0, 7 do
local r, g, b, a = roofData:getPixel(sx + x, y)
atlasData:setPixel(dx + x, dy + y, r, g, b, a)
end
end
end
local image = love.graphics.newImage(atlasData)
image:setFilter("nearest", "nearest")
return image
end
local function optionalTable(data, gen2Key, plainKey, generated)
if data[gen2Key] then return data[gen2Key] end
if data[plainKey] then return data[plainKey] end
local ok, mod = pcall(require, "data.generated." .. generated)
if ok and type(mod) == "table" then return mod end
return nil
end
function MapPreview.baker(data)
data = data or {}
return {
tilesets = data.gen2Tilesets or data.tilesets or {},
-- Data:load does not pull roofs.lua; the Gold cache still has it.
roofs = optionalTable(data, "gen2Roofs", "roofs", "roofs"),
palettes = optionalTable(data, "gen2Palettes", "palettes", "palettes"),
atlasCache = {},
mapImages = {},
}
end
function MapPreview.atlasFor(baker, mapDef)
if not (baker and mapDef) then return nil, nil end
local tileset = baker.tilesets and baker.tilesets[mapDef.tileset]
if not tileset then return nil, nil end
local cacheKey = mapDef.tileset
local roofName = nil
local roofs = baker.roofs
if ROOF_TILESETS[mapDef.tileset] then
roofName = roofs and roofs.mapGroupRoofs and roofs.mapGroupRoofs[mapDef.group]
end
if roofName then cacheKey = cacheKey .. "|" .. roofName end
local cached = baker.atlasCache[cacheKey]
if cached then return cached, tileset end
local tilesPerRow = tileset.tilesPerRow or 16
local atlas
local roofSpec = roofName and roofs and roofs.roofs and roofs.roofs[roofName]
if roofSpec and roofSpec.image and love.image and love.image.newImageData then
local ok, img = pcall(applyRoofOverlay, tileset.image, roofSpec.image, tilesPerRow)
if ok then atlas = img end
end
if not atlas then
if not tileset.image then return nil, tileset end
local ok, img = pcall(Assets.image, tileset.image)
if not ok then return nil, tileset end
atlas = img
if atlas.setFilter then atlas:setFilter("nearest", "nearest") end
end
baker.atlasCache[cacheKey] = atlas
return atlas, tileset
end
-- Same bake as World:bakeMapImage (src/world/gen2/World.lua), minus the
-- World fields the overworld keeps for anim overlays and cave flicker.
function MapPreview.bake(baker, map, daytime)
local atlas, tileset = MapPreview.atlasFor(baker, map.def)
if not atlas or not tileset then return nil end
if not (love.graphics and love.graphics.newCanvas) then return nil end
local blocks = tileset.blocks
local tilesPerRow = tileset.tilesPerRow or 16
local pw, ph = map.width * 32, map.height * 32
local okCanvas, canvas = pcall(love.graphics.newCanvas, pw, ph)
if not okCanvas or not canvas then return nil end
if canvas.setFilter then canvas:setFilter("nearest", "nearest") end
local quads = {}
local function quadFor(tile)
local q = quads[tile]
if q then return q end
local sx = (tile % tilesPerRow) * 8
local sy = math.floor(tile / tilesPerRow) * 8
q = love.graphics.newQuad(sx, sy, 8, 8, atlas:getDimensions())
quads[tile] = q
return q
end
local tilePalettes = tileset.tilePalettes
local bgSet = baker.palettes and daytime
and Palettes.bgSet(baker.palettes, map.def, daytime) or nil
local colored = bgSet and tilePalettes and GbcPalette.available()
local clearColor = { 0.15, 0.55, 0.25 }
if bgSet and bgSet[1] and bgSet[1][1] then
local c = GbcPalette.color(bgSet[1], 1)
clearColor = { c[1] / 255, c[2] / 255, c[3] / 255 }
end
local function drawTiles(slot)
for by = 0, map.height - 1 do
for bx = 0, map.width - 1 do
local blockId = BorderFill.blockFor(
map.blocks[by * map.width + bx + 1], map.borderBlock)
local block = blocks and blocks[(blockId or 0) + 1]
if block then
for i = 0, 15 do
local tile = block[i + 1] or 0
local tileSlot = tilePalettes and tilePalettes[tile + 1] or 1
if not slot or tileSlot == slot then
local tx = bx * 32 + (i % 4) * 8
local ty = by * 32 + math.floor(i / 4) * 8
love.graphics.draw(atlas, quadFor(tile), tx, ty)
end
end
end
end
end
end
local function paint()
love.graphics.clear(clearColor[1], clearColor[2], clearColor[3], 1)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.push()
love.graphics.origin()
if colored then
for slot = 1, 8 do
GbcPalette.with(bgSet[slot], function() drawTiles(slot) end)
end
else
drawTiles(nil)
end
love.graphics.pop()
end
if canvas.renderTo then
canvas:renderTo(paint)
else
paint()
end
return canvas
end
function MapPreview.imageFor(baker, map)
if not (baker and map and map.id) then return nil end
local cached = baker.mapImages[map.id]
if cached then return cached end
local img = MapPreview.bake(baker, map, "DAY")
baker.mapImages[map.id] = img or false
return img
end
function MapPreview.renderer(baker, map)
local img = MapPreview.imageFor(baker, map)
if not img then return nil end
return {
draw = function(_, camX, camY)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(img, -math.floor(camX or 0), -math.floor(camY or 0))
end,
}
end
return MapPreview
+121
View File
@@ -27,11 +27,27 @@ local Movement = require("src.script.gen2.Movement")
local Runtime = require("src.mods.Runtime")
local HiddenItems = require("src.world.gen2.HiddenItems")
local MapOverview = require("src.world.MapOverview")
local Bike = require("src.world.gen2.Bike")
local FieldMoves = require("src.world.gen2.FieldMoves")
local Permissions = require("src.world.gen2.Permissions")
local WorldAPI = {}
WorldAPI.__index = WorldAPI
local NO_OVERWORLD = "no overworld"
local RODS = { "OLD_ROD", "GOOD_ROD", "SUPER_ROD" }
local FIELD_ACTIONS = {
{ id = "cut", move = "CUT" },
{ id = "surf", move = "SURF" },
{ id = "strength", move = "STRENGTH" },
{ id = "flash", move = "FLASH" },
{ id = "headbutt", move = "HEADBUTT" },
{ id = "whirlpool", move = "WHIRLPOOL" },
{ id = "waterfall", move = "WATERFALL" },
{ id = "sweet_scent", move = "SWEET_SCENT" },
{ id = "dig", move = "DIG" },
{ id = "teleport", move = "TELEPORT" },
}
function WorldAPI.new(game, modId)
return setmetatable({ game = game, modId = modId }, WorldAPI)
@@ -52,6 +68,111 @@ function WorldAPI:current()
facing = p and p.facing }
end
local function itemLabel(game, id)
local def = game and game.data and game.data.items
and game.data.items[id]
return (def and def.name) or id
end
-- The same field-item contract as Gen 1, resolved through Gold's own bike,
-- collision and fishing rules.
function WorldAPI:availableFieldActions()
local world, game, out = self:overworld(), self.game, {}
if not (world and game and game.save and world.map and world.player) then
return out, NO_OVERWORLD
end
if not world:acceptsMenuInput() then return out, "world is busy" end
local inventory = game.save.inventory or {}
if (inventory.BICYCLE or 0) > 0 then
local bike = Bike.tryBike({
state = world.playerState,
environment = world.map.def and world.map.def.environment,
collision = world:playerCollision(),
alwaysOnBike = world:alwaysOnBike(),
})
if bike == "mount" or bike == "dismount" then
out[#out + 1] = { id = "bicycle",
label = bike == "dismount" and "BIKE OFF" or "BICYCLE" }
end
end
local context = world:fieldContext()
if not FieldMoves.isSurfing(world.playerState)
and Permissions.isWater(context.facingColl) then
local rods = {}
for _, id in ipairs(RODS) do
if (inventory[id] or 0) > 0 then
rods[#rods + 1] = { id = id, label = itemLabel(game, id) }
end
end
if #rods > 0 then
out[#out + 1] = { id = "fish", label = "FISH", rods = rods }
end
end
for _, row in ipairs(FIELD_ACTIONS) do
if not (row.move == "STRENGTH" and world.strengthActive) then
local mon = FieldMoves.partyMoveUser(context.party, row.move, context)
if mon then
context.mon = mon
local result = FieldMoves.fromMenu(row.move, context)
if result.ok then
out[#out + 1] = { id = row.id,
label = row.move:gsub("_", " ") }
end
end
end
end
if (inventory.SQUIRTBOTTLE or 0) > 0
and world:squirtbottleTreeScript() then
out[#out + 1] = { id = "squirtbottle",
label = itemLabel(game, "SQUIRTBOTTLE") }
end
return out
end
function WorldAPI:useFieldAction(id, opts)
local world = self:overworld()
if not world then return nil, NO_OVERWORLD end
if not world:acceptsMenuInput() then return nil, "world is busy" end
local found
for _, action in ipairs(self:availableFieldActions()) do
if action.id == id then found = action break end
end
if not found then return nil, "field action unavailable" end
if id == "bicycle" then
local outcome = world:useFieldItem("BICYCLE")
if outcome and outcome ~= "nowhere" then return true end
elseif id == "fish" then
local rod = opts and opts.rod
if not rod and #found.rods == 1 then rod = found.rods[1].id end
for _, choice in ipairs(found.rods) do
if choice.id == rod then
local outcome = world:useFieldItem(rod)
if outcome and outcome ~= "nowhere" then return true end
break
end
end
return nil, "fishing rod unavailable"
elseif id == "squirtbottle" then
local outcome = world:useFieldItem("SQUIRTBOTTLE")
if outcome and outcome ~= "nowhere" then return true end
end
for _, row in ipairs(FIELD_ACTIONS) do
if row.id == id then
local context = world:fieldContext()
local mon = FieldMoves.partyMoveUser(context.party, row.move, context)
local result = mon and world:useFieldMove(row.move, mon)
if result and result.ok then return true end
return nil, "field action unavailable"
end
end
return nil, "field action unavailable"
end
-- The same read-only minimap contract as Gen 1, with Gold's object/event
-- visibility rules supplying the semantic markers.
function WorldAPI:mapOverview()
+40
View File
@@ -0,0 +1,40 @@
-- Driver: Oak's Pallet Town escort. Logs the player/Oak separation the
-- whole way down and shoots the walk mid-street, so the formation is
-- checkable by eye as well as by number (the escort holds one cell,
-- ~16px; a desynced Oak drifts off by a cell per two steps).
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
U.teleport(game, "PALLET_TOWN", 10, 3, "up")
local ow = game.overworld
local function oak()
for _, n in ipairs(ow.npcs or {}) do
if n.def and n.def.name == "PALLETTOWN_OAK" then return n end
end
end
U.hold(game, "up", 40)
local worst, samples = 0, 0
for _ = 1, 1200 do
if game.stack:top() ~= ow then U.tap(game, "a") end
local o = oak()
if o and #ow.scriptMoves > 0 and ow.map.id == "PALLET_TOWN" then
local d = math.abs(o.px - ow.player.px) + math.abs(o.py - ow.player.py)
if samples > 0 or d <= 20 then
samples = samples + 1
if d > worst then worst = d end
if samples == 60 then U.shot(game, DIR .. "/escort_midwalk.png") end
if samples % 24 == 1 then
U.log(("t=%d player=(%d,%d) oak=(%d,%d) dist=%dpx")
:format(samples, ow.player.cellX, ow.player.cellY,
o.cellX, o.cellY, d))
end
end
end
if ow.map.id == "OAKS_LAB" then break end
U.wait(2)
end
U.log(("ESCORT worst separation: %dpx over %d samples"):format(worst, samples))
U.log("map:", ow.map.id, "flag:",
tostring(game.save.flags.EVENT_FOLLOWED_OAK_INTO_LAB))
love.event.quit()
end
File diff suppressed because it is too large Load Diff
@@ -49,10 +49,40 @@ check(position("onHostPause();") < position("super.onPause();"),
check(position("onHostDestroy();") < position("super.onDestroy();"),
"destroy hook runs before SDL destruction")
check(source:find("DisplayManager.DisplayListener", 1, true)
and source:find("registerDisplayListener", 1, true)
and source:find("unregisterDisplayListener", 1, true),
"secondary displays are monitored while the activity is active")
check(position("if (secondaryEnabled) registerSecondaryDisplayListener();") <
position("setupSecondaryDisplay();"),
"secondary display monitoring starts before initial discovery")
check(source:find("!monitor.hasDisplay(display.getDisplayId())", 1, true),
"a disconnected active display is rebound without replacing a live one")
check(not source:lower():find("openxr", 1, true),
"generic Android activity must not require OpenXR")
check(not source:find("QuestActivity", 1, true) and
not source:find("QuestBridge", 1, true),
"generic Android activity must not require Quest classes")
-- Required mod files use Android's Storage Access Framework, which works with
-- Android 13 scoped storage without broad media/storage permissions. Keep the
-- native destination distinct so it cannot be consumed as a game ROM.
check(source:find('PICKED_REQUIRED_IMPORT_FILENAME = "picked_required_import.bin"',
1, true), "required imports use their own Android picker destination")
check(source:find("showRequiredImportFilePicker", 1, true),
"Android exposes a required-import picker entry point")
check(source:find("Intent.ACTION_OPEN_DOCUMENT", 1, true)
and source:find("Intent.FLAG_GRANT_READ_URI_PERMISSION", 1, true),
"Android 13 uses SAF with an explicit read grant")
local systemPath = "mobile/android/love/src/jni/love/src/modules/system/System.cpp"
local systemFile = assert(io.open(systemPath, "rb"))
local system = systemFile:read("*a")
systemFile:close()
check(system:find('strcmp(kind, "required_import")', 1, true)
and system:find('dest = "picked_required_import.bin"', 1, true)
and system:find('return "rom,mod,sav,required_import"', 1, true),
"native Android bridge advertises and routes required imports")
print("android_host_extension_test: ok")
+81
View File
@@ -0,0 +1,81 @@
-- HostShell.httpPost must work with Lua/LuaJIT's one-way io.popen.
--
-- io.popen accepts "r" or "w", not "rw". POST needs both a request body
-- and a response status, so the body is staged in a temporary file and curl
-- is opened read-only for its response.
-- luajit tests/engine/host_shell_postlog.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
local HostShell = require("src.core.HostShell")
local MARK = "\n__gen1recomp_http__"
local STAGE_DIR = "/tmp/gen1recomp-postlog-stage"
local URL = "https://logs.example.com/logs"
local BODY = "debug log body\n"
local realOpen = io.open
local realPopen = io.popen
local realGetenv = os.getenv
local realRemove = os.remove
local realHaveCurl = HostShell.haveCurl
local openedPath, openedMode, writtenBody
local popenCommand, popenMode, removedPath
HostShell.haveCurl = function() return true end
os.getenv = function(name)
if name == "TEMP" or name == "TMP" or name == "TMPDIR" then
return STAGE_DIR
end
return realGetenv(name)
end
os.remove = function(path)
removedPath = path
return true
end
io.open = function(path, mode)
openedPath, openedMode = path, mode
return {
write = function(_, value)
writtenBody = value
return true
end,
close = function() return true end,
}
end
io.popen = function(command, mode)
popenCommand, popenMode = command, mode
return {
read = function() return MARK .. "200" end,
close = function() return true end,
}
end
local ok, err = HostShell.httpPost(URL, BODY, "text/plain", "gen1recomp-mod/test", 10)
io.open = realOpen
io.popen = realPopen
os.getenv = realGetenv
os.remove = realRemove
HostShell.haveCurl = realHaveCurl
eq(ok, true, "a desktop POST succeeds through the read-only response pipe: " .. tostring(err))
check(type(openedPath) == "string" and openedPath:find(STAGE_DIR .. "/gen1recomp-post-", 1, true) == 1, "the request body is staged under the OS temp dir")
check(openedPath and openedPath:sub(-4) == ".tmp", "the staged body carries a .tmp name")
eq(openedMode, "wb", "the temporary request body is opened for binary writing")
eq(writtenBody, BODY, "the complete log body is staged")
eq(popenMode, "r", "curl is opened in the supported read-only mode")
check(popenCommand:find("--data-binary", 1, true) ~= nil,
"curl reads the staged body with --data-binary")
check(openedPath and popenCommand:find(openedPath, 1, true) ~= nil,
"curl receives the temporary body path")
check(popenCommand:find(BODY, 1, true) == nil,
"the log body is not placed directly in the command line")
eq(removedPath, openedPath, "the staged request body is removed")
T.finish("host shell postlog")
@@ -0,0 +1,32 @@
-- iOS required imports travel through the same document-picker contract as
-- Android. Keep the Swift bridge and liblove patch aligned: a build that has
-- only one side would show the import button but fail on device.
local function read(path)
local file = assert(io.open(path, "rb"))
local data = file:read("*a")
file:close()
return data
end
local function check(value, message)
if not value then error(message, 2) end
end
local bridge = read("mobile/ios/native/GRPickerBridge.swift")
check(bridge:find('case "required_import":', 1, true)
and bridge:find('destName = "picked_required_import.bin"', 1, true),
"iOS routes required imports to their own staged filename")
check(bridge:find("types.append(.data)", 1, true)
and bridge:find("types.append(.item)", 1, true),
"iOS required imports accept user-owned binary ROM files")
check(bridge:find('"rom,mod,sav,stadium,required_import"', 1, true),
"iOS advertises required_import to Lua before opening the picker")
local patch = read("mobile/ios/patch_love_src.py")
check(patch:find("int w_pickFileKinds", 1, true)
and patch:find('{ "pickFileKinds", w_pickFileKinds }', 1, true),
"iOS liblove patch exposes the picker capability query")
check(patch:find('("GRPickerBridge.swift", ID_FILE_PICKER', 1, true),
"iOS build patch compiles the required-import picker bridge")
print("ios_required_import_picker_test: ok")
@@ -0,0 +1,62 @@
-- Mods "Show for" chips collapse to a dropdown when they cannot all fit.
-- Landscape / desktop keep the individual game chips. No pokered cite: the
-- launcher is port-only chrome.
-- luajit tests/engine/launcher_mod_scope_dropdown.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check = T.check
love = love or require("tests.love_stub")
love.graphics.setLineJoin = love.graphics.setLineJoin or function() end
love.graphics.newShader = love.graphics.newShader or function() return {} end
local Kit = require("src.ui.kit.Kit")
local RomImporter = require("src.import.RomImporter")
local LauncherView = require("src.import.LauncherView")
local function window(w, h)
love.graphics.getDimensions = function() return w, h end
love.graphics.getPixelDimensions = function() return w, h end
end
local function navIds()
local ids = {}
for i = 1, (Kit._navPrevN or 0) do
local slot = Kit._nav[i]
if slot and slot.id then ids[slot.id] = true end
end
return ids
end
local function drawMods(W, H)
window(W, H)
local imp = RomImporter.new(function() end, { launcher = true })
imp.tab = "mods"
imp.ready = { red = true, blue = true, yellow = true, gold = true }
local ok, err = pcall(LauncherView.draw, imp)
check(ok, ("%dx%d mods draws: %s"):format(W, H, tostring(err)))
return navIds()
end
local portrait = drawMods(360, 780)
check(portrait["mod-scope-menu"] == true,
"portrait: Show-for collapses to a dropdown when the chips cannot all fit")
check(portrait["mod-scope-gold"] == nil
and portrait["mod-scope-red"] == nil
and portrait["mod-scope-blue"] == nil
and portrait["mod-scope-yellow"] == nil,
"portrait: individual version chips are not drawn beside the dropdown")
local desktop = drawMods(1280, 720)
check(desktop["mod-scope-menu"] == nil,
"desktop: chips fit, so there is no dropdown")
check(desktop["mod-scope-all"] == true
and desktop["mod-scope-red"] == true
and desktop["mod-scope-blue"] == true
and desktop["mod-scope-yellow"] == true
and desktop["mod-scope-gold"] == true,
"desktop: every Show-for chip stays reachable")
print("ok launcher mod scope dropdown")
@@ -23,7 +23,7 @@ local function importer(field)
end
local modalFields = {
"_modConfirm", "_modVersions", "_modReleaseNotes", "_findDetails",
"_modConfirm", "_modVersions", "_modReleaseNotes", "_appPatchNotes", "_findDetails",
}
for _, field in ipairs(modalFields) do
@@ -77,6 +77,12 @@ do
imp:keypressed("escape")
eq(imp._modReleaseNotes, nil, "Escape closes release notes")
end
do
resetFocus()
local imp = importer("_appPatchNotes")
imp:keypressed("escape")
eq(imp._appPatchNotes, nil, "Escape closes patch notes")
end
do
resetFocus()
local imp = importer("_modVersions")
+89
View File
@@ -0,0 +1,89 @@
-- Launcher footer Patch notes control. The GitHub release body is already
-- fetched by the updater; this is the in-app viewer for it.
-- luajit tests/engine/launcher_patch_notes.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
love.graphics.setLineJoin = love.graphics.setLineJoin or function() end
love.graphics.newShader = love.graphics.newShader or function() return {} end
local Kit = require("src.ui.kit.Kit")
local RomImporter = require("src.import.RomImporter")
local LauncherView = require("src.import.LauncherView")
local PatchNotes = require("src.update.PatchNotes")
local function window(w, h)
love.graphics.getDimensions = function() return w, h end
love.graphics.getPixelDimensions = function() return w, h end
end
local function freshLauncher()
return RomImporter.new(function() end, { launcher = true })
end
local realPrint = love.graphics.print
local function drawAndCapture(imp)
local seen = {}
love.graphics.print = function(str, ...)
seen[#seen + 1] = tostring(str)
return realPrint(str, ...)
end
local ok, err = pcall(LauncherView.draw, imp)
love.graphics.print = realPrint
check(ok, "the frame draws: " .. tostring(err))
return table.concat(seen, "\n")
end
window(1280, 720)
local imp = freshLauncher()
local text = drawAndCapture(imp)
check(text:find("Patch notes", 1, true) ~= nil,
"desktop footer prints Patch notes")
window(360, 780)
local phone = freshLauncher()
check(drawAndCapture(phone):find("Patch notes", 1, true) ~= nil,
"portrait footer still prints Patch notes")
do
local body, ver = PatchNotes.body({
state = function()
return { notes = "## Issues closed\n\n- #12 cart padding", latest = "1.4.2" }
end,
})
eq(body, "## Issues closed\n\n- #12 cart padding",
"PatchNotes prefers the updater's GitHub body")
eq(ver, "1.4.2", "PatchNotes carries the release version")
end
do
local body, ver = PatchNotes.body(nil)
check(type(body) == "string" and (body:find("Download", 1, true) or body:find("Issues", 1, true)),
"without a check result PatchNotes uses the stashed iOS app-repo notes")
check(type(ver) == "string" and ver:find("^%d+%.%d+%.%d+$") ~= nil,
"stashed notes name a release version")
end
do
local f = assert(io.open("mobile/ios/app-repo.json", "rb"))
local list = PatchNotes.parseRepo(f:read("*a"))
f:close()
check(#list >= 2, "app-repo.json stashes more than one release")
local notes, ver = PatchNotes.fromRepo(list[2].version)
eq(ver, list[2].version, "fromRepo can pick a specific stashed version")
eq(notes, list[2].notes, "fromRepo returns that version's notes")
end
imp._appPatchNotes = true
local modal = drawAndCapture(imp)
check(modal:find("Patch notes", 1, true) ~= nil, "the modal titles itself")
check(modal:find("Close", 1, true) ~= nil, "the modal can be closed")
imp:keypressed("escape")
eq(imp._appPatchNotes, nil, "Escape dismisses patch notes")
T.finish("launcher patch notes")
+131
View File
@@ -0,0 +1,131 @@
-- Trainer battles may use a battle-local view of save-party records without
-- mutating, reordering, or hiding those records in the authoritative save.
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local T = require("tests.harness").suite("trainer battle party scope")
local BattleState = require("src.battle.BattleState")
local BagMenu = require("src.ui.BagMenu")
local Fixtures = require("tests.modkit").fixtures
local PartyMenu = require("src.ui.PartyMenu")
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local Bag = require("src.inventory.Bag")
local Data = Fixtures.fresh()
Data.items.POTION = { id = "POTION", index = 99, name = "POTION",
price = 300, tossable = true }
local function makeGame()
local save = SaveData.newGame()
save.party = {
Pokemon.new(Data, "FIXMON_A", 10),
Pokemon.new(Data, "FIXMON_B", 11),
Pokemon.new(Data, "FIXMON_C", 12),
}
local stack = { states = {} }
function stack:push(value) self.states[#self.states + 1] = value end
function stack:pop() return table.remove(self.states) end
function stack:top() return self.states[#self.states] end
return { data = Data, save = save, stack = stack }
end
local game = makeGame()
local originalParty = game.save.party
local first, second, third = unpack(originalParty)
local battle = BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1, {
playerPartyIndices = { 2, 3 },
})
T.check(game.save.party == originalParty,
"scoping never replaces the authoritative save-party table")
T.check(game.save.party[1] == first and game.save.party[2] == second
and game.save.party[3] == third,
"scoping never reorders authoritative save-party records")
T.same(battle.playerPartyIndices, { 2, 3 },
"the battle records normalized save-party indices")
T.check(battle.playerParty[1] == second and battle.playerParty[2] == third,
"the local party view contains the same selected Pokemon records")
T.check(battle.player.mon == second,
"initial send chooses the first healthy scoped member")
local menu = PartyMenu.new(game, { battle = battle })
T.check(menu.party == battle.playerParty,
"battle party menus traverse only the local eligible view")
Bag.add(game.save, "POTION", 1)
local bag = BagMenu.new(game, { battle = battle })
local potion
for _, row in ipairs(bag.items) do
if row.value == "POTION" then potion = row; break end
end
T.check(potion ~= nil, "the fixture potion is available for target selection")
bag.onChoose(potion, bag)
local targetPicker = game.stack:top()
T.check(targetPicker and targetPicker.party == battle.playerParty,
"in-battle item target selection traverses only eligible members")
second.hp = 0
battle.player.mon.hp = 0
battle:playerMonFainted()
T.eq(battle.result, nil,
"a healthy scoped replacement prevents premature exhaustion")
third.hp = 0
battle:playerMonFainted()
T.eq(battle.result, "lose",
"an excluded healthy save-party member cannot prevent scoped exhaustion")
T.check(first.hp > 0, "the excluded save-party member remains untouched")
local expGame = makeGame()
expGame.save.inventory.EXP_ALL = 1
local expBattle = BattleState.newTrainer(expGame,
"OPP_FIX_YOUNGSTER", 1, { playerPartyIndices = { 2, 3 } })
local excludedExp = expGame.save.party[1].exp
local participantExp = expGame.save.party[2].exp
local sharedExp = expGame.save.party[3].exp
expBattle.participants = { [expGame.save.party[2]] = true }
expBattle:awardExp()
T.eq(expGame.save.party[1].exp, excludedExp,
"EXP.ALL cannot award an excluded save-party member")
T.check(expGame.save.party[2].exp > participantExp,
"a scoped participant receives battle experience")
T.check(expGame.save.party[3].exp > sharedExp,
"EXP.ALL traverses other eligible scoped members")
local fallbackGame = makeGame()
local fallback = BattleState.newTrainer(fallbackGame,
"OPP_FIX_YOUNGSTER", 1, { playerPartyIndices = { 0, 99, 1.5, 0 } })
T.eq(fallback.playerParty, nil,
"a malformed or empty scope degrades to the vanilla full-party path")
T.eq(fallback.player.mon, fallbackGame.save.party[1],
"invalid scope fallback preserves vanilla initial send")
local partialGame = makeGame()
local partial = BattleState.newTrainer(partialGame,
"OPP_FIX_YOUNGSTER", 1, { playerPartyIndices = { 2, 99 } })
T.eq(partial.playerParty, nil,
"one invalid member makes the entire scope fall back")
local duplicateGame = makeGame()
local duplicate = BattleState.newTrainer(duplicateGame,
"OPP_FIX_YOUNGSTER", 1, { playerPartyIndices = { 2, 2 } })
T.eq(duplicate.playerParty, nil,
"duplicate members make the entire scope fall back")
local malformedOptionsGame = makeGame()
local malformedOptions = BattleState.newTrainer(malformedOptionsGame,
"OPP_FIX_YOUNGSTER", 1, 7)
T.eq(malformedOptions.playerParty, nil,
"a malformed options value degrades to the vanilla full-party path")
local linkGame = makeGame()
local linkBattle = BattleState.newTrainer(linkGame,
"OPP_FIX_YOUNGSTER", 1, { playerPartyIndices = { 2, 3 } })
linkBattle.kind = "link"
linkBattle.result = "guestWin"
linkGame.save.party[2].hp, linkGame.save.party[3].hp = 0, 0
linkBattle:finish()
T.eq(linkBattle.result, "guestWin",
"link spectator outcomes are not rewritten by trainer eligibility scope")
T.finish()
@@ -13,6 +13,8 @@ package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local OW = require("src.world.OverworldController")
local Hooks = require("src.mods.Hooks")
local Runtime = require("src.mods.Runtime")
local function setUpvalue(fn, name, val)
local i = 1
@@ -96,6 +98,89 @@ T.eq(rivalCount, 0, "rival classes play no encounter sting here")
local _, seenCount = stingFor("OPP_LASS", true)
T.eq(seenCount, 0, "self.engaging suppresses a second sting")
-- A deferred preparation may cancel instead of constructing a battle. For a
-- sight trainer, that must leave a one-position latch: otherwise the still
-- undefeated adjacent trainer sees the stationary player again next frame and
-- immediately reopens the preparation screen.
local oldEvents, oldHooks, oldErrors = Runtime.events, Runtime.hooks,
Runtime.errors
local cancelHooks = Hooks.new()
Runtime.install(oldEvents, cancelHooks, oldErrors)
cancelHooks:wrap("trainer.before_battle", function(_, _, _, continue)
continue({ cancel = true })
return true
end, 0, "cancel_probe")
local cancelNpc = { id = "npc#cancel", cellX = 0, cellY = -1,
facing = "down", moving = false, def = { trainerClass = "OPP_LASS",
trainerParty = 1, index = 1 } }
fakeSelf.player = { cellX = 0, cellY = 0, moving = false }
fakeSelf.map.id = "FIX_ROUTE"
fakeSelf.engaging = false
pushed, plays = {}, {}
local completed = 0
fakeSelf:engageTrainer(cancelNpc, function() completed = completed + 1 end)
pushed[1].onDone()
T.eq(completed, 1, "cancel completes the deferred encounter without a battle")
T.same(fakeSelf.cancelledTrainerSight, {
npcId = "npc#cancel", playerX = 0, playerY = 0,
}, "cancel suppresses immediate sight re-entry at the current player cell")
local approaches = 0
fakeSelf.npcs = { cancelNpc }
fakeSelf.trainerDefeated = function() return false end
fakeSelf.startTrainerApproach = function() approaches = approaches + 1 end
fakeGame.stack.top = function() return fakeSelf end
fakeGame.data.trainerHeader = function() return { range = 2 } end
T.check(setUpvalue(OW.checkTrainerSight, "mapScripts", {
talkScript = function() return nil end,
}), "mapScripts upvalue on checkTrainerSight")
fakeSelf:checkTrainerSight()
T.eq(approaches, 0,
"a cancelled adjacent trainer cannot reacquire the stationary player")
fakeSelf.player.cellX = 1
fakeSelf:checkTrainerSight()
T.eq(fakeSelf.cancelledTrainerSight, nil,
"moving one cell releases the cancelled sight latch")
fakeSelf.player.cellX = 0
fakeSelf:checkTrainerSight()
T.eq(approaches, 1,
"returning to the sight line permits a fresh trainer challenge")
Runtime.install(oldEvents, oldHooks, oldErrors)
-- OverworldState is a singleton reused by StateStack. A title/load cycle must
-- clear this volatile latch too, or CONTINUE at the same map and cell inherits
-- the cancelled sight suppression from the previous session.
local Camera = require("src.render.Camera")
local Collision = require("src.world.Collision")
local Encounter = require("src.world.Encounter")
local ScriptRunner = require("src.script.ScriptRunner")
local oldCameraNew, oldCollisionLoad = Camera.new, Collision.load
local oldEncounterLoad, oldRunnerNew = Encounter.load, ScriptRunner.new
local oldGameModule = package.loaded["src.core.Game"]
local oldScriptsModule = package.loaded["data.scripts.init"]
Camera.new = function() return {} end
Collision.load = function() end
Encounter.load = function() end
ScriptRunner.new = function() return {} end
package.loaded["src.core.Game"] = {
data = {}, save = { lastOutdoor = "FIX_ROUTE" },
}
package.loaded["data.scripts.init"] = {}
local lifecycle = setmetatable({
cancelledTrainerSight = {
npcId = "FIX_ROUTE_obj_1", playerX = 0, playerY = 0,
},
setMap = function() end,
refreshStandingOnWarp = function() end,
}, { __index = OW })
lifecycle:enter("FIX_ROUTE", 0, 0, "down", { via = "boot" })
T.eq(lifecycle.cancelledTrainerSight, nil,
"fresh overworld entry clears a cancelled trainer sight latch")
Camera.new, Collision.load = oldCameraNew, oldCollisionLoad
Encounter.load, ScriptRunner.new = oldEncounterLoad, oldRunnerNew
package.loaded["src.core.Game"] = oldGameModule
package.loaded["data.scripts.init"] = oldScriptsModule
if realMusic ~= nil then package.loaded["src.core.Music"] = realMusic
else package.loaded["src.core.Music"] = nil end
if realBattle ~= nil then package.loaded["src.battle.BattleState"] = realBattle
+92
View File
@@ -0,0 +1,92 @@
-- trainers.trueColor: the same 4-shade opt-out pokemon and sprites already
-- carry, now on the trainers registry. ROM-free.
-- luajit tests/engine/trainer_true_color.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local BattleState = require("src.battle.BattleState")
local Gen2Battle = require("src.ui.gen2.BattleState")
local Schemas = require("src.mods.Schemas")
local OakSpeech = require("src.ui.OakSpeech")
local spec = Schemas.REGISTRIES.trainers
T.check(spec.fields.trueColor ~= nil,
"Gen 1 trainers schema lists trueColor")
T.check(Schemas.check(spec, "trainers", "OPP_BROCK",
{ trueColor = true }, "patch"),
"a trueColor patch validates")
T.check(not Schemas.check(spec, "trainers", "OPP_BROCK",
{ trueColor = "yes" }, "patch"),
"trueColor rejects a non-boolean")
local gen2 = Schemas.shapeFor("trainers", spec, 2)
T.check(gen2.fields.trueColor ~= nil and gen2.fields.pic ~= nil,
"Gold trainers schema lists pic and trueColor")
T.check(Schemas.check(spec, "trainers", "BEAUTY",
{ pic = "mods/x/beauty.png", trueColor = true },
"patch", 2),
"a Gold pic+trueColor patch validates")
T.eq(BattleState.trainerTrueColor(nil, nil), false,
"no trainer is not trueColor")
T.eq(BattleState.trainerTrueColor(nil, { pic = "a.png" }), false,
"a vanilla portrait is not trueColor")
T.eq(BattleState.trainerTrueColor(nil, { trueColor = true }), true,
"the record's own flag wins")
T.eq(BattleState.trainerTrueColor(nil, { trueColor = false }), false,
"explicit false stays false")
local data = {
trainers = {
BASE = { pic = "base.png", trueColor = true },
VANILLA = { pic = "vanilla.png" },
},
}
T.eq(BattleState.trainerTrueColor(data, { basePic = "BASE" }), true,
"a basePic reuse inherits the base flag")
T.eq(BattleState.trainerTrueColor(data,
{ basePic = "BASE", trueColor = false }), false,
"an explicit false on the subclass beats the base")
T.eq(BattleState.trainerTrueColor(data, { basePic = "VANILLA" }), false,
"reusing a vanilla base stays unshaded-off")
local goldData = {
gen2Trainers = {
classes = {
BEAUTY = { pic = "mods/x/beauty.png", trueColor = true },
BUG_CATCHER = {},
},
},
gen2MenuGfx = {
battleHud = {
trainerPics = {
BEAUTY = "assets/generated/trainers/beauty.png",
BUG_CATCHER = "assets/generated/trainers/bug_catcher.png",
},
},
},
}
local beautyPath, beautyTc = Gen2Battle.trainerArt(goldData, "BEAUTY")
T.eq(beautyPath, "mods/x/beauty.png",
"a class pic wins over the extracted sheet")
T.eq(beautyTc, true, "and keeps trueColor")
local bugPath, bugTc = Gen2Battle.trainerArt(goldData, "BUG_CATCHER")
T.eq(bugPath, "assets/generated/trainers/bug_catcher.png",
"a class without pic keeps the extracted sheet")
T.eq(bugTc, false, "and is not trueColor")
T.eq(select(1, Gen2Battle.trainerArt(goldData, nil)), nil,
"no class is no pic")
local game = {
data = {
trainers = {
OPP_BROCK = { pic = "brock.png", trueColor = true },
OPP_PROF_OAK = { pic = "oak.png", trueColor = true },
},
},
}
local _, _, oakTc = OakSpeech.resolvePic(game,
{ type = "trainer", id = "OPP_BROCK" })
T.eq(oakTc, true, "OakSpeech reports a trainer record's trueColor")
T.finish("trainer true color")
+9
View File
@@ -32,6 +32,15 @@ eq(rel.payloadName, "gen1recomp-1.4.2.love", "payload name derived from version"
eq(rel.payload.url, "http://x/love", "payload asset url picked")
eq(rel.payload.size, 12345, "payload asset size picked")
eq(rel.sums.url, "http://x/sums", "sums asset url picked")
eq(rel.notes, "", "missing release body becomes empty notes")
local withNotes = Check.parseRelease(Json.encode({
tag_name = "v1.4.2",
body = "## Issues closed\n\n- #1 cart padding",
assets = {},
}))
eq(withNotes.notes, "## Issues closed\n\n- #1 cart padding",
"parseRelease keeps the GitHub release body")
-- a newer release that ships no .love yet: parses, but the payload/sums are nil
-- so the worker will route to needs_full rather than an in-place update
+1
View File
@@ -287,6 +287,7 @@ eq(rel.payload.url, "http://x/love", "parseRelease picks the payload asset url")
eq(rel.payload.size, 12345, "parseRelease picks the payload asset size")
eq(rel.sums.url, "http://x/sums", "parseRelease picks the sums asset url")
eq(rel.sums.size, 99, "parseRelease picks the sums asset size")
eq(rel.notes, "", "parseRelease treats a missing body as empty notes")
-- a release with no .love yet still parses; payload/sums are nil so the worker
-- routes to a full reinstall rather than an in-place update
+40
View File
@@ -44,6 +44,46 @@ check(importer.installedPath == [[C:\LocalState\picked_mod.zip]],
check(removedPath == [[C:\LocalState\picked_mod.zip]],
"removes the temporary copy after installation")
-- The UWP picker returns a temporary path rather than a mobile staged name.
-- Required imports must use that same picker and remain scoped to the selected
-- mod instead of relying on a desktop shell or Android/iOS inbox handling.
local pickedKind
love.system.pickFile = function(kind)
pickedKind = kind
return true
end
love.system.getPickedFile = function()
love.system.getPickedFile = function() return nil end
return [[C:\LocalState\picked_required_import.bin]]
end
removedPath = nil
local required = RomImporter.new(function() end, { launcher = true })
required.mods = { {
id = "needs-source",
manifest = {
id = "needs-source", name = "Needs source",
required_imports = { {
id = "source", name = "Source", file = "source.bin",
md5 = { "00000000000000000000000000000000" },
} },
},
} }
required._importRequiredSource = function(self, modId, importId, path)
self.requiredPath = { modId = modId, importId = importId, path = path }
return true
end
required:chooseRequiredImport("needs-source", "source")
check(pickedKind == "required_import", "UWP requests the required-import picker kind")
required:update(0)
check(required.requiredPath and required.requiredPath.path
== [[C:\LocalState\picked_required_import.bin]],
"UWP routes the picked dependency to its declared import")
check(required.requiredPath.modId == "needs-source"
and required.requiredPath.importId == "source",
"UWP preserves the pending mod and import identity")
check(removedPath == [[C:\LocalState\picked_required_import.bin]],
"UWP removes its temporary required-import copy after validation")
love.system.getOS = saved.getOS
love.system.pickFile = saved.pickFile
love.system.getPickedFile = saved.getPickedFile
+9
View File
@@ -138,6 +138,15 @@ function FsIo.new(rootDir)
return loadfile(abs(path))
end
function fs.createDirectory(path)
os.execute(("mkdir -p %q"):format(abs(path)))
return true
end
function fs.remove(path)
return os.remove(abs(path)) ~= nil
end
function fs.getDirectoryItems(path)
return FsIo.listDir(abs(path))
end
+60
View File
@@ -17,6 +17,7 @@ local ARCHIVE = {
local files, dirs, arch = {}, {}, {}
local fileDataMounts, pathMounts, stagedTemps = 0, 0, {}
local stagedEver = false
local failWriteOnce
local function resetFs()
for k in pairs(files) do files[k] = nil end
@@ -25,6 +26,7 @@ local function resetFs()
fileDataMounts, pathMounts = 0, 0
stagedTemps = {}
stagedEver = false
failWriteOnce = nil
end
local function dirChild(key, name)
@@ -45,6 +47,10 @@ end
local vfs = {}
function vfs.write(name, data)
if failWriteOnce == name then
failWriteOnce = nil
return nil, "simulated write failure"
end
files[name] = data
if name:match("^mod_import_") then
stagedTemps[name] = true
@@ -171,6 +177,16 @@ eq(staged, 0, "FileData path leaves no staged temp zip")
check(files["mods/" .. MOD_ID .. "/manifest.json"] ~= nil,
"install wrote manifest into mods/")
-- A third-party archive cannot bypass modkit's no-baseroms packaging gate.
resetFs()
ARCHIVE[MOD_ID .. "/baseroms/source.z64"] = "packaged rom"
files["imports/mods/packaged-rom.zip"] = "PK\3\4packaged-rom"
ok, err = LauncherMods.installZip("imports/mods/packaged-rom.zip")
check(not ok, "an archive containing baseroms is rejected")
check(tostring(err):find("must not include", 1, true),
"baseroms archive rejection explains the policy")
ARCHIVE[MOD_ID .. "/baseroms/source.z64"] = nil
-- Fallback: no newFileData → stage temp + path mount
resetFs()
vfs.newFileData = nil
@@ -205,6 +221,50 @@ check(files["mods/WildsOfKanto-1.5.0/manifest.json"] == nil,
check(files["mods/" .. MOD_ID .. "/manifest.json"] ~= nil,
"replace still lands in mods/<id>")
-- User-selected baseroms belong to the installation, not the downloaded mod
-- archive, and survive the same replacement path.
resetFs()
files["mods/OldFolder/manifest.json"] =
('{"id":"%s","name":"Old Copy","version":"0.9.0","entry":"main.lua"}')
:format(MOD_ID)
files["mods/OldFolder/main.lua"] = "return function() end\n"
files["mods/OldFolder/baseroms/stadium2.z64"] = "user-owned-rom"
files["imports/mods/update-with-rom.zip"] = "PK\3\4update"
ok, err = LauncherMods.installZip("imports/mods/update-with-rom.zip",
{ replace = true, expectId = MOD_ID })
check(ok == true, "replace with a baserom succeeds (" .. tostring(err) .. ")")
eq(files["mods/" .. MOD_ID .. "/baseroms/stadium2.z64"], "user-owned-rom",
"replace preserves user-owned baseroms under the canonical mod folder")
check(files["mods/OldFolder/baseroms/stadium2.z64"] == nil,
"the shadow mod tree is still removed after preservation")
-- A preservation write failure keeps recovery bytes outside mods/, where
-- discovery cannot mistake a baseroms-only directory for an installed mod.
resetFs()
files["mods/OldFolder/manifest.json"] =
('{"id":"%s","name":"Old Copy","version":"0.9.0","entry":"main.lua"}')
:format(MOD_ID)
files["mods/OldFolder/main.lua"] = "return function() end\n"
files["mods/OldFolder/baseroms/stadium2.z64"] = "user-owned-rom"
files["imports/mods/preserve-fail.zip"] = "PK\3\4update"
failWriteOnce = "mods/" .. MOD_ID .. "/baseroms/stadium2.z64"
ok, err = LauncherMods.installZip("imports/mods/preserve-fail.zip",
{ replace = true, expectId = MOD_ID })
check(not ok, "preservation failure rejects the update")
check(files["mods/" .. MOD_ID .. "/manifest.json"] == nil,
"preservation failure leaves no manifest-less tree under mods")
eq(files["imports/baseroms-recovery/" .. MOD_ID .. "/stadium2.z64"],
"user-owned-rom", "preservation failure stages recovery outside mods")
files["imports/mods/preserve-retry.zip"] = "PK\3\4update"
ok, err = LauncherMods.installZip("imports/mods/preserve-retry.zip",
{ replace = true, expectId = MOD_ID })
check(ok == true, "retry restores staged baseroms (" .. tostring(err) .. ")")
eq(files["mods/" .. MOD_ID .. "/baseroms/stadium2.z64"], "user-owned-rom",
"retry restores the recovered baserom into the installed mod")
check(files["imports/baseroms-recovery/" .. MOD_ID .. "/stadium2.z64"] == nil,
"successful retry clears baserom recovery debris")
-- #834: a manifest-less mods/<id> tree (interrupted copy debris) must not
-- block a plain re-import as "already installed"
resetFs()
+246
View File
@@ -0,0 +1,246 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local S = require("tests.harness").suite("mod battle snapshot")
local check, eq = S.check, S.eq
local Gen1BattleState = require("src.battle.BattleState")
check(Gen1BattleState.isBattleState == true,
"Gen 1 battle states carry the discovery marker")
local TypeChart = require("src.battle.TypeChart")
TypeChart.load({ type_chart = {
types = { NORMAL = { name = "NORMAL", category = "physical" } },
matchups = {},
} })
local Damage = require("src.battle.Damage")
local attacker, defender = { stages = {} }, { stages = {} }
eq(Damage.accuracyThreshold({ oneIn256Miss = true }, { accuracy = 100 },
attacker, defender), 255, "faithful accuracy keeps the 1-in-256 miss")
eq(Damage.accuracyThreshold({ oneIn256Miss = false }, { accuracy = 100 },
attacker, defender), 256, "clean accuracy exposes a certain hit")
local Catching = require("src.battle.Catching")
eq(Catching.chance("MASTER_BALL", { hp = 1, stats = { hp = 1 } },
{ catchRate = 1 }), 100, "Master Ball preview is certain")
check(Catching.chance("MOD_BALL", { hp = 1, stats = { hp = 1 } },
{ catchRate = 1 }, nil, { ballDef = { attempt = function() end } }) == nil,
"custom ball logic does not receive a guessed preview")
local mon = { species = "TESTMON", level = 5, hp = 18,
stats = { hp = 20 }, moves = {} }
local game = {
data = {
pokemon = { TESTMON = { name = "TESTMON", catchRate = 255 } },
moves = { TACKLE = { name = "TACKLE", type = "NORMAL", power = 35,
accuracy = 95, pp = 35 } },
items = { POTION = { name = "POTION" },
POKE_BALL = { name = "POKE BALL" } },
},
save = { party = { mon }, inventory = { POTION = 1, POKE_BALL = 1 } },
stack = { states = {} },
}
local battle = {
isBattleState = true, phase = "menu", queue = {},
ruleset = { oneIn256Miss = true },
player = { mon = mon, curTypes = { "NORMAL" }, stages = {},
curMoves = { { id = "TACKLE", pp = 35 } } },
enemy = { mon = { species = "TESTMON", level = 4, hp = 12,
stats = { hp = 12 }, moves = {} }, curTypes = { "NORMAL" }, stages = {} },
}
function battle:battleKind() return "wild" end
function battle:effectRecord() return { accuracyChecked = true } end
function battle:visibleText() return { "Wild TESTMON appeared!" } end
function battle:menuLockedAction() return nil end
function battle:chooseMenu(choice)
self.chosenMenu = choice
if choice == "fight" then self.phase = "moveSelect" end
return true
end
function battle:chooseMove(slot)
self.chosenMove = slot
self.phase = "messages"
return true
end
function battle:cancelMove() self.phase = "menu" return true end
function battle:catchChance(ball)
return require("src.battle.Catching").chance(ball, self.enemy.mon,
game.data.pokemon[self.enemy.mon.species])
end
game.stack.states = { battle }
local api = require("src.battle.BattleAPI").new(game)
local snapshot = api:snapshot()
check(snapshot and snapshot.kind == "wild" and snapshot.prompt == "menu",
"Gen 1 battle is exposed")
eq(snapshot.player.maxHp, 20, "Gen 1 max HP comes from battle stats")
eq(snapshot.moves[1].name, "TACKLE", "move records are copied")
eq(snapshot.message[1], "Wild TESTMON appeared!", "battle text is copied")
eq(#snapshot.items, 2, "medicine and balls are exposed")
check(type(snapshot.items[1].catchChance) == "number"
or type(snapshot.items[2].catchChance) == "number",
"stock catch chance is available")
snapshot.player.hp = 0
snapshot.moves[1].pp = 0
eq(mon.hp, 18, "changing a snapshot cannot change a Pokemon")
eq(battle.player.curMoves[1].pp, 35,
"changing a snapshot cannot change a move")
local same = api:snapshot()
eq(same.revision, snapshot.revision, "unchanged battle keeps its revision")
battle.enemy.mon.hp = 5
check(api:snapshot().revision > same.revision,
"observable battle changes advance the revision")
game.stack.states = {}
check(api:snapshot() == nil, "Gen 1 returns nil outside a battle")
game.stack.states = { battle }
local menu = api:snapshot()
local ok, err = api:submit({ id = 1, revision = menu.revision - 1,
kind = "menu", choice = "fight" })
check(not ok and err == "stale battle context",
"Gen 1 rejects a stale intent")
ok, err = api:submit({ id = 1, revision = menu.revision,
kind = "menu", choice = "missing" })
check(not ok and err == "unknown battle menu choice",
"Gen 1 rejects an unknown menu choice")
check(api:submit({ id = 1, revision = menu.revision,
kind = "menu", choice = "fight" }), "Gen 1 accepts a menu intent")
eq(battle.chosenMenu, "fight", "Gen 1 uses the semantic menu path")
ok, err = api:submit({ id = 1, revision = menu.revision,
kind = "menu", choice = "fight" })
check(not ok and err == "replayed intent", "Gen 1 rejects a replayed intent")
local moveMenu = api:snapshot()
ok, err = api:submit({ id = 2, revision = moveMenu.revision,
kind = "move", slot = 9 })
check(not ok and err == "invalid move slot",
"Gen 1 rejects an invalid move slot")
check(api:submit({ id = 2, revision = moveMenu.revision,
kind = "move", slot = 1 }), "Gen 1 accepts a valid move")
eq(battle.chosenMove, 1, "Gen 1 uses the semantic move path")
battle.phase = "moveSelect"
local back = api:snapshot()
check(api:submit({ id = 3, revision = back.revision, kind = "back" }),
"Gen 1 accepts move-menu back")
eq(battle.phase, "menu", "Gen 1 back restores the command menu")
local player2 = { species = "CHIKORITA", level = 5, hp = 20,
maxHp = 21, moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } }
local enemy2 = { species = "RATTATA", level = 3, hp = 12, maxHp = 12,
moves = {} }
local battle2 = { player = player2, enemy = enemy2, party = { player2 },
wild = true, turn = 0 }
function battle2:moveDisabled() return false end
local screen2 = { screenId = "Gen2BattleState", battle = battle2,
phase = "menu", menuIndex = 1, moveIndex = 1 }
function screen2:chooseMenu(choice)
self.chosenMenu = choice
if choice == "fight" then self.phase = "moves" end
return true
end
function screen2:chooseMove(slot)
self.chosenMove = slot
self.phase = "resolving"
return true
end
function screen2:cancelMove() self.phase = "menu" return true end
local game2 = {
data = {
pokemon = { CHIKORITA = { name = "CHIKORITA" },
RATTATA = { name = "RATTATA" } },
moves = { TACKLE = { name = "TACKLE", type = "NORMAL",
power = 35, accuracy = 95, pp = 35 } },
},
save = { party = { player2 } }, stack = { states = { screen2 } },
}
local api2 = require("src.battle.gen2.BattleAPI").new(game2)
local snapshot2 = api2:snapshot()
check(snapshot2 and snapshot2.kind == "wild" and snapshot2.prompt == "menu",
"Gold battle is discovered through its screen id")
eq(snapshot2.player.maxHp, 21, "Gold max HP uses the mon field")
eq(snapshot2.moves[1].name, "TACKLE", "Gold moves are copied")
snapshot2.player.hp = 0
snapshot2.moves[1].pp = 0
eq(player2.hp, 20, "changing a snapshot cannot change a Gold Pokemon")
eq(player2.moves[1].pp, 35,
"changing a snapshot cannot change a Gold move")
screen2.message = "A wild RATTATA appeared!"
screen2.phase = "resolving"
local message2 = api2:snapshot()
eq(message2.prompt, "advance", "Gold message state is exposed")
check(message2.revision > snapshot2.revision,
"Gold battle changes advance the revision")
game2.stack.states = {}
check(api2:snapshot() == nil, "Gold returns nil outside a battle")
game2.stack.states = { screen2 }
screen2.message = nil
screen2.phase = "menu"
local menu2 = api2:snapshot()
ok, err = api2:submit({ id = 1, revision = menu2.revision - 1,
kind = "menu", choice = "fight" })
check(not ok and err == "stale battle context",
"Gold rejects a stale intent")
ok, err = api2:submit({ id = 1, revision = menu2.revision,
kind = "menu", choice = "missing" })
check(not ok and err == "unknown battle menu choice",
"Gold rejects an unknown menu choice")
check(api2:submit({ id = 1, revision = menu2.revision,
kind = "menu", choice = "fight" }), "Gold accepts a menu intent")
eq(screen2.chosenMenu, "fight", "Gold uses the semantic menu path")
local moveMenu2 = api2:snapshot()
ok, err = api2:submit({ id = 2, revision = moveMenu2.revision,
kind = "move", slot = 9 })
check(not ok and err == "invalid move slot",
"Gold rejects an invalid move slot")
check(api2:submit({ id = 2, revision = moveMenu2.revision,
kind = "move", slot = 1 }), "Gold accepts a valid move")
eq(screen2.chosenMove, 1, "Gold uses the semantic move path")
screen2.phase = "moves"
local back2 = api2:snapshot()
check(api2:submit({ id = 3, revision = back2.revision, kind = "back" }),
"Gold accepts move-menu back")
eq(screen2.phase, "menu", "Gold back restores the command menu")
do
local Data = require("tests.modkit").fixtures.fresh()
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local save = SaveData.newGame()
save.party = { Pokemon.new(Data, "FIXMON_A", 20) }
local pressed = {}
local game3 = { data = Data, save = save, input = {
wasPressed = function(_, key) return pressed[key] == true end,
isDown = function() return false end,
}, stack = { states = {} } }
function game3.stack:top() return self.states[#self.states] end
function game3.stack:push(state) self.states[#self.states + 1] = state end
local real = Gen1BattleState.newWild(game3, "FIXMON_B", 12)
real.phase, real.queue, real.introSlide = "menu", {}, nil
game3.stack.states = { real }
pressed.a = true
real:update(1 / 60)
pressed.a = nil
eq(real.phase, "moveSelect", "native Gen 1 FIGHT uses the semantic path")
pressed.b = true
real:update(1 / 60)
pressed.b = nil
eq(real.phase, "menu", "native Gen 1 move-menu back still works")
end
local Loader = require("src.mods.Loader")
local fs = { read = function() end, getInfo = function() end,
getDirectoryItems = function() return {} end }
local mod = { path = "mods/snapshot_test", manifest = {
id = "snapshot_test", version = "1.0.0", permissionSet = {},
} }
local loader1 = Loader.new({ fs = fs, generation = 1 })
loader1.game = game
check(loader1:_api(mod).battle:snapshot().kind == "wild",
"mod.battle selects the Gen 1 facade")
local loader2 = Loader.new({ fs = fs, generation = 2 })
loader2.game = game2
check(loader2:_api(mod).battle:snapshot().kind == "wild",
"mod.battle selects the Gen 2 facade")
S.finish()
+15
View File
@@ -23,6 +23,7 @@ local MoveEffects = require("src.battle.MoveEffects")
local Pokemon = require("src.pokemon.Pokemon")
local Runtime = require("src.mods.Runtime")
local SaveData = require("src.core.SaveData")
local Schemas = require("src.mods.Schemas")
local Status = require("src.battle.Status")
local TrainerAI = require("src.battle.TrainerAI")
local TurnOrder = require("src.battle.TurnOrder")
@@ -346,6 +347,20 @@ do
check(BattleState.trainerPicPath(Data, { basePic = "OPP_ENGINEER" })
== Data.trainers.OPP_ENGINEER.pic,
"a custom trainer can reuse a base trainer portrait by id")
check(BattleState.trainerTrueColor(Data, { trueColor = true }) == true,
"a trainer record's trueColor flag is readable")
check(BattleState.trainerTrueColor(Data, { trueColor = false }) == false,
"explicit false stays false")
check(BattleState.trainerTrueColor(Data, { basePic = "OPP_ENGINEER" })
== false,
"a vanilla base portrait is not trueColor")
check(Schemas.check(Schemas.REGISTRIES.trainers, "trainers", "OPP_BROCK",
{ trueColor = true }, "patch"),
"a trueColor trainers patch validates against the catalog schema")
check(Schemas.check(Schemas.REGISTRIES.trainers, "trainers", "BEAUTY",
{ pic = "mods/x/beauty.png", trueColor = true },
"patch", 2),
"a Gold trainers patch can carry pic and trueColor")
end
do
+26
View File
@@ -370,6 +370,32 @@ check(math.abs(r - 0.4) < 1e-6 and math.abs(g - 0.7) < 1e-6
and math.abs(b - 0.9) < 1e-6,
"a trueColor pic keeps a pixel no 4-shade palette contains")
-- trainers.trueColor is the same opt-out on a class portrait
BattleState.invalidate()
local trainerPicData = {
trainers = {
SHADED = { pic = "assets/generated/battle/front/shaded.png" },
FULLCOLOR = { pic = "assets/generated/battle/front/full.png",
trueColor = true },
REUSED = { basePic = "FULLCOLOR" },
},
palettes = { palettes = { MEWMON = monPalette }, pokemon = {} },
}
local shadedTrainer = BattleState.trainerSprite(trainerPicData,
trainerPicData.trainers.SHADED)
r, g, b = shadedTrainer.data:getPixel(0, 0)
check(r == 0 and g == 0 and b == 1,
"a 4-shade trainer pic is palette-quantized onto its shade bucket")
local fullTrainer = BattleState.trainerSprite(trainerPicData,
trainerPicData.trainers.FULLCOLOR)
r, g, b = fullTrainer.data:getPixel(0, 0)
check(math.abs(r - 0.4) < 1e-6 and math.abs(g - 0.7) < 1e-6
and math.abs(b - 0.9) < 1e-6,
"a trueColor trainer pic keeps a pixel no 4-shade palette contains")
check(BattleState.trainerTrueColor(trainerPicData,
trainerPicData.trainers.REUSED) == true,
"a basePic reuse inherits the base portrait's trueColor flag")
-- ------- trueColor: the colors == false zone sentinel
check(PaletteFX.zone(nil, 0, 0, 1, 1) == nil, "nil colors is still no zone")
+45
View File
@@ -550,6 +550,51 @@ local installedColorlib = Manifest.validate({
version = "1.0.0",
entry = "main.lua",
}, "mods/colorlib")
local unconditionalConflict = LauncherMods.checkDependencies(testTargetManifest,
nil, nil, { testTargetManifest, installedColorlib })
check(unconditionalConflict.hasIssues == true,
"dependency resolver reports an unversioned conflict")
local function rangeTarget(version, conflicts)
return Manifest.validate({
id = "range_target",
name = "Range Target",
version = version,
entry = "main.lua",
conflicts = conflicts or {},
}, "mods/range_target")
end
local function rangeSource(conflicts)
return Manifest.validate({
id = "range_source",
name = "Range Source",
version = "1.0.0",
entry = "main.lua",
conflicts = conflicts or {},
}, "mods/range_source")
end
local forwardSource = rangeSource({ "range_target@<2.0.0" })
local matchingTarget = rangeTarget("1.4.0")
local nonmatchingTarget = rangeTarget("2.0.0")
local forwardMatching = LauncherMods.checkDependencies(forwardSource,
nil, nil, { forwardSource, matchingTarget })
check(forwardMatching.hasIssues == true and #forwardMatching.deps == 1,
"dependency resolver applies a matching forward conflict range")
local forwardNonmatching = LauncherMods.checkDependencies(forwardSource,
nil, nil, { forwardSource, nonmatchingTarget })
check(forwardNonmatching.hasIssues == false and #forwardNonmatching.deps == 0,
"dependency resolver ignores a nonmatching forward conflict range")
local reverseSource = rangeSource({ "range_target@<2.0.0" })
local reverseMatching = LauncherMods.checkDependencies(matchingTarget,
nil, nil, { reverseSource, matchingTarget })
check(reverseMatching.hasIssues == true and #reverseMatching.deps == 1,
"dependency resolver applies a matching reverse conflict range")
local reverseNonmatching = LauncherMods.checkDependencies(nonmatchingTarget,
nil, nil, { reverseSource, nonmatchingTarget })
check(reverseNonmatching.hasIssues == false and #reverseNonmatching.deps == 0,
"dependency resolver ignores a nonmatching reverse conflict range")
-- ------- scoped dependency tests
local Json = require("src.link.Json")
local scopedDepManifest = Manifest.validate({

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