c64bridge 1.0.2 → 1.0.4

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.
@@ -1,9 +1,13 @@
1
1
  import { parseSidwave, midiToHz } from "../sidwave.js";
2
- export async function recordAndAnalyzeAudio(params) {
3
- const duration = Math.max(0.5, Math.min(120, Number(params.durationSeconds || 0)));
2
+ export function normalizeAudioDuration(durationSeconds) {
3
+ const duration = Number(durationSeconds);
4
4
  if (!Number.isFinite(duration)) {
5
5
  throw new Error("durationSeconds must be a number");
6
6
  }
7
+ return Math.max(0.5, Math.min(120, duration));
8
+ }
9
+ export async function recordAndAnalyzeAudio(params) {
10
+ const duration = normalizeAudioDuration(params.durationSeconds);
7
11
  // Capture PCM from default microphone using naudiodon (PortAudio)
8
12
  const sampleRate = 44100;
9
13
  const channelCount = 1;
@@ -52,9 +56,49 @@ export async function recordAndAnalyzeAudio(params) {
52
56
  });
53
57
  const pcm16 = Buffer.concat(chunks);
54
58
  const float32 = convertInt16ToFloat32(pcm16);
55
- const analysis = await analyzePcm(float32, sampleRate, params.expectedSidwave);
59
+ const analysis = await analyzePcm(float32, sampleRate, "microphone", params.expectedSidwave);
56
60
  return analysis;
57
61
  }
62
+ /**
63
+ * Analyzes interleaved 16-bit stereo PCM captured from the Ultimate audio stream.
64
+ *
65
+ * The stream carries a large constant offset per channel even when silent
66
+ * (for example left 26900, right 20000). The mean of each channel is removed
67
+ * before analysis; otherwise the RMS measures the offset instead of the audio level.
68
+ * Analyze channel energy separately, since a mono mix can cancel opposite-phase
69
+ * audio and falsely verify silence. Pitch and level follow the louder channel in each window.
70
+ */
71
+ export async function analyzeStereoStream(interleaved, sampleRate, expectedSidwave) {
72
+ const [left, right] = stereoToCenteredChannels(interleaved);
73
+ return analyzePcm(left, sampleRate, "ultimate-stream", expectedSidwave, right);
74
+ }
75
+ function stereoToCenteredChannels(interleaved) {
76
+ const frames = Math.floor(interleaved.length / 2);
77
+ const left = new Float32Array(frames);
78
+ const right = new Float32Array(frames);
79
+ if (frames === 0) {
80
+ return [left, right];
81
+ }
82
+ let leftSum = 0;
83
+ let rightSum = 0;
84
+ for (let i = 0; i < frames; i += 1) {
85
+ leftSum += interleaved[i * 2];
86
+ rightSum += interleaved[i * 2 + 1];
87
+ }
88
+ const leftMean = leftSum / frames;
89
+ const rightMean = rightSum / frames;
90
+ for (let i = 0; i < frames; i += 1) {
91
+ left[i] = (interleaved[i * 2] - leftMean) / 32768;
92
+ right[i] = (interleaved[i * 2 + 1] - rightMean) / 32768;
93
+ }
94
+ return [left, right];
95
+ }
96
+ export function stereoToCenteredMono(interleaved) {
97
+ const [left, right] = stereoToCenteredChannels(interleaved);
98
+ for (let i = 0; i < left.length; i += 1)
99
+ left[i] = (left[i] + right[i]) / 2;
100
+ return left;
101
+ }
58
102
  function convertInt16ToFloat32(buf) {
59
103
  const len = Math.floor(buf.length / 2);
60
104
  const out = new Float32Array(len);
@@ -64,7 +108,7 @@ function convertInt16ToFloat32(buf) {
64
108
  }
65
109
  return out;
66
110
  }
67
- async function analyzePcm(signal, sampleRate, expectedSidwave) {
111
+ async function analyzePcm(signal, sampleRate, source, expectedSidwave, secondChannel) {
68
112
  let Pitchfinder;
69
113
  let Meyda = null;
70
114
  try {
@@ -87,13 +131,28 @@ async function analyzePcm(signal, sampleRate, expectedSidwave) {
87
131
  const yin = Pitchfinder.YIN({ sampleRate });
88
132
  const frames = [];
89
133
  for (let i = 0; i + windowSize <= signal.length; i += hopSize) {
90
- const win = signal.subarray(i, i + windowSize);
91
- const rms = computeRms(win, Meyda, sampleRate, windowSize);
134
+ let win = signal.subarray(i, i + windowSize);
135
+ let rms = computeRms(win, Meyda, sampleRate, windowSize);
136
+ if (secondChannel) {
137
+ const otherWin = secondChannel.subarray(i, i + windowSize);
138
+ const otherRms = computeRms(otherWin, Meyda, sampleRate, windowSize);
139
+ if (otherRms > rms) {
140
+ rms = otherRms;
141
+ win = otherWin;
142
+ }
143
+ }
92
144
  const minRms = 0.01; // simple noise gate
93
145
  let freq = null;
94
146
  if (rms >= minRms) {
95
147
  try {
96
- const f = yin(win);
148
+ // pitchfinder's YIN seeds its difference sum with 1, making low-level
149
+ // normalized PCM produce spurious ultrasonic pitches. Normalize only
150
+ // the detector input; RMS and the noise gate retain the original level.
151
+ let peak = 0;
152
+ for (const value of win)
153
+ peak = Math.max(peak, Math.abs(value));
154
+ const pitchInput = peak > 0 ? Float32Array.from(win, (value) => value / peak) : win;
155
+ const f = yin(pitchInput);
97
156
  if (Number.isFinite(f) && f > 20 && f < 8000)
98
157
  freq = f;
99
158
  }
@@ -175,7 +234,7 @@ async function analyzePcm(signal, sampleRate, expectedSidwave) {
175
234
  return {
176
235
  sidwave: 1.0,
177
236
  analysis: {
178
- source: "microphone",
237
+ source,
179
238
  durationSeconds: signal.length / sampleRate,
180
239
  voices,
181
240
  global_metrics: {
@@ -189,7 +248,7 @@ async function analyzePcm(signal, sampleRate, expectedSidwave) {
189
248
  }
190
249
  // Test-only helper: bypasses microphone capture and analyzes provided PCM buffer.
191
250
  export async function analyzePcmForTest(signal, sampleRate, expectedSidwave) {
192
- return analyzePcm(signal, sampleRate, expectedSidwave);
251
+ return analyzePcm(signal, sampleRate, "microphone", expectedSidwave);
193
252
  }
194
253
  function groupSegments(frames, _sampleRate) {
195
254
  const segments = [];
@@ -286,10 +345,19 @@ function noteNameToMidiSafe(note) {
286
345
  function computeRms(win, Meyda, sampleRate, bufferSize) {
287
346
  try {
288
347
  if (Meyda && typeof Meyda.extract === "function") {
289
- const features = Meyda.extract(["rms"], win, { sampleRate, bufferSize });
290
- const val = Number(features?.rms ?? 0);
291
- if (Number.isFinite(val))
292
- return val;
348
+ // Rectangular windows give plain sample RMS. Restore Meyda's shared setting
349
+ // synchronously so other consumers retain their window configuration.
350
+ const previousWindow = Meyda.windowingFunction;
351
+ try {
352
+ Meyda.windowingFunction = "rect";
353
+ const features = Meyda.extract(["rms"], win, { sampleRate, bufferSize });
354
+ const val = Number(features?.rms ?? 0);
355
+ if (Number.isFinite(val))
356
+ return val;
357
+ }
358
+ finally {
359
+ Meyda.windowingFunction = previousWindow;
360
+ }
293
361
  }
294
362
  }
295
363
  catch { }
package/dist/c64Client.js CHANGED
@@ -6,7 +6,9 @@ Licensed under the GNU General Public License v2.0 or later.
6
6
  See <https://www.gnu.org/licenses/> for details.
7
7
  */
8
8
  import { Buffer } from "node:buffer";
9
+ import { execFile } from "node:child_process";
9
10
  import { createSocket } from "node:dgram";
11
+ import { networkInterfaces } from "node:os";
10
12
  import axios from "axios";
11
13
  import { basicToPrg } from "./tools/translation/basicTokenizer.js";
12
14
  import { assemblyToPrg } from "./tools/translation/assembler.js";
@@ -16,6 +18,7 @@ import { createAllFacades, createFacade, } from "./device.js";
16
18
  import { Api } from "../generated/c64/index.js";
17
19
  import { createLoggingHttpClient } from "./loggingHttpClient.js";
18
20
  import { withDiagnosticSpan, writeDiagnosticEvent } from "./diagnostics.js";
21
+ import { analyzeStereoStream, normalizeAudioDuration, recordAndAnalyzeAudio, } from "./audio/record_and_analyze_audio.js";
19
22
  import { C64U_NTSC_AUDIO_SAMPLE_RATE, C64U_PAL_AUDIO_SAMPLE_RATE, collectCompleteVideoFrames, parseAudioPacket, parseVideoPacket, } from "./streamCapture.js";
20
23
  import { buildVicBitmapRegisters, resolveVicBitmapMemoryLayout, } from "./vicBitmap.js";
21
24
  /** Raised when the KERNAL keyboard buffer did not drain before another chunk
@@ -29,6 +32,84 @@ export class KeyboardQueueDrainTimeoutError extends Error {
29
32
  this.timeoutMs = timeoutMs;
30
33
  }
31
34
  }
35
+ const C64U_SYSTEM_MODE_CATEGORY = "U64 Specific Settings";
36
+ const C64U_SYSTEM_MODE_ITEM = "System Mode";
37
+ // System Mode values that run the machine clock at the NTSC frequency
38
+ // (VIDEO_FMT_NTSC_FREQ in the firmware colour timings); the other values use the PAL clock.
39
+ const NTSC_CLOCK_SYSTEM_MODES = new Set(["NTSC", "PAL-60", "PAL-60/L"]);
40
+ /**
41
+ * Reads the current System Mode from a config response. The firmware answers
42
+ * `{ "<category>": { "<item>": { "current": "PAL", ... } } }`; a bare `{ value }` is also accepted.
43
+ */
44
+ export function readSystemMode(response) {
45
+ const record = (value) => value && typeof value === "object" ? value : null;
46
+ const category = record(record(response)?.[C64U_SYSTEM_MODE_CATEGORY]);
47
+ const node = category?.[C64U_SYSTEM_MODE_ITEM] ?? response;
48
+ const raw = typeof node === "string" ? node : record(node)?.current ?? record(node)?.value;
49
+ return typeof raw === "string" && raw.trim() !== "" ? raw.trim() : null;
50
+ }
51
+ export function audioSampleRateForSystemMode(mode) {
52
+ return mode !== null && NTSC_CLOCK_SYSTEM_MODES.has(mode.toUpperCase())
53
+ ? C64U_NTSC_AUDIO_SAMPLE_RATE
54
+ : C64U_PAL_AUDIO_SAMPLE_RATE;
55
+ }
56
+ // A partial capture must still hold one analysis window (2048 stereo frames).
57
+ const MIN_PARTIAL_AUDIO_VALUES = 2048 * 2;
58
+ const STREAM_HOST_RESOLVE_ERROR = "Network Host Resolve Error";
59
+ const NEIGHBOR_PRIME_TIMEOUT_MS = 4000;
60
+ /**
61
+ * The Ultimate firmware resolves a unicast stream target through its ARP cache
62
+ * and answers 404 "Network Host Resolve Error" immediately when the client is
63
+ * not cached. Accepts both a thrown HTTP error and an unsuccessful result.
64
+ */
65
+ function isStreamHostResolveFailure(failure) {
66
+ const body = axios.isAxiosError(failure) ? failure.response?.data : failure;
67
+ if (body === undefined || body === null) {
68
+ return false;
69
+ }
70
+ try {
71
+ return JSON.stringify(body).includes(STREAM_HOST_RESOLVE_ERROR);
72
+ }
73
+ catch {
74
+ return false;
75
+ }
76
+ }
77
+ /** Hostnames come from configuration; accept only plain host or address characters so they cannot act as ping options. */
78
+ export function pingableHost(hostname) {
79
+ const host = hostname.replace(/^\[(.*)\]$/, "$1");
80
+ return /^[A-Za-z0-9][A-Za-z0-9._:%-]*$/.test(host) ? host : null;
81
+ }
82
+ /** True when `host` is an address of one of this machine's network interfaces. */
83
+ export function isLocalAddress(host) {
84
+ const wanted = host.replace(/^\[(.*)\]$/, "$1").toLowerCase();
85
+ return Object.values(networkInterfaces()).some((entries) => (entries ?? []).some((entry) => entry.address.toLowerCase() === wanted));
86
+ }
87
+ /** Host part of a `host:port` stream target, or null when it cannot be split. */
88
+ export function streamTargetHost(target) {
89
+ const bracketed = /^\[([^\]]+)\](?::\d+)?$/.exec(target);
90
+ if (bracketed) {
91
+ return bracketed[1];
92
+ }
93
+ const colon = target.indexOf(":");
94
+ if (colon === -1) {
95
+ return target.length > 0 ? target : null;
96
+ }
97
+ // Exactly one colon separates host and port; several colons are a bare IPv6 address.
98
+ if (colon !== target.lastIndexOf(":")) {
99
+ return target;
100
+ }
101
+ return colon > 0 ? target.slice(0, colon) : null;
102
+ }
103
+ function neighborPrimeArgs(host) {
104
+ switch (process.platform) {
105
+ case "win32":
106
+ return ["-n", "1", "-w", "2000", host];
107
+ case "darwin":
108
+ return ["-c", "1", "-t", "2", host];
109
+ default:
110
+ return ["-c", "1", "-W", "2", host];
111
+ }
112
+ }
32
113
  function menuMatrixIncludes(matrix, expected) {
33
114
  // Firmware supplies the character matrix followed by its colour matrix. The
34
115
  // documented wire format is raw bytes, so only inspect the first 40×25
@@ -1026,7 +1107,7 @@ export class C64Client {
1026
1107
  async streamStart(stream, ip) {
1027
1108
  try {
1028
1109
  const facade = await this.facadePromise;
1029
- return await facade.streamStart(stream, ip);
1110
+ return await this.startStreamResolvingHost(facade, stream, ip);
1030
1111
  }
1031
1112
  catch (error) {
1032
1113
  return { success: false, details: this.normaliseError(error) };
@@ -1060,6 +1141,9 @@ export class C64Client {
1060
1141
  if (facade.type === "vice") {
1061
1142
  return this.captureViceFrames(requestedCount);
1062
1143
  }
1144
+ if (facade.type !== "c64u") {
1145
+ throw new Error("Video frame capture is only available on C64U/U64 and VICE; U2 has no video stream");
1146
+ }
1063
1147
  return this.captureC64uVideoFrames(facade, requestedCount, {
1064
1148
  reuseSession: options?.reuseSession === true,
1065
1149
  keepAliveMs: Math.max(0, Math.trunc(options?.keepAliveMs ?? 0)),
@@ -1074,6 +1158,25 @@ export class C64Client {
1074
1158
  }
1075
1159
  return this.captureC64uAudioSamples(facade, requestedPairs);
1076
1160
  }
1161
+ /**
1162
+ * Records and analyzes audio. On the C64 Ultimate the PCM comes from the
1163
+ * device audio stream; other backends have no stream, so the host microphone is used.
1164
+ */
1165
+ async recordAndAnalyzeAudio(params) {
1166
+ const durationSeconds = normalizeAudioDuration(params.durationSeconds);
1167
+ const facade = await this.facadePromise;
1168
+ if (facade.type !== "c64u") {
1169
+ return recordAndAnalyzeAudio(params);
1170
+ }
1171
+ const sampleRateHz = await this.getC64uAudioSampleRate(facade);
1172
+ const samplePairs = Math.ceil(durationSeconds * sampleRateHz);
1173
+ const capture = await this.captureC64uAudioSamples(facade, samplePairs, {
1174
+ timeoutMs: Math.ceil(durationSeconds * 1500) + 2000,
1175
+ sampleRateHz,
1176
+ acceptPartial: true,
1177
+ });
1178
+ return analyzeStereoStream(capture.samples, capture.sampleRateHz, params.expectedSidwave);
1179
+ }
1077
1180
  async configsList() {
1078
1181
  const facade = await this.facadePromise;
1079
1182
  return facade.configsList();
@@ -1223,7 +1326,7 @@ export class C64Client {
1223
1326
  try {
1224
1327
  await this.bindCaptureSocket(socket, bindAddress);
1225
1328
  const target = this.socketEndpoint(socket, bindAddress);
1226
- await this.ensureStreamSuccess(await facade.streamStart("video", target), "start video stream");
1329
+ await this.ensureStreamSuccess(await this.startStreamResolvingHost(facade, "video", target), "start video stream");
1227
1330
  const frames = await new Promise((resolve, reject) => {
1228
1331
  const timeoutMs = Math.max(1500, count * 750);
1229
1332
  const timer = setTimeout(() => {
@@ -1313,7 +1416,7 @@ export class C64Client {
1313
1416
  this.rejectC64uVideoCaptureWaiters(session, err);
1314
1417
  });
1315
1418
  try {
1316
- await this.ensureStreamSuccess(await facade.streamStart("video", target), "start video stream");
1419
+ await this.ensureStreamSuccess(await this.startStreamResolvingHost(facade, "video", target), "start video stream");
1317
1420
  }
1318
1421
  catch (error) {
1319
1422
  session.closed = true;
@@ -1447,44 +1550,76 @@ export class C64Client {
1447
1550
  }
1448
1551
  throw lastError instanceof Error ? lastError : new Error(String(lastError ?? "VICE display capture failed"));
1449
1552
  }
1450
- async captureC64uAudioSamples(facade, samplePairs) {
1553
+ async captureC64uAudioSamples(facade, samplePairs, options) {
1451
1554
  const host = new URL(this.baseUrl).hostname;
1452
1555
  const bindAddress = await this.resolveLocalCaptureAddress(host);
1453
1556
  const socket = createSocket("udp4");
1454
1557
  const chunks = [];
1455
1558
  let collectedValues = 0;
1456
- let stopError = null;
1559
+ const neededValues = samplePairs * 2;
1560
+ let captureError = null;
1561
+ let captureSettled = false;
1562
+ let notifyCapture = () => { };
1563
+ let socketBound = false;
1564
+ let streamAttempted = false;
1565
+ let failed = false;
1566
+ // The firmware can emit UDP before its REST response reaches us. Listen
1567
+ // before starting, and cap collection even while start/config/stop requests are pending.
1568
+ const onMessage = (msg) => {
1569
+ if (captureSettled || captureError || collectedValues >= neededValues)
1570
+ return;
1571
+ try {
1572
+ const packet = parseAudioPacket(msg);
1573
+ if (packet.samples.length % 2 !== 0)
1574
+ throw new Error("Audio packet contains an incomplete stereo sample pair");
1575
+ const samples = packet.samples.subarray(0, neededValues - collectedValues);
1576
+ chunks.push(samples);
1577
+ collectedValues += samples.length;
1578
+ }
1579
+ catch (error) {
1580
+ captureError = error instanceof Error ? error : new Error(String(error));
1581
+ }
1582
+ notifyCapture();
1583
+ };
1584
+ const onError = (error) => {
1585
+ captureError = error;
1586
+ notifyCapture();
1587
+ };
1588
+ socket.on("message", onMessage);
1589
+ socket.on("error", onError);
1457
1590
  try {
1458
1591
  await this.bindCaptureSocket(socket, bindAddress);
1592
+ socketBound = true;
1459
1593
  const target = this.socketEndpoint(socket, bindAddress);
1460
- await this.ensureStreamSuccess(await facade.streamStart("audio", target), "start audio stream");
1461
- const neededValues = samplePairs * 2;
1594
+ streamAttempted = true;
1595
+ await this.ensureStreamSuccess(await this.startStreamResolvingHost(facade, "audio", target), "start audio stream");
1462
1596
  await new Promise((resolve, reject) => {
1463
- const timeoutMs = Math.max(1000, Math.ceil(samplePairs / 192) * 500);
1597
+ const timeoutMs = options?.timeoutMs ?? Math.max(1000, Math.ceil(samplePairs / 192) * 500);
1464
1598
  const timer = setTimeout(() => {
1599
+ captureSettled = true;
1600
+ // UDP drops packets, so a count-based capture can fall short. Analysis accepts what arrived.
1601
+ if (options?.acceptPartial && collectedValues >= MIN_PARTIAL_AUDIO_VALUES) {
1602
+ resolve();
1603
+ return;
1604
+ }
1465
1605
  reject(new Error(`Timed out after ${timeoutMs}ms while capturing ${samplePairs} audio sample pair(s)`));
1466
1606
  }, timeoutMs);
1467
- socket.on("message", (msg) => {
1468
- try {
1469
- const packet = parseAudioPacket(Buffer.from(msg));
1470
- chunks.push(packet.samples);
1471
- collectedValues += packet.samples.length;
1472
- if (collectedValues >= neededValues) {
1473
- clearTimeout(timer);
1474
- resolve();
1475
- }
1476
- }
1477
- catch (error) {
1607
+ notifyCapture = () => {
1608
+ if (captureSettled)
1609
+ return;
1610
+ if (captureError || collectedValues >= neededValues) {
1611
+ captureSettled = true;
1478
1612
  clearTimeout(timer);
1479
- reject(error);
1613
+ if (captureError)
1614
+ reject(captureError);
1615
+ else
1616
+ resolve();
1480
1617
  }
1481
- });
1482
- socket.once("error", (error) => {
1483
- clearTimeout(timer);
1484
- reject(error);
1485
- });
1618
+ };
1619
+ notifyCapture();
1486
1620
  });
1487
- const samples = new Int16Array(samplePairs * 2);
1621
+ const capturedPairs = Math.min(samplePairs, Math.floor(collectedValues / 2));
1622
+ const samples = new Int16Array(capturedPairs * 2);
1488
1623
  let offset = 0;
1489
1624
  for (const chunk of chunks) {
1490
1625
  const remaining = samples.length - offset;
@@ -1497,32 +1632,37 @@ export class C64Client {
1497
1632
  return {
1498
1633
  backend: "c64u",
1499
1634
  channels: 2,
1500
- sampleRateHz: await this.getC64uAudioSampleRate(facade),
1501
- samplePairs,
1635
+ sampleRateHz: options?.sampleRateHz ?? await this.getC64uAudioSampleRate(facade),
1636
+ samplePairs: capturedPairs,
1502
1637
  samples,
1503
1638
  };
1504
1639
  }
1640
+ catch (error) {
1641
+ failed = true;
1642
+ throw error;
1643
+ }
1505
1644
  finally {
1645
+ captureSettled = true;
1646
+ notifyCapture = () => { };
1647
+ socket.off("message", onMessage);
1648
+ if (socketBound)
1649
+ socket.close();
1506
1650
  try {
1507
- await this.ensureStreamSuccess(await facade.streamStop("audio"), "stop audio stream");
1651
+ if (streamAttempted)
1652
+ await this.ensureStreamSuccess(await facade.streamStop("audio"), "stop audio stream");
1508
1653
  }
1509
1654
  catch (error) {
1510
- stopError = error instanceof Error ? error : new Error(String(error));
1511
- }
1512
- socket.close();
1513
- if (stopError) {
1514
- throw stopError;
1655
+ // Preserve the capture/start error, while still reporting cleanup diagnostics.
1656
+ if (!failed)
1657
+ throw error;
1658
+ writeDiagnosticEvent("audio_capture_stop_failed", { error: this.normaliseError(error) });
1515
1659
  }
1516
1660
  }
1517
1661
  }
1518
1662
  async getC64uAudioSampleRate(facade) {
1519
1663
  try {
1520
- const response = await facade.configGet("Video", "Mode");
1521
- const raw = response?.value ?? response;
1522
- const mode = typeof raw === "string" ? raw.trim().toUpperCase() : "";
1523
- if (mode.includes("NTSC")) {
1524
- return C64U_NTSC_AUDIO_SAMPLE_RATE;
1525
- }
1664
+ const response = await facade.configGet(C64U_SYSTEM_MODE_CATEGORY, C64U_SYSTEM_MODE_ITEM);
1665
+ return audioSampleRateForSystemMode(readSystemMode(response));
1526
1666
  }
1527
1667
  catch {
1528
1668
  // Fall back to PAL below.
@@ -1568,6 +1708,69 @@ export class C64Client {
1568
1708
  }
1569
1709
  return `${bindAddress}:${address.port}`;
1570
1710
  }
1711
+ /**
1712
+ * Starts a stream. When the device answers "Network Host Resolve Error", sends
1713
+ * one ICMP echo to the device so it has to address a reply to this host, then
1714
+ * retries the start once.
1715
+ */
1716
+ async startStreamResolvingHost(facade, stream, target) {
1717
+ let firstResult;
1718
+ let firstError;
1719
+ try {
1720
+ const result = await facade.streamStart(stream, target);
1721
+ if (result.success || !isStreamHostResolveFailure(result.details)) {
1722
+ return result;
1723
+ }
1724
+ firstResult = result;
1725
+ }
1726
+ catch (error) {
1727
+ if (!isStreamHostResolveFailure(error)) {
1728
+ throw error;
1729
+ }
1730
+ firstError = error;
1731
+ }
1732
+ // A ping from this host only helps when the stream target is this host: the device then has to
1733
+ // address the echo reply to it and learns its address. Any other target stays unresolvable.
1734
+ const targetHost = streamTargetHost(target);
1735
+ if (targetHost === null || !isLocalAddress(targetHost)) {
1736
+ if (firstResult) {
1737
+ return firstResult;
1738
+ }
1739
+ throw firstError;
1740
+ }
1741
+ const host = new URL(this.baseUrl).hostname;
1742
+ writeDiagnosticEvent("stream_start_host_resolve_retry", { stream, target, host });
1743
+ await this.primeDeviceNeighborCache(host);
1744
+ try {
1745
+ const retry = await facade.streamStart(stream, target);
1746
+ if (retry.success || !isStreamHostResolveFailure(retry.details)) {
1747
+ return retry;
1748
+ }
1749
+ }
1750
+ catch (error) {
1751
+ if (!isStreamHostResolveFailure(error)) {
1752
+ throw error;
1753
+ }
1754
+ }
1755
+ throw new Error(`The Ultimate at ${host} could not resolve stream target ${target} (${STREAM_HOST_RESOLVE_ERROR}) even after pinging it from this host. ` +
1756
+ "Check that this host is on the same network as the device, or use a multicast target.");
1757
+ }
1758
+ /** Best effort: ping failure (missing binary, blocked ICMP) is ignored and the caller retries anyway. */
1759
+ async primeDeviceNeighborCache(host) {
1760
+ const pingHost = pingableHost(host);
1761
+ if (!pingHost) {
1762
+ writeDiagnosticEvent("stream_start_ping_failed", { host, message: "host is not a plain hostname or address" });
1763
+ return;
1764
+ }
1765
+ await new Promise((resolve) => {
1766
+ execFile("ping", neighborPrimeArgs(pingHost), { timeout: NEIGHBOR_PRIME_TIMEOUT_MS, windowsHide: true }, (error) => {
1767
+ if (error) {
1768
+ writeDiagnosticEvent("stream_start_ping_failed", { host, message: error.message });
1769
+ }
1770
+ resolve();
1771
+ });
1772
+ });
1773
+ }
1571
1774
  async ensureStreamSuccess(result, action) {
1572
1775
  if (result.success) {
1573
1776
  return;
package/dist/device.js CHANGED
@@ -632,10 +632,6 @@ export class ViceBackend {
632
632
  }
633
633
  async powerCycle() { return this.nuclearReset(); }
634
634
  async nuclearReset() {
635
- const poweroffResult = await this.poweroff();
636
- if (!poweroffResult.success) {
637
- return poweroffResult;
638
- }
639
635
  if (!this.manageProcess) {
640
636
  return {
641
637
  success: false,
@@ -645,6 +641,10 @@ export class ViceBackend {
645
641
  },
646
642
  };
647
643
  }
644
+ const poweroffResult = await this.poweroff();
645
+ if (!poweroffResult.success) {
646
+ return poweroffResult;
647
+ }
648
648
  try {
649
649
  await this.ensureProcess();
650
650
  return { success: true };
@@ -694,7 +694,10 @@ export class ViceBackend {
694
694
  return drives;
695
695
  });
696
696
  }
697
- async driveMount(drive, imagePath) {
697
+ async driveMount(drive, imagePath, options) {
698
+ if (options?.type !== undefined || options?.mode !== undefined) {
699
+ throw unsupported("driveMount type/mode overrides; omit these options on VICE");
700
+ }
698
701
  const n = parseDriveNumber(drive);
699
702
  await this.withClient(async (client) => {
700
703
  await client.resourceSet(`Drive${n}CPUEnabled`, 1);
@@ -760,8 +763,18 @@ export class ViceBackend {
760
763
  };
761
764
  }
762
765
  async configGet(_category, item) {
763
- if (!item)
764
- throw unsupported("configGet without item name");
766
+ if (!item) {
767
+ if (_category !== "VICE")
768
+ throw new Error(`Unknown VICE configuration category '${_category}'`);
769
+ const inventory = await this.configsList();
770
+ return this.withClient(async (client) => {
771
+ const values = {};
772
+ for (const name of inventory.categories[0].items) {
773
+ values[name] = (await client.resourceGet(name)).value;
774
+ }
775
+ return values;
776
+ });
777
+ }
765
778
  return await this.withClient(async (client) => {
766
779
  const res = await client.resourceGet(item);
767
780
  return { category: _category, item, value: res.value, type: res.type };
@@ -771,9 +784,7 @@ export class ViceBackend {
771
784
  let parsed = value;
772
785
  await this.withClient(async (client) => {
773
786
  const current = await client.resourceGet(item);
774
- parsed = current.type === "int" ? Number(value) : value;
775
- if (current.type === "int" && !Number.isFinite(parsed))
776
- throw new Error(`VICE resource '${item}' requires an integer value`);
787
+ parsed = parseViceConfigValue(value, current.type, item);
777
788
  await client.resourceSet(item, parsed);
778
789
  });
779
790
  return { success: true, details: { item, value: parsed } };
@@ -784,11 +795,8 @@ export class ViceBackend {
784
795
  for (const [category, items] of Object.entries(payload)) {
785
796
  for (const [item, value] of Object.entries(items)) {
786
797
  try {
787
- const str = String(value ?? "");
788
798
  const current = await client.resourceGet(item);
789
- const parsed = current.type === "int" ? Number(str) : str;
790
- if (current.type === "int" && !Number.isFinite(parsed))
791
- throw new Error(`VICE resource '${item}' requires an integer value`);
799
+ const parsed = parseViceConfigValue(value, current.type, item);
792
800
  await client.resourceSet(item, parsed);
793
801
  results.push({ item: `${category}/${item}`, success: true });
794
802
  }
@@ -814,6 +822,16 @@ export class ViceBackend {
814
822
  }
815
823
  ViceBackend.supervisors = new Map();
816
824
  ViceBackend.cleanupRegistered = false;
825
+ function parseViceConfigValue(value, type, item) {
826
+ if (type !== "int")
827
+ return String(value ?? "");
828
+ const parsed = value === true || value === "true" ? 1
829
+ : value === false || value === "false" ? 0 : Number(value);
830
+ if (!Number.isInteger(parsed) || parsed < -0x80000000 || parsed > 0x7fffffff) {
831
+ throw new Error(`VICE resource '${item}' requires a 32-bit integer value`);
832
+ }
833
+ return parsed;
834
+ }
817
835
  function unsupported(name) { const err = new Error(`Operation '${name}' is not supported by the VICE backend in phase one`); err.code = "UNSUPPORTED"; return err; }
818
836
  /** Ultimate file APIs treat slash as a path separator. Encode each name once,
819
837
  * preserving leading and nested separators for firmware routing. */