Files
gen1recomp/tests/engine/gate_gen3_mod_api.lua
T
bryanthaboi dee935a4ea CLOSES #2430
2026-09-23 11:58:32 -04:00

490 lines
20 KiB
Lua

package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local GameVersion = require("src.core.GameVersion")
local Manifest = require("src.mods.Manifest")
local ModTargets = require("src.mods.ModTargets")
local Schemas = require("src.mods.Schemas")
T.eq(GameVersion.generation("firered"), 3, "FireRed is Gen 3")
T.eq(GameVersion.generation("leafgreen"), 3, "LeafGreen is Gen 3")
T.eq(table.concat(ModTargets.expand("gen3"), ","), "firered,leafgreen",
"gen3 is every Gen 3 game")
local function manifest(extra)
local raw = { id = "fix", name = "Fixture", version = "1.0.0",
entry = "main.lua", api = 2 }
for key, value in pairs(extra or {}) do raw[key] = value end
return Manifest.validate(raw, "mods/fix")
end
T.check(ModTargets.supports(manifest({ games = { "gen3" } }), "firered"),
"games = [gen3] claims FireRed")
T.check(ModTargets.supports(manifest({ games = { "gen3" } }), "leafgreen"),
"games = [gen3] claims LeafGreen")
T.check(ModTargets.supports(manifest({ games = { "firered" } }), "firered"),
"games = [firered] claims FireRed")
T.check(not ModTargets.supports(manifest({ games = { "firered" } }), "leafgreen"),
"games = [firered] does not claim LeafGreen")
T.check(ModTargets.supports(manifest({ games = { "leafgreen" } }), "leafgreen"),
"games = [leafgreen] claims LeafGreen")
T.check(not ModTargets.supports(manifest({ games = { "leafgreen" } }), "firered"),
"games = [leafgreen] does not claim FireRed")
T.check(not ModTargets.supports(manifest(), "firered"),
"a manifest that says nothing is not a FireRed mod")
T.check(not ModTargets.supports(manifest({ gen2compat = true }), "firered"),
"and gen2compat is not a Gen 3 claim")
T.eq(manifest({ games = { "gen3" } }).gen2compat, false,
"a Gen 3 claim is not a Gen 2 claim either")
local ROUTED = {
pokemon = "gen3Pokemon", moves = "gen3Moves", items = "gen3Items",
encounters = "gen3Encounters", trainers = "gen3Trainers",
text = "gen3Text", map_scripts = "gen3Scripts",
}
for name, path in pairs(ROUTED) do
local spec = Schemas.REGISTRIES[name]
T.check(spec ~= nil, "catalog still has registry: " .. name)
T.eq(Schemas.targetFor(name, spec, 3), path,
"available under Gen 3 at its Gen 3 path: " .. name)
T.eq(Schemas.gatedFor(name, 3), false, "not gated under Gen 3: " .. name)
T.eq(Schemas.targetFor(name, spec, 1), spec.target,
"and Gen 1 is untouched by the routing: " .. name)
end
for _, name in ipairs({ "maps", "commands", "strings" }) do
local spec = Schemas.REGISTRIES[name]
T.eq(Schemas.targetFor(name, spec, 3), spec.target,
"available under Gen 3 at its shared path: " .. name)
end
for _, name in ipairs({ "tilesets", "sprites", "type_chart", "statuses",
"move_effects", "balls", "ai_classes", "growth_rates",
"evolution_methods", "transitions", "rulesets",
"field", "text_pointers", "link_fields", "screens",
"tokens", "music", "sfx", "cries", "map_songs",
"audio", "font", "palettes", "icons", "battle_anims",
"held_items", "phone_contacts", "decorations",
"apricorns", "landmarks", "radio_channels" }) do
local spec = Schemas.REGISTRIES[name]
T.check(spec ~= nil, "catalog still has registry: " .. name)
T.eq(Schemas.targetFor(name, spec, 3), nil,
"gated registry has no Gen 3 target: " .. name)
T.eq(Schemas.gatedFor(name, 3), true, "gated under Gen 3: " .. name)
end
T.eq(Schemas.targetFor("rom_text", Schemas.REGISTRIES.rom_text, 3), "gen3RomText",
"rom_text overrides FireRed text by its pret name")
for name in pairs(Schemas.GEN3) do
T.check(Schemas.REGISTRIES[name] ~= nil,
"Schemas.GEN3 names a real registry: " .. name)
end
do
local claimed = {}
for name, spec in pairs(Schemas.REGISTRIES) do
local path = Schemas.targetFor(name, spec, 3)
if path then
T.check(claimed[path] == nil or claimed[path] == name,
("two registries share one Gen 3 path (%s): %s and %s")
:format(path, tostring(claimed[path]), name))
claimed[path] = name
end
end
end
do
local spec = Schemas.REGISTRIES.pokemon
local gen3 = Schemas.shapeFor("pokemon", spec, 3)
T.check(gen3 ~= spec, "Gen 3 gets a derived spec")
T.eq(Schemas.shapeFor("pokemon", gen3, 3), gen3,
"resolving a derived spec again is a no-op")
T.check(gen3.fields.learnset ~= nil and gen3.fields.baseStats.fields.specialAttack ~= nil,
"the Gen 3 species shape is folded onto `fields`")
T.check(gen3.fields.eggMoves ~= nil,
"the Gen 3 species shape carries the gEggMoves field")
T.eq(gen3.gen3Fields, nil, "the gen3* keys are gone from the derived spec")
T.eq(gen3.gen2Fields, nil, "and so are the gen2* keys")
T.eq(gen3.target, "gen3Pokemon", "the derived spec carries the routed path")
T.check(gen3.baseAt ~= nil and gen3.baseIds ~= nil and gen3.write ~= nil,
"and reaches the numeric tables through baseAt/baseIds/write")
local gen2 = Schemas.shapeFor("pokemon", spec, 2)
T.eq(gen2.gen3Fields, nil, "a Gen 2 derived spec carries no gen3* keys")
T.eq(gen2.baseAt, Schemas.REGISTRIES.pokemon.gen2BaseAt,
"and keeps its own Gen 2 baseAt")
local scripts = Schemas.shapeFor("map_scripts", Schemas.REGISTRIES.map_scripts, 3)
T.eq(scripts.semantics, "record",
"Gen 3 scripts are whole VM row lists, not compose chains")
end
for name, spec in pairs(Schemas.REGISTRIES) do
if Schemas.hasGen3Shape(spec) then
T.eq(Schemas.gatedFor(name, 3), false,
"a registry with a Gen 3 shape is not gated: " .. name)
end
end
do
local G3 = Schemas.gen3View
T.eq(G3.idOf("NIDORAN\226\153\128"), "NIDORAN_F", "female glyph id")
T.eq(G3.idOf("NIDORAN\226\153\130"), "NIDORAN_M", "male glyph id")
T.eq(G3.idOf("FARFETCH'D"), "FARFETCHD", "apostrophes drop")
T.eq(G3.idOf("MR. MIME"), "MR_MIME", "punctuation folds to one underscore")
T.eq(G3.idOf("HO-OH"), "HO_OH", "hyphens fold")
T.eq(G3.idOf("POK\195\169 BALL"), "POKE_BALL", "the accented E folds")
T.eq(G3.idOf("??????????"), nil, "a placeholder name has no id")
local ir = G3.textIr("A\nB\n\nC")
local kinds = {}
for i, token in ipairs(ir) do kinds[i] = token.t end
T.eq(table.concat(kinds, ","), "text,nl,text,para,text,eos",
"a plain string becomes the VM's text IR")
end
local Catalog = T.catalog
local function isGen3Site(path)
return path:match("game3") ~= nil or path:match("Game3") ~= nil
or path:match("Gen3") ~= nil
end
local function isFacadeSite(path)
return path:match("Gen3Compat") ~= nil or path:match("WorldAPI") ~= nil
or path:match("BattleAPI") ~= nil
end
local GEN3_EVENTS = {
"game.ready", "save.created", "save.loaded", "save.loading", "save.writing",
"map.entered", "map.exited", "map.reloaded", "player.warped",
"world.stepped", "world.interacted", "world.npc_spawned",
"world.blacked_out", "world.block_replaced", "world.boulder_moved",
"world.object_toggled",
"flag.changed", "script.started", "script.ended",
"pokemon.before_give", "pokemon.evolved",
"battle.started", "battle.ended", "battle.turn_started", "battle.turn_ended",
"battle.move_used", "battle.damage_dealt", "battle.status_inflicted",
"battle.fainted", "battle.battler_switched", "battle.exp_gained",
"battle.ball_thrown", "pokemon.caught", "pokemon.level_up",
"pokemon.move_learned", "world.trainer_engaged",
"intro.oak_speech.started", "intro.oak_speech.step",
"intro.oak_speech.answered", "intro.oak_speech.finished",
}
local GEN3_HOOKS = {
"encounter.roll", "encounter.species", "encounter.table",
"movement.collision", "warp.destination", "world.talk", "item.use",
"world.follower.spawn",
"script.command", "save.write", "save.new_game",
"ui.start_menu.items", "pokemon.sprite",
"input.step", "input.key", "input.gamepad", "input.wheel", "render.hud",
"trainer.party", "catch.rate", "exp.gain", "evolution.check",
"battle.damage", "battle.crit", "battle.accuracy", "battle.charge_required",
"battle.run", "battle.turn_order", "battle.enemy_action",
}
local function assertShared(name, sites, kind)
local gen3, other = 0, 0
for _, path in ipairs(sites) do
if isGen3Site(path) then gen3 = gen3 + 1 else other = other + 1 end
end
T.check(gen3 > 0, ("FireRed raises the %s: %s"):format(kind, name))
T.check(other > 0,
("the %s %s is shared, not a Gen 3 invention (no Gen 1 or Gen 2 site)")
:format(kind, name))
end
for _, name in ipairs(GEN3_EVENTS) do
assertShared(name, Catalog.eventSites(name), "event")
end
for _, name in ipairs(GEN3_HOOKS) do
assertShared(name, Catalog.hookSites(name), "hook")
end
local function assertListed(names, catalogNames, sites, kind)
local listed = {}
for _, name in ipairs(names) do listed[name] = true end
for _, name in ipairs(catalogNames) do
if not Catalog.isModEvent(name) then
local gen3, other = false, false
for _, path in ipairs(sites(name)) do
if isGen3Site(path) then gen3 = true else other = true end
end
if gen3 then
T.check(other,
("%s %s is raised only from Gen 3 modules; Gen 3 reuses the "
.. "Gen 1 / Gen 2 names and invents none"):format(kind, name))
T.check(listed[name],
("%s %s has a Gen 3 site but is not in this gate's list; add it "
.. "here and to docs/mod-api-gen3-compat.md"):format(kind, name))
end
end
end
end
assertListed(GEN3_EVENTS, Catalog.events(), Catalog.eventSites, "event")
assertListed(GEN3_HOOKS, Catalog.hooks(), Catalog.hookSites, "hook")
for _, name in ipairs(Catalog.events()) do
T.check(name:sub(1, 5) ~= "gen3.", "no generation-prefixed event: " .. name)
end
for _, name in ipairs(Catalog.hooks()) do
T.check(name:sub(1, 5) ~= "gen3.", "no generation-prefixed hook: " .. name)
end
do
local cache = {}
local function sourceOf(path)
if cache[path] == nil then
local handle = io.open(path, "r")
cache[path] = handle and handle:read("*a") or false
if handle then handle:close() end
end
return cache[path] or nil
end
for _, name in ipairs(Catalog.events()) do
for _, path in ipairs(Catalog.eventSites(name)) do
if isGen3Site(path) and not isFacadeSite(path) then
local body = sourceOf(path)
T.check(body and body:find(('Runtime.wants("%s")'):format(name), 1, true),
("the Gen 3 emit of %s in %s is guarded by Runtime.wants")
:format(name, path))
end
end
end
end
local function files(...)
local out = {}
for _, set in ipairs({ ... }) do
for path, body in pairs(set) do out[path] = body end
end
return out
end
local function fixture(id, games, body)
local gamesJson = games and (',"games":["' .. table.concat(games, '","') .. '"]') or ""
return {
["mods/" .. id .. "/manifest.json"] = ([[{
"id": "%s", "name": "%s", "version": "1.0.0", "entry": "main.lua",
"api": 2%s
}]]):format(id, id, gamesJson),
["mods/" .. id .. "/main.lua"] = body,
}
end
local function statusOf(run, id)
for _, entry in ipairs(run.loader:status().available) do
if entry.id == id then return entry end
end
return nil
end
local GEN1_ONLY = fixture("fix_gen1_only", nil, [[
local mod = ...
mod.content.pokemon:patch("MEW", { catchRate = 1 })
]])
local GEN3_READY = fixture("fix_gen3_ready", { "gen1", "gen3" }, [[
local mod = ...
mod.exports.api = mod
local mew = mod.content.pokemon:get("MEW")
mod.exports.mewIndex = mew and mew.index
mod.exports.mewStats = mew and mew.baseStats.specialAttack
local pidgey = mod.content.pokemon:get("PIDGEY")
mod.exports.pidgeyEggMoves = pidgey and pidgey.eggMoves
mod.exports.mewEggMoves = mew and mew.eggMoves
local copy = {}
for key, value in pairs(mew) do copy[key] = value end
copy.spriteFront = mod.path .. "/front.png"
mod.content.pokemon:override("MEW", copy)
mod.content.pokemon:patch("CHARMANDER", {
catchRate = 3,
learnset = { { level = 1, move = "SURF" } },
eggMoves = { "EMBER" },
evolutions = { { method = "EVO_ITEM", item = "THUNDERSTONE",
species = "CHARMELEON" } },
})
mod.content.moves:patch("EMBER", { power = 50 })
mod.content.items:patch("POTION", { price = 1 })
mod.content.encounters:patch("FR_ROUTE_1", { land = { rate = 99 } })
mod.content.trainers:patch("326", {
party = { { species = "MEW", level = 7, moves = { "SURF" } } },
})
mod.content.text:override("Text_BootedUpPC", "HELLO")
mod.content.map_scripts:override("EventScript_Fixture", { { op = "end" } })
mod.content.transitions:register("FIXTURE_WIPE", { frames = 30 })
mod.exports.species = {}
for id in mod.content.pokemon:each() do mod.exports.species[#mod.exports.species + 1] = id end
]])
do
local data = T.sdk.gen3Data()
local run = T.sdk.loadMods({ "mods/fix_gen1_only", "mods/fix_gen3_ready" }, {
fs = T.sdk.memfs(files(GEN1_ONLY, GEN3_READY)),
data = data,
generation = 3,
})
local skipped = statusOf(run, "fix_gen1_only")
T.eq(skipped.state, "wrong_generation",
"Gen 3: a mod that never claimed FireRed is not loaded")
T.eq(skipped.error, nil, "Gen 3: and the skip is not a failure")
T.eq(statusOf(run, "fix_gen3_ready").state, "loaded",
"Gen 3: the declared mod loads (" .. tostring(run.errors[1]) .. ")")
local exports = run.loader.exports.fix_gen3_ready or {}
T.eq(exports.mewIndex, 151, "Gen 3: MEW resolves by name to species 151")
T.eq(exports.mewStats, 100, "Gen 3: with the split special stats")
T.eq(table.concat(exports.pidgeyEggMoves or {}, ","), "SCRATCH,TACKLE",
"Gen 3: the species record carries its egg moves as move ids")
T.eq(exports.mewEggMoves, nil,
"Gen 3: a species with no gEggMoves entry carries no eggMoves field")
local seen = {}
for _, id in ipairs(exports.species or {}) do seen[id] = true end
T.check(seen.NIDORAN_F and seen.CHARMANDER and not seen["?"],
"Gen 3: each() yields name ids and skips placeholder species")
local P = data.gen3Pokemon
T.eq(P._speciesMeta[4].catchRate, 3, "Gen 3: a patch lands in the numeric meta table")
T.eq(P._speciesMeta[151].catchRate, 45, "Gen 3: the skipped mod's patch left no trace")
T.eq(P._learnsets[4][1][2], 57, "Gen 3: a learnset move name writes back as its number")
T.eq(P._eggMoves[4][1], 52, "Gen 3: an egg move name writes back as its number")
T.eq(P._evolutions[4][1].method, 7, "Gen 3: an evolution method writes back as EVO_ITEM")
T.eq(P._evolutions[4][1].param, 96, "Gen 3: and its item as the item number")
T.eq(P._evolutions[4][1].target, 5, "Gen 3: and its species as the species number")
T.check(P.spriteOverrides and P.spriteOverrides[151]
and P.spriteOverrides[151].front == "mods/fix_gen3_ready/front.png",
"Gen 3: a sprite path override is recorded for the pic seeder")
T.eq(P.spriteOverrides[151].back, nil, "Gen 3: the untouched back pic stays vanilla")
T.eq(data.gen3Moves._rom[52].power, 50, "Gen 3: a move patch lands in the ROM row")
T.eq(data.gen3Items._byId[13].price, 1, "Gen 3: an item patch lands in the item row")
T.eq(data.gen3Encounters.FR_ROUTE_1.land.rate, 99, "Gen 3: an encounter patch lands")
T.eq(data.gen3Encounters["3:19"].land.rate, 99,
"Gen 3: and reaches the group:num alias of the same map")
T.eq(data.gen3Encounters.FR_ROUTE_1.land.slots[1].species, 16,
"Gen 3: encounter species stay numeric in the engine table")
local rival = data.gen3Trainers.trainers[326]
T.eq(rival.party[1].species, 151, "Gen 3: a trainer party species writes back as a number")
T.eq(rival.party[1].moves[1], 57, "Gen 3: and its moves as numbers")
T.eq(rival.partySize, 1, "Gen 3: partySize follows the party")
local text = data.gen3Text.Text_BootedUpPC
T.check(text[1].t == "text" and text[1].s == "HELLO" and text[2].t == "eos",
"Gen 3: a string text override becomes the VM's IR")
T.eq(data.gen3Scripts.EventScript_Fixture[1].op, "end",
"Gen 3: a script override replaces the VM row list")
T.check(data.transitions == nil or data.transitions.FIXTURE_WIPE == nil,
"Gen 3: a gated registry merges nothing")
local told = false
for _, message in ipairs(run.errors) do
if message:match("transitions") and message:match("Gen 3") then told = true end
end
T.check(told, "Gen 3: the dropped registration is reported in Gen 3's words")
local api = exports.api
T.check(api ~= nil, "Gen 3: the mod handed its api back for the facade check")
if api then
run.loader.game = {}
local world = api.world
T.eq(getmetatable(world), require("src.world.game3.WorldAPI"),
"Gen 3: mod.world resolves to src.world.game3.WorldAPI")
local battle = api.battle
T.eq(getmetatable(battle), require("src.battle.game3.BattleAPI"),
"Gen 3: mod.battle resolves to src.battle.game3.BattleAPI")
run.loader.game = nil
end
run.release()
end
do
local BAD = fixture("fix_refs", { "gen3" }, [[
local mod = ...
mod.content.pokemon:patch("PIDGEY", {
evolutions = { { method = "EVO_LEVEL", level = 18, species = "PIDGEOTTO_TYPO" } },
})
mod.content.pokemon:patch("MEW", { learnset = { { level = 1, move = "NOT_A_MOVE" } } })
]])
local run = T.sdk.loadMods({ "mods/fix_refs" }, {
fs = T.sdk.memfs(BAD), data = T.sdk.gen3Data(), generation = 3,
})
local pokemon, moves = false, false
for _, message in ipairs(run.errors) do
if message:match("unresolved reference to pokemon") then pokemon = true end
if message:match("unresolved reference to moves") then moves = true end
end
T.check(pokemon, "Gen 3: an evolution into an unknown species is reported")
T.check(moves, "Gen 3: a learnset move that does not exist is reported")
run.release()
end
do
local GOOD = fixture("fix_refs_ok", { "firered" }, [[
local mod = ...
mod.content.pokemon:patch("PIDGEY", {
evolutions = { { method = "EVO_LEVEL", level = 18, species = "CHARMELEON" } },
itemRare = "POTION",
})
]])
local run = T.sdk.loadMods({ "mods/fix_refs_ok" }, {
fs = T.sdk.memfs(GOOD), data = T.sdk.gen3Data(), generation = 3,
})
local dangling = {}
for _, message in ipairs(run.errors) do
if message:match("unresolved reference") then dangling[#dangling + 1] = message end
end
T.eq(#dangling, 0, "Gen 3: refs that resolve report nothing ("
.. table.concat(dangling, "; ") .. ")")
T.eq(statusOf(run, "fix_refs_ok").state, "loaded", "Gen 3: games = [firered] loads")
run.release()
end
do
local run = T.sdk.loadNone({ generation = 3, data = T.sdk.gen3Data() })
local content = run.loader.content
T.eq(content.balls:get("GREAT_BALL"), nil, "Gen 3 seeds none of Red's balls")
T.eq(content.statuses:get("BRN"), nil, "Gen 3 seeds none of Red's statuses")
T.eq(content.ai_classes:get("LAYER_1"), nil, "or Red's AI layers")
T.eq(content.commands:get("show_text"), nil, "or Gen 1's row-list verbs")
T.eq(run.data.statuses, nil, "a mod-free FireRed boot writes no Gen 1 namespace")
T.eq(run.data.commands, nil, "and leaves data.commands absent")
T.eq(#require("src.mods.Builtins").registries(3), 0,
"the Gen 3 registrant list is empty")
run.release()
end
do
local PROBE = [[
local mod = ...
local function attempt(name)
local ok, result = pcall(require, name)
if ok then return nil end
return tostring(result)
end
mod.exports.gen2Err = attempt("src.battle.gen2.Mon")
mod.exports.game2Err = attempt("src.core.Game2")
mod.exports.game3Err = attempt("src.core.game3.pokemon")
]]
local FILES = fixture("fix_probe", { "all" }, PROBE)
local onRed = T.sdk.loadMods({ "mods/fix_probe" },
{ fs = T.sdk.memfs(files(FILES)), data = {}, generation = 1 })
local red = onRed.loader.exports.fix_probe or {}
T.check(red.game3Err and red.game3Err:find("Gen 3 engine module", 1, true),
"Gen 1: a Gen 3 engine module is refused: " .. tostring(red.game3Err))
onRed.release()
local onFireRed = T.sdk.loadMods({ "mods/fix_probe" },
{ fs = T.sdk.memfs(files(FILES)), data = T.sdk.gen3Data(), generation = 3 })
local fr = onFireRed.loader.exports.fix_probe or {}
T.check(fr.gen2Err and fr.gen2Err:find("Gen 2 engine module", 1, true),
"Gen 3: a Gen 2 engine module is refused: " .. tostring(fr.gen2Err))
T.check(fr.game2Err and fr.game2Err:find("src.core.Game2", 1, true),
"Gen 3: and so is src.core.Game2")
T.check(not (fr.game3Err and fr.game3Err:find("engine module and this is", 1, true)),
"Gen 3: its own engine modules are not refused: " .. tostring(fr.game3Err))
onFireRed.release()
end
T.finish("gate_gen3_mod_api")