@henols/c64-re-tools 2.1.0 → 2.1.2

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 (73) hide show
  1. package/package.json +1 -1
  2. package/skills/c64-assembler/SKILL.md +7 -3
  3. package/skills/c64-assembler/scripts/assembler.ts +29 -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 +3 -2
  7. package/skills/c64-disk/scripts/disk.ts +26 -12
  8. package/skills/c64-disk/scripts/lib/containers.ts +10 -11
  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 +5 -3
  12. package/skills/c64-docs/scripts/lib/reference.ts +2 -2
  13. package/skills/c64-emulator/SKILL.md +9 -2
  14. package/skills/c64-knowledge/SKILL.md +9 -5
  15. package/skills/c64-knowledge/scripts/knowledge.ts +91 -53
  16. package/skills/c64-knowledge/scripts/lib/listing.ts +8 -30
  17. package/skills/c64-memory-map/SKILL.md +2 -1
  18. package/skills/c64-memory-map/scripts/memory-map.ts +4 -3
  19. package/skills/c64-provenance/SKILL.md +6 -4
  20. package/skills/c64-provenance/scripts/lib/library.ts +18 -6
  21. package/skills/c64-provenance/scripts/lib/separate.ts +45 -6
  22. package/skills/c64-provenance/scripts/provenance.ts +14 -10
  23. package/skills/c64-source/SKILL.md +3 -0
  24. package/skills/c64-source/scripts/lib/source.ts +9 -31
  25. package/skills/c64-source/scripts/source.ts +14 -10
  26. package/skills/c64-static-analysis/SKILL.md +2 -0
  27. package/skills/c64-static-analysis/scripts/lib/idioms.ts +6 -2
  28. package/skills/c64-static-analysis/scripts/static-analysis.ts +18 -8
  29. package/skills/c64-testing/SKILL.md +14 -3
  30. package/skills/c64-testing/references/recording-format.md +2 -0
  31. package/skills/c64-testing/references/scenario-format.md +6 -0
  32. package/skills/c64-testing/scripts/lib/diff-files.ts +76 -14
  33. package/skills/c64-testing/scripts/lib/diff.ts +3 -3
  34. package/skills/c64-testing/scripts/lib/recording.ts +50 -15
  35. package/skills/c64-testing/scripts/lib/replay.ts +12 -1
  36. package/skills/c64-testing/scripts/lib/runs.ts +84 -4
  37. package/skills/c64-testing/scripts/lib/scenario.ts +43 -6
  38. package/skills/c64-testing/scripts/testing.ts +20 -7
  39. package/skills/c64-unpacker/SKILL.md +17 -10
  40. package/skills/c64-unpacker/scripts/lib/evidence.ts +4 -5
  41. package/skills/c64-unpacker/scripts/lib/loads.ts +14 -7
  42. package/skills/c64-unpacker/scripts/lib/machine.ts +30 -3
  43. package/skills/c64-unpacker/scripts/lib/stages.ts +15 -7
  44. package/skills/c64-unpacker/scripts/unpacker.ts +93 -42
  45. package/src/host/vice/adapter.ts +4 -1
  46. package/src/host/vice/session.ts +148 -13
  47. package/src/host-client/image.ts +40 -8
  48. package/src/host-client/steps.ts +145 -9
  49. package/src/host-client/tools.ts +1 -1
  50. package/src/host-client/transfer.ts +33 -18
  51. package/src/knowledge/expression.ts +1 -1
  52. package/src/knowledge/history.ts +74 -4
  53. package/src/knowledge/import.ts +79 -7
  54. package/src/knowledge/names.ts +2 -1
  55. package/src/knowledge/program.ts +32 -18
  56. package/src/knowledge/read.ts +46 -0
  57. package/src/knowledge/schema.ts +4 -2
  58. package/src/knowledge/text.ts +2 -0
  59. package/src/knowledge/write.ts +58 -16
  60. package/src/mcp/inputs.ts +6 -3
  61. package/src/mcp/output.ts +5 -0
  62. package/src/mcp/server.ts +20 -4
  63. package/src/mcp/tools/debug.ts +36 -9
  64. package/src/mcp/tools/execution.ts +18 -5
  65. package/src/mcp/tools/machine.ts +3 -1
  66. package/src/mcp/tools/media.ts +14 -2
  67. package/src/mcp/tools/memory.ts +11 -6
  68. package/src/mcp/tools/video.ts +5 -3
  69. package/src/media.ts +42 -0
  70. package/src/opcodes.ts +51 -18
  71. package/src/project.ts +111 -3
  72. package/src/protocol/vice.ts +8 -2
  73. package/src/script.ts +55 -3
@@ -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";
@@ -58,34 +58,49 @@ export function readProjectFile(path: string, options: { root?: string } = {}):
58
58
  return { bytes: readFileSync(real), type: extname(path).slice(1).toLowerCase() };
59
59
  }
60
60
 
61
+ /**
62
+ * The refusal for a write that the file system refused because of the path
63
+ * (a folder, a file in place of a folder, no permission): invalid-input with
64
+ * the path. Undefined for another error, which is no fault of the path.
65
+ */
66
+ export function writeRefusal(error: unknown, path: string, what?: string): WireFailure | undefined {
67
+ const message = writeProblem(error, path, what);
68
+ return message === undefined ? undefined : new WireFailure("invalid-input", message);
69
+ }
70
+
61
71
  /**
62
72
  * Writes bytes to a project file on the client side, making its directories
63
- * and overwriting it. Throws WireFailure(invalid-input) for a path outside the
64
- * project, also one that leaves it through a symbolic link.
73
+ * and overwriting it. First the check of checkOutputPath, which a script also
74
+ * does on its output options before any work. Throws WireFailure(invalid-input)
75
+ * for a path outside the project, also one that leaves it through a symbolic
76
+ * link, for a path in the knowledge, and for a path that the file system
77
+ * refuses: a folder, a file in place of a folder, no permission.
65
78
  */
66
79
  export function writeProjectFile(path: string, bytes: Uint8Array, options: { root?: string; what?: string } = {}): void {
67
80
  const root = options.root ?? currentProject();
68
81
  const refuse = () => new WireFailure("invalid-input", `${options.what ?? path}: give a path relative to the project folder that stays inside it, not ${path}.`);
69
82
  let file: string;
70
83
  try {
71
- file = resolveProjectPath(path, root);
72
- } catch {
73
- throw refuse();
84
+ file = checkOutputPath(path, "file", { root, ...(options.what === undefined ? {} : { what: options.what }) });
85
+ } catch (error) {
86
+ if (isRefusal(error)) throw new WireFailure(error.code, error.message);
87
+ throw error;
74
88
  }
75
- // The nearest folder that exists must be inside the project before a folder is made below it.
76
- let existing = dirname(file);
77
- while (!existsSync(existing)) existing = dirname(existing);
78
- if (!isInside(realpathSync(root), realpathSync(existing))) throw refuse();
79
- mkdirSync(dirname(file), { recursive: true });
80
- if (!isInside(realpathSync(root), realpathSync(dirname(file)))) throw refuse();
81
- let link = false;
82
89
  try {
83
- link = lstatSync(file).isSymbolicLink();
90
+ mkdirSync(dirname(file), { recursive: true });
91
+ // Again after the folders are made: another process can change the folders between the check and the write.
92
+ if (!isInside(realpathSync(root), realpathSync(dirname(file)))) throw refuse();
93
+ let link = false;
94
+ try {
95
+ link = lstatSync(file).isSymbolicLink();
96
+ } catch (error) {
97
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
98
+ }
99
+ if (link) throw refuse();
100
+ writeFileSync(file, bytes);
84
101
  } catch (error) {
85
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
102
+ throw writeRefusal(error, path, options.what) ?? error;
86
103
  }
87
- if (link) throw refuse();
88
- writeFileSync(file, bytes);
89
104
  }
90
105
 
91
106
  /** Directories never staged: version control and the toolkit's own project state. */
@@ -119,7 +119,7 @@ function value(node: Node, resolve: (name: string) => number | undefined): numbe
119
119
  return node.value;
120
120
  case "name": {
121
121
  const found = resolve(node.name);
122
- if (found === undefined) throw new Refusal("invalid-input", `"${node.name}" is no symbol of the knowledge and no platform name.`);
122
+ if (found === undefined) throw new Refusal("invalid-input", `${node.name} is not a symbol of the knowledge or a platform name. Give a number, a symbol that knowledge.ts symbols lists, or a platform name that memory-map.ts at gives.`);
123
123
  return found;
124
124
  }
125
125
  case "unary": {
@@ -37,8 +37,8 @@ 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";
41
- import { compareText } from "./text.ts";
40
+ import { DEFAULT_SPACE, type CoverageKind, type EvidenceKind, type Origin, type Space } from "./schema.ts";
41
+ import { compareText, NO_REVISION } from "./text.ts";
42
42
  import type { Evidence } from "./write.ts";
43
43
 
44
44
  export interface Revision {
@@ -49,12 +49,49 @@ export interface Revision {
49
49
  description?: string;
50
50
  inputHash?: string;
51
51
  toolVersion?: string;
52
+ /** A revert: the revision whose knowledge it restored. */
53
+ restores?: string;
54
+ /** A revert of one space: that space. A revert without it restored every space. */
55
+ restoresSpace?: Space;
52
56
  /** What the change rests on. */
53
57
  evidence?: Evidence[];
54
58
  }
55
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
+
56
90
  /** One knowledge row with its validity: from its first revision until the revision that closed it. */
57
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 }
58
95
  | { entity: "symbol"; row: SymbolRow; from: string; to?: string }
59
96
  | { entity: "analyzer-name"; row: SymbolRow; from: string; to?: string }
60
97
  | { entity: "region"; row: RegionRow; from: string; to?: string }
@@ -79,6 +116,8 @@ function toRevision(row: Raw): Revision {
79
116
  if (row.description !== null) revision.description = row.description as string;
80
117
  if (row.input_hash !== null) revision.inputHash = row.input_hash as string;
81
118
  if (row.tool_version !== null) revision.toolVersion = row.tool_version as string;
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;
82
121
  return revision;
83
122
  }
84
123
 
@@ -170,8 +209,22 @@ export function revisions(db: DatabaseSync | undefined, options: { limit?: numbe
170
209
  return (rows as Raw[]).map((row) => withEvidence(db, toRevision(row)));
171
210
  }
172
211
 
212
+ /** Revision 0: the knowledge before every revision. It is empty, and no write made it, so it has no time, origin or evidence. */
213
+ export interface InitialRevision {
214
+ id: typeof NO_REVISION;
215
+ operation: "none";
216
+ description: string;
217
+ createdAt?: undefined;
218
+ origin?: undefined;
219
+ inputHash?: undefined;
220
+ toolVersion?: undefined;
221
+ restores?: undefined;
222
+ restoresSpace?: undefined;
223
+ evidence?: undefined;
224
+ }
225
+
173
226
  export interface RevisionChanges {
174
- revision: Revision;
227
+ revision: Revision | InitialRevision;
175
228
  /** Rows this revision made current. */
176
229
  added: HistoryEntry[];
177
230
  /** Rows this revision closed. */
@@ -181,6 +234,7 @@ export interface RevisionChanges {
181
234
  /** One revision and exactly what it changed. */
182
235
  export function revision(db: DatabaseSync | undefined, text: string): RevisionChanges {
183
236
  const id = resolveRevision(db, text);
237
+ if (id === NO_REVISION) return { revision: { id: NO_REVISION, operation: "none", description: "The knowledge before every revision. It is empty." }, added: [], closed: [] };
184
238
  const raw = db?.prepare("SELECT * FROM revisions WHERE id = ?").get(id) as Raw | undefined;
185
239
  if (db === undefined || raw === undefined) throw new Refusal("not-found", `There is no revision ${id}.`);
186
240
  const tables = [
@@ -196,9 +250,25 @@ export function revision(db: DatabaseSync | undefined, text: string): RevisionCh
196
250
  ["macro", "macros", MACRO_COLUMNS, toMacro],
197
251
  ["macro-use", "macro_uses", MACRO_USE_COLUMNS, toMacroUse],
198
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;
199
269
  const added: HistoryEntry[] = [];
200
270
  const closed: HistoryEntry[] = [];
201
- for (const [entity, table, columns, map] of tables) {
271
+ for (const [entity, table, columns, map] of [...tables, ...program]) {
202
272
  for (const row of db.prepare(`SELECT ${columns}, valid_to_revision FROM ${table} WHERE valid_from_revision = ?`).all(id) as Raw[]) {
203
273
  added.push(entry(entity, map(row), row));
204
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;
@@ -20,7 +20,8 @@ export function addressOfName(db: DatabaseSync | undefined, name: string, space:
20
20
  if (addresses.length === 1) return addresses[0]!;
21
21
  const where = space === DEFAULT_SPACE ? "" : ` in ${space}`;
22
22
  if (addresses.length > 1) throw new Refusal("invalid-input", `the analyzers give ${name} to ${rows.map((row) => `${formatC64Address(row.address)} (${row.origin})`).join(" and ")}${where}. Give the address`);
23
- throw new Refusal("not-found", `no symbol or analyzer name${where} is ${name}`);
23
+ const option = space === DEFAULT_SPACE ? "" : ` --space ${space}`;
24
+ throw new Refusal("not-found", `${name} is not a name in the knowledge${where}. Give a $ address, or a name that knowledge.ts symbols${option} lists, with or without --analyzer.`);
24
25
  }
25
26
 
26
27
  /** Every name of a space with its address: the symbols, and each analyzer name that addressOfName accepts. With kinds, only names of those kinds. */
@@ -10,6 +10,7 @@ import { Refusal } from "../errors.ts";
10
10
  import { type AddressRange, COVERAGE, formatC64Address } from "../c64.ts";
11
11
 
12
12
  import { COVERAGE_KINDS, DEFAULT_SPACE, MEMORY_ROW, type CoverageKind, type Space } from "./schema.ts";
13
+ import { coverageRuns } from "./read.ts";
13
14
  import { checkSpace, write, type Evidence, type WriteContext } from "./write.ts";
14
15
 
15
16
  /** The 64 KiB of one space: offset n is address n. */
@@ -147,22 +148,6 @@ function currentCoverage(db: DatabaseSync, space: Space): Map<CoverageKind, Uint
147
148
  return maps;
148
149
  }
149
150
 
150
- /** One import of a coverage map: its project path, its SHA-256, the space it went into and the revision. */
151
- export interface CoverageRun {
152
- name: string;
153
- sha256: string;
154
- space: Space;
155
- revision: string;
156
- }
157
-
158
- /** The imports of coverage maps, by name and then oldest first: the last import of a name is the latest. */
159
- export function coverageRuns(db: DatabaseSync | undefined): CoverageRun[] {
160
- if (db === undefined) return [];
161
- return db
162
- .prepare(`SELECT DISTINCT e.ref AS name, e.sha256 AS sha256, coalesce(e.space, '${DEFAULT_SPACE}') AS space, e.revision AS revision FROM evidence e JOIN revisions r ON r.id = e.revision WHERE r.operation = 'coverage' AND e.kind = 'coverage' ORDER BY e.ref, e.revision`)
163
- .all() as unknown as CoverageRun[];
164
- }
165
-
166
151
  /**
167
152
  * Joins coverage maps into the coverage of a space: adds the ranges of
168
153
  * the flags that no imported run set before, and as wx the addresses that one
@@ -239,13 +224,42 @@ export function requireProgram(db: DatabaseSync | undefined, space: Space = DEFA
239
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.`);
240
225
  }
241
226
 
242
- /** 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
+ */
243
257
  export function analyzedBytes(image: ProgramImage): { bytes: Buffer; kind: "prg" | "flat64k" } {
244
258
  if (image.segments.length === 1 && image.state === undefined) {
245
259
  const [segment] = image.segments;
246
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" };
247
261
  }
248
- return { bytes: Buffer.from(image.memory), kind: "flat64k" };
262
+ return { bytes: Buffer.from(entryMemory(image)), kind: "flat64k" };
249
263
  }
250
264
 
251
265
  /**
@@ -243,6 +243,52 @@ export function currentRevision(db: DatabaseSync | undefined): string {
243
243
  return (db.prepare("SELECT max(id) AS id FROM revisions").get() as { id: string | null }).id ?? "0";
244
244
  }
245
245
 
246
+ /** One import of a coverage map: its project path, its SHA-256, the space it went into and the revision. */
247
+ export interface CoverageRun {
248
+ name: string;
249
+ sha256: string;
250
+ space: Space;
251
+ revision: string;
252
+ }
253
+
254
+ /**
255
+ * The imports of coverage maps that the knowledge has, by name and then oldest
256
+ * first: the last import of a name is the latest. A revert gives back the maps
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.
259
+ * With `at`, the maps of the knowledge at that revision.
260
+ */
261
+ export function coverageRuns(db: DatabaseSync | undefined, at?: string): CoverageRun[] {
262
+ if (db === undefined || at === NO_REVISION) return [];
263
+ const rows = db
264
+ .prepare(
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
266
+ FROM revisions r LEFT JOIN evidence e ON e.revision = r.id AND e.kind = 'coverage'
267
+ WHERE (r.operation = 'coverage' AND e.id IS NOT NULL) OR (r.operation = 'revert' AND r.restored_revision IS NOT NULL)
268
+ ORDER BY r.id, e.id`,
269
+ )
270
+ .all() as Array<{ id: string; operation: string; restored: string | null; restoredSpace: Space | null; name: string | null; sha256: string | null; space: Space }>;
271
+ // The maps after each revision that changed them, oldest first.
272
+ const after: Array<{ id: string; runs: CoverageRun[] }> = [];
273
+ let runs: CoverageRun[] = [];
274
+ for (const row of rows) {
275
+ if (at !== undefined && row.id > at) break;
276
+ if (row.operation === "revert") {
277
+ const restored = row.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)];
282
+ } else {
283
+ const run: CoverageRun = { name: row.name!, sha256: row.sha256!, space: row.space, revision: row.id };
284
+ if (!runs.some((item) => item.name === run.name && item.sha256 === run.sha256 && item.space === run.space && item.revision === run.revision)) runs = [...runs, run];
285
+ }
286
+ if (after.at(-1)?.id === row.id) after[after.length - 1]!.runs = runs;
287
+ else after.push({ id: row.id, runs });
288
+ }
289
+ return [...runs].sort((a, b) => (a.name === b.name ? (a.revision < b.revision ? -1 : a.revision > b.revision ? 1 : 0) : a.name < b.name ? -1 : 1));
290
+ }
291
+
246
292
  /** At most this many ids are named when a start of an id fits several revisions. */
247
293
  const NAMED_REVISIONS = 5;
248
294
 
@@ -113,7 +113,9 @@ function createSchema(db: DatabaseSync): void {
113
113
  operation TEXT NOT NULL,
114
114
  input_hash TEXT,
115
115
  tool_version TEXT,
116
- description TEXT
116
+ description TEXT,
117
+ restored_revision TEXT,
118
+ restored_space TEXT
117
119
  );
118
120
  CREATE TABLE symbols (
119
121
  id TEXT PRIMARY KEY,
@@ -316,7 +318,7 @@ function createSchema(db: DatabaseSync): void {
316
318
  }
317
319
 
318
320
  /** The version of the index this build makes. An index with another version is emptied and made again. */
319
- export const INDEX_VERSION = 6;
321
+ export const INDEX_VERSION = 8;
320
322
 
321
323
  function indexVersion(db: DatabaseSync): number | undefined {
322
324
  const hasMeta = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'meta'").get();
@@ -187,6 +187,8 @@ const REVISION_FIELDS: readonly Field[] = [
187
187
  field("reason", "description", "text", true),
188
188
  field("inputHash", "input_hash", "text", true),
189
189
  field("toolVersion", "tool_version", "text", true),
190
+ field("restores", "restored_revision", "text", true),
191
+ field("restoresSpace", "restored_space", "text", true),
190
192
  ];
191
193
 
192
194
  /** The SQL name of a table: "references" is a keyword. */