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