@henols/vice-mcp 0.2.1 → 0.2.2

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 (57) hide show
  1. package/README.md +2 -1
  2. package/THIRD-PARTY-NOTICES.md +1 -24
  3. package/{r2000-acme-ident.ts → anno-acme-ident.ts} +13 -13
  4. package/anno-cli.ts +1465 -0
  5. package/{r2000-confidence.ts → anno-confidence.ts} +22 -22
  6. package/anno-coverage.ts +2465 -0
  7. package/{r2000-d64.ts → anno-d64.ts} +5 -5
  8. package/anno-derive.ts +590 -0
  9. package/anno-details.ts +169 -0
  10. package/anno-enum-gen.ts +533 -0
  11. package/anno-export-asm.ts +1310 -0
  12. package/anno-index.ts +150 -0
  13. package/{r2000-memmap-render.ts → anno-memmap-render.ts} +236 -95
  14. package/{r2000-regbits-gen.ts → anno-regbits-gen.ts} +20 -15
  15. package/{r2000-regbits.json → anno-regbits.json} +2 -2
  16. package/anno-register.ts +240 -0
  17. package/anno-store.ts +3486 -0
  18. package/anno-symbols.ts +266 -0
  19. package/anno-tools.ts +2111 -0
  20. package/anno-types.ts +1636 -0
  21. package/block-class.ts +201 -0
  22. package/build.ts +1 -1
  23. package/capability-registry.ts +3 -1
  24. package/disasm-decoder.ts +14 -14
  25. package/disasm-opcodes.ts +4 -4
  26. package/disasm-renderer.ts +2 -2
  27. package/hostpath.ts +1 -1
  28. package/install-resources.ts +1 -1
  29. package/package.json +23 -17
  30. package/prg-image.ts +119 -0
  31. package/repo-root.ts +20 -5
  32. package/resources/broker-launch.mjs +8 -4
  33. package/resources/vice-launcher.sh +3 -3
  34. package/stock-address.ts +5 -5
  35. package/stock-cia.ts +2 -2
  36. package/stock-condition.ts +7 -7
  37. package/stock-connect.ts +1 -1
  38. package/stock-dispatch.ts +35 -5
  39. package/stock-execution.ts +5 -3
  40. package/stock-input.ts +9 -9
  41. package/stock-machine.ts +17 -6
  42. package/stock-protocol.ts +16 -11
  43. package/stock-registers.ts +54 -29
  44. package/stock-sprites.ts +3 -3
  45. package/stock-symbols.ts +9 -9
  46. package/stock-timing.ts +1 -1
  47. package/stock-vicii.ts +1 -1
  48. package/version.ts +1 -1
  49. package/vice-proxy.ts +68 -46
  50. package/r2000-cli.ts +0 -1103
  51. package/r2000-enum-gen.ts +0 -574
  52. package/r2000-launch.ts +0 -357
  53. package/r2000-mcp-client.ts +0 -596
  54. package/r2000-project.ts +0 -190
  55. package/r2000-symbols.ts +0 -388
  56. package/r2000-tools.ts +0 -914
  57. package/r2000-verify.ts +0 -184
@@ -0,0 +1,1310 @@
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 TWO places
57
+ // that already do, each of which says so in its own comment:
58
+ // `CODE_DATA_TYPE`'s decoder-or-dump branch, and `WORD_PAIR_DATA_TYPES`'s
59
+ // `!word` eligibility check. Both are questions about the emitted TEXT.
60
+ // `block-class.ts` is the one place in this tree allowed to INTERPRET that
61
+ // column -- what the data means -- and everywhere else here the string is
62
+ // copied VERBATIM onto the emitted block and its trailing comment.
63
+ // - Never import this tree's host/container path-translation modules
64
+ // (`hostpath.ts` / `containerpath.ts`). Their consumer set is a closed,
65
+ // mechanically asserted list of named modules and an exporter has no reason
66
+ // to join it -- the failure would surface as a test about something else
67
+ // entirely.
68
+ // - Never interpolate a read file's own bytes into an error message. A path,
69
+ // an address and a length are facts ABOUT a file; its contents are not, and
70
+ // an error text that quotes them turns a refusal into a content-disclosure
71
+ // oracle (CR-03). Every throw below carries paths, addresses and counts and
72
+ // nothing read out of the image or the store.
73
+ // - Never sanitise a label name. `assertLegalAcmeIdentifier()`'s contract is
74
+ // REJECT: a space-to-underscore substitution silently merges two distinct
75
+ // names into one, permanently, and the caller-visible name then diverges
76
+ // from what actually reached the ACME source.
77
+ //
78
+ // ---------------------------------------------------------------------------
79
+ // WHAT THIS FILE DOES NOT CHECK
80
+ // ---------------------------------------------------------------------------
81
+ // Addresses no range covers. `expectedBytes` spans `[minStart,
82
+ // maxEndExclusive)` with `$00` in the gaps between blocks -- which is exactly
83
+ // what ACME `-f plain` emits for those gaps (measured) -- so the padding is
84
+ // never a false disagreement. But an export makes no claim about bytes outside
85
+ // its own blocks, and neither does the byte-diff that settles it.
86
+ //
87
+ // SCOPE, STILL DELIBERATELY NARROW: code ranges, the twelve typed data ranges,
88
+ // comments, mid-instruction inline labels and immediate-operand enum
89
+ // substitution.
90
+ import { readFileSync } from "node:fs";
91
+ import { extname } from "node:path";
92
+
93
+ import { openStore, closeStore, listRanges, listLabels, listComments, listProjectEnums, listEnumUsage } from "./anno-store.ts";
94
+ import { AnnoCommentError, COMMENT_TYPES, DATA_TYPES, assertCommentText, assertDataType, parseVariantKey } from "./anno-types.ts";
95
+ import type { CommentRow, DataType, EnumUsageRow, LabelRow, ProjectEnumRow, RangeRow } from "./anno-types.ts";
96
+ import { assertLegalAcmeIdentifier } from "./anno-acme-ident.ts";
97
+ // The eleven typed auto-name prefixes, IMPORTED FROM THEIR ONE HOME rather than
98
+ // restated. This is the first cross-module PRODUCTION importer of that
99
+ // constant; before this the only consumers were `anno-coverage.ts`'s own
100
+ // `computeLabelRatio()` and its test file.
101
+ //
102
+ // WHY THE EXPORTER CARES. `routine-queue-walker`'s SKILL.md reads the typed
103
+ // prefixes as DOCUMENTED VOCABULARY (`s_`, `p_`, `b_`, `zpp_`, `zpf_`, `zpa_`,
104
+ // `f_`, `a_` at :128-181 and :220-226) and never imports the regex, so the
105
+ // coupling between that skill's backlog signal and this repo's definition of
106
+ // "auto-generated name" is by convention and would break in SILENCE. Marking
107
+ // those definitions in the exported source is what keeps the backlog visible to
108
+ // a human reading the generated assembly, which is the one artefact that leaves
109
+ // this tree.
110
+ //
111
+ // WHAT NOT TO DO: do not restate the eleven here, in any form -- not as an
112
+ // array, not as a second regex, not as a doc comment listing them.
113
+ // `anno-types.ts:93-99` forbids it by name, and `EXPORT-02` names the failure
114
+ // mode: a five-prefix copy under-counts silently.
115
+ import { AUTO_NAME_PREFIX_RE } from "./anno-coverage.ts";
116
+ import { decode } from "./disasm-decoder.ts";
117
+ import { renderLine } from "./disasm-renderer.ts";
118
+ import { parsePrg, flatImageOrigin } from "./prg-image.ts";
119
+
120
+ /** The store's own spelling for an executable range, read out of the one home
121
+ * of that vocabulary rather than re-typed as a literal. `dataType` is never
122
+ * COMPARED anywhere else in this module -- `block-class.ts` owns
123
+ * interpretation; this single equality is the emitter deciding whether to run
124
+ * a decoder or dump bytes, and it is the only one. */
125
+ const CODE_DATA_TYPE = "code";
126
+
127
+ /** One block as this module emitted it. */
128
+ export interface ExportBlock {
129
+ /** First address the block covers. */
130
+ start: number;
131
+ /** One past the last address the block covers. ACME's `*` sits here after
132
+ * the block, and the store's own row uses an INCLUSIVE end, so the
133
+ * conversion is `endExclusive = row.endInclusive + 1`. */
134
+ endExclusive: number;
135
+ /** The store's `dataType`, copied VERBATIM off the row. Never compared in
136
+ * this module beyond the single code/not-code branch the emitter needs. */
137
+ dataType: string;
138
+ /** How many CONTENT lines the block emitted -- not its `* =` origin line and
139
+ * not the two `!if * != ...` assertions that bracket it. */
140
+ lineCount: number;
141
+ }
142
+
143
+ export interface ExportAsmOptions {
144
+ /**
145
+ * The annotation store to export. `openStore()` below confines it against
146
+ * `workspaceRoot`; a CLI caller confines the same string a second time
147
+ * through `storePathWithinWorkspace()` before it ever reaches here, so both
148
+ * answers agree by construction rather than by a second rule. */
149
+ storePath: string;
150
+ /** The program image the store annotates -- a `.prg` (2-byte little-endian
151
+ * load address then payload) or a flat 64K capture. NOTHING CONFINES THIS
152
+ * PATH INSIDE THIS MODULE: it reaches `readFileSync` directly, so the CALLER
153
+ * owns its confinement, exactly as it owns `storePath`'s. That sentence is
154
+ * present because an absent comment beside a present one is itself a claim,
155
+ * and a silently-undocumented path field is what a prior review named as the
156
+ * mechanism of a real defect. */
157
+ imagePath: string;
158
+ /** The workspace root the store-path confinement is taken against. REQUIRED
159
+ * rather than defaulted: `openStore()`'s default behaviour is to CREATE the
160
+ * file, so an unconfined store path is a store file created wherever the
161
+ * caller's argument pointed -- and an export of a store this call just
162
+ * invented would read as "the program has no annotations". */
163
+ workspaceRoot: string;
164
+ }
165
+
166
+ export interface ExportAsmResult {
167
+ /** The ACME source text. */
168
+ source: string;
169
+ /** The bytes `source` must assemble to, DERIVED FROM THE IMAGE and never
170
+ * from `source`. Spans `[minStart, maxEndExclusive)` across every emitted
171
+ * block, with `$00` filling the gaps between them -- which is exactly what
172
+ * ACME `-f plain` emits for those gaps (measured), so padding can never
173
+ * register as a disagreement while a wrong byte inside a covered range
174
+ * still fails. */
175
+ expectedBytes: Uint8Array;
176
+ /** The blocks emitted, ascending by start. Passed straight to the verify
177
+ * primitive as its `expectedSegments`. */
178
+ blocks: ExportBlock[];
179
+ /** How many STORE LABELS the export carries, header and inline together --
180
+ * i.e. `sortedLabels.length`, one per `anno_label` row in range.
181
+ *
182
+ * THIS DOC USED TO SAY "how many symbol definitions the header carries",
183
+ * AND THAT WAS NOT WHAT IT COUNTED (30-REVIEW WR-01, corrected 2026-08-31).
184
+ * The two readings diverge in BOTH directions: a mid-instruction label is
185
+ * defined inline and skipped by the header loop yet still counted here,
186
+ * and every `enumDefinitionLines` entry IS a header definition yet is not.
187
+ * Reproduced: a store with `start`@$0801 plus `smc_operand`/`smc_alias`
188
+ * both at the mid-instruction address $0802 emits a header carrying exactly
189
+ * ONE definition while this field reported 3 -- a number the CLI prints
190
+ * verbatim to the user as "3 symbol(s)".
191
+ *
192
+ * The header count is now its own field (`headerDefinitionCount` below)
193
+ * rather than this one being redefined, because both numbers have a real
194
+ * consumer and collapsing them into one is what produced the divergence. */
195
+ symbolCount: number;
196
+ /** How many definition lines the HEADER block actually carries -- enum
197
+ * variant definitions plus every store label NOT defined inline. Computed
198
+ * from `headerLines` itself, so it cannot drift from the emitted text the
199
+ * way a separately-maintained count did (30-REVIEW WR-01). */
200
+ headerDefinitionCount: number;
201
+ /** How many decoded instructions ACME's `!cpu 6510` cannot express, and
202
+ * which therefore went out as `!byte` directives with their mnemonic moved
203
+ * into a trailing comment. */
204
+ unexpressibleCount: number;
205
+ /** How many bytes went out through the DATA path -- every byte of every
206
+ * block whose `dataType` is not `code`. A code block contributes nothing
207
+ * here, however many bytes it decoded. */
208
+ dataByteCount: number;
209
+ /** How many store comments the source carries. Always the store's FULL
210
+ * comment count when this function returns: a comment with no emitted line
211
+ * to attach to is refused by name rather than left out of this number. */
212
+ commentCount: number;
213
+ /** How many inline mid-instruction label definitions the source carries --
214
+ * one per store label whose address falls STRICTLY INSIDE a decoded
215
+ * instruction. `midInstructionLabelLine()` is the one place that spelling
216
+ * exists. Equal to the number of labels that were therefore EXCLUDED from the
217
+ * header definition block, because such a label is defined inline and
218
+ * defining it twice is ACME's `Symbol already defined.`
219
+ *
220
+ * COUNTED PER EMITTED DEFINITION, NOT PER ADDRESS (30-REVIEW WR-02,
221
+ * corrected 2026-08-31). It used to be `midInstructionLabelAddresses.size`,
222
+ * a set of ADDRESSES, while the inline loop emits one line per LABEL.
223
+ * `anno_label` is `unique` on `name` only and `setLabel()` refuses only a
224
+ * name already bound to a DIFFERENT address, so two names at one address is
225
+ * a supported store state -- and in it, two inline definitions were emitted
226
+ * and two labels excluded from the header while this field reported 1,
227
+ * contradicting the sentence directly above. */
228
+ midInstructionLabelCount: number;
229
+ /** How many emitted symbol definitions carry an AUTO-GENERATED name, decided
230
+ * by `AUTO_NAME_PREFIX_RE` -- the eleven typed prefixes, read from their one
231
+ * home and never restated here. This is `routine-queue-walker`'s backlog
232
+ * signal, surfaced in the one artefact that leaves this tree. */
233
+ autoNamedSymbolCount: number;
234
+ /** How many instruction operands were rendered through a project enum's
235
+ * variant name instead of a hex literal. Every one of them is an IMMEDIATE
236
+ * operand; any other operand role is refused. */
237
+ enumSubstitutionCount: number;
238
+ }
239
+
240
+ /** How many raw bytes go on one `!byte` line for a non-code block. */
241
+ const BYTES_PER_DATA_LINE = 16;
242
+
243
+ /** How many 16-bit values go on one `!word` line. */
244
+ const WORDS_PER_DATA_LINE = 8;
245
+
246
+ /**
247
+ * The two `DATA_TYPES` members whose bytes are ADJACENT little-endian pairs,
248
+ * and therefore the only ones `!word` can emit without changing a byte.
249
+ *
250
+ * THIS IS THE ONE PLACE IN THIS MODULE A TYPE NAME IS READ FOR EMISSION, other
251
+ * than `CODE_DATA_TYPE`'s decoder/dump branch. `block-class.ts` owns
252
+ * INTERPRETATION of a `dataType`; these two names are read here only to answer
253
+ * "may this block's bytes be re-grouped into pairs", which is a question about
254
+ * the emitted TEXT and not about what the data means. Every other type -- the
255
+ * four split-table layouts included, whose low and high halves are NOT adjacent
256
+ * pairs -- is copied verbatim onto a `!byte` line and never compared.
257
+ */
258
+ const WORD_PAIR_DATA_TYPES: readonly string[] = Object.freeze(["word", "address"]);
259
+
260
+ /** One emitted data line, with the address span it covers. The span is what
261
+ * lets a stored comment find its line: a `!byte` line covers up to sixteen
262
+ * addresses, and a comment on any of them belongs to that line. */
263
+ interface DataLine {
264
+ text: string;
265
+ start: number;
266
+ endExclusive: number;
267
+ }
268
+
269
+ /**
270
+ * Emits one non-code block's bytes.
271
+ *
272
+ * THE RULE, and its reason: byte-identity is the criterion, and `!byte` is
273
+ * byte-identical for every type. `!word` is used ONLY where it is provably
274
+ * identical AND improves readability -- `word` and `address` ranges of even
275
+ * length, where ACME's `!word` emits little-endian pairs (measured). Everything
276
+ * else goes out as `!byte`.
277
+ *
278
+ * `!text` IS DELIBERATELY NOT EMITTED for `petscii` / `screencode`. `!text`
279
+ * applies ACME's CURRENT conversion table, and a conversion this exporter does
280
+ * not control is exactly the way a byte-identical claim stops being true --
281
+ * silently, on somebody else's machine, with a different ACME build. The type
282
+ * name goes into the trailing comment instead, so a human reader loses nothing
283
+ * a converter would have told them about the bytes' meaning.
284
+ *
285
+ * `stock-petscii.ts` was checked for a reusable byte-to-text converter and
286
+ * exports only `asciiToPetscii()` -- the other direction. A second conversion
287
+ * table is NOT invented here; that would be the same drift hazard wearing a
288
+ * local name.
289
+ *
290
+ * OVERLAP: `--strict-segments` is in the verify argv, which promotes ACME's
291
+ * "Segment starts inside another one, overwriting it." from a Warning to an
292
+ * Error (measured, exit 1). Without it a store holding two overlapping ranges
293
+ * would silently overwrite one with the other and the byte-diff would compare
294
+ * against whichever won.
295
+ */
296
+ function emitDataLines(slice: Uint8Array, dataType: string, blockStart: number): DataLine[] {
297
+ const out: DataLine[] = [];
298
+
299
+ if (WORD_PAIR_DATA_TYPES.includes(dataType) && slice.length % 2 === 0) {
300
+ for (let offset = 0; offset < slice.length; offset += WORDS_PER_DATA_LINE * 2) {
301
+ const chunk = slice.subarray(offset, Math.min(offset + WORDS_PER_DATA_LINE * 2, slice.length));
302
+ const values: string[] = [];
303
+ for (let i = 0; i < chunk.length; i += 2) values.push(hex4(chunk[i]! | (chunk[i + 1]! << 8)));
304
+ out.push({
305
+ text: `${INDENT}!word ${values.join(", ")} ; ${dataType}`,
306
+ start: blockStart + offset,
307
+ endExclusive: blockStart + offset + chunk.length,
308
+ });
309
+ }
310
+ return out;
311
+ }
312
+
313
+ // The fallback says WHY it happened, because "this word table came out as
314
+ // bytes" is otherwise indistinguishable from a missing feature.
315
+ const why = WORD_PAIR_DATA_TYPES.includes(dataType)
316
+ ? ` (odd byte count ${slice.length} -- !word emits PAIRS, so a byte-identical emission falls back to !byte)`
317
+ : "";
318
+ for (let offset = 0; offset < slice.length; offset += BYTES_PER_DATA_LINE) {
319
+ const chunk = slice.subarray(offset, Math.min(offset + BYTES_PER_DATA_LINE, slice.length));
320
+ out.push({
321
+ text: `${INDENT}!byte ${[...chunk].map(hex2).join(", ")} ; ${dataType}${why}`,
322
+ start: blockStart + offset,
323
+ endExclusive: blockStart + offset + chunk.length,
324
+ });
325
+ }
326
+ return out;
327
+ }
328
+
329
+ /** ACME source indent for directive lines, matching `disasm-renderer.ts`'s own
330
+ * cosmetic indent so the two emitters' output reads as one document. */
331
+ const INDENT = " ";
332
+
333
+ function hex2(value: number): string {
334
+ return `$${(value & 0xff).toString(16).padStart(2, "0")}`;
335
+ }
336
+
337
+ function hex4(value: number): string {
338
+ return `$${(value & 0xffff).toString(16).padStart(4, "0")}`;
339
+ }
340
+
341
+ /**
342
+ * `$XXXX` FOR A BLOCK'S EXCLUSIVE END, WHICH IS `hex4()` -- MASKED, NOT PADDED.
343
+ *
344
+ * THIS FUNCTION USED TO DO THE OPPOSITE, AND IT WAS WRONG (30-REVIEW WR-04,
345
+ * corrected 2026-08-31). It padded without masking, so a range ending at
346
+ * `$ffff` produced the end assertion `!if * != $10000`. Its doc justified that
347
+ * by asserting that `hex4()`'s mask "would render that as `$0000` -- an
348
+ * assertion no assembly can ever satisfy, firing on a correct export". That
349
+ * claim was never measured, and it is FALSE in exactly the direction that
350
+ * matters: ACME's `*` is a 16-BIT program counter and WRAPS.
351
+ *
352
+ * MEASURED, real ACME 0.97 "Zem" on this host, 2026-08-31:
353
+ *
354
+ * !cpu 6510
355
+ * * = $fffe
356
+ * !if * != $fffe { !error "origin drifted, expected $fffe" }
357
+ * !byte $aa, $bb
358
+ * !if * != $0000 { !error "end drifted, expected $0000" }
359
+ *
360
+ * -> exit 0, output file written, bytes `aa bb`.
361
+ *
362
+ * The same source with `!if * != $10000` fails: `!error: end drifted` and no
363
+ * output file. So the UNMASKED form is the one that "fires on a correct
364
+ * export", for every range touching the top of memory -- and the failure looks
365
+ * like an exporter bug rather than an arithmetic one.
366
+ *
367
+ * WHY THE ASSUMPTION LOOKED SAFE: ACME's own `-v2` diagnostics DO print the
368
+ * unwrapped extent, `Saving 2 (0x2) bytes (0xfffe - 0x10000 exclusive)`. That
369
+ * is ACME describing a SEGMENT; `*` is a different thing and wraps. Do not
370
+ * reintroduce an unmasked extent on the strength of that line.
371
+ *
372
+ * THE GUARD STILL BITES AT THE TOP OF MEMORY, measured the same way: the same
373
+ * source emitting ONE byte instead of two leaves `*` at `$ffff`, the
374
+ * assertion fires, ACME exits 1 and writes no output file. The mask does not
375
+ * make the top-of-memory assertion vacuous.
376
+ *
377
+ * Kept as a named function rather than folded into `hex4()` so this record has
378
+ * somewhere to live, and so the ONE value in this module that is not an
379
+ * address still reads differently at its call site.
380
+ */
381
+ function hexExtent(value: number): string {
382
+ return hex4(value);
383
+ }
384
+
385
+ /**
386
+ * Wraps one block's content lines in its origin and its `*` assertions.
387
+ *
388
+ * THE EXCLUSIVE END IS THE POINT. Measured on ACME 0.97: `* = $0801` followed
389
+ * by `lda #$00` and `rts` leaves `*` at `$0804`, one past the last emitted
390
+ * byte. The store's own row uses an INCLUSIVE end, so the conversion is
391
+ * `endExclusive = row.endInclusive + 1` and it is done in exactly one place
392
+ * (see `exportAsm()`'s block construction).
393
+ *
394
+ * WHY BOTH ENDS. `!cpu 6510` plus correct-looking mnemonics is not enough:
395
+ * measured, a substitution that changes ONE instruction's length -- dropping
396
+ * ACME's `+2` size force from an absolute operand below `$0100`, or
397
+ * forward-referencing a zero-page symbol -- assembles at exit 0 and shifts
398
+ * every byte after it. The end assertion is what turns that into a refusal:
399
+ * ACME exits 1, prints the `!error` text below on stderr, and writes NO output
400
+ * file.
401
+ *
402
+ * WHY THE EXPECTED VALUE IS SPELLED OUT IN THE MESSAGE. Interpolating `*` into
403
+ * an `!error` renders it as `<decimal> (0x<hex>)`, not as `$hex`, so the
404
+ * expected value is written in `$` form in the message text itself rather than
405
+ * relying on ACME's rendering.
406
+ *
407
+ * WHY NOT `!pseudopc`. It is not an alternative: measured, it errors with
408
+ * `Program counter undefined.` unless `*` has already been set.
409
+ */
410
+ function emitBlock(start: number, endExclusive: number, lines: readonly string[]): string[] {
411
+ return [
412
+ `* = ${hexExtent(start)}`,
413
+ `!if * != ${hexExtent(start)} { !error "export-asm: block origin drifted, expected ${hexExtent(start)}" }`,
414
+ ...lines,
415
+ `!if * != ${hexExtent(endExclusive)} { !error "export-asm: block end drifted, expected ${hexExtent(endExclusive)}" }`,
416
+ ];
417
+ }
418
+
419
+ /**
420
+ * Reads the image and dispatches its layout, BY EXTENSION FIRST and never by
421
+ * byte length. That order is a contract copied from the surface's own image
422
+ * loaders rather than re-derived: a truncated flat capture that falls through
423
+ * to the `.prg` parser gets a load address read backwards out of its own
424
+ * payload bytes, and every downstream address is then wrong with no
425
+ * diagnostic.
426
+ */
427
+ function loadImage(imagePath: string): { origin: number; bytes: Uint8Array } {
428
+ let raw: Uint8Array;
429
+ try {
430
+ raw = new Uint8Array(readFileSync(imagePath));
431
+ } catch (err) {
432
+ // An ERRNO-class failure (ENOENT, EACCES, EISDIR) carries no byte of the
433
+ // file's content, so it is left interpolated on purpose.
434
+ throw new Error(`exportAsm: could not read the image at "${imagePath}": ${err instanceof Error ? err.message : String(err)}`);
435
+ }
436
+
437
+ const ext = extname(imagePath).toLowerCase();
438
+ if (ext === ".raw" || ext === ".bin") {
439
+ return { origin: flatImageOrigin(raw), bytes: raw };
440
+ }
441
+ if (ext !== ".prg" && raw.length === 65536) {
442
+ return { origin: flatImageOrigin(raw), bytes: raw };
443
+ }
444
+ if (ext === ".prg") {
445
+ const { origin, body } = parsePrg(raw);
446
+ return { origin, bytes: new Uint8Array(body) };
447
+ }
448
+ throw new Error(
449
+ `exportAsm: the image at "${imagePath}" has extension "${ext}", which is not one this exporter reads. ` +
450
+ `Supply a .prg (2-byte load address plus payload) or a flat 64K capture (.raw/.bin, exactly 65536 bytes).`,
451
+ );
452
+ }
453
+
454
+ /**
455
+ * Formats one header symbol definition.
456
+ *
457
+ * THE HEX-DIGIT COUNT IS LOAD-BEARING, not cosmetic. Measured on ACME 0.97:
458
+ * `zpf = $10` then `lda zpf` assembles to `a5 10` (2 bytes, zeropage), while
459
+ * `zpf = $0010` then the same `lda zpf` assembles to `ad 10 00` (3 bytes,
460
+ * absolute). The DEFINITION's width decides the OPERAND's width, so an address
461
+ * below `$0100` must be written with two digits and everything else with four.
462
+ * Getting this wrong ships an export that ACME accepts and that produces the
463
+ * wrong bytes -- the single most likely way for this module to be quietly
464
+ * incorrect.
465
+ *
466
+ * THE HEX CASE IS LOWER, MATCHING EVERY OTHER EMITTER IN THIS DOCUMENT
467
+ * (30-REVIEW IN-03, corrected 2026-08-31). This function used to emit
468
+ * uppercase (`start = $C000`) while `hex2()`, `hex4()` and `hexExtent()` all
469
+ * emit lowercase (`* = $0801`, `!byte $a9`), so one generated file carried two
470
+ * conventions. Both assemble identically -- ACME is case-insensitive for hex
471
+ * digits, and the round-trip byte-diff is unchanged by this -- so the only
472
+ * cost was that the artefact read as if two tools had written it. Lower is
473
+ * chosen because it is what the other three emitters, and the golden witness
474
+ * disassembly they were matched to, already use: one emitter changes rather
475
+ * than three.
476
+ *
477
+ * The WIDTH rule above is untouched by this and is not a matter of taste.
478
+ */
479
+ function formatSymbolDefinition(name: string, address: number): string {
480
+ const digits = address < 0x100 ? 2 : 4;
481
+ return `${name} = $${address.toString(16).padStart(digits, "0")}`;
482
+ }
483
+
484
+ /**
485
+ * One mid-instruction label definition, in the golden witness's own compact
486
+ * spelling -- no spaces around the `=`, the offset in two hex digits:
487
+ * `f_0900 =*+$01` [`.planning/notes/dxa-ghidra-pivot-evidence/anno.asm:202`].
488
+ * That witness carries SIX such labels (lines 51, 81, 135, 145, 202 and 205);
489
+ * the ROADMAP note saying four is documentation drift, corrected in
490
+ * `30-RESEARCH.md`.
491
+ *
492
+ * `offset` is `label.address - instr.address`, so it is 1 or 2 for every
493
+ * 6502/6510 instruction -- the value is rendered rather than bounded here
494
+ * because the caller derives it from a decoded instruction's own length and
495
+ * cannot produce anything else.
496
+ */
497
+ function midInstructionLabelLine(name: string, offset: number): string {
498
+ return `${name} =*+$${offset.toString(16).padStart(2, "0")}`;
499
+ }
500
+
501
+ /**
502
+ * Below this address a mid-instruction label is REFUSED rather than emitted.
503
+ * See `exportAsm()`'s code-block emitter for the measured reason.
504
+ */
505
+ const MID_INSTRUCTION_LABEL_FLOOR = 0x100;
506
+
507
+ /**
508
+ * The fixed trailing comment that marks an auto-generated symbol name in the
509
+ * emitted source. ONE spelling, in one place: a second wording would make the
510
+ * marker ungreppable for the human reading the generated assembly, which is the
511
+ * only reader it exists for.
512
+ */
513
+ const AUTO_NAME_MARKER = " ; auto-generated name -- still in the annotation backlog";
514
+
515
+ /**
516
+ * The fixed trailing comment that marks a definition at an address carrying
517
+ * MORE THAN ONE store label (30-REVIEW WR-02). ONE spelling, in one place, for
518
+ * the same reason `AUTO_NAME_MARKER` is: a second wording makes it ungreppable
519
+ * for the only reader it exists for.
520
+ *
521
+ * The full marker is this prefix, the colliding names in `sortedLabels` order,
522
+ * and which of them references actually render through -- so the arbitrary
523
+ * pick `symbolFor()` used to make in silence is stated in the artefact.
524
+ */
525
+ const ALIAS_MARKER_PREFIX = " ; ALIAS: this address also carries ";
526
+
527
+ /**
528
+ * The largest value an enum variant may carry to be substitutable into an
529
+ * IMMEDIATE operand.
530
+ *
531
+ * Measured on ACME 0.97: `viccolor_WIDE = $0100` then `lda #viccolor_WIDE`
532
+ * is `Error ... : Number does not fit in 8 bits.` at exit 1. The exporter
533
+ * refuses FIRST so the message can name the store row and the enum, rather than
534
+ * a line number in a temp file the caller never sees.
535
+ */
536
+ const MAX_IMMEDIATE_VARIANT_VALUE = 0xff;
537
+
538
+ /**
539
+ * Replaces the `#$XX` immediate literal `renderLine()` produced with `#symbol`.
540
+ *
541
+ * WHY A TARGETED TEXT SUBSTITUTION RATHER THAN A `RenderOptions` WIDENING.
542
+ * D-11 forbids `renderLine()` from substituting a symbol into an immediate
543
+ * operand at all, because of the `#<`/`#>` high/low-byte ambiguity, and that
544
+ * rule is verified against a real assembler in `disasm-roundtrip.test.ts`. It
545
+ * is not relaxed here. What an ENUM adds is a caller-supplied fact the renderer
546
+ * does not have -- that this particular byte is a member of a named vocabulary
547
+ * -- so the substitution happens at this boundary, on this module's own output,
548
+ * for exactly one operand whose width is one byte and therefore cannot change.
549
+ *
550
+ * The literal is located and matched EXACTLY. A rendered line that does not
551
+ * carry the expected literal is a disagreement between this module and the
552
+ * renderer, and it is refused rather than patched over: a `replace()` that
553
+ * silently matched nothing would emit the hex literal while the count claimed a
554
+ * substitution happened.
555
+ *
556
+ * THE SEARCH IS CONFINED TO THE DIRECTIVE HALF OF THE LINE, AND THAT IS THE
557
+ * SECOND HALF OF 30-REVIEW CR-01's FIX (2026-08-31). `renderLine()` emits a
558
+ * trailing `" ; "` comment for notes, and for an instruction whose
559
+ * `acmeExpressible` is false it emits the whole thing as a `!byte` directive
560
+ * with the mnemonic AND its `#$xx` operand moved INTO that comment
561
+ * (`disasm-renderer.ts`'s `!instr.acmeExpressible` branch). A bare
562
+ * `line.indexOf()` therefore found `#$00` in the COMMENT, rewrote it there,
563
+ * and returned a line whose assembler-visible half still carried the raw
564
+ * byte -- while the caller counted a substitution and the export exited 0.
565
+ * Reproduced against the committed code before this fix, for `$eb`
566
+ * (`sbc #imm`):
567
+ *
568
+ * !byte $eb, $00 ; sbc #viccolor_BLACK [illegal opcode | not expressible ...]
569
+ *
570
+ * The caller now refuses an unexpressible opcode outright, so this confinement
571
+ * is defence in depth against the same class of mistake arriving by a
572
+ * different route -- a future renderer that puts a `#$xx` in a comment for any
573
+ * other reason gets the "does not contain the literal" refusal below instead
574
+ * of a silent no-op substitution.
575
+ *
576
+ * EXPORTED FOR TEST REACH ONLY, on the same terms as
577
+ * `assertExportableCommentText()` below: no other module calls it, and the one
578
+ * that would (`anno-cli.ts`) goes through `exportAsm()`. It is exported
579
+ * because the caller now refuses an unexpressible opcode BEFORE reaching here,
580
+ * which makes the confinement above unreachable through `exportAsm()` and
581
+ * therefore untestable at that level -- an untested guard is the thing that
582
+ * lets the next route in.
583
+ */
584
+ export function substituteImmediateEnum(line: string, value: number, symbol: string, address: number): string {
585
+ const literal = `#${hex2(value)}`;
586
+ // `" ; "` is `renderLine()`'s own comment separator, in the one place this
587
+ // module has to know about it. Everything from it onward is prose for a
588
+ // human and is never assembler input; a substitution there reaches nobody.
589
+ const directiveHalf = line.split(" ; ")[0];
590
+ const at = directiveHalf.indexOf(literal);
591
+ if (at < 0) {
592
+ throw new Error(
593
+ `exportAsm: the instruction at ${hex4(address)} carries an enum usage, but the ASSEMBLER-VISIBLE half of its rendered line ` +
594
+ `does not contain the immediate literal ${literal} this module expected to replace. Refusing to emit a line whose ` +
595
+ `substitution silently did nothing (or landed in the trailing comment, where the assembler never reads it).`,
596
+ );
597
+ }
598
+ return `${line.slice(0, at)}#${symbol}${line.slice(at + literal.length)}`;
599
+ }
600
+
601
+ /**
602
+ * The store's own spellings for the two comment placements, DESTRUCTURED out of
603
+ * `COMMENT_TYPES` -- the ONE home of that vocabulary -- rather than re-typed as
604
+ * literals here. The same idiom `anno-memmap-render.ts` uses at its own read
605
+ * boundary: a re-typed `"line"` is a second copy of a vocabulary that has one
606
+ * home, and the two diverge in silence the first time the schema's spelling
607
+ * changes.
608
+ */
609
+ const [LINE_COMMENT, SIDE_COMMENT] = COMMENT_TYPES;
610
+
611
+ /**
612
+ * Re-checks, at the EXPORT boundary, that a stored comment can be emitted.
613
+ *
614
+ * WHY THIS EXISTS RATHER THAN TRUSTING THE STORE. `assertCommentText()` is the
615
+ * one comment-text vocabulary and it now refuses an embedded line break -- but
616
+ * a store file written BEFORE that refusal existed, or through any route that
617
+ * did not call it, can still hold one on disk. This boundary is the last place
618
+ * before those bytes become assembler input, so it asks the question again. It
619
+ * RE-CHECKS rather than RE-DEFINES: the predicate is `assertCommentText()`'s,
620
+ * called here, never a second regex that could drift from it.
621
+ *
622
+ * The store validator's own message is deliberately DISCARDED and replaced.
623
+ * That message interpolates the offending text for one of its four cases, and
624
+ * an exporter error that quotes a file's contents back is a content-disclosure
625
+ * oracle (CR-03). What survives is the address and which rule fired -- facts
626
+ * ABOUT the comment, never the comment.
627
+ */
628
+ export function assertExportableCommentText(text: string, address: number): string {
629
+ try {
630
+ return assertCommentText(text);
631
+ } catch (err) {
632
+ const reason = err instanceof AnnoCommentError && err.reason !== undefined ? err.reason : "refused by the store's comment-text vocabulary";
633
+ throw new Error(
634
+ `exportAsm: the comment at ${hex4(address)} cannot be emitted (${reason}). Every comment this exporter emits is a single line of text ` +
635
+ `that the store's own comment-text vocabulary accepts; a stored line break would put everything after it into the ACME source at ` +
636
+ `column zero, as assembler input rather than as a comment. REFUSED rather than repaired -- stripping or truncating here would change ` +
637
+ `what somebody wrote and report success.`,
638
+ );
639
+ }
640
+ }
641
+
642
+ /**
643
+ * Re-checks, at the EXPORT boundary, that a stored range's `dataType` is one
644
+ * the store's own vocabulary defines (30-REVIEW WR-03).
645
+ *
646
+ * The sibling of `assertExportableCommentText()` below, on the same terms and
647
+ * for the same reason: `listRanges()` casts the column with no validator, so
648
+ * this is the last place before that string is interpolated into ACME source
649
+ * text. See the call site in `exportAsm()` for the full record.
650
+ *
651
+ * The store validator's own message is deliberately DISCARDED and replaced,
652
+ * again for `assertExportableCommentText()`'s reason: `assertDataType()`
653
+ * interpolates the offending value, and an exporter error that quotes a
654
+ * file's contents back is a content-disclosure oracle. What survives is the
655
+ * ADDRESS RANGE and the valid list -- facts about the row and about this
656
+ * module's own vocabulary, never a byte read off disk.
657
+ *
658
+ * EXPORTED FOR TEST REACH ONLY, on the same terms as
659
+ * `assertExportableCommentText()`: the state it guards against is reachable
660
+ * only through a store file edited outside `anno-store.ts`, and
661
+ * `anno-store.ts` is the ONE module in this repo permitted to name
662
+ * `node:sqlite` -- so a test cannot manufacture the row and can only drive the
663
+ * predicate. An unreachable-through-the-type guard with no test is how the
664
+ * next such column goes unchecked.
665
+ */
666
+ export function assertDataTypeForExport(row: { start: number; endInclusive: number; dataType: unknown }): DataType {
667
+ try {
668
+ return assertDataType(row.dataType);
669
+ } catch {
670
+ throw new Error(
671
+ `exportAsm: the range ${hex4(row.start)}..${hex4(row.endInclusive)} (inclusive) carries a data type that is not one of the ` +
672
+ `${DATA_TYPES.length} the store defines (${DATA_TYPES.join(", ")}) -- refusing to guess what it meant. This module copies a ` +
673
+ `range's data type VERBATIM into the emitted source's block comment, so an unvalidated value reaches ACME as text: one ` +
674
+ `containing a line break would put everything after it at column zero, as assembler input rather than as a comment. ` +
675
+ `The offending value is deliberately NOT quoted here -- an exporter error that echoes a file's contents is a ` +
676
+ `content-disclosure oracle.`,
677
+ );
678
+ }
679
+ }
680
+
681
+ /** Where the comments live while a block is being emitted, and which of them
682
+ * have found a line to attach to. Anything still unplaced when the last block
683
+ * is done is REFUSED by name rather than dropped. */
684
+ interface CommentPlacement {
685
+ byAddress: ReadonlyMap<number, CommentRow[]>;
686
+ placed: Set<number>;
687
+ }
688
+
689
+ /**
690
+ * Attaches every stored comment for the addresses `[start, endExclusive)` to
691
+ * one emitted line: a `line` comment on its own line immediately before it, a
692
+ * `side` comment appended to it.
693
+ *
694
+ * Multiple comments at one address emit in `id` order, which is the order
695
+ * `listComments()` returns them in -- so two people's notes at one address keep
696
+ * the order they were written in rather than an order this module invented.
697
+ *
698
+ * A COMMENT ON A MULTI-ADDRESS LINE IS QUALIFIED WITH ITS OWN ADDRESS
699
+ * (30-REVIEW IN-02, added 2026-08-31). The CODE path calls this with a span of
700
+ * exactly ONE address (`[instr.address, instr.address + 1)`), so a comment
701
+ * there is unambiguous and is emitted unchanged -- nothing about the existing
702
+ * output moves. The DATA path calls it with a span of up to
703
+ * `BYTES_PER_DATA_LINE` addresses, and there `n` comments on `n` DISTINCT data
704
+ * bytes emitted as `n` indistinguishable lines above one `!byte` directive: a
705
+ * human reading the generated assembly could not tell which byte each note was
706
+ * about, and the information was not recoverable from the artefact.
707
+ *
708
+ * QUALIFIED RATHER THAN SPLIT. Splitting the `!byte` line at each commented
709
+ * address was the other candidate and is worse here: it changes the emitted
710
+ * TEXT's structure for a presentation problem, and every extra directive is
711
+ * another line whose width and origin the byte-diff has to keep agreeing
712
+ * about. A prefix changes nothing an assembler reads.
713
+ *
714
+ * GATED ON AMBIGUITY, not applied always: prefixing every code-path comment
715
+ * with an address it already sits next to is noise, and it would rewrite every
716
+ * existing expected line in the test suite for nothing.
717
+ */
718
+ function withComments(text: string, start: number, endExclusive: number, ctx: CommentPlacement): string[] {
719
+ const before: string[] = [];
720
+ let line = text;
721
+
722
+ // One emitted line covering more than one address cannot say WHICH address a
723
+ // comment belongs to unless the comment says so itself.
724
+ const spanIsAmbiguous = endExclusive - start > 1;
725
+
726
+ for (let address = start; address < endExclusive; address++) {
727
+ for (const row of ctx.byAddress.get(address) ?? []) {
728
+ const checked = assertExportableCommentText(row.text, row.address);
729
+ const safe = spanIsAmbiguous ? `${hex4(row.address)}: ${checked}` : checked;
730
+ ctx.placed.add(row.id);
731
+ if (row.commentType === LINE_COMMENT) {
732
+ before.push(`${INDENT}; ${safe}`);
733
+ } else if (row.commentType === SIDE_COMMENT) {
734
+ line = `${line} ; ${safe}`;
735
+ } else {
736
+ // Unreachable through the type, and reachable through a store file
737
+ // somebody edited. Refusing beats guessing which of the two placements
738
+ // an unknown third one meant.
739
+ throw new Error(
740
+ `exportAsm: the comment at ${hex4(row.address)} has placement ${JSON.stringify(row.commentType)}, which is not one of the ` +
741
+ `${COMMENT_TYPES.length} placements the store defines (${COMMENT_TYPES.join(", ")}) -- refusing to guess where it belongs.`,
742
+ );
743
+ }
744
+ }
745
+ }
746
+
747
+ return [...before, line];
748
+ }
749
+
750
+ /**
751
+ * Exports the annotation store at `options.storePath`, over the image at
752
+ * `options.imagePath`, as ACME source plus the exact bytes that source must
753
+ * assemble to.
754
+ *
755
+ * The returned `source` is NOT self-verifying and this function makes no claim
756
+ * that it reassembles: that claim is settled by assembling it with a real ACME
757
+ * and byte-diffing the result against `expectedBytes`.
758
+ *
759
+ * Throws (never returns a degraded result) when the store holds no ranges, when
760
+ * a range is not covered by the image, or when a label name is not a legal ACME
761
+ * identifier. Every message is prefixed `exportAsm:` and carries paths,
762
+ * addresses and counts only.
763
+ */
764
+ export function exportAsm(options: ExportAsmOptions): ExportAsmResult {
765
+ const { storePath, imagePath, workspaceRoot } = options;
766
+
767
+ const image = loadImage(imagePath);
768
+
769
+ // ONE handle for the whole export, closed in a `finally`. `mustExist` is what
770
+ // makes "the annotations are gone" and "there are no annotations" refuse
771
+ // differently: without it a mistyped path would CREATE an empty store and
772
+ // export as a program with nothing annotated, indistinguishable from a real
773
+ // one.
774
+ const handle = openStore(storePath, { workspaceRoot, mustExist: true });
775
+ let ranges: RangeRow[];
776
+ let labels: LabelRow[];
777
+ let comments: CommentRow[];
778
+ let projectEnums: ProjectEnumRow[];
779
+ let enumUsage: EnumUsageRow[];
780
+ try {
781
+ ranges = listRanges(handle);
782
+ labels = listLabels(handle);
783
+ comments = listComments(handle);
784
+ projectEnums = listProjectEnums(handle);
785
+ enumUsage = listEnumUsage(handle);
786
+ } finally {
787
+ closeStore(handle);
788
+ }
789
+
790
+ if (ranges.length === 0) {
791
+ throw new Error(
792
+ `exportAsm: the annotation store at "${storePath}" holds no ranges -- refusing to emit an empty ACME source, ` +
793
+ `because "nothing is annotated" and "the export produced nothing" must not read the same.`,
794
+ );
795
+ }
796
+
797
+ const sortedRanges = [...ranges].sort((a, b) => a.start - b.start);
798
+
799
+ // The store's end is INCLUSIVE; ACME's `*` after a block sits one past the
800
+ // last byte. This conversion is a documented carried hazard in this project
801
+ // with several boundaries -- it is done ONCE, here.
802
+ const blocks: ExportBlock[] = sortedRanges.map((row) => ({
803
+ start: row.start,
804
+ endExclusive: row.endInclusive + 1,
805
+ // THE STORE'S `dataType` IS RE-CHECKED AT THIS BOUNDARY (30-REVIEW WR-03,
806
+ // fixed 2026-08-31), for exactly the reason `withComments()` re-checks
807
+ // `commentType` a few functions up: "Unreachable through the type, and
808
+ // reachable through a store file somebody edited. Refusing beats
809
+ // guessing." That reasoning applies here and had not been applied.
810
+ //
811
+ // `listRanges()` casts `row.data_type as DataType` with no validator call
812
+ // (`anno-store.ts`), so a store whose `anno_range.data_type` column was
813
+ // edited on disk carried an ARBITRARY string into `emitDataLines()`, which
814
+ // interpolates it verbatim into the emitted block comment. A value
815
+ // containing a line break would put everything after it into the ACME
816
+ // source at COLUMN ZERO, as assembler input rather than as a comment --
817
+ // the same mechanism `assertExportableCommentText()` refuses for comment
818
+ // text, arriving through a column nobody had checked.
819
+ //
820
+ // The block-end `!if` assertion would catch the resulting drift at
821
+ // ASSEMBLY time, but the `anno export-asm` CLI verb assembles nothing: it
822
+ // would write the corrupted file and exit 0. This is the last boundary
823
+ // before those bytes become assembler input, so it asks the question here.
824
+ //
825
+ // RE-CHECKS rather than RE-DEFINES: the predicate is `assertDataType()`'s,
826
+ // called here, never a second list of the twelve types that could drift
827
+ // from it.
828
+ dataType: assertDataTypeForExport(row) as string,
829
+ lineCount: 0,
830
+ }));
831
+
832
+ const imageStart = image.origin;
833
+ const imageEndExclusive = image.origin + image.bytes.length;
834
+ for (const block of blocks) {
835
+ if (block.start < imageStart || block.endExclusive > imageEndExclusive) {
836
+ throw new Error(
837
+ `exportAsm: the range ${hex4(block.start)}..${hex4(block.endExclusive - 1)} (inclusive) is not covered by the ` +
838
+ `image at "${imagePath}", which covers ${hex4(imageStart)}..${hex4(imageEndExclusive - 1)} (inclusive). ` +
839
+ `Refusing to export a range whose bytes the image does not contain.`,
840
+ );
841
+ }
842
+ }
843
+
844
+ // The label index the renderer's `symbolFor` hook reads. Every name is
845
+ // validated BEFORE it can reach the source text -- REJECT, never sanitise.
846
+ const sortedLabels = [...labels].sort((a, b) => a.address - b.address);
847
+ const labelIndex = new Map<number, string>();
848
+ /** Every address carrying MORE THAN ONE store label, with all their names in
849
+ * `sortedLabels` order. See the loop below for why this is recorded rather
850
+ * than refused. */
851
+ const aliasedAddresses = new Map<number, string[]>();
852
+ for (const label of sortedLabels) {
853
+ assertLegalAcmeIdentifier(label.name, `exportAsm: label at ${hex4(label.address)}`);
854
+ // TWO NAMES AT ONE ADDRESS IS RECORDED IN THE EMITTED SOURCE, NOT RESOLVED
855
+ // IN SILENCE (30-REVIEW WR-02, second half, fixed 2026-08-31).
856
+ //
857
+ // `labelIndex` is a `Map<number, string>` while `anno_label` is `unique`
858
+ // on NAME only -- `setLabel()` refuses only a name already bound to a
859
+ // DIFFERENT address -- so two names at one address is a SUPPORTED store
860
+ // state (an alias). In it, this `set()` silently overwrote the first and
861
+ // `symbolFor()` returned whichever name sorted last. Reproduced: with
862
+ // `smc_operand` and `smc_alias` both at $0802, the emitted `inc`
863
+ // referenced `smc_alias` with no diagnostic anywhere.
864
+ //
865
+ // NOT REFUSED, DELIBERATELY, and this is the one place in this module that
866
+ // records rather than refuses. Every other refusal here is for something
867
+ // the exporter CANNOT express; an alias it CAN -- both definitions go into
868
+ // the header (or inline), ACME accepts two symbols with one value, and the
869
+ // bytes are unaffected. The only thing that was wrong is that the arbitrary
870
+ // pick for REFERENCES was invisible. Refusing instead would delete a
871
+ // supported store state to fix a diagnostic problem.
872
+ //
873
+ // FIRST NAME WINS, not last: `sortedLabels` is ascending by address and
874
+ // otherwise in `listLabels()` order, so keeping the first makes the pick
875
+ // stable rather than an artefact of a sort that never promised a
876
+ // tiebreak. The comment emitted with the definitions names every
877
+ // candidate, so a human reading the source can see what was chosen and
878
+ // what was not.
879
+ const existing = labelIndex.get(label.address);
880
+ if (existing === undefined) {
881
+ labelIndex.set(label.address, label.name);
882
+ } else {
883
+ const names = aliasedAddresses.get(label.address);
884
+ if (names) names.push(label.name);
885
+ else aliasedAddresses.set(label.address, [existing, label.name]);
886
+ }
887
+ }
888
+ const symbolFor = (address: number): string | undefined => labelIndex.get(address);
889
+
890
+ /** EVERY store label's NAME, whether it ends up defined in the header or
891
+ * inline. `labelIndex` cannot serve this: it is keyed by address and holds
892
+ * only the first name at each, so an ALIASED label would be invisible to a
893
+ * collision check reading it. ACME has ONE symbol namespace, so an enum
894
+ * variant symbol colliding with any of these is `Symbol already defined.`
895
+ * (30-REVIEW WR-10). */
896
+ const labelSymbolNames = new Set(sortedLabels.map((label) => label.name));
897
+
898
+ // Comments indexed by the address they annotate, each address's list left in
899
+ // `listComments()`'s own `id` order.
900
+ const commentsByAddress = new Map<number, CommentRow[]>();
901
+ for (const row of comments) {
902
+ const at = commentsByAddress.get(row.address);
903
+ if (at) at.push(row);
904
+ else commentsByAddress.set(row.address, [row]);
905
+ }
906
+ const placement: CommentPlacement = { byAddress: commentsByAddress, placed: new Set<number>() };
907
+
908
+ // Enum usages indexed by the address they annotate. `listEnumUsage()` already
909
+ // resolves the enum's NAME through a join on `anno_enum.id`, so this module
910
+ // never holds a second on-disk copy of it; the row set is turned into an
911
+ // address lookup here and the enum's own variants are joined on by name from
912
+ // `listProjectEnums()`.
913
+ const enumsByName = new Map<string, ProjectEnumRow>();
914
+ for (const row of projectEnums) enumsByName.set(row.name, row);
915
+ const usageByAddress = new Map<number, EnumUsageRow>();
916
+ for (const row of enumUsage) usageByAddress.set(row.address, row);
917
+ const appliedEnumUsage = new Set<number>();
918
+ /** `<enumName>_<VARIANT> = $XX` definition lines, in first-emitted order.
919
+ * They join the header block for the same reason label definitions do. */
920
+ const enumDefinitionLines: string[] = [];
921
+ const definedEnumSymbols = new Set<string>();
922
+
923
+ let unexpressibleCount = 0;
924
+ let dataByteCount = 0;
925
+
926
+ // AUTO-GENERATED NAMES ARE MARKED, not filtered. Every store label reaches
927
+ // the source either way; the marker is the backlog signal, carried into the
928
+ // one artefact that leaves this tree. The predicate is
929
+ // `AUTO_NAME_PREFIX_RE`'s, imported -- never a second copy. It is applied at
930
+ // BOTH definition sites, header and inline, so an auto-named self-modifying
931
+ // code operand is as visible in the backlog as any other.
932
+ let autoNamedSymbolCount = 0;
933
+ const markIfAutoNamed = (name: string, line: string): string => {
934
+ if (!AUTO_NAME_PREFIX_RE.test(name)) return line;
935
+ autoNamedSymbolCount++;
936
+ return `${line}${AUTO_NAME_MARKER}`;
937
+ };
938
+
939
+ // THE ALIAS PICK IS MADE VISIBLE IN THE SOURCE (30-REVIEW WR-02, second
940
+ // half). Two store labels at one address are BOTH defined -- ACME accepts
941
+ // two symbols with one value and the bytes are unaffected -- but a
942
+ // REFERENCE to that address can render through only one of them. Which one
943
+ // was previously invisible. Marking both definitions with the same fixed
944
+ // wording means the human reading the generated assembly can see the
945
+ // collision and the choice, from either definition line, without having to
946
+ // reconstruct the exporter's sort order. Applied at BOTH definition sites,
947
+ // header and inline, for the reason `markIfAutoNamed()` is: an aliased
948
+ // self-modifying-code operand is exactly the shape this was reproduced on.
949
+ const markIfAliased = (address: number, line: string): string => {
950
+ const names = aliasedAddresses.get(address);
951
+ if (names === undefined) return line;
952
+ return `${line}${ALIAS_MARKER_PREFIX}${names.join(", ")} -- references render through ${labelIndex.get(address)}`;
953
+ };
954
+
955
+ // The addresses of every label emitted INLINE as `name =*+$NN`. They are
956
+ // collected during block emission and read afterwards by the header, which
957
+ // is why the header is built AFTER this loop even though it is emitted
958
+ // BEFORE it: a label defined inline must not ALSO be defined in the header,
959
+ // or ACME refuses the whole source with `Symbol already defined.`
960
+ const midInstructionLabelAddresses = new Set<number>();
961
+
962
+ // ONE PER EMITTED INLINE DEFINITION, not one per address (30-REVIEW WR-02).
963
+ // The set above answers the HEADER's question ("is this address defined
964
+ // inline already?"), which is per-address by nature. This counter answers
965
+ // the RESULT's question ("how many inline definitions does the source
966
+ // carry?"), which is per-label -- and two labels at one address is a
967
+ // supported store state, so the two questions have different answers.
968
+ // Incremented beside the `content.push()` that emits the line it counts,
969
+ // so it cannot drift from the emitted text.
970
+ let midInstructionLabelCount = 0;
971
+
972
+ const blockLines: string[] = [];
973
+
974
+ for (const block of blocks) {
975
+ const slice = image.bytes.subarray(block.start - imageStart, block.endExclusive - imageStart);
976
+ const content: string[] = [];
977
+
978
+ if (block.dataType === CODE_DATA_TYPE) {
979
+ // D-11 is inherited UNCHANGED: `renderLine()` decides operand width and
980
+ // refuses to substitute a symbol into an immediate or zeropage-family
981
+ // operand. Do not widen `RenderOptions` and do not bypass `renderLine()`.
982
+ // `end` IS INCLUSIVE, SO IT IS HANDED AN INCLUSIVE VALUE (30-REVIEW
983
+ // WR-07, corrected 2026-08-31). This used to pass `block.endExclusive`.
984
+ // `DecodeOptions.end` is compared with `if (end !== undefined && address
985
+ // > end) break` and documented as "an instruction starting past `end` is
986
+ // dropped ... an instruction starting AT OR BEFORE `end` is emitted in
987
+ // full" -- an INCLUSIVE bound. Passing the exclusive end therefore
988
+ // permitted one instruction more than intended.
989
+ //
990
+ // It was INERT, and that is exactly why it needed fixing rather than
991
+ // leaving: `slice` is exactly the block's bytes and `decode()`'s own
992
+ // `offset < bytes.length` loop condition bounds it first, so the `end`
993
+ // guard was doing nothing at all. The next maintainer who passes a WIDER
994
+ // slice -- to give `decode()` lookahead across a block boundary, say --
995
+ // inherits a silent one-instruction overrun with no test to catch it.
996
+ //
997
+ // THE SLICE IS THE AUTHORITY AND `end` IS THE BELT-AND-BRACES SECOND
998
+ // BOUND, stated here so the two are not read as one mechanism. Both now
999
+ // describe the same last byte, `block.endExclusive - 1`.
1000
+ const instructions = decode(slice, block.start, { end: block.endExclusive - 1 });
1001
+ for (const instr of instructions) {
1002
+ if (!instr.acmeExpressible) unexpressibleCount++;
1003
+
1004
+ // A store label whose address falls STRICTLY INSIDE this instruction
1005
+ // names one of its operand bytes -- a self-modifying-code write target.
1006
+ // It is emitted as `name =*+$NN` on its own line IMMEDIATELY BEFORE the
1007
+ // instruction that owns the byte, and NEVER after it.
1008
+ //
1009
+ // PLACEMENT IS LOAD-BEARING AND WAS MEASURED IN BOTH DIRECTIONS ON ACME
1010
+ // 0.97. With `smc_operand =*+$01` above `lda #$00` at $0801, a later
1011
+ // `sta smc_operand` assembles as `8d 02 08` -- $0802, the `lda`'s own
1012
+ // operand byte. Move the same line BELOW its host and the symbol takes
1013
+ // the value of the NEXT instruction's operand ($0804), producing
1014
+ // `8d 04 08`. ACME exits 0 in BOTH cases and prints nothing to
1015
+ // distinguish them: only a byte-diff tells the two apart, which is why
1016
+ // `anno-export-asm.test.ts` carries that move as a negative control
1017
+ // rather than trusting an exit status.
1018
+ for (const label of sortedLabels) {
1019
+ if (label.address <= instr.address || label.address >= instr.address + instr.bytes.length) continue;
1020
+
1021
+ // A mid-instruction label below $0100 is REFUSED. This is the ONE
1022
+ // hole `disasm-renderer.ts`'s `+2` width force does not already close
1023
+ // FOR THIS MODULE, in the precise sense that it is the one place this
1024
+ // module has no mitigation of its own and depends entirely on the
1025
+ // renderer's.
1026
+ //
1027
+ // For an ORDINARY label this module owns the mitigation: it writes
1028
+ // the header definition with TWO hex digits below $0100 (see
1029
+ // `formatSymbolDefinition()`), which is what makes ACME encode the
1030
+ // reference at the original width. A `=*+$NN` label cannot use it --
1031
+ // it is defined INLINE by construction, so its width is decided by
1032
+ // whatever the referencing instruction's own rendering forced.
1033
+ //
1034
+ // MEASURED, ACME 0.97, a label at $0081 named by an earlier
1035
+ // `lda #$00` at $0080:
1036
+ // `inc+2 smc_operand` -> `ee 81 00`, EXIT 0, no diagnostic (correct)
1037
+ // `inc smc_operand` -> `e6 81`, EXIT 0, NO DIAGNOSTIC AT ALL
1038
+ // Two bytes where the original was three, silently, with the whole
1039
+ // rest of the block shifted. (The same reference placed BEFORE the
1040
+ // definition widens instead, and does at least emit
1041
+ // `Warning (Zone <untitled>): Using oversized addressing mode.` --
1042
+ // still exit 0.) The only thing standing between this exporter and
1043
+ // that shift is a `disasm-renderer.ts` invariant this module does not
1044
+ // own, so the case is refused BY NAME rather than emitted and hoped
1045
+ // for.
1046
+ if (label.address < MID_INSTRUCTION_LABEL_FLOOR) {
1047
+ throw new Error(
1048
+ `exportAsm: label ${JSON.stringify(label.name)} names address ${hex4(label.address)} inside an instruction, and a ` +
1049
+ `mid-instruction label below ${hex4(MID_INSTRUCTION_LABEL_FLOOR)} cannot be emitted -- it must be defined INLINE, ` +
1050
+ `relative to the program counter at its host instruction, which forgoes this exporter's own two-hex-digit ` +
1051
+ `header-definition width rule, and a reference to it ` +
1052
+ `then encodes at whatever width the renderer forced. Measured on ACME 0.97: the unforced form shrinks a three-byte ` +
1053
+ `absolute instruction to a two-byte zeropage one at exit 0 with NO diagnostic, shifting every byte after it. ` +
1054
+ `REFUSED rather than emitted.`,
1055
+ );
1056
+ }
1057
+
1058
+ midInstructionLabelAddresses.add(label.address);
1059
+ content.push(markIfAliased(label.address, markIfAutoNamed(label.name, midInstructionLabelLine(label.name, label.address - instr.address))));
1060
+ midInstructionLabelCount++;
1061
+ block.lineCount++;
1062
+ }
1063
+
1064
+ let rendered = renderLine(instr, { showSymbols: true, symbolFor });
1065
+
1066
+ // ENUM SUBSTITUTION, IMMEDIATE OPERAND ONLY.
1067
+ const usage = usageByAddress.get(instr.address);
1068
+ if (usage !== undefined) {
1069
+ const role = instr.operand?.role;
1070
+ if (role !== "immediate") {
1071
+ throw new Error(
1072
+ `exportAsm: enum ${JSON.stringify(usage.enumName)} is bound to ${hex4(usage.address)}, whose operand role is ` +
1073
+ `${JSON.stringify(role ?? "none")} -- an enum renders on the IMMEDIATE operand only. Emitting it on any other operand ` +
1074
+ `changes both the bytes and the instruction length while ACME exits 0 (measured on ACME 0.97: \`sta\` on a symbol below ` +
1075
+ `$0100 encodes as zeropage, 2 bytes instead of 3). REFUSED rather than rendered.`,
1076
+ );
1077
+ }
1078
+
1079
+ // ROLE IS NOT ENOUGH: THE OPERAND MUST ALSO BE ASSEMBLER-VISIBLE
1080
+ // (30-REVIEW CR-02, fixed 2026-08-31). `decode()` assigns
1081
+ // `role: "immediate"` from the ADDRESSING MODE alone, independently
1082
+ // of `acmeExpressible`. Six opcodes in `disasm-opcodes.ts` are
1083
+ // `mode: "immediate"` AND `acmeExpressible: false` -- $2b (`anc`),
1084
+ // $82/$89/$c2/$e2 (`nop #imm`) and $eb (`sbc #imm`). For those,
1085
+ // `renderLine()` emits a `!byte` DIRECTIVE and moves the mnemonic
1086
+ // and its `#$xx` operand into the trailing comment
1087
+ // (`disasm-renderer.ts`'s `!instr.acmeExpressible` branch), so the
1088
+ // substitution below reached the COMMENT and never the assembler:
1089
+ // the operand stayed a raw byte in the `!byte` list, an unreferenced
1090
+ // `viccolor_BLACK = $00` was emitted into the header, the usage was
1091
+ // counted as applied, and the CLI printed "1 enum substitution(s)"
1092
+ // and exited 0. The bytes stay correct, so the byte-diff oracle
1093
+ // cannot see it either -- a round-trip test goes green on it.
1094
+ //
1095
+ // Reproduced against the committed code, store: one `code` range
1096
+ // $0801..$0803 over `eb 00 60`, enum `viccolor { $00: BLACK }`
1097
+ // applied at $0801 through the ordinary public `applyEnumUsage()`
1098
+ // route (which performs no opcode validation, so this needs no
1099
+ // hand-edited store):
1100
+ //
1101
+ // !byte $eb, $00 ; sbc #viccolor_BLACK [illegal opcode | ...]
1102
+ // === enumSubstitutionCount: 1
1103
+ //
1104
+ // This is D-30's "an annotation the exporter cannot express is
1105
+ // REFUSED loudly and by name, never silently dropped while the
1106
+ // export reports success" exactly inverted. It is refused now.
1107
+ if (!instr.acmeExpressible) {
1108
+ throw new Error(
1109
+ `exportAsm: enum ${JSON.stringify(usage.enumName)} is bound to the immediate operand at ${hex4(usage.address)}, but that ` +
1110
+ `opcode (${hex2(instr.bytes[0])}, ${instr.mnemonic}) is NOT EXPRESSIBLE in ACME's !cpu 6510 dialect. An unexpressible ` +
1111
+ `opcode goes out as a \`!byte\` directive with its mnemonic and operand in a TRAILING COMMENT, so an enum symbol ` +
1112
+ `substituted there would reach the comment and never the assembler -- the operand would stay a raw byte while this ` +
1113
+ `export reported the substitution as applied. REFUSED rather than counted as applied.`,
1114
+ );
1115
+ }
1116
+
1117
+ const project = enumsByName.get(usage.enumName);
1118
+ if (project === undefined) {
1119
+ // Unreachable through `applyEnumUsage()`, which resolves the enum
1120
+ // inside its own transaction, and reachable through a store file
1121
+ // somebody edited. Refusing beats emitting an operand with no
1122
+ // vocabulary behind it.
1123
+ throw new Error(
1124
+ `exportAsm: the enum usage at ${hex4(usage.address)} names enum ${JSON.stringify(usage.enumName)}, which the store holds ` +
1125
+ `no definition for. Refusing to emit an operand whose vocabulary is missing.`,
1126
+ );
1127
+ }
1128
+
1129
+ // EVERY variant of the enum is checked, not only the one this operand
1130
+ // matched. An enum carrying a variant above $ff is not a BYTE
1131
+ // vocabulary, and binding it to a byte operand is a modelling error
1132
+ // whose only symptom would otherwise be a variant that silently never
1133
+ // renders. Refusing here names the enum and the variant; ACME's own
1134
+ // refusal for the same shape is `Number does not fit in 8 bits.` at
1135
+ // exit 1, and names a line in a temp file instead.
1136
+ let matched: string | undefined;
1137
+ for (const [key, variantName] of Object.entries(project.variants)) {
1138
+ const value = parseVariantKey(key);
1139
+ if (value > MAX_IMMEDIATE_VARIANT_VALUE) {
1140
+ throw new Error(
1141
+ `exportAsm: enum ${JSON.stringify(usage.enumName)} is bound to the immediate operand at ${hex4(usage.address)}, but its ` +
1142
+ `variant ${JSON.stringify(variantName)} has the value ${value}, above ` +
1143
+ `${MAX_IMMEDIATE_VARIANT_VALUE} -- an immediate operand is ONE byte, so this enum is not a byte vocabulary. ` +
1144
+ `Real ACME refuses the same shape with "Number does not fit in 8 bits." and exit 1; this refusal happens first so it ` +
1145
+ `can name the enum and the variant rather than a temp-file line number.`,
1146
+ );
1147
+ }
1148
+ if (value === instr.operand!.value) matched = variantName;
1149
+ }
1150
+
1151
+ if (matched === undefined) {
1152
+ throw new Error(
1153
+ `exportAsm: enum ${JSON.stringify(usage.enumName)} is bound to the immediate operand at ${hex4(usage.address)}, whose ` +
1154
+ `value is ${hex2(instr.operand!.value)}, and the enum has no variant for that value. Refusing to emit the hex literal ` +
1155
+ `while reporting an enum substitution that did not happen.`,
1156
+ );
1157
+ }
1158
+
1159
+ const symbol = `${usage.enumName}_${matched}`;
1160
+ // REJECT, never sanitise -- the same contract every other name this
1161
+ // module emits passes through, applied to the COMPOSED name because
1162
+ // that is what actually reaches the ACME source.
1163
+ assertLegalAcmeIdentifier(symbol, `exportAsm: enum variant symbol for ${hex4(usage.address)}`);
1164
+
1165
+ // THE COLLISION THE COMMENT BELOW NAMES IS NOW CHECKED FOR
1166
+ // (30-REVIEW WR-10, fixed 2026-08-31). That comment identified the
1167
+ // hazard exactly -- "every extra emitted symbol is one more chance to
1168
+ // collide with a label name and turn a correct export into ACME's
1169
+ // `Symbol already defined.`" -- and then did not look.
1170
+ // `definedEnumSymbols` dedupes enum symbols against EACH OTHER but
1171
+ // never against the store's labels.
1172
+ //
1173
+ // Since the `anno export-asm` CLI verb runs no assembler, the
1174
+ // collision produced a file that exited 0 here and failed wherever
1175
+ // the user actually assembled it, with no pointer back to the store
1176
+ // row that caused it. Refusing here names BOTH the enum and the
1177
+ // label, which is what makes it fixable.
1178
+ //
1179
+ // Checked against `labelSymbolNames` -- every store label's name,
1180
+ // whether it ends up defined in the header or inline -- because ACME
1181
+ // has ONE symbol namespace and an inline `=*+$NN` definition
1182
+ // collides exactly as a header one does.
1183
+ if (labelSymbolNames.has(symbol)) {
1184
+ throw new Error(
1185
+ `exportAsm: the enum variant symbol ${JSON.stringify(symbol)} (enum ${JSON.stringify(usage.enumName)}, variant ` +
1186
+ `${JSON.stringify(matched)}, bound at ${hex4(usage.address)}) is ALSO the name of a store label. ACME has one symbol ` +
1187
+ `namespace, so emitting both definitions is \`Symbol already defined.\` and exit 1 -- and this verb assembles ` +
1188
+ `nothing, so without this refusal the export would exit 0 here and fail wherever you assembled it, with no pointer ` +
1189
+ `back to the rows that caused it. REFUSED -- rename the label or the enum variant.`,
1190
+ );
1191
+ }
1192
+
1193
+ // ONLY THE MATCHED VARIANT IS DEFINED, not the whole vocabulary. A
1194
+ // definition the source never references is clutter a human reader
1195
+ // has to discount, and every extra emitted symbol is one more chance
1196
+ // to collide with a label name and turn a correct export into ACME's
1197
+ // `Symbol already defined.`
1198
+ if (!definedEnumSymbols.has(symbol)) {
1199
+ definedEnumSymbols.add(symbol);
1200
+ enumDefinitionLines.push(formatSymbolDefinition(symbol, instr.operand!.value));
1201
+ }
1202
+ rendered = substituteImmediateEnum(rendered, instr.operand!.value, symbol, instr.address);
1203
+ appliedEnumUsage.add(usage.id);
1204
+ }
1205
+
1206
+ // The span is the instruction's FIRST address only, not its whole
1207
+ // length: a comment stored against an operand byte belongs to no
1208
+ // emitted line, and attaching it to the instruction that happens to
1209
+ // contain that byte would move a human's note onto a different address
1210
+ // than the one they chose. It stays unplaced and is refused below.
1211
+ const emitted = withComments(rendered, instr.address, instr.address + 1, placement);
1212
+ content.push(...emitted);
1213
+ block.lineCount += emitted.length;
1214
+ }
1215
+ } else {
1216
+ // The `dataType` reaching `emitDataLines()` is the store's own string,
1217
+ // copied off the row and passed through -- this module never branches on
1218
+ // it beyond the code/not-code test above and the `!word` eligibility
1219
+ // check inside the emitter.
1220
+ for (const dataLine of emitDataLines(slice, block.dataType, block.start)) {
1221
+ const emitted = withComments(dataLine.text, dataLine.start, dataLine.endExclusive, placement);
1222
+ content.push(...emitted);
1223
+ block.lineCount += emitted.length;
1224
+ }
1225
+ dataByteCount += slice.length;
1226
+ }
1227
+
1228
+ // EVERY block goes through `emitBlock()`, code and data alike, so there is
1229
+ // exactly one place that brackets a block and no route that emits an
1230
+ // unbracketed one.
1231
+ blockLines.push(...emitBlock(block.start, block.endExclusive, content));
1232
+ }
1233
+
1234
+ // EVERY store label is defined here, in a block BEFORE the first `* =`, not
1235
+ // only the ones a substitution happened to use -- EXCEPT the mid-instruction
1236
+ // ones, which the loop above already defined inline and which ACME would
1237
+ // refuse as `Symbol already defined.` if they appeared twice.
1238
+ //
1239
+ // Measured on ACME 0.97: a symbol defined AFTER its first reference widens
1240
+ // the referencing instruction from zeropage to absolute -- `a5 10` becomes
1241
+ // `ad 10 00`, three bytes where the original was two -- and it does so with
1242
+ // the WARNING `Using oversized addressing mode.` and exit status 0.
1243
+ // Everything after it shifts. Defining first is the mitigation; the per-block
1244
+ // `*` assertions are the backstop for a future change that ever drops this
1245
+ // block, and the byte-diff is what settles the whole claim.
1246
+ //
1247
+ // THIS BLOCK IS BUILT AFTER THE BLOCK LOOP AND EMITTED BEFORE IT. Which
1248
+ // labels are defined inline is only knowable once the code blocks have been
1249
+ // decoded, and the header must not restate those; the assembled order below
1250
+ // is what the source actually carries.
1251
+ const headerLines: string[] = [...enumDefinitionLines];
1252
+ for (const label of sortedLabels) {
1253
+ if (midInstructionLabelAddresses.has(label.address)) continue;
1254
+ headerLines.push(markIfAliased(label.address, markIfAutoNamed(label.name, formatSymbolDefinition(label.name, label.address))));
1255
+ }
1256
+
1257
+ // An enum usage this export never reached is REFUSED BY NAME, for the reason
1258
+ // an unplaceable comment is: its address is inside an instruction rather than
1259
+ // at its start, or is not covered by any CODE range, and in both cases the
1260
+ // honest answer is that this export does not carry it, said out loud.
1261
+ if (appliedEnumUsage.size !== enumUsage.length) {
1262
+ const unapplied = enumUsage.filter((row) => !appliedEnumUsage.has(row.id));
1263
+ const first = unapplied[0]!;
1264
+ throw new Error(
1265
+ `exportAsm: the enum usage at ${hex4(first.address)} (enum ${JSON.stringify(first.enumName)}) has no decoded instruction to ` +
1266
+ `attach to -- that address is inside an instruction rather than at its start, or is not covered by any \`code\` range. ` +
1267
+ `${unapplied.length} of ${enumUsage.length} enum usage(s) are in this state. Refusing to export while silently dropping them.`,
1268
+ );
1269
+ }
1270
+
1271
+ const lines: string[] = ["!cpu 6510", ...headerLines, ...blockLines];
1272
+
1273
+ // An annotation this exporter cannot express is REFUSED BY NAME, never
1274
+ // dropped from the output while the export reports success. A comment is
1275
+ // unplaceable when its address is inside an instruction rather than at its
1276
+ // start, or outside every annotated range -- and in both cases the honest
1277
+ // answer is that this export does not carry it, said out loud.
1278
+ if (placement.placed.size !== comments.length) {
1279
+ const unplaced = comments.filter((row) => !placement.placed.has(row.id));
1280
+ const first = unplaced[0]!;
1281
+ throw new Error(
1282
+ `exportAsm: the ${first.commentType} comment at ${hex4(first.address)} has no emitted line to attach to -- that address is inside an ` +
1283
+ `instruction rather than at its start, or is not covered by any annotated range. ` +
1284
+ `${unplaced.length} of ${comments.length} comment(s) are in this state. Refusing to export while silently dropping them.`,
1285
+ );
1286
+ }
1287
+
1288
+ // `expectedBytes` is built from the IMAGE, never from `lines`. Gaps between
1289
+ // blocks stay `$00`, matching ACME `-f plain`'s measured zero-fill.
1290
+ const minStart = blocks[0]!.start;
1291
+ const maxEndExclusive = blocks.reduce((acc, b) => Math.max(acc, b.endExclusive), blocks[0]!.endExclusive);
1292
+ const expectedBytes = new Uint8Array(maxEndExclusive - minStart);
1293
+ for (const block of blocks) {
1294
+ expectedBytes.set(image.bytes.subarray(block.start - imageStart, block.endExclusive - imageStart), block.start - minStart);
1295
+ }
1296
+
1297
+ return {
1298
+ source: `${lines.join("\n")}\n`,
1299
+ expectedBytes,
1300
+ blocks,
1301
+ symbolCount: sortedLabels.length,
1302
+ headerDefinitionCount: headerLines.length,
1303
+ unexpressibleCount,
1304
+ dataByteCount,
1305
+ commentCount: placement.placed.size,
1306
+ midInstructionLabelCount,
1307
+ autoNamedSymbolCount,
1308
+ enumSubstitutionCount: appliedEnumUsage.size,
1309
+ };
1310
+ }