@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.
Files changed (67) hide show
  1. package/package.json +1 -1
  2. package/skills/c64-assembler/SKILL.md +6 -3
  3. package/skills/c64-assembler/scripts/assembler.ts +19 -11
  4. package/skills/c64-assembler/scripts/lib/compare.ts +4 -3
  5. package/skills/c64-basic/SKILL.md +1 -0
  6. package/skills/c64-disk/SKILL.md +1 -0
  7. package/skills/c64-disk/scripts/disk.ts +2 -2
  8. package/skills/c64-disk/scripts/lib/containers.ts +4 -4
  9. package/skills/c64-docs/SKILL.md +2 -1
  10. package/skills/c64-docs/scripts/docs.ts +1 -1
  11. package/skills/c64-docs/scripts/lib/links.ts +2 -0
  12. package/skills/c64-emulator/SKILL.md +4 -1
  13. package/skills/c64-knowledge/SKILL.md +12 -6
  14. package/skills/c64-knowledge/scripts/knowledge.ts +41 -25
  15. package/skills/c64-knowledge/scripts/lib/listing.ts +16 -31
  16. package/skills/c64-memory-map/SKILL.md +3 -0
  17. package/skills/c64-memory-map/scripts/memory-map.ts +23 -4
  18. package/skills/c64-provenance/SKILL.md +5 -3
  19. package/skills/c64-provenance/scripts/lib/library.ts +28 -5
  20. package/skills/c64-provenance/scripts/lib/separate.ts +34 -6
  21. package/skills/c64-provenance/scripts/provenance.ts +22 -10
  22. package/skills/c64-source/SKILL.md +2 -1
  23. package/skills/c64-source/scripts/lib/source.ts +25 -33
  24. package/skills/c64-source/scripts/source.ts +13 -17
  25. package/skills/c64-static-analysis/SKILL.md +6 -3
  26. package/skills/c64-static-analysis/scripts/lib/findings.ts +23 -8
  27. package/skills/c64-static-analysis/scripts/lib/pointers.ts +38 -25
  28. package/skills/c64-static-analysis/scripts/static-analysis.ts +31 -19
  29. package/skills/c64-testing/SKILL.md +9 -5
  30. package/skills/c64-testing/references/scenario-format.md +5 -3
  31. package/skills/c64-testing/scripts/lib/diff-files.ts +41 -10
  32. package/skills/c64-testing/scripts/lib/recording.ts +3 -3
  33. package/skills/c64-testing/scripts/lib/replay.ts +1 -1
  34. package/skills/c64-testing/scripts/lib/runs.ts +45 -9
  35. package/skills/c64-testing/scripts/lib/scenario.ts +130 -81
  36. package/skills/c64-testing/scripts/testing.ts +9 -6
  37. package/skills/c64-unpacker/SKILL.md +10 -7
  38. package/skills/c64-unpacker/scripts/lib/loads.ts +15 -6
  39. package/skills/c64-unpacker/scripts/lib/machine.ts +61 -3
  40. package/skills/c64-unpacker/scripts/lib/stages.ts +15 -7
  41. package/skills/c64-unpacker/scripts/unpacker.ts +21 -17
  42. package/src/host/vice/adapter.ts +4 -1
  43. package/src/host/vice/session.ts +144 -11
  44. package/src/host-client/image.ts +27 -13
  45. package/src/host-client/steps.ts +41 -5
  46. package/src/host-client/tools.ts +4 -3
  47. package/src/host-client/transfer.ts +22 -29
  48. package/src/knowledge/history.ts +55 -2
  49. package/src/knowledge/import.ts +79 -7
  50. package/src/knowledge/program.ts +31 -2
  51. package/src/knowledge/read.ts +7 -4
  52. package/src/knowledge/schema.ts +3 -2
  53. package/src/knowledge/text.ts +1 -0
  54. package/src/knowledge/write.ts +86 -42
  55. package/src/mcp/inputs.ts +6 -3
  56. package/src/mcp/output.ts +1 -1
  57. package/src/mcp/server.ts +9 -1
  58. package/src/mcp/tools/debug.ts +31 -4
  59. package/src/mcp/tools/machine.ts +3 -1
  60. package/src/mcp/tools/media.ts +5 -1
  61. package/src/mcp/tools/memory.ts +4 -3
  62. package/src/media.ts +57 -0
  63. package/src/opcodes.ts +81 -18
  64. package/src/platform.ts +14 -1
  65. package/src/project.ts +118 -3
  66. package/src/protocol/vice.ts +4 -1
  67. package/src/script.ts +118 -5
@@ -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
- * Reads a PRG of the project: a 2-byte load address, and bytes that end at
41
- * $ffff or before. Refuses a disk, tape or cartridge file and any other file
42
- * that is no PRG with invalid-input; `remedy` tells what to do in its place.
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 readPrg(path: string, remedy = MEDIA_REMEDY): { bytes: Buffer; start: number; end: number; body: Uint8Array } {
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
- let range: ReturnType<typeof prgRange>;
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
  };
@@ -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 record and replay commands of c64-testing have them. Use type for the KERNAL keyboard queue, or open the window and ask the user to press the key";
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
- /** Reads a steps file of the project: a JSON list of action steps. `option` names the option of the file in a refusal. */
388
- export function readActionSteps(path: string, option: string): ActionStep[] {
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
- return checkActionSteps(value, fail);
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. */
@@ -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 file = readProjectFile(request.program);
78
- return (await callTool("petcat.decode", {}, [file.bytes], options)).result;
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 { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
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, resolveProjectPath } from "../project.ts";
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 problem = WRITE_PROBLEMS[(error as NodeJS.ErrnoException | undefined)?.code ?? ""];
80
- return problem === undefined ? undefined : new WireFailure("invalid-input", `${what === undefined ? "" : `${what}: `}${path} ${problem}.`);
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. Throws WireFailure(invalid-input) for a path outside the
86
- * project, also one that leaves it through a symbolic link, and for a path
87
- * that the file system refuses: a folder, a file in place of a folder, no permission.
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 = resolveProjectPath(path, root);
95
- } catch {
96
- throw refuse();
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 {
@@ -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
  }
@@ -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
- if (findings.executed === undefined) return false;
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
- function importRegions(db: DatabaseSync, change: Change, findings: NormalizedFindings, coverage: AddressRange[], result: ImportResult): void {
263
- const { analyzer } = findings;
264
- const space = findings.space ?? DEFAULT_SPACE;
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;
@@ -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 bytes for an analyzer: a PRG for one segment without an entry state, else the 64 KiB. */
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.memory), kind: "flat64k" };
262
+ return { bytes: Buffer.from(entryMemory(image)), kind: "flat64k" };
234
263
  }
235
264
 
236
265
  /**
@@ -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
- runs = [];
278
- for (const item of after) if (item.id <= restored) runs = item.runs;
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];
@@ -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 = 7;
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();
@@ -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. */