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
@@ -43,6 +43,12 @@ const PLATFORM_CORE_OPTIONS = {
43
43
  // `… - C-BIOS` machine tree ships in romdev-core-bluemsx/bios and is mirrored
44
44
  // into the wasm FS as the system dir (see loadMedia + resolveSystemDir).
45
45
  msx: { bluemsx_msxtype: "MSX2+ - C-BIOS" },
46
+ // geargrafx ships with the TurboTap disabled, which makes port-1 input
47
+ // unreachable in-game (every pad scan slot mirrors pad 0). Enabling it
48
+ // costs nothing for 1P games (slot 0 still reads pad 0) and routes the
49
+ // host's port-1 input to pad slot 2 — PCE 2P works (probed 2026-06-10
50
+ // during the ZENITH BARRAGE gold round).
51
+ pce: { geargrafx_turbotap: "Enabled" },
46
52
  // VICE mounts a .d64/.tap/.crt but, with autostart off, just sits at the BASIC
47
53
  // `READY.` prompt — the agent would see a blue boot screen, not the game. Force
48
54
  // autostart so a disk/tape image runs the first program automatically (same as
@@ -57,6 +63,19 @@ const PLATFORM_CORE_OPTIONS = {
57
63
  // files back into the .d64 (VICE updates the in-FS image in place). Without
58
64
  // this a game's SAVE silently fails / errors — defeating disk-save support.
59
65
  vice_floppy_write_protection: "disabled",
66
+ // TWO live C64 control ports so 2P games see player 2. The VICE core drives
67
+ // ONE control port per RetroPad by default (every retro port → cur_port);
68
+ // the per-port split only happens with the userport adapter, where the
69
+ // mapper does vice_port = cur_port + retro_port. With joyport=1 + a userport
70
+ // adapter that gives retro0→control-port-1, retro1→control-port-2 — BOTH
71
+ // standard ports live. Our games read P1 on control port 2 ($DC00) and P2
72
+ // on port 1 ($DC01); the host swaps the two retro ports below
73
+ // (portInputToMask C64 path) so host port 0 = P1 (control port 2) and host
74
+ // port 1 = P2 (control port 1), matching the universal "port 0 = player 1"
75
+ // convention. Verified: drives both paddles independently in 2P, 1P-vs-CPU
76
+ // still reachable. (Both options ship in the wasm — no core rebuild.)
77
+ vice_joyport: "1",
78
+ vice_userport_joytype: "HIT",
60
79
  },
61
80
  };
62
81
 
@@ -415,6 +434,9 @@ export class LibretroHost {
415
434
 
416
435
  // Configure controller port 0 as joypad (some cores default to NONE).
417
436
  mod._retro_set_controller_port_device(0, RETRO_DEVICE_JOYPAD);
437
+ // Port 1 too — needed for 2P. The C64/VICE 2P path (two live control ports)
438
+ // only reads RetroPad port 1 when it's registered as a joypad device.
439
+ mod._retro_set_controller_port_device(1, RETRO_DEVICE_JOYPAD);
418
440
 
419
441
  // ---- Settle the framebuffer to the ROM's chosen geometry ----
420
442
  //
@@ -623,7 +645,14 @@ export class LibretroHost {
623
645
  this._applyC64ButtonKeys(input.ports[0] || {});
624
646
  }
625
647
  for (let port = 0; port < this.state.inputPorts.length; port++) {
626
- const portInput = this._c64StripKeyButtons(input.ports[port], platform);
648
+ // C64 2P port swap: with joyport=1 + userport the VICE mapper binds
649
+ // RetroPad 0 → C64 control port 1 and RetroPad 1 → control port 2. But
650
+ // our games read player 1 on control port 2 ($DC00) and player 2 on
651
+ // control port 1 ($DC01). So feed host port 0's input to RetroPad slot 1
652
+ // (→ control port 2 = P1) and host port 1's to slot 0 (→ port 1 = P2),
653
+ // restoring the universal "host port 0 = player 1" convention.
654
+ const srcPort = platform === "c64" ? (port ^ 1) : port;
655
+ const portInput = this._c64StripKeyButtons(input.ports[srcPort], platform);
627
656
  this.state.inputPorts[port][0] = portInputToMask(portInput, platform);
628
657
  }
629
658
  }
@@ -1095,7 +1124,36 @@ export class LibretroHost {
1095
1124
  this.reset();
1096
1125
  return false;
1097
1126
  }
1127
+ // Battery semantics: SAVE_RAM survives a power-cycle on a battery cart.
1128
+ // Carry it across the reload (the reload itself zeroes it).
1129
+ let sram = null;
1130
+ try {
1131
+ const size = this.regionSize("save_ram");
1132
+ if (size > 0) sram = Uint8Array.from(this.readMemory("save_ram", 0, size));
1133
+ } catch { /* no save_ram region on this core/cart — nothing to carry */ }
1098
1134
  await this.loadMedia(this._loadArgs);
1135
+ if (sram && sram.some((b) => b !== 0)) {
1136
+ // Restore like a frontend restores the .srm: bytes in place BEFORE the
1137
+ // game's boot code reads them. Some cores size SAVE_RAM lazily (gpgx
1138
+ // scans for the last non-empty byte → size 0 on a fresh boot), so fall
1139
+ // back to the raw region pointer when the sized path refuses.
1140
+ let restored = false;
1141
+ try {
1142
+ if (this.regionSize("save_ram") >= sram.length) {
1143
+ this.writeMemory("save_ram", 0, sram);
1144
+ restored = true;
1145
+ }
1146
+ } catch { /* sized path unavailable */ }
1147
+ if (!restored) {
1148
+ try {
1149
+ const ptr = this.mod._retro_get_memory_data(0); // RETRO_MEMORY_SAVE_RAM
1150
+ if (ptr) { this.mod.HEAPU8.set(sram, ptr); restored = true; }
1151
+ } catch { /* no save buffer on this core/cart */ }
1152
+ }
1153
+ // loadMedia's settle frames may already have run the game's
1154
+ // hi-score load against empty SRAM — soft-reset so boot re-reads.
1155
+ if (restored) { try { this.reset(); } catch { /* keep the loaded state */ } }
1156
+ }
1099
1157
  return true;
1100
1158
  }
1101
1159
 
@@ -17,7 +17,7 @@
17
17
  import { z } from "zod";
18
18
  import { registerTools } from "../mcp/tools/index.js";
19
19
  import { withClearToolErrors } from "../mcp/util.js";
20
- import { observer, summarizeForLog, extractImages } from "../observer/bus.js";
20
+ import { observer, summarizeForLog, extractImages, pushObserverFrame } from "../observer/bus.js";
21
21
  import { getHostOrNull } from "../mcp/state.js";
22
22
 
23
23
  /**
@@ -151,21 +151,21 @@ export async function runTool(tool, args, sessionKey) {
151
151
  // Strip both from the caller-visible result before it's serialized.
152
152
  let sidebandImages = [];
153
153
  let frameProvider = null;
154
+ let frameCaption = null;
154
155
  if (r && typeof r === "object") {
155
156
  if (Array.isArray(r._observerImages)) { sidebandImages = r._observerImages; delete r._observerImages; }
156
157
  if (typeof r._observerFrameProvider === "function") { frameProvider = r._observerFrameProvider; delete r._observerFrameProvider; }
158
+ if (typeof r._observerFrameCaption === "string") { frameCaption = r._observerFrameCaption; delete r._observerFrameCaption; }
157
159
  }
158
160
  if (frameProvider) {
159
- setImmediate(() => {
160
- try {
161
- const img = frameProvider();
162
- // re-resolve platform: a call like loadMedia sets it DURING the call, so
163
- // the post-call value is the most accurate for the frame's system label.
164
- let framePlatform = platform;
165
- try { framePlatform = getHostOrNull(sessionKey)?.status?.platform ?? platform; } catch { /* keep */ }
166
- if (img) observer.push({ type: "call_frame", sessionKey: sessionKey ?? "http", platform: framePlatform, ts: startedAt, tool: tool.name, images: [img] });
167
- } catch { /* livestream is best-effort; never affects the caller */ }
168
- });
161
+ // Throttled to one per 2s per (session, tool), trailing-edge — same
162
+ // policy as the MCP path (bus.js pushObserverFrame). Platform is
163
+ // re-resolved at emit time (loadMedia sets it DURING the call).
164
+ pushObserverFrame({
165
+ sessionKey, tool: tool.name, ts: startedAt, platform,
166
+ resolvePlatform: () => { try { return getHostOrNull(sessionKey)?.status?.platform ?? platform; } catch { return platform; } },
167
+ ...(frameCaption ? { caption: frameCaption } : {}),
168
+ }, frameProvider);
169
169
  }
170
170
  const inlineImages = extractImages(r);
171
171
  const images = inlineImages.length > 0 ? inlineImages : sidebandImages;
@@ -2,6 +2,7 @@ import path from "node:path";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { getHost } from "../state.js";
4
4
  import { jsonContent, safeTool } from "../util.js";
5
+ import { attachObserverFrame } from "./watch-memory.js";
5
6
  import { lookupCheats, searchCheatGames } from "../../cheats/lookup.js";
6
7
  import { encodeForDevice, nativeDevicesFor, decodeCode } from "../../cheats/gamegenie.js";
7
8
 
@@ -334,7 +335,7 @@ export function registerCheatTools(server, z, sessionKey) {
334
335
  switch (args.op) {
335
336
  case "lookup": return jsonContent(await cheatsLookupCore(args));
336
337
  case "search": return jsonContent(await cheatsSearchCore(args));
337
- case "apply": return jsonContent(await cheatsApplyCore(args, sessionKey));
338
+ case "apply": return attachObserverFrame(jsonContent(await cheatsApplyCore(args, sessionKey)), getHost(sessionKey), "cheat applied");
338
339
  case "clear": return jsonContent(await cheatsClearCore(args, sessionKey));
339
340
  case "make": return jsonContent(await cheatsMakeCore(args));
340
341
  default: throw new Error(`cheats: unknown op '${args.op}'`);
@@ -249,12 +249,13 @@ export function registerFrameTools(server, z, sessionKey) {
249
249
  // actively playing in the playtest window means this step raced their
250
250
  // real-time loop. Field only appears when the conflict is real.
251
251
  const coDrive = humanCoDriveWarning(sessionKey);
252
- return jsonContent({
252
+ // Livestream: the post-step frame (throttled to 1/2s per tool by the bus).
253
+ return attachObserverFrame(jsonContent({
253
254
  framesRun: n,
254
255
  frameCount: host.status.frameCount,
255
256
  framebuffer: { width: host.status.fbWidth, height: host.status.fbHeight },
256
257
  ...(coDrive ? { humanCoDriveWarning: coDrive } : {}),
257
- });
258
+ }), host, `step ×${n}`);
258
259
  }
259
260
 
260
261
  // Contract: an image goes to disk (path) OR comes back inline (inline:true).
@@ -87,7 +87,7 @@ const CATEGORIES = [
87
87
  {
88
88
  name: "platforms",
89
89
  description: "Discover supported platforms, their cores, toolchains, and language matrices.",
90
- useWhen: ["scaffolding a new project", "checking which platforms are available", "looking up a platform's default language"],
90
+ useWhen: ["before forking an example for a new game", "checking which platforms are available", "looking up a platform's default language"],
91
91
  register: (s, z, k) => registerPlatformTools(s, z, k), // listPlatforms, resolvePlatform
92
92
  },
93
93
  {
@@ -138,8 +138,8 @@ const CATEGORIES = [
138
138
  },
139
139
  {
140
140
  name: "project",
141
- description: "Project scaffolding + starter snippets per platform.",
142
- useWhen: ["starting a new game from scratch", "looking up canonical patterns like NMI handler, OAM DMA, joypad read"],
141
+ description: "The example-game library (fork/list/show) + starter snippets per platform.",
142
+ useWhen: ["starting a new game (ALWAYS fork the nearest example — never a blank file)", "looking up canonical patterns like NMI handler, OAM DMA, joypad read"],
143
143
  register: (s, z, k) => { registerProjectTools(s, z, k); registerSnippetTools(s, z, k); registerPlatformDocsTools(s, z); },
144
144
  },
145
145
  {
@@ -2,6 +2,7 @@ import { getHost } from "../state.js";
2
2
  import { jsonContent, safeTool } from "../util.js";
3
3
  import { getInputLayoutCore } from "./input-layout.js";
4
4
  import { humanCoDriveWarning } from "./playtest.js";
5
+ import { attachObserverFrame } from "./watch-memory.js";
5
6
 
6
7
  // Spreadable co-drive conflict marker for every input-driving op: while a
7
8
  // human is actively playing in this session's playtest window, their input
@@ -257,21 +258,21 @@ export function registerInputTools(server, z, sessionKey) {
257
258
  switch (args.op) {
258
259
  case "set": {
259
260
  if (!args.ports) throw new Error("input({op:'set'}): `ports` is required.");
260
- return jsonContent(inputSetCore(args, sessionKey));
261
+ return attachObserverFrame(jsonContent(inputSetCore(args, sessionKey)), getHost(sessionKey), "input set");
261
262
  }
262
263
  case "press": {
263
264
  if (!args.button) throw new Error("input({op:'press'}): `button` is required.");
264
- return jsonContent(inputPressCore(args, sessionKey));
265
+ return attachObserverFrame(jsonContent(inputPressCore(args, sessionKey)), getHost(sessionKey), `press ${args.button}`);
265
266
  }
266
267
  case "sequence": {
267
268
  if (!args.steps) throw new Error("input({op:'sequence'}): `steps` is required.");
268
- return jsonContent(inputSequenceCore(args, sessionKey));
269
+ return attachObserverFrame(jsonContent(inputSequenceCore(args, sessionKey)), getHost(sessionKey), "input sequence");
269
270
  }
270
271
  case "navigate": {
271
272
  if (!args.steps) throw new Error("input({op:'navigate'}): `steps` is required.");
272
273
  // Fill per-step defaults the old navigate schema provided.
273
274
  const steps = args.steps.map((s) => ({ holdFrames: 2, maxWaitFrames: 120, settleFrames: 2, ...s }));
274
- return jsonContent(inputNavigateCore({ steps }, sessionKey));
275
+ return attachObserverFrame(jsonContent(inputNavigateCore({ steps }, sessionKey)), getHost(sessionKey), "navigate");
275
276
  }
276
277
  case "layout": {
277
278
  if (!args.platform) throw new Error("input({op:'layout'}): `platform` is required.");
@@ -2,6 +2,7 @@ import { resolveCore } from "../../cores/registry.js";
2
2
  import { clearHost, getHost, getHostOrNull, rememberLastMedia, resetHost } from "../state.js";
3
3
  import { jsonContent, safeTool, textContent } from "../util.js";
4
4
  import { resolveCheatCodeForApply } from "./cheats.js";
5
+ import { attachObserverFrame } from "./watch-memory.js";
5
6
 
6
7
  const MEDIA_KINDS = ["cartridge", "disk", "tape", "program"];
7
8
 
@@ -58,7 +59,8 @@ export function registerLifecycleTools(server, z, sessionKey) {
58
59
  // on dimensions, so we omit it until a frame has been stepped and point the
59
60
  // caller at stepFrames instead.
60
61
  const framebufferKnown = host.status.frameCount > 0;
61
- return jsonContent({
62
+ // Livestream: show what just loaded (the boot frame).
63
+ return attachObserverFrame(jsonContent({
62
64
  loaded: true,
63
65
  platform,
64
66
  core: resolved.coreName,
@@ -68,7 +70,7 @@ export function registerLifecycleTools(server, z, sessionKey) {
68
70
  ? { framebuffer: { width: host.status.fbWidth, height: host.status.fbHeight } }
69
71
  : { framebufferNote: "Framebuffer dimensions are unknown until the core runs — call stepFrames first, then getStatus (the pre-boot default does not match the real output resolution)." }),
70
72
  ...(appliedCheats ? { cheats: appliedCheats } : {}),
71
- });
73
+ }), host, `loaded ${host.status.mediaPath ? host.status.mediaPath.split("/").pop() : platform}`);
72
74
  }
73
75
 
74
76
  server.tool(
@@ -125,10 +127,10 @@ export function registerLifecycleTools(server, z, sessionKey) {
125
127
  const host = getHost(sessionKey);
126
128
  if (hard) {
127
129
  const reloaded = await host.hardReset();
128
- return textContent(reloaded ? "reset (hard / power-cycle — RAM cleared)" : "reset (soft — no cached ROM to reload for a hard reset)");
130
+ return attachObserverFrame(textContent(reloaded ? "reset (hard / power-cycle — RAM cleared)" : "reset (soft — no cached ROM to reload for a hard reset)"), host, "reset (hard)");
129
131
  }
130
132
  host.reset();
131
- return textContent("reset (soft — RESET button; work RAM persists, use hard:true to clear it)");
133
+ return attachObserverFrame(textContent("reset (soft — RESET button; work RAM persists, use hard:true to clear it)"), host, "reset");
132
134
  }
133
135
  case "pause":
134
136
  getHost(sessionKey).pause();
@@ -69,7 +69,7 @@ export async function listPlatformDocsCore({ platform }) {
69
69
  platform,
70
70
  docs,
71
71
  note: docs.length === 0
72
- ? `No docs shipped for '${platform}' yet. Try a different platform or scaffold for boilerplate. (For RE/patching workflow, see platform({op:'doc', platform:'romhacking', name:'playbook'}).)`
72
+ ? `No docs shipped for '${platform}' yet. Try a different platform, or fork an example game (examples({op:'fork'})) for boilerplate. (For RE/patching workflow, see platform({op:'doc', platform:'romhacking', name:'playbook'}).)`
73
73
  : `Call platform({op:'doc', platform, name}) to read one. 'name' is 'mental_model' or 'troubleshooting'. For RE/patching workflow across platforms, see platform({op:'doc', platform:'romhacking', name:'playbook'}).`,
74
74
  };
75
75
  }
@@ -409,7 +409,10 @@ export async function previewTileArtCore(args) {
409
409
 
410
410
  if (outputPath) {
411
411
  await writeFile(outputPath, png);
412
- return { ...result, outputPath, note: `${png.length} bytes of PNG written to ${outputPath}.` };
412
+ // Livestream sideband: the human sees the rendered sheet even though the
413
+ // agent only gets the path.
414
+ return { ...result, outputPath, note: `${png.length} bytes of PNG written to ${outputPath}.`,
415
+ _observerImages: [{ kind: "image", mimeType: "image/png", base64: png.toString("base64") }] };
413
416
  }
414
417
  return { ...result, pngBase64: png.toString("base64") };
415
418
  }
@@ -467,7 +470,8 @@ async function previewMsxScreen2(args, d) {
467
470
  };
468
471
  if (outputPath) {
469
472
  await writeFile(outputPath, buf);
470
- return { ...result, outputPath };
473
+ return { ...result, outputPath,
474
+ _observerImages: [{ kind: "image", mimeType: "image/png", base64: buf.toString("base64") }] };
471
475
  }
472
476
  return { ...result, pngBase64: buf.toString("base64") };
473
477
  }