@henols/vice-mcp 0.2.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) 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 +1674 -99
  5. package/anno-enum-gen.ts +416 -30
  6. package/anno-export-asm.ts +1175 -89
  7. package/anno-graphics.ts +338 -0
  8. package/anno-hazard-report.ts +1367 -0
  9. package/anno-import.ts +495 -0
  10. package/anno-join.ts +480 -0
  11. package/anno-provenance-ledger.ts +472 -0
  12. package/anno-register.ts +159 -0
  13. package/anno-store-export.ts +661 -0
  14. package/anno-store.ts +518 -2
  15. package/anno-tools.ts +1169 -16
  16. package/anno-types.ts +275 -2
  17. package/backend-detect.mts +124 -312
  18. package/build.ts +3 -1
  19. package/capture-predicate.ts +597 -0
  20. package/channel-lock.ts +349 -0
  21. package/evid-ingest.ts +217 -0
  22. package/evid-reconcile.ts +316 -0
  23. package/host-tool-client.ts +430 -0
  24. package/incident-record.ts +23 -12
  25. package/install-resources.ts +29 -13
  26. package/memmap-lookup.ts +285 -0
  27. package/package.json +27 -8
  28. package/prg-image.ts +1 -2
  29. package/repo-root.ts +87 -3
  30. package/resources/backend-detect.mjs +98 -236
  31. package/resources/broker-control.mjs +189 -16
  32. package/resources/broker-epoch.mjs +1 -1
  33. package/resources/broker-kill.mjs +8 -2
  34. package/resources/broker-launch.mjs +365 -210
  35. package/resources/broker-state.mjs +64 -18
  36. package/resources/container-guard.mjs +1 -1
  37. package/resources/ghidra-project.mjs +790 -0
  38. package/resources/host-tool.mjs +2561 -0
  39. package/resources/vice-broker.mjs +330 -184
  40. package/resources/vice-launcher.sh +127 -9
  41. package/stock-address.ts +1 -1
  42. package/stock-condition.ts +1 -1
  43. package/stock-connect.ts +9 -5
  44. package/stock-derived.ts +29 -37
  45. package/stock-diagnose.ts +200 -36
  46. package/stock-dispatch.ts +179 -77
  47. package/stock-handler.ts +1 -1
  48. package/stock-paths.ts +18 -14
  49. package/stock-petscii.ts +1 -1
  50. package/stock-protocol.ts +1 -1
  51. package/stock-recycle.ts +83 -2
  52. package/stock-reproducible-run.ts +811 -0
  53. package/stock-run-until.ts +100 -1
  54. package/stock-symbols.ts +4 -4
  55. package/stock-timing.ts +1 -1
  56. package/stop-oracle.ts +167 -0
  57. package/text-capability-probe.ts +660 -0
  58. package/text-connect.ts +157 -0
  59. package/text-protocol.ts +810 -0
  60. package/text-tools.ts +778 -0
  61. package/textmon-backtrace.ts +385 -0
  62. package/textmon-cpuhistory.ts +335 -0
  63. package/textmon-memmap.ts +494 -0
  64. package/textmon-profile.ts +458 -0
  65. package/textmon-registers.ts +748 -0
  66. package/tools-manifest.stock.json +864 -3
  67. package/vice-broker-client.ts +189 -42
  68. package/vice-errors.ts +268 -0
  69. package/vice-proxy.ts +339 -2144
  70. package/vsf-slice.ts +640 -0
  71. package/anno-d64.ts +0 -310
  72. package/capability-registry.ts +0 -390
  73. package/refresh-manifest.ts +0 -124
  74. package/tools-manifest.json +0 -1223
  75. package/vice-probe.ts +0 -278
  76. package/vice-sync.ts +0 -336
  77. package/vice.ts +0 -772
@@ -0,0 +1,494 @@
1
+ // textmon-memmap.ts
2
+ //
3
+ // THE ONE owning module for VICE's `memmapshow` text-monitor output
4
+ // (PARSE-01, PARSE-03). Nothing else in this tree reads or interprets a
5
+ // `memmapshow` reply -- a handler dials the wire, this module is the only
6
+ // place that turns the framed text into structured data.
7
+ //
8
+ // WHY THIS FILE EXISTS RATHER THAN LIVING INSIDE THE TOOL HANDLER: the exact
9
+ // reasoning `disasm-decoder.ts` states for itself applies here unchanged --
10
+ // keeping the parser import-free of transport code means any future
11
+ // non-tool consumer (an offline diff over two captured maps, say) can depend
12
+ // on this one file without dragging in a socket. It is also PARSE-03's own
13
+ // requirement: an owning module that is pure by construction cannot be the
14
+ // thing that silently launders a wire error into a parse result, because it
15
+ // never touches the wire at all.
16
+ //
17
+ // WHAT NOT TO DO:
18
+ // - Never import anything -- not `node:` anything, not `text-protocol.ts`,
19
+ // not `textmon-fixtures.ts`, not even a type-only import of a sibling
20
+ // module. Purity is asserted mechanically by this file's own test
21
+ // (reads this module's source, greps for a top-level `import`) -- it is
22
+ // not a style preference to be relaxed later.
23
+ // - Never throw on a malformed or drifted input (D-42-3, decided once for
24
+ // all five text parsers this phase adds). Return the discriminated
25
+ // `AccessMapParseResult` instead. `withTextTool()`'s own `try/catch`
26
+ // converts anything THROWN into a transport-error message -- a thrown
27
+ // refusal would be reported to the caller as a WIRE failure, laundering
28
+ // a format-drift refusal into exactly the "absorbed into a
29
+ // plausible-looking wrong answer" failure mode PARSE-03 exists to
30
+ // prevent.
31
+ // - Never fold `execute` into `read`, default it from `read`, or treat the
32
+ // absence of a read glyph as evidence about execute. VICE emits
33
+ // `MEMMAP_*_X` as a bit distinct from `MEMMAP_*_R`/`_W` and prints each
34
+ // as its own glyph -- an entry with `--x` (execute recorded, no read) is
35
+ // real, measured output, not an edge case to normalize away.
36
+ // - Never treat an empty or whitespace-only payload as a zero-entry
37
+ // access map. Those are two different facts: "nothing was captured"
38
+ // and "the capture recorded no access anywhere" are not the same
39
+ // sentence, and collapsing them is exactly the "absence read as a
40
+ // claim" failure T-42-05 exists to close.
41
+ // - Never add a field, key, label or enum member anywhere in this module
42
+ // or its answer types that classifies an address as DATA on the
43
+ // strength of never having been observed. An address absent from
44
+ // `AccessMap.entries` is reported only as a count against
45
+ // `addressesQueried` in `accessMapRanges()`'s result -- never rendered,
46
+ // named, or implied as anything else.
47
+ // - Never add a second buffer or a second length cap here. The transport
48
+ // (`text-protocol.ts`'s `TEXT_MAX_BUFFERED_LEN`) is the one place that
49
+ // bound lives; this module receives an already-framed string and its
50
+ // own `maxRanges` budget bounds only what is EMITTED to a caller, never
51
+ // what is read.
52
+
53
+ /**
54
+ * One access-permission triple, exactly as `memmapshow` prints one column
55
+ * (IO, ROM or RAM) of a data line. `execute` is a field of its own, never
56
+ * derived from `read` -- see the module header's "WHAT NOT TO DO".
57
+ */
58
+ export interface AccessFlags {
59
+ readonly read: boolean;
60
+ readonly write: boolean;
61
+ readonly execute: boolean;
62
+ }
63
+
64
+ /**
65
+ * The closed set of parenthesised trailers VICE's `mon_memmap.c` can print
66
+ * after a data line's three glyph groups. `dummy` is the only one either
67
+ * committed real capture actually contains; `uninitialized-read` and
68
+ * `uninitialized-exec` are the fork build's remaining two arms (see this
69
+ * module's test file for where each is exercised from).
70
+ */
71
+ export type AccessAnnotation = "dummy" | "uninitialized-read" | "uninitialized-exec";
72
+
73
+ /** One decoded `memmapshow` data line. */
74
+ export interface AccessMapEntry {
75
+ readonly address: number;
76
+ readonly io: AccessFlags;
77
+ readonly rom: AccessFlags;
78
+ readonly ram: AccessFlags;
79
+ readonly annotations: readonly AccessAnnotation[];
80
+ }
81
+
82
+ /**
83
+ * The full decoded access map. `entries` is SPARSE by design -- VICE skips
84
+ * an address with no recorded access with a bare `continue` in its own
85
+ * print loop, so a shorter `entries` array is evidence, not an error, and
86
+ * this module never pads it back out to 65536 rows.
87
+ */
88
+ export interface AccessMap {
89
+ readonly entries: readonly AccessMapEntry[];
90
+ }
91
+
92
+ /**
93
+ * The closed refusal-code union (D-42-3). `malformed-line` covers a data
94
+ * line whose overall layout does not match (a non-hex-digit or
95
+ * wrong-length address field, a missing separator, a short or long glyph
96
+ * group) -- a STRUCTURAL mismatch, distinct from `unrecognised-glyph`,
97
+ * which covers a line that IS laid out correctly but carries a character
98
+ * this parser has never seen at a glyph position.
99
+ */
100
+ export type AccessMapRefusalCode =
101
+ | "empty-response"
102
+ | "missing-header"
103
+ | "no-data-lines"
104
+ | "malformed-line"
105
+ | "unrecognised-glyph"
106
+ | "unrecognised-annotation";
107
+
108
+ /**
109
+ * A parser refusal (D-42-3): returned, never thrown. `line` and
110
+ * `lineNumber` name the offending content whenever one exists -- for the
111
+ * two whole-payload refusals (`empty-response`, and `no-data-lines`'s
112
+ * absence of any content past the header) `lineNumber` points at the last
113
+ * real content seen (0 when there was none at all).
114
+ */
115
+ export interface TextParseRefusal {
116
+ readonly code: AccessMapRefusalCode;
117
+ readonly message: string;
118
+ readonly line: string;
119
+ readonly lineNumber: number;
120
+ }
121
+
122
+ /** D-42-3's discriminated shape: a parser never throws, it returns one of
123
+ * these two arms. */
124
+ export type AccessMapParseResult = { ok: true; value: AccessMap } | { ok: false; refusal: TextParseRefusal };
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // Framing constants -- this module's OWN copies, never imported from
128
+ // text-protocol.ts (purity rule above). `text-protocol.ts`'s `PROMPT_RE` is
129
+ // tail-anchored only and is applied by the transport to strip the TRAILING
130
+ // prompt before this module ever sees the string; the LEADING entry-echo
131
+ // prompt `memmapshow` alone emits is untouched by the transport and must be
132
+ // tolerated here.
133
+ // ---------------------------------------------------------------------------
134
+
135
+ const LEADING_PROMPT_RE = /^\(C:\$[0-9A-Fa-f]{4}\)\s*/;
136
+ const TRAILING_PROMPT_RE = /\(C:\$[0-9A-Fa-f]{4}\)\s*$/;
137
+
138
+ /** VICE's own header line, byte-for-byte -- two spaces after `IO`, one
139
+ * after `ROM`, no trailing space (`mon_memmap.c`'s own `mon_out()` call,
140
+ * confirmed against both committed real captures). */
141
+ const HEADER_LINE = "addr: IO ROM RAM";
142
+
143
+ /** A data line's overall layout: a 4-hex-digit address, `: `, then three
144
+ * space-separated 3-character glyph groups (IO, ROM, RAM, in that fixed
145
+ * order), then whatever trailer text follows (validated separately by
146
+ * `parseTrailer()`). A line that does not match this AT ALL is a
147
+ * STRUCTURAL malformation (`malformed-line`) -- the per-character glyph
148
+ * and per-suffix trailer checks below only run once this much has already
149
+ * matched. */
150
+ const DATA_LINE_RE = /^([0-9a-fA-F]{4}): (.{3}) (.{3}) (.{3})(.*)$/;
151
+
152
+ /** The closed, ordered set of recognised trailer suffixes. Order matters:
153
+ * `parseTrailer()` strips them in VICE's own emission order, per the plan's
154
+ * own framing note -- never a set membership test that would accept the
155
+ * three suffixes in any order. */
156
+ const ANNOTATION_SUFFIXES: ReadonlyArray<{ suffix: string; annotation: AccessAnnotation }> = [
157
+ { suffix: " (dummy)", annotation: "dummy" },
158
+ { suffix: " (uninitialized read)", annotation: "uninitialized-read" },
159
+ { suffix: " (uninitialized exec)", annotation: "uninitialized-exec" },
160
+ ];
161
+
162
+ function makeRefusal(code: AccessMapRefusalCode, message: string, line: string, lineNumber: number): TextParseRefusal {
163
+ return { code, message, line, lineNumber };
164
+ }
165
+
166
+ /** Decodes one 3-character glyph group. Recognised characters only:
167
+ * position 0 is `r` or `-`, position 1 is `w` or `-`, position 2 is `x` or
168
+ * `-` -- exact match, case-sensitive (VICE has never emitted an uppercase
169
+ * glyph; recognition is exact, not case-insensitive). The group's WIDTH is
170
+ * already guaranteed to be exactly 3 by `DATA_LINE_RE`'s own capture group,
171
+ * so only content is checked here. */
172
+ function parseGlyphGroup(group: string): { ok: true; flags: AccessFlags } | { ok: false; badChar: string } {
173
+ const r = group[0]!;
174
+ const w = group[1]!;
175
+ const x = group[2]!;
176
+ if (r !== "r" && r !== "-") return { ok: false, badChar: r };
177
+ if (w !== "w" && w !== "-") return { ok: false, badChar: w };
178
+ if (x !== "x" && x !== "-") return { ok: false, badChar: x };
179
+ return { ok: true, flags: { read: r === "r", write: w === "w", execute: x === "x" } };
180
+ }
181
+
182
+ /** Decodes the trailer following a data line's three glyph groups: zero or
183
+ * more of the three recognised suffixes, in VICE's own emission order
184
+ * (`ANNOTATION_SUFFIXES`'s own order). Any leftover text that is not a
185
+ * clean, ordered concatenation of recognised suffixes refuses. Bounded by
186
+ * construction -- at most three iterations over a fixed array, no
187
+ * recursion. */
188
+ function parseTrailer(trailer: string): { ok: true; annotations: AccessAnnotation[] } | { ok: false } {
189
+ let rest = trailer;
190
+ const annotations: AccessAnnotation[] = [];
191
+ for (const { suffix, annotation } of ANNOTATION_SUFFIXES) {
192
+ if (rest.startsWith(suffix)) {
193
+ annotations.push(annotation);
194
+ rest = rest.slice(suffix.length);
195
+ }
196
+ }
197
+ if (rest !== "") return { ok: false };
198
+ return { ok: true, annotations };
199
+ }
200
+
201
+ /**
202
+ * Parses `memmapshow`'s framed text-monitor reply into a structured
203
+ * {@link AccessMap}. Input is the string `TextMonitorClient.command()`
204
+ * resolves to, or a fixture's `text` -- both must parse identically (see
205
+ * the module header). Never throws (D-42-3): every failure mode returns
206
+ * `{ ok: false, refusal }` naming exactly what was wrong and where.
207
+ *
208
+ * Bounded by construction: one pass over the payload's lines, each
209
+ * iteration consumes exactly one line, no recursion anywhere in this
210
+ * function.
211
+ */
212
+ export function parseAccessMap(text: string): AccessMapParseResult {
213
+ if (typeof text !== "string" || text.trim() === "") {
214
+ return {
215
+ ok: false,
216
+ refusal: makeRefusal(
217
+ "empty-response",
218
+ "memmapshow: the response was empty or whitespace-only -- never decoded as a zero-entry access map, " +
219
+ "because an empty response and a real capture that recorded no access anywhere are two different facts",
220
+ "",
221
+ 0,
222
+ ),
223
+ };
224
+ }
225
+
226
+ let body = text;
227
+ const leadingMatch = LEADING_PROMPT_RE.exec(body);
228
+ if (leadingMatch) {
229
+ body = body.slice(leadingMatch[0]!.length);
230
+ }
231
+ body = body.replace(TRAILING_PROMPT_RE, "");
232
+
233
+ if (body.trim() === "") {
234
+ return {
235
+ ok: false,
236
+ refusal: makeRefusal(
237
+ "empty-response",
238
+ "memmapshow: the response contained only prompt text and no header -- never decoded as a zero-entry access map",
239
+ "",
240
+ 0,
241
+ ),
242
+ };
243
+ }
244
+
245
+ const lines = body.split("\n");
246
+ // A trailing "\n" before the (already-stripped) prompt splits into one
247
+ // trailing empty element -- drop exactly that split artifact, never any
248
+ // other blank line, so a genuinely blank line inside the payload still
249
+ // reaches the data-line parser below and refuses structurally rather
250
+ // than being silently swallowed here.
251
+ if (lines.length > 0 && lines[lines.length - 1] === "") {
252
+ lines.pop();
253
+ }
254
+
255
+ const headerLine = lines[0] ?? "";
256
+ if (headerLine !== HEADER_LINE) {
257
+ return {
258
+ ok: false,
259
+ refusal: makeRefusal(
260
+ "missing-header",
261
+ `memmapshow: expected the header line ${JSON.stringify(HEADER_LINE)}, found ${JSON.stringify(headerLine)}`,
262
+ headerLine,
263
+ 1,
264
+ ),
265
+ };
266
+ }
267
+
268
+ const dataLines = lines.slice(1);
269
+ if (dataLines.length === 0) {
270
+ return {
271
+ ok: false,
272
+ refusal: makeRefusal(
273
+ "no-data-lines",
274
+ "memmapshow: the header was present but no data lines followed it -- never decoded as a zero-entry access map",
275
+ headerLine,
276
+ 1,
277
+ ),
278
+ };
279
+ }
280
+
281
+ const entries: AccessMapEntry[] = [];
282
+
283
+ for (let i = 0; i < dataLines.length; i++) {
284
+ const line = dataLines[i]!;
285
+ const lineNumber = i + 2; // the header occupies line 1
286
+
287
+ const match = DATA_LINE_RE.exec(line);
288
+ if (!match) {
289
+ return {
290
+ ok: false,
291
+ refusal: makeRefusal(
292
+ "malformed-line",
293
+ `memmapshow: line ${lineNumber} does not match the expected "aaaa: xxx xxx xxx" layout: ${JSON.stringify(line)}`,
294
+ line,
295
+ lineNumber,
296
+ ),
297
+ };
298
+ }
299
+ const addrHex = match[1]!;
300
+ const ioStr = match[2]!;
301
+ const romStr = match[3]!;
302
+ const ramStr = match[4]!;
303
+ const trailer = match[5] ?? "";
304
+
305
+ const ioResult = parseGlyphGroup(ioStr);
306
+ if (!ioResult.ok) {
307
+ return {
308
+ ok: false,
309
+ refusal: makeRefusal(
310
+ "unrecognised-glyph",
311
+ `memmapshow: line ${lineNumber} has an unrecognised IO glyph character ${JSON.stringify(ioResult.badChar)}: ${JSON.stringify(line)}`,
312
+ line,
313
+ lineNumber,
314
+ ),
315
+ };
316
+ }
317
+ const romResult = parseGlyphGroup(romStr);
318
+ if (!romResult.ok) {
319
+ return {
320
+ ok: false,
321
+ refusal: makeRefusal(
322
+ "unrecognised-glyph",
323
+ `memmapshow: line ${lineNumber} has an unrecognised ROM glyph character ${JSON.stringify(romResult.badChar)}: ${JSON.stringify(line)}`,
324
+ line,
325
+ lineNumber,
326
+ ),
327
+ };
328
+ }
329
+ const ramResult = parseGlyphGroup(ramStr);
330
+ if (!ramResult.ok) {
331
+ return {
332
+ ok: false,
333
+ refusal: makeRefusal(
334
+ "unrecognised-glyph",
335
+ `memmapshow: line ${lineNumber} has an unrecognised RAM glyph character ${JSON.stringify(ramResult.badChar)}: ${JSON.stringify(line)}`,
336
+ line,
337
+ lineNumber,
338
+ ),
339
+ };
340
+ }
341
+
342
+ const trailerResult = parseTrailer(trailer);
343
+ if (!trailerResult.ok) {
344
+ return {
345
+ ok: false,
346
+ refusal: makeRefusal(
347
+ "unrecognised-annotation",
348
+ `memmapshow: line ${lineNumber} has an unrecognised trailer ${JSON.stringify(trailer)}: ${JSON.stringify(line)}`,
349
+ line,
350
+ lineNumber,
351
+ ),
352
+ };
353
+ }
354
+
355
+ entries.push({
356
+ address: parseInt(addrHex, 16),
357
+ io: ioResult.flags,
358
+ rom: romResult.flags,
359
+ ram: ramResult.flags,
360
+ annotations: trailerResult.annotations,
361
+ });
362
+ }
363
+
364
+ return { ok: true, value: { entries } };
365
+ }
366
+
367
+ // ---------------------------------------------------------------------------
368
+ // accessMapRanges() -- the adjacency-merged, budgeted range projection.
369
+ // ---------------------------------------------------------------------------
370
+
371
+ export interface AccessMapRangesOptions {
372
+ /** Inclusive lower bound, 0-65535. Defaults to 0. */
373
+ startAddress?: number;
374
+ /** Inclusive upper bound, 0-65535. Defaults to 65535. */
375
+ endAddress?: number;
376
+ /** Hard ceiling on emitted ranges. Defaults to 256, clamped to a hard
377
+ * ceiling of 4096 regardless of what is requested. */
378
+ maxRanges?: number;
379
+ }
380
+
381
+ /** One merged, adjacency-collapsed run of addresses sharing identical
382
+ * access flags and annotation sets. */
383
+ export interface AccessMapRange {
384
+ readonly start: number;
385
+ readonly end: number;
386
+ readonly io: AccessFlags;
387
+ readonly rom: AccessFlags;
388
+ readonly ram: AccessFlags;
389
+ readonly annotations: readonly AccessAnnotation[];
390
+ }
391
+
392
+ export interface AccessMapRangesResult {
393
+ readonly ranges: readonly AccessMapRange[];
394
+ /** The full merged range count, BEFORE the `maxRanges` budget is applied
395
+ * -- always equal to `ranges.length` when `truncated` is false. */
396
+ readonly rangeCount: number;
397
+ /** True when `rangeCount` exceeds the effective `maxRanges` budget. */
398
+ readonly truncated: boolean;
399
+ /** How many addresses in `[startAddress, endAddress]` actually appear in
400
+ * `map.entries` -- the numerator every count in this result is a
401
+ * fraction of. */
402
+ readonly addressesWithRecordedAccess: number;
403
+ /** `endAddress - startAddress + 1` (0 if the range is empty/inverted) --
404
+ * the denominator `addressesWithRecordedAccess` is a fraction of. An
405
+ * address counted here but absent from `addressesWithRecordedAccess`
406
+ * proves nothing about that address; it is never reported as data. */
407
+ readonly addressesQueried: number;
408
+ }
409
+
410
+ const DEFAULT_MAX_RANGES = 256;
411
+ const MAX_RANGES_CEILING = 4096;
412
+
413
+ function isValidAddress(value: unknown): value is number {
414
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && value <= 0xffff;
415
+ }
416
+
417
+ function isValidMaxRanges(value: unknown): value is number {
418
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 1;
419
+ }
420
+
421
+ function sameFlags(a: AccessFlags, b: AccessFlags): boolean {
422
+ return a.read === b.read && a.write === b.write && a.execute === b.execute;
423
+ }
424
+
425
+ function sameAnnotationSet(a: readonly AccessAnnotation[], b: readonly AccessAnnotation[]): boolean {
426
+ if (a.length !== b.length) return false;
427
+ const sortedA = [...a].sort();
428
+ const sortedB = [...b].sort();
429
+ return sortedA.every((value, index) => value === sortedB[index]);
430
+ }
431
+
432
+ /**
433
+ * Walks `map.entries` (already in address order -- `parseAccessMap()`
434
+ * always emits them that way, but this function re-sorts defensively
435
+ * rather than trusting the caller) and merges a run of ADJACENT addresses
436
+ * (address n+1 immediately following n) whose nine access booleans and
437
+ * whose annotation set are all identical into one range. A single
438
+ * differing bit, a differing annotation set, or an address gap starts a
439
+ * new range. Bounded by construction: one pass over `map.entries`, no
440
+ * recursion.
441
+ *
442
+ * There is no field, key or label anywhere in this function's return value
443
+ * that classifies an address as data -- an address absent from
444
+ * `map.entries` is reported only through the gap between
445
+ * `addressesWithRecordedAccess` and `addressesQueried` (see this module's
446
+ * header comment).
447
+ */
448
+ export function accessMapRanges(map: AccessMap, opts: AccessMapRangesOptions = {}): AccessMapRangesResult {
449
+ const options = opts && typeof opts === "object" ? opts : {};
450
+ const startAddress = isValidAddress(options.startAddress) ? options.startAddress : 0;
451
+ const endAddress = isValidAddress(options.endAddress) ? options.endAddress : 0xffff;
452
+ const requestedMaxRanges = isValidMaxRanges(options.maxRanges) ? options.maxRanges : DEFAULT_MAX_RANGES;
453
+ const maxRanges = Math.min(requestedMaxRanges, MAX_RANGES_CEILING);
454
+
455
+ const sorted = [...map.entries].sort((a, b) => a.address - b.address);
456
+ const filtered = sorted.filter((entry) => entry.address >= startAddress && entry.address <= endAddress);
457
+
458
+ const merged: AccessMapRange[] = [];
459
+ for (const entry of filtered) {
460
+ const last = merged[merged.length - 1];
461
+ if (
462
+ last !== undefined &&
463
+ last.end + 1 === entry.address &&
464
+ sameFlags(last.io, entry.io) &&
465
+ sameFlags(last.rom, entry.rom) &&
466
+ sameFlags(last.ram, entry.ram) &&
467
+ sameAnnotationSet(last.annotations, entry.annotations)
468
+ ) {
469
+ merged[merged.length - 1] = { ...last, end: entry.address };
470
+ } else {
471
+ merged.push({
472
+ start: entry.address,
473
+ end: entry.address,
474
+ io: entry.io,
475
+ rom: entry.rom,
476
+ ram: entry.ram,
477
+ annotations: entry.annotations,
478
+ });
479
+ }
480
+ }
481
+
482
+ const rangeCount = merged.length;
483
+ const truncated = rangeCount > maxRanges;
484
+ const ranges = truncated ? merged.slice(0, maxRanges) : merged;
485
+ const addressesQueried = endAddress >= startAddress ? endAddress - startAddress + 1 : 0;
486
+
487
+ return {
488
+ ranges,
489
+ rangeCount,
490
+ truncated,
491
+ addressesWithRecordedAccess: filtered.length,
492
+ addressesQueried,
493
+ };
494
+ }