Table of Contents
- Preparing your mod for Gen 2
- What actually breaks, and why
- Step 1: run the checker before you change anything
- Step 2: declare which games the mod is for
- Step 3: prefer the API over the modules
- Step 4: the adapter, module by module
- The patterns no adapter can fix
- 1. A hardcoded version allow-list
- 2. String-matching a screen id
- 3. debug.setupvalue on an engine local
- 4. Capturing state off src.core.Game at file scope
- 5. Monkey-patching a class, and the two ways it goes wrong
- A worked migration
- Testing
- What this guide does not promise
- See also
Preparing your mod for Gen 2
You have a mod that works on Red, Blue or Yellow, and you want it to work on Gold. This is the migration guide: what breaks, what the engine papers over for you, what it refuses to paper over, and the order to do the work in.
What actually breaks, and why
Gold is not a skin over the Gen 1 engine. It is a second engine living beside
the first one: src/core/Game2.lua owns the boot, src/world/gen2/World.lua
is the overworld, src/battle/gen2/Battle.lua is the battle, and
src/script/gen2/Vm.lua runs the cart's own bytecode instead of a Lua row
list. A Gold boot never loads src/core/Game.lua,
src/world/OverworldController.lua or src/battle/BattleState.lua at all.
The mod API on top is deliberately one API: the same registry names, the same
hook names, the same event names, the same mod.* facade. A mod that stays on
that surface mostly moves across unchanged. What does not move is everything
underneath it.
The failure that motivated all of this is quiet, which is what makes it worth a
whole page. A mod with engine_internals writes
local Game = require("src.core.Game") and patches a method on it. Under Gold
that require used to succeed: the file is on disk, require finds it, hands
back a perfectly good module table, and your patch lands on it. Nothing ever
instantiates that table, so the patch runs zero times and the only symptom is
that your mod does nothing. No error, no warning, no crash to bisect.
Two things fixed that. First, a mod is not loaded on a Gold boot unless it says it is for Gold, so the default outcome is "not running" rather than "running wrong". Second, when it does say so, a require made from your own file is answered by an adapter that presents the Gen 1 API over Gold's internals, and a member the adapter cannot honestly back reads nil instead of reading plausibly-wrong.
Step 1: run the checker before you change anything
modkit gen2check reads your manifest, statically scans every .lua the
package carries, and cross-references what it finds against the adapter's own
coverage table. Run it first: it tells you the size of the job in a few seconds.
python3 tools/modkit.py gen2check <id-or-path> [<id-or-path>...]
Real output, against a follower mod written for Yellow:
-- PokePCFollowers_VoxelMerge: api 1, profile content, no games declared, permissions engine_internals, 0 dependencies, game_version unset
MK400 ERROR manifest.json: no Gen 2 game in "games" (and no gen2compat), so a Gen 2 boot skips this mod; the rest of this report is what it would hit once it claims one
MK404 ERROR main.lua:575: BattleState.newWild has no Gen 2 backing: Gold has no factory that returns an unpushed battle, and World:startBattle constructs and pushes in one call. A mod that wraps newWild to rewrite the species must be pointed at the encounter.species hook, which Gold raises with the same name and shape; this reads nil
MK409 WARN main.lua:13: allow-lists a Gen 1 version string, which excludes this mod from a Gen 2 game by construction; test for the capability the code needs instead of the version
modkit: unresolved: 1 site: requires whose result is neither bound to a name nor indexed here, so where the module goes is not followed (main.lua:221)
modkit: src.world.PikachuFollower.onMapEntered closes over 'shouldSpawn' on a Gen 2 boot, so the upvalue surgery at main.lua:325 lands as it does on Gen 1
FAIL PokePCFollowers_VoxelMerge on gen 2: will not work (3 errors, 3 warnings)
Three kinds of line, and the difference matters:
MK4xx ERROR/MK4xx WARNare findings with a file and a line. Errors set the exit code; warnings do not unless you pass--strict.modkit:notes are things the tool derived rather than found, or could not decide at all. They never change the exit code.- The verdict:
will load,will load but degrade, orwill not work.
The rule ladder:
| rule | what it means |
|---|---|
MK400 |
the manifest claims no Gen 2 game, so a Gen 2 boot skips the mod |
MK401 |
a dependency claims no Gen 2 game, which takes you down with it |
MK402 |
you require a Gen 1-only module the adapter does not serve |
MK403 |
a Gen 2 boot runs a gen2/ sibling of the module instead |
MK404 |
a member you touch has no Gen 2 backing (the adapter's own reason is quoted) |
MK405 |
a member you touch degrades and says so once |
MK406 |
the signature moved under an alias |
MK407 |
debug upvalue surgery the Gen 2 arm cannot take |
MK408 |
upvalue surgery the scan could not resolve either way |
MK409 |
a version allow-list, or a Gen 1 screen id |
MK410 |
the entry chunk reads a member of a game that is not up yet |
Flags: --strict promotes warnings to failures, --notes prints the adapter's
note for every backed member you touch (worth reading once per mod, because
several backed members are backed with a caveat), --json emits one document
for the whole batch, --quiet drops everything except the findings, so a clean
mod prints nothing at all and the exit code is the whole answer. Exit code is 0
clean, 1 on a fatal finding, 2 on usage.
Name several mods in one invocation and they are read as one install set, so a
mod and its dependencies can answer each other's MK401.
What the checker cannot see, and says so
It is a static scan, not a run. It follows a require made through your own
tryRequire-style wrapper, local ok, M = pcall(require, "..."), an inline
require("src.world.Map").waterTiles(...), a bracket index M["member"] and a
local hop local F = M.
What it cannot follow it names instead of ignoring. Every unfollowed reach
comes back as an unresolved: note carrying a file and a line: a require name
built at runtime or concatenated, a require whose result is neither bound nor
indexed on the spot, a multiple assignment whose value it cannot pair to a name,
an engine module indexed with a computed key, rawget or rawset on a bound
module (that goes to the table the require shim hands back, so on Gold it
reaches the adapter facade and not the module behind it), an engine module read
as a value rather than indexed, and a debug upvalue call whose target it could
not tie to a module.
The practical consequence is worth stating plainly: an empty finding list plus
no unresolved: notes means the scan followed everything it saw. An empty
finding list on its own does not.
It is also silent about any member the coverage table does not record. A clean
gen2check means "nothing known-broken was found", not "this works". Boot it.
Step 2: declare which games the mod is for
Nothing moves on disk. A mod is installed once, into mods/<id>/, and that one
directory serves every game. There is no mods/gen1/, no mods/gen2/, and no
per-generation copy: targeting is something the manifest declares, not
something the filesystem encodes.
{
"id": "my_mod",
"name": "My Mod",
"version": "1.0.0",
"entry": "main.lua",
"api": 2,
"games": ["gen1", "gen2"]
}
games is an optional array. Each entry is one of:
| token | means |
|---|---|
"red", "blue", "yellow", "gold" |
that one game |
"gen1", "gen2" |
every game of that generation (case-insensitive; "gen 2" also parses) |
"all" |
every game this engine has |
The scaffold writes the key for you:
python3 tools/modkit.py scaffold my_mod --games gen1,gen2
Omitting games keeps the old meaning exactly. No games key means Gen 1
only, plus Gen 2 if the legacy "gen2compat": true flag is set. Every manifest
written before the key existed means precisely what it always meant.
gen2compat is still accepted and is purely additive: it adds the Gen 2 games
to whatever games says, so no manifest can lose a game it already ran on.
An unknown token warns and is dropped under api 1 and refuses the manifest
under api 2. A games array naming no game this engine knows falls back to
the default rather than orphaning the mod. A non-array games is a hard error.
What you are claiming
Adding a game to games is you saying I have run this there. It is not a
request for best-effort support and the loader does not treat it as one: a mod
that claims a game is loaded on that boot in full, with its registrations, its
subscriptions and its entry chunk, exactly like a mod written for it. If it is
half-working, the player sees a broken mod, not a partially-supported one. That
is the whole reason the key exists rather than being inferred.
Every token is enforced, per game. "games": ["blue"] really does not load
on Red. "games": ["gold"] alone does not load on Red either. If you want a mod
everywhere, say so: ["gen1", "gen2"] or ["all"].
Dependencies are contagious. A mod whose hard dependency does not run here
is left out too, carrying the dependency's own wording (depends on X, which does not run here (For Blue, not Red)). It is reported as a skip rather than a
failure and neither mod lands on the boot error list, but the mod does not run.
Every hard dependency in the chain has to cover the same games.
The player can overrule you, in one direction only. The in-game mod manager
offers TRY HERE ANYWAY for any mod that does not claim this game. The choice
is per game, so forcing a mod onto Red does not force it onto Gold. A forced mod
loads normally and keeps a note saying its author never verified it here; the
launcher shows it as Forced onto Gold by you (untested).
What the player sees
All three surfaces read the same derivation, the two UIs and the loader, so they
cannot disagree about your mod. The launcher's mod panel carries a Show for:
chip row and a per-mod tag (GEN 1, GEN 1+2, RED/GOLD, BLUE), greyed out
with Not for this game when the mod does not run on the selected game. The
in-game manager shows the same as ENABLED (NOT THIS GAME) with the skipped
glyph. The launcher's dependency verdict asks the same question of your
dependencies: Needs <id> (not for Gold) rather than Ready.
Step 3: prefer the API over the modules
Before doing any adapter work, check whether you need the modules at all. In new
code, take the live game from mod.game and the world from mod.world. Both
resolve per generation inside the loader: mod.game is the Gen 1 singleton or
the Game2 instance, read on every touch rather than cached; mod.world is
the Gen 1 or Gen 2 WorldAPI behind one method set. Neither needs
engine_internals. The game.ready payload and every ui.* hook's first
argument carry the same live game.
Anything you can express as a registry write, a hook or an event subscription is generation-agnostic already and needs nothing from this page. The adapter exists for code written before Gold did, and for the small number of things the API genuinely does not reach.
Step 4: the adapter, module by module
On a Gen 2 boot with mods present, require is interposed, and a require made
from a mod's own chunk for one of fifteen Gen 1 names is answered by
src/mods/Gen2Compat.lua. Engine code is unaffected: the shim compares the
caller's chunk against the engine tree, so engine files still get the real Gen 1
module on both generations. This is not a dev-mode feature; it installs on any
Gold boot that has mods.
| the name you require | kind | what you get | backed / warned / absent |
|---|---|---|---|
src.core.Game |
facade | a live proxy onto the Game2 instance |
70 / 9 / 12 |
src.world.OverworldController |
facade | over src/world/gen2/World.lua |
56 / 5 / 68 |
src.world.Map |
alias | src/world/gen2/Map.lua |
28 / 2 / 9 |
src.world.NPC |
alias | src/world/gen2/Npc.lua |
27 / 0 / 1 |
src.pokemon.Boxes |
facade | over src/core/gen2/Boxes.lua |
22 / 0 / 0 |
src.battle.BattleState |
facade | over src/ui/gen2/BattleState.lua |
16 / 2 / 39 |
src.ui.PartyMenu |
facade | over src/ui/gen2/PartyMenu.lua |
15 / 2 / 16 |
src.world.WorldAPI |
alias | src/world/gen2/WorldAPI.lua |
12 / 2 / 0 |
src.world.PikachuFollower |
alias | src/world/gen2/Follower.lua |
10 / 0 / 11 |
src.script.ScriptRunner |
facade | over src/script/gen2/Vm.lua |
10 / 7 / 1 |
src.ui.OptionsMenu |
facade | over src/ui/gen2/OptionsMenu.lua |
8 / 0 / 1 |
src.world.FieldDefaults |
facade | the playerSprites answer and named refusals |
5 / 2 / 3 |
src.world.Collision |
facade | DELTA / target / occupied / canMove |
4 / 1 / 0 |
src.ui.StartMenu |
facade | over src/ui/gen2/StartMenu.lua |
4 / 0 / 0 |
src.ui.BoxMenu |
alias | src/ui/gen2/PcMenu.lua |
1 / 0 / 0 |
Alias means the adapter is the Gen 2 module. Your monkey-patch, your
rawset sentinel and your == idempotency check all land on the table Gold
actually runs, and getmetatable(npc) == NPC is true. Five names are aliases
because nothing less would work.
Note that src.ui.BoxMenu points at src/ui/gen2/PcMenu.lua, not at
src/ui/gen2/BoxMenu.lua. Gen 1's BoxMenu is Bill's PC top menu, whose Gold
counterpart is PcMenu; Gold's BoxMenu is the withdraw/deposit list that
Gen 1 builds inline.
Facade means a translating wrapper. .overworld resolves Game2.world,
writeOptions resolves Game2:persistOptions, game.data.sprites resolves
data.gen2Sprites, and NPC.new(data, mapId, objDef) is sniffed apart from
NPC.new(mapId, objDef, spriteDef) with the movement vocabulary translated
alongside it.
The four UI facades (PartyMenu, StartMenu, OptionsMenu, BattleState) are
write-through: reads fall to the Gen 2 class and writes go to the Gen 2
class, so PartyMenu.update = wrapper patches the live class Gold pushes.
Your write also reads back as your own value, so rawequal holds and an
idempotency check works. That is what makes the ordinary capture-and-chain idiom
safe: a wrapper calling the value it captured reaches Gold's real constructor
rather than re-entering the facade's own override. Writing nil clears the
member instead of re-exposing the override underneath.
The src.world.OverworldController facade sits over the live World, not over
a class, so seven of its fields (map, player, npcs, entities, ghosts,
npcPool, camera) read and write through to the running world: Gen 1's
module is the singleton, so a write has to land somewhere real. A write made
before a world exists is dropped with a warning rather than shadowing the world
it would have applied to.
backed, warned, absent
The adapter publishes what it covers, and the checker consumes that same table rather than a copy. Exactly three statuses, and a member listed as both resolves to the weaker one:
backed: present, and it does the Gen 1 job on Gold. Read the note anyway where there is one. Several backed members are backed with a caveat (Boxes.COUNTis 14 on Gold and not 12;BattleState.sayignoressayAuto's delay because Gold's messages always auto-advance;Collision.DELTAis Gold's live table, so adding a key mutates Gold's own movement).warned: present, answers nil or degrades, and names itself once in the log with your mod attributed.OverworldController.neighborsis the shape of the category: Gold's rows are{ id, ox, oy, image }where Gen 1's are{ map = mapDef, ox, oy }, so the field warns and answers nil rather than handing back a list whosenb.mapis nil on every row.absent: deliberately not on the table. It reads nil, which is the honest failure.
"Absent" means not served, not wrong. Every one was left off for a stated
reason, and the reason is in the coverage note. BattleState.newWild is the
clearest case: Gold has no factory returning an unpushed battle, because
World:startBattle constructs and pushes in one call, so a newWild taking a
species and a level would be a lie about what Gold's battle screen is. The route
for what you were actually doing, rewriting the species of a wild encounter, is
the encounter.species hook, which Gold raises under the same name with the
same shape.
A member the table does not record is not a guarantee of anything, and the checker is silent about it too.
Reading the coverage yourself
The table is queryable, and it is the same query the checker makes:
local Gen2Compat = require("src.mods.Gen2Compat")
Gen2Compat.modules() -- the 15 served names, sorted
Gen2Compat.serves("src.world.Map") -- true
Gen2Compat.memberStatus("src.battle.BattleState", "newWild") -- "absent"
local c = Gen2Compat.coverage("src.world.Map")
-- { module, kind = "facade"|"alias", target, members = { [name] = status },
-- notes = { [name-or-topic] = "one line" } }
notes keys are documentation topics, not a member list: dotted paths
(save.money), field names (warpAt), hook names (hook ui.pc.items) and bare
topics (identity, iteration, rawset) all appear there. members is the
authoritative set.
The patterns no adapter can fix
Five shapes come up in nearly every real Gen 1 mod, and none can be fixed on the engine side without lying to you. Each has a route that works on both generations.
1. A hardcoded version allow-list
local v = GameVersion.get()
if v ~= "red" and v ~= "blue" and v ~= "yellow" then return false end
This excludes you from Gold by construction, and it does so after everything
else in your mod has been made to work, which produces the most confusing
possible outcome: the adapter resolves, your patches land, and the feature still
never appears. MK409 catches it.
Instead, test for the thing the branch actually depends on:
local Follower = require("src.world.PikachuFollower")
if Follower.setShouldSpawn then ... end -- present on Gold, absent on Gen 1
Version tests stay legitimate for genuinely per-cart content, which is what Yellow's starter rename is. They are never right as a gate on a whole feature.
2. String-matching a screen id
if id == "BoxMenu" then ... end
Gold's builtin screens are registered under Gen2-prefixed ids, so this matches
nothing there. Screens.GEN2_IDS is the full list, 51 ids.
Instead, either match both ids or take the seam the screen offers. Most
screens a mod wants to decorate raise a hook whose name is shared across both
generations (ui.start_menu.items, ui.options.rows, ui.party.submenu,
ui.pc.items, ui.naming.grid, ui.list_menu), and a hook subscription needs
no id at all. Where you must key off the id:
local BOX_IDS = { BoxMenu = true, Gen2PcMenu = true }
if BOX_IDS[id] then ... end
Watch the pairing. ui.pc.items has the same name on both sides but a different
menu behind it: Gen 1 raises it over the WHICH-PC list, Gold over Bill's PC's
own rows. And Gen 1's BoxMenu pairs with Gen2PcMenu, not Gen2BoxMenu.
3. debug.setupvalue on an engine local
local idx = findUpvalue(PikachuFollower.update, "shouldSpawn")
debug.setupvalue(PikachuFollower.update, idx, myPredicate)
This only ever worked because the Gen 1 file happened to hold that predicate in
a file-local of that name. Nothing about the engine promises it. Today the Gen 2
follower declares local shouldSpawn for exactly this reason, so follower mods
reaching for it work unchanged on Gold. That is a deliberate courtesy, not a
contract.
Instead, use the named seam when there is one, and fall back only when there is not:
if Follower.setShouldSpawn then
Follower.setShouldSpawn(myPredicate) -- Gen 2
else
patchUpvalue(Follower.update, "shouldSpawn", myPredicate) -- Gen 1 today
end
setShouldSpawn writes the same cell debug.setupvalue reaches, so the two
cannot disagree. The presence test is doing real work: Gen 1's
PikachuFollower has no setShouldSpawn, so this is not a rename you can apply
blindly. Note also that the predicate is called (game, world) on Gold where
Gen 1 passes (game, ow): the same object under a different name, so a
predicate reading ow.player or ow.map is unchanged.
4. Capturing state off src.core.Game at file scope
local Game = require("src.core.Game")
local save = Game.save -- nil forever
local party = Game.save.party -- error at load
The module require itself is fine and is meant to be: the Gen 2 src.core.Game
is a proxy that reads the live Game2 instance on every touch, precisely so a
mod capturing it at file scope keeps working once a save and a world exist. What
does not survive is capturing a field off it at file scope, which snapshots
nil. MK410 catches it.
local Game = require("src.core.Game")
mod.events:on("game.ready", function(ev)
local game = ev.game -- the real Game2 instance
local party = Game.save.party -- read now, not at file scope
end)
Three further properties of the proxy that a Gen 1 mod can trip over:
- Identity. The proxy can never compare equal to the
Game2instance thegame.readypayload carries. Lua 5.1 fires__eqonly when both operands share a metatable, soGame == ev.gameis false on Gold. Do not use it as an idempotency check. - Iteration.
pairs,nextandrawgetsee an empty table, because the proxy holds nothing of its own. Enumerate thegame.readypayload instead. rawset.rawset(Game, k, v)lands on the proxy, reads back correctly through the same facade, and is completely invisible to the engine. That read-back is what hides it. Use a plain assignment, which writes through.
The save layout moved too, and those fields are absent rather than aliased so a
wrong read is loud rather than silent: save.money is save.player.money,
save.player.map / .x / .y / .facing are save.position.*, and
save.player.rival is save.rival.name.
5. Monkey-patching a class, and the two ways it goes wrong
Patching a shared class method is supported, which is worth stating plainly because it is the thing most authors expect to have to rewrite:
local PartyMenu = require("src.ui.PartyMenu")
local origUpdate = PartyMenu.update
function PartyMenu.update(self, dt) ... return origUpdate(self, dt) end
Two variants do not work, and neither can be made to.
Patching a member the Gen 2 class does not have. The write succeeds, reads
back as your own function, and nothing ever calls it. BattleState.newWild = wrapper is the canonical case. This is the one place the read-back works
against you, which is why MK404 reports the write site separately from the
read site.
Patching a field on a live instance. menu.onSwitch = fn writes a field Gen
2 never reads: Gold takes it as onChoose at construction. Same for
menu.swapFrom (renamed switchFrom) and for StartMenu's tx / ty / tw
/ th / anchor / maxVisible, which do not exist on Gold at all because the
box is fixed. Pass what you need to .new instead:
PartyMenu.new(game, { onSwitch = f }) with no battle, pickOnly or
forceSwitch opens the plain list and calls onSwitch(mon, menu) on A, which
is the Gen 1 behavior the facade reproduces.
A close relative worth calling out because it errors rather than no-ops:
map.warpAt is a name collision, not a rename. Gen 1's is a table keyed by
cell; Gold's Map:warpAt is a method of the same name. map.warpAt[cell] and
pairs(map.warpAt) both raise, which is loud but points at your mod. Enumerate
map.warps, which Gold carries as an ordered array.
A worked migration
A follower pack written for Red/Blue/Yellow. gen2check reports MK400 on the
manifest, MK404 twice on BattleState.newWild and MK409 on a version
allow-list, plus a note confirming its shouldSpawn surgery lands.
Before. Three separate problems in about twenty lines.
local BattleState = require("src.battle.BattleState")
local PikachuFollower = require("src.world.PikachuFollower")
local GameVersion = require("src.core.GameVersion")
return function(mod)
-- (1) rewrite the starter encounter's species
local origNewWild = BattleState.newWild
BattleState.newWild = function(game, species, level, ...)
if species == "PIKACHU" and level == 5 then species = "CHARMANDER" end
return origNewWild(game, species, level, ...)
end
-- (2) decide whether a follower spawns
local newShouldSpawn = function(game, ow)
local v = GameVersion.get()
if v ~= "red" and v ~= "blue" and v ~= "yellow" then return false end
return packSize(game) > 0
end
-- (3) install it
patchUpvalue(PikachuFollower.update, "shouldSpawn", newShouldSpawn)
patchUpvalue(PikachuFollower.onMapEntered, "shouldSpawn", newShouldSpawn)
end
On Gold: (1) assigns onto a name nothing reads, so the species rewrite never happens. (2) returns false for every Gold boot, so no follower ever spawns. (3) actually works, and works on a predicate that has already decided to do nothing. Two silent failures and one correct mechanism pointed at them.
After. The manifest gains "games": ["gen1", "gen2"], and:
local PikachuFollower = require("src.world.PikachuFollower")
return function(mod)
-- (1) the species of a wild encounter is a hook on both generations
mod.hooks:wrap("encounter.species", function(next, enc, ctx)
local rolled = next(enc, ctx)
if rolled and rolled.species == "PIKACHU" and rolled.level == 5 then
rolled.species = "CHARMANDER"
end
return rolled
end)
-- (2) no cart check: whether there is a pack to walk is the whole question
local newShouldSpawn = function(game, ow)
return packSize(game) > 0
end
-- (3) the named seam where there is one, the upvalue where there is not
if PikachuFollower.setShouldSpawn then
PikachuFollower.setShouldSpawn(newShouldSpawn)
else
patchUpvalue(PikachuFollower.update, "shouldSpawn", newShouldSpawn)
patchUpvalue(PikachuFollower.onMapEntered, "shouldSpawn", newShouldSpawn)
end
end
gen2check now reports clean, and the mod is shorter than it was on Gen 1
alone. That is the usual shape of this work: two of the three fixes replace
engine surgery with an API that existed the whole time, and only the third needs
a generation branch.
The one change that is not a simplification is the hook's contract. A wrapper
takes (next, ...) and must call next with the arguments it was handed, where
the monkey-patch could do as it liked with them. encounter.species transforms a
rolled { species, level } and gets a ctx beside it: Gen 1 fills in mapId,
terrain and rng, and Gold adds daytime, environment, kind ("wild" /
"contest" / "script" / "sweet_scent"), tables and data. So the same
subscription serves both games, and a Gold-only refinement is a field test
rather than a second hook.
Testing
Headless, without a Gold cache. The SDK harness takes the generation directly, and everything after that is the production path:
local run = T.sdk.loadMod("mods/my_mod", { generation = 2 })
T.eq(run.mod and run.mod.state, "loaded",
"runs on gen 2: " .. tostring(run.mod and run.mod.skipReason))
T.eq(#run.errors, 0, "and loads with no boot errors")
run.release()
Assert the state, not just the error count. A gate skip is deliberately not
an error, so T.eq(#run.errors, 0) on its own passes for a mod that never ran a
line, which is the one result you were testing to rule out. run.mod.state is
"loaded" when the entry chunk ran and "wrong_generation" when the gate or the
dependency contagion took it. Keep the error assertion too: it catches a registry
with no Gen 2 home and a require the adapter does not serve.
On a real Gold boot. Nothing above substitutes for running it. The two channels are not the same:
- The log carries the adapter's own warnings, each attributed to the mod
holding the facade (
[my_mod] Game.renderer has no Gen 2 backing: ...). These do not appear in the manager. - The manager's error feed is a shorter list: a mod that failed validation, a duplicate id, a registry with no Gen 2 target, a cross-validation problem, and one adapter-adjacent case, a require for a Gen 1 module the adapter does not serve. A skipped mod is not on it, and neither is a degraded member.
So: read the log for coverage problems, and the manager for load problems.
POKEPORT_IDENTITY=<name> sandboxes the save directory if you want a clean
profile to test in, and POKEPORT_DEV=1 adds the console and F5 hot reload.
What this guide does not promise
- Coverage is partial and will stay partial. 15 Gen 1 modules are served out of a much larger engine, and within those 15 the coverage table records 288 backed members against 32 warned and 161 absent. The absent ones are not a backlog; most are absent because there is no honest Gen 2 answer, and each carries its reason. The counts move as the adapter learns something: a member that turns out to answer nil is demoted rather than left flattering the table.
- Absent is not broken, it is not-served. A nil read is the designed outcome. If you would rather have an error, test for the member before you use it.
- The checker is a static scan. It cannot follow a require built at runtime,
cannot tie every
debugcall to a module, and says nothing about a member the coverage table does not record. What it can do is admit each of those individually, with a file and a line, as anunresolved:note. A clean finding list with notes under it means "nothing known-broken was found in the part I could follow"; only a clean finding list with no notes means the scan followed everything. - A backed member can still surprise you.
backedmeans the adapter took responsibility for the Gen 1 call shape, not that Gold behaves identically. Rungen2check --notesonce and read the caveats on the members you touch. - The adapter is not a compatibility layer for new code. It exists so mods
written before Gold keep working. If you are writing something now,
mod.game,mod.world, the registries and the hooks mean the same thing in both games and need none of this.
See also
- Compatibility - API level, engine version, mod-vs-mod, and link play
- The modkit CLI - every subcommand, including
gen2check - Manifest - the full manifest key reference
- Hooks -
encounter.speciesand the sharedui.*seams
Start here
Concepts
Tutorials
- The ladder
- 01 Sprite and Text Tweak
- 02 Balance Patch
- 03 New Species
- 04 New Item and Ball
- 05 New Move
- 06 NPC and Dialogue
- 07 New Map
- 08 Quest
- 09 Custom Music
- 10 New Mechanic
- 11 Custom UI Screen
- 12 Mini Total Conversion
Reference
Guides
- Art Pipeline
- Audio Authoring
- Preparing Your Mod for Gen 2
- Translations
- Total Conversions
- Link Compatibility
- Publishing a Mod
- Docs Style Guide
Playing
Engine 1.0.0 · mod API 2 · repository · Discord
This project ships no ROM data. All game content is decoded on the player's machine from their own verified Pokemon Red ROM; mods ship recipes and original assets, never extracted content.