mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-09-26 13:33:27 -04:00
bd92b6cb34
There was no way to ask "will this mod load on FireRed?". `validate`, `lint`
and `pack` only reason about Gen 1, and `gen2check` refuses a Gen 3 mod at the
manifest gate. `gen3check` is the Gen 3 counterpart of `gen2check`:
python3 tools/modkit.py gen3check mods/<id> [--notes] [--json] [--strict]
- Factor the shared compat analysis behind a `Generation` descriptor, with
`GEN2`/`GEN3` module-level singletons holding everything that differs: the
compat facade name, the generation's own directory (`gen2` vs `game3`), the
legacy manifest flag, the screen-twin prefix, and the sibling spelling. Every
compat helper now takes a trailing `gen`.
- MK400-MK410 stay shared by design -- they are the same questions asked of a
different facade -- and the verdict line (`on gen 3: ...`) disambiguates.
- Gen 3 sibling spelling: `src/ui/game3/` is snake_case while
`src/world/game3/WorldAPI.lua` is not, so MK403 tries both spellings and
stops at the first sibling that exists. Only 8 Gen 1 modules are reachable
this way (`BattleAPI`, `BagMenu`, `HallOfFame`, `IntroMovie`, `OptionRows`,
`ShopMenu`, `SummaryMenu`, `TrainerCard`), so MK403 on Gen 3 is live but
narrow.
- MK409's screen-twin half is switched off on Gen 3 -- there is no
`Screens.GEN2_IDS` equivalent -- while its version-string half still runs,
since a Gen 1 version id in a mod that declares only FireRed is a real bug.
- Rename `GEN2_IDS_DUMP` to `VERSION_IDS_DUMP`, now parameterized by
generation.
Gen 2 behaviour is unchanged: `diff -r` of `gen2check --notes` and
`gen2check --notes --json` output over all 10 shipped mods, captured before and
after this change, is empty.
Tests: `tests/modkit/cases/gen3check.lua` grows from 504 to 542 checks. Every
fixture is derived from the engine at run time -- the coverage table,
`GEN1_ONLY_MODULES`, and the `game3/` directory listings -- so the suite cannot
drift away from the tables it is asserting against. It covers MK400's Gen 3
wording, MK402 naming `Gen3Compat` and never `Gen2Compat`, MK403 naming the
snake_case path, MK404 quoting the member, MK409's version-string half, MK410
counted once at file scope, the `--json` verdict shape, and that no adapted
module is ever MK402.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
442 lines
16 KiB
Lua
442 lines
16 KiB
Lua
-- T4: FireRed mod API parity, read from src/mods/Gen3Compat.lua and the
|
|
-- per-generation mod.world / mod.battle modules at run time.
|
|
|
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
|
|
|
local T = require("tests.modkit")
|
|
|
|
local Gen2Compat = require("src.mods.Gen2Compat")
|
|
local Gen3Compat = require("src.mods.Gen3Compat")
|
|
|
|
for _, fn in ipairs({ "serves", "resolve", "bind", "coverage", "modules",
|
|
"memberStatus", "applyMerged", "scriptCtx" }) do
|
|
T.eq(type(Gen3Compat[fn]), "function", "Gen3Compat." .. fn .. " exists")
|
|
end
|
|
T.eq(type(Gen3Compat.ADAPTERS), "table", "Gen3Compat.ADAPTERS is published")
|
|
T.eq(Gen3Compat.COVERAGE_VERSION, Gen2Compat.COVERAGE_VERSION,
|
|
"both compat layers speak one coverage contract version")
|
|
|
|
for name in pairs(Gen2Compat.ADAPTERS) do
|
|
T.check(Gen3Compat.serves(name),
|
|
"a module Gold adapts is adapted on FireRed too: " .. name)
|
|
end
|
|
for name in pairs(Gen3Compat.ADAPTERS) do
|
|
T.check(Gen2Compat.serves(name),
|
|
"FireRed adapts nothing Gold does not: " .. name)
|
|
local row = Gen3Compat.coverage(name)
|
|
T.check(row ~= nil, "coverage row for " .. name)
|
|
if row then
|
|
T.check(row.kind == "facade" or row.kind == "alias",
|
|
"coverage kind is facade or alias: " .. name)
|
|
for member, status in pairs(row.members) do
|
|
T.check(status == "backed" or status == "warned" or status == "absent",
|
|
("%s.%s carries one of the three statuses"):format(name, member))
|
|
end
|
|
end
|
|
end
|
|
|
|
local function publicMethods(module)
|
|
local out = {}
|
|
for key, value in pairs(module) do
|
|
if type(value) == "function" and not key:find("^_") then out[#out + 1] = key end
|
|
end
|
|
table.sort(out)
|
|
return out
|
|
end
|
|
|
|
local function surface(label, gen3Name, others)
|
|
local ok3, gen3 = pcall(require, gen3Name)
|
|
T.check(ok3, label .. ": " .. gen3Name .. " loads headless")
|
|
if not ok3 then return end
|
|
for _, otherName in ipairs(others) do
|
|
local ok, other = pcall(require, otherName)
|
|
if ok and type(other) == "table" then
|
|
for _, key in ipairs(publicMethods(other)) do
|
|
T.eq(type(gen3[key]), "function",
|
|
("%s: %s.%s has a FireRed arm"):format(label, otherName, key))
|
|
end
|
|
else
|
|
T.check(true, label .. ": " .. otherName .. " not loadable headless, skipped")
|
|
end
|
|
end
|
|
end
|
|
|
|
surface("mod.world", "src.world.game3.WorldAPI",
|
|
{ "src.world.WorldAPI", "src.world.gen2.WorldAPI" })
|
|
surface("mod.battle", "src.battle.game3.BattleAPI",
|
|
{ "src.battle.BattleAPI", "src.battle.gen2.BattleAPI" })
|
|
|
|
local worldRow = Gen3Compat.coverage("src.world.WorldAPI")
|
|
T.eq(worldRow and worldRow.target, "src.world.game3.WorldAPI",
|
|
"the WorldAPI coverage row names the FireRed module")
|
|
|
|
-- ------- the command itself. `gen3check` is the same checks as `gen2check`
|
|
-- run against src/mods/Gen3Compat.lua, so what this section has to prove is
|
|
-- that the generation reached every one of them and that the one rule which
|
|
-- only makes sense on Gold was switched off. Every fixture is derived from
|
|
-- the engine, for the reason this file's header gives.
|
|
|
|
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: the gen3check command is not exercised")
|
|
T.finish("gen3check")
|
|
return
|
|
end
|
|
|
|
local function gen3check(dir, extra)
|
|
return run(("%s tools/modkit.py gen3check %q %s")
|
|
:format(python, dir, extra or ""))
|
|
end
|
|
|
|
-- ------- what to write the fixtures against, taken from the engine
|
|
|
|
local names = {}
|
|
for name in pairs(Gen3Compat.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
|
|
|
|
local aliasName, backedMember, absentName, absentMember, liveName, liveMember
|
|
for _, name in ipairs(names) do
|
|
local row = Gen3Compat.coverage and Gen3Compat.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(absentMember ~= nil, "the FireRed coverage table names at least one "
|
|
.. "absent member")
|
|
|
|
-- the Gen 1 modules a FireRed boot never instantiates (src/mods/Loader.lua),
|
|
-- at least one of which the coverage table refuses to adapt: MK402
|
|
local unservedName
|
|
do
|
|
local handle = io.open("src/mods/Loader.lua", "r")
|
|
local body = handle and handle:read("*a") or ""
|
|
if handle then handle:close() end
|
|
local block = body:match("GEN1_ONLY_MODULES%s*=%s*{(.-)\n}")
|
|
for name in (block or ""):gmatch('%["([^"]+)"%]') do
|
|
if not Gen3Compat.serves(name) then
|
|
unservedName = name
|
|
break
|
|
end
|
|
end
|
|
end
|
|
T.check(unservedName ~= nil,
|
|
"at least one Gen 1 module has no FireRed adapter, which is MK402")
|
|
|
|
-- the shape only FireRed has: a module a FireRed boot really runs from a
|
|
-- snake_case file under a game3/ directory, on a Gen 1 module nothing adapts,
|
|
-- so the sibling search has to try its second spelling. The directory comes
|
|
-- from a coverage target, so nothing here restates the tree.
|
|
local function listLua(dir)
|
|
local files = {}
|
|
local pipe = io.popen(isWindows and ('dir /b "%s\\*.lua" 2>nul'):format(dir)
|
|
or ('ls "%s"/*.lua 2>/dev/null'):format(dir))
|
|
if not pipe then return files end
|
|
for line in pipe:lines() do
|
|
local base = line:match("([^/\\]+)%.lua$")
|
|
if base and base ~= "init" then files[#files + 1] = base end
|
|
end
|
|
pipe:close()
|
|
table.sort(files)
|
|
return files
|
|
end
|
|
|
|
local siblingName, siblingPath, siblingCamel
|
|
for _, name in ipairs(Gen3Compat.modules()) do
|
|
if not siblingName then
|
|
local row = Gen3Compat.coverage(name)
|
|
local dir = row and row.target and row.target:match("^(.*%.)")
|
|
if dir and dir:find("%.game3%.$") then
|
|
local gen1 = dir:gsub("%.game3%.$", ".")
|
|
local gen1dir = gen1:gsub("%.", "/")
|
|
for _, file in ipairs(listLua(dir:gsub("%.$", ""):gsub("%.", "/"))) do
|
|
local camel = file:gsub("_(%a)", string.upper):gsub("^%l", string.upper)
|
|
local handle = io.open(gen1dir .. camel .. ".lua", "r")
|
|
if handle then handle:close() end
|
|
if handle and camel ~= file and not Gen3Compat.serves(gen1 .. camel)
|
|
and not siblingName then
|
|
siblingName = gen1 .. camel -- src.ui.BagMenu, what a mod requires
|
|
siblingPath = dir .. file -- src.ui.game3.bag_menu, what runs
|
|
siblingCamel = dir .. camel -- src.ui.game3.BagMenu, not on disk
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
T.check(siblingName ~= nil,
|
|
"a snake_case game3 sibling with no adapter exists, which is MK403")
|
|
|
|
-- ------- 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) .. "_gen3check"
|
|
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": "gen3check fixture"%s }')
|
|
:format(id, id, extra or "")
|
|
end
|
|
|
|
local FIRERED = ', "games": ["firered"]'
|
|
|
|
-- claims FireRed and nothing else, so gen2check has to fail it while
|
|
-- gen3check passes it: the same checks against two generations
|
|
local firered = write("gen3_firered", {
|
|
["manifest.json"] = manifest("gen3_firered", FIRERED),
|
|
["main.lua"] = "local mod = ...\n",
|
|
})
|
|
|
|
-- claims no generation at all
|
|
local unclaimed = write("gen3_unclaimed", {
|
|
["manifest.json"] = manifest("gen3_unclaimed"),
|
|
["main.lua"] = "local mod = ...\n",
|
|
})
|
|
|
|
-- only reads what the FireRed adapter backs
|
|
local clean = aliasName and write("gen3_clean", {
|
|
["manifest.json"] = manifest("gen3_clean", FIRERED),
|
|
["main.lua"] = ([[
|
|
local mod = ...
|
|
local M = require("%s")
|
|
local held = M.%s
|
|
mod.exports.held = held ~= nil
|
|
]]):format(aliasName, backedMember),
|
|
})
|
|
|
|
-- requires a module a FireRed boot never instantiates and nothing adapts
|
|
local unserved = write("gen3_unserved", {
|
|
["manifest.json"] = manifest("gen3_unserved", FIRERED),
|
|
["main.lua"] = ([[
|
|
local mod = ...
|
|
local M = require("%s")
|
|
M.thing(mod)
|
|
]]):format(unservedName),
|
|
})
|
|
|
|
-- requires a module a FireRed boot runs out of another file entirely
|
|
local sibling = siblingName and write("gen3_sibling", {
|
|
["manifest.json"] = manifest("gen3_sibling", FIRERED),
|
|
["main.lua"] = ([[
|
|
local mod = ...
|
|
local M = require("%s")
|
|
M.open(mod)
|
|
]]):format(siblingName),
|
|
})
|
|
|
|
-- calls a member the FireRed coverage table refuses to invent
|
|
local absent = write("gen3_absent", {
|
|
["manifest.json"] = manifest("gen3_absent", FIRERED),
|
|
["main.lua"] = ([[
|
|
local mod = ...
|
|
local M = require("%s")
|
|
M.%s(mod)
|
|
]]):format(absentName, absentMember),
|
|
})
|
|
|
|
-- an entry chunk holding a member of a game that is not up yet
|
|
local held = liveMember and write("gen3_held", {
|
|
["manifest.json"] = manifest("gen3_held", FIRERED),
|
|
["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),
|
|
})
|
|
|
|
-- the Gen 2 screen-twin half of MK409 is off on FireRed, so the version
|
|
-- string is the only MK409 a FireRed boot can raise
|
|
local version = write("gen3_version", {
|
|
["manifest.json"] = manifest("gen3_version", FIRERED),
|
|
["main.lua"] = ([[
|
|
local mod = ...
|
|
if mod.game.version == "red" then mod.exports.gen1 = true end
|
|
]]),
|
|
})
|
|
|
|
-- ------- a mod claiming FireRed, run through both commands
|
|
|
|
local out, code = gen3check(firered)
|
|
T.eq(code, 0, "a mod claiming FireRed exits 0 from gen3check: " .. out)
|
|
T.check(out:find("will load", 1, true) ~= nil,
|
|
"and its verdict is 'will load': " .. out)
|
|
T.check(out:find("on gen 3", 1, true) ~= nil,
|
|
"and the verdict line names the generation it checked: " .. out)
|
|
T.check(out:find("MK400", 1, true) == nil,
|
|
"a FireRed mod is never told it claims no FireRed game: " .. out)
|
|
|
|
out, code = run(("%s tools/modkit.py gen2check %q"):format(python, firered))
|
|
T.eq(code, 1, "the same mod fails gen2check, which is the point: " .. out)
|
|
T.check(out:find("MK400", 1, true) ~= nil,
|
|
"on the manifest gate: " .. out)
|
|
T.check(out:find("on gen 2", 1, true) ~= nil,
|
|
"with a verdict naming Gen 2: " .. out)
|
|
|
|
-- ------- the manifest gate, in FireRed's words
|
|
|
|
out, code = gen3check(unclaimed)
|
|
T.eq(code, 1, "a mod claiming no Gen 3 game fails the check")
|
|
T.check(out:find("MK400", 1, true) ~= nil, "MK400 names the manifest: " .. out)
|
|
T.check(out:find("Gen 3", 1, true) ~= nil,
|
|
"and the message is the FireRed one, not Gold's: " .. out)
|
|
T.check(out:find("will not work", 1, true) ~= nil,
|
|
"and the verdict says so: " .. out)
|
|
|
|
-- ------- a mod that only reads what the adapter backs
|
|
|
|
if clean then
|
|
out, code = gen3check(clean)
|
|
T.eq(code, 0, "a mod inside the FireRed adapter's coverage exits 0: " .. out)
|
|
T.check(out:find("MK40", 1, true) == nil,
|
|
"and raises nothing: " .. out)
|
|
end
|
|
|
|
-- ------- a module FireRed never instantiates and nothing adapts
|
|
|
|
out, code = gen3check(unserved)
|
|
T.eq(code, 1, "requiring it fails the check: " .. out)
|
|
T.check(out:find("MK402", 1, true) ~= nil, "MK402 names it: " .. out)
|
|
T.check(out:find(unservedName, 1, true) ~= nil,
|
|
"and quotes the module by name: " .. out)
|
|
T.check(out:find("Gen3Compat", 1, true) ~= nil,
|
|
"and points at the FireRed adapter file: " .. out)
|
|
T.check(out:find("Gen2Compat", 1, true) == nil,
|
|
"with no mention of the Gold one: " .. out)
|
|
|
|
-- ------- a module FireRed runs, from a file with another spelling
|
|
|
|
if sibling then
|
|
out, code = gen3check(sibling)
|
|
T.eq(code, 0, "MK403 is a warning, so it does not fail the gate: " .. out)
|
|
T.check(out:find("MK403", 1, true) ~= nil,
|
|
"a module FireRed runs from another file is MK403: " .. out)
|
|
T.check(out:find(siblingPath, 1, true) ~= nil,
|
|
"and the sibling it names is the snake_case one: " .. out)
|
|
T.check(out:find(siblingCamel, 1, true) == nil,
|
|
"never the CamelCase spelling that is not on disk: " .. out)
|
|
end
|
|
|
|
-- ------- a member the coverage table refuses to invent
|
|
|
|
out, code = gen3check(absent)
|
|
T.eq(code, 1, "calling an unbacked member fails the check: " .. out)
|
|
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)
|
|
T.check(out:find("Gen 3 backing", 1, true) ~= nil,
|
|
"and says which generation has no backing for it: " .. out)
|
|
|
|
-- ------- the entry chunk holding a member of a game that is not up yet
|
|
|
|
if held then
|
|
out = gen3check(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
|
|
|
|
-- ------- MK409's screen half is a Gen 2 fact and is off here
|
|
|
|
out = gen3check(version)
|
|
T.check(out:find("MK409", 1, true) ~= nil,
|
|
"a Gen 1 version string is MK409 on FireRed too: " .. out)
|
|
T.check(out:find("a Gen 3 game by construction", 1, true) ~= nil,
|
|
"and the message is the FireRed one: " .. out)
|
|
|
|
-- ------- the machine-readable form one CI step reads
|
|
|
|
out, code = run(("%s tools/modkit.py --json gen3check %q"):format(python,
|
|
firered))
|
|
T.eq(code, 0, "the JSON run of a passing mod exits 0")
|
|
T.check(out:find('"verdict": "will load"', 1, true) ~= nil,
|
|
"the JSON carries a verdict per mod: " .. out)
|
|
T.check(out:find('"ok": true', 1, true) ~= nil,
|
|
"and one ok for the batch: " .. out)
|
|
|
|
out, code = run(("%s tools/modkit.py --json gen3check %q %q")
|
|
:format(python, firered, unclaimed))
|
|
T.eq(code, 1, "the batch fails when any mod in it fails")
|
|
T.check(out:find('"ok": false', 1, true) ~= nil,
|
|
"and the batch ok follows: " .. 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("gen3_served", {
|
|
["manifest.json"] = manifest("gen3_served", FIRERED),
|
|
["main.lua"] = table.concat(requires, "\n") .. "\n",
|
|
})
|
|
out = gen3check(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("gen3check")
|