romdevtools 0.28.0 → 0.29.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.
Files changed (154) hide show
  1. package/AGENTS.md +51 -41
  2. package/CHANGELOG.md +46 -0
  3. package/README.md +3 -3
  4. package/examples/README.md +7 -7
  5. package/examples/atari2600/templates/platformer.asm +1225 -332
  6. package/examples/atari2600/templates/puzzle.asm +1056 -0
  7. package/examples/atari2600/templates/racing.asm +906 -275
  8. package/examples/atari2600/templates/shmup.asm +1031 -239
  9. package/examples/atari2600/templates/sports.asm +1135 -253
  10. package/examples/atari7800/templates/platformer.c +991 -156
  11. package/examples/atari7800/templates/puzzle.c +1091 -148
  12. package/examples/atari7800/templates/racing.c +952 -124
  13. package/examples/atari7800/templates/shmup.c +812 -134
  14. package/examples/atari7800/templates/sports.c +820 -184
  15. package/examples/c64/templates/platformer.c +879 -164
  16. package/examples/c64/templates/puzzle.c +855 -178
  17. package/examples/c64/templates/racing.c +873 -97
  18. package/examples/c64/templates/shmup.c +757 -161
  19. package/examples/c64/templates/sports.c +755 -100
  20. package/examples/gb/templates/platformer.c +841 -179
  21. package/examples/gb/templates/puzzle.c +986 -246
  22. package/examples/gb/templates/racing.c +754 -174
  23. package/examples/gb/templates/shmup.c +673 -175
  24. package/examples/gb/templates/sports.c +790 -159
  25. package/examples/gba/templates/platformer.c +626 -165
  26. package/examples/gba/templates/puzzle.c +519 -269
  27. package/examples/gba/templates/racing.c +511 -206
  28. package/examples/gba/templates/shmup.c +564 -179
  29. package/examples/gba/templates/sports.c +454 -174
  30. package/examples/gbc/templates/platformer.c +944 -180
  31. package/examples/gbc/templates/puzzle.c +363 -109
  32. package/examples/gbc/templates/racing.c +884 -180
  33. package/examples/gbc/templates/shmup.c +821 -185
  34. package/examples/gbc/templates/sports.c +870 -162
  35. package/examples/genesis/templates/platformer.c +747 -129
  36. package/examples/genesis/templates/puzzle.c +694 -261
  37. package/examples/genesis/templates/racing.c +726 -203
  38. package/examples/genesis/templates/shmup.c +535 -142
  39. package/examples/genesis/templates/sports.c +495 -158
  40. package/examples/gg/templates/platformer.c +880 -215
  41. package/examples/gg/templates/puzzle.c +875 -216
  42. package/examples/gg/templates/racing.c +915 -172
  43. package/examples/gg/templates/shmup.c +714 -191
  44. package/examples/gg/templates/sports.c +732 -129
  45. package/examples/lynx/templates/platformer.c +604 -69
  46. package/examples/lynx/templates/puzzle.c +498 -158
  47. package/examples/lynx/templates/racing.c +538 -102
  48. package/examples/lynx/templates/shmup.c +458 -131
  49. package/examples/lynx/templates/sports.c +496 -72
  50. package/examples/msx/platformer/main.c +649 -162
  51. package/examples/msx/puzzle/main.c +742 -240
  52. package/examples/msx/racing/main.c +669 -178
  53. package/examples/msx/shmup/main.c +460 -178
  54. package/examples/msx/sports/main.c +592 -126
  55. package/examples/nes/templates/platformer.c +589 -171
  56. package/examples/nes/templates/puzzle.c +563 -242
  57. package/examples/nes/templates/racing.c +502 -208
  58. package/examples/nes/templates/shmup.c +339 -145
  59. package/examples/nes/templates/sports.c +341 -183
  60. package/examples/pce/platformer/main.c +874 -205
  61. package/examples/pce/puzzle/main.c +802 -287
  62. package/examples/pce/racing/main.c +783 -208
  63. package/examples/pce/shmup/main.c +638 -212
  64. package/examples/pce/sports/main.c +586 -169
  65. package/examples/porting-across-platforms/README.md +1 -1
  66. package/examples/sms/templates/platformer.c +762 -177
  67. package/examples/sms/templates/puzzle.c +752 -212
  68. package/examples/sms/templates/racing.c +808 -145
  69. package/examples/sms/templates/shmup.c +599 -162
  70. package/examples/sms/templates/sports.c +630 -122
  71. package/examples/snes/templates/music_demo.c +7 -0
  72. package/examples/snes/templates/platformer-data.asm +123 -24
  73. package/examples/snes/templates/platformer-hdr.asm +57 -0
  74. package/examples/snes/templates/platformer.c +586 -165
  75. package/examples/snes/templates/puzzle-data.asm +116 -21
  76. package/examples/snes/templates/puzzle-hdr.asm +57 -0
  77. package/examples/snes/templates/puzzle.c +614 -235
  78. package/examples/snes/templates/racing-data.asm +390 -32
  79. package/examples/snes/templates/racing-hdr.asm +57 -0
  80. package/examples/snes/templates/racing.c +807 -196
  81. package/examples/snes/templates/shmup-data.asm +87 -29
  82. package/examples/snes/templates/shmup-hdr.asm +57 -0
  83. package/examples/snes/templates/shmup.c +459 -198
  84. package/examples/snes/templates/sports-data.asm +48 -2
  85. package/examples/snes/templates/sports-hdr.asm +57 -0
  86. package/examples/snes/templates/sports.c +414 -163
  87. package/package.json +1 -1
  88. package/src/host/LibretroHost.js +59 -1
  89. package/src/http/tool-registry.js +11 -11
  90. package/src/mcp/tools/cheats.js +2 -1
  91. package/src/mcp/tools/frame.js +3 -2
  92. package/src/mcp/tools/index.js +3 -3
  93. package/src/mcp/tools/input.js +5 -4
  94. package/src/mcp/tools/lifecycle.js +6 -4
  95. package/src/mcp/tools/platform-docs.js +1 -1
  96. package/src/mcp/tools/preview-tile.js +6 -2
  97. package/src/mcp/tools/project.js +1098 -130
  98. package/src/mcp/tools/rom-id.js +5 -1
  99. package/src/mcp/tools/run-until.js +4 -2
  100. package/src/mcp/tools/snippets.js +6 -6
  101. package/src/mcp/tools/sprite-pipeline.js +14 -2
  102. package/src/mcp/tools/state.js +2 -1
  103. package/src/mcp/tools/tile-inspect.js +8 -1
  104. package/src/mcp/tools/toolchain.js +12 -1
  105. package/src/mcp/tools/watch-memory.js +4 -3
  106. package/src/observer/bus.js +73 -0
  107. package/src/observer/livestream.html +4 -2
  108. package/src/observer/tool-wrap.js +17 -14
  109. package/src/platforms/atari7800/MENTAL_MODEL.md +5 -5
  110. package/src/platforms/atari7800/TROUBLESHOOTING.md +5 -5
  111. package/src/platforms/c64/MENTAL_MODEL.md +11 -4
  112. package/src/platforms/c64/TROUBLESHOOTING.md +13 -0
  113. package/src/platforms/gb/MENTAL_MODEL.md +3 -3
  114. package/src/platforms/gb/TROUBLESHOOTING.md +61 -8
  115. package/src/platforms/gb/lib/c/README.md +10 -11
  116. package/src/platforms/gb/lib/c/gb_crt0.s +27 -3
  117. package/src/platforms/gb/lib/c/patch-header.js +13 -3
  118. package/src/platforms/gba/MENTAL_MODEL.md +4 -4
  119. package/src/platforms/gba/TROUBLESHOOTING.md +3 -3
  120. package/src/platforms/gba/lib/c/gba_sfx.c +40 -0
  121. package/src/platforms/gba/lib/c/gba_sfx.h +10 -0
  122. package/src/platforms/gbc/MENTAL_MODEL.md +4 -4
  123. package/src/platforms/gbc/TROUBLESHOOTING.md +4 -4
  124. package/src/platforms/gbc/UPSTREAM_SOURCES.md +1 -1
  125. package/src/platforms/gbc/lib/c/README.md +10 -11
  126. package/src/platforms/gbc/lib/c/gb_crt0.s +26 -3
  127. package/src/platforms/gbc/lib/c/patch-header.js +13 -3
  128. package/src/platforms/genesis/MENTAL_MODEL.md +3 -3
  129. package/src/platforms/genesis/TROUBLESHOOTING.md +2 -2
  130. package/src/platforms/gg/MENTAL_MODEL.md +4 -4
  131. package/src/platforms/gg/TROUBLESHOOTING.md +3 -3
  132. package/src/platforms/gg/UPSTREAM_SOURCES.md +1 -1
  133. package/src/platforms/gg/lib/c/joypad_read.c +29 -0
  134. package/src/platforms/lynx/MENTAL_MODEL.md +1 -1
  135. package/src/platforms/lynx/TROUBLESHOOTING.md +3 -3
  136. package/src/platforms/msx/MENTAL_MODEL.md +5 -5
  137. package/src/platforms/msx/TROUBLESHOOTING.md +2 -2
  138. package/src/platforms/msx/lib/c/msx_hw.h +1 -0
  139. package/src/platforms/msx/lib/c/msx_vdp.c +25 -0
  140. package/src/platforms/nes/MENTAL_MODEL.md +2 -2
  141. package/src/platforms/nes/lib/c/nes_runtime.c +149 -34
  142. package/src/platforms/nes/lib/c/nes_runtime.h +34 -1
  143. package/src/platforms/pce/MENTAL_MODEL.md +5 -5
  144. package/src/platforms/pce/TROUBLESHOOTING.md +1 -1
  145. package/src/platforms/pce/lib/c/pce_hw.h +11 -0
  146. package/src/platforms/pce/lib/c/pce_video.c +32 -0
  147. package/src/platforms/sms/MENTAL_MODEL.md +6 -6
  148. package/src/platforms/snes/MENTAL_MODEL.md +2 -2
  149. package/src/platforms/snes/TROUBLESHOOTING.md +40 -1
  150. package/src/toolchains/cc65/presets/nes/chr-ram-runtime.cfg +13 -8
  151. package/src/toolchains/cc65/presets/nes/chr-ram-runtime.crt0.s +58 -5
  152. package/src/toolchains/cc65/presets/nes/chr-rom.crt0.s +52 -3
  153. package/src/toolchains/cc65/presets/pce/rom32k.cfg +52 -0
  154. package/src/toolchains/index.js +27 -11
@@ -305,13 +305,17 @@ export async function extractSpriteSheetCore({ platform, path: romPath, offset,
305
305
  const path = await import("node:path");
306
306
  await mkdir(path.dirname(outputPath), { recursive: true });
307
307
  await writeFile(outputPath, png);
308
- return jsonContent({
308
+ // Livestream sideband: show the rendered sheet even though the agent
309
+ // only gets the path.
310
+ const out = jsonContent({
309
311
  path: outputPath,
310
312
  intent: d.intent,
311
313
  bytes: png.length,
312
314
  paletteSource,
313
315
  note,
314
316
  });
317
+ out._observerImages = [{ kind: "image", mimeType: "image/png", base64: png.toString("base64") }];
318
+ return out;
315
319
  }
316
320
  return {
317
321
  content: [
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { getHost } from "../state.js";
12
12
  import { jsonContent, safeTool } from "../util.js";
13
+ import { attachObserverFrame } from "./watch-memory.js";
13
14
 
14
15
  export function registerRunUntilTools(server, z, sessionKey) {
15
16
  const memoryCondition = z.object({
@@ -75,11 +76,12 @@ export function registerRunUntilTools(server, z, sessionKey) {
75
76
  }
76
77
  }
77
78
 
78
- return jsonContent({
79
+ // Livestream: the frame where the condition was met (or where we gave up).
80
+ return attachObserverFrame(jsonContent({
79
81
  conditionMet: met,
80
82
  framesStepped,
81
83
  finalValue,
82
- });
84
+ }), host, met ? "runUntil: condition met" : "runUntil: gave up");
83
85
  }),
84
86
  );
85
87
  }
@@ -97,11 +97,11 @@ export function registerSnippetTools(_server, _z) {
97
97
  platform, languages, snippets: filtered,
98
98
  note: filtered.length === 0
99
99
  ? `No snippets matched for '${platform}'${language ? ` (language=${language})` : ""}.`
100
- : `Fetch one with starterSnippets({ platform, mode:'get', name${language ? ", language" : ""} }), or all with mode:'getAll'.`,
100
+ : `Fetch one with examples({ op:'snippets', platform, mode:'get', snippetName${language ? ", language" : ""} }), or all with mode:'getAll'.`,
101
101
  });
102
102
  }
103
103
  async function snippetsGet(platform, name, language) {
104
- if (!name) throw new Error("starterSnippets mode:'get' requires `name`.");
104
+ if (!name) throw new Error("examples({op:'snippets'}) mode:'get' requires `snippetName`.");
105
105
  if (name.includes("..") || (name.includes("/") && !/^[a-z]+\/[\w.-]+$/i.test(name))) {
106
106
  throw new Error("snippet name must not contain '..' or arbitrary path separators");
107
107
  }
@@ -124,7 +124,7 @@ export function registerSnippetTools(_server, _z) {
124
124
  return textContent(`No snippets available for '${platform}'${language ? ` (language=${language})` : ""}.`);
125
125
  }
126
126
  if (!inline && !outputPath) {
127
- throw new Error("starterSnippets mode:'getAll': pass outputPath (write the joined snippets to disk, returns {path}) or inline:true (return `combined` in the response). Or use copyStarterSnippets to write each snippet as its own file.");
127
+ throw new Error("examples({op:'snippets'}) mode:'getAll': pass outputPath (write the joined snippets to disk, returns {path}) or inline:true (return `combined` in the response). Or use examples({op:'copySnippets'}) to write each snippet as its own file.");
128
128
  }
129
129
  const parts = [];
130
130
  for (const s of filtered) {
@@ -161,7 +161,7 @@ export function registerSnippetTools(_server, _z) {
161
161
  if (filtered.length === 0) {
162
162
  const langPart = language ? ` (language=${language})` : "";
163
163
  const includePart = include ? ` (include=${JSON.stringify(include)})` : "";
164
- throw new Error(`copyStarterSnippets: no snippets matched for platform '${platform}'${langPart}${includePart}.`);
164
+ throw new Error(`examples({op:'copySnippets'}): no snippets matched for platform '${platform}'${langPart}${includePart}.`);
165
165
  }
166
166
  await mkdir(destinationDir, { recursive: true });
167
167
  const written = [];
@@ -201,9 +201,9 @@ export function registerSnippetTools(_server, _z) {
201
201
  };
202
202
  }
203
203
 
204
- // starterSnippets/copyStarterSnippets folded into the `scaffold` tool. The cores
204
+ // starterSnippets/copyStarterSnippets folded into the `examples` tool. The cores
205
205
  // are assigned inside registerSnippetTools (they close over the local helpers);
206
- // scaffold imports these and calls them. registerSnippetTools registers NO tools
206
+ // examples imports these and calls them. registerSnippetTools registers NO tools
207
207
  // now — it just wires the cores.
208
208
  export let starterSnippetsCore = async () => { throw new Error("snippet cores not initialized — registerSnippetTools must run first"); };
209
209
  export let copyStarterSnippetsCore = async () => { throw new Error("snippet cores not initialized — registerSnippetTools must run first"); };
@@ -143,6 +143,7 @@ async function cropSpriteSheetImpl({ path, tileX, tileY, tileW, tileH, tileSize
143
143
  }
144
144
  await writeFile(outputPath, outBuf);
145
145
  return {
146
+ _observerImages: [{ kind: "image", mimeType: "image/png", base64: outBuf.toString("base64") }],
146
147
  path: outputPath,
147
148
  intent: d.intent,
148
149
  width: pxW,
@@ -157,6 +158,16 @@ async function cropSpriteSheetImpl({ path, tileX, tileY, tileW, tileH, tileSize
157
158
  };
158
159
  }
159
160
 
161
+ // Lift a plain core result's livestream sideband OUT of the JSON body and
162
+ // onto the MCP result object (it must never serialize into agent-visible text).
163
+ function liftObserverImages(r) {
164
+ const sideband = r._observerImages;
165
+ delete r._observerImages;
166
+ const out = jsonContent(r);
167
+ if (sideband) out._observerImages = sideband;
168
+ return out;
169
+ }
170
+
160
171
  // ── quantizePngForPlatform ──────────────────────────────────────────
161
172
 
162
173
  /**
@@ -247,6 +258,7 @@ async function quantizePngForPlatformImpl({ path, platform, outputPath, intent,
247
258
  const outBuf = writeIndexedPng(png.width, png.height, indices, palette);
248
259
  await writeFile(outputPath, outBuf);
249
260
  return {
261
+ _observerImages: [{ kind: "image", mimeType: "image/png", base64: outBuf.toString("base64") }],
250
262
  path: outputPath,
251
263
  intent: d.intent,
252
264
  width: png.width,
@@ -596,12 +608,12 @@ export function registerSpritePipelineTools(server, z, _sessionKey) {
596
608
  case "quantize": {
597
609
  if (!args.platform) throw new Error("encodeArt({stage:'quantize'}): `platform` is required.");
598
610
  if (!args.path || !args.outputPath) throw new Error("encodeArt({stage:'quantize'}): `path` and `outputPath` are required.");
599
- return jsonContent(await quantizePngForPlatformImpl(args));
611
+ return liftObserverImages(await quantizePngForPlatformImpl(args));
600
612
  }
601
613
  case "crop": {
602
614
  if (!args.path || !args.outputPath) throw new Error("encodeArt({stage:'crop'}): `path` and `outputPath` are required.");
603
615
  if (args.tileX == null || args.tileY == null || args.tileW == null || args.tileH == null) throw new Error("encodeArt({stage:'crop'}): `tileX`, `tileY`, `tileW`, `tileH` are required.");
604
- return jsonContent(await cropSpriteSheetImpl(args));
616
+ return liftObserverImages(await cropSpriteSheetImpl(args));
605
617
  }
606
618
  case "tiles": {
607
619
  if (!args.platform) throw new Error("encodeArt({stage:'tiles'}): `platform` is required.");
@@ -1,6 +1,7 @@
1
1
  import { mkdir, writeFile, readFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { getHost } from "../state.js";
4
+ import { attachObserverFrame } from "./watch-memory.js";
4
5
  import { jsonContent, safeTool } from "../util.js";
5
6
 
6
7
  // Resolve a state-file `path`. An ABSOLUTE path is used as-is. A RELATIVE path
@@ -342,7 +343,7 @@ export function registerStateTools(server, z, sessionKey) {
342
343
  safeTool(async (args) => {
343
344
  switch (args.op) {
344
345
  case "save": return jsonContent(await saveStateCore(args, sessionKey));
345
- case "load": return jsonContent(await loadStateCore(args, sessionKey));
346
+ case "load": return attachObserverFrame(jsonContent(await loadStateCore(args, sessionKey)), getHost(sessionKey), `state load ${args.name ?? args.path ?? ""}`.trim());
346
347
  case "list": return jsonContent(listStatesCore(args, sessionKey));
347
348
  case "export": {
348
349
  if (!args.fromSlot) throw new Error("state({op:'export'}): `fromSlot` is required.");
@@ -284,7 +284,14 @@ export function registerTileInspectTools(server, z, sessionKey) {
284
284
  if (r.pngBase64) {
285
285
  return { content: [imageContent(r.pngBase64), textContent(JSON.stringify({ ...r, pngBase64: undefined }))] };
286
286
  }
287
- return jsonContent(r);
287
+ // Lift the livestream sideband OUT of the core's plain result before
288
+ // it's serialized — it must ride on the MCP result object, never in
289
+ // the agent-visible JSON text.
290
+ const sideband = r._observerImages;
291
+ delete r._observerImages;
292
+ const out = jsonContent(r);
293
+ if (sideband) out._observerImages = sideband;
294
+ return out;
288
295
  }
289
296
  default: throw new Error(`tiles: unknown op '${args.op}'`);
290
297
  }
@@ -703,7 +703,7 @@ export function registerToolchainTools(server, z, sessionKey) {
703
703
  "• output:'rom' (default) — assemble or compile `source` (single) / `sources` ({name:contents}) / `sourcePath` / `sourcesPaths`. Returns the ROM (path by default; `inline:true` for binaryBase64) + build log. **`binaryIncludes`/`binaryIncludePaths` (base64/path CHR-ROM, music blobs for `.incbin`) — WITHOUT them no game with external assets builds.** `includes`/`includePaths` for `.include`d text. `linkerConfig` (cc65; NES preset 'chr-ram-runtime' RECOMMENDED). `crt0`/`crt0Path`/`codeLoc`/`dataLoc` (SDCC). `runtime`/`maxmod`/`rebuildSdk` (GBA/Genesis SDK). **`lint:'strict'` fails the build (stage:'lint', no binary) if the pre-flight SDCC crash-pattern scan flags anything (e.g. the uint8 loop-bound trap); 'advisory' (default) just lists hits in issues[].** **`includeSymbols:true` returns the .map text inline on a PLAIN rom build — distinct from output:'romWithDebug' which writes .dbg/.map FILES.** Language is inferred from extension/content — usually OMIT `language`.\n" +
704
704
  "• output:'romWithDebug' — like 'rom' but also emits linker debug info for the `symbols` tool: cc65 → `.dbg`, SDCC → sdld `.map`, Genesis m68k → GNU ld map (find where a RAM var landed). DEFAULT writes ROM + debug file + log to disk (`outputPath` required unless `inline:true`). **`resolveSymbols:['grid','score']` folds those names' addresses ({resolvedSymbols:{grid:{address,hex,region?,ramOffset?}}}) straight into the result — the cheap way to a WRAM variable's address without loading the whole map (or round-tripping it through `symbols`).**\n" +
705
705
  "• output:'run' — BUILD + LOAD + RUN + SCREENSHOT in one round trip — the fastest iteration loop. Same build args; runs `frames` frames and returns the screenshot INLINE. `holdInputs` holds controller state; `screenshotPath` writes the PNG to disk instead; `projectName` titles the playtest window.\n" +
706
- "• output:'project' — build a project DIRECTORY (`path`) without re-passing the file manifest each call. Entry point is `main.c` (C/SGDK Genesis, GBA, cc65/SDCC C) OR `main.s`/`main.asm` (asm). Every `.c`/`.s`/`.asm` in the dir is a translation unit (linked together), every `.h`/`.inc` an include, and `.bin/.chr/.pcm/.brr/.vgm/...` become binaryIncludes (for `.incbin`). Iterate an on-disk project by re-calling with just `{path, platform}`. **This is the no-boilerplate path for a scaffold({op:'project'}) dir: the per-platform recipe auto-supplies the crt0 + load address — GB/GBC default `gb_crt0.s` + `codeLoc:0x150` (don't hand-pass them!), MSX routes `msx_crt0.s` + `codeLoc:0x4010`, SMS/GG auto-inject their bundled crt0, NES applies the chr-ram-runtime preset. PREFER this over re-passing `crt0Path`/`codeLoc` to output:'rom' for a scaffolded project.**",
706
+ "• output:'project' — build a project DIRECTORY (`path`) without re-passing the file manifest each call. Entry point is `main.c` (C/SGDK Genesis, GBA, cc65/SDCC C) OR `main.s`/`main.asm` (asm). Every `.c`/`.s`/`.asm` in the dir is a translation unit (linked together), every `.h`/`.inc` an include, and `.bin/.chr/.pcm/.brr/.vgm/...` become binaryIncludes (for `.incbin`). Iterate an on-disk project by re-calling with just `{path, platform}`. **This is the no-boilerplate path for an examples({op:'fork'}) dir: the per-platform recipe auto-supplies the crt0 + load address — GB/GBC default `gb_crt0.s` + `codeLoc:0x150` (don't hand-pass them!), MSX routes `msx_crt0.s` + `codeLoc:0x4010`, SMS/GG auto-inject their bundled crt0, NES applies the chr-ram-runtime preset. PREFER this over re-passing `crt0Path`/`codeLoc` to output:'rom' for a forked project.**",
707
707
  {
708
708
  output: z.enum(["rom", "romWithDebug", "run", "project"])
709
709
  .describe("rom=produce a ROM (default); romWithDebug=ROM + .dbg/.map debug files; run=build+load+run+screenshot; project=build a project directory."),
@@ -838,6 +838,17 @@ export function projectBuildRecipe(platform, names) {
838
838
  // commercial ROMs boot in the same host; only our scaffolds failed). Routing
839
839
  // msx_crt0.s through crt0 makes ITS header + init the cartridge entry.
840
840
  if (has("msx_crt0.s")) { r.crt0File = "msx_crt0.s"; r.codeLoc = 0x4010; }
841
+ } else if (platform === "pce") {
842
+ // PCE example projects (they ship pce_hw.h) build on the 'rom32k' preset:
843
+ // a 32KB HuCard with bank 0 (STARTUP/VECTORS) FIRST in the file at $E000
844
+ // and banks 1-3 (CODE/RODATA) at $8000-$DFFF, where cc65's pce crt0 TAMs
845
+ // them before main(). cc65's stock pce.cfg is an 8KB boot bank — too small
846
+ // for a complete example game — and its documented 32K variant places the
847
+ // vectors in the LAST file bank, which a HuCard never maps at reset
848
+ // (verified black screen on geargrafx). An 8KB-sized program still links
849
+ // and boots identically under this preset, so it's safe for every
850
+ // pce_hw.h-style project. Bare hand-rolled dirs are left alone.
851
+ if (has("pce_hw.h")) r.linkerConfig = "rom32k";
841
852
  } else if (platform === "sms" || platform === "gg") {
842
853
  // SMS/GG: route the project's *_crt0.s through the crt0 channel (like
843
854
  // GB/MSX), NOT as a plain source TU. The OLD recipe skipped it on the
@@ -56,7 +56,7 @@ async function maybeRestoreState(host, fromState, fromStatePath) {
56
56
  // observer wrapper encodes it ASYNCHRONOUSLY, after the agent's response has
57
57
  // already gone out. The provider is stripped from the agent-visible result. The
58
58
  // frame is captured by reference now (correct frozen state) but rasterized later.
59
- export function attachObserverFrame(json, host) {
59
+ export function attachObserverFrame(json, host, caption) {
60
60
  json._observerFrameProvider = () => {
61
61
  try {
62
62
  const shot = host.screenshot(); // { pngBase64, width, height }
@@ -65,6 +65,7 @@ export function attachObserverFrame(json, host) {
65
65
  : null;
66
66
  } catch { return null; }
67
67
  };
68
+ if (caption) json._observerFrameCaption = String(caption);
68
69
  return json;
69
70
  }
70
71
 
@@ -927,7 +928,7 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
927
928
  ? `Stopped at ${r.stoppedAtPC} (your stopAtPC) with PARTIAL output — readMemory the dst to see what's been written so far.`
928
929
  : "Did not return within maxFrames AND the watchdog didn't trip — this usually means the entry FELL BACK INTO THE GAME (a wrapper PC with a wrong source, so it never reaches the sentinel) and the game is just free-running. finalPC is inside the main loop, not your routine. Re-check the entry PC (use the routine body, not a wrapper) and the source regs; or lower maxInstructions to fail fast while probing. Bump maxFrames/maxInstructions only if you're sure it's a legitimately huge decompress.")
929
930
  + frameLogicCaveat;
930
- return jsonContent({
931
+ return attachObserverFrame(jsonContent({
931
932
  returned: r.returned, framesRun: r.framesRun, sandbox,
932
933
  ...(r.pure ? { pure: true, pureMode: r.pureMode } : {}),
933
934
  ...(r.watchdog ? { watchdog: true, reason: r.reason } : {}),
@@ -935,7 +936,7 @@ export function registerWatchMemoryTools(server, z, sessionKey) {
935
936
  ...(r.finalPC ? { finalPC: r.finalPC } : {}),
936
937
  ...(r.finalRegs ? { finalRegs: r.finalRegs } : {}),
937
938
  note,
938
- });
939
+ }), host, "cpu call");
939
940
  }
940
941
 
941
942
  async function cpuDecompress({ entryPC, sourceAddress, destAddress, maxFrames = 600 }) {
@@ -80,6 +80,79 @@ class ObserverBus extends EventEmitter {
80
80
 
81
81
  export const observer = new ObserverBus();
82
82
 
83
+ // ── Throttled deferred-frame emission ───────────────────────────────────────
84
+ // `call_frame` events carry a freshly-rasterized framebuffer PNG for the
85
+ // human's livestream. Tools attach a PROVIDER thunk (attachObserverFrame) and
86
+ // both transports route it here. Two guarantees:
87
+ // 1. The PNG encode NEVER runs on the agent's critical path (deferred via
88
+ // setImmediate / the trailing timer).
89
+ // 2. Rate-limited to one frame per FRAME_MIN_INTERVAL_MS **per
90
+ // (session, tool)** — frame({op:'step'}) called 120× in a narrowing loop
91
+ // emits at most every 2s, but a step followed immediately by a DIFFERENT
92
+ // tool's frame (input, state load, …) still shows: distinct tools don't
93
+ // throttle each other. Trailing-edge: the LAST suppressed frame in a
94
+ // burst always lands when the window reopens (rendered at fire time =
95
+ // the current screen, which is exactly what the human wants to converge
96
+ // on).
97
+ let FRAME_MIN_INTERVAL_MS = 2000;
98
+ export function _setFrameThrottleForTest(ms) { FRAME_MIN_INTERVAL_MS = ms; }
99
+
100
+ /** @type {Map<string, {lastTs: number, timer: any, pending: null | {provider: Function, meta: object}}>} */
101
+ const _frameThrottle = new Map();
102
+
103
+ function _emitFrame(provider, meta) {
104
+ try {
105
+ const img = provider();
106
+ if (img) {
107
+ observer.push({
108
+ type: "call_frame",
109
+ sessionKey: meta.sessionKey ?? "http",
110
+ platform: typeof meta.resolvePlatform === "function" ? (meta.resolvePlatform() ?? meta.platform ?? null) : (meta.platform ?? null),
111
+ ts: meta.ts ?? Date.now(),
112
+ tool: meta.tool,
113
+ ...(meta.caption ? { caption: meta.caption } : {}),
114
+ images: [img],
115
+ });
116
+ }
117
+ } catch { /* livestream is best-effort; never affects the agent */ }
118
+ }
119
+
120
+ /**
121
+ * Queue a deferred framebuffer for the livestream, throttled per
122
+ * (session, tool). `meta`: { sessionKey, tool, ts?, platform?,
123
+ * resolvePlatform?, caption? } — resolvePlatform (a thunk) is preferred so
124
+ * the platform label reflects post-call state (loadMedia sets it DURING the
125
+ * call). `provider` returns {kind:'image', mimeType, base64} or null; it is
126
+ * invoked OFF the agent's critical path.
127
+ */
128
+ export function pushObserverFrame(meta, provider) {
129
+ const key = `${meta.sessionKey ?? "http"}|${meta.tool ?? "?"}`;
130
+ let st = _frameThrottle.get(key);
131
+ if (!st) { st = { lastTs: 0, timer: null, pending: null }; _frameThrottle.set(key, st); }
132
+ const now = Date.now();
133
+ if (!st.timer && now - st.lastTs >= FRAME_MIN_INTERVAL_MS) {
134
+ st.lastTs = now;
135
+ setImmediate(() => _emitFrame(provider, meta));
136
+ return;
137
+ }
138
+ // Inside the window: stash as the pending trailing frame (latest wins) and
139
+ // arm the trailing timer once.
140
+ st.pending = { provider, meta };
141
+ if (!st.timer) {
142
+ const delay = Math.max(1, st.lastTs + FRAME_MIN_INTERVAL_MS - now);
143
+ st.timer = setTimeout(() => {
144
+ st.timer = null;
145
+ const p = st.pending;
146
+ st.pending = null;
147
+ if (p) {
148
+ st.lastTs = Date.now();
149
+ _emitFrame(p.provider, p.meta);
150
+ }
151
+ }, delay);
152
+ if (st.timer.unref) st.timer.unref(); // never hold the process open
153
+ }
154
+ }
155
+
83
156
  /**
84
157
  * Extract image payloads from an MCP tool result. MCP tool results have
85
158
  * `content: [{type:'text'|'image', ...}]`. We pull out images so the UI
@@ -305,7 +305,8 @@
305
305
  // One latest image per "tool" (kind = tool name); ev.tool
306
306
  // identifies which inspect call produced it.
307
307
  s.latestByKind[ev.tool] = { ts: ev.ts, base64: img.base64,
308
- mimeType: img.mimeType, tool: ev.tool, platform: ev.platform ?? null };
308
+ mimeType: img.mimeType, tool: ev.tool, platform: ev.platform ?? null,
309
+ caption: ev.caption ?? null };
309
310
  }
310
311
  }
311
312
  // screenshotAscii pushes both the PNG (above) AND the raw ANSI
@@ -416,7 +417,8 @@
416
417
  card.className = "image-card";
417
418
  const dt = new Date(img.ts).toLocaleTimeString();
418
419
  const platBadge = img.platform ? `<span class="plat">${escapeHtml(img.platform)}</span> ` : "";
419
- card.innerHTML = `<div class="meta"><span>${platBadge}${escapeHtml(img.tool)}</span><span>${dt}</span></div>`;
420
+ const label = img.caption ? `${img.tool}${img.caption}` : img.tool;
421
+ card.innerHTML = `<div class="meta"><span>${platBadge}${escapeHtml(label)}</span><span>${dt}</span></div>`;
420
422
  const el = document.createElement("img");
421
423
  el.src = `data:${img.mimeType};base64,${img.base64}`;
422
424
  el.alt = img.tool;
@@ -5,7 +5,7 @@
5
5
  //
6
6
  // Idempotent per server instance — installs once, repeats are no-ops.
7
7
 
8
- import { observer, extractImages, summarizeForLog } from "./bus.js";
8
+ import { observer, extractImages, summarizeForLog, pushObserverFrame } from "./bus.js";
9
9
  import { getHostOrNull } from "../mcp/state.js";
10
10
 
11
11
  const INSTALLED = Symbol.for("romdev.observer-installed");
@@ -54,6 +54,7 @@ export function installObserverMiddleware(server, sessionKey) {
54
54
  const platform = sessionPlatform(sessionKey); // which console this call drives
55
55
  let event;
56
56
  let frameProvider = null; // deferred framebuffer thunk (encoded async below)
57
+ let frameCaption = null; // optional human label for the call_frame event
57
58
  if (thrown) {
58
59
  event = {
59
60
  type: "call",
@@ -98,6 +99,10 @@ export function installObserverMiddleware(server, sessionKey) {
98
99
  frameProvider = result._observerFrameProvider;
99
100
  delete result._observerFrameProvider;
100
101
  }
102
+ if (result && typeof result === "object" && typeof result._observerFrameCaption === "string") {
103
+ frameCaption = result._observerFrameCaption;
104
+ delete result._observerFrameCaption;
105
+ }
101
106
  const inlineImages = extractImages(result);
102
107
  const images = inlineImages.length > 0 ? inlineImages : sidebandImages;
103
108
  const resultSummary = summarizeForLog(result);
@@ -121,20 +126,18 @@ export function installObserverMiddleware(server, sessionKey) {
121
126
  // tool response on observer delivery.
122
127
  try { observer.push(event); } catch { /* never let observer kill the tool */ }
123
128
 
124
- // Deferred frame: encode + push the PNG AFTER the agent's response goes
125
- // out, so the (expensive) rasterize never delays the tool. setImmediate
126
- // yields the response first; a separate `call_frame` event carries the
127
- // image for the human's livestream. Best-effort never throws into the
128
- // tool path. (Only breakpoint/watch tools set a provider.)
129
+ // Deferred frame: encoded + pushed AFTER the agent's response goes out,
130
+ // throttled to one per 2s PER (session, tool) with a trailing-edge
131
+ // emit (bus.js pushObserverFrame) frame-step loops can't flood the
132
+ // stream, distinct tools never throttle each other, and the last frame
133
+ // of a burst always lands. Best-effort never throws into the tool
134
+ // path.
129
135
  if (frameProvider) {
130
- setImmediate(() => {
131
- try {
132
- const img = frameProvider();
133
- if (img) {
134
- observer.push({ type: "call_frame", sessionKey, platform, ts: startedAt, tool: name, images: [img] });
135
- }
136
- } catch { /* livestream is best-effort; never affects the agent */ }
137
- });
136
+ pushObserverFrame({
137
+ sessionKey, tool: name, ts: startedAt, platform,
138
+ resolvePlatform: () => sessionPlatform(sessionKey),
139
+ ...(frameCaption ? { caption: frameCaption } : {}),
140
+ }, frameProvider);
138
141
  }
139
142
 
140
143
  if (thrown) throw thrown;
@@ -58,7 +58,7 @@ and DLL; you do NOT poke pixels into a framebuffer.**
58
58
  and (worse) burns enough cycles that the CPU stops getting time.
59
59
  - **Y position = which zone the object lives in.** Each zone covers
60
60
  N scanlines. To move an object up/down, you move it between
61
- zones. (Or — in our scaffolds — you stamp the same sprite at
61
+ zones. (Or — in our example games — you stamp the same sprite at
62
62
  different row offsets within ONE zone's data block, which fakes
63
63
  Y movement.)
64
64
  - **Each DL header can pick a palette per object** (one of 8
@@ -136,7 +136,7 @@ The "loop continues" mask is `0x5F` (bits 0-4 + bit 6). Bit 5
136
136
  (indirect flag) and bit 7 (write-mode) do NOT keep the loop going
137
137
  by themselves.
138
138
 
139
- ### 5-byte extended form (the bundled scaffolds use this)
139
+ ### 5-byte extended form (the bundled example games use this)
140
140
 
141
141
  ```
142
142
  +0 pixel-data LOW byte
@@ -195,7 +195,7 @@ scanlines for the ENTIRE display area (243 scanlines on NTSC,
195
195
  including 10 lines of top overscan before the visible area).
196
196
 
197
197
  If your DLL is shorter than 243 entries, MARIA reads past the end
198
- into random memory and renders garbage zones. The bundled scaffold
198
+ into random memory and renders garbage zones. The bundled example
199
199
  allocates 243 entries × 3 bytes = 729 bytes (fits easily in 4 KB
200
200
  internal RAM) and points every zone with no objects at a shared
201
201
  `dl_empty[2] = {0, 0}` terminator.
@@ -213,11 +213,11 @@ for an 8-row sprite) unless you pack many sprites per page.
213
213
  **Easy work-around:** make every zone 1 scanline tall (offset=0)
214
214
  and use one DL entry per sprite ROW. Then `offset` is always 0, the
215
215
  address quirk goes away, and you can store sprite rows back-to-back.
216
- The bundled scaffold uses this pattern.
216
+ The bundled example uses this pattern.
217
217
 
218
218
  The cost is more DLL entries (one per scanline), but at 3 bytes each
219
219
  across 243 lines = 729 bytes total — trivial RAM cost. Worth it for
220
- the simpler mental model on a starter scaffold.
220
+ the simpler mental model on a starter example.
221
221
 
222
222
  ## Colour bytes (Atari NTSC palette)
223
223
 
@@ -56,14 +56,14 @@ you have to:
56
56
  2. Place the sprite's DL entry into the zone covering its Y range.
57
57
  3. Per-frame, move the entry's bytes from old-zone DL → new-zone DL.
58
58
 
59
- Our scaffolds use a single-zone DLL for simplicity. Vertical
59
+ Our example games use a single-zone DLL for simplicity. Vertical
60
60
  movement is faked by stamping the sprite at different row offsets
61
61
  within the canvas data — only works if the canvas is tall enough.
62
62
 
63
63
  ## "Memory overflow during link (RAM1 by N bytes)"
64
64
 
65
65
  The 7800 has **4 KB of RAM**. The `default.c` and `hello_sprite.c`
66
- scaffolds use very little; the `shmup.c` puzzle (and the older
66
+ example games use very little; the `shmup.c` puzzle (and the older
67
67
  canvas-buffer approach) easily blow past it.
68
68
 
69
69
  Symptoms:
@@ -77,7 +77,7 @@ Fixes:
77
77
  - Use ROM constants (`const uint8_t` at file scope) instead of
78
78
  RAM globals.
79
79
  - Replace canvas-buffer rendering with per-object DLs (see
80
- `shmup.c` scaffold for the canonical pattern).
80
+ `shmup.c` example for the canonical pattern).
81
81
  - Avoid per-frame `memset(canvas, 0, ...)` — instead, only stamp
82
82
  changed cells.
83
83
 
@@ -126,7 +126,7 @@ DL during active rendering; safe modification windows:
126
126
  Build a "next-frame" DL during the game-state update phase and
127
127
  swap pointers (DPPL/DPPH) at vblank — double-buffered.
128
128
 
129
- Our scaffolds rebuild the DL during vblank, which works for small
129
+ Our example games rebuild the DL during vblank, which works for small
130
130
  DLs (< ~100 bytes). Large DLs that take ~1 ms to rebuild may
131
131
  exceed vblank time and start corrupting the active frame.
132
132
 
@@ -197,5 +197,5 @@ Fix options (in order of how much they shrink BSS):
197
197
  if you only need one sprite at a time — see `default.c` and
198
198
  `hello_sprite.c`. No per-scanline pool needed.
199
199
 
200
- The bundled scaffolds size their pools to fit; if you scale up
200
+ The bundled example games size their pools to fit; if you scale up
201
201
  (more objects, taller play area), watch the build log.
@@ -115,9 +115,16 @@ which is what the KERNAL's IRQ uses to update key state every
115
115
 
116
116
  **Joystick.** One fire button. Press it with `input({op:'set', b: true})` (or
117
117
  spatial `south`) — both clear `$DC00` bit 4 (verified live). `a` is a **no-op**
118
- (no second button). Drive fire with `b`/`south` + the d-pad. The joystick reads
119
- **port 2** by default; switch with `input({op:'joyport', joyport:1})` /
120
- `input({op:'joyport'})` to read it.
118
+ (no second button). Drive fire with `b`/`south` + the d-pad.
119
+
120
+ **Two players.** BOTH C64 control ports are live at once, so 2P games just work:
121
+ **host port 0 = player 1** (control port 2, `$DC00`) and **host port 1 = player
122
+ 2** (control port 1, `$DC01`) — the universal "port 0 = P1" convention. Pass two
123
+ port entries: `input({op:'set', ports:[{up:true}, {down:true}]})` moves P1 up and
124
+ P2 down independently. (Under the hood the host enables the VICE userport-adapter
125
+ mapping so both ports route, and swaps them so P1 lands on control port 2 where
126
+ the games read it.) The legacy `input({op:'joyport', joyport:1|2})` still selects
127
+ which single port a ONE-stick setup drives, but you rarely need it now.
121
128
 
122
129
  **Keyboard (the C64-specific part — many games NEED it).** Unlike consoles, most
123
130
  C64 games (and cracktros) gate gameplay behind a KEYBOARD setup screen — **F1**
@@ -311,7 +318,7 @@ loads games, wrap the `.prg` into a `.d64`: `cart({op:'packDisk', prgPath})`
311
318
 
312
319
  ## Horizontal scrolling (for side-scrollers)
313
320
 
314
- The `platformer` scaffold is single-screen. C64 scrolling is the fiddliest of
321
+ The `platformer` example is single-screen. C64 scrolling is the fiddliest of
315
322
  the platforms because the VIC-II only does a 0-7 px *fine* scroll in hardware;
316
323
  moving further is a software char-cell shift.
317
324
 
@@ -83,6 +83,19 @@ KERNAL last selected. Result: ghost input.
83
83
 
84
84
  **Use port 2 (CIA1_PRA) by default.** All bundled C64 templates do.
85
85
 
86
+ ## "Player 2 input does nothing"
87
+
88
+ Both C64 control ports ARE live over MCP, so 2P works — the mapping is just
89
+ non-obvious: **host port 0 → control port 2 ($DC00) = player 1**, **host port 1
90
+ → control port 1 ($DC01) = player 2** (the universal "port 0 = P1" convention).
91
+ So a 2P game reads P1 from $DC00 and P2 from $DC01, and you drive them with two
92
+ port entries: `input({op:'set', ports:[{up:true},{down:true}]})` moves P1 up,
93
+ P2 down. If P2 seems dead, check you passed a SECOND `ports` entry (not just
94
+ port 0) and that the game actually entered 2P mode (its title pick, e.g. "PORT 1
95
+ FIRE = 2P"). The host enables the VICE userport-adapter mapping + swaps the two
96
+ RetroPad ports under the hood so this convention holds — you don't configure
97
+ anything.
98
+
86
99
  ## "Audio is silent / SID doesn't play"
87
100
 
88
101
  Three things to check:
@@ -280,9 +280,9 @@ names also resolve (east→A, west→B). So `input({op:'set', a: true})` presses
280
280
  expected — unlike the genesis_plus_gx platforms (Genesis/SMS/GG), there's no
281
281
  surprise here. (Same for **GBC** — it shares the gambatte core.)
282
282
 
283
- ## What `scaffold({op:'project'})` copies into your project
283
+ ## What `examples({op:'fork'})` copies into your project
284
284
 
285
- `scaffold({op:'project', platform:"gb"|"gbc", template:...})` writes these files
285
+ `examples({op:'fork', example:"gb/..."|"gbc/...", name, path})` writes these files
286
286
  into your project directory. **They're yours** — every byte that compiles
287
287
  is in the repo. Edit, fork, replace; nothing is auto-injected at build time.
288
288
 
@@ -340,7 +340,7 @@ Most game patterns DON'T need any of this. Try the C path first.
340
340
 
341
341
  ## Horizontal scrolling (for side-scrollers)
342
342
 
343
- The `platformer` scaffold is single-screen. To make it a side-scroller:
343
+ The `platformer` example is single-screen. To make it a side-scroller:
344
344
 
345
345
  - **Hardware scroll:** write `SCX` (`$FF43`) each frame = camera X mod 256.
346
346
  The BG is a 32×32 tile map (256×256 px) that wraps, so `SCX` alone scrolls