The generated cart release workflow curls cartkit.py into RUNNER_TEMP and
runs the selftest from the cart repo, where find_repo() finds nothing and
scaffold exits non-zero, so every cart release failed at the first step.
Report failures and skips even under --quiet; the workflow passes it, so
the only output was the FAIL count with no indication of which check.
Both features landed independently and each was green on its own, so the gap
only appears in the combination.
- tools/cartkit.py BASES did not list crystal, so cartkit rejected a Crystal
cart as an unknown base game. The Lua validator never had this problem: it
asks GameVersion.VERSIONS, so it accepted Crystal the moment the row existed.
- Two self-tests used the literal "crystal" as their never-a-game sentinel and
invert now that Crystal is real: tests/engine/cart_manifest.lua and
cartkit's own t_identity. Both re-pointed at "nonesuch", the token the rest
of the suite already uses for this.
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.
Gold and Silver had no label-keyed string table at all. The manifests
carried no text section, RomExtractorGen2 had no extractText, and
game.data.text was never assigned, so every call through
src/core/RomText.lua fell back to the literal written beside it. The only
Gen 2 text the cache held was the script text in data/generated/text.lua,
keyed by bank:address for the overworld VM, which nothing can look a battle
line up in.
make_gold_manifest.py now walks data/text/'s five dialogue files for their
labels, the way make_rom_manifest.text_metadata walks pokered's, and embeds
each one's symbol. 889 labels, all of them resolving in both editions.
make_silver_manifest.py inherits the list unchanged and re-resolves the
addresses from pokesilver.sym.
RomExtractorGen2:extractText decodes them into data/generated/rom_text.lua.
The mechanism is the one extractOakSpeech already used for _OakText1-7:
resolve the label, decode from the cart, key by name. What is new is that
the list comes from the manifest rather than being written out in Lua, so
all of data/text/ arrives instead of seven strings.
decodeGen2Text also emits the three runtime name slots it used to drop.
PlaceMoveUsersName, PlaceMoveTargetsName and PlaceEnemysName (home/text.asm)
substitute a battler's name as the line prints, so <USER>, <TARGET> and
<ENEMY> are markers, not glyphs. Skipped as control glyphs, SubTookDamageText
decoded as "The SUBSTITUTE / took damage for" with nothing after it.
Game2:load assigns the table to self.data.text, which is what makes the
existing shared RomText helper work on Gold and Silver at all.
The new cache file is listed in the Gold override rather than bumping
CACHE_FORMAT, so caches built before this stage re-import themselves and Red,
Blue and Yellow are left alone.
Implemented a new deinterleave function in ImageWriter to restore row-major order for PNGs. Updated RomExtractorGen2 to utilize this function for extracting Slot Machine graphics, and added new functions for padding and writing graphics sheets. Updated the ROM manifests to include necessary symbols for Slots and Card Flip assets.
Added functions to write raw graphics assets for the Slot Machine and Card Flip games, ensuring that the necessary files are generated when the corresponding symbols are present in the ROM. Updated the manifest to include required symbols for these assets, preventing fallback to labelled cells when graphics are missing.
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.
Several hand-ported scripts carry pokered dialogue as inline English
literals instead of reading game.data.text, because the real ROM
label was never reachable from data/generated/text.lua:
ViridianCityYoungster2OkThenText/CaterpieAndWeedleDescriptionText,
TMNotebookText, the SS Anne kitchen cook's three dish lines, and the
Viridian fisher's pre-gift line (data/scripts/story5.lua's gift()
already read t[label] here, just had a stale comment and a missing
fallback).
Traced the actual cause carefully -- there are two independent,
differently-behaved label scanners in this codebase:
- tools/extract/text.py's parse_text_file() requires a label to
start with "_" to be collected. This is a real bug (confirmed
against a real pret/pokered checkout), but this function has no
callers anywhere in the tree and no __main__ entry point -- it
looks like dead code left over from an earlier version of the
pipeline.
- The function that actually produces the shipped label list is
text_metadata() in tools/make_rom_manifest.py, which feeds
manifest["text"]["labels"], which build_rom_data.py's
extract_text() iterates to decode each label straight from the
ROM. text_metadata() already uses the permissive regex (no "_"
requirement) since commit 0f581e2f.
So the actual blocker is that the committed tools/rom_manifest.json
was stale relative to text_metadata()'s current code, not a source
bug. Verified by rebuilding pret/pokered from source with RGBDS
(reproducible -- the resulting pokered.gbc/pokeblue.gbc hash to the
same canonical SHA-1s gen1recomp already pins, so no cartridge dump
was involved anywhere here) and running the real, unmodified
make_rom_manifest.py against it: 2595 labels against the committed
manifest's 2585, a clean superset containing everything these
scripts need. SilphCo2FSilphWorkerFPleaseTakeThisText is the one
exception already in the manifest -- confirmed by commit 0f581e2f
("so many bugs i cannot even breathe") that it was hand-patched in
exactly this same targeted way, for issue #393.
Fix:
- tools/extract/text.py: relaxed parse_text_file()'s regex to match
text_metadata()'s, for consistency (no effect on what ships, since
nothing calls this function, but no reason to leave a legacy copy
of the same scanner out of sync).
- Four scripts read the real label first (t[label] or fallback, the
established pattern): celadon_eevee.lua, ss_anne_kitchen.lua,
viridian_city.lua, story5.lua (comment/fallback only, lookup was
already correct).
- tools/rom_manifest.json and tools/rom_manifest_blue.json:
regenerated for real -- both files are the direct, unedited output
of running make_rom_manifest.py/make_blue_manifest.py against a
real pret/pokered checkout at POKERED_REVISION, not hand-assembled
or reverse-engineered to match. Only safe because of the two fixes
below, which exist specifically so a real run doesn't regress
anything the previously-committed files had. Diffing a real
make_rom_manifest.py run against that previous file (1143 lines
out of 45253) found exactly what a naive "just regenerate against
whatever pokered HEAD is handy" would have silently broken:
- pret/pokered commit 079d1cc92fc3b0ec82bc1418c2b4045bfca84620
(PR #596, 2026-08-06) renamed
_SilphCo10FGiovanniILostAgainText/_SilphCo10FPorygonText to
_SilphCo11F... (they live in text/SilphCo11F.asm, Giovanni's
floor). data/scripts/victories.lua:183 still hardcodes the old
name, and extracting under pokered's new name would silently
blank Giovanni's "I lost again!?" rematch line. Fix is the pin
below: POKERED_REVISION is pinned to the last commit before this
rename, so today's generator output matches victories.lua
natively, no engine code touched, no generator-side workaround
either. Advancing the pin past this commit is a real, welcome
future upgrade -- it just needs victories.lua's labels (and
anything else's) fixed up in the same change.
- trainerPartyOverrides.OPP_CHIEF (Giovanni's Celadon gym team)
wasn't produced by any code under tools/ at all. Traced why:
pret/pokered's data/trainers/parties.asm has "ChiefData: ; none"
-- the Celadon Chief's battle is unused/cut content in the
original game, and RomExtractor.lua's own comment confirms
gen1recomp reimplements it as a real fight using a hand-authored
party for exactly that reason -- no pokered commit, old or new,
will ever produce this data. Added a TRAINER_PARTY_OVERRIDES
constant to make_rom_manifest.py so this survives every future
regeneration automatically; verified it reproduces the committed
value byte-for-byte and flows through to Blue/Yellow for free
via their existing derive-from-Red path.
- trainerHeaders.MtMoonB2F's Super Nerd slot is pre-existing
fabricated data, not pokered drift: his event name,
EVENT_BEAT_MT_MOON_3_SUPER_NERD, has never existed in pokered at
any point in its history (the real name,
EVENT_BEAT_MT_MOON_EXIT_SUPER_NERD, has been stable since 2015),
nor in gen1recomp's own event_flags.lua; trainerDefeated()
checks defeatedTrainers[npc.id] first, the same pattern already
used for the Fighting Dojo's Karate Master, so this entry is
very likely already inert. field.seafoam also differs from a
fresh regeneration (two showObject boulder-toggle IDs in the B3F
puzzle, a live gameplay system nobody has verified either value
against), and field.tradeArt is new content a fresh extraction
produces that was never shipped. None of those three are this
PR's problem to fix, but a real generator run has to do
something with them regardless -- so make_rom_manifest.py gets a
new apply_known_nonreproducible_overrides(), called right after
text_metadata()/field_metadata(), that pins MtMoonB2F and
seafoam back to what was already shipped and drops tradeArt,
each with a comment explaining why and what the real fix looks
like (MtMoonB2F needs a Data:seedMtMoonB2FSuperNerd()-style
engine seed, not manifest data). Verified this override function
was complete and correct -- diffed a real run against the
previously-committed file first, empty -- before trusting it to
write tools/rom_manifest.json/_blue.json directly; both are now
literally that generator's output, not hand-assembled.
tools/rom_manifest_yellow.json isn't touched by this PR at all:
it already had all ten labels, and make_yellow_manifest.py has
no matching override yet for its own field.oldManBattle outlier,
so regenerating it for real isn't safe the same way yet.
- tools/make_rom_manifest.py: added a POKERED_REVISION pin --
cf621a76d4941c93c078eb38e0880fe8db48ef40, the last pret/pokered
commit before the Silph Co rename above, chosen deliberately
rather than current HEAD -- and a check_pokered_revision() guard
main() calls before generating: fails loudly if --pokered isn't
at that commit instead of silently absorbing whatever upstream
renames or restructures since, with an explicit
--allow-revision-mismatch escape hatch for a deliberate pin bump.
That's the intended way this pin moves forward: diff a fresh run
against the committed manifest, fix up whatever engine code
depends on by exact name, and bump POKERED_REVISION in the same
change -- a conscious, reviewable decision instead of a silent
contributor default. Wired the same guard into
make_blue_manifest.py and make_yellow_manifest.py for their own
--pokered/--pokeyellow checkouts; make_yellow_manifest.py gets its
own POKEYELLOW_REVISION (e6ba56989b0f2694f393e6924820be11dcc1fbb8,
verified here). The pin and its guard live entirely in the
generator source, not also embedded as a field in the shipped
manifests -- that would be redundant with the .py constant next to
it in the same commit, for no protection the guard doesn't already
give.
- tools/make_rom_manifest.py, make_blue_manifest.py,
make_yellow_manifest.py: switched json.dump(..., ensure_ascii=
False, ...) to ensure_ascii=True to match how the committed
manifests were actually encoded (escaped \uXXXX rather than
literal UTF-8). Purely cosmetic -- json.load parses both
identically -- but needed so that a fresh make_rom_manifest.py run
at POKERED_REVISION now produces tools/rom_manifest.json
byte-for-byte (plain diff empty), not just content-equal. One
small, pre-existing cosmetic mismatch remains and wasn't chased: a
single nested dict, field.cardKeyDoors.doors, has its
SILPH_CO_10F/11F keys in natural floor order in the committed file
instead of the sort_keys=True lexicographic order everything else
in the file uses -- same content either way.
- tests/rom_manifest_generator_test.py: new ROM-free unit tests (same
style as the existing tests/build_rom_data_cli_test.py, wired into
scripts/test.sh as a T0 tier) for check_pokered_revision() and
apply_known_nonreproducible_overrides() -- matching/mismatched/
bypassed/unresolvable-checkout revision cases, and that the
MtMoonB2F/seafoam/tradeArt overrides land correctly (including
alongside a populated map entry, and without erroring when
tradeArt is already absent). Doesn't replace the manual real-ROM
verification above, which needs an actual pokered/RGBDS toolchain
-- but a future typo or logic slip in either function now fails
immediately instead of only surfacing next time someone happens to
redo that manual check.
- Left data/scripts/flavor/silph_co_9f.lua's nurse dialogue (labels
also added to both manifests here) untouched code-wise: static
command table, not a function, needs its
face_player/heal_party/fade state machine restructured to use
t[label] safely, and show_text's un-resolved-label fallback prints
the label name literally rather than English -- not safe without
interactive testing.
Checked Yellow's equivalent case (Melanie's House) since it looked
like the same shape: it isn't actually broken.
tools/make_yellow_manifest.py's YELLOW_EXTRA_TEXT_LABELS already
force-includes those eight labels, and a real built
dialogue_yellow.lua already has correct French translations for
them. tools/rom_manifest_yellow.json also already carries all ten
labels this PR adds to Red/Blue. No label changes needed there.
Tested: patched parse_text_file() against a real pret/pokered
checkout (+11 labels, 0 removed, all clearly dialogue-shaped);
rebuilt pokered.gbc/pokeblue.gbc with RGBDS at current pret/pokered
HEAD (confirmed hashes to gen1recomp's own canonical SHA-1s) and
diffed a fresh make_rom_manifest.py run there against the committed
manifest to map out what a full regen at HEAD would need; re-checked
out the same pokered checkout at cf621a76 (POKERED_REVISION),
rebuilt both ROMs again (same canonical hashes); with
apply_known_nonreproducible_overrides() and the ensure_ascii fix in
place, ran both generators to a scratch path first and diffed
against the then-committed manifests -- empty -- before running them
again writing tools/rom_manifest.json/_blue.json directly, so both
files are now the generator's literal, unedited output (git diff on
that final write: 18 lines moved in rom_manifest.json, exactly the
pre-existing SILPH_CO_10F/11F ordering quirk; zero lines changed in
rom_manifest_blue.json); rebuilt pokeyellow.gbc the same way, confirmed its
canonical hash, and confirmed make_yellow_manifest.py's symbols/
text/trainerHeaders/trainerPartyOverrides also come out byte-for-
byte identical to the committed tools/rom_manifest_yellow.json; ran
the real build_rom_data.py --only text against all three rebuilt
ROMs and confirmed every added label decodes from real ROM bytes
matching the English fallback literals exactly; verified
check_pokered_revision() actually raises on a deliberate mismatch
before relying on it to gate the runs above; python3
tests/rom_manifest_generator_test.py: 9/9 pass; luajit
tests/run_engine.lua: 250/250 suites pass.
A fixed cutoff (e.g. "<=200 is ink") only makes sense for sprites with a light background to split against; a mostly-opaque 16x16 icon has almost no pixel above that cutoff, so every such icon collapsed onto the same "all ink" hash and was flagged as a near-duplicate of anything else that also collapsed -- which was most of them, boulder.png included. Thresholding against the image's own mean keeps the split meaningful (and roughly balanced) no matter how light or dark the source is.
bindGoldData points gen2Palettes, gen2Icons, gen2Pokedex, gen2Landmarks,
gen2Roofs and gen2Sprites at the extractor's own Gold tables through
loadGen, but never gen2Constants -- despite Schemas.GEN2 routing
`constants` to that same namespaced-and-differently-shaped category
palettes and icons are in. A save editor boot left data.gen2Constants
unset, so mod.content.constants:get(...) read an empty table instead
of the cart's ordered name lists, misreading the generation and
rejecting every record a mod shaped off it.
data.gen2Constants now goes through the same loadGen("constants") path
the other five already use, falling back the same way they do when no
ROM cache is active.
The save editor now routes Gold vs RBY through a generation adapter so boxes, items, events, and maps write the right fields. The launcher layout is shuffled a bit, and patch notes can come from the updater, a packed file, or the iOS sidecar.
Expose a non-blocking TLS socket API to mods (WSS clients) without bundling
any game-specific multiworld content.
Co-authored-by: Cursor <cursoragent@cursor.com>
subprocess.run(..., capture_output=True, text=True) with no explicit
encoding falls back to locale.getpreferredencoding(False) -- the OS
default codepage. On Windows that's a legacy single-byte codepage
(e.g. cp1252), never UTF-8. When the LuaJIT dump contains a byte with
no mapping in that codepage, subprocess's internal _readerthread
crashes with an uncaught UnicodeDecodeError in a background thread; the
thread dies silently and the caller gets back stdout=None instead of a
string, crashing one line later with
AttributeError: 'NoneType' object has no attribute 'splitlines'.
Concretely, the Yellow-side imported dataset contains:
"_ColosseumHeightText" -> "...6’8” tall!"
The right double quotation mark (U+201D) encodes in UTF-8 as E2 80 9D;
0x9D has no defined character in cp1252, so decoding as cp1252 fails
outright. Verified against the real imported dataset: the Red/Blue-only
dump has zero bytes outside cp1252's defined range; the Yellow dump has
exactly one, at this row.
UTF-8 is the actual encoding these dumps are produced in -- the driver
Lua sources are read/written as UTF-8 throughout this file, and LuaJIT
writes those source strings' bytes back out verbatim -- so passing
encoding="utf-8" explicitly at the three affected call sites
(run_loader, check_data_dump, dump_dataset) is a no-op on platforms
whose default codepage is already UTF-8 (Linux/macOS) and a correctness
fix on Windows.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Yellow's field.oakSpeech manifest carried only shrink frames, so
OakSpeech.lua's `oakGfx.demoSpecies or "NIDORINO"` fallback fired and
the opening speech showed Nidorino's sprite and cry instead of the
player's Pikachu.
- Stamp demoSpecies "PIKACHU" in the Yellow import manifest (source of
truth for fresh ROM imports and developer builds).
- Stamp it in make_yellow_manifest.py so regeneration keeps the value.
- Repair stale Yellow caches in Data:applyVersionedFieldData() with a
fill-if-absent block, matching the #617 oldManBattle RATTATA pattern.
- Add parity test (manifest carries PIKACHU; stale cache filled;
pre-stamped value left alone).
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The mon inspector now has a NICKNAME section: a text field with Set / Clear, commit-on-Enter and discard-on-Escape.
Input is gated at the field instead of refused at commit. Only glyphs the game
font can actually draw AND that round-trip through a .sav are accepted, capped
at the naming screen's 10 glyphs, so a bad keystroke or paste never appears and
Set always succeeds. Chars like "@" (the Gen1 string terminator), "#" and the
dakuten kana have codec entries but no font tile so they are blocked rather than silently mangled.