@henols/c64-re-tools 2.1.1 → 2.1.2

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 (57) hide show
  1. package/package.json +1 -1
  2. package/skills/c64-assembler/SKILL.md +6 -3
  3. package/skills/c64-assembler/scripts/assembler.ts +19 -11
  4. package/skills/c64-assembler/scripts/lib/compare.ts +4 -3
  5. package/skills/c64-basic/SKILL.md +1 -0
  6. package/skills/c64-disk/SKILL.md +1 -0
  7. package/skills/c64-disk/scripts/disk.ts +2 -2
  8. package/skills/c64-disk/scripts/lib/containers.ts +3 -4
  9. package/skills/c64-docs/SKILL.md +2 -1
  10. package/skills/c64-docs/scripts/docs.ts +1 -1
  11. package/skills/c64-docs/scripts/lib/links.ts +2 -0
  12. package/skills/c64-emulator/SKILL.md +2 -1
  13. package/skills/c64-knowledge/SKILL.md +6 -4
  14. package/skills/c64-knowledge/scripts/knowledge.ts +35 -13
  15. package/skills/c64-knowledge/scripts/lib/listing.ts +8 -30
  16. package/skills/c64-memory-map/SKILL.md +1 -0
  17. package/skills/c64-memory-map/scripts/memory-map.ts +1 -1
  18. package/skills/c64-provenance/SKILL.md +1 -0
  19. package/skills/c64-provenance/scripts/provenance.ts +9 -6
  20. package/skills/c64-source/SKILL.md +1 -0
  21. package/skills/c64-source/scripts/lib/source.ts +7 -29
  22. package/skills/c64-source/scripts/source.ts +4 -4
  23. package/skills/c64-static-analysis/SKILL.md +2 -0
  24. package/skills/c64-static-analysis/scripts/static-analysis.ts +14 -5
  25. package/skills/c64-testing/SKILL.md +3 -2
  26. package/skills/c64-testing/scripts/lib/diff-files.ts +14 -2
  27. package/skills/c64-testing/scripts/lib/recording.ts +3 -3
  28. package/skills/c64-testing/scripts/lib/replay.ts +1 -1
  29. package/skills/c64-testing/scripts/lib/runs.ts +27 -7
  30. package/skills/c64-testing/scripts/testing.ts +5 -5
  31. package/skills/c64-unpacker/SKILL.md +8 -5
  32. package/skills/c64-unpacker/scripts/lib/loads.ts +12 -6
  33. package/skills/c64-unpacker/scripts/lib/machine.ts +30 -3
  34. package/skills/c64-unpacker/scripts/lib/stages.ts +15 -7
  35. package/skills/c64-unpacker/scripts/unpacker.ts +18 -17
  36. package/src/host/vice/adapter.ts +4 -1
  37. package/src/host/vice/session.ts +49 -3
  38. package/src/host-client/image.ts +10 -10
  39. package/src/host-client/steps.ts +39 -3
  40. package/src/host-client/transfer.ts +14 -26
  41. package/src/knowledge/history.ts +55 -2
  42. package/src/knowledge/import.ts +79 -7
  43. package/src/knowledge/program.ts +31 -2
  44. package/src/knowledge/read.ts +7 -4
  45. package/src/knowledge/schema.ts +3 -2
  46. package/src/knowledge/text.ts +1 -0
  47. package/src/knowledge/write.ts +22 -14
  48. package/src/mcp/inputs.ts +6 -3
  49. package/src/mcp/server.ts +2 -1
  50. package/src/mcp/tools/debug.ts +29 -3
  51. package/src/mcp/tools/machine.ts +3 -1
  52. package/src/mcp/tools/memory.ts +4 -3
  53. package/src/media.ts +42 -0
  54. package/src/opcodes.ts +51 -18
  55. package/src/project.ts +110 -2
  56. package/src/protocol/vice.ts +4 -1
  57. package/src/script.ts +55 -3
@@ -224,13 +224,42 @@ export function requireProgram(db: DatabaseSync | undefined, space: Space = DEFA
224
224
  throw new Refusal("not-found", `The knowledge has no program${where}. Put it in with knowledge.ts image <image.json|prg>${option} of the c64-knowledge skill.`);
225
225
  }
226
226
 
227
- /** The bytes for an analyzer: a PRG for one segment without an entry state, else the 64 KiB. */
227
+ /** The pages of an entry state: $0000-$03ff as the capture wrote them, 256 bytes as hex for each page. */
228
+ const STATE_PAGES = ["$0000", "$0100", "$0200", "$0300"] as const;
229
+
230
+ /**
231
+ * The memory that the program has at its entry: the segments, and below them
232
+ * $0002-$03ff of the entry state (the zero page, the stack and the system
233
+ * pages hold code and data that the loader left). $0000 and $0001 are the
234
+ * CPU port, not memory. A segment byte wins over a state byte.
235
+ */
236
+ export function entryMemory(image: ProgramImage): Uint8Array {
237
+ const memory = new Uint8Array(image.memory);
238
+ const pages = image.state?.pages;
239
+ if (typeof pages !== "object" || pages === null) return memory;
240
+ const inSegment = (address: number) => image.segments.some((segment) => address >= segment.start && address <= segment.end);
241
+ STATE_PAGES.forEach((page, index) => {
242
+ const hex = (pages as Record<string, unknown>)[page];
243
+ if (typeof hex !== "string" || !/^(?:[0-9a-fA-F]{2}){1,256}$/.test(hex)) return;
244
+ Buffer.from(hex, "hex").forEach((byte, offset) => {
245
+ const address = index * 0x100 + offset;
246
+ if (address >= 2 && !inSegment(address)) memory[address] = byte;
247
+ });
248
+ });
249
+ return memory;
250
+ }
251
+
252
+ /**
253
+ * The bytes for an analyzer: a PRG for one segment without an entry state,
254
+ * else the 64 KiB with the entry-state pages (entryMemory). DXA, Ghidra and
255
+ * c64-source get the same bytes.
256
+ */
228
257
  export function analyzedBytes(image: ProgramImage): { bytes: Buffer; kind: "prg" | "flat64k" } {
229
258
  if (image.segments.length === 1 && image.state === undefined) {
230
259
  const [segment] = image.segments;
231
260
  return { bytes: Buffer.concat([Buffer.from([segment!.start & 0xff, segment!.start >> 8]), Buffer.from(image.memory.subarray(segment!.start, segment!.end + 1))]), kind: "prg" };
232
261
  }
233
- return { bytes: Buffer.from(image.memory), kind: "flat64k" };
262
+ return { bytes: Buffer.from(entryMemory(image)), kind: "flat64k" };
234
263
  }
235
264
 
236
265
  /**
@@ -255,18 +255,19 @@ export interface CoverageRun {
255
255
  * The imports of coverage maps that the knowledge has, by name and then oldest
256
256
  * first: the last import of a name is the latest. A revert gives back the maps
257
257
  * of the revision that it restores, so a map that a revert removed is not known.
258
+ * A revert of one space gives back only the maps of that space.
258
259
  * With `at`, the maps of the knowledge at that revision.
259
260
  */
260
261
  export function coverageRuns(db: DatabaseSync | undefined, at?: string): CoverageRun[] {
261
262
  if (db === undefined || at === NO_REVISION) return [];
262
263
  const rows = db
263
264
  .prepare(
264
- `SELECT r.id AS id, r.operation AS operation, r.restored_revision AS restored, e.ref AS name, e.sha256 AS sha256, coalesce(e.space, '${DEFAULT_SPACE}') AS space
265
+ `SELECT r.id AS id, r.operation AS operation, r.restored_revision AS restored, r.restored_space AS restoredSpace, e.ref AS name, e.sha256 AS sha256, coalesce(e.space, '${DEFAULT_SPACE}') AS space
265
266
  FROM revisions r LEFT JOIN evidence e ON e.revision = r.id AND e.kind = 'coverage'
266
267
  WHERE (r.operation = 'coverage' AND e.id IS NOT NULL) OR (r.operation = 'revert' AND r.restored_revision IS NOT NULL)
267
268
  ORDER BY r.id, e.id`,
268
269
  )
269
- .all() as Array<{ id: string; operation: string; restored: string | null; name: string | null; sha256: string | null; space: Space }>;
270
+ .all() as Array<{ id: string; operation: string; restored: string | null; restoredSpace: Space | null; name: string | null; sha256: string | null; space: Space }>;
270
271
  // The maps after each revision that changed them, oldest first.
271
272
  const after: Array<{ id: string; runs: CoverageRun[] }> = [];
272
273
  let runs: CoverageRun[] = [];
@@ -274,8 +275,10 @@ export function coverageRuns(db: DatabaseSync | undefined, at?: string): Coverag
274
275
  if (at !== undefined && row.id > at) break;
275
276
  if (row.operation === "revert") {
276
277
  const restored = row.restored!;
277
- runs = [];
278
- for (const item of after) if (item.id <= restored) runs = item.runs;
278
+ let then: CoverageRun[] = [];
279
+ for (const item of after) if (item.id <= restored) then = item.runs;
280
+ const only = row.restoredSpace;
281
+ runs = only === null ? then : [...runs.filter((run) => run.space !== only), ...then.filter((run) => run.space === only)];
279
282
  } else {
280
283
  const run: CoverageRun = { name: row.name!, sha256: row.sha256!, space: row.space, revision: row.id };
281
284
  if (!runs.some((item) => item.name === run.name && item.sha256 === run.sha256 && item.space === run.space && item.revision === run.revision)) runs = [...runs, run];
@@ -114,7 +114,8 @@ function createSchema(db: DatabaseSync): void {
114
114
  input_hash TEXT,
115
115
  tool_version TEXT,
116
116
  description TEXT,
117
- restored_revision TEXT
117
+ restored_revision TEXT,
118
+ restored_space TEXT
118
119
  );
119
120
  CREATE TABLE symbols (
120
121
  id TEXT PRIMARY KEY,
@@ -317,7 +318,7 @@ function createSchema(db: DatabaseSync): void {
317
318
  }
318
319
 
319
320
  /** The version of the index this build makes. An index with another version is emptied and made again. */
320
- export const INDEX_VERSION = 7;
321
+ export const INDEX_VERSION = 8;
321
322
 
322
323
  function indexVersion(db: DatabaseSync): number | undefined {
323
324
  const hasMeta = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'meta'").get();
@@ -188,6 +188,7 @@ const REVISION_FIELDS: readonly Field[] = [
188
188
  field("inputHash", "input_hash", "text", true),
189
189
  field("toolVersion", "tool_version", "text", true),
190
190
  field("restores", "restored_revision", "text", true),
191
+ field("restoresSpace", "restored_space", "text", true),
191
192
  ];
192
193
 
193
194
  /** The SQL name of a table: "references" is a keyword. */
@@ -8,6 +8,7 @@ import { type AddressRange, formatC64Address, instructionLength, SPACE_PATTERN,
8
8
  import { isoCet } from "../time.ts";
9
9
  import { CONFLICT_RULES, conflicts, conflictTable, type Conflict, type ConflictRow } from "./conflicts.ts";
10
10
  import { transaction } from "./database.ts";
11
+ import { labelProblem } from "../opcodes.ts";
11
12
  import { checkExpression, evaluate, identifiers, operandText, replaceIdentifiers } from "./expression.ts";
12
13
  import {
13
14
  COMMENT_COLUMNS,
@@ -140,6 +141,8 @@ export interface ChangeContext {
140
141
  evidence?: Evidence[];
141
142
  /** A revert: the revision whose knowledge it restores. */
142
143
  restores?: string;
144
+ /** A revert of one space: the space whose knowledge it restores. Without it, a revert restores every space. */
145
+ restoresSpace?: Space;
143
146
  }
144
147
 
145
148
  /**
@@ -181,8 +184,8 @@ export class Change {
181
184
  const now = new Date();
182
185
  const id = newRevisionId(currentRevision(this.#db), now);
183
186
  this.#db
184
- .prepare("INSERT INTO revisions (id, created_at, origin, operation, input_hash, tool_version, description, restored_revision) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
185
- .run(id, isoCet(now), this.#context.origin, this.#operation, this.#context.inputHash ?? null, this.#context.toolVersion ?? null, this.#context.description ?? null, this.#context.restores ?? null);
187
+ .prepare("INSERT INTO revisions (id, created_at, origin, operation, input_hash, tool_version, description, restored_revision, restored_space) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)")
188
+ .run(id, isoCet(now), this.#context.origin, this.#operation, this.#context.inputHash ?? null, this.#context.toolVersion ?? null, this.#context.description ?? null, this.#context.restores ?? null, this.#context.restoresSpace ?? null);
186
189
  this.revision = id;
187
190
  const evidence = this.#db.prepare("INSERT INTO evidence (revision, kind, ref, sha256, space) VALUES (?, ?, ?, ?, ?)");
188
191
  for (const item of this.#context.evidence ?? []) evidence.run(id, item.kind, item.ref, item.sha256 ?? null, item.space ?? null);
@@ -369,9 +372,8 @@ export function checkSpace(request: InSpace): void {
369
372
  function checkSymbol(request: SymbolRequest): void {
370
373
  checkSpace(request);
371
374
  checkAddress(request.address);
372
- if (!SYMBOL_NAME.test(request.name)) {
373
- throw new Refusal("invalid-input", "A symbol name is a letter or underscore, then letters, digits or underscores (at most 64), with an optional leading dot.");
374
- }
375
+ const problem = labelProblem(request.name);
376
+ if (problem !== undefined) throw new Refusal("invalid-input", problem);
375
377
  if (request.kind !== undefined) checkOneOf(SYMBOL_KINDS, request.kind, "kind");
376
378
  }
377
379
 
@@ -1199,19 +1201,25 @@ const KEYS = {
1199
1201
 
1200
1202
  /**
1201
1203
  * Makes the current knowledge equal to what it was at `revision`, as a new
1202
- * revision. History is never erased; restored rows keep their origin.
1204
+ * revision. With `space`, only the rows of that space change; without it,
1205
+ * every space changes. History is never erased; restored rows keep their origin.
1203
1206
  */
1204
- export function revert(db: DatabaseSync, context: WriteContext, request: { revision: string }): WriteResult<{ restoredRevision: string }> {
1207
+ export function revert(db: DatabaseSync, context: WriteContext, request: { revision: string; space?: Space }): WriteResult<{ restoredRevision: string }> {
1208
+ checkSpace(request);
1205
1209
  const restored = resolveRevision(db, request.revision);
1206
- return write(db, { ...context, restores: restored }, "revert", (change) => {
1210
+ const only = request.space;
1211
+ return write(db, { ...context, restores: restored, ...(only === undefined ? {} : { restoresSpace: only }) }, "revert", (change) => {
1207
1212
  // The coverage maps that the knowledge has follow the revert, also when no coverage range changes.
1208
- const maps = (runs: CoverageRun[]) => JSON.stringify(runs.map((run) => [run.name, run.sha256, run.space, run.revision]));
1213
+ const maps = (runs: CoverageRun[]) => JSON.stringify(runs.filter((run) => only === undefined || run.space === only).map((run) => [run.name, run.sha256, run.space, run.revision]));
1209
1214
  if (maps(coverageRuns(db, restored)) !== maps(coverageRuns(db))) change.id();
1210
- const at = "valid_from_revision <= ? AND (valid_to_revision IS NULL OR valid_to_revision > ?)";
1215
+ const inSpace = only === undefined ? "" : " AND space = ?";
1216
+ const spaceArgs = only === undefined ? [] : [only];
1217
+ const at = `valid_from_revision <= ? AND (valid_to_revision IS NULL OR valid_to_revision > ?)${inSpace}`;
1218
+ const current = `valid_to_revision IS NULL${inSpace}`;
1211
1219
  for (const [table, spec] of Object.entries(KEYS)) {
1212
1220
  const name = table === "references" ? '"references"' : table;
1213
- const then = db.prepare(`SELECT ${spec.columns}, id FROM ${name} WHERE ${at}`).all(restored, restored) as Array<Record<string, unknown>>;
1214
- const now = db.prepare(`SELECT ${spec.columns}, id FROM ${name} WHERE valid_to_revision IS NULL`).all() as Array<Record<string, unknown>>;
1221
+ const then = db.prepare(`SELECT ${spec.columns}, id FROM ${name} WHERE ${at}`).all(restored, restored, ...spaceArgs) as Array<Record<string, unknown>>;
1222
+ const now = db.prepare(`SELECT ${spec.columns}, id FROM ${name} WHERE ${current}`).all(...spaceArgs) as Array<Record<string, unknown>>;
1215
1223
  const key = (row: Record<string, unknown>) => (spec.key as (row: unknown) => string)(spec.map(row as never));
1216
1224
  const thenKeys = new Set(then.map(key));
1217
1225
  const nowKeys = new Set(now.map(key));
@@ -1253,8 +1261,8 @@ export function revert(db: DatabaseSync, context: WriteContext, request: { revis
1253
1261
  }
1254
1262
  // The program: a row is the same when all its values are the same.
1255
1263
  for (const [table, columns] of PROGRAM_TABLES) {
1256
- const then = db.prepare(`SELECT ${columns}, id FROM ${table} WHERE ${at}`).all(restored, restored) as Array<Record<string, unknown>>;
1257
- const now = db.prepare(`SELECT ${columns}, id FROM ${table} WHERE valid_to_revision IS NULL`).all() as Array<Record<string, unknown>>;
1264
+ const then = db.prepare(`SELECT ${columns}, id FROM ${table} WHERE ${at}`).all(restored, restored, ...spaceArgs) as Array<Record<string, unknown>>;
1265
+ const now = db.prepare(`SELECT ${columns}, id FROM ${table} WHERE ${current}`).all(...spaceArgs) as Array<Record<string, unknown>>;
1258
1266
  const key = (row: Record<string, unknown>) => JSON.stringify(columns.split(", ").map((column) => row[column]));
1259
1267
  const thenKeys = new Set(then.map(key));
1260
1268
  const nowKeys = new Set(now.map(key));
package/src/mcp/inputs.ts CHANGED
@@ -33,12 +33,15 @@ export const AddressInput = z
33
33
  .transform((value) => c64AddressOf(value)!)
34
34
  .describe("C64 address: $ and 1 to 4 hex digits, $0000 to $ffff");
35
35
 
36
+ /** The space of a memory target or condition: the emulator tests only C64 memory. */
37
+ const MemorySpaceInput = SpaceInput.describe("c64 only: the emulator tests only C64 memory. For the drive, use a watchpoint or an address target in space drive8");
38
+
36
39
  const rasterLine = z.number().int().min(0).max(MAX_RASTER_LINE).describe("raster line: PAL 0-311, NTSC 0-262");
37
40
 
38
41
  const condition = z
39
42
  .discriminatedUnion("kind", [
40
43
  z.object({ kind: z.literal("register"), register: z.enum(CONDITION_REGISTERS), operator: z.enum(COMPARISONS), value: Byte }).strict(),
41
- z.object({ kind: z.literal("memory"), address: AddressInput, operator: z.enum(COMPARISONS), value: Byte, space: SpaceInput, view: MemoryViewInput }).strict(),
44
+ z.object({ kind: z.literal("memory"), address: AddressInput, operator: z.enum(COMPARISONS), value: Byte, space: MemorySpaceInput, view: MemoryViewInput }).strict(),
42
45
  z.object({ kind: z.literal("raster"), line: rasterLine, cycle: z.number().int().min(0).max(64).optional().describe("cycle in the line: PAL 0-62, NTSC 0-64. True from this cycle on") }).strict(),
43
46
  ])
44
47
  .describe("register: a CPU register compared to a byte. memory: a byte in memory compared to a byte. raster: the raster position");
@@ -50,7 +53,7 @@ export const TOOL_INPUTS = {
50
53
 
51
54
  runTarget: z.discriminatedUnion("kind", [
52
55
  z.object({ kind: z.literal("address"), address: AddressInput, space: SpaceInput, condition: condition.optional().describe("stop at the address only when this is true") }).strict(),
53
- z.object({ kind: z.literal("memory"), address: AddressInput, operator: z.enum(COMPARISONS), value: Byte, space: SpaceInput, view: MemoryViewInput }).strict(),
56
+ z.object({ kind: z.literal("memory"), address: AddressInput, operator: z.enum(COMPARISONS), value: Byte, space: MemorySpaceInput, view: MemoryViewInput }).strict(),
54
57
  z.object({ kind: z.literal("raster"), line: rasterLine, cycle: z.number().int().min(0).max(64).optional().describe("cycle in the line: PAL 0-62, NTSC 0-64. Default 0") }).strict(),
55
58
  ]),
56
59
 
@@ -82,7 +85,7 @@ export const TOOL_INPUTS = {
82
85
 
83
86
  joystick: z
84
87
  .object({
85
- port: z.union([z.literal(1), z.literal(2)]).describe("control port 1 or 2"),
88
+ port: z.union([z.literal(1), z.literal(2)], { error: "port must be the number 1 or 2" }).describe("control port 1 or 2"),
86
89
  direction: z.enum(JOYSTICK_DIRECTIONS),
87
90
  fire: z.boolean().default(false),
88
91
  })
package/src/mcp/server.ts CHANGED
@@ -182,7 +182,8 @@ function describeIssues(error: z.ZodError, input?: z.ZodObject): string {
182
182
  return error.issues
183
183
  .map((issue) => {
184
184
  const where = issue.path.length === 0 ? "input" : issue.path.join(".");
185
- const fields = issue.code === "unrecognized_keys" && issue.path.length === 0 && input !== undefined ? `. The fields are ${Object.keys(input.shape).join(", ")}` : "";
185
+ const names = input === undefined ? [] : Object.keys(input.shape);
186
+ const fields = issue.code !== "unrecognized_keys" || issue.path.length !== 0 || input === undefined ? "" : names.length === 0 ? ". The tool takes no fields" : `. The fields are ${names.join(", ")}`;
186
187
  return `${where}: ${issue.message}${fields}`;
187
188
  })
188
189
  .join(". ");
@@ -339,7 +339,9 @@ export const c64Memmap = defineTool({
339
339
  "A read on a byte right after executed code, or at a return address, can be a dummy read that the CPU does not use. " +
340
340
  "Action summary gives the counts for RAM, ROM and I/O. It also gives the executed code in RAM with its operand bytes, and the RAM that was written and executed. " +
341
341
  `Each list has at most ${MAX_SUMMARY_RANGES} ranges. ` +
342
- "Action clear sets all access records to zero.",
342
+ "Action clear sets all access records to zero. " +
343
+ "The emulator decides RAM, ROM or I/O from one bit of $01. At $A000-$BFFF it gives ROM when bit 0 is set, also when bit 1 is clear and the CPU used RAM. " +
344
+ "Every access at $D000-$DFFF is I/O, also when $01 shows RAM or the character ROM there. emulatorFlaw in the result tells when ROM at $A000-$BFFF can be RAM.",
343
345
  inputSchema: z
344
346
  .object({
345
347
  action: z.enum(["read", "summary", "clear"]),
@@ -362,6 +364,7 @@ export const c64Memmap = defineTool({
362
364
  writtenAndExecuted: z.array(RangeOutput).optional(),
363
365
  writtenAndExecutedTruncated: z.boolean().optional(),
364
366
  cleared: z.boolean().optional(),
367
+ emulatorFlaw: z.string().optional().describe("a known flaw of VICE that can make the ROM and I/O classes wrong in this result"),
365
368
  ...WrittenOutput,
366
369
  }),
367
370
  readOnly: false,
@@ -391,12 +394,14 @@ export const c64Memmap = defineTool({
391
394
  ...(code.length > MAX_SUMMARY_RANGES ? { codeTruncated: true } : {}),
392
395
  writtenAndExecuted: showRanges(written),
393
396
  ...(written.length > MAX_SUMMARY_RANGES ? { writtenAndExecutedTruncated: true } : {}),
397
+ ...basicRomFlaw(romInBasicArea(map, start)),
394
398
  };
395
399
  }
396
400
  requireOut(fields, ["maxRanges"]);
397
401
  requireFields("read", fields, ["start", "end", "maxRanges", "out", "project"]);
398
402
  if (fields.out !== undefined) {
399
- return writeOut(fields.out, await readCoverage(session, start, end), fields.project);
403
+ const map = await readCoverage(session, start, end);
404
+ return { ...writeOut(fields.out, map, fields.project), ...basicRomFlaw(romInBasicArea(map, start)) };
400
405
  }
401
406
  const result = await session.memmap({ action: "read", start, end, maxRanges: fields.maxRanges ?? 256 });
402
407
  const ranges = result.ranges.map((range) => ({
@@ -407,8 +412,29 @@ export const c64Memmap = defineTool({
407
412
  write: (range.flags & WRITE) !== 0,
408
413
  in: sources(range.flags),
409
414
  }));
410
- return result.next === undefined ? { ranges } : { ranges, truncated: true, next: formatC64Address(result.next) };
415
+ const flaw = basicRomFlaw(result.ranges.some((range) => range.start <= 0xbfff && range.end >= 0xa000 && (range.flags & ROM_ACCESS) !== 0));
416
+ return result.next === undefined ? { ranges, ...flaw } : { ranges, truncated: true, next: formatC64Address(result.next), ...flaw };
411
417
  },
412
418
  });
413
419
 
420
+ const ROM_ACCESS = COVERAGE.romRead | COVERAGE.romExecute;
421
+
422
+ /** True when the map from `start` has a ROM access at $A000-$BFFF. */
423
+ function romInBasicArea(map: Uint8Array, start: number): boolean {
424
+ for (let address = Math.max(start, 0xa000); address <= Math.min(start + map.length - 1, 0xbfff); address++) {
425
+ if ((map[address - start]! & ROM_ACCESS) !== 0) return true;
426
+ }
427
+ return false;
428
+ }
429
+
430
+ /** VICE tests only bit 0 of $01 for $A000-$BFFF; the C64 shows BASIC ROM there only when bits 0 and 1 are set. */
431
+ function basicRomFlaw(present: boolean): { emulatorFlaw?: string } {
432
+ if (!present) return {};
433
+ return {
434
+ emulatorFlaw:
435
+ "The emulator gives ROM for an access at $A000-$BFFF when bit 0 of $01 is set, also when bit 1 is clear and the CPU used the RAM there. " +
436
+ "Read $01 of the program at that time before you take rom at $A000-$BFFF as the BASIC ROM.",
437
+ };
438
+ }
439
+
414
440
  export const debugTools = [c64Breakpoint, c64Watchpoint, c64CpuHistory, c64Trace, c64Backtrace, c64Profile, c64Memmap, c64Timing];
@@ -5,7 +5,7 @@ import { z } from "zod";
5
5
  import { formatC64Address } from "../../c64.ts";
6
6
  import { PACKAGE } from "../../package.ts";
7
7
  import { VIDEO_STANDARDS } from "../../protocol/messages.ts";
8
- import { POINT_STOP_REASONS, RESET_MODES, RUN_STATES, WINDOW_ACTIONS } from "../../protocol/vice.ts";
8
+ import { POINT_STOP_REASONS, RESET_MODES, RUN_STATES, SPACES, WINDOW_ACTIONS } from "../../protocol/vice.ts";
9
9
  import { AddressOutput, defineTool } from "../server.ts";
10
10
 
11
11
  export const c64Status = defineTool({
@@ -27,6 +27,7 @@ export const c64Status = defineTool({
27
27
  pc: AddressOutput.optional().describe("program counter. Only when the CPU is stopped"),
28
28
  stopReason: z.enum(POINT_STOP_REASONS).optional().describe("what stopped the CPU. Only when a breakpoint, a watchpoint or a JAM opcode stopped it"),
29
29
  pointId: z.number().int().min(1).optional().describe("the id of the breakpoint or watchpoint that stopped the CPU"),
30
+ space: z.enum(SPACES).optional().describe("with pc: c64, or drive8 when a breakpoint or watchpoint in the disk drive stopped the CPU. pc is the address in that space"),
30
31
  cartridge: z.boolean().describe("true while a cartridge from c64_autostart is attached"),
31
32
  version: z.string().describe("the c64-re-tools version of this MCP server"),
32
33
  }),
@@ -43,6 +44,7 @@ export const c64Status = defineTool({
43
44
  ...(status.pc === undefined ? {} : { pc: formatC64Address(status.pc) }),
44
45
  ...(status.stopReason === undefined ? {} : { stopReason: status.stopReason }),
45
46
  ...(status.pointId === undefined ? {} : { pointId: status.pointId }),
47
+ ...(status.space === undefined ? {} : { space: status.space }),
46
48
  };
47
49
  },
48
50
  });
@@ -123,7 +123,7 @@ export const c64MemorySearch = defineTool({
123
123
  title: "Search C64 memory",
124
124
  description:
125
125
  `Find a byte pattern of 1 to ${MAX_SEARCH_PATTERN} bytes in memory from start to end. ?? matches any byte. ` +
126
- `The result gives the start address of each match, at most maxResults (1 to ${MAX_SEARCH_RESULTS}, default 100). ` +
126
+ `The result gives the start address of each match, at most maxResults (1 to ${MAX_SEARCH_RESULTS}, default 100). truncated: true tells that more matches can follow. ` +
127
127
  "This does not change the run state of the machine.",
128
128
  inputSchema: z
129
129
  .object({
@@ -135,7 +135,7 @@ export const c64MemorySearch = defineTool({
135
135
  maxResults: z.number().int().min(1).max(MAX_SEARCH_RESULTS).default(100),
136
136
  })
137
137
  .strict(),
138
- outputSchema: z.object({ matches: z.array(AddressOutput) }),
138
+ outputSchema: z.object({ matches: z.array(AddressOutput), truncated: z.boolean().optional().describe("true when the result has maxResults matches: more can follow. Search again from the address after the last match") }),
139
139
  readOnly: true,
140
140
  async run(input, session) {
141
141
  if (input.pattern.length > MAX_SEARCH_PATTERN) {
@@ -147,7 +147,8 @@ export const c64MemorySearch = defineTool({
147
147
  // VICE refuses a range shorter than the pattern. Such a range has no match.
148
148
  if (end - input.start + 1 < input.pattern.length) return { matches: [] };
149
149
  const result = await session.memorySearch({ ...input, end });
150
- return { matches: result.matches.map(formatC64Address) };
150
+ const matches = result.matches.map(formatC64Address);
151
+ return matches.length >= input.maxResults ? { matches, truncated: true } : { matches };
151
152
  },
152
153
  });
153
154
 
package/src/media.ts ADDED
@@ -0,0 +1,42 @@
1
+ // Media file signatures and sizes. The host checks a file with them before
2
+ // VICE gets it: VICE takes a file that is not of its type in another way, and
3
+ // a CRT or T64 that it cannot read loads as a PRG with a successful autostart.
4
+
5
+ import { Refusal } from "./errors.ts";
6
+
7
+ /** The signature at the start of each media type that has one. */
8
+ const SIGNATURES: Readonly<Record<string, readonly string[]>> = {
9
+ crt: ["C64 CARTRIDGE "],
10
+ t64: ["C64"],
11
+ tap: ["C64-TAPE-RAW"],
12
+ g64: ["GCR-1541"],
13
+ p00: ["C64File"],
14
+ };
15
+
16
+ /** True when `bytes` start with a signature of `type`, or `type` has none. */
17
+ export function hasSignature(type: string, bytes: Uint8Array): boolean {
18
+ const buffer = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
19
+ const signatures = SIGNATURES[type];
20
+ return signatures === undefined || signatures.some((signature) => buffer.toString("latin1", 0, signature.length) === signature);
21
+ }
22
+
23
+ /** The file sizes that VICE reads for each disk image type without a signature. */
24
+ const SIZES: Readonly<Record<string, readonly number[]>> = {
25
+ d64: [174848, 175531, 196608, 197376, 205312, 206114],
26
+ d71: [349696, 351062],
27
+ d81: [819200, 822400],
28
+ };
29
+
30
+ /** The x64 header: 64 bytes that start with these four, then a d64 image. */
31
+ const X64_MAGIC = Buffer.from([0x43, 0x15, 0x41, 0x64]);
32
+
33
+ /** Refuses a file whose bytes are not of `type` (its extension), with media-error. */
34
+ export function checkMedia(type: string, bytes: Buffer): void {
35
+ const refuse = (why: string) => {
36
+ throw new Refusal("media-error", `The file is not a valid .${type} file: ${why}. Give a file of that type.`);
37
+ };
38
+ if (!hasSignature(type, bytes)) refuse(`it does not start with ${SIGNATURES[type]!.map((signature) => `"${signature.trimEnd()}"`).join(" or ")}`);
39
+ const sizes = SIZES[type];
40
+ if (sizes !== undefined && !sizes.includes(bytes.length)) refuse(`it has ${bytes.length} bytes, and a .${type} image has ${sizes.join(", ")}`);
41
+ if (type === "x64" && (!bytes.subarray(0, 4).equals(X64_MAGIC) || !SIZES.d64!.includes(bytes.length - 64))) refuse("it has no x64 header before a d64 image");
42
+ }
package/src/opcodes.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  // spelling, ACME writes only one of them: the generator finds those with a
5
5
  // probe and writes the others as bytes.
6
6
 
7
- import { formatC64Byte, formatC64Address } from "./c64.ts";
7
+ import { formatC64Byte, formatC64Address, SYMBOL_NAME } from "./c64.ts";
8
8
 
9
9
  export type Mode = "imp" | "imm" | "zp" | "zpx" | "zpy" | "abs" | "abx" | "aby" | "ind" | "izx" | "izy" | "rel";
10
10
 
@@ -45,6 +45,17 @@ export const MODE_LENGTH: Record<Mode, 1 | 2 | 3> = { imp: 1, imm: 2, zp: 2, zpx
45
45
  /** Every mnemonic in the table: a symbol with one of these names would read as an instruction. */
46
46
  export const MNEMONICS: ReadonlySet<string> = new Set(OPCODES.map((opcode) => opcode.mnemonic));
47
47
 
48
+ /**
49
+ * Why ACME cannot take a name as a label, or undefined when it can. A label
50
+ * has label syntax (SYMBOL_NAME) and is no mnemonic in upper or lower case.
51
+ * The one rule for a symbol of the knowledge and a label of c64-source.
52
+ */
53
+ export function labelProblem(name: string): string | undefined {
54
+ if (!SYMBOL_NAME.test(name)) return "A symbol name is a letter or underscore, then letters, digits or underscores (at most 64), with an optional leading dot.";
55
+ if (MNEMONICS.has(name.toLowerCase())) return `${name} is the name of a 6502 instruction, which ACME cannot read as a label. Give another name.`;
56
+ return undefined;
57
+ }
58
+
48
59
  const ZERO_PAGE_OF: Partial<Record<Mode, Mode>> = { abs: "zp", abx: "zpx", aby: "zpy" };
49
60
 
50
61
  /** True when the mnemonic also has the zero-page form of an absolute mode: ACME would pick it for a small operand. */
@@ -78,26 +89,48 @@ export function useKind(opcode: number): UseKind {
78
89
  }
79
90
 
80
91
 
81
- /** The text of the instruction in `bytes` at `pc`, for example "sta $d011" or "bne $c002". */
82
- export function instructionText(bytes: readonly number[], pc: number): string {
83
- const { mnemonic, mode } = OPCODES[bytes[0]!]!;
84
- const low = bytes[1] ?? 0;
85
- const word = low | ((bytes[2] ?? 0) << 8);
92
+ /**
93
+ * The ACME text of an instruction with its operand as an expression: the
94
+ * mnemonic, the size suffix (+1 or +2, or none) and the operand in the syntax
95
+ * of its addressing mode, for example "lda #<table", "sta screen,x" or
96
+ * "lda (pointer),y". ACME reads an operand in brackets as indirect, so a
97
+ * non-immediate expression that starts with a bracket gets 0+ in front.
98
+ * The one place that writes an instruction: the generated source, the
99
+ * listing of the knowledge and the disassembly of the emulator use it.
100
+ */
101
+ export function instructionWith(opcode: number, expression: string, suffix = ""): string {
102
+ const { mnemonic, mode } = OPCODES[opcode]!;
103
+ const value = mode !== "imm" && expression.startsWith("(") ? `0+${expression}` : expression;
86
104
  const operand: Record<Mode, string> = {
87
105
  imp: "",
88
- imm: `#${formatC64Byte(low)}`,
89
- zp: formatC64Byte(low),
90
- zpx: `${formatC64Byte(low)},x`,
91
- zpy: `${formatC64Byte(low)},y`,
92
- abs: formatC64Address(word),
93
- abx: `${formatC64Address(word)},x`,
94
- aby: `${formatC64Address(word)},y`,
95
- ind: `(${formatC64Address(word)})`,
96
- izx: `(${formatC64Byte(low)},x)`,
97
- izy: `(${formatC64Byte(low)}),y`,
98
- rel: formatC64Address((pc + 2 + (low < 0x80 ? low : low - 0x100)) & 0xffff),
106
+ imm: `#${value}`,
107
+ zp: value,
108
+ zpx: `${value},x`,
109
+ zpy: `${value},y`,
110
+ abs: value,
111
+ abx: `${value},x`,
112
+ aby: `${value},y`,
113
+ ind: `(${value})`,
114
+ izx: `(${value},x)`,
115
+ izy: `(${value}),y`,
116
+ rel: value,
99
117
  };
100
- return operand[mode] === "" ? mnemonic : `${mnemonic} ${operand[mode]}`;
118
+ return operand[mode] === "" ? `${mnemonic}${suffix}` : `${mnemonic}${suffix} ${operand[mode]}`;
119
+ }
120
+
121
+ /** The value of the operand of the instruction in `bytes` at `pc` as a number: a byte, a word, or the target of a branch. Empty for no operand. */
122
+ export function operandNumber(bytes: readonly number[], pc: number): string {
123
+ const { mode } = OPCODES[bytes[0]!]!;
124
+ const low = bytes[1] ?? 0;
125
+ if (mode === "imp") return "";
126
+ if (mode === "rel") return formatC64Address((pc + 2 + (low < 0x80 ? low : low - 0x100)) & 0xffff);
127
+ return MODE_LENGTH[mode] === 3 ? formatC64Address(low | ((bytes[2] ?? 0) << 8)) : formatC64Byte(low);
128
+ }
129
+
130
+ /** The text of the instruction in `bytes` at `pc` as the monitor shows it, for example "STA $d011" or "BNE $c002". */
131
+ export function instructionText(bytes: readonly number[], pc: number): string {
132
+ const { mnemonic } = OPCODES[bytes[0]!]!;
133
+ return `${mnemonic.toUpperCase()}${instructionWith(bytes[0]!, operandNumber(bytes, pc)).slice(mnemonic.length)}`;
101
134
  }
102
135
 
103
136
  /**
package/src/project.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  // the agent saves for itself and ignores itself in git.
6
6
 
7
7
  import { Refusal } from "./errors.ts";
8
- import { existsSync, mkdirSync, readdirSync, realpathSync, statSync, writeFileSync } from "node:fs";
8
+ import { accessSync, constants, existsSync, lstatSync, mkdirSync, readdirSync, realpathSync, statSync, writeFileSync } from "node:fs";
9
9
  import { homedir } from "node:os";
10
10
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
11
11
 
@@ -117,7 +117,8 @@ export function namedProject(folder: string, cwd: string = process.cwd()): strin
117
117
  if (stat === undefined) throw new Refusal("not-found", `There is no folder ${folder}.`);
118
118
  if (!stat.isDirectory()) throw new Refusal("invalid-input", `${folder} is not a folder.`);
119
119
  if (path === homedir()) throw new Refusal("invalid-input", "The home folder is not a project folder.");
120
- const outer = foldersUpFrom(path).slice(1).find(isProject);
120
+ // A project inside another project is a project of its own, as the lookup from a working directory in it finds.
121
+ const outer = isProject(path) ? undefined : foldersUpFrom(path).slice(1).find(isProject);
121
122
  if (outer !== undefined) {
122
123
  throw new Refusal("invalid-input", `${folder} is inside the project ${relative(cwd, outer) || outer}. Give that project folder.`);
123
124
  }
@@ -180,6 +181,113 @@ export function resolveProjectPath(path: string, root = projectRoot()): string {
180
181
  throw new Refusal("invalid-input", `${path} leads out of the project folder: give a path that stays inside it.`);
181
182
  }
182
183
 
184
+ /** The folder of the committed knowledge text in PROJECT_DIRECTORY. The knowledge (src/knowledge/database.ts) has the same name. */
185
+ export const KNOWLEDGE_TEXT_DIRECTORY = "knowledge";
186
+
187
+ /** The index of the knowledge in LOCAL_DIRECTORY, and the files that SQLite keeps beside it. The knowledge has the same name. */
188
+ const KNOWLEDGE_INDEX = /^knowledge\.db(?:-wal|-shm|-journal)?$/;
189
+
190
+ /**
191
+ * The part of the knowledge that a path relative to the project folder is in:
192
+ * the text in .c64-re-tools/knowledge/ or the index in .c64-re-tools/local/.
193
+ * Undefined for every other path. Letter case does not count: some file
194
+ * systems do not tell the cases apart.
195
+ */
196
+ export function knowledgePart(fromRoot: string): string | undefined {
197
+ const parts = fromRoot.split(/[\\/]+/).filter((part) => part !== "" && part !== ".").map((part) => part.toLowerCase());
198
+ if (parts[0] !== PROJECT_DIRECTORY) return undefined;
199
+ if (parts[1] === KNOWLEDGE_TEXT_DIRECTORY) return `${PROJECT_DIRECTORY}/${KNOWLEDGE_TEXT_DIRECTORY}/`;
200
+ if (parts[1] === LOCAL_DIRECTORY && parts.length === 3 && KNOWLEDGE_INDEX.test(parts[2]!)) return `${PROJECT_DIRECTORY}/${LOCAL_DIRECTORY}/${parts[2]}`;
201
+ return undefined;
202
+ }
203
+
204
+ /** What a command writes at an output path: one file, or files in a folder. */
205
+ export type OutputKind = "file" | "folder";
206
+
207
+ /** Why a file cannot be written, by the error code of the file system; the codes that are a fault of the path. */
208
+ const WRITE_PROBLEMS: Record<string, string> = {
209
+ EISDIR: "is a folder. Give the path of a file",
210
+ EEXIST: "has a file where a folder must be. Give a path whose folders are folders",
211
+ ENOTDIR: "has a file where a folder must be. Give a path whose folders are folders",
212
+ ENOENT: "is in a folder that does not exist",
213
+ EACCES: "cannot be written: there is no permission. Give a path that you can write",
214
+ EPERM: "cannot be written: there is no permission. Give a path that you can write",
215
+ EROFS: "is on a file system that is read-only. Give another path",
216
+ ENAMETOOLONG: "is too long. Give a shorter path",
217
+ };
218
+
219
+ /**
220
+ * The message for a write that the file system refused because of the path
221
+ * (a folder, a file in place of a folder, no permission): the path and what
222
+ * to do. Undefined for another error, which is no fault of the path.
223
+ */
224
+ export function writeProblem(error: unknown, path: string, what?: string): string | undefined {
225
+ const problem = WRITE_PROBLEMS[(error as NodeJS.ErrnoException | undefined)?.code ?? ""];
226
+ return problem === undefined ? undefined : `${what === undefined ? "" : `${what}: `}${path} ${problem}.`;
227
+ }
228
+
229
+ /**
230
+ * Checks a path that a command writes, before the command does any work. The
231
+ * path must stay inside the project, also through symbolic links, and must
232
+ * not be in the knowledge (only the c64-knowledge skill writes it). A file
233
+ * must not be a folder or a symbolic link; a folder must be a folder. The
234
+ * nearest folder that exists must be a folder that can be written. Returns
235
+ * the absolute path. Throws a Refusal (invalid-input) whose message starts
236
+ * with `what` (for example --out). writeProjectFile does the same check for
237
+ * each file that it writes.
238
+ */
239
+ export function checkOutputPath(path: string, kind: OutputKind, options: { root?: string; what?: string } = {}): string {
240
+ const root = options.root ?? projectRoot();
241
+ const what = options.what;
242
+ const outside = () => new Refusal("invalid-input", `${what ?? path}: give a path relative to the project folder that stays inside it, not ${path}.`);
243
+ const problem = (error: unknown) => {
244
+ const message = writeProblem(error, path, what);
245
+ return message === undefined ? error : new Refusal("invalid-input", message);
246
+ };
247
+ const inKnowledge = (fromRoot: string) => {
248
+ const part = knowledgePart(fromRoot);
249
+ if (part !== undefined) {
250
+ throw new Refusal("invalid-input", `${what === undefined ? "" : `${what}: `}${path} is in ${part}, the knowledge of the project. Only the c64-knowledge skill writes it: a write there destroys the knowledge. Give a path outside ${PROJECT_DIRECTORY}/${KNOWLEDGE_TEXT_DIRECTORY}/, for example ${PROJECT_DIRECTORY}/${LOCAL_DIRECTORY}/<file>.`);
251
+ }
252
+ };
253
+ let target: string;
254
+ try {
255
+ target = resolveProjectPath(path, root);
256
+ } catch {
257
+ throw outside();
258
+ }
259
+ inKnowledge(relative(root, target));
260
+ // The nearest entry that exists: the path itself, or a folder above it.
261
+ let existing = target;
262
+ for (;;) {
263
+ let found;
264
+ try {
265
+ found = lstatSync(existing, { throwIfNoEntry: false });
266
+ } catch (error) {
267
+ throw problem(error);
268
+ }
269
+ if (found !== undefined) break;
270
+ existing = dirname(existing);
271
+ }
272
+ const realRoot = realpathSync(root);
273
+ if (existing === target && kind === "file" && lstatSync(target).isSymbolicLink()) throw outside();
274
+ const real = realpathSync(existing);
275
+ if (!isInside(realRoot, real)) throw outside();
276
+ inKnowledge(relative(realRoot, join(real, relative(existing, target))));
277
+ const stat = statSync(real);
278
+ if (existing === target && kind === "file") {
279
+ if (stat.isDirectory()) throw problem({ code: "EISDIR" });
280
+ } else if (!stat.isDirectory()) {
281
+ throw problem({ code: "ENOTDIR" });
282
+ }
283
+ try {
284
+ accessSync(real, constants.W_OK);
285
+ } catch (error) {
286
+ throw problem(error);
287
+ }
288
+ return target;
289
+ }
290
+
183
291
  /** A project path in one spelling: relative to the project folder, with / and without ./, .. or a double /. Refuses a path as resolveProjectPath does. */
184
292
  export function projectRelative(path: string, root = projectRoot()): string {
185
293
  return relative(root, resolveProjectPath(path, root)).split(sep).join("/");