@henols/c64-re-tools 2.0.0-rc.7 → 2.0.0-rc.9

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 (36) hide show
  1. package/README.md +3 -1
  2. package/package.json +1 -1
  3. package/skills/c64-basic/SKILL.md +1 -1
  4. package/skills/c64-basic/scripts/basic.ts +1 -1
  5. package/skills/c64-emulator/SKILL.md +2 -1
  6. package/skills/c64-knowledge/SKILL.md +1 -1
  7. package/skills/c64-knowledge/scripts/knowledge.ts +5 -2
  8. package/skills/c64-provenance/SKILL.md +4 -1
  9. package/skills/c64-provenance/scripts/compare.ts +9 -6
  10. package/skills/c64-provenance/scripts/provenance.ts +13 -7
  11. package/skills/c64-source/SKILL.md +6 -5
  12. package/skills/c64-source/scripts/generate.ts +19 -0
  13. package/skills/c64-source/scripts/loader.ts +172 -7
  14. package/skills/c64-source/scripts/source.ts +7 -3
  15. package/skills/c64-static-analysis/SKILL.md +1 -1
  16. package/skills/c64-static-analysis/scripts/idioms.ts +2 -2
  17. package/skills/c64-testing/references/scenario-format.md +1 -1
  18. package/skills/c64-testing/scripts/scenario.ts +7 -5
  19. package/skills/c64-unpacker/SKILL.md +2 -2
  20. package/skills/c64-unpacker/scripts/loads.ts +4 -3
  21. package/skills/c64-unpacker/scripts/machine.ts +13 -1
  22. package/skills/c64-unpacker/scripts/unpack.ts +40 -13
  23. package/src/c64.ts +6 -1
  24. package/src/host/main.ts +27 -6
  25. package/src/host/vice/adapter.ts +14 -0
  26. package/src/host/vice/chips.ts +11 -1
  27. package/src/host/vice/screen.ts +3 -2
  28. package/src/host/vice/session.ts +23 -7
  29. package/src/host-client/autostart.ts +11 -1
  30. package/src/host-client/tools.ts +1 -1
  31. package/src/mcp/tools/debug.ts +6 -3
  32. package/src/mcp/tools/machine.ts +2 -2
  33. package/src/mcp/tools/media.ts +2 -0
  34. package/src/mcp/tools/video.ts +5 -2
  35. package/src/protocol/vice.ts +50 -8
  36. package/src/trace.ts +55 -23
package/README.md CHANGED
@@ -27,6 +27,8 @@ Start the Host Runtime and leave it running:
27
27
  npx -y --package=@henols/c64-re-tools@latest c64-re-tools-host
28
28
  ```
29
29
 
30
+ Add `--log-console` to print each request and each c1541 or petcat run, or `--log-dir <directory>` to write them to a file. Both are off by default.
31
+
30
32
  Install into your project and start the agent:
31
33
 
32
34
  ```
@@ -64,7 +66,7 @@ The Feature installs ACME, dxa, Java 21 and Ghidra, puts the toolkit into the pr
64
66
  | `C64RT_GHIDRA` | skills, CLI | The Ghidra installation directory, when `analyzeHeadless` is not on `PATH`. |
65
67
  | `C64RT_HOST` | MCP, skills, CLI | `host:port` of the Host Runtime when it is not on 127.0.0.1, `host.docker.internal` or `host.containers.internal` at 6464. |
66
68
  | `C64RT_VIDEO` | MCP | `pal` (default) or `ntsc`, fixed for the life of the MCP process. |
67
- | `C64RT_TRACE` | host, MCP, skills, CLI | A directory for a trace of what the programs do, for a person who debugs a run. See [docs/debug-install.md](docs/debug-install.md). Nothing of the trace reaches the agent. |
69
+ | `C64RT_TRACE` | MCP, skills, CLI | A directory for a trace of what the programs do, for a person who debugs a run. See [docs/debug-install.md](docs/debug-install.md). Nothing of the trace reaches the agent. The host uses `--log-dir` instead. |
68
70
 
69
71
  Ghidra runs with its own settings directory for each request. The toolkit
70
72
  brings its own NMOS 6510 language (all 256 opcodes); your Ghidra installation
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henols/c64-re-tools",
3
- "version": "2.0.0-rc.7",
3
+ "version": "2.0.0-rc.9",
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",
@@ -54,7 +54,7 @@ The exit status is 0 for a listing. The exit status is 1 for `decoded: false` an
54
54
 
55
55
  ## Failure and conflicts
56
56
 
57
- - `decoded: false`: the file is not a BASIC program. This is a normal result. Usually the file is machine code. Give it to the c64-static-analysis skill.
57
+ - `decoded: false`: the file is not a BASIC program. This is a normal result. Usually the file is machine code. Give it to the c64-static-analysis skill. A memory dump also gives `decoded: false`: its first two bytes are not a load address, and its bytes go past `$ffff`.
58
58
  - An empty `handoffs` list: the BASIC does not start machine code with SYS or USR. Do not make up a handoff. Look for a POKE into a vector or a LOAD of another file in the listing.
59
59
  - `installation-incomplete`, or `machine-unavailable` with a message that the host runtime does not run: stop the task. Give the message to the user and wait for the user. The message gives the remedy. Do not make a replacement for the tool or the host runtime, for example your own disk parser, disassembler or emulator script.
60
60
  - `not-found` or `invalid-input`: the path or an argument is wrong. Read the message and correct the call.
@@ -22,7 +22,7 @@ async function run(argv: string[]): Promise<{ output: unknown; failed: boolean }
22
22
  const result = await decodeBasic({ program });
23
23
  if (!result.decoded) return { output: { decoded: false, reason: result.reason }, failed: true };
24
24
  const size = statSync(resolveProjectPath(program)).size - 2;
25
- const end = Math.min(0xffff, result.loadAddress + size - 1);
25
+ const end = result.loadAddress + size - 1;
26
26
  return {
27
27
  output: {
28
28
  decoded: true,
@@ -66,12 +66,13 @@ Write operations need a stopped CPU. Stop the CPU with `c64_execution` action `p
66
66
 
67
67
  `c64_reset` with `run` false stops the CPU at the first instruction of the reset routine. The result gives that address in `pc`. A reset and `c64_autostart` set `c64_timing` to zero.
68
68
 
69
- A cartridge from `c64_autostart` stays in the C64 through every reset, as on a real C64. `c64_status` gives `cartridge: true` while the C64 has it. `c64_autostart` of a file that is not a cartridge removes the cartridge first.
69
+ A cartridge from `c64_autostart` stays in the C64 through every reset, as on a real C64. `c64_status` gives `cartridge: true` while the C64 has it, and `cartridge: false` when it does not. `c64_autostart` of a file that is not a cartridge removes the cartridge first. `c64_autostart` also sets both joysticks to the center first. `joysticksCentred` gives the ports that it changed.
70
70
 
71
71
  Step, next and until-return work only in space `c64`. The emulator cannot stop the disk drive CPU after one instruction. To find when the drive executes an address, use `c64_run_until` with an address target in space `drive8`, or a breakpoint in space `drive8`. The drive can execute a few more instructions before it stops. The computer completes its current instruction. The `pc` in the result is the drive address where it stopped.
72
72
 
73
73
  `c64_keyboard` puts keys in the keyboard queue of the KERNAL. A program that reads the keyboard hardware directly does not see them. Many games do this. The emulator has no command that presses one key of the keyboard matrix. For such a program:
74
74
 
75
+ - After `c64_window` or a snapshot restore, the profiler of VICE keeps the routines that ran before the restore. Then `c64_profile` can give the cycles of the program to such a routine, for example `$fd50`. Do not trust the routine addresses of `c64_profile` after a restore.
75
76
  - VICE 3.10 and older have a known flaw: bits 5-7 of `$DC00` and `$DC01` are always 0. Then `emulatorFlaw` in the result of `c64_keyboard` and `c64_joystick` gives the flaw. With this flaw, a wait for any key that reads all keyboard columns at one time ends without a key. A wait for one specific key, or a compare of the full byte of a joystick port, never ends. Tell the user about the flaw. The next VICE release and the VICE nightly builds do not have it. Joystick port 1 uses the same lines as the keyboard.
76
77
  - For one specific key, for example F1 or RUN/STOP, open the window and ask the user to press the key.
77
78
  - Do not change the program to make it accept a key.
@@ -146,7 +146,7 @@ The script records the origin `llm`. Use `--origin user` only when the user deci
146
146
 
147
147
  For a read: the current or historical knowledge in compact JSON.
148
148
 
149
- For a write: the new revision and the current value after the write. If the write replaces a symbol or a region of a different origin, `previous` gives that row as it was before the write. For example, an `llm` write over a `user` name gives `previous`. Read `previous` and tell the user about the change.
149
+ For a write: the new revision and the current value after the write. If the write replaces a symbol or a region of a different origin, `previous` gives that row as it was before the write. For example, an `llm` write over a `user` name gives `previous`. Read `previous` and tell the user about the change. For `apply`, `replaced` lists each write that replaced a row. Each item has the number of the write, its `op` and its `previous`.
150
150
 
151
151
  The exit status is 0 for a result. The exit status is 1 for an error from the knowledge, for example `conflict`. The exit status is 2 for a wrong argument.
152
152
 
@@ -346,8 +346,11 @@ function applyOperation(args: string[], context: WriteContext, space: string): W
346
346
  const operations = parsed.map((item, index) => batchOperation(item, `write ${index + 1}`, space));
347
347
  return (db) => {
348
348
  const applied = applyBatch(db, context, operations);
349
- // replaced: writes that replaced a row of another origin, as previous does for one write.
350
- return { revision: applied.revision, writes: applied.steps.length, replaced: applied.steps.filter((step) => step.previous !== undefined).length };
349
+ // replaced: each write that replaced a row of another origin, with that row as previous gives it for one write (b2/07).
350
+ const replaced = applied.steps.flatMap((step, index) =>
351
+ step.previous === undefined ? [] : [{ write: index + 1, op: operations[index]!.op, previous: Array.isArray(step.previous) ? step.previous.map(region) : symbol(step.previous) }],
352
+ );
353
+ return { revision: applied.revision, writes: applied.steps.length, replaced };
351
354
  };
352
355
  }
353
356
 
@@ -47,7 +47,9 @@ With `--run`, a PRG starts with autostart and RUN. An image starts from its byte
47
47
  ## Workflow
48
48
 
49
49
  1. Run the script on the releases.
50
- 2. Read `identicalShare`. If it is low and `suggestedShifts` is present, the releases are at different addresses. Run the script again with the `--shift` of `suggestedShifts`. `shiftsNotSuggested` tells why a found shift does not fit in `$0000-$ffff`.
50
+ 2. Read `identicalShare`. If it is low and `suggestedShifts` is present, the releases are at different addresses. Run the script again with a `--shift` of `suggestedShifts`. `shiftsNotSuggested` tells why a found shift does not fit in `$0000-$ffff`.
51
+
52
+ `suggestedShifts` has at most 3 shifts for each release. The shift with the most `matchingSequences` is first. Data that did not move and code that moved give different shifts. Try each shift. `current: true` marks the shift of this run.
51
53
  3. Read `differences`. Each one has an address range, the bytes of each release, and `groups`: the releases that have the same bytes there.
52
54
  4. To know how the program uses the differences, run the script again with `--run` and the frames that get the program to the part you examine. Read `runtime` of each difference:
53
55
  - `first`: the first access in that release: `read`, `write`, `execute` or `none`. An instruction that reads and then writes the same byte, for example INC, counts as `read`. The operand bytes of an executed instruction count as `execute`, for example the value of `LDA #$03`.
@@ -67,6 +69,7 @@ Use `separate` when you have only one release. It gives evidence, not a decision
67
69
 
68
70
  - `ranges`: the image in ranges with the same stage and access. `stage` is the last unpack stage that wrote the range. In `image.json`, the last stage is the program. An earlier stage is a depacker, an intro or a loader. Only an image from c64-unpacker `capture` has stages. For an image from `loads`, `noStages` tells this.
69
71
  - `access`: from the joined `--coverage` files: `executed`, `read`, `written`, `read-written` or `untouched`. `untouched` bytes did not take part in the runs.
72
+ - `codeRegions.total`: the number of code regions in the knowledge.
70
73
  - `codeRegions.unreachable`: code regions of the knowledge that no chain of call and jump references reaches from the entry. Run c64-static-analysis first, so that the knowledge has the code regions and the references.
71
74
  - `outside`: RAM outside the image that the program `read` or `executed` but did not write, and code it `generated`. A loader or an earlier stage put the bytes that the program did not write. A `generated` range can have gaps of two bytes or less: these are operand bytes.
72
75
 
@@ -93,11 +93,15 @@ function addressesOf(release: Release): number[] {
93
93
  return release.segments.flatMap((segment) => Array.from({ length: segment.end - segment.start + 1 }, (_, offset) => segment.start + offset));
94
94
  }
95
95
 
96
+ /** Most shifts that suggestShifts gives: data that did not move can outvote code that did (b2/04). */
97
+ export const MAX_SUGGESTED_SHIFTS = 3;
98
+
96
99
  /**
97
- * The shift that puts `other` over `base` best: the most common address
98
- * difference between equal 8-byte sequences. Undefined when nothing matches.
100
+ * The shifts that put `other` over `base` best: the most common address
101
+ * differences between equal 8-byte sequences, most votes first. A shift with
102
+ * less than a tenth of the best one's votes is chance. Empty when nothing matches.
99
103
  */
100
- export function suggestShift(base: Release, other: Release): { shift: number; matches: number } | undefined {
104
+ export function suggestShifts(base: Release, other: Release): Array<{ shift: number; matches: number }> {
101
105
  const WINDOW = 8;
102
106
  const key = (release: Release, addresses: number[], at: number) =>
103
107
  String.fromCharCode(...addresses.slice(at, at + WINDOW).map((address) => release.byteAt(address)));
@@ -117,9 +121,8 @@ export function suggestShift(base: Release, other: Release): { shift: number; ma
117
121
  votes.set(shift, (votes.get(shift) ?? 0) + 1);
118
122
  }
119
123
  }
120
- let best: { shift: number; matches: number } | undefined;
121
- for (const [shift, matches] of votes) if (best === undefined || matches > best.matches) best = { shift, matches };
122
- return best;
124
+ const ranked = [...votes].map(([shift, matches]) => ({ shift, matches })).sort((a, b) => b.matches - a.matches || Math.abs(a.shift) - Math.abs(b.shift));
125
+ return ranked.filter((found) => found.matches * 10 >= ranked[0]!.matches).slice(0, MAX_SUGGESTED_SHIFTS);
123
126
  }
124
127
 
125
128
  export type ShiftSuggestion = { release: number; shift: number; matchingSequences: number } | { release: number; matchingSequences: number; reason: string };
@@ -18,7 +18,7 @@ import { KnowledgeError, openForRead } from "#src/knowledge/database.ts";
18
18
  import { listReferences, listRegions } from "#src/knowledge/read.ts";
19
19
  import { CODE_REGION_TYPES } from "#src/knowledge/schema.ts";
20
20
  import { runScript, UsageError } from "#src/script.ts";
21
- import { compareReleases, shiftSuggestion, suggestShift, type Range, type Release } from "./compare.ts";
21
+ import { compareReleases, shiftSuggestion, suggestShifts, type Range, type Release } from "./compare.ts";
22
22
  import { watchRelease, type ImageState, type Start } from "./runtime.ts";
23
23
  import { separate } from "./separate.ts";
24
24
 
@@ -204,12 +204,17 @@ async function run(argv: string[]): Promise<unknown> {
204
204
  const compared = comparison.identicalBytes + comparison.differingBytes;
205
205
  const identicalShare = compared === 0 ? 0 : comparison.identicalBytes / compared;
206
206
  // A poor match at the load addresses can mean that the releases are placed differently.
207
+ // Each release gets its best shifts: data that stayed can outvote code that moved, so one is not enough (b2/04).
208
+ const applied = loaded.map((item) => item.shift);
207
209
  const suggestions =
208
210
  identicalShare < 0.5
209
- ? releases.slice(1).flatMap((release, index) => {
210
- const best = suggestShift(releases[0]!, release);
211
- return best === undefined || best.shift === 0 ? [] : [shiftSuggestion(releases, loaded.map((item) => item.shift), index + 1, best)];
212
- })
211
+ ? releases.slice(1).flatMap((release, index) =>
212
+ suggestShifts(releases[0]!, release).map((found) => {
213
+ const suggestion = shiftSuggestion(releases, applied, index + 1, found);
214
+ // The shift that the run already has: its votes tell how good the present alignment is.
215
+ return "shift" in suggestion && suggestion.shift === applied[suggestion.release - 1] ? { ...suggestion, current: true } : suggestion;
216
+ }),
217
+ )
213
218
  : [];
214
219
  const suggested = suggestions.filter((suggestion) => "shift" in suggestion);
215
220
  const notSuggested = suggestions.filter((suggestion) => "reason" in suggestion);
@@ -316,9 +321,10 @@ function separateCommand(path: string, coveragePaths: string[], out: string | un
316
321
  }),
317
322
  ...(coverage === undefined ? {} : { bytesByAccess: Object.fromEntries((["executed", "read", "written", "read-written", "untouched"] as const).map((access) => [access, bytes((range) => range.access === access)])) }),
318
323
  ranges: listed(ranges),
324
+ // total counts every code region; unreachable is a list like the others (b2/08).
319
325
  codeRegions: {
320
- count: codeRegions.length,
321
- ...(loaded.entry === undefined ? {} : { unreachable: codeRegions.filter((region) => region.reachable === false) }),
326
+ total: codeRegions.length,
327
+ ...(loaded.entry === undefined ? {} : { unreachable: listed(codeRegions.filter((region) => region.reachable === false)) }),
322
328
  },
323
329
  ...(coverage === undefined ? {} : { outside: { read: listed(outside.read), executed: listed(outside.executed), generated: listed(outside.generated) } }),
324
330
  ...(out === undefined ? {} : { out }),
@@ -52,7 +52,7 @@ node <skill>/scripts/generate.ts unpacked/image.json --out src --loader
52
52
  - `main.asm` sets the address of each segment and includes the files in address order.
53
53
  - Each module in the knowledge is one file. A module starts at its address and stops at the next module or at the end of its segment. Without modules, each segment is one file, `segment_xxxx.asm`.
54
54
  - `constants.asm` holds the names of addresses outside the image, for example `EXTCOL = $d020`.
55
- - A label or a constant gets the name of the address in the knowledge. Without a knowledge name, it gets the platform name of the c64-memory-map skill, for example `EXTCOL` or `CHROUT`. An address in the image without a name gets `l_xxxx`.
55
+ - A label or a constant gets the name of the address in the knowledge. Without a knowledge name, it gets the platform name of the c64-memory-map skill, for example `EXTCOL` or `CHROUT`. A platform name wins over a name from an analyzer (`dxa`, `ghidra` or `heuristic`), for example `EXTCOL` over `ld020`. A `user` or `llm` name wins over a platform name. An address in the image without a name gets `l_xxxx`.
56
56
  - Code regions, and the instructions that Ghidra decodes outside of data regions, become instructions.
57
57
  - Data regions become data by their type: `!byte`, `!word`, `!pet`, `!scr`, `!fill`, tables of labels, and sprite bytes in binary.
58
58
  - `lda+2 $0012` is an absolute operand below $0100. `+1` is a zero-page operand with a label.
@@ -72,11 +72,12 @@ Do not edit the generated files. The next run writes over them.
72
72
  2. It moves its copier to two free pages. The copier runs with interrupts off and with all RAM visible.
73
73
  3. It copies each segment and the pages $0002-$03ff of the entry state into place. The script selects an order in which no copy writes over the packed bytes of a copy that has not run yet.
74
74
  4. It sets the display registers of the VIC-II and the VIC bank from `io` in the entry state. Then it waits for the raster line of the entry.
75
- 5. It sets $00/$01, the stack pointer, A, X, Y and the flags, and jumps to the entry.
75
+ 5. It sets the CIA timers, their latches and the interrupt masks from `io` and `cia` in the entry state. A running timer gets the value that counts down to the timer value of the entry at the entry.
76
+ 6. It sets $00/$01, the stack pointer, A, X, Y and the flags, and jumps to the entry.
76
77
 
77
- The flags go through the free stack byte below the stack pointer (`stackByte`). The loader changes this byte. The loader does not set the SID, the CIA timers or the interrupt registers of the VIC-II. They stay as the KERNAL set them. Thus a scenario can still find a difference in an interrupt or a timer. Then use `runUntil` with `raster` in the c64-testing skill.
78
+ The flags go through the free stack byte below the stack pointer (`stackByte`). The loader changes this byte. The loader does not set the SID, the time of day of the CIAs, the CIA interrupt flags or the interrupt registers of the VIC-II. `loader.limits` names each CIA value that the loader cannot set as it was at the entry. A program that reads the SID voice 3 for random numbers can still differ in a scenario. A raster sync does not change a timer or the SID. Then observe after the program sets up its own random numbers.
78
79
 
79
- The script simulates the copies first. Then it runs `loader.prg` in the emulator to its jump to the entry. It compares the memory, $00/$01, the registers, the display registers of the VIC-II, the VIC bank and the raster line with the entry state there. `loader.verified: true` tells that all of them are equal. An interrupt can run right at the entry, as in the original program.
80
+ The script simulates the copies first. Then it runs `loader.prg` in the emulator to its jump to the entry. It compares the memory, $00/$01, the registers, the display registers of the VIC-II, the VIC bank and the raster line with the entry state there. It also compares the CIA timers, latches and interrupt masks. `loader.verified: true` tells that all of them are equal. An interrupt can run right at the entry, as in the original program.
80
81
 
81
82
  ## Knowledge
82
83
 
@@ -102,7 +103,7 @@ One JSON object:
102
103
  - `entryOutside`: the entry of `image.json` is in no segment of the image, with a `reason`. The image does not hold the program.
103
104
  - `notExecuted`: with `--coverage`, the count of instructions that no run executed, the count of their ranges, and the first ranges. `all` names the file `not_executed.json` in `--out`. It has all of the ranges. Put all of these ranges in the report.
104
105
  - `check`: `identical`, and the result for each segment. A different segment gives its first different address.
105
- - `loader`: the program, the copies in their order, the copier page, `stackByte`, `verified` and `differences`. When the script cannot make the loader, `loader` has `verified: false` and a `reason`. The loader includes the source. Thus a source that does not assemble to the image also stops the loader.
106
+ - `loader`: the program, the copies in their order, the copier page, `stackByte`, `verified` and `differences`. `limits` lists the CIA values that the loader cannot set. When the script cannot make the loader, `loader` has `verified: false` and a `reason`. The loader includes the source. Thus a source that does not assemble to the image also stops the loader.
106
107
 
107
108
  The exit status is 0 when the source is identical and the emulator check of the loader passes. The exit status is 1 for a failed check, for an entry in no segment, and for an error. The exit status is 2 for a wrong argument.
108
109
 
@@ -130,6 +130,8 @@ async function decode(image: Image, knowledge: ReturnType<typeof readKnowledge>,
130
130
  }
131
131
  const entryPoints = [...seeds].filter(inImage).sort((a, b) => a - b);
132
132
  if (entryPoints.length === 0) {
133
+ // An entry in no segment is a capture fault: the image is data only, and the result says entryOutside (R3-01).
134
+ if (image.entry !== undefined && !inImage(image.entry)) return new Map();
133
135
  throw new UsageError("Ghidra needs an entry point: give --entry, or record a routine or a code region in knowledge with the c64-knowledge skill");
134
136
  }
135
137
  const dataRanges = knowledge.regions
@@ -286,6 +288,21 @@ async function runLoader(path: string, program: Buffer, plan: LoaderPlan, image:
286
288
  const wanted = rasterLine(state.vic.registers);
287
289
  if (line !== wanted && line !== wanted + 1) differences.push(`the raster line is ${line}, not ${wanted}`);
288
290
  }
291
+ if (plan.cia !== undefined) {
292
+ // The CIA timers (D63): at the jump a running timer is 3 cycles, the JMP, before its value at the entry.
293
+ const chips = (await client.cia("both")).chips;
294
+ const word = (value: number) => `$${value.toString(16).padStart(4, "0")}`;
295
+ const differWord = (what: string, got: number, wanted: number) => got !== wanted && differences.push(`${what} is ${word(got)}, not ${word(wanted)}`);
296
+ for (const timer of plan.cia.timers) {
297
+ const chip = chips[timer.cia - 1]!;
298
+ const name = `CIA ${timer.cia} timer ${timer.timer}`;
299
+ const [counter, latch, control] = timer.timer === "A" ? [chip.timerA, chip.latchA, chip.controlA] : [chip.timerB, chip.latchB, chip.controlB];
300
+ differ(`${name} control`, control, timer.control);
301
+ differWord(`${name} latch`, latch, timer.latch);
302
+ if (timer.counterAtJump !== undefined) differWord(`${name} counter`, counter, timer.counterAtJump);
303
+ }
304
+ plan.cia.masks.forEach((mask, index) => differ(`CIA ${index + 1} interrupt mask`, chips[index]!.interruptMask, mask));
305
+ }
289
306
  return { verified: differences.length === 0, differences: differences.slice(0, MAX_LISTED) };
290
307
  } finally {
291
308
  await client.close();
@@ -457,6 +474,8 @@ async function run(argv: string[]): Promise<{ output: Record<string, unknown>; f
457
474
  copier: formatC64Address(loader.copier),
458
475
  stackByte: formatC64Address(loader.stackByte),
459
476
  ...ran,
477
+ // What the loader cannot set as it was at the entry (D63).
478
+ ...(loader.cia === undefined || loader.cia.limits.length === 0 ? {} : { limits: loader.cia.limits }),
460
479
  };
461
480
  return { output, failed: !ran.verified };
462
481
  }
@@ -1,8 +1,8 @@
1
1
  // The loader of a generated source (D48): a PRG that copies each segment and
2
2
  // the entry state's pages $0002-$03ff into place, sets the display registers
3
3
  // of the VIC-II and the VIC bank, waits for the entry's raster line (D60),
4
- // sets $00/$01, the stack pointer, A, X, Y and the flags, and jumps to the
5
- // entry. The copies run
4
+ // sets the CIA timers and interrupt masks (D63), sets $00/$01, the stack
5
+ // pointer, A, X, Y and the flags, and jumps to the entry. The copies run
6
6
  // from a free page with SEI and all RAM visible, in an order where no copy
7
7
  // overwrites the packed bytes of a copy that has not run yet. The plan is
8
8
  // simulated here before the emulator runs it.
@@ -24,6 +24,16 @@ export interface EntryState {
24
24
  pages: Uint8Array;
25
25
  /** The VIC-II at the entry (D60): $d000-$d02e, and the VIC bank (bits 0-1 of $dd00). */
26
26
  vic?: { registers: Uint8Array; bank: number };
27
+ /** CIA 1 and CIA 2 at the entry (D63). */
28
+ cia?: [EntryCia, EntryCia];
29
+ }
30
+
31
+ /** A CIA at the entry: $xx00-$xx0f as a read without side effects gives them, and what no read gives. */
32
+ export interface EntryCia {
33
+ registers: Uint8Array;
34
+ latchA: number;
35
+ latchB: number;
36
+ interruptMask: number;
27
37
  }
28
38
 
29
39
  /**
@@ -85,6 +95,7 @@ export function readEntryState(text: string, path: string): EntryState {
85
95
  port: [byte(value.cpuPort?.$00, "cpuPort.$00"), byte(value.cpuPort?.$01, "cpuPort.$01")],
86
96
  pages,
87
97
  ...readVic(value.io, fail),
98
+ ...readCia(value, fail),
88
99
  };
89
100
  }
90
101
 
@@ -114,6 +125,8 @@ export interface LoaderPlan {
114
125
  stackByte: number;
115
126
  end: number;
116
127
  text: string;
128
+ /** The CIA timers and masks that the loader sets, when the entry state has them (D63). */
129
+ cia?: CiaPlan;
117
130
  }
118
131
 
119
132
  /** The label of the loader's jump to the entry, in ACME's symbol list. */
@@ -190,6 +203,151 @@ function readVic(io: Record<string, any> | undefined, fail: (what: string) => ne
190
203
  return { vic: { registers: Buffer.from(vic, "hex"), bank: Number.parseInt(cia2.slice(0, 2), 16) & 0x03 } };
191
204
  }
192
205
 
206
+ function readCia(value: Record<string, any>, fail: (what: string) => never): { cia: [EntryCia, EntryCia] } | Record<string, never> {
207
+ if (value.cia === undefined) return {};
208
+ const chip = (name: "cia1" | "cia2"): EntryCia => {
209
+ const bytes = value.io?.[name]?.bytes;
210
+ if (typeof bytes !== "string" || !/^[0-9a-f]{32}$/i.test(bytes)) return fail(`io.${name}.bytes is not 16 bytes of hex`);
211
+ const hidden = value.cia[name];
212
+ const number = (item: unknown, what: string, max: number) => (Number.isInteger(item) && (item as number) >= 0 && (item as number) <= max ? (item as number) : fail(`cia.${name}.${what} is not a number from 0 to ${max}`));
213
+ return { registers: Buffer.from(bytes, "hex"), latchA: number(hidden?.latchA, "latchA", 0xffff), latchB: number(hidden?.latchB, "latchB", 0xffff), interruptMask: number(hidden?.interruptMask, "interruptMask", 0x1f) };
214
+ };
215
+ return { cia: [chip("cia1"), chip("cia2")] };
216
+ }
217
+
218
+ /** One CIA timer that the loader sets, and what the check of the loader expects of it at the jump. */
219
+ export interface CiaTimerPlan {
220
+ cia: 1 | 2;
221
+ timer: "A" | "B";
222
+ /** The control register as a read gives it: without bit 4, the force load. */
223
+ control: number;
224
+ latch: number;
225
+ /** The counter at the jump, 3 cycles before the entry; undefined when the loader cannot set it. */
226
+ counterAtJump?: number;
227
+ }
228
+
229
+ export interface CiaPlan {
230
+ timers: CiaTimerPlan[];
231
+ masks: [number, number];
232
+ /** What the loader cannot set as it was at the entry. */
233
+ limits: string[];
234
+ }
235
+
236
+ /**
237
+ * The CIA timers as the KERNAL leaves them when RUN starts the loader, on a
238
+ * PAL C64: only CIA 1 timer A runs, for the 60 Hz interrupt. A timer that the
239
+ * entry has in this state is not written. The loader check reads every timer.
240
+ */
241
+ const KERNAL_TIMERS: ReadonlyArray<{ control: number; latch: number; counter?: number }> = [
242
+ { control: 0x01, latch: 0x4025 },
243
+ { control: 0x08, latch: 0xffff, counter: 0xffff },
244
+ { control: 0x08, latch: 0xffff, counter: 0xffff },
245
+ { control: 0x08, latch: 0xffff, counter: 0xffff },
246
+ ];
247
+ const KERNAL_MASKS: readonly [number, number] = [0x01, 0x00];
248
+ /** Cycles from the end of the CIA part to the entry: $00, $01, the stack pointer, the flags, A, X, Y, PLP and JMP. */
249
+ const TAIL_CYCLES = 33;
250
+ /** Cycles after the write that starts a CIA timer in which it does not count yet, measured in VICE. */
251
+ const CIA_START_DELAY = 1;
252
+ /** Cycles of one raster line of a PAL C64. */
253
+ const LINE_CYCLES = 63;
254
+ const PAL_LINES = 312;
255
+
256
+ interface Op {
257
+ text: string;
258
+ bytes: number;
259
+ cycles: number;
260
+ }
261
+
262
+ /**
263
+ * The copier's CIA part (D63), after the raster wait: the timers and
264
+ * interrupt masks of the entry state. A running timer is stopped, loaded
265
+ * with its counter at the entry plus the cycles to the entry, started, and
266
+ * given its latch, so it counts to the entry's value just at the entry.
267
+ * Then the interrupt flags that the copy left are read away.
268
+ */
269
+ export function ciaSection(state: EntryState): { ops: Op[]; plan: CiaPlan } | undefined {
270
+ if (state.cia === undefined) return undefined;
271
+ const lda = (value: number): Op => ({ text: `lda #${hex2(value)}`, bytes: 2, cycles: 2 });
272
+ const sta = (address: number): Op => ({ text: `sta ${hex4(address)}`, bytes: 3, cycles: 4 });
273
+ const limits: string[] = [];
274
+ const timers: CiaTimerPlan[] = [];
275
+ const disable: Op[] = [];
276
+ const loads: Array<Op | ((cycles: number[]) => Op)> = [];
277
+ const starts: Op[] = [];
278
+ const latches: Op[] = [];
279
+ const acks: Op[] = [];
280
+ const masks: Op[] = [];
281
+ /** For each running timer: its index in starts, and how its load value follows from the cycles after its start. */
282
+ const running: Array<{ start: number; counter: number; latch: number; system: boolean; name: string; plan: CiaTimerPlan }> = [];
283
+ state.cia.forEach((chip, index) => {
284
+ const id = (index + 1) as 1 | 2;
285
+ const base = index === 0 ? 0xdc00 : 0xdd00;
286
+ let touched = false;
287
+ for (const timer of ["A", "B"] as const) {
288
+ const at = timer === "A" ? 0x04 : 0x06;
289
+ const controlAt = base + (timer === "A" ? 0x0e : 0x0f);
290
+ const control = chip.registers[timer === "A" ? 0x0e : 0x0f]! & 0xef;
291
+ const counter = chip.registers[at]! | (chip.registers[at + 1]! << 8);
292
+ const latch = timer === "A" ? chip.latchA : chip.latchB;
293
+ const name = `CIA ${id} timer ${timer}`;
294
+ const plan: CiaTimerPlan = { cia: id, timer, control, latch, counterAtJump: counter };
295
+ timers.push(plan);
296
+ const kernal = KERNAL_TIMERS[index * 2 + (timer === "A" ? 0 : 1)]!;
297
+ const runs = (control & 0x01) !== 0;
298
+ if (!runs && control === kernal.control && latch === kernal.latch && counter === kernal.counter) continue;
299
+ touched = true;
300
+ // Stopped, a write of the high latch byte also loads the counter.
301
+ loads.push(lda(control & 0xee), sta(controlAt));
302
+ if (runs) {
303
+ // Timer A counts system cycles without bit 5, timer B without bits 5 and 6; other inputs do not count in the loader.
304
+ const system = timer === "A" ? (control & 0x20) === 0 : (control & 0x60) === 0;
305
+ const slot = running.length;
306
+ running.push({ start: starts.length, counter, latch, system, name, plan });
307
+ loads.push((values) => lda(values[slot]! & 0xff), sta(base + at), (values) => lda(values[slot]! >> 8), sta(base + at + 1));
308
+ starts.push(lda(control), sta(controlAt));
309
+ latches.push(lda(latch & 0xff), sta(base + at), lda(latch >> 8), sta(base + at + 1));
310
+ if (system) plan.counterAtJump = counter + 3;
311
+ continue;
312
+ }
313
+ loads.push(lda(counter & 0xff), sta(base + at), lda(counter >> 8), sta(base + at + 1));
314
+ if (latch === counter) continue;
315
+ if (latch >> 8 === counter >> 8) {
316
+ loads.push(lda(latch & 0xff), sta(base + at));
317
+ continue;
318
+ }
319
+ // The high byte loads the counter of a stopped timer too.
320
+ loads.push(lda(latch & 0xff), sta(base + at), lda(latch >> 8), sta(base + at + 1));
321
+ plan.counterAtJump = latch;
322
+ limits.push(`${name} is stopped with the counter $${counter.toString(16).padStart(4, "0")} and the latch $${latch.toString(16).padStart(4, "0")}: a stopped CIA timer loads its latch, so the counter is the latch.`);
323
+ }
324
+ const mask = chip.interruptMask;
325
+ if (mask !== KERNAL_MASKS[index]) {
326
+ disable.push(lda(0x7f), sta(base + 0x0d));
327
+ if (mask !== 0) masks.push(lda(0x80 | mask), sta(base + 0x0d));
328
+ touched = true;
329
+ }
330
+ if (touched) acks.push({ text: `lda ${hex4(base + 0x0d)}`, bytes: 3, cycles: 4 });
331
+ const flags = chip.registers[0x0d]! & 0x1f;
332
+ if (flags !== 0) limits.push(`CIA ${id} has the interrupt flags $${flags.toString(16).padStart(2, "0")} at the entry: only an event sets a flag, so the loader cannot.`);
333
+ });
334
+ if (loads.length === 0 && disable.length === 0) return { ops: [], plan: { timers, masks: [state.cia[0].interruptMask, state.cia[1].interruptMask], limits } };
335
+ // The cycles from each start write to the entry: what follows it here, and the tail.
336
+ const after = [...starts, ...latches, ...acks, ...masks];
337
+ const values = running.map((item) => {
338
+ const followers = after.slice(item.start + 2).reduce((sum, op) => sum + op.cycles, 0);
339
+ if (!item.system) return item.counter;
340
+ const load = item.counter + followers + TAIL_CYCLES - CIA_START_DELAY;
341
+ if (load <= 0xffff) return load;
342
+ limits.push(`${item.name} counts from $${item.counter.toString(16).padStart(4, "0")} at the entry: the loader cannot start it so near $ffff without an underflow, so the counter differs.`);
343
+ delete item.plan.counterAtJump;
344
+ return item.counter;
345
+ });
346
+ const pre: Op[] = state.vic === undefined ? [lda(0x35), { text: "sta $01", bytes: 2, cycles: 3 }] : [];
347
+ const ops = [...pre, ...disable, ...loads.map((op) => (typeof op === "function" ? op(values) : op)), ...after];
348
+ return { ops, plan: { timers, masks: [state.cia[0].interruptMask, state.cia[1].interruptMask], limits } };
349
+ }
350
+
193
351
  export interface LoaderInput {
194
352
  /** The segments with the files that hold them, in address order. */
195
353
  segments: Array<Range & { files: string[] }>;
@@ -222,9 +380,13 @@ export function planLoader(input: LoaderInput): LoaderPlan {
222
380
  }
223
381
  at += block.range.end - block.range.start + 1;
224
382
  }
225
- if (plain.length > MAX_COPIES) throw new WireFailure("invalid-input", `The loader needs ${plain.length} copies; it can do at most ${MAX_COPIES}. Join segments in the unpacker capture (--range) or write the loader by hand.`);
383
+ const cia = ciaSection(state);
384
+ const ciaSize = cia === undefined ? 0 : cia.ops.reduce((sum, op) => sum + op.bytes, 0);
385
+ // The CIA part takes room from the copy table in the copier's two pages.
386
+ const maxCopies = Math.min(MAX_COPIES, Math.floor((COPIER_LIMIT - COPIER_CODE_SIZE - (state.vic === undefined ? 0 : VIC_SIZE) - ciaSize - 2) / COPY_ENTRY));
387
+ if (plain.length > maxCopies) throw new WireFailure("invalid-input", `The loader needs ${plain.length} copies; it can do at most ${maxCopies}. Join segments in the unpacker capture (--range) or write the loader by hand.`);
226
388
  const copierImage = at;
227
- const copierSize = COPIER_CODE_SIZE + (state.vic === undefined ? 0 : VIC_SIZE) + COPY_ENTRY * plain.length + 2;
389
+ const copierSize = COPIER_CODE_SIZE + (state.vic === undefined ? 0 : VIC_SIZE) + ciaSize + COPY_ENTRY * plain.length + 2;
228
390
  const end = copierImage + copierSize - 1;
229
391
  if (end > LOAD_LIMIT) throw new WireFailure("invalid-input", `The loader would end at ${hex4(end)}, after ${hex4(LOAD_LIMIT)}: loading it would write the I/O area. The image is too large for one loader file.`);
230
392
 
@@ -330,7 +492,9 @@ export function planLoader(input: LoaderInput): LoaderPlan {
330
492
  " tax",
331
493
  " jmp .next",
332
494
  ".done",
333
- ...vicLines(state),
495
+ // The CIA part runs after the raster wait: the wait ends its whole lines earlier, so the entry comes in its line.
496
+ ...vicLines(state, Math.floor((cia?.ops.reduce((sum, op) => sum + op.cycles, 0) ?? 0) / LINE_CYCLES)),
497
+ ...(cia === undefined ? [] : cia.ops.map((op) => ` ${op.text}`)),
334
498
  ` lda #${hex2(port0)}`,
335
499
  " sta $00",
336
500
  ` lda #${hex2(port1)}`,
@@ -371,6 +535,7 @@ export function planLoader(input: LoaderInput): LoaderPlan {
371
535
  stackByte,
372
536
  end,
373
537
  text: `${lines.join("\n")}\n`,
538
+ ...(cia === undefined ? {} : { cia: cia.plan }),
374
539
  };
375
540
  }
376
541
 
@@ -379,9 +544,9 @@ export function planLoader(input: LoaderInput): LoaderPlan {
379
544
  * display registers and the VIC bank of the entry state, then waits for the
380
545
  * entry's raster line, so the program starts in the same phase of the frame.
381
546
  */
382
- function vicLines(state: EntryState): string[] {
547
+ function vicLines(state: EntryState, earlier: number): string[] {
383
548
  if (state.vic === undefined) return [];
384
- const line = rasterLine(state.vic.registers);
549
+ const line = (rasterLine(state.vic.registers) - earlier + PAL_LINES) % PAL_LINES;
385
550
  return [
386
551
  " lda #$35",
387
552
  " sta $01",
@@ -7,7 +7,8 @@
7
7
 
8
8
  import { COVERAGE, formatC64Address } from "#src/c64.ts";
9
9
  import { WireFailure } from "#src/host-client/tools.ts";
10
- import { isGeneratedFile, type CommentPlacement, type RegionType } from "#src/knowledge/schema.ts";
10
+ import { ANALYZERS } from "#src/knowledge/import.ts";
11
+ import { isGeneratedFile, type CommentPlacement, type Origin, type RegionType } from "#src/knowledge/schema.ts";
11
12
  import { hasZeroPageForm, MNEMONICS, MODE_LENGTH, OPCODES, takesAddress, type Mode } from "./opcodes.ts";
12
13
 
13
14
  export interface Range {
@@ -23,7 +24,8 @@ export interface SourceInput {
23
24
  entry?: number;
24
25
  /** Where Ghidra decoded an instruction, with its length. */
25
26
  instructions: ReadonlyMap<number, number>;
26
- symbols: ReadonlyArray<{ address: number; name: string }>;
27
+ /** Without an origin, a symbol counts as a user or LLM name. */
28
+ symbols: ReadonlyArray<{ address: number; name: string; origin?: Origin }>;
27
29
  regions: ReadonlyArray<{ start: number; end: number; type: RegionType; element?: number }>;
28
30
  comments: ReadonlyArray<{ address: number; placement: CommentPlacement; text: string }>;
29
31
  modules: ReadonlyArray<{ start: number; file: string; title?: string }>;
@@ -196,10 +198,12 @@ export function layout(input: SourceInput): Layout {
196
198
  const wanted = new Set<number>();
197
199
  const want = (address: number) => inImage(address) && wanted.add(address);
198
200
  const outside = new Set<number>();
199
- const symbols = new Map(input.symbols.map((symbol) => [symbol.address, symbol.name]));
200
201
  // A platform name that knowledge gives to another address, or that reads as an instruction, is not used.
201
202
  const knowledgeNames = new Set(input.symbols.map((symbol) => symbol.name));
202
203
  const platform = new Map((input.platform ?? []).filter(({ name }) => !knowledgeNames.has(name) && !MNEMONICS.has(name.toLowerCase())).map(({ address, name }) => [address, name]));
204
+ // An analyzer name (DXA's ld020) gives way to the platform name (EXTCOL); a user or LLM name wins (D61).
205
+ const isAnalyzer = (origin: Origin | undefined) => (ANALYZERS as readonly (Origin | undefined)[]).includes(origin);
206
+ const symbols = new Map(input.symbols.filter((symbol) => !(isAnalyzer(symbol.origin) && platform.has(symbol.address))).map((symbol) => [symbol.address, symbol.name]));
203
207
  /** Addresses outside the image without a name of their own, inside a named platform entry of more bytes: the start of that entry (F-20). */
204
208
  const insideEntry = new Map<number, number>();
205
209
  const use = (address: number) => {
@@ -65,7 +65,7 @@ The analyzers take at most 1024 entry points. Knowledge routines and `--entry` c
65
65
  - Ghidra: `functions` gives the number of routines that Ghidra found (`count`) and lists the first routines (`listed`). `decompilations` gives C-like code for the requested routines.
66
66
  - If a list is not complete, `rest` gives the c64-knowledge command that lists all of them. For `candidates`, `rest` tells you to give a larger `--max-listed`.
67
67
  - `conflicts` lists the places where the analyzer and the current knowledge disagree.
68
- - `idioms` tells what the script found by pattern: RTS dispatch tables (`rtsTables`), JMP tables (`jumpTables`) and BIT skips (`bitSkips`). The script writes them as knowledge with the origin `heuristic`, and seeds their targets into the same analysis. `candidates` gives DOP padding (`dop`) and opcodes that the program wrote and then executed (`selfModified`). For `selfModified`, the write and the execution must be in one coverage file. Each list has a `count`. The script does not write the candidates. Look at them and decide.
68
+ - `idioms` tells what the script found by pattern: RTS dispatch tables (`rtsTables`), JMP tables (`jumpTables`) and BIT skips (`bitSkips`). The script writes them as knowledge with the origin `heuristic`, and seeds their targets into the same analysis. `candidates` gives DOP padding (`dop`) and opcodes that the program wrote and executed (`selfModified`). For `selfModified`, the write and the execution must be in one coverage file. A coverage file does not record the order. Thus a candidate can also be code that ran first, and that the program then wrote over. To prevent this, save one coverage file for each part of the program, for example the intro and the game. Clear the memory map between the parts. Each list has a `count`. The script does not write the candidates. Look at them and decide.
69
69
  - With `--coverage`, a code finding replaces the DXA or Ghidra data where a run executed an instruction start. `coverageConflicts` lists the analyzer data regions where the coverage still shows executed code. Classify them as code with the c64-knowledge skill, or as `dual-use` when the bytes are also data.
70
70
  6. Give each important routine a name with the c64-knowledge skill. Then run the analysis again. The analyzers use the new names.
71
71
 
@@ -2,8 +2,8 @@
2
2
  // JMP tables and BIT skips are found by their exact instruction patterns and
3
3
  // become heuristic findings and entry points; DOP padding and self-modified
4
4
  // opcodes are only reported. A self-modified opcode is one that one run wrote
5
- // and executed: a copier's writes in one run and their execution in another
6
- // do not count (F-16). A table's length is not known statically: entries
5
+ // and executed, in either order, since a coverage file holds no order (R3-04):
6
+ // a copier's writes in one run and their execution in another do not count (F-16). A table's length is not known statically: entries
7
7
  // count while each target is a plausible instruction in the program.
8
8
 
9
9
  import { COVERAGE } from "#src/c64.ts";
@@ -73,7 +73,7 @@ An `observe` step has a checkpoint name. A scenario can have each name only one
73
73
  | `vicii` | The VIC-II state: mode, screen address, scroll and colors. |
74
74
  | `sprites` | A list of sprite numbers from 0 to 7. The script compares their position, color and flags. |
75
75
 
76
- A `tolerance` is the largest permitted difference for each byte. A `mask` has at most 64 rectangles. A scenario can observe the screen at most at 64 checkpoints.
76
+ A `tolerance` is the largest permitted difference for each byte. A `mask` has at most 64 rectangles. A `mask` must not cover all of the screen: PAL is 384×272 pixels and NTSC is 384×247 pixels. A scenario can observe the screen at most at 64 checkpoints.
77
77
 
78
78
  `exclude` is a list of bytes in the observed range that the comparison does not include. Each has `at` and a `reason`, for example `{ "at": "$d01e", "reason": "latched sprite collision, not writable" }`. The result lists each excluded byte with its reason.
79
79