mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-16 00:02:23 +02:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a66efe207d | |||
| 5198b35945 | |||
| 40977337b1 | |||
| 1598f34954 | |||
| 43cbc554c3 | |||
| 3c3e2c54c5 | |||
| 00c72c441b | |||
| 7804ef9793 | |||
| 673d8b3ad8 | |||
| 62e1296ced | |||
| a3bbd78e7b | |||
| 8dfbd1daae | |||
| 4046b28a8c | |||
| a86d57ac44 | |||
| 72f126ae26 | |||
| 5d7a8c9291 | |||
| c48fc578ca | |||
| dfc216f974 | |||
| 542856c83d | |||
| 545a99d86d | |||
| 2b6473ae03 | |||
| df0be1cba6 | |||
| c9d67582ae | |||
| 1cfd91a503 | |||
| fb738fa1ce | |||
| c0ad6d0328 |
@@ -79,3 +79,9 @@ mobile/ios/bundle_id.local
|
||||
|
||||
# Local options / preferences
|
||||
/options.lua*
|
||||
|
||||
# User-owned ROMs imported for individual mods. Manifests declare the
|
||||
# destinations, but source checkouts and packaged mods never ship the files.
|
||||
/mods/*/baseroms/
|
||||
/imports/baseroms/
|
||||
/imports/baseroms-recovery/
|
||||
|
||||
+45
-15
@@ -246,17 +246,37 @@ real Gold boot.
|
||||
|
||||
Your code runs in a sandbox (`src/mods/Sandbox.lua`), not against the
|
||||
engine's globals. Every chunk you author gets it: `main.lua`, your
|
||||
`options_schema`, and anything you `load()` yourself. What is absent:
|
||||
`options_schema`, and anything you `load()` yourself.
|
||||
|
||||
| Absent | Use instead |
|
||||
The globals the sandbox took away are still *reachable*, as compat
|
||||
stand-ins (`src/mods/LegacyCompat.lua`) that answer with the new API
|
||||
underneath. A mod written before the sandbox keeps working; it logs one
|
||||
warning per call it should migrate, and the mod manager lists them. What
|
||||
each stand-in actually does:
|
||||
|
||||
| Pre-sandbox call | What it does now | Migrate to |
|
||||
| --- | --- | --- |
|
||||
| `io.open`, `io.lines`, `love.filesystem.read`/`lines`/`newFile` | reads your own shipped files, then your overlay, then `mod.storage` | `mod:read`, `mod.storage` |
|
||||
| `love.filesystem.write`/`append`, `io.open(…, "w")`, `os.remove`, `os.rename` | writes to a private per-mod overlay under `mod_compat/<your id>/` | `mod.storage` |
|
||||
| `love.filesystem.getDirectoryItems`/`getInfo` | your own directory plus your overlay | `mod:list`, `mod:info` |
|
||||
| `love.filesystem.getSaveDirectory` and friends | a virtual root; anything joined to it lands in your overlay | `mod.storage` |
|
||||
| `os.getenv` | `nil`, except home-like names, which answer with that same virtual root | nothing |
|
||||
| `love.filesystem.load`, `dofile`, `loadfile` | compiles the chunk into your sandbox | `require`, `mod:read` plus `load` |
|
||||
| `love.system` | `getOS`/`getPowerInfo`/`getProcessorCount` read through; clipboard and `openURL` do nothing | `mod.device:powerInfo()`, `mod.steps` |
|
||||
| `love.event` | passes through, except `quit`, which does nothing | `mod.events`, `mod.hooks` |
|
||||
| `love.mousemoved = fn` and the other callbacks | installs on the real `love` table, the way it always did | `mod.hooks`, `mod.events` |
|
||||
| `package` | an inert stub, so `package.path = …` does not crash | `require` |
|
||||
|
||||
What has no stand-in, because there is nothing honest to reroute it to:
|
||||
|
||||
| Still refused | Why |
|
||||
| --- | --- |
|
||||
| `io`, and `require("io")` | `mod:read` for your own files, `mod.storage` to persist |
|
||||
| `os.getenv`, `os.execute`, `os.remove`, `os.rename`, `os.exit` | nothing; `os.time`/`os.date`/`os.clock` still work |
|
||||
| `package`, `dofile`, `loadfile`, `debug`, `getfenv`, `setfenv` | `require` for the supported engine modules |
|
||||
| `require("ffi")`, `require("love.*")` | the `love` table you are given |
|
||||
| `love.filesystem` | `mod.storage` (per-mod, per-playthrough) and `mod:read` |
|
||||
| `love.thread`, `love.event` | `mod.events`, `mod.hooks` |
|
||||
| `love.system` | `mod.device:powerInfo()` for battery information; `mod.steps` (with the `steps` permission) for the step bridge |
|
||||
| `love.thread` | a LÖVE thread is a fresh Lua state with the full standard library, which no environment-based sandbox in this state can reach. Use `mod.fetch` for background HTTP (`network`) or `mod.job` for background compute (`background`) — both run your code inside the sandbox instead of outside it |
|
||||
| `require("ffi")` | arbitrary C |
|
||||
| `debug`, `getfenv`, `setfenv` | each one undoes the sandbox from inside |
|
||||
| `io.popen`, `os.execute` | spawning a process |
|
||||
| `love.run`, `love.errorhandler` | the engine's own loop and its crash path |
|
||||
| replacing a `love` module table (`love.filesystem = {}`) | the engine reads those tables too |
|
||||
|
||||
The rest of `love` passes through unchanged, so graphics, audio, timers and
|
||||
input work as they always have.
|
||||
@@ -269,19 +289,29 @@ Three consequences worth knowing before you write against it:
|
||||
`mod.find("your_id").exports` — the channel that was always the intended
|
||||
one. The same goes for the standard library: `string`, `table` and `math`
|
||||
are per-mod copies, so patching one is a local decision.
|
||||
- **Paths cannot climb.** `mod:read`, `mod.assets:path` and `mod.assets:image`
|
||||
join to your own directory, and `..`, absolute paths and drive letters are
|
||||
refused. So are `entry` and `options_schema` in your manifest.
|
||||
- **Paths cannot climb.** `mod:read`, `mod:list`, `mod:info`, `mod.assets:path`
|
||||
and `mod.assets:image` join to your own directory, and `..`, absolute paths
|
||||
and drive letters are refused. So are `entry` and `options_schema` in your
|
||||
manifest. `mod:list("assets")` is the sandboxed `getDirectoryItems` for a
|
||||
folder you shipped; `mod:info` tells file from directory so a walk can
|
||||
recurse.
|
||||
- **Ship source, not bytecode.** A precompiled entry file is refused.
|
||||
|
||||
`permissions` in the manifest is still a disclosure the manager shows the
|
||||
player, and `network` now gates `require("socket")` and friends. There is no
|
||||
player. `network` gates `require("socket")` and friends plus `mod.fetch`
|
||||
(non-blocking HTTP), and `background` gates `mod.job` (compute on a worker
|
||||
thread). Those two are the sanctioned ways to work off the main thread now
|
||||
that `love.thread` is refused. There is no
|
||||
permission that grants raw filesystem access, because no mod needs one:
|
||||
everything a mod legitimately writes is already scoped by
|
||||
`mod.storage` or the asset-transform derived root.
|
||||
|
||||
If your mod used one of the absent globals, the fix is almost always
|
||||
`mod.storage`. Open an issue if you have a case it does not cover.
|
||||
If your mod used one of the rerouted globals, the fix is almost always
|
||||
`mod.storage`. The overlay is a compatibility floor, not a second storage
|
||||
system: it is not scoped per playthrough, it does not migrate, and it is
|
||||
the first thing that will be dropped once the mods on the index have
|
||||
moved off it. Open an issue if you have a case `mod.storage` does not
|
||||
cover.
|
||||
|
||||
### 6. `mod.card`
|
||||
|
||||
|
||||
@@ -486,9 +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
|
||||
bicycle and fishing records in both games. Each engine keeps ownership of its
|
||||
inventory, terrain, surfing, bike, and fishing rules.
|
||||
`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
|
||||
|
||||
+222
-7
@@ -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,19 +204,23 @@ 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 items
|
||||
## Contextual field actions
|
||||
|
||||
`mod.world:availableFieldActions()` returns the field items that can start at
|
||||
the player's current position. Red and Gold currently expose `bicycle` and
|
||||
`fish`; fishing rows include the owned rods that are valid choices. The list
|
||||
is empty while the world is busy, while riding states or terrain forbid an
|
||||
action, or when the required item is not owned.
|
||||
`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 bike, collision, or fishing logic.
|
||||
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
|
||||
|
||||
@@ -741,3 +802,157 @@ the native side's pending file itself, each permissioned mod receives its
|
||||
own copy of a delivery, and steps are anchored natively so the same walk
|
||||
is never delivered twice. Without the permission, `sync` and `poll` raise
|
||||
an error naming it.
|
||||
|
||||
## Background HTTP
|
||||
|
||||
`mod.fetch` is how a mod does work off the main thread. It is behind the
|
||||
`network` permission in `manifest.json`, the same one that gates
|
||||
`require("socket")`, and the player sees it in the mod manager.
|
||||
|
||||
```lua
|
||||
-- somewhere once
|
||||
local job = mod.fetch:get("https://example.com/data.json")
|
||||
|
||||
-- in a hook or update, every frame -- poll never blocks
|
||||
if job then
|
||||
local r = mod.fetch:poll(job)
|
||||
if r.status ~= "pending" then
|
||||
if r.status == "ok" then use(r.body) else warn(r.err) end
|
||||
mod.fetch:release(job)
|
||||
job = nil
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
`get(url, opts)` returns an opaque handle, or `nil` plus a reason. `opts`
|
||||
takes `accept` (a request Accept header) and `maxSeconds` (clamped to 30).
|
||||
`poll(handle)` returns `{ status, body, err, progress }` where `status` is
|
||||
`"pending"`, `"ok"`, `"error"` or `"cancelled"`; it is a copy, and it never
|
||||
blocks, so calling it every frame is the intended use. `release(handle)`
|
||||
frees a finished job — do it, or you will hit the ceiling. `cancel(handle)`
|
||||
drops a result you no longer want. `available()` is `false` when the build
|
||||
has no transport and for mods without the permission, so a probe is safe.
|
||||
|
||||
The rules worth knowing before you design around it:
|
||||
|
||||
- **http and https only.** The underlying transport also speaks `file://`,
|
||||
`ftp://` and `scp://`; those are refused, on the initial URL and on any
|
||||
redirect. `mod.fetch` is not a way to read a local file.
|
||||
- **Four requests in flight per mod.** The worker pool is shared with the
|
||||
launcher's own downloads, so one mod cannot fill it. Over the ceiling,
|
||||
`get` returns `nil` and a reason until you release something.
|
||||
- **Handles are yours alone.** A handle from another mod, a fabricated
|
||||
table, or a guessed number all poll as `"error"`.
|
||||
- **Your mod id is in the User-Agent**, so a server operator can see who is
|
||||
calling and a mod cannot pose as the launcher.
|
||||
- Jobs are released when your mod unloads.
|
||||
|
||||
This is deliberately not `love.thread`. A LÖVE thread is a fresh Lua state
|
||||
with a full standard library that the sandbox cannot reach, so handing one
|
||||
to a mod would undo every other rule; `mod.fetch`'s workers run engine
|
||||
code, so a mod gets asynchrony without gaining any new reach.
|
||||
|
||||
## Background jobs
|
||||
|
||||
`mod.fetch` covers work waiting on a server. `mod.job` covers work waiting on
|
||||
the CPU — generating a map, crunching a table, anything that would otherwise
|
||||
stall a frame. It is behind the `background` permission in `manifest.json`.
|
||||
|
||||
Ship the job as its own file inside your mod:
|
||||
|
||||
```lua
|
||||
-- mods/your_mod/jobs/crunch.lua
|
||||
local arg = ...
|
||||
local total = 0
|
||||
for i = 1, arg.n do total = total + i end
|
||||
return { total = total }
|
||||
```
|
||||
|
||||
```lua
|
||||
-- in your entry file
|
||||
local job = mod.job:run("jobs/crunch.lua", { n = 1e6 })
|
||||
|
||||
-- later, in a hook -- poll never blocks
|
||||
local r = mod.job:poll(job)
|
||||
if r.status == "ok" then
|
||||
use(r.result.total)
|
||||
mod.job:release(job)
|
||||
end
|
||||
```
|
||||
|
||||
`run(script, arg, opts)` returns an opaque handle, or `nil` plus a reason.
|
||||
`opts.maxSeconds` sets the job's time budget (default 5, clamped to 30).
|
||||
`poll(handle)` returns `{ status, result, err }` with `status` one of
|
||||
`"pending"`, `"ok"`, `"error"` or `"cancelled"`. `release(handle)` frees it.
|
||||
`available()` is `false` on a host without threads and for mods without the
|
||||
permission, so a probe is always safe.
|
||||
|
||||
**A job is pure compute.** This is the part to design around, not a detail:
|
||||
|
||||
- **Plain data in, plain data out.** Numbers, strings, booleans and tables of
|
||||
them. A function, userdata, a cycle or a table key that is not a string or
|
||||
number is refused at your `run` call with a reason. Nothing is shared —
|
||||
your argument is snapshotted, and mutating the original afterwards does not
|
||||
reach the job.
|
||||
- **No engine API, no game state, no storage.** `require` is refused inside a
|
||||
job, and there is no `mod` object. A job cannot read the party, write
|
||||
`mod.storage`, or touch a registry. Get what it needs into the argument and
|
||||
act on the result back on the main thread.
|
||||
- **Your script is a file in your mod folder.** The path goes through the same
|
||||
rules as `mod:read`; `..`, absolute paths and drive letters are refused.
|
||||
- **Two jobs per mod, four on the machine.** Over the limit, `run` returns
|
||||
`nil` and a reason until you release one.
|
||||
- **The budget bounds how long YOU wait, not how long the work runs.** Past
|
||||
`maxSeconds`, `poll` reports an error and the result is dropped if it ever
|
||||
arrives — but the thread runs to its own end. There is no way to stop a
|
||||
LÖVE thread from outside, and every attempt to stop one from inside was
|
||||
worse than the disease (a debug hook does not reliably interrupt LuaJIT,
|
||||
and raising from one wedged the whole process). `cancel(handle)` is the
|
||||
same deal: it drops the result, it does not stop the work.
|
||||
|
||||
So **write jobs that terminate.** A job with an infinite loop will keep one
|
||||
core busy until the game closes. It will not freeze the game — the main
|
||||
thread stays responsive and quitting still works — but nothing will reclaim
|
||||
that core in the meantime.
|
||||
|
||||
Your job script runs in the same sandbox your entry file does, so `io`, `os`,
|
||||
`debug`, `ffi`, `package` and `love.filesystem` are absent there too. That is
|
||||
the whole reason this exists rather than `love.thread`: a raw LÖVE thread is a
|
||||
fresh Lua state with a full standard library that the sandbox cannot reach, so
|
||||
handing one to a mod would undo every other rule. Here the worker builds your
|
||||
sandbox first and loads your chunk into it.
|
||||
|
||||
## Pre-sandbox globals (compat)
|
||||
|
||||
A mod written before the sandbox landed does not have to be updated to
|
||||
load. `io`, `package`, `dofile`, `loadfile`, `os.getenv`, `love.filesystem`,
|
||||
`love.system` and `love.event` are all present again as compat stand-ins
|
||||
(`src/mods/LegacyCompat.lua`), and assigning a LÖVE callback
|
||||
(`love.mousemoved = fn`) installs on the real table the way it always did.
|
||||
Every stand-in call logs one warning naming its replacement, and
|
||||
`loader:legacyReport(modId)` returns the same list with call counts, which
|
||||
is what a "needs updating" badge should read.
|
||||
|
||||
The stand-ins are not the old globals. Paths are classified rather than
|
||||
passed through:
|
||||
|
||||
- A path inside your own mod directory reads the file you shipped.
|
||||
- Anything else, including an absolute path, resolves into a private
|
||||
per-mod overlay at `mod_compat/<your id>/` under the save directory.
|
||||
Two mods naming the same path never see each other's bytes, and nothing
|
||||
is written outside the game tree.
|
||||
- A read misses through the overlay to your shipped file, then to
|
||||
`mod.storage`, so a half-migrated mod sees both.
|
||||
- A write over a path you shipped shadows it; the packaged file is never
|
||||
modified, and `mod:read` still returns the packaged bytes.
|
||||
- `love.filesystem.getSaveDirectory()` and `os.getenv("HOME")` answer with
|
||||
a virtual root, so a legacy mod that joins its own paths lands back in
|
||||
the same overlay.
|
||||
|
||||
`love.thread` stays refused. A LÖVE thread runs in a separate Lua state
|
||||
with the full standard library, which the sandbox in this state cannot
|
||||
reach, so a stand-in would be a hole rather than a reroute. The same goes
|
||||
for `ffi`, `debug`, `setfenv`, `os.execute`, `io.popen`, `love.run` and
|
||||
`love.errorhandler`. A mod that needs real background work needs an
|
||||
engine-owned facility, not a compat shim -- for HTTP that facility is
|
||||
[`mod.fetch`](#modfetch), which runs on the engine's own worker pool.
|
||||
|
||||
@@ -19,7 +19,7 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow
|
||||
* **Soft reset button combination**
|
||||
* **Keyboard and controller rebinding**
|
||||
* **Mod profiles** with separate mod settings and save slots
|
||||
* **Sandboxed mods**: an installed mod can read only its own folder and write only its own storage, so it cannot reach the rest of your device
|
||||
* **Sandboxed mods**: an installed mod can read only its own folder and write only its own storage, so it cannot reach the rest of your device, and mods that need the internet or heavy background work do it through permissions the mod manager shows you, without freezing the game
|
||||
* **Improved launcher and save editor UI**, including background downloads and update checks
|
||||
* **Direct-launch options** for shortcuts, Steam entries, and handheld frontends
|
||||
* **Custom boot branding**
|
||||
@@ -40,3 +40,4 @@ A fourth game the launcher can import and play, built from pret/pokegold the sam
|
||||
* **On-screen touch pad** and controller SELECT for registered items
|
||||
|
||||
|
||||
* **Older mods keep loading** after the sandbox change, through per-mod compat stand-ins for the pre-sandbox globals
|
||||
|
||||
@@ -76,15 +76,6 @@ end
|
||||
local editorHost, editorVersion, editorWindow
|
||||
local closeEditor -- forward declaration: openEditor hands it to the editor
|
||||
|
||||
-- tools/save-editor/ models the Gen 1 save and nothing else: a Gen 2 party row
|
||||
-- carries fields its MonOps and panels have no idea about (dvs, statExp,
|
||||
-- happiness, pokerus, caughtLevel), and SaveIO.save writes the WHOLE table
|
||||
-- back, so a Gold slot opened here comes out in a shape src/core/gen2/Save.lua
|
||||
-- then has to quarantine on the next boot. Refuse by name, the same way the
|
||||
-- .sav paths do (src/save_convert/SaveConvert.lua GEN2_SAV_UNSUPPORTED), so
|
||||
-- Red/Blue/Yellow slots are untouched.
|
||||
local GEN2_NO_EDITOR = { gold = "Pokemon Gold" }
|
||||
|
||||
-- The editor's modules use flat names (require("Kit"), require("Party")), so
|
||||
-- their directories have to be on the require path. It must be
|
||||
-- love.filesystem's path, not package.path: in a packaged build these files
|
||||
@@ -137,11 +128,6 @@ local function openEditor(version, slotId)
|
||||
Importer.saveNotice = Importer.saveNotice or {}
|
||||
Importer.saveNotice[version] = { ok = false, text = text }
|
||||
end
|
||||
local gen2Name = GEN2_NO_EDITOR[version]
|
||||
if gen2Name then
|
||||
refuse(gen2Name .. " uses a Gen 2 save; the save editor does not read one yet.")
|
||||
return
|
||||
end
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local path = SaveData.slotDiskPath(version, slotId)
|
||||
if not path then
|
||||
@@ -320,14 +306,6 @@ function love.load(args)
|
||||
-- cache has to be mounted before the editor's Data:load.
|
||||
if editorMode then
|
||||
local version = os.getenv("POKEPORT_VERSION") or "red"
|
||||
local gen2Name = GEN2_NO_EDITOR[version]
|
||||
if gen2Name then
|
||||
-- No launcher behind this run to carry a notice, so say it and stop
|
||||
-- rather than open a Gen 2 slot on Gen 1 panels.
|
||||
print(gen2Name .. " uses a Gen 2 save; the save editor does not read one yet.")
|
||||
love.event.quit(1)
|
||||
return
|
||||
end
|
||||
require("src.core.GameVersion").set(version)
|
||||
require("src.import.CacheFs").mountVersion(version)
|
||||
addEditorRequirePath()
|
||||
|
||||
@@ -192,8 +192,14 @@ bool System::pickFile(const char *kind) const
|
||||
dest = "picked_mod.zip";
|
||||
else if (strcmp(kind, "sav") == 0 || strcmp(kind, "save") == 0)
|
||||
dest = "picked_save.sav";
|
||||
else if (strcmp(kind, "required_import") == 0)
|
||||
dest = "picked_required_import.bin";
|
||||
else if (strcmp(kind, "rom") == 0)
|
||||
dest = "picked_rom.gb";
|
||||
// Unknown kinds used to fall through to the ROM destination. Refuse them
|
||||
// so a newer Lua caller cannot silently route an unrelated file as a ROM.
|
||||
else
|
||||
return false;
|
||||
}
|
||||
return love::android::showFilePicker(dest);
|
||||
#else
|
||||
@@ -202,6 +208,15 @@ bool System::pickFile(const char *kind) const
|
||||
#endif
|
||||
}
|
||||
|
||||
const char *System::pickFileKinds() const
|
||||
{
|
||||
#ifdef LOVE_ANDROID
|
||||
return "rom,mod,sav,required_import";
|
||||
#else
|
||||
return "";
|
||||
#endif
|
||||
}
|
||||
|
||||
bool System::createFile(const char *suggestedName) const
|
||||
{
|
||||
#ifdef LOVE_ANDROID
|
||||
|
||||
@@ -112,10 +112,12 @@ public:
|
||||
* love::android::showFilePicker and src/import/RomImporter.lua.
|
||||
*
|
||||
* @param kind Optional pick kind: nullptr/"rom" -> picked_rom.gb,
|
||||
* "mod" -> picked_mod.zip, "sav"/"save" -> picked_save.sav.
|
||||
* "mod" -> picked_mod.zip, "sav"/"save" -> picked_save.sav,
|
||||
* "required_import" -> picked_required_import.bin.
|
||||
* @return Whether the picker was shown.
|
||||
**/
|
||||
virtual bool pickFile(const char *kind = nullptr) const;
|
||||
virtual const char *pickFileKinds() const;
|
||||
|
||||
/**
|
||||
* Shows the platform's native "create / save a file" UI (Android SAF
|
||||
|
||||
@@ -102,6 +102,12 @@ int w_pickFile(lua_State *L)
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_pickFileKinds(lua_State *L)
|
||||
{
|
||||
luax_pushstring(L, instance()->pickFileKinds());
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_createFile(lua_State *L)
|
||||
{
|
||||
const char *suggested = luaL_optstring(L, 1, nullptr);
|
||||
@@ -222,6 +228,7 @@ static const luaL_Reg functions[] =
|
||||
{ "openURL", w_openURL },
|
||||
{ "vibrate", w_vibrate },
|
||||
{ "pickFile", w_pickFile },
|
||||
{ "pickFileKinds", w_pickFileKinds },
|
||||
{ "createFile", w_createFile },
|
||||
{ "syncHealthSteps", w_syncHealthSteps },
|
||||
{ "restartApp", w_restartApp },
|
||||
|
||||
@@ -12,6 +12,41 @@
|
||||
"tintColor": "3b5ca8",
|
||||
"category": "games",
|
||||
"versions": [
|
||||
{
|
||||
"version": "0.1.91",
|
||||
"date": "2026-08-15",
|
||||
"size": 11351477,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.91/gen1recomp++-0.1.91-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.90",
|
||||
"date": "2026-08-15",
|
||||
"size": 11344338,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.90/gen1recomp++-0.1.90-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @1Jamie\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.89",
|
||||
"date": "2026-08-15",
|
||||
"size": 11343841,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.89/gen1recomp++-0.1.89-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @1Jamie\n- @anxiousintrovert\n- @AverageConsumer\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.88",
|
||||
"date": "2026-08-14",
|
||||
"size": 11311237,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.88/gen1recomp++-0.1.88-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.87",
|
||||
"date": "2026-08-14",
|
||||
"size": 11310910,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.87/gen1recomp++-0.1.87-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @AverageConsumer\n- @bryanthaboi\n- @mleo2003\n- @ShaneMcGovernIE\n- MaxTomahawk"
|
||||
},
|
||||
{
|
||||
"version": "0.1.86",
|
||||
"date": "2026-08-14",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// without updating that patch (see mobile/ios/patch_love_src.py).
|
||||
//
|
||||
// Contract (mirrors love-android's GameActivity.showFilePicker):
|
||||
// love.system.pickFile("rom"|"mod"|"sav") -> copies the user's pick into
|
||||
// love.system.pickFile("rom"|"mod"|"sav"|"required_import") -> copies the user's pick into
|
||||
// the LÖVE save directory as picked_rom.gb / picked_mod.zip /
|
||||
// picked_save.sav; RomImporter's pending-file scan consumes it.
|
||||
// love.system.createFile(name) -> exports save dir's pending_export.sav
|
||||
@@ -83,6 +83,8 @@ public final class GRPickerBridge: NSObject {
|
||||
types = [.zip]
|
||||
case "sav":
|
||||
destName = "picked_save.sav"
|
||||
case "required_import":
|
||||
destName = "picked_required_import.bin"
|
||||
// A Nintendo 64 cartridge, for mods that build assets out of one --
|
||||
// the voxel mod's Pokemon Stadium battle models are the caller this
|
||||
// was added for. Its own filename on purpose: an N64 ROM landing on
|
||||
@@ -135,7 +137,7 @@ public final class GRPickerBridge: NSObject {
|
||||
// Kept beside the switch it describes, because the two drifting apart is
|
||||
// the only way this can lie.
|
||||
@objc public static func supportedPickerKinds() -> NSString {
|
||||
return "rom,mod,sav,stadium" as NSString
|
||||
return "rom,mod,sav,stadium,required_import" as NSString
|
||||
}
|
||||
|
||||
@objc(presentExportWithName:saveDir:)
|
||||
|
||||
@@ -89,7 +89,8 @@ int w_pickFile(lua_State *L)
|
||||
return gr_callBridge(L, "GRPickerBridge", "presentPickerWithKind:saveDir:", kind);
|
||||
}
|
||||
|
||||
// love.system.pickFileKinds() -> "rom,mod,sav,stadium", or nil off iOS.
|
||||
// love.system.pickFileKinds() -> the comma-separated kinds supported by the
|
||||
// Swift bridge (including required_import), or nil off iOS.
|
||||
//
|
||||
// So a caller can ask what this build's picker understands BEFORE opening it.
|
||||
// An unknown kind is refused (GRPickerBridge), and a refusal looks exactly
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+5
-2
@@ -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
|
||||
|
||||
@@ -325,7 +325,8 @@ function HostShell.httpDownload(url, absPath, userAgent, accept, maxTime)
|
||||
if type(absPath) ~= "string" or absPath == "" then return nil, "missing path" end
|
||||
userAgent = userAgent or "gen1recomp"
|
||||
if HostShell.haveCurl() then
|
||||
local cmd = ("curl -fsSL --connect-timeout 15 --max-time %d ")
|
||||
local cmd = ("curl -fsSL --proto =http,https --proto-redir =http,https "
|
||||
.. "--connect-timeout 15 --max-time %d ")
|
||||
:format(tonumber(maxTime) or 300)
|
||||
.. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " "
|
||||
if accept then
|
||||
@@ -370,7 +371,8 @@ function HostShell.httpGet(url, userAgent, accept, maxTime)
|
||||
-- BODY, and on the two services this talks to that body is the whole
|
||||
-- diagnosis: GitHub's 403 says "API rate limit exceeded for <ip>", which
|
||||
-- tells a user to wait rather than to go hunting for a broken index.
|
||||
local cmd = ("curl -sSL --connect-timeout 10 --max-time %d ")
|
||||
local cmd = ("curl -sSL --proto =http,https --proto-redir =http,https "
|
||||
.. "--connect-timeout 10 --max-time %d ")
|
||||
:format(tonumber(maxTime) or 40)
|
||||
.. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " "
|
||||
if accept then
|
||||
|
||||
+49
-2
@@ -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
@@ -0,0 +1,50 @@
|
||||
-- ROM extraction, off the main thread. The extractor's require closure needs
|
||||
-- only love.filesystem, love.image, love.math and love.system, none of which
|
||||
-- are main-thread-only, so it runs here instead of as a coroutine the frame
|
||||
-- loop resumed for 8ms out of every 16.7ms.
|
||||
--
|
||||
-- The caller clears the stale cache and writes the completion marker itself;
|
||||
-- this only fills the tree between those two steps, so the "marker appears
|
||||
-- last" order isReady() depends on stays on one thread.
|
||||
|
||||
require("love.filesystem")
|
||||
require("love.image")
|
||||
require("love.math")
|
||||
require("love.system")
|
||||
require("love.timer")
|
||||
|
||||
local version, prefix, romData, progressName, resultName = ...
|
||||
|
||||
local progressChannel = love.thread.getChannel(progressName)
|
||||
local resultChannel = love.thread.getChannel(resultName)
|
||||
|
||||
-- RomExtractor:tick fires per item, thousands of times per import; a channel
|
||||
-- push each would cost more than the work it reports.
|
||||
local PROGRESS_HZ = 20
|
||||
|
||||
local ok, err = pcall(function()
|
||||
local CacheFs = require("src.import.CacheFs")
|
||||
CacheFs.prefix = prefix
|
||||
|
||||
local manifest = require("src.import.RomManifest").decode(version)
|
||||
local RomExtractor = version == "gold"
|
||||
and require("src.import.RomExtractorGen2")
|
||||
or require("src.import.RomExtractor")
|
||||
|
||||
local lastPush, lastStage = 0, nil
|
||||
local extractor = RomExtractor.new(romData, manifest,
|
||||
function(progress, total, stage, current, stageTotal)
|
||||
local now = love.timer.getTime()
|
||||
-- Stage changes always go through, or the caption goes stale.
|
||||
if stage ~= lastStage or now - lastPush >= 1 / PROGRESS_HZ then
|
||||
lastPush, lastStage = now, stage
|
||||
progressChannel:push({
|
||||
progress = progress, total = total, stage = stage,
|
||||
current = current, stageTotal = stageTotal,
|
||||
})
|
||||
end
|
||||
end)
|
||||
extractor:run()
|
||||
end)
|
||||
|
||||
resultChannel:push({ ok = ok, error = ok and nil or tostring(err) })
|
||||
+516
-139
@@ -630,6 +630,7 @@ end
|
||||
|
||||
local function modStatusColor(status)
|
||||
if status == "ok" then return Strings("Ready"), PAL.green end
|
||||
if status == "needs_import" then return Strings("Import required"), PAL.yellow end
|
||||
if status == "conflict" then return Strings("Conflict"), PAL.red end
|
||||
-- not a fault: the mod is intact, this is simply not a game it is for
|
||||
-- (src/mods/ModTargets.lua)
|
||||
@@ -638,14 +639,8 @@ local function modStatusColor(status)
|
||||
end
|
||||
|
||||
-- MODS panel scope row: which game the list is answering for, plus dedicated Profile control (cycle + gear).
|
||||
local function buildModScopeRow(imp, x, y, w, m)
|
||||
local function modScopeOptions(imp)
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
local h = math.max(Kit.tapMin(), math.floor(26 * m.s))
|
||||
local gap = math.floor(6 * m.s)
|
||||
local label = Strings("Show for:")
|
||||
Kit.text("small", label, x, y + (h - Kit.textHeight("small")) / 2, PAL.muted)
|
||||
local cx = x + Kit.textWidth("small", label) + math.floor(10 * m.s)
|
||||
local options = { { id = nil, label = Strings("All games") } }
|
||||
for _, version in ipairs(GameVersion.ORDER) do
|
||||
if imp.ready and imp.ready[version] then
|
||||
@@ -653,6 +648,33 @@ local function buildModScopeRow(imp, x, y, w, m)
|
||||
{ id = version, label = GameVersion.info(version).label }
|
||||
end
|
||||
end
|
||||
return options
|
||||
end
|
||||
|
||||
local function modScopeCurrentLabel(imp, options)
|
||||
for _, opt in ipairs(options) do
|
||||
if imp.modScope == opt.id then return opt.label end
|
||||
end
|
||||
return options[1] and options[1].label or Strings("All games")
|
||||
end
|
||||
|
||||
local function modScopeChipsWidth(options, gap, m)
|
||||
local need = 0
|
||||
for i, opt in ipairs(options) do
|
||||
need = need + Kit.textWidth("micro", opt.label) + math.floor(18 * m.s)
|
||||
if i < #options then need = need + gap end
|
||||
end
|
||||
return need
|
||||
end
|
||||
|
||||
local function buildModScopeRow(imp, x, y, w, m)
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
local h = math.max(Kit.tapMin(), math.floor(26 * m.s))
|
||||
local gap = math.floor(6 * m.s)
|
||||
local label = Strings("Show for:")
|
||||
Kit.text("small", label, x, y + (h - Kit.textHeight("small")) / 2, PAL.muted)
|
||||
local cx = x + Kit.textWidth("small", label) + math.floor(10 * m.s)
|
||||
local options = modScopeOptions(imp)
|
||||
|
||||
-- Dedicated Profile control section (cycle button + gear icon button) on right side of Scope Bar
|
||||
local _, activeProf = LauncherMods.getProfiles()
|
||||
@@ -690,9 +712,12 @@ local function buildModScopeRow(imp, x, y, w, m)
|
||||
})
|
||||
|
||||
if #options >= 2 then
|
||||
for _, opt in ipairs(options) do
|
||||
local cw = Kit.textWidth("micro", opt.label) + math.floor(18 * m.s)
|
||||
if cx + cw <= profX - gap then
|
||||
local avail = profX - gap - cx
|
||||
-- Chips stay when they all fit; otherwise they used to be skipped and
|
||||
-- vanish off the portrait edge. Collapse to one menu in that case only.
|
||||
if modScopeChipsWidth(options, gap, m) <= avail then
|
||||
for _, opt in ipairs(options) do
|
||||
local cw = Kit.textWidth("micro", opt.label) + math.floor(18 * m.s)
|
||||
if Kit.chip(cx, y, cw, h, opt.label, imp.modScope == opt.id, PAL.lineStrong,
|
||||
"mod-scope-" .. tostring(opt.id or "all")) then
|
||||
local want = opt.id
|
||||
@@ -701,6 +726,15 @@ local function buildModScopeRow(imp, x, y, w, m)
|
||||
end
|
||||
cx = cx + cw + gap
|
||||
end
|
||||
elseif avail > 0 then
|
||||
local shown = Kit.ellipsize("micro", modScopeCurrentLabel(imp, options),
|
||||
math.max(0, avail - math.floor(18 * m.s)))
|
||||
local cw = math.min(avail,
|
||||
Kit.textWidth("micro", shown) + math.floor(18 * m.s))
|
||||
if Kit.chip(cx, y, cw, h, shown, true, PAL.lineStrong, "mod-scope-menu") then
|
||||
queueAction(imp, "mod-scope-menu",
|
||||
function() imp._modScopePopup = true end)
|
||||
end
|
||||
end
|
||||
end
|
||||
return h + math.floor(8 * m.s)
|
||||
@@ -740,8 +774,8 @@ local function setPage(imp, key, v)
|
||||
imp._pages[key] = v
|
||||
end
|
||||
|
||||
-- A hand-drawn X, for the same reason drawCheck exists below: the UI font has
|
||||
-- no guaranteed glyph, and the launcher ships no icon asset for it.
|
||||
-- A hand-drawn X / check: the UI font has no guaranteed glyph for either,
|
||||
-- and the launcher ships no icon asset for them.
|
||||
local function drawCross(x, y, size, color)
|
||||
love.graphics.push("all")
|
||||
love.graphics.setColor(color)
|
||||
@@ -752,11 +786,66 @@ local function drawCross(x, y, size, color)
|
||||
love.graphics.pop()
|
||||
end
|
||||
|
||||
local function drawCheck(x, y, size, color)
|
||||
love.graphics.push("all")
|
||||
love.graphics.setColor(color)
|
||||
love.graphics.setLineWidth(math.max(2.2, size * 0.17))
|
||||
love.graphics.setLineJoin("bevel")
|
||||
love.graphics.line(
|
||||
x + size * 0.02, y + size * 0.52,
|
||||
x + size * 0.38, y + size * 0.80,
|
||||
x + size * 1.015, y + size * 0.18)
|
||||
love.graphics.pop()
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------- header
|
||||
-- Rail, logo row (settings and quit on the right), tab bar.
|
||||
-- Returns the y at which content may start. Its vertical arithmetic is
|
||||
-- mirrored by headerHeight() at the bottom of this file (the short-window
|
||||
-- scroll decision needs the height before anything draws) -- keep in sync.
|
||||
-- Header chrome is fixed: the same six tabs, the same gear and Quit, every
|
||||
-- frame. Their tab rows, opts tables and action closures are built once
|
||||
-- instead of 60 times a second -- only `active`, `image` and the queued
|
||||
-- action are written per frame.
|
||||
local HEADER_TABS = {
|
||||
{ id = "red", key = "tab-red", letter = "R", color = PAL.railRed },
|
||||
{ id = "blue", key = "tab-blue", letter = "B", color = PAL.railBlue },
|
||||
{ id = "yellow", key = "tab-yellow", letter = "Y", color = PAL.railGold },
|
||||
{ id = "gold", key = "tab-gold", letter = "G", color = PAL.railAmber },
|
||||
{ id = "mods", key = "tab-mods" },
|
||||
{ id = "find", key = "tab-find" },
|
||||
}
|
||||
for _, t in ipairs(HEADER_TABS) do
|
||||
t.opts = { face = "tab", font = "tab", color = t.color, letter = t.letter }
|
||||
end
|
||||
|
||||
local QUIT_INK_HOT = { 0, 0, 0, 1 }
|
||||
local QUIT_INK_REST = { 1, 1, 1, 0.85 }
|
||||
|
||||
-- Keyed off the launcher instance so the closures die with it.
|
||||
local function headerChrome(imp)
|
||||
local c = imp._headerChrome
|
||||
if c then return c end
|
||||
c = {
|
||||
gear = { face = "invert",
|
||||
action = function() imp:_openSettings() end },
|
||||
quit = { face = "invert",
|
||||
action = function() imp:_quitApp() end,
|
||||
drawFn = function(x, y, w, h, hot)
|
||||
local pad = math.floor(w * 0.32)
|
||||
drawCross(x + pad, y + pad, w - 2 * pad,
|
||||
hot and QUIT_INK_HOT or QUIT_INK_REST)
|
||||
end },
|
||||
tab = {},
|
||||
}
|
||||
for _, t in ipairs(HEADER_TABS) do
|
||||
local id = t.id
|
||||
c.tab[id] = function() imp:_switchTab(id) end
|
||||
end
|
||||
imp._headerChrome = c
|
||||
return c
|
||||
end
|
||||
|
||||
local function buildHeader(imp, m)
|
||||
local y = m.top
|
||||
Theme.versionRail(m.x, y, m.w, m.railH)
|
||||
@@ -815,20 +904,11 @@ local function buildHeader(imp, m)
|
||||
imp._gearIcon = imp._gearIcon
|
||||
or love.graphics.newImage("assets/launcher/gear.png")
|
||||
rx = rx - gear
|
||||
btn(imp, rx, by, gear, gear, "gear", "", {
|
||||
face = "invert", image = imp._gearIcon,
|
||||
action = function() imp:_openSettings() end,
|
||||
})
|
||||
local chrome = headerChrome(imp)
|
||||
chrome.gear.image = imp._gearIcon
|
||||
btn(imp, rx, by, gear, gear, "gear", "", chrome.gear)
|
||||
|
||||
btn(imp, quitX, by, gear, gear, "quit", "", {
|
||||
face = "invert",
|
||||
action = function() imp:_quitApp() end,
|
||||
drawFn = function(x, y, w, h, hot)
|
||||
local pad = math.floor(w * 0.32)
|
||||
drawCross(x + pad, y + pad, w - 2 * pad,
|
||||
hot and { 0, 0, 0, 1 } or { 1, 1, 1, 0.85 })
|
||||
end,
|
||||
})
|
||||
btn(imp, quitX, by, gear, gear, "quit", "", chrome.quit)
|
||||
|
||||
-- The self-update control lives in the FOOTER next to the BCG mark (small,
|
||||
-- out of the wordmark's way -- it used to overlap the logo on a phone). It
|
||||
@@ -846,14 +926,8 @@ local function buildHeader(imp, m)
|
||||
-- the fill when active, the same rule the buttons follow. Yellow stays the
|
||||
-- bright cart gold; Gold (Gen 2) uses the deeper amber so the two do not
|
||||
-- collide.
|
||||
local tabs = {
|
||||
{ id = "red", letter = "R", color = PAL.railRed },
|
||||
{ id = "blue", letter = "B", color = PAL.railBlue },
|
||||
{ id = "yellow", letter = "Y", color = PAL.railGold },
|
||||
{ id = "gold", letter = "G", color = PAL.railAmber },
|
||||
{ id = "mods", icon = imp._modsIcon },
|
||||
{ id = "find", icon = imp._findIcon },
|
||||
}
|
||||
local tabs = HEADER_TABS
|
||||
tabs[5].icon, tabs[6].icon = imp._modsIcon, imp._findIcon
|
||||
local tabH = m.chip
|
||||
local tx = m.x + m.pad
|
||||
local ty = y + math.floor(6 * m.s)
|
||||
@@ -862,18 +936,16 @@ local function buildHeader(imp, m)
|
||||
local tabGap = math.floor(6 * m.s)
|
||||
local tabRowGap = math.floor(4 * m.s)
|
||||
for _, t in ipairs(tabs) do
|
||||
local active = imp.tab == t.id
|
||||
local key = "tab-" .. t.id
|
||||
local w = tabH
|
||||
if tx > tabLeft and tx + w > tabRight then
|
||||
tx = tabLeft
|
||||
ty = ty + tabH + tabRowGap
|
||||
end
|
||||
btn(imp, tx, ty, w, tabH, key, "", {
|
||||
face = "tab", font = "tab", color = t.color, active = active,
|
||||
image = t.icon, letter = t.letter,
|
||||
action = function() imp:_switchTab(t.id) end,
|
||||
})
|
||||
local o = t.opts
|
||||
o.active = imp.tab == t.id
|
||||
o.image = t.icon
|
||||
o.action = chrome.tab[t.id]
|
||||
btn(imp, tx, ty, w, tabH, t.key, "", o)
|
||||
tx = tx + w + tabGap
|
||||
end
|
||||
|
||||
@@ -1316,20 +1388,32 @@ local function buildGamePanel(imp, x, y, w, availH, m, version)
|
||||
or tostring(version)
|
||||
local ready = (not locked) and imp.ready[version] or false
|
||||
|
||||
-- title + status tag
|
||||
-- title + status tag. Ready is a check chip (the font has no tick glyph);
|
||||
-- missing ROM stays a yellow "ROM REQUIRED" tag so it still reads as an action.
|
||||
local titleH = Kit.textHeight("title")
|
||||
Kit.text("title", Kit.ellipsize("title", gameName, w * 0.6), x, y, PAL.heading)
|
||||
local tagText, tagCol
|
||||
if ready then tagText, tagCol = Strings("GOOD TO GO"), PAL.green
|
||||
elseif imp.baseRoms and imp.baseRoms[version] then
|
||||
tagText, tagCol = Strings("ROM FOUND"), PAL.green
|
||||
elseif locked then tagText, tagCol = Strings("COMING SOON"), PAL.steel
|
||||
else tagText, tagCol = Strings("ROM REQUIRED"), PAL.yellow end
|
||||
local tagW = Kit.textWidth("micro", tagText) + math.floor(18 * m.s)
|
||||
local tagH = Kit.textHeight("micro") + math.floor(10 * m.s)
|
||||
local tagX = x + Kit.textWidth("title", Kit.ellipsize("title", gameName, w * 0.6))
|
||||
+ math.floor(12 * m.s)
|
||||
Kit.tag(tagX, y + (titleH - tagH) / 2, tagW, tagH, tagText, tagCol)
|
||||
local tagY = y + (titleH - tagH) / 2
|
||||
local tagW, tagCol
|
||||
if ready then
|
||||
tagCol = PAL.green
|
||||
tagW = tagH
|
||||
if love.graphics then
|
||||
Theme.strokeRounded(tagX, tagY, tagW, tagH, tagCol, 0.7, 1)
|
||||
local ck = math.floor(tagH * 0.55)
|
||||
drawCheck(tagX + (tagW - ck) / 2, tagY + (tagH - ck) / 2, ck, tagCol)
|
||||
end
|
||||
else
|
||||
local tagText
|
||||
if imp.baseRoms and imp.baseRoms[version] then
|
||||
tagText, tagCol = Strings("ROM FOUND"), PAL.green
|
||||
elseif locked then tagText, tagCol = Strings("COMING SOON"), PAL.steel
|
||||
else tagText, tagCol = Strings("ROM REQUIRED"), PAL.yellow end
|
||||
tagW = Kit.textWidth("micro", tagText) + math.floor(18 * m.s)
|
||||
Kit.tag(tagX, tagY, tagW, tagH, tagText, tagCol)
|
||||
end
|
||||
if ready then
|
||||
local hint = Strings("(PRESS THE CART TO PLAY)")
|
||||
local hintX = tagX + tagW + math.floor(10 * m.s)
|
||||
@@ -1337,8 +1421,11 @@ local function buildGamePanel(imp, x, y, w, availH, m, version)
|
||||
Kit.text("micro", Kit.ellipsize("micro", hint, hintW), hintX,
|
||||
y + (titleH - Kit.textHeight("micro")) / 2, PAL.heading)
|
||||
end
|
||||
local cy = y + titleH + math.floor(12 * m.s)
|
||||
local remaining = availH - (titleH + math.floor(12 * m.s))
|
||||
-- Extra gap under the title when the cart is showing: 12px left the 3D
|
||||
-- shell sitting on the hairline. Scaled, and still small on a phone.
|
||||
local afterTitle = math.floor((ready and 22 or 12) * m.s)
|
||||
local cy = y + titleH + afterTitle
|
||||
local remaining = availH - (titleH + afterTitle)
|
||||
|
||||
local gap = m.gap
|
||||
local lx, lw, rx2, rw
|
||||
@@ -1408,6 +1495,20 @@ end
|
||||
-- gets whatever width the previous ones left, and the first segment that has
|
||||
-- to ellipsize ends the line. Lets the download count sit green inside an
|
||||
-- otherwise muted stats line without two competing ellipsis passes.
|
||||
-- A row's control key is a pure function of its id, but concatenating it per
|
||||
-- visible row per frame is ~1200 strings a second. Memoised on the launcher,
|
||||
-- NOT on the entry: index entries are the same tables ModIndex.writeCache
|
||||
-- persists into options.modIndexCache, and view state must not ride along.
|
||||
local function rowKeyFor(imp, prefix, id)
|
||||
local keys = imp._rowKeys
|
||||
if not keys then keys = {}; imp._rowKeys = keys end
|
||||
local byPrefix = keys[prefix]
|
||||
if not byPrefix then byPrefix = {}; keys[prefix] = byPrefix end
|
||||
local key = byPrefix[id]
|
||||
if not key then key = prefix .. tostring(id); byPrefix[id] = key end
|
||||
return key
|
||||
end
|
||||
|
||||
local function segLine(fontName, segs, x, y, maxW)
|
||||
local sx = x
|
||||
for _, seg in ipairs(segs) do
|
||||
@@ -1433,6 +1534,55 @@ local function sortDefs()
|
||||
}
|
||||
end
|
||||
|
||||
-- Sorting is decorate-sort-undecorate: the key is computed once per entry
|
||||
-- instead of the 2*n*log(n) times a comparator that derives it would, and the
|
||||
-- comparator itself is a module-level function so no closure is allocated per
|
||||
-- comparison. Measured on a synthetic index: 500 entries went from 8,964 key
|
||||
-- computations and 4,482 closures to 500 and none.
|
||||
local sortAsc = true
|
||||
|
||||
local function decCompare(a, b)
|
||||
if a.k ~= b.k then
|
||||
if sortAsc then return a.k < b.k end
|
||||
return a.k > b.k -- data sorts newest / most popular first
|
||||
end
|
||||
return a.tie < b.tie
|
||||
end
|
||||
|
||||
-- Fill `scratch` with one { e, k, tie } slot per entry, reusing the slots.
|
||||
local function decorate(scratch, src, keyOf, tieOf)
|
||||
local n = #src
|
||||
for i = 1, n do
|
||||
local e = src[i]
|
||||
local slot = scratch[i]
|
||||
if not slot then slot = {}; scratch[i] = slot end
|
||||
slot.e, slot.tie = e, tieOf(e)
|
||||
slot.k = keyOf(e, slot.tie)
|
||||
end
|
||||
for i = #scratch, n + 1, -1 do scratch[i] = nil end
|
||||
return n
|
||||
end
|
||||
|
||||
local function undecorate(scratch, n)
|
||||
local out = {}
|
||||
for i = 1, n do out[i] = scratch[i].e end
|
||||
return out
|
||||
end
|
||||
|
||||
-- While results are still streaming in, re-ordering on every arrival re-sorts
|
||||
-- the whole list every frame and makes rows jump under the reader. Hold the
|
||||
-- current order this long and take the change in one pass.
|
||||
local RESORT_DEBOUNCE = 0.25
|
||||
|
||||
-- True when the cached order is still good. `rev` is only part of the key
|
||||
-- for a stats-dependent sort: Name order does not depend on release data, so
|
||||
-- a stats arrival used to invalidate a sort whose result could not change.
|
||||
local function sortCacheOk(cache, src, key, rev, pending)
|
||||
if not (cache and cache.src == src and cache.key == key) then return false end
|
||||
if cache.rev == rev then return true end
|
||||
return pending and (Kit.time - (cache.at or 0)) < RESORT_DEBOUNCE
|
||||
end
|
||||
|
||||
local function currentSort(imp)
|
||||
local sortKey = imp.modSort
|
||||
if sortKey == nil then
|
||||
@@ -1446,20 +1596,6 @@ local function currentSort(imp)
|
||||
return sortKey
|
||||
end
|
||||
|
||||
-- A hand-drawn check mark: the UI font has no guaranteed glyph for one, and
|
||||
-- a tofu box on the "you already have this" signal would be worse than none.
|
||||
local function drawCheck(x, y, size, color)
|
||||
love.graphics.push("all")
|
||||
love.graphics.setColor(color)
|
||||
love.graphics.setLineWidth(math.max(2.2, size * 0.17))
|
||||
love.graphics.setLineJoin("bevel")
|
||||
love.graphics.line(
|
||||
x + size * 0.02, y + size * 0.52,
|
||||
x + size * 0.38, y + size * 0.80,
|
||||
x + size * 1.015, y + size * 0.18)
|
||||
love.graphics.pop()
|
||||
end
|
||||
|
||||
-- One compact coloured checkbox for each game. The cartridge colour carries
|
||||
-- the game identity even when the row is narrow.
|
||||
local function modGameCheckbox(x, y, size, checked, game, id)
|
||||
@@ -1588,16 +1724,18 @@ local function buildModsPanel(imp, x, y, w, availH, m)
|
||||
-- per frame (with lowercased-string allocations in the comparator) fed the
|
||||
-- GC for nothing. Cache the sorted array, keyed on the list identity, the
|
||||
-- sort mode, and the update-info revision the fetch pump bumps.
|
||||
local statsSort = sortKey ~= "name"
|
||||
local rev = statsSort and (imp._modUpdateRev or 0) or 0
|
||||
local cache = imp._modSortCache
|
||||
if cache and cache.src == mods and cache.n == #mods
|
||||
and cache.key == sortKey and cache.rev == (imp._modUpdateRev or 0) then
|
||||
if cache and cache.n == #mods
|
||||
and sortCacheOk(cache, mods, sortKey, rev, imp._modInfoFetch ~= nil) then
|
||||
mods = cache.list
|
||||
else
|
||||
local sorted = {}
|
||||
for i, v in ipairs(mods) do sorted[i] = v end
|
||||
table.sort(sorted, function(a, b)
|
||||
local function value(mod)
|
||||
if sortKey == "name" then return (mod.name or ""):lower() end
|
||||
local scratch = imp._modSortScratch or {}
|
||||
imp._modSortScratch = scratch
|
||||
local n = decorate(scratch, mods,
|
||||
function(mod, tie)
|
||||
if sortKey == "name" then return tie end
|
||||
local info = mod.github and mod.github ~= "" and imp:_modUpdateInfo(mod.id)
|
||||
if sortKey == "popularity" then
|
||||
return info and info.downloads and info.downloads.total or -1
|
||||
@@ -1605,16 +1743,13 @@ local function buildModsPanel(imp, x, y, w, availH, m)
|
||||
local date = info and info.dates
|
||||
if sortKey == "release" then return date and date.first or "0000-00-00" end
|
||||
return date and date.latest or "0000-00-00"
|
||||
end
|
||||
local va, vb = value(a), value(b)
|
||||
if va ~= vb then
|
||||
if sortKey == "name" then return va < vb end
|
||||
return va > vb -- data sorts newest / most popular first
|
||||
end
|
||||
return (a.name or ""):lower() < (b.name or ""):lower()
|
||||
end)
|
||||
imp._modSortCache = { src = imp.mods, n = #mods, key = sortKey,
|
||||
rev = imp._modUpdateRev or 0, list = sorted }
|
||||
end,
|
||||
function(mod) return (mod.name or ""):lower() end)
|
||||
sortAsc = sortKey == "name"
|
||||
table.sort(scratch, decCompare)
|
||||
local sorted = undecorate(scratch, n)
|
||||
imp._modSortCache = { src = mods, n = #mods, key = sortKey,
|
||||
rev = rev, at = Kit.time, list = sorted }
|
||||
mods = sorted
|
||||
end
|
||||
|
||||
@@ -1637,7 +1772,9 @@ local function buildModsPanel(imp, x, y, w, availH, m)
|
||||
local contentH = shown * rowH + math.max(0, shown - 1) * gap
|
||||
local scrollMax = math.max(0, contentH - listH)
|
||||
local scroll = clamp(imp.modScroll or 0, 0, scrollMax)
|
||||
imp._modListRect = { x = x, y = listTop, w = w, h = listH }
|
||||
local lr = imp._modListRect
|
||||
if not lr then lr = {}; imp._modListRect = lr end
|
||||
lr.x, lr.y, lr.w, lr.h = x, listTop, w, listH
|
||||
imp._modScrollMax = scrollMax
|
||||
if scrollMax > 0 and (Kit.wheelY or 0) ~= 0 and Kit.hit(x, listTop, w, listH) then
|
||||
scroll = clamp(scroll - Kit.wheelY * math.floor(48 * m.s), 0, scrollMax)
|
||||
@@ -1653,7 +1790,7 @@ local function buildModsPanel(imp, x, y, w, availH, m)
|
||||
for i = first, last do
|
||||
local mod = mods[i]
|
||||
local ry = listTop + (i - first) * (rowH + gap) - scroll
|
||||
local rowKey = "mod-row-" .. mod.id
|
||||
local rowKey = rowKeyFor(imp, "mod-row-", mod.id)
|
||||
local isFullyDisabled = true
|
||||
if mod.enabledByVersion then
|
||||
for _, on in pairs(mod.enabledByVersion) do
|
||||
@@ -1845,30 +1982,30 @@ local function buildFindPanel(imp, x, y, w, availH, m)
|
||||
|
||||
-- Same caching rule as the MODS tab: the comparator allocates, so only
|
||||
-- re-sort when the inputs actually change.
|
||||
local statsSort = sortKey ~= "name"
|
||||
local rev = statsSort and (imp._findStatsRev or 0) or 0
|
||||
local fcache = imp._findSortCache
|
||||
if fcache and fcache.src == rows and fcache.key == sortKey
|
||||
and fcache.rev == (imp._findStatsRev or 0) then
|
||||
if sortCacheOk(fcache, rows, sortKey, rev, imp._findStatsPending ~= nil) then
|
||||
rows = fcache.list
|
||||
else
|
||||
local sorted = {}
|
||||
for i, v in ipairs(rows) do sorted[i] = v end
|
||||
table.sort(sorted, function(a, b)
|
||||
local function value(entry)
|
||||
if sortKey == "name" then return (entry.title or entry.id or ""):lower() end
|
||||
local stats = imp:_findStats(entry)
|
||||
local scratch = imp._findSortScratch or {}
|
||||
imp._findSortScratch = scratch
|
||||
local n = decorate(scratch, rows,
|
||||
function(entry, tie)
|
||||
if sortKey == "name" then return tie end
|
||||
-- The CACHED read, never the requesting one: a sort must not queue a
|
||||
-- fetch for every entry in the index (see _findStatsCached).
|
||||
local stats = imp:_findStatsCached(entry)
|
||||
if sortKey == "popularity" then return stats and stats.total or -1 end
|
||||
if sortKey == "release" then return stats and stats.first or "0000-00-00" end
|
||||
return stats and stats.latest or "0000-00-00"
|
||||
end
|
||||
local va, vb = value(a), value(b)
|
||||
if va ~= vb then
|
||||
if sortKey == "name" then return va < vb end
|
||||
return va > vb
|
||||
end
|
||||
return (a.title or a.id or ""):lower() < (b.title or b.id or ""):lower()
|
||||
end)
|
||||
imp._findSortCache = { src = rows, key = sortKey,
|
||||
rev = imp._findStatsRev or 0, list = sorted }
|
||||
end,
|
||||
function(entry) return (entry.title or entry.id or ""):lower() end)
|
||||
sortAsc = sortKey == "name"
|
||||
table.sort(scratch, decCompare)
|
||||
local sorted = undecorate(scratch, n)
|
||||
imp._findSortCache = { src = rows, key = sortKey, rev = rev,
|
||||
at = Kit.time, list = sorted }
|
||||
rows = sorted
|
||||
end
|
||||
|
||||
@@ -1897,7 +2034,7 @@ local function buildFindPanel(imp, x, y, w, availH, m)
|
||||
for i = first, last do
|
||||
local entry = rows[i]
|
||||
local ry = listTop + (i - first) * (rowH + gap)
|
||||
local rowKey = "find-row-" .. entry.id
|
||||
local rowKey = rowKeyFor(imp, "find-row-", entry.id)
|
||||
-- The whole row is the control: it opens the per-mod popup where
|
||||
-- Install / Details / Source moved. The only inline signal left is a
|
||||
-- green check when the mod is already installed.
|
||||
@@ -1930,8 +2067,15 @@ local function buildFindPanel(imp, x, y, w, availH, m)
|
||||
love.graphics.draw(image, Theme.snap(px), Theme.snap(ly), 0, s, s)
|
||||
else
|
||||
Theme.stroke(px, ly, thumb, thumb, PAL.line, Theme.A.hairline, 1)
|
||||
Kit.textCenter("micro", "MOD", px,
|
||||
ly + (thumb - Kit.textHeight("micro")) / 2, thumb, PAL.faint)
|
||||
-- A thumbnail still downloading and one that will never arrive drew the
|
||||
-- same dead box, so a slow index looked broken. Spin while it is in
|
||||
-- flight; only fall back to the wordmark once it has resolved.
|
||||
if imp:_findThumbPending(entry.id) then
|
||||
Kit.spinner(px + thumb / 2, ly + thumb / 2, thumb * 0.28)
|
||||
else
|
||||
Kit.textCenter("micro", "MOD", px,
|
||||
ly + (thumb - Kit.textHeight("micro")) / 2, thumb, PAL.faint)
|
||||
end
|
||||
end
|
||||
|
||||
local bx = px + thumb + math.floor(10 * m.s)
|
||||
@@ -1965,10 +2109,35 @@ local function buildFindPanel(imp, x, y, w, availH, m)
|
||||
segs[#segs + 1] = { " - " .. table.concat(rest, " - "), baseCol }
|
||||
end
|
||||
segLine("small", segs, bx, by2, bw)
|
||||
-- The stats line used to simply be absent until the release check landed,
|
||||
-- so rows silently changed under the reader and a slow check was
|
||||
-- indistinguishable from a mod with no data. Say which it is, the way
|
||||
-- the MODS tab already does on its own rows.
|
||||
if not stats and imp:_findStatsPendingFor(entry.id) then
|
||||
local sw = Kit.textWidth("small", segs[1][1]) + math.floor(12 * m.s)
|
||||
local dh = Kit.textHeight("small")
|
||||
Loader.dot(bx + sw, by2, dh)
|
||||
Kit.text("small", Strings("Checking..."),
|
||||
bx + sw + dh + math.floor(6 * m.s), by2, PAL.muted)
|
||||
end
|
||||
end
|
||||
|
||||
local pagerY = listTop + (last - first + 1) * (rowH + gap)
|
||||
setPage(imp, "find", Kit.pager(x, pagerY, w, cur, #rows, perPage, "find"))
|
||||
|
||||
-- Aggregate progress. Enrichment happens a page at a time and each row says
|
||||
-- so for itself, but with nothing summarising it the panel looked idle while
|
||||
-- work was in flight. Only drawn while something is actually pending.
|
||||
local waiting = imp:_findStatsPendingCount()
|
||||
if waiting > 0 then
|
||||
local py = pagerY + math.max(Kit.tapMin(), math.floor(30 * m.s))
|
||||
+ math.floor(4 * m.s)
|
||||
local dh = Kit.textHeight("micro")
|
||||
Loader.dot(x, py, dh)
|
||||
Kit.text("micro", Strings("Checking %d of %d on this page...",
|
||||
waiting, last - first + 1),
|
||||
x + dh + math.floor(6 * m.s), py, PAL.muted)
|
||||
end
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------ footer
|
||||
@@ -1978,21 +2147,47 @@ local TRUST_WARNING = "if you did not get this from bryanthaboi's github "
|
||||
.. "it might have been tampered with. go to the discord to verify "
|
||||
.. COMMUNITY_URL .. " (or click the logo above)"
|
||||
|
||||
-- Mark + optional updater + Patch notes. Chips match the mark's 22px
|
||||
-- height so they do not read as bigger than the logo; the row can still
|
||||
-- be tapMin tall for spacing. On a phone the notes chip drops onto a
|
||||
-- second row rather than overflowing the mark.
|
||||
local function footerLayout(imp, m, markW)
|
||||
local markH = math.floor(22 * m.s)
|
||||
local rowH = math.max(markH, Kit.tapMin())
|
||||
local notesLabel = Strings("Patch notes")
|
||||
-- Tight chip, still enough for Kit.button's labelInset so the words survive.
|
||||
local chipPad = math.floor(24 * m.s)
|
||||
local nw = Kit.textWidth("micro", notesLabel) + chipPad
|
||||
local upStatus, upLabel, upAction, upGlow = LauncherView._updateControl(imp)
|
||||
local uw = upStatus
|
||||
and (Kit.textWidth("micro", upLabel) + chipPad) or 0
|
||||
local gap = math.floor(10 * m.s)
|
||||
local inner = m.w - 2 * m.pad
|
||||
local topW = (markW or 0) + (upStatus and (gap + uw) or 0) + gap + nw
|
||||
return {
|
||||
rowH = rowH, chipH = markH, gap = gap,
|
||||
notesLabel = notesLabel, nw = nw,
|
||||
upStatus = upStatus, upLabel = upLabel, upAction = upAction, upGlow = upGlow,
|
||||
uw = uw, wrap = topW > inner,
|
||||
}
|
||||
end
|
||||
|
||||
-- Pinned to the bottom of the window; returns the y it starts at, so the
|
||||
-- panels above know how much room they have.
|
||||
-- Deliberately compact: at a large UI scale the footer is pure overhead
|
||||
-- competing with the panel for a short window's height, so the mark and the
|
||||
-- link share one line and the trust warning is capped at a single line.
|
||||
local function footerHeight(imp, m)
|
||||
-- Top pad + mark/update row + gap + the FULL wrapped trust message +
|
||||
-- bottom pad. The message wraps to as many lines as it needs: truncating
|
||||
-- a trust warning defeats its purpose, and the bottom pad is not optional
|
||||
-- either (without it the last line sits flush on the window edge and its
|
||||
-- lower half clips off). The row is tapMin tall because the small update
|
||||
-- button rides beside the mark.
|
||||
local rowH = math.max(math.floor(22 * m.s), Kit.tapMin())
|
||||
return math.floor(8 * m.s) + rowH + math.floor(6 * m.s)
|
||||
+ Kit.wrapHeight("micro", TRUST_WARNING, m.contentW)
|
||||
-- Top pad + mark/update row + optional notes wrap row + gap + the FULL
|
||||
-- wrapped trust message + bottom pad. The message wraps to as many lines
|
||||
-- as it needs: truncating a trust warning defeats its purpose, and the
|
||||
-- bottom pad is not optional either (without it the last line sits flush
|
||||
-- on the window edge and its lower half clips off). The row is tapMin
|
||||
-- tall because the small update button rides beside the mark.
|
||||
local f = footerLayout(imp, m, math.floor(130 * m.s))
|
||||
local h = math.floor(8 * m.s) + f.rowH + math.floor(6 * m.s)
|
||||
if f.wrap then h = h + f.chipH + math.floor(6 * m.s) end
|
||||
return h + Kit.wrapHeight("micro", TRUST_WARNING, m.contentW)
|
||||
+ math.floor(8 * m.s)
|
||||
end
|
||||
|
||||
@@ -2009,19 +2204,17 @@ local function buildFooter(imp, m, y)
|
||||
local bw, bh = imp.bcg:getDimensions()
|
||||
local scale = math.min((130 * m.s) / bw, (22 * m.s) / bh)
|
||||
local dw, dh = bw * scale, bh * scale
|
||||
local rowH = math.max(math.floor(22 * m.s), Kit.tapMin())
|
||||
-- The mark and the small self-update control share the row, centred as a
|
||||
-- group. The updater moved down here from the header, where it overlapped
|
||||
-- the wordmark on a phone; small on purpose, its glow still carries the
|
||||
-- "act on me" signal.
|
||||
local upStatus, upLabel, upAction, upGlow = LauncherView._updateControl(imp)
|
||||
-- Kit.button insets its label 16*scale per side, so the width must budget
|
||||
-- more than that or the label ellipsizes ("Check for updat...").
|
||||
local uw = upStatus
|
||||
and (Kit.textWidth("micro", upLabel) + math.floor(36 * m.s)) or 0
|
||||
local groupW = dw + (upStatus and (math.floor(10 * m.s) + uw) or 0)
|
||||
local bx = m.x + math.floor((m.w - groupW) / 2)
|
||||
local f = footerLayout(imp, m, dw)
|
||||
local rowH, gap, chipH = f.rowH, f.gap, f.chipH
|
||||
-- The mark, the small self-update control, and Patch notes share the row,
|
||||
-- centred as a group. The updater moved down here from the header, where
|
||||
-- it overlapped the wordmark on a phone; small on purpose, its glow still
|
||||
-- carries the "act on me" signal. Notes wrap under the mark on a phone.
|
||||
local topW = dw + (f.upStatus and (gap + f.uw) or 0)
|
||||
if not f.wrap then topW = topW + gap + f.nw end
|
||||
local bx = m.x + math.floor((m.w - topW) / 2)
|
||||
local my = cy + math.floor((rowH - dh) / 2)
|
||||
local chipY = cy + math.floor((rowH - chipH) / 2)
|
||||
local hot = Kit.hover(bx, my, dw, dh)
|
||||
love.graphics.setShader(imp.invertShader)
|
||||
love.graphics.setColor(1, 1, 1, hot and 1 or 0.85)
|
||||
@@ -2031,14 +2224,30 @@ local function buildFooter(imp, m, y)
|
||||
if Kit.press(bx, my, dw, dh) then
|
||||
queueAction(imp, "bcg", function() love.system.openURL(COMMUNITY_URL) end)
|
||||
end
|
||||
if upStatus then
|
||||
btn(imp, bx + dw + math.floor(10 * m.s), cy, uw, rowH, "updater",
|
||||
upLabel, {
|
||||
kind = upGlow and "warn" or "ghost", font = "micro",
|
||||
glow = upGlow, action = upAction,
|
||||
local cx = bx + dw
|
||||
if f.upStatus then
|
||||
cx = cx + gap
|
||||
btn(imp, cx, chipY, f.uw, chipH, "updater",
|
||||
f.upLabel, {
|
||||
kind = f.upGlow and "warn" or "ghost", font = "micro",
|
||||
glow = f.upGlow, action = f.upAction,
|
||||
})
|
||||
cx = cx + f.uw
|
||||
end
|
||||
local function notesBtn(x, y)
|
||||
btn(imp, x, y, f.nw, chipH, "patch-notes", f.notesLabel, {
|
||||
kind = "ghost", font = "micro",
|
||||
action = function() imp._appPatchNotes = true end,
|
||||
})
|
||||
end
|
||||
if f.wrap then
|
||||
cy = cy + rowH + gap
|
||||
notesBtn(m.x + math.floor((m.w - f.nw) / 2), cy)
|
||||
cy = cy + chipH + math.floor(6 * m.s)
|
||||
else
|
||||
notesBtn(cx + gap, chipY)
|
||||
cy = cy + rowH + math.floor(6 * m.s)
|
||||
end
|
||||
cy = cy + rowH + math.floor(6 * m.s)
|
||||
-- The trust message wraps in full, each line centred under the mark, and
|
||||
-- the URL inside it IS the link -- no separate link floating elsewhere.
|
||||
-- font:getWrap never splits an unspaced word, so the URL stays whole on
|
||||
@@ -2543,6 +2752,34 @@ local function buildSortModal(imp, m)
|
||||
action = function() imp._sortPopup = nil end })
|
||||
end
|
||||
|
||||
-- Game-scope chooser used when the Show-for chips cannot all fit on the
|
||||
-- mods toolbar (portrait phones). Same options as the chip row.
|
||||
local function buildModScopeModal(imp, m)
|
||||
local options = modScopeOptions(imp)
|
||||
local pad = math.floor(18 * m.s)
|
||||
local w = math.floor(360 * m.s)
|
||||
local gap = math.floor(8 * m.s)
|
||||
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s)
|
||||
+ #options * (m.btnH + gap) + m.btnH + pad
|
||||
local px, py, pw = modalPanel(m, w, h)
|
||||
local cy = py + pad
|
||||
Kit.text("button", Strings("Show for"), px + pad, cy, PAL.heading)
|
||||
cy = cy + Kit.textHeight("button") + math.floor(12 * m.s)
|
||||
for _, opt in ipairs(options) do
|
||||
local key = tostring(opt.id or "all")
|
||||
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "scopepop-" .. key, opt.label, {
|
||||
kind = (imp.modScope == opt.id) and "primary" or "ghost", font = "small",
|
||||
action = function()
|
||||
imp:_setModScope(opt.id)
|
||||
imp._modScopePopup = nil
|
||||
end })
|
||||
cy = cy + m.btnH + gap
|
||||
end
|
||||
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "scopepop-close",
|
||||
Strings("Close"), { font = "small",
|
||||
action = function() imp._modScopePopup = nil end })
|
||||
end
|
||||
|
||||
-- Category filter for FIND MODS. Two columns, because an index can list
|
||||
-- enough categories to overflow a single stacked column on a short window.
|
||||
local function buildFilterModal(imp, m)
|
||||
@@ -2645,11 +2882,14 @@ local function buildModActionsModal(imp, m)
|
||||
local hasGit = mod.github and mod.github ~= ""
|
||||
local depSpecs = mod.dependencySpecs or (mod.manifest and mod.manifest.dependencySpecs)
|
||||
local hasDeps = depSpecs and #depSpecs > 0
|
||||
local imports = mod.imports or mod.requiredImports
|
||||
local hasImports = imports and #imports > 0
|
||||
local info = hasGit and imp:_modUpdateInfo(mod.id)
|
||||
local pad = math.floor(18 * m.s)
|
||||
local w = math.floor(440 * m.s)
|
||||
local gap = math.floor(8 * m.s)
|
||||
local nBtns = (hasGit and 2 or 0) + (hasDeps and 1 or 0) + 2
|
||||
local nBtns = (hasGit and 2 or 0) + (hasDeps and 1 or 0)
|
||||
+ (hasImports and 1 or 0) + 2
|
||||
local h = pad + Kit.textHeight("button") + math.floor(4 * m.s)
|
||||
+ Kit.textHeight("small") + math.floor(12 * m.s)
|
||||
+ nBtns * (m.btnH + gap) - gap + pad
|
||||
@@ -2699,6 +2939,19 @@ local function buildModActionsModal(imp, m)
|
||||
end })
|
||||
cy = cy + m.btnH + gap
|
||||
end
|
||||
if hasImports then
|
||||
local missing = tonumber(mod.missingRequiredImports) or 0
|
||||
local label = missing > 0
|
||||
and Strings("Imported files (%d required)", missing)
|
||||
or Strings("Imported files")
|
||||
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "modact-imports",
|
||||
label, { kind = missing > 0 and "warn" or "accent", font = "small",
|
||||
action = function()
|
||||
imp._modImports = id
|
||||
imp._modActions = nil
|
||||
end })
|
||||
cy = cy + m.btnH + gap
|
||||
end
|
||||
local armed = deleteArmed(imp, "mod", id, nil)
|
||||
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "modact-del",
|
||||
DELETE_LABEL(armed), {
|
||||
@@ -2715,6 +2968,109 @@ local function buildModActionsModal(imp, m)
|
||||
action = function() imp._modActions = nil end })
|
||||
end
|
||||
|
||||
-- Imported files declared by one installed mod. The engine picks, validates,
|
||||
-- canonicalizes and copies; this surface never exposes a host path to mod code.
|
||||
local function buildRequiredImportsModal(imp, m)
|
||||
local mod
|
||||
for _, candidate in ipairs(imp.mods or {}) do
|
||||
if candidate.id == imp._modImports then mod = candidate break end
|
||||
end
|
||||
if not mod then imp._modImports = nil return end
|
||||
local imports = mod.imports or mod.requiredImports or {}
|
||||
local pad, gap = math.floor(18 * m.s), math.floor(8 * m.s)
|
||||
local w = math.floor(540 * m.s)
|
||||
local notice = imp.requiredImportNotice
|
||||
if not notice or notice.modId ~= mod.id then notice = nil end
|
||||
local noticeText
|
||||
if notice then
|
||||
local importName = notice.importId
|
||||
for _, row in ipairs(imports) do
|
||||
if row.id == notice.importId then importName = row.name break end
|
||||
end
|
||||
noticeText = Strings("%s rejected: %s", importName, notice.text)
|
||||
end
|
||||
local noticeW = w - 2 * pad
|
||||
local noticeH = noticeText and Kit.wrapHeight("small", noticeText, noticeW, 2) or 0
|
||||
local rowH = math.max(math.floor(70 * m.s), m.btnH)
|
||||
local perPage = math.min(4, math.max(1, #imports))
|
||||
local pagerH = #imports > perPage and math.max(Kit.tapMin(), math.floor(30 * m.s)) or 0
|
||||
local h = pad + Kit.textHeight("button") + math.floor(4 * m.s)
|
||||
+ Kit.textHeight("small") + math.floor(12 * m.s)
|
||||
+ noticeH + (noticeH > 0 and gap or 0)
|
||||
+ perPage * rowH + math.max(0, perPage - 1) * gap
|
||||
+ (pagerH > 0 and (gap + pagerH) or 0) + gap + m.btnH + pad
|
||||
local px, py, pw = modalPanel(m, w, h)
|
||||
local cy = py + pad
|
||||
Kit.text("button", Kit.ellipsize("button", mod.name, pw - 2 * pad),
|
||||
px + pad, cy, PAL.heading)
|
||||
cy = cy + Kit.textHeight("button") + math.floor(4 * m.s)
|
||||
Kit.text("small", Strings("User-supplied files are validated by MD5 and copied into this mod only."),
|
||||
px + pad, cy, PAL.muted)
|
||||
cy = cy + Kit.textHeight("small") + math.floor(12 * m.s)
|
||||
if noticeText then
|
||||
cy = cy + Kit.textWrapped("small", noticeText, px + pad, cy,
|
||||
pw - 2 * pad, PAL.red, 2) + gap
|
||||
end
|
||||
|
||||
local pageKey = "required-imports-" .. mod.id
|
||||
local cur = page(imp, pageKey)
|
||||
local first, last, bounded = Kit.pageBounds(cur, #imports, perPage)
|
||||
setPage(imp, pageKey, bounded)
|
||||
for i = first, last do
|
||||
local row = imports[i]
|
||||
local importId = row.id
|
||||
Kit.card(px + pad, cy, pw - 2 * pad, rowH, row.present and "muted" or false)
|
||||
local innerX = px + pad + math.floor(12 * m.s)
|
||||
local actionW = math.floor(108 * m.s)
|
||||
local removeW = row.present and math.floor(86 * m.s) or 0
|
||||
local actionX = px + pw - pad - math.floor(10 * m.s) - actionW
|
||||
if removeW > 0 then actionX = actionX - removeW - math.floor(6 * m.s) end
|
||||
local textW = actionX - innerX - math.floor(8 * m.s)
|
||||
Kit.text("small", Kit.ellipsize("small", row.name, textW), innerX,
|
||||
cy + math.floor(8 * m.s), PAL.heading)
|
||||
local stateY = cy + math.floor(8 * m.s) + Kit.textHeight("small")
|
||||
+ math.floor(3 * m.s)
|
||||
if row.description and row.description ~= "" then
|
||||
Kit.text("micro", Kit.ellipsize("micro", row.description, textW),
|
||||
innerX, stateY, PAL.muted)
|
||||
stateY = stateY + Kit.textHeight("micro") + math.floor(2 * m.s)
|
||||
end
|
||||
local state = row.present and Strings("Ready - %s", row.file)
|
||||
or (row.error and Strings("Invalid file - choose again")
|
||||
or (row.required and Strings("Required - %s", row.file)
|
||||
or Strings("Optional - %s", row.file)))
|
||||
Kit.text("micro", Kit.ellipsize("micro", state, textW), innerX, stateY,
|
||||
row.present and PAL.green or (row.required and PAL.yellow or PAL.muted))
|
||||
btn(imp, actionX, cy + (rowH - m.btnH) / 2, actionW, m.btnH,
|
||||
"req-pick-" .. mod.id .. "-" .. importId,
|
||||
row.present and Strings("Replace") or Strings("Choose file"), {
|
||||
kind = row.present and "ghost" or "accent", font = "small",
|
||||
action = function() imp:chooseRequiredImport(mod.id, importId) end })
|
||||
if row.present then
|
||||
local deleteId = mod.id .. ":" .. importId
|
||||
local armed = deleteArmed(imp, "required-import", deleteId, nil)
|
||||
btn(imp, actionX + actionW + math.floor(6 * m.s),
|
||||
cy + (rowH - m.btnH) / 2, removeW, m.btnH,
|
||||
"req-remove-" .. mod.id .. "-" .. row.id, DELETE_LABEL(armed), {
|
||||
kind = "danger", font = "small", keepArm = true,
|
||||
action = function()
|
||||
imp:pressDelete("required-import", deleteId, nil, function()
|
||||
imp:_removeRequiredImport(mod.id, importId)
|
||||
end)
|
||||
end })
|
||||
end
|
||||
cy = cy + rowH + gap
|
||||
end
|
||||
if pagerH > 0 then
|
||||
local newPage = Kit.pager(px + pad, cy, pw - 2 * pad, bounded,
|
||||
#imports, perPage, pageKey)
|
||||
setPage(imp, pageKey, newPage)
|
||||
cy = cy + pagerH + gap
|
||||
end
|
||||
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "req-close", Strings("Close"), {
|
||||
font = "small", action = function() imp._modImports = nil end })
|
||||
end
|
||||
|
||||
-- Per-mod popup for FIND MODS: the row is a plain click, and Install /
|
||||
-- Details / Source live here instead of crowding every row.
|
||||
local function buildFindEntryModal(imp, m)
|
||||
@@ -3253,8 +3609,10 @@ end
|
||||
local function modalUp(imp)
|
||||
return (imp._settingsText or imp._settings or imp._rename
|
||||
or imp._indexPrompt or imp._modConfirm or imp._modReleaseNotes
|
||||
or imp._appPatchNotes
|
||||
or imp._findDetails or imp._modVersions or imp._modDepResolver or imp._sortPopup
|
||||
or imp._filterPopup or imp._indexManage or imp._modActions
|
||||
or imp._filterPopup or imp._modScopePopup or imp._indexManage
|
||||
or imp._modActions or imp._modImports
|
||||
or imp._modHeaderActionsPopup or imp._profilesPopup or imp._singleProfileActions or imp._profileSavePrompt
|
||||
or imp._profileRenamePrompt or imp._findEntry or imp._gameManage) ~= nil
|
||||
end
|
||||
@@ -3351,6 +3709,20 @@ local function buildModals(imp, m)
|
||||
return true
|
||||
end
|
||||
if imp._modConfirm then buildConfirmModal(imp, m) return true end
|
||||
if imp._appPatchNotes then
|
||||
local PatchNotes = require("src.update.PatchNotes")
|
||||
local ModUpdate = require("src.mods.ModUpdate")
|
||||
local raw, ver = PatchNotes.body(imp.Check)
|
||||
local body = ModUpdate.cleanBody(raw or "", 0)
|
||||
if body == "" then body = Strings("(No patch notes.)") end
|
||||
local title = Strings("Patch notes")
|
||||
if ver and ver ~= "" then
|
||||
title = title .. " v" .. tostring(ver)
|
||||
end
|
||||
buildTextModal(imp, m, "patch-notes-modal", title, body,
|
||||
function() imp._appPatchNotes = nil end)
|
||||
return true
|
||||
end
|
||||
if imp._modReleaseNotes then
|
||||
local ModUpdate = require("src.mods.ModUpdate")
|
||||
local n = imp._modReleaseNotes
|
||||
@@ -3372,6 +3744,7 @@ local function buildModals(imp, m)
|
||||
end
|
||||
if imp._modVersions then buildVersionsModal(imp, m) return true end
|
||||
if imp._modDepResolver then buildDepResolverModal(imp, m) return true end
|
||||
if imp._modImports then buildRequiredImportsModal(imp, m) return true end
|
||||
-- The lighter popups come after the deep ones on purpose: opening
|
||||
-- Versions or Details from inside an actions popup draws the deeper modal
|
||||
-- while the popup's own state stays set, so closing the deep one drops
|
||||
@@ -3380,6 +3753,7 @@ local function buildModals(imp, m)
|
||||
if imp._profilesPopup then buildProfilesModal(imp, m) return true end
|
||||
if imp._modHeaderActionsPopup then buildModHeaderActionsModal(imp, m) return true end
|
||||
if imp._sortPopup then buildSortModal(imp, m) return true end
|
||||
if imp._modScopePopup then buildModScopeModal(imp, m) return true end
|
||||
if imp._filterPopup then buildFilterModal(imp, m) return true end
|
||||
if imp._indexManage then buildIndexesModal(imp, m) return true end
|
||||
if imp._modActions then buildModActionsModal(imp, m) return true end
|
||||
@@ -3510,11 +3884,14 @@ function LauncherView.draw(imp)
|
||||
-- one is up; buildModals lowers the shield for the modal's own controls.
|
||||
Kit.blockClicks = modalUp(imp)
|
||||
|
||||
local ms = m
|
||||
if scroll > 0 then
|
||||
ms = setmetatable({ top = m.top - scroll }, { __index = m })
|
||||
end
|
||||
local contentY = buildHeader(imp, ms)
|
||||
-- The header is the only block that moves with the page scroll, so shift
|
||||
-- m.top across the call and put it back rather than wrapping `m` in a
|
||||
-- proxy: the proxy cost two tables a frame and put a metatable lookup on
|
||||
-- every m.* read for the rest of the frame.
|
||||
local baseTop = m.top
|
||||
if scroll > 0 then m.top = baseTop - scroll end
|
||||
local contentY = buildHeader(imp, m)
|
||||
m.top = baseTop
|
||||
local footY, availH
|
||||
if scrollMax > 0 then
|
||||
availH = minPanelHeight(m)
|
||||
|
||||
+445
-79
@@ -316,18 +316,6 @@ function RomImporter.isReady(version)
|
||||
end
|
||||
|
||||
-- Load the import manifest for a version and confirm it matches that ROM.
|
||||
local function decodeManifest(version)
|
||||
local path = GameVersion.info(version).manifest
|
||||
local raw, readError = love.filesystem.read(path)
|
||||
if not raw then error("ROM import metadata is missing: " .. tostring(readError)) end
|
||||
local Json = require("src.link.Json")
|
||||
local manifest, decodeError = Json.decode(raw)
|
||||
if not manifest then error("ROM import metadata is invalid: " .. tostring(decodeError)) end
|
||||
assert(manifest.romSha1 == GameVersion.info(version).sha1,
|
||||
"ROM import metadata version mismatch")
|
||||
return manifest
|
||||
end
|
||||
|
||||
local function sha1(data)
|
||||
local digest = love.data.hash("sha1", data)
|
||||
if type(digest) == "userdata" and digest.getString then
|
||||
@@ -344,6 +332,14 @@ local function readExternalPath(path)
|
||||
return data
|
||||
end
|
||||
|
||||
local function externalFileSize(path)
|
||||
local file = io.open(path, "rb")
|
||||
if not file then return nil end
|
||||
local size = file:seek("end")
|
||||
file:close()
|
||||
return size
|
||||
end
|
||||
|
||||
local function readDroppedFile(file)
|
||||
local ok, openError = file:open("r")
|
||||
if not ok then return nil, openError end
|
||||
@@ -981,6 +977,25 @@ local function findPendingSav(preferAny, skip)
|
||||
return nil
|
||||
end
|
||||
|
||||
local function pickerHasKind(kind)
|
||||
local fn = love.system.pickFileKinds
|
||||
if type(fn) ~= "function" then return false end
|
||||
local ok, kinds = pcall(fn)
|
||||
if not ok or type(kinds) ~= "string" then return false end
|
||||
for token in kinds:gmatch("[^,%s]+") do
|
||||
if token == kind then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function findPendingRequiredImport()
|
||||
local names = { "picked_required_import.bin", "picked_stadium.z64" }
|
||||
for _, name in ipairs(names) do
|
||||
if love.filesystem.getInfo(name, "file") then return name end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Retire an Android pick once it has been through the installer / importer,
|
||||
-- whether or not it worked: a pick left on disk wins the scans above forever,
|
||||
-- so the next tap re-runs the same failing file and the picker never reopens
|
||||
@@ -1112,6 +1127,39 @@ local function chooseSav()
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Generic user-supplied dependency picker. Keep the native prompt entirely
|
||||
-- engine-owned: manifest labels are untrusted and must never enter shell
|
||||
-- command templates. The LÖVE modal already shows the specific import name.
|
||||
local function chooseRequiredFile()
|
||||
local prompt = shellSafe(Strings("Choose required mod file"))
|
||||
local platform = love.system.getOS()
|
||||
if platform == "OS X" then
|
||||
return commandOutput(
|
||||
([[osascript -e 'POSIX path of (choose file with prompt "%s")' 2>/dev/null]])
|
||||
:format(prompt))
|
||||
elseif platform == "Windows" then
|
||||
local script = table.concat({
|
||||
"Add-Type -AssemblyName System.Windows.Forms;",
|
||||
"$d=New-Object System.Windows.Forms.OpenFileDialog;",
|
||||
"$d.Title='" .. prompt .. "';",
|
||||
"$d.Filter='All files (*.*)|*.*';",
|
||||
"if($d.ShowDialog() -eq 'OK'){",
|
||||
"$t=Join-Path $env:TEMP 'pokeport_required_import.bin';",
|
||||
"Copy-Item -LiteralPath $d.FileName -Destination $t -Force;",
|
||||
"[Console]::OutputEncoding=[Text.Encoding]::UTF8;",
|
||||
"[Console]::Write($t)}",
|
||||
})
|
||||
return commandOutput(
|
||||
'powershell -NoProfile -STA -Command "' .. script .. '"')
|
||||
elseif platform == "Linux" then
|
||||
local path = commandOutput(
|
||||
([[zenity --file-selection --title="%s" 2>/dev/null]]):format(prompt))
|
||||
if path then return path end
|
||||
return commandOutput([[kdialog --getopenfilename "$HOME" 2>/dev/null]])
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- The self-updater only surfaces on the real distributed build: a fused,
|
||||
-- interactive launcher with no scripted-run override. A dev / source checkout
|
||||
-- (unfused, where Boot.run already no-ops) or an autopilot / driver /
|
||||
@@ -1230,7 +1278,9 @@ function RomImporter.new(onComplete, opts)
|
||||
-- (refreshed lazily on first draw and after any toggle/install/delete);
|
||||
-- modScroll is the current paged list's inner scroll offset (px, clamped
|
||||
-- in draw); modNotice is the last install/delete result { ok, text }.
|
||||
mods = nil, modScroll = 0, modNotice = nil,
|
||||
-- requiredImportNotice stays inside the imported-files modal so validation
|
||||
-- failures are visible beside the file picker that caused them.
|
||||
mods = nil, modScroll = 0, modNotice = nil, requiredImportNotice = nil,
|
||||
-- Which game the MODS panel is answering for (a GameVersion id, nil =
|
||||
-- every game). Rows resolve their enable-state and their "runs here"
|
||||
-- verdict against it (src/mods/ModTargets.lua).
|
||||
@@ -1420,7 +1470,13 @@ function RomImporter:focus(f)
|
||||
local text = "Could not read the picked file. Reopen the picker and choose "
|
||||
.. "it with the Files (Documents) app, or copy it into: "
|
||||
.. love.filesystem.getSaveDirectory()
|
||||
if pickError:find("picked_mod", 1, true) then
|
||||
if pickError:find("picked_required_import", 1, true)
|
||||
or pickError:find("picked_stadium", 1, true) then
|
||||
self.modNotice = { ok = false, text = text }
|
||||
self.pickerPendingKind = nil
|
||||
self.pickerPendingModId = nil
|
||||
self.pickerPendingImportId = nil
|
||||
elseif pickError:find("picked_mod", 1, true) then
|
||||
self.modNotice = { ok = false, text = text }
|
||||
elseif pickError:find("picked_save", 1, true) then
|
||||
local version = self.androidPendingVersion or self:_savedropTarget()
|
||||
@@ -1431,6 +1487,20 @@ function RomImporter:focus(f)
|
||||
end
|
||||
return
|
||||
end
|
||||
local requiredName = findPendingRequiredImport()
|
||||
if requiredName then
|
||||
local modId, importId = self.pickerPendingModId, self.pickerPendingImportId
|
||||
self.pickerPendingKind = nil
|
||||
self.pickerPendingModId, self.pickerPendingImportId = nil, nil
|
||||
local imported = modId and importId
|
||||
and self:_importRequiredSource(modId, importId, requiredName)
|
||||
consumePick(self, requiredName, requiredName, imported)
|
||||
if not modId or not importId then
|
||||
self.modNotice = { ok = false,
|
||||
text = "A picked dependency file had no pending mod request and was discarded." }
|
||||
end
|
||||
return
|
||||
end
|
||||
local modName = findPendingMod(false, self.pickSkip)
|
||||
if modName then
|
||||
self:_installMod(modName)
|
||||
@@ -1470,6 +1540,8 @@ function RomImporter:setError(message, version)
|
||||
self.detail = tostring(message)
|
||||
self.progress = 0
|
||||
self.worker = nil
|
||||
-- Dropping the job stops collection; the next import clears the channels.
|
||||
self._extract = nil
|
||||
self.romData = nil
|
||||
-- A headless import has no launcher to read this off: POKEPORT_IMPORT_ONLY
|
||||
-- only ever quits from onComplete, so an import that fails here would sit in
|
||||
@@ -1536,23 +1608,65 @@ function RomImporter:startData(data, displayName)
|
||||
self.detail = displayName or info.displayName
|
||||
self.progress = 0
|
||||
self.romData = data
|
||||
self.worker = coroutine.create(function()
|
||||
self.status = "Preparing private game data"
|
||||
coroutine.yield()
|
||||
-- Redirect every cache write to this version's subtree, then clear only
|
||||
-- that version's previous cache from both homes (save directory and, for
|
||||
-- a portable install, the game folder). The other version is untouched.
|
||||
local CacheFs = require("src.import.CacheFs")
|
||||
local prefix = info.cachePrefix
|
||||
CacheFs.prefix = prefix
|
||||
self.status = "Preparing private game data"
|
||||
|
||||
-- Clear this version's previous cache from both homes before anything
|
||||
-- writes. Stays on the main thread so delete-then-fill-then-mark keeps one
|
||||
-- owner; the prefix is restored at once because the worker sets its own.
|
||||
local CacheFs = require("src.import.CacheFs")
|
||||
local prefix = info.cachePrefix
|
||||
local savedPrefix = CacheFs.prefix
|
||||
CacheFs.prefix = prefix
|
||||
local cleared, clearError = pcall(function()
|
||||
removeTree(prefix .. "data/generated")
|
||||
removeTree(prefix .. "assets/generated")
|
||||
love.filesystem.remove(prefix .. MARKER_PATH)
|
||||
CacheFs.removeTree("data/generated")
|
||||
CacheFs.removeTree("assets/generated")
|
||||
CacheFs.remove(MARKER_PATH)
|
||||
end)
|
||||
CacheFs.prefix = savedPrefix
|
||||
if not cleared then
|
||||
self:setError(tostring(clearError), version)
|
||||
return
|
||||
end
|
||||
|
||||
local manifest = decodeManifest(version)
|
||||
if self:_startExtractThread(version, prefix, data, displayName) then return end
|
||||
self:_startExtractCoroutine(version, info, prefix, displayName)
|
||||
end
|
||||
|
||||
-- False when threads are unavailable, so the coroutine path still covers
|
||||
-- that host. POKEPORT_NO_THREAD=1 forces it, which is the only way to
|
||||
-- exercise the fallback on a desktop.
|
||||
function RomImporter:_startExtractThread(version, prefix, data, displayName)
|
||||
if os.getenv("POKEPORT_NO_THREAD") == "1" then return false end
|
||||
if not (love.thread and love.thread.newThread) then return false end
|
||||
local ok, thread = pcall(love.thread.newThread, "src/import/ExtractThread.lua")
|
||||
if not ok or not thread then return false end
|
||||
local progressName = "rom_import_progress"
|
||||
local resultName = "rom_import_result"
|
||||
love.thread.getChannel(progressName):clear()
|
||||
love.thread.getChannel(resultName):clear()
|
||||
local started = pcall(thread.start, thread, version, prefix, data,
|
||||
progressName, resultName)
|
||||
if not started then return false end
|
||||
self._extract = {
|
||||
thread = thread, version = version, prefix = prefix,
|
||||
displayName = displayName,
|
||||
progress = love.thread.getChannel(progressName),
|
||||
result = love.thread.getChannel(resultName),
|
||||
}
|
||||
-- The worker owns the bytes now; drop ours so the 1-2 MiB string can go.
|
||||
self.romData = nil
|
||||
return true
|
||||
end
|
||||
|
||||
function RomImporter:_startExtractCoroutine(version, info, prefix, displayName)
|
||||
self.worker = coroutine.create(function()
|
||||
coroutine.yield()
|
||||
local CacheFs = require("src.import.CacheFs")
|
||||
CacheFs.prefix = prefix
|
||||
local manifest = require("src.import.RomManifest").decode(version)
|
||||
local RomExtractor = version == "gold"
|
||||
and require("src.import.RomExtractorGen2")
|
||||
or require("src.import.RomExtractor")
|
||||
@@ -1565,47 +1679,93 @@ function RomImporter:startData(data, displayName)
|
||||
coroutine.yield()
|
||||
end)
|
||||
extractor:run()
|
||||
CacheFs.prefix = "" -- restore the default so later writes stay at the root
|
||||
self.romData = nil
|
||||
collectgarbage("collect")
|
||||
-- Written last: the marker is what isReady() checks, so it must only
|
||||
-- appear once every required file is in place.
|
||||
local ok, writeError = CacheFs.write(MARKER_PATH, markerFor(version))
|
||||
CacheFs.prefix = "" -- restore the default so later writes stay at the root
|
||||
if not ok then error("could not finish the private cache: " .. tostring(writeError)) end
|
||||
self.ready[version] = true
|
||||
self.returning[version] = false
|
||||
self.romName[version] = (displayName
|
||||
and (displayName:match("[^/\\]+$") or displayName)) or self.romName[version]
|
||||
-- Android: drop the consumed save-dir .gb/.gbc (picked_rom.gb or a USB copy)
|
||||
-- so the next Choose / focus cannot treat it as a fresh pending ROM.
|
||||
if self.mobileFileBridge and type(displayName) == "string"
|
||||
and not displayName:find("[/\\]") then
|
||||
love.filesystem.remove(displayName)
|
||||
end
|
||||
self.importing = nil
|
||||
self.workState = "complete"
|
||||
self.completeVersion = version
|
||||
self.status = "Ready"
|
||||
-- NX launcher stays put: keep the imports/ cleanup hint instead of
|
||||
-- overwriting it with a "Starting…" line that never boots from here.
|
||||
if self.launcher and self.isNX and type(displayName) == "string" then
|
||||
self.detail = Strings("%s imported. You may delete the copy from "
|
||||
.. "imports/ when finished.", displayName)
|
||||
else
|
||||
self.detail = "Starting " .. info.displayName .. "..."
|
||||
end
|
||||
self.progress = 1
|
||||
if self.launcher then
|
||||
-- Stay on the launcher; the player presses Play to boot the new game.
|
||||
return
|
||||
end
|
||||
self._handedOff = true
|
||||
resetPointerCursor(self)
|
||||
if self._flex then require("src.import.LauncherView").detach(self) end
|
||||
if self.onComplete then self.onComplete(version) end
|
||||
self:_completeImport(version, prefix, displayName)
|
||||
end)
|
||||
end
|
||||
|
||||
-- Everything after the tree is filled, shared by both worker paths. Raises
|
||||
-- on a failed marker write; the thread path calls it inside a pcall.
|
||||
function RomImporter:_completeImport(version, prefix, displayName)
|
||||
local info = GameVersion.info(version)
|
||||
local CacheFs = require("src.import.CacheFs")
|
||||
-- Written last: the marker is what isReady() checks, so it must only
|
||||
-- appear once every required file is in place.
|
||||
local savedPrefix = CacheFs.prefix
|
||||
CacheFs.prefix = prefix
|
||||
local ok, writeError = CacheFs.write(MARKER_PATH, markerFor(version))
|
||||
CacheFs.prefix = savedPrefix
|
||||
if not ok then
|
||||
error("could not finish the private cache: " .. tostring(writeError))
|
||||
end
|
||||
self.ready[version] = true
|
||||
self.returning[version] = false
|
||||
self.romName[version] = (displayName
|
||||
and (displayName:match("[^/\\]+$") or displayName)) or self.romName[version]
|
||||
-- Android: drop the consumed save-dir .gb/.gbc (picked_rom.gb or a USB copy)
|
||||
-- so the next Choose / focus cannot treat it as a fresh pending ROM.
|
||||
if self.mobileFileBridge and type(displayName) == "string"
|
||||
and not displayName:find("[/\\]") then
|
||||
love.filesystem.remove(displayName)
|
||||
end
|
||||
self.importing = nil
|
||||
self.workState = "complete"
|
||||
self.completeVersion = version
|
||||
self.status = "Ready"
|
||||
-- NX launcher stays put: keep the imports/ cleanup hint instead of
|
||||
-- overwriting it with a "Starting…" line that never boots from here.
|
||||
if self.launcher and self.isNX and type(displayName) == "string" then
|
||||
self.detail = Strings("%s imported. You may delete the copy from "
|
||||
.. "imports/ when finished.", displayName)
|
||||
else
|
||||
self.detail = "Starting " .. info.displayName .. "..."
|
||||
end
|
||||
self.progress = 1
|
||||
if self.launcher then
|
||||
-- Stay on the launcher; the player presses Play to boot the new game.
|
||||
return
|
||||
end
|
||||
self._handedOff = true
|
||||
resetPointerCursor(self)
|
||||
if self._flex then require("src.import.LauncherView").detach(self) end
|
||||
if self.onComplete then self.onComplete(version) end
|
||||
end
|
||||
|
||||
-- Drain the worker's progress and finish when it reports done. One
|
||||
-- non-blocking poll per frame, like the other _pump* collectors above.
|
||||
function RomImporter:_pumpExtract()
|
||||
local job = self._extract
|
||||
if not job then return end
|
||||
local msg = job.progress:pop()
|
||||
while msg do
|
||||
self.status = msg.stage
|
||||
self.progress = msg.progress / msg.total
|
||||
self.stageCurrent = msg.current
|
||||
self.stageTotal = msg.stageTotal
|
||||
msg = job.progress:pop()
|
||||
end
|
||||
local res = job.result:pop()
|
||||
if not res then
|
||||
-- A thread that died before pushing a result would strand the loader.
|
||||
local threadError = job.thread.getError and job.thread:getError()
|
||||
if threadError then
|
||||
self._extract = nil
|
||||
self:setError(tostring(threadError), job.version)
|
||||
end
|
||||
return
|
||||
end
|
||||
self._extract = nil
|
||||
if not res.ok then
|
||||
self:setError(tostring(res.error), job.version)
|
||||
return
|
||||
end
|
||||
local ok, err = pcall(self._completeImport, self, job.version, job.prefix,
|
||||
job.displayName)
|
||||
if not ok then self:setError(tostring(err), job.version) end
|
||||
end
|
||||
|
||||
function RomImporter:startPath(path)
|
||||
if not path then return end
|
||||
local data, readError = readExternalPath(path)
|
||||
@@ -1738,6 +1898,152 @@ function RomImporter:chooseMod()
|
||||
if path then self:_installMod(path) end
|
||||
end
|
||||
|
||||
local function requiredManifest(self, modId)
|
||||
for _, row in ipairs(self.mods or {}) do
|
||||
if row.id == modId then return row.manifest, row end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function requiredSpec(manifest, importId)
|
||||
for _, candidate in ipairs(require("src.mods.RequiredImports").specs(manifest)) do
|
||||
if candidate.id == importId then return candidate end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function requiredImportNotice(self, modId, importId, text)
|
||||
self.requiredImportNotice = {
|
||||
modId = modId,
|
||||
importId = importId,
|
||||
text = tostring(text),
|
||||
}
|
||||
end
|
||||
|
||||
function RomImporter:_importRequiredData(modId, importId, data)
|
||||
local manifest = requiredManifest(self, modId)
|
||||
if not manifest then
|
||||
self.modNotice = { ok = false, text = "Required import failed: mod not found." }
|
||||
return nil
|
||||
end
|
||||
local ok, result = require("src.mods.RequiredImports")
|
||||
.importData(manifest, importId, data)
|
||||
if ok then
|
||||
self.requiredImportNotice = nil
|
||||
self.modNotice = { ok = true, text = "Imported " .. tostring(importId)
|
||||
.. " for " .. tostring(manifest.name or manifest.id) .. "." }
|
||||
self:_refreshMods()
|
||||
return true
|
||||
end
|
||||
-- Keep validation feedback on the imported-files page. A general Mods-page
|
||||
-- notice is hidden by this modal and made MD5 failures especially easy to miss.
|
||||
requiredImportNotice(self, modId, importId, result)
|
||||
self.modNotice = nil
|
||||
return nil
|
||||
end
|
||||
|
||||
function RomImporter:_importRequiredSource(modId, importId, source)
|
||||
local manifest = requiredManifest(self, modId)
|
||||
local spec = manifest and requiredSpec(manifest, importId)
|
||||
if not spec then
|
||||
requiredImportNotice(self, modId, importId, "Import declaration was not found.")
|
||||
self.modNotice = nil
|
||||
return nil
|
||||
end
|
||||
local info = love.filesystem.getInfo(source, "file")
|
||||
local size = info and info.size or externalFileSize(source)
|
||||
local sizeErr = require("src.mods.RequiredImports").sizeError(spec, size, false)
|
||||
if sizeErr then
|
||||
requiredImportNotice(self, modId, importId, sizeErr)
|
||||
self.modNotice = nil
|
||||
return nil
|
||||
end
|
||||
local data = love.filesystem.read(source)
|
||||
if not data then data = readExternalPath(source) end
|
||||
if not data then
|
||||
requiredImportNotice(self, modId, importId, "Could not read the selected file.")
|
||||
self.modNotice = nil
|
||||
return nil
|
||||
end
|
||||
return self:_importRequiredData(modId, importId, data)
|
||||
end
|
||||
|
||||
function RomImporter:_removeRequiredImport(modId, importId)
|
||||
local manifest = requiredManifest(self, modId)
|
||||
if not manifest then return end
|
||||
local ok, err = require("src.mods.RequiredImports").remove(manifest, importId)
|
||||
if ok then
|
||||
self.requiredImportNotice = nil
|
||||
self.modNotice = { ok = true, text = "Deleted " .. tostring(importId) .. "." }
|
||||
self:_refreshMods()
|
||||
else
|
||||
requiredImportNotice(self, modId, importId, err)
|
||||
self.modNotice = nil
|
||||
end
|
||||
end
|
||||
|
||||
-- Select and validate one manifest-declared file. NX has no host picker, so
|
||||
-- its equivalent is an engine-owned imports/baseroms inbox that can be filled
|
||||
-- over MTP; every other native/mobile picker lands on the same validation path.
|
||||
function RomImporter:chooseRequiredImport(modId, importId)
|
||||
if self.workState == "working" then return end
|
||||
local manifest = requiredManifest(self, modId)
|
||||
if not manifest then return end
|
||||
local spec = requiredSpec(manifest, importId)
|
||||
if not spec then return end
|
||||
|
||||
if self.isNX then
|
||||
local inbox = "imports/baseroms"
|
||||
love.filesystem.createDirectory(inbox)
|
||||
local lastError
|
||||
for _, name in ipairs(love.filesystem.getDirectoryItems(inbox) or {}) do
|
||||
if name:sub(1, 1) ~= "." then
|
||||
local path = inbox .. "/" .. name
|
||||
local info = love.filesystem.getInfo(path, "file")
|
||||
local sizeErr = info and require("src.mods.RequiredImports")
|
||||
.sizeError(spec, info.size, false)
|
||||
local data = not sizeErr and love.filesystem.read(path) or nil
|
||||
if data and self:_importRequiredData(modId, importId, data) then return end
|
||||
if sizeErr then lastError = sizeErr
|
||||
elseif self.requiredImportNotice
|
||||
and self.requiredImportNotice.modId == modId
|
||||
and self.requiredImportNotice.importId == importId then
|
||||
lastError = self.requiredImportNotice.text
|
||||
end
|
||||
end
|
||||
end
|
||||
requiredImportNotice(self, modId, importId, lastError
|
||||
or "No matching file in imports/baseroms/. Copy it there over MTP, then try again.")
|
||||
self.modNotice = nil
|
||||
return
|
||||
end
|
||||
if self.nativePicker then
|
||||
if self.mobileFileBridge and not pickerHasKind("required_import") then
|
||||
requiredImportNotice(self, modId, importId,
|
||||
"This app build cannot pick required mod files yet. Update the app and try again.")
|
||||
self.modNotice = nil
|
||||
return
|
||||
end
|
||||
self.pickerPendingKind = "required_import"
|
||||
self.pickerPendingModId = modId
|
||||
self.pickerPendingImportId = importId
|
||||
if not pickFile("required_import") then
|
||||
self.pickerPendingKind = nil
|
||||
self.pickerPendingModId = nil
|
||||
self.pickerPendingImportId = nil
|
||||
requiredImportNotice(self, modId, importId, "Could not open the file picker.")
|
||||
self.modNotice = nil
|
||||
elseif self.android then
|
||||
self.pickPending = true
|
||||
self.pickTimer = 0
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
local path = chooseRequiredFile()
|
||||
if path then self:_importRequiredSource(modId, importId, path) end
|
||||
end
|
||||
|
||||
-- Which game a dropped .sav imports into: a .sav has no version signature of
|
||||
-- its own, so it lands on the active game tab. When a non-game tab (mods) is
|
||||
-- showing, default to red -- the always-present first game -- rather than
|
||||
@@ -2045,7 +2351,8 @@ function RomImporter:_pollPickedFiles(dt)
|
||||
if not found then
|
||||
for _, name in ipairs(love.filesystem.getDirectoryItems("")) do
|
||||
local n = name:lower()
|
||||
if n:match("%.gbc?$") or n == "picked_mod.zip" or n == "picked_save.sav" then
|
||||
if n:match("%.gbc?$") or n == "picked_mod.zip" or n == "picked_save.sav"
|
||||
or n == "picked_required_import.bin" or n == "picked_stadium.z64" then
|
||||
found = true
|
||||
break
|
||||
end
|
||||
@@ -2087,6 +2394,7 @@ function RomImporter:update(dt)
|
||||
self:_pumpFindThumbs()
|
||||
self:_pumpModCheck()
|
||||
self:_pumpModInstall()
|
||||
self:_pumpExtract()
|
||||
-- Dev harness: POKEPORT_LAUNCHER_SHOT=/path.png resizes the window from
|
||||
-- POKEPORT_WIN=WxH, lets the view settle, then captures one frame and
|
||||
-- quits, so a scripted run can see the real launcher at any window shape
|
||||
@@ -2174,7 +2482,12 @@ function RomImporter:update(dt)
|
||||
local version = self.pickerPendingVersion
|
||||
self.pickerPendingKind = nil
|
||||
self.pickerPendingVersion = nil
|
||||
if kind == "mod" then
|
||||
if kind == "required_import" then
|
||||
local modId, importId = self.pickerPendingModId, self.pickerPendingImportId
|
||||
self.pickerPendingModId, self.pickerPendingImportId = nil, nil
|
||||
if modId and importId then self:_importRequiredSource(modId, importId, path) end
|
||||
if Platform.isUWP() then os.remove(path) end
|
||||
elseif kind == "mod" then
|
||||
self:_installMod(path)
|
||||
if Platform.isUWP() and self.modNotice and self.modNotice.ok then
|
||||
os.remove(path)
|
||||
@@ -2196,7 +2509,10 @@ function RomImporter:update(dt)
|
||||
local version = self.pickerPendingVersion or self:_savedropTarget()
|
||||
self.pickerPendingKind = nil
|
||||
self.pickerPendingVersion = nil
|
||||
if kind == "mod" then
|
||||
if kind == "required_import" then
|
||||
self.modNotice = { ok = false, text = errorText }
|
||||
self.pickerPendingModId, self.pickerPendingImportId = nil, nil
|
||||
elseif kind == "mod" then
|
||||
self.modNotice = { ok = false, text = errorText }
|
||||
elseif kind == "sav" then
|
||||
self.saveNotice[version] = { ok = false, text = errorText }
|
||||
@@ -2820,7 +3136,7 @@ function RomImporter:keypressed(key)
|
||||
return
|
||||
end
|
||||
if self._modConfirm or self._modVersions or self._modReleaseNotes
|
||||
or self._findDetails then
|
||||
or self._findDetails or self._appPatchNotes then
|
||||
-- Focus navigation belongs to the visible modal as well as the launcher
|
||||
-- beneath it. Route arrows and an already-armed confirm before this guard
|
||||
-- returns; unarmed Enter still falls through to the modal guard. Keep this
|
||||
@@ -2833,6 +3149,8 @@ function RomImporter:keypressed(key)
|
||||
self._findDetails = nil
|
||||
elseif self._modReleaseNotes then
|
||||
self._modReleaseNotes = nil
|
||||
elseif self._appPatchNotes then
|
||||
self._appPatchNotes = nil
|
||||
else
|
||||
self._modConfirm = nil
|
||||
self._modVersions = nil
|
||||
@@ -3851,11 +4169,19 @@ end
|
||||
|
||||
-- Turn finished thumbnail downloads into images. Called from update(), so
|
||||
-- love.graphics.newImage runs on the render thread where it belongs.
|
||||
-- love.graphics.newImage decodes the PNG and uploads it, both on the render
|
||||
-- thread. A page's worth of thumbnails landing in the same frame did that
|
||||
-- many times back to back and dropped the frame, so only this many are
|
||||
-- decoded per pass; the rest keep their spinner one frame longer.
|
||||
local THUMB_DECODES_PER_FRAME = 2
|
||||
|
||||
function RomImporter:_pumpFindThumbs()
|
||||
local pending = self._findThumbFetch
|
||||
if not pending then return end
|
||||
local Fetch = require("src.net.Fetch")
|
||||
local decoded = 0
|
||||
for id, item in pairs(pending) do
|
||||
if decoded >= THUMB_DECODES_PER_FRAME then break end
|
||||
local st = Fetch.poll(item.job)
|
||||
if st.status ~= "pending" then
|
||||
Fetch.release(item.job)
|
||||
@@ -3864,6 +4190,7 @@ function RomImporter:_pumpFindThumbs()
|
||||
if st.status == "ok" and st.path then
|
||||
local ok, img = pcall(love.graphics.newImage, st.path)
|
||||
image = ok and img or nil
|
||||
decoded = decoded + 1
|
||||
end
|
||||
self._findThumbs = self._findThumbs or {}
|
||||
self._findThumbs[id] = image or false
|
||||
@@ -3880,14 +4207,21 @@ end
|
||||
-- entry per frame so opening the tab cannot stall for the whole listing.
|
||||
-- The result is memoized per id for the session; a repo with no releases
|
||||
-- or a failed fetch resolves to an empty table so it is tried once.
|
||||
function RomImporter:_findStats(entry)
|
||||
-- PURE read: whatever is already known for a row, or nil. Resolving a
|
||||
-- feed-published stat or a repo-less entry is memoization, not network, so it
|
||||
-- stays here; nothing in this function can start a fetch. That matters
|
||||
-- because the sort comparator calls it for EVERY entry -- when queueing lived
|
||||
-- in here, sorting a 500-mod index by Popularity queued 500 GitHub requests
|
||||
-- on the first frame, blew the hourly rate limit, and the failures then
|
||||
-- re-queued together every 60s for as long as the tab was open.
|
||||
function RomImporter:_findStatsCached(entry)
|
||||
self._findStatsCache = self._findStatsCache or {}
|
||||
local cached = self._findStatsCache[entry.id]
|
||||
if cached then
|
||||
if cached.done or (cached.retryAt and os.time() < cached.retryAt) then
|
||||
return cached
|
||||
end
|
||||
self._findStatsCache[entry.id] = nil -- retry window open, refetch
|
||||
return nil -- retry window open; _requestFindStats decides what to do
|
||||
end
|
||||
if entry.downloads ~= nil or entry.first_release or entry.last_release then
|
||||
cached = { total = entry.downloads, first = entry.first_release,
|
||||
@@ -3900,22 +4234,54 @@ function RomImporter:_findStats(entry)
|
||||
self._findStatsCache[entry.id] = cached
|
||||
return cached
|
||||
end
|
||||
-- ASYNC (was a blocking fetch, one row per frame). "One per frame" bounded
|
||||
-- how many stalls happened at once, not how long each one lasted: every
|
||||
-- frame that started a fetch blocked for the whole round trip, so scrolling
|
||||
-- a listing juddered once per row. Rows now queue a handle and fill in
|
||||
-- when it lands; until then the row simply has no stats line.
|
||||
self._findStatsPending = self._findStatsPending or {}
|
||||
if not self._findStatsPending[entry.id] then
|
||||
local ModUpdate = require("src.mods.ModUpdate")
|
||||
self._findStatsPending[entry.id] = {
|
||||
id = entry.id,
|
||||
h = ModUpdate.beginFetchReleases(entry.github, entry.id, {}),
|
||||
}
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Queue one row's release fetch. Only rows actually on the page call this --
|
||||
-- the rule _findThumb already follows -- so the fan-out is a page, not the
|
||||
-- whole index.
|
||||
function RomImporter:_requestFindStats(entry)
|
||||
if self:_findStatsCached(entry) then return end
|
||||
if not entry.github or entry.github == "" then return end
|
||||
local cached = self._findStatsCache[entry.id]
|
||||
if cached then
|
||||
if cached.retryAt and os.time() >= cached.retryAt then
|
||||
self._findStatsCache[entry.id] = nil -- retry window open, refetch
|
||||
else
|
||||
return
|
||||
end
|
||||
end
|
||||
self._findStatsPending = self._findStatsPending or {}
|
||||
if self._findStatsPending[entry.id] then return end
|
||||
local ModUpdate = require("src.mods.ModUpdate")
|
||||
self._findStatsPending[entry.id] = {
|
||||
id = entry.id,
|
||||
h = ModUpdate.beginFetchReleases(entry.github, entry.id, {}),
|
||||
}
|
||||
end
|
||||
|
||||
-- Request-and-read, for a row that is being drawn and for the detail modal.
|
||||
function RomImporter:_findStats(entry)
|
||||
self:_requestFindStats(entry)
|
||||
return self:_findStatsCached(entry)
|
||||
end
|
||||
|
||||
-- How many rows are still waiting on a release check, for the panel's
|
||||
-- progress line.
|
||||
function RomImporter:_findStatsPendingCount()
|
||||
local n = 0
|
||||
for _ in pairs(self._findStatsPending or {}) do n = n + 1 end
|
||||
return n
|
||||
end
|
||||
|
||||
function RomImporter:_findStatsPendingFor(id)
|
||||
return (self._findStatsPending and self._findStatsPending[id]) ~= nil
|
||||
end
|
||||
|
||||
function RomImporter:_findThumbPending(id)
|
||||
return (self._findThumbFetch and self._findThumbFetch[id]) ~= nil
|
||||
end
|
||||
|
||||
-- Drive in-flight FIND MODS stats lookups. Called from update().
|
||||
function RomImporter:_pumpFindStats()
|
||||
local pending = self._findStatsPending
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
-- The per-version import metadata (symbol table + ROM hash) that drives
|
||||
-- RomExtractor. Split out of RomImporter so the extraction worker thread can
|
||||
-- decode it itself: shipping the decoded table across a love.thread channel
|
||||
-- would deep-copy every symbol for nothing.
|
||||
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
|
||||
local RomManifest = {}
|
||||
|
||||
function RomManifest.decode(version)
|
||||
local info = GameVersion.info(version)
|
||||
local raw, readError = love.filesystem.read(info.manifest)
|
||||
if not raw then
|
||||
error("ROM import metadata is missing: " .. tostring(readError))
|
||||
end
|
||||
local Json = require("src.link.Json")
|
||||
local manifest, decodeError = Json.decode(raw)
|
||||
if not manifest then
|
||||
error("ROM import metadata is invalid: " .. tostring(decodeError))
|
||||
end
|
||||
assert(manifest.romSha1 == info.sha1, "ROM import metadata version mismatch")
|
||||
return manifest
|
||||
end
|
||||
|
||||
return RomManifest
|
||||
@@ -0,0 +1,209 @@
|
||||
-- Background compute for sandboxed mods, behind the "background" permission.
|
||||
--
|
||||
-- mod.fetch covers work that is waiting on a server. This covers work that is
|
||||
-- waiting on the CPU: a mod hands over a script from its own folder plus a
|
||||
-- table of plain data, and gets the return value back through the same
|
||||
-- handle/poll/release shape mod.fetch uses.
|
||||
--
|
||||
-- The worker (src/mods/job_worker.lua) builds the SAME Sandbox.envFor
|
||||
-- environment the main thread does before it loads the mod's chunk, so this
|
||||
-- is not the love.thread hole reopened: the mod's code still cannot see
|
||||
-- io, os, debug, ffi, package or love.filesystem, and require is refused
|
||||
-- outright inside a job.
|
||||
--
|
||||
-- One thread per job rather than a pool. A pooled state would carry one
|
||||
-- mod's globals into the next mod's job, and resetting it properly is the
|
||||
-- same work as making a new one.
|
||||
|
||||
local SafePath = require("src.mods.SafePath")
|
||||
|
||||
local Job = {}
|
||||
|
||||
Job.MAX_INFLIGHT = 2 -- per mod
|
||||
Job.MAX_GLOBAL = 4 -- across all mods, so jobs cannot eat every core
|
||||
Job.DEFAULT_SECONDS = 5
|
||||
Job.MAX_SECONDS = 30
|
||||
-- Depth cap on the data crossing the channel. A cycle is caught by the seen
|
||||
-- set; this catches the merely absurd.
|
||||
Job.MAX_DEPTH = 16
|
||||
|
||||
local nextId = 0
|
||||
local liveGlobal = 0
|
||||
|
||||
-- Only plain data crosses a thread boundary: a function or userdata cannot be
|
||||
-- serialised, and letting one through would fail deep inside LÖVE instead of
|
||||
-- at the call the mod made.
|
||||
local function plain(value, depth, seen)
|
||||
local t = type(value)
|
||||
if t == "nil" or t == "boolean" or t == "number" or t == "string" then
|
||||
return value
|
||||
end
|
||||
if t ~= "table" then
|
||||
return nil, ("a job cannot carry a %s, only plain data"):format(t)
|
||||
end
|
||||
depth = (depth or 0) + 1
|
||||
if depth > Job.MAX_DEPTH then
|
||||
return nil, "a job's data is nested too deeply"
|
||||
end
|
||||
seen = seen or {}
|
||||
if seen[value] then return nil, "a job cannot carry a cycle" end
|
||||
seen[value] = true
|
||||
local out = {}
|
||||
for k, v in pairs(value) do
|
||||
local kt = type(k)
|
||||
if kt ~= "string" and kt ~= "number" then
|
||||
return nil, ("a job cannot carry a %s key"):format(kt)
|
||||
end
|
||||
local copied, err = plain(v, depth, seen)
|
||||
if err then return nil, err end
|
||||
out[k] = copied
|
||||
end
|
||||
seen[value] = nil
|
||||
return out
|
||||
end
|
||||
Job.plain = plain
|
||||
|
||||
function Job.available()
|
||||
return (love and love.thread and love.thread.newThread) ~= nil
|
||||
end
|
||||
|
||||
local function bucket(loader, modId)
|
||||
loader.jobs = loader.jobs or {}
|
||||
local b = loader.jobs[modId]
|
||||
if not b then b = {}; loader.jobs[modId] = b end
|
||||
return b
|
||||
end
|
||||
|
||||
local function inflight(b)
|
||||
local n = 0
|
||||
for _, job in pairs(b) do
|
||||
if job.status == "pending" then n = n + 1 end
|
||||
end
|
||||
return n
|
||||
end
|
||||
|
||||
-- `script` is relative to the mod's own folder, and goes through the same
|
||||
-- SafePath rules mod:read does -- a job is not a way to name a path.
|
||||
-- Argument checks come BEFORE the host check: a bad path or an unserialisable
|
||||
-- argument is the mod author's bug and should read the same on every machine,
|
||||
-- not be masked into "unavailable" on a build without threads.
|
||||
function Job.run(loader, modId, modPath, script, arg, opts)
|
||||
if type(script) ~= "string" or script == "" then
|
||||
return nil, "a job needs a script path inside your mod"
|
||||
end
|
||||
-- SafePath.require raises rather than returning, so the mod's bad path
|
||||
-- comes back as a value here instead of unwinding its caller.
|
||||
local okPath, safe = pcall(SafePath.join, modPath, script, "a job script")
|
||||
if not okPath then return nil, tostring(safe) end
|
||||
local payload, dataErr = plain(arg)
|
||||
if dataErr then return nil, dataErr end
|
||||
if not Job.available() then return nil, "background jobs are unavailable" end
|
||||
|
||||
local b = bucket(loader, modId)
|
||||
if inflight(b) >= Job.MAX_INFLIGHT then
|
||||
return nil, ("too many jobs in flight (limit %d); poll and release the "
|
||||
.. "ones you have"):format(Job.MAX_INFLIGHT)
|
||||
end
|
||||
if liveGlobal >= Job.MAX_GLOBAL then
|
||||
return nil, "the machine is already running as many jobs as it will"
|
||||
end
|
||||
|
||||
opts = type(opts) == "table" and opts or {}
|
||||
local seconds = tonumber(opts.maxSeconds) or Job.DEFAULT_SECONDS
|
||||
if seconds > Job.MAX_SECONDS then seconds = Job.MAX_SECONDS end
|
||||
if seconds < 1 then seconds = 1 end
|
||||
|
||||
nextId = nextId + 1
|
||||
local argName = "modjob_arg_" .. nextId
|
||||
local resultName = "modjob_result_" .. nextId
|
||||
local argCh = love.thread.getChannel(argName)
|
||||
local resCh = love.thread.getChannel(resultName)
|
||||
argCh:clear()
|
||||
resCh:clear()
|
||||
argCh:push(payload == nil and false or payload)
|
||||
|
||||
local okNew, thread = pcall(love.thread.newThread, "src/mods/job_worker.lua")
|
||||
if not okNew or not thread then return nil, "could not start a job thread" end
|
||||
local Json = require("src.link.Json")
|
||||
local permissions = select(2, pcall(Json.encode,
|
||||
loader.mods and loader.mods[modId]
|
||||
and loader.mods[modId].manifest.permissionSet or {})) or "{}"
|
||||
local started = pcall(thread.start, thread, modId, safe, argName, resultName,
|
||||
permissions)
|
||||
if not started then return nil, "could not start a job thread" end
|
||||
|
||||
liveGlobal = liveGlobal + 1
|
||||
local handle = {}
|
||||
b[handle] = { thread = thread, resultCh = resCh, status = "pending",
|
||||
deadline = love.timer.getTime() + seconds, seconds = seconds }
|
||||
return handle
|
||||
end
|
||||
|
||||
local function settle(job, status, value, err)
|
||||
if job.status == "pending" then liveGlobal = math.max(0, liveGlobal - 1) end
|
||||
job.status, job.value, job.err = status, value, err
|
||||
end
|
||||
|
||||
function Job.poll(loader, modId, handle)
|
||||
local job = bucket(loader, modId)[handle]
|
||||
if not job then return { status = "error", err = "unknown job" } end
|
||||
if job.status == "pending" then
|
||||
local msg = job.resultCh:pop()
|
||||
if msg then
|
||||
if msg.ok then settle(job, "ok", msg.result)
|
||||
else settle(job, "error", nil, msg.err) end
|
||||
else
|
||||
-- A worker that died before pushing anything (an error outside its own
|
||||
-- pcall) would otherwise leave the mod polling forever.
|
||||
local threadErr = job.thread.getError and job.thread:getError()
|
||||
if threadErr then
|
||||
settle(job, "error", nil, tostring(threadErr))
|
||||
elseif love.timer.getTime() > job.deadline then
|
||||
-- The budget bounds how long the MOD waits, not how long the work
|
||||
-- runs: there is no way to stop a LÖVE thread, and every in-worker
|
||||
-- attempt made things worse (see job_worker.lua). A job that
|
||||
-- overruns is reported here and its result dropped if it ever lands.
|
||||
settle(job, "error", nil, ("job exceeded its %gs budget")
|
||||
:format(job.seconds))
|
||||
end
|
||||
end
|
||||
end
|
||||
if job.status == "ok" then
|
||||
-- A copy, so a mod cannot edit what a later poll returns.
|
||||
return { status = "ok", result = (plain(job.value)) }
|
||||
end
|
||||
return { status = job.status, err = job.err }
|
||||
end
|
||||
|
||||
function Job.release(loader, modId, handle)
|
||||
local b = bucket(loader, modId)
|
||||
local job = b[handle]
|
||||
if not job then return false end
|
||||
if job.status == "pending" then liveGlobal = math.max(0, liveGlobal - 1) end
|
||||
b[handle] = nil
|
||||
return true
|
||||
end
|
||||
|
||||
-- There is no way to kill a LÖVE thread, so cancelling drops the result
|
||||
-- rather than stopping the work; the worker's own time budget is what bounds
|
||||
-- how long an abandoned job can run.
|
||||
function Job.cancel(loader, modId, handle)
|
||||
local job = bucket(loader, modId)[handle]
|
||||
if not job then return false end
|
||||
if job.status == "pending" then
|
||||
settle(job, "cancelled")
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
function Job.releaseAll(loader, modId)
|
||||
local b = loader.jobs and loader.jobs[modId]
|
||||
if not b then return end
|
||||
for handle, job in pairs(b) do
|
||||
if job.status == "pending" then liveGlobal = math.max(0, liveGlobal - 1) end
|
||||
b[handle] = nil
|
||||
end
|
||||
loader.jobs[modId] = nil
|
||||
end
|
||||
|
||||
return Job
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,943 @@
|
||||
local Logger = require("src.core.Logger")
|
||||
local SafePath = require("src.mods.SafePath")
|
||||
|
||||
local okSave, SaveData = pcall(require, "src.core.SaveData")
|
||||
if not okSave then SaveData = nil end
|
||||
local okStorage, Storage = pcall(require, "src.mods.Storage")
|
||||
if not okStorage then Storage = nil end
|
||||
|
||||
local LegacyCompat = {}
|
||||
|
||||
local ROOT = "mod_compat"
|
||||
local OWN = "_own"
|
||||
local MAX_SEGMENTS = 24
|
||||
|
||||
LegacyCompat.reports = {}
|
||||
|
||||
function LegacyCompat.reset()
|
||||
LegacyCompat.reports = {}
|
||||
end
|
||||
|
||||
function LegacyCompat.report(modId)
|
||||
if modId then
|
||||
local entry = LegacyCompat.reports[modId]
|
||||
return entry and entry.order or {}
|
||||
end
|
||||
local out = {}
|
||||
for id, entry in pairs(LegacyCompat.reports) do
|
||||
out[id] = entry.order
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
local function note(ctx, call, advice)
|
||||
local entry = LegacyCompat.reports[ctx.modId]
|
||||
if not entry then
|
||||
entry = { calls = {}, order = {} }
|
||||
LegacyCompat.reports[ctx.modId] = entry
|
||||
end
|
||||
local row = entry.calls[call]
|
||||
if row then
|
||||
row.count = row.count + 1
|
||||
return
|
||||
end
|
||||
row = { call = call, advice = advice, count = 1 }
|
||||
entry.calls[call] = row
|
||||
entry.order[#entry.order + 1] = row
|
||||
Logger.warn("[%s] %s was removed from the mod sandbox; %s", ctx.modId, call,
|
||||
advice)
|
||||
end
|
||||
|
||||
local function refuse(ctx, call, advice)
|
||||
note(ctx, call, advice)
|
||||
return nil, ("%s is not available to mods; %s"):format(call, advice)
|
||||
end
|
||||
|
||||
-- ------- path routing
|
||||
|
||||
local function normalize(path)
|
||||
if type(path) ~= "string" or path == "" then return nil end
|
||||
path = path:gsub("\\", "/")
|
||||
while path:find("//", 1, true) do path = (path:gsub("//", "/")) end
|
||||
if path ~= "/" then path = (path:gsub("/$", "")) end
|
||||
return path
|
||||
end
|
||||
|
||||
local function flatten(path)
|
||||
local parts = {}
|
||||
for segment in path:gmatch("[^/]+") do
|
||||
if segment ~= "." and segment ~= ".." then
|
||||
parts[#parts + 1] = (segment:gsub("[^%w_%.%-]", "_"))
|
||||
end
|
||||
end
|
||||
if #parts == 0 then return nil end
|
||||
while #parts > MAX_SEGMENTS do table.remove(parts, 1) end
|
||||
return table.concat(parts, "/")
|
||||
end
|
||||
|
||||
local function storageKey(key)
|
||||
local parts = {}
|
||||
for segment in key:gmatch("[^/]+") do parts[#parts + 1] = segment end
|
||||
if #parts == 0 then return nil end
|
||||
parts[#parts] = (parts[#parts]:gsub("%.[^%.]*$", ""))
|
||||
for i = 1, #parts do
|
||||
local cleaned = (parts[i]:gsub("[^%w_%-]", "_"))
|
||||
if cleaned == "" then return nil end
|
||||
parts[i] = cleaned
|
||||
end
|
||||
return table.concat(parts, "/")
|
||||
end
|
||||
|
||||
local function ownRelative(ctx, path)
|
||||
if not ctx.modPath then return nil end
|
||||
if path == ctx.modPath then return "" end
|
||||
if path:sub(1, #ctx.modPath + 1) == ctx.modPath .. "/" then
|
||||
return path:sub(#ctx.modPath + 2)
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function ownFull(ctx, rel)
|
||||
if rel == "" then return ctx.modPath end
|
||||
local safe = SafePath.safe(rel)
|
||||
if not safe then return nil end
|
||||
return ctx.modPath .. "/" .. safe
|
||||
end
|
||||
|
||||
local function ownExists(ctx, rel)
|
||||
local full = ownFull(ctx, rel)
|
||||
local fs = ctx.fs
|
||||
if not (full and fs and fs.getInfo) then return nil end
|
||||
return fs.getInfo(full)
|
||||
end
|
||||
|
||||
local function classify(ctx, path)
|
||||
path = normalize(path)
|
||||
if not path then return nil end
|
||||
|
||||
local root = ctx.virtualRoot
|
||||
if path == root then return { key = "", dir = true } end
|
||||
if path:sub(1, #root + 1) == root .. "/" then
|
||||
return { key = flatten(path:sub(#root + 2)) }
|
||||
end
|
||||
|
||||
local rel = ownRelative(ctx, path)
|
||||
if not rel and path:sub(1, 1) ~= "/" and not path:match("^%a:")
|
||||
and ownExists(ctx, path) then
|
||||
rel = path
|
||||
end
|
||||
if rel then
|
||||
if rel == "" then return { key = OWN, rel = "", dir = true } end
|
||||
local flat = flatten(rel)
|
||||
return { key = flat and (OWN .. "/" .. flat) or nil, rel = rel }
|
||||
end
|
||||
|
||||
return { key = flatten(path) }
|
||||
end
|
||||
|
||||
-- ------- the overlay
|
||||
|
||||
local function persistFs(ctx)
|
||||
if SaveData and SaveData.persistenceFs then
|
||||
return SaveData.persistenceFs(ctx.fs)
|
||||
end
|
||||
return ctx.fs
|
||||
end
|
||||
|
||||
local function overlayPath(ctx, key)
|
||||
if key == nil or key == "" then return ROOT .. "/" .. ctx.modId end
|
||||
return ROOT .. "/" .. ctx.modId .. "/" .. key
|
||||
end
|
||||
|
||||
local function ensureParent(fs, path)
|
||||
if not fs.createDirectory then return end
|
||||
local dir = path:match("^(.*)/[^/]+$")
|
||||
if not dir then return end
|
||||
local built = nil
|
||||
for segment in dir:gmatch("[^/]+") do
|
||||
built = built and (built .. "/" .. segment) or segment
|
||||
fs.createDirectory(built)
|
||||
end
|
||||
end
|
||||
|
||||
local function overlayRead(ctx, key)
|
||||
local fs = persistFs(ctx)
|
||||
if not (fs and fs.read and fs.getInfo) then return nil end
|
||||
local path = overlayPath(ctx, key)
|
||||
local info = fs.getInfo(path)
|
||||
if not info or info.type == "directory" then return nil end
|
||||
local body = fs.read(path)
|
||||
if type(body) ~= "string" then return nil end
|
||||
return body
|
||||
end
|
||||
|
||||
local function overlayWrite(ctx, key, data)
|
||||
local fs = persistFs(ctx)
|
||||
if not (fs and fs.write) then
|
||||
return false, "no writable filesystem is available"
|
||||
end
|
||||
local path = overlayPath(ctx, key)
|
||||
ensureParent(fs, path)
|
||||
local ok, err = fs.write(path, data)
|
||||
if ok == false then return false, err or "write failed" end
|
||||
return true
|
||||
end
|
||||
|
||||
local function overlayRemove(ctx, key)
|
||||
local fs = persistFs(ctx)
|
||||
if not (fs and fs.remove and fs.getInfo) then return false end
|
||||
local path = overlayPath(ctx, key)
|
||||
if not fs.getInfo(path) then return false end
|
||||
fs.remove(path)
|
||||
return true
|
||||
end
|
||||
|
||||
local function overlayInfo(ctx, key)
|
||||
local fs = persistFs(ctx)
|
||||
if not (fs and fs.getInfo) then return nil end
|
||||
return fs.getInfo(overlayPath(ctx, key))
|
||||
end
|
||||
|
||||
local function storageRead(ctx, key)
|
||||
if not (Storage and key and key ~= "") then return nil end
|
||||
if key:sub(1, #OWN + 1) == OWN .. "/" then return nil end
|
||||
local game = ctx.game and ctx.game()
|
||||
if not game then return nil end
|
||||
local sk = storageKey(key)
|
||||
if not sk then return nil end
|
||||
ctx.storage = ctx.storage or Storage.new(ctx.modId, ctx.fs)
|
||||
local ok, bytes = pcall(ctx.storage.readBytes, ctx.storage, game, sk)
|
||||
if ok and type(bytes) == "string" then return bytes end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function readPath(ctx, path)
|
||||
local at = classify(ctx, path)
|
||||
if not at then return nil, "invalid path" end
|
||||
local body = at.key and overlayRead(ctx, at.key)
|
||||
if body then return body end
|
||||
if at.rel then
|
||||
local full = ownFull(ctx, at.rel)
|
||||
local fs = ctx.fs
|
||||
if full and fs and fs.read then
|
||||
local packaged = fs.read(full)
|
||||
if type(packaged) == "string" then return packaged end
|
||||
end
|
||||
end
|
||||
body = at.key and storageRead(ctx, at.key)
|
||||
if body then return body end
|
||||
return nil, "could not open " .. tostring(path)
|
||||
end
|
||||
|
||||
local function writePath(ctx, path, data)
|
||||
local at = classify(ctx, path)
|
||||
if not (at and at.key) then return false, "invalid path" end
|
||||
return overlayWrite(ctx, at.key, data)
|
||||
end
|
||||
|
||||
local function infoPath(ctx, path)
|
||||
local at = classify(ctx, path)
|
||||
if not at then return nil end
|
||||
if at.key then
|
||||
local info = overlayInfo(ctx, at.key)
|
||||
if info then
|
||||
return { type = info.type, size = info.size, modtime = info.modtime }
|
||||
end
|
||||
end
|
||||
if at.rel then
|
||||
local info = ownExists(ctx, at.rel)
|
||||
if info then
|
||||
return { type = info.type, size = info.size, modtime = info.modtime }
|
||||
end
|
||||
end
|
||||
local stored = at.key and storageRead(ctx, at.key)
|
||||
if stored then return { type = "file", size = #stored } end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function listPath(ctx, path)
|
||||
local at = classify(ctx, path)
|
||||
local seen, out = {}, {}
|
||||
local function add(name)
|
||||
if name and name ~= "" and not seen[name] then
|
||||
seen[name] = true
|
||||
out[#out + 1] = name
|
||||
end
|
||||
end
|
||||
if at and at.rel then
|
||||
local full = ownFull(ctx, at.rel)
|
||||
local fs = ctx.fs
|
||||
if full and fs and fs.getDirectoryItems then
|
||||
for _, name in ipairs(fs.getDirectoryItems(full) or {}) do add(name) end
|
||||
end
|
||||
end
|
||||
if at and at.key then
|
||||
local fs = persistFs(ctx)
|
||||
if fs and fs.getDirectoryItems then
|
||||
for _, name in ipairs(fs.getDirectoryItems(overlayPath(ctx, at.key)) or {}) do
|
||||
add(name)
|
||||
end
|
||||
end
|
||||
end
|
||||
table.sort(out)
|
||||
return out
|
||||
end
|
||||
|
||||
-- ------- buffered file handles
|
||||
|
||||
local function openBuffer(ctx, path, mode)
|
||||
mode = tostring(mode or "r"):gsub("b", "")
|
||||
local writable = mode:find("[wa+]") ~= nil
|
||||
local body
|
||||
if mode:sub(1, 1) == "w" then
|
||||
body = ""
|
||||
else
|
||||
body = readPath(ctx, path)
|
||||
if not body then
|
||||
if not writable then return nil, "could not open " .. tostring(path) end
|
||||
body = ""
|
||||
end
|
||||
end
|
||||
local state = {
|
||||
ctx = ctx, path = path, buf = body, pos = 1,
|
||||
writable = writable, closed = false, dirty = false,
|
||||
}
|
||||
if mode:sub(1, 1) == "a" then state.pos = #body + 1 end
|
||||
return state
|
||||
end
|
||||
|
||||
local function bufferFlush(state)
|
||||
if not (state.writable and state.dirty) then return true end
|
||||
local ok, err = writePath(state.ctx, state.path, state.buf)
|
||||
if ok then state.dirty = false end
|
||||
return ok, err
|
||||
end
|
||||
|
||||
local function bufferWrite(state, text)
|
||||
if not state.writable then return false, "file is not open for writing" end
|
||||
local head = state.buf:sub(1, state.pos - 1)
|
||||
if #head < state.pos - 1 then head = head .. string.rep("\0", state.pos - 1 - #head) end
|
||||
local tail = state.buf:sub(state.pos + #text)
|
||||
state.buf = head .. text .. tail
|
||||
state.pos = state.pos + #text
|
||||
state.dirty = true
|
||||
return true
|
||||
end
|
||||
|
||||
local function bufferRead(state, fmt)
|
||||
if fmt == nil then fmt = "*l" end
|
||||
if type(fmt) == "number" then
|
||||
if fmt == 0 then return state.pos <= #state.buf and "" or nil end
|
||||
if state.pos > #state.buf then return nil end
|
||||
local chunk = state.buf:sub(state.pos, state.pos + fmt - 1)
|
||||
state.pos = state.pos + #chunk
|
||||
return chunk
|
||||
end
|
||||
fmt = tostring(fmt):gsub("^%*", "")
|
||||
if fmt == "a" then
|
||||
local rest = state.buf:sub(state.pos)
|
||||
state.pos = #state.buf + 1
|
||||
return rest
|
||||
end
|
||||
if fmt == "l" or fmt == "L" then
|
||||
if state.pos > #state.buf then return nil end
|
||||
local nl = state.buf:find("\n", state.pos, true)
|
||||
local line
|
||||
if nl then
|
||||
line = state.buf:sub(state.pos, fmt == "L" and nl or nl - 1)
|
||||
state.pos = nl + 1
|
||||
else
|
||||
line = state.buf:sub(state.pos)
|
||||
state.pos = #state.buf + 1
|
||||
end
|
||||
return line
|
||||
end
|
||||
if fmt == "n" then
|
||||
local rest = state.buf:sub(state.pos)
|
||||
local text, after = rest:match("^%s*(%-?%d+%.?%d*[eE]?[-+]?%d*)()")
|
||||
if not text then return nil end
|
||||
state.pos = state.pos + after - 1
|
||||
return tonumber(text)
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function bufferSeek(state, whence, offset)
|
||||
whence = whence or "cur"
|
||||
offset = offset or 0
|
||||
if whence == "set" then state.pos = offset + 1
|
||||
elseif whence == "cur" then state.pos = state.pos + offset
|
||||
elseif whence == "end" then state.pos = #state.buf + offset + 1
|
||||
else return nil, "bad seek base" end
|
||||
if state.pos < 1 then state.pos = 1 end
|
||||
return state.pos - 1
|
||||
end
|
||||
|
||||
local function bufferClose(state)
|
||||
if state.closed then return true end
|
||||
local ok, err = bufferFlush(state)
|
||||
state.closed = true
|
||||
return ok, err
|
||||
end
|
||||
|
||||
local function ioFile(ctx, path, mode)
|
||||
local state, err = openBuffer(ctx, path, mode)
|
||||
if not state then return nil, err end
|
||||
local file = {}
|
||||
function file:read(...)
|
||||
local count = select("#", ...)
|
||||
if count == 0 then return bufferRead(state, "*l") end
|
||||
local out = {}
|
||||
for i = 1, count do out[i] = bufferRead(state, (select(i, ...))) end
|
||||
return unpack(out, 1, count)
|
||||
end
|
||||
function file:write(...)
|
||||
for i = 1, select("#", ...) do
|
||||
local value = select(i, ...)
|
||||
local ok, writeErr = bufferWrite(state, tostring(value))
|
||||
if not ok then return nil, writeErr end
|
||||
end
|
||||
return file
|
||||
end
|
||||
function file:lines(fmt)
|
||||
return function() return bufferRead(state, fmt or "*l") end
|
||||
end
|
||||
function file:seek(whence, offset) return bufferSeek(state, whence, offset) end
|
||||
function file:flush() bufferFlush(state) return file end
|
||||
function file:close() return bufferClose(state) end
|
||||
function file:setvbuf() return true end
|
||||
return file
|
||||
end
|
||||
|
||||
local function loveFile(ctx, path, mode)
|
||||
local state = nil
|
||||
local file = {}
|
||||
function file:open(openMode)
|
||||
local opened, err = openBuffer(ctx, path, openMode or mode or "r")
|
||||
if not opened then return false, err end
|
||||
state = opened
|
||||
return true
|
||||
end
|
||||
function file:isOpen() return state ~= nil and not state.closed end
|
||||
function file:read(bytes)
|
||||
if not state and not file:open("r") then return nil, 0 end
|
||||
local body = bytes and bufferRead(state, bytes) or bufferRead(state, "*a")
|
||||
if not body then return nil, 0 end
|
||||
return body, #body
|
||||
end
|
||||
function file:write(data, size)
|
||||
if not state and not file:open(mode or "w") then return false end
|
||||
data = tostring(data)
|
||||
if size then data = data:sub(1, size) end
|
||||
local ok, err = bufferWrite(state, data)
|
||||
if not ok then return false, err end
|
||||
bufferFlush(state)
|
||||
return true
|
||||
end
|
||||
function file:lines()
|
||||
if not state then file:open("r") end
|
||||
return function() return state and bufferRead(state, "*l") or nil end
|
||||
end
|
||||
function file:seek(offset)
|
||||
if not state then return false end
|
||||
bufferSeek(state, "set", offset or 0)
|
||||
return true
|
||||
end
|
||||
function file:tell() return state and (state.pos - 1) or 0 end
|
||||
function file:getSize()
|
||||
if state then return #state.buf end
|
||||
local body = readPath(ctx, path)
|
||||
return body and #body or 0
|
||||
end
|
||||
function file:flush() if state then bufferFlush(state) end return true end
|
||||
function file:close()
|
||||
if state then bufferClose(state) end
|
||||
state = nil
|
||||
return true
|
||||
end
|
||||
function file:getFilename() return path end
|
||||
function file:getMode() return mode or "c" end
|
||||
function file:setBuffer() return true end
|
||||
if mode and mode ~= "c" then file:open(mode) end
|
||||
return file
|
||||
end
|
||||
|
||||
-- ------- the love.filesystem stand-in
|
||||
|
||||
local function realFilesystem()
|
||||
return _G.love and _G.love.filesystem or nil
|
||||
end
|
||||
|
||||
local function filesystemShim(ctx)
|
||||
local fsShim = {}
|
||||
|
||||
local function readAdvice() return "reads now come from mod:read and mod.storage" end
|
||||
|
||||
function fsShim.read(a, b, c)
|
||||
local path, size = a, b
|
||||
if (a == "string" or a == "data") and type(b) == "string" then
|
||||
path, size = b, c
|
||||
end
|
||||
note(ctx, "love.filesystem.read", readAdvice())
|
||||
local body, err = readPath(ctx, path)
|
||||
if not body then return nil, err end
|
||||
if size and size >= 0 then body = body:sub(1, size) end
|
||||
return body, #body
|
||||
end
|
||||
|
||||
function fsShim.write(path, data, size)
|
||||
note(ctx, "love.filesystem.write",
|
||||
"writes are rerouted to this mod's private compat storage; migrate to mod.storage")
|
||||
data = tostring(data)
|
||||
if size then data = data:sub(1, size) end
|
||||
local ok, err = writePath(ctx, path, data)
|
||||
if not ok then return false, err end
|
||||
return true
|
||||
end
|
||||
|
||||
function fsShim.append(path, data, size)
|
||||
note(ctx, "love.filesystem.append",
|
||||
"writes are rerouted to this mod's private compat storage; migrate to mod.storage")
|
||||
data = tostring(data)
|
||||
if size then data = data:sub(1, size) end
|
||||
local existing = readPath(ctx, path) or ""
|
||||
local ok, err = writePath(ctx, path, existing .. data)
|
||||
if not ok then return false, err end
|
||||
return true
|
||||
end
|
||||
|
||||
function fsShim.lines(path)
|
||||
note(ctx, "love.filesystem.lines", readAdvice())
|
||||
local body = readPath(ctx, path)
|
||||
if not body then error("could not open " .. tostring(path), 2) end
|
||||
local pos = 1
|
||||
return function()
|
||||
if pos > #body then return nil end
|
||||
local nl = body:find("\n", pos, true)
|
||||
local line
|
||||
if nl then
|
||||
line = body:sub(pos, nl - 1)
|
||||
pos = nl + 1
|
||||
else
|
||||
line = body:sub(pos)
|
||||
pos = #body + 1
|
||||
end
|
||||
return line
|
||||
end
|
||||
end
|
||||
|
||||
function fsShim.load(path)
|
||||
note(ctx, "love.filesystem.load",
|
||||
"the chunk is compiled into this mod's sandbox; prefer require or mod:read plus load")
|
||||
local body = readPath(ctx, path)
|
||||
if not body then return nil, "could not open " .. tostring(path) end
|
||||
if not ctx.compile then return nil, "no sandbox is bound yet" end
|
||||
return ctx.compile(body, "@" .. tostring(path))
|
||||
end
|
||||
|
||||
function fsShim.getInfo(path, a, b)
|
||||
local info = infoPath(ctx, path)
|
||||
local filter = type(a) == "string" and a or nil
|
||||
local into = type(a) == "table" and a or (type(b) == "table" and b or nil)
|
||||
if not info then return nil end
|
||||
if filter and info.type ~= filter then return nil end
|
||||
if into then
|
||||
into.type, into.size, into.modtime = info.type, info.size, info.modtime
|
||||
return into
|
||||
end
|
||||
return info
|
||||
end
|
||||
|
||||
function fsShim.getDirectoryItems(path)
|
||||
note(ctx, "love.filesystem.getDirectoryItems", "use mod.assets:list")
|
||||
return listPath(ctx, path)
|
||||
end
|
||||
|
||||
function fsShim.createDirectory(path)
|
||||
local at = classify(ctx, path)
|
||||
if not (at and at.key) then return false end
|
||||
local fs = persistFs(ctx)
|
||||
if not (fs and fs.createDirectory) then return false end
|
||||
ensureParent(fs, overlayPath(ctx, at.key) .. "/.")
|
||||
fs.createDirectory(overlayPath(ctx, at.key))
|
||||
return true
|
||||
end
|
||||
|
||||
function fsShim.remove(path)
|
||||
local at = classify(ctx, path)
|
||||
if not (at and at.key) then return false end
|
||||
return overlayRemove(ctx, at.key)
|
||||
end
|
||||
|
||||
function fsShim.exists(path) return infoPath(ctx, path) ~= nil end
|
||||
|
||||
function fsShim.isFile(path)
|
||||
local info = infoPath(ctx, path)
|
||||
return info ~= nil and info.type == "file"
|
||||
end
|
||||
|
||||
function fsShim.isDirectory(path)
|
||||
local info = infoPath(ctx, path)
|
||||
return info ~= nil and info.type == "directory"
|
||||
end
|
||||
|
||||
function fsShim.getSize(path)
|
||||
local info = infoPath(ctx, path)
|
||||
if not info then return nil, "could not open " .. tostring(path) end
|
||||
return info.size or 0
|
||||
end
|
||||
|
||||
function fsShim.getLastModified(path)
|
||||
local info = infoPath(ctx, path)
|
||||
if not info then return nil, "could not open " .. tostring(path) end
|
||||
return info.modtime or 0
|
||||
end
|
||||
|
||||
local function virtual(call)
|
||||
note(ctx, call, "paths are virtual now; everything under the returned root "
|
||||
.. "lands in this mod's private compat storage")
|
||||
return ctx.virtualRoot
|
||||
end
|
||||
|
||||
function fsShim.getSaveDirectory() return virtual("love.filesystem.getSaveDirectory") end
|
||||
function fsShim.getWorkingDirectory() return virtual("love.filesystem.getWorkingDirectory") end
|
||||
function fsShim.getUserDirectory() return virtual("love.filesystem.getUserDirectory") end
|
||||
function fsShim.getAppdataDirectory() return virtual("love.filesystem.getAppdataDirectory") end
|
||||
function fsShim.getSourceBaseDirectory() return virtual("love.filesystem.getSourceBaseDirectory") end
|
||||
function fsShim.getRealDirectory() return ctx.virtualRoot end
|
||||
|
||||
function fsShim.getIdentity() return ctx.modId end
|
||||
|
||||
function fsShim.setIdentity()
|
||||
note(ctx, "love.filesystem.setIdentity",
|
||||
"a mod cannot repoint the save directory; the call does nothing")
|
||||
return false
|
||||
end
|
||||
|
||||
function fsShim.getRequirePath() return "" end
|
||||
function fsShim.setRequirePath() return false end
|
||||
function fsShim.getCRequirePath() return "" end
|
||||
function fsShim.setCRequirePath() return false end
|
||||
|
||||
function fsShim.mount()
|
||||
note(ctx, "love.filesystem.mount",
|
||||
"mounting is refused; ship the files inside your mod and use mod:read")
|
||||
return false
|
||||
end
|
||||
fsShim.unmount = fsShim.mount
|
||||
|
||||
function fsShim.newFile(path, mode) return loveFile(ctx, path, mode) end
|
||||
|
||||
function fsShim.newFileData(a, b)
|
||||
local real = realFilesystem()
|
||||
if type(a) == "string" and b == nil then
|
||||
note(ctx, "love.filesystem.newFileData", readAdvice())
|
||||
local body = readPath(ctx, a)
|
||||
if not body then return nil, "could not open " .. tostring(a) end
|
||||
if real and real.newFileData then return real.newFileData(body, a) end
|
||||
return nil, "no filesystem"
|
||||
end
|
||||
if real and real.newFileData then return real.newFileData(a, b) end
|
||||
return nil, "no filesystem"
|
||||
end
|
||||
|
||||
function fsShim.isFused()
|
||||
local real = realFilesystem()
|
||||
return real and real.isFused and real.isFused() or false
|
||||
end
|
||||
|
||||
function fsShim.areSymlinksEnabled() return false end
|
||||
function fsShim.setSymlinksEnabled() return false end
|
||||
function fsShim.init() return false end
|
||||
function fsShim.setSource() return false end
|
||||
|
||||
return setmetatable(fsShim, { __index = function(_, key)
|
||||
note(ctx, "love.filesystem." .. tostring(key),
|
||||
"there is no compat stand-in for it; use mod.storage or mod:read")
|
||||
return nil
|
||||
end })
|
||||
end
|
||||
|
||||
-- ------- the rest of the removed surface
|
||||
|
||||
local function systemShim(ctx)
|
||||
local function real() return _G.love and _G.love.system or nil end
|
||||
return {
|
||||
getOS = function()
|
||||
local sys = real()
|
||||
return sys and sys.getOS and sys.getOS() or "Unknown"
|
||||
end,
|
||||
getPowerInfo = function()
|
||||
local sys = real()
|
||||
if not (sys and sys.getPowerInfo) then return "unknown", nil, nil end
|
||||
return sys.getPowerInfo()
|
||||
end,
|
||||
getProcessorCount = function()
|
||||
local sys = real()
|
||||
return sys and sys.getProcessorCount and sys.getProcessorCount() or 1
|
||||
end,
|
||||
getClipboardText = function()
|
||||
note(ctx, "love.system.getClipboardText",
|
||||
"clipboard access stays sandboxed; the call returns an empty string")
|
||||
return ""
|
||||
end,
|
||||
setClipboardText = function()
|
||||
note(ctx, "love.system.setClipboardText",
|
||||
"clipboard access stays sandboxed; the call does nothing")
|
||||
return false
|
||||
end,
|
||||
openURL = function()
|
||||
note(ctx, "love.system.openURL",
|
||||
"launching a URL stays sandboxed; the call does nothing")
|
||||
return false
|
||||
end,
|
||||
vibrate = function(...)
|
||||
local sys = real()
|
||||
if sys and sys.vibrate then return sys.vibrate(...) end
|
||||
return false
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
local function eventShim(ctx)
|
||||
local function real() return _G.love and _G.love.event or nil end
|
||||
local shim = {
|
||||
quit = function()
|
||||
note(ctx, "love.event.quit",
|
||||
"a mod cannot close the game out from under the player; the call does nothing")
|
||||
return false
|
||||
end,
|
||||
}
|
||||
shim.push = function(name, ...)
|
||||
if name == "quit" then return shim.quit() end
|
||||
local ev = real()
|
||||
if ev and ev.push then return ev.push(name, ...) end
|
||||
return false
|
||||
end
|
||||
return setmetatable(shim, { __index = function(_, key)
|
||||
local ev = real()
|
||||
return ev and ev[key] or nil
|
||||
end })
|
||||
end
|
||||
|
||||
local function ioShim(ctx)
|
||||
local stream = {
|
||||
write = function(self, ...)
|
||||
for i = 1, select("#", ...) do io.write(tostring((select(i, ...)))) end
|
||||
return self
|
||||
end,
|
||||
flush = function(self) return self end,
|
||||
close = function() return true end,
|
||||
read = function() return nil end,
|
||||
lines = function() return function() return nil end end,
|
||||
seek = function() return 0 end,
|
||||
setvbuf = function() return true end,
|
||||
}
|
||||
local out = setmetatable({}, { __index = stream })
|
||||
local shim = {
|
||||
open = function(path, mode)
|
||||
note(ctx, "io.open",
|
||||
"the handle is backed by mod:read and this mod's private compat storage")
|
||||
local file, err = ioFile(ctx, path, mode)
|
||||
if not file then return nil, err, 2 end
|
||||
return file
|
||||
end,
|
||||
lines = function(path, fmt)
|
||||
if path == nil then return function() return nil end end
|
||||
note(ctx, "io.lines",
|
||||
"the handle is backed by mod:read and this mod's private compat storage")
|
||||
local file, err = ioFile(ctx, path, "r")
|
||||
if not file then error(err, 2) end
|
||||
return file:lines(fmt)
|
||||
end,
|
||||
close = function(file) if file and file.close then return file:close() end return true end,
|
||||
type = function(file)
|
||||
if type(file) == "table" and file.read and file.close then return "file" end
|
||||
return nil
|
||||
end,
|
||||
read = function() return nil end,
|
||||
write = function(...) return out:write(...) end,
|
||||
input = function() return out end,
|
||||
output = function() return out end,
|
||||
stdout = out,
|
||||
stderr = out,
|
||||
stdin = setmetatable({}, { __index = stream }),
|
||||
popen = function()
|
||||
return refuse(ctx, "io.popen", "spawning a process is refused")
|
||||
end,
|
||||
tmpfile = function()
|
||||
return ioFile(ctx, ctx.virtualRoot .. "/io_tmpfile", "w+")
|
||||
end,
|
||||
}
|
||||
return shim
|
||||
end
|
||||
|
||||
local function osShim(ctx)
|
||||
local HOME = { HOME = true, APPDATA = true, LOCALAPPDATA = true,
|
||||
USERPROFILE = true, XDG_DATA_HOME = true,
|
||||
XDG_CONFIG_HOME = true, TMPDIR = true, TEMP = true, TMP = true }
|
||||
return {
|
||||
getenv = function(name)
|
||||
note(ctx, "os.getenv",
|
||||
"the environment is hidden; home-like names answer with this mod's virtual root")
|
||||
if type(name) == "string" and HOME[name:upper()] then
|
||||
return ctx.virtualRoot
|
||||
end
|
||||
return nil
|
||||
end,
|
||||
remove = function(path)
|
||||
note(ctx, "os.remove", "the delete lands in this mod's private compat storage")
|
||||
local at = classify(ctx, path)
|
||||
if not (at and at.key and overlayRemove(ctx, at.key)) then
|
||||
return nil, tostring(path) .. ": no such file"
|
||||
end
|
||||
return true
|
||||
end,
|
||||
rename = function(from, to)
|
||||
note(ctx, "os.rename", "the move lands in this mod's private compat storage")
|
||||
local body = readPath(ctx, from)
|
||||
if not body then return nil, tostring(from) .. ": no such file" end
|
||||
local ok, err = writePath(ctx, to, body)
|
||||
if not ok then return nil, err end
|
||||
local at = classify(ctx, from)
|
||||
if at and at.key then overlayRemove(ctx, at.key) end
|
||||
return true
|
||||
end,
|
||||
tmpname = function()
|
||||
return ctx.virtualRoot .. "/tmp/os_tmpname"
|
||||
end,
|
||||
execute = function()
|
||||
note(ctx, "os.execute", "running a command is refused")
|
||||
return false
|
||||
end,
|
||||
exit = function()
|
||||
note(ctx, "os.exit",
|
||||
"a mod cannot end the process; the call does nothing")
|
||||
return false
|
||||
end,
|
||||
setlocale = function() return "C" end,
|
||||
}
|
||||
end
|
||||
|
||||
local function packageShim(ctx)
|
||||
local shim = { path = "", cpath = "", preload = {}, loaded = {}, loaders = {} }
|
||||
return setmetatable(shim, {
|
||||
__index = function(_, key)
|
||||
note(ctx, "package." .. tostring(key),
|
||||
"the module loader is sandboxed; use require for the supported engine modules")
|
||||
return nil
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
-- ------- love callbacks
|
||||
|
||||
local CALLBACKS = {
|
||||
load = true, update = true, draw = true, quit = true, lowmemory = true,
|
||||
threaderror = true, keypressed = true, keyreleased = true,
|
||||
textinput = true, textedited = true, mousemoved = true,
|
||||
mousepressed = true, mousereleased = true, wheelmoved = true,
|
||||
mousefocus = true, touchpressed = true, touchreleased = true,
|
||||
touchmoved = true, joystickadded = true, joystickremoved = true,
|
||||
joystickpressed = true, joystickreleased = true, joystickaxis = true,
|
||||
joystickhat = true, gamepadpressed = true, gamepadreleased = true,
|
||||
gamepadaxis = true, focus = true, visible = true, resize = true,
|
||||
filedropped = true, directorydropped = true, displayrotated = true,
|
||||
audiodevicechanged = true, localechanged = true,
|
||||
}
|
||||
|
||||
local REFUSED = {
|
||||
run = "love.run is the engine's fixed-step loop; use mod.hooks and mod.events",
|
||||
errorhandler = "love.errorhandler is how a crash reaches the player; "
|
||||
.. "use mod.events",
|
||||
}
|
||||
|
||||
-- ------- assembly
|
||||
|
||||
function LegacyCompat.new(opts)
|
||||
opts = opts or {}
|
||||
local ctx = {
|
||||
modId = opts.modId or "mod",
|
||||
modPath = opts.modPath,
|
||||
fs = opts.fs,
|
||||
game = opts.game,
|
||||
compile = nil,
|
||||
}
|
||||
ctx.virtualRoot = "/pokeport/" .. ctx.modId
|
||||
|
||||
local filesystem = filesystemShim(ctx)
|
||||
local io_ = ioShim(ctx)
|
||||
local system = systemShim(ctx)
|
||||
local event = eventShim(ctx)
|
||||
|
||||
local compat = {
|
||||
ctx = ctx,
|
||||
love = { filesystem = filesystem, system = system, event = event },
|
||||
os = osShim(ctx),
|
||||
globals = {
|
||||
io = io_,
|
||||
package = packageShim(ctx),
|
||||
loadfile = function(path)
|
||||
note(ctx, "loadfile",
|
||||
"the chunk is compiled into this mod's sandbox; prefer require or mod:read")
|
||||
local body = readPath(ctx, path)
|
||||
if not body then return nil, "could not open " .. tostring(path) end
|
||||
if not ctx.compile then return nil, "no sandbox is bound yet" end
|
||||
return ctx.compile(body, "@" .. tostring(path))
|
||||
end,
|
||||
},
|
||||
modules = {
|
||||
io = io_,
|
||||
["love.filesystem"] = filesystem,
|
||||
["love.system"] = system,
|
||||
["love.event"] = event,
|
||||
},
|
||||
}
|
||||
|
||||
compat.globals.dofile = function(path)
|
||||
local chunk, err = compat.globals.loadfile(path)
|
||||
if not chunk then error(err or ("could not open " .. tostring(path)), 2) end
|
||||
return chunk()
|
||||
end
|
||||
|
||||
function compat.module(name)
|
||||
if type(name) ~= "string" then return nil end
|
||||
local substitute = compat.modules[name]
|
||||
if substitute then
|
||||
note(ctx, ('require("%s")'):format(name),
|
||||
"it now answers with the compat stand-in, not the real module")
|
||||
return substitute
|
||||
end
|
||||
if name == "os" then
|
||||
note(ctx, 'require("os")',
|
||||
"it now answers with the compat stand-in, not the real module")
|
||||
return compat.osTable
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- true when the assignment is a callback chain, which is what a mod
|
||||
-- wrapping love.mousemoved did before the sandbox; false plus a reason for
|
||||
-- the two names the engine owns, false alone for a module table.
|
||||
function compat.assign(key, value)
|
||||
if REFUSED[key] then
|
||||
note(ctx, ("love.%s assignment"):format(key), REFUSED[key])
|
||||
return false, ("[%s] %s"):format(ctx.modId, REFUSED[key])
|
||||
end
|
||||
if not CALLBACKS[key] then return false end
|
||||
if value ~= nil and type(value) ~= "function" then return false end
|
||||
note(ctx, ("love.%s assignment"):format(key),
|
||||
"the callback lands on the real love table the way it did before the "
|
||||
.. "sandbox; prefer mod.hooks and mod.events")
|
||||
return true
|
||||
end
|
||||
|
||||
function compat.bind(env, compile)
|
||||
ctx.compile = compile
|
||||
compat.osTable = env and env.os or nil
|
||||
end
|
||||
|
||||
return compat
|
||||
end
|
||||
|
||||
return LegacyCompat
|
||||
+119
-2
@@ -4,6 +4,7 @@ local SaveData = require("src.core.SaveData")
|
||||
local Data = require("src.core.Data")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Version = require("src.core.Version")
|
||||
local RequiredImports = require("src.mods.RequiredImports")
|
||||
local Assets = require("src.render.Assets")
|
||||
local ModUI = require("src.ui.ModUI")
|
||||
local DateTime = require("src.core.DateTime")
|
||||
@@ -19,8 +20,11 @@ local Semver = require("src.mods.Semver")
|
||||
local Events = require("src.mods.Events")
|
||||
local Gen2Compat = require("src.mods.Gen2Compat")
|
||||
local Hooks = require("src.mods.Hooks")
|
||||
local LegacyCompat = require("src.mods.LegacyCompat")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Steps = require("src.mods.Steps")
|
||||
local Net = require("src.mods.Net")
|
||||
local Job = require("src.mods.Job")
|
||||
|
||||
local Loader = {}
|
||||
Loader.__index = Loader
|
||||
@@ -565,7 +569,24 @@ function Loader:_validate()
|
||||
elseif manifest.assets_transforms
|
||||
and not self:_exists(mod.path .. "/" .. manifest.assets_transforms) then
|
||||
reason = "assets_transforms file missing: " .. manifest.assets_transforms
|
||||
elseif manifest.game_version and not devEngine() then
|
||||
end
|
||||
if not reason and #(manifest.required_imports or {}) > 0 then
|
||||
for _, import in ipairs(manifest.required_imports) do
|
||||
local path = mod.path .. "/baseroms/" .. import.file
|
||||
if not self:_exists(path) then
|
||||
reason = "required import missing: " .. import.name
|
||||
break
|
||||
end
|
||||
local valid, importErr = RequiredImports.validateStored(
|
||||
manifest, import, self.fs)
|
||||
if not valid then
|
||||
reason = "required import invalid: " .. import.name
|
||||
.. " (" .. tostring(importErr) .. ")"
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
if not reason and manifest.game_version and not devEngine() then
|
||||
local ok, err = Semver.satisfies(Version.engine, manifest.game_version)
|
||||
if not ok then
|
||||
reason = ("needs game version %s, engine is %s")
|
||||
@@ -1051,6 +1072,51 @@ function Loader:_api(mod)
|
||||
return { available = function() return false end,
|
||||
sync = refuse, poll = refuse }
|
||||
end)(),
|
||||
-- Background HTTP, behind the "network" permission the player already
|
||||
-- sees. This is what love.thread is NOT: the worker runs engine code in
|
||||
-- an engine-owned pool, so a mod gets asynchrony without getting a Lua
|
||||
-- state the sandbox cannot reach. get() hands back an opaque handle;
|
||||
-- poll() is non-blocking, so nothing here can hang a frame.
|
||||
fetch = (function()
|
||||
if mod.manifest.permissionSet.network then
|
||||
return {
|
||||
available = function() return Net.available() end,
|
||||
get = function(_, url, opts) return Net.get(loader, modId, url, opts) end,
|
||||
poll = function(_, handle) return Net.poll(loader, modId, handle) end,
|
||||
release = function(_, handle) return Net.release(loader, modId, handle) end,
|
||||
cancel = function(_, handle) return Net.cancel(loader, modId, handle) end,
|
||||
}
|
||||
end
|
||||
local function refuse()
|
||||
error(('[%s] mod.fetch needs the "network" permission in '
|
||||
.. "manifest.json"):format(modId), 2)
|
||||
end
|
||||
return { available = function() return false end,
|
||||
get = refuse, poll = refuse, release = refuse, cancel = refuse }
|
||||
end)(),
|
||||
-- Background compute, behind the "background" permission. The worker
|
||||
-- rebuilds this mod's sandbox before loading the script, so a job is the
|
||||
-- one thing love.thread is not: off the main thread without a Lua state
|
||||
-- that escapes the sandbox. Plain data in, plain data out.
|
||||
job = (function()
|
||||
if mod.manifest.permissionSet.background then
|
||||
return {
|
||||
available = function() return Job.available() end,
|
||||
run = function(_, script, arg, opts)
|
||||
return Job.run(loader, modId, mod.path, script, arg, opts)
|
||||
end,
|
||||
poll = function(_, handle) return Job.poll(loader, modId, handle) end,
|
||||
release = function(_, handle) return Job.release(loader, modId, handle) end,
|
||||
cancel = function(_, handle) return Job.cancel(loader, modId, handle) end,
|
||||
}
|
||||
end
|
||||
local function refuse()
|
||||
error(('[%s] mod.job needs the "background" permission in '
|
||||
.. "manifest.json"):format(modId), 2)
|
||||
end
|
||||
return { available = function() return false end,
|
||||
run = refuse, poll = refuse, release = refuse, cancel = refuse }
|
||||
end)(),
|
||||
-- namespaced per mod; M11 backs these with save.modData /
|
||||
-- options.modOptions, the shape mods compile against is already final
|
||||
save = {
|
||||
@@ -1163,6 +1229,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({
|
||||
@@ -1179,12 +1278,16 @@ function Loader:_api(mod)
|
||||
loader.imageCache[full] = image
|
||||
return image
|
||||
end,
|
||||
list = listOwn,
|
||||
info = infoOwn,
|
||||
}, { __index = api.content })
|
||||
-- the mod's own directory and nothing above it: PhysFS already refuses a
|
||||
-- climb, but loader.fs is injectable and has no such floor
|
||||
function api:read(relative)
|
||||
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
|
||||
@@ -1233,12 +1336,24 @@ function Loader:_modEnv(mod)
|
||||
local id = mod.manifest.id
|
||||
local env = self.modEnv[id]
|
||||
if not env then
|
||||
env = Sandbox.envFor({ modId = id, permissions = mod.manifest.permissionSet })
|
||||
local loader = self
|
||||
local compat = LegacyCompat.new({
|
||||
modId = id, modPath = mod.path, fs = self.fs,
|
||||
game = function() return loader:_game() end,
|
||||
})
|
||||
env = Sandbox.envFor({ modId = id, permissions = mod.manifest.permissionSet,
|
||||
compat = compat })
|
||||
self.modEnv[id] = env
|
||||
end
|
||||
return env
|
||||
end
|
||||
|
||||
-- Which pre-sandbox calls each loaded mod actually took, for the manager's
|
||||
-- "needs updating" badge; nil id answers for every mod.
|
||||
function Loader:legacyReport(modId)
|
||||
return LegacyCompat.report(modId)
|
||||
end
|
||||
|
||||
function Loader:_loadMod(mod)
|
||||
local path = SafePath.join(mod.path, mod.manifest.entry, "manifest entry")
|
||||
local chunk, err = Sandbox.loadFile(self.fs, path, self:_modEnv(mod))
|
||||
@@ -1274,6 +1389,8 @@ function Loader:_rollback(modId)
|
||||
self.migrations[modId] = nil
|
||||
self.modSave[modId] = nil
|
||||
self.stepsQueues[modId] = nil
|
||||
Net.releaseAll(self, modId)
|
||||
Job.releaseAll(self, modId)
|
||||
end
|
||||
|
||||
-- a mod that explicitly swears it stays link-compatible while writing into a
|
||||
|
||||
+102
-2
@@ -11,7 +11,8 @@ local Manifest = {}
|
||||
|
||||
Manifest.PROFILES = { content = true, overhaul = true, total_conversion = true }
|
||||
Manifest.PERMISSIONS = { network = true, filesystem = true,
|
||||
engine_internals = true, steps = true }
|
||||
engine_internals = true, steps = true,
|
||||
background = true }
|
||||
|
||||
-- link-relevant registries; a mod that writes into one of these while
|
||||
-- declaring affects_link = false gets an attributed warning from the loader
|
||||
@@ -132,13 +133,97 @@ local function mergeConflictLists(conflicts, incompatible)
|
||||
return out
|
||||
end
|
||||
|
||||
local scrubUtf8 -- shared by required-import labels and top-level strings
|
||||
|
||||
-- User-supplied files a mod needs beside its own source. The launcher owns
|
||||
-- the picker and the copy; the mod only reads the resulting
|
||||
-- <mod>/baseroms/<file> through its existing scoped mod:read surface. MD5 is
|
||||
-- deliberately the manifest vocabulary here: ROM preservation databases and
|
||||
-- the mods consuming these files commonly identify dumps by MD5, and the
|
||||
-- digest is an identity check rather than a security boundary.
|
||||
local function parseImports(value, field, required)
|
||||
field = field or "required_imports"
|
||||
local out, ids, files = {}, {}, {}
|
||||
for _, entry in ipairs(array(value)) do
|
||||
assert(type(entry) == "table", field .. " entries must be objects")
|
||||
local id = entry.id
|
||||
assert(type(id) == "string" and id:match("^[%w_%-]+$"),
|
||||
field .. " id must contain only letters, numbers, _ or -")
|
||||
assert(not ids[id], "duplicate " .. field .. " id: " .. id)
|
||||
ids[id] = true
|
||||
|
||||
local file = entry.file
|
||||
assert(type(file) == "string" and file ~= "",
|
||||
field .. " file is required")
|
||||
file = SafePath.require(file, field .. " file")
|
||||
assert(not file:find("/", 1, true),
|
||||
field .. " file must be a filename inside baseroms")
|
||||
assert(file:sub(1, 1) ~= ".",
|
||||
field .. " file must not use a hidden metadata filename")
|
||||
assert(not files[file], "duplicate " .. field .. " file: " .. file)
|
||||
files[file] = true
|
||||
|
||||
local hashes = entry.md5
|
||||
if type(hashes) == "string" then hashes = { hashes } end
|
||||
assert(type(hashes) == "table" and #hashes > 0,
|
||||
field .. " md5 must be a hash or non-empty array")
|
||||
local accepted, seen = {}, {}
|
||||
for _, digest in ipairs(hashes) do
|
||||
assert(type(digest) == "string" and digest:match("^[%x]+$")
|
||||
and #digest == 32, field .. " md5 values must be 32 hex characters")
|
||||
digest = digest:lower()
|
||||
if not seen[digest] then
|
||||
seen[digest] = true
|
||||
accepted[#accepted + 1] = digest
|
||||
end
|
||||
end
|
||||
|
||||
local format = entry.format or "raw"
|
||||
assert(format == "raw" or format == "n64",
|
||||
field .. " format must be raw or n64")
|
||||
local name = entry.name or id
|
||||
assert(type(name) == "string" and name ~= "",
|
||||
field .. " name must be a non-empty string")
|
||||
local description = entry.description or entry.hint
|
||||
if description ~= nil then
|
||||
assert(type(description) == "string" and description ~= "",
|
||||
field .. " description must be a non-empty string")
|
||||
end
|
||||
local size = entry.size
|
||||
local maxSize = entry.max_size
|
||||
local function validateSize(value, label)
|
||||
if value == nil then return end
|
||||
assert(type(value) == "number" and value > 0 and value % 1 == 0,
|
||||
field .. " " .. label .. " must be a positive integer")
|
||||
assert(value <= 128 * 1024 * 1024,
|
||||
field .. " " .. label .. " exceeds the 128 MiB hard limit")
|
||||
end
|
||||
validateSize(size, "size")
|
||||
validateSize(maxSize, "max_size")
|
||||
assert(not (size and maxSize) or size <= maxSize,
|
||||
field .. " size must not exceed max_size")
|
||||
out[#out + 1] = {
|
||||
id = id,
|
||||
name = scrubUtf8(name),
|
||||
description = scrubUtf8(description),
|
||||
file = file,
|
||||
md5 = accepted,
|
||||
format = format,
|
||||
size = size,
|
||||
max_size = maxSize,
|
||||
required = required ~= false,
|
||||
}
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- Drop bytes that are not valid UTF-8 (malformed sequences, overlongs,
|
||||
-- surrogates, > U+10FFFF) and a leading BOM. LÖVE's text renderer raises
|
||||
-- "Invalid UTF-8" from love.graphics.print/printf, so any manifest string a
|
||||
-- panel may draw must be scrubbed here -- the one place every mod manifest
|
||||
-- passes through -- or a single mangled description crashes the whole MODS
|
||||
-- panel instead of misrendering one card.
|
||||
local function scrubUtf8(s)
|
||||
scrubUtf8 = function(s)
|
||||
if type(s) ~= "string" then return s end
|
||||
s = s:gsub("^\239\187\191", "")
|
||||
local out, i, n = {}, 1, #s
|
||||
@@ -290,6 +375,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 +416,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,
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
-- Background HTTP for sandboxed mods, behind the "network" permission.
|
||||
--
|
||||
-- The sandbox blocks love.thread because newThread boots a Lua state with a
|
||||
-- full standard library that none of the sandbox's rules reach -- one call and
|
||||
-- a mod has io back. That is correct, but it left mods with no way to do
|
||||
-- anything off the main thread at all: the only reachable transports
|
||||
-- (socket, http) block, so a mod that wanted to fetch something had to hang
|
||||
-- the game to do it.
|
||||
--
|
||||
-- This is the narrow replacement. src/net/Fetch.lua already runs a pool of
|
||||
-- engine-owned worker threads, and those workers run OUR code, not the mod's,
|
||||
-- so handing a mod a job in that pool grants no new reach. A mod submits a
|
||||
-- URL and polls for the body; it never gets a thread, a path, or a raw handle
|
||||
-- into the shared job table.
|
||||
--
|
||||
-- WHAT THIS FILE HAS TO GET RIGHT, because Fetch itself is shared with the
|
||||
-- launcher:
|
||||
-- * Handles are opaque tables owned per mod. Fetch keys jobs by integer,
|
||||
-- and the launcher's own ROM download and index fetches live in the same
|
||||
-- table; an integer handed to a mod would let it poll (or cancel) work
|
||||
-- that is not its own. A forged table simply misses the lookup.
|
||||
-- * Only http and https. The transport is curl, which also speaks file://,
|
||||
-- scp:// and ftp://; without this check mod.fetch would be a filesystem
|
||||
-- read and the sandbox would be back to square one.
|
||||
-- * A per-mod ceiling on jobs in flight, so one mod cannot fill the shared
|
||||
-- three-worker pool and starve the launcher's own fetches.
|
||||
|
||||
local Net = {}
|
||||
|
||||
-- Per mod, not global: the pool is shared with the launcher and a mod should
|
||||
-- never be able to monopolise it.
|
||||
Net.MAX_INFLIGHT = 4
|
||||
-- Clamp on the caller's timeout, so a mod cannot pin a worker indefinitely.
|
||||
Net.MAX_SECONDS = 30
|
||||
|
||||
local function fetch()
|
||||
return require("src.net.Fetch")
|
||||
end
|
||||
|
||||
-- http/https only, and a host must actually be present -- "http://" alone
|
||||
-- reaches curl as a malformed URL rather than being refused here.
|
||||
function Net.urlDenial(url)
|
||||
if type(url) ~= "string" or url == "" then return "url must be a string" end
|
||||
local scheme, rest = url:match("^(%a[%w+.-]*)://(.*)$")
|
||||
if not scheme then return "url must start with http:// or https://" end
|
||||
scheme = scheme:lower()
|
||||
if scheme ~= "http" and scheme ~= "https" then
|
||||
return ("%s:// is not allowed; mod.fetch speaks http and https only")
|
||||
:format(scheme)
|
||||
end
|
||||
if rest == "" or rest:match("^/") then return "url has no host" end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function bucket(loader, modId)
|
||||
loader.netJobs = loader.netJobs or {}
|
||||
local b = loader.netJobs[modId]
|
||||
if not b then b = {}; loader.netJobs[modId] = b end
|
||||
return b
|
||||
end
|
||||
|
||||
local function inflight(b)
|
||||
local n = 0
|
||||
for _, id in pairs(b) do
|
||||
if fetch().isPending(id) then n = n + 1 end
|
||||
end
|
||||
return n
|
||||
end
|
||||
|
||||
function Net.available()
|
||||
local ok, F = pcall(fetch)
|
||||
if not ok then return false end
|
||||
local okAvail, avail = pcall(F.available)
|
||||
return okAvail and avail and true or false
|
||||
end
|
||||
|
||||
-- Returns an opaque handle, or nil plus a reason.
|
||||
function Net.get(loader, modId, url, opts)
|
||||
local denial = Net.urlDenial(url)
|
||||
if denial then return nil, denial end
|
||||
opts = type(opts) == "table" and opts or {}
|
||||
local b = bucket(loader, modId)
|
||||
if inflight(b) >= Net.MAX_INFLIGHT then
|
||||
return nil, ("too many requests in flight (limit %d); poll and release "
|
||||
.. "the ones you have"):format(Net.MAX_INFLIGHT)
|
||||
end
|
||||
local maxSeconds = tonumber(opts.maxSeconds) or Net.MAX_SECONDS
|
||||
if maxSeconds > Net.MAX_SECONDS then maxSeconds = Net.MAX_SECONDS end
|
||||
if maxSeconds < 1 then maxSeconds = 1 end
|
||||
-- The mod is named in the agent string so a server operator can see which
|
||||
-- mod is calling them, and a mod cannot pretend to be the launcher.
|
||||
local id = fetch().get(url, {
|
||||
userAgent = "gen1recomp-mod/" .. tostring(modId),
|
||||
accept = type(opts.accept) == "string" and opts.accept or nil,
|
||||
maxSeconds = maxSeconds,
|
||||
})
|
||||
local handle = {}
|
||||
b[handle] = id
|
||||
return handle
|
||||
end
|
||||
|
||||
-- A copy of the job's state, never the engine's own table. An unknown or
|
||||
-- forged handle reads as an error rather than nil, so a mod that lost track of
|
||||
-- one cannot spin waiting on it forever.
|
||||
function Net.poll(loader, modId, handle)
|
||||
local id = bucket(loader, modId)[handle]
|
||||
if not id then return { status = "error", err = "unknown request" } end
|
||||
local st = fetch().poll(id)
|
||||
return { status = st.status, body = st.body, err = st.err,
|
||||
progress = st.progress }
|
||||
end
|
||||
|
||||
function Net.release(loader, modId, handle)
|
||||
local b = bucket(loader, modId)
|
||||
local id = b[handle]
|
||||
if not id then return false end
|
||||
fetch().release(id)
|
||||
b[handle] = nil
|
||||
return true
|
||||
end
|
||||
|
||||
function Net.cancel(loader, modId, handle)
|
||||
local id = bucket(loader, modId)[handle]
|
||||
if not id then return false end
|
||||
fetch().cancel(id)
|
||||
return true
|
||||
end
|
||||
|
||||
-- Drop everything this mod still holds. Called when a mod unloads, so a
|
||||
-- disabled mod cannot leave jobs accumulating in the shared table.
|
||||
function Net.releaseAll(loader, modId)
|
||||
local b = loader.netJobs and loader.netJobs[modId]
|
||||
if not b then return end
|
||||
local F = fetch()
|
||||
for handle, id in pairs(b) do
|
||||
pcall(F.cancel, id)
|
||||
pcall(F.release, id)
|
||||
b[handle] = nil
|
||||
end
|
||||
loader.netJobs[modId] = nil
|
||||
end
|
||||
|
||||
return Net
|
||||
@@ -0,0 +1,295 @@
|
||||
-- Engine-owned import handling for files declared by a mod's required or
|
||||
-- optional import arrays. Mods never receive host paths or broader
|
||||
-- filesystem access: accepted bytes are copied into their own
|
||||
-- mods/<id>/baseroms/ tree, where the existing mod:read sandbox can see them.
|
||||
|
||||
local CacheFs = require("src.import.CacheFs")
|
||||
|
||||
local RequiredImports = {}
|
||||
|
||||
local function allSpecs(manifest)
|
||||
local out = {}
|
||||
for _, spec in ipairs((manifest and manifest.required_imports) or {}) do
|
||||
out[#out + 1] = spec
|
||||
end
|
||||
for _, spec in ipairs((manifest and manifest.optional_imports) or {}) do
|
||||
out[#out + 1] = spec
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
local function isRequired(spec)
|
||||
return spec.required ~= false
|
||||
end
|
||||
|
||||
RequiredImports.specs = allSpecs
|
||||
RequiredImports.MAX_BYTES = 128 * 1024 * 1024
|
||||
|
||||
local function sizeLabel(bytes)
|
||||
return ("%.1f MiB"):format(bytes / (1024 * 1024))
|
||||
end
|
||||
|
||||
-- Check size before a caller reads an external or stored file into one large
|
||||
-- Lua string. N64 sources may carry a 512-byte copier header, while stored
|
||||
-- files are always canonical and therefore must match the declared size.
|
||||
function RequiredImports.sizeError(spec, size, stored)
|
||||
if type(size) ~= "number" then return nil end
|
||||
if size > RequiredImports.MAX_BYTES then
|
||||
return ("file is too large (%s; hard limit is %s)")
|
||||
:format(sizeLabel(size), sizeLabel(RequiredImports.MAX_BYTES))
|
||||
end
|
||||
local headerAllowance = not stored and spec and spec.format == "n64" and 512 or 0
|
||||
if spec and spec.size then
|
||||
if size ~= spec.size and size ~= spec.size + headerAllowance then
|
||||
return ("wrong file size (expected %d bytes%s, got %d)")
|
||||
:format(spec.size, headerAllowance > 0 and " or a 512-byte header" or "", size)
|
||||
end
|
||||
end
|
||||
if spec and spec.max_size and size > spec.max_size + headerAllowance then
|
||||
return ("file is too large for this import (maximum %d bytes, got %d)")
|
||||
:format(spec.max_size, size)
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local N64_MAGIC = {
|
||||
["\128\55\18\64"] = "z64", -- big endian / canonical
|
||||
["\55\128\64\18"] = "v64", -- byte-swapped
|
||||
["\64\18\55\128"] = "n64", -- little endian words
|
||||
}
|
||||
|
||||
local function n64KindAt(data, offset)
|
||||
return N64_MAGIC[data:sub(offset, offset + 3)]
|
||||
end
|
||||
|
||||
-- Return canonical big-endian N64 bytes. A 512-byte copier header is
|
||||
-- recognized only when valid N64 magic follows it, so arbitrary data is never
|
||||
-- shortened just because its size happens to line up.
|
||||
function RequiredImports.normalizeN64(data)
|
||||
if type(data) ~= "string" then return nil, "selected file could not be read" end
|
||||
local offset, kind = 1, n64KindAt(data, 1)
|
||||
if not kind then
|
||||
kind = n64KindAt(data, 513)
|
||||
if kind then offset = 513 end
|
||||
end
|
||||
if not kind then
|
||||
return nil, "expected an N64 ROM (.z64/.v64/.n64); file signature was not recognized"
|
||||
end
|
||||
data = data:sub(offset)
|
||||
if kind == "z64" then return data end
|
||||
|
||||
if kind == "v64" then
|
||||
if #data % 2 ~= 0 then return nil, "byte-swapped N64 ROM has an odd size" end
|
||||
return (data:gsub("(.)(.)", "%2%1"))
|
||||
else
|
||||
if #data % 4 ~= 0 then return nil, "little-endian N64 ROM size is not word aligned" end
|
||||
return (data:gsub("(.)(.)(.)(.)", "%4%3%2%1"))
|
||||
end
|
||||
end
|
||||
|
||||
function RequiredImports.normalize(spec, data)
|
||||
if spec and spec.format == "n64" then
|
||||
return RequiredImports.normalizeN64(data)
|
||||
end
|
||||
if type(data) ~= "string" then return nil, "selected file could not be read" end
|
||||
return data
|
||||
end
|
||||
|
||||
local function hexDigest(data, hashFn)
|
||||
if hashFn then return hashFn(data):lower() end
|
||||
if not (love and love.data and love.data.hash and love.data.encode) then
|
||||
return nil, "MD5 support is unavailable in this build"
|
||||
end
|
||||
local digest = love.data.hash("md5", data)
|
||||
if type(digest) == "userdata" and digest.getString then
|
||||
digest = digest:getString()
|
||||
end
|
||||
return love.data.encode("string", "hex", digest):lower()
|
||||
end
|
||||
|
||||
local function accepts(spec, digest)
|
||||
for _, wanted in ipairs((spec and spec.md5) or {}) do
|
||||
if wanted == digest then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
function RequiredImports.path(manifest, spec)
|
||||
return manifest.path .. "/baseroms/" .. spec.file
|
||||
end
|
||||
|
||||
local function removedMarker(manifest, spec)
|
||||
return manifest.path .. "/baseroms/.required-import-" .. spec.id .. ".removed"
|
||||
end
|
||||
|
||||
local function receiptPath(manifest, spec)
|
||||
return manifest.path .. "/baseroms/.required-import-" .. spec.id .. ".validated"
|
||||
end
|
||||
|
||||
RequiredImports.receiptPath = receiptPath
|
||||
|
||||
local function parseReceipt(raw)
|
||||
if type(raw) ~= "string" then return nil end
|
||||
local digest, size, modtime = raw:match("^v1\n([%x]+)\n(%d+)\n([^\n]+)\n?$")
|
||||
if not digest then return nil end
|
||||
return digest:lower(), tonumber(size), tonumber(modtime)
|
||||
end
|
||||
|
||||
local function cachedDigest(manifest, spec, fs, info)
|
||||
-- A size alone cannot detect a same-length replacement. Require modtime as
|
||||
-- well; filesystems that do not expose it simply take the safe hash path.
|
||||
if not (fs and fs.read and info and info.size and info.modtime) then return nil end
|
||||
local digest, size, modtime = parseReceipt(fs.read(receiptPath(manifest, spec)))
|
||||
if digest and size == info.size and modtime == info.modtime
|
||||
and accepts(spec, digest) then
|
||||
return digest
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function writeReceipt(manifest, spec, digest, info, fs)
|
||||
if not (digest and info and info.size and info.modtime) then return end
|
||||
local path = receiptPath(manifest, spec)
|
||||
local body = ("v1\n%s\n%d\n%s\n")
|
||||
:format(digest, info.size, tostring(info.modtime))
|
||||
if love and fs == love.filesystem then
|
||||
local savedPrefix = CacheFs.prefix
|
||||
CacheFs.prefix = ""
|
||||
CacheFs.write(path, body)
|
||||
CacheFs.prefix = savedPrefix
|
||||
elseif fs and fs.write then
|
||||
fs.write(path, body)
|
||||
end
|
||||
end
|
||||
|
||||
local function removeReceipt(manifest, spec, fs)
|
||||
local path = receiptPath(manifest, spec)
|
||||
if love and fs == love.filesystem then
|
||||
local savedPrefix = CacheFs.prefix
|
||||
CacheFs.prefix = ""
|
||||
CacheFs.remove(path)
|
||||
CacheFs.prefix = savedPrefix
|
||||
elseif fs and fs.remove then
|
||||
fs.remove(path)
|
||||
end
|
||||
end
|
||||
|
||||
-- Validate bytes against a declaration. The returned data is canonicalized
|
||||
-- (notably for N64 byte order/header variants) and is what must be stored.
|
||||
function RequiredImports.validateData(spec, data, hashFn)
|
||||
local sourceSizeErr = type(data) == "string"
|
||||
and RequiredImports.sizeError(spec, #data, false)
|
||||
if sourceSizeErr then return nil, sourceSizeErr end
|
||||
local normalized, normalizeErr = RequiredImports.normalize(spec, data)
|
||||
if not normalized then return nil, normalizeErr end
|
||||
local storedSizeErr = RequiredImports.sizeError(spec, #normalized, true)
|
||||
if storedSizeErr then return nil, storedSizeErr end
|
||||
local digest, hashErr = hexDigest(normalized, hashFn)
|
||||
if not digest then return nil, hashErr end
|
||||
if not accepts(spec, digest) then
|
||||
return nil, ("MD5 mismatch (got %s)"):format(digest)
|
||||
end
|
||||
return normalized, digest
|
||||
end
|
||||
|
||||
-- Validate one installed import without reading it when the engine-authored
|
||||
-- receipt still matches the file's size and modification time.
|
||||
function RequiredImports.validateStored(manifest, spec, fs, hashFn)
|
||||
fs = fs or (love and love.filesystem)
|
||||
if not (fs and fs.getInfo) then return nil, "filesystem is unavailable" end
|
||||
local path = RequiredImports.path(manifest, spec)
|
||||
local info = fs.getInfo(path, "file")
|
||||
if not info then
|
||||
removeReceipt(manifest, spec, fs)
|
||||
return nil, "file is missing"
|
||||
end
|
||||
local sizeErr = RequiredImports.sizeError(spec, info.size, true)
|
||||
if sizeErr then
|
||||
removeReceipt(manifest, spec, fs)
|
||||
return nil, sizeErr
|
||||
end
|
||||
local cached = cachedDigest(manifest, spec, fs, info)
|
||||
if cached then return true, cached, true end
|
||||
removeReceipt(manifest, spec, fs)
|
||||
if not fs.read then return nil, "file could not be read" end
|
||||
local data = fs.read(path)
|
||||
local normalized, detail = RequiredImports.validateStoredData(spec, data, hashFn)
|
||||
if not normalized then return nil, detail end
|
||||
info = fs.getInfo(path, "file") or info
|
||||
info.size = info.size or #data
|
||||
writeReceipt(manifest, spec, detail, info, fs)
|
||||
return true, detail, false
|
||||
end
|
||||
|
||||
function RequiredImports.validateStoredData(spec, data, hashFn)
|
||||
local normalized, detail = RequiredImports.validateData(spec, data, hashFn)
|
||||
if not normalized then return nil, detail end
|
||||
if normalized ~= data then
|
||||
return nil, "stored N64 ROM is not canonical; choose the source file again"
|
||||
end
|
||||
return normalized, detail
|
||||
end
|
||||
|
||||
function RequiredImports.inspect(manifest, fs, hashFn)
|
||||
fs = fs or (love and love.filesystem)
|
||||
local rows, missing, missingOptional = {}, 0, 0
|
||||
for _, spec in ipairs(allSpecs(manifest)) do
|
||||
local path = RequiredImports.path(manifest, spec)
|
||||
local suppressed = fs and fs.getInfo
|
||||
and fs.getInfo(removedMarker(manifest, spec), "file") ~= nil
|
||||
local exists = fs and fs.getInfo and fs.getInfo(path, "file") ~= nil
|
||||
local valid, detail = RequiredImports.validateStored(manifest, spec, fs, hashFn)
|
||||
local row = { id = spec.id, name = spec.name, file = spec.file,
|
||||
description = spec.description, format = spec.format, path = path,
|
||||
present = valid == true, digest = valid and detail or nil,
|
||||
error = exists and not valid and detail or nil,
|
||||
suppressed = suppressed, required = isRequired(spec), spec = spec }
|
||||
if not row.present then
|
||||
if row.required then missing = missing + 1
|
||||
else missingOptional = missingOptional + 1 end
|
||||
end
|
||||
rows[#rows + 1] = row
|
||||
end
|
||||
return rows, missing, missingOptional
|
||||
end
|
||||
|
||||
function RequiredImports.importData(manifest, importId, data, opts)
|
||||
opts = opts or {}
|
||||
local spec
|
||||
for _, candidate in ipairs(allSpecs(manifest)) do
|
||||
if candidate.id == importId then spec = candidate break end
|
||||
end
|
||||
if not spec then return nil, "unknown required import: " .. tostring(importId) end
|
||||
local normalized, digest = RequiredImports.validateData(spec, data, opts.hash)
|
||||
if not normalized then return nil, digest end
|
||||
local savedPrefix = CacheFs.prefix
|
||||
CacheFs.prefix = ""
|
||||
CacheFs.remove(receiptPath(manifest, spec))
|
||||
local ok, err = CacheFs.write(RequiredImports.path(manifest, spec), normalized)
|
||||
if ok then CacheFs.remove(removedMarker(manifest, spec)) end
|
||||
if ok and love and love.filesystem and love.filesystem.getInfo then
|
||||
local info = love.filesystem.getInfo(RequiredImports.path(manifest, spec), "file")
|
||||
writeReceipt(manifest, spec, digest, info, love.filesystem)
|
||||
end
|
||||
CacheFs.prefix = savedPrefix
|
||||
if not ok then return nil, "could not copy import: " .. tostring(err) end
|
||||
return true, digest
|
||||
end
|
||||
|
||||
function RequiredImports.remove(manifest, importId)
|
||||
for _, spec in ipairs(allSpecs(manifest)) do
|
||||
if spec.id == importId then
|
||||
local savedPrefix = CacheFs.prefix
|
||||
CacheFs.prefix = ""
|
||||
CacheFs.remove(RequiredImports.path(manifest, spec))
|
||||
CacheFs.remove(receiptPath(manifest, spec))
|
||||
local marked, markErr = CacheFs.write(removedMarker(manifest, spec), "removed\n")
|
||||
CacheFs.prefix = savedPrefix
|
||||
if not marked then return nil, "could not remember removal: " .. tostring(markErr) end
|
||||
return true
|
||||
end
|
||||
end
|
||||
return nil, "unknown required import: " .. tostring(importId)
|
||||
end
|
||||
|
||||
return RequiredImports
|
||||
+42
-13
@@ -46,11 +46,11 @@ function Sandbox.moduleDenial(name, permissionSet)
|
||||
local reason = DENIED[root]
|
||||
if reason then
|
||||
return ("%s is not available to mods (it grants %s); use mod.storage, "
|
||||
.. "mod:read and the engine API instead"):format(name, reason)
|
||||
.. "mod:read, mod:list and the engine API instead"):format(name, reason)
|
||||
end
|
||||
if DENIED_PREFIX[root] and name ~= root then
|
||||
return ("%s is not available to mods; use mod.storage, mod:read and the "
|
||||
.. "engine API instead"):format(name)
|
||||
return ("%s is not available to mods; use mod.storage, mod:read, mod:list "
|
||||
.. "and the engine API instead"):format(name)
|
||||
end
|
||||
if NETWORK[root] and not (permissionSet or {}).network then
|
||||
return ("%s needs the \"network\" permission in manifest.json"):format(name)
|
||||
@@ -68,16 +68,24 @@ end
|
||||
-- without an edit here.
|
||||
-- value is the replacement to name in the error, or true when there is none
|
||||
local BLOCKED_LOVE = {
|
||||
filesystem = "mod.storage and mod:read", thread = true,
|
||||
filesystem = "mod.storage, mod:read and mod:list",
|
||||
-- newThread's state has a full standard library and none of this file's
|
||||
-- rules, so it stays blocked -- but the reason mods reached for it was
|
||||
-- background work, and mod.fetch is that without the escape.
|
||||
thread = 'mod.fetch for background HTTP (needs the "network" permission)',
|
||||
system = "mod.device:powerInfo() for battery information, mod.steps for "
|
||||
.. "the step bridge", event = true,
|
||||
}
|
||||
|
||||
local loveProxy
|
||||
local function loveFacade()
|
||||
if loveProxy or not _G.love then return loveProxy end
|
||||
loveProxy = setmetatable({}, {
|
||||
-- Per-mod, because the compat overrides (src/mods/LegacyCompat.lua) are backed
|
||||
-- by that mod's own overlay and must not be shared.
|
||||
local function loveFacade(compat)
|
||||
if not _G.love then return nil end
|
||||
local overrides = compat and compat.love
|
||||
return setmetatable({}, {
|
||||
__index = function(_, key)
|
||||
local override = overrides and overrides[key]
|
||||
if override ~= nil then return override end
|
||||
local hint = BLOCKED_LOVE[key]
|
||||
if hint then
|
||||
error(("love.%s is not available to mods%s"):format(key,
|
||||
@@ -85,11 +93,20 @@ local function loveFacade()
|
||||
end
|
||||
return _G.love[key]
|
||||
end,
|
||||
__newindex = function(_, key)
|
||||
-- a callback chain lands on the real table (compat.assign decides which
|
||||
-- names qualify); a module table never does
|
||||
__newindex = function(_, key, value)
|
||||
if compat then
|
||||
local allowed, reason = compat.assign(key, value)
|
||||
if allowed then
|
||||
_G.love[key] = value
|
||||
return
|
||||
end
|
||||
if reason then error(reason, 2) end
|
||||
end
|
||||
error(("mods cannot assign love.%s"):format(tostring(key)), 2)
|
||||
end,
|
||||
})
|
||||
return loveProxy
|
||||
end
|
||||
|
||||
-- ------- the environment
|
||||
@@ -177,8 +194,12 @@ end
|
||||
-- Runtime.modRequire is how the loader's gate identifies the caller for the
|
||||
-- Gen 2 facade once Runtime.currentMod has gone back to nil (a mod requiring
|
||||
-- lazily from an event handler).
|
||||
local function sandboxedRequire(modId, permissionSet)
|
||||
local function sandboxedRequire(modId, permissionSet, compat)
|
||||
return function(name, ...)
|
||||
-- the compat stand-in answers first, so a legacy require("io") gets the
|
||||
-- rerouted table instead of the denial below (src/mods/LegacyCompat.lua)
|
||||
local substitute = compat and compat.module(name)
|
||||
if substitute ~= nil then return substitute end
|
||||
local denial = Sandbox.moduleDenial(name, permissionSet)
|
||||
if denial then error(("[%s] %s"):format(modId or "mod", denial), 2) end
|
||||
local previous = Runtime.modRequire
|
||||
@@ -192,15 +213,23 @@ end
|
||||
|
||||
function Sandbox.envFor(opts)
|
||||
opts = opts or {}
|
||||
local compat = opts.compat
|
||||
local env = baseGlobals()
|
||||
env.love = loveFacade()
|
||||
env.require = sandboxedRequire(opts.modId, opts.permissions)
|
||||
env.love = loveFacade(compat)
|
||||
env.require = sandboxedRequire(opts.modId, opts.permissions, compat)
|
||||
local loader = sandboxedLoad(env)
|
||||
env.load = loader
|
||||
env.loadstring = loader
|
||||
-- a mod's globals are its own: two mods no longer share a namespace, and
|
||||
-- neither can reach the engine's
|
||||
env._G = env
|
||||
if compat then
|
||||
for key, value in pairs(compat.globals) do env[key] = value end
|
||||
for key, value in pairs(compat.os) do env.os[key] = value end
|
||||
compat.bind(env, function(source, chunkname)
|
||||
return Sandbox.compile(source, chunkname, env)
|
||||
end)
|
||||
end
|
||||
return env
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
-- Worker state behind src/mods/Job.lua. One per job, not a pool: a reused
|
||||
-- state would carry one mod's globals into another mod's job.
|
||||
--
|
||||
-- This is the file that makes running mod Lua off the main thread safe. The
|
||||
-- mod's chunk is loaded into the SAME sandbox environment the main thread
|
||||
-- builds (Sandbox.envFor), so love.filesystem, io, os, debug, ffi and package
|
||||
-- are as absent here as they are there -- even though this state required
|
||||
-- love.filesystem to bootstrap itself.
|
||||
--
|
||||
-- A job is pure compute: plain data in, plain data out, no engine API, no
|
||||
-- game state, no storage. require is refused outright rather than reaching
|
||||
-- src.* -- an engine module loaded in a second state would be a second
|
||||
-- instance writing the same files as the main thread's.
|
||||
|
||||
require("love.thread")
|
||||
require("love.filesystem")
|
||||
require("love.timer")
|
||||
|
||||
local modId, scriptPath, argChannel, resultChannel, permissionsJson = ...
|
||||
|
||||
-- Fresh love threads have no "src.*" searcher (see src/net/fetch_worker.lua),
|
||||
-- so install one before Sandbox's own requires run.
|
||||
table.insert(package.loaders or package.searchers, function(name)
|
||||
local path = name:gsub("%.", "/") .. ".lua"
|
||||
if not love.filesystem.getInfo(path) then return nil end
|
||||
return love.filesystem.load(path)
|
||||
end)
|
||||
|
||||
local resCh = love.thread.getChannel(resultChannel)
|
||||
|
||||
local function fail(err)
|
||||
resCh:push({ ok = false, err = tostring(err) })
|
||||
end
|
||||
|
||||
local ok, err = pcall(function()
|
||||
local Sandbox = require("src.mods.Sandbox")
|
||||
local Json = require("src.link.Json")
|
||||
|
||||
local permissions = {}
|
||||
if type(permissionsJson) == "string" and permissionsJson ~= "" then
|
||||
local decoded = select(2, pcall(Json.decode, permissionsJson))
|
||||
if type(decoded) == "table" then permissions = decoded end
|
||||
end
|
||||
|
||||
local env = Sandbox.envFor({ modId = modId, permissions = permissions })
|
||||
-- A job cannot reach the engine. Anything it needs comes in through its
|
||||
-- argument and goes back through its return value.
|
||||
env.require = function(name)
|
||||
error(("[%s] require(%q) is not available inside a background job; a job "
|
||||
.. "takes plain data and returns plain data"):format(modId,
|
||||
tostring(name)), 2)
|
||||
end
|
||||
|
||||
local chunk, loadErr = Sandbox.loadFile(love.filesystem, scriptPath, env)
|
||||
if not chunk then error(loadErr or ("could not load " .. scriptPath), 0) end
|
||||
|
||||
local arg = love.thread.getChannel(argChannel):pop()
|
||||
|
||||
-- NO in-worker time budget, deliberately. A debug count hook was the
|
||||
-- obvious way to stop a runaway, and it does not work: LuaJIT swallows an
|
||||
-- error raised from a hook (measured: ~5000 raises a second, the loop
|
||||
-- running straight through them), and the raising itself wedged the whole
|
||||
-- process -- the main thread stopped being scheduled at all. Without the
|
||||
-- hook a runaway job simply spins on its own core, the game stays
|
||||
-- responsive, and it quits normally. Job.poll enforces maxSeconds on the
|
||||
-- main thread so the MOD is never left waiting; the work itself runs to its
|
||||
-- own end.
|
||||
local ranOk, result = pcall(chunk, arg)
|
||||
if not ranOk then error(result, 0) end
|
||||
resCh:push({ ok = true, result = result })
|
||||
end)
|
||||
|
||||
if not ok then fail(err) end
|
||||
+12
-52
@@ -454,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),
|
||||
@@ -505,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")
|
||||
@@ -567,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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -630,7 +630,7 @@ function PackMenu:openTeachParty(row)
|
||||
if not allowed then
|
||||
if game.say then
|
||||
game:say(("%s can't learn %s!"):format(
|
||||
mon.nickname or mon.species or "?", moveName))
|
||||
require("src.battle.gen2.Mon").displayName(mon), moveName))
|
||||
end
|
||||
return
|
||||
end
|
||||
@@ -638,7 +638,7 @@ function PackMenu:openTeachParty(row)
|
||||
if move.id == moveId then
|
||||
if game.say then
|
||||
game:say(("%s already knows %s!"):format(
|
||||
mon.nickname or mon.species or "?", moveName))
|
||||
require("src.battle.gen2.Mon").displayName(mon), moveName))
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
+29
-6
@@ -132,9 +132,11 @@ local UI_SCALE = 1.3
|
||||
|
||||
function Kit.layout(width, height)
|
||||
local s = Theme.clamp(math.min(width / 640, height / 768), 0.9, 1.6) * UI_SCALE
|
||||
local key = ("%dx%d"):format(math.floor(width), math.floor(height))
|
||||
if Kit._fontKey ~= key then
|
||||
Kit._fontKey = key
|
||||
-- Two numbers, not a formatted key: this runs once per frame and the
|
||||
-- string:format allocated on every one of them.
|
||||
local kw, kh = math.floor(width), math.floor(height)
|
||||
if Kit._fontW ~= kw or Kit._fontH ~= kh then
|
||||
Kit._fontW, Kit._fontH = kw, kh
|
||||
Kit.fonts = Theme.fonts(s)
|
||||
clearCaches() -- every cached Text/width belongs to the old font set
|
||||
end
|
||||
@@ -857,6 +859,8 @@ end
|
||||
-- never silently truncated. This is the ONLY way the launcher moves through
|
||||
-- a long list: no scrollbars, no momentum, bounded row count per frame.
|
||||
-- Returns the new page (1-based) and the row height consumed.
|
||||
local pagerLabels = {}
|
||||
|
||||
function Kit.pager(x, y, w, page, total, perPage, idPrefix)
|
||||
local h = math.max(Kit.tapMin(), 30 * Kit.scale)
|
||||
local bw = 74 * Kit.scale
|
||||
@@ -876,7 +880,19 @@ function Kit.pager(x, y, w, page, total, perPage, idPrefix)
|
||||
|
||||
local first = total > 0 and ((page - 1) * perPage + 1) or 0
|
||||
local last = math.min(total, page * perPage)
|
||||
local label = ("%d-%d of %d (page %d/%d)"):format(first, last, total, page, pages)
|
||||
-- One memo per pager id. The counts only change when the user pages or the
|
||||
-- list does; formatting them every frame minted a new string that then
|
||||
-- missed the width / ellipsis / Text caches by content.
|
||||
local memo = pagerLabels[idPrefix]
|
||||
if not memo then memo = {}; pagerLabels[idPrefix] = memo end
|
||||
if memo.first ~= first or memo.last ~= last or memo.total ~= total
|
||||
or memo.page ~= page or memo.pages ~= pages then
|
||||
memo.first, memo.last, memo.total = first, last, total
|
||||
memo.page, memo.pages = page, pages
|
||||
memo.label = ("%d-%d of %d (page %d/%d)")
|
||||
:format(first, last, total, page, pages)
|
||||
end
|
||||
local label = memo.label
|
||||
local labelX = x + 2 * bw + 2 * gap + gap
|
||||
Kit.text("mono", Kit.ellipsize("mono", label, math.max(0, x + w - labelX)),
|
||||
labelX, y + (h - Kit.textHeight("mono")) / 2, PAL.caption)
|
||||
@@ -942,7 +958,10 @@ end
|
||||
-- region can never unclip its parent. The tracked rect also bounds Kit.hit,
|
||||
-- so a widget clipped out of view is inert instead of taking taps aimed at
|
||||
-- whatever is drawn where it left.
|
||||
-- The stack rects are pooled by depth and fully overwritten on every push,
|
||||
-- so a frame that clips a dozen lists allocates nothing.
|
||||
local clipStack = {}
|
||||
local clipPool = {}
|
||||
|
||||
local function applyClip(rect)
|
||||
Kit._clipRect = rect
|
||||
@@ -967,8 +986,12 @@ function Kit.pushClip(x, y, w, h)
|
||||
x2 = math.min(x2, prev.x + prev.w)
|
||||
y2 = math.min(y2, prev.y + prev.h)
|
||||
end
|
||||
local rect = { x = x, y = y, w = math.max(0, x2 - x), h = math.max(0, y2 - y) }
|
||||
clipStack[#clipStack + 1] = rect
|
||||
local n = #clipStack + 1
|
||||
local rect = clipPool[n]
|
||||
if not rect then rect = {}; clipPool[n] = rect end
|
||||
rect.x, rect.y = x, y
|
||||
rect.w, rect.h = math.max(0, x2 - x), math.max(0, y2 - y)
|
||||
clipStack[n] = rect
|
||||
applyClip(rect)
|
||||
end
|
||||
|
||||
|
||||
+29
-15
@@ -29,6 +29,15 @@ Layout.BP = {
|
||||
|
||||
-- Build the frame's metrics. `maxAppW` caps the content column on an
|
||||
-- ultrawide monitor so the UI stays a readable measure instead of stretching.
|
||||
-- One metrics table, reused. Every field is a pure function of the window
|
||||
-- size, the safe area and maxAppW, so the table only has to be rebuilt when
|
||||
-- one of those changes; the launcher asked for a fresh one 60 times a second
|
||||
-- and threw all of them away. Callers must treat `m` as read-only (nothing
|
||||
-- writes to it today) -- a caller that needs a shifted field should save,
|
||||
-- assign and restore it around the call, not wrap `m` in a proxy.
|
||||
local M = {}
|
||||
local lastW, lastH, lastOx, lastOy, lastSw, lastSh, lastMax
|
||||
|
||||
function Layout.metrics(maxAppW)
|
||||
local W, H = 0, 0
|
||||
if love and love.graphics and love.graphics.getDimensions then
|
||||
@@ -36,23 +45,28 @@ function Layout.metrics(maxAppW)
|
||||
end
|
||||
local ox, oy, sw, sh = SafeArea.rect()
|
||||
local s = Kit.layout(sw, sh)
|
||||
if W == lastW and H == lastH and ox == lastOx and oy == lastOy
|
||||
and sw == lastSw and sh == lastSh and maxAppW == lastMax then
|
||||
return M
|
||||
end
|
||||
lastW, lastH, lastOx, lastOy = W, H, ox, oy
|
||||
lastSw, lastSh, lastMax = sw, sh, maxAppW
|
||||
|
||||
local appW = math.min(sw, (maxAppW or 1200) * s)
|
||||
local m = {
|
||||
W = W, H = H, s = s,
|
||||
x = math.floor(ox + (sw - appW) / 2),
|
||||
top = math.floor(oy),
|
||||
w = math.floor(appW),
|
||||
h = math.floor(sh),
|
||||
pad = math.floor(Theme.clamp(appW * 0.03, 10, 24)),
|
||||
gap = math.floor(12 * s),
|
||||
colGap = math.floor(16 * s),
|
||||
rowH = math.max(Kit.tapMin(), math.floor(44 * s)),
|
||||
btnH = math.max(Kit.tapMin(), math.floor(38 * s)),
|
||||
chip = math.max(Kit.tapMin(), math.floor(40 * s)),
|
||||
railH = math.max(3, math.floor(4 * s)),
|
||||
logoH = math.floor(Theme.clamp(sh * 0.10, 36, 84)),
|
||||
}
|
||||
local m = M
|
||||
m.W, m.H, m.s = W, H, s
|
||||
m.x = math.floor(ox + (sw - appW) / 2)
|
||||
m.top = math.floor(oy)
|
||||
m.w = math.floor(appW)
|
||||
m.h = math.floor(sh)
|
||||
m.pad = math.floor(Theme.clamp(appW * 0.03, 10, 24))
|
||||
m.gap = math.floor(12 * s)
|
||||
m.colGap = math.floor(16 * s)
|
||||
m.rowH = math.max(Kit.tapMin(), math.floor(44 * s))
|
||||
m.btnH = math.max(Kit.tapMin(), math.floor(38 * s))
|
||||
m.chip = math.max(Kit.tapMin(), math.floor(40 * s))
|
||||
m.railH = math.max(3, math.floor(4 * s))
|
||||
m.logoH = math.floor(Theme.clamp(sh * 0.10, 36, 84))
|
||||
m.cols = (appW >= Layout.BP.threeCol * s and 3)
|
||||
or (appW >= Layout.BP.twoCol * s and 2)
|
||||
or 1
|
||||
|
||||
+12
-3
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -784,6 +784,49 @@ function OverworldState:useBicycle()
|
||||
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
|
||||
@@ -2500,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
|
||||
|
||||
@@ -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,8 @@ 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)
|
||||
@@ -115,6 +119,30 @@ function WorldAPI:availableFieldActions()
|
||||
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
|
||||
|
||||
@@ -130,6 +158,19 @@ function WorldAPI:useFieldAction(id, opts)
|
||||
|
||||
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
|
||||
@@ -137,6 +178,13 @@ function WorldAPI:useFieldAction(id, opts)
|
||||
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
|
||||
|
||||
@@ -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
|
||||
@@ -36,6 +36,18 @@ 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)
|
||||
@@ -96,6 +108,26 @@ function WorldAPI:availableFieldActions()
|
||||
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
|
||||
|
||||
@@ -123,6 +155,18 @@ function WorldAPI:useFieldAction(id, opts)
|
||||
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
|
||||
|
||||
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")
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -138,6 +138,15 @@ function FsIo.new(rootDir)
|
||||
return loadfile(abs(path))
|
||||
end
|
||||
|
||||
function fs.createDirectory(path)
|
||||
os.execute(("mkdir -p %q"):format(abs(path)))
|
||||
return true
|
||||
end
|
||||
|
||||
function fs.remove(path)
|
||||
return os.remove(abs(path)) ~= nil
|
||||
end
|
||||
|
||||
function fs.getDirectoryItems(path)
|
||||
return FsIo.listDir(abs(path))
|
||||
end
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1,203 @@
|
||||
-- mod.fetch: background HTTP for sandboxed mods, behind the "network"
|
||||
-- permission. The sandbox blocks love.thread because newThread's Lua state
|
||||
-- escapes every rule in it; this is the replacement, so the things that make
|
||||
-- it NOT an escape are what this file pins -- http/https only, handles that
|
||||
-- are opaque and per-mod, a ceiling on jobs in flight, and release on unload.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Net = require("src.mods.Net")
|
||||
local Fetch = require("src.net.Fetch")
|
||||
|
||||
-- Stand in for the worker pool: jobs resolve when the test says so, so no
|
||||
-- test here touches a socket.
|
||||
local submitted, nextId, states = {}, 0, {}
|
||||
Fetch.get = function(url, opts)
|
||||
nextId = nextId + 1
|
||||
submitted[nextId] = { url = url, opts = opts }
|
||||
states[nextId] = { status = "pending", progress = 0 }
|
||||
return nextId
|
||||
end
|
||||
Fetch.poll = function(id) return states[id] or { status = "error", err = "unknown job" } end
|
||||
Fetch.isPending = function(id) return (states[id] or {}).status == "pending" end
|
||||
Fetch.release = function(id) states[id] = nil end
|
||||
Fetch.cancel = function(id)
|
||||
if states[id] and states[id].status == "pending" then states[id].status = "cancelled" end
|
||||
end
|
||||
Fetch.available = function() return true end
|
||||
|
||||
local FETCHER = {
|
||||
["mods/net_fetcher/manifest.json"] = [[{
|
||||
"id": "net_fetcher",
|
||||
"name": "Net Fetcher",
|
||||
"version": "1.0.0",
|
||||
"entry": "main.lua",
|
||||
"api": 2,
|
||||
"permissions": ["network"]
|
||||
}]],
|
||||
["mods/net_fetcher/main.lua"] = [[
|
||||
local mod = ...
|
||||
mod.exports.available = mod.fetch:available()
|
||||
mod.exports.get = function(url, opts) return mod.fetch:get(url, opts) end
|
||||
mod.exports.poll = function(h) return mod.fetch:poll(h) end
|
||||
mod.exports.release = function(h) return mod.fetch:release(h) end
|
||||
mod.exports.cancel = function(h) return mod.fetch:cancel(h) end
|
||||
]],
|
||||
}
|
||||
|
||||
local OTHER = {
|
||||
["mods/net_other/manifest.json"] = [[{
|
||||
"id": "net_other",
|
||||
"name": "Net Other",
|
||||
"version": "1.0.0",
|
||||
"entry": "main.lua",
|
||||
"api": 2,
|
||||
"permissions": ["network"]
|
||||
}]],
|
||||
["mods/net_other/main.lua"] = [[
|
||||
local mod = ...
|
||||
mod.exports.poll = function(h) return mod.fetch:poll(h) end
|
||||
mod.exports.cancel = function(h) return mod.fetch:cancel(h) end
|
||||
mod.exports.get = function(url) return mod.fetch:get(url) end
|
||||
]],
|
||||
}
|
||||
|
||||
local UNPERMISSIONED = {
|
||||
["mods/net_probe/manifest.json"] = [[{
|
||||
"id": "net_probe",
|
||||
"name": "Net Probe",
|
||||
"version": "1.0.0",
|
||||
"entry": "main.lua",
|
||||
"api": 2
|
||||
}]],
|
||||
["mods/net_probe/main.lua"] = [[
|
||||
local mod = ...
|
||||
mod.exports.available = mod.fetch:available()
|
||||
local ok, err = pcall(function() return mod.fetch:get("https://example.com") end)
|
||||
mod.exports.refused = not ok and tostring(err) or false
|
||||
]],
|
||||
}
|
||||
|
||||
local function merged(...)
|
||||
local out = {}
|
||||
for _, fixture in ipairs({ ... }) do
|
||||
for path, body in pairs(fixture) do out[path] = body end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------- scheme restriction
|
||||
-- curl also speaks file://, scp:// and ftp://. Without this check mod.fetch
|
||||
-- would be a filesystem read and the sandbox would be pointless.
|
||||
T.eq(Net.urlDenial("https://example.com/i.json"), nil, "https is allowed")
|
||||
T.eq(Net.urlDenial("http://example.com/i.json"), nil, "http is allowed")
|
||||
T.check(Net.urlDenial("file:///etc/passwd"), "file:// is refused")
|
||||
T.check(Net.urlDenial("FILE:///etc/passwd"), "file:// is refused case-insensitively")
|
||||
T.check(Net.urlDenial("scp://host/secret"), "scp:// is refused")
|
||||
T.check(Net.urlDenial("ftp://host/x"), "ftp:// is refused")
|
||||
T.check(Net.urlDenial("/etc/passwd"), "a bare path is refused")
|
||||
T.check(Net.urlDenial("https://"), "a url with no host is refused")
|
||||
T.check(Net.urlDenial(nil), "a non-string url is refused")
|
||||
T.check(Net.urlDenial("file:///x"):find("http", 1, true),
|
||||
"the refusal says what is allowed")
|
||||
|
||||
-- ------------------------------------------------------- permissioned use
|
||||
local run = T.sdk.loadMods({ "mods/net_fetcher", "mods/net_other" },
|
||||
{ fs = T.sdk.memfs(merged(FETCHER, OTHER)) })
|
||||
T.eq(#run.errors, 0,
|
||||
"the permissioned mods load clean (" .. tostring(run.errors[1]) .. ")")
|
||||
local api = run.loader.exports.net_fetcher
|
||||
T.eq(api.available, true, "available() is true with the permission")
|
||||
|
||||
local handle, err = api.get("https://example.com/index.json")
|
||||
T.check(handle ~= nil, "get() returns a handle (" .. tostring(err) .. ")")
|
||||
T.eq(type(handle), "table", "the handle is opaque, not the engine's job id")
|
||||
T.eq(api.poll(handle).status, "pending", "a fresh job polls as pending")
|
||||
|
||||
-- the mod is named to the server, and cannot pose as the launcher
|
||||
local sent
|
||||
for _, job in pairs(submitted) do
|
||||
if job.url == "https://example.com/index.json" then sent = job end
|
||||
end
|
||||
T.check(sent and sent.opts.userAgent:find("net_fetcher", 1, true),
|
||||
"the request identifies the calling mod")
|
||||
|
||||
-- a refused url never reaches the pool
|
||||
local before = nextId
|
||||
local bad, badErr = api.get("file:///etc/passwd")
|
||||
T.eq(bad, nil, "a file:// url returns no handle")
|
||||
T.check(badErr and badErr:find("http", 1, true), "and says why")
|
||||
T.eq(nextId, before, "and never reaches the fetch pool")
|
||||
|
||||
-- the body arrives through poll, as a copy
|
||||
states[sent and 1 or 1] = { status = "ok", body = "{\"mods\":[]}", progress = 1 }
|
||||
local got = api.poll(handle)
|
||||
T.eq(got.status, "ok", "a completed job polls ok")
|
||||
T.eq(got.body, "{\"mods\":[]}", "and hands over the body")
|
||||
got.body = "tampered"
|
||||
T.eq(api.poll(handle).body, "{\"mods\":[]}",
|
||||
"poll returns a copy; a mod cannot edit the engine's job table")
|
||||
|
||||
-- --------------------------------------------- handles do not cross mods
|
||||
-- Fetch keys jobs by integer and the launcher's own downloads live in the
|
||||
-- same table, so this is the property that matters most.
|
||||
local other = run.loader.exports.net_other
|
||||
T.eq(other.poll(handle).status, "error",
|
||||
"another mod cannot poll a handle it does not own")
|
||||
T.eq(other.cancel(handle), false,
|
||||
"another mod cannot cancel a handle it does not own")
|
||||
T.eq(api.poll({}).status, "error", "a forged handle reads as an error")
|
||||
T.eq(api.poll(1).status, "error", "a guessed integer id reads as an error")
|
||||
|
||||
-- ------------------------------------------------------ in-flight ceiling
|
||||
-- One mod must not be able to fill the shared three-worker pool.
|
||||
local held = {}
|
||||
for i = 1, Net.MAX_INFLIGHT + 2 do
|
||||
held[i] = select(1, api.get("https://example.com/" .. i))
|
||||
end
|
||||
local live = 0
|
||||
for _, h in ipairs(held) do if h then live = live + 1 end end
|
||||
T.check(live <= Net.MAX_INFLIGHT,
|
||||
"a mod is capped at " .. Net.MAX_INFLIGHT .. " requests in flight")
|
||||
local _, capErr = api.get("https://example.com/overflow")
|
||||
T.check(capErr and capErr:find("in flight", 1, true),
|
||||
"the refusal explains the cap")
|
||||
-- releasing frees a slot
|
||||
api.release(held[1])
|
||||
local after = api.get("https://example.com/after-release")
|
||||
T.check(after ~= nil, "releasing a handle frees a slot")
|
||||
|
||||
-- the timeout is clamped, so a mod cannot pin a worker
|
||||
for _, h in ipairs(held) do if h then api.release(h) end end
|
||||
if after then api.release(after) end
|
||||
local slow, slowErr = api.get("https://example.com/slow", { maxSeconds = 99999 })
|
||||
T.check(slow ~= nil, "a slot is free again (" .. tostring(slowErr) .. ")")
|
||||
local slowJob
|
||||
for _, job in pairs(submitted) do
|
||||
if job.url == "https://example.com/slow" then slowJob = job end
|
||||
end
|
||||
T.check(slow and slowJob.opts.maxSeconds <= Net.MAX_SECONDS,
|
||||
"a caller's timeout is clamped to " .. Net.MAX_SECONDS .. "s")
|
||||
|
||||
-- ------------------------------------------------------ release on unload
|
||||
local loader = run.loader
|
||||
T.check(loader.netJobs and loader.netJobs.net_fetcher,
|
||||
"the loader tracks the mod's jobs")
|
||||
Net.releaseAll(loader, "net_fetcher")
|
||||
T.eq(loader.netJobs.net_fetcher, nil,
|
||||
"unloading a mod drops every job it still held")
|
||||
run.release()
|
||||
|
||||
-- --------------------------------------------- without the permission
|
||||
local probe = T.sdk.loadMods({ "mods/net_probe" },
|
||||
{ fs = T.sdk.memfs(UNPERMISSIONED) })
|
||||
T.eq(#probe.errors, 0,
|
||||
"the unpermissioned mod loads clean (" .. tostring(probe.errors[1]) .. ")")
|
||||
local out = probe.loader.exports.net_probe
|
||||
T.eq(out.available, false, "available() is quietly false without the permission")
|
||||
T.check(out.refused and out.refused:find('"network" permission', 1, true),
|
||||
"get() without the permission names it")
|
||||
probe.release()
|
||||
|
||||
T.finish("mod_fetch")
|
||||
@@ -0,0 +1,176 @@
|
||||
-- mod.job: background compute for sandboxed mods, behind the "background"
|
||||
-- permission. The worker rebuilds the mod's sandbox before loading its
|
||||
-- script, so what this file pins is the contract that keeps a job from being
|
||||
-- love.thread by another name -- plain data only, paths that cannot climb,
|
||||
-- handles that do not cross mods, and ceilings on how much a mod can start.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Job = require("src.mods.Job")
|
||||
|
||||
local WORKER = {
|
||||
["mods/job_worker_mod/manifest.json"] = [[{
|
||||
"id": "job_worker_mod",
|
||||
"name": "Job Worker",
|
||||
"version": "1.0.0",
|
||||
"entry": "main.lua",
|
||||
"api": 2,
|
||||
"permissions": ["background"]
|
||||
}]],
|
||||
["mods/job_worker_mod/main.lua"] = [[
|
||||
local mod = ...
|
||||
mod.exports.available = mod.job:available()
|
||||
mod.exports.run = function(script, arg, opts)
|
||||
return mod.job:run(script, arg, opts)
|
||||
end
|
||||
mod.exports.poll = function(h) return mod.job:poll(h) end
|
||||
mod.exports.release = function(h) return mod.job:release(h) end
|
||||
mod.exports.cancel = function(h) return mod.job:cancel(h) end
|
||||
]],
|
||||
["mods/job_worker_mod/jobs/crunch.lua"] = [[
|
||||
local arg = ...
|
||||
local total = 0
|
||||
for i = 1, (arg and arg.n or 0) do total = total + i end
|
||||
return { total = total }
|
||||
]],
|
||||
}
|
||||
|
||||
local OTHER = {
|
||||
["mods/job_other/manifest.json"] = [[{
|
||||
"id": "job_other",
|
||||
"name": "Job Other",
|
||||
"version": "1.0.0",
|
||||
"entry": "main.lua",
|
||||
"api": 2,
|
||||
"permissions": ["background"]
|
||||
}]],
|
||||
["mods/job_other/main.lua"] = [[
|
||||
local mod = ...
|
||||
mod.exports.poll = function(h) return mod.job:poll(h) end
|
||||
mod.exports.cancel = function(h) return mod.job:cancel(h) end
|
||||
]],
|
||||
}
|
||||
|
||||
local UNPERMISSIONED = {
|
||||
["mods/job_probe/manifest.json"] = [[{
|
||||
"id": "job_probe",
|
||||
"name": "Job Probe",
|
||||
"version": "1.0.0",
|
||||
"entry": "main.lua",
|
||||
"api": 2
|
||||
}]],
|
||||
["mods/job_probe/main.lua"] = [[
|
||||
local mod = ...
|
||||
mod.exports.available = mod.job:available()
|
||||
local ok, err = pcall(function() return mod.job:run("jobs/x.lua") end)
|
||||
mod.exports.refused = not ok and tostring(err) or false
|
||||
]],
|
||||
}
|
||||
|
||||
local function merged(...)
|
||||
local out = {}
|
||||
for _, fixture in ipairs({ ... }) do
|
||||
for path, body in pairs(fixture) do out[path] = body end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- ------------------------------------------------- plain-data enforcement
|
||||
-- Nothing but plain data can cross a thread boundary; a function reaching
|
||||
-- LÖVE's serialiser fails deep inside it instead of at the mod's own call.
|
||||
local okData, dataErr = Job.plain({ a = 1, b = "two", c = { d = true } })
|
||||
T.check(okData and okData.c.d == true, "plain data survives the copy")
|
||||
T.check(select(2, Job.plain({ f = function() end })),
|
||||
"a function is refused")
|
||||
T.check(select(2, Job.plain(function() end)), "a bare function is refused")
|
||||
T.check(select(2, Job.plain({ [{}] = 1 })), "a table key is refused")
|
||||
local cycle = {}; cycle.self = cycle
|
||||
T.check(select(2, Job.plain(cycle)), "a cycle is refused, not hung on")
|
||||
local deep = {}
|
||||
local cur = deep
|
||||
for _ = 1, Job.MAX_DEPTH + 4 do cur.next = {}; cur = cur.next end
|
||||
T.check(select(2, Job.plain(deep)), "absurd nesting is refused")
|
||||
T.eq(dataErr, nil, "a clean table reports no error")
|
||||
|
||||
-- the copy is a copy: mutating the source does not reach the copy
|
||||
local src = { n = 1 }
|
||||
local copied = Job.plain(src)
|
||||
src.n = 99
|
||||
T.eq(copied.n, 1, "the payload is snapshotted, not referenced")
|
||||
|
||||
-- ------------------------------------------------------- permissioned use
|
||||
local run = T.sdk.loadMods({ "mods/job_worker_mod", "mods/job_other" },
|
||||
{ fs = T.sdk.memfs(merged(WORKER, OTHER)) })
|
||||
T.eq(#run.errors, 0,
|
||||
"the permissioned mods load clean (" .. tostring(run.errors[1]) .. ")")
|
||||
local api = run.loader.exports.job_worker_mod
|
||||
|
||||
-- The test harness has no love.thread, so run() reports that rather than
|
||||
-- pretending; the gating, path and data rules above it still apply and are
|
||||
-- what this suite exists to pin.
|
||||
local handle, why = api.run("jobs/crunch.lua", { n = 10 })
|
||||
if Job.available() then
|
||||
T.check(handle ~= nil, "run() returns a handle (" .. tostring(why) .. ")")
|
||||
T.eq(type(handle), "table", "the handle is opaque")
|
||||
else
|
||||
T.eq(handle, nil, "run() reports when the host has no threads")
|
||||
T.check(why and why:find("unavailable", 1, true), "and says so plainly")
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------ paths cannot climb
|
||||
-- A job script is named inside the mod's own folder, the same rule mod:read
|
||||
-- follows. A job is not a way to name a path.
|
||||
-- These assert the PATH message specifically: "unavailable" is also truthy,
|
||||
-- so a loose check here would pass even with the path rules removed.
|
||||
local _, climbErr = api.run("../../../etc/passwd", {})
|
||||
T.check(climbErr and climbErr:find("inside its root", 1, true),
|
||||
"a climbing path is refused (" .. tostring(climbErr) .. ")")
|
||||
local _, absErr = api.run("/etc/passwd", {})
|
||||
T.check(absErr and absErr:find("inside its root", 1, true),
|
||||
"an absolute path is refused")
|
||||
local _, driveErr = api.run("C:/windows/system32/x.lua", {})
|
||||
T.check(driveErr and driveErr:find("inside its root", 1, true),
|
||||
"a drive-relative path is refused")
|
||||
local _, emptyErr = api.run("", {})
|
||||
T.check(emptyErr and emptyErr:find("script path", 1, true),
|
||||
"an empty path is refused")
|
||||
local _, typeErr = api.run(nil, {})
|
||||
T.check(typeErr and typeErr:find("script path", 1, true),
|
||||
"a non-string path is refused")
|
||||
|
||||
-- a function in the argument is caught at the mod's call, not in LÖVE
|
||||
local _, argErr = api.run("jobs/crunch.lua", { cb = function() end })
|
||||
T.check(argErr and argErr:find("plain data", 1, true),
|
||||
"a non-serialisable argument is refused with a reason")
|
||||
|
||||
-- ------------------------------------------- handles do not cross mods
|
||||
local other = run.loader.exports.job_other
|
||||
T.eq(api.poll({}).status, "error", "a forged handle reads as an error")
|
||||
T.eq(api.poll(1).status, "error", "a guessed id reads as an error")
|
||||
if handle then
|
||||
T.eq(other.poll(handle).status, "error",
|
||||
"another mod cannot poll a handle it does not own")
|
||||
T.eq(other.cancel(handle), false,
|
||||
"another mod cannot cancel a handle it does not own")
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------- unload
|
||||
local loader = run.loader
|
||||
Job.releaseAll(loader, "job_worker_mod")
|
||||
T.eq(loader.jobs and loader.jobs.job_worker_mod, nil,
|
||||
"unloading a mod drops every job it still held")
|
||||
run.release()
|
||||
|
||||
-- --------------------------------------------- without the permission
|
||||
local probe = T.sdk.loadMods({ "mods/job_probe" },
|
||||
{ fs = T.sdk.memfs(UNPERMISSIONED) })
|
||||
T.eq(#probe.errors, 0,
|
||||
"the unpermissioned mod loads clean (" .. tostring(probe.errors[1]) .. ")")
|
||||
local out = probe.loader.exports.job_probe
|
||||
T.eq(out.available, false, "available() is quietly false without the permission")
|
||||
T.check(out.refused and out.refused:find('"background" permission', 1, true),
|
||||
"run() without the permission names it")
|
||||
probe.release()
|
||||
|
||||
T.finish("mod_job")
|
||||
+234
-55
@@ -1,7 +1,8 @@
|
||||
-- T4: the mod sandbox (src/mods/Sandbox.lua). A mod's own chunks run against
|
||||
-- an environment with no io, no os beyond the clock, and no way to name a path
|
||||
-- outside its own directory, so a mod cannot reach the player's filesystem.
|
||||
-- Every case here is an escape a mod would actually try.
|
||||
-- T4: the mod sandbox (src/mods/Sandbox.lua) and the compat reroute over it
|
||||
-- (src/mods/LegacyCompat.lua). A mod's own chunks still cannot name a path
|
||||
-- outside their own directory: the pre-sandbox globals are back as stand-ins
|
||||
-- whose reads come from the mod's own files and whose writes land in a private
|
||||
-- per-mod overlay. Every case here is an escape a mod would actually try.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
@@ -9,6 +10,7 @@ local T = require("tests.modkit")
|
||||
local Manifest = require("src.mods.Manifest")
|
||||
local Sandbox = require("src.mods.Sandbox")
|
||||
local SafePath = require("src.mods.SafePath")
|
||||
local LegacyCompat = require("src.mods.LegacyCompat")
|
||||
|
||||
local function manifest(id, extra)
|
||||
return ('{"id":"%s","name":"%s","version":"1.0.0","entry":"main.lua",'
|
||||
@@ -20,16 +22,14 @@ end
|
||||
local PROBE = [[
|
||||
local mod = ...
|
||||
local out = mod.exports
|
||||
out.io = io
|
||||
out.package = package
|
||||
out.dofile = dofile
|
||||
out.loadfile = loadfile
|
||||
out.io = type(io)
|
||||
out.package = type(package)
|
||||
out.dofile = type(dofile)
|
||||
out.loadfile = type(loadfile)
|
||||
out.setfenv = setfenv
|
||||
out.getfenv = getfenv
|
||||
out.debug = debug
|
||||
out.osGetenv = os.getenv
|
||||
out.osExecute = os.execute
|
||||
out.osRemove = os.remove
|
||||
out.osGetenv = type(os.getenv)
|
||||
out.osTime = type(os.time)
|
||||
out.stringOk = ("a"):rep(3)
|
||||
|
||||
@@ -38,30 +38,86 @@ local PROBE = [[
|
||||
if ok then return false end
|
||||
return tostring(err)
|
||||
end
|
||||
out.requireIo = attempt(require, "io")
|
||||
out.requireOs = attempt(require, "os")
|
||||
out.requireIoIsShim = select(2, pcall(require, "io")) == io
|
||||
out.requireLoveFsIsShim =
|
||||
select(2, pcall(require, "love.filesystem")) == love.filesystem
|
||||
out.requireDebug = attempt(require, "debug")
|
||||
out.requirePackage = attempt(require, "package")
|
||||
out.requireFfi = attempt(require, "ffi")
|
||||
out.requireLoveFs = attempt(require, "love.filesystem")
|
||||
out.requireSocket = attempt(require, "socket")
|
||||
-- called from a nested Lua frame rather than straight off pcall, which is
|
||||
-- the shape a stack-walking gate reads differently
|
||||
out.requireIoNested = attempt(function() return require("io") end)
|
||||
out.requireSemver = select(2, pcall(require, "src.mods.Semver"))
|
||||
|
||||
out.loveFilesystem = attempt(function() return love.filesystem end)
|
||||
out.loveThread = attempt(function() return love.thread end)
|
||||
out.loveSystem = attempt(function() return love.system end)
|
||||
out.loveGraphics = type(love.graphics)
|
||||
out.loveAssign = attempt(function() love.filesystem = {} end)
|
||||
-- the callback chain a mod wrapping the mouse writes: it has to reach the
|
||||
-- real love table or the wrap silently never fires
|
||||
out.chainedInner = false
|
||||
local inner = love.mousemoved
|
||||
love.mousemoved = function(...)
|
||||
out.chainedInner = true
|
||||
if inner then return inner(...) end
|
||||
end
|
||||
out.assignRun = attempt(function() love.run = function() end end)
|
||||
out.assignGarbage = attempt(function() love.mousemoved = 7 end)
|
||||
out.powerInfo = type(love.system.getPowerInfo)
|
||||
out.openUrl = love.system.openURL("https://example.com")
|
||||
out.eventQuit = love.event.quit()
|
||||
out.popen = select(1, io.popen("ls"))
|
||||
|
||||
-- the reroute: a write anywhere a mod used to name must land in the mod's
|
||||
-- own overlay, and reading it back must see the write and nothing else
|
||||
local escape = io.open("/etc/hosts", "w")
|
||||
out.escapeOpened = escape ~= nil
|
||||
if escape then
|
||||
escape:write("pwned")
|
||||
escape:close()
|
||||
end
|
||||
local reread = io.open("/etc/hosts", "r")
|
||||
out.escapeReadBack = reread and reread:read("*a") or nil
|
||||
if reread then reread:close() end
|
||||
|
||||
out.homeEnv = os.getenv("HOME")
|
||||
out.saveDir = love.filesystem.getSaveDirectory()
|
||||
|
||||
love.filesystem.write("cfg/settings.txt", "x=1\ny=2\n")
|
||||
out.roundTrip = love.filesystem.read("cfg/settings.txt")
|
||||
out.roundTripInfo = love.filesystem.getInfo("cfg/settings.txt")
|
||||
local lines = {}
|
||||
for line in love.filesystem.lines("cfg/settings.txt") do
|
||||
lines[#lines + 1] = line
|
||||
end
|
||||
out.roundTripLines = lines
|
||||
love.filesystem.append("cfg/settings.txt", "z=3\n")
|
||||
out.appended = love.filesystem.read("cfg/settings.txt")
|
||||
|
||||
-- an absolute path built off the reported save directory comes back to the
|
||||
-- same overlay, which is what a legacy mod's own path joining does
|
||||
love.filesystem.write(out.saveDir .. "/cfg/settings.txt", "rooted")
|
||||
out.rootedRead = love.filesystem.read("cfg/settings.txt")
|
||||
|
||||
-- the mod's own packaged files still read through the old call
|
||||
out.ownThroughLove = love.filesystem.read("mods/fix_sandbox/data/note.txt")
|
||||
out.ownThroughIo = (function()
|
||||
local f = io.open("data/note.txt", "r")
|
||||
if not f then return nil end
|
||||
local body = f:read("*a")
|
||||
f:close()
|
||||
return body
|
||||
end)()
|
||||
|
||||
-- copy-on-write: writing over a packaged path shadows it, it does not
|
||||
-- rewrite the shipped file
|
||||
love.filesystem.write("mods/fix_sandbox/data/note.txt", "shadowed")
|
||||
out.shadowed = love.filesystem.read("mods/fix_sandbox/data/note.txt")
|
||||
out.shadowedOwn = mod:read("data/note.txt")
|
||||
|
||||
-- the multi-file pattern mods/timekeepers_hut uses: a chunk loaded from the
|
||||
-- mod's own source must inherit the sandbox, not the real globals
|
||||
local child = load("return io, os.getenv, _G")
|
||||
local childIo, childGetenv, childG = child()
|
||||
out.childIo = childIo
|
||||
out.childGetenv = childGetenv
|
||||
out.childIoIsShim = childIo == io
|
||||
out.childGetenvIsShim = childGetenv == os.getenv
|
||||
out.childSharesEnv = childG == _G
|
||||
|
||||
out.readEscape = attempt(function() return mod:read("../../secret.txt") end)
|
||||
@@ -69,6 +125,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,60 +147,125 @@ 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",
|
||||
}
|
||||
|
||||
LegacyCompat.reset()
|
||||
local savedMouseMoved = love.mousemoved
|
||||
local run = T.sdk.loadMods({ "mods/fix_sandbox" }, { fs = T.sdk.memfs(FILES) })
|
||||
local installedMouseMoved = love.mousemoved
|
||||
love.mousemoved = savedMouseMoved
|
||||
T.eq(#run.errors, 0,
|
||||
"the probe mod loads clean (" .. tostring(run.errors[1]) .. ")")
|
||||
local out = run.loader.exports.fix_sandbox or {}
|
||||
|
||||
-- ------- the standard library a mod does not get
|
||||
-- ------- the pre-sandbox globals are stand-ins, not the real thing
|
||||
|
||||
T.eq(out.io, nil, "io is absent from the mod environment")
|
||||
T.eq(out.package, nil, "package is absent, so package.loaded is unreachable")
|
||||
T.eq(out.dofile, nil, "dofile is absent")
|
||||
T.eq(out.loadfile, nil, "loadfile is absent")
|
||||
T.eq(out.setfenv, nil, "setfenv is absent, so a mod cannot swap its own env")
|
||||
T.eq(out.getfenv, nil, "getfenv is absent, so a mod cannot read the real _G out")
|
||||
T.eq(out.debug, nil, "the debug library is absent")
|
||||
T.eq(out.osGetenv, nil, "os.getenv is absent -- it is how the report's exploit "
|
||||
.. "found the user's home directory")
|
||||
T.eq(out.osExecute, nil, "os.execute is absent")
|
||||
T.eq(out.osRemove, nil, "os.remove is absent")
|
||||
T.eq(out.osTime, "function", "os.time still works: the clock is not the hole")
|
||||
T.eq(out.io, "table", "io is present again, as the compat stand-in")
|
||||
T.eq(out.package, "table", "so is package, as an inert stub")
|
||||
T.eq(out.dofile, "function", "dofile routes through the stand-in")
|
||||
T.eq(out.loadfile, "function", "so does loadfile")
|
||||
T.eq(out.osGetenv, "function", "os.getenv answers rather than crashing the mod")
|
||||
T.eq(out.osTime, "function", "os.time still works: the clock was never the hole")
|
||||
T.eq(out.stringOk, "aaa", "the safe standard library is intact")
|
||||
|
||||
-- ------- require, the one call that would undo all of the above
|
||||
-- what stays gone: there is no rerouted stand-in for these, so faking one
|
||||
-- would be the hole rather than a compat shim
|
||||
T.eq(out.setfenv, nil, "setfenv is still absent, so a mod cannot swap its own env")
|
||||
T.eq(out.getfenv, nil, "getfenv is still absent, so a mod cannot read the real _G out")
|
||||
T.eq(out.debug, nil, "the debug library is still absent")
|
||||
T.check(out.loveThread ~= false, "love.thread is still refused: it opens a full Lua state")
|
||||
T.check(out.requireFfi ~= false, "require(\"ffi\") is still refused: it is arbitrary C")
|
||||
T.check(out.requireDebug ~= false, "require(\"debug\") is still refused")
|
||||
T.check(out.requirePackage ~= false, "require(\"package\") is still refused")
|
||||
T.eq(out.popen, nil, "io.popen refuses rather than spawning a process")
|
||||
T.eq(out.openUrl, false, "love.system.openURL does nothing")
|
||||
T.eq(out.eventQuit, false, "love.event.quit cannot close the game on the player")
|
||||
|
||||
T.check(out.requireIo and out.requireIo:find("not available to mods", 1, true),
|
||||
"require(\"io\") is refused: " .. tostring(out.requireIo))
|
||||
T.check(out.requireOs ~= false, "require(\"os\") is refused")
|
||||
T.check(out.requireDebug ~= false, "require(\"debug\") is refused")
|
||||
T.check(out.requirePackage ~= false, "require(\"package\") is refused")
|
||||
T.check(out.requireFfi ~= false, "require(\"ffi\") is refused: it is arbitrary C")
|
||||
T.check(out.requireLoveFs ~= false, "require(\"love.filesystem\") is refused")
|
||||
T.check(out.requireIoNested ~= false,
|
||||
"require(\"io\") from a nested frame is refused the same way")
|
||||
-- ------- require answers with the same stand-ins
|
||||
|
||||
T.check(out.requireIoIsShim, "require(\"io\") hands back the same compat table")
|
||||
T.check(out.requireLoveFsIsShim,
|
||||
"require(\"love.filesystem\") hands back the same compat table")
|
||||
T.check(out.requireSocket and out.requireSocket:find("network", 1, true),
|
||||
"a network module names the permission it needs: " .. tostring(out.requireSocket))
|
||||
"a network module still names the permission it needs: " .. tostring(out.requireSocket))
|
||||
T.eq(type(out.requireSemver), "table",
|
||||
"the supported engine requires still resolve")
|
||||
|
||||
-- ------- the love facade
|
||||
|
||||
T.check(out.loveFilesystem and out.loveFilesystem:find("mod.storage", 1, true),
|
||||
"love.filesystem is refused and names the replacement")
|
||||
T.check(out.loveThread ~= false, "love.thread is refused: it opens a full Lua state")
|
||||
T.check(out.loveSystem and out.loveSystem:find("mod.device:powerInfo()", 1, true),
|
||||
"love.system is refused and names the scoped power replacement")
|
||||
T.eq(out.loveGraphics, "table", "the rest of love passes through")
|
||||
T.check(out.loveAssign ~= false, "a mod cannot assign into the love facade")
|
||||
T.check(out.loveAssign ~= false,
|
||||
"a mod cannot replace a love module table: " .. tostring(out.loveAssign))
|
||||
T.eq(out.powerInfo, "function",
|
||||
"love.system reads through to the same information mod.device exposes")
|
||||
|
||||
-- a wrapped callback has to land on the real table or the wrap never fires
|
||||
T.eq(type(installedMouseMoved), "function",
|
||||
"love.mousemoved assigned by a mod reaches the real love table")
|
||||
T.check(installedMouseMoved ~= savedMouseMoved,
|
||||
"and it is the mod's wrapper, not the one that was there")
|
||||
T.check(out.assignRun and out.assignRun:find("fixed-step loop", 1, true),
|
||||
"love.run stays refused: it is the engine's own loop ("
|
||||
.. tostring(out.assignRun) .. ")")
|
||||
T.check(out.assignGarbage ~= false,
|
||||
"and a callback slot only takes a function")
|
||||
|
||||
-- ------- containment: every rerouted write lands in the mod's own overlay
|
||||
|
||||
T.check(out.escapeOpened, "io.open on an absolute path outside the tree opens")
|
||||
T.eq(out.escapeReadBack, "pwned", "and reads its own write back")
|
||||
T.eq(FILES["/etc/hosts"], nil,
|
||||
"but nothing was written outside the game tree")
|
||||
T.eq(FILES["mod_compat/fix_sandbox/etc/hosts"], "pwned",
|
||||
"the bytes went to this mod's private overlay instead")
|
||||
T.eq(out.homeEnv, "/pokeport/fix_sandbox",
|
||||
"os.getenv(\"HOME\") answers with the mod's virtual root, not the real one")
|
||||
T.eq(out.saveDir, "/pokeport/fix_sandbox",
|
||||
"and so does the reported save directory")
|
||||
|
||||
T.eq(out.roundTrip, "x=1\ny=2\n", "a love.filesystem write reads back")
|
||||
T.eq(out.roundTripInfo and out.roundTripInfo.type, "file",
|
||||
"and getInfo sees it")
|
||||
T.same(out.roundTripLines, { "x=1", "y=2" }, "lines() walks it")
|
||||
T.eq(out.appended, "x=1\ny=2\nz=3\n", "append extends it")
|
||||
T.eq(out.rootedRead, "rooted",
|
||||
"a path joined to the reported save directory routes to the same key")
|
||||
T.eq(FILES["mod_compat/fix_sandbox/cfg/settings.txt"], "rooted",
|
||||
"one key in the overlay, under the mod's own id, however it was named")
|
||||
|
||||
-- ------- reads still see the mod's packaged files
|
||||
|
||||
T.eq(out.ownThroughLove, "own file",
|
||||
"love.filesystem.read of a path inside the mod reads the shipped file")
|
||||
T.eq(out.ownThroughIo, "own file", "and so does io.open on a relative path")
|
||||
T.eq(out.shadowed, "shadowed", "a write over a packaged path shadows it")
|
||||
T.eq(FILES["mods/fix_sandbox/data/note.txt"], "own file",
|
||||
"without rewriting what the mod shipped")
|
||||
T.eq(out.shadowedOwn, "own file",
|
||||
"and mod:read still reports the packaged bytes")
|
||||
|
||||
-- ------- the reroute is reported, not silent
|
||||
|
||||
do
|
||||
local report = run.loader:legacyReport("fix_sandbox")
|
||||
local calls = {}
|
||||
for _, row in ipairs(report) do calls[row.call] = row end
|
||||
T.check(calls["io.open"], "io.open is recorded against the mod")
|
||||
T.check(calls["love.filesystem.write"], "so is love.filesystem.write")
|
||||
T.check(calls["os.getenv"], "and os.getenv")
|
||||
T.check(calls["love.filesystem.write"].count >= 2,
|
||||
"with a count, so a manager can rank the worst offenders")
|
||||
T.check(calls["io.open"].advice and #calls["io.open"].advice > 0,
|
||||
"each row carries the advice the warning printed")
|
||||
end
|
||||
|
||||
-- ------- env propagation and isolation
|
||||
|
||||
T.eq(out.childIo, nil,
|
||||
T.check(out.childIoIsShim,
|
||||
"a chunk a mod load()s inherits the sandbox (5.1 would hand it the real _G)")
|
||||
T.eq(out.childGetenv, nil, "the child chunk gets the same reduced os")
|
||||
T.check(out.childGetenvIsShim, "the child chunk gets the same rerouted os")
|
||||
T.check(out.childSharesEnv, "the child chunk shares the mod's own globals table")
|
||||
T.check(out.globalsAreOwn, "a mod's globals write to its own table")
|
||||
T.eq(_G.SANDBOX_LEAK, nil, "and never reach the engine's _G")
|
||||
@@ -155,8 +286,56 @@ 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()
|
||||
|
||||
-- ------- two mods never share an overlay
|
||||
|
||||
do
|
||||
local files = {
|
||||
["mods/one/manifest.json"] = manifest("one"),
|
||||
["mods/one/main.lua"] = [[
|
||||
local mod = ...
|
||||
love.filesystem.write("shared.txt", "from one")
|
||||
mod.exports.mine = love.filesystem.read("shared.txt")
|
||||
]],
|
||||
["mods/two/manifest.json"] = manifest("two"),
|
||||
["mods/two/main.lua"] = [[
|
||||
local mod = ...
|
||||
mod.exports.peek = love.filesystem.read("shared.txt")
|
||||
mod.exports.climb = love.filesystem.read("../one/shared.txt")
|
||||
]],
|
||||
}
|
||||
local pair = T.sdk.loadMods({ "mods/one", "mods/two" },
|
||||
{ fs = T.sdk.memfs(files) })
|
||||
T.eq(#pair.errors, 0, "both mods load (" .. tostring(pair.errors[1]) .. ")")
|
||||
T.eq(pair.loader.exports.one.mine, "from one", "the first mod sees its write")
|
||||
T.eq(pair.loader.exports.two.peek, nil,
|
||||
"the second mod, naming the same path, sees nothing")
|
||||
T.eq(files["mod_compat/one/shared.txt"], "from one",
|
||||
"because the overlay is keyed by mod id")
|
||||
T.eq(pair.loader.exports.two.climb, nil,
|
||||
"and a climb out of the overlay resolves inside it, not into the neighbour")
|
||||
pair.release()
|
||||
end
|
||||
|
||||
-- ------- the grammar itself
|
||||
|
||||
for _, bad in ipairs({ "../x", "a/../../x", "/etc/hosts", "C:/Windows/x",
|
||||
@@ -196,11 +375,11 @@ do
|
||||
bytecodeRun.release()
|
||||
end
|
||||
|
||||
-- ------- the sandbox is not opt-in
|
||||
-- ------- the sandbox with no compat layer is still closed
|
||||
|
||||
do
|
||||
local env = Sandbox.envFor({ modId = "probe" })
|
||||
T.eq(env.io, nil, "a bare Sandbox.envFor is already closed")
|
||||
T.eq(env.io, nil, "a bare Sandbox.envFor has no io")
|
||||
T.eq(env._G, env, "_G points at the sandbox, not the real globals")
|
||||
T.check(not pcall(env.require, "io"), "and its require refuses io")
|
||||
end
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
-- Contextual bicycle and fishing actions share one public contract in both
|
||||
-- generations while each engine keeps ownership of its own field-item path.
|
||||
-- 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 items")
|
||||
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" } },
|
||||
@@ -14,11 +16,15 @@ local redWorld = {
|
||||
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 = { items = { OLD_ROD = { name = "OLD ROD" } } },
|
||||
data = { field = { outsideTilesets = { "OVERWORLD" } },
|
||||
items = { OLD_ROD = { name = "OLD ROD" } } },
|
||||
save = { player = { name = "RED" }, party = {},
|
||||
inventory = { BICYCLE = 1, OLD_ROD = 1 } },
|
||||
stack = { states = { redWorld } },
|
||||
@@ -30,8 +36,11 @@ 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",
|
||||
"Red keeps field-item execution in its world")
|
||||
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")
|
||||
@@ -55,26 +64,98 @@ 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,
|
||||
fieldContext = function() return { facingColl = 0x20 } 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" } } },
|
||||
save = { inventory = { BICYCLE = 1, OLD_ROD = 1 } },
|
||||
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].rods[1].id, "OLD_ROD", "Gold shares the rod shape")
|
||||
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",
|
||||
@@ -84,5 +165,9 @@ 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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user