Merge remote-tracking branch 'origin/dev' into feat/mod-pokemon-icon

# Conflicts:
#	docs/modding.md
This commit is contained in:
MaxTomahawk
2026-08-12 09:46:44 +02:00
540 changed files with 231032 additions and 1896 deletions
+37
View File
@@ -0,0 +1,37 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local FIXTURE = {
["mods/date_probe/manifest.json"] = [[{
"id": "date_probe",
"name": "Date Probe",
"version": "1.0.0",
"entry": "main.lua",
"api": 2
}]],
["mods/date_probe/main.lua"] = [[
local mod = ...
mod.exports.datetime = mod.datetime
]],
}
local run = T.sdk.loadMods({ "mods/date_probe" }, { fs = T.sdk.memfs(FIXTURE) })
T.eq(#run.errors, 0, "fixture mod loads cleanly")
local datetime = run.loader.exports.date_probe.datetime
T.eq(type(datetime), "table", "mod object exposes datetime formatting")
T.eq(type(datetime.date), "function", "public formatter exposes date")
T.eq(type(datetime.time), "function", "public formatter exposes time")
T.eq(type(datetime.dateTime), "function", "public formatter exposes date-time")
local stamp = os.time({ year = 2026, month = 8, day = 11, hour = 17, min = 5, sec = 0 })
local game = { save = { options = { dateFormat = "dmy", timeFormat = "24h" } } }
T.eq(datetime:date(game, stamp), os.date("%d-%m-%Y", stamp),
"mod date follows current global engine preference")
T.eq(datetime:time(game, stamp), os.date("%H:%M", stamp),
"mod time follows current global engine preference")
T.eq(datetime:dateTime(game, stamp), os.date("%d-%m-%Y %H:%M", stamp),
"mod date-time composes the same preference")
run.release()
T.finish("date_time")
+239
View File
@@ -0,0 +1,239 @@
-- Gold's intro seams: the Oak speech under Gen 1's names, and the GS boot
-- cinema under its own.
--
-- Two different claims are being made here, and they are deliberately not the
-- same claim:
--
-- * src/ui/gen2/OakSpeech.lua reuses `intro.oak_speech.build` and the four
-- `intro.oak_speech.*` events VERBATIM -- same names, same payload keys,
-- same moments -- because Gold has a real Oak speech and a mod written for
-- Red's must land on Gold's without a second listener.
-- * the copyright card, the GAME FREAK splash, the attract movie and the
-- Ho-Oh title have NO Gen 1 counterpart, so they take new `intro.boot.*`
-- names rather than borrowing one that would then mean two things.
--
-- Both halves are also the parity case the no-mod gate wants: with nobody
-- subscribed every one of these sites must be inert.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Events = require("src.mods.Events")
local Hooks = require("src.mods.Hooks")
local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime")
local CopyrightSplash = require("src.ui.gen2.CopyrightSplash")
local GameFreakPresents = require("src.ui.gen2.GameFreakPresents")
local GoldSilverIntro = require("src.ui.gen2.GoldSilverIntro")
local OakSpeech = require("src.ui.gen2.OakSpeech")
local TitleState = require("src.ui.gen2.TitleState")
-- The shared names, spelled out so a rename has to come through this file.
local SPEECH_EVENTS = {
"intro.oak_speech.started",
"intro.oak_speech.step",
"intro.oak_speech.answered",
"intro.oak_speech.finished",
}
-- ...and the Gen 2-only ones.
local BOOT_EVENTS = {
"intro.boot.copyright",
"intro.boot.gamefreak",
"intro.boot.movie",
"intro.boot.movie_ended",
"intro.boot.title",
}
local savedEvents, savedHooks = Runtime.events, Runtime.hooks
local function logged(fragment)
for _, line in ipairs(Logger.history or {}) do
if line:find(fragment, 1, true) then return true end
end
return false
end
local function fakeGame()
return { data = {}, save = { player = {} } }
end
-- ------- 1. no-mod parity: every site cold
do
Runtime.events, Runtime.hooks = savedEvents, savedHooks
for _, name in ipairs(SPEECH_EVENTS) do
T.eq(Runtime.wants(name), false, "no subscriber leaves " .. name .. " cold")
end
for _, name in ipairs(BOOT_EVENTS) do
T.eq(Runtime.wants(name), false, "no subscriber leaves " .. name .. " cold")
end
T.eq(Runtime.wantsHook("intro.oak_speech.build"), false,
"no wrapper leaves intro.oak_speech.build cold")
-- the four boot cards still run their moment with nobody listening
CopyrightSplash.new(fakeGame(), {}):enter()
GameFreakPresents.new(fakeGame(), {}):enter()
local movie = GoldSilverIntro.new(fakeGame(), {})
movie:enter()
movie:finish()
TitleState.new(fakeGame(), {}):enter()
T.check(true, "the boot cinema runs unsubscribed")
end
-- ------- 2. the Oak speech step list
do
local speech = OakSpeech.new(fakeGame(), {})
local steps = OakSpeech.defaultSteps(speech)
T.eq(#steps, 9, "Gold's vanilla speech has nine beats")
T.eq(steps[1].id, "init_clock",
"the speech opens on the farcall InitClock beat")
T.eq(steps[#steps].id, "shrink", "and ends on ShrinkPlayer")
-- the anchors a Gen 1 mod already knows how to aim at
local byId = {}
for index, step in ipairs(steps) do byId[step.id] = index end
for _, id in ipairs({ "oak_welcome", "demo_mon", "world_spiel",
"ask_player_name", "name_player", "legend",
"shrink" }) do
T.check(byId[id] ~= nil,
"Gold keeps Gen 1's step anchor `" .. id .. "`")
end
T.check(byId.oak_welcome < byId.demo_mon
and byId.demo_mon < byId.name_player
and byId.name_player < byId.shrink,
"and keeps them in Gen 1's order")
end
-- ------- 3. intro.oak_speech.build, the hook Gen 1 already ships
do
Runtime.events, Runtime.hooks = Events.new(), Hooks.new()
local speech = OakSpeech.new(fakeGame(), {})
Runtime.hooks:wrap("intro.oak_speech.build", function(nextFn, steps, sp)
steps = nextFn(steps, sp)
table.insert(steps, 3, { id = "extra_q", kind = "choice",
saveKey = "mood", choices = { "FINE", "TIRED" } })
return steps
end, 0, "fixture")
local built = speech:buildSteps()
T.eq(built[3].id, "extra_q",
"intro.oak_speech.build can insert a beat into Gold's speech")
T.eq(built[4].id, "demo_mon", "later vanilla beats shift down")
Runtime.hooks:removeOwner("fixture")
Runtime.hooks:wrap("intro.oak_speech.build", function() return 42 end, 0, "bad")
built = speech:buildSteps()
T.eq(built[1].id, "init_clock",
"a non-table intro.oak_speech.build result degrades to vanilla")
T.check(logged("intro.oak_speech.build returned"),
"and the degrade is logged, as it is under Gen 1")
Runtime.hooks:removeOwner("bad")
end
-- ------- 4. the four lifecycle events, with the Gen 1 payload keys
do
Runtime.events, Runtime.hooks = Events.new(), Hooks.new()
local speech = OakSpeech.new(fakeGame(), {})
-- Replace the beats with two that need no stack, so the lifecycle can be
-- driven headlessly: `fn` is the escape hatch a build wrapper gets.
Runtime.hooks:wrap("intro.oak_speech.build", function()
return {
{ id = "probe_one", kind = "fn",
run = function(sp, done) sp:recordAnswer({ id = "probe_one",
saveKey = "mood" }, 2, "TIRED", "TIRED") done() end },
{ id = "probe_two", kind = "fn", run = function(_, done) done() end },
}
end, 0, "fixture")
local started, stepped, answered, finished = nil, {}, nil, nil
Runtime.events:on("intro.oak_speech.started", function(ev)
started = ev
end, 0, "fixture")
Runtime.events:on("intro.oak_speech.step", function(ev)
stepped[#stepped + 1] = ev
end, 0, "fixture")
Runtime.events:on("intro.oak_speech.answered", function(ev)
answered = ev
end, 0, "fixture")
Runtime.events:on("intro.oak_speech.finished", function(ev)
finished = ev
end, 0, "fixture")
speech:enter()
T.check(started ~= nil and started.speech == speech
and type(started.steps) == "table",
"intro.oak_speech.started carries { speech, steps }")
T.eq(#stepped, 2, "intro.oak_speech.step fires once per beat")
T.eq(stepped[1].index, 1, "and carries the 1-based index")
T.eq(stepped[1].step.id, "probe_one", "and the step itself")
T.check(answered ~= nil and answered.saveKey == "mood"
and answered.value == "TIRED" and answered.index == 2
and answered.label == "TIRED" and answered.speech == speech,
"intro.oak_speech.answered carries Gen 1's six keys")
T.eq(speech.answers.mood, "TIRED", "and the answer is stored on the speech")
T.check(finished ~= nil and finished.answers == speech.answers,
"intro.oak_speech.finished carries the answer table")
-- #308's guard, ported: a second finish is not a second speech.
local firstFinish = finished
finished = nil
speech:finish()
T.eq(finished, nil, "finished fires exactly once per speech")
T.check(firstFinish ~= nil, "and it did fire the first time")
Runtime.events:removeOwner("fixture")
Runtime.hooks:removeOwner("fixture")
end
-- ------- 5. the GS boot cinema's own names
do
Runtime.events, Runtime.hooks = Events.new(), Hooks.new()
local seen = {}
for _, name in ipairs(BOOT_EVENTS) do
Runtime.events:on(name, function(ev) seen[name] = ev end, 0, "fixture")
end
local game = fakeGame()
CopyrightSplash.new(game, {}):enter()
T.check(seen["intro.boot.copyright"] ~= nil,
"the copyright card announces itself")
T.check(seen["intro.boot.copyright"].screen ~= nil,
"with the screen in the payload")
GameFreakPresents.new(game, {}):enter()
T.check(seen["intro.boot.gamefreak"] ~= nil,
"the GAME FREAK splash announces itself")
local movie = GoldSilverIntro.new(game, {})
movie:enter()
T.check(seen["intro.boot.movie"] ~= nil, "the attract movie announces itself")
movie:skip()
T.check(seen["intro.boot.movie_ended"] ~= nil,
"and announces its end")
T.eq(seen["intro.boot.movie_ended"].skipped, true,
"intro.boot.movie_ended reports a button skip")
-- a movie that plays out reports the other answer
seen["intro.boot.movie_ended"] = nil
local watched = GoldSilverIntro.new(game, {})
watched:enter()
watched:finish()
T.eq(seen["intro.boot.movie_ended"].skipped, false,
"and reports a movie that was watched to the end")
TitleState.new(game, {}):enter()
T.check(seen["intro.boot.title"] ~= nil, "the title screen announces itself")
Runtime.events:removeOwner("fixture")
end
Runtime.events, Runtime.hooks = savedEvents, savedHooks
T.finish("gen2_intro_seams")
+549
View File
@@ -0,0 +1,549 @@
-- T4: `modkit gen2check`, the tool that answers whether a mod runs on a Gen 2
-- game and how far it gets (tools/modkit.py, MK4xx).
--
-- Every fixture below is generated FROM the engine: the module with no
-- adapter, the member the coverage table calls absent and the screen id with
-- a Gen 2 twin are all read out of src/mods/Gen2Compat.lua and
-- src/ui/Screens.lua at run time. A case that spelled them out would pass
-- against a stale adapter, which is the one thing this tool must never do.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local isWindows = package.config:sub(1, 1) == "\\"
-- luajit's pclose drops the exit status, so the shell reports it in-band
-- (tests/modkit_tests.lua uses the same shape)
local function run(command)
if isWindows then
command = 'cmd /v:on /c "' .. command .. ' 2>&1 & echo EXIT:!errorlevel!"'
else
command = command .. ' 2>&1; echo "EXIT:$?"'
end
local pipe = io.popen(command)
local output = pipe:read("*a")
pipe:close()
return output, tonumber(output:match("EXIT:(%d+)%s*$")) or -1
end
local python = isWindows and "python" or "python3"
if not run(python .. " --version"):find("Python 3", 1, true) then
T.check(true, "python3 is absent: gen2check not exercised")
T.finish("gen2check")
return
end
-- ------- what to write the fixtures against, taken from the engine
local Gen2Compat = require("src.mods.Gen2Compat")
local Screens = require("src.ui.Screens")
local names = {}
for name in pairs(Gen2Compat.ADAPTERS) do names[#names + 1] = name end
table.sort(names)
-- written onto the running game and never onto the module table, which is
-- what MK410 is about; read from the Gen 1 source, as the tool does
local function instanceField(module, member)
local handle = io.open(module:gsub("%.", "/") .. ".lua", "r")
if not handle then return false end
local body = handle:read("*a")
handle:close()
if not body:find("self%." .. member .. "%s*=[^=]") then return false end
if body:find("function%s+%w+[%.:]" .. member .. "%s*%(") then return false end
for owner in body:gmatch("[\n%s]([%a_][%w_]*)%." .. member .. "%s*=[^=]") do
if owner ~= "self" then return false end
end
return true
end
-- an adapted alias plus a member it backs: the shape a mod may hold and read
local aliasName, backedMember
-- a member the adapter deliberately does not carry, whatever module it is on
local absentName, absentMember
-- a facade member the Gen 1 module only writes onto the running game, which
-- an entry chunk cannot have on a Gen 2 boot
local liveName, liveMember
for _, name in ipairs(names) do
local row = Gen2Compat.coverage and Gen2Compat.coverage(name)
local members = row and row.members or {}
local sorted = {}
for member in pairs(members) do sorted[#sorted + 1] = member end
table.sort(sorted)
for _, member in ipairs(sorted) do
if not member:find("[%.%s]") then
if members[member] == "absent" and not absentMember then
absentName, absentMember = name, member
end
if members[member] == "backed" and row.kind == "alias"
and not backedMember then
aliasName, backedMember = name, member
end
if members[member] == "backed" and row.kind == "facade"
and not liveMember and instanceField(name, member) then
liveName, liveMember = name, member
end
end
end
end
T.check(aliasName ~= nil, "the adapter aliases at least one module")
T.check(absentMember ~= nil, "the coverage table names at least one absent "
.. "member")
-- A member that really closes over a local, and a local declared in the same
-- Gen 2 file that it does NOT close over: the two answers the upvalue check
-- has to tell apart, both read out of the engine.
local landName, landMember, landUpvalue
local strayUpvalue
for _, name in ipairs(names) do
local row = Gen2Compat.coverage and Gen2Compat.coverage(name)
if row and row.kind == "alias" and row.target and not landMember then
local ok, adapter = pcall(Gen2Compat.resolve, name)
local sorted = {}
if ok and type(adapter) == "table" then
for member in pairs(adapter) do sorted[#sorted + 1] = member end
end
table.sort(sorted)
for _, member in ipairs(sorted) do
local value = adapter[member]
if type(value) == "function" and not landMember then
local held, index = {}, 1
while true do
local up = debug.getupvalue(value, index)
if not up then break end
held[up] = true
if up:match("^%a[%w_]*$") and up ~= "_ENV" and not landUpvalue then
landName, landMember, landUpvalue = name, member, up
end
index = index + 1
end
if landUpvalue then
local handle = io.open(row.target:gsub("%.", "/") .. ".lua", "r")
local body = handle and handle:read("*a") or ""
if handle then handle:close() end
for local_ in body:gmatch("\nlocal%s+([%a_][%w_]*)") do
if not held[local_] and not strayUpvalue then
strayUpvalue = local_
end
end
end
end
end
end
end
-- a Gen 1 screen id whose Gen 2 twin carries the prefix
local twin
for _, id in ipairs(Screens.GEN2_IDS) do
local bare = id:gsub("^Gen2", "")
local handle = io.open("src/ui/" .. bare .. ".lua", "r")
if handle then handle:close() end
if not twin and handle then twin = bare end
end
T.check(twin ~= nil, "at least one screen id exists in both generations")
-- ------- fixtures on disk, because the tool reads a mod directory
local tmp = os.tmpname()
os.remove(tmp)
local root = (isWindows and tmp:gsub("\\", "/") or tmp) .. "_gen2check"
run((isWindows and "mkdir " or "mkdir -p ") .. ("%q"):format(root))
local function write(dir, files)
run((isWindows and "mkdir " or "mkdir -p ")
.. ("%q"):format(root .. "/" .. dir))
for name, body in pairs(files) do
local handle = assert(io.open(root .. "/" .. dir .. "/" .. name, "w"))
handle:write(body)
handle:close()
end
return root .. "/" .. dir
end
local function manifest(id, extra)
return ('{ "id": "%s", "name": "%s", "version": "1.0.0", "api": 2, '
.. '"entry": "main.lua", "description": "gen2check fixture"%s }')
:format(id, id, extra or "")
end
local GEN2 = ', "gen2compat": true, "games": ["gen1", "gen2"]'
local clean = write("gen2_clean", {
["manifest.json"] = manifest("gen2_clean", GEN2),
["main.lua"] = ([[
local mod = ...
local M = require("%s")
local held = M.%s
mod.exports.held = held ~= nil
]]):format(aliasName, backedMember),
})
local unflagged = write("gen2_unflagged", {
["manifest.json"] = manifest("gen2_unflagged"),
["main.lua"] = "local mod = ...\n",
})
local absent = write("gen2_absent", {
["manifest.json"] = manifest("gen2_absent", GEN2),
["main.lua"] = ([[
local mod = ...
local M = require("%s")
M.%s(mod)
]]):format(absentName, absentMember),
})
local patterns = write("gen2_patterns", {
["manifest.json"] = manifest("gen2_patterns", GEN2),
["main.lua"] = ([[
local mod = ...
local M = require("%s")
local function patchUpvalue(fn, name, value)
local i = 1
while true do
local found = debug.getupvalue(fn, i)
if not found then return false end
if found == name then debug.setupvalue(fn, i, value) return true end
i = i + 1
end
end
patchUpvalue(M.%s, "gen2checkNoSuchUpvalue", 1)
mod.events:on("screen.pushed", function(ev)
if ev.screenId == "%s" then mod.exports.saw = true end
end)
]]):format(aliasName, backedMember, twin),
})
local held = liveMember and write("gen2_held", {
["manifest.json"] = manifest("gen2_held", GEN2),
["main.lua"] = ([[
local mod = ...
local G = require("%s")
local captured = G.%s
mod.events:on("game.ready", function()
mod.exports.live = G.%s ~= nil
end)
mod.exports.captured = captured ~= nil
]]):format(liveName, liveMember, liveMember),
})
-- every shape the scan has to follow to reach one unbacked member: a wrapper
-- the mod requires through, an inline require, a bracket index and a local hop
local reaches = write("gen2_reaches", {
["manifest.json"] = manifest("gen2_reaches", GEN2),
["main.lua"] = ([[
local mod = ...
local function tryRequire(path)
local ok, m = pcall(require, path)
if ok then return m end
end
local W = tryRequire("%s")
W.%s(mod)
require("%s").%s(mod)
local B = require("%s")
B["%s"](mod)
local H = B
H.%s(mod)
]]):format(absentName, absentMember, absentName, absentMember,
absentName, absentMember, absentMember),
})
-- a module name this scan cannot tie to a require: it must say so, never
-- pass the file in silence
local opaque = write("gen2_opaque", {
["manifest.json"] = manifest("gen2_opaque", GEN2),
["main.lua"] = ([[
local mod = ...
local CANDIDATES = { "%s" }
mod.exports.names = CANDIDATES
]]):format(absentName),
})
-- an upvalue that is really an upvalue of the resolved function, and one that
-- is only a file-local of the same module
local lands = landUpvalue and write("gen2_lands", {
["manifest.json"] = manifest("gen2_lands", GEN2),
["main.lua"] = ([[
local mod = ...
local M = require("%s")
debug.setupvalue(M.%s, "%s", nil)
]]):format(landName, landMember, landUpvalue),
})
local stray = strayUpvalue and write("gen2_stray", {
["manifest.json"] = manifest("gen2_stray", GEN2),
["main.lua"] = ([[
local mod = ...
local M = require("%s")
debug.setupvalue(M.%s, "%s", nil)
]]):format(landName, landMember, strayUpvalue),
})
-- the screen id on a line carrying no screen-shaped word, which is the
-- example the docs give
local screenId = write("gen2_screen_id", {
["manifest.json"] = manifest("gen2_screen_id", GEN2),
["main.lua"] = ([[
local mod = ...
mod.exports.pick = function(id)
if id == "%s" then return true end
end
]]):format(twin),
})
-- one statement binding two requires: each name takes the value in its own
-- slot, or neither does. Both names reach the same unbacked member, so a
-- scan that paired them by position reports two and one that guessed reports
-- one against a name it invented.
local pairs_one = write("gen2_pairs_one", {
["manifest.json"] = manifest("gen2_pairs_one", GEN2),
["main.lua"] = ([[
local mod = ...
local A, B = require("%s"), require("%s")
A.%s(mod)
B.%s(mod)
]]):format(absentName, absentName, absentMember, absentMember),
})
-- the same mod with the requires on their own lines: two mods that differ
-- only in line breaks must not get opposite verdicts
local pairs_lines = write("gen2_pairs_lines", {
["manifest.json"] = manifest("gen2_pairs_lines", GEN2),
["main.lua"] = ([[
local mod = ...
local A = require("%s")
local B = require("%s")
A.%s(mod)
B.%s(mod)
]]):format(absentName, absentName, absentMember, absentMember),
})
-- a mod helper that only ever puts its second parameter in the VALUE slot:
-- it names no upvalue, so its call sites are unresolved, never MK407
local valueSlot = write("gen2_value_slot", {
["manifest.json"] = manifest("gen2_value_slot", GEN2),
["main.lua"] = ([[
local mod = ...
local M = require("%s")
local function applyPatches(fn, label)
debug.setupvalue(fn, 1, label)
end
applyPatches(M.%s, "gen2checkNotAnUpvalue")
]]):format(aliasName, backedMember),
})
-- a module name assembled from two literals: the fragment binds to no engine
-- module, and every reach off it has to say so
local head, tail = absentName:match("^(.*%.)([^.]+)$")
local truncated = head and write("gen2_truncated", {
["manifest.json"] = manifest("gen2_truncated", GEN2),
["main.lua"] = ([[
local mod = ...
local M = require("%s" .. "%s")
M.%s(mod)
]]):format(head, tail, absentMember),
})
-- the same concatenation starting with a literal, which a lookahead on the
-- first token alone lets through
local root_, rest = absentName:match("^(src)(%..*)$")
local concat = root_ and write("gen2_concat", {
["manifest.json"] = manifest("gen2_concat", GEN2),
["main.lua"] = ([[
local mod = ...
local M = require("%s" .. "%s")
M.%s(mod)
]]):format(root_, rest, absentMember),
})
-- a bound module read as a value: parked on a table, delegated to through a
-- metatable, handed to a call. The module escapes the scan at each one.
local valueRead = write("gen2_value_read", {
["manifest.json"] = manifest("gen2_value_read", GEN2),
["main.lua"] = ([[
local mod = ...
local M = require("%s")
local proxy = setmetatable({}, { __index = M })
mod.exports.proxy = proxy
mod.exports.parked = M
]]):format(absentName),
})
-- rawget/rawset skip the facade's metatable, so mod state stashed this way
-- lands on the adapter table and not on the Gen 2 module behind it
local rawReach = write("gen2_raw_reach", {
["manifest.json"] = manifest("gen2_raw_reach", GEN2),
["main.lua"] = ([[
local mod = ...
local M = require("%s")
rawset(M, "gen2checkState", {})
mod.exports.state = rawget(M, "gen2checkState")
]]):format(aliasName),
})
local function gen2check(dir, extra)
return run(("%s tools/modkit.py gen2check %q %s")
:format(python, dir, extra or ""))
end
-- ------- a mod that only reads what the adapter backs
local out, code = gen2check(clean)
T.eq(code, 0, "a mod inside the adapter's coverage exits 0: " .. out)
T.check(out:find("will load", 1, true) ~= nil,
"the clean fixture's verdict is 'will load': " .. out)
-- ------- the manifest gate, which decides before a line of the mod runs
out, code = gen2check(unflagged)
T.eq(code, 1, "a mod claiming no Gen 2 game fails the check")
T.check(out:find("MK400", 1, true) ~= nil, "MK400 names the manifest: " .. out)
T.check(out:find("will not work", 1, true) ~= nil,
"and the verdict says so: " .. out)
-- ------- a member the adapter refuses to invent
out, code = gen2check(absent)
T.eq(code, 1, "calling an unbacked member fails the check")
T.check(out:find("MK404", 1, true) ~= nil, "MK404 names the member: " .. out)
T.check(out:find(absentMember, 1, true) ~= nil,
"and quotes it by name: " .. out)
-- ------- the two shapes no adapter can fix
out, code = gen2check(patterns)
T.eq(code, 1, "upvalue surgery with no target on Gold fails the check")
T.check(out:find("MK407", 1, true) ~= nil,
"MK407 names the upvalue that does not exist: " .. out)
T.check(out:find("MK409", 1, true) ~= nil,
"MK409 names the Gen 1 screen id: " .. out)
T.check(out:find("Gen2" .. twin, 1, true) ~= nil,
"and gives the Gen 2 spelling of it: " .. out)
-- ------- the entry chunk holding a member of a game that is not up yet
if held then
out = gen2check(held)
T.check(out:find("MK410", 1, true) ~= nil,
"MK410 names the file-scope read: " .. out)
T.check(select(2, out:gsub("MK410", "")) == 1,
"and only the file-scope one, not the read inside the handler: " .. out)
end
-- ------- the reaches a scan that only saw `X = require(...)` used to miss
out, code = gen2check(reaches)
T.eq(code, 1, "an unbacked member reached through a wrapper fails: " .. out)
T.eq(select(2, out:gsub("MK404", "")), 4,
"the wrapper, the inline require, the bracket index and the local hop each "
.. "come back: " .. out)
-- ------- and what it still cannot follow says so out loud
out = gen2check(opaque)
T.check(out:find("unresolved", 1, true) ~= nil,
"a module name the scan cannot tie to a require is reported unresolved, "
.. "not passed over: " .. out)
-- ------- upvalue surgery, told apart by what the function really closes over
if lands then
out, code = gen2check(lands)
T.check(out:find("lands as it does on Gen 1", 1, true) ~= nil,
"a real upvalue of the resolved function is reported as landing: " .. out)
T.check(out:find("MK407", 1, true) == nil,
"and raises nothing: " .. out)
end
if stray then
out, code = gen2check(stray)
T.check(out:find("MK407", 1, true) ~= nil,
"a file-local that is not an upvalue of the function is MK407: " .. out)
T.check(out:find("lands as it does on Gen 1", 1, true) == nil,
"and is never reported as landing: " .. out)
end
-- ------- the screen id with no screen-shaped word beside it
out = gen2check(screenId)
T.check(out:find("MK409", 1, true) ~= nil,
"MK409 reads the id itself, not the line around it: " .. out)
T.check(out:find("Gen2" .. twin, 1, true) ~= nil,
"and gives the Gen 2 spelling: " .. out)
-- ------- names and values of one assignment, paired by position
out, code = gen2check(pairs_one)
T.eq(code, 1, "two names bound to two requires in one statement fail: " .. out)
T.eq(select(2, out:gsub("MK404", "")), 2,
"each name carries its own require's module, so both reaches come back: "
.. out)
local lines, code2 = gen2check(pairs_lines)
T.eq(code2, code, "the same mod with the requires on separate lines gets the "
.. "same exit: " .. out .. lines)
T.eq(select(2, lines:gsub("MK404", "")), 2,
"and the same findings, since only the line breaks moved: " .. lines)
-- ------- a helper whose second parameter is a value, not an upvalue name
out, code = gen2check(valueSlot)
T.check(out:find("MK407", 1, true) == nil,
"a helper that names no upvalue is never read as upvalue surgery: " .. out)
T.check(out:find("unresolved", 1, true) ~= nil,
"and its call sites come back unresolved rather than silent: " .. out)
-- ------- a module name this scan resolved but cannot map to a module
if truncated then
out, code = gen2check(truncated)
T.check(out:find("neither an adapter nor a module", 1, true) ~= nil,
"a require built from two literals cannot pass in silence: " .. out)
end
if concat then
out, code = gen2check(concat)
T.check(out:find("building a require name at runtime", 1, true) ~= nil,
"a concatenation starting with a literal is still dynamic: " .. out)
end
-- ------- a bound module read as a value rather than indexed
out, code = gen2check(valueRead)
T.check(out:find("read as a value", 1, true) ~= nil,
"a module parked on a table or delegated to is reported unresolved: " .. out)
-- ------- rawget/rawset, which never see the module behind the facade
out, code = gen2check(rawReach)
T.check(out:find("rawset", 1, true) ~= nil,
"rawset onto an engine module earns its own note: " .. out)
T.check(out:find("rawget", 1, true) ~= nil,
"and so does rawget off one: " .. out)
-- ------- the machine-readable form one CI step reads
out, code = run(("%s tools/modkit.py --json gen2check %q %q")
:format(python, clean, unflagged))
T.eq(code, 1, "the batch fails when any mod in it fails")
T.check(out:find('"verdict": "will load"', 1, true) ~= nil,
"the JSON carries a verdict per mod: " .. out)
T.check(out:find('"ok": false', 1, true) ~= nil,
"and one ok for the batch: " .. out)
-- ------- every adapted name is served, so requiring one is never MK402
local requires = { "local mod = ..." }
for _, name in ipairs(names) do
requires[#requires + 1] = ("require(%q)"):format(name)
end
local served = write("gen2_served", {
["manifest.json"] = manifest("gen2_served", GEN2),
["main.lua"] = table.concat(requires, "\n") .. "\n",
})
out = gen2check(served)
T.check(out:find("MK402", 1, true) == nil,
"no adapted module is reported as unserved: " .. out)
run((isWindows and "rmdir /s /q " or "rm -rf ") .. ("%q"):format(root))
T.finish("gen2check")
@@ -0,0 +1,301 @@
-- A tool can persist a checkpoint before the first normal Pokémon save. After
-- a restart the title runtime is deliberately a fresh save skeleton, so it
-- needs a non-allocating binding to the already-selected playthrough -- not a
-- call to the normal active-playthrough storage methods, which would mint an id.
--
-- This is a public SDK contract test. The fixture never reaches into storage
-- paths or launcher slot internals.
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local T = require("tests.harness").suite("mod title playthrough context")
local Loader = require("src.mods.Loader")
local Runtime = require("src.mods.Runtime")
local SaveData = require("src.core.SaveData")
local SaveSerializer = require("src.core.SaveSerializer")
local Version = require("src.core.Version")
local GameMethods = require("src.core.Game")
local StateStack = require("src.core.StateStack")
local savedEvents, savedHooks = Runtime.events, Runtime.hooks
local realFs = love.filesystem
local function memfs(files)
return {
read = function(path) return files[path] end,
write = function(path, body) files[path] = body return true end,
remove = function(path) files[path] = nil return true end,
createDirectory = function() return true end,
getInfo = function(path)
if files[path] then return { type = "file" } end
local prefix = path .. "/"
for key in pairs(files) do
if key:sub(1, #prefix) == prefix then return { type = "directory" } end
end
return nil
end,
load = function(path)
if not files[path] then return nil, "no file: " .. path end
return load(files[path], path)
end,
getDirectoryItems = function(path)
local prefix, seen, out = path .. "/", {}, {}
for key in pairs(files) do
if key:sub(1, #prefix) == prefix then
local child = key:sub(#prefix + 1):match("^[^/]+")
if child and not seen[child] then seen[child] = true; out[#out + 1] = child end
end
end
table.sort(out)
return out
end,
}
end
local files = {
["mods/probe/manifest.json"] =
'{"id":"probe","name":"probe","version":"1.0.0",'
.. '"entry":"main.lua","api":2,"profile":"content"}',
["mods/probe/main.lua"] = [[
return function(mod)
_G.MOD_TITLE_STORAGE = mod.storage
_G.MOD_TITLE_CHECKPOINTS = mod.checkpoints
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
end)
end
]],
}
local fs = memfs(files)
love.filesystem = fs
-- Production storage and checkpoint resume share the same engine persistence
-- backend. Route the test's default SaveData lookup to this fixture backend so
-- the restart path exercises that shared mapping rather than host test files.
local originalLoadOptions = SaveData.loadOptions
SaveData.loadOptions = function(injectedFs)
return originalLoadOptions(injectedFs or fs)
end
local active = { save = SaveData.newGame({ version = "red" }) }
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
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", {
format = 1, newest = "q0001",
})
T.check(written == true,
"a fresh playthrough can durably store tool history: "
.. tostring(writeCode or writeMessage))
local originalId = active.save.meta and active.save.meta.playthroughId
T.check(type(originalId) == "string" and originalId ~= "",
"first tool persistence allocates the opaque active playthrough identity")
local nonTitleSelected, nonTitleCode = storage:selected(active)
T.check(nonTitleSelected == nil and nonTitleCode == "not_at_title",
"selected-playthrough storage cannot be used from active gameplay")
-- Simulate a fresh process/title session. The normal save was never written:
-- only the engine-owned slot/playthrough mapping and this mod's durable data
-- exist. The title skeleton must remain unmodified by browsing.
SaveData.resetSlotState()
local title = {
save = SaveData.newGame({ version = "red" }),
stack = {
states = { { screenId = "TitleState" } },
top = function(self) return self.states[#self.states] end,
},
}
T.check(title.save.meta.playthroughId == nil,
"title starts from an unbound fresh skeleton before normal SAVE")
T.check(type(storage.selected) == "function",
"public storage exposes a read-only selected-playthrough binding at title")
if type(storage.selected) == "function" then
local selected, selectedCode, selectedMessage = storage:selected(title)
T.check(type(selected) == "table",
"title resolves the selected existing playthrough: "
.. tostring(selectedCode or selectedMessage))
if type(selected) == "table" then
T.same(selected:context(), {
engineVersion = Version.engine,
gameVersion = "red",
playthroughId = originalId,
}, "selected binding reports the durable playthrough without exposing a slot path")
T.same(selected:read("history/index"), { format = 1, newest = "q0001" },
"title reads only this mod's selected-playthrough durable history")
T.check(selected:write("history/title-operation", { allowed = true }) == true,
"title binding supports safe same-namespace durable operations")
T.same(selected:read("history/title-operation"), { allowed = true },
"title durable operation remains scoped to the selected playthrough")
end
T.check(title.save.meta.playthroughId == nil,
"opening title history never allocates or adopts a playthrough identity")
end
local function makeRuntime(save, title)
local stack = setmetatable({ states = {} }, { __index = StateStack })
local game
local overworld = {
map = { id = "PALLET_TOWN" },
player = { cellX = 3, cellY = 6, facing = "down", surfing = false },
scriptMoves = {}, pendingScripts = {}, parallelRunners = {}, parallelQueue = {},
runner = { isRunning = function() return false end },
}
function overworld:captureSave(target)
target.player.map, target.player.x, target.player.y = self.map.id,
self.player.cellX, self.player.cellY
target.player.facing, target.player.surfing = self.player.facing,
self.player.surfing and true or false
end
function overworld:enter(mapId, x, y, facing)
self.map = { id = mapId }
self.player = { cellX = x, cellY = y, facing = facing,
surfing = game.save.player.surfing and true or false }
self.scriptMoves, self.pendingScripts = {}, {}
self.parallelRunners, self.parallelQueue = {}, {}
self.runner = { isRunning = function() return false end }
end
game = setmetatable({
save = save, stack = stack, overworld = overworld,
data = {
pokemon = {}, moves = { TACKLE = { pp = 35 } }, items = { POTION = {} },
constants = { fallbackMove = "TACKLE" },
field = { boot = { startMap = "PALLET_TOWN", startX = 3, startY = 6 } },
maps = { PALLET_TOWN = { id = "PALLET_TOWN", width = 10, height = 9 } },
},
}, { __index = GameMethods })
stack.states[1] = title and { screenId = "TitleState" } or overworld
if title then
-- The failure-injection path needs the same title recovery contract as a
-- real Game without constructing renderer-owned title content.
function game:makeTitleState() return { screenId = "TitleState" } end
end
return game
end
local runtime = makeRuntime(active.save, false)
local checkpoints = _G.MOD_TITLE_CHECKPOINTS
T.check(type(checkpoints) == "table", "loader exposes the public checkpoint facade")
local checkpoint = checkpoints and checkpoints:capture(runtime)
T.check(type(checkpoint) == "table",
"a fresh playthrough can capture a stable overworld checkpoint")
T.check(type(checkpoints and checkpoints.ensureNormalSave) == "function",
"public checkpoints expose an idempotent first-save anchor")
local normalWrites = 0
local writeSave = runtime.writeSave
function runtime:writeSave()
normalWrites = normalWrites + 1
return writeSave(self)
end
local anchored, anchorCode, anchorMessage =
checkpoints:ensureNormalSave(runtime, checkpoint)
T.check(anchored == true,
"first persisted checkpoint can anchor normal progress: "
.. tostring(anchorCode or anchorMessage))
T.eq(normalWrites, 1,
"first checkpoint creates exactly one normal Pokemon save")
local anchoredAgain, againCode = checkpoints:ensureNormalSave(runtime, checkpoint)
T.check(anchoredAgain == true and againCode == "already_exists",
"later checkpoints leave the established normal save independent")
T.eq(normalWrites, 1,
"idempotent anchor never rewrites the established normal save")
local normalBytes = files["save.lua"]
T.check(type(normalBytes) == "string" and normalBytes ~= "",
"first checkpoint anchor is durably represented before restart")
local anchoredAt = SaveSerializer.decode(normalBytes).meta.savedAt
SaveData.resetSlotState()
local titleRuntime = makeRuntime(SaveData.newGame({ version = "red" }), true)
titleRuntime.save.options = { volume = 9, bindings = {} }
T.check(type(checkpoints and checkpoints.resume) == "function",
"public checkpoints expose validated title-session resume")
if type(checkpoints and checkpoints.resume) == "function" and checkpoint then
local resumed, resumeCode, resumeMessage = checkpoints:resume(titleRuntime, checkpoint)
T.check(resumed == true,
"title resumes the durable checkpoint: " .. tostring(resumeCode or resumeMessage))
T.eq(titleRuntime.save.meta.playthroughId, originalId,
"title bootstrap retains the checkpoint's original playthrough identity")
T.eq(titleRuntime.save.options.volume, 9,
"title bootstrap preserves current options rather than rewinding them")
T.eq(SaveData.selectedNormalSaveInfo({
version = "red", meta = { playthroughId = originalId },
}, fs).savedAt, anchoredAt,
"title bootstrap never rewrites the first normal save")
T.same(checkpoints:capture(titleRuntime), checkpoint,
"bootstrapped overworld differentially recaptures the selected checkpoint")
T.eq(_G.MOD_TITLE_RESTORE_COUNT, 1,
"a successfully verified title resume emits checkpoint.restored exactly once")
T.eq(_G.MOD_TITLE_RESTORE_KIND, "overworld",
"title resume lifecycle reports the reconstructed checkpoint kind")
-- Force a failure after restoreCheckpointSave has already installed the
-- checkpoint's canonical save and overworld. Title has no live checkpoint
-- rollback, so it must rebuild a clean title session.
SaveData.resetSlotState()
local failingTitle = makeRuntime(SaveData.newGame({ version = "red" }), true)
failingTitle.save.options = { volume = 7, bindings = {} }
local restoreCheckpointSave = failingTitle.restoreCheckpointSave
function failingTitle:restoreCheckpointSave(loaded)
restoreCheckpointSave(self, loaded)
error("forced title reconstruction failure")
end
local failed, failureCode = checkpoints:resume(failingTitle, checkpoint)
T.check(failed == false and failureCode == "resume_failed",
"failed title reconstruction reports a recoverable bootstrap failure")
T.eq(failingTitle.stack:top().screenId, "TitleState",
"failed title reconstruction returns to a usable title session")
T.check(failingTitle.save.meta.playthroughId == nil,
"failed title reconstruction restores the unbound title skeleton")
T.eq(failingTitle.save.options.volume, 7,
"failed title reconstruction retains current title options")
T.eq(SaveData.selectedNormalSaveInfo({
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,
"failed title reconstruction emits no additional restored lifecycle event")
end
-- A title policy may compare its own durable checkpoint chronology with the
-- ordinary CONTINUE target, but it must never receive that save's contents,
-- slot path, or a way to open another playthrough. This fixture writes the
-- canonical normal save directly to model an already-completed vanilla SAVE.
active.save.meta.savedAt = 4321
T.check(SaveData.save(active.save) == true,
"fixture updates the selected normal save chronology")
SaveData.resetSlotState()
local titleWithNormalSave = {
save = SaveData.newGame({ version = "red" }),
stack = { states = { { screenId = "TitleState" } } },
}
local selectedWithNormal, normalCode, normalMessage = storage:selected(titleWithNormalSave)
T.check(type(selectedWithNormal) == "table",
"legacy-to-slot migration keeps the selected playthrough identity: "
.. tostring(normalCode or normalMessage))
if type(selectedWithNormal) == "table" then
T.eq(selectedWithNormal:context().normalSavedAt, 4321,
"title selected context exposes only matching normal-save chronology")
end
T.check(titleWithNormalSave.save.meta.playthroughId == nil,
"normal-save chronology lookup does not bind the fresh title skeleton")
local explicitNewGame = SaveData.newGame({ version = "red" })
local freshContext = storage:context({ save = explicitNewGame })
T.check(freshContext and freshContext.playthroughId ~= originalId,
"an explicit New Game receives a distinct identity and cannot inherit old history")
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
T.finish()
@@ -0,0 +1,66 @@
-- Public party-ordering contract over an idle overworld fixture. No ROM data
-- is needed, so companion UIs exercise this seam in the normal mod-SDK tier.
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local T = require("tests.harness").suite("mod world party reorder")
local StateStack = require("src.core.StateStack")
local WorldAPI = require("src.world.WorldAPI")
local first = { species = "BULBASAUR" }
local second = { species = "CHARMANDER" }
local runner = { running = false }
function runner:isRunning() return self.running end
local ow = {
isOverworld = true,
map = { id = "PALLET_TOWN" },
player = { moving = false, inputLocked = false },
runner = runner,
scriptMoves = {},
}
local stack = setmetatable({ states = { ow } }, { __index = StateStack })
local game = {
data = {},
save = { party = { first, second } },
stack = stack,
overworld = ow,
}
local api = WorldAPI.new(game, "fixture")
T.check(api:canReorderParty(), "idle free roam allows party reordering")
local Sound = require("src.core.Sound")
local realPlay, played = Sound.play
Sound.play = function(_, name) played = name end
T.check(api:reorderParty(1, 2) == true, "valid slots reorder")
Sound.play = realPlay
T.check(game.save.party[1] == second and game.save.party[2] == first,
"the live party is swapped")
T.eq(played, "Swap", "the normal party swap sound is used")
local value, err = api:reorderParty(1.5, 2)
T.check(value == nil and err == "invalid party slot",
"non-integer slots are rejected")
value, err = api:reorderParty("1", 2)
T.check(value == nil and err == "invalid party slot",
"string slots are rejected")
stack:push({ screenId = "SomeMenu" })
T.check(not api:canReorderParty(), "a screen above the world blocks reordering")
value, err = api:reorderParty(1, 2)
T.check(value == nil and err == "world is busy",
"reordering refuses while another screen owns input")
stack:pop()
ow.player.moving = true
T.check(not api:canReorderParty(), "movement blocks reordering")
ow.player.moving = false
runner.running = true
T.check(not api:canReorderParty(), "scripts block reordering")
runner.running = false
ow.transitioning = true
T.check(not api:canReorderParty(), "map transitions block reordering")
T.finish()