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.
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.
Every existing test around these callsites only ever runs with an
empty/fixture Data.text, so none of them could tell a properly-wired
romText/t._X call apart from a literal that never looked at the
catalog at all -- every assertion passed either way, the same coverage
gap the museum ticket clerk and status abbreviation fixes hit earlier.
Seven new tests fake the real label for each fix and assert the pushed
or queued message uses the translated value, plus a vanilla case per
fix confirming the no-catalog fallback still matches the original
English literal exactly:
- box_release_confirmation_romtext.lua (BoxMenu _OnceReleasedText, via
a TextBox.new spy so real pagination/choice behavior stays intact)
- slot_machine_lined_up_romtext.lua (SlotMachine _LinedUpText, symbol
concatenated in front of the translated suffix)
- battle_fainted_message_romtext.lua (BattleState onFaint, both
_PlayerMonFaintedText and _EnemyMonFaintedText, confirming the raw
name reaches each without a duplicated "Enemy")
- battle_catch_messages_romtext.lua (BattleState storeCaughtMon,
_ItemUseBallText06 plus both _ItemUseBallText07/08 branches on
EVENT_MET_BILL)
- battle_ball_dodge_romtext.lua (BattleState throwBall,
_ItemUseBallText00's \f-merge collapsing to exactly one queued
message)
- overworld_field_faint_heal_romtext.lua (OverworldController
applyFieldPoison's _PokemonFaintedText, and
useSoftboiledFieldMove's _ItemUseNoEffectText/_PotionText including
the recovered-amount slot the old literal never showed)
- overworld_hidden_item_romtext.lua (OverworldController
tryHiddenObject's _FoundHiddenItemText, both the {PLAYER} token and
the item name landing in the right slots)
Confirmed several of these fail against the pre-fix code and pass
against the current code, not just reasoned about it. Not every one of
the 15 fixed callsites has its own dedicated test -- the ShopMenu,
LinkBattle, trainer-withdraw/sent-out and the normal (non-hidden)
found-item sites share the same romText mechanism already proven
correct by the seven tests above, and building the heavier fixtures
each would need (a full mart flow, a link session, a trainer AI
switch, an object_event NPC) wasn't judged worth it for what would be
the same assertion shape again.
LinkBattle.lua's "%s wants\nto fight!" intro was a plain Lua literal,
even though the comment right above it already named the real label
(_TrainerWantsToFightText). The battle object built at this point is
already a BattleState, so this reuses its self:romText convenience
method rather than requiring the module-level helper separately.
ShopMenu.lua's buy/sell price confirmations were plain Lua literals,
even though the comment on each line already named the real label
(_PokemartTellBuyPriceText, _PokemartTellSellPriceText). This file's
own txt(game, key, fallback) helper doesn't support substitution
arguments, so it can't be reused as-is; added the module-level romText
helper instead, same as every other file in this batch.