@henols/c64-re-tools 2.0.0-rc.3 → 2.0.0-rc.4
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 +15 -3
- package/package.json +1 -1
- package/skills/c64-assembler/SKILL.md +5 -2
- package/skills/c64-assembler/scripts/assemble.ts +8 -1
- package/skills/c64-assembler/scripts/compare.ts +84 -0
- package/skills/c64-basic/SKILL.md +1 -2
- package/skills/c64-disk/SKILL.md +20 -6
- package/skills/c64-disk/scripts/containers.ts +124 -0
- package/skills/c64-disk/scripts/disk.ts +59 -5
- package/skills/c64-emulator/SKILL.md +11 -4
- package/skills/c64-knowledge/SKILL.md +53 -3
- package/skills/c64-knowledge/scripts/knowledge.ts +258 -29
- package/skills/c64-memory-map/SKILL.md +4 -1
- package/skills/c64-memory-map/scripts/memmap.ts +14 -3
- package/skills/c64-provenance/SKILL.md +37 -9
- package/skills/c64-provenance/scripts/compare.ts +60 -29
- package/skills/c64-provenance/scripts/provenance.ts +248 -19
- package/skills/c64-provenance/scripts/runtime.ts +179 -0
- package/skills/c64-provenance/scripts/separate.ts +132 -0
- package/skills/c64-reverse-engineering/SKILL.md +65 -11
- package/skills/c64-source/SKILL.md +119 -0
- package/skills/c64-source/scripts/generate.ts +402 -0
- package/skills/c64-source/scripts/loader.ts +320 -0
- package/skills/c64-source/scripts/opcodes.ts +61 -0
- package/skills/c64-source/scripts/source.ts +558 -0
- package/skills/c64-static-analysis/SKILL.md +27 -4
- package/skills/c64-static-analysis/scripts/analyze.ts +102 -13
- package/skills/c64-static-analysis/scripts/coverage.ts +78 -0
- package/skills/c64-static-analysis/scripts/findings.ts +6 -4
- package/skills/c64-static-analysis/scripts/idioms.ts +144 -0
- package/skills/c64-static-analysis/scripts/instructions.ts +105 -0
- package/skills/c64-testing/SKILL.md +27 -3
- package/skills/c64-testing/references/scenario-format.md +43 -3
- package/skills/c64-testing/scripts/run.ts +189 -0
- package/skills/c64-testing/scripts/scenario.ts +153 -135
- package/skills/c64-testing/scripts/test.ts +16 -6
- package/skills/c64-unpacker/SKILL.md +65 -14
- package/skills/c64-unpacker/scripts/evidence.ts +86 -21
- package/skills/c64-unpacker/scripts/loads.ts +174 -0
- package/skills/c64-unpacker/scripts/machine.ts +68 -0
- package/skills/c64-unpacker/scripts/unpack.ts +302 -48
- package/src/c64.ts +91 -0
- package/src/cli/status.ts +3 -1
- package/src/host/server.ts +6 -5
- package/src/host/vice/adapter.ts +25 -10
- package/src/host/vice/session.ts +64 -20
- package/src/host-client/autostart.ts +45 -0
- package/src/host-client/connect.ts +4 -2
- package/src/host-client/coverage.ts +59 -0
- package/src/host-client/steps.ts +170 -0
- package/src/host-client/transfer.ts +29 -3
- package/src/host-client/vice-session.ts +42 -13
- package/src/knowledge/history.ts +40 -13
- package/src/knowledge/import.ts +72 -56
- package/src/knowledge/read.ts +203 -52
- package/src/knowledge/schema.ts +94 -12
- package/src/knowledge/write.ts +432 -123
- package/src/mcp/output.ts +32 -0
- package/src/mcp/server.ts +4 -2
- package/src/mcp/tools/debug.ts +110 -24
- package/src/mcp/tools/input.ts +1 -0
- package/src/mcp/tools/media.ts +22 -6
- package/src/mcp/tools/memory.ts +57 -10
- package/src/mcp/tools/video.ts +9 -3
- package/src/native/discover.ts +5 -2
- package/src/native/ghidra/index.ts +19 -3
- package/src/native/ghidra/scripts/C64Export.java +33 -1
- package/src/native/status.ts +15 -3
- package/src/native/types.ts +30 -1
- package/{skills/c64-memory-map/scripts → src}/platform.ts +18 -2
- package/src/protocol/messages.ts +17 -1
- package/src/protocol/vice.ts +72 -18
- package/src/script.ts +3 -2
package/src/host/vice/adapter.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import { WireFailure, type VideoStandard } from "../../protocol/messages.ts";
|
|
5
5
|
import { type Comparison, type Condition, type BacktraceFrame, type HistoryEntry, type Instruction, type MemmapRange, type ProfileEntry, type JoystickState, type MemoryView, type RegisterValues, type Registers, type Space } from "../../protocol/vice.ts";
|
|
6
|
-
import { formatC64Address, formatC64Byte } from "../../c64.ts";
|
|
6
|
+
import { COVERAGE, formatC64Address, formatC64Byte } from "../../c64.ts";
|
|
7
7
|
import { decodeDisplay, decodePalette, visibleFrame, type IndexedFrame } from "./screen.ts";
|
|
8
8
|
import type { TextMonitor } from "./text-monitor.ts";
|
|
9
9
|
import {
|
|
@@ -258,12 +258,27 @@ function unknownForm(what: string, lines: string[]): WireFailure {
|
|
|
258
258
|
return new WireFailure("operation-failed", `VICE printed the ${what} in a form that c64-re-tools does not read: ${JSON.stringify(lines.slice(0, 3))}`);
|
|
259
259
|
}
|
|
260
260
|
|
|
261
|
+
/** The coverage byte (D40) of one memmap row's I/O, ROM and RAM columns, each "rwx" with "-" for no access. */
|
|
262
|
+
export function memmapFlags(io: string, rom: string, ram: string): number {
|
|
263
|
+
let flags = 0;
|
|
264
|
+
if (ram[0] === "r") flags |= COVERAGE.ramRead;
|
|
265
|
+
if (ram[1] === "w") flags |= COVERAGE.ramWrite;
|
|
266
|
+
if (ram[2] === "x") flags |= COVERAGE.ramExecute;
|
|
267
|
+
if (rom[0] === "r") flags |= COVERAGE.romRead;
|
|
268
|
+
if (rom[2] === "x") flags |= COVERAGE.romExecute;
|
|
269
|
+
if (io[0] === "r") flags |= COVERAGE.ioRead;
|
|
270
|
+
if (io[1] === "w") flags |= COVERAGE.ioWrite;
|
|
271
|
+
if (io[2] === "x") flags |= COVERAGE.ioExecute;
|
|
272
|
+
return flags;
|
|
273
|
+
}
|
|
274
|
+
|
|
261
275
|
/**
|
|
262
276
|
* Parses memmap rows such as "00c2: --- --- rw- (uninitialized read)": the I/O,
|
|
263
277
|
* ROM and RAM access flags of one address. Adjacent addresses with the same
|
|
264
|
-
*
|
|
278
|
+
* flags merge into one range; at most `maxRanges` come back. When more ranges
|
|
279
|
+
* follow, `next` is the first touched address that is not reported.
|
|
265
280
|
*/
|
|
266
|
-
export function parseMemmap(answer: string, maxRanges: number): MemmapRange[] {
|
|
281
|
+
export function parseMemmap(answer: string, maxRanges: number): { ranges: MemmapRange[]; next?: number } {
|
|
267
282
|
const ranges: MemmapRange[] = [];
|
|
268
283
|
const unknown: string[] = [];
|
|
269
284
|
for (const line of answer.split("\n")) {
|
|
@@ -273,18 +288,18 @@ export function parseMemmap(answer: string, maxRanges: number): MemmapRange[] {
|
|
|
273
288
|
continue;
|
|
274
289
|
}
|
|
275
290
|
const address = Number.parseInt(match[1]!, 16);
|
|
276
|
-
const flags = match[2]
|
|
277
|
-
|
|
291
|
+
const flags = memmapFlags(match[2]!, match[3]!, match[4]!);
|
|
292
|
+
if (flags === 0) continue;
|
|
278
293
|
const last = ranges.at(-1);
|
|
279
|
-
if (last !== undefined && last.end === address - 1 && last.
|
|
294
|
+
if (last !== undefined && last.end === address - 1 && last.flags === flags) {
|
|
280
295
|
last.end = address;
|
|
281
296
|
continue;
|
|
282
297
|
}
|
|
283
|
-
if (ranges.length >= maxRanges)
|
|
284
|
-
ranges.push({ start: address, end: address,
|
|
298
|
+
if (ranges.length >= maxRanges) return { ranges, next: address };
|
|
299
|
+
ranges.push({ start: address, end: address, flags });
|
|
285
300
|
}
|
|
286
301
|
if (ranges.length === 0 && unknown.length > 0) throw unknownForm("memory map", unknown);
|
|
287
|
-
return ranges;
|
|
302
|
+
return { ranges };
|
|
288
303
|
}
|
|
289
304
|
|
|
290
305
|
/**
|
|
@@ -739,7 +754,7 @@ export class ViceAdapter {
|
|
|
739
754
|
}
|
|
740
755
|
|
|
741
756
|
/** How the C64 CPU accessed [start, end] since the memmap was last cleared, as merged ranges. */
|
|
742
|
-
async memmap(start: number, end: number, maxRanges: number): Promise<MemmapRange[]> {
|
|
757
|
+
async memmap(start: number, end: number, maxRanges: number): Promise<{ ranges: MemmapRange[]; next?: number }> {
|
|
743
758
|
// The first argument is the access mask ("ioRWXrwx"); ff shows every kind of access.
|
|
744
759
|
return parseMemmap(await this.#computerText(`mmsh ff ${hex4(start)} ${hex4(end)}`, 60_000), maxRanges);
|
|
745
760
|
}
|
package/src/host/vice/session.ts
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
// and back; breakpoints, watchpoints and the rest go along.
|
|
5
5
|
|
|
6
6
|
import { WireFailure, type VideoStandard } from "../../protocol/messages.ts";
|
|
7
|
-
import { MAX_BASELINES, MAX_SNAPSHOTS, MAX_COMPARE_DIFFERENCES, RASTER, type Breakpoint, type BreakpointParams, type Condition, type ExecutionParams, type HistoryEntry, type RunTarget, type RunUntilResult, type ScreenComparison, type CiaSelection, type CiaState, type ObserveParams, type ObserveResult, type SidState, type SpriteState, type ViciiState, type JoystickState, type Watchpoint, type WatchpointParams, type ExecutionResult, type MachineStatus, type RunState, type ViceOperation, type ViceOperations, type WindowResult } from "../../protocol/vice.ts";
|
|
8
|
-
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { MAX_BASELINES, MAX_SNAPSHOTS, MAX_COMPARE_DIFFERENCES, RASTER, type Breakpoint, type BreakpointParams, type PointRemoved, type Condition, type ExecutionParams, type HistoryEntry, type RunTarget, type RunUntilResult, type ScreenComparison, type CiaSelection, type CiaState, type ObserveParams, type ObserveResult, type SidState, type SpriteState, type ViciiState, type JoystickState, type Watchpoint, type WatchpointParams, type ExecutionResult, type MachineStatus, type RunState, type ViceOperation, type ViceOperations, type WindowResult } from "../../protocol/vice.ts";
|
|
8
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
9
9
|
import { tmpdir } from "node:os";
|
|
10
10
|
import { join } from "node:path";
|
|
11
11
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
@@ -283,9 +283,10 @@ export class ViceSession implements ViceSessionHandle {
|
|
|
283
283
|
op: O,
|
|
284
284
|
params: ViceOperations[O]["params"],
|
|
285
285
|
attachments: Buffer[] = [],
|
|
286
|
+
reply: Buffer[] = [],
|
|
286
287
|
): Promise<ViceOperations[O]["result"]> {
|
|
287
288
|
// A window request for the mode the session is in sends nothing to VICE, so a held VICE does not stop it.
|
|
288
|
-
return this.#enqueue(() => this.#run(op, params, attachments), { whileHeld: op === "window" }) as Promise<ViceOperations[O]["result"]>;
|
|
289
|
+
return this.#enqueue(() => this.#run(op, params, attachments, reply), { whileHeld: op === "window" }) as Promise<ViceOperations[O]["result"]>;
|
|
289
290
|
}
|
|
290
291
|
|
|
291
292
|
async close(): Promise<void> {
|
|
@@ -303,10 +304,15 @@ export class ViceSession implements ViceSessionHandle {
|
|
|
303
304
|
return this.#adapter;
|
|
304
305
|
}
|
|
305
306
|
|
|
306
|
-
async #run(op: ViceOperation, params: unknown, attachments: Buffer[]): Promise<unknown> {
|
|
307
|
+
async #run(op: ViceOperation, params: unknown, attachments: Buffer[], reply: Buffer[]): Promise<unknown> {
|
|
307
308
|
switch (op) {
|
|
308
309
|
case "status":
|
|
309
310
|
return this.#status();
|
|
311
|
+
case "ioRead": {
|
|
312
|
+
const read = params as ViceOperations["ioRead"]["params"];
|
|
313
|
+
const bytes = await this.#observe(() => this.#machine.readIo(read.address, read.size));
|
|
314
|
+
return { address: read.address, data: Buffer.from(bytes).toString("hex") };
|
|
315
|
+
}
|
|
310
316
|
case "memoryRead": {
|
|
311
317
|
const read = params as ViceOperations["memoryRead"]["params"];
|
|
312
318
|
const data = await this.#observe(() => this.#machine.readMemory(read));
|
|
@@ -394,6 +400,8 @@ export class ViceSession implements ViceSessionHandle {
|
|
|
394
400
|
}
|
|
395
401
|
case "snapshot":
|
|
396
402
|
return this.#snapshot(params as ViceOperations["snapshot"]["params"]);
|
|
403
|
+
case "snapshotFile":
|
|
404
|
+
return this.#snapshotFile(params as ViceOperations["snapshotFile"]["params"], attachments, reply);
|
|
397
405
|
case "cpuHistory":
|
|
398
406
|
return this.#cpuHistory(params as ViceOperations["cpuHistory"]["params"]);
|
|
399
407
|
case "backtrace": {
|
|
@@ -422,7 +430,7 @@ export class ViceSession implements ViceSessionHandle {
|
|
|
422
430
|
await this.#observe(() => this.#machine.clearMemmap());
|
|
423
431
|
return { cleared: true };
|
|
424
432
|
}
|
|
425
|
-
return
|
|
433
|
+
return await this.#observe(() => this.#machine.memmap(request.start, request.end, request.maxRanges));
|
|
426
434
|
}
|
|
427
435
|
case "vicii":
|
|
428
436
|
return this.#observe(() => this.#vicii());
|
|
@@ -716,34 +724,44 @@ export class ViceSession implements ViceSessionHandle {
|
|
|
716
724
|
async #changePoint<T extends { checkpoint: number; enabled: boolean }>(
|
|
717
725
|
registry: Map<number, T>,
|
|
718
726
|
kind: string,
|
|
719
|
-
params: { action: "
|
|
727
|
+
params: { action: "enable" | "disable"; id: number },
|
|
720
728
|
): Promise<T> {
|
|
721
|
-
const point = registry
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
registry.delete(params.id);
|
|
726
|
-
} else {
|
|
727
|
-
const enabled = params.action === "enable";
|
|
728
|
-
await this.#observe(() => this.#machine.toggleCheckpoint(point.checkpoint, enabled));
|
|
729
|
-
point.enabled = enabled;
|
|
730
|
-
}
|
|
729
|
+
const point = this.#point(registry, kind, params.id);
|
|
730
|
+
const enabled = params.action === "enable";
|
|
731
|
+
await this.#observe(() => this.#machine.toggleCheckpoint(point.checkpoint, enabled));
|
|
732
|
+
point.enabled = enabled;
|
|
731
733
|
return point;
|
|
732
734
|
}
|
|
733
735
|
|
|
734
|
-
async #
|
|
736
|
+
async #removePoint(registry: Map<number, { checkpoint: number }>, kind: string, id: number): Promise<PointRemoved> {
|
|
737
|
+
const point = this.#point(registry, kind, id);
|
|
738
|
+
await this.#observe(() => this.#machine.deleteCheckpoint(point.checkpoint));
|
|
739
|
+
registry.delete(id);
|
|
740
|
+
return { removed: true, id };
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
#point<T>(registry: Map<number, T>, kind: string, id: number): T {
|
|
744
|
+
const point = registry.get(id);
|
|
745
|
+
if (point === undefined) throw new WireFailure("not-found", `There is no ${kind} with id ${id}.`);
|
|
746
|
+
return point;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
async #breakpoint(params: BreakpointParams): Promise<Breakpoint | PointRemoved | { breakpoints: Breakpoint[] }> {
|
|
735
750
|
const publicView = ({ checkpoint: _checkpoint, expression: _expression, ...point }: Breakpoint & { checkpoint: number; expression?: string }): Breakpoint =>
|
|
736
751
|
point;
|
|
737
752
|
if (params.action === "list") return { breakpoints: [...this.#breakpoints.values()].map(publicView) };
|
|
753
|
+
if (params.action === "remove") return this.#removePoint(this.#breakpoints, "breakpoint", params.id);
|
|
738
754
|
if (params.action !== "add") return publicView(await this.#changePoint(this.#breakpoints, "breakpoint", params));
|
|
755
|
+
// A breakpoint over a range stops at the first instruction executed in it: exec, not a watch (D43).
|
|
739
756
|
const added = await this.#addPoint({
|
|
740
|
-
...pointRange({ address: params.address }),
|
|
757
|
+
...(params.size === undefined ? pointRange({ address: params.address }) : { start: params.address, end: params.address + params.size - 1, operation: 0x04 }),
|
|
741
758
|
space: params.space,
|
|
742
759
|
...(params.condition === undefined ? {} : { condition: params.condition }),
|
|
743
760
|
});
|
|
744
761
|
const point = {
|
|
745
762
|
id: this.#nextPointId++,
|
|
746
763
|
address: params.address,
|
|
764
|
+
...(params.size === undefined ? {} : { size: params.size }),
|
|
747
765
|
space: params.space,
|
|
748
766
|
enabled: true,
|
|
749
767
|
...(params.condition === undefined ? {} : { condition: params.condition }),
|
|
@@ -753,10 +771,11 @@ export class ViceSession implements ViceSessionHandle {
|
|
|
753
771
|
return publicView(point);
|
|
754
772
|
}
|
|
755
773
|
|
|
756
|
-
async #watchpoint(params: WatchpointParams): Promise<Watchpoint | { watchpoints: Watchpoint[] }> {
|
|
774
|
+
async #watchpoint(params: WatchpointParams): Promise<Watchpoint | PointRemoved | { watchpoints: Watchpoint[] }> {
|
|
757
775
|
const publicView = ({ checkpoint: _checkpoint, expression: _expression, ...point }: Watchpoint & { checkpoint: number; expression?: string }): Watchpoint =>
|
|
758
776
|
point;
|
|
759
777
|
if (params.action === "list") return { watchpoints: [...this.#watchpoints.values()].map(publicView) };
|
|
778
|
+
if (params.action === "remove") return this.#removePoint(this.#watchpoints, "watchpoint", params.id);
|
|
760
779
|
if (params.action !== "add") return publicView(await this.#changePoint(this.#watchpoints, "watchpoint", params));
|
|
761
780
|
const added = await this.#addPoint({
|
|
762
781
|
...pointRange({ address: params.address, size: params.size, access: params.access }),
|
|
@@ -910,15 +929,40 @@ export class ViceSession implements ViceSessionHandle {
|
|
|
910
929
|
this.#snapshots.delete(params.name);
|
|
911
930
|
return { discarded: true };
|
|
912
931
|
}
|
|
932
|
+
return this.#restoreFrom(existing);
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/** Restores the machine from a .vsf in the session directory; the CPU stays stopped. */
|
|
936
|
+
async #restoreFrom(file: string): Promise<{ restored: true; state: "stopped" }> {
|
|
913
937
|
await this.#stop();
|
|
914
938
|
const elapsed = (await this.#machine.clock()) - this.#timingStart;
|
|
915
|
-
await this.#machine.restoreSnapshot(
|
|
939
|
+
await this.#machine.restoreSnapshot(file);
|
|
916
940
|
this.#state = "stopped";
|
|
917
941
|
// The cycle clock jumped with the restore; the stopwatch keeps its reading, as a window move does.
|
|
918
942
|
this.#timingStart = (await this.#machine.clock()) - elapsed;
|
|
919
943
|
return { restored: true, state: "stopped" };
|
|
920
944
|
}
|
|
921
945
|
|
|
946
|
+
/** A snapshot as bytes (D41): export a named one into `reply`, or restore from the attached .vsf. */
|
|
947
|
+
async #snapshotFile(params: ViceOperations["snapshotFile"]["params"], attachments: Buffer[], reply: Buffer[]): Promise<ViceOperations["snapshotFile"]["result"]> {
|
|
948
|
+
if (params.action === "export") {
|
|
949
|
+
const file = this.#snapshots.get(params.name);
|
|
950
|
+
if (file === undefined) throw new WireFailure("not-found", `There is no snapshot named ${params.name}.`);
|
|
951
|
+
const bytes = readFileSync(file);
|
|
952
|
+
reply.push(bytes);
|
|
953
|
+
return { exported: true, bytes: bytes.length };
|
|
954
|
+
}
|
|
955
|
+
const file = this.#stage(onlyAttachment(attachments), "vsf");
|
|
956
|
+
try {
|
|
957
|
+
return await this.#restoreFrom(file);
|
|
958
|
+
} catch (error) {
|
|
959
|
+
if (error instanceof WireFailure) throw error;
|
|
960
|
+
throw new WireFailure("invalid-input", "The emulator did not restore this snapshot file. A snapshot file restores only in the emulator version and with the machine settings that saved it.");
|
|
961
|
+
} finally {
|
|
962
|
+
rmSync(file, { force: true });
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
|
|
922
966
|
/**
|
|
923
967
|
* Moves the machine into a VICE of the other mode: a snapshot of this
|
|
924
968
|
* one, restored in a new one, with the user points, joysticks, warp and the
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Autostart without the autostart's own RUN: the caller decides when the
|
|
2
|
+
// program starts, so keys it types later never mix with VICE's typing.
|
|
3
|
+
|
|
4
|
+
import { textToPetscii } from "../c64.ts";
|
|
5
|
+
import type { ViceSessionClient } from "./vice-session.ts";
|
|
6
|
+
|
|
7
|
+
/** How often, and for how long, a load is checked. */
|
|
8
|
+
export const LOAD_POLL_FRAMES = 25;
|
|
9
|
+
export const LOAD_LIMIT_FRAMES = 1500;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Autostarts the PRG at `path` with run false and advances frames until its
|
|
13
|
+
* first bytes are at its load address. Returns false when they do not arrive
|
|
14
|
+
* within LOAD_LIMIT_FRAMES.
|
|
15
|
+
*/
|
|
16
|
+
export async function autostartLoaded(client: ViceSessionClient, path: string, prg: Uint8Array): Promise<boolean> {
|
|
17
|
+
const load = prg[0]! | (prg[1]! << 8);
|
|
18
|
+
const head = Buffer.from(prg.subarray(2, Math.min(prg.length, 2 + 64))).toString("hex");
|
|
19
|
+
await client.autostart({ path, index: 0, run: false });
|
|
20
|
+
for (let frames = 0; frames < LOAD_LIMIT_FRAMES; frames += LOAD_POLL_FRAMES) {
|
|
21
|
+
await client.execution({ action: "advance-frames", count: LOAD_POLL_FRAMES, space: "c64" });
|
|
22
|
+
if ((await client.memoryRead({ address: load, size: head.length / 2, space: "c64", view: "ram" })).data === head) return true;
|
|
23
|
+
}
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** CURLIN high byte: $ff while BASIC waits in direct mode, a line number's high byte while a program runs. */
|
|
28
|
+
const CURLIN_HIGH = 0x003a;
|
|
29
|
+
export const RUN_LIMIT_FRAMES = 300;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Autostart that returns only when the program runs: load without VICE's
|
|
33
|
+
* own RUN, type RUN, and wait until BASIC runs a program line. A key that a
|
|
34
|
+
* later step types then reaches the program, never the READY prompt. Returns
|
|
35
|
+
* why it did not start, or undefined when it runs.
|
|
36
|
+
*/
|
|
37
|
+
export async function runProgram(client: ViceSessionClient, path: string, prg: Uint8Array): Promise<"not-loaded" | "not-started" | undefined> {
|
|
38
|
+
if (!(await autostartLoaded(client, path, prg))) return "not-loaded";
|
|
39
|
+
await client.keyboard(textToPetscii("RUN\n"));
|
|
40
|
+
const started = await client.runUntil({
|
|
41
|
+
target: { kind: "memory", address: CURLIN_HIGH, operator: "ne", value: 0xff, space: "c64", view: "ram" },
|
|
42
|
+
timeoutFrames: RUN_LIMIT_FRAMES,
|
|
43
|
+
});
|
|
44
|
+
return started.reached ? undefined : "not-started";
|
|
45
|
+
}
|
|
@@ -4,7 +4,7 @@ import { connect as connectTcp, type Socket } from "node:net";
|
|
|
4
4
|
|
|
5
5
|
import { encodeFrame, MessageReader } from "../protocol/framing.ts";
|
|
6
6
|
import { elapsedMs, trace } from "../trace.ts";
|
|
7
|
-
import { DEFAULT_HOST_PORT, HEARTBEAT_INTERVAL_MS, HEARTBEAT_TIMEOUT_MS, HOST_PROTOCOL_ID, HOST_PROTOCOL_VERSION, parseHostMessage, ProtocolError, WireFailure, type ErrorCode, type Hello, type HostMessage, type Ping, type Request, type Role, type VideoStandard } from "../protocol/messages.ts";
|
|
7
|
+
import { DEFAULT_HOST_PORT, HEARTBEAT_INTERVAL_MS, HEARTBEAT_TIMEOUT_MS, HOST_PROTOCOL_ID, HOST_PROTOCOL_VERSION, parseHostMessage, ProtocolError, WireFailure, withStopAndAsk, type ErrorCode, type Hello, type HostMessage, type Ping, type Request, type Role, type VideoStandard } from "../protocol/messages.ts";
|
|
8
8
|
|
|
9
9
|
export interface Endpoint {
|
|
10
10
|
host: string;
|
|
@@ -40,7 +40,9 @@ function unreachable(role: Role): WireFailure {
|
|
|
40
40
|
const code: ErrorCode = role === "vice-session" ? "machine-unavailable" : "operation-failed";
|
|
41
41
|
return new WireFailure(
|
|
42
42
|
code,
|
|
43
|
-
|
|
43
|
+
withStopAndAsk(
|
|
44
|
+
"The c64-re-tools host runtime is not running or cannot be reached. Start it on the host (npx -y --package=@henols/c64-re-tools@latest c64-re-tools-host) and try again.",
|
|
45
|
+
),
|
|
44
46
|
);
|
|
45
47
|
}
|
|
46
48
|
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Reads the emulator's memory access map as coverage bytes (D40): one byte
|
|
2
|
+
// per address, RAM, ROM and I/O apart. The MCP tool and the skill scripts share it.
|
|
3
|
+
|
|
4
|
+
import { COVERAGE, type CodeRange } from "../c64.ts";
|
|
5
|
+
import { MAX_MEMMAP_RANGES, type MemmapRange } from "../protocol/vice.ts";
|
|
6
|
+
import { readProjectFile } from "./transfer.ts";
|
|
7
|
+
import type { ViceSessionClient } from "./vice-session.ts";
|
|
8
|
+
|
|
9
|
+
/** A full coverage file: offset n is address n (D40). */
|
|
10
|
+
export const FULL_COVERAGE = 0x10000;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Reads full coverage files from the project and joins them: an address has
|
|
14
|
+
* every flag that any run gave it. `fail` refuses a file of another size.
|
|
15
|
+
*/
|
|
16
|
+
export function readCoverageFiles(paths: string[], fail: (message: string) => never): Uint8Array | undefined {
|
|
17
|
+
if (paths.length === 0) return undefined;
|
|
18
|
+
const joined = new Uint8Array(FULL_COVERAGE);
|
|
19
|
+
for (const path of paths) {
|
|
20
|
+
const { bytes } = readProjectFile(path);
|
|
21
|
+
if (bytes.length !== FULL_COVERAGE) {
|
|
22
|
+
fail(`--coverage ${path} has ${bytes.length} bytes; a full coverage file has 65536, one for each address. Write it with c64_memmap action read and out, without start and end.`);
|
|
23
|
+
}
|
|
24
|
+
for (let address = 0; address < FULL_COVERAGE; address++) joined[address]! |= bytes[address]!;
|
|
25
|
+
}
|
|
26
|
+
return joined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The coverage bytes of start..end, read from the session in parts. */
|
|
30
|
+
export async function readCoverage(session: Pick<ViceSessionClient, "memmap">, start = 0x0000, end = 0xffff): Promise<Uint8Array> {
|
|
31
|
+
const map = new Uint8Array(end - start + 1);
|
|
32
|
+
for (let from: number | undefined = start; from !== undefined; ) {
|
|
33
|
+
const part: { ranges: MemmapRange[]; next?: number } = await session.memmap({ action: "read", start: from, end, maxRanges: MAX_MEMMAP_RANGES });
|
|
34
|
+
for (const range of part.ranges) map.fill(range.flags, range.start - start, range.end - start + 1);
|
|
35
|
+
from = part.next;
|
|
36
|
+
}
|
|
37
|
+
return map;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Runs of RAM that a program wrote, at least `minimum` bytes each, in a map from $0000. */
|
|
41
|
+
export function writtenBlocks(map: Uint8Array, minimum: number): CodeRange[] {
|
|
42
|
+
const blocks: CodeRange[] = [];
|
|
43
|
+
let start = -1;
|
|
44
|
+
for (let address = 0; address <= map.length; address++) {
|
|
45
|
+
const written = address < map.length && (map[address]! & COVERAGE.ramWrite) !== 0;
|
|
46
|
+
if (written && start < 0) start = address;
|
|
47
|
+
if (!written && start >= 0) {
|
|
48
|
+
if (address - start >= minimum) blocks.push({ start, end: address - 1 });
|
|
49
|
+
start = -1;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return blocks;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** True when the CPU executed an instruction in RAM somewhere in the range. */
|
|
56
|
+
export function executedIn(map: Uint8Array, range: CodeRange): boolean {
|
|
57
|
+
for (let address = range.start; address <= range.end; address++) if ((map[address]! & COVERAGE.ramExecute) !== 0) return true;
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// The action steps of the step language (D43): write, registers, joystick,
|
|
2
|
+
// type, frames and runUntil. c64-testing scenarios and the c64-unpacker
|
|
3
|
+
// steps file share them; each caller adds its own steps around them.
|
|
4
|
+
|
|
5
|
+
import { parseC64Address, textToPetscii } from "../c64.ts";
|
|
6
|
+
import { JOYSTICK_DIRECTIONS, MAX_EXECUTION_COUNT, MAX_KEYBOARD_BYTES, MAX_MEMORY_WRITE, MAX_TIMEOUT_FRAMES } from "../protocol/vice.ts";
|
|
7
|
+
import type { RunTarget, RunUntilResult } from "../protocol/vice.ts";
|
|
8
|
+
import type { ViceSessionClient } from "./vice-session.ts";
|
|
9
|
+
|
|
10
|
+
/** A "$xxxx" address, or a symbol name where the caller resolves names. */
|
|
11
|
+
export type Location = string;
|
|
12
|
+
|
|
13
|
+
type JoystickDirection = (typeof JOYSTICK_DIRECTIONS)[number];
|
|
14
|
+
|
|
15
|
+
export type ActionStep =
|
|
16
|
+
| { write: { at: Location; bytes: number[] } }
|
|
17
|
+
| { registers: Partial<Record<"pc" | "a" | "x" | "y" | "sp", number | Location>> }
|
|
18
|
+
| { joystick: { port: 1 | 2; direction: JoystickDirection; fire?: boolean } }
|
|
19
|
+
| { type: string }
|
|
20
|
+
| { frames: number }
|
|
21
|
+
| { runUntil: { at: Location; raster?: number } | { raster: number } | { memory: { at: Location; equals: number } }; timeoutFrames: number };
|
|
22
|
+
|
|
23
|
+
/** The highest raster line of a PAL frame; an NTSC frame has fewer, which the emulator checks. */
|
|
24
|
+
const MAX_RASTER_LINE = 311;
|
|
25
|
+
|
|
26
|
+
export const ACTION_STEP_KINDS = ["write", "registers", "joystick", "type", "frames", "runUntil"] as const;
|
|
27
|
+
|
|
28
|
+
/** The keys a step of each kind can have, when it is more than the kind itself. */
|
|
29
|
+
export const ACTION_STEP_KEYS: Record<string, readonly string[]> = { runUntil: ["runUntil", "timeoutFrames"] };
|
|
30
|
+
|
|
31
|
+
/** The registers a registers step can set. */
|
|
32
|
+
export const SET_REGISTERS = ["pc", "a", "x", "y", "sp"];
|
|
33
|
+
|
|
34
|
+
export const isObject = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
35
|
+
export const isInteger = (value: unknown, min: number, max: number): value is number => Number.isInteger(value) && (value as number) >= min && (value as number) <= max;
|
|
36
|
+
|
|
37
|
+
/** Checks a "$xxxx" address, or also a symbol name when `names` allows it. */
|
|
38
|
+
export function checkLocation(value: unknown, what: string, fail: (message: string) => never, names = true): void {
|
|
39
|
+
if (typeof value !== "string" || value === "") fail(`${what} must be a $ address${names ? " or a symbol name" : ""}`);
|
|
40
|
+
if ((value as string).startsWith("$")) {
|
|
41
|
+
try {
|
|
42
|
+
parseC64Address(value as string);
|
|
43
|
+
} catch {
|
|
44
|
+
fail(`${what} must be $ followed by four hex digits, not ${value as string}`);
|
|
45
|
+
}
|
|
46
|
+
} else if (!names) {
|
|
47
|
+
fail(`${what} must be $ followed by four hex digits, not ${value as string}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Checks one action step of the given kind. `names` says whether symbol names may stand for addresses. */
|
|
52
|
+
export function checkActionStep(kind: string, step: Record<string, unknown>, at: string, fail: (message: string) => never, names = true): void {
|
|
53
|
+
switch (kind) {
|
|
54
|
+
case "write": {
|
|
55
|
+
const write = step.write;
|
|
56
|
+
if (!isObject(write) || Object.keys(write).some((key) => key !== "at" && key !== "bytes")) return fail(`${at}: write needs at and bytes, and nothing else`);
|
|
57
|
+
checkLocation(write.at, `${at}: write.at`, fail, names);
|
|
58
|
+
if (!Array.isArray(write.bytes) || write.bytes.length === 0 || write.bytes.length > MAX_MEMORY_WRITE || !write.bytes.every((byte) => isInteger(byte, 0, 0xff))) {
|
|
59
|
+
fail(`${at}: write.bytes must be a list of 1 to ${MAX_MEMORY_WRITE} numbers from 0 to 255`);
|
|
60
|
+
}
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
case "registers": {
|
|
64
|
+
const registers = step.registers;
|
|
65
|
+
if (!isObject(registers) || Object.keys(registers).length === 0) return fail(`${at}: registers must name at least one of ${SET_REGISTERS.join(", ")}`);
|
|
66
|
+
for (const [name, value] of Object.entries(registers)) {
|
|
67
|
+
if (!SET_REGISTERS.includes(name)) fail(`${at}: registers can set only ${SET_REGISTERS.join(", ")}, not ${name}`);
|
|
68
|
+
if (typeof value === "string") checkLocation(value, `${at}: registers.${name}`, fail, names);
|
|
69
|
+
else if (!isInteger(value, 0, name === "pc" ? 0xffff : 0xff)) fail(`${at}: registers.${name} must be ${names ? "a symbol name, " : ""}a $ address or a number from 0 to ${name === "pc" ? 65535 : 255}`);
|
|
70
|
+
}
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
case "joystick": {
|
|
74
|
+
const joystick = step.joystick;
|
|
75
|
+
if (!isObject(joystick) || Object.keys(joystick).some((key) => !["port", "direction", "fire"].includes(key))) return fail(`${at}: joystick needs port and direction, and fire is optional`);
|
|
76
|
+
if (joystick.port !== 1 && joystick.port !== 2) fail(`${at}: joystick.port must be 1 or 2`);
|
|
77
|
+
if (!(JOYSTICK_DIRECTIONS as readonly unknown[]).includes(joystick.direction)) fail(`${at}: joystick.direction must be one of ${JOYSTICK_DIRECTIONS.join(", ")}`);
|
|
78
|
+
if (joystick.fire !== undefined && typeof joystick.fire !== "boolean") fail(`${at}: joystick.fire must be true or false`);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
case "type": {
|
|
82
|
+
if (typeof step.type !== "string" || step.type === "") return fail(`${at}: type needs text`);
|
|
83
|
+
let bytes: Uint8Array;
|
|
84
|
+
try {
|
|
85
|
+
bytes = textToPetscii(step.type);
|
|
86
|
+
} catch (error) {
|
|
87
|
+
return fail(`${at}: ${(error as Error).message}`);
|
|
88
|
+
}
|
|
89
|
+
if (bytes.length > MAX_KEYBOARD_BYTES) fail(`${at}: type can have at most ${MAX_KEYBOARD_BYTES} keys`);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
case "frames":
|
|
93
|
+
if (!isInteger(step.frames, 1, MAX_EXECUTION_COUNT)) fail(`${at} needs a frame count from 1 to ${MAX_EXECUTION_COUNT}`);
|
|
94
|
+
return;
|
|
95
|
+
case "runUntil": {
|
|
96
|
+
const target = step.runUntil;
|
|
97
|
+
const keys = isObject(target) ? Object.keys(target).sort().join(",") : "";
|
|
98
|
+
// at, raster, at with raster (the address only on that line, D46), or memory.
|
|
99
|
+
if (!["at", "raster", "at,raster", "memory"].includes(keys)) return fail(`${at}: runUntil needs at, raster, at and raster, or memory`);
|
|
100
|
+
const until = target as Record<string, unknown>;
|
|
101
|
+
if ("raster" in until && !isInteger(until.raster, 0, MAX_RASTER_LINE)) fail(`${at}: runUntil.raster must be a raster line from 0 to ${MAX_RASTER_LINE}`);
|
|
102
|
+
if ("at" in until) checkLocation(until.at, `${at}: runUntil.at`, fail, names);
|
|
103
|
+
else if ("memory" in until) {
|
|
104
|
+
const memory = until.memory;
|
|
105
|
+
if (!isObject(memory) || Object.keys(memory).some((key) => key !== "at" && key !== "equals")) return fail(`${at}: runUntil.memory needs at and equals, and nothing else`);
|
|
106
|
+
checkLocation(memory.at, `${at}: runUntil.memory.at`, fail, names);
|
|
107
|
+
if (!isInteger(memory.equals, 0, 0xff)) fail(`${at}: runUntil.memory.equals must be a number from 0 to 255`);
|
|
108
|
+
}
|
|
109
|
+
if (!isInteger(step.timeoutFrames, 1, MAX_TIMEOUT_FRAMES)) fail(`${at} needs timeoutFrames from 1 to ${MAX_TIMEOUT_FRAMES}`);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
default:
|
|
113
|
+
fail(`${at}: ${kind} is not an action step`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Checks a list of action steps only, with $ addresses only: the steps file of c64-unpacker. */
|
|
118
|
+
export function checkActionSteps(value: unknown, fail: (message: string) => never): ActionStep[] {
|
|
119
|
+
if (!Array.isArray(value) || value.length === 0) return fail("the steps are a JSON list with at least one step");
|
|
120
|
+
value.forEach((step: unknown, index) => {
|
|
121
|
+
const at = `step ${index + 1}`;
|
|
122
|
+
if (!isObject(step)) return fail(`${at} must be an object`);
|
|
123
|
+
const kinds = Object.keys(step).filter((key) => (ACTION_STEP_KINDS as readonly string[]).includes(key));
|
|
124
|
+
if (kinds.length !== 1) return fail(`${at} must have exactly one of ${ACTION_STEP_KINDS.join(", ")}`);
|
|
125
|
+
const kind = kinds[0]!;
|
|
126
|
+
const allowed = ACTION_STEP_KEYS[kind] ?? [kind];
|
|
127
|
+
for (const key of Object.keys(step)) if (!allowed.includes(key)) fail(`${at} has the key ${key}, which a ${kind} step does not have`);
|
|
128
|
+
checkActionStep(kind, step, at, fail, false);
|
|
129
|
+
});
|
|
130
|
+
return value as ActionStep[];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Stops the CPU when it runs: a write, a register change or an exact frame count needs it stopped. */
|
|
134
|
+
async function stop(client: ViceSessionClient): Promise<void> {
|
|
135
|
+
if ((await client.status()).state === "running") await client.execution({ action: "pause", space: "c64" });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Runs one action step. `at` turns a location into an address. A runUntil
|
|
140
|
+
* step that does not reach its target returns the stop, for the caller to judge.
|
|
141
|
+
*/
|
|
142
|
+
export async function runActionStep(client: ViceSessionClient, step: ActionStep, at: (location: Location) => number): Promise<RunUntilResult | undefined> {
|
|
143
|
+
if ("write" in step) {
|
|
144
|
+
await stop(client);
|
|
145
|
+
await client.memoryWrite({ address: at(step.write.at), data: Buffer.from(step.write.bytes).toString("hex"), space: "c64", view: "cpu" });
|
|
146
|
+
} else if ("registers" in step) {
|
|
147
|
+
await stop(client);
|
|
148
|
+
const values: Record<string, number> = {};
|
|
149
|
+
for (const [name, value] of Object.entries(step.registers)) values[name] = typeof value === "number" ? value : at(value as Location);
|
|
150
|
+
await client.registersSet("c64", values);
|
|
151
|
+
} else if ("joystick" in step) {
|
|
152
|
+
await client.joystick({ port: step.joystick.port, direction: step.joystick.direction, fire: step.joystick.fire ?? false });
|
|
153
|
+
} else if ("type" in step) {
|
|
154
|
+
await client.keyboard(textToPetscii(step.type));
|
|
155
|
+
} else if ("frames" in step) {
|
|
156
|
+
await stop(client);
|
|
157
|
+
await client.execution({ action: "advance-frames", count: step.frames, space: "c64" });
|
|
158
|
+
} else {
|
|
159
|
+
const until = step.runUntil;
|
|
160
|
+
const target: RunTarget =
|
|
161
|
+
"at" in until
|
|
162
|
+
? { kind: "address", address: at(until.at), space: "c64", ...(until.raster === undefined ? {} : { condition: { kind: "raster" as const, line: until.raster } }) }
|
|
163
|
+
: "raster" in until
|
|
164
|
+
? { kind: "raster", line: until.raster }
|
|
165
|
+
: { kind: "memory", address: at(until.memory.at), operator: "eq", value: until.memory.equals, space: "c64", view: "cpu" };
|
|
166
|
+
const reached = await client.runUntil({ target, timeoutFrames: step.timeoutFrames });
|
|
167
|
+
if (!reached.reached) return reached;
|
|
168
|
+
}
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// Reads project files on the client side for transfer to the Host Runtime.
|
|
2
2
|
// The host never sees a client path: only bytes and the file's type.
|
|
3
3
|
|
|
4
|
-
import { readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
5
|
-
import { extname, join, relative, sep } from "node:path";
|
|
4
|
+
import { lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { dirname, extname, join, relative, sep } from "node:path";
|
|
6
6
|
|
|
7
|
-
import { isInside, projectRoot, resolveProjectFile } from "../project.ts";
|
|
7
|
+
import { isInside, projectRoot, resolveProjectFile, resolveProjectPath } from "../project.ts";
|
|
8
8
|
import { MAX_ATTACHMENT_BYTES } from "../protocol/framing.ts";
|
|
9
9
|
import { WireFailure } from "../protocol/messages.ts";
|
|
10
10
|
import { MAX_TREE_FILES, type SourceTree } from "../protocol/tools.ts";
|
|
@@ -42,6 +42,32 @@ export function readProjectFile(path: string, options: { root?: string } = {}):
|
|
|
42
42
|
return { bytes: readFileSync(real), type: extname(path).slice(1).toLowerCase() };
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Writes bytes to a project file on the client side, making its directories
|
|
47
|
+
* and overwriting it. Throws WireFailure(invalid-input) for a path outside the
|
|
48
|
+
* project, also one that leaves it through a symbolic link.
|
|
49
|
+
*/
|
|
50
|
+
export function writeProjectFile(path: string, bytes: Uint8Array, options: { root?: string; what?: string } = {}): void {
|
|
51
|
+
const root = options.root ?? projectRoot();
|
|
52
|
+
const refuse = () => new WireFailure("invalid-input", `${options.what ?? path}: give a path relative to the project directory that stays inside it, not ${path}.`);
|
|
53
|
+
let file: string;
|
|
54
|
+
try {
|
|
55
|
+
file = resolveProjectPath(path, root);
|
|
56
|
+
} catch {
|
|
57
|
+
throw refuse();
|
|
58
|
+
}
|
|
59
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
60
|
+
if (!isInside(realpathSync(root), realpathSync(dirname(file)))) throw refuse();
|
|
61
|
+
let link = false;
|
|
62
|
+
try {
|
|
63
|
+
link = lstatSync(file).isSymbolicLink();
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
66
|
+
}
|
|
67
|
+
if (link) throw refuse();
|
|
68
|
+
writeFileSync(file, bytes);
|
|
69
|
+
}
|
|
70
|
+
|
|
45
71
|
/** Directories never staged: version control and the toolkit's own project state. */
|
|
46
72
|
const SKIPPED_DIRECTORIES = new Set([".git", ".hg", ".svn", ".c64-re-tools"]);
|
|
47
73
|
|