@henols/c64-re-tools 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,7 +18,7 @@ usage, not measured). Individual rows that have since been exercised live are ma
18
18
  | Whole-chip SID state without the read hazards | `vice_sid_get_state` (**requires the fork** — SID `$D400-$D418` is write-only in hardware and the binary monitor has no SID command; unrecoverable on stock) |
19
19
  | Decode sprite data | `vice_sprite_get` / `vice_sprite_inspect` (**both backends**) |
20
20
  | Find a known byte pattern | `vice_memory_search` (**both backends**) |
21
- | Carry labels across sessions | `vice_symbols_load` / `vice_symbols_lookup` (**both backends**) — ACME `--vicelabels` and regenerator2000 output share this channel |
21
+ | Carry labels across sessions | `vice_symbols_load` / `vice_symbols_lookup` (**both backends**) — ACME `--vicelabels` emits the format they consume. The annotation store's own export into that format is **withdrawn as of 2026-08-29, and no phase currently owns its return** — an earlier forecast naming a numbered phase for it is superseded |
22
22
  | Is the machine wedged, or did it stop itself? | `vice_diagnose` — five-state verdict with its evidence (the two backends' verdict sets differ by one; see `docs/stock-vice-parity.md` D-03). **Reachable and proxy-intercepted as of 2026-08-04** (verified live). Triage tree: `vice-wedge-triage` |
23
23
  | Replace a wedged instance | `vice_recycle` — destructive, requires a `reason`, and that reason is written into `.planning/incidents/` **before** anything is killed. The reason *is* the evidence record |
24
24
  | Read the restart epoch | **No tool does.** The proxy compares it around every forwarded call and raises drift itself; a value comes from that error or from `vice_diagnose` |
@@ -30,7 +30,7 @@ usage, not measured). Individual rows that have since been exercised live are ma
30
30
  | What does address X mean? | the `c64-memory-mapping` skill — `node … lookup '$D018'`. **Do not restate its tables.** |
31
31
  | Is this byte original or cracker-changed? | the `c64-provenance-diff` skill |
32
32
  | A verified 64K image, or comparing two captures | the `c64-ram-capture` skill |
33
- | Traced disassembly with code/data separation | regenerator2000, via `vice-mcp r2000 export-asm`a recursive-descent disassembler with an auto-analyzer; verified live: its `--verify` run reassembled byte-identically through a real ACME for both a `.prg` and a flat 64K image (`.planning/phases/10-adoption-boundaries-automated-bootstrap-and-the-removal/evidence/10-verify-transcript.txt`) |
33
+ | Whole-program static disassembly with code/data separation | **`anno export-asm`**withdrawn 2026-08-29, returned 2026-08-31, settled by assembling the output with a real ACME and diffing the bytes against the input. That oracle is test-only, so the verb itself writes source and runs no assembler. For a single routine, read one explicit range at a time with `anno_read_region` / `anno_disassemble` (4096-byte cap per call, refused rather than truncated above it) and record what you verified with `anno_set_data_type` |
34
34
 
35
35
  ## Three traps in this table
36
36
 
@@ -0,0 +1,631 @@
1
+ #!/usr/bin/env node
2
+ // packer-finding.mjs -- the ONE place this project answers "which packer was
3
+ // used on this binary", as a project-owned recon finding with an ordered
4
+ // oracle chain and a hard, reasoned unknown (SURF-03).
5
+ //
6
+ // ---------------------------------------------------------------------------
7
+ // WHY THIS FILE EXISTS
8
+ // ---------------------------------------------------------------------------
9
+ // DATED PROVENANCE (2026-08-29): the retired static analyser this project once
10
+ // rented -- the external analyser, pinned at 0.9.20 -- computed packer identity on
11
+ // EVERY load and then threw it away before it reached any machine-readable
12
+ // surface. That analyser is GONE from this repository; nothing below calls it,
13
+ // and this paragraph is history in the past tense, not a route. It is kept
14
+ // because it is the whole reason this file exists: without it, the obvious
15
+ // next step is to go looking for the packer-name call that was already proven
16
+ // not to be there. At the pin, that absence was established four independent
17
+ // ways, each read at the pin:
18
+ //
19
+ // (a) the curated binary-info tool emits a fixed seven-field object
20
+ // (origin, size, system, filename, description, an illegal-opcode hint
21
+ // and entropy) built from a static literal -- a packed input cannot
22
+ // change the field set;
23
+ // (b) the unpack result carries no name field at all: it returns data,
24
+ // start/end addresses, an entry point, a dependency address and an
25
+ // instruction count, and the one place the detected packer is consulted
26
+ // applies a memory patch and feeds a progress callback the MCP handler
27
+ // passes nothing to;
28
+ // (c) the loaded-project value that DOES hold a detected packer name is an
29
+ // in-memory return value only -- it is absent from the serialised
30
+ // project shape, so saving and re-reading the project file yields
31
+ // nothing;
32
+ // (d) every consumer of the name lives in the terminal-UI crate. There are
33
+ // zero non-UI, non-internal consumers, and the command line has no flag
34
+ // that reports file info.
35
+ //
36
+ // So there was no read-only route to a packer name in that analyser, and there
37
+ // is none on the surface that replaced it either: the annotation store holds
38
+ // annotations, the derived reads decode instructions, and neither answers
39
+ // "which packer". The two routes that would produce a name -- copying that
40
+ // upstream project's signature table (or transcribing its bytes into search
41
+ // patterns), and inventing a tool name on this project's own surface that no
42
+ // verb actually implements -- are both refused: the first is the copy this
43
+ // milestone exists to avoid, and the second would be a fabricated tool that
44
+ // this repository's own honesty gates would then treat as legitimate. What is
45
+ // left, and what this file is, is a project-owned finding with an EXTERNAL
46
+ // oracle and an explicit unknown.
47
+ //
48
+ // ---------------------------------------------------------------------------
49
+ // WHAT THIS IS THE ONE AUTHORITATIVE PLACE FOR
50
+ // ---------------------------------------------------------------------------
51
+ // - the ordered oracle chain (external identifier, then a packedness-only
52
+ // entropy gate, then an explicit unknown) and its first-hit-wins rule;
53
+ // - the finding's response shape and its four-verdict vocabulary
54
+ // (`PACKER_VERDICTS`);
55
+ // - the external identifier's probe (`probeUnp64`) and the defensive parser
56
+ // for its standard output (`parseUnp64Stdout`).
57
+ //
58
+ // ---------------------------------------------------------------------------
59
+ // WHAT NOT TO DO -- the four rules, each enforced structurally below
60
+ // ---------------------------------------------------------------------------
61
+ // 1. NEVER write the `packer` field from anything but an oracle's own
62
+ // verbatim output. Not from entropy, not from a decompression or
63
+ // dependency address, not from a byte pattern. There is exactly ONE
64
+ // assignment of that field in this module, inside `identifiedByOracle()`
65
+ // below, and it refuses a value the oracle did not supply.
66
+ // 2. NEVER return the high confidence level off the oracle route. The
67
+ // entropy route is MEDIUM and answers packedness only; the no-route case
68
+ // is LOW and answers `unknown`. `CONFIDENCE_HIGH` is ASSIGNED at exactly
69
+ // one site in this file, in the oracle branch, and the colocated test
70
+ // counts that site at source level.
71
+ // 3. NEVER return the `unknown` verdict with an empty reason. A silent null
72
+ // with nothing said about it is the failure mode this whole file exists
73
+ // to prevent, so `unknownFinding()` throws rather than build one. More
74
+ // generally: every finding whose `packer` is null states, in
75
+ // `unavailableReason`, why no name is being reported.
76
+ // 4. NEVER add vocabulary beyond the four verdicts. No fractional rating, no
77
+ // ratio, no hedged phrasing, no named-but-unconfirmed packer. A reader
78
+ // must get either a name an oracle stated or an explicit unknown, and
79
+ // nothing in between.
80
+ //
81
+ // Also: NEVER invoke the oracle through a command interpreter. Every child
82
+ // process below is launched with an argument ARRAY and an explicitly disabled
83
+ // interpreter, and a configured oracle path that does not exist on disk is
84
+ // treated as oracle-absent rather than being placed into any command anywhere
85
+ // (T-19-18). The oracle's standard output reaches exactly one field, through
86
+ // one bounded parser that evaluates nothing (T-19-19).
87
+ //
88
+ // ---------------------------------------------------------------------------
89
+ // STATUS OF THE ORACLE BRANCH (recorded, deliberate)
90
+ // ---------------------------------------------------------------------------
91
+ // The env-var convention below (`UNP64`, then `UNP64_PATH`) is the same one
92
+ // upstream's own comparison harness uses, and is verified. The SHAPE of the
93
+ // identifier's standard output is NOT verified here -- the tool was not
94
+ // installed on the machine where this was written, so `parseUnp64Stdout()` is
95
+ // written defensively and its accepted marker set is a stated assumption, not
96
+ // a measurement. Installing the identifier and running it against a genuinely
97
+ // packed fixture is the experiment that would settle it; until then the
98
+ // oracle-route test SKIPS with a visible reason and never reads as a pass.
99
+ import { spawnSync } from "node:child_process";
100
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
101
+ import { tmpdir } from "node:os";
102
+ import { join } from "node:path";
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // Vocabulary. Exactly four verdicts, frozen. Rule 4.
106
+ // ---------------------------------------------------------------------------
107
+
108
+ /**
109
+ * The complete verdict vocabulary. Nothing else may ever appear in a
110
+ * finding's `verdict` field.
111
+ *
112
+ * identified -- an oracle stated a name, verbatim.
113
+ * packed-unidentified -- the entropy gate places the bytes at or above its
114
+ * packedness threshold, and NO oracle named the
115
+ * packer. Packedness, never identity.
116
+ * unpacked -- the entropy gate puts the bytes below its
117
+ * threshold. Still not an identity claim.
118
+ * unknown -- no route produced an answer. Always carries a
119
+ * reason.
120
+ */
121
+ export const PACKER_VERDICTS = Object.freeze(["identified", "packed-unidentified", "unpacked", "unknown"]);
122
+
123
+ const VERDICT_IDENTIFIED = "identified";
124
+ const VERDICT_PACKED_UNIDENTIFIED = "packed-unidentified";
125
+ const VERDICT_UNPACKED = "unpacked";
126
+ const VERDICT_UNKNOWN = "unknown";
127
+
128
+ const CONFIDENCE_HIGH = "HIGH";
129
+ const CONFIDENCE_MEDIUM = "MEDIUM";
130
+ const CONFIDENCE_LOW = "LOW";
131
+
132
+ const ROUTE_ORACLE = "unp64";
133
+ const ROUTE_ENTROPY = "entropy-only";
134
+ const ROUTE_NONE = "none";
135
+
136
+ /**
137
+ * The packedness threshold, taken from the curated binary-info tool's own
138
+ * description ("values higher than 7.5 suggest the binary might be
139
+ * compressed") rather than restated from a second source. Shannon entropy
140
+ * over bytes runs 0.0 to 8.0.
141
+ *
142
+ * This number gates PACKEDNESS ONLY. It can never reach the `packer` field --
143
+ * see rule 1.
144
+ */
145
+ export const PACKED_ENTROPY_THRESHOLD = 7.5;
146
+
147
+ /** Default command name when neither environment variable is set. */
148
+ const DEFAULT_ORACLE_COMMAND = "unp64";
149
+
150
+ /** The two environment variables the external identifier is located from, in
151
+ * this order -- the same convention upstream's own comparison harness uses. */
152
+ const ORACLE_ENV_VARS = Object.freeze(["UNP64", "UNP64_PATH"]);
153
+
154
+ /** The opt-in variable that turns an absent oracle from an expected skip into
155
+ * a hard failure, by the established `VICE_REQUIRE_*` precedent. */
156
+ export const REQUIRE_ORACLE_ENV_VAR = "VICE_REQUIRE_UNP64";
157
+
158
+ /** Every child process here is bounded. A hung identifier is treated exactly
159
+ * like an absent one (T-19-23). */
160
+ const ORACLE_TIMEOUT_MS = 20_000;
161
+
162
+ /** Hard cap on how much of the oracle's standard output the parser will even
163
+ * look at. Longer than this is rejected outright rather than scanned
164
+ * (T-19-19). */
165
+ export const MAX_ORACLE_STDOUT_BYTES = 64 * 1024;
166
+
167
+ /** Hard cap on the length of a parsed packer name. */
168
+ export const MAX_PACKER_NAME_LENGTH = 64;
169
+
170
+ /**
171
+ * The accepted shape of a parsed name: begins alphanumeric, then a small,
172
+ * explicitly listed printable set. Deliberately narrow -- the parsed value's
173
+ * only destination is a report field, and narrowing it here means a hostile
174
+ * standard output cannot smuggle control characters or markup into a document
175
+ * a human later reads.
176
+ */
177
+ const PACKER_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9 ._/+-]*$/;
178
+
179
+ /**
180
+ * Line markers the parser accepts, each anchored at the start of a trimmed
181
+ * line and each followed by the name. ASSUMED, not measured -- see the module
182
+ * header's status note. An unrecognised line yields null, never a guess drawn
183
+ * from the surrounding text.
184
+ */
185
+ const ORACLE_NAME_MARKERS = Object.freeze([/^packer\s*:\s*(.+)$/i, /^detected\s+packer\s*:\s*(.+)$/i, /^detected\s*:\s*(.+)$/i]);
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // Entropy -- packedness only
189
+ // ---------------------------------------------------------------------------
190
+
191
+ /**
192
+ * Shannon entropy over a byte buffer, 0.0 to 8.0 -- the same quantity the
193
+ * curated binary-info tool reports, computed here so the command-line entry
194
+ * can answer from a bare file with no live session.
195
+ *
196
+ * Returns null for an empty buffer: zero bytes is an absent measurement, not
197
+ * a measurement of zero (a zero would read as "definitely not compressed",
198
+ * which is a claim nobody made).
199
+ */
200
+ export function shannonEntropy(bytes) {
201
+ if (!bytes || bytes.length === 0) return null;
202
+ const counts = new Array(256).fill(0);
203
+ for (const b of bytes) counts[b & 0xff]++;
204
+ let total = 0;
205
+ for (const count of counts) {
206
+ if (count === 0) continue;
207
+ const p = count / bytes.length;
208
+ total -= p * Math.log2(p);
209
+ }
210
+ return total;
211
+ }
212
+
213
+ // ---------------------------------------------------------------------------
214
+ // The oracle: probe, run, parse. All three read-only.
215
+ // ---------------------------------------------------------------------------
216
+
217
+ /**
218
+ * Locates and probes the external packer identifier, WITHOUT touching any
219
+ * input file.
220
+ *
221
+ * Resolution order: the two environment variables, then the bare command name
222
+ * on the search path. A CONFIGURED path that does not exist on disk is
223
+ * reported as absent and is deliberately not echoed back in the reason
224
+ * (T-19-18: an attacker-influenceable value is never placed into a command,
225
+ * and not into a message a later step might paste into one either).
226
+ *
227
+ * Never throws. A launch error, a non-zero status or a timeout are all
228
+ * "absent", never a failure -- absence of the oracle is an expected state.
229
+ */
230
+ export function probeUnp64(env = process.env) {
231
+ const source = env ?? {};
232
+ let configuredVar = null;
233
+ let configured = null;
234
+ for (const name of ORACLE_ENV_VARS) {
235
+ const value = source[name];
236
+ if (typeof value === "string" && value.trim() !== "") {
237
+ configuredVar = name;
238
+ configured = value.trim();
239
+ break;
240
+ }
241
+ }
242
+
243
+ if (configured !== null && !existsSync(configured)) {
244
+ return {
245
+ available: false,
246
+ command: null,
247
+ version: null,
248
+ reason:
249
+ `the packer identifier configured through ${configuredVar} does not exist on disk -- ` +
250
+ "treated as oracle-absent, and the configured value was not placed into any command",
251
+ };
252
+ }
253
+
254
+ const command = configured ?? DEFAULT_ORACLE_COMMAND;
255
+ const probe = spawnSync(command, ["--version"], {
256
+ encoding: "utf8",
257
+ timeout: ORACLE_TIMEOUT_MS,
258
+ shell: false,
259
+ windowsHide: true,
260
+ });
261
+
262
+ if (probe.error) {
263
+ return {
264
+ available: false,
265
+ command: null,
266
+ version: null,
267
+ reason:
268
+ configuredVar === null
269
+ ? `no "${DEFAULT_ORACLE_COMMAND}" packer identifier was found on the search path`
270
+ : `the packer identifier configured through ${configuredVar} could not be launched`,
271
+ };
272
+ }
273
+
274
+ const banner = `${probe.stdout ?? ""}${probe.stderr ?? ""}`.trim();
275
+ if (banner === "") {
276
+ return {
277
+ available: false,
278
+ command: null,
279
+ version: null,
280
+ reason: "the packer identifier produced no version banner, so it was not accepted as an oracle",
281
+ };
282
+ }
283
+
284
+ return { available: true, command, version: banner.slice(0, 200), reason: null };
285
+ }
286
+
287
+ /**
288
+ * Runs the located identifier against `filePath`, read-only.
289
+ *
290
+ * The input file is never modified: any unpacked output the identifier writes
291
+ * goes to a scratch path under the system temporary directory, which is
292
+ * removed before this function returns (T-19-24). The child is launched with
293
+ * an argument ARRAY and an explicitly disabled command interpreter, so neither
294
+ * the configured command nor the caller's filename is ever parsed as a
295
+ * command (T-19-18).
296
+ *
297
+ * Never throws: every failure is reported as `{ ok: false, reason }`.
298
+ */
299
+ export function runUnp64(probe, filePath) {
300
+ if (!probe || probe.available !== true || typeof probe.command !== "string") {
301
+ return { ok: false, stdout: "", reason: "the oracle was not available" };
302
+ }
303
+ if (typeof filePath !== "string" || filePath === "") {
304
+ return { ok: false, stdout: "", reason: "no input file was given to the oracle" };
305
+ }
306
+ if (!existsSync(filePath)) {
307
+ return { ok: false, stdout: "", reason: "the input file does not exist" };
308
+ }
309
+
310
+ let scratch = null;
311
+ try {
312
+ scratch = mkdtempSync(join(tmpdir(), "packer-finding-"));
313
+ const scratchOut = join(scratch, "unpacked.out");
314
+ const run = spawnSync(probe.command, [filePath, scratchOut], {
315
+ encoding: "utf8",
316
+ timeout: ORACLE_TIMEOUT_MS,
317
+ shell: false,
318
+ windowsHide: true,
319
+ maxBuffer: MAX_ORACLE_STDOUT_BYTES,
320
+ });
321
+ if (run.error) {
322
+ return { ok: false, stdout: "", reason: "the oracle could not be run against the input file" };
323
+ }
324
+ return { ok: true, stdout: `${run.stdout ?? ""}`, reason: null };
325
+ } catch {
326
+ // A scratch directory that could not be created is an absent oracle, not
327
+ // an error a recon pass should stop for.
328
+ return { ok: false, stdout: "", reason: "a scratch directory for the oracle's output could not be created" };
329
+ } finally {
330
+ if (scratch !== null) {
331
+ try {
332
+ rmSync(scratch, { recursive: true, force: true });
333
+ } catch {
334
+ // Best effort. A leftover empty scratch directory is not worth
335
+ // failing a read-only recon finding over.
336
+ }
337
+ }
338
+ }
339
+ }
340
+
341
+ /**
342
+ * Parses a packer name out of the oracle's standard output.
343
+ *
344
+ * Defensive by construction (T-19-19): an explicit byte cap before anything
345
+ * is scanned, no evaluation of any kind, a narrow accepted character set, and
346
+ * a length cap on the result. Empty, truncated, over-long and unrecognised
347
+ * input all return null WITHOUT throwing -- a parser that throws inside a
348
+ * recon pass would turn an unhelpful oracle into a stopped session.
349
+ *
350
+ * The returned value's only destination is the finding's `packer` field. It
351
+ * never reaches a command, a path, or any executable position.
352
+ */
353
+ export function parseUnp64Stdout(text) {
354
+ if (typeof text !== "string") return null;
355
+ if (text.length === 0) return null;
356
+ if (text.length > MAX_ORACLE_STDOUT_BYTES) return null;
357
+
358
+ for (const rawLine of text.split(/\r?\n/)) {
359
+ const line = rawLine.trim();
360
+ if (line === "") continue;
361
+ for (const marker of ORACLE_NAME_MARKERS) {
362
+ const matched = line.match(marker);
363
+ if (!matched) continue;
364
+ const candidate = (matched[1] ?? "").trim();
365
+ if (candidate === "") return null;
366
+ if (candidate.length > MAX_PACKER_NAME_LENGTH) return null;
367
+ if (!PACKER_NAME_RE.test(candidate)) return null;
368
+ return candidate;
369
+ }
370
+ }
371
+ return null;
372
+ }
373
+
374
+ // ---------------------------------------------------------------------------
375
+ // Finding constructors. Each route has exactly one, and only the oracle's
376
+ // constructor may set `packer`.
377
+ // ---------------------------------------------------------------------------
378
+
379
+ /**
380
+ * THE ONLY PLACE IN THIS MODULE THAT ASSIGNS A PACKER NAME (rule 1), and the
381
+ * only place the high confidence level is written (rule 2). It refuses to
382
+ * build a finding from anything but a non-empty string the oracle's own
383
+ * output supplied.
384
+ */
385
+ function identifiedByOracle(name, evidence, checkedAt) {
386
+ if (typeof name !== "string" || name.trim() === "") {
387
+ throw new Error("identifiedByOracle: refusing to report a packer name the oracle did not state");
388
+ }
389
+ return Object.freeze({
390
+ packer: name,
391
+ verdict: VERDICT_IDENTIFIED,
392
+ confidence: CONFIDENCE_HIGH,
393
+ route: ROUTE_ORACLE,
394
+ evidence: Object.freeze(evidence.slice()),
395
+ checkedAt,
396
+ unavailableReason: null,
397
+ });
398
+ }
399
+
400
+ /** The entropy route. Answers PACKEDNESS and nothing else -- `packer` is
401
+ * written as the literal null here, and the entropy value has no path to it
402
+ * (rule 1). Never the high confidence level (rule 2). */
403
+ function fromEntropy(entropy, threshold, evidence, checkedAt) {
404
+ const packed = entropy >= threshold;
405
+ return Object.freeze({
406
+ packer: null,
407
+ verdict: packed ? VERDICT_PACKED_UNIDENTIFIED : VERDICT_UNPACKED,
408
+ confidence: CONFIDENCE_MEDIUM,
409
+ route: ROUTE_ENTROPY,
410
+ evidence: Object.freeze(evidence.slice()),
411
+ checkedAt,
412
+ unavailableReason: packed
413
+ ? "the entropy gate answers packedness only -- no oracle named the packer, and this project never " +
414
+ "infers a name from entropy, from a decompression address, or from a byte pattern"
415
+ : "no name is claimed: the entropy gate places these bytes below its packedness threshold, which is " +
416
+ "a statement about compression and not about identity",
417
+ });
418
+ }
419
+
420
+ /** The no-route case. Rule 3 is enforced here: an `unknown` verdict with an
421
+ * empty reason cannot be constructed at all. */
422
+ function unknownFinding(reason, evidence, checkedAt) {
423
+ if (typeof reason !== "string" || reason.trim() === "") {
424
+ throw new Error("unknownFinding: refusing to return an unknown verdict with no reason (rule 3)");
425
+ }
426
+ return Object.freeze({
427
+ packer: null,
428
+ verdict: VERDICT_UNKNOWN,
429
+ confidence: CONFIDENCE_LOW,
430
+ route: ROUTE_NONE,
431
+ evidence: Object.freeze(evidence.slice()),
432
+ checkedAt,
433
+ unavailableReason: reason,
434
+ });
435
+ }
436
+
437
+ // ---------------------------------------------------------------------------
438
+ // The finding
439
+ // ---------------------------------------------------------------------------
440
+
441
+ /**
442
+ * Produces the packer recon finding for one binary, walking the ordered oracle
443
+ * chain: the external identifier first (first hit wins for the name), then the
444
+ * packedness-only entropy gate, then an explicit unknown.
445
+ *
446
+ * Options:
447
+ * filePath -- the binary to ask the oracle about. Required for the oracle
448
+ * route; the entropy route does not need it when `entropy` is
449
+ * supplied directly.
450
+ * entropy -- a measured entropy value (for example the one the curated
451
+ * binary-info tool reports). When absent and `bytes` is given,
452
+ * it is computed locally; the evidence entry records WHICH.
453
+ * bytes -- the binary's bytes, for the local entropy computation.
454
+ * threshold -- overrides the packedness threshold. The default is the one
455
+ * the curated tool's own description states.
456
+ * probe -- injectable oracle probe, so a test can force the oracle
457
+ * absent without uninstalling anything.
458
+ * run -- injectable oracle runner, same reason.
459
+ * now -- injectable clock, so a test can pin `checkedAt`.
460
+ */
461
+ export function packerFinding(options = {}) {
462
+ const {
463
+ filePath = null,
464
+ entropy = null,
465
+ bytes = null,
466
+ threshold = PACKED_ENTROPY_THRESHOLD,
467
+ env = process.env,
468
+ probe = probeUnp64,
469
+ run = runUnp64,
470
+ now = () => new Date().toISOString(),
471
+ } = options;
472
+
473
+ const checkedAt = now();
474
+ const evidence = [];
475
+
476
+ // --- Route 1: the external oracle. The ONLY route that can name a packer.
477
+ const probed = probe(env);
478
+ evidence.push(
479
+ Object.freeze({
480
+ source: ROUTE_ORACLE,
481
+ available: probed.available === true,
482
+ version: probed.available === true ? probed.version : null,
483
+ reason: probed.available === true ? null : (probed.reason ?? "the oracle was not available"),
484
+ }),
485
+ );
486
+
487
+ if (probed.available === true && typeof filePath === "string" && filePath !== "") {
488
+ const result = run(probed, filePath);
489
+ const raw = typeof result.stdout === "string" ? result.stdout.slice(0, MAX_PACKER_NAME_LENGTH * 8) : "";
490
+ evidence.push(Object.freeze({ source: ROUTE_ORACLE, ok: result.ok === true, raw, reason: result.reason ?? null }));
491
+ if (result.ok === true) {
492
+ const name = parseUnp64Stdout(result.stdout);
493
+ if (name !== null) {
494
+ return identifiedByOracle(name, evidence, checkedAt);
495
+ }
496
+ // The oracle ran and named nothing. That is a real answer about the
497
+ // oracle, not about the packer -- fall through to the entropy gate
498
+ // rather than inventing a name from what it did print.
499
+ }
500
+ }
501
+
502
+ // --- Route 2: the entropy gate. Packedness only, never a name.
503
+ let measured = null;
504
+ let entropySource = null;
505
+ if (typeof entropy === "number" && Number.isFinite(entropy)) {
506
+ measured = entropy;
507
+ // THE RECORDED PROVENANCE VALUE, DECIDED 2026-08-29 -- not string-replaced.
508
+ // Until this date this field carried the retired static analyser's
509
+ // binary-info verb, in that verb's own tool-name shape. Carrying that
510
+ // shape forward under ANY spelling is wrong twice over. It is a FACT
511
+ // ABOUT A PAST RUN: renaming it to a verb on the current surface would
512
+ // claim a run that never happened. And this branch cannot know who
513
+ // produced the number in the first place -- `--entropy` is
514
+ // caller-supplied, and the caller may equally have measured it, read it
515
+ // from a derived binary-info read, or copied it out of a report. So the
516
+ // value now names the CHANNEL, which this code can actually observe,
517
+ // rather than guessing a producer; the historical producer is recorded
518
+ // above in prose, with no token an extractor could mistake for a live
519
+ // route.
520
+ entropySource = "caller-supplied";
521
+ } else if (bytes && bytes.length > 0) {
522
+ measured = shannonEntropy(bytes);
523
+ entropySource = "local-shannon-entropy";
524
+ }
525
+
526
+ if (measured !== null) {
527
+ evidence.push(Object.freeze({ source: entropySource, entropy: measured, threshold }));
528
+ return fromEntropy(measured, threshold, evidence, checkedAt);
529
+ }
530
+
531
+ // --- Route 3: the explicit unknown, always with a reason.
532
+ return unknownFinding(
533
+ "no packer-identity route was available: this project's own tool and command-line surfaces expose no " +
534
+ "packer name, the external oracle was absent, and no entropy measurement was supplied, so packedness " +
535
+ "could not be established either",
536
+ evidence,
537
+ checkedAt,
538
+ );
539
+ }
540
+
541
+ // ---------------------------------------------------------------------------
542
+ // The live gate. Absence of the oracle is an EXPECTED SKIP by default and a
543
+ // hard FAIL under the opt-in variable -- never a pass either way.
544
+ // ---------------------------------------------------------------------------
545
+
546
+ /**
547
+ * Returns a non-empty skip reason naming the absent oracle, or `false` when a
548
+ * real one is available -- meant to be handed straight to a test runner's
549
+ * `{ skip }` option so the skip is VISIBLE in the report rather than a test
550
+ * that quietly returns early and reads as a pass.
551
+ */
552
+ export function skipReasonForUnp64(probed) {
553
+ const result = probed ?? probeUnp64();
554
+ if (result.available === true) return false;
555
+ return (
556
+ `the oracle-route tests are skipped -- no external packer identifier was found (${result.reason ?? "reason not recorded"}). ` +
557
+ `Point ${ORACLE_ENV_VARS.join(" or ")} at one, or install "${DEFAULT_ORACLE_COMMAND}". ` +
558
+ `An absent oracle is an EXPECTED SKIP here, never a pass: set ${REQUIRE_ORACLE_ENV_VAR} to turn it into a hard failure.`
559
+ );
560
+ }
561
+
562
+ // ---------------------------------------------------------------------------
563
+ // Command-line entry: one file in, one JSON object out.
564
+ // ---------------------------------------------------------------------------
565
+
566
+ const USAGE = `usage: node src/skills/c64-program-recon/scripts/packer-finding.mjs <file> [--entropy N]
567
+
568
+ Prints ONE JSON object: the packer recon finding for <file>.
569
+
570
+ packer the packer name, or null. Non-null ONLY when an external
571
+ oracle stated it verbatim. This project never guesses one.
572
+ verdict one of: ${PACKER_VERDICTS.join(", ")}
573
+ confidence HIGH (oracle route only), MEDIUM (entropy route), LOW
574
+ route ${ROUTE_ORACLE}, ${ROUTE_ENTROPY}, or ${ROUTE_NONE}
575
+ evidence what each step actually saw
576
+ checkedAt when this was answered
577
+ unavailableReason why no name is being reported, whenever there is none
578
+
579
+ --entropy overrides the locally computed value with one you already have (the
580
+ curated binary-info tool reports it). The entropy gate answers PACKEDNESS and
581
+ never identity.`;
582
+
583
+ function readFlag(argv, name) {
584
+ const index = argv.indexOf(`--${name}`);
585
+ if (index === -1) return undefined;
586
+ const value = argv[index + 1];
587
+ if (value === undefined || value.startsWith("--")) return undefined;
588
+ return value;
589
+ }
590
+
591
+ function main(argv) {
592
+ const positional = [];
593
+ for (let i = 0; i < argv.length; i++) {
594
+ const token = argv[i];
595
+ if (token === "--entropy") {
596
+ i++;
597
+ continue;
598
+ }
599
+ if (token.startsWith("--")) continue;
600
+ positional.push(token);
601
+ }
602
+
603
+ const filePath = positional[0];
604
+ if (filePath === undefined || argv.includes("--help") || argv.includes("-h")) {
605
+ console.log(USAGE);
606
+ process.exit(filePath === undefined && !argv.includes("--help") && !argv.includes("-h") ? 2 : 0);
607
+ }
608
+
609
+ let bytes = null;
610
+ try {
611
+ bytes = readFileSync(filePath);
612
+ } catch (err) {
613
+ console.error(`packer-finding: could not read ${filePath}: ${err instanceof Error ? err.message : String(err)}`);
614
+ process.exit(1);
615
+ }
616
+
617
+ const entropyRaw = readFlag(argv, "entropy");
618
+ const entropy = entropyRaw === undefined ? null : Number.parseFloat(entropyRaw);
619
+ if (entropyRaw !== undefined && !Number.isFinite(entropy)) {
620
+ console.error(`packer-finding: --entropy must be a number, got "${entropyRaw}"`);
621
+ process.exit(1);
622
+ }
623
+
624
+ const finding = packerFinding({ filePath, bytes, ...(entropy === null ? {} : { entropy }) });
625
+ console.log(JSON.stringify(finding, null, 2));
626
+ }
627
+
628
+ // Run only when invoked directly, never when imported by the colocated test.
629
+ if (process.argv[1] && process.argv[1].endsWith("packer-finding.mjs")) {
630
+ main(process.argv.slice(2));
631
+ }