Trainers.info gave a trainer the player's chosen rival name only when its
class was called "RIVAL". A mod that translates the class (RIVALE in
German and Italian) made the rival battle as the ROM's placeholder TERRY,
and the champion, whose class is CHAMPION, was always TERRY. pret's
B_TXT_TRAINER1_NAME names TRAINER_CLASS_RIVAL_EARLY, _RIVAL_LATE and
_CHAMPION by the rival's name (pokefirered/src/battle_message.c:2078);
Trainers.info now does the same, by class id.
The party menu drew an egg's slot like any other mon's: level, gender, HP
numbers and HP bar. pret's DisplayPartyPokemonData prints only the nickname
for an egg and blits the slot without its HP frame (sSlotTilemap_MainNoHP /
WideNoHP; pokefirered/src/party_menu.c:781, :2197), and
DisplayPartyPokemonDescriptionData likewise skips the level and gender of an
egg (party_menu.c:804). The slot now does the same, reusing the no-HP frame
the description slots already draw.
Three screens that show a single mon still drew an egg as the species it
will hatch into, and printed its stored nickname untranslated:
- The PC's hovered-mon panel drew the hatch species' front pic, the stored
"EGG" nickname, "/PICHU", gender, level and held item. pret draws the
SPECIES_OR_EGG pic and, for an egg, prints only gText_EggNickname with the
other lines blank (pokemon_storage_system_data.c:1034, :1091).
- The summary's egg page drew the hatch species' front pic, where pret uses
SPECIES_OR_EGG (pokemon_summary_screen.c:4016). It also recognised an egg by
mon.isEgg alone; it now uses Pokemon.isEgg like the rest of the engine.
- The trade scene drew a traded egg as its species and named it by the stored
nickname (trade_scene.c:757, :1239).
The script string buffers (BufferMonNickname and the other nicknameOf
helpers) now also give the language's EGG for an egg, as GetMonData
(MON_DATA_NICKNAME) does (pokemon.c:3020).
The party, the PC boxes and the release animation drew an egg with the icon
of the species it will hatch into, so a Pichu egg showed as a Pichu. pret's
party menu picks the icon from GetMonData(MON_DATA_SPECIES_OR_EGG), which is
SPECIES_EGG for any egg (pokefirered/src/pokemon.c:3245, party_menu.c:2655).
Pokemon.speciesOrEgg does the same, and the icon call sites use it. The
species pass only extracted icons up to NUM_SPECIES - 1, so the egg extractor
now also bakes gMonIconTable[SPECIES_EGG] as pokemon/icons/412.rgba. Cache
version 114 re-imports existing caches so the icon appears.
Party.giveMon stores "EGG" as a new egg's nickname (party.lua for a
gift egg, breeding.lua for the Day-Care's), and the party slots and the
other screens that name a mon through Pokemon.displayName and
displayMonName print that nickname as it is. A translated game therefore
listed its eggs as EGG, while the summary, which prints Strings("EGG")
for an egg, already said OEUF, EI, HUEVO or UOVO.
The cart does not rely on the stored nickname either: its Day-Care
writes the Japanese タマゴ there (daycare.c:1100), and GetMonData
returns gText_EggNickname for any egg's nickname (pokemon.c:3020). Do the
same in both name helpers: an egg reads as Strings("EGG"), whatever its
nickname holds, which also covers the eggs saves already carry. A
hatched egg, whose nickname is cleared, goes back to its species name.
The bufferitemnameplural checks asserted hard-coded item names, but those
come from data/generated/gba/items/pack.lua, which does not exist in a
ROM-free checkout. CI failed 2/70 there ("got BERRIES"), while a machine
with an imported pack passed, so the suite was green locally and red in CI.
Assert the pluralisation rule against each item's own singular name
(ItemsData.displayName) instead: a Poké Ball stack gains "S", a berry stack
has its final letter replaced by "IES", and quantity 1 stays singular.
72/72 with and without the pack; the full engine tier is 616/616 with no
ROM data.
The rest of the Gen 3 review fixes, each with a gated suite in tests/engine/.
ROM semantics were checked against pret/pokefirered.
Scripts
- givemon carried the wrong operand layout, found earlier; four more layout
desyncs came out of pret asm/macros/event.inc: comparestat is {byte,word},
and setptr / loadbytefromptr / setptrbyte each carry a leading byte plus a
word. A wrong size mis-decodes every instruction after the bad one, so
Versions.CACHE_VERSION moves to 113 and existing caches re-import.
- handlers for previously handler-less verbs: comparestat,
bufferitemnameplural, setmonmove, setmonmetlocation, the modern
fateful-encounter pair, the script-locals family (copylocal, setptr,
loadbytefromptr, setptrbyte, copybyte, compare_local_to_* and
compare_ptr_to_*), the RAM-script family (setvaddress, vgoto, vcall,
vgoto_if, vcall_if, vmessage, vbuffermessage, vbufferstring, endram,
returnram) and the same-map forms of the *at verbs.
- setdooropen / setdoorclosed read their coordinates through VarGet.
Battles
- Knock Off and Thief / Trick persist the item change instead of only
touching the in-battle copy.
- knocked-off party slots are tracked in a bitmask, so a slot reused later
does not inherit the flag.
Field and UI
- Player.reset restores facing and clears the surf flags.
- a definition-less Map.load no longer leaves collision unbound.
- an unresolved region-map section no longer reports PALLET TOWN.
- the naming screen splits input from the timer, so update(dt) stops
indexing a number.
- the hall of fame commits through the engine save path and serializes its
fields.
Persistence
- gameStats, the link-battle records and the trainer card are serialized.
- the PC deposit refuses at the 999 cap instead of destroying the overflow.
Robustness
- Data.load runs cached modules sandboxed.
- the file browser quotes shell arguments.
- .meta dimensions are bounds-checked and mids.idx validates its header.
do_save() discarded both pcall results and set _phase = "saved" unconditionally,
so a refused or failed save still showed "<player> saved the game." and closed the
menu. Only a truthy saveGame now reports success; a raised error, an explicit
false, or a missing saveGame enters a new save_failed phase, logs the reason via
Logger, and shows "The game could not be saved."
Test: tests/engine/game3_save_menu_failure_test.lua (8/10 fail on the previous
code, 10/10 pass here).
- Save Editor: Implement full Gen 3 FireRed save editing including PID/personality, IVs/EVs, natures, ability slots, gender, shiny toggles, held items, Pokédex flags, and event/trainer toggles.
- Scripting & Specials: Add Pokédex rating evaluations, Magikarp/Heracross size records, Trainer Fan Club tracking, and FireRed event flag mappings.
- Overworld & Rendering: Interleave metatile and actor draw calls by row bucket with OAM elevation priority sorting for bridges and overhead layers.
- ROM Extractor: Parallelize Gen 3 extraction across 4 concurrent love.thread workers with love.filesystem searchers for cross-platform support (Switch/Xbox/Mobile/Desktop).
- LZ77 Decompressor: Accelerate GBA LZ77 with preallocated FFI buffers and 0-based displacement indexing ensuring 100% byte-for-byte asset parity.
- Testing: Add unit test suites for LZ77, size records, Pokédex rating, Fan Club, elevation priority, Seafoam puzzles, and Gen 3 save editor operations.
Only src/import/gba/versions.lua conflicted, and not on the value: both
branches had independently taken CACHE_VERSION 111 for unrelated cache
layouts. dev's 111 is the hidden-item bitfield fix in
extract_map_events.lua (id % 256, qty % 128); this branch's 111 is the new
deoxys_rock_fragments field effect. Sharing one number would let a cache
stamped by dev be treated as fresh, silently dropping the meteorite
shatter artwork, so the Deoxys artwork moves to 112.
src/core/game3/field_view.lua auto-merged: dev's Doors.draw viewport
arguments and this branch's bg_flash veil do not overlap.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Two Birth Island visuals were broken; both are now verified on screen.
1. The meteorite never reddened as the triangle puzzle progressed.
OwSprites.recolour_sprite called Image:newImageData, which LOVE 11 does
not have, so the pcall always failed and setObjectPalette silently
returned false for all ten steps. Keep the ImageData the sheet was
decoded from in load_one and clone() it before mapPixel (which mutates
in place); the override copy keeps its imageData in step too.
2. The shatter happened, but the shards were invisible. The flash was a
whole-screen Renderer.screenVeil painted after the composite, and the
shards only live ~8 frames, so they were drawn underneath it. pret
(field_effect.c:3946) blends PALETTES_BG only -- BlendPalettes plus
BeginNormalPaletteFade -- so the OBJ shards, which share the
meteorite's palette tag 4371, keep their colours against a white map.
Replace startWhiteFlash with startBgFlash, drawn by FieldView.draw
between the last map layer and the actors. Also match pret's shake
cadence (flip every second frame) and its `++tTimer > 120` test.
Adds tests/drivers/game3_deoxys_visual.lua, which walks all eleven puzzle
positions, shatters the rock, and counts shard-coloured pixels in the
saved frames: 492 px at the shatter frame, 192 px a few frames later
(0 before this fix).
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Two Birth Island visuals were missing against pokefirered.
The rock never reddened as the puzzle progressed. pret loads
sDeoxysObjectPals[num] into the rock's OBJ palette (field_specials.c:2398);
the engine can only substitute colours into the already-baked sprite, and the
ramp it matched against held the .pal ASCII values (32/82/139) while the ROM
bakes 33/82/140, so almost every pixel missed the lookup. ROCK_PALS now holds
the 8-bit expansion of the ROM ramp at 0x3F6206 (verified byte-exact against
all 11 palettes), sourcePalette() supplies the pristine colours the meteorite
is baked with, and recolour_sprite falls back to the nearest source colour
within a small tolerance so a one-unit rounding difference can never silently
kill the swap again. A swap that matches nothing now returns nil and says so.
The rock vanished instead of shattering. FLDEFF_DESTROY_DEOXYS_ROCK already
ran the camera shake and thunder, but the fragment stage had no artwork and
moved the shards wrongly. Added the four 8x8 tiles from ROM 0x3CBDB0 with
palette 0x3F6346 (ramp step 10, the awakened red pret inherits from the rock);
decoding them through the field-effect extractor reproduces the original
graphics exactly, so no nibble handling was needed. The shards now all spawn
at the rock's own corner and travel +/-16 x / +/-12 y per frame with no
gravity (field_effect.c:3993), dropping out once they leave the viewport, while
the shake decays from amplitude 4 and the rock is removed.
Cache version 111 forces existing caches to re-import so the new sheet appears.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Ports pokefirered's Birth Island Deoxys puzzle and its battle in full.
Puzzle (src/core/game3/deoxys.lua), from src/field_specials.c:2319-2456:
- Task_DoDeoxysTriangleInteraction with the 11 rock positions, the 10
per-step caps and the pre-reset step-counter read that pret uses.
- IncrementBirthIslandRockStepCount wired into StepEvents.onStepTaken.
- MoveDeoxysObject / SetDeoxysTrianglePalette palette swaps, realised via
a new OwSprites.setObjectPalette colour substitution because palettes
are baked to RGBA at extract time.
- FLDEFF_MOVE_DEOXYS_ROCK (67) and FLDEFF_DESTROY_DEOXYS_ROCK (68), plus
setfieldeffectargument plumbing and the rock-move lerp / rock-shatter
field effects.
- DoDeoxysTriangleInteraction = 0x1AB added to Std.SPECIAL, with real
handlers for it and SetDeoxysTrianglePalette.
Battle: seteventmon SPECIES_DEOXYS, 30 expands to CreateEnemyEventMon,
which the engine already implements, so the encounter flows through
Encounters._pendingWild into StartLegendaryBattle. No new code needed
there; tests now pin the whole chain.
Fixes the Deoxys battle theme. Both battle-BGM sites gated on species
386, but FRLG's SPECIES_DEOXYS is 410 (species.h:419) and 386 is
SPECIES_VOLBEAT (species.h:395), so Deoxys silently fell through to the
generic wild theme. Extracted Audio.legendaryBattleSong as the single
source of truth, keyed by the FRLG internal species id, matching the
switch in src/battle_setup.c:349.
Verification: 24/24 Deoxys groups, game3_special_events_test,
engine tier 593/593, luacheck 0/0 in 970 files, plus the hand-run
game3 battle/field-effect/object/step suites.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Four of the 22 Easy Chat groups carry no text of their own: POKéMON,
POKéMON (NAT), MOVE 1 and MOVE 2 are lists of species and move ids, and
pret prints them through gSpeciesNames and gMoveNames. The extractor
resolves them the same way, so easy_chat_data.lua ended up holding 807
copies of names the dataset already has -- copies that go stale the
moment anything renames a species or a move. A mod that renames them,
a translation filling the species_names and move_names catalogs first
of all, changed every screen but this one: the picker still offered
the cart's English names, and the profile read back in them.
Resolve those two kinds of word at draw time instead, against the same
Pokemon.name / Pokemon.moveName the party screen and the Pokédex use.
When the dataset has no entry for an id it answers with a placeholder
("POKéMON 063", "-------"), which is worse than the word the cart
shipped, so that case keeps the extracted text. An entry that names
the group ("easyChat.POKéMON|PIKACHU") still wins over both, since a
species name may need a wording other screens do not use. A bare-key
entry does not: bare keys are shared with menu labels this engine
already translates (CUT, FLASH, STRENGTH), and one of those should not
decide what a move is called here.
tools/modkit.py keeps emitting those 807 rows, since the species and
move catalogs it writes come from the Gen 1 dataset and do not list a
Hoenn species or a Gen 3 move; its note now says an empty entry shows
the game's own name, so a translator only fills the ones nothing else
renames.
The Easy Chat screen picks its words from src/core/game3/easy_chat_data.lua, the ROM's own word tables as the extractor writes them: 1,813 English words across 22 groups, plus the group names the picker lists. It drew them straight from that table, so every word the player composes with stayed English, and the screen's own prompts -- routed through Strings() already -- sat in a frame of untranslated vocabulary.
src/core/game3/easy_chat_text.lua is the seam. The data module stays exactly what the extractor produced, because it is regenerated from the ROM and anything written into it would be lost on the next import; the words are translated where they are drawn instead, the same split the rest of game3 uses for extracted text. Five sites go through it: the group list, its header, the word list, the word sitting in a slot, and the saved profile a message box prints (ShowEasyChatMessage). The ids the save file stores are untouched.
Each word carries its group's context ("easyChat.FEELINGS"), because the same English word means different things in different groups and 155 of them collide with labels this engine already translates elsewhere (ATTACK, BAG, CANCEL, BACK...). Strings() falls back to the plain key, so a catalog that does not need the distinction lands with one entry -- with the consequence that a mod which already translates BAG for the menus now sees that wording in the Easy Chat list too, until it adds a context-specific entry.
A seam nothing can discover is not much use: tools/modkit.py's catalog generator emits these keys too. The literal harvester cannot see them (the picker looks each word up at draw time, not at a Strings("...") call site), so the dataset dump walks EasyChatData.GROUPS and writes 1,835 rows into lang/strings.lua, where a translator finds them beside the rest of the engine text.
Each word is clipped to the box it sits in: in the picker, the red selection rectangle; in the phrase frame, the slot's own frame. The cart's words fit those boxes (72 px at most); a longer translation is cut at the box edge instead of running past the highlight, into the next slot's cursor, or off screen. Group names get the room up to the scroll arrows instead, because an official translation already needs more than the rectangle: the French cart's VIE QUOTIDIEN. is 83 px.
tests/engine/game3_easy_chat_text_test.lua covers the context lookup, the fallback to a plain key, the untouched English with no catalog, and that the extracted data itself is never rewritten. It lives in tests/engine so the T1/T2 tier actually runs it.
The summary's MOVES page drew each move name with a fixed 64 px limit.
pret prints the names at x 3 of POKESUM_WIN_MOVES_3, a 10-tile window
that starts at tile 20 (pokemon_summary_screen.c:857, :2543), so a name
has up to the right edge of the screen: 77 px from the pen at 163. The
cart's own longest names are 72 px -- SKY UPPERCUT, FRENZY PLANT, HYDRO
CANNON -- and lost their last letters, as did translated names that the
cart's window would hold (RUGISSEMENT for GROWL).
Clip to the window's right edge instead, measured from wherever the
chrome manifest puts the pen.
A FireRed mod's pokemon and moves patches are merged onto the live
src.core.game3.pokemon module while the mods load (Game3:_loadMods).
Entering FireRed then runs Pokemon.install() from Runtime, which swaps
every species table for a fresh copy of the ROM pack -- names, stats,
types, learnsets, and the move names that live in the same pack. The
only reload hook, in Gen3Compat.applyMerged, re-seeded the sprites and
nothing else, so every such patch was gone before the first frame: a
translation's species_names and move_names catalogs never reached the
party screen, the summary or a battle, and a mod's base stats reverted
to the ROM's.
Write the moves and pokemon registries again onto the new tables from
that hook, the way reapplyMoves already does for the moves module. The
moves registry is part of it because its names and the battle-move copy
it mirrors into belong to the reloaded pack, and it goes first, in the
order Loader:_mergeOrder uses: the moves writer is what adds a mod's own
moves to the move index, and the species writer resolves learnsets, egg
moves and TM/HM lists through that index.
The word picker pages eight words at a time and its navigation moves through four rows of two: `row < 3` when moving down, `3 * 2 + col + 1` when stepping up into the previous page. The draw loop rendered three rows, so the seventh and eighth word of every page were selectable -- the cursor sat on them, and pressing A picked them -- while nothing was drawn there. A group's last words could be chosen only blind.
Eight is the cart's own page: easy_chat_2.c scrolls selectWordRowsAbove by four, and easy_chat_3.c's PrintECRowsWin2 prints row * 16 + 96 for each row. The loop now draws four, starting 2 px higher so the fourth row's 14 px of glyphs stop exactly at the bottom of the 78 px frame.
tests/engine/game3_easy_chat_page_test.lua drives the screen with a group larger than one page and checks all eight words reach the font, inside the frame. It reports the two missing words before this change.
1. The Cerulean City Policeman remained in front of the burgled house at `(30, 12)` even after receiving the S.S. Ticket from Bill.
2. Bill remained standing inside the cell separator teleporter door at `(3, 3)` upon returning to Sea Cottage after helping him.
- State.new and Battle.start now default the initial active battler slot to the first usable (non-egg, HP > 0) Pokémon in the party instead of unconditionally selecting slot 1.
- In double battles, slots 0 and 2 correctly select the first and second conscious Pokémon.
- Add regression test in tests/game3_battle_fainted_lead_test.lua verifying single, double, and party-order behavior when the lead slot is fainted.
- Doors:
- Fix Viridian City Gym and Dojo warp doors registering as wooden house doors; map metatile 0x15B to SlidingDouble animation and SE_SLIDING_DOOR (18).
- Embed full FRLG metatile fallback lookup table and support CacheFs/Dataset.cache() for door textures and manifests.
- Scripting & Movement (Fixes#2356):
- Add support for jump movement opcodes (jump_2_down, jump_in_place, etc.) for both player and NPC objects.
- Implement scriptJump routines updating cell coords and triggering jump trajectory animations.
- CacheFS & GBA ROM Extraction:
- Wire CacheFS persistence across storage, region map, battle AI/moves, items, pokedex chrome, multichoice, and easy chat extractors for Android portability.
- Fix box storage chrome extraction palette/shadow colors and duplicate label bug.
- Add region map chrome extraction and interactive Town Map rendering.
- Performance & Android Optimization:
- Eliminate dynamic per-frame require/pcall lookups in battle render/update loops.
- Pre-allocate particle sort comparator in AnimVm to avoid per-frame closures.
- Cache palette shader uniform uploads and blend state in AnimPal/AnimVm.
- Pool scratch color tables in FrlgFont to remove garbage generation during text token scanning.
- Harden audio FFI caching and m4a worker initialization for sandboxed environments.
- Tests:
- Add test suites for Viridian Gym doors, jump opcodes, storage chrome, and town map caching.
fixes#2356fixes#2357
The box above the menu during a Bug-Catching Contest (pokecrystal ContestStatus) drew its three labels and the empty-slot placeholder as bare literals: CAUGHT, LEVEL, BALLS: and "None". They were the last raw player-facing sinks in this file, which tests/gen2_ui_translation_harvest_test.py already lists among the modules that should have none.
The values keep the cart's own columns (x=8, 7, 8) only while the label fits them: "ATTRAPÉ" fills the seven tiles CAUGHT leaves, and "GEFANGEN" or "ATRAPADO" one more, so each value starts after its label instead of being drawn over by it.
"None" carries a context: the bare key is also the import wizard's "no limit" label (src/import/online/Wizard.lua), and one catalog entry cannot serve both -- which is also why the harvest gate asks for the three labels and not for it.
The caught mon's own name is left alone: it is a nickname, or a species the `pokemon` registry renames.
The Bug-Catching Contest confirmation was drawn as two bare literals, one per row, with no hook a mod could reach. It becomes one catalog key, "Would you like to\nend the Contest?", the way every other confirmation prompt in this port is written (src/ui/StartMenu.lua, src/ui/gen2/SaveMenu.lua): "Would you like to" alone has no stable translation out of context, and the cart writes it as one text (data/text/common_2.asm).
Both prompts in this file now share one helper. It draws with Chrome.printWrapped, which wraps on the translated line break AND on pixel width, and reads the line count back from it to warn -- once per prompt -- when a translation needs a third row the box cannot hold. The title-screen prompt went through a hand-rolled splitter on the first line break, which handed Font.draw an embedded break, rendered as a space: a translation needing three rows printed two of them joined and ran past the box.
The official wordings fit: "Voulez-vous arrê- / ter le concours?" and "Möchtest du den / Wettkampf beenden?".
tests/engine/gen2_contest_coins_translation_test.lua drives StartMenu's confirmContest arm and StartGameCornerGame's no-coins branch with a mod-loaded catalog and checks the translated wording reaches the screen, plus the English case. On dev the three prompt checks fail; the refusal's own check passes there too, because that lookup already ran at runtime -- what dev was missing is the key, not the call.
StartGameCornerGame prints its refusal through Strings(NO_COINS_TEXT) already, but the literal sat behind a plain local, and catalog generation harvests the literals it can see at a Strings(...) / Strings.source(...) call site (tools/modkit.py's STRINGS_CALL). A literal hidden behind a variable is not one, so the key never landed in a generated catalog and the lookup had nothing to find.
Its neighbour one line below, NO_COIN_CASE_TEXT, already used Strings.source and did reach the catalog; this was the odd one out. Both are now in the harvest gate's REQUIRED set, which reports them as unharvested before this change.
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.