@henols/c64-re-tools 0.2.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/package.json +2 -2
  2. package/skills/acme-build/SKILL.md +39 -23
  3. package/skills/acme-build/scripts/acme.mjs +159 -64
  4. package/skills/acme-build/template.a +1 -1
  5. package/skills/c64-disk-access/SKILL.md +156 -0
  6. package/skills/c64-disk-access/scripts/c1541.mjs +569 -0
  7. package/skills/c64-memory-mapping/SKILL.md +30 -23
  8. package/skills/c64-memory-mapping/scripts/driver.mjs +1 -1
  9. package/skills/c64-petcat/SKILL.md +87 -0
  10. package/skills/c64-petcat/scripts/petcat.mjs +221 -0
  11. package/skills/c64-program-recon/SKILL.md +93 -39
  12. package/skills/c64-program-recon/references/control-flow.md +12 -15
  13. package/skills/c64-program-recon/references/graphics.md +1 -1
  14. package/skills/c64-program-recon/references/observation-hazards.md +18 -16
  15. package/skills/c64-program-recon/references/reconstruction.md +1 -2
  16. package/skills/c64-program-recon/references/sound-and-input.md +6 -8
  17. package/skills/c64-program-recon/references/tool-selection.md +36 -17
  18. package/skills/c64-program-recon/scripts/packer-finding.mjs +165 -87
  19. package/skills/c64-program-recon/templates/memory-map.template.md +2 -2
  20. package/skills/c64-provenance-diff/SKILL.md +40 -5
  21. package/skills/c64-provenance-diff/scripts/diff-images.mjs +8 -8
  22. package/skills/c64-provenance-diff/scripts/recovery-schema.mjs +7 -5
  23. package/skills/c64-ram-capture/SKILL.md +112 -44
  24. package/skills/c64-ram-capture/scripts/compare.mjs +2 -2
  25. package/skills/c64-ram-capture/scripts/derive-transients.mjs +575 -0
  26. package/skills/c64-ram-capture/scripts/dump-artifacts.mjs +3 -3
  27. package/skills/c64-ram-capture/scripts/mcp-module.mjs +174 -0
  28. package/skills/c64-ram-capture/scripts/releases.mjs +1 -1
  29. package/skills/c64-ram-capture/scripts/vsf-slice.mjs +147 -0
  30. package/skills/c64-ram-capture/scripts/watch-loads.mjs +15 -15
  31. package/skills/c64-ram-capture/templates/capture-record.template.md +44 -4
  32. package/skills/c64-ram-capture/transients/README.md +136 -0
  33. package/skills/routine-queue-walker/SKILL.md +114 -22
  34. package/skills/routine-queue-walker/scripts/completeness-report.mjs +463 -0
  35. package/skills/vice-wedge-triage/SKILL.md +96 -89
  36. package/skills/c64-ram-capture/scripts/d64-parse.mjs +0 -243
@@ -182,9 +182,9 @@ combined (driver.mjs:262, 268) — so a partially-reachable or partially-changed
182
182
  source set can silently replace good tracked data with less of it. Rebuild, then
183
183
  run `git diff --stat` on `memmap.json` before accepting the result, and
184
184
  `git checkout` the file if the diff is not explainable as the correction you
185
- were expecting. Because it mutates the repo, `memmap` belongs behind a GSD
186
- command (`/gsd-quick`), per this project's GSD Workflow Enforcement rule it is
187
- not a read-only lookup like `lookup` and `annotate`.
185
+ were expecting. **`memmap` mutates tracked files** — unlike `lookup` and
186
+ `annotate`, which are read-only so run it deliberately, on a clean working
187
+ tree, and review the diff before you commit it.
188
188
 
189
189
  ## Feeding the enum generator
190
190
 
@@ -224,7 +224,14 @@ for any other enum.** `anno_create_project_enum` defines the variants and `anno_
224
224
  binds one to the accessing instruction's address — see "Name it" and "Document it" below. That is
225
225
  manual where `gen-enums` was bulk, but it writes exactly the same rows into the store.
226
226
 
227
- ## Classifying every region of an annotation project
227
+ ## Reference material for annotating
228
+
229
+ Two deeper jobs feed the annotate/lookup job above, rather than standing apart from it: knowing
230
+ what a whole region actually is (code, or one of eight kinds of data) before it can be annotated
231
+ at all, and knowing what one of a program's own addresses represents when no published table can
232
+ say. Both exist to serve a documented listing; neither is invoked as a job on its own.
233
+
234
+ ### Classifying every region of an annotation project
228
235
 
229
236
  Everything above answers *what does this published address mean*. This section
230
237
  answers a different question over the same map: **given a loaded binary in an
@@ -243,7 +250,7 @@ entry point and marks it **Code**. Everything else is **Undefined** — not
243
250
  "data", just unexplored. The job here is to walk the Undefined regions, work
244
251
  out what each one actually is, and set it.
245
252
 
246
- ### The one mistake that matters more than the rest
253
+ #### The one mistake that matters more than the rest
247
254
 
248
255
  **Never disassemble a region without concrete proof that it executes.** Random
249
256
  data routinely disassembles into plausible-looking instruction sequences, and
@@ -269,7 +276,7 @@ None of those? Leave it **Undefined**, or classify it as data — even when the
269
276
  bytes disassemble cleanly. "It looked like code" is how a sprite sheet becomes
270
277
  four hundred lines of fiction.
271
278
 
272
- ### The order of the passes
279
+ #### The order of the passes
273
280
 
274
281
  Work the Undefined blocks in four passes, in this order. Do not interleave
275
282
  them; each pass makes the next one cheaper.
@@ -287,7 +294,7 @@ them; each pass makes the next one cheaper.
287
294
  **Never** speculatively disassemble in this pass; by definition nothing here
288
295
  met the proof bar.
289
296
 
290
- ### Scope, and reading a region
297
+ #### Scope, and reading a region
291
298
 
292
299
  1. `anno_get_binary_info` first. Keep `origin`, `size`, `system`, `filename`,
293
300
  `description` and `may_contain_undocumented_opcodes`.
@@ -315,7 +322,7 @@ them; each pass makes the next one cheaper.
315
322
  classification; a 4096-byte hexdump is more than can be read carefully in
316
323
  one pass.
317
324
 
318
- ### Applying the classification
325
+ #### Applying the classification
319
326
 
320
327
  - **Code**: `anno_disassemble` from the entry-point address to READ, then
321
328
  `anno_set_data_type` with `"code"` to RECORD the range you verified.
@@ -357,7 +364,7 @@ then: anno_set_data_type start=2049 end=2303 data_type="code"
357
364
  then: anno_get_blocks max_results=500 # refresh
358
365
  ```
359
366
 
360
- ### The block types
367
+ #### The block types
361
368
 
362
369
  | Block type | `data_type` | When |
363
370
  |---|---|---|
@@ -374,7 +381,7 @@ then: anno_get_blocks max_results=500 # refresh
374
381
  | External file | `external_file` | Large blobs to export as-is: SID tunes, bitmaps, charsets |
375
382
  | Undefined | `undefined` | Reset to unknown. The honest answer for a region you cannot place |
376
383
 
377
- ### Recognising each kind
384
+ #### Recognising each kind
378
385
 
379
386
  **Byte data** — regular patterns that form no valid instruction sequence;
380
387
  addressed by `LDA addr,X` / `LDA addr,Y` table lookups; sprite data in 63-byte
@@ -413,7 +420,7 @@ in multiples of 64, or a bitmap. Export it rather than annotate it.
413
420
  it is passed to CHROUT, it is PETSCII. Getting this backwards produces text
414
421
  that renders as garbage in exactly one of the two places.
415
422
 
416
- ### The adjacent-table limitation, and how it was closed
423
+ #### The adjacent-table limitation, and how it was closed
417
424
 
418
425
  **Dated limitation, recorded 2026-08-24; CLOSED 2026-08-29 when the store
419
426
  changed underneath it.** The old store auto-merged two adjacent regions of the
@@ -440,7 +447,7 @@ So the working rules are now the ordinary ones:
440
447
  - **Do not** carry the old over-merge caveat into a report taken from this
441
448
  store. It was true of the retired one and is not true here.
442
449
 
443
- ### Labelling, and the report
450
+ #### Labelling, and the report
444
451
 
445
452
  Name what you classified — `anno_set_label_name` on entry points, tables and
446
453
  strings — and comment it with `anno_set_comment` (`"line"` above,
@@ -460,7 +467,7 @@ Then report, and mean it:
460
467
  own — so quoting the revision is how the report is pinned to an exact store
461
468
  state rather than to "after the pass".
462
469
 
463
- ### What goes wrong
470
+ #### What goes wrong
464
471
 
465
472
  | Symptom | What it actually is |
466
473
  |---|---|
@@ -471,7 +478,7 @@ Then report, and mean it:
471
478
  | Two tables you classified separately show up as one block | The adjacent-table limitation above. Not your error. |
472
479
  | Text renders as garbage on screen but fine through CHROUT | It is PETSCII, typed as screencode — or the reverse. |
473
480
 
474
- ## What a symbol in the store actually represents
481
+ ### What a symbol in the store actually represents
475
482
 
476
483
  `lookup` at the top of this page answers what a **published** address means —
477
484
  a hardware register, a KERNAL entry point, an OS variable. That answer comes
@@ -483,7 +490,7 @@ When `lookup` returns a region-only answer — the dominant case for a game's ow
483
490
  code and variables, as noted above — this is the procedure that gets you a
484
491
  name.
485
492
 
486
- ### 1. Target and context
493
+ #### 1. Target and context
487
494
 
488
495
  - **Always start from an explicit address**, `$XXXX` or its decimal
489
496
  equivalent. There is no editor cursor in this project's route, and upstream's
@@ -497,7 +504,7 @@ name.
497
504
  remember that `LAX`, `SAX` and `DCP` have real read/write side effects that
498
505
  belong in the data-flow picture.
499
506
 
500
- ### 2. Gather the usage
507
+ #### 2. Gather the usage
501
508
 
502
509
  `anno_get_cross_references` on the address — naming the `store`, the `image` and
503
510
  a REQUIRED `max_results` — returns everywhere it is touched. Read the
@@ -519,7 +526,7 @@ order of likelihood:
519
526
  3. It is genuinely **dead**: unused variable, or code no longer reached. Say so
520
527
  in the report rather than inventing a purpose.
521
528
 
522
- ### 3. Place it
529
+ #### 3. Place it
523
530
 
524
531
  **A hardware register?** `lookup` it. If one of the four tables names it, take
525
532
  the published name and the per-bit breakdown with it — that reading rests on
@@ -557,7 +564,7 @@ define one (`0 = INIT`, `1 = TITLE`, `2 = GAMEPLAY`, `3 = GAME_OVER`) with a
557
564
  real `description` if none matches, then apply it to every instruction reading
558
565
  or writing the variable.
559
566
 
560
- ### 4. Name it
567
+ #### 4. Name it
561
568
 
562
569
  | Symbol kind | Convention | Example |
563
570
  |---|---|---|
@@ -578,7 +585,7 @@ or writing the variable.
578
585
 
579
586
  Apply it with `anno_set_label_name`.
580
587
 
581
- ### 5. Document it
588
+ #### 5. Document it
582
589
 
583
590
  - `anno_set_comment` `"line"` at the definition: the range it occupies, its
584
591
  purpose, its bitfield layout if it has one.
@@ -590,15 +597,15 @@ Apply it with `anno_set_label_name`.
590
597
  initialised by immediate loads of a target's low and high bytes — `LDA #<target
591
598
  / STA ptr / LDA #>target / STA ptr+1` — upstream calls `set_immediate_format`
592
599
  twice to turn both immediates into a single readable symbol reference. **That
593
- call is not exposed on this project's surface.** `BUILD-03` ("every branch,
594
- `JSR`/`JMP` and data reference goes through a symbol, so code can move") is the
595
- requirement that supplies its criterion, and the per-call disposition is
600
+ call is not exposed on this project's surface.** The rule it would serve — "every branch,
601
+ `JSR`/`JMP` and data reference goes through a symbol, so code can move" still
602
+ supplies the criterion, and the per-call disposition is
596
603
  recorded in the manifest named in the attribution header above. Until then,
597
604
  reconstruct the target by hand and put it in a side comment on both
598
605
  instructions — `; low byte of ptr_sprite_table ($C240)` — so the pointer is
599
606
  still readable even though the store cannot format it.
600
607
 
601
- ### 6. Report
608
+ #### 6. Report
602
609
 
603
610
  - **Address** and its current label.
604
611
  - **Classification**: flag, counter, pointer, hardware register, state
@@ -21,7 +21,7 @@ import { dirname, join, resolve } from "node:path";
21
21
 
22
22
  const HERE = dirname(fileURLToPath(import.meta.url));
23
23
  // memmap.json lives at the skill root, one level up from scripts/, by decision
24
- // (D-03): only .mjs modules move into scripts/, data files stay put.
24
+ // only .mjs modules move into scripts/, data files stay put.
25
25
  const MEMMAP_JSON = join(HERE, "..", "memmap.json");
26
26
 
27
27
  // Reference tables, merged into one address -> meaning index. `kind` selects the
@@ -0,0 +1,87 @@
1
+ ---
2
+ name: c64-petcat
3
+ description: Convert a Commodore .prg between PETSCII and ASCII, detokenize a BASIC program into readable text, and read the machine-code handover address out of its startup line, using VICE's own petcat conversion tool as the reference implementation. Use when asked to detokenize a BASIC listing, list what a BASIC stub does, find where a program hands over to machine code, or convert C64 text between PETSCII and ASCII.
4
+ ---
5
+
6
+ # Detokenizing BASIC with petcat
7
+
8
+ One capability, one script, one binary (`petcat`) reached only through the
9
+ host-tool execution seam:
10
+
11
+ ```bash
12
+ S=src/skills/c64-petcat/scripts/petcat.mjs # from the repo root
13
+
14
+ node $S decode --image path/to/program.prg # detokenize + resolve the SYS handover
15
+ ```
16
+
17
+ The script wraps `petcat` and nothing else. `--image` is required; `--out-dir`
18
+ is optional, defaulting to the image's own directory, exactly like
19
+ `acme-build`'s own `--out-dir` default. Both are resolved
20
+ **workspace-relative** to the smallest ancestor directory containing both,
21
+ before the request ever reaches the seam — the same resolution
22
+ `acme-build`/`c64-disk-access` already go through.
23
+
24
+ Options: `--image PATH` `--out-dir DIR` `--json`.
25
+
26
+ ## Detokenizing and resolving the handover point
27
+
28
+ ```bash
29
+ node $S decode --image game.prg --json
30
+ ```
31
+
32
+ Prints the seam's response verbatim as one line of JSON:
33
+
34
+ ```json
35
+ {"ok":true,"tool":"petcat.decode","exitStatus":0,"results":[{"path":"/abs/path/game.bas.txt","sha256":"...","byteLength":21}],"stderrTail":"","entrypoint":2064,"entrypointReason":"literal SYS argument on BASIC line 10: sys2064"}
36
+ ```
37
+
38
+ `results[0].path` names the detokenized listing file the seam wrote —
39
+ `petcat`'s own decoded text, captured and digested. The listing itself never
40
+ crosses inline, no matter how short it is.
41
+
42
+ Two fields carry the handover verdict, and BOTH are present on every
43
+ `ok: true` response:
44
+
45
+ - **`entrypoint`** — a number when the program's `SYS` argument is a literal
46
+ decimal value, `null` otherwise.
47
+ - **`entrypointReason`** — always a string. When `entrypoint` is a number, it
48
+ names the BASIC line the value came from. When `entrypoint` is `null`, it
49
+ quotes the unresolved expression verbatim, or states that the listing
50
+ carries no handover instruction at all.
51
+
52
+ A `null` entrypoint is **not a failure** — it is a resolved "no": the tool
53
+ detokenized the program correctly and correctly concluded the entry point is
54
+ not a static address. Reported this way, never as a guessed address: a
55
+ guessed entry point is expensive downstream, spent on a disassembler that
56
+ then has nothing real to work from.
57
+
58
+ Non-JSON mode prints the same information as two lines — the resolved entry
59
+ point (or the decline and its reason) and the listing file's path — never the
60
+ listing's contents inline.
61
+
62
+ ## The BASIC dialect
63
+
64
+ Fixed server-side, not a flag on this script or a field on the wire — this
65
+ project's target is fixed to C64 BASIC V2.0 everywhere already, the same
66
+ posture `acme-build` already takes for its own assembler target.
67
+
68
+ ## Failure shape
69
+
70
+ A file `petcat` does not recognise as a BASIC program at all — including a
71
+ missing file — is reported as `{"ok":false,"message":"..."}` with a
72
+ non-zero exit code, never a success envelope carrying an empty or guessed
73
+ verdict. `petcat` itself exits `0` even on garbage input; the seam's own
74
+ classifier, not the exit code, is what decides success here.
75
+
76
+ ## What this skill does NOT do
77
+
78
+ - **No direct binary spawn.** `petcat` runs host-side; this script only ever
79
+ constructs a typed request and reads the produced listing file back off
80
+ the shared workspace tree — the host-tool execution seam is the only
81
+ route.
82
+ - **No guessed entry point.** A computed or otherwise unresolvable `SYS`
83
+ argument is always reported as a named decline with `entrypoint: null` —
84
+ never a fallback value, never an inline listing scan for "something that
85
+ looks like an address".
86
+ - **No emulator dependency.** This skill names no VICE emulator tool at all
87
+ — it works entirely on files, never on a running machine, by construction.
@@ -0,0 +1,221 @@
1
+ #!/usr/bin/env node
2
+ // petcat -> BASIC detokenization + SYS-handover driver. One capability:
3
+ // decode a BASIC program's stub to readable text and resolve the numeric
4
+ // address it hands over to machine code, when that address is a literal.
5
+ //
6
+ // Reached ONLY through the host-tool execution seam -- the project owner's
7
+ // rule of 2026-08-28 is that this script runs container-side, `petcat` lives
8
+ // host-side, and there is no container PATH to find it on. This file never spawns `petcat` itself; it constructs a
9
+ // TYPED request (`petcat.decode`) and reads the produced listing file back
10
+ // off the shared workspace tree, mirroring
11
+ // src/skills/c64-disk-access/scripts/c1541.mjs's own invokeSeam() shape
12
+ // verbatim.
13
+ //
14
+ // WHAT NOT TO DO:
15
+ // - Never spawn the conversion binary (`petcat`) directly from this
16
+ // script, even as a "just this once" fallback. A direct call works on
17
+ // the developer's own host and silently fails inside a container -- the
18
+ // exact failure this seam exists to remove.
19
+ // - Never guess an entry point when the seam reports a null one. A
20
+ // guessed address is spent on a disassembler downstream, and a wrong
21
+ // one is expensive there -- report the decline and its reason exactly
22
+ // as the seam gave them, never a fallback value.
23
+ import { dirname, relative, isAbsolute, resolve, sep } from "node:path";
24
+ import { fileURLToPath } from "node:url";
25
+ import { spawn } from "node:child_process";
26
+
27
+ import { resolveMcpModule, refusalMessage } from "../../c64-ram-capture/scripts/mcp-module.mjs";
28
+
29
+ const SELF = fileURLToPath(import.meta.url);
30
+ const HERE = dirname(SELF);
31
+
32
+ /** The MCP-side module this script reaches -- never imported statically
33
+ * (cross-package: this file ships in `@henols/c64-re-tools`, the seam client
34
+ * ships in `@henols/vice-mcp`), only located via the ladder and invoked with
35
+ * `process.execPath`, the interpreter already running this script, on an
36
+ * in-tree module -- not an external host binary. */
37
+ const HOST_TOOL_CLIENT_FILE = "host-tool-client.ts";
38
+
39
+ /**
40
+ * Invokes the host-tool execution seam for `tool`/`args`, rooted at
41
+ * `repoRoot` for THIS invocation's workspace-relative path resolution.
42
+ * Never rejects: a resolution failure, a spawn failure, or unparseable
43
+ * output all resolve to `{ ok: false, message }` -- the same shape a tool's
44
+ * own refusal uses, so a caller never needs a try/catch. Copied verbatim
45
+ * from acme.mjs's own invokeSeam() -- see this file's own header for why a
46
+ * shared import is not possible across the two npm packages.
47
+ */
48
+ function invokeSeam(tool, args, repoRoot) {
49
+ return new Promise((resolvePromise) => {
50
+ const resolved = resolveMcpModule(HOST_TOOL_CLIENT_FILE);
51
+ if (!resolved.ok) {
52
+ resolvePromise({ ok: false, message: refusalMessage(HOST_TOOL_CLIENT_FILE, resolved.rungs) });
53
+ return;
54
+ }
55
+
56
+ const cliArgs = [resolved.path, "run", "--tool", tool, "--args", JSON.stringify(args), "--repo-root", repoRoot];
57
+ let child;
58
+ try {
59
+ child = spawn(process.execPath, cliArgs, { stdio: ["ignore", "pipe", "pipe"] });
60
+ } catch (e) {
61
+ resolvePromise({ ok: false, message: e instanceof Error ? e.message : String(e) });
62
+ return;
63
+ }
64
+
65
+ let stdout = "";
66
+ let stderr = "";
67
+ child.stdout.on("data", (chunk) => { stdout += chunk.toString("utf8"); });
68
+ child.stderr.on("data", (chunk) => { stderr += chunk.toString("utf8"); });
69
+ child.on("error", (err) => resolvePromise({ ok: false, message: err.message }));
70
+ child.on("close", () => {
71
+ const lines = stdout.split("\n").filter((line) => line.trim() !== "");
72
+ const last = lines[lines.length - 1];
73
+ if (last === undefined) {
74
+ resolvePromise({ ok: false, message: `host-tool-client.ts produced no output${stderr ? ` (stderr: ${stderr})` : ""}` });
75
+ return;
76
+ }
77
+ try {
78
+ resolvePromise(JSON.parse(last));
79
+ } catch {
80
+ resolvePromise({ ok: false, message: `host-tool-client.ts produced non-JSON output: ${last}` });
81
+ }
82
+ });
83
+ });
84
+ }
85
+
86
+ /** The smallest common ancestor directory of two absolute paths -- computed,
87
+ * never a fixed guess, so the request's `--repo-root` for THIS invocation is
88
+ * always exactly big enough to contain both the image and the output
89
+ * directory, and no bigger. Copied verbatim from acme.mjs's own
90
+ * commonAncestorDir() -- see this file's own header for why a shared import
91
+ * is not possible. */
92
+ function commonAncestorDir(a, b) {
93
+ const partsA = resolve(a).split(sep);
94
+ const partsB = resolve(b).split(sep);
95
+ const common = [];
96
+ for (let i = 0; i < Math.min(partsA.length, partsB.length); i++) {
97
+ if (partsA[i] === partsB[i]) common.push(partsA[i]);
98
+ else break;
99
+ }
100
+ const joined = common.join(sep);
101
+ return joined === "" ? sep : joined;
102
+ }
103
+
104
+ /** `path.relative()`, except the "same directory" case yields `"."` rather
105
+ * than `""` -- the seam's `resolveWorkspacePath()` refuses an empty string,
106
+ * but accepts `"."` as a no-op relative reference to its own root. */
107
+ function toRel(root, abs) {
108
+ const r = relative(root, abs);
109
+ return r === "" ? "." : r;
110
+ }
111
+
112
+ // How to refer to this script in hints, from wherever we were run.
113
+ function selfPath() {
114
+ const r = relative(process.cwd(), SELF);
115
+ return !r || r.startsWith("..") || isAbsolute(r) ? SELF : r;
116
+ }
117
+
118
+ const die = (m) => { console.error(`error: ${m}`); process.exit(1); };
119
+
120
+ // ------------------------------------------------------------- capabilities
121
+
122
+ /**
123
+ * Detokenizes `--image` (required) to readable BASIC text and resolves its
124
+ * `SYS` handover point, when it has one. `--out-dir` is optional, defaulting
125
+ * to the seam's own dirname(image) default exactly as acme.build's own
126
+ * outDir default does. Prints the seam's response verbatim as one line of
127
+ * JSON when `--json` is given -- the response IS the reportable shape
128
+ * (`{ ok, tool, exitStatus, results, stderrTail, entrypoint,
129
+ * entrypointReason }` / `{ ok: false, message }`), so no reshaping happens
130
+ * here.
131
+ */
132
+ async function runDecode(argv) {
133
+ const o = parseOpts(argv);
134
+ if (!o.image) die(`usage: decode --image <path.prg> [--out-dir <dir>] [--json]`);
135
+
136
+ const imageAbs = resolve(o.image);
137
+ const outDirAbs = o.outDir ? resolve(o.outDir) : dirname(imageAbs);
138
+
139
+ // Workspace-relative request construction (mirrors acme.mjs's own build()):
140
+ // the root for THIS invocation is the smallest ancestor containing both
141
+ // the image and the output directory.
142
+ const repoRoot = commonAncestorDir(dirname(imageAbs), outDirAbs);
143
+ const args = { image: toRel(repoRoot, imageAbs) };
144
+ if (o.outDir) args.outDir = toRel(repoRoot, outDirAbs);
145
+
146
+ const response = await invokeSeam("petcat.decode", args, repoRoot);
147
+ report(response, o);
148
+ process.exit(response.ok ? 0 : 1);
149
+ }
150
+
151
+ function report(response, { json }) {
152
+ if (json) {
153
+ console.log(JSON.stringify(response));
154
+ return;
155
+ }
156
+ if (!response.ok) {
157
+ console.error(`petcat call FAILED: ${response.message}`);
158
+ return;
159
+ }
160
+ // WHAT NOT TO DO (this file's own header): never guess an entry point --
161
+ // print exactly what the seam reported, decline included.
162
+ if (response.entrypoint !== null) {
163
+ console.log(`entry point: ${response.entrypoint} (${response.entrypointReason})`);
164
+ } else {
165
+ console.log(`entry point not resolved: ${response.entrypointReason}`);
166
+ }
167
+ for (const r of response.results ?? []) {
168
+ console.log(`${r.path} (${r.byteLength} bytes, sha256 ${r.sha256})`);
169
+ }
170
+ }
171
+
172
+ // ------------------------------------------------------------------ options
173
+
174
+ // Exported so petcat.test.mjs can cover this file's
175
+ // own CLI-option parsing as a pure unit, mirroring c1541.mjs's own exported
176
+ // parsers -- never spawns anything, never needs petcat installed.
177
+ export function parseOpts(argv) {
178
+ const o = { json: false };
179
+ for (let i = 0; i < argv.length; i++) {
180
+ const a = argv[i];
181
+ if (a === "--json") o.json = true;
182
+ else if (a === "--image") o.image = argv[++i];
183
+ else if (a === "--out-dir") o.outDir = argv[++i];
184
+ }
185
+ return o;
186
+ }
187
+
188
+ // --------------------------------------------------------------------- main
189
+ //
190
+ // The CLI dispatch below MUST be guarded to run only
191
+ // when this file is the actual entry point, not merely imported -- mirrors
192
+ // c1541.mjs's own entry-point guard verbatim (the "Rule 3
193
+ // fix, discovered mid-execution"), added there after an unguarded dispatch
194
+ // ran with the TEST RUNNER's own process.argv on every import of
195
+ // c1541.test.mjs, printing the usage banner and calling process.exit(0)
196
+ // before a single test() call ever registered. Nothing imports petcat.mjs as
197
+ // a module today (confirmed by grep across src/ and scripts/), so this was
198
+ // latent rather than live here -- but the next petcat.test.mjs that imports
199
+ // a pure helper from this file would reintroduce the exact bug c1541.mjs
200
+ // already found and fixed once. Same shape
201
+ // (`resolve(process.argv[1]) === fileURLToPath(import.meta.url)`), never a
202
+ // second guard shape invented for this sibling script.
203
+
204
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
205
+ const [cmd, ...rest] = process.argv.slice(2);
206
+ const VERBS = {
207
+ decode: (argv) => runDecode(argv),
208
+ };
209
+ if (!cmd || !VERBS[cmd]) {
210
+ console.log(`usage: node ${selfPath()} <command> [options]
211
+
212
+ decode --image <path.prg> [--out-dir <dir>] [--json] detokenize a BASIC program and resolve its SYS handover point
213
+
214
+ Never invokes petcat directly and never guesses an entry point -- a computed
215
+ SYS argument is reported as a named decline, never an address.
216
+
217
+ options: --image PATH --out-dir DIR --json`);
218
+ process.exit(cmd ? 1 : 0);
219
+ }
220
+ await VERBS[cmd](rest);
221
+ }