mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 16:31:05 +02:00
993 lines
44 KiB
Lua
993 lines
44 KiB
Lua
-- Parity gate for the Gen 1 / Gen 2 mod API boundary.
|
|
--
|
|
-- The rule this file exists to hold: hook names, event names and registry
|
|
-- names are SHARED across generations, and the only things that differ are
|
|
-- where a registry's content lands and whether the mod runs at all. A mod
|
|
-- opts into Gen 2 with `gen2compat` in its manifest and is left out of a Gold
|
|
-- boot entirely without it, because a mod that half-applies reads as broken.
|
|
--
|
|
-- Runs ROM-free: the generation is injected through the loader rather than by
|
|
-- booting Gold (T.sdk.loadMods opts.generation).
|
|
|
|
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 Schemas = require("src.mods.Schemas")
|
|
local StateStack = require("src.core.StateStack")
|
|
|
|
-- ------- 1. the version table knows its generation
|
|
|
|
T.eq(GameVersion.generation("red"), 1, "Red is Gen 1")
|
|
T.eq(GameVersion.generation("blue"), 1, "Blue is Gen 1")
|
|
T.eq(GameVersion.generation("yellow"), 1, "Yellow is Gen 1")
|
|
T.eq(GameVersion.generation("gold"), 2, "Gold is Gen 2")
|
|
|
|
-- ------- 2. manifest: gen2compat is opt-in and defaults off
|
|
|
|
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.eq(manifest().gen2compat, false,
|
|
"a manifest that says nothing is Gen 1 only")
|
|
T.eq(manifest({ gen2compat = true }).gen2compat, true,
|
|
"gen2compat = true is carried through")
|
|
T.eq(manifest({ gen2compat = false }).gen2compat, false,
|
|
"gen2compat = false is carried through")
|
|
T.check(not pcall(manifest, { gen2compat = "yes" }),
|
|
"a non-boolean gen2compat is rejected")
|
|
|
|
-- ------- 3. registry target routing
|
|
--
|
|
-- One routing table per generation (Schemas.routing): Gen 2 keeps the shared
|
|
-- target for everything it can serve and reports the rest instead of merging
|
|
-- into a table nothing reads, and Gen 1 does the same for the registries that
|
|
-- only exist because Gold does. Gen 1 used to consult nothing at all; the
|
|
-- catalog now holds content BOTH ways round, so the claim is the symmetrical
|
|
-- one -- a registry is gated in a generation exactly when it has no home
|
|
-- there, whichever generation that is.
|
|
|
|
for name, spec in pairs(Schemas.REGISTRIES) do
|
|
T.eq(Schemas.targetFor(name, spec, 1), spec.target,
|
|
"Gen 1 keeps the catalog target: " .. name)
|
|
if Schemas.GEN1[name] == nil then
|
|
T.eq(Schemas.gatedFor(name, 1), false,
|
|
"a registry Gen 1 routing says nothing about is not gated there: " .. name)
|
|
else
|
|
-- the mirror of the gated rows below: a Gen 2-only system, so there is no
|
|
-- Gen 1 target to keep and a Red mod's write is dropped and reported
|
|
-- rather than merged into a namespace no Gen 1 boot reads. The per-
|
|
-- registry cases are in tests/engine/gen2_content_registries.lua.
|
|
T.eq(Schemas.gatedFor(name, 1), true, "gated under Gen 1: " .. name)
|
|
T.eq(spec.target, nil,
|
|
"a Gen 1-gated registry carries no Gen 1 target: " .. name)
|
|
T.check(Schemas.targetFor(name, spec, 2) ~= nil,
|
|
"and has a Gen 2 home, or the name is dead in both: " .. name)
|
|
end
|
|
end
|
|
|
|
-- the registries Gold genuinely reads off game.data keep their name AND their
|
|
-- path, which is what lets one mod source target both generations.
|
|
-- `commands` is here rather than in the routed set below because the Gen 2 VM
|
|
-- resolves a mod verb out of the SAME merged data.commands table Gen 1's
|
|
-- runner does (src/script/gen2/Vm.lua:runModCommand, reached from the
|
|
-- Opcodes.MOD_COMMAND row a cart can never write).
|
|
for _, name in ipairs({ "pokemon", "moves", "items", "type_chart", "screens",
|
|
"strings", "font", "audio", "music", "sfx", "cries",
|
|
"map_songs", "commands",
|
|
-- `tokens` because TextBox.substitute reads
|
|
-- game.data.tokens on every box in both games, and
|
|
-- `growth_rates` because src/mods/Builtins.lua's Gen 2
|
|
-- registrant seeds Gold's curves as the same
|
|
-- { expForLevel } record Gen 1 uses, so one mod record
|
|
-- serves both (src/battle/gen2/Mon.lua:growthFor is the
|
|
-- single accessor all six Gen 2 readers go through)
|
|
"tokens", "growth_rates",
|
|
-- `battle_sprite_scales` because
|
|
-- src/ui/gen2/BattleState.lua:imageScale walks
|
|
-- data.battle_sprite_scales for a record whose .path
|
|
-- matches the pic being drawn, skipping `_owners`,
|
|
-- exactly as Gen 1's BattleState.imageBattleScale
|
|
-- does, and picScale falls through to the species'
|
|
-- battleScaleFront / battleScaleBack after it. Only
|
|
-- the default differs (Red draws 32x32 back pics at
|
|
-- 2x, Gold's 48x48 ones fill their box at 1x) and the
|
|
-- default is not a registry record either side.
|
|
--
|
|
-- `render_pipelines` because src/core/Game2.lua:load
|
|
-- calls Pipelines.install(self.data) AFTER mods:load,
|
|
-- so src/render/Pipelines.lua walks the MERGED table,
|
|
-- and Game2:draw composites the whole-frame half
|
|
-- through Pipelines.wantsPresent / Pipelines.present.
|
|
-- Gold does not composite `drawWorld` yet -- its
|
|
-- overworld draws straight to the window -- and
|
|
-- Game2:load retires a restored drawWorld-only level
|
|
-- rather than leaving it on and drawing nothing, so
|
|
-- that half is inert rather than broken. Routing it
|
|
-- is still right: the registry has a live reader, and
|
|
-- a gated registry would drop the `present` half too.
|
|
"battle_sprite_scales", "render_pipelines" }) do
|
|
local spec = Schemas.REGISTRIES[name]
|
|
T.check(spec ~= nil, "catalog still has registry: " .. name)
|
|
T.eq(Schemas.targetFor(name, spec, 2), spec.target,
|
|
"available under Gen 2 at its Gen 1 path: " .. name)
|
|
T.eq(Schemas.gatedFor(name, 2), false, "not gated under Gen 2: " .. name)
|
|
end
|
|
|
|
-- The tables Gold namespaces: same registry NAME, a Gen 2 Data path
|
|
-- underneath it. src/core/Game2.lua:load reads each of these into game.data
|
|
-- before mods:load runs, and the consumer holds it by reference --
|
|
-- src/world/gen2/World.lua:dataTable for the overworld four, the menus for
|
|
-- palettes/icons/battle_anims/constants -- so the merge lands in the table the
|
|
-- game walks. The battle-rule six have no table on disk at all: they come
|
|
-- into existence AS the merge (src/mods/Builtins.lua seeds Gold's own records
|
|
-- there), and each consumer reads them through a per-id lookup that falls back
|
|
-- to the module's records, so a mod-free boot behaves identically.
|
|
-- The pairing is asserted both ways round: routed is NOT the Gen 1 target
|
|
-- (that would mean merging into a table no Gold boot reads) and NOT nil (that
|
|
-- would mean the write is being dropped).
|
|
for name, path in pairs({ maps = "gen2Maps", tilesets = "gen2Tilesets",
|
|
sprites = "gen2Sprites", text = "gen2Text",
|
|
encounters = "gen2Encounters",
|
|
trainers = "gen2Trainers",
|
|
palettes = "gen2Palettes", icons = "gen2Icons",
|
|
battle_anims = "gen2BattleAnims",
|
|
constants = "gen2Constants",
|
|
statuses = "gen2Statuses",
|
|
move_effects = "gen2MoveEffects",
|
|
item_effects = "gen2ItemEffects",
|
|
balls = "gen2Balls",
|
|
ai_classes = "gen2AiClasses",
|
|
evolution_methods = "gen2EvolutionMethods" }) do
|
|
local spec = Schemas.REGISTRIES[name]
|
|
T.check(spec ~= nil, "catalog still has registry: " .. name)
|
|
T.eq(Schemas.targetFor(name, spec, 2), path,
|
|
"available under Gen 2 at its Gen 2 path: " .. name)
|
|
T.check(Schemas.targetFor(name, spec, 2) ~= spec.target,
|
|
"a routed registry does not keep the Gen 1 path: " .. name)
|
|
T.eq(Schemas.gatedFor(name, 2), false, "not gated under Gen 2: " .. name)
|
|
T.eq(Schemas.targetFor(name, spec, 1), spec.target,
|
|
"and Gen 1 is untouched by the routing: " .. name)
|
|
end
|
|
|
|
-- The mirror set: six registries that exist because GOLD does. They carry no
|
|
-- Gen 1 target at all, so the routed path is the only path they ever have, and
|
|
-- Schemas.GEN1 gates them on Red the way Schemas.GEN2 gates `map_scripts` on Gold.
|
|
-- Each is held to a live consumer, which is the claim that matters: a routed
|
|
-- registry nothing reads is the silent no-op the whole routing table exists to
|
|
-- prevent. The per-consumer cases are in
|
|
-- tests/engine/gen2_content_registries.lua; here the pairing itself is pinned.
|
|
-- held_items src/core/gen2/ItemEffects.lua:heldItemFor / applyHeldItems,
|
|
-- written back onto data.items for Battle:itemDef
|
|
-- phone_contacts src/core/gen2/Phone.lua:useRegistry
|
|
-- decorations src/core/gen2/Decorations.lua:attributes
|
|
-- apricorns src/core/gen2/Apricorns.lua:useRegistry
|
|
-- landmarks src/core/gen2/Nests.lua:landmarkId / landmark
|
|
-- radio_channels src/ui/gen2/MapRadio.lua:channelRecord
|
|
for name, path in pairs({ held_items = "gen2HeldItems",
|
|
phone_contacts = "gen2PhoneContacts",
|
|
decorations = "gen2Decorations",
|
|
apricorns = "gen2Apricorns",
|
|
landmarks = "gen2Landmarks.landmarks",
|
|
radio_channels = "gen2RadioChannels" }) do
|
|
local spec = Schemas.REGISTRIES[name]
|
|
T.check(spec ~= nil, "catalog still has registry: " .. name)
|
|
T.eq(Schemas.targetFor(name, spec, 2), path,
|
|
"a Gen 2-only registry is available under Gen 2: " .. name)
|
|
T.eq(Schemas.gatedFor(name, 2), false, "not gated under Gen 2: " .. name)
|
|
T.eq(spec.target, nil,
|
|
"and carries no Gen 1 target to fall back on: " .. name)
|
|
T.eq(Schemas.gatedFor(name, 1), true,
|
|
"so a Red boot reports the write rather than merging it: " .. name)
|
|
T.eq(Schemas.targetFor(name, spec, 1), nil,
|
|
"and has no Gen 1 path at all: " .. name)
|
|
end
|
|
|
|
-- and the ones Gold has no home for are gated, not silently retargeted.
|
|
-- One cause is left behind these: Gold reimplements the system WITHOUT
|
|
-- reading a registry, so there is no table a merge could land in that anything
|
|
-- would read. Closing one is a consumer change in the Gen 2 module first and
|
|
-- a routing row second, which is exactly how growth_rates closed --
|
|
-- src/battle/gen2/Mon.lua grew growthFor / registerInto and takes an
|
|
-- expForLevel record ahead of the coefficient row, so the registry now routes
|
|
-- to the SHARED Gen 1 path and one mod record serves both games.
|
|
--
|
|
-- `tokens` was on this list by mistake rather than by cause: TextBox.new runs
|
|
-- TextBox.substitute on every box in both generations and substitute reads
|
|
-- game.data.tokens, so the shared target was live on Gold the whole time.
|
|
-- `battle_sprite_scales` and `render_pipelines` came off it the way
|
|
-- growth_rates did, consumer first: src/ui/gen2/BattleState.lua:imageScale now
|
|
-- reads data.battle_sprite_scales, and src/core/Game2.lua:load installs
|
|
-- src/render/Pipelines.lua on Gold's merged dataset after mods:load so
|
|
-- Game2:draw composites a `present` pipeline. Both are asserted in the shared
|
|
-- set above.
|
|
--
|
|
-- `transitions` stays because Gold's own intro
|
|
-- (src/ui/gen2/BattleTransition.lua) keys STYLES as a boolean SET of the four
|
|
-- cart wipes rather than the { frames, draw, sound, flash } record this
|
|
-- registry carries, and has no styleDef lookup a mod id could reach.
|
|
--
|
|
-- map_scripts is the one genuine script-side gap left: src/script/gen2/Vm.lua
|
|
-- runs the cart's bytecode out of data.gen2Scripts keyed by ROM pointer, and a
|
|
-- Lua row list merged into that pool is not something the VM can run.
|
|
for _, name in ipairs({ "map_scripts", "rulesets", "transitions",
|
|
"field", "text_pointers", "link_fields" }) do
|
|
local spec = Schemas.REGISTRIES[name]
|
|
T.check(spec ~= nil, "catalog still has registry: " .. name)
|
|
T.eq(Schemas.targetFor(name, spec, 2), nil,
|
|
"gated registry has no Gen 2 target: " .. name)
|
|
T.eq(Schemas.gatedFor(name, 2), true, "gated under Gen 2: " .. name)
|
|
end
|
|
|
|
-- A routed registry is only routed if the records already sitting at that
|
|
-- path pass the shared schema, so this pins the two optional warp fields Gen 2
|
|
-- carries (the ROM map-group pair) that a strict record would otherwise
|
|
-- reject on every one of Gold's 368 maps.
|
|
do
|
|
local spec = Schemas.REGISTRIES.maps
|
|
local gen2Map = {
|
|
id = "MOD_TOWN", tileset = "TILESET_JOHTO", width = 2, height = 2,
|
|
blocks = { 1, 2, 3, 4 },
|
|
warps = { { x = 6, y = 3, destMap = "ELMS_LAB", destWarp = 1,
|
|
destGroup = 24, destMapNum = 5 } },
|
|
}
|
|
T.check(Schemas.check(spec, "maps", "MOD_TOWN", gen2Map, "register"),
|
|
"a Gen 2 warp row validates against the shared maps schema")
|
|
local gen1Map = {
|
|
id = "MOD_TOWN", tileset = "OVERWORLD", width = 2, height = 2,
|
|
blocks = { 1, 2, 3, 4 },
|
|
warps = { { x = 1, y = 1, destMap = "PALLET_TOWN", destWarp = 1 } },
|
|
}
|
|
T.check(Schemas.check(spec, "maps", "MOD_TOWN", gen1Map, "register"),
|
|
"and the Gen 1 warp row still does, the added fields being optional")
|
|
end
|
|
|
|
-- ------- 3b. the per-generation RECORD shape
|
|
--
|
|
-- Routing says where a registration lands; this says what a record there looks
|
|
-- like. A registry whose Gen 2 records differ carries gen2Fields / gen2Keys /
|
|
-- gen2Write beside the Gen 1 slots and Schemas.shapeFor folds them onto the
|
|
-- canonical names, which is what let the six shaped registries above be routed
|
|
-- at all: without it a Gold species would be judged against Red's `special`.
|
|
|
|
do
|
|
local spec = Schemas.REGISTRIES.pokemon
|
|
T.eq(Schemas.shapeFor("pokemon", spec, 1), spec,
|
|
"Gen 1 gets the catalog spec itself, not a copy")
|
|
local gen2 = Schemas.shapeFor("pokemon", spec, 2)
|
|
T.check(gen2 ~= spec, "Gen 2 gets a derived spec")
|
|
T.eq(Schemas.shapeFor("pokemon", gen2, 2), gen2,
|
|
"resolving a derived spec again is a no-op")
|
|
T.check(gen2.fields.levelMoves ~= nil and gen2.fields.level1Moves == nil,
|
|
"the Gen 2 species shape is folded onto `fields`")
|
|
T.eq(gen2.gen2Fields, nil, "and the gen2* keys are gone from the derived spec")
|
|
T.eq(gen2.target, "pokemon",
|
|
"a reshaped registry that is not rerouted keeps its path")
|
|
|
|
-- the split special stats, which is the difference that makes register
|
|
-- usable on Gold at all
|
|
local gold = {
|
|
id = "MODMON", name = "MODMON", dex = 252,
|
|
types = { "GRASS" },
|
|
baseStats = { hp = 45, attack = 49, defense = 49, speed = 45,
|
|
specialAttack = 65, specialDefense = 65 },
|
|
catchRate = 45, baseExp = 64, growthRate = "MEDIUM_SLOW",
|
|
levelMoves = { { level = 1, move = "FIX_TACKLE" } },
|
|
evolutions = {},
|
|
spriteFront = "a.png", spriteBack = "b.png", picSize = 5,
|
|
}
|
|
T.check(Schemas.check(spec, "pokemon", "MODMON", gold, "register", 2),
|
|
"a Gen 2 species record registers under Gen 2")
|
|
T.check(not Schemas.check(spec, "pokemon", "MODMON", gold, "register", 1),
|
|
"and the same record is not a Gen 1 species")
|
|
local _, err = Schemas.check(spec, "pokemon", "FIXMON_A",
|
|
{ baseStats = { special = 80 } }, "patch", 2)
|
|
T.check(err ~= nil and err:match("special"),
|
|
"a Gen 1 baseStats.special is rejected under Gen 2: " .. tostring(err))
|
|
end
|
|
|
|
do
|
|
-- trainers routes one level further in, into .classes, and battle_anims
|
|
-- CLEARS the Gen 1 write (there the ids are the subtables the Gen 1 write
|
|
-- would have routed into)
|
|
local trainers = Schemas.shapeFor("trainers", Schemas.REGISTRIES.trainers, 2)
|
|
T.eq(trainers.target, "gen2Trainers", "the derived spec carries the routed path")
|
|
T.check(trainers.write ~= nil and trainers.baseAt ~= nil
|
|
and trainers.baseIds ~= nil,
|
|
"trainers reaches into .classes through write/baseAt/baseIds")
|
|
local anims = Schemas.shapeFor("battle_anims", Schemas.REGISTRIES.battle_anims, 2)
|
|
T.eq(anims.write, nil, "gen2Write = false clears the Gen 1 write")
|
|
T.eq(anims.baseAt, nil, "and the Gen 1 baseAt with it")
|
|
T.check(anims.keys ~= nil and anims.value == nil,
|
|
"a Gen 2 shape described by keys clears the Gen 1 value slot")
|
|
end
|
|
|
|
-- a Gen 2 shape on a registry with no Gen 2 home would be dead code: nothing
|
|
-- ever validates against it, because the write is dropped before it is checked
|
|
for name, spec in pairs(Schemas.REGISTRIES) do
|
|
if Schemas.hasGen2Shape(spec) then
|
|
T.eq(Schemas.gatedFor(name, 2), false,
|
|
"a registry with a Gen 2 shape is not gated: " .. name)
|
|
end
|
|
end
|
|
|
|
-- every routing entry names a real registry, so a rename cannot leave a
|
|
-- stale row behind that silently stops gating anything
|
|
for name in pairs(Schemas.GEN2) do
|
|
T.check(Schemas.REGISTRIES[name] ~= nil,
|
|
"Schemas.GEN2 names a real registry: " .. name)
|
|
end
|
|
|
|
-- a routed path must not be some other registry's path: two registries
|
|
-- folding into one table would let the second one's ids overwrite the first's
|
|
do
|
|
local claimed = {}
|
|
for name, spec in pairs(Schemas.REGISTRIES) do
|
|
local path = Schemas.targetFor(name, spec, 2)
|
|
if path then
|
|
T.check(claimed[path] == nil or claimed[path] == name,
|
|
("two registries share one Gen 2 path (%s): %s and %s")
|
|
:format(path, tostring(claimed[path]), name))
|
|
claimed[path] = name
|
|
end
|
|
end
|
|
end
|
|
|
|
-- ------- 4. the shared names, raised from Gold's own call sites
|
|
--
|
|
-- The rule: when a Gen 2 call site lands for something Gen 1 already names,
|
|
-- it reuses the EXACT name, so one mod's subscription serves both games. The
|
|
-- catalog reads the names back out of the source (tests/modkit/catalog.lua
|
|
-- scans for Runtime.emit / Runtime.call), so each name below is held to
|
|
-- having BOTH a site inside a Gen 2 module and a site outside one. A
|
|
-- "gen2.world.stepped" would satisfy the first half and fail the second,
|
|
-- which is exactly the drift this gate exists to catch; so would quietly
|
|
-- deleting Gold's site while docs/mod-api-gen2-compat.md still promises it.
|
|
--
|
|
-- Payload parity cannot be checked here (the shapes come from a live Gold
|
|
-- boot, and this file is ROM-free); tests/engine/gate_hooks.lua and
|
|
-- gate_events.lua carry the per-payload cases, and the Gen 2 sites were
|
|
-- proved against the gold_* drivers.
|
|
|
|
local Catalog = T.catalog
|
|
|
|
-- Gold's modules live under a gen2/ directory, except the two that own the
|
|
-- boot and the extractor and carry the generation in their name
|
|
local function isGen2Site(path)
|
|
return path:match("gen2") ~= nil or path:match("Gen2") ~= nil
|
|
or path:match("Game2") ~= nil
|
|
end
|
|
|
|
local GEN2_EVENTS = {
|
|
-- overworld
|
|
"map.entered", "map.exited", "map.reloaded", "player.warped",
|
|
"world.stepped", "world.interacted", "world.npc_spawned",
|
|
"world.trainer_engaged", "world.blacked_out", "world.block_replaced",
|
|
"world.boulder_moved", "world.tod_changed", "world.object_toggled",
|
|
"flag.changed",
|
|
-- battle
|
|
"battle.started", "battle.ended", "battle.turn_started", "battle.turn_ended",
|
|
"battle.move_used", "battle.damage_dealt", "battle.fainted",
|
|
"battle.status_inflicted", "battle.battler_switched", "battle.ball_thrown",
|
|
"battle.exp_gained", "pokemon.level_up", "pokemon.move_learned",
|
|
-- the catch and the evolution themselves: pushCaught emits after the mon is
|
|
-- in the party or the box, Evolution.apply after the species swap, both
|
|
-- matching the Gen 1 payload keys
|
|
"pokemon.caught", "pokemon.evolved",
|
|
-- boot, save and the script VM
|
|
"game.ready", "save.created", "save.loaded", "save.loading", "save.writing",
|
|
"script.started", "script.ended",
|
|
-- The new-game speech. Gold's is a different scene (Elm, not Oak, and its
|
|
-- own src/ui/gen2/OakSpeech.lua), but it is the SAME moment -- the intro
|
|
-- asking the player for the answers a save is built from -- so it keeps Gen
|
|
-- 1's four names and payload keys rather than inventing "intro.elm_speech".
|
|
"intro.oak_speech.started", "intro.oak_speech.step",
|
|
"intro.oak_speech.answered", "intro.oak_speech.finished",
|
|
}
|
|
|
|
local GEN2_HOOKS = {
|
|
-- overworld
|
|
"warp.destination", "movement.collision", "movement.speed",
|
|
"encounter.roll", "encounter.species", "encounter.fishing",
|
|
"world.tod", "map.palette", "fieldmove.eligibility",
|
|
-- menus and the battle intro
|
|
"ui.start_menu.items", "ui.title_menu.items", "ui.options.rows",
|
|
"ui.party.submenu", "ui.naming.grid", "ui.pc.items", "ui.list_menu",
|
|
"transition.style",
|
|
-- battle
|
|
"battle.damage", "battle.crit", "battle.accuracy", "battle.turn_order",
|
|
"battle.enemy_action", "battle.run", "battle.exp_award", "exp.gain",
|
|
"catch.rate", "trainer.party",
|
|
-- one wrap cancels or forces an evolution in either game: Gold passes `data`
|
|
-- where Gen 1 passes `game`, and positions 2-4 (mon, row, trigger) match
|
|
"evolution.check",
|
|
-- save and the script VM
|
|
"save.write", "save.new_game", "script.command",
|
|
-- the intro's step list, wrapped before the first card draws. Same hook,
|
|
-- same (steps, speech) arguments and same "return the list" contract as
|
|
-- src/ui/OakSpeech.lua's, so one wrapper reorders either game's speech.
|
|
"intro.oak_speech.build",
|
|
-- Battle seams Gold raises from src/battle/gen2/Battle.lua and
|
|
-- src/ui/gen2/BattleState.lua. battle.low_health_alarm carries `data` on
|
|
-- Gold where Gen 1's vanilla link reads ctx.battle.data: Gold's battle
|
|
-- screen has no .data field, so the key is ADDED beside the Gen 1 ones
|
|
-- rather than the payload being reshaped (docs/mod-api-gen2-compat.md warns
|
|
-- that a Gen 1 mod reaching through ctx.battle.data instead of calling
|
|
-- nextFn gets nil there).
|
|
"battle.catch_exp", "battle.low_health_alarm", "battle.overlay",
|
|
-- One pic path resolver for both games: the Gen 1 site is the SHARED
|
|
-- src/pokemon/Sprites.lua and Gold's own battle screen calls the same hook
|
|
-- with the Gen 1 ctx keys plus `letter` and `shiny`, which Red has no
|
|
-- concept of.
|
|
"pokemon.sprite",
|
|
-- The player's own trainer pic, same story: the Runtime.call is the shared
|
|
-- src/pokemon/Sprites.lua and Gold's battle back pic, Hall of Fame and intro
|
|
-- resolve their own path into Sprites.playerPic with the Gen 1 ctx keys.
|
|
"player.sprite",
|
|
-- The frame itself, from src/core/Game2.lua, in the same places
|
|
-- src/core/Game.lua raises them: the logic tick before the pad is read, a
|
|
-- pointer with the touch overlay given first refusal, the palette zone list
|
|
-- handed to the present pass, the letterbox and the HUD rect.
|
|
"input.step", "input.pointer",
|
|
"render.zones", "render.compose", "render.letterbox", "render.hud",
|
|
}
|
|
|
|
local function assertShared(name, sites, kind)
|
|
local gen2, gen1 = 0, 0
|
|
for _, path in ipairs(sites) do
|
|
if isGen2Site(path) then gen2 = gen2 + 1 else gen1 = gen1 + 1 end
|
|
end
|
|
T.check(gen2 > 0, ("Gold raises the %s: %s"):format(kind, name))
|
|
T.check(gen1 > 0,
|
|
("the %s %s is shared, not a Gen 2 invention (no Gen 1 site)")
|
|
:format(kind, name))
|
|
end
|
|
|
|
for _, name in ipairs(GEN2_EVENTS) do
|
|
assertShared(name, Catalog.eventSites(name), "event")
|
|
end
|
|
for _, name in ipairs(GEN2_HOOKS) do
|
|
assertShared(name, Catalog.hookSites(name), "hook")
|
|
end
|
|
|
|
-- and the lists are COMPLETE, not a sample. Without this half the gate only
|
|
-- catches a seam being taken away; a Gen 2 site landing for a Gen 1 name and
|
|
-- never reaching docs/mod-api-gen2-compat.md is the other drift, and it is the
|
|
-- more likely one -- the doc is where an author looks to decide whether a
|
|
-- subscription serves both games, so an unlisted shared seam reads as absent.
|
|
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 gen2, gen1 = false, false
|
|
for _, path in ipairs(sites(name)) do
|
|
if isGen2Site(path) then gen2 = true else gen1 = true end
|
|
end
|
|
if gen2 and gen1 then
|
|
T.check(listed[name],
|
|
("%s %s has a site in both generations but is not in this gate's "
|
|
.. "list; add it here and to docs/mod-api-gen2-compat.md")
|
|
:format(kind, name))
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
assertListed(GEN2_EVENTS, Catalog.events(), Catalog.eventSites, "event")
|
|
assertListed(GEN2_HOOKS, Catalog.hooks(), Catalog.hookSites, "hook")
|
|
|
|
-- and nothing anywhere invents a generation-prefixed name. New-in-Gen-2
|
|
-- systems (held_item.trigger, egg.hatched) get plain names of their own;
|
|
-- "gen2." would be a namespace no Gen 1 mod could ever match.
|
|
for _, name in ipairs(Catalog.events()) do
|
|
T.check(name:sub(1, 5) ~= "gen2.", "no generation-prefixed event: " .. name)
|
|
end
|
|
for _, name in ipairs(Catalog.hooks()) do
|
|
T.check(name:sub(1, 5) ~= "gen2.", "no generation-prefixed hook: " .. name)
|
|
end
|
|
|
|
-- ------- 4b. the seams Gen 2 invents
|
|
--
|
|
-- The other half of the shared-name rule. Section 4 holds a name Gen 1
|
|
-- already has to keeping it; these are the systems Red does not have at all
|
|
-- (friendship, breeding, the Pokegear, the radio, Pokerus, the roamers, Kurt,
|
|
-- the Bug Contest, the Unown puzzle, mail, held items, shininess and gender),
|
|
-- so a NEW name is justified -- and the discipline is the same one from the
|
|
-- other side: a plain name, never a "gen2." namespace no Gen 1 mod could
|
|
-- match, so that when Red ever grows the system the name is already right.
|
|
--
|
|
-- Three things are asserted per seam, and each one has failed at some point in
|
|
-- a review of this programme:
|
|
--
|
|
-- 1. the site exists at all. docs/mod-api-gen2-compat.md promises these by
|
|
-- name, so a deleted emit is doc drift the moment it happens.
|
|
-- 2. every site is inside a Gen 2 module. If a Gen 1 site ever appears the
|
|
-- seam is no longer Gen 2-only and belongs in the shared lists above,
|
|
-- where BOTH halves are checked -- this is the tripwire for that move.
|
|
-- 3. the site is guarded by Runtime.wants / wantsHook for its own name, so a
|
|
-- mod-free boot allocates no payload table. Several of these sit in the
|
|
-- step loop (happiness.changed, roamer.moved) or in the damage path
|
|
-- (held_item.trigger, eight triggers a turn), where an unguarded emit is
|
|
-- a per-frame cost every player pays for a feature nobody enabled.
|
|
--
|
|
-- Payload keys are not checkable here (this file is ROM-free);
|
|
-- tests/engine/gen2_new_seams.lua drives each one through a live bus and
|
|
-- asserts the payload the call site documents.
|
|
|
|
local GEN2_ONLY_EVENTS = {
|
|
"happiness.changed", "breeding.egg_created", "egg.hatched",
|
|
"phone.call_received", "clock.day_changed", "pokerus.infected",
|
|
"roamer.moved", "roamer.encountered", "apricorn.converted",
|
|
"bug_contest.scored", "unown.unlocked", "radio.channel",
|
|
"mail.written", "mail.read",
|
|
-- The GS boot cinema, card by card. Red boots straight into its title
|
|
-- screen, so there is no Gen 1 moment for these to share a name with; they
|
|
-- are plain names rather than "gen2." ones so that the day Red grows a
|
|
-- cinema the name is already right. Each fires as its card comes UP, with
|
|
-- movie_ended the one card END worth a name of its own (it is where the
|
|
-- attract loop restarts).
|
|
"intro.boot.copyright", "intro.boot.gamefreak", "intro.boot.movie",
|
|
"intro.boot.movie_ended", "intro.boot.title",
|
|
}
|
|
|
|
local GEN2_ONLY_HOOKS = {
|
|
"held_item.trigger", "breeding.compatibility", "phone.contact_list",
|
|
"shiny.roll", "gender.roll",
|
|
}
|
|
|
|
local sourceCache = {}
|
|
local function sourceOf(path)
|
|
if sourceCache[path] == nil then
|
|
local handle = io.open(path, "r")
|
|
sourceCache[path] = handle and handle:read("*a") or false
|
|
if handle then handle:close() end
|
|
end
|
|
return sourceCache[path] or nil
|
|
end
|
|
|
|
local function assertGen2Only(name, sites, kind, guard)
|
|
T.check(#sites > 0, ("Gold raises the Gen 2-only %s: %s"):format(kind, name))
|
|
local guarded = false
|
|
for _, path in ipairs(sites) do
|
|
T.check(isGen2Site(path),
|
|
("a Gen 2-only %s is raised from a Gen 2 module (%s is not one): %s")
|
|
:format(kind, path, name))
|
|
local body = sourceOf(path)
|
|
if body and body:find(('%s("%s")'):format(guard, name), 1, true) then
|
|
guarded = true
|
|
end
|
|
end
|
|
T.check(guarded,
|
|
("the %s %s is guarded by %s, so a mod-free boot pays nothing")
|
|
:format(kind, name, guard))
|
|
end
|
|
|
|
for _, name in ipairs(GEN2_ONLY_EVENTS) do
|
|
assertGen2Only(name, Catalog.eventSites(name), "event", "Runtime.wants")
|
|
end
|
|
for _, name in ipairs(GEN2_ONLY_HOOKS) do
|
|
assertGen2Only(name, Catalog.hookSites(name), "hook", "Runtime.wantsHook")
|
|
end
|
|
|
|
-- complete both ways, like the shared lists: a seam raised ONLY from Gen 2
|
|
-- modules is by definition a Gen 2-only one, so if it is not listed above it
|
|
-- has skipped the guard check, the doc's payload table and gen2_new_seams.lua
|
|
-- all at once.
|
|
local function assertGen2OnlyListed(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 anyGen1 = false
|
|
for _, path in ipairs(sites(name)) do
|
|
if not isGen2Site(path) then anyGen1 = true end
|
|
end
|
|
if not anyGen1 then
|
|
T.check(listed[name],
|
|
("%s %s is raised from Gen 2 modules alone but is not listed as a "
|
|
.. "Gen 2-only seam; add it here and to "
|
|
.. "docs/mod-api-gen2-compat.md"):format(kind, name))
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
assertGen2OnlyListed(GEN2_ONLY_EVENTS, Catalog.events(), Catalog.eventSites,
|
|
"event")
|
|
assertGen2OnlyListed(GEN2_ONLY_HOOKS, Catalog.hooks(), Catalog.hookSites,
|
|
"hook")
|
|
|
|
-- ------- 5. the gate, through a real load
|
|
|
|
local GEN1_ONLY = {
|
|
["mods/fix_gen1_only/manifest.json"] = [[{
|
|
"id": "fix_gen1_only",
|
|
"name": "Fixture Gen 1 Only",
|
|
"version": "1.0.0",
|
|
"entry": "main.lua",
|
|
"api": 2
|
|
}]],
|
|
["mods/fix_gen1_only/main.lua"] = [[
|
|
local mod = ...
|
|
mod.content.pokemon:patch("FIXMON_A", { catchRate = 111 })
|
|
]],
|
|
}
|
|
|
|
local GEN2_READY = {
|
|
["mods/fix_gen2_ready/manifest.json"] = [[{
|
|
"id": "fix_gen2_ready",
|
|
"name": "Fixture Gen 2 Ready",
|
|
"version": "1.0.0",
|
|
"entry": "main.lua",
|
|
"api": 2,
|
|
"gen2compat": true
|
|
}]],
|
|
["mods/fix_gen2_ready/main.lua"] = [[
|
|
local mod = ...
|
|
-- one registry with a Gen 2 home, one without: the first applies in both
|
|
-- generations, the second applies in Gen 1 and reports in Gen 2.
|
|
-- `transitions` is the gated one because Gold draws its own battle intro
|
|
-- (src/ui/gen2/BattleTransition.lua) and never composes through the Gen 1
|
|
-- Renderer, so nothing on a Gold boot would ever read the merged record.
|
|
mod.content.pokemon:patch("FIXMON_A", { catchRate = 123 })
|
|
mod.content.transitions:register("FIXTURE_WIPE", { frames = 30 })
|
|
]],
|
|
}
|
|
|
|
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 statusOf(run, id)
|
|
for _, entry in ipairs(run.loader:status().available) do
|
|
if entry.id == id then return entry end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
-- Gen 1: both mods run, both patches land
|
|
do
|
|
local run = T.sdk.loadMods({ "mods/fix_gen1_only", "mods/fix_gen2_ready" }, {
|
|
fs = T.sdk.memfs(files(GEN1_ONLY, GEN2_READY)),
|
|
generation = 1,
|
|
})
|
|
T.eq(statusOf(run, "fix_gen1_only").state, "loaded",
|
|
"Gen 1: a mod with no gen2compat loads")
|
|
T.eq(statusOf(run, "fix_gen2_ready").state, "loaded",
|
|
"Gen 1: a gen2compat mod loads too")
|
|
T.eq(run.data.pokemon.FIXMON_A.catchRate, 123,
|
|
"Gen 1: the later mod's patch merged")
|
|
T.check(run.data.transitions ~= nil
|
|
and run.data.transitions.FIXTURE_WIPE ~= nil,
|
|
"Gen 1: transitions merged")
|
|
run.release()
|
|
end
|
|
|
|
-- Gen 2: the undeclared mod is skipped whole, the declared one runs
|
|
do
|
|
local run = T.sdk.loadMods({ "mods/fix_gen1_only", "mods/fix_gen2_ready" }, {
|
|
fs = T.sdk.memfs(files(GEN1_ONLY, GEN2_READY)),
|
|
generation = 2,
|
|
})
|
|
|
|
local skipped = statusOf(run, "fix_gen1_only")
|
|
T.eq(skipped.state, "wrong_generation",
|
|
"Gen 2: a mod with no gen2compat is not loaded")
|
|
T.check(skipped.note ~= nil and skipped.note:match("gen2compat"),
|
|
"Gen 2: the skip says why")
|
|
T.eq(skipped.error, nil,
|
|
"Gen 2: a skip is not reported as a failure")
|
|
T.eq(skipped.enabled, true,
|
|
"Gen 2: the player's enable flag is untouched by the skip")
|
|
|
|
T.eq(statusOf(run, "fix_gen2_ready").state, "loaded",
|
|
"Gen 2: the declared mod loads")
|
|
|
|
-- the skipped mod's registration must leave no trace: 111 would mean it ran
|
|
T.eq(run.data.pokemon.FIXMON_A.catchRate, 123,
|
|
"Gen 2: only the declared mod's patch merged")
|
|
|
|
-- a gated registry takes the write, drops it, and says so
|
|
T.check(run.data.transitions == nil
|
|
or run.data.transitions.FIXTURE_WIPE == nil,
|
|
"Gen 2: a gated registry merges nothing")
|
|
local told = false
|
|
for _, message in ipairs(run.errors) do
|
|
if message:match("transitions") and message:match("Gen 2") then told = true end
|
|
end
|
|
T.check(told, "Gen 2: the dropped registration is reported, not silent")
|
|
run.release()
|
|
end
|
|
|
|
-- The drop is worded from the loader's own generation, because the gating runs
|
|
-- both ways: a Red boot rejecting a write to a Gen 2-only registry must not
|
|
-- claim the registry has "no Gen 2 target". The registry name and the drop
|
|
-- were always right; the sentence was one-directional.
|
|
do
|
|
local MIRROR = {
|
|
["mods/fix_gen1_drop/manifest.json"] = [[{
|
|
"id": "fix_gen1_drop",
|
|
"name": "Fixture Gen 1 Drop",
|
|
"version": "1.0.0",
|
|
"entry": "main.lua",
|
|
"api": 2
|
|
}]],
|
|
["mods/fix_gen1_drop/main.lua"] = [[
|
|
local mod = ...
|
|
mod.content.decorations:patch("deco:2", { name = "COZY" })
|
|
]],
|
|
}
|
|
local run = T.sdk.loadMods({ "mods/fix_gen1_drop" },
|
|
{ fs = T.sdk.memfs(MIRROR), generation = 1 })
|
|
local told
|
|
for _, message in ipairs(run.errors) do
|
|
if message:match("decorations") then told = message end
|
|
end
|
|
T.check(told ~= nil,
|
|
"Gen 1: a write to a Gen 2-only registry is reported")
|
|
T.check(told and told:match("Gen 1"),
|
|
"Gen 1: and the report names Gen 1, not Gen 2: " .. tostring(told))
|
|
T.eq(run.data.gen2Decorations, nil, "Gen 1: and nothing merged")
|
|
run.release()
|
|
end
|
|
|
|
-- ------- 5b. the vanilla records at a routed path are GOLD's
|
|
--
|
|
-- Six of the routed registries are the battle rules, and there the registry is
|
|
-- not just a merge target: src/battle/gen2/Catching.lua:recordFor,
|
|
-- Battle.statusRecordFor / moveEffectRecordFor, Ai.layersFor,
|
|
-- Evolution.methodFor and src/core/gen2/ItemEffects.lua:recordFor all read the
|
|
-- merged table. So WHICH module seeds it is load bearing, and it is not the
|
|
-- one that seeds Red: the ids collide. src/mods/Builtins.lua swaps the
|
|
-- registrant per generation and this holds it to that -- seeding Red's
|
|
-- GREAT_BALL would leave Gold's x1.5 multiplier nil, which reads as a ball
|
|
-- that quietly stopped working.
|
|
do
|
|
local gen1 = T.sdk.loadNone({ generation = 1 })
|
|
local gen2 = T.sdk.loadNone({ generation = 2 })
|
|
|
|
local ball1 = gen1.loader.content.balls:get("GREAT_BALL")
|
|
local ball2 = gen2.loader.content.balls:get("GREAT_BALL")
|
|
T.check(ball1 ~= nil and ball1.hpFactor ~= nil and ball1.multiplier == nil,
|
|
"Gen 1 seeds Red's GREAT_BALL (an HP factor, no multiplier)")
|
|
T.check(ball2 ~= nil and ball2.multiplier == 1.5,
|
|
"Gen 2 seeds Gold's GREAT_BALL (the x1.5 the cart multiplies by)")
|
|
|
|
-- statuses are the clearest case of the shared NAME over different ids:
|
|
-- Red writes BRN into mon.status where Gold writes "burn"
|
|
T.check(gen1.loader.content.statuses:get("BRN") ~= nil,
|
|
"Gen 1 seeds Red's status ids")
|
|
T.check(gen2.loader.content.statuses:get("burn") ~= nil
|
|
and gen2.loader.content.statuses:get("BRN") == nil,
|
|
"Gen 2 seeds Gold's status ids and none of Red's")
|
|
|
|
-- Ai.layersFor walks the merged table for mod-registered scoring passes, so
|
|
-- Red's LAYER_1..LAYER_3 landing there would join Gold's ten
|
|
T.check(gen1.loader.content.ai_classes:get("LAYER_1") ~= nil,
|
|
"Gen 1 seeds Red's move-scoring layers")
|
|
T.check(gen2.loader.content.ai_classes:get("LAYER_1") == nil
|
|
and gen2.loader.content.ai_classes:get("SMART") ~= nil,
|
|
"Gen 2 seeds Gold's scoring passes instead")
|
|
|
|
-- and the Gen 2 VM's verb table is the mod verbs alone: a Gen 1 row-list
|
|
-- verb handed Gold's ctx would find no runner on it
|
|
T.check(gen1.loader.content.commands:get("show_text") ~= nil,
|
|
"Gen 1 seeds the row-list verbs")
|
|
T.eq(gen2.loader.content.commands:get("show_text"), nil,
|
|
"Gen 2 seeds none of them")
|
|
T.eq(gen2.data.commands, nil,
|
|
"and a mod-free Gold boot leaves data.commands absent entirely")
|
|
|
|
-- the seeded records land at the routed path, not the Gen 1 one
|
|
T.check(gen2.data.gen2Statuses ~= nil and gen2.data.gen2Statuses.burn ~= nil,
|
|
"the Gen 2 records merge into their Gen 2 path")
|
|
T.eq(gen2.data.statuses, nil,
|
|
"and nothing is written to the Gen 1 path a Gold boot never reads")
|
|
T.check(gen1.data.statuses ~= nil and gen1.data.statuses.BRN ~= nil,
|
|
"while Gen 1 is untouched by any of it")
|
|
|
|
gen1.release()
|
|
gen2.release()
|
|
end
|
|
|
|
-- A gated registry is an absent id space, not an empty one. Gold's species
|
|
-- carry a growthRate exactly as Red's do, so a patch that keeps one must not
|
|
-- be reported as referencing something that does not exist just because the
|
|
-- Gen 1 `growth_rates` namespace has no Gen 2 home. A ROUTED registry is the
|
|
-- opposite: `evolution_methods` has real ids on Gold now, so the same pass
|
|
-- resolves an evolution's method against them and a typo is caught.
|
|
local function refsFixture(body)
|
|
return {
|
|
["mods/fix_refs/manifest.json"] = [[{
|
|
"id": "fix_refs",
|
|
"name": "Fixture Refs",
|
|
"version": "1.0.0",
|
|
"entry": "main.lua",
|
|
"api": 2,
|
|
"gen2compat": true
|
|
}]],
|
|
["mods/fix_refs/main.lua"] = body,
|
|
}
|
|
end
|
|
|
|
-- The ROM-free fixture dataset is Gen 1 shaped, and Gold hangs its experience
|
|
-- curves off data.pokemon.growthRates (which src/mods/Builtins.lua's Gen 2
|
|
-- registrant seeds the growth_rates registry from). A generation-2 run over
|
|
-- unmodified fixtures therefore seeds no curves, and every fixture species'
|
|
-- growthRate reads as a dangling reference -- an artifact of the dataset, not
|
|
-- of the engine: on a real Gold boot the ids line up exactly (both sides say
|
|
-- GROWTH_MEDIUM_SLOW). Added per-run rather than to tests/fixture_data, whose
|
|
-- shape is Gen 1's and whose fingerprint is a committed golden.
|
|
local function gen2Fixtures()
|
|
local data = T.fixtures.fresh()
|
|
-- pokegold data/growth_rates.asm's MEDIUM_SLOW row, under the id the fixture
|
|
-- species reference
|
|
data.pokemon.growthRates = {
|
|
MEDIUM_SLOW = { numerator = 6, denominator = 5, squared = -15,
|
|
linear = 100, constant = 140 },
|
|
}
|
|
return data
|
|
end
|
|
|
|
local function danglingRefs(run)
|
|
local dangling = {}
|
|
for _, message in ipairs(run.errors) do
|
|
if message:match("unresolved reference") then
|
|
dangling[#dangling + 1] = message
|
|
end
|
|
end
|
|
return dangling
|
|
end
|
|
|
|
do
|
|
-- the Gen 2 evolution row shape: `into` rather than `species`, and Gold's
|
|
-- own EVOLVE_* method ids, which src/core/gen2/Evolution.lua seeds
|
|
local run = T.sdk.loadMods({ "mods/fix_refs" }, {
|
|
fs = T.sdk.memfs(refsFixture([[
|
|
local mod = ...
|
|
local base = mod.content.pokemon:get("FIXMON_A")
|
|
mod.content.pokemon:patch("FIXMON_A", {
|
|
catchRate = 90,
|
|
growthRate = base.growthRate,
|
|
evolutions = { { method = "EVOLVE_LEVEL", level = 16,
|
|
into = "FIXMON_B" } },
|
|
})
|
|
]])),
|
|
data = gen2Fixtures(),
|
|
generation = 2,
|
|
})
|
|
local dangling = danglingRefs(run)
|
|
T.eq(#dangling, 0,
|
|
"Gen 2: a record whose refs all resolve reports nothing ("
|
|
.. table.concat(dangling, "; ") .. ")")
|
|
T.eq(run.data.pokemon.FIXMON_A.catchRate, 90, "Gen 2: the patch still landed")
|
|
run.release()
|
|
end
|
|
|
|
do
|
|
local run = T.sdk.loadMods({ "mods/fix_refs" }, {
|
|
fs = T.sdk.memfs(refsFixture([[
|
|
local mod = ...
|
|
mod.content.pokemon:patch("FIXMON_A", {
|
|
evolutions = { { method = "EVOLVE_BY_VIBES", level = 16,
|
|
into = "FIXMON_B" } },
|
|
})
|
|
]])),
|
|
data = gen2Fixtures(),
|
|
generation = 2,
|
|
})
|
|
local dangling = danglingRefs(run)
|
|
T.eq(#dangling, 1,
|
|
"Gen 2: a routed registry HAS an id space, so a bad method is caught")
|
|
T.check(dangling[1] and dangling[1]:match("evolution_methods"),
|
|
"Gen 2: and the report names the registry it could not resolve against")
|
|
run.release()
|
|
end
|
|
|
|
-- the skip is contagious as a SKIP. A mod that DID claim gen2compat but sits
|
|
-- on one that did not is left out with the dependency's own wording, not
|
|
-- failed with "dependency X failed to load": neither mod has a bug and neither
|
|
-- belongs on the boot error list the player is shown.
|
|
do
|
|
local DEPENDENT = {
|
|
["mods/fix_gen2_dependent/manifest.json"] = [[{
|
|
"id": "fix_gen2_dependent",
|
|
"name": "Fixture Gen 2 Dependent",
|
|
"version": "1.0.0",
|
|
"entry": "main.lua",
|
|
"api": 2,
|
|
"gen2compat": true,
|
|
"dependencies": ["fix_gen1_only"]
|
|
}]],
|
|
["mods/fix_gen2_dependent/main.lua"] = [[
|
|
local mod = ...
|
|
mod.content.pokemon:patch("FIXMON_A", { catchRate = 222 })
|
|
]],
|
|
}
|
|
local run = T.sdk.loadMods({ "mods/fix_gen1_only", "mods/fix_gen2_dependent" }, {
|
|
fs = T.sdk.memfs(files(GEN1_ONLY, DEPENDENT)),
|
|
generation = 2,
|
|
})
|
|
local dependent = statusOf(run, "fix_gen2_dependent")
|
|
T.eq(dependent.state, "wrong_generation",
|
|
"Gen 2: a dependent of a gate-skipped mod is skipped, not failed")
|
|
T.eq(dependent.error, nil,
|
|
"Gen 2: the dependent's skip is not reported as a failure")
|
|
T.check(dependent.note ~= nil and dependent.note:match("gen2compat"),
|
|
"Gen 2: the dependent's skip names the dependency's reason")
|
|
T.eq(#run.errors, 0, "Gen 2: neither mod contributes a boot error")
|
|
run.release()
|
|
end
|
|
|
|
-- the player's override: options.modsGen2 forces a mod past the gate, because
|
|
-- the manifest flag is the AUTHOR's claim and a mod written before the field
|
|
-- existed can never carry one
|
|
do
|
|
local fs = T.sdk.memfs(files(GEN1_ONLY))
|
|
fs.write("options.lua", require("src.core.SaveSerializer").encode({
|
|
mods = {}, modsGen2 = { fix_gen1_only = true },
|
|
}))
|
|
local run = T.sdk.loadMods({ "mods/fix_gen1_only" },
|
|
{ fs = fs, generation = 2 })
|
|
local forced = statusOf(run, "fix_gen1_only")
|
|
T.eq(forced.state, "loaded", "Gen 2: the override loads an unclaimed mod")
|
|
T.eq(forced.gen2Forced, true, "Gen 2: the manager sees the override")
|
|
T.check(forced.note ~= nil and forced.note:match("not verified"),
|
|
"Gen 2: a forced mod still says its author never claimed this game")
|
|
T.eq(run.data.pokemon.FIXMON_A.catchRate, 111,
|
|
"Gen 2: the forced mod's patch merged")
|
|
run.release()
|
|
end
|
|
|
|
-- a skipped mod is skipped before validation, so a Gen 1 mod with a broken
|
|
-- manifest does not ALSO shout about its entry file on a Gold boot
|
|
do
|
|
local BROKEN = {
|
|
["mods/fix_broken/manifest.json"] = [[{
|
|
"id": "fix_broken",
|
|
"name": "Fixture Broken",
|
|
"version": "1.0.0",
|
|
"entry": "missing.lua",
|
|
"api": 2
|
|
}]],
|
|
}
|
|
local run = T.sdk.loadMods({ "mods/fix_broken" },
|
|
{ fs = T.sdk.memfs(BROKEN), generation = 2 })
|
|
T.eq(statusOf(run, "fix_broken").state, "wrong_generation",
|
|
"Gen 2: the generation gate runs before entry-file validation")
|
|
T.eq(#run.errors, 0, "Gen 2: a skipped mod contributes no boot errors")
|
|
run.release()
|
|
end
|
|
|
|
-- ------- 6. StateStack:clear, which is what Gold's boot cinema hands off
|
|
-- through now that it runs the engine stack
|
|
|
|
do
|
|
local stack = setmetatable({}, { __index = StateStack })
|
|
stack:init()
|
|
local order = {}
|
|
local function state(name)
|
|
return { isOpaque = true, exit = function() order[#order + 1] = name end }
|
|
end
|
|
stack:push(state("a"))
|
|
stack:push(state("b"))
|
|
stack:push(state("c"))
|
|
stack:clear()
|
|
T.eq(stack:top(), nil, "clear empties the stack")
|
|
T.eq(table.concat(order, ","), "c,b,a", "clear unwinds top-first")
|
|
end
|
|
|
|
-- Without this the file printed its FAILs and exited 0, so the runner marked
|
|
-- the gate "ok" while it was red -- a gate that cannot fail is not a gate.
|
|
T.finish("gate_gen2_mod_api")
|