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>
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.
CELADON_TM/MON and GOLDENROD_TM/MON stored each Game Corner row as one
hand-padded "NAME COST" literal; a translated name of a different length
than the English original shifted or overlapped the price that used to be
right-aligned by the padding alone. Split each row into a translatable name
and the existing numeric cost field, print them as two separate calls (the
cost right-aligned against a fixed priceRight column per counter, the same
column-aware approach MartMenu.printPriceOpaque already uses), and clamp the
name to the tile budget before the price column with Font.split (glyph-aware,
so a <PK><MN> macro or multi-byte UTF-8 character -- including one whose
expansion straddles the clamp boundary -- is never cut mid-sequence).
TEXTS (SlotMachine.lua) kept a hand-typed English line array next to each
entry's Strings.source() template; derive the array from source once at load
instead, and cache localizedLines()'s parsed split per source table so the
bet/result screens do not re-run the same gmatch split every draw() call.
ContestMenu.TEXT.alreadyCaught's line split now uses the same gmatch loop
the rest of this file's line-parsing already uses (an anchored ^(.-)\n(.*)$
match assumed exactly one \n and left a nil hole when a translation merges
the two lines into one clause).
Four of PrizeMenu.TEXTS' Game Corner vendor messages (Celadon's and
Goldenrod's prize-vendor intros, Goldenrod's quit line, and the coin
vendor's no-COIN-CASE refusal) used \f where the real cart text
(poke-corpus GoldSilver, e.g. gs.CeladonGameCornerPrizeRoom.
CeladonPrizeRoom_PrizeVendorIntroText) ends in \v: a plain page clear
instead of a scroll, so the vendor's last line appeared alone with no
lead-in instead of continuing under the previous one. Found and confirmed
against the corpus while auditing this branch for the same class of bug as
CenterPcMenu.lua's \n-vs-\v fix.
messagePages() only split on \n (line) and \f (paragraph), so a translated
message needing a \v scroll-continue break -- the same marker PackMenu and
PrizeMenu's messages already rely on via CommonText.pages -- rendered wrong
here: no line ever scrolled. Delegate to CommonText.pages, the same shared
page-break implementation PackMenu and PrizeMenu already use for their own
messages, instead of a second, incomplete reimplementation local to this
file.
CommonText.pages() also only treats \n as the box's second row, not a page
break, unlike the old local messagePages(), which grouped every two
\n-separated lines into a page regardless of \f. The one BOX_FAILURE_SOURCES
literal with a third line via a second bare \n ("You'll need a\nPOKéMON to
call\nwith.") needed \f instead, the marker every other multi-page message
in this file already uses for the same transition (see RELEASED just
above); pinned with a test.
CenterPcMenu.lua's own TEXT.noMon had the identical \n-vs-\v bug: the
Pokecenter PC's empty-party refusal (_PokecenterPCCantUseText, "ends in
cont" per the comment already on this line) needs \v to scroll "have a #MON
to" up and land "use this!" under it, not a third bare \n line, which
pagesOf() (this screen's own \f/\v-aware paginator, unaffected by the
messagePages() bug above) renders as a lone one-line page instead -- caught
by gen1recomp/dev's own independent fix to the same line while rebasing this
branch onto dev, and confirmed against tests/gen2_pc_screens_test.lua's
scroll assertions. The French/German/Spanish/Italian/Japanese/Korean
overrides already carry the correct \v in their translated values; only the
lookup key needed the same fix, made in gen1recomp-translation-mods
alongside this commit.
GEN2_STATUS_IDS (psn/brn/frz/par/paralysis/slp -> Gen 2's own registry ids)
was declared verbatim in both PartyMenu.lua and SummaryMenu.lua; move it to
Status.GEN2_ID_ALIASES so a future status alias fix only has one copy to
update.
SummaryMenu.TYPE_NAMES was left behind after this branch switched its two
former internal uses to the shared TypeChart.displayName/DISPLAY_NAMES; it
has no remaining callers anywhere in src/ or tests/.
`Checkpoint.restore()` rejected most valid checkpoints:
Checkpoint restoration failed: restored state differed at $.save.poisonSteps
poisonSteps is a plain step counter -- (poisonSteps + 1) % 4 on EVERY step,
not only while a mon is poisoned (OverworldController:applyFieldPoison) -- so
it is non-zero three steps out of four in ordinary play.
Installing the restored world re-enters the map, and the map-enter path zeroes
the counter, correctly mirroring ClearVariablesOnEnterMap. Checkpoint.restore
then re-captures the applied state and compares it against the checkpoint, so
the field it had just discarded failed the comparison and the whole restore
rolled back.
A restore is not a map entry from the player's point of view: the counter
belongs to the state being restored. Carry it across the push.
Why this stayed hidden: the two autosave triggers a checkpoint consumer
naturally uses, map.entered and player.warped, are emitted from inside the
very paths that zero the counter (OverworldController 377/572 and 4664/4783),
so those captures hold 0 and restore cleanly. Only a capture taken at an
arbitrary step -- a manual quicksave, or one tied to the ordinary SAVE --
carries a non-zero value. The T4 title-checkpoint tier misses it for the same
reason: its save is fresh, so the counter is already 0.
The comment above the push claimed Checkpoint.resume was this method's only
caller. Both callers arrive through Checkpoint.apply, which serves resume from
the title session and restore from a settled runtime; that is exactly why the
map-entry side effects matter here. Corrected.
tests/engine/restore_poison_steps_bug1971.lua covers 1..3 and 0 across a
restore, and pins the two behaviours that must NOT change: a plain map entry
still zeroes the counter, and a seamless connection crossing still carries it.
It fails on main (3/6, "got 0, want 3") and passes with this change.
- Introduced the `POKEPORT_IDLE_AFTER` and `POKEPORT_IDLE_FPS` environment variables to manage presentation rates on static screens, allowing game logic and audio to maintain full speed.
- Updated the vsync handling to keep it enabled across all platforms, including KMSDRM handhelds, to leverage PresentSync for improved cadence and pacing.
- Enhanced FrameCap logic to ensure proper handling of performance caps in handheld environments, preventing unnecessary software pacing when hardware capabilities are sufficient.
- Adjusted documentation to reflect these changes and their impact on power efficiency and performance.