Merge branch 'grandmas-kitchen' into dev

This commit is contained in:
bryanthaboi
2026-08-14 07:13:00 -04:00
26 changed files with 1170 additions and 148 deletions
@@ -82,10 +82,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
@@ -135,11 +136,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 }))
@@ -149,15 +151,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)
+4 -6
View File
@@ -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()
+6 -8
View File
@@ -359,7 +359,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"] = [[
@@ -374,7 +375,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")
@@ -389,7 +390,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")
@@ -398,10 +399,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",
@@ -409,7 +408,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
@@ -441,7 +440,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,
@@ -467,7 +466,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
View File
@@ -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
+6 -7
View File
@@ -139,10 +139,11 @@ 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
_G.MOD_HOOKS = mod.hooks
mod.exports.checkpoints = mod.checkpoints
mod.exports.hooks = mod.hooks
end
]],
}
@@ -150,12 +151,12 @@ 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
local modHooks = (loader.exports.probe or {}).hooks
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
@@ -440,7 +441,7 @@ end
-- at the restored safe decision reaches its semantic auxiliary action without
-- selecting a native command.
local auxiliaryCalls = 0
_G.MOD_HOOKS:wrap("battle.menu_auxiliary", function(nextFn, liveGame, context)
modHooks:wrap("battle.menu_auxiliary", function(nextFn, liveGame, context)
auxiliaryCalls = auxiliaryCalls + 1
T.check(liveGame == battleGame, "public battle auxiliary action receives the game")
T.same(context, { kind = "wild" }, "public auxiliary context is data-only")
@@ -456,8 +457,6 @@ T.eq(boundary.menuIndex, originalMenuIndex, "public auxiliary hook preserves cur
Runtime.events, Runtime.hooks = savedEvents, savedHooks
Runtime.currentMod = nil
_G.MOD_CHECKPOINTS = nil
_G.MOD_HOOKS = nil
love.math.getRandomState = oldGetRandomState
love.math.setRandomState = oldSetRandomState
+52
View File
@@ -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")
+15 -18
View File
@@ -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")
+208
View File
@@ -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")
+141
View File
@@ -0,0 +1,141 @@
-- The "steps" permission gates the native step bridge (#1186): a
-- permissioned mod syncs and polls deliveries without ever seeing
-- love.system or the pending file; an unpermissioned mod gets a quiet
-- available() = false and loud, permission-naming refusals from the
-- calls that would act.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Steps = require("src.mods.Steps")
local WALKER = {
["mods/step_walker/manifest.json"] = [[{
"id": "step_walker",
"name": "Step Walker",
"version": "1.0.0",
"entry": "main.lua",
"api": 2,
"permissions": ["steps"]
}]],
["mods/step_walker/main.lua"] = [[
local mod = ...
mod.exports.available = mod.steps:available()
mod.exports.synced = mod.steps:sync()
mod.exports.poll = function() return mod.steps:poll() end
]],
}
local SECOND = {
["mods/step_second/manifest.json"] = [[{
"id": "step_second",
"name": "Step Second",
"version": "1.0.0",
"entry": "main.lua",
"api": 2,
"permissions": ["steps"]
}]],
["mods/step_second/main.lua"] = [[
local mod = ...
mod.exports.poll = function() return mod.steps:poll() end
]],
}
local UNPERMISSIONED = {
["mods/step_probe/manifest.json"] = [[{
"id": "step_probe",
"name": "Step Probe",
"version": "1.0.0",
"entry": "main.lua",
"api": 2
}]],
["mods/step_probe/main.lua"] = [[
local mod = ...
mod.exports.available = mod.steps:available()
local ok, err = pcall(function() return mod.steps:sync() end)
mod.exports.refused = not ok and tostring(err) or false
]],
}
local function merged(...)
local out = {}
for _, fixture in ipairs({ ... }) do
for path, body in pairs(fixture) do out[path] = body end
end
return out
end
local savedSync = T.love.system.syncHealthSteps
local syncCalls = 0
T.love.system.syncHealthSteps = function()
syncCalls = syncCalls + 1
return true
end
-- no mod: the bridge stays cold and a pending delivery stays on disk
T.love.filesystem.write(Steps.PENDING, '{"steps": 4312}')
local vanilla = T.sdk.loadNone({})
T.eq(syncCalls, 0, "no mod leaves the step bridge cold")
T.check(T.love.filesystem.read(Steps.PENDING) ~= nil,
"no mod leaves the pending delivery untouched")
vanilla.release()
-- a permissioned mod syncs and receives the delivery
local run = T.sdk.loadMods({ "mods/step_walker", "mods/step_second" },
{ fs = T.sdk.memfs(merged(WALKER, SECOND)) })
T.eq(#run.errors, 0,
"the permissioned mods load clean (" .. tostring(run.errors[1]) .. ")")
local walker = run.loader.exports.step_walker
T.eq(walker.available, true, "available() sees the bridge")
T.eq(walker.synced, true, "sync() reaches the bridge")
T.eq(syncCalls, 1, "one sync call makes one bridge call")
local delivery = walker.poll()
T.check(delivery and delivery.steps == 4312,
"poll() hands the mod the delivered step count")
T.check(T.love.filesystem.read(Steps.PENDING) == nil,
"the engine consumed the pending file, not the mod")
T.eq(walker.poll(), nil, "a delivery is handed out once per mod")
local second = run.loader.exports.step_second
local secondDelivery = second.poll()
T.check(secondDelivery and secondDelivery.steps == 4312,
"a second permissioned mod receives its own copy of the walk")
T.check(secondDelivery ~= delivery, "copies, not a shared table")
-- contract fields only, and a malformed delivery is dropped whole
T.love.filesystem.write(Steps.PENDING,
'{"steps": 12, "from": "a", "to": "b", "path": "/etc/passwd"}')
delivery = walker.poll()
T.eq(delivery.steps, 12, "the steps field travels")
T.eq(delivery.from, "a", "the from field travels")
T.eq(delivery.path, nil, "fields outside the contract do not travel")
T.love.filesystem.write(Steps.PENDING, "not json at all")
T.eq(walker.poll(), nil, "a malformed delivery is dropped, not raised")
T.check(T.love.filesystem.read(Steps.PENDING) == nil,
"and the bad file does not wedge the pump")
run.release()
-- without the permission: quiet probe, loud act
local probe = T.sdk.loadMods({ "mods/step_probe" },
{ fs = T.sdk.memfs(UNPERMISSIONED) })
T.eq(#probe.errors, 0,
"the unpermissioned mod loads clean (" .. tostring(probe.errors[1]) .. ")")
local out = probe.loader.exports.step_probe
T.eq(out.available, false, "available() is quietly false without the permission")
T.check(out.refused and out.refused:find('"steps" permission', 1, true),
"sync() without the permission names it")
probe.release()
-- no bridge on this build: sync reports it, nothing raises
T.love.system.syncHealthSteps = nil
local ashore = T.sdk.loadMods({ "mods/step_walker" },
{ fs = T.sdk.memfs(WALKER) })
local dry = ashore.loader.exports.step_walker
T.eq(dry.available, false, "available() is false without a native bridge")
T.eq(dry.synced, false, "sync() reports there was no bridge to ask")
ashore.release()
T.love.system.syncHealthSteps = savedSync
T.love.filesystem.remove(Steps.PENDING)
T.finish("steps_bridge")
+5 -5
View File
@@ -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",
@@ -238,9 +242,9 @@ if type(storage) == "table" then
end
T.same(recaptured, 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
@@ -267,7 +271,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
@@ -302,10 +306,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
+6 -4
View File
@@ -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