c64bridge 1.0.6 → 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.
- package/.github/skills/assembly-program/SKILL.md +37 -0
- package/.github/skills/basic-program/SKILL.md +40 -0
- package/.github/skills/cross-platform-demo/SKILL.md +32 -0
- package/.github/skills/drive-manager/SKILL.md +33 -0
- package/.github/skills/graphics-demo/SKILL.md +30 -0
- package/.github/skills/hello-world/SKILL.md +36 -0
- package/.github/skills/memory-debug/SKILL.md +32 -0
- package/.github/skills/pr-converge/SKILL.md +33 -0
- package/.github/skills/printer-job/SKILL.md +30 -0
- package/.github/skills/sid-music/SKILL.md +35 -0
- package/.github/skills/software-launch/SKILL.md +31 -0
- package/.github/skills/stream-control/SKILL.md +29 -0
- package/.github/skills/system-control/SKILL.md +32 -0
- package/AGENTS.md +28 -7
- package/README.md +70 -15
- package/data/assembly/assembly-spec.md +2 -0
- package/dist/c64Client.js +238 -60
- package/dist/chargen.js +0 -4
- package/dist/config.js +53 -28
- package/dist/device.js +92 -37
- package/dist/diagnostics.js +66 -5
- package/dist/hostPaths.js +128 -0
- package/dist/logger.js +3 -2
- package/dist/loggingHttpClient.js +71 -2
- package/dist/mcp-server.js +416 -291
- package/dist/numericArg.js +55 -0
- package/dist/petscii.js +13 -6
- package/dist/platform.js +12 -3
- package/dist/prompts/registry.js +6 -1
- package/dist/redact.js +166 -0
- package/dist/tools/access.js +66 -0
- package/dist/tools/debug.js +6 -0
- package/dist/tools/developer.js +77 -36
- package/dist/tools/errors.js +54 -3
- package/dist/tools/input.js +25 -8
- package/dist/tools/machineControl.js +113 -21
- package/dist/tools/memory.js +135 -30
- package/dist/tools/meta/artifacts.js +5 -5
- package/dist/tools/meta/background.js +216 -54
- package/dist/tools/meta/compilation.js +6 -5
- package/dist/tools/meta/config.js +20 -10
- package/dist/tools/meta/configInventory.js +25 -2
- package/dist/tools/meta/diagnostics.js +2 -1
- package/dist/tools/meta/filesystem.js +16 -14
- package/dist/tools/meta/graphics.js +48 -20
- package/dist/tools/meta/memory.js +45 -19
- package/dist/tools/meta/program.js +10 -7
- package/dist/tools/meta/screen.js +116 -25
- package/dist/tools/pauseGuard.js +23 -0
- package/dist/tools/pollValidator.js +53 -8
- package/dist/tools/programRunners.js +62 -18
- package/dist/tools/registry/batch.js +17 -11
- package/dist/tools/registry/config.js +9 -2
- package/dist/tools/registry/disk.js +12 -2
- package/dist/tools/registry/drive.js +3 -0
- package/dist/tools/registry/extract.js +3 -0
- package/dist/tools/registry/graphics.js +59 -6
- package/dist/tools/registry/index.js +17 -1
- package/dist/tools/registry/memory.js +11 -1
- package/dist/tools/registry/platform.js +4 -1
- package/dist/tools/registry/printer.js +2 -1
- package/dist/tools/registry/program.js +5 -1
- package/dist/tools/registry/rag.js +1 -0
- package/dist/tools/registry/sound.js +12 -1
- package/dist/tools/registry/stream.js +2 -1
- package/dist/tools/registry/system.js +8 -1
- package/dist/tools/schema.js +7 -7
- package/dist/tools/streaming.js +34 -4
- package/dist/tools/translation/assembler.js +247 -24
- package/dist/tools/translation/basicDetokenizer.js +80 -0
- package/dist/tools/translation/basicTokenizer.js +8 -3
- package/dist/tools/translation/disassembler.js +14 -0
- package/dist/tools/types.js +258 -7
- package/dist/tools/vice.js +5 -16
- package/dist/vicBitmap.js +1 -1
- package/dist/vice/mockServer.js +3 -0
- package/dist/vice/process.js +19 -0
- package/dist/vice/resourcePolicy.js +36 -0
- package/dist/vice/viceClient.js +22 -5
- package/doc/vice/support-matrix.md +17 -11
- package/mcp/schemas/c64_batch.schema.json +2 -1
- package/mcp/schemas/c64_config.schema.json +33 -466
- package/mcp/schemas/c64_debug.schema.json +3 -602
- package/mcp/schemas/c64_disk.schema.json +17 -344
- package/mcp/schemas/c64_drive.schema.json +2 -157
- package/mcp/schemas/c64_extract.schema.json +10 -364
- package/mcp/schemas/c64_graphics.schema.json +19 -426
- package/mcp/schemas/c64_input.schema.json +5 -318
- package/mcp/schemas/c64_memory.schema.json +23 -491
- package/mcp/schemas/c64_printer.schema.json +3 -258
- package/mcp/schemas/c64_program.schema.json +16 -491
- package/mcp/schemas/c64_rag.schema.json +1 -81
- package/mcp/schemas/c64_select_backend.schema.json +2 -36
- package/mcp/schemas/c64_sound.schema.json +20 -822
- package/mcp/schemas/c64_stream.schema.json +4 -75
- package/mcp/schemas/c64_system.schema.json +28 -348
- package/mcp/schemas/c64_vice.schema.json +2 -71
- package/mcp/server.json +1 -1
- package/mcp/tools.json +1990 -6900
- package/mcp.json +21 -1
- package/package.json +19 -8
- package/scripts/mockC64Server.mjs +44 -0
- package/scripts/run-coverage.mjs +27 -1
- package/scripts/smoke-run.sh +3 -2
- package/scripts/start.mjs +23 -1
- package/server.json +53 -3
- package/dist/.tsbuildinfo +0 -1
- package/doc/agent-state-spec.md +0 -243
- package/doc/plans/bun/baseline-metrics.md +0 -16
- package/doc/plans/bun/migration-checklist.md +0 -55
- package/doc/plans/bun/migration.md +0 -83
- package/doc/plans/improve-knowledge/notes.md +0 -43
- package/doc/plans/improve-knowledge/plan.md +0 -121
- package/doc/plans/improve-knowledge/rollout.md +0 -110
- package/doc/plans/meta-tools/README.md +0 -110
- package/doc/plans/meta-tools/analysis.md +0 -272
- package/doc/plans/meta-tools/catalog.md +0 -435
- package/doc/plans/meta-tools/notes.md +0 -192
- package/doc/plans/meta-tools/rollout.md +0 -368
- package/doc/plans/prompts/README.md +0 -96
- package/doc/plans/prompts/user-prompt-ideas.md +0 -147
- package/doc/plans/tool-consolidation/notes.md +0 -28
- package/doc/plans/tool-consolidation/plan.md +0 -169
- package/doc/plans/tool-consolidation/rollout.md +0 -104
- package/doc/plans/vice-backend-switch/plan.md +0 -486
- package/doc/plans/vice-backend-switch-2/plan.md +0 -48
- package/doc/plans/vice-support/notes.md +0 -63
- package/doc/plans/vice-support/plan.md +0 -88
- package/doc/plans/vice-support/rollout.md +0 -99
- package/doc/reviews/audit.md +0 -143
- package/doc/reviews/hardening/01-fable/prompt-handover.md +0 -125
- package/doc/reviews/hardening/01-fable/prompt.md +0 -98
- package/doc/reviews/hardening/01-fable/review-prompt.md +0 -468
- package/doc/reviews/hardening/01-fable/review.md +0 -1234
- package/doc/reviews/vice-support-review.md +0 -185
- 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 |  |
|
|
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
|
-
| `get` | Read a configuration category or specific item. | `category` | `item` | ✅ | ✅ | ✅ |
|
|
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=
|
|
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` |
|
|
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
|
-
| `
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
664
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
1371
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
//
|
|
1896
|
+
// Handled below.
|
|
1698
1897
|
}
|
|
1699
1898
|
finally {
|
|
1700
1899
|
socket.close();
|
|
1701
1900
|
}
|
|
1702
|
-
|
|
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
|
-
|
|
2181
|
-
if (trimmed.length === 0) {
|
|
2383
|
+
if (value.trim().length === 0) {
|
|
2182
2384
|
throw new Error("Empty numeric value");
|
|
2183
2385
|
}
|
|
2184
|
-
const
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
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
|
}
|