@henols/c64-re-tools 2.1.2 → 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 (44) hide show
  1. package/package.json +1 -1
  2. package/skills/c64-disk/scripts/lib/containers.ts +2 -1
  3. package/skills/c64-emulator/SKILL.md +2 -0
  4. package/skills/c64-knowledge/SKILL.md +6 -2
  5. package/skills/c64-knowledge/scripts/knowledge.ts +6 -12
  6. package/skills/c64-knowledge/scripts/lib/listing.ts +8 -1
  7. package/skills/c64-memory-map/SKILL.md +2 -0
  8. package/skills/c64-memory-map/scripts/memory-map.ts +22 -3
  9. package/skills/c64-provenance/SKILL.md +4 -3
  10. package/skills/c64-provenance/scripts/lib/library.ts +28 -5
  11. package/skills/c64-provenance/scripts/lib/separate.ts +34 -6
  12. package/skills/c64-provenance/scripts/provenance.ts +13 -4
  13. package/skills/c64-source/SKILL.md +1 -1
  14. package/skills/c64-source/scripts/lib/source.ts +18 -4
  15. package/skills/c64-source/scripts/source.ts +9 -13
  16. package/skills/c64-static-analysis/SKILL.md +4 -3
  17. package/skills/c64-static-analysis/scripts/lib/findings.ts +23 -8
  18. package/skills/c64-static-analysis/scripts/lib/pointers.ts +38 -25
  19. package/skills/c64-static-analysis/scripts/static-analysis.ts +17 -14
  20. package/skills/c64-testing/SKILL.md +6 -3
  21. package/skills/c64-testing/references/scenario-format.md +5 -3
  22. package/skills/c64-testing/scripts/lib/diff-files.ts +27 -8
  23. package/skills/c64-testing/scripts/lib/runs.ts +23 -7
  24. package/skills/c64-testing/scripts/lib/scenario.ts +130 -81
  25. package/skills/c64-testing/scripts/testing.ts +5 -2
  26. package/skills/c64-unpacker/SKILL.md +2 -2
  27. package/skills/c64-unpacker/scripts/lib/loads.ts +4 -1
  28. package/skills/c64-unpacker/scripts/lib/machine.ts +31 -0
  29. package/skills/c64-unpacker/scripts/unpacker.ts +4 -1
  30. package/src/host/vice/session.ts +98 -11
  31. package/src/host-client/image.ts +22 -8
  32. package/src/host-client/steps.ts +2 -2
  33. package/src/host-client/tools.ts +4 -3
  34. package/src/host-client/transfer.ts +8 -3
  35. package/src/knowledge/write.ts +64 -28
  36. package/src/mcp/output.ts +1 -1
  37. package/src/mcp/server.ts +7 -0
  38. package/src/mcp/tools/debug.ts +2 -1
  39. package/src/mcp/tools/media.ts +5 -1
  40. package/src/media.ts +15 -0
  41. package/src/opcodes.ts +31 -1
  42. package/src/platform.ts +14 -1
  43. package/src/project.ts +8 -1
  44. package/src/script.ts +63 -2
@@ -62,7 +62,7 @@ import {
62
62
  type SuppressionRow,
63
63
  type SymbolRow,
64
64
  } from "./read.ts";
65
- 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";
66
66
  import { newRevisionId, rowId, sqlName } from "./text.ts";
67
67
 
68
68
  /** What a change rests on: a coverage run, a test or another project file with its SHA-256, or a note. */
@@ -353,6 +353,27 @@ const result = <T, P>(written: { revision: string | null; current: Step<T, P> })
353
353
  // Each write has a check, which refuses bad input before anything opens, and a
354
354
  // step, which runs inside a change. A single write and a batch share both.
355
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
+
356
377
  /** Every request names its space; without one it is the C64. */
357
378
  type InSpace = { space?: Space };
358
379
  type SymbolRequest = InSpace & { address: number; name: string; kind?: SymbolKind };
@@ -381,7 +402,7 @@ function renameStep(db: DatabaseSync, change: Change, origin: Origin, request: S
381
402
  const space = spaceOf(request);
382
403
  const existing = symbolAt(db, request.address, space);
383
404
  const kind = request.kind ?? existing?.kind ?? "label";
384
- 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 };
385
406
  const owner = symbolNamed(db, request.name, space);
386
407
  if (owner !== undefined && owner.address !== request.address) {
387
408
  throw new Refusal("conflict", `The name ${request.name} already belongs to ${formatC64Address(owner.address)}. Rename or remove that symbol first.`);
@@ -459,7 +480,7 @@ function classifyStep(db: DatabaseSync, change: Change, origin: Origin, request:
459
480
  const overlapping = regionsOverlapping(db, request.start, request.end, space);
460
481
  const [only] = overlapping;
461
482
  const same = overlapping.length === 1 && only!.start === request.start && only!.end === request.end && only!.type === request.type && only!.element === request.element;
462
- if (same) return { current: overlapping };
483
+ if (same && !confirms(only!.origin, origin)) return { current: overlapping };
463
484
  clearRange(db, change, request.start, request.end, space);
464
485
  change.insertRegion({ space, start: request.start, end: request.end, type: request.type, ...(request.element === undefined ? {} : { element: request.element }), origin });
465
486
  liftRegionSuppressions(db, change, request.start, request.end, space);
@@ -490,14 +511,17 @@ function checkComment(request: CommentRequest): string {
490
511
  return text;
491
512
  }
492
513
 
493
- 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> {
494
515
  const space = spaceOf(request);
495
516
  const text = request.text.trim();
496
517
  const existing = commentsAt(db, request.address, space).find((comment) => comment.placement === request.placement);
497
- 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");
498
521
  if (existing !== undefined) change.close("comments", "space = ? AND address = ? AND placement = ?", space, request.address, request.placement);
499
522
  change.insertComment({ space, address: request.address, placement: request.placement, text, origin });
500
- 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 };
501
525
  }
502
526
 
503
527
  function checkPlacement(request: PlacementRequest): void {
@@ -523,14 +547,16 @@ function checkReference(request: ReferenceRequest): void {
523
547
  if (request.kind === POINTER_KIND) throw new Refusal("invalid-input", "Only an operand expression writes a pointer reference: use operand and unoperand.");
524
548
  }
525
549
 
526
- 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> {
527
551
  const space = spaceOf(request);
528
552
  const find = () => referencesFrom(db, request.from, space).find((reference) => reference.to === request.to && reference.kind === request.kind);
529
553
  const existing = find();
530
- 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);
531
557
  change.insertReference({ space, from: request.from, to: request.to, kind: request.kind, origin });
532
558
  change.close("suppressions", "space = ? AND entity = 'reference' AND start_address = ? AND to_address = ? AND kind = ?", space, request.from, request.to, request.kind);
533
- return { current: find()! };
559
+ return existing !== undefined ? { current: find()!, previous: existing } : { current: find()! };
534
560
  }
535
561
 
536
562
  function unreferenceStep(db: DatabaseSync, change: Change, origin: Origin, request: ReferenceRequest): Step<null> {
@@ -581,26 +607,32 @@ const shown = (value: number, size: number) => (value < 0 ? String(value) : `$${
581
607
  /**
582
608
  * Refuses an address that is not the start of an instruction. The code
583
609
  * regions of the knowledge give the instructions: they are decoded from the
584
- * 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.
585
614
  */
586
- 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 {
587
616
  const where = formatC64Address(address);
588
617
  const code = regionsOverlapping(db, address, address, space).find((region) => CODE_REGION_TYPES.includes(region.type));
589
618
  if (code === undefined) {
619
+ if (write !== "operand") return;
590
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.`);
591
621
  }
592
622
  let start = code.start;
593
623
  let next = code.start;
594
624
  while (next < address) {
595
- const opcode = image.byteAt(next);
625
+ const opcode = byteAt(next);
596
626
  if (opcode === undefined) {
627
+ if (write !== "operand") return;
597
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.`);
598
629
  }
599
630
  start = next;
600
631
  next += instructionLength(opcode);
601
632
  }
602
633
  if (next !== address) {
603
- 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)}.`);
604
636
  }
605
637
  }
606
638
 
@@ -613,7 +645,7 @@ function checkOperandValue(db: DatabaseSync, space: Space, image: OperandImage,
613
645
  const where = formatC64Address(request.address);
614
646
  const opcode = image.byteAt(request.address);
615
647
  if (opcode === undefined) throw new Refusal("invalid-input", `${where} is outside the image.`);
616
- checkInstructionStart(db, space, image, request.address);
648
+ checkInstructionStart(db, space, (at) => image.byteAt(at), request.address, "operand");
617
649
  const length = instructionLength(opcode);
618
650
  if (length === 1) throw new Refusal("invalid-input", `The instruction at ${where} (opcode ${shown(opcode, 0x100)}) has no operand.`);
619
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.`);
@@ -646,10 +678,10 @@ function operandStep(db: DatabaseSync, change: Change, context: ChangeContext &
646
678
  if (request.expression !== undefined) wanted.expression = request.expression.trim();
647
679
  const targets = new Set(checkOperandValue(db, space, context.image, request, operandText(wanted)));
648
680
  const existing = operandAt(db, request.address, space);
649
- 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)) {
650
683
  return { current: existing };
651
684
  }
652
- const origin = context.origin as OperandRow["origin"];
653
685
  if (existing !== undefined) change.close("operands", "space = ? AND address = ?", space, request.address);
654
686
  change.insertOperand({ space, address: request.address, ...wanted, origin });
655
687
  const pointers = referencesFrom(db, request.address, space).filter((reference) => reference.kind === POINTER_KIND);
@@ -683,19 +715,22 @@ function checkModule(request: ModuleRequest): void {
683
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.`);
684
716
  }
685
717
 
686
- 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> {
687
719
  const space = spaceOf(request);
688
720
  const existing = moduleAt(db, request.start, space);
689
721
  // A module write without a title keeps the title it has.
690
722
  const title = request.title === undefined ? existing?.title : request.title.trim();
691
- 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");
692
726
  const owner = moduleWithFile(db, request.file, space);
693
727
  if (owner !== undefined && owner.start !== request.start) {
694
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.`);
695
729
  }
696
730
  if (existing !== undefined) change.close("modules", "space = ? AND start_address = ?", space, request.start);
697
731
  change.insertModule({ space, start: request.start, file: request.file, ...(title === undefined ? {} : { title }), origin: origin as ModuleRow["origin"] });
698
- 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 };
699
734
  }
700
735
 
701
736
  function unmoduleStep(db: DatabaseSync, change: Change, _origin: Origin, request: InSpace & { start: number }): Step<null> {
@@ -760,9 +795,10 @@ function patchStep(db: DatabaseSync, change: Change, context: ChangeContext & {
760
795
  const symbol = symbolNamed(db, request.switch, space);
761
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.`);
762
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.`);
763
- 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);
764
800
  if (other !== undefined) {
765
- 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).`);
766
802
  }
767
803
  const [use] = macroUsesOverlapping(db, request.start, request.end, space);
768
804
  if (use !== undefined) {
@@ -772,8 +808,8 @@ function patchStep(db: DatabaseSync, change: Change, context: ChangeContext & {
772
808
  const code = request.code.trimEnd();
773
809
  // A patch write without a comment keeps the comment it has, as a module keeps its title.
774
810
  const comment = request.comment === undefined ? existing?.comment : request.comment.trim();
775
- if (existing !== undefined && existing.end === request.end && existing.switch === request.switch && existing.code === code && existing.comment === comment) return { current: existing };
776
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 };
777
813
  if (existing !== undefined) change.close("patches", "space = ? AND start_address = ?", space, request.start);
778
814
  change.insertPatch({ space, start: request.start, end: request.end, switch: request.switch, code, ...(comment === undefined ? {} : { comment }), origin });
779
815
  const current = patchesOverlapping(db, request.start, request.start, space).find((row) => row.start === request.start)!;
@@ -815,7 +851,7 @@ function macroStep(db: DatabaseSync, change: Change, origin: Origin, request: Ma
815
851
  const params = request.params ?? [];
816
852
  const body = request.body.trimEnd();
817
853
  const comment = request.comment === undefined ? existing?.comment : request.comment.trim();
818
- 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 };
819
855
  if (existing !== undefined && existing.params.length !== params.length) {
820
856
  const uses = listMacroUses(db, undefined, request.name, undefined, space);
821
857
  if (uses.length > 0) {
@@ -901,8 +937,8 @@ function useStep(db: DatabaseSync, change: Change, context: ChangeContext & { im
901
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).`);
902
938
  }
903
939
  const existing = macroUsesOverlapping(db, request.start, request.start, space).find((row) => row.start === request.start);
904
- if (existing !== undefined && existing.end === request.end && existing.macro === request.macro && listText(existing.args) === listText(args)) return { current: existing };
905
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 };
906
942
  if (existing !== undefined) change.close("macro_uses", "space = ? AND start_address = ?", space, request.start);
907
943
  change.insertMacroUse({ space, start: request.start, end: request.end, macro: request.macro, args, origin });
908
944
  const current = macroUsesOverlapping(db, request.start, request.start, space).find((row) => row.start === request.start)!;
@@ -940,7 +976,7 @@ export function unclassifyRegion(db: DatabaseSync, context: WriteContext, reques
940
976
  return result(write(db, context, "unclassify-region", (change) => unclassifyStep(db, change, context.origin, request)));
941
977
  }
942
978
 
943
- export function setComment(db: DatabaseSync, context: WriteContext, request: CommentRequest): WriteResult<CommentRow> {
979
+ export function setComment(db: DatabaseSync, context: WriteContext, request: CommentRequest): WriteResult<CommentRow, CommentRow> {
944
980
  checkComment(request);
945
981
  return result(write(db, context, "set-comment", (change) => commentStep(db, change, context.origin, request)));
946
982
  }
@@ -950,7 +986,7 @@ export function removeComment(db: DatabaseSync, context: WriteContext, request:
950
986
  return result(write(db, context, "remove-comment", (change) => uncommentStep(db, change, context.origin, request)));
951
987
  }
952
988
 
953
- export function addReference(db: DatabaseSync, context: WriteContext, request: ReferenceRequest): WriteResult<ReferenceRow> {
989
+ export function addReference(db: DatabaseSync, context: WriteContext, request: ReferenceRequest): WriteResult<ReferenceRow, ReferenceRow> {
954
990
  checkReference(request);
955
991
  return result(write(db, context, "add-reference", (change) => referenceStep(db, change, context.origin, request)));
956
992
  }
@@ -961,7 +997,7 @@ export function removeReference(db: DatabaseSync, context: WriteContext, request
961
997
  }
962
998
 
963
999
  /** Starts a file of the generated source at an address; one module per address and per file. */
964
- export function setModule(db: DatabaseSync, context: WriteContext, request: ModuleRequest): WriteResult<ModuleRow> {
1000
+ export function setModule(db: DatabaseSync, context: WriteContext, request: ModuleRequest): WriteResult<ModuleRow, ModuleRow> {
965
1001
  checkModule(request);
966
1002
  return result(write(db, context, "set-module", (change) => moduleStep(db, change, context.origin, request)));
967
1003
  }
@@ -1053,7 +1089,7 @@ export const MAX_BATCH = 10_000;
1053
1089
  /** The result of each write of a batch, in order. */
1054
1090
  export type BatchStep = Step<
1055
1091
  SymbolRow | RegionRow[] | CommentRow | ReferenceRow | ModuleRow | OperandRow | PatchRow | MacroRow | MacroUseRow | null,
1056
- SymbolRow | RegionRow[] | OperandRow | PatchRow | MacroRow | MacroUseRow
1092
+ SymbolRow | RegionRow[] | CommentRow | ReferenceRow | ModuleRow | OperandRow | PatchRow | MacroRow | MacroUseRow
1057
1093
  >;
1058
1094
 
1059
1095
  /**
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,7 +180,10 @@ 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
189
  const names = input === undefined ? [] : Object.keys(input.shape);
@@ -229,6 +233,9 @@ async function runCall(
229
233
  const parsed = tool.inputSchema.safeParse(args ?? {});
230
234
  if (!parsed.success) return errorResult({ code: "invalid-input", message: describeIssues(parsed.error, tool.inputSchema) });
231
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);
232
239
  let output: unknown;
233
240
  try {
234
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. " +
@@ -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);
package/src/media.ts CHANGED
@@ -4,6 +4,9 @@
4
4
 
5
5
  import { Refusal } from "./errors.ts";
6
6
 
7
+ /** The signature at the start of every VICE snapshot file (.vsf). */
8
+ export const VSF_SIGNATURE = "VICE Snapshot File\x1a";
9
+
7
10
  /** The signature at the start of each media type that has one. */
8
11
  const SIGNATURES: Readonly<Record<string, readonly string[]>> = {
9
12
  crt: ["C64 CARTRIDGE "],
@@ -11,6 +14,7 @@ const SIGNATURES: Readonly<Record<string, readonly string[]>> = {
11
14
  tap: ["C64-TAPE-RAW"],
12
15
  g64: ["GCR-1541"],
13
16
  p00: ["C64File"],
17
+ vsf: [VSF_SIGNATURE],
14
18
  };
15
19
 
16
20
  /** True when `bytes` start with a signature of `type`, or `type` has none. */
@@ -30,11 +34,22 @@ const SIZES: Readonly<Record<string, readonly number[]>> = {
30
34
  /** The x64 header: 64 bytes that start with these four, then a d64 image. */
31
35
  const X64_MAGIC = Buffer.from([0x43, 0x15, 0x41, 0x64]);
32
36
 
37
+ /** The refusal of a file that is no VICE snapshot file (.vsf): its signature is missing. `path` names the file. */
38
+ export function checkSnapshotFile(path: string, bytes: Uint8Array): void {
39
+ if (!hasSignature("vsf", bytes)) throw new Refusal("invalid-input", `${path} is not a VICE snapshot file (.vsf): it does not start with "VICE Snapshot File". Give a file that c64_snapshot save or a save step of a run wrote.`);
40
+ }
41
+
33
42
  /** Refuses a file whose bytes are not of `type` (its extension), with media-error. */
34
43
  export function checkMedia(type: string, bytes: Buffer): void {
35
44
  const refuse = (why: string) => {
36
45
  throw new Refusal("media-error", `The file is not a valid .${type} file: ${why}. Give a file of that type.`);
37
46
  };
47
+ if (type === "crt" && hasSignature(type, bytes)) {
48
+ // The header gives its own length; a CHIP packet with the ROM data follows it.
49
+ const header = bytes.length >= 0x40 ? bytes.readUInt32BE(0x10) : 0;
50
+ if (bytes.length < 0x40) refuse("the header is shorter than 64 bytes");
51
+ if (bytes.toString("latin1", header, header + 4) !== "CHIP") refuse("it has no CHIP packet with ROM data after the header");
52
+ }
38
53
  if (!hasSignature(type, bytes)) refuse(`it does not start with ${SIGNATURES[type]!.map((signature) => `"${signature.trimEnd()}"`).join(" or ")}`);
39
54
  const sizes = SIZES[type];
40
55
  if (sizes !== undefined && !sizes.includes(bytes.length)) refuse(`it has ${bytes.length} bytes, and a .${type} image has ${sizes.join(", ")}`);
package/src/opcodes.ts CHANGED
@@ -76,6 +76,34 @@ export type UseKind = (typeof USE_KINDS)[number];
76
76
  const WRITES = new Set(["sta", "stx", "sty", "sax", "sha", "shx", "shy", "tas"]);
77
77
  const MODIFIES = new Set(["inc", "dec", "asl", "lsr", "rol", "ror", "slo", "rla", "sre", "rra", "dcp", "isc"]);
78
78
 
79
+ /**
80
+ * The byte that an instruction wrote to or read from its operand address:
81
+ * a store gives its register, a load the register after it. For another
82
+ * access the byte at the address now (`memoryNow`) is the byte, when the
83
+ * caller can read it back. Undefined when it is not known.
84
+ */
85
+ export function accessedValue(opcode: number, before: { a: number; x: number; y: number }, after: { a: number; x: number; y: number }, memoryNow: number | undefined): number | undefined {
86
+ switch (OPCODES[opcode]!.mnemonic) {
87
+ case "sta":
88
+ return before.a;
89
+ case "stx":
90
+ return before.x;
91
+ case "sty":
92
+ return before.y;
93
+ case "sax":
94
+ return before.a & before.x;
95
+ case "lda":
96
+ case "lax":
97
+ return after.a;
98
+ case "ldx":
99
+ return after.x;
100
+ case "ldy":
101
+ return after.y;
102
+ default:
103
+ return memoryNow;
104
+ }
105
+ }
106
+
79
107
  /** The use of the operand address of an instruction that takes an address. Through (zp),y and (zp,x) the address is a pointer. */
80
108
  export function useKind(opcode: number): UseKind {
81
109
  const { mnemonic, mode } = OPCODES[opcode]!;
@@ -130,7 +158,9 @@ export function operandNumber(bytes: readonly number[], pc: number): string {
130
158
  /** The text of the instruction in `bytes` at `pc` as the monitor shows it, for example "STA $d011" or "BNE $c002". */
131
159
  export function instructionText(bytes: readonly number[], pc: number): string {
132
160
  const { mnemonic } = OPCODES[bytes[0]!]!;
133
- return `${mnemonic.toUpperCase()}${instructionWith(bytes[0]!, operandNumber(bytes, pc)).slice(mnemonic.length)}`;
161
+ const operand = instructionWith(bytes[0]!, operandNumber(bytes, pc)).slice(mnemonic.length);
162
+ // The monitor writes the index register in capitals: LDA ($c1),Y.
163
+ return `${mnemonic.toUpperCase()}${operand.replace(/,([xy])\b/g, (_, index: string) => `,${index.toUpperCase()}`)}`;
134
164
  }
135
165
 
136
166
  /**
package/src/platform.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  // Names follow "Mapping the Commodore 64". Only platform facts live here:
4
4
  // an address without one belongs to the application.
5
5
 
6
- import { d018Offsets, formatC64Address, vicBankBase } from "./c64.ts";
6
+ import { COVERAGE, d018Offsets, formatC64Address, vicBankBase } from "./c64.ts";
7
7
  import { Refusal } from "./errors.ts";
8
8
 
9
9
  export interface Entry {
@@ -420,6 +420,19 @@ export function romUseOfPort(port: number): RomUse {
420
420
  return { basic: (port & 0x03) === 0x03, kernal: (port & 0x02) !== 0 };
421
421
  }
422
422
 
423
+ /**
424
+ * The ROMs that a program uses, and what tells it: the coverage when the
425
+ * knowledge has it, else $01 of the entry state, else both ROMs. c64-source and
426
+ * c64-static-analysis give the platform names of platformNamesFor of it, so
427
+ * they use the same names.
428
+ */
429
+ export function romUseOfProgram(coverage: Uint8Array | undefined, state: Record<string, unknown> | undefined): RomUse & { from: "coverage" | "entry state" | "none" } {
430
+ if (coverage !== undefined) return { ...romUseOf(coverage, COVERAGE.romExecute), from: "coverage" };
431
+ const port = (state?.cpuPort as Record<string, unknown> | undefined)?.$01;
432
+ if (typeof port === "number") return { ...romUseOfPort(port), from: "entry state" };
433
+ return { basic: true, kernal: true, from: "none" };
434
+ }
435
+
423
436
  export type Decoded = Record<string, unknown>;
424
437
 
425
438
  const bits = (value: number) => [0, 1, 2, 3, 4, 5, 6, 7].filter((bit) => (value & (1 << bit)) !== 0);
package/src/project.ts CHANGED
@@ -37,8 +37,13 @@ let chosen: string | undefined;
37
37
  /** The project that the project lookup found from a working directory, kept for the next call from the same directory. */
38
38
  let found: { cwd: string; root: string } | undefined;
39
39
 
40
+ /** True when `folder` holds .c64-re-tools/. A folder that cannot be read (no permission) is not a project. */
40
41
  function isProject(folder: string): boolean {
41
- return statSync(join(folder, PROJECT_DIRECTORY), { throwIfNoEntry: false })?.isDirectory() === true;
42
+ try {
43
+ return statSync(join(folder, PROJECT_DIRECTORY), { throwIfNoEntry: false })?.isDirectory() === true;
44
+ } catch {
45
+ return false;
46
+ }
42
47
  }
43
48
 
44
49
  /** `start` and each folder above it, nearest first, below the home folder when `start` is under it. */
@@ -277,6 +282,8 @@ export function checkOutputPath(path: string, kind: OutputKind, options: { root?
277
282
  const stat = statSync(real);
278
283
  if (existing === target && kind === "file") {
279
284
  if (stat.isDirectory()) throw problem({ code: "EISDIR" });
285
+ } else if (existing === target && !stat.isDirectory()) {
286
+ throw new Refusal("invalid-input", `${what === undefined ? "" : `${what}: `}${path} is a file, but ${what ?? "the path"} needs a folder. Give the path of a folder, or a new path for the folder.`);
280
287
  } else if (!stat.isDirectory()) {
281
288
  throw problem({ code: "ENOTDIR" });
282
289
  }
package/src/script.ts CHANGED
@@ -428,6 +428,65 @@ function refuseSplitRange(command: CommandSpec, options: Readonly<Record<string,
428
428
  }
429
429
  }
430
430
 
431
+ /** The options of a command for a message: --a, --b and --c, or a word that it has none. */
432
+ function optionList(options: Readonly<Record<string, OptionSpec>>): string {
433
+ const names = Object.keys(options).map((name) => `--${name}`);
434
+ if (names.length === 0) return "It takes no options";
435
+ return `Its options are ${names.length === 1 ? names[0] : `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`}`;
436
+ }
437
+
438
+ /** True for a word that the command line reads as an option: - and one or more characters. A lone - is a subject (standard input). */
439
+ const isOptionWord = (word: string) => word.length > 1 && word.startsWith("-");
440
+
441
+ /**
442
+ * Refuses the words of a command line that parseArgs refuses, with a message
443
+ * that names the option and tells what to give: an option that the command
444
+ * does not have, a flag with a value, an option without its value, a value
445
+ * that starts with -, and a range option in two parts.
446
+ */
447
+ function refuseOptionWords(command: CommandSpec, options: Readonly<Record<string, OptionSpec>>, argv: readonly string[]): void {
448
+ const end = argv.indexOf("--");
449
+ const words = end < 0 ? argv : argv.slice(0, end);
450
+ for (let index = 0; index < words.length; index++) {
451
+ const word = words[index]!;
452
+ if (!isOptionWord(word)) continue;
453
+ if (!word.startsWith("--")) {
454
+ throw new UsageError(`${word} is not an option of ${command.name}: an option starts with --. ${optionList(options)}. Give a subject that starts with - after --, for example -- ${word}`);
455
+ }
456
+ const equals = word.indexOf("=");
457
+ const name = equals < 0 ? word.slice(2) : word.slice(2, equals);
458
+ const spec = options[name];
459
+ if (spec === undefined) throw new UsageError(`${command.name} has no option --${name}. ${optionList(options)}`);
460
+ if (spec.kind === "flag") {
461
+ if (equals >= 0) throw new UsageError(`--${name} is a flag: give --${name} without a value`);
462
+ continue;
463
+ }
464
+ let value: string;
465
+ let valueIndex = index;
466
+ if (equals >= 0) {
467
+ value = word.slice(equals + 1);
468
+ } else {
469
+ valueIndex = index + 1;
470
+ const next = words[valueIndex];
471
+ if (next === undefined) throw new UsageError(`--${name} needs a value: give --${name} ${spec.value ?? "<value>"}`);
472
+ if (isOptionWord(next)) {
473
+ if ((spec.kind === "count" || spec.kind === "number") && /^-\d/.test(next)) {
474
+ const range = spec.kind === "count" ? `a whole number from ${spec.min ?? 0} to ${spec.max ?? Number.MAX_SAFE_INTEGER}` : `a number from ${spec.min ?? 0} to ${spec.max ?? 0xffff}`;
475
+ throw new UsageError(`--${name} must be ${range}, not ${JSON.stringify(next)}`);
476
+ }
477
+ throw new UsageError(`--${name} needs a value: give --${name} ${spec.value ?? "<value>"}, not ${next}. Give a value that starts with - as --${name}=${next}`);
478
+ }
479
+ value = next;
480
+ index++;
481
+ }
482
+ // A range option in two parts: --range '$1bc2' '$1c00'.
483
+ const second = words[valueIndex + 1];
484
+ if (spec.kind === "range" && rangeParts(value) === undefined && isAddressText(value) && second !== undefined && c64AddressOf(second) !== undefined) {
485
+ throw new UsageError(`--${name} is one token <start>-<end>: give --${name} '${value}-${second}', not '${value}' '${second}'`);
486
+ }
487
+ }
488
+ }
489
+
431
490
  /**
432
491
  * Refuses an output path before the command does any work: a path that
433
492
  * leaves the project, a path in the knowledge, a folder where a file must be,
@@ -442,6 +501,7 @@ function checkOutput(text: string, what: string, kind: OutputKind): void {
442
501
  export function parseCall(script: ScriptSpec, command: CommandSpec, argv: readonly string[]): Call {
443
502
  const options = commandOptions(command);
444
503
  refuseSplitRange(command, options, argv);
504
+ refuseOptionWords(command, options, argv);
445
505
  let parsed: ReturnType<typeof parseArgs>;
446
506
  try {
447
507
  parsed = parseArgs({
@@ -450,8 +510,9 @@ export function parseCall(script: ScriptSpec, command: CommandSpec, argv: readon
450
510
  allowPositionals: true,
451
511
  options: Object.fromEntries(Object.entries(options).map(([name, spec]) => [name, { type: spec.kind === "flag" ? "boolean" : "string", ...(spec.multiple ? { multiple: true } : {}) }])),
452
512
  });
453
- } catch (error) {
454
- throw new UsageError((error as Error).message);
513
+ } catch {
514
+ // refuseOptionWords gives a message of its own for each word that parseArgs refuses; this is the last guard.
515
+ throw new UsageError(`the command line of ${command.name} is not correct. Give each option as --name <value>, each flag as --name, and a subject that starts with - after --`);
455
516
  }
456
517
  const values = parsed.values as Record<string, string | boolean | string[] | undefined>;
457
518
  const subjects = parsed.positionals;