@henols/vice-mcp 0.2.2 → 0.2.4

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 (84) hide show
  1. package/README.md +2 -2
  2. package/THIRD-PARTY-NOTICES.md +422 -1
  3. package/anno-bank.ts +171 -0
  4. package/anno-cli.ts +1736 -163
  5. package/anno-confidence.ts +2 -2
  6. package/anno-derive.ts +6 -6
  7. package/anno-details.ts +4 -4
  8. package/anno-enum-gen.ts +416 -30
  9. package/anno-export-asm.ts +1211 -126
  10. package/anno-graphics.ts +338 -0
  11. package/anno-hazard-report.ts +1367 -0
  12. package/anno-import.ts +495 -0
  13. package/anno-index.ts +8 -8
  14. package/anno-join.ts +480 -0
  15. package/anno-memmap-render.ts +22 -21
  16. package/anno-provenance-ledger.ts +472 -0
  17. package/anno-regbits-gen.ts +13 -13
  18. package/anno-register.ts +159 -0
  19. package/anno-store-export.ts +661 -0
  20. package/anno-store.ts +635 -124
  21. package/anno-symbols.ts +7 -7
  22. package/anno-tools.ts +1169 -16
  23. package/anno-types.ts +313 -40
  24. package/backend-detect.mts +124 -312
  25. package/build.ts +3 -1
  26. package/capture-predicate.ts +597 -0
  27. package/channel-lock.ts +349 -0
  28. package/evid-ingest.ts +217 -0
  29. package/evid-reconcile.ts +316 -0
  30. package/host-tool-client.ts +430 -0
  31. package/incident-record.ts +23 -12
  32. package/install-resources.ts +29 -13
  33. package/memmap-lookup.ts +285 -0
  34. package/package.json +27 -8
  35. package/prg-image.ts +1 -2
  36. package/repo-root.ts +87 -3
  37. package/resources/backend-detect.mjs +98 -236
  38. package/resources/broker-control.mjs +220 -54
  39. package/resources/broker-epoch.mjs +7 -8
  40. package/resources/broker-kill.mjs +36 -31
  41. package/resources/broker-launch.mjs +511 -374
  42. package/resources/broker-state.mjs +69 -24
  43. package/resources/container-guard.mjs +1 -1
  44. package/resources/ghidra-project.mjs +790 -0
  45. package/resources/host-tool.mjs +2533 -0
  46. package/resources/vice-broker.mjs +434 -290
  47. package/resources/vice-launcher.sh +127 -9
  48. package/stock-address.ts +1 -1
  49. package/stock-condition.ts +1 -1
  50. package/stock-connect.ts +9 -5
  51. package/stock-derived.ts +29 -37
  52. package/stock-diagnose.ts +200 -36
  53. package/stock-dispatch.ts +179 -77
  54. package/stock-handler.ts +1 -1
  55. package/stock-paths.ts +18 -14
  56. package/stock-petscii.ts +1 -1
  57. package/stock-protocol.ts +1 -1
  58. package/stock-recycle.ts +83 -2
  59. package/stock-reproducible-run.ts +811 -0
  60. package/stock-run-until.ts +100 -1
  61. package/stock-symbols.ts +4 -4
  62. package/stock-timing.ts +1 -1
  63. package/stop-oracle.ts +167 -0
  64. package/text-capability-probe.ts +660 -0
  65. package/text-connect.ts +157 -0
  66. package/text-protocol.ts +810 -0
  67. package/text-tools.ts +778 -0
  68. package/textmon-backtrace.ts +385 -0
  69. package/textmon-cpuhistory.ts +335 -0
  70. package/textmon-memmap.ts +494 -0
  71. package/textmon-profile.ts +458 -0
  72. package/textmon-registers.ts +748 -0
  73. package/tools-manifest.stock.json +864 -3
  74. package/vice-broker-client.ts +253 -108
  75. package/vice-errors.ts +268 -0
  76. package/vice-proxy.ts +339 -2144
  77. package/vsf-slice.ts +640 -0
  78. package/anno-d64.ts +0 -310
  79. package/capability-registry.ts +0 -390
  80. package/refresh-manifest.ts +0 -124
  81. package/tools-manifest.json +0 -1223
  82. package/vice-probe.ts +0 -278
  83. package/vice-sync.ts +0 -336
  84. package/vice.ts +0 -772
@@ -0,0 +1,1367 @@
1
+ #!/usr/bin/env node
2
+ // anno-hazard-report.ts
3
+ //
4
+ // The ONE place a movement-hazard finding is derived. A "hazard" here is a
5
+ // static construction that blocks a piece of code or data from being moved,
6
+ // relocated, rebased or stripped without breaking the program -- a runtime
7
+ // self-modification landing on a hardcoded address is the first and, for
8
+ // now, only construction this module recognises; later work adds siblings
9
+ // under the same shape.
10
+ //
11
+ // WHAT THIS MODULE DOES NOT DO, STATED ONCE AND PLAINLY: it enumerates. It
12
+ // never removes, strips, drops, relocates or rebases anything, and it never
13
+ // emits an instruction, flag or field a caller could act on as an automatic
14
+ // relocation. It reports; the reader decides what happens to the bytes it
15
+ // describes.
16
+ //
17
+ // THE MOST IMPORTANT SENTENCE IN THIS FILE: a finding proves a hazard
18
+ // exists. The absence of a finding proves NOTHING. A region this report
19
+ // never flags may still be exactly as hazardous as one it does -- it may
20
+ // simply be a construction none of the detectors below know how to see. No
21
+ // field, count or rendered line derived from this module's answer may ever
22
+ // be read as a safety certificate, and `HAZARD_LIMITS` below exists
23
+ // specifically to keep saying so next to every answer this module gives.
24
+ //
25
+ // ---------------------------------------------------------------------------
26
+ // PURE BY DESIGN -- every input arrives as an already-fetched argument
27
+ // ---------------------------------------------------------------------------
28
+ // `buildHazardReport()` takes one plain-data object and returns one plain-data
29
+ // object. It calls the decoder and nothing else that reaches outside its own
30
+ // arguments: no store handle, no filesystem path, no child-process spawn, no
31
+ // transport socket, and no host/container path translation. The caller (the
32
+ // MCP tool dispatch arm and the CLI command function) does every fetch --
33
+ // ranges, labels, comments, cross-references, execution observations, and the
34
+ // raw image bytes -- and hands this module plain arrays. That split is the
35
+ // same one the observed-execution reconciler already ships, and it is what
36
+ // makes a single, structural test possible: read this file's own source text
37
+ // off disk and assert that none of the calls that would turn a report into a
38
+ // store mutation ever appear in it.
39
+ //
40
+ // ---------------------------------------------------------------------------
41
+ // A SEPARATE, SMALL DETECTION-STRENGTH VOCABULARY -- WHY, NOT JUST WHAT
42
+ // ---------------------------------------------------------------------------
43
+ // This codebase already has a five-grade confidence vocabulary, and its own
44
+ // module states plainly that nothing may define a second one. That rule is
45
+ // about ONE axis: what does this ADDRESS classify as, code or data, spelled
46
+ // as a short bracketed token inside a rendered line comment. This module
47
+ // answers a DIFFERENT question about a DIFFERENT thing: given a specific
48
+ // static signal already found, how strong is the evidence for THAT signal --
49
+ // never rendered as a bracketed comment prefix, and never keyed by address in
50
+ // a second sidecar store. One vocabulary cannot honestly answer both
51
+ // questions at once without a reader having to guess which axis a token on
52
+ // the page is even talking about, so this module declares its own three
53
+ // tokens below, spelled so they can never be confused with the other
54
+ // vocabulary's five, and records that departure here rather than leaving it
55
+ // to be rediscovered as an unexplained duplicate.
56
+ //
57
+ // ---------------------------------------------------------------------------
58
+ // WHAT NOT TO DO -- each of these is a specific, named trap
59
+ // ---------------------------------------------------------------------------
60
+ // 1. NEVER add the synchronous file-write call, the store-open entry
61
+ // point, the cross-reference write entry point, or the live-session
62
+ // module to this file's import list or its body. A report that could
63
+ // write would stop being safe to run twice concurrently and would turn
64
+ // an interrupted call into a corrupted project. This module's own test
65
+ // file reads this file's source text and fails the moment any of those
66
+ // appears -- by concept, not by a copy-pasted identifier, which is
67
+ // exactly why this header never spells one out.
68
+ // 2. NEVER import the host-path or container-path translation modules.
69
+ // This module never resolves anything outside the bytes it was handed.
70
+ // 3. NEVER return a boolean verdict, and never name a field `clean`,
71
+ // `dirty`, `safe` or `ok`. Region outcomes are a closed three-member
72
+ // set (`hazard-reported`, `no-signal`, `unclassified`) precisely so a
73
+ // caller cannot collapse them into a single yes/no.
74
+ // 4. NEVER let an execution observation remove, downgrade or silence a
75
+ // finding. An observation only ever RAISES a finding's strength; no
76
+ // observation, or no observations supplied at all, changes nothing
77
+ // about whether a finding exists.
78
+ // 5. NEVER merge two findings that differ in hazard class or mechanism
79
+ // even when they share an address. The de-duplication key is the whole
80
+ // triple -- class, anchor address, mechanism -- never the address
81
+ // alone.
82
+ // 6. NEVER throw on an empty or single-element input. A store with no
83
+ // ranges is a legal, answerable question: zero denominator, empty
84
+ // findings, empty regions -- never an exception and never a report that
85
+ // reads as "no hazards found."
86
+ // 7. NEVER re-derive the indexed-dispatch scanner already owned elsewhere
87
+ // in this tree. A second implementation of that scan anywhere in this
88
+ // phase is a defect, not a convenience.
89
+ //
90
+ // ---------------------------------------------------------------------------
91
+ // WHAT THIS PLAN'S SLICE ACTUALLY DETECTS
92
+ // ---------------------------------------------------------------------------
93
+ // One class only: a store, or a read-modify-write instruction (increment,
94
+ // decrement, shift or rotate), whose LITERAL target address -- the address
95
+ // encoded directly in the instruction, for a mode where that address does
96
+ // not depend on a runtime register or a runtime-computed pointer -- lands
97
+ // inside another decoded instruction's own byte range. Landing on that other
98
+ // instruction's first byte is a control-flow hazard (the opcode changes);
99
+ // landing on any later byte is a value hazard (an operand changes). Both are
100
+ // reported as the same hazard class under two different mechanism strings.
101
+ //
102
+ // A store through a zero-page pointer computed at runtime (indirect or
103
+ // indirect-indexed addressing) has no literal target for this detector to
104
+ // test -- only a pointer whose value is a runtime fact this module was never
105
+ // handed. That construction is a genuine miss, not an oversight, and it is
106
+ // named as a limit below rather than silently absent from the
107
+ // answer.
108
+
109
+ import { decode, type Instruction } from "./disasm-decoder.ts";
110
+ import { blockClassAt, type BlockEntry } from "./block-class.ts";
111
+ import type { LabelRow, CommentRow, XrefRow, EvidExecRow } from "./anno-types.ts";
112
+ import { scanIndirectDispatch, type IndirectDispatchScan, type SplitTableFinding } from "./anno-coverage.ts";
113
+ import {
114
+ deriveGraphicsRanges,
115
+ BANK_SELECT_ADDRESS,
116
+ MEMORY_CONTROL_ADDRESS,
117
+ CONTROL_REGISTER_1_ADDRESS,
118
+ SPRITE_POINTER_OFFSET,
119
+ type GraphicsConstWriteFact,
120
+ type GraphicsRange,
121
+ } from "./anno-graphics.ts";
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // The hazard-class vocabulary
125
+ // ---------------------------------------------------------------------------
126
+
127
+ /**
128
+ * The full, frozen set of hazard classes this report family will eventually
129
+ * cover. Declared in full here even though this plan's slice only ever
130
+ * populates `self-modifying-code` findings -- the union type and the
131
+ * ordering it fixes are the stable surface later work builds against.
132
+ */
133
+ export const HAZARD_CLASSES = Object.freeze([
134
+ "indexed-dispatch",
135
+ "self-modifying-code",
136
+ "page-alignment",
137
+ "cycle-exact-raster",
138
+ ] as const);
139
+
140
+ /** One of the four hazard classes, in `HAZARD_CLASSES`'s own declared order. */
141
+ export type HazardClass = (typeof HAZARD_CLASSES)[number];
142
+
143
+ /**
144
+ * The three detection-strength tokens. Answers "how sure is this
145
+ * ONE static signal", never "what does this address classify as" -- a
146
+ * different axis from this codebase's five-grade confidence vocabulary, and
147
+ * never spelled as that vocabulary's bracket-prefix form.
148
+ */
149
+ export const HAZARD_DETECTION_STRENGTHS = Object.freeze([
150
+ "observed-corroborated",
151
+ "static-shape-matched",
152
+ "static-signature-only",
153
+ ] as const);
154
+
155
+ /** One of the three detection-strength tokens. */
156
+ export type HazardDetectionStrength = (typeof HAZARD_DETECTION_STRENGTHS)[number];
157
+
158
+ /**
159
+ * The three region dispositions. No boolean anywhere: a region is
160
+ * one of exactly these three, never a "clean"/"dirty" pair.
161
+ */
162
+ export const HAZARD_REGION_OUTCOMES = Object.freeze(["hazard-reported", "no-signal", "unclassified"] as const);
163
+
164
+ /** One of the three region outcomes. */
165
+ export type HazardRegionOutcome = (typeof HAZARD_REGION_OUTCOMES)[number];
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // The answer shape
169
+ // ---------------------------------------------------------------------------
170
+
171
+ /**
172
+ * One hazard finding. `anchorAddress` is the address of the instruction
173
+ * whose bytes get overwritten -- the code that cannot be relocated without
174
+ * breaking the construction, and the address an execution observation is
175
+ * matched against for `strength` promotion: if THAT code was
176
+ * observed actually running, the modification it plants is corroborated as
177
+ * a real runtime event rather than a static coincidence. `blockedAddress` is
178
+ * the exact byte the write lands on -- equal to `anchorAddress` for an
179
+ * opcode-byte hit, or a later byte within the same instruction for an
180
+ * operand-byte hit.
181
+ */
182
+ export interface HazardFinding {
183
+ hazardClass: HazardClass;
184
+ anchorAddress: number;
185
+ /** Nullable: some future hazard class may know a hazard exists without
186
+ * being able to name a single blocked byte. Always populated for the
187
+ * self-modifying-code class this plan's slice reports. */
188
+ blockedAddress: number | null;
189
+ /** A stable, lowercase-hyphenated identifier for how this finding was
190
+ * derived, e.g. `store-target-in-instruction-opcode-byte`. */
191
+ mechanism: string;
192
+ /**
193
+ * THE STRENGTH ASYMMETRY, STATED ONCE FOR THE WHOLE MODULE: an execution
194
+ * observation can only ever RAISE this token, never establish a finding on
195
+ * its own and never lower or remove one. The static signal is what raised
196
+ * the finding in the first place -- a run that happened to execute the
197
+ * anchored code corroborates that the construction is live, but a run that
198
+ * did NOT happen to execute it is not evidence about that address at all,
199
+ * only evidence that this particular run took a different path. Every
200
+ * detector below that ever promotes this field (the self-modifying-code
201
+ * and cycle-exact-raster detectors) checks the SAME direction only; none
202
+ * checks whether an address was absent from the observations to decide
203
+ * anything.
204
+ */
205
+ strength: HazardDetectionStrength;
206
+ /** Prose naming what breaks if the anchored code or data is moved. */
207
+ detail: string;
208
+ corroboration: "runtime-observed" | "none";
209
+ }
210
+
211
+ /**
212
+ * One region's disposition against every detector this report ran.
213
+ * `reason` is REQUIRED when `outcome` is `"unclassified"` -- it names which
214
+ * of the causes applied -- and is otherwise omitted.
215
+ */
216
+ export interface HazardRegionDisposition {
217
+ start: number;
218
+ endInclusive: number;
219
+ outcome: HazardRegionOutcome;
220
+ reason?: string;
221
+ }
222
+
223
+ /**
224
+ * One named, always-emitted limit -- what this report cannot determine, and
225
+ * what a reader must therefore not conclude from its absence. `hazardClass`
226
+ * is `null` for a limit that spans every class rather than one in
227
+ * particular.
228
+ */
229
+ export interface HazardLimit {
230
+ hazardClass: HazardClass | null;
231
+ limit: string;
232
+ consequence: string;
233
+ }
234
+
235
+ /**
236
+ * The two limits seeded here; later work adds the
237
+ * remaining entries -- this array only ever grows.
238
+ */
239
+ export const HAZARD_LIMITS: readonly HazardLimit[] = Object.freeze([
240
+ {
241
+ hazardClass: "indexed-dispatch",
242
+ limit:
243
+ "the imported scanner's promotion gate accepts exactly two evidence " +
244
+ "shapes by design -- the stack-return (RTS-trick) idiom and a " +
245
+ "zero-page vector actually jumped through -- so a real-world " +
246
+ "computed-dispatch construction outside those two shapes is reported " +
247
+ "as an unproven candidate, never as a finding.",
248
+ consequence:
249
+ "an empty findings list for this class is never a claim that the " +
250
+ "program contains no computed dispatch; a declined candidate still " +
251
+ "appears in this report's unprovenDispatchCandidates collection, " +
252
+ "which this field's own absence would otherwise silently hide.",
253
+ },
254
+ {
255
+ hazardClass: "self-modifying-code",
256
+ limit:
257
+ "a store through a runtime-computed zero-page pointer -- indirect or " +
258
+ "indirect-indexed addressing -- into the code range is not detected. " +
259
+ "The static decoder has no literal target address to test in that " +
260
+ "case, only a pointer whose value is a fact about the running " +
261
+ "machine, not about the bytes on disk.",
262
+ consequence:
263
+ "an indirect-indexed self-modification into this program's code is " +
264
+ "invisible to this report; its absence from the findings below is not " +
265
+ "evidence that no such construction exists.",
266
+ },
267
+ {
268
+ hazardClass: "page-alignment",
269
+ limit:
270
+ "only VIC-II HARDWARE alignment boundaries are evaluated -- the " +
271
+ "sprite pointer's 64-byte granularity and the character-set select " +
272
+ "bits' 2048-byte granularity. A code or table alignment chosen so an " +
273
+ "indexed access never crosses a 256-byte page -- which changes " +
274
+ "INSTRUCTION TIMING, not which bytes the hardware reads -- is a " +
275
+ "separate, real hazard this report does not evaluate at all.",
276
+ consequence:
277
+ "a program relying on page-crossing timing stability must be checked " +
278
+ "for that separately; this report's silence on it is not a claim " +
279
+ "that no such dependency exists.",
280
+ },
281
+ {
282
+ hazardClass: "page-alignment",
283
+ limit:
284
+ "a sprite-pointer value this detector could not resolve to a literal " +
285
+ "at analysis time -- because it was computed at runtime rather than " +
286
+ "loaded as an immediate -- is reported as a dependency with an " +
287
+ "unknown target, never omitted.",
288
+ consequence:
289
+ "the absent blocked address on a sprite-pointer-computed-value " +
290
+ "finding is not a claim that the store is safe to move past; it " +
291
+ "means the target could not be named, not that none exists.",
292
+ },
293
+ {
294
+ hazardClass: "cycle-exact-raster",
295
+ limit:
296
+ "cycle-exact correctness after relocation cannot be verified " +
297
+ "statically on the 6502: indexed-addressing and branch-taken " +
298
+ "instructions cost an extra cycle when they cross a 256-byte page " +
299
+ "boundary, so moving a raster routine's start address can change " +
300
+ "which of its own instructions cross a page and silently change its " +
301
+ "total cycle count even though every opcode byte is unchanged. No " +
302
+ "static algorithm for cycle-exact raster detection exists; this " +
303
+ "class matches a structural SIGNATURE only.",
304
+ consequence:
305
+ "a class-4 finding is never a verified, proven or confirmed timing " +
306
+ "claim -- it names a matched pattern consistent with cycle-exact " +
307
+ "raster code, and whether the code actually is cycle-exact, or would " +
308
+ "remain so after relocation, is not determined by this report.",
309
+ },
310
+ {
311
+ hazardClass: null,
312
+ limit:
313
+ "a region with no finding was checked by every detector this report " +
314
+ "ran, and none of them matched anything in it.",
315
+ consequence:
316
+ "no detection is not evidence of safety: a region with no finding is " +
317
+ "never a claim that the region is safe to move, clean, or hazard-free " +
318
+ "-- it means nothing this report knows how to look for fired there, " +
319
+ "not that nothing is there.",
320
+ },
321
+ {
322
+ hazardClass: null,
323
+ limit:
324
+ "an execution observation can only ever raise a finding's detection " +
325
+ "strength, never establish one on its own -- the underlying finding " +
326
+ "always comes from a static signal, and an observation merely " +
327
+ "corroborates that the anchored code was seen running.",
328
+ consequence:
329
+ "an address never observed executing proves nothing about whether " +
330
+ "moving it is safe: no count, field or line in this report is " +
331
+ "derived from the size of the never-observed population, and an " +
332
+ "address's absence from every run's observations is never evidence " +
333
+ "that it is safe to move.",
334
+ },
335
+ ]);
336
+
337
+ /**
338
+ * Plain data the caller already fetched -- this module never fetches any of
339
+ * it itself. `symbols`, `comments` and `xrefs` are accepted now so the
340
+ * shape is stable for classes later plans add; this plan's slice does not
341
+ * read them.
342
+ */
343
+ export interface HazardReportInput {
344
+ bytes: Uint8Array;
345
+ origin: number;
346
+ symbols?: readonly LabelRow[];
347
+ comments?: readonly CommentRow[];
348
+ ranges?: readonly BlockEntry[];
349
+ xrefs?: readonly XrefRow[];
350
+ execObservations?: readonly EvidExecRow[];
351
+ }
352
+
353
+ /**
354
+ * The full answer. No field here is, or could be mistaken for, a single
355
+ * verdict -- see this module's header.
356
+ */
357
+ export interface HazardReport {
358
+ findings: HazardFinding[];
359
+ regions: HazardRegionDisposition[];
360
+ limits: readonly HazardLimit[];
361
+ /** The count of distinct addresses the store's ranges cover -- computed
362
+ * here from the ranges' own inclusive extents, never copied from an
363
+ * input. Zero for an empty `ranges` array; never a refusal. */
364
+ denominator: number;
365
+ /** Which hazard classes this call actually ran a detector for. */
366
+ classesEvaluated: readonly HazardClass[];
367
+ /**
368
+ * The imported scanner's ADVISORY `splitTableCandidates` collection,
369
+ * carried through VERBATIM -- never converted to a finding, never
370
+ * renamed, never filtered and never sorted into `findings`. The
371
+ * scanner's own promotion gate is deliberately closed to two accepted
372
+ * evidence shapes (the stack-return idiom and a zero-page vector actually
373
+ * jumped through), so a real-world dispatch construction outside those
374
+ * two shapes lands here rather than being silently promoted on weaker
375
+ * evidence. Dropping this collection -- or folding it into `findings` --
376
+ * would make an honest decline indistinguishable from an absence, which
377
+ * is exactly the confusion this field exists to prevent. See
378
+ * `HAZARD_LIMITS`'s `indexed-dispatch` entry for the same point stated in
379
+ * the report's own emitted output.
380
+ */
381
+ unprovenDispatchCandidates: readonly SplitTableFinding[];
382
+ truncated: boolean;
383
+ }
384
+
385
+ // ---------------------------------------------------------------------------
386
+ // The class-1 (indexed-dispatch) detector -- IMPORTED, never re-derived
387
+ // ---------------------------------------------------------------------------
388
+ //
389
+ // This is the existing `scanIndirectDispatch()` scan (`anno-coverage.ts`),
390
+ // called exactly once, mapped onto this module's own finding shape. No
391
+ // opcode table, no table walk and no entry-point plausibility check is
392
+ // written here -- every one of those already lives in the scanner, survived
393
+ // a real false-positive incident there, and must not be rebuilt beside it.
394
+
395
+ /**
396
+ * Maps the scanner's four PROVEN collections onto `HazardFinding`s.
397
+ * `anchorAddress` is always the dispatching instruction (the code that
398
+ * reads the table or vector); `blockedAddress` is always the table or
399
+ * vector BASE -- the thing that cannot move -- never the dispatching
400
+ * instruction's own address, because a report about movement must name
401
+ * what is pinned, not only where the pin is read from.
402
+ */
403
+ function detectIndexedDispatch(scan: IndirectDispatchScan): HazardFinding[] {
404
+ const findings: HazardFinding[] = [];
405
+
406
+ for (const jump of scan.indirectJumps) {
407
+ findings.push({
408
+ hazardClass: "indexed-dispatch",
409
+ anchorAddress: jump.at,
410
+ blockedAddress: jump.pointer,
411
+ mechanism: "indirect-jump-through-vector",
412
+ strength: "static-shape-matched",
413
+ detail:
414
+ "this jmp reads its target from the vector address named here; " +
415
+ "relocating the vector itself, or whatever value is stored at it, " +
416
+ "without updating every indirect jump that reads it silently " +
417
+ "changes where control transfers.",
418
+ corroboration: "none",
419
+ });
420
+ }
421
+
422
+ for (const table of scan.multiEntryTables) {
423
+ findings.push({
424
+ hazardClass: "indexed-dispatch",
425
+ anchorAddress: table.at,
426
+ blockedAddress: table.base,
427
+ mechanism: "multi-entry-dispatch-table",
428
+ strength: "static-shape-matched",
429
+ detail:
430
+ "this indirect jump reads one of several consecutive table entries " +
431
+ "starting at the base named here; relocating the table without " +
432
+ "updating every jump that reads through it silently changes which " +
433
+ "entry -- and therefore which target -- a given index selects.",
434
+ corroboration: "none",
435
+ });
436
+ }
437
+
438
+ for (const stackReturn of scan.stackReturnDispatch) {
439
+ findings.push({
440
+ hazardClass: "indexed-dispatch",
441
+ anchorAddress: stackReturn.at,
442
+ // The lower of the two reconstructed table bases: the two tables sit
443
+ // back-to-back and this is the base of that combined region, not an
444
+ // arbitrary pick between them.
445
+ blockedAddress: Math.min(stackReturn.loBase, stackReturn.hiBase),
446
+ mechanism: "stack-return-dispatch",
447
+ strength: "static-shape-matched",
448
+ detail:
449
+ "this routine reconstructs a return address from a split hi/lo " +
450
+ "table pair via the RTS-trick idiom (push hi, push lo, rts); " +
451
+ "relocating either table without updating the loads that read it " +
452
+ "silently changes which address the trailing rts resumes at.",
453
+ corroboration: "none",
454
+ });
455
+ }
456
+
457
+ for (const splitTable of scan.splitTables) {
458
+ findings.push({
459
+ hazardClass: "indexed-dispatch",
460
+ anchorAddress: splitTable.at,
461
+ blockedAddress: Math.min(splitTable.loBase, splitTable.hiBase),
462
+ mechanism: "split-address-table",
463
+ strength: "static-shape-matched",
464
+ detail:
465
+ "this routine builds a jump vector from a split hi/lo table pair " +
466
+ "and dispatches through it; relocating either table without " +
467
+ "updating the loads that read it silently changes the vector the " +
468
+ "indirect jump reads and therefore where control transfers.",
469
+ corroboration: "none",
470
+ });
471
+ }
472
+
473
+ return findings;
474
+ }
475
+
476
+ // ---------------------------------------------------------------------------
477
+ // The class-2 (self-modifying-code) detector
478
+ // ---------------------------------------------------------------------------
479
+
480
+ const WRITE_MNEMONICS_LITERAL_TARGET = new Set(["sta", "stx", "sty", "inc", "dec", "asl", "lsr", "rol", "ror"]);
481
+
482
+ /** Addressing modes whose operand is a literal address this detector can
483
+ * test directly -- no runtime register value and no runtime-computed
484
+ * pointer stands between the encoded bytes and the target address. Deliberately
485
+ * EXCLUDES `indirect_x`/`indirect_y`: those two modes route through a
486
+ * zero-page pointer whose value is a runtime fact, which is exactly the
487
+ * limit named in `HAZARD_LIMITS` above rather than a detector to
488
+ * build. */
489
+ const LITERAL_TARGET_MODES = new Set(["absolute", "absolute_x", "absolute_y", "zeropage", "zeropage_x", "zeropage_y"]);
490
+
491
+ /** The literal target address an instruction's operand encodes, for an
492
+ * addressing mode where that address does not depend on a runtime register
493
+ * value or a runtime-computed pointer -- `null` for every other mode
494
+ * (indirect, indirect-indexed, or no operand at all). SHARED between the
495
+ * class-2 (self-modifying-code) detector and the class-3 (page-alignment)
496
+ * detector's own literal-target checks: this is the ONE operand decoder,
497
+ * never duplicated. */
498
+ function literalOperandTarget(instr: Instruction): number | null {
499
+ if (!instr.operand) return null;
500
+ if (!LITERAL_TARGET_MODES.has(instr.mode)) return null;
501
+ return instr.operand.value;
502
+ }
503
+
504
+ function isNonNegativeSafeInteger(value: unknown): value is number {
505
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
506
+ }
507
+
508
+ /** Builds an address -> owning-instruction index over an already-decoded,
509
+ * already-bounded instruction stream. One pass, no recursion. */
510
+ function buildInstructionIndex(instructions: readonly Instruction[]): Map<number, Instruction> {
511
+ const index = new Map<number, Instruction>();
512
+ for (const instr of instructions) {
513
+ for (let offset = 0; offset < instr.bytes.length; offset++) {
514
+ index.set(instr.address + offset, instr);
515
+ }
516
+ }
517
+ return index;
518
+ }
519
+
520
+ function detailForMechanism(mechanism: string): string {
521
+ if (mechanism === "store-target-in-instruction-opcode-byte") {
522
+ return (
523
+ "this write changes which INSTRUCTION runs at the target address on a " +
524
+ "later pass -- moving the target instruction elsewhere leaves this " +
525
+ "write patching an address that is no longer the intended opcode byte, " +
526
+ "silently changing control flow rather than merely producing a wrong " +
527
+ "value."
528
+ );
529
+ }
530
+ return (
531
+ "this write changes an OPERAND byte the target instruction reads -- " +
532
+ "moving the target instruction elsewhere leaves this write patching an " +
533
+ "address that no longer belongs to it, so the target instruction keeps " +
534
+ "whatever stale operand was there instead of the intended one."
535
+ );
536
+ }
537
+
538
+ function detectSelfModifyingCode(
539
+ instructions: readonly Instruction[],
540
+ index: ReadonlyMap<number, Instruction>,
541
+ observedAddresses: ReadonlySet<number>,
542
+ ): HazardFinding[] {
543
+ const findings: HazardFinding[] = [];
544
+ for (const instr of instructions) {
545
+ if (!WRITE_MNEMONICS_LITERAL_TARGET.has(instr.mnemonic)) continue;
546
+ const target = literalOperandTarget(instr);
547
+ if (target === null) continue;
548
+
549
+ const host = index.get(target);
550
+ if (!host) continue; // hardware register, zp scratch, or outside every instruction: no finding
551
+ if (host === instr) continue; // "another decoded instruction", never itself
552
+
553
+ const mechanism =
554
+ target === host.address ? "store-target-in-instruction-opcode-byte" : "store-target-in-instruction-operand-byte";
555
+ // Corroboration is checked against the HOST's address, not the writer's:
556
+ // what strengthens this finding is proof that the MODIFIED code actually
557
+ // ran, not proof that the modifying instruction ran.
558
+ const strength: HazardDetectionStrength = observedAddresses.has(host.address)
559
+ ? "observed-corroborated"
560
+ : "static-shape-matched";
561
+
562
+ findings.push({
563
+ hazardClass: "self-modifying-code",
564
+ anchorAddress: host.address,
565
+ blockedAddress: target,
566
+ mechanism,
567
+ strength,
568
+ detail: detailForMechanism(mechanism),
569
+ corroboration: strength === "observed-corroborated" ? "runtime-observed" : "none",
570
+ });
571
+ }
572
+ return findings;
573
+ }
574
+
575
+ // ---------------------------------------------------------------------------
576
+ // The class-3 (page-alignment) detector -- VIC-II hardware alignment ONLY
577
+ // ---------------------------------------------------------------------------
578
+ //
579
+ // VIC-II HARDWARE alignment, and only that reading: a VIC-II register or the
580
+ // sprite pointer names an address not as an address but as a SCALED INDEX (a
581
+ // 2048-byte character-set unit, a 64-byte sprite-shape unit), so the
582
+ // hardware silently reads whatever real bytes now sit at that index if the
583
+ // named data moves off the required boundary. A different, excluded reading
584
+ // -- a code or table alignment chosen so an indexed access never crosses a
585
+ // 256-byte page, which changes instruction TIMING rather than which bytes
586
+ // the hardware reads -- is recorded as a named limit above, never evaluated
587
+ // here.
588
+ //
589
+ // The VIC-II register arithmetic (bank base, screen base, character base,
590
+ // bitmap-vs-charset mode, the sprite pointer table's own offset) is NEVER
591
+ // re-derived in this file -- `anno-graphics.ts` already owns it, takes
592
+ // plain register-write facts and returns plain ranges, and names a
593
+ // register it never recovered instead of substituting a power-on default.
594
+ // What genuinely IS this module's job, and is not that module's by its own
595
+ // stated scope: recovering the register-write facts from DECODED BYTES
596
+ // (that module takes them as a given), and turning a derived range into a
597
+ // MOVEMENT CONSTRAINT (that module explicitly declines to promise a sprite
598
+ // shape's own address).
599
+
600
+ /** The three VIC-II registers this detector recovers constant writes for --
601
+ * `anno-graphics.ts`'s own three exported addresses, named once here so the
602
+ * recovery walk below reads a single set rather than three separate
603
+ * comparisons. */
604
+ const WATCHED_VIC_REGISTERS: ReadonlySet<number> = new Set([BANK_SELECT_ADDRESS, MEMORY_CONTROL_ADDRESS, CONTROL_REGISTER_1_ADDRESS]);
605
+
606
+ /** `anno-graphics.ts`'s own register-name spelling, reversed to an address --
607
+ * the same strings `GraphicsMap.registerValues`/`missingRegisters` already
608
+ * use, so a lookup built from a recovered register name matches the
609
+ * module's own vocabulary rather than inventing a second one. Built once so
610
+ * the missing-register evidence walk below is a single lookup, never a
611
+ * `find()` over three entries per recovered register value. */
612
+ const VIC_REGISTER_ADDRESS_BY_NAME: Readonly<Record<string, number>> = Object.freeze({
613
+ "bank-select": BANK_SELECT_ADDRESS,
614
+ "memory-control": MEMORY_CONTROL_ADDRESS,
615
+ "control-register-1": CONTROL_REGISTER_1_ADDRESS,
616
+ });
617
+
618
+ const IMMEDIATE_LOAD_TO_STORE: Readonly<Record<string, string>> = Object.freeze({ lda: "sta", ldx: "stx", ldy: "sty" });
619
+
620
+ /**
621
+ * Walks the decoded stream ONCE, pairing an immediate load with the very
622
+ * next instruction when that next instruction is a store, through the SAME
623
+ * register, to one of the three watched VIC-II registers -- reusing
624
+ * `literalOperandTarget()` for the store's target rather than a second
625
+ * operand decoder. A register value built from anything else (a
626
+ * read-modify-write through the accumulator, an indexed store, a value
627
+ * loaded from memory) is a genuine runtime fact this narrow recovery does
628
+ * not claim to know, and is correctly left unrecovered rather than guessed.
629
+ */
630
+ /** One recovered `(storeAddress, targetAddress, value)` triple -- the same
631
+ * shape `GraphicsConstWriteFact`/`ConstWriteFact` already use elsewhere in
632
+ * this tree. Declared locally so this generic recovery walk carries no
633
+ * dependency of its own beyond the plain load/store shape it reads. */
634
+ interface ImmediateStoreFact {
635
+ storeAddress: number;
636
+ targetAddress: number;
637
+ value: number;
638
+ }
639
+
640
+ /**
641
+ * Walks the decoded stream ONCE, pairing an immediate load with the very
642
+ * next instruction when that next instruction is a store, through the SAME
643
+ * register, to one of `watched`. THE ONE adjacent-pair recovery walk in
644
+ * this module -- both `recoverVicConstWrites()` (the three VIC-II
645
+ * registers) and the class-4 interrupt-vector recovery below call this,
646
+ * rather than each writing its own copy.
647
+ */
648
+ function recoverImmediateStoreFacts(instructions: readonly Instruction[], watched: ReadonlySet<number>): ImmediateStoreFact[] {
649
+ const facts: ImmediateStoreFact[] = [];
650
+ for (let i = 0; i + 1 < instructions.length; i++) {
651
+ const load = instructions[i]!;
652
+ if (!load.operand || load.operand.role !== "immediate") continue;
653
+ const expectedStore = IMMEDIATE_LOAD_TO_STORE[load.mnemonic];
654
+ if (!expectedStore) continue;
655
+
656
+ const store = instructions[i + 1]!;
657
+ if (store.mnemonic !== expectedStore) continue;
658
+ const target = literalOperandTarget(store);
659
+ if (target === null || !watched.has(target)) continue;
660
+
661
+ facts.push({ storeAddress: store.address, targetAddress: target, value: load.operand.value });
662
+ }
663
+ return facts;
664
+ }
665
+
666
+ function recoverVicConstWrites(instructions: readonly Instruction[]): GraphicsConstWriteFact[] {
667
+ return recoverImmediateStoreFacts(instructions, WATCHED_VIC_REGISTERS);
668
+ }
669
+
670
+ /** The store address of the FIRST recovered fact writing `value` to
671
+ * `targetAddress`, or `null` -- the anchor for a graphics-derived finding:
672
+ * the store that actually SET the register value the derivation used. */
673
+ function anchorForRegisterValue(facts: readonly GraphicsConstWriteFact[], targetAddress: number, value: number): number | null {
674
+ for (const fact of facts) {
675
+ if (fact.targetAddress === targetAddress && fact.value === value) return fact.storeAddress;
676
+ }
677
+ return null;
678
+ }
679
+
680
+ const CHARSET_DETAIL =
681
+ "the VIC-II reads this character set through a 2048-byte-granularity " +
682
+ "index stored in the memory-control register; relocating the character " +
683
+ "data without updating that register (or vice versa) leaves the register " +
684
+ "naming the OLD 2048-byte-aligned block while the bytes moved -- the " +
685
+ "hardware silently reads whatever now sits at that index, with no error, " +
686
+ "exception or diagnostic.";
687
+
688
+ const SPRITE_RESOLVED_DETAIL =
689
+ `the VIC-II reads this sprite's shape through a 64-byte-granularity index ` +
690
+ `stored in the sprite pointer byte (the sprite pointer table sits at a ` +
691
+ `fixed $${SPRITE_POINTER_OFFSET.toString(16)} offset from the screen ` +
692
+ "matrix base); relocating the shape data without updating that pointer " +
693
+ "leaves it naming the OLD 64-byte-aligned block, and the hardware " +
694
+ "silently renders whatever now sits there instead.";
695
+
696
+ const SPRITE_COMPUTED_DETAIL =
697
+ "this store targets the sprite pointer table but its value could not be " +
698
+ "resolved to a literal at analysis time, so whether the resulting " +
699
+ "64-byte-aligned base is satisfied is unknown -- the dependency is real " +
700
+ "even though this report cannot name the block it points at.";
701
+
702
+ interface GraphicsEvaluation {
703
+ findings: HazardFinding[];
704
+ /** Store address -> reason, for every recovered fact whose OWN recovered
705
+ * combination has at least one missing register -- "we looked and could
706
+ * not tell", never folded into "no-signal" ("we looked and found
707
+ * nothing"). Consumed by `classifyRegions()` below. */
708
+ incompleteAreas: Map<number, string>;
709
+ /** Every `sprite-pointers`-kind range any recovered combination derived,
710
+ * across every map -- what `detectSpritePointerStores()` tests a literal
711
+ * store target against. */
712
+ spriteRanges: GraphicsRange[];
713
+ }
714
+
715
+ /**
716
+ * Hands the recovered facts to the EXISTING graphics derivation
717
+ * (`deriveGraphicsRanges()`, never re-implemented here) and turns its
718
+ * answer into class-3 findings for the character-set range only -- screen
719
+ * matrix and bitmap-mode ranges are out of this detector's declared scope
720
+ * (see this module's own mechanism-id list).
721
+ */
722
+ function evaluateGraphicsFacts(facts: readonly GraphicsConstWriteFact[], imageStart: number, imageEndInclusive: number): GraphicsEvaluation {
723
+ const findings: HazardFinding[] = [];
724
+ const incompleteAreas = new Map<number, string>();
725
+ const spriteRanges: GraphicsRange[] = [];
726
+ if (facts.length === 0) return { findings, incompleteAreas, spriteRanges };
727
+
728
+ const maps = deriveGraphicsRanges(facts);
729
+ for (const map of maps) {
730
+ if (map.missingRegisters.length > 0) {
731
+ const reason =
732
+ `the VIC-II register recovery for this combination is incomplete -- missing ${map.missingRegisters.join(", ")} -- ` +
733
+ "so this report could not determine whether a page-alignment dependency exists here; that is a limit of what was " +
734
+ "recovered, not a finding that nothing depends on it.";
735
+ for (const [name, value] of Object.entries(map.registerValues)) {
736
+ const targetAddress = VIC_REGISTER_ADDRESS_BY_NAME[name];
737
+ if (targetAddress === undefined) continue;
738
+ for (const fact of facts) {
739
+ if (fact.targetAddress === targetAddress && fact.value === value) incompleteAreas.set(fact.storeAddress, reason);
740
+ }
741
+ }
742
+ }
743
+
744
+ for (const range of map.ranges) {
745
+ if (range.kind === "sprite-pointers") spriteRanges.push(range);
746
+ if (range.kind !== "character-set") continue;
747
+ if (range.start < imageStart || range.start > imageEndInclusive) continue;
748
+
749
+ const memoryControlValue = map.registerValues["memory-control"];
750
+ const anchor = memoryControlValue !== undefined ? anchorForRegisterValue(facts, MEMORY_CONTROL_ADDRESS, memoryControlValue) : null;
751
+ findings.push({
752
+ hazardClass: "page-alignment",
753
+ anchorAddress: anchor ?? range.start,
754
+ blockedAddress: range.start,
755
+ mechanism: "charset-base-pinned-by-register",
756
+ strength: "static-shape-matched",
757
+ detail: CHARSET_DETAIL,
758
+ corroboration: "none",
759
+ });
760
+ }
761
+ }
762
+ return { findings, incompleteAreas, spriteRanges };
763
+ }
764
+
765
+ const SPRITE_STORE_TO_LOAD: Readonly<Record<string, string>> = Object.freeze({ sta: "lda", stx: "ldx", sty: "ldy" });
766
+
767
+ /**
768
+ * A store whose literal target lands inside ANY derived sprite-pointers
769
+ * range is a sprite shape selection -- the one signal the graphics module
770
+ * deliberately does not promise (it derives the pointer TABLE range only,
771
+ * never a shape's own address). When the value stored is an immediate
772
+ * literal, the blocked address is that value times 64 (the fixed sprite
773
+ * granularity); otherwise the dependency is real but its target is not
774
+ * statically known, reported at the weakest strength with no blocked
775
+ * address.
776
+ */
777
+ function detectSpritePointerStores(
778
+ instructions: readonly Instruction[],
779
+ spriteRanges: readonly GraphicsRange[],
780
+ imageStart: number,
781
+ imageEndInclusive: number,
782
+ ): HazardFinding[] {
783
+ const findings: HazardFinding[] = [];
784
+ if (spriteRanges.length === 0) return findings;
785
+
786
+ for (let i = 0; i < instructions.length; i++) {
787
+ const instr = instructions[i]!;
788
+ const expectedLoad = SPRITE_STORE_TO_LOAD[instr.mnemonic];
789
+ if (!expectedLoad) continue;
790
+ const target = literalOperandTarget(instr);
791
+ if (target === null) continue;
792
+ if (!spriteRanges.some((r) => target >= r.start && target <= r.endInclusive)) continue;
793
+
794
+ const prev = i > 0 ? instructions[i - 1] : undefined;
795
+ const sourcedFromImmediate = !!prev && prev.mnemonic === expectedLoad && prev.operand?.role === "immediate";
796
+
797
+ if (sourcedFromImmediate) {
798
+ const value = prev!.operand!.value;
799
+ const blockedAddress = value * 64;
800
+ if (blockedAddress < imageStart || blockedAddress > imageEndInclusive) continue; // not in this image -- no finding
801
+ findings.push({
802
+ hazardClass: "page-alignment",
803
+ anchorAddress: instr.address,
804
+ blockedAddress,
805
+ mechanism: "sprite-pointer-names-aligned-base",
806
+ strength: "static-shape-matched",
807
+ detail: SPRITE_RESOLVED_DETAIL,
808
+ corroboration: "none",
809
+ });
810
+ } else {
811
+ findings.push({
812
+ hazardClass: "page-alignment",
813
+ anchorAddress: instr.address,
814
+ blockedAddress: null,
815
+ mechanism: "sprite-pointer-computed-value",
816
+ strength: "static-signature-only",
817
+ detail: SPRITE_COMPUTED_DETAIL,
818
+ corroboration: "none",
819
+ });
820
+ }
821
+ }
822
+ return findings;
823
+ }
824
+
825
+ // ---------------------------------------------------------------------------
826
+ // The class-4 (cycle-exact-raster) detector -- structural SIGNATURE only
827
+ // ---------------------------------------------------------------------------
828
+ //
829
+ // No static algorithm for cycle-exact raster detection exists (stated
830
+ // verbatim in `HAZARD_LIMITS`'s own `cycle-exact-raster` entry). What this
831
+ // detector matches is a STRUCTURAL SIGNATURE consistent with the textbook
832
+ // double-IRQ stabiliser and its timer-based variant -- never a claim that
833
+ // the matched code IS cycle-exact, and never a claim it would REMAIN so
834
+ // after relocation. Every finding this detector emits carries the WEAKEST
835
+ // detection-strength token unless a runtime observation covers its own
836
+ // anchor address; its mechanism ids and detail prose are structurally
837
+ // asserted (this module's own test file) to never contain a word that
838
+ // asserts verification.
839
+
840
+ /** The RAM-resident IRQ vector KERNAL-hooking code installs through
841
+ * (`$0314`/`$0315`) -- not the hardware vector at `$FFFE`/`$FFFF`, which
842
+ * sits in ROM and is not the address real C64 programs rewrite. */
843
+ const IRQ_VECTOR_LOW = 0x0314;
844
+ const IRQ_VECTOR_HIGH = 0x0315;
845
+ const IRQ_VECTOR_ADDRESSES: ReadonlySet<number> = new Set([IRQ_VECTOR_LOW, IRQ_VECTOR_HIGH]);
846
+
847
+ /** `$D012` serves both roles this detector treats as one signal: reading it
848
+ * returns the current raster line, and writing it sets the raster-compare
849
+ * value the next raster IRQ fires against. Either access is "a raster
850
+ * register access" for this detector's purposes. */
851
+ const RASTER_REGISTER_ADDRESS = 0xd012;
852
+
853
+ /** CIA1 Timer A's reload (latch) registers -- the ones a one-shot
854
+ * stabiliser reloads on every interrupt. CIA1, not CIA2: CIA1 drives the
855
+ * IRQ line this detector's vectored-handler walk is already anchored on. */
856
+ const TIMER_A_RELOAD_ADDRESSES: ReadonlySet<number> = new Set([0xdc04, 0xdc05]);
857
+
858
+ const RASTER_SIGNATURE_DETAIL =
859
+ "a structural signature consistent with cycle-exact raster code was " +
860
+ "matched: a raster-register access inside a routine an interrupt vector " +
861
+ "names. No static check here determines whether the code is actually " +
862
+ "cycle-exact, or whether it would remain so after relocation.";
863
+
864
+ const TIMING_SLED_SIGNATURE_DETAIL =
865
+ "a structural signature consistent with cycle-exact raster code was " +
866
+ "matched: a run of no-operation instructions immediately following a " +
867
+ "raster-register access, the textbook jitter-compensation sled. No " +
868
+ "static check here determines whether the code is actually cycle-exact, " +
869
+ "or whether it would remain so after relocation.";
870
+
871
+ const TIMER_RELOAD_SIGNATURE_DETAIL =
872
+ "a structural signature consistent with cycle-exact raster code was " +
873
+ "matched: a one-shot timer reload written inside a routine an interrupt " +
874
+ "vector names, the CIA-timer stabiliser variant. No static check here " +
875
+ "determines whether the code is actually cycle-exact, or whether it " +
876
+ "would remain so after relocation.";
877
+
878
+ /** Cross-products every recovered low-byte write against every recovered
879
+ * high-byte write of the RAM IRQ vector into 16-bit target addresses,
880
+ * deduplicated. This detector recognises the conventional low-then-high
881
+ * install order's TWO byte facts regardless of their relative position in
882
+ * the stream (`recoverImmediateStoreFacts()` finds each independently); it
883
+ * does not attempt to prove the two stores belong to a single 4-instruction
884
+ * idiom, which would be a second, narrower recovery this module does not
885
+ * need for the signature it matches. */
886
+ function recoverInterruptVectorTargets(instructions: readonly Instruction[]): number[] {
887
+ const facts = recoverImmediateStoreFacts(instructions, IRQ_VECTOR_ADDRESSES);
888
+ const lowValues = facts.filter((f) => f.targetAddress === IRQ_VECTOR_LOW).map((f) => f.value);
889
+ const highValues = facts.filter((f) => f.targetAddress === IRQ_VECTOR_HIGH).map((f) => f.value);
890
+ const targets = new Set<number>();
891
+ for (const lo of lowValues) {
892
+ for (const hi of highValues) targets.add((lo | (hi << 8)) & 0xffff);
893
+ }
894
+ return [...targets];
895
+ }
896
+
897
+ /**
898
+ * Walks forward from `handlerAddress` (an instruction START address only --
899
+ * a mid-instruction byte is not a real entry point) to the first
900
+ * return-from-interrupt (`rti`) or return-from-subroutine (`rts`),
901
+ * collecting every instruction in between. Bounded by construction: no
902
+ * revisit, no recursion, and the walk stops at the first return OR the end
903
+ * of the already-decoded (already image-bounded) instruction array,
904
+ * whichever comes first.
905
+ */
906
+ function handlerWindow(instructions: readonly Instruction[], addressToIndex: ReadonlyMap<number, number>, handlerAddress: number): Instruction[] | null {
907
+ const startIdx = addressToIndex.get(handlerAddress);
908
+ if (startIdx === undefined) return null;
909
+ const window: Instruction[] = [];
910
+ for (let i = startIdx; i < instructions.length; i++) {
911
+ const instr = instructions[i]!;
912
+ window.push(instr);
913
+ if (instr.opcode === 0x40 /* rti */ || instr.opcode === 0x60 /* rts */) break;
914
+ }
915
+ return window;
916
+ }
917
+
918
+ function class4Strength(anchorAddress: number, observedAddresses: ReadonlySet<number>): HazardDetectionStrength {
919
+ return observedAddresses.has(anchorAddress) ? "observed-corroborated" : "static-signature-only";
920
+ }
921
+
922
+ /**
923
+ * Structural signature matching ONLY -- see this section's own header.
924
+ * Collects every RAM IRQ vector target this image recovers, walks each
925
+ * one's handler window (when the target names a real instruction start
926
+ * inside the image), and emits one finding per signal that fires.
927
+ */
928
+ function detectCycleExactRasterSignatures(
929
+ instructions: readonly Instruction[],
930
+ addressToIndex: ReadonlyMap<number, number>,
931
+ imageStart: number,
932
+ imageEndInclusive: number,
933
+ observedAddresses: ReadonlySet<number>,
934
+ ): HazardFinding[] {
935
+ const findings: HazardFinding[] = [];
936
+ const targets = recoverInterruptVectorTargets(instructions);
937
+
938
+ for (const handlerAddress of targets) {
939
+ if (handlerAddress < imageStart || handlerAddress > imageEndInclusive) continue; // named address lies outside the image
940
+ const window = handlerWindow(instructions, addressToIndex, handlerAddress);
941
+ if (window === null) continue; // not a real instruction start -- nothing to walk
942
+
943
+ let rasterAccessAnchor: number | null = null;
944
+ let timingSledAnchor: number | null = null;
945
+ let timerReloadAnchor: number | null = null;
946
+
947
+ for (let i = 0; i < window.length; i++) {
948
+ const instr = window[i]!;
949
+ const target = literalOperandTarget(instr);
950
+ if (target === null) continue;
951
+
952
+ if (target === RASTER_REGISTER_ADDRESS) {
953
+ if (rasterAccessAnchor === null) rasterAccessAnchor = instr.address;
954
+ // A timing sled is a run of >= 3 NOPs IMMEDIATELY following this
955
+ // access -- checked once per raster access, so the FIRST qualifying
956
+ // run in the window is what is reported.
957
+ if (timingSledAnchor === null && i + 3 < window.length) {
958
+ const allNop = window[i + 1]!.opcode === 0xea && window[i + 2]!.opcode === 0xea && window[i + 3]!.opcode === 0xea;
959
+ if (allNop) timingSledAnchor = instr.address;
960
+ }
961
+ } else if (TIMER_A_RELOAD_ADDRESSES.has(target) && (instr.mnemonic === "sta" || instr.mnemonic === "stx" || instr.mnemonic === "sty")) {
962
+ if (timerReloadAnchor === null) timerReloadAnchor = instr.address;
963
+ }
964
+ }
965
+
966
+ if (rasterAccessAnchor !== null) {
967
+ findings.push({
968
+ hazardClass: "cycle-exact-raster",
969
+ anchorAddress: rasterAccessAnchor,
970
+ blockedAddress: null,
971
+ mechanism: "raster-access-in-vectored-handler",
972
+ strength: class4Strength(rasterAccessAnchor, observedAddresses),
973
+ detail: RASTER_SIGNATURE_DETAIL,
974
+ corroboration: observedAddresses.has(rasterAccessAnchor) ? "runtime-observed" : "none",
975
+ });
976
+ }
977
+ if (timingSledAnchor !== null) {
978
+ findings.push({
979
+ hazardClass: "cycle-exact-raster",
980
+ anchorAddress: timingSledAnchor,
981
+ blockedAddress: null,
982
+ mechanism: "timing-sled-after-raster-access",
983
+ strength: class4Strength(timingSledAnchor, observedAddresses),
984
+ detail: TIMING_SLED_SIGNATURE_DETAIL,
985
+ corroboration: observedAddresses.has(timingSledAnchor) ? "runtime-observed" : "none",
986
+ });
987
+ }
988
+ if (timerReloadAnchor !== null) {
989
+ findings.push({
990
+ hazardClass: "cycle-exact-raster",
991
+ anchorAddress: timerReloadAnchor,
992
+ blockedAddress: null,
993
+ mechanism: "timer-reload-in-vectored-handler",
994
+ strength: class4Strength(timerReloadAnchor, observedAddresses),
995
+ detail: TIMER_RELOAD_SIGNATURE_DETAIL,
996
+ corroboration: observedAddresses.has(timerReloadAnchor) ? "runtime-observed" : "none",
997
+ });
998
+ }
999
+ }
1000
+
1001
+ return findings;
1002
+ }
1003
+
1004
+ // ---------------------------------------------------------------------------
1005
+ // De-duplication and ordering
1006
+ // ---------------------------------------------------------------------------
1007
+
1008
+ function findingKey(f: HazardFinding): string {
1009
+ return `${f.hazardClass}|${f.anchorAddress}|${f.mechanism}`;
1010
+ }
1011
+
1012
+ function dedupeFindings(findings: readonly HazardFinding[]): HazardFinding[] {
1013
+ const seen = new Set<string>();
1014
+ const out: HazardFinding[] = [];
1015
+ for (const f of findings) {
1016
+ const key = findingKey(f);
1017
+ if (seen.has(key)) continue;
1018
+ seen.add(key);
1019
+ out.push(f);
1020
+ }
1021
+ return out;
1022
+ }
1023
+
1024
+ function sortFindings(findings: readonly HazardFinding[]): HazardFinding[] {
1025
+ const classOrder = new Map<HazardClass, number>(HAZARD_CLASSES.map((c, i) => [c, i]));
1026
+ return [...findings].sort((a, b) => {
1027
+ if (a.anchorAddress !== b.anchorAddress) return a.anchorAddress - b.anchorAddress;
1028
+ const ao = classOrder.get(a.hazardClass) ?? 0;
1029
+ const bo = classOrder.get(b.hazardClass) ?? 0;
1030
+ if (ao !== bo) return ao - bo;
1031
+ return a.mechanism < b.mechanism ? -1 : a.mechanism > b.mechanism ? 1 : 0;
1032
+ });
1033
+ }
1034
+
1035
+ // ---------------------------------------------------------------------------
1036
+ // Region disposition
1037
+ // ---------------------------------------------------------------------------
1038
+
1039
+ function classifyRegions(
1040
+ ranges: readonly BlockEntry[],
1041
+ imageStart: number,
1042
+ imageEndInclusive: number,
1043
+ imageIsEmpty: boolean,
1044
+ classesEvaluated: readonly HazardClass[],
1045
+ findingAddresses: ReadonlySet<number>,
1046
+ incompleteEvidence: ReadonlyMap<number, string> = new Map(),
1047
+ ): HazardRegionDisposition[] {
1048
+ const regions: HazardRegionDisposition[] = [];
1049
+ for (const range of ranges) {
1050
+ if (!range) continue;
1051
+ const start = range.start_address;
1052
+ const endInclusive = range.end_address;
1053
+ if (!Number.isInteger(start) || !Number.isInteger(endInclusive)) continue;
1054
+
1055
+ let outcome: HazardRegionOutcome;
1056
+ let reason: string | undefined;
1057
+
1058
+ if (imageIsEmpty || start > imageEndInclusive || endInclusive < imageStart) {
1059
+ outcome = "unclassified";
1060
+ reason = "the region lies outside the loaded image";
1061
+ } else if (classesEvaluated.length === 0) {
1062
+ outcome = "unclassified";
1063
+ reason = "every detector declined to evaluate this region";
1064
+ } else {
1065
+ const cls = blockClassAt(ranges, start);
1066
+ if (cls === null || cls === "undefined") {
1067
+ outcome = "unclassified";
1068
+ reason = "the region's own store block class is undefined";
1069
+ } else {
1070
+ let hit = false;
1071
+ let incompleteReason: string | undefined;
1072
+ const clampedEnd = Math.min(endInclusive, 0xffff);
1073
+ for (let addr = Math.max(start, 0); addr <= clampedEnd; addr++) {
1074
+ if (findingAddresses.has(addr)) {
1075
+ hit = true;
1076
+ break;
1077
+ }
1078
+ // "We looked and could not tell" (a recovered-but-incomplete VIC-II
1079
+ // register combination) is never folded into "no-signal" ("we
1080
+ // looked and found nothing"). A real finding elsewhere in this
1081
+ // same region still wins (checked first, above), because a proven
1082
+ // hazard is not weakened by an unrelated recovery gap.
1083
+ if (incompleteReason === undefined && incompleteEvidence.has(addr)) {
1084
+ incompleteReason = incompleteEvidence.get(addr);
1085
+ }
1086
+ }
1087
+ if (hit) {
1088
+ outcome = "hazard-reported";
1089
+ } else if (incompleteReason !== undefined) {
1090
+ outcome = "unclassified";
1091
+ reason = incompleteReason;
1092
+ } else {
1093
+ outcome = "no-signal";
1094
+ }
1095
+ }
1096
+ }
1097
+
1098
+ regions.push(reason !== undefined ? { start, endInclusive, outcome, reason } : { start, endInclusive, outcome });
1099
+ }
1100
+ return regions.sort((a, b) => a.start - b.start);
1101
+ }
1102
+
1103
+ function computeDenominator(ranges: readonly BlockEntry[]): number {
1104
+ const covered = new Set<number>();
1105
+ for (const range of ranges) {
1106
+ if (!range) continue;
1107
+ const start = range.start_address;
1108
+ const end = range.end_address;
1109
+ if (!Number.isInteger(start) || !Number.isInteger(end)) continue;
1110
+ const clampedEnd = Math.min(end, 0xffff);
1111
+ for (let addr = Math.max(start, 0); addr <= clampedEnd; addr++) covered.add(addr);
1112
+ }
1113
+ return covered.size;
1114
+ }
1115
+
1116
+ // ---------------------------------------------------------------------------
1117
+ // The entry point
1118
+ // ---------------------------------------------------------------------------
1119
+
1120
+ /**
1121
+ * Builds a hazard report from already-fetched plain data. Never throws on
1122
+ * empty or malformed input -- every array field is normalized with the same
1123
+ * `Array.isArray(...)` guard the observed-execution reconciler uses.
1124
+ */
1125
+ export function buildHazardReport(input: HazardReportInput): HazardReport {
1126
+ const bytes = input && input.bytes instanceof Uint8Array ? input.bytes : new Uint8Array(0);
1127
+ const origin = isNonNegativeSafeInteger(input?.origin) && input.origin <= 0xffff ? input.origin : 0;
1128
+ const ranges: readonly BlockEntry[] = Array.isArray(input?.ranges) ? input.ranges : [];
1129
+ const execObservations: readonly EvidExecRow[] = Array.isArray(input?.execObservations) ? input.execObservations : [];
1130
+
1131
+ // Bound the walk at the 16-bit address space, the same way the existing
1132
+ // coverage census clamps its own effective end -- never an unbounded loop
1133
+ // over an attacker-controlled byte count.
1134
+ const imageIsEmpty = bytes.length === 0;
1135
+ const effectiveEnd = Math.min(origin + bytes.length, 0x10000);
1136
+ const imageEndInclusive = imageIsEmpty ? origin : effectiveEnd - 1;
1137
+
1138
+ const instructions = imageIsEmpty ? [] : decode(bytes, origin, { end: imageEndInclusive });
1139
+ const instructionIndex = buildInstructionIndex(instructions);
1140
+
1141
+ const observedAddresses = new Set<number>();
1142
+ for (const row of execObservations) {
1143
+ if (row && isNonNegativeSafeInteger(row.address)) observedAddresses.add(row.address);
1144
+ }
1145
+
1146
+ const classesEvaluated: HazardClass[] = imageIsEmpty
1147
+ ? []
1148
+ : ["indexed-dispatch", "self-modifying-code", "page-alignment", "cycle-exact-raster"];
1149
+
1150
+ // The ONE new call site this plan adds. Same triple the existing consumer
1151
+ // (`buildCoverageReport()`) passes: the already-decoded instruction stream,
1152
+ // the raw bytes, and the origin -- see `hazard reuse:` in this module's
1153
+ // own test file for why a second call site anywhere else is a defect.
1154
+ const dispatchScan: IndirectDispatchScan | null = imageIsEmpty ? null : scanIndirectDispatch(instructions, bytes, origin);
1155
+
1156
+ const vicFacts = imageIsEmpty ? [] : recoverVicConstWrites(instructions);
1157
+ const graphicsEvaluation = evaluateGraphicsFacts(vicFacts, origin, imageEndInclusive);
1158
+ const spriteFindings = imageIsEmpty
1159
+ ? []
1160
+ : detectSpritePointerStores(instructions, graphicsEvaluation.spriteRanges, origin, imageEndInclusive);
1161
+
1162
+ // address -> index in `instructions`, first-wins -- only instruction
1163
+ // START addresses key this map, unlike `instructionIndex` above (which
1164
+ // keys every byte an instruction occupies).
1165
+ const addressToIndex = new Map<number, number>();
1166
+ instructions.forEach((instr, idx) => {
1167
+ if (!addressToIndex.has(instr.address)) addressToIndex.set(instr.address, idx);
1168
+ });
1169
+ const rasterFindings = imageIsEmpty
1170
+ ? []
1171
+ : detectCycleExactRasterSignatures(instructions, addressToIndex, origin, imageEndInclusive, observedAddresses);
1172
+
1173
+ const rawFindings = imageIsEmpty
1174
+ ? []
1175
+ : [
1176
+ ...detectIndexedDispatch(dispatchScan!),
1177
+ ...detectSelfModifyingCode(instructions, instructionIndex, observedAddresses),
1178
+ ...graphicsEvaluation.findings,
1179
+ ...spriteFindings,
1180
+ ...rasterFindings,
1181
+ ];
1182
+ const findings = sortFindings(dedupeFindings(rawFindings));
1183
+
1184
+ const findingAddresses = new Set<number>();
1185
+ for (const f of findings) {
1186
+ findingAddresses.add(f.anchorAddress);
1187
+ if (f.blockedAddress !== null) findingAddresses.add(f.blockedAddress);
1188
+ }
1189
+
1190
+ const regions = classifyRegions(
1191
+ ranges,
1192
+ origin,
1193
+ imageEndInclusive,
1194
+ imageIsEmpty,
1195
+ classesEvaluated,
1196
+ findingAddresses,
1197
+ graphicsEvaluation.incompleteAreas,
1198
+ );
1199
+
1200
+ return {
1201
+ findings,
1202
+ regions,
1203
+ limits: HAZARD_LIMITS,
1204
+ denominator: computeDenominator(ranges),
1205
+ classesEvaluated,
1206
+ // Carried through VERBATIM -- never filtered, never converted to a
1207
+ // finding. See this field's own doc comment on `HazardReport`.
1208
+ unprovenDispatchCandidates: dispatchScan ? dispatchScan.splitTableCandidates : [],
1209
+ // Propagated, never swallowed: a scan cut short by MAX_TABLE_ENTRIES
1210
+ // must be visible on the report it feeds, not silently absorbed.
1211
+ truncated: dispatchScan ? dispatchScan.truncated : false,
1212
+ };
1213
+ }
1214
+
1215
+ // ---------------------------------------------------------------------------
1216
+ // The cross-check comparator -- measuring the detectors above against
1217
+ // programs this phase did not author
1218
+ // ---------------------------------------------------------------------------
1219
+ //
1220
+ // Shaped exactly like `dxa-proof01-compare.ts`'s own comparator (an already
1221
+ // -built answer in, a denominator plus three counts plus three sorted
1222
+ // address arrays out, never a score, a rate or a percentage anywhere): the
1223
+ // same discipline, applied to this report instead of a dxa listing.
1224
+ //
1225
+ // WHAT MAKES THIS A CONTROL RATHER THAN A SECOND OPINION FROM THE SAME
1226
+ // SOURCE: the expectation this comparator joins against is never derived
1227
+ // from a detector run. It is read from a fixture's own committed source or
1228
+ // bytes by a person, before this function is ever called, and it is never
1229
+ // edited afterward to make a disagreeing row agree. When a row disagrees,
1230
+ // exactly one of two things is true -- the expectation was wrong about the
1231
+ // fixture's own bytes (provable by reading them again) or the detector is
1232
+ // wrong -- and both are findings this comparator exists to surface, never a
1233
+ // reason to move the expectation. Moving it would convert the one
1234
+ // independent control this phase has into a mirror of the thing it checks.
1235
+
1236
+ function sortAscendingNumbers(values: Iterable<number>): number[] {
1237
+ return [...values].sort((a, b) => a - b);
1238
+ }
1239
+
1240
+ /**
1241
+ * One committed fixture's declared expectation for one hazard class --
1242
+ * ground truth this phase did NOT derive from any detector run.
1243
+ * `expectedAddresses` is populated only for `kind: "positive"`; empty for
1244
+ * the other two kinds. `kind` distinguishes three cases:
1245
+ * - `positive`: an independently-sourced fixture genuinely carries this
1246
+ * class, at exactly the named addresses.
1247
+ * - `negative`: the fixture carries none of this class -- any reported
1248
+ * address of this class on it is a false positive.
1249
+ * - `no-example`: this phase has no independently-sourced positive
1250
+ * fixture for this class at all. The comparator returns a marker for
1251
+ * this row, not a measurement -- see `crossCheckHazardFixture()`.
1252
+ */
1253
+ export interface HazardCrossCheckExpectation {
1254
+ fixture: string;
1255
+ hazardClass: HazardClass;
1256
+ kind: "positive" | "negative" | "no-example";
1257
+ expectedAddresses: readonly number[];
1258
+ }
1259
+
1260
+ /**
1261
+ * The comparator's answer for one fixture, one class. An explicit
1262
+ * denominator, three counts, three sorted-ascending address arrays, and a
1263
+ * named positive class -- mirroring `Proof01Comparison`'s own shape exactly.
1264
+ * There is no boolean, no score, no rate and no percentage anywhere in this
1265
+ * type, and the comparator computes none at any point.
1266
+ *
1267
+ * For a `no-example` expectation, every count and the denominator are zero
1268
+ * and every address array is empty: a MEASUREMENT of "nothing to measure",
1269
+ * never silently absent -- the same distinction `HazardRegionOutcome`'s own
1270
+ * three-way split preserves one layer up.
1271
+ */
1272
+ export interface HazardCrossCheckResult {
1273
+ fixture: string;
1274
+ hazardClass: HazardClass;
1275
+ kind: "positive" | "negative" | "no-example";
1276
+ /** Always equal to `hazardClass` -- the class this row measures, named as
1277
+ * its own field so the shape mirrors `Proof01Comparison`'s own
1278
+ * `positiveClass` field rather than requiring a reader to infer it from
1279
+ * `hazardClass` alone. */
1280
+ positiveClass: HazardClass;
1281
+ /** `expectedAddresses.length` for a `positive` row, `0` for the other two
1282
+ * kinds. Never the image size and never a count copied from the report. */
1283
+ denominator: number;
1284
+ detected: number;
1285
+ missed: number;
1286
+ falsePositive: number;
1287
+ detectedAddresses: number[];
1288
+ missedAddresses: number[];
1289
+ falsePositiveAddresses: number[];
1290
+ }
1291
+
1292
+ /**
1293
+ * Joins one already-built report against one declared expectation for one
1294
+ * fixture, one hazard class. See this section's own header for why the
1295
+ * expectation itself is never edited to match what this function returns.
1296
+ *
1297
+ * The join is the same three-way branch `compareByteDerivedRecovery()`
1298
+ * already uses: walk the expected addresses first, partitioning into
1299
+ * detected and missed; then walk the reported addresses of this class,
1300
+ * adding any the expectation did not name to false-positive.
1301
+ */
1302
+ export function crossCheckHazardFixture(report: HazardReport, expectation: HazardCrossCheckExpectation): HazardCrossCheckResult {
1303
+ const { fixture, hazardClass, kind, expectedAddresses } = expectation;
1304
+
1305
+ if (kind === "no-example") {
1306
+ return {
1307
+ fixture,
1308
+ hazardClass,
1309
+ kind,
1310
+ positiveClass: hazardClass,
1311
+ denominator: 0,
1312
+ detected: 0,
1313
+ missed: 0,
1314
+ falsePositive: 0,
1315
+ detectedAddresses: [],
1316
+ missedAddresses: [],
1317
+ falsePositiveAddresses: [],
1318
+ };
1319
+ }
1320
+
1321
+ // Joined against `blockedAddress` ONLY, never `anchorAddress` too: the
1322
+ // expectation names the address that cannot move (the thing a positive
1323
+ // row's own comment derives from the fixture's bytes), and the anchor is
1324
+ // merely where the write that blocks it is issued from -- counting the
1325
+ // anchor as a second "reported address" would manufacture a false
1326
+ // positive out of every real detection whose anchor and blocked address
1327
+ // legitimately differ (an operand-byte hit, a table dispatch, a
1328
+ // register-pinned charset base). A finding with no blocked address (class
1329
+ // 4's structural signatures, an unresolved sprite-pointer target) names no
1330
+ // address at all and contributes nothing here, exactly like a `no-example`
1331
+ // expectation contributes nothing to any count.
1332
+ const reportedAddresses = new Set<number>();
1333
+ for (const finding of report.findings) {
1334
+ if (finding.hazardClass !== hazardClass) continue;
1335
+ if (finding.blockedAddress !== null) reportedAddresses.add(finding.blockedAddress);
1336
+ }
1337
+
1338
+ const expectedSet = new Set(expectedAddresses);
1339
+ const detectedAddresses: number[] = [];
1340
+ const missedAddresses: number[] = [];
1341
+ for (const address of expectedSet) {
1342
+ if (reportedAddresses.has(address)) detectedAddresses.push(address);
1343
+ else missedAddresses.push(address);
1344
+ }
1345
+ const falsePositiveAddresses: number[] = [];
1346
+ for (const address of reportedAddresses) {
1347
+ if (!expectedSet.has(address)) falsePositiveAddresses.push(address);
1348
+ }
1349
+
1350
+ const detected = sortAscendingNumbers(detectedAddresses);
1351
+ const missed = sortAscendingNumbers(missedAddresses);
1352
+ const falsePositive = sortAscendingNumbers(falsePositiveAddresses);
1353
+
1354
+ return {
1355
+ fixture,
1356
+ hazardClass,
1357
+ kind,
1358
+ positiveClass: hazardClass,
1359
+ denominator: expectedSet.size,
1360
+ detected: detected.length,
1361
+ missed: missed.length,
1362
+ falsePositive: falsePositive.length,
1363
+ detectedAddresses: detected,
1364
+ missedAddresses: missed,
1365
+ falsePositiveAddresses: falsePositive,
1366
+ };
1367
+ }