Compare commits

...

34 Commits

Author SHA1 Message Date
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
MaxTomahawk a77210799f feat(mod-api): add trainer battle party scope 2026-08-14 17:33:17 +02: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
84 changed files with 6695 additions and 1781 deletions
+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/
+7 -4
View File
@@ -254,7 +254,7 @@ engine's globals. Every chunk you author gets it: `main.lua`, your
| `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.filesystem` | `mod.storage` (per-mod, per-playthrough), `mod:read` for a known file, `mod:list` / `mod:info` to iterate your own directory |
| `love.thread`, `love.event` | `mod.events`, `mod.hooks` |
| `love.system` | `mod.device:powerInfo()` for battery information; `mod.steps` (with the `steps` permission) for the step bridge |
@@ -269,9 +269,12 @@ 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
+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
+145 -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,6 +86,8 @@ 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"]`). |
| `github` | `string` | GitHub repository (`"owner/repo"`) used for update checks and dependency download links. |
@@ -91,6 +112,42 @@ 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.
## Mods and Gold (Gen 2)
The mod API is one API across both generations, but Gold runs its own battle
@@ -147,6 +204,24 @@ Companion UIs and alternate party screens can call
operation is accepted only during idle overworld play; menus, movement,
scripts, battles, and transitions leave the party untouched.
## Contextual field actions
`mod.world:availableFieldActions()` returns the field items and moves that can
start at the player's current position. Both games expose `bicycle`, `fish`,
`cut`, `surf`, `strength`, `flash`, `dig`, and `teleport`; Gold additionally
exposes `headbutt`, `whirlpool`, `waterfall`, `sweet_scent`, and the
contextual `squirtbottle` key item. Fishing rows include the owned rods that
are valid choices. The list is empty while the world is busy, and omits an
action whenever its item, move, badge, terrain, or engine state forbids it.
Call `mod.world:useFieldAction(id, opts)` to perform a listed action through
the active game's own field-item path. Fishing accepts `{ rod = "OLD_ROD" }`
and chooses automatically when only one rod is available. Invalid, stale, and
busy requests return `nil` plus a reason without changing game state. Mods do
not need generation-specific badge, terrain, bike, fishing, or field-move
logic. Action lists are extensible; callers should render the records they
understand and ignore unknown ids rather than assuming a fixed list length.
## Rendering pipelines
Most registries hand the engine *content*. `render_pipelines` hands it
@@ -308,6 +383,25 @@ local keys, code, message = mod.storage:list(game, "history/quick")
local deleted, code, message = mod.storage:delete(game, "history/quick/q0001")
```
For independently generated binary data, use the opaque byte methods. They
accept and return the exact Lua string of bytes, including NUL bytes and bytes
that are not valid text:
```lua
local ok, code, message = mod.storage:writeBytes(
game, "cache/maps/pallet/terrain", encodedMesh)
local encodedMesh, code, message = mod.storage:readBytes(
game, "cache/maps/pallet/terrain")
```
Opaque values are limited to 512 MiB per key. The engine stores them without
decoding, compression, or an engine-defined file format, and never executes
them. A consuming mod owns validation of its format, fingerprint, checksum,
and compression metadata. Byte writes are staged and compared byte-for-byte
before replacement, and reads can recover a valid backup after an interrupted
write. Existing table values and opaque byte values use one shared logical key
space; delete a key before changing its value from one type to the other.
`context` returns `{ engineVersion, gameVersion, playthroughId }`. The engine
version is compatibility metadata; physical launcher-slot and path identity stays
private. A title-selected context may additionally contain `normalSavedAt`, the
@@ -316,19 +410,22 @@ progress or a slot/path handle.
At the title screen only, `mod.storage:selected(game)` returns a bound storage
facade for the launcher-selected existing playthrough, or `nil, code, message`.
Resolving this facade is read-only: it never allocates an identity, adopts a
Resolving this facade is non-allocating: it never allocates an identity, adopts a
fresh New Game, or exposes a slot id/path. Its `context()`, `read(key)`,
`write(key, value)`, `list(prefix)`, and `delete(key)` methods have the same
data-only and transaction contract as `mod.storage`, but remain restricted to
the calling mod's selected existing namespace. It is intended for title tools
that need to browse or manage durable history before the first normal SAVE.
`write(key, value)`, `readBytes(key)`, `writeBytes(key, bytes)`,
`list(prefix)`, and `delete(key)` methods have the same scoped and
transactional contract as `mod.storage`, but remain restricted to the calling
mod's selected existing namespace. It is intended for title tools that need to
browse or manage durable history before the first normal SAVE.
Values must be tables containing serializable data only. Keys are conservative
slash-separated segments (letters, digits, `_`, `-`); paths and filesystem
handles are never exposed. Writes are staged and decode-verified, reads recover
from a valid staged/backup generation, and methods return structured errors for
normal data or I/O failures. The playthrough identity is allocated lazily on the
first storage/checkpoint call, so an unused API changes no save bytes.
Table values must contain serializable data only. Opaque values must be Lua
strings. Keys are conservative slash-separated segments (letters, digits, `_`,
`-`); paths and filesystem handles are never exposed. Table writes are staged
and decode-verified; opaque writes are staged and byte-verified; reads recover
from a valid staged/backup generation. Methods return structured errors for
normal data, byte validation, and I/O failures. The playthrough identity is
allocated lazily on the first storage/checkpoint call, so an unused API changes
no save bytes.
`mod.checkpoints` captures and reconstructs engine-owned semantic runtime state:
@@ -423,6 +520,43 @@ animation/messages, forced choices, and every phase that cannot safely be
checkpointed remain excluded. Exceptions are contained by normal hook isolation
and fall through without advancing a turn.
Gen 1 trainer encounters also expose `trainer.before_battle` after the
challenge text and immediately before battle construction. This lets a mod
defer the encounter while it collects a player choice through a registered
screen, then resume with a battle-local view of the save party:
```lua
mod.hooks:wrap("trainer.before_battle", function(next, game, context, continue)
-- context = { trainerClass, partyIndex, mapId, npcId }
mod.ui.push(game, "party_registration", {
onConfirm = function(indices)
continue({ playerPartyIndices = indices })
end,
onCancel = function()
continue({ cancel = true })
end,
})
return true
end)
```
Return `true` only when retaining `continue` for a later callback. Calling
`continue({ cancel = true })` ends the encounter without constructing a battle;
the normal encounter completion callback returns control to the overworld and
no trainer-defeated state is written. A cancelled sight encounter is suppressed
at the current player cell so it cannot immediately reopen; moving one cell or
talking to the trainer permits a new challenge. Calling `continue()` uses the
full save party; passing
`{ playerPartyIndices = { 2, 4, 5 } }` uses those ordered, one-based party
members for initial send, switching and forced replacement, exhaustion,
experience traversal, and battle party displays. The continuation is one-shot.
An empty, duplicate, out-of-range, or otherwise malformed list safely falls
back to the full party. The view references the original Pokemon records and
never reorders or replaces `game.save.party`; trainer battle checkpoints retain
the selected indices. Mods remain responsible for selection policy and should
use only public `mod.ui`, hook, and save APIs. See RFC 0010 for the exact
contract and compatibility guarantees.
## Developer console
Boot with developer mode on to unlock the in-game console and hot-reload
+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 -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.
-22
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
@@ -320,14 +306,6 @@ function love.load(args)
-- cache has to be mounted before the editor's Data:load.
if editorMode then
local version = os.getenv("POKEPORT_VERSION") or "red"
local gen2Name = GEN2_NO_EDITOR[version]
if gen2Name then
-- No launcher behind this run to carry a notice, so say it and stop
-- rather than open a Gen 2 slot on Gen 1 panels.
print(gen2Name .. " uses a Gen 2 save; the save editor does not read one yet.")
love.event.quit(1)
return
end
require("src.core.GameVersion").set(version)
require("src.import.CacheFs").mountVersion(version)
addEditorRequirePath()
@@ -192,8 +192,14 @@ bool System::pickFile(const char *kind) const
dest = "picked_mod.zip";
else if (strcmp(kind, "sav") == 0 || strcmp(kind, "save") == 0)
dest = "picked_save.sav";
else if (strcmp(kind, "required_import") == 0)
dest = "picked_required_import.bin";
else if (strcmp(kind, "rom") == 0)
dest = "picked_rom.gb";
// Unknown kinds used to fall through to the ROM destination. Refuse them
// so a newer Lua caller cannot silently route an unrelated file as a ROM.
else
return false;
}
return love::android::showFilePicker(dest);
#else
@@ -202,6 +208,15 @@ bool System::pickFile(const char *kind) const
#endif
}
const char *System::pickFileKinds() const
{
#ifdef LOVE_ANDROID
return "rom,mod,sav,required_import";
#else
return "";
#endif
}
bool System::createFile(const char *suggestedName) const
{
#ifdef LOVE_ANDROID
@@ -112,10 +112,12 @@ public:
* love::android::showFilePicker and src/import/RomImporter.lua.
*
* @param kind Optional pick kind: nullptr/"rom" -> picked_rom.gb,
* "mod" -> picked_mod.zip, "sav"/"save" -> picked_save.sav.
* "mod" -> picked_mod.zip, "sav"/"save" -> picked_save.sav,
* "required_import" -> picked_required_import.bin.
* @return Whether the picker was shown.
**/
virtual bool pickFile(const char *kind = nullptr) const;
virtual const char *pickFileKinds() const;
/**
* Shows the platform's native "create / save a file" UI (Android SAF
@@ -102,6 +102,12 @@ int w_pickFile(lua_State *L)
return 1;
}
int w_pickFileKinds(lua_State *L)
{
luax_pushstring(L, instance()->pickFileKinds());
return 1;
}
int w_createFile(lua_State *L)
{
const char *suggested = luaL_optstring(L, 1, nullptr);
@@ -222,6 +228,7 @@ static const luaL_Reg functions[] =
{ "openURL", w_openURL },
{ "vibrate", w_vibrate },
{ "pickFile", w_pickFile },
{ "pickFileKinds", w_pickFileKinds },
{ "createFile", w_createFile },
{ "syncHealthSteps", w_syncHealthSteps },
{ "restartApp", w_restartApp },
+28
View File
@@ -12,6 +12,34 @@
"tintColor": "3b5ca8",
"category": "games",
"versions": [
{
"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",
+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
+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
+78 -14
View File
@@ -316,6 +316,27 @@ function BattleState.trainerPicPath(data, trainer, oppClass, partyIndex)
return base and base.pic or nil
end
-- trueColor on the trainer record, or on the basePic it reuses when the
-- subclass does not set the flag itself. Explicit false stays false.
function BattleState.trainerTrueColor(data, trainer)
if not trainer then return false end
if trainer.trueColor ~= nil then
return trainer.trueColor and true or false
end
local base = trainer.basePic and data and data.trainers
and data.trainers[trainer.basePic]
return (base and base.trueColor) and true or false
end
-- Load a trainer frontpic through getImage so a trueColor portrait skips
-- the 4-shade quantize the same way a species pic does.
function BattleState.trainerSprite(data, trainer, oppClass, partyIndex)
return getImage(
BattleState.trainerPicPath(data, trainer, oppClass, partyIndex),
BattleState.trainerPalette(data, trainer),
BattleState.trainerTrueColor(data, trainer))
end
-- The battle-BGP fade variant of a pic (AnimationFlashScreen and the
-- SetAnimationBGPalette effects remap the four BG shades; on the SGB
-- the colorizer then colors the REMAPPED shade, so a faded pic shows
@@ -617,6 +638,44 @@ local function newBattle(game)
return self
end
local function scopedPlayerParty(game, indices)
if indices == nil then return nil, nil end
if type(indices) ~= "table" then
Logger.warn("trainer battle party scope is not a table; using full party")
return nil, nil
end
local count = #indices
local keyCount = 0
for key in pairs(indices) do
keyCount = keyCount + 1
if type(key) ~= "number" or key % 1 ~= 0 or key < 1 or key > count then
Logger.warn("trainer battle party scope is malformed; using full party")
return nil, nil
end
end
if count == 0 or keyCount ~= count then
Logger.warn("trainer battle party scope is empty or sparse; using full party")
return nil, nil
end
local party, normalized, seen = {}, {}, {}
for i = 1, count do
local index = indices[i]
if type(index) ~= "number" or index % 1 ~= 0
or not game.save.party[index] or seen[index] then
Logger.warn("trainer battle party scope contains an invalid index; using full party")
return nil, nil
end
seen[index] = true
normalized[i] = index
party[i] = game.save.party[index]
end
return party, normalized
end
function BattleState:playerPartyView()
return self.playerParty or self.game.save.party
end
-- opts.hooked: rod encounter, announced with _HookedMonAttackedText
function BattleState.newWild(game, species, level, opts)
local self = newBattle(game)
@@ -692,13 +751,15 @@ local function applySpecialMoves(data, oppClass, partyIndex, party)
end
end
function BattleState.newTrainer(game, oppClass, partyIndex)
function BattleState.newTrainer(game, oppClass, partyIndex, opts)
local self = newBattle(game)
self.kind = "trainer"
self.oppClass = oppClass
-- the object_event trainer arg (roster index). computeMusicKind keys
-- data/scripts/victories.lua on class#party, so keep it on the battle (#782).
self.partyIndex = partyIndex or 1
self.playerParty, self.playerPartyIndices = scopedPlayerParty(game,
type(opts) == "table" and opts.playerPartyIndices or nil)
self.trainer = game.data.trainers[oppClass]
assert(self.trainer, "unknown trainer class " .. tostring(oppClass))
-- pret GetTrainerName_: RIVAL1/2/3 copy wRivalName into wTrainerName
@@ -742,7 +803,7 @@ function BattleState.newTrainer(game, oppClass, partyIndex)
end
end
self.enemyIndex = 1
local playerMon = Party.firstHealthy(game.save.party)
local playerMon = Party.firstHealthy(self:playerPartyView())
if not playerMon then
Logger.warn("trainer battle with no healthy party; skipping")
self.dead = true
@@ -756,9 +817,8 @@ function BattleState.newTrainer(game, oppClass, partyIndex)
-- MonsterPalettes[0] = PAL_MEWMON -- InitBattleCommon zeroes
-- wEnemyMonSpecies2 before the intro's SET_PAL_BATTLE
-- (engine/battle/core.asm:6682, engine/gfx/palettes.asm SetPal_Battle)
self.trainerPic = getImage(
BattleState.trainerPicPath(game.data, self.trainer, oppClass, partyIndex),
BattleState.trainerPalette(game.data, self.trainer))
self.trainerPic = BattleState.trainerSprite(
game.data, self.trainer, oppClass, partyIndex)
self.introText = Strings("%s wants\nto fight!", self.trainer.name)
return self
end
@@ -2002,7 +2062,7 @@ function BattleState:update(dt)
-- loops the party menu until a healthy mon is picked, so B and
-- fainted picks land back here and reopen it
if self.player.mon.hp <= 0 then
if Party.firstHealthy(self.game.save.party) then
if Party.firstHealthy(self:playerPartyView()) then
self:openReplacementMenu()
end
return
@@ -3890,7 +3950,8 @@ function BattleState:awardExp()
-- (RemoveFaintedPlayerMon), so it drops out of the divisor and only
-- the surviving participants are counted and paid
local participants, alive = 0, {}
for _, mon in ipairs(self.game.save.party) do
local playerParty = self:playerPartyView()
for _, mon in ipairs(playerParty) do
if self.participants and self.participants[mon] then
participants = participants + 1
if mon.hp > 0 then table.insert(alive, mon) end
@@ -3984,9 +4045,9 @@ function BattleState:awardExp()
-- experience.asm:9-13); each mon gets its own GainedText with the
-- "with EXP.ALL," tail (wBoostExpByExpAll) -- pokered prints no
-- summary line
for _, mon in ipairs(self.game.save.party) do
for _, mon in ipairs(playerParty) do
if mon.hp > 0 then
ctx.applyShare(mon, math.max(1, ctx.participants) * #self.game.save.party * 2, "expAll")
ctx.applyShare(mon, math.max(1, ctx.participants) * #playerParty * 2, "expAll")
end
end
end
@@ -4027,7 +4088,7 @@ function BattleState:enemyMonFainted()
local nextName = nextMon.nickname or self.data.pokemon[nextMon.species].name
local style = tostring((self.game.save.options or {}).battleStyle or "shift")
:lower()
local partyCount = #self.game.save.party
local partyCount = #self:playerPartyView()
-- ReplaceFaintedEnemyMon (core.asm:892-896): DrawEnemyPokeballs puts the
-- foe's party ball row -- and the HUD chrome PlaceEnemyHUDTiles lays
-- down under it (draw_hud_pokeball_gfx.asm:9-11, 33-45, 134-141) -- into
@@ -4054,6 +4115,7 @@ function BattleState:enemyMonFainted()
local game = self.game
Screens.push(game, "PartyMenu", {
battle = self,
party = self:playerPartyView(),
forceSwitch = true,
onSwitch = function(mon)
if mon ~= self.player.mon and mon.hp > 0 then
@@ -4226,7 +4288,7 @@ function BattleState.isOaksLabStarterRival(self)
end
function BattleState:playerMonFainted()
local nextMon = Party.firstHealthy(self.game.save.party)
local nextMon = Party.firstHealthy(self:playerPartyView())
-- Being out of useable POKéMON blacks you out even when the battle was
-- already decided in our favour. A double faint -- our last mon dying
-- to residual damage on the turn it lands the KO -- used to hit the
@@ -4299,6 +4361,7 @@ function BattleState:openReplacementMenu()
self:ui(function()
return self:buildScreen("PartyMenu", {
battle = self,
party = self:playerPartyView(),
-- ChooseNextMon: pick immediately (no SWITCH/STATS/CANCEL)
forceSwitch = true,
onSwitch = function(mon)
@@ -4779,6 +4842,7 @@ function BattleState:openParty()
self:ui(function()
return self:buildScreen("PartyMenu", {
battle = self,
party = self:playerPartyView(),
onSwitch = function(mon)
if mon == self.player.mon then
self:say(Strings("%s is\nalready out!", self.player.name))
@@ -4825,8 +4889,8 @@ function BattleState:finish()
-- here it did not, so say so rather than silently papering over it.
-- The old-man / PROF.OAK demo also skips it: the party never fought
-- (Yellow's Pallet intro runs before the player owns a mon at all).
if self.result ~= "lose" and not self.demo
and not Party.firstHealthy(self.game.save.party) then
if self.kind ~= "link" and self.result ~= "lose" and not self.demo
and not Party.firstHealthy(self:playerPartyView()) then
Logger.warn("battle finished %s with no healthy party; forcing blackout",
tostring(self.result))
self.result = "lose"
@@ -5707,7 +5771,7 @@ function BattleState:drawHUDs(slide)
for i = 10, 17 do hudTile(0x76, i * 8, 88) end
hudTile(0x6F, 72, 88)
love.graphics.setColor(1, 1, 1, 1)
self:drawBallRow(self.playerParty or self.game.save.party, 88, 80, 8)
self:drawBallRow(self:playerPartyView(), 88, 80, 8)
end
local hidePlayer = self.safari or self.demo
if showStatus and self.player and not hidePlayer and not self.showPlayerBack
+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
+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
+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
+328 -68
View File
@@ -299,6 +299,11 @@ end
local CART_DRAG_SLOP = 8
local TAU = math.pi * 2
-- The 3D mesh is inset inside the hit box so yaw/pitch and the 1.05 hover
-- scale cannot climb into the title row (or the gear) on desktop, high-DPI,
-- or a portrait phone. Fraction of the shorter side, with a pixel floor.
local CART_MESH_PAD = 0.07
local CART_MESH_PAD_MIN = 8
local function cartridgeState(imp, version)
imp._cartridge = imp._cartridge or {}
@@ -543,8 +548,11 @@ local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action)
Theme.A.focus, 2, Theme.cardRadius() + 2)
end
local halfW, halfH = w / 2, h / 2
local depth = math.max(8, w * 0.14)
local meshPad = math.max(CART_MESH_PAD_MIN,
math.floor(math.min(w, h) * CART_MESH_PAD))
local halfW = math.max(1, w / 2 - meshPad)
local halfH = math.max(1, h / 2 - meshPad)
local depth = math.max(8, (halfW * 2) * 0.14)
local project = function(px, py, pz)
return cartProject(cx + pressX, cy + pressY, yaw, pitch,
px * pressedScale, py * pressedScale, pz * pressedScale)
@@ -630,6 +638,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 +647,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 +656,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 +720,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 +734,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 +782,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,6 +794,18 @@ 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
@@ -1316,20 +1370,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 +1403,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
@@ -1446,20 +1515,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)
@@ -1978,21 +2033,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 +2090,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 +2110,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 +2638,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 +2768,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 +2825,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 +2854,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 +3495,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 +3595,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 +3630,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 +3639,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
+259 -6
View File
@@ -344,6 +344,14 @@ local function readExternalPath(path)
return data
end
local function externalFileSize(path)
local file = io.open(path, "rb")
if not file then return nil end
local size = file:seek("end")
file:close()
return size
end
local function readDroppedFile(file)
local ok, openError = file:open("r")
if not ok then return nil, openError end
@@ -981,6 +989,25 @@ local function findPendingSav(preferAny, skip)
return nil
end
local function pickerHasKind(kind)
local fn = love.system.pickFileKinds
if type(fn) ~= "function" then return false end
local ok, kinds = pcall(fn)
if not ok or type(kinds) ~= "string" then return false end
for token in kinds:gmatch("[^,%s]+") do
if token == kind then return true end
end
return false
end
local function findPendingRequiredImport()
local names = { "picked_required_import.bin", "picked_stadium.z64" }
for _, name in ipairs(names) do
if love.filesystem.getInfo(name, "file") then return name end
end
return nil
end
-- Retire an Android pick once it has been through the installer / importer,
-- whether or not it worked: a pick left on disk wins the scans above forever,
-- so the next tap re-runs the same failing file and the picker never reopens
@@ -1112,6 +1139,39 @@ local function chooseSav()
return nil
end
-- Generic user-supplied dependency picker. Keep the native prompt entirely
-- engine-owned: manifest labels are untrusted and must never enter shell
-- command templates. The LÖVE modal already shows the specific import name.
local function chooseRequiredFile()
local prompt = shellSafe(Strings("Choose required mod file"))
local platform = love.system.getOS()
if platform == "OS X" then
return commandOutput(
([[osascript -e 'POSIX path of (choose file with prompt "%s")' 2>/dev/null]])
:format(prompt))
elseif platform == "Windows" then
local script = table.concat({
"Add-Type -AssemblyName System.Windows.Forms;",
"$d=New-Object System.Windows.Forms.OpenFileDialog;",
"$d.Title='" .. prompt .. "';",
"$d.Filter='All files (*.*)|*.*';",
"if($d.ShowDialog() -eq 'OK'){",
"$t=Join-Path $env:TEMP 'pokeport_required_import.bin';",
"Copy-Item -LiteralPath $d.FileName -Destination $t -Force;",
"[Console]::OutputEncoding=[Text.Encoding]::UTF8;",
"[Console]::Write($t)}",
})
return commandOutput(
'powershell -NoProfile -STA -Command "' .. script .. '"')
elseif platform == "Linux" then
local path = commandOutput(
([[zenity --file-selection --title="%s" 2>/dev/null]]):format(prompt))
if path then return path end
return commandOutput([[kdialog --getopenfilename "$HOME" 2>/dev/null]])
end
return nil
end
-- The self-updater only surfaces on the real distributed build: a fused,
-- interactive launcher with no scripted-run override. A dev / source checkout
-- (unfused, where Boot.run already no-ops) or an autopilot / driver /
@@ -1230,7 +1290,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 +1482,13 @@ function RomImporter:focus(f)
local text = "Could not read the picked file. Reopen the picker and choose "
.. "it with the Files (Documents) app, or copy it into: "
.. love.filesystem.getSaveDirectory()
if pickError:find("picked_mod", 1, true) then
if pickError:find("picked_required_import", 1, true)
or pickError:find("picked_stadium", 1, true) then
self.modNotice = { ok = false, text = text }
self.pickerPendingKind = nil
self.pickerPendingModId = nil
self.pickerPendingImportId = nil
elseif pickError:find("picked_mod", 1, true) then
self.modNotice = { ok = false, text = text }
elseif pickError:find("picked_save", 1, true) then
local version = self.androidPendingVersion or self:_savedropTarget()
@@ -1431,6 +1499,20 @@ function RomImporter:focus(f)
end
return
end
local requiredName = findPendingRequiredImport()
if requiredName then
local modId, importId = self.pickerPendingModId, self.pickerPendingImportId
self.pickerPendingKind = nil
self.pickerPendingModId, self.pickerPendingImportId = nil, nil
local imported = modId and importId
and self:_importRequiredSource(modId, importId, requiredName)
consumePick(self, requiredName, requiredName, imported)
if not modId or not importId then
self.modNotice = { ok = false,
text = "A picked dependency file had no pending mod request and was discarded." }
end
return
end
local modName = findPendingMod(false, self.pickSkip)
if modName then
self:_installMod(modName)
@@ -1738,6 +1820,152 @@ function RomImporter:chooseMod()
if path then self:_installMod(path) end
end
local function requiredManifest(self, modId)
for _, row in ipairs(self.mods or {}) do
if row.id == modId then return row.manifest, row end
end
return nil
end
local function requiredSpec(manifest, importId)
for _, candidate in ipairs(require("src.mods.RequiredImports").specs(manifest)) do
if candidate.id == importId then return candidate end
end
return nil
end
local function requiredImportNotice(self, modId, importId, text)
self.requiredImportNotice = {
modId = modId,
importId = importId,
text = tostring(text),
}
end
function RomImporter:_importRequiredData(modId, importId, data)
local manifest = requiredManifest(self, modId)
if not manifest then
self.modNotice = { ok = false, text = "Required import failed: mod not found." }
return nil
end
local ok, result = require("src.mods.RequiredImports")
.importData(manifest, importId, data)
if ok then
self.requiredImportNotice = nil
self.modNotice = { ok = true, text = "Imported " .. tostring(importId)
.. " for " .. tostring(manifest.name or manifest.id) .. "." }
self:_refreshMods()
return true
end
-- Keep validation feedback on the imported-files page. A general Mods-page
-- notice is hidden by this modal and made MD5 failures especially easy to miss.
requiredImportNotice(self, modId, importId, result)
self.modNotice = nil
return nil
end
function RomImporter:_importRequiredSource(modId, importId, source)
local manifest = requiredManifest(self, modId)
local spec = manifest and requiredSpec(manifest, importId)
if not spec then
requiredImportNotice(self, modId, importId, "Import declaration was not found.")
self.modNotice = nil
return nil
end
local info = love.filesystem.getInfo(source, "file")
local size = info and info.size or externalFileSize(source)
local sizeErr = require("src.mods.RequiredImports").sizeError(spec, size, false)
if sizeErr then
requiredImportNotice(self, modId, importId, sizeErr)
self.modNotice = nil
return nil
end
local data = love.filesystem.read(source)
if not data then data = readExternalPath(source) end
if not data then
requiredImportNotice(self, modId, importId, "Could not read the selected file.")
self.modNotice = nil
return nil
end
return self:_importRequiredData(modId, importId, data)
end
function RomImporter:_removeRequiredImport(modId, importId)
local manifest = requiredManifest(self, modId)
if not manifest then return end
local ok, err = require("src.mods.RequiredImports").remove(manifest, importId)
if ok then
self.requiredImportNotice = nil
self.modNotice = { ok = true, text = "Deleted " .. tostring(importId) .. "." }
self:_refreshMods()
else
requiredImportNotice(self, modId, importId, err)
self.modNotice = nil
end
end
-- Select and validate one manifest-declared file. NX has no host picker, so
-- its equivalent is an engine-owned imports/baseroms inbox that can be filled
-- over MTP; every other native/mobile picker lands on the same validation path.
function RomImporter:chooseRequiredImport(modId, importId)
if self.workState == "working" then return end
local manifest = requiredManifest(self, modId)
if not manifest then return end
local spec = requiredSpec(manifest, importId)
if not spec then return end
if self.isNX then
local inbox = "imports/baseroms"
love.filesystem.createDirectory(inbox)
local lastError
for _, name in ipairs(love.filesystem.getDirectoryItems(inbox) or {}) do
if name:sub(1, 1) ~= "." then
local path = inbox .. "/" .. name
local info = love.filesystem.getInfo(path, "file")
local sizeErr = info and require("src.mods.RequiredImports")
.sizeError(spec, info.size, false)
local data = not sizeErr and love.filesystem.read(path) or nil
if data and self:_importRequiredData(modId, importId, data) then return end
if sizeErr then lastError = sizeErr
elseif self.requiredImportNotice
and self.requiredImportNotice.modId == modId
and self.requiredImportNotice.importId == importId then
lastError = self.requiredImportNotice.text
end
end
end
requiredImportNotice(self, modId, importId, lastError
or "No matching file in imports/baseroms/. Copy it there over MTP, then try again.")
self.modNotice = nil
return
end
if self.nativePicker then
if self.mobileFileBridge and not pickerHasKind("required_import") then
requiredImportNotice(self, modId, importId,
"This app build cannot pick required mod files yet. Update the app and try again.")
self.modNotice = nil
return
end
self.pickerPendingKind = "required_import"
self.pickerPendingModId = modId
self.pickerPendingImportId = importId
if not pickFile("required_import") then
self.pickerPendingKind = nil
self.pickerPendingModId = nil
self.pickerPendingImportId = nil
requiredImportNotice(self, modId, importId, "Could not open the file picker.")
self.modNotice = nil
elseif self.android then
self.pickPending = true
self.pickTimer = 0
end
return
end
local path = chooseRequiredFile()
if path then self:_importRequiredSource(modId, importId, path) end
end
-- Which game a dropped .sav imports into: a .sav has no version signature of
-- its own, so it lands on the active game tab. When a non-game tab (mods) is
-- showing, default to red -- the always-present first game -- rather than
@@ -2045,7 +2273,8 @@ function RomImporter:_pollPickedFiles(dt)
if not found then
for _, name in ipairs(love.filesystem.getDirectoryItems("")) do
local n = name:lower()
if n:match("%.gbc?$") or n == "picked_mod.zip" or n == "picked_save.sav" then
if n:match("%.gbc?$") or n == "picked_mod.zip" or n == "picked_save.sav"
or n == "picked_required_import.bin" or n == "picked_stadium.z64" then
found = true
break
end
@@ -2174,7 +2403,12 @@ function RomImporter:update(dt)
local version = self.pickerPendingVersion
self.pickerPendingKind = nil
self.pickerPendingVersion = nil
if kind == "mod" then
if kind == "required_import" then
local modId, importId = self.pickerPendingModId, self.pickerPendingImportId
self.pickerPendingModId, self.pickerPendingImportId = nil, nil
if modId and importId then self:_importRequiredSource(modId, importId, path) end
if Platform.isUWP() then os.remove(path) end
elseif kind == "mod" then
self:_installMod(path)
if Platform.isUWP() and self.modNotice and self.modNotice.ok then
os.remove(path)
@@ -2196,7 +2430,10 @@ function RomImporter:update(dt)
local version = self.pickerPendingVersion or self:_savedropTarget()
self.pickerPendingKind = nil
self.pickerPendingVersion = nil
if kind == "mod" then
if kind == "required_import" then
self.modNotice = { ok = false, text = errorText }
self.pickerPendingModId, self.pickerPendingImportId = nil, nil
elseif kind == "mod" then
self.modNotice = { ok = false, text = errorText }
elseif kind == "sav" then
self.saveNotice[version] = { ok = false, text = errorText }
@@ -2356,6 +2593,20 @@ function RomImporter:_updatePadCursor(dt)
local ny = self._padCursor.y + dy * speed * dt
self._padCursor.x = math.max(ox, math.min(ox + w, nx))
self._padCursor.y = math.max(oy, math.min(oy + h, ny))
-- Pushing INTO the top/bottom edge scrolls the page instead of stalling.
-- The cursor is clamped to the safe area above, so on a short window the
-- rows below the fold are unreachable on a stickless handheld: no mouse
-- wheel, no touchscreen, and no right stick to feed the existing wheel
-- path. Only the OVERSHOOT scrolls -- parking the cursor at the edge does
-- nothing, it has to be actively pushed -- and this block only runs on pad
-- input, so a real mouse is unaffected. /48 matches the pixels-per-notch
-- LauncherView.draw multiplies back out.
local overY = 0
if ny > oy + h then overY = ny - (oy + h)
elseif ny < oy then overY = ny - oy end
if overY ~= 0 and self._flex then
require("src.import.LauncherView").wheelmoved(self, 0, -overY / 48)
end
-- Desktop: FlexLove polls the real mouse, so warp it with the pad pointer.
-- NX: the getPosition bridge already returns pad coords — skip setPosition.
if not self.isNX and love.mouse.setPosition then
@@ -2806,7 +3057,7 @@ function RomImporter:keypressed(key)
return
end
if self._modConfirm or self._modVersions or self._modReleaseNotes
or self._findDetails then
or self._findDetails or self._appPatchNotes then
-- Focus navigation belongs to the visible modal as well as the launcher
-- beneath it. Route arrows and an already-armed confirm before this guard
-- returns; unarmed Enter still falls through to the modal guard. Keep this
@@ -2819,6 +3070,8 @@ function RomImporter:keypressed(key)
self._findDetails = nil
elseif self._modReleaseNotes then
self._modReleaseNotes = nil
elseif self._appPatchNotes then
self._appPatchNotes = nil
else
self._modConfirm = nil
self._modVersions = nil
+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",
+83 -1
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 = {}
@@ -459,13 +460,38 @@ function LauncherMods.list(version)
local ok, result = pcall(function()
local options = SaveData.loadOptions()
local manifests = discover()
-- Imports are private player grants. Never scan or copy another mod's
-- baseroms: a matching public digest is not permission to share the file.
local importState = {}
for _, manifest in ipairs(manifests) do
local rows, missing, missingOptional = RequiredImports.inspect(manifest)
importState[manifest.id] = { rows = rows, missing = missing,
missingOptional = missingOptional }
end
-- The first build containing game-specific switches turns the old shared
-- state into one explicit answer per installed mod and game. Saving here
-- means users who only visit the launcher still receive the migration.
if SaveData.migrateModEnablement(options, manifests) then
SaveData.saveOptions(options)
end
return LauncherMods.deriveList(manifests, options, version)
local rows = LauncherMods.deriveList(manifests, options, version)
for _, row in ipairs(rows) do
local state = importState[row.id]
or { rows = {}, missing = 0, missingOptional = 0 }
local imports, missing = state.rows, state.missing
row.requiredImports, row.missingRequiredImports = imports, missing
row.imports = imports
row.missingOptionalImports = state.missingOptional or 0
if missing > 0 and row.status == "ok" then
row.status = "needs_import"
local first
for _, import in ipairs(imports) do
if not import.present then first = import break end
end
row.statusDetail = "Needs import: " .. (first and first.name or "required file")
end
end
return rows
end)
if not ok then
-- a single bad options/mod file must not blank the launcher
@@ -677,6 +703,26 @@ local function copyTree(src, dst)
return true
end
-- User-supplied baseroms are install state, not package content. Snapshot
-- them before replacing a mod tree so an update cannot make the player select
-- the same cartridge again (or destroy their only reusable copy if the new
-- archive later fails to copy).
local function snapshotTree(path, into, relative)
local fs = love.filesystem
into, relative = into or {}, relative or ""
local info = fs.getInfo(path)
if not info then return into end
if info.type == "directory" then
for _, name in ipairs(fs.getDirectoryItems(path) or {}) do
local rel = relative == "" and name or (relative .. "/" .. name)
snapshotTree(path .. "/" .. name, into, rel)
end
elseif relative ~= "" and into[relative] == nil then
into[relative] = fs.read(path)
end
return into
end
-- Delete an installed mod subtree. Enumeration stays on love.filesystem (the
-- portable game folder is on its read path), but the deletes go through
-- CacheFs so a portable install's real files actually go away instead of
@@ -919,14 +965,28 @@ function LauncherMods._installZipInner(source, opts)
return nil, ("zip is for '%s', expected '%s'")
:format(manifest.id, opts.expectId)
end
local packagedBaseroms = root .. "/baseroms"
if fs.getInfo(packagedBaseroms, "directory")
and #(fs.getDirectoryItems(packagedBaseroms) or {}) > 0 then
cleanup()
return nil, "mod archives must not include user-supplied baseroms/ files"
end
local dest = "mods/" .. manifest.id
local baseromRecovery = "imports/baseroms-recovery/" .. manifest.id
local existing, installedSomewhere = sameIdTrees(fs, manifest.id)
if installedSomewhere and not opts.replace then
cleanup()
return nil, "a mod named '" .. manifest.id .. "' is already installed"
end
local preservedBaseroms = {}
-- A previous failed update may have staged the user's files outside mods/ so
-- discovery cannot mistake recovery debris for an installed mod.
snapshotTree(baseromRecovery, preservedBaseroms)
if #existing > 0 then
for _, path in ipairs(existing) do
snapshotTree(path .. "/baseroms", preservedBaseroms)
end
-- drop every old tree before copy -- mods/<id> and any same-id folder
-- under another name, or the survivor keeps winning discover()'s
-- first-id-wins race after the "successful" update (#801). A tree with
@@ -950,6 +1010,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()
+62 -2
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")
@@ -565,7 +566,24 @@ function Loader:_validate()
elseif manifest.assets_transforms
and not self:_exists(mod.path .. "/" .. manifest.assets_transforms) then
reason = "assets_transforms file missing: " .. manifest.assets_transforms
elseif manifest.game_version and not devEngine() then
end
if not reason and #(manifest.required_imports or {}) > 0 then
for _, import in ipairs(manifest.required_imports) do
local path = mod.path .. "/baseroms/" .. import.file
if not self:_exists(path) then
reason = "required import missing: " .. import.name
break
end
local valid, importErr = RequiredImports.validateStored(
manifest, import, self.fs)
if not valid then
reason = "required import invalid: " .. import.name
.. " (" .. tostring(importErr) .. ")"
break
end
end
end
if not reason and manifest.game_version and not devEngine() then
local ok, err = Semver.satisfies(Version.engine, manifest.game_version)
if not ok then
reason = ("needs game version %s, engine is %s")
@@ -1069,7 +1087,8 @@ function Loader:_api(mod)
bucket[key] = value
end,
},
-- Data-only state independent of the vanilla progress checkpoint. The
-- Data-only and opaque-byte state independent of the vanilla progress
-- checkpoint. The
-- engine binds version/playthrough/mod scope and portable persistence;
-- callers never receive paths or a raw filesystem handle.
storage = {
@@ -1077,6 +1096,10 @@ function Loader:_api(mod)
selected = function(_, game) return storage:selected(game) end,
write = function(_, game, key, value) return storage:write(game, key, value) end,
read = function(_, game, key) return storage:read(game, key) end,
writeBytes = function(_, game, key, bytes)
return storage:writeBytes(game, key, bytes)
end,
readBytes = function(_, game, key) return storage:readBytes(game, key) end,
list = function(_, game, prefix) return storage:list(game, prefix) end,
delete = function(_, game, key) return storage:delete(game, key) end,
},
@@ -1158,6 +1181,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,12 +1230,16 @@ function Loader:_api(mod)
loader.imageCache[full] = image
return image
end,
list = listOwn,
info = infoOwn,
}, { __index = api.content })
-- the mod's own directory and nothing above it: PhysFS already refuses a
-- climb, but loader.fs is injectable and has no such floor
function api:read(relative)
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
+100 -1
View File
@@ -132,13 +132,97 @@ local function mergeConflictLists(conflicts, incompatible)
return out
end
local scrubUtf8 -- shared by required-import labels and top-level strings
-- User-supplied files a mod needs beside its own source. The launcher owns
-- the picker and the copy; the mod only reads the resulting
-- <mod>/baseroms/<file> through its existing scoped mod:read surface. MD5 is
-- deliberately the manifest vocabulary here: ROM preservation databases and
-- the mods consuming these files commonly identify dumps by MD5, and the
-- digest is an identity check rather than a security boundary.
local function parseImports(value, field, required)
field = field or "required_imports"
local out, ids, files = {}, {}, {}
for _, entry in ipairs(array(value)) do
assert(type(entry) == "table", field .. " entries must be objects")
local id = entry.id
assert(type(id) == "string" and id:match("^[%w_%-]+$"),
field .. " id must contain only letters, numbers, _ or -")
assert(not ids[id], "duplicate " .. field .. " id: " .. id)
ids[id] = true
local file = entry.file
assert(type(file) == "string" and file ~= "",
field .. " file is required")
file = SafePath.require(file, field .. " file")
assert(not file:find("/", 1, true),
field .. " file must be a filename inside baseroms")
assert(file:sub(1, 1) ~= ".",
field .. " file must not use a hidden metadata filename")
assert(not files[file], "duplicate " .. field .. " file: " .. file)
files[file] = true
local hashes = entry.md5
if type(hashes) == "string" then hashes = { hashes } end
assert(type(hashes) == "table" and #hashes > 0,
field .. " md5 must be a hash or non-empty array")
local accepted, seen = {}, {}
for _, digest in ipairs(hashes) do
assert(type(digest) == "string" and digest:match("^[%x]+$")
and #digest == 32, field .. " md5 values must be 32 hex characters")
digest = digest:lower()
if not seen[digest] then
seen[digest] = true
accepted[#accepted + 1] = digest
end
end
local format = entry.format or "raw"
assert(format == "raw" or format == "n64",
field .. " format must be raw or n64")
local name = entry.name or id
assert(type(name) == "string" and name ~= "",
field .. " name must be a non-empty string")
local description = entry.description or entry.hint
if description ~= nil then
assert(type(description) == "string" and description ~= "",
field .. " description must be a non-empty string")
end
local size = entry.size
local maxSize = entry.max_size
local function validateSize(value, label)
if value == nil then return end
assert(type(value) == "number" and value > 0 and value % 1 == 0,
field .. " " .. label .. " must be a positive integer")
assert(value <= 128 * 1024 * 1024,
field .. " " .. label .. " exceeds the 128 MiB hard limit")
end
validateSize(size, "size")
validateSize(maxSize, "max_size")
assert(not (size and maxSize) or size <= maxSize,
field .. " size must not exceed max_size")
out[#out + 1] = {
id = id,
name = scrubUtf8(name),
description = scrubUtf8(description),
file = file,
md5 = accepted,
format = format,
size = size,
max_size = maxSize,
required = required ~= false,
}
end
return out
end
-- Drop bytes that are not valid UTF-8 (malformed sequences, overlongs,
-- surrogates, > U+10FFFF) and a leading BOM. LÖVE's text renderer raises
-- "Invalid UTF-8" from love.graphics.print/printf, so any manifest string a
-- panel may draw must be scrubbed here -- the one place every mod manifest
-- passes through -- or a single mangled description crashes the whole MODS
-- panel instead of misrendering one card.
local function scrubUtf8(s)
scrubUtf8 = function(s)
if type(s) ~= "string" then return s end
s = s:gsub("^\239\187\191", "")
local out, i, n = {}, 1, #s
@@ -290,6 +374,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,
@@ -318,6 +415,8 @@ function Manifest.validate(raw, path)
permissionSet = permissionSet,
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,
+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
+4 -4
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,7 +68,7 @@ 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", thread = true,
system = "mod.device:powerInfo() for battery information, mod.steps for "
.. "the step bridge", event = true,
}
+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
+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
+23 -4
View File
@@ -185,6 +185,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 +321,7 @@ function BattleState.new(game, opts)
-- pic is a cache asset, so an import made before the extractor grew that
-- stage has none and the mon stands in for the whole intro.
self.showEnemyTrainer = false
self.enemyTrainerTrueColor = false
-- The CLASS CONSTANT (BUG_CATCHER), which is what both tables this looks the
-- pic up in are keyed by: menu_gfx's trainerPics is written out of
-- constants.trainerClassOrder, and palettes.trainers out of the same names.
@@ -317,17 +331,19 @@ function BattleState.new(game, opts)
-- no palette for every trainer the world starts, which is all of them.
-- `classId` is the trainers.lua key, i.e. the constant; `className` is the
-- DISPLAY name ("BUG CATCHER", with the space) and is not a key at all.
-- A class record's own `pic` / `trueColor` (the trainers registry) wins
-- over the extracted sheet, so a mod can drop in full-color art.
local enemyTrainer = self.battle and self.battle.trainer
self.enemyTrainerClass = enemyTrainer
and (enemyTrainer.classId or enemyTrainer.class)
local trainerPics = hudGfx and hudGfx.trainerPics
local trainerPath = self.enemyTrainerClass and trainerPics
and trainerPics[self.enemyTrainerClass]
local trainerPath, trainerTrueColor =
BattleState.trainerArt(data, self.enemyTrainerClass)
if trainerPath then
local ok, image = pcall(Assets.image, trainerPath)
if ok and image then
self.enemyTrainerImage = image
self.enemyTrainerPath = trainerPath
self.enemyTrainerTrueColor = trainerTrueColor and true or false
self.showEnemyTrainer = true
end
end
@@ -588,7 +604,10 @@ function BattleState:drawPic(mon, back)
-- slides it out (InitEnemyTrainer, engine/battle/core.asm:7848).
local enemyTrainer = (not back) and self.showEnemyTrainer
and self.enemyTrainerImage
if enemyTrainer then image, path = enemyTrainer, self.enemyTrainerPath end
if enemyTrainer then
image, path = enemyTrainer, self.enemyTrainerPath
trueColor = self.enemyTrainerTrueColor
end
if not image then return end
local side = back and "player" or "enemy"
local anim = self:animPicState(side)
+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
+12 -3
View File
@@ -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
+6 -1
View File
@@ -45,7 +45,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"
+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
+103
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,104 @@ 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)
or not acceptsMenuInput(game, ow) then return out end
local save, inventory = game.save, game.save.inventory or {}
local items = game.data and game.data.items or {}
if (inventory.BICYCLE or 0) > 0 and not ow.player.surfing
and not (save.onBike and save.forcedBike)
and (save.onBike or ow:bikeAllowed(ow.map.id)) then
out[#out + 1] = { id = "bicycle",
label = save.onBike and "BIKE OFF" or "BICYCLE" }
end
if not ow.player.surfing and ow:facingIsShoreOrWater() then
local rods = {}
for _, id in ipairs(RODS) do
if (inventory[id] or 0) > 0 then
local def = items[id]
rods[#rods + 1] = { id = id, label = def and def.name or id }
end
end
if #rods > 0 then
out[#out + 1] = { id = "fish", label = "FISH", rods = rods }
end
end
if ow:useCutFieldMove() == "ok" then
out[#out + 1] = { id = "cut", label = "CUT" }
end
local surf = ow:useSurfFieldMove()
if surf == "ok" or surf == "dismount" then
out[#out + 1] = { id = "surf",
label = surf == "dismount" and "LEAVE WATER" or "SURF" }
end
if not ow.strengthActive and ow:partyKnows("STRENGTH") then
out[#out + 1] = { id = "strength", label = "STRENGTH" }
end
if ow.dark and ow:partyKnows("FLASH") then
out[#out + 1] = { id = "flash", label = "FLASH" }
end
if DIG_TILESETS[ow.map.def.tileset] and ow.map.id ~= "AGATHAS_ROOM"
and ow:partyKnows("DIG") then
out[#out + 1] = { id = "dig", label = "DIG" }
end
if ow:partyKnows("TELEPORT") and Map.isOutside(ow.map.def,
FieldDefaults.field(game.data, "outsideTilesets")) then
out[#out + 1] = { id = "teleport", label = "TELEPORT" }
end
return out
end
function WorldAPI:useFieldAction(id, opts)
local game, ow = self.game, self:overworld()
if not ow then return nil, NO_OVERWORLD end
if not acceptsMenuInput(game, ow) then return nil, "world is busy" end
local found
for _, action in ipairs(self:availableFieldActions()) do
if action.id == id then found = action break end
end
if not found then return nil, "field action unavailable" end
if id == "bicycle" then
if ow:useBicycle() then return true end
elseif id == "cut" then
local x, y = ow.player:facingCell()
if ow:tryCut(x, y) then return true end
elseif id == "surf" then
local mode = ow:useSurfFieldMove()
if mode == "dismount" then
ow:stopSurfing()
return true
elseif mode == "ok" then
local x, y = ow.player:facingCell()
ow:trySurf(x, y)
return true
end
elseif id == "fish" then
local rod = opts and opts.rod
if not rod and #found.rods == 1 then rod = found.rods[1].id end
for _, choice in ipairs(found.rods) do
if choice.id == rod and ow:useFishingRod(rod) then return true end
end
return nil, "fishing rod unavailable"
elseif id == "strength" then
if ow:useStrengthFieldMove() then return true end
elseif id == "flash" then
if ow:useFlashFieldMove() then return true end
elseif id == "dig" or id == "teleport" then
ow:beginTeleportOut()
return true
end
return nil, "field action unavailable"
end
-- A compact, read-only view of the active map for minimaps and companion UIs.
-- `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
+119
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,109 @@ 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)
or not world:acceptsMenuInput() then return out end
local inventory = game.save.inventory or {}
if (inventory.BICYCLE or 0) > 0 then
local bike = Bike.tryBike({
state = world.playerState,
environment = world.map.def and world.map.def.environment,
collision = world:playerCollision(),
alwaysOnBike = world:alwaysOnBike(),
})
if bike == "mount" or bike == "dismount" then
out[#out + 1] = { id = "bicycle",
label = bike == "dismount" and "BIKE OFF" or "BICYCLE" }
end
end
local context = world:fieldContext()
if not FieldMoves.isSurfing(world.playerState)
and Permissions.isWater(context.facingColl) then
local rods = {}
for _, id in ipairs(RODS) do
if (inventory[id] or 0) > 0 then
rods[#rods + 1] = { id = id, label = itemLabel(game, id) }
end
end
if #rods > 0 then
out[#out + 1] = { id = "fish", label = "FISH", rods = rods }
end
end
for _, row in ipairs(FIELD_ACTIONS) do
if not (row.move == "STRENGTH" and world.strengthActive) then
local mon = FieldMoves.partyMoveUser(context.party, row.move, context)
if mon then
context.mon = mon
local result = FieldMoves.fromMenu(row.move, context)
if result.ok then
out[#out + 1] = { id = row.id,
label = row.move:gsub("_", " ") }
end
end
end
end
if (inventory.SQUIRTBOTTLE or 0) > 0
and world:squirtbottleTreeScript() then
out[#out + 1] = { id = "squirtbottle",
label = itemLabel(game, "SQUIRTBOTTLE") }
end
return out
end
function WorldAPI:useFieldAction(id, opts)
local world = self:overworld()
if not world then return nil, NO_OVERWORLD end
if not world:acceptsMenuInput() then return nil, "world is busy" end
local found
for _, action in ipairs(self:availableFieldActions()) do
if action.id == id then found = action break end
end
if not found then return nil, "field action unavailable" end
if id == "bicycle" then
local outcome = world:useFieldItem("BICYCLE")
if outcome and outcome ~= "nowhere" then return true end
elseif id == "fish" then
local rod = opts and opts.rod
if not rod and #found.rods == 1 then rod = found.rods[1].id end
for _, choice in ipairs(found.rods) do
if choice.id == rod then
local outcome = world:useFieldItem(rod)
if outcome and outcome ~= "nowhere" then return true end
break
end
end
return nil, "fishing rod unavailable"
elseif id == "squirtbottle" then
local outcome = world:useFieldItem("SQUIRTBOTTLE")
if outcome and outcome ~= "nowhere" then return true end
end
for _, row in ipairs(FIELD_ACTIONS) do
if row.id == id then
local context = world:fieldContext()
local mon = FieldMoves.partyMoveUser(context.party, row.move, context)
local result = mon and world:useFieldMove(row.move, mon)
if result and result.ok then return true end
return nil, "field action unavailable"
end
end
return nil, "field action unavailable"
end
-- The same read-only minimap contract as Gen 1, with Gold's object/event
-- visibility rules supplying the semantic markers.
function WorldAPI:mapOverview()
File diff suppressed because it is too large Load Diff
@@ -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
+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()
+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")
+256
View File
@@ -0,0 +1,256 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local Manifest = require("src.mods.Manifest")
local RequiredImports = require("src.mods.RequiredImports")
local Loader = require("src.mods.Loader")
local S = require("tests.harness").suite("required mod imports")
local check, eq = S.check, S.eq
local DIGEST = "0123456789abcdef0123456789abcdef"
local function fakeHash(data)
return data:sub(1, 4) == "\128\55\18\64" and DIGEST
or "ffffffffffffffffffffffffffffffff"
end
local manifest = Manifest.validate({
id = "stadium_fx", name = "Stadium FX", version = "1.0.0", entry = "main.lua",
required_imports = {
{ id = "stadium2", name = "Stadium 2", file = "stadium2.z64",
description = "USA dump", format = "n64", size = 8,
md5 = { DIGEST, DIGEST:upper() } },
},
}, "mods/stadium_fx")
eq(#manifest.required_imports, 1, "required import parses")
eq(#manifest.required_imports[1].md5, 1, "accepted MD5 values normalize and dedupe")
eq(manifest.required_imports[1].md5[1], DIGEST, "MD5 is lowercase")
eq(manifest.required_imports[1].description, "USA dump",
"import description is preserved for the picker UI")
eq(manifest.required_imports[1].size, 8, "exact import size parses")
local optionalManifest = Manifest.validate({
id = "optional_fx", name = "Optional FX", version = "1.0.0", entry = "main.lua",
optional_imports = {
{ id = "bonus", name = "Bonus ROM", file = "bonus.z64",
format = "n64", md5 = DIGEST },
},
}, "mods/optional_fx")
eq(#optionalManifest.optional_imports, 1, "optional import parses")
eq(optionalManifest.optional_imports[1].required, false,
"optional import is marked non-blocking")
local optionalRows, optionalMissing, missingOptional =
RequiredImports.inspect(optionalManifest, love.filesystem, fakeHash)
eq(optionalMissing, 0, "missing optional import is not required")
eq(missingOptional, 1, "missing optional import is reported separately")
check(not optionalRows[1].required, "optional row is labeled optional")
check(not pcall(Manifest.validate, {
id = "bad", name = "Bad", version = "1", entry = "main.lua",
required_imports = { { id = "rom", file = "../outside.z64", md5 = DIGEST } },
}), "required import cannot escape baseroms")
check(not pcall(Manifest.validate, {
id = "bad", name = "Bad", version = "1", entry = "main.lua",
required_imports = { { id = "rom", file = "rom.z64", md5 = "short" } },
}), "malformed MD5 is refused")
check(not pcall(Manifest.validate, {
id = "bad", name = "Bad", version = "1", entry = "main.lua",
required_imports = { { id = "rom", file = ".rom.removed", md5 = DIGEST } },
}), "hidden import filenames cannot collide with engine metadata")
check(not pcall(Manifest.validate, {
id = "bad", name = "Bad", version = "1", entry = "main.lua",
required_imports = { { id = "rom", file = "rom.bin", md5 = DIGEST,
max_size = RequiredImports.MAX_BYTES + 1 } },
}), "manifest import sizes cannot exceed the hard limit")
local canonical = "\128\55\18\64ABCD"
local v64 = "\55\128\64\18BADC"
local n64 = "\64\18\55\128DCBA"
check(RequiredImports.importData(optionalManifest, "bonus", canonical,
{ hash = fakeHash }), "optional import uses the normal validation path")
eq(RequiredImports.normalizeN64(canonical), canonical, "z64 stays canonical")
eq(RequiredImports.normalizeN64(v64), canonical, "v64 pair swap canonicalizes")
eq(RequiredImports.normalizeN64(n64), canonical, "n64 word swap canonicalizes")
eq(RequiredImports.normalizeN64(string.rep("H", 512) .. v64), canonical,
"recognized 512-byte copier header is stripped")
check(RequiredImports.normalizeN64(string.rep("H", 520)) == nil,
"an arbitrary 512-byte prefix is not treated as a copier header")
local ok, digest = RequiredImports.importData(manifest, "stadium2", v64,
{ hash = fakeHash })
check(ok, "validated bytes import")
eq(digest, DIGEST, "import reports canonical digest")
eq(love.filesystem.read("mods/stadium_fx/baseroms/stadium2.z64"), canonical,
"import writes canonical bytes inside the mod")
local rows, missing = RequiredImports.inspect(manifest, love.filesystem, fakeHash)
eq(missing, 0, "written import satisfies its declaration")
check(rows[1].present, "inspection reports ready")
local target = Manifest.validate({
id = "other_fx", name = "Other FX", version = "1.0.0", entry = "main.lua",
required_imports = {
{ id = "same_rom", file = "source.z64", format = "n64", md5 = DIGEST },
},
}, "mods/other_fx")
local targetRows, targetMissing = RequiredImports.inspect(target,
love.filesystem, fakeHash)
eq(targetMissing, 1, "matching hashes do not silently share another mod's import")
check(not targetRows[1].present,
"a mod needs its own explicit player-selected file")
check(RequiredImports.remove(target, "same_rom"), "a required import can be removed")
eq(love.filesystem.read("mods/other_fx/baseroms/source.z64"), nil,
"remove deletes this mod's private copy")
check(RequiredImports.importData(target, "same_rom", canonical, { hash = fakeHash }),
"choosing the file again clears the removal decision")
local legacy = Manifest.validate({
id = "legacy", name = "Legacy", version = "1.0.0", entry = "main.lua",
}, "mods/legacy")
local legacyTarget = Manifest.validate({
id = "legacy_user", name = "Legacy User", version = "1.0.0", entry = "main.lua",
required_imports = {
{ id = "rom", file = "legacy-source.z64", format = "n64", md5 = DIGEST },
},
}, "mods/legacy_user")
love.filesystem.write("mods/legacy/baseroms/manually-imported.v64", v64)
local legacyRows, legacyMissing = RequiredImports.inspect(legacyTarget,
love.filesystem, fakeHash)
eq(legacyMissing, 1, "undeclared files in another mod are never indexed")
check(not legacyRows[1].present, "legacy baseroms remain private to their mod")
local capped = Manifest.validate({
id = "capped", name = "Capped", version = "1.0.0", entry = "main.lua",
required_imports = {
{ id = "small", file = "small.bin", md5 = DIGEST, max_size = 4 },
},
}, "mods/capped")
local tooLarge, sizeWhy = RequiredImports.validateData(
capped.required_imports[1], "12345", fakeHash)
eq(tooLarge, nil, "per-import size cap rejects before hashing")
check(tostring(sizeWhy):find("too large", 1, true) ~= nil,
"size rejection explains the limit")
-- A successful validation writes an engine receipt. Matching size + modtime
-- lets later launcher refreshes avoid reading and hashing the ROM again.
local cacheFiles = {
["mods/cache/baseroms/source.z64"] = canonical,
}
local dataReads = 0
local cacheModtime = 123
local cacheFs = {
getInfo = function(path, kind)
local data = cacheFiles[path]
if data then return { type = "file", size = #data, modtime = cacheModtime } end
return nil
end,
read = function(path)
if path == "mods/cache/baseroms/source.z64" then dataReads = dataReads + 1 end
return cacheFiles[path]
end,
write = function(path, data) cacheFiles[path] = data return true end,
remove = function(path) cacheFiles[path] = nil return true end,
}
local cacheManifest = Manifest.validate({
id = "cache", name = "Cache", version = "1.0.0", entry = "main.lua",
required_imports = {
{ id = "source", file = "source.z64", format = "n64", size = 8,
md5 = DIGEST },
},
}, "mods/cache")
local cacheRows = RequiredImports.inspect(cacheManifest, cacheFs, fakeHash)
check(cacheRows[1].present, "initial cached import validation succeeds")
cacheRows = RequiredImports.inspect(cacheManifest, cacheFs, function()
error("unchanged cached import should not be hashed again")
end)
check(cacheRows[1].present, "validation receipt satisfies the next refresh")
eq(dataReads, 1, "unchanged imported ROM is read only once")
cacheFiles["mods/cache/baseroms/source.z64"] = "BADBYTES"
cacheModtime = 124
cacheRows = RequiredImports.inspect(cacheManifest, cacheFs, fakeHash)
check(not cacheRows[1].present, "changed imported ROM bypasses a stale receipt")
eq(dataReads, 2, "changed imported ROM is read again")
eq(cacheFiles[RequiredImports.receiptPath(cacheManifest, cacheManifest.required_imports[1])],
nil, "stale validation receipt is removed")
local rejected, why = RequiredImports.importData(target, "same_rom", "wrong",
{ hash = fakeHash })
eq(rejected, nil, "mismatched selection is rejected")
check(tostring(why):find("N64 ROM (.z64/.v64/.n64)", 1, true) ~= nil,
"normalization failure explains the selected format")
love.filesystem.write("mods/launcher_needs/manifest.json", ([[{
"id":"launcher_needs","name":"Launcher Needs","version":"1.0.0",
"entry":"main.lua","required_imports":[{"id":"source","name":"Source ROM",
"file":"source.bin","md5":"%s"}]
}]]):format(DIGEST))
love.filesystem.write("mods/launcher_needs/main.lua", "return function(mod) end")
local launcherRows = require("src.mods.LauncherMods").list()
eq(#launcherRows, 1, "launcher keeps a mod with a missing required import visible")
eq(launcherRows[1].missingRequiredImports, 1,
"launcher row carries the missing import count")
eq(launcherRows[1].status, "needs_import",
"missing import changes Ready to Import required")
check(launcherRows[1].statusDetail:find("Source ROM", 1, true) ~= nil,
"launcher warning names the missing file")
local function memfs(files)
return {
read = function(path) return files[path] end,
getInfo = function(path)
if files[path] then return { type = "file" } end
local prefix = path .. "/"
for key in pairs(files) do
if key:sub(1, #prefix) == prefix then return { type = "directory" } end
end
end,
getDirectoryItems = function(path)
if path == "mods" then
local out, seen = {}, {}
for key in pairs(files) do
local id = key:match("^mods/([^/]+)/manifest%.json$")
if id and not seen[id] then seen[id] = true; out[#out + 1] = id end
end
table.sort(out)
return out
end
return {}
end,
load = function(path)
local source = files[path]
if not source then return nil, "missing" end
return load(source, path)
end,
}
end
local manifestJson = ([[{
"id":"needs_rom","name":"Needs ROM","version":"1.0.0","entry":"main.lua",
"required_imports":[{"id":"rom","name":"Source ROM","file":"source.bin",
"md5":"%s"}]
}]]):format(DIGEST)
local loader = Loader.new({ fs = memfs({
["mods/needs_rom/manifest.json"] = manifestJson,
["mods/needs_rom/main.lua"] = "return function(mod) mod.exports.ran = true end",
}) })
check(loader:load({}) == false, "missing required import blocks the enabled mod")
local status = loader:status().available[1]
eq(status.state, "invalid", "blocked mod reports invalid")
check(status.error:find("Source ROM", 1, true) ~= nil,
"loader failure names the required import")
check(not (loader.exports.needs_rom and loader.exports.needs_rom.ran),
"blocked mod entry never executes")
local optionalJson = ([[{
"id":"optional_rom","name":"Optional ROM","version":"1.0.0","entry":"main.lua",
"optional_imports":[{"id":"rom","name":"Bonus ROM","file":"bonus.bin",
"md5":"%s"}]
}]]):format(DIGEST)
local optionalLoader = Loader.new({ fs = memfs({
["mods/optional_rom/manifest.json"] = optionalJson,
["mods/optional_rom/main.lua"] = "return function(mod) mod.exports.ran = true end",
}) })
check(optionalLoader:load({}), "missing optional import does not block the mod")
check(optionalLoader.exports.optional_rom.ran,
"mod entry executes without its optional import")
S.finish()
+74 -5
View File
@@ -358,11 +358,13 @@ T.same(checkpoints:capture(game), beforeFailure,
-- The same public facade must carry a real battle checkpoint end to end. The
-- engine-side fixture is deliberately constructed outside the probe mod; the
-- mod sees and calls only mod.checkpoints.
local function makeBattleGame()
local function makeBattleGame(kind)
local data = Fixtures.fresh()
local save = SaveData.newGame()
save.meta.playthroughId = "public-battle-playthrough"
save.party = { Pokemon.new(data, "FIXMON_A", 20) }
save.party = { Pokemon.new(data, "FIXMON_A", 20),
Pokemon.new(data, "FIXMON_B", 19),
Pokemon.new(data, "FIXMON_C", 18) }
-- The tiny fixture registry intentionally omits several full-game defaults.
-- Normalize those once, then place the save on its fixture map.
SaveData.validate(save, data)
@@ -387,7 +389,8 @@ local function makeBattleGame()
self.player = { cellX = x, cellY = y, facing = facing, surfing = false }
end
function battleOw:restoreBattleContinuation(restoredBattle, origin)
if origin.kind ~= "wild_encounter" or origin.map ~= self.map.id then
local expected = kind == "trainer" and "trainer_encounter" or "wild_encounter"
if origin.kind ~= expected or origin.map ~= self.map.id then
return false
end
restoredBattle.onFinish = function() end
@@ -397,9 +400,19 @@ local function makeBattleGame()
data = data, save = save, stack = stack, overworld = battleOw,
}, { __index = GameMethods })
stack.states[1] = battleOw
local battle = BattleState.newWild(battleGame, "FIXMON_B", 12)
local battle
if kind == "trainer" then
battle = BattleState.newTrainer(battleGame, "OPP_FIX_YOUNGSTER", 1, {
playerPartyIndices = { 2, 3 },
})
else
battle = BattleState.newWild(battleGame, "FIXMON_B", 12)
end
battle.phase, battle.queue = "menu", {}
battle.checkpointOrigin = { kind = "wild_encounter", map = "FIX_TOWN" }
battle.checkpointOrigin = kind == "trainer"
and { kind = "trainer_encounter", map = "FIX_TOWN", npcId = "TRAINER_1",
trainerClass = "OPP_FIX_YOUNGSTER", partyIndex = 1 }
or { kind = "wild_encounter", map = "FIX_TOWN" }
battle.musicKind = battle:computeMusicKind()
battle.onFinish = function() end
stack.states[2] = battle
@@ -437,6 +450,62 @@ if battleSnapshot then
"public battle capture/restore/capture is a normalized differential roundtrip")
end
checkpointRngState = "scoped-trainer-rng-A"
local scopedGame, scopedBattle = makeBattleGame("trainer")
local scopedSnapshot, scopedCaptureCode = checkpoints:capture(scopedGame)
T.check(scopedSnapshot ~= nil,
"public checkpoints capture a scoped trainer battle: "
.. tostring(scopedCaptureCode))
if scopedSnapshot then
T.same(scopedSnapshot.runtime.battle.playerPartyIndices, { 2, 3 },
"capture stores the battle-local save-party index scope")
local restored, code, message = checkpoints:restore(scopedGame, scopedSnapshot)
T.check(restored == true,
"public checkpoints restore a scoped trainer battle: "
.. tostring(code) .. " / " .. tostring(message))
local scopedRestored = scopedGame.stack:top()
T.same(scopedRestored.playerPartyIndices, { 2, 3 },
"restore reconstructs the same ordered party scope")
T.check(scopedRestored.playerParty[1] == scopedGame.save.party[2]
and scopedRestored.playerParty[2] == scopedGame.save.party[3],
"restored scope points at authoritative save-party records")
scopedSnapshot.runtime.battle.playerPartyIndices = nil
local oldRestored, oldCode = checkpoints:restore(scopedGame, scopedSnapshot)
T.check(oldRestored == true,
"an old checkpoint without party scope remains compatible: "
.. tostring(oldCode))
T.eq(scopedGame.stack:top().playerParty, nil,
"an old checkpoint restores the vanilla full-party view")
end
local function scopedCheckpoint()
local freshGame = makeBattleGame("trainer")
local snapshot = assert(checkpoints:capture(freshGame))
return freshGame, snapshot
end
local excludedGame, excludedSnapshot = scopedCheckpoint()
excludedSnapshot.runtime.battle.player.index = 1
local excludedRestored, excludedCode = checkpoints:restore(excludedGame,
excludedSnapshot)
T.check(excludedRestored == false and excludedCode == "invalid_checkpoint",
"a scoped checkpoint rejects an active battler outside the eligible view")
local malformedGame, malformedSnapshot = scopedCheckpoint()
malformedSnapshot.runtime.battle.playerPartyIndices.extra = 3
local malformedRestored, malformedCode = checkpoints:restore(malformedGame,
malformedSnapshot)
T.check(malformedRestored == false and malformedCode == "invalid_checkpoint",
"a scoped checkpoint rejects non-array scope members instead of failing open")
local participantGame, participantSnapshot = scopedCheckpoint()
participantSnapshot.runtime.battle.participants = { 1 }
local participantRestored, participantCode = checkpoints:restore(
participantGame, participantSnapshot)
T.check(participantRestored == false and participantCode == "invalid_checkpoint",
"a scoped checkpoint rejects excluded participant references")
-- The mod receives the normal public hook facade, never BattleState. START
-- at the restored safe decision reaches its semantic auxiliary action without
-- selecting a native command.
+30
View File
@@ -69,6 +69,16 @@ local PROBE = [[
out.readBackslash = attempt(function() return mod:read("..\\secret.txt") end)
out.assetsEscape = attempt(function() return mod.assets:path("../../x.png") end)
out.readOwn = mod:read("data/note.txt")
out.listAssets = mod:list("assets")
out.listSprites = mod:list("assets/sprites")
out.listRoot = mod:list()
out.assetsList = mod.assets:list("assets")
out.infoAssets = mod:info("assets")
out.infoNote = mod:info("data/note.txt")
out.infoMissing = mod:info("nope")
out.listMissing = mod:list("nope")
out.listEscape = attempt(function() return mod:list("../secret") end)
out.infoEscape = attempt(function() return mod:info("../../x") end)
_G.SANDBOX_LEAK = "escaped"
out.globalsAreOwn = _G ~= nil and _G.SANDBOX_LEAK == "escaped"
@@ -81,6 +91,8 @@ local FILES = {
["mods/fix_sandbox/manifest.json"] = manifest("fix_sandbox"),
["mods/fix_sandbox/main.lua"] = PROBE,
["mods/fix_sandbox/data/note.txt"] = "own file",
["mods/fix_sandbox/assets/front.png"] = "png",
["mods/fix_sandbox/assets/sprites/walk.png"] = "png",
}
local run = T.sdk.loadMods({ "mods/fix_sandbox" }, { fs = T.sdk.memfs(FILES) })
@@ -155,6 +167,24 @@ T.check(out.readAbsolute ~= false, "mod:read refuses an absolute path")
T.check(out.readBackslash ~= false, "mod:read refuses a backslash climb")
T.check(out.assetsEscape ~= false, "mod.assets:path refuses a climb")
T.eq(out.readOwn, "own file", "and the mod's own files still read")
T.same(out.listAssets, { "front.png", "sprites" },
"mod:list names the children of a directory inside the mod")
T.same(out.listSprites, { "walk.png" },
"and a nested directory")
T.check(out.listRoot and out.listRoot[1] ~= nil,
"mod:list() with no path lists the mod root")
T.same(out.assetsList, out.listAssets,
"mod.assets:list is the same listing")
T.eq(out.infoAssets and out.infoAssets.type, "directory",
"mod:info reports a directory")
T.eq(out.infoNote and out.infoNote.type, "file",
"and a file")
T.eq(out.infoMissing, nil, "mod:info is nil for a missing path")
T.same(out.listMissing, {}, "mod:list of a missing path is empty, not an error")
T.check(out.listEscape and out.listEscape:find("must stay inside", 1, true),
"mod:list cannot climb out of the mod directory: " .. tostring(out.listEscape))
T.check(out.infoEscape and out.infoEscape:find("must stay inside", 1, true),
"mod:info cannot climb either")
run.release()
-- ------- the grammar itself
+110 -2
View File
@@ -7,6 +7,7 @@ love = love or require("tests.love_stub")
local T = require("tests.harness").suite("mod storage")
local Loader = require("src.mods.Loader")
local Runtime = require("src.mods.Runtime")
local Storage = require("src.mods.Storage")
local Version = require("src.core.Version")
local savedEvents, savedHooks = Runtime.events, Runtime.hooks
@@ -22,7 +23,9 @@ local function memfs(files)
function fs.read(path) return files[path] end
function fs.write(path, body)
if fs.failTmp and path:sub(-4) == ".tmp" then return false, "tmp denied" end
if fs.failMain and path:sub(-4) == ".lua" then return false, "main denied" end
if fs.failMain and (path:sub(-4) == ".lua" or path:sub(-4) == ".bin") then
return false, "main denied"
end
files[path] = body
return true
end
@@ -107,6 +110,50 @@ T.same(loaded, payload, "stored payload roundtrips as data")
T.check(loaded ~= payload and loaded.nested ~= payload.nested,
"read returns decoded data rather than the caller's live table")
T.check(type(alpha.writeBytes) == "function"
and type(alpha.readBytes) == "function",
"mod.storage exposes opaque byte read/write methods")
if type(alpha.writeBytes) == "function" and type(alpha.readBytes) == "function" then
local binary = "MESH\0\1\255\128\nreturn _G.MOD_STORAGE_EXECUTED = true"
local binaryOk, binaryCode, binaryMessage =
alpha:writeBytes(current, "states/quick/blob", binary)
T.check(binaryOk == true,
"opaque bytes write exactly: " .. tostring(binaryCode or binaryMessage))
local binaryLoaded, binaryReadCode =
alpha:readBytes(current, "states/quick/blob")
T.eq(binaryLoaded, binary,
"opaque bytes round-trip without text or Lua decoding")
T.eq(binaryReadCode, nil, "successful opaque byte read has no error")
T.eq(_G.MOD_STORAGE_EXECUTED, nil,
"Lua-looking opaque bytes are never executed")
local emptyOk = alpha:writeBytes(current, "binary/empty", "")
T.check(emptyOk == true, "empty opaque byte payloads are valid")
T.eq(alpha:readBytes(current, "binary/empty"), "",
"empty opaque byte payloads round-trip")
local badBytes, badBytesCode =
alpha:writeBytes(current, "binary/bad-type", { byte = true })
T.check(not badBytes and badBytesCode == "invalid_bytes",
"non-string opaque payloads are rejected")
local savedLimit = Storage.MAX_BYTES
Storage.MAX_BYTES = 4
local tooLarge, tooLargeCode =
alpha:writeBytes(current, "binary/too-large", "12345")
Storage.MAX_BYTES = savedLimit
T.check(not tooLarge and tooLargeCode == "size_limit",
"opaque payloads over the per-key limit are rejected")
local tableConflict, tableConflictCode =
alpha:writeBytes(current, "states/quick/q1", "table-key-conflict")
T.check(not tableConflict and tableConflictCode == "type_conflict",
"bytes cannot replace a table record without deletion")
local wrongType, wrongTypeCode = alpha:read(current, "states/quick/blob")
T.check(wrongType == nil and wrongTypeCode == "type_mismatch",
"table reads identify byte records as the wrong storage type")
end
local bad, badCode = alpha:write(current, "states/bad", { callback = function() end })
T.check(not bad and badCode == "encode_failed",
"functions are rejected with a stable data-only error")
@@ -120,19 +167,33 @@ T.check(alpha:write(current, "states/quick/zeta", { n = 2 }), "write zeta")
T.check(alpha:write(current, "states/quick/alpha", { n = 1 }), "write alpha")
T.check(alpha:write(current, "settings", { enabled = true }), "write settings")
local keys = alpha:list(current, "states/quick")
T.same(keys, { "states/quick/alpha", "states/quick/q1", "states/quick/zeta" },
T.same(keys, { "states/quick/alpha", "states/quick/blob",
"states/quick/q1", "states/quick/zeta" },
"list returns sorted logical keys under the requested prefix")
-- Mod, playthrough, and game namespaces cannot observe each other.
local missing, missingCode = beta:read(current, "states/quick/q1")
T.check(missing == nil and missingCode == "not_found",
"another mod cannot read the first mod's payload")
if type(alpha.readBytes) == "function" then
missing, missingCode = beta:readBytes(current, "states/quick/blob")
T.check(missing == nil and missingCode == "not_found",
"another mod cannot read the first mod's opaque payload")
end
missing, missingCode = alpha:read(game("red", "play-b"), "states/quick/q1")
T.check(missing == nil and missingCode == "not_found",
"another playthrough cannot read the payload")
missing, missingCode = alpha:read(game("blue", "play-a"), "states/quick/q1")
T.check(missing == nil and missingCode == "not_found",
"another game version cannot read the payload")
if type(alpha.readBytes) == "function" then
missing, missingCode = alpha:readBytes(game("red", "play-b"), "states/quick/blob")
T.check(missing == nil and missingCode == "not_found",
"another playthrough cannot read the opaque payload")
missing, missingCode = alpha:readBytes(game("blue", "play-a"), "states/quick/blob")
T.check(missing == nil and missingCode == "not_found",
"another game version cannot read the opaque payload")
end
-- Find the implementation-owned file only to inject corruption; assertions stay
-- on public read behavior, not the path shape.
@@ -142,6 +203,12 @@ local function mainFor(fragment)
end
end
local function byteMainFor(fragment)
for path in pairs(files) do
if path:find(fragment, 1, true) and path:sub(-4) == ".bin" then return path end
end
end
local q1Main = mainFor("q1")
T.check(type(q1Main) == "string", "failure fixture locates the persisted q1")
files[q1Main] = "not a serialized table"
@@ -158,6 +225,47 @@ T.check(not ok and code == "write_failed", "staging failure is reported")
T.same(alpha:read(current, "replace"), { version = 1 },
"staging failure leaves the prior value readable")
if type(alpha.writeBytes) == "function" and type(alpha.readBytes) == "function" then
T.check(alpha:writeBytes(current, "binary/recover", "old-bytes"),
"seed opaque recovery value")
local recoverMain = byteMainFor("binary/recover")
T.check(type(recoverMain) == "string", "failure fixture locates opaque recovery data")
files[recoverMain] = nil
T.eq(alpha:readBytes(current, "binary/recover"), "old-bytes",
"missing opaque main recovers the last verified backup")
T.check(alpha:writeBytes(current, "binary/replace", "version-1"),
"seed opaque replacement value")
fs.failTmp = true
ok, code = alpha:writeBytes(current, "binary/replace", "version-2")
fs.failTmp = false
T.check(not ok and code == "write_failed",
"opaque staging failure is reported")
T.eq(alpha:readBytes(current, "binary/replace"), "version-1",
"opaque staging failure leaves the prior value readable")
fs.failMain = true
ok, code = alpha:writeBytes(current, "binary/replace", "version-3")
fs.failMain = false
T.check(not ok and code == "write_failed",
"opaque replacement failure is reported")
T.eq(alpha:readBytes(current, "binary/replace"), "version-1",
"opaque replacement failure leaves the prior value readable")
local byteConflict, byteConflictCode =
alpha:write(current, "binary/replace", { version = 3 })
T.check(not byteConflict and byteConflictCode == "type_conflict",
"tables cannot replace a byte record without deletion")
T.check(alpha:writeBytes(current, "binary/delete", "delete-me"),
"seed opaque delete target")
T.check(alpha:delete(current, "binary/delete") == true,
"delete removes an opaque record")
missing, missingCode = alpha:readBytes(current, "binary/delete")
T.check(missing == nil and missingCode == "not_found",
"deleted opaque key is unavailable")
end
-- Delete is exact and idempotent-not-found is explicit.
T.check(alpha:write(current, "delete/me", { yes = true }), "seed delete target")
T.check(alpha:write(current, "delete/keep", { yes = true }), "seed delete neighbor")
@@ -135,6 +135,17 @@ if type(storage) == "table" then
"title binding supports safe same-namespace durable operations")
T.same(selected:read("history/title-operation"), { allowed = true },
"title durable operation remains scoped to the selected playthrough")
T.check(type(selected.writeBytes) == "function"
and type(selected.readBytes) == "function",
"selected storage exposes opaque byte methods")
if type(selected.writeBytes) == "function"
and type(selected.readBytes) == "function" then
local titleBytes = "TITLE\0\255-cache"
T.check(selected:writeBytes("history/title-bytes", titleBytes) == true,
"title binding writes opaque bytes in the selected namespace")
T.eq(selected:readBytes("history/title-bytes"), titleBytes,
"title binding reads opaque bytes in the selected namespace")
end
end
T.check(title.save.meta.playthroughId == nil,
"opening title history never allocates or adopts a playthrough identity")
@@ -0,0 +1,92 @@
-- A sandboxed mod can defer an ordinary trainer engagement and later resume
-- it with a battle-local player-party scope, using only public mod surfaces.
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local T = require("tests.modkit")
local OW = require("src.world.OverworldController")
local FIXTURE = {
["mods/scope_probe/manifest.json"] = [[{
"id": "scope_probe",
"name": "Scope Probe",
"version": "1.0.0",
"entry": "main.lua",
"api": 2
}]],
["mods/scope_probe/main.lua"] = [[
local mod = ...
mod.hooks:wrap("trainer.before_battle", function(next, game, context, continue)
mod.exports.game = game
mod.exports.context = context
mod.exports.continue = continue
return true
end)
]],
}
local vanilla = T.sdk.loadNone({})
local vanillaCalls, vanillaOptions = 0
OW.prepareTrainerBattle({ id = "game" }, {
trainerClass = "OPP_FIX_YOUNGSTER", partyIndex = 1,
mapId = "FIX_ROUTE", npcId = "TRAINER_1",
}, function(options)
vanillaCalls, vanillaOptions = vanillaCalls + 1, options
end)
T.eq(vanillaCalls, 1, "no mod starts the trainer battle exactly once")
T.eq(vanillaOptions, nil, "no mod supplies no battle-local party scope")
vanilla.release()
local run = T.sdk.loadMods({ "mods/scope_probe" }, {
fs = T.sdk.memfs(FIXTURE),
})
T.eq(#run.errors, 0,
"the public preparation probe loads clean (" .. tostring(run.errors[1]) .. ")")
local game = { id = "live-game" }
local calls, options = 0
OW.prepareTrainerBattle(game, {
trainerClass = "OPP_FIX_YOUNGSTER", partyIndex = 2,
mapId = "FIX_ROUTE", npcId = "TRAINER_7",
}, function(value)
calls, options = calls + 1, value
end)
T.eq(calls, 0, "a claiming public hook defers battle construction")
local out = run.loader.exports.scope_probe or {}
T.check(out.game == game, "the hook receives the live game")
T.same(out.context, {
trainerClass = "OPP_FIX_YOUNGSTER", partyIndex = 2,
mapId = "FIX_ROUTE", npcId = "TRAINER_7",
}, "the hook receives data-only trainer identity context")
T.eq(out.continue({ playerPartyIndices = { 2, 4 } }), true,
"the retained continuation resumes the deferred battle")
T.eq(calls, 1, "resume constructs the battle exactly once")
T.same(options, { playerPartyIndices = { 2, 4 } },
"ordered eligible indices cross the public seam unchanged")
T.eq(out.continue({ playerPartyIndices = { 1 } }), false,
"the continuation refuses a second invocation")
T.eq(calls, 1, "a duplicate resume cannot start a second battle")
run.release()
local cancelRun = T.sdk.loadMods({ "mods/scope_probe" }, {
fs = T.sdk.memfs(FIXTURE),
})
local starts, cancels = 0, 0
OW.prepareTrainerBattle(game, {
trainerClass = "OPP_FIX_YOUNGSTER", partyIndex = 2,
mapId = "FIX_ROUTE", npcId = "TRAINER_7",
}, function()
starts = starts + 1
end, function()
cancels = cancels + 1
end)
local cancelOut = cancelRun.loader.exports.scope_probe or {}
T.eq(cancelOut.continue({ cancel = true }), true,
"the retained continuation can cancel a deferred encounter")
T.eq(starts, 0, "cancelling never constructs a trainer battle")
T.eq(cancels, 1, "cancelling invokes the encounter's completion callback")
T.eq(cancelOut.continue(), false,
"a cancelled continuation remains one-shot")
cancelRun.release()
T.finish("trainer_before_battle")
+173
View File
@@ -0,0 +1,173 @@
-- Contextual field actions share one public contract in both generations
-- while each engine keeps ownership of its own field-item and move paths.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness").suite("mod world field actions")
local facingWater = false
local redCut, redSurf = false, "no_water"
local redMoves = {}
local redWorld = {
isOverworld = true,
map = { id = "ROUTE_1", def = { tileset = "OVERWORLD" } },
player = { moving = false, inputLocked = false, surfing = false },
runner = { isRunning = function() return false end },
scriptMoves = {},
bikeAllowed = function() return true end,
facingIsShoreOrWater = function() return facingWater end,
useCutFieldMove = function() return redCut and "ok" or "nothing" end,
useSurfFieldMove = function() return redSurf end,
partyKnows = function(_, move) return redMoves[move] end,
useBicycle = function(self) self.bikeUsed = true return true end,
useFishingRod = function(self, rod) self.rodUsed = rod return true end,
}
local redGame = {
data = { field = { outsideTilesets = { "OVERWORLD" } },
items = { OLD_ROD = { name = "OLD ROD" } } },
save = { player = { name = "RED" }, party = {},
inventory = { BICYCLE = 1, OLD_ROD = 1 } },
stack = { states = { redWorld } },
overworld = redWorld,
}
function redGame.stack:top() return self.states[#self.states] end
local RedAPI = require("src.world.WorldAPI")
local red = RedAPI.new(redGame, "fixture")
local RedWorld = require("src.world.OverworldController")
T.check(type(RedWorld.useBicycle) == "function"
and type(RedWorld.useFishingRod) == "function"
and type(RedWorld.useFlashFieldMove) == "function"
and type(RedWorld.useStrengthFieldMove) == "function"
and type(RedWorld.stopSurfing) == "function",
"Red keeps field-action execution in its world")
local actions = red:availableFieldActions()
T.eq(actions[1].id, "bicycle", "Red lists an owned usable bicycle")
T.check(red:useFieldAction("bicycle"), "Red accepts the listed bicycle")
T.check(redWorld.bikeUsed, "Red delegates to its world-owned bicycle path")
facingWater = true
actions = red:availableFieldActions()
T.eq(actions[2].rods[1].id, "OLD_ROD", "Red lists owned rods at water")
T.check(red:useFieldAction("fish", { rod = "OLD_ROD" }),
"Red accepts a listed rod")
T.eq(redWorld.rodUsed, "OLD_ROD", "Red delegates to its fishing path")
local used = redWorld.rodUsed
local ok, err = red:useFieldAction("fish", { rod = "SUPER_ROD" })
T.check(not ok and err == "fishing rod unavailable",
"Red rejects an unowned rod")
T.eq(redWorld.rodUsed, used, "a rejected Red rod changes nothing")
redWorld.player.moving = true
T.eq(#red:availableFieldActions(), 0, "Red hides actions while moving")
ok, err = red:useFieldAction("bicycle")
T.check(not ok and err == "world is busy",
"Red refuses a stale action while busy")
redWorld.player.moving = false
facingWater, redCut, redSurf = false, true, "ok"
redWorld.dark = true
for _, move in ipairs({ "STRENGTH", "FLASH", "TELEPORT" }) do
redMoves[move] = { species = "MEW", moves = { { id = move } } }
end
redWorld.player.facingCell = function() return 4, 5 end
redWorld.tryCut = function(self) self.cutUsed = true return true end
redWorld.trySurf = function(self) self.surfUsed = true end
redWorld.useStrengthFieldMove = function(self) self.strengthUsed = true return true end
redWorld.useFlashFieldMove = function(self) self.flashUsed = true return true end
redWorld.beginTeleportOut = function(self) self.teleportUsed = true end
actions = red:availableFieldActions()
local byId = {}
for _, action in ipairs(actions) do byId[action.id] = action end
T.check(byId.cut and byId.surf and byId.strength and byId.flash
and byId.teleport, "Red lists field moves that can start now")
for _, id in ipairs({ "cut", "surf", "strength", "flash", "teleport" }) do
T.check(red:useFieldAction(id), "Red accepts listed " .. id)
end
T.check(redWorld.cutUsed and redWorld.surfUsed and redWorld.strengthUsed
and redWorld.flashUsed and redWorld.teleportUsed,
"Red delegates every move to its overworld path")
redSurf = "dismount"
redWorld.player.surfing = true
redWorld.stopSurfing = function(self) self.dismounted = true end
T.check(red:useFieldAction("surf") and redWorld.dismounted,
"Red delegates the contextual SURF dismount")
redWorld.player.surfing, redSurf = false, "ok"
redCut = false
ok, err = red:useFieldAction("cut")
T.check(not ok and err == "field action unavailable",
"Red revalidates a stale field move")
redWorld.map.id = "ROCK_TUNNEL_1F"
redWorld.map.def.tileset = "CAVERN"
redMoves.DIG = { species = "MEW", moves = { { id = "DIG" } } }
redWorld.beginTeleportOut = function(self) self.digUsed = true end
byId = {}
for _, action in ipairs(red:availableFieldActions()) do byId[action.id] = action end
T.check(byId.dig and not byId.teleport,
"Red distinguishes dungeon DIG from outdoor TELEPORT")
T.check(red:useFieldAction("dig") and redWorld.digUsed,
"Red delegates DIG to its escape path")
local goldWorld = {
map = { id = "ROUTE_29", def = { environment = "ROUTE" } },
player = {}, playerState = "normal",
acceptsMenuInput = function() return true end,
playerCollision = function() return 0x00 end,
alwaysOnBike = function() return false end,
useFieldItem = function(self, item) self.itemUsed = item return "used" end,
squirtbottleTreeScript = function() return { { op = "end" } } end,
useFieldMove = function(self, move)
self.moveUsed = move
return { ok = true }
end,
}
local goldGame = {
data = { items = { OLD_ROD = { name = "OLD ROD" },
SQUIRTBOTTLE = { name = "SQUIRTBOTTLE" } } },
save = { inventory = { BICYCLE = 1, OLD_ROD = 1, SQUIRTBOTTLE = 1 },
player = { badges = { FOG = true } },
party = { { moves = { { id = "SURF" }, { id = "SWEET_SCENT" },
{ id = "TELEPORT" } } } } },
world = goldWorld,
}
goldWorld.fieldContext = function(_, mon) return {
save = goldGame.save, party = goldGame.save.party, mon = mon,
facing = "right", facingColl = 0x29, playerColl = 0,
environment = "ROUTE", playerState = "normal", alwaysOnBike = false,
dark = false, canEscapeRope = false,
} end
local GoldAPI = require("src.world.gen2.WorldAPI")
local gold = GoldAPI.new(goldGame, "fixture")
actions = gold:availableFieldActions()
byId = {}
for _, action in ipairs(actions) do byId[action.id] = action end
T.eq(actions[1].id, "bicycle", "Gold shares the bicycle action id")
T.eq(actions[2].id, "fish", "Gold preserves the original action order")
T.check(byId.surf and byId.sweet_scent and byId.teleport,
"Gold lists field moves through its generic dispatcher")
T.eq(byId.fish.rods[1].id, "OLD_ROD", "Gold shares the rod shape")
T.check(byId.squirtbottle,
"Gold lists the SquirtBottle only at its matching tree")
T.check(gold:useFieldAction("sweet_scent"),
"Gold accepts a listed field move")
T.eq(goldWorld.moveUsed, "SWEET_SCENT",
"Gold delegates moves to its own field-move path")
T.check(gold:useFieldAction("fish", { rod = "OLD_ROD" }),
"Gold accepts the same fishing request")
T.eq(goldWorld.itemUsed, "OLD_ROD",
"Gold delegates to its own field-item path")
used = goldWorld.itemUsed
ok, err = gold:useFieldAction("fish", { rod = "SUPER_ROD" })
T.check(not ok and err == "fishing rod unavailable",
"Gold rejects an unowned rod")
T.eq(goldWorld.itemUsed, used, "a rejected Gold rod changes nothing")
T.check(gold:useFieldAction("squirtbottle"),
"Gold accepts the contextual SquirtBottle")
T.eq(goldWorld.itemUsed, "SQUIRTBOTTLE",
"Gold delegates the SquirtBottle to its field-item path")
T.finish()
+4
View File
@@ -463,6 +463,8 @@ return function(mod)
end
]])
write(bad .. "/hack.gb", "GBDATA")
os.execute((mkdir .. " %q"):format(bad .. "/baseroms"))
write(bad .. "/baseroms/stadium2.z64", "USER ROM")
write(bad .. "/cachepath.lua",
'return { pic = "assets/generated/battle/front/mew.png" }')
@@ -474,6 +476,8 @@ check(out:find("MK101", 1, true) ~= nil, "schema typo reported as MK101")
check(out:find("base_stats", 1, true) ~= nil, "MK101 names the bad field")
check(out:find("MK301", 1, true) ~= nil, "cache reference reported as MK301")
check(out:find("MK303", 1, true) ~= nil, "ROM patch file reported as MK303")
check(out:find("MK307", 1, true) ~= nil,
"a user-supplied baseroms file is refused explicitly")
out, code = run(("%s tools/modkit.py pack %q -o %q --base fixture")
:format(python, bad, root .. "/bad.modpkg"))
@@ -14,6 +14,7 @@ love.system = love.system or {}
local saved = {
getOS = love.system.getOS,
pickFile = love.system.pickFile,
pickFileKinds = love.system.pickFileKinds,
}
local pickCalls = {}
@@ -22,6 +23,7 @@ love.system.pickFile = function(kind)
pickCalls[#pickCalls + 1] = kind or "rom"
return true
end
love.system.pickFileKinds = function() return "rom,mod,sav,required_import" end
local function freshImporter(ready)
return setmetatable({
@@ -94,11 +96,83 @@ eq(ri._imported.source, "picked_save.sav", "focus reads the SAF save filename")
check(love.filesystem.getInfo("picked_save.sav") == nil,
"successful focus import removes picked_save.sav")
-- Required mod files use their own safe picker kind and pending filename.
pickCalls = {}
ri = freshImporter({ red = true, blue = true })
ri.nativePicker = true
ri.mobileFileBridge = true
ri.mods = { {
id = "needs_source",
manifest = { id = "needs_source", name = "Needs Source",
required_imports = { { id = "source", name = "Source", file = "source.bin",
format = "raw", md5 = { "00000000000000000000000000000000" } } } },
} }
ri:chooseRequiredImport("needs_source", "source")
eq(pickCalls[1], "required_import",
"required file asks for the dedicated picker kind")
eq(ri.pickerPendingModId, "needs_source", "pending mod is remembered")
eq(ri.pickerPendingImportId, "source", "pending import is remembered")
-- A rejected selection stays on the imported-files page, where the player can
-- see it before choosing another file, instead of behind the modal.
ri.nativePicker = false
ri._importRequiredData = RomImporter._importRequiredData
local savedData = love.data
love.data = {
hash = function() return "not accepted" end,
encode = function() return "ffffffffffffffffffffffffffffffff" end,
}
ri:_importRequiredData("needs_source", "source", "wrong source bytes")
love.data = savedData
check(ri.requiredImportNotice ~= nil,
"required import rejection creates an in-modal notice")
eq(ri.requiredImportNotice.modId, "needs_source",
"required import notice identifies its mod")
eq(ri.requiredImportNotice.importId, "source",
"required import notice identifies its file")
check(ri.requiredImportNotice.text:find("MD5 mismatch", 1, true) ~= nil,
"required import notice includes the MD5 failure")
check(ri.modNotice == nil,
"required import rejection is not hidden in the general Mods notice")
-- Reported size is checked before the selected file is read into Lua.
local savedGetInfo = love.filesystem.getInfo
love.filesystem.getInfo = function(name, kind)
if name == "oversized_required.bin" then
return { type = "file", size = 10 }
end
return savedGetInfo(name, kind)
end
ri.mods[1].manifest.required_imports[1].max_size = 4
ri._importRequiredSource = RomImporter._importRequiredSource
ri._importRequiredData = function(self) self._oversizedWasRead = true end
ri:_importRequiredSource("needs_source", "source", "oversized_required.bin")
check(not ri._oversizedWasRead, "oversized required file is rejected before import")
check(ri.requiredImportNotice.text:find("too large", 1, true) ~= nil,
"oversized required file reports its size error in the modal")
ri.mods[1].manifest.required_imports[1].max_size = nil
love.filesystem.getInfo = savedGetInfo
ri.nativePicker = true
ri._importRequiredSource = function(self, modId, importId, source)
self._requiredImported = { modId = modId, importId = importId, source = source }
return true
end
love.filesystem.write("picked_required_import.bin", "source bytes")
ri:focus(true)
check(ri._requiredImported ~= nil, "focus consumes a required-file SAF pick")
eq(ri._requiredImported.modId, "needs_source", "focus routes to the pending mod")
eq(ri._requiredImported.importId, "source", "focus routes to the pending declaration")
check(love.filesystem.getInfo("picked_required_import.bin") == nil,
"focus removes the staged required-file pick")
love.system.getOS = saved.getOS
love.system.pickFile = saved.pickFile
love.system.pickFileKinds = saved.pickFileKinds
-- leftover cleanup if a failed assertion left files behind
love.filesystem.remove("usb_mod.zip")
love.filesystem.remove("picked_mod.zip")
love.filesystem.remove("picked_save.sav")
love.filesystem.remove("picked_required_import.bin")
S.finish()
+1
View File
@@ -9,6 +9,7 @@
-- tests/save_editor_task7_tests.lua
-- tests/save_editor_task8_tests.lua
-- tests/save_editor_mod_tests.lua
-- tests/save_editor_gen2_tests.lua
-- See tools/save-editor/README.md for the full list.
--
-- All of them drive tools/save-editor/Ops.lua rather than clicking pixel
+2
View File
@@ -3475,6 +3475,8 @@ do
local lua = (arg and arg[-1]) or "luajit"
local status = os.execute(("%q tests/save_editor_mod_tests.lua"):format(lua))
check(status == 0 or status == true, "save_editor_mod_tests suite")
status = os.execute(("%q tests/save_editor_gen2_tests.lua"):format(lua))
check(status == 0 or status == true, "save_editor_gen2_tests suite")
end
-- ---------------------------------------------- input hold regressions
+377
View File
@@ -0,0 +1,377 @@
-- Headless Gold save-editor rules. Run from repo root:
-- luajit tests/save_editor_gen2_tests.lua
package.path = package.path .. ";./?.lua;./?/init.lua;./tools/save-editor/?.lua"
.. ";./tools/save-editor/panels/?.lua"
local love_stub = require("tests.love_stub")
love = love_stub
local passed, failed = 0, 0
local function check(cond, msg)
if cond then
passed = passed + 1
else
failed = failed + 1
print("FAIL: " .. msg)
end
end
local function eq(a, b, msg)
check(a == b, msg .. string.format(" (got %s, want %s)", tostring(a), tostring(b)))
end
print("== save editor gen2 tests ==")
local Gen = require("Gen")
local Catalog = require("Catalog")
local MonOps = require("MonOps")
local Ops = require("Ops")
local State = require("State")
local Save2 = require("src.core.gen2.Save")
local SaveData = require("src.core.SaveData")
local GameVersion = require("src.core.GameVersion")
local data = {
pokemon = {
CYNDAQUIL = {
id = "CYNDAQUIL", name = "CYNDAQUIL", dex = 155,
types = { "FIRE" },
baseStats = {
hp = 39, attack = 52, defense = 43, speed = 65,
specialAttack = 60, specialDefense = 50,
},
catchRate = 45, baseExp = 65,
growthRate = "MEDIUM_FAST",
levelMoves = { { level = 1, move = "TACKLE" } },
genderRatio = 31,
},
TOTODILE = {
id = "TOTODILE", name = "TOTODILE", dex = 158,
types = { "WATER" },
baseStats = {
hp = 50, attack = 65, defense = 64, speed = 43,
specialAttack = 44, specialDefense = 48,
},
catchRate = 45, baseExp = 66,
growthRate = "MEDIUM_FAST",
levelMoves = { { level = 1, move = "SCRATCH" } },
genderRatio = 31,
},
},
moves = {
TACKLE = { pp = 35 },
SCRATCH = { pp = 35 },
},
items = {
POTION = { pocket = "ITEM" },
MASTER_BALL = { pocket = "BALL" },
FLOWER_MAIL = { pocket = "ITEM" },
},
maps = {},
}
local function newState()
local S = State.new()
S.data = data
S.cat = Catalog.build(data)
S.save = Save2.newGame()
S.version = "gold"
Gen.ensureBoxes(S.save)
return S
end
do
GameVersion.set("gold")
eq(Gen.of({ generation = 2 }), 2, "Gen.of generation field")
eq(Gen.of({ version = "gold" }), 2, "Gen.of version gold")
eq(Gen.of(SaveData.newGame()), 1, "Gen.of gen1 newGame")
eq(Gen.of(Save2.newGame()), 2, "Gen.of gold newGame")
end
do
local S = newState()
check(Ops.speciesUsable(S, "CYNDAQUIL"), "spa/spd species is usable")
local S1 = State.new()
S1.data = {
pokemon = {
PIDGEY = { baseStats = { hp = 40, attack = 45, defense = 40, speed = 56, special = 35 } },
BROKEN = { baseStats = { hp = 1 } },
},
}
check(Ops.speciesUsable(S1, "PIDGEY"), "gen1 special species is usable")
check(not Ops.speciesUsable(S1, "BROKEN"), "partial record is not usable")
end
do
local S = newState()
Ops.partyAdd(S)
eq(#S.save.party, 1, "partyAdd on gold")
local mon = S.save.party[1]
eq(mon.species, "CYNDAQUIL", "first catalog species")
check(mon.experience ~= nil, "gold mon has experience")
check(mon.exp == nil or mon.experience ~= nil, "does not rely on gen1 exp")
check(mon.stats.specialAttack and mon.stats.specialDefense,
"gold stats have spa/spd")
check(mon.happiness ~= nil, "gold mon has happiness")
eq(mon.ot, S.save.player.name, "stampOT copies player name")
Ops.setLevel(S, mon, 20)
eq(mon.level, 20, "setLevel 20")
check(mon.experience > 0, "experience resynced")
Ops.setHappiness(S, mon, 200)
eq(mon.happiness, 200, "happiness 200")
Ops.setPokerus(S, mon, 15)
eq(mon.pokerus, 15, "pokerus byte")
Ops.setHeldItem(S, mon, "POTION")
eq(mon.item, "POTION", "held item")
eq(mon.name, "CYNDAQUIL", "new mon copies species display name")
Ops.setSpecies(S, mon, "TOTODILE")
eq(mon.species, "TOTODILE", "setSpecies id")
eq(mon.name, "TOTODILE", "setSpecies rewrites the Gold display name")
check(mon.nickname == nil, "setSpecies does not invent a nickname")
eq(mon.types[1], "WATER", "setSpecies rewrites copied types")
end
do
local S = newState()
Ops.partyAdd(S)
Ops.partyAdd(S)
local Mail = require("src.core.gen2.Mail")
Mail.set(S.save, 1, Mail.entry("FLOWER_MAIL", "hi", "GOLD", 1, "CYNDAQUIL"))
Mail.set(S.save, 2, Mail.entry("SURF_MAIL", "bye", "GOLD", 1, "CYNDAQUIL"))
S.selectedParty = 1
Ops.partyMove(S, 1)
eq(Mail.state(S.save).party[1].message, "bye", "partyMove carries mail with the mon")
eq(Mail.state(S.save).party[2].message, "hi", "partyMove swaps the other letter")
S.selectedParty = 1
check(Ops.partyRemove(S) == false, "partyRemove arms")
check(Ops.partyRemove(S) == true, "partyRemove commits")
eq(Mail.state(S.save).party[1].message, "hi", "partyRemove shifts leftover mail up")
check(Mail.state(S.save).party[2] == nil, "partyRemove clears the vacated slot")
end
do
local S = newState()
Ops.partyAdd(S)
local mon = S.save.party[1]
local Mail = require("src.core.gen2.Mail")
Ops.setHeldItem(S, mon, "FLOWER_MAIL")
eq(mon.item, "FLOWER_MAIL", "held mail item")
local letter = Mail.state(S.save).party[1]
check(letter ~= nil, "giving mail writes sPartyMail")
eq(letter.species, "CYNDAQUIL", "new letter stamps current species")
Ops.setSpecies(S, mon, "TOTODILE")
eq(Mail.state(S.save).party[1].species, "TOTODILE",
"setSpecies updates the letter's species copy")
Ops.setHeldItem(S, mon, "POTION")
check(Mail.state(S.save).party[1] == nil, "non-mail held item drops the letter")
end
do
local Mon = require("src.battle.gen2.Mon")
local S = newState()
local stale = Mon.new(data, "CYNDAQUIL", 5)
stale.species = "TOTODILE"
stale.name = "CYNDAQUIL"
stale.types = { "FIRE" }
S.save.dayCare = { man = { mon = stale }, lady = {} }
Mon.syncSaveIdentity(S.save, data)
eq(stale.name, "TOTODILE", "syncSaveIdentity rewrites Day-Care display name")
eq(stale.types[1], "WATER", "syncSaveIdentity rewrites Day-Care types")
eq(Mon.displayName({ nickname = nil, name = "ABRA", species = "RAYQUAZA" }),
"ABRA", "displayName prefers the species copy over the id")
eq(Mon.displayName({ nickname = "BOB", name = "ABRA", species = "RAYQUAZA" }),
"BOB", "displayName prefers nickname")
end
do
local S = newState()
eq(Ops.boxCount(S), 14, "14 gold boxes")
eq(Ops.boxCapacity(S), 20, "20 per box")
Ops.boxAdd(S)
eq(#Ops.boxes(S)[1], 1, "boxAdd into box 1")
end
do
local S = newState()
Ops.partyAdd(S)
S.save.party[1].hp = 0
Ops.partyAdd(S)
S.selectedParty = 1
S.selectedBox = 1
local ok = Ops.deposit(S)
check(ok, "deposit fainted mon while a healthy remains")
end
do
local S = newState()
Ops.partyAdd(S)
Ops.partyAdd(S)
S.selectedParty = 1
S.selectedBox = 1
local ok = Ops.deposit(S)
check(ok, "deposit one of two healthy mons")
end
do
local S = newState()
Ops.partyAdd(S)
S.selectedParty = 1
S.selectedBox = 1
local ok = Ops.deposit(S)
check(not ok, "refuse depositing last healthy mon")
check(S.status:lower():find("last", 1, true) or S.status:find("POKéMON")
or S.status:find("POKEMON") or S.status:find("last"),
"deposit refusal names the last-healthy rule: " .. tostring(S.status))
eq(#S.save.party, 1, "party still has the mon")
end
do
local S = newState()
eq(Gen.money(S.save), 3000, "gold start money on player")
Ops.addMoney(S, 1000)
eq(S.save.player.money, 4000, "money writes player.money")
check(S.save.money == nil or S.save.money ~= 4000, "does not write save.money")
Ops.maxMoney(S)
eq(S.save.player.money, 999999, "money cap")
Ops.addCoins(S, 250)
eq(S.save.player.coins, 250, "coins write player.coins")
end
do
local S = newState()
check(not Gen.hasBadge(S.save, "ZEPHYR"), "no zephyr yet")
Ops.toggleBadge(S, "ZEPHYR")
check(Gen.hasBadge(S.save, "ZEPHYR"), "zephyr earned")
check(S.save.player.badges.ZEPHYR, "stored on player.badges")
Ops.toggleBadge(S, "BOULDER")
check(S.save.player.kantoBadges.BOULDER, "kanto badge store")
end
do
local S = newState()
Ops.dexOwned(S, "CYNDAQUIL", true)
check(S.save.pokedex.caught.CYNDAQUIL, "dex writes caught")
check(S.save.pokedex.owned == nil or S.save.pokedex.owned.CYNDAQUIL == nil,
"does not write owned on gold")
check(S.save.pokedex.seen.CYNDAQUIL, "owned implies seen")
local _, owned = Ops.dexCounts(S)
eq(owned, 1, "dexCounts reads caught")
end
do
local S = newState()
local name = "EVENT_BEAT_FALKNER"
Ops.setFlag(S, name, true)
check(Gen.getFlag(S.save, name), "gold EVENT_ sets bitfield")
check(S.save.flags[name] == nil, "numeric flags are not string keys")
Ops.setFlag(S, "MOD_EDITMON_GIFT", true)
check(S.save.flags.MOD_EDITMON_GIFT, "mod flags stay named on gold")
end
do
local S = newState()
S.mapId = "NEW_BARK_TOWN"
S.mapClickCell = { cx = 4, cy = 5 }
Ops.setPlayerHere(S)
eq(S.save.position.map, "NEW_BARK_TOWN", "position.map")
eq(S.save.position.x, 4, "position.x")
eq(S.save.position.y, 5, "position.y")
check(S.save.player.map == nil or S.save.player.map ~= "NEW_BARK_TOWN",
"does not write player.map on gold")
end
do
local encoded = SaveData.encode(Save2.newGame())
local back = SaveData.decode(encoded)
eq(back.generation, 2, "round-trip keeps generation 2")
end
do
local names = Catalog.goldEventList()
local hasFalkner = false
for _, n in ipairs(names) do
if n == "EVENT_BEAT_FALKNER" then hasFalkner = true break end
end
check(hasFalkner, "gold event list includes EVENT_BEAT_FALKNER")
end
do
local maps = Gen.maps({ gen2Maps = { AZALEA_GYM = true }, maps = { PALLET_TOWN = true } })
check(maps.AZALEA_GYM, "Gen.maps includes gen2Maps")
check(maps.PALLET_TOWN, "Gen.maps keeps Data:load maps beside gen2Maps")
check(Gen.maps({ maps = { PALLET_TOWN = true } }).PALLET_TOWN,
"Gen.maps falls back to maps")
local mansion = Gen.maps({
maps = {
CELADON_MANSION_2F = { id = "CELADON_MANSION_2F", width = 4, height = 5 },
},
gen2Maps = {
CELADON_MANSION_2F = { objects = { { name = "NPC" } } },
BERRY_FARM = { id = "BERRY_FARM", width = 19, height = 12 },
},
})
eq(mansion.CELADON_MANSION_2F.width, 4,
"Gen.maps keeps extractor width under a gen2Maps objects patch")
eq(mansion.CELADON_MANSION_2F.objects[1].name, "NPC",
"Gen.maps still applies the gen2Maps patch fields")
eq(mansion.BERRY_FARM.width, 19, "Gen.maps keeps mod maps only on gen2Maps")
local bound = Gen.bindGoldData({ maps = { A = true }, tilesets = { T = true } })
check(bound.gen2Maps == bound.maps, "bindGoldData aliases gen2Maps")
check(bound.gen2Tilesets == bound.tilesets, "bindGoldData aliases gen2Tilesets")
check(Gen.tilesets({ gen2Tilesets = { TILESET_GYM = true } }).TILESET_GYM,
"Gen.tilesets prefers gen2Tilesets")
end
do
local Map2 = require("src.world.gen2.Map")
local MapPreview = require("src.world.gen2.MapPreview")
local def = {
id = "AZALEA_GYM", tileset = "TILESET_GYM",
width = 1, height = 1, blocks = { 1 }, borderBlock = 1,
warps = {}, environment = "INDOOR",
}
local tileset = {
id = "TILESET_GYM",
image = "assets/generated/tilesets/gym.png",
tilesPerRow = 16,
blocks = { { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } },
}
local map = Map2.new(def, tileset)
check(map.renderer == nil, "Map2 does not ship a renderer")
local baker = MapPreview.baker({ tilesets = { TILESET_GYM = tileset } })
local renderer = MapPreview.renderer(baker, map)
check(renderer ~= nil and renderer.draw ~= nil,
"MapPreview attaches a draw for a Gold map")
end
do
local memfs = {
files = {
["saves/gold/slot1.lua"] = 'return { version = "gold", generation = 2, player = { name = "GOLD" } }',
["options.lua"] = 'return { textSpeed = 3 }',
},
getInfo = function(self, path)
return self.files[path] and { type = "file" } or nil
end,
read = function(self, path)
return self.files[path]
end,
write = function(self, path, data)
self.files[path] = data
return true
end,
remove = function(self, path)
self.files[path] = nil
return true
end,
}
local main, _, _ = SaveData.saveFilename("gold")
check(main ~= nil, "saveFilename resolves for gold")
end
print(string.format("save editor gen2 tests: %d passed, %d failed", passed, failed))
if failed > 0 then os.exit(1) end
+36 -1
View File
@@ -142,6 +142,41 @@ local ok, err = pcall(function()
local report = SaveData.validate(probe, Data)
check(#report.lostMons == 0 and probe.party[1].species == "EDITMON",
"validate keeps the modded mon while the mod is enabled")
-- Gold-targeted mods: spa/spd records are usable, and a Gen 1-only
-- manifest stays out of Gold (no ROM cache / App.load("gold") required).
local ModTargets = require("src.mods.ModTargets")
local Ops = require("Ops")
check(not ModTargets.supports({}, "gold"),
"legacy gen1-only fixture does not support gold")
check(not ModTargets.supports({ games = { "red" } }, "gold"),
"explicit gen1 games list does not support gold")
check(ModTargets.supports({ games = { "gold" } }, "gold"),
"gold-targeted manifest supports gold")
check(ModTargets.supports({ gen2compat = true }, "gold"),
"gen2compat legacy still supports gold")
local goldS = {
data = {
pokemon = {
EDITMON = {
baseStats = {
hp = 50, attack = 50, defense = 50, speed = 50,
specialAttack = 50, specialDefense = 50,
},
},
G1ONLY = {
baseStats = {
hp = 50, attack = 50, defense = 50, speed = 50, special = 50,
},
},
},
},
}
check(Ops.speciesUsable(goldS, "EDITMON"),
"spa/spd EDITMON is usable on gold")
check(Ops.speciesUsable(goldS, "G1ONLY"),
"gen1 special record remains usable (dual-key gate)")
end)
os.remove(MOD_ROOT .. "/main.lua")
@@ -154,7 +189,7 @@ os.remove(tmpPath)
love.filesystem = savedFS
-- leave shared singletons the way we found them (the fixture merged one
-- record into Data.pokemon)
Data.pokemon.EDITMON = nil
if Data.pokemon then Data.pokemon.EDITMON = nil end
Assets.loader = savedBridge
Assets.invalidate()
Runtime.install(savedEvents, savedHooks, savedErrors)
+1 -1
View File
@@ -12,7 +12,7 @@ package.path = "./?.lua;./?/init.lua;" .. package.path
local Flags = dofile("tools/goldwalk/flags.lua")
local ROOT = arg[1] or "../pokegold"
local OUT = "tests/drivers/gold/flag_names.lua"
local OUT = "src/core/gen2/FlagNames.lua"
local events, _ = Flags.parse(ROOT .. "/constants/event_flags.asm")
local engine, _ = Flags.parse(ROOT .. "/constants/engine_flags.asm")
+8
View File
@@ -1045,6 +1045,14 @@ def lint_dir(repo, mod_dir, manifest):
for rel in mod_files(mod_dir):
path = os.path.join(mod_dir, rel)
ext = os.path.splitext(rel)[1].lower()
# MK307: required_imports are user-owned installation state. Keeping
# baseroms in the walk without an explicit gate would let `pack`
# silently bundle exactly the ROM this feature exists not to ship.
if rel.startswith("baseroms/"):
findings.append(Finding(
"MK307", "error",
"user-supplied baseroms must not be distributed", rel))
continue
# MK301: nothing may live in (or point into) the generated trees
if rel.startswith(("data/generated/", "assets/generated/")):
findings.append(Finding(
+49 -42
View File
@@ -25,6 +25,7 @@ local State = require("State")
local Kit = require("Kit")
local Theme = require("Theme")
local Ops = require("Ops")
local Gen = require("Gen")
local PadInput = require("PadInput")
local PAL = Theme.PAL
@@ -87,56 +88,43 @@ local function applyLoaded(path, statusVerb)
if save then
S.save = save
S.status = statusVerb .. " " .. path
S.mapId = save.player.map
S.loadError = false
S.allowSave = true
elseif existed then
-- File is present but SaveIO.load couldn't decode it: treat it as a
-- real (corrupt) save, not a missing one. Editing a stub here is fine,
-- but Save must stay disabled so we never clobber the corrupt file
-- until the user fixes it and Reload succeeds.
S.save = require("src.core.SaveData").newGame()
S.save = Gen.newGame(S.version)
S.status = "Corrupt save at " .. path .. " (" .. tostring(err) ..
"), Save disabled, use Reload after fixing the file"
S.mapId = S.save.player.map
S.loadError = true
S.allowSave = false
else
S.save = require("src.core.SaveData").newGame()
S.save = Gen.newGame(S.version)
S.status = "No save at " .. path .. " (" .. tostring(err) ..
"), editing new game stub"
S.mapId = S.save.player.map
S.loadError = false
S.allowSave = true
end
local mapId = Gen.playerMap(S.save)
S.mapId = mapId
S.dirty = false
S._quitArmed = false
S._openArmed = false
S.editingMon = nil
Ops.disarm(S)
local boxes = require("src.pokemon.Boxes").ensure(S.save)
-- Imported .sav box mons have no stat block (box_struct stops before
-- MON_STATS). The game derives them in SaveData.validate; the editor
-- only validates a copy, so hydrate here for Boxes/Party/MonEditor.
local Stats = require("src.pokemon.Stats")
local function ensureStats(mon)
Stats.ensure(Data.pokemon and Data.pokemon[mon.species], mon)
end
for _, mon in ipairs(S.save.party or {}) do ensureStats(mon) end
for _, box in ipairs(boxes) do
for _, mon in ipairs(box) do ensureStats(mon) end
end
if S.save.daycare and S.save.daycare.mon then
ensureStats(S.save.daycare.mon)
end
-- what the running game would quarantine, computed on a copy so the
-- editor never mutates the file behind the user's back
local SaveData = require("src.core.SaveData")
Gen.ensureBoxes(S.save)
Gen.hydrateSave(Data, S.save)
local probe = require("src.mods.Merge").deepCopy(S.save)
S.validation = SaveData.validate(probe, Data)
if not SaveData.emptyReport(S.validation) then
S.status = S.status .. string.format(", game would quarantine: %d mons, %d items, %d maps",
#S.validation.lostMons, #S.validation.lostItems, #S.validation.remappedMaps)
S.validation = Gen.validate(probe, Data)
if not Gen.emptyReport(S.save, S.validation) then
if Gen.of(S.save, S.version) == 2 then
S.status = S.status .. string.format(
", game would quarantine: %d script bytes, %d mail, %d events",
#(S.validation.lostScriptMem or {}),
#(S.validation.lostMail or {}),
#(S.validation.lostEvents or {}))
else
S.status = S.status .. string.format(", game would quarantine: %d mons, %d items, %d maps",
#S.validation.lostMons, #S.validation.lostItems, #S.validation.remappedMaps)
end
end
end
@@ -152,6 +140,9 @@ function App.load(pathOverride, opts)
S.slotId = opts.slotId
S.embedded = opts.embedded or false
S.onClose = opts.onClose
if opts.version then
require("src.core.GameVersion").set(opts.version)
end
-- the same mod set the game loads, merged into Data before the catalogs
-- build, so modded species/items/moves are editable and MonOps stops
-- asserting on them
@@ -163,6 +154,10 @@ function App.load(pathOverride, opts)
-- loaded at least once, so it doubles as the "needs evicting" marker.
if Data._pristineKeys then Data:unloadGenerated() end
Data:load()
if Gen.of(nil, opts.version) == 2
or require("src.core.GameVersion").generation() == 2 then
Gen.bindGoldData(Data)
end
local ModLoader = require("src.mods.Loader")
mods = ModLoader.new()
mods:load(Data)
@@ -174,9 +169,16 @@ function App.load(pathOverride, opts)
for _, mod in ipairs(S.mods:status().loaded) do
modRoots[#modRoots + 1] = mod.path
end
S.events = Catalog.scrapeEvents("data/scripts", "data/generated/trainer_headers.lua",
nil, modRoots)
if Gen.of(nil, opts.version) == 2 or require("src.core.GameVersion").generation() == 2 then
S.events = Catalog.goldEventList(modRoots)
else
S.events = Catalog.scrapeEvents("data/scripts", "data/generated/trainer_headers.lua",
nil, modRoots)
end
applyLoaded(pathOverride or SaveIO.defaultPath(), "Loaded")
if Gen.of(S.save, S.version) == 2 then
S.events = Catalog.goldEventList(modRoots)
end
end
-- Switch to another save file (Open button, drag-drop, or --save arg).
@@ -582,11 +584,9 @@ local function tabCount(id)
return tostring(n)
elseif id == "items" then
local Bag = require("src.inventory.Bag")
return ("%d/%d"):format(Bag.slots(S.save), Bag.capacity(S.data))
return ("%d/%d"):format(Bag.slots(S.save, S.data), Bag.capacity(S.data))
elseif id == "events" then
local n = 0
for _ in pairs(S.save.flags or {}) do n = n + 1 end
return tostring(n)
return tostring(Gen.flagCount(S.save))
elseif id == "map" then
-- map ids run long (REDS_HOUSE_2F); the rail is a summary, not a label
return Kit.ellipsize("tiny", S.mapId or "", 110 * Kit.scale)
@@ -602,21 +602,28 @@ end
-- something. Returns what to draw plus the tab that owns the first problem,
-- so the rail can reserve the pill's width before laying out the tiles.
local function validationPill()
local SaveData = require("src.core.SaveData")
local report = S.validation
if not report or SaveData.emptyReport(report) then
if not report or Gen.emptyReport(S.save, report) then
return "Save validates clean", PAL.green, nil, true
end
local parts = {}
local target
local function add(n, singular, plural, tab)
n = n or 0
if n <= 0 then return end
parts[#parts + 1] = ("%d %s"):format(n, n == 1 and singular or plural)
target = target or tab
end
add(#report.lostMons, "mon", "mons", "party")
add(#report.lostItems, "item", "items", "items")
add(#report.remappedMaps, "map", "maps", "map")
if Gen.of(S.save, S.version) == 2 then
add(#(report.lostScriptMem or {}), "script byte", "script bytes", "events")
add(#(report.lostMail or {}), "mail", "mail", "party")
add(#(report.lostEvents or {}), "event", "events", "events")
add(#(report.lostMapScenes or {}), "map scene", "map scenes", "map")
else
add(#(report.lostMons or {}), "mon", "mons", "party")
add(#(report.lostItems or {}), "item", "items", "items")
add(#(report.remappedMaps or {}), "map", "maps", "map")
end
return "Would quarantine " .. table.concat(parts, ", "), PAL.yellow, target, false
end
+23 -1
View File
@@ -91,7 +91,8 @@ function Catalog.scrapeEvents(scriptDir, headerPath, listFiles, extraDirs)
end
end
local dirs = { scriptDir }
local dirs = {}
if scriptDir then dirs[#dirs + 1] = scriptDir end
for _, dir in ipairs(extraDirs or {}) do
dirs[#dirs + 1] = dir
end
@@ -110,4 +111,25 @@ function Catalog.scrapeEvents(scriptDir, headerPath, listFiles, extraDirs)
return sortedKeys(found)
end
function Catalog.goldEventList(extraDirs)
local names = {}
local ok, flags = pcall(require, "src.core.gen2.FlagNames")
if ok and flags and flags.events then
for name in pairs(flags.events) do
names[#names + 1] = name
end
end
table.sort(names)
local modFlags = Catalog.scrapeEvents(nil, nil, nil, extraDirs)
local seen = {}
for _, name in ipairs(names) do seen[name] = true end
for _, name in ipairs(modFlags) do
if not seen[name] then
names[#names + 1] = name
seen[name] = true
end
end
return names
end
return Catalog
+350
View File
@@ -0,0 +1,350 @@
-- Generation adapter for the save editor. Panels stay generation-blind;
-- Ops and App read Gold vs RBY through this module so a Gold write never
-- lands in Gen 1 fields (save.money, pokedex.owned, 12 boxes, ...).
local GameVersion = require("src.core.GameVersion")
local Gen = {}
local function versionGeneration(version)
if type(version) ~= "string" then return nil end
local info = GameVersion.info(version)
if info then return info.generation or 1 end
return nil
end
function Gen.of(save, version)
if type(save) == "table" then
if save.generation == 2 then return 2 end
local fromVersion = versionGeneration(save.version)
if fromVersion then return fromVersion end
end
local fromArg = versionGeneration(version)
if fromArg then return fromArg end
return GameVersion.generation()
end
function Gen.ofState(S)
if not S then return GameVersion.generation() end
return Gen.of(S.save, S.version)
end
function Gen.is2(save, version)
return Gen.of(save, version) == 2
end
-- Data:load writes Gold maps/tilesets to the Gen 1 keys; Game2 and the mod
-- merge write gen2Maps / gen2Tilesets. Overlay the gen2 table on the loaded
-- cache so a mod patch that landed on an empty gen2Maps (objects only, no
-- width) does not hide the extractor's record, and a new map like BERRY_FARM
-- still appears.
local function overlayRecords(base, overlay)
if not overlay then return base or {} end
if not base or base == overlay then return overlay end
local out = {}
for id, def in pairs(base) do out[id] = def end
for id, def in pairs(overlay) do
local prior = out[id]
if type(def) == "table" and type(prior) == "table" then
local merged = {}
for k, v in pairs(prior) do merged[k] = v end
for k, v in pairs(def) do merged[k] = v end
out[id] = merged
else
out[id] = def
end
end
return out
end
function Gen.maps(data)
if type(data) ~= "table" then return {} end
return overlayRecords(data.maps, data.gen2Maps)
end
function Gen.tilesets(data)
if type(data) ~= "table" then return {} end
return overlayRecords(data.tilesets, data.gen2Tilesets)
end
-- Point the Gen 2 Data keys at the tables Data:load already filled, before
-- mods:load folds into gen2Maps. Same wiring Game2 does when it boots Gold.
function Gen.bindGoldData(data)
if type(data) ~= "table" then return data end
if data.maps and data.gen2Maps == nil then data.gen2Maps = data.maps end
if data.tilesets and data.gen2Tilesets == nil then
data.gen2Tilesets = data.tilesets
end
if data.palettes and data.gen2Palettes == nil then
data.gen2Palettes = data.palettes
end
if data.gen2Roofs == nil and data.roofs == nil then
local ok, roofs = pcall(require, "data.generated.roofs")
if ok and type(roofs) == "table" then
data.roofs = roofs
data.gen2Roofs = roofs
end
elseif data.roofs and data.gen2Roofs == nil then
data.gen2Roofs = data.roofs
end
return data
end
function Gen.newGame(version)
if (versionGeneration(version) or GameVersion.generation(version)) == 2 then
return require("src.core.gen2.Save").newGame()
end
return require("src.core.SaveData").newGame()
end
function Gen.validate(save, data)
if Gen.of(save) == 2 then
return require("src.core.gen2.Save").validate(save)
end
return require("src.core.SaveData").validate(save, data)
end
function Gen.emptyReport(save, report)
if Gen.of(save) == 2 then
return require("src.core.gen2.Save").emptyReport(report)
end
return require("src.core.SaveData").emptyReport(report)
end
function Gen.hydrateMon(data, mon)
if type(mon) ~= "table" then return mon end
local def = data and data.pokemon and data.pokemon[mon.species]
local gen2 = (mon.stats and mon.stats.specialAttack)
or (def and def.baseStats and def.baseStats.specialAttack)
or mon.experience ~= nil
if gen2 then
require("src.battle.gen2.Mon").refreshStats(mon, data)
else
require("src.pokemon.Stats").ensure(def, mon)
end
return mon
end
-- Party, boxes, Day-Care. Gold's dayCare.man/lady/egg are not save.daycare.
function Gen.hydrateSave(data, save)
if type(save) ~= "table" then return save end
if Gen.of(save) == 2 then
local Mon = require("src.battle.gen2.Mon")
Mon.eachSaveMon(save, function(mon) Mon.refreshStats(mon, data) end)
return save
end
for _, mon in ipairs(save.party or {}) do Gen.hydrateMon(data, mon) end
for _, box in ipairs(save.boxes or {}) do
if type(box) == "table" then
for _, mon in ipairs(box) do Gen.hydrateMon(data, mon) end
end
end
if save.daycare and save.daycare.mon then
Gen.hydrateMon(data, save.daycare.mon)
end
return save
end
function Gen.ensureBoxes(save)
if Gen.of(save) == 2 then
local Boxes2 = require("src.core.gen2.Boxes")
save.boxes = save.boxes or {}
for i = 1, Boxes2.NUM_BOXES do
save.boxes[i] = save.boxes[i] or {}
end
save.currentBox = math.max(1, math.min(Boxes2.NUM_BOXES, save.currentBox or 1))
return save.boxes
end
return require("src.pokemon.Boxes").ensure(save)
end
function Gen.boxCount(save)
if Gen.of(save) == 2 then
return require("src.core.gen2.Boxes").NUM_BOXES
end
return require("src.pokemon.Boxes").COUNT
end
function Gen.boxCapacity(save)
if Gen.of(save) == 2 then
return require("src.core.gen2.Boxes").MONS_PER_BOX
end
return require("src.pokemon.Boxes").CAPACITY
end
function Gen.money(save)
if Gen.of(save) == 2 then
return (save.player and save.player.money) or 0
end
return save.money or 0
end
function Gen.setMoney(save, amount)
if Gen.of(save) == 2 then
save.player = save.player or {}
save.player.money = amount
else
save.money = amount
end
end
function Gen.coins(save)
if Gen.of(save) == 2 then
return (save.player and save.player.coins) or 0
end
return save.coins or 0
end
function Gen.setCoins(save, amount)
if Gen.of(save) == 2 then
save.player = save.player or {}
save.player.coins = amount
else
save.coins = amount
end
end
function Gen.dexOwnedKey(save)
if Gen.of(save) == 2 then return "caught" end
return "owned"
end
function Gen.playerMap(save)
if Gen.of(save) == 2 then
local p = save.position
if p and p.map then return p.map, p.x or 0, p.y or 0, p.facing end
return save.spawn, 0, 0
end
local p = save.player or {}
return p.map, p.x or 0, p.y or 0
end
function Gen.setPlayerHere(save, mapId, x, y, facing)
if Gen.of(save) == 2 then
local prev = save.position or {}
save.position = {
map = mapId,
x = x,
y = y,
facing = facing or prev.facing or "down",
}
return
end
save.player = save.player or {}
save.player.map = mapId
save.player.x = x
save.player.y = y
end
local JOHTO = {
"ZEPHYR", "HIVE", "PLAIN", "FOG", "MINERAL", "STORM", "GLACIER", "RISING",
}
local KANTO = {
"BOULDER", "CASCADE", "THUNDER", "RAINBOW",
"SOUL", "MARSH", "VOLCANO", "EARTH",
}
function Gen.badgeIds(save, cat)
if Gen.of(save) == 2 then
local ids = {}
for _, name in ipairs(JOHTO) do ids[#ids + 1] = name end
for _, name in ipairs(KANTO) do ids[#ids + 1] = name end
return ids
end
local ids = {}
for _, id in ipairs((cat and cat.items) or {}) do
if tostring(id):find("BADGE", 1, true) then ids[#ids + 1] = id end
end
return ids
end
local KANTO_SET = {}
for _, name in ipairs(KANTO) do KANTO_SET[name] = true end
function Gen.hasBadge(save, id)
if Gen.of(save) == 2 then
local p = save.player or {}
local store = KANTO_SET[id] and (p.kantoBadges or {}) or (p.badges or {})
if store[id] then return true end
local list = KANTO_SET[id] and KANTO or JOHTO
for index, name in ipairs(list) do
if name == id then return store[index] == true end
end
return false
end
return save.inventory and save.inventory[id] and true or false
end
function Gen.toggleBadge(save, id)
if Gen.of(save) == 2 then
save.player = save.player or {}
local storeName = KANTO_SET[id] and "kantoBadges" or "badges"
save.player[storeName] = save.player[storeName] or {}
local store = save.player[storeName]
local on = Gen.hasBadge(save, id)
store[id] = (not on) and true or nil
for index, name in ipairs(KANTO_SET[id] and KANTO or JOHTO) do
if name == id then store[index] = nil end
end
return not on
end
local on = save.inventory[id] and true or false
save.inventory[id] = (not on) and 1 or nil
return not on
end
local function goldFlagId(name)
local flags = require("src.core.gen2.FlagNames")
return flags.events and flags.events[name]
end
function Gen.getFlag(save, name)
if Gen.of(save) == 2 then
local id = goldFlagId(name)
if id then
local Events2 = require("src.world.gen2.Events")
local ev = Events2.new()
ev:restore(save.events)
return ev:get(id)
end
return save.flags and save.flags[name] == true
end
return save.flags and save.flags[name] == true
end
function Gen.setFlag(save, name, on)
if Gen.of(save) == 2 then
local id = goldFlagId(name)
if id then
local Events2 = require("src.world.gen2.Events")
local ev = Events2.new()
ev:restore(save.events)
ev:set(id, on and true or false)
save.events = ev:serialize()
return
end
save.flags = save.flags or {}
save.flags[name] = on and true or nil
return
end
save.flags = save.flags or {}
save.flags[name] = on and true or nil
end
function Gen.flagCount(save)
if Gen.of(save) == 2 then
local n = 0
for _ in pairs(save.events or {}) do n = n + 1 end
for _ in pairs(save.flags or {}) do n = n + 1 end
return n
end
local n = 0
for _ in pairs(save.flags or {}) do n = n + 1 end
return n
end
function Gen.exp(mon)
return mon.experience or mon.exp or 0
end
return Gen
+45 -8
View File
@@ -4,23 +4,40 @@ local Growth = require("src.pokemon.Growth")
local MonOps = {}
function MonOps.create(data, species, level)
function MonOps.create(data, species, level, gen)
if gen == 2 then
local Mon = require("src.battle.gen2.Mon")
local mon = Mon.new(data, species, level)
assert(mon, "unknown species " .. tostring(species))
return mon
end
return Pokemon.new(data, species, level)
end
function MonOps.recalc(data, mon)
function MonOps.recalc(data, mon, gen)
if gen == 2 or (mon.stats and mon.stats.specialAttack) or mon.experience then
require("src.battle.gen2.Mon").refreshStats(mon, data)
return
end
local def = data.pokemon[mon.species]
assert(def, "unknown species")
mon.stats = Stats.calc(def, mon.level, mon.dvs, mon.statExp)
mon.hp = math.max(0, math.min(mon.hp or mon.stats.hp, mon.stats.hp))
end
function MonOps.setLevel(data, mon, level)
function MonOps.setLevel(data, mon, level, gen)
level = math.max(1, math.min(100, math.floor(level)))
local def = data.pokemon[mon.species]
mon.level = level
if gen == 2 or mon.experience ~= nil then
local Mon = require("src.battle.gen2.Mon")
local growth = Mon.growthFor(data, def.growthRate)
mon.experience = Mon.experienceForLevel(growth, level)
Mon.refreshStats(mon, data)
return
end
mon.exp = Growth.expForLevel(def.growthRate, level)
MonOps.recalc(data, mon)
MonOps.recalc(data, mon, gen)
end
function MonOps.setMove(data, mon, slot, moveId)
@@ -32,6 +49,7 @@ function MonOps.setMove(data, mon, slot, moveId)
id = moveId,
pp = mdef.pp + ((mon.moves[slot] and mon.moves[slot].ppUps) or 0) * math.floor(mdef.pp / 5),
ppUps = mon.moves[slot] and mon.moves[slot].ppUps or nil,
maxPp = mdef.pp,
}
end
@@ -42,19 +60,38 @@ function MonOps.syncHpDv(dvs)
return dvs
end
function MonOps.setDv(data, mon, key, value)
function MonOps.setDv(data, mon, key, value, gen)
mon.dvs[key] = math.max(0, math.min(15, math.floor(value)))
if key ~= "hp" then
MonOps.syncHpDv(mon.dvs)
end
MonOps.recalc(data, mon)
if gen == 2 or (mon.stats and mon.stats.specialAttack) then
local Mon = require("src.battle.gen2.Mon")
mon.dvs.hp = Mon.hpDV(mon.dvs)
local def = data.pokemon[mon.species]
if def 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 })
local Unown = require("src.core.gen2.Unown")
if mon.species == Unown.SPECIES then
mon.unownLetter = Unown.letterFromDVs(mon.dvs)
end
end
end
MonOps.recalc(data, mon, gen)
end
-- Keep level; resync exp to the species growth curve (species changes).
function MonOps.setSpecies(data, mon, species)
-- Gold also copies def.name onto mon.name: the party list and SUMMARY print
-- that field when nickname is nil, so leaving the previous species' name
-- made a swapped mon still read as ABRA (or whoever was added first).
function MonOps.setSpecies(data, mon, species, gen)
assert(data.pokemon[species], "unknown species")
mon.species = species
MonOps.setLevel(data, mon, mon.level)
MonOps.setLevel(data, mon, mon.level, gen)
if gen == 2 or mon.experience ~= nil or (mon.stats and mon.stats.specialAttack) then
require("src.battle.gen2.Mon").syncIdentity(mon, data)
end
end
return MonOps
+246 -70
View File
@@ -17,6 +17,7 @@ local BoxesMod = require("src.pokemon.Boxes")
local Bag = require("src.inventory.Bag")
local MonOps = require("MonOps")
local Charmap = require("src.save_convert.data.charmap")
local Gen = require("Gen")
local Ops = {}
@@ -35,6 +36,62 @@ local function clamp(n, lo, hi)
end
Ops.clamp = clamp
local function stampNewMon(S, mon)
if Gen.ofState(S) == 2 then
require("src.battle.gen2.Mon").stampOT(S.save, mon)
else
mon.ot = S.save.player.name
mon.otId = S.save.player.id
end
return mon
end
local function createMon(S, species, level)
local mon = MonOps.create(S.data, species, level, Gen.ofState(S))
return stampNewMon(S, mon)
end
local function partySlot(S, mon)
for i, member in ipairs(S.save.party or {}) do
if member == mon then return i end
end
end
-- Portrait mail (and CheckPokeMail) store species on the letter, not the mon.
local function partyMailEntry(S, slot)
local Mail = require("src.core.gen2.Mail")
return Mail.state(S.save).party[slot]
end
local function syncPartyMailSpecies(S, mon)
if Gen.ofState(S) ~= 2 then return end
local slot = partySlot(S, mon)
if not slot then return end
local entry = partyMailEntry(S, slot)
if entry then entry.species = mon.species end
end
local function syncPartyMailHeldItem(S, mon, prevItem, newItem)
if Gen.ofState(S) ~= 2 then return end
local slot = partySlot(S, mon)
if not slot then return end
local Mail = require("src.core.gen2.Mail")
if Mail.isMail(newItem) then
local prev = partyMailEntry(S, slot)
local player = S.save.player or {}
Mail.set(S.save, slot, Mail.entry(
newItem,
prev and prev.message or "",
tostring(mon.otName or mon.ot or player.name or ""):sub(1, Mail.AUTHOR_LENGTH),
mon.otId or player.id or 0,
mon.species))
return
end
if Mail.isMail(prevItem) or partyMailEntry(S, slot) then
Mail.clear(S.save, slot)
end
end
local function now()
if love and love.timer and love.timer.getTime then
return love.timer.getTime()
@@ -111,9 +168,7 @@ function Ops.partyAdd(S)
return Ops.say(S, ("Party is full (%d/%d)"):format(#S.save.party, PartyMod.MAX))
end
local species = S.cat.species[1]
local mon = MonOps.create(S.data, species, 5)
mon.ot = S.save.player.name
mon.otId = S.save.player.id
local mon = createMon(S, species, 5)
table.insert(S.save.party, mon)
S.selectedParty = #S.save.party
S.editingMon = mon
@@ -129,6 +184,11 @@ function Ops.partyRemove(S)
return false
end
table.remove(S.save.party, index)
-- sPartyMail is keyed by party slot, not by mon: dropping a member without
-- shifting letters hands the next mon someone else's mail.
if Gen.ofState(S) == 2 then
require("src.core.gen2.Mail").removeSlot(S.save, index)
end
if S.editingMon == mon then S.editingMon = nil end
S.selectedParty = clamp(index, 1, math.max(#S.save.party, 1))
S.editingMon = S.save.party[S.selectedParty]
@@ -144,6 +204,9 @@ function Ops.partyMove(S, delta)
return Ops.say(S, delta < 0 and "Already the lead mon" or "Already the last mon")
end
party[i], party[j] = party[j], party[i]
if Gen.ofState(S) == 2 then
require("src.core.gen2.Mail").swapSlots(S.save, i, j)
end
S.selectedParty = j
return Ops.mark(S, ("Moved %s to slot %d"):format(party[j].species, j))
end
@@ -157,7 +220,7 @@ function Ops.setLevel(S, mon, level)
if want == mon.level then
return Ops.say(S, want == 1 and "Level is already 1" or "Level is already 100")
end
MonOps.setLevel(S.data, mon, want)
MonOps.setLevel(S.data, mon, want, Gen.ofState(S))
return Ops.mark(S, ("%s is now Lv%d"):format(mon.species, mon.level))
end
@@ -172,15 +235,23 @@ end
-- instead of trusting the list: without this, picking such a species walked
-- Stats.calc into `speciesDef.baseStats[key]` on a nil and took the window
-- down (#541).
local BASE_STAT_KEYS = { "hp", "attack", "defense", "speed", "special" }
local BASE_STAT_KEYS_G1 = { "hp", "attack", "defense", "speed", "special" }
local BASE_STAT_KEYS_G2 = {
"hp", "attack", "defense", "speed", "specialAttack", "specialDefense",
}
local function baseStatsComplete(bs, keys)
for _, key in ipairs(keys) do
if type(bs[key]) ~= "number" then return false end
end
return true
end
function Ops.speciesUsable(S, id)
local def = id and S.data.pokemon[id]
if type(def) ~= "table" or type(def.baseStats) ~= "table" then return false end
for _, key in ipairs(BASE_STAT_KEYS) do
if type(def.baseStats[key]) ~= "number" then return false end
end
return true
return baseStatsComplete(def.baseStats, BASE_STAT_KEYS_G1)
or baseStatsComplete(def.baseStats, BASE_STAT_KEYS_G2)
end
-- The one funnel every species change goes through (the picker, the stepper,
@@ -199,14 +270,19 @@ function Ops.setSpecies(S, mon, id)
end
-- MonOps.recalc replaces mon.stats with a fresh table rather than editing
-- it in place, so holding the old reference is a real rollback.
local wasSpecies, wasLevel, wasExp = mon.species, mon.level, mon.exp
local wasStats, wasHp = mon.stats, mon.hp
local ok, err = pcall(MonOps.setSpecies, S.data, mon, id)
local wasSpecies, wasLevel, wasExp, wasExperience = mon.species, mon.level, mon.exp, mon.experience
local wasStats, wasHp, wasName = mon.stats, mon.hp, mon.name
local wasTypes, wasGender, wasShiny, wasUnown, wasMaxHp =
mon.types, mon.gender, mon.shiny, mon.unownLetter, mon.maxHp
local ok, err = pcall(MonOps.setSpecies, S.data, mon, id, Gen.ofState(S))
if not ok then
mon.species, mon.level, mon.exp = wasSpecies, wasLevel, wasExp
mon.stats, mon.hp = wasStats, wasHp
mon.species, mon.level, mon.exp, mon.experience = wasSpecies, wasLevel, wasExp, wasExperience
mon.stats, mon.hp, mon.name = wasStats, wasHp, wasName
mon.types, mon.gender, mon.shiny, mon.unownLetter, mon.maxHp =
wasTypes, wasGender, wasShiny, wasUnown, wasMaxHp
return Ops.say(S, ("Could not set %s: %s"):format(tostring(id), tostring(err)))
end
syncPartyMailSpecies(S, mon)
return Ops.mark(S, ("Species set to %s"):format(id))
end
@@ -313,9 +389,9 @@ end
-- the target is the box, not a mon.
function Ops.openBoxAddPicker(S, Kit)
local box = Ops.boxes(S)[S.selectedBox]
if #box >= BoxesMod.CAPACITY then
if #box >= Ops.boxCapacity(S) then
return Ops.say(S, ("Box %d is full (%d/%d)")
:format(S.selectedBox, #box, BoxesMod.CAPACITY))
:format(S.selectedBox, #box, Ops.boxCapacity(S)))
end
S.speciesPicker = { query = "", offset = 0, opened = true, mode = "box-add" }
if Kit then Kit.focus = "species-picker" end -- soft keyboard rises (#529)
@@ -327,17 +403,15 @@ end
-- box mon and a party mon born in the editor are indistinguishable.
function Ops.boxAddSpecies(S, id)
local box = Ops.boxes(S)[S.selectedBox]
if #box >= BoxesMod.CAPACITY then
if #box >= Ops.boxCapacity(S) then
return Ops.say(S, ("Box %d is full (%d/%d)")
:format(S.selectedBox, #box, BoxesMod.CAPACITY))
:format(S.selectedBox, #box, Ops.boxCapacity(S)))
end
if not Ops.speciesUsable(S, id) then
return Ops.say(S, ("%s has no usable base stats, cannot add it")
:format(tostring(id)))
end
local mon = MonOps.create(S.data, id, 5)
mon.ot = S.save.player.name
mon.otId = S.save.player.id
local mon = createMon(S, id, 5)
table.insert(box, mon)
S.selectedBoxSlot = #box
S.editingMon = mon
@@ -351,7 +425,7 @@ function Ops.setDv(S, mon, key, value)
if want == mon.dvs[key] then
return Ops.say(S, ("%s DV is already %d"):format(key, want))
end
MonOps.setDv(S.data, mon, key, want)
MonOps.setDv(S.data, mon, key, want, Gen.ofState(S))
return Ops.mark(S, ("%s DV %d (HP DV now %d)"):format(key, mon.dvs[key], mon.dvs.hp))
end
@@ -382,7 +456,17 @@ end
function Ops.resetMoves(S, mon)
if not mon then return false end
local def = S.data.pokemon[mon.species]
local learned = Pokemon.movesAtLevel(def, mon.level)
local gen = Gen.ofState(S)
local learned
if gen == 2 then
local Mon = require("src.battle.gen2.Mon")
learned = {}
for _, mv in ipairs(Mon.movesAtLevel(def, mon.level, S.data.moves)) do
learned[#learned + 1] = mv.id
end
else
learned = Pokemon.movesAtLevel(def, mon.level)
end
mon.moves = {}
for slot, id in ipairs(learned) do
MonOps.setMove(S.data, mon, slot, id)
@@ -397,6 +481,7 @@ function Ops.healMon(S, mon)
return Ops.say(S, ("%s is already at full HP"):format(mon.species))
end
mon.hp = mon.stats.hp
if mon.maxHp then mon.maxHp = mon.stats.hp end
mon.status = nil
for _, mv in ipairs(mon.moves or {}) do
local def = S.data.moves[mv.id]
@@ -554,27 +639,35 @@ function Ops.clearNickname(S, mon)
end
-- ------------------------------------------------------------------ boxes
function Ops.boxCount(S)
return Gen.boxCount(S.save)
end
function Ops.boxCapacity(S)
return Gen.boxCapacity(S.save)
end
function Ops.boxes(S)
return BoxesMod.ensure(S.save)
return Gen.ensureBoxes(S.save)
end
function Ops.selectBox(S, index)
S.selectedBox = clamp(index, 1, BoxesMod.COUNT)
S.selectedBox = clamp(index, 1, Ops.boxCount(S))
S.selectedBoxSlot = 1
S.save.currentBox = S.selectedBox
local box = Ops.boxes(S)[S.selectedBox]
S.status = ("Box %d (%d/%d)"):format(S.selectedBox, #box, BoxesMod.CAPACITY)
S.status = ("Box %d (%d/%d)"):format(S.selectedBox, #box, Ops.boxCapacity(S))
return true
end
function Ops.stepBox(S, delta)
local n = BoxesMod.COUNT
local n = Ops.boxCount(S)
return Ops.selectBox(S, ((S.selectedBox - 1 + delta) % n) + 1)
end
function Ops.selectBoxSlot(S, index)
local box = Ops.boxes(S)[S.selectedBox]
S.selectedBoxSlot = clamp(index, 1, BoxesMod.CAPACITY)
S.selectedBoxSlot = clamp(index, 1, Ops.boxCapacity(S))
local mon = box[S.selectedBoxSlot]
S.editingMon = mon
S.status = mon
@@ -589,14 +682,12 @@ end
-- chooses what lands in the box instead of always getting catalog entry #1.
function Ops.boxAdd(S)
local box = Ops.boxes(S)[S.selectedBox]
if #box >= BoxesMod.CAPACITY then
if #box >= Ops.boxCapacity(S) then
return Ops.say(S, ("Box %d is full (%d/%d)")
:format(S.selectedBox, #box, BoxesMod.CAPACITY))
:format(S.selectedBox, #box, Ops.boxCapacity(S)))
end
local species = S.cat.species[1]
local mon = MonOps.create(S.data, species, 5)
mon.ot = S.save.player.name
mon.otId = S.save.player.id
local mon = createMon(S, species, 5)
table.insert(box, mon)
S.selectedBoxSlot = #box
S.editingMon = mon
@@ -612,9 +703,16 @@ function Ops.withdraw(S)
return Ops.say(S, ("Party is full (%d/%d), deposit one first")
:format(#S.save.party, PartyMod.MAX))
end
table.remove(box, S.selectedBoxSlot)
table.insert(S.save.party, mon)
S.selectedBoxSlot = clamp(S.selectedBoxSlot, 1, math.max(#box, 1))
if Gen.ofState(S) == 2 then
local Boxes2 = require("src.core.gen2.Boxes")
local ok, reason = Boxes2.canWithdraw(S.save, S.selectedBox, S.selectedBoxSlot)
if not ok then return Ops.say(S, reason) end
Boxes2.withdraw(S.save, S.selectedBox, S.selectedBoxSlot)
else
table.remove(box, S.selectedBoxSlot)
table.insert(S.save.party, mon)
end
S.selectedBoxSlot = clamp(S.selectedBoxSlot, 1, math.max(#Ops.boxes(S)[S.selectedBox], 1))
S.selectedParty = #S.save.party
return Ops.mark(S, ("Withdrew %s to party slot %d"):format(mon.species, #S.save.party))
end
@@ -639,6 +737,17 @@ function Ops.deposit(S)
local i = S.selectedParty
local mon = S.save.party[i]
if not mon then return Ops.say(S, "No party slot selected") end
if Gen.ofState(S) == 2 then
local Boxes2 = require("src.core.gen2.Boxes")
local boxIndex = S.selectedBox or S.save.currentBox or 1
local ok, reason = Boxes2.canDeposit(S.save, i, boxIndex)
if not ok then return Ops.say(S, reason) end
Boxes2.deposit(S.save, i, boxIndex)
S.selectedParty = clamp(i, 1, math.max(#S.save.party, 1))
S.selectedBox = boxIndex
if S.editingMon == mon then S.editingMon = nil end
return Ops.mark(S, ("Deposited %s into box %d"):format(mon.species, boxIndex))
end
local boxNum = BoxesMod.deposit(S.save, mon)
if not boxNum then
return Ops.say(S, "Every box is full, release something first")
@@ -652,12 +761,13 @@ end
-- ------------------------------------------------------------------ items
function Ops.addMoney(S, delta)
local want = clamp((S.save.money or 0) + delta, 0, Ops.MONEY_MAX)
if want == S.save.money then
local have = Gen.money(S.save)
local want = clamp(have + delta, 0, Ops.MONEY_MAX)
if want == have then
return Ops.say(S, delta < 0 and "Money is already $0"
or ("Money is already capped at $%d"):format(Ops.MONEY_MAX))
end
S.save.money = want
Gen.setMoney(S.save, want)
return Ops.mark(S, ("Money set to $%d"):format(want))
end
@@ -665,15 +775,29 @@ function Ops.maxMoney(S)
return Ops.addMoney(S, Ops.MONEY_MAX)
end
Ops.COIN_MAX = 9999
function Ops.addCoins(S, delta)
local have = Gen.coins(S.save)
local want = clamp(have + delta, 0, Ops.COIN_MAX)
if want == have then
return Ops.say(S, delta < 0 and "Coins are already 0"
or ("Coins are already capped at %d"):format(Ops.COIN_MAX))
end
Gen.setCoins(S.save, want)
return Ops.mark(S, ("Coins set to %d"):format(want))
end
function Ops.addToBag(S, id)
if not id then return Ops.say(S, "Pick an item first") end
local capacity = Bag.capacity(S.data)
local pocket = Bag.pocketOf(id, S.data)
local capacity = Bag.capacity(S.data, pocket)
if Bag.add(S.save, id, 1, S.data) then
return Ops.mark(S, ("Added %s to the bag (%d/%d slots)")
:format(id, Bag.slots(S.save), capacity))
return Ops.mark(S, ("Added %s to the bag (%d/%d %s slots)")
:format(id, Bag.slots(S.save, S.data, pocket), capacity, pocket))
end
return Ops.say(S, ("Bag is full (%d/%d slots)")
:format(Bag.slots(S.save), capacity))
return Ops.say(S, ("Bag is full (%d/%d %s slots)")
:format(Bag.slots(S.save, S.data, pocket), capacity, pocket))
end
function Ops.bagAdjust(S, id, delta)
@@ -715,6 +839,12 @@ end
function Ops.addToPc(S, id)
if not id then return Ops.say(S, "Pick an item first") end
local pc = Ops.pcItems(S)
local n = 0
for _ in pairs(pc) do n = n + 1 end
if not pc[id] and Gen.ofState(S) == 2 and n >= 50 then
return Ops.say(S, "PC item storage is full (50 stacks)")
end
local pc = Ops.pcItems(S)
pc[id] = math.min(Ops.STACK_MAX, (pc[id] or 0) + 1)
return Ops.mark(S, ("%s x%d in PC storage"):format(id, pc[id]))
end
@@ -749,27 +879,17 @@ function Ops.isBadgeId(id)
end
function Ops.badgeIds(S)
local ids = {}
for _, id in ipairs(S.cat.items) do
if Ops.isBadgeId(id) then ids[#ids + 1] = id end
end
return ids
return Gen.badgeIds(S.save, S.cat)
end
function Ops.toggleBadge(S, id)
-- #515: badges are truthy inventory entries written as 1 by the in-game
-- grant (checkVictoryRewards, src/world/OverworldController.lua) and by
-- GenSave's .sav import; read and write that same shape here, or a badge
-- earned in game reads as unowned and an editor-written boolean blows up
-- Bag.add's `(inv[id] or 0) + qty` (src/inventory/Bag.lua).
local on = S.save.inventory[id] and true or false
S.save.inventory[id] = (not on) and 1 or nil
return Ops.mark(S, ("%s %s"):format(id, on and "removed" or "earned"))
local nowOn = Gen.toggleBadge(S.save, id)
return Ops.mark(S, ("%s %s"):format(id, nowOn and "earned" or "removed"))
end
-- ----------------------------------------------------------------- events
function Ops.setFlag(S, name, on)
S.save.flags[name] = on and true or nil
Gen.setFlag(S.save, name, on)
return Ops.mark(S, ("%s = %s"):format(name, tostring(on and true or false)))
end
@@ -801,17 +921,19 @@ end
-- -------------------------------------------------------------------- dex
function Ops.dex(S)
S.save.pokedex = S.save.pokedex or { seen = {}, owned = {} }
local key = Gen.dexOwnedKey(S.save)
S.save.pokedex = S.save.pokedex or { seen = {}, [key] = {} }
S.save.pokedex.seen = S.save.pokedex.seen or {}
S.save.pokedex.owned = S.save.pokedex.owned or {}
S.save.pokedex[key] = S.save.pokedex[key] or {}
return S.save.pokedex
end
function Ops.dexCounts(S)
local dex = Ops.dex(S)
local key = Gen.dexOwnedKey(S.save)
local seen, owned = 0, 0
for _ in pairs(dex.seen) do seen = seen + 1 end
for _ in pairs(dex.owned) do owned = owned + 1 end
for _ in pairs(dex[key] or {}) do owned = owned + 1 end
return seen, owned, #S.cat.species
end
@@ -819,25 +941,28 @@ end
-- the game's own rule, enforced here so a hand-edited dex stays legal.
function Ops.dexSeen(S, species, on)
local dex = Ops.dex(S)
local key = Gen.dexOwnedKey(S.save)
dex.seen[species] = on and true or nil
if not on then dex.owned[species] = nil end
if not on then dex[key][species] = nil end
return Ops.mark(S, ("%s %s"):format(species, on and "marked seen" or "cleared"))
end
function Ops.dexOwned(S, species, on)
local dex = Ops.dex(S)
dex.owned[species] = on and true or nil
local key = Gen.dexOwnedKey(S.save)
dex[key][species] = on and true or nil
if on then dex.seen[species] = true end
return Ops.mark(S, ("%s %s"):format(species, on and "marked owned" or "un-owned"))
end
function Ops.dexStamp(S)
local dex = Ops.dex(S)
local key = Gen.dexOwnedKey(S.save)
local n = 0
local function stamp(mon)
if not dex.owned[mon.species] then n = n + 1 end
if not dex[key][mon.species] then n = n + 1 end
dex.seen[mon.species] = true
dex.owned[mon.species] = true
dex[key][mon.species] = true
end
for _, m in ipairs(S.save.party) do stamp(m) end
for _, box in ipairs(S.save.boxes or {}) do
@@ -855,9 +980,10 @@ end
function Ops.dexOwnAll(S)
local dex = Ops.dex(S)
local key = Gen.dexOwnedKey(S.save)
for _, species in ipairs(S.cat.species) do
dex.seen[species] = true
dex.owned[species] = true
dex[key][species] = true
end
return Ops.mark(S, ("Marked all %d species owned"):format(#S.cat.species))
end
@@ -866,7 +992,8 @@ function Ops.dexClear(S)
if not Ops.arm(S, "dex-clear", "Wipe the whole Pokedex? Click again to confirm") then
return false
end
S.save.pokedex = { seen = {}, owned = {} }
local key = Gen.dexOwnedKey(S.save)
S.save.pokedex = { seen = {}, [key] = {} }
return Ops.mark(S, "Pokedex wiped")
end
@@ -927,6 +1054,11 @@ end
-- OVERWORLD/PLATEAU tilesets, maps with connections, or fly spots the save
-- has already visited.
function Ops.isOutdoor(S, map)
if not map or not map.def then return false end
local Map2 = require("src.world.gen2.Map")
if Gen.ofState(S) == 2 and Map2.isOutdoor then
return Map2.isOutdoor(map.def) and true or false
end
if map.def.tileset == "OVERWORLD" or map.def.tileset == "PLATEAU" then
return true
end
@@ -937,15 +1069,17 @@ end
function Ops.setPlayerHere(S)
local cell = S.mapClickCell
if not cell then return Ops.say(S, "Click a cell first") end
S.save.player.map = S.mapId
S.save.player.x = cell.cx
S.save.player.y = cell.cy
Gen.setPlayerHere(S.save, S.mapId, cell.cx, cell.cy)
return Ops.mark(S, ("Player set to %s (%d,%d)"):format(S.mapId, cell.cx, cell.cy))
end
function Ops.setLastOutdoor(S, map)
local cell = S.mapClickCell
if not cell then return Ops.say(S, "Click a cell first") end
if Gen.ofState(S) == 2 then
S.save.spawn = S.mapId
return Ops.mark(S, ("spawn set to %s"):format(S.mapId))
end
if not Ops.isOutdoor(S, map) then
return Ops.say(S, S.mapId .. " doesn't look outdoor (no connections, not visited)")
end
@@ -956,8 +1090,50 @@ end
function Ops.setLastHeal(S)
local cell = S.mapClickCell
if not cell then return Ops.say(S, "Click a cell first") end
if Gen.ofState(S) == 2 then
S.save.spawn = S.mapId
return Ops.mark(S, ("spawn set to %s"):format(S.mapId))
end
S.save.lastHeal = { map = S.mapId, x = cell.cx, y = cell.cy }
return Ops.mark(S, ("lastHeal set to %s (%d,%d)"):format(S.mapId, cell.cx, cell.cy))
end
function Ops.setHeldItem(S, mon, id)
if not mon then return false end
if id == "" or id == nil then
if not mon.item then return Ops.say(S, "No held item to clear") end
local was = mon.item
mon.item = nil
syncPartyMailHeldItem(S, mon, was, nil)
return Ops.mark(S, ("Cleared held item (%s)"):format(was))
end
if not S.data.items[id] then
return Ops.say(S, ("%s is not an item"):format(tostring(id)))
end
local was = mon.item
mon.item = id
syncPartyMailHeldItem(S, mon, was, id)
return Ops.mark(S, ("%s now holds %s"):format(mon.species, id))
end
function Ops.setHappiness(S, mon, value)
if not mon then return false end
local want = clamp(math.floor(value), 0, 255)
if want == (mon.happiness or 0) then
return Ops.say(S, ("Happiness is already %d"):format(want))
end
mon.happiness = want
return Ops.mark(S, ("%s happiness %d"):format(mon.species, want))
end
function Ops.setPokerus(S, mon, value)
if not mon then return false end
local want = clamp(math.floor(value), 0, 255)
if want == (mon.pokerus or 0) then
return Ops.say(S, ("Pokerus is already %d"):format(want))
end
mon.pokerus = want
return Ops.mark(S, ("%s pokerus byte %d"):format(mon.species, want))
end
return Ops
+9 -10
View File
@@ -19,7 +19,6 @@
-- in the selected box as a Lv5 mon built by the same MonOps path partyAdd
-- uses, so its stats, exp and moves are consistent.
local BoxesMod = require("src.pokemon.Boxes")
local PartyMod = require("src.pokemon.Party")
local Theme = require("Theme")
local Ops = require("Ops")
@@ -33,12 +32,12 @@ local function drawStrip(S, Kit, boxes, x, y, stripW, h)
local s = Kit.scale
local pad = 16 * s
Kit.card(x, y, stripW, h)
Kit.caption(x + pad, y + pad, ("BOXES . %d"):format(BoxesMod.COUNT))
Kit.caption(x + pad, y + pad, ("BOXES . %d"):format(Ops.boxCount(S)))
local stripTop = y + pad + Kit.textHeight("caption") + 10 * s
local stripInner = stripW - 2 * pad
local bRowH = math.min(30 * s, math.max(22 * s,
(h - (stripTop - y) - pad - (BoxesMod.COUNT - 1) * 6 * s) / BoxesMod.COUNT))
for i = 1, BoxesMod.COUNT do
(h - (stripTop - y) - pad - (Ops.boxCount(S) - 1) * 6 * s) / Ops.boxCount(S)))
for i = 1, Ops.boxCount(S) do
local ry = stripTop + (i - 1) * (bRowH + 6 * s)
if ry + bRowH > y + h - pad then break end
if Kit.row(x + pad, ry, stripInner, bRowH, i == S.selectedBox, PAL.blue, 9 * s) then
@@ -52,7 +51,7 @@ local function drawStrip(S, Kit, boxes, x, y, stripW, h)
ry + (bRowH - Kit.textHeight("tiny")) / 2, PAL.caption)
local mx = x + pad + stripInner - 10 * s - countW - 8 * s - 44 * s
Kit.meter(mx, ry + (bRowH - 5 * s) / 2, 44 * s, 5 * s,
fill / BoxesMod.CAPACITY * 100, fill >= BoxesMod.CAPACITY and PAL.yellow or PAL.blue)
fill / Ops.boxCapacity(S) * 100, fill >= Ops.boxCapacity(S) and PAL.yellow or PAL.blue)
end
end
@@ -66,7 +65,7 @@ local function drawGrid(S, Kit, box, gridX, y, gridW, h)
Kit.text("tab", ("Box %d"):format(S.selectedBox), gx,
y + gpad + (headH - Kit.textHeight("tab")) / 2, PAL.heading)
local titleW = Kit.textWidth("tab", ("Box %d"):format(S.selectedBox))
Kit.text("mono", ("%d/%d"):format(#box, BoxesMod.CAPACITY),
Kit.text("mono", ("%d/%d"):format(#box, Ops.boxCapacity(S)),
gx + titleW + 14 * s, y + gpad + (headH - Kit.textHeight("mono")) / 2, PAL.caption)
local navW = 34 * s
if Kit.stepper(gx + ginner - 2 * navW - 8 * s, y + gpad, navW, headH, "<",
@@ -102,7 +101,7 @@ local function drawGrid(S, Kit, box, gridX, y, gridW, h)
end
if Kit.button(gx + wdW + 10 * s, actY, addW, actH, addLabel,
{ font = "small", radius = 9 * s,
enabled = #box < BoxesMod.CAPACITY }) then
enabled = #box < Ops.boxCapacity(S) }) then
Ops.openBoxAddPicker(S, Kit)
end
if Kit.button(gx + ginner - relW, actY, relW, actH, relLabel,
@@ -119,7 +118,7 @@ local function drawGrid(S, Kit, box, gridX, y, gridW, h)
-- of five slivers.
local cols = math.max(2, math.min(COLS,
math.floor((ginner + cellGap) / (86 * s + cellGap))))
local rows = math.ceil(BoxesMod.CAPACITY / cols)
local rows = math.ceil(Ops.boxCapacity(S) / cols)
local cellW = math.max(0, (ginner - cellGap * (cols - 1)) / cols)
-- floor at Kit's 26px tap target so a short window shrinks the cells but
-- never inverts them (#715); overflow clips inside the grid body rather
@@ -128,7 +127,7 @@ local function drawGrid(S, Kit, box, gridX, y, gridW, h)
math.min((gridH - cellGap * (rows - 1)) / rows, 110 * s))
Kit.pushClip(gx, gridTop, ginner, gridH)
for i = 1, BoxesMod.CAPACITY do
for i = 1, Ops.boxCapacity(S) do
local cc = (i - 1) % cols
local cr = math.floor((i - 1) / cols)
local bx = gx + cc * (cellW + cellGap)
@@ -220,7 +219,7 @@ function M.draw(S, Kit, x, y, w, h)
local s = Kit.scale
local gap = 20 * s
S.selectedBox = Ops.clamp(S.selectedBox or 1, 1, BoxesMod.COUNT)
S.selectedBox = Ops.clamp(S.selectedBox or 1, 1, Ops.boxCount(S))
S.save.currentBox = S.selectedBox
local boxes = Ops.boxes(S)
local box = boxes[S.selectedBox]
+2 -1
View File
@@ -145,7 +145,8 @@ function M.draw(S, Kit, x, y, w, h)
local rx = cx + ci * (colW + colGap)
local ry = gridTop + ri * (rowH + rowGap)
local isSeen = dex.seen[id] == true
local isOwned = dex.owned[id] == true
local ownedKey = require("Gen").dexOwnedKey(S.save)
local isOwned = dex[ownedKey] and dex[ownedKey][id] == true
Theme.row(rx, ry, colW, rowH, 9 * s, 0.6)
local def = S.data.pokemon[id]
+8 -2
View File
@@ -9,6 +9,7 @@
local Theme = require("Theme")
local Ops = require("Ops")
local Gen = require("Gen")
local PAL = Theme.PAL
local M = {}
@@ -50,7 +51,7 @@ local function buildRows(S)
if contains(name, filter) then
rows[#rows + 1] = {
label = name,
checked = S.save.flags[name] == true,
checked = Gen.getFlag(S.save, name),
set = function(on) Ops.setFlag(S, name, on) end,
}
end
@@ -107,7 +108,12 @@ function M.draw(S, Kit, x, y, w, h)
-- past the card edge.
local pillH = 32 * s
local px, py = cx, y + pad
for _, t in ipairs(SUB_TABS) do
local pills = SUB_TABS
if Gen.of(S.save) == 2 then
pills = { SUB_TABS[1] }
if S.eventsTab ~= "flags" then S.eventsTab = "flags" end
end
for _, t in ipairs(pills) do
local pw = Kit.textWidth("small", t.label) + 32 * s
if px > cx and px + pw > cx + inner then
px = cx
+26 -12
View File
@@ -18,6 +18,7 @@
local Bag = require("src.inventory.Bag")
local Theme = require("Theme")
local Ops = require("Ops")
local Gen = require("Gen")
local PAL = Theme.PAL
local M = {}
@@ -46,7 +47,11 @@ local function quantityRow(S, Kit, x, y, w, h, id, qty, selected, onMinus, onPlu
local qtyW = Kit.textWidth("monoRow", qtyText)
Kit.textRight("monoRow", qtyText, bx - 10 * s,
y + (h - Kit.textHeight("monoRow")) / 2, PAL.heading)
Kit.text("mono", Kit.ellipsize("mono", id, bx - qtyW - 30 * s - (x + 10 * s)),
local label = id
if Gen.of(S.save) == 2 then
label = (Bag.pocketOf(id, S.data) or "ITEM") .. " " .. id
end
Kit.text("mono", Kit.ellipsize("mono", label, bx - qtyW - 30 * s - (x + 10 * s)),
x + 10 * s, y + (h - Kit.textHeight("mono")) / 2, PAL.text)
return clicked
end
@@ -55,9 +60,13 @@ end
-- Each card is a function of its own rect so the wide (three column) and the
-- stacked (#715) layouts are the same drawing code with different geometry.
local function moneyHeight(Kit, s, pad)
return pad * 2 + Kit.textHeight("caption") + 8 * s
local function moneyHeight(Kit, s, pad, S)
local h = pad * 2 + Kit.textHeight("caption") + 8 * s
+ Kit.textHeight("headline") + 10 * s + 30 * s
if S and Gen.of(S.save) == 2 then
h = h + 28 * s
end
return h
end
local function drawMoney(S, Kit, x, y, w, h)
@@ -68,11 +77,19 @@ local function drawMoney(S, Kit, x, y, w, h)
local maxW = 74 * s
if Kit.button(x + w - pad - maxW, y + pad - 4 * s, maxW, 26 * s, "Max out",
{ kind = "accent", font = "tiny", radius = 7 * s,
enabled = (S.save.money or 0) < Ops.MONEY_MAX }) then
enabled = Gen.money(S.save) < Ops.MONEY_MAX }) then
Ops.maxMoney(S)
end
Kit.text("headline", ("$%d"):format(S.save.money or 0), x + pad,
Kit.text("headline", ("$%d"):format(Gen.money(S.save)), x + pad,
y + pad + Kit.textHeight("caption") + 8 * s, PAL.yellow)
if Gen.of(S.save) == 2 then
Kit.text("mono", ("COINS %d"):format(Gen.coins(S.save)), x + pad + 160 * s,
y + pad + Kit.textHeight("caption") + 8 * s, PAL.muted)
if Kit.button(x + w - pad - 74 * s, y + pad + 26 * s, 74 * s, 22 * s, "+100 coins",
{ kind = "ghost", font = "tiny", radius = 6 * s }) then
Ops.addCoins(S, 100)
end
end
local mbY = y + h - pad - 30 * s
local mbW = (w - 2 * pad - 3 * 8 * s) / 4
for i, delta in ipairs(MONEY_STEPS) do
@@ -99,10 +116,7 @@ local function drawBadges(S, Kit, x, y, w, h)
Kit.card(x, y, w, h)
local earned = 0
for _, id in ipairs(badgeIds) do
-- #515: truthy check, not `== true` -- the in-game grant path stores a
-- number (see OverworldController.lua checkVictoryRewards), matching
-- src/inventory/Badges.lua's own truthy read.
if S.save.inventory[id] then earned = earned + 1 end
if Gen.hasBadge(S.save, id) then earned = earned + 1 end
end
Kit.caption(x + pad, y + pad, "BADGES")
Kit.textRight("mono", ("%d/%d"):format(earned, #badgeIds), x + w - pad,
@@ -112,7 +126,7 @@ local function drawBadges(S, Kit, x, y, w, h)
for i, id in ipairs(badgeIds) do
local bc = (i - 1) % BADGE_COLS
local br = math.floor((i - 1) / BADGE_COLS)
local on = S.save.inventory[id]
local on = Gen.hasBadge(S.save, id)
local short = id:gsub("BADGE$", "")
if Kit.chip(x + pad + bc * (bW + 7 * s), bTop + br * (28 * s + 7 * s),
bW, 28 * s, Kit.ellipsize("micro", short, bW - 8 * s), on,
@@ -253,7 +267,7 @@ function M.draw(S, Kit, x, y, w, h)
-- drag over their own bodies first.
local off = Theme.clamp(S.itemsScroll or 0, 0,
math.max(0, (S._itemsContentH or 0) - h))
local moneyH = moneyHeight(Kit, s, pad)
local moneyH = moneyHeight(Kit, s, pad, S)
local badgeH = badgeHeight(S, Kit, s, pad)
local pickH = 280 * s
local listH = 300 * s
@@ -278,7 +292,7 @@ function M.draw(S, Kit, x, y, w, h)
-- Money and badges are fixed-height so the picker gets every pixel left
-- over: cycling through ~250 item ids in a two-row list was the thing that
-- made the old panel unusable.
local moneyH = moneyHeight(Kit, s, pad)
local moneyH = moneyHeight(Kit, s, pad, S)
local badgeH = badgeHeight(S, Kit, s, pad)
drawMoney(S, Kit, x, y, leftW, moneyH)
drawPicker(S, Kit, x, y + moneyH + gap, leftW, h - moneyH - badgeH - 2 * gap)
+119 -39
View File
@@ -14,12 +14,18 @@ local MapLoader = require("src.world.MapLoader")
local Warp = require("src.world.Warp")
local Theme = require("Theme")
local Ops = require("Ops")
local Gen = require("Gen")
local PAL = Theme.PAL
local MapBrowser = {}
local CELL = 16 -- the walk grid; a cell is 16px of map art
local function playerPos(S)
local map, x, y = Gen.playerMap(S.save)
return map, x or 0, y or 0
end
local function clampZoom(z)
if z < 1 then return 1 end
if z > 4 then return 4 end
@@ -38,7 +44,7 @@ MapBrowser.centerOn = centerOn
local function sortedMapIds(data)
local ids = {}
for id in pairs(data.maps) do ids[#ids + 1] = id end
for id in pairs(Gen.maps(data)) do ids[#ids + 1] = id end
table.sort(ids)
return ids
end
@@ -52,7 +58,19 @@ local OUTSIDE_TILESETS = { OVERWORLD = true, PLATEAU = true }
local function goToWarp(S, warp)
local def = warp.def
local fromMap = S.data.maps[S.mapId]
if Gen.of(S.save) == 2 then
local dest = def.destMap or def.map
if dest then
S.mapId = dest
S.mapClickCell = nil
S._mapCenteredFor = dest
S.status = "Followed warp to " .. tostring(dest)
else
S.status = "Warp has no destination map"
end
return
end
local fromMap = Gen.maps(S.data)[S.mapId]
if fromMap and OUTSIDE_TILESETS[fromMap.tileset]
and def.destMap ~= "LAST_MAP" and def.destMap ~= S.mapId then
S.save.lastOutdoor = { id = S.mapId, x = def.x, y = def.y }
@@ -125,22 +143,25 @@ local function drawOverlays(S, map)
return cx * CELL - S.mapCamX, cy * CELL - S.mapCamY, CELL, CELL
end
love.graphics.setColor(0.27, 0.59, 1, 0.55)
for _, wdef in ipairs(map.def.warps) do
for _, wdef in ipairs(map.def.warps or {}) do
love.graphics.rectangle("line", cellRect(wdef.x, wdef.y))
end
if S.save.player.map == S.mapId then
local playerMap, px, py = playerPos(S)
if playerMap == S.mapId then
love.graphics.setColor(1, 0.36, 0.4, 0.9)
love.graphics.rectangle("fill", cellRect(S.save.player.x, S.save.player.y))
love.graphics.rectangle("fill", cellRect(px, py))
end
local heal = S.save.lastHeal
if heal and heal.map == S.mapId then
love.graphics.setColor(0.24, 0.88, 0.54, 0.9)
love.graphics.rectangle("line", cellRect(heal.x, heal.y))
end
local out = S.save.lastOutdoor
if out and out.id == S.mapId then
love.graphics.setColor(1, 0.8, 0.02, 0.9)
love.graphics.rectangle("line", cellRect(out.x, out.y))
if Gen.of(S.save) ~= 2 then
local heal = S.save.lastHeal
if heal and heal.map == S.mapId then
love.graphics.setColor(0.24, 0.88, 0.54, 0.9)
love.graphics.rectangle("line", cellRect(heal.x, heal.y))
end
local out = S.save.lastOutdoor
if out and out.id == S.mapId then
love.graphics.setColor(1, 0.8, 0.02, 0.9)
love.graphics.rectangle("line", cellRect(out.x, out.y))
end
end
if S.mapClickCell then
love.graphics.setColor(1, 1, 0.35, 0.95)
@@ -239,9 +260,13 @@ function MapBrowser.draw(S, Kit, x, y, w, h)
#ids, perPage)
if Kit.button(lr.x + pad, gotoY, listInner, gotoH, "Go to save location",
{ font = "small", radius = 9 * s }) then
MapBrowser.select(S, S.save.player.map)
Ops.say(S, ("Jumped to %s (%d,%d)"):format(S.save.player.map,
S.save.player.x, S.save.player.y))
local pmap, px, py = playerPos(S)
if pmap then
MapBrowser.select(S, pmap)
Ops.say(S, ("Jumped to %s (%d,%d)"):format(pmap, px, py))
else
Ops.say(S, "No player location on this save")
end
end
-- ---------------------------------------------------------- the viewport
@@ -253,7 +278,32 @@ function MapBrowser.draw(S, Kit, x, y, w, h)
Kit.text("monoBig", tostring(S.mapId), vx0,
vr.y + vpad + (headH - Kit.textHeight("monoBig")) / 2, PAL.heading)
local ok, map = pcall(MapLoader.load, S.data, S.mapId)
local ok, map
if Gen.of(S.save) == 2 then
local def = Gen.maps(S.data)[S.mapId]
if def then
local Map2 = require("src.world.gen2.Map")
if type(def.width) ~= "number" or type(def.height) ~= "number" then
ok, map = false, "incomplete map record (missing width/height)"
else
local tileset = Gen.tilesets(S.data)[def.tileset]
ok, map = pcall(Map2.new, def, tileset or {})
if ok and map and not map.renderer then
local MapPreview = require("src.world.gen2.MapPreview")
S._g2MapBaker = S._g2MapBaker or MapPreview.baker({
tilesets = Gen.tilesets(S.data),
gen2Roofs = S.data.gen2Roofs, roofs = S.data.roofs,
gen2Palettes = S.data.gen2Palettes, palettes = S.data.palettes,
})
map.renderer = MapPreview.renderer(S._g2MapBaker, map)
end
end
else
ok, map = false, "unknown map"
end
else
ok, map = pcall(MapLoader.load, S.data, S.mapId)
end
if not ok then
Kit.text("mono", "Failed to load map: " .. tostring(map), vx0,
vr.y + vpad + headH + 20 * s, PAL.red)
@@ -279,12 +329,13 @@ function MapBrowser.draw(S, Kit, x, y, w, h)
local zBtn = 32 * s
local rightEdge = vx0 + vinner
local zoomW = 2 * zBtn + 56 * s + 12 * s
local pmap, px, py = playerPos(S)
local showCenter = vinner >= zoomW + 10 * s + centerW + 160 * s
if showCenter then
if Kit.button(rightEdge - centerW, vr.y + vpad, centerW, headH, "Center on player",
{ kind = "accent", font = "small", radius = 7 * s }) then
if S.save.player.map == S.mapId then
centerOn(S, S.save.player.x, S.save.player.y)
if pmap == S.mapId then
centerOn(S, px, py)
Ops.say(S, "Centred on the player")
else
Ops.say(S, "Player isn't on this map")
@@ -312,10 +363,11 @@ function MapBrowser.draw(S, Kit, x, y, w, h)
-- the panel has laid itself out.
if S._mapCenteredFor ~= S.mapId then
S._mapCenteredFor = S.mapId
if S.save.player.map == S.mapId then
centerOn(S, S.save.player.x, S.save.player.y)
if pmap == S.mapId then
centerOn(S, px, py)
else
centerOn(S, map.widthCells / 2, map.heightCells / 2)
centerOn(S, (map.widthCells or map.width or 10) / 2,
(map.heightCells or map.height or 10) / 2)
end
end
@@ -334,7 +386,24 @@ function MapBrowser.draw(S, Kit, x, y, w, h)
love.graphics.push()
love.graphics.translate(vx0, vy0)
love.graphics.scale(S.mapZoom, S.mapZoom)
map.renderer:draw(S.mapCamX, S.mapCamY)
if map.renderer and map.renderer.draw then
map.renderer:draw(S.mapCamX, S.mapCamY)
else
local wc = map.widthCells or ((map.width or 8) * 2)
local hc = map.heightCells or ((map.height or 8) * 2)
for cy = 0, hc - 1 do
for cx = 0, wc - 1 do
if (cx + cy) % 2 == 0 then
love.graphics.setColor(0.18, 0.22, 0.32, 1)
else
love.graphics.setColor(0.14, 0.17, 0.26, 1)
end
love.graphics.rectangle("fill",
cx * CELL - S.mapCamX, cy * CELL - S.mapCamY, CELL, CELL)
end
end
love.graphics.setColor(1, 1, 1, 1)
end
drawOverlays(S, map)
love.graphics.pop()
love.graphics.setScissor()
@@ -402,20 +471,31 @@ function MapBrowser.draw(S, Kit, x, y, w, h)
Kit.caption(sx0 + pad, sr.y + pad, "SPAWN POINTS")
local sTop = sr.y + pad + Kit.textHeight("caption") + 12 * s
local sInner = sr.w - 2 * pad
local player = S.save.player
local out = S.save.lastOutdoor
local heal = S.save.lastHeal
local spawns = {
{ key = "PLAYER", color = PAL.red,
value = ("%s (%d,%d)"):format(player.map, player.x, player.y),
set = function() Ops.setPlayerHere(S) end },
{ key = "LAST HEAL", color = PAL.green,
value = heal and ("%s (%d,%d)"):format(heal.map, heal.x, heal.y) or "unset",
set = function() Ops.setLastHeal(S) end },
{ key = "LAST OUTDOOR", color = PAL.yellow,
value = out and ("%s (%d,%d)"):format(out.id, out.x, out.y) or "unset",
set = function() Ops.setLastOutdoor(S, map) end },
}
local pmap2, px2, py2 = playerPos(S)
local playerValue = pmap2 and ("%s (%d,%d)"):format(pmap2, px2, py2) or "unset"
local spawns
if Gen.of(S.save) == 2 then
spawns = {
{ key = "PLAYER", color = PAL.red, value = playerValue,
set = function() Ops.setPlayerHere(S) end },
{ key = "SPAWN", color = PAL.green,
value = tostring(S.save.spawn or "SPAWN_HOME"),
set = function() Ops.setLastHeal(S) end },
}
else
local out = S.save.lastOutdoor
local heal = S.save.lastHeal
spawns = {
{ key = "PLAYER", color = PAL.red, value = playerValue,
set = function() Ops.setPlayerHere(S) end },
{ key = "LAST HEAL", color = PAL.green,
value = heal and ("%s (%d,%d)"):format(heal.map, heal.x, heal.y) or "unset",
set = function() Ops.setLastHeal(S) end },
{ key = "LAST OUTDOOR", color = PAL.yellow,
value = out and ("%s (%d,%d)"):format(out.id, out.x, out.y) or "unset",
set = function() Ops.setLastOutdoor(S, map) end },
}
end
local spawnH = 62 * s
for i, sp in ipairs(spawns) do
local ry = sTop + (i - 1) * (spawnH + 8 * s)
@@ -431,7 +511,7 @@ function MapBrowser.draw(S, Kit, x, y, w, h)
sx0 + pad + 12 * s, ry + spawnH - 10 * s - Kit.textHeight("mono"), PAL.muted)
end
local noteY = sTop + 3 * (spawnH + 8 * s) + 6 * s
local noteY = sTop + #spawns * (spawnH + 8 * s) + 6 * s
Kit.textCenter("tiny",
"Click a cell first. Warp cells follow the warp instead of selecting. " ..
"Arrow keys / WASD pan, the wheel zooms.",
+52 -5
View File
@@ -19,17 +19,26 @@
local Theme = require("Theme")
local Ops = require("Ops")
local PAL = Theme.PAL
local Gen = require("Gen")
local MonEditor = {}
local DV_KEYS = { "attack", "defense", "speed", "special" }
local STAT_KEYS = {
local STAT_KEYS_G1 = {
{ key = "HP", field = "hp" },
{ key = "ATK", field = "attack" },
{ key = "DEF", field = "defense" },
{ key = "SPD", field = "speed" },
{ key = "SPC", field = "special" },
}
local STAT_KEYS_G2 = {
{ key = "HP", field = "hp" },
{ key = "ATK", field = "attack" },
{ key = "DEF", field = "defense" },
{ key = "SPA", field = "specialAttack" },
{ key = "SPD", field = "specialDefense" },
{ key = "SPE", field = "speed" },
}
-- Front sprites are read straight off the generated cache. One image per
-- species, cached for the process: the old panel called newImage every frame,
@@ -96,7 +105,7 @@ local function drawLevelRow(S, Kit, mon, lx0, ly)
end
lx = lx + bw + 8 * s
end
Kit.text("mono", ("EXP %d"):format(mon.exp or 0), lx + 6 * s,
Kit.text("mono", ("EXP %d"):format(Gen.exp(mon)), lx + 6 * s,
ly + (lh - Kit.textHeight("mono")) / 2, PAL.muted)
return lh
end
@@ -106,7 +115,7 @@ end
local function levelRowWidth(Kit, mon)
local s = Kit.scale
return 52 * s + 2 * (40 * s + 8 * s) + 58 * s + 8 * s + 2 * (40 * s + 8 * s)
+ 6 * s + Kit.textWidth("mono", ("EXP %d"):format(mon.exp or 0))
+ 6 * s + Kit.textWidth("mono", ("EXP %d"):format(Gen.exp(mon)))
end
local function drawDvRows(S, Kit, mon, cx, rowY, colW, rowH, rowGap)
@@ -179,7 +188,7 @@ function MonEditor.draw(S, Kit, x, y, w, h)
local tw = math.min(w - 40 * s, 340 * s)
Kit.textCenter("button",
"Pick a slot on the left to inspect it. Every change here re-runs the " ..
"Gen1 stat formulas, so HP and stats stay legal.",
"stat formulas, so HP and stats stay legal.",
x + (w - tw) / 2, y + h / 2 - Kit.textHeight("button"), tw, PAL.muted)
return
end
@@ -220,10 +229,13 @@ function MonEditor.draw(S, Kit, x, y, w, h)
end
-- the nickname section: a caption line (with the Clear button on it) plus
-- the field + Set row
local extraH = 0
if Gen.ofState(S) == 2 then extraH = 88 * s end
local nickFieldH = 30 * s
local contentH = pad + headerH + 18 * s
+ capH + 10 * s + nickFieldH + 18 * s
+ capH + 10 * s + cellH + 18 * s
+ extraH
+ colsH + pad
-- Called before the widgets so this frame already draws at the updated
@@ -311,8 +323,9 @@ function MonEditor.draw(S, Kit, x, y, w, h)
local statsY = nickY + capH + 10 * s + nickFieldH + 18 * s
Kit.caption(cx, statsY, "STATS . recalculated from level + DVs")
statsY = statsY + capH + 10 * s
local STAT_KEYS = Gen.ofState(S) == 2 and STAT_KEYS_G2 or STAT_KEYS_G1
local gap = 12 * s
local cellW = (inner - gap * 4) / 5
local cellW = (inner - gap * (#STAT_KEYS - 1)) / #STAT_KEYS
for i, st in ipairs(STAT_KEYS) do
local bx = cx + (i - 1) * (cellW + gap)
Theme.row(bx, statsY, cellW, cellH, 10 * s, 0.6)
@@ -326,6 +339,40 @@ function MonEditor.draw(S, Kit, x, y, w, h)
-- --------------------------------------------------- DVs | moves split
local colY = statsY + cellH + 18 * s
if Gen.ofState(S) == 2 then
local extraY = colY
Kit.caption(cx, extraY, "GOLD")
extraY = extraY + capH + 8 * s
local row = 28 * s
Kit.text("tiny", "HELD " .. tostring(mon.item or "none"), cx, extraY, PAL.text)
if Kit.button(cx + inner - 70 * s, extraY, 70 * s, row, "Clear item",
{ kind = "danger", font = "tiny", radius = 6 * s }) then
Ops.setHeldItem(S, mon, nil)
end
extraY = extraY + row + 6 * s
Kit.text("tiny", ("HAPPINESS %d"):format(mon.happiness or 0), cx, extraY, PAL.text)
if Kit.stepper(cx + 140 * s, extraY, 28 * s, row, "-", { font = "small" }) then
Ops.setHappiness(S, mon, (mon.happiness or 0) - 10)
end
if Kit.stepper(cx + 174 * s, extraY, 28 * s, row, "+", { font = "small" }) then
Ops.setHappiness(S, mon, (mon.happiness or 0) + 10)
end
Kit.text("tiny", ("PKRS %d"):format(mon.pokerus or 0), cx + 220 * s, extraY, PAL.text)
if Kit.stepper(cx + 300 * s, extraY, 28 * s, row, "-", { font = "small" }) then
Ops.setPokerus(S, mon, (mon.pokerus or 0) - 1)
end
if Kit.stepper(cx + 334 * s, extraY, 28 * s, row, "+", { font = "small" }) then
Ops.setPokerus(S, mon, (mon.pokerus or 0) + 1)
end
extraY = extraY + row + 4 * s
local bits = {}
if mon.gender then bits[#bits + 1] = mon.gender end
if mon.shiny then bits[#bits + 1] = "shiny" end
if mon.unownLetter then bits[#bits + 1] = "Unown " .. tostring(mon.unownLetter) end
Kit.text("tiny", table.concat(bits, " ") ~= "" and table.concat(bits, " ")
or "gender/shiny follow DVs", cx, extraY, PAL.caption)
colY = extraY + 22 * s
end
if narrow then
-- stacked: DVs first, then moves, then the two actions side by side at
-- full width (#715)