romdevtools 0.71.0 → 0.71.1

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/CHANGELOG.md CHANGED
@@ -4,6 +4,27 @@ 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.71.1 — 2026-06-29
8
+
9
+ Feedback from the first run against the `pret/pokeruby` GBA decompilation.
10
+
11
+ - **Project-mode `entry` now resolves a NESTED path** (e.g. `entry:'src/main.c'`). The
12
+ recursive asset staging already walked subdirs, but entry resolution stayed top-level
13
+ only — so any decomp/SDK-layout project whose entry isn't at the repo root failed with
14
+ "entry not found." Now it resolves against the recursive file set, reads the nested file
15
+ as the entry **source**, accepts a bare filename when it uniquely matches one nested
16
+ file, and errors with a "did you mean `src/…`?" hint.
17
+ - **`platform({op:'list'})` honors the `platform` filter** — returns just that one
18
+ platform's row instead of the whole 17-platform matrix (the biggest token sink reported:
19
+ ~17 KB → ~0.8 KB for one platform). New **`slim:true`** drops the verbose per-language
20
+ `note` + `quirks` prose (~60× smaller); detail stays behind `op:'doc'`/`op:'capabilities'`.
21
+ - **`platform({op:'resolve'})` surfaces the toolchain(s)** + a note that the build
22
+ toolchain is WASM/harness-only (was core-paths-only, forcing `node_modules` spelunking).
23
+ - **GBA `mental_model` "What's NOT bundled"** now calls out **agbcc's absence** (the real
24
+ blocker for byte-exact Gen-III decomps like pokeruby/pokeemerald) and adds a "romdev's
25
+ build model" section: `build` is single-shot compile→ROM, the toolchain can't back an
26
+ external Makefile, with the confirmed host agbcc recipe for matching builds.
27
+
7
28
  ## 0.71.0 — 2026-06-28
8
29
 
9
30
  ### N64 / PS1 / Dreamcast reach full parity: cpuState + audioDebug + GPU-rendering helper libs + 5 examples each
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "romdevtools",
3
- "version": "0.71.0",
3
+ "version": "0.71.1",
4
4
  "description": "Tool server giving coding agents full control of homebrew ROM development AND reverse-engineering/romhacking across 17 retro platforms (NES, SNES, GB, Genesis, Atari, C64, PC Engine, MSX, PlayStation, N64, Dreamcast, ...) 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",
@@ -93,9 +93,17 @@ const PLATFORM_QUIRKS = {
93
93
  };
94
94
 
95
95
  /** op:'list' — every platform with core/toolchains/languages/quirks. */
96
- export function listPlatformsCore() {
96
+ export function listPlatformsCore({ platform, slim } = {}) {
97
97
  const available = new Set(listAvailableCores());
98
- const platforms = Object.entries(CORES).map(([id, info]) => {
98
+ let ids = Object.keys(CORES);
99
+ if (platform) {
100
+ if (!CORES[platform]) {
101
+ throw new Error(`platform({op:'list'}): unknown platform '${platform}'. Known: ${ids.join(", ")}.`);
102
+ }
103
+ ids = [platform]; // per-platform filter — the big token-sink fix (v0.71.0 fb)
104
+ }
105
+ const platforms = ids.map((id) => {
106
+ const info = CORES[id];
99
107
  const toolchains = Object.values(TOOLCHAINS)
100
108
  .filter((t) => t.platforms.includes(id))
101
109
  .map((t) => ({ id: t.id, displayName: t.displayName, tier: t.tier }));
@@ -107,18 +115,45 @@ export function listPlatformsCore() {
107
115
  toolchains,
108
116
  };
109
117
  const langs = getLanguageOptions(id);
110
- if (langs) entry.languages = langs;
111
- if (PLATFORM_QUIRKS[id]) entry.quirks = PLATFORM_QUIRKS[id];
118
+ if (langs) {
119
+ // `slim` drops the heavy per-language `note` + `quirks` prose (the big
120
+ // token sink when you only need "which platforms/toolchains/languages
121
+ // exist"). Detail stays behind platform({op:'doc'}) + op:'capabilities'.
122
+ entry.languages = slim
123
+ ? { defaultLanguage: langs.defaultLanguage,
124
+ languages: (langs.languages || []).map((l) => ({ language: l.language, toolchain: l.toolchain, available: l.available })) }
125
+ : langs;
126
+ }
127
+ if (!slim && PLATFORM_QUIRKS[id]) entry.quirks = PLATFORM_QUIRKS[id];
112
128
  return entry;
113
129
  });
114
- return { platforms };
130
+ // A single-platform query returns just that row (not wrapped in `platforms[]`).
131
+ return platform ? platforms[0] : { platforms };
115
132
  }
116
133
 
117
- /** op:'resolve' — resolved core paths for a platform (debugging aid). */
134
+ /** op:'resolve' — resolved core paths + the toolchain summary for a platform. */
118
135
  export function resolvePlatformCore({ platform }) {
119
136
  const r = resolveCore(platform);
120
137
  if (!r) throw new Error(`no core available for platform '${platform}'`);
121
- return r;
138
+ // Also surface the toolchain(s) — resolve used to report only the emulator
139
+ // core, so agents had to spelunk node_modules to learn the build path (v0.71.0
140
+ // fb #3). We do NOT hand out the WASM/.mjs artifact paths: those tools run ONLY
141
+ // inside romdev's `build` worker harness (virtual FS), so a node_modules path
142
+ // invites the wrong mental model (shimming them into an external Makefile —
143
+ // which does NOT work). The `note` states that plainly (fb #4/#5).
144
+ const toolchains = Object.values(TOOLCHAINS)
145
+ .filter((t) => t.platforms.includes(platform))
146
+ .map((t) => ({ id: t.id, displayName: t.displayName, tier: t.tier }));
147
+ return {
148
+ ...r,
149
+ toolchains,
150
+ toolchainNote:
151
+ "Build via the `build` tool (it compiles a source set into one ROM). The toolchain " +
152
+ "binaries are WASM, run ONLY inside romdev's build worker (virtual FS) — they are NOT " +
153
+ "host-callable and CANNOT back an external project's Makefile. For an existing decomp/" +
154
+ "romhack that needs its own legacy compiler (e.g. agbcc) + Makefile, build it on the host " +
155
+ "and use romdev to run/inspect/debug the resulting ROM.",
156
+ };
122
157
  }
123
158
 
124
159
  export function registerPlatformTools(server, z) {
@@ -142,14 +177,15 @@ export function registerPlatformTools(server, z) {
142
177
  "troubleshooting / upstream_sources; `platform:'romhacking'` + `name:'playbook'` for the RE decision tree). " +
143
178
  "Read MENTAL_MODEL before writing code, and the romhacking playbook before a hack.",
144
179
  {
145
- op: z.enum(["list", "capabilities", "resolve", "toolchains", "docs", "doc"]).describe("list=platforms; capabilities=per-platform op support matrix; resolve=core paths; toolchains; docs=a platform's doc names; doc=read one doc."),
146
- platform: z.string().optional().describe("op=resolve/docs/doc: platform id (e.g. nes, gb, genesis; 'romhacking' for the RE playbook)."),
180
+ op: z.enum(["list", "capabilities", "resolve", "toolchains", "docs", "doc"]).describe("list=platforms (pass `platform` to get just ONE, `slim:true` to drop the verbose notes); capabilities=per-platform op support matrix; resolve=core + toolchain artifact paths; toolchains; docs=a platform's doc names; doc=read one doc."),
181
+ platform: z.string().optional().describe("op=list/resolve/docs/doc/capabilities: platform id (e.g. nes, gb, genesis; 'romhacking' for the RE playbook). On op=list it filters to that ONE platform's row instead of the whole matrix (big token saver)."),
182
+ slim: z.boolean().optional().describe("op=list: drop the heavy per-language `note` + `quirks` prose; return just {platform, toolchains[], languages{defaultLanguage,…}}. Detail stays behind op:'doc' / op:'capabilities'."),
147
183
  id: z.string().optional().describe("op=toolchains: a specific toolchain's install status (e.g. 'cc65')."),
148
184
  name: z.string().optional().describe("op=doc: which doc — mental_model | troubleshooting | upstream_sources | playbook."),
149
185
  },
150
186
  safeTool(async (args) => {
151
187
  switch (args.op) {
152
- case "list": return jsonContent(listPlatformsCore());
188
+ case "list": return jsonContent(listPlatformsCore({ platform: args.platform, slim: args.slim }));
153
189
  case "capabilities": {
154
190
  if (args.platform) {
155
191
  const cap = capabilitiesFor(args.platform);
@@ -977,16 +977,33 @@ export async function readProjectDir(projPath, platform, opts = {}) {
977
977
  const subAssets = await walkSubdirAssets(projPath);
978
978
 
979
979
  // Entry override: an existing project whose top file isn't main.* (e.g.
980
- // smw.asm). Accept a project-relative or bare name; it becomes the single
981
- // entry source. Without it, auto-detect main.c / main.s / main.asm.
982
- const entryName = opts.entry ? path.normalize(opts.entry).replace(/^[./]+/, "") : null;
980
+ // smw.asm) OR is nested (e.g. src/main.c common for decomps/SDK projects).
981
+ // Accept a project-relative path or a bare name; it becomes the single entry
982
+ // source. Resolve against BOTH the top-level files AND the recursively staged
983
+ // subdir set, with POSIX slashes (`path.normalize` may emit `\` on Windows).
984
+ const entryName = opts.entry
985
+ ? path.normalize(opts.entry).replace(/\\/g, "/").replace(/^[./]+/, "")
986
+ : null;
987
+ // The resolved {rel, abs} for the entry when it's nested in a subdirectory.
988
+ let entrySubAsset = null;
983
989
  if (entryName) {
984
- const exists = files.some((f) => f.name === entryName) ||
985
- subAssets.some((a) => a.rel === entryName);
986
- if (!exists) {
990
+ const topMatch = files.some((f) => f.name === entryName);
991
+ entrySubAsset = subAssets.find((a) => a.rel === entryName) || null;
992
+ // Bare-filename fallback: `entry:'main.c'` matches `src/main.c` if unique.
993
+ if (!topMatch && !entrySubAsset && !entryName.includes("/")) {
994
+ const byBase = subAssets.filter((a) => a.rel.split("/").pop() === entryName);
995
+ if (byBase.length === 1) entrySubAsset = byBase[0];
996
+ }
997
+ if (!topMatch && !entrySubAsset) {
998
+ const nearby = subAssets
999
+ .filter((a) => a.rel.split("/").pop() === entryName.split("/").pop())
1000
+ .map((a) => a.rel).slice(0, 8);
987
1001
  throw new Error(
988
- `entry '${entryName}' not found in ${projPath}. Found top-level: ` +
989
- `${files.map((f) => f.name).join(", ") || "(none)"}.`
1002
+ `entry '${entryName}' not found in ${projPath}. ` +
1003
+ (nearby.length
1004
+ ? `Did you mean: ${nearby.join(", ")}? (entry is project-relative — e.g. 'src/main.c'.)`
1005
+ : `Top-level: ${files.map((f) => f.name).join(", ") || "(none)"}. ` +
1006
+ `entry is project-relative — pass the path under the repo root (e.g. 'src/main.c').`)
990
1007
  );
991
1008
  }
992
1009
  }
@@ -1006,7 +1023,7 @@ export async function readProjectDir(projPath, platform, opts = {}) {
1006
1023
  // the dir build matches the hand-written build({output:'run'}) call.
1007
1024
  const recipe = projectBuildRecipe(platform, files.map((f) => f.name));
1008
1025
 
1009
- // With a custom entry, the entry is the ONE source; every OTHER top-level
1026
+ // With a custom ASM entry, the entry is the ONE source; every OTHER top-level
1010
1027
  // .asm/.s/.c routes as an include (asar/wla resolve `.include`/`#include`
1011
1028
  // from the includes mount), matching the single-source asm model.
1012
1029
  if (entryName && /\.(asm|s)$/i.test(entryName)) {
@@ -1040,7 +1057,7 @@ export async function readProjectDir(projPath, platform, opts = {}) {
1040
1057
  // the mount. Text-ish includes (.asm/.s/.h/.inc) found in subdirs go to includes
1041
1058
  // (so `incsrc "lib/foo.asm"` works); everything else is a binary asset.
1042
1059
  for (const a of subAssets) {
1043
- if (recipe.skip.has(a.rel) || a.rel === entryName) continue;
1060
+ if (recipe.skip.has(a.rel) || a.rel === entrySubAsset?.rel) continue;
1044
1061
  if (/\.(h|inc|asm|s)$/i.test(a.rel)) {
1045
1062
  includes[a.rel] = (await readFile(a.abs)).toString("utf-8");
1046
1063
  } else {
@@ -1048,6 +1065,14 @@ export async function readProjectDir(projPath, platform, opts = {}) {
1048
1065
  }
1049
1066
  }
1050
1067
 
1068
+ // A NESTED entry (e.g. src/main.c) is read here as the entry SOURCE — the
1069
+ // top-level loop above only sees root files, so a subdir entry would otherwise
1070
+ // never compile. Keyed by its relative path so #include/.include resolution
1071
+ // from the same subdir works.
1072
+ if (entrySubAsset) {
1073
+ sources[entrySubAsset.rel] = (await readFile(entrySubAsset.abs)).toString("utf-8");
1074
+ }
1075
+
1051
1076
  // SMS/GG with no crt0 file in the dir → fall back to the bundled crt0,
1052
1077
  // exactly like the output:'rom'/'run' handlers do. Without this the link
1053
1078
  // silently uses SDCC's stock z80 crt0, which never calls main() (black
@@ -248,6 +248,11 @@ Loadable via mGBA (`loadMedia`).
248
248
 
249
249
  ## What's NOT bundled
250
250
 
251
+ - **`agbcc` (the legacy GBA compiler).** romdev's GBA C path is **modern
252
+ `arm-none-eabi-gcc` 14.2.0** only. The byte-exact decompilations + romhacks
253
+ (pokeruby / pokeemerald / pokefirered, etc.) build with agbcc + a custom
254
+ `ld_script.txt`, so romdev **cannot reproduce a matching retail ROM** for those.
255
+ That's a hard limit, not a missing feature — see "romdev's build model" below.
251
256
  - **libgba's `console.c`** (iprintf-style stdio output). Pulls in
252
257
  devkitPro's libsysbase header chain — not yet ported. See
253
258
  TROUBLESHOOTING.md for the trade-off rationale and workarounds.
@@ -256,7 +261,23 @@ Loadable via mGBA (`loadMedia`).
256
261
  - **devkitARM's `bin2s`** (binary → assembly converter for asset
257
262
  pipelines). Not bundled; ship binary assets as C arrays for now.
258
263
 
259
- Everything else from a stock devkitARM install works.
264
+ Everything else from a stock devkitARM install (homebrew-style) works.
265
+
266
+ ## romdev's build model (read this before driving an existing project)
267
+
268
+ `build` is a **single-shot "compile these sources → one ROM" tool**, NOT an arbitrary
269
+ build-system backend. The toolchain binaries are **WASM, run only inside romdev's build
270
+ worker (virtual FS)** — they are **not host-callable** and **cannot back an external
271
+ project's `Makefile`** as `$(TOOLCHAIN)/bin`. (The `.mjs` wrappers under `node_modules`
272
+ export a worker factory, not a CLI `main` — invoking one directly exits 0 and writes
273
+ nothing.)
274
+
275
+ So for an **existing decomp/romhack** (agbcc-era or any project with its own Makefile):
276
+ build it **on the host** with its own toolchain, then point romdev at the resulting
277
+ `.gba` for the run / inspect / debug / decompile loop. Confirmed host recipe for the
278
+ Pokémon Gen-III decomps: `brew install arm-none-eabi-gcc arm-none-eabi-binutils`, clone
279
+ `pret/agbcc` → `./build.sh` → `./install.sh <repo>`, then `make <target>` (yields a
280
+ byte-matching ROM). romdev's value here is everything AFTER the build, not the build.
260
281
 
261
282
  ## Horizontal scrolling (for side-scrollers)
262
283