From f256d901a227dc0871ba602753e774e62e33d5cd Mon Sep 17 00:00:00 2001 From: Andrew Hutchings Date: Thu, 16 Jul 2026 08:57:21 +0100 Subject: [PATCH 1/2] control: add the Copperline Control Protocol server and client A versioned JSON-RPC 2.0 interface over loopback TCP for driving the emulator from scripts, CI, editors, and AI agents: inspect state, set breakpoints, resume, rewind, inject input, swap media, and capture the display within one live session, which the snapshot-once COPPERLINE_DBG_* environment debugger cannot do and the GDB remote protocol cannot carry. Two server modes share one typed command surface (src/control/exec.rs), a thin dispatch over the same ui_*/debug_*/tt_* machinery the debugger window, console, and GDB stub already share: - --control ADDR: headless server that owns the Emulator (the gdbstub ownership pattern); paused at reset until a client resumes; polls the socket once per frame/chunk while running. - --control-gui ADDR: attaches to the normal windowed session; socket threads enqueue over mpsc, the frame loop drains at the top of about_to_wait, and an EventLoopProxy wake covers ControlFlow::Wait. Resume verbs defer their response until the machine stops and report a consistent position (pc/frame/vpos/hpos/cck/seconds/retired); an optional collect list is evaluated atomically at the stop. Auth is a generated token announced on stderr and via --control-info (0600 JSON). Injected input and media changes journal through tt_note_input and the input recorder (--record-input now works headless), so a control-driven session replays deterministically as a .clscript. capture.digest and capture.screenshot render through the side-effect-free display path and are identical in both modes; last_writer exposes the reverse-debug attribution scan. A remote stop pauses the windowed machine without commandeering the local debugger window, a user pause completes a pending resume as "user_pause", GUI stops notify as event.stopped, and the status bar shows a CCP tag while a client is attached. src/bin/copperline-ctl.rs is a std+serde_json client with one-shot and --repl modes, built by default (features: control, ctl-bin). Covered by proto/exec/session unit tests, headless end-to-end tests over real loopback sockets, and windowed drain tests through the test_app() fixture; documented in docs/debugger/control.md. --- Cargo.lock | 1 + Cargo.toml | 17 +- README.md | 7 +- docs/debugger/control.md | 207 +++++ docs/guide/headless.md | 14 + docs/index.md | 2 + docs/myst.yml | 2 + src/bin/copperline-ctl.rs | 255 ++++++ src/control.rs | 254 ++++++ src/control/exec.rs | 1531 +++++++++++++++++++++++++++++++++ src/control/headless.rs | 1162 +++++++++++++++++++++++++ src/control/proto.rs | 573 ++++++++++++ src/control/session.rs | 469 ++++++++++ src/control/windowed.rs | 260 ++++++ src/gdbstub.rs | 7 +- src/lib.rs | 2 + src/main.rs | 137 ++- src/video/window.rs | 69 ++ src/video/window/control.rs | 646 ++++++++++++++ src/video/window/statusbar.rs | 14 + src/video/window/tests.rs | 171 ++++ 21 files changed, 5792 insertions(+), 8 deletions(-) create mode 100644 docs/debugger/control.md create mode 100644 src/bin/copperline-ctl.rs create mode 100644 src/control.rs create mode 100644 src/control/exec.rs create mode 100644 src/control/headless.rs create mode 100644 src/control/proto.rs create mode 100644 src/control/session.rs create mode 100644 src/control/windowed.rs create mode 100644 src/video/window/control.rs diff --git a/Cargo.lock b/Cargo.lock index eed1e185..4744670c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -494,6 +494,7 @@ dependencies = [ "ringbuf", "serde", "serde-big-array", + "serde_json", "thread-priority", "toml", "wasmtime", diff --git a/Cargo.toml b/Cargo.toml index e4d1d3f3..5ece0256 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 @@ -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 @@ -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" @@ -115,13 +123,18 @@ web-time = "1" [[bin]] name = "copperline" path = "src/main.rs" -required-features = ["frontend"] +required-features = ["frontend", "control"] [[bin]] 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. diff --git a/README.md b/README.md index e63aa0cd..87c365c3 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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: diff --git a/docs/debugger/control.md b/docs/debugger/control.md new file mode 100644 index 00000000..6fb32a07 --- /dev/null +++ b/docs/debugger/control.md @@ -0,0 +1,207 @@ +# 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`. + +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. diff --git a/docs/guide/headless.md b/docs/guide/headless.md index cbe93487..8eb36658 100644 --- a/docs/guide/headless.md +++ b/docs/guide/headless.md @@ -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 diff --git a/docs/index.md b/docs/index.md index bf04a704..ceb64146 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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. diff --git a/docs/myst.yml b/docs/myst.yml index 585c7996..0f67ee90 100644 --- a/docs/myst.yml +++ b/docs/myst.yml @@ -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: @@ -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 diff --git a/src/bin/copperline-ctl.rs b/src/bin/copperline-ctl.rs new file mode 100644 index 00000000..7242a9a8 --- /dev/null +++ b/src/bin/copperline-ctl.rs @@ -0,0 +1,255 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! copperline-ctl: a thin command-line client for the Copperline Control +//! Protocol (docs/debugger/control.md). +//! +//! One-shot: +//! copperline-ctl --info /tmp/ccp.json status +//! copperline-ctl --connect 127.0.0.1:7710 --token HEX \ +//! run_until '{"pc": "0xFC0100"}' +//! +//! REPL (one `METHOD [JSON-PARAMS]` per line on stdin): +//! copperline-ctl --info /tmp/ccp.json --repl +//! +//! Responses print to stdout as one JSON object per line; server +//! notifications (event.*) print as they arrive. Exit status is nonzero +//! when a one-shot request returns a JSON-RPC error. +//! +//! Deliberately std + serde_json only, so it stays a trivially portable +//! sidecar for scripts and agents. + +use serde_json::{json, Value}; +use std::io::{BufRead, BufReader, Write}; +use std::net::TcpStream; +use std::process::ExitCode; + +struct Options { + connect: Option, + token: Option, + repl: bool, + method: Option, + params: Value, +} + +fn usage() -> &'static str { + "usage: copperline-ctl (--info FILE | --connect ADDR --token TOKEN) \ + [--repl | METHOD [JSON-PARAMS]]" +} + +fn parse_options() -> Result { + let mut connect = None; + let mut token = None; + let mut repl = false; + let mut method = None; + let mut params = Value::Null; + let mut args = std::env::args().skip(1); + while let Some(arg) = args.next() { + match arg.as_str() { + "--connect" => { + connect = Some(args.next().ok_or("--connect requires ADDR")?); + } + "--token" => { + token = Some(args.next().ok_or("--token requires a token")?); + } + "--info" => { + let path = args.next().ok_or("--info requires a file path")?; + let body = + std::fs::read_to_string(&path).map_err(|e| format!("reading {path}: {e}"))?; + let info: Value = serde_json::from_str(body.trim()) + .map_err(|e| format!("parsing {path}: {e}"))?; + connect = info + .get("listen") + .and_then(Value::as_str) + .map(String::from) + .or(connect); + token = info + .get("token") + .and_then(Value::as_str) + .map(String::from) + .or(token); + } + "--repl" => repl = true, + "-h" | "--help" => return Err(usage().to_string()), + _ if method.is_none() && !arg.starts_with('-') => method = Some(arg), + _ if method.is_some() && params.is_null() => { + params = + serde_json::from_str(&arg).map_err(|e| format!("params must be JSON: {e}"))?; + } + other => return Err(format!("unexpected argument {other:?}\n{}", usage())), + } + } + if connect.is_none() { + return Err(format!("no server address\n{}", usage())); + } + if !repl && method.is_none() { + return Err(format!("no method\n{}", usage())); + } + Ok(Options { + connect, + token, + repl, + method, + params, + }) +} + +struct Client { + reader: BufReader, + writer: TcpStream, + next_id: u64, +} + +impl Client { + fn connect(addr: &str) -> Result { + let stream = TcpStream::connect(addr).map_err(|e| format!("connecting to {addr}: {e}"))?; + stream.set_nodelay(true).ok(); + let reader = BufReader::new( + stream + .try_clone() + .map_err(|e| format!("cloning stream: {e}"))?, + ); + Ok(Self { + reader, + writer: stream, + next_id: 1, + }) + } + + fn send(&mut self, method: &str, params: &Value) -> Result { + let id = self.next_id; + self.next_id += 1; + let msg = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}); + let line = format!("{msg}\n"); + self.writer + .write_all(line.as_bytes()) + .and_then(|_| self.writer.flush()) + .map_err(|e| format!("sending request: {e}"))?; + Ok(id) + } + + /// Read until the response for `id` arrives; notifications and other + /// responses are printed as they pass by. + fn wait_for(&mut self, id: u64) -> Result { + loop { + let mut line = String::new(); + let n = self + .reader + .read_line(&mut line) + .map_err(|e| format!("reading response: {e}"))?; + if n == 0 { + return Err("server closed the connection".to_string()); + } + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let msg: Value = + serde_json::from_str(trimmed).map_err(|e| format!("bad server message: {e}"))?; + if msg.get("id").and_then(Value::as_u64) == Some(id) { + return Ok(msg); + } + println!("{msg}"); + } + } + + /// One request-response round trip; returns false when the reply is + /// a JSON-RPC error. + fn call(&mut self, method: &str, params: &Value) -> Result { + let id = self.send(method, params)?; + let msg = self.wait_for(id)?; + println!("{msg}"); + Ok(msg.get("error").is_none()) + } + + fn auth(&mut self, token: &str) -> Result<(), String> { + let id = self.send("hello", &json!({"token": token}))?; + let msg = self.wait_for(id)?; + if msg["result"]["authed"] == Value::Bool(true) { + Ok(()) + } else { + Err(format!("auth failed: {msg}")) + } + } +} + +fn main() -> ExitCode { + let options = match parse_options() { + Ok(options) => options, + Err(message) => { + eprintln!("{message}"); + return ExitCode::FAILURE; + } + }; + let addr = options.connect.as_deref().expect("checked in parsing"); + let mut client = match Client::connect(addr) { + Ok(client) => client, + Err(message) => { + eprintln!("{message}"); + return ExitCode::FAILURE; + } + }; + if let Some(token) = &options.token { + if let Err(message) = client.auth(token) { + eprintln!("{message}"); + return ExitCode::FAILURE; + } + } + if options.repl { + let stdin = std::io::stdin(); + for line in stdin.lock().lines() { + let Ok(line) = line else { break }; + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let (method, rest) = match line.split_once(char::is_whitespace) { + Some((method, rest)) => (method, rest.trim()), + None => (line, ""), + }; + let params: Value = if rest.is_empty() { + Value::Null + } else { + match serde_json::from_str(rest) { + Ok(params) => params, + Err(e) => { + eprintln!("params must be JSON: {e}"); + continue; + } + } + }; + match client.call(method, ¶ms) { + Ok(_) => {} + Err(message) => { + eprintln!("{message}"); + return ExitCode::FAILURE; + } + } + } + ExitCode::SUCCESS + } else { + let method = options.method.as_deref().expect("checked in parsing"); + match client.call(method, &options.params) { + Ok(true) => ExitCode::SUCCESS, + Ok(false) => ExitCode::FAILURE, + Err(message) => { + eprintln!("{message}"); + ExitCode::FAILURE + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn request_lines_are_json_rpc_shaped() { + // The request builder is exercised through send() over a real + // socket in the server's e2e tests; here just pin the envelope. + let msg = json!({"jsonrpc": "2.0", "id": 1, "method": "status", "params": Value::Null}); + let line = msg.to_string(); + assert!(line.contains("\"jsonrpc\":\"2.0\"")); + assert!(line.contains("\"method\":\"status\"")); + } +} diff --git a/src/control.rs b/src/control.rs new file mode 100644 index 00000000..71b0542c --- /dev/null +++ b/src/control.rs @@ -0,0 +1,254 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! The Copperline Control Protocol (CCP): a versioned JSON-RPC interface +//! over loopback TCP for driving the emulator from scripts and external +//! tools. +//! +//! Two server modes share one command surface: +//! +//! - headless (`--control ADDR`): the server owns the [`Emulator`] and +//! drives it directly, one client at a time, exactly like the GDB stub +//! (`gdbstub::run`); see `control::headless`. +//! - windowed (`--control-gui ADDR`): socket threads enqueue typed +//! commands over an mpsc channel and the winit frame loop drains them +//! at a frame boundary; see `control::windowed`. +//! +//! Like the GDB stub, this is a host debugger transport, not an emulated +//! device: inspection commands are side-effect-free, and every mutation a +//! client injects (input, media, memory pokes) lands at a deterministic +//! boundary of the emulated timeline and is journaled so the session +//! stays reproducible. The wire format lives in `control::proto`. +//! +//! [`Emulator`]: crate::emulator::Emulator + +pub mod exec; +pub mod headless; +pub mod proto; +pub mod session; +pub mod windowed; + +use anyhow::{Context, Result}; +use std::hash::{BuildHasher, Hasher}; +use std::io::Write as _; +use std::path::PathBuf; + +/// Startup settings for a control server, mirroring `gdbstub::Config`: +/// the CLI carries the listen address and token plumbing, the reverse- +/// debugging knobs come from the same environment variables the other +/// debugger frontends use. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Config { + pub listen: String, + /// Pinned auth token (`--control-token`); `None` generates one. + pub token: Option, + /// File to write the connection info line to (`--control-info`). + pub info_file: Option, + /// Journal control-injected input into this `.clscript` + /// (`--record-input`, headless mode). + pub record_input: Option, + pub reverse_budget_mb: usize, + pub reverse_interval_frames: u64, +} + +impl Config { + pub fn new(listen: String) -> Self { + Self { + listen, + token: None, + info_file: None, + record_input: None, + reverse_budget_mb: crate::envcfg::var("COPPERLINE_DBG_RR_BUDGET_MB") + .and_then(|s| s.trim().parse::().ok()) + .unwrap_or(crate::debugger::RR_DEFAULT_BUDGET_MB), + reverse_interval_frames: crate::envcfg::var("COPPERLINE_DBG_RR_INTERVAL") + .and_then(|s| s.trim().parse::().ok()) + .unwrap_or(crate::debugger::RR_DEFAULT_INTERVAL_FRAMES), + } + } + + /// The token to serve with: the pinned one, or a fresh random token. + pub fn resolve_token(&self) -> String { + self.token.clone().unwrap_or_else(generate_token) + } +} + +/// Generate a 128-bit session token as 32 lowercase hex characters. +/// +/// Reads /dev/urandom where it exists; otherwise falls back to mixing two +/// independently OS-seeded `RandomState` hashers over process-unique +/// values. The token guards a loopback socket against other local users +/// and browser cross-protocol requests; it is not network-grade auth. +pub fn generate_token() -> String { + if let Some(tok) = urandom_token() { + return tok; + } + let mut out = String::with_capacity(32); + for salt in 0..2u64 { + let mut h = std::collections::hash_map::RandomState::new().build_hasher(); + h.write_u64(salt); + h.write_u128( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0), + ); + h.write_u32(std::process::id()); + h.write_usize(&salt as *const u64 as usize); + out.push_str(&format!("{:016x}", h.finish())); + } + out +} + +fn urandom_token() -> Option { + use std::io::Read as _; + let mut bytes = [0u8; 16]; + let mut f = std::fs::File::open("/dev/urandom").ok()?; + f.read_exact(&mut bytes).ok()?; + let mut out = String::with_capacity(32); + for b in bytes { + out.push_str(&format!("{b:02x}")); + } + Some(out) +} + +/// Announce a bound control server: one machine-parseable stderr line, +/// plus the optional `--control-info` file (JSON, owner-only permissions) +/// as the preferred handoff since command lines are visible in `ps`. +pub fn announce( + addr: &std::net::SocketAddr, + token: &str, + info_file: Option<&PathBuf>, +) -> Result<()> { + eprintln!( + "copperline-control: listen={addr} token={token} proto={}", + proto::PROTO_VERSION + ); + if let Some(path) = info_file { + let line = format!( + "{{\"listen\":\"{addr}\",\"token\":\"{token}\",\"proto\":{}}}\n", + proto::PROTO_VERSION + ); + write_private_file(path, line.as_bytes()) + .with_context(|| format!("writing control info file {}", path.display()))?; + } + Ok(()) +} + +/// Write `contents` to `path` readable by the owner only (0600 on unix), +/// truncating any previous file. +fn write_private_file(path: &PathBuf, contents: &[u8]) -> std::io::Result<()> { + let mut opts = std::fs::OpenOptions::new(); + opts.write(true).create(true).truncate(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + opts.mode(0o600); + } + let mut f = opts.open(path)?; + f.write_all(contents)?; + f.flush() +} + +/// Build a minimal asset-free emulator for control tests: a 68000 whose +/// ROM program loops incrementing D0 and writing it to chip RAM, so +/// stepping, breakpoints, watches, and last-writer scans all have +/// something deterministic to bite on. +/// +/// ```text +/// F80010 NOP +/// F80012 ADDQ.L #1,D0 +/// F80014 MOVE.W D0,($20000).L +/// F8001A BRA.S F80010 +/// ``` +#[cfg(test)] +pub(crate) fn test_emulator() -> crate::emulator::Emulator { + let mut rom = vec![0u8; crate::memory::ROM_SIZE]; + let put_word = |mem: &mut [u8], off: usize, word: u16| { + mem[off..off + 2].copy_from_slice(&word.to_be_bytes()); + }; + put_word(&mut rom, 0x10, 0x4E71); // NOP + put_word(&mut rom, 0x12, 0x5280); // ADDQ.L #1,D0 + put_word(&mut rom, 0x14, 0x33C0); // MOVE.W D0,(abs).L + put_word(&mut rom, 0x16, 0x0002); + put_word(&mut rom, 0x18, 0x0000); + put_word(&mut rom, 0x1A, 0x60F4); // BRA.S F80010 + + let mut chip_ram = vec![0u8; 512 * 1024]; + chip_ram[0..4].copy_from_slice(&0x0000_4000u32.to_be_bytes()); // reset SSP + chip_ram[4..8].copy_from_slice(&0x00F8_0010u32.to_be_bytes()); // reset PC + + let bus = crate::bus::Bus::new( + crate::memory::Memory { + chip_ram, + slow_ram: Vec::new(), + rom, + overlay: false, + zorro: crate::zorro::ZorroChain::default(), + extended_rom: Vec::new(), + extended_rom_base: 0, + wcs: Vec::new(), + wcs_write_protected: false, + }, + crate::chipset::paula::Paula::new( + Box::new(crate::serial::NullSerialSink), + Box::new(crate::audio::NullSink), + ), + crate::floppy::FloppyController::default(), + ); + crate::emulator::Emulator::new( + bus, + crate::config::CpuModel::M68000, + false, + Default::default(), + crate::config::PacingBudget::Cycles, + 2, + false, + ) + .unwrap() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn token_is_32_hex_and_unique() { + let a = generate_token(); + let b = generate_token(); + assert_eq!(a.len(), 32); + assert!(a + .chars() + .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())); + assert_ne!(a, b); + } + + #[test] + fn config_pins_token() { + let mut cfg = Config::new(":0".into()); + assert_eq!(cfg.resolve_token().len(), 32); + cfg.token = Some("sesame".into()); + assert_eq!(cfg.resolve_token(), "sesame"); + } + + #[test] + fn info_file_is_json_line() -> Result<()> { + let dir = std::env::temp_dir().join(format!("ccp-info-test-{}", std::process::id())); + std::fs::create_dir_all(&dir)?; + let path = dir.join("info.json"); + let addr: std::net::SocketAddr = "127.0.0.1:4321".parse()?; + announce(&addr, "deadbeef", Some(&path))?; + let body = std::fs::read_to_string(&path)?; + assert_eq!( + body, + "{\"listen\":\"127.0.0.1:4321\",\"token\":\"deadbeef\",\"proto\":1}\n" + ); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + let mode = std::fs::metadata(&path)?.permissions().mode(); + assert_eq!(mode & 0o777, 0o600); + } + std::fs::remove_dir_all(&dir).ok(); + Ok(()) + } +} diff --git a/src/control/exec.rs b/src/control/exec.rs new file mode 100644 index 00000000..5e168cb6 --- /dev/null +++ b/src/control/exec.rs @@ -0,0 +1,1531 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! The typed command layer of the control protocol: method+params are +//! parsed into a [`CoreOp`] (executed identically by both server modes +//! through [`exec_core`]) or a [`HostOp`] (resume verbs, input, media -- +//! things each driver applies through its own boundary). Everything +//! here calls the same `ui_*` / `debug_*` / `tt_*` machinery the +//! debugger window, console, and GDB stub already share; there is no +//! second debugger implementation. + +use super::proto::{self, CtlError, StopEvent}; +use super::session::{BreakSpec, InputAction, SessionCtx}; +use crate::debugger::{BreakCond, CondOp, CondOperand, DebugStop, WatchSource}; +use crate::emulator::Emulator; +use crate::inputsched::JoyState; +use crate::timetravel::ReverseOutcome; +use crate::video::{FB_WIDTH, MAX_FB_PIXELS}; +use serde_json::{json, Map, Value}; +use std::path::PathBuf; + +/// Longest single memory transfer, matching the wire-line budget. +pub const MEM_TRANSFER_CAP: usize = 1024 * 1024; + +/// Instruction budget for bounded run helpers (step-over, run-to-pc...), +/// mirroring the debugger window's transports. +pub const RUN_BUDGET: usize = 5_000_000; + +/// Once a cck/seconds run target is within this many colour clocks +/// (about one PAL frame), the drivers finish instruction-by-instruction +/// so the stop lands at the first instruction boundary at or past the +/// target. +pub const CCK_FINE_WINDOW: u64 = 80_000; + +/// A parsed request, split by which layer executes it. +#[derive(Debug, Clone, PartialEq)] +pub enum Request { + Core(CoreOp), + Host(HostOp), +} + +/// Commands executed directly against the `Emulator`, shared verbatim +/// by the headless driver and the windowed drain. +#[derive(Debug, Clone, PartialEq)] +pub enum CoreOp { + Status, + RegsGet, + RegsSet { reg: usize, value: u32 }, + MemRead { addr: u32, len: usize, base64: bool }, + MemWrite { addr: u32, data: Vec }, + Disasm { addr: Option, count: usize }, + CustomDump, + CustomRead { off: u16 }, + CiaGet { b: bool }, + BeamGet, + DisplayGet, + CopperList { addr: Option, max: usize }, + LastWriter { addr: u32 }, + PcHistory, + BreakAdd(BreakSpec), + BreakRemove { id: u32 }, + BreakList, + BreakClear, + FloppyQuery, + StateSave { path: PathBuf }, + Digest, + Screenshot { path: Option }, + ReverseStep { n: u64 }, + ReverseFrame, + ReverseContinue, +} + +impl CoreOp { + /// Whether this op may be serviced at a quantum boundary while a + /// resume is pending. Repositioning ops (reverse, last-writer) must + /// not race an in-flight run. + pub fn allowed_while_running(&self) -> bool { + !matches!( + self, + CoreOp::LastWriter { .. } + | CoreOp::ReverseStep { .. } + | CoreOp::ReverseFrame + | CoreOp::ReverseContinue + ) + } + + /// Whether this op is read-only, and therefore allowed in a + /// `collect` list evaluated at a stop. + pub fn collectable(&self) -> bool { + matches!( + self, + CoreOp::Status + | CoreOp::RegsGet + | CoreOp::MemRead { .. } + | CoreOp::Disasm { .. } + | CoreOp::CustomDump + | CoreOp::CustomRead { .. } + | CoreOp::CiaGet { .. } + | CoreOp::BeamGet + | CoreOp::DisplayGet + | CoreOp::CopperList { .. } + | CoreOp::PcHistory + | CoreOp::BreakList + | CoreOp::FloppyQuery + | CoreOp::Digest + | CoreOp::Screenshot { .. } + ) + } +} + +/// Commands the drivers execute through their own boundary: run control +/// (whose responses are deferred to the stop), input, media, state +/// restore, and reset. +#[derive(Debug, Clone, PartialEq)] +pub enum HostOp { + Pause, + Resume(ResumeVerb), + Input(InputCmd), + FloppyInsert { + drive: usize, + path: PathBuf, + write_protected: bool, + }, + FloppyEject { + drive: usize, + }, + CdInsert { + path: PathBuf, + }, + CdEject, + StateLoad { + path: PathBuf, + }, + Reset { + warm: bool, + }, +} + +/// A resume-type command: the machine runs and the JSON-RPC response is +/// the eventual [`StopEvent`], with `collect` evaluated at the stop. +#[derive(Debug, Clone, PartialEq)] +pub struct ResumeVerb { + pub kind: ResumeKind, + pub collect: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum ResumeKind { + Continue, + Step { n: u32 }, + StepOver, + StepOut, + StepCopper, + StepFrame { n: u32 }, + RunUntil(RunTarget), +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum RunTarget { + Pc(u32), + Beam { vpos: u16, hpos: Option }, + Frame(u64), + Cck(u64), + Seconds(f64), +} + +impl RunTarget { + pub fn describe(&self) -> String { + match self { + RunTarget::Pc(pc) => format!("pc ${pc:06X}"), + RunTarget::Beam { vpos, hpos } => format!( + "beam v{vpos}{}", + hpos.map(|h| format!(" h{h}")).unwrap_or_default() + ), + RunTarget::Frame(f) => format!("frame {f}"), + RunTarget::Cck(c) => format!("cck {c}"), + RunTarget::Seconds(s) => format!("{s}s"), + } + } +} + +/// A parsed input command, before the driver expands it into immediate +/// and scheduled transitions. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum InputCmd { + Key { + rawkey: u8, + kind: KeyKind, + at_seconds: Option, + }, + Mouse { + left: Option, + right: Option, + middle: Option, + dx: i32, + dy: i32, + }, + Joy(JoyState), +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum KeyKind { + Press, + Release, + /// Press now, release after `hold_ms` of emulated time. + Tap { + hold_ms: u32, + }, +} + +impl InputCmd { + /// Expand into (immediate transitions, scheduled transitions), + /// given the current emulated time. Shared by both drivers so tap + /// and `at_seconds` semantics cannot diverge. + pub fn expand(&self, now_secs: f64) -> (Vec, Vec) { + let mut now = Vec::new(); + let mut later = Vec::new(); + let mut emit = |at: Option, action: InputAction| match at { + Some(t) if t > now_secs => { + later.push(super::session::ScheduledInput { + at_seconds: t, + action, + }); + } + _ => now.push(action), + }; + match *self { + InputCmd::Key { + rawkey, + kind, + at_seconds, + } => match kind { + KeyKind::Press => emit( + at_seconds, + InputAction::Key { + rawkey, + pressed: true, + }, + ), + KeyKind::Release => emit( + at_seconds, + InputAction::Key { + rawkey, + pressed: false, + }, + ), + KeyKind::Tap { hold_ms } => { + let press_at = at_seconds.unwrap_or(now_secs); + emit( + at_seconds, + InputAction::Key { + rawkey, + pressed: true, + }, + ); + emit( + Some(press_at + f64::from(hold_ms) / 1000.0), + InputAction::Key { + rawkey, + pressed: false, + }, + ); + } + }, + InputCmd::Mouse { + left, + right, + middle, + dx, + dy, + } => { + for (index, state) in [(0u8, left), (1, right), (2, middle)] { + if let Some(pressed) = state { + emit(None, InputAction::MouseButton { index, pressed }); + } + } + if dx != 0 || dy != 0 { + emit(None, InputAction::MouseMove { dx, dy }); + } + } + InputCmd::Joy(j) => emit(None, InputAction::Joy(j)), + } + (now, later) + } +} + +// --------------------------------------------------------------------- +// Parsing + +/// Parse a method name and params object into a typed request. +pub fn parse_method(method: &str, params: &Value) -> Result { + let p = ParamReader::new(params)?; + let core = |op: CoreOp| Ok(Request::Core(op)); + let host = |op: HostOp| Ok(Request::Host(op)); + match method { + "status" => core(CoreOp::Status), + "pause" => host(HostOp::Pause), + "continue" => resume(ResumeKind::Continue, &p), + "step" => resume( + ResumeKind::Step { + n: p.u32_or("n", 1)?.clamp(1, 1_000_000), + }, + &p, + ), + "step_over" => resume(ResumeKind::StepOver, &p), + "step_out" => resume(ResumeKind::StepOut, &p), + "step_copper" => resume(ResumeKind::StepCopper, &p), + "step_frame" => resume( + ResumeKind::StepFrame { + n: p.u32_or("n", 1)?.clamp(1, 1_000_000), + }, + &p, + ), + "run_until" => resume(ResumeKind::RunUntil(parse_run_target(&p)?), &p), + "reverse_step" => core(CoreOp::ReverseStep { + n: u64::from(p.u32_or("n", 1)?.max(1)), + }), + "reverse_frame" => core(CoreOp::ReverseFrame), + "reverse_continue" => core(CoreOp::ReverseContinue), + "regs.get" => core(CoreOp::RegsGet), + "regs.set" => core(CoreOp::RegsSet { + reg: parse_reg_name(&p.str_req("reg")?)?, + value: p.u32_req("value")?, + }), + "mem.read" => { + let len = p.usize_or("len", 2)?; + if len == 0 || len > MEM_TRANSFER_CAP { + return Err(CtlError::invalid_params(format!( + "len must be 1..={MEM_TRANSFER_CAP}" + ))); + } + core(CoreOp::MemRead { + addr: p.u32_req("addr")?, + len, + base64: match p.str_opt("encoding")?.as_deref() { + None | Some("hex") => false, + Some("base64") => true, + Some(other) => { + return Err(CtlError::invalid_params(format!( + "unknown encoding: {other}" + ))) + } + }, + }) + } + "mem.write" => { + let data = p.str_req("data")?; + let bytes = match p.str_opt("encoding")?.as_deref() { + None | Some("hex") => proto::decode_hex(&data), + Some("base64") => proto::decode_base64(&data), + Some(other) => { + return Err(CtlError::invalid_params(format!( + "unknown encoding: {other}" + ))) + } + }; + let Some(bytes) = bytes else { + return Err(CtlError::invalid_params("malformed data payload")); + }; + if bytes.is_empty() || bytes.len() > MEM_TRANSFER_CAP { + return Err(CtlError::invalid_params(format!( + "data must be 1..={MEM_TRANSFER_CAP} bytes" + ))); + } + core(CoreOp::MemWrite { + addr: p.u32_req("addr")?, + data: bytes, + }) + } + "disasm" => core(CoreOp::Disasm { + addr: p.u32_opt("addr")?, + count: p.usize_or("count", 16)?.clamp(1, 256), + }), + "custom.dump" => core(CoreOp::CustomDump), + "custom.read" => core(CoreOp::CustomRead { + off: parse_custom_reg_param(&p)?, + }), + "cia.get" => core(CoreOp::CiaGet { + b: match p.str_req("cia")?.as_str() { + "a" | "A" => false, + "b" | "B" => true, + other => { + return Err(CtlError::invalid_params(format!( + "cia must be \"a\" or \"b\", got {other}" + ))) + } + }, + }), + "beam.get" => core(CoreOp::BeamGet), + "display.get" => core(CoreOp::DisplayGet), + "copper.list" => core(CoreOp::CopperList { + addr: p.u32_opt("addr")?, + max: p.usize_or("max", 32)?.clamp(1, 256), + }), + "last_writer" => core(CoreOp::LastWriter { + addr: p.u32_req("addr")?, + }), + "pc_history" => core(CoreOp::PcHistory), + "break.add" => core(CoreOp::BreakAdd(parse_break_spec(&p)?)), + "break.remove" => core(CoreOp::BreakRemove { + id: p.u32_req("id")?, + }), + "break.list" => core(CoreOp::BreakList), + "break.clear" => core(CoreOp::BreakClear), + "input.key" => { + let rawkey = p.u32_req("rawkey")?; + if rawkey > 0xFF { + return Err(CtlError::invalid_params("rawkey must be 0..=255")); + } + let kind = match p.str_opt("action")?.as_deref() { + None | Some("tap") => KeyKind::Tap { + hold_ms: p.u32_or("hold_ms", 80)?, + }, + Some("press") => KeyKind::Press, + Some("release") => KeyKind::Release, + Some(other) => { + return Err(CtlError::invalid_params(format!( + "action must be press|release|tap, got {other}" + ))) + } + }; + host(HostOp::Input(InputCmd::Key { + rawkey: rawkey as u8, + kind, + at_seconds: p.f64_opt("at_seconds")?, + })) + } + "input.mouse" => host(HostOp::Input(InputCmd::Mouse { + left: p.bool_opt("left")?, + right: p.bool_opt("right")?, + middle: p.bool_opt("middle")?, + dx: p.i32_or("dx", 0)?, + dy: p.i32_or("dy", 0)?, + })), + "input.joy" => host(HostOp::Input(InputCmd::Joy(JoyState { + up: p.bool_or("up", false)?, + down: p.bool_or("down", false)?, + left: p.bool_or("left", false)?, + right: p.bool_or("right", false)?, + red: p.bool_or("red", false)? || p.bool_or("fire1", false)?, + blue: p.bool_or("blue", false)? || p.bool_or("fire2", false)?, + play: p.bool_or("play", false)?, + rwd: p.bool_or("rwd", false)?, + ffw: p.bool_or("ffw", false)?, + green: p.bool_or("green", false)?, + yellow: p.bool_or("yellow", false)?, + }))), + "media.floppy.insert" => host(HostOp::FloppyInsert { + drive: p.usize_req("drive")?, + path: PathBuf::from(p.str_req("path")?), + write_protected: p.bool_or("write_protected", false)?, + }), + "media.floppy.eject" => host(HostOp::FloppyEject { + drive: p.usize_req("drive")?, + }), + "media.floppy.query" => core(CoreOp::FloppyQuery), + "media.cd.insert" => host(HostOp::CdInsert { + path: PathBuf::from(p.str_req("path")?), + }), + "media.cd.eject" => host(HostOp::CdEject), + "state.save" => core(CoreOp::StateSave { + path: PathBuf::from(p.str_req("path")?), + }), + "state.load" => host(HostOp::StateLoad { + path: PathBuf::from(p.str_req("path")?), + }), + "capture.screenshot" => core(CoreOp::Screenshot { + path: p.str_opt("path")?.map(PathBuf::from), + }), + "capture.digest" => core(CoreOp::Digest), + "machine.reset" => host(HostOp::Reset { + warm: match p.str_opt("kind")?.as_deref() { + None | Some("warm") => true, + Some("cold") => false, + Some(other) => { + return Err(CtlError::invalid_params(format!( + "kind must be warm|cold, got {other}" + ))) + } + }, + }), + other => Err(CtlError::method_not_found(other)), + } +} + +fn resume(kind: ResumeKind, p: &ParamReader) -> Result { + Ok(Request::Host(HostOp::Resume(ResumeVerb { + kind, + collect: parse_collect(p)?, + }))) +} + +fn parse_collect(p: &ParamReader) -> Result, CtlError> { + let Some(items) = p.get("collect") else { + return Ok(Vec::new()); + }; + let Some(items) = items.as_array() else { + return Err(CtlError::invalid_params("collect must be an array")); + }; + let mut ops = Vec::with_capacity(items.len()); + for item in items { + let Some(method) = item.get("method").and_then(Value::as_str) else { + return Err(CtlError::invalid_params( + "collect entries are {method, params?} objects", + )); + }; + let params = item.get("params").cloned().unwrap_or(Value::Null); + match parse_method(method, ¶ms)? { + Request::Core(op) if op.collectable() => ops.push(op), + _ => { + return Err(CtlError::invalid_params(format!( + "method not allowed in collect: {method}" + ))) + } + } + } + Ok(ops) +} + +fn parse_run_target(p: &ParamReader) -> Result { + let mut targets = Vec::new(); + if let Some(pc) = p.u32_opt("pc")? { + targets.push(RunTarget::Pc(pc)); + } + if let Some(vpos) = p.u32_opt("vpos")? { + targets.push(RunTarget::Beam { + vpos: vpos as u16, + hpos: p.u32_opt("hpos")?.map(|h| h as u16), + }); + } + if let Some(frame) = p.u64_opt("frame")? { + targets.push(RunTarget::Frame(frame)); + } + if let Some(cck) = p.u64_opt("cck")? { + targets.push(RunTarget::Cck(cck)); + } + if let Some(secs) = p.f64_opt("seconds")? { + targets.push(RunTarget::Seconds(secs)); + } + match targets.len() { + 1 => Ok(targets.remove(0)), + 0 => Err(CtlError::invalid_params( + "run_until needs exactly one of pc, vpos[+hpos], frame, cck, seconds", + )), + _ => Err(CtlError::invalid_params( + "run_until takes exactly one target", + )), + } +} + +fn parse_break_spec(p: &ParamReader) -> Result { + match p.str_req("kind")?.as_str() { + "pc" => Ok(BreakSpec::Pc { + addr: p.u32_req("addr")?, + cond: match p.get("cond") { + None | Some(Value::Null) => None, + Some(cond) => Some(parse_break_cond(cond)?), + }, + ignore: p.u32_or("ignore", 0)?, + }), + "watch" => { + Ok(BreakSpec::Watch { + addr: p.u32_req("addr")?, + source: match p.str_opt("class")? { + None => None, + Some(token) => Some(WatchSource::parse(&token).ok_or_else(|| { + CtlError::invalid_params("class must be cpu|blitter|disk") + })?), + }, + }) + } + "reg_watch" => Ok(BreakSpec::RegWatch { + off: parse_custom_reg_param(p)?, + }), + "beam" => Ok(BreakSpec::Beam { + vpos: p.u32_req("vpos")? as u16, + hpos: p.u32_opt("hpos")?.map(|h| h as u16), + }), + "copper" => Ok(BreakSpec::Copper { + addr: p.u32_req("addr")?, + }), + "catch" => Ok(BreakSpec::Catch { + vector: p.u32_req("vector")? as u16, + }), + other => Err(CtlError::invalid_params(format!( + "kind must be pc|watch|reg_watch|beam|copper|catch, got {other}" + ))), + } +} + +fn parse_break_cond(cond: &Value) -> Result { + let get = |key: &str| { + cond.get(key) + .ok_or_else(|| CtlError::invalid_params(format!("cond needs {key}"))) + }; + let op = match get("op")?.as_str().unwrap_or_default() { + "eq" => CondOp::Eq, + "ne" => CondOp::Ne, + "lt" => CondOp::Lt, + "gt" => CondOp::Gt, + "le" => CondOp::Le, + "ge" => CondOp::Ge, + "and" => CondOp::And, + other => { + return Err(CtlError::invalid_params(format!( + "cond op must be eq|ne|lt|gt|le|ge|and, got {other:?}" + ))) + } + }; + Ok(BreakCond { + lhs: parse_cond_operand(get("lhs")?)?, + op, + rhs: parse_cond_operand(get("rhs")?)?, + }) +} + +fn parse_cond_operand(v: &Value) -> Result { + if let Some(imm) = value_as_u32(v) { + return Ok(CondOperand::Imm(imm)); + } + if let Some(mem) = v.get("mem") { + return value_as_u32(mem) + .map(CondOperand::Mem) + .ok_or_else(|| CtlError::invalid_params("cond mem operand needs an address")); + } + let Some(name) = v.as_str() else { + return Err(CtlError::invalid_params( + "cond operand must be a register name, a number, or {mem: addr}", + )); + }; + let lower = name.to_ascii_lowercase(); + match lower.as_str() { + "pc" => Ok(CondOperand::Pc), + "sr" => Ok(CondOperand::Sr), + _ => { + let reg = parse_reg_name(&lower)?; + Ok(match reg { + 0..=7 => CondOperand::Data(reg), + 8..=15 => CondOperand::Addr(reg - 8), + _ => unreachable!("parse_reg_name yields 0..=17"), + }) + } + } +} + +/// Parse a register selector into the GDB-style register number +/// (D0-D7 = 0-7, A0-A7 = 8-15, SR = 16, PC = 17) used by +/// `debug_register` / `debug_set_register`. +fn parse_reg_name(name: &str) -> Result { + let lower = name.to_ascii_lowercase(); + let bytes = lower.as_bytes(); + match bytes { + b"pc" => return Ok(17), + b"sr" => return Ok(16), + b"sp" => return Ok(15), + _ => {} + } + if bytes.len() == 2 && bytes[1].is_ascii_digit() { + let n = (bytes[1] - b'0') as usize; + if n < 8 { + match bytes[0] { + b'd' => return Ok(n), + b'a' => return Ok(8 + n), + _ => {} + } + } + } + Err(CtlError::invalid_params(format!( + "unknown register: {name} (want d0-d7, a0-a7, sp, sr, pc)" + ))) +} + +/// A custom register selector: a name ("DMACON"), or an offset as a +/// number or hex string. +fn parse_custom_reg_param(p: &ParamReader) -> Result { + let Some(v) = p.get("reg") else { + return Err(CtlError::invalid_params("needs reg (name or offset)")); + }; + if let Some(s) = v.as_str() { + return crate::gdbstub::parse_custom_reg(&s.to_ascii_uppercase()) + .ok_or_else(|| CtlError::invalid_params(format!("unknown custom register: {s}"))); + } + match value_as_u32(v) { + Some(off) if off < 0x200 => Ok((off as u16) & !1), + _ => Err(CtlError::invalid_params("reg offset must be below 0x200")), + } +} + +/// Numbers are decimal values; strings are hex with an optional `0x` or +/// `$` prefix (the notation every Amiga reference uses for addresses). +fn value_as_u32(v: &Value) -> Option { + value_as_u64(v).and_then(|n| u32::try_from(n).ok()) +} + +fn value_as_u64(v: &Value) -> Option { + if let Some(n) = v.as_u64() { + return Some(n); + } + let s = v.as_str()?.trim(); + let hex = s + .strip_prefix("0x") + .or_else(|| s.strip_prefix("0X")) + .or_else(|| s.strip_prefix('$')) + .unwrap_or(s); + u64::from_str_radix(hex, 16).ok() +} + +/// Typed access to the params object with uniform error messages. +struct ParamReader<'a> { + obj: Option<&'a Map>, +} + +impl<'a> ParamReader<'a> { + fn new(params: &'a Value) -> Result { + match params { + Value::Null => Ok(Self { obj: None }), + Value::Object(map) => Ok(Self { obj: Some(map) }), + _ => Err(CtlError::invalid_params("params must be an object")), + } + } + + fn get(&self, key: &str) -> Option<&'a Value> { + self.obj.and_then(|o| o.get(key)) + } + + fn u32_req(&self, key: &str) -> Result { + self.u32_opt(key)? + .ok_or_else(|| CtlError::invalid_params(format!("missing {key}"))) + } + + fn u32_opt(&self, key: &str) -> Result, CtlError> { + match self.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => value_as_u32(v) + .map(Some) + .ok_or_else(|| CtlError::invalid_params(format!("bad {key}"))), + } + } + + fn u32_or(&self, key: &str, default: u32) -> Result { + Ok(self.u32_opt(key)?.unwrap_or(default)) + } + + fn u64_opt(&self, key: &str) -> Result, CtlError> { + match self.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => value_as_u64(v) + .map(Some) + .ok_or_else(|| CtlError::invalid_params(format!("bad {key}"))), + } + } + + fn usize_req(&self, key: &str) -> Result { + Ok(self.u32_req(key)? as usize) + } + + fn usize_or(&self, key: &str, default: usize) -> Result { + Ok(self.u64_opt(key)?.map(|n| n as usize).unwrap_or(default)) + } + + fn i32_or(&self, key: &str, default: i32) -> Result { + match self.get(key) { + None | Some(Value::Null) => Ok(default), + Some(v) => v + .as_i64() + .and_then(|n| i32::try_from(n).ok()) + .ok_or_else(|| CtlError::invalid_params(format!("bad {key}"))), + } + } + + fn f64_opt(&self, key: &str) -> Result, CtlError> { + match self.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => v + .as_f64() + .map(Some) + .ok_or_else(|| CtlError::invalid_params(format!("bad {key}"))), + } + } + + fn bool_opt(&self, key: &str) -> Result, CtlError> { + match self.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => v + .as_bool() + .map(Some) + .ok_or_else(|| CtlError::invalid_params(format!("bad {key}"))), + } + } + + fn bool_or(&self, key: &str, default: bool) -> Result { + Ok(self.bool_opt(key)?.unwrap_or(default)) + } + + fn str_req(&self, key: &str) -> Result { + self.str_opt(key)? + .ok_or_else(|| CtlError::invalid_params(format!("missing {key}"))) + } + + fn str_opt(&self, key: &str) -> Result, CtlError> { + match self.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => v + .as_str() + .map(|s| Some(s.to_string())) + .ok_or_else(|| CtlError::invalid_params(format!("bad {key}"))), + } + } +} + +// --------------------------------------------------------------------- +// Execution + +/// Execute a [`CoreOp`] against the machine. Both server modes call +/// this for everything that is not run control, input, or media. +pub fn exec_core(emu: &mut Emulator, ctx: &mut SessionCtx, op: &CoreOp) -> Result { + match op { + CoreOp::Status => Ok(status_value(emu, ctx)), + CoreOp::RegsGet => Ok(regs_value(emu)), + CoreOp::RegsSet { reg, value } => { + if !emu.machine.debug_set_register(*reg, *value) { + return Err(CtlError::invalid_params("register write refused")); + } + emu.machine.refresh_irq_line(); + Ok(json!({})) + } + CoreOp::MemRead { addr, len, base64 } => { + let bytes = emu.machine.debug_read_memory(*addr, *len); + let data = if *base64 { + proto::encode_base64(&bytes) + } else { + proto::encode_hex(&bytes) + }; + Ok(json!({"addr": addr, "len": bytes.len(), "data": data})) + } + CoreOp::MemWrite { addr, data } => { + let written = emu.machine.debug_write_memory(*addr, data); + // Rebaseline memory watches so the poke itself does not fire + // them (matching the GDB stub's refresh after `M`). + emu.machine.ui_rebaseline_watches(); + let mut result = json!({"written": written}); + if emu.time_travel_enabled() { + // Memory pokes are not part of the replay journal, so a + // reverse replay across this write can diverge. + result["replay_unsafe"] = Value::Bool(true); + } + Ok(result) + } + CoreOp::Disasm { addr, count } => { + let cpu_type = emu.machine.cpu_type(); + let mut pc = addr.unwrap_or_else(|| emu.machine.pc()); + let bus = emu.machine.bus(); + let mut lines = Vec::with_capacity(*count); + for _ in 0..*count { + let (text, len) = + crate::disasm::disassemble(|a| bus.peek_word_any(a), pc, cpu_type); + lines.push(json!({"addr": pc, "text": text, "len": len})); + pc = pc.wrapping_add(len); + } + Ok(json!({"lines": lines})) + } + CoreOp::CustomDump => { + let bus = emu.bus(); + let mut regs = Map::new(); + for off in (0u16..0x200).step_by(2) { + if let Some(value) = bus.debug_custom_word(off) { + regs.insert(crate::debugger::custom_reg_name(off), Value::from(value)); + } + } + Ok(json!({"regs": regs})) + } + CoreOp::CustomRead { off } => match emu.bus().debug_custom_word(*off) { + Some(value) => Ok(json!({ + "off": off, + "name": crate::debugger::custom_reg_name(*off), + "value": value, + })), + None => Err(CtlError::not_found(format!( + "custom register ${off:03X} is not readable" + ))), + }, + CoreOp::CiaGet { b } => { + let bus = emu.bus(); + let cia = if *b { &bus.cia_b } else { &bus.cia_a }; + let regs: Vec = (0..16).map(|r| cia.peek_register(r)).collect(); + Ok(json!({ + "cia": if *b { "b" } else { "a" }, + "regs": regs, + "icr_data": cia.debug_icr_data(), + "timer_a": { + "count": cia.ta_count, "latch": cia.ta_latch, + "running": cia.ta_running, "oneshot": cia.ta_oneshot, + }, + "timer_b": { + "count": cia.tb_count, "latch": cia.tb_latch, + "running": cia.tb_running, "oneshot": cia.tb_oneshot, + }, + })) + } + CoreOp::BeamGet => { + let bus = emu.bus(); + Ok(json!({ + "vpos": bus.agnus.vpos, + "hpos": bus.agnus.hpos, + "cck": bus.emulated_cck(), + "frame": bus.emulated_frames(), + "seconds": bus.emulated_seconds(), + })) + } + CoreOp::DisplayGet => { + let bus = emu.bus(); + Ok(json!({ + "dmacon": bus.debug_dmacon(), + "display": bus.debug_display_state(), + })) + } + CoreOp::CopperList { addr, max } => { + let bus = emu.bus(); + let copper_pc = bus.copper.pc(); + let start = addr.unwrap_or_else(|| copper_pc.saturating_sub(4 * 4)); + let entries: Vec = crate::disasm::dump_copper_list( + |a| bus.peek_word_any(a), + start, + *max, + ) + .into_iter() + .map(|(addr, text)| json!({"addr": addr, "text": text, "cursor": addr == copper_pc})) + .collect(); + Ok(json!({ + "cop1lc": bus.agnus.cop1lc, + "cop2lc": bus.agnus.cop2lc, + "coppc": copper_pc, + "running": bus.copper.is_running(), + "entries": entries, + })) + } + CoreOp::LastWriter { addr } => { + require_time_travel(emu)?; + let before = emu.retired_instructions(); + let outcome = emu + .tt_last_writer(*addr, before) + .map_err(|e| CtlError::internal(format!("last-writer scan: {e:#}")))?; + let (outcome_name, record) = match outcome { + ReverseOutcome::Found(rec) => ( + "found", + Some(json!({ + "addr": rec.addr, "old": rec.old, "new": rec.new, + "pc": rec.pc, "pos": rec.pos, "cck": rec.cck, + "frame": rec.frame, + })), + ), + ReverseOutcome::NotFound => ("never_written", None), + ReverseOutcome::BeyondHistory => ("beyond_history", None), + }; + // On "found" the machine is parked at the writing + // instruction; always report where it ended up. + let position = stop_snapshot(emu, "last_writer", &format!("${addr:06X}")); + let mut result = json!({ + "outcome": outcome_name, + "position": serde_json::to_value(&position) + .map_err(|e| CtlError::internal(e.to_string()))?, + }); + if let Some(record) = record { + result["record"] = record; + } + Ok(result) + } + CoreOp::PcHistory => Ok(json!({"pcs": emu.machine.ui_pc_history()})), + CoreOp::BreakAdd(spec) => match ctx.install_break(emu, spec.clone()) { + Ok(id) => Ok(json!({"id": id})), + Err(msg) => Err(CtlError::invalid_params(msg)), + }, + CoreOp::BreakRemove { id } => { + if ctx.remove_break(emu, *id) { + Ok(json!({})) + } else { + Err(CtlError::not_found(format!("no break with id {id}"))) + } + } + CoreOp::BreakList => Ok(break_list_value(emu, ctx)), + CoreOp::BreakClear => { + emu.machine.ui_breaks_clear(); + // ui_breaks_clear covers the bus mirrors for reg/mem watches + // but beam traps and copper breaks live only bus-side. + emu.bus_mut().ui_clear_beam_traps(); + emu.bus_mut().ui_clear_copper_breaks(); + let ids: Vec = ctx.breaks().map(|(id, _)| id).collect(); + for id in ids { + ctx.remove_break(emu, id); + } + Ok(json!({})) + } + CoreOp::FloppyQuery => { + let floppy = &emu.bus().floppy; + let drives: Vec = (0..4) + .map(|idx| { + json!({ + "drive": idx, + "inserted": floppy.disk_inserted(idx), + "name": floppy.inserted_disk_name(idx), + }) + }) + .collect(); + Ok(json!({"drives": drives})) + } + CoreOp::StateSave { path } => { + emu.save_state(path) + .map_err(|e| CtlError::io(format!("saving state: {e:#}")))?; + Ok(json!({"path": path.display().to_string()})) + } + CoreOp::Digest => { + let (fb, lines) = render_frame(emu); + let digest = fnv1a64(&fb[..FB_WIDTH * lines]); + Ok(json!({ + "algo": "fnv1a64", + "digest": format!("{digest:016x}"), + "width": FB_WIDTH, + "height": lines, + "frame": emu.bus().emulated_frames(), + })) + } + CoreOp::Screenshot { path } => { + let (fb, lines) = render_frame(emu); + let path = path + .clone() + .unwrap_or_else(crate::screenshot::auto_filename); + crate::screenshot::save( + &path, + &fb[..FB_WIDTH * lines], + FB_WIDTH as u32, + lines as u32, + ) + .map_err(|e| CtlError::io(format!("saving screenshot: {e:#}")))?; + Ok(json!({ + "path": path.display().to_string(), + "width": FB_WIDTH, + "height": lines, + })) + } + CoreOp::ReverseStep { n } => { + require_time_travel(emu)?; + let outcome = emu + .tt_reverse_step(*n) + .map_err(|e| CtlError::internal(format!("reverse step: {e:#}")))?; + // Found carries the new (earlier) position the machine was + // left at, same as the stop event's retired_instructions. + reverse_result( + emu, + map_outcome(outcome, |pos| format!("stepped back {n} to position {pos}")), + ) + } + CoreOp::ReverseFrame => { + require_time_travel(emu)?; + let outcome = emu + .tt_reverse_frame() + .map_err(|e| CtlError::internal(format!("reverse frame: {e:#}")))?; + reverse_result( + emu, + map_outcome(outcome, |pos| { + format!("stepped back one frame to position {pos}") + }), + ) + } + CoreOp::ReverseContinue => { + require_time_travel(emu)?; + let outcome = emu + .tt_reverse_continue() + .map_err(|e| CtlError::internal(format!("reverse continue: {e:#}")))?; + reverse_result(emu, map_outcome(outcome, |(_, desc)| desc)) + } + } +} + +/// Evaluate a `collect` list at a stop; each entry independently +/// reports `{ok: result}` or `{err: {code, message}}` so one failing +/// item cannot poison the whole stop event. +pub fn eval_collect(emu: &mut Emulator, ctx: &mut SessionCtx, items: &[CoreOp]) -> Vec { + items + .iter() + .map(|op| match exec_core(emu, ctx, op) { + Ok(value) => json!({"ok": value}), + Err(err) => json!({"err": {"code": err.code, "message": err.message}}), + }) + .collect() +} + +/// The consistent stop coordinate every resume verb returns. +pub fn stop_snapshot(emu: &Emulator, reason: &str, detail: &str) -> StopEvent { + let bus = emu.bus(); + StopEvent { + reason: reason.to_string(), + detail: detail.to_string(), + pc: emu.machine.pc(), + frame: bus.emulated_frames(), + vpos: bus.agnus.vpos.min(u32::from(u16::MAX)) as u16, + hpos: bus.agnus.hpos.min(u32::from(u16::MAX)) as u16, + cck: bus.emulated_cck(), + seconds: bus.emulated_seconds(), + retired_instructions: emu.retired_instructions(), + collect: None, + } +} + +/// Map a machine [`DebugStop`] onto the protocol's stop reason plus its +/// human-readable description. +pub fn stop_reason_of(stop: &DebugStop) -> (&'static str, String) { + let reason = match stop { + DebugStop::Breakpoint { .. } => "breakpoint", + DebugStop::Watch { .. } => "watchpoint", + DebugStop::ChipReg { .. } => "reg_watch", + DebugStop::Beam { .. } => "beam_trap", + DebugStop::CopperBreak { .. } => "copper_break", + DebugStop::Exception { .. } => "catch", + DebugStop::Task { .. } => "task_catch", + }; + (reason, stop.describe()) +} + +fn require_time_travel(emu: &Emulator) -> Result<(), CtlError> { + if emu.time_travel_enabled() { + Ok(()) + } else { + Err(CtlError::invalid_state( + "time travel is not armed on this session", + )) + } +} + +fn map_outcome( + outcome: ReverseOutcome, + describe: impl FnOnce(T) -> String, +) -> ReverseOutcome { + match outcome { + ReverseOutcome::Found(v) => ReverseOutcome::Found(describe(v)), + ReverseOutcome::NotFound => ReverseOutcome::NotFound, + ReverseOutcome::BeyondHistory => ReverseOutcome::BeyondHistory, + } +} + +fn reverse_result(emu: &Emulator, outcome: ReverseOutcome) -> Result { + match outcome { + ReverseOutcome::Found(detail) => { + let stop = stop_snapshot(emu, "reverse", &detail); + serde_json::to_value(&stop).map_err(|e| CtlError::internal(e.to_string())) + } + ReverseOutcome::NotFound | ReverseOutcome::BeyondHistory => Err(CtlError::new( + proto::HISTORY_EXHAUSTED, + "no retained history at that distance", + )), + } +} + +fn status_value(emu: &Emulator, ctx: &SessionCtx) -> Value { + let bus = emu.bus(); + json!({ + "state": if ctx.running { "running" } else { "paused" }, + "pending_resume": ctx.pending, + "powered_on": ctx.powered_on, + "double_faulted": emu.machine.cpu_double_faulted(), + "cpu": format!("{:?}", emu.machine.cpu_type()), + "cpu_stopped": emu.machine.stopped(), + "tt_armed": emu.time_travel_enabled(), + "pc": emu.machine.pc(), + "frame": bus.emulated_frames(), + "vpos": bus.agnus.vpos, + "hpos": bus.agnus.hpos, + "cck": bus.emulated_cck(), + "seconds": bus.emulated_seconds(), + "retired_instructions": emu.retired_instructions(), + }) +} + +fn regs_value(emu: &Emulator) -> Value { + let machine = &emu.machine; + let d: Vec = (0..8).map(|n| machine.d(n)).collect(); + let a: Vec = (0..8).map(|n| machine.a(n)).collect(); + json!({ + "d": d, + "a": a, + "pc": machine.pc(), + "sr": machine.sr(), + "stopped": machine.stopped(), + }) +} + +fn break_list_value(emu: &Emulator, ctx: &SessionCtx) -> Value { + let mut entries = Vec::new(); + let breaks = emu.machine.ui_breaks(); + for bp in &breaks.breakpoints { + let spec = BreakSpec::Pc { + addr: bp.addr, + cond: bp.cond, + ignore: bp.ignore, + }; + let mut entry = json!({ + "kind": "pc", + "addr": bp.addr, + "ignore": bp.ignore, + "hits": bp.hits, + }); + if let Some(cond) = &bp.cond { + entry["cond"] = Value::from(cond.describe()); + } + push_id(&mut entry, ctx.id_for(&spec)); + entries.push(entry); + } + for w in &breaks.watches { + let spec = BreakSpec::Watch { + addr: w.addr, + source: None, + }; + let mut entry = json!({"kind": "watch", "addr": w.addr}); + push_id(&mut entry, ctx.id_for(&spec)); + entries.push(entry); + } + for &off in &breaks.reg_watches { + let mut entry = json!({ + "kind": "reg_watch", + "off": off, + "name": crate::debugger::custom_reg_name(off), + }); + push_id(&mut entry, ctx.id_for(&BreakSpec::RegWatch { off })); + entries.push(entry); + } + for &vector in &breaks.catches { + let mut entry = json!({ + "kind": "catch", + "vector": vector, + "name": crate::debugger::exception_vector_name(vector), + }); + push_id(&mut entry, ctx.id_for(&BreakSpec::Catch { vector })); + entries.push(entry); + } + for trap in emu.bus().ui_beam_traps() { + if trap.once { + continue; // internal one-shot run-to-position trap + } + let mut entry = json!({"kind": "beam", "vpos": trap.vpos}); + if let Some(hpos) = trap.hpos { + entry["hpos"] = Value::from(hpos); + } + push_id( + &mut entry, + ctx.id_for(&BreakSpec::Beam { + vpos: trap.vpos, + hpos: trap.hpos, + }), + ); + entries.push(entry); + } + for &addr in emu.bus().ui_copper_breaks() { + let mut entry = json!({"kind": "copper", "addr": addr}); + push_id(&mut entry, ctx.id_for(&BreakSpec::Copper { addr })); + entries.push(entry); + } + json!({"breaks": entries}) +} + +fn push_id(entry: &mut Value, id: Option) { + if let Some(id) = id { + entry["id"] = Value::from(id); + } +} + +/// Render the current frame into a fresh buffer via the side-effect-free +/// display path, returning the buffer and its visible line count. Both +/// `capture.digest` and `capture.screenshot` use this in BOTH server +/// modes, so captures are mode-identical and comparable. +fn render_frame(emu: &Emulator) -> (Vec, usize) { + let mut fb = vec![0u32; MAX_FB_PIXELS]; + crate::video::bitplane::render_display_only(emu.bus(), &mut fb); + let lines = emu.bus().frame_geometry().visible_lines; + (fb, lines) +} + +/// FNV-1a over the framebuffer words (little-endian byte order), for +/// cheap change detection without pulling pixels over the wire. +fn fnv1a64(words: &[u32]) -> u64 { + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for word in words { + for byte in word.to_le_bytes() { + hash ^= u64::from(byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + } + hash +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::control::test_emulator; + use serde_json::json; + + fn core(method: &str, params: Value) -> CoreOp { + match parse_method(method, ¶ms).expect("parse should succeed") { + Request::Core(op) => op, + other => panic!("expected core op, got {other:?}"), + } + } + + #[test] + fn parse_accepts_hex_strings_and_numbers_for_addresses() { + assert_eq!( + core("mem.read", json!({"addr": "$F80010", "len": 4})), + CoreOp::MemRead { + addr: 0xF80010, + len: 4, + base64: false + } + ); + assert_eq!( + core("mem.read", json!({"addr": "0xF80010"})), + CoreOp::MemRead { + addr: 0xF80010, + len: 2, + base64: false + } + ); + assert_eq!( + core("disasm", json!({"addr": 16252944, "count": 2})), + CoreOp::Disasm { + addr: Some(0xF80010), + count: 2 + } + ); + } + + #[test] + fn parse_run_until_requires_exactly_one_target() { + assert!(parse_method("run_until", &json!({})).is_err()); + assert!(parse_method("run_until", &json!({"pc": 16, "frame": 3})).is_err()); + match parse_method("run_until", &json!({"vpos": 100, "hpos": 60})).unwrap() { + Request::Host(HostOp::Resume(verb)) => assert_eq!( + verb.kind, + ResumeKind::RunUntil(RunTarget::Beam { + vpos: 100, + hpos: Some(60) + }) + ), + other => panic!("expected resume, got {other:?}"), + } + } + + #[test] + fn parse_collect_whitelists_read_only_ops() { + let params = json!({"collect": [ + {"method": "regs.get"}, + {"method": "mem.read", "params": {"addr": 0, "len": 8}}, + ]}); + match parse_method("continue", ¶ms).unwrap() { + Request::Host(HostOp::Resume(verb)) => assert_eq!(verb.collect.len(), 2), + other => panic!("expected resume, got {other:?}"), + } + let bad = json!({"collect": [ + {"method": "mem.write", "params": {"addr": 0, "data": "00"}}, + ]}); + let err = parse_method("continue", &bad).unwrap_err(); + assert_eq!(err.code, proto::INVALID_PARAMS); + } + + #[test] + fn parse_unknown_method_reports_method_not_found() { + let err = parse_method("warp.nine", &Value::Null).unwrap_err(); + assert_eq!(err.code, proto::METHOD_NOT_FOUND); + } + + #[test] + fn regs_set_and_get_round_trip() { + let mut emu = test_emulator(); + let mut ctx = SessionCtx::new(); + exec_core( + &mut emu, + &mut ctx, + &core("regs.set", json!({"reg": "d3", "value": 0xCAFE})), + ) + .unwrap(); + let regs = exec_core(&mut emu, &mut ctx, &CoreOp::RegsGet).unwrap(); + assert_eq!(regs["d"][3], 0xCAFE); + assert_eq!(regs["pc"], 0xF80010); + } + + #[test] + fn mem_write_and_read_round_trip_across_encodings() { + let mut emu = test_emulator(); + let mut ctx = SessionCtx::new(); + let write = exec_core( + &mut emu, + &mut ctx, + &core( + "mem.write", + json!({"addr": 0x30000, "data": "deadbeef0102"}), + ), + ) + .unwrap(); + assert_eq!(write["written"], 6); + assert!(write.get("replay_unsafe").is_none(), "tt is not armed"); + let read = exec_core( + &mut emu, + &mut ctx, + &core( + "mem.read", + json!({"addr": 0x30000, "len": 6, "encoding": "base64"}), + ), + ) + .unwrap(); + assert_eq!( + proto::decode_base64(read["data"].as_str().unwrap()).unwrap(), + vec![0xde, 0xad, 0xbe, 0xef, 0x01, 0x02] + ); + } + + #[test] + fn break_ids_track_the_machine_store() { + let mut emu = test_emulator(); + let mut ctx = SessionCtx::new(); + let add = core("break.add", json!({"kind": "pc", "addr": "$F8001A"})); + let id = exec_core(&mut emu, &mut ctx, &add).unwrap()["id"] + .as_u64() + .unwrap() as u32; + assert!(emu.machine.ui_breaks().is_breakpoint(0xF8001A)); + + // Duplicate installs are refused, not silently toggled away. + let err = exec_core(&mut emu, &mut ctx, &add).unwrap_err(); + assert_eq!(err.code, proto::INVALID_PARAMS); + assert!(emu.machine.ui_breaks().is_breakpoint(0xF8001A)); + + let list = exec_core(&mut emu, &mut ctx, &CoreOp::BreakList).unwrap(); + assert_eq!(list["breaks"][0]["kind"], "pc"); + assert_eq!(list["breaks"][0]["id"], id); + + exec_core(&mut emu, &mut ctx, &CoreOp::BreakRemove { id }).unwrap(); + assert!(!emu.machine.ui_breaks().is_breakpoint(0xF8001A)); + let err = exec_core(&mut emu, &mut ctx, &CoreOp::BreakRemove { id }).unwrap_err(); + assert_eq!(err.code, proto::NOT_FOUND); + } + + #[test] + fn break_list_reports_gui_set_points_without_ids() { + let mut emu = test_emulator(); + let mut ctx = SessionCtx::new(); + emu.machine.ui_set_breakpoint(0xF80014, None, 0); + let list = exec_core(&mut emu, &mut ctx, &CoreOp::BreakList).unwrap(); + assert_eq!(list["breaks"][0]["addr"], 0xF80014); + assert!(list["breaks"][0].get("id").is_none()); + } + + #[test] + fn digest_is_stable_without_stepping() { + let mut emu = test_emulator(); + let mut ctx = SessionCtx::new(); + let a = exec_core(&mut emu, &mut ctx, &CoreOp::Digest).unwrap(); + let b = exec_core(&mut emu, &mut ctx, &CoreOp::Digest).unwrap(); + assert_eq!(a["digest"], b["digest"]); + assert_eq!(a["width"], FB_WIDTH); + } + + #[test] + fn collect_evaluates_each_item_independently() { + let mut emu = test_emulator(); + let mut ctx = SessionCtx::new(); + let items = vec![ + CoreOp::RegsGet, + CoreOp::CustomRead { off: 0x1FF }, // odd/unreadable: per-item err + CoreOp::BeamGet, + ]; + let results = eval_collect(&mut emu, &mut ctx, &items); + assert_eq!(results.len(), 3); + assert!(results[0].get("ok").is_some()); + assert!(results[1].get("err").is_some()); + assert!(results[2].get("ok").is_some()); + } + + #[test] + fn status_reports_position_and_host_flags() { + let mut emu = test_emulator(); + let mut ctx = SessionCtx::new(); + ctx.running = true; + ctx.pending = true; + let status = exec_core(&mut emu, &mut ctx, &CoreOp::Status).unwrap(); + assert_eq!(status["state"], "running"); + assert_eq!(status["pending_resume"], true); + assert_eq!(status["pc"], 0xF80010); + assert_eq!(status["tt_armed"], false); + } + + #[test] + fn reverse_without_time_travel_is_an_invalid_state() { + let mut emu = test_emulator(); + let mut ctx = SessionCtx::new(); + let err = exec_core(&mut emu, &mut ctx, &CoreOp::ReverseStep { n: 1 }).unwrap_err(); + assert_eq!(err.code, proto::INVALID_STATE); + } + + #[test] + fn input_key_tap_expands_to_press_plus_scheduled_release() { + let cmd = InputCmd::Key { + rawkey: 0x45, + kind: KeyKind::Tap { hold_ms: 100 }, + at_seconds: None, + }; + let (now, later) = cmd.expand(2.0); + assert_eq!( + now, + vec![InputAction::Key { + rawkey: 0x45, + pressed: true + }] + ); + assert_eq!(later.len(), 1); + assert!((later[0].at_seconds - 2.1).abs() < 1e-9); + assert_eq!( + later[0].action, + InputAction::Key { + rawkey: 0x45, + pressed: false + } + ); + } + + #[test] + fn stop_reason_mapping_names_the_hardware_event() { + let (reason, detail) = stop_reason_of(&DebugStop::Beam { + vpos: 100, + hpos: 60, + }); + assert_eq!(reason, "beam_trap"); + assert!(detail.contains("100")); + let (reason, _) = stop_reason_of(&DebugStop::Breakpoint { pc: 0x1000 }); + assert_eq!(reason, "breakpoint"); + } +} diff --git a/src/control/headless.rs b/src/control/headless.rs new file mode 100644 index 00000000..cabce222 --- /dev/null +++ b/src/control/headless.rs @@ -0,0 +1,1162 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! The headless control server (`--control ADDR`): owns the [`Emulator`] +//! and drives it directly from the connection, exactly the GDB stub's +//! ownership shape -- one client at a time, the machine paused between +//! sessions, the Emulator moved back out of a finished session for the +//! next one. +//! +//! Determinism: the machine starts paused at power-on and advances only +//! on resume verbs. While running, the socket is polled once per +//! quantum (a frame, or an instruction chunk for `run_until pc`); every +//! deterministic stop condition is still detected per instruction by +//! the core, so poll cadence only affects where a host-timed `pause` +//! lands -- which is inherently wall-clock, like a GDB Ctrl-C. + +use super::exec::{ + self, HostOp, Request, ResumeKind, ResumeVerb, RunTarget, CCK_FINE_WINDOW, RUN_BUDGET, +}; +use super::proto::{self, AuthGate, CtlError, Gate, MAX_LINE_BYTES}; +use super::session::SessionCtx; +use super::Config; +use crate::debugger::DebugStop; +use crate::emulator::Emulator; +use crate::inputrec::InputRecorder; +use crate::inputsched::ReplayAction; +use anyhow::{Context, Result}; +use serde_json::{json, Value}; +use std::io::{self, Read}; +use std::net::{TcpListener, TcpStream}; +use std::time::Duration; + +/// Instructions executed between socket polls when running to a PC +/// target instruction-by-instruction. At emulated 68000 speeds this is +/// well under a frame of host time. +const PC_POLL_CHUNK: usize = 4096; + +pub fn run(mut emu: Emulator, config: Config) -> Result<()> { + let bind = crate::gdbstub::normalize_listen_addr(&config.listen)?; + let listener = + TcpListener::bind(&bind).with_context(|| format!("binding control server {bind}"))?; + let local = listener.local_addr().context("resolving control address")?; + let token = config.resolve_token(); + super::announce(&local, &token, config.info_file.as_ref())?; + log::info!("control: listening on {local}"); + + emu.set_paced(false); + emu.enable_time_travel(config.reverse_budget_mb, config.reverse_interval_frames); + emu.debug_ensure_time_travel_anchor()?; + emu.machine.ui_set_pc_history_enabled(true); + + let mut recorder = config + .record_input + .as_ref() + .map(|_| InputRecorder::new(emu.bus().emulated_seconds())); + + loop { + let (stream, peer) = listener.accept().context("accepting control connection")?; + log::info!("control: connection from {peer}"); + stream.set_nodelay(true).ok(); + let mut session = Session::new(emu, stream, &token, &config, recorder.take()); + let end = match session.serve() { + Ok(end) => end, + Err(e) => { + log::warn!("control: session ended with error: {e:#}"); + SessionEnd::Detached + } + }; + session.teardown(); + recorder = session.ctx.recorder.take(); + emu = session.emu; + match end { + SessionEnd::Detached => { + log::info!("control: client detached; machine paused, listening again"); + } + SessionEnd::Killed => break, + } + } + if let (Some(rec), Some(path)) = (recorder, config.record_input.as_ref()) { + let events = rec.events_recorded(); + std::fs::write(path, rec.finish()) + .with_context(|| format!("writing input recording {}", path.display()))?; + log::info!( + "control: input recording saved: {} ({events} events)", + path.display() + ); + } + Ok(()) +} + +/// How a session ended: detach/EOF keeps serving, `shutdown` ends the +/// server. +enum SessionEnd { + Detached, + Killed, +} + +/// A newline-delimited reader whose partial-line buffer survives across +/// poll attempts, so a command split over TCP segments is never lost to +/// a read timeout mid-line. +struct LineReader { + stream: TcpStream, + buf: Vec, +} + +enum Polled { + Line(String), + Empty, + Eof, +} + +impl LineReader { + fn new(stream: TcpStream) -> Self { + Self { + stream, + buf: Vec::new(), + } + } + + /// Extract the next complete, non-blank line from the buffer. + fn take_buffered_line(&mut self) -> io::Result> { + while let Some(pos) = self.buf.iter().position(|&b| b == b'\n') { + let line: Vec = self.buf.drain(..=pos).take(pos).collect(); + if line.iter().all(|b| b.is_ascii_whitespace()) { + continue; + } + let line = String::from_utf8(line).map_err(|_| { + io::Error::new(io::ErrorKind::InvalidData, "control message is not UTF-8") + })?; + return Ok(Some(line)); + } + if self.buf.len() > MAX_LINE_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "control message exceeds the line limit", + )); + } + Ok(None) + } + + /// Block until a full line, EOF, or an error. + fn read_blocking(&mut self) -> io::Result { + self.stream.set_read_timeout(None)?; + loop { + if let Some(line) = self.take_buffered_line()? { + return Ok(Polled::Line(line)); + } + let mut chunk = [0u8; 4096]; + match self.stream.read(&mut chunk) { + Ok(0) => return Ok(Polled::Eof), + Ok(n) => self.buf.extend_from_slice(&chunk[..n]), + Err(e) if e.kind() == io::ErrorKind::Interrupted => {} + Err(e) => return Err(e), + } + } + } + + /// Non-blocking-ish poll used while the machine is running: returns + /// `Empty` when no complete line has arrived yet. + fn poll(&mut self) -> io::Result { + if let Some(line) = self.take_buffered_line()? { + return Ok(Polled::Line(line)); + } + self.stream + .set_read_timeout(Some(Duration::from_millis(1)))?; + let mut chunk = [0u8; 4096]; + match self.stream.read(&mut chunk) { + Ok(0) => Ok(Polled::Eof), + Ok(n) => { + self.buf.extend_from_slice(&chunk[..n]); + Ok(match self.take_buffered_line()? { + Some(line) => Polled::Line(line), + None => Polled::Empty, + }) + } + Err(e) + if e.kind() == io::ErrorKind::WouldBlock + || e.kind() == io::ErrorKind::TimedOut + || e.kind() == io::ErrorKind::Interrupted => + { + Ok(Polled::Empty) + } + Err(e) => Err(e), + } + } +} + +/// What the mid-run socket poll decided. +enum MidRun { + /// Nothing that ends the run; keep going. + Kept, + /// `pause` arrived: end the run, reply to these extra request ids + /// with the stop position too. + Pause, + /// `shutdown` arrived: end the run, then end the server. + Kill, + /// The client vanished. + Lost(SessionEnd), +} + +/// The outcome of driving a resume verb. +enum RunOutcome { + Stop { + reason: String, + detail: String, + /// `pause`/`shutdown` requests that arrived mid-run and also + /// get the stop position as their response. + extra_ids: Vec, + /// End the server after replying (a mid-run `shutdown`). + kill_after: bool, + }, + ClientLost(SessionEnd), +} + +struct Session { + emu: Emulator, + reader: LineReader, + writer: TcpStream, + gate: AuthGate, + ctx: SessionCtx, + reverse_budget_mb: usize, + reverse_interval_frames: u64, +} + +impl Session { + fn new( + emu: Emulator, + stream: TcpStream, + token: &str, + config: &Config, + recorder: Option, + ) -> Self { + let reader = LineReader::new(stream.try_clone().expect("cloning control stream")); + let mut ctx = SessionCtx::new(); + ctx.recorder = recorder; + Self { + emu, + reader, + writer: stream, + gate: AuthGate::new(token.to_string()), + ctx, + reverse_budget_mb: config.reverse_budget_mb, + reverse_interval_frames: config.reverse_interval_frames, + } + } + + fn serve(&mut self) -> Result { + loop { + let line = match self.reader.read_blocking()? { + Polled::Eof => return Ok(SessionEnd::Detached), + Polled::Line(line) => line, + Polled::Empty => continue, + }; + if let Some(end) = self.handle_line(&line)? { + return Ok(end); + } + } + } + + /// Remove everything this session installed and drain any pending + /// stop, so a stale hit cannot ambush the next client. + fn teardown(&mut self) { + self.ctx.remove_all_breaks(&mut self.emu); + self.emu.bus_mut().ui_disarm_beam_trap_once(); + while self.emu.machine.take_ui_debug_stop().is_some() {} + } + + fn write(&mut self, line: &str) -> io::Result<()> { + proto::write_line(&mut self.writer, line) + } + + fn handle_line(&mut self, line: &str) -> Result> { + let req = match proto::parse_request(line) { + Ok(req) => req, + Err(reply) => { + self.write(&reply)?; + return Ok(None); + } + }; + match self.gate.handle(&req) { + Gate::Reply(reply) => { + self.write(&reply)?; + Ok(None) + } + Gate::ReplyAndClose(reply) => { + self.write(&reply)?; + Ok(Some(SessionEnd::Detached)) + } + Gate::Pass => self.dispatch(req), + } + } + + fn dispatch(&mut self, req: proto::RpcRequest) -> Result> { + if req.method == "shutdown" { + self.write(&proto::ok_line(&req.id, json!({})))?; + return Ok(Some(SessionEnd::Killed)); + } + let parsed = match exec::parse_method(&req.method, &req.params) { + Ok(parsed) => parsed, + Err(err) => { + self.write(&proto::err_line(&req.id, &err))?; + return Ok(None); + } + }; + match parsed { + Request::Core(op) => { + let reply = match exec::exec_core(&mut self.emu, &mut self.ctx, &op) { + Ok(result) => proto::ok_line(&req.id, result), + Err(err) => proto::err_line(&req.id, &err), + }; + self.write(&reply)?; + Ok(None) + } + Request::Host(op) => self.dispatch_host(req.id, op), + } + } + + fn dispatch_host(&mut self, id: Value, op: HostOp) -> Result> { + match op { + HostOp::Pause => { + // Already paused: reply with the current position. + let stop = exec::stop_snapshot(&self.emu, "pause", "already paused"); + let value = serde_json::to_value(&stop)?; + self.write(&proto::ok_line(&id, value))?; + Ok(None) + } + HostOp::Resume(verb) => self.run_resume(id, verb), + HostOp::Input(cmd) => { + let now = self.emu.bus().emulated_seconds(); + let (immediate, later) = cmd.expand(now); + for action in immediate { + self.ctx.inject_now(&mut self.emu, action); + } + let scheduled = later.len(); + for entry in later { + self.ctx.schedule(entry.at_seconds, entry.action); + } + self.write(&proto::ok_line( + &id, + json!({"applied_at_seconds": now, "scheduled": scheduled}), + ))?; + Ok(None) + } + HostOp::FloppyInsert { + drive, + path, + write_protected, + } => { + let result = self.emu.bus_mut().floppy.insert_disk_image( + drive, + path.clone(), + write_protected, + ); + let reply = match result { + Ok(()) => { + self.note_media_change(drive, Some(&path)); + let name = self.emu.bus().floppy.inserted_disk_name(drive); + proto::ok_line(&id, json!({"drive": drive, "name": name})) + } + Err(e) => proto::err_line(&id, &CtlError::io(format!("{e:#}"))), + }; + self.write(&reply)?; + Ok(None) + } + HostOp::FloppyEject { drive } => { + let reply = match self.emu.bus_mut().floppy.eject_disk_image(drive) { + Ok(()) => { + self.note_media_change(drive, None); + proto::ok_line(&id, json!({})) + } + Err(e) => proto::err_line(&id, &CtlError::io(format!("{e:#}"))), + }; + self.write(&reply)?; + Ok(None) + } + HostOp::CdInsert { path } => { + let reply = if !self.emu.bus().cd_drive_present() { + proto::err_line(&id, &CtlError::unsupported("no CD drive on this machine")) + } else { + match crate::cdrom::CdImage::load(&path) { + Ok(image) => { + let describe = image.describe(); + self.emu.bus_mut().cd_insert_disc(image); + proto::ok_line(&id, json!({"disc": describe})) + } + Err(e) => proto::err_line(&id, &CtlError::io(format!("{e:#}"))), + } + }; + self.write(&reply)?; + Ok(None) + } + HostOp::CdEject => { + let reply = if !self.emu.bus().cd_drive_present() { + proto::err_line(&id, &CtlError::unsupported("no CD drive on this machine")) + } else { + self.emu.bus_mut().cd_eject_disc(); + proto::ok_line(&id, json!({})) + }; + self.write(&reply)?; + Ok(None) + } + HostOp::StateLoad { path } => { + let reply = match self.emu.load_state(&path) { + Ok(outcome) => { + // The snapshot ring's positions belong to the old + // timeline; re-arm on the loaded one. + self.emu.enable_time_travel( + self.reverse_budget_mb, + self.reverse_interval_frames, + ); + self.emu.debug_ensure_time_travel_anchor()?; + proto::ok_line( + &id, + json!({ + "summary": outcome.summary, + "reconfigured": outcome.reconfigured, + "seconds": self.emu.bus().emulated_seconds(), + }), + ) + } + Err(e) => proto::err_line(&id, &CtlError::io(format!("{e:#}"))), + }; + self.write(&reply)?; + Ok(None) + } + HostOp::Reset { warm } => { + let result = if warm { + self.emu.keyboard_reset() + } else { + self.emu.power_on_reset() + }; + let reply = match result { + Ok(()) => proto::ok_line(&id, json!({})), + Err(e) => proto::err_line(&id, &CtlError::internal(format!("{e:#}"))), + }; + self.write(&reply)?; + Ok(None) + } + } + } + + /// Journal a floppy media change like the window does: note it for + /// reverse replay and record it in the input recording. + fn note_media_change(&mut self, drive: usize, inserted: Option<&std::path::Path>) { + self.emu.tt_note_input(ReplayAction::DiskChange); + if let (Some(rec), Some(path)) = (self.ctx.recorder.as_mut(), inserted) { + let secs = self.emu.bus().emulated_seconds(); + rec.record_disk_insert(drive, path, secs); + } + } + + fn run_resume(&mut self, id: Value, verb: ResumeVerb) -> Result> { + if self.emu.machine.cpu_double_faulted() { + self.write(&proto::err_line( + &id, + &CtlError::invalid_state("CPU is double-faulted; reset the machine"), + ))?; + return Ok(None); + } + self.ctx.running = true; + self.ctx.pending = true; + let outcome = self.drive(&verb.kind); + self.ctx.running = false; + self.ctx.pending = false; + match outcome? { + RunOutcome::Stop { + reason, + detail, + extra_ids, + kill_after, + } => { + let mut stop = exec::stop_snapshot(&self.emu, &reason, &detail); + if !verb.collect.is_empty() { + stop.collect = Some(exec::eval_collect( + &mut self.emu, + &mut self.ctx, + &verb.collect, + )); + } + self.write(&proto::ok_line(&id, serde_json::to_value(&stop)?))?; + // pause/shutdown requests that ended the run get the + // same position (without the collect payload). + let plain = exec::stop_snapshot(&self.emu, &reason, &detail); + let plain = serde_json::to_value(&plain)?; + for extra in extra_ids { + self.write(&proto::ok_line(&extra, plain.clone()))?; + } + Ok(kill_after.then_some(SessionEnd::Killed)) + } + RunOutcome::ClientLost(end) => Ok(Some(end)), + } + } + + /// Drive the machine for one resume verb until a stop condition, + /// polling the socket at quantum boundaries. + fn drive(&mut self, kind: &ResumeKind) -> Result { + let stop = |reason: &str, detail: String| { + Ok(RunOutcome::Stop { + reason: reason.to_string(), + detail, + extra_ids: Vec::new(), + kill_after: false, + }) + }; + let mut cpu_idle = false; + + // Bounded verbs run to completion without polling; they are + // over in at most RUN_BUDGET instructions. + match kind { + ResumeKind::Step { n } => { + for _ in 0..*n { + self.emu.debug_step_for_gdb(&mut cpu_idle)?; + self.ctx.apply_due_scheduled(&mut self.emu); + if let Some((reason, detail)) = self.take_stop() { + return stop(reason, detail); + } + } + return stop("step", format!("{n} instruction(s)")); + } + ResumeKind::StepOver => { + self.emu.debug_step_over(RUN_BUDGET)?; + return self.bounded_result("stepped over"); + } + ResumeKind::StepOut => { + self.emu.debug_step_out(RUN_BUDGET)?; + return self.bounded_result("stepped out"); + } + ResumeKind::StepCopper => { + let advanced = self.emu.debug_step_copper(RUN_BUDGET)?; + if let Some((reason, detail)) = self.take_stop() { + return stop(reason, detail); + } + return if advanced { + stop("step", "copper instruction retired".to_string()) + } else { + stop( + "budget", + "copper did not advance (stopped or DMA off)".to_string(), + ) + }; + } + ResumeKind::StepFrame { n } => { + for _ in 0..*n { + self.emu.step_frame()?; + self.ctx.apply_due_scheduled(&mut self.emu); + if let Some((reason, detail)) = self.take_stop() { + return stop(reason, detail); + } + } + return stop("step", format!("{n} frame(s)")); + } + _ => {} + } + + // Unbounded runs: continue / run_until. One socket poll per + // quantum; interrupted by pause, EOF, or shutdown. + let target = match kind { + ResumeKind::Continue => None, + ResumeKind::RunUntil(target) => Some(*target), + _ => unreachable!("bounded verbs returned above"), + }; + if let Some(RunTarget::Beam { vpos, hpos }) = target { + self.emu.bus_mut().ui_arm_beam_trap_once(vpos, hpos); + } + let cck_target = match target { + Some(RunTarget::Cck(cck)) => Some(cck), + Some(RunTarget::Seconds(secs)) => { + Some((secs * f64::from(crate::chipset::paula::PAULA_CLOCK_HZ)).ceil() as u64) + } + _ => None, + }; + let mut extra_ids = Vec::new(); + let finish = |reason: &str, detail: String, extra_ids: Vec, kill: bool| { + Ok(RunOutcome::Stop { + reason: reason.to_string(), + detail, + extra_ids, + kill_after: kill, + }) + }; + loop { + // Target already met (or met exactly at the last quantum)? + match target { + Some(RunTarget::Pc(pc)) + if self.emu.machine.pc() & 0x00FF_FFFF == pc & 0x00FF_FFFF => + { + return finish("target", format!("pc ${pc:06X}"), extra_ids, false); + } + Some(RunTarget::Frame(frame)) if self.emu.bus().emulated_frames() >= frame => { + return finish("target", format!("frame {frame}"), extra_ids, false); + } + _ => {} + } + if let Some(cck) = cck_target { + if self.emu.bus().emulated_cck() >= cck { + return finish("target", format!("cck {cck}"), extra_ids, false); + } + } + + // One quantum. + match target { + Some(RunTarget::Pc(pc)) => { + // Instruction-granular so the landing is exact; + // debug_step_for_gdb keeps reverse-debug captures + // happening at frame crossings. The hit itself is + // seen by the checks at the top of the loop. + let mask = 0x00FF_FFFF; + for _ in 0..PC_POLL_CHUNK { + self.emu.debug_step_for_gdb(&mut cpu_idle)?; + if self.emu.machine.pc() & mask == pc & mask + || self.emu.machine.ui_debug_stop_pending() + { + break; + } + } + } + _ if cck_target + .is_some_and(|cck| cck - self.emu.bus().emulated_cck() < CCK_FINE_WINDOW) => + { + // Close to a cck/seconds target: land on the first + // instruction boundary at or past it. + let cck = cck_target.expect("guarded by is_some_and"); + while self.emu.bus().emulated_cck() < cck { + self.emu.debug_step_for_gdb(&mut cpu_idle)?; + if self.emu.machine.ui_debug_stop_pending() { + break; + } + } + } + _ => { + // Frame-granular: step_frame ends early on a debug + // stop and takes reverse-debug snapshots when due. + self.emu.step_frame()?; + } + } + self.ctx.apply_due_scheduled(&mut self.emu); + + if self.emu.machine.cpu_double_faulted() { + return finish( + "double_fault", + "CPU double fault (halted)".to_string(), + extra_ids, + false, + ); + } + if let Some(debug_stop) = self.emu.machine.take_ui_debug_stop() { + let (mut reason, detail) = exec::stop_reason_of(&debug_stop); + if let (Some(RunTarget::Beam { vpos, .. }), DebugStop::Beam { vpos: at, .. }) = + (target, &debug_stop) + { + if *at == vpos { + reason = "target"; + } + } + return finish(reason, detail, extra_ids, false); + } + + match self.poll_mid_run(&mut extra_ids)? { + MidRun::Kept => {} + MidRun::Pause => { + return finish("pause", "paused by client".to_string(), extra_ids, false) + } + MidRun::Kill => return finish("pause", "shutdown".to_string(), extra_ids, true), + MidRun::Lost(end) => { + self.emu.bus_mut().ui_disarm_beam_trap_once(); + return Ok(RunOutcome::ClientLost(end)); + } + } + } + } + + /// Report the end of a bounded step-over/step-out: a debug stop hit + /// on the way wins, otherwise it is a plain step. + fn bounded_result(&mut self, what: &str) -> Result { + let (reason, detail) = self + .take_stop() + .map(|(r, d)| (r.to_string(), d)) + .unwrap_or_else(|| ("step".to_string(), what.to_string())); + Ok(RunOutcome::Stop { + reason, + detail, + extra_ids: Vec::new(), + kill_after: false, + }) + } + + /// Drain the machine's pending stop, mapping it onto the protocol + /// reason. Double fault is checked first: it is not a `ui_*` stop. + fn take_stop(&mut self) -> Option<(&'static str, String)> { + if self.emu.machine.cpu_double_faulted() { + return Some(("double_fault", "CPU double fault (halted)".to_string())); + } + self.emu + .machine + .take_ui_debug_stop() + .map(|stop| exec::stop_reason_of(&stop)) + } + + /// Service requests that arrive while the machine is running. Runs + /// at a quantum boundary, so inspection sees consistent state. + fn poll_mid_run(&mut self, extra_ids: &mut Vec) -> Result { + loop { + let line = match self.reader.poll()? { + Polled::Empty => return Ok(MidRun::Kept), + Polled::Eof => return Ok(MidRun::Lost(SessionEnd::Detached)), + Polled::Line(line) => line, + }; + let req = match proto::parse_request(&line) { + Ok(req) => req, + Err(reply) => { + self.write(&reply)?; + continue; + } + }; + match self.gate.handle(&req) { + Gate::Reply(reply) => { + self.write(&reply)?; + continue; + } + Gate::ReplyAndClose(reply) => { + self.write(&reply)?; + return Ok(MidRun::Lost(SessionEnd::Detached)); + } + Gate::Pass => {} + } + if req.method == "shutdown" { + self.write(&proto::ok_line(&req.id, json!({})))?; + extra_ids.push(req.id); + return Ok(MidRun::Kill); + } + let parsed = match exec::parse_method(&req.method, &req.params) { + Ok(parsed) => parsed, + Err(err) => { + self.write(&proto::err_line(&req.id, &err))?; + continue; + } + }; + match parsed { + Request::Host(HostOp::Pause) => { + extra_ids.push(req.id); + return Ok(MidRun::Pause); + } + Request::Host(HostOp::Resume(_)) => { + self.write(&proto::err_line( + &req.id, + &CtlError::new(proto::RESUME_PENDING, "a resume is already pending"), + ))?; + } + Request::Host(HostOp::Input(cmd)) => { + let now = self.emu.bus().emulated_seconds(); + let (immediate, later) = cmd.expand(now); + for action in immediate { + self.ctx.inject_now(&mut self.emu, action); + } + let scheduled = later.len(); + for entry in later { + self.ctx.schedule(entry.at_seconds, entry.action); + } + self.write(&proto::ok_line( + &req.id, + json!({"applied_at_seconds": now, "scheduled": scheduled}), + ))?; + } + Request::Host(HostOp::StateLoad { .. }) => { + self.write(&proto::err_line( + &req.id, + &CtlError::invalid_state("pause before loading a state"), + ))?; + } + Request::Host( + op @ (HostOp::FloppyInsert { .. } + | HostOp::FloppyEject { .. } + | HostOp::CdInsert { .. } + | HostOp::CdEject + | HostOp::Reset { .. }), + ) => { + // Media changes and reset are ordinary live events; + // apply them at this boundary. + if let Some(end) = self.dispatch_host(req.id, op)? { + return Ok(MidRun::Lost(end)); + } + } + Request::Core(op) if op.allowed_while_running() => { + let reply = match exec::exec_core(&mut self.emu, &mut self.ctx, &op) { + Ok(result) => proto::ok_line(&req.id, result), + Err(err) => proto::err_line(&req.id, &err), + }; + self.write(&reply)?; + } + Request::Core(_) => { + self.write(&proto::err_line( + &req.id, + &CtlError::invalid_state("pause before repositioning the machine"), + ))?; + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::control::test_emulator; + use std::io::BufRead; + + const TOKEN: &str = "sesame"; + + /// A minimal JSON-RPC line client for driving a [`Session`] over + /// loopback, with out-of-order response matching by id. + struct Client { + reader: std::io::BufReader, + writer: TcpStream, + next_id: u64, + stash: Vec, + } + + impl Client { + fn connect(addr: std::net::SocketAddr) -> Self { + let stream = TcpStream::connect(addr).expect("connecting to control session"); + stream.set_nodelay(true).ok(); + Self { + reader: std::io::BufReader::new(stream.try_clone().expect("cloning client stream")), + writer: stream, + next_id: 1, + stash: Vec::new(), + } + } + + fn send(&mut self, method: &str, params: Value) -> u64 { + let id = self.next_id; + self.next_id += 1; + let msg = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}); + proto::write_line(&mut self.writer, &msg.to_string()).expect("sending request"); + id + } + + fn recv(&mut self) -> Value { + let mut line = String::new(); + let n = self.reader.read_line(&mut line).expect("reading response"); + assert!(n > 0, "server closed the connection unexpectedly"); + serde_json::from_str(line.trim()).expect("response is JSON") + } + + /// The full response envelope for `id`, stashing any other + /// responses that arrive first. + fn wait_for(&mut self, id: u64) -> Value { + if let Some(pos) = self.stash.iter().position(|v| v["id"].as_u64() == Some(id)) { + return self.stash.remove(pos); + } + loop { + let msg = self.recv(); + if msg["id"].as_u64() == Some(id) { + return msg; + } + self.stash.push(msg); + } + } + + fn call(&mut self, method: &str, params: Value) -> Value { + let id = self.send(method, params); + self.wait_for(id) + } + + /// Call and unwrap the `result`, panicking on an error reply. + fn result(&mut self, method: &str, params: Value) -> Value { + let msg = self.call(method, params); + assert!( + msg.get("error").is_none(), + "{method} failed: {}", + msg["error"] + ); + msg["result"].clone() + } + + fn auth(&mut self) { + let hello = self.result("hello", json!({"token": TOKEN})); + assert_eq!(hello["authed"], true); + } + } + + /// Run one session against the test emulator: the client closure + /// drives it from a spawned thread while the session runs on the + /// test thread (the gdbstub `run_session` pattern). Time travel is + /// armed like `run()` arms it. Returns the session context and how + /// it ended, for post-conditions. The session (and its sockets) is + /// dropped before joining the client, exactly as `run()`'s serve + /// loop drops it per iteration -- a client waiting for EOF after a + /// server-side close would otherwise deadlock the harness. + fn run_session( + recorder: Option, + client_fn: impl FnOnce(&mut Client) + Send + 'static, + ) -> (SessionCtx, SessionEnd) { + let mut emu = test_emulator(); + emu.enable_time_travel(64, 1); + emu.debug_ensure_time_travel_anchor().unwrap(); + emu.machine.ui_set_pc_history_enabled(true); + + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let handle = std::thread::spawn(move || { + let mut client = Client::connect(addr); + client_fn(&mut client); + }); + let (stream, _) = listener.accept().unwrap(); + let config = Config::new(":0".into()); + let mut session = Session::new(emu, stream, TOKEN, &config, recorder); + let end = session.serve().expect("session should not error"); + session.teardown(); + // Close the session's stream handles NOW: fields matched by + // `..` in a partial-move destructure live until end of scope, + // which would be after the join below. + let Session { + ctx, + reader, + writer, + .. + } = session; + drop(reader); + drop(writer); + handle.join().expect("client assertions failed"); + (ctx, end) + } + + fn scratch_path(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("ccp-e2e-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + dir.join(name) + } + + #[test] + fn hello_auth_status() { + run_session(None, |c| { + // Bare hello: version fields only, not authenticated. + let hello = c.result("hello", json!({})); + assert_eq!(hello["proto"], proto::PROTO_VERSION); + assert_eq!(hello["authed"], false); + // Anything else is refused pre-auth. + let refused = c.call("status", json!({})); + assert_eq!(refused["error"]["code"], proto::NOT_AUTHED); + // Token via auth. + let authed = c.result("auth", json!({"token": TOKEN})); + assert_eq!(authed["authed"], true); + let status = c.result("status", json!({})); + assert_eq!(status["state"], "paused"); + assert_eq!(status["pc"], 0xF80010); + assert_eq!(status["tt_armed"], true); + }); + } + + #[test] + fn wrong_token_gets_one_error_then_close() { + let (_, end) = run_session(None, |c| { + let refused = c.call("auth", json!({"token": "wrong"})); + assert_eq!(refused["error"]["code"], proto::AUTH_FAILED); + // The server drops the connection after the reply. + let mut line = String::new(); + let n = c.reader.read_line(&mut line).expect("read after close"); + assert_eq!(n, 0, "expected EOF after failed auth"); + }); + assert!(matches!(end, SessionEnd::Detached)); + } + + #[test] + fn step_advances_pc() { + run_session(None, |c| { + c.auth(); + let stop = c.result("step", json!({"n": 1})); + assert_eq!(stop["reason"], "step"); + assert_eq!(stop["pc"], 0xF80012); // past the NOP + let before = stop["retired_instructions"].as_u64().unwrap(); + let stop = c.result("step", json!({"n": 2})); + assert_eq!(stop["pc"], 0xF8001A); // ADDQ, MOVE.W executed + assert!(stop["retired_instructions"].as_u64().unwrap() > before); + }); + } + + #[test] + fn breakpoint_hit_on_continue() { + run_session(None, |c| { + c.auth(); + let id = c.result("break.add", json!({"kind": "pc", "addr": "$F8001A"})); + assert_eq!(id["id"], 1); + let stop = c.result("continue", json!({})); + assert_eq!(stop["reason"], "breakpoint"); + assert_eq!(stop["pc"], 0xF8001A); + }); + } + + #[test] + fn run_until_pc_and_beam() { + run_session(None, |c| { + c.auth(); + let stop = c.result("run_until", json!({"pc": "$F80014"})); + assert_eq!(stop["reason"], "target"); + assert_eq!(stop["pc"], 0xF80014); + let stop = c.result("run_until", json!({"vpos": 150})); + assert_eq!(stop["reason"], "target"); + assert_eq!(stop["vpos"], 150); + }); + } + + #[test] + fn memory_rw_roundtrip_reports_replay_unsafe() { + run_session(None, |c| { + c.auth(); + let write = c.result("mem.write", json!({"addr": "$30000", "data": "cafef00d"})); + assert_eq!(write["written"], 4); + assert_eq!(write["replay_unsafe"], true, "tt is armed in this session"); + let read = c.result( + "mem.read", + json!({"addr": "$30000", "len": 4, "encoding": "base64"}), + ); + assert_eq!( + proto::decode_base64(read["data"].as_str().unwrap()).unwrap(), + vec![0xca, 0xfe, 0xf0, 0x0d] + ); + }); + } + + #[test] + fn screenshot_and_digest_are_deterministic() { + run_session(None, |c| { + c.auth(); + let path = scratch_path("shot.png"); + let shot = c.result( + "capture.screenshot", + json!({"path": path.display().to_string()}), + ); + assert_eq!(shot["width"], 716); + assert!(path.exists(), "screenshot file written"); + std::fs::remove_file(&path).ok(); + let a = c.result("capture.digest", json!({})); + let b = c.result("capture.digest", json!({})); + assert_eq!(a["digest"], b["digest"]); + }); + } + + #[test] + fn save_load_state_roundtrip() { + run_session(None, |c| { + c.auth(); + let path = scratch_path("state.clstate"); + c.result("step_frame", json!({"n": 2})); + let saved_at = c.result("beam.get", json!({})); + c.result("state.save", json!({"path": path.display().to_string()})); + c.result("step_frame", json!({"n": 3})); + let loaded = c.result("state.load", json!({"path": path.display().to_string()})); + assert_eq!(loaded["reconfigured"], false); + let now = c.result("beam.get", json!({})); + assert_eq!(now["cck"], saved_at["cck"], "timeline restored"); + std::fs::remove_file(&path).ok(); + }); + } + + #[test] + fn input_key_is_journaled_and_stamped() { + let (ctx, _) = run_session(Some(InputRecorder::new(0.0)), |c| { + c.auth(); + c.result("step_frame", json!({"n": 1})); + let applied = c.result("input.key", json!({"rawkey": 0x45, "action": "press"})); + let at = applied["applied_at_seconds"].as_f64().unwrap(); + assert!(at > 0.0, "landed on the emulated clock: {at}"); + // A tap schedules its release on the emulated timeline. + let tap = c.result("input.key", json!({"rawkey": 0x20, "hold_ms": 50})); + assert_eq!(tap["scheduled"], 1); + }); + let script = ctx.recorder.map(|r| r.finish()).unwrap_or_default(); + assert!( + script.contains("0x45"), + "recording carries the injected key: {script}" + ); + } + + #[test] + fn reverse_step_and_last_writer() { + run_session(None, |c| { + c.auth(); + let stop = c.result("step", json!({"n": 200})); + let before = stop["retired_instructions"].as_u64().unwrap(); + // The ROM loop has been writing D0 to $20000. + let mem = c.result("mem.read", json!({"addr": "$20000", "len": 2})); + assert_ne!(mem["data"], "0000", "the loop stored a nonzero counter"); + + let back = c.result("reverse_step", json!({})); + assert_eq!(back["reason"], "reverse"); + assert_eq!(back["retired_instructions"].as_u64().unwrap(), before - 1); + + let writer = c.result("last_writer", json!({"addr": "$20000"})); + assert_eq!(writer["outcome"], "found"); + assert_eq!( + writer["record"]["pc"], 0xF80014, + "the MOVE.W D0,(abs).L instruction is the writer" + ); + }); + } + + #[test] + fn pause_interrupts_continue_and_second_resume_is_refused() { + run_session(None, |c| { + c.auth(); + // The ROM program loops forever; only pause can stop it. + let cont = c.send("continue", json!({})); + let second = c.send("step_frame", json!({})); + let pause = c.send("pause", json!({})); + + let refused = c.wait_for(second); + assert_eq!(refused["error"]["code"], proto::RESUME_PENDING); + + let stop = c.wait_for(cont); + assert_eq!(stop["result"]["reason"], "pause"); + let also = c.wait_for(pause); + assert_eq!( + also["result"]["cck"], stop["result"]["cck"], + "pause reports the same stop position" + ); + }); + } + + #[test] + fn inspection_is_serviced_mid_run() { + run_session(None, |c| { + c.auth(); + let cont = c.send("continue", json!({})); + let regs = c.call("regs.get", json!({})); + assert!(regs.get("error").is_none(), "inspection works mid-run"); + let denied = c.call("reverse_step", json!({})); + assert_eq!(denied["error"]["code"], proto::INVALID_STATE); + let stop = c.call("pause", json!({})); + assert_eq!(stop["result"]["reason"], "pause"); + c.wait_for(cont); + }); + } + + #[test] + fn shutdown_ends_the_server() { + let (_, end) = run_session(None, |c| { + c.auth(); + c.result("shutdown", json!({})); + }); + assert!(matches!(end, SessionEnd::Killed)); + } + + #[test] + fn continue_with_collect_returns_data_at_the_stop() { + run_session(None, |c| { + c.auth(); + c.result("break.add", json!({"kind": "pc", "addr": "$F8001A"})); + let stop = c.result( + "continue", + json!({"collect": [ + {"method": "regs.get"}, + {"method": "mem.read", "params": {"addr": "$20000", "len": 2}}, + ]}), + ); + assert_eq!(stop["reason"], "breakpoint"); + let collect = stop["collect"].as_array().unwrap(); + assert_eq!(collect.len(), 2); + assert_eq!(collect[0]["ok"]["pc"], stop["pc"]); + assert!(collect[1]["ok"]["data"].is_string()); + }); + } +} diff --git a/src/control/proto.rs b/src/control/proto.rs new file mode 100644 index 00000000..efbf2c90 --- /dev/null +++ b/src/control/proto.rs @@ -0,0 +1,573 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Wire layer of the control protocol: JSON-RPC 2.0 messages, one JSON +//! object per newline-terminated UTF-8 line, plus the auth handshake and +//! the small payload codecs. This module knows nothing about the +//! emulator; both server modes (headless owner and windowed drain) speak +//! through it. +//! +//! Protocol rules enforced here: +//! - every client request must carry an `id` (server notifications to the +//! client carry none, per JSON-RPC 2.0); +//! - the first successful `hello {token}` or `auth {token}` authenticates +//! the connection; a wrong token gets one error reply and the +//! connection is closed; anything else before auth is refused; +//! - `hello` never exposes machine state, only version fields, so it is +//! safe to answer from the socket thread pre-auth. + +use serde::Serialize; +use serde_json::{json, Value}; +use std::io::{self, BufRead, Write}; + +/// Control protocol version returned by `hello`. Bump on breaking wire +/// changes; additive fields and methods do not bump it. +pub const PROTO_VERSION: u32 = 1; + +/// Cap on one wire line, so a hostile or broken client cannot balloon +/// the reader; generous enough for a 1 MiB `mem.write` payload as hex +/// plus JSON overhead. +pub const MAX_LINE_BYTES: usize = 4 * 1024 * 1024; + +// JSON-RPC 2.0 standard error codes. +pub const PARSE_ERROR: i64 = -32700; +pub const INVALID_REQUEST: i64 = -32600; +pub const METHOD_NOT_FOUND: i64 = -32601; +pub const INVALID_PARAMS: i64 = -32602; +pub const INTERNAL_ERROR: i64 = -32603; +// Implementation-defined error codes (-32000..-32099 reserved range). +pub const AUTH_FAILED: i64 = -32000; +pub const NOT_AUTHED: i64 = -32001; +pub const RESUME_PENDING: i64 = -32002; +pub const INVALID_STATE: i64 = -32003; +pub const UNSUPPORTED: i64 = -32004; +pub const IO_ERROR: i64 = -32005; +pub const HISTORY_EXHAUSTED: i64 = -32006; +pub const NOT_FOUND: i64 = -32007; + +/// A protocol-level error: code plus human-readable message, rendered +/// into the JSON-RPC `error` object. +#[derive(Debug, Clone, PartialEq)] +pub struct CtlError { + pub code: i64, + pub message: String, +} + +impl CtlError { + pub fn new(code: i64, message: impl Into) -> Self { + Self { + code, + message: message.into(), + } + } + + pub fn invalid_params(message: impl Into) -> Self { + Self::new(INVALID_PARAMS, message) + } + + pub fn invalid_state(message: impl Into) -> Self { + Self::new(INVALID_STATE, message) + } + + pub fn unsupported(message: impl Into) -> Self { + Self::new(UNSUPPORTED, message) + } + + pub fn io(message: impl Into) -> Self { + Self::new(IO_ERROR, message) + } + + pub fn not_found(message: impl Into) -> Self { + Self::new(NOT_FOUND, message) + } + + pub fn internal(message: impl Into) -> Self { + Self::new(INTERNAL_ERROR, message) + } + + pub fn method_not_found(method: &str) -> Self { + Self::new(METHOD_NOT_FOUND, format!("unknown method: {method}")) + } + + pub fn auth_failed() -> Self { + Self::new(AUTH_FAILED, "auth failed") + } + + pub fn not_authed() -> Self { + Self::new( + NOT_AUTHED, + "not authenticated; call hello or auth with the session token", + ) + } +} + +/// A parsed client request. `id` is kept as raw JSON (number or string) +/// and echoed back verbatim in the response. +#[derive(Debug, Clone, PartialEq)] +pub struct RpcRequest { + pub id: Value, + pub method: String, + pub params: Value, +} + +/// Parse one wire line into a request. On failure, returns the response +/// line to send back (JSON-RPC prescribes `id: null` for unparseable +/// requests, the echoed id for malformed ones). +pub fn parse_request(line: &str) -> Result { + let value: Value = match serde_json::from_str(line) { + Ok(v) => v, + Err(e) => { + return Err(err_line( + &Value::Null, + &CtlError::new(PARSE_ERROR, format!("parse error: {e}")), + )) + } + }; + let Some(obj) = value.as_object() else { + return Err(err_line( + &Value::Null, + &CtlError::new(INVALID_REQUEST, "request must be a JSON object"), + )); + }; + let id = obj.get("id").cloned().unwrap_or(Value::Null); + if let Some(version) = obj.get("jsonrpc") { + if version.as_str() != Some("2.0") { + return Err(err_line( + &id, + &CtlError::new(INVALID_REQUEST, "jsonrpc must be \"2.0\""), + )); + } + } + if id.is_null() { + return Err(err_line( + &Value::Null, + &CtlError::new(INVALID_REQUEST, "requests must carry an id"), + )); + } + let Some(method) = obj.get("method").and_then(|m| m.as_str()) else { + return Err(err_line( + &id, + &CtlError::new(INVALID_REQUEST, "request has no method"), + )); + }; + Ok(RpcRequest { + id, + method: method.to_string(), + params: obj.get("params").cloned().unwrap_or(Value::Null), + }) +} + +/// Serialize a success response line (no trailing newline). +pub fn ok_line(id: &Value, result: Value) -> String { + json!({"jsonrpc": "2.0", "id": id, "result": result}).to_string() +} + +/// Serialize an error response line (no trailing newline). +pub fn err_line(id: &Value, err: &CtlError) -> String { + json!({"jsonrpc": "2.0", "id": id, "error": {"code": err.code, "message": err.message}}) + .to_string() +} + +/// Serialize a server-to-client notification line (no trailing newline). +pub fn event_line(method: &str, params: Value) -> String { + json!({"jsonrpc": "2.0", "method": method, "params": params}).to_string() +} + +/// Write one message line and flush, so a blocked emulator never sits on +/// a buffered reply. +pub fn write_line(w: &mut impl Write, line: &str) -> io::Result<()> { + w.write_all(line.as_bytes())?; + w.write_all(b"\n")?; + w.flush() +} + +/// Read the next non-blank message line. Returns `Ok(None)` on a clean +/// EOF; blank lines are skipped so interactive `nc` sessions stay +/// friendly. Enforces [`MAX_LINE_BYTES`]. +pub fn read_msg_line(r: &mut R) -> io::Result> { + let mut buf: Vec = Vec::new(); + loop { + let chunk = r.fill_buf()?; + if chunk.is_empty() { + if buf.iter().all(|b| b.is_ascii_whitespace()) { + return Ok(None); + } + return line_from_utf8(buf); + } + let newline = chunk.iter().position(|&b| b == b'\n'); + let take = newline.unwrap_or(chunk.len()); + buf.extend_from_slice(&chunk[..take]); + let consumed = newline.map_or(take, |p| p + 1); + r.consume(consumed); + if buf.len() > MAX_LINE_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "control message exceeds the line limit", + )); + } + if newline.is_some() { + if buf.iter().all(|b| b.is_ascii_whitespace()) { + buf.clear(); + continue; + } + return line_from_utf8(buf); + } + } +} + +fn line_from_utf8(buf: Vec) -> io::Result> { + String::from_utf8(buf) + .map(Some) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "control message is not UTF-8")) +} + +/// The position/stop report returned by every resume verb and carried by +/// `event.stopped`: a consistent coordinate on the emulated timeline. +#[derive(Debug, Clone, Serialize)] +pub struct StopEvent { + /// `breakpoint`, `watchpoint`, `reg_watch`, `beam_trap`, + /// `copper_break`, `catch`, `step`, `target`, `pause`, `user_pause`, + /// `double_fault`, `reverse`, or `history_partial`. + pub reason: String, + /// Human-readable detail (the debug-stop description, target spec...). + pub detail: String, + pub pc: u32, + pub frame: u64, + pub vpos: u16, + pub hpos: u16, + pub cck: u64, + pub seconds: f64, + pub retired_instructions: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub collect: Option>, +} + +/// Pre-auth gatekeeper shared by both server modes. `hello` and `auth` +/// are answered here and never reach the executor; anything else is +/// refused until a token has been presented. +pub struct AuthGate { + token: String, + authed: bool, +} + +/// What to do with an incoming request, as decided by the [`AuthGate`]. +#[derive(Debug, PartialEq)] +pub enum Gate { + /// Authenticated request for the executor. + Pass, + /// Session-layer method handled here; send this line. + Reply(String), + /// Send this line, then drop the connection (failed auth). + ReplyAndClose(String), +} + +impl AuthGate { + pub fn new(token: String) -> Self { + Self { + token, + authed: false, + } + } + + pub fn authed(&self) -> bool { + self.authed + } + + pub fn handle(&mut self, req: &RpcRequest) -> Gate { + match req.method.as_str() { + "hello" => { + if let Some(supplied) = req.params.get("token").and_then(Value::as_str) { + if supplied == self.token { + self.authed = true; + } else { + return Gate::ReplyAndClose(err_line(&req.id, &CtlError::auth_failed())); + } + } + Gate::Reply(ok_line( + &req.id, + json!({ + "proto": PROTO_VERSION, + "emulator": concat!("copperline ", env!("CARGO_PKG_VERSION")), + "authed": self.authed, + }), + )) + } + "auth" => match req.params.get("token").and_then(Value::as_str) { + Some(supplied) if supplied == self.token => { + self.authed = true; + Gate::Reply(ok_line(&req.id, json!({"authed": true}))) + } + _ => Gate::ReplyAndClose(err_line(&req.id, &CtlError::auth_failed())), + }, + _ if !self.authed => Gate::Reply(err_line(&req.id, &CtlError::not_authed())), + _ => Gate::Pass, + } + } +} + +/// Lowercase hex encoding for memory payloads. +pub fn encode_hex(data: &[u8]) -> String { + let mut out = String::with_capacity(data.len() * 2); + for b in data { + out.push_str(&format!("{b:02x}")); + } + out +} + +/// Decode hex (case-insensitive, no separators). `None` on any +/// malformed input. +pub fn decode_hex(s: &str) -> Option> { + let s = s.trim(); + if !s.len().is_multiple_of(2) { + return None; + } + let mut out = Vec::with_capacity(s.len() / 2); + let bytes = s.as_bytes(); + for pair in bytes.chunks(2) { + let hi = (pair[0] as char).to_digit(16)?; + let lo = (pair[1] as char).to_digit(16)?; + out.push(((hi << 4) | lo) as u8); + } + Some(out) +} + +const BASE64_ALPHABET: &[u8; 64] = + b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +/// Standard base64 with padding, for bulk memory payloads. +pub fn encode_base64(data: &[u8]) -> String { + let mut out = String::with_capacity(data.len().div_ceil(3) * 4); + for chunk in data.chunks(3) { + let b = [ + chunk[0], + *chunk.get(1).unwrap_or(&0), + *chunk.get(2).unwrap_or(&0), + ]; + let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]); + out.push(BASE64_ALPHABET[(n >> 18) as usize & 63] as char); + out.push(BASE64_ALPHABET[(n >> 12) as usize & 63] as char); + out.push(if chunk.len() > 1 { + BASE64_ALPHABET[(n >> 6) as usize & 63] as char + } else { + '=' + }); + out.push(if chunk.len() > 2 { + BASE64_ALPHABET[n as usize & 63] as char + } else { + '=' + }); + } + out +} + +/// Decode standard base64 (padding optional). `None` on any malformed +/// input, including nonzero discarded padding bits. +pub fn decode_base64(s: &str) -> Option> { + fn val(b: u8) -> Option { + match b { + b'A'..=b'Z' => Some(u32::from(b - b'A')), + b'a'..=b'z' => Some(u32::from(b - b'a') + 26), + b'0'..=b'9' => Some(u32::from(b - b'0') + 52), + b'+' => Some(62), + b'/' => Some(63), + _ => None, + } + } + let raw = s.trim().as_bytes(); + let stripped = raw + .strip_suffix(b"==") + .or_else(|| raw.strip_suffix(b"=")) + .unwrap_or(raw); + if stripped.len() % 4 == 1 { + return None; + } + let mut out = Vec::with_capacity(stripped.len() * 3 / 4); + let mut acc: u32 = 0; + let mut bits: u32 = 0; + for &b in stripped { + acc = (acc << 6) | val(b)?; + bits += 6; + if bits >= 8 { + bits -= 8; + out.push((acc >> bits) as u8); + } + } + if bits > 0 && (acc & ((1 << bits) - 1)) != 0 { + return None; + } + Some(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + fn req(line: &str) -> RpcRequest { + parse_request(line).expect("request should parse") + } + + #[test] + fn framing_reads_lines_skips_blanks_and_reports_eof() { + let mut r = Cursor::new(b"{\"a\":1}\n\n \n{\"b\":2}\nno-newline-tail".to_vec()); + assert_eq!(read_msg_line(&mut r).unwrap().as_deref(), Some("{\"a\":1}")); + assert_eq!(read_msg_line(&mut r).unwrap().as_deref(), Some("{\"b\":2}")); + assert_eq!( + read_msg_line(&mut r).unwrap().as_deref(), + Some("no-newline-tail") + ); + assert_eq!(read_msg_line(&mut r).unwrap(), None); + } + + #[test] + fn framing_survives_split_reads() { + // A one-byte buffer forces the smallest possible fill_buf chunks, + // the worst case for reassembling a line from a TCP stream. + let data = b"{\"method\":\"status\",\"id\":7}\n".to_vec(); + let mut r = std::io::BufReader::with_capacity(1, Cursor::new(data)); + let line = read_msg_line(&mut r).unwrap().unwrap(); + assert_eq!(req(&line).method, "status"); + } + + #[test] + fn framing_caps_line_length() { + let data = vec![b'x'; MAX_LINE_BYTES + 2]; + let mut r = Cursor::new(data); + let err = read_msg_line(&mut r).unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidData); + } + + #[test] + fn parse_error_replies_with_null_id() { + let line = parse_request("{not json").unwrap_err(); + let v: serde_json::Value = serde_json::from_str(&line).unwrap(); + assert_eq!(v["id"], Value::Null); + assert_eq!(v["error"]["code"], PARSE_ERROR); + } + + #[test] + fn requests_require_id_and_method() { + let line = parse_request("{\"method\":\"status\"}").unwrap_err(); + let v: serde_json::Value = serde_json::from_str(&line).unwrap(); + assert_eq!(v["error"]["code"], INVALID_REQUEST); + + let line = parse_request("{\"id\":1}").unwrap_err(); + let v: serde_json::Value = serde_json::from_str(&line).unwrap(); + assert_eq!(v["id"], 1); + assert_eq!(v["error"]["code"], INVALID_REQUEST); + + let line = parse_request("{\"jsonrpc\":\"1.0\",\"id\":1,\"method\":\"x\"}").unwrap_err(); + let v: serde_json::Value = serde_json::from_str(&line).unwrap(); + assert_eq!(v["error"]["code"], INVALID_REQUEST); + } + + #[test] + fn request_round_trips_id_and_params() { + let r = req( + "{\"jsonrpc\":\"2.0\",\"id\":\"a1\",\"method\":\"mem.read\",\"params\":{\"addr\":4}}", + ); + assert_eq!(r.id, json!("a1")); + assert_eq!(r.method, "mem.read"); + assert_eq!(r.params["addr"], 4); + let ok = ok_line(&r.id, json!({"data": "00"})); + let v: serde_json::Value = serde_json::from_str(&ok).unwrap(); + assert_eq!(v["id"], "a1"); + assert_eq!(v["result"]["data"], "00"); + } + + #[test] + fn auth_gate_flows() { + // Method before auth is refused without closing. + let mut gate = AuthGate::new("sesame".into()); + let refused = gate.handle(&req("{\"id\":1,\"method\":\"status\"}")); + match refused { + Gate::Reply(line) => { + let v: serde_json::Value = serde_json::from_str(&line).unwrap(); + assert_eq!(v["error"]["code"], NOT_AUTHED); + } + other => panic!("expected refusal reply, got {other:?}"), + } + + // Bare hello answers version fields but does not authenticate. + let hello = gate.handle(&req("{\"id\":2,\"method\":\"hello\"}")); + match hello { + Gate::Reply(line) => { + let v: serde_json::Value = serde_json::from_str(&line).unwrap(); + assert_eq!(v["result"]["proto"], PROTO_VERSION); + assert_eq!(v["result"]["authed"], false); + } + other => panic!("expected hello reply, got {other:?}"), + } + assert!(!gate.authed()); + + // Wrong token closes the connection. + let bad = gate.handle(&req( + "{\"id\":3,\"method\":\"auth\",\"params\":{\"token\":\"wrong\"}}", + )); + assert!(matches!(bad, Gate::ReplyAndClose(_))); + + // Token in hello authenticates in one round trip. + let mut gate = AuthGate::new("sesame".into()); + let hello = gate.handle(&req( + "{\"id\":4,\"method\":\"hello\",\"params\":{\"token\":\"sesame\"}}", + )); + match hello { + Gate::Reply(line) => { + let v: serde_json::Value = serde_json::from_str(&line).unwrap(); + assert_eq!(v["result"]["authed"], true); + } + other => panic!("expected hello reply, got {other:?}"), + } + assert!(gate.authed()); + assert_eq!( + gate.handle(&req("{\"id\":5,\"method\":\"status\"}")), + Gate::Pass + ); + } + + #[test] + fn hex_codec_round_trips() { + let data: Vec = (0..=255).collect(); + assert_eq!(decode_hex(&encode_hex(&data)).unwrap(), data); + assert_eq!( + decode_hex("DEADbeef").unwrap(), + vec![0xde, 0xad, 0xbe, 0xef] + ); + assert!(decode_hex("abc").is_none()); + assert!(decode_hex("zz").is_none()); + } + + #[test] + fn base64_codec_round_trips() { + for len in 0..64usize { + let data: Vec = (0..len as u8).map(|b| b.wrapping_mul(37)).collect(); + let enc = encode_base64(&data); + assert_eq!(decode_base64(&enc).unwrap(), data, "len {len}"); + } + assert_eq!(encode_base64(b"Amiga"), "QW1pZ2E="); + assert_eq!(decode_base64("QW1pZ2E=").unwrap(), b"Amiga"); + assert_eq!(decode_base64("QW1pZ2E").unwrap(), b"Amiga"); + assert!(decode_base64("Q").is_none()); + assert!(decode_base64("Q!==").is_none()); + // Nonzero discarded padding bits are malformed, not silently dropped. + assert!(decode_base64("QX==").is_none()); + } + + #[test] + fn stop_event_omits_absent_collect() { + let ev = StopEvent { + reason: "step".into(), + detail: String::new(), + pc: 0xFC0000, + frame: 2, + vpos: 44, + hpos: 101, + cck: 12345, + seconds: 0.03, + retired_instructions: 99, + collect: None, + }; + let v = serde_json::to_value(&ev).unwrap(); + assert_eq!(v["pc"], 0xFC0000); + assert!(v.get("collect").is_none()); + } +} diff --git a/src/control/session.rs b/src/control/session.rs new file mode 100644 index 00000000..35f955ab --- /dev/null +++ b/src/control/session.rs @@ -0,0 +1,469 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Per-connection session state shared by both control-server modes: +//! server-assigned breakpoint ids over the machine's interactive break +//! store, input scheduled on the emulated timeline, and the journaling +//! hooks that keep a control-driven session deterministically +//! replayable (`tt_note_input` for reverse replay, `InputRecorder` for +//! `--record-input`). + +use crate::debugger::{BreakCond, WatchSource}; +use crate::emulator::Emulator; +use crate::inputrec::InputRecorder; +use crate::inputsched::{JoyState, ReplayAction}; +use std::collections::BTreeMap; + +/// One installed break of any kind, as requested over the protocol. +/// Mirrors the parameter set of the `ui_*` install calls so removal can +/// re-toggle the exact same point. +#[derive(Clone, Debug, PartialEq)] +pub enum BreakSpec { + Pc { + addr: u32, + cond: Option, + ignore: u32, + }, + Watch { + addr: u32, + source: Option, + }, + RegWatch { + off: u16, + }, + Beam { + vpos: u16, + hpos: Option, + }, + Copper { + addr: u32, + }, + Catch { + vector: u16, + }, +} + +/// One machine-visible input transition a client asked for. Applied +/// through the same bus primitives as live and scripted input, so it +/// journals identically. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum InputAction { + Key { + rawkey: u8, + pressed: bool, + }, + /// Port-1 mouse button: index 0 = left, 1 = right, 2 = middle. + MouseButton { + index: u8, + pressed: bool, + }, + MouseMove { + dx: i32, + dy: i32, + }, + Joy(JoyState), +} + +/// An input action deferred to an emulated-time boundary (`at_seconds` +/// scheduling and `hold_ms` releases). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ScheduledInput { + pub at_seconds: f64, + pub action: InputAction, +} + +/// Session state carried across requests on one connection. +pub struct SessionCtx { + /// Server-assigned id -> installed break. Ordered so `break.list` + /// output is stable. + breaks: BTreeMap, + next_break_id: u32, + /// Input waiting for its emulated time; drained by the driver at + /// its command boundary. + pub scheduled: Vec, + /// Journaling recorder owned by the headless driver + /// (`--record-input`); the windowed drain journals through the + /// App's recorder instead and leaves this `None`. + pub recorder: Option, + /// Host-state flags the driver keeps updated for `status`. + pub running: bool, + /// A resume verb's response is still outstanding. + pub pending: bool, + pub powered_on: bool, +} + +impl SessionCtx { + pub fn new() -> Self { + Self { + breaks: BTreeMap::new(), + next_break_id: 1, + scheduled: Vec::new(), + recorder: None, + running: false, + pending: false, + powered_on: true, + } + } + + /// Install `spec` on the machine and assign it an id. Refuses a + /// point that already exists (whether set by this session or the + /// local GUI), because the underlying store toggles: installing + /// twice would silently remove it. + pub fn install_break(&mut self, emu: &mut Emulator, spec: BreakSpec) -> Result { + if self.break_exists(emu, &spec) { + return Err(format!("already set: {}", describe_spec(&spec))); + } + let installed = toggle_spec(emu, &spec); + if !installed { + // A toggle that reports "removed" against a point we just + // checked as absent means the store disagreed; restore and + // report rather than leave it half-changed. + toggle_spec(emu, &spec); + return Err(format!("could not install: {}", describe_spec(&spec))); + } + let id = self.next_break_id; + self.next_break_id += 1; + self.breaks.insert(id, spec); + Ok(id) + } + + /// Remove the break with server id `id`. Returns false only for an + /// unknown id; a point the GUI already removed still counts as + /// success (the goal state is reached either way). + pub fn remove_break(&mut self, emu: &mut Emulator, id: u32) -> bool { + let Some(spec) = self.breaks.remove(&id) else { + return false; + }; + if self.break_exists(emu, &spec) { + toggle_spec(emu, &spec); + } + true + } + + /// Remove every break this session installed (teardown on + /// disconnect). GUI-set points are left alone. + pub fn remove_all_breaks(&mut self, emu: &mut Emulator) { + let ids: Vec = self.breaks.keys().copied().collect(); + for id in ids { + self.remove_break(emu, id); + } + } + + /// The server id of an installed point matching `other`'s kind and + /// coordinates. Condition/ignore differences do not matter for + /// identification: the machine store keys points the same way. + pub fn id_for(&self, other: &BreakSpec) -> Option { + self.breaks + .iter() + .find(|(_, s)| same_point(s, other)) + .map(|(id, _)| *id) + } + + pub fn breaks(&self) -> impl Iterator { + self.breaks.iter().map(|(id, s)| (*id, s)) + } + + /// Whether `spec`'s point currently exists in the machine's break + /// store (regardless of who installed it). + pub fn break_exists(&self, emu: &Emulator, spec: &BreakSpec) -> bool { + match spec { + BreakSpec::Pc { addr, .. } => emu.machine.ui_breaks().is_breakpoint(*addr), + BreakSpec::Watch { addr, .. } => { + let masked = addr & emu.machine.ui_breaks().addr_mask & !1; + emu.machine + .ui_breaks() + .watches + .iter() + .any(|w| w.addr == masked) + } + BreakSpec::RegWatch { off } => emu.machine.ui_breaks().reg_watches.contains(off), + BreakSpec::Beam { vpos, hpos } => emu + .bus() + .ui_beam_traps() + .iter() + .any(|t| !t.once && t.vpos == *vpos && t.hpos == *hpos), + BreakSpec::Copper { addr } => emu.bus().ui_copper_breaks().contains(addr), + BreakSpec::Catch { vector } => emu.machine.ui_breaks().catches.contains(vector), + } + } + + /// Queue `action` for `at_seconds` on the emulated clock, keeping + /// the queue sorted by time. + pub fn schedule(&mut self, at_seconds: f64, action: InputAction) { + self.scheduled.push(ScheduledInput { at_seconds, action }); + self.scheduled + .sort_by(|a, b| a.at_seconds.total_cmp(&b.at_seconds)); + } + + /// Apply every scheduled action whose time has arrived. Called by + /// the headless driver at each quantum boundary (the windowed drain + /// maps scheduling onto the App's own scheduled-input lists + /// instead). + pub fn apply_due_scheduled(&mut self, emu: &mut Emulator) { + let now = emu.bus().emulated_seconds(); + while let Some(first) = self.scheduled.first() { + if first.at_seconds > now { + break; + } + let entry = self.scheduled.remove(0); + inject_input(emu, &mut self.recorder, entry.action); + } + } + + /// Apply `action` now, journaled. Returns the emulated time it + /// landed at. + pub fn inject_now(&mut self, emu: &mut Emulator, action: InputAction) -> f64 { + inject_input(emu, &mut self.recorder, action) + } +} + +impl Default for SessionCtx { + fn default() -> Self { + Self::new() + } +} + +/// Whether two specs address the same point in the break store (the +/// coordinates the machine keys on), regardless of condition or ignore +/// count. +fn same_point(a: &BreakSpec, b: &BreakSpec) -> bool { + match (a, b) { + (BreakSpec::Pc { addr: x, .. }, BreakSpec::Pc { addr: y, .. }) => x == y, + (BreakSpec::Watch { addr: x, .. }, BreakSpec::Watch { addr: y, .. }) => x == y, + (BreakSpec::RegWatch { off: x }, BreakSpec::RegWatch { off: y }) => x == y, + (BreakSpec::Beam { vpos: xv, hpos: xh }, BreakSpec::Beam { vpos: yv, hpos: yh }) => { + xv == yv && xh == yh + } + (BreakSpec::Copper { addr: x }, BreakSpec::Copper { addr: y }) => x == y, + (BreakSpec::Catch { vector: x }, BreakSpec::Catch { vector: y }) => x == y, + _ => false, + } +} + +/// Toggle `spec`'s point in the machine's break store; returns whether +/// it is now set. +fn toggle_spec(emu: &mut Emulator, spec: &BreakSpec) -> bool { + match spec { + BreakSpec::Pc { addr, cond, ignore } => { + emu.machine.ui_set_breakpoint(*addr, *cond, *ignore) + } + BreakSpec::Watch { addr, source } => emu.machine.ui_toggle_watch_filtered(*addr, *source), + BreakSpec::RegWatch { off } => emu.machine.ui_toggle_reg_watch(*off), + BreakSpec::Beam { vpos, hpos } => emu.bus_mut().ui_toggle_beam_trap(*vpos, *hpos), + BreakSpec::Copper { addr } => emu.bus_mut().ui_toggle_copper_break(*addr), + BreakSpec::Catch { vector } => emu.machine.ui_toggle_catch(*vector), + } +} + +/// Human-readable description of a break spec for error messages and +/// `break.list`. +pub fn describe_spec(spec: &BreakSpec) -> String { + match spec { + BreakSpec::Pc { addr, cond, ignore } => { + let mut s = format!("pc breakpoint at ${addr:06X}"); + if let Some(cond) = cond { + s.push_str(&format!(" if {}", cond.describe())); + } + if *ignore > 0 { + s.push_str(&format!(" ignore {ignore}")); + } + s + } + BreakSpec::Watch { addr, source } => format!( + "memory watch at ${addr:06X}{}", + source + .map(|f| format!(" ({} writes)", f.label())) + .unwrap_or_default() + ), + BreakSpec::RegWatch { off } => format!( + "register watch {} (${off:03X})", + crate::debugger::custom_reg_name(*off) + ), + BreakSpec::Beam { vpos, hpos } => format!( + "beam trap v{vpos}{}", + hpos.map(|h| format!(" h{h}")).unwrap_or_default() + ), + BreakSpec::Copper { addr } => format!("copper breakpoint at ${addr:06X}"), + BreakSpec::Catch { vector } => format!( + "catch {} (vector {vector})", + crate::debugger::exception_vector_name(*vector) + ), + } +} + +/// Apply one input action through the bus primitives, note it for +/// reverse replay, and journal it in the recorder when one is active. +/// Returns the emulated time the action landed at. +pub fn inject_input( + emu: &mut Emulator, + recorder: &mut Option, + action: InputAction, +) -> f64 { + let secs = emu.bus().emulated_seconds(); + match action { + InputAction::Key { rawkey, pressed } => { + if pressed { + emu.bus_mut().enqueue_key(rawkey); + } else { + emu.bus_mut().enqueue_key_event(rawkey, false); + } + emu.tt_note_input(ReplayAction::Key { rawkey, pressed }); + if let Some(rec) = recorder.as_mut() { + rec.record_key(rawkey, pressed, secs); + } + } + InputAction::MouseButton { index, pressed } => { + let input = &mut emu.bus_mut().input; + match index { + 0 => input.lmb_port1 = pressed, + 1 => input.rmb_port1 = pressed, + 2 => input.mmb_port1 = pressed, + _ => {} + } + emu.tt_note_input(ReplayAction::MouseButton { index, pressed }); + observe_recorder(emu, recorder, secs); + } + InputAction::MouseMove { dx, dy } => { + emu.bus_mut().input.add_mouse_delta_port1(dx, dy); + emu.tt_note_input(ReplayAction::MouseMove { dx, dy }); + observe_recorder(emu, recorder, secs); + } + InputAction::Joy(j) => { + let input = &mut emu.bus_mut().input; + input.set_joystick_port2(j.up, j.down, j.left, j.right, j.red, j.blue); + input.set_cd32_buttons_port2(j.play, j.rwd, j.ffw, j.green, j.yellow); + emu.tt_note_input(ReplayAction::Joy(j)); + observe_recorder(emu, recorder, secs); + } + } + secs +} + +/// The recorder captures mouse/joystick state by diffing `InputState` +/// snapshots (the same once-per-quantum pattern the window uses). +fn observe_recorder(emu: &Emulator, recorder: &mut Option, secs: f64) { + if let Some(rec) = recorder.as_mut() { + rec.observe(&emu.bus().input, secs); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::control::test_emulator; + + #[test] + fn scheduled_input_applies_in_time_order_on_the_emulated_clock() { + let mut emu = test_emulator(); + let mut ctx = SessionCtx::new(); + // Push out of order; the queue must sort by emulated time. + ctx.schedule( + 0.030, + InputAction::Key { + rawkey: 0x22, + pressed: false, + }, + ); + ctx.schedule( + 0.001, + InputAction::Key { + rawkey: 0x22, + pressed: true, + }, + ); + assert!(ctx.scheduled[0].at_seconds < ctx.scheduled[1].at_seconds); + + // Nothing due at power-on. + ctx.apply_due_scheduled(&mut emu); + assert_eq!(ctx.scheduled.len(), 2); + + // One frame (~0.02s PAL) passes the press but not the release. + emu.step_frame().unwrap(); + ctx.apply_due_scheduled(&mut emu); + assert_eq!(ctx.scheduled.len(), 1); + + emu.step_frame().unwrap(); + ctx.apply_due_scheduled(&mut emu); + assert!(ctx.scheduled.is_empty()); + } + + #[test] + fn injected_input_is_journaled_in_the_recorder() { + let mut emu = test_emulator(); + let mut ctx = SessionCtx::new(); + ctx.recorder = Some(InputRecorder::new(0.0)); + ctx.inject_now( + &mut emu, + InputAction::Key { + rawkey: 0x45, + pressed: true, + }, + ); + ctx.inject_now( + &mut emu, + InputAction::Key { + rawkey: 0x45, + pressed: false, + }, + ); + let script = ctx.recorder.take().unwrap().finish(); + assert!( + script.contains("key-after") && script.contains("0x45"), + "recording should carry the injected key: {script}" + ); + } + + #[test] + fn break_install_remove_and_teardown_leave_the_store_clean() { + let mut emu = test_emulator(); + let mut ctx = SessionCtx::new(); + let pc = ctx + .install_break( + &mut emu, + BreakSpec::Pc { + addr: 0xF80012, + cond: None, + ignore: 0, + }, + ) + .unwrap(); + ctx.install_break( + &mut emu, + BreakSpec::Beam { + vpos: 120, + hpos: Some(60), + }, + ) + .unwrap(); + ctx.install_break(&mut emu, BreakSpec::Copper { addr: 0x20000 }) + .unwrap(); + assert!(emu.machine.ui_breaks().is_breakpoint(0xF80012)); + assert_eq!(emu.bus().ui_beam_traps().len(), 1); + assert_eq!(emu.bus().ui_copper_breaks().len(), 1); + + assert!(ctx.remove_break(&mut emu, pc)); + assert!(!emu.machine.ui_breaks().is_breakpoint(0xF80012)); + + ctx.remove_all_breaks(&mut emu); + assert!(emu.bus().ui_beam_traps().is_empty()); + assert!(emu.bus().ui_copper_breaks().is_empty()); + } + + #[test] + fn teardown_leaves_gui_owned_points_alone() { + let mut emu = test_emulator(); + let mut ctx = SessionCtx::new(); + emu.machine.ui_set_breakpoint(0xF80010, None, 0); // "GUI" point + ctx.install_break( + &mut emu, + BreakSpec::Pc { + addr: 0xF80014, + cond: None, + ignore: 0, + }, + ) + .unwrap(); + ctx.remove_all_breaks(&mut emu); + assert!(emu.machine.ui_breaks().is_breakpoint(0xF80010)); + assert!(!emu.machine.ui_breaks().is_breakpoint(0xF80014)); + } +} diff --git a/src/control/windowed.rs b/src/control/windowed.rs new file mode 100644 index 00000000..8b262fe6 --- /dev/null +++ b/src/control/windowed.rs @@ -0,0 +1,260 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Socket plumbing for the windowed control server (`--control-gui`): +//! an accept thread plus, per connection, a reader thread (framing and +//! auth, so unauthenticated requests never reach the frame loop) and a +//! writer thread. Parsed requests cross to the winit frame loop over an +//! mpsc channel and are drained at the top of `about_to_wait`; replies +//! travel back over a per-connection channel. This module holds no +//! winit types -- the frame loop passes a wake callback so a command +//! arriving while the machine is paused (`ControlFlow::Wait`) still +//! gets serviced promptly. +//! +//! One client at a time, like the GDB stub: extra connections receive +//! one JSON error line and are closed. + +use super::exec::{self, Request}; +use super::proto::{self, AuthGate, CtlError, Gate}; +use super::Config; +use anyhow::{Context, Result}; +use serde_json::Value; +use std::io::BufReader; +use std::net::{TcpListener, TcpStream}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{Receiver, Sender}; +use std::sync::{Arc, Mutex}; + +/// A message from the socket threads to the frame loop. +pub enum CtlMsg { + /// A client authenticated; the frame loop resets session state and + /// arms time travel. + Connected, + /// The client went away (EOF or error); the frame loop tears down + /// session-owned breakpoints and drops any pending resume. + Disconnected, + /// `shutdown`: reply and exit the application. + Shutdown { id: Value }, + /// A parsed, authenticated request. + Request { id: Value, req: Request }, +} + +/// The frame loop's handle on the control server: the command receiver, +/// the current connection's reply channel, and the connection flag for +/// the status bar. +pub struct ControlHandle { + listener: Option, + token: String, + cmd_tx: Sender, + cmd_rx: Receiver, + reply_tx: Arc>>>, + connected: Arc, +} + +impl ControlHandle { + /// Bind the listener, resolve the token, and announce the endpoint + /// (stderr line + optional info file). Called from `main` before + /// the window exists so scripts can attach as soon as it opens; + /// threads are spawned later by [`ControlHandle::start`]. + pub fn bind(config: &Config) -> Result { + let bind = crate::gdbstub::normalize_listen_addr(&config.listen)?; + let listener = + TcpListener::bind(&bind).with_context(|| format!("binding control server {bind}"))?; + let local = listener.local_addr().context("resolving control address")?; + let token = config.resolve_token(); + super::announce(&local, &token, config.info_file.as_ref())?; + log::info!("control: listening on {local}"); + let (cmd_tx, cmd_rx) = std::sync::mpsc::channel(); + Ok(Self { + listener: Some(listener), + token, + cmd_tx, + cmd_rx, + reply_tx: Arc::new(Mutex::new(None)), + connected: Arc::new(AtomicBool::new(false)), + }) + } + + /// A loopback handle with no sockets, for driving the frame-loop + /// drain directly in tests: commands are pushed through the + /// returned sender, replies arrive on the returned receiver. + pub fn test_pair() -> (Self, Sender, Receiver) { + let (cmd_tx, cmd_rx) = std::sync::mpsc::channel(); + let (reply_tx, reply_rx) = std::sync::mpsc::channel(); + let handle = Self { + listener: None, + token: String::new(), + cmd_tx: cmd_tx.clone(), + cmd_rx, + reply_tx: Arc::new(Mutex::new(Some(reply_tx))), + connected: Arc::new(AtomicBool::new(true)), + }; + (handle, cmd_tx, reply_rx) + } + + /// Spawn the accept thread. `wake` is invoked after every enqueued + /// message so the event loop leaves `ControlFlow::Wait`. + pub fn start(&mut self, wake: Box) { + let Some(listener) = self.listener.take() else { + return; // test handle, or started twice + }; + let token = self.token.clone(); + let cmd_tx = self.cmd_tx.clone(); + let reply_slot = Arc::clone(&self.reply_tx); + let connected = Arc::clone(&self.connected); + let wake: Arc = Arc::from(wake); + std::thread::Builder::new() + .name("control-accept".into()) + .spawn(move || { + accept_loop(listener, token, cmd_tx, reply_slot, connected, wake); + }) + .expect("spawning control accept thread"); + } + + /// Whether a client is currently attached (status-bar indicator). + pub fn connected(&self) -> bool { + self.connected.load(Ordering::Relaxed) + } + + /// Drain one queued message, non-blocking. + pub fn try_recv(&self) -> Option { + self.cmd_rx.try_recv().ok() + } + + /// Send one reply/notification line to the current client; quietly + /// drops it if the client is gone (its stop already ended the + /// session). + pub fn send(&self, line: String) { + if let Some(tx) = self.reply_tx.lock().expect("reply slot lock").as_ref() { + let _ = tx.send(line); + } + } +} + +fn accept_loop( + listener: TcpListener, + token: String, + cmd_tx: Sender, + reply_slot: Arc>>>, + connected: Arc, + wake: Arc, +) { + loop { + let (stream, peer) = match listener.accept() { + Ok(conn) => conn, + Err(e) => { + log::warn!("control: accept failed: {e}"); + return; + } + }; + if connected.load(Ordering::Relaxed) { + // One client at a time; tell the extra one why. + let mut refused = stream; + let _ = proto::write_line( + &mut refused, + &proto::err_line( + &Value::Null, + &CtlError::new(proto::INVALID_STATE, "another control client is attached"), + ), + ); + continue; + } + log::info!("control: connection from {peer}"); + stream.set_nodelay(true).ok(); + + // Writer thread: owns the write half, drains the reply channel. + let (reply_tx, reply_rx) = std::sync::mpsc::channel::(); + let write_stream = match stream.try_clone() { + Ok(clone) => clone, + Err(e) => { + log::warn!("control: cloning stream failed: {e}"); + continue; + } + }; + let writer = std::thread::Builder::new() + .name("control-write".into()) + .spawn(move || { + let mut stream = write_stream; + for line in reply_rx { + if proto::write_line(&mut stream, &line).is_err() { + break; + } + } + }) + .expect("spawning control writer thread"); + + *reply_slot.lock().expect("reply slot lock") = Some(reply_tx.clone()); + connected.store(true, Ordering::Relaxed); + let _ = cmd_tx.send(CtlMsg::Connected); + wake(); + + // Read this connection to completion on the accept thread; the + // next client is only accepted afterwards (one at a time). + read_connection(stream, &token, &cmd_tx, &reply_tx, &wake); + + *reply_slot.lock().expect("reply slot lock") = None; + connected.store(false, Ordering::Relaxed); + let _ = cmd_tx.send(CtlMsg::Disconnected); + wake(); + drop(reply_tx); // writer thread drains and exits + let _ = writer.join(); + log::info!("control: client detached; listening again"); + } +} + +/// Read one connection until EOF: framing, auth, and parsing happen +/// here so the frame loop only ever sees valid, authenticated requests. +fn read_connection( + stream: TcpStream, + token: &str, + cmd_tx: &Sender, + reply_tx: &Sender, + wake: &Arc, +) { + let mut reader = BufReader::new(stream); + let mut gate = AuthGate::new(token.to_string()); + loop { + let line = match proto::read_msg_line(&mut reader) { + Ok(Some(line)) => line, + Ok(None) => return, + Err(e) => { + log::warn!("control: read failed: {e}"); + return; + } + }; + let req = match proto::parse_request(&line) { + Ok(req) => req, + Err(reply) => { + let _ = reply_tx.send(reply); + continue; + } + }; + match gate.handle(&req) { + Gate::Reply(reply) => { + let _ = reply_tx.send(reply); + continue; + } + Gate::ReplyAndClose(reply) => { + let _ = reply_tx.send(reply); + return; + } + Gate::Pass => {} + } + if req.method == "shutdown" { + let _ = cmd_tx.send(CtlMsg::Shutdown { id: req.id }); + wake(); + continue; + } + match exec::parse_method(&req.method, &req.params) { + Ok(parsed) => { + let _ = cmd_tx.send(CtlMsg::Request { + id: req.id, + req: parsed, + }); + wake(); + } + Err(err) => { + let _ = reply_tx.send(proto::err_line(&req.id, &err)); + } + } + } +} diff --git a/src/gdbstub.rs b/src/gdbstub.rs index 746cf6c4..f174ca95 100644 --- a/src/gdbstub.rs +++ b/src/gdbstub.rs @@ -132,10 +132,13 @@ enum SessionEnd { Killed, } -fn normalize_listen_addr(input: &str) -> Result { +/// Expand the listen-address shorthand shared by the debug servers +/// (`--gdb`, `--control`, `--control-gui`): bare `PORT` and `:PORT` +/// bind loopback; anything else is taken verbatim. +pub(crate) fn normalize_listen_addr(input: &str) -> Result { let trimmed = input.trim(); if trimmed.is_empty() { - return Err(anyhow!("--gdb requires ADDR, :PORT, or PORT")); + return Err(anyhow!("listen address requires ADDR, :PORT, or PORT")); } if trimmed.starts_with(':') { return Ok(format!("127.0.0.1{trimmed}")); diff --git a/src/lib.rs b/src/lib.rs index 6495cbf6..e6326a73 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -23,6 +23,8 @@ pub mod cdrom; pub mod cdtv; pub mod chipset; pub mod config; +#[cfg(feature = "control")] +pub mod control; pub mod cpu; pub mod debugger; pub mod dirfs; diff --git a/src/main.rs b/src/main.rs index 82ede2d8..789a3665 100644 --- a/src/main.rs +++ b/src/main.rs @@ -44,6 +44,13 @@ pub struct CliArgs { /// `--gdb ADDR`: run a headless GDB remote-protocol server on ADDR, /// `:PORT`, or `PORT`, pausing at reset until the debugger resumes. pub gdb: Option, + /// `--control ADDR`: run the headless Copperline Control Protocol + /// server (JSON-RPC over loopback TCP), pausing at reset until a + /// client resumes. `--control-token`/`--control-info` refine it. + pub control: Option, + /// `--control-gui ADDR`: attach a control server to the normal + /// windowed session instead of owning the machine. + pub control_gui: Option, /// Dump consecutive rendered frames after an emulated-time delay. This /// is intended for debugging flicker and frame-to-frame palette /// changes that a single screenshot cannot show. @@ -240,6 +247,10 @@ where let mut load_state: Option = None; let mut benchmark_until: Option = None; let mut gdb: Option = None; + let mut control_listen: Option = None; + let mut control_gui_listen: Option = None; + let mut control_token: Option = None; + let mut control_info: Option = None; let mut dump_dir: Option = None; let mut dump_start_secs: f32 = 0.0; let mut dump_count: Option = None; @@ -513,6 +524,30 @@ where .ok_or_else(|| anyhow!("--gdb requires ADDR, :PORT, or PORT"))?; gdb = Some(gdbstub::Config::new(listen)); } + "--control" => { + let listen = args + .next() + .ok_or_else(|| anyhow!("--control requires ADDR, :PORT, or PORT"))?; + control_listen = Some(listen); + } + "--control-gui" => { + let listen = args + .next() + .ok_or_else(|| anyhow!("--control-gui requires ADDR, :PORT, or PORT"))?; + control_gui_listen = Some(listen); + } + "--control-token" => { + let token = args + .next() + .ok_or_else(|| anyhow!("--control-token requires a token string"))?; + control_token = Some(token); + } + "--control-info" => { + let path = args + .next() + .ok_or_else(|| anyhow!("--control-info requires a file path"))?; + control_info = Some(PathBuf::from(path)); + } "--dump-frames" => { let path = args .next() @@ -668,6 +703,28 @@ where None } }; + if control_listen.is_some() && control_gui_listen.is_some() { + return Err(anyhow!("--control and --control-gui cannot be combined")); + } + if control_listen.is_none() + && control_gui_listen.is_none() + && (control_token.is_some() || control_info.is_some()) + { + return Err(anyhow!( + "--control-token/--control-info require --control or --control-gui" + )); + } + let build_control = |listen: String, journal: Option| { + let mut config = copperline::control::Config::new(listen); + config.token = control_token.clone(); + config.info_file = control_info.clone(); + config.record_input = journal; + config + }; + // The headless control server owns the machine, so it journals + // --record-input itself; windowed mode journals through the App. + let control = control_listen.map(|listen| build_control(listen, record_input.clone())); + let control_gui = control_gui_listen.map(|listen| build_control(listen, None)); Ok(CliArgs { config_path, rom_path, @@ -676,6 +733,8 @@ where load_state, benchmark_until, gdb, + control, + control_gui, frame_dump, waveform, press_after, @@ -734,6 +793,12 @@ fn print_help() { \x20 time SECS, report counters, then exit\n \ --gdb ADDR run a headless GDB remote server on ADDR,\n \ \x20 :PORT, or PORT; port-only forms bind 127.0.0.1\n \ + --control ADDR run the headless JSON-RPC control server on ADDR\n \ + \x20 (port 0 picks a free port; see docs/debugger/control.md)\n \ + --control-gui ADDR attach the control server to the normal window\n \ + --control-token TOKEN pin the control auth token (default: generated;\n \ + \x20 visible in ps -- prefer --control-info)\n \ + --control-info PATH write the control endpoint and token to PATH as JSON\n \ --dump-frames DIR dump consecutive PNG frames into DIR, then exit\n \ --dump-start SECS start frame dumping after SECS seconds (default: 0)\n \ --dump-count COUNT number of frames to dump with --dump-frames\n \ @@ -948,6 +1013,60 @@ fn validate_gdb_args(cli: &CliArgs) -> Result<()> { Ok(()) } +fn validate_control_args(cli: &CliArgs) -> Result<()> { + if cli.control.is_some() || cli.control_gui.is_some() { + if cli.gdb.is_some() { + return Err(anyhow!( + "--control/--control-gui cannot be combined with --gdb" + )); + } + if cli.benchmark_until.is_some() { + return Err(anyhow!( + "--control/--control-gui cannot be combined with --benchmark-until" + )); + } + } + if cli.control.is_none() { + return Ok(()); + } + // The headless server owns the machine like --gdb does; the windowed + // App (which fires the scheduled/capture flags) never runs. Input + // recording IS supported: the server journals injected input itself. + if cli.screenshot_after.is_some() { + return Err(anyhow!( + "--control cannot be combined with --screenshot-after (use capture.screenshot)" + )); + } + if cli.save_state_after.is_some() { + return Err(anyhow!( + "--control cannot be combined with --save-state-after (use state.save)" + )); + } + if cli.frame_dump.is_some() { + return Err(anyhow!("--control cannot be combined with --dump-frames")); + } + if cli.live_audio_profile_secs.is_some() { + return Err(anyhow!( + "--control cannot be combined with --profile-live-audio" + )); + } + if !cli.press_after.is_empty() + || !cli.click_after.is_empty() + || !cli.joy_after.is_empty() + || !cli.mouse_after.is_empty() + { + return Err(anyhow!( + "--control cannot be combined with scheduled input events (use input.*)" + )); + } + if !cli.disk_insert_after.is_empty() { + return Err(anyhow!( + "--control cannot be combined with scheduled disk inserts (use media.*)" + )); + } + Ok(()) +} + fn run_headless_benchmark(mut emu: Emulator, target_secs: f32) -> Result<()> { emu.set_paced(false); emu.reset_stats(); @@ -1112,6 +1231,7 @@ fn main() -> Result<()> { let cli = parse_args()?; validate_benchmark_args(&cli)?; validate_gdb_args(&cli)?; + validate_control_args(&cli)?; if cli.calibrate_gamepad { return gamepad::run_calibration(); } @@ -1211,7 +1331,8 @@ fn main() -> Result<()> { let headless_capture = cli.screenshot_after.is_some() || cli.frame_dump.is_some() || cli.benchmark_until.is_some() - || cli.gdb.is_some(); + || cli.gdb.is_some() + || cli.control.is_some(); let paced = !headless_capture; info!("emulation timing: deterministic core, paced={paced}"); let mut emu = emulator::build_machine(&cfg, audio, paced, cli.load_state.is_some())?; @@ -1248,6 +1369,9 @@ fn main() -> Result<()> { if let Some(gdb) = cli.gdb { return gdbstub::run(emu, gdb); } + if let Some(control) = cli.control { + return copperline::control::headless::run(emu, control); + } let disk_write_protected = std::array::from_fn(|idx| { cfg.floppy.drives[idx] .as_ref() @@ -1255,7 +1379,7 @@ fn main() -> Result<()> { .unwrap_or(true) }); video::set_pixel_aspect(config::resolve_pixel_aspect(cfg.pixel_aspect)); - let app = App::new( + let mut app = App::new( emu, cfg.emulation.power_on, cli.screenshot_after, @@ -1277,6 +1401,13 @@ fn main() -> Result<()> { raw_cfg, live_audio, ); + if let Some(control_gui) = cli.control_gui { + // Bind (and announce) before the window opens so scripts can + // attach as soon as the endpoint line appears; the socket + // threads start inside App::run once the event loop exists. + let handle = copperline::control::windowed::ControlHandle::bind(&control_gui)?; + app.attach_control(handle, &control_gui); + } // Elevate the thread that is about to run the event loop and the pacer. // Only when actually pacing to wall-clock time: headless capture advances @@ -1383,6 +1514,8 @@ fn launcher_requested(cli: &CliArgs) -> bool { && cli.frame_dump.is_none() && cli.benchmark_until.is_none() && cli.gdb.is_none() + && cli.control.is_none() + && cli.control_gui.is_none() && cli.load_state.is_none() && cli.press_after.is_empty() && cli.click_after.is_empty() diff --git a/src/video/window.rs b/src/video/window.rs index 03b80d48..ce72aaaa 100644 --- a/src/video/window.rs +++ b/src/video/window.rs @@ -536,6 +536,11 @@ pub struct App { pending_auto_joys: Vec<(f32, JoyButtonKind, u32)>, auto_joy_held: AutoJoyHeld, auto_joy_engaged: bool, + /// The windowed control-protocol server (`--control-gui`), attached + /// after construction via [`App::attach_control`]; its commands are + /// drained at the top of `about_to_wait` (see window/control.rs). + #[cfg(feature = "control")] + control: Option, /// Scheduled relative port-1 mouse motions from --mouse-after, /// one-shot per entry; (at_emulated_secs, dx, dy). auto_mouse: Vec<(f64, i32, i32)>, @@ -921,6 +926,8 @@ impl App { pending_auto_joys: joy_after, auto_joy_held: AutoJoyHeld::default(), auto_joy_engaged: false, + #[cfg(feature = "control")] + control: None, auto_mouse: Vec::new(), pending_auto_mouse: mouse_after, auto_disk_inserts: Vec::new(), @@ -1147,6 +1154,16 @@ impl App { let event_loop = EventLoop::new().map_err(|e| anyhow!("EventLoop::new: {e}"))?; event_loop.set_control_flow(ControlFlow::Poll); let mut app = self; + // Start the control server's socket threads with a wake that + // kicks the loop out of ControlFlow::Wait, so a command arriving + // while the machine is paused is serviced promptly. + #[cfg(feature = "control")] + if let Some(ctl) = app.control.as_mut() { + let proxy = event_loop.create_proxy(); + ctl.handle.start(Box::new(move || { + let _ = proxy.send_event(()); + })); + } event_loop .run_app(&mut app) .map_err(|e| anyhow!("event loop: {e}"))?; @@ -1722,6 +1739,16 @@ impl ApplicationHandler for App { let hover = self .cursor_pos .and_then(|pos| control_at(pos, &bar_layout(&media))); + let control_connected = { + #[cfg(feature = "control")] + { + self.control.as_ref().is_some_and(|c| c.handle.connected()) + } + #[cfg(not(feature = "control"))] + { + false + } + }; let view = StatusBarView { status, powered_on: self.powered_on, @@ -1729,6 +1756,7 @@ impl ApplicationHandler for App { media, joystick_input_mode: self.joystick_input_mode, hover, + control_connected, }; let osd = self.active_osd_text(); let ui_hover = self.cursor_pos.and_then(|p| self.main_ui_control_at(p)); @@ -1816,6 +1844,18 @@ impl ApplicationHandler for App { } fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { + // Drain remote control commands first, before this pass's run + // state is computed, so they land at a frame boundary. Sits + // ahead of the render guard so tests can drive the drain on an + // App that never opened a window. + #[cfg(feature = "control")] + { + self.drain_control(); + if self.control_exit_requested() { + event_loop.exit(); + return; + } + } if self.render.is_none() { return; } @@ -1908,6 +1948,12 @@ impl ApplicationHandler for App { if self.surface_debug_stop() { break; } + // A remote run_until frame/cck target completes the + // pending resume and pauses at its boundary. + #[cfg(feature = "control")] + if self.control_run_target_reached() { + break; + } if frames_done >= frame_cap { break; } @@ -4807,6 +4853,10 @@ impl App { } self.paused = true; self.sync_live_audio_suspension(); + #[cfg(feature = "control")] + if !self.control_complete_pending("double_fault", &message) { + self.control_notify_stopped("double_fault", &message); + } self.show_osd(message); self.request_redraw(); return true; @@ -4819,6 +4869,17 @@ impl App { }; let message = stop.describe(); info!("debugger stop: {message}"); + // A stop while a remote resume is pending answers the client and + // pauses without commandeering the local debugger window. + #[cfg(feature = "control")] + if self.control_completes_stop(&stop) { + self.paused = true; + self.sync_live_audio_suspension(); + self.last_debug_stop = Some(message.clone()); + self.show_osd(message); + self.request_redraw(); + return true; + } self.paused = true; self.paused_before_debugger = true; self.sync_live_audio_suspension(); @@ -6659,6 +6720,10 @@ impl App { self.sync_live_audio_suspension(); if self.paused { info!("pause button: emulation paused"); + // A user pause completes a remote client's pending resume; + // the client learns where the machine stopped. + #[cfg(feature = "control")] + self.control_complete_pending("user_pause", "paused from the window"); } else { info!("pause button: emulation resumed"); } @@ -6672,6 +6737,8 @@ impl App { self.paused = false; self.sync_live_audio_suspension(); info!("power button: machine powered off (cold boot state)"); + #[cfg(feature = "control")] + self.control_complete_pending("pause", "power state changed"); if let Err(e) = self.emu.power_on_reset() { error!("cold power-on reset failed: {e:#}"); self.cpu_halted = true; @@ -6881,6 +6948,8 @@ impl App { } mod console; +#[cfg(feature = "control")] +mod control; mod host_input; mod present; mod statusbar; diff --git a/src/video/window/control.rs b/src/video/window/control.rs new file mode 100644 index 00000000..c86fe91b --- /dev/null +++ b/src/video/window/control.rs @@ -0,0 +1,646 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! The windowed control-protocol drain: commands enqueued by the socket +//! threads (`control::windowed`) are executed here, on the winit thread, +//! at the top of `about_to_wait` -- the same deterministic boundary the +//! scheduled-input scripting uses. Split out of `window.rs` for size; +//! this is the same `App`, with full access to its private state, and it +//! routes input/media through the exact helpers live input uses so +//! journaling (input recorder, reverse-replay log) is identical. + +use super::*; +use crate::control::exec::{ + self, CoreOp, HostOp, Request, ResumeKind, ResumeVerb, RunTarget, CCK_FINE_WINDOW, RUN_BUDGET, +}; +use crate::control::proto::{self, CtlError}; +use crate::control::session::{InputAction, SessionCtx}; +use crate::control::windowed::{ControlHandle, CtlMsg}; +use crate::debugger::DebugStop; +use serde_json::{json, Value}; + +/// Per-connection control state owned by the `App`. +pub(super) struct ControlState { + pub(super) handle: ControlHandle, + ctx: SessionCtx, + pending: Option, + /// A `run_until pc` target breakpoint this session planted (and must + /// remove on completion); `None` when the target address already had + /// a user breakpoint. + temp_pc_break: Option, + exit_requested: bool, + reverse_budget_mb: usize, + reverse_interval_frames: u64, +} + +/// A resume verb whose JSON-RPC response is deferred until the machine +/// stops. +struct PendingResume { + id: Value, + collect: Vec, + /// Stop-translation targets: a matching stop reports + /// `reason_on_target` instead of its raw trap reason. + pc_target: Option, + beam_target: Option, + frame_target: Option, + cck_target: Option, + reason_on_target: &'static str, +} + +impl PendingResume { + fn new(id: Value, collect: Vec) -> Self { + Self { + id, + collect, + pc_target: None, + beam_target: None, + frame_target: None, + cck_target: None, + reason_on_target: "target", + } + } +} + +impl App { + /// Adopt a bound control server; called from `main` between + /// `App::new` and `run()`. + pub fn attach_control(&mut self, handle: ControlHandle, config: &crate::control::Config) { + self.control = Some(ControlState { + handle, + ctx: SessionCtx::new(), + pending: None, + temp_pc_break: None, + exit_requested: false, + reverse_budget_mb: config.reverse_budget_mb, + reverse_interval_frames: config.reverse_interval_frames, + }); + } + + pub(super) fn control_exit_requested(&self) -> bool { + self.control.as_ref().is_some_and(|c| c.exit_requested) + } + + /// Drain queued control commands. First statement of + /// `about_to_wait`, before the machine steps, so commands land at a + /// frame boundary; also callable directly from tests (no sockets or + /// event loop required). + pub(super) fn drain_control(&mut self) { + if self.control.is_none() { + return; + } + self.control_apply_due_scheduled(); + while let Some(msg) = self.control.as_ref().and_then(|c| c.handle.try_recv()) { + match msg { + CtlMsg::Connected => self.control_on_connected(), + CtlMsg::Disconnected => self.control_on_disconnected(), + CtlMsg::Shutdown { id } => { + self.control_send(proto::ok_line(&id, json!({}))); + if let Some(ctl) = self.control.as_mut() { + ctl.exit_requested = true; + } + } + CtlMsg::Request { id, req } => self.control_dispatch(id, req), + } + } + } + + fn control_send(&self, line: String) { + if let Some(ctl) = &self.control { + ctl.handle.send(line); + } + } + + fn control_on_connected(&mut self) { + let (budget, interval) = { + let ctl = self + .control + .as_mut() + .expect("connected without control state"); + ctl.ctx = SessionCtx::new(); + ctl.pending = None; + ctl.temp_pc_break = None; + (ctl.reverse_budget_mb, ctl.reverse_interval_frames) + }; + // Arm the reverse-debug ring for this client, like the headless + // server and the debugger window do. + self.emu.enable_time_travel(budget, interval); + if let Err(e) = self.emu.debug_ensure_time_travel_anchor() { + warn!("control: arming time travel failed: {e:#}"); + } + self.emu.machine.ui_set_pc_history_enabled(true); + self.show_osd("Control client attached"); + self.request_redraw(); + } + + fn control_on_disconnected(&mut self) { + let Some(ctl) = self.control.as_mut() else { + return; + }; + // A pending resume has no client left to answer; the machine + // keeps whatever run state it is in (the window still owns it). + ctl.pending = None; + let temp_pc = ctl.temp_pc_break.take(); + let mut ctx = std::mem::take(&mut ctl.ctx); + if let Some(addr) = temp_pc { + self.emu.machine.ui_set_breakpoint(addr, None, 0); + } + self.emu.bus_mut().ui_disarm_beam_trap_once(); + ctx.remove_all_breaks(&mut self.emu); + self.show_osd("Control client detached"); + self.request_redraw(); + } + + fn control_dispatch(&mut self, id: Value, req: Request) { + match req { + Request::Core(op) => { + let pending = self.control.as_ref().is_some_and(|c| c.pending.is_some()); + if pending && !op.allowed_while_running() { + self.control_send(proto::err_line( + &id, + &CtlError::invalid_state("pause before repositioning the machine"), + )); + return; + } + // Keep the host-state flags `status` reports current. + let repositions = !op.allowed_while_running(); + let (paused, powered_on, halted) = (self.paused, self.powered_on, self.cpu_halted); + let result = { + let ctl = self + .control + .as_mut() + .expect("dispatch without control state"); + ctl.ctx.running = powered_on && !halted && !paused; + ctl.ctx.pending = pending; + ctl.ctx.powered_on = powered_on; + exec::exec_core(&mut self.emu, &mut ctl.ctx, &op) + }; + let line = match result { + Ok(value) => proto::ok_line(&id, value), + Err(err) => proto::err_line(&id, &err), + }; + self.control_send(line); + if repositions { + // Reverse steps / last-writer moved the timeline; + // refresh the presentation like the debugger's own + // reverse transports do. + self.finish_render_for_current_frame(); + self.request_redraw(); + } + } + Request::Host(op) => self.control_dispatch_host(id, op), + } + } + + fn control_dispatch_host(&mut self, id: Value, op: HostOp) { + match op { + HostOp::Pause => { + let had_pending = self.control_complete_pending("pause", "paused by client"); + let was_paused = self.paused; + if !was_paused { + self.paused = true; + self.sync_live_audio_suspension(); + self.request_redraw(); + } + let detail = if had_pending { + "paused by client" + } else if was_paused { + "already paused" + } else { + "paused" + }; + let stop = exec::stop_snapshot(&self.emu, "pause", detail); + match serde_json::to_value(&stop) { + Ok(value) => self.control_send(proto::ok_line(&id, value)), + Err(e) => { + self.control_send(proto::err_line(&id, &CtlError::internal(e.to_string()))) + } + } + } + HostOp::Resume(verb) => self.control_start_resume(id, verb), + HostOp::Input(cmd) => { + let now = self.emu.bus().emulated_seconds(); + let (immediate, later) = cmd.expand(now); + for action in immediate { + self.control_apply_input(action); + } + let scheduled = later.len(); + if let Some(ctl) = self.control.as_mut() { + for entry in later { + ctl.ctx.schedule(entry.at_seconds, entry.action); + } + } + self.control_send(proto::ok_line( + &id, + json!({"applied_at_seconds": now, "scheduled": scheduled}), + )); + } + HostOp::FloppyInsert { + drive, + path, + write_protected, + } => { + let line = if self.insert_disk_image(drive, path, write_protected) { + let name = self.emu.bus().floppy.inserted_disk_name(drive); + proto::ok_line(&id, json!({"drive": drive, "name": name})) + } else { + proto::err_line(&id, &CtlError::io("floppy insert failed (see log)")) + }; + self.control_send(line); + } + HostOp::FloppyEject { drive } => { + self.eject_drive_disk(drive); + self.control_send(proto::ok_line(&id, json!({}))); + } + HostOp::CdInsert { path } => { + let line = if !self.emu.bus().cd_drive_present() { + proto::err_line(&id, &CtlError::unsupported("no CD drive on this machine")) + } else { + self.insert_cd_image_from_path(&path); + if self.emu.bus().cd_disc_inserted() { + proto::ok_line(&id, json!({})) + } else { + proto::err_line(&id, &CtlError::io("CD image load failed (see log)")) + } + }; + self.control_send(line); + } + HostOp::CdEject => { + let line = if !self.emu.bus().cd_drive_present() { + proto::err_line(&id, &CtlError::unsupported("no CD drive on this machine")) + } else { + self.eject_cd(); + proto::ok_line(&id, json!({})) + }; + self.control_send(line); + } + HostOp::StateLoad { path } => { + if self.control.as_ref().is_some_and(|c| c.pending.is_some()) { + self.control_send(proto::err_line( + &id, + &CtlError::invalid_state("pause before loading a state"), + )); + return; + } + let line = if self.load_state_from_path(&path) { + // The snapshot ring's positions belong to the old + // timeline; re-arm on the loaded one. + let (budget, interval) = self + .control + .as_ref() + .map(|c| (c.reverse_budget_mb, c.reverse_interval_frames)) + .unwrap_or(( + crate::debugger::RR_DEFAULT_BUDGET_MB, + crate::debugger::RR_DEFAULT_INTERVAL_FRAMES, + )); + self.emu.enable_time_travel(budget, interval); + if let Err(e) = self.emu.debug_ensure_time_travel_anchor() { + warn!("control: re-arming time travel failed: {e:#}"); + } + proto::ok_line(&id, json!({"seconds": self.emu.bus().emulated_seconds()})) + } else { + proto::err_line(&id, &CtlError::io("state load failed (see log)")) + }; + self.control_send(line); + } + HostOp::Reset { warm } => { + if warm { + self.reset_emulator(true); + } else { + // Cold reset = power cycle: power_off parks a cold-boot + // state, then power back on to run from the reset vector. + self.power_off(); + self.powered_on = true; + self.sync_live_audio_suspension(); + self.request_redraw(); + } + self.control_send(proto::ok_line(&id, json!({}))); + } + } + } + + fn control_start_resume(&mut self, id: Value, verb: ResumeVerb) { + if self.control.as_ref().is_some_and(|c| c.pending.is_some()) { + self.control_send(proto::err_line( + &id, + &CtlError::new(proto::RESUME_PENDING, "a resume is already pending"), + )); + return; + } + if !self.powered_on { + self.control_send(proto::err_line( + &id, + &CtlError::invalid_state("machine is powered off"), + )); + return; + } + if self.emu.machine.cpu_double_faulted() { + self.control_send(proto::err_line( + &id, + &CtlError::invalid_state("CPU is double-faulted; reset the machine"), + )); + return; + } + match verb.kind { + // Bounded step verbs run synchronously at this boundary, + // like the debugger window's own step buttons. + ResumeKind::Step { .. } + | ResumeKind::StepOver + | ResumeKind::StepOut + | ResumeKind::StepCopper => { + if !self.paused { + self.control_send(proto::err_line( + &id, + &CtlError::invalid_state("machine is running; pause first"), + )); + return; + } + self.control_sync_step(id, verb); + } + ResumeKind::StepFrame { n } => { + let mut pending = PendingResume::new(id, verb.collect); + pending.frame_target = Some(self.emu.bus().emulated_frames() + u64::from(n.max(1))); + pending.reason_on_target = "step"; + self.control_arm_pending(pending); + } + ResumeKind::Continue => { + self.control_arm_pending(PendingResume::new(id, verb.collect)); + } + ResumeKind::RunUntil(target) => { + let mut pending = PendingResume::new(id, verb.collect); + match target { + RunTarget::Pc(pc) => { + pending.pc_target = Some(pc); + if !self.emu.machine.ui_breaks().is_breakpoint(pc) { + self.emu.machine.ui_set_breakpoint(pc, None, 0); + if let Some(ctl) = self.control.as_mut() { + ctl.temp_pc_break = Some(pc); + } + } + } + RunTarget::Beam { vpos, hpos } => { + pending.beam_target = Some(vpos); + self.emu.bus_mut().ui_arm_beam_trap_once(vpos, hpos); + } + RunTarget::Frame(frame) => pending.frame_target = Some(frame), + RunTarget::Cck(cck) => pending.cck_target = Some(cck), + RunTarget::Seconds(secs) => { + pending.cck_target = Some( + (secs * f64::from(crate::chipset::paula::PAULA_CLOCK_HZ)).ceil() as u64, + ); + } + } + self.control_arm_pending(pending); + } + } + } + + fn control_arm_pending(&mut self, pending: PendingResume) { + if let Some(ctl) = self.control.as_mut() { + ctl.pending = Some(pending); + } + if self.paused { + self.paused = false; + self.sync_live_audio_suspension(); + } + self.request_redraw(); + } + + /// Run a bounded step verb synchronously and reply immediately. + fn control_sync_step(&mut self, id: Value, verb: ResumeVerb) { + let mut label = "stepped"; + let result = (|| -> anyhow::Result<()> { + match verb.kind { + ResumeKind::Step { n } => { + label = "instruction step"; + let mut cpu_idle = false; + for _ in 0..n { + self.emu.debug_step_for_gdb(&mut cpu_idle)?; + if self.emu.machine.ui_debug_stop_pending() { + break; + } + } + } + ResumeKind::StepOver => { + label = "stepped over"; + self.emu.debug_step_over(RUN_BUDGET)?; + } + ResumeKind::StepOut => { + label = "stepped out"; + self.emu.debug_step_out(RUN_BUDGET)?; + } + ResumeKind::StepCopper => { + label = "copper instruction retired"; + self.emu.debug_step_copper(RUN_BUDGET)?; + } + _ => unreachable!("sync step called with an unbounded verb"), + } + Ok(()) + })(); + if let Err(e) = result { + self.cpu_halted = true; + self.sync_live_audio_suspension(); + self.control_send(proto::err_line(&id, &CtlError::internal(format!("{e:#}")))); + return; + } + let (reason, detail) = if self.emu.machine.cpu_double_faulted() { + ("double_fault", "CPU double fault (halted)".to_string()) + } else if let Some(stop) = self.emu.machine.take_ui_debug_stop() { + let (reason, detail) = exec::stop_reason_of(&stop); + (reason, detail) + } else { + ("step", label.to_string()) + }; + self.control_reply_stop(&id, verb.collect, reason, &detail); + self.last_debug_stop = Some(detail); + self.finish_render_for_current_frame(); + self.request_redraw(); + } + + /// Build a stop event (with collect) and send it as the response to + /// `id`. + fn control_reply_stop(&mut self, id: &Value, collect: Vec, reason: &str, detail: &str) { + let mut stop = exec::stop_snapshot(&self.emu, reason, detail); + if !collect.is_empty() { + let collected = { + let ctl = self.control.as_mut().expect("reply without control state"); + exec::eval_collect(&mut self.emu, &mut ctl.ctx, &collect) + }; + stop.collect = Some(collected); + } + match serde_json::to_value(&stop) { + Ok(value) => self.control_send(proto::ok_line(id, value)), + Err(e) => self.control_send(proto::err_line(id, &CtlError::internal(e.to_string()))), + } + } + + /// Complete the pending resume, if any, with the given stop reason. + /// Returns whether one was completed. + pub(super) fn control_complete_pending(&mut self, reason: &str, detail: &str) -> bool { + let Some((pending, temp_pc)) = self + .control + .as_mut() + .and_then(|ctl| ctl.pending.take().map(|p| (p, ctl.temp_pc_break.take()))) + else { + return false; + }; + if let Some(addr) = temp_pc { + // Toggle our temporary run-to breakpoint back off. + self.emu.machine.ui_set_breakpoint(addr, None, 0); + } + self.emu.bus_mut().ui_disarm_beam_trap_once(); + self.control_reply_stop(&pending.id.clone(), pending.collect, reason, detail); + true + } + + /// Hook for `surface_debug_stop`: when a remote resume is pending, + /// the stop answers the client instead of commandeering the local + /// debugger window. Returns whether the stop was consumed remotely. + pub(super) fn control_completes_stop(&mut self, stop: &DebugStop) -> bool { + let Some(ctl) = self.control.as_ref() else { + return false; + }; + let Some(pending) = ctl.pending.as_ref() else { + self.control_notify_stopped_of(stop); + return false; + }; + let (mut reason, detail) = exec::stop_reason_of(stop); + match stop { + DebugStop::Breakpoint { pc } + if pending.pc_target.is_some_and(|t| { + t & self.emu.machine.ui_addr_mask() == *pc & self.emu.machine.ui_addr_mask() + }) => + { + reason = pending.reason_on_target; + } + DebugStop::Beam { vpos, .. } if pending.beam_target == Some(*vpos) => { + reason = pending.reason_on_target; + } + _ => {} + } + self.control_complete_pending(reason, &detail); + true + } + + /// Notify an attached client of a stop it did not request (a GUI + /// breakpoint, a user pause) as an `event.stopped` notification. + fn control_notify_stopped_of(&self, stop: &DebugStop) { + let (reason, detail) = exec::stop_reason_of(stop); + self.control_notify_stopped(reason, &detail); + } + + pub(super) fn control_notify_stopped(&self, reason: &str, detail: &str) { + let Some(ctl) = &self.control else { + return; + }; + if ctl.pending.is_some() || !ctl.handle.connected() { + return; + } + let stop = exec::stop_snapshot(&self.emu, reason, detail); + if let Ok(value) = serde_json::to_value(&stop) { + ctl.handle.send(proto::event_line("event.stopped", value)); + } + } + + /// Burst-loop check: complete a pending frame/cck target when the + /// machine has reached it. Returns true when the run finished (the + /// burst should end). + pub(super) fn control_run_target_reached(&mut self) -> bool { + let Some(pending) = self.control.as_ref().and_then(|c| c.pending.as_ref()) else { + return false; + }; + let (frame_target, cck_target, reason) = ( + pending.frame_target, + pending.cck_target, + pending.reason_on_target, + ); + if let Some(frame) = frame_target { + if self.emu.bus().emulated_frames() >= frame { + self.paused = true; + self.sync_live_audio_suspension(); + self.control_complete_pending(reason, &format!("frame {frame}")); + self.request_redraw(); + return true; + } + } + if let Some(cck) = cck_target { + let current = self.emu.bus().emulated_cck(); + if current >= cck || cck - current < CCK_FINE_WINDOW { + // Land on the first instruction boundary at or past the + // target (bounded: less than a frame away). + let mut cpu_idle = false; + while self.emu.bus().emulated_cck() < cck { + if let Err(e) = self.emu.debug_step_for_gdb(&mut cpu_idle) { + error!("emulator step halted: {e:?}"); + self.cpu_halted = true; + break; + } + if self.emu.machine.ui_debug_stop_pending() { + // A trap fired first; let the normal stop path + // complete the pending with its reason. + return self.surface_debug_stop(); + } + } + self.paused = true; + self.sync_live_audio_suspension(); + self.control_complete_pending(reason, &format!("cck {cck}")); + self.request_redraw(); + return true; + } + } + false + } + + /// Apply control-scheduled input whose emulated time has arrived, + /// through the same App helpers live input uses. + fn control_apply_due_scheduled(&mut self) { + let now = self.emu.bus().emulated_seconds(); + let due: Vec = { + let Some(ctl) = self.control.as_mut() else { + return; + }; + let mut due = Vec::new(); + while ctl + .ctx + .scheduled + .first() + .is_some_and(|entry| entry.at_seconds <= now) + { + due.push(ctl.ctx.scheduled.remove(0).action); + } + due + }; + for action in due { + self.control_apply_input(action); + } + } + + fn control_apply_input(&mut self, action: InputAction) { + match action { + InputAction::Key { rawkey, pressed } => self.handle_amiga_key_event(rawkey, pressed), + InputAction::MouseButton { index, pressed } => { + let kind = match index { + 0 => MouseButtonKind::Left, + 1 => MouseButtonKind::Right, + _ => MouseButtonKind::Middle, + }; + set_mouse_button(&mut self.emu, kind, pressed); + } + InputAction::MouseMove { dx, dy } => self.add_mouse_delta_i32(dx, dy), + InputAction::Joy(j) => { + self.auto_joy_held = AutoJoyHeld { + up: j.up, + down: j.down, + left: j.left, + right: j.right, + red: j.red, + blue: j.blue, + green: j.green, + yellow: j.yellow, + play: j.play, + rwd: j.rwd, + ffw: j.ffw, + }; + self.apply_auto_joy_state(); + } + } + } +} diff --git a/src/video/window/statusbar.rs b/src/video/window/statusbar.rs index 8e37aa01..cb8b6a2b 100644 --- a/src/video/window/statusbar.rs +++ b/src/video/window/statusbar.rs @@ -106,6 +106,18 @@ pub(super) fn draw_status_bar(frame: &mut [u8], view: &StatusBarView, texture_sc hover == Some(BarControl::Joystick), texture_scale, ); + if view.control_connected { + // A remote control-protocol client is attached; tag the bar so a + // machine that pauses or steps "by itself" is explicable. + draw_text( + frame, + (JOY_TOGGLE_X.saturating_sub(44)) * texture_scale, + (present_height() + STATUS_CONTROL_Y + 2) * texture_scale, + "CCP", + STATUS_TEXT, + texture_scale, + ); + } draw_volume_control(frame, status.output_volume_percent, texture_scale); draw_menu_button( frame, @@ -169,6 +181,8 @@ pub(super) struct StatusBarView { /// Active host joystick source, shown by the status-bar toggle icon. pub(super) joystick_input_mode: JoystickInputMode, pub(super) hover: Option, + /// A control-protocol client is attached (--control-gui). + pub(super) control_connected: bool, } /// A clickable status-bar control, used for hit-testing and hover. diff --git a/src/video/window/tests.rs b/src/video/window/tests.rs index b2cb8404..ed6c83ce 100644 --- a/src/video/window/tests.rs +++ b/src/video/window/tests.rs @@ -68,6 +68,7 @@ fn view(status: FrontPanelStatus, powered_on: bool, paused: bool) -> StatusBarVi media: single_drive_media(), joystick_input_mode: JoystickInputMode::Gamepad, hover: None, + control_connected: false, } } @@ -951,6 +952,7 @@ fn status_bar_draws_cd_buttons_only_on_cd_machines() { media: bar, joystick_input_mode: JoystickInputMode::Gamepad, hover: None, + control_connected: false, }; draw_status_bar(&mut frame, &v, scale); // The disc body below the hub. @@ -3679,3 +3681,172 @@ fn dropped_files_coalesce_across_events() { // Single drive connected: both disks become DF0's playlist. assert_eq!(app.disk_playlists[0].len(), 2); } + +/// Windowed control-protocol drain tests: a synthetic ControlHandle +/// (channel pair, no sockets) feeds commands straight into the same +/// drain `about_to_wait` runs, against the real App and emulator. +#[cfg(feature = "control")] +mod control_drain { + use super::test_app; + use crate::control::exec::parse_method; + use crate::control::windowed::{ControlHandle, CtlMsg}; + use serde_json::{json, Value}; + use std::sync::mpsc::{Receiver, Sender}; + + fn attached_app() -> (super::super::App, Sender, Receiver) { + let mut app = test_app(); + let (handle, cmd_tx, reply_rx) = ControlHandle::test_pair(); + app.attach_control(handle, &crate::control::Config::new(":0".into())); + (app, cmd_tx, reply_rx) + } + + fn push(cmd_tx: &Sender, id: u64, method: &str, params: Value) { + let req = parse_method(method, ¶ms).expect("request should parse"); + cmd_tx.send(CtlMsg::Request { id: json!(id), req }).unwrap(); + } + + fn reply(reply_rx: &Receiver) -> Value { + serde_json::from_str(&reply_rx.try_recv().expect("a reply should be queued")) + .expect("replies are JSON") + } + + #[test] + fn drain_executes_core_ops_and_replies() { + let (mut app, cmd_tx, reply_rx) = attached_app(); + push(&cmd_tx, 1, "regs.get", Value::Null); + app.drain_control(); + let msg = reply(&reply_rx); + assert_eq!(msg["id"], 1); + assert_eq!(msg["result"]["pc"], app.emu.machine.pc()); + } + + #[test] + fn continue_completes_on_breakpoint_without_opening_the_debugger() { + let (mut app, cmd_tx, reply_rx) = attached_app(); + let target = app.emu.machine.pc() + 16; // ahead in the NOP sled + push( + &cmd_tx, + 1, + "break.add", + json!({"kind": "pc", "addr": target}), + ); + push(&cmd_tx, 2, "continue", json!({})); + app.drain_control(); + assert_eq!(reply(&reply_rx)["result"]["id"], 1); + assert!(!app.paused, "continue unpaused the machine"); + + // Mimic the about_to_wait burst: step frames, surface stops. + let mut stopped = false; + for _ in 0..3 { + app.emu.step_frame().unwrap(); + if app.surface_debug_stop() { + stopped = true; + break; + } + } + assert!(stopped, "the planted breakpoint should stop the run"); + assert!(app.paused, "a remote stop pauses the machine"); + assert!( + app.debugger_panel.is_none(), + "a remote-driven stop must not commandeer the debugger window" + ); + let stop = reply(&reply_rx); + assert_eq!(stop["id"], 2); + assert_eq!(stop["result"]["reason"], "breakpoint"); + assert_eq!(stop["result"]["pc"], target); + } + + #[test] + fn run_until_frame_target_completes_in_the_burst_check() { + let (mut app, cmd_tx, reply_rx) = attached_app(); + let target = app.emu.bus().emulated_frames() + 2; + push(&cmd_tx, 1, "run_until", json!({"frame": target})); + app.drain_control(); + assert!(!app.paused); + let mut completed = false; + for _ in 0..4 { + app.emu.step_frame().unwrap(); + if app.surface_debug_stop() { + break; + } + if app.control_run_target_reached() { + completed = true; + break; + } + } + assert!(completed, "the frame target should complete the run"); + assert!(app.paused); + let stop = reply(&reply_rx); + assert_eq!(stop["result"]["reason"], "target"); + assert!(stop["result"]["frame"].as_u64().unwrap() >= target); + } + + #[test] + fn user_pause_completes_a_pending_resume() { + let (mut app, cmd_tx, reply_rx) = attached_app(); + push(&cmd_tx, 1, "continue", json!({})); + app.drain_control(); + assert!(!app.paused); + app.toggle_pause(); + assert!(app.paused); + let stop = reply(&reply_rx); + assert_eq!(stop["id"], 1); + assert_eq!(stop["result"]["reason"], "user_pause"); + } + + #[test] + fn injected_key_reaches_the_app_recorder() { + let (mut app, cmd_tx, reply_rx) = attached_app(); + app.input_recorder = Some(crate::inputrec::InputRecorder::new(0.0)); + push( + &cmd_tx, + 1, + "input.key", + json!({"rawkey": 0x45, "action": "press"}), + ); + app.drain_control(); + let msg = reply(&reply_rx); + assert!(msg["result"]["applied_at_seconds"].is_number()); + let recorder = app.input_recorder.take().unwrap(); + assert!( + recorder.events_recorded() > 0, + "the App recorder journals control-injected input" + ); + } + + #[test] + fn connected_arms_time_travel_and_shutdown_requests_exit() { + let (mut app, cmd_tx, reply_rx) = attached_app(); + assert!(!app.emu.time_travel_enabled()); + cmd_tx.send(CtlMsg::Connected).unwrap(); + app.drain_control(); + assert!(app.emu.time_travel_enabled(), "connect arms the ring"); + + cmd_tx.send(CtlMsg::Shutdown { id: json!(9) }).unwrap(); + app.drain_control(); + assert!(app.control_exit_requested()); + let msg = reply(&reply_rx); + assert_eq!(msg["id"], 9); + } + + #[test] + fn step_requires_a_paused_machine_and_advances_it() { + let (mut app, cmd_tx, reply_rx) = attached_app(); + // test_app starts unpaused: step must refuse. + push(&cmd_tx, 1, "step", json!({"n": 2})); + app.drain_control(); + assert_eq!( + reply(&reply_rx)["error"]["code"], + crate::control::proto::INVALID_STATE + ); + + app.paused = true; + let before = app.emu.machine.pc(); + push(&cmd_tx, 2, "step", json!({"n": 2})); + app.drain_control(); + let stop = reply(&reply_rx); + assert_eq!(stop["result"]["reason"], "step"); + assert_eq!(stop["result"]["pc"], before + 4, "two NOPs retired"); + assert!(app.paused, "sync steps leave the machine paused"); + } +} From 4ecb5693270632bc2a3d8e7f7a707200d9969c06 Mon Sep 17 00:00:00 2001 From: Andrew Hutchings Date: Thu, 16 Jul 2026 09:14:02 +0100 Subject: [PATCH 2/2] control: address review feedback on normalization, validation, and feature gating - Normalize break specs at install time to the exact form the machine stores key them by (PC/watch through the address-bus mask, watches word-aligned, copper breaks masked to an even 24-bit address), so break.list attaches ids reliably and removing a copper break added with an unnormalized address cannot leak it installed. - Reject out-of-range beam coordinates and catch vectors instead of silently wrapping u32 -> u16, and reject negative or non-finite run_until seconds (previously saturated to a cck target of 0 and completed immediately); non-finite input.key at_seconds likewise. - Point the StopEvent reason doc comment at docs/debugger/control.md instead of enumerating a value list that had already drifted, and make the documented list exhaustive. - Keep the control feature genuinely optional for the copperline binary: CliArgs carries raw --control/--control-gui strings, the server config is assembled at the cfg-gated dispatch sites, and a build without the feature rejects the flags with a clear error, so --no-default-features --features frontend compiles again. --- Cargo.toml | 2 +- docs/debugger/control.md | 4 +- src/control/exec.rs | 58 ++++++++++++++++++++++---- src/control/proto.rs | 7 ++-- src/control/session.rs | 89 ++++++++++++++++++++++++++++++++++++++++ src/main.rs | 56 ++++++++++++++++--------- 6 files changed, 184 insertions(+), 32 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 5ece0256..c249507e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -123,7 +123,7 @@ web-time = "1" [[bin]] name = "copperline" path = "src/main.rs" -required-features = ["frontend", "control"] +required-features = ["frontend"] [[bin]] name = "copperline-bench" diff --git a/docs/debugger/control.md b/docs/debugger/control.md index 6fb32a07..a50877ff 100644 --- a/docs/debugger/control.md +++ b/docs/debugger/control.md @@ -89,7 +89,9 @@ Every stop event carries a consistent position on the emulated timeline: Reasons: `breakpoint`, `watchpoint`, `reg_watch`, `beam_trap`, `copper_break`, `catch`, `task_catch`, `step`, `target`, `pause`, -`user_pause`, `double_fault`, `reverse`, `budget`. +`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 diff --git a/src/control/exec.rs b/src/control/exec.rs index 5e168cb6..12771edb 100644 --- a/src/control/exec.rs +++ b/src/control/exec.rs @@ -421,7 +421,14 @@ pub fn parse_method(method: &str, params: &Value) -> Result { host(HostOp::Input(InputCmd::Key { rawkey: rawkey as u8, kind, - at_seconds: p.f64_opt("at_seconds")?, + at_seconds: match p.f64_opt("at_seconds")? { + Some(t) if !t.is_finite() => { + return Err(CtlError::invalid_params( + "at_seconds must be a finite number", + )) + } + other => other, + }, })) } "input.mouse" => host(HostOp::Input(InputCmd::Mouse { @@ -521,10 +528,10 @@ fn parse_run_target(p: &ParamReader) -> Result { if let Some(pc) = p.u32_opt("pc")? { targets.push(RunTarget::Pc(pc)); } - if let Some(vpos) = p.u32_opt("vpos")? { + if let Some(vpos) = p.u16_opt("vpos")? { targets.push(RunTarget::Beam { - vpos: vpos as u16, - hpos: p.u32_opt("hpos")?.map(|h| h as u16), + vpos, + hpos: p.u16_opt("hpos")?, }); } if let Some(frame) = p.u64_opt("frame")? { @@ -534,6 +541,11 @@ fn parse_run_target(p: &ParamReader) -> Result { targets.push(RunTarget::Cck(cck)); } if let Some(secs) = p.f64_opt("seconds")? { + if !secs.is_finite() || secs < 0.0 { + return Err(CtlError::invalid_params( + "seconds must be a finite, non-negative number", + )); + } targets.push(RunTarget::Seconds(secs)); } match targets.len() { @@ -572,14 +584,14 @@ fn parse_break_spec(p: &ParamReader) -> Result { off: parse_custom_reg_param(p)?, }), "beam" => Ok(BreakSpec::Beam { - vpos: p.u32_req("vpos")? as u16, - hpos: p.u32_opt("hpos")?.map(|h| h as u16), + vpos: p.u16_req("vpos")?, + hpos: p.u16_opt("hpos")?, }), "copper" => Ok(BreakSpec::Copper { addr: p.u32_req("addr")?, }), "catch" => Ok(BreakSpec::Catch { - vector: p.u32_req("vector")? as u16, + vector: p.u16_req("vector")?, }), other => Err(CtlError::invalid_params(format!( "kind must be pc|watch|reg_watch|beam|copper|catch, got {other}" @@ -740,6 +752,20 @@ impl<'a> ParamReader<'a> { Ok(self.u32_opt(key)?.unwrap_or(default)) } + fn u16_req(&self, key: &str) -> Result { + self.u16_opt(key)? + .ok_or_else(|| CtlError::invalid_params(format!("missing {key}"))) + } + + fn u16_opt(&self, key: &str) -> Result, CtlError> { + match self.u32_opt(key)? { + None => Ok(None), + Some(value) => u16::try_from(value) + .map(Some) + .map_err(|_| CtlError::invalid_params(format!("{key} must be 0..={}", u16::MAX))), + } + } + fn u64_opt(&self, key: &str) -> Result, CtlError> { match self.get(key) { None | Some(Value::Null) => Ok(None), @@ -1325,6 +1351,24 @@ mod tests { ); } + #[test] + fn parse_rejects_out_of_range_targets() { + // u16 beam coordinates must not silently wrap. + let err = parse_method("run_until", &json!({"vpos": 70000})).unwrap_err(); + assert_eq!(err.code, proto::INVALID_PARAMS); + let err = parse_method("run_until", &json!({"vpos": 100, "hpos": 65536})).unwrap_err(); + assert_eq!(err.code, proto::INVALID_PARAMS); + // Negative or non-finite seconds would otherwise saturate to a + // cck target of 0 and complete immediately. + let err = parse_method("run_until", &json!({"seconds": -1.0})).unwrap_err(); + assert_eq!(err.code, proto::INVALID_PARAMS); + let err = parse_method("break.add", &json!({"kind": "beam", "vpos": 70000})).unwrap_err(); + assert_eq!(err.code, proto::INVALID_PARAMS); + let err = + parse_method("break.add", &json!({"kind": "catch", "vector": 70000})).unwrap_err(); + assert_eq!(err.code, proto::INVALID_PARAMS); + } + #[test] fn parse_run_until_requires_exactly_one_target() { assert!(parse_method("run_until", &json!({})).is_err()); diff --git a/src/control/proto.rs b/src/control/proto.rs index efbf2c90..20c81fde 100644 --- a/src/control/proto.rs +++ b/src/control/proto.rs @@ -224,9 +224,10 @@ fn line_from_utf8(buf: Vec) -> io::Result> { /// `event.stopped`: a consistent coordinate on the emulated timeline. #[derive(Debug, Clone, Serialize)] pub struct StopEvent { - /// `breakpoint`, `watchpoint`, `reg_watch`, `beam_trap`, - /// `copper_break`, `catch`, `step`, `target`, `pause`, `user_pause`, - /// `double_fault`, `reverse`, or `history_partial`. + /// Why the machine stopped. The value set is part of the wire + /// contract and documented in docs/debugger/control.md; the trap + /// kinds come from `exec::stop_reason_of`, the run-control reasons + /// from the drivers. pub reason: String, /// Human-readable detail (the debug-stop description, target spec...). pub detail: String, diff --git a/src/control/session.rs b/src/control/session.rs index 35f955ab..1a6cbefa 100644 --- a/src/control/session.rs +++ b/src/control/session.rs @@ -109,6 +109,11 @@ impl SessionCtx { /// local GUI), because the underlying store toggles: installing /// twice would silently remove it. pub fn install_break(&mut self, emu: &mut Emulator, spec: BreakSpec) -> Result { + // Store the spec exactly as the machine's break stores key it, + // so existence checks, `break.list` id attachment, and removal + // all compare like with like whatever address form the client + // sent. + let spec = normalize_spec(emu, spec); if self.break_exists(emu, &spec) { return Err(format!("already set: {}", describe_spec(&spec))); } @@ -222,6 +227,33 @@ impl Default for SessionCtx { } } +/// Rewrite `spec`'s coordinates into the canonical form the machine's +/// break stores use, mirroring each `ui_*` install call's own masking: +/// PC breakpoints and memory watches compare through the address-bus +/// mask (watches also word-align), Copper breakpoints mask to an even +/// 24-bit chip address. Beam traps and catches match their inputs +/// exactly. +fn normalize_spec(emu: &Emulator, spec: BreakSpec) -> BreakSpec { + let addr_mask = emu.machine.ui_addr_mask(); + match spec { + BreakSpec::Pc { addr, cond, ignore } => BreakSpec::Pc { + addr: addr & addr_mask, + cond, + ignore, + }, + BreakSpec::Watch { addr, source } => BreakSpec::Watch { + addr: addr & addr_mask & !1, + source, + }, + BreakSpec::Copper { addr } => BreakSpec::Copper { + addr: addr & 0x00FF_FFFE, + }, + other @ (BreakSpec::RegWatch { .. } | BreakSpec::Beam { .. } | BreakSpec::Catch { .. }) => { + other + } + } +} + /// Whether two specs address the same point in the break store (the /// coordinates the machine keys on), regardless of condition or ignore /// count. @@ -448,6 +480,63 @@ mod tests { assert!(emu.bus().ui_copper_breaks().is_empty()); } + #[test] + fn specs_are_normalized_like_the_machine_stores_key_them() { + let mut emu = test_emulator(); + let mut ctx = SessionCtx::new(); + // A 24-bit machine masks PC breakpoint addresses; the session + // spec must key the same way or break.list/remove lose track. + let id = ctx + .install_break( + &mut emu, + BreakSpec::Pc { + addr: 0xFFF8_001A, + cond: None, + ignore: 0, + }, + ) + .unwrap(); + assert!(emu.machine.ui_breaks().is_breakpoint(0xF8_001A)); + assert_eq!( + ctx.id_for(&BreakSpec::Pc { + addr: 0xF8_001A, + cond: None, + ignore: 0 + }), + Some(id), + "the masked machine-store address must map back to the id" + ); + + // Copper breaks mask to an even 24-bit address; an odd raw spec + // must still toggle back off on removal (not leak). + let copper = ctx + .install_break(&mut emu, BreakSpec::Copper { addr: 0x2_0001 }) + .unwrap(); + assert_eq!(emu.bus().ui_copper_breaks(), &[0x2_0000]); + assert!(ctx.remove_break(&mut emu, copper)); + assert!(emu.bus().ui_copper_breaks().is_empty()); + + // Watches word-align; duplicate adds through a different raw + // form of the same word are refused instead of toggled away. + ctx.install_break( + &mut emu, + BreakSpec::Watch { + addr: 0x3_0001, + source: None, + }, + ) + .unwrap(); + let dup = ctx.install_break( + &mut emu, + BreakSpec::Watch { + addr: 0x3_0000, + source: None, + }, + ); + assert!(dup.is_err()); + assert_eq!(emu.machine.ui_breaks().watches.len(), 1); + } + #[test] fn teardown_leaves_gui_owned_points_alone() { let mut emu = test_emulator(); diff --git a/src/main.rs b/src/main.rs index 789a3665..a8b119bf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -47,10 +47,16 @@ pub struct CliArgs { /// `--control ADDR`: run the headless Copperline Control Protocol /// server (JSON-RPC over loopback TCP), pausing at reset until a /// client resumes. `--control-token`/`--control-info` refine it. - pub control: Option, + /// Kept as the raw listen address so the CLI parses without the + /// `control` feature; a build without it rejects the flags in + /// validation, and `main` assembles the server config at dispatch. + pub control: Option, /// `--control-gui ADDR`: attach a control server to the normal /// windowed session instead of owning the machine. - pub control_gui: Option, + pub control_gui: Option, + /// `--control-token TOKEN` / `--control-info PATH` for either mode. + pub control_token: Option, + pub control_info: Option, /// Dump consecutive rendered frames after an emulated-time delay. This /// is intended for debugging flicker and frame-to-frame palette /// changes that a single screenshot cannot show. @@ -714,17 +720,6 @@ where "--control-token/--control-info require --control or --control-gui" )); } - let build_control = |listen: String, journal: Option| { - let mut config = copperline::control::Config::new(listen); - config.token = control_token.clone(); - config.info_file = control_info.clone(); - config.record_input = journal; - config - }; - // The headless control server owns the machine, so it journals - // --record-input itself; windowed mode journals through the App. - let control = control_listen.map(|listen| build_control(listen, record_input.clone())); - let control_gui = control_gui_listen.map(|listen| build_control(listen, None)); Ok(CliArgs { config_path, rom_path, @@ -733,8 +728,10 @@ where load_state, benchmark_until, gdb, - control, - control_gui, + control: control_listen, + control_gui: control_gui_listen, + control_token, + control_info, frame_dump, waveform, press_after, @@ -1014,6 +1011,13 @@ fn validate_gdb_args(cli: &CliArgs) -> Result<()> { } fn validate_control_args(cli: &CliArgs) -> Result<()> { + #[cfg(not(feature = "control"))] + if cli.control.is_some() || cli.control_gui.is_some() { + return Err(anyhow!( + "this build was compiled without the control feature; \ + rebuild with --features control for --control/--control-gui" + )); + } if cli.control.is_some() || cli.control_gui.is_some() { if cli.gdb.is_some() { return Err(anyhow!( @@ -1369,8 +1373,15 @@ fn main() -> Result<()> { if let Some(gdb) = cli.gdb { return gdbstub::run(emu, gdb); } - if let Some(control) = cli.control { - return copperline::control::headless::run(emu, control); + #[cfg(feature = "control")] + if let Some(listen) = cli.control.clone() { + let mut config = copperline::control::Config::new(listen); + config.token = cli.control_token.clone(); + config.info_file = cli.control_info.clone(); + // The headless server owns the machine, so it journals + // --record-input itself; windowed mode journals through the App. + config.record_input = cli.record_input.clone(); + return copperline::control::headless::run(emu, config); } let disk_write_protected = std::array::from_fn(|idx| { cfg.floppy.drives[idx] @@ -1379,6 +1390,7 @@ fn main() -> Result<()> { .unwrap_or(true) }); video::set_pixel_aspect(config::resolve_pixel_aspect(cfg.pixel_aspect)); + #[cfg_attr(not(feature = "control"), allow(unused_mut))] let mut app = App::new( emu, cfg.emulation.power_on, @@ -1401,12 +1413,16 @@ fn main() -> Result<()> { raw_cfg, live_audio, ); - if let Some(control_gui) = cli.control_gui { + #[cfg(feature = "control")] + if let Some(listen) = cli.control_gui { // Bind (and announce) before the window opens so scripts can // attach as soon as the endpoint line appears; the socket // threads start inside App::run once the event loop exists. - let handle = copperline::control::windowed::ControlHandle::bind(&control_gui)?; - app.attach_control(handle, &control_gui); + let mut config = copperline::control::Config::new(listen); + config.token = cli.control_token; + config.info_file = cli.control_info; + let handle = copperline::control::windowed::ControlHandle::bind(&config)?; + app.attach_control(handle, &config); } // Elevate the thread that is about to run the event loop and the pacer.