- TypeScript 94.7%
- JavaScript 5.3%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
`port` wrote to a hard-coded record: `portChange` always patched SPECIES_GEODUDE (or TRAINER_NONE), so "apply this edit to every base" landed on the wrong species and ignored the intended target entirely. The target was never designed in — the patch carries no key and the CLI had no way to pass one — so add `--key <name>` and thread it through. A base that does not have the record is now skipped with the real reason rather than a generic "update failed". `diffBase` keyed each record by project kind, so two copies of the same game collapsed into one map entry and the comparison reported no differences while claiming both bases were present. Label bases per base instead, falling back to the root only when two share a kind. Neither module had tests, which is how both shipped. Add test/crossbase.test.ts: diffing identical and diverged bases, refusing a single base, porting to the named record while leaving every other record alone, skipping a missing record, requiring a key, and writing nothing on a dry run. Co-authored-by: CommandCodeBot <[email protected]> |
||
| .commandcode | ||
| src | ||
| test | ||
| .gitignore | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| ROADMAP.md | ||
| tsconfig.build.json | ||
| tsconfig.json | ||
| vitest.config.ts | ||
pkmn-ai-toolchain
An AI-first toolchain for Pokemon GBA and DS ROM hacks built on pret decomp projects.
ROM hacks are edited as source code: C designated initialisers, JSON data tables, and generated headers. That is great for humans and terrible for automation, because every base game stores the same concept differently. This toolkit gives one machine-readable surface over all of them, designed so an agent can inspect, edit, preview, validate and build without guessing at formatting or file layout.
pokemon_emerald/ res/pokemon/<slug>/data.json
├── species_info.h files/poketool/personal/personal.json
├── trainers.h files/poketool/trainer/trainers.json
├── wild.json generated/species.txt
└── ... ▲
│ │
└──────┬───────────────┘
│ normalised records + surgical edits
┌──────▼──────────────────────────────┐
│ core library → CLI → MCP server │
└─────────────────────────────────────┘
Quickstart
Three steps, and everything else in this file is optional depth.
# 1. Get the toolchain and build the CLI
git clone https://git.simonharms.com/thesimonharms/pkmn-ai-toolchain
cd pkmn-ai-toolchain && npm install && npm run build
# 2. Install the mod (or just point at this checkout with --mod)
cmd mods add /path/to/pkmn-ai-toolchain
# 3. Work in your hack, not in the toolchain
cd ~/my-emerald-hack && cmd
Then ask in plain language: "what's in this project?", "make Treecko faster", "read me the Route 101 scene", "add Piplup to the dex".
If anything is off, /pkmn-setup checks it and tells you what to fix:
pkmn setup
CLI /usr/bin/node /home/me/pkmn-ai-toolchain/dist/cli/index.js
working /home/me/my-emerald-hack
project pokeemerald / pokefirered
art no generator configured (optional; only needed for gfx generate)
next:
/pkmn info --counts what is in this project
/pkmn-validate check the data
/pkmn-romhack <goal> start making changes
The mod finds the CLI without configuration: it looks in the working directory,
in a sibling or home pkmn-ai-toolchain, and then on PATH. Set
PKMN_TOOLCHAIN or pass --mod-option toolchain=/path/to/toolchain to be
explicit.
Supported bases
| Base | Detection marker | Domains |
|---|---|---|
pokeemerald |
src/data/pokemon/species_info.h |
species, trainers, wild, maps, story, text, graphics |
pokefirered |
same, no SPECIES_TREECKO |
species, trainers, wild, maps, story, text, graphics |
pokeemerald-expansion |
.speciesName or include/config/pokemon.h |
species, trainers, wild, maps, story, text, graphics |
pokeplatinum |
res/pokemon/*/data.json + generated/species.txt |
species, maps, story, text, graphics |
pokeheartgold |
files/poketool/personal/personal.json |
species, trainers, maps, text, graphics |
Anything else is still usable for full-text search via the unknown fallback.
Install
npm install
npm run build # emits dist/ and the `pkmn` binary
npm test # 164 tests
During development use npm run dev -- <args> (tsx, no build step).
CLI
Every command accepts the global flags:
| Flag | Meaning |
|---|---|
-C, --root <dir> |
project root (defaults to cwd) |
--json |
emit one JSON envelope instead of prose |
-n, --dry-run |
compute and show the diff, write nothing |
-q, --quiet |
suppress the human-readable body |
--project-kind <kind> |
skip auto-detection |
# What am I looking at?
pkmn -C ~/pokeemerald info --counts
# Find things
pkmn -C ~/pokeemerald species list --type GRASS --min-bst 500
pkmn -C ~/pokeemerald species show treecko
pkmn -C ~/pokeemerald trainers show TRAINER_SAWYER_1
pkmn -C ~/pokeemerald wild show route101 --kind land_mons
# Maps and story
pkmn -C ~/pokeemerald maps list
pkmn -C ~/pokeemerald maps show route101
pkmn -C ~/pokeemerald maps analyze
pkmn -C ~/pokeemerald story show LittlerootTown
pkmn -C ~/pokeemerald story find "running shoes"
# Analyse
pkmn -C ~/pokeemerald species balance --top 15
pkmn -C ~/pokeemerald wild analyze
# Edit (preview first, always)
pkmn -C ~/pokeemerald species set treecko --hp 60 --speed 80 --dry-run
pkmn -C ~/pokeemerald species set treecko --hp 60 --type grass --type poison
pkmn -C ~/pokeemerald trainers set TRAINER_SAWYER_1 --party "SPECIES_GEODUDE:22:ITEM_ORAN_BERRY"
pkmn -C ~/pokeemerald wild set route101 --rate 25 --slot 0:SPECIES_GEODUDE:4:5
# Move an NPC, add one, and rewrite a line of dialogue
pkmn -C ~/pokeemerald maps object-set Route101 0 --x 10 --y 12
pkmn -C ~/pokeemerald maps object-add Route101 --graphics OBJ_EVENT_GFX_YOUNGSTER \
--id LOCALID_NEW_KID --x 5 --y 5 --script Route101_EventScript_Youngster
pkmn -C ~/pokeemerald story say LittlerootTown LittlerootTown_Text_TownSign \
--text "LITTLEROOT TOWN
Where every hue is welcome."
# Check and build
pkmn -C ~/pokeemerald validate --strict
pkmn -C ~/pokeemerald build --jobs 8
# Oops
pkmn -C ~/pokeemerald undo
Species and trainer identifiers are forgiving: treecko, Treecko,
SPECIES_TREECKO and 252 all resolve, and an ambiguous input is reported
rather than guessed.
set commands take a full patch with --patch, as either the snake_case names
used by the data files and the MCP schema (base_stats, catch_rate,
special_attack) or their camelCase equivalents:
pkmn -C ~/pokeemerald species set treecko --patch '{"base_stats":{"hp":60},"types":["grass","poison"]}'
pkmn -C ~/pokeemerald trainers set SAWYER --patch '{"class_name":"HIKER","party":[{"species":"SPECIES_GEODUDE","level":22,"held_item":"ITEM_ORAN_BERRY"}]}'
An unknown field or an unknown stat key is an error, never a silent no-op — a typo that quietly edits nothing is the worst failure mode for a caller that cannot see the file.
Maps and story
The setting is most of what a hack is, so maps and dialogue are first-class rather than something an agent is expected to hand-edit.
pkmn -C ~/pokeemerald maps list # every map, size, event counts
pkmn -C ~/pokeemerald maps show MAP_ROUTE101 # metadata, connections, NPCs, warps, triggers, signs
pkmn -C ~/pokeemerald maps analyze # audit every map at once
pkmn -C ~/pokeemerald story show MAP_LITTLEROOT_TOWN # the scene, in playing order
pkmn -C ~/pokeemerald story find "running shoes" # returns the labels, not just lines
pkmn -C ~/pokeemerald story say MAP_LITTLEROOT_TOWN LittlerootTown_Text_TownSign \
--text "LITTLEROOT TOWN
Where every hue is welcome."
| Base | Maps | Objects / warps | Dialogue |
|---|---|---|---|
| pokeemerald / firered / expansion | data/maps/<Map>/map.json, size from data/layouts/layouts.json |
add, move, rewire, remove | read and rewrite (scripts.inc) |
| pokeplatinum | res/field/events/events_*.json |
add, move, rewire, remove | read and rewrite (res/text/*.json) |
| pokeheartgold | files/fielddata/eventdata/zone_event/*.json |
add, move, rewire, remove | not available |
Geometry is read-only, events are editable. All three bases store their
tilemap blocks as binary; the events — which is what a map edit usually means —
are source. maps show and maps analyze say so rather than failing later.
Writing dialogue is forgiving. You write the text the way a person would:
- literal line breaks become whatever the base stores — the
\nescape on GBA, a real LF on DS — and a blank line becomes a paragraph break (\p), which is what a blank line means in a text box; --textaccepts either convention, so GBA-style escapes work on DS and vice versa, and the conversion is reported as a warning so nothing changes silently;- characters the game's own
charmap.txtcannot draw are flagged (pasted curly quotes and emoji are the usual culprits), and so are lines wider than the 36-column text box.
story show is the scene as the player experiences it — each script label
followed by the lines it displays — so a cutscene can be rewritten in one pass
instead of hopping between a script file and a text table.
maps analyze catches what a single-file read cannot: a warp pointing at a
map that does not exist, an NPC whose script label is missing everywhere in the
project (shared scripts under data/scripts/ count as present), and two objects
sharing a local id.
Sprites, tilesets and palettes
Art is not really game-specific: every base stores indexed PNGs and JASC .pal
files in a tree, so one implementation covers all of them — tilesets included,
since a tileset sheet is just a PNG in data/tilesets/.
pkmn -C ~/pokeemerald gfx list # every image, size, colours, depth
pkmn -C ~/pokeemerald gfx find OBJ_EVENT_GFX_YOUNGSTER # resolve a constant to its file
pkmn -C ~/pokeemerald gfx show graphics/pokemon/abra/front.png
pkmn -C ~/pokeemerald gfx validate # audit every image
# Bring in art you produced elsewhere
pkmn -C ~/pokeemerald gfx import graphics/trainers/front_pics/aqua_grunt_m.png \
--source ~/art/aqua_grunt_m.png --dry-run
# Recolour it
pkmn -C ~/pokeemerald gfx palette-set graphics/pokemon/abra/front.png --set 1=255,0,0
pkmn -C ~/pokeemerald gfx palette graphics/pokemon/abra/front.png
# Look at something
pkmn -C ~/pokeemerald gfx dump graphics/pokemon/abra/front.png --out /tmp/abra.png
There is no image generation here. What the toolchain does is everything around the art: it indexes it, describes it, validates it, and turns a finished image into a legal asset.
gfx import is the main workflow. You supply an ordinary PNG — from an
image model, Aseprite, anything — and it is reduced to the palette budget,
written as an indexed 4bpp PNG, and paired with a .pal written from the same
colours. It reports whether the art was kept exactly or quantised, and never
resizes: scaling pixel art is a creative decision, so a size mismatch is
reported for you to resolve rather than silently stretched.
gfx validate audits every image without inflating any of them (it reads
headers only): not indexed, more than 16 colours, 8bpp where 4bpp is expected,
dimensions that break the convention for that directory (a 16x32 overworld
frame, a 64x64 trainer picture, whole 8x8 tileset tiles), a palette file that is
too small for the image, and transparency that is not at index 0 — which is the
index the games actually draw as transparent.
Palette pairing follows the conventions these projects really use: beside the
file, in a sibling palettes/ or icon_palettes/ directory, a Pokemon species'
normal.pal/shiny.pal, or a tileset's numbered palette banks.
gfx palette-set treats the .pal as the source of truth — it is what the
build consumes — then re-encodes the image from the same palette, so the two
cannot drift.
Generating art
There is no image model in this repo. What there is instead is the other half of
the problem: knowing exactly what size, colour budget and format a given asset
needs, and being able to check the result. gfx generate joins the two.
# Any program that takes a prompt and writes an image
pkmn -C ~/pokeemerald gfx generate graphics/trainers/front_pics/aqua_grunt_m.png \
--prompt "a cheerful swimmer in a blue cap" \
--generator-command "my-generator --prompt {prompt} --out {out} --size {width}x{height}"
# Or an HTTP endpoint you already run
export PKMN_GENERATOR_URL=http://127.0.0.1:7860/sdapi/v1/txt2img
export PKMN_GENERATOR_BODY='{"prompt":"{prompt}","width":{width},"height":{height}}'
export PKMN_GENERATOR_RESPONSE='images[0]'
pkmn -C ~/pokeemerald gfx generate graphics/pokemon/abra/front.png --prompt "a psychic fox"
Either way the result is reduced to the palette budget, written as an indexed
4bpp PNG, paired with a .pal written from the same colours, and validated —
the same path as importing an image you made by hand.
The size defaults to the asset you are replacing, so generated art keeps its
shape. Tilesets and odd-sized sprites work because of that, and an explicit
--width/--height overrides it.
Commands run as argv, never through a shell. A prompt containing ;, $()
or backticks is one inert argument, not a command. That is tested.
A dry run does not call the generator. Generating costs money or GPU time, so
--dry-run shows the request that would be sent instead of spending it.
Placeholders: {prompt}, {width}, {height}, {colours}, {seed}, {out}.
In a command template {out} is a temp file the generator writes; in a JSON body
{prompt} is escaped for you.
Configuration comes from flags or the environment:
PKMN_GENERATOR (command/http), PKMN_GENERATOR_COMMAND,
PKMN_GENERATOR_URL, PKMN_GENERATOR_BODY, PKMN_GENERATOR_RESPONSE,
PKMN_GENERATOR_KEY, PKMN_GENERATOR_HEADER, PKMN_GENERATOR_TIMEOUT_MS.
Not covered: metatile definitions and the binary block data behind them
(metatiles.bin, and a layout's map.bin/border.bin), and the DS games'
binary .NCGR/.NSCR/.NCLR formats. Tile sheets are covered; painting
metatiles into a map is not.
Expanding the dex
One new Pokemon is the most scattered edit in the ecosystem: it has to appear, consistently, in 19 files and 7 assets, or the project either fails to link or the species shows up blank in the Pokedex, the party menu, or in battle.
# See exactly what would change, without touching anything
pkmn -C ~/my-hack species add --from pokeapi:piplup --dry-run
# Do it
pkmn -C ~/my-hack species add --from pokeapi:piplup
The definition comes from PokéAPI by name or dex number, or from a JSON file you keep in the repo — both go through the same planner, so an offline definition and a fetched one cannot behave differently.
A whole generation at once
# Everything from Gen 4, previewed
pkmn -C ~/my-hack dex expand --generation 4 --dry-run
# A dex range, or an explicit list
pkmn -C ~/my-hack dex expand --range 387-493
pkmn -C ~/my-hack dex expand --names piplup,chimchar,turtwig
Each species goes through exactly the same 19-file addition as species add,
run one after another so ids stay sequential. A batch also does what a single
add cannot:
- skips what is already there instead of failing the run at the first existing species, so it is safe to re-run after a partial failure
- aggregates everything the base cannot express into one list, rather than
repeating the same "no
MOVE_LEAFAGEin Gen 3" note once per species - caches PokéAPI replies on disk under
.pkmn-toolchain/cache/pokeapi(which it gitignores for you), because a generation is two hundred requests and a re-run should be free - keeps going past a bad definition, reporting the species it could not handle at the end
A dry run writes nothing but still fetches, so the numbering it prints is the numbering you will actually get.
Where the dex number comes from
The national dex enum is positional, and vanilla pokeemerald's is not simply
386 long: it continues past NATIONAL_DEX_DEOXYS with the 25 OLD_UNOWN
placeholders, while NATIONAL_DEX_COUNT deliberately points at the last real
species so those placeholders stay out of the Pokedex listing.
So a new species is inserted immediately after the constant
NATIONAL_DEX_COUNT points at, not at the end of the enum, and COUNT then
moves to the new species. The placeholder block shifts up and stays outside the
count. Adding at the end of the enum instead would pull the placeholders inside
COUNT's range, and the Pokedex loops to COUNT.
The consequence is that the slot is assigned in order, not chosen. Adding Piplup alone to vanilla pokeemerald gives it dex 387 and says so:
warn: the canonical dex number for Piplup is 393, but this project assigns slots
in order, so it becomes 387
Add the generation in dex order and the two agree — Gen 4 species land on
387-493 exactly. An explicit --dex-number is accepted only when it matches the
slot the project would pick, because anything else would renumber whatever is
already sitting there.
What it wires, per species:
- the species constant, taking the
SPECIES_EGGslot and movingNUM_SPECIES - the national dex number, inside the enum, and the shifted
NATIONAL_DEX_COUNT - the stat block in
gSpeciesInfo - a level-up learnset and its pointer in
gLevelUpLearnsets - the name in
gSpeciesNames, the Pokedex text and the Pokedex entry - the three positional dex-order arrays, at the right sorted positions — these are not designated initialisers, so appending would leave the species at the end of the alphabetical and weight listings
- the sprite declarations in
include/graphics.h, theINCGFX_*definitions in three files, and registration in all seven sprite tables - placeholder artwork at the conventional sizes (64x64 battle pictures, 32x64 icon, 16x16 footprint, plus normal and shiny palettes) so the project still builds
Later generations do not fully exist in an older base, and it says so. Adding Sprigatito to vanilla pokeemerald reports:
not expressible in this project, so left out:
abilities: protean
7 move(s): u-turn, worry-seed, seed-bomb, energy-ball, hone-claws, play-rough, leafage
(add these constants to the project first if you need them)
Writing MOVE_LEAFAGE into a project that has no such constant would break the
build, so unknown names are dropped and counted instead. Gen 3 does not have
Fairy, most Gen 4+ moves, or many later abilities; pokeemerald-expansion is
the base that has them, and this toolchain detects and edits it too.
Verified against a real build. All 107 species of Gen 4 — Turtwig through
Arceus — were added to a clean checkout of upstream pokeemerald in one command,
in 11 seconds, then compiled with make MODERN=1:
- 107 requested, 107 added, 0 skipped, 0 failed
- the ROM linked at 13544456 bytes, up from 13295520, with no undefined references and every new sprite symbol present
NATIONAL_DEX_TURTWIGis 387 andNATIONAL_DEX_ARCEUSis 493 — the canonical Gen 4 range — and theOLD_UNOWNblock had shifted to 494SPECIES_TURTWIGis 412 andSPECIES_EGGmoved to 519, one step per species, with no drift across all 107 additions- all three dex-order arrays grew and contain the new species, every new dex
number has a
gPokedexEntriesentry, and the 25 placeholders still have none validatereports 0 errors and 0 warnings over the resulting 519 species
Catching a species that was only half added
Adding a species touches 19 files, and a hand edit — or a run interrupted partway — can leave one wired in some of them and not others. That state does not fail loudly on its own: it either breaks the link at the end of a long build, or links with the species blank in the Pokedex.
validate now checks it directly. Every species with data has to appear in
gSpeciesNames, gLevelUpLearnsets and gMonFrontPicTable, and anything
missing is named:
error SPECIES_PIPLUP has no name in gSpeciesNames; the project will not build
or will show it blank. Add it to src/data/text/species_names.h.
error SPECIES_BUDEW has no entry in gLevelUpLearnsets; ...
A wiring file that does not exist in a given base is skipped rather than treated as empty, so a project with a different layout is not reported as hundreds of incomplete species.
What is not covered
- The artwork is a placeholder, not a sprite. It is the right size, depth and
palette so the project builds and the species can be tested, but it is flat
colour. Replace it with
gfx import(your own art) orgfx generate. - DS bases cannot add species yet.
species addanddex expandrefuse withUNSUPPORTED_DOMAINon pokeplatinum and pokeheartgold rather than writing something half-valid. Those bases keep species data across narc archives and their engine tables do not line up with the GBA ones, so it is a separate piece of work, not a flag. Everything else — maps, story, trainers, text, graphics — works on DS bases today. - You cannot choose the dex slot. The enum is positional, so the species takes the next free one. Adding a generation in dex order lands on the canonical numbers; adding a single late-generation species gives it the next slot and says so.
The agent contract
With --json every command emits one envelope and never mixes prose into
stdout:
{
"ok": true,
"command": "species.set",
"project": { "kind": "pokeemerald", "game": "Pokemon Emerald", "root": "/home/me/pokeemerald" },
"dryRun": true,
"data": { "key": "SPECIES_TREECKO", "bst": 335, "...": "..." },
"changes": [
{
"file": "src/data/pokemon/species_info.h",
"applied": false,
"added": 3,
"removed": 3,
"diff": "--- a/src/... \n+++ b/src/...\n@@ ..."
}
],
"warnings": []
}
Failures keep the same shape with ok: false and a stable error.code
(SPECIES_NOT_FOUND, AMBIGUOUS_SPECIES, UNSUPPORTED_DOMAIN,
PARTY_NOT_EDITABLE, UNKNOWN_PROJECT_KIND, …), so callers branch on the code
rather than parsing prose. Exit code is 0 on success and non-zero on failure;
validate --strict exits non-zero when errors are found, which makes it usable
as a pre-commit or CI gate.
MCP server
npm run mcp
Exposes the same capabilities as 40 tools (pkmn_species_show,
pkmn_trainers_set, pkmn_maps_object_add, pkmn_story_say, pkmn_gfx_import,
pkmn_validate, pkmn_build, …). Wire it into an MCP
client:
{
"mcpServers": {
"pkmn": {
"command": "node",
"args": ["/absolute/path/to/pkmn-ai-toolchain/dist/mcp/server.js"]
}
}
}
Mutating tools accept dry_run and return the diff plus the backup id they
created.
Command Code mod
The repo ships a Command Code mod at
.commandcode/mods/pkmn.ts, so the toolchain can be used natively from inside a
Command Code session instead of shelling out.
cmd mods list # shows: pkmn · project · .commandcode/mods/pkmn.ts
cmd --mod ./.commandcode/mods/pkmn.ts # or load it ad hoc, no install
It is a typed facade over the CLI's --json mode, so the mod can never drift
from the toolchain. It registers:
Tools (the model calls these by name). Read-only ones — pkmn_project_info,
pkmn_species_list, pkmn_species_show, pkmn_trainers_show, pkmn_wild_show,
pkmn_maps_list, pkmn_maps_show, pkmn_maps_analyze, pkmn_story_show,
pkmn_story_dialogue, pkmn_story_find, pkmn_gfx_list, pkmn_gfx_show,
pkmn_gfx_find, pkmn_gfx_validate, pkmn_gfx_palette, pkmn_validate, pkmn_search — stay
available in plan mode. The rest — pkmn_species_set, pkmn_trainers_set,
pkmn_wild_set, pkmn_maps_object_add/set/remove, pkmn_maps_warp_set,
pkmn_story_say, pkmn_gfx_import, pkmn_gfx_generate, pkmn_gfx_palette_set,
pkmn_gfx_dump,
pkmn_species_add, pkmn_build, pkmn_undo — take dry_run and hand back the
unified diff. pkmn_cli is an escape hatch for anything without a dedicated
tool (["species","balance"], ["wild","analyze"], …).
Slash commands — /pkmn <command…> runs any CLI command and prints the
result, /pkmn-info identifies the project, /pkmn-maps [filter] lists maps,
/pkmn-map <map> shows one map's NPCs and warps, /pkmn-story <map> reads a
scene, /pkmn-gfx [filter] lists art, /pkmn-sprite <file> shows one image,
/pkmn-species-add <name> expands the dex, /pkmn-setup diagnoses the install,
/pkmn-validate validates and renders a styled summary row, and
/pkmn-romhack [goal] starts a guided session that inspects, validates,
previews each edit with dry_run, and re-validates afterwards.
Options (cmd --mod-option name=value):
| Option | Purpose |
|---|---|
toolchain=<path> |
Where the CLI lives. Auto-detected from the working directory, ./pkmn-ai-toolchain, ../pkmn-ai-toolchain, or pkmn on PATH. |
root=<path> |
Project to operate on. Defaults to the working directory. |
validate-on-stop=true |
After a run that actually edited data, validate the project and raise a notice with the error/warning counts. |
The validate-when-the-run-ends hook validates the project the tools actually
edited, not just the flag/cwd, so a per-call root still gets checked.
Safety model
- Preview by default. Every read/write command supports
--dry-run, which produces the real diff without touching disk. - Backups before every write. Originals are copied to
.pkmn-toolchain/backups/<id>/with a manifest, sopkmn undo [file]always works. - Atomic writes. Temp file + rename, preserving file mode.
- Root confinement. Paths that escape the project root are refused.
- No destructive defaults.
replaceis capped at 50 files per run and reports the cap rather than silently truncating.
Why edits stay small
A toolchain for agents lives or dies on diff quality. A one-value change that reports 300 changed lines cannot be reviewed.
- C sources are parsed, not reformatted. Data is located by byte offset in comment-masked source (masking preserves length, so offsets stay valid), and a field edit splices only that value. Comments, alignment and trailing commas survive untouched.
- JSON is spliced, not re-serialised. The value's exact span is located and replaced in the style of the text it replaces. pokeplatinum writes short arrays inline and pokeheartgold writes them expanded; both stay as they were.
- Semantics, not strings.
PERCENT_FEMALE(12.5),(1 << 3),AI_FLAG_A | AI_FLAG_BandLEVEL_UP_MOVE(1, MOVE_POUND)are evaluated, so values can be compared and reasoned about numerically.
Editing a trainer's party through the macro form (NO_ITEM_DEFAULT_MOVES(sParty_X))
rewrites the referenced array, swaps the mon struct and macro to match the new
contents, and leaves partySize/partyFlags alone because the macro already
expands to them.
Validation
validate is the highest-value command before a build, because a full ROM build
takes minutes. Checks include:
- unknown constants (
ABILITY_*,TYPE_*,MOVE_*,ITEM_*,EGG_GROUP_*,SPECIES_*) across species, learnsets, trainer parties and encounter tables - stats outside the engine's 0-255 range, impossible EV yields
- learnsets: unknown moves, levels outside 1-100, out-of-order, duplicates
- trainers: empty parties, unknown classes, out-of-range mon levels
- encounters: unknown species, min level above max level, slot chances not totalling 100%
- duplicate species names
maps analyze covers the world instead: warp destinations, object scripts and
duplicate object ids (see Maps and story), and gfx validate
covers art (see Sprites, tilesets and palettes).
Reference checks are skipped (with an info note) when fewer than 50 constants
resolve, so an unusual or partial tree does not produce a wall of false errors.
Empty placeholder slots such as SPECIES_OLD_UNOWN_B and the canonical
TRAINER_NONE are recognised and reported at info level. On a pristine
upstream pokeemerald checkout the result is 0 errors, 0 warnings.
Library
The CLI and MCP server are thin wrappers; everything is importable.
import { openProject, listSpecies, setSpecies, validateProject, runBuild, OpContext } from 'pkmn-ai-toolchain';
const adapter = openProject('~/pokeemerald');
listSpecies(adapter, { type: 'GRASS' });
const ctx = new OpContext(adapter.ws, adapter.project.root, true); // dry run
setSpecies(adapter, ctx, 'treecko', { baseStats: { hp: 60 } });
console.log(ctx.changes[0].diff);
Lower-level pieces are exported too, for hacking on other pret projects:
ConstantTable (C #define/enum resolution), evaluateNumber /
evaluateString (C expression evaluation with macro expansion), CNode plus
setField/insertField/removeField (designated-initialiser editing),
applyJsonEdits (span-splicing JSON), and Workspace (backups, atomic writes).
Layout
src/
core/ errors, workspace+backups, diff, JSON splicing, detection, output
parse/ C expression evaluation, constant tables, initialiser parsing,
.inc script/text blocks, charmaps
image/ PNG codec, JASC palettes, colour quantisation
generate/ AI art generator seam (command argv or HTTP), no vendor lock-in
dex/ species addition: the 19-file wiring, and PokeAPI mapping
model/ normalised records + identifier matching
adapters/ one per base game, behind a shared ProjectAdapter interface
ops/ species, trainers, wild, text, validate, build
cli/ commander wiring + human rendering
mcp/ MCP stdio server
.commandcode/
mods/ the Command Code mod (a facade over the CLI)
test/
fixtures/ small, format-accurate pokeemerald / pokeplatinum / pokeheartgold trees
Fixtures mirror the real formats (verified against upstream), including
trainer_parties.h, generated/species.txt, and each game's JSON layout.
Notes
- Detection and adapters target the upstream
pretlayouts. Forks that move files need a path inSPECS(src/core/detect.ts). buildruns the project's ownmake(ormeson/cmake) and turns compiler output into{file, line, column, severity, message}diagnostics. It requires the usual toolchain (devkitARMetc.) to be installed already.- Reading trainer parties requires the party array to be resolvable; a project
that generates party data purely through macros reports
PARTY_NOT_EDITABLEwith the symbol name instead of writing broken C.
Development
npm run typecheck # strict, noUnusedLocals/Parameters
npm test # vitest
npm run build # required before the mod can drive the CLI
Linting is intentionally absent: the TypeScript configuration is strict enough that adding a linter would restate the compiler.
The mod in .commandcode/ is not part of that tsconfig (it imports
@commandcode/harness types that the package does not export); it is compiled
by Command Code's own loader, so verify it by loading it:
cmd mods list # must list pkmn with no warnings
cmd -p "call pkmn_project_info" --mod ./.commandcode/mods/pkmn.ts