@henols/vice-mcp 0.1.9 → 0.1.11

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,636 @@
1
+ #!/usr/bin/env node
2
+ // stock-condition.ts
3
+ //
4
+ // The ONE place that builds a checkpoint-condition expression for stock
5
+ // VICE's binary monitor: a typed AST, a single canonical emitter that turns
6
+ // that AST into wire text, and the two input paths (a fork-compatible
7
+ // string and a structured object) that both funnel into it (D-09). No other
8
+ // module in this tree may construct condition text.
9
+ //
10
+ // WHY THIS FILE EXISTS: VICE's condition parser has three independent traps
11
+ // that each produce a condition that is always false, with NO diagnostic
12
+ // over the socket -- only error code 0x8f, with no body. (1) No operator
13
+ // precedence at all (mon_parse.y:168), so a naive `RL == $64 && CY == $14`
14
+ // parses as `(((RL==$64) && CY) == $14)`, always false. (2) Bare integer
15
+ // literals are read as HEX by default (monitor.c:1597), so `RL == 100`
16
+ // silently means raster line 256, not 100. (3) The pseudo-registers are the
17
+ // uppercase-only tokens `RL`/`CY`, NOT the REGISTERS_GET names `LIN`/`CYC`,
18
+ // which lex as BANKNAME in COND_MODE and fail with error 0x8f and no socket
19
+ // diagnostic. String concatenation at a call site is exactly how all three
20
+ // ship in practice -- there is nothing at the call site to stop it. This
21
+ // file exists to make that class of bug structurally unreachable rather
22
+ // than merely discouraged: a typed AST plus one emitter that always
23
+ // over-parenthesises, always emits `$hex`, and always uppercases RL/CY.
24
+ //
25
+ // WHAT NOT TO DO:
26
+ // - Never string-concatenate a condition -- that is exactly how a
27
+ // silently-always-false condition ships (unparenthesised `&&`, decimal
28
+ // literal, wrong-case register); D-09/D-10 exist to make this class of
29
+ // bug structurally unreachable.
30
+ // - Never add a second emitter, a "minimal parens" mode, or a fast path
31
+ // that skips emitCondition()'s range/kind validation. Because VICE has
32
+ // no operator precedence at all, over-parenthesising is the ONLY safe
33
+ // emission -- there is no such thing as an unnecessary paren here.
34
+ // - Never trust a caller's AST as already-valid. emitCondition() is the
35
+ // last gate before the wire and re-validates every literal and every
36
+ // kind itself, even though parseConditionString() and conditionFromJson()
37
+ // also validate on the way in.
38
+ // - Phase 6's GAIN-06 extends this AST with raster semantics (finer-grained
39
+ // raster/cycle conditions) rather than replacing it or adding a second,
40
+ // parallel condition-building path. Any future raster work grows this
41
+ // module's types, it does not fork them.
42
+ //
43
+ // This module has no handlers and no dispatch entries -- a later plan
44
+ // consumes emitCondition()'s output as the only thing a condition-set
45
+ // request body ever receives. It is a pure transform: it never resolves a
46
+ // session, never touches a socket, never imports anything from the
47
+ // session/session-handler layer.
48
+
49
+ import { ViceError } from "./vice.ts";
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Errors
53
+ // ---------------------------------------------------------------------------
54
+
55
+ export interface StockConditionErrorOptions {
56
+ /** For conditionFromJson(): the offending field path, e.g. "condition.left.op". */
57
+ path?: string;
58
+ /** For parseConditionString(): the offending token/substring, verbatim. */
59
+ token?: string;
60
+ }
61
+
62
+ /**
63
+ * Raised by every validation/refusal path in this module: emitCondition()'s
64
+ * own re-validation, parseConditionString()'s six named traps, and
65
+ * conditionFromJson()'s narrowing refusals. Always carries an explanation
66
+ * naming the correct form -- never a bare "syntax error" (D-09).
67
+ */
68
+ export class StockConditionError extends ViceError {
69
+ path?: string;
70
+ token?: string;
71
+
72
+ constructor(message: string, { path, token }: StockConditionErrorOptions = {}) {
73
+ super(message);
74
+ this.name = "StockConditionError";
75
+ this.path = path;
76
+ this.token = token;
77
+ }
78
+ }
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // The typed AST
82
+ // ---------------------------------------------------------------------------
83
+
84
+ export type ConditionRegister = "A" | "X" | "Y" | "SP" | "PC" | "FL";
85
+
86
+ /** The condition-grammar-only pseudo-registers -- raster line and cycle
87
+ * within line. Deliberately NOT named LIN/CYC (the REGISTERS_GET names);
88
+ * see the header comment's trap (3). */
89
+ export type ConditionPseudo = "RL" | "CY";
90
+
91
+ export type ConditionOperand =
92
+ | { kind: "register"; name: ConditionRegister }
93
+ | { kind: "pseudo"; name: ConditionPseudo }
94
+ | { kind: "literal"; value: number };
95
+
96
+ export type ConditionOp = "==" | "!=" | "<" | ">" | "<=" | ">=";
97
+
98
+ export type ConditionNode =
99
+ | { kind: "comparison"; left: ConditionOperand; op: ConditionOp; right: ConditionOperand }
100
+ | { kind: "and"; left: ConditionNode; right: ConditionNode }
101
+ | { kind: "or"; left: ConditionNode; right: ConditionNode };
102
+
103
+ const REGISTER_NAMES: readonly string[] = ["A", "X", "Y", "SP", "PC", "FL"];
104
+ const PSEUDO_NAMES: readonly string[] = ["RL", "CY"];
105
+ const CONDITION_OPS: readonly string[] = ["==", "!=", "<", ">", "<=", ">="];
106
+
107
+ /** 312 PAL raster lines, 0-indexed -- the largest legal RL comparison value. */
108
+ const RASTER_LINE_MAX = 0x138;
109
+ /** 63 cycles per raster line, 0-indexed -- the largest legal CY comparison value. */
110
+ const CYCLE_MAX = 0x3f;
111
+
112
+ /** D-09/T-3-04: a pathological nested object or an absurd chain of
113
+ * comparisons must not be able to blow the 255-byte wire limit or the
114
+ * parser's own stack. Both input paths cap at the same numbers. */
115
+ const MAX_CONDITION_DEPTH = 8;
116
+ const MAX_COMPARISON_COUNT = 8;
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // The canonical emitter -- the ONE function that ever produces condition
120
+ // wire text. Every rule below is structural, not a style preference: because
121
+ // VICE's condition grammar has no operator precedence at all, there is no
122
+ // "minimal parens" mode, and because bare integers are hex by default, there
123
+ // is no bare-decimal emission path.
124
+ // ---------------------------------------------------------------------------
125
+
126
+ /** Formats a validated literal as `$` + lowercase hex, zero-padded to 2
127
+ * digits for values <= 0xff and to 4 digits for values <= 0xffff. Never a
128
+ * bare decimal, never `0x`, never uppercase hex digits -- one deterministic
129
+ * form. Re-validates range itself; this is the last gate before the wire. */
130
+ function formatLiteral(value: number): string {
131
+ if (!Number.isInteger(value) || value < 0 || value > 0xffff) {
132
+ throw new StockConditionError(
133
+ `condition literal ${value} is out of range -- must be an integer between 0 and 0xffff (65535) inclusive`,
134
+ );
135
+ }
136
+ const width = value <= 0xff ? 2 : 4;
137
+ return `$${value.toString(16).padStart(width, "0")}`;
138
+ }
139
+
140
+ /** RL/CY comparisons get an additional range check on top of the general
141
+ * 0..0xffff literal check: a raster-line or cycle value outside the real
142
+ * hardware range can never be true, and would otherwise arm a checkpoint
143
+ * that looks valid but never fires -- the same "silently always false"
144
+ * failure mode D-09 exists to prevent, just from a different trap. */
145
+ function checkPseudoLiteralRange(pseudo: ConditionPseudo, literalValue: number): void {
146
+ if (pseudo === "RL" && literalValue > RASTER_LINE_MAX) {
147
+ throw new StockConditionError(
148
+ `RL (raster line) literal ${formatLiteral(literalValue)} exceeds the maximum 0x138 (312 PAL raster ` +
149
+ `lines, 0-indexed) -- VICE's condition lexer reads bare integers as hex by default (monitor.c:1597), ` +
150
+ `so double-check the intended raster line before widening this condition`,
151
+ );
152
+ }
153
+ if (pseudo === "CY" && literalValue > CYCLE_MAX) {
154
+ throw new StockConditionError(
155
+ `CY (cycle within line) literal ${formatLiteral(literalValue)} exceeds the maximum 0x3f (63 cycles per ` +
156
+ `line, 0-indexed) -- VICE's condition lexer reads bare integers as hex by default (monitor.c:1597), ` +
157
+ `so double-check the intended cycle before widening this condition`,
158
+ );
159
+ }
160
+ }
161
+
162
+ function emitOperand(operand: ConditionOperand): string {
163
+ switch (operand.kind) {
164
+ case "register":
165
+ return operand.name;
166
+ case "pseudo":
167
+ return operand.name;
168
+ case "literal":
169
+ return formatLiteral(operand.value);
170
+ default: {
171
+ const exhaustive: never = operand;
172
+ throw new StockConditionError(`condition operand has an unrecognised kind: ${JSON.stringify(exhaustive)}`);
173
+ }
174
+ }
175
+ }
176
+
177
+ function emitComparisonNode(node: Extract<ConditionNode, { kind: "comparison" }>): string {
178
+ const leftText = emitOperand(node.left);
179
+ const rightText = emitOperand(node.right);
180
+ if (node.left.kind === "pseudo" && node.right.kind === "literal") {
181
+ checkPseudoLiteralRange(node.left.name, node.right.value);
182
+ }
183
+ if (node.right.kind === "pseudo" && node.left.kind === "literal") {
184
+ checkPseudoLiteralRange(node.right.name, node.left.value);
185
+ }
186
+ // A comparison always emits its own parentheses -- never bare. See the
187
+ // header comment: there is no operator precedence, so over-parenthesising
188
+ // is the only safe emission.
189
+ return `(${leftText} ${node.op} ${rightText})`;
190
+ }
191
+
192
+ /**
193
+ * The ONE function in this tree that ever produces condition wire text.
194
+ * Both parseConditionString() and conditionFromJson() only ever build the
195
+ * AST above -- this is the sole place that turns it into bytes-on-the-wire
196
+ * text, and it re-validates every literal and every kind itself rather than
197
+ * trusting its caller (it is the last gate before the wire).
198
+ *
199
+ * A comparison emits `(<left> <op> <right>)` -- always its own parentheses.
200
+ * An and/or emits `(<left> && <right>)` / `(<left> || <right>)` -- also
201
+ * always its own parentheses. So the worked trap-avoidance example
202
+ * `{ kind: "and", left: { kind: "comparison", left: { kind: "pseudo", name: "RL" }, op: "==", right: { kind: "literal", value: 0x64 } }, right: { kind: "comparison", left: { kind: "pseudo", name: "CY" }, op: "==", right: { kind: "literal", value: 0x14 } } }`
203
+ * emits exactly `((RL == $64) && (CY == $14))`.
204
+ */
205
+ export function emitCondition(node: ConditionNode): string {
206
+ switch (node.kind) {
207
+ case "comparison":
208
+ return emitComparisonNode(node);
209
+ case "and":
210
+ return `(${emitCondition(node.left)} && ${emitCondition(node.right)})`;
211
+ case "or":
212
+ return `(${emitCondition(node.left)} || ${emitCondition(node.right)})`;
213
+ default: {
214
+ const exhaustive: never = node;
215
+ throw new StockConditionError(`condition node has an unrecognised kind: ${JSON.stringify(exhaustive)}`);
216
+ }
217
+ }
218
+ }
219
+
220
+ // ---------------------------------------------------------------------------
221
+ // conditionFromJson() -- D-09's structured-object input path
222
+ // ---------------------------------------------------------------------------
223
+
224
+ /** True iff `value` is a well-formed, generic JSON object -- not null, not
225
+ * an array. Matches this module tree's own isPlainObject() convention
226
+ * (vice.ts:310-316) -- redeclared privately here, not imported, per the
227
+ * established per-module convention. */
228
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
229
+ return typeof value === "object" && value !== null && !Array.isArray(value);
230
+ }
231
+
232
+ function narrowRegisterName(name: unknown, path: string): ConditionRegister {
233
+ if (typeof name !== "string") {
234
+ throw new StockConditionError(`${path} must be a string register name, got ${typeof name}`, { path });
235
+ }
236
+ const upper = name.toUpperCase();
237
+ if (upper === "LIN" || upper === "CYC") {
238
+ throw new StockConditionError(
239
+ `${path}: "${name}" is not a register -- the pseudo-registers are "RL" (raster line) and "CY" (cycle ` +
240
+ `within line), not "LIN"/"CYC" (those lex as BANKNAME in COND_MODE and fail with error 0x8f, no socket ` +
241
+ `diagnostic)`,
242
+ { path },
243
+ );
244
+ }
245
+ if (!REGISTER_NAMES.includes(upper)) {
246
+ throw new StockConditionError(
247
+ `${path}: "${name}" is not a recognised register -- must be one of ${REGISTER_NAMES.join(", ")}`,
248
+ { path },
249
+ );
250
+ }
251
+ if (name !== upper) {
252
+ throw new StockConditionError(
253
+ `${path}: register names must be uppercase -- use "${upper}", not "${name}"`,
254
+ { path },
255
+ );
256
+ }
257
+ return upper as ConditionRegister;
258
+ }
259
+
260
+ function narrowPseudoName(name: unknown, path: string): ConditionPseudo {
261
+ if (typeof name !== "string") {
262
+ throw new StockConditionError(`${path} must be a string pseudo-register name, got ${typeof name}`, { path });
263
+ }
264
+ const upper = name.toUpperCase();
265
+ if (upper === "LIN" || upper === "CYC") {
266
+ throw new StockConditionError(
267
+ `${path}: "${name}" is not a valid pseudo-register -- use "RL" (raster line) or "CY" (cycle within ` +
268
+ `line), not "LIN"/"CYC" (those lex as BANKNAME in COND_MODE and fail with error 0x8f, no socket ` +
269
+ `diagnostic)`,
270
+ { path },
271
+ );
272
+ }
273
+ if (!PSEUDO_NAMES.includes(upper)) {
274
+ throw new StockConditionError(
275
+ `${path}: "${name}" is not a recognised pseudo-register -- must be "RL" or "CY"`,
276
+ { path },
277
+ );
278
+ }
279
+ if (name !== upper) {
280
+ throw new StockConditionError(
281
+ `${path}: pseudo-register names must be uppercase -- use "${upper}", not "${name}"`,
282
+ { path },
283
+ );
284
+ }
285
+ return upper as ConditionPseudo;
286
+ }
287
+
288
+ function narrowOperand(value: unknown, path: string): ConditionOperand {
289
+ if (!isPlainObject(value)) {
290
+ throw new StockConditionError(
291
+ `${path} must be an object with a "kind" field ("register", "pseudo", or "literal")`,
292
+ { path },
293
+ );
294
+ }
295
+ switch (value.kind) {
296
+ case "register":
297
+ return { kind: "register", name: narrowRegisterName(value.name, `${path}.name`) };
298
+ case "pseudo":
299
+ return { kind: "pseudo", name: narrowPseudoName(value.name, `${path}.name`) };
300
+ case "literal": {
301
+ const literal = value.value;
302
+ if (typeof literal !== "number") {
303
+ throw new StockConditionError(
304
+ `${path}.value must be a number, got ${typeof literal}`,
305
+ { path: `${path}.value` },
306
+ );
307
+ }
308
+ return { kind: "literal", value: literal };
309
+ }
310
+ default:
311
+ throw new StockConditionError(
312
+ `${path}.kind is missing or unrecognised: ${JSON.stringify(value.kind)} -- must be "register", ` +
313
+ `"pseudo", or "literal"`,
314
+ { path: `${path}.kind` },
315
+ );
316
+ }
317
+ }
318
+
319
+ interface NarrowState {
320
+ comparisons: number;
321
+ }
322
+
323
+ function narrowConditionNode(value: unknown, path: string, depth: number, state: NarrowState): ConditionNode {
324
+ if (depth > MAX_CONDITION_DEPTH) {
325
+ throw new StockConditionError(
326
+ `${path}: condition nesting exceeds the maximum depth of ${MAX_CONDITION_DEPTH} -- refused to bound the ` +
327
+ `wire-frame size and the parser's own stack`,
328
+ { path },
329
+ );
330
+ }
331
+ if (!isPlainObject(value)) {
332
+ throw new StockConditionError(
333
+ `${path} must be an object with a "kind" field ("comparison", "and", or "or")`,
334
+ { path },
335
+ );
336
+ }
337
+ switch (value.kind) {
338
+ case "comparison": {
339
+ state.comparisons += 1;
340
+ if (state.comparisons > MAX_COMPARISON_COUNT) {
341
+ throw new StockConditionError(
342
+ `${path}: condition has more than ${MAX_COMPARISON_COUNT} comparisons -- refused to bound the ` +
343
+ `wire-frame size and the parser's own stack`,
344
+ { path },
345
+ );
346
+ }
347
+ const op = value.op;
348
+ if (typeof op !== "string" || !CONDITION_OPS.includes(op)) {
349
+ throw new StockConditionError(
350
+ `${path}.op: "${String(op)}" is not a recognised operator -- must be one of ${CONDITION_OPS.join(", ")}`,
351
+ { path: `${path}.op` },
352
+ );
353
+ }
354
+ return {
355
+ kind: "comparison",
356
+ left: narrowOperand(value.left, `${path}.left`),
357
+ op: op as ConditionOp,
358
+ right: narrowOperand(value.right, `${path}.right`),
359
+ };
360
+ }
361
+ case "and":
362
+ return {
363
+ kind: "and",
364
+ left: narrowConditionNode(value.left, `${path}.left`, depth + 1, state),
365
+ right: narrowConditionNode(value.right, `${path}.right`, depth + 1, state),
366
+ };
367
+ case "or":
368
+ return {
369
+ kind: "or",
370
+ left: narrowConditionNode(value.left, `${path}.left`, depth + 1, state),
371
+ right: narrowConditionNode(value.right, `${path}.right`, depth + 1, state),
372
+ };
373
+ default:
374
+ throw new StockConditionError(
375
+ `${path}.kind is missing or unrecognised: ${JSON.stringify(value.kind)} -- must be "comparison", ` +
376
+ `"and", or "or"`,
377
+ { path: `${path}.kind` },
378
+ );
379
+ }
380
+ }
381
+
382
+ /**
383
+ * D-09's structured-object input path. Narrows an untrusted parsed-JSON
384
+ * value into the AST above, refusing with a message that names the
385
+ * offending path (e.g. "condition.left.op") on: a missing or unknown
386
+ * `kind`, an unknown operator string, a lowercase register or pseudo name
387
+ * (naming the required uppercase form), `LIN`/`CYC` (naming `RL`/`CY` as the
388
+ * replacement), a literal that is a string rather than a number, and a
389
+ * nesting depth or comparison count over 8. Never emits text itself --
390
+ * emitCondition() remains the only producer of wire text.
391
+ */
392
+ export function conditionFromJson(value: unknown): ConditionNode {
393
+ return narrowConditionNode(value, "condition", 0, { comparisons: 0 });
394
+ }
395
+
396
+ // ---------------------------------------------------------------------------
397
+ // parseConditionString() -- D-09's fork-compatible string input path
398
+ // ---------------------------------------------------------------------------
399
+
400
+ const HEX_DOLLAR_RE = /^\$([0-9a-fA-F]+)$/;
401
+ const HEX_0X_RE = /^0[xX]([0-9a-fA-F]+)$/;
402
+ const DECIMAL_RE = /^[0-9]+$/;
403
+ // Operand tokens never contain whitespace (grammar restriction below), so a
404
+ // plain \S+ on each side of the operator is sufficient -- no need to track
405
+ // paren depth within a single comparison.
406
+ const COMPARISON_RE = /^(\S+)\s*(<=|>=|==|!=|<|>)\s*(\S+)$/;
407
+
408
+ /** Strips exactly one pair of parentheses that wrap the ENTIRE string (not
409
+ * just start with "(" and end with ")" -- "(A) && (B)" must NOT be
410
+ * unwrapped by this, since its first "(" closes well before the end). */
411
+ function stripFullyWrappingParens(s: string): string {
412
+ if (!(s.startsWith("(") && s.endsWith(")"))) return s;
413
+ let depth = 0;
414
+ for (let i = 0; i < s.length; i++) {
415
+ if (s[i] === "(") depth++;
416
+ else if (s[i] === ")") {
417
+ depth--;
418
+ if (depth === 0 && i !== s.length - 1) return s;
419
+ }
420
+ }
421
+ return s.slice(1, -1).trim();
422
+ }
423
+
424
+ function assertBalancedParens(expr: string, original: string): void {
425
+ let depth = 0;
426
+ for (const ch of expr) {
427
+ if (ch === "(") depth++;
428
+ else if (ch === ")") {
429
+ depth--;
430
+ if (depth < 0) {
431
+ throw new StockConditionError(
432
+ `"${original}" has unbalanced parentheses -- an extra ")" appears with no matching "("`,
433
+ );
434
+ }
435
+ }
436
+ }
437
+ if (depth !== 0) {
438
+ throw new StockConditionError(
439
+ `"${original}" has unbalanced parentheses -- ${depth} unmatched "("`,
440
+ );
441
+ }
442
+ }
443
+
444
+ /** Finds every depth-0 occurrence of `joiner` ("&&" or "||") in `s`, so a
445
+ * multi-comparison expression can be split at the boundaries between its
446
+ * individually-parenthesised comparisons without being fooled by a "&&"
447
+ * that appears nested inside one of them (it cannot today, since operands
448
+ * never contain "&&", but the scan is depth-aware regardless -- the same
449
+ * discipline as never assuming a wire frame's shape without checking it). */
450
+ function findTopLevelJoins(s: string, joiner: "&&" | "||"): number[] {
451
+ const positions: number[] = [];
452
+ let depth = 0;
453
+ for (let i = 0; i < s.length; i++) {
454
+ const ch = s[i];
455
+ if (ch === "(") depth++;
456
+ else if (ch === ")") depth--;
457
+ else if (depth === 0 && s.startsWith(joiner, i)) positions.push(i);
458
+ }
459
+ return positions;
460
+ }
461
+
462
+ /** Parses a single operand token -- a register name, a pseudo-register
463
+ * name, or a $hex/0x literal. This is where all three of the header
464
+ * comment's named traps are actually caught: LIN/CYC, wrong-case names, and
465
+ * bare-decimal literals. Refuses with a message naming the correct form,
466
+ * never a bare "syntax error" (D-09). */
467
+ function parseOperandToken(token: string): ConditionOperand {
468
+ const dollarMatch = HEX_DOLLAR_RE.exec(token);
469
+ const zeroXMatch = HEX_0X_RE.exec(token);
470
+ const hexMatch = dollarMatch ?? zeroXMatch;
471
+ if (hexMatch) {
472
+ return { kind: "literal", value: parseInt(hexMatch[1], 16) };
473
+ }
474
+ if (DECIMAL_RE.test(token)) {
475
+ // Trap (2): bare integer literals are read as HEX by default
476
+ // (monitor.c:1597), so "RL == 100" would silently mean line 256 --
477
+ // the author's intent cannot be recovered, so this refuses rather
478
+ // than guesses.
479
+ throw new StockConditionError(
480
+ `"${token}" is a bare decimal literal -- VICE's condition lexer reads bare integers as hex by default ` +
481
+ `(monitor.c:1597), so it cannot recover whether you meant decimal ${token} or hex $${token}; write ` +
482
+ `"$${token}" or "0x${token}" explicitly to say which one you mean`,
483
+ { token },
484
+ );
485
+ }
486
+ const upper = token.toUpperCase();
487
+ if (upper === "LIN" || upper === "CYC") {
488
+ // Trap (3): LIN/CYC lex as BANKNAME in COND_MODE and fail with error
489
+ // 0x8f, with no socket diagnostic.
490
+ throw new StockConditionError(
491
+ `"${token}" is not a valid pseudo-register -- use "RL" (raster line) or "CY" (cycle within line); ` +
492
+ `"LIN"/"CYC" lex as BANKNAME in COND_MODE and fail with error 0x8f (no socket diagnostic)`,
493
+ { token },
494
+ );
495
+ }
496
+ if (REGISTER_NAMES.includes(upper)) {
497
+ if (token !== upper) {
498
+ throw new StockConditionError(
499
+ `register/pseudo names must be uppercase -- use "${upper}", not "${token}"`,
500
+ { token },
501
+ );
502
+ }
503
+ return { kind: "register", name: upper as ConditionRegister };
504
+ }
505
+ if (PSEUDO_NAMES.includes(upper)) {
506
+ if (token !== upper) {
507
+ throw new StockConditionError(
508
+ `register/pseudo names must be uppercase -- use "${upper}", not "${token}"`,
509
+ { token },
510
+ );
511
+ }
512
+ return { kind: "pseudo", name: upper as ConditionPseudo };
513
+ }
514
+ throw new StockConditionError(
515
+ `"${token}" is not a recognised operand -- expected a register (${REGISTER_NAMES.join(", ")}), a ` +
516
+ `pseudo-register (${PSEUDO_NAMES.join(", ")}), or a $hex/0x literal`,
517
+ { token },
518
+ );
519
+ }
520
+
521
+ function parseSingleComparison(text: string, originalExpr: string): ConditionNode {
522
+ const trimmed = text.trim();
523
+ if (trimmed === "") {
524
+ throw new StockConditionError(`"${originalExpr}" contains an empty comparison`);
525
+ }
526
+ const unwrapped = stripFullyWrappingParens(trimmed);
527
+ const match = COMPARISON_RE.exec(unwrapped);
528
+ if (!match) {
529
+ throw new StockConditionError(
530
+ `"${originalExpr}" is not a recognised comparison -- expected "OPERAND OP OPERAND" (e.g. "A == $42"), ` +
531
+ `operators are ==, !=, <, >, <=, >=`,
532
+ );
533
+ }
534
+ const [, leftTok, op, rightTok] = match;
535
+ return {
536
+ kind: "comparison",
537
+ left: parseOperandToken(leftTok),
538
+ op: op as ConditionOp,
539
+ right: parseOperandToken(rightTok),
540
+ };
541
+ }
542
+
543
+ /**
544
+ * D-09's fork-compatible input path. Parses into the SAME AST
545
+ * conditionFromJson() produces and returns it; it never emits text itself,
546
+ * so emitCondition() remains the only producer of wire text.
547
+ *
548
+ * Accepted input, deliberately narrow (widening this grammar is Phase 6's
549
+ * GAIN-06 decision, not a maintenance liberty -- do not implement a general
550
+ * expression parser):
551
+ * - a single comparison, with or without surrounding parentheses:
552
+ * "A == $42", "(PC == $c000)", "SP <= $ff", "RL == $64"
553
+ * - a conjunction/disjunction where EVERY comparison is individually
554
+ * parenthesised: "(RL == $64) && (CY == $14)", "(A == $42) || (X == $01)",
555
+ * and the same with outer parentheses present
556
+ * - operand forms: an uppercase register name, an uppercase pseudo name,
557
+ * or a $hex / 0x literal, on either side of the operator
558
+ * - arbitrary internal whitespace around operators and parentheses;
559
+ * leading and trailing whitespace trimmed
560
+ * - operators ==, !=, <, >, <=, >=
561
+ *
562
+ * Refuses (StockConditionError, message names the offending token AND the
563
+ * correct form, never a bare "syntax error"): LIN/CYC anywhere; a lowercase
564
+ * or mixed-case register/pseudo name; a bare decimal literal; a
565
+ * multi-comparison expression where any comparison is not individually
566
+ * parenthesised (no operator precedence exists, so this would silently
567
+ * mis-parse); a value out of range for its operand (delegated to
568
+ * emitCondition()'s own range checks, same message); an empty string,
569
+ * unknown operator, unbalanced parentheses, more than 8 comparisons, or any
570
+ * token outside the grammar above.
571
+ */
572
+ export function parseConditionString(expr: string): ConditionNode {
573
+ const trimmed = expr.trim();
574
+ if (trimmed === "") {
575
+ throw new StockConditionError(`condition string is empty -- provide at least one comparison, e.g. "A == $42"`);
576
+ }
577
+
578
+ assertBalancedParens(trimmed, expr);
579
+
580
+ const unwrapped = stripFullyWrappingParens(trimmed);
581
+
582
+ const andPositions = findTopLevelJoins(unwrapped, "&&");
583
+ const orPositions = findTopLevelJoins(unwrapped, "||");
584
+ if (andPositions.length > 0 && orPositions.length > 0) {
585
+ throw new StockConditionError(
586
+ `"${expr}" mixes && and || in one string -- build this as a nested structured condition object instead ` +
587
+ `of a mixed string`,
588
+ );
589
+ }
590
+
591
+ const joiner: "&&" | "||" | null =
592
+ andPositions.length > 0 ? "&&" : orPositions.length > 0 ? "||" : null;
593
+
594
+ if (joiner === null) {
595
+ return parseSingleComparison(unwrapped, expr);
596
+ }
597
+
598
+ const positions = joiner === "&&" ? andPositions : orPositions;
599
+ const parts: string[] = [];
600
+ let start = 0;
601
+ for (const pos of positions) {
602
+ parts.push(unwrapped.slice(start, pos).trim());
603
+ start = pos + joiner.length;
604
+ }
605
+ parts.push(unwrapped.slice(start).trim());
606
+
607
+ if (parts.length > MAX_COMPARISON_COUNT) {
608
+ throw new StockConditionError(
609
+ `"${expr}" has ${parts.length} comparisons, exceeding the maximum of ${MAX_COMPARISON_COUNT} -- refused ` +
610
+ `to bound the wire-frame size and the parser's own stack`,
611
+ );
612
+ }
613
+
614
+ const nodes: ConditionNode[] = parts.map((part) => {
615
+ const isIndividuallyParenthesised =
616
+ part.startsWith("(") && part.endsWith(")") && stripFullyWrappingParens(part) !== part;
617
+ if (!isIndividuallyParenthesised) {
618
+ // Trap (1): no operator precedence at all (mon_parse.y:168). An
619
+ // unparenthesised multi-comparison expression parses left-to-right
620
+ // with no boolean grouping and is always false.
621
+ throw new StockConditionError(
622
+ `"${expr}" has no operator precedence (mon_parse.y:168) -- an unparenthesised multi-comparison ` +
623
+ `expression parses left-to-right with no boolean grouping (the canonical trap: ` +
624
+ `"RL == $64 && CY == $14" parses as "(((RL==$64) && CY) == $14)", always false); parenthesise ` +
625
+ `every comparison individually, e.g. "(RL == $64) && (CY == $14)"`,
626
+ );
627
+ }
628
+ return parseSingleComparison(part, expr);
629
+ });
630
+
631
+ let combined: ConditionNode = nodes[0];
632
+ for (let i = 1; i < nodes.length; i++) {
633
+ combined = { kind: joiner === "&&" ? "and" : "or", left: combined, right: nodes[i] };
634
+ }
635
+ return combined;
636
+ }