A cart's saves are keyed by cart id, not by version. SaveData resolves
every path through activeScopeKey, which answers cart_<id> while one is
active, and the launcher lists, creates and selects a cart's slots from
the cartSlots registry (RomImporter._refreshSlots / _selectSlot /
_newSlot).
src/core/gen2/Save.lua asked in the version's name alone. saveNames
built saves/<version>/<slot>.lua or save_<suffix>.lua from the version in
both branches and never consulted the active cart, so a cart on Gold,
Silver or Crystal read and wrote the BASE GAME's playthrough. Gen 1 was
unaffected because it saves through SaveData itself, which is already
cart-scoped -- so this only showed on a Gen 2 cart.
It was worse than sharing one file. Save.save opens by asking
activeSlot(version) and, on nil, calling createSlot + setActiveSlot in
the version's name, so the first save inside a cart registered a slot in
the base game's registry and made it active: the cart's playthrough
appeared in the launcher's list for the base version, and the player's
own save there was what the cart then overwrote.
Both sites now resolve the cart scope the way SaveData does, and the
names they build are SaveData's own -- slotDir's saves/cart_<id>/ and
legacyNames' save_cart_<id>.lua -- so the in-game save layer and the
launcher land on one file again.
tests/gen2_save_test.lua covers the cart's flat name, its slot name, the
slot going into the cart's registry rather than the base game's, the
cart's scope winning over a base slot, and the base game keeping its own
once the cart is cleared. Four of them fail on the unpatched module.
Reported as "when I select Wild Crystal to launch it loads my save from
regular Crystal".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
BattleTowerMenu:drawPanel() shows self.message via Chrome.printWrapped
directly, with no Strings() lookup of its own -- same shape as
Game2:say()/TextBox.new(), which also expect their caller to have
already resolved the text. Every assignment to self.message in this
file used the raw Strings.source() return value (an identity/harvest
marker, not a lookup) instead: the level-picker prompt, the "quit your
challenge?" confirmation, the level-cap refusal, and the Uber-clause
refusal (which additionally called Lua's own string.format() on the
untranslated source instead of Strings(UBER_TEXT, name)). The YES/NO
confirmation labels and the CANCEL row label had the identical bug one
level down: declared with Strings.source() but printed via
Chrome.print() with no Strings() call around them either. The whole
Battle Tower level-picker menu, including its yes/no prompt, stayed in
English regardless of any translation catalog a mod supplied.
Found via a systematic trace of every TextBox.new() (143) and
Chrome.print()/Chrome.printWrapped() call site in the engine back to
its text source, cross-checked against the real tools/modkit.py
harvester and an independent review of the branch. That same review
also flagged src/world/OverworldController.lua:3314
(Strings.source("%s's PC"):format(playerName)) as the same bug shape,
and an earlier revision of this branch "fixed" it to
Strings("%s's PC", playerName) -- but that call site is not a bug:
openPC() deliberately builds every PC row's label from a stable
English Strings.source() value first, so the ui.pc.items mod hook can
match/reorder rows by their vanilla English text, and only translates
via a second translatedLabels[playerPcLabel] pass after the hook has
run (openPC()'s own comment: "Hooks identify the vanilla rows by their
English source labels. Delay localization until after ui.pc.items has
inspected/reordered/replaced them"). Translating playerPcLabel early
broke that contract -- confirmed by tests/engine/rby_translation_runtime_test.lua,
which already covered this exact case and failed with
"ui.pc.items sees the stable player-PC source label (got PC DE RED,
want RED's PC)" once that change landed. Reverted; the label was
already being translated correctly by the existing second pass, which
this branch never needed to touch.
A second independent review round, after the fixes above, found one
more real gap in the same family: ItemEffects.lua's VITAMIN_LABEL
table (folded into the previous commit once found), and confirmed
everything else on the branch clean.
Verified with tools/modkit.py's harvest_engine_strings that all seven
BattleTowerMenu.lua literals are still discovered from their
Strings.source() declarations, luajit tests/run_gen2.lua: 144/145 (the
one unrelated pre-existing failure noted in the previous commit), and
luajit tests/run_engine.lua: 533/533 (including the PC-label
regression test above, now passing).
learnMoveOn (Game2.lua) built every move-learning message -- level-up
learn, TM/HM teach, the "trying to learn X, forget a move?" flow, "stop
learning?", the HM-can't-be-forgotten refusal, "which move should be
forgotten?", and the "1, 2 and... forgot X, learned Y!" replace result
-- with a bare string literal or a direct :format() call, bypassing
Strings() entirely (src/core/Strings.lua). Game2:say()/TextBox.new()
show whatever text they're handed with no lookup of their own, so this
meant the entire move-learning UI stayed in English no matter what
translation catalog a mod supplied -- confirmed against a real
gen1recomp-translation-mods build, which has translated overrides for
several of these keys that could never apply because the literal never
reached the catalog. Same bug for the TM/HM teach refusals ("X can't
learn Y!"/"X already knows Y!").
src/core/gen2/ItemEffects.lua had the same bug across its whole family:
every item-usage message (Potion/heal, status cure, Revive, Rare Candy,
Vitamin, PP restore/PP Up, "no effect"/"can't use on an EGG"/"can't use
on this #MON") was either a bare table constant or built with :format()
directly, none of it routed through Strings(). Its constants are now
wrapped in Strings.source() at declaration (so a mod's catalog harvest
still finds them, per the pattern already documented in Strings.lua)
and looked up through Strings() at each use site. The Vitamin success
message's own VITAMIN_LABEL table (HEALTH/ATTACK/DEFENSE/SPEED/SPECIAL)
gets the same two-part fix -- each entry wrapped in Strings.source() at
declaration, and Strings(VITAMIN_LABEL[stat]) at the one use site --
matching the identical stat-name tables in MoveEffects.lua/TrainerAI.lua/
gen2/Effects.lua/ContestMenu.lua/SummaryMenu.lua, which all already do
this; without it a Vitamin's stat name would stay in English mid-sentence
even inside an otherwise fully translated message. Two more call sites
read ItemEffects.TEXT_NO_EFFECT raw outside that file and needed the
same fix: PartyMenu.lua's Softboiled-no-target refusal, and
BattleState.lua's X-item-reused-with-no-effect and
BitterBerry-when-not-confused refusals -- both battle-side messages
where every neighboring self.message assignment already correctly
wraps in Strings(), which is what made these two stand out as missed.
None of this changes vanilla (no mod loaded) output: Strings.get() is
an identity function with no catalog active, so every message renders
byte-identical to before. Verified with the headless Gen 2 suite
(luajit tests/run_gen2.lua): 144/145, the one failure
(gen2_fishing_time_test.lua) reproduces identically on dev before this
change and is unrelated (fishing time-group logic); the full engine
suite (luajit tests/run_engine.lua) is 533/533.
wire up Crystal PalMap tile attrs (bank 1, flips, BG_PRIO) through map bake,
attr grid, and BG-over-OAM blits instead of the gold/silver grassAtlasFor shortcut.
crystal-only: MapAttrGrid + TileAttrs, OAM bottom/top split for IN_GRASS,
keyed grass over feet strip via attrmap, drawBgPriorityOver for wAttrmap bit 7.
gold/silver left alone on the old path — all of this gated behind isCrystal().
fixes standing still in grass with tufts on torso / feet on top of grass (#2080).
RomExtractorGen2 pulls crystal PalMap attrs; SpriteRenderer splits standing
sheets on frameHeight; tests for tile attrs + feet strip regression.
Battle.MOVE_EFFECT_RECORDS only lists effects with a standalone handler --
by design, per its own comment: a move whose effect is just "deal damage"
(EFFECT_NORMAL_HIT and the multi-hit/recoil/drain families) has none and
falls through to the generic damage path. But src/mods/Schemas.lua's
`moves.effect = f.id("move_effects")` cross-check treats move_effects as
the complete id space for the field regardless of generation, so every
"full" effect the real Gold/Silver movedex uses read as a dangling
reference the instant any mod's `moves` patch touched the registry --
caught building the Gold/Silver translation mod, whose move_names patch
(name only, never effect) was enough to trigger the scan on all ~130
moves.
registerMoveEffectsInto now also registers a bare `{kind="primary"}`
marker for every effect id data.moves actually uses that MOVE_EFFECT_RECORDS
doesn't already cover. Both of moveEffectRecordFor's call sites already
treat a handler-less record exactly like a missing one (nil-checking
.run/.status before use), so this is a validation-only change with no
battle behavior difference -- a real typo in a mod's own effect patch is
still caught, since the widened set is seeded from the pre-merge data.moves,
not from whatever a mod patches in afterward.