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.
- package/AGENTS.md +51 -41
- package/CHANGELOG.md +46 -0
- package/README.md +3 -3
- package/examples/README.md +7 -7
- package/examples/atari2600/templates/platformer.asm +1225 -332
- package/examples/atari2600/templates/puzzle.asm +1056 -0
- package/examples/atari2600/templates/racing.asm +906 -275
- package/examples/atari2600/templates/shmup.asm +1031 -239
- package/examples/atari2600/templates/sports.asm +1135 -253
- package/examples/atari7800/templates/platformer.c +991 -156
- package/examples/atari7800/templates/puzzle.c +1091 -148
- package/examples/atari7800/templates/racing.c +952 -124
- package/examples/atari7800/templates/shmup.c +812 -134
- package/examples/atari7800/templates/sports.c +820 -184
- package/examples/c64/templates/platformer.c +879 -164
- package/examples/c64/templates/puzzle.c +855 -178
- package/examples/c64/templates/racing.c +873 -97
- package/examples/c64/templates/shmup.c +757 -161
- package/examples/c64/templates/sports.c +755 -100
- package/examples/gb/templates/platformer.c +841 -179
- package/examples/gb/templates/puzzle.c +986 -246
- package/examples/gb/templates/racing.c +754 -174
- package/examples/gb/templates/shmup.c +673 -175
- package/examples/gb/templates/sports.c +790 -159
- package/examples/gba/templates/platformer.c +626 -165
- package/examples/gba/templates/puzzle.c +519 -269
- package/examples/gba/templates/racing.c +511 -206
- package/examples/gba/templates/shmup.c +564 -179
- package/examples/gba/templates/sports.c +454 -174
- package/examples/gbc/templates/platformer.c +944 -180
- package/examples/gbc/templates/puzzle.c +363 -109
- package/examples/gbc/templates/racing.c +884 -180
- package/examples/gbc/templates/shmup.c +821 -185
- package/examples/gbc/templates/sports.c +870 -162
- package/examples/genesis/templates/platformer.c +747 -129
- package/examples/genesis/templates/puzzle.c +694 -261
- package/examples/genesis/templates/racing.c +726 -203
- package/examples/genesis/templates/shmup.c +535 -142
- package/examples/genesis/templates/sports.c +495 -158
- package/examples/gg/templates/platformer.c +880 -215
- package/examples/gg/templates/puzzle.c +875 -216
- package/examples/gg/templates/racing.c +915 -172
- package/examples/gg/templates/shmup.c +714 -191
- package/examples/gg/templates/sports.c +732 -129
- package/examples/lynx/templates/platformer.c +604 -69
- package/examples/lynx/templates/puzzle.c +498 -158
- package/examples/lynx/templates/racing.c +538 -102
- package/examples/lynx/templates/shmup.c +458 -131
- package/examples/lynx/templates/sports.c +496 -72
- package/examples/msx/platformer/main.c +649 -162
- package/examples/msx/puzzle/main.c +742 -240
- package/examples/msx/racing/main.c +669 -178
- package/examples/msx/shmup/main.c +460 -178
- package/examples/msx/sports/main.c +592 -126
- package/examples/nes/templates/platformer.c +589 -171
- package/examples/nes/templates/puzzle.c +563 -242
- package/examples/nes/templates/racing.c +502 -208
- package/examples/nes/templates/shmup.c +339 -145
- package/examples/nes/templates/sports.c +341 -183
- package/examples/pce/platformer/main.c +874 -205
- package/examples/pce/puzzle/main.c +802 -287
- package/examples/pce/racing/main.c +783 -208
- package/examples/pce/shmup/main.c +638 -212
- package/examples/pce/sports/main.c +586 -169
- package/examples/porting-across-platforms/README.md +1 -1
- package/examples/sms/templates/platformer.c +762 -177
- package/examples/sms/templates/puzzle.c +752 -212
- package/examples/sms/templates/racing.c +808 -145
- package/examples/sms/templates/shmup.c +599 -162
- package/examples/sms/templates/sports.c +630 -122
- package/examples/snes/templates/music_demo.c +7 -0
- package/examples/snes/templates/platformer-data.asm +123 -24
- package/examples/snes/templates/platformer-hdr.asm +57 -0
- package/examples/snes/templates/platformer.c +586 -165
- package/examples/snes/templates/puzzle-data.asm +116 -21
- package/examples/snes/templates/puzzle-hdr.asm +57 -0
- package/examples/snes/templates/puzzle.c +614 -235
- package/examples/snes/templates/racing-data.asm +390 -32
- package/examples/snes/templates/racing-hdr.asm +57 -0
- package/examples/snes/templates/racing.c +807 -196
- package/examples/snes/templates/shmup-data.asm +87 -29
- package/examples/snes/templates/shmup-hdr.asm +57 -0
- package/examples/snes/templates/shmup.c +459 -198
- package/examples/snes/templates/sports-data.asm +48 -2
- package/examples/snes/templates/sports-hdr.asm +57 -0
- package/examples/snes/templates/sports.c +414 -163
- package/package.json +1 -1
- package/src/host/LibretroHost.js +59 -1
- package/src/http/tool-registry.js +11 -11
- package/src/mcp/tools/cheats.js +2 -1
- package/src/mcp/tools/frame.js +3 -2
- package/src/mcp/tools/index.js +3 -3
- package/src/mcp/tools/input.js +5 -4
- package/src/mcp/tools/lifecycle.js +6 -4
- package/src/mcp/tools/platform-docs.js +1 -1
- package/src/mcp/tools/preview-tile.js +6 -2
- package/src/mcp/tools/project.js +1098 -130
- package/src/mcp/tools/rom-id.js +5 -1
- package/src/mcp/tools/run-until.js +4 -2
- package/src/mcp/tools/snippets.js +6 -6
- package/src/mcp/tools/sprite-pipeline.js +14 -2
- package/src/mcp/tools/state.js +2 -1
- package/src/mcp/tools/tile-inspect.js +8 -1
- package/src/mcp/tools/toolchain.js +12 -1
- package/src/mcp/tools/watch-memory.js +4 -3
- package/src/observer/bus.js +73 -0
- package/src/observer/livestream.html +4 -2
- package/src/observer/tool-wrap.js +17 -14
- package/src/platforms/atari7800/MENTAL_MODEL.md +5 -5
- package/src/platforms/atari7800/TROUBLESHOOTING.md +5 -5
- package/src/platforms/c64/MENTAL_MODEL.md +11 -4
- package/src/platforms/c64/TROUBLESHOOTING.md +13 -0
- package/src/platforms/gb/MENTAL_MODEL.md +3 -3
- package/src/platforms/gb/TROUBLESHOOTING.md +61 -8
- package/src/platforms/gb/lib/c/README.md +10 -11
- package/src/platforms/gb/lib/c/gb_crt0.s +27 -3
- package/src/platforms/gb/lib/c/patch-header.js +13 -3
- package/src/platforms/gba/MENTAL_MODEL.md +4 -4
- package/src/platforms/gba/TROUBLESHOOTING.md +3 -3
- package/src/platforms/gba/lib/c/gba_sfx.c +40 -0
- package/src/platforms/gba/lib/c/gba_sfx.h +10 -0
- package/src/platforms/gbc/MENTAL_MODEL.md +4 -4
- package/src/platforms/gbc/TROUBLESHOOTING.md +4 -4
- package/src/platforms/gbc/UPSTREAM_SOURCES.md +1 -1
- package/src/platforms/gbc/lib/c/README.md +10 -11
- package/src/platforms/gbc/lib/c/gb_crt0.s +26 -3
- package/src/platforms/gbc/lib/c/patch-header.js +13 -3
- package/src/platforms/genesis/MENTAL_MODEL.md +3 -3
- package/src/platforms/genesis/TROUBLESHOOTING.md +2 -2
- package/src/platforms/gg/MENTAL_MODEL.md +4 -4
- package/src/platforms/gg/TROUBLESHOOTING.md +3 -3
- package/src/platforms/gg/UPSTREAM_SOURCES.md +1 -1
- package/src/platforms/gg/lib/c/joypad_read.c +29 -0
- package/src/platforms/lynx/MENTAL_MODEL.md +1 -1
- package/src/platforms/lynx/TROUBLESHOOTING.md +3 -3
- package/src/platforms/msx/MENTAL_MODEL.md +5 -5
- package/src/platforms/msx/TROUBLESHOOTING.md +2 -2
- package/src/platforms/msx/lib/c/msx_hw.h +1 -0
- package/src/platforms/msx/lib/c/msx_vdp.c +25 -0
- package/src/platforms/nes/MENTAL_MODEL.md +2 -2
- package/src/platforms/nes/lib/c/nes_runtime.c +149 -34
- package/src/platforms/nes/lib/c/nes_runtime.h +34 -1
- package/src/platforms/pce/MENTAL_MODEL.md +5 -5
- package/src/platforms/pce/TROUBLESHOOTING.md +1 -1
- package/src/platforms/pce/lib/c/pce_hw.h +11 -0
- package/src/platforms/pce/lib/c/pce_video.c +32 -0
- package/src/platforms/sms/MENTAL_MODEL.md +6 -6
- package/src/platforms/snes/MENTAL_MODEL.md +2 -2
- package/src/platforms/snes/TROUBLESHOOTING.md +40 -1
- package/src/toolchains/cc65/presets/nes/chr-ram-runtime.cfg +13 -8
- package/src/toolchains/cc65/presets/nes/chr-ram-runtime.crt0.s +58 -5
- package/src/toolchains/cc65/presets/nes/chr-rom.crt0.s +52 -3
- package/src/toolchains/cc65/presets/pce/rom32k.cfg +52 -0
- package/src/toolchains/index.js +27 -11
package/src/mcp/tools/rom-id.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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("
|
|
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("
|
|
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(`
|
|
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 `
|
|
204
|
+
// starterSnippets/copyStarterSnippets folded into the `examples` tool. The cores
|
|
205
205
|
// are assigned inside registerSnippetTools (they close over the local helpers);
|
|
206
|
-
//
|
|
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
|
|
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
|
|
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.");
|
package/src/mcp/tools/state.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 }) {
|
package/src/observer/bus.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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`
|
|
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
|
|
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
|
|
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.
|
|
119
|
-
|
|
120
|
-
|
|
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`
|
|
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 `
|
|
283
|
+
## What `examples({op:'fork'})` copies into your project
|
|
284
284
|
|
|
285
|
-
`
|
|
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`
|
|
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
|