No description
  • TypeScript 94.7%
  • JavaScript 5.3%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Simon Harms c04fab7c57
fix: port the named record, and let diff see same-kind bases
`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]>
2026-09-23 20:37:32 -04:00
.commandcode chore: add the taste mod preferences 2026-09-23 16:27:53 -04:00
src fix: port the named record, and let diff see same-kind bases 2026-09-23 20:37:32 -04:00
test fix: port the named record, and let diff see same-kind bases 2026-09-23 20:37:32 -04:00
.gitignore test: cover the fix loop 2026-09-23 16:28:38 -04:00
package-lock.json chore: scaffold TypeScript package 2026-09-19 13:10:30 -04:00
package.json feat: expose the toolchain as a Command Code mod 2026-09-19 13:45:21 -04:00
README.md fix: resolve a species' default form before fetching it 2026-09-19 15:19:52 -04:00
ROADMAP.md feat: add item/move/ability catalogs 2026-09-23 16:10:47 -04:00
tsconfig.build.json chore: scaffold TypeScript package 2026-09-19 13:10:30 -04:00
tsconfig.json feat: AI-first toolchain for Pokemon GBA and DS romhacks 2026-09-19 13:35:13 -04:00
vitest.config.ts feat: AI-first toolchain for Pokemon GBA and DS romhacks 2026-09-19 13:35:13 -04:00

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 \n escape 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;
  • --text accepts 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.txt cannot 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_LEAFAGE in 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_EGG slot and moving NUM_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, the INCGFX_* 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_TURTWIG is 387 and NATIONAL_DEX_ARCEUS is 493 — the canonical Gen 4 range — and the OLD_UNOWN block had shifted to 494
  • SPECIES_TURTWIG is 412 and SPECIES_EGG moved 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 gPokedexEntries entry, and the 25 placeholders still have none
  • validate reports 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) or gfx generate.
  • DS bases cannot add species yet. species add and dex expand refuse with UNSUPPORTED_DOMAIN on 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, so pkmn 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. replace is 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_B and LEVEL_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 pret layouts. Forks that move files need a path in SPECS (src/core/detect.ts).
  • build runs the project's own make (or meson/cmake) and turns compiler output into {file, line, column, severity, message} diagnostics. It requires the usual toolchain (devkitARM etc.) 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_EDITABLE with 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