OXYGENHUB
# 398756c0e9b2 retro-remote-debug-controller / SPEC.md
17.3 KiB · Markdown · last changed docs: bring SPEC/README up to 0.5.0 and record the no-pointer gap 3 months ago renderedsource blame history raw
961b3c61 Andy McCall 1
# Retro Remote Debug Controller — HTTP Control Contract
2
398756c0 Andy McCall ↶ 3
**Contract version: 0.5.0** (semver; clients assert on the MAJOR). 0.5 adds a
4
virtual game controller (`POST /pad`, `GET /pad`); 0.4 added pointer injection
5
(`POST /pointer`, `GET /pointer`); 0.3 added memory write (`POST /mem`) and an
6
audio drain (`GET /audio`); 0.2 added input injection (`POST /key`,
7
`POST /reset`). Every minor is purely additive, so older clients keep working and
8
servers advertise the highest level whose callbacks are all present via
9
`/status.contract`.
961b3c61 Andy McCall 10
11
A minimal, portable HTTP API that a retro-platform emulator exposes so an
12
external harness can screenshot the screen, read memory and registers, step the
d44319b1 Andy McCall ↶ 13
machine deterministically, and inject input. The same contract is implemented
4c0b050e Andy McCall ↶ 14
by the forked FAB Agon Emulator, the Neo6502 emulator, the Commander X16
15
emulator, and the NEC PC-FX (Mednafen) emulator, so **one** pytest harness
16
drives all four ports.
961b3c61 Andy McCall 17
18
## Principles
19
20
- **Opt-in.** The server is OFF by default and starts only when the emulator is
21
  launched with a control-port flag. Zero cost to normal use.
22
- **Localhost only.** The server MUST bind `127.0.0.1` (never `0.0.0.0`).
23
  `/mem` and input injection are a memory-read / remote-control surface; they do
24
  not leave the machine.
25
- **The contract is the HTTP surface, not the flag.** Each emulator names its
26
  launch flag idiomatically (x16emu: `-controlport N`; FAB: `--control-port N`).
27
  Only the endpoints below are normative.
28
- **Determinism is a first-class requirement.** These are frame-exact ports.
29
  Every state read (`/mem`, `/regs`, `/screenshot`) MUST observe a consistent
30
  machine state — captured at an instruction/frame boundary on the emulator
31
  thread, never a torn mid-instruction read from the HTTP thread. See
32
  *Consistency* below.
33
34
## Conventions
35
36
- JSON responses are `application/json; charset=utf-8`.
37
- Binary responses use the stated content-type; no chunked encoding required.
38
- Errors: HTTP 4xx/5xx with body `{"error":"<message>"}`.
39
- Numbers in JSON are decimal unless a field is documented as a hex string.
40
- Methods are exactly as listed (`GET`/`POST`). Unknown path → 404. Unknown
41
  method on a known path → 405.
42
43
## Endpoints
44
45
### `GET /status`
46
Liveness + contract negotiation. Always available.
47
```json
48
{
398756c0 Andy McCall ↶ 49
  "contract": "0.5.0",
961b3c61 Andy McCall 50
  "emulator": "x16emu",
51
  "platform": "x16",
52
  "frame": 12345,
53
  "paused": true,
54
  "running": true
55
}
56
```
57
- `contract` — this document's version. Clients MUST reject a mismatched MAJOR.
4c0b050e Andy McCall ↶ 58
- `platform` — one of `x16`, `neo6502`, `agon`, `pcfx` (extensible). Tells the
59
  client the memory/register model (see *Per-platform* appendix).
961b3c61 Andy McCall 60
- `frame` — monotonic completed-frame counter since boot. The determinism
61
  anchor: a screenshot taken at `frame == N` shows the state after N complete
62
  frames.
63
- `paused` — true if the machine is halted (only advances via `/step`).
64
65
### `GET /screenshot`
66
The live screen as a Portable Pixmap.
67
- Response: `image/x-portable-pixmap`, binary **PPM P6**, 8-bit RGB, no alpha.
68
- Pixels are the most recent COMPLETE frame (never a half-scanned frame). The
69
  emulator keeps a snapshot the render thread fills at frame end; the HTTP
70
  thread serves that snapshot.
71
- Dimensions are the platform's native framebuffer (appendix). Clients read
72
  `width`/`height` from the PPM header — do not hardcode.
4c0b050e Andy McCall ↶ 73
- Reserved: `?frame=N` (block until frame N) — still reserved, not yet required.
961b3c61 Andy McCall 74
75
### `GET /mem?addr=<a>&len=<n>[&bank=<b>]`
76
Raw memory bytes.
77
- `addr` — start address, decimal or `0x`-hex. Interpreted in the platform's
78
  address space (appendix).
79
- `len` — byte count, decimal. Servers MAY cap `len` (recommend ≥ 65536); a
80
  capped request returns what it can with `Content-Length` telling the truth.
81
- `bank` — OPTIONAL bank selector for banked platforms (X16). Omitted → the
82
  machine's currently-selected bank. Ignored by flat-memory platforms.
83
- Response: `application/octet-stream`, exactly the requested bytes.
84
- Reads go through the emulator's debug-read path (no side effects, no I/O
85
  triggers, no access-flag mutation).
86
674b8447 Andy McCall ↶ 87
### `POST /mem?addr=<a>[&bank=<b>]`  *(0.3)*
88
Write (poke) memory. The write-sibling of `GET /mem`; same address/bank model.
89
- `addr`, `bank` — as for the read form. `len` is NOT a query param: the byte
90
  count is the request **body** length.
91
- Body — `application/octet-stream`, the raw bytes to write starting at `addr`.
92
  Servers MAY cap the body (recommend ≥ 65536).
93
- Response: `{ "written": <n> }` — bytes actually written. A server MAY write
94
  fewer than requested (e.g. it stops at a ROM/unwritable boundary); the count
95
  tells the truth.
96
- Writes go through the emulator's CPU write path. Intended for RAM/state
97
  fixup (force lives, seed a level, restore a snapshot). Writing an I/O register
98
  MAY trigger device side effects — that is the caller's responsibility.
99
- Determinism: poke while `paused` so the machine can't overwrite between the
100
  write and a subsequent read.
101
961b3c61 Andy McCall 102
### `GET /regs`
103
CPU registers as JSON. Keys are platform-specific (appendix); values are decimal
104
unless suffixed `_hex`. Example (x16 / 65C02):
105
```json
106
{ "a":0, "x":16, "y":2, "sp":505, "pc":49152, "status":32,
107
  "ram_bank":1, "rom_bank":0 }
108
```
109
110
### `POST /step?frames=<n>`  *(recommended; required for frame-exact tests)*
111
Advance the machine by exactly `n` complete frames (default 1), then halt.
112
- Only meaningful when the machine is paused (see `/pause`). On a free-running
113
  machine, servers SHOULD pause, run `n` frames, and stay paused.
114
- Response: `{ "frame": <new frame counter> }`.
115
- `POST /step?cycles=<n>` MAY be offered for sub-frame stepping.
116
117
### `POST /pause` / `POST /resume`  *(recommended)*
118
Halt / free-run. `{ "paused": true|false }`. A machine launched with a
119
`--control-pause`-style option SHOULD boot paused so tests are deterministic
120
from frame 0.
121
d44319b1 Andy McCall ↶ 122
### `POST /key?text=<char> | ?code=<n>  [&down=0|1]`  *(0.2)*
123
Inject a key. Query params (like `/mem` and `/step`), no body:
124
- `text` — a single character, mapped to the platform's keyboard.
125
- `code` — a raw platform key code (e.g. fabgl virtual key on Agon, an SDL/PS2
126
  code on others). `text` and `code` are alternatives; provide one.
127
- `down` — `1` presses (holds), `0` releases; **omitted taps** (press then
128
  release). Holding across `/step` frames is how a program that samples input
129
  each frame (e.g. a game) actually sees the key.
130
- Response: `{ "injected": true }`. Missing/unmapped key → 400.
961b3c61 Andy McCall 131
d44319b1 Andy McCall ↶ 132
Injection plus `/step` retires scripted-autoexec input: press → step → assert →
133
release, deterministically.
134
346ca3ee Andy McCall ↶ 135
### `POST /pointer?x=<n>&y=<n> | ?dx=<n>&dy=<n>  [&buttons=<mask>]`  *(0.4)*
136
Inject a pointer (mouse) move and/or click through the platform's native pointer
137
path — the mouse analogue of `/key`. Query params (no body):
138
- `x`, `y` — **absolute** pointer position, in the platform's pointer coordinate
139
  space (framebuffer pixels; appendix). Provide both.
140
- `dx`, `dy` — **relative** motion applied to the current position; either may be
141
  omitted (treated as 0). `x`/`y` and `dx`/`dy` are alternatives; provide one
142
  pair. Absolute is preferred for tests ("put the cursor on the button" beats
143
  dead-reckoning).
144
- `buttons` — OPTIONAL bitmask, **bit0 = primary, bit1 = secondary**. Omitted →
145
  the button state is left unchanged (a pure move). Provide `buttons=0` to
146
  release. Hold a button across `/step` frames (re-assert it each move) so a
147
  program that samples the pointer per frame sees the press.
148
- Response: `{ "injected": true }`. No pointer on the platform → 400.
149
150
Device-level, not driver-state: the injection MUST exercise the same path a real
151
mouse takes (e.g. the KERNAL/VERA mouse on the X16, the firmware mouse on the
152
Neo6502), so what the program reads is what a physical mouse would produce.
153
Determinism: inject under `/pause`, then `/step` — the move lands at the frame
154
boundary, so screenshot-diffing a hovered/clicked UI is reproducible.
155
156
### `GET /pointer`  *(0.4)*
157
Read back the current pointer state. Response:
158
```json
159
{ "x": 42, "y": 24, "buttons": 1 }
160
```
161
- `x`, `y` — current position in the same coordinate space as `POST /pointer`.
162
- `buttons` — the current button bitmask (same bit assignment).
163
- Lets a test distinguish "the input never reached the platform" from "it
164
  arrived but the program drew the wrong thing". No pointer on the platform → 400.
165
d44319b1 Andy McCall ↶ 166
### `POST /reset`  *(0.2)*
167
Soft/cold reset the machine. Response `{ "reset": true }`. The `frame` counter
168
MAY reset here (the one case `/status.frame` is allowed to go backwards).
674b8447 Andy McCall ↶ 169
170
### `GET /audio`  *(0.3)*
171
Drain the audio the emulator has synthesised since the last `/audio` call — the
172
audio analogue of `/screenshot`, but a *window* (everything since last drain)
173
rather than a snapshot.
174
- Response: `audio/wav`, a self-describing **PCM WAV** (RIFF, 16-bit signed,
175
  interleaved). The header carries the sample rate and channel count; clients
176
  read them from the WAV — do not hardcode (appendix lists native rates).
177
- The server keeps a bounded ring of mixed output samples, teed from the same
178
  buffer that feeds the sound device. Each call empties the ring and returns it;
179
  an empty ring returns a valid zero-frame WAV.
180
- Determinism: under `/pause` + `/step`, the drain covers exactly the audio of
181
  the stepped frames, so a test can `pause → step(n) → GET /audio` and get the
182
  sound of those `n` frames reproducibly.
183
- `X-Rrdc-Audio-Dropped: <n>` — samples the ring dropped (oldest-first) to
184
  overflow since the last drain. Non-zero means drain more often. `0` when clean.
d44319b1 Andy McCall ↶ 185
961b3c61 Andy McCall 186
## Consistency (normative)
187
188
- `/mem`, `/regs`, and `/step` MUST be serviced on the emulator thread at an
189
  instruction boundary — e.g. the HTTP handler enqueues a command that the main
190
  loop drains between instructions/frames and signals back. The HTTP thread MUST
191
  NOT read CPU/RAM state directly while the CPU is executing.
192
- `/screenshot` MUST serve a complete-frame snapshot (double-buffer: render
193
  thread writes the back buffer, swaps under a lock at frame end; HTTP thread
194
  reads the front buffer).
195
- `frame` in `/status` MUST increment once per completed video frame and never
196
  go backwards (except across `/reset`).
197
198
## Versioning
199
200
- Semantic. MAJOR bump = breaking change to an existing endpoint's
201
  request/response. MINOR = additive (new endpoint / optional field). PATCH =
202
  clarification.
203
- Clients read `/status.contract` and fail fast on a MAJOR mismatch.
204
205
---
206
207
## Per-platform appendix
208
209
### Commander X16 — `x16emu`
210
- Flag: `-controlport <N>` (single-dash, matching x16emu's `-dump` style).
211
- Framebuffer: **640×480**, native order BGRA8888 (`framebuffer[i*4+2]=R`,
212
  `+1=G`, `+0=B`); the server swizzles to PPM RGB.
213
- Address space: 16-bit CPU (`0x0000–0xFFFF`). The banked window `0xA000–0xBFFF`
214
  is selected by `bank` → the emulator's `read6502(addr, bank)` / banked RAM
215
  (`BRAM`). `addr < 0xA000` or `≥ 0xC000` ignores `bank`.
216
- Registers: `a x y` (8), `sp pc` (16), `status` (8), plus `ram_bank`,
217
  `rom_bank`. Source: the `regs` struct + `memory_get_ram_bank/rom_bank()`.
218
- Frame source: `video.c` `frame_count`.
674b8447 Andy McCall ↶ 219
- Audio: stereo (2 ch), native rate `25000000/512` ≈ **48828 Hz**, teed from the
220
  mixed `audio_render()` output.
346ca3ee Andy McCall ↶ 221
- Pointer: `/pointer` maps to the KERNAL/VERA mouse — absolute/relative position
222
  and buttons fed through the same path the host mouse uses, so `MOUSE_GET`
398756c0 Andy McCall ↶ 223
  (and VERA sprite 0) see it. Coordinates are screen pixels.
224
- Pad: `/pad` presents a virtual controller through the emulator's own joystick
225
  read path. The fork implements the full 0.5 callback set and advertises
226
  **0.5.0**.
961b3c61 Andy McCall 227
228
### Neo6502 — neo6502 emulator
229
- Flag: `--control-port <N>`.
230
- Address space: flat 64 KB; `bank` ignored.
231
- Registers: 6502 `a x y sp pc status`.
232
- Framebuffer: native resolution per the emulator's video mode.
346ca3ee Andy McCall ↶ 233
- Pointer: `/pointer` drives the firmware mouse — absolute → `MSESetPosition`,
234
  relative → `MSEOffsetPosition`, buttons → `MSEUpdateButtonState`; `GET /pointer`
235
  reads `MSEGetState`. Coordinates are firmware graphics pixels (the same space a
236
  program reads). Buttons: bit0 = left, bit1 = right (the firmware's native
237
  masks), matching the contract's primary/secondary. The emulator enables the
238
  mouse at reset, so injection works from boot.
961b3c61 Andy McCall 239
240
### Agon (Console8 / Light 2) — FAB Agon Emulator
42c217d9 Andy McCall ↶ 241
- **Adoption:** conform-only (Rust emulator — keeps its own HTTP server, matches
242
  this contract rather than vendoring the C core).
961b3c61 Andy McCall 243
- Flag: `--control-port <N>`.
244
- Address space: eZ80 24-bit, flat 16 MB (`addr` up to `0xFFFFFF`); `bank`
42c217d9 Andy McCall ↶ 245
  tolerated and ignored. Program load base `0x040000`.
246
- Registers (ADL) — `/regs` example (16-bit regs; `mbase` 8-bit; `adl` 0/1):
247
  ```json
248
  { "af":0, "bc":0, "de":0, "hl":0, "ix":0, "iy":0,
249
    "sp":0, "pc":262144, "mbase":0, "adl":1 }
250
  ```
251
- Framebuffer: from `vgabuf` (the VDP output); the offscreen path already keeps
252
  video alive (the FAB equivalent of x16's `SDL_VIDEODRIVER=dummy`).
67065eaa Andy McCall ↶ 253
- Audio: the VDP mixes 8-bit unsigned mono at 16384 Hz; `/audio` returns it as a
254
  16-bit signed PCM WAV. The SDL audio callback (the sole `getAudioSamples`
255
  consumer) tees each block into the ring; headless capture needs
256
  `SDL_AUDIODRIVER=dummy` so that callback runs.
d44319b1 Andy McCall ↶ 257
- Input: `/key?text=c` maps the char to a fabgl virtual key (`ascii2vk`) and
258
  delivers it via `sendVKeyEventToFabgl`; `?code=<vk>` passes a raw fabgl vkey.
259
  The control thread queues events; the render thread delivers them (same
260
  thread as normal keyboard input). `/reset` triggers the soft-reset atomic.
961b3c61 Andy McCall 261
c17f852c Andy McCall ↶ 262
### NEC PC-FX — `mednafen` (Doomsday One fork)
263
- **Adoption:** vendors the shared core as a git submodule
264
  (`src/extern/retro-remote-debug-controller`), built in with `./configure
265
  --enable-rrdc`. Dispatched per running console, so PC Engine and Saturn can
266
  reuse the same core once their backends land.
267
- Control port: the `MEDNAFEN_CONTROLPORT=<N>` environment variable (Mednafen
268
  exposes no free-form CLI flag for a driver add-on). Unset ⇒ server off.
269
- Address space: V810, flat **2 MB** main RAM (`0x000000–0x1FFFFF`); `addr`
270
  wraps in that space and `bank` is ignored.
271
- Registers: `pc` + `r0`–`r31` (33 × 32-bit), from the V810 core.
4c0b050e Andy McCall ↶ 272
- Framebuffer: the active display, decoded to RGB888 by the fork with the
273
  surface's channel shifts (so it is correct regardless of Mednafen's
274
  display-dependent pixel order). The PC-FX varies horizontal resolution per
275
  scene (256 for the maze, up to 1024 for high-res text); the fork normalizes
276
  each scanline by Mednafen's per-line `LineWidths`, so the PPM is centred and
277
  complete at whatever width the scene uses. Height is the visible line count
278
  (~232). Clients read `width`/`height` from the PPM header.
c17f852c Andy McCall ↶ 279
- Audio: stereo, at Mednafen's configured output rate (`espec.SoundRate`),
280
  teed from the per-frame `SoundBuf`.
281
- Input: the PC-FX has a gamepad, not a keyboard, so `/key?text=<c>` and
282
  `/key?code=<n>` are both read as a character over a WASD-style pad map —
283
  `w/a/s/d` = up/left/down/right, space or Enter = RUN (start), `c` = SELECT,
284
  `1`–`6` = buttons I–VI. A held button is re-asserted each frame (the frontend
285
  rewrites the pad from physical input every frame), so holds persist across
286
  `/step`. `/reset` power-cycles the machine.
398756c0 Andy McCall ↶ 287
- Pad: `/pad` remaps the canonical mask to the FX pad word (`I=0 II=1 III=2 IV=3
288
  V=4 VI=5 SELECT=6 RUN=7 UP=8 RIGHT=9 DOWN=10 LEFT=11`; A/B → I/II, X/Y →
289
  III/IV, L/R → V/VI, START → RUN) and OR-merges it into the pad read every
290
  frame, as a level. **Trap:** that layout is implicit in the *order* of
291
  Mednafen's `PCFX_GamepadIDII` array — the third `IDIIS_Button` argument is
292
  `ConfigOrder`, not a bit offset. Using those numbers as bits binds every button
293
  wrongly and is a defect currently present in the fork.
294
- Pointer: the PC-FX has no pointing device. Because 0.5.0 advertises
295
  cumulatively, the fork supplies refusing stubs (`/pointer` → **400**) rather
296
  than NULL callbacks, so it can honestly advertise **0.5.0** for `/pad`.
297
- Audio: `/audio` is fed by the mixer, which only runs when sound is enabled.
298
  Launching with `-sound 0` yields a valid but **header-only (44-byte)** WAV, not
299
  an error — use `-sound 1 -sound.driver dummy` for headless audio capture.
c17f852c Andy McCall ↶ 300
961b3c61 Andy McCall 301
---
302
42c217d9 Andy McCall ↶ 303
## Adoption paths
961b3c61 Andy McCall 304
42c217d9 Andy McCall ↶ 305
An emulator conforms by exposing exactly the HTTP surface above — nothing else
306
is required. Two ways to get there:
307
308
- **Vendor the shared core** (`core/retro_control.c` + `.h`) — for C/C++
674b8447 Andy McCall ↶ 309
  emulators. Implement the backend callbacks (four are enough for 0.1; add
398756c0 Andy McCall ↶ 310
  `inject_key`/`reset` for 0.2, `write_mem`/`capture_audio` for 0.3,
311
  `set_pointer`/`get_pointer` for 0.4, and `set_pad`/`get_pad` for 0.5 — a NULL
312
  callback makes its endpoint return 501 and lowers the advertised contract, so a
313
  platform lacking a device should supply a stub that returns 400 instead), wire
314
  three loop hooks, call
346ca3ee Andy McCall ↶ 315
  `retro_control_start(port, &backend)`; the socket loop, HTTP parsing, PPM/JSON
316
  encoding and pause/step control come for free. The Commander X16 fork does
317
  this.
42c217d9 Andy McCall ↶ 318
- **Conform directly** — for emulators in other languages (the FAB Agon emulator
319
  is Rust). Keep your own HTTP server; just match the contract and pass the
320
  conformance suite. Sharing the C core is an optimization, not a requirement —
321
  the contract is the asset.
322
323
Either way, `conformance/` is the arbiter: pass it and you conform.
324
325
## In this repo
326
327
- `core/retro_control.{c,h}` — portable server + backend vtable (vendor path).
961b3c61 Andy McCall 328
- `client/emu_control.py` — the shared pytest client.
42c217d9 Andy McCall ↶ 329
- `conformance/` — a platform-agnostic pytest that proves an emulator conforms.