romdevtools 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -4,7 +4,7 @@ You are reading this because romdev is connected. This is the orientation. Read
4
4
 
5
5
  ## What this server does
6
6
 
7
- Drives the full homebrew ROM dev loop for 14 retro game platforms (NES, SNES, Game Boy, Game Boy Color, Game Boy Advance, Genesis, Sega Master System, Game Gear, Atari 2600/7800, Atari Lynx, Commodore 64, PC Engine / TurboGrafx-16, and MSX / MSX2). Build → run → screenshot → inspect → patch → iterate. Also a strong reverse-engineering kit: disassemble existing ROMs into byte-exact rebuildable projects (`disasm({target:'project'})`/`disasm({target:'references'})` — the workhorse for any structural hack), find a value's address with the Cheat-Engine search loop (`memory({op:'search'})`/`memory({op:'searchNext'})`), find the EXACT instruction that wrote a RAM byte (`breakpoint({on:'write'})`, a core-level write watchpoint), confirm a patch is live in the running image (`memory({op:'readCart'})`), tell whether a "found table" is really ASCII (`memory({op:'classify'})`), trace which ROM offset a Genesis graphic was DMA'd from (`dmaTrace({precision:'sampled'})`), drive menus by screen-change (`navigate`), and look up cheats (`cheats({op:'lookup'})`/`cheats({op:'search'})`: a free, crowd-sourced labeled RAM/code map for known ROMs), apply + create cheats, convert assets, study patterns from real games. **Doing a romhack? Start with `platform({op:'doc', platform:'romhacking', name:'playbook'})`** — the decision tree that wires all of the above together. Bundled WASM toolchains and emulator cores — no system dependencies, no installs.
7
+ Drives the full homebrew ROM dev loop for 14 retro game platforms (NES, SNES, Game Boy, Game Boy Color, Game Boy Advance, Genesis, Sega Master System, Game Gear, Atari 2600/7800, Atari Lynx, Commodore 64, PC Engine / TurboGrafx-16, and MSX / MSX2). Build → run → screenshot → inspect → patch → iterate. Also a strong reverse-engineering kit: disassemble existing ROMs into byte-exact rebuildable projects (`disasm({target:'project'})`/`disasm({target:'references'})` — the workhorse for any structural hack), find a value's address with the Cheat-Engine search loop (`memory({op:'search'})`/`memory({op:'searchNext'})`), find the EXACT instruction that wrote a RAM byte (`breakpoint({on:'write'})`, a core-level write watchpoint), confirm a patch is live in the running image (`memory({op:'readCart'})`), tell whether a "found table" is really ASCII (`memory({op:'classify'})`), trace which ROM offset a Genesis graphic was DMA'd from (`watch({on:'dma', precision:'sampled'})`), drive menus by screen-change (`navigate`), and look up cheats (`cheats({op:'lookup'})`/`cheats({op:'search'})`: a free, crowd-sourced labeled RAM/code map for known ROMs), apply + create cheats, convert assets, study patterns from real games. **Doing a romhack? Start with `platform({op:'doc', platform:'romhacking', name:'playbook'})`** — the decision tree that wires all of the above together. Bundled WASM toolchains and emulator cores — no system dependencies, no installs.
8
8
 
9
9
  You drive the work. The human is a director — they may want a game, a ROM disassembly, a tool-assisted reverse-engineering session, or anything else this server can do.
10
10
 
@@ -54,7 +54,7 @@ Skip playtest only when there's clearly no human in the loop: CI runs, automated
54
54
  - `input` — drive controllers, look up hardware bit layouts. `navigate` walks menus by advancing on SCREEN CHANGE (not fixed frames) and reports whether each press was consumed — the fast, reliable way to script a UI.
55
55
  - `state` — savestates and forensic state inspection (`state({op:'save'})`, `state({op:'load'})`, `state({op:'export'})` a slot to disk without touching the live host, `state({op:'list'})`, `state({op:'dump'})`)
56
56
  - `memory` — read/write VRAM/OAM/CGRAM/ARAM and other regions (all 14 platforms). `memory({op:'read'})` takes `offsets:[…]` to batch scattered reads in one call. **`memory({op:'search'})`/`memory({op:'searchNext'})`** = the Cheat-Engine value-search loop ("find the address of X, narrow as X changes"). **`memory({op:'readCart'})`** reads the loaded cart image to confirm a patch is live. **`memory({op:'classify'})`** says whether bytes look like ASCII/code/tile-data (kills the "found table that's really a string" trap). `memory({op:'snapshot'})` + `memory({op:'diff'})` answer "which bytes changed across this event?" (diff defaults to a clustered summary with stride detection); `state({op:'diff'})` is the coarse whole-machine version.
57
- - `debug` — `sprites({op:'inspect'})`, `palette({source:'live'})`, `cpu({op:'read'})` (all 14), `audioDebug({op:'inspect'})` (the 12 systems with a sound chip — all but Atari 2600/7800; pass `frames:N` to TRACE a per-channel note-timeline for headless melody asserts), `background({view:'renderState'})`, `breakpoint({on:'write'})` (write watchpoint, all 14), **`dmaTrace({precision:'sampled'})`** (Genesis: which ROM offset a VRAM graphic was DMA'd from), **`disasm({target:'bytes'|'rom'|'references'|'project'})`** (ALL 14 — native binutils objdump per CPU, incl. GBA ARM7/Thumb; the byte-exact `disasm({target:'project'})` reassembles through native as/ld/objcopy), `symbols({op})` lookup, `background({view:'rendered'})`, plus **`cheats({op})`** (`cheats({op:'lookup'})` = a free labeled RAM/code map for known ROMs, `cheats({op:'search'})` to fuzzy-find a game by name, `cheats({op:'apply'})`/`cheats({op:'clear'})` non-destructively, `cheats({op:'make'})` to create codes)
57
+ - `debug` — `sprites({op:'inspect'})`, `palette({source:'live'})`, `cpu({op:'read'})` (all 14), `audioDebug({op:'inspect'})` (the 12 systems with a sound chip — all but Atari 2600/7800; pass `frames:N` to TRACE a per-channel note-timeline for headless melody asserts), `background({view:'renderState'})`, `breakpoint({on:'write'})` (write watchpoint, all 14), **`watch({on:'dma', precision:'sampled'})`** (Genesis: which ROM offset a VRAM graphic was DMA'd from), **`disasm({target:'bytes'|'rom'|'references'|'project'})`** (ALL 14 — native binutils objdump per CPU, incl. GBA ARM7/Thumb; the byte-exact `disasm({target:'project'})` reassembles through native as/ld/objcopy), `symbols({op})` lookup, `background({view:'rendered'})`, plus **`cheats({op})`** (`cheats({op:'lookup'})` = a free labeled RAM/code map for known ROMs, `cheats({op:'search'})` to fuzzy-find a game by name, `cheats({op:'apply'})`/`cheats({op:'clear'})` non-destructively, `cheats({op:'make'})` to create codes)
58
58
  - `assets` — convert PNGs to tiles (`encodeArt`/`importArt`), WAVs to BRR, identify ROMs (`cart({op:'identify'})`), plus the hacking toolkit (`romPatch({op})` — write/writeMany/spliceCHR/relocate/makeStored/findFree/findPointer/diff, `assembleSnippet`, `cart({op:'extract'})`, `cart({op:'wrap'})`)
59
59
  - `project` — starter snippets per platform
60
60
  - `show` — `playtest({op})`: `op:'open'` opens the live SDL window for a human, `op:'stop'` closes it, `op:'status'` reports liveness, `op:'framebuffer'` captures exactly what the human's window shows
@@ -297,7 +297,7 @@ Different platforms have different levels of MCP-exposed debugging — different
297
297
  > lookup/apply/create), `cpu({op:'read'})`, `memory({op:'search'})`/`memory({op:'searchNext'})`/`memory({op:'readCart'})`/`memory({op:'classify'})`,
298
298
  > `memory({op:'snapshot'})`/`memory({op:'diff'})`/`state({op:'diff'})`, `watch({on:'mem'})`/`breakpoint({on:'write',precision:'sampled'})`.
299
299
  > `audioDebug({op:'inspect'})` covers the 12 systems with a sound chip (all but Atari 2600/7800).
300
- > **`dmaTrace({precision:'exact'})`** (which DMA wrote a VRAM tile, and from where) is **Genesis-only**
300
+ > **`watch({on:'dma', precision:'exact'})`** (which DMA wrote a VRAM tile, and from where) is **Genesis-only**
301
301
  > (VDP DMA) — elsewhere use `breakpoint({on:'write'})`/`watch({on:'range'})`. All other RE tools above
302
302
  > work on every platform that has the register-write/watch core hooks (all 14).
303
303
  > `disasm({target:'rom'})` + `disasm({target:'references'})` + `disasm({target:'project'})` cover **all 14** — every
@@ -1044,10 +1044,10 @@ A few platform-tool quirks worth knowing up front:
1044
1044
  - **asar bank-border-crossed** can happen if your `org` + `dw` runs past $00FFFF. Native vectors are at $FFE4-$FFEE; emulation vectors at $FFF4-$FFFF. Use `scaffold({op:'snippets', platform: "snes", mode: "get", name: "lorom_header.asm"})` for the layout.
1045
1045
  - **cc65 (NES, C64, etc.) zero page** starts at $02. cc65 reserves $00-$01 for its runtime. Your first `.res 1` lands at $02, not $00. Use `symbols({op:'map'})` after `build({output:'romWithDebug'})` to confirm.
1046
1046
  - **NES pattern table cap = 256 tiles per nametable**. The tilemap index is 8-bit, so per-frame BG can use at most 256 unique tiles per pattern table. Auto-converting a busy illustration usually overflows. `encodeArt({stage:'tilemap'})` warns; the only workaround is mid-frame CHR bank switching (MMC3-class mapper).
1047
- - **NES + GB/GBC turnkey** (R9/R10 self-contained + sound, 2026-05-25): use `scaffold({op:'project', platform, template, name, path})` to scaffold a project. The pipeline copies every file the template depends on — `{nes,gb}_runtime.{h,c}`, `gb_hardware.h`, custom `crt0.s`, linker `.cfg`, `patch-header.js` (GB) — into the project directory alongside `main.c`. **No auto-injection at build time.** The build pipeline compiles exactly what you tell it via `sources` / `sourcesPaths` / `includes` / `includePaths` / `crt0` / `crt0Path` / `linkerConfig` / `codeLoc`. Take the project elsewhere with stock cc65/sdcc and it builds the same way. The runtime APIs include sprites, BG, input, AND **sound** — `sound_init` / `sound_play_tone(channel, period, vol, length)` / `sound_play_noise` / `sound_off`. NES drives pulse1+pulse2+triangle+noise via $4000-$400F + $4015; GB drives the 4-channel APU via NR10-NR52. SFX-grade, fire-and-forget — for full music tracks, drop in famitone2 (NES) or your own driver. Templates: `default` (palette cycle), `hello_sprite` (sprite + d-pad + **beep on A press**), `tile_engine` (multi-room tile map). Docs: [`src/platforms/nes/MENTAL_MODEL.md`](src/platforms/nes/MENTAL_MODEL.md) + [`TROUBLESHOOTING.md`](src/platforms/nes/TROUBLESHOOTING.md); [`src/platforms/gb/MENTAL_MODEL.md`](src/platforms/gb/MENTAL_MODEL.md) + [`TROUBLESHOOTING.md`](src/platforms/gb/TROUBLESHOOTING.md). **Game-loop order matters on NES:** stage `oam_clear`+`oam_spr` BEFORE `ppu_wait_nmi`, not after — the NMI handler DMA's whatever shadow_oam contains at vblank-start. **GB ROM header:** both asm and C builds now auto-run `rgbfix` inside `build({output:'rom'})`, so the Nintendo logo + checksums + CGB flag are correct out of the box — no manual `patchGbHeader` step needed.
1047
+ - **NES + GB/GBC turnkey** (R9/R10 self-contained + sound, 2026-05-25): use `scaffold({op:'project', platform, template, name, path})` to scaffold a project. The pipeline copies every file the template depends on — `{nes,gb}_runtime.{h,c}`, `gb_hardware.h`, custom `crt0.s`, linker `.cfg`, `patch-header.js` (GB) — into the project directory alongside `main.c`. **No auto-injection at build time.** The build pipeline compiles exactly what you tell it via `sources` / `sourcesPaths` / `includes` / `includePaths` / `crt0` / `crt0Path` / `linkerConfig` / `codeLoc`. Take the project elsewhere with stock cc65/sdcc and it builds the same way. The runtime APIs include sprites, BG, input, AND **sound** — `sound_init` / `sound_play_tone(channel, period, vol, length)` / `sound_play_noise` / `sound_off`. NES drives pulse1+pulse2+triangle+noise via $4000-$400F + $4015; GB drives the 4-channel APU via NR10-NR52. SFX-grade, fire-and-forget — for full music tracks, drop in famitone2 (NES) or your own driver. Templates: `default` (palette cycle), `hello_sprite` (sprite + d-pad + **beep on A press**), `tile_engine` (multi-room tile map). Docs: [`src/platforms/nes/MENTAL_MODEL.md`](src/platforms/nes/MENTAL_MODEL.md) + [`TROUBLESHOOTING.md`](src/platforms/nes/TROUBLESHOOTING.md); [`src/platforms/gb/MENTAL_MODEL.md`](src/platforms/gb/MENTAL_MODEL.md) + [`TROUBLESHOOTING.md`](src/platforms/gb/TROUBLESHOOTING.md). **Game-loop order matters on NES:** stage `oam_clear`+`oam_spr` BEFORE `ppu_wait_nmi`, not after — the NMI handler DMA's whatever shadow_oam contains at vblank-start. **GB ROM header:** both asm and C builds now auto-run `rgbfix` inside `build({output:'rom'})`, so the Nintendo logo + checksums + CGB flag are correct out of the box — no manual header-patch step needed (use `romPatch({op:'gbHeader'})` only to fix up an external ROM).
1048
1048
  - **Game Boy / GBC silent-failure footguns** (R54 cleanup, full detail in `platform({op:'doc', platform:"gb"|"gbc", name:"mental_model"})`):
1049
1049
  - **The bundled `gb_crt0.s` is now actually linked.** Pre-r54 a fundamental bug in `buildZ80C` was shipping the raw .s text to sdld as if pre-assembled — sdld silently rejected it and fell back to SDCC's stock sm83 crt0 (no GB cart boot, no IRQ vectors). Map showed no `init` symbol, $0000 was $FF, $0100 was $FF. Every GB ROM ran on stock crt0 invisibly. Fixed by auto-detecting .s source vs .rel object and running it through sdasgb first. Post-fix: `init` at $0150, entry $0100 = `00 c3 50 01` (nop; jp $0150), reset vector $0000 = $C9. **This was the root cause for #14 audio AND part of why every previous "runtime should work OOTB" round still felt friction-heavy.**
1050
- - **GB/GBC C builds now auto-fix the header at build time** (rgbfix runs inside `build({output:'rom'})`): Nintendo logo at $0104, header checksum at $014D, global checksum, and the CGB flag at $0143 ($00 for `.gb`, $C0 for `.gbc`). You no longer need to call `patchGbHeader` manually — the ROM `build({output:'rom'})` hands back boots on real hardware as-is. `patchGbHeader({path})` still exists if you want to override title / cart type / RAM size / etc. on an existing file.
1050
+ - **GB/GBC C builds now auto-fix the header at build time** (rgbfix runs inside `build({output:'rom'})`): Nintendo logo at $0104, header checksum at $014D, global checksum, and the CGB flag at $0143 ($00 for `.gb`, $C0 for `.gbc`). You no longer need to patch the header manually — the ROM `build({output:'rom'})` hands back boots on real hardware as-is. `romPatch({op:'gbHeader', path})` still exists if you want to override title / cart type / RAM size / etc. on an existing file.
1051
1051
  - **`shadow_oam` is pinned at $C100** in the bundled `gb_runtime.c` via `__at(0xC100)`. OAM DMA reads ONLY the high byte and copies 160 bytes from `$XX00` — a plain `uint8_t my_oam[160]` may land at $C017 and DMA garbage. If you roll your own OAM buffer, pick an address with `0x00` low byte (e.g. $C200) and pass it directly to `oam_dma_copy`.
1052
1052
  - **Call `enable_vblank_irq()` once at boot.** Without it, `wait_vblank()` busy-polls `LY` which updates only at WASM `frame({op:'step'})` quantum boundaries → game loop runs at ~1/30 intended speed on the emulator. After enable, `wait_vblank()` compiles to `HALT` + vblank IRQ wake (~10 cycles per frame).
1053
1053
  - **Use `memcpy_vram(dst, src, n)` for VRAM bulk writes**, NOT raw `(uint8_t*)0x8000` casts — SDCC sm83 may elide the latter as dead code. The bundled `gb_hardware.h` declares every $FFxx register as `volatile`-typed so direct writes like `BGP = 0xE4;` are fine; the hazard is only on cast-through-pointer block copies.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,67 @@ All notable changes to `romdevtools`. Dates are release dates.
4
4
  (Published as `romdev-mcp` through 0.11.0; renamed to `romdevtools` in 0.13.0 —
5
5
  the `romdev-mcp` bin is kept as an alias.)
6
6
 
7
+ ## 0.14.0
8
+
9
+ **Two platform-specific top-level tools folded into their domain verbs, a
10
+ device-free cheat search, and skill-surface polish.** Pre-1.0 the surface keeps
11
+ consolidating with no deprecated aliases (call `catalog({op:'whatsNew'})` for the
12
+ live OLD→NEW map), so the tool count drops 34 → 32.
13
+
14
+ ### Changed (breaking — pre-1.0, no aliases)
15
+ - **`patchGbHeader` → `romPatch({op:'gbHeader'})`.** It's a ROM-file patch, same
16
+ family as romPatch's other ops — not a standalone Game-Boy tool. Same params
17
+ (path/outputPath/cgb/title/cartType/romSize/ramSize/destination), same output.
18
+ - **`dmaTrace` → `watch({on:'dma'})`.** The Genesis VDP-DMA trace is a log-all
19
+ trace like `watch`'s on:'mem'/'range'/'pc' — now a fourth `on`. Same
20
+ `precision:'exact'|'sampled'` + filters.
21
+ - **`cheats({op:'search'})` no longer needs `platform`.** Omit it and the search
22
+ sweeps EVERY indexed platform; each match reports its own `platform`. You don't
23
+ have to know the console to find a game's cheats. Pass `platform` only to scope
24
+ the search to one console.
25
+
26
+ ### Added
27
+ - **`GET /skills/romdev/SKILL.md`** is now the primary skill URL — it mirrors the
28
+ on-disk path agents save skills to (`~/.claude/skills/romdev/SKILL.md`) exactly.
29
+ `/romdev/SKILL.md` and the flat `/romdev-skill.md` are kept as aliases.
30
+ - **The `/livestream` observer header now links to `/documentation`** (the live
31
+ Swagger console), so a human watching can jump to the API docs.
32
+
33
+ ### Fixed (HTTP transport — failures are now unmissable)
34
+ - **A failed tool call returns a non-2xx, never a 200 with the error in the body.**
35
+ Tools that signal failure by RETURNING a failure-shaped result ({ok:false} /
36
+ {opened:false} / {applied:false} / a top-level `error`) — not just by throwing —
37
+ now map to HTTP 400 uniformly for EVERY tool. Before, e.g. `playtest` returning
38
+ `{opened:false}` came back 200, so an agent driving the REST/skill surface saw
39
+ "success," never read the body, and reported "window's up!" while no window
40
+ existed. (Valid "no"/state answers — `notSupported`, `matched:false`,
41
+ `loaded:false` — correctly stay 200.) A failed `playtest` window-open also now
42
+ logs to the server console so a human at the terminal sees it regardless.
43
+ - **`x-romdev-session` is now REQUIRED — a missing header returns 401**, instead
44
+ of the server auto-minting a throwaway one-shot session (which silently dropped
45
+ the loaded ROM and surfaced as "No ROM loaded" a couple calls later). The 401
46
+ message tells the agent to pick one stable id and send it every call.
47
+
48
+ ### Fixed (playtest — no more invisible windows)
49
+ - **`playtest({op:'open'})` now FAILS LOUDLY when there's no real display** instead
50
+ of reporting `opened:true` for a window nothing can see. If the server's SDL
51
+ comes up on the `offscreen`/`dummy` video driver (no desktop session — server
52
+ started over SSH, from a tty, before the desktop login, or as a headless agent
53
+ subprocess), the window would render and play audio but never appear on a
54
+ screen. We now detect the selected driver via SDL itself
55
+ (`sdl.info.drivers.video.current`) — ground truth, cross-platform (Linux/macOS/
56
+ Windows), and it correctly ALLOWS a virtual display like Xvfb (reports `x11`,
57
+ not `offscreen`). On the offscreen case the open throws (→ 400 / MCP error) with
58
+ the exact fix: run the server from inside your logged-in desktop session.
59
+ Headless tools (screenshot/runSource/inspect) are unaffected — offscreen stays
60
+ fine for everything except opening a window for a human.
61
+
62
+ ### Changed
63
+ - **The skill/HTTP session docs now coach a UNIQUE, task-descriptive
64
+ `x-romdev-session` id** (e.g. `nes-platformer-build`) — the id is the label a
65
+ human sees in `/livestream`, and it's how several agents share one server
66
+ without clobbering each other's emulator host.
67
+
7
68
  ## 0.13.0
8
69
 
9
70
  **Renamed `romdev-mcp` → `romdevtools` + a plain-HTTP tool surface and an Agent
@@ -34,7 +95,7 @@ duplication). Same Express app, same localhost trust, per-agent dynamic sessions
34
95
  via zod→JSON-Schema (the same conversion MCP `tools/list` uses).
35
96
  - **`GET /documentation`** — Swagger UI over the spec: a live "try it" console.
36
97
  - **`GET /tool/{name}/schema`** — that tool's JSON Schema (a validator on demand).
37
- - **`GET /romdev-skill.md`** — the Agent Skills open-standard SKILL.md
98
+ - **`GET /skills/romdev/SKILL.md`** — the Agent Skills open-standard SKILL.md
38
99
  (frontmatter + workflow guide + generated tool reference). ~100 tokens of
39
100
  name+description until invoked vs the always-on MCP tool defs — the on-demand
40
101
  context win. Works in Claude Code, opencode, OpenClaw, Hermes, etc. unchanged.
package/README.md CHANGED
@@ -9,7 +9,7 @@ npx romdevtools
9
9
  That's it — one command starts the local romdev **tool server** (no global install, no host compiler/emulator). Point any coding agent at it three ways:
10
10
 
11
11
  - **Plain HTTP** — `POST http://127.0.0.1:7331/tool/{name}`; browse/try every tool at `/documentation`.
12
- - **Agent Skill** — `GET /romdev-skill.md` (the [Agent Skills](https://agentskills.io) standard; ~100 tokens until invoked).
12
+ - **Agent Skill** — `GET /skills/romdev/SKILL.md` (the [Agent Skills](https://agentskills.io) standard; save it to your skills dir as `skills/romdev/SKILL.md`; ~100 tokens until invoked).
13
13
  - **MCP** — it's also a [Model Context Protocol](https://modelcontextprotocol.io/) server at `/mcp` for clients that want it.
14
14
 
15
15
  This package contains all the JavaScript — the tool surface, the WASM emulator host, the per-platform scaffolds, runtime/library source, and debug helpers — but **no emulator or compiler WASM itself.** Those ship in the `romdev-*` binary packages it depends on, loaded on demand the first time you build or run a given platform.
@@ -19,7 +19,7 @@ This package contains all the JavaScript — the tool surface, the WASM emulator
19
19
  ## What's in this package
20
20
 
21
21
  - **`bin`**
22
- - `romdevtools` → the tool server (`src/mcp/server.js`). Serves the HTTP tool routes, `/documentation`, `/romdev-skill.md`, and an MCP endpoint on `http://127.0.0.1:7331` by default (`PORT` / `HOST` to override). `romdev-mcp` is kept as an alias of the same command.
22
+ - `romdevtools` → the tool server (`src/mcp/server.js`). Serves the HTTP tool routes, `/documentation`, `/skills/romdev/SKILL.md`, and an MCP endpoint on `http://127.0.0.1:7331` by default (`PORT` / `HOST` to override). `romdev-mcp` is kept as an alias of the same command.
23
23
  - `romdevtools-cli` → a smoke/utility CLI, incl. `romdevtools-cli play <rom>` (SDL window, hot-plug controllers).
24
24
  - **`src/`** — the server, MCP tools, WASM host, core/toolchain resolvers, per-platform memory interpretation, and bundled library/runtime source (cc65 libs, PVSnesLib, SGDK, libtonc/libgba, hUGEDriver, …) that scaffolded projects link against.
25
25
  - **`examples/`** — per-platform starter projects and genre scaffolds.
@@ -58,12 +58,17 @@ running server:
58
58
  - **Plain HTTP:** `POST http://127.0.0.1:7331/tool/{name}` with the args as a JSON
59
59
  body; the response is JSON. Browse/try every tool at **`/documentation`**
60
60
  (Swagger UI, served locally — no CDN), or get the machine spec at
61
- **`/openapi.json`**. For stateful work (load step read) the first call
62
- returns an `x-romdev-session` header echo it on later calls to keep the same
63
- emulator session. romdev runs **locally** and tool path args (`path`,
64
- `outputPath`, …) are **local filesystem paths**, not uploads — pass absolute
65
- paths on the same machine.
66
- - **Agent Skill:** **`GET /romdev-skill.md`** is a portable [Agent
61
+ **`/openapi.json`**. **The agent picks its own session id** and sends it as the
62
+ `x-romdev-session` header on every call it's **required** (no header `401`;
63
+ the server won't silently run you in a throwaway session). Make it unique and
64
+ task-descriptive (e.g. `nes-platformer-build`), since it's also the label shown
65
+ in the `/livestream` observer. The emulator host is per-session, so the same id
66
+ keeps your ROM across calls, and several agents can share one server by each
67
+ using a different id. A call that fails returns a non-2xx (4xx) with the reason
68
+ in the body — never a 200 that hides an error. romdev runs **locally** and tool
69
+ path args (`path`, `outputPath`, …) are **local filesystem paths**, not uploads
70
+ — pass absolute paths on the same machine.
71
+ - **Agent Skill:** **`GET /skills/romdev/SKILL.md`** is a portable [Agent
67
72
  Skills](https://agentskills.io) `SKILL.md` (works in Claude Code, opencode,
68
73
  OpenClaw, Hermes, …). Drop it in your agent's skills dir; it costs ~100 tokens
69
74
  until invoked (vs always-on MCP tool defs), then teaches the workflows + the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "romdevtools",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Tool server giving coding agents full control of homebrew ROM development AND reverse-engineering/romhacking across 14 retro platforms (NES, SNES, GB, Genesis, Atari, C64, PC Engine, MSX, ...) via WASM toolchains + emulator cores. Use over plain HTTP, as an Agent Skill, or as an MCP server.",
5
5
  "type": "module",
6
6
  "main": "src/mcp/server.js",
@@ -121,37 +121,58 @@ function baseName(name) {
121
121
  .trim();
122
122
  }
123
123
 
124
+ // The platforms that ship a bundled cheat index — the set searchCheatGames sweeps
125
+ // when no `platform` is given. (Kept in step with cheats.js SUPPORTED; C64 has no
126
+ // source cheats so it has no index.)
127
+ const INDEXED_PLATFORMS = [
128
+ "nes", "gb", "gbc", "snes", "genesis", "sms", "gg",
129
+ "atari2600", "atari7800", "lynx", "gba", "pce", "msx",
130
+ ];
131
+
124
132
  /**
125
- * Fuzzy-search a platform's cheat index by game name. Returns the best-matching
126
- * game names + cheat counts WITHOUT dumping the whole DB so an agent can find
127
- * "NBA Jam Tournament" the real entry without a huge context load.
133
+ * Fuzzy-search the cheat DB by game NAME. Each match carries the `platform` it
134
+ * was found on, so the caller never has to know the console up front. By default
135
+ * this searches EVERY indexed platform (the natural ask: "find a game's cheats"
136
+ * — you shouldn't have to name the device); pass `platform` only to scope the
137
+ * search to one console. Returns best-matching game names + cheat counts WITHOUT
138
+ * dumping the whole DB.
128
139
  * @param {object} a
129
- * @param {string} a.platform
130
140
  * @param {string} a.query
141
+ * @param {string} [a.platform] optional — restrict to one platform; omit to search all
131
142
  * @param {number} [a.limit]
132
143
  * @param {number} [a.minScore]
133
- * @returns {Promise<{platform:string, query:string, matches:Array<{game:string, score:number, cheats:number}>, gameCount:number, note:string}>}
144
+ * @returns {Promise<{platform:string|null, query:string, matches:Array<{game:string, platform:string, score:number, cheats:number}>, gameCount:number, note:string}>}
134
145
  */
135
146
  export async function searchCheatGames({ platform, query, limit = 12, minScore = 0.25 }) {
136
- const idx = await loadIndex(platform);
137
- if (!idx || !idx.games) {
147
+ const platforms = platform ? [platform] : INDEXED_PLATFORMS;
148
+ let scored = [];
149
+ let gameCount = 0;
150
+ let missing = [];
151
+ for (const plat of platforms) {
152
+ const idx = await loadIndex(plat);
153
+ if (!idx || !idx.games) { missing.push(plat); continue; }
154
+ gameCount += idx.gameCount ?? Object.keys(idx.games).length;
155
+ const fuse = await getFuse(plat);
156
+ if (!fuse) continue;
157
+ for (const r of fuse.search(baseName(query))) {
158
+ const score = simFromFuse(r.score);
159
+ if (score >= minScore) scored.push({ game: r.item.game, platform: plat, score, cheats: r.item.cheats });
160
+ }
161
+ }
162
+ // When scoped to one platform and that platform has no index at all, say so.
163
+ if (platform && missing.length) {
138
164
  return { platform, query, matches: [], gameCount: 0, note: `No bundled cheat index for platform '${platform}'.` };
139
165
  }
140
- const names = Object.keys(idx.games);
141
- const fuse = await getFuse(platform);
142
- // Search the tag-stripped query against the tag-stripped baseName index.
143
- const results = fuse.search(baseName(query));
144
- const matches = results
145
- .map((r) => ({ game: r.item.game, score: simFromFuse(r.score), cheats: r.item.cheats }))
146
- .filter((m) => m.score >= minScore)
166
+ const matches = scored
147
167
  .sort((a, b) => b.score - a.score || a.game.length - b.game.length)
148
168
  .slice(0, limit);
169
+ const scope = platform ? `the ${platform} cheat DB` : `the cheat DB (all ${platforms.length - missing.length} platforms)`;
149
170
  return {
150
- platform, query, matches,
151
- gameCount: idx.gameCount ?? names.length,
171
+ platform: platform ?? null,
172
+ query, matches, gameCount,
152
173
  note: matches.length === 0
153
- ? `No game in the ${platform} cheat DB (${names.length} games) is close to "${query}". Try fewer/looser words.`
154
- : `${matches.length} candidate(s) by fuzzy name match (region/revision tags ignored, typo-tolerant). Pass an exact \`game\` to cheats({op:'lookup'}) (it also fuzzy-matches). Score is name similarity, not a content guarantee — verify a label before patching.`,
174
+ ? `No game in ${scope} (${gameCount} games) is close to "${query}". Try fewer/looser words.`
175
+ : `${matches.length} candidate(s) by fuzzy name match across ${platform ? "1 platform" : "all platforms"} (each match shows its \`platform\`; region/revision tags ignored, typo-tolerant). To read a game's cheats, call cheats({op:'lookup', platform, game}) with the match's own platform. Score is name similarity, not a content guarantee — verify a label before patching.`,
155
176
  };
156
177
  }
157
178
 
@@ -5,20 +5,22 @@
5
5
  // GET /tool/:name/schema that tool's JSON Schema (a validator on demand)
6
6
  // GET /openapi.json OpenAPI 3.1 spec for every /tool/:name route
7
7
  // GET /documentation Swagger UI over /openapi.json (live "try it" console)
8
- // GET /romdev-skill.md the SKILL.md (Agent Skills open standard) — channel
9
- // doc that drives the routes, never mentions MCP
8
+ // GET /skills/romdev/SKILL.md the SKILL.md (Agent Skills open standard) — the
9
+ // channel doc that drives the routes, never mentions
10
+ // MCP. Also at /romdev/SKILL.md and /romdev-skill.md.
10
11
  //
11
- // Sessions: each agent gets its own session dynamically, same isolation as MCP.
12
- // First call with no x-romdev-session mint one, return it in the response
13
- // header; the agent echoes it on later calls (sticky host across load→step→read).
14
- // A call with no header gets an ephemeral per-request session (fine for pure-file
15
- // tools; stateful host work should keep the header). No auth localhost trust,
16
- // same as /mcp (the app already mounts localhostHostValidation()).
12
+ // Sessions: each agent picks its own stable id and sends it as x-romdev-session
13
+ // on EVERY call (same per-agent host isolation as MCP). The header is REQUIRED —
14
+ // no header 401 (we don't auto-mint a throwaway session; that silently dropped
15
+ // the loaded ROM and surfaced as "No ROM loaded" later). First use of an id
16
+ // creates the session, reuse keeps the host across load→step→read, different ids
17
+ // isolate different agents. No auth beyond that — localhost trust, same as /mcp
18
+ // (the app already mounts localhostHostValidation()).
17
19
 
18
- import { randomUUID } from "node:crypto";
19
20
  import { buildToolRegistry, runTool, toolJsonSchema } from "./tool-registry.js";
20
21
  import { skillPreamble, skillToolReference, buildSkillDoc } from "./skill-doc.js";
21
22
  import { swaggerHtml, swaggerAsset } from "./swagger.js";
23
+ import { observer } from "../observer/bus.js";
22
24
  import { log } from "../mcp/log.js";
23
25
 
24
26
  const SESSION_HEADER = "x-romdev-session";
@@ -40,12 +42,16 @@ export function mountHttpToolRoutes(app, opts = {}) {
40
42
  /** @type {Map<string, {registry: Map<string,any>, lastSeen: number}>} */
41
43
  const sessions = new Map();
42
44
 
43
- function getSession(sessionKey) {
45
+ function getSession(sessionKey, { sticky = false } = {}) {
44
46
  let s = sessions.get(sessionKey);
45
47
  if (!s) {
46
- s = { registry: buildToolRegistry(sessionKey), lastSeen: Date.now() };
48
+ s = { registry: buildToolRegistry(sessionKey), lastSeen: Date.now(), sticky };
47
49
  sessions.set(sessionKey, s);
48
50
  log.debug(`[http] session ${sessionKey.slice(0, 8)} created (${sessions.size} active)`);
51
+ // Surface sticky sessions in /livestream (like the MCP path does on init).
52
+ // Ephemeral one-shot sessions are NOT registered (they'd spam connect/
53
+ // disconnect); their individual `call` events still show in the stream.
54
+ if (sticky) { try { observer.sessionConnected(sessionKey); } catch {} }
49
55
  } else {
50
56
  s.lastSeen = Date.now();
51
57
  }
@@ -58,6 +64,7 @@ export function mountHttpToolRoutes(app, opts = {}) {
58
64
  for (const [key, s] of sessions) {
59
65
  if (now - s.lastSeen > idleMs) {
60
66
  sessions.delete(key);
67
+ if (s.sticky) { try { observer.sessionDisconnected(key); } catch {} }
61
68
  log.debug(`[http] session ${key.slice(0, 8)} reaped (idle)`);
62
69
  }
63
70
  }
@@ -71,30 +78,38 @@ export function mountHttpToolRoutes(app, opts = {}) {
71
78
  // ── POST /tool/:name ──────────────────────────────────────────────────────
72
79
  app.post("/tool/:name", async (req, res) => {
73
80
  const name = req.params.name;
74
- // session: sticky if header present, ephemeral otherwise.
75
- let sessionKey = req.headers[SESSION_HEADER];
76
- let ephemeral = false;
81
+ // Session model: the AGENT picks its own stable, task-descriptive id and
82
+ // sends it as x-romdev-session on EVERY call — first use creates the session,
83
+ // reuse keeps the same host/state (load→step→read), and different ids isolate
84
+ // different agents. NO HEADER → 401: we don't auto-mint a throwaway session
85
+ // (that silently dropped the loaded ROM and surfaced as "No ROM loaded" two
86
+ // calls later). Requiring the header up front turns that silent footgun into
87
+ // a loud, fixable 401.
88
+ const sessionKey = req.headers[SESSION_HEADER];
77
89
  if (typeof sessionKey !== "string" || !sessionKey) {
78
- sessionKey = randomUUID();
79
- ephemeral = true;
90
+ res.status(401).json({
91
+ error: "Missing required `x-romdev-session` header. Pick ONE stable, " +
92
+ "task-descriptive id for yourself (e.g. 'nes-platformer-build') and send " +
93
+ "it on EVERY call — it's your per-session emulator key (the ROM you load " +
94
+ "lives under it; the next call only sees it with the SAME id) and the " +
95
+ "label shown in the /livestream observer. Several agents share one server " +
96
+ "by each using a different id.",
97
+ });
98
+ return;
80
99
  }
81
- const { registry } = getSession(sessionKey);
100
+ const { registry } = getSession(sessionKey, { sticky: true });
82
101
  const tool = registry.get(name);
83
102
  if (!tool) {
84
103
  res.status(404).json({
85
- error: `Unknown tool '${name}'. GET /openapi.json or /romdev-skill.md for the list.`,
104
+ error: `Unknown tool '${name}'. GET /openapi.json or /skills/romdev/SKILL.md for the list.`,
86
105
  });
87
106
  return;
88
107
  }
89
- // echo the session id so the agent can reuse it (esp. when we minted one)
108
+ // echo the session id back (convenience for clients that log it)
90
109
  res.setHeader(SESSION_HEADER, sessionKey);
91
- const out = await runTool(tool, req.body);
92
- if (ephemeral) {
93
- // drop the ephemeral session immediately (no sticky host wanted)
94
- sessions.delete(sessionKey);
95
- }
110
+ const out = await runTool(tool, req.body, sessionKey);
96
111
  if (out.ok) res.json(out.result);
97
- else res.status(400).json({ error: out.error });
112
+ else res.status(400).json(out.result ?? { error: out.error });
98
113
  });
99
114
 
100
115
  // ── GET /tool/:name/schema ────────────────────────────────────────────────
@@ -122,17 +137,26 @@ export function mountHttpToolRoutes(app, opts = {}) {
122
137
  res.send(buf);
123
138
  });
124
139
 
125
- // ── GET /romdev-skill.md ──────────────────────────────────────────────────
126
- app.get("/romdev-skill.md", (req, res) => {
140
+ // ── GET /skills/romdev/SKILL.md (primary) + aliases ───────────────────────
141
+ // Agents store skills on disk as skills/<name>/SKILL.md (a dir named after the
142
+ // skill, canonical file SKILL.md). We serve the same doc at several paths so
143
+ // the URL matches wherever the agent saved it:
144
+ // /skills/romdev/SKILL.md — primary: full disk mirror (~/.claude/skills/romdev/SKILL.md)
145
+ // /romdev/SKILL.md — alias: the <name>/SKILL.md tail
146
+ // /romdev-skill.md — alias: flat form (older refs)
147
+ const serveSkill = (req, res) => {
127
148
  const md = buildSkillDoc({
128
149
  registry: metaRegistry,
129
150
  agentsBody: opts.agentsBody ?? "",
130
151
  version,
131
152
  });
132
153
  res.type("text/markdown").send(md);
133
- });
154
+ };
155
+ app.get("/skills/romdev/SKILL.md", serveSkill);
156
+ app.get("/romdev/SKILL.md", serveSkill);
157
+ app.get("/romdev-skill.md", serveSkill); // alias
134
158
 
135
- log.debug("[http] tool surface mounted: POST /tool/:name, /openapi.json, /documentation, /romdev-skill.md");
159
+ log.debug("[http] tool surface mounted: POST /tool/:name, /openapi.json, /documentation, /skills/romdev/SKILL.md");
136
160
  return { sessions, stop: () => clearInterval(reaper) };
137
161
  }
138
162
 
@@ -158,13 +182,14 @@ export function buildOpenApi(registry, version) {
158
182
  },
159
183
  responses: {
160
184
  200: { description: "Tool result (JSON).", content: { "application/json": { schema: { type: "object" } } } },
161
- 400: { description: "Validation or tool error.", content: { "application/json": { schema: { type: "object", properties: { error: { type: "string" } } } } } },
185
+ 400: { description: "Validation or tool error (the action did not succeed).", content: { "application/json": { schema: { type: "object", properties: { error: { type: "string" } } } } } },
186
+ 401: { description: "Missing required x-romdev-session header.", content: { "application/json": { schema: { type: "object", properties: { error: { type: "string" } } } } } },
162
187
  404: { description: "Unknown tool." },
163
188
  },
164
189
  parameters: [{
165
- name: SESSION_HEADER, in: "header", required: false,
190
+ name: SESSION_HEADER, in: "header", required: true,
166
191
  schema: { type: "string" },
167
- description: "Per-agent session id. Omit on the first call to get one back in the response header; echo it on later calls to keep a sticky emulator session (load→step→read). Omit entirely for one-shot pure-file tools.",
192
+ description: "REQUIRED. Per-agent session id pick one stable, UNIQUE, task-DESCRIPTIVE string (e.g. 'nes-platformer-build', 'zelda-romhack-text') and send it on EVERY call. It's the per-session emulator key (load→step→read state lives under it) AND the label shown in the /livestream observer, so a descriptive id tells a watching human which task each call belongs to. Several agents share one server safely by each using a different id. Missing → 401.",
168
193
  }],
169
194
  },
170
195
  };
@@ -174,7 +199,7 @@ export function buildOpenApi(registry, version) {
174
199
  info: {
175
200
  title: "romdev HTTP tool API",
176
201
  version,
177
- description: "Plain-HTTP surface for romdev's retro-game-dev tools — the non-MCP way to drive the same tools. Generated from the tool registry. See /romdev-skill.md for the workflow guide.",
202
+ description: "Plain-HTTP surface for romdev's retro-game-dev tools — the non-MCP way to drive the same tools. Generated from the tool registry. See /skills/romdev/SKILL.md for the workflow guide.",
178
203
  },
179
204
  servers: [{ url: "/" }],
180
205
  paths,
@@ -3,7 +3,7 @@
3
3
  // One shared body (AGENTS.md, channel-neutral) is wrapped per delivery channel:
4
4
  // - MCP connection instructions = mcpPreamble + body (says "call the MCP
5
5
  // tools"; never mentions HTTP routes / skills)
6
- // - GET /romdev-skill.md = skill frontmatter + skillPreamble + body +
6
+ // - GET /skills/romdev/SKILL.md = skill frontmatter + skillPreamble + body +
7
7
  // generated tool reference (says "POST /tool/{name}"; never mentions MCP)
8
8
  //
9
9
  // So neither surface mentions the other: the delivery instructions live in the
@@ -38,8 +38,8 @@ export const skillPreamble = [
38
38
  " • GET /tool/{name}/schema — that tool's JSON Schema (the exact parameters + types).",
39
39
  " • GET /openapi.json — the full machine-readable API; GET /documentation — a browsable console.",
40
40
  "",
41
- "Sessions (for stateful work like load→step→read): your first POST returns an `x-romdev-session` header.",
42
- "Echo that header on subsequent calls to keep the SAME emulator session. Omit it for one-shot file tools.",
41
+ "## Sessions — IMPORTANT for stateful work (load step read)",
42
+ "**Pick ONE session id for yourself and send it as the `x-romdev-session` header on EVERY call.** Make it UNIQUE and DESCRIPTIVE of what you're doing — e.g. `nes-platformer-build`, `zelda-romhack-text`, `gba-sprite-debug` (a slug, optionally with a short random suffix to stay unique). A human may be watching the live observer at /livestream, where your session id is the label for all your activity — a descriptive id tells them at a glance which agent/task each call belongs to; a bare uuid or `default` is opaque. The emulator/host is per-session: the ROM you `loadMedia` lives in YOUR session, and the next `frame`/`memory`/`cpu` call only sees it if it carries the SAME id. Do NOT send a new id each call — that's a fresh empty session every time (your loaded ROM vanishes; \"No ROM loaded\"). Several agents can share one server safely: each just sends a DIFFERENT id, so nobody clobbers another's ROM (another reason to make yours distinctive). The header is REQUIRED on every `/tool/{name}` call — omit it and you get a **401** (the server will NOT silently run you in a throwaway session). Pure file tools (romPatch/cart/encodeAudio) still need the header; just reuse your one id everywhere.",
43
43
  "",
44
44
  "Each tool is a domain VERB keyed by an operation axis — e.g. POST /tool/memory {\"op\":\"read\",…},",
45
45
  "POST /tool/build {\"output\":\"rom\",…}, POST /tool/romPatch {\"op\":\"findPointer\",…}. The full per-tool",
@@ -47,7 +47,7 @@ export const skillPreamble = [
47
47
  ].join("\n");
48
48
 
49
49
  /**
50
- * Build GET /romdev-skill.md: frontmatter + skill preamble + shared body +
50
+ * Build GET /skills/romdev/SKILL.md: frontmatter + skill preamble + shared body +
51
51
  * generated tool reference.
52
52
  * @param {{registry: Map<string,any>, agentsBody: string, version?: string}} args
53
53
  * @returns {string}
@@ -68,16 +68,17 @@ export function buildSkillDoc({ registry, agentsBody, version }) {
68
68
 
69
69
  // Update note — stamped with the running server's version. A saved skill is a
70
70
  // static snapshot (it doesn't auto-update), but this doc is GENERATED live from
71
- // the running server, so re-fetching always gives you the current version. The
72
- // server reports its version at GET /healthz, so an agent can detect staleness.
71
+ // the running server, so re-fetching always gives the current version. An agent
72
+ // can check the running version two ways: the tool call POST /tool/catalog
73
+ // {"op":"status"} → `romdevVersion`, or GET /healthz → `version`.
73
74
  const v = version ?? "0.0.0";
74
75
  const updateNote = [
75
76
  "## Keeping this skill current",
76
77
  `This skill was generated by romdev **v${v}** (it's a snapshot — it does not auto-update). ` +
77
78
  "romdev generates it live from the running server, so to update: run the latest `npx romdevtools`, " +
78
- `then re-fetch \`GET http://localhost:7331/romdev-skill.md\` and overwrite your saved copy. ` +
79
- "The running server reports its version at `GET /healthz` (`{\"version\":\"…\"}`) — if it's newer than the " +
80
- "`metadata.version` above, your saved skill is stale; re-fetch it.",
79
+ `then re-fetch \`GET http://localhost:7331/skills/romdev/SKILL.md\` and overwrite your saved copy. ` +
80
+ "To check whether you're stale, ask the running server its version `POST /tool/catalog {\"op\":\"status\"}` " +
81
+ "returns `romdevVersion` (or `GET /healthz` → `version`); if it's newer than the `metadata.version` above, re-fetch.",
81
82
  ].join("\n");
82
83
 
83
84
  return [
@@ -56,7 +56,7 @@ export function swaggerHtml(opts = {}) {
56
56
  <p>Loading interactive docs… If this doesn't render, the raw OpenAPI spec is at
57
57
  <a href="${specUrl}"><code>${specUrl}</code></a>, every tool is callable via
58
58
  <code>POST /tool/{name}</code>, and the workflow guide is at
59
- <a href="/romdev-skill.md"><code>/romdev-skill.md</code></a>.</p>
59
+ <a href="/skills/romdev/SKILL.md"><code>/skills/romdev/SKILL.md</code></a>.</p>
60
60
  </div>
61
61
  <div id="swagger-ui"></div>
62
62
  <script src="${base}/swagger-ui-bundle.js"></script>