# 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

```json
{
  "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]`, holds each game's
enable flag. The launcher shows a coloured Red / Blue / Yellow / Gold checkbox
for every installed mod, and the loader and in-game manager read the same
game-specific answer on the next boot. On the first launch after this feature,
the existing shared state is copied to every game, so a mod that was enabled
remains enabled everywhere; after that, changing one checkbox affects only
that game. New mods still default to enabled on every game (experimental mods
retain their explicit opt-in default).

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** (unless scoped to specific games, e.g.
`dependencies: [{ id = "x", games = ["gen2"] }]`), 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:

```lua
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:

```lua
{ 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.
`mapOverview` returns the same read-only terrain, tile-shading, and marker
shape, using Gold's live object masks and event flags to omit collected items.
`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`,
  `battle.catch_exp`, `battle.bottom_ui_visible` and
  `battle.status_hud_visible`. 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.output_enabled`, `render.output`,
  `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
  composed frame before GBCFX, 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:

- `trainer.before_battle`: Gold constructs and pushes its trainer battle in
  `src/world/gen2/World.lua:startBattle`, which does not yet expose a deferred
  preparation boundary or a battle-local player-party view. Gen 1 mods can use
  the hook documented in `docs/modding.md`; do not claim Gold compatibility
  when that selection is required.
- `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:

```sh
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:

```lua
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.
