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

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 (48) hide show
  1. package/README.md +3 -1
  2. package/distribution/package.ts +1 -16
  3. package/distribution/plugin.ts +5 -3
  4. package/package.json +1 -1
  5. package/skills/c64-basic/SKILL.md +3 -3
  6. package/skills/c64-emulator/SKILL.md +2 -2
  7. package/skills/c64-knowledge/SKILL.md +17 -3
  8. package/skills/c64-knowledge/scripts/knowledge.ts +5 -2
  9. package/skills/c64-memory-map/scripts/memmap.ts +1 -1
  10. package/skills/c64-provenance/SKILL.md +3 -2
  11. package/skills/c64-provenance/scripts/provenance.ts +4 -2
  12. package/skills/c64-provenance/scripts/runtime.ts +14 -2
  13. package/skills/c64-source/SKILL.md +4 -4
  14. package/skills/c64-source/scripts/generate.ts +57 -14
  15. package/skills/c64-source/scripts/loader.ts +5 -1
  16. package/skills/c64-source/scripts/source.ts +3 -1
  17. package/skills/c64-static-analysis/SKILL.md +2 -2
  18. package/skills/c64-static-analysis/scripts/analyze.ts +8 -4
  19. package/skills/c64-unpacker/SKILL.md +5 -5
  20. package/skills/c64-unpacker/scripts/evidence.ts +29 -22
  21. package/skills/c64-unpacker/scripts/loads.ts +30 -7
  22. package/skills/c64-unpacker/scripts/stages.ts +67 -0
  23. package/skills/c64-unpacker/scripts/unpack.ts +167 -47
  24. package/src/c64.ts +37 -12
  25. package/src/cli/main.ts +7 -4
  26. package/src/cli/status.ts +9 -7
  27. package/src/host/main.ts +11 -5
  28. package/src/host/server.ts +12 -4
  29. package/src/host/tools/index.ts +6 -2
  30. package/src/host/tools/petcat.ts +5 -1
  31. package/src/host/tools/status.ts +19 -5
  32. package/src/host/vice/process.ts +12 -6
  33. package/src/host/vice/session.ts +28 -15
  34. package/src/host-client/connect.ts +2 -1
  35. package/src/host-client/coverage.ts +0 -5
  36. package/src/host-client/vice-session.ts +37 -4
  37. package/src/knowledge/history.ts +8 -0
  38. package/src/knowledge/import.ts +34 -3
  39. package/src/knowledge/write.ts +2 -1
  40. package/src/mcp/main.ts +43 -15
  41. package/src/mcp/server.ts +9 -2
  42. package/src/mcp/tools/execution.ts +3 -2
  43. package/src/mcp/tools/input.ts +6 -4
  44. package/src/mcp/tools/media.ts +4 -3
  45. package/src/native/discover.ts +38 -4
  46. package/src/package.ts +21 -0
  47. package/src/protocol/tools.ts +7 -3
  48. package/src/protocol/vice.ts +14 -6
@@ -1,7 +1,7 @@
1
1
  // Local evidence of packing for the c64-unpacker script: where BASIC hands
2
2
  // over and how compressed the bytes look. It never names a packer.
3
3
 
4
- import { basicLineText, formatC64Address, instructionLength, parseTokenized } from "#src/c64.ts";
4
+ import { basicLineText, findHandoffs, formatC64Address, instructionLength, parseTokenized } from "#src/c64.ts";
5
5
 
6
6
  export class PackingError extends Error {}
7
7
 
@@ -21,26 +21,29 @@ function entropy(bytes: Uint8Array): number {
21
21
  return bits;
22
22
  }
23
23
 
24
- /** The constant SYS address of the first BASIC line, when the program starts with one. */
25
- function basicSys(program: Uint8Array): { line: number; address: number; end: number } | undefined {
26
- if (program.length < 8 || program[0] !== 0x01 || program[1] !== 0x08) return undefined;
27
- const number = program[4]! | (program[5]! << 8);
28
- const end = program.indexOf(0, 6);
29
- if (end < 0) return undefined;
30
- const sys = program.subarray(6, end).indexOf(0x9e);
31
- if (sys < 0) return undefined;
32
- const digits = Buffer.from(program.subarray(6 + sys + 1, end)).toString("latin1").replace(/[ (]/g, "");
33
- const match = /^(\d{1,5})/.exec(digits);
34
- if (match === null || Number(match[1]) > 0xffff) return undefined;
35
- return { line: number, address: Number(match[1]), end: 0x0801 + end - 2 };
24
+ /** A BASIC program at $0801, which RUN starts. */
25
+ export interface BasicStart {
26
+ /** The first address after the BASIC text. */
27
+ basicEnd: number;
28
+ /** The first SYS: its line, and its address when the argument is a constant. */
29
+ sys?: { line: number; address?: number; expression?: string };
30
+ }
31
+
32
+ /** The BASIC program of a PRG at $0801 and its first SYS, or undefined when the file is no BASIC program there. */
33
+ export function basicStart(program: Uint8Array): BasicStart | undefined {
34
+ const tokenized = parseTokenized(program);
35
+ if ("reason" in tokenized || tokenized.loadAddress !== 0x0801 || tokenized.lines.length === 0) return undefined;
36
+ const sys = findHandoffs(tokenized.lines).find((handoff) => handoff.kind === "sys");
37
+ if (sys === undefined) return { basicEnd: tokenized.basicEnd };
38
+ return { basicEnd: tokenized.basicEnd, sys: "address" in sys ? { line: sys.line, address: sys.address } : { line: sys.line, expression: "expression" in sys ? sys.expression : "" } };
36
39
  }
37
40
 
38
41
  /** The local packing evidence of one PRG. */
39
42
  export interface PackingEvidence {
40
43
  loadRange: { start: string; end: string };
41
44
  bytes: number;
42
- /** The first BASIC line and its constant SYS address, or null when the program has none. */
43
- basicStart: { line: number; sys: string } | null;
45
+ /** The first SYS line and its address (null when the argument needs run-time values, with the argument), or null when the program has no SYS. */
46
+ basicStart: { line: number; sys: string | null; expression?: string } | null;
44
47
  entropy: { blocks: number; highBlocks: number; median: number | null };
45
48
  /** The BASIC lines as text: a packer or a crack often puts its banner there (D43). */
46
49
  basicText: string[];
@@ -112,9 +115,10 @@ export function inspect(program: Uint8Array): PackingEvidence {
112
115
  if (program.length < 3) throw new PackingError("the file is too short for a PRG");
113
116
  const load = program[0]! | (program[1]! << 8);
114
117
  const end = load + program.length - 3;
115
- const stub = basicSys(program);
116
- // Without a BASIC start, all bytes count; with one, only the bytes after the BASIC line.
117
- const from = stub === undefined ? 2 : Math.min(program.length, stub.end - load + 2 + 3);
118
+ const basic = basicStart(program);
119
+ const stub = basic?.sys;
120
+ // Without BASIC, all bytes count; with it, only the bytes after the BASIC text.
121
+ const from = basic === undefined ? 2 : Math.min(program.length, basic.basicEnd - load + 2);
118
122
  const body = program.subarray(from);
119
123
  const blocks: number[] = [];
120
124
  for (let at = 0; at + BLOCK <= body.length; at += BLOCK) blocks.push(entropy(body.subarray(at, at + BLOCK)));
@@ -123,7 +127,7 @@ export function inspect(program: Uint8Array): PackingEvidence {
123
127
  const byEntropy = blocks.length < 2 ? "unclear" : share >= 0.75 ? "likely" : share <= 0.1 ? "unlikely" : "unclear";
124
128
  const tokenized = parseTokenized(program);
125
129
  const basicText = "lines" in tokenized && load === 0x0801 ? tokenized.lines.map(basicLineText) : [];
126
- const signals = stub === undefined ? { start: null, decodeLoops: [] } : startSignals(program, load, end, stub.address);
130
+ const signals = stub?.address === undefined ? { start: null, decodeLoops: [] } : startSignals(program, load, end, stub.address);
127
131
  const startMoves = signals.start?.jumpsOutside !== undefined || signals.start?.writesOutside !== undefined || signals.decodeLoops.length > 0;
128
132
  // Code that decodes or moves itself is a reason to run it, also when the bytes look plain.
129
133
  const packing = byEntropy === "unlikely" && startMoves ? "unclear" : byEntropy;
@@ -131,7 +135,7 @@ export function inspect(program: Uint8Array): PackingEvidence {
131
135
  return {
132
136
  loadRange: { start: formatC64Address(load), end: formatC64Address(Math.min(end, 0xffff)) },
133
137
  bytes: program.length - 2,
134
- basicStart: stub === undefined ? null : { line: stub.line, sys: formatC64Address(stub.address) },
138
+ basicStart: stub === undefined ? null : stub.address === undefined ? { line: stub.line, sys: null, expression: stub.expression! } : { line: stub.line, sys: formatC64Address(stub.address) },
135
139
  entropy: {
136
140
  blocks: blocks.length,
137
141
  highBlocks: high,
@@ -185,7 +189,10 @@ export const underRom = (address: number) => (address >= 0xa000 && address <= 0x
185
189
  * `bytes` and `running` count only RAM outside the ROM areas: a write under
186
190
  * a ROM and code that runs in that ROM are not one piece of code.
187
191
  */
188
- export function writtenThenExecuted(ranges: AccessRange[], pc: number): { written: Array<{ start: number; end: number }>; bytes: number; running?: { start: number; end: number } } {
192
+ export function writtenThenExecuted(
193
+ ranges: AccessRange[],
194
+ pc: number,
195
+ ): { written: Array<{ start: number; end: number }>; counted: Array<{ start: number; end: number }>; bytes: number; running?: { start: number; end: number } } {
189
196
  // VICE marks only opcode bytes as executed; a gap of at most two operand bytes stays inside one piece of code.
190
197
  const written: Array<{ start: number; end: number }> = [];
191
198
  for (const range of ranges.filter((candidate) => candidate.write && candidate.execute)) {
@@ -196,5 +203,5 @@ export function writtenThenExecuted(ranges: AccessRange[], pc: number): { writte
196
203
  const counted = written.filter((range) => !underRom(range.start));
197
204
  const bytes = counted.reduce((sum, range) => sum + range.end - range.start + 1, 0);
198
205
  const running = counted.find((range) => pc >= range.start && pc <= range.end);
199
- return { written, bytes, ...(running === undefined ? {} : { running }) };
206
+ return { written, counted, bytes, ...(running === undefined ? {} : { running }) };
200
207
  }
@@ -22,6 +22,8 @@ const NAME = /^[A-Za-z0-9._-]{1,64}$/;
22
22
  const DRIVE_RAM = 0x0800;
23
23
  /** Pages 0-3 belong to the entry state, not to the segments. */
24
24
  const FIRST_SEGMENT_ADDRESS = 0x0400;
25
+ /** The KERNAL LOAD entry in the jump table. */
26
+ const KERNAL_LOAD = 0xffd5;
25
27
 
26
28
  /** Reads and checks the steps file of loads: action steps with $ addresses, and capture steps with new names. */
27
29
  export function readLoadSteps(path: string): LoadStep[] {
@@ -58,18 +60,27 @@ export function readLoadSteps(path: string): LoadStep[] {
58
60
  return value as LoadStep[];
59
61
  }
60
62
 
61
- /** Ranges from $0400 where the RAM changed and the CPU wrote since the last capture, joined across gaps below `join` bytes. */
62
- export function changedBlocks(before: Uint8Array, after: Uint8Array, map: Uint8Array, join: number): Array<{ start: number; end: number; bytes: number }> {
63
- const blocks: Array<{ start: number; end: number; bytes: number }> = [];
63
+ /**
64
+ * Ranges from $0400 that the CPU wrote since the last capture, joined across
65
+ * gaps of up to `join` bytes, each with at least one changed byte. With
66
+ * `writesKnown`, every written byte counts, also one written with the value
67
+ * that was there (zeros over the zeros of VICE's start pattern, F-20).
68
+ * Without it, the KERNAL RAM test wrote all of RAM after the map was cleared,
69
+ * and only the changed bytes count.
70
+ */
71
+ export function changedBlocks(before: Uint8Array, after: Uint8Array, map: Uint8Array, join: number, writesKnown: boolean): Array<{ start: number; end: number; bytes: number }> {
72
+ const blocks: Array<{ start: number; end: number; bytes: number; changed: boolean }> = [];
64
73
  for (let address = FIRST_SEGMENT_ADDRESS; address < 0x10000; address++) {
65
- if (before[address] === after[address] || (map[address]! & COVERAGE.ramWrite) === 0) continue;
74
+ const changed = before[address] !== after[address];
75
+ if ((map[address]! & COVERAGE.ramWrite) === 0 || (!writesKnown && !changed)) continue;
66
76
  const last = blocks.at(-1);
67
77
  if (last !== undefined && address - last.end <= join) {
68
78
  last.end = address;
69
79
  last.bytes++;
70
- } else blocks.push({ start: address, end: address, bytes: 1 });
80
+ last.changed ||= changed;
81
+ } else blocks.push({ start: address, end: address, bytes: 1, changed });
71
82
  }
72
- return blocks;
83
+ return blocks.filter((block) => block.changed).map(({ start, end, bytes }) => ({ start, end, bytes }));
73
84
  }
74
85
 
75
86
  const hex4 = (value: number) => value.toString(16).padStart(4, "0");
@@ -87,6 +98,8 @@ export async function loads(program: string, options: { out: string; steps: Load
87
98
  let previous: Buffer;
88
99
  /** A PRG that autostart put into memory without the CPU: its bytes count as written in the first capture. */
89
100
  let loadedProgram: Buffer | undefined;
101
+ /** False while the map holds the writes of the KERNAL RAM test. */
102
+ let writesKnown = true;
90
103
  if (program.toLowerCase().endsWith(".prg")) {
91
104
  const { bytes } = readProjectFile(program);
92
105
  if (!(await autostartLoaded(client, program, bytes))) return { output: { loaded: false, reason: `The program was not in memory after ${LOAD_LIMIT_FRAMES} frames.` }, failed: true };
@@ -102,6 +115,15 @@ export async function loads(program: string, options: { out: string; steps: Load
102
115
  previous = await readRam(client, 0x0000, 0x10000);
103
116
  await client.memmap({ action: "clear" });
104
117
  await client.autostart({ path: program, index: 0, run: true });
118
+ writesKnown = false;
119
+ if (!program.toLowerCase().endsWith(".crt")) {
120
+ // The map starts again where the KERNAL LOAD starts, after the RAM test: then it holds only what the program wrote.
121
+ const load = await client.runUntil({ target: { kind: "address", address: KERNAL_LOAD, space: "c64" }, timeoutFrames: LOAD_LIMIT_FRAMES });
122
+ if (load.reached) {
123
+ await client.memmap({ action: "clear" });
124
+ writesKnown = true;
125
+ }
126
+ }
105
127
  }
106
128
 
107
129
  const captures: Array<Record<string, unknown>> = [];
@@ -124,7 +146,7 @@ export async function loads(program: string, options: { out: string; steps: Load
124
146
  for (let address = load; address < Math.min(0x10000, load + loadedProgram.length - 2); address++) map[address]! |= COVERAGE.ramWrite;
125
147
  loadedProgram = undefined;
126
148
  }
127
- const blocks = changedBlocks(previous, memory, map, 16);
149
+ const blocks = changedBlocks(previous, memory, map, 16, writesKnown);
128
150
  const segments = blocks.filter((block) => block.end - block.start + 1 >= options.minBlock);
129
151
  const small = blocks.filter((block) => !segments.includes(block)).reduce((sum, block) => sum + block.bytes, 0);
130
152
  const state = await machineState(client);
@@ -166,6 +188,7 @@ export async function loads(program: string, options: { out: string; steps: Load
166
188
  });
167
189
  previous = memory;
168
190
  await client.memmap({ action: "clear" });
191
+ writesKnown = true;
169
192
  }
170
193
  return { output: { loaded: true, captures }, failed: false };
171
194
  } finally {
@@ -0,0 +1,67 @@
1
+ // The stage rule of the c64-unpacker script (D43, F-17). The memory map keeps
2
+ // no order: a written and executed byte can be code that ran first and was
3
+ // written over later, as a decruncher does to its own start code. So a stage
4
+ // starts only where the CPU executes an opcode byte that the current stage
5
+ // wrote before the CPU got there. Code that the stage executed before it wrote
6
+ // it is the stage's own code: it stops the run once, and then a write watch
7
+ // over it arms it again.
8
+
9
+ import { COVERAGE } from "#src/c64.ts";
10
+ import { writtenBlocks } from "#src/host-client/coverage.ts";
11
+
12
+ export interface Range {
13
+ start: number;
14
+ end: number;
15
+ }
16
+
17
+ const contains = (range: Range, address: number) => address >= range.start && address <= range.end;
18
+
19
+ /** The flags of two maps from $0000 in one. */
20
+ export function mergeMaps(into: Uint8Array, from: Uint8Array): void {
21
+ for (let address = 0; address < into.length; address++) into[address]! |= from[address]!;
22
+ }
23
+
24
+ /**
25
+ * The written blocks of at least `minimum` bytes in which a chunk executed
26
+ * code that can start a stage: every executed address except own code that
27
+ * the stage has not written.
28
+ */
29
+ export function stageCandidates(stage: Uint8Array, chunk: Uint8Array, own: ReadonlySet<number>, minimum: number): Range[] {
30
+ const combined = Uint8Array.from(stage);
31
+ mergeMaps(combined, chunk);
32
+ return writtenBlocks(combined, minimum).filter((block) => {
33
+ for (let address = block.start; address <= block.end; address++) {
34
+ if ((chunk[address]! & COVERAGE.ramExecute) === 0) continue;
35
+ if (own.has(address) && (combined[address]! & COVERAGE.ramWrite) === 0) continue;
36
+ return true;
37
+ }
38
+ return false;
39
+ });
40
+ }
41
+
42
+ /**
43
+ * The points of a run over the candidate blocks: exec breakpoints over all of
44
+ * them except own code that is not written yet, and write watches over that
45
+ * own code.
46
+ */
47
+ export function stagePoints(blocks: readonly Range[], own: ReadonlySet<number>, written: (address: number) => boolean): { exec: Range[]; watch: Range[] } {
48
+ const exec: Range[] = [];
49
+ const watch: Range[] = [];
50
+ const add = (list: Range[], address: number) => {
51
+ const last = list[list.length - 1];
52
+ if (last !== undefined && last.end === address - 1) last.end = address;
53
+ else list.push({ start: address, end: address });
54
+ };
55
+ for (const block of blocks) {
56
+ for (let address = block.start; address <= block.end; address++) {
57
+ if (own.has(address) && !written(address)) add(watch, address);
58
+ else add(exec, address);
59
+ }
60
+ }
61
+ return { exec, watch };
62
+ }
63
+
64
+ /** True when the address lies in one of the ranges. */
65
+ export function inRanges(ranges: readonly Range[], address: number): boolean {
66
+ return ranges.some((range) => contains(range, address));
67
+ }
@@ -8,22 +8,23 @@
8
8
 
9
9
  import { parseArgs } from "node:util";
10
10
 
11
- import { EXECUTE, formatC64Address, parseC64Address, READ, textToPetscii, WRITE } from "#src/c64.ts";
11
+ import { COVERAGE, EXECUTE, formatC64Address, parseC64Address, READ, textToPetscii, WRITE } from "#src/c64.ts";
12
12
  import { autostartLoaded, LOAD_LIMIT_FRAMES } from "#src/host-client/autostart.ts";
13
- import { executedIn, readCoverage, writtenBlocks } from "#src/host-client/coverage.ts";
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";
16
16
  import { readProjectFile, writeProjectFile } from "#src/host-client/transfer.ts";
17
17
  import { ViceSessionClient } from "#src/host-client/vice-session.ts";
18
18
  import { resolveProjectPath } from "#src/project.ts";
19
19
  import { runScript, UsageError } from "#src/script.ts";
20
- import { inspect, PackingError, readMemoryMap, underRom, writtenThenExecuted } from "./evidence.ts";
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
23
  import { entryState, IO_AREAS, json, machineState, readRam, READY_FRAMES, vectorsOf } from "./machine.ts";
23
24
 
24
25
  const USAGE = `unpack.ts inspect <program.prg>
25
26
  unpack.ts trace <program.prg> [--frames <n>] [--entry <address>] [--steps <file>]
26
- unpack.ts capture <program.prg> --until <address> --out <dir> [--range <start> <end>] [--timeout-frames <n>] [--steps <file>] [--min-stage <bytes>]
27
+ unpack.ts capture <program.prg> --until <address> --out <dir> [--range <start> <end>] [--timeout-frames <n>] [--entry <address>] [--steps <file>] [--min-stage <bytes>]
27
28
  unpack.ts stages <program.prg> [--frames <n>] [--entry <address>] [--steps <file>] [--min-stage <bytes>]
28
29
  unpack.ts loads <program> --steps <file> --out <dir> [--drive] [--min-block <bytes>]
29
30
 
@@ -36,6 +37,7 @@ capture run the program until the CPU gets to <address>, and write into the
36
37
  for each segment, and entry_state.json (registers, $00/$01, pages
37
38
  0-3, VIC, SID and CIA registers, vectors, and what differs from a
38
39
  clean reset). --range gives one segment in place of the found ones.
40
+ --entry starts it there instead of RUN.
39
41
 
40
42
  stages run the program for <n> frames (default 3000) and report each stage:
41
43
  where it starts, the memory it wrote, and where the next one starts.
@@ -100,8 +102,7 @@ async function trace(program: string, options: { frames: number; entry?: number;
100
102
  const { bytes } = readProjectFile(program);
101
103
  if (bytes.length < 3) throw new UsageError("the file is too short for a PRG");
102
104
  const load = bytes[0]! | (bytes[1]! << 8);
103
- const basic = inspect(bytes).basicStart;
104
- if (options.entry === undefined && basic === null) throw new UsageError("the program has no BASIC start line; give --entry <address>");
105
+ if (options.entry === undefined && basicStart(bytes) === undefined) throw new UsageError(NO_BASIC);
105
106
  const client = await ViceSessionClient.open({ videoStandard: "pal" });
106
107
  try {
107
108
  // Loaded but not started, so the memory map can start empty: the KERNAL start-up writes all of RAM.
@@ -125,7 +126,17 @@ async function trace(program: string, options: { frames: number; entry?: number;
125
126
  const ranges = map.ranges.map(({ start: from, end, flags }) => ({ start: from, end, read: (flags & READ) !== 0, write: (flags & WRITE) !== 0, execute: (flags & EXECUTE) !== 0 }));
126
127
  return map.next === undefined ? { ranges } : { ranges, next: map.next };
127
128
  });
128
- const { written, bytes: count, running } = writtenThenExecuted(ranges, pc);
129
+ const { written, counted, bytes: count, running: atStop } = writtenThenExecuted(ranges, pc);
130
+ // The run often ends in a KERNAL interrupt: the written code that runs next gives the range and the point after the unpacking.
131
+ let running = atStop;
132
+ let until = pc;
133
+ if (running === undefined && counted.length > 0) {
134
+ const next = await firstPcIn(client, counted);
135
+ if (next !== undefined) {
136
+ running = counted.find((range) => next >= range.start && next <= range.end);
137
+ until = next;
138
+ }
139
+ }
129
140
  const packing = count >= UNPACKED_BYTES || running !== undefined ? "likely" : count > 0 ? "unclear" : "unlikely";
130
141
  return {
131
142
  output: {
@@ -142,13 +153,13 @@ async function trace(program: string, options: { frames: number; entry?: number;
142
153
  evidence:
143
154
  packing === "likely"
144
155
  ? running !== undefined
145
- ? `The CPU now runs code at ${formatC64Address(pc)} that the program wrote: it unpacked, decrypted or relocated it.`
156
+ ? `The CPU runs code at ${formatC64Address(until)} that the program wrote: it unpacked, decrypted or relocated it.`
146
157
  : `The program wrote ${count} bytes and then executed them: it unpacked, decrypted or relocated code.`
147
158
  : packing === "unclear"
148
159
  ? `The program executed ${count} bytes that it wrote. This can be code that changes itself or a small loader.`
149
160
  : "The program executed no code that it wrote while it ran.",
150
161
  // A point after the unpacking where the unpacked code runs; the entry point can be at the start of its range.
151
- ...(running === undefined ? {} : { suggestedUntil: formatC64Address(pc), runningRange: { start: formatC64Address(running.start), end: formatC64Address(running.end) } }),
162
+ ...(running === undefined ? {} : { suggestedUntil: formatC64Address(until), runningRange: { start: formatC64Address(running.start), end: formatC64Address(running.end) } }),
152
163
  },
153
164
  failed: false,
154
165
  };
@@ -157,9 +168,37 @@ async function trace(program: string, options: { frames: number; entry?: number;
157
168
  }
158
169
  }
159
170
 
171
+ const NO_BASIC = "the program is no BASIC program at $0801, so RUN cannot start it. Give --entry <address>.";
172
+
173
+ /** The first PC in one of the ranges within SYS_FRAMES frames, found with exec breakpoints over them. */
174
+ async function firstPcIn(client: ViceSessionClient, ranges: Array<{ start: number; end: number }>): Promise<number | undefined> {
175
+ const points = [];
176
+ for (const range of ranges) points.push(await client.breakpoint({ action: "add", address: range.start, size: range.end - range.start + 1, space: "c64" }));
177
+ try {
178
+ const stop = await client.execution({ action: "advance-frames", count: SYS_FRAMES, space: "c64" });
179
+ const pc = stop.pc ?? (await client.registersGet("c64")).pc;
180
+ return ranges.some((range) => pc >= range.start && pc <= range.end) ? pc : undefined;
181
+ } finally {
182
+ for (const point of points) await client.breakpoint({ action: "remove", id: point.id });
183
+ }
184
+ }
185
+
186
+ /** Frames that RUN gets to reach the machine code of a SYS whose argument needs run-time values. */
187
+ const SYS_FRAMES = 50;
188
+
189
+ /**
190
+ * The first PC in the program after its BASIC text, for a SYS whose address
191
+ * BASIC computes: an exec breakpoint over that part of the program stops there.
192
+ */
193
+ async function firstPcAfterBasic(client: ViceSessionClient, from: number, to: number): Promise<number | undefined> {
194
+ return from > to ? undefined : firstPcIn(client, [{ start: from, end: to }]);
195
+ }
196
+
160
197
  /** Frames between two looks at the memory map while stages are followed. */
161
198
  const STAGE_CHUNK_FRAMES = 25;
162
199
  const MAX_STAGES = 16;
200
+ /** Most stops of one re-run of a chunk: own code and writes over it each stop once. */
201
+ const MAX_STAGE_STOPS = 4096;
163
202
  const STAGE_SNAPSHOT = "stage-chunk";
164
203
 
165
204
  interface Stage {
@@ -196,65 +235,125 @@ async function follow(
196
235
  const { bytes } = readProjectFile(program);
197
236
  if (bytes.length < 3) throw new UsageError("the file is too short for a PRG");
198
237
  const load = bytes[0]! | (bytes[1]! << 8);
199
- const sys = inspect(bytes).basicStart?.sys;
200
- if (options.entry === undefined && sys === undefined) throw new UsageError("the program has no BASIC start line; give --entry <address>");
238
+ const basic = basicStart(bytes);
239
+ if (options.entry === undefined && basic === undefined) throw new UsageError(NO_BASIC);
201
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.` };
202
241
  await client.memmap({ action: "clear" });
203
242
  if (options.entry !== undefined) await client.registersSet("c64", { pc: options.entry });
204
243
  else await client.keyboard(textToPetscii("RUN\n"));
205
244
  const target = options.until === undefined ? undefined : await client.breakpoint({ action: "add", address: options.until, space: "c64" });
206
245
  const found: Stage[] = [];
207
- let entry = options.entry ?? parseC64Address(sys!);
246
+ const start = options.entry ?? basic!.sys?.address ?? (await firstPcAfterBasic(client, basic!.basicEnd, load + bytes.length - 3));
247
+ 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>.` };
248
+ let entry = start;
208
249
  let firstFrame = 0;
209
250
  let total = 0;
210
251
  let ended = false;
211
252
  let reached = false;
212
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>() };
256
+ const startStage = async (next: number, last: Uint8Array) => {
257
+ mergeMaps(stage.map, last);
258
+ found.push({ entry, firstFrame, frames: total - firstFrame, wrote: writtenBlocks(stage.map, options.minStage), next });
259
+ entry = next;
260
+ firstFrame = total;
261
+ stage = { map: new Uint8Array(0x10000), own: new Set<number>() };
262
+ await client.memmap({ action: "clear" });
263
+ };
264
+
265
+ /**
266
+ * Runs the chunk again from its snapshot with points over the candidate
267
+ * blocks, and stops at the first opcode that the stage wrote and the CPU
268
+ * then executes. Own code stops the run once; a write watch arms it again.
269
+ */
270
+ const rerun = async (candidates: Range[], frames: number): Promise<{ used: number; map: Uint8Array; next?: number; atTarget: boolean }> => {
271
+ const points = new Map<string, { kind: "exec" | "watch"; id: number }>();
272
+ const arm = async (wanted: { exec: Range[]; watch: Range[] }) => {
273
+ const keys = new Set([...wanted.exec.map((range) => `exec:${range.start}:${range.end}`), ...wanted.watch.map((range) => `watch:${range.start}:${range.end}`)]);
274
+ for (const [key, point] of points) {
275
+ if (keys.has(key)) continue;
276
+ if (point.kind === "exec") await client.breakpoint({ action: "remove", id: point.id });
277
+ else await client.watchpoint({ action: "remove", id: point.id });
278
+ points.delete(key);
279
+ }
280
+ for (const range of wanted.exec) {
281
+ const key = `exec:${range.start}:${range.end}`;
282
+ if (!points.has(key)) points.set(key, { kind: "exec", id: (await client.breakpoint({ action: "add", address: range.start, size: range.end - range.start + 1, space: "c64" })).id });
283
+ }
284
+ for (const range of wanted.watch) {
285
+ const key = `watch:${range.start}:${range.end}`;
286
+ 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
+ }
288
+ };
289
+ let left = frames;
290
+ let used = 0;
291
+ let map: Uint8Array = new Uint8Array(0x10000);
292
+ try {
293
+ for (let stops = 0; ; stops++) {
294
+ if (stops > MAX_STAGE_STOPS) {
295
+ 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
+ }
297
+ 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" });
300
+ const advanced = run.advancedFrames ?? 0;
301
+ used += advanced;
302
+ left -= advanced;
303
+ const pc = run.pc ?? (await client.registersGet("c64")).pc;
304
+ map = await readCoverage(client);
305
+ if (inRanges(candidates, pc)) {
306
+ if (written(pc)) return { used, map, next: pc, atTarget: atTarget(pc) };
307
+ stage.own.add(pc);
308
+ }
309
+ if (atTarget(pc)) return { used, map, atTarget: true };
310
+ if (left <= 0) return { used, map, atTarget: false };
311
+ }
312
+ } finally {
313
+ for (const point of points.values()) {
314
+ if (point.kind === "exec") await client.breakpoint({ action: "remove", id: point.id });
315
+ else await client.watchpoint({ action: "remove", id: point.id });
316
+ }
317
+ }
318
+ };
319
+
213
320
  const track = async (frames: number): Promise<void> => {
214
321
  let left = frames;
215
322
  while (left > 0 && !ended && !reached && found.length < MAX_STAGES) {
216
323
  await client.snapshot({ action: "save", name: STAGE_SNAPSHOT });
324
+ await client.memmap({ action: "clear" });
217
325
  const chunk = Math.min(STAGE_CHUNK_FRAMES, left);
218
326
  const first = await client.execution({ action: "advance-frames", count: chunk, space: "c64" });
219
327
  const firstPc = first.pc ?? (await client.registersGet("c64")).pc;
220
328
  const map = await readCoverage(client);
221
- const entered = writtenBlocks(map, options.minStage).filter((block) => executedIn(map, block));
222
- if (entered.length === 0) {
329
+ const candidates = stageCandidates(stage.map, map, stage.own, options.minStage);
330
+ if (candidates.length === 0) {
223
331
  const advanced = first.advancedFrames ?? 0;
224
332
  total += advanced;
225
333
  left -= advanced;
226
334
  if (atTarget(firstPc)) {
227
- // The stop at until comes before its instruction runs: a target in written memory starts a stage too.
228
- const blocks = writtenBlocks(map, options.minStage);
229
- if (blocks.some((block) => firstPc >= block.start && firstPc <= block.end)) {
230
- found.push({ entry, firstFrame, frames: total - firstFrame, wrote: blocks, next: firstPc });
231
- entry = firstPc;
232
- firstFrame = total;
233
- await client.memmap({ action: "clear" });
234
- }
335
+ // The stop at until comes before its instruction runs: a target that the stage wrote starts a stage too.
336
+ const combined = Uint8Array.from(stage.map);
337
+ mergeMaps(combined, map);
338
+ const wrote = (combined[firstPc]! & COVERAGE.ramWrite) !== 0 && inRanges(writtenBlocks(combined, options.minStage), firstPc);
339
+ if (wrote) await startStage(firstPc, map);
340
+ else mergeMaps(stage.map, map);
235
341
  reached = true;
236
- } else if (advanced === 0) ended = true;
342
+ } else {
343
+ mergeMaps(stage.map, map);
344
+ if (advanced === 0) ended = true;
345
+ }
237
346
  continue;
238
347
  }
239
- // Again from the start of the chunk, with breakpoints over the blocks: the stop is the next entry.
348
+ // Again from the start of the chunk, with points over the candidate blocks.
240
349
  await client.snapshot({ action: "restore", name: STAGE_SNAPSHOT });
241
- const points = [];
242
- for (const block of entered) points.push(await client.breakpoint({ action: "add", address: block.start, size: block.end - block.start + 1, space: "c64" }));
243
- const rerun = await client.execution({ action: "advance-frames", count: chunk, space: "c64" });
244
- for (const point of points) await client.breakpoint({ action: "remove", id: point.id });
245
- const next = rerun.pc ?? (await client.registersGet("c64")).pc;
246
- const used = rerun.advancedFrames ?? 0;
247
- total += used;
248
- left -= used;
249
- if (entered.some((block) => next >= block.start && next <= block.end)) {
250
- found.push({ entry, firstFrame, frames: total - firstFrame, wrote: writtenBlocks(await readCoverage(client), options.minStage), next });
251
- entry = next;
252
- firstFrame = total;
253
- await client.memmap({ action: "clear" });
254
- } else if (!atTarget(next)) {
255
- throw new WireFailure("operation-failed", `The run from the snapshot did not enter the written memory again; it stopped at ${formatC64Address(next)}.`);
256
- }
257
- if (atTarget(next)) reached = true;
350
+ await client.memmap({ action: "clear" });
351
+ const again = await rerun(candidates, chunk);
352
+ total += again.used;
353
+ left -= again.used;
354
+ if (again.next !== undefined) await startStage(again.next, again.map);
355
+ else mergeMaps(stage.map, again.map);
356
+ if (again.atTarget) reached = true;
258
357
  }
259
358
  };
260
359
  for (const step of options.steps) {
@@ -264,7 +363,8 @@ async function follow(
264
363
  await track(Math.max(0, options.frames - total));
265
364
  if (target !== undefined) await client.breakpoint({ action: "remove", id: target.id });
266
365
  await client.snapshot({ action: "discard", name: STAGE_SNAPSHOT }).catch(() => undefined);
267
- found.push({ entry, firstFrame, frames: total - firstFrame, wrote: writtenBlocks(await readCoverage(client), options.minStage) });
366
+ mergeMaps(stage.map, await readCoverage(client));
367
+ found.push({ entry, firstFrame, frames: total - firstFrame, wrote: writtenBlocks(stage.map, options.minStage) });
268
368
  return { stages: found, frames: total, reached };
269
369
  }
270
370
 
@@ -301,11 +401,11 @@ async function stages(program: string, options: { frames: number; entry?: number
301
401
 
302
402
  async function capture(
303
403
  program: string,
304
- options: { until: number; out: string; range?: [number, number]; timeoutFrames: number; steps: ActionStep[]; minStage: number },
404
+ options: { until: number; out: string; range?: [number, number]; timeoutFrames: number; entry?: number; steps: ActionStep[]; minStage: number },
305
405
  ): Promise<{ output: Record<string, unknown>; failed: boolean }> {
306
406
  const client = await ViceSessionClient.open({ videoStandard: "pal" });
307
407
  try {
308
- const followed = await follow(client, program, { frames: options.timeoutFrames, steps: options.steps, minStage: options.minStage, until: options.until });
408
+ const followed = await follow(client, program, { frames: options.timeoutFrames, steps: options.steps, minStage: options.minStage, until: options.until, ...(options.entry === undefined ? {} : { entry: options.entry }) });
309
409
  if (followed.failed !== undefined) return { output: { captured: false, reason: followed.failed }, failed: true };
310
410
  const registers = await client.registersGet("c64");
311
411
  if (!followed.reached) {
@@ -373,6 +473,15 @@ async function capture(
373
473
  }
374
474
  }
375
475
 
476
+ /** The options that each command takes; any other option is refused, not ignored. */
477
+ const OPTIONS_OF: Record<string, string[]> = {
478
+ inspect: [],
479
+ trace: ["frames", "entry", "steps"],
480
+ stages: ["frames", "entry", "steps", "min-stage"],
481
+ capture: ["until", "out", "range", "timeout-frames", "entry", "steps", "min-stage"],
482
+ loads: ["steps", "out", "drive", "min-block"],
483
+ };
484
+
376
485
  async function run(argv: string[]): Promise<{ output: unknown; failed: boolean }> {
377
486
  const { values, positionals } = parseArgs({
378
487
  args: argv,
@@ -395,6 +504,11 @@ async function run(argv: string[]): Promise<{ output: unknown; failed: boolean }
395
504
  if (values.help) throw new UsageError("");
396
505
  const [command, program, ...rest] = positionals;
397
506
  if (program === undefined) throw new UsageError("give a command and a program");
507
+ const allowed = OPTIONS_OF[command ?? ""];
508
+ if (allowed === undefined) throw new UsageError(`unknown command ${command}`);
509
+ for (const option of Object.keys(values)) {
510
+ if (option !== "help" && !allowed.includes(option)) throw new UsageError(`${command} does not take --${option}`);
511
+ }
398
512
  if (command === "inspect") {
399
513
  if (rest.length > 0) throw new UsageError("inspect takes one program");
400
514
  const { bytes } = readProjectFile(program);
@@ -435,8 +549,6 @@ async function run(argv: string[]): Promise<{ output: unknown; failed: boolean }
435
549
  if (!Number.isInteger(minBlock) || minBlock < 1 || minBlock > 0x10000) throw new UsageError("--min-block must be from 1 to 65536");
436
550
  return loads(program, { out: values.out, steps: readLoadSteps(values.steps), drive: values.drive === true, minBlock });
437
551
  }
438
- if (values.drive !== undefined || values["min-block"] !== undefined) throw new UsageError("--drive and --min-block work only with loads");
439
- if (command !== "capture") throw new UsageError(`unknown command ${command}`);
440
552
  if (values.until === undefined || values.out === undefined) throw new UsageError("capture needs --until and --out");
441
553
  // --range takes two values: the end is the next positional argument.
442
554
  let range: [number, number] | undefined;
@@ -457,7 +569,15 @@ async function run(argv: string[]): Promise<{ output: unknown; failed: boolean }
457
569
  const steps = values.steps === undefined ? [] : readSteps(values.steps);
458
570
  const minStage = values["min-stage"] === undefined ? 64 : Number(values["min-stage"]);
459
571
  if (!Number.isInteger(minStage) || minStage < 1 || minStage > 0x10000) throw new UsageError("--min-stage must be from 1 to 65536");
460
- return capture(program, { until: address(values.until, "--until"), out: values.out, ...(range === undefined ? {} : { range }), timeoutFrames, steps, minStage });
572
+ return capture(program, {
573
+ until: address(values.until, "--until"),
574
+ out: values.out,
575
+ ...(range === undefined ? {} : { range }),
576
+ timeoutFrames,
577
+ steps,
578
+ minStage,
579
+ ...(values.entry === undefined ? {} : { entry: address(values.entry, "--entry") }),
580
+ });
461
581
  }
462
582
 
463
583
  await runScript(