mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-15 07:41:21 +02:00
Merge branch 'grandmas-kitchen' into dev
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user