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.
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>
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 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>
RFC 0021 gave a mode back the bag on the link cable: opts.items makes an
item the turn, and `{ type = "action", kind = "item", item, index, move }`
rides the wire so the peer -- and a spectator -- can apply the same effect
to their copies before the moves. The peer never got the item.
Wire.sanitize rebuilds every inbound message field by field from
SCHEMAS[type] and drops anything the schema does not name. SCHEMAS.action
names kind, slot and index; `item` and `move` are not among them, so they
were stripped on the way in. Added beside the others, clamped the same
way (a string id, a move slot in 1..MAX_MOVES). SCHEMAS.spectate calls
the same sanitize, so a spectator is fixed by the same line.
Why the tests did not see it. tests/engine/link_items.lua -- and every
in-process link test -- pairs the two sides with Net.loopbackPair, which
hands the table straight over; sanitize only runs on the way through
Session, which is every REAL transport. So the failure needed two
machines to show up, and when it did it was the quietest kind: the peer
receives an item action with no item in it, LinkItems.apply returns
nothing, the turn is still spent, nothing is printed, and the two
simulations part by one heal with nothing on either screen to say so --
until a hash several turns later blames the wrong turn. Found by a
downstream mod's two-client harness on its first real duel: the guest
healed 12 -> 18 and the host watched its copy of that mon go 12 -> 6.
link_items.lua now pins the schema directly (an item action through
sanitize, through a spectate wrapper, and a move action carrying no
item), which is a check a loopback pair cannot make. docs/rfcs/0021
gains the section saying a new field on an existing message type is
invisible until SCHEMAS knows about it.
tests/run_link_tests.lua green; the engine tier matches stock's reds
(572/584, the same twelve).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A link battle runs under cable rules and LinkBattle keeps them: openItems
prints "Items can't be used in a link battle!" and the wire knows move,
struggle, locked, switch and RUN. Right for the Cable Club, wrong for a
mode that is not the Cable Club -- a battle royale played over
LinkState.newFromSession fights with real, damaged parties, and the
potions and X items on the ground are its whole economy; against a bot
the bag works, against a person it says no. The mode cannot fix it:
submit, resolveLockstep and the decoder are closures, and there is no
action that means "nothing" to spend the turn on.
opts.items = true on newHost/newGuest (off by default, set on both
machines like turnLimit): the bag is BattleState.openItems -- the vanilla
BagMenu against this battle, whose picker already offers the clamped
copies -- and the effect is ItemEffects.use as in any fight. What
changes is what spending the turn means: itemUsed puts { kind = "item",
item, index, move } on the wire as the turn's action instead of running
the AI's reply. Both machines and a spectator resolve it before switches
and moves: the user's side is already applied (the bag did it), the other
side applies the same effect to its own copies of that side through
src/link/LinkItems.lua -- ItemEffects.use behind a battle whose player is
the user's battler and a save whose party is the user's copies, nothing
consumed -- and prints "<name> used <ITEM>!" plus the effect's lines.
Every effect reachable in a battle is deterministic, so the per-turn
hash still agrees. BagMenu hands itemUsed the item, target and move it
used; a local battle ignores them. A ball on the cable is refused
(ItemEffects), the way the doll is in any trainer battle.
tests/engine/link_items.lua: cable rules by default; opted in, the host's
POTION heals the guest's copy before the moves, both print the lines,
one turn, hashes agree; a spectator fed the same two messages heals its
copy too. docs/modding.md and docs/rfcs/0021-link-battle-items.md.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- Align CacheContract firered overrides with pure ROM extraction
- Fix door animation extraction tile sizes, palettes, and strides
- Update storage chrome, pokedex, summary, and battle chrome extractors
- Ensure 100% self-contained ROM extraction without external dependencies
Kanto Companion's Edit Mode can't run during battle -- there's no way
to stop a D-pad press from also driving the battle menu, so the only
option today is pausing the game outright. This adds the same input
precedence a mod had before the sandbox changes: vanilla is the whole
existing callback body, and the hook fires before any of it runs.
input.wheel stays a plain observer like input.pointer, since nothing
depends on suppressing it.