There was no way to ask "will this mod load on FireRed?". `validate`, `lint`
and `pack` only reason about Gen 1, and `gen2check` refuses a Gen 3 mod at the
manifest gate. `gen3check` is the Gen 3 counterpart of `gen2check`:
python3 tools/modkit.py gen3check mods/<id> [--notes] [--json] [--strict]
- Factor the shared compat analysis behind a `Generation` descriptor, with
`GEN2`/`GEN3` module-level singletons holding everything that differs: the
compat facade name, the generation's own directory (`gen2` vs `game3`), the
legacy manifest flag, the screen-twin prefix, and the sibling spelling. Every
compat helper now takes a trailing `gen`.
- MK400-MK410 stay shared by design -- they are the same questions asked of a
different facade -- and the verdict line (`on gen 3: ...`) disambiguates.
- Gen 3 sibling spelling: `src/ui/game3/` is snake_case while
`src/world/game3/WorldAPI.lua` is not, so MK403 tries both spellings and
stops at the first sibling that exists. Only 8 Gen 1 modules are reachable
this way (`BattleAPI`, `BagMenu`, `HallOfFame`, `IntroMovie`, `OptionRows`,
`ShopMenu`, `SummaryMenu`, `TrainerCard`), so MK403 on Gen 3 is live but
narrow.
- MK409's screen-twin half is switched off on Gen 3 -- there is no
`Screens.GEN2_IDS` equivalent -- while its version-string half still runs,
since a Gen 1 version id in a mod that declares only FireRed is a real bug.
- Rename `GEN2_IDS_DUMP` to `VERSION_IDS_DUMP`, now parameterized by
generation.
Gen 2 behaviour is unchanged: `diff -r` of `gen2check --notes` and
`gen2check --notes --json` output over all 10 shipped mods, captured before and
after this change, is empty.
Tests: `tests/modkit/cases/gen3check.lua` grows from 504 to 542 checks. Every
fixture is derived from the engine at run time -- the coverage table,
`GEN1_ONLY_MODULES`, and the `game3/` directory listings -- so the suite cannot
drift away from the tables it is asserting against. It covers MK400's Gen 3
wording, MK402 naming `Gen3Compat` and never `Gen2Compat`, MK403 naming the
snake_case path, MK404 quoting the member, MK409's version-string half, MK410
counted once at file scope, the `--json` verdict shape, and that no adapted
module is ever MK402.
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>
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.
GEN2_STATUS_IDS (psn/brn/frz/par/paralysis/slp -> Gen 2's own registry ids)
was declared verbatim in both PartyMenu.lua and SummaryMenu.lua; move it to
Status.GEN2_ID_ALIASES so a future status alias fix only has one copy to
update.
SummaryMenu.TYPE_NAMES was left behind after this branch switched its two
former internal uses to the shared TypeChart.displayName/DISPLAY_NAMES; it
has no remaining callers anywhere in src/ or tests/.
A Pokémon's level is printed on four Gen 1 surfaces -- both battle
healthboxes, the party rows, and both status pages -- and every one of them
prints it unconditionally. There is no seam, so a mode that wants the number
gone has two options today and both are bad: paint over the engine's own
pixels from render.hud (four rectangles, a background shade to match, and
the palette flashes and healthbox slide to survive), or monkey-patch the
render modules from inside the sandbox, which works and is exactly what
CONTRIBUTING-mods.md tells mods not to do.
The motivating case is a battle royale that scales every party to a shared
rung rising with its fog: the number is the same for everyone, it changes on
a clock, and it reads as a threat it is not -- a Lv37 opponent looks
dangerous to a player who has not worked out that their own team is Lv37
too. A randomizer keeping an encounter unreadable, a challenge run that
forbids level-checking and a blind Nuzlocke want the same switch.
New hook `pokemon.level_visible`, taking the shape the presentation
predicates on the battle screen already use -- battle.status_hud_visible,
battle.bottom_ui_visible, battle.caught_marker_visible: consulted behind
Runtime.wantsHook, default visible, only an explicit false suppresses. It is
not named battle.* because a level is not a battle-only readout, and it
carries the surface that asked (battle.enemy / battle.player / party /
summary) so a mode can hide an opponent's level and keep its own.
src/ui/LevelDisplay.lua holds the one definition of "visible", so the four
call sites are a one-line guard each rather than four copies of the same
five lines that can drift apart.
No layout moves. Each site keeps its own hand-rolled PrintLevel rule
(home/pokemon.asm:335-345), it just asks first. Two details are deliberate:
a status condition still replaces the level on a healthbox exactly as in the
cart, so hiding a level never hides PSN or BRN (the guard is an elseif on
the existing status branch); and on status page 2 the <to> arrow is hidden
with the level it points at, because an arrow with nothing after it is half
a sentence.
Gen 1 only. The Gen 2 screens and the Gen 1 PC box list -- where the level
is part of a row label rather than a drawn field -- keep their own readouts
and do not consult the hook. Both are stated as follow-ups in the RFC and
beside the hook in docs/modding.md, so a mod author reads the limit before
depending on it.
Verification: tests/modkit/cases/pokemon_level_visible.lua covers the
contract through the public mod API; gate_hooks picks the hook up on its own
because it walks the live catalog; gate_meta_coverage is satisfied by the
change that introduces the seam, so it never enters the DEBT ledger.
tests/run_modkit.lua 33/33, tests/run_engine.lua 327/331 -- the same four
audio/hostshell suites fail unchanged on dev without this branch.
Co-Authored-By: Claude <noreply@anthropic.com>
Lets a mod ask what a map's wild-encounter odds look like right now,
composed with any live encounter.table wrapper, without rolling the RNG.
encounter.roll/encounter.species can only transform one draw; this is the
missing read side. Guarded query method (mod.world:effectiveEncounters),
no changes to the real roll path, docs and a public-API modkit case.
Two decisions the OPTION screen and the cart make for the player, made
hookable so a game mode can make them instead: whether a faint offers the
SHIFT free switch, and whether a catch asks for a nickname. Guarded call
sites, file-local vanilla links, docs and a public-API modkit case.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1DVpYXGabigHqMwviDoKV
A capture the party cannot hold silently falls through to the box; the
new partyFullDestination seam lets a mode claim custody at that moment
instead, and pokemon.caught reports destination "mod" so the mode can
find the mon again. Guarded call site, file-local vanilla, docs and a
public-API modkit case.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1DVpYXGabigHqMwviDoKV
Route B in CONTRIBUTING-mods.md asks an event/hook change for five things.
This adds the two that were missing and fixes what the third turned up.
link.battle_ended built its payload unconditionally. Route B is explicit
that a new event must not allocate when nothing wants it, and every other
emit in the engine already guards -- Runtime.wants now gates this one too,
so an unsubscribed build runs the branch exactly as it did.
world.talk was handing Runtime.call a closure built fresh on every A press,
purely to have a fallthrough to pass. It is a file-local now, so an unhooked
press allocates nothing it did not allocate before.
The RFC covers motivation, the API delta with call sites, migration (nothing
changes for existing mods), and verification. The backward-compatibility
statement is in it: every item is a new name or a new optional argument, and
example_mew_starter -- api 1, category = "GAMEPLAY", whole-species copy --
still loads, which run_modkit proves on every run.
No registry or schema field is added, so gen_registry_docs has nothing to
emit for this change. Running it does show pre-existing drift in
docs/modding/reference/registries.md (timeFishGroups, an objects refinement)
from earlier Schemas.lua edits that were never regenerated; that is not this
branch's to carry, so it is left alone.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
gate_meta_coverage asks every extension point for a unit test through the
public mod API, a no-mod parity test, and docs. The seams commit shipped the
call sites and owed the rest.
Both cases drive the real engine path, and both assert the unhooked build
first, so they fail if the seam is deleted and if it changes vanilla
behaviour. world_talk stands an object on the faced cell: with no mod the A
press reaches talkTo, with a mod that owns the object it does not, and an
object the mod ignores still falls through. link_battle_ended parks a
session at the end of a battle and checks the event carries the result, the
role, and both party copies.
The parity side needed nothing. gate_hooks and gate_events walk the live
catalog, so both seams were covered structurally as soon as the call sites
existed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
DENIED_PREFIX blocked love.* and ffi.* submodule requires but had no
entry for jit, so require("jit.util") walked straight through to the
real module. jit.util is LuaJIT's own equivalent of the debug library
this file already denies by name: funcbc, funck and the rest read the
bytecode and constants of any function a chunk can reach, which is
enough to recover upvalues -- the real _G, love, io -- that the sandbox
exists to keep out of a mod's hands.
Adding "jit" to DENIED_PREFIX blocks jit.* submodule requires the same
way love.* and ffi.* already are, while leaving the bare jit global
(env.jit, handed over directly for jit.on/off/flush) and a bare
require("jit") untouched -- jit.util is not a field of that table
without its own require, so neither route was ever a way to reach it.
We already ship the DLL, but mods can't ffi-load it under the sandbox, and
the compat love.system shim wasn't passing tls* through either. So wss://
still died on stock builds. Engine loads the dialer at boot; compat forwards
those keys; clipboard/openURL stay stubbed.
Co-authored-by: Cursor <cursoragent@cursor.com>
The sandbox blocks love.system and love.filesystem, which orphans the
native step bridge (#452, #489): its one consumer can no longer call
syncHealthSteps or read steps_pending.json (#1186).
Adds a "steps" manifest permission (shown to the player like the
others) gating a mod.steps facade: available() probes the bridge
quietly, sync() forwards the async refresh, poll() hands the mod its
copy of a delivery. The engine owns the pending file -- mods never name
a path and receive only { steps, from, to }. Without the permission the
acting calls name it, following the network gate. No new events, hooks
or registries; nothing removed.
RFC 0009. Tests: tests/modkit/cases/steps_bridge.lua (no-mod cold
bridge, permissioned sync/poll, per-mod copies, contract-field
filtering, malformed-delivery drop, unpermissioned refusal, bridgeless
build).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>