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:
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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,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
|
||||
|
||||
@@ -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