@henols/vice-mcp 0.2.2 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/THIRD-PARTY-NOTICES.md +422 -1
- package/anno-bank.ts +171 -0
- package/anno-cli.ts +1674 -99
- package/anno-enum-gen.ts +416 -30
- package/anno-export-asm.ts +1175 -89
- package/anno-graphics.ts +338 -0
- package/anno-hazard-report.ts +1367 -0
- package/anno-import.ts +495 -0
- package/anno-join.ts +480 -0
- package/anno-provenance-ledger.ts +472 -0
- package/anno-register.ts +159 -0
- package/anno-store-export.ts +661 -0
- package/anno-store.ts +518 -2
- package/anno-tools.ts +1169 -16
- package/anno-types.ts +275 -2
- package/backend-detect.mts +124 -312
- package/build.ts +3 -1
- package/capture-predicate.ts +597 -0
- package/channel-lock.ts +349 -0
- package/evid-ingest.ts +217 -0
- package/evid-reconcile.ts +316 -0
- package/host-tool-client.ts +430 -0
- package/incident-record.ts +23 -12
- package/install-resources.ts +29 -13
- package/memmap-lookup.ts +285 -0
- package/package.json +27 -8
- package/prg-image.ts +1 -2
- package/repo-root.ts +87 -3
- package/resources/backend-detect.mjs +98 -236
- package/resources/broker-control.mjs +189 -16
- package/resources/broker-epoch.mjs +1 -1
- package/resources/broker-kill.mjs +8 -2
- package/resources/broker-launch.mjs +365 -210
- package/resources/broker-state.mjs +64 -18
- package/resources/container-guard.mjs +1 -1
- package/resources/ghidra-project.mjs +790 -0
- package/resources/host-tool.mjs +2561 -0
- package/resources/vice-broker.mjs +330 -184
- package/resources/vice-launcher.sh +127 -9
- package/stock-address.ts +1 -1
- package/stock-condition.ts +1 -1
- package/stock-connect.ts +9 -5
- package/stock-derived.ts +29 -37
- package/stock-diagnose.ts +200 -36
- package/stock-dispatch.ts +179 -77
- package/stock-handler.ts +1 -1
- package/stock-paths.ts +18 -14
- package/stock-petscii.ts +1 -1
- package/stock-protocol.ts +1 -1
- package/stock-recycle.ts +83 -2
- package/stock-reproducible-run.ts +811 -0
- package/stock-run-until.ts +100 -1
- package/stock-symbols.ts +4 -4
- package/stock-timing.ts +1 -1
- package/stop-oracle.ts +167 -0
- package/text-capability-probe.ts +660 -0
- package/text-connect.ts +157 -0
- package/text-protocol.ts +810 -0
- package/text-tools.ts +778 -0
- package/textmon-backtrace.ts +385 -0
- package/textmon-cpuhistory.ts +335 -0
- package/textmon-memmap.ts +494 -0
- package/textmon-profile.ts +458 -0
- package/textmon-registers.ts +748 -0
- package/tools-manifest.stock.json +864 -3
- package/vice-broker-client.ts +189 -42
- package/vice-errors.ts +268 -0
- package/vice-proxy.ts +339 -2144
- package/vsf-slice.ts +640 -0
- package/anno-d64.ts +0 -310
- package/capability-registry.ts +0 -390
- package/refresh-manifest.ts +0 -124
- package/tools-manifest.json +0 -1223
- package/vice-probe.ts +0 -278
- package/vice-sync.ts +0 -336
- package/vice.ts +0 -772
package/anno-export-asm.ts
CHANGED
|
@@ -53,13 +53,17 @@
|
|
|
53
53
|
// so the substitution changes both the bytes and the instruction length. A
|
|
54
54
|
// non-immediate operand is REFUSED by name below, never rendered and hoped
|
|
55
55
|
// for.
|
|
56
|
-
// - Never compare a `dataType` string in this module beyond the
|
|
57
|
-
// that already do, each of which says so in its own comment:
|
|
58
|
-
// `CODE_DATA_TYPE`'s decoder-or-dump branch,
|
|
59
|
-
// `!word` eligibility check
|
|
60
|
-
// `
|
|
61
|
-
//
|
|
62
|
-
//
|
|
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.
|
|
63
67
|
// - Never import this tree's host/container path-translation modules
|
|
64
68
|
// (`hostpath.ts` / `containerpath.ts`). Their consumer set is a closed,
|
|
65
69
|
// mechanically asserted list of named modules and an exporter has no reason
|
|
@@ -87,12 +91,12 @@
|
|
|
87
91
|
// SCOPE, STILL DELIBERATELY NARROW: code ranges, the twelve typed data ranges,
|
|
88
92
|
// comments, mid-instruction inline labels and immediate-operand enum
|
|
89
93
|
// substitution.
|
|
90
|
-
import { readFileSync } from "node:fs";
|
|
91
|
-
import { extname } from "node:path";
|
|
94
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
95
|
+
import { extname, join } from "node:path";
|
|
92
96
|
|
|
93
|
-
import { openStore, closeStore, listRanges, listLabels, listComments, listProjectEnums, listEnumUsage } from "./anno-store.ts";
|
|
94
|
-
import { AnnoCommentError, COMMENT_TYPES, DATA_TYPES, assertCommentText, assertDataType, parseVariantKey } from "./anno-types.ts";
|
|
95
|
-
import type { CommentRow, DataType, EnumUsageRow, LabelRow, ProjectEnumRow, RangeRow } from "./anno-types.ts";
|
|
97
|
+
import { openStore, closeStore, listRanges, listLabels, listComments, listProjectEnums, listEnumUsage, listExcludedRanges, listScopes } from "./anno-store.ts";
|
|
98
|
+
import { AnnoCommentError, COMMENT_TYPES, DATA_TYPES, assertCommentText, assertDataType, isSplitDataType, parseVariantKey } from "./anno-types.ts";
|
|
99
|
+
import type { CommentRow, DataType, EnumUsageRow, ExcludedRangeRow, LabelRow, ProjectEnumRow, RangeRow, ScopeRow } from "./anno-types.ts";
|
|
96
100
|
import { assertLegalAcmeIdentifier } from "./anno-acme-ident.ts";
|
|
97
101
|
// The eleven typed auto-name prefixes, IMPORTED FROM THEIR ONE HOME rather than
|
|
98
102
|
// restated. This is the first cross-module PRODUCTION importer of that
|
|
@@ -113,9 +117,22 @@ import { assertLegalAcmeIdentifier } from "./anno-acme-ident.ts";
|
|
|
113
117
|
// `anno-types.ts:93-99` forbids it by name, and `EXPORT-02` names the failure
|
|
114
118
|
// mode: a five-prefix copy under-counts silently.
|
|
115
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";
|
|
116
127
|
import { decode } from "./disasm-decoder.ts";
|
|
128
|
+
import type { Instruction } from "./disasm-decoder.ts";
|
|
117
129
|
import { renderLine } from "./disasm-renderer.ts";
|
|
118
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";
|
|
119
136
|
|
|
120
137
|
/** The store's own spelling for an executable range, read out of the one home
|
|
121
138
|
* of that vocabulary rather than re-typed as a literal. `dataType` is never
|
|
@@ -124,6 +141,18 @@ import { parsePrg, flatImageOrigin } from "./prg-image.ts";
|
|
|
124
141
|
* a decoder or dump bytes, and it is the only one. */
|
|
125
142
|
const CODE_DATA_TYPE = "code";
|
|
126
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
|
+
|
|
127
156
|
/** One block as this module emitted it. */
|
|
128
157
|
export interface ExportBlock {
|
|
129
158
|
/** First address the block covers. */
|
|
@@ -138,6 +167,39 @@ export interface ExportBlock {
|
|
|
138
167
|
/** How many CONTENT lines the block emitted -- not its `* =` origin line and
|
|
139
168
|
* not the two `!if * != ...` assertions that bracket it. */
|
|
140
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;
|
|
141
203
|
}
|
|
142
204
|
|
|
143
205
|
export interface ExportAsmOptions {
|
|
@@ -161,11 +223,36 @@ export interface ExportAsmOptions {
|
|
|
161
223
|
* caller's argument pointed -- and an export of a store this call just
|
|
162
224
|
* invented would read as "the program has no annotations". */
|
|
163
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;
|
|
164
238
|
}
|
|
165
239
|
|
|
166
240
|
export interface ExportAsmResult {
|
|
167
241
|
/** The ACME source text. */
|
|
168
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[];
|
|
169
256
|
/** The bytes `source` must assemble to, DERIVED FROM THE IMAGE and never
|
|
170
257
|
* from `source`. Spans `[minStart, maxEndExclusive)` across every emitted
|
|
171
258
|
* block, with `$00` filling the gaps between them -- which is exactly what
|
|
@@ -206,6 +293,15 @@ export interface ExportAsmResult {
|
|
|
206
293
|
* block whose `dataType` is not `code`. A code block contributes nothing
|
|
207
294
|
* here, however many bytes it decoded. */
|
|
208
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[];
|
|
209
305
|
/** How many store comments the source carries. Always the store's FULL
|
|
210
306
|
* comment count when this function returns: a comment with no emitted line
|
|
211
307
|
* to attach to is refused by name rather than left out of this number. */
|
|
@@ -233,8 +329,30 @@ export interface ExportAsmResult {
|
|
|
233
329
|
autoNamedSymbolCount: number;
|
|
234
330
|
/** How many instruction operands were rendered through a project enum's
|
|
235
331
|
* variant name instead of a hex literal. Every one of them is an IMMEDIATE
|
|
236
|
-
* operand; any other operand role is refused.
|
|
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). */
|
|
237
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;
|
|
238
356
|
}
|
|
239
357
|
|
|
240
358
|
/** How many raw bytes go on one `!byte` line for a non-code block. */
|
|
@@ -257,6 +375,26 @@ const WORDS_PER_DATA_LINE = 8;
|
|
|
257
375
|
*/
|
|
258
376
|
const WORD_PAIR_DATA_TYPES: readonly string[] = Object.freeze(["word", "address"]);
|
|
259
377
|
|
|
378
|
+
/**
|
|
379
|
+
* 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
|
+
|
|
260
398
|
/** One emitted data line, with the address span it covers. The span is what
|
|
261
399
|
* lets a stored comment find its line: a `!byte` line covers up to sixteen
|
|
262
400
|
* addresses, and a comment on any of them belongs to that line. */
|
|
@@ -287,6 +425,18 @@ interface DataLine {
|
|
|
287
425
|
* table is NOT invented here; that would be the same drift hazard wearing a
|
|
288
426
|
* local name.
|
|
289
427
|
*
|
|
428
|
+
* `external_file` (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
|
+
*
|
|
290
440
|
* OVERLAP: `--strict-segments` is in the verify argv, which promotes ACME's
|
|
291
441
|
* "Segment starts inside another one, overwriting it." from a Warning to an
|
|
292
442
|
* Error (measured, exit 1). Without it a store holding two overlapping ranges
|
|
@@ -294,6 +444,16 @@ interface DataLine {
|
|
|
294
444
|
* against whichever won.
|
|
295
445
|
*/
|
|
296
446
|
function emitDataLines(slice: Uint8Array, dataType: string, blockStart: number): DataLine[] {
|
|
447
|
+
if (dataType === EXTERNAL_FILE_DATA_TYPE) {
|
|
448
|
+
return [
|
|
449
|
+
{
|
|
450
|
+
text: `${INDENT}!binary "${binaryFileName(blockStart)}" ; ${dataType}`,
|
|
451
|
+
start: blockStart,
|
|
452
|
+
endExclusive: blockStart + slice.length,
|
|
453
|
+
},
|
|
454
|
+
];
|
|
455
|
+
}
|
|
456
|
+
|
|
297
457
|
const out: DataLine[] = [];
|
|
298
458
|
|
|
299
459
|
if (WORD_PAIR_DATA_TYPES.includes(dataType) && slice.length % 2 === 0) {
|
|
@@ -326,6 +486,143 @@ function emitDataLines(slice: Uint8Array, dataType: string, blockStart: number):
|
|
|
326
486
|
return out;
|
|
327
487
|
}
|
|
328
488
|
|
|
489
|
+
/** 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
|
+
|
|
329
626
|
/** ACME source indent for directive lines, matching `disasm-renderer.ts`'s own
|
|
330
627
|
* cosmetic indent so the two emitters' output reads as one document. */
|
|
331
628
|
const INDENT = " ";
|
|
@@ -524,6 +821,57 @@ const AUTO_NAME_MARKER = " ; auto-generated name -- still in the annotation bac
|
|
|
524
821
|
*/
|
|
525
822
|
const ALIAS_MARKER_PREFIX = " ; ALIAS: this address also carries ";
|
|
526
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
|
+
|
|
527
875
|
/**
|
|
528
876
|
* The largest value an enum variant may carry to be substitutable into an
|
|
529
877
|
* IMMEDIATE operand.
|
|
@@ -535,6 +883,29 @@ const ALIAS_MARKER_PREFIX = " ; ALIAS: this address also carries ";
|
|
|
535
883
|
*/
|
|
536
884
|
const MAX_IMMEDIATE_VARIANT_VALUE = 0xff;
|
|
537
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
|
+
|
|
538
909
|
/**
|
|
539
910
|
* Replaces the `#$XX` immediate literal `renderLine()` produced with `#symbol`.
|
|
540
911
|
*
|
|
@@ -714,10 +1085,22 @@ interface CommentPlacement {
|
|
|
714
1085
|
* GATED ON AMBIGUITY, not applied always: prefixing every code-path comment
|
|
715
1086
|
* with an address it already sits next to is noise, and it would rewrite every
|
|
716
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.
|
|
717
1099
|
*/
|
|
718
|
-
function withComments(text: string, start: number, endExclusive: number, ctx: CommentPlacement): string[] {
|
|
1100
|
+
function withComments(text: string, start: number, endExclusive: number, ctx: CommentPlacement, generatedSuffix?: string): string[] {
|
|
719
1101
|
const before: string[] = [];
|
|
720
1102
|
let line = text;
|
|
1103
|
+
let generatedMerged = false;
|
|
721
1104
|
|
|
722
1105
|
// One emitted line covering more than one address cannot say WHICH address a
|
|
723
1106
|
// comment belongs to unless the comment says so itself.
|
|
@@ -731,7 +1114,12 @@ function withComments(text: string, start: number, endExclusive: number, ctx: Co
|
|
|
731
1114
|
if (row.commentType === LINE_COMMENT) {
|
|
732
1115
|
before.push(`${INDENT}; ${safe}`);
|
|
733
1116
|
} else if (row.commentType === SIDE_COMMENT) {
|
|
734
|
-
|
|
1117
|
+
if (generatedSuffix !== undefined && !generatedMerged) {
|
|
1118
|
+
line = `${line} ; ${safe} -- ${generatedSuffix}`;
|
|
1119
|
+
generatedMerged = true;
|
|
1120
|
+
} else {
|
|
1121
|
+
line = `${line} ; ${safe}`;
|
|
1122
|
+
}
|
|
735
1123
|
} else {
|
|
736
1124
|
// Unreachable through the type, and reachable through a store file
|
|
737
1125
|
// somebody edited. Refusing beats guessing which of the two placements
|
|
@@ -744,9 +1132,58 @@ function withComments(text: string, start: number, endExclusive: number, ctx: Co
|
|
|
744
1132
|
}
|
|
745
1133
|
}
|
|
746
1134
|
|
|
1135
|
+
if (generatedSuffix !== undefined && !generatedMerged) {
|
|
1136
|
+
line = `${line} ; ${generatedSuffix}`;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
747
1139
|
return [...before, line];
|
|
748
1140
|
}
|
|
749
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
|
+
|
|
750
1187
|
/**
|
|
751
1188
|
* Exports the annotation store at `options.storePath`, over the image at
|
|
752
1189
|
* `options.imagePath`, as ACME source plus the exact bytes that source must
|
|
@@ -762,7 +1199,7 @@ function withComments(text: string, start: number, endExclusive: number, ctx: Co
|
|
|
762
1199
|
* addresses and counts only.
|
|
763
1200
|
*/
|
|
764
1201
|
export function exportAsm(options: ExportAsmOptions): ExportAsmResult {
|
|
765
|
-
const { storePath, imagePath, workspaceRoot } = options;
|
|
1202
|
+
const { storePath, imagePath, workspaceRoot, ledgerPath } = options;
|
|
766
1203
|
|
|
767
1204
|
const image = loadImage(imagePath);
|
|
768
1205
|
|
|
@@ -777,12 +1214,24 @@ export function exportAsm(options: ExportAsmOptions): ExportAsmResult {
|
|
|
777
1214
|
let comments: CommentRow[];
|
|
778
1215
|
let projectEnums: ProjectEnumRow[];
|
|
779
1216
|
let enumUsage: EnumUsageRow[];
|
|
1217
|
+
let excludedRanges: ExcludedRangeRow[];
|
|
1218
|
+
let scopes: ScopeRow[];
|
|
780
1219
|
try {
|
|
781
1220
|
ranges = listRanges(handle);
|
|
782
1221
|
labels = listLabels(handle);
|
|
783
1222
|
comments = listComments(handle);
|
|
784
1223
|
projectEnums = listProjectEnums(handle);
|
|
785
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);
|
|
786
1235
|
} finally {
|
|
787
1236
|
closeStore(handle);
|
|
788
1237
|
}
|
|
@@ -827,6 +1276,9 @@ export function exportAsm(options: ExportAsmOptions): ExportAsmResult {
|
|
|
827
1276
|
// from it.
|
|
828
1277
|
dataType: assertDataTypeForExport(row) as string,
|
|
829
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: [],
|
|
830
1282
|
}));
|
|
831
1283
|
|
|
832
1284
|
const imageStart = image.origin;
|
|
@@ -841,6 +1293,14 @@ export function exportAsm(options: ExportAsmOptions): ExportAsmResult {
|
|
|
841
1293
|
}
|
|
842
1294
|
}
|
|
843
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
|
+
|
|
844
1304
|
// The label index the renderer's `symbolFor` hook reads. Every name is
|
|
845
1305
|
// validated BEFORE it can reach the source text -- REJECT, never sanitise.
|
|
846
1306
|
const sortedLabels = [...labels].sort((a, b) => a.address - b.address);
|
|
@@ -918,10 +1378,37 @@ export function exportAsm(options: ExportAsmOptions): ExportAsmResult {
|
|
|
918
1378
|
/** `<enumName>_<VARIANT> = $XX` definition lines, in first-emitted order.
|
|
919
1379
|
* They join the header block for the same reason label definitions do. */
|
|
920
1380
|
const enumDefinitionLines: string[] = [];
|
|
921
|
-
|
|
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>();
|
|
922
1388
|
|
|
923
1389
|
let unexpressibleCount = 0;
|
|
924
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;
|
|
925
1412
|
|
|
926
1413
|
// AUTO-GENERATED NAMES ARE MARKED, not filtered. Every store label reaches
|
|
927
1414
|
// the source either way; the marker is the backlog signal, carried into the
|
|
@@ -971,6 +1458,12 @@ export function exportAsm(options: ExportAsmOptions): ExportAsmResult {
|
|
|
971
1458
|
|
|
972
1459
|
const blockLines: string[] = [];
|
|
973
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
|
+
|
|
974
1467
|
for (const block of blocks) {
|
|
975
1468
|
const slice = image.bytes.subarray(block.start - imageStart, block.endExclusive - imageStart);
|
|
976
1469
|
const content: string[] = [];
|
|
@@ -1063,6 +1556,34 @@ export function exportAsm(options: ExportAsmOptions): ExportAsmResult {
|
|
|
1063
1556
|
|
|
1064
1557
|
let rendered = renderLine(instr, { showSymbols: true, symbolFor });
|
|
1065
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
|
+
|
|
1066
1587
|
// ENUM SUBSTITUTION, IMMEDIATE OPERAND ONLY.
|
|
1067
1588
|
const usage = usageByAddress.get(instr.address);
|
|
1068
1589
|
if (usage !== undefined) {
|
|
@@ -1126,81 +1647,169 @@ export function exportAsm(options: ExportAsmOptions): ExportAsmResult {
|
|
|
1126
1647
|
);
|
|
1127
1648
|
}
|
|
1128
1649
|
|
|
1129
|
-
//
|
|
1130
|
-
//
|
|
1131
|
-
//
|
|
1132
|
-
//
|
|
1133
|
-
//
|
|
1134
|
-
//
|
|
1135
|
-
//
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
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) {
|
|
1140
1674
|
throw new Error(
|
|
1141
|
-
`exportAsm: enum ${
|
|
1142
|
-
|
|
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.`,
|
|
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)}`,
|
|
1146
1677
|
);
|
|
1147
1678
|
}
|
|
1148
|
-
if (value === instr.operand!.value) matched = variantName;
|
|
1149
1679
|
}
|
|
1150
1680
|
|
|
1151
|
-
if (
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
)
|
|
1157
|
-
|
|
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
|
+
}
|
|
1158
1722
|
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
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
|
+
}
|
|
1192
1766
|
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
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);
|
|
1201
1812
|
}
|
|
1202
|
-
rendered = substituteImmediateEnum(rendered, instr.operand!.value, symbol, instr.address);
|
|
1203
|
-
appliedEnumUsage.add(usage.id);
|
|
1204
1813
|
}
|
|
1205
1814
|
|
|
1206
1815
|
// The span is the instruction's FIRST address only, not its whole
|
|
@@ -1208,27 +1817,173 @@ export function exportAsm(options: ExportAsmOptions): ExportAsmResult {
|
|
|
1208
1817
|
// emitted line, and attaching it to the instruction that happens to
|
|
1209
1818
|
// contain that byte would move a human's note onto a different address
|
|
1210
1819
|
// than the one they chose. It stays unplaced and is refused below.
|
|
1211
|
-
const emitted = withComments(rendered, instr.address, instr.address + 1, placement);
|
|
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);
|
|
1212
1838
|
content.push(...emitted);
|
|
1213
1839
|
block.lineCount += emitted.length;
|
|
1214
1840
|
}
|
|
1841
|
+
dataByteCount += slice.length;
|
|
1215
1842
|
} else {
|
|
1216
1843
|
// The `dataType` reaching `emitDataLines()` is the store's own string,
|
|
1217
1844
|
// copied off the row and passed through -- this module never branches on
|
|
1218
|
-
// it beyond the code/not-code test above
|
|
1219
|
-
// check inside the emitter.
|
|
1845
|
+
// it beyond the code/not-code test above, the `!word` eligibility check,
|
|
1846
|
+
// and the `!binary` check, all three inside the emitter.
|
|
1220
1847
|
for (const dataLine of emitDataLines(slice, block.dataType, block.start)) {
|
|
1221
1848
|
const emitted = withComments(dataLine.text, dataLine.start, dataLine.endExclusive, placement);
|
|
1222
1849
|
content.push(...emitted);
|
|
1223
1850
|
block.lineCount += emitted.length;
|
|
1224
1851
|
}
|
|
1225
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);
|
|
1226
1949
|
}
|
|
1227
1950
|
|
|
1228
1951
|
// EVERY block goes through `emitBlock()`, code and data alike, so there is
|
|
1229
1952
|
// exactly one place that brackets a block and no route that emits an
|
|
1230
|
-
// unbracketed one.
|
|
1231
|
-
blockLines
|
|
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
|
+
);
|
|
1232
1987
|
}
|
|
1233
1988
|
|
|
1234
1989
|
// EVERY store label is defined here, in a block BEFORE the first `* =`, not
|
|
@@ -1296,15 +2051,346 @@ export function exportAsm(options: ExportAsmOptions): ExportAsmResult {
|
|
|
1296
2051
|
|
|
1297
2052
|
return {
|
|
1298
2053
|
source: `${lines.join("\n")}\n`,
|
|
2054
|
+
headerLines,
|
|
2055
|
+
scopes,
|
|
1299
2056
|
expectedBytes,
|
|
1300
2057
|
blocks,
|
|
1301
2058
|
symbolCount: sortedLabels.length,
|
|
1302
2059
|
headerDefinitionCount: headerLines.length,
|
|
1303
2060
|
unexpressibleCount,
|
|
1304
2061
|
dataByteCount,
|
|
2062
|
+
binaries: [...binaries].sort((a, b) => a.start - b.start),
|
|
1305
2063
|
commentCount: placement.placed.size,
|
|
1306
2064
|
midInstructionLabelCount,
|
|
1307
2065
|
autoNamedSymbolCount,
|
|
1308
2066
|
enumSubstitutionCount: appliedEnumUsage.size,
|
|
2067
|
+
enumDecompositionCount,
|
|
2068
|
+
excludedRangeCount: excludedRangeIdsEmitted.size,
|
|
1309
2069
|
};
|
|
1310
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
|
+
}
|