A custom cart pairs an identity (title, shell colour, label art) with a base
game, a list of mods pinned to exact builds with their option values frozen,
a load order, and a seal. It ships no code of its own: every mod it names is
a separately published mod, which is what keeps a cart auditable before it
runs and reproducible after an author's repo disappears.
Format and storage:
- src/carts/CartManifest.lua parses and validates cart.json, canonicalises it
for hashing and reads/writes the .g1rcart bundle. The bundle is a data-only
serialised table read through SaveSerializer, so an imported cart can never
execute code. Canonical strings are length-prefixed because option keys and
values are author-controlled and could otherwise forge a record boundary and
collide two different carts onto one hash.
- Pins name a public source: a GitHub release with its sha256, a GameBanana
file id with its md5, or "local" for a capture that only exists on this
install. A local pin is unpublishable by construction, which is what makes
"build it here, publish later" possible without inventing a hash.
- Label art rides alongside the manifest rather than inside its identity, so
re-arting a cart does not tell every player their run is out of date.
src/core/Base64.lua decodes it; strict, with no whitespace tolerance.
Saves:
- Cart playthroughs live in the cart's own slot namespace (saves/cart_<id>/),
so a cart's file never sits beside a vanilla one and uninstalling a cart
never orphans a save. Every save records the cart build it was made under.
The seal:
- A sealed cart loads its pinned list, in its order, with its options, and
nothing else. A pinned mod with no frozen options gets an empty bucket so
unfrozen keys fall to schema defaults, identical for everyone; otherwise two
players on one cart quietly run different games.
- A sealed cart refuses to load when a pin is missing or installed at another
version. Playing a subset of the cart is the exact dishonesty the seal
exists to prevent, so the refusal loads nothing at all.
- Breaking the seal is permanent, marked per save slot, and downgrades that
playthrough to open behaviour. It cannot be cleared through any public API.
Launcher:
- A game's page carries a Custom Carts control and a picker; choosing a cart
turns the page into that cart's page, with its own cartridge, title and save
slots. The rail of five games never grows and a cart id never reaches
imp.tab or imp.panelVersion.
- Loader.planCart runs before boot so a refusal is visible on the page instead
of being discovered as an error after launch.
- Save as cart captures the enabled mods for a game and names, before the
player confirms, every mod that could only be pinned to this install and
whether the result can be shared at all.
Authoring:
- tools/cartkit.py scaffolds, validates, pins and packs a cart, and installs a
release workflow. Its writer is byte-identical to the engine's serialiser.
Crystal boots from a user-supplied ROM, imports a full cache and is playable:
copyright, the Crystal intro movie, the animated title, gender select, Oak,
and out into Johto. 122 of the cart's 169 script specials are implemented.
Import and data
- tools/make_crystal_manifest.py derives the manifest by importing
make_gold_manifest as a library, with three additive keyword seams. Gold and
Silver still regenerate byte-identical, which is the standing requirement for
touching that generator.
- crystal_symbol_deltas.py and crystal_movie_symbols.py carry the symbol delta:
Crystal renames the credits mons, splits the trainer card, Pokegear and
pack-pal blocks by gender, and replaces the intro and title outright.
- Crystal-only manifest keys: engineFlagOrder (162 flags to Gold's 93, so the
badge block sits one higher) and unownCharmap (the main charmap parser stops
at the first newcharmap so the two cannot contaminate each other).
Extractor
- RomExtractorGen2 becomes three-edition. Crystal corrections: PAL_MAP_BANK
0x13, a flat PICS_FIX pic bank, audio bank 0x5e, the mapSongs id-100 hole,
seven NPC trades, a TradeTexts stride of 8, the five Crystal tileset anim
steps with per-row degrade, and the column-major trainer card portraits.
- New: animated front sprites (frames, bitmasks, play and idle scripts), the
Battle Tower roster, Kris assets, Mobile System GB art, and the Crystal
intro and title via src/import/CrystalMovie.lua.
Engine
- GameVersion gains engine(id) and fixes(id). Gold and Silver keep their
original bugs where the bug is not hardware dependent; Crystal gets the fixes
Crystal shipped: Lucky Number boxes 10-14, surfing onto an NPC, and the
Reflect and Light Screen defence overflow.
- Crystal story: Suicune and Eusine, Celebi behind the GS Ball flag, the Ruins
of Alph chambers, Buena, the Move Tutor, the Poke Seer, and the Battle Tower
including the wInBattleTowerBattle badge-boost guard.
- Kris and the gender flag, animated fronts in battle and the summary screen,
and mon caught data.
Verification
- Every extracted asset is pixel-compared against pret's own source PNGs.
- Gold caches are byte-identical before and after, file for file.
- New Crystal suites plus a T2 Gen 2 tier; the full suite passes.
Route B in CONTRIBUTING-mods.md asks an event/hook change for five things.
This adds the two that were missing and fixes what the third turned up.
link.battle_ended built its payload unconditionally. Route B is explicit
that a new event must not allocate when nothing wants it, and every other
emit in the engine already guards -- Runtime.wants now gates this one too,
so an unsubscribed build runs the branch exactly as it did.
world.talk was handing Runtime.call a closure built fresh on every A press,
purely to have a fallthrough to pass. It is a file-local now, so an unhooked
press allocates nothing it did not allocate before.
The RFC covers motivation, the API delta with call sites, migration (nothing
changes for existing mods), and verification. The backward-compatibility
statement is in it: every item is a new name or a new optional argument, and
example_mew_starter -- api 1, category = "GAMEPLAY", whole-species copy --
still loads, which run_modkit proves on every run.
No registry or schema field is added, so gen_registry_docs has nothing to
emit for this change. Running it does show pre-existing drift in
docs/modding/reference/registries.md (timeFishGroups, an objects refinement)
from earlier Schemas.lua edits that were never regenerated; that is not this
branch's to carry, so it is left alone.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
gate_meta_coverage asks every extension point for a unit test through the
public mod API, a no-mod parity test, and docs. The seams commit shipped the
call sites and owed the rest.
Both cases drive the real engine path, and both assert the unhooked build
first, so they fail if the seam is deleted and if it changes vanilla
behaviour. world_talk stands an object on the faced cell: with no mod the A
press reaches talkTo, with a mod that owns the object it does not, and an
object the mod ignores still falls through. link_battle_ended parks a
session at the end of a battle and checks the event carries the result, the
role, and both party copies.
The parity side needed nothing. gate_hooks and gate_events walk the live
catalog, so both seams were covered structurally as soon as the call sites
existed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
battle.exp_award hands a mod ctx.applyShare(mon, split, announce) on both
generations, and on Gen 1 the third argument decides whether the mon's
GainedText box is printed -- which is how a mod paying the whole party
prints ONE summary line instead of a box per recipient. Gold accepted
the argument and ignored it, so the same mod source printed one line on
Red and one per party member on Gold.
The Exp Share mod is the live case: it declares games gen1+gen2 and its
description promises "a single shared-exp line instead of one message per
Pokemon", passing true for the fighters and nil for the bench exactly as
the Gen 1 seam asks. On Gold every nil call announced anyway, so a
five-mon party turned every KO into six boxes. There was no mod-side
fix: the emit sits behind no hook, and the argument meaning "quietly" was
discarded.
Gold now reads it, and ONLY when it is actually passed -- by argument
count, not by value. select("#", ...) counts an explicit nil, so
applyShare(mon, split) is distinguishable from applyShare(mon, split,
nil); the first is a Gen 2-era call written against a seam that always
announced and keeps announcing, the second is a deliberate "pay this one
quietly" and is now silent on both games. No mod that exists today
changes behaviour, and a mod that passes the argument gets parity.
Only the { kind = "experience" } event is affected. A silent award is
still a whole award: exp, stat exp, battle.exp_gained, "grew to level",
learned moves and the forget prompt are untouched, in the same order.
giveExperiencePass takes a sixth `silent` parameter that defaults to
announcing, so both of the cart's own passes are unchanged.
RFC: docs/rfcs/0012-gen2-exp-award-announce.md
Docs: docs/mod-api-gen2-compat.md gains the reading and the residual
omitted-argument difference beside the existing payload note.
Tests: tests/gen2_exp_share_test.lua grows the two Route B tests -- the
no-mod parity case and the seam driven through hooks:wrap -- and
its 23 existing checks are unchanged.
Silver: derived import manifest (tools/make_silver_manifest.py re-resolves
the Gold manifest's symbols from pokesilver.sym), silver GameVersion row,
generation-keyed extractor routing, required-files override, edition save
stamping (a Silver playthrough no longer writes into the Gold save),
checkver-driven edition data, SILVER/KAMON/OSCAR/MAX presets, GOLD rival
default, edition credits banner, Lugia title screen (OAM layouts, bob,
trail, palettes as title.lua data keys with Gold defaults so old caches
need no re-import), packaging for every build target, docs, and tests.
Launcher: the installed-mods list is one continuous scroll (rows culled to
the viewport) instead of a pager with an inner scroll viewport; the pad
cursor's edge-scroll no longer runs it to the bottom. The game dropdown
shows just the initial and caret. Find-tab behavior unchanged.
Title tempo: a sprite-anim frame shows duration+1 ticks
(engine/sprite_anims/core.asm GetSpriteAnimFrame), which locks both
editions' 64-tick wing beat to the 64-tick sine bob; the title screens no
longer run fast and out of phase.