@henols/c64-re-tools 2.1.1 → 2.1.3

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 (67) 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 +4 -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 +4 -1
  13. package/skills/c64-knowledge/SKILL.md +12 -6
  14. package/skills/c64-knowledge/scripts/knowledge.ts +41 -25
  15. package/skills/c64-knowledge/scripts/lib/listing.ts +16 -31
  16. package/skills/c64-memory-map/SKILL.md +3 -0
  17. package/skills/c64-memory-map/scripts/memory-map.ts +23 -4
  18. package/skills/c64-provenance/SKILL.md +5 -3
  19. package/skills/c64-provenance/scripts/lib/library.ts +28 -5
  20. package/skills/c64-provenance/scripts/lib/separate.ts +34 -6
  21. package/skills/c64-provenance/scripts/provenance.ts +22 -10
  22. package/skills/c64-source/SKILL.md +2 -1
  23. package/skills/c64-source/scripts/lib/source.ts +25 -33
  24. package/skills/c64-source/scripts/source.ts +13 -17
  25. package/skills/c64-static-analysis/SKILL.md +6 -3
  26. package/skills/c64-static-analysis/scripts/lib/findings.ts +23 -8
  27. package/skills/c64-static-analysis/scripts/lib/pointers.ts +38 -25
  28. package/skills/c64-static-analysis/scripts/static-analysis.ts +31 -19
  29. package/skills/c64-testing/SKILL.md +9 -5
  30. package/skills/c64-testing/references/scenario-format.md +5 -3
  31. package/skills/c64-testing/scripts/lib/diff-files.ts +41 -10
  32. package/skills/c64-testing/scripts/lib/recording.ts +3 -3
  33. package/skills/c64-testing/scripts/lib/replay.ts +1 -1
  34. package/skills/c64-testing/scripts/lib/runs.ts +45 -9
  35. package/skills/c64-testing/scripts/lib/scenario.ts +130 -81
  36. package/skills/c64-testing/scripts/testing.ts +9 -6
  37. package/skills/c64-unpacker/SKILL.md +10 -7
  38. package/skills/c64-unpacker/scripts/lib/loads.ts +15 -6
  39. package/skills/c64-unpacker/scripts/lib/machine.ts +61 -3
  40. package/skills/c64-unpacker/scripts/lib/stages.ts +15 -7
  41. package/skills/c64-unpacker/scripts/unpacker.ts +21 -17
  42. package/src/host/vice/adapter.ts +4 -1
  43. package/src/host/vice/session.ts +144 -11
  44. package/src/host-client/image.ts +27 -13
  45. package/src/host-client/steps.ts +41 -5
  46. package/src/host-client/tools.ts +4 -3
  47. package/src/host-client/transfer.ts +22 -29
  48. package/src/knowledge/history.ts +55 -2
  49. package/src/knowledge/import.ts +79 -7
  50. package/src/knowledge/program.ts +31 -2
  51. package/src/knowledge/read.ts +7 -4
  52. package/src/knowledge/schema.ts +3 -2
  53. package/src/knowledge/text.ts +1 -0
  54. package/src/knowledge/write.ts +86 -42
  55. package/src/mcp/inputs.ts +6 -3
  56. package/src/mcp/output.ts +1 -1
  57. package/src/mcp/server.ts +9 -1
  58. package/src/mcp/tools/debug.ts +31 -4
  59. package/src/mcp/tools/machine.ts +3 -1
  60. package/src/mcp/tools/media.ts +5 -1
  61. package/src/mcp/tools/memory.ts +4 -3
  62. package/src/media.ts +57 -0
  63. package/src/opcodes.ts +81 -18
  64. package/src/platform.ts +14 -1
  65. package/src/project.ts +118 -3
  66. package/src/protocol/vice.ts +4 -1
  67. package/src/script.ts +118 -5
@@ -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,
@@ -61,7 +62,7 @@ import {
61
62
  type SuppressionRow,
62
63
  type SymbolRow,
63
64
  } from "./read.ts";
64
- import { CODE_REGION_TYPES, COMMENT_PLACEMENTS, EVIDENCE_KINDS, MAX_ELEMENT, MODULE_FILE, DEFAULT_SPACE, GENERATED_FILES, isGeneratedFile, MACRO_NAME, MACRO_PARAM, SWITCH_NAME, NO_ELEMENT_TYPES, OPERAND_SELECTS, POINTER_KIND, REFERENCE_KINDS, REGION_TYPES, SYMBOL_KINDS, type CommentPlacement, type EvidenceKind, type OperandSelect, type Origin, type ReferenceKind, type RegionType, type Space, type SymbolKind } from "./schema.ts";
65
+ import { CODE_REGION_TYPES, COMMENT_PLACEMENTS, EVIDENCE_KINDS, MAX_ELEMENT, MODULE_FILE, DEFAULT_SPACE, GENERATED_FILES, isGeneratedFile, isSemantic, MACRO_NAME, MACRO_PARAM, SWITCH_NAME, NO_ELEMENT_TYPES, OPERAND_SELECTS, POINTER_KIND, REFERENCE_KINDS, REGION_TYPES, SYMBOL_KINDS, type CommentPlacement, type EvidenceKind, type OperandSelect, type Origin, type ReferenceKind, type RegionType, type Space, type SymbolKind } from "./schema.ts";
65
66
  import { newRevisionId, rowId, sqlName } from "./text.ts";
66
67
 
67
68
  /** What a change rests on: a coverage run, a test or another project file with its SHA-256, or a note. */
@@ -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);
@@ -350,6 +353,27 @@ const result = <T, P>(written: { revision: string | null; current: Step<T, P> })
350
353
  // Each write has a check, which refuses bad input before anything opens, and a
351
354
  // step, which runs inside a change. A single write and a batch share both.
352
355
 
356
+ /**
357
+ * True when a write of `origin` over a current row with the same content
358
+ * records a decision, so it replaces the row: over a row of an analyzer (the
359
+ * next run of the analyzer can retire its own rows, never a decision), and the
360
+ * user over the LLM. The LLM over the user changes nothing.
361
+ */
362
+ function confirms(existing: Origin, origin: Origin): boolean {
363
+ return existing !== origin && (!isSemantic(existing) || origin === "user");
364
+ }
365
+
366
+ /**
367
+ * The bytes of the program of a space in the knowledge, by address: undefined
368
+ * outside it. A module and a comment are checked against them.
369
+ */
370
+ function programBytes(db: DatabaseSync, space: Space): (address: number) => number | undefined {
371
+ const rows = db.prepare("SELECT address, bytes FROM current_memory WHERE space = ?").all(space) as Array<{ address: number; bytes: string }>;
372
+ const memory = new Map<number, number>();
373
+ for (const row of rows) Buffer.from(row.bytes, "hex").forEach((byte, offset) => memory.set(row.address + offset, byte));
374
+ return (address) => memory.get(address);
375
+ }
376
+
353
377
  /** Every request names its space; without one it is the C64. */
354
378
  type InSpace = { space?: Space };
355
379
  type SymbolRequest = InSpace & { address: number; name: string; kind?: SymbolKind };
@@ -369,9 +393,8 @@ export function checkSpace(request: InSpace): void {
369
393
  function checkSymbol(request: SymbolRequest): void {
370
394
  checkSpace(request);
371
395
  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
- }
396
+ const problem = labelProblem(request.name);
397
+ if (problem !== undefined) throw new Refusal("invalid-input", problem);
375
398
  if (request.kind !== undefined) checkOneOf(SYMBOL_KINDS, request.kind, "kind");
376
399
  }
377
400
 
@@ -379,7 +402,7 @@ function renameStep(db: DatabaseSync, change: Change, origin: Origin, request: S
379
402
  const space = spaceOf(request);
380
403
  const existing = symbolAt(db, request.address, space);
381
404
  const kind = request.kind ?? existing?.kind ?? "label";
382
- if (existing !== undefined && existing.name === request.name && existing.kind === kind) return { current: existing };
405
+ if (existing !== undefined && existing.name === request.name && existing.kind === kind && !confirms(existing.origin, origin)) return { current: existing };
383
406
  const owner = symbolNamed(db, request.name, space);
384
407
  if (owner !== undefined && owner.address !== request.address) {
385
408
  throw new Refusal("conflict", `The name ${request.name} already belongs to ${formatC64Address(owner.address)}. Rename or remove that symbol first.`);
@@ -457,7 +480,7 @@ function classifyStep(db: DatabaseSync, change: Change, origin: Origin, request:
457
480
  const overlapping = regionsOverlapping(db, request.start, request.end, space);
458
481
  const [only] = overlapping;
459
482
  const same = overlapping.length === 1 && only!.start === request.start && only!.end === request.end && only!.type === request.type && only!.element === request.element;
460
- if (same) return { current: overlapping };
483
+ if (same && !confirms(only!.origin, origin)) return { current: overlapping };
461
484
  clearRange(db, change, request.start, request.end, space);
462
485
  change.insertRegion({ space, start: request.start, end: request.end, type: request.type, ...(request.element === undefined ? {} : { element: request.element }), origin });
463
486
  liftRegionSuppressions(db, change, request.start, request.end, space);
@@ -488,14 +511,17 @@ function checkComment(request: CommentRequest): string {
488
511
  return text;
489
512
  }
490
513
 
491
- function commentStep(db: DatabaseSync, change: Change, origin: Origin, request: CommentRequest): Step<CommentRow> {
514
+ function commentStep(db: DatabaseSync, change: Change, origin: Origin, request: CommentRequest): Step<CommentRow, CommentRow> {
492
515
  const space = spaceOf(request);
493
516
  const text = request.text.trim();
494
517
  const existing = commentsAt(db, request.address, space).find((comment) => comment.placement === request.placement);
495
- if (existing?.text === text) return { current: existing };
518
+ if (existing?.text === text && !confirms(existing.origin, origin)) return { current: existing };
519
+ // A comment belongs to an instruction: the listing and the source show it at the start of the instruction only.
520
+ checkInstructionStart(db, space, programBytes(db, space), request.address, "comment");
496
521
  if (existing !== undefined) change.close("comments", "space = ? AND address = ? AND placement = ?", space, request.address, request.placement);
497
522
  change.insertComment({ space, address: request.address, placement: request.placement, text, origin });
498
- return { current: commentsAt(db, request.address, space).find((comment) => comment.placement === request.placement)! };
523
+ const current = commentsAt(db, request.address, space).find((comment) => comment.placement === request.placement)!;
524
+ return existing !== undefined && existing.origin !== origin ? { current, previous: existing } : { current };
499
525
  }
500
526
 
501
527
  function checkPlacement(request: PlacementRequest): void {
@@ -521,14 +547,16 @@ function checkReference(request: ReferenceRequest): void {
521
547
  if (request.kind === POINTER_KIND) throw new Refusal("invalid-input", "Only an operand expression writes a pointer reference: use operand and unoperand.");
522
548
  }
523
549
 
524
- function referenceStep(db: DatabaseSync, change: Change, origin: Origin, request: ReferenceRequest): Step<ReferenceRow> {
550
+ function referenceStep(db: DatabaseSync, change: Change, origin: Origin, request: ReferenceRequest): Step<ReferenceRow, ReferenceRow> {
525
551
  const space = spaceOf(request);
526
552
  const find = () => referencesFrom(db, request.from, space).find((reference) => reference.to === request.to && reference.kind === request.kind);
527
553
  const existing = find();
528
- if (existing !== undefined) return { current: existing };
554
+ if (existing !== undefined && !confirms(existing.origin, origin)) return { current: existing };
555
+ // A confirmation of a reference of an analyzer makes it a reference of the writer: the next run of the analyzer keeps it.
556
+ if (existing !== undefined) change.close('"references"', "space = ? AND from_address = ? AND to_address = ? AND kind = ?", space, request.from, request.to, request.kind);
529
557
  change.insertReference({ space, from: request.from, to: request.to, kind: request.kind, origin });
530
558
  change.close("suppressions", "space = ? AND entity = 'reference' AND start_address = ? AND to_address = ? AND kind = ?", space, request.from, request.to, request.kind);
531
- return { current: find()! };
559
+ return existing !== undefined ? { current: find()!, previous: existing } : { current: find()! };
532
560
  }
533
561
 
534
562
  function unreferenceStep(db: DatabaseSync, change: Change, origin: Origin, request: ReferenceRequest): Step<null> {
@@ -579,26 +607,32 @@ const shown = (value: number, size: number) => (value < 0 ? String(value) : `$${
579
607
  /**
580
608
  * Refuses an address that is not the start of an instruction. The code
581
609
  * regions of the knowledge give the instructions: they are decoded from the
582
- * start of the region, as the listing and the source decode them.
610
+ * start of the region, as the listing and the source decode them. An operand
611
+ * expression needs an instruction, so it is refused also outside the code
612
+ * regions and where the program has no byte. A module and a comment are
613
+ * refused only at an operand byte of a known instruction: they can start data.
583
614
  */
584
- function checkInstructionStart(db: DatabaseSync, space: Space, image: OperandImage, address: number): void {
615
+ function checkInstructionStart(db: DatabaseSync, space: Space, byteAt: (address: number) => number | undefined, address: number, write: "operand" | "module" | "comment"): void {
585
616
  const where = formatC64Address(address);
586
617
  const code = regionsOverlapping(db, address, address, space).find((region) => CODE_REGION_TYPES.includes(region.type));
587
618
  if (code === undefined) {
619
+ if (write !== "operand") return;
588
620
  throw new Refusal("invalid-input", `${where} is in no code region, so the knowledge does not know an instruction that starts there. Classify the code that holds the instruction first (classify <range> code), then write the operand.`);
589
621
  }
590
622
  let start = code.start;
591
623
  let next = code.start;
592
624
  while (next < address) {
593
- const opcode = image.byteAt(next);
625
+ const opcode = byteAt(next);
594
626
  if (opcode === undefined) {
627
+ if (write !== "operand") return;
595
628
  throw new Refusal("invalid-input", `The code region ${formatC64Address(code.start)}-${formatC64Address(code.end)} has no program byte at ${formatC64Address(next)}, so its instructions up to ${where} are not known. Correct the region (classify), then write the operand.`);
596
629
  }
597
630
  start = next;
598
631
  next += instructionLength(opcode);
599
632
  }
600
633
  if (next !== address) {
601
- throw new Refusal("invalid-input", `${where} is not the start of an instruction. It is an operand byte of the instruction at ${formatC64Address(start)}, decoded from the start of the code region at ${formatC64Address(code.start)}. Give ${formatC64Address(start)}.`);
634
+ const what = write === "operand" ? "" : ` A ${write} is at the start of an instruction, as the listing and the source show it.`;
635
+ throw new Refusal("invalid-input", `${where} is not the start of an instruction. It is an operand byte of the instruction at ${formatC64Address(start)}, decoded from the start of the code region at ${formatC64Address(code.start)}.${what} Give ${formatC64Address(start)}.`);
602
636
  }
603
637
  }
604
638
 
@@ -611,7 +645,7 @@ function checkOperandValue(db: DatabaseSync, space: Space, image: OperandImage,
611
645
  const where = formatC64Address(request.address);
612
646
  const opcode = image.byteAt(request.address);
613
647
  if (opcode === undefined) throw new Refusal("invalid-input", `${where} is outside the image.`);
614
- checkInstructionStart(db, space, image, request.address);
648
+ checkInstructionStart(db, space, (at) => image.byteAt(at), request.address, "operand");
615
649
  const length = instructionLength(opcode);
616
650
  if (length === 1) throw new Refusal("invalid-input", `The instruction at ${where} (opcode ${shown(opcode, 0x100)}) has no operand.`);
617
651
  if ((opcode & 0x1f) === 0x10) throw new Refusal("invalid-input", `The instruction at ${where} is a branch: its operand is a distance, not a value.`);
@@ -644,10 +678,10 @@ function operandStep(db: DatabaseSync, change: Change, context: ChangeContext &
644
678
  if (request.expression !== undefined) wanted.expression = request.expression.trim();
645
679
  const targets = new Set(checkOperandValue(db, space, context.image, request, operandText(wanted)));
646
680
  const existing = operandAt(db, request.address, space);
647
- if (existing !== undefined && existing.select === wanted.select && existing.symbol === wanted.symbol && existing.offset === wanted.offset && existing.expression === wanted.expression) {
681
+ const origin = context.origin as OperandRow["origin"];
682
+ if (existing !== undefined && existing.select === wanted.select && existing.symbol === wanted.symbol && existing.offset === wanted.offset && existing.expression === wanted.expression && !confirms(existing.origin, origin)) {
648
683
  return { current: existing };
649
684
  }
650
- const origin = context.origin as OperandRow["origin"];
651
685
  if (existing !== undefined) change.close("operands", "space = ? AND address = ?", space, request.address);
652
686
  change.insertOperand({ space, address: request.address, ...wanted, origin });
653
687
  const pointers = referencesFrom(db, request.address, space).filter((reference) => reference.kind === POINTER_KIND);
@@ -681,19 +715,22 @@ function checkModule(request: ModuleRequest): void {
681
715
  if (request.title !== undefined && (request.title.trim().length === 0 || request.title.length > MAX_COMMENT)) throw new Refusal("invalid-input", `A module title has 1 to ${MAX_COMMENT} characters.`);
682
716
  }
683
717
 
684
- function moduleStep(db: DatabaseSync, change: Change, origin: Origin, request: ModuleRequest): Step<ModuleRow> {
718
+ function moduleStep(db: DatabaseSync, change: Change, origin: Origin, request: ModuleRequest): Step<ModuleRow, ModuleRow> {
685
719
  const space = spaceOf(request);
686
720
  const existing = moduleAt(db, request.start, space);
687
721
  // A module write without a title keeps the title it has.
688
722
  const title = request.title === undefined ? existing?.title : request.title.trim();
689
- if (existing !== undefined && existing.file === request.file && existing.title === title) return { current: existing };
723
+ if (existing !== undefined && existing.file === request.file && existing.title === title && !confirms(existing.origin, origin)) return { current: existing };
724
+ // A file of the source starts at an instruction: a start inside one splits it into bytes.
725
+ checkInstructionStart(db, space, programBytes(db, space), request.start, "module");
690
726
  const owner = moduleWithFile(db, request.file, space);
691
727
  if (owner !== undefined && owner.start !== request.start) {
692
728
  throw new Refusal("conflict", `The file ${request.file} already belongs to the module at ${formatC64Address(owner.start)}. Give this module another file, or remove that module first.`);
693
729
  }
694
730
  if (existing !== undefined) change.close("modules", "space = ? AND start_address = ?", space, request.start);
695
731
  change.insertModule({ space, start: request.start, file: request.file, ...(title === undefined ? {} : { title }), origin: origin as ModuleRow["origin"] });
696
- return { current: moduleAt(db, request.start, space)! };
732
+ const current = moduleAt(db, request.start, space)!;
733
+ return existing !== undefined && existing.origin !== origin ? { current, previous: existing } : { current };
697
734
  }
698
735
 
699
736
  function unmoduleStep(db: DatabaseSync, change: Change, _origin: Origin, request: InSpace & { start: number }): Step<null> {
@@ -758,9 +795,10 @@ function patchStep(db: DatabaseSync, change: Change, context: ChangeContext & {
758
795
  const symbol = symbolNamed(db, request.switch, space);
759
796
  if (symbol !== undefined) throw new Refusal("conflict", `The switch name ${request.switch} is the name of the symbol at ${formatC64Address(symbol.address)}. Give the switch another name.`);
760
797
  if (space === DEFAULT_SPACE && context.image.platform.has(request.switch)) throw new Refusal("conflict", `The switch name ${request.switch} is a C64 platform name. Give the switch another name.`);
761
- const other = patchesOverlapping(db, request.start, request.end, space).find((row) => row.start !== request.start);
798
+ // A write at the start of a patch of the same switch changes that patch; every other patch that it overlaps is a conflict.
799
+ const other = patchesOverlapping(db, request.start, request.end, space).find((row) => row.start !== request.start || row.switch !== request.switch);
762
800
  if (other !== undefined) {
763
- throw new Refusal("conflict", `The patch ${range} overlaps the patch of ${other.switch} at ${formatC64Address(other.start)}-${formatC64Address(other.end)}. Change or remove that patch first (unpatch).`);
801
+ throw new Refusal("conflict", `The patch ${range} of ${request.switch} overlaps the patch of ${other.switch} at ${formatC64Address(other.start)}-${formatC64Address(other.end)}. A patch cannot overlap another patch, and only a patch of the same switch at the same start replaces it. Change or remove that patch first (unpatch).`);
764
802
  }
765
803
  const [use] = macroUsesOverlapping(db, request.start, request.end, space);
766
804
  if (use !== undefined) {
@@ -770,8 +808,8 @@ function patchStep(db: DatabaseSync, change: Change, context: ChangeContext & {
770
808
  const code = request.code.trimEnd();
771
809
  // A patch write without a comment keeps the comment it has, as a module keeps its title.
772
810
  const comment = request.comment === undefined ? existing?.comment : request.comment.trim();
773
- if (existing !== undefined && existing.end === request.end && existing.switch === request.switch && existing.code === code && existing.comment === comment) return { current: existing };
774
811
  const origin = context.origin as PatchRow["origin"];
812
+ if (existing !== undefined && existing.end === request.end && existing.switch === request.switch && existing.code === code && existing.comment === comment && !confirms(existing.origin, origin)) return { current: existing };
775
813
  if (existing !== undefined) change.close("patches", "space = ? AND start_address = ?", space, request.start);
776
814
  change.insertPatch({ space, start: request.start, end: request.end, switch: request.switch, code, ...(comment === undefined ? {} : { comment }), origin });
777
815
  const current = patchesOverlapping(db, request.start, request.start, space).find((row) => row.start === request.start)!;
@@ -813,7 +851,7 @@ function macroStep(db: DatabaseSync, change: Change, origin: Origin, request: Ma
813
851
  const params = request.params ?? [];
814
852
  const body = request.body.trimEnd();
815
853
  const comment = request.comment === undefined ? existing?.comment : request.comment.trim();
816
- if (existing !== undefined && listText(existing.params) === listText(params) && existing.body === body && existing.comment === comment) return { current: existing };
854
+ if (existing !== undefined && listText(existing.params) === listText(params) && existing.body === body && existing.comment === comment && !confirms(existing.origin, origin)) return { current: existing };
817
855
  if (existing !== undefined && existing.params.length !== params.length) {
818
856
  const uses = listMacroUses(db, undefined, request.name, undefined, space);
819
857
  if (uses.length > 0) {
@@ -899,8 +937,8 @@ function useStep(db: DatabaseSync, change: Change, context: ChangeContext & { im
899
937
  throw new Refusal("conflict", `The macro use ${range} overlaps the use of ${other.macro} at ${formatC64Address(other.start)}-${formatC64Address(other.end)}. Remove that use first (unuse).`);
900
938
  }
901
939
  const existing = macroUsesOverlapping(db, request.start, request.start, space).find((row) => row.start === request.start);
902
- if (existing !== undefined && existing.end === request.end && existing.macro === request.macro && listText(existing.args) === listText(args)) return { current: existing };
903
940
  const origin = context.origin as MacroUseRow["origin"];
941
+ if (existing !== undefined && existing.end === request.end && existing.macro === request.macro && listText(existing.args) === listText(args) && !confirms(existing.origin, origin)) return { current: existing };
904
942
  if (existing !== undefined) change.close("macro_uses", "space = ? AND start_address = ?", space, request.start);
905
943
  change.insertMacroUse({ space, start: request.start, end: request.end, macro: request.macro, args, origin });
906
944
  const current = macroUsesOverlapping(db, request.start, request.start, space).find((row) => row.start === request.start)!;
@@ -938,7 +976,7 @@ export function unclassifyRegion(db: DatabaseSync, context: WriteContext, reques
938
976
  return result(write(db, context, "unclassify-region", (change) => unclassifyStep(db, change, context.origin, request)));
939
977
  }
940
978
 
941
- export function setComment(db: DatabaseSync, context: WriteContext, request: CommentRequest): WriteResult<CommentRow> {
979
+ export function setComment(db: DatabaseSync, context: WriteContext, request: CommentRequest): WriteResult<CommentRow, CommentRow> {
942
980
  checkComment(request);
943
981
  return result(write(db, context, "set-comment", (change) => commentStep(db, change, context.origin, request)));
944
982
  }
@@ -948,7 +986,7 @@ export function removeComment(db: DatabaseSync, context: WriteContext, request:
948
986
  return result(write(db, context, "remove-comment", (change) => uncommentStep(db, change, context.origin, request)));
949
987
  }
950
988
 
951
- export function addReference(db: DatabaseSync, context: WriteContext, request: ReferenceRequest): WriteResult<ReferenceRow> {
989
+ export function addReference(db: DatabaseSync, context: WriteContext, request: ReferenceRequest): WriteResult<ReferenceRow, ReferenceRow> {
952
990
  checkReference(request);
953
991
  return result(write(db, context, "add-reference", (change) => referenceStep(db, change, context.origin, request)));
954
992
  }
@@ -959,7 +997,7 @@ export function removeReference(db: DatabaseSync, context: WriteContext, request
959
997
  }
960
998
 
961
999
  /** Starts a file of the generated source at an address; one module per address and per file. */
962
- export function setModule(db: DatabaseSync, context: WriteContext, request: ModuleRequest): WriteResult<ModuleRow> {
1000
+ export function setModule(db: DatabaseSync, context: WriteContext, request: ModuleRequest): WriteResult<ModuleRow, ModuleRow> {
963
1001
  checkModule(request);
964
1002
  return result(write(db, context, "set-module", (change) => moduleStep(db, change, context.origin, request)));
965
1003
  }
@@ -1051,7 +1089,7 @@ export const MAX_BATCH = 10_000;
1051
1089
  /** The result of each write of a batch, in order. */
1052
1090
  export type BatchStep = Step<
1053
1091
  SymbolRow | RegionRow[] | CommentRow | ReferenceRow | ModuleRow | OperandRow | PatchRow | MacroRow | MacroUseRow | null,
1054
- SymbolRow | RegionRow[] | OperandRow | PatchRow | MacroRow | MacroUseRow
1092
+ SymbolRow | RegionRow[] | CommentRow | ReferenceRow | ModuleRow | OperandRow | PatchRow | MacroRow | MacroUseRow
1055
1093
  >;
1056
1094
 
1057
1095
  /**
@@ -1199,19 +1237,25 @@ const KEYS = {
1199
1237
 
1200
1238
  /**
1201
1239
  * 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.
1240
+ * revision. With `space`, only the rows of that space change; without it,
1241
+ * every space changes. History is never erased; restored rows keep their origin.
1203
1242
  */
1204
- export function revert(db: DatabaseSync, context: WriteContext, request: { revision: string }): WriteResult<{ restoredRevision: string }> {
1243
+ export function revert(db: DatabaseSync, context: WriteContext, request: { revision: string; space?: Space }): WriteResult<{ restoredRevision: string }> {
1244
+ checkSpace(request);
1205
1245
  const restored = resolveRevision(db, request.revision);
1206
- return write(db, { ...context, restores: restored }, "revert", (change) => {
1246
+ const only = request.space;
1247
+ return write(db, { ...context, restores: restored, ...(only === undefined ? {} : { restoresSpace: only }) }, "revert", (change) => {
1207
1248
  // 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]));
1249
+ 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
1250
  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 > ?)";
1251
+ const inSpace = only === undefined ? "" : " AND space = ?";
1252
+ const spaceArgs = only === undefined ? [] : [only];
1253
+ const at = `valid_from_revision <= ? AND (valid_to_revision IS NULL OR valid_to_revision > ?)${inSpace}`;
1254
+ const current = `valid_to_revision IS NULL${inSpace}`;
1211
1255
  for (const [table, spec] of Object.entries(KEYS)) {
1212
1256
  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>>;
1257
+ const then = db.prepare(`SELECT ${spec.columns}, id FROM ${name} WHERE ${at}`).all(restored, restored, ...spaceArgs) as Array<Record<string, unknown>>;
1258
+ const now = db.prepare(`SELECT ${spec.columns}, id FROM ${name} WHERE ${current}`).all(...spaceArgs) as Array<Record<string, unknown>>;
1215
1259
  const key = (row: Record<string, unknown>) => (spec.key as (row: unknown) => string)(spec.map(row as never));
1216
1260
  const thenKeys = new Set(then.map(key));
1217
1261
  const nowKeys = new Set(now.map(key));
@@ -1253,8 +1297,8 @@ export function revert(db: DatabaseSync, context: WriteContext, request: { revis
1253
1297
  }
1254
1298
  // The program: a row is the same when all its values are the same.
1255
1299
  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>>;
1300
+ const then = db.prepare(`SELECT ${columns}, id FROM ${table} WHERE ${at}`).all(restored, restored, ...spaceArgs) as Array<Record<string, unknown>>;
1301
+ const now = db.prepare(`SELECT ${columns}, id FROM ${table} WHERE ${current}`).all(...spaceArgs) as Array<Record<string, unknown>>;
1258
1302
  const key = (row: Record<string, unknown>) => JSON.stringify(columns.split(", ").map((column) => row[column]));
1259
1303
  const thenKeys = new Set(then.map(key));
1260
1304
  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/output.ts CHANGED
@@ -7,7 +7,7 @@ import { z } from "zod";
7
7
 
8
8
  import { projectFolder, writeReported, type Written } from "../host-client/transfer.ts";
9
9
 
10
- export const OutInput = z.string().min(1).describe("write the result to this file, relative to the project folder, in place of returning it. An existing file is overwritten");
10
+ export const OutInput = z.string().min(1).describe("write the data to this file, relative to the project folder. An existing file is overwritten. The tool description tells what the result then gives");
11
11
 
12
12
  /** The project of a tool with a path. */
13
13
  export const ProjectInput = z
package/src/mcp/server.ts CHANGED
@@ -10,6 +10,7 @@ import { z } from "zod";
10
10
  import { formatC64Address } from "../c64.ts";
11
11
  import { Byte, TOOL_INPUTS } from "./inputs.ts";
12
12
  import { LostBeforeAnswer, type ViceSessionClient } from "../host-client/vice-session.ts";
13
+ import { checkOut } from "../host-client/transfer.ts";
13
14
  import { isRefusal } from "../errors.ts";
14
15
  import { shownMessage, WireFailure, type WireError } from "../protocol/messages.ts";
15
16
  import { COMPARISONS, CONDITION_REGISTERS, MEMORY_VIEWS, SPACES, type Condition } from "../protocol/vice.ts";
@@ -179,10 +180,14 @@ function errorResult(error: WireError): CallToolResult {
179
180
  * An unknown field of the tool input also gets the fields that the tool takes.
180
181
  */
181
182
  function describeIssues(error: z.ZodError, input?: z.ZodObject): string {
183
+ // An unknown field also breaks the rules that look at the whole object: name only the field.
184
+ const unknown = error.issues.some((issue) => issue.code === "unrecognized_keys");
182
185
  return error.issues
186
+ .filter((issue) => !unknown || issue.code !== "custom")
183
187
  .map((issue) => {
184
188
  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(", ")}` : "";
189
+ const names = input === undefined ? [] : Object.keys(input.shape);
190
+ 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
191
  return `${where}: ${issue.message}${fields}`;
187
192
  })
188
193
  .join(". ");
@@ -228,6 +233,9 @@ async function runCall(
228
233
  const parsed = tool.inputSchema.safeParse(args ?? {});
229
234
  if (!parsed.success) return errorResult({ code: "invalid-input", message: describeIssues(parsed.error, tool.inputSchema) });
230
235
  try {
236
+ // An out path is checked before the tool runs: a refused path must not lose the work or leave its side effects.
237
+ const fields = parsed.data as { out?: unknown; project?: unknown };
238
+ if (typeof fields.out === "string") checkOut(fields.out, typeof fields.project === "string" ? fields.project : undefined);
231
239
  let output: unknown;
232
240
  try {
233
241
  output = await tool.run(parsed.data, await session());
@@ -186,7 +186,8 @@ export const c64Trace = defineTool({
186
186
  "Each hit gives the instruction (pc, bytes, text) and the registers a, x, y and sp before it. " +
187
187
  "point in a hit is the number of its point in points, 0 for the first. " +
188
188
  "It also gives the frame from 0, and the raster line and cycle where the instruction started. " +
189
- "A read or write hit also gives the address that the instruction used, and the byte there after the access. " +
189
+ "A read or write hit also gives the address that the instruction used, and value, the byte that it read or wrote. " +
190
+ "At $D000-$DFFF only a load or a store gives value: a read after the access can give another byte. " +
190
191
  `The trace ends after frames frames (1 to ${MAX_TRACE_FRAMES}), at maxHits hits (at most ${MAX_TRACE_HITS}), or at a breakpoint, a watchpoint or a JAM. ` +
191
192
  `The result lists the first ${TRACE_LISTED} hits. With out, it writes all hits as JSON lines to that project file. ` +
192
193
  "Each hit stops the machine for a short time. Thus a point with many hits in each frame makes the trace slow. Use a small range and a maxHits. " +
@@ -339,7 +340,9 @@ export const c64Memmap = defineTool({
339
340
  "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
341
  "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
342
  `Each list has at most ${MAX_SUMMARY_RANGES} ranges. ` +
342
- "Action clear sets all access records to zero.",
343
+ "Action clear sets all access records to zero. " +
344
+ "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. " +
345
+ "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
346
  inputSchema: z
344
347
  .object({
345
348
  action: z.enum(["read", "summary", "clear"]),
@@ -362,6 +365,7 @@ export const c64Memmap = defineTool({
362
365
  writtenAndExecuted: z.array(RangeOutput).optional(),
363
366
  writtenAndExecutedTruncated: z.boolean().optional(),
364
367
  cleared: z.boolean().optional(),
368
+ emulatorFlaw: z.string().optional().describe("a known flaw of VICE that can make the ROM and I/O classes wrong in this result"),
365
369
  ...WrittenOutput,
366
370
  }),
367
371
  readOnly: false,
@@ -391,12 +395,14 @@ export const c64Memmap = defineTool({
391
395
  ...(code.length > MAX_SUMMARY_RANGES ? { codeTruncated: true } : {}),
392
396
  writtenAndExecuted: showRanges(written),
393
397
  ...(written.length > MAX_SUMMARY_RANGES ? { writtenAndExecutedTruncated: true } : {}),
398
+ ...basicRomFlaw(romInBasicArea(map, start)),
394
399
  };
395
400
  }
396
401
  requireOut(fields, ["maxRanges"]);
397
402
  requireFields("read", fields, ["start", "end", "maxRanges", "out", "project"]);
398
403
  if (fields.out !== undefined) {
399
- return writeOut(fields.out, await readCoverage(session, start, end), fields.project);
404
+ const map = await readCoverage(session, start, end);
405
+ return { ...writeOut(fields.out, map, fields.project), ...basicRomFlaw(romInBasicArea(map, start)) };
400
406
  }
401
407
  const result = await session.memmap({ action: "read", start, end, maxRanges: fields.maxRanges ?? 256 });
402
408
  const ranges = result.ranges.map((range) => ({
@@ -407,8 +413,29 @@ export const c64Memmap = defineTool({
407
413
  write: (range.flags & WRITE) !== 0,
408
414
  in: sources(range.flags),
409
415
  }));
410
- return result.next === undefined ? { ranges } : { ranges, truncated: true, next: formatC64Address(result.next) };
416
+ const flaw = basicRomFlaw(result.ranges.some((range) => range.start <= 0xbfff && range.end >= 0xa000 && (range.flags & ROM_ACCESS) !== 0));
417
+ return result.next === undefined ? { ranges, ...flaw } : { ranges, truncated: true, next: formatC64Address(result.next), ...flaw };
411
418
  },
412
419
  });
413
420
 
421
+ const ROM_ACCESS = COVERAGE.romRead | COVERAGE.romExecute;
422
+
423
+ /** True when the map from `start` has a ROM access at $A000-$BFFF. */
424
+ function romInBasicArea(map: Uint8Array, start: number): boolean {
425
+ for (let address = Math.max(start, 0xa000); address <= Math.min(start + map.length - 1, 0xbfff); address++) {
426
+ if ((map[address - start]! & ROM_ACCESS) !== 0) return true;
427
+ }
428
+ return false;
429
+ }
430
+
431
+ /** VICE tests only bit 0 of $01 for $A000-$BFFF; the C64 shows BASIC ROM there only when bits 0 and 1 are set. */
432
+ function basicRomFlaw(present: boolean): { emulatorFlaw?: string } {
433
+ if (!present) return {};
434
+ return {
435
+ emulatorFlaw:
436
+ "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. " +
437
+ "Read $01 of the program at that time before you take rom at $A000-$BFFF as the BASIC ROM.",
438
+ };
439
+ }
440
+
414
441
  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
  });
@@ -44,6 +44,10 @@ export const c64Autostart = defineTool({
44
44
  }),
45
45
  readOnly: false,
46
46
  async run(input, session) {
47
+ const type = extname(input.path).slice(1).toLowerCase();
48
+ if (input.index !== undefined && !(DISK_TYPES as readonly string[]).includes(type)) {
49
+ throw new WireFailure("invalid-input", `index selects an entry of a disk image (${list(DISK_TYPES)}). A .${type} file has no directory: leave out index.`);
50
+ }
47
51
  // VICE numbers the directory entries from 1, and its 0 loads "*".
48
52
  return session.autostart({ path: input.path, index: input.index === undefined ? 0 : input.index + 1, run: input.run, root: projectFolder(input.project) });
49
53
  },
@@ -131,7 +135,7 @@ export const c64Snapshot = defineTool({
131
135
  }
132
136
  if (action === "restore" && fields.file !== undefined) {
133
137
  requireFields(action, fields, ["file", "project"], ["file"]);
134
- return session.snapshotRestore(readProjectFile(fields.file, { root: projectFolder(fields.project) }).bytes);
138
+ return session.snapshotRestore(readProjectFile(fields.file, { root: projectFolder(fields.project), what: "file" }).bytes);
135
139
  }
136
140
  if (action === "save") {
137
141
  requireOut(fields);
@@ -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