Skip to content

Guide Portable Mods

bryanthaboi edited this page Oct 20, 2026 · 1 revision

Portable mods across Gen 1, 2 and 3

One mod, one entry file, running on Red/Blue/Yellow, Gold/Silver/Crystal and FireRed/LeafGreen/Ruby/Sapphire/Emerald. This is mod API 3. It is opt-in: an api 1 or 2 mod behaves exactly as before. For the model behind it, see Portable API 3; this page is the how-to.

Quick start

Set "api": 3 and list the games you have tested:

{
  "id": "portable_example",
  "name": "Portable Example",
  "version": "1.0.0",
  "api": 3,
  "games": ["gen1", "gen2", "gen3"],
  "entry": "main.lua"
}

The entry file runs once per boot, on whichever game is loaded:

return function(mod)
  local mew = mod.content.pokemon:get("MEW")
  if mew then
    mod.content.pokemon:patch("MEW", {
      baseStats = { attack = math.min(255, mew.baseStats.attack + 10) },
    })
  end

  if mod.content.moves:get("TACKLE") then
    mod.content.moves:patch("TACKLE", { power = 45 })
  end

  mod.events:once("world.stepped", function()
    local ok, reason = mod.world:setFlag("mod:visited", true)
    if not ok then mod.log:warn("%s", reason) end
  end)
end

That is the engine's mods/examples/portable_api mod. Validate it and ask the checker how far it gets on each generation:

python3 tools/modkit.py validate mods/examples/portable_api
python3 tools/modkit.py gen2check mods/examples/portable_api
python3 tools/modkit.py gen3check mods/examples/portable_api

modkit scaffold writes "api": 2 unless you pass --api 3, which writes "api": 3.

Declaring games

games takes version ids (red, blue, yellow, gold, silver, crystal, firered, leafgreen, ruby, sapphire, emerald), generations (gen1, gen2, gen3), the Gen 3 families frlg (FireRed, LeafGreen) and rse (Ruby, Sapphire, Emerald), or all. A mod is only loaded on a game it claims; a manifest that says nothing is Gen 1 only, and gen2compat is not a Gen 3 claim. A claim means "I ran this there". See Preparing your mod for Gen 2 and Gen 3 for per-game enablement and dependencies that scope themselves to a generation.

What you write the same way everywhere

Gen 1 is the source of truth: names, record shapes and units are the Gen 1 ones, and Gen 2 and Gen 3 translate to them at the boundary.

Surface Portable on all three
mod.content pokemon, moves, items, trainers, encounters take the canonical Gen 1 record shape (and add has)
mod.events, mod.hooks shared names; payload records are converted at the boundary
mod.world current, party, setFlag, getFlag, warpTo, spawnNpc, queueScript, startWildBattle, field actions and the rest of the common set
mod.battle snapshot() and submit() intents
mod:supports the capability query, below

Details worth knowing:

  • Species use baseStats.special; on Gen 2 and Gen 3 it sets both later Special stats. Add specialAttack and specialDefense when you want them to differ. Gen 1 requires them equal.
  • Species ids are Gen 1 spellings (FARFETCHD, MR_MIME), Gen 3 types are PSYCHIC and MYSTERY. Moves use Gen 1 effect ids such as NO_ADDITIONAL_EFFECT; an effect with no verified mapping is refused.
  • Registrations need no storage index. Gen 3 allocates a free slot for you.
  • register, override, patch and remove return nil, reason when the selected game cannot represent the change. Nothing is recorded. Check the result instead of assuming.
  • Reads and iteration return detached copies. Change data only through a write method.

Ask before you rely on it: mod:supports

local ok, reason = mod:supports("content", "pokemon")
local canFly = mod:supports("world", "flyTo")
local hasHook = mod:supports("hooks", "core.logic_speed")
local hasEvent = mod:supports("events", "world.stepped")

Surfaces are content, world, hooks and events. Hook and event answers are per version and follow native call sites, so a Gen 1 baseline name is true only where that generation has the site (battle.style, catch.nickname and catch.party_full are Gen 1 only; transition.style is Gen 1 and Gen 2; mod.options_changed is true everywhere). Phone, radio and the intro cinema are Gold/Silver/Crystal only. shiny.roll, gender.roll, held_item.trigger, breeding.compatibility, egg.hatched, happiness.changed, pokerus.infected and the roamer events are true on Gen 2 and Gen 3. Custom mod.<id>.* events are always true.

hooks:wrap, events:on and events:once accept any catalogued name on any game, so one file loads everywhere; a subscription to a name the game never raises just never fires. Unknown names raise. Gate behavior on mod:supports, not on a version string.

What works where

Shared names are registrable everywhere, but a feature only does something where the cart has it. The API never invents gameplay: there is no Gen 3 phone, radio or apricorns.

Area Gen 1 Gen 2 Gen 3
Species, moves, items, trainers, encounters yes yes yes
Encounter writes one table a shared write applies to every time of day translated to the Gen 3 encounter tables
Text (text, strings, tokens) yes yes tokens expand in text; text takes the native shape
rom_text no yes yes, native shape
Audio (music, sfx, cries, map_songs) yes yes file and chip rows; a row keyed by a native name replaces the cart sound
sprites, icons, palettes, font yes yes (sprites, icons, palettes pass through in the Gold shape) yes (native shape; Gen 3 rules below)
type_chart, growth_rates, evolution_methods, item_effects yes yes yes
balls yes yes yes, native shape
move_effects, statuses, ai_classes yes yes yes, native shape on Gen 3
battle_anims yes yes, native Gen 2 shape yes, native Gen 3 shape
decorations, landmarks no yes landmarks yes; decorations on Ruby, Sapphire and Emerald only
screens and mod.ui yes yes yes, drawn inside the GBA frame
apricorns, phone_contacts, radio_channels, held_items no yes no
rulesets, text_pointers, link_fields yes no no
tilesets, field, constants, audio, battle_sprite_scales, transitions yes yes no Gen 3 target

A write to a registry with no target on the running game is dropped and reported once in the manager's error feed. It is not fatal, so register the content that applies and let the rest go. The exact routing per generation is in Reference: registries and the generation notes in the Cookbook.

Registries written in the native shape

Some registries have a different shape on each generation and no canonical record adapter. An api 3 mod still writes them: the record passes through in the shape the running game uses (battle_anims, constants, icons, map_scripts, palettes, sprites, balls, maps, plus statuses, move_effects and ai_classes), wherever the game routes the registry. Put the generation's native record in the call, and branch on mod:supports or the generation when the shapes differ. A registry with no target on the running game (see the table above) answers false with a reason and the write is dropped. Always check mod:supports rather than this table; it is the live answer.

The Gen 3 statuses, move_effects and ai_classes registries accept native Gen 3 records (see the Gen 3 notes in the Cookbook). Gen 3 statuses live in their own per-battler slot, so a mon holds at most one status of either kind.

Gen 3 specifics

  • Sprites. A sprites record draws through the field renderer for any NPC whose sprite names it (mod.world:spawnNpc, npc:setAppearance). Gen 3 art is full colour, so mod sheets draw as true colour; trueColor is accepted and changes nothing, and paletteSource has no meaning. Species battle pictures come from the pokemon record (spriteFront, spriteBack, 64x64).
  • Palettes. Four-row records are applied as a luminance ramp to the cache's baked art. Ids are <SPECIES>MON, <SPECIES>MON_SHINY and OBJ_EVENT_GFX_<NAME>. Register them; there is no vanilla record to override. A four-colour ramp cannot reproduce a multi-hue native palette.
  • Fonts. font records keep the Gen 1 shape: page alpha is the glyph mask in 8x8 cells, and multi-character charmap: rows are ignored.
  • Trainers. Rows may carry className, class, baseMoney and a pic image path.
  • Balls. balls take { multiplier, autoCatch }.
  • Item effects. use(ctx) returns "consumed", "kept" or "failed" plus messages, through the native item flow; ctx.target is the native Gen 3 party record.
  • Tilesets are not available: Gen 3 maps are 16 px metatiles baked per tileset pair, with no per-tile block table to replace.
  • Blocks. activeBlockAt and replaceBlock use 16 px zero-based blocks on Gen 3 and 32 px on Gen 1 and 2.

Portable scripts

mod.world:queueScript(rows, { onDone = function(ok, reason) end }) runs a list of { verb, arg... } rows on every generation. The whole list is validated before anything runs, waits for native completion between asynchronous rows, and is capped at 1024 rows.

mod.world:queueScript({
  { "show_text", "Take this." },
  { "give_item", "POTION", 1 },
  { "set_flag", "mod:gift_given" },
}, { onDone = function(ok, reason)
  if not ok then mod.log:warn("%s", reason) end
end })

Verbs available on all three:

Group Verbs
Flow label, jump, jump_if_true, jump_if_false, end, wait, wait_flag
Checks check_flag, check_item, check_money, check_dex_owned, check_battle_result
State set_flag, clear_flag, set_field, set_option, give_money, take_money, take_coins, give_item, take_item, heal_party, mark_seen, give_pokemon
Dialogue show_text, ask, choice
World warp, move_player, move_npc, move_npc_to, face, face_object, face_npc, face_player, face_player_dir, place_npc, show_object, hide_object, replace_block
Battle and shops start_battle, static_battle, open_mart
Audio play_sound, play_music, stop_music, play_cry, play_once, text_sound, wait_sound, engage_music, fade_music, play_default_music, remember_outdoor

text, setflag and clearflag are aliases of show_text, set_flag and clear_flag.

Adapted verbs depend on what the native game can do:

Verb Gen 1 Gen 2 Gen 3
emote, fade, walk_npc, text_opts, push_screen, trade native command set, if it has the verb adapted adapted
pan_camera, march_in_place native command set, if it has the verb native command set, if it has the verb (not adapted) adapted (the only generation that adapts them)

Anything else is refused before the first row runs, with unsupported script command: <verb> or native adapter unavailable: <verb>. On Gen 2 and Gen 3 text_opts accepts only auto. push_screen needs a screen registered in content.screens. give_item and static_battle resolve ids through the running game's data, and an unknown id refuses the whole list. Native script bytecode stays version-specific: a Gold map_scripts row or a Gen 3 scriptKey is not portable.

Flags written with a mod: prefix are private booleans in your mod's save bucket on every generation. Symbolic native flags need constants for the version; Crystal does not reuse Gold's.

Screens with mod.ui

mod.ui.push(game, id, ...) pushes a screen registered in content.screens. On Gen 3 there are no builtin Gen 1 screens, so an unregistered id raises. The Gen 3 bridge belongs to api 3 mods; api 1 and 2 mods keep the Screens-based push.

A screen state has enter, update, draw, exit and optional isOpaque. On Gen 3 it draws into the 160x144 interior of the 240x160 GBA frame, translated to (40, 8). Set gbaCanvas = true on the state to draw in the full 240x160 space. screen.pushed and screen.popped are raised.

mod.ui.TextBox.new(game, text, onDone, opts) opens the native message window. On Gen 3, opts.choice and opts.stay raise an error; use the ask or choice script rows for questions. The tutorial on custom screens is Red-only; draw with coordinates relative to the interior and test on Gen 3 before claiming it.

Battle snapshots

local snapshot = mod.battle:snapshot()
if snapshot and snapshot.prompt == "menu" then
  mod.battle:submit({ id = 1, revision = snapshot.revision, kind = "menu", choice = "fight" })
end

Use a fresh, increasing intent id per mod. Snapshots carry player, enemy, party, moves, items, revision, prompt, turn, catchable, plus double, active, battlers and targets. Singles use battler ids 0 and 1; Gen 3 doubles use 0 to 3. Mutating a snapshot changes nothing.

Testing

  • Run the same body against every claimed version. The loader result, native data changes, unsupported answers, rollback and frozen content should all be asserted.
  • python3 tools/modkit.py gen2check <mod> and gen3check <mod> scan for Gen 1-only module use and unbacked members. A clean scan means nothing known-broken was found, not that it works. Boot each generation.
  • Registering a callback only proves registration. Native coverage needs the real call site and its payload.
  • For runtime NPCs, also check pool membership, collision, interaction, owner-scoped removal and session cleanup. Runtime NPCs survive map reloads in a session but are not saved: respawn on load. A permanent NPC belongs in a map content patch.

Link play

A portable mod is judged by what it registers, on the generation the room runs. See Link Compatibility.

See also

Clone this wiki locally