@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.
- package/README.md +15 -0
- package/THIRD-PARTY-NOTICES.md +113 -0
- package/backend-detect.mts +595 -0
- package/build.ts +1 -0
- package/disasm-decoder.ts +248 -0
- package/disasm-opcodes.ts +464 -0
- package/disasm-renderer.ts +306 -0
- package/package.json +27 -2
- package/refresh-manifest.ts +23 -2
- package/resources/backend-detect.mjs +396 -0
- package/resources/broker-control.mjs +110 -8
- package/resources/broker-kill.mjs +114 -103
- package/resources/broker-launch.mjs +334 -19
- package/resources/broker-state.mjs +11 -0
- package/resources/vice-broker.mjs +185 -14
- package/stock-address.ts +219 -0
- package/stock-checkpoints.ts +794 -0
- package/stock-condition.ts +636 -0
- package/stock-connect.ts +427 -0
- package/stock-derived.ts +122 -0
- package/stock-disassemble.ts +252 -0
- package/stock-dispatch.ts +640 -0
- package/stock-execution.ts +327 -0
- package/stock-handler.ts +175 -0
- package/stock-input.ts +274 -0
- package/stock-machine.ts +357 -0
- package/stock-memory.ts +323 -0
- package/stock-paths.ts +191 -0
- package/stock-petscii.ts +143 -0
- package/stock-protocol.ts +2057 -0
- package/stock-registers.ts +324 -0
- package/stock-runstate.ts +104 -0
- package/tools-manifest.json +1 -9
- package/tools-manifest.stock.json +841 -0
- package/vice-broker-client.ts +233 -7
- package/vice-proxy.ts +202 -17
|
@@ -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
|
+
}
|