diff --git a/.github/workflows/release-desktop-apps.yml b/.github/workflows/release-desktop-apps.yml index 204deb12..ede06098 100644 --- a/.github/workflows/release-desktop-apps.yml +++ b/.github/workflows/release-desktop-apps.yml @@ -50,6 +50,11 @@ jobs: chmod u+x src/apps/Highbyte.DotNet6502.App.Headless/publish.sh src/apps/Highbyte.DotNet6502.App.Headless/publish.sh ${{ matrix.runtime }} + - name: Publish RemoteClient App + run: | + chmod u+x src/apps/Highbyte.DotNet6502.App.RemoteClient/publish.sh + src/apps/Highbyte.DotNet6502.App.RemoteClient/publish.sh ${{ matrix.runtime }} + - name: Zip Apps run: | cd src/apps/Highbyte.DotNet6502.App.SilkNetNative/publish/${{ matrix.runtime }} @@ -64,6 +69,9 @@ jobs: cd $GITHUB_WORKSPACE/src/apps/Highbyte.DotNet6502.App.Headless/publish/${{ matrix.runtime }} zip -r $GITHUB_WORKSPACE/DotNet6502-Headless-${{ matrix.runtime }}.zip . + cd $GITHUB_WORKSPACE/src/apps/Highbyte.DotNet6502.App.RemoteClient/publish/${{ matrix.runtime }} + zip -r $GITHUB_WORKSPACE/DotNet6502-RemoteClient-${{ matrix.runtime }}.zip . + - name: Generate SHA256 checksums run: | cd $GITHUB_WORKSPACE @@ -71,6 +79,7 @@ jobs: sha256sum DotNet6502-Avalonia-${{ matrix.runtime }}.zip >> checksums-${{ matrix.runtime }}.sha256 sha256sum DotNet6502-SadConsole-${{ matrix.runtime }}.zip >> checksums-${{ matrix.runtime }}.sha256 sha256sum DotNet6502-Headless-${{ matrix.runtime }}.zip >> checksums-${{ matrix.runtime }}.sha256 + sha256sum DotNet6502-RemoteClient-${{ matrix.runtime }}.zip >> checksums-${{ matrix.runtime }}.sha256 - name: Upload Release Assets if: github.event_name == 'release' @@ -82,6 +91,7 @@ jobs: DotNet6502-Avalonia-${{ matrix.runtime }}.zip \ DotNet6502-SadConsole-${{ matrix.runtime }}.zip \ DotNet6502-Headless-${{ matrix.runtime }}.zip \ + DotNet6502-RemoteClient-${{ matrix.runtime }}.zip \ checksums-${{ matrix.runtime }}.sha256 - name: Upload Workflow Artifacts (non-release) @@ -94,6 +104,7 @@ jobs: DotNet6502-Avalonia-${{ matrix.runtime }}.zip DotNet6502-SadConsole-${{ matrix.runtime }}.zip DotNet6502-Headless-${{ matrix.runtime }}.zip + DotNet6502-RemoteClient-${{ matrix.runtime }}.zip checksums-${{ matrix.runtime }}.sha256 update-package-managers: @@ -138,6 +149,11 @@ jobs: echo "headless_linux_arm64=$(extract_hash Headless linux-arm64)" >> "$GITHUB_OUTPUT" echo "headless_win_x64=$(extract_hash Headless win-x64)" >> "$GITHUB_OUTPUT" echo "headless_win_arm64=$(extract_hash Headless win-arm64)" >> "$GITHUB_OUTPUT" + echo "remote_osx_arm64=$(extract_hash RemoteClient osx-arm64)" >> "$GITHUB_OUTPUT" + echo "remote_linux_x64=$(extract_hash RemoteClient linux-x64)" >> "$GITHUB_OUTPUT" + echo "remote_linux_arm64=$(extract_hash RemoteClient linux-arm64)" >> "$GITHUB_OUTPUT" + echo "remote_win_x64=$(extract_hash RemoteClient win-x64)" >> "$GITHUB_OUTPUT" + echo "remote_win_arm64=$(extract_hash RemoteClient win-arm64)" >> "$GITHUB_OUTPUT" - name: Extract macOS app bundle name from zip id: app_bundle @@ -156,6 +172,9 @@ jobs: HEADLESS_OSX_ARM64_HASH: ${{ steps.hashes.outputs.headless_osx_arm64 }} HEADLESS_LINUX_X64_HASH: ${{ steps.hashes.outputs.headless_linux_x64 }} HEADLESS_LINUX_ARM64_HASH: ${{ steps.hashes.outputs.headless_linux_arm64 }} + REMOTE_OSX_ARM64_HASH: ${{ steps.hashes.outputs.remote_osx_arm64 }} + REMOTE_LINUX_X64_HASH: ${{ steps.hashes.outputs.remote_linux_x64 }} + REMOTE_LINUX_ARM64_HASH: ${{ steps.hashes.outputs.remote_linux_arm64 }} run: | git clone "https://x-access-token:${GH_TOKEN}@github.com/highbyte/homebrew-dotnet-6502.git" cd homebrew-dotnet-6502 @@ -198,6 +217,12 @@ jobs: os.environ["HEADLESS_LINUX_X64_HASH"], os.environ["HEADLESS_LINUX_ARM64_HASH"], ]) + # dotnet-6502-remote formula: macOS arm64, then Linux x64, then Linux arm64 + update_formula("Formula/dotnet-6502-remote.rb", version, [ + os.environ["REMOTE_OSX_ARM64_HASH"], + os.environ["REMOTE_LINUX_X64_HASH"], + os.environ["REMOTE_LINUX_ARM64_HASH"], + ]) PYEOF git config user.name "github-actions[bot]" @@ -214,6 +239,8 @@ jobs: WIN_ARM64_HASH: ${{ steps.hashes.outputs.win_arm64 }} HEADLESS_WIN_X64_HASH: ${{ steps.hashes.outputs.headless_win_x64 }} HEADLESS_WIN_ARM64_HASH: ${{ steps.hashes.outputs.headless_win_arm64 }} + REMOTE_WIN_X64_HASH: ${{ steps.hashes.outputs.remote_win_x64 }} + REMOTE_WIN_ARM64_HASH: ${{ steps.hashes.outputs.remote_win_arm64 }} run: | git clone "https://x-access-token:${GH_TOKEN}@github.com/highbyte/scoop-dotnet-6502.git" cd scoop-dotnet-6502 @@ -236,8 +263,9 @@ jobs: json.dump(manifest, f, indent=4) f.write("\n") - update_manifest("bucket/dotnet-6502.json", "Avalonia", os.environ["WIN_X64_HASH"], os.environ["WIN_ARM64_HASH"]) - update_manifest("bucket/dotnet-6502-headless.json", "Headless", os.environ["HEADLESS_WIN_X64_HASH"], os.environ["HEADLESS_WIN_ARM64_HASH"]) + update_manifest("bucket/dotnet-6502.json", "Avalonia", os.environ["WIN_X64_HASH"], os.environ["WIN_ARM64_HASH"]) + update_manifest("bucket/dotnet-6502-headless.json", "Headless", os.environ["HEADLESS_WIN_X64_HASH"], os.environ["HEADLESS_WIN_ARM64_HASH"]) + update_manifest("bucket/dotnet-6502-remote.json", "RemoteClient", os.environ["REMOTE_WIN_X64_HASH"], os.environ["REMOTE_WIN_ARM64_HASH"]) PYEOF git config user.name "github-actions[bot]" diff --git a/.gitignore b/.gitignore index 7d4c5a7d..8117dff4 100644 --- a/.gitignore +++ b/.gitignore @@ -288,3 +288,6 @@ src/apps/Highbyte.DotNet6502.App.SilkNetNative/appsettings.Development.json src/apps/Avalonia/Highbyte.DotNet6502.App.Avalonia.Browser/wwwroot/scripts/ src/apps/Avalonia/Highbyte.DotNet6502.App.Avalonia.Desktop/appsettings.Development.json + +# memsearch AI memory (kept in separate private repo) +.memsearch/ diff --git a/README.md b/README.md index 4002f909..0d220c7b 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,20 @@ The Avalonia desktop and browser apps support [Lua scripting](doc/SCRIPTING.md) | ----------------------------------------------- | ------------------------------------------------- | | | | +## Remote control + +The Avalonia desktop app and the headless app expose a **TCP remote control endpoint** that lets external processes inspect and drive a running emulator instance in real time. A persistent, newline-delimited JSON protocol is used — one client at a time. Useful for automation, AI agent integration, and tooling that needs ad-hoc access without embedding a Lua script inside the emulator process. + +The **Debug & Remoting tab** shows the server status. While a client is connected a blue banner appears at the bottom of the window. + +See [TCP Remote Control documentation](doc/REMOTE_CONTROL.md) for the full protocol reference and command list. + +A ready-made CLI client, `dotnet-6502-remote`, is distributed separately — see [Install Remote Client](doc/INSTALL_REMOTE_CLIENT.md) for download and installation instructions. + +| [Remote control in desktop app](doc/REMOTE_CONTROL.md)| +| ----------------------------------------------- | +| | + ## Other features | [Run 6502 machine code in your own .NET apps](doc/CPU_LIBRARY.md) | [Machine code monitor](doc/MONITOR.md) | [C64 Basic AI code completion](doc/SYSTEMS_C64_AI_CODE_COMPLETION.md) | diff --git a/doc/APPS_AVALONIA.md b/doc/APPS_AVALONIA.md index f9060a16..67abd213 100644 --- a/doc/APPS_AVALONIA.md +++ b/doc/APPS_AVALONIA.md @@ -61,15 +61,7 @@ The desktop app can be launched from the command line with arguments to control When a Lua script is supplied the script owns all emulator setup and lifecycle — system selection, start, load, and quit. -| Argument | Description | -|---|---| -| `--script ` | Load and run a Lua script (can be specified multiple times) | -| `--scriptDir ` | Override the script directory from `appsettings.json` | -| `--console-log` / `-c` | Enable console logging | -| `--log-level ` / `-l ` | Set console log level (Trace/Debug/Information/Warning/Error) | -| `--enableExternalDebug` | Enable VS Code debug adapter (DAP) over TCP | -| `--debug-port ` | TCP port for the debug adapter (default: 6502) | -| `--debug-wait` | Wait for a debug client to connect before starting | +See [Parameter reference](#parameter-reference) for [`--script` and `--scriptDir`](#scripting-parameters), [logging](#logging), [debug adapter](#debug-adapter), and [remote control](#remote-control) options. > [!IMPORTANT] > `--script` and `--scriptDir` are **mutually exclusive** with `--system`, `--systemVariant`, `--start`, `--waitForSystemReady`, `--loadPrg`, and `--runLoadedProgram`. The script is responsible for all emulator setup and lifecycle. Combining them is an error. @@ -78,6 +70,19 @@ When a Lua script is supplied the script owns all emulator setup and lifecycle Used when driving the emulator from the command line without a script — primarily by the VS Code debugger extension. +See [Parameter reference](#parameter-reference) for [`--system`, `--start`, and related options](#automated-startup-parameters), [logging](#logging), [debug adapter](#debug-adapter), and [remote control](#remote-control) options. + +### Parameter reference + +#### Scripting parameters + +| Argument | Description | +|---|---| +| `--script ` | Load and run a Lua script (can be specified multiple times) | +| `--scriptDir ` | Override the script directory from `appsettings.json` | + +#### Automated startup parameters + | Argument | Description | |---|---| | `--system ` | Select a system (e.g. `C64`, `Generic`) | @@ -86,12 +91,32 @@ Used when driving the emulator from the command line without a script — primar | `--waitForSystemReady` | Wait until the system reports ready before continuing. Requires `--start`. | | `--loadPrg ` | Load a `.prg` file into memory. Requires `--start`. | | `--runLoadedProgram` | Run the loaded `.prg` file after loading. Requires `--start` and `--loadPrg`. | + +#### Logging + +| Argument | Description | +|---|---| | `--console-log` / `-c` | Enable console logging | | `--log-level ` / `-l ` | Set console log level (Trace/Debug/Information/Warning/Error) | + +#### Debug adapter + +| Argument | Description | +|---|---| | `--enableExternalDebug` | Enable VS Code debug adapter (DAP) over TCP | | `--debug-port ` | TCP port for the debug adapter (default: 6502) | +| `--debug-bind-address ` | IP address to bind the debug adapter server to (default: `127.0.0.1`) | | `--debug-wait` | Wait for a debug client to connect before starting | +#### Remote control + +| Argument | Description | +|---|---| +| `--remote-port ` | Start the TCP remote control server on this port | +| `--remote-bind-address ` | IP address to bind the remote control server to (default: `127.0.0.1`) | + +See [REMOTE_CONTROL.md](REMOTE_CONTROL.md) for the remote control protocol and usage. + ### Examples ``` @@ -103,6 +128,15 @@ Used when driving the emulator from the command line without a script — primar # Start with debug adapter for VS Code, waiting for client ./Highbyte.DotNet6502.App.Avalonia.Desktop --system C64 --start --enableExternalDebug --debug-port 6502 --debug-wait + +# Start with debug adapter bound to all interfaces (use only on trusted networks) +./Highbyte.DotNet6502.App.Avalonia.Desktop --system C64 --start --enableExternalDebug --debug-port 6502 --debug-bind-address 0.0.0.0 + +# Start with remote control server on port 6510 (loopback only) +./Highbyte.DotNet6502.App.Avalonia.Desktop --system C64 --start --remote-port 6510 + +# Start with remote control server accessible from the network (trusted networks only) +./Highbyte.DotNet6502.App.Avalonia.Desktop --system C64 --start --remote-port 6510 --remote-bind-address 0.0.0.0 ``` ## UI diff --git a/doc/APPS_AVALONIA_AUTOMATION.md b/doc/APPS_AVALONIA_AUTOMATION.md index 59cde1b5..d5eb968c 100644 --- a/doc/APPS_AVALONIA_AUTOMATION.md +++ b/doc/APPS_AVALONIA_AUTOMATION.md @@ -56,7 +56,7 @@ A non-exhaustive list of the most useful AutomationIds, grouped by view. All of - **Emulator control**: `StartButton`, `PauseButton`, `ResetButton`, `StopButton`, `MonitorButton`, `StatsButton` - **Display/audio**: `ScaleSlider`, `AudioCheckBox`, `AudioVolumeSlider`, `OptionsButton` - **Status**: `EmulatorStateText` -- **Bottom tab control**: `InformationTabControl` with tabs `InformationTab`, `ConfigStatusTab`, `LogTab`, `ScriptsTab`, `GeneralInfoTab`, `DebugTab` +- **Bottom tab control**: `InformationTabControl` with tabs `InformationTab`, `ConfigStatusTab`, `LogTab`, `ScriptsTab`, `GeneralInfoTab`, `DebugAndRemotingTab` - **Log tab**: `ClearLogButton` - **Scripts tab**: `ScriptsBannerRefreshButton`, `ScriptFolderLink`, `AddScriptButton`, `LoadExamplesButton`, `ScriptsRefreshButton`; sort headers `SortByFileNameButton`, `SortByStatusButton`, `SortByYieldButton`, `SortByHooksButton` - **Script rows (dynamic)**: `ScriptRow.ToggleEnabled.`, `ScriptRow.Reload.`, `ScriptRow.Edit.`, `ScriptRow.Delete.` @@ -84,7 +84,7 @@ A non-exhaustive list of the most useful AutomationIds, grouped by view. All of ## EmulatorConfigUserControl (general options) -`DefaultEmulatorComboBox`, `DefaultScaleSlider`, `ShowErrorDialogCheckBox`, `ShowDebugTabCheckBox`, `AudioProfileComboBox`, `StopOnBrkCheckBox`, `StopOnUnknownInstructionCheckBox`, `LuaScriptDirectoryTextBox`, `LuaStorePrefixTextBox`, `CancelButton`, `OkButton`. +`DefaultEmulatorComboBox`, `DefaultScaleSlider`, `ShowErrorDialogCheckBox`, `ShowDebugToolsCheckBox`, `AudioProfileComboBox`, `StopOnBrkCheckBox`, `StopOnUnknownInstructionCheckBox`, `LuaScriptDirectoryTextBox`, `LuaStorePrefixTextBox`, `CancelButton`, `OkButton`. ## MonitorDialog / MonitorUserControl @@ -192,7 +192,7 @@ peekaboo menu click --app "DotNet 6502 Emulator" --path "DotNet 6502 Emulator > 2. **Collapsed/conditional content** only appears in the AX tree when its container is rendered. Examples: - `C64MenuView` section contents (`DiskSectionContent`, `LoadSaveSectionContent`, `ConfigSectionContent`) — only visible when the section header is expanded. - - `Debug` tab contents — hidden until `ShowDebugTab` is enabled in `EmulatorConfig`. + - Some tools in `Debug & Remoting` tab contents — hidden until `ShowDebugTools` is enabled in `EmulatorConfig`. - Dialog controls (`C64ConfigDialog`, `MonitorDialog`, `ScriptEditorDialog`) — only exist while the dialog is open. To automate these, expand/open the container first, then re-query the AX tree. diff --git a/doc/APPS_HEADLESS.md b/doc/APPS_HEADLESS.md index 35b8767f..9a8b8893 100644 --- a/doc/APPS_HEADLESS.md +++ b/doc/APPS_HEADLESS.md @@ -56,14 +56,7 @@ By default `appsettings.json` expects them in `%HOME%/Downloads/C64` (i.e. `~/Do When a Lua script is supplied the script owns the full emulator lifecycle — it calls `emu.start()`, loads programs, and quits when done. -| Argument | Description | -|---|---| -| `--script ` | Load and run a Lua script (can be specified multiple times) | -| `--scriptDir ` | Override the script directory from `appsettings.json` | -| `--log-level ` / `-l ` | Set console log level (Trace/Debug/Information/Warning/Error) | -| `--enableExternalDebug` | Enable VS Code debug adapter (DAP) over TCP | -| `--debug-port ` | TCP port for the debug adapter (default: 6502) | -| `--debug-wait` | Wait for a debug client to connect before starting | +See [Parameter reference](#parameter-reference) for [`--script` and `--scriptDir`](#scripting-parameters), [logging](#logging), [debug adapter](#debug-adapter), and [remote control](#remote-control) options. > [!IMPORTANT] > `--script` and `--scriptDir` are **mutually exclusive** with `--system`, `--systemVariant`, `--start`, `--waitForSystemReady`, `--loadPrg`, and `--runLoadedProgram`. The script is responsible for all emulator setup and lifecycle. Combining them is an error. @@ -72,6 +65,19 @@ When a Lua script is supplied the script owns the full emulator lifecycle — it Used when driving the emulator from the command line without a script — primarily by the VS Code debugger extension. +See [Parameter reference](#parameter-reference) for [`--system`, `--start`, and related options](#automated-startup-parameters), [logging](#logging), [debug adapter](#debug-adapter), and [remote control](#remote-control) options. + +### Parameter reference + +#### Scripting parameters + +| Argument | Description | +|---|---| +| `--script ` | Load and run a Lua script (can be specified multiple times) | +| `--scriptDir ` | Override the script directory from `appsettings.json` | + +#### Automated startup parameters + | Argument | Description | |---|---| | `--system ` | Select a system (e.g. `C64`, `Generic`) | @@ -80,11 +86,32 @@ Used when driving the emulator from the command line without a script — primar | `--waitForSystemReady` | Wait until the system reports ready before continuing. Requires `--start`. | | `--loadPrg ` | Load a `.prg` file into memory. Requires `--start`. | | `--runLoadedProgram` | Run the loaded `.prg` file after loading. Requires `--start` and `--loadPrg`. | + +#### Logging + +| Argument | Description | +|---|---| | `--log-level ` / `-l ` | Set console log level (Trace/Debug/Information/Warning/Error) | + +#### Debug adapter + +| Argument | Description | +|---|---| | `--enableExternalDebug` | Enable VS Code debug adapter (DAP) over TCP | | `--debug-port ` | TCP port for the debug adapter (default: 6502) | +| `--debug-bind-address ` | IP address to bind the debug adapter server to (default: `127.0.0.1`) | | `--debug-wait` | Wait for a debug client to connect before starting | +#### Remote control + +| Argument | Description | +|---|---| +| `--remote-port ` | Start the TCP remote control server on this port | +| `--remote-bind-address ` | IP address to bind the remote control server to (default: `127.0.0.1`) | +| `--allow-remote-quit` | Allow the `emu.quit` remote control command to terminate the app | + +See [REMOTE_CONTROL.md](REMOTE_CONTROL.md) for the remote control protocol and usage. + ## Lua scripting The same Lua scripting API available in the Avalonia apps is fully supported here. Scripts are the primary way to control the emulator. Example scripts are included in the `scripts/` directory: @@ -109,7 +136,7 @@ The same Lua scripting API available in the Avalonia apps is fully supported her See [Scripting](SCRIPTING.md) for the full Lua API reference. ## External debug adapter -The headless app supports the [Debug Adapter Protocol (DAP)](https://microsoft.github.io/debug-adapter-protocol/) over TCP, allowing VS Code to attach a debugger while the emulator runs headless. +The headless app supports the [Debug Adapter Protocol (DAP)](https://microsoft.github.io/debug-adapter-protocol/) over TCP, allowing VS Code to attach a debugger while the emulator runs headless. By default the server binds to `127.0.0.1`; pass `--debug-bind-address ` to change the interface. `0.0.0.0` accepts connections on any network interface, so only use it on trusted networks because the debug adapter is unauthenticated. See the [VS Code debugger extension](../tools/vscode-extension/README.md) for details. @@ -124,11 +151,31 @@ Start a C64 and run a Lua script (script owns all setup and lifecycle): dotnet-6502-headless --script scripts/example_c64_basic_readwrite.lua ``` -Start with debug adapter listening on port 6502, waiting for client (no script): +Start with debug adapter listening on `127.0.0.1:6502`, waiting for client (no script): ``` dotnet-6502-headless --system C64 --start --enableExternalDebug --debug-port 6502 --debug-wait ``` +Start with debug adapter listening on all interfaces at port 6502 (trusted networks only): +``` +dotnet-6502-headless --system C64 --start --enableExternalDebug --debug-port 6502 --debug-bind-address 0.0.0.0 --debug-wait +``` + +Start with remote control server on port 6510 (loopback only): +``` +dotnet-6502-headless --system C64 --start --remote-port 6510 +``` + +Start with remote control server accessible from the network (trusted networks only): +``` +dotnet-6502-headless --system C64 --start --remote-port 6510 --remote-bind-address 0.0.0.0 +``` + +Start with remote control and allow `emu.quit` command: +``` +dotnet-6502-headless --system C64 --start --remote-port 6510 --allow-remote-quit +``` + Example console output: ``` 09:12:01 info: Program[0] Starting headless emulator. diff --git a/doc/DEBUG_ADAPTER_TCP.md b/doc/DEBUG_ADAPTER_TCP.md index 53fcd662..13329ee7 100644 --- a/doc/DEBUG_ADAPTER_TCP.md +++ b/doc/DEBUG_ADAPTER_TCP.md @@ -28,7 +28,9 @@ The debug adapter now supports TCP transport in addition to STDIN/STDOUT, enabli - **Highbyte.DotNet6502.App.Avalonia.Desktop** - Integrated `TcpDebugAdapterServer` for TCP-based debugging + - Accepts `--enableExternalDebug` to enable the TCP debug server - Accepts `--debug-port ` command-line argument + - Accepts `--debug-bind-address ` command-line argument (defaults to `127.0.0.1`) - Accepts `--debug-wait` flag to wait for debugger connection before starting - Runs debug adapter server on background thread @@ -39,19 +41,25 @@ The debug adapter now supports TCP transport in addition to STDIN/STDOUT, enabli Start the Avalonia Desktop app with debug adapter enabled: ```bash -./Highbyte.DotNet6502.App.Avalonia.Desktop --debug-port 6502 +./Highbyte.DotNet6502.App.Avalonia.Desktop --enableExternalDebug --debug-port 6502 ``` To wait for the debugger to connect before starting: ```bash -./Highbyte.DotNet6502.App.Avalonia.Desktop --debug-port 6502 --debug-wait +./Highbyte.DotNet6502.App.Avalonia.Desktop --enableExternalDebug --debug-port 6502 --debug-wait +``` + +To bind the server to a different interface, add `--debug-bind-address `. For example, use `0.0.0.0` to accept connections on any local interface: + +```bash +./Highbyte.DotNet6502.App.Avalonia.Desktop --enableExternalDebug --debug-port 6502 --debug-bind-address 0.0.0.0 --debug-wait ``` Combined with console logging: ```bash -./Highbyte.DotNet6502.App.Avalonia.Desktop --debug-port 6502 --console-log -l Debug +./Highbyte.DotNet6502.App.Avalonia.Desktop --enableExternalDebug --debug-port 6502 --console-log -l Debug ``` ### VSCode Configuration @@ -60,17 +68,17 @@ To debug the Avalonia Desktop app from VSCode, you'll need to add a launch confi ```json { - "type": "dotnet6502-debug", + "type": "dotnet6502", "request": "attach", "name": "Attach to Avalonia Desktop", - "debugServer": 6502, + "debugHost": "127.0.0.1", + "debugPort": 6502, "program": "${workspaceFolder}/samples/Assembler/GenericComputer/snake6502/build/snake6502.prg", - "stopOnEntry": true, - "trace": true + "stopOnEntry": true } ``` -**Note:** The VSCode extension may need updates to support the `debugServer` configuration property for TCP connections. +**Note:** The emulator can bind the debug server to a specific IP via `--debug-bind-address`, and the VS Code extension can connect to a matching host via `debugHost`. Both still default to `127.0.0.1`, which remains the right default for local debugging. ## Implementation Details @@ -86,7 +94,7 @@ The `TcpTransport` class implements `IDebugAdapterTransport` using a `TcpClient` The `TcpDebugAdapterServer` class: -- Listens on `IPAddress.Loopback` (localhost only) +- Listens on a configurable bind address (default: `127.0.0.1`, loopback only) - Accepts a single client connection at a time - Fires `ClientConnected` event with a `TcpTransport` instance - Supports port 0 for random port assignment @@ -95,8 +103,8 @@ The `TcpDebugAdapterServer` class: The Avalonia Desktop app: -1. Parses `--debug-port` and `--debug-wait` command-line arguments -2. Creates a `TcpDebugAdapterServer` when debug port is specified +1. Parses `--enableExternalDebug`, `--debug-port`, `--debug-bind-address`, and `--debug-wait` command-line arguments +2. Creates a `TcpDebugAdapterServer` when external debug is enabled 3. Handles `ClientConnected` event by: - Creating `DapProtocol` and `DebugAdapterLogic` instances - Starting a message loop on a background thread @@ -120,9 +128,9 @@ This log file contains: ## Future Enhancements 1. **VSCode Extension Updates** - - Add support for `debugServer` configuration property - - Allow attaching to running desktop applications - Provide UI for discovering running instances + - Offer a quick-pick or command to populate `debugHost`/`debugPort` from known emulator sessions + - Validate common misconfigurations (for example `debugHost: 0.0.0.0`) before trying to connect 2. **Multiple Client Support** - Allow multiple simultaneous debug connections @@ -142,13 +150,14 @@ This log file contains: 1. Start the Avalonia Desktop app with debug port: ```bash - ./Highbyte.DotNet6502.App.Avalonia.Desktop --debug-port 6502 --debug-wait --console-log + ./Highbyte.DotNet6502.App.Avalonia.Desktop --enableExternalDebug --debug-port 6502 --debug-wait --console-log ``` 2. Connect with a TCP client (e.g., `nc`): ```bash - nc localhost 6502 + nc 127.0.0.1 6502 ``` + If you started the emulator with `--debug-bind-address 0.0.0.0`, connecting to `127.0.0.1` from the same machine still works. 3. Send a DAP initialize request: ``` diff --git a/doc/DEBUG_ADAPTER_TCP_TESTING.md b/doc/DEBUG_ADAPTER_TCP_TESTING.md index 0f1f3655..f1de8acb 100644 --- a/doc/DEBUG_ADAPTER_TCP_TESTING.md +++ b/doc/DEBUG_ADAPTER_TCP_TESTING.md @@ -34,14 +34,14 @@ From the main workspace root, run the Avalonia Desktop app task with debug serve 4. Press F5 or click the green play button **Expected behavior:** -- Terminal shows: "Starting TCP debug adapter server on port 6502" +- Terminal shows: "Starting TCP debug adapter server on 127.0.0.1:6502" - Terminal shows: "Waiting for debug client to connect (--debug-wait specified)..." - Avalonia app window appears but waits for debugger **Alternative (command line):** ```bash cd src/apps/Avalonia/Highbyte.DotNet6502.App.Avalonia.Desktop/bin/Debug/net10.0 -./Highbyte.DotNet6502.App.Avalonia.Desktop --debug-port 6502 --debug-wait --console-log -l Debug +./Highbyte.DotNet6502.App.Avalonia.Desktop --enableExternalDebug --debug-port 6502 --debug-wait --console-log -l Debug ``` ### Step 2: Attach VSCode Debugger @@ -125,7 +125,7 @@ This ensures the existing STDIN/STDOUT debug adapter still works. ## Test Scenario 3: Launch Mode with TCP (Optional) -This tests using `debugServer` property in a launch configuration. +This tests using the `debugPort` property in a launch configuration. ### Step 1: Modify Launch Configuration @@ -136,7 +136,7 @@ Edit `tools/vscode-extension-test/.vscode/launch.json` and add: "type": "dotnet6502", "request": "launch", "name": "Debug with TCP (launch mode)", - "debugServer": 6502, + "debugPort": 6502, "program": "${workspaceFolder}/test-program.prg", "dbgFile": "${workspaceFolder}/test-program.dbg", "stopOnEntry": true @@ -148,7 +148,7 @@ Edit `tools/vscode-extension-test/.vscode/launch.json` and add: In a terminal: ```bash cd src/apps/Avalonia/Highbyte.DotNet6502.App.Avalonia.Desktop/bin/Debug/net10.0 -./Highbyte.DotNet6502.App.Avalonia.Desktop --debug-port 6502 --debug-wait +./Highbyte.DotNet6502.App.Avalonia.Desktop --enableExternalDebug --debug-port 6502 --debug-wait ``` ### Step 3: Launch Debug Session @@ -163,7 +163,9 @@ cd src/apps/Avalonia/Highbyte.DotNet6502.App.Avalonia.Desktop/bin/Debug/net10.0 ## Verification Checklist ### Connection -- [ ] VSCode successfully connects to TCP port 6502 +- [ ] VSCode successfully connects to `127.0.0.1:6502` +- [ ] Optional: emulator accepts a custom bind address via `--debug-bind-address ` when started manually +- [ ] Optional: VS Code can attach using `debugHost` when the emulator listens on a matching non-loopback address - [ ] Avalonia app detects client connection - [ ] Debug adapter log file is created - [ ] No connection errors in Debug Console @@ -204,8 +206,8 @@ cd src/apps/Avalonia/Highbyte.DotNet6502.App.Avalonia.Desktop/bin/Debug/net10.0 **Cause:** Avalonia app not running or not listening on port 6502 **Solution:** -1. Check Avalonia app is running with `--debug-port 6502` -2. Check console output for "Started listening on port 6502" +1. Check Avalonia app is running with `--enableExternalDebug --debug-port 6502` +2. Check console output for "Started listening on 127.0.0.1:6502" (or your configured bind address) 3. Verify no other process is using port 6502: `lsof -i :6502` ### "Debug adapter executable not found" @@ -293,7 +295,7 @@ The TCP debug adapter integration is working correctly if: ## Known Limitations 1. **Single Connection:** TCP server accepts only one client at a time -2. **Localhost Only:** Server binds to 127.0.0.1 (security) +2. **Bind/Connect Must Match:** If the emulator binds to a non-loopback interface, set `debugHost` to a routable address for that emulator. `0.0.0.0` is valid for the emulator bind side, but not as a client connect target in VS Code. 3. **No Auto-Discovery:** Must manually specify port number 4. **No Reconnect:** If connection drops, must restart debug session 5. **Port Conflicts:** If port 6502 is in use, must choose different port diff --git a/doc/INSTALL_REMOTE_CLIENT.md b/doc/INSTALL_REMOTE_CLIENT.md new file mode 100644 index 00000000..1038f7ee --- /dev/null +++ b/doc/INSTALL_REMOTE_CLIENT.md @@ -0,0 +1,164 @@ +# Remote client — installation + +Pre-built binaries of the `dotnet-6502-remote` CLI are available for Windows, Linux, and macOS. + +The remote client connects to a running [Avalonia Desktop](APPS_AVALONIA.md) or [Headless](APPS_HEADLESS.md) emulator via its TCP remote control endpoint. See [REMOTE_CONTROL.md](REMOTE_CONTROL.md) for the protocol, available commands, and usage examples. + +## Install via Package Manager + +**Prerequisites:** Install [Homebrew](https://brew.sh/) (macOS/Linux) or [Scoop](https://scoop.sh/) (Windows) if you don't have them already. + +### macOS (Homebrew) + +```bash +brew tap highbyte/dotnet-6502 +brew install --formula dotnet-6502-remote +``` + +### Linux (Homebrew) + +```bash +brew tap highbyte/dotnet-6502 +brew install --formula dotnet-6502-remote +``` + +### Windows (Scoop) + +```powershell +scoop bucket add dotnet-6502 https://github.com/highbyte/scoop-dotnet-6502 +scoop install dotnet-6502-remote +``` + +### Launching + +After installing, start an emulator with remote control enabled (see [REMOTE_CONTROL.md](REMOTE_CONTROL.md#starting-the-emulator)), then from any terminal: + +``` +dotnet-6502-remote emu.state +dotnet-6502-remote --port 6510 cpu.get +``` + +Run `dotnet-6502-remote --help` for the full command list. + +### Updating + +```bash +# macOS / Linux +brew update && brew upgrade dotnet-6502-remote +``` + +```powershell +# Windows +scoop update +scoop update dotnet-6502-remote +``` + +### Uninstalling + +```bash +# macOS / Linux +brew uninstall dotnet-6502-remote +brew untap highbyte/dotnet-6502 +``` + +```powershell +# Windows +scoop uninstall dotnet-6502-remote +scoop bucket rm dotnet-6502 +``` + +--- + +## Install via manual download + +Download the latest release for your platform from the [Releases](https://github.com/highbyte/dotnet-6502/releases) page under Assets. + +| Platform | Download | +|----------|----------| +| Windows x64 | `DotNet6502-RemoteClient-win-x64.zip` | +| Windows ARM64 | `DotNet6502-RemoteClient-win-arm64.zip` | +| Linux x64 | `DotNet6502-RemoteClient-linux-x64.zip` | +| Linux ARM64 | `DotNet6502-RemoteClient-linux-arm64.zip` | +| macOS ARM64 (Apple Silicon) | `DotNet6502-RemoteClient-osx-arm64.zip` | + +### Windows + +1. Extract the `.zip` file to a folder +2. Open a terminal in that folder and run: + ``` + Highbyte.DotNet6502.App.RemoteClient.exe --help + ``` + +#### SmartScreen Warning + +Since the application is not code-signed, Windows SmartScreen may show a warning the first time you run it: + +> "Windows protected your PC - Microsoft Defender SmartScreen prevented an unrecognized app from starting." + +**To proceed:** +1. Click **"More info"** +2. Click **"Run anyway"** + +--- + +### Linux + +1. Extract the `.zip` file: + ```sh + unzip DotNet6502-RemoteClient-linux-x64.zip -d dotnet6502-remote + cd dotnet6502-remote + ``` + +2. Run the app: + ```sh + ./Highbyte.DotNet6502.App.RemoteClient --help + ``` + +--- + +### macOS + +> **Note:** The macOS build is not notarized with Apple. + +1. Extract the `.zip` file + +2. Open Terminal and navigate to the extracted folder: + ```sh + cd /path/to/extracted/folder + ``` + +3. Remove the quarantine attribute: + ```sh + xattr -cr . + ``` + +4. Run the app: + ```sh + ./Highbyte.DotNet6502.App.RemoteClient --help + ``` + +--- + +### Verifying Download Integrity (Optional) + +Each release includes SHA256 checksum files (`checksums-*.sha256`) to verify your download. + +#### Windows (PowerShell) + +```powershell +(Get-FileHash -Algorithm SHA256 DotNet6502-RemoteClient-win-x64.zip).Hash.ToLower() +``` + +#### Linux + +```sh +sha256sum DotNet6502-RemoteClient-linux-x64.zip +``` + +#### macOS + +```sh +shasum -a 256 DotNet6502-RemoteClient-osx-arm64.zip +``` + +Compare the output with the corresponding entry in the `checksums-*.sha256` file. diff --git a/doc/REMOTE_CONTROL.md b/doc/REMOTE_CONTROL.md new file mode 100644 index 00000000..423b17a4 --- /dev/null +++ b/doc/REMOTE_CONTROL.md @@ -0,0 +1,1248 @@ +# TCP Remote Control + +## Overview + +The emulator supports a persistent TCP remote control endpoint that lets external processes inspect and drive a running emulator instance in real time. It is designed for automation, AI agent integration, and tooling that needs ad-hoc access without embedding a Lua script inside the emulator process. + +Key design points: +- **Persistent TCP connection** — one client at a time; the server accepts a new client after the previous one disconnects. +- **Newline-delimited JSON** — every request is a single JSON object terminated by `\n`; every response is a single JSON object terminated by `\n`. +- **Platform-agnostic** — the same protocol works against both the Avalonia Desktop app and the headless console app. +- **Non-exclusive** — user input from keyboard/joystick and remote input coexist; neither locks the other out. +- **Frame-synchronized input** — joystick, keyboard, and memory-write commands are queued and executed at the next frame boundary so they do not race with the CPU. + +--- + +## Starting the Emulator with Remote Control + +### Avalonia Desktop + +**Option 1 — command line (server starts automatically on launch):** + +```sh +# Start on a fixed port (loopback only — 127.0.0.1 — by default) +dotnet run --project src/apps/Avalonia/Highbyte.DotNet6502.App.Avalonia.Desktop -- --remote-port 6510 + +# Or via the published binary +./Highbyte.DotNet6502.App.Avalonia.Desktop --remote-port 6510 + +# Bind to a specific interface to accept remote connections +./Highbyte.DotNet6502.App.Avalonia.Desktop --remote-port 6510 --remote-bind-address 0.0.0.0 +``` + +**Option 2 — from the UI (start/stop at any time without restarting the app):** + +1. Open the **Debug & Remoting tab** (via the `View` menu → `Debug & Remoting`). +2. In the *Remote Control Server* section, enter the desired **bind address** (default `127.0.0.1`) and **port** number. +3. Click **Start** to begin listening. + +When the server is listening the **Debug & Remoting tab** shows a *Remote Control Server* section with the status `Listening on 127.0.0.1:6510`. When a client connects a blue banner appears at the bottom of the window: `• Remote Control Connected (port 6510)`. + +### Headless + +```sh +# Loopback only (default) +dotnet run --project src/apps/Highbyte.DotNet6502.App.Headless -- \ + --remote-port 6510 \ + --system C64 --start + +# Bind to all network interfaces so the server is reachable from another device +dotnet run --project src/apps/Highbyte.DotNet6502.App.Headless -- \ + --remote-port 6510 --remote-bind-address 0.0.0.0 \ + --system C64 --start + +# Allow the emu.quit command (disabled by default on headless too unless opted in) +dotnet run --project src/apps/Highbyte.DotNet6502.App.Headless -- \ + --remote-port 6510 --allow-remote-quit \ + --system C64 --start +``` + +### Bind address (`--remote-bind-address`) + +By default the server binds to `127.0.0.1`, so it is only reachable from the same machine. Pass `--remote-bind-address ` (or set the **Bind** field in the *Remote Control Server* section of the *Debug & Remoting* tab) to change this. + +| Value | Meaning | +|-----------------|--------------------------------------------------------------------------| +| `127.0.0.1` | Loopback only — same machine (default) | +| `0.0.0.0` | Any IPv4 interface — reachable over the network | +| `192.168.x.y` | A specific LAN IP on the host | +| `::1` | IPv6 loopback | +| `::` | Any IPv6 interface | + +> ⚠️ **The protocol is unauthenticated.** Any client that can reach the bind address can fully drive the emulator (inject input, read/write memory, etc.). Only bind to non-loopback addresses on networks you trust. + +--- + +## Protocol + +### Request format + +```json +{"id": 1, "cmd": "emu.state"} +{"id": 2, "cmd": "mem.read", "addr": "C000", "len": 16} +``` + +`id` is optional. If supplied it is echoed back in the response, which lets clients correlate responses when pipelining multiple requests. + +### Response format + +Success: +```json +{"id": 1, "ok": true, "state": "Running", "system": "C64"} +``` + +Failure: +```json +{"id": 99, "ok": false, "error": "Emulator not running"} +``` + +Null-valued fields are omitted from the response. + +--- + +## Command Reference + +### `emu.state` + +Returns the current emulator state and selected system name. + +**Request** +```json +{"id": 1, "cmd": "emu.state"} +``` + +**Response fields** + +| Field | Type | Description | +|-----------|--------|----------------------------------------------------------| +| `state` | string | `Uninitialized`, `Running`, or `Paused` | +| `system` | string | Currently selected system, e.g. `C64` or `Generic` | +| `variant` | string | Currently selected configuration variant, e.g. `C64NTSC`| + +**Response example** +```json +{"id": 1, "ok": true, "state": "Running", "system": "C64", "variant": "C64NTSC"} +``` + +--- + +### `emu.start` + +Starts the emulator. Equivalent to clicking the **Start** button in the UI. + +When the emulator is `Paused`, this command **resumes** it rather than reinitializing — the existing system runner is reused. Use `emu.reset` if you want a fresh start from the `Paused` state. + +> There is no separate `emu.resume` command; `emu.start` serves both roles. + +```json +{"id": 2, "cmd": "emu.start"} +``` +```json +{"id": 2, "ok": true} +``` + +--- + +### `emu.stop` + +Stops the emulator and resets it to the `Uninitialized` state. + +```json +{"id": 3, "cmd": "emu.stop"} +``` +```json +{"id": 3, "ok": true} +``` + +--- + +### `emu.pause` + +Pauses emulation without resetting state. + +```json +{"id": 4, "cmd": "emu.pause"} +``` +```json +{"id": 4, "ok": true} +``` + +--- + +### `emu.reset` + +Stops and immediately restarts the emulator. + +```json +{"id": 5, "cmd": "emu.reset"} +``` +```json +{"id": 5, "ok": true} +``` + +--- + +### `emu.quit` + +Terminates the host application. **Headless only** — requires `--allow-remote-quit`. Returns an error in the Avalonia Desktop app. + +```json +{"id": 6, "cmd": "emu.quit"} +``` +```json +{"id": 6, "ok": true} +``` + +--- + +### `emu.systems` + +Returns the names of all available systems the emulator supports. + +```json +{"id": 6, "cmd": "emu.systems"} +``` + +**Response fields** + +| Field | Type | Description | +|--------|-----------------|------------------------| +| `data` | array of string | Available system names | + +```json +{"id": 6, "ok": true, "data": ["C64", "Generic"]} +``` + +--- + +### `emu.selectsystem` + +Selects the active system. **The emulator must be stopped** (`Uninitialized` state) before calling this command; use `emu.stop` first if needed. + +**Parameters** + +| Parameter | Type | Description | +|-----------|--------|---------------------------| +| `name` | string | System name, e.g. `C64` | + +```json +{"id": 7, "cmd": "emu.selectsystem", "name": "C64"} +``` +```json +{"id": 7, "ok": true} +``` + +After selecting a system, call `emu.variants` to see which configuration variants are available, then `emu.selectvariant` to pick one before starting with `emu.start`. + +--- + +### `emu.variants` + +Returns the available configuration variants for the currently selected system (e.g. `C64NTSC`, `C64PAL`). + +```json +{"id": 8, "cmd": "emu.variants"} +``` + +**Response fields** + +| Field | Type | Description | +|--------|-----------------|----------------------------------| +| `data` | array of string | Configuration variant names | + +```json +{"id": 8, "ok": true, "data": ["C64NTSC", "C64PAL"]} +``` + +--- + +### `emu.selectvariant` + +Selects a configuration variant for the current system. **The emulator must be stopped** (`Uninitialized` state). + +**Parameters** + +| Parameter | Type | Description | +|-----------|--------|--------------------------------| +| `name` | string | Variant name, e.g. `C64NTSC` | + +```json +{"id": 9, "cmd": "emu.selectvariant", "name": "C64NTSC"} +``` +```json +{"id": 9, "ok": true} +``` + +--- + +### `cpu.get` + +Returns all CPU registers. The emulator must be running or paused. + +```json +{"id": 7, "cmd": "cpu.get"} +``` + +**Response fields** + +| Field | Type | Description | +|---------|--------|------------------------------------| +| `pc` | string | Program Counter as 4-digit hex | +| `a` | int | Accumulator | +| `x` | int | X register | +| `y` | int | Y register | +| `sp` | int | Stack Pointer | +| `flags` | string | 8-char processor status: each position is the flag letter (`N`,`V`,`U`,`B`,`D`,`I`,`Z`,`C`) or `-` when clear | + +```json +{"id": 7, "ok": true, "pc": "E5CD", "a": 0, "x": 0, "y": 0, "sp": 255, "flags": "----I--C"} +``` + +--- + +### `cpu.set` + +Sets one or more CPU registers. At least one parameter must be supplied. Executed at the next frame boundary, so the emulator must be `Running`. + +**Parameters** (all optional; omitted registers are left unchanged) + +| Parameter | Type | Description | +|-----------|--------|-----------------------------------------------------------------------------------| +| `pc` | string | Program Counter as a hex string, e.g. `C000` | +| `a` | int | Accumulator (0–255) | +| `x` | int | X register (0–255) | +| `y` | int | Y register (0–255) | +| `sp` | int | Stack Pointer (0–255) | +| `flags` | string | Processor status — 8-char string in `NVUBDIZC` format, same as `cpu.get` output | + +For `flags`, each character is the flag letter when set or `-` when clear. Example: `"----I---"` sets only the InterruptDisable flag. You can copy the `flags` value directly from a `cpu.get` response. + +```json +{"id": 1, "cmd": "cpu.set", "a": 42, "x": 0, "flags": "------Z-"} +``` +```json +{"id": 1, "ok": true} +``` + +Set only the program counter: + +```json +{"id": 2, "cmd": "cpu.set", "pc": "C000"} +``` +```json +{"id": 2, "ok": true} +``` + +--- + +### `mem.read` + +Reads bytes from the system's address space. The emulator must be running or paused. + +**Parameters** + +| Parameter | Type | Description | +|-----------|--------|---------------------------------------------| +| `addr` | string | Start address as a hex string (e.g. `C000`) | +| `len` | int | Number of bytes to read (1–4096) | + +```json +{"id": 8, "cmd": "mem.read", "addr": "C000", "len": 4} +``` +```json +{"id": 8, "ok": true, "data": [0, 169, 0, 133]} +``` + +--- + +### `mem.write` + +Writes bytes into the system's address space. Executed at the next frame boundary. + +**Parameters** + +| Parameter | Type | Description | +|-----------|--------------|------------------------------------------------| +| `addr` | string | Start address as a hex string | +| `data` | array of int | Byte values to write (0–255 each) | + +```json +{"id": 9, "cmd": "mem.write", "addr": "C000", "data": [169, 42, 133, 254]} +``` +```json +{"id": 9, "ok": true} +``` + +--- + +### `mem.loadbin` + +Loads raw binary data into the system's address space at a specified address. Unlike `mem.write` (which takes a JSON integer array), this command accepts a base64-encoded byte string — convenient for larger payloads and binary files. Bytes are written directly with no header interpretation. Executed at the next frame boundary. + +For C64 PRG files that include a 2-byte load-address header, use `c64.loadprg` instead. + +**Parameters** + +| Parameter | Type | Description | +|-----------|--------|------------------------------------------| +| `addr` | string | Start address as a hex string (e.g. `0801`) | +| `data` | string | Base64-encoded raw bytes | + +```json +{"id": 10, "cmd": "mem.loadbin", "addr": "C000", "data": "qrtM"} +``` +```json +{"id": 10, "ok": true} +``` + +When using `dotnet-6502-remote`, pass `--file ` and the client reads and encodes the file: + +```sh +dotnet-6502-remote mem.loadbin --addr 0801 --file /path/to/binary.bin +``` + +--- + +### `joystick.set` + +Sets joystick direction and fire button state for the next frame. Any combination of directions may be active simultaneously. Executed at the next frame boundary. + +**Parameters** + +| Parameter | Type | Description | +|-----------|------|--------------------------------| +| `port` | int | Joystick port: `1` or `2` | +| `up` | bool | Up direction | +| `down` | bool | Down direction | +| `left` | bool | Left direction | +| `right` | bool | Right direction | +| `fire` | bool | Fire button | + +Only the fields you include are changed; omitted fields are not touched. + +When using `dotnet-6502-remote`, you can explicitly clear an action with `--no-up`, `--no-down`, `--no-left`, `--no-right`, or `--no-fire`. The client also accepts `--up false` style boolean values. This is mainly useful when multiple `joystick.set` updates are queued before the same frame boundary; across frames, `joystick.set` is already non-persistent and must be resent every frame to remain active. + +```json +{"id": 10, "cmd": "joystick.set", "port": 1, "up": true, "fire": false} +``` +```json +{"id": 10, "ok": true} +``` + +--- + +### `joystick.press` + +Presses and holds joystick actions on the selected port. Held joystick actions stay active across frames until explicitly released with `joystick.release` or `joystick.releaseall`. Executed at the next frame boundary. + +**Parameters** + +| Parameter | Type | Description | +|-----------|------|--------------------------------| +| `port` | int | Joystick port: `1` or `2` | +| `up` | bool | Hold Up direction when `true` | +| `down` | bool | Hold Down direction when `true`| +| `left` | bool | Hold Left direction when `true`| +| `right` | bool | Hold Right direction when `true`| +| `fire` | bool | Hold Fire button when `true` | + +Only fields explicitly set to `true` are applied; omitted or `false` fields are ignored. + +```json +{"id": 10, "cmd": "joystick.press", "port": 1, "up": true, "fire": true} +``` +```json +{"id": 10, "ok": true} +``` + +--- + +### `joystick.release` + +Releases held joystick actions on the selected port. Executed at the next frame boundary. + +**Parameters** + +| Parameter | Type | Description | +|-----------|------|--------------------------------------| +| `port` | int | Joystick port: `1` or `2` | +| `up` | bool | Release Up direction when `true` | +| `down` | bool | Release Down direction when `true` | +| `left` | bool | Release Left direction when `true` | +| `right` | bool | Release Right direction when `true` | +| `fire` | bool | Release Fire button when `true` | + +Only fields explicitly set to `true` are applied; omitted or `false` fields are ignored. + +```json +{"id": 11, "cmd": "joystick.release", "port": 1, "up": true} +``` +```json +{"id": 11, "ok": true} +``` + +--- + +### `joystick.releaseall` + +Releases all held joystick actions on one port. Executed at the next frame boundary. + +**Parameters** + +| Parameter | Type | Description | +|-----------|------|---------------------------| +| `port` | int | Joystick port: `1` or `2` | + +```json +{"id": 12, "cmd": "joystick.releaseall", "port": 1} +``` +```json +{"id": 12, "ok": true} +``` + +--- + +### `keyboard.press` + +Holds down a named key. The key stays down until `keyboard.release` or `keyboard.releaseall` is sent. Executed at the next frame boundary. + +**Parameters** + +| Parameter | Type | Description | +|-----------|--------|----------------------------------------------------------| +| `key` | string | Key name (case-insensitive), e.g. `space`, `return`, `a` | + +```json +{"id": 11, "cmd": "keyboard.press", "key": "space"} +``` +```json +{"id": 11, "ok": true} +``` + +--- + +### `keyboard.release` + +Releases a previously pressed key. Executed at the next frame boundary. + +**Parameters** + +| Parameter | Type | Description | +|-----------|--------|--------------| +| `key` | string | Key name | + +```json +{"id": 12, "cmd": "keyboard.release", "key": "space"} +``` +```json +{"id": 12, "ok": true} +``` + +--- + +### `keyboard.releaseall` + +Releases all injected keys at once. Useful for cleanup after automation. Executed at the next frame boundary. + +```json +{"id": 13, "cmd": "keyboard.releaseall"} +``` +```json +{"id": 13, "ok": true} +``` + +--- + +### `keyboard.iskeydown` + +Returns whether a key is currently pressed (by the user or by a prior `keyboard.press`). + +**Parameters** + +| Parameter | Type | Description | +|-----------|--------|--------------| +| `key` | string | Key name | + +**Response fields** + +| Field | Type | Description | +|----------|------|----------------------------------| +| `isdown` | bool | `true` if the key is down | + +```json +{"id": 14, "cmd": "keyboard.iskeydown", "key": "space"} +``` +```json +{"id": 14, "ok": true, "isdown": false} +``` + +--- + +### `keyboard.getall` + +Returns the list of all valid key names for the currently running system. + +**Response fields** + +| Field | Type | Description | +|--------|-----------------|-------------------------------| +| `data` | array of string | Valid key names for this system | + +```json +{"id": 15, "cmd": "keyboard.getall"} +``` +```json +{"id": 15, "ok": true, "data": ["space","return","a","b",...]} +``` + +#### C64 key name reference + +Call `keyboard.getall` at runtime for the authoritative list. Common C64 key names: + +| Key name | Physical key | +|----------|-------------| +| `space` | Space bar | +| `return` | Return (Enter) | +| `a`–`z` | Letter keys A–Z | +| `0`–`9` | Digit keys | +| `+` `-` `*` `/` `:` `;` `=` `.` `,` `@` | Punctuation keys | +| `lira` | Pound sign (£) | +| `leftarrow` | ← left-arrow key (top-left of keyboard) | +| `rightarrow` | ↑ up-arrow key | +| `stop` | RUN/STOP | +| `cbm` | Commodore key | +| `ctrl` | CTRL | +| `lshift` / `rshift` | Left / Right Shift | +| `home` | CLR/HOME | +| `delete` | INST/DEL | +| `crsrdown` | Cursor Down | +| `crsrright` | Cursor Right | +| `f1` `f3` `f5` `f7` | Function keys F1 / F3 / F5 / F7 | + +> Cursor Up = `crsrdown` + `lshift` held. Cursor Left = `crsrright` + `lshift` held. F2/F4/F6/F8 = corresponding F-key + `lshift` held. + +--- + +### `screenshot` + +Captures the current display as a Base64-encoded PNG. Returns an error in headless mode (no renderer). + +```json +{"id": 19, "cmd": "screenshot"} +``` + +**Response fields** + +| Field | Type | Description | +|----------|--------|------------------------------------| +| `format` | string | Always `"png"` | +| `data` | string | Base64-encoded PNG bytes | + +```json +{"id": 19, "ok": true, "format": "png", "data": "iVBORw0KGgoAAAANSUhEUgAA..."} +``` + +--- + +### `ui.message` + +Displays a message in the emulator UI. In the Avalonia Desktop app it appears in the **Log tab** prefixed with `[Remote]`. In headless mode it is written to stdout. + +**Parameters** + +| Parameter | Type | Description | +|-----------|--------|-------------------------------------------------------| +| `text` | string | Message text | +| `level` | string | `info` (default), `warning`, or `error` | + +```json +{"id": 20, "cmd": "ui.message", "text": "Watch out — enemy spawns left!", "level": "info"} +``` +```json +{"id": 20, "ok": true} +``` + +--- + +### `c64.type` + +Pastes a string of text into the C64's keyboard buffer character by character. Characters are converted from ASCII to PETSCII automatically and fed into the buffer across frames — the C64 BASIC interpreter processes them exactly as if the user typed them. Newline (`\n`) maps to C64 Return. + +**C64 only.** Returns an error on other systems. + +**Parameters** + +| Parameter | Type | Description | +|-----------|--------|-------------------------------------------------------| +| `text` | string | Text to paste, e.g. `load"*",8,1\nrun\n` | + +```json +{"id": 16, "cmd": "c64.type", "text": "load\"*\",8,1\n"} +``` +```json +{"id": 16, "ok": true} +``` + +#### PETSCII case mapping + +> ⚠️ **Use lowercase letters in the `text` parameter to produce uppercase letters on the C64 screen.** + +The C64 boots into *upper/graphics* character mode. In this mode the PETSCII code range $41–$5A (produced by lowercase `a`–`z` input) renders as the uppercase Latin alphabet A–Z, while $C1–$DA (produced by uppercase `A`–`Z` input) renders as graphics characters. + +| Input character | PETSCII code | Displayed on C64 screen | +|-----------------|--------------|--------------------------| +| `a`–`z` (lowercase) | $41–$5A | **A–Z** (uppercase letters) | +| `A`–`Z` (uppercase) | $C1–$DA | Graphics / symbols | +| `0`–`9`, punctuation | Same as ASCII | Digits and punctuation | + +**Practical rule:** write all BASIC keywords and commands in lowercase in the `text` value. + +```sh +# Correct — displays LOAD"*",8,1 on the C64 screen +dotnet-6502-remote c64.type --text 'load"*",8,1' + +# Wrong — each letter maps to a graphics character, BASIC gives SYNTAX ERROR +dotnet-6502-remote c64.type --text 'LOAD"*",8,1' + +# Correct — displays SYS 49152 and executes the machine code at $C000 +dotnet-6502-remote c64.type --text "sys 49152" +``` + +Newline (`\n`) in the text is equivalent to pressing Return on the C64 keyboard. + +--- + +### `c64.loadprg` + +Loads a Commodore 64 PRG file into memory. The first two bytes of the data are the little-endian load address; the remaining bytes are written to memory starting at that address. Executed at the next frame boundary, so the emulator must be `Running`. + +**C64 only.** Returns an error on other systems. + +**Parameters** + +| Parameter | Type | Description | +|-----------|--------|--------------------------------------------------| +| `data` | string | Base64-encoded PRG file bytes (address + payload)| + +```json +{"id": 16, "cmd": "c64.loadprg", "data": "AMCqu8w="} +``` +```json +{"id": 16, "ok": true} +``` + +When using `dotnet-6502-remote`, pass `--file ` and the client reads and encodes the file for you: + +```sh +dotnet-6502-remote c64.loadprg --file /path/to/program.prg +``` + +--- + +### `c64.isbasicstarted` + +Returns whether C64 BASIC has finished initializing. Checks the `TXTAB` pointer at `$002B–$002C`; it equals `$0801` once BASIC is ready. Use this to poll before sending `c64.type` commands. + +**C64 only.** + +**Response fields** + +| Field | Type | Description | +|------------------|------|------------------------------------------| +| `isbasicstarted` | bool | `true` once BASIC initialization is done | + +```json +{"id": 17, "cmd": "c64.isbasicstarted"} +``` +```json +{"id": 17, "ok": true, "isbasicstarted": true} +``` + +--- + +### `c64.getbasicsource` + +Returns the current BASIC program in memory as a human-readable string with line numbers. Returns an empty string if BASIC has not initialized yet. + +**C64 only.** + +**Response fields** + +| Field | Type | Description | +|--------|--------|--------------------------------------| +| `data` | string | BASIC source text with line numbers | + +```json +{"id": 18, "cmd": "c64.getbasicsource"} +``` +```json +{"id": 18, "ok": true, "data": "10 PRINT \"HELLO\"\n20 GOTO 10\n"} +``` + +--- + +## `dotnet-6502-remote` Client + +A thin CLI wrapper is provided that hides the JSON framing, handles connection setup, and formats output for human or pipeline consumption. + +### Installation + +Pre-built binaries are available via Homebrew (macOS/Linux), Scoop (Windows), or manual download. See [INSTALL_REMOTE_CLIENT.md](INSTALL_REMOTE_CLIENT.md) for platform-specific instructions. + +Run from source (development): + +```sh +# Run directly from source +dotnet run --project src/apps/Highbyte.DotNet6502.App.RemoteClient -- --help + +# Or build and add to PATH +dotnet build src/apps/Highbyte.DotNet6502.App.RemoteClient -c Release +# binary: src/apps/Highbyte.DotNet6502.App.RemoteClient/bin/Release/net10.0/Highbyte.DotNet6502.App.RemoteClient +``` + +### Global options + +| Option | Default | Description | +|-----------------|-------------|------------------------------| +| `--host ` | `127.0.0.1` | Server hostname or IP | +| `--port ` | `6510` | TCP port | +| `--help` | | Print usage and exit | + +### Usage examples + +```sh +# Check emulator state +dotnet-6502-remote emu.state + +# Start the emulator (also resumes from paused) +dotnet-6502-remote --port 6510 emu.start + +# List available systems and variants +dotnet-6502-remote emu.systems +dotnet-6502-remote emu.variants + +# Switch system (requires emulator to be stopped first) +dotnet-6502-remote emu.stop +dotnet-6502-remote emu.selectsystem --name C64 +dotnet-6502-remote emu.selectvariant --name C64NTSC +dotnet-6502-remote emu.start + +# Read 16 bytes from $C000 +dotnet-6502-remote mem.read --addr C000 --len 16 + +# Write bytes to $C000 +dotnet-6502-remote mem.write --addr C000 --data 169,42,133,254 + +# Get CPU registers +dotnet-6502-remote cpu.get + +# Set CPU registers (set A and force InterruptDisable flag) +dotnet-6502-remote cpu.set --a 42 --flags "----I---" + +# Jump to a specific address +dotnet-6502-remote cpu.set --pc C000 + +# Load a PRG file into C64 memory +dotnet-6502-remote c64.loadprg --file /path/to/program.prg + +# Set joystick port 1: up + fire +dotnet-6502-remote joystick.set --port 1 --up --fire + +# Hold joystick port 1 up + fire until release +dotnet-6502-remote joystick.press --port 1 --up --fire + +# Release held joystick up on port 1 +dotnet-6502-remote joystick.release --port 1 --up + +# Release all held joystick actions on port 1 +dotnet-6502-remote joystick.releaseall --port 1 + +# Clear joystick port 1 up + fire explicitly for the next frame only +dotnet-6502-remote joystick.set --port 1 --no-up --fire false + +# Press and release the Return key +dotnet-6502-remote keyboard.press --key return +dotnet-6502-remote keyboard.release --key return + +# Paste text into the C64 keyboard buffer (C64 only; use lowercase — see c64.type section) +dotnet-6502-remote c64.type --text "load\"*\",8,1" + +# Take a screenshot and save to a file +dotnet-6502-remote screenshot --output /tmp/screen.png + +# Display a message in the Log tab +dotnet-6502-remote ui.message --text "Checkpoint reached" --level info + +# Quit the headless emulator (requires --allow-remote-quit on server) +dotnet-6502-remote emu.quit +``` + +Exit codes: `0` = success, `1` = server returned an error or connection failed, `2` = bad arguments. + +--- + +## Common Automation Workflows + +This section shows end-to-end patterns for typical C64 automation tasks using the `dotnet-6502-remote` CLI. The same command sequence translates directly to JSON requests over the raw TCP connection. + +### Typing a BASIC command and running it + +Poll `c64.isbasicstarted` before sending text — the C64 BASIC ROM takes several frames to initialize after `emu.start`. + +```sh +dotnet-6502-remote emu.start + +# Poll until BASIC is ready (repeat until isbasicstarted is true) +dotnet-6502-remote c64.isbasicstarted +# → {"ok":true,"isbasicstarted":true} + +# Type the BASIC command in LOWERCASE (lowercase input → uppercase on C64 screen) +dotnet-6502-remote c64.type --text 'load"*",8,1' +dotnet-6502-remote keyboard.press --key return +dotnet-6502-remote keyboard.release --key return +``` + +Shell poll loop: + +```sh +until dotnet-6502-remote c64.isbasicstarted | grep -q '"isbasicstarted":true'; do + sleep 0.5 +done +dotnet-6502-remote c64.type --text "list" +dotnet-6502-remote keyboard.press --key return +dotnet-6502-remote keyboard.release --key return +``` + +### Writing and running machine code + +There are two ways to jump to machine code. Setting the PC directly is simpler and works on any system. + +#### Method 1: Set PC directly (recommended for agents) + +`cpu.set --pc` sets the program counter at the next frame boundary. No BASIC, no keyboard input, no PETSCII — just write code and point the CPU at it. + +```sh +dotnet-6502-remote emu.start + +# Write machine code to $C000 as comma-separated decimal bytes +# Example: LDA #$42 (169,66), STA $D020 (141,32,208), RTS (96) +dotnet-6502-remote mem.write --addr C000 --data 169,66,141,32,208,96 + +# Verify the bytes were written +dotnet-6502-remote mem.read --addr C000 --len 6 + +# Jump to the routine — takes effect at next frame boundary +dotnet-6502-remote cpu.set --pc C000 + +# Confirm the CPU is executing inside the routine +dotnet-6502-remote cpu.get + +# Capture the result visually +dotnet-6502-remote screenshot --output /tmp/result.png +``` + +> **Note:** `cpu.set --pc` does not push a return address. If the routine ends with `RTS`, it will pop whatever is on the stack (potentially crashing). Use this method for programs that loop forever, or end with a `JMP` or `BRK`. Use the `SYS` method below when the routine is designed to return to BASIC via `RTS`. + +#### Method 2: Via BASIC SYS command (C64 only) + +This approach uses the C64 BASIC interpreter to call the routine, so BASIC must be running and the routine must end with `RTS` to return cleanly to BASIC. + +```sh +dotnet-6502-remote emu.start + +dotnet-6502-remote mem.write --addr C000 --data 169,66,141,32,208,96 +dotnet-6502-remote mem.read --addr C000 --len 6 + +# Wait until BASIC is ready before using the keyboard buffer +dotnet-6502-remote c64.isbasicstarted # repeat until "isbasicstarted":true + +# Type SYS in LOWERCASE — "sys 49152" displays as SYS 49152 on the C64 screen +dotnet-6502-remote c64.type --text "sys 49152" +dotnet-6502-remote keyboard.press --key return +dotnet-6502-remote keyboard.release --key return + +dotnet-6502-remote cpu.get +dotnet-6502-remote screenshot --output /tmp/result.png +``` + +### Discovering valid key names at runtime + +```sh +dotnet-6502-remote keyboard.getall +# → {"ok":true,"data":["space","return","a","b",...,"f7","crsrdown","crsrright","stop",...]} +``` + +Use this before calling `keyboard.press` to confirm the exact spelling of a key for the currently running system. Key names are case-insensitive in the protocol. + +--- + +## Bash / Shell Script Examples + +These examples use `nc` (netcat) and standard Unix tools. On macOS, `nc` is available by default; on Linux, install `netcat-openbsd`. + +### Single command + +```sh +echo '{"id":1,"cmd":"emu.state"}' | nc -G2 localhost 6510 +``` + +On Linux (where `-G` is not available): + +```sh +echo '{"id":1,"cmd":"emu.state"}' | nc -q1 localhost 6510 +``` + +### Multi-command session (one connection, multiple requests) + +```sh +( + echo '{"id":1,"cmd":"emu.start"}' + sleep 2 + echo '{"id":2,"cmd":"emu.state"}' + echo '{"id":3,"cmd":"cpu.get"}' + echo '{"id":4,"cmd":"mem.read","addr":"0400","len":8}' + sleep 0.1 +) | nc localhost 6510 +``` + +### Parse response with `jq` + +```sh +# Print just the state field +echo '{"id":1,"cmd":"emu.state"}' | nc -G2 localhost 6510 | jq -r '.state' + +# Read memory and format as hex +echo '{"id":1,"cmd":"mem.read","addr":"0400","len":40}' \ + | nc -G2 localhost 6510 \ + | jq '.data | map(. | tostring) | join(",")' + +# Save screenshot to PNG +echo '{"id":1,"cmd":"screenshot"}' \ + | nc -G2 localhost 6510 \ + | jq -r '.data' \ + | base64 --decode > /tmp/screen.png +``` + +### Automation loop: poll state until emulator is running + +```sh +#!/usr/bin/env bash +PORT=6510 +HOST=localhost + +echo "Starting emulator..." +echo '{"cmd":"emu.start"}' | nc -G2 "$HOST" "$PORT" > /dev/null + +for i in $(seq 1 30); do + STATE=$(echo '{"cmd":"emu.state"}' | nc -G2 "$HOST" "$PORT" | jq -r '.state') + echo " state: $STATE" + if [ "$STATE" = "Running" ]; then + echo "Emulator is running." + break + fi + sleep 0.5 +done +``` + +### Write a POKE and verify it + +```sh +# Write 42 ($2A) to address $C100 +echo '{"id":1,"cmd":"mem.write","addr":"D020","data":[1]}' \ + | nc -G2 localhost 6510 + +# Read it back +echo '{"id":2,"cmd":"mem.read","addr":"C100","len":1}' \ + | nc -G2 localhost 6510 \ + | jq '.data[0]' +# → 42 +``` + +### Persistent helper function + +Add this to your `~/.bashrc` or `~/.zshrc`: + +```sh +emu() { + local port="${EMU_PORT:-6510}" + local host="${EMU_HOST:-localhost}" + echo "$1" | nc -G2 "$host" "$port" +} + +# Usage: +emu '{"cmd":"emu.state"}' +emu '{"cmd":"mem.read","addr":"C000","len":16}' +``` + +--- + +## PowerShell Examples + +### Single command + +```powershell +$json = '{"id":1,"cmd":"emu.state"}' +$tcp = [System.Net.Sockets.TcpClient]::new("127.0.0.1", 6510) +$stream = $tcp.GetStream() +$bytes = [System.Text.Encoding]::UTF8.GetBytes($json + "`n") +$stream.Write($bytes, 0, $bytes.Length) + +$reader = [System.IO.StreamReader]::new($stream, [System.Text.Encoding]::UTF8) +$response = $reader.ReadLine() +$tcp.Close() + +$response | ConvertFrom-Json +``` + +### Reusable helper function + +```powershell +function Invoke-EmuCommand { + param( + [string]$Command, + [string]$Host = "127.0.0.1", + [int]$Port = 6510 + ) + $tcp = [System.Net.Sockets.TcpClient]::new($Host, $Port) + $stream = $tcp.GetStream() + try { + $bytes = [System.Text.Encoding]::UTF8.GetBytes($Command + "`n") + $stream.Write($bytes, 0, $bytes.Length) + $reader = [System.IO.StreamReader]::new($stream) + return $reader.ReadLine() | ConvertFrom-Json + } + finally { + $tcp.Close() + } +} + +# Examples +Invoke-EmuCommand '{"id":1,"cmd":"emu.state"}' +Invoke-EmuCommand '{"id":2,"cmd":"emu.start"}' +Invoke-EmuCommand '{"id":3,"cmd":"cpu.get"}' +Invoke-EmuCommand '{"id":4,"cmd":"mem.read","addr":"C000","len":4}' +``` + +### Multi-command session (one TCP connection) + +```powershell +function Invoke-EmuSession { + param( + [string[]]$Commands, + [string]$Host = "127.0.0.1", + [int]$Port = 6510 + ) + $tcp = [System.Net.Sockets.TcpClient]::new($Host, $Port) + $stream = $tcp.GetStream() + $reader = [System.IO.StreamReader]::new($stream) + try { + foreach ($cmd in $Commands) { + $bytes = [System.Text.Encoding]::UTF8.GetBytes($cmd + "`n") + $stream.Write($bytes, 0, $bytes.Length) + $response = $reader.ReadLine() | ConvertFrom-Json + Write-Output $response + } + } + finally { + $tcp.Close() + } +} + +# Example: start the emulator, wait, then read memory +$cmds = @( + '{"id":1,"cmd":"emu.start"}', + '{"id":2,"cmd":"emu.state"}', + '{"id":3,"cmd":"mem.read","addr":"0400","len":8}' +) +Invoke-EmuSession -Commands $cmds +``` + +### Screenshot to file + +```powershell +function Save-EmuScreenshot { + param( + [string]$OutputPath, + [string]$Host = "127.0.0.1", + [int]$Port = 6510 + ) + $resp = Invoke-EmuCommand '{"id":1,"cmd":"screenshot"}' -Host $Host -Port $Port + if (-not $resp.ok) { throw "Screenshot failed: $($resp.error)" } + $bytes = [Convert]::FromBase64String($resp.data) + [System.IO.File]::WriteAllBytes($OutputPath, $bytes) + Write-Host "Screenshot saved to $OutputPath" +} + +Save-EmuScreenshot -OutputPath "$env:TEMP\screen.png" +``` + +### Poll until running + +```powershell +Invoke-EmuCommand '{"cmd":"emu.start"}' | Out-Null + +for ($i = 0; $i -lt 30; $i++) { + $state = (Invoke-EmuCommand '{"cmd":"emu.state"}').state + Write-Host " state: $state" + if ($state -eq "Running") { Write-Host "Emulator is running."; break } + Start-Sleep -Milliseconds 500 +} +``` + +--- + +## Architecture + +``` +┌─────────────────────────────────────────────────────────┐ +│ External process (AI agent, script, dotnet-6502-remote) │ +└──────────────────────┬──────────────────────────────────┘ + │ TCP (newline-delimited JSON) + ▼ +┌──────────────────────────────────────────┐ +│ Highbyte.DotNet6502.Remoting (library) │ +│ ┌─────────────────────────────────────┐ │ +│ │ RemoteControlController │ │ ← owns server + session +│ │ RemoteCommandDispatcher │ │ ← routes commands +│ │ IRemoteControlEnvironment │ │ ← platform abstraction +│ │ IRemotableHostApp │ │ ← host app contract +│ └─────────────────────────────────────┘ │ +│ ┌─────────────────────────────────────┐ │ +│ │ Tcp/ │ │ +│ │ TcpRemoteControlServer │ │ ← listens on configurable bind address +│ │ RemoteControlSession │ │ ← one per client +│ └─────────────────────────────────────┘ │ +└───────────┬──────────────────────────────┘ + │ + ┌───────┴────────┐ + │ │ + ▼ ▼ + Avalonia Headless + Desktop App +``` + +**Dispatch paths:** + +| Command type | Execution thread | +|---------------------------|--------------------------| +| Read-only queries (`emu.state`, `emu.systems`, `emu.variants`, `cpu.get`, `mem.read`, `screenshot`, `ui.message`, `c64.isbasicstarted`, `c64.getbasicsource`) | Session thread (direct) | +| `emu.start/stop/pause/reset/quit`, `emu.selectsystem`, `emu.selectvariant` | UI thread via dispatcher | +| `mem.write`, `mem.loadbin`, `cpu.set`, `c64.loadprg`, `joystick.set/press/release/releaseall`, `keyboard.press/release/releaseall`, `c64.type` | Frame boundary via action queue | +| `keyboard.iskeydown`, `keyboard.getall` | Session thread (direct read) | + +--- + +## Limitations + +- **Frame-boundary commands require the emulator to be running.** `mem.write`, `cpu.set`, `keyboard.press/release/releaseall`, `joystick.set/press/release/releaseall`, `c64.type`, and `c64.loadprg` return an immediate error if the emulator state is `Paused` or `Uninitialized`. Use `emu.state` to confirm `Running` before sending these commands, or send `emu.start` first. +- **There is no `emu.resume` command.** `emu.start` serves dual purpose: it starts the emulator from `Uninitialized` *and* resumes from `Paused`. The existing system state is preserved on resume; use `emu.reset` for a hard restart. +- **`emu.selectsystem` and `emu.selectvariant` require the emulator to be stopped** (`Uninitialized`). Call `emu.stop` first, then select, then `emu.start`. +- **One client at a time.** A second connection attempt is accepted only after the first client disconnects. +- **`emu.quit` is disabled in Avalonia Desktop** by default. It is available in headless mode when `--allow-remote-quit` is passed. +- **`screenshot` returns an error in headless mode** because no renderer is active. +- **Loopback by default.** The server binds to `127.0.0.1` unless `--remote-bind-address` (or the **Bind** field in the Debug & Remoting tab) is set to a different interface. The wire protocol is unauthenticated — only bind to non-loopback addresses on trusted networks. +- **`keyboard.press` holds a key until `keyboard.release` or `keyboard.releaseall`.** The client controls press duration by choosing when to release. Keys are applied at frame boundary and remain held until released. +- **`joystick.press` holds joystick actions until `joystick.release` or `joystick.releaseall`.** Use this for ergonomic hold/release remote control. +- **`c64.type` and `c64.loadprg` are C64-specific.** Other systems do not implement these and will return an error. The text/PRG is applied at the next frame boundary. +- **`c64.type` feeds text across frames** — if the C64 keyboard buffer is full the remaining characters wait until space is available. +- **Injected joystick actions from `joystick.set` are not persistent** — they must be resent every frame to hold a direction. Use `joystick.press` if you want stateful joystick hold/release behavior. diff --git a/doc/Screenshots/AvaloniaDesktop_RemoteControl.png b/doc/Screenshots/AvaloniaDesktop_RemoteControl.png new file mode 100644 index 00000000..e7177590 Binary files /dev/null and b/doc/Screenshots/AvaloniaDesktop_RemoteControl.png differ diff --git a/dotnet-6502.sln b/dotnet-6502.sln index 353750e5..4d60dfc2 100644 --- a/dotnet-6502.sln +++ b/dotnet-6502.sln @@ -80,6 +80,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Highbyte.DotNet6502.App.Hea EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "libraries", "libraries", "{5E7EC0AF-E837-5B7A-FB41-A4DCCA877D1F}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Highbyte.DotNet6502.Remoting", "src\libraries\Highbyte.DotNet6502.Remoting\Highbyte.DotNet6502.Remoting.csproj", "{0EB68EDA-053E-4C7A-BCD7-FB329A58F051}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Highbyte.DotNet6502.App.RemoteClient", "src\apps\Highbyte.DotNet6502.App.RemoteClient\Highbyte.DotNet6502.App.RemoteClient.csproj", "{DC22514E-D1DA-4E3D-8FAE-824F110FE3D8}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -426,6 +430,30 @@ Global {7731CBFB-6969-4C0C-8BF2-14A361769AB2}.Release|x64.Build.0 = Release|Any CPU {7731CBFB-6969-4C0C-8BF2-14A361769AB2}.Release|x86.ActiveCfg = Release|Any CPU {7731CBFB-6969-4C0C-8BF2-14A361769AB2}.Release|x86.Build.0 = Release|Any CPU + {0EB68EDA-053E-4C7A-BCD7-FB329A58F051}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0EB68EDA-053E-4C7A-BCD7-FB329A58F051}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0EB68EDA-053E-4C7A-BCD7-FB329A58F051}.Debug|x64.ActiveCfg = Debug|Any CPU + {0EB68EDA-053E-4C7A-BCD7-FB329A58F051}.Debug|x64.Build.0 = Debug|Any CPU + {0EB68EDA-053E-4C7A-BCD7-FB329A58F051}.Debug|x86.ActiveCfg = Debug|Any CPU + {0EB68EDA-053E-4C7A-BCD7-FB329A58F051}.Debug|x86.Build.0 = Debug|Any CPU + {0EB68EDA-053E-4C7A-BCD7-FB329A58F051}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0EB68EDA-053E-4C7A-BCD7-FB329A58F051}.Release|Any CPU.Build.0 = Release|Any CPU + {0EB68EDA-053E-4C7A-BCD7-FB329A58F051}.Release|x64.ActiveCfg = Release|Any CPU + {0EB68EDA-053E-4C7A-BCD7-FB329A58F051}.Release|x64.Build.0 = Release|Any CPU + {0EB68EDA-053E-4C7A-BCD7-FB329A58F051}.Release|x86.ActiveCfg = Release|Any CPU + {0EB68EDA-053E-4C7A-BCD7-FB329A58F051}.Release|x86.Build.0 = Release|Any CPU + {DC22514E-D1DA-4E3D-8FAE-824F110FE3D8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DC22514E-D1DA-4E3D-8FAE-824F110FE3D8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DC22514E-D1DA-4E3D-8FAE-824F110FE3D8}.Debug|x64.ActiveCfg = Debug|Any CPU + {DC22514E-D1DA-4E3D-8FAE-824F110FE3D8}.Debug|x64.Build.0 = Debug|Any CPU + {DC22514E-D1DA-4E3D-8FAE-824F110FE3D8}.Debug|x86.ActiveCfg = Debug|Any CPU + {DC22514E-D1DA-4E3D-8FAE-824F110FE3D8}.Debug|x86.Build.0 = Debug|Any CPU + {DC22514E-D1DA-4E3D-8FAE-824F110FE3D8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DC22514E-D1DA-4E3D-8FAE-824F110FE3D8}.Release|Any CPU.Build.0 = Release|Any CPU + {DC22514E-D1DA-4E3D-8FAE-824F110FE3D8}.Release|x64.ActiveCfg = Release|Any CPU + {DC22514E-D1DA-4E3D-8FAE-824F110FE3D8}.Release|x64.Build.0 = Release|Any CPU + {DC22514E-D1DA-4E3D-8FAE-824F110FE3D8}.Release|x86.ActiveCfg = Release|Any CPU + {DC22514E-D1DA-4E3D-8FAE-824F110FE3D8}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -462,6 +490,8 @@ Global {AD2A5A3B-A595-3E96-334A-002D08FE630C} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {7731CBFB-6969-4C0C-8BF2-14A361769AB2} = {AD2A5A3B-A595-3E96-334A-002D08FE630C} {5E7EC0AF-E837-5B7A-FB41-A4DCCA877D1F} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {0EB68EDA-053E-4C7A-BCD7-FB329A58F051} = {5E7EC0AF-E837-5B7A-FB41-A4DCCA877D1F} + {DC22514E-D1DA-4E3D-8FAE-824F110FE3D8} = {AD2A5A3B-A595-3E96-334A-002D08FE630C} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {0F55B6C2-E4B4-4F2C-9D2A-D63A17F3B5C4} diff --git a/src/apps/Avalonia/Highbyte.DotNet6502.App.Avalonia.Core/App.axaml.cs b/src/apps/Avalonia/Highbyte.DotNet6502.App.Avalonia.Core/App.axaml.cs index 48b2b795..0f46652b 100644 --- a/src/apps/Avalonia/Highbyte.DotNet6502.App.Avalonia.Core/App.axaml.cs +++ b/src/apps/Avalonia/Highbyte.DotNet6502.App.Avalonia.Core/App.axaml.cs @@ -9,6 +9,7 @@ using Avalonia.Threading; using Highbyte.DotNet6502.App.Avalonia.Core.SystemSetup; using Highbyte.DotNet6502.DebugAdapter; +using Highbyte.DotNet6502.Remoting; using Highbyte.DotNet6502.App.Avalonia.Core.ViewModels; using Highbyte.DotNet6502.App.Avalonia.Core.Views; using Highbyte.DotNet6502.Impl.Avalonia.Input; @@ -58,6 +59,12 @@ public partial class App : Application /// public IExternalDebugController? ExternalDebugController { get; private set; } + /// + /// Runtime controller for the TCP remote control server. + /// Non-null only on Desktop and Headless; null on Browser. + /// + public IRemoteControlController? RemoteControlController { get; private set; } + /// /// Static reference to the current App instance (for debug adapter integration). /// @@ -102,6 +109,7 @@ public App( Func? saveCustomConfigSection = null, IGamepad? gamepad = null, IExternalDebugController? externalDebugController = null, + IRemoteControlController? remoteControlController = null, IScriptingEngine? scriptingEngine = null, Func? loadScript = null, Action? saveScript = null, @@ -129,6 +137,7 @@ public App( // Set static reference for external access (e.g., debug adapter) Current = this; ExternalDebugController = externalDebugController; + RemoteControlController = remoteControlController; // Initialize static logger factory for use in Views and other classes where DI is not available AppLogger.Factory = loggerFactory; diff --git a/src/apps/Avalonia/Highbyte.DotNet6502.App.Avalonia.Core/AvaloniaHostApp.cs b/src/apps/Avalonia/Highbyte.DotNet6502.App.Avalonia.Core/AvaloniaHostApp.cs index c1160687..0246acda 100644 --- a/src/apps/Avalonia/Highbyte.DotNet6502.App.Avalonia.Core/AvaloniaHostApp.cs +++ b/src/apps/Avalonia/Highbyte.DotNet6502.App.Avalonia.Core/AvaloniaHostApp.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.ComponentModel; +using System.IO; using System.Linq; using System.Threading.Tasks; using Avalonia; @@ -16,6 +17,7 @@ using Highbyte.DotNet6502.Impl.Avalonia.Render; using Highbyte.DotNet6502.Impl.NAudio; using Highbyte.DotNet6502.Impl.NAudio.WavePlayers; +using Highbyte.DotNet6502.Remoting; using Highbyte.DotNet6502.Systems; using Highbyte.DotNet6502.Systems.Input; using Highbyte.DotNet6502.Systems.Logging.InMem; @@ -30,7 +32,7 @@ namespace Highbyte.DotNet6502.App.Avalonia.Core; /// /// Host app for running Highbyte.DotNet6502 emulator in an Avalonia window /// -public class AvaloniaHostApp : HostApp, INotifyPropertyChanged, IDebuggableHostApp +public class AvaloniaHostApp : HostApp, INotifyPropertyChanged, IDebuggableHostApp, IRemotableHostApp { private readonly ILogger _logger; private readonly EmulatorConfig _emulatorConfig; @@ -54,7 +56,6 @@ public class AvaloniaHostApp : HostApp base.ScriptingEngine; private PeriodicAsyncTimer? _updateTimer; - private PeriodicAsyncTimer? _scriptingTickTimer; private EmulatorDisplayControlBase? _renderControl; @@ -338,21 +339,11 @@ public override void OnAfterStop() base.OnAfterStop(); } - protected override void StopScriptingTimer() - { - if (_scriptingTickTimer != null) - { - _scriptingTickTimer.Elapsed -= ScriptingTickTimerElapsed; - _scriptingTickTimer.Stop(); - _scriptingTickTimer.Dispose(); - _scriptingTickTimer = null; - } - } + protected override IScriptingTickTimer CreateScriptingTickTimer(double intervalMs) => + new PeriodicAsyncTimer { IntervalMilliseconds = intervalMs }; protected override void OnScriptingEngineSet() { - _scriptingTickTimer = CreateScriptingTickTimer(); - _scriptingTickTimer.Start(); _ = Dispatcher.UIThread.InvokeAsync(DrainPendingScriptActionsAsync); } @@ -602,22 +593,6 @@ private void StopAndDisposeUpdateTimer() } } - private PeriodicAsyncTimer CreateScriptingTickTimer() - { - var timer = new PeriodicAsyncTimer - { - IntervalMilliseconds = 16.0 // ~60 Hz, independent of system refresh rate - }; - timer.Elapsed += ScriptingTickTimerElapsed; - return timer; - } - - private async void ScriptingTickTimerElapsed(object? sender, EventArgs e) - { - InvokeScriptingTick(); - await DrainPendingScriptActionsAsync(); - } - private async void UpdateTimerElapsed(object? sender, EventArgs e) { RunEmulatorOneFrame(); @@ -887,4 +862,14 @@ internal void DeleteScript(string fileName) _deleteScript?.Invoke(fileName); ScriptingEngine.DeleteScript(fileName); } + + // IRemotableHostApp — screenshot capture + public byte[]? CaptureScreenshotPng() + { + var renderTarget = GetRenderTarget(); + if (renderTarget == null) return null; + using var ms = new MemoryStream(); + renderTarget.Bitmap.Save(ms); + return ms.ToArray(); + } } diff --git a/src/apps/Avalonia/Highbyte.DotNet6502.App.Avalonia.Core/EmulatorConfig.cs b/src/apps/Avalonia/Highbyte.DotNet6502.App.Avalonia.Core/EmulatorConfig.cs index ba4769f8..dfd2c4af 100644 --- a/src/apps/Avalonia/Highbyte.DotNet6502.App.Avalonia.Core/EmulatorConfig.cs +++ b/src/apps/Avalonia/Highbyte.DotNet6502.App.Avalonia.Core/EmulatorConfig.cs @@ -20,7 +20,7 @@ public class EmulatorConfig public float CurrentDrawScale { get; set; } = 2.0f; public bool UseGlobalExceptionHandler { get; set; } = true; // If set to false, the app will crash on unhandled exceptions. Can be useful for debugging to trigger the debugger where the exception occurs. public bool ShowErrorDialog { get; set; } = true; // If UseGlobalExceptionHandler is true, setting ShowErrorDialog to true shows a dialog on unhandled exceptions. Otherwise, exceptions are just logged. - public bool ShowDebugTab { get; set; } = false; + public bool ShowDebugTools { get; set; } = false; public bool LoadResourcesOverHttp { get; set; } = false; public WavePlayerSettingsProfile AudioSettingsProfile { get; set; } = WavePlayerSettingsProfile.Balanced; @@ -75,7 +75,7 @@ public void WriteToConfiguration(IConfiguration config) configSection["DefaultEmulator"] = DefaultEmulator; configSection["DefaultDrawScale"] = DefaultDrawScale.ToString(); configSection["ShowErrorDialog"] = ShowErrorDialog.ToString(); - configSection["ShowDebugTab"] = ShowDebugTab.ToString(); + configSection["ShowDebugTools"] = ShowDebugTools.ToString(); configSection["AudioSettingsProfile"] = AudioSettingsProfile.ToString(); var monitorSection = configSection.GetSection("Monitor"); diff --git a/src/apps/Avalonia/Highbyte.DotNet6502.App.Avalonia.Core/Highbyte.DotNet6502.App.Avalonia.Core.csproj b/src/apps/Avalonia/Highbyte.DotNet6502.App.Avalonia.Core/Highbyte.DotNet6502.App.Avalonia.Core.csproj index a2742d66..b3e9d4b6 100644 --- a/src/apps/Avalonia/Highbyte.DotNet6502.App.Avalonia.Core/Highbyte.DotNet6502.App.Avalonia.Core.csproj +++ b/src/apps/Avalonia/Highbyte.DotNet6502.App.Avalonia.Core/Highbyte.DotNet6502.App.Avalonia.Core.csproj @@ -56,6 +56,7 @@ + diff --git a/src/apps/Avalonia/Highbyte.DotNet6502.App.Avalonia.Core/PeriodicAsyncTimer.cs b/src/apps/Avalonia/Highbyte.DotNet6502.App.Avalonia.Core/PeriodicAsyncTimer.cs index d2d41b28..c7f15c6a 100644 --- a/src/apps/Avalonia/Highbyte.DotNet6502.App.Avalonia.Core/PeriodicAsyncTimer.cs +++ b/src/apps/Avalonia/Highbyte.DotNet6502.App.Avalonia.Core/PeriodicAsyncTimer.cs @@ -3,6 +3,7 @@ using System.Threading; using System.Threading.Tasks; using Avalonia.Threading; +using Highbyte.DotNet6502.Systems; namespace Highbyte.DotNet6502.App.Avalonia.Core; @@ -10,7 +11,7 @@ namespace Highbyte.DotNet6502.App.Avalonia.Core; /// A timer that uses .NET built-in PeriodicTimer. /// As PeriodicTimer runs on the thread it is created on, we need to marshal the Elapsed event to the UI thread. /// -public class PeriodicAsyncTimer : IDisposable, IAsyncDisposable +public class PeriodicAsyncTimer : IScriptingTickTimer, IAsyncDisposable { private CancellationTokenSource? _cts; private readonly Stopwatch _stopwatch = Stopwatch.StartNew(); @@ -21,6 +22,12 @@ public class PeriodicAsyncTimer : IDisposable, IAsyncDisposable public event EventHandler? Elapsed; + event EventHandler IScriptingTickTimer.Elapsed + { + add => Elapsed += value; + remove => Elapsed -= value; + } + public void Start() { Stop(); diff --git a/src/apps/Avalonia/Highbyte.DotNet6502.App.Avalonia.Core/Styles/InputStyles.axaml b/src/apps/Avalonia/Highbyte.DotNet6502.App.Avalonia.Core/Styles/InputStyles.axaml index 7244bf48..9920ec01 100644 --- a/src/apps/Avalonia/Highbyte.DotNet6502.App.Avalonia.Core/Styles/InputStyles.axaml +++ b/src/apps/Avalonia/Highbyte.DotNet6502.App.Avalonia.Core/Styles/InputStyles.axaml @@ -30,6 +30,16 @@ + + + @@ -138,6 +148,7 @@ + @@ -149,9 +160,20 @@ + + + + +