@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
@@ -13,7 +13,7 @@ import { setTimeout as sleep } from "node:timers/promises";
13
13
 
14
14
  import { formatC64Address, instructionLength } from "../../c64.ts";
15
15
  import { encodePng, type IndexedFrame } from "../../graphics.ts";
16
- import { instructionText, operandAddress } from "../../opcodes.ts";
16
+ import { accessedValue, instructionText, operandAddress } from "../../opcodes.ts";
17
17
  import type { ProcessSupervisor } from "../../native/processes.ts";
18
18
  import { trace } from "../../trace.ts";
19
19
  import type { ViceSessionFactory, ViceSessionHandle } from "../server.ts";
@@ -31,7 +31,7 @@ import { decodeProgramCounter, MonitorConnectionError, MonitorError, MonitorTime
31
31
  import { decodeCia, decodeSid, decodeSprite, decodeVicii } from "./chips.ts";
32
32
  import { compareFrames, differenceImage } from "./screen.ts";
33
33
  import { isRefusal } from "../../errors.ts";
34
- import { checkMedia } from "../../media.ts";
34
+ import { checkMedia, hasSignature } from "../../media.ts";
35
35
  import { launchVice, startErrorLines, type ViceMode, type ViceProcess } from "./process.ts";
36
36
  import { TextMonitorError, TextMonitorTimeoutError } from "./text-monitor.ts";
37
37
 
@@ -80,7 +80,6 @@ const WINDOW_CLOSED =
80
80
 
81
81
  /** Emulator state that a move to another VICE cannot carry. */
82
82
  /** The first bytes of every VICE snapshot file. */
83
- const VSF_MAGIC = Buffer.from("VICE Snapshot File\x1a", "latin1");
84
83
  const NOT_CARRIED = ["cpu history", "memory map", "profile", "keyboard input not yet typed"];
85
84
 
86
85
  /** Starts another VICE for this session; `log` also gets that launch's lines. */
@@ -202,6 +201,21 @@ export class ViceSession implements ViceSessionHandle {
202
201
  #autostarting = false;
203
202
  /** The C64 clock just after the last snapshot restore: VICE keeps the history of before it. */
204
203
  #historySince: bigint | undefined;
204
+ /**
205
+ * A breakpoint or watchpoint stopped a step, next or until-return early. VICE
206
+ * keeps the rest of that step and stops the machine once more when it ends.
207
+ * No monitor command ends it without running the CPU, so the session takes
208
+ * that one stop as the end of the step and lets the machine run on.
209
+ */
210
+ #pendingStep = false;
211
+ /**
212
+ * The CPU stands at the stop of a reset without run, and nothing ran since. VICE
213
+ * (3.9, 3.10) stops answering at the restore of a snapshot saved here while the
214
+ * disk drive was busy, so a save here is refused.
215
+ */
216
+ #atResetStop = false;
217
+ /** The CPU that the last run stopped in, when it was the drive: status then gives the drive's pc. */
218
+ #stopSpace: "c64" | "drive8" = "c64";
205
219
  /** VICE records no CPU history after a JAM until the next reset. */
206
220
  #historyStopped = false;
207
221
  /** The CPU ran again after a JAM without a reset: the CPU history that VICE gives is old. */
@@ -558,7 +572,7 @@ export class ViceSession implements ViceSessionHandle {
558
572
  if (stop !== undefined) Object.assign(status, stop);
559
573
  // A point in the drive stopped it: the pc is the drive's, as advance-frames and run-until give it.
560
574
  const point = status.pointId === undefined ? undefined : (status.stopReason === "watchpoint" ? this.#watchpoints : this.#breakpoints).get(status.pointId);
561
- status.space = point?.space === "drive8" ? "drive8" : "c64";
575
+ status.space = point?.space === "drive8" || this.#stopSpace === "drive8" ? "drive8" : "c64";
562
576
  if (status.space === "drive8") status.pc = (await this.#machine.readRegisters("drive8")).pc;
563
577
  }
564
578
  return status;
@@ -572,6 +586,7 @@ export class ViceSession implements ViceSessionHandle {
572
586
  if (this.#state === "stopped") {
573
587
  await this.#refuseJammed("resume");
574
588
  await this.#resume();
589
+ if (this.#pendingStep) await this.#endPendingStep();
575
590
  }
576
591
  return { state: "running" };
577
592
  }
@@ -600,6 +615,7 @@ export class ViceSession implements ViceSessionHandle {
600
615
  throw new WireFailure("invalid-input", `The anchor is outside the frame: a ${this.#videoStandard} frame has lines 0-${raster.lines - 1} and cycles 0-${raster.cycles - 1}.`);
601
616
  }
602
617
  const outcome = await this.#runFrames(params.count, undefined, params.anchor);
618
+ this.#stopSpace = outcome.space;
603
619
  return {
604
620
  state: "stopped",
605
621
  // A stop event carries the computer's PC; a stop in the drive reports the drive's.
@@ -612,6 +628,8 @@ export class ViceSession implements ViceSessionHandle {
612
628
  }
613
629
  await this.#stop();
614
630
  await this.#refuseJammed(params.action);
631
+ // A new step replaces the rest of an earlier one in VICE.
632
+ this.#pendingStep = false;
615
633
  if (params.action === "until-return") {
616
634
  const stopped = this.#nextStop(this.#untilReturnLimitMs);
617
635
  await this.#resumingCommand(() => this.#machine.untilReturn());
@@ -623,18 +641,39 @@ export class ViceSession implements ViceSessionHandle {
623
641
  `The routine did not return within ${this.#untilReturnLimitMs / 1000} seconds. The machine is stopped at ${formatC64Address(await this.#pc())}.`,
624
642
  );
625
643
  }
626
- return { state: "stopped", pc: stop.pc };
644
+ return { state: "stopped", pc: stop.pc, ...this.#stepStoppedByPoint() };
627
645
  }
628
646
  const count = params.count ?? 1;
647
+ this.#atResetStop = false;
629
648
  try {
630
649
  await this.#machine.step(count, params.action === "next", this.#stepLimitMs);
631
650
  } catch (error) {
632
651
  if (error instanceof MonitorTimeoutError) throw new StepTimeout(stepNotEnded(params.action, count, this.#stepLimitMs), error);
633
652
  throw error;
634
653
  }
635
- // VICE answers a step only after the machine stopped again.
654
+ // VICE answers a step only after the machine stopped again; a ping reads the events of that stop (a hit point).
655
+ await this.#machine.stop();
636
656
  this.#state = "stopped";
637
- return { state: "stopped", pc: await this.#pc(), executed: count };
657
+ const point = this.#stepStoppedByPoint();
658
+ // A point stops the step early: VICE does not tell how many instructions ran.
659
+ return { state: "stopped", pc: await this.#pc(), ...(point.pointId === undefined ? { executed: count } : point) };
660
+ }
661
+
662
+ /** The breakpoint or watchpoint that stopped a step, next or until-return early; VICE then keeps the rest of the step. */
663
+ #stepStoppedByPoint(): { stopReason?: PointStopReason; pointId?: number } {
664
+ const stop = this.#lastStop;
665
+ if (stop?.pointId === undefined) return {};
666
+ this.#pendingStep = true;
667
+ return { stopReason: stop.stopReason, pointId: stop.pointId };
668
+ }
669
+
670
+ /** After a resume: waits for the stop that ends the rest of an earlier step, and runs on. */
671
+ async #endPendingStep(): Promise<void> {
672
+ const stop = await this.#nextStop(HALF_FRAME_LIMIT_MS);
673
+ if (stop === undefined) return;
674
+ this.#pendingStep = false;
675
+ // A user point or a JAM stopped the machine: it stays stopped and status tells why.
676
+ if (stop.hits.length === 0 && this.#lastStop === undefined) await this.#resume();
638
677
  }
639
678
 
640
679
  async #reset(params: { mode: "soft" | "hard"; run: boolean }): Promise<{ state: RunState; pc?: number }> {
@@ -659,6 +698,7 @@ export class ViceSession implements ViceSessionHandle {
659
698
  if (!reached) await this.#stop();
660
699
  await this.#machine.deleteCheckpoint(checkpoint);
661
700
  if (!reached) throw new WireFailure("operation-failed", "The C64 did not reach its reset vector after the reset.");
701
+ this.#atResetStop = true;
662
702
  return { state: "stopped", pc: vector };
663
703
  }
664
704
 
@@ -715,6 +755,11 @@ export class ViceSession implements ViceSessionHandle {
715
755
  }
716
756
  if (end === "unknown") {
717
757
  if (await this.#jamAt(stop.pc)) return { end: "jam", frames, pc: stop.pc, space: "c64" };
758
+ if (this.#pendingStep) {
759
+ // The rest of an earlier step ended: run on.
760
+ this.#pendingStep = false;
761
+ continue;
762
+ }
718
763
  throw new WireFailure("operation-failed", "The emulator stopped the machine for a reason outside this run.");
719
764
  }
720
765
  // A drive stop is completed before the result names the computer's pc; `space` says which CPU stopped.
@@ -783,6 +828,10 @@ export class ViceSession implements ViceSessionHandle {
783
828
  const point = this.#userPoint(stop.hits);
784
829
  if (point !== undefined) return { hits, frames, end: point.stopReason, pc: stop.pc };
785
830
  if (await this.#jamAt(stop.pc)) return { hits, frames, end: "jam", pc: stop.pc };
831
+ if (this.#pendingStep) {
832
+ this.#pendingStep = false;
833
+ continue;
834
+ }
786
835
  throw new WireFailure("operation-failed", "The emulator stopped the machine for a reason outside this trace.");
787
836
  }
788
837
  } finally {
@@ -841,10 +890,22 @@ export class ViceSession implements ViceSessionHandle {
841
890
  frame,
842
891
  rasterLine: Math.floor(at / cycles),
843
892
  rasterCycle: at % cycles,
844
- ...(address === undefined ? {} : { address, value: await byteAt(address) }),
893
+ ...(address === undefined ? {} : { address, ...(await this.#accessed(bytes[0]!, entry, address, byteAt)) }),
845
894
  };
846
895
  }
847
896
 
897
+ /**
898
+ * The byte of a read or write hit. A read of an I/O register after the
899
+ * access can give another byte than the access did (raster bits, cleared
900
+ * flags), so for $D000-$DFFF only a load or a store gives a value.
901
+ */
902
+ async #accessed(opcode: number, before: { a: number; x: number; y: number }, address: number, byteAt: (address: number) => Promise<number>): Promise<{ value?: number }> {
903
+ const after = await this.#machine.readRegisters("c64");
904
+ const io = address >= 0xd000 && address <= 0xdfff;
905
+ const value = accessedValue(opcode, before, after, io ? undefined : await byteAt(address));
906
+ return value === undefined ? {} : { value };
907
+ }
908
+
848
909
  /**
849
910
  * Completes a stop that a drive 8 checkpoint caused: the computer runs to
850
911
  * its next instruction boundary, where a computer checkpoint stops it, so
@@ -975,6 +1036,7 @@ export class ViceSession implements ViceSessionHandle {
975
1036
  const stopReason = outcome.end === "frames" ? "timeout" : outcome.end;
976
1037
  // A stop event carries the computer's PC; a stop in the drive reports the drive's.
977
1038
  const pc = outcome.space === "drive8" ? (await this.#machine.readRegisters("drive8")).pc : outcome.pc;
1039
+ this.#stopSpace = outcome.space;
978
1040
  return { reached: stopReason === "target", stopReason, state: "stopped", pc, space: outcome.space, ...(outcome.pointId === undefined ? {} : { pointId: outcome.pointId }) };
979
1041
  } finally {
980
1042
  await this.#machine.deleteCheckpoint(checkpoint);
@@ -1030,7 +1092,10 @@ export class ViceSession implements ViceSessionHandle {
1030
1092
 
1031
1093
  #point<T>(registry: Map<number, T>, kind: string, id: number): T {
1032
1094
  const point = registry.get(id);
1033
- if (point === undefined) throw new WireFailure("not-found", `There is no ${kind} with id ${id}.`);
1095
+ if (point === undefined) {
1096
+ const other = kind === "breakpoint" ? (this.#watchpoints.has(id) ? "watchpoint" : undefined) : this.#breakpoints.has(id) ? "breakpoint" : undefined;
1097
+ throw new WireFailure("not-found", `There is no ${kind} with id ${id}.${other === undefined ? "" : ` Id ${id} is a ${other}: use c64_${other}.`}`);
1098
+ }
1034
1099
  return point;
1035
1100
  }
1036
1101
 
@@ -1145,7 +1210,11 @@ export class ViceSession implements ViceSessionHandle {
1145
1210
  }
1146
1211
  return this.#observe(async () => {
1147
1212
  const all = await this.#machine.history(params.limit, params.space);
1148
- if (params.space === "drive8") return { entries: all.map(({ clock: _clock, ...entry }) => entry) };
1213
+ if (params.space === "drive8") {
1214
+ // VICE records a wrong third byte for a 3-byte drive instruction ("JSR $00a6" for 20 a6 d5): the drive memory gives the bytes.
1215
+ const memory = await this.#machine.readAll("drive8");
1216
+ return { entries: all.map(({ clock: _clock, ...entry }) => driveEntry(entry, memory)) };
1217
+ }
1149
1218
  const now = await this.#machine.clock();
1150
1219
  const raw = entriesSince(all, this.#historySince, now);
1151
1220
  const position = await this.#machine.rasterPosition();
@@ -1221,6 +1290,13 @@ export class ViceSession implements ViceSessionHandle {
1221
1290
  if (params.action === "list") return { snapshots: [...this.#snapshots.keys()] };
1222
1291
  const existing = this.#snapshots.get(params.name);
1223
1292
  if (params.action === "save") {
1293
+ if (this.#atResetStop) {
1294
+ throw new WireFailure(
1295
+ "operation-failed",
1296
+ "The CPU stands at the reset stop of c64_reset with run false. The emulator stops answering at the restore of a snapshot saved here when the disk drive was busy. " +
1297
+ "Step one instruction with c64_execution, then save the snapshot.",
1298
+ );
1299
+ }
1224
1300
  if (existing === undefined && this.#snapshots.size >= MAX_SNAPSHOTS) {
1225
1301
  throw new WireFailure("limit-exceeded", `A session keeps at most ${MAX_SNAPSHOTS} snapshots. Remove one first with action remove.`);
1226
1302
  }
@@ -1266,7 +1342,7 @@ export class ViceSession implements ViceSessionHandle {
1266
1342
  }
1267
1343
  const bytes = onlyAttachment(attachments);
1268
1344
  // Checked before the machine stops: VICE gives no reason for a file that is no snapshot.
1269
- if (!bytes.subarray(0, VSF_MAGIC.length).equals(VSF_MAGIC)) throw new WireFailure("invalid-input", "The file is not a VICE snapshot file (.vsf). Give a file that c64_snapshot save wrote.");
1345
+ if (!hasSignature("vsf", bytes)) throw new WireFailure("invalid-input", "The file is not a VICE snapshot file (.vsf). Give a file that c64_snapshot save wrote.");
1270
1346
  const file = this.#stage(bytes, "vsf");
1271
1347
  // VICE resets the machine after a snapshot file that it cannot read: a backup keeps the state before the restore.
1272
1348
  const backup = join(this.#workDir, "before-restore.vsf");
@@ -1452,6 +1528,8 @@ export class ViceSession implements ViceSessionHandle {
1452
1528
  * arrive together, and those events must have the last word.
1453
1529
  */
1454
1530
  async #resumingCommand(send: () => Promise<void>): Promise<void> {
1531
+ this.#atResetStop = false;
1532
+ this.#stopSpace = "c64";
1455
1533
  this.#state = "running";
1456
1534
  this.#lastStop = undefined;
1457
1535
  try {
@@ -1643,6 +1721,15 @@ export class ViceSession implements ViceSessionHandle {
1643
1721
  }
1644
1722
  }
1645
1723
 
1724
+ /** A drive history entry with its bytes and text from drive memory, when the opcode there is still the one that ran. */
1725
+ export function driveEntry<T extends { address: number; bytes: string; text: string }>(entry: T, memory: Uint8Array): T {
1726
+ const opcode = Number.parseInt(entry.bytes.slice(0, 2), 16);
1727
+ if (memory[entry.address] !== opcode) return entry;
1728
+ const length = instructionLength(opcode);
1729
+ const bytes = Array.from({ length }, (_, offset) => memory[(entry.address + offset) & 0xffff]!);
1730
+ return { ...entry, bytes: Buffer.from(bytes).toString("hex"), text: instructionText(bytes, entry.address) };
1731
+ }
1732
+
1646
1733
  /**
1647
1734
  * The history entries (oldest first) that the CPU executed after a restore at
1648
1735
  * clock `since`, with the clock at `now`: the last entries, back to the first
@@ -36,20 +36,34 @@ const MEDIA_KINDS: Record<string, string> = {
36
36
  /** The remedy for a disk, tape or cartridge file where a PRG must be: get the PRG out of it. */
37
37
  export const MEDIA_REMEDY = "Get the PRG out of it with disk.ts read (or disk.ts crt for a cartridge) of the c64-disk skill";
38
38
 
39
- /** Refuses a disk, tape or cartridge file by its extension (`type`, lowercase, without the dot): invalid-input with `remedy`. */
40
- function refuseMedia(path: string, type: string, remedy: string): void {
39
+ /**
40
+ * Refuses a file that is no PRG by its extension (`type`, lowercase, without
41
+ * the dot): invalid-input. A disk, tape or cartridge file gets `remedy`. A PRG
42
+ * ends in .prg in any letter case, or has no extension, as c1541 writes the
43
+ * names of the files that it reads from a disk; every other extension is
44
+ * refused. `accepted` names what the reader takes, for the message.
45
+ */
46
+ export function refuseNotPrg(path: string, type: string, remedy = MEDIA_REMEDY, accepted = "a PRG"): void {
41
47
  const media = MEDIA_KINDS[type];
42
48
  if (media !== undefined) throw new Refusal("invalid-input", `${path} is ${media} (.${type}), not a PRG. ${remedy}.`);
49
+ if (type !== "prg" && type !== "") {
50
+ throw new Refusal("invalid-input", `${path} is not ${accepted}: a PRG has the extension .prg or no extension, not .${type}. Give the PRG of the program, or rename the file to .prg when it is a PRG.`);
51
+ }
43
52
  }
44
53
 
45
54
  /**
46
55
  * Reads a PRG of the project: a 2-byte load address, and bytes that end at
47
- * $ffff or before. Refuses a disk, tape or cartridge file and any other file
48
- * that is no PRG with invalid-input; `remedy` tells what to do in its place.
56
+ * $ffff or before. Refuses with invalid-input a file that is no PRG by its
57
+ * extension (refuseNotPrg) or by its bytes; `remedy` tells what to do in place
58
+ * of a disk, tape or cartridge file. A file of fewer than 3 bytes is refused
59
+ * as too short: no remedy applies.
49
60
  */
50
- export function readPrg(path: string, remedy = MEDIA_REMEDY): { bytes: Buffer; start: number; end: number; body: Uint8Array } {
61
+ export function readPrg(path: string, remedy = MEDIA_REMEDY, accepted = "a PRG"): { bytes: Buffer; start: number; end: number; body: Uint8Array } {
51
62
  const { bytes, type } = readProjectFile(path);
52
- refuseMedia(path, type, remedy);
63
+ refuseNotPrg(path, type, remedy, accepted);
64
+ if (bytes.length < 3) {
65
+ throw new Refusal("invalid-input", `${path} is too short for a PRG: it has ${bytes.length} ${bytes.length === 1 ? "byte" : "bytes"}, and a PRG has a 2-byte load address and at least one byte. Give the complete PRG file.`);
66
+ }
53
67
  try {
54
68
  return { bytes, ...prgRange(bytes) };
55
69
  } catch (error) {
@@ -58,10 +72,10 @@ export function readPrg(path: string, remedy = MEDIA_REMEDY): { bytes: Buffer; s
58
72
  }
59
73
  }
60
74
 
61
- /** Reads an image.json or a PRG, relative to the project folder. Refuses a disk, tape or cartridge file as readPrg does. */
75
+ /** Reads an image.json or a PRG, relative to the project folder. Refuses every other file as readPrg does. */
62
76
  export function readImage(path: string): Image {
63
77
  if (!path.endsWith(".json")) {
64
- const range = readPrg(path);
78
+ const range = readPrg(path, MEDIA_REMEDY, "an image.json of the c64-unpacker skill or a PRG");
65
79
  return { segments: [{ start: range.start, end: range.end }], byteAt: (address) => range.body[address - range.start]! };
66
80
  }
67
81
  const { bytes } = readProjectFile(path);
@@ -228,8 +228,8 @@ function checkTarget(value: unknown, where: string, fail: (message: string) => n
228
228
  return fail(`${where}.kind must be one of address, memory, raster`);
229
229
  }
230
230
 
231
- /** What a step of a script without the replay core gets for press. */
232
- const PRESS_REMEDY = "press pushes keys of the keyboard matrix, and only the record and replay commands of c64-testing have them. Use type for the KERNAL keyboard queue, or open the window and ask the user to press the key";
231
+ /** What a step of a script without the replay core gets for press: the scripts that read these steps run without a window. */
232
+ const PRESS_REMEDY = "press pushes keys of the keyboard matrix, and only the replay core of c64-testing has them. Use type for the KERNAL keyboard queue, or put the press step in a record file and play it with testing.ts record of c64-testing";
233
233
 
234
234
  /** Checks the input of one step kind and returns it with the defaults of its MCP tool. */
235
235
  function checkInput(kind: ActionStepKind, value: unknown, at: string, fail: (message: string) => never): ActionStep {
@@ -6,6 +6,7 @@
6
6
  import { ProtocolError, WireFailure } from "../protocol/messages.ts";
7
7
  import { DISK_IMAGE_TYPES, validateToolResult, type C1541Result, type DiskAction, type DiskImageType, type PetcatResult, type ToolOperation, type ToolOperations, type HostInfo, type ToolStatus } from "../protocol/tools.ts";
8
8
  import { HostConnection } from "./connect.ts";
9
+ import { readPrg } from "./image.ts";
9
10
  import { readProjectFile } from "./transfer.ts";
10
11
 
11
12
  export interface ToolCallOptions {
@@ -72,10 +73,10 @@ export async function inspectDisk(request: InspectDiskRequest, options: ToolCall
72
73
  return result.action === "read" && result.found ? { ...result, data: attachments[0]! } : result;
73
74
  }
74
75
 
75
- /** petcat.decode: the C64 BASIC V2 listing of a project program and its machine-code handoffs. */
76
+ /** petcat.decode: the C64 BASIC V2 listing of a project program and its machine-code handoffs. Refuses a file that is no PRG as readPrg does. */
76
77
  export async function decodeBasic(request: { program: string }, options: ToolCallOptions = {}): Promise<PetcatResult> {
77
- const file = readProjectFile(request.program);
78
- return (await callTool("petcat.decode", {}, [file.bytes], options)).result;
78
+ const { bytes } = readPrg(request.program);
79
+ return (await callTool("petcat.decode", {}, [bytes], options)).result;
79
80
  }
80
81
 
81
82
  /** host.status: the Host Runtime, and each native tool on the host, found and run once. */
@@ -20,6 +20,11 @@ export function projectFolder(project: string | undefined): string {
20
20
  return resolveProject(project, process.cwd(), { remedy: "Give project: <folder>" });
21
21
  }
22
22
 
23
+ /** Refuses an MCP `out` before the tool does any work, with the checks that every --out of a script gets. */
24
+ export function checkOut(path: string, project: string | undefined): void {
25
+ checkOutputPath(path, "file", { root: projectFolder(project), what: "out" });
26
+ }
27
+
23
28
  /** The project folder of this process (--project or the project lookup), or a WireFailure. */
24
29
  function currentProject(): string {
25
30
  return projectRoot();
@@ -36,17 +41,17 @@ export interface ProjectFile {
36
41
  * project rules refuse, not-found when nothing is there, limit-exceeded when
37
42
  * the file is too large to transfer.
38
43
  */
39
- export function readProjectFile(path: string, options: { root?: string } = {}): ProjectFile {
44
+ export function readProjectFile(path: string, options: { root?: string; what?: string } = {}): ProjectFile {
40
45
  const root = options.root ?? currentProject();
41
46
  let real: string;
42
47
  try {
43
48
  real = resolveProjectFile(path, root);
44
49
  } catch (error) {
45
50
  if ((error as NodeJS.ErrnoException).code === "ENOENT") {
46
- throw new WireFailure("not-found", `There is no file at ${path} in the project folder.`);
51
+ throw new WireFailure("not-found", `${options.what === undefined ? "" : `${options.what}: `}There is no file at ${path} in the project folder.`);
47
52
  }
48
53
  if (isRefusal(error) && error.code === "invalid-input") {
49
- throw new WireFailure("invalid-input", `${path}: give a path relative to the project folder that stays inside it.`);
54
+ throw new WireFailure("invalid-input", `${options.what ?? path}: give a path relative to the project folder that stays inside it, not ${path}.`);
50
55
  }
51
56
  throw error;
52
57
  }