@henols/vice-mcp 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/README.md +4 -3
  2. package/THIRD-PARTY-NOTICES.md +423 -25
  3. package/{r2000-acme-ident.ts → anno-acme-ident.ts} +13 -13
  4. package/anno-bank.ts +171 -0
  5. package/anno-cli.ts +3040 -0
  6. package/{r2000-confidence.ts → anno-confidence.ts} +22 -22
  7. package/anno-coverage.ts +2465 -0
  8. package/anno-derive.ts +590 -0
  9. package/anno-details.ts +169 -0
  10. package/anno-enum-gen.ts +919 -0
  11. package/anno-export-asm.ts +2396 -0
  12. package/anno-graphics.ts +338 -0
  13. package/anno-hazard-report.ts +1367 -0
  14. package/anno-import.ts +495 -0
  15. package/anno-index.ts +150 -0
  16. package/anno-join.ts +480 -0
  17. package/{r2000-memmap-render.ts → anno-memmap-render.ts} +236 -95
  18. package/anno-provenance-ledger.ts +472 -0
  19. package/{r2000-regbits-gen.ts → anno-regbits-gen.ts} +20 -15
  20. package/{r2000-regbits.json → anno-regbits.json} +2 -2
  21. package/anno-register.ts +399 -0
  22. package/anno-store-export.ts +661 -0
  23. package/anno-store.ts +4002 -0
  24. package/anno-symbols.ts +266 -0
  25. package/anno-tools.ts +3264 -0
  26. package/anno-types.ts +1909 -0
  27. package/backend-detect.mts +124 -312
  28. package/block-class.ts +201 -0
  29. package/build.ts +4 -2
  30. package/capture-predicate.ts +597 -0
  31. package/channel-lock.ts +349 -0
  32. package/disasm-decoder.ts +14 -14
  33. package/disasm-opcodes.ts +4 -4
  34. package/disasm-renderer.ts +2 -2
  35. package/evid-ingest.ts +217 -0
  36. package/evid-reconcile.ts +316 -0
  37. package/host-tool-client.ts +430 -0
  38. package/hostpath.ts +1 -1
  39. package/incident-record.ts +23 -12
  40. package/install-resources.ts +30 -14
  41. package/memmap-lookup.ts +285 -0
  42. package/package.json +48 -23
  43. package/prg-image.ts +118 -0
  44. package/repo-root.ts +107 -8
  45. package/resources/backend-detect.mjs +98 -236
  46. package/resources/broker-control.mjs +189 -16
  47. package/resources/broker-epoch.mjs +1 -1
  48. package/resources/broker-kill.mjs +8 -2
  49. package/resources/broker-launch.mjs +373 -214
  50. package/resources/broker-state.mjs +64 -18
  51. package/resources/container-guard.mjs +1 -1
  52. package/resources/ghidra-project.mjs +790 -0
  53. package/resources/host-tool.mjs +2561 -0
  54. package/resources/vice-broker.mjs +330 -184
  55. package/resources/vice-launcher.sh +130 -12
  56. package/stock-address.ts +6 -6
  57. package/stock-cia.ts +2 -2
  58. package/stock-condition.ts +8 -8
  59. package/stock-connect.ts +10 -6
  60. package/stock-derived.ts +29 -37
  61. package/stock-diagnose.ts +200 -36
  62. package/stock-dispatch.ts +200 -68
  63. package/stock-execution.ts +5 -3
  64. package/stock-handler.ts +1 -1
  65. package/stock-input.ts +9 -9
  66. package/stock-machine.ts +17 -6
  67. package/stock-paths.ts +18 -14
  68. package/stock-petscii.ts +1 -1
  69. package/stock-protocol.ts +17 -12
  70. package/stock-recycle.ts +83 -2
  71. package/stock-registers.ts +54 -29
  72. package/stock-reproducible-run.ts +811 -0
  73. package/stock-run-until.ts +100 -1
  74. package/stock-sprites.ts +3 -3
  75. package/stock-symbols.ts +13 -13
  76. package/stock-timing.ts +2 -2
  77. package/stock-vicii.ts +1 -1
  78. package/stop-oracle.ts +167 -0
  79. package/text-capability-probe.ts +660 -0
  80. package/text-connect.ts +157 -0
  81. package/text-protocol.ts +810 -0
  82. package/text-tools.ts +778 -0
  83. package/textmon-backtrace.ts +385 -0
  84. package/textmon-cpuhistory.ts +335 -0
  85. package/textmon-memmap.ts +494 -0
  86. package/textmon-profile.ts +458 -0
  87. package/textmon-registers.ts +748 -0
  88. package/tools-manifest.stock.json +864 -3
  89. package/version.ts +1 -1
  90. package/vice-broker-client.ts +189 -42
  91. package/vice-errors.ts +268 -0
  92. package/vice-proxy.ts +392 -2175
  93. package/vsf-slice.ts +640 -0
  94. package/capability-registry.ts +0 -388
  95. package/r2000-cli.ts +0 -1103
  96. package/r2000-d64.ts +0 -310
  97. package/r2000-enum-gen.ts +0 -574
  98. package/r2000-launch.ts +0 -357
  99. package/r2000-mcp-client.ts +0 -596
  100. package/r2000-project.ts +0 -190
  101. package/r2000-symbols.ts +0 -388
  102. package/r2000-tools.ts +0 -914
  103. package/r2000-verify.ts +0 -184
  104. package/refresh-manifest.ts +0 -124
  105. package/tools-manifest.json +0 -1223
  106. package/vice-probe.ts +0 -278
  107. package/vice-sync.ts +0 -336
  108. package/vice.ts +0 -772
@@ -0,0 +1,2396 @@
1
+ // anno-export-asm.ts -- the ONE place annotation-store rows plus image bytes
2
+ // become ACME source text (EXPORT-01).
3
+ //
4
+ // ---------------------------------------------------------------------------
5
+ // WHY THIS FILE EXISTS
6
+ // ---------------------------------------------------------------------------
7
+ // The previous export route was WITHDRAWN in Phase 29 rather than left
8
+ // standing, because it made a reassembly claim nothing verified: it produced
9
+ // something that looked like ACME source and asserted, in effect, that
10
+ // assembling it would reproduce the program. No assembler ever ran. Withdrawing
11
+ // it was the right call and the withdrawal notices in both skill trees are the
12
+ // record that the capability was missing.
13
+ //
14
+ // This module is the rebuild, over the Phase 28 annotation store, and it is
15
+ // allowed to exist only because the claim is now settled somewhere else: a real
16
+ // ACME 0.97 assembles this module's output and the resulting bytes are diffed
17
+ // against the IMAGE bytes. Nothing in this file verifies this file. Re-reading,
18
+ // re-parsing or substring-matching the text below to decide whether the export
19
+ // is correct would be a self-check wearing an oracle's clothes, and this
20
+ // project's own record is that an internally-verified opcode table still
21
+ // shipped fourteen wrong entries.
22
+ //
23
+ // ---------------------------------------------------------------------------
24
+ // WHAT THIS IS THE ONE AUTHORITATIVE PLACE FOR
25
+ // ---------------------------------------------------------------------------
26
+ // Turning `(store rows, image bytes)` into `(ACME source text, the exact bytes
27
+ // that source must assemble to)`. The second half of that pair is what makes
28
+ // the round trip a round trip: `expectedBytes` is built from the IMAGE, never
29
+ // from `source`.
30
+ //
31
+ // ---------------------------------------------------------------------------
32
+ // WHAT NOT TO DO
33
+ // ---------------------------------------------------------------------------
34
+ // - Never re-derive the opcode table. `disasm-opcodes.ts` forbids a second
35
+ // copy by name, and its `acmeExpressible` column is round-trip-proven
36
+ // against a real assembler across all 256 opcodes. Decoding here happens
37
+ // through `decode()` and nowhere else.
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
40
+ // verified against real ACME. A second emitter would be a second answer to
41
+ // "how wide is this operand", and the two would drift silently.
42
+ // - Never restate the eleven auto-name prefixes here. `anno-types.ts:93-99`
43
+ // forbids a second copy of that vocabulary BY NAME, and names the exact
44
+ // failure a short reimplementation causes: a five-prefix copy silently
45
+ // under-counts, which breaks `routine-queue-walker`'s backlog construction
46
+ // while every test keeps passing. This module DOES need the vocabulary --
47
+ // it marks auto-generated names in the emitted source -- and it gets it by
48
+ // IMPORTING `AUTO_NAME_PREFIX_RE` from its one home. A copy made "just to
49
+ // filter" is how the eleventh prefix goes missing in one of two places.
50
+ // - Never emit an enum on anything but an IMMEDIATE operand. Measured on ACME
51
+ // 0.97, `sta viccolor_WHITE` with `viccolor_WHITE = $01` encodes as
52
+ // ZEROPAGE -- `85 01`, two bytes where the absolute original was three --
53
+ // so the substitution changes both the bytes and the instruction length. A
54
+ // non-immediate operand is REFUSED by name below, never rendered and hoped
55
+ // for.
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, (phase 47, plan 47-03)
60
+ // `EXTERNAL_FILE_DATA_TYPE`'s `!binary`-versus-inline branch inside
61
+ // `emitDataLines()`, and (phase 47, plan 47-06) `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.
67
+ // - Never import this tree's host/container path-translation modules
68
+ // (`hostpath.ts` / `containerpath.ts`). Their consumer set is a closed,
69
+ // mechanically asserted list of named modules and an exporter has no reason
70
+ // to join it -- the failure would surface as a test about something else
71
+ // entirely.
72
+ // - Never interpolate a read file's own bytes into an error message. A path,
73
+ // an address and a length are facts ABOUT a file; its contents are not, and
74
+ // an error text that quotes them turns a refusal into a content-disclosure
75
+ // oracle (CR-03). Every throw below carries paths, addresses and counts and
76
+ // nothing read out of the image or the store.
77
+ // - Never sanitise a label name. `assertLegalAcmeIdentifier()`'s contract is
78
+ // REJECT: a space-to-underscore substitution silently merges two distinct
79
+ // names into one, permanently, and the caller-visible name then diverges
80
+ // from what actually reached the ACME source.
81
+ //
82
+ // ---------------------------------------------------------------------------
83
+ // WHAT THIS FILE DOES NOT CHECK
84
+ // ---------------------------------------------------------------------------
85
+ // Addresses no range covers. `expectedBytes` spans `[minStart,
86
+ // maxEndExclusive)` with `$00` in the gaps between blocks -- which is exactly
87
+ // what ACME `-f plain` emits for those gaps (measured) -- so the padding is
88
+ // never a false disagreement. But an export makes no claim about bytes outside
89
+ // its own blocks, and neither does the byte-diff that settles it.
90
+ //
91
+ // SCOPE, STILL DELIBERATELY NARROW: code ranges, the twelve typed data ranges,
92
+ // comments, mid-instruction inline labels and immediate-operand enum
93
+ // substitution.
94
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
95
+ import { extname, join } from "node:path";
96
+
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";
100
+ import { assertLegalAcmeIdentifier } from "./anno-acme-ident.ts";
101
+ // The eleven typed auto-name prefixes, IMPORTED FROM THEIR ONE HOME rather than
102
+ // restated. This is the first cross-module PRODUCTION importer of that
103
+ // constant; before this the only consumers were `anno-coverage.ts`'s own
104
+ // `computeLabelRatio()` and its test file.
105
+ //
106
+ // WHY THE EXPORTER CARES. `routine-queue-walker`'s SKILL.md reads the typed
107
+ // prefixes as DOCUMENTED VOCABULARY (`s_`, `p_`, `b_`, `zpp_`, `zpf_`, `zpa_`,
108
+ // `f_`, `a_` at :128-181 and :220-226) and never imports the regex, so the
109
+ // coupling between that skill's backlog signal and this repo's definition of
110
+ // "auto-generated name" is by convention and would break in SILENCE. Marking
111
+ // those definitions in the exported source is what keeps the backlog visible to
112
+ // a human reading the generated assembly, which is the one artefact that leaves
113
+ // this tree.
114
+ //
115
+ // WHAT NOT TO DO: do not restate the eleven here, in any form -- not as an
116
+ // array, not as a second regex, not as a doc comment listing them.
117
+ // `anno-types.ts:93-99` forbids it by name, and `EXPORT-02` names the failure
118
+ // mode: a five-prefix copy under-counts silently.
119
+ import { AUTO_NAME_PREFIX_RE } from "./anno-coverage.ts";
120
+ // THE ONE OWNING DECODER (D-16, plan 45-03). This module decodes NOTHING
121
+ // itself -- `decomposeRegisterValue()` is the ONLY place a register value is
122
+ // split into named bit-fields, and `anno_disassemble` (plan 45-05, the other
123
+ // D-16 renderer) calls the SAME function. Never re-derive a per-field bit
124
+ // mask in this file -- a phase-45 verification gate greps this file's own
125
+ // text for that shape and must find none.
126
+ import { decomposeRegisterValue, hasRegBitsEntry, type RegisterDecomposition } from "./anno-enum-gen.ts";
127
+ import { decode } from "./disasm-decoder.ts";
128
+ import type { Instruction } from "./disasm-decoder.ts";
129
+ import { renderLine } from "./disasm-renderer.ts";
130
+ import { parsePrg, flatImageOrigin } from "./prg-image.ts";
131
+ // BUILD-05 (phase 46 plan 01): the ONE reader of `recovery/PROVENANCE.md`'s
132
+ // generated tier. This module never recomputes a verdict -- see
133
+ // `anno-provenance-ledger.ts`'s own header for why reading and recomputing
134
+ // are deliberately kept apart.
135
+ import { provenanceForRange, readProvenanceLedger, type ProvenanceLedger } from "./anno-provenance-ledger.ts";
136
+
137
+ /** The store's own spelling for an executable range, read out of the one home
138
+ * of that vocabulary rather than re-typed as a literal. `dataType` is never
139
+ * COMPARED anywhere else in this module -- `block-class.ts` owns
140
+ * interpretation; this single equality is the emitter deciding whether to run
141
+ * a decoder or dump bytes, and it is the only one. */
142
+ const CODE_DATA_TYPE = "code";
143
+
144
+ /** The store's own spelling for a large binary blob exported AS ITS OWN
145
+ * FILE rather than inline (phase 47, plan 47-03; `anno_set_data_type`'s
146
+ * schema calls this "large binary blob to export as-is") -- the eleventh of
147
+ * `DATA_TYPES`' twelve members, the one vocabulary this module imports
148
+ * rather than restates. The SAME discipline `CODE_DATA_TYPE` above already
149
+ * follows: one named constant carries the literal ONCE, so every comparison
150
+ * below reads the NAME and this is the only place the spelling itself is
151
+ * written down. `dataType` is never COMPARED anywhere else in this module
152
+ * for this purpose -- `emitDataLines()`'s `!binary`-versus-inline branch is
153
+ * the only one. */
154
+ const EXTERNAL_FILE_DATA_TYPE = "external_file";
155
+
156
+ /** One block as this module emitted it. */
157
+ export interface ExportBlock {
158
+ /** First address the block covers. */
159
+ start: number;
160
+ /** One past the last address the block covers. ACME's `*` sits here after
161
+ * the block, and the store's own row uses an INCLUSIVE end, so the
162
+ * conversion is `endExclusive = row.endInclusive + 1`. */
163
+ endExclusive: number;
164
+ /** The store's `dataType`, copied VERBATIM off the row. Never compared in
165
+ * this module beyond the single code/not-code branch the emitter needs. */
166
+ dataType: string;
167
+ /** How many CONTENT lines the block emitted -- not its `* =` origin line and
168
+ * not the two `!if * != ...` assertions that bracket it. */
169
+ lineCount: number;
170
+ /** Phase 47, plan 47-01: exactly what `emitBlock()` returned for this block
171
+ * -- the `* =` origin line, both `!if * != ...` bracket assertions, and
172
+ * every content line between them, in emitted order. This is FOR a tree
173
+ * writer to partition an emission that is already proven, rather than a
174
+ * second description of the block: `exportAsmTree()` writes each scope's
175
+ * `.a` file by concatenating its contained blocks' `lines` verbatim, never
176
+ * by re-deriving what a block looks like from `dataType`/`start`/
177
+ * `endExclusive` a second time. Populated by `exportAsm()` at the same
178
+ * point it calls `emitBlock()`; empty only before that call runs. */
179
+ lines: string[];
180
+ }
181
+
182
+ /**
183
+ * Phase 47, plan 47-03 (BUILD-02): one `external_file`-typed block's own
184
+ * `.bin` sibling. `bytes` is the block's own slice of the IMAGE, carried
185
+ * verbatim -- never a copy that passed through any conversion, on the same
186
+ * terms `expectedBytes` is derived from the image and never from `source`.
187
+ *
188
+ * A caller that writes `source` (or a tree's `.a` files) and not these
189
+ * bytes to their named files has written a source tree that names files
190
+ * nobody produced: every `!binary "..."` line `emitDataLines()` emits names
191
+ * exactly one of these entries, and `exportAsmTree()`'s whole tree-writing
192
+ * contract exists because of that obligation.
193
+ */
194
+ export interface ExportBinary {
195
+ /** The bare file name the emitted `!binary` line names -- `binaryFileName(start)`, never a store-supplied string. */
196
+ name: string;
197
+ /** First address the block covers -- same field as `ExportBlock.start`. */
198
+ start: number;
199
+ /** One past the last address the block covers -- same field as `ExportBlock.endExclusive`. */
200
+ endExclusive: number;
201
+ /** The block's own slice of the image, verbatim. `bytes.length === endExclusive - start`. */
202
+ bytes: Uint8Array;
203
+ }
204
+
205
+ export interface ExportAsmOptions {
206
+ /**
207
+ * The annotation store to export. `openStore()` below confines it against
208
+ * `workspaceRoot`; a CLI caller confines the same string a second time
209
+ * through `storePathWithinWorkspace()` before it ever reaches here, so both
210
+ * answers agree by construction rather than by a second rule. */
211
+ storePath: string;
212
+ /** The program image the store annotates -- a `.prg` (2-byte little-endian
213
+ * load address then payload) or a flat 64K capture. NOTHING CONFINES THIS
214
+ * PATH INSIDE THIS MODULE: it reaches `readFileSync` directly, so the CALLER
215
+ * owns its confinement, exactly as it owns `storePath`'s. That sentence is
216
+ * present because an absent comment beside a present one is itself a claim,
217
+ * and a silently-undocumented path field is what a prior review named as the
218
+ * mechanism of a real defect. */
219
+ imagePath: string;
220
+ /** The workspace root the store-path confinement is taken against. REQUIRED
221
+ * rather than defaulted: `openStore()`'s default behaviour is to CREATE the
222
+ * file, so an unconfined store path is a store file created wherever the
223
+ * caller's argument pointed -- and an export of a store this call just
224
+ * invented would read as "the program has no annotations". */
225
+ workspaceRoot: string;
226
+ /**
227
+ * The provenance ledger to annotate every emitted block from -- `c64-
228
+ * provenance-diff`'s generated `recovery/PROVENANCE.md`, read (never
229
+ * re-derived) through `readProvenanceLedger()`. OPTIONAL (assumption A1,
230
+ * `46-01-PLAN.md`): every existing caller that omits it keeps exporting
231
+ * exactly as before, byte for byte and comment for comment. NOTHING
232
+ * CONFINES THIS PATH INSIDE THIS MODULE, on the same terms as `imagePath`
233
+ * above -- the CALLER owns its confinement. Supplying it and having the
234
+ * file be unreadable, malformed, or missing a row for some block is a
235
+ * refusal; omitting it is not.
236
+ */
237
+ ledgerPath?: string;
238
+ }
239
+
240
+ export interface ExportAsmResult {
241
+ /** The ACME source text. */
242
+ source: string;
243
+ /** Phase 47, plan 47-01: the symbol-definition block VERBATIM -- every
244
+ * enum-variant definition line followed by every store label's own
245
+ * definition line, in exactly the order `source` carries them. This is FOR
246
+ * `exportAsmTree()` to write `symbols.a` from, never re-derived: `source`
247
+ * is still `["!cpu 6510", ...headerLines, ...blockLines].join("\n")` plus
248
+ * the trailing newline, byte for byte unchanged by this field's addition. */
249
+ headerLines: string[];
250
+ /** Phase 47, plan 47-01: every scope the store holds, read by `listScopes()`
251
+ * as a SEVENTH call inside this function's existing store handle -- there
252
+ * is still exactly one handle opened for the whole export. `exportAsmTree()`
253
+ * uses this to decide which scope (or none) each block belongs to; nothing
254
+ * in `exportAsm()` itself reads a scope's span to change what it emits. */
255
+ scopes: ScopeRow[];
256
+ /** The bytes `source` must assemble to, DERIVED FROM THE IMAGE and never
257
+ * from `source`. Spans `[minStart, maxEndExclusive)` across every emitted
258
+ * block, with `$00` filling the gaps between them -- which is exactly what
259
+ * ACME `-f plain` emits for those gaps (measured), so padding can never
260
+ * register as a disagreement while a wrong byte inside a covered range
261
+ * still fails. */
262
+ expectedBytes: Uint8Array;
263
+ /** The blocks emitted, ascending by start. Passed straight to the verify
264
+ * primitive as its `expectedSegments`. */
265
+ blocks: ExportBlock[];
266
+ /** How many STORE LABELS the export carries, header and inline together --
267
+ * i.e. `sortedLabels.length`, one per `anno_label` row in range.
268
+ *
269
+ * THIS DOC USED TO SAY "how many symbol definitions the header carries",
270
+ * AND THAT WAS NOT WHAT IT COUNTED (30-REVIEW WR-01, corrected 2026-08-31).
271
+ * The two readings diverge in BOTH directions: a mid-instruction label is
272
+ * defined inline and skipped by the header loop yet still counted here,
273
+ * and every `enumDefinitionLines` entry IS a header definition yet is not.
274
+ * Reproduced: a store with `start`@$0801 plus `smc_operand`/`smc_alias`
275
+ * both at the mid-instruction address $0802 emits a header carrying exactly
276
+ * ONE definition while this field reported 3 -- a number the CLI prints
277
+ * verbatim to the user as "3 symbol(s)".
278
+ *
279
+ * The header count is now its own field (`headerDefinitionCount` below)
280
+ * rather than this one being redefined, because both numbers have a real
281
+ * consumer and collapsing them into one is what produced the divergence. */
282
+ symbolCount: number;
283
+ /** How many definition lines the HEADER block actually carries -- enum
284
+ * variant definitions plus every store label NOT defined inline. Computed
285
+ * from `headerLines` itself, so it cannot drift from the emitted text the
286
+ * way a separately-maintained count did (30-REVIEW WR-01). */
287
+ headerDefinitionCount: number;
288
+ /** How many decoded instructions ACME's `!cpu 6510` cannot express, and
289
+ * which therefore went out as `!byte` directives with their mnemonic moved
290
+ * into a trailing comment. */
291
+ unexpressibleCount: number;
292
+ /** How many bytes went out through the DATA path -- every byte of every
293
+ * block whose `dataType` is not `code`. A code block contributes nothing
294
+ * here, however many bytes it decoded. */
295
+ dataByteCount: number;
296
+ /** Phase 47, plan 47-03 (BUILD-02): every `external_file`-typed block's
297
+ * own sibling data file, ascending by `start`. `dataByteCount` above still
298
+ * counts these bytes -- they went out through the data path exactly as an
299
+ * inline `!byte`-typed range's bytes would have, only to a different
300
+ * destination file, and a count whose name stops matching what it counts
301
+ * is the WR-01 lesson this module already learned once. See
302
+ * `ExportBinary`'s own doc-comment for the obligation a caller that reads
303
+ * this field takes on. */
304
+ binaries: ExportBinary[];
305
+ /** How many store comments the source carries. Always the store's FULL
306
+ * comment count when this function returns: a comment with no emitted line
307
+ * to attach to is refused by name rather than left out of this number. */
308
+ commentCount: number;
309
+ /** How many inline mid-instruction label definitions the source carries --
310
+ * one per store label whose address falls STRICTLY INSIDE a decoded
311
+ * instruction. `midInstructionLabelLine()` is the one place that spelling
312
+ * exists. Equal to the number of labels that were therefore EXCLUDED from the
313
+ * header definition block, because such a label is defined inline and
314
+ * defining it twice is ACME's `Symbol already defined.`
315
+ *
316
+ * COUNTED PER EMITTED DEFINITION, NOT PER ADDRESS (30-REVIEW WR-02,
317
+ * corrected 2026-08-31). It used to be `midInstructionLabelAddresses.size`,
318
+ * a set of ADDRESSES, while the inline loop emits one line per LABEL.
319
+ * `anno_label` is `unique` on `name` only and `setLabel()` refuses only a
320
+ * name already bound to a DIFFERENT address, so two names at one address is
321
+ * a supported store state -- and in it, two inline definitions were emitted
322
+ * and two labels excluded from the header while this field reported 1,
323
+ * contradicting the sentence directly above. */
324
+ midInstructionLabelCount: number;
325
+ /** How many emitted symbol definitions carry an AUTO-GENERATED name, decided
326
+ * by `AUTO_NAME_PREFIX_RE` -- the eleven typed prefixes, read from their one
327
+ * home and never restated here. This is `routine-queue-walker`'s backlog
328
+ * signal, surfaced in the one artefact that leaves this tree. */
329
+ autoNamedSymbolCount: number;
330
+ /** How many instruction operands were rendered through a project enum's
331
+ * variant name instead of a hex literal. Every one of them is an IMMEDIATE
332
+ * operand; any other operand role is refused. INCLUDES decomposed writes
333
+ * (`enumDecompositionCount` below is a SUBSET of this, never added to it --
334
+ * two named figures, never one combined figure). */
335
+ enumSubstitutionCount: number;
336
+ /** How many of `enumSubstitutionCount`'s substitutions were rendered as an
337
+ * OR-ed multi-bit decomposition (D-16/D-17) rather than a single whole-value
338
+ * variant name -- one register key `regbits` entry with two or more
339
+ * fields, decoded through `decomposeRegisterValue()`. A single-field
340
+ * register, or an enum usage whose name is not a register key at all,
341
+ * counts toward `enumSubstitutionCount` only, never here. */
342
+ enumDecompositionCount: number;
343
+ /** How many RECORDS from `anno_excluded_range` the export emitted a marker
344
+ * for -- at least one of their addresses overlapping at least one emitted
345
+ * block. `BUILD-07` (phase 46 plan 05).
346
+ *
347
+ * COUNTS RECORDS, NOT MARKER LINES AND NOT EXCLUDED BYTES -- the same
348
+ * WR-01 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;
356
+ }
357
+
358
+ /** How many raw bytes go on one `!byte` line for a non-code block. */
359
+ const BYTES_PER_DATA_LINE = 16;
360
+
361
+ /** How many 16-bit values go on one `!word` line. */
362
+ const WORDS_PER_DATA_LINE = 8;
363
+
364
+ /**
365
+ * The two `DATA_TYPES` members whose bytes are ADJACENT little-endian pairs,
366
+ * and therefore the only ones `!word` can emit without changing a byte.
367
+ *
368
+ * THIS IS THE ONE PLACE IN THIS MODULE A TYPE NAME IS READ FOR EMISSION, other
369
+ * than `CODE_DATA_TYPE`'s decoder/dump branch. `block-class.ts` owns
370
+ * INTERPRETATION of a `dataType`; these two names are read here only to answer
371
+ * "may this block's bytes be re-grouped into pairs", which is a question about
372
+ * the emitted TEXT and not about what the data means. Every other type -- the
373
+ * four split-table layouts included, whose low and high halves are NOT adjacent
374
+ * pairs -- is copied verbatim onto a `!byte` line and never compared.
375
+ */
376
+ const WORD_PAIR_DATA_TYPES: readonly string[] = Object.freeze(["word", "address"]);
377
+
378
+ /**
379
+ * Phase 47, plan 47-06 (BUILD-03): 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
+
398
+ /** One emitted data line, with the address span it covers. The span is what
399
+ * lets a stored comment find its line: a `!byte` line covers up to sixteen
400
+ * addresses, and a comment on any of them belongs to that line. */
401
+ interface DataLine {
402
+ text: string;
403
+ start: number;
404
+ endExclusive: number;
405
+ }
406
+
407
+ /**
408
+ * Emits one non-code block's bytes.
409
+ *
410
+ * THE RULE, and its reason: byte-identity is the criterion, and `!byte` is
411
+ * byte-identical for every type. `!word` is used ONLY where it is provably
412
+ * identical AND improves readability -- `word` and `address` ranges of even
413
+ * length, where ACME's `!word` emits little-endian pairs (measured). Everything
414
+ * else goes out as `!byte`.
415
+ *
416
+ * `!text` IS DELIBERATELY NOT EMITTED for `petscii` / `screencode`. `!text`
417
+ * applies ACME's CURRENT conversion table, and a conversion this exporter does
418
+ * not control is exactly the way a byte-identical claim stops being true --
419
+ * silently, on somebody else's machine, with a different ACME build. The type
420
+ * name goes into the trailing comment instead, so a human reader loses nothing
421
+ * a converter would have told them about the bytes' meaning.
422
+ *
423
+ * `stock-petscii.ts` was checked for a reusable byte-to-text converter and
424
+ * exports only `asciiToPetscii()` -- the other direction. A second conversion
425
+ * table is NOT invented here; that would be the same drift hazard wearing a
426
+ * local name.
427
+ *
428
+ * `external_file` (phase 47, plan 47-03) 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
+ *
440
+ * OVERLAP: `--strict-segments` is in the verify argv, which promotes ACME's
441
+ * "Segment starts inside another one, overwriting it." from a Warning to an
442
+ * Error (measured, exit 1). Without it a store holding two overlapping ranges
443
+ * would silently overwrite one with the other and the byte-diff would compare
444
+ * against whichever won.
445
+ */
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
+
457
+ const out: DataLine[] = [];
458
+
459
+ if (WORD_PAIR_DATA_TYPES.includes(dataType) && slice.length % 2 === 0) {
460
+ for (let offset = 0; offset < slice.length; offset += WORDS_PER_DATA_LINE * 2) {
461
+ const chunk = slice.subarray(offset, Math.min(offset + WORDS_PER_DATA_LINE * 2, slice.length));
462
+ const values: string[] = [];
463
+ for (let i = 0; i < chunk.length; i += 2) values.push(hex4(chunk[i]! | (chunk[i + 1]! << 8)));
464
+ out.push({
465
+ text: `${INDENT}!word ${values.join(", ")} ; ${dataType}`,
466
+ start: blockStart + offset,
467
+ endExclusive: blockStart + offset + chunk.length,
468
+ });
469
+ }
470
+ return out;
471
+ }
472
+
473
+ // The fallback says WHY it happened, because "this word table came out as
474
+ // bytes" is otherwise indistinguishable from a missing feature.
475
+ const why = WORD_PAIR_DATA_TYPES.includes(dataType)
476
+ ? ` (odd byte count ${slice.length} -- !word emits PAIRS, so a byte-identical emission falls back to !byte)`
477
+ : "";
478
+ for (let offset = 0; offset < slice.length; offset += BYTES_PER_DATA_LINE) {
479
+ const chunk = slice.subarray(offset, Math.min(offset + BYTES_PER_DATA_LINE, slice.length));
480
+ out.push({
481
+ text: `${INDENT}!byte ${[...chunk].map(hex2).join(", ")} ; ${dataType}${why}`,
482
+ start: blockStart + offset,
483
+ endExclusive: blockStart + offset + chunk.length,
484
+ });
485
+ }
486
+ return out;
487
+ }
488
+
489
+ /** Phase 47, plan 47-06 (BUILD-03): 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
+
626
+ /** ACME source indent for directive lines, matching `disasm-renderer.ts`'s own
627
+ * cosmetic indent so the two emitters' output reads as one document. */
628
+ const INDENT = " ";
629
+
630
+ function hex2(value: number): string {
631
+ return `$${(value & 0xff).toString(16).padStart(2, "0")}`;
632
+ }
633
+
634
+ function hex4(value: number): string {
635
+ return `$${(value & 0xffff).toString(16).padStart(4, "0")}`;
636
+ }
637
+
638
+ /**
639
+ * `$XXXX` FOR A BLOCK'S EXCLUSIVE END, WHICH IS `hex4()` -- MASKED, NOT PADDED.
640
+ *
641
+ * THIS FUNCTION USED TO DO THE OPPOSITE, AND IT WAS WRONG (30-REVIEW WR-04,
642
+ * corrected 2026-08-31). It padded without masking, so a range ending at
643
+ * `$ffff` produced the end assertion `!if * != $10000`. Its doc justified that
644
+ * by asserting that `hex4()`'s mask "would render that as `$0000` -- an
645
+ * assertion no assembly can ever satisfy, firing on a correct export". That
646
+ * claim was never measured, and it is FALSE in exactly the direction that
647
+ * matters: ACME's `*` is a 16-BIT program counter and WRAPS.
648
+ *
649
+ * MEASURED, real ACME 0.97 "Zem" on this host, 2026-08-31:
650
+ *
651
+ * !cpu 6510
652
+ * * = $fffe
653
+ * !if * != $fffe { !error "origin drifted, expected $fffe" }
654
+ * !byte $aa, $bb
655
+ * !if * != $0000 { !error "end drifted, expected $0000" }
656
+ *
657
+ * -> exit 0, output file written, bytes `aa bb`.
658
+ *
659
+ * The same source with `!if * != $10000` fails: `!error: end drifted` and no
660
+ * output file. So the UNMASKED form is the one that "fires on a correct
661
+ * export", for every range touching the top of memory -- and the failure looks
662
+ * like an exporter bug rather than an arithmetic one.
663
+ *
664
+ * WHY THE ASSUMPTION LOOKED SAFE: ACME's own `-v2` diagnostics DO print the
665
+ * unwrapped extent, `Saving 2 (0x2) bytes (0xfffe - 0x10000 exclusive)`. That
666
+ * is ACME describing a SEGMENT; `*` is a different thing and wraps. Do not
667
+ * reintroduce an unmasked extent on the strength of that line.
668
+ *
669
+ * THE GUARD STILL BITES AT THE TOP OF MEMORY, measured the same way: the same
670
+ * source emitting ONE byte instead of two leaves `*` at `$ffff`, the
671
+ * assertion fires, ACME exits 1 and writes no output file. The mask does not
672
+ * make the top-of-memory assertion vacuous.
673
+ *
674
+ * Kept as a named function rather than folded into `hex4()` so this record has
675
+ * somewhere to live, and so the ONE value in this module that is not an
676
+ * address still reads differently at its call site.
677
+ */
678
+ function hexExtent(value: number): string {
679
+ return hex4(value);
680
+ }
681
+
682
+ /**
683
+ * Wraps one block's content lines in its origin and its `*` assertions.
684
+ *
685
+ * THE EXCLUSIVE END IS THE POINT. Measured on ACME 0.97: `* = $0801` followed
686
+ * by `lda #$00` and `rts` leaves `*` at `$0804`, one past the last emitted
687
+ * byte. The store's own row uses an INCLUSIVE end, so the conversion is
688
+ * `endExclusive = row.endInclusive + 1` and it is done in exactly one place
689
+ * (see `exportAsm()`'s block construction).
690
+ *
691
+ * WHY BOTH ENDS. `!cpu 6510` plus correct-looking mnemonics is not enough:
692
+ * measured, a substitution that changes ONE instruction's length -- dropping
693
+ * ACME's `+2` size force from an absolute operand below `$0100`, or
694
+ * forward-referencing a zero-page symbol -- assembles at exit 0 and shifts
695
+ * every byte after it. The end assertion is what turns that into a refusal:
696
+ * ACME exits 1, prints the `!error` text below on stderr, and writes NO output
697
+ * file.
698
+ *
699
+ * WHY THE EXPECTED VALUE IS SPELLED OUT IN THE MESSAGE. Interpolating `*` into
700
+ * an `!error` renders it as `<decimal> (0x<hex>)`, not as `$hex`, so the
701
+ * expected value is written in `$` form in the message text itself rather than
702
+ * relying on ACME's rendering.
703
+ *
704
+ * WHY NOT `!pseudopc`. It is not an alternative: measured, it errors with
705
+ * `Program counter undefined.` unless `*` has already been set.
706
+ */
707
+ function emitBlock(start: number, endExclusive: number, lines: readonly string[]): string[] {
708
+ return [
709
+ `* = ${hexExtent(start)}`,
710
+ `!if * != ${hexExtent(start)} { !error "export-asm: block origin drifted, expected ${hexExtent(start)}" }`,
711
+ ...lines,
712
+ `!if * != ${hexExtent(endExclusive)} { !error "export-asm: block end drifted, expected ${hexExtent(endExclusive)}" }`,
713
+ ];
714
+ }
715
+
716
+ /**
717
+ * Reads the image and dispatches its layout, BY EXTENSION FIRST and never by
718
+ * byte length. That order is a contract copied from the surface's own image
719
+ * loaders rather than re-derived: a truncated flat capture that falls through
720
+ * to the `.prg` parser gets a load address read backwards out of its own
721
+ * payload bytes, and every downstream address is then wrong with no
722
+ * diagnostic.
723
+ */
724
+ function loadImage(imagePath: string): { origin: number; bytes: Uint8Array } {
725
+ let raw: Uint8Array;
726
+ try {
727
+ raw = new Uint8Array(readFileSync(imagePath));
728
+ } catch (err) {
729
+ // An ERRNO-class failure (ENOENT, EACCES, EISDIR) carries no byte of the
730
+ // file's content, so it is left interpolated on purpose.
731
+ throw new Error(`exportAsm: could not read the image at "${imagePath}": ${err instanceof Error ? err.message : String(err)}`);
732
+ }
733
+
734
+ const ext = extname(imagePath).toLowerCase();
735
+ if (ext === ".raw" || ext === ".bin") {
736
+ return { origin: flatImageOrigin(raw), bytes: raw };
737
+ }
738
+ if (ext !== ".prg" && raw.length === 65536) {
739
+ return { origin: flatImageOrigin(raw), bytes: raw };
740
+ }
741
+ if (ext === ".prg") {
742
+ const { origin, body } = parsePrg(raw);
743
+ return { origin, bytes: new Uint8Array(body) };
744
+ }
745
+ throw new Error(
746
+ `exportAsm: the image at "${imagePath}" has extension "${ext}", which is not one this exporter reads. ` +
747
+ `Supply a .prg (2-byte load address plus payload) or a flat 64K capture (.raw/.bin, exactly 65536 bytes).`,
748
+ );
749
+ }
750
+
751
+ /**
752
+ * Formats one header symbol definition.
753
+ *
754
+ * THE HEX-DIGIT COUNT IS LOAD-BEARING, not cosmetic. Measured on ACME 0.97:
755
+ * `zpf = $10` then `lda zpf` assembles to `a5 10` (2 bytes, zeropage), while
756
+ * `zpf = $0010` then the same `lda zpf` assembles to `ad 10 00` (3 bytes,
757
+ * absolute). The DEFINITION's width decides the OPERAND's width, so an address
758
+ * below `$0100` must be written with two digits and everything else with four.
759
+ * Getting this wrong ships an export that ACME accepts and that produces the
760
+ * wrong bytes -- the single most likely way for this module to be quietly
761
+ * incorrect.
762
+ *
763
+ * THE HEX CASE IS LOWER, MATCHING EVERY OTHER EMITTER IN THIS DOCUMENT
764
+ * (30-REVIEW IN-03, corrected 2026-08-31). This function used to emit
765
+ * uppercase (`start = $C000`) while `hex2()`, `hex4()` and `hexExtent()` all
766
+ * emit lowercase (`* = $0801`, `!byte $a9`), so one generated file carried two
767
+ * conventions. Both assemble identically -- ACME is case-insensitive for hex
768
+ * digits, and the round-trip byte-diff is unchanged by this -- so the only
769
+ * cost was that the artefact read as if two tools had written it. Lower is
770
+ * chosen because it is what the other three emitters, and the golden witness
771
+ * disassembly they were matched to, already use: one emitter changes rather
772
+ * than three.
773
+ *
774
+ * The WIDTH rule above is untouched by this and is not a matter of taste.
775
+ */
776
+ function formatSymbolDefinition(name: string, address: number): string {
777
+ const digits = address < 0x100 ? 2 : 4;
778
+ return `${name} = $${address.toString(16).padStart(digits, "0")}`;
779
+ }
780
+
781
+ /**
782
+ * One mid-instruction label definition, in the golden witness's own compact
783
+ * spelling -- no spaces around the `=`, the offset in two hex digits:
784
+ * `f_0900 =*+$01` [`.planning/notes/dxa-ghidra-pivot-evidence/anno.asm:202`].
785
+ * That witness carries SIX such labels (lines 51, 81, 135, 145, 202 and 205);
786
+ * the ROADMAP note saying four is documentation drift, corrected in
787
+ * `30-RESEARCH.md`.
788
+ *
789
+ * `offset` is `label.address - instr.address`, so it is 1 or 2 for every
790
+ * 6502/6510 instruction -- the value is rendered rather than bounded here
791
+ * because the caller derives it from a decoded instruction's own length and
792
+ * cannot produce anything else.
793
+ */
794
+ function midInstructionLabelLine(name: string, offset: number): string {
795
+ return `${name} =*+$${offset.toString(16).padStart(2, "0")}`;
796
+ }
797
+
798
+ /**
799
+ * Below this address a mid-instruction label is REFUSED rather than emitted.
800
+ * See `exportAsm()`'s code-block emitter for the measured reason.
801
+ */
802
+ const MID_INSTRUCTION_LABEL_FLOOR = 0x100;
803
+
804
+ /**
805
+ * The fixed trailing comment that marks an auto-generated symbol name in the
806
+ * emitted source. ONE spelling, in one place: a second wording would make the
807
+ * marker ungreppable for the human reading the generated assembly, which is the
808
+ * only reader it exists for.
809
+ */
810
+ const AUTO_NAME_MARKER = " ; auto-generated name -- still in the annotation backlog";
811
+
812
+ /**
813
+ * The fixed trailing comment that marks a definition at an address carrying
814
+ * MORE THAN ONE store label (30-REVIEW WR-02). ONE spelling, in one place, for
815
+ * the same reason `AUTO_NAME_MARKER` is: a second wording makes it ungreppable
816
+ * for the only reader it exists for.
817
+ *
818
+ * The full marker is this prefix, the colliding names in `sortedLabels` order,
819
+ * and which of them references actually render through -- so the arbitrary
820
+ * pick `symbolFor()` used to make in silence is stated in the artefact.
821
+ */
822
+ const ALIAS_MARKER_PREFIX = " ; ALIAS: this address also carries ";
823
+
824
+ /**
825
+ * The fixed leading comment on every line carrying a provenance ledger row's
826
+ * Verdict and Confidence (BUILD-05, phase 46 plan 01). ONE spelling, in one
827
+ * place, for the same reason `AUTO_NAME_MARKER` and `ALIAS_MARKER_PREFIX`
828
+ * are: a second wording makes it ungreppable for the only reader it exists
829
+ * for.
830
+ *
831
+ * THIS SPELLING WAS CHOSEN, NOT INHERITED. `diff-images.mjs`'s own
832
+ * `renderLedger()` header prose mentions `; PROVENANCE:` once, as an
833
+ * unwired, forward-looking remark about THIS PROJECT'S OWN documentation
834
+ * provenance conventions -- it names no writer, no reader and no format, and
835
+ * `.planning/ARCHITECTURE.md` (the real one, not `.planning/research/
836
+ * ARCHITECTURE.md`) never uses the word "provenance" at all. Reusing that
837
+ * string silently would attribute intent to it that it does not carry
838
+ * (`46-RESEARCH.md` Pitfall 6). `PROVENANCE_MARKER_PREFIX` is therefore a
839
+ * deliberately DIFFERENT, prefix-distinct spelling: `; PROVENANCE LEDGER:`,
840
+ * never `; PROVENANCE:`.
841
+ */
842
+ const PROVENANCE_MARKER_PREFIX = " ; PROVENANCE LEDGER: ";
843
+
844
+ /**
845
+ * The fixed leading comment recording that MORE THAN ONE ledger row overlaps
846
+ * one emitted block. Same rationale as `PROVENANCE_MARKER_PREFIX` above: one
847
+ * spelling, in one place. The ambiguity is RECORDED, exactly as
848
+ * `ALIAS_MARKER_PREFIX` records a colliding label, never resolved by picking
849
+ * one row and staying silent about the rest (T-46-03).
850
+ */
851
+ const PROVENANCE_AMBIGUITY_MARKER_PREFIX = " ; PROVENANCE LEDGER AMBIGUITY: ";
852
+
853
+ /**
854
+ * The fixed leading comment on every line naming a user-requested exclusion
855
+ * (`BUILD-07`, phase 46 plan 05). ONE spelling, in one place, for the same
856
+ * reason `AUTO_NAME_MARKER`, `ALIAS_MARKER_PREFIX` and `PROVENANCE_MARKER_PREFIX`
857
+ * are: a second wording would make it ungreppable for the only reader it
858
+ * exists for, and criterion 2's own readback -- recovering what was excluded
859
+ * and why straight out of the exported text -- depends on there being exactly
860
+ * one spelling to anchor on.
861
+ *
862
+ * THIS IS HOW A USER'S REQUEST TO LEAVE A SPAN OUT APPEARS IN THE ARTEFACT,
863
+ * and it is deliberately a MARKER rather than an OMISSION: `BUILD-07` requires
864
+ * the export be lossless by default, and any exclusion be "emitted as a
865
+ * recorded excluded range rather than a hole". The failure this constant's
866
+ * existence prevents is the obvious wrong implementation -- skipping the
867
+ * block would satisfy the word "exclude" and lose the bytes, and the
868
+ * byte-diff oracle downstream could only ever report that as a coverage gap,
869
+ * never as "the user asked for this". Every block carrying an overlapping
870
+ * exclusion record is still emitted in full, with this comment prepended,
871
+ * never in place of any content.
872
+ */
873
+ export const EXCLUSION_MARKER_PREFIX = " ; EXCLUDED BY USER REQUEST: ";
874
+
875
+ /**
876
+ * The largest value an enum variant may carry to be substitutable into an
877
+ * IMMEDIATE operand.
878
+ *
879
+ * Measured on ACME 0.97: `viccolor_WIDE = $0100` then `lda #viccolor_WIDE`
880
+ * is `Error ... : Number does not fit in 8 bits.` at exit 1. The exporter
881
+ * refuses FIRST so the message can name the store row and the enum, rather than
882
+ * a line number in a temp file the caller never sees.
883
+ */
884
+ const MAX_IMMEDIATE_VARIANT_VALUE = 0xff;
885
+
886
+ /**
887
+ * The SHAPE `registerKeyFor(address).slice(1)` always produces (uppercase,
888
+ * exactly four hex digits) -- the SAME string `planEnumsForPairing()` uses as
889
+ * a project enum's own `enumName` (`anno-enum-gen.ts`, D-15). An enum usage
890
+ * whose `enumName` matches this shape is a CANDIDATE for
891
+ * `decomposeRegisterValue()`; one that does not (a hand-authored name like
892
+ * `viccolor`) is never a candidate at all -- this module holds no second
893
+ * table of which arbitrary names are "really" registers, and guessing would
894
+ * be exactly the kind of plausible-looking wrong answer this file refuses
895
+ * everywhere else. Lowercase is deliberately excluded: every writer of this
896
+ * convention (`registerKeyFor()`) emits uppercase, and matching lowercase too
897
+ * would accept a shape nothing in this codebase produces.
898
+ *
899
+ * SHAPE ALONE IS NOT ENOUGH (45-REVIEW CR-01, fixed 2026-09-11): a candidate
900
+ * is only ATTEMPTED once `hasRegBitsEntry()` also confirms `anno-regbits.json`
901
+ * has a table entry for it. `$D020`/`$D021` -- among the most commonly
902
+ * hand-annotated C64 registers -- are register-shaped and absent from the
903
+ * table; before this fix, naming either via `anno_create_project_enum` made
904
+ * the WHOLE export throw instead of falling through to the pre-existing
905
+ * single-symbol path. See the call site below for the two-part gate.
906
+ */
907
+ const REGISTER_ENUM_NAME_RE = /^[0-9A-F]{4}$/;
908
+
909
+ /**
910
+ * Replaces the `#$XX` immediate literal `renderLine()` produced with `#symbol`.
911
+ *
912
+ * WHY A TARGETED TEXT SUBSTITUTION RATHER THAN A `RenderOptions` WIDENING.
913
+ * D-11 forbids `renderLine()` from substituting a symbol into an immediate
914
+ * operand at all, because of the `#<`/`#>` high/low-byte ambiguity, and that
915
+ * rule is verified against a real assembler in `disasm-roundtrip.test.ts`. It
916
+ * is not relaxed here. What an ENUM adds is a caller-supplied fact the renderer
917
+ * does not have -- that this particular byte is a member of a named vocabulary
918
+ * -- so the substitution happens at this boundary, on this module's own output,
919
+ * for exactly one operand whose width is one byte and therefore cannot change.
920
+ *
921
+ * The literal is located and matched EXACTLY. A rendered line that does not
922
+ * carry the expected literal is a disagreement between this module and the
923
+ * renderer, and it is refused rather than patched over: a `replace()` that
924
+ * silently matched nothing would emit the hex literal while the count claimed a
925
+ * substitution happened.
926
+ *
927
+ * THE SEARCH IS CONFINED TO THE DIRECTIVE HALF OF THE LINE, AND THAT IS THE
928
+ * SECOND HALF OF 30-REVIEW CR-01's FIX (2026-08-31). `renderLine()` emits a
929
+ * trailing `" ; "` comment for notes, and for an instruction whose
930
+ * `acmeExpressible` is false it emits the whole thing as a `!byte` directive
931
+ * with the mnemonic AND its `#$xx` operand moved INTO that comment
932
+ * (`disasm-renderer.ts`'s `!instr.acmeExpressible` branch). A bare
933
+ * `line.indexOf()` therefore found `#$00` in the COMMENT, rewrote it there,
934
+ * and returned a line whose assembler-visible half still carried the raw
935
+ * byte -- while the caller counted a substitution and the export exited 0.
936
+ * Reproduced against the committed code before this fix, for `$eb`
937
+ * (`sbc #imm`):
938
+ *
939
+ * !byte $eb, $00 ; sbc #viccolor_BLACK [illegal opcode | not expressible ...]
940
+ *
941
+ * The caller now refuses an unexpressible opcode outright, so this confinement
942
+ * is defence in depth against the same class of mistake arriving by a
943
+ * different route -- a future renderer that puts a `#$xx` in a comment for any
944
+ * other reason gets the "does not contain the literal" refusal below instead
945
+ * of a silent no-op substitution.
946
+ *
947
+ * EXPORTED FOR TEST REACH ONLY, on the same terms as
948
+ * `assertExportableCommentText()` below: no other module calls it, and the one
949
+ * that would (`anno-cli.ts`) goes through `exportAsm()`. It is exported
950
+ * because the caller now refuses an unexpressible opcode BEFORE reaching here,
951
+ * which makes the confinement above unreachable through `exportAsm()` and
952
+ * therefore untestable at that level -- an untested guard is the thing that
953
+ * lets the next route in.
954
+ */
955
+ export function substituteImmediateEnum(line: string, value: number, symbol: string, address: number): string {
956
+ const literal = `#${hex2(value)}`;
957
+ // `" ; "` is `renderLine()`'s own comment separator, in the one place this
958
+ // module has to know about it. Everything from it onward is prose for a
959
+ // human and is never assembler input; a substitution there reaches nobody.
960
+ const directiveHalf = line.split(" ; ")[0];
961
+ const at = directiveHalf.indexOf(literal);
962
+ if (at < 0) {
963
+ throw new Error(
964
+ `exportAsm: the instruction at ${hex4(address)} carries an enum usage, but the ASSEMBLER-VISIBLE half of its rendered line ` +
965
+ `does not contain the immediate literal ${literal} this module expected to replace. Refusing to emit a line whose ` +
966
+ `substitution silently did nothing (or landed in the trailing comment, where the assembler never reads it).`,
967
+ );
968
+ }
969
+ return `${line.slice(0, at)}#${symbol}${line.slice(at + literal.length)}`;
970
+ }
971
+
972
+ /**
973
+ * The store's own spellings for the two comment placements, DESTRUCTURED out of
974
+ * `COMMENT_TYPES` -- the ONE home of that vocabulary -- rather than re-typed as
975
+ * literals here. The same idiom `anno-memmap-render.ts` uses at its own read
976
+ * boundary: a re-typed `"line"` is a second copy of a vocabulary that has one
977
+ * home, and the two diverge in silence the first time the schema's spelling
978
+ * changes.
979
+ */
980
+ const [LINE_COMMENT, SIDE_COMMENT] = COMMENT_TYPES;
981
+
982
+ /**
983
+ * Re-checks, at the EXPORT boundary, that a stored comment can be emitted.
984
+ *
985
+ * WHY THIS EXISTS RATHER THAN TRUSTING THE STORE. `assertCommentText()` is the
986
+ * one comment-text vocabulary and it now refuses an embedded line break -- but
987
+ * a store file written BEFORE that refusal existed, or through any route that
988
+ * did not call it, can still hold one on disk. This boundary is the last place
989
+ * before those bytes become assembler input, so it asks the question again. It
990
+ * RE-CHECKS rather than RE-DEFINES: the predicate is `assertCommentText()`'s,
991
+ * called here, never a second regex that could drift from it.
992
+ *
993
+ * The store validator's own message is deliberately DISCARDED and replaced.
994
+ * That message interpolates the offending text for one of its four cases, and
995
+ * an exporter error that quotes a file's contents back is a content-disclosure
996
+ * oracle (CR-03). What survives is the address and which rule fired -- facts
997
+ * ABOUT the comment, never the comment.
998
+ */
999
+ export function assertExportableCommentText(text: string, address: number): string {
1000
+ try {
1001
+ return assertCommentText(text);
1002
+ } catch (err) {
1003
+ const reason = err instanceof AnnoCommentError && err.reason !== undefined ? err.reason : "refused by the store's comment-text vocabulary";
1004
+ throw new Error(
1005
+ `exportAsm: the comment at ${hex4(address)} cannot be emitted (${reason}). Every comment this exporter emits is a single line of text ` +
1006
+ `that the store's own comment-text vocabulary accepts; a stored line break would put everything after it into the ACME source at ` +
1007
+ `column zero, as assembler input rather than as a comment. REFUSED rather than repaired -- stripping or truncating here would change ` +
1008
+ `what somebody wrote and report success.`,
1009
+ );
1010
+ }
1011
+ }
1012
+
1013
+ /**
1014
+ * Re-checks, at the EXPORT boundary, that a stored range's `dataType` is one
1015
+ * the store's own vocabulary defines (30-REVIEW WR-03).
1016
+ *
1017
+ * The sibling of `assertExportableCommentText()` below, on the same terms and
1018
+ * for the same reason: `listRanges()` casts the column with no validator, so
1019
+ * this is the last place before that string is interpolated into ACME source
1020
+ * text. See the call site in `exportAsm()` for the full record.
1021
+ *
1022
+ * The store validator's own message is deliberately DISCARDED and replaced,
1023
+ * again for `assertExportableCommentText()`'s reason: `assertDataType()`
1024
+ * interpolates the offending value, and an exporter error that quotes a
1025
+ * file's contents back is a content-disclosure oracle. What survives is the
1026
+ * ADDRESS RANGE and the valid list -- facts about the row and about this
1027
+ * module's own vocabulary, never a byte read off disk.
1028
+ *
1029
+ * EXPORTED FOR TEST REACH ONLY, on the same terms as
1030
+ * `assertExportableCommentText()`: the state it guards against is reachable
1031
+ * only through a store file edited outside `anno-store.ts`, and
1032
+ * `anno-store.ts` is the ONE module in this repo permitted to name
1033
+ * `node:sqlite` -- so a test cannot manufacture the row and can only drive the
1034
+ * predicate. An unreachable-through-the-type guard with no test is how the
1035
+ * next such column goes unchecked.
1036
+ */
1037
+ export function assertDataTypeForExport(row: { start: number; endInclusive: number; dataType: unknown }): DataType {
1038
+ try {
1039
+ return assertDataType(row.dataType);
1040
+ } catch {
1041
+ throw new Error(
1042
+ `exportAsm: the range ${hex4(row.start)}..${hex4(row.endInclusive)} (inclusive) carries a data type that is not one of the ` +
1043
+ `${DATA_TYPES.length} the store defines (${DATA_TYPES.join(", ")}) -- refusing to guess what it meant. This module copies a ` +
1044
+ `range's data type VERBATIM into the emitted source's block comment, so an unvalidated value reaches ACME as text: one ` +
1045
+ `containing a line break would put everything after it at column zero, as assembler input rather than as a comment. ` +
1046
+ `The offending value is deliberately NOT quoted here -- an exporter error that echoes a file's contents is a ` +
1047
+ `content-disclosure oracle.`,
1048
+ );
1049
+ }
1050
+ }
1051
+
1052
+ /** Where the comments live while a block is being emitted, and which of them
1053
+ * have found a line to attach to. Anything still unplaced when the last block
1054
+ * is done is REFUSED by name rather than dropped. */
1055
+ interface CommentPlacement {
1056
+ byAddress: ReadonlyMap<number, CommentRow[]>;
1057
+ placed: Set<number>;
1058
+ }
1059
+
1060
+ /**
1061
+ * Attaches every stored comment for the addresses `[start, endExclusive)` to
1062
+ * one emitted line: a `line` comment on its own line immediately before it, a
1063
+ * `side` comment appended to it.
1064
+ *
1065
+ * Multiple comments at one address emit in `id` order, which is the order
1066
+ * `listComments()` returns them in -- so two people's notes at one address keep
1067
+ * the order they were written in rather than an order this module invented.
1068
+ *
1069
+ * A COMMENT ON A MULTI-ADDRESS LINE IS QUALIFIED WITH ITS OWN ADDRESS
1070
+ * (30-REVIEW IN-02, added 2026-08-31). The CODE path calls this with a span of
1071
+ * exactly ONE address (`[instr.address, instr.address + 1)`), so a comment
1072
+ * there is unambiguous and is emitted unchanged -- nothing about the existing
1073
+ * output moves. The DATA path calls it with a span of up to
1074
+ * `BYTES_PER_DATA_LINE` addresses, and there `n` comments on `n` DISTINCT data
1075
+ * bytes emitted as `n` indistinguishable lines above one `!byte` directive: a
1076
+ * human reading the generated assembly could not tell which byte each note was
1077
+ * about, and the information was not recoverable from the artefact.
1078
+ *
1079
+ * QUALIFIED RATHER THAN SPLIT. Splitting the `!byte` line at each commented
1080
+ * address was the other candidate and is worse here: it changes the emitted
1081
+ * TEXT's structure for a presentation problem, and every extra directive is
1082
+ * another line whose width and origin the byte-diff has to keep agreeing
1083
+ * about. A prefix changes nothing an assembler reads.
1084
+ *
1085
+ * GATED ON AMBIGUITY, not applied always: prefixing every code-path comment
1086
+ * with an address it already sits next to is noise, and it would rewrite every
1087
+ * existing expected line in the test suite for nothing.
1088
+ *
1089
+ * `generatedSuffix` (D-17, plan 45-05) is this module's OWN mechanical text --
1090
+ * today, only `decomposeRegisterValue()`'s decoded-field comment for an OR-ed
1091
+ * multi-bit enum substitution -- never a second stored comment. It is NEVER
1092
+ * DROPPED and NEVER REORDERED BEHIND authored text: when a stored SIDE
1093
+ * comment exists at the same address, the authored text renders FIRST and
1094
+ * `generatedSuffix` follows a ` -- ` separator on the SAME trailing comment;
1095
+ * when none exists, `generatedSuffix` is appended alone. Only the FIRST side
1096
+ * comment found absorbs it -- a second one at the same address (a supported
1097
+ * but rare store state) still renders, just without the merge, so the
1098
+ * generated text is never silently duplicated across two lines.
1099
+ */
1100
+ function withComments(text: string, start: number, endExclusive: number, ctx: CommentPlacement, generatedSuffix?: string): string[] {
1101
+ const before: string[] = [];
1102
+ let line = text;
1103
+ let generatedMerged = false;
1104
+
1105
+ // One emitted line covering more than one address cannot say WHICH address a
1106
+ // comment belongs to unless the comment says so itself.
1107
+ const spanIsAmbiguous = endExclusive - start > 1;
1108
+
1109
+ for (let address = start; address < endExclusive; address++) {
1110
+ for (const row of ctx.byAddress.get(address) ?? []) {
1111
+ const checked = assertExportableCommentText(row.text, row.address);
1112
+ const safe = spanIsAmbiguous ? `${hex4(row.address)}: ${checked}` : checked;
1113
+ ctx.placed.add(row.id);
1114
+ if (row.commentType === LINE_COMMENT) {
1115
+ before.push(`${INDENT}; ${safe}`);
1116
+ } else if (row.commentType === SIDE_COMMENT) {
1117
+ if (generatedSuffix !== undefined && !generatedMerged) {
1118
+ line = `${line} ; ${safe} -- ${generatedSuffix}`;
1119
+ generatedMerged = true;
1120
+ } else {
1121
+ line = `${line} ; ${safe}`;
1122
+ }
1123
+ } else {
1124
+ // Unreachable through the type, and reachable through a store file
1125
+ // somebody edited. Refusing beats guessing which of the two placements
1126
+ // an unknown third one meant.
1127
+ throw new Error(
1128
+ `exportAsm: the comment at ${hex4(row.address)} has placement ${JSON.stringify(row.commentType)}, which is not one of the ` +
1129
+ `${COMMENT_TYPES.length} placements the store defines (${COMMENT_TYPES.join(", ")}) -- refusing to guess where it belongs.`,
1130
+ );
1131
+ }
1132
+ }
1133
+ }
1134
+
1135
+ if (generatedSuffix !== undefined && !generatedMerged) {
1136
+ line = `${line} ; ${generatedSuffix}`;
1137
+ }
1138
+
1139
+ return [...before, line];
1140
+ }
1141
+
1142
+ /**
1143
+ * Phase 47, plan 47-04 (BUILD-03): the address one decoded instruction's
1144
+ * operand REFERENCES, for the in-tree symbol rule below -- the SAME address
1145
+ * a substitution would substitute, extracted through `resolvedTarget` FIRST
1146
+ * (`disasm-decoder.ts` rule 5 for every relative branch, rule 6 for absolute
1147
+ * `jmp` ($4C) and `jsr` ($20)), and otherwise through the operand's own
1148
+ * `value` for the `absolute`, `zeropage` and `indirect` roles -- the same
1149
+ * three roles `disasm-renderer.ts`'s own `resolveSymbol()` call sites read.
1150
+ *
1151
+ * AN `immediate` OPERAND IS DELIBERATELY NEVER A REFERENCE, and returns
1152
+ * `undefined` here exactly as it falls through every branch below. An
1153
+ * immediate is a BYTE VALUE, not an address: project enums (the
1154
+ * `usageByAddress` branch above) are what give one a name, and treating it as
1155
+ * an address is how `lda #$08` would start demanding a label be recorded at
1156
+ * `$0008`. `disasm-renderer.ts`'s own D-11 comment states the same exclusion
1157
+ * for the identical reason, on the substitution side of this same boundary.
1158
+ */
1159
+ function referencedAddress(instr: Instruction): number | undefined {
1160
+ if (instr.resolvedTarget !== undefined) return instr.resolvedTarget;
1161
+ const role = instr.operand?.role;
1162
+ if (role === "absolute" || role === "zeropage" || role === "indirect") return instr.operand!.value;
1163
+ return undefined;
1164
+ }
1165
+
1166
+ /**
1167
+ * Phase 47, plan 47-04 (BUILD-03, D47-F): true iff `address` falls inside
1168
+ * `[block.start, block.endExclusive)` for SOME block this export emitted --
1169
+ * the half-open interval every other boundary test in this module already
1170
+ * uses. Tested against the `blocks` array `exportAsm()` already built at the
1171
+ * top of the function, never a second range list derived independently of
1172
+ * it: a second list is exactly how the in-tree test and the emitted blocks
1173
+ * could drift apart.
1174
+ *
1175
+ * D47-F'S BOUNDARY, STATED HERE BECAUSE THIS IS WHERE IT IS DECIDED: an
1176
+ * address OUTSIDE every emitted block -- a hardware register like `$d020`,
1177
+ * a KERNAL entry like `$ffd2` -- is NOT in-tree, and a reference to one is
1178
+ * rendered as a hex literal and is never refused. Those addresses are fixed
1179
+ * hardware that cannot move, which is precisely what "so the code can move"
1180
+ * is about; a rule that refused on them would make every real export
1181
+ * impossible and would be measuring the wrong thing.
1182
+ */
1183
+ function isInTree(address: number, blocks: readonly ExportBlock[]): boolean {
1184
+ return blocks.some((block) => address >= block.start && address < block.endExclusive);
1185
+ }
1186
+
1187
+ /**
1188
+ * Exports the annotation store at `options.storePath`, over the image at
1189
+ * `options.imagePath`, as ACME source plus the exact bytes that source must
1190
+ * assemble to.
1191
+ *
1192
+ * The returned `source` is NOT self-verifying and this function makes no claim
1193
+ * that it reassembles: that claim is settled by assembling it with a real ACME
1194
+ * and byte-diffing the result against `expectedBytes`.
1195
+ *
1196
+ * Throws (never returns a degraded result) when the store holds no ranges, when
1197
+ * a range is not covered by the image, or when a label name is not a legal ACME
1198
+ * identifier. Every message is prefixed `exportAsm:` and carries paths,
1199
+ * addresses and counts only.
1200
+ */
1201
+ export function exportAsm(options: ExportAsmOptions): ExportAsmResult {
1202
+ const { storePath, imagePath, workspaceRoot, ledgerPath } = options;
1203
+
1204
+ const image = loadImage(imagePath);
1205
+
1206
+ // ONE handle for the whole export, closed in a `finally`. `mustExist` is what
1207
+ // makes "the annotations are gone" and "there are no annotations" refuse
1208
+ // differently: without it a mistyped path would CREATE an empty store and
1209
+ // export as a program with nothing annotated, indistinguishable from a real
1210
+ // one.
1211
+ const handle = openStore(storePath, { workspaceRoot, mustExist: true });
1212
+ let ranges: RangeRow[];
1213
+ let labels: LabelRow[];
1214
+ let comments: CommentRow[];
1215
+ let projectEnums: ProjectEnumRow[];
1216
+ let enumUsage: EnumUsageRow[];
1217
+ let excludedRanges: ExcludedRangeRow[];
1218
+ let scopes: ScopeRow[];
1219
+ try {
1220
+ ranges = listRanges(handle);
1221
+ labels = listLabels(handle);
1222
+ comments = listComments(handle);
1223
+ projectEnums = listProjectEnums(handle);
1224
+ enumUsage = listEnumUsage(handle);
1225
+ // BUILD-07 (phase 46 plan 05): a sixth read in the SAME handle and the
1226
+ // SAME `try`, mirroring the discipline the five siblings above already
1227
+ // follow -- one handle for the whole export, closed once in the
1228
+ // `finally` below. There is no second store opened for this.
1229
+ excludedRanges = listExcludedRanges(handle);
1230
+ // Phase 47, plan 47-01: a SEVENTH read in the SAME handle and the SAME
1231
+ // `try`, on the same terms as the sixth above -- still one handle for
1232
+ // the whole export, closed once in the `finally` below. There is no
1233
+ // second store opened for this either.
1234
+ scopes = listScopes(handle);
1235
+ } finally {
1236
+ closeStore(handle);
1237
+ }
1238
+
1239
+ if (ranges.length === 0) {
1240
+ throw new Error(
1241
+ `exportAsm: the annotation store at "${storePath}" holds no ranges -- refusing to emit an empty ACME source, ` +
1242
+ `because "nothing is annotated" and "the export produced nothing" must not read the same.`,
1243
+ );
1244
+ }
1245
+
1246
+ const sortedRanges = [...ranges].sort((a, b) => a.start - b.start);
1247
+
1248
+ // The store's end is INCLUSIVE; ACME's `*` after a block sits one past the
1249
+ // last byte. This conversion is a documented carried hazard in this project
1250
+ // with several boundaries -- it is done ONCE, here.
1251
+ const blocks: ExportBlock[] = sortedRanges.map((row) => ({
1252
+ start: row.start,
1253
+ endExclusive: row.endInclusive + 1,
1254
+ // THE STORE'S `dataType` IS RE-CHECKED AT THIS BOUNDARY (30-REVIEW WR-03,
1255
+ // fixed 2026-08-31), for exactly the reason `withComments()` re-checks
1256
+ // `commentType` a few functions up: "Unreachable through the type, and
1257
+ // reachable through a store file somebody edited. Refusing beats
1258
+ // guessing." That reasoning applies here and had not been applied.
1259
+ //
1260
+ // `listRanges()` casts `row.data_type as DataType` with no validator call
1261
+ // (`anno-store.ts`), so a store whose `anno_range.data_type` column was
1262
+ // edited on disk carried an ARBITRARY string into `emitDataLines()`, which
1263
+ // interpolates it verbatim into the emitted block comment. A value
1264
+ // containing a line break would put everything after it into the ACME
1265
+ // source at COLUMN ZERO, as assembler input rather than as a comment --
1266
+ // the same mechanism `assertExportableCommentText()` refuses for comment
1267
+ // text, arriving through a column nobody had checked.
1268
+ //
1269
+ // The block-end `!if` assertion would catch the resulting drift at
1270
+ // ASSEMBLY time, but the `anno export-asm` CLI verb assembles nothing: it
1271
+ // would write the corrupted file and exit 0. This is the last boundary
1272
+ // before those bytes become assembler input, so it asks the question here.
1273
+ //
1274
+ // RE-CHECKS rather than RE-DEFINES: the predicate is `assertDataType()`'s,
1275
+ // called here, never a second list of the twelve types that could drift
1276
+ // from it.
1277
+ dataType: assertDataTypeForExport(row) as string,
1278
+ lineCount: 0,
1279
+ // Populated below, at the same point `emitBlock()` is called for this
1280
+ // block -- empty only before that call runs.
1281
+ lines: [],
1282
+ }));
1283
+
1284
+ const imageStart = image.origin;
1285
+ const imageEndExclusive = image.origin + image.bytes.length;
1286
+ for (const block of blocks) {
1287
+ if (block.start < imageStart || block.endExclusive > imageEndExclusive) {
1288
+ throw new Error(
1289
+ `exportAsm: the range ${hex4(block.start)}..${hex4(block.endExclusive - 1)} (inclusive) is not covered by the ` +
1290
+ `image at "${imagePath}", which covers ${hex4(imageStart)}..${hex4(imageEndExclusive - 1)} (inclusive). ` +
1291
+ `Refusing to export a range whose bytes the image does not contain.`,
1292
+ );
1293
+ }
1294
+ }
1295
+
1296
+ // BUILD-05 (phase 46 plan 01): ONE read for the whole export, mirroring the
1297
+ // one-store-handle discipline directly above. `ledgerPath === undefined` is
1298
+ // the ONLY question asked of the caller's intent here -- everything after
1299
+ // this line either has a ledger to join against or does not, and no branch
1300
+ // anywhere below (here or in the per-block loop) ever reads a Verdict,
1301
+ // Confidence or Kind VALUE to decide anything.
1302
+ const ledger: ProvenanceLedger | undefined = ledgerPath === undefined ? undefined : readProvenanceLedger(ledgerPath);
1303
+
1304
+ // The label index the renderer's `symbolFor` hook reads. Every name is
1305
+ // validated BEFORE it can reach the source text -- REJECT, never sanitise.
1306
+ const sortedLabels = [...labels].sort((a, b) => a.address - b.address);
1307
+ const labelIndex = new Map<number, string>();
1308
+ /** Every address carrying MORE THAN ONE store label, with all their names in
1309
+ * `sortedLabels` order. See the loop below for why this is recorded rather
1310
+ * than refused. */
1311
+ const aliasedAddresses = new Map<number, string[]>();
1312
+ for (const label of sortedLabels) {
1313
+ assertLegalAcmeIdentifier(label.name, `exportAsm: label at ${hex4(label.address)}`);
1314
+ // TWO NAMES AT ONE ADDRESS IS RECORDED IN THE EMITTED SOURCE, NOT RESOLVED
1315
+ // IN SILENCE (30-REVIEW WR-02, second half, fixed 2026-08-31).
1316
+ //
1317
+ // `labelIndex` is a `Map<number, string>` while `anno_label` is `unique`
1318
+ // on NAME only -- `setLabel()` refuses only a name already bound to a
1319
+ // DIFFERENT address -- so two names at one address is a SUPPORTED store
1320
+ // state (an alias). In it, this `set()` silently overwrote the first and
1321
+ // `symbolFor()` returned whichever name sorted last. Reproduced: with
1322
+ // `smc_operand` and `smc_alias` both at $0802, the emitted `inc`
1323
+ // referenced `smc_alias` with no diagnostic anywhere.
1324
+ //
1325
+ // NOT REFUSED, DELIBERATELY, and this is the one place in this module that
1326
+ // records rather than refuses. Every other refusal here is for something
1327
+ // the exporter CANNOT express; an alias it CAN -- both definitions go into
1328
+ // the header (or inline), ACME accepts two symbols with one value, and the
1329
+ // bytes are unaffected. The only thing that was wrong is that the arbitrary
1330
+ // pick for REFERENCES was invisible. Refusing instead would delete a
1331
+ // supported store state to fix a diagnostic problem.
1332
+ //
1333
+ // FIRST NAME WINS, not last: `sortedLabels` is ascending by address and
1334
+ // otherwise in `listLabels()` order, so keeping the first makes the pick
1335
+ // stable rather than an artefact of a sort that never promised a
1336
+ // tiebreak. The comment emitted with the definitions names every
1337
+ // candidate, so a human reading the source can see what was chosen and
1338
+ // what was not.
1339
+ const existing = labelIndex.get(label.address);
1340
+ if (existing === undefined) {
1341
+ labelIndex.set(label.address, label.name);
1342
+ } else {
1343
+ const names = aliasedAddresses.get(label.address);
1344
+ if (names) names.push(label.name);
1345
+ else aliasedAddresses.set(label.address, [existing, label.name]);
1346
+ }
1347
+ }
1348
+ const symbolFor = (address: number): string | undefined => labelIndex.get(address);
1349
+
1350
+ /** EVERY store label's NAME, whether it ends up defined in the header or
1351
+ * inline. `labelIndex` cannot serve this: it is keyed by address and holds
1352
+ * only the first name at each, so an ALIASED label would be invisible to a
1353
+ * collision check reading it. ACME has ONE symbol namespace, so an enum
1354
+ * variant symbol colliding with any of these is `Symbol already defined.`
1355
+ * (30-REVIEW WR-10). */
1356
+ const labelSymbolNames = new Set(sortedLabels.map((label) => label.name));
1357
+
1358
+ // Comments indexed by the address they annotate, each address's list left in
1359
+ // `listComments()`'s own `id` order.
1360
+ const commentsByAddress = new Map<number, CommentRow[]>();
1361
+ for (const row of comments) {
1362
+ const at = commentsByAddress.get(row.address);
1363
+ if (at) at.push(row);
1364
+ else commentsByAddress.set(row.address, [row]);
1365
+ }
1366
+ const placement: CommentPlacement = { byAddress: commentsByAddress, placed: new Set<number>() };
1367
+
1368
+ // Enum usages indexed by the address they annotate. `listEnumUsage()` already
1369
+ // resolves the enum's NAME through a join on `anno_enum.id`, so this module
1370
+ // never holds a second on-disk copy of it; the row set is turned into an
1371
+ // address lookup here and the enum's own variants are joined on by name from
1372
+ // `listProjectEnums()`.
1373
+ const enumsByName = new Map<string, ProjectEnumRow>();
1374
+ for (const row of projectEnums) enumsByName.set(row.name, row);
1375
+ const usageByAddress = new Map<number, EnumUsageRow>();
1376
+ for (const row of enumUsage) usageByAddress.set(row.address, row);
1377
+ const appliedEnumUsage = new Set<number>();
1378
+ /** `<enumName>_<VARIANT> = $XX` definition lines, in first-emitted order.
1379
+ * They join the header block for the same reason label definitions do. */
1380
+ const enumDefinitionLines: string[] = [];
1381
+ /** Every emitted enum-derived symbol name (single-value or OR-ed term) to
1382
+ * the ONE value it was defined with. A Map, not a Set (D-17, plan 45-05):
1383
+ * a term name defined by one instruction with one value and referenced by a
1384
+ * SECOND instruction with a DIFFERENT value is a genuine collision in
1385
+ * ACME's one symbol namespace, and the value is what makes that collision
1386
+ * detectable rather than merely a duplicate-looking string. */
1387
+ const definedEnumSymbols = new Map<string, number>();
1388
+
1389
+ let unexpressibleCount = 0;
1390
+ let dataByteCount = 0;
1391
+ let enumDecompositionCount = 0;
1392
+ /** Phase 47, plan 47-03 (BUILD-02): every `external_file`-typed block's
1393
+ * own `.bin` sibling, populated at the same point `dataByteCount` above
1394
+ * is incremented for that block -- see `ExportAsmResult.binaries`'s own
1395
+ * doc-comment for what a caller reading this field is obliged to do. */
1396
+ const binaries: ExportBinary[] = [];
1397
+
1398
+ /** Phase 47, plan 47-04 (BUILD-03): one in-tree reference this export could
1399
+ * not resolve to a symbol -- collected across the WHOLE block loop and
1400
+ * refused ONCE at the end, in the shape the unapplied-enum-usage and
1401
+ * unplaced-comment refusals below already use. */
1402
+ interface UnresolvedReference {
1403
+ referringAddress: number;
1404
+ targetAddress: number;
1405
+ }
1406
+ const unresolvedReferences: UnresolvedReference[] = [];
1407
+ /** How many in-tree references this export encountered, resolved or not --
1408
+ * the denominator for the refusal's own "N of M" count, on the same terms
1409
+ * `enumUsage.length` is the denominator for the unapplied-enum-usage
1410
+ * refusal below. */
1411
+ let inTreeReferenceCount = 0;
1412
+
1413
+ // AUTO-GENERATED NAMES ARE MARKED, not filtered. Every store label reaches
1414
+ // the source either way; the marker is the backlog signal, carried into the
1415
+ // one artefact that leaves this tree. The predicate is
1416
+ // `AUTO_NAME_PREFIX_RE`'s, imported -- never a second copy. It is applied at
1417
+ // BOTH definition sites, header and inline, so an auto-named self-modifying
1418
+ // code operand is as visible in the backlog as any other.
1419
+ let autoNamedSymbolCount = 0;
1420
+ const markIfAutoNamed = (name: string, line: string): string => {
1421
+ if (!AUTO_NAME_PREFIX_RE.test(name)) return line;
1422
+ autoNamedSymbolCount++;
1423
+ return `${line}${AUTO_NAME_MARKER}`;
1424
+ };
1425
+
1426
+ // THE ALIAS PICK IS MADE VISIBLE IN THE SOURCE (30-REVIEW WR-02, second
1427
+ // half). Two store labels at one address are BOTH defined -- ACME accepts
1428
+ // two symbols with one value and the bytes are unaffected -- but a
1429
+ // REFERENCE to that address can render through only one of them. Which one
1430
+ // was previously invisible. Marking both definitions with the same fixed
1431
+ // wording means the human reading the generated assembly can see the
1432
+ // collision and the choice, from either definition line, without having to
1433
+ // reconstruct the exporter's sort order. Applied at BOTH definition sites,
1434
+ // header and inline, for the reason `markIfAutoNamed()` is: an aliased
1435
+ // self-modifying-code operand is exactly the shape this was reproduced on.
1436
+ const markIfAliased = (address: number, line: string): string => {
1437
+ const names = aliasedAddresses.get(address);
1438
+ if (names === undefined) return line;
1439
+ return `${line}${ALIAS_MARKER_PREFIX}${names.join(", ")} -- references render through ${labelIndex.get(address)}`;
1440
+ };
1441
+
1442
+ // The addresses of every label emitted INLINE as `name =*+$NN`. They are
1443
+ // collected during block emission and read afterwards by the header, which
1444
+ // is why the header is built AFTER this loop even though it is emitted
1445
+ // BEFORE it: a label defined inline must not ALSO be defined in the header,
1446
+ // or ACME refuses the whole source with `Symbol already defined.`
1447
+ const midInstructionLabelAddresses = new Set<number>();
1448
+
1449
+ // ONE PER EMITTED INLINE DEFINITION, not one per address (30-REVIEW WR-02).
1450
+ // The set above answers the HEADER's question ("is this address defined
1451
+ // inline already?"), which is per-address by nature. This counter answers
1452
+ // the RESULT's question ("how many inline definitions does the source
1453
+ // carry?"), which is per-label -- and two labels at one address is a
1454
+ // supported store state, so the two questions have different answers.
1455
+ // Incremented beside the `content.push()` that emits the line it counts,
1456
+ // so it cannot drift from the emitted text.
1457
+ let midInstructionLabelCount = 0;
1458
+
1459
+ const blockLines: string[] = [];
1460
+
1461
+ /** RECORDS, not lines -- one entry per `anno_excluded_range.id` that has
1462
+ * had at least one marker line emitted for it, across every block. Read at
1463
+ * the end for `excludedRangeCount`; see that field's own doc comment for
1464
+ * why a record spanning two blocks must count once, not twice. */
1465
+ const excludedRangeIdsEmitted = new Set<number>();
1466
+
1467
+ for (const block of blocks) {
1468
+ const slice = image.bytes.subarray(block.start - imageStart, block.endExclusive - imageStart);
1469
+ const content: string[] = [];
1470
+
1471
+ if (block.dataType === CODE_DATA_TYPE) {
1472
+ // D-11 is inherited UNCHANGED: `renderLine()` decides operand width and
1473
+ // refuses to substitute a symbol into an immediate or zeropage-family
1474
+ // operand. Do not widen `RenderOptions` and do not bypass `renderLine()`.
1475
+ // `end` IS INCLUSIVE, SO IT IS HANDED AN INCLUSIVE VALUE (30-REVIEW
1476
+ // WR-07, corrected 2026-08-31). This used to pass `block.endExclusive`.
1477
+ // `DecodeOptions.end` is compared with `if (end !== undefined && address
1478
+ // > end) break` and documented as "an instruction starting past `end` is
1479
+ // dropped ... an instruction starting AT OR BEFORE `end` is emitted in
1480
+ // full" -- an INCLUSIVE bound. Passing the exclusive end therefore
1481
+ // permitted one instruction more than intended.
1482
+ //
1483
+ // It was INERT, and that is exactly why it needed fixing rather than
1484
+ // leaving: `slice` is exactly the block's bytes and `decode()`'s own
1485
+ // `offset < bytes.length` loop condition bounds it first, so the `end`
1486
+ // guard was doing nothing at all. The next maintainer who passes a WIDER
1487
+ // slice -- to give `decode()` lookahead across a block boundary, say --
1488
+ // inherits a silent one-instruction overrun with no test to catch it.
1489
+ //
1490
+ // THE SLICE IS THE AUTHORITY AND `end` IS THE BELT-AND-BRACES SECOND
1491
+ // BOUND, stated here so the two are not read as one mechanism. Both now
1492
+ // describe the same last byte, `block.endExclusive - 1`.
1493
+ const instructions = decode(slice, block.start, { end: block.endExclusive - 1 });
1494
+ for (const instr of instructions) {
1495
+ if (!instr.acmeExpressible) unexpressibleCount++;
1496
+
1497
+ // A store label whose address falls STRICTLY INSIDE this instruction
1498
+ // names one of its operand bytes -- a self-modifying-code write target.
1499
+ // It is emitted as `name =*+$NN` on its own line IMMEDIATELY BEFORE the
1500
+ // instruction that owns the byte, and NEVER after it.
1501
+ //
1502
+ // PLACEMENT IS LOAD-BEARING AND WAS MEASURED IN BOTH DIRECTIONS ON ACME
1503
+ // 0.97. With `smc_operand =*+$01` above `lda #$00` at $0801, a later
1504
+ // `sta smc_operand` assembles as `8d 02 08` -- $0802, the `lda`'s own
1505
+ // operand byte. Move the same line BELOW its host and the symbol takes
1506
+ // the value of the NEXT instruction's operand ($0804), producing
1507
+ // `8d 04 08`. ACME exits 0 in BOTH cases and prints nothing to
1508
+ // distinguish them: only a byte-diff tells the two apart, which is why
1509
+ // `anno-export-asm.test.ts` carries that move as a negative control
1510
+ // rather than trusting an exit status.
1511
+ for (const label of sortedLabels) {
1512
+ if (label.address <= instr.address || label.address >= instr.address + instr.bytes.length) continue;
1513
+
1514
+ // A mid-instruction label below $0100 is REFUSED. This is the ONE
1515
+ // hole `disasm-renderer.ts`'s `+2` width force does not already close
1516
+ // FOR THIS MODULE, in the precise sense that it is the one place this
1517
+ // module has no mitigation of its own and depends entirely on the
1518
+ // renderer's.
1519
+ //
1520
+ // For an ORDINARY label this module owns the mitigation: it writes
1521
+ // the header definition with TWO hex digits below $0100 (see
1522
+ // `formatSymbolDefinition()`), which is what makes ACME encode the
1523
+ // reference at the original width. A `=*+$NN` label cannot use it --
1524
+ // it is defined INLINE by construction, so its width is decided by
1525
+ // whatever the referencing instruction's own rendering forced.
1526
+ //
1527
+ // MEASURED, ACME 0.97, a label at $0081 named by an earlier
1528
+ // `lda #$00` at $0080:
1529
+ // `inc+2 smc_operand` -> `ee 81 00`, EXIT 0, no diagnostic (correct)
1530
+ // `inc smc_operand` -> `e6 81`, EXIT 0, NO DIAGNOSTIC AT ALL
1531
+ // Two bytes where the original was three, silently, with the whole
1532
+ // rest of the block shifted. (The same reference placed BEFORE the
1533
+ // definition widens instead, and does at least emit
1534
+ // `Warning (Zone <untitled>): Using oversized addressing mode.` --
1535
+ // still exit 0.) The only thing standing between this exporter and
1536
+ // that shift is a `disasm-renderer.ts` invariant this module does not
1537
+ // own, so the case is refused BY NAME rather than emitted and hoped
1538
+ // for.
1539
+ if (label.address < MID_INSTRUCTION_LABEL_FLOOR) {
1540
+ throw new Error(
1541
+ `exportAsm: label ${JSON.stringify(label.name)} names address ${hex4(label.address)} inside an instruction, and a ` +
1542
+ `mid-instruction label below ${hex4(MID_INSTRUCTION_LABEL_FLOOR)} cannot be emitted -- it must be defined INLINE, ` +
1543
+ `relative to the program counter at its host instruction, which forgoes this exporter's own two-hex-digit ` +
1544
+ `header-definition width rule, and a reference to it ` +
1545
+ `then encodes at whatever width the renderer forced. Measured on ACME 0.97: the unforced form shrinks a three-byte ` +
1546
+ `absolute instruction to a two-byte zeropage one at exit 0 with NO diagnostic, shifting every byte after it. ` +
1547
+ `REFUSED rather than emitted.`,
1548
+ );
1549
+ }
1550
+
1551
+ midInstructionLabelAddresses.add(label.address);
1552
+ content.push(markIfAliased(label.address, markIfAutoNamed(label.name, midInstructionLabelLine(label.name, label.address - instr.address))));
1553
+ midInstructionLabelCount++;
1554
+ block.lineCount++;
1555
+ }
1556
+
1557
+ let rendered = renderLine(instr, { showSymbols: true, symbolFor });
1558
+
1559
+ // Phase 47, plan 47-04 (BUILD-03): THE IN-TREE SYMBOL RULE, beside
1560
+ // the `renderLine()` call it has to agree with. `rendered` above
1561
+ // already went through `symbolFor` -- the SAME `labelIndex` map read
1562
+ // here -- so a reference this check calls unresolved is a reference
1563
+ // `renderLine()` also had no name for, and one this check calls
1564
+ // resolved is a reference `renderLine()` already substituted. Reading
1565
+ // a SECOND index here, or re-scanning `sortedLabels`, is exactly how
1566
+ // a refusal could disagree with what the renderer actually did: it
1567
+ // could refuse an export the renderer would have substituted
1568
+ // correctly, or silently pass one it would not.
1569
+ //
1570
+ // Collected here and refused ONCE, after every block has been
1571
+ // processed -- see the throw below the block loop for the message
1572
+ // and D47-F's own boundary.
1573
+ const referenced = referencedAddress(instr);
1574
+ if (referenced !== undefined && isInTree(referenced, blocks)) {
1575
+ inTreeReferenceCount++;
1576
+ if (labelIndex.get(referenced) === undefined) {
1577
+ unresolvedReferences.push({ referringAddress: instr.address, targetAddress: referenced });
1578
+ }
1579
+ }
1580
+
1581
+ // The mechanical decode text (D-17), set only by the OR-ed
1582
+ // decomposition branch below and merged into this instruction's
1583
+ // trailing comment by `withComments()` after the enum-substitution
1584
+ // block finishes.
1585
+ let decompositionComment: string | undefined;
1586
+
1587
+ // ENUM SUBSTITUTION, IMMEDIATE OPERAND ONLY.
1588
+ const usage = usageByAddress.get(instr.address);
1589
+ if (usage !== undefined) {
1590
+ const role = instr.operand?.role;
1591
+ if (role !== "immediate") {
1592
+ throw new Error(
1593
+ `exportAsm: enum ${JSON.stringify(usage.enumName)} is bound to ${hex4(usage.address)}, whose operand role is ` +
1594
+ `${JSON.stringify(role ?? "none")} -- an enum renders on the IMMEDIATE operand only. Emitting it on any other operand ` +
1595
+ `changes both the bytes and the instruction length while ACME exits 0 (measured on ACME 0.97: \`sta\` on a symbol below ` +
1596
+ `$0100 encodes as zeropage, 2 bytes instead of 3). REFUSED rather than rendered.`,
1597
+ );
1598
+ }
1599
+
1600
+ // ROLE IS NOT ENOUGH: THE OPERAND MUST ALSO BE ASSEMBLER-VISIBLE
1601
+ // (30-REVIEW CR-02, fixed 2026-08-31). `decode()` assigns
1602
+ // `role: "immediate"` from the ADDRESSING MODE alone, independently
1603
+ // of `acmeExpressible`. Six opcodes in `disasm-opcodes.ts` are
1604
+ // `mode: "immediate"` AND `acmeExpressible: false` -- $2b (`anc`),
1605
+ // $82/$89/$c2/$e2 (`nop #imm`) and $eb (`sbc #imm`). For those,
1606
+ // `renderLine()` emits a `!byte` DIRECTIVE and moves the mnemonic
1607
+ // and its `#$xx` operand into the trailing comment
1608
+ // (`disasm-renderer.ts`'s `!instr.acmeExpressible` branch), so the
1609
+ // substitution below reached the COMMENT and never the assembler:
1610
+ // the operand stayed a raw byte in the `!byte` list, an unreferenced
1611
+ // `viccolor_BLACK = $00` was emitted into the header, the usage was
1612
+ // counted as applied, and the CLI printed "1 enum substitution(s)"
1613
+ // and exited 0. The bytes stay correct, so the byte-diff oracle
1614
+ // cannot see it either -- a round-trip test goes green on it.
1615
+ //
1616
+ // Reproduced against the committed code, store: one `code` range
1617
+ // $0801..$0803 over `eb 00 60`, enum `viccolor { $00: BLACK }`
1618
+ // applied at $0801 through the ordinary public `applyEnumUsage()`
1619
+ // route (which performs no opcode validation, so this needs no
1620
+ // hand-edited store):
1621
+ //
1622
+ // !byte $eb, $00 ; sbc #viccolor_BLACK [illegal opcode | ...]
1623
+ // === enumSubstitutionCount: 1
1624
+ //
1625
+ // This is D-30's "an annotation the exporter cannot express is
1626
+ // REFUSED loudly and by name, never silently dropped while the
1627
+ // export reports success" exactly inverted. It is refused now.
1628
+ if (!instr.acmeExpressible) {
1629
+ throw new Error(
1630
+ `exportAsm: enum ${JSON.stringify(usage.enumName)} is bound to the immediate operand at ${hex4(usage.address)}, but that ` +
1631
+ `opcode (${hex2(instr.bytes[0])}, ${instr.mnemonic}) is NOT EXPRESSIBLE in ACME's !cpu 6510 dialect. An unexpressible ` +
1632
+ `opcode goes out as a \`!byte\` directive with its mnemonic and operand in a TRAILING COMMENT, so an enum symbol ` +
1633
+ `substituted there would reach the comment and never the assembler -- the operand would stay a raw byte while this ` +
1634
+ `export reported the substitution as applied. REFUSED rather than counted as applied.`,
1635
+ );
1636
+ }
1637
+
1638
+ const project = enumsByName.get(usage.enumName);
1639
+ if (project === undefined) {
1640
+ // Unreachable through `applyEnumUsage()`, which resolves the enum
1641
+ // inside its own transaction, and reachable through a store file
1642
+ // somebody edited. Refusing beats emitting an operand with no
1643
+ // vocabulary behind it.
1644
+ throw new Error(
1645
+ `exportAsm: the enum usage at ${hex4(usage.address)} names enum ${JSON.stringify(usage.enumName)}, which the store holds ` +
1646
+ `no definition for. Refusing to emit an operand whose vocabulary is missing.`,
1647
+ );
1648
+ }
1649
+
1650
+ // D-16 (plan 45-05): THE ONE OWNING DECODER. Attempted ONLY when
1651
+ // BOTH (45-REVIEW CR-01, fixed 2026-09-11):
1652
+ // 1. `usage.enumName` has the exact shape `registerKeyFor().slice(1)`
1653
+ // produces -- see `REGISTER_ENUM_NAME_RE`'s own comment for why a
1654
+ // name that does not have this shape (e.g. `viccolor`) is never
1655
+ // a candidate at all; AND
1656
+ // 2. `anno-regbits.json` actually has a table entry for that
1657
+ // register (`hasRegBitsEntry()`) -- a register-shaped name for a
1658
+ // register the table does not cover (e.g. `D020`) is not a
1659
+ // decomposition failure, it is simply not a decomposable
1660
+ // register, and falls through to the existing single-symbol
1661
+ // path below with NO substitution counted.
1662
+ // Once BOTH hold, the table DOES claim this register, and a throw
1663
+ // here is a genuine, non-collateral data/coverage error (e.g. the
1664
+ // disclosed `$DD00` incomplete-bitfield-table case) -- it is NEVER
1665
+ // swallowed to fall back to the hex literal while still counting a
1666
+ // substitution that did not happen (T-45-21). It propagates with the
1667
+ // usage address prepended, so the store row that caused it is always
1668
+ // nameable.
1669
+ let decomposition: RegisterDecomposition | undefined;
1670
+ if (REGISTER_ENUM_NAME_RE.test(usage.enumName) && hasRegBitsEntry(`$${usage.enumName}`)) {
1671
+ try {
1672
+ decomposition = decomposeRegisterValue(parseInt(usage.enumName, 16), instr.operand!.value);
1673
+ } catch (err) {
1674
+ throw new Error(
1675
+ `exportAsm: decomposing the enum usage at ${hex4(usage.address)} (enum ${JSON.stringify(usage.enumName)}, value ` +
1676
+ `${hex2(instr.operand!.value)}) against its bit-name table failed: ${err instanceof Error ? err.message : String(err)}`,
1677
+ );
1678
+ }
1679
+ }
1680
+
1681
+ if (decomposition !== undefined && decomposition.multiField) {
1682
+ // D-17: OR-ED NAMED CONSTANTS AND THE DECODED COMMENT -- BOTH,
1683
+ // never either alone. A bare hex constant with a comment still
1684
+ // "emits one hex constant"; bare constants with no comment are
1685
+ // not readable.
1686
+ for (const term of decomposition.terms) {
1687
+ const existingValue = definedEnumSymbols.get(term.name);
1688
+ if (existingValue !== undefined) {
1689
+ if (existingValue !== term.value) {
1690
+ // ACME has ONE symbol namespace: two register writes that
1691
+ // decode the SAME field name to TWO different values cannot
1692
+ // both be `<name> = $XX`. Refusing names the symbol and
1693
+ // BOTH values, so the conflicting rows are findable.
1694
+ throw new Error(
1695
+ `exportAsm: the enum term symbol ${JSON.stringify(term.name)} (enum ${JSON.stringify(usage.enumName)}, field ` +
1696
+ `${JSON.stringify(term.fieldName)}, bound at ${hex4(usage.address)}) would be defined as ${hex2(term.value)} here, ` +
1697
+ `but was already defined as ${hex2(existingValue)} by an earlier instruction in this export. ACME has one symbol ` +
1698
+ `namespace, so one name cannot carry two values. REFUSED -- reconcile the two register writes or bind them to ` +
1699
+ `distinct enum names.`,
1700
+ );
1701
+ }
1702
+ // Same name, same value, already defined by an earlier
1703
+ // instruction -- no second definition line (30-REVIEW WR-10's
1704
+ // own "only what the source references" discipline, extended
1705
+ // to terms).
1706
+ } else {
1707
+ // THE SAME LABEL COLLISION CHECK 30-REVIEW WR-10 ADDED FOR A
1708
+ // SINGLE ENUM SYMBOL, EXTENDED HERE -- not a second check.
1709
+ if (labelSymbolNames.has(term.name)) {
1710
+ throw new Error(
1711
+ `exportAsm: the enum term symbol ${JSON.stringify(term.name)} (enum ${JSON.stringify(usage.enumName)}, field ` +
1712
+ `${JSON.stringify(term.fieldName)}, bound at ${hex4(usage.address)}) is ALSO the name of a store label. ACME has ` +
1713
+ `one symbol namespace, so emitting both definitions is \`Symbol already defined.\` and exit 1 -- and this verb ` +
1714
+ `assembles nothing, so without this refusal the export would exit 0 here and fail wherever you assembled it. ` +
1715
+ `REFUSED -- rename the label or the enum term.`,
1716
+ );
1717
+ }
1718
+ definedEnumSymbols.set(term.name, term.value);
1719
+ enumDefinitionLines.push(formatSymbolDefinition(term.name, term.value));
1720
+ }
1721
+ }
1722
+
1723
+ const orExpression = decomposition.terms.map((term) => term.name).join(" | ");
1724
+ rendered = substituteImmediateEnum(rendered, instr.operand!.value, orExpression, instr.address);
1725
+ appliedEnumUsage.add(usage.id);
1726
+ enumDecompositionCount++;
1727
+ decompositionComment = decomposition.comment;
1728
+ } else {
1729
+ // THE EXISTING SINGLE-SYMBOL PATH (D-16: unchanged, not
1730
+ // replaced) -- a single-field register, an enum usage whose name
1731
+ // is not register-shaped at all, OR (45-REVIEW CR-01) a
1732
+ // register-shaped name for a register `anno-regbits.json` simply
1733
+ // has no entry for (e.g. `D020`) -- reached here with no
1734
+ // substitution counted above, never a throw.
1735
+ //
1736
+ // EVERY variant of the enum is checked, not only the one this
1737
+ // operand matched. An enum carrying a variant above $ff is not a
1738
+ // BYTE vocabulary, and binding it to a byte operand is a
1739
+ // modelling error whose only symptom would otherwise be a
1740
+ // variant that silently never renders. Refusing here names the
1741
+ // enum and the variant; ACME's own refusal for the same shape is
1742
+ // `Number does not fit in 8 bits.` at exit 1, and names a line in
1743
+ // a temp file instead.
1744
+ let matched: string | undefined;
1745
+ for (const [key, variantName] of Object.entries(project.variants)) {
1746
+ const value = parseVariantKey(key);
1747
+ if (value > MAX_IMMEDIATE_VARIANT_VALUE) {
1748
+ throw new Error(
1749
+ `exportAsm: enum ${JSON.stringify(usage.enumName)} is bound to the immediate operand at ${hex4(usage.address)}, but its ` +
1750
+ `variant ${JSON.stringify(variantName)} has the value ${value}, above ` +
1751
+ `${MAX_IMMEDIATE_VARIANT_VALUE} -- an immediate operand is ONE byte, so this enum is not a byte vocabulary. ` +
1752
+ `Real ACME refuses the same shape with "Number does not fit in 8 bits." and exit 1; this refusal happens first so it ` +
1753
+ `can name the enum and the variant rather than a temp-file line number.`,
1754
+ );
1755
+ }
1756
+ if (value === instr.operand!.value) matched = variantName;
1757
+ }
1758
+
1759
+ if (matched === undefined) {
1760
+ throw new Error(
1761
+ `exportAsm: enum ${JSON.stringify(usage.enumName)} is bound to the immediate operand at ${hex4(usage.address)}, whose ` +
1762
+ `value is ${hex2(instr.operand!.value)}, and the enum has no variant for that value. Refusing to emit the hex literal ` +
1763
+ `while reporting an enum substitution that did not happen.`,
1764
+ );
1765
+ }
1766
+
1767
+ const symbol = `${usage.enumName}_${matched}`;
1768
+ // REJECT, never sanitise -- the same contract every other name this
1769
+ // module emits passes through, applied to the COMPOSED name because
1770
+ // that is what actually reaches the ACME source.
1771
+ assertLegalAcmeIdentifier(symbol, `exportAsm: enum variant symbol for ${hex4(usage.address)}`);
1772
+
1773
+ // THE COLLISION THE COMMENT BELOW NAMES IS NOW CHECKED FOR
1774
+ // (30-REVIEW WR-10, fixed 2026-08-31). That comment identified the
1775
+ // hazard exactly -- "every extra emitted symbol is one more chance to
1776
+ // collide with a label name and turn a correct export into ACME's
1777
+ // `Symbol already defined.`" -- and then did not look.
1778
+ // `definedEnumSymbols` dedupes enum symbols against EACH OTHER but
1779
+ // never against the store's labels.
1780
+ //
1781
+ // Since the `anno export-asm` CLI verb runs no assembler, the
1782
+ // collision produced a file that exited 0 here and failed wherever
1783
+ // the user actually assembled it, with no pointer back to the store
1784
+ // row that caused it. Refusing here names BOTH the enum and the
1785
+ // label, which is what makes it fixable.
1786
+ //
1787
+ // Checked against `labelSymbolNames` -- every store label's name,
1788
+ // whether it ends up defined in the header or inline -- because ACME
1789
+ // has ONE symbol namespace and an inline `=*+$NN` definition
1790
+ // collides exactly as a header one does.
1791
+ if (labelSymbolNames.has(symbol)) {
1792
+ throw new Error(
1793
+ `exportAsm: the enum variant symbol ${JSON.stringify(symbol)} (enum ${JSON.stringify(usage.enumName)}, variant ` +
1794
+ `${JSON.stringify(matched)}, bound at ${hex4(usage.address)}) is ALSO the name of a store label. ACME has one symbol ` +
1795
+ `namespace, so emitting both definitions is \`Symbol already defined.\` and exit 1 -- and this verb assembles ` +
1796
+ `nothing, so without this refusal the export would exit 0 here and fail wherever you assembled it, with no pointer ` +
1797
+ `back to the rows that caused it. REFUSED -- rename the label or the enum variant.`,
1798
+ );
1799
+ }
1800
+
1801
+ // ONLY THE MATCHED VARIANT IS DEFINED, not the whole vocabulary. A
1802
+ // definition the source never references is clutter a human reader
1803
+ // has to discount, and every extra emitted symbol is one more chance
1804
+ // to collide with a label name and turn a correct export into ACME's
1805
+ // `Symbol already defined.`
1806
+ if (!definedEnumSymbols.has(symbol)) {
1807
+ definedEnumSymbols.set(symbol, instr.operand!.value);
1808
+ enumDefinitionLines.push(formatSymbolDefinition(symbol, instr.operand!.value));
1809
+ }
1810
+ rendered = substituteImmediateEnum(rendered, instr.operand!.value, symbol, instr.address);
1811
+ appliedEnumUsage.add(usage.id);
1812
+ }
1813
+ }
1814
+
1815
+ // The span is the instruction's FIRST address only, not its whole
1816
+ // length: a comment stored against an operand byte belongs to no
1817
+ // emitted line, and attaching it to the instruction that happens to
1818
+ // contain that byte would move a human's note onto a different address
1819
+ // than the one they chose. It stays unplaced and is refused below.
1820
+ const emitted = withComments(rendered, instr.address, instr.address + 1, placement, decompositionComment);
1821
+ content.push(...emitted);
1822
+ block.lineCount += emitted.length;
1823
+ }
1824
+ } else if (isSplitAddressDataType(block.dataType)) {
1825
+ // Phase 47, plan 47-06 (BUILD-03): the two split ADDRESS layouts get
1826
+ // PAIRED symbol emission, never the raw-`!byte` fallback -- see
1827
+ // `emitSplitAddressLines()`'s own doc-comment for the orientation and
1828
+ // the one-symbol-per-entry rule. Read the SAME `blocks`/`labelIndex`
1829
+ // the code path's in-tree symbol rule above reads, and fold its
1830
+ // contribution into the SAME `unresolvedReferences`/
1831
+ // `inTreeReferenceCount` totals, so a table entry and a `jsr` are
1832
+ // refused (or not) by ONE rule, never two that could disagree.
1833
+ const split = emitSplitAddressLines(slice, block.dataType, block.start, blocks, labelIndex);
1834
+ inTreeReferenceCount += split.inTreeReferenceCount;
1835
+ unresolvedReferences.push(...split.unresolvedReferences);
1836
+ for (const dataLine of split.lines) {
1837
+ const emitted = withComments(dataLine.text, dataLine.start, dataLine.endExclusive, placement);
1838
+ content.push(...emitted);
1839
+ block.lineCount += emitted.length;
1840
+ }
1841
+ dataByteCount += slice.length;
1842
+ } else {
1843
+ // The `dataType` reaching `emitDataLines()` is the store's own string,
1844
+ // copied off the row and passed through -- this module never branches on
1845
+ // it beyond the code/not-code test above, the `!word` eligibility check,
1846
+ // and the `!binary` check, all three inside the emitter.
1847
+ for (const dataLine of emitDataLines(slice, block.dataType, block.start)) {
1848
+ const emitted = withComments(dataLine.text, dataLine.start, dataLine.endExclusive, placement);
1849
+ content.push(...emitted);
1850
+ block.lineCount += emitted.length;
1851
+ }
1852
+ dataByteCount += slice.length;
1853
+
1854
+ // Phase 47, plan 47-03 (BUILD-02): an `external_file` block's bytes
1855
+ // also go out as their own sibling data file. `bytes` is `slice`
1856
+ // itself -- the same bytes `dataByteCount` above just counted, carried
1857
+ // verbatim and never re-read from anywhere else.
1858
+ if (block.dataType === EXTERNAL_FILE_DATA_TYPE) {
1859
+ binaries.push({ name: binaryFileName(block.start), start: block.start, endExclusive: block.endExclusive, bytes: slice });
1860
+ }
1861
+ }
1862
+
1863
+ // BUILD-05 (phase 46 plan 01): when ledger mode is on, every block gets a
1864
+ // provenance comment BEFORE it is bracketed -- never as a threshold, never
1865
+ // gating which blocks reach `emitBlock()` below. The only conditionals
1866
+ // here are "is ledger mode on", "did zero rows come back" (refuse) and
1867
+ // "did more than one come back" (record the ambiguity, choose neither) --
1868
+ // none of which reads a Verdict, Confidence or Kind VALUE.
1869
+ if (ledger !== undefined) {
1870
+ const overlapping = provenanceForRange(ledger, block.start, block.endExclusive - 1);
1871
+ if (overlapping.length === 0) {
1872
+ throw new Error(
1873
+ `exportAsm: the ledger at "${ledgerPath}" carries no row overlapping the block ${hexExtent(block.start)}..` +
1874
+ `${hexExtent(block.endExclusive - 1)} (inclusive) -- refusing to emit this block unannotated or with an invented ` +
1875
+ `verdict. Regenerate the ledger with c64-provenance-diff's "ledger" verb so it covers this range, or omit --ledger.`,
1876
+ );
1877
+ }
1878
+ const provenanceLines: string[] = [];
1879
+ for (const row of overlapping) {
1880
+ // The ONLY free-text field here (Evidence/Reason) goes through the
1881
+ // EXISTING comment-text validator -- never a second one. Verdict,
1882
+ // Confidence and Kind are the ledger's own short controlled-ish
1883
+ // strings, carried verbatim without re-validation, exactly as
1884
+ // `dataType` is copied onto the block comment elsewhere in this file.
1885
+ const checkedEvidence = assertExportableCommentText(row.evidence, block.start);
1886
+ provenanceLines.push(
1887
+ `${PROVENANCE_MARKER_PREFIX}${hex4(row.start)}..${hex4(row.endInclusive)} verdict=${row.verdict} ` +
1888
+ `confidence=${row.confidence} kind=${row.kind} agreeing=${row.agreeingReleases} evidence=${checkedEvidence}`,
1889
+ );
1890
+ }
1891
+ if (overlapping.length > 1) {
1892
+ // Multiplicity is RECORDED, never resolved by picking one (T-46-03) --
1893
+ // the same posture `ALIAS_MARKER_PREFIX` already takes for two store
1894
+ // labels at one address.
1895
+ provenanceLines.push(
1896
+ `${PROVENANCE_AMBIGUITY_MARKER_PREFIX}${overlapping.length} ledger rows overlap this block; none was chosen -- see above.`,
1897
+ );
1898
+ }
1899
+ // Prepended, not counted in `block.lineCount`: `lineCount` counts
1900
+ // CONTENT lines by its own doc-comment, and a provenance comment is
1901
+ // bookkeeping ABOUT the block -- the two `!if * != ...` assertions in
1902
+ // `emitBlock()` are what actually police its extent.
1903
+ content.unshift(...provenanceLines);
1904
+ }
1905
+
1906
+ // BUILD-07 (phase 46 plan 05): every recorded exclusion overlapping this
1907
+ // block gets a marker naming ITS OWN extent (never the block's) and its
1908
+ // checked reason -- NEVER a skipped block, a shortened slice, or a
1909
+ // shrunk `expectedBytes`. `block.start`/`block.endExclusive` above are
1910
+ // completely untouched by this: the slice was already taken, the code
1911
+ // was already decoded or the data lines already emitted, and every one
1912
+ // of those bytes stays in `content`. This is bookkeeping ABOUT the block,
1913
+ // exactly as the provenance comment above is -- prepended, and not
1914
+ // counted in `block.lineCount`, which by its own doc-comment counts
1915
+ // CONTENT lines only.
1916
+ //
1917
+ // THE OVERLAP TEST IS THE SAME PREDICATE `provenanceForRange()` and
1918
+ // `addExcludedRange()` BOTH USE (`row.start <= blockEndInclusive &&
1919
+ // row.endInclusive >= blockStart`, addExcludedRange's own predicate
1920
+ // transposed) -- textually the same test at all three sites, so they
1921
+ // agree by construction and not by three separate authors reaching the
1922
+ // same answer by coincidence. Sorted ascending by `start`: this plan's
1923
+ // own recorded (backstop) choice for two disjoint exclusions inside one
1924
+ // block, never derived from a written contract.
1925
+ const blockEndInclusive = block.endExclusive - 1;
1926
+ const overlappingExclusions = excludedRanges
1927
+ .filter((row) => row.start <= blockEndInclusive && row.endInclusive >= block.start)
1928
+ .sort((a, b) => a.start - b.start);
1929
+ if (overlappingExclusions.length > 0) {
1930
+ const exclusionLines: string[] = [];
1931
+ for (const row of overlappingExclusions) {
1932
+ // RE-CHECKED, NOT RE-DEFINED -- the same predicate
1933
+ // `assertExportableCommentText()` already applies to every stored
1934
+ // comment and to the ledger's own free-text evidence cell, on
1935
+ // EXACTLY the grounds `assertDataTypeForExport()`'s own comment a
1936
+ // few dozen lines above states: the store validated this reason at
1937
+ // write time, and a store file somebody edited on disk reaches this
1938
+ // point through a column nobody re-checked. A stored line break
1939
+ // would put everything after it into the ACME source at column
1940
+ // zero, as assembler input rather than as a comment.
1941
+ const checkedReason = assertExportableCommentText(row.reason, block.start);
1942
+ // THE EXCLUSION'S OWN EXTENT, NOT THE BLOCK'S -- a row narrower than
1943
+ // the block it lands in says so here, because the user asked about
1944
+ // a span, not about whatever block that span happened to land in.
1945
+ exclusionLines.push(`${EXCLUSION_MARKER_PREFIX}${hex4(row.start)}..${hex4(row.endInclusive)} ${checkedReason}`);
1946
+ excludedRangeIdsEmitted.add(row.id);
1947
+ }
1948
+ content.unshift(...exclusionLines);
1949
+ }
1950
+
1951
+ // EVERY block goes through `emitBlock()`, code and data alike, so there is
1952
+ // exactly one place that brackets a block and no route that emits an
1953
+ // unbracketed one. Captured onto `block.lines` (Phase 47, plan 47-01) at
1954
+ // the SAME point it is pushed onto `blockLines` -- one call, two
1955
+ // destinations, never a second bracketing.
1956
+ const emittedBlockLines = emitBlock(block.start, block.endExclusive, content);
1957
+ block.lines = emittedBlockLines;
1958
+ blockLines.push(...emittedBlockLines);
1959
+ }
1960
+
1961
+ // Phase 47, plan 47-04 (BUILD-03): an in-tree reference this export could
1962
+ // not resolve to a symbol is REFUSED BY NAME, once, across the whole
1963
+ // export -- never emitted as a hex literal that freezes the target's
1964
+ // address into the source while the export reports success. In the same
1965
+ // shape the unapplied-enum-usage and unplaced-comment refusals below
1966
+ // already use: name the first offender, state the "N of M" count, and say
1967
+ // plainly what is refused.
1968
+ //
1969
+ // A reference OUTSIDE every emitted block -- a hardware register, a
1970
+ // KERNAL entry -- is rendered as a hex literal and is NOT a refusal
1971
+ // (D47-F, `isInTree()`'s own doc-comment): that address is fixed hardware
1972
+ // which cannot move, which is precisely what "so the code can move" is
1973
+ // about, and this refusal never fires on one.
1974
+ //
1975
+ // No image byte, no mnemonic and no store comment text is ever
1976
+ // interpolated here -- only addresses and counts, on the same discipline
1977
+ // `assertDataTypeForExport()`'s own comment states.
1978
+ if (unresolvedReferences.length > 0) {
1979
+ const first = unresolvedReferences[0]!;
1980
+ throw new Error(
1981
+ `exportAsm: the instruction at ${hex4(first.referringAddress)} references ${hex4(first.targetAddress)}, an address inside an ` +
1982
+ `emitted block, but no label names that address -- every reference that has to move with the code goes through a symbol, and ` +
1983
+ `this one has none to go through. ${unresolvedReferences.length} of ${inTreeReferenceCount} in-tree reference(s) are in this ` +
1984
+ `state. Refusing to emit a hex literal that freezes the target's address into the source while reporting success -- the whole ` +
1985
+ `point of the symbol is that the code can move. Record a label at ${hex4(first.targetAddress)} to fix this.`,
1986
+ );
1987
+ }
1988
+
1989
+ // EVERY store label is defined here, in a block BEFORE the first `* =`, not
1990
+ // only the ones a substitution happened to use -- EXCEPT the mid-instruction
1991
+ // ones, which the loop above already defined inline and which ACME would
1992
+ // refuse as `Symbol already defined.` if they appeared twice.
1993
+ //
1994
+ // Measured on ACME 0.97: a symbol defined AFTER its first reference widens
1995
+ // the referencing instruction from zeropage to absolute -- `a5 10` becomes
1996
+ // `ad 10 00`, three bytes where the original was two -- and it does so with
1997
+ // the WARNING `Using oversized addressing mode.` and exit status 0.
1998
+ // Everything after it shifts. Defining first is the mitigation; the per-block
1999
+ // `*` assertions are the backstop for a future change that ever drops this
2000
+ // block, and the byte-diff is what settles the whole claim.
2001
+ //
2002
+ // THIS BLOCK IS BUILT AFTER THE BLOCK LOOP AND EMITTED BEFORE IT. Which
2003
+ // labels are defined inline is only knowable once the code blocks have been
2004
+ // decoded, and the header must not restate those; the assembled order below
2005
+ // is what the source actually carries.
2006
+ const headerLines: string[] = [...enumDefinitionLines];
2007
+ for (const label of sortedLabels) {
2008
+ if (midInstructionLabelAddresses.has(label.address)) continue;
2009
+ headerLines.push(markIfAliased(label.address, markIfAutoNamed(label.name, formatSymbolDefinition(label.name, label.address))));
2010
+ }
2011
+
2012
+ // An enum usage this export never reached is REFUSED BY NAME, for the reason
2013
+ // an unplaceable comment is: its address is inside an instruction rather than
2014
+ // at its start, or is not covered by any CODE range, and in both cases the
2015
+ // honest answer is that this export does not carry it, said out loud.
2016
+ if (appliedEnumUsage.size !== enumUsage.length) {
2017
+ const unapplied = enumUsage.filter((row) => !appliedEnumUsage.has(row.id));
2018
+ const first = unapplied[0]!;
2019
+ throw new Error(
2020
+ `exportAsm: the enum usage at ${hex4(first.address)} (enum ${JSON.stringify(first.enumName)}) has no decoded instruction to ` +
2021
+ `attach to -- that address is inside an instruction rather than at its start, or is not covered by any \`code\` range. ` +
2022
+ `${unapplied.length} of ${enumUsage.length} enum usage(s) are in this state. Refusing to export while silently dropping them.`,
2023
+ );
2024
+ }
2025
+
2026
+ const lines: string[] = ["!cpu 6510", ...headerLines, ...blockLines];
2027
+
2028
+ // An annotation this exporter cannot express is REFUSED BY NAME, never
2029
+ // dropped from the output while the export reports success. A comment is
2030
+ // unplaceable when its address is inside an instruction rather than at its
2031
+ // start, or outside every annotated range -- and in both cases the honest
2032
+ // answer is that this export does not carry it, said out loud.
2033
+ if (placement.placed.size !== comments.length) {
2034
+ const unplaced = comments.filter((row) => !placement.placed.has(row.id));
2035
+ const first = unplaced[0]!;
2036
+ throw new Error(
2037
+ `exportAsm: the ${first.commentType} comment at ${hex4(first.address)} has no emitted line to attach to -- that address is inside an ` +
2038
+ `instruction rather than at its start, or is not covered by any annotated range. ` +
2039
+ `${unplaced.length} of ${comments.length} comment(s) are in this state. Refusing to export while silently dropping them.`,
2040
+ );
2041
+ }
2042
+
2043
+ // `expectedBytes` is built from the IMAGE, never from `lines`. Gaps between
2044
+ // blocks stay `$00`, matching ACME `-f plain`'s measured zero-fill.
2045
+ const minStart = blocks[0]!.start;
2046
+ const maxEndExclusive = blocks.reduce((acc, b) => Math.max(acc, b.endExclusive), blocks[0]!.endExclusive);
2047
+ const expectedBytes = new Uint8Array(maxEndExclusive - minStart);
2048
+ for (const block of blocks) {
2049
+ expectedBytes.set(image.bytes.subarray(block.start - imageStart, block.endExclusive - imageStart), block.start - minStart);
2050
+ }
2051
+
2052
+ return {
2053
+ source: `${lines.join("\n")}\n`,
2054
+ headerLines,
2055
+ scopes,
2056
+ expectedBytes,
2057
+ blocks,
2058
+ symbolCount: sortedLabels.length,
2059
+ headerDefinitionCount: headerLines.length,
2060
+ unexpressibleCount,
2061
+ dataByteCount,
2062
+ binaries: [...binaries].sort((a, b) => a.start - b.start),
2063
+ commentCount: placement.placed.size,
2064
+ midInstructionLabelCount,
2065
+ autoNamedSymbolCount,
2066
+ enumSubstitutionCount: appliedEnumUsage.size,
2067
+ enumDecompositionCount,
2068
+ excludedRangeCount: excludedRangeIdsEmitted.size,
2069
+ };
2070
+ }
2071
+
2072
+ // ---------------------------------------------------------------------------
2073
+ // Phase 47, plan 47-01: the TREE writer (D47-A). `exportAsm()` above stays the
2074
+ // proven EMITTER -- this is the primary shape a caller reaches for, built by
2075
+ // PARTITIONING `exportAsm()`'s already-proven emission, never by emitting a
2076
+ // second time through a second route. See `exportAsmTree()`'s own doc-comment
2077
+ // below for the one claim it is NOT allowed to make.
2078
+ // ---------------------------------------------------------------------------
2079
+
2080
+ /** D47-B: the tree's three fixed file names. DERIVED from nothing but this
2081
+ * module's own naming convention -- never from a store row -- so a store's
2082
+ * free text can never reach one of these three names. */
2083
+ export const ROOT_FILE_NAME = "root.a";
2084
+ export const SYMBOLS_FILE_NAME = "symbols.a";
2085
+ export const UNSCOPED_FILE_NAME = "unscoped.a";
2086
+
2087
+ /**
2088
+ * D47-B: the `.a` file name for the scope starting at `start` --
2089
+ * `scope_XXXX.a`, four LOWERCASE hex digits, no `$`, no store free text
2090
+ * anywhere in it.
2091
+ *
2092
+ * DERIVED FROM `start`, an integer this project already controls (a scope
2093
+ * row's own `start` field, itself validated by `addScope()` before it ever
2094
+ * reached the store) -- never from a label name, a comment or any other
2095
+ * store free text (T-47-01). This is what makes a re-export a REVIEWABLE
2096
+ * DIFF instead of full-tree churn: the same scope always gets the same file
2097
+ * name, so an unrelated edit elsewhere in the store does not rename files a
2098
+ * human may have opened.
2099
+ */
2100
+ export function scopeFileName(start: number): string {
2101
+ return `scope_${(start & 0xffff).toString(16).padStart(4, "0")}.a`;
2102
+ }
2103
+
2104
+ /**
2105
+ * Phase 47, plan 47-03 (BUILD-02): the `.bin` sibling file name for the
2106
+ * `external_file`-typed block starting at `start` -- `data_XXXX.bin`, four
2107
+ * LOWERCASE hex digits, no `$`, no store free text anywhere in it. Module-
2108
+ * private: nothing outside this file needs the name computed independently
2109
+ * of `ExportBinary.name`/`emitDataLines()`'s own emitted `!binary` argument,
2110
+ * both of which already carry it.
2111
+ *
2112
+ * DERIVED FROM `start`, an integer this project already controls, on
2113
+ * exactly the same terms `scopeFileName()` above already states for `.a`
2114
+ * siblings (T-47-01) -- never from a label name, a comment or any other
2115
+ * store free text. This is what makes the emitted `!binary "..."` argument
2116
+ * safe to trust: it can only ever be four hex digits and a fixed prefix/
2117
+ * suffix, never a string a store row supplied.
2118
+ */
2119
+ function binaryFileName(start: number): string {
2120
+ return `data_${(start & 0xffff).toString(16).padStart(4, "0")}.bin`;
2121
+ }
2122
+
2123
+ export interface ExportAsmTreeOptions extends ExportAsmOptions {
2124
+ /** The directory the tree is written into. NOTHING CONFINES THIS PATH
2125
+ * INSIDE THIS MODULE, on exactly the same terms `imagePath`/`storePath`
2126
+ * above already carry (T-47-03) -- that sentence is present because an
2127
+ * absent comment beside a present one is itself a claim, and a silently-
2128
+ * undocumented path field is what a prior review named as the mechanism of
2129
+ * a real defect. The CALLER owns confining it (the CLI does, through
2130
+ * `storePathWithinWorkspace()`, plan 47-05). Within the directory, every
2131
+ * name this function writes is DERIVED (`scopeFileName()`, the three fixed
2132
+ * constants above), so nothing the caller supplies can escape it a second
2133
+ * time. */
2134
+ outDir: string;
2135
+ /**
2136
+ * Phase 47, plan 47-02, Task 2: what the CALLER IS ASKING FOR, not a
2137
+ * switch that widens what this function is willing to destroy. Set, it
2138
+ * means "this directory already holds a tree I exported before -- replace
2139
+ * it." It deliberately does NOT mean "remove whatever is in my way": a
2140
+ * directory entry that is not one of this export's own file names is
2141
+ * refused by name even with `force: true`, never deleted to make room for
2142
+ * the write. See `exportAsmTree()`'s own doc-comment for the two-rule
2143
+ * contract this field gates.
2144
+ */
2145
+ force?: boolean;
2146
+ }
2147
+
2148
+ export interface ExportAsmTreeResult extends ExportAsmResult {
2149
+ outDir: string;
2150
+ /** Every file this call wrote, ASCENDING -- exactly the tree's on-disk
2151
+ * file-name set. */
2152
+ files: string[];
2153
+ /** The `!source` order `root.a` carries, D47-B: `symbols.a` first, then
2154
+ * each populated scope file ascending by scope start, then `unscoped.a`
2155
+ * last (only when non-empty). */
2156
+ sourceOrder: string[];
2157
+ }
2158
+
2159
+ /**
2160
+ * Phase 47, plan 47-02: decides which file ONE block belongs to, given the
2161
+ * store's own scopes -- the ONE containment predicate, applied ONCE, so the
2162
+ * tree writer's placement answer can never drift from a second copy of this
2163
+ * question (see `blocks`'s own `.map()` above for the sibling boundary this
2164
+ * project already carries the same discipline for).
2165
+ *
2166
+ * A scope WHOLLY CONTAINS a block when `scope.start <= block.start` AND
2167
+ * `block.endExclusive - 1 <= scope.endInclusive`. That subtraction is the
2168
+ * inclusive/exclusive conversion, done HERE and only here -- a second
2169
+ * conversion site elsewhere in this function is exactly how the two answers
2170
+ * would drift.
2171
+ *
2172
+ * A block no scope contains, and that overlaps no scope AT ALL, belongs to
2173
+ * the unscoped group (D47-D). This is a genuine "goes somewhere, never
2174
+ * nowhere" answer rather than a refusal-in-disguise: `listScopes()` was read
2175
+ * by nothing in the export path before this plan, so every store that exists
2176
+ * today has zero scopes, and refusing here would make the tree export
2177
+ * unreachable for every one of them. Losslessness is this project's
2178
+ * governing constraint.
2179
+ *
2180
+ * A block that OVERLAPS a scope WITHOUT being wholly contained by it is
2181
+ * REFUSED (D47-C) -- scopes cannot themselves overlap (`addScope()`'s own
2182
+ * overlap refusal), so this is genuinely ambiguous rather than a case this
2183
+ * function could resolve by trying harder. The export refuses to CHOOSE a
2184
+ * file for it rather than guess what a straddling range means: `emitBlock()`
2185
+ * brackets every block with a single `* =` origin and a single
2186
+ * `!if * != ...` end assertion, and a block emitted in two pieces has no
2187
+ * single extent for that pair to assert. The fix belongs to the user --
2188
+ * move the scope boundary, or split the range -- never to this exporter.
2189
+ */
2190
+ function placeBlockInScope(block: ExportBlock, sortedScopes: readonly ScopeRow[]): ScopeRow | "unscoped" {
2191
+ const blockEndInclusive = block.endExclusive - 1;
2192
+ for (const scope of sortedScopes) {
2193
+ const whollyContained = scope.start <= block.start && blockEndInclusive <= scope.endInclusive;
2194
+ if (whollyContained) return scope;
2195
+
2196
+ const overlaps = block.start <= scope.endInclusive && scope.start <= blockEndInclusive;
2197
+ if (overlaps) {
2198
+ throw new Error(
2199
+ `exportAsmTree: the range ${hex4(block.start)}..${hex4(blockEndInclusive)} (inclusive) overlaps the scope ` +
2200
+ `${hex4(scope.start)}..${hex4(scope.endInclusive)} (inclusive) without being wholly contained by it -- refusing to choose ` +
2201
+ `which file it belongs in, because that would be this tool deciding what your range means. Splitting the range across two ` +
2202
+ `files is not offered instead: emitBlock() brackets every block with a single \`* =\` origin and a single ` +
2203
+ `\`!if * != ...\` end assertion, and a block emitted in two pieces has no single extent left for that pair to assert. ` +
2204
+ `Move the scope boundary, or split the range -- the fix belongs to you, not this export.`,
2205
+ );
2206
+ }
2207
+ }
2208
+ return "unscoped";
2209
+ }
2210
+
2211
+ /**
2212
+ * Writes `exportAsm()`'s already-proven emission as a TREE of real files on
2213
+ * disk, rather than emitting a second time through a second route --
2214
+ * `root.a` (D47-B order), `symbols.a`, one `scope_XXXX.a` per scope that
2215
+ * contains at least one block, and `unscoped.a` only when at least one block
2216
+ * lies inside no scope (D47-D).
2217
+ *
2218
+ * ASSIGNMENT RULE (D47-C/D47-D, both now implemented): every block is placed
2219
+ * by `placeBlockInScope()` above -- the scope that wholly contains it, the
2220
+ * unscoped group when no scope contains it at all, or a thrown refusal when
2221
+ * it overlaps a scope without being wholly contained. There is no fourth
2222
+ * outcome and no path that silently discards a block.
2223
+ *
2224
+ * Every `!source` argument this function emits is a bare filename -- no
2225
+ * directory component, no absolute path, no host-machine path anywhere in
2226
+ * the generated text, so the tree assembles on a machine that has never
2227
+ * seen this one (hard scope fence 2).
2228
+ *
2229
+ * `root.a` is written LAST, through a temp name in the same directory
2230
+ * followed by a `renameSync` into place -- the same atomic-publish
2231
+ * discipline `build.ts` already uses for every artifact it emits. A tree
2232
+ * whose root exists is a tree every file it sources exists for, so an
2233
+ * interrupted export leaves nothing an assembler would happily turn into a
2234
+ * wrong program.
2235
+ *
2236
+ * `exportAsmTree()` SPAWNS NOTHING AND ASSEMBLES NOTHING. Writing the tree is
2237
+ * not the same claim as the tree being able to reassemble, and nothing this
2238
+ * function writes, prints or returns may be read as an assembly verdict
2239
+ * (P-01) -- that claim is settled elsewhere, by a real ACME reached through
2240
+ * `runHostTool()`.
2241
+ */
2242
+ export function exportAsmTree(options: ExportAsmTreeOptions): ExportAsmTreeResult {
2243
+ const result = exportAsm(options);
2244
+ const { outDir } = options;
2245
+
2246
+ const sortedScopes = [...result.scopes].sort((a, b) => a.start - b.start);
2247
+ // Keyed by scope START (D47-B's own file-naming key), never by scope id --
2248
+ // the file name is a function of `start`, so the grouping key matches it.
2249
+ //
2250
+ // THIS WHOLE PASS RUNS BEFORE ANY FILE IS WRITTEN, on purpose: a refusal
2251
+ // that has already written half a tree has left an artefact a later
2252
+ // assemble might succeed on. `placeBlockInScope()` either returns a
2253
+ // placement or throws; nothing below this loop runs until every block has
2254
+ // been placed.
2255
+ const scopeBlocks = new Map<number, ExportBlock[]>();
2256
+ const unscopedBlocks: ExportBlock[] = [];
2257
+ for (const block of result.blocks) {
2258
+ const placement = placeBlockInScope(block, sortedScopes);
2259
+ if (placement === "unscoped") {
2260
+ unscopedBlocks.push(block);
2261
+ continue;
2262
+ }
2263
+ const existing = scopeBlocks.get(placement.start);
2264
+ if (existing) existing.push(block);
2265
+ else scopeBlocks.set(placement.start, [block]);
2266
+ }
2267
+
2268
+ // The FULL set of names this call will write, computed BEFORE any write and
2269
+ // BEFORE the directory-contract check below reads it: the placement pass
2270
+ // above already knows exactly which scopes are populated and whether any
2271
+ // block is unscoped, so this is arithmetic over what is already decided,
2272
+ // never a guess revised after the fact.
2273
+ const populatedScopeStarts = [...scopeBlocks.keys()].sort((a, b) => a - b);
2274
+ const hasUnscoped = unscopedBlocks.length > 0;
2275
+ // Phase 47, plan 47-03 (T-47-08): every `.bin` sibling this call will write
2276
+ // joins the SAME name set the directory contract below evaluates, so a
2277
+ // re-export with `force: true` may replace a previously-exported `.bin`
2278
+ // exactly as it may replace a previously-exported `.a` file -- and, without
2279
+ // `force`, a directory holding one is refused by name like anything else.
2280
+ const binaryNames = result.binaries.map((binary) => binary.name);
2281
+ const namesToWrite = [
2282
+ SYMBOLS_FILE_NAME,
2283
+ ...populatedScopeStarts.map((start) => scopeFileName(start)),
2284
+ ...(hasUnscoped ? [UNSCOPED_FILE_NAME] : []),
2285
+ ...binaryNames,
2286
+ ROOT_FILE_NAME,
2287
+ ];
2288
+
2289
+ // ---------------------------------------------------------------------
2290
+ // Phase 47, plan 47-02, Task 2: the output-directory contract. Two rules,
2291
+ // both evaluated BEFORE the first write below -- a refusal that has
2292
+ // already written half a tree has left an artefact a later assemble might
2293
+ // succeed on (the same reason the placement pass above runs to completion
2294
+ // before any write).
2295
+ //
2296
+ // Rule one, without `force`: a directory holding ANY entry at all is
2297
+ // refused by name, unconditionally. An export writes a whole tree and will
2298
+ // not mix its files with whatever the directory already held.
2299
+ //
2300
+ // Rule two, with `force`: the caller is asking "this directory already
2301
+ // holds a tree I exported before, replace it" -- never "remove whatever is
2302
+ // in my way". Anything in the directory that is NOT one of `namesToWrite`
2303
+ // is refused by name; nothing is ever deleted to make room for it. The
2304
+ // `.bin` files this export writes are the ones a person is expected to
2305
+ // edit by hand, so replacing them is something the user has to ask for,
2306
+ // and a directory the user pointed at by mistake must not lose a file this
2307
+ // tool never created.
2308
+ // ---------------------------------------------------------------------
2309
+ const existingEntries = existsSync(outDir) ? readdirSync(outDir) : [];
2310
+ if (existingEntries.length > 0) {
2311
+ if (!options.force) {
2312
+ throw new Error(
2313
+ `exportAsmTree: the output directory "${outDir}" already holds ${existingEntries.length} ` +
2314
+ `${existingEntries.length === 1 ? "entry" : "entries"} -- refusing to write into it. An export writes a whole tree and will ` +
2315
+ `not mix its files with whatever is already there. Pass \`force: true\` to ask for the overwrite explicitly if this directory ` +
2316
+ `holds a previous export of this same store.`,
2317
+ );
2318
+ }
2319
+ const namesToWriteSet = new Set(namesToWrite);
2320
+ const unexpected = existingEntries.filter((entry) => !namesToWriteSet.has(entry));
2321
+ if (unexpected.length > 0) {
2322
+ throw new Error(
2323
+ `exportAsmTree: the output directory "${outDir}" holds ${JSON.stringify(unexpected)}, which this export would NOT write -- ` +
2324
+ `refusing the overwrite. \`force: true\` means "replace the tree I exported here before", never "remove whatever is in my ` +
2325
+ `way": every name this export itself produces may be overwritten, but any other entry is left untouched. Remove it yourself, ` +
2326
+ `or point --out at an empty directory.`,
2327
+ );
2328
+ }
2329
+ }
2330
+ mkdirSync(outDir, { recursive: true });
2331
+
2332
+ const files: string[] = [];
2333
+ const sourceOrder: string[] = [];
2334
+
2335
+ // symbols.a -- ALWAYS written, sourced FIRST (D47-B). Measured live this
2336
+ // session: a zero-page symbol defined AFTER its first use widens the
2337
+ // referencing instruction, so every scope file depends on this one having
2338
+ // already run.
2339
+ const symbolsFileLines = [
2340
+ `; ${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.`,
2341
+ ...result.headerLines,
2342
+ ];
2343
+ writeFileSync(join(outDir, SYMBOLS_FILE_NAME), `${symbolsFileLines.join("\n")}\n`, "utf8");
2344
+ files.push(SYMBOLS_FILE_NAME);
2345
+ sourceOrder.push(SYMBOLS_FILE_NAME);
2346
+
2347
+ // One scope_XXXX.a per POPULATED scope, ascending by scope start (D47-B).
2348
+ // `populatedScopeStarts` was already computed above, for `namesToWrite`.
2349
+ for (const scopeStart of populatedScopeStarts) {
2350
+ const name = scopeFileName(scopeStart);
2351
+ const blocksInScope = [...scopeBlocks.get(scopeStart)!].sort((a, b) => a.start - b.start);
2352
+ const fileLines = [`; ${name} -- one scope of this export's tree, addresses ${hex4(scopeStart)} upward.`, ...blocksInScope.flatMap((b) => b.lines)];
2353
+ writeFileSync(join(outDir, name), `${fileLines.join("\n")}\n`, "utf8");
2354
+ files.push(name);
2355
+ sourceOrder.push(name);
2356
+ }
2357
+
2358
+ // unscoped.a -- only when at least one block lies inside no scope (D47-D):
2359
+ // losslessness is the governing constraint, so every existing store (which
2360
+ // has zero scopes today, since nothing reads listScopes() yet) still
2361
+ // exports every block somewhere, never nowhere.
2362
+ if (unscopedBlocks.length > 0) {
2363
+ const sortedUnscoped = [...unscopedBlocks].sort((a, b) => a.start - b.start);
2364
+ const fileLines = [`; ${UNSCOPED_FILE_NAME} -- every block this export emitted that lies inside no scope.`, ...sortedUnscoped.flatMap((b) => b.lines)];
2365
+ writeFileSync(join(outDir, UNSCOPED_FILE_NAME), `${fileLines.join("\n")}\n`, "utf8");
2366
+ files.push(UNSCOPED_FILE_NAME);
2367
+ sourceOrder.push(UNSCOPED_FILE_NAME);
2368
+ }
2369
+
2370
+ // .bin siblings (T-47-08/T-47-09/T-47-10, phase 47 plan 47-03) -- one per
2371
+ // `external_file`-typed block, written in the SAME pass as every `.a`
2372
+ // file above and, like them, BEFORE root.a: data files precede the root
2373
+ // for the identical interruption-safety reason the `.a` files already do
2374
+ // -- a partial tree must have no root an assembler could start from.
2375
+ // These are NOT `!source`d, so `sourceOrder` is untouched; they are only
2376
+ // ever reached through the `!binary` line `emitDataLines()` already wrote
2377
+ // into their owning scope/unscoped `.a` file.
2378
+ for (const binary of result.binaries) {
2379
+ writeFileSync(join(outDir, binary.name), Buffer.from(binary.bytes.buffer, binary.bytes.byteOffset, binary.bytes.byteLength));
2380
+ files.push(binary.name);
2381
+ }
2382
+
2383
+ // root.a -- LAST, and atomically: a temp name in the SAME directory (so
2384
+ // `renameSync` is a same-filesystem rename, never EXDEV), then renamed into
2385
+ // place. A tree whose root exists is a tree every file it sources exists
2386
+ // for.
2387
+ const rootPath = join(outDir, ROOT_FILE_NAME);
2388
+ const rootTmpPath = join(outDir, `${ROOT_FILE_NAME}.tmp-${process.pid}`);
2389
+ const rootFileLines = [`; ${ROOT_FILE_NAME} -- this tree's entry point.`, "!cpu 6510", ...sourceOrder.map((name) => `!source "${name}"`)];
2390
+ writeFileSync(rootTmpPath, `${rootFileLines.join("\n")}\n`, "utf8");
2391
+ renameSync(rootTmpPath, rootPath);
2392
+ files.push(ROOT_FILE_NAME);
2393
+ files.sort();
2394
+
2395
+ return { ...result, outDir, files, sourceOrder };
2396
+ }