endFrame composites a world override mirrored on LOVE 12, because a
pipeline's own projection pre-flips Y. ctx.drawFx puts the standing
effects into that same canvas as ordinary 2D, so the mirror inverts them
instead of righting them: a trainer's "!" draws upside down, below its
feet, on the mirrored half of the screen.
The condition is now Renderer.mirrorsWorldOverride, named because it is
not only the blit's business, and ctx.drawFx runs its block under the
matching mirror about the bound canvas. project() is left alone: it
already answers in screen rows.
A Japanese glyph advances by its width plus the window's letter spacing,
which a Latin one ignores (pokefirered/src/text.c:853): 1 for the normal
font (new_menu_helpers.c:413, the field message printer), 0 for the small
one (gFontInfos, new_menu_helpers.c:65). The engine already adds that
spacing, but only to a string carrying the {JPN} control code a ROM-
extracted one does; a mod's Japanese text has none, so its kana ran a pixel
tighter per character than on the cart. A glyph drawn from a Japanese sheet
now takes the window's spacing either way, and only once, falling back to
the message printer's spacing when the window sets none.
Name limits count characters (PLAYER_NAME_LENGTH, POKEMON_NAME_LENGTH), but
the start menu, the continue screen and the PC's hovered-mon panel cut names
with string.sub, in bytes. A kana is three bytes, so a five-kana species name
lost two characters in the PC and a Japanese player name was cut inside a
character. FrlgFont.truncate cuts by UTF-8 character, and those sites use it;
Latin names are cut as before, and an accented Latin name, whose accented
letters are two bytes, is no longer cut short either.
game3 prints every string with the US cart's Latin font, and FrlgFont has
no glyph for a kana, so a Japanese translation of FireRed would print
blank. The US cart still carries its Japanese fonts, which its text
printer draws for a string in Japanese mode (pokefirered/src/text.c:141,
:227): pret builds them into the US ROM, with the normal font's width
table.
The font extractor now bakes both, the normal font from its eight-to-a-row
layout (DecompressGlyph_Normal, text.c:1452) with its widths and the small
one sixteen to a row (DecompressGlyph_Small, text.c:1372), as
chrome/fonts/japanese_{normal,small}_{fg,shadow}.rgba and
japanese_widths.lua. LeafGreen's addresses follow the same -0x24 shift as
the rest of text.o. Cache version 120 re-imports existing caches.
FrlgFont numbers a Japanese character by its byte in the Japanese block of
pokefirered/charmap.txt (kana 01-A0, the full-width space, !?。ー), plus
the symbols the font keeps at the Latin block's codes in their full-width
forms (digits, letters, 『』「」, 円, /, :), offset past the Latin
glyphs. A character with no Latin glyph draws from the Japanese sheet, at
the width the cart gives it (the table for the normal font, 8px for the
small one). Latin text is drawn as before.
- Preserve active track voices in Player.stopAt after fast-forward catchup
- Add start-from-0 fallback in Player.stopAt when at timestamp predates the retained snaps ring
- Mute m4a worker during stopAt pause so it does not synthesize empty buffers while fanfare plays
- Ensure Audio.resumeBgm calls Source:play on bgmSource if stopped
Second batch of the Gen 3 review work: the review lanes closed their queues
and the test gate grew with them.
Review census: 139 routed findings -- 118 fixed, 14 invalid (the report had
Gen 4+ semantics in more than one place), 6 struck as stale after
re-verification, 1 re-routed. Every fix carries its own pret citation in
docs/game3/review-v3-triage.md; the E10 opcode closure is itemised in
docs/game3/e10-opcode-spec.md (18 ops wired, 2 reclassified to the seam class).
Representative ROM-grounded changes:
- The POKéMON start-menu entry is now gated on FLAG_SYS_POKEMON_GET the way
retail does it (pokefirered/src/start_menu.c:217-218, flag 0x828).
- Money ops read their amount raw and gate the change on the disable byte
(pokefirered/src/scrcmd.c:1798-1830, pokefirered/asm/macros/event.inc:1166-1186);
random and the warp family VarGet theirs
(pokefirered/src/scrcmd.c:455-461, :719-731).
- The HM table matches FRLG: there is no Whirlpool HM
(pokefirered/include/constants/items.h:411-418).
- The day-care party-full guard follows src/daycare.c:525, :1081.
- Knock Off keeps its battle-scoped send-out mask
(pokefirered/src/battle_script_commands.c:2730-2752, :4489), carried from
the first batch.
Structural work: the I6/I9/J1 architecture items landed as seams (profile
selector, capability flags, font provider, virtual-object layer), the adopted
footprint register keeps a KEEP verdict, the quantizer target is met, and the
30 drain items plus carves 1-4 are folded in.
Tests: T6 now runs 282 top-level suites (273 + 9 new), the re-sweep ends at
267 pass / 0 fail, the engine tier is 634 suites, modkit 37, and the full
gate ran green twice with T3 active on an imported Red cache. Documented
skips are the lua5.4 oversize-save oracle where lua5.4 is absent and the
config-partials listed in docs/game3/game3-artifact-conversions-v3.md.
Docs shipped: review-v3-triage.md, e10-opcode-spec.md, rse-seams.md,
game3-suite-sweep-v113.md, game3-artifact-conversions-v3.md,
test-baseline-v3.md (plus the first-batch docs already on the branch).
Braille.encode spells a braille message from Latin letters, through
Braille.CODE and the Latin charmap byte of any other character. That
covers the US cart, but the European carts' braille lines use cells no
character reaches: German ä (dots 3-4-5), the full cell, dot 5 alone. A
translation mod therefore cannot hand over the German, Spanish or Italian
braille, and the 39 lines of the Dotted Hole, Ruin Valley and Mt. Ember
stay in English in every language.
pret's braille font holds every dot combination
(pokefirered/include/characters.h:282), so Braille.encode now draws a
Unicode braille character (U+2800-U+283F) as that very cell, mapping
Unicode's dot bits to the cart's cell numbering (dot 1 = 0x01, 4 = 0x02,
2 = 0x04, 5 = 0x08, 3 = 0x10, 6 = 0x20). A cell spells its own number sign,
as the carts' braille does. Latin text is spelled as before.
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.
Second wave of fixes from the Gen 3 codebase review (210 findings), the
regressions the full-suite sweep surfaced, and a T6 tier so the top-level
tests/game3_*.lua suites run inside ./scripts/test.sh instead of outside it.
Battle: Knock Off and Thief/Trick item persistence follow the ROM, not the
review - the party slot is never written through during battle, and the
knocked-off mask suppresses the item on later send-outs
(pokefirered/src/battle_script_commands.c:2731 MOVE_EFFECT_KNOCK_OFF with the
STICKY_HOLD guard at :2732, opponent-steal guard at :2610-2622, mask use at
:4489). Rapid Spin keeps its one-per-use chain order (:8435-8474); Growl
keeps pret's target (src/data/battle_moves.h, include/battle.h:63).
Scripting: operand layouts match pret/asm/macros/event.inc - givemon 15 bytes
(:989-997), comparestat {B,W} (:1573-1576), setptr/loadbytefromptr/setptrbyte
take a word pointer (:118-137). 18 of the 20 E10 ops are wired per
src/scrcmd.c (per-op citations in docs/game3/e10-opcode-spec.md), money and
random and the warp family read their operands as VarGets
(src/scrcmd.c:1798-1830, :455-461, :719-731), and the day-care party-full
guard is in place (src/daycare.c:525,:1081).
Field and UI: fishing counts rounds as the ROM does
(src/field_player_avatar.c:1740-1765), an unresolved map section no longer
reports Pallet Town (src/region_map.c:3782), and a font provider sits behind
a FireRed capability/profile seam.
Save data: profile-driven Options.block, a save version round-trip, and slot
id validation that blocks path traversal (slotDiskPath("firered", "../evil")
returns nil).
Importer: the parallel import path now writes both completion markers,
object kind/clone bytes decode as pret defines them
(include/global.fieldmap.h:110-130), and the HM table drops Whirlpool
(include/constants/items.h:411-418).
Tests: six new scenario suites (battle_ai, capture, event, menu, move,
overworld) and eight engine suites (profile, capabilities, cache paths, font
provider, options block, save version round-trip, version dispatch,
versions_game). T6 runs every top-level game3 suite at GAME3_JOBS default 8
with per-suite logs, failure cause classification and a KNOWN_GAME3_FAILURES
ceiling of 23 as a shrinking guard.
Docs: the working notes under docs/game3 (triage ledger, pret citation audit,
e10 opcode spec, RSE seams, sweep v3/v113, test baseline, artifact
conversions, merge trial).
Full gate at this snapshot: exit 0, all tiers passed - engine 624/624,
T6 279/279 (0 known, 0 fresh failures), gen2 146/146, modkit 37/37, luacheck
clean, privacy gate 3/3 over a 9,711-file publication set.
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).
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.
mod.content.encounters:patch on a Gold boot keys by encounter KIND
(encounters.grass.ROUTE_29), but the id space was open: a key the
catalog did not describe was treated as a mod's own data and merged
as-is. A Gen 1 encounters mod ported unchanged writes the MAP where
Gold wants the kind, so the call was accepted, merged into
data.gen2Encounters.ROUTE_29 and read by nothing -- vanilla game, no
error, nothing in the Mod Manager (#2369).
That key cannot be a mod's own data the way an extra palette id can:
src/battle/gen2/Encounter.lua, src/core/gen2/Roamers.lua,
src/core/gen2/BugContest.lua and src/world/gen2/World.lua read this
table by name and the set of names is fixed, so an unknown id is a
write nothing reads. Add an opt-in `keysClosed` shape slot (folded by
Schemas.shapeFor like the other gen2* slots) that Schemas.check honours
in its keys/keyValue branch, and set it on encounters' Gen 2 spec. An
unknown id now names the ids that do exist instead of silently
no-opping; api 2 fails the mod, api 1 keeps the warning.
Also catalogue roamMons, which the extractor has always emitted
(RomExtractorGen2:readRoamMons) and Roamers.roster has always read, but
which was missing from gen2Keys.
Only encounters opts in: palettes, battle_anims and constants keep
their open id spaces, and Red's encounters id space is untouched.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>