| 1 |
# Retro Remote Debug Controller — HTTP Control Contract |
| 2 |
|
| 3 |
**Contract version: 0.6.0** (semver; clients assert on the MAJOR). 0.6 adds |
| 4 |
performance metrics (`GET /perf`, `POST /perf`) and `/status.capabilities`; 0.5 |
| 5 |
added a virtual game controller (`POST /pad`, `GET /pad`); 0.4 added pointer injection |
| 6 |
(`POST /pointer`, `GET /pointer`); 0.3 added memory write (`POST /mem`) and an |
| 7 |
audio drain (`GET /audio`); 0.2 added input injection (`POST /key`, |
| 8 |
`POST /reset`). Every minor is purely additive, so older clients keep working and |
| 9 |
servers advertise the highest level whose callbacks are all present via |
| 10 |
`/status.contract`. |
| 11 |
|
| 12 |
A minimal, portable HTTP API that a retro-platform emulator exposes so an |
| 13 |
external harness can screenshot the screen, read memory and registers, step the |
| 14 |
machine deterministically, and inject input. The same contract is implemented |
| 15 |
by the forked FAB Agon Emulator, the Neo6502 emulator, the Commander X16 |
| 16 |
emulator, and the NEC PC-FX (Mednafen) emulator, so **one** pytest harness |
| 17 |
drives all four ports. |
| 18 |
|
| 19 |
## Principles |
| 20 |
|
| 21 |
- **Opt-in.** The server is OFF by default and starts only when the emulator is |
| 22 |
launched with a control-port flag. Zero cost to normal use. |
| 23 |
- **Localhost only.** The server MUST bind `127.0.0.1` (never `0.0.0.0`). |
| 24 |
`/mem` and input injection are a memory-read / remote-control surface; they do |
| 25 |
not leave the machine. |
| 26 |
- **The contract is the HTTP surface, not the flag.** Each emulator names its |
| 27 |
launch flag idiomatically (x16emu: `-controlport N`; FAB: `--control-port N`). |
| 28 |
Only the endpoints below are normative. |
| 29 |
- **Determinism is a first-class requirement.** These are frame-exact ports. |
| 30 |
Every state read (`/mem`, `/regs`, `/screenshot`) MUST observe a consistent |
| 31 |
machine state — captured at an instruction/frame boundary on the emulator |
| 32 |
thread, never a torn mid-instruction read from the HTTP thread. See |
| 33 |
*Consistency* below. |
| 34 |
|
| 35 |
## Conventions |
| 36 |
|
| 37 |
- JSON responses are `application/json; charset=utf-8`. |
| 38 |
- Binary responses use the stated content-type; no chunked encoding required. |
| 39 |
- Errors: HTTP 4xx/5xx with body `{"error":"<message>"}`. |
| 40 |
- Numbers in JSON are decimal unless a field is documented as a hex string. |
| 41 |
- Methods are exactly as listed (`GET`/`POST`). Unknown path → 404. Unknown |
| 42 |
method on a known path → 405. |
| 43 |
|
| 44 |
## Endpoints |
| 45 |
|
| 46 |
### `GET /status` |
| 47 |
Liveness + contract negotiation. Always available. |
| 48 |
```json |
| 49 |
{ |
| 50 |
"contract": "0.6.0", |
| 51 |
"emulator": "x16emu", |
| 52 |
"platform": "x16", |
| 53 |
"frame": 12345, |
| 54 |
"paused": true, |
| 55 |
"running": true, |
| 56 |
"capabilities": ["mem", "regs", "screenshot", "step", "key", "reset", |
| 57 |
"mem-write", "audio", "pointer", "pad", "perf"] |
| 58 |
} |
| 59 |
``` |
| 60 |
- `contract` — this document's version. Clients MUST reject a mismatched MAJOR. |
| 61 |
- `capabilities` *(0.6)* — the endpoint groups this server serves, whatever |
| 62 |
level `contract` reaches: `mem`, `regs`, `screenshot`, `step` (always), then |
| 63 |
`key`, `reset`, `mem-write` (`POST /mem`), `audio`, `pointer`, `pad`, `perf`. |
| 64 |
`contract` stays cumulative (0.6.0 means everything up to and including |
| 65 |
`/perf`); `capabilities` lets a client ask for one group directly, so a |
| 66 |
backend with no pointer can still offer `/perf` without a refusing stub. |
| 67 |
Absent on servers older than 0.6: read `contract` there. |
| 68 |
- `platform` — one of `x16`, `neo6502`, `agon`, `pcfx` (extensible). Tells the |
| 69 |
client the memory/register model (see *Per-platform* appendix). |
| 70 |
- `frame` — monotonic completed-frame counter since boot. The determinism |
| 71 |
anchor: a screenshot taken at `frame == N` shows the state after N complete |
| 72 |
frames. |
| 73 |
- `paused` — true if the machine is halted (only advances via `/step`). |
| 74 |
|
| 75 |
### `GET /screenshot` |
| 76 |
The live screen as a Portable Pixmap. |
| 77 |
- Response: `image/x-portable-pixmap`, binary **PPM P6**, 8-bit RGB, no alpha. |
| 78 |
- Pixels are the most recent COMPLETE frame (never a half-scanned frame). The |
| 79 |
emulator keeps a snapshot the render thread fills at frame end; the HTTP |
| 80 |
thread serves that snapshot. |
| 81 |
- Dimensions are the platform's native framebuffer (appendix). Clients read |
| 82 |
`width`/`height` from the PPM header — do not hardcode. |
| 83 |
- Reserved: `?frame=N` (block until frame N) — still reserved, not yet required. |
| 84 |
|
| 85 |
### `GET /mem?addr=<a>&len=<n>[&bank=<b>]` |
| 86 |
Raw memory bytes. |
| 87 |
- `addr` — start address, decimal or `0x`-hex. Interpreted in the platform's |
| 88 |
address space (appendix). |
| 89 |
- `len` — byte count, decimal. Servers MAY cap `len` (recommend ≥ 65536); a |
| 90 |
capped request returns what it can with `Content-Length` telling the truth. |
| 91 |
- `bank` — OPTIONAL bank selector for banked platforms (X16). Omitted → the |
| 92 |
machine's currently-selected bank. Ignored by flat-memory platforms. |
| 93 |
- Response: `application/octet-stream`, exactly the requested bytes. |
| 94 |
- Reads go through the emulator's debug-read path (no side effects, no I/O |
| 95 |
triggers, no access-flag mutation). |
| 96 |
|
| 97 |
### `POST /mem?addr=<a>[&bank=<b>]` *(0.3)* |
| 98 |
Write (poke) memory. The write-sibling of `GET /mem`; same address/bank model. |
| 99 |
- `addr`, `bank` — as for the read form. `len` is NOT a query param: the byte |
| 100 |
count is the request **body** length. |
| 101 |
- Body — `application/octet-stream`, the raw bytes to write starting at `addr`. |
| 102 |
Servers MAY cap the body (recommend ≥ 65536). |
| 103 |
- Response: `{ "written": <n> }` — bytes actually written. A server MAY write |
| 104 |
fewer than requested (e.g. it stops at a ROM/unwritable boundary); the count |
| 105 |
tells the truth. |
| 106 |
- Writes go through the emulator's CPU write path. Intended for RAM/state |
| 107 |
fixup (force lives, seed a level, restore a snapshot). Writing an I/O register |
| 108 |
MAY trigger device side effects — that is the caller's responsibility. |
| 109 |
- Determinism: poke while `paused` so the machine can't overwrite between the |
| 110 |
write and a subsequent read. |
| 111 |
|
| 112 |
### `GET /regs` |
| 113 |
CPU registers as JSON. Keys are platform-specific (appendix); values are decimal |
| 114 |
unless suffixed `_hex`. Example (x16 / 65C02): |
| 115 |
```json |
| 116 |
{ "a":0, "x":16, "y":2, "sp":505, "pc":49152, "status":32, |
| 117 |
"ram_bank":1, "rom_bank":0 } |
| 118 |
``` |
| 119 |
|
| 120 |
### `POST /step?frames=<n>` *(recommended; required for frame-exact tests)* |
| 121 |
Advance the machine by exactly `n` complete frames (default 1), then halt. |
| 122 |
- Only meaningful when the machine is paused (see `/pause`). On a free-running |
| 123 |
machine, servers SHOULD pause, run `n` frames, and stay paused. |
| 124 |
- Response: `{ "frame": <new frame counter> }`. |
| 125 |
- `POST /step?cycles=<n>` MAY be offered for sub-frame stepping. |
| 126 |
|
| 127 |
### `POST /pause` / `POST /resume` *(recommended)* |
| 128 |
Halt / free-run. `{ "paused": true|false }`. A machine launched with a |
| 129 |
`--control-pause`-style option SHOULD boot paused so tests are deterministic |
| 130 |
from frame 0. |
| 131 |
|
| 132 |
### `POST /key?text=<char> | ?code=<n> [&down=0|1]` *(0.2)* |
| 133 |
Inject a key. Query params (like `/mem` and `/step`), no body: |
| 134 |
- `text` — a single character, mapped to the platform's keyboard. |
| 135 |
- `code` — a raw platform key code (e.g. fabgl virtual key on Agon, an SDL/PS2 |
| 136 |
code on others). `text` and `code` are alternatives; provide one. |
| 137 |
- `down` — `1` presses (holds), `0` releases; **omitted taps** (press then |
| 138 |
release). Holding across `/step` frames is how a program that samples input |
| 139 |
each frame (e.g. a game) actually sees the key. |
| 140 |
- Response: `{ "injected": true }`. Missing/unmapped key → 400. |
| 141 |
|
| 142 |
Injection plus `/step` retires scripted-autoexec input: press → step → assert → |
| 143 |
release, deterministically. |
| 144 |
|
| 145 |
### `POST /pointer?x=<n>&y=<n> | ?dx=<n>&dy=<n> [&buttons=<mask>]` *(0.4)* |
| 146 |
Inject a pointer (mouse) move and/or click through the platform's native pointer |
| 147 |
path — the mouse analogue of `/key`. Query params (no body): |
| 148 |
- `x`, `y` — **absolute** pointer position, in the platform's pointer coordinate |
| 149 |
space (framebuffer pixels; appendix). Provide both. |
| 150 |
- `dx`, `dy` — **relative** motion applied to the current position; either may be |
| 151 |
omitted (treated as 0). `x`/`y` and `dx`/`dy` are alternatives; provide one |
| 152 |
pair. Absolute is preferred for tests ("put the cursor on the button" beats |
| 153 |
dead-reckoning). |
| 154 |
- `buttons` — OPTIONAL bitmask, **bit0 = primary, bit1 = secondary**. Omitted → |
| 155 |
the button state is left unchanged (a pure move). Provide `buttons=0` to |
| 156 |
release. Hold a button across `/step` frames (re-assert it each move) so a |
| 157 |
program that samples the pointer per frame sees the press. |
| 158 |
- Response: `{ "injected": true }`. No pointer on the platform → 400. |
| 159 |
|
| 160 |
Device-level, not driver-state: the injection MUST exercise the same path a real |
| 161 |
mouse takes (e.g. the KERNAL/VERA mouse on the X16, the firmware mouse on the |
| 162 |
Neo6502), so what the program reads is what a physical mouse would produce. |
| 163 |
Determinism: inject under `/pause`, then `/step` — the move lands at the frame |
| 164 |
boundary, so screenshot-diffing a hovered/clicked UI is reproducible. |
| 165 |
|
| 166 |
### `GET /pointer` *(0.4)* |
| 167 |
Read back the current pointer state. Response: |
| 168 |
```json |
| 169 |
{ "x": 42, "y": 24, "buttons": 1 } |
| 170 |
``` |
| 171 |
- `x`, `y` — current position in the same coordinate space as `POST /pointer`. |
| 172 |
- `buttons` — the current button bitmask (same bit assignment). |
| 173 |
- Lets a test distinguish "the input never reached the platform" from "it |
| 174 |
arrived but the program drew the wrong thing". No pointer on the platform → 400. |
| 175 |
|
| 176 |
### `POST /pad?index=<n> [&buttons=<mask>] [&connected=0|1]` *(0.5)* |
| 177 |
Inject a game controller — the pad analogue of `/key` and `/pointer`. Query |
| 178 |
params (no body): |
| 179 |
- `index` — which pad, 0-based. OPTIONAL, default 0. |
| 180 |
- `buttons` — bitmask of what is HELD, in the canonical layout below. Omitted → |
| 181 |
the button state is left unchanged. `buttons=0` releases everything while |
| 182 |
leaving the pad connected. |
| 183 |
- `connected` — OPTIONAL. `1` (the default whenever `buttons` is given) presents |
| 184 |
a **virtual pad** at `index`; `0` unplugs it. Unplugging matters: a program |
| 185 |
that asks "is a controller present" before offering two-player must be |
| 186 |
testable in both states, and on most platforms the count is what it asks. |
| 187 |
- Response: `{ "injected": true }`. No controller support on the platform → 400. |
| 188 |
|
| 189 |
Canonical button layout, low bit first: |
| 190 |
|
| 191 |
|
| 192 |
|
| 193 |
| | LEFT | RIGHT | UP | DOWN | A | B | X | Y | START | SELECT | L | R | |
| 194 |
|
| 195 |
This is the SNES/Neo6502 native order, chosen for the same reason `/pointer` |
| 196 |
fixed bit0 = primary: **one layout, defined once, so a test written against one |
| 197 |
platform reads the same on another.** A backend whose hardware disagrees — the |
| 198 |
X16 KERNAL reports RIGHT/LEFT/DOWN/UP — converts in its own floor. A test never |
| 199 |
learns which machine it is on, which is the entire point. |
| 200 |
|
| 201 |
Device-level, not driver-state: the injection MUST exercise the same path a real |
| 202 |
pad takes, so what the program reads is what physical hardware would produce. On |
| 203 |
an emulator that reads SDL joysticks, that means presenting a virtual pad the |
| 204 |
existing read path picks up — **not** driving SDL, which would need a joystick |
| 205 |
to exist and defeats the purpose. Headless CI has no joystick; that is the case |
| 206 |
this endpoint is for. |
| 207 |
|
| 208 |
Hold across frames: a pad is a LEVEL, not an event. `buttons` stays as set until |
| 209 |
changed, so a press survives any number of `/step` frames without re-asserting — |
| 210 |
unlike `/key`, whose TAP is deliberately momentary. Release explicitly. |
| 211 |
|
| 212 |
### `GET /pad?index=<n>` *(0.5)* |
| 213 |
Read back a pad. Response: |
| 214 |
```json |
| 215 |
{ "index": 0, "connected": true, "buttons": 48 } |
| 216 |
``` |
| 217 |
- `connected` — whether anything is present at that index, real or virtual. |
| 218 |
- `buttons` — the current mask in the layout above. |
| 219 |
- Same job as `GET /pointer`: distinguishes "the input never reached the |
| 220 |
platform" from "it arrived and the program ignored it". No controller support |
| 221 |
on the platform → 400. |
| 222 |
|
| 223 |
### `POST /reset` *(0.2)* |
| 224 |
Soft/cold reset the machine. Response `{ "reset": true }`. The `frame` counter |
| 225 |
MAY reset here (the one case `/status.frame` is allowed to go backwards). |
| 226 |
|
| 227 |
### `GET /audio` *(0.3)* |
| 228 |
Drain the audio the emulator has synthesised since the last `/audio` call — the |
| 229 |
audio analogue of `/screenshot`, but a *window* (everything since last drain) |
| 230 |
rather than a snapshot. |
| 231 |
- Response: `audio/wav`, a self-describing **PCM WAV** (RIFF, 16-bit signed, |
| 232 |
interleaved). The header carries the sample rate and channel count; clients |
| 233 |
read them from the WAV — do not hardcode (appendix lists native rates). |
| 234 |
- The server keeps a bounded ring of mixed output samples, teed from the same |
| 235 |
buffer that feeds the sound device. Each call empties the ring and returns it; |
| 236 |
an empty ring returns a valid zero-frame WAV. |
| 237 |
- Determinism: under `/pause` + `/step`, the drain covers exactly the audio of |
| 238 |
the stepped frames, so a test can `pause → step(n) → GET /audio` and get the |
| 239 |
sound of those `n` frames reproducibly. |
| 240 |
- `X-Rrdc-Audio-Dropped: <n>` — samples the ring dropped (oldest-first) to |
| 241 |
overflow since the last drain. Non-zero means drain more often. `0` when clean. |
| 242 |
|
| 243 |
### `GET /perf [?windows=<list>] [&zones=0|1] [&frames=<n>] [&since=<frame>]` *(0.6)* |
| 244 |
How much of each frame the program uses: the measure a profiler gives, read by |
| 245 |
a test the way `/mem` is. Answered on the emulator thread (see *Consistency*), |
| 246 |
so it describes whole frames. |
| 247 |
|
| 248 |
Query params, all OPTIONAL: |
| 249 |
- `windows` — comma-separated statistics windows, each a number of seconds or |
| 250 |
`session` (the whole history). Default `1,10,30,60,session`. |
| 251 |
- `zones` — `0` leaves `zones` out. Default `1`. |
| 252 |
- `frames` — return the last `n` completed frames' raw records in `frames` |
| 253 |
(oldest first). Default `0`: none. |
| 254 |
- `since` — return the raw records of every frame after `<frame>` still in the |
| 255 |
history (oldest first; `frames` then caps how many, the newest kept). Lets a |
| 256 |
harness poll and never miss a frame: pass the last `frame` it saw. |
| 257 |
- A platform MAY accept more (the X16: `lines=1`, below); unknown params are |
| 258 |
ignored. |
| 259 |
|
| 260 |
Every count is in **guest CPU cycles** unless its name says otherwise (`…Us`: |
| 261 |
host microseconds; `…Pct`: percent). The shape: |
| 262 |
|
| 263 |
```json |
| 264 |
{ |
| 265 |
"enabled": true, |
| 266 |
"budget": { |
| 267 |
"targetFps": 60, "vsyncHz": 59.52, "machineKhz": 8000, |
| 268 |
"cyclesPerFrame": 134400, "scanlinesPerFrame": 525, "cyclesPerScanline": 256, |
| 269 |
"budgetCycles": 134400, "framesPerBudget": 1, |
| 270 |
"historyFrames": 3600, "totalFrames": 7200, "idleMode": "auto" |
| 271 |
}, |
| 272 |
"current": { |
| 273 |
"frame": 7199, "totalCycles": 134400, "workCycles": 31012, |
| 274 |
"idleCycles": 103388, "irqCycles": 3297, "mainCycles": 27715, |
| 275 |
"budgetWorkCycles": 31012, "instructions": 9120, "hostUs": 412, |
| 276 |
"utilizationPct": 23.1, "overBudget": false |
| 277 |
}, |
| 278 |
"windows": [ |
| 279 |
{ "name": "1s", "seconds": 1, "frames": 60, "budgetCycles": 134400, |
| 280 |
"work": {"min": 0, "mean": 0, "p50": 0, "p95": 0, "p99": 0, "max": 0}, |
| 281 |
"budgetWork": {"min": 0, "mean": 0, "p50": 0, "p95": 0, "p99": 0, "max": 0}, |
| 282 |
"total": {"…": 0}, "irq": {"…": 0}, "hostUs": {"…": 0}, |
| 283 |
"overruns": 0, "overrunPct": 0.0, "worstOverrunCycles": 0, "worstFrame": 0, |
| 284 |
"meanUtilizationPct": 23.1, "p95UtilizationPct": 31.0, "maxUtilizationPct": 40.2 } |
| 285 |
], |
| 286 |
"zones": [ |
| 287 |
{ "id": 0, "name": "sound", "kind": "marker", "markerId": 7, "idle": false, |
| 288 |
"current": 11520, |
| 289 |
"windows": { "1s": {"min": 0, "mean": 0, "p50": 0, "p95": 0, "p99": 0, "max": 0} } }, |
| 290 |
{ "id": 1, "name": "vblank wait", "kind": "address", |
| 291 |
"start": 49152, "end": 49408, "bank": -1, "idle": true, "current": 0, "windows": {} } |
| 292 |
], |
| 293 |
"frames": [ |
| 294 |
{ "frame": 7198, "totalCycles": 134400, "workCycles": 30544, |
| 295 |
"idleCycles": 103856, "irqCycles": 3297, "instructions": 9001, |
| 296 |
"hostUs": 398, "budgetWorkCycles": 30544, "zones": [11520, 0] } |
| 297 |
], |
| 298 |
"platform": { } |
| 299 |
} |
| 300 |
``` |
| 301 |
|
| 302 |
- `enabled` — whether the backend is measuring. Off, every count is 0 and the |
| 303 |
history is empty; `POST /perf {"enabled":true}` turns it on. A backend MAY |
| 304 |
start measuring on the first `/perf` request. |
| 305 |
- `budget` — the frame and its budget. `budgetCycles` is the cycles the program |
| 306 |
may use per budget period of `framesPerBudget` frames (a 30 fps target on a |
| 307 |
60 Hz machine: 2 frames). `cyclesPerScanline` converts a count to scanlines. |
| 308 |
- `current` — the last **completed** frame. `workCycles` = total − idle; |
| 309 |
`mainCycles` = work − irq. `idleCycles` are cycles the backend classifies as |
| 310 |
waiting (see *Idle*). `budgetWorkCycles` is the work over the trailing budget |
| 311 |
period, which `utilizationPct` and `overBudget` measure against `budgetCycles`. |
| 312 |
- `windows` — per requested window: the frames it covers and `{min, mean, p50, |
| 313 |
p95, p99, max}` of each series (`budgetWork` per budget period, the rest per |
| 314 |
frame); `overruns` (periods over budget), their share, and the worst. |
| 315 |
- `zones` — the program's own breakdown (see *Zones*). `current` is the zone's |
| 316 |
cycles in the last completed frame; `windows` its `{min…max}` per window, where |
| 317 |
the backend keeps a zone's history (`{}` where it does not). A cycle belongs |
| 318 |
to one zone at most: the innermost open marker region, else the address zone |
| 319 |
the PC is in. |
| 320 |
- `frames` — raw per-frame records, `zones` indexed by zone `id`. |
| 321 |
- `platform` — anything platform-specific, described in the *Per-platform |
| 322 |
appendix* (the X16's VERA bus bandwidth and sprite render time). A client |
| 323 |
that does not know the platform ignores it. |
| 324 |
|
| 325 |
**Idle.** A backend counts as idle what the CPU spends waiting: a halt |
| 326 |
instruction (`WAI`, `HALT`), a recognised spin loop, an address zone marked |
| 327 |
idle, or what the guest marks so. `idleMode` says which: `auto` (all of them), |
| 328 |
`markers` (only what the guest marks), `none`. |
| 329 |
|
| 330 |
**Zones.** Two kinds, both optional: |
| 331 |
- *address* zones — a PC range `[start, end)` (and `bank`, `-1` any), set by |
| 332 |
`POST /perf`. |
| 333 |
- *marker* regions — the guest program writes markers to a platform-defined |
| 334 |
register as it enters and leaves a region of its own (the X16: `$9FBC`, see |
| 335 |
the appendix). A marker region exists once the guest first enters it, named |
| 336 |
`region <n>` unless `POST /perf` has named it. |
| 337 |
|
| 338 |
### `POST /perf` *(0.6)* |
| 339 |
Settings. The body is a JSON object; every field is OPTIONAL, and a field left |
| 340 |
out is left as it is: |
| 341 |
```json |
| 342 |
{ |
| 343 |
"enabled": true, |
| 344 |
"targetFps": 60, |
| 345 |
"idleMode": "auto", |
| 346 |
"historyFrames": 3600, |
| 347 |
"reset": false, |
| 348 |
"zones": [ |
| 349 |
{ "name": "game", "marker": 1 }, |
| 350 |
{ "name": "vblank wait", "start": 49152, "end": 49408, "bank": -1, "idle": true } |
| 351 |
] |
| 352 |
} |
| 353 |
``` |
| 354 |
- `enabled` — start or stop measuring. Stopping MAY discard the history. |
| 355 |
- `targetFps` — the rate the program aims for, which sets `budget`. |
| 356 |
- `idleMode` — `auto`, `markers` or `none` (see *Idle*). |
| 357 |
- `historyFrames` — how many frames of history to keep. |
| 358 |
- `reset` — `true` empties the history and the statistics. |
| 359 |
- `zones` — **replaces** the zone list. An entry with `marker` names the guest's |
| 360 |
marker region `<n>`; one with `start`/`end` is an address zone. |
| 361 |
- A platform MAY accept more (the X16: `sprites`, below); unknown fields are |
| 362 |
ignored. |
| 363 |
- Response: `200` and the `GET /perf` body without `windows`, `zones` or |
| 364 |
`frames`, i.e. the settings as they now stand. A body that is not a JSON |
| 365 |
object, or a field of the wrong type, is `400`. |
| 366 |
|
| 367 |
## Consistency (normative) |
| 368 |
|
| 369 |
- `/mem`, `/regs`, and `/step` MUST be serviced on the emulator thread at an |
| 370 |
instruction boundary — e.g. the HTTP handler enqueues a command that the main |
| 371 |
loop drains between instructions/frames and signals back. The HTTP thread MUST |
| 372 |
NOT read CPU/RAM state directly while the CPU is executing. |
| 373 |
- `/perf` MUST be answered on the emulator thread, and describe completed |
| 374 |
frames only (`current` is the last whole frame, never the one in progress). |
| 375 |
- `/screenshot` MUST serve a complete-frame snapshot (double-buffer: render |
| 376 |
thread writes the back buffer, swaps under a lock at frame end; HTTP thread |
| 377 |
reads the front buffer). |
| 378 |
- `frame` in `/status` MUST increment once per completed video frame and never |
| 379 |
go backwards (except across `/reset`). |
| 380 |
|
| 381 |
## Versioning |
| 382 |
|
| 383 |
- Semantic. MAJOR bump = breaking change to an existing endpoint's |
| 384 |
request/response. MINOR = additive (new endpoint / optional field). PATCH = |
| 385 |
clarification. |
| 386 |
- Clients read `/status.contract` and fail fast on a MAJOR mismatch. |
| 387 |
|
| 388 |
--- |
| 389 |
|
| 390 |
## Per-platform appendix |
| 391 |
|
| 392 |
### Commander X16 — `x16emu` |
| 393 |
- Flag: `-controlport <N>` (single-dash, matching x16emu's `-dump` style). |
| 394 |
- Framebuffer: **640×480**, native order BGRA8888 (`framebuffer[i*4+2]=R`, |
| 395 |
`+1=G`, `+0=B`); the server swizzles to PPM RGB. |
| 396 |
- Address space: 16-bit CPU (`0x0000–0xFFFF`). The banked window `0xA000–0xBFFF` |
| 397 |
is selected by `bank` → the emulator's `read6502(addr, bank)` / banked RAM |
| 398 |
(`BRAM`). `addr < 0xA000` or `≥ 0xC000` ignores `bank`. |
| 399 |
- Registers: `a x y` (8), `sp pc` (16), `status` (8), plus `ram_bank`, |
| 400 |
`rom_bank`. Source: the `regs` struct + `memory_get_ram_bank/rom_bank()`. |
| 401 |
- Frame source: `video.c` `frame_count`. |
| 402 |
- Audio: stereo (2 ch), native rate `25000000/512` ≈ **48828 Hz**, teed from the |
| 403 |
mixed `audio_render()` output. |
| 404 |
- Pointer: `/pointer` maps to the KERNAL/VERA mouse — absolute/relative position |
| 405 |
and buttons fed through the same path the host mouse uses, so `MOUSE_GET` |
| 406 |
(and VERA sprite 0) see it. Coordinates are screen pixels. |
| 407 |
- Pad: `/pad` presents a virtual controller through the emulator's own joystick |
| 408 |
read path. The fork implements the full 0.5 callback set and advertises |
| 409 |
**0.5.0**. |
| 410 |
- Perf *(0.6)*: the **enhanced X16 emulator** (`enhanced-x16-emulator`, the one |
| 411 |
with the ImGui Performance panel) implements `/perf` from the same |
| 412 |
measurements the panel shows, and advertises **0.6.0**; the plain fork does |
| 413 |
not measure, and stays at 0.5.0. The budget is the X16's: 8 MHz, 525 |
| 414 |
scanlines a frame at 59.52 Hz, 134,400 cycles, 256 a scanline. Idle: `WAI`, |
| 415 |
a short store-free backward branch taken twice, or markers. |
| 416 |
- Markers: the guest writes one byte to **`$9FBC`** (the emulator's register |
| 417 |
12; reading it returns `1`, the marker protocol, so a program can tell it |
| 418 |
is there): `$01`–`$3F` enter region n, `$80|n` leave region n, `$00` leave |
| 419 |
the innermost, `$F0` the frame's work begins, `$F1` the frame's work ends |
| 420 |
(the rest of the frame is idle), `$FE` reset the statistics. Up to 8 |
| 421 |
regions nest; cycles go to the innermost. A write to a stock X16 or the |
| 422 |
plain fork does nothing. |
| 423 |
- Zones: up to 16; the first 8 keep a history (`windows`), the rest report |
| 424 |
`current` only. |
| 425 |
- `platform`: |
| 426 |
```json |
| 427 |
{ "bandwidth": { "frame": 7198, "lineClocks": 800, "linesActive": 480, |
| 428 |
"peakLine": 12, "peakLineClocks": 480, "p95LineClocks": 480, |
| 429 |
"meanLineClocks": 320, "linesOverBudget": 0, |
| 430 |
"layers": [ {"fetches": 57600, "bytes": 230400, "peakLine": 0, "peakFetches": 120}, |
| 431 |
{"fetches": 57600, "bytes": 230400, "peakLine": 0, "peakFetches": 120} ], |
| 432 |
"port": {"readBytes": 0, "writeBytes": 64, "accesses": 64, "vramBytes": 64}, |
| 433 |
"lines": [320, 320, "…480 entries"] }, |
| 434 |
"sprites": { "enabled": true, "frame": 7198, "lineBudget": 798, |
| 435 |
"peakLine": 40, "peakBudget": 612, "peakDemand": 612, |
| 436 |
"linesExhausted": 0, "spritesDropped": 0, "budgetUsedTotal": 51200, |
| 437 |
"slotsUsed": 24, "lines": [0, 0, "…480 entries"] } } |
| 438 |
``` |
| 439 |
`bandwidth` is VERA's VRAM bus in **bus clocks** (800 a scanline; a layer |
| 440 |
fetch or a CPU data-port access takes 2), for the last completed frame; |
| 441 |
`lines` (only with `?lines=1`) is each active line's clocks. |
| 442 |
`sprites` is the sprite renderer's time per line against its 798-clock |
| 443 |
budget, for the last frame; it is off until `POST /perf {"sprites":true}` |
| 444 |
(it costs the emulator time), `{"enabled":false}` until then. |
| 445 |
|
| 446 |
### Neo6502 — neo6502 emulator |
| 447 |
- Flag: `--control-port <N>`. |
| 448 |
- Address space: flat 64 KB; `bank` ignored. |
| 449 |
- Registers: 6502 `a x y sp pc status`. |
| 450 |
- Framebuffer: native resolution per the emulator's video mode. |
| 451 |
- Pointer: `/pointer` drives the firmware mouse — absolute → `MSESetPosition`, |
| 452 |
relative → `MSEOffsetPosition`, buttons → `MSEUpdateButtonState`; `GET /pointer` |
| 453 |
reads `MSEGetState`. Coordinates are firmware graphics pixels (the same space a |
| 454 |
program reads). Buttons: bit0 = left, bit1 = right (the firmware's native |
| 455 |
masks), matching the contract's primary/secondary. The emulator enables the |
| 456 |
mouse at reset, so injection works from boot. |
| 457 |
|
| 458 |
### Agon (Console8 / Light 2) — FAB Agon Emulator |
| 459 |
- **Adoption:** conform-only (Rust emulator — keeps its own HTTP server, matches |
| 460 |
this contract rather than vendoring the C core). |
| 461 |
- Flag: `--control-port <N>`. |
| 462 |
- Address space: eZ80 24-bit, flat 16 MB (`addr` up to `0xFFFFFF`); `bank` |
| 463 |
tolerated and ignored. Program load base `0x040000`. |
| 464 |
- Registers (ADL) — `/regs` example (16-bit regs; `mbase` 8-bit; `adl` 0/1): |
| 465 |
```json |
| 466 |
{ "af":0, "bc":0, "de":0, "hl":0, "ix":0, "iy":0, |
| 467 |
"sp":0, "pc":262144, "mbase":0, "adl":1 } |
| 468 |
``` |
| 469 |
- Framebuffer: from `vgabuf` (the VDP output); the offscreen path already keeps |
| 470 |
video alive (the FAB equivalent of x16's `SDL_VIDEODRIVER=dummy`). |
| 471 |
- Audio: the VDP mixes 8-bit unsigned mono at 16384 Hz; `/audio` returns it as a |
| 472 |
16-bit signed PCM WAV. The SDL audio callback (the sole `getAudioSamples` |
| 473 |
consumer) tees each block into the ring; headless capture needs |
| 474 |
`SDL_AUDIODRIVER=dummy` so that callback runs. |
| 475 |
- Input: `/key?text=c` maps the char to a fabgl virtual key (`ascii2vk`) and |
| 476 |
delivers it via `sendVKeyEventToFabgl`; `?code=<vk>` passes a raw fabgl vkey. |
| 477 |
The control thread queues events; the render thread delivers them (same |
| 478 |
thread as normal keyboard input). `/reset` triggers the soft-reset atomic. |
| 479 |
- Pointer: `/pointer` feeds PS/2 mouse packets through `sendHostMouseEventToFabgl` |
| 480 |
— the same device path host mouse motion uses, so it drives the real fabgl/VDP |
| 481 |
mouse rather than a shortcut. PS/2 motion is relative, so the control server |
| 482 |
tracks the absolute position itself: `x`/`y` become a delta from that tracked |
| 483 |
point (split across packets past the ±127 int9 range), `dx`/`dy` apply directly, |
| 484 |
and `GET /pointer` reports it exactly. Coordinates are screen space (x right, |
| 485 |
y down; y is negated into the PS/2 up-positive convention). `buttons` bit0/bit1 |
| 486 |
map to the PS/2 left/right (primary/secondary) button bits. Like `/key`, packets |
| 487 |
are queued by the control thread and delivered by the render thread at the frame |
| 488 |
boundary, so `pause → pointer → step(1) → screenshot` is reproducible. |
| 489 |
|
| 490 |
### NEC PC-FX — `mednafen` (Doomsday One fork) |
| 491 |
- **Adoption:** vendors the shared core as a git submodule |
| 492 |
(`src/extern/retro-remote-debug-controller`), built in with `./configure |
| 493 |
--enable-rrdc`. Dispatched per running console, so PC Engine and Saturn can |
| 494 |
reuse the same core once their backends land. |
| 495 |
- Control port: the `MEDNAFEN_CONTROLPORT=<N>` environment variable (Mednafen |
| 496 |
exposes no free-form CLI flag for a driver add-on). Unset ⇒ server off. |
| 497 |
- Address space: V810, flat **2 MB** main RAM (`0x000000–0x1FFFFF`); `addr` |
| 498 |
wraps in that space and `bank` is ignored. |
| 499 |
- Registers: `pc` + `r0`–`r31` (33 × 32-bit), from the V810 core. |
| 500 |
- Framebuffer: the active display, decoded to RGB888 by the fork with the |
| 501 |
surface's channel shifts (so it is correct regardless of Mednafen's |
| 502 |
display-dependent pixel order). The PC-FX varies horizontal resolution per |
| 503 |
scene (256 for the maze, up to 1024 for high-res text); the fork normalizes |
| 504 |
each scanline by Mednafen's per-line `LineWidths`, so the PPM is centred and |
| 505 |
complete at whatever width the scene uses. Height is the visible line count |
| 506 |
(~232). Clients read `width`/`height` from the PPM header. |
| 507 |
- Audio: stereo, at Mednafen's configured output rate (`espec.SoundRate`), |
| 508 |
teed from the per-frame `SoundBuf`. |
| 509 |
- Input: the PC-FX has a gamepad, not a keyboard, so `/key?text=<c>` and |
| 510 |
`/key?code=<n>` are both read as a character over a WASD-style pad map — |
| 511 |
`w/a/s/d` = up/left/down/right, space or Enter = RUN (start), `c` = SELECT, |
| 512 |
`1`–`6` = buttons I–VI. A held button is re-asserted each frame (the frontend |
| 513 |
rewrites the pad from physical input every frame), so holds persist across |
| 514 |
`/step`. `/reset` power-cycles the machine. |
| 515 |
- Pad: `/pad` remaps the canonical mask to the FX pad word (`I=0 II=1 III=2 IV=3 |
| 516 |
V=4 VI=5 SELECT=6 RUN=7 UP=8 RIGHT=9 DOWN=10 LEFT=11`; A/B → I/II, X/Y → |
| 517 |
III/IV, L/R → V/VI, START → RUN) and OR-merges it into the pad read every |
| 518 |
frame, as a level. **Trap:** that layout is implicit in the *order* of |
| 519 |
Mednafen's `PCFX_GamepadIDII` array — the third `IDIIS_Button` argument is |
| 520 |
`ConfigOrder`, not a bit offset. Using those numbers as bits binds every button |
| 521 |
wrongly and is a defect currently present in the fork. |
| 522 |
- Pointer: the PC-FX has no pointing device. Because 0.5.0 advertises |
| 523 |
cumulatively, the fork supplies refusing stubs (`/pointer` → **400**) rather |
| 524 |
than NULL callbacks, so it can honestly advertise **0.5.0** for `/pad`. |
| 525 |
- Audio: `/audio` is fed by the mixer, which only runs when sound is enabled. |
| 526 |
Launching with `-sound 0` yields a valid but **header-only (44-byte)** WAV, not |
| 527 |
an error — use `-sound 1 -sound.driver dummy` for headless audio capture. |
| 528 |
|
| 529 |
--- |
| 530 |
|
| 531 |
## Adoption paths |
| 532 |
|
| 533 |
An emulator conforms by exposing exactly the HTTP surface above — nothing else |
| 534 |
is required. Two ways to get there: |
| 535 |
|
| 536 |
- **Vendor the shared core** (`core/retro_control.c` + `.h`) — for C/C++ |
| 537 |
emulators. Implement the backend callbacks (four are enough for 0.1; add |
| 538 |
`inject_key`/`reset` for 0.2, `write_mem`/`capture_audio` for 0.3, |
| 539 |
`set_pointer`/`get_pointer` for 0.4, `set_pad`/`get_pad` for 0.5, and |
| 540 |
`get_perf`/`set_perf` (which return allocated JSON) for 0.6 — a NULL |
| 541 |
callback makes its endpoint return 501 and lowers the advertised contract, so a |
| 542 |
platform lacking a device should supply a stub that returns 400 instead), wire |
| 543 |
three loop hooks, call |
| 544 |
`retro_control_start(port, &backend)`; the socket loop, HTTP parsing, PPM/JSON |
| 545 |
encoding and pause/step control come for free. The Commander X16 fork does |
| 546 |
this. |
| 547 |
- **Conform directly** — for emulators in other languages (the FAB Agon emulator |
| 548 |
is Rust). Keep your own HTTP server; just match the contract and pass the |
| 549 |
conformance suite. Sharing the C core is an optimization, not a requirement — |
| 550 |
the contract is the asset. |
| 551 |
|
| 552 |
Either way, `conformance/` is the arbiter: pass it and you conform. |
| 553 |
|
| 554 |
## In this repo |
| 555 |
|
| 556 |
- `core/retro_control.{c,h}` — portable server + backend vtable (vendor path). |
| 557 |
- `client/emu_control.py` — the shared pytest client. |
| 558 |
- `conformance/` — a platform-agnostic pytest that proves an emulator conforms. |