Compare commits

...

13 Commits

Author SHA1 Message Date
bryanthaboi a66efe207d Merge pull request #1347 from bryanthaboi/dev
bazinga performance and addl hooks
2026-08-15 10:46:39 -04:00
bryanthaboi 5198b35945 bazinga 2026-08-15 10:44:20 -04:00
github-actions 40977337b1 chore(ios): update app-repo.json [skip ci] 2026-08-15 06:31:55 -04:00
bryanthaboi 1598f34954 Merge pull request #1337 from bryanthaboi/dev
legcompat
2026-08-15 06:23:05 -04:00
bryanthaboi 43cbc554c3 legcompat 2026-08-15 06:04:04 -04:00
github-actions 3c3e2c54c5 chore(ios): update app-repo.json [skip ci] 2026-08-14 23:48:42 -04:00
bryanthaboi 00c72c441b Merge pull request #1322 from bryanthaboi/dev 2026-08-14 23:40:02 -04:00
bryanthaboi 7804ef9793 Merge pull request #1323 from 1Jamie/fix/patch-notes-ci-resilience 2026-08-14 23:37:59 -04:00
1jamie 673d8b3ad8 fix(tests): make launcher_patch_notes resilient to release note wording changes 2026-08-14 22:36:24 -05:00
bryanthaboi 62e1296ced Merge pull request #1321 from 1Jamie/fix/gold-save-bug 2026-08-14 23:20:49 -04:00
1jamie a3bbd78e7b Remove androidcrash.log from project root 2026-08-14 22:17:33 -05:00
1jamie 8dfbd1daae fix(save/gen2): auto-recover orphaned save slots and fix PartyMenu field moves
- Auto-recover save slots: Scan saves/<version>/ on boot to re-index orphaned slot files if options.lua is reset or loses its slot registry.
- Fix Gen 2 field moves: Make PartyMenu field actions (Strength, Surf, Cut, Flash) generation-aware so they execute safely in Gold.
- Fortify platform stability: Guard native controller/hidapi init against Android 14 receiver exceptions and ensure safe Windows file replacement.
- Add unit test coverage for slot auto-recovery in save_editor_gen2_tests.lua.
2026-08-14 22:16:17 -05:00
github-actions 4046b28a8c chore(ios): update app-repo.json [skip ci] 2026-08-14 21:53:50 -04:00
26 changed files with 2902 additions and 265 deletions
+39 -12
View File
@@ -246,17 +246,37 @@ real Gold boot.
Your code runs in a sandbox (`src/mods/Sandbox.lua`), not against the
engine's globals. Every chunk you author gets it: `main.lua`, your
`options_schema`, and anything you `load()` yourself. What is absent:
`options_schema`, and anything you `load()` yourself.
| Absent | Use instead |
The globals the sandbox took away are still *reachable*, as compat
stand-ins (`src/mods/LegacyCompat.lua`) that answer with the new API
underneath. A mod written before the sandbox keeps working; it logs one
warning per call it should migrate, and the mod manager lists them. What
each stand-in actually does:
| Pre-sandbox call | What it does now | Migrate to |
| --- | --- | --- |
| `io.open`, `io.lines`, `love.filesystem.read`/`lines`/`newFile` | reads your own shipped files, then your overlay, then `mod.storage` | `mod:read`, `mod.storage` |
| `love.filesystem.write`/`append`, `io.open(…, "w")`, `os.remove`, `os.rename` | writes to a private per-mod overlay under `mod_compat/<your id>/` | `mod.storage` |
| `love.filesystem.getDirectoryItems`/`getInfo` | your own directory plus your overlay | `mod:list`, `mod:info` |
| `love.filesystem.getSaveDirectory` and friends | a virtual root; anything joined to it lands in your overlay | `mod.storage` |
| `os.getenv` | `nil`, except home-like names, which answer with that same virtual root | nothing |
| `love.filesystem.load`, `dofile`, `loadfile` | compiles the chunk into your sandbox | `require`, `mod:read` plus `load` |
| `love.system` | `getOS`/`getPowerInfo`/`getProcessorCount` read through; clipboard and `openURL` do nothing | `mod.device:powerInfo()`, `mod.steps` |
| `love.event` | passes through, except `quit`, which does nothing | `mod.events`, `mod.hooks` |
| `love.mousemoved = fn` and the other callbacks | installs on the real `love` table, the way it always did | `mod.hooks`, `mod.events` |
| `package` | an inert stub, so `package.path = …` does not crash | `require` |
What has no stand-in, because there is nothing honest to reroute it to:
| Still refused | Why |
| --- | --- |
| `io`, and `require("io")` | `mod:read` for your own files, `mod.storage` to persist |
| `os.getenv`, `os.execute`, `os.remove`, `os.rename`, `os.exit` | nothing; `os.time`/`os.date`/`os.clock` still work |
| `package`, `dofile`, `loadfile`, `debug`, `getfenv`, `setfenv` | `require` for the supported engine modules |
| `require("ffi")`, `require("love.*")` | the `love` table you are given |
| `love.filesystem` | `mod.storage` (per-mod, per-playthrough), `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`
+154
View File
@@ -802,3 +802,157 @@ the native side's pending file itself, each permissioned mod receives its
own copy of a delivery, and steps are anchored natively so the same walk
is never delivered twice. Without the permission, `sync` and `poll` raise
an error naming it.
## Background HTTP
`mod.fetch` is how a mod does work off the main thread. It is behind the
`network` permission in `manifest.json`, the same one that gates
`require("socket")`, and the player sees it in the mod manager.
```lua
-- somewhere once
local job = mod.fetch:get("https://example.com/data.json")
-- in a hook or update, every frame -- poll never blocks
if job then
local r = mod.fetch:poll(job)
if r.status ~= "pending" then
if r.status == "ok" then use(r.body) else warn(r.err) end
mod.fetch:release(job)
job = nil
end
end
```
`get(url, opts)` returns an opaque handle, or `nil` plus a reason. `opts`
takes `accept` (a request Accept header) and `maxSeconds` (clamped to 30).
`poll(handle)` returns `{ status, body, err, progress }` where `status` is
`"pending"`, `"ok"`, `"error"` or `"cancelled"`; it is a copy, and it never
blocks, so calling it every frame is the intended use. `release(handle)`
frees a finished job — do it, or you will hit the ceiling. `cancel(handle)`
drops a result you no longer want. `available()` is `false` when the build
has no transport and for mods without the permission, so a probe is safe.
The rules worth knowing before you design around it:
- **http and https only.** The underlying transport also speaks `file://`,
`ftp://` and `scp://`; those are refused, on the initial URL and on any
redirect. `mod.fetch` is not a way to read a local file.
- **Four requests in flight per mod.** The worker pool is shared with the
launcher's own downloads, so one mod cannot fill it. Over the ceiling,
`get` returns `nil` and a reason until you release something.
- **Handles are yours alone.** A handle from another mod, a fabricated
table, or a guessed number all poll as `"error"`.
- **Your mod id is in the User-Agent**, so a server operator can see who is
calling and a mod cannot pose as the launcher.
- Jobs are released when your mod unloads.
This is deliberately not `love.thread`. A LÖVE thread is a fresh Lua state
with a full standard library that the sandbox cannot reach, so handing one
to a mod would undo every other rule; `mod.fetch`'s workers run engine
code, so a mod gets asynchrony without gaining any new reach.
## Background jobs
`mod.fetch` covers work waiting on a server. `mod.job` covers work waiting on
the CPU — generating a map, crunching a table, anything that would otherwise
stall a frame. It is behind the `background` permission in `manifest.json`.
Ship the job as its own file inside your mod:
```lua
-- mods/your_mod/jobs/crunch.lua
local arg = ...
local total = 0
for i = 1, arg.n do total = total + i end
return { total = total }
```
```lua
-- in your entry file
local job = mod.job:run("jobs/crunch.lua", { n = 1e6 })
-- later, in a hook -- poll never blocks
local r = mod.job:poll(job)
if r.status == "ok" then
use(r.result.total)
mod.job:release(job)
end
```
`run(script, arg, opts)` returns an opaque handle, or `nil` plus a reason.
`opts.maxSeconds` sets the job's time budget (default 5, clamped to 30).
`poll(handle)` returns `{ status, result, err }` with `status` one of
`"pending"`, `"ok"`, `"error"` or `"cancelled"`. `release(handle)` frees it.
`available()` is `false` on a host without threads and for mods without the
permission, so a probe is always safe.
**A job is pure compute.** This is the part to design around, not a detail:
- **Plain data in, plain data out.** Numbers, strings, booleans and tables of
them. A function, userdata, a cycle or a table key that is not a string or
number is refused at your `run` call with a reason. Nothing is shared —
your argument is snapshotted, and mutating the original afterwards does not
reach the job.
- **No engine API, no game state, no storage.** `require` is refused inside a
job, and there is no `mod` object. A job cannot read the party, write
`mod.storage`, or touch a registry. Get what it needs into the argument and
act on the result back on the main thread.
- **Your script is a file in your mod folder.** The path goes through the same
rules as `mod:read`; `..`, absolute paths and drive letters are refused.
- **Two jobs per mod, four on the machine.** Over the limit, `run` returns
`nil` and a reason until you release one.
- **The budget bounds how long YOU wait, not how long the work runs.** Past
`maxSeconds`, `poll` reports an error and the result is dropped if it ever
arrives — but the thread runs to its own end. There is no way to stop a
LÖVE thread from outside, and every attempt to stop one from inside was
worse than the disease (a debug hook does not reliably interrupt LuaJIT,
and raising from one wedged the whole process). `cancel(handle)` is the
same deal: it drops the result, it does not stop the work.
So **write jobs that terminate.** A job with an infinite loop will keep one
core busy until the game closes. It will not freeze the game — the main
thread stays responsive and quitting still works — but nothing will reclaim
that core in the meantime.
Your job script runs in the same sandbox your entry file does, so `io`, `os`,
`debug`, `ffi`, `package` and `love.filesystem` are absent there too. That is
the whole reason this exists rather than `love.thread`: a raw LÖVE thread is a
fresh Lua state with a full standard library that the sandbox cannot reach, so
handing one to a mod would undo every other rule. Here the worker builds your
sandbox first and loads your chunk into it.
## Pre-sandbox globals (compat)
A mod written before the sandbox landed does not have to be updated to
load. `io`, `package`, `dofile`, `loadfile`, `os.getenv`, `love.filesystem`,
`love.system` and `love.event` are all present again as compat stand-ins
(`src/mods/LegacyCompat.lua`), and assigning a LÖVE callback
(`love.mousemoved = fn`) installs on the real table the way it always did.
Every stand-in call logs one warning naming its replacement, and
`loader:legacyReport(modId)` returns the same list with call counts, which
is what a "needs updating" badge should read.
The stand-ins are not the old globals. Paths are classified rather than
passed through:
- A path inside your own mod directory reads the file you shipped.
- Anything else, including an absolute path, resolves into a private
per-mod overlay at `mod_compat/<your id>/` under the save directory.
Two mods naming the same path never see each other's bytes, and nothing
is written outside the game tree.
- A read misses through the overlay to your shipped file, then to
`mod.storage`, so a half-migrated mod sees both.
- A write over a path you shipped shadows it; the packaged file is never
modified, and `mod:read` still returns the packaged bytes.
- `love.filesystem.getSaveDirectory()` and `os.getenv("HOME")` answer with
a virtual root, so a legacy mod that joins its own paths lands back in
the same overlay.
`love.thread` stays refused. A LÖVE thread runs in a separate Lua state
with the full standard library, which the sandbox in this state cannot
reach, so a stand-in would be a hole rather than a reroute. The same goes
for `ffi`, `debug`, `setfenv`, `os.execute`, `io.popen`, `love.run` and
`love.errorhandler`. A mod that needs real background work needs an
engine-owned facility, not a compat shim -- for HTTP that facility is
[`mod.fetch`](#modfetch), which runs on the engine's own worker pool.
+2 -1
View File
@@ -19,7 +19,7 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow
* **Soft reset button combination**
* **Keyboard and controller rebinding**
* **Mod profiles** with separate mod settings and save slots
* **Sandboxed mods**: an installed mod can read only its own folder and write only its own storage, so it cannot reach the rest of your device
* **Sandboxed mods**: an installed mod can read only its own folder and write only its own storage, so it cannot reach the rest of your device, and mods that need the internet or heavy background work do it through permissions the mod manager shows you, without freezing the game
* **Improved launcher and save editor UI**, including background downloads and update checks
* **Direct-launch options** for shortcuts, Steam entries, and handheld frontends
* **Custom boot branding**
@@ -40,3 +40,4 @@ A fourth game the launcher can import and play, built from pret/pokegold the sam
* **On-screen touch pad** and controller SELECT for registered items
* **Older mods keep loading** after the sandbox change, through per-mod compat stand-ins for the pre-sandbox globals
+21
View File
@@ -12,6 +12,27 @@
"tintColor": "3b5ca8",
"category": "games",
"versions": [
{
"version": "0.1.91",
"date": "2026-08-15",
"size": 11351477,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.91/gen1recomp++-0.1.91-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
},
{
"version": "0.1.90",
"date": "2026-08-15",
"size": 11344338,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.90/gen1recomp++-0.1.90-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @1Jamie\n- @bryanthaboi"
},
{
"version": "0.1.89",
"date": "2026-08-15",
"size": 11343841,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.89/gen1recomp++-0.1.89-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @1Jamie\n- @anxiousintrovert\n- @AverageConsumer\n- @bryanthaboi"
},
{
"version": "0.1.88",
"date": "2026-08-14",
+4 -2
View File
@@ -325,7 +325,8 @@ function HostShell.httpDownload(url, absPath, userAgent, accept, maxTime)
if type(absPath) ~= "string" or absPath == "" then return nil, "missing path" end
userAgent = userAgent or "gen1recomp"
if HostShell.haveCurl() then
local cmd = ("curl -fsSL --connect-timeout 15 --max-time %d ")
local cmd = ("curl -fsSL --proto =http,https --proto-redir =http,https "
.. "--connect-timeout 15 --max-time %d ")
:format(tonumber(maxTime) or 300)
.. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " "
if accept then
@@ -370,7 +371,8 @@ function HostShell.httpGet(url, userAgent, accept, maxTime)
-- BODY, and on the two services this talks to that body is the whole
-- diagnosis: GitHub's 403 says "API rate limit exceeded for <ip>", which
-- tells a user to wait rather than to go hunting for a broken index.
local cmd = ("curl -sSL --connect-timeout 10 --max-time %d ")
local cmd = ("curl -sSL --proto =http,https --proto-redir =http,https "
.. "--connect-timeout 10 --max-time %d ")
:format(tonumber(maxTime) or 40)
.. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " "
if accept then
+49 -2
View File
@@ -832,9 +832,41 @@ local function tryMigrateLegacy(version, fs)
return id
end
-- Scan the filesystem for orphaned slot files under saves/<version>/ when options.lua
-- has no registered slots for this version (e.g. options.lua was reset or lost).
local function scanDiskSlots(version, fs)
if not fs then return nil end
local dir = "saves/" .. version
local slots = {}
if fs.getDirectoryItems and fs.getInfo and fs.getInfo(dir) then
local items = pcall(fs.getDirectoryItems, dir) and fs.getDirectoryItems(dir) or {}
local numbers = {}
for _, item in ipairs(items) do
local slotId = item:match("^(slot%d+)%.lua$")
if slotId then
local n = tonumber(slotId:match("%d+"))
table.insert(numbers, { id = slotId, num = n or 0 })
end
end
table.sort(numbers, function(a, b) return a.num < b.num end)
for _, item in ipairs(numbers) do
table.insert(slots, item.id)
end
else
for i = 1, 30 do
local slotId = "slot" .. i
local path = dir .. "/" .. slotId .. ".lua"
if fs.getInfo and fs.getInfo(path) then
table.insert(slots, slotId)
end
end
end
return #slots > 0 and slots or nil
end
-- Resolve (once per version per process) which slot in-game saves use: an
-- existing registry wins; otherwise a lazy legacy migration may create
-- slot1; otherwise false, meaning the flat legacy path.
-- slot1; otherwise auto-recover disk slots; otherwise false (flat legacy path).
local function ensureVersionSlots(version, fs)
if slotsChecked[version] then return end
slotsChecked[version] = true
@@ -848,7 +880,22 @@ local function ensureVersionSlots(version, fs)
activeSlotCache[version] = reg.active or reg.list[1]
return
end
activeSlotCache[version] = tryMigrateLegacy(version, fs) or false
local migrated = tryMigrateLegacy(version, fs)
if migrated then
activeSlotCache[version] = migrated
return
end
-- Auto-recovery: if options.lua lost its slot registry, scan disk for orphaned slot files
local recovered = scanDiskSlots(version, fs)
if recovered and #recovered > 0 then
opts.saveSlots = opts.saveSlots or {}
opts.saveSlots[version] = { list = recovered, active = recovered[1] }
SaveData.saveOptions(opts, fs)
activeSlotCache[version] = recovered[1]
Logger.info("auto-recovered %d save slot(s) for %s from disk", #recovered, version)
return
end
activeSlotCache[version] = false
end
-- (body for the forward-declared saveNames.) Resolves the ACTIVE slot for
+50
View File
@@ -0,0 +1,50 @@
-- ROM extraction, off the main thread. The extractor's require closure needs
-- only love.filesystem, love.image, love.math and love.system, none of which
-- are main-thread-only, so it runs here instead of as a coroutine the frame
-- loop resumed for 8ms out of every 16.7ms.
--
-- The caller clears the stale cache and writes the completion marker itself;
-- this only fills the tree between those two steps, so the "marker appears
-- last" order isReady() depends on stays on one thread.
require("love.filesystem")
require("love.image")
require("love.math")
require("love.system")
require("love.timer")
local version, prefix, romData, progressName, resultName = ...
local progressChannel = love.thread.getChannel(progressName)
local resultChannel = love.thread.getChannel(resultName)
-- RomExtractor:tick fires per item, thousands of times per import; a channel
-- push each would cost more than the work it reports.
local PROGRESS_HZ = 20
local ok, err = pcall(function()
local CacheFs = require("src.import.CacheFs")
CacheFs.prefix = prefix
local manifest = require("src.import.RomManifest").decode(version)
local RomExtractor = version == "gold"
and require("src.import.RomExtractorGen2")
or require("src.import.RomExtractor")
local lastPush, lastStage = 0, nil
local extractor = RomExtractor.new(romData, manifest,
function(progress, total, stage, current, stageTotal)
local now = love.timer.getTime()
-- Stage changes always go through, or the caption goes stale.
if stage ~= lastStage or now - lastPush >= 1 / PROGRESS_HZ then
lastPush, lastStage = now, stage
progressChannel:push({
progress = progress, total = total, stage = stage,
current = current, stageTotal = stageTotal,
})
end
end)
extractor:run()
end)
resultChannel:push({ ok = ok, error = ok and nil or tostring(err) })
+200 -83
View File
@@ -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
View File
@@ -316,18 +316,6 @@ function RomImporter.isReady(version)
end
-- Load the import manifest for a version and confirm it matches that ROM.
local function decodeManifest(version)
local path = GameVersion.info(version).manifest
local raw, readError = love.filesystem.read(path)
if not raw then error("ROM import metadata is missing: " .. tostring(readError)) end
local Json = require("src.link.Json")
local manifest, decodeError = Json.decode(raw)
if not manifest then error("ROM import metadata is invalid: " .. tostring(decodeError)) end
assert(manifest.romSha1 == GameVersion.info(version).sha1,
"ROM import metadata version mismatch")
return manifest
end
local function sha1(data)
local digest = love.data.hash("sha1", data)
if type(digest) == "userdata" and digest.getString then
@@ -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
+25
View File
@@ -0,0 +1,25 @@
-- The per-version import metadata (symbol table + ROM hash) that drives
-- RomExtractor. Split out of RomImporter so the extraction worker thread can
-- decode it itself: shipping the decoded table across a love.thread channel
-- would deep-copy every symbol for nothing.
local GameVersion = require("src.core.GameVersion")
local RomManifest = {}
function RomManifest.decode(version)
local info = GameVersion.info(version)
local raw, readError = love.filesystem.read(info.manifest)
if not raw then
error("ROM import metadata is missing: " .. tostring(readError))
end
local Json = require("src.link.Json")
local manifest, decodeError = Json.decode(raw)
if not manifest then
error("ROM import metadata is invalid: " .. tostring(decodeError))
end
assert(manifest.romSha1 == info.sha1, "ROM import metadata version mismatch")
return manifest
end
return RomManifest
+209
View File
@@ -0,0 +1,209 @@
-- Background compute for sandboxed mods, behind the "background" permission.
--
-- mod.fetch covers work that is waiting on a server. This covers work that is
-- waiting on the CPU: a mod hands over a script from its own folder plus a
-- table of plain data, and gets the return value back through the same
-- handle/poll/release shape mod.fetch uses.
--
-- The worker (src/mods/job_worker.lua) builds the SAME Sandbox.envFor
-- environment the main thread does before it loads the mod's chunk, so this
-- is not the love.thread hole reopened: the mod's code still cannot see
-- io, os, debug, ffi, package or love.filesystem, and require is refused
-- outright inside a job.
--
-- One thread per job rather than a pool. A pooled state would carry one
-- mod's globals into the next mod's job, and resetting it properly is the
-- same work as making a new one.
local SafePath = require("src.mods.SafePath")
local Job = {}
Job.MAX_INFLIGHT = 2 -- per mod
Job.MAX_GLOBAL = 4 -- across all mods, so jobs cannot eat every core
Job.DEFAULT_SECONDS = 5
Job.MAX_SECONDS = 30
-- Depth cap on the data crossing the channel. A cycle is caught by the seen
-- set; this catches the merely absurd.
Job.MAX_DEPTH = 16
local nextId = 0
local liveGlobal = 0
-- Only plain data crosses a thread boundary: a function or userdata cannot be
-- serialised, and letting one through would fail deep inside LÖVE instead of
-- at the call the mod made.
local function plain(value, depth, seen)
local t = type(value)
if t == "nil" or t == "boolean" or t == "number" or t == "string" then
return value
end
if t ~= "table" then
return nil, ("a job cannot carry a %s, only plain data"):format(t)
end
depth = (depth or 0) + 1
if depth > Job.MAX_DEPTH then
return nil, "a job's data is nested too deeply"
end
seen = seen or {}
if seen[value] then return nil, "a job cannot carry a cycle" end
seen[value] = true
local out = {}
for k, v in pairs(value) do
local kt = type(k)
if kt ~= "string" and kt ~= "number" then
return nil, ("a job cannot carry a %s key"):format(kt)
end
local copied, err = plain(v, depth, seen)
if err then return nil, err end
out[k] = copied
end
seen[value] = nil
return out
end
Job.plain = plain
function Job.available()
return (love and love.thread and love.thread.newThread) ~= nil
end
local function bucket(loader, modId)
loader.jobs = loader.jobs or {}
local b = loader.jobs[modId]
if not b then b = {}; loader.jobs[modId] = b end
return b
end
local function inflight(b)
local n = 0
for _, job in pairs(b) do
if job.status == "pending" then n = n + 1 end
end
return n
end
-- `script` is relative to the mod's own folder, and goes through the same
-- SafePath rules mod:read does -- a job is not a way to name a path.
-- Argument checks come BEFORE the host check: a bad path or an unserialisable
-- argument is the mod author's bug and should read the same on every machine,
-- not be masked into "unavailable" on a build without threads.
function Job.run(loader, modId, modPath, script, arg, opts)
if type(script) ~= "string" or script == "" then
return nil, "a job needs a script path inside your mod"
end
-- SafePath.require raises rather than returning, so the mod's bad path
-- comes back as a value here instead of unwinding its caller.
local okPath, safe = pcall(SafePath.join, modPath, script, "a job script")
if not okPath then return nil, tostring(safe) end
local payload, dataErr = plain(arg)
if dataErr then return nil, dataErr end
if not Job.available() then return nil, "background jobs are unavailable" end
local b = bucket(loader, modId)
if inflight(b) >= Job.MAX_INFLIGHT then
return nil, ("too many jobs in flight (limit %d); poll and release the "
.. "ones you have"):format(Job.MAX_INFLIGHT)
end
if liveGlobal >= Job.MAX_GLOBAL then
return nil, "the machine is already running as many jobs as it will"
end
opts = type(opts) == "table" and opts or {}
local seconds = tonumber(opts.maxSeconds) or Job.DEFAULT_SECONDS
if seconds > Job.MAX_SECONDS then seconds = Job.MAX_SECONDS end
if seconds < 1 then seconds = 1 end
nextId = nextId + 1
local argName = "modjob_arg_" .. nextId
local resultName = "modjob_result_" .. nextId
local argCh = love.thread.getChannel(argName)
local resCh = love.thread.getChannel(resultName)
argCh:clear()
resCh:clear()
argCh:push(payload == nil and false or payload)
local okNew, thread = pcall(love.thread.newThread, "src/mods/job_worker.lua")
if not okNew or not thread then return nil, "could not start a job thread" end
local Json = require("src.link.Json")
local permissions = select(2, pcall(Json.encode,
loader.mods and loader.mods[modId]
and loader.mods[modId].manifest.permissionSet or {})) or "{}"
local started = pcall(thread.start, thread, modId, safe, argName, resultName,
permissions)
if not started then return nil, "could not start a job thread" end
liveGlobal = liveGlobal + 1
local handle = {}
b[handle] = { thread = thread, resultCh = resCh, status = "pending",
deadline = love.timer.getTime() + seconds, seconds = seconds }
return handle
end
local function settle(job, status, value, err)
if job.status == "pending" then liveGlobal = math.max(0, liveGlobal - 1) end
job.status, job.value, job.err = status, value, err
end
function Job.poll(loader, modId, handle)
local job = bucket(loader, modId)[handle]
if not job then return { status = "error", err = "unknown job" } end
if job.status == "pending" then
local msg = job.resultCh:pop()
if msg then
if msg.ok then settle(job, "ok", msg.result)
else settle(job, "error", nil, msg.err) end
else
-- A worker that died before pushing anything (an error outside its own
-- pcall) would otherwise leave the mod polling forever.
local threadErr = job.thread.getError and job.thread:getError()
if threadErr then
settle(job, "error", nil, tostring(threadErr))
elseif love.timer.getTime() > job.deadline then
-- The budget bounds how long the MOD waits, not how long the work
-- runs: there is no way to stop a LÖVE thread, and every in-worker
-- attempt made things worse (see job_worker.lua). A job that
-- overruns is reported here and its result dropped if it ever lands.
settle(job, "error", nil, ("job exceeded its %gs budget")
:format(job.seconds))
end
end
end
if job.status == "ok" then
-- A copy, so a mod cannot edit what a later poll returns.
return { status = "ok", result = (plain(job.value)) }
end
return { status = job.status, err = job.err }
end
function Job.release(loader, modId, handle)
local b = bucket(loader, modId)
local job = b[handle]
if not job then return false end
if job.status == "pending" then liveGlobal = math.max(0, liveGlobal - 1) end
b[handle] = nil
return true
end
-- There is no way to kill a LÖVE thread, so cancelling drops the result
-- rather than stopping the work; the worker's own time budget is what bounds
-- how long an abandoned job can run.
function Job.cancel(loader, modId, handle)
local job = bucket(loader, modId)[handle]
if not job then return false end
if job.status == "pending" then
settle(job, "cancelled")
end
return true
end
function Job.releaseAll(loader, modId)
local b = loader.jobs and loader.jobs[modId]
if not b then return end
for handle, job in pairs(b) do
if job.status == "pending" then liveGlobal = math.max(0, liveGlobal - 1) end
b[handle] = nil
end
loader.jobs[modId] = nil
end
return Job
+943
View File
@@ -0,0 +1,943 @@
local Logger = require("src.core.Logger")
local SafePath = require("src.mods.SafePath")
local okSave, SaveData = pcall(require, "src.core.SaveData")
if not okSave then SaveData = nil end
local okStorage, Storage = pcall(require, "src.mods.Storage")
if not okStorage then Storage = nil end
local LegacyCompat = {}
local ROOT = "mod_compat"
local OWN = "_own"
local MAX_SEGMENTS = 24
LegacyCompat.reports = {}
function LegacyCompat.reset()
LegacyCompat.reports = {}
end
function LegacyCompat.report(modId)
if modId then
local entry = LegacyCompat.reports[modId]
return entry and entry.order or {}
end
local out = {}
for id, entry in pairs(LegacyCompat.reports) do
out[id] = entry.order
end
return out
end
local function note(ctx, call, advice)
local entry = LegacyCompat.reports[ctx.modId]
if not entry then
entry = { calls = {}, order = {} }
LegacyCompat.reports[ctx.modId] = entry
end
local row = entry.calls[call]
if row then
row.count = row.count + 1
return
end
row = { call = call, advice = advice, count = 1 }
entry.calls[call] = row
entry.order[#entry.order + 1] = row
Logger.warn("[%s] %s was removed from the mod sandbox; %s", ctx.modId, call,
advice)
end
local function refuse(ctx, call, advice)
note(ctx, call, advice)
return nil, ("%s is not available to mods; %s"):format(call, advice)
end
-- ------- path routing
local function normalize(path)
if type(path) ~= "string" or path == "" then return nil end
path = path:gsub("\\", "/")
while path:find("//", 1, true) do path = (path:gsub("//", "/")) end
if path ~= "/" then path = (path:gsub("/$", "")) end
return path
end
local function flatten(path)
local parts = {}
for segment in path:gmatch("[^/]+") do
if segment ~= "." and segment ~= ".." then
parts[#parts + 1] = (segment:gsub("[^%w_%.%-]", "_"))
end
end
if #parts == 0 then return nil end
while #parts > MAX_SEGMENTS do table.remove(parts, 1) end
return table.concat(parts, "/")
end
local function storageKey(key)
local parts = {}
for segment in key:gmatch("[^/]+") do parts[#parts + 1] = segment end
if #parts == 0 then return nil end
parts[#parts] = (parts[#parts]:gsub("%.[^%.]*$", ""))
for i = 1, #parts do
local cleaned = (parts[i]:gsub("[^%w_%-]", "_"))
if cleaned == "" then return nil end
parts[i] = cleaned
end
return table.concat(parts, "/")
end
local function ownRelative(ctx, path)
if not ctx.modPath then return nil end
if path == ctx.modPath then return "" end
if path:sub(1, #ctx.modPath + 1) == ctx.modPath .. "/" then
return path:sub(#ctx.modPath + 2)
end
return nil
end
local function ownFull(ctx, rel)
if rel == "" then return ctx.modPath end
local safe = SafePath.safe(rel)
if not safe then return nil end
return ctx.modPath .. "/" .. safe
end
local function ownExists(ctx, rel)
local full = ownFull(ctx, rel)
local fs = ctx.fs
if not (full and fs and fs.getInfo) then return nil end
return fs.getInfo(full)
end
local function classify(ctx, path)
path = normalize(path)
if not path then return nil end
local root = ctx.virtualRoot
if path == root then return { key = "", dir = true } end
if path:sub(1, #root + 1) == root .. "/" then
return { key = flatten(path:sub(#root + 2)) }
end
local rel = ownRelative(ctx, path)
if not rel and path:sub(1, 1) ~= "/" and not path:match("^%a:")
and ownExists(ctx, path) then
rel = path
end
if rel then
if rel == "" then return { key = OWN, rel = "", dir = true } end
local flat = flatten(rel)
return { key = flat and (OWN .. "/" .. flat) or nil, rel = rel }
end
return { key = flatten(path) }
end
-- ------- the overlay
local function persistFs(ctx)
if SaveData and SaveData.persistenceFs then
return SaveData.persistenceFs(ctx.fs)
end
return ctx.fs
end
local function overlayPath(ctx, key)
if key == nil or key == "" then return ROOT .. "/" .. ctx.modId end
return ROOT .. "/" .. ctx.modId .. "/" .. key
end
local function ensureParent(fs, path)
if not fs.createDirectory then return end
local dir = path:match("^(.*)/[^/]+$")
if not dir then return end
local built = nil
for segment in dir:gmatch("[^/]+") do
built = built and (built .. "/" .. segment) or segment
fs.createDirectory(built)
end
end
local function overlayRead(ctx, key)
local fs = persistFs(ctx)
if not (fs and fs.read and fs.getInfo) then return nil end
local path = overlayPath(ctx, key)
local info = fs.getInfo(path)
if not info or info.type == "directory" then return nil end
local body = fs.read(path)
if type(body) ~= "string" then return nil end
return body
end
local function overlayWrite(ctx, key, data)
local fs = persistFs(ctx)
if not (fs and fs.write) then
return false, "no writable filesystem is available"
end
local path = overlayPath(ctx, key)
ensureParent(fs, path)
local ok, err = fs.write(path, data)
if ok == false then return false, err or "write failed" end
return true
end
local function overlayRemove(ctx, key)
local fs = persistFs(ctx)
if not (fs and fs.remove and fs.getInfo) then return false end
local path = overlayPath(ctx, key)
if not fs.getInfo(path) then return false end
fs.remove(path)
return true
end
local function overlayInfo(ctx, key)
local fs = persistFs(ctx)
if not (fs and fs.getInfo) then return nil end
return fs.getInfo(overlayPath(ctx, key))
end
local function storageRead(ctx, key)
if not (Storage and key and key ~= "") then return nil end
if key:sub(1, #OWN + 1) == OWN .. "/" then return nil end
local game = ctx.game and ctx.game()
if not game then return nil end
local sk = storageKey(key)
if not sk then return nil end
ctx.storage = ctx.storage or Storage.new(ctx.modId, ctx.fs)
local ok, bytes = pcall(ctx.storage.readBytes, ctx.storage, game, sk)
if ok and type(bytes) == "string" then return bytes end
return nil
end
local function readPath(ctx, path)
local at = classify(ctx, path)
if not at then return nil, "invalid path" end
local body = at.key and overlayRead(ctx, at.key)
if body then return body end
if at.rel then
local full = ownFull(ctx, at.rel)
local fs = ctx.fs
if full and fs and fs.read then
local packaged = fs.read(full)
if type(packaged) == "string" then return packaged end
end
end
body = at.key and storageRead(ctx, at.key)
if body then return body end
return nil, "could not open " .. tostring(path)
end
local function writePath(ctx, path, data)
local at = classify(ctx, path)
if not (at and at.key) then return false, "invalid path" end
return overlayWrite(ctx, at.key, data)
end
local function infoPath(ctx, path)
local at = classify(ctx, path)
if not at then return nil end
if at.key then
local info = overlayInfo(ctx, at.key)
if info then
return { type = info.type, size = info.size, modtime = info.modtime }
end
end
if at.rel then
local info = ownExists(ctx, at.rel)
if info then
return { type = info.type, size = info.size, modtime = info.modtime }
end
end
local stored = at.key and storageRead(ctx, at.key)
if stored then return { type = "file", size = #stored } end
return nil
end
local function listPath(ctx, path)
local at = classify(ctx, path)
local seen, out = {}, {}
local function add(name)
if name and name ~= "" and not seen[name] then
seen[name] = true
out[#out + 1] = name
end
end
if at and at.rel then
local full = ownFull(ctx, at.rel)
local fs = ctx.fs
if full and fs and fs.getDirectoryItems then
for _, name in ipairs(fs.getDirectoryItems(full) or {}) do add(name) end
end
end
if at and at.key then
local fs = persistFs(ctx)
if fs and fs.getDirectoryItems then
for _, name in ipairs(fs.getDirectoryItems(overlayPath(ctx, at.key)) or {}) do
add(name)
end
end
end
table.sort(out)
return out
end
-- ------- buffered file handles
local function openBuffer(ctx, path, mode)
mode = tostring(mode or "r"):gsub("b", "")
local writable = mode:find("[wa+]") ~= nil
local body
if mode:sub(1, 1) == "w" then
body = ""
else
body = readPath(ctx, path)
if not body then
if not writable then return nil, "could not open " .. tostring(path) end
body = ""
end
end
local state = {
ctx = ctx, path = path, buf = body, pos = 1,
writable = writable, closed = false, dirty = false,
}
if mode:sub(1, 1) == "a" then state.pos = #body + 1 end
return state
end
local function bufferFlush(state)
if not (state.writable and state.dirty) then return true end
local ok, err = writePath(state.ctx, state.path, state.buf)
if ok then state.dirty = false end
return ok, err
end
local function bufferWrite(state, text)
if not state.writable then return false, "file is not open for writing" end
local head = state.buf:sub(1, state.pos - 1)
if #head < state.pos - 1 then head = head .. string.rep("\0", state.pos - 1 - #head) end
local tail = state.buf:sub(state.pos + #text)
state.buf = head .. text .. tail
state.pos = state.pos + #text
state.dirty = true
return true
end
local function bufferRead(state, fmt)
if fmt == nil then fmt = "*l" end
if type(fmt) == "number" then
if fmt == 0 then return state.pos <= #state.buf and "" or nil end
if state.pos > #state.buf then return nil end
local chunk = state.buf:sub(state.pos, state.pos + fmt - 1)
state.pos = state.pos + #chunk
return chunk
end
fmt = tostring(fmt):gsub("^%*", "")
if fmt == "a" then
local rest = state.buf:sub(state.pos)
state.pos = #state.buf + 1
return rest
end
if fmt == "l" or fmt == "L" then
if state.pos > #state.buf then return nil end
local nl = state.buf:find("\n", state.pos, true)
local line
if nl then
line = state.buf:sub(state.pos, fmt == "L" and nl or nl - 1)
state.pos = nl + 1
else
line = state.buf:sub(state.pos)
state.pos = #state.buf + 1
end
return line
end
if fmt == "n" then
local rest = state.buf:sub(state.pos)
local text, after = rest:match("^%s*(%-?%d+%.?%d*[eE]?[-+]?%d*)()")
if not text then return nil end
state.pos = state.pos + after - 1
return tonumber(text)
end
return nil
end
local function bufferSeek(state, whence, offset)
whence = whence or "cur"
offset = offset or 0
if whence == "set" then state.pos = offset + 1
elseif whence == "cur" then state.pos = state.pos + offset
elseif whence == "end" then state.pos = #state.buf + offset + 1
else return nil, "bad seek base" end
if state.pos < 1 then state.pos = 1 end
return state.pos - 1
end
local function bufferClose(state)
if state.closed then return true end
local ok, err = bufferFlush(state)
state.closed = true
return ok, err
end
local function ioFile(ctx, path, mode)
local state, err = openBuffer(ctx, path, mode)
if not state then return nil, err end
local file = {}
function file:read(...)
local count = select("#", ...)
if count == 0 then return bufferRead(state, "*l") end
local out = {}
for i = 1, count do out[i] = bufferRead(state, (select(i, ...))) end
return unpack(out, 1, count)
end
function file:write(...)
for i = 1, select("#", ...) do
local value = select(i, ...)
local ok, writeErr = bufferWrite(state, tostring(value))
if not ok then return nil, writeErr end
end
return file
end
function file:lines(fmt)
return function() return bufferRead(state, fmt or "*l") end
end
function file:seek(whence, offset) return bufferSeek(state, whence, offset) end
function file:flush() bufferFlush(state) return file end
function file:close() return bufferClose(state) end
function file:setvbuf() return true end
return file
end
local function loveFile(ctx, path, mode)
local state = nil
local file = {}
function file:open(openMode)
local opened, err = openBuffer(ctx, path, openMode or mode or "r")
if not opened then return false, err end
state = opened
return true
end
function file:isOpen() return state ~= nil and not state.closed end
function file:read(bytes)
if not state and not file:open("r") then return nil, 0 end
local body = bytes and bufferRead(state, bytes) or bufferRead(state, "*a")
if not body then return nil, 0 end
return body, #body
end
function file:write(data, size)
if not state and not file:open(mode or "w") then return false end
data = tostring(data)
if size then data = data:sub(1, size) end
local ok, err = bufferWrite(state, data)
if not ok then return false, err end
bufferFlush(state)
return true
end
function file:lines()
if not state then file:open("r") end
return function() return state and bufferRead(state, "*l") or nil end
end
function file:seek(offset)
if not state then return false end
bufferSeek(state, "set", offset or 0)
return true
end
function file:tell() return state and (state.pos - 1) or 0 end
function file:getSize()
if state then return #state.buf end
local body = readPath(ctx, path)
return body and #body or 0
end
function file:flush() if state then bufferFlush(state) end return true end
function file:close()
if state then bufferClose(state) end
state = nil
return true
end
function file:getFilename() return path end
function file:getMode() return mode or "c" end
function file:setBuffer() return true end
if mode and mode ~= "c" then file:open(mode) end
return file
end
-- ------- the love.filesystem stand-in
local function realFilesystem()
return _G.love and _G.love.filesystem or nil
end
local function filesystemShim(ctx)
local fsShim = {}
local function readAdvice() return "reads now come from mod:read and mod.storage" end
function fsShim.read(a, b, c)
local path, size = a, b
if (a == "string" or a == "data") and type(b) == "string" then
path, size = b, c
end
note(ctx, "love.filesystem.read", readAdvice())
local body, err = readPath(ctx, path)
if not body then return nil, err end
if size and size >= 0 then body = body:sub(1, size) end
return body, #body
end
function fsShim.write(path, data, size)
note(ctx, "love.filesystem.write",
"writes are rerouted to this mod's private compat storage; migrate to mod.storage")
data = tostring(data)
if size then data = data:sub(1, size) end
local ok, err = writePath(ctx, path, data)
if not ok then return false, err end
return true
end
function fsShim.append(path, data, size)
note(ctx, "love.filesystem.append",
"writes are rerouted to this mod's private compat storage; migrate to mod.storage")
data = tostring(data)
if size then data = data:sub(1, size) end
local existing = readPath(ctx, path) or ""
local ok, err = writePath(ctx, path, existing .. data)
if not ok then return false, err end
return true
end
function fsShim.lines(path)
note(ctx, "love.filesystem.lines", readAdvice())
local body = readPath(ctx, path)
if not body then error("could not open " .. tostring(path), 2) end
local pos = 1
return function()
if pos > #body then return nil end
local nl = body:find("\n", pos, true)
local line
if nl then
line = body:sub(pos, nl - 1)
pos = nl + 1
else
line = body:sub(pos)
pos = #body + 1
end
return line
end
end
function fsShim.load(path)
note(ctx, "love.filesystem.load",
"the chunk is compiled into this mod's sandbox; prefer require or mod:read plus load")
local body = readPath(ctx, path)
if not body then return nil, "could not open " .. tostring(path) end
if not ctx.compile then return nil, "no sandbox is bound yet" end
return ctx.compile(body, "@" .. tostring(path))
end
function fsShim.getInfo(path, a, b)
local info = infoPath(ctx, path)
local filter = type(a) == "string" and a or nil
local into = type(a) == "table" and a or (type(b) == "table" and b or nil)
if not info then return nil end
if filter and info.type ~= filter then return nil end
if into then
into.type, into.size, into.modtime = info.type, info.size, info.modtime
return into
end
return info
end
function fsShim.getDirectoryItems(path)
note(ctx, "love.filesystem.getDirectoryItems", "use mod.assets:list")
return listPath(ctx, path)
end
function fsShim.createDirectory(path)
local at = classify(ctx, path)
if not (at and at.key) then return false end
local fs = persistFs(ctx)
if not (fs and fs.createDirectory) then return false end
ensureParent(fs, overlayPath(ctx, at.key) .. "/.")
fs.createDirectory(overlayPath(ctx, at.key))
return true
end
function fsShim.remove(path)
local at = classify(ctx, path)
if not (at and at.key) then return false end
return overlayRemove(ctx, at.key)
end
function fsShim.exists(path) return infoPath(ctx, path) ~= nil end
function fsShim.isFile(path)
local info = infoPath(ctx, path)
return info ~= nil and info.type == "file"
end
function fsShim.isDirectory(path)
local info = infoPath(ctx, path)
return info ~= nil and info.type == "directory"
end
function fsShim.getSize(path)
local info = infoPath(ctx, path)
if not info then return nil, "could not open " .. tostring(path) end
return info.size or 0
end
function fsShim.getLastModified(path)
local info = infoPath(ctx, path)
if not info then return nil, "could not open " .. tostring(path) end
return info.modtime or 0
end
local function virtual(call)
note(ctx, call, "paths are virtual now; everything under the returned root "
.. "lands in this mod's private compat storage")
return ctx.virtualRoot
end
function fsShim.getSaveDirectory() return virtual("love.filesystem.getSaveDirectory") end
function fsShim.getWorkingDirectory() return virtual("love.filesystem.getWorkingDirectory") end
function fsShim.getUserDirectory() return virtual("love.filesystem.getUserDirectory") end
function fsShim.getAppdataDirectory() return virtual("love.filesystem.getAppdataDirectory") end
function fsShim.getSourceBaseDirectory() return virtual("love.filesystem.getSourceBaseDirectory") end
function fsShim.getRealDirectory() return ctx.virtualRoot end
function fsShim.getIdentity() return ctx.modId end
function fsShim.setIdentity()
note(ctx, "love.filesystem.setIdentity",
"a mod cannot repoint the save directory; the call does nothing")
return false
end
function fsShim.getRequirePath() return "" end
function fsShim.setRequirePath() return false end
function fsShim.getCRequirePath() return "" end
function fsShim.setCRequirePath() return false end
function fsShim.mount()
note(ctx, "love.filesystem.mount",
"mounting is refused; ship the files inside your mod and use mod:read")
return false
end
fsShim.unmount = fsShim.mount
function fsShim.newFile(path, mode) return loveFile(ctx, path, mode) end
function fsShim.newFileData(a, b)
local real = realFilesystem()
if type(a) == "string" and b == nil then
note(ctx, "love.filesystem.newFileData", readAdvice())
local body = readPath(ctx, a)
if not body then return nil, "could not open " .. tostring(a) end
if real and real.newFileData then return real.newFileData(body, a) end
return nil, "no filesystem"
end
if real and real.newFileData then return real.newFileData(a, b) end
return nil, "no filesystem"
end
function fsShim.isFused()
local real = realFilesystem()
return real and real.isFused and real.isFused() or false
end
function fsShim.areSymlinksEnabled() return false end
function fsShim.setSymlinksEnabled() return false end
function fsShim.init() return false end
function fsShim.setSource() return false end
return setmetatable(fsShim, { __index = function(_, key)
note(ctx, "love.filesystem." .. tostring(key),
"there is no compat stand-in for it; use mod.storage or mod:read")
return nil
end })
end
-- ------- the rest of the removed surface
local function systemShim(ctx)
local function real() return _G.love and _G.love.system or nil end
return {
getOS = function()
local sys = real()
return sys and sys.getOS and sys.getOS() or "Unknown"
end,
getPowerInfo = function()
local sys = real()
if not (sys and sys.getPowerInfo) then return "unknown", nil, nil end
return sys.getPowerInfo()
end,
getProcessorCount = function()
local sys = real()
return sys and sys.getProcessorCount and sys.getProcessorCount() or 1
end,
getClipboardText = function()
note(ctx, "love.system.getClipboardText",
"clipboard access stays sandboxed; the call returns an empty string")
return ""
end,
setClipboardText = function()
note(ctx, "love.system.setClipboardText",
"clipboard access stays sandboxed; the call does nothing")
return false
end,
openURL = function()
note(ctx, "love.system.openURL",
"launching a URL stays sandboxed; the call does nothing")
return false
end,
vibrate = function(...)
local sys = real()
if sys and sys.vibrate then return sys.vibrate(...) end
return false
end,
}
end
local function eventShim(ctx)
local function real() return _G.love and _G.love.event or nil end
local shim = {
quit = function()
note(ctx, "love.event.quit",
"a mod cannot close the game out from under the player; the call does nothing")
return false
end,
}
shim.push = function(name, ...)
if name == "quit" then return shim.quit() end
local ev = real()
if ev and ev.push then return ev.push(name, ...) end
return false
end
return setmetatable(shim, { __index = function(_, key)
local ev = real()
return ev and ev[key] or nil
end })
end
local function ioShim(ctx)
local stream = {
write = function(self, ...)
for i = 1, select("#", ...) do io.write(tostring((select(i, ...)))) end
return self
end,
flush = function(self) return self end,
close = function() return true end,
read = function() return nil end,
lines = function() return function() return nil end end,
seek = function() return 0 end,
setvbuf = function() return true end,
}
local out = setmetatable({}, { __index = stream })
local shim = {
open = function(path, mode)
note(ctx, "io.open",
"the handle is backed by mod:read and this mod's private compat storage")
local file, err = ioFile(ctx, path, mode)
if not file then return nil, err, 2 end
return file
end,
lines = function(path, fmt)
if path == nil then return function() return nil end end
note(ctx, "io.lines",
"the handle is backed by mod:read and this mod's private compat storage")
local file, err = ioFile(ctx, path, "r")
if not file then error(err, 2) end
return file:lines(fmt)
end,
close = function(file) if file and file.close then return file:close() end return true end,
type = function(file)
if type(file) == "table" and file.read and file.close then return "file" end
return nil
end,
read = function() return nil end,
write = function(...) return out:write(...) end,
input = function() return out end,
output = function() return out end,
stdout = out,
stderr = out,
stdin = setmetatable({}, { __index = stream }),
popen = function()
return refuse(ctx, "io.popen", "spawning a process is refused")
end,
tmpfile = function()
return ioFile(ctx, ctx.virtualRoot .. "/io_tmpfile", "w+")
end,
}
return shim
end
local function osShim(ctx)
local HOME = { HOME = true, APPDATA = true, LOCALAPPDATA = true,
USERPROFILE = true, XDG_DATA_HOME = true,
XDG_CONFIG_HOME = true, TMPDIR = true, TEMP = true, TMP = true }
return {
getenv = function(name)
note(ctx, "os.getenv",
"the environment is hidden; home-like names answer with this mod's virtual root")
if type(name) == "string" and HOME[name:upper()] then
return ctx.virtualRoot
end
return nil
end,
remove = function(path)
note(ctx, "os.remove", "the delete lands in this mod's private compat storage")
local at = classify(ctx, path)
if not (at and at.key and overlayRemove(ctx, at.key)) then
return nil, tostring(path) .. ": no such file"
end
return true
end,
rename = function(from, to)
note(ctx, "os.rename", "the move lands in this mod's private compat storage")
local body = readPath(ctx, from)
if not body then return nil, tostring(from) .. ": no such file" end
local ok, err = writePath(ctx, to, body)
if not ok then return nil, err end
local at = classify(ctx, from)
if at and at.key then overlayRemove(ctx, at.key) end
return true
end,
tmpname = function()
return ctx.virtualRoot .. "/tmp/os_tmpname"
end,
execute = function()
note(ctx, "os.execute", "running a command is refused")
return false
end,
exit = function()
note(ctx, "os.exit",
"a mod cannot end the process; the call does nothing")
return false
end,
setlocale = function() return "C" end,
}
end
local function packageShim(ctx)
local shim = { path = "", cpath = "", preload = {}, loaded = {}, loaders = {} }
return setmetatable(shim, {
__index = function(_, key)
note(ctx, "package." .. tostring(key),
"the module loader is sandboxed; use require for the supported engine modules")
return nil
end,
})
end
-- ------- love callbacks
local CALLBACKS = {
load = true, update = true, draw = true, quit = true, lowmemory = true,
threaderror = true, keypressed = true, keyreleased = true,
textinput = true, textedited = true, mousemoved = true,
mousepressed = true, mousereleased = true, wheelmoved = true,
mousefocus = true, touchpressed = true, touchreleased = true,
touchmoved = true, joystickadded = true, joystickremoved = true,
joystickpressed = true, joystickreleased = true, joystickaxis = true,
joystickhat = true, gamepadpressed = true, gamepadreleased = true,
gamepadaxis = true, focus = true, visible = true, resize = true,
filedropped = true, directorydropped = true, displayrotated = true,
audiodevicechanged = true, localechanged = true,
}
local REFUSED = {
run = "love.run is the engine's fixed-step loop; use mod.hooks and mod.events",
errorhandler = "love.errorhandler is how a crash reaches the player; "
.. "use mod.events",
}
-- ------- assembly
function LegacyCompat.new(opts)
opts = opts or {}
local ctx = {
modId = opts.modId or "mod",
modPath = opts.modPath,
fs = opts.fs,
game = opts.game,
compile = nil,
}
ctx.virtualRoot = "/pokeport/" .. ctx.modId
local filesystem = filesystemShim(ctx)
local io_ = ioShim(ctx)
local system = systemShim(ctx)
local event = eventShim(ctx)
local compat = {
ctx = ctx,
love = { filesystem = filesystem, system = system, event = event },
os = osShim(ctx),
globals = {
io = io_,
package = packageShim(ctx),
loadfile = function(path)
note(ctx, "loadfile",
"the chunk is compiled into this mod's sandbox; prefer require or mod:read")
local body = readPath(ctx, path)
if not body then return nil, "could not open " .. tostring(path) end
if not ctx.compile then return nil, "no sandbox is bound yet" end
return ctx.compile(body, "@" .. tostring(path))
end,
},
modules = {
io = io_,
["love.filesystem"] = filesystem,
["love.system"] = system,
["love.event"] = event,
},
}
compat.globals.dofile = function(path)
local chunk, err = compat.globals.loadfile(path)
if not chunk then error(err or ("could not open " .. tostring(path)), 2) end
return chunk()
end
function compat.module(name)
if type(name) ~= "string" then return nil end
local substitute = compat.modules[name]
if substitute then
note(ctx, ('require("%s")'):format(name),
"it now answers with the compat stand-in, not the real module")
return substitute
end
if name == "os" then
note(ctx, 'require("os")',
"it now answers with the compat stand-in, not the real module")
return compat.osTable
end
return nil
end
-- true when the assignment is a callback chain, which is what a mod
-- wrapping love.mousemoved did before the sandbox; false plus a reason for
-- the two names the engine owns, false alone for a module table.
function compat.assign(key, value)
if REFUSED[key] then
note(ctx, ("love.%s assignment"):format(key), REFUSED[key])
return false, ("[%s] %s"):format(ctx.modId, REFUSED[key])
end
if not CALLBACKS[key] then return false end
if value ~= nil and type(value) ~= "function" then return false end
note(ctx, ("love.%s assignment"):format(key),
"the callback lands on the real love table the way it did before the "
.. "sandbox; prefer mod.hooks and mod.events")
return true
end
function compat.bind(env, compile)
ctx.compile = compile
compat.osTable = env and env.os or nil
end
return compat
end
return LegacyCompat
+63 -1
View File
@@ -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,51 @@ function Loader:_api(mod)
return { available = function() return false end,
sync = refuse, poll = refuse }
end)(),
-- Background HTTP, behind the "network" permission the player already
-- sees. This is what love.thread is NOT: the worker runs engine code in
-- an engine-owned pool, so a mod gets asynchrony without getting a Lua
-- state the sandbox cannot reach. get() hands back an opaque handle;
-- poll() is non-blocking, so nothing here can hang a frame.
fetch = (function()
if mod.manifest.permissionSet.network then
return {
available = function() return Net.available() end,
get = function(_, url, opts) return Net.get(loader, modId, url, opts) end,
poll = function(_, handle) return Net.poll(loader, modId, handle) end,
release = function(_, handle) return Net.release(loader, modId, handle) end,
cancel = function(_, handle) return Net.cancel(loader, modId, handle) end,
}
end
local function refuse()
error(('[%s] mod.fetch needs the "network" permission in '
.. "manifest.json"):format(modId), 2)
end
return { available = function() return false end,
get = refuse, poll = refuse, release = refuse, cancel = refuse }
end)(),
-- Background compute, behind the "background" permission. The worker
-- rebuilds this mod's sandbox before loading the script, so a job is the
-- one thing love.thread is not: off the main thread without a Lua state
-- that escapes the sandbox. Plain data in, plain data out.
job = (function()
if mod.manifest.permissionSet.background then
return {
available = function() return Job.available() end,
run = function(_, script, arg, opts)
return Job.run(loader, modId, mod.path, script, arg, opts)
end,
poll = function(_, handle) return Job.poll(loader, modId, handle) end,
release = function(_, handle) return Job.release(loader, modId, handle) end,
cancel = function(_, handle) return Job.cancel(loader, modId, handle) end,
}
end
local function refuse()
error(('[%s] mod.job needs the "background" permission in '
.. "manifest.json"):format(modId), 2)
end
return { available = function() return false end,
run = refuse, poll = refuse, release = refuse, cancel = refuse }
end)(),
-- namespaced per mod; M11 backs these with save.modData /
-- options.modOptions, the shape mods compile against is already final
save = {
@@ -1288,12 +1336,24 @@ function Loader:_modEnv(mod)
local id = mod.manifest.id
local env = self.modEnv[id]
if not env then
env = Sandbox.envFor({ modId = id, permissions = mod.manifest.permissionSet })
local loader = self
local compat = LegacyCompat.new({
modId = id, modPath = mod.path, fs = self.fs,
game = function() return loader:_game() end,
})
env = Sandbox.envFor({ modId = id, permissions = mod.manifest.permissionSet,
compat = compat })
self.modEnv[id] = env
end
return env
end
-- Which pre-sandbox calls each loaded mod actually took, for the manager's
-- "needs updating" badge; nil id answers for every mod.
function Loader:legacyReport(modId)
return LegacyCompat.report(modId)
end
function Loader:_loadMod(mod)
local path = SafePath.join(mod.path, mod.manifest.entry, "manifest entry")
local chunk, err = Sandbox.loadFile(self.fs, path, self:_modEnv(mod))
@@ -1329,6 +1389,8 @@ function Loader:_rollback(modId)
self.migrations[modId] = nil
self.modSave[modId] = nil
self.stepsQueues[modId] = nil
Net.releaseAll(self, modId)
Job.releaseAll(self, modId)
end
-- a mod that explicitly swears it stays link-compatible while writing into a
+2 -1
View File
@@ -11,7 +11,8 @@ local Manifest = {}
Manifest.PROFILES = { content = true, overhaul = true, total_conversion = true }
Manifest.PERMISSIONS = { network = true, filesystem = true,
engine_internals = true, steps = true }
engine_internals = true, steps = true,
background = true }
-- link-relevant registries; a mod that writes into one of these while
-- declaring affects_link = false gets an attributed warning from the loader
+143
View File
@@ -0,0 +1,143 @@
-- Background HTTP for sandboxed mods, behind the "network" permission.
--
-- The sandbox blocks love.thread because newThread boots a Lua state with a
-- full standard library that none of the sandbox's rules reach -- one call and
-- a mod has io back. That is correct, but it left mods with no way to do
-- anything off the main thread at all: the only reachable transports
-- (socket, http) block, so a mod that wanted to fetch something had to hang
-- the game to do it.
--
-- This is the narrow replacement. src/net/Fetch.lua already runs a pool of
-- engine-owned worker threads, and those workers run OUR code, not the mod's,
-- so handing a mod a job in that pool grants no new reach. A mod submits a
-- URL and polls for the body; it never gets a thread, a path, or a raw handle
-- into the shared job table.
--
-- WHAT THIS FILE HAS TO GET RIGHT, because Fetch itself is shared with the
-- launcher:
-- * Handles are opaque tables owned per mod. Fetch keys jobs by integer,
-- and the launcher's own ROM download and index fetches live in the same
-- table; an integer handed to a mod would let it poll (or cancel) work
-- that is not its own. A forged table simply misses the lookup.
-- * Only http and https. The transport is curl, which also speaks file://,
-- scp:// and ftp://; without this check mod.fetch would be a filesystem
-- read and the sandbox would be back to square one.
-- * A per-mod ceiling on jobs in flight, so one mod cannot fill the shared
-- three-worker pool and starve the launcher's own fetches.
local Net = {}
-- Per mod, not global: the pool is shared with the launcher and a mod should
-- never be able to monopolise it.
Net.MAX_INFLIGHT = 4
-- Clamp on the caller's timeout, so a mod cannot pin a worker indefinitely.
Net.MAX_SECONDS = 30
local function fetch()
return require("src.net.Fetch")
end
-- http/https only, and a host must actually be present -- "http://" alone
-- reaches curl as a malformed URL rather than being refused here.
function Net.urlDenial(url)
if type(url) ~= "string" or url == "" then return "url must be a string" end
local scheme, rest = url:match("^(%a[%w+.-]*)://(.*)$")
if not scheme then return "url must start with http:// or https://" end
scheme = scheme:lower()
if scheme ~= "http" and scheme ~= "https" then
return ("%s:// is not allowed; mod.fetch speaks http and https only")
:format(scheme)
end
if rest == "" or rest:match("^/") then return "url has no host" end
return nil
end
local function bucket(loader, modId)
loader.netJobs = loader.netJobs or {}
local b = loader.netJobs[modId]
if not b then b = {}; loader.netJobs[modId] = b end
return b
end
local function inflight(b)
local n = 0
for _, id in pairs(b) do
if fetch().isPending(id) then n = n + 1 end
end
return n
end
function Net.available()
local ok, F = pcall(fetch)
if not ok then return false end
local okAvail, avail = pcall(F.available)
return okAvail and avail and true or false
end
-- Returns an opaque handle, or nil plus a reason.
function Net.get(loader, modId, url, opts)
local denial = Net.urlDenial(url)
if denial then return nil, denial end
opts = type(opts) == "table" and opts or {}
local b = bucket(loader, modId)
if inflight(b) >= Net.MAX_INFLIGHT then
return nil, ("too many requests in flight (limit %d); poll and release "
.. "the ones you have"):format(Net.MAX_INFLIGHT)
end
local maxSeconds = tonumber(opts.maxSeconds) or Net.MAX_SECONDS
if maxSeconds > Net.MAX_SECONDS then maxSeconds = Net.MAX_SECONDS end
if maxSeconds < 1 then maxSeconds = 1 end
-- The mod is named in the agent string so a server operator can see which
-- mod is calling them, and a mod cannot pretend to be the launcher.
local id = fetch().get(url, {
userAgent = "gen1recomp-mod/" .. tostring(modId),
accept = type(opts.accept) == "string" and opts.accept or nil,
maxSeconds = maxSeconds,
})
local handle = {}
b[handle] = id
return handle
end
-- A copy of the job's state, never the engine's own table. An unknown or
-- forged handle reads as an error rather than nil, so a mod that lost track of
-- one cannot spin waiting on it forever.
function Net.poll(loader, modId, handle)
local id = bucket(loader, modId)[handle]
if not id then return { status = "error", err = "unknown request" } end
local st = fetch().poll(id)
return { status = st.status, body = st.body, err = st.err,
progress = st.progress }
end
function Net.release(loader, modId, handle)
local b = bucket(loader, modId)
local id = b[handle]
if not id then return false end
fetch().release(id)
b[handle] = nil
return true
end
function Net.cancel(loader, modId, handle)
local id = bucket(loader, modId)[handle]
if not id then return false end
fetch().cancel(id)
return true
end
-- Drop everything this mod still holds. Called when a mod unloads, so a
-- disabled mod cannot leave jobs accumulating in the shared table.
function Net.releaseAll(loader, modId)
local b = loader.netJobs and loader.netJobs[modId]
if not b then return end
local F = fetch()
for handle, id in pairs(b) do
pcall(F.cancel, id)
pcall(F.release, id)
b[handle] = nil
end
loader.netJobs[modId] = nil
end
return Net
+39 -10
View File
@@ -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
+73
View File
@@ -0,0 +1,73 @@
-- Worker state behind src/mods/Job.lua. One per job, not a pool: a reused
-- state would carry one mod's globals into another mod's job.
--
-- This is the file that makes running mod Lua off the main thread safe. The
-- mod's chunk is loaded into the SAME sandbox environment the main thread
-- builds (Sandbox.envFor), so love.filesystem, io, os, debug, ffi and package
-- are as absent here as they are there -- even though this state required
-- love.filesystem to bootstrap itself.
--
-- A job is pure compute: plain data in, plain data out, no engine API, no
-- game state, no storage. require is refused outright rather than reaching
-- src.* -- an engine module loaded in a second state would be a second
-- instance writing the same files as the main thread's.
require("love.thread")
require("love.filesystem")
require("love.timer")
local modId, scriptPath, argChannel, resultChannel, permissionsJson = ...
-- Fresh love threads have no "src.*" searcher (see src/net/fetch_worker.lua),
-- so install one before Sandbox's own requires run.
table.insert(package.loaders or package.searchers, function(name)
local path = name:gsub("%.", "/") .. ".lua"
if not love.filesystem.getInfo(path) then return nil end
return love.filesystem.load(path)
end)
local resCh = love.thread.getChannel(resultChannel)
local function fail(err)
resCh:push({ ok = false, err = tostring(err) })
end
local ok, err = pcall(function()
local Sandbox = require("src.mods.Sandbox")
local Json = require("src.link.Json")
local permissions = {}
if type(permissionsJson) == "string" and permissionsJson ~= "" then
local decoded = select(2, pcall(Json.decode, permissionsJson))
if type(decoded) == "table" then permissions = decoded end
end
local env = Sandbox.envFor({ modId = modId, permissions = permissions })
-- A job cannot reach the engine. Anything it needs comes in through its
-- argument and goes back through its return value.
env.require = function(name)
error(("[%s] require(%q) is not available inside a background job; a job "
.. "takes plain data and returns plain data"):format(modId,
tostring(name)), 2)
end
local chunk, loadErr = Sandbox.loadFile(love.filesystem, scriptPath, env)
if not chunk then error(loadErr or ("could not load " .. scriptPath), 0) end
local arg = love.thread.getChannel(argChannel):pop()
-- NO in-worker time budget, deliberately. A debug count hook was the
-- obvious way to stop a runaway, and it does not work: LuaJIT swallows an
-- error raised from a hook (measured: ~5000 raises a second, the loop
-- running straight through them), and the raising itself wedged the whole
-- process -- the main thread stopped being scheduled at all. Without the
-- hook a runaway job simply spins on its own core, the game stays
-- responsive, and it quits normally. Job.poll enforces maxSeconds on the
-- main thread so the MOD is never left waiting; the work itself runs to its
-- own end.
local ranOk, result = pcall(chunk, arg)
if not ranOk then error(result, 0) end
resCh:push({ ok = true, result = result })
end)
if not ok then fail(err) end
+10 -3
View File
@@ -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
View File
@@ -132,9 +132,11 @@ local UI_SCALE = 1.3
function Kit.layout(width, height)
local s = Theme.clamp(math.min(width / 640, height / 768), 0.9, 1.6) * UI_SCALE
local key = ("%dx%d"):format(math.floor(width), math.floor(height))
if Kit._fontKey ~= key then
Kit._fontKey = key
-- Two numbers, not a formatted key: this runs once per frame and the
-- string:format allocated on every one of them.
local kw, kh = math.floor(width), math.floor(height)
if Kit._fontW ~= kw or Kit._fontH ~= kh then
Kit._fontW, Kit._fontH = kw, kh
Kit.fonts = Theme.fonts(s)
clearCaches() -- every cached Text/width belongs to the old font set
end
@@ -857,6 +859,8 @@ end
-- never silently truncated. This is the ONLY way the launcher moves through
-- a long list: no scrollbars, no momentum, bounded row count per frame.
-- Returns the new page (1-based) and the row height consumed.
local pagerLabels = {}
function Kit.pager(x, y, w, page, total, perPage, idPrefix)
local h = math.max(Kit.tapMin(), 30 * Kit.scale)
local bw = 74 * Kit.scale
@@ -876,7 +880,19 @@ function Kit.pager(x, y, w, page, total, perPage, idPrefix)
local first = total > 0 and ((page - 1) * perPage + 1) or 0
local last = math.min(total, page * perPage)
local label = ("%d-%d of %d (page %d/%d)"):format(first, last, total, page, pages)
-- One memo per pager id. The counts only change when the user pages or the
-- list does; formatting them every frame minted a new string that then
-- missed the width / ellipsis / Text caches by content.
local memo = pagerLabels[idPrefix]
if not memo then memo = {}; pagerLabels[idPrefix] = memo end
if memo.first ~= first or memo.last ~= last or memo.total ~= total
or memo.page ~= page or memo.pages ~= pages then
memo.first, memo.last, memo.total = first, last, total
memo.page, memo.pages = page, pages
memo.label = ("%d-%d of %d (page %d/%d)")
:format(first, last, total, page, pages)
end
local label = memo.label
local labelX = x + 2 * bw + 2 * gap + gap
Kit.text("mono", Kit.ellipsize("mono", label, math.max(0, x + w - labelX)),
labelX, y + (h - Kit.textHeight("mono")) / 2, PAL.caption)
@@ -942,7 +958,10 @@ end
-- region can never unclip its parent. The tracked rect also bounds Kit.hit,
-- so a widget clipped out of view is inert instead of taking taps aimed at
-- whatever is drawn where it left.
-- The stack rects are pooled by depth and fully overwritten on every push,
-- so a frame that clips a dozen lists allocates nothing.
local clipStack = {}
local clipPool = {}
local function applyClip(rect)
Kit._clipRect = rect
@@ -967,8 +986,12 @@ function Kit.pushClip(x, y, w, h)
x2 = math.min(x2, prev.x + prev.w)
y2 = math.min(y2, prev.y + prev.h)
end
local rect = { x = x, y = y, w = math.max(0, x2 - x), h = math.max(0, y2 - y) }
clipStack[#clipStack + 1] = rect
local n = #clipStack + 1
local rect = clipPool[n]
if not rect then rect = {}; clipPool[n] = rect end
rect.x, rect.y = x, y
rect.w, rect.h = math.max(0, x2 - x), math.max(0, y2 - y)
clipStack[n] = rect
applyClip(rect)
end
+29 -15
View File
@@ -29,6 +29,15 @@ Layout.BP = {
-- Build the frame's metrics. `maxAppW` caps the content column on an
-- ultrawide monitor so the UI stays a readable measure instead of stretching.
-- One metrics table, reused. Every field is a pure function of the window
-- size, the safe area and maxAppW, so the table only has to be rebuilt when
-- one of those changes; the launcher asked for a fresh one 60 times a second
-- and threw all of them away. Callers must treat `m` as read-only (nothing
-- writes to it today) -- a caller that needs a shifted field should save,
-- assign and restore it around the call, not wrap `m` in a proxy.
local M = {}
local lastW, lastH, lastOx, lastOy, lastSw, lastSh, lastMax
function Layout.metrics(maxAppW)
local W, H = 0, 0
if love and love.graphics and love.graphics.getDimensions then
@@ -36,23 +45,28 @@ function Layout.metrics(maxAppW)
end
local ox, oy, sw, sh = SafeArea.rect()
local s = Kit.layout(sw, sh)
if W == lastW and H == lastH and ox == lastOx and oy == lastOy
and sw == lastSw and sh == lastSh and maxAppW == lastMax then
return M
end
lastW, lastH, lastOx, lastOy = W, H, ox, oy
lastSw, lastSh, lastMax = sw, sh, maxAppW
local appW = math.min(sw, (maxAppW or 1200) * s)
local m = {
W = W, H = H, s = s,
x = math.floor(ox + (sw - appW) / 2),
top = math.floor(oy),
w = math.floor(appW),
h = math.floor(sh),
pad = math.floor(Theme.clamp(appW * 0.03, 10, 24)),
gap = math.floor(12 * s),
colGap = math.floor(16 * s),
rowH = math.max(Kit.tapMin(), math.floor(44 * s)),
btnH = math.max(Kit.tapMin(), math.floor(38 * s)),
chip = math.max(Kit.tapMin(), math.floor(40 * s)),
railH = math.max(3, math.floor(4 * s)),
logoH = math.floor(Theme.clamp(sh * 0.10, 36, 84)),
}
local m = M
m.W, m.H, m.s = W, H, s
m.x = math.floor(ox + (sw - appW) / 2)
m.top = math.floor(oy)
m.w = math.floor(appW)
m.h = math.floor(sh)
m.pad = math.floor(Theme.clamp(appW * 0.03, 10, 24))
m.gap = math.floor(12 * s)
m.colGap = math.floor(16 * s)
m.rowH = math.max(Kit.tapMin(), math.floor(44 * s))
m.btnH = math.max(Kit.tapMin(), math.floor(38 * s))
m.chip = math.max(Kit.tapMin(), math.floor(40 * s))
m.railH = math.max(3, math.floor(4 * s))
m.logoH = math.floor(Theme.clamp(sh * 0.10, 36, 84))
m.cols = (appW >= Layout.BP.threeCol * s and 3)
or (appW >= Layout.BP.twoCol * s and 2)
or 1
+1 -1
View File
@@ -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")
+9
View File
@@ -138,6 +138,15 @@ function FsIo.new(rootDir)
return loadfile(abs(path))
end
function fs.createDirectory(path)
os.execute(("mkdir -p %q"):format(abs(path)))
return true
end
function fs.remove(path)
return os.remove(abs(path)) ~= nil
end
function fs.getDirectoryItems(path)
return FsIo.listDir(abs(path))
end
+203
View File
@@ -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")
+176
View File
@@ -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")
+204 -55
View File
@@ -1,7 +1,8 @@
-- T4: the mod sandbox (src/mods/Sandbox.lua). A mod's own chunks run against
-- an environment with no io, no os beyond the clock, and no way to name a path
-- outside its own directory, so a mod cannot reach the player's filesystem.
-- Every case here is an escape a mod would actually try.
-- T4: the mod sandbox (src/mods/Sandbox.lua) and the compat reroute over it
-- (src/mods/LegacyCompat.lua). A mod's own chunks still cannot name a path
-- outside their own directory: the pre-sandbox globals are back as stand-ins
-- whose reads come from the mod's own files and whose writes land in a private
-- per-mod overlay. Every case here is an escape a mod would actually try.
package.path = "./?.lua;./?/init.lua;" .. package.path
@@ -9,6 +10,7 @@ local T = require("tests.modkit")
local Manifest = require("src.mods.Manifest")
local Sandbox = require("src.mods.Sandbox")
local SafePath = require("src.mods.SafePath")
local LegacyCompat = require("src.mods.LegacyCompat")
local function manifest(id, extra)
return ('{"id":"%s","name":"%s","version":"1.0.0","entry":"main.lua",'
@@ -20,16 +22,14 @@ end
local PROBE = [[
local mod = ...
local out = mod.exports
out.io = io
out.package = package
out.dofile = dofile
out.loadfile = loadfile
out.io = type(io)
out.package = type(package)
out.dofile = type(dofile)
out.loadfile = type(loadfile)
out.setfenv = setfenv
out.getfenv = getfenv
out.debug = debug
out.osGetenv = os.getenv
out.osExecute = os.execute
out.osRemove = os.remove
out.osGetenv = type(os.getenv)
out.osTime = type(os.time)
out.stringOk = ("a"):rep(3)
@@ -38,30 +38,86 @@ local PROBE = [[
if ok then return false end
return tostring(err)
end
out.requireIo = attempt(require, "io")
out.requireOs = attempt(require, "os")
out.requireIoIsShim = select(2, pcall(require, "io")) == io
out.requireLoveFsIsShim =
select(2, pcall(require, "love.filesystem")) == love.filesystem
out.requireDebug = attempt(require, "debug")
out.requirePackage = attempt(require, "package")
out.requireFfi = attempt(require, "ffi")
out.requireLoveFs = attempt(require, "love.filesystem")
out.requireSocket = attempt(require, "socket")
-- called from a nested Lua frame rather than straight off pcall, which is
-- the shape a stack-walking gate reads differently
out.requireIoNested = attempt(function() return require("io") end)
out.requireSemver = select(2, pcall(require, "src.mods.Semver"))
out.loveFilesystem = attempt(function() return love.filesystem end)
out.loveThread = attempt(function() return love.thread end)
out.loveSystem = attempt(function() return love.system end)
out.loveGraphics = type(love.graphics)
out.loveAssign = attempt(function() love.filesystem = {} end)
-- the callback chain a mod wrapping the mouse writes: it has to reach the
-- real love table or the wrap silently never fires
out.chainedInner = false
local inner = love.mousemoved
love.mousemoved = function(...)
out.chainedInner = true
if inner then return inner(...) end
end
out.assignRun = attempt(function() love.run = function() end end)
out.assignGarbage = attempt(function() love.mousemoved = 7 end)
out.powerInfo = type(love.system.getPowerInfo)
out.openUrl = love.system.openURL("https://example.com")
out.eventQuit = love.event.quit()
out.popen = select(1, io.popen("ls"))
-- the reroute: a write anywhere a mod used to name must land in the mod's
-- own overlay, and reading it back must see the write and nothing else
local escape = io.open("/etc/hosts", "w")
out.escapeOpened = escape ~= nil
if escape then
escape:write("pwned")
escape:close()
end
local reread = io.open("/etc/hosts", "r")
out.escapeReadBack = reread and reread:read("*a") or nil
if reread then reread:close() end
out.homeEnv = os.getenv("HOME")
out.saveDir = love.filesystem.getSaveDirectory()
love.filesystem.write("cfg/settings.txt", "x=1\ny=2\n")
out.roundTrip = love.filesystem.read("cfg/settings.txt")
out.roundTripInfo = love.filesystem.getInfo("cfg/settings.txt")
local lines = {}
for line in love.filesystem.lines("cfg/settings.txt") do
lines[#lines + 1] = line
end
out.roundTripLines = lines
love.filesystem.append("cfg/settings.txt", "z=3\n")
out.appended = love.filesystem.read("cfg/settings.txt")
-- an absolute path built off the reported save directory comes back to the
-- same overlay, which is what a legacy mod's own path joining does
love.filesystem.write(out.saveDir .. "/cfg/settings.txt", "rooted")
out.rootedRead = love.filesystem.read("cfg/settings.txt")
-- the mod's own packaged files still read through the old call
out.ownThroughLove = love.filesystem.read("mods/fix_sandbox/data/note.txt")
out.ownThroughIo = (function()
local f = io.open("data/note.txt", "r")
if not f then return nil end
local body = f:read("*a")
f:close()
return body
end)()
-- copy-on-write: writing over a packaged path shadows it, it does not
-- rewrite the shipped file
love.filesystem.write("mods/fix_sandbox/data/note.txt", "shadowed")
out.shadowed = love.filesystem.read("mods/fix_sandbox/data/note.txt")
out.shadowedOwn = mod:read("data/note.txt")
-- the multi-file pattern mods/timekeepers_hut uses: a chunk loaded from the
-- mod's own source must inherit the sandbox, not the real globals
local child = load("return io, os.getenv, _G")
local childIo, childGetenv, childG = child()
out.childIo = childIo
out.childGetenv = childGetenv
out.childIoIsShim = childIo == io
out.childGetenvIsShim = childGetenv == os.getenv
out.childSharesEnv = childG == _G
out.readEscape = attempt(function() return mod:read("../../secret.txt") end)
@@ -95,58 +151,121 @@ 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")
-- 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 +306,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 +375,11 @@ do
bytecodeRun.release()
end
-- ------- the sandbox is not opt-in
-- ------- the sandbox with no compat layer is still closed
do
local env = Sandbox.envFor({ modId = "probe" })
T.eq(env.io, nil, "a bare Sandbox.envFor is already closed")
T.eq(env.io, nil, "a bare Sandbox.envFor has no io")
T.eq(env._G, env, "_G points at the sandbox, not the real globals")
T.check(not pcall(env.require, "io"), "and its require refuses io")
end
+25
View File
@@ -348,5 +348,30 @@ 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
print(string.format("save editor gen2 tests: %d passed, %d failed", passed, failed))
if failed > 0 then os.exit(1) end