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_scalesandrender_pipelinesamong them), 16 route to a Gen 2 table under the same name, 6 are Gen 2-only systems Red has no counterpart for, andmigrationsis 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
decorationsis told exactly as a Gold boot writing tomap_scriptsis. - 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.luareads 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
gen2compatbelow.
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, soGame.saveis nil during the entry chunk and correct forever after. It aliases the two names Gold spells differently (Game.overworldisGame2.world,Game.writeOptionsisGame2:persistOptions) and the one data table that was renamed (game.data.spritesisdata.gen2Sprites). - A member with no backing says so.
game.data.fielddoes not exist on Gold, so it reads nil and logs once, naming the mods holding the facade.BattleState.newWildis absent rather than invented, because anewWildthat 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
rawsetsentinel 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:imageScalewalks the merged table for a record whosepathmatches the pic being drawn, skipping the registry's own_ownersrow, andpicScalefalls through to the species record'sbattleScaleFront/battleScaleBackafter 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:loadinstallssrc/render/Pipelines.luaon Gold's dataset aftermods:load, so the merged table is the one it walks, andGame2:drawcomposites the whole-frame half throughPipelines.wantsPresent/Pipelines.presentwith the Gen 1 ctx keys (width,height,scale,dpi,dpiX,dpiY). ThedrawWorldhalf is inert on Gold: its overworld draws straight to the window rather than into a canvas the waysrc/world/OverworldController.luahands one toPipelines.drawWorld. A drawWorld-only pipeline is not left switched on and drawing nothing --Game2:loadretires a restored level for one, leavingoptions.pipelinesuntouched so the mode comes back the day Gold grows a world canvas. Gold also has no OPTION row for a pipeline (Pipelines.rowsis read only fromsrc/ui/OptionsMenu.lua), so a Gold player reaches one by itshotkey.
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_itemsreaches the battle by being written back ontodata.items, so a held row for an id with nodata.itemsrecord lands nowhere. To invent a held item, register theitemsrecord too. The write-back is a diff against a pre-merge snapshot, which is what letsitemsandheld_itemscompose instead of one reverting the other.decorationsids are"deco:<n>", notDECO_*names: the cart's decoration constants are a bareconst_defblock with no name table behind them, so there is nothing in the ROM to spell them by.battle_animsaddresses its unnamed rows the same way.nis the attribute row's index, which iswMenuSelection.phone_contactsdoes not register the fourPHONE_UNUSEDconst_skipholes (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_channelsandphone_contactsregister 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 splitsspecialintospecialAttack/specialDefense, names the level-up tablelevelMovesand the pic sizepicSize, has no separatelevel1Moves, and points an evolution atintorather thanspecies. 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 aratesset per time of day and one slot list.fishGroups,trees/treeSets,rocks,bugContestandroamMapsare 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, intodata.gen2Trainers.classes, so the call shape is unchanged.icons. Two id forms in one registry, routed by theICON_prefix a sheet name carries: a species id names an assignment (a string, the sheet's name), anICON_*id names a sheet.palettes,battle_anims,constants. The id is a subtable of the target:pokemon/trainers/bg/objects/roofsfor palettes,scripts/moves/objects/framesets/oamsets/gfxfor battle_anims, and one of Gold's 42 ordered ROM name lists (plusmapGroups,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
textids are ROM pointer strings such as"55:4067", not theTEXT_*names Red uses.overridethem by pointer; there is no name table. - A Gen 2 tileset carries its walkability as
collisionwhere Gen 1 sayswalkable. Both fields validate; onlycollisionis read on Gold. - A Gen 2 warp row carries
destGroup/destMapNumbeside thedestMap/destWarppair 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"intomon.statuswhere Red writesBRN/SLP. Thestatusesregistry 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; hookswarp.destination,movement.collision,movement.speed,encounter.roll,encounter.species,encounter.fishing,world.tod,map.palette,fieldmove.eligibility.flag.changedcarries the numericwEventFlagsid under Gen 1'snamekey, 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_menucovers Gold's script menus (ScriptMenu.lua); theChrome.Listwidget 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 theintro.oak_speech.buildhook. 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 owninitclockanddemo), 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-- soModUI.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(thefarcall InitClockthe speech opens with) andoak_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 byCopScriptinmaps/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; hooksbattle.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_alarmandbattle.catch_exp. One payload difference: Gen 1's vanillabattle.low_health_alarmlink readsctx.battle.data, and Gold's battle screen has no.datafield, so the Gen 2 site addsctx.databeside the Gen 1 keys. A mod that callsnextFnis unaffected; one that reaches throughctx.battle.datainstead gets nil on Gold. - The catch and the evolution:
pokemon.caught,pokemon.evolved; hookevolution.check.src/ui/gen2/BattleState.lua:pushCaughtemitspokemon.caughtonce the mon is in the party or the box, andsrc/core/gen2/Evolution.luaemitspokemon.evolvedfromapplyand wraps each row's decision inevolution.check. The hook passesdatawhere Gen 1 passesgame; positions 2-4 (mon, row, trigger) match. - The frame (
src/core/Game2.lua): hooksinput.step,input.pointer,render.zones,render.compose,render.letterbox,render.hud. Each sits at the same momentsrc/core/Game.luaandsrc/render/Renderer.luaraise 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'sgameX/gameYreally is where Gold's dialogue boxes and menus land, becauseChrome.fitScale/fitOriginandWorld:fitScalecompute the same number.render.zonesis handednilin 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 whatsrc/render/Renderer.lua:scissorClampeddoes on the Gen 1 side. - Sprites (
src/pokemon/Sprites.lua, shared):pokemon.sprite,pokemon.iconandplayer.sprite.pokemon.iconis reached fromsrc/ui/gen2/PartyMenu.luathrough the shared module, so it is one call site serving both games.player.spriteis raised bySprites.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 infield.playerPics, so the path is found first and the hook raised over it, with the Gen 1ctxkeys (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.spritehas a second site of its own insrc/ui/gen2/BattleState.lua, which addsletter(Unown) andshinyto the Gen 1 ctx keys -- both concepts Red does not have. - Save and the script VM:
save.created,save.loaded,save.loading,save.writing; hookssave.write,save.new_game,script.command, and thescript.started/script.endedpair offsrc/script/gen2/Vm.lua.script.commandreports 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 itsSTYLESis 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 theSTYLESmembership 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 indata.gen2Mapsand the VM's own tables.text_pointers: Gen 1'sTEXT_*indirection. Gold's text is pointers.link_fields: link play is Gen 1 only.map_scripts:data.gen2Scriptsis the cart's bytecode pool keyed by ROM pointer, and a Lua row list merged into it is not somethingsrc/script/gen2/Vm.luacan run. Routing it needs a Gen 2 side dispatcher inWorld, not just the verb tablemod.commandsalready has. Thescript.started/script.ended/script.commandseams 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_ratesnow routes to the SHARED Gen 1 target. Gold's curves are coefficient rows in the extractedpokemon.lua, sosrc/mods/Builtins.lua's Gen 2 registrant wraps each as the{ expForLevel }record Gen 1's registry uses, andsrc/battle/gen2/Mon.lua:growthForis the one accessor all six readers go through (Montwice,BattleState,SummaryMenu,Breeding,ItemEffects). One record shape, one id space, one mod source for both games. Because it is routed, thepokemonschema'sgrowthRatereference is now checked rather than skipped, and it resolves: both sides sayGROWTH_MEDIUM_SLOW.tokenswas on the list by mistake rather than by cause.TextBox.newrunsTextBox.substituteon every box in both generations andsubstitutereadsgame.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_scalesclosed consumer-first, thegrowth_ratesway:src/ui/gen2/BattleState.luagrewimageScale/picScale, a faithful mirror of Gen 1'sBattleState.imageBattleScale/resolveBattleScaledown to skipping_ownersand 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_pipelinesclosed because the reader moved, not the registry:src/core/Game2.lua:loadinstallssrc/render/Pipelines.luaon Gold's merged dataset aftermods:loadandGame2:drawcompositespresent. ThedrawWorldhalf 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.*andtrade.completed: a Gold boot offers no link menu at all. The Gen 2 fingerprint and handshake exist (src/link/Fingerprint.luahashes a Gen 2 surface and a cross-generation pairing is refused by name), but nothing insrc/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.speciesare wired into the grass/water step,randomwildmon, the Bug Contest and SWEET SCENT, but not intoWorld:tryHeadbutt,World:rockMonEncounterorRoamers.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.luabuilds a flatoptsforCatching.attemptwith nodatain it, so a mod-registered ball is readable throughCatching.recordForbut is not yet resolved at the real throw site.- Three Gold UI files carry their own copy of the status HUD labels the merged
statusesrecords now hold ashudLabel, so a mod status shows no label in the battle HUD, the party menu or the summary page until they readBattle.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.