@henols/c64-re-tools 1.1.0 → 2.0.0-rc.3
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/LICENSE +21 -0
- package/README.md +138 -37
- package/distribution/package.ts +36 -0
- package/distribution/plugin.ts +120 -0
- package/package.json +34 -40
- package/skills/c64-assembler/SKILL.md +73 -0
- package/skills/c64-assembler/scripts/assemble.ts +103 -0
- package/skills/c64-basic/SKILL.md +67 -0
- package/skills/c64-basic/scripts/basic.ts +46 -0
- package/skills/c64-disk/SKILL.md +79 -0
- package/skills/c64-disk/scripts/disk.ts +65 -0
- package/skills/c64-emulator/SKILL.md +124 -0
- package/skills/c64-knowledge/SKILL.md +102 -0
- package/skills/c64-knowledge/scripts/knowledge.ts +257 -0
- package/skills/c64-memory-map/SKILL.md +76 -0
- package/skills/c64-memory-map/scripts/memmap.ts +81 -0
- package/skills/c64-memory-map/scripts/platform.ts +424 -0
- package/skills/c64-provenance/SKILL.md +76 -0
- package/skills/c64-provenance/scripts/compare.ts +92 -0
- package/skills/c64-provenance/scripts/provenance.ts +83 -0
- package/skills/c64-reverse-engineering/SKILL.md +90 -0
- package/skills/c64-static-analysis/SKILL.md +96 -0
- package/skills/c64-static-analysis/scripts/analyze.ts +175 -0
- package/skills/c64-static-analysis/scripts/findings.ts +73 -0
- package/skills/c64-testing/SKILL.md +79 -0
- package/skills/c64-testing/references/scenario-format.md +76 -0
- package/skills/c64-testing/scripts/checklist.ts +51 -0
- package/skills/c64-testing/scripts/scenario.ts +473 -0
- package/skills/c64-testing/scripts/test.ts +55 -0
- package/skills/c64-unpacker/SKILL.md +82 -0
- package/skills/c64-unpacker/scripts/evidence.ts +135 -0
- package/skills/c64-unpacker/scripts/unpack.ts +216 -0
- package/src/c64.ts +281 -0
- package/src/cli/ap-sdk.ts +44 -0
- package/src/cli/cleanup.ts +194 -0
- package/src/cli/main.ts +122 -0
- package/src/cli/status.ts +34 -0
- package/src/host/main.ts +106 -0
- package/src/host/server.ts +376 -0
- package/src/host/tools/c1541.ts +255 -0
- package/src/host/tools/index.ts +31 -0
- package/src/host/tools/petcat.ts +70 -0
- package/src/host/tools/status.ts +30 -0
- package/src/host/tools/version.ts +41 -0
- package/src/host/vice/adapter.ts +761 -0
- package/src/host/vice/binary-monitor.ts +389 -0
- package/src/host/vice/chips.ts +108 -0
- package/src/host/vice/connect.ts +34 -0
- package/src/host/vice/process.ts +402 -0
- package/src/host/vice/screen.ts +183 -0
- package/src/host/vice/session.ts +1316 -0
- package/src/host/vice/text-monitor.ts +223 -0
- package/src/host-client/connect.ts +238 -0
- package/src/host-client/tools.ts +89 -0
- package/src/host-client/transfer.ts +100 -0
- package/src/host-client/vice-session.ts +254 -0
- package/src/knowledge/database.ts +132 -0
- package/src/knowledge/history.ts +128 -0
- package/src/knowledge/import.ts +344 -0
- package/src/knowledge/read.ts +191 -0
- package/src/knowledge/schema.ts +140 -0
- package/src/knowledge/write.ts +348 -0
- package/src/mcp/main.ts +106 -0
- package/src/mcp/server.ts +307 -0
- package/src/mcp/tools/debug.ts +237 -0
- package/src/mcp/tools/execution.ts +116 -0
- package/src/mcp/tools/input.ts +69 -0
- package/src/mcp/tools/machine.ts +97 -0
- package/src/mcp/tools/media.ts +106 -0
- package/src/mcp/tools/memory.ts +203 -0
- package/src/mcp/tools/video.ts +260 -0
- package/src/native/acme.ts +116 -0
- package/src/native/discover.ts +141 -0
- package/src/native/dxa.ts +166 -0
- package/src/native/ghidra/analyze.ts +86 -0
- package/src/native/ghidra/index.ts +157 -0
- package/src/native/ghidra/language/c64rt_6510.cspec +34 -0
- package/src/native/ghidra/language/c64rt_6510.ldefs +15 -0
- package/src/native/ghidra/language/c64rt_6510.pspec +15 -0
- package/src/native/ghidra/language/c64rt_6510.slaspec +596 -0
- package/src/native/ghidra/scripts/C64Export.java +242 -0
- package/src/native/ghidra/scripts/C64Prepare.java +60 -0
- package/src/native/local.ts +34 -0
- package/src/native/processes.ts +350 -0
- package/src/native/run.ts +172 -0
- package/src/native/staging.ts +83 -0
- package/src/native/status.ts +101 -0
- package/src/native/types.ts +228 -0
- package/src/native/watchdog.ts +78 -0
- package/src/project.ts +39 -0
- package/src/protocol/framing.ts +132 -0
- package/src/protocol/messages.ts +243 -0
- package/src/protocol/tools.ts +247 -0
- package/src/protocol/vice.ts +1235 -0
- package/src/script.ts +59 -0
- package/src/time.ts +15 -0
- package/src/trace.ts +162 -0
- package/THIRD-PARTY-NOTICES.md +0 -26
- package/bin/cli.mjs +0 -283
- package/skills/acme-build/SKILL.md +0 -260
- package/skills/acme-build/scripts/acme.mjs +0 -341
- package/skills/acme-build/template.a +0 -53
- package/skills/c64-disk-access/SKILL.md +0 -159
- package/skills/c64-disk-access/scripts/c1541.mjs +0 -569
- package/skills/c64-memory-mapping/SKILL.md +0 -631
- package/skills/c64-memory-mapping/memmap.json +0 -8800
- package/skills/c64-memory-mapping/scripts/driver.mjs +0 -553
- package/skills/c64-petcat/SKILL.md +0 -87
- package/skills/c64-petcat/scripts/petcat.mjs +0 -221
- package/skills/c64-program-recon/SKILL.md +0 -706
- package/skills/c64-program-recon/references/control-flow.md +0 -180
- package/skills/c64-program-recon/references/graphics.md +0 -79
- package/skills/c64-program-recon/references/observation-hazards.md +0 -143
- package/skills/c64-program-recon/references/reconstruction.md +0 -133
- package/skills/c64-program-recon/references/sound-and-input.md +0 -74
- package/skills/c64-program-recon/references/tool-selection.md +0 -78
- package/skills/c64-program-recon/scripts/derive.mjs +0 -364
- package/skills/c64-program-recon/scripts/packer-finding.mjs +0 -709
- package/skills/c64-program-recon/templates/memory-map.template.md +0 -101
- package/skills/c64-provenance-diff/SKILL.md +0 -292
- package/skills/c64-provenance-diff/scripts/diff-images.mjs +0 -984
- package/skills/c64-provenance-diff/scripts/recovery-schema.mjs +0 -395
- package/skills/c64-ram-capture/RELEASES.json.example +0 -17
- package/skills/c64-ram-capture/SKILL.md +0 -474
- package/skills/c64-ram-capture/scripts/compare-cross-binary.mjs +0 -525
- package/skills/c64-ram-capture/scripts/compare.mjs +0 -258
- package/skills/c64-ram-capture/scripts/derive-transients.mjs +0 -575
- package/skills/c64-ram-capture/scripts/dump-artifacts.mjs +0 -317
- package/skills/c64-ram-capture/scripts/mcp-module.mjs +0 -174
- package/skills/c64-ram-capture/scripts/project-paths.mjs +0 -81
- package/skills/c64-ram-capture/scripts/releases.mjs +0 -109
- package/skills/c64-ram-capture/scripts/vsf-slice.mjs +0 -147
- package/skills/c64-ram-capture/scripts/watch-loads.mjs +0 -581
- package/skills/c64-ram-capture/templates/capture-record.template.md +0 -99
- package/skills/c64-ram-capture/transients/README.md +0 -137
- package/skills/routine-queue-walker/SKILL.md +0 -394
- package/skills/routine-queue-walker/scripts/completeness-report.mjs +0 -465
- package/skills/vice-wedge-triage/SKILL.md +0 -269
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: c64-unpacker
|
|
3
|
+
description: Use this skill to find out if a packer or a cruncher compressed a C64 program, and to get the unpacked program for analysis. It shows evidence of packing without a guess of the packer name. It runs the program in an emulator until the unpacked code starts, then writes the memory into the project. Do not use it to analyze the unpacked code.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# c64-unpacker
|
|
7
|
+
|
|
8
|
+
## Purpose
|
|
9
|
+
|
|
10
|
+
Find out if a packer compressed a C64 program. Get the unpacked program for analysis.
|
|
11
|
+
|
|
12
|
+
This skill does not change project knowledge.
|
|
13
|
+
|
|
14
|
+
## Inputs
|
|
15
|
+
|
|
16
|
+
- The program: a PRG file, relative to the project directory.
|
|
17
|
+
- For a capture: the address where the unpacked program starts, and where to write the result.
|
|
18
|
+
|
|
19
|
+
## Tools / execution path
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
node <skill>/scripts/unpack.ts inspect original/game.prg
|
|
23
|
+
node <skill>/scripts/unpack.ts trace original/game.prg
|
|
24
|
+
node <skill>/scripts/unpack.ts capture original/game.prg --until '$c000' --out unpacked/game.prg --range '$c000' '$cfff'
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
`inspect` examines the file locally. `trace` and `capture` start their own emulator through the host runtime and start the program. `trace` runs the program and finds the memory that the program wrote and then executed. `capture` stops the program when the CPU gets to the `--until` address.
|
|
28
|
+
|
|
29
|
+
| Option | Meaning |
|
|
30
|
+
|---|---|
|
|
31
|
+
| `--until '$c000'` | The address where the unpacked program starts. |
|
|
32
|
+
| `--out unpacked/game.prg` | Where to write the result, relative to the project directory. |
|
|
33
|
+
| `--range '$c000' '$cfff'` | Write this range as a PRG. Without it, the script writes all 64 KiB of RAM as a flat file. |
|
|
34
|
+
| `--timeout-frames 3000` | How many frames the program can run before the capture stops (default 3000). |
|
|
35
|
+
| `--frames 3000` | `trace` only: how many frames the program runs (default 3000). |
|
|
36
|
+
| `--entry '$c000'` | `trace` only: start the program at this address. Without it, `trace` types RUN. A program without a BASIC start line needs it. |
|
|
37
|
+
|
|
38
|
+
The capture reads RAM, also the RAM under the ROMs and the I/O area. The script prints one JSON object.
|
|
39
|
+
|
|
40
|
+
## Workflow
|
|
41
|
+
|
|
42
|
+
1. Run `inspect`. Read `basicStart` and `packing`:
|
|
43
|
+
- `likely`: most of the bytes look compressed. Packed data has this property.
|
|
44
|
+
- `unlikely`: the bytes look like code and data. A simple cruncher can also give this result.
|
|
45
|
+
- `unclear`: the file is short, or the blocks do not give clear evidence.
|
|
46
|
+
2. Run `trace`. It gives the evidence from the run. Read `packing`:
|
|
47
|
+
- `likely`: the program wrote code and then executed it. It unpacked, decrypted or moved code. `runningRange` is the code that runs now. Its `start` is usually the entry point of the unpacked program.
|
|
48
|
+
- `unclear`: the program executed a small quantity of code that it wrote. This can be code that changes itself.
|
|
49
|
+
- `unlikely`: the program executed no code that it wrote. If the program waits for a key or a joystick, it can move code after that input. Then use the c64-emulator skill.
|
|
50
|
+
3. Find the address where the unpacked program starts:
|
|
51
|
+
- Use `runningRange.start` from `trace`, or `suggestedUntil`. The CPU gets to `suggestedUntil` after the unpacking.
|
|
52
|
+
- Or use the c64-emulator skill: set a breakpoint after the unpack loop, or look at the last JMP of the unpacker.
|
|
53
|
+
4. Run `capture` with that address. Use `--range` for the area that holds the unpacked program, if you know it.
|
|
54
|
+
5. Make sure that the result is the application: the code at the start address must not be the unpacker again.
|
|
55
|
+
6. Give the result to the c64-static-analysis skill. For a flat 64 KiB file, use its `--flat64k` option.
|
|
56
|
+
|
|
57
|
+
## Knowledge
|
|
58
|
+
|
|
59
|
+
Automatic write:
|
|
60
|
+
- none.
|
|
61
|
+
|
|
62
|
+
Do not record the unpacker in the project knowledge, unless the user asks to examine the unpacker.
|
|
63
|
+
|
|
64
|
+
## Result
|
|
65
|
+
|
|
66
|
+
The packing evidence, or the unpacked program in the project with the address where it starts.
|
|
67
|
+
|
|
68
|
+
The exit status is 0 for a result. The exit status is 1 for `traced: false`, for `captured: false` and for an error. The exit status is 2 for a wrong argument.
|
|
69
|
+
|
|
70
|
+
## Failure and conflicts
|
|
71
|
+
|
|
72
|
+
- `traced: false`: the emulator did not load the program into memory in the time limit. The reason gives the load address. Make sure that the file is a PRG that the C64 can load.
|
|
73
|
+
- `captured: false`: the program did not get to the address in the time limit. The reason tells where it stopped. Find a better address with the c64-emulator skill, or give more frames.
|
|
74
|
+
- Do not name a packer from weak evidence. The script never names one.
|
|
75
|
+
- Some programs unpack in more than one stage. Make sure that the captured code is the application and not a second unpacker.
|
|
76
|
+
- `not-found` or `invalid-input`: a path or an argument is wrong. Read the message.
|
|
77
|
+
- `operation-failed` with a message about the host runtime: the host runtime does not run. Tell the user to start the host runtime with `npx -y --package=@henols/c64-re-tools@latest c64-re-tools-host`.
|
|
78
|
+
|
|
79
|
+
## Handoffs
|
|
80
|
+
|
|
81
|
+
- Unpacked machine code → c64-static-analysis
|
|
82
|
+
- Find the start address at run time → c64-emulator
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Local evidence of packing for the c64-unpacker script: where BASIC hands
|
|
2
|
+
// over and how compressed the bytes look. It never names a packer.
|
|
3
|
+
|
|
4
|
+
import { formatC64Address } from "#src/c64.ts";
|
|
5
|
+
|
|
6
|
+
export class PackingError extends Error {}
|
|
7
|
+
|
|
8
|
+
/** A 256-byte block above this many bits per byte looks compressed. */
|
|
9
|
+
const HIGH_ENTROPY = 6.9;
|
|
10
|
+
const BLOCK = 256;
|
|
11
|
+
|
|
12
|
+
function entropy(bytes: Uint8Array): number {
|
|
13
|
+
const counts = new Array<number>(256).fill(0);
|
|
14
|
+
for (const byte of bytes) counts[byte]!++;
|
|
15
|
+
let bits = 0;
|
|
16
|
+
for (const count of counts) {
|
|
17
|
+
if (count === 0) continue;
|
|
18
|
+
const p = count / bytes.length;
|
|
19
|
+
bits -= p * Math.log2(p);
|
|
20
|
+
}
|
|
21
|
+
return bits;
|
|
22
|
+
}
|
|
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 };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The local packing evidence of one PRG. */
|
|
39
|
+
export interface PackingEvidence {
|
|
40
|
+
loadRange: { start: string; end: string };
|
|
41
|
+
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;
|
|
44
|
+
entropy: { blocks: number; highBlocks: number; median: number | null };
|
|
45
|
+
packing: "likely" | "unlikely" | "unclear";
|
|
46
|
+
evidence: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function inspect(program: Uint8Array): PackingEvidence {
|
|
50
|
+
if (program.length < 3) throw new PackingError("the file is too short for a PRG");
|
|
51
|
+
const load = program[0]! | (program[1]! << 8);
|
|
52
|
+
const end = load + program.length - 3;
|
|
53
|
+
const stub = basicSys(program);
|
|
54
|
+
// Without a BASIC start, all bytes count; with one, only the bytes after the BASIC line.
|
|
55
|
+
const from = stub === undefined ? 2 : Math.min(program.length, stub.end - load + 2 + 3);
|
|
56
|
+
const body = program.subarray(from);
|
|
57
|
+
const blocks: number[] = [];
|
|
58
|
+
for (let at = 0; at + BLOCK <= body.length; at += BLOCK) blocks.push(entropy(body.subarray(at, at + BLOCK)));
|
|
59
|
+
const high = blocks.filter((value) => value > HIGH_ENTROPY).length;
|
|
60
|
+
const share = blocks.length === 0 ? 0 : high / blocks.length;
|
|
61
|
+
const packing = blocks.length < 2 ? "unclear" : share >= 0.75 ? "likely" : share <= 0.1 ? "unlikely" : "unclear";
|
|
62
|
+
const sorted = [...blocks].sort((a, b) => a - b);
|
|
63
|
+
return {
|
|
64
|
+
loadRange: { start: formatC64Address(load), end: formatC64Address(Math.min(end, 0xffff)) },
|
|
65
|
+
bytes: program.length - 2,
|
|
66
|
+
basicStart: stub === undefined ? null : { line: stub.line, sys: formatC64Address(stub.address) },
|
|
67
|
+
entropy: {
|
|
68
|
+
blocks: blocks.length,
|
|
69
|
+
highBlocks: high,
|
|
70
|
+
median: sorted.length === 0 ? null : Number(sorted[sorted.length >> 1]!.toFixed(2)),
|
|
71
|
+
},
|
|
72
|
+
packing,
|
|
73
|
+
evidence:
|
|
74
|
+
packing === "likely"
|
|
75
|
+
? `${high} of ${blocks.length} blocks of 256 bytes look compressed (above ${HIGH_ENTROPY} bits per byte); code and data are usually below.`
|
|
76
|
+
: packing === "unlikely"
|
|
77
|
+
? `Only ${high} of ${blocks.length} blocks look compressed; the bytes look like code and data. A simple cruncher can look the same: run trace to see if the program writes code and then executes it.`
|
|
78
|
+
: "The entropy does not show clearly if the program is packed. Run it and look at what it writes to memory.",
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** One range of the emulator's memory map: what the program did with these addresses. */
|
|
83
|
+
export interface AccessRange {
|
|
84
|
+
start: number;
|
|
85
|
+
end: number;
|
|
86
|
+
read: boolean;
|
|
87
|
+
write: boolean;
|
|
88
|
+
execute: boolean;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The addresses that one memory map read covers before it is split. */
|
|
92
|
+
const MEMMAP_WINDOW = 0x1000;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Reads the memory map of $0000-$ffff in windows, in address order. `read`
|
|
96
|
+
* gives at most `maxRanges` ranges for one window; a window that gives that
|
|
97
|
+
* many can have more, so it is read again as two halves.
|
|
98
|
+
*/
|
|
99
|
+
export async function readMemoryMap(read: (start: number, end: number) => Promise<AccessRange[]>, maxRanges: number): Promise<AccessRange[]> {
|
|
100
|
+
const ranges: AccessRange[] = [];
|
|
101
|
+
const window = async (start: number, end: number): Promise<void> => {
|
|
102
|
+
const found = await read(start, end);
|
|
103
|
+
if (found.length < maxRanges || start === end) {
|
|
104
|
+
ranges.push(...found);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const middle = Math.floor((start + end) / 2);
|
|
108
|
+
await window(start, middle);
|
|
109
|
+
await window(middle + 1, end);
|
|
110
|
+
};
|
|
111
|
+
for (let start = 0; start <= 0xffff; start += MEMMAP_WINDOW) await window(start, start + MEMMAP_WINDOW - 1);
|
|
112
|
+
return ranges;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** ROM areas: an address here can be written (RAM below) and executed (ROM) without one being the other. */
|
|
116
|
+
export const underRom = (address: number) => (address >= 0xa000 && address <= 0xbfff) || address >= 0xe000;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The memory that the program wrote and then executed, in pieces of code.
|
|
120
|
+
* `bytes` and `running` count only RAM outside the ROM areas: a write under
|
|
121
|
+
* a ROM and code that runs in that ROM are not one piece of code.
|
|
122
|
+
*/
|
|
123
|
+
export function writtenThenExecuted(ranges: AccessRange[], pc: number): { written: Array<{ start: number; end: number }>; bytes: number; running?: { start: number; end: number } } {
|
|
124
|
+
// VICE marks only opcode bytes as executed; a gap of at most two operand bytes stays inside one piece of code.
|
|
125
|
+
const written: Array<{ start: number; end: number }> = [];
|
|
126
|
+
for (const range of ranges.filter((candidate) => candidate.write && candidate.execute)) {
|
|
127
|
+
const last = written.at(-1);
|
|
128
|
+
if (last !== undefined && range.start - last.end <= 3 && underRom(range.start) === underRom(last.start)) last.end = range.end;
|
|
129
|
+
else written.push({ start: range.start, end: range.end });
|
|
130
|
+
}
|
|
131
|
+
const counted = written.filter((range) => !underRom(range.start));
|
|
132
|
+
const bytes = counted.reduce((sum, range) => sum + range.end - range.start + 1, 0);
|
|
133
|
+
const running = counted.find((range) => pc >= range.start && pc <= range.end);
|
|
134
|
+
return { written, bytes, ...(running === undefined ? {} : { running }) };
|
|
135
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// The c64-unpacker script. "inspect" gives local evidence of packing; it never
|
|
2
|
+
// names a packer. "trace" runs the program and reports the memory that it
|
|
3
|
+
// wrote and then executed: the run-time evidence that also finds a simple
|
|
4
|
+
// cruncher whose bytes look like code. "capture" runs the program in an
|
|
5
|
+
// emulator until it reaches a given address (the unpacked entry point) and
|
|
6
|
+
// writes the memory into the project. No native unpack tool is used.
|
|
7
|
+
|
|
8
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { dirname } from "node:path";
|
|
10
|
+
import { parseArgs } from "node:util";
|
|
11
|
+
|
|
12
|
+
import { formatC64Address, parseC64Address, textToPetscii } from "#src/c64.ts";
|
|
13
|
+
import { WireFailure } from "#src/host-client/tools.ts";
|
|
14
|
+
import { readProjectFile } from "#src/host-client/transfer.ts";
|
|
15
|
+
import { ViceSessionClient } from "#src/host-client/vice-session.ts";
|
|
16
|
+
import { resolveProjectPath } from "#src/project.ts";
|
|
17
|
+
import { runScript, UsageError } from "#src/script.ts";
|
|
18
|
+
import { inspect, PackingError, readMemoryMap, underRom, writtenThenExecuted } from "./evidence.ts";
|
|
19
|
+
|
|
20
|
+
const USAGE = `unpack.ts inspect <program.prg>
|
|
21
|
+
unpack.ts trace <program.prg> [--frames <n>] [--entry <address>]
|
|
22
|
+
unpack.ts capture <program.prg> --until <address> --out <file> [--range <start> <end>] [--timeout-frames <n>]
|
|
23
|
+
|
|
24
|
+
inspect packing evidence: the BASIC start and the entropy of the bytes
|
|
25
|
+
trace run the program for <n> frames (default 3000) and report the memory
|
|
26
|
+
that it wrote and then executed; --entry starts it there instead of RUN
|
|
27
|
+
capture run the program until the CPU gets to <address>, then write memory:
|
|
28
|
+
with --range, a PRG of that range; without it, all 64 KiB of RAM
|
|
29
|
+
|
|
30
|
+
Addresses are $ and four hex digits. The result is one JSON object.`;
|
|
31
|
+
|
|
32
|
+
const MAX_READ = 4096;
|
|
33
|
+
|
|
34
|
+
function address(text: string, option: string): number {
|
|
35
|
+
try {
|
|
36
|
+
return parseC64Address(text);
|
|
37
|
+
} catch {
|
|
38
|
+
throw new UsageError(`${option} must be $ followed by four hex digits, not ${text}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Written-then-executed bytes from this many on are unpacking, decrypting or relocating, not self-changing code. */
|
|
43
|
+
const UNPACKED_BYTES = 256;
|
|
44
|
+
const LOAD_POLL_FRAMES = 25;
|
|
45
|
+
const LOAD_LIMIT_FRAMES = 1500;
|
|
46
|
+
const MAX_ADVANCE = 10_000;
|
|
47
|
+
/** The most ranges one memory map read gives. */
|
|
48
|
+
const MAX_RANGES = 1000;
|
|
49
|
+
|
|
50
|
+
async function trace(program: string, options: { frames: number; entry?: number }): Promise<{ output: Record<string, unknown>; failed: boolean }> {
|
|
51
|
+
const { bytes } = readProjectFile(program);
|
|
52
|
+
if (bytes.length < 3) throw new UsageError("the file is too short for a PRG");
|
|
53
|
+
const load = bytes[0]! | (bytes[1]! << 8);
|
|
54
|
+
const basic = inspect(bytes).basicStart;
|
|
55
|
+
if (options.entry === undefined && basic === null) throw new UsageError("the program has no BASIC start line; give --entry <address>");
|
|
56
|
+
const client = await ViceSessionClient.open({ videoStandard: "pal" });
|
|
57
|
+
try {
|
|
58
|
+
// Loaded but not started, so the memory map can start empty: the KERNAL start-up writes all of RAM.
|
|
59
|
+
await client.autostart({ path: program, index: 0, run: false });
|
|
60
|
+
const head = bytes.subarray(2, Math.min(bytes.length, 2 + 64)).toString("hex");
|
|
61
|
+
let loaded = false;
|
|
62
|
+
for (let frames = 0; frames < LOAD_LIMIT_FRAMES && !loaded; frames += LOAD_POLL_FRAMES) {
|
|
63
|
+
await client.execution({ action: "advance-frames", count: LOAD_POLL_FRAMES, space: "c64" });
|
|
64
|
+
loaded = (await client.memoryRead({ address: load, size: head.length / 2, space: "c64", view: "ram" })).data === head;
|
|
65
|
+
}
|
|
66
|
+
if (!loaded) return { output: { traced: false, reason: `The program was not in memory at ${formatC64Address(load)} after ${LOAD_LIMIT_FRAMES} frames.` }, failed: true };
|
|
67
|
+
await client.memmap({ action: "clear" });
|
|
68
|
+
if (options.entry !== undefined) await client.registersSet("c64", { pc: options.entry });
|
|
69
|
+
else await client.keyboard(textToPetscii("RUN\n"));
|
|
70
|
+
let ran = 0;
|
|
71
|
+
while (ran < options.frames) {
|
|
72
|
+
const step = await client.execution({ action: "advance-frames", count: Math.min(MAX_ADVANCE, options.frames - ran), space: "c64" });
|
|
73
|
+
const advanced = step.advancedFrames ?? 0;
|
|
74
|
+
ran += advanced;
|
|
75
|
+
if (advanced === 0) break;
|
|
76
|
+
}
|
|
77
|
+
const { pc } = await client.registersGet("c64");
|
|
78
|
+
const ranges = await readMemoryMap(async (start, end) => {
|
|
79
|
+
const map = await client.memmap({ action: "read", start, end, maxRanges: MAX_RANGES });
|
|
80
|
+
if (!("ranges" in map)) throw new WireFailure("operation-failed", "The emulator did not give the memory map.");
|
|
81
|
+
return map.ranges;
|
|
82
|
+
}, MAX_RANGES);
|
|
83
|
+
const { written, bytes: count, running } = writtenThenExecuted(ranges, pc);
|
|
84
|
+
const packing = count >= UNPACKED_BYTES || running !== undefined ? "likely" : count > 0 ? "unclear" : "unlikely";
|
|
85
|
+
return {
|
|
86
|
+
output: {
|
|
87
|
+
traced: true,
|
|
88
|
+
frames: ran,
|
|
89
|
+
pc: formatC64Address(pc),
|
|
90
|
+
writtenThenExecuted: written.map((range) => ({
|
|
91
|
+
start: formatC64Address(range.start),
|
|
92
|
+
end: formatC64Address(range.end),
|
|
93
|
+
...(underRom(range.start) ? { underRom: true } : {}),
|
|
94
|
+
})),
|
|
95
|
+
bytes: count,
|
|
96
|
+
packing,
|
|
97
|
+
evidence:
|
|
98
|
+
packing === "likely"
|
|
99
|
+
? running !== undefined
|
|
100
|
+
? `The CPU now runs code at ${formatC64Address(pc)} that the program wrote: it unpacked, decrypted or relocated it.`
|
|
101
|
+
: `The program wrote ${count} bytes and then executed them: it unpacked, decrypted or relocated code.`
|
|
102
|
+
: packing === "unclear"
|
|
103
|
+
? `The program executed ${count} bytes that it wrote. This can be code that changes itself or a small loader.`
|
|
104
|
+
: "The program executed no code that it wrote while it ran.",
|
|
105
|
+
// A point after the unpacking where the unpacked code runs; the entry point can be at the start of its range.
|
|
106
|
+
...(running === undefined ? {} : { suggestedUntil: formatC64Address(pc), runningRange: { start: formatC64Address(running.start), end: formatC64Address(running.end) } }),
|
|
107
|
+
},
|
|
108
|
+
failed: false,
|
|
109
|
+
};
|
|
110
|
+
} finally {
|
|
111
|
+
await client.close();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function capture(program: string, options: { until: number; out: string; range?: [number, number]; timeoutFrames: number }): Promise<{ output: Record<string, unknown>; failed: boolean }> {
|
|
116
|
+
const client = await ViceSessionClient.open({ videoStandard: "pal" });
|
|
117
|
+
try {
|
|
118
|
+
await client.autostart({ path: program, index: 0, run: true });
|
|
119
|
+
const reached = await client.runUntil({ target: { kind: "address", address: options.until, space: "c64" }, timeoutFrames: options.timeoutFrames });
|
|
120
|
+
if (!reached.reached) {
|
|
121
|
+
return {
|
|
122
|
+
output: { captured: false, reason: `The program did not get to ${formatC64Address(options.until)} in ${options.timeoutFrames} frames. It stopped by ${reached.stopReason} at ${formatC64Address(reached.pc)}.` },
|
|
123
|
+
failed: true,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
const [start, end] = options.range ?? [0x0000, 0xffff];
|
|
127
|
+
const memory = Buffer.alloc(end - start + 1);
|
|
128
|
+
for (let at = start; at <= end; at += MAX_READ) {
|
|
129
|
+
const size = Math.min(MAX_READ, end - at + 1);
|
|
130
|
+
// The RAM view: unpacked code is often under the ROMs or the I/O area.
|
|
131
|
+
const read = await client.memoryRead({ address: at, size, space: "c64", view: "ram" });
|
|
132
|
+
Buffer.from(read.data, "hex").copy(memory, at - start);
|
|
133
|
+
}
|
|
134
|
+
const bytes = options.range === undefined ? memory : Buffer.concat([Buffer.from([start & 0xff, start >> 8]), memory]);
|
|
135
|
+
const path = resolveProjectPath(options.out);
|
|
136
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
137
|
+
writeFileSync(path, bytes);
|
|
138
|
+
const registers = await client.registersGet("c64");
|
|
139
|
+
return {
|
|
140
|
+
output: {
|
|
141
|
+
captured: true,
|
|
142
|
+
pc: formatC64Address(registers.pc),
|
|
143
|
+
output: options.out,
|
|
144
|
+
...(options.range === undefined ? { flat64k: true } : { loadRange: { start: formatC64Address(start), end: formatC64Address(end) } }),
|
|
145
|
+
registers: { a: registers.a, x: registers.x, y: registers.y, sp: registers.sp },
|
|
146
|
+
},
|
|
147
|
+
failed: false,
|
|
148
|
+
};
|
|
149
|
+
} finally {
|
|
150
|
+
await client.close();
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function run(argv: string[]): Promise<{ output: unknown; failed: boolean }> {
|
|
155
|
+
const { values, positionals } = parseArgs({
|
|
156
|
+
args: argv,
|
|
157
|
+
strict: true,
|
|
158
|
+
allowPositionals: true,
|
|
159
|
+
options: {
|
|
160
|
+
until: { type: "string" },
|
|
161
|
+
out: { type: "string" },
|
|
162
|
+
range: { type: "string" },
|
|
163
|
+
"timeout-frames": { type: "string" },
|
|
164
|
+
frames: { type: "string" },
|
|
165
|
+
entry: { type: "string" },
|
|
166
|
+
help: { type: "boolean" },
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
if (values.help) throw new UsageError("");
|
|
170
|
+
const [command, program, ...rest] = positionals;
|
|
171
|
+
if (program === undefined) throw new UsageError("give a command and a program");
|
|
172
|
+
if (command === "inspect") {
|
|
173
|
+
if (rest.length > 0) throw new UsageError("inspect takes one program");
|
|
174
|
+
const { bytes } = readProjectFile(program);
|
|
175
|
+
try {
|
|
176
|
+
return { output: inspect(bytes), failed: false };
|
|
177
|
+
} catch (error) {
|
|
178
|
+
if (error instanceof PackingError) throw new UsageError(error.message);
|
|
179
|
+
throw error;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (command === "trace") {
|
|
183
|
+
if (rest.length > 0) throw new UsageError("trace takes one program");
|
|
184
|
+
const frames = values.frames === undefined ? 3000 : Number(values.frames);
|
|
185
|
+
if (!Number.isInteger(frames) || frames < 1 || frames > 30_000) throw new UsageError("--frames must be from 1 to 30000");
|
|
186
|
+
return trace(program, { frames, ...(values.entry === undefined ? {} : { entry: address(values.entry, "--entry") }) });
|
|
187
|
+
}
|
|
188
|
+
if (command !== "capture") throw new UsageError(`unknown command ${command}`);
|
|
189
|
+
if (values.until === undefined || values.out === undefined) throw new UsageError("capture needs --until and --out");
|
|
190
|
+
// --range takes two values: the end is the next positional argument.
|
|
191
|
+
let range: [number, number] | undefined;
|
|
192
|
+
if (values.range !== undefined) {
|
|
193
|
+
if (rest.length !== 1) throw new UsageError("--range takes a start and an end address");
|
|
194
|
+
range = [address(values.range, "--range"), address(rest[0]!, "--range")];
|
|
195
|
+
if (range[1] < range[0]) throw new UsageError("the --range end must not be before its start");
|
|
196
|
+
} else if (rest.length > 0) {
|
|
197
|
+
throw new UsageError("too many arguments");
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
resolveProjectPath(values.out);
|
|
201
|
+
} catch {
|
|
202
|
+
throw new UsageError(`--out must be a path relative to the project directory that stays inside it, not ${values.out}`);
|
|
203
|
+
}
|
|
204
|
+
const timeoutFrames = values["timeout-frames"] === undefined ? 3000 : Number(values["timeout-frames"]);
|
|
205
|
+
if (!Number.isInteger(timeoutFrames) || timeoutFrames < 1 || timeoutFrames > 30_000) throw new UsageError("--timeout-frames must be from 1 to 30000");
|
|
206
|
+
return capture(program, { until: address(values.until, "--until"), out: values.out, ...(range === undefined ? {} : { range }), timeoutFrames });
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
await runScript(
|
|
210
|
+
async () => {
|
|
211
|
+
const { output, failed } = await run(process.argv.slice(2));
|
|
212
|
+
if (failed) process.exitCode = 1;
|
|
213
|
+
return output;
|
|
214
|
+
},
|
|
215
|
+
{ usage: USAGE },
|
|
216
|
+
);
|