bmweb-cli 0.1.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.
Files changed (72) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +351 -0
  3. package/dist/bmweb.js +2790 -0
  4. package/package.json +53 -0
  5. package/runtime/core/bestvm/codec.js +285 -0
  6. package/runtime/core/bestvm/environment.js +116 -0
  7. package/runtime/core/bestvm/executor.js +1483 -0
  8. package/runtime/core/bestvm/index.js +52 -0
  9. package/runtime/core/bestvm/machine.js +491 -0
  10. package/runtime/core/bestvm/operands.js +356 -0
  11. package/runtime/core/bestvm/registers.js +152 -0
  12. package/runtime/core/bestvm/write-guard.js +111 -0
  13. package/runtime/core/ipofile/compile.js +364 -0
  14. package/runtime/core/ipofile/decls.js +187 -0
  15. package/runtime/core/ipofile/emit.js +708 -0
  16. package/runtime/core/ipofile/exec.js +164 -0
  17. package/runtime/core/ipofile/lex.js +243 -0
  18. package/runtime/core/ipofile/parse.js +550 -0
  19. package/runtime/core/ipofile/pool.js +404 -0
  20. package/runtime/core/ipofile/walk.js +431 -0
  21. package/runtime/core/ipovm/builtin-helpers.js +182 -0
  22. package/runtime/core/ipovm/builtins-api.js +610 -0
  23. package/runtime/core/ipovm/builtins-screen.js +493 -0
  24. package/runtime/core/ipovm/builtins-table.js +166 -0
  25. package/runtime/core/ipovm/builtins-text.js +166 -0
  26. package/runtime/core/ipovm/emissions.js +138 -0
  27. package/runtime/core/ipovm/hosts.js +191 -0
  28. package/runtime/core/ipovm/operators.js +229 -0
  29. package/runtime/core/ipovm/structures.js +250 -0
  30. package/runtime/core/ipovm/suspensions.js +241 -0
  31. package/runtime/core/ipovm/tape.js +206 -0
  32. package/runtime/core/ipovm/values.js +241 -0
  33. package/runtime/core/ipovm/vm.js +1166 -0
  34. package/runtime/core/translate.js +526 -0
  35. package/runtime/core/webshim/api-router.js +592 -0
  36. package/runtime/core/webshim/bus.js +95 -0
  37. package/runtime/core/webshim/coding.js +82 -0
  38. package/runtime/core/webshim/data-fetch.js +66 -0
  39. package/runtime/core/webshim/exchange.js +288 -0
  40. package/runtime/core/webshim/framing.js +331 -0
  41. package/runtime/core/webshim/install.js +30 -0
  42. package/runtime/core/webshim/job-runner.js +319 -0
  43. package/runtime/core/webshim/native-bus.js +108 -0
  44. package/runtime/core/webshim/timers.js +82 -0
  45. package/runtime/core/webshim/trace.js +205 -0
  46. package/runtime/core/webshim/transport-base.js +128 -0
  47. package/runtime/core/webshim/variant-resolver.js +249 -0
  48. package/runtime/core/webshim/web-serial-bus.js +734 -0
  49. package/runtime/home/bmweb-home.ips +76 -0
  50. package/runtime/home/bmweb.h +26 -0
  51. package/runtime/screens/activations.js +258 -0
  52. package/runtime/screens/garage/diff.js +331 -0
  53. package/runtime/screens/garage/share.js +276 -0
  54. package/runtime/screens/garage/store.js +547 -0
  55. package/runtime/screens/ipo-runtime/cells.js +176 -0
  56. package/runtime/screens/ipo-runtime/dialogs.js +254 -0
  57. package/runtime/screens/ipo-runtime/home.js +358 -0
  58. package/runtime/screens/ipo-runtime/open.js +393 -0
  59. package/runtime/screens/ipo-runtime/paint-grid.js +106 -0
  60. package/runtime/screens/ipo-runtime/paint-modern.js +424 -0
  61. package/runtime/screens/ipo-runtime/print.js +281 -0
  62. package/runtime/screens/ipo-runtime/program.js +1337 -0
  63. package/runtime/screens/ipo-runtime/protocol.js +464 -0
  64. package/runtime/screens/ipo-runtime/script-scan.js +225 -0
  65. package/runtime/screens/ipo-runtime/translate-sets.js +130 -0
  66. package/runtime/screens/ipo-runtime/ui.js +249 -0
  67. package/runtime/screens/ipo-runtime/wire-policy.js +113 -0
  68. package/runtime/screens/ir.js +324 -0
  69. package/runtime/screens/search/data.js +153 -0
  70. package/runtime/screens/search/match.js +285 -0
  71. package/runtime/screens/search/open.js +66 -0
  72. package/runtime/vendor/fflate.min.js +1 -0
@@ -0,0 +1,1483 @@
1
+ /**
2
+ * @file The executor: the opcode `step` switch and the flag arithmetic it
3
+ * leans on. Extends Best2Vm (machine.js).
4
+ *
5
+ * THE SWITCH IS A 1:1 OPCODE TABLE, on purpose. One `case` per BEST2
6
+ * opcode, in the reference's grouping, each carrying the engine fact that
7
+ * makes it right. It is not a dispatch table and not per-opcode methods:
8
+ * the value of reading an opcode's semantics in one place, next to its
9
+ * neighbours, outweighs the length. Helpers and tables live AROUND it
10
+ * (this file's top, operands.js, environment.js); the case bodies keep
11
+ * their order and semantics.
12
+ */
13
+
14
+ if (typeof require === 'function' && typeof module !== 'undefined') {
15
+ Object.assign(
16
+ globalThis,
17
+ require('./machine.js'),
18
+ require('./operands.js'),
19
+ require('./environment.js')
20
+ );
21
+ }
22
+
23
+ /**
24
+ * Which flag combination each conditional jump tests (EdOperations' jump
25
+ * handlers). Named exactly as the disassembler emits them. jt/jnt are
26
+ * handled in step(): they test the TRAP REGISTER with an optional bit
27
+ * selector, not a boolean flag.
28
+ * @type {Object<string, (f: import('./machine.js').VmFlags) => boolean>}
29
+ */
30
+ const JUMP_TESTS = {
31
+ jz: (f) => f.zero,
32
+ jnz: (f) => !f.zero,
33
+ jc: (f) => f.carry,
34
+ jnc: (f) => !f.carry,
35
+ jae: (f) => !f.carry,
36
+ jbe: (f) => f.carry || f.zero,
37
+ ja: (f) => !f.carry && !f.zero,
38
+ jb: (f) => f.carry,
39
+ jmi: (f) => f.sign,
40
+ jpl: (f) => !f.sign,
41
+ jv: (f) => f.overflow,
42
+ jnv: (f) => !f.overflow,
43
+ jg: (f) => !f.zero && f.sign === f.overflow,
44
+ jge: (f) => f.sign === f.overflow,
45
+ jl: (f) => f.sign !== f.overflow,
46
+ jle: (f) => f.zero || f.sign !== f.overflow,
47
+ };
48
+
49
+ /**
50
+ * Each erg* opcode has a FIXED width and signedness, independent of the
51
+ * operand's own type (the reference's result table). Publishing ergi
52
+ * unsigned reported SMG2's coolant temperature as 65531 where the engine
53
+ * says -5.
54
+ * @type {Object<string, [number, boolean]>} opcode -> [width, signed]
55
+ */
56
+ const ERG_SPECS = {
57
+ ergb: [1, false],
58
+ ergw: [2, false],
59
+ ergd: [4, false],
60
+ ergi: [2, true],
61
+ ergl: [4, true],
62
+ };
63
+
64
+ /**
65
+ * Bits of the flags WORD that `pushf` writes and `popf` reads.
66
+ */
67
+ const FLAG_CARRY = 1;
68
+ const FLAG_ZERO = 2;
69
+ const FLAG_SIGN = 4;
70
+ const FLAG_OVERFLOW = 8;
71
+
72
+ /** `jt target, 32` aliases the unmapped trap bit 0. */
73
+ const TRAP_BIT_ALIAS_UNMAPPED = 32;
74
+
75
+ /**
76
+ * What `xtype` reports: EdInterfaceObd's name for the K+DCAN cable this
77
+ * app drives. SGBDs (carb) branch on it to pick their concept.
78
+ */
79
+ const INTERFACE_TYPE = 'OBD';
80
+
81
+ /**
82
+ * What `xvers` reports: EdInterfaceObd.InterfaceVersion, 209 (0xD1). Not
83
+ * the engine's 7.3.0 -- the INTERFACE's version, which is what the SGBD
84
+ * asks.
85
+ */
86
+ const INTERFACE_VERSION = 209;
87
+
88
+ /**
89
+ * A CommParameter blob DECLARES its own element width in byte 1
90
+ * (EdOperations.OpXsetpar): 0x00 = 16-bit words, 0x01 = 32-bit dwords,
91
+ * 0xFF = bytes; anything else is not a CommParameter.
92
+ * @type {Object<number, number>}
93
+ */
94
+ const COMM_PARAM_WIDTHS = { 0x00: 2, 0x01: 4, 0xff: 1 };
95
+
96
+ /** The largest protocol concept number a CommParameter blob can name. */
97
+ const COMM_CONCEPT_MAX = 0x1ff;
98
+
99
+ /** BEST/2 `wait` is in SECONDS; the transport pauses in milliseconds. */
100
+ const MS_PER_SECOND = 1000;
101
+
102
+ /**
103
+ * Reduce a value to the unsigned range of a width: `value mod 2^(8*width)`,
104
+ * non-negative even for a negative input.
105
+ * @param {number} value - The value.
106
+ * @param {number} width - The width in bytes.
107
+ * @returns {number} The wrapped value.
108
+ */
109
+ function wrapToWidth(value, width) {
110
+ const lim = 2 ** (8 * width);
111
+ return ((value % lim) + lim) % lim;
112
+ }
113
+
114
+ /**
115
+ * Two's-complement negation in FULL 32 bits, `(uint)(-value)` -- what the
116
+ * engine feeds SetOverflow for a subtraction.
117
+ * @param {number} value - An unsigned value.
118
+ * @returns {number} Its 32-bit negation, unsigned.
119
+ */
120
+ function negateU32(value) {
121
+ return (0x100000000 - value) % 0x100000000;
122
+ }
123
+
124
+ /**
125
+ * Read an unsigned 32-bit pattern as a signed integer.
126
+ * @param {number} value - The unsigned value.
127
+ * @returns {number} The signed value.
128
+ */
129
+ function toInt32(value) {
130
+ return value >= 0x80000000 ? value - 0x100000000 : value;
131
+ }
132
+
133
+ /**
134
+ * Zero-pad a number to two digits, for the `date`/`time` texts.
135
+ * @param {number} n - The number.
136
+ * @returns {string} Two or more digits.
137
+ */
138
+ function twoDigits(n) {
139
+ return String(n).padStart(2, '0');
140
+ }
141
+
142
+ Object.assign(Best2Vm.prototype, {
143
+ /**
144
+ * Flags.SetOverflow: only when the operands SHARE a sign that differs
145
+ * from the result's. Operands are compared at the operation width.
146
+ * @param {number} v1 - The first operand, unsigned at `width`.
147
+ * @param {number} v2 - The second operand, unsigned at `width`.
148
+ * @param {number} result - The raw (unwrapped) result.
149
+ * @param {number} width - The operation width in bytes.
150
+ * @returns {void}
151
+ */
152
+ setOverflow(v1, v2, result, width) {
153
+ const sm = 2 ** (8 * width - 1);
154
+ const s1 = (v1 & sm) !== 0,
155
+ s2 = (v2 & sm) !== 0;
156
+ const sr = (wrapToWidth(result, width) & sm) !== 0;
157
+ this.flags.overflow = s1 === s2 && s1 !== sr;
158
+ },
159
+
160
+ /**
161
+ * Set Zero and Sign from a result at a width.
162
+ * @param {number} value - The raw (unwrapped) result.
163
+ * @param {number} width - The width in bytes.
164
+ * @returns {void}
165
+ */
166
+ updateFlags(value, width) {
167
+ const masked = wrapToWidth(value, width);
168
+ this.flags.zero = masked === 0;
169
+ this.flags.sign = masked >= 2 ** (8 * width - 1);
170
+ },
171
+
172
+ /**
173
+ * Push a value as `n` bytes, LSB first, so the top of the stack is the
174
+ * most significant byte.
175
+ * @param {number} value - The value.
176
+ * @param {number} n - How many bytes.
177
+ * @returns {void}
178
+ */
179
+ pushBytes(value, n) {
180
+ let v = value;
181
+ for (let i = 0; i < n; i++) {
182
+ this.stack.push(v & 0xff);
183
+ v = Math.floor(v / 256);
184
+ }
185
+ },
186
+
187
+ /**
188
+ * Pop `n` bytes and rebuild the value: push wrote LSB first, so the MSB
189
+ * is on top, and popping MSB-first while shifting left reassembles
190
+ * exactly what was pushed. The caller checks the stack is deep enough.
191
+ * @param {number} n - How many bytes.
192
+ * @returns {number} The value.
193
+ */
194
+ popValue(n) {
195
+ let v = 0;
196
+ for (let i = 0; i < n; i++) v = v * 256 + this.stack.pop();
197
+ return v;
198
+ },
199
+
200
+ /**
201
+ * Execute one instruction.
202
+ * @param {string} name - The opcode name as sgbd_code.py emits it.
203
+ * @param {import('./machine.js').Operand[]} a - Its operands.
204
+ * @param {number} pc - Its index in `ops`, for error messages.
205
+ * @returns {number|symbol|undefined} The next `pc` for a taken jump,
206
+ * STOP at `eoj`, or undefined to fall through to `pc + 1`.
207
+ * @throws {VmError} An unimplemented opcode, an unresolved jump, a
208
+ * refused write, an oversized answer, or an `eerr`.
209
+ */
210
+ step(name, a, pc) {
211
+ const A = a[0],
212
+ B = a[1];
213
+ const f = this.flags;
214
+
215
+ switch (name) {
216
+ case 'nop':
217
+ return;
218
+ case 'eoj':
219
+ return STOP;
220
+
221
+ // ---- stack: BYTES, not values. push writes the operand's width,
222
+ // MSB last so the top of the stack is the most significant byte;
223
+ // pop/atsp read a width and rebuild big-endian. atsp reads POS bytes
224
+ // DOWN from the top rather than peeking (OpAtsp: index = pos - len).
225
+ case 'push': {
226
+ const w = this.widthOf(A);
227
+ let v = this.val(A);
228
+ if (opIsImm(A[0])) {
229
+ // an immediate pushes 4 bytes (EdValueType width)
230
+ this.pushBytes(v, 4);
231
+ return;
232
+ }
233
+ this.pushBytes(v, w);
234
+ return;
235
+ }
236
+ case 'pop': {
237
+ // push wrote LSB first, so the MSB is on top: popping MSB-first and
238
+ // shifting left reassembles exactly what was pushed.
239
+ const w = this.widthOf(A);
240
+ if (this.stack.length < w) {
241
+ this.store(A, 0);
242
+ this.updateFlags(0, w);
243
+ return;
244
+ }
245
+ const v = this.popValue(w);
246
+ this.store(A, v);
247
+ this.flags.overflow = false;
248
+ this.updateFlags(v, w);
249
+ return;
250
+ }
251
+ case 'atsp': {
252
+ // `atsp reg,#pos` reads WITHOUT popping, pos bytes down from the top
253
+ // (OpAtsp: index = pos - length into a pop-order array, then
254
+ // big-endian assembly). In our array the top is the LAST element,
255
+ // so pop-order index k is stack[len-1-k].
256
+ const w = this.widthOf(A);
257
+ const pos = this.val(B);
258
+ if (this.stack.length < w || pos < w) {
259
+ this.store(A, 0);
260
+ return;
261
+ }
262
+ let v = 0;
263
+ for (let k = pos - w; k < pos; k++) {
264
+ v = v * 256 + (this.stack[this.stack.length - 1 - k] ?? 0);
265
+ }
266
+ this.store(A, v);
267
+ this.updateFlags(v, w);
268
+ return;
269
+ }
270
+ case 'pushf': {
271
+ // the flags WORD (bit0 carry, bit1 zero, bit2 sign, bit3 overflow),
272
+ // 4 bytes LSB-first like any other push -- not four zeros
273
+ const v =
274
+ (f.carry ? FLAG_CARRY : 0) |
275
+ (f.zero ? FLAG_ZERO : 0) |
276
+ (f.sign ? FLAG_SIGN : 0) |
277
+ (f.overflow ? FLAG_OVERFLOW : 0);
278
+ this.pushBytes(v, 4);
279
+ return;
280
+ }
281
+ case 'popf': {
282
+ const v = this.stack.length >= 4 ? this.popValue(4) : 0;
283
+ f.carry = !!(v & FLAG_CARRY);
284
+ f.zero = !!(v & FLAG_ZERO);
285
+ f.sign = !!(v & FLAG_SIGN);
286
+ f.overflow = !!(v & FLAG_OVERFLOW);
287
+ return;
288
+ }
289
+
290
+ case 'clear': {
291
+ if (opIsStringReg(A)) {
292
+ this.clearS(A[1]);
293
+ } else {
294
+ this.store(A, 0, false);
295
+ }
296
+ // OpClear sets the same flags for EVERY register type; leaving them
297
+ // stale on the numeric path made a jz right after `clear I0` not
298
+ // jump.
299
+ f.carry = false;
300
+ f.zero = true;
301
+ f.sign = false;
302
+ f.overflow = false;
303
+ return;
304
+ }
305
+
306
+ case 'move': {
307
+ // width coercion: a byte destination takes the low byte, a string
308
+ // destination takes bytes. Reading a string source into a numeric
309
+ // destination folds big-endian (Operand.GetValueData).
310
+ const dstIsBytes =
311
+ opIsStringReg(A) ||
312
+ opIsRange(A[0]) ||
313
+ B[0] === OpMode.IMM_STR ||
314
+ opIsStringReg(B);
315
+ if (dstIsBytes) {
316
+ this.store(A, this.bytes(B), true);
317
+ return;
318
+ }
319
+ const w = this.widthOf(A);
320
+ const v = this.val(B, w);
321
+ this.store(A, v);
322
+ f.carry = false;
323
+ f.overflow = false;
324
+ this.updateFlags(v, w);
325
+ return;
326
+ }
327
+
328
+ // ---- integer arithmetic. Carry is the unsigned overflow out of the
329
+ // destination's width; Overflow is the signed one.
330
+ case 'adds':
331
+ case 'addc': {
332
+ const w = this.widthOf(A);
333
+ const x = this.val(A, w),
334
+ y = this.val(B, w) + (name === 'addc' && f.carry ? 1 : 0);
335
+ const sum = x + y;
336
+ const lim = 2 ** (8 * w);
337
+ f.carry = sum >= lim;
338
+ this.setOverflow(x, y, sum, w);
339
+ this.store(A, sum % lim);
340
+ this.updateFlags(sum, w);
341
+ return;
342
+ }
343
+ case 'subb':
344
+ case 'subc': {
345
+ const w = this.widthOf(A);
346
+ const x = this.val(A, w),
347
+ y = this.val(B, w) + (name === 'subc' && f.carry ? 1 : 0);
348
+ const diff = x - y;
349
+ f.carry = diff < 0;
350
+ this.setOverflow(x, negateU32(y), diff, w);
351
+ this.store(A, wrapToWidth(diff, w));
352
+ this.updateFlags(diff, w);
353
+ return;
354
+ }
355
+ case 'comp': {
356
+ // Subtract without storing. Carry is the unsigned BORROW, and
357
+ // Overflow follows SetOverflow(val0, (uint)(-val1), diff) -- the
358
+ // subtrahend is negated in FULL 32-BIT two's complement and only
359
+ // then masked to the width, which for w < 4 is NOT the same as
360
+ // negating within the width. This decides `jc`/`jl`/`jg`, i.e.
361
+ // every loop bound in the corpus, so it is copied literally.
362
+ const w = this.widthOf(A);
363
+ const x = this.val(A, w),
364
+ y = this.val(B, w);
365
+ const diff = x - y;
366
+ f.carry = diff < 0;
367
+ this.setOverflow(x, negateU32(y), diff, w);
368
+ this.updateFlags(diff, w);
369
+ return;
370
+ }
371
+ case 'mult': {
372
+ // SIGNED narrow multiply, and it CLOBBERS arg1 with the high half
373
+ // when arg1 is register-backed (OpMult). Missing the clobber left a
374
+ // stride register holding a stale value, so FS_LESEN's fault-record
375
+ // cursor advanced from the wrong base and every record read one
376
+ // byte early.
377
+ //
378
+ // OpMult computes the product in 32-BIT integer arithmetic -- the
379
+ // exact low 32 bits, wrapping -- never a 64-bit product. A plain JS
380
+ // double multiply rounds past 2^53 and got the low word wrong for
381
+ // large 32-bit operands; Math.imul is the exact wrap. The high half
382
+ // is "(UInt64)result >> (len << 3)" of that 32-bit result, so it is
383
+ // ZERO at width 4 (the engine's own comment: negative high halves
384
+ // are not emulated).
385
+ const w = this.widthOf(A);
386
+ const lim = 2 ** (8 * w),
387
+ half = lim / 2;
388
+ const sx = (() => {
389
+ const v = this.val(A, w);
390
+ return v >= half ? v - lim : v;
391
+ })();
392
+ const sy = (() => {
393
+ const v = this.val(B, w);
394
+ return v >= half ? v - lim : v;
395
+ })();
396
+ const result = (w === 4 ? Math.imul(sx | 0, sy | 0) : sx * sy) >>> 0;
397
+ this.store(A, result % lim);
398
+ f.overflow = false;
399
+ this.updateFlags(result, w);
400
+ if (opIsNumReg(B)) {
401
+ this.store(B, Math.floor(result / lim) % lim);
402
+ }
403
+ return;
404
+ }
405
+ case 'div':
406
+ case 'divs': {
407
+ // OpDivs does 32-BIT SIGNED division for EVERY width -- the narrow
408
+ // native forms are commented out in the engine as "DIVS failure in
409
+ // ediabas!" -- and it writes the REMAINDER back into arg1 when arg1
410
+ // is register-backed. Dividing unsigned gave SMG2's hydraulic
411
+ // pressure 42949660 where the engine says 65524: the dividend was
412
+ // 0xFFFFFB38 = -1224, and -1224/100 = -12, which is 65524 as an
413
+ // unsigned 16-bit result.
414
+ const w = this.widthOf(A);
415
+ const x = toInt32(this.val(A, w)),
416
+ y = toInt32(this.val(B, w));
417
+ if (y === 0) {
418
+ // OpDivs on a zero divisor raises EDIABAS_BIP_0007 (which has no
419
+ // mapped trap bit -> 0), KEEPS arg0's value, and still writes a
420
+ // zero remainder. Yielding a silent 0 quotient turned a garbled
421
+ // count byte into 0-valued results published as OKAY. The
422
+ // trap-mask layer is not modeled (see generr), so trapping is
423
+ // the conservative choice over aborting the job.
424
+ this.trapBit = TRAP_UNMAPPED;
425
+ f.overflow = false;
426
+ this.updateFlags(0, w);
427
+ if (opIsNumReg(B)) {
428
+ this.store(B, 0);
429
+ }
430
+ return;
431
+ }
432
+ const q = Math.trunc(x / y),
433
+ rem = x % y;
434
+ const stored = wrapToWidth(q, w);
435
+ this.store(A, stored);
436
+ f.overflow = false;
437
+ this.updateFlags(stored, w);
438
+ if (opIsNumReg(B)) {
439
+ this.store(B, wrapToWidth(rem, w));
440
+ }
441
+ return;
442
+ }
443
+ case 'mod': {
444
+ const w = this.widthOf(A);
445
+ const d = this.val(B);
446
+ const r = d === 0 ? 0 : this.val(A) % d;
447
+ this.store(A, r);
448
+ this.updateFlags(r, w);
449
+ return;
450
+ }
451
+ case 'and':
452
+ case 'or':
453
+ case 'xor': {
454
+ const w = this.widthOf(A);
455
+ const x = BigInt(this.val(A, w)),
456
+ y = BigInt(this.val(B, w));
457
+ const r = Number(
458
+ name === 'and' ? x & y : name === 'or' ? x | y : x ^ y
459
+ );
460
+ this.store(A, r);
461
+ this.updateFlags(r, w);
462
+ return;
463
+ }
464
+ case 'not': {
465
+ const w = this.widthOf(A);
466
+ const lim = 2 ** (8 * w);
467
+ const r = lim - 1 - this.val(A);
468
+ this.store(A, r);
469
+ this.updateFlags(r, w);
470
+ return;
471
+ }
472
+ case 'asl':
473
+ case 'lsl': {
474
+ const w = this.widthOf(A);
475
+ const n = this.val(B);
476
+ const v = this.val(A, w);
477
+ const r = v * 2 ** n;
478
+ // carry is the LAST BIT SHIFTED OUT of the width, like the engine's
479
+ // shift ops; untouched when the count is zero
480
+ if (n > 0)
481
+ f.carry = n <= 8 * w && Math.floor(v / 2 ** (8 * w - n)) % 2 === 1;
482
+ this.store(A, r % 2 ** (8 * w));
483
+ this.updateFlags(r, w);
484
+ return;
485
+ }
486
+ case 'lsr':
487
+ case 'asr': {
488
+ // asr is ARITHMETIC: the sign bit (at the operand's width) shifts
489
+ // back in. lsr shifts in zeros. Both report the last bit shifted
490
+ // out in carry.
491
+ const w = this.widthOf(A);
492
+ const n = this.val(B);
493
+ const lim = 2 ** (8 * w);
494
+ const u = this.val(A, w); // bit pattern
495
+ let v = u;
496
+ if (name === 'asr' && v >= lim / 2) v -= lim; // signed view
497
+ // the shifted-out bit is bit n-1 of the PATTERN, sign play or not
498
+ if (n > 0 && n <= 8 * w) {
499
+ f.carry = Math.floor(u / 2 ** (n - 1)) % 2 === 1;
500
+ }
501
+ const r = Math.floor(v / 2 ** n);
502
+ this.store(A, wrapToWidth(r, w));
503
+ this.updateFlags(r, w);
504
+ return;
505
+ }
506
+
507
+ // ---- float
508
+ case 'fix2flt':
509
+ case 'ufix2flt': {
510
+ // fix2flt SIGN-EXTENDS by the SOURCE operand's width (1/2/4 ->
511
+ // SByte/Int16/Int32); ufix2flt does not. Skipping the sign made a
512
+ // negative reading come out as its unsigned complement scaled --
513
+ // SMG2's lateral acceleration read 4294942.72 where the engine
514
+ // says -24.576, i.e. exactly (value - 2^32) * scale.
515
+ const w = this.widthOf(B) || 4;
516
+ let v = this.val(B, w);
517
+ if (name === 'fix2flt') {
518
+ const lim = 2 ** (8 * w);
519
+ if (v >= lim / 2) v -= lim;
520
+ }
521
+ this.fregs.set(A[1], v);
522
+ return;
523
+ }
524
+ case 'flt2fix': {
525
+ const w = this.widthOf(A);
526
+ const v = Math.trunc(this.getReg(B[1]));
527
+ this.store(A, wrapToWidth(v, w));
528
+ this.updateFlags(v, w);
529
+ return;
530
+ }
531
+ case 'fadd':
532
+ case 'fsub':
533
+ case 'fmul':
534
+ case 'fdiv': {
535
+ const x = this.getReg(A[1]);
536
+ const y = this.floatOf(B);
537
+ let r = x;
538
+ if (name === 'fadd') r = x + y;
539
+ else if (name === 'fsub') r = x - y;
540
+ else if (name === 'fmul') r = x * y;
541
+ else {
542
+ // OpFdiv divides regardless and stores what comes out; an
543
+ // infinite or NaN quotient raises EDIABAS_BIP_0011 (trap bit 8).
544
+ // Storing a silent 0 hid the division by zero entirely.
545
+ r = x / y;
546
+ if (!Number.isFinite(r)) this.trapBit = TRAP_FLOAT;
547
+ }
548
+ this.fregs.set(A[1], r);
549
+ return;
550
+ }
551
+ case 'fcomp': {
552
+ // comp for floats: subtract without storing. There is no width or
553
+ // borrow arithmetic here -- zero/sign carry the ordering and
554
+ // overflow stays clear, which is exactly what makes jl/jg/jle/jge
555
+ // (sign vs overflow) and jc/jb (carry) all read as plain < and >.
556
+ const x = this.floatOf(A),
557
+ y = this.floatOf(B);
558
+ f.zero = x === y;
559
+ f.sign = x < y;
560
+ f.carry = x < y;
561
+ f.overflow = false;
562
+ return;
563
+ }
564
+ case 'y42flt':
565
+ case 'y82flt': {
566
+ // 4/8 raw bytes -> IEEE float, LITTLE-endian: the corpus reverses
567
+ // wire bytes with swap first (swap S1,0,4 / y42flt F5,S1[0]), so
568
+ // the op itself reads host order.
569
+ const n = name === 'y42flt' ? 4 : 8;
570
+ const [m, a, b, c] = B;
571
+ let src;
572
+ if (opIsIndexed(m)) {
573
+ const raw = this.getSraw(a);
574
+ let i = m === OpMode.IDX_IMM ? b : this.getReg(b);
575
+ if (m === OpMode.IDX_REG_IMM) i += c || 0;
576
+ src = raw.slice(i, i + n);
577
+ } else {
578
+ src = Uint8Array.from(this.bytes(B)).slice(0, n);
579
+ }
580
+ const buf = new Uint8Array(n);
581
+ buf.set(src.slice(0, n));
582
+ const dv = new DataView(buf.buffer);
583
+ this.fregs.set(
584
+ A[1],
585
+ n === 4 ? dv.getFloat32(0, true) : dv.getFloat64(0, true)
586
+ );
587
+ return;
588
+ }
589
+ case 'flt2y4':
590
+ case 'flt2y8': {
591
+ const n = name === 'flt2y4' ? 4 : 8;
592
+ const buf = new Uint8Array(n);
593
+ const dv = new DataView(buf.buffer);
594
+ const v = this.getReg(B[1]);
595
+ if (n === 4) dv.setFloat32(0, v, true);
596
+ else dv.setFloat64(0, v, true);
597
+ this.store(A, buf, true);
598
+ return;
599
+ }
600
+ case 'a2flt': {
601
+ const txt = this.textOf(B);
602
+ this.fregs.set(A[1], Best2Codec.parseNum(txt));
603
+ return;
604
+ }
605
+ case 'a2fix': {
606
+ // StringToValue, NOT a float parse: it understands 0x hex and 0y
607
+ // binary. The `bits` tables hold masks as "0x01", and parseFloat
608
+ // stops at the 'x' and yields 0 -- so every table-driven bit test
609
+ // masked with 0 and reported the bit set.
610
+ const w = this.widthOf(A);
611
+ const txt = this.textOf(B);
612
+ const v = Best2Codec.strToValue(txt);
613
+ this.store(A, wrapToWidth(v, w));
614
+ // a2fix forces Zero and Sign false regardless of the value
615
+ f.zero = false;
616
+ f.sign = false;
617
+ f.overflow = false;
618
+ return;
619
+ }
620
+ case 'flt2a': {
621
+ this.storeText(A, Best2Codec.fltText(this.getReg(B[1])));
622
+ return;
623
+ }
624
+ case 'fix2a':
625
+ case 'ufix2a': {
626
+ this.storeText(A, String(this.val(B)));
627
+ return;
628
+ }
629
+ case 'fix2dez':
630
+ case 'ufix2dez': {
631
+ // signed for fix2dez, unsigned for ufix2dez, width from the SOURCE
632
+ const w = this.widthOf(B) || 1;
633
+ let v = this.val(B);
634
+ if (name === 'fix2dez') {
635
+ const lim = 2 ** (8 * w);
636
+ if (v >= lim / 2) v -= lim;
637
+ }
638
+ this.storeText(A, String(v));
639
+ return;
640
+ }
641
+
642
+ // ---- byte/text conversions
643
+ case 'fix2hex':
644
+ case 'ufix2hex': {
645
+ // EXACT format: "0x{0:X02}" / "0x{0:X04}" / "0x{0:X08}" by the
646
+ // SOURCE operand's width -- prefix included, zero-padded, uppercase.
647
+ // This is load-bearing, not cosmetic: JobResult's SB column holds
648
+ // "0xA0", and the status lookup is `fix2hex S1,B0` / `tabseek "SB"`,
649
+ // so dropping the prefix made every job's status miss and clamp to
650
+ // the table's last row (ERROR_ECU_UNKNOWN_STATUSBYTE instead of OKAY).
651
+ const w = this.widthOf(B) || 1;
652
+ const digits = w >= 4 ? 8 : w === 2 ? 4 : 2;
653
+ const v = this.val(B);
654
+ const txt =
655
+ '0x' + (v >>> 0).toString(16).toUpperCase().padStart(digits, '0');
656
+ this.storeText(A, txt);
657
+ return;
658
+ }
659
+ case 'y2hex': {
660
+ let s = '';
661
+ for (const x of this.bytes(B)) {
662
+ s += x.toString(16).toUpperCase().padStart(2, '0');
663
+ }
664
+ this.storeText(A, s);
665
+ return;
666
+ }
667
+ case 'y2bcd': {
668
+ // a nibble above 9 is not a BCD digit: ValueToBcd prints '*'
669
+ let s = '';
670
+ for (const x of this.bytes(B)) {
671
+ for (const n of [(x >> 4) & 0xf, x & 0xf]) {
672
+ s += n <= 9 ? String(n) : '*';
673
+ }
674
+ }
675
+ this.storeText(A, s);
676
+ return;
677
+ }
678
+ case 'hex2y': {
679
+ const txt = Best2Codec.cstr(this.bytes(B)).replace(/[^0-9A-Fa-f]/g, '');
680
+ const out = new Uint8Array(Math.floor(txt.length / 2));
681
+ for (let i = 0; i < out.length; i++) {
682
+ out[i] = parseInt(txt.substr(i * 2, 2), 16);
683
+ }
684
+ this.store(A, out, true);
685
+ return;
686
+ }
687
+
688
+ // ---- strings (byte buffers)
689
+ case 'slen':
690
+ case 'strlen': {
691
+ const b = this.bytes(B);
692
+ const n = name === 'strlen' ? Best2Codec.cstr(b).length : b.length;
693
+ this.store(A, n);
694
+ this.updateFlags(n, this.widthOf(A));
695
+ return;
696
+ }
697
+ case 'scat':
698
+ case 'strcat': {
699
+ const x = this.bytes(A),
700
+ y = this.bytes(B);
701
+ const base =
702
+ name === 'strcat' ? Best2Codec.strBytes(Best2Codec.cstr(x)) : x;
703
+ const add =
704
+ name === 'strcat' ? Best2Codec.strBytes(Best2Codec.cstr(y)) : y;
705
+ const out = new Uint8Array(base.length + add.length);
706
+ out.set(base);
707
+ out.set(add, base.length);
708
+ this.store(A, out, true);
709
+ return;
710
+ }
711
+ case 'scut': {
712
+ // KNOWN OPEN QUESTION (do not "fix" without engine evidence): this
713
+ // counts on string registers carrying no trailing NUL, but
714
+ // storeText (the store path for pars/tabget/flt2a/...) DOES count
715
+ // its NUL in the logical length -- so scut directly after a
716
+ // storeText keeps a NUL the engine would drop. No fixture in the
717
+ // 3,730-result suite exercises that sequence; changing either side
718
+ // blind risks the 100% agreement. Revisit with a captured trace of
719
+ // a job doing scut-after-storeText.
720
+ //
721
+ // strcut removes the last `len` bytes -- and `len` COUNTS THE
722
+ // TERMINATING NUL, which the register's logical length does not
723
+ // include once SetStringData has stored it. So `scut S1,#1` on the
724
+ // 2-byte "FP" removes only the (absent) terminator and leaves "FP"
725
+ // intact; treating it as "drop the last byte" cut the P and broke
726
+ // EWS's VIN check digit 200 instructions later.
727
+ //
728
+ // Concretely: the effective byte count removed is len-1, floored at
729
+ // zero, because our string registers carry no trailing NUL of their
730
+ // own (storeText appends one only for text results).
731
+ const reg = A[1];
732
+ const buf = this.getS(reg);
733
+ const n = Math.max(0, this.val(B) - 1);
734
+ this.setS(
735
+ reg,
736
+ n >= buf.length ? new Uint8Array(0) : buf.slice(0, buf.length - n)
737
+ );
738
+ return;
739
+ }
740
+ case 'spaste': {
741
+ // INSERT (datainsert), shifting the tail right -- not an overwrite.
742
+ // Inserting at or past the current logical end is a silent no-op.
743
+ const reg = A[1];
744
+ const idx = A[0] === OpMode.IDX_IMM ? A[2] : this.getReg(A[2]);
745
+ const src = this.bytes(B);
746
+ const buf = this.getS(reg);
747
+ if (idx >= buf.length) return;
748
+ const out = new Uint8Array(buf.length + src.length);
749
+ out.set(buf.slice(0, idx), 0);
750
+ out.set(src, idx);
751
+ out.set(buf.slice(idx), idx + src.length);
752
+ this.setS(reg, out);
753
+ return;
754
+ }
755
+ case 'scmp': {
756
+ // datacmp: byte-exact, and Zero means EQUAL (the normal sense)
757
+ const x = this.bytes(A),
758
+ y = this.bytes(B);
759
+ f.zero = x.length === y.length && x.every((v, i) => v === y[i]);
760
+ return;
761
+ }
762
+ case 'strcmp': {
763
+ // INVERTED relative to scmp: OpStrcmp sets
764
+ // Zero = (String.Compare(a, b, Ordinal) != 0)
765
+ // so ZERO MEANS THE STRINGS DIFFER. Getting this backwards made
766
+ // every `strcmp S2,"BUSY" / jz done` status check read as "still
767
+ // busy" on an OKAY response, so all 28 BMS46 jobs retried the
768
+ // telegram until the step limit.
769
+ const xs = Best2Codec.cstr(this.bytes(A));
770
+ const ys = this.textOf(B);
771
+ f.zero = xs !== ys;
772
+ return;
773
+ }
774
+ case 'serase': {
775
+ // delete `len` bytes at arg0's index, closing the gap (dataerase).
776
+ // arg0 must be an indexed operand; the index names the position.
777
+ const reg = A[1];
778
+ const idx = A[0] === OpMode.IDX_IMM ? A[2] : this.getReg(A[2]);
779
+ const n = this.val(B);
780
+ const buf = this.getS(reg);
781
+ // The gap is CLIPPED to the string: an erase at or past the end
782
+ // removes nothing, and one that runs past the end removes only what
783
+ // is there. Sizing the result as length-minus-count instead dropped
784
+ // the surviving bytes whenever the range overhung -- FA.PRG's
785
+ // `serase S4[1], 1` on a one-byte S4 emptied it, and the vehicle
786
+ // order decode stopped at its third option with
787
+ // ERROR_UNKNOWN_IDENTIFIER.
788
+ const from = Math.max(0, Math.min(buf.length, idx));
789
+ const to = Math.max(from, Math.min(buf.length, idx + Math.max(0, n)));
790
+ const out = new Uint8Array(buf.length - (to - from));
791
+ out.set(buf.subarray(0, from), 0);
792
+ out.set(buf.subarray(to), from);
793
+ this.setS(reg, out);
794
+ return;
795
+ }
796
+ case 'srevrs': {
797
+ const b = Uint8Array.from(this.bytes(A)).reverse();
798
+ this.store(A, b, true);
799
+ return;
800
+ }
801
+ case 'strim': {
802
+ this.store(
803
+ A,
804
+ Best2Codec.strBytes(Best2Codec.cstr(this.bytes(A)).trim()),
805
+ true
806
+ );
807
+ return;
808
+ }
809
+ case 'swap': {
810
+ // ranged modes can hold a REGISTER NAME for index or length --
811
+ // resolve them the way every other indexed op does, or a register-
812
+ // backed range coerces to 0 and nothing is reversed
813
+ const [m, reg, bIdx, cLen] = A;
814
+ const idx = this.resolveIdx(m, bIdx ?? 0);
815
+ const n = this.resolveLen(m, cLen ?? 0);
816
+ const buf = Uint8Array.from(this.getS(reg));
817
+ const part = buf.slice(idx, idx + n).reverse();
818
+ buf.set(part, idx);
819
+ this.setS(reg, buf);
820
+ return;
821
+ }
822
+ case 'setspc': {
823
+ // Sets the split parameters the NEXT stoken uses: arg0 is the
824
+ // separator characters (a pool string like " " or ", "), arg1 the
825
+ // 1-based token number.
826
+ this.tokenSep = Best2Codec.cstr(this.bytes(A));
827
+ this.tokenIdx = this.val(B);
828
+ return;
829
+ }
830
+ case 'stoken': {
831
+ // Token extraction: split arg1 by the setspc separators and store
832
+ // the setspc-selected token into arg0. Zero flag reports "no such
833
+ // token" -- the corpus idiom is `setspc " ",n / stoken S5,S7 / jz
834
+ // fail`. Runs of separators collapse: the seps here are " " and
835
+ // ", ", where keeping empty tokens would make every second token
836
+ // blank.
837
+ const src = Best2Codec.cstr(this.bytes(B));
838
+ const seps = this.tokenSep || ' ';
839
+ const parts = src
840
+ .split(
841
+ new RegExp(`[${seps.replace(/[.*+?^${}()|[\]\\-]/g, '\\$&')}]+`)
842
+ )
843
+ .filter((t) => t !== '');
844
+ const tok = this.tokenIdx >= 1 ? parts[this.tokenIdx - 1] : undefined;
845
+ this.storeText(A, tok ?? '');
846
+ f.zero = tok === undefined;
847
+ return;
848
+ }
849
+ case 'test': {
850
+ // non-destructive AND: flags only, arg0 is NOT written
851
+ const w = this.widthOf(A);
852
+ const r = Number(BigInt(this.val(A)) & BigInt(this.val(B)));
853
+ f.overflow = false;
854
+ this.updateFlags(r, w);
855
+ return;
856
+ }
857
+ case 'setc':
858
+ f.carry = true;
859
+ return;
860
+ case 'clrc':
861
+ f.carry = false;
862
+ return;
863
+ case 'clrv':
864
+ f.overflow = false;
865
+ return;
866
+ case 'sett':
867
+ this.trapBit = this.val(A) || TRAP_USER;
868
+ return;
869
+ case 'ssize': {
870
+ const n = this.bytes(A).length;
871
+ this.store(B, n);
872
+ return;
873
+ }
874
+
875
+ // ---- control flow
876
+ case 'jump':
877
+ if (A[1] === null) throw new VmError(`unresolved jump at ${pc}`);
878
+ return A[1];
879
+ case 'jt':
880
+ case 'jnt': {
881
+ // `jt target[, bit]`: with a bit selector, fire when the trap
882
+ // register equals it (bit 32 aliases the unmapped bit 0); with no
883
+ // selector, jt fires on ANY pending trap. jnt is the inverse --
884
+ // except with no selector, where EDIABAS has a documented bug
885
+ // (tests >= 0x40000000, so it effectively always jumps); that is
886
+ // emulated deliberately, since SGBDs are compiled against it.
887
+ let hit;
888
+ if (B && B[0] !== OpMode.NONE) {
889
+ const bit = this.val(B, 1);
890
+ hit =
891
+ bit > 0
892
+ ? this.trapBit === bit ||
893
+ (this.trapBit === TRAP_UNMAPPED &&
894
+ bit === TRAP_BIT_ALIAS_UNMAPPED)
895
+ : this.trapBit >= TRAP_USER;
896
+ } else {
897
+ hit = name === 'jt' ? this.trapBit >= 0 : this.trapBit >= TRAP_USER;
898
+ }
899
+ if (name === 'jnt') hit = !hit;
900
+ if (!hit) return;
901
+ if (A[1] === null) throw new VmError(`unresolved jump at ${pc}`);
902
+ return A[1];
903
+ }
904
+ case 'clrt':
905
+ this.trapBit = TRAP_CLEAN;
906
+ return;
907
+ case 'jz':
908
+ case 'jnz':
909
+ case 'jc':
910
+ case 'jnc':
911
+ case 'jae':
912
+ case 'jbe':
913
+ case 'ja':
914
+ case 'jb':
915
+ case 'jmi':
916
+ case 'jpl':
917
+ case 'jv':
918
+ case 'jnv':
919
+ case 'jg':
920
+ case 'jge':
921
+ case 'jl':
922
+ case 'jle': {
923
+ const test = JUMP_TESTS[name];
924
+ if (!test) throw new VmError(`no test for ${name}`);
925
+ if (!test(f)) return;
926
+ if (A[1] === null) throw new VmError(`unresolved jump at ${pc}`);
927
+ return A[1];
928
+ }
929
+
930
+ // ---- results
931
+ // Each erg* opcode has a FIXED width and signedness, independent of
932
+ // the operand's own type (the reference's result table, ERG_SPECS):
933
+ // ergb unsigned 8 ergc SIGNED 8
934
+ // ergw unsigned 16 ergi SIGNED 16
935
+ // ergd unsigned 32 ergl SIGNED 32
936
+ // Publishing ergi unsigned reported SMG2's coolant temperature as
937
+ // 65531 where the engine says -5.
938
+ //
939
+ // The result NAME is arg0.GetStringData() (textOf): usually an
940
+ // inline literal, but it can equally be a REGISTER -- MS420's VANOS
941
+ // jobs name a result from a register still holding response bytes,
942
+ // and the engine faithfully publishes the resulting garbage key.
943
+ // Assuming a pool index dropped the name to the empty string.
944
+ case 'ergb':
945
+ case 'ergw':
946
+ case 'ergd':
947
+ case 'ergi':
948
+ case 'ergl': {
949
+ const [w, signed] = ERG_SPECS[name];
950
+ let v = this.val(B, w) % 2 ** (8 * w);
951
+ if (signed && v >= 2 ** (8 * w - 1)) v -= 2 ** (8 * w);
952
+ this.emit(this.textOf(A), v);
953
+ return;
954
+ }
955
+ case 'ergr': {
956
+ // arg1 is GetFloatData(): a pool literal parses as a number rather
957
+ // than falling through val()'s m===8 case, which returned 0.
958
+ this.emit(this.textOf(A), this.floatOf(B));
959
+ return;
960
+ }
961
+ case 'ergs': {
962
+ const txt = this.textOf(B);
963
+ this.emit(this.textOf(A), txt);
964
+ return;
965
+ }
966
+ case 'ergy': {
967
+ this.emit(this.textOf(A), Array.from(this.bytes(B)));
968
+ return;
969
+ }
970
+ case 'ergc': {
971
+ // SIGNED 8-bit, published as a NUMBER (TypeC), not a character
972
+ let v = this.val(B, 1) & 0xff;
973
+ if (v >= 0x80) v -= 0x100;
974
+ this.emit(this.textOf(A), v);
975
+ return;
976
+ }
977
+ case 'enewset':
978
+ this.flush();
979
+ return;
980
+ case 'etag': {
981
+ // `etag target, "NAME"` is a JUMP, not a flag test: if the caller
982
+ // asked for a specific subset of results and NAME is not in it,
983
+ // skip the block that computes it. An empty request set means
984
+ // "everything is wanted", so the jump is never taken -- which is
985
+ // why treating this as a flag left every result uncomputed.
986
+ if (!this.wanted || this.wanted.size === 0) return;
987
+ const nm = B ? this.textOf(B) : '';
988
+ if (this.wanted.has(String(nm || '').toUpperCase())) return;
989
+ if (A[1] === null) throw new VmError(`unresolved etag at ${pc}`);
990
+ return A[1];
991
+ }
992
+
993
+ // ---- tables
994
+ case 'tabset':
995
+ case 'tabsetex': {
996
+ // Table lookup is CASE-INSENSITIVE (TableNameDict is keyed on
997
+ // ToUpper), and `tabsetex` names the table in arg0 with the SGBD
998
+ // file in arg1 -- an empty file name keeps the current stream.
999
+ //
1000
+ // The external file matters: every group SGBD in data/groups
1001
+ // reaches the variant assignment table with `tabsetex
1002
+ // "ZuordnungsTabelle", "t_grtb"` (or the Motorrad/UDS spelling),
1003
+ // and OpTabsetex (EdOperations.cs) opens THAT file's table stream
1004
+ // for a non-empty name -- a missing file is EDIABAS_SYS_0002 and a
1005
+ // missing table EDIABAS_BIP_0010, and neither falls back to the
1006
+ // current SGBD's tables. Both land in the trap register here
1007
+ // (SYS_0002 folded onto bit 10: this VM's trap model has no
1008
+ // system-error tier, and the observable effect -- the jz/jt guard
1009
+ // after the tabset fires -- is the same).
1010
+ const t = this.textOf(A);
1011
+ let rows;
1012
+ if (name === 'tabsetex') {
1013
+ const file = B ? this.textOf(B) : '';
1014
+ rows = file ? this.findExtTable(file, t) : this.findTable(t);
1015
+ } else {
1016
+ rows = this.findTable(t);
1017
+ }
1018
+ this.table = rows ? { name: t, rows, row: null } : null;
1019
+ // a missing table is EDIABAS_BIP_0010 (trap bit 10); a found one
1020
+ // must CLEAR the trap, or the SGBD's `jt err,#10` guard fires on a
1021
+ // perfectly good table
1022
+ f.zero = !rows;
1023
+ this.trapBit = rows ? TRAP_CLEAN : TRAP_TABLE;
1024
+ return;
1025
+ }
1026
+ case 'tabseek':
1027
+ case 'tabseeku': {
1028
+ if (!this.table) {
1029
+ f.zero = true;
1030
+ this.trapBit = TRAP_TABLE;
1031
+ return;
1032
+ }
1033
+ // arg0 is always the COLUMN NAME, arg1 the value being sought.
1034
+ // `tabseek` compares text case-INsensitively; `tabseeku` parses
1035
+ // each cell as a NUMBER (StringToValue: 0x hex, 0y binary, else
1036
+ // decimal) and compares numerically -- which is what makes
1037
+ // JobResult's "0x10" style status bytes match a numeric key.
1038
+ //
1039
+ // DELIBERATELY NO WILDCARD MATCHING, group tables included. The
1040
+ // ZuordnungsTabelle cells look like patterns ("32 .2M. 0550",
1041
+ // "00 ---- 0100"), but the engine's SeekTable (EdiabasNet.cs) is a
1042
+ // plain ToUpper dictionary -- first matching row, exact text -- and
1043
+ // the wildcard walk lives IN the group bytecode: d_0032's
1044
+ // IDENTIFIKATION writes the '.' characters into its seek key
1045
+ // itself (`move L0,#$2e / move S5[L1],B0` at 0x9d6/0xabb) when the
1046
+ // ident chars are in [0-9A-Z], so the key it seeks is byte-equal
1047
+ // to the cell it must hit. A wildcard-aware seek here would match
1048
+ // rows the engine never selects.
1049
+ const col = this.textOf(A);
1050
+ const keyOp = B || A;
1051
+ const numeric = name === 'tabseeku';
1052
+ const keyNum = numeric ? this.val(keyOp) : null;
1053
+ const keyTxt = numeric ? null : this.textOf(keyOp).toLowerCase();
1054
+ const hit = this.table.rows.find((r) => {
1055
+ const c = sgbdTableCell(r, col);
1056
+ if (c === undefined) return false;
1057
+ if (numeric) return Best2Codec.strToValue(String(c)) === keyNum;
1058
+ return String(c).toLowerCase() === keyTxt;
1059
+ });
1060
+ // A VALUE MISS IS NOT AN ERROR: SeekTable returns the LAST data row
1061
+ // and reports not-found, because SGBD tables conventionally put a
1062
+ // catch-all in the last row. Zero==true means "not found" (inverted
1063
+ // from the usual sense), which is the `tabseek / jz` idiom.
1064
+ this.table.row =
1065
+ hit || this.table.rows[this.table.rows.length - 1] || null;
1066
+ f.zero = !hit;
1067
+ return;
1068
+ }
1069
+ case 'tabget': {
1070
+ // Column name from a pool literal OR a register (lit() never
1071
+ // returns nullish, so `lit(B[1]) ?? ...` silently missed every
1072
+ // register-held name). Case-insensitive like tabseek.
1073
+ const col = this.textOf(B);
1074
+ const cell =
1075
+ this.table && this.table.row
1076
+ ? sgbdTableCell(this.table.row, col)
1077
+ : undefined;
1078
+ this.storeText(
1079
+ A,
1080
+ cell === undefined || cell === null ? '' : String(cell)
1081
+ );
1082
+ f.zero = cell === undefined || cell === null;
1083
+ return;
1084
+ }
1085
+ case 'tabrows': {
1086
+ // Rows + 1: the header row counts (GetTableRows + 1), while every
1087
+ // row INDEX is 0-based over data rows -- so this is not a loop bound
1088
+ this.store(A, this.table ? this.table.rows.length + 1 : 0);
1089
+ return;
1090
+ }
1091
+ case 'tabcols': {
1092
+ this.store(
1093
+ A,
1094
+ this.table && this.table.rows[0]
1095
+ ? Object.keys(this.table.rows[0]).length
1096
+ : 0
1097
+ );
1098
+ return;
1099
+ }
1100
+ case 'tabline': {
1101
+ // 0-based over DATA rows; an out-of-range index clamps to the last
1102
+ // row and reports Zero=true (GetTableLine), it does not fail
1103
+ const i = this.val(B);
1104
+ if (!this.table) {
1105
+ f.zero = true;
1106
+ return;
1107
+ }
1108
+ const inRange = i >= 0 && i < this.table.rows.length;
1109
+ this.table.row = inRange
1110
+ ? this.table.rows[i]
1111
+ : this.table.rows[this.table.rows.length - 1] || null;
1112
+ f.zero = !inRange;
1113
+ return;
1114
+ }
1115
+
1116
+ // ---- job arguments. ZERO IS THE PRESENCE FLAG, not "value == 0":
1117
+ // par* set Zero=true and only clear it when the requested parameter
1118
+ // exists and is non-empty. Indices are 1-BASED and the decrement is
1119
+ // unsigned, so index 0 underflows and always misses. Without these
1120
+ // flags a job that guards on `pars / jz` ran its with-arguments path
1121
+ // on no arguments and exited before emitting anything.
1122
+ case 'pars': {
1123
+ const parts = this.args();
1124
+ const i = this.val(B) - 1;
1125
+ f.zero = true;
1126
+ let txt = '';
1127
+ if (i >= 0 && i < parts.length && parts[i] !== '') {
1128
+ txt = parts[i];
1129
+ f.zero = false;
1130
+ }
1131
+ this.storeText(A, txt);
1132
+ return;
1133
+ }
1134
+ case 'parb':
1135
+ case 'parw':
1136
+ case 'parl':
1137
+ case 'pard':
1138
+ case 'pari': {
1139
+ const parts = this.args();
1140
+ const i = this.val(B) - 1;
1141
+ f.zero = true;
1142
+ f.carry = false;
1143
+ f.sign = false;
1144
+ f.overflow = false;
1145
+ let v = 0;
1146
+ if (i >= 0 && i < parts.length && parts[i] !== '') {
1147
+ v = Best2Codec.strToValue(parts[i]);
1148
+ f.zero = false;
1149
+ }
1150
+ this.store(A, v);
1151
+ return;
1152
+ }
1153
+ case 'parr': {
1154
+ const parts = this.args();
1155
+ const i = this.val(B) - 1;
1156
+ f.zero = true;
1157
+ f.carry = false;
1158
+ f.sign = false;
1159
+ f.overflow = false;
1160
+ let v = 0;
1161
+ if (i >= 0 && i < parts.length && parts[i] !== '') {
1162
+ v = Best2Codec.parseNum(parts[i]);
1163
+ f.zero = false;
1164
+ }
1165
+ this.fregs.set(A[1], v);
1166
+ return;
1167
+ }
1168
+ case 'parn': {
1169
+ const n = this.args().length;
1170
+ this.store(A, n);
1171
+ f.overflow = false;
1172
+ this.updateFlags(this.val(A), this.widthOf(A));
1173
+ return;
1174
+ }
1175
+ case 'pary': {
1176
+ // the WHOLE binary argument blob -- no index, no splitting
1177
+ f.zero = this.argBytes.length === 0;
1178
+ this.store(A, this.argBytes, true);
1179
+ return;
1180
+ }
1181
+
1182
+ // ---- telegrams
1183
+ case 'xsend':
1184
+ case 'xsendf':
1185
+ case 'xsendr':
1186
+ case 'xsendex':
1187
+ case 'xrequf':
1188
+ case 'xraw': {
1189
+ // arg0 names the register that RECEIVES the answer; the request is
1190
+ // the second operand where present, else arg0's current contents.
1191
+ const req = B ? this.bytes(B) : this.bytes(A);
1192
+ // THE WRITE GUARD. This is the only point where bytes leave the VM,
1193
+ // so it is the only place the check has to exist. A job whose name
1194
+ // says it CHANGES the ECU refuses to transmit unless the caller
1195
+ // opted in -- the failure happens before the bytes reach the cable
1196
+ // rather than after. Simulation and fixtures are unaffected: they
1197
+ // pass allowWrites, because writing into a .sim file harms nothing.
1198
+ if (this.writeJob && !this.allowWrites) {
1199
+ throw new VmError(
1200
+ `refusing to transmit for write job ${this.jobName}: ` +
1201
+ 'construct the VM with {allowWrites: true} to permit it'
1202
+ );
1203
+ }
1204
+ // The wire parameters ride along: the transport needs the concept
1205
+ // to frame, checksum and pace this exchange. Callers that replay
1206
+ // captured answers just ignore the second argument. A `wait` since
1207
+ // the last exchange becomes waitMs, honored before the write.
1208
+ let comm = this.comm;
1209
+ if (this.pendingWaitMs) {
1210
+ comm = { ...(comm || {}), waitMs: this.pendingWaitMs };
1211
+ this.pendingWaitMs = 0;
1212
+ }
1213
+ const ans = this.send(Array.from(req), comm) || [];
1214
+ if (ans.length > this.arraySize) {
1215
+ // An answer that exceeds the register capacity must not be
1216
+ // half-stored: setS would refuse the write and the job would
1217
+ // decode its own request bytes -- still sitting in the register --
1218
+ // as the ECU's response. Fail loudly instead.
1219
+ throw new VmError(
1220
+ `answer of ${ans.length} bytes exceeds the ` +
1221
+ `job's array size (${this.arraySize})`
1222
+ );
1223
+ }
1224
+ this.answer = Uint8Array.from(ans);
1225
+ this.store(A, this.answer, true);
1226
+ f.zero = this.answer.length === 0;
1227
+ return;
1228
+ }
1229
+
1230
+ // ---- environment / no-ops for decode purposes. These affect timing,
1231
+ // tracing or interface configuration, none of which changes a decoded
1232
+ // value, so they are accepted and ignored rather than aborting a job.
1233
+ //
1234
+ // Process-wide shared data. `shmset key, value` (arg0 is the KEY,
1235
+ // arg1 the value); `shmget dest, key` (arg0 is the DEST). Keys are
1236
+ // uppercased, values persist across jobs in a session -- which is how
1237
+ // MS450 hands its AIF block from one job to the next, using a key
1238
+ // that is literally a single NUL byte.
1239
+ case 'shmset': {
1240
+ this.shared.set(this.shmKey(A), Uint8Array.from(this.bytes(B)));
1241
+ return;
1242
+ }
1243
+ case 'shmget': {
1244
+ // CARRY IS THE MISS INDICATOR (true = key absent); the destination
1245
+ // gets an empty array.
1246
+ const hit = this.shared.get(this.shmKey(B));
1247
+ f.carry = !hit;
1248
+ this.store(A, hit || new Uint8Array(0), true);
1249
+ return;
1250
+ }
1251
+ case 'xsetpar': {
1252
+ // CommParameter: the SGBD tells the interface handler how to drive
1253
+ // the wire. The blob DECLARES its own element width in byte 1
1254
+ // (EdOperations.OpXsetpar): 0x00 = 16-bit words, 0x01 = 32-bit
1255
+ // dwords, 0xFF = bytes; anything else is not a CommParameter. That
1256
+ // replaces the old "a dword parse gives an absurd concept" guess.
1257
+ //
1258
+ // The VM does not touch the port; it surfaces {concept, baud,
1259
+ // timeout, ...} to send(), which owns framing and the wire.
1260
+ const raw = Array.from(this.bytes(A));
1261
+ const width = raw.length >= 2 ? COMM_PARAM_WIDTHS[raw[1]] || 0 : 0;
1262
+ let words = [];
1263
+ if (width && raw.length % width === 0) {
1264
+ for (let i = 0; i + width - 1 < raw.length; i += width) {
1265
+ words.push(Best2Codec.leValue(raw, i, width));
1266
+ }
1267
+ }
1268
+ if (words.length >= 2 && words[0] > 0 && words[0] <= COMM_CONCEPT_MAX) {
1269
+ this.comm = Best2Codec.decodeCommParams(words);
1270
+ if (this.answerLen) this.comm.answerLen = this.answerLen;
1271
+ }
1272
+ return;
1273
+ }
1274
+ case 'a2y': {
1275
+ // ASCII hex -> bytes: "AB0102" (separators tolerated) into a byte
1276
+ // array. Carry reports a character that is not hex -- conversion
1277
+ // stops there, keeping the bytes parsed so far.
1278
+ const txt = Best2Codec.cstr(this.bytes(B)).trim();
1279
+ const clean = txt.replace(/^0x/i, '');
1280
+ const out = [];
1281
+ let bad = false;
1282
+ for (let i = 0; i < clean.length;) {
1283
+ if (/[\s,;:]/.test(clean[i])) {
1284
+ i += 1;
1285
+ continue;
1286
+ }
1287
+ const pair = clean.slice(i, i + 2);
1288
+ if (!/^[0-9a-fA-F]{2}$/.test(pair)) {
1289
+ bad = true;
1290
+ break;
1291
+ }
1292
+ out.push(parseInt(pair, 16));
1293
+ i += 2;
1294
+ }
1295
+ this.store(A, Uint8Array.from(out), true);
1296
+ f.carry = bad;
1297
+ f.zero = out.length === 0;
1298
+ return;
1299
+ }
1300
+ case 'eerr': {
1301
+ // The corpus idiom is `sett n / eerr` inside flash and
1302
+ // authentication jobs. The engine's exact behavior is not
1303
+ // recoverable from this repo, so raise the trapped error loudly --
1304
+ // a clean failure beats a silently-continued flash guard. If a
1305
+ // replay ever shows eerr on a passing path, the differential test
1306
+ // will flag this and it gets refined.
1307
+ throw new VmError(`SGBD raised error via eerr (trap ${this.trapBit})`);
1308
+ }
1309
+ case 'xtype': {
1310
+ // Interface type name. EdInterfaceObd reports "OBD" for the K+DCAN
1311
+ // cable this app drives; SGBDs (carb) branch on it to pick their
1312
+ // concept.
1313
+ this.store(A, Best2Codec.strBytes(INTERFACE_TYPE), true);
1314
+ return;
1315
+ }
1316
+ case 'xvers': {
1317
+ // EdInterfaceObd.InterfaceVersion: 209 (0xD1). Not the engine's
1318
+ // 7.3.0 -- the INTERFACE's version, which is what the SGBD asks.
1319
+ this.store(A, INTERFACE_VERSION);
1320
+ return;
1321
+ }
1322
+ case 'settmr': {
1323
+ // set_trap_mask: bits of errors the SGBD chooses to handle itself
1324
+ // (EdOperations.OpSettmr / EdiabasNet.SetError: an error whose bit
1325
+ // is masked does not abort the job; the SGBD reads it back with
1326
+ // `trap`/`gettmr`). This is NOT a timer.
1327
+ this.trapMask = this.val(A) >>> 0;
1328
+ return;
1329
+ }
1330
+ case 'gettmr': {
1331
+ this.store(A, this.trapMask >>> 0);
1332
+ return;
1333
+ }
1334
+ case 'ticks': {
1335
+ // milliseconds of wall clock (EdOperations.OpTicks: Ticks / 10000)
1336
+ this.store(A, (this.now ? this.now.getTime() : Date.now()) >>> 0);
1337
+ return;
1338
+ }
1339
+ case 'date': {
1340
+ // dd.MM.yyyy text, the German convention AIF date fields expect.
1341
+ // this.now (when the caller set one) keeps the bytes identical
1342
+ // across webshim's replay passes -- see the constructor.
1343
+ const d = this.now || new Date();
1344
+ this.storeText(
1345
+ A,
1346
+ `${twoDigits(d.getDate())}.${twoDigits(d.getMonth() + 1)}.${d.getFullYear()}`
1347
+ );
1348
+ return;
1349
+ }
1350
+ case 'time': {
1351
+ const d = this.now || new Date();
1352
+ this.storeText(
1353
+ A,
1354
+ `${twoDigits(d.getHours())}:${twoDigits(d.getMinutes())}:${twoDigits(d.getSeconds())}`
1355
+ );
1356
+ return;
1357
+ }
1358
+ case 'wait': {
1359
+ // The VM is synchronous, so it cannot sleep -- but the TRANSPORT
1360
+ // can. Accumulate the requested pause and hand it to the next
1361
+ // exchange via comm, where runExchange honors it before writing.
1362
+ // Pacing-sensitive sequences (reset-then-reident, adaptation
1363
+ // clears) stop firing back-to-back on a real wire.
1364
+ //
1365
+ // BEST/2 `wait` is in SECONDS: EdOperations.OpWait sleeps
1366
+ // arg * 1000 ms. Treating it as milliseconds retried the MS45
1367
+ // authentisierung_start key ~1 s too early -- the DME was still
1368
+ // busy, answered nothing, and the retransmit collided with the
1369
+ // late answer into an IFH-0003 cascade. `waitex` is the ms variant.
1370
+ this.pendingWaitMs =
1371
+ (this.pendingWaitMs || 0) + this.val(A) * MS_PER_SECOND;
1372
+ return;
1373
+ }
1374
+ case 'waitex': {
1375
+ // BEST/2 `waitex`: the same pause in MILLISECONDS (OpWaitex).
1376
+ this.pendingWaitMs = (this.pendingWaitMs || 0) + this.val(A);
1377
+ return;
1378
+ }
1379
+ case 'generr': {
1380
+ // Deliberately raise an EDIABAS error. Modeled like sett: the
1381
+ // error lands in the trap register where jt/jnt can test it, which
1382
+ // is the trappable half of the engine's behavior. (The engine also
1383
+ // terminates when the error is unmasked; the trap-mask layer is
1384
+ // not decoded, so trapping is the conservative choice -- a job
1385
+ // that meant to die still takes its error path via jt.)
1386
+ this.trapBit = this.val(A) || TRAP_USER;
1387
+ return;
1388
+ }
1389
+ case 'fopen': {
1390
+ // SGBDs open runtime scratch files (ALC_60: c:/ediabas/ecu/
1391
+ // cod_lm.dat -- a coding session's leftovers, absent even on a
1392
+ // fresh real install). No virtual filesystem is provided, so every
1393
+ // open MISSES with honest flags rather than leaving stale
1394
+ // registers: handle 0, zero = failure -- the state a real engine
1395
+ // reports on a clean machine, and what the jobs' own jz guards
1396
+ // expect to handle.
1397
+ this.store(A, 0);
1398
+ f.zero = true;
1399
+ f.carry = true;
1400
+ return;
1401
+ }
1402
+ case 'freadln':
1403
+ case 'fread': {
1404
+ // nothing is ever open (see fopen): empty read, zero = at end
1405
+ this.store(A, new Uint8Array(0), true);
1406
+ f.zero = true;
1407
+ f.carry = true;
1408
+ return;
1409
+ }
1410
+ case 'xawlen': {
1411
+ // set_answer_length (EdOperations.OpXawlen): int16 pairs. For DS2 the
1412
+ // interface derives the answer's total length from it
1413
+ // (EdInterfaceObd.TelLengthDs2): [n>0] fixed n bytes; [-o, k] = the
1414
+ // byte at offset o plus k. zke5/lws5 say [-1, 0]; ms450ds0 [-3, 5].
1415
+ // Carried on comm so the transport reads the SGBD's rule, not a
1416
+ // guessed "length is byte 1".
1417
+ const raw = Array.from(this.bytes(A));
1418
+ const al = [];
1419
+ for (let i = 0; i + 1 < raw.length; i += 2) {
1420
+ const v = raw[i] | (raw[i + 1] << 8);
1421
+ al.push(v & 0x8000 ? v - 0x10000 : v);
1422
+ }
1423
+ this.answerLen = al;
1424
+ this.comm = Object.assign({}, this.comm || {}, { answerLen: al });
1425
+ return;
1426
+ }
1427
+ case 'xconnect':
1428
+ case 'xhangup':
1429
+ case 'xstopf':
1430
+ case 'xreps':
1431
+ case 'xkeyb':
1432
+ case 'xkeybytes':
1433
+ case 'xprog':
1434
+ case 'xreset':
1435
+ case 'setflt':
1436
+ case 'clrflt':
1437
+ case 'cfgig':
1438
+ case 'cfgsg':
1439
+ case 'cfgss':
1440
+ case 'trap':
1441
+ case 'plink':
1442
+ case 'pjob':
1443
+ case 'pexec':
1444
+ case 'fclose':
1445
+ case 'fseekln':
1446
+ case 'fwrite':
1447
+ case 'ergsysi':
1448
+ case 'iupdate':
1449
+ case 'realf':
1450
+ // ergsysi stays a no-op ON PURPOSE: it publishes into result set 0,
1451
+ // which this VM deliberately never synthesizes (the engine injects
1452
+ // set 0; see SYSTEM_RESULTS in test_bestvm.js).
1453
+ return;
1454
+
1455
+ default:
1456
+ // Silence is how a VM produces wrong answers. An unknown opcode
1457
+ // throws, the differential test catches it, and the gap gets
1458
+ // implemented instead of guessed.
1459
+ throw new VmError(`unimplemented opcode ${name} at ${pc}`);
1460
+ }
1461
+ },
1462
+ });
1463
+
1464
+ if (typeof module !== 'undefined' && module.exports) {
1465
+ module.exports = {
1466
+ JUMP_TESTS,
1467
+ ERG_SPECS,
1468
+ FLAG_CARRY,
1469
+ FLAG_ZERO,
1470
+ FLAG_SIGN,
1471
+ FLAG_OVERFLOW,
1472
+ TRAP_BIT_ALIAS_UNMAPPED,
1473
+ INTERFACE_TYPE,
1474
+ INTERFACE_VERSION,
1475
+ COMM_PARAM_WIDTHS,
1476
+ COMM_CONCEPT_MAX,
1477
+ MS_PER_SECOND,
1478
+ wrapToWidth,
1479
+ negateU32,
1480
+ toInt32,
1481
+ twoDigits,
1482
+ };
1483
+ }