@henols/c64-re-tools 2.0.0-rc.7 → 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 (33) hide show
  1. package/package.json +1 -1
  2. package/skills/c64-basic/SKILL.md +1 -1
  3. package/skills/c64-basic/scripts/basic.ts +1 -1
  4. package/skills/c64-emulator/SKILL.md +2 -1
  5. package/skills/c64-knowledge/SKILL.md +1 -1
  6. package/skills/c64-knowledge/scripts/knowledge.ts +5 -2
  7. package/skills/c64-provenance/SKILL.md +4 -1
  8. package/skills/c64-provenance/scripts/compare.ts +9 -6
  9. package/skills/c64-provenance/scripts/provenance.ts +13 -7
  10. package/skills/c64-source/SKILL.md +6 -5
  11. package/skills/c64-source/scripts/generate.ts +19 -0
  12. package/skills/c64-source/scripts/loader.ts +172 -7
  13. package/skills/c64-source/scripts/source.ts +7 -3
  14. package/skills/c64-static-analysis/SKILL.md +1 -1
  15. package/skills/c64-static-analysis/scripts/idioms.ts +2 -2
  16. package/skills/c64-testing/references/scenario-format.md +1 -1
  17. package/skills/c64-testing/scripts/scenario.ts +7 -5
  18. package/skills/c64-unpacker/SKILL.md +2 -2
  19. package/skills/c64-unpacker/scripts/loads.ts +4 -3
  20. package/skills/c64-unpacker/scripts/machine.ts +13 -1
  21. package/skills/c64-unpacker/scripts/unpack.ts +40 -13
  22. package/src/c64.ts +6 -1
  23. package/src/host/vice/adapter.ts +14 -0
  24. package/src/host/vice/chips.ts +11 -1
  25. package/src/host/vice/screen.ts +3 -2
  26. package/src/host/vice/session.ts +23 -7
  27. package/src/host-client/autostart.ts +11 -1
  28. package/src/host-client/tools.ts +1 -1
  29. package/src/mcp/tools/debug.ts +6 -3
  30. package/src/mcp/tools/machine.ts +2 -2
  31. package/src/mcp/tools/media.ts +2 -0
  32. package/src/mcp/tools/video.ts +5 -2
  33. package/src/protocol/vice.ts +50 -8
@@ -36,7 +36,7 @@ node <skill>/scripts/unpack.ts loads original/game.d64 --steps parts.json --out
36
36
  | `--timeout-frames 3000` | How many frames the program can run before the capture stops (default 3000). |
37
37
  | `--frames 3000` | `trace` only: how many frames the program runs (default 3000). |
38
38
  | `--entry '$c000'` | `trace`, `stages` and `capture`: start the program at this address. Without it, the script types RUN. A file that is not a BASIC program at `$0801` needs it. |
39
- | `--steps input.json` | Input for the program, right after it starts: a JSON list of steps. Use it when an intro waits for a key or a menu waits for a choice. |
39
+ | `--steps input.json` | Input for the program, right after it starts: a JSON list of steps. Use it when an intro waits for a key or a menu waits for a choice. The scripts start the program as a c64-testing scenario does. Thus the same steps in a scenario get the program to the entry at the same time. |
40
40
  | `--drive` | `loads` only: each capture also writes the 2 KiB RAM of the 1541 drive as `drive8.prg`. |
41
41
  | `--min-block 64` | `loads` only: the smallest block of changed memory that becomes a segment (default 64). The result counts smaller changes as `smallChanges`. |
42
42
  | `--min-stage 64` | `stages` and `capture`: the smallest block of written memory that starts a new stage when the CPU executes it (default 64). |
@@ -76,7 +76,7 @@ A `type` step puts keys in the keyboard queue of the KERNAL. A program that read
76
76
  - `image.bin`: all 64 KiB of RAM at the entry.
77
77
  - `image.json`: the entry, the segments, the vectors and the stages. A segment is a block that a stage wrote. A block is not a segment when it holds the entry of a later stage before the program, and no stage wrote over that entry. The block with the capture entry is always a segment. Segments do not overlap. `writtenBy` tells the last stage that wrote into the segment.
78
78
  - One PRG for each segment.
79
- - `entry_state.json`: the registers, `$00` and `$01`, pages 0 to 3, the VIC, SID and CIA registers, and the vectors. `differsFromReset` lists the bytes that are not as a clean C64 has them at READY. A rebuild must make these bytes the same, because the program can read them.
79
+ - `entry_state.json`: the registers, `$00` and `$01`, pages 0 to 3, the VIC, SID and CIA registers, and the vectors. `cia` gives the CIA timer latches and interrupt masks. `differsFromReset` lists the bytes that are not as a clean C64 has them at READY. A rebuild must make these bytes the same, because the program can read them.
80
80
  5. Make sure that the result is the application: the code at the entry must not be the unpacker again. A segment can be data that a loader left, for example intro graphics. Keep it until other evidence shows that the program does not use it.
81
81
  6. Give `image.bin` to the c64-static-analysis skill with its `--flat64k` option, or give it the segment PRGs.
82
82
  7. Use `--range` only when you know the area of the program. Then the capture writes that one segment.
@@ -4,8 +4,8 @@
4
4
  // image with its entry state, and with --drive the drive RAM as a PRG for
5
5
  // knowledge space drive8. No tool guesses where a load ends: the steps decide.
6
6
 
7
- import { COVERAGE, formatC64Address, parseC64Address, textToPetscii } from "#src/c64.ts";
8
- import { autostartLoaded, LOAD_LIMIT_FRAMES } from "#src/host-client/autostart.ts";
7
+ import { COVERAGE, formatC64Address, parseC64Address } from "#src/c64.ts";
8
+ import { autostartLoaded, LOAD_LIMIT_FRAMES, RUN_LIMIT_FRAMES, typeRun } from "#src/host-client/autostart.ts";
9
9
  import { readCoverage } from "#src/host-client/coverage.ts";
10
10
  import { ACTION_STEP_KEYS, ACTION_STEP_KINDS, checkActionStep, isObject, runActionStep, type ActionStep } from "#src/host-client/steps.ts";
11
11
  import { WireFailure } from "#src/host-client/tools.ts";
@@ -113,7 +113,8 @@ export async function loads(program: string, options: { out: string; steps: Load
113
113
  previous.fill(0, load, Math.min(0x10000, load + bytes.length - 2));
114
114
  loadedProgram = bytes;
115
115
  await client.memmap({ action: "clear" });
116
- await client.keyboard(textToPetscii("RUN\n"));
116
+ // RUN as c64-testing types it: the steps start at the same time as a scenario's (D62).
117
+ if (!(await typeRun(client))) return { output: { loaded: false, reason: `After RUN, BASIC ran no program line in ${RUN_LIMIT_FRAMES} frames.` }, failed: true };
117
118
  } else {
118
119
  // A disk, tape or cartridge: VICE loads and starts it. The RAM test of the KERNAL puts back what it writes.
119
120
  previous = await readRam(client, 0x0000, 0x10000);
@@ -14,10 +14,19 @@ export const IO_AREAS = { vic: [0xd000, 0x2f], sid: [0xd400, 0x1d], cia1: [0xdc0
14
14
  /** Frames from a hard reset to the READY prompt, with margin. */
15
15
  export const READY_FRAMES = 200;
16
16
 
17
+ /** What no register read of a CIA gives (D63). */
18
+ export interface CiaHidden {
19
+ latchA: number;
20
+ latchB: number;
21
+ interruptMask: number;
22
+ }
23
+
17
24
  export interface MachineState {
18
25
  port: Buffer;
19
26
  pages: Buffer;
20
27
  io: Record<keyof typeof IO_AREAS, Buffer>;
28
+ /** The timer latches and the interrupt mask of each CIA. */
29
+ cia: { cia1: CiaHidden; cia2: CiaHidden };
21
30
  romVectors: Buffer;
22
31
  }
23
32
 
@@ -36,8 +45,9 @@ export async function machineState(client: ViceSessionClient): Promise<MachineSt
36
45
  const pages = await readRam(client, 0x0000, 0x400);
37
46
  const io = {} as Record<keyof typeof IO_AREAS, Buffer>;
38
47
  for (const [name, [address, size]] of Object.entries(IO_AREAS)) io[name as keyof typeof IO_AREAS] = Buffer.from((await client.ioRead(address, size)).data, "hex");
48
+ const [cia1, cia2] = (await client.cia("both")).chips.map(({ latchA, latchB, interruptMask }) => ({ latchA, latchB, interruptMask }));
39
49
  const romVectors = Buffer.from((await client.memoryRead({ address: 0xfffa, size: 6, space: "c64", view: "cpu" })).data, "hex");
40
- return { port, pages, io, romVectors };
50
+ return { port, pages, io, cia: { cia1: cia1!, cia2: cia2! }, romVectors };
41
51
  }
42
52
 
43
53
  const word = (bytes: Buffer, offset: number) => formatC64Address(bytes[offset]! | (bytes[offset + 1]! << 8));
@@ -60,6 +70,8 @@ export function entryState(registers: Registers, state: MachineState, vectors: R
60
70
  cpuPort: { $00: state.port[0], $01: state.port[1] },
61
71
  pages: Object.fromEntries([0, 1, 2, 3].map((page) => [formatC64Address(page << 8), state.pages.subarray(page << 8, (page + 1) << 8).toString("hex")])),
62
72
  io: Object.fromEntries(Object.entries(IO_AREAS).map(([name, [address]]) => [name, { from: formatC64Address(address), bytes: state.io[name as keyof typeof IO_AREAS].toString("hex") }])),
73
+ // The CIA latches and interrupt masks: write-only on the chip, so the I/O bytes do not hold them (D63).
74
+ cia: state.cia,
63
75
  vectors,
64
76
  ...(differsFromReset === undefined ? {} : { differsFromReset }),
65
77
  };
@@ -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";
@@ -107,10 +107,8 @@ async function trace(program: string, options: { frames: number; entry?: number;
107
107
  const client = await ViceSessionClient.open({ videoStandard: "pal" });
108
108
  try {
109
109
  // Loaded but not started, so the memory map can start empty: the KERNAL start-up writes all of RAM.
110
- 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 };
111
- await client.memmap({ action: "clear" });
112
- if (options.entry !== undefined) await client.registersSet("c64", { pc: options.entry });
113
- 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 };
114
112
  const stepFailed = await runSteps(client, options.steps);
115
113
  if (stepFailed !== undefined) return { output: { traced: false, reason: stepFailed }, failed: true };
116
114
  let ran = 0;
@@ -191,6 +189,26 @@ const SYS_FRAMES = 50;
191
189
  * The first PC in the program after its BASIC text, for a SYS whose address
192
190
  * BASIC computes: an exec breakpoint over that part of the program stops there.
193
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
+
194
212
  async function firstPcAfterBasic(client: ViceSessionClient, from: number, to: number): Promise<number | undefined> {
195
213
  return from > to ? undefined : firstPcIn(client, [{ start: from, end: to }]);
196
214
  }
@@ -246,10 +264,11 @@ async function follow(
246
264
  const load = bytes[0]! | (bytes[1]! << 8);
247
265
  const basic = basicStart(bytes);
248
266
  if (options.entry === undefined && basic === undefined) throw new UsageError(NO_BASIC);
249
- 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.` };
250
- await client.memmap({ action: "clear" });
251
- if (options.entry !== undefined) await client.registersSet("c64", { pc: options.entry });
252
- else await client.keyboard(textToPetscii("RUN\n"));
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 });
253
272
  const found: Stage[] = [];
254
273
  const start = options.entry ?? basic!.sys?.address ?? (await firstPcAfterBasic(client, basic!.basicEnd, load + bytes.length - 3));
255
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>.` };
@@ -311,7 +330,7 @@ async function follow(
311
330
  await client.snapshot({ action: "save", name: CALL_SNAPSHOT });
312
331
  const point = await client.breakpoint({ action: "add", address: back, space: "c64" });
313
332
  try {
314
- const run = await client.execution({ action: "advance-frames", count: frames, space: "c64" });
333
+ const run = await client.execution({ action: "advance-frames", count: frames, space: "c64", ...framed() });
315
334
  if ((run.pc ?? (await client.registersGet("c64")).pc) === back) return run.advancedFrames ?? 0;
316
335
  await client.snapshot({ action: "restore", name: CALL_SNAPSHOT });
317
336
  return undefined;
@@ -330,7 +349,7 @@ async function follow(
330
349
  }
331
350
  const written = (address: number) => ((stage.map[address]! | map[address]!) & COVERAGE.ramWrite) !== 0;
332
351
  await arm(stagePoints(candidates, stage.own, written, stage.called));
333
- const run = await client.execution({ action: "advance-frames", count: left, space: "c64" });
352
+ const run = await client.execution({ action: "advance-frames", count: left, space: "c64", ...framed() });
334
353
  const advanced = run.advancedFrames ?? 0;
335
354
  used += advanced;
336
355
  left -= advanced;
@@ -365,13 +384,21 @@ async function follow(
365
384
 
366
385
  /** Tracks the stages for some frames; true when the CPU got to stopAt. */
367
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> => {
368
395
  let left = frames;
369
396
  let stopped = false;
370
397
  while (left > 0 && !ended && !stopped && found.length < MAX_STAGES) {
371
398
  await client.snapshot({ action: "save", name: STAGE_SNAPSHOT });
372
399
  await client.memmap({ action: "clear" });
373
400
  const chunk = Math.min(STAGE_CHUNK_FRAMES, left);
374
- 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() });
375
402
  const firstPc = first.pc ?? (await client.registersGet("c64")).pc;
376
403
  const map = await readCoverage(client);
377
404
  const candidates = stageCandidates(stage.map, map, stage.own, options.minStage, stage.called);
package/src/c64.ts CHANGED
@@ -163,7 +163,9 @@ const MIN_BASIC_LOAD = 0x0400;
163
163
  * zero, the "marker" is the first machine code after the BASIC text (SYS
164
164
  * lines that jump right behind themselves). The stored links are not read:
165
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).
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).
167
169
  */
168
170
  export function parseTokenized(program: Uint8Array): TokenizedProgram {
169
171
  if (program.length < 4) return { reason: "The file is too short for a BASIC program." };
@@ -171,6 +173,9 @@ export function parseTokenized(program: Uint8Array): TokenizedProgram {
171
173
  if (loadAddress < MIN_BASIC_LOAD) {
172
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.` };
173
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
+ }
174
179
  const lines: TokenizedLine[] = [];
175
180
  let at = 2;
176
181
  const ended = (basicEnd: number): TokenizedProgram => ({ loadAddress, basicEnd, textBytes: at, lines });
@@ -6,6 +6,7 @@ import { type Comparison, type Condition, type BacktraceFrame, type HistoryEntry
6
6
  import { COVERAGE, formatC64Address, formatC64Byte } from "../../c64.ts";
7
7
  import { decodeDisplay, decodePalette, visibleFrame, type IndexedFrame } from "./screen.ts";
8
8
  import type { TextMonitor } from "./text-monitor.ts";
9
+ import type { CiaHidden } from "./chips.ts";
9
10
  import {
10
11
  BinaryMonitor,
11
12
  Command,
@@ -431,6 +432,19 @@ export class ViceAdapter {
431
432
  return bytes.toString("hex");
432
433
  }
433
434
 
435
+ /**
436
+ * The timer latches and the interrupt mask of the CIA at `base`, from VICE's
437
+ * text-monitor dump: no register read gives them (D63).
438
+ */
439
+ async ciaHidden(base: 0xdc00 | 0xdd00): Promise<CiaHidden> {
440
+ const answer = await this.#computerText(`io ${hex4(base)}`);
441
+ const mask = /\(mask: ([0-9a-f]{2})\)/i.exec(answer);
442
+ const latchA = /Timer A: [0-9a-f]{4} \(latched ([0-9a-f]{4})\)/i.exec(answer);
443
+ const latchB = /Timer B: [0-9a-f]{4} \(latched ([0-9a-f]{4})\)/i.exec(answer);
444
+ if (mask === null || latchA === null || latchB === null) throw unknownForm("CIA dump", answer.split("\n"));
445
+ return { latchA: Number.parseInt(latchA[1]!, 16), latchB: Number.parseInt(latchB[1]!, 16), interruptMask: Number.parseInt(mask[1]!, 16) & 0x1f };
446
+ }
447
+
434
448
  /** Reads C64 I/O registers through the I/O bank, whatever the CPU has banked in, without side effects. */
435
449
  async readIo(start: number, size: number): Promise<Uint8Array> {
436
450
  const response = await this.#monitor.request(
@@ -57,8 +57,15 @@ function bcd(value: number): number {
57
57
  return (value >> 4) * 10 + (value & 0x0f);
58
58
  }
59
59
 
60
+ /** The parts of a CIA that no register read gives: the timer latches and the interrupt mask. */
61
+ export interface CiaHidden {
62
+ latchA: number;
63
+ latchB: number;
64
+ interruptMask: number;
65
+ }
66
+
60
67
  /** `registers` holds $dc00-$dc0f or $dd00-$dd0f. TOD hours become 0-23. */
61
- export function decodeCia(id: 1 | 2, registers: Uint8Array): CiaState {
68
+ export function decodeCia(id: 1 | 2, registers: Uint8Array, hidden: CiaHidden): CiaState {
62
69
  const word = (low: number) => registers[low]! | (registers[low + 1]! << 8);
63
70
  const hourRegister = registers[0x0b]!;
64
71
  const pm = (hourRegister & 0x80) !== 0;
@@ -70,9 +77,12 @@ export function decodeCia(id: 1 | 2, registers: Uint8Array): CiaState {
70
77
  ddrB: registers[0x03]!,
71
78
  timerA: word(0x04),
72
79
  timerB: word(0x06),
80
+ latchA: hidden.latchA,
81
+ latchB: hidden.latchB,
73
82
  controlA: registers[0x0e]!,
74
83
  controlB: registers[0x0f]!,
75
84
  interruptStatus: registers[0x0d]!,
85
+ interruptMask: hidden.interruptMask,
76
86
  tod: {
77
87
  hours: (bcd(hourRegister & 0x1f) % 12) + (pm ? 12 : 0),
78
88
  minutes: bcd(registers[0x0a]! & 0x7f),
@@ -4,6 +4,7 @@
4
4
  import { crc32, deflateSync } from "node:zlib";
5
5
 
6
6
  import type { VideoStandard } from "../../protocol/messages.ts";
7
+ import { FRAME_SIZE } from "../../protocol/vice.ts";
7
8
 
8
9
  export interface IndexedFrame {
9
10
  width: number;
@@ -44,8 +45,8 @@ export function decodeDisplay(body: Buffer): RawDisplay {
44
45
  * offsets: PAL 384×272, NTSC 384×247.
45
46
  */
46
47
  const VISIBLE: Record<VideoStandard, { dx: number; dy: number; width: number; height: number }> = {
47
- pal: { dx: -32, dy: -36, width: 384, height: 272 },
48
- ntsc: { dx: -32, dy: -23, width: 384, height: 247 },
48
+ pal: { dx: -32, dy: -36, ...FRAME_SIZE.pal },
49
+ ntsc: { dx: -32, dy: -23, ...FRAME_SIZE.ntsc },
49
50
  };
50
51
 
51
52
  /** Crops the visible bordered frame out of VICE's full raster buffer. */
@@ -381,12 +381,21 @@ export class ViceSession implements ViceSessionHandle {
381
381
  this.#cartridge = false;
382
382
  this.#replaceStaged("cartridge", undefined);
383
383
  }
384
+ // Port 1 shares the keyboard lines: a held direction types keys into the RUN that the autostart types (R3-05).
385
+ const centred: Array<1 | 2> = [];
386
+ for (const [port, joystick] of this.#joysticks) {
387
+ if (joystick.direction === "center" && !joystick.fire) continue;
388
+ const centre: JoystickState = { port, direction: "center", fire: false };
389
+ await this.#observe(() => this.#machine.setJoystick(centre));
390
+ this.#joysticks.set(port, centre);
391
+ centred.push(port);
392
+ }
384
393
  await this.#runningCommand(() => this.#machine.autostart(file, index, run));
385
394
  if (type === "crt") this.#cartridge = true;
386
395
  this.#replaceStaged(stagedKind(type), file);
387
396
  // A reset sets the CPU clock back, so the stopwatch starts again.
388
397
  this.#timingStart = 0n;
389
- return { state: "running", ...(type === "tap" && this.#inputFlaw !== undefined ? { emulatorFlaw: this.#inputFlaw } : {}) };
398
+ return { state: "running", ...(type === "tap" && this.#inputFlaw !== undefined ? { emulatorFlaw: this.#inputFlaw } : {}), ...(centred.length === 0 ? {} : { joysticksCentred: centred }) };
390
399
  }
391
400
  case "diskAttach": {
392
401
  const { type } = params as ViceOperations["diskAttach"]["params"];
@@ -498,8 +507,7 @@ export class ViceSession implements ViceSessionHandle {
498
507
  async #status(): Promise<MachineStatus> {
499
508
  const warp = await this.#observe(() => this.#machine.warp());
500
509
  // Read after #observe: it resumes a running machine, unless a breakpoint or a JAM stopped it meanwhile.
501
- const status: MachineStatus = { state: this.#state, videoStandard: this.#videoStandard, warp, window: this.#mode === "window" };
502
- if (this.#cartridge) status.cartridge = true;
510
+ const status: MachineStatus = { state: this.#state, videoStandard: this.#videoStandard, warp, window: this.#mode === "window", cartridge: this.#cartridge };
503
511
  if (status.state === "stopped") status.pc = await this.#pc();
504
512
  return status;
505
513
  }
@@ -526,7 +534,11 @@ export class ViceSession implements ViceSessionHandle {
526
534
  }
527
535
  if (params.action === "advance-frames") {
528
536
  if (params.count === undefined) throw new WireFailure("invalid-input", "advance-frames needs count, the number of frames");
529
- const outcome = await this.#runFrames(params.count);
537
+ const raster = RASTER[this.#videoStandard];
538
+ if (params.anchor !== undefined && (params.anchor.line >= raster.lines || params.anchor.cycle >= raster.cycles)) {
539
+ 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}.`);
540
+ }
541
+ const outcome = await this.#runFrames(params.count, undefined, params.anchor);
530
542
  return { state: "stopped", pc: outcome.pc, advancedFrames: outcome.frames };
531
543
  }
532
544
  await this.#stop();
@@ -588,10 +600,11 @@ export class ViceSession implements ViceSessionHandle {
588
600
  * checkpoint, a user breakpoint or watchpoint, or a JAM ends the run early.
589
601
  * The machine is stopped afterwards.
590
602
  */
591
- async #runFrames(limit: number, target?: { checkpoint: number; deferred: boolean }): Promise<{ end: RunEnd; frames: number; pc: number; space: "c64" | "drive8" }> {
603
+ async #runFrames(limit: number, target?: { checkpoint: number; deferred: boolean }, anchor?: { line: number; cycle: number }): Promise<{ end: RunEnd; frames: number; pc: number; space: "c64" | "drive8" }> {
592
604
  await this.#stop();
593
605
  const lines = RASTER[this.#videoStandard].lines;
594
- const start = await this.#machine.rasterPosition();
606
+ // A frame ends where the raster passes the anchor; without one, where this run starts.
607
+ const start = anchor ?? (await this.#machine.rasterPosition());
595
608
  const arming = rasterLinesExpression((start.line + Math.floor(lines / 2)) % lines, lines);
596
609
  const ending = rasterWindowExpression(start.line, start.cycle, lines);
597
610
  const clock = await this.#machine.addCheckpoint({ start: 0x0000, end: 0xffff, operation: 0x04, space: "c64" });
@@ -920,7 +933,10 @@ export class ViceSession implements ViceSessionHandle {
920
933
  async #cias(which: CiaSelection): Promise<CiaState[]> {
921
934
  const ids: Array<1 | 2> = which === "both" ? [1, 2] : [Number(which) as 1 | 2];
922
935
  const chips = [];
923
- for (const id of ids) chips.push(decodeCia(id, await this.#machine.readIo(id === 1 ? 0xdc00 : 0xdd00, 0x10)));
936
+ for (const id of ids) {
937
+ const base = id === 1 ? 0xdc00 : 0xdd00;
938
+ chips.push(decodeCia(id, await this.#machine.readIo(base, 0x10), await this.#machine.ciaHidden(base)));
939
+ }
924
940
  return chips;
925
941
  }
926
942
 
@@ -36,10 +36,20 @@ export const RUN_LIMIT_FRAMES = 300;
36
36
  */
37
37
  export async function runProgram(client: ViceSessionClient, path: string, prg: Uint8Array): Promise<"not-loaded" | "not-started" | undefined> {
38
38
  if (!(await autostartLoaded(client, path, prg))) return "not-loaded";
39
+ return (await typeRun(client)) ? undefined : "not-started";
40
+ }
41
+
42
+ /**
43
+ * Types RUN at the READY prompt and stops where BASIC starts the first line,
44
+ * before its SYS. Every tool that starts a loaded program this way reaches
45
+ * its entry at the same raster line and timer values (D62). False when BASIC
46
+ * runs no line within RUN_LIMIT_FRAMES.
47
+ */
48
+ export async function typeRun(client: ViceSessionClient): Promise<boolean> {
39
49
  await client.keyboard(textToPetscii("RUN\n"));
40
50
  const started = await client.runUntil({
41
51
  target: { kind: "memory", address: CURLIN_HIGH, operator: "ne", value: 0xff, space: "c64", view: "ram" },
42
52
  timeoutFrames: RUN_LIMIT_FRAMES,
43
53
  });
44
- return started.reached ? undefined : "not-started";
54
+ return started.reached;
45
55
  }
@@ -86,4 +86,4 @@ export async function hostTools(options: ToolCallOptions = {}): Promise<{ host:
86
86
  /** Skill scripts see failures and wire limits through the host-client, never the private protocol module. */
87
87
  export { WireFailure } from "../protocol/messages.ts";
88
88
  export { DISK_ACTIONS } from "../protocol/tools.ts";
89
- export { JOYSTICK_DIRECTIONS, MAX_BASELINES, MAX_EXECUTION_COUNT, MAX_KEYBOARD_BYTES, MAX_MASK_RECTANGLES, MAX_MEMORY_READ, MAX_MEMORY_WRITE, MAX_TIMEOUT_FRAMES } from "../protocol/vice.ts";
89
+ export { JOYSTICK_DIRECTIONS, MAX_BASELINES, MAX_EXECUTION_COUNT, MAX_KEYBOARD_BYTES, MAX_MASK_RECTANGLES, MAX_MEMORY_READ, MAX_MEMORY_WRITE, MAX_TIMEOUT_FRAMES, maskCoversFrame } from "../protocol/vice.ts";
@@ -41,7 +41,8 @@ export const c64Breakpoint = defineTool({
41
41
  description:
42
42
  "Manage breakpoints. A breakpoint stops the CPU when it is about to execute the instruction at an address. " +
43
43
  "Action add sets one at address in space c64 or drive8. With a condition, it stops only when the condition is true. " +
44
- "add returns the new id. Actions remove, enable and disable take that id. remove returns removed true and the id. Action list gives all breakpoints. Each breakpoint shows its condition, when it has one. " +
44
+ "add returns the new id. Breakpoints and watchpoints take their ids from one counter, so the ids of one kind can have gaps. " +
45
+ "Actions remove, enable and disable take that id. remove returns removed true and the id. Action list gives all breakpoints. Each breakpoint shows its condition, when it has one. " +
45
46
  "Breakpoints stay through a reset.",
46
47
  inputSchema: z
47
48
  .object({
@@ -84,7 +85,8 @@ export const c64Watchpoint = defineTool({
84
85
  "Manage watchpoints. A watchpoint stops the CPU when an instruction reads or writes memory in a range. " +
85
86
  `Action add sets one at address for size bytes (1 to ${MAX_WATCH_SIZE}, default 1), with access read, write or read-write. ` +
86
87
  "With a condition, it stops only when the condition is true. For a write, a memory condition sees the memory after the write. " +
87
- "add returns the new id. Actions remove, enable and disable take that id. remove returns removed true and the id. Action list gives all watchpoints. Each watchpoint shows its condition, when it has one.",
88
+ "add returns the new id. Breakpoints and watchpoints take their ids from one counter, so the ids of one kind can have gaps. " +
89
+ "Actions remove, enable and disable take that id. remove returns removed true and the id. Action list gives all watchpoints. Each watchpoint shows its condition, when it has one.",
88
90
  inputSchema: z
89
91
  .object({
90
92
  action: z.enum(CHECKPOINT_ACTIONS),
@@ -205,7 +207,8 @@ export const c64Profile = defineTool({
205
207
  name: "c64_profile",
206
208
  title: "Cycle profile",
207
209
  description:
208
- "Get the routines where the C64 CPU spent the most cycles since the session started. " +
210
+ "Get the routines where the C64 CPU spent the most cycles since the session started or since the last c64_window. " +
211
+ "After c64_window or a snapshot restore, the emulator can give the cycles to a routine that ran before the restore, for example $fd50. Do not trust the routine addresses then. " +
209
212
  `limit is the maximum number of routines (1 to ${MAX_PROFILE}, default 20). ` +
210
213
  "Each entry gives the routine address, its total cycles with the routines it calls, and its own cycles. " +
211
214
  "percent is the part of all cycles that the routine used itself. " +
@@ -22,7 +22,7 @@ export const c64Status = defineTool({
22
22
  warp: z.boolean(),
23
23
  window: z.boolean().describe("true when the emulator window is open (c64_window)"),
24
24
  pc: AddressOutput.optional().describe("program counter. Only when the CPU is stopped"),
25
- cartridge: z.literal(true).optional().describe("true while a cartridge is attached. Absent when none is"),
25
+ cartridge: z.boolean().describe("true while a cartridge from c64_autostart is attached"),
26
26
  }),
27
27
  readOnly: true,
28
28
  async run(_input, session) {
@@ -32,8 +32,8 @@ export const c64Status = defineTool({
32
32
  videoStandard: status.videoStandard,
33
33
  warp: status.warp,
34
34
  window: status.window,
35
+ cartridge: status.cartridge,
35
36
  ...(status.pc === undefined ? {} : { pc: formatC64Address(status.pc) }),
36
- ...(status.cartridge === undefined ? {} : { cartridge: status.cartridge }),
37
37
  };
38
38
  },
39
39
  });
@@ -21,6 +21,7 @@ export const c64Autostart = defineTool({
21
21
  `Start a program, or a disk, tape or cartridge image, from the project (${list(AUTOSTART_TYPES)}). ` +
22
22
  "The C64 resets and loads the file. With run true (the default), it also runs the file. A disk image stays in drive 8. " +
23
23
  "A cartridge stays in the C64 through every reset, as on a real C64. An autostart of a file that is not a cartridge detaches it first. " +
24
+ "Both joysticks are centred first: joystick port 1 uses the keyboard lines, and a held direction changes the RUN that the autostart types. " +
24
25
  "Without index, a disk image loads like LOAD\"*\",8,1. index selects one directory entry by its number in the c64-disk directory list (0 is the first entry). " +
25
26
  "The machine runs when this tool returns, and the load continues.",
26
27
  inputSchema: z
@@ -33,6 +34,7 @@ export const c64Autostart = defineTool({
33
34
  outputSchema: z.object({
34
35
  state: z.enum(RUN_STATES),
35
36
  emulatorFlaw: z.string().optional().describe("for a tape image: a known flaw of this VICE version that stops a tape load"),
37
+ joysticksCentred: z.array(z.union([z.literal(1), z.literal(2)])).optional().describe("the joystick ports that held a direction or fire and were centred before the reset"),
36
38
  }),
37
39
  readOnly: false,
38
40
  async run(input, session) {
@@ -74,9 +74,12 @@ const CiaOutput = z.object({
74
74
  ddrB: Byte,
75
75
  timerA: z.number().int().min(0).max(0xffff),
76
76
  timerB: z.number().int().min(0).max(0xffff),
77
+ latchA: z.number().int().min(0).max(0xffff).describe("the value that timer A loads when it underflows"),
78
+ latchB: z.number().int().min(0).max(0xffff).describe("the value that timer B loads when it underflows"),
77
79
  controlA: Byte,
78
80
  controlB: Byte,
79
81
  interruptStatus: Byte,
82
+ interruptMask: z.number().int().min(0).max(0x1f).describe("the interrupt sources that are on, as bits 0-4 of the interrupt control register"),
80
83
  tod: z.object({ hours: z.number().int(), minutes: z.number().int(), seconds: z.number().int(), tenths: z.number().int() }),
81
84
  });
82
85
 
@@ -84,8 +87,8 @@ export const c64Cia = defineTool({
84
87
  name: "c64_cia",
85
88
  title: "CIA state",
86
89
  description:
87
- "Get the state of CIA 1, CIA 2 or both. The state has the ports, the data direction registers and the current timer values. " +
88
- `It also has the control registers, the interrupt flags and the time of day (hours 0 to 23). ${NO_CHANGE}`,
90
+ "Get the state of CIA 1, CIA 2 or both. The state has the ports, the data direction registers, the current timer values and the timer latches. " +
91
+ `It also has the control registers, the interrupt flags, the interrupt mask and the time of day (hours 0 to 23). ${NO_CHANGE}`,
89
92
  inputSchema: z.object({ cia: z.enum(CIA_SELECTIONS).default("both") }).strict(),
90
93
  outputSchema: z.object({ chips: z.array(CiaOutput) }),
91
94
  readOnly: true,
@@ -168,9 +168,14 @@ export interface CiaState {
168
168
  ddrB: number;
169
169
  timerA: number;
170
170
  timerB: number;
171
+ /** The values that a timer loads at an underflow: write-only on the chip, from VICE's dump (D63). */
172
+ latchA: number;
173
+ latchB: number;
171
174
  controlA: number;
172
175
  controlB: number;
173
176
  interruptStatus: number;
177
+ /** The interrupt sources that are on: bits 0-4 of a write to $xd0d with bit 7 set. */
178
+ interruptMask: number;
174
179
  tod: { hours: number; minutes: number; seconds: number; tenths: number };
175
180
  }
176
181
 
@@ -256,6 +261,24 @@ export interface Rectangle {
256
261
  height: number;
257
262
  }
258
263
 
264
+ /** The visible frame with normal borders that a screen capture holds, in pixels. */
265
+ export const FRAME_SIZE: Record<VideoStandard, { width: number; height: number }> = {
266
+ pal: { width: 384, height: 272 },
267
+ ntsc: { width: 384, height: 247 },
268
+ };
269
+
270
+ /** True when the mask rectangles leave no pixel of the frame to compare. */
271
+ export function maskCoversFrame(mask: readonly Rectangle[], standard: VideoStandard): boolean {
272
+ const { width, height } = FRAME_SIZE[standard];
273
+ const masked = new Uint8Array(width * height);
274
+ for (const rectangle of mask) {
275
+ for (let y = rectangle.y; y < Math.min(height, rectangle.y + rectangle.height); y++) {
276
+ if (rectangle.x < width) masked.fill(1, y * width + rectangle.x, y * width + Math.min(width, rectangle.x + rectangle.width));
277
+ }
278
+ }
279
+ return !masked.includes(0);
280
+ }
281
+
259
282
  export interface ScreenComparison {
260
283
  match: boolean;
261
284
  mismatchingPixels: number;
@@ -312,8 +335,8 @@ export interface MachineStatus {
312
335
  window: boolean;
313
336
  /** Present only while the C64 CPU is stopped. */
314
337
  pc?: number;
315
- /** Present, true, while a cartridge from an autostart is attached. */
316
- cartridge?: true;
338
+ /** True while a cartridge from an autostart is attached (D64). */
339
+ cartridge: boolean;
317
340
  }
318
341
 
319
342
  export interface MemoryReadParams {
@@ -370,6 +393,12 @@ export interface ExecutionParams {
370
393
  /** step and next: defaults to 1; advance-frames: required. */
371
394
  count?: number;
372
395
  space: Space;
396
+ /**
397
+ * advance-frames only: the raster position that ends a frame, instead of the
398
+ * position where this call starts. Runs that stop between frames then keep
399
+ * the frames of one timeline (D62).
400
+ */
401
+ anchor?: { line: number; cycle: number };
373
402
  }
374
403
 
375
404
  export interface ExecutionResult {
@@ -421,8 +450,11 @@ export interface ViceOperations {
421
450
  /** Attachment: the PRG bytes, load address first. */
422
451
  programLoad: { params: { address?: number }; result: { state: RunState; loadAddress: number; size: number } };
423
452
  /** Attachment: the program or image bytes; `type` is the file's extension. `index` is VICE's file number: 0 loads "*", 1 is the first directory entry. */
424
- /** `emulatorFlaw`: for a tape image, a known flaw of the host's VICE version that a tape load meets (D56). */
425
- autostart: { params: { type: string; index: number; run: boolean }; result: { state: RunState; emulatorFlaw?: string } };
453
+ /**
454
+ * `emulatorFlaw`: for a tape image, a known flaw of the host's VICE version that a tape load meets (D56).
455
+ * `joysticksCentred`: the control ports that held a direction or fire and were centred first (R3-05).
456
+ */
457
+ autostart: { params: { type: string; index: number; run: boolean }; result: { state: RunState; emulatorFlaw?: string; joysticksCentred?: Array<1 | 2> } };
426
458
  /** Attachment: the disk image bytes; `type` is the file's extension. */
427
459
  diskAttach: { params: { type: string }; result: { attached: boolean } };
428
460
  /** pc is the reset vector when run is false: the CPU stops there. */
@@ -748,7 +780,7 @@ export function validateViceParams<O extends ViceOperation>(op: O, params: unkno
748
780
  return { space: params.space, values: result } as ViceOperations[O]["params"];
749
781
  }
750
782
  case "execution": {
751
- onlyFields(params, ["action", "space", "count"]);
783
+ onlyFields(params, ["action", "space", "count", "anchor"]);
752
784
  if (!isOneOf(EXECUTION_ACTIONS, params.action)) invalid(`action must be one of ${EXECUTION_ACTIONS.join(", ")}`);
753
785
  if (!isOneOf(SPACES, params.space)) invalid("space must be c64 or drive8");
754
786
  const counted = params.action === "step" || params.action === "next" || params.action === "advance-frames";
@@ -760,6 +792,13 @@ export function validateViceParams<O extends ViceOperation>(op: O, params: unkno
760
792
  }
761
793
  const result: ExecutionParams = { action: params.action, space: params.space };
762
794
  if (counted) result.count = (params.count as number | undefined) ?? 1;
795
+ if (params.anchor !== undefined) {
796
+ if (params.action !== "advance-frames") invalid(`anchor is not used with action ${params.action}`);
797
+ const anchor = params.anchor;
798
+ if (!isObject(anchor) || !isInteger(anchor.line, 0, 511) || !isInteger(anchor.cycle, 0, 127)) invalid("anchor needs line from 0 to 511 and cycle from 0 to 127");
799
+ onlyFields(anchor as Record<string, unknown>, ["line", "cycle"]);
800
+ result.anchor = { line: anchor.line as number, cycle: anchor.cycle as number };
801
+ }
763
802
  return result as ViceOperations[O]["params"];
764
803
  }
765
804
  case "runUntil": {
@@ -972,7 +1011,7 @@ export function validateViceResult<O extends ViceOperation>(op: O, value: unknow
972
1011
  typeof value.warp === "boolean" &&
973
1012
  typeof value.window === "boolean" &&
974
1013
  (value.pc === undefined || isInteger(value.pc, 0, 0xffff)) &&
975
- (value.cartridge === undefined || value.cartridge === true);
1014
+ typeof value.cartridge === "boolean";
976
1015
  if (!ok) throw new ProtocolError("status result is malformed");
977
1016
  break;
978
1017
  }
@@ -1059,6 +1098,9 @@ export function validateViceResult<O extends ViceOperation>(op: O, value: unknow
1059
1098
  if (!isOneOf(RUN_STATES, value.state)) throw new ProtocolError(`${op} result is malformed`);
1060
1099
  if (op === "reset" && value.pc !== undefined && !isInteger(value.pc, 0, 0xffff)) throw new ProtocolError("reset result is malformed");
1061
1100
  if (op === "autostart" && !(value.emulatorFlaw === undefined || typeof value.emulatorFlaw === "string")) throw new ProtocolError("autostart result is malformed");
1101
+ if (op === "autostart" && !(value.joysticksCentred === undefined || (Array.isArray(value.joysticksCentred) && value.joysticksCentred.every((port) => port === 1 || port === 2)))) {
1102
+ throw new ProtocolError("autostart result is malformed");
1103
+ }
1062
1104
  break;
1063
1105
  }
1064
1106
  case "programLoad": {
@@ -1235,8 +1277,8 @@ export function validateViceResult<O extends ViceOperation>(op: O, value: unknow
1235
1277
  isObject(chip) &&
1236
1278
  (chip.id === 1 || chip.id === 2) &&
1237
1279
  (["portA", "portB", "ddrA", "ddrB", "controlA", "controlB", "interruptStatus"] as const).every((name) => isInteger(chip[name], 0, 0xff)) &&
1238
- isInteger(chip.timerA, 0, 0xffff) &&
1239
- isInteger(chip.timerB, 0, 0xffff) &&
1280
+ isInteger(chip.interruptMask, 0, 0x1f) &&
1281
+ (["timerA", "timerB", "latchA", "latchB"] as const).every((name) => isInteger(chip[name], 0, 0xffff)) &&
1240
1282
  isObject(chip.tod) &&
1241
1283
  isInteger(chip.tod.hours, 0, 23) &&
1242
1284
  isInteger(chip.tod.minutes, 0, 99) &&