Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 14 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ publish = false
default-run = "copperline"

[features]
default = ["midi", "frontend", "wasm-boards"]
default = ["midi", "frontend", "wasm-boards", "control", "ctl-bin"]
display-plan-trace = []
# Local investigation switches that deliberately alter timing or rendering.
# Normal builds keep these environment-variable overrides inert so release
Expand Down Expand Up @@ -49,6 +49,13 @@ wasm-boards = ["dep:wasmtime"]
# for wasm32-wasip1). Off by default so native release artifacts are
# unchanged.
bench-bin = []
# The Copperline Control Protocol server (src/control/): a JSON-RPC interface
# over loopback TCP for driving the emulator from scripts and external tools
# (`--control` headless, `--control-gui` windowed). Uses std::net + threads,
# so browser builds (--no-default-features) compile it out.
control = ["dep:serde_json"]
# The `copperline-ctl` command-line client for the control protocol.
ctl-bin = ["dep:serde_json"]

[dependencies]
# Path-only dependency on the in-tree fork (crates/m68k). No version requirement
Expand All @@ -62,6 +69,7 @@ anyhow = "1"
log = "0.4"
env_logger = { version = "0.11", optional = true }
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", optional = true }
serde-big-array = "0.5"
bincode = "1"
toml = "0.8"
Expand Down Expand Up @@ -122,6 +130,11 @@ name = "copperline-bench"
path = "src/bin/bench.rs"
required-features = ["bench-bin"]

[[bin]]
name = "copperline-ctl"
path = "src/bin/copperline-ctl.rs"
required-features = ["ctl-bin"]

[dev-dependencies]
# Compile WAT to wasm bytes in the WASM-plugin-host tests, so golden test
# plugins live as readable text in the tests rather than checked-in binaries.
Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,9 @@ against real hardware.
- **Tooling**: an in-window debugger that can step backwards, an
interactive chip-bus frame analyzer, a trigger-based VCD waveform export
of the chipset signals for GTKWave (`docs/debugger/waveform.md`), remote
GDB support, deterministic save states, input recording/replay, and
GDB support, a JSON-RPC control protocol for scripts and AI agents
(`docs/debugger/control.md`, with the `copperline-ctl` client),
deterministic save states, input recording/replay, and
headless screenshot/frame-dump capture -- the deterministic core makes
every replay byte-identical.
- **A browser build**: the same core compiled to WebAssembly with a
Expand Down Expand Up @@ -247,7 +249,8 @@ the OS.

User and developer documentation -- getting started, the UI and shortcuts,
headless capture, save states, input recording, the debugger frontends
(window, headless, and remote GDB), the configuration reference, and the
(window, headless, remote GDB, and the JSON-RPC control protocol), the
configuration reference, and the
internals (timing model, chipset, CPU, video pipeline) -- is published at
[copperline.dev](https://copperline.dev/) and lives under `docs/` as a
[MyST](https://mystmd.org/) project you can also build locally:
Expand Down
209 changes: 209 additions & 0 deletions docs/debugger/control.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
# Control protocol

The Copperline Control Protocol (CCP) is a versioned JSON-RPC 2.0
interface over loopback TCP for driving the emulator from scripts,
editors, CI, and AI agents: inspect state, set breakpoints, resume,
rewind, inject input, swap media, and capture the display -- all inside
one session, without restarting the process. It complements the
environment-driven [headless debugger](headless.md) (which is
snapshot-once at startup) and [remote GDB](gdb.md) (whose wire protocol
cannot carry chipset state, media, or input).

Two server modes share one command surface:

```sh
# Headless: the server owns the machine (like --gdb); paused at reset
# until a client resumes. Port 0 picks a free port.
./target/release/copperline --config kick13.example.toml --noaudio \
--control :0 --control-info /tmp/ccp.json

# Windowed: attach a control server to a normal interactive session.
./target/release/copperline --config kick13.example.toml --control-gui :7710
```

On startup the server prints one machine-parseable stderr line:

```
copperline-control: listen=127.0.0.1:52114 token=1f0c... proto=1
```

`--control-info FILE` also writes it as a JSON object
(`{"listen": ..., "token": ..., "proto": 1}`, owner-readable only),
which is the preferred handoff since command lines are visible in `ps`.
`--control-token TOKEN` pins the token instead of generating one.

## The bundled client

`copperline-ctl` wraps the wire protocol for shells and scripts:

```sh
copperline-ctl --info /tmp/ccp.json status
copperline-ctl --info /tmp/ccp.json break.add '{"kind": "pc", "addr": "0xFC0100"}'
copperline-ctl --info /tmp/ccp.json continue # blocks until the stop
copperline-ctl --info /tmp/ccp.json --repl # METHOD [JSON] per line
```

Each response prints as one JSON line; a JSON-RPC error sets a nonzero
exit status. Anything that speaks TCP works just as well -- the protocol
is newline-delimited JSON, so `nc`, Python, or an editor extension can
drive it directly.

## Wire format and auth

One JSON-RPC 2.0 object per newline-terminated UTF-8 line. Every client
request must carry an `id`; responses echo it. The first successful
`hello {"token": ...}` (or `auth {"token": ...}`) authenticates the
connection; a wrong token gets one error and the connection is closed,
and anything except `hello`/`auth` is refused until then. `hello`
returns only version fields (`{"proto": 1, "emulator": ..., "authed":
...}`), so it is safe to answer before auth.

One client at a time (the GDB-stub convention). In headless mode a
detach leaves the machine paused and the server listening; `shutdown`
ends the emulator (windowed: closes the application).

Numbers in parameters are decimal values; strings are hex with an
optional `0x` or `$` prefix, so `"addr": "$DFF096"` and
`"addr": 14676118` are the same address.

## Execution model

Resume verbs -- `continue`, `step {n}`, `step_over`, `step_out`,
`step_copper`, `step_frame {n}`, `run_until {...}` -- do not answer
immediately: the response is the eventual **stop event**, so a script's
natural flow is "resume, block, inspect the reply". At most one resume
may be outstanding (`-32002` otherwise); inspection commands are still
serviced while the machine runs, at a frame boundary. `pause` ends a
pending resume (both requests receive the stop position). `run_until`
takes exactly one of `pc`, `vpos` (optional `hpos`), `frame`, `cck`, or
`seconds`.

Every stop event carries a consistent position on the emulated timeline:

```json
{"reason": "breakpoint", "detail": "Breakpoint at $FC0100",
"pc": 16515328, "frame": 122, "vpos": 44, "hpos": 101,
"cck": 8712345, "seconds": 2.456,
"retired_instructions": 1745210}
```

Reasons: `breakpoint`, `watchpoint`, `reg_watch`, `beam_trap`,
`copper_break`, `catch`, `task_catch`, `step`, `target`, `pause`,
`user_pause`, `double_fault`, `reverse`, `budget` (a bounded step ran
out of its instruction budget), and `last_writer` (only as the
`position` field of a `last_writer` reply).

Resume verbs accept a `collect` list -- read-only requests evaluated
atomically at the stop and returned inside the stop event -- so one
round trip can resume, wait, and gather everything:

```json
{"method": "continue", "params": {"collect": [
{"method": "regs.get"},
{"method": "mem.read", "params": {"addr": "$20000", "len": 16}},
{"method": "capture.digest"}
]}}
```

## Determinism and journaling

Commands land at deterministic boundaries of the emulated timeline:
instruction boundaries while paused, frame boundaries while running.
Injected input is journaled exactly like live input -- into the
reverse-debug replay log when time travel is armed, and into
`--record-input` recordings (supported headless too) -- and every
`input.*` response reports the emulated time the action landed
(`applied_at_seconds`), so an interactive control session converts into
a deterministic `.clscript` reproduction. The exceptions are documented
where they occur: `mem.write` is not part of the replay journal (its
response carries `"replay_unsafe": true` while time travel is armed),
and where a host-timed `pause` lands is inherently wall-clock, like a
GDB Ctrl-C. Every deterministic stop condition is detected by the core
per instruction regardless of how often the server polls the socket.

## Method reference

Session: `hello {token?}`, `auth {token}`, `status`, `shutdown`.

Execution: `continue`, `step {n?}`, `step_over`, `step_out`,
`step_copper`, `step_frame {n?}`,
`run_until {pc | vpos[,hpos] | frame | cck | seconds}`, `pause`
(all resume verbs accept `collect?`).

Reverse (time travel is armed automatically for control sessions;
`-32006` when history is exhausted): `reverse_step {n?}`,
`reverse_frame`, `reverse_continue`, and
`last_writer {addr}` -- find the instruction that last wrote a chip-RAM
word; on `"outcome": "found"` the machine is left parked at the writing
instruction (the reply's `position` says where).

Inspection: `regs.get`, `regs.set {reg, value}`, `mem.read {addr, len,
encoding?}` / `mem.write {addr, data, encoding?}` (hex default, base64
for bulk; 1 MiB cap; side-effect-free RAM/ROM view, device windows are
not touched), `disasm {addr?, count?}`, `custom.dump`,
`custom.read {reg}` (name or offset), `cia.get {cia: "a"|"b"}`,
`beam.get`, `display.get`, `copper.list {addr?, max?}`, `pc_history`.

Breakpoints (shared with the debugger window's live store, so
GUI-toggled points appear in `break.list`): `break.add {kind: "pc",
addr, cond?, ignore?}` / `{kind: "watch", addr, class?}` /
`{kind: "reg_watch", reg}` / `{kind: "beam", vpos, hpos?}` /
`{kind: "copper", addr}` / `{kind: "catch", vector}` -> `{id}`;
`break.remove {id}`; `break.list`; `break.clear`. Conditions are
`{lhs, op, rhs}` with operands `"d0"`-`"a7"`, `"pc"`, `"sr"`, a number,
or `{"mem": addr}`, and ops `eq|ne|lt|gt|le|ge|and`. A session's own
breakpoints are removed when it disconnects; GUI-set points are left
alone.

Input (applied now by default, or scheduled with `at_seconds`; `tap`
presses and schedules the release after `hold_ms`, default 80):
`input.key {rawkey, action: "press"|"release"|"tap", hold_ms?,
at_seconds?}`, `input.mouse {left?, right?, middle?, dx?, dy?}`,
`input.joy {up, down, left, right, red/fire1, blue/fire2, green,
yellow, play, rwd, ffw}` (port-2 held state, replaced wholesale).

Media: `media.floppy.insert {drive, path, write_protected?}`,
`media.floppy.eject {drive}`, `media.floppy.query`,
`media.cd.insert {path}`, `media.cd.eject`.

State and capture: `state.save {path}`, `state.load {path}` (re-arms
the reverse-debug ring on the loaded timeline), `capture.screenshot
{path?}` (raw framebuffer PNG, 716 pixels wide), `capture.digest`
(FNV-1a hash of the rendered frame -- the cheap change-detection
primitive, identical in both server modes), `machine.reset
{kind: "warm"|"cold"}`.

Notifications (windowed mode; no `id`): `event.stopped` fires when the
machine stops without a pending resume -- a GUI breakpoint, a user
pause, a guru/double fault -- so an attached client can follow along.

## Errors

Standard JSON-RPC codes (`-32700` parse, `-32600` invalid request,
`-32601` unknown method, `-32602` invalid params, `-32603` internal)
plus: `-32000` auth failed (connection closed), `-32001` not
authenticated, `-32002` a resume is already pending, `-32003` invalid
state (e.g. repositioning while running, stepping a running window),
`-32004` unsupported on this machine (e.g. CD without a drive),
`-32005` host I/O failed, `-32006` reverse history exhausted, `-32007`
not found.

## Windowed-mode notes

The status bar shows a `CCP` tag while a client is attached. A remote
stop pauses the machine without opening the debugger window; local and
remote control interleave -- a user pause completes a pending resume
with reason `user_pause`, and GUI breakpoints report to the client as
`event.stopped`. Reverse operations replay snapshots synchronously and
briefly block the window. Windowed screenshots via `capture.screenshot`
use the same raw framebuffer render as headless mode (not the presented,
aspect-corrected window content), so captures are comparable across
modes.

## Security

The token guards a loopback socket against other local users and
against browser cross-protocol requests; it is not network-grade
authentication. Binding beyond loopback is a trust decision, as with
`--gdb` and `[serial] mode = "tcp"`: a control client can read and
write guest RAM, drive input, and load host files as media.
14 changes: 14 additions & 0 deletions docs/guide/headless.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,20 @@ exclusive with anything that needs a window or scheduled work --
`--profile-live-audio`, `--record-input`, scripted input, and scheduled
disk inserts are all rejected. `--bench-until` is an accepted alias.

## Live control

Scripted flags fix the whole run in advance. For a tool that needs to
inspect, decide, and steer mid-session -- set a breakpoint, resume,
rewind, inject input, capture the screen -- run the
[control protocol](../debugger/control) instead: `--control ADDR` serves
a headless machine over JSON-RPC (with `--control-token` /
`--control-info` for the auth handoff), `--control-gui ADDR` attaches
the same server to a normal windowed session, and the bundled
`copperline-ctl` drives either from the shell. `--record-input` works
with `--control` too: injected input is journaled into the same
`.clscript` format, so an interactive control session replays
deterministically.

## Investigating a run

The [headless debugger](../debugger/headless) layers on top of any of
Expand Down
2 changes: 2 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ running in Copperline.
- [](debugger/window), [](debugger/headless), and [](debugger/gdb) -- the
interactive debugger window, chip-bus frame analyzer, environment-driven
headless debugger, and remote GDB frontend.
- [](debugger/control) -- the JSON-RPC control protocol for driving the
emulator from scripts, CI, and AI agents (`--control`, `copperline-ctl`).
- [](internals/architecture) -- how the emulator works inside, for
contributors.

Expand Down
2 changes: 2 additions & 0 deletions docs/myst.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ project:
- file: debugger/reverse.md
- file: debugger/waveform.md
- file: debugger/gdb.md
- file: debugger/control.md
- file: debugger/workflows.md
- title: Internals
children:
Expand Down Expand Up @@ -60,6 +61,7 @@ project:
- debugger/reverse.md
- debugger/waveform.md
- debugger/gdb.md
- debugger/control.md
- debugger/workflows.md
- internals/architecture.md
- internals/timing.md
Expand Down
Loading
Loading