Files
gen1recomp/docs/mod-api-gen2-compat.md
bryanthaboi ae6cac89e1 G2 support
2026-08-11 11:53:30 -04:00

49 KiB

Mods and Gen 2 (Gold)

The mod API is one API across both generations. Hook names, event names, registry names and the mod.* facade are shared on purpose: a mod that runs on Red should be able to run on Gold without learning a second vocabulary.

What differs is how much of it Gold can actually serve, and that is why Gen 2 support is something a mod declares rather than something it inherits.

What you can rely on today

The short version, for an author deciding what to write:

  • Every registry name, hook name and event name means the same thing in both games. Nothing is prefixed, renamed or repurposed per generation. Where Gen 2 genuinely carries more, the record or the payload gains a field.
  • 40 of the 46 registries are available on Gold. 17 keep their Gen 1 target outright (commands, tokens, growth_rates, battle_sprite_scales and render_pipelines among them), 16 route to a Gen 2 table under the same name, 6 are Gen 2-only systems Red has no counterpart for, and migrations is a code registry with no data target in either game. The other 6 are gated, and are listed below with the consumer change each one still needs.
  • A registry with no home in a generation is reported, never silently merged. The write is taken, dropped, and named once per mod in the same error feed the mod manager shows -- in both directions, so a Red boot writing to decorations is told exactly as a Gold boot writing to map_scripts is.
  • 40 event names and 43 hook names have a call site in both generations, so one subscription serves both games. tests/engine/gate_gen2_mod_api.lua reads those names back out of the source and fails if a site is renamed or deleted on either side, and fails again if a new shared site appears without being listed here.
  • 24 further names are Gen 2-only (friendship, breeding, the Pokegear, the radio, Pokerus, the roamers, Kurt, the Bug Contest, the Unown puzzle, mail, held items, shininess, gender, and the five cards of the GS boot cinema). They are plain names, not a gen2. namespace, so if Red ever grows the system the name is already right.
  • Every Gen 2 seam is guarded by Runtime.wants / Runtime.wantsHook, so a boot with no mod subscribed allocates nothing at any of them.
  • A mod is loaded on Gold only if it says so. See gen2compat below.

src/mods/Schemas.lua is authoritative for routing; tests/engine/gate_gen2_mod_api.lua holds this document to it.

Declaring which games a mod is for

{
  "id": "my_mod",
  "name": "My Mod",
  "version": "1.0.0",
  "entry": "main.lua",
  "api": 2,
  "games": ["gen1", "gen2"]
}

games is an optional array of version ids ("red", "blue", "yellow", "gold"), generations ("gen1", "gen2", case-insensitive) or "all". src/mods/ModTargets.lua resolves the tokens off GameVersion.ORDER and GameVersion.generation, so nothing anywhere restates the game list. Manifest.validate stores the resolved, ORDER-sorted ids on manifest.games and derives manifest.gen2compat from them, which is the one field the loader's gate reads.

Nothing moves on disk for any of this. A mod is installed once, into mods/<id>/, and that directory serves every game: there is no mods/gen1/ and no per-generation copy. Targeting is declared, not filed.

"gen2compat": true is the legacy spelling and is still accepted. It is purely additive -- it adds the Gen 2 games to whatever games says -- so no shipped manifest can lose a game it already ran on. A manifest with neither key is Gen 1 only, which is exactly what it always meant. An unknown token warns and is dropped under api 1 and refuses the manifest under api 2; a games array that names no game this engine knows falls back to the default rather than orphaning the mod; a non-array games is a hard error.

Every token is enforced, per game. Loader:_gateGeneration gates on ModTargets.supports(manifest, version, generation), the same call both mod surfaces make, so "games": ["blue"] really does not load on Red and the loader's skip line is the launcher's line, For Blue, not Red. A manifest with no games and no gen2compat still covers every Gen 1 game, so nothing written before the key existed changes behavior.

On a Gold boot, a mod claiming no Gen 2 game is not loaded at all: no registrations, no subscriptions, no entry chunk. The manager still lists it, showing ENABLED (NOT THIS GAME) and the reason, and the player's enable flag is left alone so it comes straight back on Red.

Both mod surfaces derive what they show from ModTargets rather than from their own copy of the rule. The launcher's mod panel carries a Show for: game chip row and a per-mod tag (GEN 1, GEN 1+2, RED/GOLD), greyed with Not for this game and the detail For Gen 1, not Gold when the mod does not run on the selected game; the in-game manager shows the same verdict as ENABLED (NOT THIS GAME) plus an inert FOR GEN 1+2 row on the detail screen. The launcher asks the same question of a mod's dependencies: one whose hard dependency does not run on the selected game reads Needs <id> (not for Gold), matching the loader's contagious skip.

A separate overlay, options.modsByVersion[version][id], is where a per-game enable flag will live. It is a preview: SaveData.PER_VERSION_MODS is false, so SaveData.modScope answers nil for every caller and the launcher panel, the in-game manager and the loader all read and write the one shared options.mods flag. Nothing consults the overlay for enablement until that flips, which is deliberate: the overlay is plantable from an imported .g1rmodlist, and a reader scoped differently from the writers would show a mod set no boot would honour.

That is deliberate. Gold reimplements the battle engine, the overworld, the script VM and the save format, so a Gen 1 mod dropped into a Gold boot would find a small fraction of its call sites live. A mod that half-applies reads to a player as a broken mod. Not running is the honest state, and naming a Gen 2 game is the author saying "I have tested this there".

Adding a Gen 2 game does not opt out of anything on Gen 1, because games is a union: ["gen1", "gen2"] covers everything it covered before. What does change is that the gate now runs on a Gen 1 boot too, so a manifest that names only Gen 2 games no longer loads on Red, Blue or Yellow. Say ["all"] or list both generations if you want both.

Two riders. A hard dependency that does not run here takes the dependent down with it, as a skip rather than a failure and carrying the dependency's own wording (depends on X, which does not run here (For Blue, not Red)), so the whole chain has to cover the same games. And the claim is yours, not the last word: it is the manager's TRY HERE ANYWAY row that lets a player run a mod whose author never opted in, which is the only route for a mod written before the field existed. The override is per game -- options.modsGen2[id] is a { [version] = true } table, so forcing a mod onto Red does not force it onto Gold, and a legacy options.modsGen2[id] = true reads as "the Gen 2 games", the only set it could ever have affected. It applies on the next boot; a forced mod loads normally and keeps a note saying it was never verified here. Where the choice cannot be persisted the manager says COULD NOT SAVE instead of promising a restart.

If you are writing new code, still prefer the API: take the live game from mod.game (or the game.ready payload, or a ui.* hook's first argument) and the world from mod.world. Those are the names that mean the same thing in both games. What follows is for the mods that were written before Gold existed and reach past it.

Gen 1 module facades

A mod with engine_internals reaches engine modules by name, and under Gold those names used to resolve to Gen 1 modules nothing instantiates -- so the patch landed on dead code and the mod was inert with no symptom but silence.

On a Gen 2 boot, a require made from a mod's own chunk is answered by an adapter: the Gen 1 API, backed by Gen 2 internals. src/mods/Gen2Compat.lua is the table, src/mods/Loader.lua's require shim is where the swap happens, and tests/engine/gate_gen2_mod_facade.lua holds both to it. Engine code is not affected -- the shim only substitutes when the calling chunk is outside the engine tree, so src/render/PaletteFX.lua still gets the real Gen 1 module on both generations.

Fifteen names are served. alias means the adapter is the Gen 2 module, so a monkey-patch, a rawset sentinel and a getmetatable(x) == M check all land on the table Gold runs; facade means a translating wrapper over it.

the Gen 1 name a mod requires kind what it gets on Gold
src.core.Game facade a live proxy onto the Game2 instance
src.world.OverworldController facade over src/world/gen2/World.lua; World:step / :interact / :interactBody dispatch through it
src.world.Map alias src/world/gen2/Map.lua, grown Gen 1's statics and instance methods
src.world.NPC alias src/world/gen2/Npc.lua; NPC.new sniffs the Gen 1 argument order
src.pokemon.Boxes facade over src/core/gen2/Boxes.lua, plus Gen 1's COUNT / CAPACITY / ensure / active / deposit
src.battle.BattleState facade over src/ui/gen2/BattleState.lua, write-through
src.ui.PartyMenu facade over src/ui/gen2/PartyMenu.lua, write-through
src.world.WorldAPI alias src/world/gen2/WorldAPI.lua
src.world.PikachuFollower alias src/world/gen2/Follower.lua
src.script.ScriptRunner facade over src/script/gen2/Vm.lua
src.ui.OptionsMenu facade over src/ui/gen2/OptionsMenu.lua, write-through
src.world.FieldDefaults facade the playerSprites answer, and a named refusal for the rest
src.world.Collision facade DELTA / target / occupied / canMove
src.ui.StartMenu facade over src/ui/gen2/StartMenu.lua, write-through
src.ui.BoxMenu alias src/ui/gen2/PcMenu.lua

Two entries in that table are not the pairing they look like. src.ui.BoxMenu resolves to PcMenu, not to src/ui/gen2/BoxMenu.lua: Gen 1's BoxMenu is Bill's PC top menu, whose Gold counterpart is PcMenu, while Gold's BoxMenu is the withdraw/deposit list Gen 1 builds inline. And src.script.ScriptRunner is served narrowly rather than fully: scanLabels and validate forward verbatim, with the default verb lookup swapped to game.data.commands so a script of Gen 1 built-ins cannot validate clean and then run as nothing, while the lifecycle half is a thin handle onto the one world.vm with resume and update refused rather than double-driving it. The script.started / script.ended / script.command seams are the supported route and already work on Gold.

src.script.Commands and src.ui.OptionRows have no adapter and are the two names a require of which still lands in the boot error feed the manager shows, with the module named. Both load fine under Gold and both are traps: the first hands back 61 Gen 1 verbs none of which Gold can run, the second paints Red's four-box options chrome over Gold's single 18x16 one.

docs/preparing-your-mod-for-gen2.md is the migration guide for an author working through this, and python3 tools/modkit.py gen2check <id> reports a mod's own findings against the coverage table below.

Three rules the adapters keep, because a plausible wrong answer is worse than the module being missing:

  • Live, never a snapshot. A mod captures require("src.core.Game") at file scope, before a save or a world exists. The facade is a proxy that reads the live instance on every touch, so Game.save is nil during the entry chunk and correct forever after. It aliases the two names Gold spells differently (Game.overworld is Game2.world, Game.writeOptions is Game2:persistOptions) and the one data table that was renamed (game.data.sprites is data.gen2Sprites).
  • A member with no backing says so. game.data.field does not exist on Gold, so it reads nil and logs once, naming the mods holding the facade. BattleState.newWild is absent rather than invented, because a newWild that took a species and a level would be a lie about what Gold's battle screen is.
  • One stable table for the run. Where the Gen 2 arm can serve the name outright the adapter is that module, so a mod's monkey-patch, its rawset sentinel and its == idempotency check all land on the table Gold actually runs.

What the adapter says it covers

The adapter publishes its own coverage, versioned by Gen2Compat.COVERAGE_VERSION (1), and modkit gen2check consumes that table rather than a second copy of the same knowledge:

Gen2Compat.modules()                            -- the 15 names, sorted
Gen2Compat.serves(name)                         -- boolean
Gen2Compat.memberStatus(name, member)           -- "backed" | "warned" | "absent" | nil
Gen2Compat.coverage(name)                       -- a fresh table per call:
--   { module, kind = "facade"|"alias", target, members = { [name] = status },
--     notes = { [name-or-topic] = "one line" } }

The status vocabulary is frozen at three values, and a member listed as both resolves to the weaker claim:

status means
backed present, and it does the Gen 1 job on Gold
warned present, answers nil or degrades, and names itself once with the mod attributed
absent deliberately not served; a nil read is the honest failure

Today that is 288 backed, 32 warned and 161 absent across the fifteen modules. notes keys are documentation topics rather than 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, and a member it does not record is not a promise either way: on an alias it resolves to whatever the Gen 2 module has, on a write-through facade it falls to the Gen 2 class, on the src.core.Game facade it reads nil and says so, and on the src.world.OverworldController facade it reads nil silently.

The follower. Gold's cart has no trailing companion at all, so src/world/gen2/Follower.lua is new Gen 2 code rather than a facade: the entity, the trail loop, and a shouldSpawn a mod replaces. World:step calls Follower.update(game, world) once per logic frame after the body, and World:setMap calls Follower.onMapEntered before it emits map.entered -- the same two call sites src/world/OverworldController.lua gives the Gen 1 arm, which is what makes a Gen 1 follower mod's wrappers tick.

Vanilla never spawns one: shouldSpawn answers false until something replaces it. Follower.setShouldSpawn(fn) is the supported way, and it writes the same file-local the Gen 1 mods reach through debug.setupvalue on the upvalue named shouldSpawn, so the two cannot disagree.

Two Gen 2 engine changes came with it, both general rather than follower-only: an entity with passable set never blocks a step (the Gen 1 name and meaning, src/world/Collision.lua), and World:rebuildPeople now preserves guests -- anything in the people list it did not put there. A rebuild runs on every zoom and every time-of-day roll, so without that a follower vanished at the top of the hour.

What the facades cannot fix. A mod that allow-lists version strings (GameVersion.get() == "red" or ...) excludes itself from Gold by construction, and no adapter should special-case it. Neither is a Gen 1 screen id: Gold's builtins carry a Gen2 prefix, so a mod matching id == "BoxMenu" matches nothing. A write to a field on a live Gen 2 menu instance is inert where Gen 1 read it back (menu.onSwitch, menu.swapFrom, StartMenu's box geometry), and map.warpAt is a name collision rather than a rename -- Gen 1's is a table keyed by cell, Gold's is a method, so indexing or iterating it raises. All of these are mod-side edits, each with a route that works on both generations; docs/preparing-your-mod-for-gen2.md walks through them.

What works on Gold today

Screens. The screens registry serves both generations. Gold's screens are registered under Gen2-prefixed ids so a mod that replaces Gold's party menu does not also replace Red's; Screens.GEN2_IDS in src/ui/Screens.lua is the full list. Every screen Gold opens goes through an id, including the boot cinema and the START menu.

Asset overrides. overrides/ shadowing and asset transforms work unchanged: Gold's screens load art through src/render/Assets.lua, the same choke point Gen 1 uses.

Content registries at the shared path. pokemon, moves, items, type_chart, strings, font, screens, commands, tokens, growth_rates, battle_sprite_scales, render_pipelines, and the audio family (audio, music, sfx, cries, map_songs). These keep their Gen 1 target path, so one mod source targets both generations.

The last two are the newest and each carries one caveat worth stating before you write against it:

  • battle_sprite_scales. src/ui/gen2/BattleState.lua:imageScale walks the merged table for a record whose path matches the pic being drawn, skipping the registry's own _owners row, and picScale falls through to the species record's battleScaleFront / battleScaleBack after it -- the same image-then-species-then-default order Gen 1 resolves in. Because the key is the asset path it also reaches the pics that are nobody's species: the player's trainer back, the DUDE's, an opponent's frontpic. The default differs and is not a registry record either side: Red's 32x32 back pics draw at 2x, Gold's 48x48 ones fill their 6x6 box at 1x, so a scale that looks right on Red is twice as large on Gold. At any scale the pic stays centred in its box and standing on the same ground line.
  • render_pipelines. src/core/Game2.lua:load installs src/render/Pipelines.lua on Gold's dataset after mods:load, so the merged table is the one it walks, and Game2:draw composites the whole-frame half through Pipelines.wantsPresent / Pipelines.present with the Gen 1 ctx keys (width, height, scale, dpi, dpiX, dpiY). The drawWorld half is inert on Gold: its overworld draws straight to the window rather than into a canvas the way src/world/OverworldController.lua hands one to Pipelines.drawWorld. A drawWorld-only pipeline is not left switched on and drawing nothing -- Game2:load retires a restored level for one, leaving options.pipelines untouched so the mode comes back the day Gold grows a world canvas. Gold also has no OPTION row for a pipeline (Pipelines.rows is read only from src/ui/OptionsMenu.lua), so a Gold player reaches one by its hotkey.

Content registries at a Gen 2 path. maps, tilesets, sprites, text, encounters, trainers, palettes, icons, battle_anims, constants, statuses, move_effects, item_effects, balls, ai_classes and evolution_methods. Same registry name, same verbs, a Gen 2 table underneath (data.gen2Maps, data.gen2Encounters, data.gen2Statuses, ...). src/core/Game2.lua loads the extracted ones into game.data before it calls mods:load, and every consumer takes them by reference and never copies, so what a mod merges is what the game walks: a registered map is a map Gold can warp into, a patched tileset is the one Map.new reads, a patched encounter table is the one the grass rolls.

The battle-rule six are the newer half and work slightly differently: there is no table on disk for them at all. They come into existence as the merge, and each consumer reads a record through a lookup that falls back to its own module records when no loader ran, so a mod-free Gold boot behaves identically:

registry who reads it
statuses Battle.statusRecordFor / statusPenaltyFor, Catching.statusBonus, ItemEffects.healClassOf
move_effects Battle.moveEffectRecordFor (useMove's dispatch)
balls Catching.recordFor
ai_classes Ai.layersFor (the ten scoring.asm passes, plus mod layers)
evolution_methods Evolution.methodFor
item_effects ItemEffects.recordFor / partyAction

src/mods/Builtins.lua seeds those six with Gold's records under Gen 2 rather than Red's. It has to: both games call it GREAT_BALL, and Red's record carries no multiplier, so seeding Red's would leave Gold's x1.5 reading nil.

Content registries that exist because Gold does. Six systems Red has no counterpart for, so there is no Gen 1 table to share and none of these carries a Gen 1 target at all. The routed Gen 2 path is their only home, and Schemas.GEN1 gates them on a Red boot the way Schemas.GEN2 gates map_scripts on a Gold one -- reported, not silently merged.

registry id space who reads it
held_items item ids ItemEffects.heldItemFor; the merged rows are written back onto data.items for Battle:itemDef
phone_contacts PHONE_* (data.gen2Constants.phoneContactOrder) Phone.useRegistry, folded onto the contact table
decorations "deco:<n>" Decorations.attributes, the single read point for an attribute row
apricorns apricorn item ids Apricorns.useRegistry, which rebuilds all three lookups and Kurt's menu order
landmarks LANDMARK_* Nests.landmarkId / Nests.landmark, which resolve a map header's landmark byte
radio_channels station ids MapRadio.channelRecord, which puts a registered station on the dial

Game2:load calls Phone.useRegistry, Decorations.useRegistry, Apricorns.useRegistry and ItemEffects.applyHeldItems immediately after mods:load, so the merge is live before the first frame. landmarks and radio_channels need no such call: their consumers take data at call time.

landmarks merges onto the cache's own gen2Landmarks.landmarks and held_items onto the view Game2 builds from data.items, so both fold against the vanilla row -- a register for an existing id collides, a patch stacks. The other four come into existence as the merge, seeded from their module's literals by src/mods/Builtins.lua.

Four honest limits on that surface:

  • held_items reaches the battle by being written back onto data.items, so a held row for an id with no data.items record lands nowhere. To invent a held item, register the items record too. The write-back is a diff against a pre-merge snapshot, which is what lets items and held_items compose instead of one reverting the other.
  • decorations ids are "deco:<n>", not DECO_* names: the cart's decoration constants are a bare const_def block with no name table behind them, so there is nothing in the ROM to spell them by. battle_anims addresses its unnamed rows the same way. n is the attribute row's index, which is wMenuSelection.
  • phone_contacts does not register the four PHONE_UNUSED const_skip holes (contact bytes 8, 9, 10 and 25). The manifest gives all four the same id, and one id cannot key four rows. They stay copies of the wrong-number filler, which is what the cart does with them.
  • radio_channels and phone_contacts register content, not new UI: a registered station gets a dial position and a name, and a registered contact gets a row the Pokegear indexes, but neither invents a screen.

Record shapes. A registry whose Gen 2 records genuinely differ carries a Gen 2 schema beside its Gen 1 one (gen2Fields / gen2Keys / gen2Write in src/mods/Schemas.lua, resolved by Schemas.shapeFor). The registry name, the verbs and wherever possible the ids stay shared; only the record changes. The differences an author meets:

  • pokemon. Gen 2 splits special into specialAttack / specialDefense, names the level-up table levelMoves and the pic size picSize, has no separate level1Moves, and points an evolution at into rather than species. It also carries the breeding block (eggGroups, eggMoves, eggSteps, genderRatio) and the wild held-item pair.
  • encounters. The id is the encounter kind, not the map: mod.content.encounters:patch("grass", { ROUTE_29 = { rates = { NITE = 40 } } }). A map's row carries a rates set per time of day and one slot list. fishGroups, trees / treeSets, rocks, bugContest and roamMaps are ids of their own.
  • trainers. The id is the trainer class, and the record is { name, index, attributes, baseMoney, encounterMusic, trainers, items }, with one entry per named trainer of the class. The registry writes one level in, into data.gen2Trainers.classes, so the call shape is unchanged.
  • icons. Two id forms in one registry, routed by the ICON_ prefix a sheet name carries: a species id names an assignment (a string, the sheet's name), an ICON_* id names a sheet.
  • palettes, battle_anims, constants. The id is a subtable of the target: pokemon / trainers / bg / objects / roofs for palettes, scripts / moves / objects / framesets / oamsets / gfx for battle_anims, and one of Gold's 42 ordered ROM name lists (plus mapGroups, trainerClassMembers, types) for constants. Those lists are ordered and position is the id a script byte resolves through, so they replace rather than append.

Four more id-space notes, because the records at those paths came out of a Gen 2 ROM:

  • Gold's text ids are ROM pointer strings such as "55:4067", not the TEXT_* names Red uses. override them by pointer; there is no name table.
  • A Gen 2 tileset carries its walkability as collision where Gen 1 says walkable. Both fields validate; only collision is read on Gold.
  • A Gen 2 warp row carries destGroup / destMapNum beside the destMap / destWarp pair Gen 1 also has. Both are optional in the shared schema, so a Gen 1 warp row and a Gen 2 one both validate, and patching one of Gold's own maps does not mean restating the ROM's map-group numbers.
  • Gold writes "burn" / "sleep" into mon.status where Red writes BRN / SLP. The statuses registry is the same registry; only the ids differ, and they have to.

mod.commands. Works on Gold. src/script/gen2/Vm.lua runs the cart's own bytecode, so there is no opcode byte to hand a mod -- the seam is a row the cart cannot write. Opcodes.MOD_COMMAND ("modcommand") is an op name with no byte behind it, and the VM dispatches it through the same merged data.commands table Gen 1's runner resolves by name. Two row shapes reach it:

{ op = "modcommand", verb = "mymod:shake", args = { 4, 2 } }   -- native
{ "mymod:shake", 4, 2 }                                        -- Gen 1 row

The second is the Gen 1 row shape verbatim, so one row list can serve both games as long as every row in it is the mod's own verb. The handler is called fn(ctx, unpack(args)) with ctx.vm where Gen 1 has ctx.runner; it may block on ctx.vm:showText / :waitFrames, and its return value speaks Gen 1's control vocabulary ("end", a row number, or nil). A missing or raising verb is warned once per name and the rest of the list still runs. The engine's own Gen 1 verbs are not seeded on Gold: a row-list verb handed Gold's ctx would find no runner on it, so data.commands under Gen 2 is the mod verbs alone.

mod.save, mod.options, mod.log, mod.assets, mod.find, exports. Generation-agnostic; nothing to adapt.

mod.world. Same method set, resolved against Gold's world (src/world/gen2/WorldAPI.lua). Two differences show through and are documented on the module: Gold's world is not a stack state, and Gen 2 event flags are numeric ids into wEventFlags rather than string keys. spawnNpc / removeNpc append onto the map def's own object list, the way the Gen 1 arm does, so a spawned actor is pooled, drawn, walked and talked to like an extracted one and survives a map reload; it is not serialized, so a mod respawns on map.entered. queueScript takes a small allowlist of verbs Gold has its own entry points for (start_battle "wild" species level, warp, text, setflag, clearflag) and refuses a list containing anything else by name, before the first row runs, so a mod never gets a half-run queue. marchInPlace still has no Gen 2 equivalent (the Gen 2 movement stream has no byte for it) and returns nil, reason rather than approximating one.

Hooks and events that fire on Gold. Every name below is the Gen 1 name carrying the Gen 1 payload keys, because Gold's call sites reuse them rather than defining a parallel vocabulary; where Gen 2 carries more, the payload gains a field instead of the name gaining a prefix.

  • Engine-wide, from the shared modules: game.ready, screen.pushed, screen.popped, screen.render_visible, music.started, music.stopped, music.select, music.volume, sound.played, zoom.range, assets.transformed, mods.loaded, mod.options_changed.
  • Overworld (src/world/gen2/): 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; hooks warp.destination, movement.collision, movement.speed, encounter.roll, encounter.species, encounter.fishing, world.tod, map.palette, fieldmove.eligibility. flag.changed carries the numeric wEventFlags id under Gen 1's name key, which is the one payload difference the numeric flag space forces.
  • Menus (src/ui/gen2/): 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. ui.list_menu covers Gold's script menus (ScriptMenu.lua); the Chrome.List widget the START and title menus draw with does not raise it yet, so those two are composed through their own hooks only.
  • The Oak speech (src/ui/gen2/OakSpeech.lua): intro.oak_speech.started, intro.oak_speech.step, intro.oak_speech.answered, intro.oak_speech.finished, and the intro.oak_speech.build hook. Gold has a real Oak speech, so it is the same extension point rather than a second one: same names, same payload keys, same moments in the sequence. The beats are a data table with the same step vocabulary (say / pic / name / choice / yesno / shrink / fn, plus Gold's own initclock and demo), and the step ids match Gen 1's wherever the moment is the same -- oak_welcome, demo_mon, world_spiel, ask_player_name, name_player, legend, shrink -- so ModUI.insertStepBefore(steps, "name_player", ...) lands in the right place in both games. The two ids with no Gen 1 counterpart are Gold's own beats, init_clock (the farcall InitClock the speech opens with) and oak_study (the return to Oak for _OakText5). Gold has no rival-naming or name-confirmation beats, so it raises no anchors for them: the rival is named by CopScript in maps/ElmsLab.asm, hours later.
  • Battle (src/battle/gen2/): 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; hooks battle.damage, battle.crit, battle.accuracy, battle.turn_order, battle.enemy_action, battle.run, battle.exp_award, exp.gain, catch.rate, trainer.party, battle.overlay, battle.low_health_alarm and battle.catch_exp. One payload difference: Gen 1's vanilla battle.low_health_alarm link reads ctx.battle.data, and Gold's battle screen has no .data field, so the Gen 2 site adds ctx.data beside the Gen 1 keys. A mod that calls nextFn is unaffected; one that reaches through ctx.battle.data instead gets nil on Gold.
  • The catch and the evolution: pokemon.caught, pokemon.evolved; hook evolution.check. src/ui/gen2/BattleState.lua:pushCaught emits pokemon.caught once the mon is in the party or the box, and src/core/gen2/Evolution.lua emits pokemon.evolved from apply and wraps each row's decision in evolution.check. The hook passes data where Gen 1 passes game; positions 2-4 (mon, row, trigger) match.
  • The frame (src/core/Game2.lua): hooks input.step, input.pointer, render.zones, render.compose, render.letterbox, render.hud. Each sits at the same moment src/core/Game.lua and src/render/Renderer.lua raise it -- the logic tick before the pad is read, a pointer the touch overlay gets first refusal on, the palette zone list handed to the present pass, the letterbox, and the finished playfield rect -- and carries the same payload. render.hud's gameX / gameY really is where Gold's dialogue boxes and menus land, because Chrome.fitScale / fitOrigin and World:fitScale compute the same number. render.zones is handed nil in GBC mode (Gold computes no zone of its own there) and the engine's own one-rect list in CLASSIC mode; a rect that clamps to nothing is skipped rather than throwing, which is what src/render/Renderer.lua:scissorClamped does on the Gen 1 side.
  • Sprites (src/pokemon/Sprites.lua, shared): pokemon.sprite, pokemon.icon and player.sprite. pokemon.icon is reached from src/ui/gen2/PartyMenu.lua through the shared module, so it is one call site serving both games. player.sprite is raised by Sprites.playerPic, which Gold's battle back pic (src/ui/gen2/BattleState.lua), Hall of Fame and intro call with an already-resolved path: Gold's trainer art is not in field.playerPics, so the path is found first and the hook raised over it, with the Gen 1 ctx keys (side, kind, demo, battle, data) unchanged. The Gen 2 trainer card is the one player-art read still outside it: its portrait is a tile sheet that also carries the frame tiles, not a swappable pic. pokemon.sprite has a second site of its own in src/ui/gen2/BattleState.lua, which adds letter (Unown) and shiny to the Gen 1 ctx keys -- both concepts Red does not have.
  • Save and the script VM: save.created, save.loaded, save.loading, save.writing; hooks save.write, save.new_game, script.command, and the script.started / script.ended pair off src/script/gen2/Vm.lua. script.command reports a mod's own row under the name "modcommand" with the row's real operands, and may rewrite them, on the same path it wraps a cart row.

New in Gen 2

These have no Gen 1 analogue -- Red has no friendship byte, no day care egg, no Pokegear, no radio, no held items -- so they are the only places a new name is justified. They are live, guarded by Runtime.wants / Runtime.wantsHook, and each is driven through a real bus by tests/engine/gen2_new_seams.lua.

Events

event raised from payload
happiness.changed Happiness (ChangeHappiness, StepHappiness) mon, event, reason ("event" / "step"), delta, from, to
breeding.egg_created Breeding (DayCare_InitBreeding) egg, mother, father, compatibility, stepsToEgg
egg.hatched Breeding mon, egg, slot, species, nickname
phone.call_received PhoneRing.script call, contact, name, className, special, scriptKey
clock.day_changed Clock day, previous, reason
pokerus.infected Pokerus party, slot, mon, strain, days, source
roamer.moved Roamers index, slot, species, from, to, reason
roamer.encountered Roamers index, slot, species, level, mapId
apricorn.converted Apricorns (Kurt) apricorn, ball, event
bug_contest.scored BugContest mon, score, place, results
unown.unlocked Unown (UpdateUnownDex) letter, name, word, count
radio.channel MapRadio station, channel, name, source
mail.written Mail entry, slot, mon, message, author, source
mail.read Mail entry, message, author, top, bottom
intro.boot.copyright CopyrightSplash:enter screen, game
intro.boot.gamefreak GameFreakPresents:enter screen, game
intro.boot.movie GoldSilverIntro:enter screen, game
intro.boot.movie_ended GoldSilverIntro:finish screen, game, skipped, frames
intro.boot.title TitleState:enter screen, game

The four intro.boot.* cards are the GS boot cinema, and they are the one part of Gold's intro with no Gen 1 moment to share a name with: Red boots into IntroMovie with no copyright card, no GAME FREAK splash and no attract movie. The Oak speech immediately after them is the opposite case and reuses intro.oak_speech.* verbatim (see the shared table above).

Each card raises its name the frame it comes up, because that is the moment a mod can act on. Only the movie has an _ended name, and only because it carries a fact nothing downstream does -- skipped is the difference between a player who watched all 2335 frames and one who pressed START. The other three cards chain straight into the next card, whose own event is their end.

delta on happiness.changed is to - from, not the table's column, because the 0 and $ff carry clamps are part of what the cart applied: a mon at 254 gaining "5" gained 1.

clock.day_changed compares against a process-local latch, so the first read after a boot has nothing to compare against and raises nothing. That is by design; it is a day change, not a day report.

unown.unlocked is raised from UpdateUnownDex -- a form first entering the #DEX list -- not from the four ENGINE_UNLOCKED_UNOWNS_* puzzle flags. Those flags are written by the cart's own setflag, so there is no Lua transition at the puzzle solve to hang a second event on yet.

mail.read rides Mail.lines with a per-struct latch, because the read page redraws every frame. The latch is re-armed by Mail.get / Mail.mailbox, which is how both readers pick the letter they are about to open, so reopening the same letter raises a second event.

Hooks

hook wraps ctx vanilla answer
held_item.trigger Battle:heldEffect battle, mon, item, def, effect, parameter, trigger ctx.effect, ctx.parameter
breeding.compatibility Breeding.compatibility data, mon1, mon2, dayCare the vanilla byte
phone.contact_list Phone's wPhoneList read called (save, list), the shape the other list hooks use the same list
shiny.roll Mon dvs, species, def, level the DV-derived boolean
gender.roll Mon def, dvs, ratio, species, level the DV-derived gender

held_item.trigger is one hook over eight call sites, because on the cart those eight are one routine (GetUserItem / GetOpponentItem loading b and c, and the caller comparing b against the HELD_* it cares about). trigger says which comparison is about to happen: "priority" (Quick Claw), "damage" (Scope Lens and the type-boost family), "endure" (Focus Band), "flinch" (King's Rock), "accuracy" (BrightPowder), "confuse", "residual" (the end-of-turn Leftovers / Berry / cure arm), and "check" for any other read. Return nil to make the item do nothing at that trigger, or another HELD_* name to substitute one -- every call site compares against a name, so substitution is the whole mechanism.

held_item.trigger wraps the read, so a mod can suppress or substitute an effect from any item. Defining a new held item is the held_items registry's job, and the two compose: register the row, then steer it from the hook.

phone.contact_list refuses an answer of the wrong length or with an unknown contact id (unknown ids blank to 0 on purpose, so the Pokegear never indexes a nil). It reorders and blanks the ten save slots; registering a contact id the game does not know is phone_contacts' job.

shiny.roll does not override a forced-shiny battle (opts.shiny), which is how the cart's own scripted shiny Gyarados stays shiny.

Registries with no Gen 2 home

Writing to one of these while Gold is running takes the write, drops it, and reports it once per mod into the same error feed the manager shows. It is not fatal: a mod that supports both generations registers its Gen 1 content unconditionally and still loads the half that applies. The report is worded from the boot's own generation, because the gating runs both ways.

rulesets, transitions, field, text_pointers, link_fields, map_scripts.

Schemas.GEN2 in src/mods/Schemas.lua is the authoritative table, and tests/engine/gate_gen2_mod_api.lua holds it to the catalog.

The list used to have three causes behind it and now has one. "No Data path exists" closed when the overworld tables stopped loading off disk into World fields. "The shape differs" closed when a registry gained the option of carrying a Gen 2 record schema beside its Gen 1 one. What is left is one cause:

Gold reimplements the system without reading a registry. The Gen 1 target is still built and merged into, but nothing in a Gold boot ever looks at it, so routing the registry would be a merge into a table with no reader -- exactly the silent no-op the gate exists to prevent. Closing one of these is a consumer change in the Gen 2 module first and a routing row second:

  • rulesets: no Gen 2 ruleset dispatch exists.
  • transitions: Gold draws its own battle intro (src/ui/gen2/BattleTransition.lua), and its STYLES is a boolean set of the four cart wipes (spin, speckle, zoom, sine) rather than the { frames, draw, sound, flash } record this registry carries. There is no styleDef lookup for a registered id to reach, so a mod style would fail the STYLES membership test and fall back to vanilla -- routing it would be the silent no-op, not the fix.
  • field: the Gen 1 overworld's data grab bag. Gold's equivalents live in data.gen2Maps and the VM's own tables.
  • text_pointers: Gen 1's TEXT_* indirection. Gold's text is pointers.
  • link_fields: link play is Gen 1 only.
  • map_scripts: data.gen2Scripts is the cart's bytecode pool keyed by ROM pointer, and a Lua row list merged into it is not something src/script/gen2/Vm.lua can run. Routing it needs a Gen 2 side dispatcher in World, not just the verb table mod.commands already has. The script.started / script.ended / script.command seams do fire, so a mod observes and can veto a script it cannot yet author whole.

Four of this list closed after it was written, and how they closed is the pattern for the rest:

  • growth_rates now routes to the SHARED Gen 1 target. Gold's curves are coefficient rows in the extracted pokemon.lua, so src/mods/Builtins.lua's Gen 2 registrant wraps each as the { expForLevel } record Gen 1's registry uses, and src/battle/gen2/Mon.lua:growthFor is the one accessor all six readers go through (Mon twice, BattleState, SummaryMenu, Breeding, ItemEffects). One record shape, one id space, one mod source for both games. Because it is routed, the pokemon schema's growthRate reference is now checked rather than skipped, and it resolves: both sides say GROWTH_MEDIUM_SLOW.
  • tokens was on the 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. A {NAME} a mod registers expands in the world, the menus and the VM's pages alike.
  • battle_sprite_scales closed consumer-first, the growth_rates way: src/ui/gen2/BattleState.lua grew imageScale / picScale, a faithful mirror of Gen 1's BattleState.imageBattleScale / resolveBattleScale down to skipping _owners and the image-then-species-then-default order, so the registry now routes to the SHARED Gen 1 path and one record serves both games. Only the default is generation-specific, and neither side reads that from the registry.
  • render_pipelines closed because the reader moved, not the registry: src/core/Game2.lua:load installs src/render/Pipelines.lua on Gold's merged dataset after mods:load and Game2:draw composites present. The drawWorld half is still inert, which is why this one is worth reading the caveat above for -- it is routed on the strength of the half that works, and Gold retires a drawWorld-only level rather than pretending.

Hooks and events Gold does not raise yet

Gold has its own draw path, intro, evolution and sprite lookups, so the call sites in those Gen 1 modules are not on Gold's path. The names are not taken and not reserved for Gen 1: when a Gen 2 call site lands it uses the existing name and the existing payload, plus fields where Gen 2 genuinely carries more (the split special stats, held items on a trainer roster).

The list is much shorter than it was. What is outstanding, in descending value:

  • pokemon.before_give / pokemon.received: Gold has no give-mon seam of its own yet.
  • link.* and trade.completed: a Gold boot offers no link menu at all. The Gen 2 fingerprint and handshake exist (src/link/Fingerprint.lua hashes a Gen 2 surface and a cross-generation pairing is refused by name), but nothing in src/ui/gen2/ opens onto the protocol, so these raise nowhere.

Four groups that used to sit here have since landed and moved to the shared table above: the frame seams (render.compose / render.hud / render.letterbox / render.zones, input.step / input.pointer), the three battle seams (battle.overlay, battle.low_health_alarm, battle.catch_exp), the two sprite lookups (pokemon.sprite, pokemon.icon), and the catch/evolution trio (pokemon.caught, pokemon.evolved, evolution.check -- src/ui/gen2/BattleState.lua emits pokemon.caught from pushCaught once the mon is in the party or the box, and src/core/gen2/Evolution.lua emits pokemon.evolved from apply and wraps each row's decision in evolution.check).

Three partial coverages worth knowing about, because "the hook exists" is not the same as "the hook sees everything":

  • encounter.roll / encounter.species are wired into the grass/water step, randomwildmon, the Bug Contest and SWEET SCENT, but not into World:tryHeadbutt, World:rockMonEncounter or Roamers.checkEncounter. Those three read row shapes that are not { species, level } slot lists, so a mod that reskins encounters misses headbutt trees, rock smash and the roamers.
  • src/ui/gen2/BattleState.lua builds a flat opts for Catching.attempt with no data in it, so a mod-registered ball is readable through Catching.recordFor but is not yet resolved at the real throw site.
  • Three Gold UI files carry their own copy of the status HUD labels the merged statuses records now hold as hudLabel, so a mod status shows no label in the battle HUD, the party menu or the summary page until they read Battle.statusRecordFor(data, status).hudLabel. The values are identical today, so nothing vanilla is affected.

Gen 2 tables with no registry

Game2:load assigns 24 data.gen2* tables and 12 of them are registry-backed, so twelve sit in game.data on a Gold boot with no registry pointing at them: gen2Marts, gen2Roofs, gen2StdScripts, gen2EventTables (the phone book, in-game trades, elevator labels, decoration descriptions), gen2InitialEvents, gen2Pokedex, gen2MenuGfx, gen2Intro, gen2Credits, gen2Diploma, gen2Trade, and gen2Scripts (which the map_scripts registry does reach, so it is the one of the twelve that is not out of reach). Naming registries for the rest is new API surface rather than a routing change, so it is deliberately not done yet.

Testing a Gen 2 mod

Static first. gen2check reads the manifest, scans every .lua the package carries and cross-references what it finds against the coverage table above:

python3 tools/modkit.py gen2check my_mod            # or a path
python3 tools/modkit.py gen2check my_mod --notes    # + the caveat on each backed member

It reports one of will load, will load but degrade or will not work, with a MK4xx finding per site and an unresolved: note, carrying a file and a line, for every reach a static scan could not follow. Exit 0 clean, 1 on a fatal finding (or any finding under --strict), 2 on usage; --json emits the whole batch as one document, and --quiet prints the findings alone, so a clean mod prints nothing and the exit code is the answer. The rule ladder is MK400-MK410 and is listed in tools/modkit.py's section header.

Then the headless harness, which takes the generation without booting Gold:

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")

Everything else is the production path: same loader, same validate, same topological sort, same merge. Assert the state as well as the error count: a gate skip is deliberately not an error, so #run.errors == 0 passes for a mod that never ran a line.

Neither substitutes for a real Gold boot, and the two output channels there are not the same. The adapter's own warnings (Gen2Compat.warnOnce) go to the log only, each attributed to the mod holding the facade. The boot error feed the manager shows is loader.errors: a failed mod, a duplicate id, a registry with no Gen 2 target, a cross-validation problem, and a require for a Gen 1 module the adapter does not serve. A skipped mod and a degraded member are on neither list, by design.