bmweb-cli 0.1.0 → 0.1.1

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/dist/bmweb.js CHANGED
@@ -1923,6 +1923,12 @@ function nodeTerminal() {
1923
1923
  subs.delete(fn);
1924
1924
  };
1925
1925
  },
1926
+ onResize(fn) {
1927
+ output.on("resize", fn);
1928
+ return () => {
1929
+ output.off("resize", fn);
1930
+ };
1931
+ },
1926
1932
  async readLine(prompt) {
1927
1933
  paused = true;
1928
1934
  raw(false);
@@ -1952,6 +1958,10 @@ var TuiUi = class {
1952
1958
  stopRequested = false;
1953
1959
  writeKeys = /* @__PURE__ */ new Set();
1954
1960
  program = null;
1961
+ /** the lines of the frame on screen, when the cursor sits right below it */
1962
+ frame = [];
1963
+ /** the terminal size the frame was drawn for; a change draws fresh */
1964
+ frameSize = "";
1955
1965
  leftResolve = null;
1956
1966
  /** resolves once the program reports it left the module */
1957
1967
  leftPromise;
@@ -1970,6 +1980,19 @@ var TuiUi = class {
1970
1980
  this.leftPromise = new Promise((res) => {
1971
1981
  this.leftResolve = res;
1972
1982
  });
1983
+ if (term.onResize) term.onResize(() => this.resized());
1984
+ }
1985
+ /**
1986
+ * The terminal changed size: the frame on screen no longer fits its
1987
+ * rows, so it is erased (as far as the cursor can climb back over it)
1988
+ * and drawn again for the new size.
1989
+ */
1990
+ resized() {
1991
+ if (this.frame.length) {
1992
+ this.term.write(`\x1B[${this.frame.length}A\r\x1B[J`);
1993
+ this.frame = [];
1994
+ }
1995
+ if (this.program) this.paint(this.program);
1973
1996
  }
1974
1997
  /**
1975
1998
  * BMWeb's own picker (the home script's bmweb_pick): the host's list as
@@ -1996,7 +2019,7 @@ var TuiUi = class {
1996
2019
  const rows = shown.map(
1997
2020
  (o, i) => ` ${String(i + 1).padStart(3)}. ${o.label}${o.meta ? ` (${o.meta})` : ""}`
1998
2021
  ).join("\n");
1999
- const a = await this.term.readLine(
2022
+ const a = await this.ask(
2000
2023
  `
2001
2024
  ${title}${shown.length !== options.length ? ` (${shown.length} of ${options.length})` : ""}
2002
2025
  ${rows}
@@ -2017,6 +2040,18 @@ Number to open, text to filter, Enter to cancel: `
2017
2040
  attach(p) {
2018
2041
  this.program = p;
2019
2042
  }
2043
+ /**
2044
+ * A prompt on the terminal. It scrolls the frame away from under the
2045
+ * cursor, so the frame is forgotten and the next paint draws fresh below
2046
+ * the answer (the prompt and its answer stay in the transcript, the way a
2047
+ * tool call does).
2048
+ * @param prompt - the prompt text
2049
+ * @returns the answer, or null when cancelled
2050
+ */
2051
+ async ask(prompt) {
2052
+ this.frame = [];
2053
+ return this.term.readLine(prompt);
2054
+ }
2020
2055
  /** Esc during a parked state machine: stop it at the next tick. */
2021
2056
  requestStop() {
2022
2057
  this.stopRequested = true;
@@ -2038,7 +2073,7 @@ Number to open, text to filter, Enter to cancel: `
2038
2073
  this.drawFooter();
2039
2074
  }
2040
2075
  async message(title, body) {
2041
- await this.term.readLine(
2076
+ await this.ask(
2042
2077
  `
2043
2078
  ${title}${body ? `
2044
2079
  ${body}` : ""}
@@ -2064,7 +2099,7 @@ ${body}` : ""}
2064
2099
  if (name === "inputdigital") {
2065
2100
  const f = prompts[prompts.length - 2] || "OFF";
2066
2101
  const t = prompts[prompts.length - 1] || "ON";
2067
- const a = await this.term.readLine(
2102
+ const a = await this.ask(
2068
2103
  `
2069
2104
  ${p0}
2070
2105
  ${p1}
@@ -2074,12 +2109,10 @@ ${p1}
2074
2109
  return /^y/i.test(a.trim()) ? 1 : 0;
2075
2110
  }
2076
2111
  if (name === "builtin_3f" && prompts.length <= 2 && refs === 1) {
2077
- const a = await this.term.readLine(
2078
- `
2112
+ const a = await this.ask(`
2079
2113
  ${p0}
2080
2114
  ${p1}
2081
- [OK = y, cancel = n] `
2082
- );
2115
+ [OK = y, cancel = n] `);
2083
2116
  return a != null && /^y/i.test(a.trim()) ? 0 : null;
2084
2117
  }
2085
2118
  const hex = /hex/i.test(name);
@@ -2089,7 +2122,7 @@ ${p1}
2089
2122
  for (let k = 0; k < refs; k++) {
2090
2123
  const cap = refs > 1 ? prompts[2 + k] || `${p0} (${k + 1}/${refs})` : p1;
2091
2124
  const range = step.lo != null && step.hi != null && !hex ? ` [${step.lo}..${step.hi}]` : "";
2092
- const a = await this.term.readLine(`
2125
+ const a = await this.ask(`
2093
2126
  ${p0}
2094
2127
  ${cap}${range}: `);
2095
2128
  if (a == null) return null;
@@ -2111,7 +2144,7 @@ ${cap}${range}: `);
2111
2144
  return refs > 1 ? vals : vals[0];
2112
2145
  }
2113
2146
  async confirmKey(_p, it, jobs, writes) {
2114
- const a = await this.term.readLine(
2147
+ const a = await this.ask(
2115
2148
  `
2116
2149
  Run "${it.label || it.legendLabel || `F${it.nr}`}"? It can send ${jobs.join(", ")} and ${writes.join(", ")} write${writes.length === 1 ? "s" : ""} to the module. [y/N] `
2117
2150
  );
@@ -2119,7 +2152,7 @@ Run "${it.label || it.legendLabel || `F${it.nr}`}"? It can send ${jobs.join(", "
2119
2152
  }
2120
2153
  async confirmWrite(_p, job, arg, ctx) {
2121
2154
  const every = String(ctx.scope || "").startsWith("screen:") ? " This screen sends it on every refresh; yes allows it while the screen is open." : "";
2122
- const a = await this.term.readLine(
2155
+ const a = await this.ask(
2123
2156
  `
2124
2157
  Send ${job}${arg ? ` ${arg}` : ""} to the module (${ctx.label})?${every} [y/N] `
2125
2158
  );
@@ -2144,7 +2177,7 @@ Send ${job}${arg ? ` ${arg}` : ""} to the module (${ctx.label})?${every} [y/N] `
2144
2177
  }
2145
2178
  const list = rows.map((r, i2) => ` ${i2 + 1}. ${r.caption} (${r.key})`).join("\n");
2146
2179
  if (step.multiple) {
2147
- const a2 = await this.term.readLine(
2180
+ const a2 = await this.ask(
2148
2181
  `
2149
2182
  ${list}
2150
2183
  Components, comma-separated (Enter cancels): `
@@ -2155,14 +2188,12 @@ Components, comma-separated (Enter cancels): `
2155
2188
  if (!picked.length) return null;
2156
2189
  return { ort: picked.map((r) => r.key).join(";"), ein: 0 };
2157
2190
  }
2158
- const a = await this.term.readLine(
2159
- `
2191
+ const a = await this.ask(`
2160
2192
  ${list}
2161
- Component number (Enter cancels): `
2162
- );
2193
+ Component number (Enter cancels): `);
2163
2194
  const [i] = pickNumbers(a, rows.length);
2164
2195
  if (i == null) return null;
2165
- const onOff = await this.term.readLine(`On or off? [on/off] `);
2196
+ const onOff = await this.ask(`On or off? [on/off] `);
2166
2197
  if (onOff == null || !onOff.trim()) return null;
2167
2198
  return {
2168
2199
  ort: rows[i].key,
@@ -2180,7 +2211,7 @@ Component number (Enter cancels): `
2180
2211
  return null;
2181
2212
  }
2182
2213
  const list = names.map((n, i) => ` ${i + 1}. ${n}`).join("\n");
2183
- const a = await this.term.readLine(
2214
+ const a = await this.ask(
2184
2215
  `
2185
2216
  ${list}
2186
2217
  Lines to show${multiple ? ", comma-separated" : ""} (a = all, Enter cancels): `
@@ -2192,7 +2223,7 @@ Lines to show${multiple ? ", comma-separated" : ""} (a = all, Enter cancels): `
2192
2223
  }
2193
2224
  /** INPA's save-as dialog: a file name, written when the body ends. */
2194
2225
  async saveFile() {
2195
- const a = await this.term.readLine(`
2226
+ const a = await this.ask(`
2196
2227
  Save as [fault-memory.txt]: `);
2197
2228
  if (a == null) return null;
2198
2229
  return { name: a.trim() || "fault-memory.txt" };
@@ -2205,6 +2236,7 @@ Save as [fault-memory.txt]: `);
2205
2236
  this.term.write(`
2206
2237
  ${this.gridLines(p).join("\n")}
2207
2238
  `);
2239
+ this.frame = [];
2208
2240
  }
2209
2241
  /**
2210
2242
  * The module a scriptchange names. From the home script it is the car's
@@ -2258,23 +2290,62 @@ ${this.gridLines(p).join("\n")}
2258
2290
  }
2259
2291
  this.paint(p);
2260
2292
  }
2261
- /** Redraw everything: title, grid, keys, footer. */
2293
+ /** Redraw everything: title, grid, keys, footer -- in place. */
2262
2294
  paint(p) {
2295
+ this.flush(this.frameLines(p));
2296
+ }
2297
+ /**
2298
+ * The frame as lines: title, rule, the view or the grid, a blank, the
2299
+ * key bar, then the status and progress lines. Cut to the terminal's
2300
+ * width (a wrapped line would break the row count the redraw relies on)
2301
+ * and to its height, the body giving way first.
2302
+ * @param p - the program
2303
+ * @returns the lines, none wider than the terminal
2304
+ */
2305
+ frameLines(p) {
2263
2306
  const w = this.term.columns;
2264
- const out = [];
2265
2307
  const title = `${p.ecu.label || p.ecu.sgbd} ${p.ecu.sgbd}.prg ${p.title || ""}`.trim();
2266
- out.push(title.slice(0, w));
2267
- out.push("-".repeat(Math.min(w, 78)));
2268
- if (p.view) {
2269
- out.push(...(p.view.lines || []).slice(0, this.term.rows - 8));
2270
- } else {
2271
- out.push(...this.gridLines(p));
2308
+ const head = [title, "-".repeat(Math.min(w, 78))];
2309
+ let body = p.view ? [...p.view.lines || []] : this.gridLines(p);
2310
+ const tail = ["", ...this.keyLines(p), this.statusText, this.progressText];
2311
+ const room = Math.max(1, this.term.rows - 1 - head.length - tail.length);
2312
+ if (body.length > room) body = body.filter((l, i) => l || body[i - 1]);
2313
+ if (body.length > room) {
2314
+ const hidden = body.length - (room - 1);
2315
+ body = [
2316
+ ...body.slice(0, room - 1),
2317
+ `(${hidden} more rows: enlarge the terminal)`
2318
+ ];
2272
2319
  }
2273
- out.push("");
2274
- out.push(...this.keyLines(p));
2275
- this.term.write(`\x1B[H\x1B[2J${out.join("\n")}
2276
- `);
2277
- this.drawFooter();
2320
+ return [...head, ...body, ...tail].map(
2321
+ (l) => String(l).replace(/[\r\n]/g, " ").slice(0, w)
2322
+ );
2323
+ }
2324
+ /**
2325
+ * Put a frame on the terminal over the last one. With a frame on screen
2326
+ * the cursor is on the line under it: go up to its top, rewrite the lines
2327
+ * that differ (each erased to the end of the row), step over the ones
2328
+ * that match, and erase whatever the old frame had below the new one.
2329
+ * Without one (first paint, after a prompt, after a resize) the frame is
2330
+ * written where the cursor is.
2331
+ * @param lines - the new frame
2332
+ */
2333
+ flush(lines) {
2334
+ const size = `${this.term.columns}x${this.term.rows}`;
2335
+ if (size !== this.frameSize) this.frame = [];
2336
+ this.frameSize = size;
2337
+ const prev = this.frame;
2338
+ if (prev.length === lines.length && prev.every((l, i) => l === lines[i]))
2339
+ return;
2340
+ let s = prev.length ? `\x1B[${prev.length}A` : "";
2341
+ lines.forEach((line, i) => {
2342
+ if (i < prev.length && prev[i] === line) s += "\x1B[B";
2343
+ else s += `\r${line}\x1B[K\r
2344
+ `;
2345
+ });
2346
+ if (lines.length < prev.length) s += "\r\x1B[J";
2347
+ if (s) this.term.write(s);
2348
+ this.frame = lines;
2278
2349
  }
2279
2350
  /**
2280
2351
  * The grid as text rows: cells placed at their column, a lamp as a dot
@@ -2314,12 +2385,20 @@ ${this.gridLines(p).join("\n")}
2314
2385
  );
2315
2386
  return out;
2316
2387
  }
2317
- /** The two bottom lines: status and progress. */
2388
+ /**
2389
+ * The two bottom lines of the frame: status and progress. They are part
2390
+ * of the frame, so a change repaints it (which writes just those lines);
2391
+ * before a program exists the text is written on its own.
2392
+ */
2318
2393
  drawFooter() {
2319
- this.term.write(
2320
- `\x1B[s\x1B[${Math.max(1, this.term.rows - 1)};1H\x1B[K${this.statusText}
2321
- \x1B[K${this.progressText}\x1B[u`
2322
- );
2394
+ if (this.program) {
2395
+ this.paint(this.program);
2396
+ return;
2397
+ }
2398
+ this.frame = [];
2399
+ this.term.write(`${this.statusText}\r
2400
+ ${this.progressText}\r
2401
+ `);
2323
2402
  }
2324
2403
  left() {
2325
2404
  if (this.leftResolve) this.leftResolve();
@@ -2446,7 +2525,7 @@ async function tuiCommand(chassis, sgbd, opts = {}) {
2446
2525
  }
2447
2526
 
2448
2527
  // src/bmweb.ts
2449
- var VERSION = true ? "0.1.0" : "0.0.0-dev";
2528
+ var VERSION = true ? "0.1.1" : "0.0.0-dev";
2450
2529
  var INCLUDE = {
2451
2530
  include: {
2452
2531
  kind: "list",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bmweb-cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "BMWeb's tools as a command line: read INPA .IPO scripts, compile .IPS sources, search the corpus job index, decode and diff shared Garage reports, and, over a K+DCAN cable, run jobs, whole-car scans and INPA screens in the terminal.",
5
5
  "license": "GPL-3.0-only",
6
6
  "type": "module",
@@ -41,9 +41,7 @@ if (typeof module !== 'undefined' && module.exports) {
41
41
  JUMP_TESTS,
42
42
  REG_BYTES,
43
43
  isWriteJob,
44
- // the classifier's parts, exported so test_write_gate.js
45
- // can compare each against its Python twin in
46
- // tools/verify/sgbd_bulk_verify.py pattern-by-pattern
44
+ // the classifier's parts, exported for tests and tooling
47
45
  READ_TOKEN,
48
46
  CONFIG_READ_TOKEN,
49
47
  WRITE_TOKEN,
@@ -6,8 +6,9 @@
6
6
  * strings); executing the program handles all of it, which is why EDIABAS is
7
7
  * flawless. Input is tools/sgbd_code.py output (ops array, jumps as indices);
8
8
  * telegram I/O is a callback, so one VM runs live cable / .sim / fixture.
9
- * Semantics ported from vendored EdiabasLib (EdOperations.cs, EdiabasNet.cs),
10
- * the engine tools/sgbd_bulk_verify.py diffs against and test_bestvm.js checks.
9
+ * Semantics ported from EdiabasLib (EdOperations.cs, EdiabasNet.cs); the
10
+ * engine's own result sets for 460 E46 jobs are the committed fixture
11
+ * data/sim-captures/vmfix.json that test_bestvm.js replays.
11
12
  *
12
13
  * THE REGISTER MODEL, which nothing else here makes sense without:
13
14
  * B/I/L/A are VIEWS over one 32-byte array, LITTLE-endian within a view, so
@@ -35,9 +35,9 @@
35
35
  * changed in the corpus -- but with INFO checked after writes (rule 3), a
36
36
  * hypothetical SYSTEMCHECK_STOP_INFO must hit the write tier, not fall
37
37
  * through to the INFO tier. Relaxing a WRITE token is the safe direction.
38
- * Kept identical to the classifier in tools/verify/sgbd_bulk_verify.py --
39
- * two different answers to "is this a write?" is worse than either alone.
40
- * test_write_gate.js check 3 enforces the twin token-by-token.
38
+ * This is the ONE classifier: the Python twin that the retired engine
39
+ * harness carried is gone, so there is no second answer to "is this a
40
+ * write?" to drift from.
41
41
  */
42
42
 
43
43
  /**
@@ -444,6 +444,18 @@ function bViewopen(vm, stack) {
444
444
  };
445
445
  }
446
446
 
447
+ /**
448
+ * printfile(->ErrorCode, FileName, PrinterName, PrinterPort, ErrorMsgFlag):
449
+ * INPA prints the named file (the fault protocol a read wrote) on the
450
+ * printer. The print itself is the runtime's (suspensions.js 'printfile');
451
+ * here the error code is 0, printed or not, so the script never takes its
452
+ * "could not print" branch over a sheet the browser handles.
453
+ * @type {IpoBuiltin}
454
+ */
455
+ function bPrintfile(vm, stack) {
456
+ storeOut(vm, stack, 0);
457
+ }
458
+
447
459
  /**
448
460
  * filewrite(text): append a line to the open file.
449
461
  * @type {IpoBuiltin}
@@ -601,6 +613,7 @@ if (typeof module !== 'undefined' && module.exports) {
601
613
  bInputDigital,
602
614
  bFileopen,
603
615
  bFileclose,
616
+ bPrintfile,
604
617
  bFilewrite,
605
618
  bFileread,
606
619
  bSettimer,
@@ -13,6 +13,12 @@ const IPO_ANALOG_FMT_RE = /^(\d+)\.(\d+)$/;
13
13
  /** toFixed()'s upper bound on decimals; a stray format never asks for more. */
14
14
  const IPO_MAX_DECIMALS = 20;
15
15
 
16
+ /** Bytes per line of INPA's hexdump. */
17
+ const IPO_HEXDUMP_LINE = 16;
18
+
19
+ /** The fewest hex digits a hexdump address column shows. */
20
+ const IPO_HEXDUMP_ADDR_MIN = 4;
21
+
16
22
  /**
17
23
  * settitle / setmenutitle: the first argument is the title.
18
24
  * @type {IpoBuiltin}
@@ -113,6 +119,38 @@ function bUserboxClear(vm) {
113
119
  if (vm.onUserbox) vm.onUserbox(vm.userbox);
114
120
  }
115
121
 
122
+ /**
123
+ * The foreground / background pair a colour builtin was handed: its last
124
+ * two integer arguments (userboxsetcolor leads with the box number).
125
+ * @param {IpoValue[]} stack - the call's arguments
126
+ * @returns {{fg: number, bk: number}|null} null when the call named no pair
127
+ */
128
+ function ipoColorArgs(stack) {
129
+ const n = allInts(stack);
130
+ if (n.length < 2) return null;
131
+ return { fg: n[n.length - 2], bk: n[n.length - 1] };
132
+ }
133
+
134
+ /**
135
+ * setcolor(FgColor, BkColor): the colour every text printed after it takes.
136
+ * Recorded on the run so a host that draws colour can honour it; the
137
+ * browser's skins keep their own palette.
138
+ * @type {IpoBuiltin}
139
+ */
140
+ function bSetcolor(vm, stack) {
141
+ const c = ipoColorArgs(stack);
142
+ if (c) vm.out.color = c;
143
+ }
144
+
145
+ /**
146
+ * userboxsetcolor(BoxNum, FgColor, BkColor): the progress window's colours.
147
+ * @type {IpoBuiltin}
148
+ */
149
+ function bUserboxSetcolor(vm, stack) {
150
+ const c = ipoColorArgs(stack);
151
+ if (c && vm.userbox) vm.userbox.color = c;
152
+ }
153
+
116
154
  /**
117
155
  * userboxftextout(text, row, col ...): a line of the progress window. It is
118
156
  * also a textout, so a body without a box paints it with the screen.
@@ -234,9 +272,69 @@ function bTextout(vm, stack) {
234
272
  el.row = ints[0];
235
273
  el.col = ints[1];
236
274
  }
275
+ // the colour setcolor chose for what follows rides on the live cell so a
276
+ // host that draws colour (the terminal UI) can; offline the element stays
277
+ // byte-identical to the Python twin's
278
+ if (vm.wireJobs && vm.out.color) el.color = vm.out.color;
237
279
  line.elements.push(el);
238
280
  }
239
281
 
282
+ /**
283
+ * The lines of INPA's hexdump: one per 16 bytes, the address (the start
284
+ * address plus the line's offset, in hex, at least as wide as the start
285
+ * address was written) then the bytes as two hex digits each. A start
286
+ * address that is not hex counts from 0.
287
+ * @param {string} startAdr - the address the bytes were read from ("0x1000")
288
+ * @param {number[]} bytes - the bytes to show
289
+ * @returns {string[]}
290
+ */
291
+ function ipoHexdumpLines(startAdr, bytes) {
292
+ const digits = String(startAdr || '')
293
+ .trim()
294
+ .replace(/^0x/i, '');
295
+ const base = /^[0-9a-f]+$/i.test(digits) ? parseInt(digits, 16) : 0;
296
+ const width = Math.max(IPO_HEXDUMP_ADDR_MIN, digits.length);
297
+ const out = [];
298
+ for (let i = 0; i < bytes.length; i += IPO_HEXDUMP_LINE) {
299
+ const addr = (base + i).toString(16).toUpperCase().padStart(width, '0');
300
+ const hex = bytes
301
+ .slice(i, i + IPO_HEXDUMP_LINE)
302
+ .map((b) => (b & 0xff).toString(16).toUpperCase().padStart(2, '0'));
303
+ out.push(`${addr} ${hex.join(' ')}`);
304
+ }
305
+ return out;
306
+ }
307
+
308
+ /**
309
+ * hexdump(StartAdr, numbytes, row, col): the bytes of the last
310
+ * INPAapiResultBinary painted as a hex table at (row, col), the way the
311
+ * Speicher-lesen screens show memory. The bytes come from the wire, so
312
+ * offline there is nothing to paint (the lift sees the call, not a table).
313
+ * Each line is printed as ftextout would print it, one row down from the
314
+ * last, so the grid, the sheet and Select treat it as text.
315
+ * @type {IpoBuiltin}
316
+ */
317
+ function bHexdump(vm, stack) {
318
+ if (!vm.wireJobs) return;
319
+ const key = vm.globals.get('__pending_binary__');
320
+ if (!key || !vm.host || typeof vm.host.raw !== 'function') return;
321
+ const set = vm.globals.get('__pending_binary_set__');
322
+ const hex = ipoBinaryHex(
323
+ vm.host.raw(key, { set: set == null ? undefined : set })
324
+ );
325
+ if (!hex) return;
326
+ let bytes = (hex.match(/[0-9A-F]{2}/g) || []).map((h) => parseInt(h, 16));
327
+ const args = stack.filter((x) => !isRef(x));
328
+ const count = args.length > 1 ? Math.trunc(num(args[1])) : bytes.length;
329
+ if (count > 0) bytes = bytes.slice(0, count);
330
+ const row = args.length > 2 ? Math.trunc(num(args[2])) : 0;
331
+ const col = args.length > 3 ? Math.trunc(num(args[3])) : 0;
332
+ const startAdr = args.length ? asStr(args[0]) : '';
333
+ ipoHexdumpLines(startAdr, bytes).forEach((text, i) =>
334
+ bTextout(vm, [text, row + i, col])
335
+ );
336
+ }
337
+
240
338
  /**
241
339
  * The element a lamp or bar builtin declares, placed on the current line.
242
340
  * Offline the row/col come from the int scan the Python twin uses (for
@@ -464,7 +562,20 @@ function bCallwin(vm, stack, item) {
464
562
  }
465
563
 
466
564
  /**
467
- * A builtin with no effect on the model (window chrome, colours, stop).
565
+ * viewclose(): INPA closes its viewer window. Live, the run says so and
566
+ * drops a view it opened earlier in the same body; a viewopen after it
567
+ * sets a new one, which wins (the runtime reads the view first, the close
568
+ * only when there is none). Offline nothing is lifted from a window.
569
+ * @type {IpoBuiltin}
570
+ */
571
+ function bViewclose(vm) {
572
+ if (!vm.wireJobs) return;
573
+ vm.out.view = null;
574
+ vm.out.viewClose = true;
575
+ }
576
+
577
+ /**
578
+ * A builtin with no effect on the model (window chrome, stop).
468
579
  * @type {IpoBuiltin}
469
580
  */
470
581
  function bNoop() {}
@@ -476,6 +587,11 @@ if (typeof module !== 'undefined' && module.exports) {
476
587
  bSetmenu,
477
588
  bSetscreen,
478
589
  bTextout,
590
+ ipoHexdumpLines,
591
+ bHexdump,
592
+ bSetcolor,
593
+ bUserboxSetcolor,
594
+ bViewclose,
479
595
  drawField,
480
596
  bAnalogout,
481
597
  bMultiAnalogout,
@@ -56,7 +56,7 @@ const BUILTINS = {
56
56
  userboxopen: bUserboxOpen,
57
57
  userboxclose: bUserboxClose,
58
58
  viewopen: bViewopen,
59
- viewclose: bNoop,
59
+ viewclose: bViewclose,
60
60
  setstate: bSetstate,
61
61
  start: bSetstate,
62
62
  select: bSelect,
@@ -81,8 +81,8 @@ const BUILTINS = {
81
81
  fileclose: bFileclose,
82
82
  filewrite: bFilewrite,
83
83
  fileread: bFileread,
84
- hexdump: bNoop,
85
- printfile: bNoop,
84
+ hexdump: bHexdump,
85
+ printfile: bPrintfile,
86
86
  setstatemachine: bNoop,
87
87
  StrArrayCreate: bStrArrayCreate,
88
88
  StrArrayDestroy: bNoop,
@@ -147,7 +147,8 @@ const BUILTINS = {
147
147
  builtin_87: bUnavailable,
148
148
  builtin_93: bUnavailable,
149
149
  builtin_90: bStrArraySize, // string array length, out-param
150
- builtin_1a: bNoop, // setcolor
150
+ builtin_1a: bSetcolor, // setcolor
151
+ setcolor: bSetcolor,
151
152
  builtin_51: bBlankscreen, // blankscreen
152
153
  blankscreen: bBlankscreen,
153
154
  settimer: bSettimer,
@@ -158,7 +159,8 @@ const BUILTINS = {
158
159
  bmweb_pick: bBmwebPick,
159
160
  bmweb_status: bBmwebStatus,
160
161
  builtin_57: bUserboxClear, // userboxclear
161
- builtin_58: bNoop, // userboxsetcolor
162
+ builtin_58: bUserboxSetcolor, // userboxsetcolor
163
+ userboxsetcolor: bUserboxSetcolor,
162
164
  };
163
165
 
164
166
  if (typeof module !== 'undefined' && module.exports) {
@@ -24,6 +24,15 @@
24
24
  * @property {string} [fmt] - analogout display format ("6.2")
25
25
  * @property {string} [on] - digitalout's TrueText
26
26
  * @property {string} [off] - digitalout's FalseText
27
+ * @property {IpoColor} [color] - the setcolor in force when a live run printed it
28
+ */
29
+
30
+ /**
31
+ * A colour pair as INPA's setcolor / userboxsetcolor name it: two of
32
+ * INPA's palette indices. Recorded for hosts that draw colour.
33
+ * @typedef {object} IpoColor
34
+ * @property {number} fg - foreground index
35
+ * @property {number} bk - background index
27
36
  */
28
37
 
29
38
  /**
@@ -111,6 +120,18 @@ class Emissions {
111
120
  * @type {{path: string, lines: string[]}|null}
112
121
  */
113
122
  this.view = null;
123
+ /**
124
+ * viewclose(): a live body closed INPA's viewer window. Read only when
125
+ * no viewopen in the same body left a view behind.
126
+ * @type {boolean}
127
+ */
128
+ this.viewClose = false;
129
+ /**
130
+ * setcolor(fg, bk): the colour in force for the text printed after it,
131
+ * null until a body sets one.
132
+ * @type {IpoColor|null}
133
+ */
134
+ this.color = null;
114
135
  /**
115
136
  * setscreen's second argument: TRUE = a frequent screen, re-run its cycle
116
137
  * while it is current (INPA's WM_TIMER loop); null = no setscreen.
@@ -12,12 +12,14 @@
12
12
  * says why; `out` is the emissions so far (a parked machine's drawn screen
13
13
  * IS the picker).
14
14
  * @typedef {object} IpoStep
15
- * @property {'done'|'yield'|'job'|'wait'|'input'|'message'|'toggle'|'print'|'select'|'exit'} kind -
15
+ * @property {'done'|'yield'|'job'|'wait'|'input'|'message'|'toggle'|'print'|'printfile'|'select'|'exit'} kind -
16
16
  * done: the proc finished; yield: parked at a %STATE; job: a wire job to
17
17
  * run and feed back; wait: a timed wartezeit; input: an INPA prompt;
18
18
  * message: a blocking messagebox; toggle: the component picker; print:
19
- * printscreen; select: INPA's line filter; exit: the script ended itself
19
+ * printscreen; printfile: a written file to print; select: INPA's line
20
+ * filter; exit: the script ended itself
20
21
  * @property {Emissions} [out] - emissions so far
22
+ * @property {string} [file] - printfile / fsread: the file's name
21
23
  * @property {string} [name] - yield: the state label
22
24
  * @property {string} [job] - job: the job name
23
25
  * @property {string|null} [sgbd] - job: the SGBD the script addressed
@@ -46,7 +48,9 @@ const IPO_SUSPEND_KINDS = new Set([
46
48
  'message',
47
49
  'toggle',
48
50
  'pick', // bmweb_pick: the host lists, the user chooses (home script)
51
+ 'fsread', // INPAapiFsLesen: the renderer reads the fault memory and writes the file
49
52
  'print',
53
+ 'printfile', // printfile: the renderer prints a file the body wrote
50
54
  'select',
51
55
  'exit',
52
56
  ]);
@@ -184,6 +188,15 @@ function ipoDriveBuiltin(vm, t, stack) {
184
188
  if (vm.wireJobs && name === 'printscreen') {
185
189
  return { kind: 'print', out: vm.out };
186
190
  }
191
+ // INPA's printfile(->rc, file, printer, port, flag): the protocol file a
192
+ // read wrote goes to the printer. The builtin answers rc = 0 first (the
193
+ // script's own error branch must not fire), then the renderer prints the
194
+ // file's lines from the VM's files. Offline only the rc is stored.
195
+ if (vm.wireJobs && name === 'printfile') {
196
+ vm._builtin(t, stack, null);
197
+ const strs = stack.filter((x) => !isRef(x)).map((x) => asStr(x));
198
+ return { kind: 'printfile', file: strs[0] || '', out: vm.out };
199
+ }
187
200
  // A LIVE togglelist is INPA's component picker: park until the renderer
188
201
  // hands back the pick ({ort, ein}); resume re-runs the builtin with it.
189
202
  // togglelist(MultipleSelectFlag, ArgNumFlag, ->ApiToggleString)
@@ -196,6 +209,23 @@ function ipoDriveBuiltin(vm, t, stack) {
196
209
  out: vm.out,
197
210
  };
198
211
  }
212
+ // INPAapiFsLesen(sgbd, file): INPA's API reads the module's fault memory
213
+ // (FS_LESEN, then the detail of every entry) and writes the protocol file
214
+ // the script then viewopen()s. 161 shipped scripts read faults only this
215
+ // way. Live, the renderer does that read and hands the file back; offline
216
+ // it stays a noop (nothing to lift from the API's own work).
217
+ if (
218
+ vm.wireJobs &&
219
+ (name === 'INPAapiFsLesen' || name === 'INPAapiFsLesen2')
220
+ ) {
221
+ const strs = stack.filter((x) => !isRef(x)).map((x) => asStr(x));
222
+ return {
223
+ kind: 'fsread',
224
+ sgbd: strs[0] || '',
225
+ file: strs[1] || 'na_fs.tmp',
226
+ out: vm.out,
227
+ };
228
+ }
199
229
  // BMWeb's own picker: bmweb_pick("chassis"|"module"|"vehicle", arg,
200
230
  // ->choice). Park like a togglelist; the renderer asks the host for the
201
231
  // list and resumes with the choice, which the re-run builtin stores.
@@ -178,9 +178,9 @@ const PCODE_MAP = {
178
178
  '27C2': 'P2562',
179
179
  '27C4': 'P2564',
180
180
  };
181
- // Flatten an EDIABAS result value to the same text the native bridge produces
182
- // (src/EdiabasMac/Diag.cs Format): byte arrays become dashed hex ("27-DA"),
183
- // everything else its plain string. The web VM returns live typed values --
181
+ // Flatten an EDIABAS result value to the text EDIABAS itself would print:
182
+ // byte arrays become dashed hex ("27-DA"), everything else its plain string.
183
+ // The web VM returns live typed values --
184
184
  // `ergy` (binary) emits a byte Array, `ergi`/`ergb`/... emit numbers -- so
185
185
  // screens that only ever saw the native path's strings funnel through here.
186
186
  // An empty binary result ([]) is truthy but must read as "no code", which the
@@ -20,9 +20,9 @@
20
20
  * here): SerialProxy.cs is the byte-mover behind NativeSerialBus (open/write/
21
21
  * readAvailable/close/flush). Bytes cross that bridge as a JSON int[]
22
22
  * (BmacwBridge.cs AsNumberArray), NOT base64 -- base64 corrupted the
23
- * echo/checksum. src/EdiabasMac is LEGACY (its InpaMac.Api server is deleted);
24
- * it is reference for what JS reimplemented, not a transport, and is
25
- * deliberately NOT part of this interface.
23
+ * echo/checksum. The former C# engine (deleted with its InpaMac.Api server) was
24
+ * reference for what JS reimplemented, not a transport, and was never part
25
+ * of this interface.
26
26
  */
27
27
  /* exported SerialTransportBase */
28
28
 
@@ -269,6 +269,84 @@ function ipoPrintScreen(p, ecu, inpa) {
269
269
  return Promise.resolve();
270
270
  }
271
271
 
272
+ /**
273
+ * Whether a printfile names the file the viewer is showing: the same path,
274
+ * or the same text (the API's fault read and a save-as can leave the
275
+ * script holding a different name for the same protocol).
276
+ * @param {IpoProgram} p - the program
277
+ * @param {string} name - the file name printfile passed
278
+ * @param {string[]} lines - the file's lines
279
+ * @returns {boolean}
280
+ */
281
+ function ipoPrintFileIsView(p, name, lines) {
282
+ const v = p.view;
283
+ if (!v) return false;
284
+ if (v.path && v.path === name) return true;
285
+ const shown = v.lines || [];
286
+ return (
287
+ shown.length > 0 &&
288
+ shown.length === lines.length &&
289
+ shown.every((l, i) => l === lines[i])
290
+ );
291
+ }
292
+
293
+ /**
294
+ * The print document for INPA's printfile: the fault protocol as the
295
+ * report's tables plus INPA's text when the file is the one the viewer
296
+ * shows (what printscreen prints over that view), else the file's lines
297
+ * as a monospace sheet. Pure, so the harness can pin it.
298
+ * @param {IpoProgram} p - the program
299
+ * @param {EcuRecord} ecu - the module
300
+ * @param {string} name - the file's name
301
+ * @param {string[]} lines - the file's lines
302
+ * @returns {PrintDocOptions}
303
+ */
304
+ function ipoPrintFileDocument(p, ecu, name, lines) {
305
+ const isView = ipoPrintFileIsView(p, name, lines);
306
+ const sections = [];
307
+ if (
308
+ isView &&
309
+ p.view.report &&
310
+ typeof ipoProtocolPrintSections === 'function'
311
+ ) {
312
+ sections.push(...ipoProtocolPrintSections(p.view));
313
+ } else {
314
+ sections.push({
315
+ html: `<pre class="pr-screen">${esc((lines || []).join('\n'))}</pre>`,
316
+ });
317
+ }
318
+ const subtitle =
319
+ isView && p.view.title ? ipoText(p.view.title) : ipoText(p.title || '');
320
+ return {
321
+ title: ecu.label || ecu.sgbd,
322
+ subtitle: subtitle || name,
323
+ meta: [
324
+ ecu.chassis ? ['Chassis', String(ecu.chassis).toUpperCase()] : null,
325
+ ['SGBD', `${ecu.sgbd}.prg`],
326
+ ['File', name],
327
+ ['Printed', new Date().toLocaleString()],
328
+ ],
329
+ sections,
330
+ };
331
+ }
332
+
333
+ /**
334
+ * INPA's printfile for the module view: the clean sheet when the print
335
+ * builder is loaded, nothing otherwise (the browser's own print would show
336
+ * the screen, not the file).
337
+ * @param {IpoProgram} p - the program
338
+ * @param {EcuRecord} ecu - the module
339
+ * @param {string} name - the file's name
340
+ * @param {string[]} lines - the file's lines
341
+ * @returns {Promise<void>}
342
+ */
343
+ function ipoPrintFile(p, ecu, name, lines) {
344
+ if (typeof printDoc === 'function') {
345
+ return printDoc(ipoPrintFileDocument(p, ecu, name, lines));
346
+ }
347
+ return Promise.resolve();
348
+ }
349
+
272
350
  if (typeof module !== 'undefined' && module.exports) {
273
351
  module.exports = {
274
352
  IPO_PRINT_COLS,
@@ -277,5 +355,7 @@ if (typeof module !== 'undefined' && module.exports) {
277
355
  ipoPrintKeysHtml,
278
356
  ipoPrintDocument,
279
357
  ipoPrintScreen,
358
+ ipoPrintFileDocument,
359
+ ipoPrintFile,
280
360
  };
281
361
  }
@@ -103,6 +103,8 @@ const IPO_SILENT_RE = /IFH-0009|IFH-0019|no answer/i;
103
103
  * @property {(p: IpoProgram, step: IpoStep) => Promise<IpoPick|null>} pickComponent - the togglelist picker
104
104
  * @property {(p: IpoProgram, names: string[], multiple: boolean, current: Set<string>|null, hints?: Array<{key: string, label: string, lines: number}>) => Promise<string[]|null>} pickLines - INPA's Select
105
105
  * @property {(p: IpoProgram) => void} printScreen - INPA's printscreen
106
+ * @property {(p: IpoProgram, name: string, lines: string[]) => void} [printFile] -
107
+ * INPA's printfile: a file the script wrote, by name, as its lines
106
108
  * @property {(ecu: EcuRecord, script: string, exec: IpoExec) => Promise<EcuRecord|null>} [resolveScriptEcu] -
107
109
  * the module a scriptchange target addresses, identified by the car
108
110
  * @property {(p: IpoProgram, step: IpoStep, guards: Set<number>) => Promise<'tick'|'press'|'stop'>} machineTick - a %STATE park
@@ -173,6 +175,8 @@ class IpoProgram {
173
175
  this.script = null;
174
176
  /** @type {string|null} the menu the entry set (the route's root) */
175
177
  this.rootMenu = null;
178
+ /** @type {string|null} the screen the entry set on the root menu */
179
+ this.rootScreen = null;
176
180
  this.answered = false; // INITIALISIERUNG returned any result
177
181
  this.silent = false; // INITIALISIERUNG got no answer at all
178
182
  this.noCable = false; // no adapter: the script cannot ask the car
@@ -276,6 +280,14 @@ class IpoProgram {
276
280
  this.messages.push({ title: step.title, body: step.body });
277
281
  await this.ui.message(step.title, step.body);
278
282
  step = vm.resume();
283
+ } else if (step.kind === 'fsread') {
284
+ // INPA's API fault read: FS_LESEN and every entry's detail on the
285
+ // wire, then the protocol file the script goes on to viewopen
286
+ const lines = await this.apiFaultRead(step.sgbd, step.file, ctx);
287
+ if (lines == null) return { done: false, cancelled: true };
288
+ vm.files.set(step.file, lines);
289
+ vm.lastWritten = step.file;
290
+ step = vm.resume();
279
291
  } else if (step.kind === 'pick') {
280
292
  // BMWeb's own picker (the home script): the host lists chassis,
281
293
  // modules or the whole-vehicle script; a cancel leaves the body
@@ -298,6 +310,9 @@ class IpoProgram {
298
310
  } else if (step.kind === 'print') {
299
311
  this.ui.printScreen(this);
300
312
  step = vm.resume();
313
+ } else if (step.kind === 'printfile') {
314
+ this.printFile(step.file);
315
+ step = vm.resume();
301
316
  } else if (step.kind === 'select') {
302
317
  // INPA's Select: which of the screen's named logical lines to show.
303
318
  // A screen with none still opens the box (INPA shows an empty
@@ -341,6 +356,23 @@ class IpoProgram {
341
356
  return { done: true, exit: !!(vm.out && vm.out.exit) };
342
357
  }
343
358
 
359
+ /**
360
+ * INPA's printfile: the named file's lines go to the UI's printer. The
361
+ * name is what the script computed; when the VM holds no file by it the
362
+ * last one written stands in, as viewopen's does (the API's fault read
363
+ * names its file for the script, and a save-as may have renamed it).
364
+ * @param {string} name - the file name the script passed
365
+ * @returns {void}
366
+ */
367
+ printFile(name) {
368
+ if (typeof this.ui.printFile !== 'function' || !this.vm) return;
369
+ const { files, lastWritten } = this.vm;
370
+ let path = name;
371
+ if (!files.has(path) && lastWritten && files.has(lastWritten))
372
+ path = lastWritten;
373
+ this.ui.printFile(this, path, [...(files.get(path) || [])]);
374
+ }
375
+
344
376
  /**
345
377
  * Select's choice: the names of the logical lines to show, or null for
346
378
  * all. The current screen is repainted through it; a screen change keeps
@@ -406,6 +438,79 @@ class IpoProgram {
406
438
  * @param {IpoRunContext} ctx - what is running
407
439
  * @returns {Promise<IpoFeed|null>} the feed, or null when the user declined
408
440
  */
441
+ /**
442
+ * What INPAapiFsLesen does inside INPA: read the module's fault memory
443
+ * (FS_LESEN), then the detail of every stored fault (FS_LESEN_DETAIL by
444
+ * its location number), and write the protocol file. The reads go through
445
+ * runJob, so wireReads carries them and the viewopen that follows draws
446
+ * the report joined with the fault lookup, as the whole-vehicle protocol
447
+ * does; the lines are INPA's own layout for the plain-text view.
448
+ * @param {string} sgbd - the module the script names
449
+ * @param {string} file - the file the script will viewopen
450
+ * @param {object} ctx - the body's context (scope, label)
451
+ * @returns {Promise<string[]|null>} the file's lines, or null when cancelled
452
+ */
453
+ async apiFaultRead(sgbd, file, ctx) {
454
+ const fed = await this.runJob(sgbd, 'FS_LESEN', null, ctx);
455
+ if (fed == null) return null;
456
+ const sets = (fed.sets || []).slice(1);
457
+ const faults = sets.filter(
458
+ (s) => s && (s.F_ORT_NR != null || s.F_HEX_CODE)
459
+ );
460
+ const name = String(sgbd || this.ecu.sgbd || '').toUpperCase();
461
+ const lines = [name, ''];
462
+ if (String(fed.get('JOB_STATUS') || 'OKAY') !== 'OKAY') {
463
+ lines.push(`Fehlerspeicher lesen: ${fed.get('JOB_STATUS')}`);
464
+ return lines;
465
+ }
466
+ if (!faults.length) {
467
+ lines.push('Kein Fehler im Fehlerspeicher');
468
+ return lines;
469
+ }
470
+ lines.push(
471
+ `${faults.length} Fehler im Fehlerspeicher`.replace(
472
+ /^1 Fehler/,
473
+ '1 Fehler'
474
+ ),
475
+ ''
476
+ );
477
+ for (const f of faults) {
478
+ const nr = f.F_ORT_NR != null ? String(f.F_ORT_NR) : '';
479
+ let det = f;
480
+ if (nr) {
481
+ // the detail pass, as INPA's API makes it; a module without the job
482
+ // answers with an error and the memory's own fields stand
483
+ const d = await this.runJob(sgbd, 'FS_LESEN_DETAIL', nr, ctx);
484
+ if (d == null) return null;
485
+ const ds = (d.sets || []).slice(1).find((s) => s && s.F_ORT_NR != null);
486
+ if (ds) det = Object.assign({}, f, ds);
487
+ }
488
+ const hex =
489
+ typeof hexText === 'function'
490
+ ? hexText(det.F_HEX_CODE)
491
+ : String(det.F_HEX_CODE || '');
492
+ lines.push(
493
+ `${nr}${hex ? ` (${hex})` : ''} ${det.F_ORT_TEXT || ''}`.trim()
494
+ );
495
+ const bits = [];
496
+ if (det.F_ART1_TEXT) bits.push(`Fehlerart: ${det.F_ART1_TEXT}`);
497
+ if (det.F_HFK != null) bits.push(`Häufigkeit: ${det.F_HFK}`);
498
+ if (det.F_LZ != null) bits.push(`Logistikzähler: ${det.F_LZ}`);
499
+ if (det.F_VORHANDEN_TEXT) bits.push(det.F_VORHANDEN_TEXT);
500
+ if (bits.length) lines.push(` ${bits.join(' ')}`);
501
+ const n = Number(det.F_UW_ANZ) || 0;
502
+ for (let i = 1; i <= n; i++) {
503
+ const t = det[`F_UW${i}_TEXT`];
504
+ if (t == null) continue;
505
+ const v = det[`F_UW${i}_WERT`];
506
+ const u = det[`F_UW${i}_EINH`];
507
+ lines.push(` ${t}: ${v != null ? v : ''}${u ? ` ${u}` : ''}`);
508
+ }
509
+ lines.push('');
510
+ }
511
+ return lines;
512
+ }
513
+
409
514
  async runJob(sgbd, job, arg, ctx) {
410
515
  const target = ipoWireTarget(this.ecu, sgbd);
411
516
  const entry = !!(ctx && (ctx.scope === 'entry' || ctx.scope === 'exit'));
@@ -561,6 +666,10 @@ class IpoProgram {
561
666
  this.menu = out.menu || null;
562
667
  this.screen = out.screen || null;
563
668
  this.frequent = !!out.screenFrequent;
669
+ // the script's own front: the menu and screen its entry set. Esc on
670
+ // that menu first returns to that screen (see back()).
671
+ this.rootMenu = this.menu;
672
+ this.rootScreen = this.screen;
564
673
  this.title = out.title || null;
565
674
  return { ok: true };
566
675
  }
@@ -1232,6 +1341,10 @@ class IpoProgram {
1232
1341
  const rep = ipoProtocolReport(this.wireReads, out.view.lines);
1233
1342
  this.view.report = rep.modules.length ? rep : null;
1234
1343
  }
1344
+ } else if (out.viewClose) {
1345
+ // viewclose without a viewopen after it: INPA's viewer window is
1346
+ // gone, the screen behind it shows again
1347
+ this.view = null;
1235
1348
  }
1236
1349
  // the body painted (userbox text, a result line): show it with the screen
1237
1350
  this.takeCells(out);
@@ -1281,6 +1394,21 @@ class IpoProgram {
1281
1394
  this.queued = 'back';
1282
1395
  return;
1283
1396
  }
1397
+ // On the root menu, a key like Ident or Code only swaps the screen
1398
+ // (setscreen without setmenu) and the menu's own F10 is "End", which
1399
+ // leaves the module. Esc there first returns to the screen the entry
1400
+ // set, so one Esc undoes the screen and the next one leaves -- INPA's
1401
+ // main menu never strands the user on a sub-screen.
1402
+ if (
1403
+ this.menu &&
1404
+ this.menu === this.rootMenu &&
1405
+ this.rootScreen &&
1406
+ this.screen !== this.rootScreen &&
1407
+ this.exec.procs[this.rootScreen]
1408
+ ) {
1409
+ await this.showScreen(this.rootScreen, false);
1410
+ return;
1411
+ }
1284
1412
  if (this.items.some((it) => it.nr === IPO_BACK_KEY))
1285
1413
  return this.press(IPO_BACK_KEY);
1286
1414
  return this.leaveModule();
@@ -165,6 +165,8 @@ function ipoMakeUi(ecu, container, back) {
165
165
  ipoPickLines(names, multiple, current, hints),
166
166
  // INPA's printscreen: the module view as a clean sheet (print.js)
167
167
  printScreen: (p) => ipoPrintScreen(p, p.ecu || ecu, inpa),
168
+ // INPA's printfile: the protocol file the script wrote, as a sheet
169
+ printFile: (p, name, lines) => ipoPrintFile(p, p.ecu || ecu, name, lines),
168
170
  resolveScriptEcu: (from, script, exec) =>
169
171
  // the home script names a module by its SGBD after a chassis pick:
170
172
  // that module is the car's own record, not a wire-resolved variant