mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-15 15:51:17 +02:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 43cbc554c3 | |||
| 7804ef9793 | |||
| 673d8b3ad8 | |||
| 62e1296ced | |||
| a3bbd78e7b | |||
| 8dfbd1daae |
+35
-11
@@ -246,17 +246,37 @@ real Gold boot.
|
|||||||
|
|
||||||
Your code runs in a sandbox (`src/mods/Sandbox.lua`), not against the
|
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
|
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 |
|
| `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 |
|
||||||
| `os.getenv`, `os.execute`, `os.remove`, `os.rename`, `os.exit` | nothing; `os.time`/`os.date`/`os.clock` still work |
|
| `require("ffi")` | arbitrary C |
|
||||||
| `package`, `dofile`, `loadfile`, `debug`, `getfenv`, `setfenv` | `require` for the supported engine modules |
|
| `debug`, `getfenv`, `setfenv` | each one undoes the sandbox from inside |
|
||||||
| `require("ffi")`, `require("love.*")` | the `love` table you are given |
|
| `io.popen`, `os.execute` | spawning a process |
|
||||||
| `love.filesystem` | `mod.storage` (per-mod, per-playthrough), `mod:read` for a known file, `mod:list` / `mod:info` to iterate your own directory |
|
| `love.run`, `love.errorhandler` | the engine's own loop and its crash path |
|
||||||
| `love.thread`, `love.event` | `mod.events`, `mod.hooks` |
|
| replacing a `love` module table (`love.filesystem = {}`) | the engine reads those tables too |
|
||||||
| `love.system` | `mod.device:powerInfo()` for battery information; `mod.steps` (with the `steps` permission) for the step bridge |
|
|
||||||
|
|
||||||
The rest of `love` passes through unchanged, so graphics, audio, timers and
|
The rest of `love` passes through unchanged, so graphics, audio, timers and
|
||||||
input work as they always have.
|
input work as they always have.
|
||||||
@@ -283,8 +303,12 @@ permission that grants raw filesystem access, because no mod needs one:
|
|||||||
everything a mod legitimately writes is already scoped by
|
everything a mod legitimately writes is already scoped by
|
||||||
`mod.storage` or the asset-transform derived root.
|
`mod.storage` or the asset-transform derived root.
|
||||||
|
|
||||||
If your mod used one of the absent globals, the fix is almost always
|
If your mod used one of the rerouted globals, the fix is almost always
|
||||||
`mod.storage`. Open an issue if you have a case it does not cover.
|
`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`
|
### 6. `mod.card`
|
||||||
|
|
||||||
|
|||||||
@@ -802,3 +802,37 @@ 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
|
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
|
is never delivered twice. Without the permission, `sync` and `poll` raise
|
||||||
an error naming it.
|
an error naming 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.
|
||||||
|
|||||||
@@ -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
|
* **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
|
||||||
|
|||||||
+49
-2
@@ -832,9 +832,41 @@ local function tryMigrateLegacy(version, fs)
|
|||||||
return id
|
return id
|
||||||
end
|
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
|
-- Resolve (once per version per process) which slot in-game saves use: an
|
||||||
-- existing registry wins; otherwise a lazy legacy migration may create
|
-- 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)
|
local function ensureVersionSlots(version, fs)
|
||||||
if slotsChecked[version] then return end
|
if slotsChecked[version] then return end
|
||||||
slotsChecked[version] = true
|
slotsChecked[version] = true
|
||||||
@@ -848,7 +880,22 @@ local function ensureVersionSlots(version, fs)
|
|||||||
activeSlotCache[version] = reg.active or reg.list[1]
|
activeSlotCache[version] = reg.active or reg.list[1]
|
||||||
return
|
return
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
-- (body for the forward-declared saveNames.) Resolves the ACTIVE slot for
|
-- (body for the forward-declared saveNames.) Resolves the ACTIVE slot for
|
||||||
|
|||||||
@@ -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
|
||||||
+14
-1
@@ -20,6 +20,7 @@ local Semver = require("src.mods.Semver")
|
|||||||
local Events = require("src.mods.Events")
|
local Events = require("src.mods.Events")
|
||||||
local Gen2Compat = require("src.mods.Gen2Compat")
|
local Gen2Compat = require("src.mods.Gen2Compat")
|
||||||
local Hooks = require("src.mods.Hooks")
|
local Hooks = require("src.mods.Hooks")
|
||||||
|
local LegacyCompat = require("src.mods.LegacyCompat")
|
||||||
local Runtime = require("src.mods.Runtime")
|
local Runtime = require("src.mods.Runtime")
|
||||||
local Steps = require("src.mods.Steps")
|
local Steps = require("src.mods.Steps")
|
||||||
|
|
||||||
@@ -1288,12 +1289,24 @@ function Loader:_modEnv(mod)
|
|||||||
local id = mod.manifest.id
|
local id = mod.manifest.id
|
||||||
local env = self.modEnv[id]
|
local env = self.modEnv[id]
|
||||||
if not env then
|
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
|
self.modEnv[id] = env
|
||||||
end
|
end
|
||||||
return env
|
return env
|
||||||
end
|
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)
|
function Loader:_loadMod(mod)
|
||||||
local path = SafePath.join(mod.path, mod.manifest.entry, "manifest entry")
|
local path = SafePath.join(mod.path, mod.manifest.entry, "manifest entry")
|
||||||
local chunk, err = Sandbox.loadFile(self.fs, path, self:_modEnv(mod))
|
local chunk, err = Sandbox.loadFile(self.fs, path, self:_modEnv(mod))
|
||||||
|
|||||||
+34
-9
@@ -73,11 +73,15 @@ local BLOCKED_LOVE = {
|
|||||||
.. "the step bridge", event = true,
|
.. "the step bridge", event = true,
|
||||||
}
|
}
|
||||||
|
|
||||||
local loveProxy
|
-- Per-mod, because the compat overrides (src/mods/LegacyCompat.lua) are backed
|
||||||
local function loveFacade()
|
-- by that mod's own overlay and must not be shared.
|
||||||
if loveProxy or not _G.love then return loveProxy end
|
local function loveFacade(compat)
|
||||||
loveProxy = setmetatable({}, {
|
if not _G.love then return nil end
|
||||||
|
local overrides = compat and compat.love
|
||||||
|
return setmetatable({}, {
|
||||||
__index = function(_, key)
|
__index = function(_, key)
|
||||||
|
local override = overrides and overrides[key]
|
||||||
|
if override ~= nil then return override end
|
||||||
local hint = BLOCKED_LOVE[key]
|
local hint = BLOCKED_LOVE[key]
|
||||||
if hint then
|
if hint then
|
||||||
error(("love.%s is not available to mods%s"):format(key,
|
error(("love.%s is not available to mods%s"):format(key,
|
||||||
@@ -85,11 +89,20 @@ local function loveFacade()
|
|||||||
end
|
end
|
||||||
return _G.love[key]
|
return _G.love[key]
|
||||||
end,
|
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)
|
error(("mods cannot assign love.%s"):format(tostring(key)), 2)
|
||||||
end,
|
end,
|
||||||
})
|
})
|
||||||
return loveProxy
|
|
||||||
end
|
end
|
||||||
|
|
||||||
-- ------- the environment
|
-- ------- the environment
|
||||||
@@ -177,8 +190,12 @@ end
|
|||||||
-- Runtime.modRequire is how the loader's gate identifies the caller for the
|
-- 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
|
-- Gen 2 facade once Runtime.currentMod has gone back to nil (a mod requiring
|
||||||
-- lazily from an event handler).
|
-- lazily from an event handler).
|
||||||
local function sandboxedRequire(modId, permissionSet)
|
local function sandboxedRequire(modId, permissionSet, compat)
|
||||||
return function(name, ...)
|
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)
|
local denial = Sandbox.moduleDenial(name, permissionSet)
|
||||||
if denial then error(("[%s] %s"):format(modId or "mod", denial), 2) end
|
if denial then error(("[%s] %s"):format(modId or "mod", denial), 2) end
|
||||||
local previous = Runtime.modRequire
|
local previous = Runtime.modRequire
|
||||||
@@ -192,15 +209,23 @@ end
|
|||||||
|
|
||||||
function Sandbox.envFor(opts)
|
function Sandbox.envFor(opts)
|
||||||
opts = opts or {}
|
opts = opts or {}
|
||||||
|
local compat = opts.compat
|
||||||
local env = baseGlobals()
|
local env = baseGlobals()
|
||||||
env.love = loveFacade()
|
env.love = loveFacade(compat)
|
||||||
env.require = sandboxedRequire(opts.modId, opts.permissions)
|
env.require = sandboxedRequire(opts.modId, opts.permissions, compat)
|
||||||
local loader = sandboxedLoad(env)
|
local loader = sandboxedLoad(env)
|
||||||
env.load = loader
|
env.load = loader
|
||||||
env.loadstring = loader
|
env.loadstring = loader
|
||||||
-- a mod's globals are its own: two mods no longer share a namespace, and
|
-- a mod's globals are its own: two mods no longer share a namespace, and
|
||||||
-- neither can reach the engine's
|
-- neither can reach the engine's
|
||||||
env._G = env
|
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
|
return env
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
+10
-3
@@ -539,11 +539,18 @@ function PartyMenu:update(dt)
|
|||||||
-- .strength, GBPalWhiteOutWithDelay3 blinks the screen white
|
-- .strength, GBPalWhiteOutWithDelay3 blinks the screen white
|
||||||
-- before CloseTextDisplay returns to the map.
|
-- before CloseTextDisplay returns to the map.
|
||||||
local ow = self.game.overworld
|
local ow = self.game.overworld
|
||||||
if ow and not ow:partyKnows("STRENGTH") then
|
if ow and ow.useStrengthFieldMove then
|
||||||
refuseBadge(self)
|
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
|
return
|
||||||
end
|
end
|
||||||
ow:useStrengthFieldMove(mon, function() self:close() end)
|
|
||||||
return
|
return
|
||||||
elseif action == "softboiled" then
|
elseif action == "softboiled" then
|
||||||
-- field SOFTBOILED (StartMenu_Pokemon .softboiled): transfer
|
-- field SOFTBOILED (StartMenu_Pokemon .softboiled): transfer
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ end
|
|||||||
|
|
||||||
do
|
do
|
||||||
local body, ver = PatchNotes.body(nil)
|
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")
|
"without a check result PatchNotes uses the stashed iOS app-repo notes")
|
||||||
check(type(ver) == "string" and ver:find("^%d+%.%d+%.%d+$") ~= nil,
|
check(type(ver) == "string" and ver:find("^%d+%.%d+%.%d+$") ~= nil,
|
||||||
"stashed notes name a release version")
|
"stashed notes name a release version")
|
||||||
|
|||||||
@@ -138,6 +138,15 @@ function FsIo.new(rootDir)
|
|||||||
return loadfile(abs(path))
|
return loadfile(abs(path))
|
||||||
end
|
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)
|
function fs.getDirectoryItems(path)
|
||||||
return FsIo.listDir(abs(path))
|
return FsIo.listDir(abs(path))
|
||||||
end
|
end
|
||||||
|
|||||||
+204
-55
@@ -1,7 +1,8 @@
|
|||||||
-- T4: the mod sandbox (src/mods/Sandbox.lua). A mod's own chunks run against
|
-- T4: the mod sandbox (src/mods/Sandbox.lua) and the compat reroute over it
|
||||||
-- an environment with no io, no os beyond the clock, and no way to name a path
|
-- (src/mods/LegacyCompat.lua). A mod's own chunks still cannot name a path
|
||||||
-- outside its own directory, so a mod cannot reach the player's filesystem.
|
-- outside their own directory: the pre-sandbox globals are back as stand-ins
|
||||||
-- Every case here is an escape a mod would actually try.
|
-- 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
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
@@ -9,6 +10,7 @@ local T = require("tests.modkit")
|
|||||||
local Manifest = require("src.mods.Manifest")
|
local Manifest = require("src.mods.Manifest")
|
||||||
local Sandbox = require("src.mods.Sandbox")
|
local Sandbox = require("src.mods.Sandbox")
|
||||||
local SafePath = require("src.mods.SafePath")
|
local SafePath = require("src.mods.SafePath")
|
||||||
|
local LegacyCompat = require("src.mods.LegacyCompat")
|
||||||
|
|
||||||
local function manifest(id, extra)
|
local function manifest(id, extra)
|
||||||
return ('{"id":"%s","name":"%s","version":"1.0.0","entry":"main.lua",'
|
return ('{"id":"%s","name":"%s","version":"1.0.0","entry":"main.lua",'
|
||||||
@@ -20,16 +22,14 @@ end
|
|||||||
local PROBE = [[
|
local PROBE = [[
|
||||||
local mod = ...
|
local mod = ...
|
||||||
local out = mod.exports
|
local out = mod.exports
|
||||||
out.io = io
|
out.io = type(io)
|
||||||
out.package = package
|
out.package = type(package)
|
||||||
out.dofile = dofile
|
out.dofile = type(dofile)
|
||||||
out.loadfile = loadfile
|
out.loadfile = type(loadfile)
|
||||||
out.setfenv = setfenv
|
out.setfenv = setfenv
|
||||||
out.getfenv = getfenv
|
out.getfenv = getfenv
|
||||||
out.debug = debug
|
out.debug = debug
|
||||||
out.osGetenv = os.getenv
|
out.osGetenv = type(os.getenv)
|
||||||
out.osExecute = os.execute
|
|
||||||
out.osRemove = os.remove
|
|
||||||
out.osTime = type(os.time)
|
out.osTime = type(os.time)
|
||||||
out.stringOk = ("a"):rep(3)
|
out.stringOk = ("a"):rep(3)
|
||||||
|
|
||||||
@@ -38,30 +38,86 @@ local PROBE = [[
|
|||||||
if ok then return false end
|
if ok then return false end
|
||||||
return tostring(err)
|
return tostring(err)
|
||||||
end
|
end
|
||||||
out.requireIo = attempt(require, "io")
|
out.requireIoIsShim = select(2, pcall(require, "io")) == io
|
||||||
out.requireOs = attempt(require, "os")
|
out.requireLoveFsIsShim =
|
||||||
|
select(2, pcall(require, "love.filesystem")) == love.filesystem
|
||||||
out.requireDebug = attempt(require, "debug")
|
out.requireDebug = attempt(require, "debug")
|
||||||
out.requirePackage = attempt(require, "package")
|
out.requirePackage = attempt(require, "package")
|
||||||
out.requireFfi = attempt(require, "ffi")
|
out.requireFfi = attempt(require, "ffi")
|
||||||
out.requireLoveFs = attempt(require, "love.filesystem")
|
|
||||||
out.requireSocket = attempt(require, "socket")
|
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.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.loveThread = attempt(function() return love.thread end)
|
||||||
out.loveSystem = attempt(function() return love.system end)
|
|
||||||
out.loveGraphics = type(love.graphics)
|
out.loveGraphics = type(love.graphics)
|
||||||
out.loveAssign = attempt(function() love.filesystem = {} end)
|
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
|
-- 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
|
-- mod's own source must inherit the sandbox, not the real globals
|
||||||
local child = load("return io, os.getenv, _G")
|
local child = load("return io, os.getenv, _G")
|
||||||
local childIo, childGetenv, childG = child()
|
local childIo, childGetenv, childG = child()
|
||||||
out.childIo = childIo
|
out.childIoIsShim = childIo == io
|
||||||
out.childGetenv = childGetenv
|
out.childGetenvIsShim = childGetenv == os.getenv
|
||||||
out.childSharesEnv = childG == _G
|
out.childSharesEnv = childG == _G
|
||||||
|
|
||||||
out.readEscape = attempt(function() return mod:read("../../secret.txt") end)
|
out.readEscape = attempt(function() return mod:read("../../secret.txt") end)
|
||||||
@@ -95,58 +151,121 @@ local FILES = {
|
|||||||
["mods/fix_sandbox/assets/sprites/walk.png"] = "png",
|
["mods/fix_sandbox/assets/sprites/walk.png"] = "png",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LegacyCompat.reset()
|
||||||
|
local savedMouseMoved = love.mousemoved
|
||||||
local run = T.sdk.loadMods({ "mods/fix_sandbox" }, { fs = T.sdk.memfs(FILES) })
|
local run = T.sdk.loadMods({ "mods/fix_sandbox" }, { fs = T.sdk.memfs(FILES) })
|
||||||
|
local installedMouseMoved = love.mousemoved
|
||||||
|
love.mousemoved = savedMouseMoved
|
||||||
T.eq(#run.errors, 0,
|
T.eq(#run.errors, 0,
|
||||||
"the probe mod loads clean (" .. tostring(run.errors[1]) .. ")")
|
"the probe mod loads clean (" .. tostring(run.errors[1]) .. ")")
|
||||||
local out = run.loader.exports.fix_sandbox or {}
|
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.io, "table", "io is present again, as the compat stand-in")
|
||||||
T.eq(out.package, nil, "package is absent, so package.loaded is unreachable")
|
T.eq(out.package, "table", "so is package, as an inert stub")
|
||||||
T.eq(out.dofile, nil, "dofile is absent")
|
T.eq(out.dofile, "function", "dofile routes through the stand-in")
|
||||||
T.eq(out.loadfile, nil, "loadfile is absent")
|
T.eq(out.loadfile, "function", "so does loadfile")
|
||||||
T.eq(out.setfenv, nil, "setfenv is absent, so a mod cannot swap its own env")
|
T.eq(out.osGetenv, "function", "os.getenv answers rather than crashing the mod")
|
||||||
T.eq(out.getfenv, nil, "getfenv is absent, so a mod cannot read the real _G out")
|
T.eq(out.osTime, "function", "os.time still works: the clock was never the hole")
|
||||||
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.stringOk, "aaa", "the safe standard library is intact")
|
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 answers with the same stand-ins
|
||||||
"require(\"io\") is refused: " .. tostring(out.requireIo))
|
|
||||||
T.check(out.requireOs ~= false, "require(\"os\") is refused")
|
T.check(out.requireIoIsShim, "require(\"io\") hands back the same compat table")
|
||||||
T.check(out.requireDebug ~= false, "require(\"debug\") is refused")
|
T.check(out.requireLoveFsIsShim,
|
||||||
T.check(out.requirePackage ~= false, "require(\"package\") is refused")
|
"require(\"love.filesystem\") hands back the same compat table")
|
||||||
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")
|
|
||||||
T.check(out.requireSocket and out.requireSocket:find("network", 1, true),
|
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",
|
T.eq(type(out.requireSemver), "table",
|
||||||
"the supported engine requires still resolve")
|
"the supported engine requires still resolve")
|
||||||
|
|
||||||
-- ------- the love facade
|
-- ------- 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.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
|
-- ------- 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)")
|
"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.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.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")
|
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")
|
"mod:info cannot climb either")
|
||||||
run.release()
|
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
|
-- ------- the grammar itself
|
||||||
|
|
||||||
for _, bad in ipairs({ "../x", "a/../../x", "/etc/hosts", "C:/Windows/x",
|
for _, bad in ipairs({ "../x", "a/../../x", "/etc/hosts", "C:/Windows/x",
|
||||||
@@ -226,11 +375,11 @@ do
|
|||||||
bytecodeRun.release()
|
bytecodeRun.release()
|
||||||
end
|
end
|
||||||
|
|
||||||
-- ------- the sandbox is not opt-in
|
-- ------- the sandbox with no compat layer is still closed
|
||||||
|
|
||||||
do
|
do
|
||||||
local env = Sandbox.envFor({ modId = "probe" })
|
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.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")
|
T.check(not pcall(env.require, "io"), "and its require refuses io")
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -348,5 +348,30 @@ do
|
|||||||
"MapPreview attaches a draw for a Gold map")
|
"MapPreview attaches a draw for a Gold map")
|
||||||
end
|
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))
|
print(string.format("save editor gen2 tests: %d passed, %d failed", passed, failed))
|
||||||
if failed > 0 then os.exit(1) end
|
if failed > 0 then os.exit(1) end
|
||||||
|
|||||||
Reference in New Issue
Block a user