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 +21 -0
- package/package.json +1 -1
- package/src/mcp/tools/platforms.js +46 -10
- package/src/mcp/tools/toolchain.js +35 -10
- package/src/platforms/gba/MENTAL_MODEL.md +22 -1
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.
|
|
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
|
-
|
|
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)
|
|
111
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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)
|
|
981
|
-
//
|
|
982
|
-
|
|
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
|
|
985
|
-
|
|
986
|
-
if
|
|
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}.
|
|
989
|
-
|
|
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 ===
|
|
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
|
|