Three follow-ups on the commit below.
statusBlockedId read the status record's optional `id` field. A mod that overrides a status rather than patching it replaces the record outright, and `id` is optional in the registry (src/mods/Schemas.lua), so the field can be nil there and full paralysis would quietly stop clearing bide, thrash, charge and trapping. It now falls back on the key the record was looked up under.
`msgs.failed` is the only thing that suppresses a primary effect's success animation now, so it is no longer an internal detail: the move_effects registry documents it, and docs/modding/reference/registries.md is regenerated. That regeneration also picks up rows that had drifted from the schema before this branch (missText, trueColor, the sprite cell fields).
The parity suite gains the case the commit below changes the rules in -- PAR's own roll clearing the volatiles, and a flinch on a paralysed battler leaving them alone -- and its translated-label block now restores the shared Data.text entries through pcall, so a failing check there cannot leak "pioupiou zzz" into every later parity suite.
The Easy Chat editor (profile, questionnaire, battle-start message) drew all of its own text as literals: the screen's title and its two instruction lines, the "is as shown. Okay?" confirmation, the quit and delete-all confirmations, YES/NO, "Select a group." and the DEL. ALL / CANCEL / OK footer.
The per-screen strings are chosen when the screen opens, so they go through Strings() there. FOOTER_BTNS is a module-level table built before any catalog exists, so its labels are Strings.source and the draw loop looks each one up, the same shape as ItemsData.POCKET_LABEL; tests/game3_strings_module_tables_test.lua covers it.
The words the player picks are not touched: they come from the ROM's own Easy Chat word groups.
The title is still centred the way the cart centres it, but no longer starts left of the ribbon: the longest English title fills 118 of the header's 128 px, so a longer translation would have spilled out of the blue fill.
Three related call sites in the RBY/shared battle pipeline decided
in-game behavior by string-matching a message that had already been
routed through Strings()/romText -- correct in English, silently wrong
under any translation catalog, since a translated message no longer
contains the English substring being searched for:
- BattleState:sayStatusMsg played the sleep/confusion SFX by searching
the status message for "is fast asleep!"/"is confused!". Under a
translation catalog the SFX silently stopped firing.
Status.beforeMove now returns onomatopoeiaKind/onomatopoeiaIndex,
pinning the SFX to the specific still-asleep/still-confused message
it belongs to (not necessarily the last one: confusion not
self-hitting can fall through into the disabled-move check or the
paralysis roll, appending one more, unrelated message after it).
- primaryEffectFailed decided whether to suppress a status/stat move's
success animation by string-matching msgs[1] against six known
English fail lines ("But, it failed!", "Nothing happened!", "didn't
affect", "is unaffected", "protected by MIST", "already asleep").
Extended the msgs.failed flag Substitute's own two failure lines
already used (#644) to every other MoveEffects.primary handler that
can fail without landing: changeStage, confuse, statusMove, and the
single-condition guards in LEECH_SEED, HEAL (including REST),
LIGHT_SCREEN, REFLECT, MIST, FOCUS_ENERGY, CONVERSION and DISABLE --
every function actually reachable through primaryEffectFailed (only
MoveEffects.primary entries are, via performMove's power == 0 and
record.kind == "primary" gate; the many other "But, it failed!"
sites elsewhere in this file belong to secondary/full-kind handlers
on a different pipeline and don't reach this function at all, left
alone here as a much larger separate follow-up). Removed the
six-substring sniffer now that every real producer sets the flag
explicitly; "is unaffected" had no live producer reaching this
function to begin with (SWITCH_AND_TELEPORT_EFFECT's own copy goes
through battle:cancelMoveAnim() directly, a different code path).
Removing the sniffer also fixes three pre-existing test failures
(parity_substitute_anim, two assertions in parity_trainer_ball_block)
that were crashing on a TextBox.strip call this test environment
doesn't stub -- that call only existed to support the sniffer.
- statusInterrupt decided whether a blocked move came from PAR's own
63/256 full-paralysis roll (which clears bide/thrash/charge/
trapping) by checking mon.status == "PAR" together with the message
containing "fully paralyzed". mon.status == "PAR" alone isn't a safe
replacement either: a paralyzed-and-flinched battler also has
status == "PAR" while canMove == false comes from the flinch, not
the paralysis roll. Status.beforeMove now also returns
statusBlockedId, set to the status record's own id only when that
record's own beforeMove handler is what returned canMove == false.
Extended tests/parity_status_onomatopoeia.lua with regression cases
for confusion overriding both ROM text labels with non-English text,
confusion stacked with a disabled selected move, and confusion stacked
with paralysis. Verified against the existing paralysis cases in
tests/mod_battle_tests.lua and the full test suite, no regressions.
Badge lines are end-battle texts (SaveEndBattleTextPointers), so they are
printed while the battle sound engine is still loaded. A text sound command
names an id rather than a sound, and that engine's SFX_Headers_2 gives those
ids different sounds: the id behind sound_get_item_1 / sound_level_up is
SFX_Level_Up there, and the id behind sound_get_key_item is SFX_Ball_Poof.
The port resolved the jingle by name, so it always played the overworld
reading (Get_Item1 / Get_Key_Item) -- the wrong jingle for receiving the
BOULDERBADGE, and the same collision for Cerulean, Saffron, Cinnabar and
Viridian in Red, Blue and Yellow alike.
Add victories.badgeSoundFor(), which answers with the jingle that is actually
audible for the badge hand-over:
Red/Blue Brock Level_Up, Misty Ball_Poof, Sabrina Ball_Poof,
Blaine Ball_Poof, Giovanni Level_Up
Yellow the same, except Misty: CeruleanGym.asm drops the command from
that line, so Yellow's Cascade Badge has no jingle
Vermilion, Celadon and Fuchsia carry no sound on their badge lines in any
version. The raw `badgeSound` field keeps the overworld-engine name, which is
what the TM texts (printed in the overworld) genuinely need.
Tests: pin the literal per-version jingle and its page for all five badge
leaders in tests/engine/gym_badge_jingle_page_bug1982.lua (53 checks), and
have the #1606 and driver suites assert through badgeSoundFor.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Gen3Compat's src.ui.StartMenu coverage row claimed the
ui.start_menu.items hook is "not raised on FireRed yet". It is raised:
src/ui/game3/start_menu.lua calls it from StartMenu.show with
(game, entries), the same name and arity as src/ui/StartMenu.lua and
src/ui/gen2/StartMenu.lua.
The note is what a mod author reads when asking whether the hook exists
on this generation, so a stale one costs a mod the hook for no reason.
- Rewrite the note to describe the FireRed arm: a row is { id, label }
and carries no onSelect to rewire, and a non-table return is dropped
without the Logger.error the other two generations emit.
- List ENTRIES as backed and note it: the facade passes the module's
list straight through, and show() rebuilds it on every open, so a mod
has to act per open rather than once.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The SBC and RG34XXSP ports hand LÖVE a source *directory*
(`love <dir>`), so `love.filesystem.isFused()` is false there and
`Boot.run` bailed on its first line. The launcher still offered
"Update vX.Y.Z" and "Restart to update", so an update downloaded,
verified, and then was silently ignored forever.
- Boot.canUpdateInPlace() replaces the fused-only gate: a packaged
build updates whether it is fused (AppImage, Flatpak game.love) or
unpacked, and only a dev / source checkout (engine "0.0.0-dev") is
excluded. Fails closed when the host cannot be established.
Prelaunch.updateAllowed now delegates to it, so the boot gate and the
`--update` gate cannot disagree.
- Check.hostPort() reads the release-target marker, with
POKEPORT_HANDHELD as a legacy fallback (the SBC launcher has always
exported it, so packs predating this change still identify
themselves). The marker was read from the environment but never
exported, so a full-package fallback on a handheld resolved to a
desktop AppImage that cannot run there.
- A handheld now fetches its own package ("Download port update",
reusing the worker's cross-platform download_full) instead of
offering a URL it has no browser to open, and reports "Update package
ready" once it is in the save directory for a manual re-extract.
- Launchers export POKEPORT_PORTMASTER / POKEPORT_RG34XXSP.
An in-place update only ever mounts a payload over the running source,
so relaxing the gate is less invasive than the fused path it joins.
A runtime bump still needs a full package, which the minShell gate
already reports as needs_full.
Tests: tests/engine/update_boot_host_gate.lua (new, 10 checks) pins the
gate; update_check_tests.lua pins both port asset names and hostPort().
scripts/test.sh --quick: all tiers passed; scripts/lint.sh: 0 errors.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The save window draws its stat values at a fixed x, 56 px into the window, which pret places for the English labels. The translated labels are wider in several languages ("DUREE JEU", "SPIELZEIT", "TIEMPO J."), and the time then prints over its label.
The value column now starts past the widest label, with the English gap between them. English labels leave it where pret has it.
The popup shown on entering an area takes its text from MapSectionsExtract.SECTIONS, the cart's English section names, with the floor suffix map_name_popup.c adds (" 3F", " B1F", " ROOFTOP") glued on in English. Nothing in a translation mod could reach either.
getInfo now also returns the name before the suffix (baseName), and the popup passes it through Strings() and translates the floor as a whole label ("3F", "B1F", "ROOFTOP"): the European carts number floors differently (3F is "2E" in French, "2S" in German), so a template around the number could not say it. The forest preview's name window (MapPreviewScreen), which shows the ROM's English section name, and the summary's met-location fallback go through Strings() too.
Species, move, item and trainer names reach a translation mod through registries, but nothing renames abilities: battle messages ("<POKéMON>'s <ability> prevents ...") and the summary page printed the ROM's English name whatever the language.
Abilities.name and the summary's ability line now pass the name through Strings(), keyed by the cart's English name ("SPEED BOOST", "COMPOUNDEYES"). The identifiers the battle code compares (SPEED_BOOST) and the description lookup are unchanged.
SummaryData looks a move or ability description up as MOVE_<name> / ABILITY_<name>, built from the name the summary shows. A translation mod renames moves in place (the moves registry writes Pokemon._moveNames), so with one enabled the key became MOVE_<translated name>, nothing matched, and the summary and TM Case showed "---" instead of the description.
Pokemon.install now keeps a copy of the ROM's English move and ability names before any mod patches them, exposed as Pokemon.romMoveName / romAbilityName, and the description lookups use it, falling back to the name passed in when the pack has none.
Strings() formats with string.format, which fills directives in argument order. A language that puts two values the other way round from English ("ATTACK de PIKACHU" for "PIKACHU's ATTACK") could not say so: the translation either kept the English order or swapped the values. The official FireRed, Gold and Red localizations reorder values like this in dozens of battle and menu lines.
A translation can now number its directives the way POSIX printf does ("%2$s de\n%1$s monte!"), and Strings() hands the arguments over in that order. Flags and widths are kept ("%3$03d") and "%%" stays a literal percent. A translation numbers all of its directives or none, and may only number arguments the source has; it may leave one out, as the official translations sometimes drop a name. Otherwise it falls back to the English source and logs one warning, like a translation with the wrong number of directives.
Sources never carry numbered directives, and a translation without them takes the same string.format path as before, so English and existing catalogs render the same text. A translation that still cannot format its arguments falls back to the English source formatted with them, instead of the raw template. An older engine given a numbered translation cannot format it and prints the template, so a catalog using numbered directives needs this engine.
The new-game intro (controls guide, Pikachu pages, Oak's speech, name choices), the naming screen titles, the title menu, Pokédex headers and labels, the Town Map names and dungeon descriptions, option values and hints, the Help System bar, the Hall of Fame and the Trainer Card were plain literals.
The intro text tables stay English sources and are passed to Strings() when shown. Option values go through Strings() with the option key as context ("option.battleStyle"), so a mod can tell the SHIFT battle style apart from other uses of the word.
The bag, TM case, Berry Pouch, party, PC, box storage, shop, summary, save and yes/no menus drew their labels, prompts and result messages as literals, so a translation mod could not reach them.
Messages built at call time go through Strings() with whole-sentence templates. Action lists the menus compare against ("CANCEL", "EXIT", "SWITCH BOX"…) keep their English identifiers and are translated where they are drawn, as are the module-level PC labels, descriptions and TEXT_* strings, now marked with Strings.source(). Default box names are stored in English ("BOX 3") and shown translated, while names the player typed are drawn as they are.
Field move texts, item-use results, Pokédex and summary fallbacks, trainer class and send-out lines, step events (repel, whiteout, fainting), VS Seeker and multichoice labels were plain literals or string.format calls, so a translation mod could not reach them.
Messages built at call time now go through Strings() with whole-sentence templates. Tables built at module load keep English sources marked with Strings.source() and are translated when read: FieldMoves.TEXT through an __index proxy, the Oak's lab rival fallback dialogs when the trainer is built, and ItemsData.POCKET_LABEL by its caller. Nature names and ability/move descriptions are translated where the summary asks for them. Values the engine stores or compares (box names, identifiers, quest-log keys) are left as they are.
Every message the game3 battle engine prints was built by concatenating English fragments around names ("X" .. " is hurt\nby poison!"), so a translation mod could not reach them, nor phrase a sentence other than as English splits it. They are now whole-sentence Strings() templates with the names as arguments (Strings("%s is hurt\nby poison!", name)), which renders the same English and lets a catalog word the whole sentence around each value. The one visible change is that effect messages now name abilities through Abilities.name, as the cart prints them (COMPOUNDEYES, LIGHTNINGROD) rather than from the identifier (COMPOUND EYES). Values still fill the template in call order (Strings() formats with string.format).
Fragment tables become full templates: status infliction, charge-turn and semi-invulnerable moves, Truant, berry flavours, held-item and enemy-item cures, and the stat-change lines (one template per rose/sharply rose/fell/harshly fell instead of a translated verb glued after the stat). Tables built at load time mark their keys with Strings.source() and are translated where they are said, since no catalog exists yet when the module loads. Stat names, the Ally/Foe prefixes, weather lines and screen names are translated at the time they are shown; the "a boosted" EXP line and the trainer send-out line become one template per variant rather than a fragment passed as an argument.
Code that read the English text back is fixed too: the double-battle send-out (animated and headless intros) no longer parses the trainer out of the translated "sent out" line, the battle UI paces the move-used line by its catalog wording, and AnimSeq recognises the miss, confusion, substitute, move-used and fainted lines it paces animations on through their catalog wording.
Game and Game2 hand the merged mod data to Strings.load right after the mod merge, so a translation mod's strings registry is what Strings() answers from. Game3:_loadMods did not: on a FireRed boot Strings() kept the catalog the launcher preloaded, which is every enabled mod's lang/strings.lua whatever game it targets, with no defined precedence, and never a registration made from mod code. Call Strings.load(self.data) after the merge, as the other two games do.
- Hidden Items & Itemfinder (fixes#2314):
* Extract BG_EVENT_HIDDEN_ITEM (kind 7) from GBA ROM with proper flag calculation (0x3E8 + id), underfoot flags, and quantities.
* Implement overworld Itemfinder scan logic (7-tile radius, directional response, underfoot detection, SFX 65, and OEM text).
* Wire up Bag Menu Itemfinder dispatch to overworld field runtime.
- Opponent Battle HUD Caught Marker (fixes#2313):
* Add authentic Pokédex caught Poké Ball icon (B_INTERFACE_GFX_BALL_CAUGHT / tile 70) to opponent healthbox.
* Match pokefirered parity in TryAddPokeballIconToHealthbox (wild encounters only, non-tutorial, non-ghost, checked against Dex).
* Ensure status ailment icons (PSN, PAR, SLP, FRZ, BRN) take rendering priority over the caught ball marker.
- Level-Up Learnset & Movepool Assignment (fixes#2315):
* Fix wild and generated Pokémon moveset assignment to assign the 4 most recent level-up moves up to the current level instead of getting stuck on basic moves (e.g. Tackle).
- Easy Chat / Profile Quick Chat:
* Extract full word groups, categories, and dictionary entries from ROM.
* Build Easy Chat interface with group selection, paginated word lists, and alphabetized filtering.
* Fix dialog text overflowing when exiting profile / quick chat prompts.
- Nurse Joy & PC Storage / Map Polishing:
* Implement Nurse Joy healing tray animation flow and audio cues in standard scripts.
* Fix PC storage box header/wallpaper rendering and storage data extraction.
* Correct Sevii Islands / Island 1 map section lookups and region map coordinate handling.
* Fix circular dependency in battle residual handlers (partialTrap).
fixes#2325fixes#2322fixes#2315fixes#2314fixes#2313
Just updates RomImporter so asset importers like A Link to the Past work properly on Android.
The importer now uses Android's native file picker, stages imported files separately from the normal Pokémon ROM flow, and routes them back to the correct importer when the picker closes.
The fallback file browser also no longer filters out .smc / .sfc files.
FrlgFont.glyphId builds its character-to-glyph map from TextIR.CHARMAP, the decode table for US FireRed text, which only lists the accented letters US text prints (é, É). Every other letter fell back to glyph 0x00, so a French, German, Spanish or Italian string drawn by game3 lost à, ç, è, ê, ä, ö, ü, ß, ñ, ¿, ¡ and the rest to blank cells.
The US ROM font already draws the whole Latin block of pret's charmap, in both latin_normal and latin_small, at the charmap's own positions. FrlgFont now maps those characters too (FrlgFont.LATIN_GLYPHS). TextIR.CHARMAP is left as is, so decoding ROM text and extracted caches are unchanged, and every US character keeps its glyph.
The bag's party-select screen read session.party, which battle_bridge only
writes back when the battle ends. Mid-battle it therefore showed pre-battle
HP and refused heals that would in fact work ("It won't have any effect.").
- Add PartyView.live(session): flushes the active player battlers into
st.playerParty and returns it, falling back to session.party outside
battle.
- bag_menu: read the live party, and stop re-mapping the party slot. The
battleOrder wrapper in PartyMenu.show already yields a real slot, so the
second map healed the wrong mon.
- Extract the battle hand-off into BagMenu.battleUse and route the Berry
Pouch's USE through it. The pouch fell back to ItemUse.useField, which
also only knows session.party, so a berry was eaten with no effect.
- Add tests/game3_battle_item_party_test.lua (7 blocks).
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Most map sections never showed a Location Preview because the preview
artwork and the region-map GUIDE text were hand-authored placeholders:
only a handful of sections had art and none of it came from the ROM.
Read the real tables out of the dump instead.
Extraction (new):
- src/import/gba/region_map_tables.lua locates and validates
sMapPreviewScreenData. The pinned offset from versions.lua is tried
first and a bounded +/-0x8000 scan is only a guard. Validation is
structural (mapsec in 88..196, type 0/1, three in-range ROM pointers,
and the shipped invariant that the palette block is the first 0x40
bytes of the tile block) so all 28 entries must hold.
- src/import/gba/map_preview_extract.lua bakes 21 deduplicated 240x160
RGBA artworks (28 entries reuse art: Pattern Bush and the six Tanoby
chambers), 109 mapsec names verified against sRegionMapSectionIdToName,
and the 19 sDungeonInfo entries. Palette banks 13/14; bank-0 tilemap
refs only occur in off-screen padding columns.
- src/import/gba/bg_bake.lua holds the shared 4bpp BG baking helpers;
berry_pouch_extract.lua is refactored onto them (-94 lines).
Runtime:
- src/ui/game3/map_preview_screen.lua ports
Task_RunMapPreviewScreenForest: 120-frame first-visit hold, 40-frame
revisit, 48-frame fade-out, artwork and name window composited into one
canvas so they fade together. Only MPS_TYPE_FOREST (8 sections) takes
over the screen; the 20 MPS_TYPE_CAVE sections only change the warp
fade colour, matching pret.
- src/core/game3/map.lua gives a changed section with a forest preview
precedence over the map name popup (overworld.c state 12).
- src/core/game3/warp.lua routes every fade through warpFadeModes,
applying WarpFadeOutScreen/WarpFadeInScreen: a section change into a
cave-preview map forces FADE_TO_BLACK, otherwise MapTransitionIsEnter/
IsExit decide white vs black on MAP_TYPE_UNDERGROUND.
- setworldmapflag feeds the transient visit flag; the HUD ticks and
dismisses the screen; the dataset installs the cache on hydrate.
- region_map.lua's GUIDE panel uses the retail geometry, tint ramp and
delayed text, and ROM names/dungeon text overlay the hand-authored
fallbacks via ensureGenerated().
Two retail quirks are tolerated: MPS_ROCKET_WAREHOUSE and Berry Forest
share flagId 2231 (as do the six Tanoby chambers), and the Tanoby mapsec
is spelled MAPSEC_DILFORD_CHAMBER.
CACHE_VERSION 99 -> 100.
Verified: tests/game3_map_preview_extract_test.lua (new; ROM-gated, all
28 entries, 8 forest / 20 cave, 21 artworks, 109 names, 19 dungeons, the
shared flag, Pattern Bush art reuse), tests/game3_town_map_test.lua,
tests/run_engine.lua 587/587, scripts/test.sh --quick all tiers,
luacheck src 0 warnings / 0 errors.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The location header drew `session.mapName or session.map`, and nothing in
src/ ever assigns session.mapName, so the fallback always won and the
screen printed the engine's internal id ("FR_ROUTE_22").
pret prints the sMapNames place name resolved from the map header:
start_menu.c PrintSaveStats -> SAVE_STAT_LOCATION
save_menu_util.c GetMapNameGeneric(dest, gMapHeader.regionMapSectionId)
region_map.c GetMapName(dst, mapsec, 0)
and centres it in the 14-tile stats window. Do the same via the existing
MapSectionsExtract.getInfo() lookup, and centre the run instead of
drawing it at a fixed x.
getInfo() also gets two fixes on the path the save screen now depends on:
- the fuzzy id match picked whichever section pairs() happened to visit
first, so "ROUTE_22" could resolve to "ROUTE 2". Prefer the exact match,
else the longest.
- return `resolved` so callers can tell a real section from the Pallet
Town placeholder the function falls back to for unknown maps.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The FRLG level-up stat window opened and the sequence kept pumping, so the
box outlived its own step: it stopped taking input (routing is phase-gated),
never closed, and drew over the win/money text.
- exp_seq: update() now waits while the window is open (mirroring the
already-correct busy()), and finish()/reset() tear the window down silently
so a stale onDone cannot advance a sequence that no longer owns it.
- stat_growth: close(opts) gains opts.silent for teardown callers.
- init: the input-routing phase list moves into STAT_WINDOW_PHASES behind
Battle.statWindowPhase(), and Battle.update() tears down a window whose
phase can no longer dismiss it.
- ui: the stat-window draw keys off the same predicate, so input and drawing
can never disagree.
Tests: the "In-Battle Level Up Stat Growth Window" block in
tests/game3_battle_switch_and_faint_test.lua now pumps ExpSeq.update() while
the box is open, and a new CI-covered tests/engine suite pins the same
regression.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Follow-up to #2319, found while testing it: the catch flow hand-wrote
"YOUR POKEMON'S NICKNAME?" into the text-entry window. That string is
141px wide, but sWindowTemplates[WIN_TEXT_ENTRY_BOX] only gives 127px
from x=73, so the title ran ~12px over the frame's right edge.
pret composes the title instead -- DrawMonTextEntryBox prints
gSpeciesNames[mon] followed by gText_PkmnsNickname ("'s nickname?"),
which fits for every species (worst case 123px).
- Naming.monTitle() is now the single source of that string, used by
both the catch flow and special:158.
- Naming.draw clamps the title to L.titleMaxW, mirroring the GBA's
per-window glyph clip, so an over-long name can never escape the frame
(the default 240px maxWidth is wider than the box itself).
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The mon branch of drawPlayerIcon reused the player overworld sprite's
16x32 box to letterbox the icon, so a 32x32 Pokemon.icon came out at
scale 0.5 -- half size inside the frame baked into bg.png. The 64x64
frontPic fallback was worse still, at 0.25.
Give the mon its own box in the layout table (pret naming_screen.c:1422
CreateMonIcon(species, SpriteCallbackDummy, 56, 40) draws it unscaled on
that centre), so the icon fills the frame 1:1 and the fallback shrinks to
fit the same box. Also pin the frame to quads[0]: pret's dummy callback
leaves the icon on its first frame, not the 1px-shifted second pose.
The player/rival overworld slot keeps its 16x32 box untouched.
Fixes#2319
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The Gen 3 extractor emitted eggCycles/eggGroups but never egg moves, so the
gen3_dexnav hidden-mon roll was inert on FireRed: a hidden mon kept its
level-up moves and could never gain an egg move.
Decode the ROM's own gEggMoves table (pokefirered/src/data/pokemon/
egg_moves.h) into pokemon/egg_moves.lua and expose it the same way as
learnsets/tmhm:
- Versions: EGG_MOVES = 0x25EF0C plus the 20000 species offset, the 0xFFFF
run terminator and a defensive per-species cap. The table is one flat u16
stream of `{ species + 20000, move..., 0xFFFF }` runs with no final
terminator, so the first word that is neither a header nor a plausible move
id ends the scan.
- PokemonExtract: extract_egg_moves + write_egg_moves_lua, written to the
cache by run(), required by ready() so a stale cache re-extracts, and
returned as pack.eggMoves. FORMAT_VERSION 3 -> 4.
- Pokemon.eggMoves(species): runtime accessor with the same species coercion
as Pokemon.learnset; nil for a species with no egg move.
- Schemas: eggMoves on monTables/monRecord/writeMon and the Gen 3 species
field list, so a mod reads record.eggMoves as move ids and writes names back.
- Versions.CACHE_VERSION 98 -> 99 to force re-extraction.
The table is sparse: species without egg moves are absent rather than an
empty list, all the way from the ROM to the mod record.
Validated against a supported FireRed USA 1.0 dump: 165 species, 973 moves,
<=8 per species, Bulbasaur {113,130,219,204,80,345,320,174}, Mankey
{157,193,96,68,179,251,279,265} (no Toxic), Mew nil.
Tests: new tests/engine/game3_egg_moves.lua (31 checks ROM-free, 39 with a
ROM), gate_gen3_mod_api 734/734, engine tier 587/587, modkit tier 37/37,
lint --gate clean.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
dev added the public Encounters.tableFor export (MapCatalog resolution,
prefix stripping, route normalization) at the same point in
src/core/game3/encounters.lua where this branch inserts the cooldown
block. The two changes are purely additive, so both are kept: dev's
export sits next to table_for where it belongs, followed by the
cooldown/rate-test block.
scripts/lint.sh auto-merged cleanly, keeping both dev's PATH export for
node_modules/.bin and ~/.luarocks/bin and this branch's luacheck --version
probe.
Re-verified after the merge that dev's RNG-parity commit does not shift
the encounter goldens: 88/88 cooldown checks still pass, including the
Route 1 4000-step counts (401 with the grace period vs 858 without).
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Completes the pret/pokefirered parity for wild encounter rate gating,
on top of the cooldown port in b84c2c15.
Ports DoWildEncounterRateTest as Encounters.encounterRate(): *16, bike
*80/100, banked failure rate +buff*16/200, flute, Cleanse Tag *2/3,
ability (Stench /2, Illuminate *2), then the 1600 clamp last -- pret's
exact order, which the modifier-order tests pin down.
Ports AddToWildEncounterRateBuff: a failed rate test (or a rate test
that passes but picks no slot) banks the area rate; an active Repel
zeroes the bank. Without this, encounters were still flat-rate rather
than ramping after near-misses.
Also ports the Mach/Acro bike *80/100 modifier.
End-to-end goldens on Route 1 (rate 21, 4000 steps) shift 397->401
with the cooldown and 843->858 without. That matches pret's steady
state analytically: a 6-step cooldown followed by p~0.112 as the bank
ramps gives a cycle of (1-0.94^6)/0.112+6 ~ 10.3 steps, ~10%/step.
The observed 401/4000 = 10.03% confirms it.
tests/game3_rng_test.lua now resets rate modifiers per replay, since
the banked rate makes sequences history-dependent; a map load resets
in-game.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Gen3 (FireRed) had no cooldown between wild battles, so every step on an
encounter tile rolled at the area's full rate -- Route 1 (~21%) landed a
battle on roughly one step in five. Gen2 already had this via
World:wildCooldownStep(); game3 never did.
Port pret/pokefirered wild_encounter.c:
- GetMapBaseEncounterCooldown -> Encounters.mapBaseCooldown: steps of
immunity derived from the area's own rate (rate >= 80 -> none, < 10 ->
8, else 8 - rate/10).
- HandleWildEncounterCooldown -> Encounters.handleCooldown: a soft floor,
not a hard gate -- once the minimum elapses a 5%/step leak lets a battle
through anyway. Includes the White/Black Flute, Cleanse Tag, Stench and
Illuminate modifiers in pret's application order (Cleanse Tag before the
ability mod, which changes the result).
- ResetEncounterRateModifiers -> Encounters.resetRateModifiers, wired to
the two places pret resets: map load (Map.load, including seamless
connection crossings) and battle start (BattleBridge.startWild). The
latter is the bug-#1229 class -- scripted battles and fishing re-arm the
grace period even though no step rolled.
Measured on Route 1 over 4000 steps: 843 encounters before, 397 after;
first encounter never lands before step 7.
Not ported (makes encounters more likely, so it does not affect the
reported symptom): the encounterRateBuff anti-frustration ramp and the
Mach/Acro bike rate modifier.
tests/engine/wild_encounter_cooldown.lua covers the cooldown golden table,
the counter/leak mechanics, all five modifiers, both re-arm paths and the
end-to-end step count.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Collision.installWarps force-marked every map-header warp cell walkable. That
is right for the outdoor MB_WARP_DOOR tiles extract can leave solid, but it also
opened cells that only carry a dead warp event. PalletTown_PlayersHouse_1F has
one at (3,9): the wall tile directly left of the door mat, mid 26, coll 1,
behavior 0x00 MB_NORMAL. pret never lets the player stand there, so the warp
never fires; the forced COLL_DOOR let the player walk out of the house through
the wall and land in PalletTown.
The repair now fires only when the behavior is unreadable (tileset attrs stopped
before that mid, so Collision.behavior returns nil) or is a behavior pret would
actually warp on. Collision.isWarpMetatileBehavior covers 0x60-0x6F plus 0x71,
matching field_control_avatar.c: the arrow warps (TryArrowWarp), the directional
stair warps (IsDirectionalStairWarpMetatileBehavior), and everything
IsWarpMetatileBehavior accepts (cave door, ladder, fall warp, regular warp,
Lavaridge 1F warp, warp door, escalators, union-room warp).
Warp indexing is unchanged: a dead warp on a wall is still registered, only the
grid write is gated.
tests/engine/firered_house_wall_warp_bug2297.lua is ROM-free: it embeds the pret
13x10 map.bin grid and the `building` metatile attributes, translates them with
the real ScriptingCollision.fromCell, binds the real Versions.WARPS table, and
asserts the wall stays solid, the four warp cells still behave, the repair still
applies for unreadable attrs and live warp behaviors, and an exhaustive
0x00-0xFF table pins the predicate. It fails 6 checks against the pre-fix file.
luajit tests/run_engine.lua: 585/585 suites passed.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
- Battle Animation Pipeline:
* Eliminate 90-frame audio polling stall in anim_vm end/waitsound opcodes,
allowing attack animations to transition instantly into target hit flicker
and HP bar drain.
* Correct visual task counting in AnimVm:visualCount to match pret gAnimVisualTaskCount.
* Signal bg task completion on restorebg (vm.args[7] = -1).
* Fix termination conditions on shake tasks (ShakeMon, ShakeMon2, ShakeBattleTerrain),
WaterSport droplet arcs, and StartSinAnimTimer.
* Handle RGB555 color args in start_blend_anim_sprite_color and route P.destroyTask
to AnimTasks.destroy.
- RNG System & Seeding Parity:
* Replace math.random in Party.giveMon with Rng.Random32() for personality values
and Rng.Random() for IV bitfield extraction (HP/Atk/Def/Spe/SpA/SpD) matching GBA format.
* Seed RNG on Title Screen A/Start press via Rng.seedNewGame() (matching SeedRngAndSetTrainerId)
and perturb RNG state on title menu inputs.
* Route NPC overworld idle/wander timers and wild encounter slot rolls through Rng.compat.
- Pokédex Habitat & Area Maps:
* Fix encounter tables extraction and map indexing to resolve Pokemon habitat
locations across Kanto/Sevii maps rather than showing Area Unknown.
* Render steady semi-transparent red overlays for Pokédex area map locations.
- Multichoice Menus & UI:
* Implement multichoice table extraction and data population for Viridian blackboard
status ailments and special dialog interactions.
* Improve choice box borders, pagination, and Town Map wall viewing.
- Overworld Events & Flags:
* Fix Route 1 entrance girl NPC spawning and script triggers after starter selection.
* Ensure consistent save reload state in Oak's Lab.