@henols/c64-re-tools 2.0.0-rc.6 → 2.0.0-rc.8

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 (55) hide show
  1. package/package.json +1 -1
  2. package/skills/c64-assembler/SKILL.md +1 -1
  3. package/skills/c64-basic/SKILL.md +2 -2
  4. package/skills/c64-basic/scripts/basic.ts +1 -1
  5. package/skills/c64-disk/SKILL.md +3 -3
  6. package/skills/c64-disk/scripts/disk.ts +2 -1
  7. package/skills/c64-emulator/SKILL.md +4 -1
  8. package/skills/c64-knowledge/SKILL.md +2 -2
  9. package/skills/c64-knowledge/scripts/knowledge.ts +5 -2
  10. package/skills/c64-provenance/SKILL.md +10 -5
  11. package/skills/c64-provenance/scripts/compare.ts +35 -6
  12. package/skills/c64-provenance/scripts/provenance.ts +35 -15
  13. package/skills/c64-provenance/scripts/separate.ts +12 -4
  14. package/skills/c64-reverse-engineering/SKILL.md +2 -2
  15. package/skills/c64-source/SKILL.md +18 -11
  16. package/skills/c64-source/scripts/generate.ts +55 -10
  17. package/skills/c64-source/scripts/loader.ts +253 -6
  18. package/skills/c64-source/scripts/source.ts +46 -11
  19. package/skills/c64-static-analysis/SKILL.md +4 -4
  20. package/skills/c64-static-analysis/scripts/analyze.ts +13 -6
  21. package/skills/c64-static-analysis/scripts/coverage.ts +4 -4
  22. package/skills/c64-static-analysis/scripts/idioms.ts +14 -9
  23. package/skills/c64-testing/SKILL.md +1 -1
  24. package/skills/c64-testing/references/scenario-format.md +1 -1
  25. package/skills/c64-testing/scripts/scenario.ts +9 -7
  26. package/skills/c64-unpacker/SKILL.md +11 -9
  27. package/skills/c64-unpacker/scripts/loads.ts +16 -4
  28. package/skills/c64-unpacker/scripts/machine.ts +13 -1
  29. package/skills/c64-unpacker/scripts/stages.ts +66 -3
  30. package/skills/c64-unpacker/scripts/unpack.ts +135 -42
  31. package/src/c64.ts +19 -5
  32. package/src/cli/status.ts +26 -3
  33. package/src/host/tools/c1541.ts +34 -5
  34. package/src/host/tools/petcat.ts +5 -2
  35. package/src/host/tools/status.ts +6 -3
  36. package/src/host/vice/adapter.ts +37 -9
  37. package/src/host/vice/chips.ts +11 -1
  38. package/src/host/vice/process.ts +7 -2
  39. package/src/host/vice/screen.ts +3 -2
  40. package/src/host/vice/session.ts +68 -15
  41. package/src/host-client/autostart.ts +11 -1
  42. package/src/host-client/connect.ts +10 -10
  43. package/src/host-client/coverage.ts +19 -10
  44. package/src/host-client/tools.ts +6 -6
  45. package/src/host-client/vice-session.ts +1 -1
  46. package/src/knowledge/schema.ts +7 -0
  47. package/src/knowledge/write.ts +5 -0
  48. package/src/mcp/tools/debug.ts +9 -5
  49. package/src/mcp/tools/machine.ts +4 -1
  50. package/src/mcp/tools/media.ts +7 -1
  51. package/src/mcp/tools/video.ts +7 -2
  52. package/src/native/discover.ts +2 -1
  53. package/src/platform.ts +3 -2
  54. package/src/protocol/tools.ts +19 -4
  55. package/src/protocol/vice.ts +56 -5
@@ -8,8 +8,8 @@
8
8
 
9
9
  import { parseArgs } from "node:util";
10
10
 
11
- import { COVERAGE, EXECUTE, formatC64Address, parseC64Address, READ, textToPetscii, WRITE } from "#src/c64.ts";
12
- import { autostartLoaded, LOAD_LIMIT_FRAMES } from "#src/host-client/autostart.ts";
11
+ import { COVERAGE, EXECUTE, formatC64Address, parseC64Address, READ, WRITE } from "#src/c64.ts";
12
+ import { autostartLoaded, LOAD_LIMIT_FRAMES, RUN_LIMIT_FRAMES, typeRun } from "#src/host-client/autostart.ts";
13
13
  import { readCoverage, writtenBlocks } from "#src/host-client/coverage.ts";
14
14
  import { checkActionSteps, runActionStep, type ActionStep } from "#src/host-client/steps.ts";
15
15
  import { WireFailure } from "#src/host-client/tools.ts";
@@ -19,7 +19,7 @@ import { resolveProjectPath } from "#src/project.ts";
19
19
  import { runScript, UsageError } from "#src/script.ts";
20
20
  import { basicStart, inspect, PackingError, readMemoryMap, underRom, writtenThenExecuted } from "./evidence.ts";
21
21
  import { loads, readLoadSteps } from "./loads.ts";
22
- import { inRanges, mergeMaps, stageCandidates, stagePoints, type Range } from "./stages.ts";
22
+ import { callReturn, captureSegments, executedIn, inRanges, mergeMaps, stageCandidates, stagePoints, type Range } from "./stages.ts";
23
23
  import { entryState, IO_AREAS, json, machineState, readRam, READY_FRAMES, vectorsOf } from "./machine.ts";
24
24
 
25
25
  const USAGE = `unpack.ts inspect <program.prg>
@@ -31,7 +31,7 @@ unpack.ts loads <program> --steps <file> --out <dir> [--drive] [--min-block <byt
31
31
  inspect packing evidence: the BASIC start and the entropy of the bytes
32
32
  trace run the program for <n> frames (default 3000) and report the memory
33
33
  that it wrote and then executed; --entry starts it there instead of RUN
34
- capture run the program until the CPU gets to <address>, and write into the
34
+ capture run the program until the CPU gets to <address> after the steps, and write into the
35
35
  directory --out: image.bin (64 KiB of RAM), image.json (the entry,
36
36
  the segments that the stages wrote, the vectors, the stages), a PRG
37
37
  for each segment, and entry_state.json (registers, $00/$01, pages
@@ -54,7 +54,8 @@ loads run a multi-load program (a PRG, or a disk, tape or cartridge image)
54
54
  --steps <file> a JSON list of input steps that run right after the program
55
55
  starts: write, registers, joystick, type, frames and runUntil, as in
56
56
  c64-testing scenarios, with $ addresses only. Use it to press a key
57
- in an intro or to get through a menu.
57
+ in an intro or to get through a menu. stages and capture track the
58
+ stages in frames steps and in runUntil steps with at alone.
58
59
 
59
60
  Addresses are $ and four hex digits. The result is one JSON object.`;
60
61
 
@@ -106,10 +107,8 @@ async function trace(program: string, options: { frames: number; entry?: number;
106
107
  const client = await ViceSessionClient.open({ videoStandard: "pal" });
107
108
  try {
108
109
  // Loaded but not started, so the memory map can start empty: the KERNAL start-up writes all of RAM.
109
- if (!(await autostartLoaded(client, program, bytes))) return { output: { traced: false, reason: `The program was not in memory at ${formatC64Address(load)} after ${LOAD_LIMIT_FRAMES} frames.` }, failed: true };
110
- await client.memmap({ action: "clear" });
111
- if (options.entry !== undefined) await client.registersSet("c64", { pc: options.entry });
112
- else await client.keyboard(textToPetscii("RUN\n"));
110
+ const notStarted = await startLoaded(client, program, bytes, options.entry);
111
+ if (notStarted !== undefined) return { output: { traced: false, reason: notStarted }, failed: true };
113
112
  const stepFailed = await runSteps(client, options.steps);
114
113
  if (stepFailed !== undefined) return { output: { traced: false, reason: stepFailed }, failed: true };
115
114
  let ran = 0;
@@ -190,6 +189,26 @@ const SYS_FRAMES = 50;
190
189
  * The first PC in the program after its BASIC text, for a SYS whose address
191
190
  * BASIC computes: an exec breakpoint over that part of the program stops there.
192
191
  */
192
+ /**
193
+ * Starts the program as a c64-testing scenario does (D62): hard reset, load,
194
+ * an empty memory map, then RUN up to the first BASIC line, or the pc at
195
+ * `entry`. Gives why it did not start.
196
+ */
197
+ async function startLoaded(client: ViceSessionClient, program: string, bytes: Uint8Array, entry: number | undefined): Promise<string | undefined> {
198
+ await client.reset({ mode: "hard", run: false });
199
+ if (!(await autostartLoaded(client, program, bytes))) return `The program was not in memory at ${formatC64Address(bytes[0]! | (bytes[1]! << 8))} after ${LOAD_LIMIT_FRAMES} frames.`;
200
+ await client.memmap({ action: "clear" });
201
+ if (entry !== undefined) await client.registersSet("c64", { pc: entry });
202
+ else if (!(await typeRun(client))) return `After RUN, BASIC ran no program line in ${RUN_LIMIT_FRAMES} frames.`;
203
+ return undefined;
204
+ }
205
+
206
+ /** The raster position now: where a step's frames end (D62). */
207
+ async function rasterAt(client: ViceSessionClient): Promise<{ line: number; cycle: number }> {
208
+ const { timing } = await client.observe({ timing: true });
209
+ return { line: timing!.rasterLine, cycle: timing!.rasterCycle };
210
+ }
211
+
193
212
  async function firstPcAfterBasic(client: ViceSessionClient, from: number, to: number): Promise<number | undefined> {
194
213
  return from > to ? undefined : firstPcIn(client, [{ start: from, end: to }]);
195
214
  }
@@ -200,6 +219,8 @@ const MAX_STAGES = 16;
200
219
  /** Most stops of one re-run of a chunk: own code and writes over it each stop once. */
201
220
  const MAX_STAGE_STOPS = 4096;
202
221
  const STAGE_SNAPSHOT = "stage-chunk";
222
+ /** The snapshot at a call into written code, while the run checks if the call returns (D59). */
223
+ const CALL_SNAPSHOT = "stage-call";
203
224
 
204
225
  interface Stage {
205
226
  entry: number;
@@ -218,14 +239,20 @@ interface Followed {
218
239
  failed?: string;
219
240
  }
220
241
 
242
+ /** A runUntil step to an address alone: a breakpoint stops it, so the stages are tracked while it runs. */
243
+ function trackedRunUntil(step: ActionStep): step is { runUntil: { at: string }; timeoutFrames: number } {
244
+ return "runUntil" in step && "at" in step.runUntil && !("raster" in step.runUntil);
245
+ }
246
+
221
247
  /**
222
248
  * Starts the program and follows its stages (D43). The memory map holds what
223
249
  * the current stage wrote; when the CPU executed a block of at least
224
250
  * `minStage` written bytes during a chunk of frames, the chunk runs again from
225
251
  * its snapshot with exec breakpoints over those blocks, which stop at the
226
252
  * first instruction of the next stage. Steps run in the tracked time: a frames
227
- * step is tracked, the others act between two chunks. With `until`, the
228
- * run ends when the CPU gets there.
253
+ * step and a runUntil step to an address alone are tracked, the others act
254
+ * between two chunks. With `until`, the run ends when the CPU gets there
255
+ * after the steps.
229
256
  */
230
257
  async function follow(
231
258
  client: ViceSessionClient,
@@ -237,11 +264,11 @@ async function follow(
237
264
  const load = bytes[0]! | (bytes[1]! << 8);
238
265
  const basic = basicStart(bytes);
239
266
  if (options.entry === undefined && basic === undefined) throw new UsageError(NO_BASIC);
240
- if (!(await autostartLoaded(client, program, bytes))) return { stages: [], frames: 0, reached: false, failed: `The program was not in memory at ${formatC64Address(load)} after ${LOAD_LIMIT_FRAMES} frames.` };
241
- await client.memmap({ action: "clear" });
242
- if (options.entry !== undefined) await client.registersSet("c64", { pc: options.entry });
243
- else await client.keyboard(textToPetscii("RUN\n"));
244
- const target = options.until === undefined ? undefined : await client.breakpoint({ action: "add", address: options.until, space: "c64" });
267
+ const notStarted = await startLoaded(client, program, bytes, options.entry);
268
+ if (notStarted !== undefined) return { stages: [], frames: 0, reached: false, failed: notStarted };
269
+ // The frames of each step end where a scenario's do: the first step counts from RUN, a later one from where it starts (D62).
270
+ let anchor: { line: number; cycle: number } | undefined = await rasterAt(client);
271
+ const framed = () => (anchor === undefined ? {} : { anchor });
245
272
  const found: Stage[] = [];
246
273
  const start = options.entry ?? basic!.sys?.address ?? (await firstPcAfterBasic(client, basic!.basicEnd, load + bytes.length - 3));
247
274
  if (start === undefined) return { stages: [], frames: 0, reached: false, failed: `After RUN, the CPU did not execute the program after its BASIC text (${formatC64Address(basic!.basicEnd)} on) in ${SYS_FRAMES} frames. Give --entry <address>.` };
@@ -249,16 +276,17 @@ async function follow(
249
276
  let firstFrame = 0;
250
277
  let total = 0;
251
278
  let ended = false;
252
- let reached = false;
253
- const atTarget = (pc: number) => options.until !== undefined && pc === options.until;
254
- // The current stage: its memory map since it started, chunk by chunk, and its own code.
255
- let stage = { map: new Uint8Array(0x10000), own: new Set<number>() };
279
+ /** Where the run stops: the target of a runUntil step while it runs, then until (R2-06). */
280
+ let stopAt: number | undefined;
281
+ const atTarget = (pc: number) => stopAt !== undefined && pc === stopAt;
282
+ // The current stage: its memory map since it started, chunk by chunk, its own code, and the written code it calls (D59).
283
+ let stage = { map: new Uint8Array(0x10000), own: new Set<number>(), called: [] as Range[] };
256
284
  const startStage = async (next: number, last: Uint8Array) => {
257
285
  mergeMaps(stage.map, last);
258
286
  found.push({ entry, firstFrame, frames: total - firstFrame, wrote: writtenBlocks(stage.map, options.minStage), next });
259
287
  entry = next;
260
288
  firstFrame = total;
261
- stage = { map: new Uint8Array(0x10000), own: new Set<number>() };
289
+ stage = { map: new Uint8Array(0x10000), own: new Set<number>(), called: [] };
262
290
  await client.memmap({ action: "clear" });
263
291
  };
264
292
 
@@ -286,6 +314,31 @@ async function follow(
286
314
  if (!points.has(key)) points.set(key, { kind: "watch", id: (await client.watchpoint({ action: "add", address: range.start, size: range.end - range.start + 1, access: "write", space: "c64" })).id });
287
315
  }
288
316
  };
317
+ /**
318
+ * At written code that a JSR of the stage called: runs on until the call
319
+ * returns, in the frames left. Gives the frames used when it returns;
320
+ * otherwise the machine goes back to the stop and nothing is given.
321
+ */
322
+ const returns = async (map: Uint8Array, frames: number): Promise<number | undefined> => {
323
+ const { sp } = await client.registersGet("c64");
324
+ const stack = await readRam(client, 0x0100, 0x100);
325
+ const pushed = stack[(sp + 1) & 0xff]! | (stack[(sp + 2) & 0xff]! << 8);
326
+ const [opcode] = await readRam(client, (pushed - 2) & 0xffff, 1);
327
+ const back = callReturn(stack, sp, () => opcode!, (address) => ((stage.map[address]! | map[address]!) & COVERAGE.ramExecute) !== 0);
328
+ if (back === undefined) return undefined;
329
+ await arm({ exec: [], watch: [] });
330
+ await client.snapshot({ action: "save", name: CALL_SNAPSHOT });
331
+ const point = await client.breakpoint({ action: "add", address: back, space: "c64" });
332
+ try {
333
+ const run = await client.execution({ action: "advance-frames", count: frames, space: "c64", ...framed() });
334
+ if ((run.pc ?? (await client.registersGet("c64")).pc) === back) return run.advancedFrames ?? 0;
335
+ await client.snapshot({ action: "restore", name: CALL_SNAPSHOT });
336
+ return undefined;
337
+ } finally {
338
+ await client.breakpoint({ action: "remove", id: point.id });
339
+ await client.snapshot({ action: "discard", name: CALL_SNAPSHOT });
340
+ }
341
+ };
289
342
  let left = frames;
290
343
  let used = 0;
291
344
  let map: Uint8Array = new Uint8Array(0x10000);
@@ -295,15 +348,27 @@ async function follow(
295
348
  throw new WireFailure("operation-failed", `The stage that starts at ${formatC64Address(entry)} stopped ${MAX_STAGE_STOPS} times in one chunk of frames without a new stage. Use --min-stage with a larger block size, or capture with --until.`);
296
349
  }
297
350
  const written = (address: number) => ((stage.map[address]! | map[address]!) & COVERAGE.ramWrite) !== 0;
298
- await arm(stagePoints(candidates, stage.own, written));
299
- const run = await client.execution({ action: "advance-frames", count: left, space: "c64" });
351
+ await arm(stagePoints(candidates, stage.own, written, stage.called));
352
+ const run = await client.execution({ action: "advance-frames", count: left, space: "c64", ...framed() });
300
353
  const advanced = run.advancedFrames ?? 0;
301
354
  used += advanced;
302
355
  left -= advanced;
303
356
  const pc = run.pc ?? (await client.registersGet("c64")).pc;
304
357
  map = await readCoverage(client);
305
- if (inRanges(candidates, pc)) {
306
- if (written(pc)) return { used, map, next: pc, atTarget: atTarget(pc) };
358
+ // A frame can end in written code that the stage called before (D59): that stop starts no stage.
359
+ if (inRanges(candidates, pc) && !inRanges(stage.called, pc)) {
360
+ if (written(pc)) {
361
+ const back = left > 0 && !atTarget(pc) ? await returns(map, left) : undefined;
362
+ if (back === undefined) return { used, map, next: pc, atTarget: atTarget(pc) };
363
+ used += back;
364
+ left -= back;
365
+ map = await readCoverage(client);
366
+ // The stage called the block and got the CPU back: the code that ran there is its own code.
367
+ const block = candidates.find((range) => pc >= range.start && pc <= range.end)!;
368
+ stage.called.push(...executedIn(map, block));
369
+ if (left <= 0) return { used, map, atTarget: false };
370
+ continue;
371
+ }
307
372
  stage.own.add(pc);
308
373
  }
309
374
  if (atTarget(pc)) return { used, map, atTarget: true };
@@ -317,28 +382,38 @@ async function follow(
317
382
  }
318
383
  };
319
384
 
320
- const track = async (frames: number): Promise<void> => {
385
+ /** Tracks the stages for some frames; true when the CPU got to stopAt. */
386
+ const track = async (frames: number): Promise<boolean> => {
387
+ anchor ??= await rasterAt(client);
388
+ try {
389
+ return await trackFrom(frames);
390
+ } finally {
391
+ anchor = undefined;
392
+ }
393
+ };
394
+ const trackFrom = async (frames: number): Promise<boolean> => {
321
395
  let left = frames;
322
- while (left > 0 && !ended && !reached && found.length < MAX_STAGES) {
396
+ let stopped = false;
397
+ while (left > 0 && !ended && !stopped && found.length < MAX_STAGES) {
323
398
  await client.snapshot({ action: "save", name: STAGE_SNAPSHOT });
324
399
  await client.memmap({ action: "clear" });
325
400
  const chunk = Math.min(STAGE_CHUNK_FRAMES, left);
326
- const first = await client.execution({ action: "advance-frames", count: chunk, space: "c64" });
401
+ const first = await client.execution({ action: "advance-frames", count: chunk, space: "c64", ...framed() });
327
402
  const firstPc = first.pc ?? (await client.registersGet("c64")).pc;
328
403
  const map = await readCoverage(client);
329
- const candidates = stageCandidates(stage.map, map, stage.own, options.minStage);
404
+ const candidates = stageCandidates(stage.map, map, stage.own, options.minStage, stage.called);
330
405
  if (candidates.length === 0) {
331
406
  const advanced = first.advancedFrames ?? 0;
332
407
  total += advanced;
333
408
  left -= advanced;
334
409
  if (atTarget(firstPc)) {
335
- // The stop at until comes before its instruction runs: a target that the stage wrote starts a stage too.
410
+ // The stop at a target comes before its instruction runs: a target that the stage wrote starts a stage too.
336
411
  const combined = Uint8Array.from(stage.map);
337
412
  mergeMaps(combined, map);
338
413
  const wrote = (combined[firstPc]! & COVERAGE.ramWrite) !== 0 && inRanges(writtenBlocks(combined, options.minStage), firstPc);
339
414
  if (wrote) await startStage(firstPc, map);
340
415
  else mergeMaps(stage.map, map);
341
- reached = true;
416
+ stopped = true;
342
417
  } else {
343
418
  mergeMaps(stage.map, map);
344
419
  if (advanced === 0) ended = true;
@@ -353,15 +428,35 @@ async function follow(
353
428
  left -= again.used;
354
429
  if (again.next !== undefined) await startStage(again.next, again.map);
355
430
  else mergeMaps(stage.map, again.map);
356
- if (again.atTarget) reached = true;
431
+ if (again.atTarget) stopped = true;
357
432
  }
433
+ return stopped;
358
434
  };
359
- for (const step of options.steps) {
435
+ /** Tracks the stages until the CPU gets to an address, with a breakpoint there. */
436
+ const trackTo = async (address: number, frames: number): Promise<boolean> => {
437
+ stopAt = address;
438
+ const point = await client.breakpoint({ action: "add", address, space: "c64" });
439
+ try {
440
+ return await track(frames);
441
+ } finally {
442
+ await client.breakpoint({ action: "remove", id: point.id });
443
+ stopAt = undefined;
444
+ }
445
+ };
446
+ // The steps run first and in the tracked time: a frames step and a runUntil step to an address (R2-06). The others act between two chunks.
447
+ for (const [index, step] of options.steps.entries()) {
360
448
  if ("frames" in step) await track(step.frames);
361
- else await runActionStep(client, step, parseC64Address);
449
+ else if (trackedRunUntil(step)) {
450
+ if (!(await trackTo(parseC64Address(step.runUntil.at), step.timeoutFrames))) {
451
+ const { pc } = await client.registersGet("c64");
452
+ return { stages: found, frames: total, reached: false, failed: `Step ${index + 1} did not reach its target in ${step.timeoutFrames} frames. It is at ${formatC64Address(pc)}.` };
453
+ }
454
+ } else await runActionStep(client, step, parseC64Address);
362
455
  }
363
- await track(Math.max(0, options.frames - total));
364
- if (target !== undefined) await client.breakpoint({ action: "remove", id: target.id });
456
+ const left = Math.max(0, options.frames - total);
457
+ let reached = false;
458
+ if (options.until === undefined) await track(left);
459
+ else reached = await trackTo(options.until, left);
365
460
  await client.snapshot({ action: "discard", name: STAGE_SNAPSHOT }).catch(() => undefined);
366
461
  mergeMaps(stage.map, await readCoverage(client));
367
462
  found.push({ entry, firstFrame, frames: total - firstFrame, wrote: writtenBlocks(stage.map, options.minStage) });
@@ -417,10 +512,8 @@ async function capture(
417
512
  // The RAM view: unpacked code is often under the ROMs or the I/O area.
418
513
  const memory = await readRam(client, 0x0000, 0x10000);
419
514
  const state = await machineState(client);
420
- // The segments: what the stages wrote, without the blocks that hold the code of an earlier stage.
421
- const earlier = followed.stages.slice(0, -1).map((stage) => stage.entry);
422
- const written = followed.stages.flatMap((stage, index) => stage.wrote.map((range) => ({ ...range, writtenBy: index })));
423
- const segments = options.range !== undefined ? [{ start: options.range[0], end: options.range[1] }] : written.filter((range) => !earlier.some((entry) => entry >= range.start && entry <= range.end));
515
+ // The segments: what the stages wrote, without loader leftovers, with no overlap.
516
+ const segments = options.range !== undefined ? [{ start: options.range[0], end: options.range[1] }] : captureSegments(followed.stages, registers.pc);
424
517
  const file = (path: string) => `${options.out}/${path}`;
425
518
  const write = (path: string, bytes: Uint8Array) => writeProjectFile(file(path), bytes, { what: "--out" });
426
519
  write("image.bin", memory);
@@ -533,8 +626,8 @@ async function run(argv: string[]): Promise<{ output: unknown; failed: boolean }
533
626
  const minStage = values["min-stage"] === undefined ? 64 : Number(values["min-stage"]);
534
627
  if (!Number.isInteger(minStage) || minStage < 1 || minStage > 0x10000) throw new UsageError("--min-stage must be from 1 to 65536");
535
628
  const steps = values.steps === undefined ? [] : readSteps(values.steps);
536
- // A runUntil step cannot be tracked in chunks of frames; capture takes it.
537
- if (steps.some((step) => "runUntil" in step)) throw new UsageError("stages takes no runUntil step; use frames steps");
629
+ // A runUntil step to a raster line or a memory value cannot be tracked in chunks of frames; capture takes it.
630
+ if (steps.some((step) => "runUntil" in step && !trackedRunUntil(step))) throw new UsageError("stages takes a runUntil step only with at alone; use frames steps or runUntil at");
538
631
  return stages(program, { frames, steps, minStage, ...(values.entry === undefined ? {} : { entry: address(values.entry, "--entry") }) });
539
632
  }
540
633
  if (command === "loads") {
package/src/c64.ts CHANGED
@@ -153,29 +153,43 @@ export type BasicHandoff =
153
153
  */
154
154
  export type TokenizedProgram = { loadAddress: number; basicEnd: number; textBytes: number; lines: TokenizedLine[] } | { reason: string };
155
155
 
156
+ /** BASIC text cannot start below the screen: there are the zero page, the stack and the system pages. */
157
+ const MIN_BASIC_LOAD = 0x0400;
158
+
156
159
  /**
157
160
  * Splits a PRG into BASIC lines the way BASIC relinks a loaded program: each
158
161
  * line ends at a zero byte, and a link whose high byte is zero ends the
159
- * program. The stored links are not trusted. A real end marker is two zero
160
- * bytes; when only the high byte is zero, the "marker" is the first machine
161
- * code after the BASIC text (SYS lines that jump right behind themselves).
162
+ * program. A real end marker is two zero bytes; when only the high byte is
163
+ * zero, the "marker" is the first machine code after the BASIC text (SYS
164
+ * lines that jump right behind themselves). The stored links are not read:
165
+ * BASIC relinks a loaded program, and a crack can store odd links to hide
166
+ * lines from LIST. A load address below $0400 is a memory dump (F-12), and so
167
+ * is a file whose bytes run past $ffff: a 64 KiB dump from $0000 always does
168
+ * (b2/01, R3-02).
162
169
  */
163
170
  export function parseTokenized(program: Uint8Array): TokenizedProgram {
164
171
  if (program.length < 4) return { reason: "The file is too short for a BASIC program." };
165
172
  const loadAddress = program[0]! | (program[1]! << 8);
173
+ if (loadAddress < MIN_BASIC_LOAD) {
174
+ return { reason: `The load address ${formatC64Address(loadAddress)} is below $0400, in the zero page, the stack or the system pages. The file is a memory dump or machine code, not a BASIC program.` };
175
+ }
176
+ if (loadAddress + program.length - 2 > 0x10000) {
177
+ return { reason: `The file holds ${program.length - 2} bytes from ${formatC64Address(loadAddress)}, which runs past $ffff. The file is a memory dump, not a BASIC program.` };
178
+ }
166
179
  const lines: TokenizedLine[] = [];
167
180
  let at = 2;
181
+ const ended = (basicEnd: number): TokenizedProgram => ({ loadAddress, basicEnd, textBytes: at, lines });
168
182
  // File offset k (from 2 on) loads at loadAddress + k - 2.
169
183
  for (;;) {
170
184
  // A file that ends right after a line leaves the end marker to the zeros after it in memory;
171
185
  // basicEnd is the address after that marker, as when the file holds it.
172
- if (at === program.length && lines.length > 0) return { loadAddress, basicEnd: loadAddress + at, textBytes: at, lines };
186
+ if (at === program.length && lines.length > 0) return ended(loadAddress + at);
173
187
  if (at + 1 >= program.length) return { reason: "The BASIC program has no end marker." };
174
188
  if (program[at + 1] === 0) {
175
189
  // An end marker before any line, with more bytes after it, is how machine code at a non-BASIC address often starts.
176
190
  if (lines.length === 0 && at + 2 < program.length) return { reason: "The file holds no BASIC line before other bytes, so it is no BASIC program." };
177
191
  const marker = program[at] === 0;
178
- return { loadAddress, basicEnd: loadAddress + (marker ? at : at - 2), textBytes: at, lines };
192
+ return ended(loadAddress + (marker ? at : at - 2));
179
193
  }
180
194
  if (at + 4 > program.length) return { reason: "The last BASIC line is cut off." };
181
195
  const number = program[at + 2]! | (program[at + 3]! << 8);
package/src/cli/status.ts CHANGED
@@ -2,8 +2,10 @@
2
2
  // Runtime with its tools.
3
3
 
4
4
  import { hostTools, type ToolCallOptions } from "../host-client/tools.ts";
5
+ import { viceVersionOf } from "../native/discover.ts";
6
+ import { PACKAGE } from "../package.ts";
5
7
  import { STOP_AND_ASK } from "../protocol/messages.ts";
6
- import type { ToolStatus } from "../protocol/tools.ts";
8
+ import type { HostInfo, ToolStatus } from "../protocol/tools.ts";
7
9
 
8
10
  /** One line per tool under `heading`: its version, or why it does not run; then each known flaw of it. */
9
11
  export function toolLines(heading: string, tools: readonly ToolStatus[]): string[] {
@@ -27,12 +29,33 @@ export function toolLines(heading: string, tools: readonly ToolStatus[]): string
27
29
  * every tool on it runs.
28
30
  */
29
31
  export async function hostStatusLines(options: ToolCallOptions = {}): Promise<{ lines: string[]; ready: boolean }> {
32
+ let host: HostInfo;
30
33
  let tools: ToolStatus[];
31
34
  try {
32
- tools = await hostTools(options);
35
+ ({ host, tools } = await hostTools(options));
33
36
  } catch (error) {
34
37
  // status is read by a person: the instruction for an agent (D37) stays out.
35
38
  return { lines: [`Host Runtime: not reachable. ${(error as Error).message.replace(` ${STOP_AND_ASK}`, "")}`], ready: false };
36
39
  }
37
- return { lines: ["Host Runtime: reachable", ...toolLines("Tools on the host:", tools)], ready: tools.every((tool) => tool.runs) };
40
+ const mixed = mixedViceBuilds(tools);
41
+ return {
42
+ lines: [
43
+ `Host Runtime: reachable, c64-re-tools ${host.version} on ${host.platform} ${host.release} (${host.arch})`,
44
+ ...(host.version === PACKAGE.version ? [] : [` Warning: the Host Runtime is c64-re-tools ${host.version} and this is ${PACKAGE.version}. Start the host again so that both are the same version.`]),
45
+ ...toolLines("Tools on the host:", tools),
46
+ ...(mixed === undefined ? [] : [` Warning: ${mixed}`]),
47
+ ],
48
+ ready: tools.every((tool) => tool.runs),
49
+ };
50
+ }
51
+
52
+ /** A warning when x64sc, c1541 and petcat that run come from different VICE versions or builds. */
53
+ export function mixedViceBuilds(tools: readonly ToolStatus[]): string | undefined {
54
+ const running = tools.filter((tool) => tool.runs && tool.version !== undefined);
55
+ const builds = running.map((tool) => {
56
+ const version = viceVersionOf(tool.version!);
57
+ return version === undefined ? tool.version! : `VICE ${version.major}.${version.minor}${version.revision === undefined ? "" : ` SVN r${version.revision}`}`;
58
+ });
59
+ if (new Set(builds).size <= 1) return undefined;
60
+ return `the VICE tools come from different VICE builds: ${running.map((tool) => `${tool.version} (${tool.path})`).join(", ")}. Put x64sc, c1541 and petcat of one VICE first on PATH, or set C64RT_VICE, C64RT_C1541 and C64RT_PETCAT.`;
38
61
  }
@@ -37,11 +37,23 @@ const LAYOUTS: Record<string, Layout> = {
37
37
  "1581": { header: { track: 40, sector: 0 }, name: 0x04, id: 0x16, dos: 0x19 },
38
38
  };
39
39
 
40
+ /** Used sectors of the format outside the header and directory chain: the 1571 BAM track 53, the two 1581 BAM blocks. */
41
+ const RESERVED: Record<string, DiskSector[]> = {
42
+ "1541": [],
43
+ "1571": Array.from({ length: 19 }, (_, sector) => ({ track: 53, sector })),
44
+ "1581": [
45
+ { track: 40, sector: 1 },
46
+ { track: 40, sector: 2 },
47
+ ],
48
+ };
49
+
40
50
  export interface DiskEntry extends DiskFile {
41
51
  /** The name's PETSCII bytes, up to the first shifted space ($a0). */
42
52
  nameBytes: Uint8Array;
43
53
  startTrack: number;
44
54
  startSector: number;
55
+ /** REL only: the first side sector. */
56
+ sideStart?: DiskSector;
45
57
  }
46
58
 
47
59
  const damaged = (message: string) => new WireFailure("media-error", `The disk image is damaged: ${message}`);
@@ -115,7 +127,7 @@ export function parseDirectory(blocks: Buffer[], layout: Layout): { diskName: st
115
127
  // Type 0 is an empty or scratched slot; the DOS lists no file there.
116
128
  if (typeByte === 0) continue;
117
129
  const nameBytes = nameField(block, offset + 5, 16);
118
- entries.push({
130
+ const entry: DiskEntry = {
119
131
  name: petsciiNameToText(nameBytes),
120
132
  nameBytes,
121
133
  type: fileType(typeByte),
@@ -124,7 +136,10 @@ export function parseDirectory(blocks: Buffer[], layout: Layout): { diskName: st
124
136
  locked: (typeByte & 0x40) !== 0,
125
137
  startTrack: block.readUInt8(offset + 3),
126
138
  startSector: block.readUInt8(offset + 4),
127
- });
139
+ };
140
+ const side = { track: block.readUInt8(offset + 0x15), sector: block.readUInt8(offset + 0x16) };
141
+ if (entry.type === "rel" && side.track !== 0) entry.sideStart = side;
142
+ entries.push(entry);
128
143
  }
129
144
  }
130
145
  return {
@@ -151,6 +166,13 @@ export function fileData(blocks: Buffer[], chain: DiskSector[]): Buffer {
151
166
  return Buffer.concat(parts);
152
167
  }
153
168
 
169
+ /** The used sectors of the BAM that are not in `referenced`, in track and sector order. */
170
+ export function unreferencedSectors(tracks: ReadonlyArray<{ track: number; usedSectors: number[] }>, referenced: Iterable<DiskSector>): DiskSector[] {
171
+ const known = new Set<string>();
172
+ for (const at of referenced) known.add(`${at.track}/${at.sector}`);
173
+ return tracks.flatMap(({ track, usedSectors }) => usedSectors.filter((sector) => !known.has(`${track}/${sector}`)).map((sector) => ({ track, sector })));
174
+ }
175
+
154
176
  const toFile = (entry: DiskEntry): DiskFile => ({ name: entry.name, type: entry.type, blocks: entry.blocks, closed: entry.closed, locked: entry.locked });
155
177
 
156
178
  const sameBytes = (a: Uint8Array, b: Uint8Array) => a.length === b.length && a.every((byte, index) => byte === b[index]);
@@ -226,11 +248,18 @@ export async function inspect(params: C1541Params, image: Buffer, context: ToolC
226
248
  const layout = LAYOUTS[info.format];
227
249
  if (layout === undefined) throw new WireFailure("media-error", `The disk image has the drive format ${info.format}; only 1541, 1571 and 1581 disks are supported.`);
228
250
 
229
- if (params.action === "bam") {
230
- return { result: { action: "bam", tracks: parseBam(await c1541.run(["-bam"]), info.tracks) } };
231
- }
232
251
  const headerChain = await c1541.chain(layout.header);
233
252
  const directory = parseDirectory(await c1541.blocks(headerChain), layout);
253
+ if (params.action === "bam") {
254
+ const tracks = parseBam(await c1541.run(["-bam"]), info.tracks);
255
+ // Every sector that the header, the directory or a file holds; a file without blocks has no chain.
256
+ const referenced = [...headerChain, ...(RESERVED[info.format] ?? [])];
257
+ for (const entry of directory.entries) {
258
+ if (entry.startTrack !== 0) referenced.push(...(await c1541.chain({ track: entry.startTrack, sector: entry.startSector })));
259
+ if (entry.sideStart !== undefined) referenced.push(...(await c1541.chain(entry.sideStart)));
260
+ }
261
+ return { result: { action: "bam", tracks, unreferenced: unreferencedSectors(tracks, referenced) } };
262
+ }
234
263
  if (params.action === "directory") {
235
264
  const { diskName, diskId, dosType, entries } = directory;
236
265
  return { result: { action: "directory", diskName, diskId, dosType, freeBlocks: parseFreeBlocks(overview), entries: entries.map(toFile) } };
@@ -16,14 +16,17 @@ import { runToolOrFail, type ToolContext } from "../../native/run.ts";
16
16
 
17
17
  const TIMEOUT_MS = 30_000;
18
18
  const LISTING = "listing.txt";
19
- /** petcat's listing lines (" 10 sys2061"), as number and text. */
19
+ /**
20
+ * petcat's listing lines (" 10 sys2061"), as number and text. petcat
21
+ * prints π ($ff) as `~`, which is no PETSCII character: the text has π (F-13).
22
+ */
20
23
  export function parseListing(listing: string): Array<{ number: number; text: string }> {
21
24
  const lines: Array<{ number: number; text: string }> = [];
22
25
  for (const line of listing.split(/\r?\n/)) {
23
26
  if (line.trim() === "") continue;
24
27
  const match = /^\s*(\d+) ?(.*)$/.exec(line);
25
28
  if (match === null) throw new WireFailure("operation-failed", "petcat printed a listing line without a line number.");
26
- lines.push({ number: Number(match[1]), text: match[2]! });
29
+ lines.push({ number: Number(match[1]), text: match[2]!.replaceAll("~", "π") });
27
30
  }
28
31
  return lines;
29
32
  }
@@ -5,7 +5,10 @@
5
5
  // running emulator at start: the Homebrew x64sc of VICE 3.10 on macOS stops
6
6
  // on -version ("argv[0] is NULL") although the emulator runs.
7
7
 
8
- import type { ToolStatus } from "../../protocol/tools.ts";
8
+ import { arch, platform, release } from "node:os";
9
+
10
+ import { PACKAGE } from "../../package.ts";
11
+ import type { HostInfo, ToolStatus } from "../../protocol/tools.ts";
9
12
  import { C1541, findTool, isOlderThanMinimum, MIN_VICE, PETCAT, VICE, viceFlaws, viceVersionOf, type ToolSpec, type ViceVersion } from "../../native/discover.ts";
10
13
  import type { ToolContext } from "../../native/run.ts";
11
14
  import { probeTools } from "../../native/status.ts";
@@ -23,7 +26,7 @@ function requireMinimum(status: ToolStatus, tool: ToolSpec): ToolStatus {
23
26
  const versionText = (version: ViceVersion) => `x64sc (VICE ${version.major}.${version.minor}${version.revision === undefined ? "" : ` SVN r${version.revision}`})`;
24
27
 
25
28
  /** `started`: the version that the host read from the emulator at start; without it, VICE is probed with -version too. */
26
- export async function hostStatus(context: ToolContext, started?: ViceVersion): Promise<{ result: { tools: ToolStatus[] } }> {
29
+ export async function hostStatus(context: ToolContext, started?: ViceVersion): Promise<{ result: { host: HostInfo; tools: ToolStatus[] } }> {
27
30
  const specs = [VICE, C1541, PETCAT];
28
31
  const probed = await probeTools(
29
32
  [
@@ -40,5 +43,5 @@ export async function hostStatus(context: ToolContext, started?: ViceVersion): P
40
43
  const version = vice.runs ? viceVersionOf(vice.version ?? "") : undefined;
41
44
  const flaws = version === undefined ? [] : viceFlaws(version);
42
45
  if (flaws.length > 0) checked[0] = { ...vice, flaws };
43
- return { result: { tools: checked } };
46
+ return { result: { host: { version: PACKAGE.version, platform: platform(), release: release(), arch: arch() }, tools: checked } };
44
47
  }