@henols/vice-mcp 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +2 -2
  2. package/THIRD-PARTY-NOTICES.md +422 -1
  3. package/anno-bank.ts +171 -0
  4. package/anno-cli.ts +1736 -163
  5. package/anno-confidence.ts +2 -2
  6. package/anno-derive.ts +6 -6
  7. package/anno-details.ts +4 -4
  8. package/anno-enum-gen.ts +416 -30
  9. package/anno-export-asm.ts +1211 -126
  10. package/anno-graphics.ts +338 -0
  11. package/anno-hazard-report.ts +1367 -0
  12. package/anno-import.ts +495 -0
  13. package/anno-index.ts +8 -8
  14. package/anno-join.ts +480 -0
  15. package/anno-memmap-render.ts +22 -21
  16. package/anno-provenance-ledger.ts +472 -0
  17. package/anno-regbits-gen.ts +13 -13
  18. package/anno-register.ts +159 -0
  19. package/anno-store-export.ts +661 -0
  20. package/anno-store.ts +635 -124
  21. package/anno-symbols.ts +7 -7
  22. package/anno-tools.ts +1169 -16
  23. package/anno-types.ts +313 -40
  24. package/backend-detect.mts +124 -312
  25. package/build.ts +3 -1
  26. package/capture-predicate.ts +597 -0
  27. package/channel-lock.ts +349 -0
  28. package/evid-ingest.ts +217 -0
  29. package/evid-reconcile.ts +316 -0
  30. package/host-tool-client.ts +430 -0
  31. package/incident-record.ts +23 -12
  32. package/install-resources.ts +29 -13
  33. package/memmap-lookup.ts +285 -0
  34. package/package.json +27 -8
  35. package/prg-image.ts +1 -2
  36. package/repo-root.ts +87 -3
  37. package/resources/backend-detect.mjs +98 -236
  38. package/resources/broker-control.mjs +220 -54
  39. package/resources/broker-epoch.mjs +7 -8
  40. package/resources/broker-kill.mjs +36 -31
  41. package/resources/broker-launch.mjs +511 -374
  42. package/resources/broker-state.mjs +69 -24
  43. package/resources/container-guard.mjs +1 -1
  44. package/resources/ghidra-project.mjs +790 -0
  45. package/resources/host-tool.mjs +2533 -0
  46. package/resources/vice-broker.mjs +434 -290
  47. package/resources/vice-launcher.sh +127 -9
  48. package/stock-address.ts +1 -1
  49. package/stock-condition.ts +1 -1
  50. package/stock-connect.ts +9 -5
  51. package/stock-derived.ts +29 -37
  52. package/stock-diagnose.ts +200 -36
  53. package/stock-dispatch.ts +179 -77
  54. package/stock-handler.ts +1 -1
  55. package/stock-paths.ts +18 -14
  56. package/stock-petscii.ts +1 -1
  57. package/stock-protocol.ts +1 -1
  58. package/stock-recycle.ts +83 -2
  59. package/stock-reproducible-run.ts +811 -0
  60. package/stock-run-until.ts +100 -1
  61. package/stock-symbols.ts +4 -4
  62. package/stock-timing.ts +1 -1
  63. package/stop-oracle.ts +167 -0
  64. package/text-capability-probe.ts +660 -0
  65. package/text-connect.ts +157 -0
  66. package/text-protocol.ts +810 -0
  67. package/text-tools.ts +778 -0
  68. package/textmon-backtrace.ts +385 -0
  69. package/textmon-cpuhistory.ts +335 -0
  70. package/textmon-memmap.ts +494 -0
  71. package/textmon-profile.ts +458 -0
  72. package/textmon-registers.ts +748 -0
  73. package/tools-manifest.stock.json +864 -3
  74. package/vice-broker-client.ts +253 -108
  75. package/vice-errors.ts +268 -0
  76. package/vice-proxy.ts +339 -2144
  77. package/vsf-slice.ts +640 -0
  78. package/anno-d64.ts +0 -310
  79. package/capability-registry.ts +0 -390
  80. package/refresh-manifest.ts +0 -124
  81. package/tools-manifest.json +0 -1223
  82. package/vice-probe.ts +0 -278
  83. package/vice-sync.ts +0 -336
  84. package/vice.ts +0 -772
@@ -1,17 +1,17 @@
1
1
  // anno-export-asm.ts -- the ONE place annotation-store rows plus image bytes
2
- // become ACME source text (EXPORT-01).
2
+ // become ACME source text.
3
3
  //
4
4
  // ---------------------------------------------------------------------------
5
5
  // WHY THIS FILE EXISTS
6
6
  // ---------------------------------------------------------------------------
7
- // The previous export route was WITHDRAWN in Phase 29 rather than left
7
+ // The previous export route was WITHDRAWN rather than left
8
8
  // standing, because it made a reassembly claim nothing verified: it produced
9
9
  // something that looked like ACME source and asserted, in effect, that
10
10
  // assembling it would reproduce the program. No assembler ever ran. Withdrawing
11
11
  // it was the right call and the withdrawal notices in both skill trees are the
12
12
  // record that the capability was missing.
13
13
  //
14
- // This module is the rebuild, over the Phase 28 annotation store, and it is
14
+ // This module is the rebuild, over this project's own annotation store, and it is
15
15
  // allowed to exist only because the claim is now settled somewhere else: a real
16
16
  // ACME 0.97 assembles this module's output and the resulting bytes are diffed
17
17
  // against the IMAGE bytes. Nothing in this file verifies this file. Re-reading,
@@ -36,7 +36,7 @@
36
36
  // against a real assembler across all 256 opcodes. Decoding here happens
37
37
  // through `decode()` and nowhere else.
38
38
  // - Never write a second `!byte` / `+2` / hex emitter. `disasm-renderer.ts`
39
- // owns D-09's `!byte` substitution and D-11's width invariant, both
39
+ // owns the `!byte` substitution rule and the operand-width invariant, both
40
40
  // verified against real ACME. A second emitter would be a second answer to
41
41
  // "how wide is this operand", and the two would drift silently.
42
42
  // - Never restate the eleven auto-name prefixes here. `anno-types.ts:93-99`
@@ -53,13 +53,17 @@
53
53
  // so the substitution changes both the bytes and the instruction length. A
54
54
  // non-immediate operand is REFUSED by name below, never rendered and hoped
55
55
  // for.
56
- // - Never compare a `dataType` string in this module beyond the TWO places
57
- // that already do, each of which says so in its own comment:
58
- // `CODE_DATA_TYPE`'s decoder-or-dump branch, and `WORD_PAIR_DATA_TYPES`'s
59
- // `!word` eligibility check. Both are questions about the emitted TEXT.
60
- // `block-class.ts` is the one place in this tree allowed to INTERPRET that
61
- // column -- what the data means -- and everywhere else here the string is
62
- // copied VERBATIM onto the emitted block and its trailing comment.
56
+ // - Never compare a `dataType` string in this module beyond the FOUR
57
+ // places that already do, each of which says so in its own comment:
58
+ // `CODE_DATA_TYPE`'s decoder-or-dump branch, `WORD_PAIR_DATA_TYPES`'s
59
+ // `!word` eligibility check,
60
+ // `EXTERNAL_FILE_DATA_TYPE`'s `!binary`-versus-inline branch inside
61
+ // `emitDataLines()`, and `isSplitAddressDataType()`'s
62
+ // paired-symbol-versus-raw-byte branch in the block loop. All four are
63
+ // questions about the emitted TEXT. `block-class.ts` is the one place in
64
+ // this tree allowed to INTERPRET that column -- what the data means -- and
65
+ // everywhere else here the string is copied VERBATIM onto the emitted
66
+ // block and its trailing comment.
63
67
  // - Never import this tree's host/container path-translation modules
64
68
  // (`hostpath.ts` / `containerpath.ts`). Their consumer set is a closed,
65
69
  // mechanically asserted list of named modules and an exporter has no reason
@@ -68,7 +72,7 @@
68
72
  // - Never interpolate a read file's own bytes into an error message. A path,
69
73
  // an address and a length are facts ABOUT a file; its contents are not, and
70
74
  // an error text that quotes them turns a refusal into a content-disclosure
71
- // oracle (CR-03). Every throw below carries paths, addresses and counts and
75
+ // oracle. Every throw below carries paths, addresses and counts and
72
76
  // nothing read out of the image or the store.
73
77
  // - Never sanitise a label name. `assertLegalAcmeIdentifier()`'s contract is
74
78
  // REJECT: a space-to-underscore substitution silently merges two distinct
@@ -87,12 +91,12 @@
87
91
  // SCOPE, STILL DELIBERATELY NARROW: code ranges, the twelve typed data ranges,
88
92
  // comments, mid-instruction inline labels and immediate-operand enum
89
93
  // substitution.
90
- import { readFileSync } from "node:fs";
91
- import { extname } from "node:path";
94
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
95
+ import { extname, join } from "node:path";
92
96
 
93
- import { openStore, closeStore, listRanges, listLabels, listComments, listProjectEnums, listEnumUsage } from "./anno-store.ts";
94
- import { AnnoCommentError, COMMENT_TYPES, DATA_TYPES, assertCommentText, assertDataType, parseVariantKey } from "./anno-types.ts";
95
- import type { CommentRow, DataType, EnumUsageRow, LabelRow, ProjectEnumRow, RangeRow } from "./anno-types.ts";
97
+ import { openStore, closeStore, listRanges, listLabels, listComments, listProjectEnums, listEnumUsage, listExcludedRanges, listScopes } from "./anno-store.ts";
98
+ import { AnnoCommentError, COMMENT_TYPES, DATA_TYPES, assertCommentText, assertDataType, isSplitDataType, parseVariantKey } from "./anno-types.ts";
99
+ import type { CommentRow, DataType, EnumUsageRow, ExcludedRangeRow, LabelRow, ProjectEnumRow, RangeRow, ScopeRow } from "./anno-types.ts";
96
100
  import { assertLegalAcmeIdentifier } from "./anno-acme-ident.ts";
97
101
  // The eleven typed auto-name prefixes, IMPORTED FROM THEIR ONE HOME rather than
98
102
  // restated. This is the first cross-module PRODUCTION importer of that
@@ -110,12 +114,24 @@ import { assertLegalAcmeIdentifier } from "./anno-acme-ident.ts";
110
114
  //
111
115
  // WHAT NOT TO DO: do not restate the eleven here, in any form -- not as an
112
116
  // array, not as a second regex, not as a doc comment listing them.
113
- // `anno-types.ts:93-99` forbids it by name, and `EXPORT-02` names the failure
114
- // mode: a five-prefix copy under-counts silently.
117
+ // `anno-types.ts:93-99` forbids it by name: a five-prefix copy under-counts silently.
115
118
  import { AUTO_NAME_PREFIX_RE } from "./anno-coverage.ts";
119
+ // THE ONE OWNING DECODER. This module decodes NOTHING
120
+ // itself -- `decomposeRegisterValue()` is the ONLY place a register value is
121
+ // split into named bit-fields, and `anno_disassemble` (the other renderer sharing
122
+ // this decoder) calls the SAME function. Never re-derive a per-field bit
123
+ // mask in this file -- a phase-45 verification gate greps this file's own
124
+ // text for that shape and must find none.
125
+ import { decomposeRegisterValue, hasRegBitsEntry, type RegisterDecomposition } from "./anno-enum-gen.ts";
116
126
  import { decode } from "./disasm-decoder.ts";
127
+ import type { Instruction } from "./disasm-decoder.ts";
117
128
  import { renderLine } from "./disasm-renderer.ts";
118
129
  import { parsePrg, flatImageOrigin } from "./prg-image.ts";
130
+ // the ONE reader of `recovery/PROVENANCE.md`'s
131
+ // generated tier. This module never recomputes a verdict -- see
132
+ // `anno-provenance-ledger.ts`'s own header for why reading and recomputing
133
+ // are deliberately kept apart.
134
+ import { provenanceForRange, readProvenanceLedger, type ProvenanceLedger } from "./anno-provenance-ledger.ts";
119
135
 
120
136
  /** The store's own spelling for an executable range, read out of the one home
121
137
  * of that vocabulary rather than re-typed as a literal. `dataType` is never
@@ -124,6 +140,18 @@ import { parsePrg, flatImageOrigin } from "./prg-image.ts";
124
140
  * a decoder or dump bytes, and it is the only one. */
125
141
  const CODE_DATA_TYPE = "code";
126
142
 
143
+ /** The store's own spelling for a large binary blob exported AS ITS OWN
144
+ * FILE rather than inline (`anno_set_data_type`'s
145
+ * schema calls this "large binary blob to export as-is") -- the eleventh of
146
+ * `DATA_TYPES`' twelve members, the one vocabulary this module imports
147
+ * rather than restates. The SAME discipline `CODE_DATA_TYPE` above already
148
+ * follows: one named constant carries the literal ONCE, so every comparison
149
+ * below reads the NAME and this is the only place the spelling itself is
150
+ * written down. `dataType` is never COMPARED anywhere else in this module
151
+ * for this purpose -- `emitDataLines()`'s `!binary`-versus-inline branch is
152
+ * the only one. */
153
+ const EXTERNAL_FILE_DATA_TYPE = "external_file";
154
+
127
155
  /** One block as this module emitted it. */
128
156
  export interface ExportBlock {
129
157
  /** First address the block covers. */
@@ -138,6 +166,39 @@ export interface ExportBlock {
138
166
  /** How many CONTENT lines the block emitted -- not its `* =` origin line and
139
167
  * not the two `!if * != ...` assertions that bracket it. */
140
168
  lineCount: number;
169
+ /** exactly what `emitBlock()` returned for this block
170
+ * -- the `* =` origin line, both `!if * != ...` bracket assertions, and
171
+ * every content line between them, in emitted order. This is FOR a tree
172
+ * writer to partition an emission that is already proven, rather than a
173
+ * second description of the block: `exportAsmTree()` writes each scope's
174
+ * `.a` file by concatenating its contained blocks' `lines` verbatim, never
175
+ * by re-deriving what a block looks like from `dataType`/`start`/
176
+ * `endExclusive` a second time. Populated by `exportAsm()` at the same
177
+ * point it calls `emitBlock()`; empty only before that call runs. */
178
+ lines: string[];
179
+ }
180
+
181
+ /**
182
+ * one `external_file`-typed block's own
183
+ * `.bin` sibling. `bytes` is the block's own slice of the IMAGE, carried
184
+ * verbatim -- never a copy that passed through any conversion, on the same
185
+ * terms `expectedBytes` is derived from the image and never from `source`.
186
+ *
187
+ * A caller that writes `source` (or a tree's `.a` files) and not these
188
+ * bytes to their named files has written a source tree that names files
189
+ * nobody produced: every `!binary "..."` line `emitDataLines()` emits names
190
+ * exactly one of these entries, and `exportAsmTree()`'s whole tree-writing
191
+ * contract exists because of that obligation.
192
+ */
193
+ export interface ExportBinary {
194
+ /** The bare file name the emitted `!binary` line names -- `binaryFileName(start)`, never a store-supplied string. */
195
+ name: string;
196
+ /** First address the block covers -- same field as `ExportBlock.start`. */
197
+ start: number;
198
+ /** One past the last address the block covers -- same field as `ExportBlock.endExclusive`. */
199
+ endExclusive: number;
200
+ /** The block's own slice of the image, verbatim. `bytes.length === endExclusive - start`. */
201
+ bytes: Uint8Array;
141
202
  }
142
203
 
143
204
  export interface ExportAsmOptions {
@@ -161,11 +222,36 @@ export interface ExportAsmOptions {
161
222
  * caller's argument pointed -- and an export of a store this call just
162
223
  * invented would read as "the program has no annotations". */
163
224
  workspaceRoot: string;
225
+ /**
226
+ * The provenance ledger to annotate every emitted block from -- `c64-
227
+ * provenance-diff`'s generated `recovery/PROVENANCE.md`, read (never
228
+ * re-derived) through `readProvenanceLedger()`. OPTIONAL: every existing
229
+ * caller that omits it keeps exporting
230
+ * exactly as before, byte for byte and comment for comment. NOTHING
231
+ * CONFINES THIS PATH INSIDE THIS MODULE, on the same terms as `imagePath`
232
+ * above -- the CALLER owns its confinement. Supplying it and having the
233
+ * file be unreadable, malformed, or missing a row for some block is a
234
+ * refusal; omitting it is not.
235
+ */
236
+ ledgerPath?: string;
164
237
  }
165
238
 
166
239
  export interface ExportAsmResult {
167
240
  /** The ACME source text. */
168
241
  source: string;
242
+ /** the symbol-definition block VERBATIM -- every
243
+ * enum-variant definition line followed by every store label's own
244
+ * definition line, in exactly the order `source` carries them. This is FOR
245
+ * `exportAsmTree()` to write `symbols.a` from, never re-derived: `source`
246
+ * is still `["!cpu 6510", ...headerLines, ...blockLines].join("\n")` plus
247
+ * the trailing newline, byte for byte unchanged by this field's addition. */
248
+ headerLines: string[];
249
+ /** every scope the store holds, read by `listScopes()`
250
+ * as a SEVENTH call inside this function's existing store handle -- there
251
+ * is still exactly one handle opened for the whole export. `exportAsmTree()`
252
+ * uses this to decide which scope (or none) each block belongs to; nothing
253
+ * in `exportAsm()` itself reads a scope's span to change what it emits. */
254
+ scopes: ScopeRow[];
169
255
  /** The bytes `source` must assemble to, DERIVED FROM THE IMAGE and never
170
256
  * from `source`. Spans `[minStart, maxEndExclusive)` across every emitted
171
257
  * block, with `$00` filling the gaps between them -- which is exactly what
@@ -180,7 +266,7 @@ export interface ExportAsmResult {
180
266
  * i.e. `sortedLabels.length`, one per `anno_label` row in range.
181
267
  *
182
268
  * THIS DOC USED TO SAY "how many symbol definitions the header carries",
183
- * AND THAT WAS NOT WHAT IT COUNTED (30-REVIEW WR-01, corrected 2026-08-31).
269
+ * AND THAT WAS NOT WHAT IT COUNTED (corrected 2026-08-31).
184
270
  * The two readings diverge in BOTH directions: a mid-instruction label is
185
271
  * defined inline and skipped by the header loop yet still counted here,
186
272
  * and every `enumDefinitionLines` entry IS a header definition yet is not.
@@ -196,7 +282,7 @@ export interface ExportAsmResult {
196
282
  /** How many definition lines the HEADER block actually carries -- enum
197
283
  * variant definitions plus every store label NOT defined inline. Computed
198
284
  * from `headerLines` itself, so it cannot drift from the emitted text the
199
- * way a separately-maintained count did (30-REVIEW WR-01). */
285
+ * way a separately-maintained count did. */
200
286
  headerDefinitionCount: number;
201
287
  /** How many decoded instructions ACME's `!cpu 6510` cannot express, and
202
288
  * which therefore went out as `!byte` directives with their mnemonic moved
@@ -206,6 +292,15 @@ export interface ExportAsmResult {
206
292
  * block whose `dataType` is not `code`. A code block contributes nothing
207
293
  * here, however many bytes it decoded. */
208
294
  dataByteCount: number;
295
+ /** every `external_file`-typed block's
296
+ * own sibling data file, ascending by `start`. `dataByteCount` above still
297
+ * counts these bytes -- they went out through the data path exactly as an
298
+ * inline `!byte`-typed range's bytes would have, only to a different
299
+ * destination file, and a count whose name stops matching what it counts
300
+ * is the same lesson this module already learned once. See
301
+ * `ExportBinary`'s own doc-comment for the obligation a caller that reads
302
+ * this field takes on. */
303
+ binaries: ExportBinary[];
209
304
  /** How many store comments the source carries. Always the store's FULL
210
305
  * comment count when this function returns: a comment with no emitted line
211
306
  * to attach to is refused by name rather than left out of this number. */
@@ -217,8 +312,8 @@ export interface ExportAsmResult {
217
312
  * header definition block, because such a label is defined inline and
218
313
  * defining it twice is ACME's `Symbol already defined.`
219
314
  *
220
- * COUNTED PER EMITTED DEFINITION, NOT PER ADDRESS (30-REVIEW WR-02,
221
- * corrected 2026-08-31). It used to be `midInstructionLabelAddresses.size`,
315
+ * COUNTED PER EMITTED DEFINITION, NOT PER ADDRESS (corrected 2026-08-31).
316
+ * It used to be `midInstructionLabelAddresses.size`,
222
317
  * a set of ADDRESSES, while the inline loop emits one line per LABEL.
223
318
  * `anno_label` is `unique` on `name` only and `setLabel()` refuses only a
224
319
  * name already bound to a DIFFERENT address, so two names at one address is
@@ -233,8 +328,31 @@ export interface ExportAsmResult {
233
328
  autoNamedSymbolCount: number;
234
329
  /** How many instruction operands were rendered through a project enum's
235
330
  * variant name instead of a hex literal. Every one of them is an IMMEDIATE
236
- * operand; any other operand role is refused. */
331
+ * operand; any other operand role is refused. INCLUDES decomposed writes
332
+ * (`enumDecompositionCount` below is a SUBSET of this, never added to it --
333
+ * two named figures, never one combined figure). */
237
334
  enumSubstitutionCount: number;
335
+ /** How many of `enumSubstitutionCount`'s substitutions were rendered as an
336
+ * OR-ed multi-bit decomposition rather than a single whole-value
337
+ * variant name -- one register key `regbits` entry with two or more
338
+ * fields, decoded through `decomposeRegisterValue()`. A single-field
339
+ * register, or an enum usage whose name is not a register key at all,
340
+ * counts toward `enumSubstitutionCount` only, never here. */
341
+ enumDecompositionCount: number;
342
+ /** How many RECORDS from `anno_excluded_range` the export emitted a marker
343
+ * for -- at least one of their addresses overlapping at least one emitted
344
+ * block. This is the marker for a user-requested exclusion, recorded rather
345
+ * than silently dropped.
346
+ *
347
+ * COUNTS RECORDS, NOT MARKER LINES AND NOT EXCLUDED BYTES -- the same
348
+ * lesson this file already learned once about `symbolCount`: a count
349
+ * whose name does not match what it counts gets printed to a user
350
+ * verbatim. One exclusion record spanning two emitted blocks emits TWO
351
+ * marker lines (one per block) and counts ONCE here. And no byte is ever
352
+ * excluded -- every block a recorded exclusion overlaps is still emitted
353
+ * in full -- so this field never measures bytes left out, because there
354
+ * are none. */
355
+ excludedRangeCount: number;
238
356
  }
239
357
 
240
358
  /** How many raw bytes go on one `!byte` line for a non-code block. */
@@ -257,6 +375,26 @@ const WORDS_PER_DATA_LINE = 8;
257
375
  */
258
376
  const WORD_PAIR_DATA_TYPES: readonly string[] = Object.freeze(["word", "address"]);
259
377
 
378
+ /**
379
+ * true iff `dataType` is one of the TWO split
380
+ * layouts that denote ADDRESSES -- `lo_hi_address` / `hi_lo_address` -- as
381
+ * opposed to the two that denote WORDS (`lo_hi_word` / `hi_lo_word`), which
382
+ * stay on the raw-`!byte` fallback below unchanged. This is the branch that
383
+ * decides whether `emitDataLines()`'s caller reaches `emitSplitAddressLines()`
384
+ * at all.
385
+ *
386
+ * DERIVED, never a fourth hand-written list of layout names -- exactly the
387
+ * drift `WORD_PAIR_DATA_TYPES`'s own comment warns against. `isSplitDataType()`
388
+ * (imported from `anno-types.ts`, never re-derived here) answers "is this one
389
+ * of the four split layouts", and the `_address` suffix is the SAME
390
+ * ADDRESS-VERSUS-WORD axis the `DATA_TYPES` comment already draws
391
+ * (`anno-types.ts:350-361`'s worked byte example: the address forms produce
392
+ * cross-references and the word forms do not).
393
+ */
394
+ function isSplitAddressDataType(dataType: string): boolean {
395
+ return isSplitDataType(dataType as DataType) && dataType.endsWith("_address");
396
+ }
397
+
260
398
  /** One emitted data line, with the address span it covers. The span is what
261
399
  * lets a stored comment find its line: a `!byte` line covers up to sixteen
262
400
  * addresses, and a comment on any of them belongs to that line. */
@@ -287,6 +425,18 @@ interface DataLine {
287
425
  * table is NOT invented here; that would be the same drift hazard wearing a
288
426
  * local name.
289
427
  *
428
+ * `external_file` IS THE OPPOSITE CASE from `!text`
429
+ * above, and for the identical reason. `!text` is refused because ACME's
430
+ * conversion table is outside this exporter's control; `!binary` is used
431
+ * here because it applies NO table at all -- the bytes written to the
432
+ * sibling `.bin` file are the image's own octets for the extent, verbatim,
433
+ * so nothing can drift between this host's ACME build and anyone else's.
434
+ * One `!binary` line covers the block's WHOLE extent (never split across
435
+ * several, the way `!byte`/`!word` chunk at `BYTES_PER_DATA_LINE`/
436
+ * `WORDS_PER_DATA_LINE`), so a stored comment anywhere in the range attaches
437
+ * to that one line through the same `withComments()` call every other data
438
+ * line already goes through.
439
+ *
290
440
  * OVERLAP: `--strict-segments` is in the verify argv, which promotes ACME's
291
441
  * "Segment starts inside another one, overwriting it." from a Warning to an
292
442
  * Error (measured, exit 1). Without it a store holding two overlapping ranges
@@ -294,6 +444,16 @@ interface DataLine {
294
444
  * against whichever won.
295
445
  */
296
446
  function emitDataLines(slice: Uint8Array, dataType: string, blockStart: number): DataLine[] {
447
+ if (dataType === EXTERNAL_FILE_DATA_TYPE) {
448
+ return [
449
+ {
450
+ text: `${INDENT}!binary "${binaryFileName(blockStart)}" ; ${dataType}`,
451
+ start: blockStart,
452
+ endExclusive: blockStart + slice.length,
453
+ },
454
+ ];
455
+ }
456
+
297
457
  const out: DataLine[] = [];
298
458
 
299
459
  if (WORD_PAIR_DATA_TYPES.includes(dataType) && slice.length % 2 === 0) {
@@ -326,6 +486,143 @@ function emitDataLines(slice: Uint8Array, dataType: string, blockStart: number):
326
486
  return out;
327
487
  }
328
488
 
489
+ /** one split-address block's own contribution
490
+ * to the export's shared in-tree-reference bookkeeping -- the SAME
491
+ * `unresolvedReferences`/`inTreeReferenceCount` totals the code path's in-tree
492
+ * symbol rule feeds inside `exportAsm()`'s block loop. Returned rather than
493
+ * mutated through a closure so this function stays a plain, explicitly-typed
494
+ * transform like every other emitter in this file; the caller folds these
495
+ * into its own running totals. */
496
+ interface SplitAddressEmission {
497
+ lines: DataLine[];
498
+ inTreeReferenceCount: number;
499
+ unresolvedReferences: { referringAddress: number; targetAddress: number }[];
500
+ }
501
+
502
+ /**
503
+ * Emits one split ADDRESS-layout block (`lo_hi_address` / `hi_lo_address`,
504
+ * `isSplitAddressDataType()`'s own membership) as PAIRED low-byte/high-byte
505
+ * symbol references -- never per-half symbolisation, and never for the two
506
+ * split WORD layouts, which never reach this function at all (they stay on
507
+ * `emitDataLines()`'s raw-`!byte` fallback, unchanged).
508
+ *
509
+ * ORIENTATION, taken from the `DATA_TYPES` comment's own worked byte example
510
+ * (`anno-types.ts:350-361`) and never re-derived: the bytes
511
+ * `10 34 00 ff 08 12 c0 cf` resolve as `$0810 $1234 $c000 $cfff` under
512
+ * `lo_hi_address` -- the FIRST run is the low bytes, the SECOND run the high
513
+ * bytes -- and as `$1008 $3412 $00c0 $ffcf` under `hi_lo_address`, where the
514
+ * order is reversed. `slice` is split into two EQUAL runs at the midpoint,
515
+ * relying on the store's own even-byte-count guarantee for every split layout
516
+ * (`assertRangeShape()`, `anno-types.ts`) rather than re-checking it here.
517
+ *
518
+ * ONE SYMBOL PER ENTRY, the property this function exists to make true by
519
+ * construction rather than merely avoid violating: entry `i`'s composed
520
+ * 16-bit target decides BOTH halves together, through the SAME `isInTree()`/
521
+ * `labelIndex` lookup the code path's in-tree symbol rule reads (one rule,
522
+ * two callers, so a table entry and a `jsr` cannot disagree about what counts
523
+ * as resolvable):
524
+ * - in-tree and named: BOTH halves render through ACME's low-byte/high-byte
525
+ * operators (`<name` / `>name`) over that ONE symbol -- measured
526
+ * byte-identical to the raw octets on real ACME 0.97 "Zem", 2026-09-12
527
+ * (`tbl_lo !byte <routine_a, <routine_b` / `tbl_hi !byte >routine_a,
528
+ * >routine_b` assembled to `01 02 08 08`, the same bytes the raw form
529
+ * would have produced).
530
+ * - out-of-tree (D47-F's boundary, applied here to a second kind of
531
+ * reference): BOTH halves keep their raw byte, exactly as the unsplit
532
+ * fallback would have emitted them, and this is NOT a refusal.
533
+ * - in-tree with no symbol: collected into the returned
534
+ * `unresolvedReferences`, for the SAME end-of-export refusal the
535
+ * instruction path already raises -- never emitted as a hex literal that
536
+ * would freeze the target's address into the source.
537
+ *
538
+ * Entry `i`'s first-run byte sits at `blockStart + i` and its second-run byte
539
+ * at `blockStart + half + i` -- so a stored comment on either address still
540
+ * finds its own line -- and both runs are chunked at `BYTES_PER_DATA_LINE`,
541
+ * the SAME per-line budget `emitDataLines()`'s raw-byte fallback already uses,
542
+ * applied to two runs instead of one. The runs are emitted in PHYSICAL order
543
+ * (first run's lines, then second run's), which is already the layout's own
544
+ * documented order: `lo_hi_address` therefore emits low halves first,
545
+ * `hi_lo_address` emits high halves first, with no reordering logic needed
546
+ * beyond the two runs' own on-disk position.
547
+ */
548
+ function emitSplitAddressLines(
549
+ slice: Uint8Array,
550
+ dataType: string,
551
+ blockStart: number,
552
+ blocks: readonly ExportBlock[],
553
+ labelIndex: ReadonlyMap<number, string>,
554
+ ): SplitAddressEmission {
555
+ const half = slice.length / 2;
556
+ const isLoHi = dataType.startsWith("lo_hi_");
557
+
558
+ let inTreeReferenceCount = 0;
559
+ const unresolvedReferences: { referringAddress: number; targetAddress: number }[] = [];
560
+
561
+ const firstRunTokens: string[] = [];
562
+ const secondRunTokens: string[] = [];
563
+ for (let i = 0; i < half; i++) {
564
+ const firstByte = slice[i]!;
565
+ const secondByte = slice[half + i]!;
566
+ const low = isLoHi ? firstByte : secondByte;
567
+ const high = isLoHi ? secondByte : firstByte;
568
+ const target = low | (high << 8);
569
+
570
+ let lowToken: string;
571
+ let highToken: string;
572
+ if (isInTree(target, blocks)) {
573
+ inTreeReferenceCount++;
574
+ const symbol = labelIndex.get(target);
575
+ if (symbol === undefined) {
576
+ // Referring address is this entry's own FIRST-run byte -- an address
577
+ // a human reading the generated source can look at, on the same
578
+ // terms `instr.address` is for the code path. The export throws
579
+ // before `lines` below is ever used, so the tokens pushed here for
580
+ // this entry are never read; they exist only so the loop can finish
581
+ // uniformly.
582
+ unresolvedReferences.push({ referringAddress: blockStart + i, targetAddress: target });
583
+ lowToken = hex2(low);
584
+ highToken = hex2(high);
585
+ } else {
586
+ // ONE SYMBOL, BOTH HALVES: `<name`/`>name` over the identical
587
+ // `symbol` string, never two independently-looked-up names.
588
+ lowToken = `<${symbol}`;
589
+ highToken = `>${symbol}`;
590
+ }
591
+ } else {
592
+ // D47-F's boundary, applied to a second kind of reference: fixed
593
+ // hardware and KERNAL addresses cannot move, and a rule that refused on
594
+ // them would make every real export impossible.
595
+ lowToken = hex2(low);
596
+ highToken = hex2(high);
597
+ }
598
+
599
+ firstRunTokens.push(isLoHi ? lowToken : highToken);
600
+ secondRunTokens.push(isLoHi ? highToken : lowToken);
601
+ }
602
+
603
+ const lines = [...tokenDataLines(firstRunTokens, blockStart, dataType), ...tokenDataLines(secondRunTokens, blockStart + half, dataType)];
604
+
605
+ return { lines, inTreeReferenceCount, unresolvedReferences };
606
+ }
607
+
608
+ /** Chunks `tokens` (each either `<name`/`>name` or a raw `hex2()` literal) into
609
+ * `!byte` lines at the SAME `BYTES_PER_DATA_LINE` budget `emitDataLines()`'s
610
+ * raw-byte fallback uses, each line spanning `startAddress + offset ..
611
+ * startAddress + offset + chunk.length` -- one physical run of a split-address
612
+ * block's paired emission (`emitSplitAddressLines()`'s only caller). */
613
+ function tokenDataLines(tokens: readonly string[], startAddress: number, dataType: string): DataLine[] {
614
+ const out: DataLine[] = [];
615
+ for (let offset = 0; offset < tokens.length; offset += BYTES_PER_DATA_LINE) {
616
+ const chunk = tokens.slice(offset, Math.min(offset + BYTES_PER_DATA_LINE, tokens.length));
617
+ out.push({
618
+ text: `${INDENT}!byte ${chunk.join(", ")} ; ${dataType}`,
619
+ start: startAddress + offset,
620
+ endExclusive: startAddress + offset + chunk.length,
621
+ });
622
+ }
623
+ return out;
624
+ }
625
+
329
626
  /** ACME source indent for directive lines, matching `disasm-renderer.ts`'s own
330
627
  * cosmetic indent so the two emitters' output reads as one document. */
331
628
  const INDENT = " ";
@@ -341,8 +638,7 @@ function hex4(value: number): string {
341
638
  /**
342
639
  * `$XXXX` FOR A BLOCK'S EXCLUSIVE END, WHICH IS `hex4()` -- MASKED, NOT PADDED.
343
640
  *
344
- * THIS FUNCTION USED TO DO THE OPPOSITE, AND IT WAS WRONG (30-REVIEW WR-04,
345
- * corrected 2026-08-31). It padded without masking, so a range ending at
641
+ * THIS FUNCTION USED TO DO THE OPPOSITE, AND IT WAS WRONG (corrected 2026-08-31). It padded without masking, so a range ending at
346
642
  * `$ffff` produced the end assertion `!if * != $10000`. Its doc justified that
347
643
  * by asserting that `hex4()`'s mask "would render that as `$0000` -- an
348
644
  * assertion no assembly can ever satisfy, firing on a correct export". That
@@ -464,7 +760,7 @@ function loadImage(imagePath: string): { origin: number; bytes: Uint8Array } {
464
760
  * incorrect.
465
761
  *
466
762
  * THE HEX CASE IS LOWER, MATCHING EVERY OTHER EMITTER IN THIS DOCUMENT
467
- * (30-REVIEW IN-03, corrected 2026-08-31). This function used to emit
763
+ * (corrected 2026-08-31). This function used to emit
468
764
  * uppercase (`start = $C000`) while `hex2()`, `hex4()` and `hexExtent()` all
469
765
  * emit lowercase (`* = $0801`, `!byte $a9`), so one generated file carried two
470
766
  * conventions. Both assemble identically -- ACME is case-insensitive for hex
@@ -484,10 +780,9 @@ function formatSymbolDefinition(name: string, address: number): string {
484
780
  /**
485
781
  * One mid-instruction label definition, in the golden witness's own compact
486
782
  * spelling -- no spaces around the `=`, the offset in two hex digits:
487
- * `f_0900 =*+$01` [`.planning/notes/dxa-ghidra-pivot-evidence/anno.asm:202`].
488
- * That witness carries SIX such labels (lines 51, 81, 135, 145, 202 and 205);
489
- * the ROADMAP note saying four is documentation drift, corrected in
490
- * `30-RESEARCH.md`.
783
+ * `f_0900 =*+$01`. That witness carries SIX such labels; an earlier note
784
+ * claiming four was documentation drift, corrected after a recount against
785
+ * the witness disassembly itself.
491
786
  *
492
787
  * `offset` is `label.address - instr.address`, so it is 1 or 2 for every
493
788
  * 6502/6510 instruction -- the value is rendered rather than bounded here
@@ -514,7 +809,7 @@ const AUTO_NAME_MARKER = " ; auto-generated name -- still in the annotation bac
514
809
 
515
810
  /**
516
811
  * The fixed trailing comment that marks a definition at an address carrying
517
- * MORE THAN ONE store label (30-REVIEW WR-02). ONE spelling, in one place, for
812
+ * MORE THAN ONE store label. ONE spelling, in one place, for
518
813
  * the same reason `AUTO_NAME_MARKER` is: a second wording makes it ungreppable
519
814
  * for the only reader it exists for.
520
815
  *
@@ -524,6 +819,57 @@ const AUTO_NAME_MARKER = " ; auto-generated name -- still in the annotation bac
524
819
  */
525
820
  const ALIAS_MARKER_PREFIX = " ; ALIAS: this address also carries ";
526
821
 
822
+ /**
823
+ * The fixed leading comment on every line carrying a provenance ledger row's
824
+ * Verdict and Confidence. ONE spelling, in one
825
+ * place, for the same reason `AUTO_NAME_MARKER` and `ALIAS_MARKER_PREFIX`
826
+ * are: a second wording makes it ungreppable for the only reader it exists
827
+ * for.
828
+ *
829
+ * THIS SPELLING WAS CHOSEN, NOT INHERITED. `diff-images.mjs`'s own
830
+ * `renderLedger()` header prose mentions `; PROVENANCE:` once, as an
831
+ * unwired, forward-looking remark about THIS PROJECT'S OWN documentation
832
+ * provenance conventions -- it names no writer, no reader and no format, and
833
+ * this project's own architecture document (the current one, not an early
834
+ * research-phase draft of the same name) never uses the word "provenance" at all. Reusing that
835
+ * string silently would attribute intent to it that it does not carry
836
+ * (a pitfall caught before this constant shipped). `PROVENANCE_MARKER_PREFIX` is therefore a
837
+ * deliberately DIFFERENT, prefix-distinct spelling: `; PROVENANCE LEDGER:`,
838
+ * never `; PROVENANCE:`.
839
+ */
840
+ const PROVENANCE_MARKER_PREFIX = " ; PROVENANCE LEDGER: ";
841
+
842
+ /**
843
+ * The fixed leading comment recording that MORE THAN ONE ledger row overlaps
844
+ * one emitted block. Same rationale as `PROVENANCE_MARKER_PREFIX` above: one
845
+ * spelling, in one place. The ambiguity is RECORDED, exactly as
846
+ * `ALIAS_MARKER_PREFIX` records a colliding label, never resolved by picking
847
+ * one row and staying silent about the rest (T-46-03).
848
+ */
849
+ const PROVENANCE_AMBIGUITY_MARKER_PREFIX = " ; PROVENANCE LEDGER AMBIGUITY: ";
850
+
851
+ /**
852
+ * The fixed leading comment on every line naming a user-requested exclusion
853
+ * ONE spelling, in one place, for the same
854
+ * reason `AUTO_NAME_MARKER`, `ALIAS_MARKER_PREFIX` and `PROVENANCE_MARKER_PREFIX`
855
+ * are: a second wording would make it ungreppable for the only reader it
856
+ * exists for, and criterion 2's own readback -- recovering what was excluded
857
+ * and why straight out of the exported text -- depends on there being exactly
858
+ * one spelling to anchor on.
859
+ *
860
+ * THIS IS HOW A USER'S REQUEST TO LEAVE A SPAN OUT APPEARS IN THE ARTEFACT,
861
+ * and it is deliberately a MARKER rather than an OMISSION: the export is
862
+ * REQUIRED to be lossless by default, and any exclusion be "emitted as a
863
+ * recorded excluded range rather than a hole". The failure this constant's
864
+ * existence prevents is the obvious wrong implementation -- skipping the
865
+ * block would satisfy the word "exclude" and lose the bytes, and the
866
+ * byte-diff oracle downstream could only ever report that as a coverage gap,
867
+ * never as "the user asked for this". Every block carrying an overlapping
868
+ * exclusion record is still emitted in full, with this comment prepended,
869
+ * never in place of any content.
870
+ */
871
+ export const EXCLUSION_MARKER_PREFIX = " ; EXCLUDED BY USER REQUEST: ";
872
+
527
873
  /**
528
874
  * The largest value an enum variant may carry to be substitutable into an
529
875
  * IMMEDIATE operand.
@@ -535,11 +881,35 @@ const ALIAS_MARKER_PREFIX = " ; ALIAS: this address also carries ";
535
881
  */
536
882
  const MAX_IMMEDIATE_VARIANT_VALUE = 0xff;
537
883
 
884
+ /**
885
+ * The SHAPE `registerKeyFor(address).slice(1)` always produces (uppercase,
886
+ * exactly four hex digits) -- the SAME string `planEnumsForPairing()` uses as
887
+ * a project enum's own `enumName` (`anno-enum-gen.ts`'s own naming rule).
888
+ * An enum usage
889
+ * whose `enumName` matches this shape is a CANDIDATE for
890
+ * `decomposeRegisterValue()`; one that does not (a hand-authored name like
891
+ * `viccolor`) is never a candidate at all -- this module holds no second
892
+ * table of which arbitrary names are "really" registers, and guessing would
893
+ * be exactly the kind of plausible-looking wrong answer this file refuses
894
+ * everywhere else. Lowercase is deliberately excluded: every writer of this
895
+ * convention (`registerKeyFor()`) emits uppercase, and matching lowercase too
896
+ * would accept a shape nothing in this codebase produces.
897
+ *
898
+ * SHAPE ALONE IS NOT ENOUGH (fixed 2026-09-11): a candidate
899
+ * is only ATTEMPTED once `hasRegBitsEntry()` also confirms `anno-regbits.json`
900
+ * has a table entry for it. `$D020`/`$D021` -- among the most commonly
901
+ * hand-annotated C64 registers -- are register-shaped and absent from the
902
+ * table; before this fix, naming either via `anno_create_project_enum` made
903
+ * the WHOLE export throw instead of falling through to the pre-existing
904
+ * single-symbol path. See the call site below for the two-part gate.
905
+ */
906
+ const REGISTER_ENUM_NAME_RE = /^[0-9A-F]{4}$/;
907
+
538
908
  /**
539
909
  * Replaces the `#$XX` immediate literal `renderLine()` produced with `#symbol`.
540
910
  *
541
911
  * WHY A TARGETED TEXT SUBSTITUTION RATHER THAN A `RenderOptions` WIDENING.
542
- * D-11 forbids `renderLine()` from substituting a symbol into an immediate
912
+ * This module's own invariant forbids `renderLine()` from substituting a symbol into an immediate
543
913
  * operand at all, because of the `#<`/`#>` high/low-byte ambiguity, and that
544
914
  * rule is verified against a real assembler in `disasm-roundtrip.test.ts`. It
545
915
  * is not relaxed here. What an ENUM adds is a caller-supplied fact the renderer
@@ -554,7 +924,7 @@ const MAX_IMMEDIATE_VARIANT_VALUE = 0xff;
554
924
  * substitution happened.
555
925
  *
556
926
  * THE SEARCH IS CONFINED TO THE DIRECTIVE HALF OF THE LINE, AND THAT IS THE
557
- * SECOND HALF OF 30-REVIEW CR-01's FIX (2026-08-31). `renderLine()` emits a
927
+ * SECOND HALF OF THIS MODULE'S OWN 2026-08-31 FIX. `renderLine()` emits a
558
928
  * trailing `" ; "` comment for notes, and for an instruction whose
559
929
  * `acmeExpressible` is false it emits the whole thing as a `!byte` directive
560
930
  * with the mnemonic AND its `#$xx` operand moved INTO that comment
@@ -622,7 +992,7 @@ const [LINE_COMMENT, SIDE_COMMENT] = COMMENT_TYPES;
622
992
  * The store validator's own message is deliberately DISCARDED and replaced.
623
993
  * That message interpolates the offending text for one of its four cases, and
624
994
  * an exporter error that quotes a file's contents back is a content-disclosure
625
- * oracle (CR-03). What survives is the address and which rule fired -- facts
995
+ * oracle. What survives is the address and which rule fired -- facts
626
996
  * ABOUT the comment, never the comment.
627
997
  */
628
998
  export function assertExportableCommentText(text: string, address: number): string {
@@ -641,7 +1011,7 @@ export function assertExportableCommentText(text: string, address: number): stri
641
1011
 
642
1012
  /**
643
1013
  * Re-checks, at the EXPORT boundary, that a stored range's `dataType` is one
644
- * the store's own vocabulary defines (30-REVIEW WR-03).
1014
+ * the store's own vocabulary defines.
645
1015
  *
646
1016
  * The sibling of `assertExportableCommentText()` below, on the same terms and
647
1017
  * for the same reason: `listRanges()` casts the column with no validator, so
@@ -696,7 +1066,7 @@ interface CommentPlacement {
696
1066
  * the order they were written in rather than an order this module invented.
697
1067
  *
698
1068
  * A COMMENT ON A MULTI-ADDRESS LINE IS QUALIFIED WITH ITS OWN ADDRESS
699
- * (30-REVIEW IN-02, added 2026-08-31). The CODE path calls this with a span of
1069
+ * (added 2026-08-31). The CODE path calls this with a span of
700
1070
  * exactly ONE address (`[instr.address, instr.address + 1)`), so a comment
701
1071
  * there is unambiguous and is emitted unchanged -- nothing about the existing
702
1072
  * output moves. The DATA path calls it with a span of up to
@@ -714,10 +1084,22 @@ interface CommentPlacement {
714
1084
  * GATED ON AMBIGUITY, not applied always: prefixing every code-path comment
715
1085
  * with an address it already sits next to is noise, and it would rewrite every
716
1086
  * existing expected line in the test suite for nothing.
1087
+ *
1088
+ * `generatedSuffix` is this module's OWN mechanical text --
1089
+ * today, only `decomposeRegisterValue()`'s decoded-field comment for an OR-ed
1090
+ * multi-bit enum substitution -- never a second stored comment. It is NEVER
1091
+ * DROPPED and NEVER REORDERED BEHIND authored text: when a stored SIDE
1092
+ * comment exists at the same address, the authored text renders FIRST and
1093
+ * `generatedSuffix` follows a ` -- ` separator on the SAME trailing comment;
1094
+ * when none exists, `generatedSuffix` is appended alone. Only the FIRST side
1095
+ * comment found absorbs it -- a second one at the same address (a supported
1096
+ * but rare store state) still renders, just without the merge, so the
1097
+ * generated text is never silently duplicated across two lines.
717
1098
  */
718
- function withComments(text: string, start: number, endExclusive: number, ctx: CommentPlacement): string[] {
1099
+ function withComments(text: string, start: number, endExclusive: number, ctx: CommentPlacement, generatedSuffix?: string): string[] {
719
1100
  const before: string[] = [];
720
1101
  let line = text;
1102
+ let generatedMerged = false;
721
1103
 
722
1104
  // One emitted line covering more than one address cannot say WHICH address a
723
1105
  // comment belongs to unless the comment says so itself.
@@ -731,7 +1113,12 @@ function withComments(text: string, start: number, endExclusive: number, ctx: Co
731
1113
  if (row.commentType === LINE_COMMENT) {
732
1114
  before.push(`${INDENT}; ${safe}`);
733
1115
  } else if (row.commentType === SIDE_COMMENT) {
734
- line = `${line} ; ${safe}`;
1116
+ if (generatedSuffix !== undefined && !generatedMerged) {
1117
+ line = `${line} ; ${safe} -- ${generatedSuffix}`;
1118
+ generatedMerged = true;
1119
+ } else {
1120
+ line = `${line} ; ${safe}`;
1121
+ }
735
1122
  } else {
736
1123
  // Unreachable through the type, and reachable through a store file
737
1124
  // somebody edited. Refusing beats guessing which of the two placements
@@ -744,9 +1131,58 @@ function withComments(text: string, start: number, endExclusive: number, ctx: Co
744
1131
  }
745
1132
  }
746
1133
 
1134
+ if (generatedSuffix !== undefined && !generatedMerged) {
1135
+ line = `${line} ; ${generatedSuffix}`;
1136
+ }
1137
+
747
1138
  return [...before, line];
748
1139
  }
749
1140
 
1141
+ /**
1142
+ * the address one decoded instruction's
1143
+ * operand REFERENCES, for the in-tree symbol rule below -- the SAME address
1144
+ * a substitution would substitute, extracted through `resolvedTarget` FIRST
1145
+ * (`disasm-decoder.ts` rule 5 for every relative branch, rule 6 for absolute
1146
+ * `jmp` ($4C) and `jsr` ($20)), and otherwise through the operand's own
1147
+ * `value` for the `absolute`, `zeropage` and `indirect` roles -- the same
1148
+ * three roles `disasm-renderer.ts`'s own `resolveSymbol()` call sites read.
1149
+ *
1150
+ * AN `immediate` OPERAND IS DELIBERATELY NEVER A REFERENCE, and returns
1151
+ * `undefined` here exactly as it falls through every branch below. An
1152
+ * immediate is a BYTE VALUE, not an address: project enums (the
1153
+ * `usageByAddress` branch above) are what give one a name, and treating it as
1154
+ * an address is how `lda #$08` would start demanding a label be recorded at
1155
+ * `$0008`. `disasm-renderer.ts`'s own comment states the same exclusion
1156
+ * for the identical reason, on the substitution side of this same boundary.
1157
+ */
1158
+ function referencedAddress(instr: Instruction): number | undefined {
1159
+ if (instr.resolvedTarget !== undefined) return instr.resolvedTarget;
1160
+ const role = instr.operand?.role;
1161
+ if (role === "absolute" || role === "zeropage" || role === "indirect") return instr.operand!.value;
1162
+ return undefined;
1163
+ }
1164
+
1165
+ /**
1166
+ * true iff `address` falls inside
1167
+ * `[block.start, block.endExclusive)` for SOME block this export emitted --
1168
+ * the half-open interval every other boundary test in this module already
1169
+ * uses. Tested against the `blocks` array `exportAsm()` already built at the
1170
+ * top of the function, never a second range list derived independently of
1171
+ * it: a second list is exactly how the in-tree test and the emitted blocks
1172
+ * could drift apart.
1173
+ *
1174
+ * D47-F'S BOUNDARY, STATED HERE BECAUSE THIS IS WHERE IT IS DECIDED: an
1175
+ * address OUTSIDE every emitted block -- a hardware register like `$d020`,
1176
+ * a KERNAL entry like `$ffd2` -- is NOT in-tree, and a reference to one is
1177
+ * rendered as a hex literal and is never refused. Those addresses are fixed
1178
+ * hardware that cannot move, which is precisely what "so the code can move"
1179
+ * is about; a rule that refused on them would make every real export
1180
+ * impossible and would be measuring the wrong thing.
1181
+ */
1182
+ function isInTree(address: number, blocks: readonly ExportBlock[]): boolean {
1183
+ return blocks.some((block) => address >= block.start && address < block.endExclusive);
1184
+ }
1185
+
750
1186
  /**
751
1187
  * Exports the annotation store at `options.storePath`, over the image at
752
1188
  * `options.imagePath`, as ACME source plus the exact bytes that source must
@@ -762,7 +1198,7 @@ function withComments(text: string, start: number, endExclusive: number, ctx: Co
762
1198
  * addresses and counts only.
763
1199
  */
764
1200
  export function exportAsm(options: ExportAsmOptions): ExportAsmResult {
765
- const { storePath, imagePath, workspaceRoot } = options;
1201
+ const { storePath, imagePath, workspaceRoot, ledgerPath } = options;
766
1202
 
767
1203
  const image = loadImage(imagePath);
768
1204
 
@@ -777,12 +1213,24 @@ export function exportAsm(options: ExportAsmOptions): ExportAsmResult {
777
1213
  let comments: CommentRow[];
778
1214
  let projectEnums: ProjectEnumRow[];
779
1215
  let enumUsage: EnumUsageRow[];
1216
+ let excludedRanges: ExcludedRangeRow[];
1217
+ let scopes: ScopeRow[];
780
1218
  try {
781
1219
  ranges = listRanges(handle);
782
1220
  labels = listLabels(handle);
783
1221
  comments = listComments(handle);
784
1222
  projectEnums = listProjectEnums(handle);
785
1223
  enumUsage = listEnumUsage(handle);
1224
+ // a sixth read in the SAME handle and the
1225
+ // SAME `try`, mirroring the discipline the five siblings above already
1226
+ // follow -- one handle for the whole export, closed once in the
1227
+ // `finally` below. There is no second store opened for this.
1228
+ excludedRanges = listExcludedRanges(handle);
1229
+ // a SEVENTH read in the SAME handle and the SAME
1230
+ // `try`, on the same terms as the sixth above -- still one handle for
1231
+ // the whole export, closed once in the `finally` below. There is no
1232
+ // second store opened for this either.
1233
+ scopes = listScopes(handle);
786
1234
  } finally {
787
1235
  closeStore(handle);
788
1236
  }
@@ -802,8 +1250,8 @@ export function exportAsm(options: ExportAsmOptions): ExportAsmResult {
802
1250
  const blocks: ExportBlock[] = sortedRanges.map((row) => ({
803
1251
  start: row.start,
804
1252
  endExclusive: row.endInclusive + 1,
805
- // THE STORE'S `dataType` IS RE-CHECKED AT THIS BOUNDARY (30-REVIEW WR-03,
806
- // fixed 2026-08-31), for exactly the reason `withComments()` re-checks
1253
+ // THE STORE'S `dataType` IS RE-CHECKED AT THIS BOUNDARY (fixed 2026-08-31),
1254
+ // for exactly the reason `withComments()` re-checks
807
1255
  // `commentType` a few functions up: "Unreachable through the type, and
808
1256
  // reachable through a store file somebody edited. Refusing beats
809
1257
  // guessing." That reasoning applies here and had not been applied.
@@ -827,6 +1275,9 @@ export function exportAsm(options: ExportAsmOptions): ExportAsmResult {
827
1275
  // from it.
828
1276
  dataType: assertDataTypeForExport(row) as string,
829
1277
  lineCount: 0,
1278
+ // Populated below, at the same point `emitBlock()` is called for this
1279
+ // block -- empty only before that call runs.
1280
+ lines: [],
830
1281
  }));
831
1282
 
832
1283
  const imageStart = image.origin;
@@ -841,6 +1292,14 @@ export function exportAsm(options: ExportAsmOptions): ExportAsmResult {
841
1292
  }
842
1293
  }
843
1294
 
1295
+ // ONE read for the whole export, mirroring the
1296
+ // one-store-handle discipline directly above. `ledgerPath === undefined` is
1297
+ // the ONLY question asked of the caller's intent here -- everything after
1298
+ // this line either has a ledger to join against or does not, and no branch
1299
+ // anywhere below (here or in the per-block loop) ever reads a Verdict,
1300
+ // Confidence or Kind VALUE to decide anything.
1301
+ const ledger: ProvenanceLedger | undefined = ledgerPath === undefined ? undefined : readProvenanceLedger(ledgerPath);
1302
+
844
1303
  // The label index the renderer's `symbolFor` hook reads. Every name is
845
1304
  // validated BEFORE it can reach the source text -- REJECT, never sanitise.
846
1305
  const sortedLabels = [...labels].sort((a, b) => a.address - b.address);
@@ -852,7 +1311,7 @@ export function exportAsm(options: ExportAsmOptions): ExportAsmResult {
852
1311
  for (const label of sortedLabels) {
853
1312
  assertLegalAcmeIdentifier(label.name, `exportAsm: label at ${hex4(label.address)}`);
854
1313
  // TWO NAMES AT ONE ADDRESS IS RECORDED IN THE EMITTED SOURCE, NOT RESOLVED
855
- // IN SILENCE (30-REVIEW WR-02, second half, fixed 2026-08-31).
1314
+ // IN SILENCE (fixed 2026-08-31).
856
1315
  //
857
1316
  // `labelIndex` is a `Map<number, string>` while `anno_label` is `unique`
858
1317
  // on NAME only -- `setLabel()` refuses only a name already bound to a
@@ -892,7 +1351,7 @@ export function exportAsm(options: ExportAsmOptions): ExportAsmResult {
892
1351
  * only the first name at each, so an ALIASED label would be invisible to a
893
1352
  * collision check reading it. ACME has ONE symbol namespace, so an enum
894
1353
  * variant symbol colliding with any of these is `Symbol already defined.`
895
- * (30-REVIEW WR-10). */
1354
+ */
896
1355
  const labelSymbolNames = new Set(sortedLabels.map((label) => label.name));
897
1356
 
898
1357
  // Comments indexed by the address they annotate, each address's list left in
@@ -918,10 +1377,37 @@ export function exportAsm(options: ExportAsmOptions): ExportAsmResult {
918
1377
  /** `<enumName>_<VARIANT> = $XX` definition lines, in first-emitted order.
919
1378
  * They join the header block for the same reason label definitions do. */
920
1379
  const enumDefinitionLines: string[] = [];
921
- const definedEnumSymbols = new Set<string>();
1380
+ /** Every emitted enum-derived symbol name (single-value or OR-ed term) to
1381
+ * the ONE value it was defined with. A Map, not a Set:
1382
+ * a term name defined by one instruction with one value and referenced by a
1383
+ * SECOND instruction with a DIFFERENT value is a genuine collision in
1384
+ * ACME's one symbol namespace, and the value is what makes that collision
1385
+ * detectable rather than merely a duplicate-looking string. */
1386
+ const definedEnumSymbols = new Map<string, number>();
922
1387
 
923
1388
  let unexpressibleCount = 0;
924
1389
  let dataByteCount = 0;
1390
+ let enumDecompositionCount = 0;
1391
+ /** every `external_file`-typed block's
1392
+ * own `.bin` sibling, populated at the same point `dataByteCount` above
1393
+ * is incremented for that block -- see `ExportAsmResult.binaries`'s own
1394
+ * doc-comment for what a caller reading this field is obliged to do. */
1395
+ const binaries: ExportBinary[] = [];
1396
+
1397
+ /** one in-tree reference this export could
1398
+ * not resolve to a symbol -- collected across the WHOLE block loop and
1399
+ * refused ONCE at the end, in the shape the unapplied-enum-usage and
1400
+ * unplaced-comment refusals below already use. */
1401
+ interface UnresolvedReference {
1402
+ referringAddress: number;
1403
+ targetAddress: number;
1404
+ }
1405
+ const unresolvedReferences: UnresolvedReference[] = [];
1406
+ /** How many in-tree references this export encountered, resolved or not --
1407
+ * the denominator for the refusal's own "N of M" count, on the same terms
1408
+ * `enumUsage.length` is the denominator for the unapplied-enum-usage
1409
+ * refusal below. */
1410
+ let inTreeReferenceCount = 0;
925
1411
 
926
1412
  // AUTO-GENERATED NAMES ARE MARKED, not filtered. Every store label reaches
927
1413
  // the source either way; the marker is the backlog signal, carried into the
@@ -936,8 +1422,8 @@ export function exportAsm(options: ExportAsmOptions): ExportAsmResult {
936
1422
  return `${line}${AUTO_NAME_MARKER}`;
937
1423
  };
938
1424
 
939
- // THE ALIAS PICK IS MADE VISIBLE IN THE SOURCE (30-REVIEW WR-02, second
940
- // half). Two store labels at one address are BOTH defined -- ACME accepts
1425
+ // THE ALIAS PICK IS MADE VISIBLE IN THE SOURCE (second half of the same fix
1426
+ // above). Two store labels at one address are BOTH defined -- ACME accepts
941
1427
  // two symbols with one value and the bytes are unaffected -- but a
942
1428
  // REFERENCE to that address can render through only one of them. Which one
943
1429
  // was previously invisible. Marking both definitions with the same fixed
@@ -959,7 +1445,7 @@ export function exportAsm(options: ExportAsmOptions): ExportAsmResult {
959
1445
  // or ACME refuses the whole source with `Symbol already defined.`
960
1446
  const midInstructionLabelAddresses = new Set<number>();
961
1447
 
962
- // ONE PER EMITTED INLINE DEFINITION, not one per address (30-REVIEW WR-02).
1448
+ // ONE PER EMITTED INLINE DEFINITION, not one per address.
963
1449
  // The set above answers the HEADER's question ("is this address defined
964
1450
  // inline already?"), which is per-address by nature. This counter answers
965
1451
  // the RESULT's question ("how many inline definitions does the source
@@ -971,16 +1457,22 @@ export function exportAsm(options: ExportAsmOptions): ExportAsmResult {
971
1457
 
972
1458
  const blockLines: string[] = [];
973
1459
 
1460
+ /** RECORDS, not lines -- one entry per `anno_excluded_range.id` that has
1461
+ * had at least one marker line emitted for it, across every block. Read at
1462
+ * the end for `excludedRangeCount`; see that field's own doc comment for
1463
+ * why a record spanning two blocks must count once, not twice. */
1464
+ const excludedRangeIdsEmitted = new Set<number>();
1465
+
974
1466
  for (const block of blocks) {
975
1467
  const slice = image.bytes.subarray(block.start - imageStart, block.endExclusive - imageStart);
976
1468
  const content: string[] = [];
977
1469
 
978
1470
  if (block.dataType === CODE_DATA_TYPE) {
979
- // D-11 is inherited UNCHANGED: `renderLine()` decides operand width and
1471
+ // That invariant is inherited UNCHANGED: `renderLine()` decides operand width and
980
1472
  // refuses to substitute a symbol into an immediate or zeropage-family
981
1473
  // operand. Do not widen `RenderOptions` and do not bypass `renderLine()`.
982
1474
  // `end` IS INCLUSIVE, SO IT IS HANDED AN INCLUSIVE VALUE (30-REVIEW
983
- // WR-07, corrected 2026-08-31). This used to pass `block.endExclusive`.
1475
+ // corrected 2026-08-31). This used to pass `block.endExclusive`.
984
1476
  // `DecodeOptions.end` is compared with `if (end !== undefined && address
985
1477
  // > end) break` and documented as "an instruction starting past `end` is
986
1478
  // dropped ... an instruction starting AT OR BEFORE `end` is emitted in
@@ -1063,6 +1555,34 @@ export function exportAsm(options: ExportAsmOptions): ExportAsmResult {
1063
1555
 
1064
1556
  let rendered = renderLine(instr, { showSymbols: true, symbolFor });
1065
1557
 
1558
+ // THE IN-TREE SYMBOL RULE, beside
1559
+ // the `renderLine()` call it has to agree with. `rendered` above
1560
+ // already went through `symbolFor` -- the SAME `labelIndex` map read
1561
+ // here -- so a reference this check calls unresolved is a reference
1562
+ // `renderLine()` also had no name for, and one this check calls
1563
+ // resolved is a reference `renderLine()` already substituted. Reading
1564
+ // a SECOND index here, or re-scanning `sortedLabels`, is exactly how
1565
+ // a refusal could disagree with what the renderer actually did: it
1566
+ // could refuse an export the renderer would have substituted
1567
+ // correctly, or silently pass one it would not.
1568
+ //
1569
+ // Collected here and refused ONCE, after every block has been
1570
+ // processed -- see the throw below the block loop for the message
1571
+ // and D47-F's own boundary.
1572
+ const referenced = referencedAddress(instr);
1573
+ if (referenced !== undefined && isInTree(referenced, blocks)) {
1574
+ inTreeReferenceCount++;
1575
+ if (labelIndex.get(referenced) === undefined) {
1576
+ unresolvedReferences.push({ referringAddress: instr.address, targetAddress: referenced });
1577
+ }
1578
+ }
1579
+
1580
+ // The mechanical decode text, set only by the OR-ed
1581
+ // decomposition branch below and merged into this instruction's
1582
+ // trailing comment by `withComments()` after the enum-substitution
1583
+ // block finishes.
1584
+ let decompositionComment: string | undefined;
1585
+
1066
1586
  // ENUM SUBSTITUTION, IMMEDIATE OPERAND ONLY.
1067
1587
  const usage = usageByAddress.get(instr.address);
1068
1588
  if (usage !== undefined) {
@@ -1077,7 +1597,7 @@ export function exportAsm(options: ExportAsmOptions): ExportAsmResult {
1077
1597
  }
1078
1598
 
1079
1599
  // ROLE IS NOT ENOUGH: THE OPERAND MUST ALSO BE ASSEMBLER-VISIBLE
1080
- // (30-REVIEW CR-02, fixed 2026-08-31). `decode()` assigns
1600
+ // (fixed 2026-08-31). `decode()` assigns
1081
1601
  // `role: "immediate"` from the ADDRESSING MODE alone, independently
1082
1602
  // of `acmeExpressible`. Six opcodes in `disasm-opcodes.ts` are
1083
1603
  // `mode: "immediate"` AND `acmeExpressible: false` -- $2b (`anc`),
@@ -1101,9 +1621,9 @@ export function exportAsm(options: ExportAsmOptions): ExportAsmResult {
1101
1621
  // !byte $eb, $00 ; sbc #viccolor_BLACK [illegal opcode | ...]
1102
1622
  // === enumSubstitutionCount: 1
1103
1623
  //
1104
- // This is D-30's "an annotation the exporter cannot express is
1105
- // REFUSED loudly and by name, never silently dropped while the
1106
- // export reports success" exactly inverted. It is refused now.
1624
+ // This inverts, exactly, the rule that an annotation the exporter cannot
1625
+ // express is REFUSED loudly and by name, never silently dropped while
1626
+ // the export reports success. It is refused now.
1107
1627
  if (!instr.acmeExpressible) {
1108
1628
  throw new Error(
1109
1629
  `exportAsm: enum ${JSON.stringify(usage.enumName)} is bound to the immediate operand at ${hex4(usage.address)}, but that ` +
@@ -1126,81 +1646,169 @@ export function exportAsm(options: ExportAsmOptions): ExportAsmResult {
1126
1646
  );
1127
1647
  }
1128
1648
 
1129
- // EVERY variant of the enum is checked, not only the one this operand
1130
- // matched. An enum carrying a variant above $ff is not a BYTE
1131
- // vocabulary, and binding it to a byte operand is a modelling error
1132
- // whose only symptom would otherwise be a variant that silently never
1133
- // renders. Refusing here names the enum and the variant; ACME's own
1134
- // refusal for the same shape is `Number does not fit in 8 bits.` at
1135
- // exit 1, and names a line in a temp file instead.
1136
- let matched: string | undefined;
1137
- for (const [key, variantName] of Object.entries(project.variants)) {
1138
- const value = parseVariantKey(key);
1139
- if (value > MAX_IMMEDIATE_VARIANT_VALUE) {
1649
+ // THE ONE OWNING DECODER. Attempted ONLY when
1650
+ // BOTH (fixed 2026-09-11):
1651
+ // 1. `usage.enumName` has the exact shape `registerKeyFor().slice(1)`
1652
+ // produces -- see `REGISTER_ENUM_NAME_RE`'s own comment for why a
1653
+ // name that does not have this shape (e.g. `viccolor`) is never
1654
+ // a candidate at all; AND
1655
+ // 2. `anno-regbits.json` actually has a table entry for that
1656
+ // register (`hasRegBitsEntry()`) -- a register-shaped name for a
1657
+ // register the table does not cover (e.g. `D020`) is not a
1658
+ // decomposition failure, it is simply not a decomposable
1659
+ // register, and falls through to the existing single-symbol
1660
+ // path below with NO substitution counted.
1661
+ // Once BOTH hold, the table DOES claim this register, and a throw
1662
+ // here is a genuine, non-collateral data/coverage error (e.g. the
1663
+ // disclosed `$DD00` incomplete-bitfield-table case) -- it is NEVER
1664
+ // swallowed to fall back to the hex literal while still counting a
1665
+ // substitution that did not happen (T-45-21). It propagates with the
1666
+ // usage address prepended, so the store row that caused it is always
1667
+ // nameable.
1668
+ let decomposition: RegisterDecomposition | undefined;
1669
+ if (REGISTER_ENUM_NAME_RE.test(usage.enumName) && hasRegBitsEntry(`$${usage.enumName}`)) {
1670
+ try {
1671
+ decomposition = decomposeRegisterValue(parseInt(usage.enumName, 16), instr.operand!.value);
1672
+ } catch (err) {
1140
1673
  throw new Error(
1141
- `exportAsm: enum ${JSON.stringify(usage.enumName)} is bound to the immediate operand at ${hex4(usage.address)}, but its ` +
1142
- `variant ${JSON.stringify(variantName)} has the value ${value}, above ` +
1143
- `${MAX_IMMEDIATE_VARIANT_VALUE} -- an immediate operand is ONE byte, so this enum is not a byte vocabulary. ` +
1144
- `Real ACME refuses the same shape with "Number does not fit in 8 bits." and exit 1; this refusal happens first so it ` +
1145
- `can name the enum and the variant rather than a temp-file line number.`,
1674
+ `exportAsm: decomposing the enum usage at ${hex4(usage.address)} (enum ${JSON.stringify(usage.enumName)}, value ` +
1675
+ `${hex2(instr.operand!.value)}) against its bit-name table failed: ${err instanceof Error ? err.message : String(err)}`,
1146
1676
  );
1147
1677
  }
1148
- if (value === instr.operand!.value) matched = variantName;
1149
1678
  }
1150
1679
 
1151
- if (matched === undefined) {
1152
- throw new Error(
1153
- `exportAsm: enum ${JSON.stringify(usage.enumName)} is bound to the immediate operand at ${hex4(usage.address)}, whose ` +
1154
- `value is ${hex2(instr.operand!.value)}, and the enum has no variant for that value. Refusing to emit the hex literal ` +
1155
- `while reporting an enum substitution that did not happen.`,
1156
- );
1157
- }
1680
+ if (decomposition !== undefined && decomposition.multiField) {
1681
+ // OR-ED NAMED CONSTANTS AND THE DECODED COMMENT -- BOTH,
1682
+ // never either alone. A bare hex constant with a comment still
1683
+ // "emits one hex constant"; bare constants with no comment are
1684
+ // not readable.
1685
+ for (const term of decomposition.terms) {
1686
+ const existingValue = definedEnumSymbols.get(term.name);
1687
+ if (existingValue !== undefined) {
1688
+ if (existingValue !== term.value) {
1689
+ // ACME has ONE symbol namespace: two register writes that
1690
+ // decode the SAME field name to TWO different values cannot
1691
+ // both be `<name> = $XX`. Refusing names the symbol and
1692
+ // BOTH values, so the conflicting rows are findable.
1693
+ throw new Error(
1694
+ `exportAsm: the enum term symbol ${JSON.stringify(term.name)} (enum ${JSON.stringify(usage.enumName)}, field ` +
1695
+ `${JSON.stringify(term.fieldName)}, bound at ${hex4(usage.address)}) would be defined as ${hex2(term.value)} here, ` +
1696
+ `but was already defined as ${hex2(existingValue)} by an earlier instruction in this export. ACME has one symbol ` +
1697
+ `namespace, so one name cannot carry two values. REFUSED -- reconcile the two register writes or bind them to ` +
1698
+ `distinct enum names.`,
1699
+ );
1700
+ }
1701
+ // Same name, same value, already defined by an earlier
1702
+ // instruction -- no second definition line, the same
1703
+ // own "only what the source references" discipline, extended
1704
+ // to terms).
1705
+ } else {
1706
+ // THE SAME LABEL COLLISION CHECK ADDED FOR A
1707
+ // SINGLE ENUM SYMBOL, EXTENDED HERE -- not a second check.
1708
+ if (labelSymbolNames.has(term.name)) {
1709
+ throw new Error(
1710
+ `exportAsm: the enum term symbol ${JSON.stringify(term.name)} (enum ${JSON.stringify(usage.enumName)}, field ` +
1711
+ `${JSON.stringify(term.fieldName)}, bound at ${hex4(usage.address)}) is ALSO the name of a store label. ACME has ` +
1712
+ `one symbol namespace, so emitting both definitions is \`Symbol already defined.\` and exit 1 -- and this verb ` +
1713
+ `assembles nothing, so without this refusal the export would exit 0 here and fail wherever you assembled it. ` +
1714
+ `REFUSED -- rename the label or the enum term.`,
1715
+ );
1716
+ }
1717
+ definedEnumSymbols.set(term.name, term.value);
1718
+ enumDefinitionLines.push(formatSymbolDefinition(term.name, term.value));
1719
+ }
1720
+ }
1158
1721
 
1159
- const symbol = `${usage.enumName}_${matched}`;
1160
- // REJECT, never sanitise -- the same contract every other name this
1161
- // module emits passes through, applied to the COMPOSED name because
1162
- // that is what actually reaches the ACME source.
1163
- assertLegalAcmeIdentifier(symbol, `exportAsm: enum variant symbol for ${hex4(usage.address)}`);
1164
-
1165
- // THE COLLISION THE COMMENT BELOW NAMES IS NOW CHECKED FOR
1166
- // (30-REVIEW WR-10, fixed 2026-08-31). That comment identified the
1167
- // hazard exactly -- "every extra emitted symbol is one more chance to
1168
- // collide with a label name and turn a correct export into ACME's
1169
- // `Symbol already defined.`" -- and then did not look.
1170
- // `definedEnumSymbols` dedupes enum symbols against EACH OTHER but
1171
- // never against the store's labels.
1172
- //
1173
- // Since the `anno export-asm` CLI verb runs no assembler, the
1174
- // collision produced a file that exited 0 here and failed wherever
1175
- // the user actually assembled it, with no pointer back to the store
1176
- // row that caused it. Refusing here names BOTH the enum and the
1177
- // label, which is what makes it fixable.
1178
- //
1179
- // Checked against `labelSymbolNames` -- every store label's name,
1180
- // whether it ends up defined in the header or inline -- because ACME
1181
- // has ONE symbol namespace and an inline `=*+$NN` definition
1182
- // collides exactly as a header one does.
1183
- if (labelSymbolNames.has(symbol)) {
1184
- throw new Error(
1185
- `exportAsm: the enum variant symbol ${JSON.stringify(symbol)} (enum ${JSON.stringify(usage.enumName)}, variant ` +
1186
- `${JSON.stringify(matched)}, bound at ${hex4(usage.address)}) is ALSO the name of a store label. ACME has one symbol ` +
1187
- `namespace, so emitting both definitions is \`Symbol already defined.\` and exit 1 -- and this verb assembles ` +
1188
- `nothing, so without this refusal the export would exit 0 here and fail wherever you assembled it, with no pointer ` +
1189
- `back to the rows that caused it. REFUSED -- rename the label or the enum variant.`,
1190
- );
1191
- }
1722
+ const orExpression = decomposition.terms.map((term) => term.name).join(" | ");
1723
+ rendered = substituteImmediateEnum(rendered, instr.operand!.value, orExpression, instr.address);
1724
+ appliedEnumUsage.add(usage.id);
1725
+ enumDecompositionCount++;
1726
+ decompositionComment = decomposition.comment;
1727
+ } else {
1728
+ // THE EXISTING SINGLE-SYMBOL PATH (unchanged, not
1729
+ // replaced) -- a single-field register, an enum usage whose name
1730
+ // is not register-shaped at all, OR a
1731
+ // register-shaped name for a register `anno-regbits.json` simply
1732
+ // has no entry for (e.g. `D020`) -- reached here with no
1733
+ // substitution counted above, never a throw.
1734
+ //
1735
+ // EVERY variant of the enum is checked, not only the one this
1736
+ // operand matched. An enum carrying a variant above $ff is not a
1737
+ // BYTE vocabulary, and binding it to a byte operand is a
1738
+ // modelling error whose only symptom would otherwise be a
1739
+ // variant that silently never renders. Refusing here names the
1740
+ // enum and the variant; ACME's own refusal for the same shape is
1741
+ // `Number does not fit in 8 bits.` at exit 1, and names a line in
1742
+ // a temp file instead.
1743
+ let matched: string | undefined;
1744
+ for (const [key, variantName] of Object.entries(project.variants)) {
1745
+ const value = parseVariantKey(key);
1746
+ if (value > MAX_IMMEDIATE_VARIANT_VALUE) {
1747
+ throw new Error(
1748
+ `exportAsm: enum ${JSON.stringify(usage.enumName)} is bound to the immediate operand at ${hex4(usage.address)}, but its ` +
1749
+ `variant ${JSON.stringify(variantName)} has the value ${value}, above ` +
1750
+ `${MAX_IMMEDIATE_VARIANT_VALUE} -- an immediate operand is ONE byte, so this enum is not a byte vocabulary. ` +
1751
+ `Real ACME refuses the same shape with "Number does not fit in 8 bits." and exit 1; this refusal happens first so it ` +
1752
+ `can name the enum and the variant rather than a temp-file line number.`,
1753
+ );
1754
+ }
1755
+ if (value === instr.operand!.value) matched = variantName;
1756
+ }
1757
+
1758
+ if (matched === undefined) {
1759
+ throw new Error(
1760
+ `exportAsm: enum ${JSON.stringify(usage.enumName)} is bound to the immediate operand at ${hex4(usage.address)}, whose ` +
1761
+ `value is ${hex2(instr.operand!.value)}, and the enum has no variant for that value. Refusing to emit the hex literal ` +
1762
+ `while reporting an enum substitution that did not happen.`,
1763
+ );
1764
+ }
1192
1765
 
1193
- // ONLY THE MATCHED VARIANT IS DEFINED, not the whole vocabulary. A
1194
- // definition the source never references is clutter a human reader
1195
- // has to discount, and every extra emitted symbol is one more chance
1196
- // to collide with a label name and turn a correct export into ACME's
1197
- // `Symbol already defined.`
1198
- if (!definedEnumSymbols.has(symbol)) {
1199
- definedEnumSymbols.add(symbol);
1200
- enumDefinitionLines.push(formatSymbolDefinition(symbol, instr.operand!.value));
1766
+ const symbol = `${usage.enumName}_${matched}`;
1767
+ // REJECT, never sanitise -- the same contract every other name this
1768
+ // module emits passes through, applied to the COMPOSED name because
1769
+ // that is what actually reaches the ACME source.
1770
+ assertLegalAcmeIdentifier(symbol, `exportAsm: enum variant symbol for ${hex4(usage.address)}`);
1771
+
1772
+ // THE COLLISION THE COMMENT BELOW NAMES IS NOW CHECKED FOR
1773
+ // (fixed 2026-08-31). That comment identified the
1774
+ // hazard exactly -- "every extra emitted symbol is one more chance to
1775
+ // collide with a label name and turn a correct export into ACME's
1776
+ // `Symbol already defined.`" -- and then did not look.
1777
+ // `definedEnumSymbols` dedupes enum symbols against EACH OTHER but
1778
+ // never against the store's labels.
1779
+ //
1780
+ // Since the `anno export-asm` CLI verb runs no assembler, the
1781
+ // collision produced a file that exited 0 here and failed wherever
1782
+ // the user actually assembled it, with no pointer back to the store
1783
+ // row that caused it. Refusing here names BOTH the enum and the
1784
+ // label, which is what makes it fixable.
1785
+ //
1786
+ // Checked against `labelSymbolNames` -- every store label's name,
1787
+ // whether it ends up defined in the header or inline -- because ACME
1788
+ // has ONE symbol namespace and an inline `=*+$NN` definition
1789
+ // collides exactly as a header one does.
1790
+ if (labelSymbolNames.has(symbol)) {
1791
+ throw new Error(
1792
+ `exportAsm: the enum variant symbol ${JSON.stringify(symbol)} (enum ${JSON.stringify(usage.enumName)}, variant ` +
1793
+ `${JSON.stringify(matched)}, bound at ${hex4(usage.address)}) is ALSO the name of a store label. ACME has one symbol ` +
1794
+ `namespace, so emitting both definitions is \`Symbol already defined.\` and exit 1 -- and this verb assembles ` +
1795
+ `nothing, so without this refusal the export would exit 0 here and fail wherever you assembled it, with no pointer ` +
1796
+ `back to the rows that caused it. REFUSED -- rename the label or the enum variant.`,
1797
+ );
1798
+ }
1799
+
1800
+ // ONLY THE MATCHED VARIANT IS DEFINED, not the whole vocabulary. A
1801
+ // definition the source never references is clutter a human reader
1802
+ // has to discount, and every extra emitted symbol is one more chance
1803
+ // to collide with a label name and turn a correct export into ACME's
1804
+ // `Symbol already defined.`
1805
+ if (!definedEnumSymbols.has(symbol)) {
1806
+ definedEnumSymbols.set(symbol, instr.operand!.value);
1807
+ enumDefinitionLines.push(formatSymbolDefinition(symbol, instr.operand!.value));
1808
+ }
1809
+ rendered = substituteImmediateEnum(rendered, instr.operand!.value, symbol, instr.address);
1810
+ appliedEnumUsage.add(usage.id);
1201
1811
  }
1202
- rendered = substituteImmediateEnum(rendered, instr.operand!.value, symbol, instr.address);
1203
- appliedEnumUsage.add(usage.id);
1204
1812
  }
1205
1813
 
1206
1814
  // The span is the instruction's FIRST address only, not its whole
@@ -1208,27 +1816,173 @@ export function exportAsm(options: ExportAsmOptions): ExportAsmResult {
1208
1816
  // emitted line, and attaching it to the instruction that happens to
1209
1817
  // contain that byte would move a human's note onto a different address
1210
1818
  // than the one they chose. It stays unplaced and is refused below.
1211
- const emitted = withComments(rendered, instr.address, instr.address + 1, placement);
1819
+ const emitted = withComments(rendered, instr.address, instr.address + 1, placement, decompositionComment);
1820
+ content.push(...emitted);
1821
+ block.lineCount += emitted.length;
1822
+ }
1823
+ } else if (isSplitAddressDataType(block.dataType)) {
1824
+ // the two split ADDRESS layouts get
1825
+ // PAIRED symbol emission, never the raw-`!byte` fallback -- see
1826
+ // `emitSplitAddressLines()`'s own doc-comment for the orientation and
1827
+ // the one-symbol-per-entry rule. Read the SAME `blocks`/`labelIndex`
1828
+ // the code path's in-tree symbol rule above reads, and fold its
1829
+ // contribution into the SAME `unresolvedReferences`/
1830
+ // `inTreeReferenceCount` totals, so a table entry and a `jsr` are
1831
+ // refused (or not) by ONE rule, never two that could disagree.
1832
+ const split = emitSplitAddressLines(slice, block.dataType, block.start, blocks, labelIndex);
1833
+ inTreeReferenceCount += split.inTreeReferenceCount;
1834
+ unresolvedReferences.push(...split.unresolvedReferences);
1835
+ for (const dataLine of split.lines) {
1836
+ const emitted = withComments(dataLine.text, dataLine.start, dataLine.endExclusive, placement);
1212
1837
  content.push(...emitted);
1213
1838
  block.lineCount += emitted.length;
1214
1839
  }
1840
+ dataByteCount += slice.length;
1215
1841
  } else {
1216
1842
  // The `dataType` reaching `emitDataLines()` is the store's own string,
1217
1843
  // copied off the row and passed through -- this module never branches on
1218
- // it beyond the code/not-code test above and the `!word` eligibility
1219
- // check inside the emitter.
1844
+ // it beyond the code/not-code test above, the `!word` eligibility check,
1845
+ // and the `!binary` check, all three inside the emitter.
1220
1846
  for (const dataLine of emitDataLines(slice, block.dataType, block.start)) {
1221
1847
  const emitted = withComments(dataLine.text, dataLine.start, dataLine.endExclusive, placement);
1222
1848
  content.push(...emitted);
1223
1849
  block.lineCount += emitted.length;
1224
1850
  }
1225
1851
  dataByteCount += slice.length;
1852
+
1853
+ // an `external_file` block's bytes
1854
+ // also go out as their own sibling data file. `bytes` is `slice`
1855
+ // itself -- the same bytes `dataByteCount` above just counted, carried
1856
+ // verbatim and never re-read from anywhere else.
1857
+ if (block.dataType === EXTERNAL_FILE_DATA_TYPE) {
1858
+ binaries.push({ name: binaryFileName(block.start), start: block.start, endExclusive: block.endExclusive, bytes: slice });
1859
+ }
1860
+ }
1861
+
1862
+ // when ledger mode is on, every block gets a
1863
+ // provenance comment BEFORE it is bracketed -- never as a threshold, never
1864
+ // gating which blocks reach `emitBlock()` below. The only conditionals
1865
+ // here are "is ledger mode on", "did zero rows come back" (refuse) and
1866
+ // "did more than one come back" (record the ambiguity, choose neither) --
1867
+ // none of which reads a Verdict, Confidence or Kind VALUE.
1868
+ if (ledger !== undefined) {
1869
+ const overlapping = provenanceForRange(ledger, block.start, block.endExclusive - 1);
1870
+ if (overlapping.length === 0) {
1871
+ throw new Error(
1872
+ `exportAsm: the ledger at "${ledgerPath}" carries no row overlapping the block ${hexExtent(block.start)}..` +
1873
+ `${hexExtent(block.endExclusive - 1)} (inclusive) -- refusing to emit this block unannotated or with an invented ` +
1874
+ `verdict. Regenerate the ledger with c64-provenance-diff's "ledger" verb so it covers this range, or omit --ledger.`,
1875
+ );
1876
+ }
1877
+ const provenanceLines: string[] = [];
1878
+ for (const row of overlapping) {
1879
+ // The ONLY free-text field here (Evidence/Reason) goes through the
1880
+ // EXISTING comment-text validator -- never a second one. Verdict,
1881
+ // Confidence and Kind are the ledger's own short controlled-ish
1882
+ // strings, carried verbatim without re-validation, exactly as
1883
+ // `dataType` is copied onto the block comment elsewhere in this file.
1884
+ const checkedEvidence = assertExportableCommentText(row.evidence, block.start);
1885
+ provenanceLines.push(
1886
+ `${PROVENANCE_MARKER_PREFIX}${hex4(row.start)}..${hex4(row.endInclusive)} verdict=${row.verdict} ` +
1887
+ `confidence=${row.confidence} kind=${row.kind} agreeing=${row.agreeingReleases} evidence=${checkedEvidence}`,
1888
+ );
1889
+ }
1890
+ if (overlapping.length > 1) {
1891
+ // Multiplicity is RECORDED, never resolved by picking one (T-46-03) --
1892
+ // the same posture `ALIAS_MARKER_PREFIX` already takes for two store
1893
+ // labels at one address.
1894
+ provenanceLines.push(
1895
+ `${PROVENANCE_AMBIGUITY_MARKER_PREFIX}${overlapping.length} ledger rows overlap this block; none was chosen -- see above.`,
1896
+ );
1897
+ }
1898
+ // Prepended, not counted in `block.lineCount`: `lineCount` counts
1899
+ // CONTENT lines by its own doc-comment, and a provenance comment is
1900
+ // bookkeeping ABOUT the block -- the two `!if * != ...` assertions in
1901
+ // `emitBlock()` are what actually police its extent.
1902
+ content.unshift(...provenanceLines);
1903
+ }
1904
+
1905
+ // every recorded exclusion overlapping this
1906
+ // block gets a marker naming ITS OWN extent (never the block's) and its
1907
+ // checked reason -- NEVER a skipped block, a shortened slice, or a
1908
+ // shrunk `expectedBytes`. `block.start`/`block.endExclusive` above are
1909
+ // completely untouched by this: the slice was already taken, the code
1910
+ // was already decoded or the data lines already emitted, and every one
1911
+ // of those bytes stays in `content`. This is bookkeeping ABOUT the block,
1912
+ // exactly as the provenance comment above is -- prepended, and not
1913
+ // counted in `block.lineCount`, which by its own doc-comment counts
1914
+ // CONTENT lines only.
1915
+ //
1916
+ // THE OVERLAP TEST IS THE SAME PREDICATE `provenanceForRange()` and
1917
+ // `addExcludedRange()` BOTH USE (`row.start <= blockEndInclusive &&
1918
+ // row.endInclusive >= blockStart`, addExcludedRange's own predicate
1919
+ // transposed) -- textually the same test at all three sites, so they
1920
+ // agree by construction and not by three separate authors reaching the
1921
+ // same answer by coincidence. Sorted ascending by `start`: this plan's
1922
+ // own recorded (backstop) choice for two disjoint exclusions inside one
1923
+ // block, never derived from a written contract.
1924
+ const blockEndInclusive = block.endExclusive - 1;
1925
+ const overlappingExclusions = excludedRanges
1926
+ .filter((row) => row.start <= blockEndInclusive && row.endInclusive >= block.start)
1927
+ .sort((a, b) => a.start - b.start);
1928
+ if (overlappingExclusions.length > 0) {
1929
+ const exclusionLines: string[] = [];
1930
+ for (const row of overlappingExclusions) {
1931
+ // RE-CHECKED, NOT RE-DEFINED -- the same predicate
1932
+ // `assertExportableCommentText()` already applies to every stored
1933
+ // comment and to the ledger's own free-text evidence cell, on
1934
+ // EXACTLY the grounds `assertDataTypeForExport()`'s own comment a
1935
+ // few dozen lines above states: the store validated this reason at
1936
+ // write time, and a store file somebody edited on disk reaches this
1937
+ // point through a column nobody re-checked. A stored line break
1938
+ // would put everything after it into the ACME source at column
1939
+ // zero, as assembler input rather than as a comment.
1940
+ const checkedReason = assertExportableCommentText(row.reason, block.start);
1941
+ // THE EXCLUSION'S OWN EXTENT, NOT THE BLOCK'S -- a row narrower than
1942
+ // the block it lands in says so here, because the user asked about
1943
+ // a span, not about whatever block that span happened to land in.
1944
+ exclusionLines.push(`${EXCLUSION_MARKER_PREFIX}${hex4(row.start)}..${hex4(row.endInclusive)} ${checkedReason}`);
1945
+ excludedRangeIdsEmitted.add(row.id);
1946
+ }
1947
+ content.unshift(...exclusionLines);
1226
1948
  }
1227
1949
 
1228
1950
  // EVERY block goes through `emitBlock()`, code and data alike, so there is
1229
1951
  // exactly one place that brackets a block and no route that emits an
1230
- // unbracketed one.
1231
- blockLines.push(...emitBlock(block.start, block.endExclusive, content));
1952
+ // unbracketed one. Captured onto `block.lines` at
1953
+ // the SAME point it is pushed onto `blockLines` -- one call, two
1954
+ // destinations, never a second bracketing.
1955
+ const emittedBlockLines = emitBlock(block.start, block.endExclusive, content);
1956
+ block.lines = emittedBlockLines;
1957
+ blockLines.push(...emittedBlockLines);
1958
+ }
1959
+
1960
+ // an in-tree reference this export could
1961
+ // not resolve to a symbol is REFUSED BY NAME, once, across the whole
1962
+ // export -- never emitted as a hex literal that freezes the target's
1963
+ // address into the source while the export reports success. In the same
1964
+ // shape the unapplied-enum-usage and unplaced-comment refusals below
1965
+ // already use: name the first offender, state the "N of M" count, and say
1966
+ // plainly what is refused.
1967
+ //
1968
+ // A reference OUTSIDE every emitted block -- a hardware register, a
1969
+ // KERNAL entry -- is rendered as a hex literal and is NOT a refusal
1970
+ // (D47-F, `isInTree()`'s own doc-comment): that address is fixed hardware
1971
+ // which cannot move, which is precisely what "so the code can move" is
1972
+ // about, and this refusal never fires on one.
1973
+ //
1974
+ // No image byte, no mnemonic and no store comment text is ever
1975
+ // interpolated here -- only addresses and counts, on the same discipline
1976
+ // `assertDataTypeForExport()`'s own comment states.
1977
+ if (unresolvedReferences.length > 0) {
1978
+ const first = unresolvedReferences[0]!;
1979
+ throw new Error(
1980
+ `exportAsm: the instruction at ${hex4(first.referringAddress)} references ${hex4(first.targetAddress)}, an address inside an ` +
1981
+ `emitted block, but no label names that address -- every reference that has to move with the code goes through a symbol, and ` +
1982
+ `this one has none to go through. ${unresolvedReferences.length} of ${inTreeReferenceCount} in-tree reference(s) are in this ` +
1983
+ `state. Refusing to emit a hex literal that freezes the target's address into the source while reporting success -- the whole ` +
1984
+ `point of the symbol is that the code can move. Record a label at ${hex4(first.targetAddress)} to fix this.`,
1985
+ );
1232
1986
  }
1233
1987
 
1234
1988
  // EVERY store label is defined here, in a block BEFORE the first `* =`, not
@@ -1296,15 +2050,346 @@ export function exportAsm(options: ExportAsmOptions): ExportAsmResult {
1296
2050
 
1297
2051
  return {
1298
2052
  source: `${lines.join("\n")}\n`,
2053
+ headerLines,
2054
+ scopes,
1299
2055
  expectedBytes,
1300
2056
  blocks,
1301
2057
  symbolCount: sortedLabels.length,
1302
2058
  headerDefinitionCount: headerLines.length,
1303
2059
  unexpressibleCount,
1304
2060
  dataByteCount,
2061
+ binaries: [...binaries].sort((a, b) => a.start - b.start),
1305
2062
  commentCount: placement.placed.size,
1306
2063
  midInstructionLabelCount,
1307
2064
  autoNamedSymbolCount,
1308
2065
  enumSubstitutionCount: appliedEnumUsage.size,
2066
+ enumDecompositionCount,
2067
+ excludedRangeCount: excludedRangeIdsEmitted.size,
1309
2068
  };
1310
2069
  }
2070
+
2071
+ // ---------------------------------------------------------------------------
2072
+ // the TREE writer (D47-A). `exportAsm()` above stays the
2073
+ // proven EMITTER -- this is the primary shape a caller reaches for, built by
2074
+ // PARTITIONING `exportAsm()`'s already-proven emission, never by emitting a
2075
+ // second time through a second route. See `exportAsmTree()`'s own doc-comment
2076
+ // below for the one claim it is NOT allowed to make.
2077
+ // ---------------------------------------------------------------------------
2078
+
2079
+ /** D47-B: the tree's three fixed file names. DERIVED from nothing but this
2080
+ * module's own naming convention -- never from a store row -- so a store's
2081
+ * free text can never reach one of these three names. */
2082
+ export const ROOT_FILE_NAME = "root.a";
2083
+ export const SYMBOLS_FILE_NAME = "symbols.a";
2084
+ export const UNSCOPED_FILE_NAME = "unscoped.a";
2085
+
2086
+ /**
2087
+ * D47-B: the `.a` file name for the scope starting at `start` --
2088
+ * `scope_XXXX.a`, four LOWERCASE hex digits, no `$`, no store free text
2089
+ * anywhere in it.
2090
+ *
2091
+ * DERIVED FROM `start`, an integer this project already controls (a scope
2092
+ * row's own `start` field, itself validated by `addScope()` before it ever
2093
+ * reached the store) -- never from a label name, a comment or any other
2094
+ * store free text (T-47-01). This is what makes a re-export a REVIEWABLE
2095
+ * DIFF instead of full-tree churn: the same scope always gets the same file
2096
+ * name, so an unrelated edit elsewhere in the store does not rename files a
2097
+ * human may have opened.
2098
+ */
2099
+ export function scopeFileName(start: number): string {
2100
+ return `scope_${(start & 0xffff).toString(16).padStart(4, "0")}.a`;
2101
+ }
2102
+
2103
+ /**
2104
+ * the `.bin` sibling file name for the
2105
+ * `external_file`-typed block starting at `start` -- `data_XXXX.bin`, four
2106
+ * LOWERCASE hex digits, no `$`, no store free text anywhere in it. Module-
2107
+ * private: nothing outside this file needs the name computed independently
2108
+ * of `ExportBinary.name`/`emitDataLines()`'s own emitted `!binary` argument,
2109
+ * both of which already carry it.
2110
+ *
2111
+ * DERIVED FROM `start`, an integer this project already controls, on
2112
+ * exactly the same terms `scopeFileName()` above already states for `.a`
2113
+ * siblings (T-47-01) -- never from a label name, a comment or any other
2114
+ * store free text. This is what makes the emitted `!binary "..."` argument
2115
+ * safe to trust: it can only ever be four hex digits and a fixed prefix/
2116
+ * suffix, never a string a store row supplied.
2117
+ */
2118
+ function binaryFileName(start: number): string {
2119
+ return `data_${(start & 0xffff).toString(16).padStart(4, "0")}.bin`;
2120
+ }
2121
+
2122
+ export interface ExportAsmTreeOptions extends ExportAsmOptions {
2123
+ /** The directory the tree is written into. NOTHING CONFINES THIS PATH
2124
+ * INSIDE THIS MODULE, on exactly the same terms `imagePath`/`storePath`
2125
+ * above already carry (T-47-03) -- that sentence is present because an
2126
+ * absent comment beside a present one is itself a claim, and a silently-
2127
+ * undocumented path field is what a prior review named as the mechanism of
2128
+ * a real defect. The CALLER owns confining it (the CLI does, through
2129
+ * `storePathWithinWorkspace()`). Within the directory, every
2130
+ * name this function writes is DERIVED (`scopeFileName()`, the three fixed
2131
+ * constants above), so nothing the caller supplies can escape it a second
2132
+ * time. */
2133
+ outDir: string;
2134
+ /**
2135
+ * What the CALLER IS ASKING FOR, not a
2136
+ * switch that widens what this function is willing to destroy. Set, it
2137
+ * means "this directory already holds a tree I exported before -- replace
2138
+ * it." It deliberately does NOT mean "remove whatever is in my way": a
2139
+ * directory entry that is not one of this export's own file names is
2140
+ * refused by name even with `force: true`, never deleted to make room for
2141
+ * the write. See `exportAsmTree()`'s own doc-comment for the two-rule
2142
+ * contract this field gates.
2143
+ */
2144
+ force?: boolean;
2145
+ }
2146
+
2147
+ export interface ExportAsmTreeResult extends ExportAsmResult {
2148
+ outDir: string;
2149
+ /** Every file this call wrote, ASCENDING -- exactly the tree's on-disk
2150
+ * file-name set. */
2151
+ files: string[];
2152
+ /** The `!source` order `root.a` carries, D47-B: `symbols.a` first, then
2153
+ * each populated scope file ascending by scope start, then `unscoped.a`
2154
+ * last (only when non-empty). */
2155
+ sourceOrder: string[];
2156
+ }
2157
+
2158
+ /**
2159
+ * decides which file ONE block belongs to, given the
2160
+ * store's own scopes -- the ONE containment predicate, applied ONCE, so the
2161
+ * tree writer's placement answer can never drift from a second copy of this
2162
+ * question (see `blocks`'s own `.map()` above for the sibling boundary this
2163
+ * project already carries the same discipline for).
2164
+ *
2165
+ * A scope WHOLLY CONTAINS a block when `scope.start <= block.start` AND
2166
+ * `block.endExclusive - 1 <= scope.endInclusive`. That subtraction is the
2167
+ * inclusive/exclusive conversion, done HERE and only here -- a second
2168
+ * conversion site elsewhere in this function is exactly how the two answers
2169
+ * would drift.
2170
+ *
2171
+ * A block no scope contains, and that overlaps no scope AT ALL, belongs to
2172
+ * the unscoped group (D47-D). This is a genuine "goes somewhere, never
2173
+ * nowhere" answer rather than a refusal-in-disguise: `listScopes()` was read
2174
+ * by nothing in the export path before this plan, so every store that exists
2175
+ * today has zero scopes, and refusing here would make the tree export
2176
+ * unreachable for every one of them. Losslessness is this project's
2177
+ * governing constraint.
2178
+ *
2179
+ * A block that OVERLAPS a scope WITHOUT being wholly contained by it is
2180
+ * REFUSED (D47-C) -- scopes cannot themselves overlap (`addScope()`'s own
2181
+ * overlap refusal), so this is genuinely ambiguous rather than a case this
2182
+ * function could resolve by trying harder. The export refuses to CHOOSE a
2183
+ * file for it rather than guess what a straddling range means: `emitBlock()`
2184
+ * brackets every block with a single `* =` origin and a single
2185
+ * `!if * != ...` end assertion, and a block emitted in two pieces has no
2186
+ * single extent for that pair to assert. The fix belongs to the user --
2187
+ * move the scope boundary, or split the range -- never to this exporter.
2188
+ */
2189
+ function placeBlockInScope(block: ExportBlock, sortedScopes: readonly ScopeRow[]): ScopeRow | "unscoped" {
2190
+ const blockEndInclusive = block.endExclusive - 1;
2191
+ for (const scope of sortedScopes) {
2192
+ const whollyContained = scope.start <= block.start && blockEndInclusive <= scope.endInclusive;
2193
+ if (whollyContained) return scope;
2194
+
2195
+ const overlaps = block.start <= scope.endInclusive && scope.start <= blockEndInclusive;
2196
+ if (overlaps) {
2197
+ throw new Error(
2198
+ `exportAsmTree: the range ${hex4(block.start)}..${hex4(blockEndInclusive)} (inclusive) overlaps the scope ` +
2199
+ `${hex4(scope.start)}..${hex4(scope.endInclusive)} (inclusive) without being wholly contained by it -- refusing to choose ` +
2200
+ `which file it belongs in, because that would be this tool deciding what your range means. Splitting the range across two ` +
2201
+ `files is not offered instead: emitBlock() brackets every block with a single \`* =\` origin and a single ` +
2202
+ `\`!if * != ...\` end assertion, and a block emitted in two pieces has no single extent left for that pair to assert. ` +
2203
+ `Move the scope boundary, or split the range -- the fix belongs to you, not this export.`,
2204
+ );
2205
+ }
2206
+ }
2207
+ return "unscoped";
2208
+ }
2209
+
2210
+ /**
2211
+ * Writes `exportAsm()`'s already-proven emission as a TREE of real files on
2212
+ * disk, rather than emitting a second time through a second route --
2213
+ * `root.a` (D47-B order), `symbols.a`, one `scope_XXXX.a` per scope that
2214
+ * contains at least one block, and `unscoped.a` only when at least one block
2215
+ * lies inside no scope (D47-D).
2216
+ *
2217
+ * ASSIGNMENT RULE (D47-C/D47-D, both now implemented): every block is placed
2218
+ * by `placeBlockInScope()` above -- the scope that wholly contains it, the
2219
+ * unscoped group when no scope contains it at all, or a thrown refusal when
2220
+ * it overlaps a scope without being wholly contained. There is no fourth
2221
+ * outcome and no path that silently discards a block.
2222
+ *
2223
+ * Every `!source` argument this function emits is a bare filename -- no
2224
+ * directory component, no absolute path, no host-machine path anywhere in
2225
+ * the generated text, so the tree assembles on a machine that has never
2226
+ * seen this one (hard scope fence 2).
2227
+ *
2228
+ * `root.a` is written LAST, through a temp name in the same directory
2229
+ * followed by a `renameSync` into place -- the same atomic-publish
2230
+ * discipline `build.ts` already uses for every artifact it emits. A tree
2231
+ * whose root exists is a tree every file it sources exists for, so an
2232
+ * interrupted export leaves nothing an assembler would happily turn into a
2233
+ * wrong program.
2234
+ *
2235
+ * `exportAsmTree()` SPAWNS NOTHING AND ASSEMBLES NOTHING. Writing the tree is
2236
+ * not the same claim as the tree being able to reassemble, and nothing this
2237
+ * function writes, prints or returns may be read as an assembly verdict
2238
+ * (P-01) -- that claim is settled elsewhere, by a real ACME reached through
2239
+ * `runHostTool()`.
2240
+ */
2241
+ export function exportAsmTree(options: ExportAsmTreeOptions): ExportAsmTreeResult {
2242
+ const result = exportAsm(options);
2243
+ const { outDir } = options;
2244
+
2245
+ const sortedScopes = [...result.scopes].sort((a, b) => a.start - b.start);
2246
+ // Keyed by scope START (D47-B's own file-naming key), never by scope id --
2247
+ // the file name is a function of `start`, so the grouping key matches it.
2248
+ //
2249
+ // THIS WHOLE PASS RUNS BEFORE ANY FILE IS WRITTEN, on purpose: a refusal
2250
+ // that has already written half a tree has left an artefact a later
2251
+ // assemble might succeed on. `placeBlockInScope()` either returns a
2252
+ // placement or throws; nothing below this loop runs until every block has
2253
+ // been placed.
2254
+ const scopeBlocks = new Map<number, ExportBlock[]>();
2255
+ const unscopedBlocks: ExportBlock[] = [];
2256
+ for (const block of result.blocks) {
2257
+ const placement = placeBlockInScope(block, sortedScopes);
2258
+ if (placement === "unscoped") {
2259
+ unscopedBlocks.push(block);
2260
+ continue;
2261
+ }
2262
+ const existing = scopeBlocks.get(placement.start);
2263
+ if (existing) existing.push(block);
2264
+ else scopeBlocks.set(placement.start, [block]);
2265
+ }
2266
+
2267
+ // The FULL set of names this call will write, computed BEFORE any write and
2268
+ // BEFORE the directory-contract check below reads it: the placement pass
2269
+ // above already knows exactly which scopes are populated and whether any
2270
+ // block is unscoped, so this is arithmetic over what is already decided,
2271
+ // never a guess revised after the fact.
2272
+ const populatedScopeStarts = [...scopeBlocks.keys()].sort((a, b) => a - b);
2273
+ const hasUnscoped = unscopedBlocks.length > 0;
2274
+ // every `.bin` sibling this call will write
2275
+ // joins the SAME name set the directory contract below evaluates, so a
2276
+ // re-export with `force: true` may replace a previously-exported `.bin`
2277
+ // exactly as it may replace a previously-exported `.a` file -- and, without
2278
+ // `force`, a directory holding one is refused by name like anything else.
2279
+ const binaryNames = result.binaries.map((binary) => binary.name);
2280
+ const namesToWrite = [
2281
+ SYMBOLS_FILE_NAME,
2282
+ ...populatedScopeStarts.map((start) => scopeFileName(start)),
2283
+ ...(hasUnscoped ? [UNSCOPED_FILE_NAME] : []),
2284
+ ...binaryNames,
2285
+ ROOT_FILE_NAME,
2286
+ ];
2287
+
2288
+ // ---------------------------------------------------------------------
2289
+ // The output-directory contract. Two rules,
2290
+ // both evaluated BEFORE the first write below -- a refusal that has
2291
+ // already written half a tree has left an artefact a later assemble might
2292
+ // succeed on (the same reason the placement pass above runs to completion
2293
+ // before any write).
2294
+ //
2295
+ // Rule one, without `force`: a directory holding ANY entry at all is
2296
+ // refused by name, unconditionally. An export writes a whole tree and will
2297
+ // not mix its files with whatever the directory already held.
2298
+ //
2299
+ // Rule two, with `force`: the caller is asking "this directory already
2300
+ // holds a tree I exported before, replace it" -- never "remove whatever is
2301
+ // in my way". Anything in the directory that is NOT one of `namesToWrite`
2302
+ // is refused by name; nothing is ever deleted to make room for it. The
2303
+ // `.bin` files this export writes are the ones a person is expected to
2304
+ // edit by hand, so replacing them is something the user has to ask for,
2305
+ // and a directory the user pointed at by mistake must not lose a file this
2306
+ // tool never created.
2307
+ // ---------------------------------------------------------------------
2308
+ const existingEntries = existsSync(outDir) ? readdirSync(outDir) : [];
2309
+ if (existingEntries.length > 0) {
2310
+ if (!options.force) {
2311
+ throw new Error(
2312
+ `exportAsmTree: the output directory "${outDir}" already holds ${existingEntries.length} ` +
2313
+ `${existingEntries.length === 1 ? "entry" : "entries"} -- refusing to write into it. An export writes a whole tree and will ` +
2314
+ `not mix its files with whatever is already there. Pass \`force: true\` to ask for the overwrite explicitly if this directory ` +
2315
+ `holds a previous export of this same store.`,
2316
+ );
2317
+ }
2318
+ const namesToWriteSet = new Set(namesToWrite);
2319
+ const unexpected = existingEntries.filter((entry) => !namesToWriteSet.has(entry));
2320
+ if (unexpected.length > 0) {
2321
+ throw new Error(
2322
+ `exportAsmTree: the output directory "${outDir}" holds ${JSON.stringify(unexpected)}, which this export would NOT write -- ` +
2323
+ `refusing the overwrite. \`force: true\` means "replace the tree I exported here before", never "remove whatever is in my ` +
2324
+ `way": every name this export itself produces may be overwritten, but any other entry is left untouched. Remove it yourself, ` +
2325
+ `or point --out at an empty directory.`,
2326
+ );
2327
+ }
2328
+ }
2329
+ mkdirSync(outDir, { recursive: true });
2330
+
2331
+ const files: string[] = [];
2332
+ const sourceOrder: string[] = [];
2333
+
2334
+ // symbols.a -- ALWAYS written, sourced FIRST (D47-B). Measured live this
2335
+ // session: a zero-page symbol defined AFTER its first use widens the
2336
+ // referencing instruction, so every scope file depends on this one having
2337
+ // already run.
2338
+ const symbolsFileLines = [
2339
+ `; ${SYMBOLS_FILE_NAME} -- every symbol definition this export carries. Sourced FIRST by ${ROOT_FILE_NAME}: a symbol defined after its first use widens the referencing instruction (measured against real ACME 0.97), so every other file in this tree depends on this one having already run.`,
2340
+ ...result.headerLines,
2341
+ ];
2342
+ writeFileSync(join(outDir, SYMBOLS_FILE_NAME), `${symbolsFileLines.join("\n")}\n`, "utf8");
2343
+ files.push(SYMBOLS_FILE_NAME);
2344
+ sourceOrder.push(SYMBOLS_FILE_NAME);
2345
+
2346
+ // One scope_XXXX.a per POPULATED scope, ascending by scope start (D47-B).
2347
+ // `populatedScopeStarts` was already computed above, for `namesToWrite`.
2348
+ for (const scopeStart of populatedScopeStarts) {
2349
+ const name = scopeFileName(scopeStart);
2350
+ const blocksInScope = [...scopeBlocks.get(scopeStart)!].sort((a, b) => a.start - b.start);
2351
+ const fileLines = [`; ${name} -- one scope of this export's tree, addresses ${hex4(scopeStart)} upward.`, ...blocksInScope.flatMap((b) => b.lines)];
2352
+ writeFileSync(join(outDir, name), `${fileLines.join("\n")}\n`, "utf8");
2353
+ files.push(name);
2354
+ sourceOrder.push(name);
2355
+ }
2356
+
2357
+ // unscoped.a -- only when at least one block lies inside no scope (D47-D):
2358
+ // losslessness is the governing constraint, so every existing store (which
2359
+ // has zero scopes today, since nothing reads listScopes() yet) still
2360
+ // exports every block somewhere, never nowhere.
2361
+ if (unscopedBlocks.length > 0) {
2362
+ const sortedUnscoped = [...unscopedBlocks].sort((a, b) => a.start - b.start);
2363
+ const fileLines = [`; ${UNSCOPED_FILE_NAME} -- every block this export emitted that lies inside no scope.`, ...sortedUnscoped.flatMap((b) => b.lines)];
2364
+ writeFileSync(join(outDir, UNSCOPED_FILE_NAME), `${fileLines.join("\n")}\n`, "utf8");
2365
+ files.push(UNSCOPED_FILE_NAME);
2366
+ sourceOrder.push(UNSCOPED_FILE_NAME);
2367
+ }
2368
+
2369
+ // .bin siblings (T-47-08/T-47-09/T-47-10) -- one per
2370
+ // `external_file`-typed block, written in the SAME pass as every `.a`
2371
+ // file above and, like them, BEFORE root.a: data files precede the root
2372
+ // for the identical interruption-safety reason the `.a` files already do
2373
+ // -- a partial tree must have no root an assembler could start from.
2374
+ // These are NOT `!source`d, so `sourceOrder` is untouched; they are only
2375
+ // ever reached through the `!binary` line `emitDataLines()` already wrote
2376
+ // into their owning scope/unscoped `.a` file.
2377
+ for (const binary of result.binaries) {
2378
+ writeFileSync(join(outDir, binary.name), Buffer.from(binary.bytes.buffer, binary.bytes.byteOffset, binary.bytes.byteLength));
2379
+ files.push(binary.name);
2380
+ }
2381
+
2382
+ // root.a -- LAST, and atomically: a temp name in the SAME directory (so
2383
+ // `renameSync` is a same-filesystem rename, never EXDEV), then renamed into
2384
+ // place. A tree whose root exists is a tree every file it sources exists
2385
+ // for.
2386
+ const rootPath = join(outDir, ROOT_FILE_NAME);
2387
+ const rootTmpPath = join(outDir, `${ROOT_FILE_NAME}.tmp-${process.pid}`);
2388
+ const rootFileLines = [`; ${ROOT_FILE_NAME} -- this tree's entry point.`, "!cpu 6510", ...sourceOrder.map((name) => `!source "${name}"`)];
2389
+ writeFileSync(rootTmpPath, `${rootFileLines.join("\n")}\n`, "utf8");
2390
+ renameSync(rootTmpPath, rootPath);
2391
+ files.push(ROOT_FILE_NAME);
2392
+ files.sort();
2393
+
2394
+ return { ...result, outDir, files, sourceOrder };
2395
+ }