c64bridge 1.0.7 → 1.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 (132) hide show
  1. package/.github/skills/assembly-program/SKILL.md +37 -0
  2. package/.github/skills/basic-program/SKILL.md +40 -0
  3. package/.github/skills/cross-platform-demo/SKILL.md +32 -0
  4. package/.github/skills/drive-manager/SKILL.md +33 -0
  5. package/.github/skills/graphics-demo/SKILL.md +30 -0
  6. package/.github/skills/hello-world/SKILL.md +36 -0
  7. package/.github/skills/memory-debug/SKILL.md +32 -0
  8. package/.github/skills/pr-converge/SKILL.md +33 -0
  9. package/.github/skills/printer-job/SKILL.md +30 -0
  10. package/.github/skills/sid-music/SKILL.md +35 -0
  11. package/.github/skills/software-launch/SKILL.md +31 -0
  12. package/.github/skills/stream-control/SKILL.md +29 -0
  13. package/.github/skills/system-control/SKILL.md +32 -0
  14. package/AGENTS.md +28 -7
  15. package/README.md +69 -14
  16. package/data/assembly/assembly-spec.md +2 -0
  17. package/dist/c64Client.js +238 -60
  18. package/dist/config.js +48 -26
  19. package/dist/device.js +92 -37
  20. package/dist/diagnostics.js +62 -2
  21. package/dist/hostPaths.js +128 -0
  22. package/dist/loggingHttpClient.js +59 -1
  23. package/dist/mcp-server.js +409 -286
  24. package/dist/numericArg.js +55 -0
  25. package/dist/petscii.js +13 -1
  26. package/dist/platform.js +12 -3
  27. package/dist/prompts/registry.js +6 -1
  28. package/dist/tools/access.js +66 -0
  29. package/dist/tools/debug.js +6 -0
  30. package/dist/tools/developer.js +67 -34
  31. package/dist/tools/errors.js +54 -3
  32. package/dist/tools/input.js +23 -6
  33. package/dist/tools/machineControl.js +112 -20
  34. package/dist/tools/memory.js +135 -30
  35. package/dist/tools/meta/artifacts.js +5 -5
  36. package/dist/tools/meta/background.js +216 -54
  37. package/dist/tools/meta/compilation.js +6 -5
  38. package/dist/tools/meta/config.js +15 -6
  39. package/dist/tools/meta/filesystem.js +16 -14
  40. package/dist/tools/meta/graphics.js +48 -20
  41. package/dist/tools/meta/memory.js +45 -19
  42. package/dist/tools/meta/program.js +10 -7
  43. package/dist/tools/meta/screen.js +116 -25
  44. package/dist/tools/pauseGuard.js +23 -0
  45. package/dist/tools/pollValidator.js +53 -8
  46. package/dist/tools/programRunners.js +62 -18
  47. package/dist/tools/registry/batch.js +16 -4
  48. package/dist/tools/registry/config.js +9 -2
  49. package/dist/tools/registry/disk.js +12 -2
  50. package/dist/tools/registry/drive.js +3 -0
  51. package/dist/tools/registry/extract.js +3 -0
  52. package/dist/tools/registry/graphics.js +59 -6
  53. package/dist/tools/registry/index.js +17 -1
  54. package/dist/tools/registry/memory.js +11 -1
  55. package/dist/tools/registry/platform.js +4 -1
  56. package/dist/tools/registry/printer.js +2 -1
  57. package/dist/tools/registry/program.js +5 -1
  58. package/dist/tools/registry/rag.js +1 -0
  59. package/dist/tools/registry/sound.js +12 -1
  60. package/dist/tools/registry/stream.js +2 -1
  61. package/dist/tools/registry/system.js +8 -1
  62. package/dist/tools/schema.js +7 -7
  63. package/dist/tools/streaming.js +34 -4
  64. package/dist/tools/translation/assembler.js +247 -24
  65. package/dist/tools/translation/basicDetokenizer.js +80 -0
  66. package/dist/tools/translation/basicTokenizer.js +8 -3
  67. package/dist/tools/translation/disassembler.js +14 -0
  68. package/dist/tools/types.js +258 -7
  69. package/dist/tools/vice.js +5 -16
  70. package/dist/vicBitmap.js +1 -1
  71. package/dist/vice/mockServer.js +3 -0
  72. package/dist/vice/process.js +19 -0
  73. package/dist/vice/resourcePolicy.js +36 -0
  74. package/dist/vice/viceClient.js +22 -5
  75. package/doc/vice/support-matrix.md +17 -11
  76. package/mcp/schemas/c64_batch.schema.json +2 -1
  77. package/mcp/schemas/c64_config.schema.json +25 -475
  78. package/mcp/schemas/c64_debug.schema.json +3 -602
  79. package/mcp/schemas/c64_disk.schema.json +17 -344
  80. package/mcp/schemas/c64_drive.schema.json +2 -157
  81. package/mcp/schemas/c64_extract.schema.json +10 -364
  82. package/mcp/schemas/c64_graphics.schema.json +19 -426
  83. package/mcp/schemas/c64_input.schema.json +5 -318
  84. package/mcp/schemas/c64_memory.schema.json +23 -491
  85. package/mcp/schemas/c64_printer.schema.json +3 -258
  86. package/mcp/schemas/c64_program.schema.json +16 -491
  87. package/mcp/schemas/c64_rag.schema.json +1 -81
  88. package/mcp/schemas/c64_select_backend.schema.json +2 -36
  89. package/mcp/schemas/c64_sound.schema.json +20 -822
  90. package/mcp/schemas/c64_stream.schema.json +4 -75
  91. package/mcp/schemas/c64_system.schema.json +28 -348
  92. package/mcp/schemas/c64_vice.schema.json +2 -71
  93. package/mcp/server.json +1 -1
  94. package/mcp/tools.json +1983 -6910
  95. package/mcp.json +21 -1
  96. package/package.json +16 -8
  97. package/scripts/mockC64Server.mjs +44 -0
  98. package/scripts/run-coverage.mjs +27 -1
  99. package/scripts/smoke-run.sh +3 -2
  100. package/scripts/start.mjs +23 -1
  101. package/server.json +35 -2
  102. package/dist/.tsbuildinfo +0 -1
  103. package/doc/agent-state-spec.md +0 -243
  104. package/doc/audit/secret-handling.md +0 -61
  105. package/doc/plans/bun/baseline-metrics.md +0 -16
  106. package/doc/plans/bun/migration-checklist.md +0 -55
  107. package/doc/plans/bun/migration.md +0 -83
  108. package/doc/plans/improve-knowledge/notes.md +0 -43
  109. package/doc/plans/improve-knowledge/plan.md +0 -121
  110. package/doc/plans/improve-knowledge/rollout.md +0 -110
  111. package/doc/plans/meta-tools/README.md +0 -110
  112. package/doc/plans/meta-tools/analysis.md +0 -272
  113. package/doc/plans/meta-tools/catalog.md +0 -435
  114. package/doc/plans/meta-tools/notes.md +0 -192
  115. package/doc/plans/meta-tools/rollout.md +0 -368
  116. package/doc/plans/prompts/README.md +0 -96
  117. package/doc/plans/prompts/user-prompt-ideas.md +0 -147
  118. package/doc/plans/tool-consolidation/notes.md +0 -28
  119. package/doc/plans/tool-consolidation/plan.md +0 -169
  120. package/doc/plans/tool-consolidation/rollout.md +0 -104
  121. package/doc/plans/vice-backend-switch/plan.md +0 -486
  122. package/doc/plans/vice-backend-switch-2/plan.md +0 -48
  123. package/doc/plans/vice-support/notes.md +0 -63
  124. package/doc/plans/vice-support/plan.md +0 -88
  125. package/doc/plans/vice-support/rollout.md +0 -99
  126. package/doc/reviews/audit.md +0 -143
  127. package/doc/reviews/hardening/01-fable/prompt-handover.md +0 -125
  128. package/doc/reviews/hardening/01-fable/prompt.md +0 -98
  129. package/doc/reviews/hardening/01-fable/review-prompt.md +0 -468
  130. package/doc/reviews/hardening/01-fable/review.md +0 -1234
  131. package/doc/reviews/vice-support-review.md +0 -185
  132. package/scripts/compare-rest/rest-compare-2025-11-01T00-25-21-830Z.json +0 -2513
package/README.md CHANGED
@@ -378,6 +378,54 @@ The screenshots below are available for the two backends that provide frame capt
378
378
  | U2-family | Frame capture unavailable (no firmware streaming). |
379
379
  | VICE | ![VICE backend switch example](doc/img/backend-switch/hello-vice.png) |
380
380
 
381
+ ### Host File Access
382
+
383
+ Tool arguments that name a file or directory on the machine running C64 Bridge (memory dumps, configuration snapshots, sprite and charset exports, run artifacts, symbol files, and VICE PRG or disk image paths) are confined to a set of allowed directories:
384
+
385
+ - the working directory of the server, unless it is the user's home directory or `/`
386
+ - `~/.c64bridge`
387
+ - the system temporary directory (`os.tmpdir()`)
388
+
389
+ Paths are resolved to their real location before the check, so `..` segments and symbolic links cannot leave these directories. A rejected path produces a validation error that lists the allowed directories, and nothing is written. Files read through these arguments are limited to 16 MiB (64 MiB for disk images).
390
+
391
+ Set `C64BRIDGE_ALLOWED_DIRS` to add directories. Separate entries with the platform path delimiter (`:` on Linux and macOS, `;` on Windows). The single value `*` disables the confinement.
392
+
393
+ ```json
394
+ "env": { "C64BRIDGE_ALLOWED_DIRS": "/home/you/c64:/home/you/disks" }
395
+ ```
396
+
397
+ Earlier versions accepted any path. Clients that pass paths outside these directories must now add them through `C64BRIDGE_ALLOWED_DIRS`.
398
+
399
+ ### Access Mode
400
+
401
+ Every operation has an effect: `read` (no change), `write` (changes machine or emulator state that a reset undoes, or writes a host file) or `destructive` (survives a reset or interrupts availability, for example power off, power cycle, reboot, flash save, load or reset to defaults, drive ROM load, disk image creation, debug register write, and config restore with `applyToFlash`). `tools/list` publishes MCP annotations derived from these effects, and each operation line in a tool description names its effect when it is not `read`.
402
+
403
+ Set `C64BRIDGE_ACCESS` to restrict what a client may call:
404
+
405
+ - `full` (default): all operations are allowed.
406
+ - `no-destructive`: `destructive` operations are rejected.
407
+ - `read-only`: only `read` operations are allowed.
408
+
409
+ The variable is read on every call. An unknown value is treated as `read-only` and logged to stderr once. A rejected call returns a validation error that names the operation, its effect and the active mode. `c64_batch` checks each command separately, and `c64_system` `start_task` checks the effect of its task operation when the task starts. `c64://platform/status` shows the active mode.
410
+
411
+ After `c64_system` `poweroff` on C64U/U64, power must be restored at the outlet. The machine starts by itself only if automatic startup on power restoration is enabled in its configuration.
412
+
413
+ ```json
414
+ "env": { "C64BRIDGE_ACCESS": "no-destructive" }
415
+ ```
416
+
417
+ ### Operator Environment Variables
418
+
419
+ These variables are read by the server itself and are set in the `env` block of the MCP client configuration. `C64BRIDGE_ALLOWED_DIRS` and `C64BRIDGE_ACCESS` are described above.
420
+
421
+ | Variable | Default | Description |
422
+ | --- | --- | --- |
423
+ | `C64BRIDGE_ALLOWED_DIRS` | unset | Additional directories for host file paths in tool arguments. Separate entries with the platform path delimiter; `*` disables the confinement. |
424
+ | `C64BRIDGE_ACCESS` | `full` | `full`, `no-destructive` or `read-only`. Limits which operations a client may call. |
425
+ | `C64BRIDGE_HTTP_TOKEN` | unset | When set, the optional HTTP transport (`--http`) requires `Authorization: Bearer <token>`. The transport accepts only loopback `Host` and `Origin` headers regardless of this setting. |
426
+ | `C64BRIDGE_DIAGNOSTICS_KEEP` | `200` | Number of diagnostics files kept in the diagnostics directory. At startup the server deletes older files beyond this count and files older than 30 days. An invalid value is ignored with a warning on stderr. Diagnostics files are created with mode 0600 in a directory created with mode 0700. |
427
+ | `C64BRIDGE_VERBOSE_SCHEMA` | unset | Set to `1` to include the full per-operation schemas in `tools/list`. By default they are omitted to keep the listing small. |
428
+
381
429
  ## VS Code MCP Setup
382
430
 
383
431
  This section covers the GitHub Copilot integration that ships with VS Code. For the Claude Code VS Code plugin, see [Claude Code](#claude-code) — it reads `.mcp.json`, not `.vscode/mcp.json`.
@@ -524,12 +572,17 @@ Every runtime environment variable documented in `mcp.json` can be set in your M
524
572
  | --- | --- | --- | --- |
525
573
  | `C64_MODE` | c64u | — | Select active backend (c64u/U64 hardware, u2/U2-family cartridge, or vice emulator) |
526
574
  | `C64_TASK_STATE_FILE` | auto | — | Override the path used to persist MCP background-task state |
575
+ | `C64BRIDGE_ACCESS` | full | — | Access mode limiting which operations may be called (full, no-destructive, read-only) |
576
+ | `C64BRIDGE_ALLOWED_DIRS` | | — | Additional directories allowed for host file paths in tool arguments, separated by the path delimiter; * disables the check |
527
577
  | `C64BRIDGE_CONFIG` | ~/.c64bridge.json | config path | Path to configuration JSON |
528
578
  | `C64BRIDGE_DIAGNOSTICS_DIR` | ~/.c64bridge/diagnostics | — | Override the directory where persistent MCP diagnostics files are written |
579
+ | `C64BRIDGE_DIAGNOSTICS_KEEP` | 200 | — | Number of diagnostics files kept at startup; files older than 30 days are always removed |
529
580
  | `C64BRIDGE_DISABLE_DIAGNOSTICS` | 0 | — | Set to 1 to disable persistent diagnostics logging |
581
+ | `C64BRIDGE_HTTP_TOKEN` | | — | Bearer token required by the optional HTTP transport (--http) |
530
582
  | `C64BRIDGE_POLL_INTERVAL_MS` | 200 | — | Interval between screen polls during program-output validation in normal runtime mode |
531
583
  | `C64BRIDGE_POLL_MAX_MS` | 2000 | — | Maximum time to poll for program-output validation before timing out in normal runtime mode |
532
584
  | `C64BRIDGE_POLL_STABILIZE_MS` | 100 | — | Extra settle time after a successful poll match before considering output stable |
585
+ | `C64BRIDGE_VERBOSE_SCHEMA` | | — | Set to 1 to include full per-operation schemas in tools/list |
533
586
  | `LOG_LEVEL` | info | — | Logger verbosity (debug, info, warn, error) |
534
587
 
535
588
  #### C64 Ultimate
@@ -715,16 +768,16 @@ Grouped entry point for configuration reads/writes, diagnostics, and snapshots.
715
768
 
716
769
  | Operation | Description | Required Inputs | Optional Inputs | C64U | U2 | VICE |
717
770
  | --- | --- | --- | --- | --- | --- | --- |
718
- | `batch_update` | Apply multiple configuration updates in a single request. | — | — | ✅ | ✅ | ✅ |
771
+ | `batch_update` | Apply multiple configuration updates in a single request. Pass `updates` as {category: {item: value}}; categories as top-level keys are still accepted at runtime. | — | `updates` | ✅ | ✅ | ✅ |
719
772
  | `diff` | Compare the current configuration with a snapshot. | `path` | — | ✅ | ✅ | ✅ |
720
773
  | `get` | Read a configuration category or specific item. | `category` | `item`, `includeSecretValues=false` | ✅ | ✅ | ✅ |
721
774
  | `info` | Retrieve Ultimate hardware information and status. | — | — | ✅ | ✅ | ✅ |
722
775
  | `list` | List configuration categories reported by the firmware. | — | — | ✅ | ✅ | ✅ |
723
- | `load_flash` | Load configuration from flash storage. | — | — | ✅ | ✅ | |
776
+ | `load_flash` | Load configuration from flash storage. | — | `category` | ✅ | ✅ | |
724
777
  | `read_debugreg` | Read the Ultimate debug register ($D7FF). | — | — | ✅ | | |
725
- | `reset_defaults` | Reset firmware configuration to factory defaults. | — | — | ✅ | ✅ | |
778
+ | `reset_defaults` | Reset firmware configuration to factory defaults. | — | `category` | ✅ | ✅ | |
726
779
  | `restore` | Restore configuration from a snapshot file. | `path` | `applyToFlash=false` | ✅ | ✅ | ✅ |
727
- | `save_flash` | Persist the current configuration to flash storage. | — | — | ✅ | ✅ | |
780
+ | `save_flash` | Persist the current configuration to flash storage. | — | `category` | ✅ | ✅ | |
728
781
  | `set` | Write a configuration value in the selected category. | `category`, `item`, `value` | — | ✅ | ✅ | ✅ |
729
782
  | `shuffle` | Discover PRG/CRT files and run each with optional screen capture. | — | `root="/"`, `extensions=["prg","crt"]`, `durationMs=5000`, `captureScreen=true`, `maxPrograms=10`, `outputPath`, `resetDelayMs=100` | ✅ | ✅ | |
730
783
  | `snapshot` | Snapshot configuration to disk for later restore or diff. | `path` | — | ✅ | ✅ | ✅ |
@@ -762,6 +815,7 @@ Grouped entry point for disk mounts, listings, image creation, and program disco
762
815
  | `create_image` | Create a blank disk image of the specified format. | `format`, `path` | `diskname`, `tracks` | ✅ | ✅ | |
763
816
  | `file_info` | Inspect metadata for a file on the Ultimate filesystem. | `path` | — | ✅ | ✅ | |
764
817
  | `find_and_run` | Search for a PRG/CRT by name substring and run the first match. | `nameContains` | `root="/"`, `extensions`, `caseInsensitive=true`, `sort="discovered"`, `waitMs=0`, `captureCandidates=10` | ✅ | ✅ | |
818
+ | `find_paths` | Search Ultimate storage for paths whose names contain a substring, without running anything. | `nameContains` | `root="/"`, `extensions`, `maxResults=100`, `caseInsensitive=true` | ✅ | ✅ | |
765
819
  | `list_drives` | List Ultimate drive slots and their mounted images. | — | — | ✅ | ✅ | ✅ |
766
820
  | `mount` | Mount a disk image with optional verification and retries. Supports verification. | `drive`, `image` | `type`, `attachmentMode`, `driveMode`, `verify=false`, `powerOnIfNeeded=true`, `resetAfterMount=true`, `maxRetries=2`, `retryDelayMs=500` | ✅ | ✅ | ✅ |
767
821
  | `unmount` | Remove the mounted image from an Ultimate drive slot. | `drive` | — | ✅ | ✅ | |
@@ -796,7 +850,7 @@ Grouped entry point for frame capture and graphics rendering workflows.
796
850
 
797
851
  | Operation | Description | Required Inputs | Optional Inputs | C64U | U2 | VICE |
798
852
  | --- | --- | --- | --- | --- | --- | --- |
799
- | `capture_frame` | Capture one or more complete video frames from the active backend. | — | `count=1`, `includePixels=true`, `encoding="base64"` | ✅ | | ✅ |
853
+ | `capture_frame` | Capture one or more complete video frames from the active backend. | — | `count=1`, `includePixels=false`, `format`, `encoding="base64"` | ✅ | | ✅ |
800
854
  | `get_display_state` | Read VIC-II and CIA2 registers to determine the current graphics mode and memory layout. The same shared-memory path is used on C64U/U64, U2-family hardware, and VICE, so the response shape is identical. | — | — | ✅ | ✅ | ✅ |
801
855
  | `render_bitmap` | Import an image file, convert it to VIC-II bitmap memory, write it into RAM, and display it. | `imagePath`, `format` | `bitmapAddress=8192`, `screenAddress=1024`, `borderColor=0`, `backgroundColor=0`, `preserveAspect=true` | ✅ | ✅ | ✅ |
802
856
  | `render_petscii_art` | Create PETSCII art from prompts, text, or explicit bitmap data, and optionally display it on the C64. | — | `prompt`, `text`, `maxWidth`, `maxHeight`, `borderColor`, `backgroundColor`, `foregroundColor`, `dryRun=false`, `bitmap` | ✅ | ✅ | ✅ |
@@ -810,7 +864,7 @@ Cross-platform PETSCII typing plus native Ultimate keyboard and joystick events.
810
864
  | Operation | Description | Required Inputs | Optional Inputs | C64U | U2 | VICE |
811
865
  | --- | --- | --- | --- | --- | --- | --- |
812
866
  | `joystick` | Simulate joystick input. On C64U/U64 this uses machine:input. On VICE it attaches VICE's I/O simulation joyport device while pressed; that device also pulls CIA lines 5-7 low, so port 1 produces phantom keys (e.g. SHIFT+C=) and port 2 reads $1E for up instead of $7E. Prefer port 2 on VICE and test bits 0-4. | `port`, `controls`, `action` | `durationMs=80` | ✅ | | ✅ |
813
- | `key` | Tap a single key or hold it for a duration. | `key` | `durationMs=0`, `count=1` | ✅ | ✅ | ✅ |
867
+ | `key` | Press a single key one or more times, optionally with a delay between presses. | `key` | `durationMs=0`, `count=1` | ✅ | ✅ | ✅ |
814
868
  | `keyboard` | Send physical C64 keyboard matrix events through machine:input (a C64U firmware version that provides it, or U64 3.15+). | `inputs`, `transition` | — | ✅ | | |
815
869
  | `release_all` | Release every key and joystick control injected through machine:input (a C64U firmware version that provides it, or U64 3.15+). | — | — | ✅ | | |
816
870
  | `state` | Read the keys and joystick controls held through machine:input (a C64U firmware version that provides it, or U64 3.15+). | — | — | ✅ | | |
@@ -826,7 +880,8 @@ Grouped entry point for memory I/O, screen reads, and screen polling.
826
880
  | `copy_memory` | Copy a RAM region to another address. | `source`, `dest`, `length` | — | ✅ | ✅ | ✅ |
827
881
  | `disassemble` | Disassemble a memory region into annotated 6502/6510 instructions, including undocumented opcodes with canonical names. Symbol annotations from `.vs` files are applied when available. Works on C64U/U64, U2-family hardware, and VICE. | `address` | `length=64`, `instructionCount` | ✅ | ✅ | ✅ |
828
882
  | `fill_memory` | Fill a memory range with a repeating byte pattern. | `address`, `length`, `pattern` | — | ✅ | ✅ | ✅ |
829
- | `read` | Read a range of bytes and return a hex dump with address metadata. | `address` | `length=256` | ✅ | ✅ | ✅ |
883
+ | `list_basic` | List the BASIC program in memory as text: reads the $2B-$2E pointers, walks the line links and detokenises. Works on C64U/U64, U2-family hardware, and VICE. | — | — | ✅ | ✅ | ✅ |
884
+ | `read` | Read a range of bytes and return a hex dump with address metadata. | `address` | `length=256`, `format="hex"` | ✅ | ✅ | ✅ |
830
885
  | `read_screen` | Return the current 40x25 text screen converted to ASCII. | — | — | ✅ | ✅ | ✅ |
831
886
  | `save_memory` | Dump a memory range to a local file, with an optional PRG load-address header. | `startAddress`, `endAddress`, `filePath` | `asPrg=true` | ✅ | ✅ | ✅ |
832
887
  | `search_memory` | Search for a byte pattern within a memory range and return matching addresses. | `startAddress`, `endAddress`, `pattern` | `maxResults=10` | ✅ | ✅ | ✅ |
@@ -852,11 +907,11 @@ Grouped entry point for program upload, execution, and batch workflows.
852
907
  | `batch_run` | Run multiple PRG/CRT programs with post-run assertions. | `programs` | `continueOnError=false`, `durationMs=2000`, `outputPath`, `resetDelayMs=100` | ✅ | ✅ | ✅ |
853
908
  | `bundle_run` | Capture screen, memory, and debug registers into an artifact bundle. | `runId`, `outputPath` | `captureScreen=true`, `memoryRanges`, `captureDebugReg` | ✅ | ✅ | ✅ |
854
909
  | `cross_platform_greeting` | Show a platform-customized greeting on one or more configured backends, capture screenshots, and verify the results. Supports verification. | — | `platforms`, `messageTemplate="HAVE A GREAT DAY, {PLATFORM}!"`, `verify=true`, `captureScreenshot=true`, `outputPath`, `restoreActiveBackend=true`, `timeoutMs=1500`, `pollIntervalMs=100` | ✅ | ✅ | ✅ |
855
- | `load_prg` | Load a PRG from Ultimate storage without executing it. | `path` | `symbolsFile` | ✅ | ✅ | |
910
+ | `load_prg` | Load a PRG from Ultimate storage without executing it. | `path` | `symbolsFile` | ✅ | ✅ | ✅ |
856
911
  | `run_crt` | Mount and run a CRT cartridge image. | `path` | — | ✅ | ✅ | |
857
912
  | `run_prg` | Load and execute a PRG from Ultimate-visible storage on C64U/U64 or U2-family hardware, or from a host-local path on VICE. | `path` | `symbolsFile` | ✅ | ✅ | ✅ |
858
913
  | `upload_run_asm` | Assemble 6502/6510 source, upload the PRG, and execute it. Supports verification. | `program` | `verify=false` | ✅ | ✅ | ✅ |
859
- | `upload_run_basic` | Upload Commodore BASIC v2 source and execute it immediately. Supports verification. | `program` | `verify=false` | ✅ | ✅ | ✅ |
914
+ | `upload_run_basic` | Upload Commodore BASIC v2 source and execute it immediately. Supports verification. | `program` | `verify=false`, `autoFix=true` | ✅ | ✅ | ✅ |
860
915
 
861
916
  #### c64_rag
862
917
 
@@ -902,7 +957,7 @@ Grouped entry point for starting and stopping Ultimate streaming sessions.
902
957
 
903
958
  | Operation | Description | Required Inputs | Optional Inputs | C64U | U2 | VICE |
904
959
  | --- | --- | --- | --- | --- | --- | --- |
905
- | `start` | Start an Ultimate streaming session toward a host:port target. | `stream`, `target` | — | ✅ | | |
960
+ | `start` | Start an Ultimate streaming session. The device sends an unencrypted UDP stream to the target (IPv4 address or hostname, optional :port). | `stream`, `target` | — | ✅ | | |
906
961
  | `stop` | Stop an active Ultimate streaming session. | `stream` | — | ✅ | | |
907
962
 
908
963
  #### c64_system
@@ -915,11 +970,11 @@ Grouped entry point for power, reset, menu, and background task control.
915
970
  | `menu` | Toggle the Ultimate menu button for navigation. | — | — | ✅ | ✅ | |
916
971
  | `pause` | Pause the machine until resumed. | — | — | ✅ | ✅ | |
917
972
  | `performance_report` | Summarize diagnostics spans and tool latencies from the current or latest MCP session. | — | `scope="current"`, `includeTimeline=true`, `maxEntries=25` | ✅ | ✅ | ✅ |
918
- | `power_cycle` | Return the active backend to a fresh state. C64U/U64 Tool Menu verification requires machine:menu_screen (a C64U firmware version that provides it, or U64 3.15+); U2-family reboots through REST. | — | — | ✅ | ✅ | ✅ |
919
- | `poweroff` | Shut down C64U/U64 or quit VICE. U2-family hardware cannot power off. | — | — | ✅ | | ✅ |
973
+ | `power_cycle` | Return the active backend to a fresh state. C64U/U64 Tool Menu verification requires machine:menu_screen (a C64U firmware version that provides it, or U64 3.15+); U2-family reboots through REST. | — | `waitForReady=false`, `readyTimeoutMs=15000` | ✅ | ✅ | ✅ |
974
+ | `poweroff` | Shut down C64U/U64 or quit VICE. U2-family hardware cannot power off. On C64U/U64 the machine cannot be restarted by software afterwards: power must be restored at the outlet, and it starts by itself only if automatic startup on power restoration is enabled. | — | — | ✅ | | ✅ |
920
975
  | `read_menu_screen` | Read the active Ultimate menu's raw character and colour matrix through machine:menu_screen (a C64U firmware version that provides it, or U64/U2 3.15+). | — | — | ✅ | ✅ | |
921
- | `reboot` | Trigger a firmware reboot to recover from faults. | — | — | ✅ | ✅ | ✅ |
922
- | `reset` | Issue a soft reset without cutting power. | — | — | ✅ | ✅ | ✅ |
976
+ | `reboot` | Trigger a firmware reboot to recover from faults. | — | `waitForReady=false`, `readyTimeoutMs=15000` | ✅ | ✅ | ✅ |
977
+ | `reset` | Issue a soft reset without cutting power. | — | `waitForReady=false`, `readyTimeoutMs=15000` | ✅ | ✅ | ✅ |
923
978
  | `resume` | Resume CPU execution after a pause. | — | — | ✅ | ✅ | |
924
979
  | `start_task` | Start a named background task that runs on an interval. | `name`, `operation` | `arguments={}`, `intervalMs=1000`, `maxIterations` | ✅ | ✅ | ✅ |
925
980
  | `stop_all_tasks` | Stop every running background task and persist state. | — | — | ✅ | ✅ | ✅ |
@@ -483,6 +483,8 @@ FF ISC ABSX U 7 - M←M+1; A←A-M-(1-C); set(C,Z,N,V)
483
483
  - Data: `.byte expr[,…]`, `.word expr[,…]`
484
484
  - Symbols: `name = expr` / `.equ name, expr`
485
485
  - Alignment/fill (assembler‑specific): `.align n`, `.fill count, value`
486
+ - Text: `.text "str"` / `.ascii "str"` convert ASCII to PETSCII for the default upper-case/graphics set: `a`-`z` and `A`-`Z` both become codes `$41`-`$5A`, `\n` becomes RETURN (`$0D`), other ASCII characters (digits, punctuation, space) are unchanged, and characters above `$7F` are an error.
487
+ - `.equ` accepts both `name .equ expr` and `.equ name, expr`; `.fill count[, value]` and `.align n[, fill]` default the fill byte to 0.
486
488
 
487
489
  ## Tips and Tricks
488
490
 
package/dist/c64Client.js CHANGED
@@ -8,6 +8,7 @@ See <https://www.gnu.org/licenses/> for details.
8
8
  import { Buffer } from "node:buffer";
9
9
  import { execFile } from "node:child_process";
10
10
  import { createSocket } from "node:dgram";
11
+ import { lookup } from "node:dns/promises";
11
12
  import { networkInterfaces } from "node:os";
12
13
  import axios from "axios";
13
14
  import { basicToPrg } from "./tools/translation/basicTokenizer.js";
@@ -16,9 +17,11 @@ import { stripHexByteSeparators } from "./tools/hex.js";
16
17
  import { SIDWAVE_PLAY_ADDRESS } from "./sidwaveCompiler.js";
17
18
  import { petsciiToAscii, screenCodesToAscii } from "./petscii.js";
18
19
  import { resolveAddressSymbol } from "./knowledge.js";
20
+ import { assertNotBareHex, parseNumericLiteral } from "./numericArg.js";
21
+ import { ToolValidationError } from "./tools/errors.js";
19
22
  import { createAllFacades, createFacade, } from "./device.js";
20
23
  import { Api } from "../generated/c64/index.js";
21
- import { createLoggingHttpClient } from "./loggingHttpClient.js";
24
+ import { createLoggingHttpClient, RequestQueue } from "./loggingHttpClient.js";
22
25
  import { withDiagnosticSpan, writeDiagnosticEvent } from "./diagnostics.js";
23
26
  import { analyzeStereoStream, normalizeAudioDuration, recordAndAnalyzeAudio, } from "./audio/record_and_analyze_audio.js";
24
27
  import { C64U_NTSC_AUDIO_SAMPLE_RATE, C64U_PAL_AUDIO_SAMPLE_RATE, collectCompleteVideoFrames, parseAudioPacket, parseVideoPacket, } from "./streamCapture.js";
@@ -34,6 +37,10 @@ export class KeyboardQueueDrainTimeoutError extends Error {
34
37
  this.timeoutMs = timeoutMs;
35
38
  }
36
39
  }
40
+ function isLoopbackHost(host) {
41
+ const h = host.toLowerCase().replace(/^\[|\]$/g, "");
42
+ return h === "localhost" || h === "::1" || h.startsWith("127.");
43
+ }
37
44
  /** JOYPORT_ID_IO_SIMULATION in joyport.h (the same in VICE 3.9 and 3.10). */
38
45
  const VICE_JOYPORT_IO_SIMULATION = 37;
39
46
  const C64U_SYSTEM_MODE_CATEGORY = "U64 Specific Settings";
@@ -190,9 +197,19 @@ export class C64Client {
190
197
  /** VICE joyport device ids to restore when a joystick press ends. */
191
198
  this.viceJoyportSavedDevice = new Map();
192
199
  this.activeType = "c64u";
200
+ /**
201
+ * Pause bookkeeping per facade. Tools that pause for their own purpose
202
+ * (acquirePause/releasePause) share one device pause: it is sent when the
203
+ * owned depth goes 0 -> 1 and the machine is not already explicitly paused,
204
+ * and the device resume is sent only when the depth returns to 0 and no
205
+ * explicit `c64_system pause` is in effect. An explicit pause is cleared
206
+ * only by an explicit resume.
207
+ */
208
+ this.pauseStates = new WeakMap();
193
209
  this.baseUrl = baseUrl;
194
210
  const headers = options.networkPassword ? { "X-Password": options.networkPassword } : undefined;
195
- this.http = createLoggingHttpClient({ baseURL: baseUrl, timeout: 10000, headers });
211
+ // Shares the device queue with the C64U backend: the fallback REST calls made through this client must not overlap its requests.
212
+ this.http = createLoggingHttpClient({ baseURL: baseUrl, timeout: 10000, headers }, undefined, RequestQueue.forDevice(baseUrl));
196
213
  this.api = new Api(this.http);
197
214
  const forceC64uFacade = options.forceC64uFacade ?? true;
198
215
  if (forceC64uFacade) {
@@ -659,15 +676,44 @@ export class C64Client {
659
676
  }
660
677
  }
661
678
  async readScreen() {
679
+ return (await this.readScreenDetailed()).text;
680
+ }
681
+ /**
682
+ * Reads the text screen where the VIC-II currently shows it: the screen
683
+ * base comes from $D018 bits 4-7 and the VIC bank from $DD00 bits 0-1, and
684
+ * $D018 bit 1 selects the lower-case character set. Reads of both registers
685
+ * that fail, or that both return $00 (not a usable VIC state), fall back to
686
+ * the default $0400 and the upper-case set.
687
+ */
688
+ async readScreenDetailed() {
662
689
  return withDiagnosticSpan("client", "read_screen", {}, async () => {
663
- const bytes = await this.readMemoryRaw(0x0400, 0x03e8);
664
- return screenCodesToAscii(bytes, { columns: 40, rows: 25 });
690
+ let baseAddress = 0x0400;
691
+ let charset = "upper";
692
+ let registers;
693
+ try {
694
+ const d018 = (await this.readMemoryRaw(0xd018, 1))[0];
695
+ const dd00 = (await this.readMemoryRaw(0xdd00, 1))[0];
696
+ if (d018 !== undefined && dd00 !== undefined && !(d018 === 0 && dd00 === 0)) {
697
+ registers = { d018, dd00 };
698
+ baseAddress = (3 - (dd00 & 0x03)) * 0x4000 + ((d018 >> 4) & 0x0f) * 0x0400;
699
+ charset = (d018 & 0x02) !== 0 ? "lower" : "upper";
700
+ }
701
+ }
702
+ catch {
703
+ // Keep the defaults when the VIC registers cannot be read.
704
+ }
705
+ const length = Math.min(0x03e8, 0x10000 - baseAddress);
706
+ const bytes = await this.readMemoryRaw(baseAddress, length);
707
+ const text = screenCodesToAscii(bytes, { columns: 40, rows: 25, charset });
708
+ return { text, baseAddress, charset, ...(registers ? { registers } : {}) };
665
709
  });
666
710
  }
667
711
  async reset() {
668
712
  try {
669
713
  const facade = await this.facadePromise;
670
714
  const res = await facade.reset();
715
+ if (res.success)
716
+ this.clearExplicitPause(facade);
671
717
  return { success: res.success, details: res.details };
672
718
  }
673
719
  catch (error) {
@@ -678,6 +724,8 @@ export class C64Client {
678
724
  try {
679
725
  const facade = await this.facadePromise;
680
726
  const res = await facade.reboot();
727
+ if (res.success)
728
+ this.clearExplicitPause(facade);
681
729
  return { success: res.success, details: res.details };
682
730
  }
683
731
  catch (error) {
@@ -906,21 +954,127 @@ export class C64Client {
906
954
  return { success: false, details: this.normaliseError(error) };
907
955
  }
908
956
  }
957
+ pauseStateFor(facade) {
958
+ let state = this.pauseStates.get(facade);
959
+ if (!state) {
960
+ state = { depth: 0, explicit: false };
961
+ this.pauseStates.set(facade, state);
962
+ }
963
+ return state;
964
+ }
965
+ /** Owned pause for one tool call. Pass the facade pinned at call start; pair with releasePause. A failed result holds no pause and needs no release. */
966
+ async acquirePause(pinnedFacade) {
967
+ try {
968
+ const facade = pinnedFacade ?? await this.facadePromise;
969
+ const state = this.pauseStateFor(facade);
970
+ state.depth += 1;
971
+ if (state.depth === 1 && !state.explicit) {
972
+ const pending = facade.pause().catch((error) => ({ success: false, details: this.normaliseError(error) }));
973
+ state.devicePause = pending;
974
+ }
975
+ const result = state.devicePause ? await state.devicePause : { success: true };
976
+ if (!result.success) {
977
+ state.depth -= 1;
978
+ if (state.depth === 0)
979
+ state.devicePause = undefined;
980
+ return result;
981
+ }
982
+ return result;
983
+ }
984
+ catch (error) {
985
+ return { success: false, details: this.normaliseError(error) };
986
+ }
987
+ }
988
+ /** Release one owned pause. The device resume is sent only by the last release and not while an explicit pause is in effect. */
989
+ async releasePause(pinnedFacade) {
990
+ try {
991
+ const facade = pinnedFacade ?? await this.facadePromise;
992
+ const state = this.pauseStateFor(facade);
993
+ if (state.depth === 0)
994
+ return { success: true, details: { resumed: false, reason: "no owned pause" } };
995
+ state.depth -= 1;
996
+ if (state.depth > 0)
997
+ return { success: true, details: { resumed: false, reason: "pause still held by another call" } };
998
+ state.devicePause = undefined;
999
+ if (state.explicit)
1000
+ return { success: true, details: { resumed: false, reason: "machine was paused explicitly" } };
1001
+ return await facade.resume();
1002
+ }
1003
+ catch (error) {
1004
+ return { success: false, details: this.normaliseError(error) };
1005
+ }
1006
+ }
1007
+ /** `c64_system pause`: stays in effect until an explicit resume, whatever tools acquire or release meanwhile. */
1008
+ async explicitPause(pinnedFacade) {
1009
+ try {
1010
+ const facade = pinnedFacade ?? await this.facadePromise;
1011
+ const state = this.pauseStateFor(facade);
1012
+ if (state.depth > 0 && !state.explicit) {
1013
+ state.explicit = true; // the device is already paused by a running tool call
1014
+ return { success: true, details: { alreadyPaused: true } };
1015
+ }
1016
+ const result = await facade.pause();
1017
+ if (result.success)
1018
+ state.explicit = true;
1019
+ return result;
1020
+ }
1021
+ catch (error) {
1022
+ return { success: false, details: this.normaliseError(error) };
1023
+ }
1024
+ }
1025
+ /** `c64_system resume`: clears the explicit pause. While a tool call still holds an owned pause, the device resume is left to its release. */
1026
+ async explicitResume(pinnedFacade) {
1027
+ try {
1028
+ const facade = pinnedFacade ?? await this.facadePromise;
1029
+ const state = this.pauseStateFor(facade);
1030
+ state.explicit = false;
1031
+ if (state.depth > 0)
1032
+ return { success: true, details: { resumed: false, reason: "a running tool call still holds a pause; it resumes the machine when it finishes" } };
1033
+ return await facade.resume();
1034
+ }
1035
+ catch (error) {
1036
+ return { success: false, details: this.normaliseError(error) };
1037
+ }
1038
+ }
909
1039
  async poweroff() {
910
1040
  try {
911
1041
  const facade = await this.facadePromise;
912
- return await facade.poweroff();
1042
+ const result = await facade.poweroff();
1043
+ if (result.success)
1044
+ this.clearExplicitPause(facade);
1045
+ return result;
913
1046
  }
914
1047
  catch (error) {
915
1048
  return { success: false, details: this.normaliseError(error) };
916
1049
  }
917
1050
  }
1051
+ /**
1052
+ * A reset, reboot, power-off or power cycle restarts the CPU, so an explicit
1053
+ * `c64_system pause` no longer holds. Without clearing the flag, later tool
1054
+ * calls would skip their own pause and read or write a running machine.
1055
+ */
1056
+ clearExplicitPause(facade) {
1057
+ this.pauseStateFor(facade).explicit = false;
1058
+ }
918
1059
  /**
919
1060
  * Start from a clean machine state. C64U/U64 firmware exposes this only in
920
1061
  * the Tool Menu; U2-family firmware uses its equivalent REST reboot and
921
1062
  * VICE is restarted when it is managed by c64bridge.
922
1063
  */
923
1064
  async powerCycle() {
1065
+ let facade;
1066
+ try {
1067
+ facade = await this.facadePromise;
1068
+ }
1069
+ catch {
1070
+ facade = undefined;
1071
+ }
1072
+ const result = await this.powerCycleActiveBackend();
1073
+ if (result.success && facade)
1074
+ this.clearExplicitPause(facade);
1075
+ return result;
1076
+ }
1077
+ async powerCycleActiveBackend() {
924
1078
  let menuOpened = false;
925
1079
  let cleanup;
926
1080
  let pinnedFacade;
@@ -1212,28 +1366,28 @@ export class C64Client {
1212
1366
  return { success: false, details: this.normaliseError(error) };
1213
1367
  }
1214
1368
  }
1215
- async configLoadFromFlash() {
1369
+ async configLoadFromFlash(category) {
1216
1370
  try {
1217
1371
  const facade = await this.facadePromise;
1218
- return await facade.configLoadFromFlash();
1372
+ return await facade.configLoadFromFlash(category);
1219
1373
  }
1220
1374
  catch (error) {
1221
1375
  return { success: false, details: this.normaliseError(error) };
1222
1376
  }
1223
1377
  }
1224
- async configSaveToFlash() {
1378
+ async configSaveToFlash(category) {
1225
1379
  try {
1226
1380
  const facade = await this.facadePromise;
1227
- return await facade.configSaveToFlash();
1381
+ return await facade.configSaveToFlash(category);
1228
1382
  }
1229
1383
  catch (error) {
1230
1384
  return { success: false, details: this.normaliseError(error) };
1231
1385
  }
1232
1386
  }
1233
- async configResetToDefault() {
1387
+ async configResetToDefault(category) {
1234
1388
  try {
1235
1389
  const facade = await this.facadePromise;
1236
- return await facade.configResetToDefault();
1390
+ return await facade.configResetToDefault(category);
1237
1391
  }
1238
1392
  catch (error) {
1239
1393
  return { success: false, details: this.normaliseError(error) };
@@ -1328,10 +1482,28 @@ export class C64Client {
1328
1482
  return { backend: "c64u", frames };
1329
1483
  }
1330
1484
  const host = new URL(this.baseUrl).hostname;
1331
- const bindAddress = await this.resolveLocalCaptureAddress(host);
1485
+ const { bindAddress, acceptsSource } = await this.resolveCaptureEndpoint(host);
1332
1486
  const socket = createSocket("udp4");
1333
1487
  const packets = [];
1334
1488
  let stopError = null;
1489
+ let captureFailed = false;
1490
+ let stopped = false;
1491
+ // Stops the stream once. A failure is reported as a warning (success path)
1492
+ // or as a diagnostic (failure path); it never replaces the capture outcome.
1493
+ const stopOneShotVideoStream = async () => {
1494
+ if (stopped)
1495
+ return {};
1496
+ stopped = true;
1497
+ try {
1498
+ await this.ensureStreamSuccess(await facade.streamStop("video"), "stop video stream");
1499
+ return {};
1500
+ }
1501
+ catch (error) {
1502
+ stopError = error instanceof Error ? error : new Error(String(error));
1503
+ writeDiagnosticEvent("video_capture_stop_failed", { error: this.normaliseError(stopError) });
1504
+ return { warnings: [`Video stream stop failed after the capture: ${stopError.message}`] };
1505
+ }
1506
+ };
1335
1507
  try {
1336
1508
  await this.bindCaptureSocket(socket, bindAddress);
1337
1509
  const target = this.socketEndpoint(socket, bindAddress);
@@ -1341,7 +1513,9 @@ export class C64Client {
1341
1513
  const timer = setTimeout(() => {
1342
1514
  reject(new Error(`Timed out after ${timeoutMs}ms while capturing ${count} video frame(s)`));
1343
1515
  }, timeoutMs);
1344
- socket.on("message", (msg) => {
1516
+ socket.on("message", (msg, rinfo) => {
1517
+ if (!acceptsSource(rinfo.address))
1518
+ return;
1345
1519
  try {
1346
1520
  const packet = this.parseVideoPacketOrNull(Buffer.from(msg));
1347
1521
  if (!packet) {
@@ -1364,19 +1538,18 @@ export class C64Client {
1364
1538
  reject(error);
1365
1539
  });
1366
1540
  });
1367
- return { backend: "c64u", frames };
1541
+ return { backend: "c64u", frames, ...(await stopOneShotVideoStream()) };
1542
+ }
1543
+ catch (error) {
1544
+ captureFailed = true;
1545
+ throw error;
1368
1546
  }
1369
1547
  finally {
1370
- try {
1371
- await this.ensureStreamSuccess(await facade.streamStop("video"), "stop video stream");
1372
- }
1373
- catch (error) {
1374
- stopError = error instanceof Error ? error : new Error(String(error));
1548
+ if (captureFailed) {
1549
+ // Keep the capture error; the stop failure is only a diagnostic.
1550
+ await stopOneShotVideoStream();
1375
1551
  }
1376
1552
  socket.close();
1377
- if (stopError) {
1378
- throw stopError;
1379
- }
1380
1553
  }
1381
1554
  }
1382
1555
  async ensureC64uVideoCaptureSession(facade) {
@@ -1393,7 +1566,7 @@ export class C64Client {
1393
1566
  }
1394
1567
  await this.stopC64uVideoCaptureSession();
1395
1568
  const host = new URL(this.baseUrl).hostname;
1396
- const bindAddress = await this.resolveLocalCaptureAddress(host);
1569
+ const { bindAddress, acceptsSource } = await this.resolveCaptureEndpoint(host);
1397
1570
  const socket = createSocket("udp4");
1398
1571
  await this.bindCaptureSocket(socket, bindAddress);
1399
1572
  const target = this.socketEndpoint(socket, bindAddress);
@@ -1408,8 +1581,8 @@ export class C64Client {
1408
1581
  closed: false,
1409
1582
  error: null,
1410
1583
  };
1411
- socket.on("message", (msg) => {
1412
- if (session.closed) {
1584
+ socket.on("message", (msg, rinfo) => {
1585
+ if (session.closed || !acceptsSource(rinfo.address)) {
1413
1586
  return;
1414
1587
  }
1415
1588
  const packet = this.parseVideoPacketOrNull(Buffer.from(msg));
@@ -1561,7 +1734,7 @@ export class C64Client {
1561
1734
  }
1562
1735
  async captureC64uAudioSamples(facade, samplePairs, options) {
1563
1736
  const host = new URL(this.baseUrl).hostname;
1564
- const bindAddress = await this.resolveLocalCaptureAddress(host);
1737
+ const { bindAddress, acceptsSource } = await this.resolveCaptureEndpoint(host);
1565
1738
  const socket = createSocket("udp4");
1566
1739
  const chunks = [];
1567
1740
  let collectedValues = 0;
@@ -1574,7 +1747,9 @@ export class C64Client {
1574
1747
  let failed = false;
1575
1748
  // The firmware can emit UDP before its REST response reaches us. Listen
1576
1749
  // before starting, and cap collection even while start/config/stop requests are pending.
1577
- const onMessage = (msg) => {
1750
+ const onMessage = (msg, rinfo) => {
1751
+ if (!acceptsSource(rinfo.address))
1752
+ return;
1578
1753
  if (captureSettled || captureError || collectedValues >= neededValues)
1579
1754
  return;
1580
1755
  try {
@@ -1678,7 +1853,31 @@ export class C64Client {
1678
1853
  }
1679
1854
  return C64U_PAL_AUDIO_SAMPLE_RATE;
1680
1855
  }
1681
- async resolveLocalCaptureAddress(remoteHost) {
1856
+ /**
1857
+ * Local address to bind the capture socket to, and a filter that accepts UDP
1858
+ * packets only from the device. The device host is resolved once here. A
1859
+ * device on loopback (the mock) is reached and heard on loopback.
1860
+ */
1861
+ async resolveCaptureEndpoint(remoteHost) {
1862
+ const loopback = isLoopbackHost(remoteHost);
1863
+ let deviceAddresses;
1864
+ try {
1865
+ const resolved = await lookup(remoteHost, { all: true, family: 4 });
1866
+ deviceAddresses = resolved.map((entry) => entry.address);
1867
+ }
1868
+ catch (error) {
1869
+ if (!loopback) {
1870
+ throw new Error(`Cannot resolve the device host '${remoteHost}' to an IPv4 address for stream capture: ${error instanceof Error ? error.message : String(error)}`);
1871
+ }
1872
+ deviceAddresses = ["127.0.0.1"];
1873
+ }
1874
+ const deviceIsLoopback = loopback || deviceAddresses.every((address) => address.startsWith("127."));
1875
+ const acceptsSource = (address) => deviceIsLoopback
1876
+ ? address.startsWith("127.")
1877
+ : deviceAddresses.includes(address.replace(/^::ffff:/i, ""));
1878
+ return { bindAddress: await this.resolveLocalCaptureAddress(remoteHost, deviceIsLoopback), acceptsSource };
1879
+ }
1880
+ async resolveLocalCaptureAddress(remoteHost, deviceIsLoopback = isLoopbackHost(remoteHost)) {
1682
1881
  const socket = createSocket("udp4");
1683
1882
  try {
1684
1883
  await new Promise((resolve, reject) => {
@@ -1694,12 +1893,16 @@ export class C64Client {
1694
1893
  }
1695
1894
  }
1696
1895
  catch {
1697
- // Fall back below.
1896
+ // Handled below.
1698
1897
  }
1699
1898
  finally {
1700
1899
  socket.close();
1701
1900
  }
1702
- return "127.0.0.1";
1901
+ // Falling back to loopback would tell the device to stream to itself, and
1902
+ // the caller would only see a capture timeout.
1903
+ if (deviceIsLoopback)
1904
+ return "127.0.0.1";
1905
+ throw new Error(`Cannot find a local network address that reaches the device at ${remoteHost}; the stream target must be an address the device can send to. Check the network route to the device.`);
1703
1906
  }
1704
1907
  async bindCaptureSocket(socket, bindAddress) {
1705
1908
  await new Promise((resolve, reject) => {
@@ -2177,38 +2380,13 @@ export class C64Client {
2177
2380
  if (typeof value !== "string") {
2178
2381
  throw new Error("Expected string input");
2179
2382
  }
2180
- const trimmed = value.trim();
2181
- if (trimmed.length === 0) {
2383
+ if (value.trim().length === 0) {
2182
2384
  throw new Error("Empty numeric value");
2183
2385
  }
2184
- const lower = trimmed.toLowerCase();
2185
- let radix = 10;
2186
- let literal = lower;
2187
- let pattern;
2188
- if (lower.startsWith("$")) {
2189
- radix = 16;
2190
- literal = lower.slice(1);
2191
- pattern = /^[0-9a-f]+$/;
2192
- }
2193
- else if (lower.startsWith("0x")) {
2194
- radix = 16;
2195
- literal = lower.slice(2);
2196
- pattern = /^[0-9a-f]+$/;
2197
- }
2198
- else if (lower.startsWith("%")) {
2199
- radix = 2;
2200
- literal = lower.slice(1);
2201
- pattern = /^[01]+$/;
2202
- }
2203
- else {
2204
- pattern = /^[0-9]+$/;
2205
- }
2206
- if (!pattern.test(literal)) {
2207
- throw new Error(`Unable to parse numeric value "${value}"`);
2208
- }
2209
- const parsed = Number.parseInt(literal, radix);
2210
- if (!Number.isFinite(parsed) || Number.isNaN(parsed) || parsed < 0) {
2211
- throw new Error(`Unable to parse numeric value "${value}"`);
2386
+ const parsed = parseNumericLiteral(value);
2387
+ if (parsed === undefined) {
2388
+ assertNotBareHex(value);
2389
+ throw new ToolValidationError(`Unable to parse numeric value "${value}"`);
2212
2390
  }
2213
2391
  return parsed;
2214
2392
  }