Skip to content

Concepts Portable API 3

bryanthaboi edited this page Oct 20, 2026 · 1 revision

Portable API 3

One entry file, every game. A mod that declares "api": 3 and lists the games it claims runs on Red, Blue, Yellow, Gold, Silver, Crystal, FireRed, LeafGreen, Ruby, Sapphire and Emerald with the same names, record shapes and units. Gen 1 is the source of truth: Gen 2 and Gen 3 add capabilities and keep the inherited contracts. Shared names are registrable everywhere, but a feature only does something where the cart has it. The API never invents gameplay a game lacks (no Gen 3 phone, radio or apricorns).

api 1 and api 2 mods behave exactly as before. API 3 is a per-manifest opt-in, not a rewrite of the mod object: mod.content, mod.events, mod.hooks, mod.world and mod.battle keep their names and are replaced by portable services for api >= 3 mods only. The engine provides modApi = 3 (src/core/Version.lua) and refuses a manifest with a higher number.

A complete portable mod

{
  "id": "portable_example",
  "name": "Portable Example",
  "version": "1.0.0",
  "api": 3,
  "games": ["gen1", "gen2", "gen3"],
  "entry": "main.lua"
}
return function(mod)
  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

The entry chunk may use local mod = ... or return function(mod) ... end. Content changes belong there, before the loader freezes the registries. The runnable mods/examples/portable_api in the repository shows the pattern.

Declaring games

games takes gen1, gen2, gen3, rse, frlg, a version id or all (Manifest). A manifest that says nothing is Gen 1 only; gen2compat is not a Gen 3 claim. Claim a game only after running on it.

Services behind the facade

API 3 routes the mod object through engine services. You never require them.

Service Facade What it does
UnifiedContent mod.content, mod.datasets canonical records for the core registries; native shapes for the rest
UnifiedRecords records inside content, hooks and events converts species, move, item, trainer and encounter rows between the Gen 1 shape and the native one
UnifiedIds ids inside records Gen 1 spellings for species (FARFETCHD, MR_MIME) and Gen 3 types (PSYCHIC, MYSTERY)
UnifiedEffects effect on a move maps Gen 1 effect ids to the native effect; an effect with no verified mapping is refused
UnifiedCallbacks mod.events, mod.hooks, mod:supports boundary conversion of record-valued payloads; the capability query
UnifiedWorld mod.world the common world method set
UnifiedBattle mod.battle snapshots and intents
UnifiedScripts mod.world:queueScript portable script rows

Shared records

The portable registries are pokemon, moves, items, trainers and encounters. They keep register, override, patch, remove, get and each, and add has. Reads and iteration return detached records; apply changes through a write method.

  • Species: baseStats.hp, attack, defense, speed, special; level1Moves; learnset = { { level = 9, move = "TACKLE" } }; evolutions = { { method = "LEVEL", level = 16, species = "CHARMELEON" } }; string species, move, item, type and growth-rate ids.
  • Gen 2 levelMoves and into, and Gen 3 numeric tables, are translated to that shape. Writing special sets both later Special stats; optional specialAttack and specialDefense carry the split, and Gen 1 requires them equal.
  • Moves use Gen 1 effect ids such as NO_ADDITIONAL_EFFECT.
  • Trainers: parties = { { { species = "MEW", level = 5 } } } plus partyNames. Encounter slots: { species = "MEW", level = 5 }; a shared encounter write applies to every Gen 2 time of day.
  • Text keeps the Gen 1 control characters with symbolic placeholders such as {PLAYER}.
  • A new registration needs no Gen 3 storage index; the adapter allocates a free slot and checks explicit collisions.

patch keeps untouched fields; lists replace or use __append / __prepend; mod.DELETE deletes. An operation the selected game cannot represent returns nil, reason before anything is recorded. A malformed record raises a schema error attributed to the mod, and a failed entry chunk rolls back its contributions. The native extension on a record is read-only data for diagnostics.

Registries whose Gen 2 or Gen 3 shape differs from Gen 1 and that have no canonical adapter are written by api 3 mods as pass-through records in the native shape of the running game, and mod:supports("content", name) says per game whether the registry has a target. Where each registry lands on each game, and the Gold and Gen 3 record differences, is in Registry Fields; Gold serves 44 of the 47 registries, Emerald 34 and FireRed and LeafGreen 33. A registry with no home is reported, never silently merged.

Capability query

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. Every content handle also has supports() and an available boolean. Content and world answers describe implemented interfaces; game state may still refuse a call.

For hooks and events the answer is native per version: a name returns true only on versions that have a call site. Names with a call site in every generation (for example pokemon.level_visible, battle.field_residual) are true on every game; Gen 1 only names (battle.style, catch.nickname, catch.party_full) and Gen 1 and Gen 2 names (transition.style, world.tod, map.palette) answer from their per-generation call sites; mod.options_changed is true everywhere; Gen 2 additions follow the cart (phone.call_received, radio.channel, phone.contact_list and the boot intro are Gold, Silver and 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. The tables in Reference: Hooks and Reference: Events show the call sites per generation.

hooks:wrap, events:on and events:once accept any catalogued name on any game, so one source file loads everywhere; a subscription to a name the game does not raise simply never fires. Unknown names raise. Check mod:supports before relying on a callback.

Hooks and events

mod.hooks:wrap(name, function(next, ...) end) and mod.events:on / once keep their forms. API 3 converts record-valued payloads at the boundary (including the mutable pokemon.before_give). Priorities, unsubscribe closures, owner rollback and the run-vanilla-at-most-once rule are unchanged. Game, battle and context handles stay native. A mod may subscribe to another mod's events but only emit under its own mod.<id>.* prefix; engine events cannot be forged. The start-menu hook exposes executable onSelect rows on every generation.

World services

Handles can be captured at entry time and resolve the current game when used. A call with no active world or save returns nil, reason.

The method set: current, party, canReorderParty, reorderParty, availableFieldActions, useFieldAction, canFly, flyTo, mapOverview, warpTo, toggleObject, setFlag, getFlag, effectiveEncounters, activeBlockAt, replaceBlock, spawnNpc, liveMaps, removeNpc, npc, queueScript, startWildBattle, invalidateMap and overworld. Actions validate arguments and native busy state before starting.

  • mod:<key> flags are private booleans in the mod's save bucket on every generation. Symbolic native flags need authoritative constants for the version; Crystal does not reuse Gold's mapping.
  • party() returns detached Pokemon with nickname, stats.special, string ids, short statuses (SLP, PSN, BRN, FRZ, PAR) and moves as { id, pp, maxPp }.
  • activeBlockAt(mapId, x, y) returns a native block id plus tileSize, cellSize, coordinateBase and kind. Blocks are 32 px on Gen 1 and Gen 2 and 16 px on Gen 3, zero-based. It is never a collision byte.
  • spawnNpc(mapId, objDef) takes a detached maps[].objects shape and returns a session id mapId .. "_obj_" .. index; npc(mapId, id) resolves it. Handles expose movement, facing, placement, passability and setAppearance(spriteId). removeNpc removes only the calling mod's runtime NPCs. Runtime NPCs survive map reloads in a session but are not saved; respawn on load. A permanent NPC belongs in a map content patch.
  • world.talk receives the native game or overworld and the target; call next(gameOrWorld, target) for the normal interaction (it runs at most once). On Gen 3 scriptKey runs the native script VM; Gen 1 talk scripts through this path are refused with an error.

Method signatures: Mod Object.

Portable scripts

queueScript(rows, { onDone = function(ok, reason) end }) validates the whole list before running anything, waits for native completion between asynchronous rows, reports completion once and cancels with a replaced session. Rows are { verb, arg... }, at most 1024. The verb tables (common verbs on all three generations, adapted verbs per game, and the refusal messages) are in Script commands. Native script bytecode stays version-specific.

For single actions use warpTo(mapId, x, y, facing, options) or startWildBattle(species, level, onDone); warp options (arrive, onDone, keepMusic) are read only for api 3 mods and have generation limits. api 1 and 2 mods keep the legacy warpTo contract, which ignores opts.

Battle snapshots and intents

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; native prompt, stale-revision and replay checks still apply. Snapshots have 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 through 3; targets is empty outside target selection. An item's catchChance is a 0 to 100 percentage and reading it rolls nothing. Mutating a snapshot does not change the battle. See Mod Object.

Screens on Gen 3

mod.ui.push(game, id, ...) pushes a screen registered in content.screens. The Gen 3 bridge is what an api 3 mod gets from mod.ui; api 1 and 2 mods keep the Screens-based push and the Gen 1 TextBox. Gen 3 has no builtin Gen 1 screens, so an unregistered id raises. A screen state table (enter, update, draw, exit, optional isOpaque) 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 instead. screen.pushed and screen.popped are raised. mod.ui.TextBox.new(game, text, onDone, opts) opens the native message window; opts.choice and opts.stay raise an error on Gen 3, so use the ask or choice script rows for questions.

What Gen 3 does and does not get

Gen 3 reuses the Gen 1 and Gen 2 hook, event and registry names and invents none. It has no phone, radio, apricorns, Bug Contest, mail or Unown puzzle, so those registries and events are never raised from Gen 3 code. Gen 3 registries that have a native consumer route to their own tables (gen3Pokemon, gen3Moves, gen3Items, gen3Encounters, gen3Text, and others); the battle registries (move_effects, statuses, ai_classes, battle_anims) hold mod content only, because the cart's own effects are battle-script code. The registry-by-registry routing, with the Gold and Gen 3 subsections, is in Registry Fields. The per-version call-site lists are in Hooks and Events.

Testing a portable mod

luajit tests/run_modkit.lua

Run the same body against every claimed version and assert the loader result, native data changes, unsupported results, rollback and frozen content. Registering a callback only proves the registration contract; 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.

See also: Portable mods guide, Compatibility, Registries, Events and Hooks, Manifest.

Clone this wiki locally