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
1
# Retro Remote Debug Controller — HTTP Control Contract
2
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`.
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
13
machine deterministically, and inject input. The same contract is implemented
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.
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
{
49
  "contract": "0.5.0",
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.
58
- `platform` — one of `x16`, `neo6502`, `agon`, `pcfx` (extensible). Tells the
59
  client the memory/register model (see *Per-platform* appendix).
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.
73
- Reserved: `?frame=N` (block until frame N) — still reserved, not yet required.
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
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
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
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.
131
132
Injection plus `/step` retires scripted-autoexec input: press → step → assert →
133
release, deterministically.
134
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
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).
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.
185
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`.
219
- Audio: stereo (2 ch), native rate `25000000/512` ≈ **48828 Hz**, teed from the
220
  mixed `audio_render()` output.
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`
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**.
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.
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.
239
240
### Agon (Console8 / Light 2) — FAB Agon Emulator
241
- **Adoption:** conform-only (Rust emulator — keeps its own HTTP server, matches
242
  this contract rather than vendoring the C core).
243
- Flag: `--control-port <N>`.
244
- Address space: eZ80 24-bit, flat 16 MB (`addr` up to `0xFFFFFF`); `bank`
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`).
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.
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.
261
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.
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.
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.
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.
300
301
---
302
303
## Adoption paths
304
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++
309
  emulators. Implement the backend callbacks (four are enough for 0.1; add
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
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.
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).
328
- `client/emu_control.py` — the shared pytest client.
329
- `conformance/` — a platform-agnostic pytest that proves an emulator conforms.