3 Concepts Registries
bryanthaboi edited this page 2026-10-20 17:01:40 -04:00

Content registries

How mods add, tweak, and remove game content. A registry is a named table of ids (pokemon, moves, items, ...) reachable during a mod's entry chunk as mod.content.<name>. Registrations are collected while every mod loads, then folded into the live data once, at the merge boundary described in Lifecycle. The catalog itself - every registry's merge semantics, Data target, and value schema - lives in one place, src/mods/Schemas.lua (47 registries), and the registry reference is generated from it. Regenerate the reference after changing the schema so the committed documentation stays aligned with the engine.

The same registry name works on every generation. Each registry is routed per game: it may keep the Gen 1 target, land in a gen2* or gen3* table with its own record shape, or have no home there (the write is dropped and reported). The per-game routing is in Data Model and each entry of the reference. A mod with "api": 3 gets canonical shared records for the core registries; see Portable API 3.

Choose the operation first

Choose by the result you want, before reading the exact schema:

You want to Use Why
Change one or a few fields on an existing record patch keeps fields you did not mention
Add a record with a new id register requires the new record's required fields
Replace an existing record as a whole override deliberately discards the old record before applying yours
Make a record unavailable remove leaves a tombstone so consumers treat the id as absent
Inspect the merged result get or each includes base data and loaded mods

Code placement: call these methods from your mod entry file, before the merge phase. Status: the snippets below show the method shapes; adapt ids and fields to your mod. Start with Choose a Registry if you know the outcome you want but not the registry name.

mod.content.pokemon:register(id, value)  -- add a new record; duplicate ids error
mod.content.pokemon:override(id, value)  -- replace a whole record
mod.content.pokemon:patch(id, partial)   -- deep-merge onto the current record
mod.content.pokemon:remove(id)           -- tombstone; consumers treat it as absent
mod.content.pokemon:get(id)              -- the effective value, base data included
mod.content.pokemon:each()               -- iterate the merged view (base + mods)
mod.content.pokemon:has(id)              -- api 3 only: true when the id exists

has exists on the api = 3 content handles only. Reads and iteration there return detached copies, so change a record through a write verb.

register collides with the imported base data too: adding "PIKACHU" errors, because changing an existing record is what override and patch are for. remove followed by a later register resurrects the id.

Patch, not restate

The classic v1 mistake was re-stating a whole species record to change one stat, which silently dropped every field the author forgot. patch merges only what you provide:

mod.content.pokemon:patch("MEWTWO", { catchRate = 1 })
mod.content.pokemon:patch("MEW", { baseStats = { attack = 120 } })  -- hp etc. intact

Patch rules, in order:

  • Dictionary values merge per key, recursively.
  • Array values replace wholesale in a record registry (element-wise merging of a learnset is ambiguous). In a deep registry they append instead, so two mods adding rows to the same list both land.
  • To extend a list in a record registry, wrap it: { __append = { row } } or { __prepend = { row } }. The wrapper is unwrapped during the merge and never reaches Data; __prepend is also how a deep registry reaches the front of a list.
  • mod.DELETE unsets a field: patch("EEVEE", { evolutions = mod.DELETE }).
  • Patches stack across mods in load order.

Two small merge examples

A record field that is a dictionary keeps the keys you did not name:

before: baseStats = { hp = 35, attack = 55, speed = 90 }
patch:  baseStats = { attack = 70 }
after:  baseStats = { hp = 35, attack = 70, speed = 90 }

A plain list means different things in the two registry families. In a record registry it replaces the existing list; use __append or __prepend when you want to retain it. In a deep registry it appends, because those registries combine one shared data tree:

record registry: before { "TACKLE" }, patch { "GROWL" } -> { "GROWL" }
deep registry:   before { "A" }, patch { "B" } -> { "A", "B" }

Merge semantics

Each registry declares one of three behaviors in Schemas.lua (constants, field and text_pointers are the deep ones; map_scripts and migrations are the compose ones; the other 42 are records):

  • record - id maps to a whole record; the verbs behave as above. Most registries are records.
  • deep - the registry is one deep-mergeable tree (constants, field, text_pointers) whose ids are the top-level keys of the target table. register and patch are synonyms: both merge into the key, with lists appending. override replaces the whole key, which is the total-conversion verb. See Data Model.
  • compose - registrations accumulate into per-id chains instead of replacing each other (map_scripts, migrations). Duplicate register calls are legal by design: two mods adding NPCs to the same map both land, ordered by priority (higher first) then load order. An override chain replaces the engine's own base contribution too, and a false entry in a map_scripts talk/scripts slot suppresses every lower-precedence entry. patch is an error on a compose registry.

Registries merge shallowest target first, so a whole-table registry such as audio lands before the granular ones nested under it (music, sfx, cries, map_songs); the name breaks ties, so the same content always merges the same way.

Validation

Every register/override/patch is checked against the registry's schema. A violation is a load error attributed to the mod - named registry, id, field path, expected type - instead of a nil-index crash three systems downstream. A near-miss field name gets a suggestion (did you mean "baseStats"?). After the merge, a cross-reference pass resolves id-typed fields (a learnset move, an evolution target) against the merged world and reports dangling references the same way.

Unknown top-level fields are allowed and preserved - extensible records are a feature - but a key that is only a case/underscore variant of a schema field is the classic typo and is rejected with the suggestion.

Mods with "api": 1 in their manifest (or no api field) downgrade all of this to logged warnings, so every v1 mod keeps loading exactly as before.

A registry with no home on the booted game takes the write, drops it and reports it once per mod in the manager's error feed; it is never a crash. A registry is gated this way in both directions (a Red boot writing to apricorns, a Gold boot writing to rulesets).

If you use the schemas from your own tools, note the argument order: Schemas.check(spec, registryName, id, value, mode, generation) - the spec comes FIRST, fetched from Schemas.REGISTRIES[name]. Passing the name first silently validates nothing. The optional generation picks the Gen 1, Gen 2 or Gen 3 record shape.

The strict level follows the manifest api: 2 and 3 reject, 1 warns.

Namespaces

A registry whose base data module never shipped a table merges into a freshly created namespace (Data.screens, Data.map_scripts, Data.audio.songs when absent) instead of being silently dropped - the fate of the v1 scripts and ui registries.

Vanilla content is registrations too

The engine registers its own built-ins - statuses, move effects, balls, growth curves, evolution methods, script commands, text tokens, rulesets, AI classes, battle transitions, the type chart - under the owner engine before any mod runs (src/mods/Builtins.lua). Which built-ins exist depends on the booted generation: Gold seeds its own statuses, balls and AI records instead of Red's, and the Gen 3 battle registries (move_effects, statuses, ai_classes, battle_anims) are seeded empty because the cart's own effects are code, not records. Three consequences: each() always yields the whole world, register on an existing vanilla id collides and forces an explicit override, and the behavioral namespaces (Data.statuses, Data.commands, ...) are populated on every boot whether or not a mod is installed. Each registration hands over the table the engine already reads, so the registry and the consumer are the same object and nothing about vanilla behavior changes.

Renames and deprecations

v1 name Now
scripts map_scripts (compose). v1 registrations, previously dropped, now work.
ui screens (record). Same: previously dropped, now merged.
audio Kept, deprecated. Whole-key replacement of Data.audio; the granular sfx / cries / map_songs / music registries supersede it.

The old names keep working as aliases of the same registry and log a one-time deprecation warning per mod.