mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-16 00:02:23 +02:00
Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d2c13ed2b | |||
| d87f6b8ad1 | |||
| 24cf367758 | |||
| 829d398a94 | |||
| 3ee50a27c5 | |||
| 871087a16b | |||
| 180ce6b2e7 | |||
| cf335f67de | |||
| 7e0d81a431 | |||
| 10314bcdcf | |||
| b8ec4fe6b5 | |||
| 099a4266a8 | |||
| cec1f196be | |||
| e24410f0fb | |||
| b29b6fd7bd | |||
| f06c4d4584 | |||
| 3a997e8a62 | |||
| e6ccdd57eb | |||
| 927507f8f7 | |||
| 9bf15c33fd | |||
| 52efdabf61 | |||
| ef208035ec | |||
| 18d61779eb | |||
| d573878a2f | |||
| a66efe207d | |||
| 5198b35945 | |||
| 40977337b1 | |||
| 1598f34954 | |||
| 43cbc554c3 | |||
| 3c3e2c54c5 | |||
| 00c72c441b | |||
| 7804ef9793 | |||
| 673d8b3ad8 | |||
| 62e1296ced | |||
| a3bbd78e7b | |||
| 8dfbd1daae | |||
| 4046b28a8c |
@@ -262,7 +262,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Setup .NET 8
|
||||
uses: actions/setup-dotnet@v4
|
||||
uses: actions/setup-dotnet@v6
|
||||
with:
|
||||
dotnet-version: "8.0.x"
|
||||
- name: Publish gen1tls (win-x64 Native AOT)
|
||||
|
||||
+39
-12
@@ -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), `mod:read` for a known file, `mod:list` / `mod:info` to iterate your own directory |
|
||||
| `love.thread`, `love.event` | `mod.events`, `mod.hooks` |
|
||||
| `love.system` | `mod.device:powerInfo()` for battery information; `mod.steps` (with the `steps` permission) for the step bridge |
|
||||
| `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.
|
||||
@@ -278,13 +298,20 @@ Three consequences worth knowing before you write against it:
|
||||
- **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`
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ save directory as:
|
||||
| nil / `"rom"` | `picked_rom.gb` (open) |
|
||||
| `"mod"` | `picked_mod.zip` (open) |
|
||||
| `"sav"` / `"save"` | `picked_save.sav` (open) |
|
||||
| `"required_import"` | `picked_required_import.bin` (open) |
|
||||
|
||||
Export uses a separate API: `love.system.createFile(suggestedName)` →
|
||||
`GameActivity.showCreateDocument` (`ACTION_CREATE_DOCUMENT`), which copies
|
||||
|
||||
+200
@@ -90,6 +90,7 @@ Every mod contains a root `manifest.json` defining its metadata, supported games
|
||||
| `optional_imports` | `array` | User-supplied files that unlock optional mod functionality. They use the same validation and private-copy flow but never block the mod from loading. |
|
||||
| `conflicts` / `incompatible` | `array` | List of mod IDs that cannot run concurrently with this mod. |
|
||||
| `permissions` | `array` | Requested privileges (e.g. `["engine_internals"]`, `["network"]`, `["filesystem"]`). |
|
||||
| `log_url` | `string` | Optional https URL for `mod.postLog` log reporting (api 2; requires the `network` permission). |
|
||||
| `github` | `string` | GitHub repository (`"owner/repo"`) used for update checks and dependency download links. |
|
||||
|
||||
### Declaring Dependencies & Scoping
|
||||
@@ -148,6 +149,18 @@ older iOS-only `"stadium"` picker kind remains temporarily for compatibility.
|
||||
Android now returns `false` for unknown picker kinds instead of treating them
|
||||
as game-ROM picks.
|
||||
|
||||
### Platform import flow
|
||||
|
||||
The same per-mod validation and private `mods/<mod-id>/baseroms/` destination
|
||||
applies on every supported platform. Windows, macOS, and Linux use the
|
||||
launcher file chooser. Android uses the Storage Access Framework, and iOS uses
|
||||
the Files document picker; both stage the choice as `picked_required_import.bin`
|
||||
before validation. Xbox/UWP uses its native picker and hands the launcher a
|
||||
temporary path. Switch/NX has no host picker, so the player copies a file to
|
||||
`imports/baseroms/` over MTP and chooses the import again. No platform grants
|
||||
the mod a host filesystem path or bypasses the manifest's size, format, and MD5
|
||||
checks.
|
||||
|
||||
## Mods and Gold (Gen 2)
|
||||
|
||||
The mod API is one API across both generations, but Gold runs its own battle
|
||||
@@ -802,3 +815,190 @@ the native side's pending file itself, each permissioned mod receives its
|
||||
own copy of a delivery, and steps are anchored natively so the same walk
|
||||
is never delivered twice. Without the permission, `sync` and `poll` raise
|
||||
an error naming it.
|
||||
|
||||
## Background HTTP
|
||||
|
||||
`mod.fetch` is how a mod does work off the main thread. It is behind the
|
||||
`network` permission in `manifest.json`, the same one that gates
|
||||
`require("socket")`, and the player sees it in the mod manager.
|
||||
|
||||
```lua
|
||||
-- somewhere once
|
||||
local job = mod.fetch:get("https://example.com/data.json")
|
||||
|
||||
-- in a hook or update, every frame -- poll never blocks
|
||||
if job then
|
||||
local r = mod.fetch:poll(job)
|
||||
if r.status ~= "pending" then
|
||||
if r.status == "ok" then use(r.body) else warn(r.err) end
|
||||
mod.fetch:release(job)
|
||||
job = nil
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
`get(url, opts)` returns an opaque handle, or `nil` plus a reason. `opts`
|
||||
takes `accept` (a request Accept header) and `maxSeconds` (clamped to 30).
|
||||
`poll(handle)` returns `{ status, body, err, progress }` where `status` is
|
||||
`"pending"`, `"ok"`, `"error"` or `"cancelled"`; it is a copy, and it never
|
||||
blocks, so calling it every frame is the intended use. `release(handle)`
|
||||
frees a finished job — do it, or you will hit the ceiling. `cancel(handle)`
|
||||
drops a result you no longer want. `available()` is `false` when the build
|
||||
has no transport and for mods without the permission, so a probe is safe.
|
||||
|
||||
The rules worth knowing before you design around it:
|
||||
|
||||
- **http and https only.** The underlying transport also speaks `file://`,
|
||||
`ftp://` and `scp://`; those are refused, on the initial URL and on any
|
||||
redirect. `mod.fetch` is not a way to read a local file.
|
||||
- **Four requests in flight per mod.** The worker pool is shared with the
|
||||
launcher's own downloads, so one mod cannot fill it. Over the ceiling,
|
||||
`get` returns `nil` and a reason until you release something.
|
||||
- **Handles are yours alone.** A handle from another mod, a fabricated
|
||||
table, or a guessed number all poll as `"error"`.
|
||||
- **Your mod id is in the User-Agent**, so a server operator can see who is
|
||||
calling and a mod cannot pose as the launcher.
|
||||
- Jobs are released when your mod unloads.
|
||||
|
||||
This is deliberately not `love.thread`. A LÖVE thread is a fresh Lua state
|
||||
with a full standard library that the sandbox cannot reach, so handing one
|
||||
to a mod would undo every other rule; `mod.fetch`'s workers run engine
|
||||
code, so a mod gets asynchrony without gaining any new reach.
|
||||
|
||||
## Log reporting
|
||||
|
||||
`mod.postLog(body, opts)` is the one-way exception to the rule that a mod
|
||||
decides where it talks. It reports a debug/crash log to the https URL the
|
||||
manifest declares in `log_url`, and it is the only API that may not be
|
||||
pointed at a caller-chosen address:
|
||||
|
||||
```json
|
||||
{
|
||||
"permissions": ["network"],
|
||||
"log_url": "https://logs.example.com/receive"
|
||||
}
|
||||
```
|
||||
|
||||
The URL is validated at load: it must be `https://`, and declaring it
|
||||
without the `network` permission is a load violation for api 2 mods. The
|
||||
destination is reviewed when the mod ships, not chosen per call, so a mod
|
||||
cannot aim this at arbitrary hosts or read back anything a server replies.
|
||||
|
||||
```lua
|
||||
-- fire and forget; poll() never blocks, same shape as mod.fetch
|
||||
local job = mod:postLog("session crashed at 0x1f3a\n" .. logText)
|
||||
```
|
||||
|
||||
`postLog(body, opts)` returns the same opaque handle as `mod.fetch:get`,
|
||||
polled and released through `mod.fetch:poll` / `mod.fetch:release`. `opts`
|
||||
is a closed list with one switch: `format`, either `"text"` (the default)
|
||||
or `"json"`. `json` wraps the body in an envelope of `{ ts, mod, format,
|
||||
body }` so a server can attribute and sort reports; any other key or value
|
||||
is refused before a job is submitted. The body is capped at 64 KB, the
|
||||
transfer is bounded by the same worker ceilings as `mod.fetch`, and the
|
||||
response body is never returned to the mod.
|
||||
|
||||
## Background jobs
|
||||
|
||||
`mod.fetch` covers work waiting on a server. `mod.job` covers work waiting on
|
||||
the CPU — generating a map, crunching a table, anything that would otherwise
|
||||
stall a frame. It is behind the `background` permission in `manifest.json`.
|
||||
|
||||
Ship the job as its own file inside your mod:
|
||||
|
||||
```lua
|
||||
-- mods/your_mod/jobs/crunch.lua
|
||||
local arg = ...
|
||||
local total = 0
|
||||
for i = 1, arg.n do total = total + i end
|
||||
return { total = total }
|
||||
```
|
||||
|
||||
```lua
|
||||
-- in your entry file
|
||||
local job = mod.job:run("jobs/crunch.lua", { n = 1e6 })
|
||||
|
||||
-- later, in a hook -- poll never blocks
|
||||
local r = mod.job:poll(job)
|
||||
if r.status == "ok" then
|
||||
use(r.result.total)
|
||||
mod.job:release(job)
|
||||
end
|
||||
```
|
||||
|
||||
`run(script, arg, opts)` returns an opaque handle, or `nil` plus a reason.
|
||||
`opts.maxSeconds` sets the job's time budget (default 5, clamped to 30).
|
||||
`poll(handle)` returns `{ status, result, err }` with `status` one of
|
||||
`"pending"`, `"ok"`, `"error"` or `"cancelled"`. `release(handle)` frees it.
|
||||
`available()` is `false` on a host without threads and for mods without the
|
||||
permission, so a probe is always safe.
|
||||
|
||||
**A job is pure compute.** This is the part to design around, not a detail:
|
||||
|
||||
- **Plain data in, plain data out.** Numbers, strings, booleans and tables of
|
||||
them. A function, userdata, a cycle or a table key that is not a string or
|
||||
number is refused at your `run` call with a reason. Nothing is shared —
|
||||
your argument is snapshotted, and mutating the original afterwards does not
|
||||
reach the job.
|
||||
- **No engine API, no game state, no storage.** `require` is refused inside a
|
||||
job, and there is no `mod` object. A job cannot read the party, write
|
||||
`mod.storage`, or touch a registry. Get what it needs into the argument and
|
||||
act on the result back on the main thread.
|
||||
- **Your script is a file in your mod folder.** The path goes through the same
|
||||
rules as `mod:read`; `..`, absolute paths and drive letters are refused.
|
||||
- **Two jobs per mod, four on the machine.** Over the limit, `run` returns
|
||||
`nil` and a reason until you release one.
|
||||
- **The budget bounds how long YOU wait, not how long the work runs.** Past
|
||||
`maxSeconds`, `poll` reports an error and the result is dropped if it ever
|
||||
arrives — but the thread runs to its own end. There is no way to stop a
|
||||
LÖVE thread from outside, and every attempt to stop one from inside was
|
||||
worse than the disease (a debug hook does not reliably interrupt LuaJIT,
|
||||
and raising from one wedged the whole process). `cancel(handle)` is the
|
||||
same deal: it drops the result, it does not stop the work.
|
||||
|
||||
So **write jobs that terminate.** A job with an infinite loop will keep one
|
||||
core busy until the game closes. It will not freeze the game — the main
|
||||
thread stays responsive and quitting still works — but nothing will reclaim
|
||||
that core in the meantime.
|
||||
|
||||
Your job script runs in the same sandbox your entry file does, so `io`, `os`,
|
||||
`debug`, `ffi`, `package` and `love.filesystem` are absent there too. That is
|
||||
the whole reason this exists rather than `love.thread`: a raw LÖVE thread is a
|
||||
fresh Lua state with a full standard library that the sandbox cannot reach, so
|
||||
handing one to a mod would undo every other rule. Here the worker builds your
|
||||
sandbox first and loads your chunk into it.
|
||||
|
||||
## Pre-sandbox globals (compat)
|
||||
|
||||
A mod written before the sandbox landed does not have to be updated to
|
||||
load. `io`, `package`, `dofile`, `loadfile`, `os.getenv`, `love.filesystem`,
|
||||
`love.system` and `love.event` are all present again as compat stand-ins
|
||||
(`src/mods/LegacyCompat.lua`), and assigning a LÖVE callback
|
||||
(`love.mousemoved = fn`) installs on the real table the way it always did.
|
||||
Every stand-in call logs one warning naming its replacement, and
|
||||
`loader:legacyReport(modId)` returns the same list with call counts, which
|
||||
is what a "needs updating" badge should read.
|
||||
|
||||
The stand-ins are not the old globals. Paths are classified rather than
|
||||
passed through:
|
||||
|
||||
- A path inside your own mod directory reads the file you shipped.
|
||||
- Anything else, including an absolute path, resolves into a private
|
||||
per-mod overlay at `mod_compat/<your id>/` under the save directory.
|
||||
Two mods naming the same path never see each other's bytes, and nothing
|
||||
is written outside the game tree.
|
||||
- A read misses through the overlay to your shipped file, then to
|
||||
`mod.storage`, so a half-migrated mod sees both.
|
||||
- A write over a path you shipped shadows it; the packaged file is never
|
||||
modified, and `mod:read` still returns the packaged bytes.
|
||||
- `love.filesystem.getSaveDirectory()` and `os.getenv("HOME")` answer with
|
||||
a virtual root, so a legacy mod that joins its own paths lands back in
|
||||
the same overlay.
|
||||
|
||||
`love.thread` stays refused. A LÖVE thread runs in a separate Lua state
|
||||
with the full standard library, which the sandbox in this state cannot
|
||||
reach, so a stand-in would be a hole rather than a reroute. The same goes
|
||||
for `ffi`, `debug`, `setfenv`, `os.execute`, `io.popen`, `love.run` and
|
||||
`love.errorhandler`. A mod that needs real background work needs an
|
||||
engine-owned facility, not a compat shim -- for HTTP that facility is
|
||||
[`mod.fetch`](#modfetch), which runs on the engine's own worker pool.
|
||||
|
||||
@@ -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
|
||||
|
||||
+13
-5
@@ -126,11 +126,19 @@ bundled game, in that case.
|
||||
already driving the frame. A payload that must change `love.run` itself
|
||||
needs a `minShell` bump so an older shell refuses to chainload it rather
|
||||
than running with half its intended behavior.
|
||||
- **Android has no in-app download transport yet.** `check_worker.lua`
|
||||
shells out to curl for both the release check and the download; curl is
|
||||
absent on Android, so `Check` degrades to `status = "error"` there (the
|
||||
launcher UI hides on that status) and the player is directed to the
|
||||
releases page via `Check.releaseUrl()` instead.
|
||||
- **Android and iOS use the native download bridge, not curl.** Neither
|
||||
platform ships curl, so the old `check_worker.lua` path (shell out to curl)
|
||||
always landed on `error` and the launcher chip's "Check for updates" tap
|
||||
was a no-op. The worker now talks through `HostShell`, the same transport
|
||||
as the mod catalog: curl on desktop, `love.system.httpDownload` on mobile.
|
||||
On Android that is the GameActivity JNI/`HttpsURLConnection` bridge; on
|
||||
iOS it is `GRPickerBridge.httpDownload` (`URLSession`). A fused sideloaded
|
||||
APK or IPA can therefore check GitHub and fetch the `.love` payload
|
||||
in-app. If neither transport exists, the worker reports `needs_full` and
|
||||
the launcher chip opens `Check.releaseUrl()`. Native package-only changes
|
||||
still need a full reinstall (`minShell` / `payloadHost` gate →
|
||||
`needs_full`). Applying a downloaded payload on Android relaunches via
|
||||
`love.system.restartApp`; iOS still uses in-process `quit("restart")`.
|
||||
- **Dev/source runs never self-update.** `Boot.run` returns immediately when
|
||||
`love.filesystem.isFused()` is false, and a working tree's `engine` is the
|
||||
`"0.0.0-dev"` placeholder that always reports up to date, so a source
|
||||
|
||||
@@ -148,9 +148,44 @@ local function openEditor(version, slotId)
|
||||
editorMode = true
|
||||
resizeForEditor()
|
||||
addEditorRequirePath()
|
||||
EditorApp = require("App")
|
||||
EditorApp.load(path, { version = version, slotId = slotId, embedded = true,
|
||||
onClose = function() closeEditor() end })
|
||||
local okReq, appOrErr = pcall(require, "App")
|
||||
if not okReq then
|
||||
editorMode = false
|
||||
if version then
|
||||
require("src.import.CacheFs").unmountVersion(version)
|
||||
end
|
||||
restoreWindow()
|
||||
Importer = editorHost
|
||||
editorHost = nil
|
||||
editorVersion = nil
|
||||
if Importer and Importer.resumeAfterOverlay then
|
||||
Importer:resumeAfterOverlay()
|
||||
end
|
||||
refuse("Could not open the save editor (" .. tostring(appOrErr) .. ").")
|
||||
return
|
||||
end
|
||||
EditorApp = appOrErr
|
||||
local okLoad, loadErr = pcall(EditorApp.load, path, {
|
||||
version = version, slotId = slotId, embedded = true,
|
||||
onClose = function() closeEditor() end,
|
||||
})
|
||||
if not okLoad then
|
||||
editorMode = false
|
||||
if EditorApp.unload then pcall(EditorApp.unload) end
|
||||
EditorApp = nil
|
||||
if version then
|
||||
require("src.import.CacheFs").unmountVersion(version)
|
||||
require("src.core.Data"):unloadGenerated()
|
||||
end
|
||||
restoreWindow()
|
||||
Importer = editorHost
|
||||
editorHost = nil
|
||||
editorVersion = nil
|
||||
if Importer and Importer.resumeAfterOverlay then
|
||||
Importer:resumeAfterOverlay()
|
||||
end
|
||||
refuse("Could not open the save editor (" .. tostring(loadErr) .. ").")
|
||||
end
|
||||
end
|
||||
|
||||
-- Back to the launcher. Everything the editor mounted or cached has to come
|
||||
@@ -166,6 +201,11 @@ function closeEditor()
|
||||
require("src.import.CacheFs").unmountVersion(version)
|
||||
require("src.core.Data"):unloadGenerated()
|
||||
end
|
||||
for k in pairs(package.loaded) do
|
||||
if type(k) == "string" and (k:find("save%-editor") or k == "App" or k == "Kit" or k == "State" or k == "Catalog" or k == "SaveIO" or k == "Ops" or k == "MonOps" or k == "ItemOps" or k == "PadInput" or k == "Gen" or k == "Theme") then
|
||||
package.loaded[k] = nil
|
||||
end
|
||||
end
|
||||
editorVersion = nil
|
||||
restoreWindow()
|
||||
Importer = editorHost
|
||||
@@ -263,6 +303,11 @@ function love.load(args)
|
||||
-- of each flashing their own cmd.exe window (#606). No-op elsewhere.
|
||||
require("src.core.HostShell").hideHostConsole()
|
||||
|
||||
-- Hang gen1tls on love.system before mods boot. Android already has tls*
|
||||
-- from JNI; this is the desktop half. No DLL / no FFI is fine -- ws://
|
||||
-- rooms still work, wss:// just won't.
|
||||
pcall(function() require("src.net.Gen1Tls").install() end)
|
||||
|
||||
-- NX fused mounts are unreliable for the blue|yellow cache overlay: wrap
|
||||
-- the love loaders once so every generated-asset read falls back to the
|
||||
-- versioned save-dir copy. Never installed on desktop/Android/iOS.
|
||||
|
||||
@@ -90,6 +90,9 @@ public class GameActivity extends SDLActivity {
|
||||
private static final String PICKED_ROM_FILENAME = "picked_rom.gb";
|
||||
private static final String PICKED_MOD_FILENAME = "picked_mod.zip";
|
||||
private static final String PICKED_SAVE_FILENAME = "picked_save.sav";
|
||||
// Kept separate from the game-ROM destination so a dependency pick can
|
||||
// never be mistaken for a game import when the picker returns on Android.
|
||||
private static final String PICKED_REQUIRED_IMPORT_FILENAME = "picked_required_import.bin";
|
||||
private static final String PENDING_EXPORT_FILENAME = "pending_export.sav";
|
||||
private static final String EXPORT_DONE_FILENAME = "export_done.flag";
|
||||
// Written when a SAF pick cannot be read at all, with the destination
|
||||
@@ -504,7 +507,8 @@ public class GameActivity extends SDLActivity {
|
||||
* picker-agnostic and unchanged.
|
||||
*
|
||||
* @param destFilename basename under the app save identity (e.g.
|
||||
* picked_rom.gb, picked_mod.zip, picked_save.sav)
|
||||
* picked_rom.gb, picked_mod.zip, picked_save.sav, or
|
||||
* picked_required_import.bin)
|
||||
*/
|
||||
/** Legacy single-argument entry; resolves the save dir itself. */
|
||||
@Keep
|
||||
@@ -535,6 +539,11 @@ public class GameActivity extends SDLActivity {
|
||||
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
||||
intent.setType("*/*");
|
||||
// The Storage Access Framework grants the returned content URI
|
||||
// directly to this activity. Request the read grant explicitly as
|
||||
// well: Android 13's scoped storage deliberately does not expose
|
||||
// arbitrary paths or require broad media/storage permissions.
|
||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||
try {
|
||||
self.startActivityForResult(intent, FILE_PICKER_REQUEST_CODE);
|
||||
return true;
|
||||
@@ -547,6 +556,7 @@ public class GameActivity extends SDLActivity {
|
||||
Intent intent = new Intent(Intent.ACTION_GET_CONTENT);
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
||||
intent.setType("*/*");
|
||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||
try {
|
||||
self.startActivityForResult(
|
||||
Intent.createChooser(intent, "Choose a file"),
|
||||
@@ -576,6 +586,12 @@ public class GameActivity extends SDLActivity {
|
||||
return showFilePicker(PICKED_SAVE_FILENAME);
|
||||
}
|
||||
|
||||
/** Required-mod-file wrapper used by love.system.pickFile("required_import"). */
|
||||
@Keep
|
||||
public static boolean showRequiredImportFilePicker() {
|
||||
return showFilePicker(PICKED_REQUIRED_IMPORT_FILENAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Relaunches the whole app for love.system.restartApp, used by
|
||||
* src/core/HostShell.lua when a mod toggle needs a cold boot (#575).
|
||||
|
||||
@@ -12,6 +12,41 @@
|
||||
"tintColor": "3b5ca8",
|
||||
"category": "games",
|
||||
"versions": [
|
||||
{
|
||||
"version": "0.1.93",
|
||||
"date": "2026-08-15",
|
||||
"size": 11366991,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.93/gen1recomp++-0.1.93-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1314 Launcher is a bit buggy\n\n## Contributors\n\n- @1Jamie\n- @anxiousintrovert\n- @bryanthaboi\n- @TheRealSolidusSnake"
|
||||
},
|
||||
{
|
||||
"version": "0.1.92",
|
||||
"date": "2026-08-15",
|
||||
"size": 11364274,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.92/gen1recomp++-0.1.92-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.91",
|
||||
"date": "2026-08-15",
|
||||
"size": 11351477,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.91/gen1recomp++-0.1.91-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.90",
|
||||
"date": "2026-08-15",
|
||||
"size": 11344338,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.90/gen1recomp++-0.1.90-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @1Jamie\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.89",
|
||||
"date": "2026-08-15",
|
||||
"size": 11343841,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.89/gen1recomp++-0.1.89-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @1Jamie\n- @anxiousintrovert\n- @AverageConsumer\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.88",
|
||||
"date": "2026-08-14",
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
/Users/bryanbassett/Documents/development/pokemon-gen1-recomp-project/.bazinga/mods/timekeepers_hut
|
||||
+39
-15
@@ -14,6 +14,14 @@ local MODULES = {
|
||||
-- Optional for compatibility with developer and stale caches.
|
||||
local OPTIONAL = { "audio", "palettes", "icons" }
|
||||
|
||||
-- Gold's extractor never writes these Gen 1 tables (RomExtractorGen2 has
|
||||
-- maps/text/pokemon/items, not text_pointers / trainer_headers / field).
|
||||
-- Desktop can still `require` Red's copies from the source tree, so Gold
|
||||
-- Edit appeared to work there; an Android APK has only the per-version
|
||||
-- cache, so Data:load used to throw on the first Gold Edit and take the
|
||||
-- activity down. Empty tables are enough for seedDefaults / the editor.
|
||||
local GEN2_OPTIONAL = { text_pointers = true, trainer_headers = true, field = true }
|
||||
|
||||
-- Vanilla defaults for rules exposed through the constants registry. A
|
||||
-- value has to exist before a mod can patch it; each one matches the
|
||||
-- engine's no-mod behavior, so seeding them changes nothing on a vanilla
|
||||
@@ -99,7 +107,11 @@ end
|
||||
-- Fills only what the cache is missing, so an importer that learns to
|
||||
-- stamp one of these keys silently takes over from the engine.
|
||||
function Data:seedDefaults()
|
||||
local constants = self.constants
|
||||
local constants = self.constants or {}
|
||||
self.constants = constants
|
||||
self.field = self.field or {}
|
||||
self.maps = self.maps or {}
|
||||
self.pokemon = self.pokemon or {}
|
||||
for key, value in pairs(CONSTANT_DEFAULTS) do
|
||||
if constants[key] == nil then constants[key] = copy(value) end
|
||||
end
|
||||
@@ -108,7 +120,11 @@ function Data:seedDefaults()
|
||||
if constants.dexSize == nil then
|
||||
local highest = 0
|
||||
for _, def in pairs(self.pokemon) do
|
||||
if def.dex and def.dex > highest then highest = def.dex end
|
||||
-- Gold's pokemon.lua also carries growthRates / tmhmMoves / generation
|
||||
-- scalars beside species rows.
|
||||
if type(def) == "table" and def.dex and def.dex > highest then
|
||||
highest = def.dex
|
||||
end
|
||||
end
|
||||
constants.dexSize = highest
|
||||
end
|
||||
@@ -212,36 +228,41 @@ local function loadModule(dir, name)
|
||||
if not chunk then return false, err end
|
||||
return pcall(chunk)
|
||||
end
|
||||
local ok, mod = pcall(require, "data.generated." .. name)
|
||||
if ok then return true, mod end
|
||||
-- Fused PhysFS / Blue|Yellow prefix: load bytes from the active version's
|
||||
-- cache explicitly when require cannot see the mounted tree.
|
||||
local CacheFs = require("src.import.CacheFs")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local path = "data/generated/" .. name .. ".lua"
|
||||
local bytes = CacheFs.readActive(path)
|
||||
if type(bytes) == "string" then
|
||||
local chunk, err = loadstring(bytes, "@" .. GameVersion.cachePrefix() .. path)
|
||||
if not chunk then return false, err or mod end
|
||||
return pcall(chunk)
|
||||
local chunk = loadstring(bytes, "@" .. GameVersion.cachePrefix() .. path)
|
||||
if chunk then
|
||||
local ok, res = pcall(chunk)
|
||||
if ok then return true, res end
|
||||
end
|
||||
end
|
||||
return false, mod
|
||||
local ok, mod = pcall(require, "data.generated." .. name)
|
||||
if ok then return true, mod end
|
||||
return false, nil
|
||||
end
|
||||
|
||||
function Data:load()
|
||||
local dir = os.getenv("POKEPORT_DATA_DIR")
|
||||
local gen2 = require("src.core.GameVersion").generation() == 2
|
||||
for _, name in ipairs(MODULES) do
|
||||
local ok, mod = loadModule(dir, name)
|
||||
if not ok then
|
||||
if dir then
|
||||
if gen2 and GEN2_OPTIONAL[name] then
|
||||
self[name] = {}
|
||||
elseif dir then
|
||||
error(("missing data module '%s/%s.lua' (POKEPORT_DATA_DIR).\n(%s)")
|
||||
:format(dir, name, mod))
|
||||
else
|
||||
error(("missing generated data module 'data/generated/%s.lua'.\n" ..
|
||||
"Import the ROM again or rebuild developer data.\n(%s)")
|
||||
:format(name, mod))
|
||||
end
|
||||
error(("missing generated data module 'data/generated/%s.lua'.\n" ..
|
||||
"Import the ROM again or rebuild developer data.\n(%s)")
|
||||
:format(name, mod))
|
||||
else
|
||||
self[name] = mod
|
||||
end
|
||||
self[name] = mod
|
||||
end
|
||||
for _, name in ipairs(OPTIONAL) do
|
||||
local ok, mod = loadModule(dir, name)
|
||||
@@ -280,11 +301,14 @@ function Data:unloadGenerated()
|
||||
if not pristine[key] then self[key] = nil end
|
||||
end
|
||||
end
|
||||
self._pristineKeys = nil
|
||||
for _, name in ipairs(MODULES) do
|
||||
package.loaded["data.generated." .. name] = nil
|
||||
self[name] = nil
|
||||
end
|
||||
for _, name in ipairs(OPTIONAL) do
|
||||
package.loaded["data.generated." .. name] = nil
|
||||
self[name] = nil
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
+56
-2
@@ -325,7 +325,8 @@ function HostShell.httpDownload(url, absPath, userAgent, accept, maxTime)
|
||||
if type(absPath) ~= "string" or absPath == "" then return nil, "missing path" end
|
||||
userAgent = userAgent or "gen1recomp"
|
||||
if HostShell.haveCurl() then
|
||||
local cmd = ("curl -fsSL --connect-timeout 15 --max-time %d ")
|
||||
local cmd = ("curl -fsSL --proto =http,https --proto-redir =http,https "
|
||||
.. "--connect-timeout 15 --max-time %d ")
|
||||
:format(tonumber(maxTime) or 300)
|
||||
.. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " "
|
||||
if accept then
|
||||
@@ -370,7 +371,8 @@ function HostShell.httpGet(url, userAgent, accept, maxTime)
|
||||
-- BODY, and on the two services this talks to that body is the whole
|
||||
-- diagnosis: GitHub's 403 says "API rate limit exceeded for <ip>", which
|
||||
-- tells a user to wait rather than to go hunting for a broken index.
|
||||
local cmd = ("curl -sSL --connect-timeout 10 --max-time %d ")
|
||||
local cmd = ("curl -sSL --proto =http,https --proto-redir =http,https "
|
||||
.. "--connect-timeout 10 --max-time %d ")
|
||||
:format(tonumber(maxTime) or 40)
|
||||
.. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " "
|
||||
if accept then
|
||||
@@ -415,4 +417,56 @@ function HostShell.httpGet(url, userAgent, accept, maxTime)
|
||||
return body
|
||||
end
|
||||
|
||||
-- POST returning success/failure. Strictly one-way: the response body is
|
||||
-- discarded, only the HTTP status class is surfaced (postLog callers never
|
||||
-- trust the reply). curl --data-binary reads the payload from a pipe, so a
|
||||
-- large body never lands in the command line; the Android bridge has no POST
|
||||
-- transport, and httpPost reports that instead of half-working through
|
||||
-- httpDownload (a GET round-trip to a POST endpoint would be a lie).
|
||||
function HostShell.httpPost(url, body, contentType, userAgent, maxTime)
|
||||
if type(url) ~= "string" or url == "" then return nil, "missing url" end
|
||||
if type(body) ~= "string" then return nil, "missing body" end
|
||||
userAgent = userAgent or "gen1recomp"
|
||||
if HostShell.haveCurl() then
|
||||
-- --data-binary @- keeps the payload out of argv (command-line length
|
||||
-- limits on Windows) and preserves every byte including trailing
|
||||
-- newlines. No -f, matching httpGet: the response body is discarded
|
||||
-- anyway, and curl's stderr carries the real diagnosis on failure.
|
||||
local cmd = ("curl -sSL --proto =http,https --proto-redir =http,https "
|
||||
.. "--connect-timeout 10 --max-time %d ")
|
||||
:format(tonumber(maxTime) or 40)
|
||||
.. "-X POST "
|
||||
.. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " "
|
||||
if contentType then
|
||||
cmd = cmd .. "-H " .. HostShell.quote("Content-Type: " .. contentType) .. " "
|
||||
end
|
||||
cmd = cmd .. "-H " .. HostShell.quote("Content-Length: " .. tostring(#body)) .. " "
|
||||
.. "--data-binary @- "
|
||||
.. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " "
|
||||
.. HostShell.quote(url) .. " 2>&1"
|
||||
local pipe = HostShell.popen(cmd, "rw")
|
||||
if not pipe then return nil, "could not run curl" end
|
||||
local writeOk, werr = pcall(pipe.write, pipe, body)
|
||||
if not writeOk then
|
||||
HostShell.pclose(pipe)
|
||||
return nil, "could not write body: " .. tostring(werr)
|
||||
end
|
||||
local readOk, out = pcall(function() return pipe:read("*a") end)
|
||||
HostShell.pclose(pipe)
|
||||
if not readOk then
|
||||
return nil, fetchError(url, nil, tostring(out))
|
||||
end
|
||||
local _, status, noise = splitCurlOutput(out)
|
||||
if not status then return nil, fetchError(url, nil, noise) end
|
||||
if status < 200 or status >= 300 then
|
||||
return nil, fetchError(url, status, "log post rejected")
|
||||
end
|
||||
return true
|
||||
end
|
||||
if not haveBridge() then
|
||||
return nil, "no network transport on this platform"
|
||||
end
|
||||
return nil, "no POST transport on this platform"
|
||||
end
|
||||
|
||||
return HostShell
|
||||
|
||||
+49
-2
@@ -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
|
||||
|
||||
@@ -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) })
|
||||
+200
-83
@@ -299,11 +299,6 @@ end
|
||||
|
||||
local CART_DRAG_SLOP = 8
|
||||
local TAU = math.pi * 2
|
||||
-- The 3D mesh is inset inside the hit box so yaw/pitch and the 1.05 hover
|
||||
-- scale cannot climb into the title row (or the gear) on desktop, high-DPI,
|
||||
-- or a portrait phone. Fraction of the shorter side, with a pixel floor.
|
||||
local CART_MESH_PAD = 0.07
|
||||
local CART_MESH_PAD_MIN = 8
|
||||
|
||||
local function cartridgeState(imp, version)
|
||||
imp._cartridge = imp._cartridge or {}
|
||||
@@ -548,11 +543,8 @@ local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action)
|
||||
Theme.A.focus, 2, Theme.cardRadius() + 2)
|
||||
end
|
||||
|
||||
local meshPad = math.max(CART_MESH_PAD_MIN,
|
||||
math.floor(math.min(w, h) * CART_MESH_PAD))
|
||||
local halfW = math.max(1, w / 2 - meshPad)
|
||||
local halfH = math.max(1, h / 2 - meshPad)
|
||||
local depth = math.max(8, (halfW * 2) * 0.14)
|
||||
local halfW, halfH = w / 2, h / 2
|
||||
local depth = math.max(8, w * 0.14)
|
||||
local project = function(px, py, pz)
|
||||
return cartProject(cx + pressX, cy + pressY, yaw, pitch,
|
||||
px * pressedScale, py * pressedScale, pz * pressedScale)
|
||||
@@ -811,6 +803,49 @@ end
|
||||
-- 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)
|
||||
@@ -869,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
|
||||
@@ -900,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)
|
||||
@@ -916,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
|
||||
|
||||
@@ -1477,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
|
||||
@@ -1502,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
|
||||
@@ -1643,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
|
||||
@@ -1660,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
|
||||
|
||||
@@ -1692,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)
|
||||
@@ -1708,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
|
||||
@@ -1900,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
|
||||
|
||||
@@ -1952,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.
|
||||
@@ -1985,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)
|
||||
@@ -2020,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
|
||||
@@ -3770,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)
|
||||
|
||||
+200
-73
@@ -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
|
||||
@@ -1552,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
|
||||
@@ -1618,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")
|
||||
@@ -1647,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)
|
||||
@@ -2316,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
|
||||
@@ -4090,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)
|
||||
@@ -4103,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
|
||||
@@ -4119,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,
|
||||
@@ -4139,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
|
||||
@@ -215,12 +215,19 @@ function LauncherMods.checkDependencies(manifest, options, version, installedMan
|
||||
return m and not m.experimental
|
||||
end
|
||||
|
||||
local function conflictApplies(spec, other)
|
||||
return not spec.range or (other and other.version
|
||||
and Semver.satisfies(other.version, spec.range))
|
||||
end
|
||||
|
||||
-- (a) Conflicts declared by target manifest
|
||||
if type(manifest.conflictSpecs) == "table" then
|
||||
for _, spec in ipairs(manifest.conflictSpecs) do
|
||||
local conflictId = spec.id
|
||||
local installedOther = installedMap[conflictId]
|
||||
if installedOther and isEnabled(conflictId) and not conflictIdsSeen[conflictId] then
|
||||
if installedOther and isEnabled(conflictId)
|
||||
and conflictApplies(spec, installedOther)
|
||||
and not conflictIdsSeen[conflictId] then
|
||||
conflictIdsSeen[conflictId] = true
|
||||
hasIssues = true
|
||||
depsResult[#depsResult + 1] = {
|
||||
@@ -238,11 +245,12 @@ function LauncherMods.checkDependencies(manifest, options, version, installedMan
|
||||
|
||||
-- (b) Reverse conflicts declared by installed mods against target manifest
|
||||
if manifest.id then
|
||||
local installedTarget = installedMap[manifest.id] or manifest
|
||||
for _, other in ipairs(manifests) do
|
||||
if other.id ~= manifest.id and isEnabled(other.id) and not conflictIdsSeen[other.id] then
|
||||
local conflicts = other.conflictSpecs or {}
|
||||
for _, spec in ipairs(conflicts) do
|
||||
if spec.id == manifest.id then
|
||||
if spec.id == manifest.id and conflictApplies(spec, installedTarget) then
|
||||
conflictIdsSeen[other.id] = true
|
||||
hasIssues = true
|
||||
depsResult[#depsResult + 1] = {
|
||||
|
||||
@@ -0,0 +1,956 @@
|
||||
local Logger = require("src.core.Logger")
|
||||
local SafePath = require("src.mods.SafePath")
|
||||
|
||||
local okSave, SaveData = pcall(require, "src.core.SaveData")
|
||||
if not okSave then SaveData = nil end
|
||||
local okStorage, Storage = pcall(require, "src.mods.Storage")
|
||||
if not okStorage then Storage = nil end
|
||||
|
||||
local LegacyCompat = {}
|
||||
|
||||
local ROOT = "mod_compat"
|
||||
local OWN = "_own"
|
||||
local MAX_SEGMENTS = 24
|
||||
|
||||
LegacyCompat.reports = {}
|
||||
|
||||
function LegacyCompat.reset()
|
||||
LegacyCompat.reports = {}
|
||||
end
|
||||
|
||||
function LegacyCompat.report(modId)
|
||||
if modId then
|
||||
local entry = LegacyCompat.reports[modId]
|
||||
return entry and entry.order or {}
|
||||
end
|
||||
local out = {}
|
||||
for id, entry in pairs(LegacyCompat.reports) do
|
||||
out[id] = entry.order
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
local function note(ctx, call, advice)
|
||||
local entry = LegacyCompat.reports[ctx.modId]
|
||||
if not entry then
|
||||
entry = { calls = {}, order = {} }
|
||||
LegacyCompat.reports[ctx.modId] = entry
|
||||
end
|
||||
local row = entry.calls[call]
|
||||
if row then
|
||||
row.count = row.count + 1
|
||||
return
|
||||
end
|
||||
row = { call = call, advice = advice, count = 1 }
|
||||
entry.calls[call] = row
|
||||
entry.order[#entry.order + 1] = row
|
||||
Logger.warn("[%s] %s was removed from the mod sandbox; %s", ctx.modId, call,
|
||||
advice)
|
||||
end
|
||||
|
||||
local function refuse(ctx, call, advice)
|
||||
note(ctx, call, advice)
|
||||
return nil, ("%s is not available to mods; %s"):format(call, advice)
|
||||
end
|
||||
|
||||
-- ------- path routing
|
||||
|
||||
local function normalize(path)
|
||||
if type(path) ~= "string" or path == "" then return nil end
|
||||
path = path:gsub("\\", "/")
|
||||
while path:find("//", 1, true) do path = (path:gsub("//", "/")) end
|
||||
if path ~= "/" then path = (path:gsub("/$", "")) end
|
||||
return path
|
||||
end
|
||||
|
||||
local function flatten(path)
|
||||
local parts = {}
|
||||
for segment in path:gmatch("[^/]+") do
|
||||
if segment ~= "." and segment ~= ".." then
|
||||
parts[#parts + 1] = (segment:gsub("[^%w_%.%-]", "_"))
|
||||
end
|
||||
end
|
||||
if #parts == 0 then return nil end
|
||||
while #parts > MAX_SEGMENTS do table.remove(parts, 1) end
|
||||
return table.concat(parts, "/")
|
||||
end
|
||||
|
||||
local function storageKey(key)
|
||||
local parts = {}
|
||||
for segment in key:gmatch("[^/]+") do parts[#parts + 1] = segment end
|
||||
if #parts == 0 then return nil end
|
||||
parts[#parts] = (parts[#parts]:gsub("%.[^%.]*$", ""))
|
||||
for i = 1, #parts do
|
||||
local cleaned = (parts[i]:gsub("[^%w_%-]", "_"))
|
||||
if cleaned == "" then return nil end
|
||||
parts[i] = cleaned
|
||||
end
|
||||
return table.concat(parts, "/")
|
||||
end
|
||||
|
||||
local function ownRelative(ctx, path)
|
||||
if not ctx.modPath then return nil end
|
||||
if path == ctx.modPath then return "" end
|
||||
if path:sub(1, #ctx.modPath + 1) == ctx.modPath .. "/" then
|
||||
return path:sub(#ctx.modPath + 2)
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function ownFull(ctx, rel)
|
||||
if rel == "" then return ctx.modPath end
|
||||
local safe = SafePath.safe(rel)
|
||||
if not safe then return nil end
|
||||
return ctx.modPath .. "/" .. safe
|
||||
end
|
||||
|
||||
local function ownExists(ctx, rel)
|
||||
local full = ownFull(ctx, rel)
|
||||
local fs = ctx.fs
|
||||
if not (full and fs and fs.getInfo) then return nil end
|
||||
return fs.getInfo(full)
|
||||
end
|
||||
|
||||
local function classify(ctx, path)
|
||||
path = normalize(path)
|
||||
if not path then return nil end
|
||||
|
||||
local root = ctx.virtualRoot
|
||||
if path == root then return { key = "", dir = true } end
|
||||
if path:sub(1, #root + 1) == root .. "/" then
|
||||
return { key = flatten(path:sub(#root + 2)) }
|
||||
end
|
||||
|
||||
local rel = ownRelative(ctx, path)
|
||||
if not rel and path:sub(1, 1) ~= "/" and not path:match("^%a:")
|
||||
and ownExists(ctx, path) then
|
||||
rel = path
|
||||
end
|
||||
if rel then
|
||||
if rel == "" then return { key = OWN, rel = "", dir = true } end
|
||||
local flat = flatten(rel)
|
||||
return { key = flat and (OWN .. "/" .. flat) or nil, rel = rel }
|
||||
end
|
||||
|
||||
return { key = flatten(path) }
|
||||
end
|
||||
|
||||
-- ------- the overlay
|
||||
|
||||
local function persistFs(ctx)
|
||||
if SaveData and SaveData.persistenceFs then
|
||||
return SaveData.persistenceFs(ctx.fs)
|
||||
end
|
||||
return ctx.fs
|
||||
end
|
||||
|
||||
local function overlayPath(ctx, key)
|
||||
if key == nil or key == "" then return ROOT .. "/" .. ctx.modId end
|
||||
return ROOT .. "/" .. ctx.modId .. "/" .. key
|
||||
end
|
||||
|
||||
local function ensureParent(fs, path)
|
||||
if not fs.createDirectory then return end
|
||||
local dir = path:match("^(.*)/[^/]+$")
|
||||
if not dir then return end
|
||||
local built = nil
|
||||
for segment in dir:gmatch("[^/]+") do
|
||||
built = built and (built .. "/" .. segment) or segment
|
||||
fs.createDirectory(built)
|
||||
end
|
||||
end
|
||||
|
||||
local function overlayRead(ctx, key)
|
||||
local fs = persistFs(ctx)
|
||||
if not (fs and fs.read and fs.getInfo) then return nil end
|
||||
local path = overlayPath(ctx, key)
|
||||
local info = fs.getInfo(path)
|
||||
if not info or info.type == "directory" then return nil end
|
||||
local body = fs.read(path)
|
||||
if type(body) ~= "string" then return nil end
|
||||
return body
|
||||
end
|
||||
|
||||
local function overlayWrite(ctx, key, data)
|
||||
local fs = persistFs(ctx)
|
||||
if not (fs and fs.write) then
|
||||
return false, "no writable filesystem is available"
|
||||
end
|
||||
local path = overlayPath(ctx, key)
|
||||
ensureParent(fs, path)
|
||||
local ok, err = fs.write(path, data)
|
||||
if ok == false then return false, err or "write failed" end
|
||||
return true
|
||||
end
|
||||
|
||||
local function overlayRemove(ctx, key)
|
||||
local fs = persistFs(ctx)
|
||||
if not (fs and fs.remove and fs.getInfo) then return false end
|
||||
local path = overlayPath(ctx, key)
|
||||
if not fs.getInfo(path) then return false end
|
||||
fs.remove(path)
|
||||
return true
|
||||
end
|
||||
|
||||
local function overlayInfo(ctx, key)
|
||||
local fs = persistFs(ctx)
|
||||
if not (fs and fs.getInfo) then return nil end
|
||||
return fs.getInfo(overlayPath(ctx, key))
|
||||
end
|
||||
|
||||
local function storageRead(ctx, key)
|
||||
if not (Storage and key and key ~= "") then return nil end
|
||||
if key:sub(1, #OWN + 1) == OWN .. "/" then return nil end
|
||||
local game = ctx.game and ctx.game()
|
||||
if not game then return nil end
|
||||
local sk = storageKey(key)
|
||||
if not sk then return nil end
|
||||
ctx.storage = ctx.storage or Storage.new(ctx.modId, ctx.fs)
|
||||
local ok, bytes = pcall(ctx.storage.readBytes, ctx.storage, game, sk)
|
||||
if ok and type(bytes) == "string" then return bytes end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function readPath(ctx, path)
|
||||
local at = classify(ctx, path)
|
||||
if not at then return nil, "invalid path" end
|
||||
local body = at.key and overlayRead(ctx, at.key)
|
||||
if body then return body end
|
||||
if at.rel then
|
||||
local full = ownFull(ctx, at.rel)
|
||||
local fs = ctx.fs
|
||||
if full and fs and fs.read then
|
||||
local packaged = fs.read(full)
|
||||
if type(packaged) == "string" then return packaged end
|
||||
end
|
||||
end
|
||||
body = at.key and storageRead(ctx, at.key)
|
||||
if body then return body end
|
||||
return nil, "could not open " .. tostring(path)
|
||||
end
|
||||
|
||||
local function writePath(ctx, path, data)
|
||||
local at = classify(ctx, path)
|
||||
if not (at and at.key) then return false, "invalid path" end
|
||||
return overlayWrite(ctx, at.key, data)
|
||||
end
|
||||
|
||||
local function infoPath(ctx, path)
|
||||
local at = classify(ctx, path)
|
||||
if not at then return nil end
|
||||
if at.key then
|
||||
local info = overlayInfo(ctx, at.key)
|
||||
if info then
|
||||
return { type = info.type, size = info.size, modtime = info.modtime }
|
||||
end
|
||||
end
|
||||
if at.rel then
|
||||
local info = ownExists(ctx, at.rel)
|
||||
if info then
|
||||
return { type = info.type, size = info.size, modtime = info.modtime }
|
||||
end
|
||||
end
|
||||
local stored = at.key and storageRead(ctx, at.key)
|
||||
if stored then return { type = "file", size = #stored } end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function listPath(ctx, path)
|
||||
local at = classify(ctx, path)
|
||||
local seen, out = {}, {}
|
||||
local function add(name)
|
||||
if name and name ~= "" and not seen[name] then
|
||||
seen[name] = true
|
||||
out[#out + 1] = name
|
||||
end
|
||||
end
|
||||
if at and at.rel then
|
||||
local full = ownFull(ctx, at.rel)
|
||||
local fs = ctx.fs
|
||||
if full and fs and fs.getDirectoryItems then
|
||||
for _, name in ipairs(fs.getDirectoryItems(full) or {}) do add(name) end
|
||||
end
|
||||
end
|
||||
if at and at.key then
|
||||
local fs = persistFs(ctx)
|
||||
if fs and fs.getDirectoryItems then
|
||||
for _, name in ipairs(fs.getDirectoryItems(overlayPath(ctx, at.key)) or {}) do
|
||||
add(name)
|
||||
end
|
||||
end
|
||||
end
|
||||
table.sort(out)
|
||||
return out
|
||||
end
|
||||
|
||||
-- ------- buffered file handles
|
||||
|
||||
local function openBuffer(ctx, path, mode)
|
||||
mode = tostring(mode or "r"):gsub("b", "")
|
||||
local writable = mode:find("[wa+]") ~= nil
|
||||
local body
|
||||
if mode:sub(1, 1) == "w" then
|
||||
body = ""
|
||||
else
|
||||
body = readPath(ctx, path)
|
||||
if not body then
|
||||
if not writable then return nil, "could not open " .. tostring(path) end
|
||||
body = ""
|
||||
end
|
||||
end
|
||||
local state = {
|
||||
ctx = ctx, path = path, buf = body, pos = 1,
|
||||
writable = writable, closed = false, dirty = false,
|
||||
}
|
||||
if mode:sub(1, 1) == "a" then state.pos = #body + 1 end
|
||||
return state
|
||||
end
|
||||
|
||||
local function bufferFlush(state)
|
||||
if not (state.writable and state.dirty) then return true end
|
||||
local ok, err = writePath(state.ctx, state.path, state.buf)
|
||||
if ok then state.dirty = false end
|
||||
return ok, err
|
||||
end
|
||||
|
||||
local function bufferWrite(state, text)
|
||||
if not state.writable then return false, "file is not open for writing" end
|
||||
local head = state.buf:sub(1, state.pos - 1)
|
||||
if #head < state.pos - 1 then head = head .. string.rep("\0", state.pos - 1 - #head) end
|
||||
local tail = state.buf:sub(state.pos + #text)
|
||||
state.buf = head .. text .. tail
|
||||
state.pos = state.pos + #text
|
||||
state.dirty = true
|
||||
return true
|
||||
end
|
||||
|
||||
local function bufferRead(state, fmt)
|
||||
if fmt == nil then fmt = "*l" end
|
||||
if type(fmt) == "number" then
|
||||
if fmt == 0 then return state.pos <= #state.buf and "" or nil end
|
||||
if state.pos > #state.buf then return nil end
|
||||
local chunk = state.buf:sub(state.pos, state.pos + fmt - 1)
|
||||
state.pos = state.pos + #chunk
|
||||
return chunk
|
||||
end
|
||||
fmt = tostring(fmt):gsub("^%*", "")
|
||||
if fmt == "a" then
|
||||
local rest = state.buf:sub(state.pos)
|
||||
state.pos = #state.buf + 1
|
||||
return rest
|
||||
end
|
||||
if fmt == "l" or fmt == "L" then
|
||||
if state.pos > #state.buf then return nil end
|
||||
local nl = state.buf:find("\n", state.pos, true)
|
||||
local line
|
||||
if nl then
|
||||
line = state.buf:sub(state.pos, fmt == "L" and nl or nl - 1)
|
||||
state.pos = nl + 1
|
||||
else
|
||||
line = state.buf:sub(state.pos)
|
||||
state.pos = #state.buf + 1
|
||||
end
|
||||
return line
|
||||
end
|
||||
if fmt == "n" then
|
||||
local rest = state.buf:sub(state.pos)
|
||||
local text, after = rest:match("^%s*(%-?%d+%.?%d*[eE]?[-+]?%d*)()")
|
||||
if not text then return nil end
|
||||
state.pos = state.pos + after - 1
|
||||
return tonumber(text)
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function bufferSeek(state, whence, offset)
|
||||
whence = whence or "cur"
|
||||
offset = offset or 0
|
||||
if whence == "set" then state.pos = offset + 1
|
||||
elseif whence == "cur" then state.pos = state.pos + offset
|
||||
elseif whence == "end" then state.pos = #state.buf + offset + 1
|
||||
else return nil, "bad seek base" end
|
||||
if state.pos < 1 then state.pos = 1 end
|
||||
return state.pos - 1
|
||||
end
|
||||
|
||||
local function bufferClose(state)
|
||||
if state.closed then return true end
|
||||
local ok, err = bufferFlush(state)
|
||||
state.closed = true
|
||||
return ok, err
|
||||
end
|
||||
|
||||
local function ioFile(ctx, path, mode)
|
||||
local state, err = openBuffer(ctx, path, mode)
|
||||
if not state then return nil, err end
|
||||
local file = {}
|
||||
function file:read(...)
|
||||
local count = select("#", ...)
|
||||
if count == 0 then return bufferRead(state, "*l") end
|
||||
local out = {}
|
||||
for i = 1, count do out[i] = bufferRead(state, (select(i, ...))) end
|
||||
return unpack(out, 1, count)
|
||||
end
|
||||
function file:write(...)
|
||||
for i = 1, select("#", ...) do
|
||||
local value = select(i, ...)
|
||||
local ok, writeErr = bufferWrite(state, tostring(value))
|
||||
if not ok then return nil, writeErr end
|
||||
end
|
||||
return file
|
||||
end
|
||||
function file:lines(fmt)
|
||||
return function() return bufferRead(state, fmt or "*l") end
|
||||
end
|
||||
function file:seek(whence, offset) return bufferSeek(state, whence, offset) end
|
||||
function file:flush() bufferFlush(state) return file end
|
||||
function file:close() return bufferClose(state) end
|
||||
function file:setvbuf() return true end
|
||||
return file
|
||||
end
|
||||
|
||||
local function loveFile(ctx, path, mode)
|
||||
local state = nil
|
||||
local file = {}
|
||||
function file:open(openMode)
|
||||
local opened, err = openBuffer(ctx, path, openMode or mode or "r")
|
||||
if not opened then return false, err end
|
||||
state = opened
|
||||
return true
|
||||
end
|
||||
function file:isOpen() return state ~= nil and not state.closed end
|
||||
function file:read(bytes)
|
||||
if not state and not file:open("r") then return nil, 0 end
|
||||
local body = bytes and bufferRead(state, bytes) or bufferRead(state, "*a")
|
||||
if not body then return nil, 0 end
|
||||
return body, #body
|
||||
end
|
||||
function file:write(data, size)
|
||||
if not state and not file:open(mode or "w") then return false end
|
||||
data = tostring(data)
|
||||
if size then data = data:sub(1, size) end
|
||||
local ok, err = bufferWrite(state, data)
|
||||
if not ok then return false, err end
|
||||
bufferFlush(state)
|
||||
return true
|
||||
end
|
||||
function file:lines()
|
||||
if not state then file:open("r") end
|
||||
return function() return state and bufferRead(state, "*l") or nil end
|
||||
end
|
||||
function file:seek(offset)
|
||||
if not state then return false end
|
||||
bufferSeek(state, "set", offset or 0)
|
||||
return true
|
||||
end
|
||||
function file:tell() return state and (state.pos - 1) or 0 end
|
||||
function file:getSize()
|
||||
if state then return #state.buf end
|
||||
local body = readPath(ctx, path)
|
||||
return body and #body or 0
|
||||
end
|
||||
function file:flush() if state then bufferFlush(state) end return true end
|
||||
function file:close()
|
||||
if state then bufferClose(state) end
|
||||
state = nil
|
||||
return true
|
||||
end
|
||||
function file:getFilename() return path end
|
||||
function file:getMode() return mode or "c" end
|
||||
function file:setBuffer() return true end
|
||||
if mode and mode ~= "c" then file:open(mode) end
|
||||
return file
|
||||
end
|
||||
|
||||
-- ------- the love.filesystem stand-in
|
||||
|
||||
local function realFilesystem()
|
||||
return _G.love and _G.love.filesystem or nil
|
||||
end
|
||||
|
||||
local function filesystemShim(ctx)
|
||||
local fsShim = {}
|
||||
|
||||
local function readAdvice() return "reads now come from mod:read and mod.storage" end
|
||||
|
||||
function fsShim.read(a, b, c)
|
||||
local path, size = a, b
|
||||
if (a == "string" or a == "data") and type(b) == "string" then
|
||||
path, size = b, c
|
||||
end
|
||||
note(ctx, "love.filesystem.read", readAdvice())
|
||||
local body, err = readPath(ctx, path)
|
||||
if not body then return nil, err end
|
||||
if size and size >= 0 then body = body:sub(1, size) end
|
||||
return body, #body
|
||||
end
|
||||
|
||||
function fsShim.write(path, data, size)
|
||||
note(ctx, "love.filesystem.write",
|
||||
"writes are rerouted to this mod's private compat storage; migrate to mod.storage")
|
||||
data = tostring(data)
|
||||
if size then data = data:sub(1, size) end
|
||||
local ok, err = writePath(ctx, path, data)
|
||||
if not ok then return false, err end
|
||||
return true
|
||||
end
|
||||
|
||||
function fsShim.append(path, data, size)
|
||||
note(ctx, "love.filesystem.append",
|
||||
"writes are rerouted to this mod's private compat storage; migrate to mod.storage")
|
||||
data = tostring(data)
|
||||
if size then data = data:sub(1, size) end
|
||||
local existing = readPath(ctx, path) or ""
|
||||
local ok, err = writePath(ctx, path, existing .. data)
|
||||
if not ok then return false, err end
|
||||
return true
|
||||
end
|
||||
|
||||
function fsShim.lines(path)
|
||||
note(ctx, "love.filesystem.lines", readAdvice())
|
||||
local body = readPath(ctx, path)
|
||||
if not body then error("could not open " .. tostring(path), 2) end
|
||||
local pos = 1
|
||||
return function()
|
||||
if pos > #body then return nil end
|
||||
local nl = body:find("\n", pos, true)
|
||||
local line
|
||||
if nl then
|
||||
line = body:sub(pos, nl - 1)
|
||||
pos = nl + 1
|
||||
else
|
||||
line = body:sub(pos)
|
||||
pos = #body + 1
|
||||
end
|
||||
return line
|
||||
end
|
||||
end
|
||||
|
||||
function fsShim.load(path)
|
||||
note(ctx, "love.filesystem.load",
|
||||
"the chunk is compiled into this mod's sandbox; prefer require or mod:read plus load")
|
||||
local body = readPath(ctx, path)
|
||||
if not body then return nil, "could not open " .. tostring(path) end
|
||||
if not ctx.compile then return nil, "no sandbox is bound yet" end
|
||||
return ctx.compile(body, "@" .. tostring(path))
|
||||
end
|
||||
|
||||
function fsShim.getInfo(path, a, b)
|
||||
local info = infoPath(ctx, path)
|
||||
local filter = type(a) == "string" and a or nil
|
||||
local into = type(a) == "table" and a or (type(b) == "table" and b or nil)
|
||||
if not info then return nil end
|
||||
if filter and info.type ~= filter then return nil end
|
||||
if into then
|
||||
into.type, into.size, into.modtime = info.type, info.size, info.modtime
|
||||
return into
|
||||
end
|
||||
return info
|
||||
end
|
||||
|
||||
function fsShim.getDirectoryItems(path)
|
||||
note(ctx, "love.filesystem.getDirectoryItems", "use mod.assets:list")
|
||||
return listPath(ctx, path)
|
||||
end
|
||||
|
||||
function fsShim.createDirectory(path)
|
||||
local at = classify(ctx, path)
|
||||
if not (at and at.key) then return false end
|
||||
local fs = persistFs(ctx)
|
||||
if not (fs and fs.createDirectory) then return false end
|
||||
ensureParent(fs, overlayPath(ctx, at.key) .. "/.")
|
||||
fs.createDirectory(overlayPath(ctx, at.key))
|
||||
return true
|
||||
end
|
||||
|
||||
function fsShim.remove(path)
|
||||
local at = classify(ctx, path)
|
||||
if not (at and at.key) then return false end
|
||||
return overlayRemove(ctx, at.key)
|
||||
end
|
||||
|
||||
function fsShim.exists(path) return infoPath(ctx, path) ~= nil end
|
||||
|
||||
function fsShim.isFile(path)
|
||||
local info = infoPath(ctx, path)
|
||||
return info ~= nil and info.type == "file"
|
||||
end
|
||||
|
||||
function fsShim.isDirectory(path)
|
||||
local info = infoPath(ctx, path)
|
||||
return info ~= nil and info.type == "directory"
|
||||
end
|
||||
|
||||
function fsShim.getSize(path)
|
||||
local info = infoPath(ctx, path)
|
||||
if not info then return nil, "could not open " .. tostring(path) end
|
||||
return info.size or 0
|
||||
end
|
||||
|
||||
function fsShim.getLastModified(path)
|
||||
local info = infoPath(ctx, path)
|
||||
if not info then return nil, "could not open " .. tostring(path) end
|
||||
return info.modtime or 0
|
||||
end
|
||||
|
||||
local function virtual(call)
|
||||
note(ctx, call, "paths are virtual now; everything under the returned root "
|
||||
.. "lands in this mod's private compat storage")
|
||||
return ctx.virtualRoot
|
||||
end
|
||||
|
||||
function fsShim.getSaveDirectory() return virtual("love.filesystem.getSaveDirectory") end
|
||||
function fsShim.getWorkingDirectory() return virtual("love.filesystem.getWorkingDirectory") end
|
||||
function fsShim.getUserDirectory() return virtual("love.filesystem.getUserDirectory") end
|
||||
function fsShim.getAppdataDirectory() return virtual("love.filesystem.getAppdataDirectory") end
|
||||
function fsShim.getSourceBaseDirectory() return virtual("love.filesystem.getSourceBaseDirectory") end
|
||||
function fsShim.getRealDirectory() return ctx.virtualRoot end
|
||||
|
||||
function fsShim.getIdentity() return ctx.modId end
|
||||
|
||||
function fsShim.setIdentity()
|
||||
note(ctx, "love.filesystem.setIdentity",
|
||||
"a mod cannot repoint the save directory; the call does nothing")
|
||||
return false
|
||||
end
|
||||
|
||||
function fsShim.getRequirePath() return "" end
|
||||
function fsShim.setRequirePath() return false end
|
||||
function fsShim.getCRequirePath() return "" end
|
||||
function fsShim.setCRequirePath() return false end
|
||||
|
||||
function fsShim.mount()
|
||||
note(ctx, "love.filesystem.mount",
|
||||
"mounting is refused; ship the files inside your mod and use mod:read")
|
||||
return false
|
||||
end
|
||||
fsShim.unmount = fsShim.mount
|
||||
|
||||
function fsShim.newFile(path, mode) return loveFile(ctx, path, mode) end
|
||||
|
||||
function fsShim.newFileData(a, b)
|
||||
local real = realFilesystem()
|
||||
if type(a) == "string" and b == nil then
|
||||
note(ctx, "love.filesystem.newFileData", readAdvice())
|
||||
local body = readPath(ctx, a)
|
||||
if not body then return nil, "could not open " .. tostring(a) end
|
||||
if real and real.newFileData then return real.newFileData(body, a) end
|
||||
return nil, "no filesystem"
|
||||
end
|
||||
if real and real.newFileData then return real.newFileData(a, b) end
|
||||
return nil, "no filesystem"
|
||||
end
|
||||
|
||||
function fsShim.isFused()
|
||||
local real = realFilesystem()
|
||||
return real and real.isFused and real.isFused() or false
|
||||
end
|
||||
|
||||
function fsShim.areSymlinksEnabled() return false end
|
||||
function fsShim.setSymlinksEnabled() return false end
|
||||
function fsShim.init() return false end
|
||||
function fsShim.setSource() return false end
|
||||
|
||||
return setmetatable(fsShim, { __index = function(_, key)
|
||||
note(ctx, "love.filesystem." .. tostring(key),
|
||||
"there is no compat stand-in for it; use mod.storage or mod:read")
|
||||
return nil
|
||||
end })
|
||||
end
|
||||
|
||||
-- ------- the rest of the removed surface
|
||||
|
||||
local function systemShim(ctx)
|
||||
local function real() return _G.love and _G.love.system or nil end
|
||||
-- tls* comes from the engine (Android JNI, or desktop gen1tls hung on
|
||||
-- love.system at boot). Forward those; keep clipboard / openURL stubbed.
|
||||
local TLS = {
|
||||
tlsOpen = true, tlsStatus = true, tlsSend = true,
|
||||
tlsReceive = true, tlsError = true, tlsClose = true,
|
||||
}
|
||||
local shim = {
|
||||
getOS = function()
|
||||
local sys = real()
|
||||
return sys and sys.getOS and sys.getOS() or "Unknown"
|
||||
end,
|
||||
getPowerInfo = function()
|
||||
local sys = real()
|
||||
if not (sys and sys.getPowerInfo) then return "unknown", nil, nil end
|
||||
return sys.getPowerInfo()
|
||||
end,
|
||||
getProcessorCount = function()
|
||||
local sys = real()
|
||||
return sys and sys.getProcessorCount and sys.getProcessorCount() or 1
|
||||
end,
|
||||
getClipboardText = function()
|
||||
note(ctx, "love.system.getClipboardText",
|
||||
"clipboard access stays sandboxed; the call returns an empty string")
|
||||
return ""
|
||||
end,
|
||||
setClipboardText = function()
|
||||
note(ctx, "love.system.setClipboardText",
|
||||
"clipboard access stays sandboxed; the call does nothing")
|
||||
return false
|
||||
end,
|
||||
openURL = function()
|
||||
note(ctx, "love.system.openURL",
|
||||
"launching a URL stays sandboxed; the call does nothing")
|
||||
return false
|
||||
end,
|
||||
vibrate = function(...)
|
||||
local sys = real()
|
||||
if sys and sys.vibrate then return sys.vibrate(...) end
|
||||
return false
|
||||
end,
|
||||
}
|
||||
return setmetatable(shim, {
|
||||
__index = function(_, key)
|
||||
if not TLS[key] then return nil end
|
||||
local sys = real()
|
||||
return sys and sys[key]
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
local function eventShim(ctx)
|
||||
local function real() return _G.love and _G.love.event or nil end
|
||||
local shim = {
|
||||
quit = function()
|
||||
note(ctx, "love.event.quit",
|
||||
"a mod cannot close the game out from under the player; the call does nothing")
|
||||
return false
|
||||
end,
|
||||
}
|
||||
shim.push = function(name, ...)
|
||||
if name == "quit" then return shim.quit() end
|
||||
local ev = real()
|
||||
if ev and ev.push then return ev.push(name, ...) end
|
||||
return false
|
||||
end
|
||||
return setmetatable(shim, { __index = function(_, key)
|
||||
local ev = real()
|
||||
return ev and ev[key] or nil
|
||||
end })
|
||||
end
|
||||
|
||||
local function ioShim(ctx)
|
||||
local stream = {
|
||||
write = function(self, ...)
|
||||
for i = 1, select("#", ...) do io.write(tostring((select(i, ...)))) end
|
||||
return self
|
||||
end,
|
||||
flush = function(self) return self end,
|
||||
close = function() return true end,
|
||||
read = function() return nil end,
|
||||
lines = function() return function() return nil end end,
|
||||
seek = function() return 0 end,
|
||||
setvbuf = function() return true end,
|
||||
}
|
||||
local out = setmetatable({}, { __index = stream })
|
||||
local shim = {
|
||||
open = function(path, mode)
|
||||
note(ctx, "io.open",
|
||||
"the handle is backed by mod:read and this mod's private compat storage")
|
||||
local file, err = ioFile(ctx, path, mode)
|
||||
if not file then return nil, err, 2 end
|
||||
return file
|
||||
end,
|
||||
lines = function(path, fmt)
|
||||
if path == nil then return function() return nil end end
|
||||
note(ctx, "io.lines",
|
||||
"the handle is backed by mod:read and this mod's private compat storage")
|
||||
local file, err = ioFile(ctx, path, "r")
|
||||
if not file then error(err, 2) end
|
||||
return file:lines(fmt)
|
||||
end,
|
||||
close = function(file) if file and file.close then return file:close() end return true end,
|
||||
type = function(file)
|
||||
if type(file) == "table" and file.read and file.close then return "file" end
|
||||
return nil
|
||||
end,
|
||||
read = function() return nil end,
|
||||
write = function(...) return out:write(...) end,
|
||||
input = function() return out end,
|
||||
output = function() return out end,
|
||||
stdout = out,
|
||||
stderr = out,
|
||||
stdin = setmetatable({}, { __index = stream }),
|
||||
popen = function()
|
||||
return refuse(ctx, "io.popen", "spawning a process is refused")
|
||||
end,
|
||||
tmpfile = function()
|
||||
return ioFile(ctx, ctx.virtualRoot .. "/io_tmpfile", "w+")
|
||||
end,
|
||||
}
|
||||
return shim
|
||||
end
|
||||
|
||||
local function osShim(ctx)
|
||||
local HOME = { HOME = true, APPDATA = true, LOCALAPPDATA = true,
|
||||
USERPROFILE = true, XDG_DATA_HOME = true,
|
||||
XDG_CONFIG_HOME = true, TMPDIR = true, TEMP = true, TMP = true }
|
||||
return {
|
||||
getenv = function(name)
|
||||
note(ctx, "os.getenv",
|
||||
"the environment is hidden; home-like names answer with this mod's virtual root")
|
||||
if type(name) == "string" and HOME[name:upper()] then
|
||||
return ctx.virtualRoot
|
||||
end
|
||||
return nil
|
||||
end,
|
||||
remove = function(path)
|
||||
note(ctx, "os.remove", "the delete lands in this mod's private compat storage")
|
||||
local at = classify(ctx, path)
|
||||
if not (at and at.key and overlayRemove(ctx, at.key)) then
|
||||
return nil, tostring(path) .. ": no such file"
|
||||
end
|
||||
return true
|
||||
end,
|
||||
rename = function(from, to)
|
||||
note(ctx, "os.rename", "the move lands in this mod's private compat storage")
|
||||
local body = readPath(ctx, from)
|
||||
if not body then return nil, tostring(from) .. ": no such file" end
|
||||
local ok, err = writePath(ctx, to, body)
|
||||
if not ok then return nil, err end
|
||||
local at = classify(ctx, from)
|
||||
if at and at.key then overlayRemove(ctx, at.key) end
|
||||
return true
|
||||
end,
|
||||
tmpname = function()
|
||||
return ctx.virtualRoot .. "/tmp/os_tmpname"
|
||||
end,
|
||||
execute = function()
|
||||
note(ctx, "os.execute", "running a command is refused")
|
||||
return false
|
||||
end,
|
||||
exit = function()
|
||||
note(ctx, "os.exit",
|
||||
"a mod cannot end the process; the call does nothing")
|
||||
return false
|
||||
end,
|
||||
setlocale = function() return "C" end,
|
||||
}
|
||||
end
|
||||
|
||||
local function packageShim(ctx)
|
||||
local shim = { path = "", cpath = "", preload = {}, loaded = {}, loaders = {} }
|
||||
return setmetatable(shim, {
|
||||
__index = function(_, key)
|
||||
note(ctx, "package." .. tostring(key),
|
||||
"the module loader is sandboxed; use require for the supported engine modules")
|
||||
return nil
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
-- ------- love callbacks
|
||||
|
||||
local CALLBACKS = {
|
||||
load = true, update = true, draw = true, quit = true, lowmemory = true,
|
||||
threaderror = true, keypressed = true, keyreleased = true,
|
||||
textinput = true, textedited = true, mousemoved = true,
|
||||
mousepressed = true, mousereleased = true, wheelmoved = true,
|
||||
mousefocus = true, touchpressed = true, touchreleased = true,
|
||||
touchmoved = true, joystickadded = true, joystickremoved = true,
|
||||
joystickpressed = true, joystickreleased = true, joystickaxis = true,
|
||||
joystickhat = true, gamepadpressed = true, gamepadreleased = true,
|
||||
gamepadaxis = true, focus = true, visible = true, resize = true,
|
||||
filedropped = true, directorydropped = true, displayrotated = true,
|
||||
audiodevicechanged = true, localechanged = true,
|
||||
}
|
||||
|
||||
local REFUSED = {
|
||||
run = "love.run is the engine's fixed-step loop; use mod.hooks and mod.events",
|
||||
errorhandler = "love.errorhandler is how a crash reaches the player; "
|
||||
.. "use mod.events",
|
||||
}
|
||||
|
||||
-- ------- assembly
|
||||
|
||||
function LegacyCompat.new(opts)
|
||||
opts = opts or {}
|
||||
local ctx = {
|
||||
modId = opts.modId or "mod",
|
||||
modPath = opts.modPath,
|
||||
fs = opts.fs,
|
||||
game = opts.game,
|
||||
compile = nil,
|
||||
}
|
||||
ctx.virtualRoot = "/pokeport/" .. ctx.modId
|
||||
|
||||
local filesystem = filesystemShim(ctx)
|
||||
local io_ = ioShim(ctx)
|
||||
local system = systemShim(ctx)
|
||||
local event = eventShim(ctx)
|
||||
|
||||
local compat = {
|
||||
ctx = ctx,
|
||||
love = { filesystem = filesystem, system = system, event = event },
|
||||
os = osShim(ctx),
|
||||
globals = {
|
||||
io = io_,
|
||||
package = packageShim(ctx),
|
||||
loadfile = function(path)
|
||||
note(ctx, "loadfile",
|
||||
"the chunk is compiled into this mod's sandbox; prefer require or mod:read")
|
||||
local body = readPath(ctx, path)
|
||||
if not body then return nil, "could not open " .. tostring(path) end
|
||||
if not ctx.compile then return nil, "no sandbox is bound yet" end
|
||||
return ctx.compile(body, "@" .. tostring(path))
|
||||
end,
|
||||
},
|
||||
modules = {
|
||||
io = io_,
|
||||
["love.filesystem"] = filesystem,
|
||||
["love.system"] = system,
|
||||
["love.event"] = event,
|
||||
},
|
||||
}
|
||||
|
||||
compat.globals.dofile = function(path)
|
||||
local chunk, err = compat.globals.loadfile(path)
|
||||
if not chunk then error(err or ("could not open " .. tostring(path)), 2) end
|
||||
return chunk()
|
||||
end
|
||||
|
||||
function compat.module(name)
|
||||
if type(name) ~= "string" then return nil end
|
||||
local substitute = compat.modules[name]
|
||||
if substitute then
|
||||
note(ctx, ('require("%s")'):format(name),
|
||||
"it now answers with the compat stand-in, not the real module")
|
||||
return substitute
|
||||
end
|
||||
if name == "os" then
|
||||
note(ctx, 'require("os")',
|
||||
"it now answers with the compat stand-in, not the real module")
|
||||
return compat.osTable
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- true when the assignment is a callback chain, which is what a mod
|
||||
-- wrapping love.mousemoved did before the sandbox; false plus a reason for
|
||||
-- the two names the engine owns, false alone for a module table.
|
||||
function compat.assign(key, value)
|
||||
if REFUSED[key] then
|
||||
note(ctx, ("love.%s assignment"):format(key), REFUSED[key])
|
||||
return false, ("[%s] %s"):format(ctx.modId, REFUSED[key])
|
||||
end
|
||||
if not CALLBACKS[key] then return false end
|
||||
if value ~= nil and type(value) ~= "function" then return false end
|
||||
note(ctx, ("love.%s assignment"):format(key),
|
||||
"the callback lands on the real love table the way it did before the "
|
||||
.. "sandbox; prefer mod.hooks and mod.events")
|
||||
return true
|
||||
end
|
||||
|
||||
function compat.bind(env, compile)
|
||||
ctx.compile = compile
|
||||
compat.osTable = env and env.os or nil
|
||||
end
|
||||
|
||||
return compat
|
||||
end
|
||||
|
||||
return LegacyCompat
|
||||
+80
-1
@@ -20,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
|
||||
@@ -1069,6 +1072,68 @@ function Loader:_api(mod)
|
||||
return { available = function() return false end,
|
||||
sync = refuse, poll = refuse }
|
||||
end)(),
|
||||
-- Background HTTP, behind the "network" permission the player already
|
||||
-- sees. This is what love.thread is NOT: the worker runs engine code in
|
||||
-- an engine-owned pool, so a mod gets asynchrony without getting a Lua
|
||||
-- state the sandbox cannot reach. get() hands back an opaque handle;
|
||||
-- poll() is non-blocking, so nothing here can hang a frame.
|
||||
fetch = (function()
|
||||
if mod.manifest.permissionSet.network then
|
||||
return {
|
||||
available = function() return Net.available() end,
|
||||
get = function(_, url, opts) return Net.get(loader, modId, url, opts) end,
|
||||
poll = function(_, handle) return Net.poll(loader, modId, handle) end,
|
||||
release = function(_, handle) return Net.release(loader, modId, handle) end,
|
||||
cancel = function(_, handle) return Net.cancel(loader, modId, handle) end,
|
||||
}
|
||||
end
|
||||
local function refuse()
|
||||
error(('[%s] mod.fetch needs the "network" permission in '
|
||||
.. "manifest.json"):format(modId), 2)
|
||||
end
|
||||
return { available = function() return false end,
|
||||
get = refuse, poll = refuse, release = refuse, cancel = refuse }
|
||||
end)(),
|
||||
-- One-way crash-log reporting to the https URL the manifest declares in
|
||||
-- log_url. The destination is reviewed at load, not chosen per call, so
|
||||
-- a mod cannot aim this at arbitrary hosts; the response body is never
|
||||
-- returned, and the worker pool bounds the transfer. Same handle/poll/
|
||||
-- release shape as mod.fetch, so mod.job's sibling patterns carry over.
|
||||
postLog = (function()
|
||||
if mod.manifest.permissionSet.network and mod.manifest.log_url then
|
||||
return function(_, body, opts)
|
||||
return Net.postLog(loader, modId, mod.manifest.log_url, body, opts)
|
||||
end
|
||||
end
|
||||
local function refuse()
|
||||
error(('[%s] mod.postLog needs the "network" permission and a '
|
||||
.. "log_url in manifest.json"):format(modId), 2)
|
||||
end
|
||||
return refuse
|
||||
end)(),
|
||||
-- Background compute, behind the "background" permission. The worker
|
||||
-- rebuilds this mod's sandbox before loading the script, so a job is the
|
||||
-- one thing love.thread is not: off the main thread without a Lua state
|
||||
-- that escapes the sandbox. Plain data in, plain data out.
|
||||
job = (function()
|
||||
if mod.manifest.permissionSet.background then
|
||||
return {
|
||||
available = function() return Job.available() end,
|
||||
run = function(_, script, arg, opts)
|
||||
return Job.run(loader, modId, mod.path, script, arg, opts)
|
||||
end,
|
||||
poll = function(_, handle) return Job.poll(loader, modId, handle) end,
|
||||
release = function(_, handle) return Job.release(loader, modId, handle) end,
|
||||
cancel = function(_, handle) return Job.cancel(loader, modId, handle) end,
|
||||
}
|
||||
end
|
||||
local function refuse()
|
||||
error(('[%s] mod.job needs the "background" permission in '
|
||||
.. "manifest.json"):format(modId), 2)
|
||||
end
|
||||
return { available = function() return false end,
|
||||
run = refuse, poll = refuse, release = refuse, cancel = refuse }
|
||||
end)(),
|
||||
-- namespaced per mod; M11 backs these with save.modData /
|
||||
-- options.modOptions, the shape mods compile against is already final
|
||||
save = {
|
||||
@@ -1288,12 +1353,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))
|
||||
@@ -1329,6 +1406,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
|
||||
|
||||
+21
-1
@@ -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
|
||||
@@ -307,6 +308,24 @@ function Manifest.validate(raw, path)
|
||||
|
||||
local github = Manifest.parseGithub(raw.github)
|
||||
|
||||
-- log_url: the mod's one-way crash-log reporting destination. https-only,
|
||||
-- declared in the manifest so the engine reviews the target at load instead
|
||||
-- of trusting per-call URLs from gameplay code, and gated on the `network`
|
||||
-- permission the mod must also declare. api 1 mods never carry it: it is a
|
||||
-- load violation, not a warning, because a postLog-capable mod that does not
|
||||
-- opt in to networking is a bug in the manifest itself.
|
||||
local logUrl = nil
|
||||
if raw.log_url ~= nil then
|
||||
if strict and not permissionSet.network then
|
||||
violation(strict, raw.id, "log_url requires the network permission")
|
||||
elseif strict and (type(raw.log_url) ~= "string"
|
||||
or not raw.log_url:match("^https://")) then
|
||||
violation(strict, raw.id, "log_url must be an https:// URL")
|
||||
elseif strict then
|
||||
logUrl = raw.log_url
|
||||
end
|
||||
end
|
||||
|
||||
assert(raw.experimental == nil or type(raw.experimental) == "boolean",
|
||||
"experimental must be a boolean")
|
||||
local experimental = raw.experimental == true
|
||||
@@ -413,6 +432,7 @@ function Manifest.validate(raw, path)
|
||||
affects_link = affectsLink,
|
||||
permissions = permissions,
|
||||
permissionSet = permissionSet,
|
||||
log_url = logUrl,
|
||||
options_schema = optionalFile(raw.options_schema, "options_schema"),
|
||||
assets_transforms = optionalFile(raw.assets_transforms, "assets_transforms"),
|
||||
required_imports = requiredImports,
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
-- Background HTTP for sandboxed mods, behind the "network" permission.
|
||||
--
|
||||
-- The sandbox blocks love.thread because newThread boots a Lua state with a
|
||||
-- full standard library that none of the sandbox's rules reach -- one call and
|
||||
-- a mod has io back. That is correct, but it left mods with no way to do
|
||||
-- anything off the main thread at all: the only reachable transports
|
||||
-- (socket, http) block, so a mod that wanted to fetch something had to hang
|
||||
-- the game to do it.
|
||||
--
|
||||
-- This is the narrow replacement. src/net/Fetch.lua already runs a pool of
|
||||
-- engine-owned worker threads, and those workers run OUR code, not the mod's,
|
||||
-- so handing a mod a job in that pool grants no new reach. A mod submits a
|
||||
-- URL and polls for the body; it never gets a thread, a path, or a raw handle
|
||||
-- into the shared job table.
|
||||
--
|
||||
-- WHAT THIS FILE HAS TO GET RIGHT, because Fetch itself is shared with the
|
||||
-- launcher:
|
||||
-- * Handles are opaque tables owned per mod. Fetch keys jobs by integer,
|
||||
-- and the launcher's own ROM download and index fetches live in the same
|
||||
-- table; an integer handed to a mod would let it poll (or cancel) work
|
||||
-- that is not its own. A forged table simply misses the lookup.
|
||||
-- * Only http and https. The transport is curl, which also speaks file://,
|
||||
-- scp:// and ftp://; without this check mod.fetch would be a filesystem
|
||||
-- read and the sandbox would be back to square one.
|
||||
-- * A per-mod ceiling on jobs in flight, so one mod cannot fill the shared
|
||||
-- three-worker pool and starve the launcher's own fetches.
|
||||
|
||||
local Net = {}
|
||||
|
||||
-- Per mod, not global: the pool is shared with the launcher and a mod should
|
||||
-- never be able to monopolise it.
|
||||
Net.MAX_INFLIGHT = 4
|
||||
-- Clamp on the caller's timeout, so a mod cannot pin a worker indefinitely.
|
||||
Net.MAX_SECONDS = 30
|
||||
-- A log body ceiling. Debug logs are kilobytes, and a server operator has no
|
||||
-- reason to accept a mod uploading arbitrary megabytes to its endpoint.
|
||||
Net.MAX_BODY = 65536
|
||||
|
||||
local function fetch()
|
||||
return require("src.net.Fetch")
|
||||
end
|
||||
|
||||
-- http/https only, and a host must actually be present -- "http://" alone
|
||||
-- reaches curl as a malformed URL rather than being refused here.
|
||||
function Net.urlDenial(url)
|
||||
if type(url) ~= "string" or url == "" then return "url must be a string" end
|
||||
local scheme, rest = url:match("^(%a[%w+.-]*)://(.*)$")
|
||||
if not scheme then return "url must start with http:// or https://" end
|
||||
scheme = scheme:lower()
|
||||
if scheme ~= "http" and scheme ~= "https" then
|
||||
return ("%s:// is not allowed; mod.fetch speaks http and https only")
|
||||
:format(scheme)
|
||||
end
|
||||
if rest == "" or rest:match("^/") then return "url has no host" end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function bucket(loader, modId)
|
||||
loader.netJobs = loader.netJobs or {}
|
||||
local b = loader.netJobs[modId]
|
||||
if not b then b = {}; loader.netJobs[modId] = b end
|
||||
return b
|
||||
end
|
||||
|
||||
local function inflight(b)
|
||||
local n = 0
|
||||
for _, id in pairs(b) do
|
||||
if fetch().isPending(id) then n = n + 1 end
|
||||
end
|
||||
return n
|
||||
end
|
||||
|
||||
function Net.available()
|
||||
local ok, F = pcall(fetch)
|
||||
if not ok then return false end
|
||||
local okAvail, avail = pcall(F.available)
|
||||
return okAvail and avail and true or false
|
||||
end
|
||||
|
||||
-- Returns an opaque handle, or nil plus a reason.
|
||||
function Net.get(loader, modId, url, opts)
|
||||
local denial = Net.urlDenial(url)
|
||||
if denial then return nil, denial end
|
||||
opts = type(opts) == "table" and opts or {}
|
||||
local b = bucket(loader, modId)
|
||||
if inflight(b) >= Net.MAX_INFLIGHT then
|
||||
return nil, ("too many requests in flight (limit %d); poll and release "
|
||||
.. "the ones you have"):format(Net.MAX_INFLIGHT)
|
||||
end
|
||||
local maxSeconds = tonumber(opts.maxSeconds) or Net.MAX_SECONDS
|
||||
if maxSeconds > Net.MAX_SECONDS then maxSeconds = Net.MAX_SECONDS end
|
||||
if maxSeconds < 1 then maxSeconds = 1 end
|
||||
-- The mod is named in the agent string so a server operator can see which
|
||||
-- mod is calling them, and a mod cannot pretend to be the launcher.
|
||||
local id = fetch().get(url, {
|
||||
userAgent = "gen1recomp-mod/" .. tostring(modId),
|
||||
accept = type(opts.accept) == "string" and opts.accept or nil,
|
||||
maxSeconds = maxSeconds,
|
||||
})
|
||||
local handle = {}
|
||||
b[handle] = id
|
||||
return handle
|
||||
end
|
||||
|
||||
-- The closed list of postLog format switches. Anything outside it is a
|
||||
-- caller bug, rejected before a job is submitted, so the surface stays
|
||||
-- exactly two shapes on the wire.
|
||||
local POST_FORMATS = { text = true, json = true }
|
||||
|
||||
-- A one-way log POST to the mod's manifest-declared log_url (https only,
|
||||
-- validated in Manifest.lua). Same shape as get(): opaque handle, per-mod
|
||||
-- in-flight ceiling, user agent naming the mod. The response body is never
|
||||
-- returned -- a postLog is fire-and-forget reporting, and the engine has no
|
||||
-- reason to hand a mod a server's reply.
|
||||
function Net.postLog(loader, modId, logUrl, body, opts)
|
||||
if type(body) ~= "string" or body == "" then
|
||||
return nil, "log body must be a non-empty string"
|
||||
end
|
||||
if #body > Net.MAX_BODY then
|
||||
return nil, ("log body too large (%d bytes, limit %d)"):format(#body, Net.MAX_BODY)
|
||||
end
|
||||
opts = type(opts) == "table" and opts or {}
|
||||
for key in pairs(opts) do
|
||||
if key ~= "format" then
|
||||
return nil, ("unknown log option %q (format is the only switch)"):format(tostring(key))
|
||||
end
|
||||
end
|
||||
local format = opts.format or "text"
|
||||
if not POST_FORMATS[format] then
|
||||
return nil, ("unknown log format %q (text and json only)"):format(tostring(format))
|
||||
end
|
||||
local denial = Net.urlDenial(logUrl)
|
||||
if denial then return nil, denial end
|
||||
local b = bucket(loader, modId)
|
||||
if inflight(b) >= Net.MAX_INFLIGHT then
|
||||
return nil, ("too many requests in flight (limit %d); poll and release "
|
||||
.. "the ones you have"):format(Net.MAX_INFLIGHT)
|
||||
end
|
||||
local payload = body
|
||||
local contentType = "text/plain"
|
||||
if format == "json" then
|
||||
local Json = require("src.link.Json")
|
||||
payload = Json.encode({
|
||||
ts = os.time(),
|
||||
mod = modId,
|
||||
format = "json",
|
||||
body = body,
|
||||
})
|
||||
contentType = "application/json"
|
||||
end
|
||||
local id = fetch().post(logUrl, payload, {
|
||||
userAgent = "gen1recomp-mod/" .. tostring(modId),
|
||||
contentType = contentType,
|
||||
maxSeconds = Net.MAX_SECONDS,
|
||||
})
|
||||
local handle = {}
|
||||
b[handle] = id
|
||||
return handle
|
||||
end
|
||||
|
||||
-- A copy of the job's state, never the engine's own table. An unknown or
|
||||
-- forged handle reads as an error rather than nil, so a mod that lost track of
|
||||
-- one cannot spin waiting on it forever.
|
||||
function Net.poll(loader, modId, handle)
|
||||
local id = bucket(loader, modId)[handle]
|
||||
if not id then return { status = "error", err = "unknown request" } end
|
||||
local st = fetch().poll(id)
|
||||
return { status = st.status, body = st.body, err = st.err,
|
||||
progress = st.progress }
|
||||
end
|
||||
|
||||
function Net.release(loader, modId, handle)
|
||||
local b = bucket(loader, modId)
|
||||
local id = b[handle]
|
||||
if not id then return false end
|
||||
fetch().release(id)
|
||||
b[handle] = nil
|
||||
return true
|
||||
end
|
||||
|
||||
function Net.cancel(loader, modId, handle)
|
||||
local id = bucket(loader, modId)[handle]
|
||||
if not id then return false end
|
||||
fetch().cancel(id)
|
||||
return true
|
||||
end
|
||||
|
||||
-- Drop everything this mod still holds. Called when a mod unloads, so a
|
||||
-- disabled mod cannot leave jobs accumulating in the shared table.
|
||||
function Net.releaseAll(loader, modId)
|
||||
local b = loader.netJobs and loader.netJobs[modId]
|
||||
if not b then return end
|
||||
local F = fetch()
|
||||
for handle, id in pairs(b) do
|
||||
pcall(F.cancel, id)
|
||||
pcall(F.release, id)
|
||||
b[handle] = nil
|
||||
end
|
||||
loader.netJobs[modId] = nil
|
||||
end
|
||||
|
||||
return Net
|
||||
+39
-10
@@ -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, mod:read and mod:list", 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
|
||||
@@ -132,6 +132,17 @@ function Fetch.get(url, opts)
|
||||
accept = opts.accept, maxSeconds = opts.maxSeconds })
|
||||
end
|
||||
|
||||
-- POST a body to a URL, one-way. The result carries no body: postLog
|
||||
-- reporting never trusts a server's reply, so the worker surfaces only
|
||||
-- ok/error and the transport's complaint.
|
||||
-- opts: { userAgent, contentType, maxSeconds }
|
||||
function Fetch.post(url, body, opts)
|
||||
opts = opts or {}
|
||||
return submit({ kind = "post", url = url, body = body,
|
||||
userAgent = opts.userAgent or "gen1recomp",
|
||||
contentType = opts.contentType, maxSeconds = opts.maxSeconds })
|
||||
end
|
||||
|
||||
-- Download a URL to `saveRel`, a path relative to the LOVE save directory.
|
||||
-- Progress is reported as a 0..1 fraction when `size` is known.
|
||||
function Fetch.download(url, saveRel, opts)
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
-- Load gen1tls from next to the exe and put its poll API on love.system --
|
||||
-- same tlsOpen / tlsSend / ... shape Android already exposes through JNI.
|
||||
--
|
||||
-- Mods can't require("ffi") under the sandbox, so they can't load the DLL
|
||||
-- themselves even when it's sitting right there. We do it here, before any
|
||||
-- mod runs. LegacyCompat's love.system shim forwards the tls* keys through
|
||||
-- (clipboard / openURL stay stubbed).
|
||||
--
|
||||
-- true = tlsOpen is ready. Already present (Android), no FFI, or no DLL:
|
||||
-- just return false and move on; plain ws:// rooms don't care.
|
||||
|
||||
local Gen1Tls = {}
|
||||
|
||||
local function exeDir()
|
||||
if love and love.filesystem and love.filesystem.getSourceBaseDirectory then
|
||||
local base = love.filesystem.getSourceBaseDirectory()
|
||||
if type(base) == "string" and base ~= "" then return base end
|
||||
end
|
||||
if type(arg) == "table" and type(arg[0]) == "string" then
|
||||
local dir = arg[0]:match("^(.*)[/\\]")
|
||||
if dir and dir ~= "" then return dir end
|
||||
end
|
||||
return "."
|
||||
end
|
||||
|
||||
local function fileReadable(path)
|
||||
local f = io.open(path, "rb")
|
||||
if not f then return false end
|
||||
f:close()
|
||||
return true
|
||||
end
|
||||
|
||||
local function libNames()
|
||||
local osName = (love and love.system and love.system.getOS and love.system.getOS()) or ""
|
||||
if osName == "Windows" then return { "gen1tls.dll" } end
|
||||
if osName == "OS X" then return { "libgen1tls.dylib", "gen1tls.dylib" } end
|
||||
if osName == "Linux" then return { "libgen1tls.so", "gen1tls.so" } end
|
||||
return { "gen1tls.dll", "libgen1tls.so", "libgen1tls.dylib" }
|
||||
end
|
||||
|
||||
function Gen1Tls.install()
|
||||
if not (love and love.system) then return false end
|
||||
if type(love.system.tlsOpen) == "function" then return true end
|
||||
|
||||
local okFfi, ffi = pcall(require, "ffi")
|
||||
if not okFfi or type(ffi) ~= "table" then return false end
|
||||
|
||||
ffi.cdef[[
|
||||
int gen1tls_open(const char *host, int port);
|
||||
int gen1tls_status(int handle);
|
||||
int gen1tls_send(int handle, const char *data, int length);
|
||||
int gen1tls_receive(int handle, char *buf, int max);
|
||||
int gen1tls_error(int handle, char *buf, int max);
|
||||
void gen1tls_close(int handle);
|
||||
]]
|
||||
|
||||
local dir = exeDir()
|
||||
local lib
|
||||
for _, name in ipairs(libNames()) do
|
||||
local path = dir .. "/" .. name
|
||||
if fileReadable(path) then
|
||||
local ok, loaded = pcall(ffi.load, path)
|
||||
if ok then lib = loaded; break end
|
||||
end
|
||||
local ok, loaded = pcall(ffi.load, name)
|
||||
if ok then lib = loaded; break end
|
||||
end
|
||||
if not lib then return false end
|
||||
|
||||
local errBuf = ffi.new("char[512]")
|
||||
local recvBuf = ffi.new("char[65536]")
|
||||
|
||||
love.system.tlsOpen = function(host, port)
|
||||
return lib.gen1tls_open(host, tonumber(port) or 0)
|
||||
end
|
||||
love.system.tlsStatus = function(handle)
|
||||
return lib.gen1tls_status(handle)
|
||||
end
|
||||
love.system.tlsSend = function(handle, data)
|
||||
data = data or ""
|
||||
return lib.gen1tls_send(handle, data, #data)
|
||||
end
|
||||
love.system.tlsReceive = function(handle, max)
|
||||
max = math.min(tonumber(max) or 8192, 65536)
|
||||
if max <= 0 then return "" end
|
||||
local n = lib.gen1tls_receive(handle, recvBuf, max)
|
||||
if n <= 0 then return "" end
|
||||
return ffi.string(recvBuf, n)
|
||||
end
|
||||
love.system.tlsError = function(handle)
|
||||
if lib.gen1tls_error(handle, errBuf, 512) == 0 then return nil end
|
||||
return ffi.string(errBuf)
|
||||
end
|
||||
love.system.tlsClose = function(handle)
|
||||
lib.gen1tls_close(handle)
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
return Gen1Tls
|
||||
@@ -99,6 +99,20 @@ local function doDownload(job)
|
||||
post({ id = job.id, ok = true, path = rel, done = true })
|
||||
end
|
||||
|
||||
local function doPost(job)
|
||||
if not HostShell then
|
||||
post({ id = job.id, ok = false, err = "no transport" })
|
||||
return
|
||||
end
|
||||
local ok, err = HostShell.httpPost(job.url, job.body, job.contentType,
|
||||
job.userAgent, tonumber(job.maxSeconds) or GET_MAX_SECONDS)
|
||||
if not ok then
|
||||
post({ id = job.id, ok = false, err = err or "post failed" })
|
||||
return
|
||||
end
|
||||
post({ id = job.id, ok = true, done = true })
|
||||
end
|
||||
|
||||
while true do
|
||||
local job = cmdCh:demand()
|
||||
-- The flag is checked before the job's KIND, so a worker woken by a
|
||||
@@ -114,6 +128,9 @@ while true do
|
||||
elseif job.kind == "get" then
|
||||
local ok, err = pcall(doGet, job)
|
||||
if not ok then post({ id = job.id, ok = false, err = tostring(err) }) end
|
||||
elseif job.kind == "post" then
|
||||
local ok, err = pcall(doPost, job)
|
||||
if not ok then post({ id = job.id, ok = false, err = tostring(err) }) end
|
||||
elseif job.kind == "download" then
|
||||
local ok, err = pcall(doDownload, job)
|
||||
if not ok then post({ id = job.id, ok = false, err = tostring(err) }) end
|
||||
|
||||
+10
-3
@@ -539,11 +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
|
||||
ow:useStrengthFieldMove(mon, function() self:close() end)
|
||||
return
|
||||
elseif action == "softboiled" then
|
||||
-- field SOFTBOILED (StartMenu_Pokemon .softboiled): transfer
|
||||
|
||||
+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
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
-- "update_check_state" worker -> main: { status, latest, progress, error }
|
||||
--
|
||||
-- Nothing here ever blocks or throws into the game loop: when love.thread is
|
||||
-- absent (the headless test stub) or the worker cannot run (no curl, Android),
|
||||
-- state() simply reports "error" and the UI hides itself. See the shared
|
||||
-- contract in the task brief for the status vocabulary and the file layout.
|
||||
-- absent (the headless test stub) or the worker cannot run, state() reports
|
||||
-- "error" (or the worker reports "needs_full" when there is no transport).
|
||||
-- See the shared contract in the task brief for the status vocabulary.
|
||||
--
|
||||
-- The release-JSON extraction and the sums parsing are exported as pure
|
||||
-- functions (no love.* calls) so plain-Lua tests can cover them, and so the
|
||||
|
||||
+65
-62
@@ -5,11 +5,10 @@
|
||||
-- "update_check_cmd" in: { cmd = "check" | "download" | "quit" }
|
||||
-- "update_check_state" out: { status, latest, progress, error }
|
||||
--
|
||||
-- Transport is curl shelled out via io.popen (curl ships on macOS, Windows 10+
|
||||
-- and desktop Linux). Everything is wrapped so a missing curl, an HTTP error,
|
||||
-- or a hung download degrades to a "error"/"needs_full" state rather than
|
||||
-- blocking or crashing the game. On Android curl is absent and the check
|
||||
-- soft-fails to "error", which the UI hides.
|
||||
-- Transport is HostShell: curl via io.popen on desktop, the JNI
|
||||
-- love.system.httpDownload bridge on Android (same path the mod catalog
|
||||
-- already uses). A missing transport, an HTTP error, or a hung download
|
||||
-- degrades to "error"/"needs_full" rather than blocking or crashing the game.
|
||||
--
|
||||
-- Fresh love threads do not carry the "src.*" package searcher, so sibling
|
||||
-- modules are pulled in with love.filesystem.load exactly like
|
||||
@@ -63,9 +62,12 @@ local API_URL = "https://api.github.com/repos/bryanthaboi/gen1recomp/releases/la
|
||||
local pending = nil
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- shell / curl
|
||||
-- shell / fetch
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local UA = "gen1recomp-updater"
|
||||
local GH_ACCEPT = "application/vnd.github+json"
|
||||
|
||||
local function shq(s)
|
||||
s = tostring(s)
|
||||
if isWindows then
|
||||
@@ -74,31 +76,17 @@ local function shq(s)
|
||||
return "'" .. s:gsub("'", "'\\''") .. "'"
|
||||
end
|
||||
|
||||
-- run curl and return its response body (text), or nil on any failure. Used
|
||||
-- for the small text resources (release JSON, sums file); -f makes curl exit
|
||||
-- non-zero and emit nothing on an HTTP error, so an empty read is a failure.
|
||||
local function curlCapture(url)
|
||||
local cmd = "curl -fsSL --connect-timeout 10 --max-time 40 "
|
||||
.. "-H " .. shq("User-Agent: gen1recomp-updater") .. " "
|
||||
.. "-H " .. shq("Accept: application/vnd.github+json") .. " "
|
||||
.. shq(url)
|
||||
local pipe = HostShell.popen(cmd)
|
||||
if not pipe then return nil end
|
||||
local out = pipe:read("*a")
|
||||
-- HostShell.pclose, not pipe:close(): a close outside the spawn lock can
|
||||
-- free a FILE while another thread's popen walks the stream list, which
|
||||
-- deadlocks that thread permanently (see HostShell's popen notes).
|
||||
HostShell.pclose(pipe)
|
||||
if not out or out == "" then return nil end
|
||||
return out
|
||||
-- Small text resources (release JSON, sums file) through HostShell so Android
|
||||
-- hits the JNI bridge instead of a curl binary that is never on the device.
|
||||
local function fetchText(url, accept)
|
||||
if not HostShell then return nil end
|
||||
local body = HostShell.httpGet(url, UA, accept)
|
||||
if type(body) ~= "string" or body == "" then return nil end
|
||||
return body
|
||||
end
|
||||
|
||||
local function haveCurl()
|
||||
local pipe = HostShell.popen("curl --version")
|
||||
if not pipe then return false end
|
||||
local out = pipe:read("*a")
|
||||
HostShell.pclose(pipe)
|
||||
return out ~= nil and out:find("curl", 1, true) ~= nil
|
||||
local function canFetch()
|
||||
return HostShell and HostShell.canFetch()
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
@@ -170,12 +158,14 @@ end
|
||||
local function doCheck()
|
||||
post({ status = "checking" })
|
||||
|
||||
if not haveCurl() then
|
||||
post({ status = "error", error = "curl not available" })
|
||||
if not canFetch() then
|
||||
-- No curl and no JNI bridge: the chip becomes "Open releases" so a tap
|
||||
-- still does something instead of retrying a check that cannot succeed.
|
||||
post({ status = "needs_full" })
|
||||
return
|
||||
end
|
||||
|
||||
local body = curlCapture(API_URL)
|
||||
local body = fetchText(API_URL, GH_ACCEPT)
|
||||
if not body then
|
||||
post({ status = "error", error = "release check failed" })
|
||||
return
|
||||
@@ -212,7 +202,7 @@ local function doCheck()
|
||||
-- pulling the bytes again.
|
||||
local finalRel = "updates/" .. rel.payloadName
|
||||
if love.filesystem.getInfo(finalRel) then
|
||||
local sums = curlCapture(rel.sums.url)
|
||||
local sums = fetchText(rel.sums.url)
|
||||
if sums and verifyPayload(finalRel, rel.payloadName, sums) then
|
||||
if gatePasses(finalRel) == false then
|
||||
love.filesystem.remove(finalRel)
|
||||
@@ -279,39 +269,52 @@ local function doDownload()
|
||||
local doneAbs = saveDir .. "/updates/" .. rel.payloadName .. ".done"
|
||||
local size = rel.payload.size or 0
|
||||
|
||||
launchDownload(rel.payload.url, partAbs, doneAbs)
|
||||
if HostShell and HostShell.haveCurl() then
|
||||
launchDownload(rel.payload.url, partAbs, doneAbs)
|
||||
|
||||
-- poll the .part size for progress until curl drops the done-marker; a
|
||||
-- stalled or run-away transfer breaks out and lets verification fail cleanly
|
||||
local waited, lastSize, lastChange = 0, -1, 0
|
||||
while true do
|
||||
-- A queued quit means the window already closed. Bail so the join in
|
||||
-- Check.shutdown does not hold the dead window's process (and, on
|
||||
-- Windows, its folder) open for up to the whole transfer (#727). The
|
||||
-- quit stays on the channel for the command loop; the detached curl
|
||||
-- times out on its own and the next launch's doCheck verifies and
|
||||
-- re-offers whatever landed.
|
||||
local peeked = cmdCh:peek()
|
||||
if type(peeked) == "table" and peeked.cmd == "quit" then return end
|
||||
if love.filesystem.getInfo(doneRel) then break end
|
||||
local pinfo = love.filesystem.getInfo(partRel)
|
||||
local cur = (pinfo and pinfo.size) or 0
|
||||
if size > 0 then
|
||||
local p = cur / size
|
||||
if p > 0.999 then p = 0.999 end -- 1.0 is reserved for "ready"
|
||||
post({ status = "downloading", latest = rel.version, progress = p })
|
||||
else
|
||||
post({ status = "downloading", latest = rel.version })
|
||||
-- poll the .part size for progress until curl drops the done-marker; a
|
||||
-- stalled or run-away transfer breaks out and lets verification fail cleanly
|
||||
local waited, lastSize, lastChange = 0, -1, 0
|
||||
while true do
|
||||
-- A queued quit means the window already closed. Bail so the join in
|
||||
-- Check.shutdown does not hold the dead window's process (and, on
|
||||
-- Windows, its folder) open for up to the whole transfer (#727). The
|
||||
-- quit stays on the channel for the command loop; the detached curl
|
||||
-- times out on its own and the next launch's doCheck verifies and
|
||||
-- re-offers whatever landed.
|
||||
local peeked = cmdCh:peek()
|
||||
if type(peeked) == "table" and peeked.cmd == "quit" then return end
|
||||
if love.filesystem.getInfo(doneRel) then break end
|
||||
local pinfo = love.filesystem.getInfo(partRel)
|
||||
local cur = (pinfo and pinfo.size) or 0
|
||||
if size > 0 then
|
||||
local p = cur / size
|
||||
if p > 0.999 then p = 0.999 end -- 1.0 is reserved for "ready"
|
||||
post({ status = "downloading", latest = rel.version, progress = p })
|
||||
else
|
||||
post({ status = "downloading", latest = rel.version })
|
||||
end
|
||||
if cur ~= lastSize then lastSize, lastChange = cur, waited end
|
||||
if waited - lastChange > 60 then break end -- 60s with no growth: give up
|
||||
if waited > 960 then break end -- absolute ceiling
|
||||
love.timer.sleep(0.25)
|
||||
waited = waited + 0.25
|
||||
end
|
||||
if cur ~= lastSize then lastSize, lastChange = cur, waited end
|
||||
if waited - lastChange > 60 then break end -- 60s with no growth: give up
|
||||
if waited > 960 then break end -- absolute ceiling
|
||||
love.timer.sleep(0.25)
|
||||
waited = waited + 0.25
|
||||
love.filesystem.remove(doneRel)
|
||||
else
|
||||
-- Android JNI bridge: blocking write, same as fetch_worker. Progress
|
||||
-- cannot be sampled from inside httpDownload.
|
||||
local ok = HostShell and HostShell.httpDownload(
|
||||
rel.payload.url, partAbs, UA, nil, 900)
|
||||
if not ok then
|
||||
love.filesystem.remove(partRel)
|
||||
post({ status = "error", error = "download failed" })
|
||||
return
|
||||
end
|
||||
post({ status = "downloading", latest = rel.version, progress = 0.999 })
|
||||
end
|
||||
love.filesystem.remove(doneRel)
|
||||
|
||||
local sums = curlCapture(rel.sums and rel.sums.url or "")
|
||||
local sums = fetchText(rel.sums and rel.sums.url or "")
|
||||
if not sums then
|
||||
love.filesystem.remove(partRel)
|
||||
post({ status = "error", error = "checksum fetch failed" })
|
||||
|
||||
@@ -55,4 +55,24 @@ check(not source:find("QuestActivity", 1, true) and
|
||||
not source:find("QuestBridge", 1, true),
|
||||
"generic Android activity must not require Quest classes")
|
||||
|
||||
-- Required mod files use Android's Storage Access Framework, which works with
|
||||
-- Android 13 scoped storage without broad media/storage permissions. Keep the
|
||||
-- native destination distinct so it cannot be consumed as a game ROM.
|
||||
check(source:find('PICKED_REQUIRED_IMPORT_FILENAME = "picked_required_import.bin"',
|
||||
1, true), "required imports use their own Android picker destination")
|
||||
check(source:find("showRequiredImportFilePicker", 1, true),
|
||||
"Android exposes a required-import picker entry point")
|
||||
check(source:find("Intent.ACTION_OPEN_DOCUMENT", 1, true)
|
||||
and source:find("Intent.FLAG_GRANT_READ_URI_PERMISSION", 1, true),
|
||||
"Android 13 uses SAF with an explicit read grant")
|
||||
|
||||
local systemPath = "mobile/android/love/src/jni/love/src/modules/system/System.cpp"
|
||||
local systemFile = assert(io.open(systemPath, "rb"))
|
||||
local system = systemFile:read("*a")
|
||||
systemFile:close()
|
||||
check(system:find('strcmp(kind, "required_import")', 1, true)
|
||||
and system:find('dest = "picked_required_import.bin"', 1, true)
|
||||
and system:find('return "rom,mod,sav,required_import"', 1, true),
|
||||
"native Android bridge advertises and routes required imports")
|
||||
|
||||
print("android_host_extension_test: ok")
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
-- iOS required imports travel through the same document-picker contract as
|
||||
-- Android. Keep the Swift bridge and liblove patch aligned: a build that has
|
||||
-- only one side would show the import button but fail on device.
|
||||
local function read(path)
|
||||
local file = assert(io.open(path, "rb"))
|
||||
local data = file:read("*a")
|
||||
file:close()
|
||||
return data
|
||||
end
|
||||
|
||||
local function check(value, message)
|
||||
if not value then error(message, 2) end
|
||||
end
|
||||
|
||||
local bridge = read("mobile/ios/native/GRPickerBridge.swift")
|
||||
check(bridge:find('case "required_import":', 1, true)
|
||||
and bridge:find('destName = "picked_required_import.bin"', 1, true),
|
||||
"iOS routes required imports to their own staged filename")
|
||||
check(bridge:find("types.append(.data)", 1, true)
|
||||
and bridge:find("types.append(.item)", 1, true),
|
||||
"iOS required imports accept user-owned binary ROM files")
|
||||
check(bridge:find('"rom,mod,sav,stadium,required_import"', 1, true),
|
||||
"iOS advertises required_import to Lua before opening the picker")
|
||||
|
||||
local patch = read("mobile/ios/patch_love_src.py")
|
||||
check(patch:find("int w_pickFileKinds", 1, true)
|
||||
and patch:find('{ "pickFileKinds", w_pickFileKinds }', 1, true),
|
||||
"iOS liblove patch exposes the picker capability query")
|
||||
check(patch:find('("GRPickerBridge.swift", ID_FILE_PICKER', 1, true),
|
||||
"iOS build patch compiles the required-import picker bridge")
|
||||
|
||||
print("ios_required_import_picker_test: ok")
|
||||
@@ -62,7 +62,7 @@ end
|
||||
|
||||
do
|
||||
local body, ver = PatchNotes.body(nil)
|
||||
check(type(body) == "string" and body:find("Issues closed", 1, true),
|
||||
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")
|
||||
|
||||
@@ -44,6 +44,46 @@ check(importer.installedPath == [[C:\LocalState\picked_mod.zip]],
|
||||
check(removedPath == [[C:\LocalState\picked_mod.zip]],
|
||||
"removes the temporary copy after installation")
|
||||
|
||||
-- The UWP picker returns a temporary path rather than a mobile staged name.
|
||||
-- Required imports must use that same picker and remain scoped to the selected
|
||||
-- mod instead of relying on a desktop shell or Android/iOS inbox handling.
|
||||
local pickedKind
|
||||
love.system.pickFile = function(kind)
|
||||
pickedKind = kind
|
||||
return true
|
||||
end
|
||||
love.system.getPickedFile = function()
|
||||
love.system.getPickedFile = function() return nil end
|
||||
return [[C:\LocalState\picked_required_import.bin]]
|
||||
end
|
||||
removedPath = nil
|
||||
local required = RomImporter.new(function() end, { launcher = true })
|
||||
required.mods = { {
|
||||
id = "needs-source",
|
||||
manifest = {
|
||||
id = "needs-source", name = "Needs source",
|
||||
required_imports = { {
|
||||
id = "source", name = "Source", file = "source.bin",
|
||||
md5 = { "00000000000000000000000000000000" },
|
||||
} },
|
||||
},
|
||||
} }
|
||||
required._importRequiredSource = function(self, modId, importId, path)
|
||||
self.requiredPath = { modId = modId, importId = importId, path = path }
|
||||
return true
|
||||
end
|
||||
required:chooseRequiredImport("needs-source", "source")
|
||||
check(pickedKind == "required_import", "UWP requests the required-import picker kind")
|
||||
required:update(0)
|
||||
check(required.requiredPath and required.requiredPath.path
|
||||
== [[C:\LocalState\picked_required_import.bin]],
|
||||
"UWP routes the picked dependency to its declared import")
|
||||
check(required.requiredPath.modId == "needs-source"
|
||||
and required.requiredPath.importId == "source",
|
||||
"UWP preserves the pending mod and import identity")
|
||||
check(removedPath == [[C:\LocalState\picked_required_import.bin]],
|
||||
"UWP removes its temporary required-import copy after validation")
|
||||
|
||||
love.system.getOS = saved.getOS
|
||||
love.system.pickFile = saved.pickFile
|
||||
love.system.getPickedFile = saved.getPickedFile
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -550,6 +550,51 @@ local installedColorlib = Manifest.validate({
|
||||
version = "1.0.0",
|
||||
entry = "main.lua",
|
||||
}, "mods/colorlib")
|
||||
local unconditionalConflict = LauncherMods.checkDependencies(testTargetManifest,
|
||||
nil, nil, { testTargetManifest, installedColorlib })
|
||||
check(unconditionalConflict.hasIssues == true,
|
||||
"dependency resolver reports an unversioned conflict")
|
||||
|
||||
local function rangeTarget(version, conflicts)
|
||||
return Manifest.validate({
|
||||
id = "range_target",
|
||||
name = "Range Target",
|
||||
version = version,
|
||||
entry = "main.lua",
|
||||
conflicts = conflicts or {},
|
||||
}, "mods/range_target")
|
||||
end
|
||||
local function rangeSource(conflicts)
|
||||
return Manifest.validate({
|
||||
id = "range_source",
|
||||
name = "Range Source",
|
||||
version = "1.0.0",
|
||||
entry = "main.lua",
|
||||
conflicts = conflicts or {},
|
||||
}, "mods/range_source")
|
||||
end
|
||||
|
||||
local forwardSource = rangeSource({ "range_target@<2.0.0" })
|
||||
local matchingTarget = rangeTarget("1.4.0")
|
||||
local nonmatchingTarget = rangeTarget("2.0.0")
|
||||
local forwardMatching = LauncherMods.checkDependencies(forwardSource,
|
||||
nil, nil, { forwardSource, matchingTarget })
|
||||
check(forwardMatching.hasIssues == true and #forwardMatching.deps == 1,
|
||||
"dependency resolver applies a matching forward conflict range")
|
||||
local forwardNonmatching = LauncherMods.checkDependencies(forwardSource,
|
||||
nil, nil, { forwardSource, nonmatchingTarget })
|
||||
check(forwardNonmatching.hasIssues == false and #forwardNonmatching.deps == 0,
|
||||
"dependency resolver ignores a nonmatching forward conflict range")
|
||||
|
||||
local reverseSource = rangeSource({ "range_target@<2.0.0" })
|
||||
local reverseMatching = LauncherMods.checkDependencies(matchingTarget,
|
||||
nil, nil, { reverseSource, matchingTarget })
|
||||
check(reverseMatching.hasIssues == true and #reverseMatching.deps == 1,
|
||||
"dependency resolver applies a matching reverse conflict range")
|
||||
local reverseNonmatching = LauncherMods.checkDependencies(nonmatchingTarget,
|
||||
nil, nil, { reverseSource, nonmatchingTarget })
|
||||
check(reverseNonmatching.hasIssues == false and #reverseNonmatching.deps == 0,
|
||||
"dependency resolver ignores a nonmatching reverse conflict range")
|
||||
-- ------- scoped dependency tests
|
||||
local Json = require("src.link.Json")
|
||||
local scopedDepManifest = Manifest.validate({
|
||||
|
||||
@@ -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")
|
||||
@@ -0,0 +1,169 @@
|
||||
-- mod.postLog: one-way log reporting to the manifest-declared log_url.
|
||||
-- The things this pins are the strict ones -- https-only destination that
|
||||
-- lives in the manifest (not per-call), a closed list of format switches,
|
||||
-- a body ceiling, opaque per-mod handles, and refusal without the network
|
||||
-- permission.
|
||||
|
||||
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.post = function(url, body, opts)
|
||||
nextId = nextId + 1
|
||||
submitted[nextId] = { url = url, body = body, 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 LOGGER = {
|
||||
["mods/log_sender/manifest.json"] = [[{
|
||||
"id": "log_sender",
|
||||
"name": "Log Sender",
|
||||
"version": "1.0.0",
|
||||
"entry": "main.lua",
|
||||
"api": 2,
|
||||
"permissions": ["network"],
|
||||
"log_url": "https://logs.example.com/logs"
|
||||
}]],
|
||||
["mods/log_sender/main.lua"] = [[
|
||||
local mod = ...
|
||||
mod.exports.send = function(body, opts)
|
||||
local handle, err = mod:postLog(body, opts)
|
||||
if not handle then return nil, err end
|
||||
return handle
|
||||
end
|
||||
mod.exports.poll = function(h) return mod.fetch:poll(h) end
|
||||
mod.exports.release = function(h) return mod.fetch:release(h) end
|
||||
]],
|
||||
}
|
||||
|
||||
local function manifest(id, extra)
|
||||
return ('{"id": "%s", "name": "T", "version": "1.0.0", "entry": "main.lua", '
|
||||
.. '"api": 2%s}'):format(id, extra or "")
|
||||
end
|
||||
|
||||
local NO_URL = {
|
||||
["mods/log_no_url/manifest.json"] = manifest("log_no_url", ', "permissions": ["network"]'),
|
||||
["mods/log_no_url/main.lua"] = [[
|
||||
local mod = ...
|
||||
mod.exports.try = function()
|
||||
local ok, err = pcall(function() return mod:postLog("body") end)
|
||||
return ok, err
|
||||
end
|
||||
]],
|
||||
}
|
||||
|
||||
-- ------------------------------------------------ the closed opts list
|
||||
local run = T.sdk.loadMods({ "mods/log_sender" }, { fs = T.sdk.memfs(LOGGER) })
|
||||
T.eq(#run.errors, 0, "the logger mod loads clean (" .. tostring(run.errors[1]) .. ")")
|
||||
local api = run.loader.exports.log_sender
|
||||
|
||||
local bad, badErr = api.send("body", { format = "xml" })
|
||||
T.eq(bad, nil, "an unknown format is refused")
|
||||
T.check(badErr and badErr:find("text and json only", 1, true), "and names the allowed ones")
|
||||
|
||||
local badKey, keyErr = api.send("body", { envelope = true })
|
||||
T.eq(badKey, nil, "an unknown opt key is refused")
|
||||
T.check(keyErr and keyErr:find("format is the only switch", 1, true), "and says so")
|
||||
|
||||
-- ------------------------------------------------ body validation
|
||||
local empty, emptyErr = api.send("")
|
||||
T.eq(empty, nil, "an empty body is refused")
|
||||
local big = string.rep("x", Net.MAX_BODY + 1)
|
||||
local bigH, bigErr = api.send(big)
|
||||
T.eq(bigH, nil, "an oversized body is refused")
|
||||
T.check(bigErr and bigErr:find("limit", 1, true), "and names the limit")
|
||||
|
||||
-- ------------------------------------------------- text format (default)
|
||||
local handle, err = api.send("hello log")
|
||||
T.check(handle ~= nil, "a plain text post 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 post polls as pending")
|
||||
|
||||
local sent
|
||||
for _, job in pairs(submitted) do
|
||||
if job.url == "https://logs.example.com/logs" and job.body == "hello log" then sent = job end
|
||||
end
|
||||
T.check(sent ~= nil, "the post reached the pool with the manifest URL")
|
||||
T.eq(sent.opts.contentType, "text/plain", "plain text posts as text/plain")
|
||||
T.check(sent.opts.userAgent:find("log_sender", 1, true),
|
||||
"the request identifies the calling mod")
|
||||
|
||||
-- -------------------------------------------------- json format
|
||||
local jh, jerr = api.send("line one", { format = "json" })
|
||||
T.check(jh ~= nil, "a json post returns a handle (" .. tostring(jerr) .. ")")
|
||||
local jsent
|
||||
for _, job in pairs(submitted) do
|
||||
if job.opts.contentType == "application/json" then jsent = job end
|
||||
end
|
||||
T.check(jsent ~= nil, "json posts as application/json")
|
||||
local decoded = require("src.link.Json").decode(jsent.body)
|
||||
T.eq(type(decoded), "table", "the json body is a table")
|
||||
T.eq(decoded.format, "json", "the envelope names its format")
|
||||
T.eq(decoded.mod, "log_sender", "the envelope names the mod")
|
||||
T.eq(decoded.body, "line one", "the payload survives the envelope")
|
||||
|
||||
-- completion flows through poll, like get
|
||||
states[1] = { status = "ok", progress = 1 }
|
||||
local got = api.poll(handle)
|
||||
T.eq(got.status, "ok", "a completed post polls ok")
|
||||
|
||||
api.release(handle)
|
||||
run.release()
|
||||
|
||||
-- --------------------------------------- manifest without log_url refuses
|
||||
local nurl = T.sdk.loadMods({ "mods/log_no_url" }, { fs = T.sdk.memfs(NO_URL) })
|
||||
T.eq(#nurl.errors, 0, "no log_url loads clean (" .. tostring(nurl.errors[1]) .. ")")
|
||||
local okCall, callErr = nurl.loader.exports.log_no_url.try()
|
||||
T.check(not okCall and callErr:find("log_url", 1, true),
|
||||
"postLog without log_url names the missing manifest field")
|
||||
nurl.release()
|
||||
|
||||
-- ------------------------------------------- manifest validation: the gate
|
||||
-- log_url without the network permission is a load violation in a strict
|
||||
-- manifest: the mod declares a network capability it did not opt in to. The
|
||||
-- violation fires inside manifest validation, so the mod never enters
|
||||
-- loader.mods at all.
|
||||
local badManifest = T.sdk.loadMods({ "mods/log_bad" }, { fs = T.sdk.memfs({
|
||||
["mods/log_bad/manifest.json"] = manifest("log_bad",
|
||||
', "log_url": "https://logs.example.com/logs"'),
|
||||
["mods/log_bad/main.lua"] = "local mod = ...",
|
||||
}) })
|
||||
T.eq(badManifest.mods.log_bad, nil,
|
||||
"log_url without network: the mod is refused before load")
|
||||
|
||||
-- a non-https log_url is refused even with the permission
|
||||
local httpManifest = T.sdk.loadMods({ "mods/log_http" }, { fs = T.sdk.memfs({
|
||||
["mods/log_http/manifest.json"] = manifest("log_http",
|
||||
', "permissions": ["network"], "log_url": "http://logs.example.com/logs"'),
|
||||
["mods/log_http/main.lua"] = "local mod = ...",
|
||||
}) })
|
||||
T.eq(httpManifest.mods.log_http, nil,
|
||||
"an http log_url: the mod is refused before load")
|
||||
|
||||
-- an api 1 manifest carries no strict surface: log_url is ignored, and the
|
||||
-- mod loads (its postLog call still refuses -- there is no log_url to use)
|
||||
local api1 = T.sdk.loadMods({ "mods/log_api1" }, { fs = T.sdk.memfs({
|
||||
["mods/log_api1/manifest.json"] = [[{
|
||||
"id": "log_api1", "name": "T", "version": "1.0.0", "entry": "main.lua",
|
||||
"api": 1, "log_url": "https://logs.example.com/logs"
|
||||
}]],
|
||||
["mods/log_api1/main.lua"] = "local mod = ...",
|
||||
}) })
|
||||
T.eq(#api1.errors, 0, "an api 1 manifest ignores log_url ("
|
||||
.. tostring(api1.errors[1]) .. ")")
|
||||
T.check(api1.loader.mods.log_api1 ~= nil, "and the mod loads")
|
||||
|
||||
T.finish("mod_postlog")
|
||||
+209
-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,89 @@ 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")
|
||||
-- tls* is forwarded from the real love.system when the engine hung it
|
||||
-- (Gen1Tls / Android JNI); without that it's just nil, not an error.
|
||||
out.tlsOpenType = type(love.system.tlsOpen)
|
||||
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)
|
||||
@@ -95,58 +154,123 @@ local FILES = {
|
||||
["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")
|
||||
T.check(out.tlsOpenType == "function" or out.tlsOpenType == "nil",
|
||||
"tls* is readable through the system shim (nil until the engine hangs it)")
|
||||
|
||||
-- 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")
|
||||
@@ -187,6 +311,36 @@ 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",
|
||||
@@ -226,11 +380,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
|
||||
|
||||
@@ -348,5 +348,50 @@ do
|
||||
"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
|
||||
|
||||
-- Gold's cache has no text_pointers / trainer_headers / field. Data:load
|
||||
-- used to throw in seedDefaults (self.field.boot) after filling pokemon
|
||||
-- with provenance scalars. That is the Android first-Edit CTD: the APK
|
||||
-- cannot fall back to Red's source-tree copies the way a desktop checkout
|
||||
-- can.
|
||||
do
|
||||
GameVersion.set("gold")
|
||||
local Data = require("src.core.Data")
|
||||
Data.constants = {}
|
||||
Data.pokemon = { generation = 2, CYNDAQUIL = { dex = 155 } }
|
||||
Data.maps = {}
|
||||
Data.field = nil
|
||||
Data.trainer_headers = nil
|
||||
local ok, err = pcall(function() Data:seedDefaults() end)
|
||||
check(ok, "gold seedDefaults survives a Gold-shaped cache: " .. tostring(err))
|
||||
check(type(Data.field) == "table", "seedDefaults creates field when Gold omitted it")
|
||||
eq(Data.constants.dexSize, 155, "dexSize ignores pokemon.generation scalar")
|
||||
GameVersion.set("red")
|
||||
end
|
||||
|
||||
print(string.format("save editor gen2 tests: %d passed, %d failed", passed, failed))
|
||||
if failed > 0 then os.exit(1) end
|
||||
|
||||
@@ -146,7 +146,7 @@ function App.load(pathOverride, opts)
|
||||
-- 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
|
||||
if not mods then
|
||||
if not mods or App.dataVersion ~= opts.version then
|
||||
-- One loader per editor session. A previous session leaves Data holding
|
||||
-- that session's merged registries (and possibly the other game's cache),
|
||||
-- and a second builtin registration over them collides -- "statuses
|
||||
|
||||
@@ -40,10 +40,11 @@ end
|
||||
|
||||
local function shellListLua(dir)
|
||||
local out = {}
|
||||
if not (io and io.popen) then return out end
|
||||
if package.config:sub(1, 1) == "\\" then
|
||||
-- cmd has no ls; dir /b prints bare names, so re-attach the directory
|
||||
local p = io.popen(string.format('dir /b "%s\\*.lua" 2>nul', dir))
|
||||
if p then
|
||||
local ok, p = pcall(io.popen, string.format('dir /b "%s\\*.lua" 2>nul', dir))
|
||||
if ok and p then
|
||||
for line in p:lines() do
|
||||
if line ~= "" then table.insert(out, dir .. "/" .. line) end
|
||||
end
|
||||
@@ -51,8 +52,8 @@ local function shellListLua(dir)
|
||||
end
|
||||
return out
|
||||
end
|
||||
local p = io.popen(string.format('ls "%s"/*.lua 2>/dev/null', dir))
|
||||
if p then
|
||||
local ok, p = pcall(io.popen, string.format('ls "%s"/*.lua 2>/dev/null', dir))
|
||||
if ok and p then
|
||||
for line in p:lines() do
|
||||
table.insert(out, line)
|
||||
end
|
||||
@@ -64,8 +65,8 @@ end
|
||||
local function readText(path)
|
||||
local fs = love and love.filesystem
|
||||
if fs and fs.read and fs.getInfo and fs.getInfo(path) then
|
||||
local body = fs.read(path)
|
||||
if body then return body end
|
||||
local ok, body = pcall(fs.read, path)
|
||||
if ok and body then return body end
|
||||
end
|
||||
local f = io.open(path, "r")
|
||||
if not f then return nil end
|
||||
@@ -78,7 +79,7 @@ end
|
||||
-- scripts show up beside the vanilla EVENT_ ones
|
||||
function Catalog.scrapeEvents(scriptDir, headerPath, listFiles, extraDirs)
|
||||
listFiles = listFiles or function(dir)
|
||||
return loveListLua(dir) or shellListLua(dir)
|
||||
return loveListLua(dir) or shellListLua(dir) or {}
|
||||
end
|
||||
|
||||
local found = {}
|
||||
@@ -97,7 +98,8 @@ function Catalog.scrapeEvents(scriptDir, headerPath, listFiles, extraDirs)
|
||||
dirs[#dirs + 1] = dir
|
||||
end
|
||||
for _, dir in ipairs(dirs) do
|
||||
for _, path in ipairs(listFiles(dir)) do
|
||||
local files = listFiles(dir) or {}
|
||||
for _, path in ipairs(files) do
|
||||
local body = readText(path)
|
||||
if body then eat(body) end
|
||||
end
|
||||
|
||||
@@ -78,15 +78,28 @@ function Gen.bindGoldData(data)
|
||||
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
|
||||
|
||||
local loadGen = function(rel)
|
||||
local CacheFs = require("src.import.CacheFs")
|
||||
local bytes = CacheFs.readActive("data/generated/" .. rel .. ".lua")
|
||||
if type(bytes) == "string" then
|
||||
local chunk = loadstring(bytes, "@gold/data/generated/" .. rel .. ".lua")
|
||||
if chunk then
|
||||
local ok, res = pcall(chunk)
|
||||
if ok and type(res) == "table" then return res end
|
||||
end
|
||||
end
|
||||
elseif data.roofs and data.gen2Roofs == nil then
|
||||
data.gen2Roofs = data.roofs
|
||||
local ok, res = pcall(require, "data.generated." .. rel)
|
||||
if ok and type(res) == "table" then return res end
|
||||
return nil
|
||||
end
|
||||
|
||||
data.gen2Palettes = data.gen2Palettes or loadGen("palettes")
|
||||
data.gen2Icons = data.gen2Icons or loadGen("icons")
|
||||
data.gen2Pokedex = data.gen2Pokedex or loadGen("pokedex")
|
||||
data.gen2Landmarks = data.gen2Landmarks or loadGen("landmarks")
|
||||
data.gen2Roofs = data.gen2Roofs or loadGen("roofs") or data.roofs
|
||||
data.gen2Sprites = data.gen2Sprites or loadGen("sprites")
|
||||
return data
|
||||
end
|
||||
|
||||
@@ -213,10 +226,15 @@ 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
|
||||
if type(save.spawn) == "table" then
|
||||
return save.spawn.map or "PLAYERS_HOUSE_2F", save.spawn.x or 0, save.spawn.y or 0, save.spawn.facing
|
||||
elseif type(save.spawn) == "string" then
|
||||
return save.spawn, 0, 0
|
||||
end
|
||||
return "PLAYERS_HOUSE_2F", 3, 3
|
||||
end
|
||||
local p = save.player or {}
|
||||
return p.map, p.x or 0, p.y or 0
|
||||
return p.map or "REDS_HOUSE_2F", p.x or 0, p.y or 0
|
||||
end
|
||||
|
||||
function Gen.setPlayerHere(save, mapId, x, y, facing)
|
||||
|
||||
Reference in New Issue
Block a user