@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.
- package/README.md +2 -2
- package/THIRD-PARTY-NOTICES.md +422 -1
- package/anno-bank.ts +171 -0
- package/anno-cli.ts +1736 -163
- package/anno-confidence.ts +2 -2
- package/anno-derive.ts +6 -6
- package/anno-details.ts +4 -4
- package/anno-enum-gen.ts +416 -30
- package/anno-export-asm.ts +1211 -126
- package/anno-graphics.ts +338 -0
- package/anno-hazard-report.ts +1367 -0
- package/anno-import.ts +495 -0
- package/anno-index.ts +8 -8
- package/anno-join.ts +480 -0
- package/anno-memmap-render.ts +22 -21
- package/anno-provenance-ledger.ts +472 -0
- package/anno-regbits-gen.ts +13 -13
- package/anno-register.ts +159 -0
- package/anno-store-export.ts +661 -0
- package/anno-store.ts +635 -124
- package/anno-symbols.ts +7 -7
- package/anno-tools.ts +1169 -16
- package/anno-types.ts +313 -40
- package/backend-detect.mts +124 -312
- package/build.ts +3 -1
- package/capture-predicate.ts +597 -0
- package/channel-lock.ts +349 -0
- package/evid-ingest.ts +217 -0
- package/evid-reconcile.ts +316 -0
- package/host-tool-client.ts +430 -0
- package/incident-record.ts +23 -12
- package/install-resources.ts +29 -13
- package/memmap-lookup.ts +285 -0
- package/package.json +27 -8
- package/prg-image.ts +1 -2
- package/repo-root.ts +87 -3
- package/resources/backend-detect.mjs +98 -236
- package/resources/broker-control.mjs +220 -54
- package/resources/broker-epoch.mjs +7 -8
- package/resources/broker-kill.mjs +36 -31
- package/resources/broker-launch.mjs +511 -374
- package/resources/broker-state.mjs +69 -24
- package/resources/container-guard.mjs +1 -1
- package/resources/ghidra-project.mjs +790 -0
- package/resources/host-tool.mjs +2533 -0
- package/resources/vice-broker.mjs +434 -290
- package/resources/vice-launcher.sh +127 -9
- package/stock-address.ts +1 -1
- package/stock-condition.ts +1 -1
- package/stock-connect.ts +9 -5
- package/stock-derived.ts +29 -37
- package/stock-diagnose.ts +200 -36
- package/stock-dispatch.ts +179 -77
- package/stock-handler.ts +1 -1
- package/stock-paths.ts +18 -14
- package/stock-petscii.ts +1 -1
- package/stock-protocol.ts +1 -1
- package/stock-recycle.ts +83 -2
- package/stock-reproducible-run.ts +811 -0
- package/stock-run-until.ts +100 -1
- package/stock-symbols.ts +4 -4
- package/stock-timing.ts +1 -1
- package/stop-oracle.ts +167 -0
- package/text-capability-probe.ts +660 -0
- package/text-connect.ts +157 -0
- package/text-protocol.ts +810 -0
- package/text-tools.ts +778 -0
- package/textmon-backtrace.ts +385 -0
- package/textmon-cpuhistory.ts +335 -0
- package/textmon-memmap.ts +494 -0
- package/textmon-profile.ts +458 -0
- package/textmon-registers.ts +748 -0
- package/tools-manifest.stock.json +864 -3
- package/vice-broker-client.ts +253 -108
- package/vice-errors.ts +268 -0
- package/vice-proxy.ts +339 -2144
- package/vsf-slice.ts +640 -0
- package/anno-d64.ts +0 -310
- package/capability-registry.ts +0 -390
- package/refresh-manifest.ts +0 -124
- package/tools-manifest.json +0 -1223
- package/vice-probe.ts +0 -278
- package/vice-sync.ts +0 -336
- 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
|
+
}
|