@fugloy/emu 0.0.0-stage → 0.3.0

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.
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Instruction length and control flow, read from the bytes at an address (spec §4, §6). The
3
+ * debugger uses it to tell an instruction fetch from a data read, to mark coverage, and to record
4
+ * the targets of jumps and calls. It follows the x/y/z/p/q decomposition of the opcode, with no
5
+ * tables built at run time, and it allocates one small object per call.
6
+ */
7
+
8
+ const WRAP = 0xffff;
9
+
10
+ /** Unprefixed opcode `op` at `at`: length, kind, and target. `CB`, `ED`, `DD`, `FD` are not handled here. */
11
+ function plain(read, at, op) {
12
+ const x = op >> 6;
13
+ const y = (op >> 3) & 7;
14
+ const z = op & 7;
15
+ if (x === 0) {
16
+ if (z === 0 && y >= 2) {
17
+ const d = read((at + 1) & WRAP);
18
+ const target = (at + 2 + (d < 0x80 ? d : d - 0x100)) & WRAP;
19
+ return { len: 2, kind: y === 2 ? "djnz" : "jr", target };
20
+ }
21
+ if (z === 1 && !(y & 1)) return { len: 3, kind: null, target: null };
22
+ if (z === 2 && y >= 4) return { len: 3, kind: null, target: null };
23
+ if (z === 6) return { len: 2, kind: null, target: null };
24
+ return { len: 1, kind: null, target: null };
25
+ }
26
+ if (x !== 3) return { len: 1, kind: null, target: null };
27
+ switch (z) {
28
+ case 0:
29
+ return { len: 1, kind: "ret", target: null };
30
+ case 1:
31
+ if (y === 1) return { len: 1, kind: "ret", target: null };
32
+ if (y === 5) return { len: 1, kind: "jp-ind", target: null };
33
+ return { len: 1, kind: null, target: null };
34
+ case 2:
35
+ return { len: 3, kind: "jp", target: word(read, at) };
36
+ case 3:
37
+ if (y === 0) return { len: 3, kind: "jp", target: word(read, at) };
38
+ if (y === 2 || y === 3) return { len: 2, kind: null, target: null };
39
+ return { len: 1, kind: null, target: null };
40
+ case 4:
41
+ return { len: 3, kind: "call", target: word(read, at) };
42
+ case 5:
43
+ if (y === 1) return { len: 3, kind: "call", target: word(read, at) };
44
+ return { len: 1, kind: null, target: null };
45
+ case 6:
46
+ return { len: 2, kind: null, target: null };
47
+ default:
48
+ return { len: 1, kind: "rst", target: y * 8 };
49
+ }
50
+ }
51
+
52
+ /** The 16-bit operand after the opcode at `at`. */
53
+ function word(read, at) {
54
+ return read((at + 1) & WRAP) | (read((at + 2) & WRAP) << 8);
55
+ }
56
+
57
+ /** Does the unprefixed opcode name (HL), so that under DD or FD it takes a displacement byte? */
58
+ function usesHlMemory(op) {
59
+ if (op === 0x34 || op === 0x35 || op === 0x36) return true;
60
+ const x = op >> 6;
61
+ if (x === 1) return op !== 0x76 && ((op & 7) === 6 || ((op >> 3) & 7) === 6);
62
+ return x === 2 && (op & 7) === 6;
63
+ }
64
+
65
+ /**
66
+ * What the instruction at `pc` is. `read(addr)` returns the byte at `addr` (addresses wrap at
67
+ * $FFFF). `kind` is "call", "jp", "jr", "djnz", "rst", "ret" (RETI and RETN too), "jp-ind"
68
+ * (JP (HL), (IX), (IY)), or null; conditional forms have the same kind as the plain ones.
69
+ * `target` is the static destination of call, jp, jr, djnz, and rst, else null. A DD or FD
70
+ * followed by DD, FD, or ED is one byte with kind null: the CPU runs it as a step of its own.
71
+ */
72
+ export function instructionInfo(read, pc) {
73
+ const op = read(pc & WRAP);
74
+ if (op === 0xcb) return { len: 2, kind: null, target: null };
75
+ if (op === 0xed) {
76
+ const next = read((pc + 1) & WRAP);
77
+ if ((next & 0xc7) === 0x43) return { len: 4, kind: null, target: null };
78
+ if ((next & 0xc7) === 0x45) return { len: 2, kind: "ret", target: null };
79
+ return { len: 2, kind: null, target: null };
80
+ }
81
+ if (op === 0xdd || op === 0xfd) {
82
+ const next = read((pc + 1) & WRAP);
83
+ if (next === 0xdd || next === 0xfd || next === 0xed) return { len: 1, kind: null, target: null };
84
+ if (next === 0xcb) return { len: 4, kind: null, target: null };
85
+ const info = plain(read, (pc + 1) & WRAP, next);
86
+ info.len += 1 + (usesHlMemory(next) ? 1 : 0);
87
+ return info;
88
+ }
89
+ return plain(read, pc & WRAP, op);
90
+ }
@@ -0,0 +1,429 @@
1
+ /**
2
+ * The label and coverage file (spec §8), the JSON contract shared by @fugloy/emu, @fugloy/mcp, and
3
+ * @fugloy/disasm: writing it from a debugger session, reading (validating) it, and merging two.
4
+ *
5
+ * Coverage fields are `rle-hex` (phase 3 Ruling 7): comma-separated run lengths in uppercase hex,
6
+ * alternating zero-runs and one-runs and starting with a zero-run (which may be `0`), summing to
7
+ * 65,536. A field with no bit set is `"10000"`. Nothing here reads a clock: a session's `started`
8
+ * is whatever the caller passes.
9
+ */
10
+ /** The format version this code writes and reads. */
11
+ export const LABEL_FILE_VERSION = 1;
12
+
13
+ /**
14
+ * The six coverage fields, in file order, with their bit in the debugger's `COVERAGE` (written as
15
+ * numbers because debugger.js imports this module; a test checks they agree).
16
+ */
17
+ export const COVERAGE_FIELDS = /** @type {const} */ ([
18
+ ['exec', 1], // EXEC
19
+ ['operand', 2], // OPERAND
20
+ ['read', 4], // READ
21
+ ['write', 8], // WRITE
22
+ ['jump', 16], // JUMP_TARGET
23
+ ['call', 32], // CALL_TARGET
24
+ ]);
25
+
26
+ const TOP_KEYS = ['fugloy', 'program', 'load', 'entry', 'labels', 'coverage', 'callers', 'sessions'];
27
+ const SOURCES = ['loader', 'observed', 'user', 'disasm'];
28
+ /** Label precedence: a higher rank wins at one address. */
29
+ const RANK = { loader: 0, disasm: 1, observed: 2, user: 3 };
30
+ const KINDS = ['call', 'jp', 'jr', 'rst', 'ret-to'];
31
+ const SPACE = 0x10000;
32
+ /** C0 controls and DEL: a CR or LF in a name, evidence, or note would start a new line in a listing or zmac source. */
33
+ const CONTROL = /[\u0000-\u001f\u007f]/;
34
+
35
+ /**
36
+ * @typedef {{ addr: number, name: string, kind: 'code' | 'data', size?: number, source: 'loader' | 'observed' | 'user' | 'disasm', evidence?: string, note?: string, map?: number, page?: number }} Label
37
+ * @typedef {{ from: number, kind: 'call' | 'jp' | 'jr' | 'rst' | 'ret-to', count: number }} CallerEdge
38
+ * @typedef {{ encoding: 'rle-hex', exec: string, operand: string, read: string, write: string, jump: string, call: string }} CoverageObject
39
+ * @typedef {{ cycles: number, started?: string, input?: string }} Session
40
+ * @typedef {{ fugloy: 1, program: { name: string, sha256?: string, model: 1 | 3 | 4 },
41
+ * load: { addr: number, len: number, source: string, map?: number, page?: number }[], entry: number | null,
42
+ * labels: Label[], coverage: Record<string, CoverageObject>, callers: Record<string, CallerEdge[]>,
43
+ * sessions: Session[], [key: string]: unknown }} LabelFile
44
+ */
45
+
46
+ /**
47
+ * Encode one coverage field: the addresses whose byte has any bit of `mask` set.
48
+ * @param {Uint8Array} bits 65,536 coverage bytes
49
+ * @param {number} mask
50
+ * @returns {string} rle-hex
51
+ */
52
+ export function encodeRle(bits, mask) {
53
+ if (bits.length !== SPACE) throw new Error(`encodeRle: bits must hold 65536 bytes, not ${bits.length}`);
54
+ const runs = [];
55
+ let state = 0;
56
+ let run = 0;
57
+ for (let a = 0; a < SPACE; a++) {
58
+ const v = (bits[a] & mask) !== 0 ? 1 : 0;
59
+ if (v === state) run++;
60
+ else {
61
+ runs.push(run);
62
+ state = v;
63
+ run = 1;
64
+ }
65
+ }
66
+ runs.push(run);
67
+ return runs.map((n) => n.toString(16).toUpperCase()).join(',');
68
+ }
69
+
70
+ /**
71
+ * Decode one rle-hex coverage field.
72
+ * @param {string} text
73
+ * @returns {Uint8Array} 65,536 bytes, each 0 or 1
74
+ */
75
+ export function decodeRle(text) {
76
+ if (typeof text !== 'string') throw new Error('rle-hex: a coverage field must be a string');
77
+ const parts = text.split(',');
78
+ const runs = [];
79
+ let sum = 0;
80
+ for (let i = 0; i < parts.length; i++) {
81
+ if (!/^[0-9A-Fa-f]{1,5}$/.test(parts[i])) throw new Error(`rle-hex: run ${i} ("${parts[i]}") is not hex`);
82
+ const n = parseInt(parts[i], 16);
83
+ runs.push(n);
84
+ sum += n;
85
+ }
86
+ if (sum !== SPACE) throw new Error(`rle-hex: the runs sum to ${sum}, not 65536`);
87
+ const out = new Uint8Array(SPACE);
88
+ let at = 0;
89
+ for (let i = 0; i < runs.length; i++) {
90
+ if (i % 2 === 1) out.fill(1, at, at + runs[i]);
91
+ at += runs[i];
92
+ }
93
+ return out;
94
+ }
95
+
96
+ /**
97
+ * Write the §8 object from a debugger session.
98
+ * @param {{ name?: string, started?: string, input?: string,
99
+ * loaded: { manifest: any, sha256: string } | null, userLabels: Label[], bits: Uint8Array,
100
+ * callerMap: Map<number, CallerEdge[]>, cycles: number, model?: 1 | 3 }} session
101
+ * `model` is the machine's (default 3, the Model III), recorded as `program.model`
102
+ * @returns {LabelFile}
103
+ */
104
+ export function writeLabelFile({ name, started, input, loaded, userLabels, bits, callerMap, cycles, model = 3 }) {
105
+ if (name !== undefined && (typeof name !== 'string' || name === '')) throw new Error(`labelFile: name must be a non-empty string, not ${String(name)}`);
106
+ if (started !== undefined && typeof started !== 'string') throw new Error(`labelFile: started must be a string (the core reads no clock; pass the time yourself), not ${String(started)}`);
107
+ if (input !== undefined && typeof input !== 'string') throw new Error(`labelFile: input must be a string, not ${String(input)}`);
108
+ const manifest = loaded?.manifest;
109
+ const programName = name ?? manifest?.name;
110
+ if (programName === undefined || programName === '') {
111
+ throw new Error(loaded === null ? 'labelFile needs a name: nothing was loaded through m.load' : `labelFile needs a name: the loaded ${manifest.format} manifest carries none`);
112
+ }
113
+
114
+ /** @type {LabelFile['program']} */
115
+ const program = loaded === null ? { name: programName, model } : { name: programName, sha256: loaded.sha256, model };
116
+ /** @type {LabelFile['load']} */
117
+ const load = [];
118
+ if (manifest !== undefined) {
119
+ manifest.blocks.forEach((/** @type {any} */ b, /** @type {number} */ i) => {
120
+ if (!b.predicted) load.push({ addr: b.addr, len: b.len, source: `${manifest.format}-block-${i}` });
121
+ });
122
+ }
123
+ const entry = manifest?.entry ?? null;
124
+
125
+ /** @type {Map<number, Label>} */
126
+ const labels = new Map();
127
+ if (entry !== null) labels.set(entry, { addr: entry, name: 'ENTRY', kind: 'code', source: 'loader' });
128
+ // Observed labels (Ruling 9): call and RST targets inside the loaded blocks.
129
+ const inLoad = (/** @type {number} */ a) => load.some((b) => ((a - b.addr) & 0xffff) < b.len);
130
+ for (const [target, edges] of [...callerMap].sort((x, y) => x[0] - y[0])) {
131
+ let count = 0;
132
+ const sites = new Set();
133
+ for (const e of edges) {
134
+ if (e.kind !== 'call' && e.kind !== 'rst') continue;
135
+ count += e.count;
136
+ sites.add(e.from);
137
+ }
138
+ if (count === 0 || !inLoad(target)) continue;
139
+ const hex = target.toString(16).toUpperCase().padStart(4, '0');
140
+ labels.set(target, { addr: target, name: `SUB_${hex}`, kind: 'code', source: 'observed', evidence: `called ${count}× from ${sites.size} site${sites.size === 1 ? '' : 's'}` });
141
+ }
142
+ for (const l of userLabels) labels.set(l.addr, { ...l });
143
+
144
+ /** @type {CoverageObject} */
145
+ const cov = /** @type {any} */ ({ encoding: 'rle-hex' });
146
+ for (const [field, mask] of COVERAGE_FIELDS) /** @type {any} */ (cov)[field] = encodeRle(bits, mask);
147
+
148
+ /** @type {Record<string, CallerEdge[]>} */
149
+ const callers = {};
150
+ for (const [target, edges] of [...callerMap].sort((x, y) => x[0] - y[0])) callers[String(target)] = edges.map((e) => ({ from: e.from, kind: e.kind, count: e.count }));
151
+
152
+ /** @type {Session} */
153
+ const session = { cycles };
154
+ if (started !== undefined) session.started = started;
155
+ if (input !== undefined) session.input = input;
156
+
157
+ return {
158
+ fugloy: LABEL_FILE_VERSION,
159
+ program,
160
+ load,
161
+ entry,
162
+ labels: [...labels.values()].sort((a, b) => a.addr - b.addr),
163
+ coverage: { '0:0': cov },
164
+ callers,
165
+ sessions: [session],
166
+ };
167
+ }
168
+
169
+ const isObject = (/** @type {unknown} */ v) => typeof v === 'object' && v !== null && !Array.isArray(v);
170
+ const isAddr = (/** @type {unknown} */ v) => Number.isInteger(v) && /** @type {number} */ (v) >= 0 && /** @type {number} */ (v) <= 0xffff;
171
+ const isNat = (/** @type {unknown} */ v) => Number.isSafeInteger(v) && /** @type {number} */ (v) >= 0;
172
+ const hex4 = (/** @type {number} */ a) => `$${a.toString(16).toUpperCase().padStart(4, '0')}`;
173
+
174
+ /**
175
+ * Validate a label file against every rule of §8.
176
+ * @param {unknown} value the parsed JSON, or its text
177
+ * @returns {{ file: LabelFile | null, errors: string[] }} `file` is the value itself (unknown top-level keys kept) when there are no errors, else null
178
+ */
179
+ export function parseLabelFile(value) {
180
+ if (typeof value === 'string') {
181
+ try {
182
+ value = JSON.parse(value);
183
+ } catch (e) {
184
+ return { file: null, errors: [`the label file is not JSON: ${/** @type {Error} */ (e).message}`] };
185
+ }
186
+ }
187
+ if (!isObject(value)) return { file: null, errors: ['the label file must be a JSON object'] };
188
+ const f = /** @type {any} */ (value);
189
+ /** @type {string[]} */
190
+ const errors = [];
191
+ const err = (/** @type {string} */ s) => errors.push(s);
192
+
193
+ if (f.fugloy === undefined) return { file: null, errors: ['fugloy: missing'] };
194
+ if (f.fugloy !== LABEL_FILE_VERSION) return { file: null, errors: [`fugloy: version ${JSON.stringify(f.fugloy)} is not supported (this reader knows ${LABEL_FILE_VERSION})`] };
195
+ for (const k of TOP_KEYS) if (f[k] === undefined) err(`${k}: missing`);
196
+
197
+ /** Optional map and page, on load entries and labels. */
198
+ const mapPage = (/** @type {any} */ o, /** @type {string} */ at) => {
199
+ for (const k of ['map', 'page']) if (o[k] !== undefined && !isNat(o[k])) err(`${at}.${k}: must be a non-negative integer`);
200
+ };
201
+
202
+ const p = f.program;
203
+ if (p !== undefined) {
204
+ if (!isObject(p)) err('program: must be an object');
205
+ else {
206
+ if (typeof p.name !== 'string' || p.name === '') err('program.name: must be a non-empty string');
207
+ if (p.sha256 !== undefined && (typeof p.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(p.sha256))) err('program.sha256: must be 64 lowercase hex digits');
208
+ if (![1, 3, 4].includes(p.model)) err('program.model: must be 1, 3, or 4');
209
+ }
210
+ }
211
+
212
+ if (f.load !== undefined) {
213
+ if (!Array.isArray(f.load)) err('load: must be an array');
214
+ else
215
+ f.load.forEach((/** @type {any} */ b, /** @type {number} */ i) => {
216
+ const at = `load[${i}]`;
217
+ if (!isObject(b)) return err(`${at}: must be an object`);
218
+ if (!isAddr(b.addr)) err(`${at}.addr: must be an address 0..65535`);
219
+ if (!Number.isInteger(b.len) || b.len < 1 || b.len > SPACE) err(`${at}.len: must be an integer 1..65536`);
220
+ if (typeof b.source !== 'string') err(`${at}.source: must be a string`);
221
+ mapPage(b, at);
222
+ });
223
+ }
224
+
225
+ if (f.entry !== undefined && f.entry !== null && !isAddr(f.entry)) err('entry: must be an address 0..65535 or null');
226
+
227
+ if (f.labels !== undefined) {
228
+ if (!Array.isArray(f.labels)) err('labels: must be an array');
229
+ else {
230
+ const seen = new Set();
231
+ f.labels.forEach((/** @type {any} */ l, /** @type {number} */ i) => {
232
+ const at = `labels[${i}]`;
233
+ if (!isObject(l)) return err(`${at}: must be an object`);
234
+ if (!isAddr(l.addr)) err(`${at}.addr: must be an address 0..65535`);
235
+ if (typeof l.name !== 'string' || l.name === '') err(`${at}.name: must be a non-empty string`);
236
+ else if (CONTROL.test(l.name)) err(`${at}.name: must not contain a control character`);
237
+ if (l.kind !== 'code' && l.kind !== 'data') err(`${at}.kind: must be code or data`);
238
+ if (!SOURCES.includes(l.source)) err(`${at}.source: must be loader, observed, user, or disasm`);
239
+ if (l.size !== undefined && (!Number.isInteger(l.size) || l.size < 1 || l.size > SPACE)) err(`${at}.size: must be an integer 1..65536`);
240
+ if (l.evidence !== undefined && typeof l.evidence !== 'string') err(`${at}.evidence: must be a string`);
241
+ else if (l.evidence !== undefined && CONTROL.test(l.evidence)) err(`${at}.evidence: must not contain a control character`);
242
+ else if (l.source === 'observed' && (l.evidence === undefined || l.evidence === '')) err(`${at}.evidence: an observed label must carry its evidence`);
243
+ if (l.note !== undefined && typeof l.note !== 'string') err(`${at}.note: must be a string`);
244
+ else if (l.note !== undefined && CONTROL.test(l.note)) err(`${at}.note: must not contain a control character`);
245
+ mapPage(l, at);
246
+ if (isAddr(l.addr)) {
247
+ const key = labelKey(l);
248
+ if (seen.has(key)) err(`${at}: a second label at ${hex4(l.addr)}${l.map || l.page ? ` (map ${l.map ?? 0}, page ${l.page ?? 0})` : ''}`);
249
+ seen.add(key);
250
+ }
251
+ });
252
+ }
253
+ }
254
+
255
+ if (f.coverage !== undefined) {
256
+ if (!isObject(f.coverage)) err('coverage: must be an object');
257
+ else
258
+ for (const [key, c] of Object.entries(f.coverage)) {
259
+ const at = `coverage[${JSON.stringify(key)}]`;
260
+ if (!/^\d+:\d+$/.test(key)) {
261
+ err(`${at}: the key must be "<map>:<page>", as "0:0"`);
262
+ continue;
263
+ }
264
+ if (!isObject(c)) {
265
+ err(`${at}: must be an object`);
266
+ continue;
267
+ }
268
+ if (c.encoding !== 'rle-hex') err(`${at}.encoding: must be "rle-hex"`);
269
+ for (const [field] of COVERAGE_FIELDS) {
270
+ if (c[field] === undefined) err(`${at}.${field}: missing`);
271
+ else {
272
+ try {
273
+ decodeRle(c[field]);
274
+ } catch (e) {
275
+ err(`${at}.${field}: ${/** @type {Error} */ (e).message.replace(/^rle-hex: /, '')}`);
276
+ }
277
+ }
278
+ }
279
+ }
280
+ }
281
+
282
+ if (f.callers !== undefined) {
283
+ if (!isObject(f.callers)) err('callers: must be an object');
284
+ else
285
+ for (const [key, edges] of Object.entries(f.callers)) {
286
+ const at = `callers[${JSON.stringify(key)}]`;
287
+ if (!/^(0|[1-9][0-9]{0,4})$/.test(key) || Number(key) > 0xffff) {
288
+ err(`${at}: the key must be a decimal address 0..65535`);
289
+ continue;
290
+ }
291
+ if (!Array.isArray(edges)) {
292
+ err(`${at}: must be an array`);
293
+ continue;
294
+ }
295
+ edges.forEach((/** @type {any} */ e, /** @type {number} */ i) => {
296
+ const eat = `${at}[${i}]`;
297
+ if (!isObject(e)) return err(`${eat}: must be an object`);
298
+ if (!isAddr(e.from)) err(`${eat}.from: must be an address 0..65535`);
299
+ if (!KINDS.includes(e.kind)) err(`${eat}.kind: must be call, jp, jr, rst, or ret-to`);
300
+ if (!Number.isSafeInteger(e.count) || e.count < 1) err(`${eat}.count: must be a positive integer`);
301
+ });
302
+ }
303
+ }
304
+
305
+ if (f.sessions !== undefined) {
306
+ if (!Array.isArray(f.sessions)) err('sessions: must be an array');
307
+ else
308
+ f.sessions.forEach((/** @type {any} */ s, /** @type {number} */ i) => {
309
+ const at = `sessions[${i}]`;
310
+ if (!isObject(s)) return err(`${at}: must be an object`);
311
+ if (!isNat(s.cycles)) err(`${at}.cycles: must be a non-negative integer`);
312
+ for (const k of ['started', 'input']) if (s[k] !== undefined && typeof s[k] !== 'string') err(`${at}.${k}: must be a string`);
313
+ });
314
+ }
315
+
316
+ return errors.length === 0 ? { file: f, errors } : { file: null, errors };
317
+ }
318
+
319
+ /** @param {{ addr: number, map?: number, page?: number }} l */
320
+ function labelKey(l) {
321
+ return `${l.map ?? 0}:${l.page ?? 0}:${l.addr}`;
322
+ }
323
+
324
+ /**
325
+ * Merge two label files by the §8 rule (refused when both name a `program.sha256` and they differ,
326
+ * or their `program.model`s differ): coverage unioned per key; labels by address with `user`
327
+ * over `observed` over `disasm` over `loader` (`b` wins a tie); `load` unioned without duplicates;
328
+ * `entry` is `a`'s unless null; callers unioned with counts summed per `(from, kind)`; sessions
329
+ * appended, `a`'s first. Unknown top-level keys are kept, `a`'s winning.
330
+ * @param {unknown} a
331
+ * @param {unknown} b
332
+ * @returns {LabelFile}
333
+ */
334
+ export function mergeLabelFiles(a, b) {
335
+ const fa = checked(a, 'a');
336
+ const fb = checked(b, 'b');
337
+ const sa = fa.program.sha256;
338
+ const sb = fb.program.sha256;
339
+ if (sa !== undefined && sb !== undefined && sa !== sb) throw new Error(`mergeLabelFiles: the files are for different programs (sha256 ${sa} and ${sb})`);
340
+ // Two models' files would union the coverage of two different ROMs at the same addresses.
341
+ const ma = fa.program.model;
342
+ const mb = fb.program.model;
343
+ if (ma !== mb) throw new Error(`mergeLabelFiles: the files are for different models (${ma} and ${mb}), whose ROMs and hardware differ`);
344
+
345
+ const { name, sha256: _s, model, ...programRest } = fa.program;
346
+ const sha256 = sa ?? sb;
347
+ const program = /** @type {LabelFile['program']} */ (sha256 === undefined ? { name, model, ...programRest } : { name, sha256, model, ...programRest });
348
+
349
+ const load = [];
350
+ const loadSeen = new Set();
351
+ for (const l of [...fa.load, ...fb.load]) {
352
+ const key = JSON.stringify([l.addr, l.len, l.source, l.map ?? 0, l.page ?? 0]);
353
+ if (loadSeen.has(key)) continue;
354
+ loadSeen.add(key);
355
+ load.push({ ...l });
356
+ }
357
+
358
+ /** @type {Map<string, Label>} */
359
+ const labels = new Map();
360
+ for (const l of [...fa.labels, ...fb.labels]) {
361
+ const key = labelKey(l);
362
+ const old = labels.get(key);
363
+ if (old === undefined || RANK[l.source] >= RANK[old.source]) labels.set(key, { ...l });
364
+ }
365
+
366
+ /** @type {Record<string, CoverageObject>} */
367
+ const coverage = {};
368
+ for (const [key, c] of [...Object.entries(fa.coverage), ...Object.entries(fb.coverage)]) {
369
+ const old = coverage[key];
370
+ if (old === undefined) {
371
+ coverage[key] = /** @type {CoverageObject} */ (Object.fromEntries([['encoding', 'rle-hex'], ...COVERAGE_FIELDS.map(([field]) => [field, /** @type {any} */ (c)[field]])]));
372
+ continue;
373
+ }
374
+ for (const [field] of COVERAGE_FIELDS) {
375
+ const x = decodeRle(/** @type {any} */ (old)[field]);
376
+ const y = decodeRle(/** @type {any} */ (c)[field]);
377
+ for (let i = 0; i < SPACE; i++) x[i] |= y[i];
378
+ /** @type {any} */ (old)[field] = encodeRle(x, 1);
379
+ }
380
+ }
381
+
382
+ /** @type {Map<number, Map<string, CallerEdge>>} */
383
+ const edges = new Map();
384
+ for (const [key, list] of [...Object.entries(fa.callers), ...Object.entries(fb.callers)]) {
385
+ const target = Number(key);
386
+ let m = edges.get(target);
387
+ if (m === undefined) edges.set(target, (m = new Map()));
388
+ for (const e of list) {
389
+ const k = `${e.from}:${e.kind}`;
390
+ const old = m.get(k);
391
+ if (old === undefined) m.set(k, { from: e.from, kind: e.kind, count: e.count });
392
+ else old.count += e.count;
393
+ }
394
+ }
395
+ /** @type {Record<string, CallerEdge[]>} */
396
+ const callers = {};
397
+ for (const target of [...edges.keys()].sort((x, y) => x - y)) {
398
+ callers[String(target)] = [.../** @type {Map<string, CallerEdge>} */ (edges.get(target)).values()].sort((x, y) => x.from - y.from || (x.kind < y.kind ? -1 : x.kind > y.kind ? 1 : 0));
399
+ }
400
+
401
+ /** @type {LabelFile} */
402
+ const out = {
403
+ fugloy: LABEL_FILE_VERSION,
404
+ program,
405
+ load,
406
+ entry: fa.entry ?? fb.entry,
407
+ labels: [...labels.values()].sort((x, y) => x.addr - y.addr || (x.map ?? 0) - (y.map ?? 0) || (x.page ?? 0) - (y.page ?? 0)),
408
+ coverage,
409
+ callers,
410
+ sessions: [...fa.sessions, ...fb.sessions].map((s) => ({ ...s })),
411
+ };
412
+ for (const f of [fb, fa]) for (const [k, v] of Object.entries(f)) if (!TOP_KEYS.includes(k)) out[k] = v;
413
+ // Keep a's unknown keys ahead of b's in the output order.
414
+ return reorder(out, fa, fb);
415
+ }
416
+
417
+ /** @param {unknown} v @param {string} which @returns {LabelFile} */
418
+ function checked(v, which) {
419
+ const { file, errors } = parseLabelFile(v);
420
+ if (file === null) throw new Error(`mergeLabelFiles: ${which} is not a valid label file: ${errors.join('; ')}`);
421
+ return file;
422
+ }
423
+
424
+ /** @param {LabelFile} out @param {LabelFile} fa @param {LabelFile} fb @returns {LabelFile} */
425
+ function reorder(out, fa, fb) {
426
+ const keys = [...TOP_KEYS];
427
+ for (const f of [fa, fb]) for (const k of Object.keys(f)) if (!keys.includes(k)) keys.push(k);
428
+ return /** @type {LabelFile} */ (Object.fromEntries(keys.map((k) => [k, out[k]])));
429
+ }