@henols/c64-re-tools 2.0.0-rc.7 → 2.0.0-rc.9

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 (36) hide show
  1. package/README.md +3 -1
  2. package/package.json +1 -1
  3. package/skills/c64-basic/SKILL.md +1 -1
  4. package/skills/c64-basic/scripts/basic.ts +1 -1
  5. package/skills/c64-emulator/SKILL.md +2 -1
  6. package/skills/c64-knowledge/SKILL.md +1 -1
  7. package/skills/c64-knowledge/scripts/knowledge.ts +5 -2
  8. package/skills/c64-provenance/SKILL.md +4 -1
  9. package/skills/c64-provenance/scripts/compare.ts +9 -6
  10. package/skills/c64-provenance/scripts/provenance.ts +13 -7
  11. package/skills/c64-source/SKILL.md +6 -5
  12. package/skills/c64-source/scripts/generate.ts +19 -0
  13. package/skills/c64-source/scripts/loader.ts +172 -7
  14. package/skills/c64-source/scripts/source.ts +7 -3
  15. package/skills/c64-static-analysis/SKILL.md +1 -1
  16. package/skills/c64-static-analysis/scripts/idioms.ts +2 -2
  17. package/skills/c64-testing/references/scenario-format.md +1 -1
  18. package/skills/c64-testing/scripts/scenario.ts +7 -5
  19. package/skills/c64-unpacker/SKILL.md +2 -2
  20. package/skills/c64-unpacker/scripts/loads.ts +4 -3
  21. package/skills/c64-unpacker/scripts/machine.ts +13 -1
  22. package/skills/c64-unpacker/scripts/unpack.ts +40 -13
  23. package/src/c64.ts +6 -1
  24. package/src/host/main.ts +27 -6
  25. package/src/host/vice/adapter.ts +14 -0
  26. package/src/host/vice/chips.ts +11 -1
  27. package/src/host/vice/screen.ts +3 -2
  28. package/src/host/vice/session.ts +23 -7
  29. package/src/host-client/autostart.ts +11 -1
  30. package/src/host-client/tools.ts +1 -1
  31. package/src/mcp/tools/debug.ts +6 -3
  32. package/src/mcp/tools/machine.ts +2 -2
  33. package/src/mcp/tools/media.ts +2 -0
  34. package/src/mcp/tools/video.ts +5 -2
  35. package/src/protocol/vice.ts +50 -8
  36. package/src/trace.ts +55 -23
@@ -4,7 +4,7 @@
4
4
  // explicit, and the result is PASS, FAIL or INCONCLUSIVE.
5
5
 
6
6
  import { formatC64Address, parseC64Address } from "#src/c64.ts";
7
- import { MAX_BASELINES, MAX_MASK_RECTANGLES, MAX_MEMORY_READ, WireFailure } from "#src/host-client/tools.ts";
7
+ import { MAX_BASELINES, MAX_MASK_RECTANGLES, MAX_MEMORY_READ, maskCoversFrame, WireFailure } from "#src/host-client/tools.ts";
8
8
  import { LOAD_LIMIT_FRAMES, RUN_LIMIT_FRAMES, runProgram } from "#src/host-client/autostart.ts";
9
9
  import { ACTION_STEP_KEYS, checkActionStep, checkLocation, isInteger, isObject, runActionStep, type ActionStep, type Location } from "#src/host-client/steps.ts";
10
10
  import { readProjectFile, writeProjectFile } from "#src/host-client/transfer.ts";
@@ -129,7 +129,7 @@ export function readScenario(path: string): Scenario {
129
129
  const allowed = [...(STEP_KEYS[kind] ?? [kind]), ...(kind === "observe" ? [] : ["side"])];
130
130
  for (const key of Object.keys(step)) if (!allowed.includes(key)) fail(`${at} has the key ${key}, which a ${kind} step does not have`);
131
131
  if (step.side !== undefined && !(SIDES as unknown[]).includes(step.side)) fail(`${at}: side must be original or rebuild`);
132
- checkStep(kind, step, at, fail);
132
+ checkStep(kind, step, at, fail, scenario.videoStandard ?? "pal");
133
133
  if (kind === "observe") {
134
134
  if (labels.has(step.observe as string)) fail(`${at} needs a new checkpoint name`);
135
135
  labels.add(step.observe as string);
@@ -142,7 +142,7 @@ export function readScenario(path: string): Scenario {
142
142
  }
143
143
 
144
144
  /** Checks the value of one step against its kind. */
145
- function checkStep(kind: string, step: Record<string, unknown>, at: string, fail: (message: string) => never): void {
145
+ function checkStep(kind: string, step: Record<string, unknown>, at: string, fail: (message: string) => never, videoStandard: "pal" | "ntsc"): void {
146
146
  switch (kind) {
147
147
  case "reset":
148
148
  if (step.reset !== "hard" && step.reset !== "soft") fail(`${at}: reset must be hard or soft`);
@@ -165,11 +165,11 @@ function checkStep(kind: string, step: Record<string, unknown>, at: string, fail
165
165
  case "runUntil":
166
166
  return checkActionStep(kind, step, at, fail);
167
167
  default:
168
- checkObserve(step, at, fail);
168
+ checkObserve(step, at, fail, videoStandard);
169
169
  }
170
170
  }
171
171
 
172
- function checkObserve(step: Record<string, unknown>, at: string, fail: (message: string) => never): void {
172
+ function checkObserve(step: Record<string, unknown>, at: string, fail: (message: string) => never, videoStandard: "pal" | "ntsc"): void {
173
173
  if (typeof step.observe !== "string" || step.observe === "") fail(`${at} needs a new checkpoint name`);
174
174
  if (step.memory !== undefined) {
175
175
  if (!Array.isArray(step.memory) || step.memory.length === 0) fail(`${at}: memory must be a list of names, $ addresses or objects with at`);
@@ -216,6 +216,8 @@ function checkObserve(step: Record<string, unknown>, at: string, fail: (message:
216
216
  isInteger(rectangle.height, 1, 4096);
217
217
  if (!ok) fail(`${at}: a screen.mask rectangle needs whole numbers x and y from 0, and width and height from 1`);
218
218
  }
219
+ // The emulator refuses such a compare too, but only after the original side ran (b2/03).
220
+ if (maskCoversFrame(screen.mask as Array<{ x: number; y: number; width: number; height: number }>, videoStandard)) fail(`${at}: screen.mask covers the whole screen, so nothing is compared`);
219
221
  }
220
222
  }
221
223
  if (step.vicii !== undefined && typeof step.vicii !== "boolean") fail(`${at}: vicii must be true or false`);
@@ -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 });
package/src/host/main.ts CHANGED
@@ -15,7 +15,7 @@ import { PACKAGE } from "../package.ts";
15
15
  const HELP = `c64-re-tools-host
16
16
 
17
17
  Usage:
18
- npx -y --package=${PACKAGE.name}@${PACKAGE.tag} c64-re-tools-host [--port <port>]
18
+ npx -y --package=${PACKAGE.name}@${PACKAGE.tag} c64-re-tools-host [--port <port>] [--log-console] [--log-dir <directory>]
19
19
  c64-re-tools-host --help
20
20
 
21
21
  Runs the c64-re-tools Host Runtime in the foreground. It first starts VICE
@@ -27,8 +27,15 @@ connected c64-re-tools-mcp process, and runs the tools that come with VICE
27
27
  emulator and tool it started.
28
28
 
29
29
  Options:
30
- --port <port> Listen on this port instead (0 picks a free port).
31
- --help Show this help.`;
30
+ --port <port> Listen on this port instead (0 picks a free port).
31
+ --log-console Also print each request with its result, and each run
32
+ of c1541 or petcat, to the console.
33
+ --log-dir <directory> Write the same events as JSON lines to a file in this
34
+ directory, with a copy of each VICE log.
35
+ --help Show this help.
36
+
37
+ Without --log-console and --log-dir the host logs only its start, its stop,
38
+ connections and failures. C64RT_TRACE does not apply to the host.`;
32
39
 
33
40
  function log(line: string): void {
34
41
  process.stderr.write(`c64-re-tools-host: ${line}\n`);
@@ -36,9 +43,12 @@ function log(line: string): void {
36
43
  }
37
44
 
38
45
  async function main(): Promise<number> {
39
- let values: { help?: boolean; port?: string };
46
+ let values: { help?: boolean; port?: string; "log-console"?: boolean; "log-dir"?: string };
40
47
  try {
41
- ({ values } = parseArgs({ options: { help: { type: "boolean" }, port: { type: "string" } }, strict: true }));
48
+ ({ values } = parseArgs({
49
+ options: { help: { type: "boolean" }, port: { type: "string" }, "log-console": { type: "boolean" }, "log-dir": { type: "string" } },
50
+ strict: true,
51
+ }));
42
52
  } catch (error) {
43
53
  process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n\n${HELP}\n`);
44
54
  return 2;
@@ -47,7 +57,18 @@ async function main(): Promise<number> {
47
57
  process.stdout.write(`${HELP}\n`);
48
58
  return 0;
49
59
  }
50
- startTrace("host", { warn: log });
60
+ const logDir = values["log-dir"];
61
+ if (logDir === "") {
62
+ process.stderr.write("--log-dir needs a directory.\n");
63
+ return 2;
64
+ }
65
+ startTrace("host", {
66
+ target: {
67
+ ...(logDir === undefined ? {} : { dir: { path: logDir, given: `--log-dir ${logDir}` } }),
68
+ ...(values["log-console"] ? { echo: (line: string) => process.stderr.write(`c64-re-tools-host: ${line}\n`) } : {}),
69
+ },
70
+ warn: log,
71
+ });
51
72
 
52
73
  const port = values.port === undefined ? DEFAULT_HOST_PORT : parsePort(values.port);
53
74
  if (port === undefined) {
@@ -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,