@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.
Files changed (77) hide show
  1. package/README.md +2 -2
  2. package/THIRD-PARTY-NOTICES.md +422 -1
  3. package/anno-bank.ts +171 -0
  4. package/anno-cli.ts +1674 -99
  5. package/anno-enum-gen.ts +416 -30
  6. package/anno-export-asm.ts +1175 -89
  7. package/anno-graphics.ts +338 -0
  8. package/anno-hazard-report.ts +1367 -0
  9. package/anno-import.ts +495 -0
  10. package/anno-join.ts +480 -0
  11. package/anno-provenance-ledger.ts +472 -0
  12. package/anno-register.ts +159 -0
  13. package/anno-store-export.ts +661 -0
  14. package/anno-store.ts +518 -2
  15. package/anno-tools.ts +1169 -16
  16. package/anno-types.ts +275 -2
  17. package/backend-detect.mts +124 -312
  18. package/build.ts +3 -1
  19. package/capture-predicate.ts +597 -0
  20. package/channel-lock.ts +349 -0
  21. package/evid-ingest.ts +217 -0
  22. package/evid-reconcile.ts +316 -0
  23. package/host-tool-client.ts +430 -0
  24. package/incident-record.ts +23 -12
  25. package/install-resources.ts +29 -13
  26. package/memmap-lookup.ts +285 -0
  27. package/package.json +27 -8
  28. package/prg-image.ts +1 -2
  29. package/repo-root.ts +87 -3
  30. package/resources/backend-detect.mjs +98 -236
  31. package/resources/broker-control.mjs +189 -16
  32. package/resources/broker-epoch.mjs +1 -1
  33. package/resources/broker-kill.mjs +8 -2
  34. package/resources/broker-launch.mjs +365 -210
  35. package/resources/broker-state.mjs +64 -18
  36. package/resources/container-guard.mjs +1 -1
  37. package/resources/ghidra-project.mjs +790 -0
  38. package/resources/host-tool.mjs +2561 -0
  39. package/resources/vice-broker.mjs +330 -184
  40. package/resources/vice-launcher.sh +127 -9
  41. package/stock-address.ts +1 -1
  42. package/stock-condition.ts +1 -1
  43. package/stock-connect.ts +9 -5
  44. package/stock-derived.ts +29 -37
  45. package/stock-diagnose.ts +200 -36
  46. package/stock-dispatch.ts +179 -77
  47. package/stock-handler.ts +1 -1
  48. package/stock-paths.ts +18 -14
  49. package/stock-petscii.ts +1 -1
  50. package/stock-protocol.ts +1 -1
  51. package/stock-recycle.ts +83 -2
  52. package/stock-reproducible-run.ts +811 -0
  53. package/stock-run-until.ts +100 -1
  54. package/stock-symbols.ts +4 -4
  55. package/stock-timing.ts +1 -1
  56. package/stop-oracle.ts +167 -0
  57. package/text-capability-probe.ts +660 -0
  58. package/text-connect.ts +157 -0
  59. package/text-protocol.ts +810 -0
  60. package/text-tools.ts +778 -0
  61. package/textmon-backtrace.ts +385 -0
  62. package/textmon-cpuhistory.ts +335 -0
  63. package/textmon-memmap.ts +494 -0
  64. package/textmon-profile.ts +458 -0
  65. package/textmon-registers.ts +748 -0
  66. package/tools-manifest.stock.json +864 -3
  67. package/vice-broker-client.ts +189 -42
  68. package/vice-errors.ts +268 -0
  69. package/vice-proxy.ts +339 -2144
  70. package/vsf-slice.ts +640 -0
  71. package/anno-d64.ts +0 -310
  72. package/capability-registry.ts +0 -390
  73. package/refresh-manifest.ts +0 -124
  74. package/tools-manifest.json +0 -1223
  75. package/vice-probe.ts +0 -278
  76. package/vice-sync.ts +0 -336
  77. package/vice.ts +0 -772
@@ -0,0 +1,458 @@
1
+ // textmon-profile.ts
2
+ //
3
+ // THE ONE owning module for VICE's `prof flat` text-monitor output
4
+ // (PARSE-02, PARSE-03). Nothing else in this tree reads or interprets a
5
+ // `prof flat` reply -- a handler dials the wire, this module is the only
6
+ // place that turns the framed text into structured data.
7
+ //
8
+ // WHY THIS FILE EXISTS RATHER THAN LIVING INSIDE THE TOOL HANDLER: the same
9
+ // reasoning `textmon-memmap.ts` and `disasm-decoder.ts` state for themselves
10
+ // applies here unchanged -- keeping the parser import-free of transport code
11
+ // means any future non-tool consumer can depend on this one file without
12
+ // dragging in a socket, and PARSE-03's own never-throw discipline (D-42-3,
13
+ // decided once in plan 42-01 for all five text parsers this phase adds)
14
+ // means this module can never be the thing that silently launders a wire
15
+ // error into a parse result, because it never touches the wire at all.
16
+ //
17
+ // THE ONE HAZARD THAT DEFINES THIS MODULE. VICE's flat profiler writes its
18
+ // cycle counts with U+202F (NARROW NO-BREAK SPACE, UTF-8 bytes e2 80 af) as
19
+ // the thousands separator -- MEASURED directly against both committed real
20
+ // captures (`xxd fixtures/textmon/flat-profile-stock.txt`) and recorded in
21
+ // `fixtures/textmon/README.md`'s own "Encoding" section. In JavaScript, the
22
+ // regular-expression whitespace class (`\s`) MATCHES U+202F. So the obvious
23
+ // implementation -- split each row on a run of `\s` -- silently cuts every
24
+ // cycle count into pieces and yields only the first piece: the stock
25
+ // capture's leading row would read `2` instead of `2326151`, an inverted
26
+ // answer with no error anywhere. THIS MODULE THEREFORE NEVER SPLITS ON `\s`.
27
+ // Every row is tokenized on runs of the ASCII space character (U+0020) ONLY
28
+ // (`/ +/`, never `/\s+/`), so a thousands-separated number -- whose internal
29
+ // separator is U+202F, not U+0020 -- survives tokenization as ONE token.
30
+ // Each numeric token is then parsed by first stripping U+202F and requiring
31
+ // the remainder to be digits only. Do not "simplify" this back to a
32
+ // whitespace-class split; that is the defect this module exists to avoid.
33
+ //
34
+ // WHAT NOT TO DO:
35
+ // - Never import anything -- not `node:` anything, not `text-protocol.ts`,
36
+ // not `textmon-fixtures.ts`, not even a type-only import of a sibling
37
+ // module. Purity is asserted mechanically by this file's own test.
38
+ // - Never throw on a malformed or drifted input (D-42-3). Return the
39
+ // discriminated `FlatProfileParseResult` instead.
40
+ // - Never sort or re-rank `entries`. VICE emits rows in its own rank
41
+ // order (by cycles descending); this module performs NO array-sorting
42
+ // or array-reversing call anywhere, asserted mechanically by this
43
+ // file's own test scanning the source for one. Re-ranking, including
44
+ // "stabilizing" a tie by re-sorting on a secondary key, would restate
45
+ // which address dominates the profile -- the whole point of the format
46
+ // -- and is never this module's decision to make.
47
+ // - Never use `\s`, `String.prototype.trim()` on a whole row before
48
+ // tokenizing, or any other general-whitespace-class operation on a row
49
+ // that still contains its numeric fields -- see the hazard note above.
50
+ // - Never silently accept an ASCII space in place of the thousands
51
+ // separator by concatenating split fragments back together. A row using
52
+ // the wrong separator character is drift and must refuse by name
53
+ // (`unrecognised-separator`), never be silently repaired.
54
+ // - Never assume the percentage's decimal separator is a comma. The
55
+ // captures render it as a comma (the capturing host's locale), but a
56
+ // period is an equally legitimate reading from a different host; both
57
+ // are accepted and the OBSERVED separator is recorded on the result
58
+ // rather than assumed or discarded.
59
+ // - Never fold a never-started profiler into `missing-header`, and never
60
+ // move `PROFILING_NOT_STARTED_TEXT` outside this module. `text-tools.ts`
61
+ // branches on the `profiling-not-started` CODE this module exports,
62
+ // never on VICE's own text -- the single-owner rule PARSE-03's
63
+ // structural guard enforces (textmon-seam.test.ts) is not weakened to
64
+ // fix a message.
65
+
66
+ /** One decoded `prof flat` row, in VICE's own emitted order (rank order by
67
+ * cycles, descending) -- this module performs no sort of its own, ever.
68
+ * `address` is a 16-bit machine address for an ordinary call-tree entry, or
69
+ * the literal string `"ROOT"` for VICE's own synthetic top-level pseudo-frame
70
+ * -- MEASURED live (plan 42-09) against genuine stock VICE: cycles that
71
+ * elapsed outside any traced call (e.g. idle-loop time before any subroutine
72
+ * call happened during the profiled window) are attributed to a row whose
73
+ * address FIELD is the literal text `ROOT`, not a hex address, and neither
74
+ * committed fixture under `fixtures/textmon/` happened to capture this row
75
+ * shape (both captures ran long enough that no cycles landed there). This is
76
+ * a real, closed two-member shape -- never a third string, and never
77
+ * silently coerced to a number. */
78
+ export interface FlatProfileEntry {
79
+ readonly totalCycles: number;
80
+ readonly totalPercent: number;
81
+ readonly selfCycles: number;
82
+ readonly selfPercent: number;
83
+ readonly address: number | "ROOT";
84
+ }
85
+
86
+ /** The full decoded flat profile. `decimalSeparator` records the FIRST data
87
+ * row's observed decimal-point character (comma or period) -- a real
88
+ * environmental fact, never assumed -- and is NOT validated against any
89
+ * later row. A caller must not read it as a whole-payload guarantee that
90
+ * every row shares the same separator; it names only what the first row
91
+ * showed. */
92
+ export interface FlatProfile {
93
+ readonly entries: readonly FlatProfileEntry[];
94
+ readonly decimalSeparator: "," | ".";
95
+ }
96
+
97
+ /** The closed refusal-code union (D-42-3): an empty response, a missing or
98
+ * unrecognised header/rule pair, a structurally malformed row (wrong field
99
+ * count, non-hex address), an unrecognised thousands separator (a numeric
100
+ * group broken apart by an ASCII space rather than joined by U+202F), an
101
+ * unrecognised percentage shape, and a profiler that was never started
102
+ * (`profiling-not-started`) -- the profiler subsystem is present and the
103
+ * command is fine, it simply has nothing recorded yet, which is a
104
+ * different fact from a header that failed to match. */
105
+ export type FlatProfileRefusalCode =
106
+ | "empty-response"
107
+ | "missing-header"
108
+ | "malformed-row"
109
+ | "unrecognised-separator"
110
+ | "unrecognised-percentage"
111
+ | "profiling-not-started";
112
+
113
+ /** A parser refusal (D-42-3): returned, never thrown. `line` and
114
+ * `lineNumber` name the offending content whenever one exists -- for the
115
+ * whole-payload `empty-response` refusal, `line` is empty and `lineNumber`
116
+ * is 0. */
117
+ export interface TextParseRefusal {
118
+ readonly code: FlatProfileRefusalCode;
119
+ readonly message: string;
120
+ readonly line: string;
121
+ readonly lineNumber: number;
122
+ }
123
+
124
+ /** D-42-3's discriminated shape: a parser never throws, it returns one of
125
+ * these two arms. */
126
+ export type FlatProfileParseResult = { ok: true; value: FlatProfile } | { ok: false; refusal: TextParseRefusal };
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // Framing constants -- this module's OWN copies, never imported from
130
+ // text-protocol.ts (purity rule above). `prof flat` carries no leading
131
+ // entry-echo prompt (unlike `memmapshow`) per fixtures/textmon/README.md's
132
+ // own "Framing" section -- only a TRAILING exit-prompt is ever stripped.
133
+ // ---------------------------------------------------------------------------
134
+
135
+ const TRAILING_PROMPT_RE = /\(C:\$[0-9A-Fa-f]{4}\)\s*$/;
136
+
137
+ /** VICE's own two-line header, byte-for-byte -- MEASURED identical across
138
+ * both the stock and fork real captures. Neither line's content depends on
139
+ * the reported row count. */
140
+ const HEADER_LINE = " Total % Self %";
141
+ const RULES_LINE = "------------- ------ ------------- ------";
142
+
143
+ /** VICE's own cold-profiler sentence, quoted byte-for-byte -- including its
144
+ * embedded double quotes and its trailing period -- from
145
+ * `text-protocol.ts`'s `TEXT_COMMAND_ALLOWLIST` doc comment and
146
+ * `docs/phase42-text-format-drift-citations.md`'s Block 9. Unlike this
147
+ * module's siblings' source-traced strings, this one is MEASURED: observed
148
+ * live against genuine stock `x64sc (VICE 3.9)`, 2026-09-09. `prof flat`
149
+ * alone, on a freshly connected session that has never issued `prof on`,
150
+ * returns exactly this sentence -- the profiler subsystem is compiled in
151
+ * and the command itself is fine, it simply has nothing recorded yet. */
152
+ export const PROFILING_NOT_STARTED_TEXT = 'No profiling data available. Start profiling with "prof on".';
153
+
154
+ /** The narrow no-break space (U+202F) VICE uses as its thousands separator
155
+ * -- see this module's header comment for the full hazard explanation. */
156
+ const THOUSANDS_SEPARATOR = "\u202f";
157
+
158
+ /** A numeric field: one or more digits, optionally interleaved with the
159
+ * narrow no-break space thousands separator -- NEVER an ASCII space, which
160
+ * is exclusively the inter-field tokenizer's own delimiter (see
161
+ * `tokenizeRow` below). */
162
+ const NUMERIC_FIELD_RE = new RegExp(`^[0-9${THOUSANDS_SEPARATOR}]+$`);
163
+
164
+ /** A percentage field: one or more digits, a decimal separator (comma or
165
+ * period, both accepted -- see this module's header comment), one or more
166
+ * digits, then a trailing `%`. */
167
+ const PERCENT_FIELD_RE = /^(\d+)([,.])(\d+)%$/;
168
+
169
+ function makeRefusal(code: FlatProfileRefusalCode, message: string, line: string, lineNumber: number): TextParseRefusal {
170
+ return { code, message, line, lineNumber };
171
+ }
172
+
173
+ /** Tokenizes one data row on runs of the ASCII space character ONLY (never
174
+ * `\s`, never any other Unicode space) -- the one hazard this module exists
175
+ * to defend against. A thousands-separated numeric field survives this
176
+ * split as a single token because its internal separator is U+202F, not
177
+ * U+0020. */
178
+ function tokenizeRow(row: string): string[] {
179
+ return row.split(/ +/).filter((token) => token.length > 0);
180
+ }
181
+
182
+ type NumericFieldResult =
183
+ | { ok: true; value: number }
184
+ | { ok: false; code: "unrecognised-separator" | "malformed-row"; detail: string };
185
+
186
+ /** Parses the totalCycles/selfCycles field from its (possibly split-apart,
187
+ * if malformed) token run. A well-formed field is exactly ONE token
188
+ * matching `NUMERIC_FIELD_RE`. When tokenization instead produced more than
189
+ * one token, the field is malformed -- but WHY it split apart matters: if
190
+ * every fragment is digits-only, an ASCII space stood where the narrow
191
+ * no-break space belongs (`unrecognised-separator`, the specific, named
192
+ * hazard this module exists to catch); anything else is a generic
193
+ * structural malformation (`malformed-row`). */
194
+ function parseNumericField(tokens: readonly string[]): NumericFieldResult {
195
+ if (tokens.length !== 1) {
196
+ const detail = tokens.join(" ");
197
+ if (tokens.length > 0 && tokens.every((t) => /^\d+$/.test(t))) {
198
+ return { ok: false, code: "unrecognised-separator", detail };
199
+ }
200
+ return { ok: false, code: "malformed-row", detail };
201
+ }
202
+ const raw = tokens[0]!;
203
+ if (!NUMERIC_FIELD_RE.test(raw)) {
204
+ return { ok: false, code: "malformed-row", detail: raw };
205
+ }
206
+ const digitsOnly = raw.split(THOUSANDS_SEPARATOR).join("");
207
+ return { ok: true, value: Number(digitsOnly) };
208
+ }
209
+
210
+ type PercentFieldResult = { ok: true; value: number; separator: "," | "." } | { ok: false };
211
+
212
+ /** Parses a percentage field. Both a comma and a period decimal separator
213
+ * are accepted (the capturing host's locale, not a VICE constant); the
214
+ * OBSERVED separator is returned alongside the numeric value rather than
215
+ * assumed. A token that carries no recognisable decimal shape at all
216
+ * (including one with the wrong separator character, e.g. a semicolon)
217
+ * fails here. */
218
+ function parsePercentField(token: string): PercentFieldResult {
219
+ const match = PERCENT_FIELD_RE.exec(token);
220
+ if (!match) return { ok: false };
221
+ const separator = match[2] as "," | ".";
222
+ return { ok: true, value: Number(`${match[1]}.${match[3]}`), separator };
223
+ }
224
+
225
+ /**
226
+ * Parses `prof flat`'s framed text-monitor reply into a structured
227
+ * {@link FlatProfile}. Input is the string `TextMonitorClient.command()`
228
+ * resolves to, or a fixture's `text` -- both must parse identically. Never
229
+ * throws (D-42-3): every failure mode returns `{ ok: false, refusal }`
230
+ * naming exactly what was wrong and where.
231
+ *
232
+ * Bounded by construction: one pass over the payload's lines, each
233
+ * iteration consumes exactly one line, no recursion anywhere in this
234
+ * function. Performs NO sort -- rows are returned in VICE's own emitted
235
+ * order, entry by entry, as encountered.
236
+ */
237
+ export function parseFlatProfile(text: string): FlatProfileParseResult {
238
+ if (typeof text !== "string" || text.trim() === "") {
239
+ return {
240
+ ok: false,
241
+ refusal: makeRefusal(
242
+ "empty-response",
243
+ "prof flat: the response was empty or whitespace-only -- never decoded as a zero-row profile, because an " +
244
+ "empty response and a real capture that recorded no ranked cycles are two different facts",
245
+ "",
246
+ 0,
247
+ ),
248
+ };
249
+ }
250
+
251
+ let body = text.replace(TRAILING_PROMPT_RE, "");
252
+ if (body.trim() === "") {
253
+ return {
254
+ ok: false,
255
+ refusal: makeRefusal(
256
+ "empty-response",
257
+ "prof flat: the response contained only prompt text and no header -- never decoded as a zero-row profile",
258
+ "",
259
+ 0,
260
+ ),
261
+ };
262
+ }
263
+
264
+ const trimmedBody = body.trim();
265
+ if (trimmedBody === PROFILING_NOT_STARTED_TEXT) {
266
+ return {
267
+ ok: false,
268
+ refusal: makeRefusal(
269
+ "profiling-not-started",
270
+ `prof flat: the connected machine replied: ${PROFILING_NOT_STARTED_TEXT} -- profiling is not currently ` +
271
+ "running there, this is not a missing build capability, and it is not a failure to read the reply",
272
+ trimmedBody,
273
+ 1,
274
+ ),
275
+ };
276
+ }
277
+
278
+ const lines = body.split("\n");
279
+ // A trailing "\n" before the (already-stripped) prompt splits into one
280
+ // trailing empty element -- drop exactly that split artifact, never any
281
+ // other blank line, so a genuinely blank data line still reaches the row
282
+ // parser below and refuses structurally rather than being silently
283
+ // swallowed here.
284
+ if (lines.length > 0 && lines[lines.length - 1] === "") {
285
+ lines.pop();
286
+ }
287
+
288
+ const headerLine = lines[0] ?? "";
289
+ const rulesLine = lines[1] ?? "";
290
+ if (headerLine !== HEADER_LINE || rulesLine !== RULES_LINE) {
291
+ const badLine = headerLine !== HEADER_LINE ? headerLine : rulesLine;
292
+ const badLineNumber = headerLine !== HEADER_LINE ? 1 : 2;
293
+ return {
294
+ ok: false,
295
+ refusal: makeRefusal(
296
+ "missing-header",
297
+ `prof flat: expected the two-line header ${JSON.stringify(HEADER_LINE)} / ${JSON.stringify(RULES_LINE)}, ` +
298
+ `found ${JSON.stringify(badLine)} at line ${badLineNumber}`,
299
+ badLine,
300
+ badLineNumber,
301
+ ),
302
+ };
303
+ }
304
+
305
+ const dataLines = lines.slice(2);
306
+ if (dataLines.length === 0) {
307
+ return {
308
+ ok: false,
309
+ refusal: makeRefusal(
310
+ "malformed-row",
311
+ "prof flat: the header was present but no data rows followed it -- never decoded as a zero-row profile",
312
+ rulesLine,
313
+ 2,
314
+ ),
315
+ };
316
+ }
317
+
318
+ const entries: FlatProfileEntry[] = [];
319
+ let decimalSeparator: "," | "." | undefined;
320
+
321
+ for (let i = 0; i < dataLines.length; i++) {
322
+ const line = dataLines[i]!;
323
+ const lineNumber = i + 3; // two header lines occupy lines 1-2
324
+
325
+ const tokens = tokenizeRow(line);
326
+ if (tokens.length === 0) {
327
+ return {
328
+ ok: false,
329
+ refusal: makeRefusal("malformed-row", `prof flat: line ${lineNumber} is blank where a data row was expected`, line, lineNumber),
330
+ };
331
+ }
332
+
333
+ const percentIdxs: number[] = [];
334
+ for (let t = 0; t < tokens.length; t++) {
335
+ if (tokens[t]!.endsWith("%")) percentIdxs.push(t);
336
+ }
337
+ if (percentIdxs.length !== 2) {
338
+ return {
339
+ ok: false,
340
+ refusal: makeRefusal(
341
+ "malformed-row",
342
+ `prof flat: line ${lineNumber} does not carry exactly two percentage fields: ${JSON.stringify(line)}`,
343
+ line,
344
+ lineNumber,
345
+ ),
346
+ };
347
+ }
348
+ const [p1, p2] = percentIdxs as [number, number];
349
+
350
+ const totalCyclesTokens = tokens.slice(0, p1);
351
+ const selfCyclesTokens = tokens.slice(p1 + 1, p2);
352
+ const addressTokens = tokens.slice(p2 + 1);
353
+
354
+ if (addressTokens.length !== 1) {
355
+ return {
356
+ ok: false,
357
+ refusal: makeRefusal(
358
+ "malformed-row",
359
+ `prof flat: line ${lineNumber} does not carry exactly one address field after the second percentage: ${JSON.stringify(line)}`,
360
+ line,
361
+ lineNumber,
362
+ ),
363
+ };
364
+ }
365
+ const addressToken = addressTokens[0]!;
366
+ // "ROOT" (exact, case-sensitive) is VICE's own synthetic top-level
367
+ // pseudo-frame -- MEASURED live (plan 42-09), see FlatProfileEntry's own
368
+ // doc comment. Checked before the hex-digit shape so a genuine ROOT row
369
+ // is never misrouted through the hex-address refusal below.
370
+ const isRootPseudoFrame = addressToken === "ROOT";
371
+ if (!isRootPseudoFrame && !/^[0-9a-fA-F]{4}$/.test(addressToken)) {
372
+ return {
373
+ ok: false,
374
+ refusal: makeRefusal(
375
+ "malformed-row",
376
+ `prof flat: line ${lineNumber} has an address field that is not four hex digits (and is not the literal ` +
377
+ `"ROOT" pseudo-frame): ${JSON.stringify(addressToken)}`,
378
+ line,
379
+ lineNumber,
380
+ ),
381
+ };
382
+ }
383
+
384
+ const totalCyclesResult = parseNumericField(totalCyclesTokens);
385
+ if (!totalCyclesResult.ok) {
386
+ return {
387
+ ok: false,
388
+ refusal: makeRefusal(
389
+ totalCyclesResult.code,
390
+ `prof flat: line ${lineNumber}'s total-cycles field ${JSON.stringify(totalCyclesResult.detail)} is ` +
391
+ (totalCyclesResult.code === "unrecognised-separator"
392
+ ? "split by an ASCII space rather than the narrow no-break space (U+202F) thousands separator"
393
+ : "not a recognised numeric field"),
394
+ line,
395
+ lineNumber,
396
+ ),
397
+ };
398
+ }
399
+ const selfCyclesResult = parseNumericField(selfCyclesTokens);
400
+ if (!selfCyclesResult.ok) {
401
+ return {
402
+ ok: false,
403
+ refusal: makeRefusal(
404
+ selfCyclesResult.code,
405
+ `prof flat: line ${lineNumber}'s self-cycles field ${JSON.stringify(selfCyclesResult.detail)} is ` +
406
+ (selfCyclesResult.code === "unrecognised-separator"
407
+ ? "split by an ASCII space rather than the narrow no-break space (U+202F) thousands separator"
408
+ : "not a recognised numeric field"),
409
+ line,
410
+ lineNumber,
411
+ ),
412
+ };
413
+ }
414
+
415
+ const totalPercentToken = tokens[p1]!;
416
+ const totalPercentResult = parsePercentField(totalPercentToken);
417
+ if (!totalPercentResult.ok) {
418
+ return {
419
+ ok: false,
420
+ refusal: makeRefusal(
421
+ "unrecognised-percentage",
422
+ `prof flat: line ${lineNumber}'s total-percent field ${JSON.stringify(totalPercentToken)} does not match ` +
423
+ "digits, a comma or period decimal separator, digits, then a trailing %",
424
+ line,
425
+ lineNumber,
426
+ ),
427
+ };
428
+ }
429
+ const selfPercentToken = tokens[p2]!;
430
+ const selfPercentResult = parsePercentField(selfPercentToken);
431
+ if (!selfPercentResult.ok) {
432
+ return {
433
+ ok: false,
434
+ refusal: makeRefusal(
435
+ "unrecognised-percentage",
436
+ `prof flat: line ${lineNumber}'s self-percent field ${JSON.stringify(selfPercentToken)} does not match ` +
437
+ "digits, a comma or period decimal separator, digits, then a trailing %",
438
+ line,
439
+ lineNumber,
440
+ ),
441
+ };
442
+ }
443
+
444
+ if (decimalSeparator === undefined) {
445
+ decimalSeparator = totalPercentResult.separator;
446
+ }
447
+
448
+ entries.push({
449
+ totalCycles: totalCyclesResult.value,
450
+ totalPercent: totalPercentResult.value,
451
+ selfCycles: selfCyclesResult.value,
452
+ selfPercent: selfPercentResult.value,
453
+ address: isRootPseudoFrame ? "ROOT" : parseInt(addressToken, 16),
454
+ });
455
+ }
456
+
457
+ return { ok: true, value: { entries, decimalSeparator: decimalSeparator ?? "," } };
458
+ }