@henols/c64-re-tools 2.1.1 → 2.1.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/package.json +1 -1
- package/skills/c64-assembler/SKILL.md +6 -3
- package/skills/c64-assembler/scripts/assembler.ts +19 -11
- package/skills/c64-assembler/scripts/lib/compare.ts +4 -3
- package/skills/c64-basic/SKILL.md +1 -0
- package/skills/c64-disk/SKILL.md +1 -0
- package/skills/c64-disk/scripts/disk.ts +2 -2
- package/skills/c64-disk/scripts/lib/containers.ts +4 -4
- package/skills/c64-docs/SKILL.md +2 -1
- package/skills/c64-docs/scripts/docs.ts +1 -1
- package/skills/c64-docs/scripts/lib/links.ts +2 -0
- package/skills/c64-emulator/SKILL.md +4 -1
- package/skills/c64-knowledge/SKILL.md +12 -6
- package/skills/c64-knowledge/scripts/knowledge.ts +41 -25
- package/skills/c64-knowledge/scripts/lib/listing.ts +16 -31
- package/skills/c64-memory-map/SKILL.md +3 -0
- package/skills/c64-memory-map/scripts/memory-map.ts +23 -4
- package/skills/c64-provenance/SKILL.md +5 -3
- package/skills/c64-provenance/scripts/lib/library.ts +28 -5
- package/skills/c64-provenance/scripts/lib/separate.ts +34 -6
- package/skills/c64-provenance/scripts/provenance.ts +22 -10
- package/skills/c64-source/SKILL.md +2 -1
- package/skills/c64-source/scripts/lib/source.ts +25 -33
- package/skills/c64-source/scripts/source.ts +13 -17
- package/skills/c64-static-analysis/SKILL.md +6 -3
- package/skills/c64-static-analysis/scripts/lib/findings.ts +23 -8
- package/skills/c64-static-analysis/scripts/lib/pointers.ts +38 -25
- package/skills/c64-static-analysis/scripts/static-analysis.ts +31 -19
- package/skills/c64-testing/SKILL.md +9 -5
- package/skills/c64-testing/references/scenario-format.md +5 -3
- package/skills/c64-testing/scripts/lib/diff-files.ts +41 -10
- package/skills/c64-testing/scripts/lib/recording.ts +3 -3
- package/skills/c64-testing/scripts/lib/replay.ts +1 -1
- package/skills/c64-testing/scripts/lib/runs.ts +45 -9
- package/skills/c64-testing/scripts/lib/scenario.ts +130 -81
- package/skills/c64-testing/scripts/testing.ts +9 -6
- package/skills/c64-unpacker/SKILL.md +10 -7
- package/skills/c64-unpacker/scripts/lib/loads.ts +15 -6
- package/skills/c64-unpacker/scripts/lib/machine.ts +61 -3
- package/skills/c64-unpacker/scripts/lib/stages.ts +15 -7
- package/skills/c64-unpacker/scripts/unpacker.ts +21 -17
- package/src/host/vice/adapter.ts +4 -1
- package/src/host/vice/session.ts +144 -11
- package/src/host-client/image.ts +27 -13
- package/src/host-client/steps.ts +41 -5
- package/src/host-client/tools.ts +4 -3
- package/src/host-client/transfer.ts +22 -29
- package/src/knowledge/history.ts +55 -2
- package/src/knowledge/import.ts +79 -7
- package/src/knowledge/program.ts +31 -2
- package/src/knowledge/read.ts +7 -4
- package/src/knowledge/schema.ts +3 -2
- package/src/knowledge/text.ts +1 -0
- package/src/knowledge/write.ts +86 -42
- package/src/mcp/inputs.ts +6 -3
- package/src/mcp/output.ts +1 -1
- package/src/mcp/server.ts +9 -1
- package/src/mcp/tools/debug.ts +31 -4
- package/src/mcp/tools/machine.ts +3 -1
- package/src/mcp/tools/media.ts +5 -1
- package/src/mcp/tools/memory.ts +4 -3
- package/src/media.ts +57 -0
- package/src/opcodes.ts +81 -18
- package/src/platform.ts +14 -1
- package/src/project.ts +118 -3
- package/src/protocol/vice.ts +4 -1
- package/src/script.ts +118 -5
package/src/host-client/image.ts
CHANGED
|
@@ -37,14 +37,33 @@ const MEDIA_KINDS: Record<string, string> = {
|
|
|
37
37
|
export const MEDIA_REMEDY = "Get the PRG out of it with disk.ts read (or disk.ts crt for a cartridge) of the c64-disk skill";
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
40
|
+
* Refuses a file that is no PRG by its extension (`type`, lowercase, without
|
|
41
|
+
* the dot): invalid-input. A disk, tape or cartridge file gets `remedy`. A PRG
|
|
42
|
+
* ends in .prg in any letter case, or has no extension, as c1541 writes the
|
|
43
|
+
* names of the files that it reads from a disk; every other extension is
|
|
44
|
+
* refused. `accepted` names what the reader takes, for the message.
|
|
43
45
|
*/
|
|
44
|
-
export function
|
|
45
|
-
const { bytes, type } = readProjectFile(path);
|
|
46
|
+
export function refuseNotPrg(path: string, type: string, remedy = MEDIA_REMEDY, accepted = "a PRG"): void {
|
|
46
47
|
const media = MEDIA_KINDS[type];
|
|
47
48
|
if (media !== undefined) throw new Refusal("invalid-input", `${path} is ${media} (.${type}), not a PRG. ${remedy}.`);
|
|
49
|
+
if (type !== "prg" && type !== "") {
|
|
50
|
+
throw new Refusal("invalid-input", `${path} is not ${accepted}: a PRG has the extension .prg or no extension, not .${type}. Give the PRG of the program, or rename the file to .prg when it is a PRG.`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Reads a PRG of the project: a 2-byte load address, and bytes that end at
|
|
56
|
+
* $ffff or before. Refuses with invalid-input a file that is no PRG by its
|
|
57
|
+
* extension (refuseNotPrg) or by its bytes; `remedy` tells what to do in place
|
|
58
|
+
* of a disk, tape or cartridge file. A file of fewer than 3 bytes is refused
|
|
59
|
+
* as too short: no remedy applies.
|
|
60
|
+
*/
|
|
61
|
+
export function readPrg(path: string, remedy = MEDIA_REMEDY, accepted = "a PRG"): { bytes: Buffer; start: number; end: number; body: Uint8Array } {
|
|
62
|
+
const { bytes, type } = readProjectFile(path);
|
|
63
|
+
refuseNotPrg(path, type, remedy, accepted);
|
|
64
|
+
if (bytes.length < 3) {
|
|
65
|
+
throw new Refusal("invalid-input", `${path} is too short for a PRG: it has ${bytes.length} ${bytes.length === 1 ? "byte" : "bytes"}, and a PRG has a 2-byte load address and at least one byte. Give the complete PRG file.`);
|
|
66
|
+
}
|
|
48
67
|
try {
|
|
49
68
|
return { bytes, ...prgRange(bytes) };
|
|
50
69
|
} catch (error) {
|
|
@@ -53,18 +72,13 @@ export function readPrg(path: string, remedy = MEDIA_REMEDY): { bytes: Buffer; s
|
|
|
53
72
|
}
|
|
54
73
|
}
|
|
55
74
|
|
|
56
|
-
/** Reads an image.json or a PRG, relative to the project folder. */
|
|
75
|
+
/** Reads an image.json or a PRG, relative to the project folder. Refuses every other file as readPrg does. */
|
|
57
76
|
export function readImage(path: string): Image {
|
|
58
|
-
const { bytes } = readProjectFile(path);
|
|
59
77
|
if (!path.endsWith(".json")) {
|
|
60
|
-
|
|
61
|
-
try {
|
|
62
|
-
range = prgRange(bytes);
|
|
63
|
-
} catch (error) {
|
|
64
|
-
throw new Refusal("invalid-input", `${path}: ${(error as Error).message}`);
|
|
65
|
-
}
|
|
78
|
+
const range = readPrg(path, MEDIA_REMEDY, "an image.json of the c64-unpacker skill or a PRG");
|
|
66
79
|
return { segments: [{ start: range.start, end: range.end }], byteAt: (address) => range.body[address - range.start]! };
|
|
67
80
|
}
|
|
81
|
+
const { bytes } = readProjectFile(path);
|
|
68
82
|
const fail = (what: string): never => {
|
|
69
83
|
throw new Refusal("invalid-input", `${path} is not an image.json of the c64-unpacker skill: ${what}`);
|
|
70
84
|
};
|
package/src/host-client/steps.ts
CHANGED
|
@@ -228,8 +228,8 @@ function checkTarget(value: unknown, where: string, fail: (message: string) => n
|
|
|
228
228
|
return fail(`${where}.kind must be one of address, memory, raster`);
|
|
229
229
|
}
|
|
230
230
|
|
|
231
|
-
/** What a step of a script without the replay core gets for press. */
|
|
232
|
-
const PRESS_REMEDY = "press pushes keys of the keyboard matrix, and only the
|
|
231
|
+
/** What a step of a script without the replay core gets for press: the scripts that read these steps run without a window. */
|
|
232
|
+
const PRESS_REMEDY = "press pushes keys of the keyboard matrix, and only the replay core of c64-testing has them. Use type for the KERNAL keyboard queue, or put the press step in a record file and play it with testing.ts record of c64-testing";
|
|
233
233
|
|
|
234
234
|
/** Checks the input of one step kind and returns it with the defaults of its MCP tool. */
|
|
235
235
|
function checkInput(kind: ActionStepKind, value: unknown, at: string, fail: (message: string) => never): ActionStep {
|
|
@@ -384,8 +384,42 @@ export function checkActionSteps(value: unknown, fail: (message: string) => neve
|
|
|
384
384
|
});
|
|
385
385
|
}
|
|
386
386
|
|
|
387
|
-
/**
|
|
388
|
-
export function
|
|
387
|
+
/** The names and addresses of an action step: each location that the step resolves when it runs. */
|
|
388
|
+
export function stepLocations(step: ActionStep): Location[] {
|
|
389
|
+
if ("write" in step) return [step.write.address];
|
|
390
|
+
if ("registers" in step) return step.registers.pc === undefined ? [] : [step.registers.pc];
|
|
391
|
+
if (!("runUntil" in step)) return [];
|
|
392
|
+
const { target } = step.runUntil;
|
|
393
|
+
if (target.kind === "raster") return [];
|
|
394
|
+
if (target.kind === "memory") return [target.address];
|
|
395
|
+
return [target.address, ...(target.condition?.kind === "memory" ? [target.condition.address] : [])];
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Resolves every name of the steps with `at` before the steps run, so that an
|
|
400
|
+
* unknown name stops the call before the emulator starts. A refusal of `at` becomes
|
|
401
|
+
* invalid-input that names `where` (for example the option and the file) and the step.
|
|
402
|
+
* `first` is the number of the first of the steps in the file.
|
|
403
|
+
*/
|
|
404
|
+
export function checkStepNames(steps: readonly ActionStep[], at: (location: Location) => number, where: string, first = 1): void {
|
|
405
|
+
steps.forEach((step, index) => {
|
|
406
|
+
for (const location of stepLocations(step)) {
|
|
407
|
+
try {
|
|
408
|
+
at(location);
|
|
409
|
+
} catch (error) {
|
|
410
|
+
if (isRefusal(error)) throw new Refusal("invalid-input", `${where}: step ${index + first}: ${error.message}`);
|
|
411
|
+
throw error;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Reads a steps file of the project: a JSON list of action steps. `option`
|
|
419
|
+
* names the option of the file in a refusal. With `at`, every name of the
|
|
420
|
+
* steps must resolve (checkStepNames).
|
|
421
|
+
*/
|
|
422
|
+
export function readActionSteps(path: string, option: string, at?: (location: Location) => number): ActionStep[] {
|
|
389
423
|
const fail = (message: string): never => {
|
|
390
424
|
throw new Refusal("invalid-input", `${option} ${path}: ${message}`);
|
|
391
425
|
};
|
|
@@ -396,7 +430,9 @@ export function readActionSteps(path: string, option: string): ActionStep[] {
|
|
|
396
430
|
if (error instanceof SyntaxError) return fail(`it is not JSON: ${error.message}`);
|
|
397
431
|
throw error;
|
|
398
432
|
}
|
|
399
|
-
|
|
433
|
+
const steps = checkActionSteps(value, fail);
|
|
434
|
+
if (at !== undefined) checkStepNames(steps, at, `${option} ${path}`);
|
|
435
|
+
return steps;
|
|
400
436
|
}
|
|
401
437
|
|
|
402
438
|
/** Stops the CPU when it runs: a write, a register change or an exact frame count needs it stopped. */
|
package/src/host-client/tools.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { ProtocolError, WireFailure } from "../protocol/messages.ts";
|
|
7
7
|
import { DISK_IMAGE_TYPES, validateToolResult, type C1541Result, type DiskAction, type DiskImageType, type PetcatResult, type ToolOperation, type ToolOperations, type HostInfo, type ToolStatus } from "../protocol/tools.ts";
|
|
8
8
|
import { HostConnection } from "./connect.ts";
|
|
9
|
+
import { readPrg } from "./image.ts";
|
|
9
10
|
import { readProjectFile } from "./transfer.ts";
|
|
10
11
|
|
|
11
12
|
export interface ToolCallOptions {
|
|
@@ -72,10 +73,10 @@ export async function inspectDisk(request: InspectDiskRequest, options: ToolCall
|
|
|
72
73
|
return result.action === "read" && result.found ? { ...result, data: attachments[0]! } : result;
|
|
73
74
|
}
|
|
74
75
|
|
|
75
|
-
/** petcat.decode: the C64 BASIC V2 listing of a project program and its machine-code handoffs. */
|
|
76
|
+
/** petcat.decode: the C64 BASIC V2 listing of a project program and its machine-code handoffs. Refuses a file that is no PRG as readPrg does. */
|
|
76
77
|
export async function decodeBasic(request: { program: string }, options: ToolCallOptions = {}): Promise<PetcatResult> {
|
|
77
|
-
const
|
|
78
|
-
return (await callTool("petcat.decode", {}, [
|
|
78
|
+
const { bytes } = readPrg(request.program);
|
|
79
|
+
return (await callTool("petcat.decode", {}, [bytes], options)).result;
|
|
79
80
|
}
|
|
80
81
|
|
|
81
82
|
/** host.status: the Host Runtime, and each native tool on the host, found and run once. */
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
// The host never sees a client path: only bytes and the file's type.
|
|
3
3
|
|
|
4
4
|
import { createHash } from "node:crypto";
|
|
5
|
-
import {
|
|
5
|
+
import { lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { dirname, extname, join, relative, sep } from "node:path";
|
|
7
7
|
|
|
8
|
-
import { isInside, projectRoot, resolveProject, resolveProjectFile,
|
|
8
|
+
import { checkOutputPath, isInside, projectRoot, resolveProject, resolveProjectFile, writeProblem } from "../project.ts";
|
|
9
9
|
import { isRefusal } from "../errors.ts";
|
|
10
10
|
import { MAX_ATTACHMENT_BYTES } from "../protocol/framing.ts";
|
|
11
11
|
import { WireFailure } from "../protocol/messages.ts";
|
|
@@ -20,6 +20,11 @@ export function projectFolder(project: string | undefined): string {
|
|
|
20
20
|
return resolveProject(project, process.cwd(), { remedy: "Give project: <folder>" });
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/** Refuses an MCP `out` before the tool does any work, with the checks that every --out of a script gets. */
|
|
24
|
+
export function checkOut(path: string, project: string | undefined): void {
|
|
25
|
+
checkOutputPath(path, "file", { root: projectFolder(project), what: "out" });
|
|
26
|
+
}
|
|
27
|
+
|
|
23
28
|
/** The project folder of this process (--project or the project lookup), or a WireFailure. */
|
|
24
29
|
function currentProject(): string {
|
|
25
30
|
return projectRoot();
|
|
@@ -36,17 +41,17 @@ export interface ProjectFile {
|
|
|
36
41
|
* project rules refuse, not-found when nothing is there, limit-exceeded when
|
|
37
42
|
* the file is too large to transfer.
|
|
38
43
|
*/
|
|
39
|
-
export function readProjectFile(path: string, options: { root?: string } = {}): ProjectFile {
|
|
44
|
+
export function readProjectFile(path: string, options: { root?: string; what?: string } = {}): ProjectFile {
|
|
40
45
|
const root = options.root ?? currentProject();
|
|
41
46
|
let real: string;
|
|
42
47
|
try {
|
|
43
48
|
real = resolveProjectFile(path, root);
|
|
44
49
|
} catch (error) {
|
|
45
50
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
46
|
-
throw new WireFailure("not-found", `There is no file at ${path} in the project folder.`);
|
|
51
|
+
throw new WireFailure("not-found", `${options.what === undefined ? "" : `${options.what}: `}There is no file at ${path} in the project folder.`);
|
|
47
52
|
}
|
|
48
53
|
if (isRefusal(error) && error.code === "invalid-input") {
|
|
49
|
-
throw new WireFailure("invalid-input", `${path}: give a path relative to the project folder that stays inside it.`);
|
|
54
|
+
throw new WireFailure("invalid-input", `${options.what ?? path}: give a path relative to the project folder that stays inside it, not ${path}.`);
|
|
50
55
|
}
|
|
51
56
|
throw error;
|
|
52
57
|
}
|
|
@@ -58,49 +63,37 @@ export function readProjectFile(path: string, options: { root?: string } = {}):
|
|
|
58
63
|
return { bytes: readFileSync(real), type: extname(path).slice(1).toLowerCase() };
|
|
59
64
|
}
|
|
60
65
|
|
|
61
|
-
/** Why a file cannot be written, by the error code of the file system; the codes that are a fault of the path. */
|
|
62
|
-
const WRITE_PROBLEMS: Record<string, string> = {
|
|
63
|
-
EISDIR: "is a folder. Give the path of a file",
|
|
64
|
-
EEXIST: "has a file where a folder must be. Give a path whose folders are folders",
|
|
65
|
-
ENOTDIR: "has a file where a folder must be. Give a path whose folders are folders",
|
|
66
|
-
ENOENT: "is in a folder that does not exist",
|
|
67
|
-
EACCES: "cannot be written: there is no permission. Give a path that you can write",
|
|
68
|
-
EPERM: "cannot be written: there is no permission. Give a path that you can write",
|
|
69
|
-
EROFS: "is on a file system that is read-only. Give another path",
|
|
70
|
-
ENAMETOOLONG: "is too long. Give a shorter path",
|
|
71
|
-
};
|
|
72
|
-
|
|
73
66
|
/**
|
|
74
67
|
* The refusal for a write that the file system refused because of the path
|
|
75
68
|
* (a folder, a file in place of a folder, no permission): invalid-input with
|
|
76
69
|
* the path. Undefined for another error, which is no fault of the path.
|
|
77
70
|
*/
|
|
78
71
|
export function writeRefusal(error: unknown, path: string, what?: string): WireFailure | undefined {
|
|
79
|
-
const
|
|
80
|
-
return
|
|
72
|
+
const message = writeProblem(error, path, what);
|
|
73
|
+
return message === undefined ? undefined : new WireFailure("invalid-input", message);
|
|
81
74
|
}
|
|
82
75
|
|
|
83
76
|
/**
|
|
84
77
|
* Writes bytes to a project file on the client side, making its directories
|
|
85
|
-
* and overwriting it.
|
|
86
|
-
*
|
|
87
|
-
*
|
|
78
|
+
* and overwriting it. First the check of checkOutputPath, which a script also
|
|
79
|
+
* does on its output options before any work. Throws WireFailure(invalid-input)
|
|
80
|
+
* for a path outside the project, also one that leaves it through a symbolic
|
|
81
|
+
* link, for a path in the knowledge, and for a path that the file system
|
|
82
|
+
* refuses: a folder, a file in place of a folder, no permission.
|
|
88
83
|
*/
|
|
89
84
|
export function writeProjectFile(path: string, bytes: Uint8Array, options: { root?: string; what?: string } = {}): void {
|
|
90
85
|
const root = options.root ?? currentProject();
|
|
91
86
|
const refuse = () => new WireFailure("invalid-input", `${options.what ?? path}: give a path relative to the project folder that stays inside it, not ${path}.`);
|
|
92
87
|
let file: string;
|
|
93
88
|
try {
|
|
94
|
-
file =
|
|
95
|
-
} catch {
|
|
96
|
-
throw
|
|
89
|
+
file = checkOutputPath(path, "file", { root, ...(options.what === undefined ? {} : { what: options.what }) });
|
|
90
|
+
} catch (error) {
|
|
91
|
+
if (isRefusal(error)) throw new WireFailure(error.code, error.message);
|
|
92
|
+
throw error;
|
|
97
93
|
}
|
|
98
|
-
// The nearest folder that exists must be inside the project before a folder is made below it.
|
|
99
|
-
let existing = dirname(file);
|
|
100
|
-
while (!existsSync(existing)) existing = dirname(existing);
|
|
101
|
-
if (!isInside(realpathSync(root), realpathSync(existing))) throw refuse();
|
|
102
94
|
try {
|
|
103
95
|
mkdirSync(dirname(file), { recursive: true });
|
|
96
|
+
// Again after the folders are made: another process can change the folders between the check and the write.
|
|
104
97
|
if (!isInside(realpathSync(root), realpathSync(dirname(file)))) throw refuse();
|
|
105
98
|
let link = false;
|
|
106
99
|
try {
|
package/src/knowledge/history.ts
CHANGED
|
@@ -37,7 +37,7 @@ import {
|
|
|
37
37
|
type SuppressionRow,
|
|
38
38
|
type SymbolRow,
|
|
39
39
|
} from "./read.ts";
|
|
40
|
-
import { DEFAULT_SPACE, type EvidenceKind, type Origin, type Space } from "./schema.ts";
|
|
40
|
+
import { DEFAULT_SPACE, type CoverageKind, type EvidenceKind, type Origin, type Space } from "./schema.ts";
|
|
41
41
|
import { compareText, NO_REVISION } from "./text.ts";
|
|
42
42
|
import type { Evidence } from "./write.ts";
|
|
43
43
|
|
|
@@ -51,12 +51,47 @@ export interface Revision {
|
|
|
51
51
|
toolVersion?: string;
|
|
52
52
|
/** A revert: the revision whose knowledge it restored. */
|
|
53
53
|
restores?: string;
|
|
54
|
+
/** A revert of one space: that space. A revert without it restored every space. */
|
|
55
|
+
restoresSpace?: Space;
|
|
54
56
|
/** What the change rests on. */
|
|
55
57
|
evidence?: Evidence[];
|
|
56
58
|
}
|
|
57
59
|
|
|
60
|
+
/** A memory row of the program: the bytes from an address. */
|
|
61
|
+
export interface MemoryRow {
|
|
62
|
+
space: Space;
|
|
63
|
+
address: number;
|
|
64
|
+
/** The count of bytes in the row. */
|
|
65
|
+
bytes: number;
|
|
66
|
+
origin: Origin;
|
|
67
|
+
revision: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The entry of the program, and whether it has a machine state and stages. */
|
|
71
|
+
export interface EntryRow {
|
|
72
|
+
space: Space;
|
|
73
|
+
address: number;
|
|
74
|
+
state: boolean;
|
|
75
|
+
stages: number;
|
|
76
|
+
origin: Origin;
|
|
77
|
+
revision: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A coverage range of one kind. */
|
|
81
|
+
export interface CoverageRow {
|
|
82
|
+
space: Space;
|
|
83
|
+
kind: CoverageKind;
|
|
84
|
+
start: number;
|
|
85
|
+
end: number;
|
|
86
|
+
origin: Origin;
|
|
87
|
+
revision: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
58
90
|
/** One knowledge row with its validity: from its first revision until the revision that closed it. */
|
|
59
91
|
export type HistoryEntry =
|
|
92
|
+
| { entity: "memory"; row: MemoryRow; from: string; to?: string }
|
|
93
|
+
| { entity: "entry"; row: EntryRow; from: string; to?: string }
|
|
94
|
+
| { entity: "coverage"; row: CoverageRow; from: string; to?: string }
|
|
60
95
|
| { entity: "symbol"; row: SymbolRow; from: string; to?: string }
|
|
61
96
|
| { entity: "analyzer-name"; row: SymbolRow; from: string; to?: string }
|
|
62
97
|
| { entity: "region"; row: RegionRow; from: string; to?: string }
|
|
@@ -82,6 +117,7 @@ function toRevision(row: Raw): Revision {
|
|
|
82
117
|
if (row.input_hash !== null) revision.inputHash = row.input_hash as string;
|
|
83
118
|
if (row.tool_version !== null) revision.toolVersion = row.tool_version as string;
|
|
84
119
|
if (row.restored_revision !== null) revision.restores = row.restored_revision as string;
|
|
120
|
+
if (row.restored_space !== null && row.restored_space !== undefined) revision.restoresSpace = row.restored_space as Space;
|
|
85
121
|
return revision;
|
|
86
122
|
}
|
|
87
123
|
|
|
@@ -183,6 +219,7 @@ export interface InitialRevision {
|
|
|
183
219
|
inputHash?: undefined;
|
|
184
220
|
toolVersion?: undefined;
|
|
185
221
|
restores?: undefined;
|
|
222
|
+
restoresSpace?: undefined;
|
|
186
223
|
evidence?: undefined;
|
|
187
224
|
}
|
|
188
225
|
|
|
@@ -213,9 +250,25 @@ export function revision(db: DatabaseSync | undefined, text: string): RevisionCh
|
|
|
213
250
|
["macro", "macros", MACRO_COLUMNS, toMacro],
|
|
214
251
|
["macro-use", "macro_uses", MACRO_USE_COLUMNS, toMacroUse],
|
|
215
252
|
] as const;
|
|
253
|
+
// The program: its memory, its entry and its coverage.
|
|
254
|
+
const program = [
|
|
255
|
+
["memory", "memory", "space, address, length(bytes) / 2 AS bytes, origin, valid_from_revision", (row: Raw): MemoryRow => ({ space: row.space as Space, address: row.address as number, bytes: row.bytes as number, origin: row.origin as Origin, revision: row.valid_from_revision as string })],
|
|
256
|
+
[
|
|
257
|
+
"entry",
|
|
258
|
+
"entries",
|
|
259
|
+
"space, address, state IS NOT NULL AS has_state, json_array_length(coalesce(stages, '[]')) AS stage_count, origin, valid_from_revision",
|
|
260
|
+
(row: Raw): EntryRow => ({ space: row.space as Space, address: row.address as number, state: row.has_state === 1, stages: row.stage_count as number, origin: row.origin as Origin, revision: row.valid_from_revision as string }),
|
|
261
|
+
],
|
|
262
|
+
[
|
|
263
|
+
"coverage",
|
|
264
|
+
"coverage",
|
|
265
|
+
"space, kind, start_address, end_address, origin, valid_from_revision",
|
|
266
|
+
(row: Raw): CoverageRow => ({ space: row.space as Space, kind: row.kind as CoverageKind, start: row.start_address as number, end: row.end_address as number, origin: row.origin as Origin, revision: row.valid_from_revision as string }),
|
|
267
|
+
],
|
|
268
|
+
] as const;
|
|
216
269
|
const added: HistoryEntry[] = [];
|
|
217
270
|
const closed: HistoryEntry[] = [];
|
|
218
|
-
for (const [entity, table, columns, map] of tables) {
|
|
271
|
+
for (const [entity, table, columns, map] of [...tables, ...program]) {
|
|
219
272
|
for (const row of db.prepare(`SELECT ${columns}, valid_to_revision FROM ${table} WHERE valid_from_revision = ?`).all(id) as Raw[]) {
|
|
220
273
|
added.push(entry(entity, map(row), row));
|
|
221
274
|
}
|
package/src/knowledge/import.ts
CHANGED
|
@@ -77,7 +77,18 @@ export type ImportConflict =
|
|
|
77
77
|
current: { address: number; name: string; kind: SymbolKind; origin: Origin };
|
|
78
78
|
finding: { name: string; kind: SymbolKind };
|
|
79
79
|
}
|
|
80
|
-
| { category: "region"; start: number; end: number; current: { type: RegionType; origin: Origin }; finding: { type: RegionType } }
|
|
80
|
+
| { category: "region"; start: number; end: number; current: { type: RegionType; origin: Origin }; finding: { type: RegionType } }
|
|
81
|
+
| {
|
|
82
|
+
/** A data finding over code that a run executed or that the user or the LLM named: the finding is not imported. */
|
|
83
|
+
category: "code-evidence";
|
|
84
|
+
start: number;
|
|
85
|
+
end: number;
|
|
86
|
+
finding: { type: RegionType };
|
|
87
|
+
/** The first executed instruction start in the range. */
|
|
88
|
+
executed?: number;
|
|
89
|
+
/** The first code name (label or routine) of the user or the LLM in the range. */
|
|
90
|
+
symbol?: { address: number; name: string; kind: SymbolKind; origin: Origin };
|
|
91
|
+
};
|
|
81
92
|
|
|
82
93
|
export interface ImportCounts {
|
|
83
94
|
added: number;
|
|
@@ -252,16 +263,53 @@ const YIELDS_TO_HEURISTIC = new Set<Origin>(["dxa", "ghidra"]);
|
|
|
252
263
|
/** Analyzers whose data gives way to a code finding over an executed instruction start. */
|
|
253
264
|
const YIELDS_TO_EXECUTED_CODE = new Set<Origin>(["dxa", "ghidra"]);
|
|
254
265
|
|
|
266
|
+
/** The first address in the range that is an executed instruction start, or undefined. */
|
|
267
|
+
function firstExecuted(findings: NormalizedFindings, start: number, end: number): number | undefined {
|
|
268
|
+
if (findings.executed === undefined) return undefined;
|
|
269
|
+
for (let address = start; address <= end; address++) if (findings.executed(address)) return address;
|
|
270
|
+
return undefined;
|
|
271
|
+
}
|
|
272
|
+
|
|
255
273
|
/** True when an address in the range is an executed instruction start. */
|
|
256
274
|
function holdsExecuted(findings: NormalizedFindings, start: number, end: number): boolean {
|
|
257
|
-
|
|
258
|
-
for (let address = start; address <= end; address++) if (findings.executed(address)) return true;
|
|
259
|
-
return false;
|
|
275
|
+
return firstExecuted(findings, start, end) !== undefined;
|
|
260
276
|
}
|
|
261
277
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
278
|
+
/**
|
|
279
|
+
* The data findings of DXA or Ghidra that code evidence contradicts: an
|
|
280
|
+
* executed instruction start, or a code name (label or routine) of the user or
|
|
281
|
+
* the LLM, in the range. Evidence wins over an analyzer guess: such a finding
|
|
282
|
+
* is not imported, and the analyzer's own code there stays.
|
|
283
|
+
*/
|
|
284
|
+
function contradictedData(db: DatabaseSync, findings: NormalizedFindings, space: Space): Array<ImportConflict & { category: "code-evidence" }> {
|
|
285
|
+
if (!YIELDS_TO_EXECUTED_CODE.has(findings.analyzer)) return [];
|
|
286
|
+
const names = db.prepare(`SELECT ${SYMBOL_COLUMNS} FROM current_symbols WHERE space = ? AND origin IN ('user', 'llm') AND kind IN ('label', 'routine') AND address BETWEEN ? AND ? ORDER BY address LIMIT 1`);
|
|
287
|
+
const found: Array<ImportConflict & { category: "code-evidence" }> = [];
|
|
288
|
+
for (const region of findings.regions) {
|
|
289
|
+
if (isCode(region.type) || region.type === "dual-use") continue;
|
|
290
|
+
const executed = firstExecuted(findings, region.start, region.end);
|
|
291
|
+
const raw = names.get(space, region.start, region.end) as Record<string, unknown> | undefined;
|
|
292
|
+
if (executed === undefined && raw === undefined) continue;
|
|
293
|
+
const symbol = raw === undefined ? undefined : toSymbol(raw);
|
|
294
|
+
found.push({
|
|
295
|
+
category: "code-evidence",
|
|
296
|
+
start: region.start,
|
|
297
|
+
end: region.end,
|
|
298
|
+
finding: { type: region.type },
|
|
299
|
+
...(executed === undefined ? {} : { executed }),
|
|
300
|
+
...(symbol === undefined ? {} : { symbol: { address: symbol.address, name: symbol.name, kind: symbol.kind, origin: symbol.origin } }),
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
return found;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function importRegions(db: DatabaseSync, change: Change, given: NormalizedFindings, coverage: AddressRange[], result: ImportResult): void {
|
|
307
|
+
const { analyzer } = given;
|
|
308
|
+
const space = given.space ?? DEFAULT_SPACE;
|
|
309
|
+
// Data findings over code evidence are conflicts, not facts.
|
|
310
|
+
const contradicted = contradictedData(db, given, space);
|
|
311
|
+
result.conflicts.push(...contradicted);
|
|
312
|
+
const findings = { ...given, regions: given.regions.filter((region) => !contradicted.some((item) => item.start === region.start && item.end === region.end)) };
|
|
265
313
|
if (analyzer === "heuristic") {
|
|
266
314
|
// An idiom is specific evidence about its bytes: the DXA or Ghidra region there gives way, its parts outside stay.
|
|
267
315
|
for (const region of findings.regions) {
|
|
@@ -333,6 +381,14 @@ function importRegions(db: DatabaseSync, change: Change, findings: NormalizedFin
|
|
|
333
381
|
if (suppressed.length > 0) result.regions.suppressed++;
|
|
334
382
|
for (const piece of subtract(region, [...protectedRows, ...suppressed])) desired.set(`${piece.start}-${piece.end}`, { ...piece, type: region.type });
|
|
335
383
|
}
|
|
384
|
+
// Under a contradicted data finding, the analyzer's own regions stay: they join the findings of the same type next to them.
|
|
385
|
+
for (const row of own) {
|
|
386
|
+
for (const item of contradicted) {
|
|
387
|
+
const inside = { start: Math.max(row.start, item.start, range.start), end: Math.min(row.end, item.end, range.end) };
|
|
388
|
+
if (inside.start > inside.end) continue;
|
|
389
|
+
for (const piece of subtract(inside, [...protectedRows, ...suppressionsOverlapping(db, "region", inside.start, inside.end, space)])) keepPiece(desired, { ...piece, type: row.type });
|
|
390
|
+
}
|
|
391
|
+
}
|
|
336
392
|
const retiredRanges = new Set<string>();
|
|
337
393
|
for (const row of own) {
|
|
338
394
|
const inside = { start: Math.max(row.start, range.start), end: Math.min(row.end, range.end) };
|
|
@@ -357,6 +413,22 @@ function importRegions(db: DatabaseSync, change: Change, findings: NormalizedFin
|
|
|
357
413
|
}
|
|
358
414
|
}
|
|
359
415
|
|
|
416
|
+
/** Puts a kept piece into the desired regions, joined with the pieces of the same type that touch or overlap it. */
|
|
417
|
+
function keepPiece(desired: Map<string, { start: number; end: number; type: RegionType }>, piece: { start: number; end: number; type: RegionType }): void {
|
|
418
|
+
let joined = { ...piece };
|
|
419
|
+
for (let found = true; found; ) {
|
|
420
|
+
found = false;
|
|
421
|
+
for (const [key, other] of desired) {
|
|
422
|
+
if (other.type !== joined.type || other.end + 1 < joined.start || joined.end + 1 < other.start) continue;
|
|
423
|
+
desired.delete(key);
|
|
424
|
+
joined = { start: Math.min(joined.start, other.start), end: Math.max(joined.end, other.end), type: joined.type };
|
|
425
|
+
found = true;
|
|
426
|
+
break;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
desired.set(`${joined.start}-${joined.end}`, joined);
|
|
430
|
+
}
|
|
431
|
+
|
|
360
432
|
function importReferences(db: DatabaseSync, change: Change, findings: NormalizedFindings, coverage: AddressRange[], result: ImportResult): void {
|
|
361
433
|
const { analyzer } = findings;
|
|
362
434
|
const space = findings.space ?? DEFAULT_SPACE;
|
package/src/knowledge/program.ts
CHANGED
|
@@ -224,13 +224,42 @@ export function requireProgram(db: DatabaseSync | undefined, space: Space = DEFA
|
|
|
224
224
|
throw new Refusal("not-found", `The knowledge has no program${where}. Put it in with knowledge.ts image <image.json|prg>${option} of the c64-knowledge skill.`);
|
|
225
225
|
}
|
|
226
226
|
|
|
227
|
-
/** The
|
|
227
|
+
/** The pages of an entry state: $0000-$03ff as the capture wrote them, 256 bytes as hex for each page. */
|
|
228
|
+
const STATE_PAGES = ["$0000", "$0100", "$0200", "$0300"] as const;
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The memory that the program has at its entry: the segments, and below them
|
|
232
|
+
* $0002-$03ff of the entry state (the zero page, the stack and the system
|
|
233
|
+
* pages hold code and data that the loader left). $0000 and $0001 are the
|
|
234
|
+
* CPU port, not memory. A segment byte wins over a state byte.
|
|
235
|
+
*/
|
|
236
|
+
export function entryMemory(image: ProgramImage): Uint8Array {
|
|
237
|
+
const memory = new Uint8Array(image.memory);
|
|
238
|
+
const pages = image.state?.pages;
|
|
239
|
+
if (typeof pages !== "object" || pages === null) return memory;
|
|
240
|
+
const inSegment = (address: number) => image.segments.some((segment) => address >= segment.start && address <= segment.end);
|
|
241
|
+
STATE_PAGES.forEach((page, index) => {
|
|
242
|
+
const hex = (pages as Record<string, unknown>)[page];
|
|
243
|
+
if (typeof hex !== "string" || !/^(?:[0-9a-fA-F]{2}){1,256}$/.test(hex)) return;
|
|
244
|
+
Buffer.from(hex, "hex").forEach((byte, offset) => {
|
|
245
|
+
const address = index * 0x100 + offset;
|
|
246
|
+
if (address >= 2 && !inSegment(address)) memory[address] = byte;
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
return memory;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* The bytes for an analyzer: a PRG for one segment without an entry state,
|
|
254
|
+
* else the 64 KiB with the entry-state pages (entryMemory). DXA, Ghidra and
|
|
255
|
+
* c64-source get the same bytes.
|
|
256
|
+
*/
|
|
228
257
|
export function analyzedBytes(image: ProgramImage): { bytes: Buffer; kind: "prg" | "flat64k" } {
|
|
229
258
|
if (image.segments.length === 1 && image.state === undefined) {
|
|
230
259
|
const [segment] = image.segments;
|
|
231
260
|
return { bytes: Buffer.concat([Buffer.from([segment!.start & 0xff, segment!.start >> 8]), Buffer.from(image.memory.subarray(segment!.start, segment!.end + 1))]), kind: "prg" };
|
|
232
261
|
}
|
|
233
|
-
return { bytes: Buffer.from(image
|
|
262
|
+
return { bytes: Buffer.from(entryMemory(image)), kind: "flat64k" };
|
|
234
263
|
}
|
|
235
264
|
|
|
236
265
|
/**
|
package/src/knowledge/read.ts
CHANGED
|
@@ -255,18 +255,19 @@ export interface CoverageRun {
|
|
|
255
255
|
* The imports of coverage maps that the knowledge has, by name and then oldest
|
|
256
256
|
* first: the last import of a name is the latest. A revert gives back the maps
|
|
257
257
|
* of the revision that it restores, so a map that a revert removed is not known.
|
|
258
|
+
* A revert of one space gives back only the maps of that space.
|
|
258
259
|
* With `at`, the maps of the knowledge at that revision.
|
|
259
260
|
*/
|
|
260
261
|
export function coverageRuns(db: DatabaseSync | undefined, at?: string): CoverageRun[] {
|
|
261
262
|
if (db === undefined || at === NO_REVISION) return [];
|
|
262
263
|
const rows = db
|
|
263
264
|
.prepare(
|
|
264
|
-
`SELECT r.id AS id, r.operation AS operation, r.restored_revision AS restored, e.ref AS name, e.sha256 AS sha256, coalesce(e.space, '${DEFAULT_SPACE}') AS space
|
|
265
|
+
`SELECT r.id AS id, r.operation AS operation, r.restored_revision AS restored, r.restored_space AS restoredSpace, e.ref AS name, e.sha256 AS sha256, coalesce(e.space, '${DEFAULT_SPACE}') AS space
|
|
265
266
|
FROM revisions r LEFT JOIN evidence e ON e.revision = r.id AND e.kind = 'coverage'
|
|
266
267
|
WHERE (r.operation = 'coverage' AND e.id IS NOT NULL) OR (r.operation = 'revert' AND r.restored_revision IS NOT NULL)
|
|
267
268
|
ORDER BY r.id, e.id`,
|
|
268
269
|
)
|
|
269
|
-
.all() as Array<{ id: string; operation: string; restored: string | null; name: string | null; sha256: string | null; space: Space }>;
|
|
270
|
+
.all() as Array<{ id: string; operation: string; restored: string | null; restoredSpace: Space | null; name: string | null; sha256: string | null; space: Space }>;
|
|
270
271
|
// The maps after each revision that changed them, oldest first.
|
|
271
272
|
const after: Array<{ id: string; runs: CoverageRun[] }> = [];
|
|
272
273
|
let runs: CoverageRun[] = [];
|
|
@@ -274,8 +275,10 @@ export function coverageRuns(db: DatabaseSync | undefined, at?: string): Coverag
|
|
|
274
275
|
if (at !== undefined && row.id > at) break;
|
|
275
276
|
if (row.operation === "revert") {
|
|
276
277
|
const restored = row.restored!;
|
|
277
|
-
|
|
278
|
-
for (const item of after) if (item.id <= restored)
|
|
278
|
+
let then: CoverageRun[] = [];
|
|
279
|
+
for (const item of after) if (item.id <= restored) then = item.runs;
|
|
280
|
+
const only = row.restoredSpace;
|
|
281
|
+
runs = only === null ? then : [...runs.filter((run) => run.space !== only), ...then.filter((run) => run.space === only)];
|
|
279
282
|
} else {
|
|
280
283
|
const run: CoverageRun = { name: row.name!, sha256: row.sha256!, space: row.space, revision: row.id };
|
|
281
284
|
if (!runs.some((item) => item.name === run.name && item.sha256 === run.sha256 && item.space === run.space && item.revision === run.revision)) runs = [...runs, run];
|
package/src/knowledge/schema.ts
CHANGED
|
@@ -114,7 +114,8 @@ function createSchema(db: DatabaseSync): void {
|
|
|
114
114
|
input_hash TEXT,
|
|
115
115
|
tool_version TEXT,
|
|
116
116
|
description TEXT,
|
|
117
|
-
restored_revision TEXT
|
|
117
|
+
restored_revision TEXT,
|
|
118
|
+
restored_space TEXT
|
|
118
119
|
);
|
|
119
120
|
CREATE TABLE symbols (
|
|
120
121
|
id TEXT PRIMARY KEY,
|
|
@@ -317,7 +318,7 @@ function createSchema(db: DatabaseSync): void {
|
|
|
317
318
|
}
|
|
318
319
|
|
|
319
320
|
/** The version of the index this build makes. An index with another version is emptied and made again. */
|
|
320
|
-
export const INDEX_VERSION =
|
|
321
|
+
export const INDEX_VERSION = 8;
|
|
321
322
|
|
|
322
323
|
function indexVersion(db: DatabaseSync): number | undefined {
|
|
323
324
|
const hasMeta = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'meta'").get();
|
package/src/knowledge/text.ts
CHANGED
|
@@ -188,6 +188,7 @@ const REVISION_FIELDS: readonly Field[] = [
|
|
|
188
188
|
field("inputHash", "input_hash", "text", true),
|
|
189
189
|
field("toolVersion", "tool_version", "text", true),
|
|
190
190
|
field("restores", "restored_revision", "text", true),
|
|
191
|
+
field("restoresSpace", "restored_space", "text", true),
|
|
191
192
|
];
|
|
192
193
|
|
|
193
194
|
/** The SQL name of a table: "references" is a keyword. */
|