@henols/c64-re-tools 2.0.0-rc.20 → 2.0.0-rc.22

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henols/c64-re-tools",
3
- "version": "2.0.0-rc.20",
3
+ "version": "2.0.0-rc.22",
4
4
  "description": "Agent-friendly Commodore 64 engineering toolkit for reverse engineering, debugging, development and testing.",
5
5
  "license": "MIT",
6
6
  "author": "Henrik Olsson",
@@ -7,7 +7,7 @@ description: Use this skill to write the documentation of a C64 program. The doc
7
7
 
8
8
  ## Purpose
9
9
 
10
- Write documentation that tells how a program works, and that points to the code. The documentation is Markdown in the project, for example in `docs/`. It has two parts:
10
+ Write documentation that tells how a program works, and that points to the code. The documentation is Markdown in the project. The entry page is `README.md` in the project folder. The other pages and the reference are in `docs/`. The documentation has two parts:
11
11
 
12
12
  - **The reference.** `generate.ts` generates it from the knowledge, the source map of c64-source and the image. It has a page for each module. The page gives the routines, their callers and callees, the data and a call graph. It also has pages for the names, the variables, the tables, the memory map and the graphics. Do not edit these pages. The next run writes over them.
13
13
  - **The pages that you write.** They tell how the program works. They give the flows, the state machines, the data formats and the task of each part. Use the page types in `references/page-types.md` and the rules in `references/style.md`.
@@ -20,7 +20,7 @@ This skill does not change the knowledge.
20
20
 
21
21
  - The source directory of c64-source, with its `source-map.json`, for example `src`. The source must be from the current knowledge revision.
22
22
  - The program in the knowledge. Put it in with `image` of the c64-knowledge skill. The reference decodes the tables and shows the graphics from it.
23
- - The directory of the documentation, for example `docs`. The reference goes into a directory of its own, for example `docs/reference`.
23
+ - The entry page `README.md` and the directory of the other pages, for example `docs`. The reference goes into a directory of its own, for example `docs/reference`.
24
24
 
25
25
  ## Tools / execution path
26
26
 
@@ -28,16 +28,16 @@ The scripts run on this machine. They do not use the emulator or the host runtim
28
28
 
29
29
  ```
30
30
  node <skill>/scripts/generate.ts --source src --out docs/reference
31
- node <skill>/scripts/link.ts docs --source src
32
- node <skill>/scripts/link.ts docs --source src --check
31
+ node <skill>/scripts/link.ts README.md docs --source src
32
+ node <skill>/scripts/link.ts README.md docs --source src --check
33
33
  ```
34
34
 
35
35
  | Option | Meaning |
36
36
  |---|---|
37
37
  | `--source src` | The source directory of c64-source. Both scripts read `src/source-map.json`. |
38
38
  | `--out docs/reference` | The directory of the reference. `generate.ts` writes only there. It refuses to start when the directory holds a page that it did not write. |
39
- | `docs` | The directory of the documentation. `link.ts` reads every `.md` file below it. It changes only the pages that you wrote. It checks the pages of the reference. |
40
- | `--reference <dir>` | The directory of the reference for `link.ts`. The default is `docs/reference` when it exists. |
39
+ | `README.md docs` | The pages of the documentation: `.md` files and directories. `link.ts` reads each file and every `.md` file below each directory. It changes only the pages that you wrote. It checks the pages of the reference. |
40
+ | `--reference <dir>` | The directory of the reference for `link.ts`. The default is the `reference` directory of the first directory that has one, for example `docs/reference`. |
41
41
  | `--check` | `link.ts` writes nothing. It fails when a link is not up to date or a page has a problem. |
42
42
 
43
43
  ### Project folder
@@ -50,15 +50,16 @@ node <skill>/scripts/link.ts docs --source src --check
50
50
  1. Read the knowledge with the c64-knowledge skill. The documentation can only be as good as the knowledge. Each routine needs a name and a comment. Each table needs a region and a name. Each file of the source needs a module with a title. Record what is missing with c64-knowledge.
51
51
  2. Generate the source with c64-source. Make sure that `check.identical` is `true`. The script also writes `source-map.json`.
52
52
  3. Run `generate.ts`. Read `index.md` of the reference. A module with no routines, or a routine with no comment, shows what the knowledge does not have yet.
53
- 4. Make the plan of the pages. Start from the modules and the call graphs. Find the main loop, the interrupt chain, the dispatch tables, the state machines and the data formats. Each subsystem gets one page. The entry page lists the pages. Use the page types in `references/page-types.md`.
53
+ 4. Make the plan of the pages. Start from the modules and the call graphs. Find the main loop, the interrupt chain, the dispatch tables, the state machines and the data formats. Each subsystem gets one page in `docs/`. The entry page `README.md` in the project folder lists the pages. Use the page types in `references/page-types.md`.
54
54
  5. Write each page. Read the code and the knowledge for each fact. Run the program with c64-testing or c64-emulator when a fact needs a run. Write each name with its address, for example `` `draw_room` `$09BC` ``. Mark each fact with its evidence. Several subagents can write the pages at the same time, one page each. Give each subagent `references/page-types.md` and `references/style.md`.
55
55
  6. Record each new fact in the knowledge first: a name, a comment, a region or a module. Then the source and the reference show it too. A fact only in a page is lost at the next change of the knowledge.
56
56
  7. Run `link.ts`. Read `problems` and correct each one. Then run `link.ts --check`. Make sure that `problems.count` is 0 and that `notUpToDate.links` is 0.
57
- 8. After each change to the knowledge, do steps 2, 3 and 7 again. The links then go to the new lines.
57
+ 8. When the project folder is in a workspace with other projects, find `README.md` at the root of the workspace. This page lists each program of the workspace. Add one row for the program, with a link to `<project>/README.md` and one sentence. If the page does not exist, make it. Use the workspace index in `references/page-types.md`. `link.ts` does not read this page, because the page is not in the project folder.
58
+ 9. After each change to the knowledge, do steps 2, 3 and 7 again. The links then go to the new lines.
58
59
 
59
60
  ## The links
60
61
 
61
- `link.ts` changes these forms in the pages that you write. It does not change a code block, a mermaid diagram or an HTML comment.
62
+ `link.ts` changes these forms in the pages that you write. It does not change a code block, a mermaid diagram or an HTML comment. The examples are for a page in `docs/`. In `README.md`, the links start with `src/` and `docs/reference/`.
62
63
 
63
64
  | You write | `link.ts` writes |
64
65
  |---|---|
@@ -4,15 +4,16 @@ Each page that you write is one of these types. The types come from the document
4
4
 
5
5
  ## 1. The entry page
6
6
 
7
- One entry page for the program, for example `docs/README.md` or `docs/rooms.md` for one large subsystem. It has these parts, in this order:
7
+ One entry page for the program: `README.md` in the project folder. A large subsystem can have an entry page of its own in `docs/`, for example `docs/rooms.md`. The entry page of the program has these parts, in this order:
8
8
 
9
9
  1. **One paragraph.** What the program is, and which release the documentation is for.
10
10
  2. **The table of pages.** One row for each page, with the link and one sentence about its content.
11
11
  3. **The layers.** A mermaid diagram of the layers of the program. The layers are the data, the code that runs one time, the main loop and the interrupts. Under the diagram, a numbered list tells how the layers work together.
12
12
  4. **The flow of a user.** A mermaid diagram of the steps of the player or user from start to end. An example is the order of the levels.
13
13
  5. **Findings.** A table of the facts that a reader does not expect, each with its evidence. Put a bug of the original program here.
14
- 6. **The reference.** A link to `reference/index.md`.
14
+ 6. **The reference.** A link to `docs/reference/index.md`.
15
15
  7. **Still open.** What is not known yet, and which run can find it.
16
+ 8. **The release and the rebuild.** What the release holds, for example a crack with a cracktro and a mover. Which part is the clean program of the knowledge. Which changes of the release to the machine state stay, and how the loader sets them. How the source assembles to the clean program, and which scenarios compare the original with the rebuild.
16
17
 
17
18
  ```markdown
18
19
  # <Program>: <subject>
@@ -21,7 +22,7 @@ One entry page for the program, for example `docs/README.md` or `docs/rooms.md`
21
22
 
22
23
  | Page | Content |
23
24
  |---|---|
24
- | [parts/main_loop.md](parts/main_loop.md) | **The main loop.** <one sentence> |
25
+ | [parts/main_loop.md](docs/parts/main_loop.md) | **The main loop.** <one sentence> |
25
26
 
26
27
  ## 1. The layers
27
28
 
@@ -31,23 +32,50 @@ flowchart TB
31
32
  ...
32
33
  ~~~
33
34
 
34
- ## 2. Findings
35
+ ## 2. The flow of a player
36
+
37
+ ...
38
+
39
+ ## 3. Findings
35
40
 
36
41
  | Finding | Evidence |
37
42
  |---|---|
38
43
  | <fact> | Observed: <what run showed it> |
39
44
 
40
- ## 3. Still open
45
+ ## 4. The reference
46
+
47
+ [docs/reference/index.md](docs/reference/index.md)
48
+
49
+ ## 5. Still open
41
50
 
42
51
  - <question> — <the run that can answer it>
52
+
53
+ ## 6. The release and the rebuild
54
+
55
+ - **The release.** <the file on the disk, and what the release adds to the program>
56
+ - **The rebuild.** <the source, the loader and the scenarios>
57
+ ```
58
+
59
+ ## 2. The workspace index
60
+
61
+ A workspace with several projects has one `README.md` at its root. It lists each program of the workspace. It has a title, one sentence and a table with one row for each program. Each row has a link to the entry page of the program and one sentence about the program.
62
+
63
+ ```markdown
64
+ # C64 games
65
+
66
+ Reverse-engineered C64 games. Each game has its own folder with the original disk, the rebuilt source and the documentation.
67
+
68
+ | Game | |
69
+ |---|---|
70
+ | [Bruce Lee](bruce_lee/README.md) | Datasoft 1984. How the program works, from the cold start to the last room. |
43
71
  ```
44
72
 
45
- ## 2. The subsystem page
73
+ ## 3. The subsystem page
46
74
 
47
75
  One page for each subsystem, for example the main loop, the interrupt chain, the room generation or the sound player. It has these parts:
48
76
 
49
77
  1. **The scope.** A list of what the page covers, and the sources (the source files, the assets).
50
- 2. **The conventions.** The terms of the page, for example "a pass is one run of `main_loop` `$1BC2`". Also the evidence labels. Use the conventions block of section 5.
78
+ 2. **The conventions.** The terms of the page, for example "a pass is one run of `main_loop` `$1BC2`". Also the evidence labels. Use the conventions block of section 6.
51
79
  3. **The overview.** A short text diagram or a mermaid flowchart of the calls, from the entry of the subsystem to its end.
52
80
  4. **The catalogue.** A table of the data of the subsystem. Each row has the name, the address, the layout (bytes, entries, the meaning of each byte), the readers and the writers.
53
81
  5. **The step-by-step walk.** For each main routine: a numbered list of what it does, in the order of the code. Each step gives the names and addresses. Add pseudo-code when a loop or a condition is not clear from the list.
@@ -71,7 +99,7 @@ stateDiagram-v2
71
99
  | Waiting `$00` | `move_timer` = 0 | Moving `$01` | `update_slider` `$3F8C` |
72
100
  ```
73
101
 
74
- ## 3. The entity sections
102
+ ## 4. The entity sections
75
103
 
76
104
  A program often has many items of one kind: levels, rooms, enemies, tunes. Give each item one section of the same form, in a page for a group of items. Each section has these parts:
77
105
 
@@ -83,7 +111,7 @@ A program often has many items of one kind: levels, rooms, enemies, tunes. Give
83
111
 
84
112
  End the group page with a table of the dependencies between the items.
85
113
 
86
- ## 4. The data-format page
114
+ ## 5. The data-format page
87
115
 
88
116
  One page for each format of data, for example a packed map, a music stream or a sprite format. It has these parts:
89
117
 
@@ -101,7 +129,7 @@ One page for each format of data, for example a packed map, a music stream or a
101
129
  | `$01-$7F` | The next byte, `b` times. |
102
130
  ```
103
131
 
104
- ## 5. The conventions block
132
+ ## 6. The conventions block
105
133
 
106
134
  Put this block near the top of each subsystem page. Change the terms for the program.
107
135
 
@@ -11,7 +11,7 @@ import { writeProjectFile } from "#src/host-client/transfer.ts";
11
11
  import { conflicts } from "#src/knowledge/conflicts.ts";
12
12
  import { KnowledgeError, openForRead } from "#src/knowledge/database.ts";
13
13
  import { currentRevision, listComments, listModules, listReferences, listRegions, listSymbols } from "#src/knowledge/read.ts";
14
- import { resolveProjectPath } from "#src/project.ts";
14
+ import { resolveProjectFile, resolveProjectPath } from "#src/project.ts";
15
15
  import { runScript, UsageError } from "#src/script.ts";
16
16
  import { buildReference, GENERATED_MARK } from "./lib/reference.ts";
17
17
  import { readSourceMap } from "./lib/source-map.ts";
@@ -28,9 +28,8 @@ When the knowledge has the program of the source (knowledge.ts image of the
28
28
  c64-knowledge skill, D83), the reference decodes the tables and shows the
29
29
  graphics. The result is one JSON object.`;
30
30
 
31
- /** The files of an earlier run, or a refusal that names a file of a person. */
32
- function oldFiles(out: string): string[] {
33
- const directory = resolveProjectPath(out);
31
+ /** The files of an earlier run in `directory`, the real folder of --out, or a refusal that names a file of a person. */
32
+ function oldFiles(out: string, directory: string): string[] {
34
33
  if (!existsSync(directory)) return [];
35
34
  const found: string[] = [];
36
35
  const walk = (relative: string) => {
@@ -62,6 +61,15 @@ function run(argv: string[]): Record<string, unknown> {
62
61
  throw new UsageError(`${option} must be a path relative to the project folder that stays inside it, not ${path}`);
63
62
  }
64
63
  }
64
+ // The files of an earlier run are deleted: only in a folder that is inside the project, also after its symbolic links.
65
+ let directory = resolveProjectPath(values.out);
66
+ if (existsSync(directory)) {
67
+ try {
68
+ directory = resolveProjectFile(values.out);
69
+ } catch {
70
+ throw new UsageError(`--out must be a path relative to the project folder that stays inside it, also through symbolic links, not ${values.out}`);
71
+ }
72
+ }
65
73
  const map = readSourceMap(values.source);
66
74
  const db = openForRead();
67
75
  let knowledge;
@@ -86,14 +94,13 @@ function run(argv: string[]): Record<string, unknown> {
86
94
  } finally {
87
95
  db?.close();
88
96
  }
89
- const old = oldFiles(values.out);
97
+ const old = oldFiles(values.out, directory);
90
98
  const built = buildReference({
91
99
  out: posix.normalize(values.out),
92
100
  map,
93
101
  ...knowledge,
94
102
  ...(image === undefined ? {} : { image: { byteAt: image.byteAt, inImage: (address: number) => image.segments.some((segment) => address >= segment.start && address <= segment.end) } }),
95
103
  });
96
- const directory = resolveProjectPath(values.out);
97
104
  for (const path of old) rmSync(join(directory, path), { force: true });
98
105
  for (const file of built.files) writeProjectFile(file.path, file.bytes, { what: "--out" });
99
106
  return { out: values.out, revision: knowledge.revision, index: posix.join(posix.normalize(values.out), "index.md"), ...built.counts, program: image === undefined ? "none: put the program into the knowledge with knowledge.ts image to decode the tables and show the graphics" : "the knowledge" };
@@ -60,7 +60,14 @@ const relative = (page: string, target: string) => {
60
60
  /** The project path of a link target, without its fragment; undefined for a link out of the project (a URL, or a page anchor only). */
61
61
  export function targetPath(page: string, target: string): string | undefined {
62
62
  if (/^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith("#")) return undefined;
63
- return posix.normalize(posix.join(posix.dirname(page), decodeURIComponent(target.split("#")[0]!)));
63
+ const raw = target.split("#")[0]!;
64
+ let path = raw;
65
+ try {
66
+ path = decodeURIComponent(raw);
67
+ } catch {
68
+ // A % that starts no escape ("100%.md") is part of the name.
69
+ }
70
+ return posix.normalize(posix.join(posix.dirname(page), path));
64
71
  }
65
72
 
66
73
  /** Writes the links of the name mentions of a page; returns the new text, the number of links changed and the problems. */
@@ -129,6 +136,11 @@ export function checkLinks(page: string, text: string, files: Files): Problem[]
129
136
  if (!anchors.has(path)) anchors.set(path, anchorsOf(path === page ? text : (files.text(path) ?? "")));
130
137
  return anchors.get(path)!;
131
138
  };
139
+ const lineCounts = new Map<string, number>();
140
+ const lineCount = (path: string) => {
141
+ if (!lineCounts.has(path)) lineCounts.set(path, (files.text(path) ?? "").split("\n").length);
142
+ return lineCounts.get(path)!;
143
+ };
132
144
  for (const link of linksOf(text)) {
133
145
  const report = (kind: ProblemKind, message: string) => problems.push({ page, line: link.line, kind, message });
134
146
  const fragment = link.target.includes("#") ? link.target.slice(link.target.indexOf("#") + 1) : undefined;
@@ -147,7 +159,7 @@ export function checkLinks(page: string, text: string, files: Files): Problem[]
147
159
  if (path.endsWith(".md")) {
148
160
  if (!anchorsAt(path).has(fragment)) report("missing-anchor", `${link.target}: ${path} has no anchor ${fragment}.`);
149
161
  } else if (line !== null) {
150
- const count = (files.text(path) ?? "").split("\n").length;
162
+ const count = lineCount(path);
151
163
  if (Number(line[1]) > count) report("missing-line", `${link.target}: ${path} has ${count} lines.`);
152
164
  }
153
165
  }
@@ -16,13 +16,14 @@ import { checkLinks, linkPage, type Files, type Problem } from "./lib/links.ts";
16
16
  import { GENERATED_MARK } from "./lib/reference.ts";
17
17
  import { readSourceMap } from "./lib/source-map.ts";
18
18
 
19
- const USAGE = `link.ts <docs> --source <dir> [--reference <dir>] [--check]
19
+ const USAGE = `link.ts <pages>... --source <dir> [--reference <dir>] [--check]
20
20
 
21
- <docs> the documentation directory, relative to the project folder;
22
- link.ts reads every .md file below it
21
+ <pages>... .md files and directories of the documentation, relative to
22
+ the project folder, for example README.md docs; link.ts reads
23
+ each file and every .md file below each directory
23
24
  --source <dir> the source directory of c64-source, with its source-map.json
24
- --reference <dir> the reference pages of generate.ts; the default is
25
- <docs>/reference when it exists
25
+ --reference <dir> the reference pages of generate.ts; the default is the
26
+ reference/ folder of the first directory that has one
26
27
  --check write nothing; fail when a link is not up to date or a page
27
28
  has a problem
28
29
 
@@ -34,9 +35,19 @@ const MAX_LISTED = 50;
34
35
  /** A name outside the image covers the addresses up to the next name, at most this many. */
35
36
  const MAX_BODY = 256;
36
37
 
38
+ const isDirectory = (path: string) => {
39
+ const real = resolveProjectPath(path);
40
+ return existsSync(real) && statSync(real).isDirectory();
41
+ };
42
+
37
43
  function markdownFiles(directory: string): string[] {
38
- const root = resolveProjectPath(directory);
39
- if (!existsSync(root) || !statSync(root).isDirectory()) throw new WireFailure("not-found", `There is no directory ${directory} in the project folder.`);
44
+ if (!isDirectory(directory)) throw new WireFailure("not-found", `There is no directory ${directory} in the project folder.`);
45
+ let root: string;
46
+ try {
47
+ root = resolveProjectFile(directory);
48
+ } catch {
49
+ throw new WireFailure("invalid-input", `${directory} leads out of the project folder through a symbolic link.`);
50
+ }
40
51
  const found: string[] = [];
41
52
  const walk = (relative: string) => {
42
53
  for (const entry of readdirSync(join(root, relative), { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
@@ -49,6 +60,9 @@ function markdownFiles(directory: string): string[] {
49
60
  return found;
50
61
  }
51
62
 
63
+ /** The text of each file that the run read: a page, a source file or a reference page is read once. */
64
+ const texts = new Map<string, string | undefined>();
65
+
52
66
  const files: Files = {
53
67
  inside: (path) => {
54
68
  try {
@@ -67,27 +81,38 @@ const files: Files = {
67
81
  }
68
82
  },
69
83
  text: (path) => {
84
+ if (texts.has(path)) return texts.get(path);
85
+ let text: string | undefined;
70
86
  try {
71
87
  const real = resolveProjectFile(path);
72
- return statSync(real).isFile() ? readFileSync(real, "utf8") : undefined;
88
+ text = statSync(real).isFile() ? readFileSync(real, "utf8") : undefined;
73
89
  } catch {
74
- return undefined;
90
+ text = undefined;
75
91
  }
92
+ texts.set(path, text);
93
+ return text;
76
94
  },
77
95
  };
78
96
 
97
+ /** A .md entry of a directory that is a symbolic link to no file, or to a file outside the project. */
98
+ const unreadable = (page: string): Problem => ({ page, line: 1, kind: "missing-file", message: `${page} is a symbolic link to no file or to a file outside the project folder.` });
99
+
79
100
  function run(argv: string[]): { output: Record<string, unknown>; failed: boolean } {
80
101
  const { values, positionals } = parseArgs({ args: argv, strict: true, allowPositionals: true, options: { source: { type: "string" }, reference: { type: "string" }, check: { type: "boolean" }, help: { type: "boolean" } } });
81
102
  if (values.help) throw new UsageError("");
82
- if (positionals.length !== 1) throw new UsageError("give one documentation directory");
103
+ if (positionals.length === 0) throw new UsageError("give the pages: .md files and directories of the documentation, for example README.md docs");
83
104
  if (values.source === undefined) throw new UsageError("give --source <dir>: the source directory of c64-source");
84
- const docs = posix.normalize(positionals[0]!);
85
- for (const [option, path] of [["<docs>", docs], ["--source", values.source], ["--reference", values.reference]] as const) {
105
+ const given = [...new Set(positionals.map((path) => posix.normalize(path)))];
106
+ for (const [option, path] of [...given.map((path) => ["<pages>", path] as const), ["--source", values.source], ["--reference", values.reference]] as const) {
86
107
  if (path === undefined) continue;
87
108
  if (!files.inside(path)) throw new UsageError(`${option} must be a path relative to the project folder that stays inside it, not ${path}`);
88
109
  }
110
+ for (const path of given) {
111
+ if (!path.endsWith(".md") && files.exists(path) && !isDirectory(path)) throw new UsageError(`<pages> takes .md files and directories, not ${path}`);
112
+ }
113
+ const directories = given.filter((path) => !path.endsWith(".md") || isDirectory(path));
89
114
  const map = readSourceMap(values.source);
90
- const reference = values.reference === undefined ? (existsSync(resolveProjectPath(posix.join(docs, "reference"))) ? posix.join(docs, "reference") : undefined) : posix.normalize(values.reference);
115
+ const reference = values.reference === undefined ? directories.map((path) => posix.join(path, "reference")).find((path) => isDirectory(path)) : posix.normalize(values.reference);
91
116
  const problems: Problem[] = [];
92
117
  const db = openForRead();
93
118
  let symbols;
@@ -108,14 +133,21 @@ function run(argv: string[]): { output: Record<string, unknown>; failed: boolean
108
133
  const routines = symbols.filter((symbol) => symbol.kind === "routine").map((symbol) => symbol.address).sort((a, b) => a - b);
109
134
  const named = [...new Set(symbols.map((symbol) => symbol.address))].sort((a, b) => a - b);
110
135
  const kinds = new Map(symbols.map((symbol) => [symbol.name, symbol.kind]));
136
+ const bodyEnds = new Map<string, number | undefined>();
111
137
  const bodyEnd = (name: string) => {
138
+ if (bodyEnds.has(name)) return bodyEnds.get(name);
112
139
  const address = addresses.get(name);
113
- if (address === undefined) return undefined;
114
- const next = ((kinds.get(name) === "routine" ? routines : named).find((other) => other > address) ?? 0x10000) - 1;
115
- const file = map.lineOf(address)?.file;
116
- if (file === undefined) return Math.min(next, address + MAX_BODY - 1);
117
- let end = address;
118
- while (end < next && map.lineOf(end + 1)?.file === file) end++;
140
+ let end: number | undefined;
141
+ if (address !== undefined) {
142
+ const next = ((kinds.get(name) === "routine" ? routines : named).find((other) => other > address) ?? 0x10000) - 1;
143
+ const file = map.lineOf(address)?.file;
144
+ if (file === undefined) end = Math.min(next, address + MAX_BODY - 1);
145
+ else {
146
+ end = address;
147
+ while (end < next && map.lineOf(end + 1)?.file === file) end++;
148
+ }
149
+ }
150
+ bodyEnds.set(name, end);
119
151
  return end;
120
152
  };
121
153
  const nameAt = new Map(symbols.map((symbol) => [symbol.address, symbol.name]));
@@ -134,7 +166,11 @@ function run(argv: string[]): { output: Record<string, unknown>; failed: boolean
134
166
  const entries = new Map<string, string>();
135
167
  const referencePages = reference === undefined ? [] : markdownFiles(reference);
136
168
  for (const page of referencePages) {
137
- const text = files.text(page)!;
169
+ const text = files.text(page);
170
+ if (text === undefined) {
171
+ problems.push(unreadable(page));
172
+ continue;
173
+ }
138
174
  const stamp = /^<!-- Generated by c64-docs from knowledge revision (\S+) /.exec(text);
139
175
  if (text.startsWith(GENERATED_MARK) && stamp !== null && stamp[1] !== map.revision) {
140
176
  problems.push({ page, line: 1, kind: "stale-reference", message: `${page} is from knowledge revision ${stamp[1]}, and the source from ${map.revision}. Run generate.ts again.` });
@@ -158,11 +194,19 @@ function run(argv: string[]): { output: Record<string, unknown>; failed: boolean
158
194
  managed: (path: string) => inside(path, posix.normalize(map.directory)) || inside(path, reference),
159
195
  };
160
196
 
161
- const pages = markdownFiles(docs);
197
+ const pages = [...new Set(given.flatMap((path) => {
198
+ if (directories.includes(path)) return markdownFiles(path);
199
+ if (files.text(path) === undefined) throw new WireFailure("not-found", `There is no file ${path} in the project folder.`);
200
+ return [path];
201
+ }))];
162
202
  const changedPages: string[] = [];
163
203
  let links = 0;
164
204
  for (const page of pages) {
165
- const text = files.text(page)!;
205
+ const text = files.text(page);
206
+ if (text === undefined) {
207
+ problems.push(unreadable(page));
208
+ continue;
209
+ }
166
210
  // A generated page is right by its making: it is only checked.
167
211
  const generated = text.startsWith(GENERATED_MARK);
168
212
  const linked = generated ? { text, changed: 0, problems: [] } : linkPage(text, { page, ...context });
@@ -170,7 +214,9 @@ function run(argv: string[]): { output: Record<string, unknown>; failed: boolean
170
214
  if (linked.changed === 0) continue;
171
215
  links += linked.changed;
172
216
  changedPages.push(page);
173
- if (!values.check) writeProjectFile(page, Buffer.from(linked.text, "utf8"));
217
+ if (values.check) continue;
218
+ writeProjectFile(page, Buffer.from(linked.text, "utf8"));
219
+ texts.set(page, linked.text);
174
220
  }
175
221
  const byKind: Record<string, number> = {};
176
222
  for (const problem of problems) byKind[problem.kind] = (byKind[problem.kind] ?? 0) + 1;
@@ -109,7 +109,7 @@ Write:
109
109
 
110
110
  1. Read the current value with `at`. Note the `revision` in the result.
111
111
  2. Decide the change. Keep observed facts apart from your conclusions.
112
- 3. Write it with `--reason` and `--expect-revision` set to the revision you read. Add `--evidence` for each thing that the change rests on: `coverage:<map>`, `test:<file>`, `file:<file>` or `note:<text>`. `<map>` is a coverage map that `coverage` put into the knowledge, by its path at that time. The script keeps the SHA-256 of each file and map.
112
+ 3. Write it with `--reason` and `--expect-revision` set to the revision you read. Add `--evidence` for each thing that the change rests on: `coverage:<map>`, `test:<file>`, `file:<file>` or `note:<text>`. `<map>` is a coverage map that `coverage` put into the knowledge, by its path at that time. The script uses the last import of the map into the space of the write. The script keeps the SHA-256 of each file and map.
113
113
  4. Read the result. A `revision` of `null` means that nothing changed.
114
114
 
115
115
  Each write is one revision. A classification inside a region splits that region, so regions never overlap.
@@ -124,7 +124,7 @@ The knowledge holds the program, so a project needs no image or coverage files.
124
124
  4. Read what the knowledge holds with `program`: the segments, the entry, and the coverage maps that it has.
125
125
 
126
126
  - `image` writes only the bytes that changed. The same image again writes nothing. A new image replaces the program of the space. The history keeps the old bytes.
127
- - `coverage` joins the maps. It keeps each address that a run executed, read or wrote. It also keeps each address that one run wrote and executed: self-modified code. A map that the knowledge has is not added again.
127
+ - `coverage` joins the maps. It keeps each address that a run executed, read or wrote. It also keeps each address that one run wrote and executed: self-modified code. `coverage` puts each map into the space of `--space`. You can add the same map to each space. A map that the space has is not added again.
128
128
  - The capture and the maps are not necessary after the import. Keep them in `.c64-re-tools/local/`.
129
129
 
130
130
  For more than a few writes, use `apply -` and give the writes on standard input as one JSON list. Each write has `op` and the arguments of the command with the same name, for example:
@@ -100,7 +100,7 @@ import {
100
100
  import { platformNames } from "#src/platform.ts";
101
101
  import { addCoverage, coverageRuns, programCoverage, programImage, requireProgram, setImage, type ImageRequest } from "#src/knowledge/program.ts";
102
102
  import { WireFailure } from "#src/host-client/tools.ts";
103
- import { isInside, LOCAL_DIRECTORY, PROJECT_DIRECTORY, projectRoot, resolveProjectPath } from "#src/project.ts";
103
+ import { isInside, LOCAL_DIRECTORY, PROJECT_DIRECTORY, projectRelative, projectRoot, resolveProjectPath } from "#src/project.ts";
104
104
  import { listing, listingText, type ListingSource } from "./lib/listing.ts";
105
105
  import { runScript, UsageError } from "#src/script.ts";
106
106
 
@@ -424,7 +424,7 @@ function run(argv: string[]): unknown {
424
424
  const context: WriteContext = { origin: values.origin === undefined ? "llm" : oneOf(["user", "llm"] as const, values.origin, "--origin") };
425
425
  if (values.reason !== undefined) context.description = values.reason;
426
426
  if (values["expect-revision"] !== undefined) context.expectedRevision = values["expect-revision"];
427
- if (values.evidence !== undefined) context.evidence = values.evidence.map(evidenceOf);
427
+ if (values.evidence !== undefined) context.evidence = values.evidence.map((value) => evidenceOf(value, space));
428
428
  // Every argument is checked before the database opens: a refused write creates no database.
429
429
  if (values.title !== undefined && command !== "module") throw new UsageError("--title works only with module");
430
430
  for (const option of ["lo", "hi", "value", "expr", "offset"] as const) {
@@ -562,7 +562,7 @@ function readAnswer(db: DatabaseSync | undefined, command: string, args: string[
562
562
  state: image.state !== undefined,
563
563
  stages: image.stages?.length ?? 0,
564
564
  },
565
- coverage: { maps: coverageRuns(db).map((run) => run.name), ranges: Object.fromEntries(ranges.map((row) => [row.kind, row.count])) },
565
+ coverage: { maps: [...new Set(coverageRuns(db).filter((run) => run.space === space).map((run) => run.name))], ranges: Object.fromEntries(ranges.map((row) => [row.kind, row.count])) },
566
566
  };
567
567
  }
568
568
  case "export": {
@@ -644,23 +644,34 @@ function originFilter(value: string): Origin[] {
644
644
  return [...origins];
645
645
  }
646
646
 
647
+ /** A project path as the knowledge names it: one spelling for each file, so ./runs/a.bin and runs/a.bin are the same file. */
648
+ function knowledgePath(path: string, what: string): string {
649
+ try {
650
+ return projectRelative(path);
651
+ } catch {
652
+ throw new UsageError(`${what} must be a path relative to the project folder that stays inside it, not ${path}`);
653
+ }
654
+ }
655
+
647
656
  /**
648
657
  * --evidence kind:ref. A file kind names a project file and keeps its SHA-256, so a later change of the file shows.
649
658
  * A file in .c64-re-tools/local/ is refused (D82): git ignores it, and the move that keeps it changes the path.
650
659
  */
651
- function evidenceOf(value: string): Evidence {
660
+ function evidenceOf(value: string, space: string): Evidence {
652
661
  const colon = value.indexOf(":");
653
662
  const kind = oneOf(EVIDENCE_KINDS, colon < 0 ? value : value.slice(0, colon), "--evidence kind");
654
- const ref = colon < 0 ? "" : value.slice(colon + 1);
655
- if (ref === "") throw new UsageError(`--evidence needs ${kind}:<${kind === "note" ? "text" : "file"}>`);
656
- if (kind === "note") return { kind, ref };
657
- // A coverage map is evidence by the name that coverage imported it with (D83).
663
+ const given = colon < 0 ? "" : value.slice(colon + 1);
664
+ if (given === "") throw new UsageError(`--evidence needs ${kind}:<${kind === "note" ? "text" : "file"}>`);
665
+ if (kind === "note") return { kind, ref: given };
666
+ const ref = knowledgePath(given, `--evidence ${kind}:<file>`);
667
+ // A coverage map is evidence by the name that coverage imported it with (D83): the latest import into the space of the write (D86).
658
668
  if (kind === "coverage") {
659
669
  const db = openForRead();
660
670
  try {
661
- const run = coverageRuns(db).find((item) => item.name === ref);
662
- if (run === undefined) throw new UsageError(`--evidence coverage:${ref} names no coverage map of the knowledge: import the map with coverage first`);
663
- return { kind, ref, sha256: run.sha256 };
671
+ const run = coverageRuns(db).filter((item) => item.name === ref && item.space === space).at(-1);
672
+ const where = space === DEFAULT_SPACE ? "" : ` in ${space}`;
673
+ if (run === undefined) throw new UsageError(`--evidence coverage:${given} names no coverage map of the knowledge${where}: import the map with coverage${space === DEFAULT_SPACE ? "" : ` --space ${space}`} first`);
674
+ return { kind, ref, sha256: run.sha256, ...(run.space === DEFAULT_SPACE ? {} : { space: run.space }) };
664
675
  } finally {
665
676
  db?.close();
666
677
  }
@@ -719,7 +730,7 @@ function programOperation(command: "image" | "coverage", args: string[], context
719
730
  };
720
731
  }
721
732
  if (args.length === 0) throw new UsageError("coverage needs one or more coverage maps");
722
- const maps = args.map((path) => ({ name: path, bytes: readProjectFile(path).bytes }));
733
+ const maps = args.map((path) => ({ bytes: readProjectFile(path).bytes, name: knowledgePath(path, "coverage <map>") }));
723
734
  return (db) => {
724
735
  const { revision, current } = addCoverage(db, context, { space, maps });
725
736
  return { revision, ...spaceOf(current), imported: current.imported, known: current.known, ranges: current.ranges };
@@ -87,7 +87,7 @@ Do the stages in this sequence. Go to the next stage only when its exit check is
87
87
  | 7. Name | c64-provenance: if the workspace has a library, find its routines with `match` and apply the batch. Then c64-knowledge and c64-memory-map: name each routine in the scope and give it a comment. Record the modules. When the scope has hundreds of routines, ask the user which routines to name. The source gives the other routines an `l_xxxx` label. | Each routine that the user selected has a name and a comment. |
88
88
  | 8. Generate | c64-source into `src/`. For an unpacked program, add `--loader`. | `check.identical` is true. For an unpacked program, `loader.verified` is true. |
89
89
  | 9. Build and test | c64-assembler with `--compare` into `build/`. c64-testing scenarios in `tests/`. | The criteria of stage 1 are true. |
90
- | 10. Document | When the criteria ask for documentation: c64-docs into `docs/`. | `link.ts --check` of c64-docs gives no problem. |
90
+ | 10. Document | When the criteria ask for documentation: c64-docs into `README.md` and `docs/`. | `link.ts --check` of c64-docs gives no problem. |
91
91
  | 11. Hand over | Find each file that you made outside `.c64-re-tools/local/` and that is not a deliverable. Move it into `.c64-re-tools/local/`. Then give the answer (below). | Each file that you made outside `.c64-re-tools/local/` is a deliverable. |
92
92
 
93
93
  In stage 5, if you cannot get to a part of the program, ask the user how to get to it. Do stages 7 and 8 again until the source is clear. Each run of c64-source writes the source again.
@@ -100,7 +100,7 @@ After stage 7, save each routine that other programs can have to the library wit
100
100
  - the generated source
101
101
  - the built program and the loader
102
102
  - the c64-testing scenarios
103
- - the documentation in `docs/`, when the criteria ask for it
103
+ - the documentation in `README.md` and `docs/`, when the criteria ask for it
104
104
  - the runs in `runs/` that the knowledge gives as evidence
105
105
  - the scripts in `tools/` for the formats of this program, when you wrote them.
106
106
 
@@ -128,6 +128,7 @@ tests/ c64-testing scenarios
128
128
  runs/ the runs that the knowledge gives as evidence
129
129
  docs/ the documentation from c64-docs, with the reference in docs/reference/
130
130
  tools/ scripts for the formats of this program only
131
+ README.md the entry page of the documentation from c64-docs
131
132
  ```
132
133
 
133
134
  Commit each folder of the layout and `.c64-re-tools/knowledge/`. The knowledge holds the program, the entry state and the coverage, so the project needs no other folder for them. Put all other files that you make in `.c64-re-tools/local/`, also the captures and the coverage maps after you put them into the knowledge. Do not let git ignore a folder of the layout. Do not change the `.gitignore` files of the project.
@@ -8,10 +8,10 @@
8
8
  // passes, 1 when one fails, 2 for a bad call.
9
9
 
10
10
  import { existsSync, readdirSync, readFileSync, rmSync } from "node:fs";
11
- import { join, posix } from "node:path";
11
+ import { join } from "node:path";
12
12
  import { parseArgs } from "node:util";
13
13
 
14
- import { COVERAGE, coverageSeedKinds, formatC64Address, parseC64Address, prgRange, textToPetscii } from "#src/c64.ts";
14
+ import { COVERAGE, coverageSeedKinds, formatC64Address, parseC64Address, textToPetscii } from "#src/c64.ts";
15
15
  import { autostartLoaded, RUN_LIMIT_FRAMES } from "#src/host-client/autostart.ts";
16
16
  import { MAX_MEMORY_READ, WireFailure } from "#src/host-client/tools.ts";
17
17
  import { writeProjectFile } from "#src/host-client/transfer.ts";
@@ -104,7 +104,6 @@ async function runSteps(client: ViceSessionClient, steps: ActionStep[]): Promise
104
104
  async function trace(program: string, options: { frames: number; entry?: number; steps: ActionStep[] }): Promise<{ output: Record<string, unknown>; failed: boolean }> {
105
105
  const { bytes } = readProjectFile(program);
106
106
  if (bytes.length < 3) throw new UsageError("the file is too short for a PRG");
107
- const load = bytes[0]! | (bytes[1]! << 8);
108
107
  if (options.entry === undefined && basicStart(bytes) === undefined) throw new UsageError(NO_BASIC);
109
108
  const client = await ViceSessionClient.open({ videoStandard: "pal" });
110
109
  try {
@@ -3,38 +3,11 @@
3
3
 
4
4
  import { COVERAGE, type CodeRange } from "../c64.ts";
5
5
  import { MAX_MEMMAP_RANGES, type MemmapRange } from "../protocol/vice.ts";
6
- import { readProjectFile } from "./transfer.ts";
7
6
  import type { ViceSessionClient } from "./vice-session.ts";
8
7
 
9
8
  /** A full coverage file: offset n is address n (D40). */
10
9
  export const FULL_COVERAGE = 0x10000;
11
10
 
12
- /** Reads full coverage files from the project, one map per file. `fail` refuses a file of another size. */
13
- export function readCoverageMaps(paths: string[], fail: (message: string) => never): Uint8Array[] {
14
- return paths.map((path) => {
15
- const { bytes } = readProjectFile(path);
16
- if (bytes.length !== FULL_COVERAGE) {
17
- fail(`--coverage ${path} has ${bytes.length} bytes; a full coverage file has 65536, one for each address. Write it with c64_memmap action read and out, without start and end.`);
18
- }
19
- return bytes;
20
- });
21
- }
22
-
23
- /** Joins coverage maps: an address has every flag that any run gave it. */
24
- export function joinCoverage(maps: readonly Uint8Array[]): Uint8Array {
25
- const joined = new Uint8Array(FULL_COVERAGE);
26
- for (const map of maps) for (let address = 0; address < FULL_COVERAGE; address++) joined[address]! |= map[address]!;
27
- return joined;
28
- }
29
-
30
- /**
31
- * Reads full coverage files from the project and joins them: an address has
32
- * every flag that any run gave it. `fail` refuses a file of another size.
33
- */
34
- export function readCoverageFiles(paths: string[], fail: (message: string) => never): Uint8Array | undefined {
35
- return paths.length === 0 ? undefined : joinCoverage(readCoverageMaps(paths, fail));
36
- }
37
-
38
11
  /** The coverage bytes of start..end, read from the session in parts. */
39
12
  export async function readCoverage(session: Pick<ViceSessionClient, "memmap">, start = 0x0000, end = 0xffff): Promise<Uint8Array> {
40
13
  const map = new Uint8Array(end - start + 1);
@@ -1,5 +1,5 @@
1
- // The image that a skill script works on: an image.json of the c64-unpacker
2
- // skill (a 64 KiB memory and its segments) or a PRG, read from the project.
1
+ // An image.json of the c64-unpacker skill (a 64 KiB memory and its segments)
2
+ // or a PRG, read from the project to put it into the knowledge (D83).
3
3
 
4
4
  import { posix } from "node:path";
5
5
 
@@ -12,12 +12,9 @@ export class ImageError extends Error {
12
12
  }
13
13
 
14
14
  export interface Image {
15
- path: string;
16
15
  segments: Array<{ start: number; end: number }>;
17
16
  /** The byte at an address inside a segment. */
18
17
  byteAt: (address: number) => number;
19
- /** The bytes that Ghidra analyzes, and how. */
20
- analyzed: { bytes: Buffer; kind: "prg" | "flat64k" };
21
18
  entry?: number;
22
19
  /** entry_state.json next to image.json. */
23
20
  statePath?: string;
@@ -35,21 +32,24 @@ export function readImage(path: string): Image {
35
32
  } catch (error) {
36
33
  throw new ImageError(`${path}: ${(error as Error).message}`);
37
34
  }
38
- return { path, segments: [{ start: range.start, end: range.end }], byteAt: (address) => range.body[address - range.start]!, analyzed: { bytes, kind: "prg" } };
35
+ return { segments: [{ start: range.start, end: range.end }], byteAt: (address) => range.body[address - range.start]! };
39
36
  }
40
37
  const fail = (what: string): never => {
41
38
  throw new ImageError(`${path} is not an image.json of the c64-unpacker skill: ${what}`);
42
39
  };
43
- let manifest: Record<string, unknown>;
40
+ const isObject = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
41
+ let parsed: unknown;
44
42
  try {
45
- manifest = JSON.parse(bytes.toString("utf8")) as Record<string, unknown>;
43
+ parsed = JSON.parse(bytes.toString("utf8"));
46
44
  } catch (error) {
47
45
  return fail((error as Error).message);
48
46
  }
47
+ const manifest = isObject(parsed) ? parsed : fail("it is not a JSON object");
49
48
  const address = (value: unknown, what: string) => (typeof value === "string" && /^\$[0-9a-f]{4}$/i.test(value) ? Number.parseInt(value.slice(1), 16) : fail(`${what} is not a $ address`));
50
49
  if (typeof manifest.image !== "string") fail("it has no image");
51
50
  if (!Array.isArray(manifest.segments) || manifest.segments.length === 0) fail("it has no segments");
52
- const segments = (manifest.segments as Array<Record<string, unknown>>)
51
+ const segments = (manifest.segments as unknown[])
52
+ .map((segment, index) => (isObject(segment) ? segment : fail(`segment ${index + 1} is not an object`)))
53
53
  .map((segment, index) => ({ start: address(segment.start, `segment ${index + 1} start`), end: address(segment.end, `segment ${index + 1} end`) }))
54
54
  .sort((a, b) => a.start - b.start);
55
55
  segments.forEach((segment, index) => {
@@ -59,8 +59,8 @@ export function readImage(path: string): Image {
59
59
  const directory = posix.dirname(path);
60
60
  const memory = readProjectFile(posix.join(directory, manifest.image as string)).bytes;
61
61
  if (memory.length !== 0x10000) fail(`its image has ${memory.length} bytes, not 65536`);
62
- const image: Image = { path, segments, byteAt: (at) => memory[at]!, analyzed: { bytes: memory, kind: "flat64k" }, statePath: posix.join(directory, "entry_state.json") };
62
+ const image: Image = { segments, byteAt: (at) => memory[at]!, statePath: posix.join(directory, "entry_state.json") };
63
63
  if (manifest.entry !== undefined) image.entry = address(manifest.entry, "entry");
64
- if (Array.isArray(manifest.stages)) image.stages = manifest.stages as Array<Record<string, unknown>>;
64
+ if (Array.isArray(manifest.stages)) image.stages = manifest.stages.map((stage, index) => (isObject(stage) ? stage : fail(`stage ${index + 1} is not an object`)));
65
65
  return image;
66
66
  }
@@ -1,7 +1,7 @@
1
1
  // Reads project files on the client side for transfer to the Host Runtime.
2
2
  // The host never sees a client path: only bytes and the file's type.
3
3
 
4
- import { lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
4
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
5
5
  import { dirname, extname, join, relative, sep } from "node:path";
6
6
 
7
7
  import { isInside, ProjectError, projectRoot, resolveProject, resolveProjectFile, resolveProjectPath } from "../project.ts";
@@ -80,6 +80,10 @@ export function writeProjectFile(path: string, bytes: Uint8Array, options: { roo
80
80
  } catch {
81
81
  throw refuse();
82
82
  }
83
+ // The nearest folder that exists must be inside the project before a folder is made below it.
84
+ let existing = dirname(file);
85
+ while (!existsSync(existing)) existing = dirname(existing);
86
+ if (!isInside(realpathSync(root), realpathSync(existing))) throw refuse();
83
87
  mkdirSync(dirname(file), { recursive: true });
84
88
  if (!isInside(realpathSync(root), realpathSync(dirname(file)))) throw refuse();
85
89
  let link = false;
@@ -149,9 +149,14 @@ export function revisionNote(db: DatabaseSync | undefined, id: string): { reason
149
149
 
150
150
  /** The revision with its evidence, when it has any. */
151
151
  function withEvidence(db: DatabaseSync, revision: Revision): Revision {
152
- const rows = db.prepare("SELECT kind, ref, sha256 FROM evidence WHERE revision = ? ORDER BY id").all(revision.id) as Raw[];
152
+ const rows = db.prepare("SELECT kind, ref, sha256, space FROM evidence WHERE revision = ? ORDER BY id").all(revision.id) as Raw[];
153
153
  if (rows.length === 0) return revision;
154
- const evidence = rows.map((row): Evidence => ({ kind: row.kind as EvidenceKind, ref: row.ref as string, ...(row.sha256 === null ? {} : { sha256: row.sha256 as string }) }));
154
+ const evidence = rows.map((row): Evidence => ({
155
+ kind: row.kind as EvidenceKind,
156
+ ref: row.ref as string,
157
+ ...(row.sha256 === null ? {} : { sha256: row.sha256 as string }),
158
+ ...(row.space === null ? {} : { space: row.space as Space }),
159
+ }));
155
160
  return { ...revision, evidence };
156
161
  }
157
162
 
@@ -146,20 +146,28 @@ function currentCoverage(db: DatabaseSync, space: Space): Map<CoverageKind, Uint
146
146
  return maps;
147
147
  }
148
148
 
149
- /** The project paths of the coverage maps that the knowledge imported, with their SHA-256. */
150
- export function coverageRuns(db: DatabaseSync | undefined): Array<{ name: string; sha256: string }> {
149
+ /** One import of a coverage map: its project path, its SHA-256, the space it went into (D86) and the revision. */
150
+ export interface CoverageRun {
151
+ name: string;
152
+ sha256: string;
153
+ space: Space;
154
+ revision: string;
155
+ }
156
+
157
+ /** The imports of coverage maps, by name and then oldest first: the last import of a name is the latest. */
158
+ export function coverageRuns(db: DatabaseSync | undefined): CoverageRun[] {
151
159
  if (db === undefined) return [];
152
- return db.prepare("SELECT DISTINCT e.ref AS name, e.sha256 AS sha256 FROM evidence e JOIN revisions r ON r.id = e.revision WHERE r.operation = 'coverage' AND e.kind = 'coverage' ORDER BY e.ref").all() as Array<{
153
- name: string;
154
- sha256: string;
155
- }>;
160
+ return db
161
+ .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`)
162
+ .all() as unknown as CoverageRun[];
156
163
  }
157
164
 
158
165
  /**
159
166
  * Joins coverage maps into the coverage of a space (D83): adds the ranges of
160
167
  * the flags that no imported run set before, and as wx the addresses that one
161
- * run both wrote and executed. Each new map is evidence of the revision. A map
162
- * that the knowledge has with the same name and bytes is not imported again.
168
+ * run both wrote and executed. Each new map is evidence of the revision, with
169
+ * its space. A map that the space has with the same name and bytes is not
170
+ * imported again.
163
171
  */
164
172
  export function addCoverage(
165
173
  db: DatabaseSync,
@@ -172,9 +180,9 @@ export function addCoverage(
172
180
  if (map.bytes.length !== SIZE) throw new KnowledgeError("invalid-input", `${map.name} has ${map.bytes.length} bytes; a coverage map has 65536, one for each address.`);
173
181
  }
174
182
  const hashed = request.maps.map((map) => ({ ...map, sha256: createHash("sha256").update(map.bytes).digest("hex") }));
175
- const known = new Set(coverageRuns(db).map((run) => `${run.name}\n${run.sha256}`));
183
+ const known = new Set(coverageRuns(db).filter((run) => run.space === space).map((run) => `${run.name}\n${run.sha256}`));
176
184
  const fresh = hashed.filter((map) => !known.has(`${map.name}\n${map.sha256}`));
177
- const evidence: Evidence[] = [...(context.evidence ?? []), ...fresh.map((map): Evidence => ({ kind: "coverage", ref: map.name, sha256: map.sha256 }))];
185
+ const evidence: Evidence[] = [...(context.evidence ?? []), ...fresh.map((map): Evidence => ({ kind: "coverage", ref: map.name, sha256: map.sha256, ...(space === DEFAULT_SPACE ? {} : { space }) }))];
178
186
  return write(db, { ...context, evidence }, "coverage", (change) => {
179
187
  const result = { space, imported: fresh.map((map) => map.name), known: hashed.filter((map) => !fresh.includes(map)).map((map) => map.name), ranges: 0 };
180
188
  if (fresh.length === 0) return result;
@@ -151,7 +151,8 @@ function createSchema(db: DatabaseSync): void {
151
151
  revision TEXT NOT NULL REFERENCES revisions (id),
152
152
  kind TEXT NOT NULL CHECK (kind IN (${list(EVIDENCE_KINDS)})),
153
153
  ref TEXT NOT NULL CHECK (length(ref) > 0),
154
- sha256 TEXT CHECK (sha256 IS NULL OR length(sha256) = 64)
154
+ sha256 TEXT CHECK (sha256 IS NULL OR length(sha256) = 64),
155
+ space TEXT CHECK (space IS NULL OR ${SPACE_CHECK})
155
156
  );
156
157
  CREATE INDEX evidence_revision ON evidence (revision);
157
158
  CREATE TABLE comments (
@@ -309,7 +310,7 @@ function createSchema(db: DatabaseSync): void {
309
310
  }
310
311
 
311
312
  /** The version of the index this build makes. An index with another version is emptied and made again. */
312
- export const INDEX_VERSION = 5;
313
+ export const INDEX_VERSION = 6;
313
314
 
314
315
  function indexVersion(db: DatabaseSync): number | undefined {
315
316
  const hasMeta = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'meta'").get();
@@ -231,10 +231,10 @@ export function tableText(db: DatabaseSync, spec: TableSpec): string {
231
231
 
232
232
  /** The text of the revision log: every revision with its evidence, oldest first. */
233
233
  export function revisionsText(db: DatabaseSync): string {
234
- const evidence = db.prepare("SELECT kind, ref, sha256 FROM evidence WHERE revision = ? ORDER BY id");
234
+ const evidence = db.prepare("SELECT kind, ref, sha256, space FROM evidence WHERE revision = ? ORDER BY id");
235
235
  return (db.prepare("SELECT * FROM revisions ORDER BY id").all() as Raw[])
236
236
  .map((raw) => {
237
- const items = (evidence.all(raw.id as string) as Raw[]).map((item) => ({ kind: item.kind, ref: item.ref, ...(item.sha256 === null ? {} : { sha256: item.sha256 }) }));
237
+ const items = (evidence.all(raw.id as string) as Raw[]).map((item) => ({ kind: item.kind, ref: item.ref, ...(item.sha256 === null ? {} : { sha256: item.sha256 }), ...(item.space === null ? {} : { space: item.space }) }));
238
238
  return `${lineOf(raw, REVISION_FIELDS, items.length === 0 ? {} : { evidence: items })}\n`;
239
239
  })
240
240
  .join("");
@@ -337,7 +337,7 @@ function joinById<T extends { id: string }>(file: string, entries: Array<{ line:
337
337
  interface RevisionLine {
338
338
  id: string;
339
339
  columns: Raw;
340
- evidence: Array<{ kind: unknown; ref: unknown; sha256: unknown }>;
340
+ evidence: Array<{ kind: unknown; ref: unknown; sha256: unknown; space: unknown }>;
341
341
  }
342
342
 
343
343
  const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
@@ -349,8 +349,8 @@ function parseRevisions(text: string): RevisionLine[] {
349
349
  const evidence = value.evidence ?? [];
350
350
  if (!Array.isArray(evidence) || evidence.some((item) => typeof item !== "object" || item === null)) bad(REVISIONS_FILE, line, "evidence must be a list of objects.");
351
351
  const items = (evidence as Raw[]).map((item) => {
352
- for (const key of Object.keys(item)) if (!["kind", "ref", "sha256"].includes(key)) bad(REVISIONS_FILE, line, `the evidence key ${JSON.stringify(key)} is not known.`);
353
- return { kind: item.kind, ref: item.ref, sha256: item.sha256 ?? null };
352
+ for (const key of Object.keys(item)) if (!["kind", "ref", "sha256", "space"].includes(key)) bad(REVISIONS_FILE, line, `the evidence key ${JSON.stringify(key)} is not known.`);
353
+ return { kind: item.kind, ref: item.ref, sha256: item.sha256 ?? null, space: item.space ?? null };
354
354
  });
355
355
  return { line, row: { id: columns.id as string, columns, evidence: items } };
356
356
  });
@@ -389,12 +389,12 @@ function parseRows(spec: TableSpec, text: string): RowLine[] {
389
389
  */
390
390
  export function loadText(db: DatabaseSync, files: ReadonlyMap<string, { text: string }>): void {
391
391
  const insertRevision = db.prepare(`INSERT INTO revisions (${REVISION_FIELDS.map((item) => item.column).join(", ")}) VALUES (${REVISION_FIELDS.map(() => "?").join(", ")})`);
392
- const insertEvidence = db.prepare("INSERT INTO evidence (revision, kind, ref, sha256) VALUES (?, ?, ?, ?)");
392
+ const insertEvidence = db.prepare("INSERT INTO evidence (revision, kind, ref, sha256, space) VALUES (?, ?, ?, ?, ?)");
393
393
  const revisions = parseRevisions(files.get(REVISIONS_FILE)?.text ?? "").sort((a, b) => compareText(a.id, b.id));
394
394
  for (const revision of revisions) {
395
395
  try {
396
396
  insertRevision.run(...(REVISION_FIELDS.map((item) => revision.columns[item.column]) as Array<string | null>));
397
- for (const item of revision.evidence) insertEvidence.run(revision.id, item.kind as string, item.ref as string, item.sha256 as string | null);
397
+ for (const item of revision.evidence) insertEvidence.run(revision.id, item.kind as string, item.ref as string, item.sha256 as string | null, item.space as string | null);
398
398
  } catch (error) {
399
399
  throw new KnowledgeError("invalid-database", `.c64-re-tools/knowledge/${REVISIONS_FILE}: the revision ${revision.id} is not valid: ${(error as Error).message}`);
400
400
  }
@@ -92,6 +92,8 @@ export interface Evidence {
92
92
  kind: EvidenceKind;
93
93
  ref: string;
94
94
  sha256?: string;
95
+ /** The space that a coverage map was imported into (D86); none is the C64, as for a row. */
96
+ space?: Space;
95
97
  }
96
98
 
97
99
  /** Who writes and why. Semantic writes come from the user or the LLM, never as an analyzer. */
@@ -212,8 +214,8 @@ export class Change {
212
214
  .prepare("INSERT INTO revisions (id, created_at, origin, operation, input_hash, tool_version, description) VALUES (?, ?, ?, ?, ?, ?, ?)")
213
215
  .run(id, isoCet(now), this.#context.origin, this.#operation, this.#context.inputHash ?? null, this.#context.toolVersion ?? null, this.#context.description ?? null);
214
216
  this.revision = id;
215
- const evidence = this.#db.prepare("INSERT INTO evidence (revision, kind, ref, sha256) VALUES (?, ?, ?, ?)");
216
- for (const item of this.#context.evidence ?? []) evidence.run(id, item.kind, item.ref, item.sha256 ?? null);
217
+ const evidence = this.#db.prepare("INSERT INTO evidence (revision, kind, ref, sha256, space) VALUES (?, ?, ?, ?, ?)");
218
+ for (const item of this.#context.evidence ?? []) evidence.run(id, item.kind, item.ref, item.sha256 ?? null, item.space ?? null);
217
219
  return id;
218
220
  }
219
221
 
@@ -348,6 +350,7 @@ function checkEvidence(evidence: Evidence[] | undefined): void {
348
350
  checkOneOf(EVIDENCE_KINDS, item.kind, "evidence kind");
349
351
  if (item.ref.trim().length === 0 || item.ref.length > MAX_EVIDENCE_REF) throw new KnowledgeError("invalid-input", `evidence has 1 to ${MAX_EVIDENCE_REF} characters after its kind.`);
350
352
  if (item.sha256 !== undefined && !/^[0-9a-f]{64}$/.test(item.sha256)) throw new KnowledgeError("invalid-input", "evidence sha256 must be 64 lowercase hex digits.");
353
+ checkSpace(item);
351
354
  }
352
355
  }
353
356
 
@@ -8,7 +8,7 @@ import { WireFailure } from "../../protocol/messages.ts";
8
8
  import { CHECKPOINT_ACTIONS, MAX_BACKTRACE, MAX_HISTORY, MAX_TRACE_FRAMES, MAX_TRACE_HITS, MAX_TRACE_POINTS, TRACE_KINDS, TRACE_ENDS, MAX_MEMMAP_RANGES, MAX_MEMORY_READ, MAX_PROFILE, MAX_WATCH_SIZE, SPACES, TIMING_ACTIONS, WATCH_ACCESS, type Breakpoint, type Watchpoint } from "../../protocol/vice.ts";
9
9
  import { readCoverage } from "../../host-client/coverage.ts";
10
10
  import { OutInput, ProjectInput, writeOut, WrittenOutput } from "../output.ts";
11
- import { AddressInput, AddressOutput, Byte, ConditionInput, ConditionOutput, defineTool, HexData, requireFields, showCondition, SpaceInput, toCondition, type ViceSessionApi } from "../server.ts";
11
+ import { AddressInput, AddressOutput, Byte, ConditionInput, ConditionOutput, defineTool, HexData, requireFields, showCondition, SpaceInput, toCondition } from "../server.ts";
12
12
  import { spaced } from "./memory.ts";
13
13
 
14
14
  /** No default here: a filled-in default would count as a field the other actions refuse. */
package/src/opcodes.ts CHANGED
@@ -56,10 +56,6 @@ export function takesAddress(mode: Mode): boolean {
56
56
  return mode !== "imp" && mode !== "imm";
57
57
  }
58
58
 
59
- export function isZeroPageMode(mode: Mode): boolean {
60
- return mode === "zp" || mode === "zpx" || mode === "zpy" || mode === "izx" || mode === "izy";
61
- }
62
-
63
59
  /** How an instruction uses the address of its operand, in the order a summary lists them. */
64
60
  export const USE_KINDS = ["read", "written", "modified", "called", "jumped to", "jumped through", "branched to", "pointer", "address", "table", "macro argument"] as const;
65
61
  export type UseKind = (typeof USE_KINDS)[number];
package/src/project.ts CHANGED
@@ -44,6 +44,9 @@ const SCRIPT_REMEDY = "Give --project <folder>";
44
44
  /** The folder that --project chose for this process, or undefined. */
45
45
  let chosen: string | undefined;
46
46
 
47
+ /** The project that the D67 lookup found from a working directory, kept for the next call from the same directory. */
48
+ let found: { cwd: string; root: string } | undefined;
49
+
47
50
  function isProject(folder: string): boolean {
48
51
  return statSync(join(folder, PROJECT_DIRECTORY), { throwIfNoEntry: false })?.isDirectory() === true;
49
52
  }
@@ -163,7 +166,13 @@ export function useProject(folder: string): void {
163
166
 
164
167
  /** The project folder of this process: the --project choice, else the D67 lookup from the working directory. */
165
168
  export function projectRoot(): string {
166
- const root = chosen ?? findProject();
169
+ let root = chosen;
170
+ if (root === undefined) {
171
+ const cwd = process.cwd();
172
+ if (found?.cwd !== cwd) found = { cwd, root: findProject(cwd) };
173
+ root = found.root;
174
+ }
175
+ // Each call: the first write of a process can make .c64-re-tools/ after the lookup.
167
176
  ensureLocal(root);
168
177
  return root;
169
178
  }
@@ -181,6 +190,11 @@ export function resolveProjectPath(path: string, root = projectRoot()): string {
181
190
  throw new TypeError("Project path escapes the project root");
182
191
  }
183
192
 
193
+ /** A project path in one spelling: relative to the project folder, with / and without ./, .. or a double /. Throws TypeError as resolveProjectPath does. */
194
+ export function projectRelative(path: string, root = projectRoot()): string {
195
+ return relative(root, resolveProjectPath(path, root)).split(sep).join("/");
196
+ }
197
+
184
198
  /** True when `candidate` is `root` or below it. Both are absolute paths. */
185
199
  export function isInside(root: string, candidate: string): boolean {
186
200
  const fromRoot = relative(root, candidate);
@@ -109,20 +109,6 @@ export function isRelativePath(value: unknown): value is string {
109
109
  return typeof value === "string" && value.length <= 1024 && RELATIVE_PATH.test(value) && !value.includes("//") && !value.endsWith("/");
110
110
  }
111
111
 
112
- /** Validates a transferred source tree against its attachments. Throws WireFailure(invalid-input). */
113
- export function validateSourceTree(value: unknown, attachments: readonly Uint8Array[]): SourceTree {
114
- if (!Array.isArray(value) || value.length > MAX_TREE_FILES) invalid(`files must list at most ${MAX_TREE_FILES} files`);
115
- if (value.length !== attachments.length) invalid("each listed file needs exactly one attachment");
116
- const seen = new Set<string>();
117
- return value.map((entry, index) => {
118
- if (!isObject(entry) || !isRelativePath(entry.path)) invalid("each file needs a relative path inside the source root");
119
- if (seen.has(entry.path)) invalid(`the file ${entry.path} is listed twice`);
120
- seen.add(entry.path);
121
- if (entry.size !== attachments[index]!.length) invalid(`the size of ${entry.path} does not match its bytes`);
122
- return { path: entry.path, size: entry.size as number };
123
- });
124
- }
125
-
126
112
  /** Refuses anything but exactly one attachment with at least one byte, named by what it must hold. */
127
113
  function oneFile(attachments: readonly Uint8Array[], what: string): void {
128
114
  if (attachments.length !== 1 || attachments[0]!.length === 0) invalid(`${what} must be the one attachment, and it must not be empty`);