@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.
- package/README.md +3 -1
- package/distribution/package.ts +1 -16
- package/distribution/plugin.ts +5 -3
- package/package.json +1 -1
- package/skills/c64-basic/SKILL.md +3 -3
- package/skills/c64-emulator/SKILL.md +2 -2
- package/skills/c64-knowledge/SKILL.md +17 -3
- package/skills/c64-knowledge/scripts/knowledge.ts +5 -2
- package/skills/c64-memory-map/scripts/memmap.ts +1 -1
- package/skills/c64-provenance/SKILL.md +3 -2
- package/skills/c64-provenance/scripts/provenance.ts +4 -2
- package/skills/c64-provenance/scripts/runtime.ts +14 -2
- package/skills/c64-source/SKILL.md +4 -4
- package/skills/c64-source/scripts/generate.ts +57 -14
- package/skills/c64-source/scripts/loader.ts +5 -1
- package/skills/c64-source/scripts/source.ts +3 -1
- package/skills/c64-static-analysis/SKILL.md +2 -2
- package/skills/c64-static-analysis/scripts/analyze.ts +8 -4
- package/skills/c64-unpacker/SKILL.md +5 -5
- package/skills/c64-unpacker/scripts/evidence.ts +29 -22
- package/skills/c64-unpacker/scripts/loads.ts +30 -7
- package/skills/c64-unpacker/scripts/stages.ts +67 -0
- package/skills/c64-unpacker/scripts/unpack.ts +167 -47
- package/src/c64.ts +37 -12
- package/src/cli/main.ts +7 -4
- package/src/cli/status.ts +9 -7
- package/src/host/main.ts +11 -5
- package/src/host/server.ts +12 -4
- package/src/host/tools/index.ts +6 -2
- package/src/host/tools/petcat.ts +5 -1
- package/src/host/tools/status.ts +19 -5
- package/src/host/vice/process.ts +12 -6
- package/src/host/vice/session.ts +28 -15
- package/src/host-client/connect.ts +2 -1
- package/src/host-client/coverage.ts +0 -5
- package/src/host-client/vice-session.ts +37 -4
- package/src/knowledge/history.ts +8 -0
- package/src/knowledge/import.ts +34 -3
- package/src/knowledge/write.ts +2 -1
- package/src/mcp/main.ts +43 -15
- package/src/mcp/server.ts +9 -2
- package/src/mcp/tools/execution.ts +3 -2
- package/src/mcp/tools/input.ts +6 -4
- package/src/mcp/tools/media.ts +4 -3
- package/src/native/discover.ts +38 -4
- package/src/package.ts +21 -0
- package/src/protocol/tools.ts +7 -3
- 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
|
-
/**
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
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
|
|
116
|
-
|
|
117
|
-
|
|
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(
|
|
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
|
-
/**
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
200
|
-
if (options.entry === undefined &&
|
|
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
|
-
|
|
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
|
|
222
|
-
if (
|
|
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
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
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, {
|
|
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(
|