mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-13 17:00:58 +02:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c0f654f8c2 | |||
| e44769a48a | |||
| d38faab03c | |||
| 83682f011d |
+43
-2
@@ -242,7 +242,48 @@ with a `MK4xx` finding per site and an `unresolved:` note, with a file and a
|
||||
line, for every reach a static scan could not follow. Neither substitutes for a
|
||||
real Gold boot.
|
||||
|
||||
### 5. `mod.card`
|
||||
### 5. What a mod's code can reach
|
||||
|
||||
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:
|
||||
|
||||
| Absent | Use instead |
|
||||
| --- | --- |
|
||||
| `io`, and `require("io")` | `mod:read` for your own files, `mod.storage` to persist |
|
||||
| `os.getenv`, `os.execute`, `os.remove`, `os.rename`, `os.exit` | nothing; `os.time`/`os.date`/`os.clock` still work |
|
||||
| `package`, `dofile`, `loadfile`, `debug`, `getfenv`, `setfenv` | `require` for the supported engine modules |
|
||||
| `require("ffi")`, `require("love.*")` | the `love` table you are given |
|
||||
| `love.filesystem` | `mod.storage` (per-mod, per-playthrough) and `mod:read` |
|
||||
| `love.thread`, `love.event` | `mod.events`, `mod.hooks` |
|
||||
| `love.system` | `mod.device:powerInfo()` for battery information |
|
||||
|
||||
The rest of `love` passes through unchanged, so graphics, audio, timers and
|
||||
input work as they always have.
|
||||
|
||||
Three consequences worth knowing before you write against it:
|
||||
|
||||
- **Your globals are yours.** `_G` inside a mod is that mod's own table. Two
|
||||
mods no longer share a namespace, and neither can reach the engine's. To
|
||||
publish something to another mod, put it on `mod.exports` and let them
|
||||
`mod.find("your_id").exports` — the channel that was always the intended
|
||||
one. The same goes for the standard library: `string`, `table` and `math`
|
||||
are per-mod copies, so patching one is a local decision.
|
||||
- **Paths cannot climb.** `mod:read`, `mod.assets:path` and `mod.assets:image`
|
||||
join to your own directory, and `..`, absolute paths and drive letters are
|
||||
refused. So are `entry` and `options_schema` in your manifest.
|
||||
- **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
|
||||
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.
|
||||
|
||||
### 6. `mod.card`
|
||||
|
||||
The manifest is the *engine's* contract: identity, load order, dependencies,
|
||||
permissions, profile. The card is the *human-facing* one: who made this,
|
||||
@@ -263,7 +304,7 @@ Two fields deserve their own note:
|
||||
distributed mod never carries ROM-derived bytes, not even in its preview
|
||||
images.
|
||||
|
||||
### 6. Tags
|
||||
### 7. Tags
|
||||
|
||||
Lowercase kebab strings, open vocabulary. The showcase generator
|
||||
lowercases and de-dupes. A recommended starting set: `beginner`,
|
||||
|
||||
@@ -514,3 +514,17 @@ local both = mod.datetime:dateTime(game, createdAt)
|
||||
The live `game` supplies only the current option context. Formatting never
|
||||
mutates the save, options, or timestamp, and invalid timestamps return
|
||||
`"----"`.
|
||||
|
||||
## Device power information
|
||||
|
||||
Sandboxed mods can read the host's battery state without receiving the rest
|
||||
of `love.system`:
|
||||
|
||||
```lua
|
||||
local state, percent = mod.device:powerInfo()
|
||||
```
|
||||
|
||||
`state` follows LÖVE's values: `"unknown"`, `"battery"`, `"nobattery"`,
|
||||
`"charging"`, or `"charged"`. `percent` is `0` through `100`, or `nil` when
|
||||
the platform cannot report it. The facade is read-only and does not expose
|
||||
URL launching, clipboard access, or other system operations.
|
||||
|
||||
@@ -19,6 +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
|
||||
* **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**
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# RFC 0008 — Read-only device power information for sandboxed mods
|
||||
|
||||
## Status
|
||||
|
||||
Proposed. Engine: `Loader.lua`, `Sandbox.lua`. Test:
|
||||
`tests/modkit/cases/device_power_info.lua`.
|
||||
|
||||
## Motivation
|
||||
|
||||
A handheld UI mod can show the player's battery state and warn before power
|
||||
loss. The sandbox correctly removes `love.system` because that module also
|
||||
launches URLs and exposes other host operations, but it leaves no scoped way
|
||||
to read the harmless power values that LÖVE already provides.
|
||||
|
||||
## The decision it extends
|
||||
|
||||
Extends the mod sandbox in `src/mods/Sandbox.lua`: blocked host modules stay
|
||||
blocked while legitimate operations receive narrow engine-owned facades.
|
||||
|
||||
## The exact API delta
|
||||
|
||||
Add `mod.device:powerInfo() -> state, percent`.
|
||||
|
||||
The engine calls `love.system.getPowerInfo()` outside the mod sandbox and
|
||||
returns only its first two values. `state` is one of LÖVE's standard power
|
||||
states. `percent` is `0` through `100` or `nil`. When the platform has no
|
||||
power-information backend, the result is `"unknown", nil`.
|
||||
|
||||
No permission grants access to `love.system`; URL launching, clipboard access,
|
||||
OS identification, and the module table itself remain unavailable.
|
||||
|
||||
## Migration note for existing mods
|
||||
|
||||
Mods that used `love.system.getPowerInfo()` replace that call with
|
||||
`mod.device:powerInfo()`. No other mod changes.
|
||||
|
||||
## Parity tests
|
||||
|
||||
- **No mod:** loading no mods does not call the platform power backend.
|
||||
- **Mod API:** a fixture mod loaded through the public loader receives state
|
||||
and percentage through `mod.device`, while the existing sandbox suite keeps
|
||||
proving that direct `love.system` access is refused.
|
||||
- **Unavailable backend:** the public facade returns `"unknown", nil` rather
|
||||
than inventing battery data or failing mod load.
|
||||
|
||||
## Deprecation etiquette
|
||||
|
||||
Nothing deprecated. The facade is additive; the sandbox's `love.system` block
|
||||
remains in force.
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
/Users/bryanbassett/Documents/development/pokemon-gen1-recomp-project/.bazinga/mods/timekeepers_hut
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
local Logger = require("src.core.Logger")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local SafePath = require("src.mods.SafePath")
|
||||
|
||||
local unpack = table.unpack or unpack
|
||||
local loadstring = loadstring or load
|
||||
@@ -31,25 +32,8 @@ AssetTransform.SOURCE_ROOT = SOURCE_ROOT
|
||||
AssetTransform.DERIVED_ROOT = DERIVED_ROOT
|
||||
|
||||
-- ------- path sandbox
|
||||
|
||||
-- a relative path that cannot climb out of the root it is joined to
|
||||
local function safeRelative(rel)
|
||||
if type(rel) ~= "string" or rel == "" then return nil end
|
||||
if rel:sub(1, 1) == "/" then return nil end
|
||||
if rel:find("\\", 1, true) then return nil end
|
||||
for segment in rel:gmatch("[^/]+") do
|
||||
if segment == ".." or segment == "." then return nil end
|
||||
end
|
||||
return rel
|
||||
end
|
||||
|
||||
local function requireRelative(rel, what)
|
||||
local safe = safeRelative(rel)
|
||||
if not safe then
|
||||
error(("%s must stay inside its root, got %q"):format(what, tostring(rel)), 0)
|
||||
end
|
||||
return safe
|
||||
end
|
||||
-- shared with mod:read and the manifest's own paths (src/mods/SafePath.lua)
|
||||
local requireRelative = SafePath.require
|
||||
|
||||
-- ------- the restricted context
|
||||
|
||||
|
||||
+68
-23
@@ -12,6 +12,8 @@ local Manifest = require("src.mods.Manifest")
|
||||
local Merge = require("src.mods.Merge")
|
||||
local ModTargets = require("src.mods.ModTargets")
|
||||
local Registry = require("src.mods.Registry")
|
||||
local SafePath = require("src.mods.SafePath")
|
||||
local Sandbox = require("src.mods.Sandbox")
|
||||
local Schemas = require("src.mods.Schemas")
|
||||
local Semver = require("src.mods.Semver")
|
||||
local Events = require("src.mods.Events")
|
||||
@@ -72,10 +74,12 @@ local function orderedIds(mods, filter)
|
||||
return ids
|
||||
end
|
||||
|
||||
-- ------- dev-mode permissions tripwire
|
||||
-- Attribution only: the shim delegates unconditionally and blocks nothing.
|
||||
-- Installed once per process and only when the loader runs in dev mode, so a
|
||||
-- player build has zero interposition.
|
||||
-- ------- the require gate
|
||||
-- Two jobs in one interposition. The engine_internals/network scan is
|
||||
-- attribution only and stays dev-mode: it warns and delegates. The
|
||||
-- Sandbox.moduleDenial check is not -- require("io") would hand back
|
||||
-- package.loaded.io and undo the whole mod environment -- so it is installed
|
||||
-- in player builds too, for any boot that has mods on it.
|
||||
|
||||
local devShim = { installed = false, permissions = {}, warned = {}, depth = 0 }
|
||||
|
||||
@@ -135,7 +139,8 @@ end
|
||||
|
||||
local function scanRequire(name)
|
||||
local modId = Runtime.currentMod
|
||||
if not modId or type(name) ~= "string" then return end
|
||||
if type(modId) ~= "string" then modId = Runtime.modRequire end
|
||||
if type(modId) ~= "string" or type(name) ~= "string" then return end
|
||||
local granted = devShim.permissions[modId] or {}
|
||||
local function warnOnce(permission)
|
||||
local key = modId .. "|" .. permission .. "|" .. name
|
||||
@@ -188,6 +193,7 @@ function Loader:_installDevShim()
|
||||
for id, mod in pairs(self.mods) do
|
||||
devShim.permissions[id] = mod.manifest.permissionSet
|
||||
end
|
||||
devShim.dev = self.dev
|
||||
if devShim.installed then return end
|
||||
devShim.installed = true
|
||||
local delegate = require
|
||||
@@ -195,12 +201,22 @@ function Loader:_installDevShim()
|
||||
-- only the mod's own call is the mod's doing; whatever that module
|
||||
-- requires in turn is the engine wiring itself up
|
||||
if devShim.depth == 0 then
|
||||
scanRequire(name)
|
||||
-- Backstop for the deny list Sandbox.envFor's require already applies:
|
||||
-- an engine module requiring io is the engine wiring itself up, a mod
|
||||
-- doing it is the hole this closes, and any future path that runs mod
|
||||
-- code without a sandbox env still lands here.
|
||||
local owner = Runtime.currentMod or Runtime.modRequire
|
||||
if owner or callerIsMod(3) then
|
||||
local id = type(owner) == "string" and owner or nil
|
||||
local denial = Sandbox.moduleDenial(name, devShim.permissions[id])
|
||||
if denial then error(("[%s] %s"):format(id or "mod", denial), 0) end
|
||||
end
|
||||
if devShim.dev or devShim.generation ~= 1 then scanRequire(name) end
|
||||
-- The Gen 1 name a mod asked for, answered by the Gen 2 arm behind it.
|
||||
-- Engine code keeps the real module: src/render/PaletteFX.lua:776
|
||||
-- requires src.core.Game on both generations and means it.
|
||||
if devShim.generation ~= 1 and Gen2Compat.serves(name)
|
||||
and callerIsMod(3) then
|
||||
and (owner or callerIsMod(3)) then
|
||||
local adapter = Gen2Compat.resolve(name, Runtime.currentMod)
|
||||
if adapter then
|
||||
local key = "adapter|" .. name
|
||||
@@ -235,7 +251,7 @@ function Loader.new(opts)
|
||||
events = Events.new(), hooks = Hooks.new(), content = {}, assets = {},
|
||||
exports = {}, migrations = {}, order = {},
|
||||
modSave = {}, modOptions = {}, optionSchemas = {}, imageCache = {},
|
||||
modInput = {},
|
||||
modInput = {}, modEnv = {},
|
||||
fs = (opts and opts.fs) or (love and love.filesystem),
|
||||
dev = dev,
|
||||
-- Which generation this boot is (1 or 2). Fixed at construction: the
|
||||
@@ -358,11 +374,13 @@ function Loader:_writeOptionSchemas()
|
||||
-- demand; using it here means older mods do not need to migrate to
|
||||
-- mod.options:define just to appear in a launcher settings screen.
|
||||
if schema == nil and mod.manifest.options_schema and self.fs.load then
|
||||
local chunk = self.fs.load(mod.path .. "/" .. mod.manifest.options_schema)
|
||||
if chunk then
|
||||
local ok, rows = pcall(chunk)
|
||||
if ok and type(rows) == "table" then schema = rows end
|
||||
end
|
||||
local ok, rows = pcall(function()
|
||||
local path = SafePath.join(mod.path, mod.manifest.options_schema,
|
||||
"options_schema")
|
||||
local chunk = Sandbox.loadFile(self.fs, path, self:_modEnv(mod))
|
||||
return chunk and chunk()
|
||||
end)
|
||||
if ok and type(rows) == "table" then schema = rows end
|
||||
end
|
||||
if schema ~= nil then
|
||||
mods[id] = schema
|
||||
@@ -981,6 +999,16 @@ function Loader:_api(mod)
|
||||
return DateTime.dateTime(game, timestamp)
|
||||
end,
|
||||
},
|
||||
-- The read-only part of love.system that device UIs legitimately need.
|
||||
-- Do not expose the module: openURL and clipboard access stay sandboxed.
|
||||
device = {
|
||||
powerInfo = function()
|
||||
local getPowerInfo = love and love.system and love.system.getPowerInfo
|
||||
if not getPowerInfo then return "unknown", nil end
|
||||
local state, percent = getPowerInfo()
|
||||
return state, percent
|
||||
end,
|
||||
},
|
||||
-- namespaced per mod; M11 backs these with save.modData /
|
||||
-- options.modOptions, the shape mods compile against is already final
|
||||
save = {
|
||||
@@ -1091,9 +1119,11 @@ function Loader:_api(mod)
|
||||
-- assets keeps the v1 alias to the content accessors and adds the file
|
||||
-- helpers on top, so mod.assets.pokemon and mod.assets:image both resolve
|
||||
api.assets = setmetatable({
|
||||
path = function(_, relative) return mod.path .. "/" .. relative end,
|
||||
path = function(_, relative)
|
||||
return SafePath.join(mod.path, relative, "mod.assets:path")
|
||||
end,
|
||||
image = function(_, relative)
|
||||
local full = mod.path .. "/" .. relative
|
||||
local full = SafePath.join(mod.path, relative, "mod.assets:image")
|
||||
local cached = loader.imageCache[full]
|
||||
if cached then return cached end
|
||||
assert(love and love.graphics,
|
||||
@@ -1103,9 +1133,10 @@ function Loader:_api(mod)
|
||||
return image
|
||||
end,
|
||||
}, { __index = api.content })
|
||||
-- the mod's own directory and nothing above it: PhysFS already refuses a
|
||||
-- climb, but loader.fs is injectable and has no such floor
|
||||
function api:read(relative)
|
||||
local path = self.path .. "/" .. relative
|
||||
return loader.fs.read(path)
|
||||
return loader.fs.read(SafePath.join(self.path, relative, "mod:read"))
|
||||
end
|
||||
-- mod.world materializes on first touch, like the image helper above: a
|
||||
-- headless load must not drag the world stack in, and the Game the facade
|
||||
@@ -1149,9 +1180,21 @@ function Loader:_game()
|
||||
return engineRequire("src.core.Game")
|
||||
end
|
||||
|
||||
-- The environment every chunk this mod authors runs in, built once per mod so
|
||||
-- its entry file and its options_schema share one globals table.
|
||||
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 })
|
||||
self.modEnv[id] = env
|
||||
end
|
||||
return env
|
||||
end
|
||||
|
||||
function Loader:_loadMod(mod)
|
||||
local path = mod.path .. "/" .. mod.manifest.entry
|
||||
local chunk, err = self.fs.load(path)
|
||||
local path = SafePath.join(mod.path, mod.manifest.entry, "manifest entry")
|
||||
local chunk, err = Sandbox.loadFile(self.fs, path, self:_modEnv(mod))
|
||||
if not chunk then error(err or ("unable to load " .. path)) end
|
||||
local api = self:_api(mod)
|
||||
local result = chunk(api)
|
||||
@@ -1349,10 +1392,12 @@ function Loader:load(data)
|
||||
-- every touch: a mod captures the facade at file scope, before Game2 has a
|
||||
-- save or a world (src/mods/Gen2Compat.lua).
|
||||
Gen2Compat.bind(function() return self:_game() end)
|
||||
-- Dev mode wants the permissions tripwire; a Gold boot with mods on it wants
|
||||
-- the Gen 1-only require report, which is the difference between "the mod
|
||||
-- does nothing" and knowing why. A Gold boot with no mods pays nothing.
|
||||
if self.dev or (self.generation ~= 1 and next(self.mods) ~= nil) then
|
||||
-- Any boot with mods on it needs the gate, because require("io") is how a
|
||||
-- mod would walk out of Sandbox.envFor. Dev mode adds the permissions
|
||||
-- tripwire on top, and a Gold boot the Gen 1-only require report -- the
|
||||
-- difference between "the mod does nothing" and knowing why. A boot with no
|
||||
-- mods pays nothing.
|
||||
if self.dev or next(self.mods) ~= nil then
|
||||
self:_installDevShim()
|
||||
end
|
||||
for _, mod in ipairs(ordered) do
|
||||
|
||||
@@ -8,6 +8,8 @@ local Font = require("src.render.Font")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local ModTargets = require("src.mods.ModTargets")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local SafePath = require("src.mods.SafePath")
|
||||
local Sandbox = require("src.mods.Sandbox")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local Semver = require("src.mods.Semver")
|
||||
local Version = require("src.core.Version")
|
||||
@@ -936,13 +938,18 @@ function ManagerState:schemaFor(m)
|
||||
local schema = loader.optionSchemas and loader.optionSchemas[m.id]
|
||||
if schema == nil and m.options_schema and m.path
|
||||
and loader.fs and loader.fs.load then
|
||||
local chunk = loader.fs.load(m.path .. "/" .. m.options_schema)
|
||||
if chunk then
|
||||
local ok, rows = pcall(chunk)
|
||||
if ok and type(rows) == "table" then
|
||||
schema = rows
|
||||
if loader.optionSchemas then loader.optionSchemas[m.id] = schema end
|
||||
end
|
||||
-- mod-authored code, so it runs in the same sandbox the entry chunk does
|
||||
local ok, rows = pcall(function()
|
||||
local path = SafePath.join(m.path, m.options_schema, "options_schema")
|
||||
local mod = loader.mods and loader.mods[m.id]
|
||||
local env = mod and loader._modEnv and loader:_modEnv(mod)
|
||||
or Sandbox.envFor()
|
||||
local chunk = Sandbox.loadFile(loader.fs, path, env)
|
||||
return chunk and chunk()
|
||||
end)
|
||||
if ok and type(rows) == "table" then
|
||||
schema = rows
|
||||
if loader.optionSchemas then loader.optionSchemas[m.id] = schema end
|
||||
end
|
||||
end
|
||||
return schema
|
||||
|
||||
+13
-3
@@ -3,6 +3,7 @@
|
||||
-- that need to stat a file, this owns shape, vocabulary and range grammar.
|
||||
local Logger = require("src.core.Logger")
|
||||
local ModTargets = require("src.mods.ModTargets")
|
||||
local SafePath = require("src.mods.SafePath")
|
||||
local Semver = require("src.mods.Semver")
|
||||
local Version = require("src.core.Version")
|
||||
|
||||
@@ -144,6 +145,9 @@ function Manifest.validate(raw, path)
|
||||
assert(type(raw.name) == "string" and raw.name ~= "", "manifest name is required")
|
||||
assert(type(raw.version) == "string" and raw.version ~= "", "manifest version is required")
|
||||
assert(type(raw.entry) == "string" and raw.entry ~= "", "manifest entry is required")
|
||||
-- every manifest path is joined to the mod's own directory, so none of them
|
||||
-- may climb out of it (src/mods/SafePath.lua)
|
||||
local entry = SafePath.require(raw.entry, "manifest entry")
|
||||
|
||||
-- absent means 1: full v1 compat, schema violations downgrade to warnings
|
||||
assert(raw.api == nil or tonumber(raw.api) ~= nil, "manifest api must be a number")
|
||||
@@ -229,19 +233,24 @@ function Manifest.validate(raw, path)
|
||||
local affectsLink = profile ~= "content" and not language
|
||||
if type(raw.affects_link) == "boolean" then affectsLink = raw.affects_link end
|
||||
|
||||
local function optionalFile(value, field)
|
||||
local function optionalString(value, field)
|
||||
if value == nil then return nil end
|
||||
assert(type(value) == "string" and value ~= "", field .. " must be a file path")
|
||||
return value
|
||||
end
|
||||
|
||||
local function optionalFile(value, field)
|
||||
local text = optionalString(value, field)
|
||||
return text and SafePath.require(text, field)
|
||||
end
|
||||
|
||||
local conflicts = mergeConflictLists(raw.conflicts, raw.incompatible)
|
||||
|
||||
return {
|
||||
id = raw.id,
|
||||
name = raw.name,
|
||||
version = raw.version,
|
||||
entry = raw.entry,
|
||||
entry = entry,
|
||||
api = api,
|
||||
priority = tonumber(raw.priority) or 0,
|
||||
dependencies = array(raw.dependencies),
|
||||
@@ -265,7 +274,8 @@ function Manifest.validate(raw, path)
|
||||
permissionSet = permissionSet,
|
||||
options_schema = optionalFile(raw.options_schema, "options_schema"),
|
||||
assets_transforms = optionalFile(raw.assets_transforms, "assets_transforms"),
|
||||
force_enable_env = optionalFile(raw.force_enable_env, "force_enable_env"),
|
||||
-- an env var name, not a path, so it keeps the plain string check
|
||||
force_enable_env = optionalString(raw.force_enable_env, "force_enable_env"),
|
||||
path = path,
|
||||
raw = raw,
|
||||
}
|
||||
|
||||
@@ -28,6 +28,11 @@ Runtime.errors = nil
|
||||
-- permissions tripwire knows there is nobody to attribute to
|
||||
Runtime.currentMod = nil
|
||||
|
||||
-- set by the sandbox's require for the duration of one mod-initiated require,
|
||||
-- so the loader's gate can still attribute a lazy one made long after
|
||||
-- currentMod went back to nil (src/mods/Sandbox.lua)
|
||||
Runtime.modRequire = nil
|
||||
|
||||
function Runtime.install(events, hooks, errors)
|
||||
Runtime.events, Runtime.hooks = events, hooks
|
||||
Runtime.errors = errors
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
-- One relative-path grammar for every path a mod supplies: a mod names files
|
||||
-- inside its own directory and nowhere else. love.filesystem (PhysFS) already
|
||||
-- refuses "..", absolute paths and backslashes, but Loader.new takes an
|
||||
-- injected fs that has no such floor, so the rule lives here and not in
|
||||
-- whichever filesystem happens to be underneath.
|
||||
local SafePath = {}
|
||||
|
||||
-- The normalized path, or nil when it could climb out of the root it is about
|
||||
-- to be joined to. "." segments are dropped rather than rejected so a
|
||||
-- manifest that says "./main.lua" still loads.
|
||||
function SafePath.safe(rel)
|
||||
if type(rel) ~= "string" or rel == "" then return nil end
|
||||
if rel:sub(1, 1) == "/" then return nil end
|
||||
if rel:find("\\", 1, true) then return nil end
|
||||
if rel:match("^%a:") then return nil end -- windows drive-relative
|
||||
local parts = {}
|
||||
for segment in rel:gmatch("[^/]+") do
|
||||
if segment == ".." then return nil end
|
||||
if segment ~= "." then parts[#parts + 1] = segment end
|
||||
end
|
||||
if #parts == 0 then return nil end
|
||||
return table.concat(parts, "/")
|
||||
end
|
||||
|
||||
function SafePath.require(rel, what)
|
||||
local safe = SafePath.safe(rel)
|
||||
if not safe then
|
||||
error(("%s must stay inside its root, got %q"):format(what, tostring(rel)), 0)
|
||||
end
|
||||
return safe
|
||||
end
|
||||
|
||||
-- root .. "/" .. rel, with the traversal check in between
|
||||
function SafePath.join(root, rel, what)
|
||||
return root .. "/" .. SafePath.require(rel, what or "path")
|
||||
end
|
||||
|
||||
return SafePath
|
||||
@@ -0,0 +1,227 @@
|
||||
-- The environment a mod's own code runs in. Every chunk a mod authors -- the
|
||||
-- entry file, an options_schema, anything it hands to load() -- runs against
|
||||
-- this table instead of _G, so the only paths it can name are the ones the
|
||||
-- engine hands it (mod:read, mod.storage, mod.assets).
|
||||
--
|
||||
-- What this is and is not: raw io/os/ffi are the only way to name a file
|
||||
-- outside the game tree at all, and they are absent here, so the reported
|
||||
-- "any mod can rewrite anything in your home directory" hole closes by
|
||||
-- construction. Inside the LÖVE tree this is defense in depth, not a security
|
||||
-- boundary: an engine module reached through require, or ImageData:encode,
|
||||
-- still writes in the save directory.
|
||||
--
|
||||
-- Lua 5.1/LuaJIT is the target, so setfenv is the mechanism; the 5.2+ arm
|
||||
-- exists because AssetTransform's sandbox needed it and getting this wrong
|
||||
-- silently hands the chunk the real globals.
|
||||
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local SafePath = require("src.mods.SafePath")
|
||||
|
||||
local Sandbox = {}
|
||||
|
||||
-- Modules that hand a mod the disk, a raw socket or a fresh Lua state no
|
||||
-- matter what this file removes from the environment. package.loaded.io is
|
||||
-- the one call that would undo every other rule here.
|
||||
local DENIED = {
|
||||
io = "the filesystem", os = "the filesystem", debug = "the debug library",
|
||||
package = "the module loader", ffi = "arbitrary C calls",
|
||||
}
|
||||
|
||||
-- Same idea one level up: love.filesystem is reachable by name, and
|
||||
-- love.thread starts a Lua state this sandbox has no say over.
|
||||
local DENIED_PREFIX = { ["love"] = true, ["ffi"] = true }
|
||||
|
||||
-- The wire, which is what the network permission governs.
|
||||
local NETWORK = { socket = true, enet = true, http = true, https = true,
|
||||
ssl = true, mime = true, ltn12 = true }
|
||||
|
||||
local function head(name)
|
||||
return (name:match("^([^%.]+)")) or name
|
||||
end
|
||||
|
||||
-- nil when the require is allowed, else the message to fail it with.
|
||||
function Sandbox.moduleDenial(name, permissionSet)
|
||||
if type(name) ~= "string" then return nil end
|
||||
local root = head(name)
|
||||
local reason = DENIED[root]
|
||||
if reason then
|
||||
return ("%s is not available to mods (it grants %s); use mod.storage, "
|
||||
.. "mod:read and the engine API instead"):format(name, reason)
|
||||
end
|
||||
if DENIED_PREFIX[root] and name ~= root then
|
||||
return ("%s is not available to mods; use mod.storage, mod:read and the "
|
||||
.. "engine API instead"):format(name)
|
||||
end
|
||||
if NETWORK[root] and not (permissionSet or {}).network then
|
||||
return ("%s needs the \"network\" permission in manifest.json"):format(name)
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- ------- the love facade
|
||||
|
||||
-- Dropped, not narrowed: filesystem writes anywhere in the save directory
|
||||
-- (including another mod's storage), thread opens a Lua state with a full
|
||||
-- standard library, system.openURL launches whatever it is handed, and event
|
||||
-- lets a mod quit the game out from under the player. Everything else LÖVE
|
||||
-- exposes passes through, so a new module in a future LÖVE is available
|
||||
-- without an edit here.
|
||||
-- value is the replacement to name in the error, or true when there is none
|
||||
local BLOCKED_LOVE = {
|
||||
filesystem = "mod.storage and mod:read", thread = true,
|
||||
system = "mod.device:powerInfo() for battery information", event = true,
|
||||
}
|
||||
|
||||
local loveProxy
|
||||
local function loveFacade()
|
||||
if loveProxy or not _G.love then return loveProxy end
|
||||
loveProxy = setmetatable({}, {
|
||||
__index = function(_, key)
|
||||
local hint = BLOCKED_LOVE[key]
|
||||
if hint then
|
||||
error(("love.%s is not available to mods%s"):format(key,
|
||||
type(hint) == "string" and (", use " .. hint) or ""), 2)
|
||||
end
|
||||
return _G.love[key]
|
||||
end,
|
||||
__newindex = function(_, key)
|
||||
error(("mods cannot assign love.%s"):format(tostring(key)), 2)
|
||||
end,
|
||||
})
|
||||
return loveProxy
|
||||
end
|
||||
|
||||
-- ------- the environment
|
||||
|
||||
-- Absent on purpose: io, package, dofile, loadfile, getfenv, setfenv, debug,
|
||||
-- newproxy, module. os keeps only the clock -- getenv is how the reported
|
||||
-- exploit found the user's home directory.
|
||||
local SAFE_OS = { time = true, date = true, clock = true, difftime = true }
|
||||
|
||||
-- Per-mod copies, not the shared tables: a mod that assigns string.trim or
|
||||
-- replaces table.insert changes its own view and nobody else's. The functions
|
||||
-- are the same objects, so state behind them (math.randomseed's RNG) is
|
||||
-- unaffected -- only the namespace is private.
|
||||
local function copy(source)
|
||||
if type(source) ~= "table" then return source end
|
||||
local out = {}
|
||||
for key, value in pairs(source) do out[key] = value end
|
||||
return out
|
||||
end
|
||||
|
||||
local function baseGlobals()
|
||||
local safeOs = {}
|
||||
for key in pairs(SAFE_OS) do safeOs[key] = os[key] end
|
||||
return {
|
||||
assert = assert, error = error, ipairs = ipairs, next = next,
|
||||
pairs = pairs, pcall = pcall, xpcall = xpcall, select = select,
|
||||
tonumber = tonumber, tostring = tostring, type = type, unpack = unpack,
|
||||
rawequal = rawequal, rawget = rawget, rawset = rawset, rawlen = rawlen,
|
||||
setmetatable = setmetatable, getmetatable = getmetatable, print = print,
|
||||
collectgarbage = collectgarbage, _VERSION = _VERSION,
|
||||
coroutine = copy(coroutine), math = copy(math), string = copy(string),
|
||||
table = copy(table), bit = copy(bit), jit = jit, os = safeOs,
|
||||
}
|
||||
end
|
||||
|
||||
-- setfenv on 5.1/LuaJIT; on 5.2+ the env has to be handed to load itself, so
|
||||
-- a caller there compiles through Sandbox.compile instead.
|
||||
function Sandbox.bind(chunk, env)
|
||||
if setfenv then setfenv(chunk, env) end
|
||||
return chunk
|
||||
end
|
||||
|
||||
-- Bytecode is unreviewable and, on LuaJIT, a way out of any sandbox built out
|
||||
-- of environments. Mods ship source.
|
||||
local function rejectBytecode(source, what)
|
||||
if type(source) == "string" and source:sub(1, 1) == "\27" then
|
||||
return nil, (what or "chunk") .. ": mods must ship Lua source, not bytecode"
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
function Sandbox.compile(source, chunkname, env)
|
||||
local ok, err = rejectBytecode(source, chunkname)
|
||||
if not ok then return nil, err end
|
||||
if setfenv then
|
||||
local chunk, compileErr = loadstring(source, chunkname)
|
||||
if not chunk then return nil, compileErr end
|
||||
return setfenv(chunk, env)
|
||||
end
|
||||
return load(source, chunkname, "t", env)
|
||||
end
|
||||
|
||||
-- The load() a mod sees. Lua 5.1 gives a loaded chunk the GLOBAL environment
|
||||
-- rather than the caller's, so without this every sandboxed mod is one
|
||||
-- load(mod:read(...)) away from the real _G -- which is exactly how the
|
||||
-- multi-file mods in mods/ are written.
|
||||
local function sandboxedLoad(env)
|
||||
return function(chunk, chunkname)
|
||||
if type(chunk) == "function" then
|
||||
local parts = {}
|
||||
while true do
|
||||
local piece = chunk()
|
||||
if piece == nil or piece == "" then break end
|
||||
parts[#parts + 1] = piece
|
||||
end
|
||||
chunk = table.concat(parts)
|
||||
end
|
||||
if type(chunk) ~= "string" then return nil, "load expects a string or reader" end
|
||||
return Sandbox.compile(chunk, chunkname or "=(load)", env)
|
||||
end
|
||||
end
|
||||
|
||||
-- The require a mod sees: the deny list lives here rather than on a stack
|
||||
-- walk, because pcall(require, "io") puts a C frame where the walk would look.
|
||||
-- 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)
|
||||
return function(name, ...)
|
||||
local denial = Sandbox.moduleDenial(name, permissionSet)
|
||||
if denial then error(("[%s] %s"):format(modId or "mod", denial), 2) end
|
||||
local previous = Runtime.modRequire
|
||||
Runtime.modRequire = modId or true
|
||||
local ok, result = pcall(_G.require, name, ...)
|
||||
Runtime.modRequire = previous
|
||||
if not ok then error(result, 0) end
|
||||
return result
|
||||
end
|
||||
end
|
||||
|
||||
function Sandbox.envFor(opts)
|
||||
opts = opts or {}
|
||||
local env = baseGlobals()
|
||||
env.love = loveFacade()
|
||||
env.require = sandboxedRequire(opts.modId, opts.permissions)
|
||||
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
|
||||
return env
|
||||
end
|
||||
|
||||
-- fs.load keeps the real filesystem's handling of the file; the environment is
|
||||
-- swapped after the fact. The 5.2+ arm has to go back to source, which is the
|
||||
-- only reason fs.read is touched here.
|
||||
function Sandbox.loadFile(fs, path, env)
|
||||
if fs.read then
|
||||
local ok, err = rejectBytecode(fs.read(path), path)
|
||||
if not ok then return nil, err end
|
||||
end
|
||||
if setfenv then
|
||||
local chunk, err = fs.load(path)
|
||||
if not chunk then return nil, err end
|
||||
return setfenv(chunk, env)
|
||||
end
|
||||
local source = fs.read and fs.read(path)
|
||||
if not source then return nil, "unable to read " .. path end
|
||||
return Sandbox.compile(source, "@" .. path, env)
|
||||
end
|
||||
|
||||
Sandbox.safePath = SafePath.safe
|
||||
Sandbox.requirePath = SafePath.require
|
||||
|
||||
return Sandbox
|
||||
@@ -72,10 +72,11 @@ local function writeProbe()
|
||||
fs.write("mods/cold_start_probe/manifest.json",
|
||||
'{"id":"cold_start_probe","name":"cold start probe","version":"1.0.0",'
|
||||
.. '"entry":"main.lua","api":2,"profile":"content"}')
|
||||
-- mod.exports, not _G: a mod's globals are its own (src/mods/Sandbox.lua)
|
||||
fs.write("mods/cold_start_probe/main.lua", [[
|
||||
return function(mod)
|
||||
_G.COLD_STORAGE = mod.storage
|
||||
_G.COLD_CHECKPOINTS = mod.checkpoints
|
||||
mod.exports.storage = mod.storage
|
||||
mod.exports.checkpoints = mod.checkpoints
|
||||
end
|
||||
]])
|
||||
end
|
||||
@@ -125,11 +126,12 @@ if phase == "capture" then
|
||||
local game = runtime(SaveData.newGame({ version = "red" }), false)
|
||||
loader.game = game
|
||||
assert(loader:load({}) == true)
|
||||
assert(_G.COLD_STORAGE:write(game, "history/index", { newest = "q0001" }))
|
||||
local checkpoint = assert(_G.COLD_CHECKPOINTS:capture(game))
|
||||
assert(_G.COLD_STORAGE:write(game, "history/q0001", checkpoint))
|
||||
local probe = assert(loader.exports.cold_start_probe)
|
||||
assert(probe.storage:write(game, "history/index", { newest = "q0001" }))
|
||||
local checkpoint = assert(probe.checkpoints:capture(game))
|
||||
assert(probe.storage:write(game, "history/q0001", checkpoint))
|
||||
local id = assert(game.save.meta.playthroughId)
|
||||
assert(_G.COLD_CHECKPOINTS:ensureNormalSave(game, checkpoint))
|
||||
assert(probe.checkpoints:ensureNormalSave(game, checkpoint))
|
||||
local normal = assert(SaveData.load("red"))
|
||||
assert(normal.meta.playthroughId == id)
|
||||
fs.write("cold-start-witness.lua", SaveSerializer.encode({ playthroughId = id }))
|
||||
@@ -139,15 +141,16 @@ else
|
||||
title.save.options = { volume = 7, bindings = {} }
|
||||
loader.game = title
|
||||
assert(loader:load({}) == true)
|
||||
local selected = assert(_G.COLD_STORAGE:selected(title))
|
||||
local probe = assert(loader.exports.cold_start_probe)
|
||||
local selected = assert(probe.storage:selected(title))
|
||||
local witness = assert(SaveSerializer.decode(assert(fs.read("cold-start-witness.lua"))))
|
||||
assert(selected:context().playthroughId == witness.playthroughId)
|
||||
assert(selected:read("history/index").newest == "q0001")
|
||||
local checkpoint = assert(selected:read("history/q0001"))
|
||||
assert(_G.COLD_CHECKPOINTS:resume(title, checkpoint))
|
||||
assert(probe.checkpoints:resume(title, checkpoint))
|
||||
assert(title.save.meta.playthroughId == witness.playthroughId)
|
||||
assert(title.save.options.volume == 7)
|
||||
assert(SaveSerializer.encode(_G.COLD_CHECKPOINTS:capture(title))
|
||||
assert(SaveSerializer.encode(probe.checkpoints:capture(title))
|
||||
== SaveSerializer.encode(checkpoint))
|
||||
local normal = assert(SaveData.load("red"))
|
||||
assert(normal.meta.playthroughId == witness.playthroughId)
|
||||
|
||||
@@ -102,20 +102,19 @@ if not headlessOk then error(headlessErr) end
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
-- ------- discovery, dependency order, merge
|
||||
-- "addon" sorts before "base" so only the dependency edge can order them
|
||||
_G.MOD_TEST_ORDER = {}
|
||||
-- "addon" sorts before "base" so only the dependency edge can order them.
|
||||
-- loader.order is the engine's own record of what ran when; a mod cannot
|
||||
-- append to a shared global any more (src/mods/Sandbox.lua).
|
||||
local files = {
|
||||
["mods/addon/manifest.json"] = manifestJson("addon", '["base"]'),
|
||||
["mods/addon/main.lua"] = [[
|
||||
return function(mod)
|
||||
_G.MOD_TEST_ORDER[#_G.MOD_TEST_ORDER + 1] = "addon"
|
||||
mod.content.pokemon:override("MODMON", { name = "ADDONMON" })
|
||||
end
|
||||
]],
|
||||
["mods/base/manifest.json"] = manifestJson("base"),
|
||||
["mods/base/main.lua"] = [[
|
||||
return function(mod)
|
||||
_G.MOD_TEST_ORDER[#_G.MOD_TEST_ORDER + 1] = "base"
|
||||
mod.content.pokemon:register("MODMON", { name = "BASEMON" })
|
||||
mod.content.music:register("MOD_SONG", { file = "song.ogg" })
|
||||
end
|
||||
@@ -126,7 +125,7 @@ local loader = Loader.new({ fs = memfs(files) })
|
||||
check(loader:load(data) == true, "headless load succeeds with injected fs")
|
||||
check(loader.mods.addon ~= nil and loader.mods.base ~= nil,
|
||||
"discovery finds both mods")
|
||||
check(_G.MOD_TEST_ORDER[1] == "base" and _G.MOD_TEST_ORDER[2] == "addon",
|
||||
check(loader.order[1] == "base" and loader.order[2] == "addon",
|
||||
"topo-sort runs the dependency before its dependent")
|
||||
check(data.pokemon.MODMON ~= nil and data.pokemon.MODMON.name == "ADDONMON",
|
||||
"registered content merges into data")
|
||||
@@ -411,6 +410,5 @@ local StateStack = require("src.core.StateStack")
|
||||
while StateStack:top() do StateStack:pop() end
|
||||
require("src.core.Music").stop()
|
||||
Runtime.install(savedEvents, savedHooks)
|
||||
_G.MOD_TEST_ORDER = nil
|
||||
|
||||
S.finish()
|
||||
|
||||
@@ -341,7 +341,8 @@ check(cycleStatus.innocent.state == "loaded" and cycleData.items.FINE ~= nil,
|
||||
"a mod beside the cycle loads normally")
|
||||
|
||||
-- ------- inter-mod exports and find
|
||||
_G.MOD_FIND_RESULTS = {}
|
||||
-- probes report through mod.exports: a mod's globals are its own
|
||||
-- (src/mods/Sandbox.lua)
|
||||
local exportLoader = Loader.new({ fs = memfs({
|
||||
["mods/colorlib/manifest.json"] = manifestJson("colorlib"),
|
||||
["mods/colorlib/main.lua"] = [[
|
||||
@@ -356,7 +357,7 @@ end
|
||||
}),
|
||||
["mods/daynight/main.lua"] = [[
|
||||
return function(mod)
|
||||
local results = _G.MOD_FIND_RESULTS
|
||||
local results = mod.exports
|
||||
local color = mod.find("colorlib")
|
||||
results.depVersion = color.version
|
||||
results.tint = color.exports.tint("dusk")
|
||||
@@ -371,7 +372,7 @@ end
|
||||
["options.lua"] = "return { mods = { shelved = false } }",
|
||||
}) })
|
||||
check(exportLoader:load({}) == true, "the export fixture loads clean")
|
||||
local found = _G.MOD_FIND_RESULTS
|
||||
local found = exportLoader.exports.daynight
|
||||
check(found.tint == "tinted:dusk", "find returns the other mod's live export table")
|
||||
check(found.depVersion == "1.0.0", "the handle carries the other mod's version")
|
||||
check(found.optional == true, "an enabled optional dependency is findable")
|
||||
@@ -380,10 +381,8 @@ check(found.disabled == nil, "find returns nil for a disabled mod")
|
||||
check(found.method == true, "mod:find is tolerated alongside mod.find")
|
||||
check(exportLoader.order[1] == "colorlib",
|
||||
"a hard dependency executes before its dependent")
|
||||
_G.MOD_FIND_RESULTS = nil
|
||||
|
||||
-- ------- the rest of the v2 mod object
|
||||
_G.MOD_OBJECT_PROBE = {}
|
||||
local objectLoader = Loader.new({ fs = memfs({
|
||||
["mods/probe/manifest.json"] = manifestJson("probe", {
|
||||
api = "2", description = '"probing"', priority = "3",
|
||||
@@ -391,7 +390,7 @@ local objectLoader = Loader.new({ fs = memfs({
|
||||
["mods/probe/data.txt"] = "hello from the mod dir",
|
||||
["mods/probe/main.lua"] = [[
|
||||
return function(mod)
|
||||
local probe = _G.MOD_OBJECT_PROBE
|
||||
local probe = mod.exports
|
||||
probe.id, probe.version, probe.path = mod.id, mod.version, mod.path
|
||||
probe.manifestApi = mod.manifest.api
|
||||
mod.manifest.api = 99
|
||||
@@ -423,7 +422,7 @@ end
|
||||
["options.lua"] = "return { modOptions = { probe = { volume = 4 } } }",
|
||||
}) })
|
||||
check(objectLoader:load({ pokemon = {} }) == true, "the mod object fixture loads clean")
|
||||
local probe = _G.MOD_OBJECT_PROBE
|
||||
local probe = objectLoader.exports.probe
|
||||
check(probe.id == "probe" and probe.version == "1.0.0" and probe.path == "mods/probe",
|
||||
"identity fields are present")
|
||||
check(probe.manifestApi == 2 and objectLoader.mods.probe.manifest.api == 2,
|
||||
@@ -449,7 +448,6 @@ check(probe.onceCount == 1 and probe.stillHeard == true,
|
||||
"events:once fires once and does not skip the listener behind it")
|
||||
check(tostring(probe.forgery):find("may only emit", 1, true) ~= nil,
|
||||
"a mod cannot emit outside its own event namespace")
|
||||
_G.MOD_OBJECT_PROBE = nil
|
||||
|
||||
-- a failing entry chunk takes its exports, commands and migrations with it
|
||||
local residueLoader = Loader.new({ fs = memfs({
|
||||
|
||||
+21
-23
@@ -87,13 +87,19 @@ end
|
||||
|
||||
-- ------- a mod registers two pipelines and the engine dispatches them
|
||||
|
||||
local trace = {}
|
||||
|
||||
-- The probe table is the mod's, published through mod.exports: a mod's
|
||||
-- globals are its own now (src/mods/Sandbox.lua). The world/present folds
|
||||
-- accept only a real Canvas, so the mod makes concrete ones to return and the
|
||||
-- test pins identity through the dispatch.
|
||||
local FILES = {
|
||||
["mods/painter/manifest.json"] = manifest("painter", ',"priority":10'),
|
||||
["mods/painter/main.lua"] = [[
|
||||
local mod = ...
|
||||
local T = _G.__RENDER_TEST
|
||||
local T = mod.exports
|
||||
T.trace, T.available = {}, true
|
||||
T.worldOut = love.graphics.newCanvas(2, 2)
|
||||
T.blurOut = love.graphics.newCanvas(2, 2)
|
||||
T.gradeOut = love.graphics.newCanvas(2, 2)
|
||||
mod.content.render_pipelines:register("diorama", {
|
||||
label = "DIORAMA",
|
||||
levels = { "OFF", "LOW", "HIGH" },
|
||||
@@ -101,8 +107,6 @@ local FILES = {
|
||||
priority = 20,
|
||||
available = function() return T.available end,
|
||||
update = function(dt, level) T.trace[#T.trace + 1] = "update:" .. level end,
|
||||
-- the folds composite only a real Canvas, so the mod hands back the
|
||||
-- canvases the test pre-created (see T.worldOut / T.blurOut / T.gradeOut)
|
||||
drawWorld = function(ctx)
|
||||
T.trace[#T.trace + 1] = "world:" .. tostring(ctx.tag)
|
||||
return T.worldOut
|
||||
@@ -123,17 +127,12 @@ local FILES = {
|
||||
]],
|
||||
}
|
||||
|
||||
_G.__RENDER_TEST = { trace = trace, available = true }
|
||||
-- the world/present folds accept only a real Canvas, so give the mod concrete
|
||||
-- ones to return and pin identity through the dispatch
|
||||
_G.__RENDER_TEST.worldOut = love.graphics.newCanvas(2, 2)
|
||||
_G.__RENDER_TEST.blurOut = love.graphics.newCanvas(2, 2)
|
||||
_G.__RENDER_TEST.gradeOut = love.graphics.newCanvas(2, 2)
|
||||
|
||||
local data = {}
|
||||
local loader = Loader.new({ fs = memfs(FILES) })
|
||||
local okLoad = loader:load(data)
|
||||
check(okLoad, "the pipeline mod loads clean: " .. table.concat(loader.errors, "; "))
|
||||
local RT = loader.exports.painter
|
||||
local trace = RT.trace
|
||||
Pipelines.install(data)
|
||||
|
||||
check(type(data.render_pipelines) == "table",
|
||||
@@ -169,24 +168,24 @@ Pipelines.setLevel("grade", 1)
|
||||
|
||||
eq(Pipelines.worldPipeline(), "diorama",
|
||||
"the eligible world pipeline claims the world pass")
|
||||
eq(Pipelines.drawWorld("diorama", { tag = "ctx" }), _G.__RENDER_TEST.worldOut,
|
||||
eq(Pipelines.drawWorld("diorama", { tag = "ctx" }), RT.worldOut,
|
||||
"drawWorld returns the mod's canvas")
|
||||
eq(trace[#trace], "world:ctx", "drawWorld received the frame context")
|
||||
|
||||
eq(Pipelines.worldPresent(_G.__RENDER_TEST.worldOut), _G.__RENDER_TEST.blurOut,
|
||||
eq(Pipelines.worldPresent(RT.worldOut), RT.blurOut,
|
||||
"worldPresent folds its canvas over the world image")
|
||||
eq(Pipelines.wantsPresent(), true, "a live present pass asks for the canvas")
|
||||
eq(Pipelines.present(_G.__RENDER_TEST.gradeOut), _G.__RENDER_TEST.gradeOut,
|
||||
eq(Pipelines.present(RT.gradeOut), RT.gradeOut,
|
||||
"present folds its canvas over the finished composite")
|
||||
|
||||
-- ------- the hardware gate
|
||||
|
||||
_G.__RENDER_TEST.available = false
|
||||
RT.available = false
|
||||
eq(Pipelines.worldPipeline(), nil,
|
||||
"an unavailable pipeline never takes the world pass")
|
||||
eq(Pipelines.worldPresent("world-canvas"), "world-canvas",
|
||||
"an unavailable pipeline's worldPresent is skipped")
|
||||
_G.__RENDER_TEST.available = true
|
||||
RT.available = true
|
||||
eq(Pipelines.worldPipeline(), "diorama", "availability is re-read each frame")
|
||||
|
||||
-- ------- the gate governs input, never the draw
|
||||
@@ -282,7 +281,8 @@ local SLOPPY = {
|
||||
["mods/sloppy/manifest.json"] = manifest("sloppy"),
|
||||
["mods/sloppy/main.lua"] = [[
|
||||
local mod = ...
|
||||
local T = _G.__SLOPPY
|
||||
local T = mod.exports
|
||||
T.ran = 0
|
||||
mod.content.render_pipelines:register("sloppy", {
|
||||
label = "SLOPPY",
|
||||
present = function(canvas)
|
||||
@@ -302,20 +302,20 @@ local SLOPPY = {
|
||||
})
|
||||
]],
|
||||
}
|
||||
_G.__SLOPPY = { ran = 0 }
|
||||
local sloppyData = {}
|
||||
local sloppyLoader = Loader.new({ fs = memfs(SLOPPY) })
|
||||
sloppyLoader:load(sloppyData)
|
||||
local SL = sloppyLoader.exports.sloppy
|
||||
Pipelines.install(sloppyData)
|
||||
|
||||
local composite = love.graphics.newCanvas(4, 4)
|
||||
Pipelines.setLevel("sloppy", 1)
|
||||
for _, bad in ipairs({ "just-a-string", true, 42 }) do
|
||||
_G.__SLOPPY.ret = bad
|
||||
SL.ret = bad
|
||||
eq(Pipelines.present(composite), composite,
|
||||
"a present returning a " .. type(bad) .. " leaves the composite untouched")
|
||||
end
|
||||
check(_G.__SLOPPY.ran == 3, "the present callback still ran each frame")
|
||||
check(SL.ran == 3, "the present callback still ran each frame")
|
||||
check(Pipelines.eligible("sloppy") == true,
|
||||
"a non-canvas return does not retire the pipeline as broken")
|
||||
Pipelines.setLevel("sloppy", 0)
|
||||
@@ -334,11 +334,9 @@ eq(love.graphics.getCanvas(), "engine-canvas",
|
||||
eq(love.graphics.getBlendMode(), "alpha",
|
||||
"a present that changed blend mode cannot leak it past the fold")
|
||||
Pipelines.setLevel("dirty", 0)
|
||||
_G.__SLOPPY = nil
|
||||
|
||||
Pipelines.reset()
|
||||
Pipelines.install(nil)
|
||||
_G.__RENDER_TEST = nil
|
||||
|
||||
-- ------- and with no mods at all, the whole subsystem is inert
|
||||
|
||||
|
||||
@@ -139,20 +139,20 @@ local files = {
|
||||
["mods/probe/manifest.json"] =
|
||||
'{"id":"probe","name":"probe","version":"1.0.0",'
|
||||
.. '"entry":"main.lua","api":2,"profile":"content"}',
|
||||
-- mod.exports, not _G: a mod's globals are its own (src/mods/Sandbox.lua)
|
||||
["mods/probe/main.lua"] = [[
|
||||
return function(mod) _G.MOD_CHECKPOINTS = mod.checkpoints end
|
||||
return function(mod) mod.exports.checkpoints = mod.checkpoints end
|
||||
]],
|
||||
}
|
||||
local game, ow = makeGame()
|
||||
local loader = Loader.new({ fs = memfs(files) })
|
||||
loader.game = game
|
||||
T.check(loader:load({}) == true, "checkpoint fixture mod loads")
|
||||
local checkpoints = _G.MOD_CHECKPOINTS
|
||||
local checkpoints = (loader.exports.probe or {}).checkpoints
|
||||
T.check(type(checkpoints) == "table",
|
||||
"Loader exposes mod.checkpoints through the public mod object")
|
||||
if type(checkpoints) ~= "table" then
|
||||
Runtime.events, Runtime.hooks = savedEvents, savedHooks
|
||||
_G.MOD_CHECKPOINTS = nil
|
||||
T.finish()
|
||||
end
|
||||
|
||||
@@ -435,7 +435,6 @@ end
|
||||
|
||||
Runtime.events, Runtime.hooks = savedEvents, savedHooks
|
||||
Runtime.currentMod = nil
|
||||
_G.MOD_CHECKPOINTS = nil
|
||||
love.math.getRandomState = oldGetRandomState
|
||||
love.math.setRandomState = oldSetRandomState
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
-- A sandboxed mod may read battery state without receiving love.system and
|
||||
-- its process-launching surface.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
|
||||
local FIXTURE = {
|
||||
["mods/power_probe/manifest.json"] = [[{
|
||||
"id": "power_probe",
|
||||
"name": "Power Probe",
|
||||
"version": "1.0.0",
|
||||
"entry": "main.lua",
|
||||
"api": 2
|
||||
}]],
|
||||
["mods/power_probe/main.lua"] = [[
|
||||
local mod = ...
|
||||
mod.exports.state, mod.exports.percent = mod.device:powerInfo()
|
||||
]],
|
||||
}
|
||||
|
||||
local saved = T.love.system.getPowerInfo
|
||||
local calls = 0
|
||||
T.love.system.getPowerInfo = function()
|
||||
calls = calls + 1
|
||||
return "charging", 42, 900
|
||||
end
|
||||
|
||||
local vanilla = T.sdk.loadNone({})
|
||||
T.eq(calls, 0, "no mod leaves the device power backend cold")
|
||||
vanilla.release()
|
||||
|
||||
local run = T.sdk.loadMods({ "mods/power_probe" },
|
||||
{ fs = T.sdk.memfs(FIXTURE) })
|
||||
T.eq(#run.errors, 0,
|
||||
"the sandboxed power probe loads clean (" .. tostring(run.errors[1]) .. ")")
|
||||
local out = run.loader.exports.power_probe or {}
|
||||
T.eq(out.state, "charging", "the public facade reports battery state")
|
||||
T.eq(out.percent, 42, "the public facade reports battery percentage")
|
||||
T.eq(calls, 1, "one facade read makes one platform call")
|
||||
run.release()
|
||||
|
||||
T.love.system.getPowerInfo = nil
|
||||
local unavailable = T.sdk.loadMods({ "mods/power_probe" },
|
||||
{ fs = T.sdk.memfs(FIXTURE) })
|
||||
out = unavailable.loader.exports.power_probe or {}
|
||||
T.eq(out.state, "unknown", "a missing platform backend has a stable state")
|
||||
T.eq(out.percent, nil, "a missing platform backend has no invented percentage")
|
||||
unavailable.release()
|
||||
|
||||
T.love.system.getPowerInfo = saved
|
||||
T.finish("device_power_info")
|
||||
@@ -23,16 +23,16 @@ local FIXTURE = {
|
||||
extraPolls = extraPolls + 1
|
||||
if not paused then nextFn(game, dt) end
|
||||
end)
|
||||
local vetoQuit = false
|
||||
mod.hooks:wrap("core.quit_to_launcher", function(nextFn)
|
||||
if os.getenv("FIXTURE_VETO_QUIT") == "1" then return false end
|
||||
if vetoQuit then return false end
|
||||
return nextFn()
|
||||
end)
|
||||
-- test-only knobs, read back through mod.storage-free globals since
|
||||
-- this fixture never leaves the process
|
||||
_G.__fixturePlatformBridge = {
|
||||
setPaused = function(v) paused = v end,
|
||||
extraPolls = function() return extraPolls end,
|
||||
}
|
||||
-- test-only knobs on mod.exports: a sandboxed mod has no shared _G to
|
||||
-- smuggle them through (src/mods/Sandbox.lua)
|
||||
mod.exports.setPaused = function(v) paused = v end
|
||||
mod.exports.extraPolls = function() return extraPolls end
|
||||
mod.exports.setVetoQuit = function(v) vetoQuit = v end
|
||||
]],
|
||||
}
|
||||
|
||||
@@ -43,23 +43,23 @@ do
|
||||
T.eq(#run.errors, 0,
|
||||
"the fixture mod loads clean (" .. tostring(run.errors[1]) .. ")")
|
||||
|
||||
local bridge = run.loader.exports.fix_platform_bridge
|
||||
local calls = 0
|
||||
local fakeGame = { update = function(self, dt) calls = calls + 1 end }
|
||||
|
||||
_G.__fixturePlatformBridge.setPaused(false)
|
||||
bridge.setPaused(false)
|
||||
PlatformHooks.update(fakeGame, 1 / 60)
|
||||
T.eq(calls, 1, "unpaused: vanilla Game:update runs")
|
||||
T.eq(_G.__fixturePlatformBridge.extraPolls(), 1,
|
||||
T.eq(bridge.extraPolls(), 1,
|
||||
"the subscriber's wrapper runs every frame")
|
||||
|
||||
_G.__fixturePlatformBridge.setPaused(true)
|
||||
bridge.setPaused(true)
|
||||
PlatformHooks.update(fakeGame, 1 / 60)
|
||||
T.eq(calls, 1, "paused: vanilla Game:update is skipped")
|
||||
T.eq(_G.__fixturePlatformBridge.extraPolls(), 2,
|
||||
T.eq(bridge.extraPolls(), 2,
|
||||
"the subscriber keeps polling every frame while paused")
|
||||
|
||||
run.release()
|
||||
_G.__fixturePlatformBridge = nil
|
||||
end
|
||||
|
||||
-- core.quit_to_launcher: a subscriber can veto without the vanilla
|
||||
@@ -70,11 +70,8 @@ do
|
||||
T.eq(#run.errors, 0,
|
||||
"the fixture mod loads clean (" .. tostring(run.errors[1]) .. ")")
|
||||
|
||||
local realGetenv = os.getenv
|
||||
os.getenv = function(name)
|
||||
if name == "FIXTURE_VETO_QUIT" then return "1" end
|
||||
return realGetenv(name)
|
||||
end
|
||||
local bridge = run.loader.exports.fix_platform_bridge
|
||||
bridge.setVetoQuit(true)
|
||||
local vanillaCalls = 0
|
||||
local vetoed = PlatformHooks.quitToLauncher(function()
|
||||
vanillaCalls = vanillaCalls + 1
|
||||
@@ -82,7 +79,7 @@ do
|
||||
end)
|
||||
T.eq(vetoed, false, "a subscriber can veto the return-to-launcher decision")
|
||||
T.eq(vanillaCalls, 0, "a veto never evaluates the vanilla condition")
|
||||
os.getenv = realGetenv
|
||||
bridge.setVetoQuit(false)
|
||||
|
||||
local passed = PlatformHooks.quitToLauncher(function() return true end)
|
||||
T.eq(passed, true, "with no veto, the vanilla decision passes through unchanged")
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
-- 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.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Manifest = require("src.mods.Manifest")
|
||||
local Sandbox = require("src.mods.Sandbox")
|
||||
local SafePath = require("src.mods.SafePath")
|
||||
|
||||
local function manifest(id, extra)
|
||||
return ('{"id":"%s","name":"%s","version":"1.0.0","entry":"main.lua",'
|
||||
.. '"api":2%s}'):format(id, id, extra or "")
|
||||
end
|
||||
|
||||
-- what a probe reports back; pcall'd so one broken assumption does not take
|
||||
-- the whole entry chunk down and hide the rest
|
||||
local PROBE = [[
|
||||
local mod = ...
|
||||
local out = mod.exports
|
||||
out.io = io
|
||||
out.package = package
|
||||
out.dofile = dofile
|
||||
out.loadfile = loadfile
|
||||
out.setfenv = setfenv
|
||||
out.getfenv = getfenv
|
||||
out.debug = debug
|
||||
out.osGetenv = os.getenv
|
||||
out.osExecute = os.execute
|
||||
out.osRemove = os.remove
|
||||
out.osTime = type(os.time)
|
||||
out.stringOk = ("a"):rep(3)
|
||||
|
||||
local function attempt(fn, ...)
|
||||
local ok, err = pcall(fn, ...)
|
||||
if ok then return false end
|
||||
return tostring(err)
|
||||
end
|
||||
out.requireIo = attempt(require, "io")
|
||||
out.requireOs = attempt(require, "os")
|
||||
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 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.childSharesEnv = childG == _G
|
||||
|
||||
out.readEscape = attempt(function() return mod:read("../../secret.txt") end)
|
||||
out.readAbsolute = attempt(function() return mod:read("/etc/hosts") end)
|
||||
out.readBackslash = attempt(function() return mod:read("..\\secret.txt") end)
|
||||
out.assetsEscape = attempt(function() return mod.assets:path("../../x.png") end)
|
||||
out.readOwn = mod:read("data/note.txt")
|
||||
|
||||
_G.SANDBOX_LEAK = "escaped"
|
||||
out.globalsAreOwn = _G ~= nil and _G.SANDBOX_LEAK == "escaped"
|
||||
-- a mod stomping the standard library must not reach the engine
|
||||
table.insert = function() error("stomped") end
|
||||
string.format = function() error("stomped") end
|
||||
]]
|
||||
|
||||
local FILES = {
|
||||
["mods/fix_sandbox/manifest.json"] = manifest("fix_sandbox"),
|
||||
["mods/fix_sandbox/main.lua"] = PROBE,
|
||||
["mods/fix_sandbox/data/note.txt"] = "own file",
|
||||
}
|
||||
|
||||
local run = T.sdk.loadMods({ "mods/fix_sandbox" }, { fs = T.sdk.memfs(FILES) })
|
||||
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
|
||||
|
||||
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.stringOk, "aaa", "the safe standard library is intact")
|
||||
|
||||
-- ------- require, the one call that would undo all of the above
|
||||
|
||||
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")
|
||||
T.check(out.requireSocket and out.requireSocket:find("network", 1, true),
|
||||
"a network module 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")
|
||||
|
||||
-- ------- env propagation and isolation
|
||||
|
||||
T.eq(out.childIo, nil,
|
||||
"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.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")
|
||||
T.eq(("%d"):format(1), "1",
|
||||
"a mod stomping string.format cannot reach the engine's copy")
|
||||
do
|
||||
local probe = {}
|
||||
table.insert(probe, "still works")
|
||||
T.eq(probe[1], "still works",
|
||||
"nor table.insert -- each mod gets its own standard-library namespace")
|
||||
end
|
||||
|
||||
-- ------- paths
|
||||
|
||||
T.check(out.readEscape and out.readEscape:find("must stay inside", 1, true),
|
||||
"mod:read cannot climb out of the mod directory: " .. tostring(out.readEscape))
|
||||
T.check(out.readAbsolute ~= false, "mod:read refuses an absolute path")
|
||||
T.check(out.readBackslash ~= false, "mod:read refuses a backslash climb")
|
||||
T.check(out.assetsEscape ~= false, "mod.assets:path refuses a climb")
|
||||
T.eq(out.readOwn, "own file", "and the mod's own files still read")
|
||||
run.release()
|
||||
|
||||
-- ------- the grammar itself
|
||||
|
||||
for _, bad in ipairs({ "../x", "a/../../x", "/etc/hosts", "C:/Windows/x",
|
||||
"..\\x", "a\\b", "..", ".", "" }) do
|
||||
T.eq(SafePath.safe(bad), nil, ("SafePath rejects %q"):format(bad))
|
||||
end
|
||||
T.eq(SafePath.safe("maps/NEW_BARK_TOWN.lua"), "maps/NEW_BARK_TOWN.lua",
|
||||
"an ordinary relative path passes")
|
||||
T.eq(SafePath.safe("./main.lua"), "main.lua",
|
||||
"a leading ./ is normalized rather than rejected, so older manifests load")
|
||||
|
||||
-- ------- manifest paths are untrusted input too
|
||||
|
||||
T.check(not pcall(Manifest.validate,
|
||||
{ id = "evil", name = "evil", version = "1.0.0", entry = "../../../evil.lua" }),
|
||||
"a manifest cannot point entry outside the mod directory")
|
||||
T.check(not pcall(Manifest.validate,
|
||||
{ id = "evil", name = "evil", version = "1.0.0", entry = "main.lua",
|
||||
options_schema = "../../options.lua" }),
|
||||
"nor options_schema")
|
||||
T.check(pcall(Manifest.validate,
|
||||
{ id = "fine", name = "fine", version = "1.0.0", entry = "main.lua" }),
|
||||
"an ordinary manifest still validates")
|
||||
|
||||
-- ------- bytecode
|
||||
|
||||
do
|
||||
local bad = {
|
||||
["mods/fix_bytecode/manifest.json"] = manifest("fix_bytecode"),
|
||||
["mods/fix_bytecode/main.lua"] = string.dump(function() end),
|
||||
}
|
||||
local bytecodeRun = T.sdk.loadMods({ "mods/fix_bytecode" },
|
||||
{ fs = T.sdk.memfs(bad) })
|
||||
T.eq(#bytecodeRun.errors, 1, "a mod that ships bytecode fails to load")
|
||||
T.check(tostring(bytecodeRun.errors[1]):find("bytecode", 1, true),
|
||||
"and says why: " .. tostring(bytecodeRun.errors[1]))
|
||||
bytecodeRun.release()
|
||||
end
|
||||
|
||||
-- ------- the sandbox is not opt-in
|
||||
|
||||
do
|
||||
local env = Sandbox.envFor({ modId = "probe" })
|
||||
T.eq(env.io, nil, "a bare Sandbox.envFor is already closed")
|
||||
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
|
||||
|
||||
T.finish("sandbox")
|
||||
@@ -66,12 +66,13 @@ end
|
||||
|
||||
local files = {
|
||||
["mods/alpha/manifest.json"] = manifest("alpha"),
|
||||
-- mod.exports, not _G: a mod's globals are its own (src/mods/Sandbox.lua)
|
||||
["mods/alpha/main.lua"] = [[
|
||||
return function(mod) _G.MOD_STORAGE_ALPHA = mod.storage end
|
||||
return function(mod) mod.exports.storage = mod.storage end
|
||||
]],
|
||||
["mods/beta/manifest.json"] = manifest("beta"),
|
||||
["mods/beta/main.lua"] = [[
|
||||
return function(mod) _G.MOD_STORAGE_BETA = mod.storage end
|
||||
return function(mod) mod.exports.storage = mod.storage end
|
||||
]],
|
||||
}
|
||||
local fs = memfs(files)
|
||||
@@ -80,12 +81,12 @@ local current = game("red", "play-a")
|
||||
loader.game = current
|
||||
T.check(loader:load({}) == true, "storage fixture mods load")
|
||||
|
||||
local alpha, beta = _G.MOD_STORAGE_ALPHA, _G.MOD_STORAGE_BETA
|
||||
local alpha = (loader.exports.alpha or {}).storage
|
||||
local beta = (loader.exports.beta or {}).storage
|
||||
T.check(type(alpha) == "table" and type(beta) == "table",
|
||||
"Loader exposes mod.storage through the public mod object")
|
||||
if type(alpha) ~= "table" or type(beta) ~= "table" then
|
||||
Runtime.events, Runtime.hooks = savedEvents, savedHooks
|
||||
_G.MOD_STORAGE_ALPHA, _G.MOD_STORAGE_BETA = nil, nil
|
||||
T.finish()
|
||||
end
|
||||
|
||||
@@ -176,6 +177,5 @@ T.eq(next(emptyFiles), nil, "no-mod boot creates no storage paths or files")
|
||||
|
||||
Runtime.events, Runtime.hooks = savedEvents, savedHooks
|
||||
Runtime.currentMod = nil
|
||||
_G.MOD_STORAGE_ALPHA, _G.MOD_STORAGE_BETA = nil, nil
|
||||
|
||||
T.finish()
|
||||
|
||||
@@ -57,13 +57,16 @@ local files = {
|
||||
["mods/probe/manifest.json"] =
|
||||
'{"id":"probe","name":"probe","version":"1.0.0",'
|
||||
.. '"entry":"main.lua","api":2,"profile":"content"}',
|
||||
-- mod.exports, not _G: a mod's globals are its own (src/mods/Sandbox.lua)
|
||||
["mods/probe/main.lua"] = [[
|
||||
return function(mod)
|
||||
_G.MOD_TITLE_STORAGE = mod.storage
|
||||
_G.MOD_TITLE_CHECKPOINTS = mod.checkpoints
|
||||
local out = mod.exports
|
||||
out.storage = mod.storage
|
||||
out.checkpoints = mod.checkpoints
|
||||
out.restoreCount = 0
|
||||
mod.events:on("checkpoint.restored", function(ev)
|
||||
_G.MOD_TITLE_RESTORE_COUNT = (_G.MOD_TITLE_RESTORE_COUNT or 0) + 1
|
||||
_G.MOD_TITLE_RESTORE_KIND = ev.kind
|
||||
out.restoreCount = out.restoreCount + 1
|
||||
out.restoreKind = ev.kind
|
||||
end)
|
||||
end
|
||||
]],
|
||||
@@ -82,7 +85,8 @@ local loader = Loader.new({ fs = fs })
|
||||
loader.game = active
|
||||
T.check(loader:load({}) == true, "title-context fixture mod loads")
|
||||
|
||||
local storage = _G.MOD_TITLE_STORAGE
|
||||
local probe = loader.exports.probe or {}
|
||||
local storage = probe.storage
|
||||
T.check(type(storage) == "table", "loader exposes the public storage facade")
|
||||
if type(storage) == "table" then
|
||||
local written, writeCode, writeMessage = storage:write(active, "history/index", {
|
||||
@@ -178,7 +182,7 @@ if type(storage) == "table" then
|
||||
end
|
||||
|
||||
local runtime = makeRuntime(active.save, false)
|
||||
local checkpoints = _G.MOD_TITLE_CHECKPOINTS
|
||||
local checkpoints = probe.checkpoints
|
||||
T.check(type(checkpoints) == "table", "loader exposes the public checkpoint facade")
|
||||
local checkpoint = checkpoints and checkpoints:capture(runtime)
|
||||
T.check(type(checkpoint) == "table",
|
||||
@@ -226,9 +230,9 @@ if type(storage) == "table" then
|
||||
"title bootstrap never rewrites the first normal save")
|
||||
T.same(checkpoints:capture(titleRuntime), checkpoint,
|
||||
"bootstrapped overworld differentially recaptures the selected checkpoint")
|
||||
T.eq(_G.MOD_TITLE_RESTORE_COUNT, 1,
|
||||
T.eq(probe.restoreCount, 1,
|
||||
"a successfully verified title resume emits checkpoint.restored exactly once")
|
||||
T.eq(_G.MOD_TITLE_RESTORE_KIND, "overworld",
|
||||
T.eq(probe.restoreKind, "overworld",
|
||||
"title resume lifecycle reports the reconstructed checkpoint kind")
|
||||
|
||||
-- Force a failure after restoreCheckpointSave has already installed the
|
||||
@@ -255,7 +259,7 @@ if type(storage) == "table" then
|
||||
version = "red", meta = { playthroughId = originalId },
|
||||
}, fs).savedAt, anchoredAt,
|
||||
"failed title reconstruction never rewrites the normal Pokémon save")
|
||||
T.eq(_G.MOD_TITLE_RESTORE_COUNT, 1,
|
||||
T.eq(probe.restoreCount, 1,
|
||||
"failed title reconstruction emits no additional restored lifecycle event")
|
||||
end
|
||||
|
||||
@@ -290,10 +294,6 @@ end
|
||||
|
||||
Runtime.events, Runtime.hooks = savedEvents, savedHooks
|
||||
Runtime.currentMod = nil
|
||||
_G.MOD_TITLE_STORAGE = nil
|
||||
_G.MOD_TITLE_CHECKPOINTS = nil
|
||||
_G.MOD_TITLE_RESTORE_COUNT = nil
|
||||
_G.MOD_TITLE_RESTORE_KIND = nil
|
||||
SaveData.resetSlotState()
|
||||
SaveData.loadOptions = originalLoadOptions
|
||||
love.filesystem = realFs
|
||||
|
||||
@@ -125,8 +125,11 @@ local hotFiles = {
|
||||
["mods/hot_mod/main.lua"] = [[
|
||||
return function(mod)
|
||||
mod.content.pokemon:patch("FIXMON_A", { baseStats = { speed = 99 } })
|
||||
-- counted on mod.exports, not _G: a mod's globals are its own
|
||||
-- (src/mods/Sandbox.lua), and a reload gives it a fresh table
|
||||
local out = mod.exports
|
||||
mod.events:on("game.ready", function()
|
||||
_G.MODKIT_TEST_READY = (_G.MODKIT_TEST_READY or 0) + 1
|
||||
out.ready = (out.ready or 0) + 1
|
||||
end)
|
||||
end
|
||||
]],
|
||||
@@ -148,7 +151,6 @@ local function freshHotData()
|
||||
return d
|
||||
end
|
||||
|
||||
_G.MODKIT_TEST_READY = 0
|
||||
local hotData = freshHotData()
|
||||
local game = { data = hotData, save = { modData = {} } }
|
||||
local bootLoader = Loader.new({ fs = hotFs })
|
||||
@@ -209,7 +211,8 @@ check(deepEqual(hotData.pokemon.FIXMON_B, fixture.load().pokemon.FIXMON_B),
|
||||
"untouched base record is byte-identical after reload")
|
||||
check(flushed >= 1, "reload flushed the registered caches")
|
||||
check(summary:find("reloaded 1 mods", 1, true) ~= nil, "reload summary counts")
|
||||
check(_G.MODKIT_TEST_READY >= 1, "game.ready re-reaches re-subscribed mods")
|
||||
check((game.mods.exports.hot_mod or {}).ready >= 1,
|
||||
"game.ready re-reaches re-subscribed mods")
|
||||
ChipAudio.stopMusic = savedStopMusic
|
||||
check(musicStops >= 1, "reload stops chip music through the cache bus")
|
||||
Sound.play(beepData, "Fix_Beep")
|
||||
@@ -228,7 +231,6 @@ local broken = HotReload.run(game, { fs = hotFs })
|
||||
check(#broken.errors > 0, "broken edit lands in the error feed")
|
||||
check(hotData.pokemon.FIXMON_A.baseStats.speed == 45,
|
||||
"broken mod rolls back to pristine base")
|
||||
_G.MODKIT_TEST_READY = nil
|
||||
|
||||
-- ------- dev console: repl, verbs, tracer, input isolation
|
||||
|
||||
|
||||
Reference in New Issue
Block a user