OXYGENHUB
◆ main retro-remote-debug-controller / SPEC.md
29.7 KiB · Markdown · last changed feat: contract 0.6 — GET/POST /perf, the performance metrics (budget, current frame, windows, zones, raw frames since one, a platform section) and their settings, and /status.capabilities; backend callbacks get_perf and set_perf return allocated JSON (#6) 25 days ago renderedsource blame history raw

Retro Remote Debug Controller — HTTP Control Contract

Contract version: 0.6.0 (semver; clients assert on the MAJOR). 0.6 adds performance metrics (GET /perf, POST /perf) and /status.capabilities; 0.5 added a virtual game controller (POST /pad, GET /pad); 0.4 added pointer injection (POST /pointer, GET /pointer); 0.3 added memory write (POST /mem) and an audio drain (GET /audio); 0.2 added input injection (POST /key, POST /reset). Every minor is purely additive, so older clients keep working and servers advertise the highest level whose callbacks are all present via /status.contract.

A minimal, portable HTTP API that a retro-platform emulator exposes so an external harness can screenshot the screen, read memory and registers, step the machine deterministically, and inject input. The same contract is implemented by the forked FAB Agon Emulator, the Neo6502 emulator, the Commander X16 emulator, and the NEC PC-FX (Mednafen) emulator, so one pytest harness drives all four ports.

Principles

  • Opt-in. The server is OFF by default and starts only when the emulator is launched with a control-port flag. Zero cost to normal use.
  • Localhost only. The server MUST bind 127.0.0.1 (never 0.0.0.0). /mem and input injection are a memory-read / remote-control surface; they do not leave the machine.
  • The contract is the HTTP surface, not the flag. Each emulator names its launch flag idiomatically (x16emu: -controlport N; FAB: --control-port N). Only the endpoints below are normative.
  • Determinism is a first-class requirement. These are frame-exact ports. Every state read (/mem, /regs, /screenshot) MUST observe a consistent machine state — captured at an instruction/frame boundary on the emulator thread, never a torn mid-instruction read from the HTTP thread. See Consistency below.

Conventions

  • JSON responses are application/json; charset=utf-8.
  • Binary responses use the stated content-type; no chunked encoding required.
  • Errors: HTTP 4xx/5xx with body {"error":"<message>"}.
  • Numbers in JSON are decimal unless a field is documented as a hex string.
  • Methods are exactly as listed (GET/POST). Unknown path → 404. Unknown method on a known path → 405.

Endpoints

GET /status

Liveness + contract negotiation. Always available.

{
  "contract": "0.6.0",
  "emulator": "x16emu",
  "platform": "x16",
  "frame": 12345,
  "paused": true,
  "running": true,
  "capabilities": ["mem", "regs", "screenshot", "step", "key", "reset",
                   "mem-write", "audio", "pointer", "pad", "perf"]
}
  • contract — this document's version. Clients MUST reject a mismatched MAJOR.
  • capabilities (0.6) — the endpoint groups this server serves, whatever level contract reaches: mem, regs, screenshot, step (always), then key, reset, mem-write (POST /mem), audio, pointer, pad, perf. contract stays cumulative (0.6.0 means everything up to and including /perf); capabilities lets a client ask for one group directly, so a backend with no pointer can still offer /perf without a refusing stub. Absent on servers older than 0.6: read contract there.
  • platform — one of x16, neo6502, agon, pcfx (extensible). Tells the client the memory/register model (see Per-platform appendix).
  • frame — monotonic completed-frame counter since boot. The determinism anchor: a screenshot taken at frame == N shows the state after N complete frames.
  • paused — true if the machine is halted (only advances via /step).

GET /screenshot

The live screen as a Portable Pixmap.

  • Response: image/x-portable-pixmap, binary PPM P6, 8-bit RGB, no alpha.
  • Pixels are the most recent COMPLETE frame (never a half-scanned frame). The emulator keeps a snapshot the render thread fills at frame end; the HTTP thread serves that snapshot.
  • Dimensions are the platform's native framebuffer (appendix). Clients read width/height from the PPM header — do not hardcode.
  • Reserved: ?frame=N (block until frame N) — still reserved, not yet required.

GET /mem?addr=<a>&len=<n>[&bank=<b>]

Raw memory bytes.

  • addr — start address, decimal or 0x-hex. Interpreted in the platform's address space (appendix).
  • len — byte count, decimal. Servers MAY cap len (recommend ≥ 65536); a capped request returns what it can with Content-Length telling the truth.
  • bank — OPTIONAL bank selector for banked platforms (X16). Omitted → the machine's currently-selected bank. Ignored by flat-memory platforms.
  • Response: application/octet-stream, exactly the requested bytes.
  • Reads go through the emulator's debug-read path (no side effects, no I/O triggers, no access-flag mutation).

POST /mem?addr=<a>[&bank=<b>] (0.3)

Write (poke) memory. The write-sibling of GET /mem; same address/bank model.

  • addr, bank — as for the read form. len is NOT a query param: the byte count is the request body length.
  • Body — application/octet-stream, the raw bytes to write starting at addr. Servers MAY cap the body (recommend ≥ 65536).
  • Response: { "written": <n> } — bytes actually written. A server MAY write fewer than requested (e.g. it stops at a ROM/unwritable boundary); the count tells the truth.
  • Writes go through the emulator's CPU write path. Intended for RAM/state fixup (force lives, seed a level, restore a snapshot). Writing an I/O register MAY trigger device side effects — that is the caller's responsibility.
  • Determinism: poke while paused so the machine can't overwrite between the write and a subsequent read.

GET /regs

CPU registers as JSON. Keys are platform-specific (appendix); values are decimal unless suffixed _hex. Example (x16 / 65C02):

{ "a":0, "x":16, "y":2, "sp":505, "pc":49152, "status":32,
  "ram_bank":1, "rom_bank":0 }

POST /step?frames=<n> (recommended; required for frame-exact tests)

Advance the machine by exactly n complete frames (default 1), then halt.

  • Only meaningful when the machine is paused (see /pause). On a free-running machine, servers SHOULD pause, run n frames, and stay paused.
  • Response: { "frame": <new frame counter> }.
  • POST /step?cycles=<n> MAY be offered for sub-frame stepping.

POST /pause / POST /resume (recommended)

Halt / free-run. { "paused": true|false }. A machine launched with a --control-pause-style option SHOULD boot paused so tests are deterministic from frame 0.

POST /key?text=<char> | ?code=<n> [&down=0|1] (0.2)

Inject a key. Query params (like /mem and /step), no body:

  • text — a single character, mapped to the platform's keyboard.
  • code — a raw platform key code (e.g. fabgl virtual key on Agon, an SDL/PS2 code on others). text and code are alternatives; provide one.
  • down — 1 presses (holds), 0 releases; omitted taps (press then release). Holding across /step frames is how a program that samples input each frame (e.g. a game) actually sees the key.
  • Response: { "injected": true }. Missing/unmapped key → 400.

Injection plus /step retires scripted-autoexec input: press → step → assert → release, deterministically.

POST /pointer?x=<n>&y=<n> | ?dx=<n>&dy=<n> [&buttons=<mask>] (0.4)

Inject a pointer (mouse) move and/or click through the platform's native pointer path — the mouse analogue of /key. Query params (no body):

  • x, y — absolute pointer position, in the platform's pointer coordinate space (framebuffer pixels; appendix). Provide both.
  • dx, dy — relative motion applied to the current position; either may be omitted (treated as 0). x/y and dx/dy are alternatives; provide one pair. Absolute is preferred for tests ("put the cursor on the button" beats dead-reckoning).
  • buttons — OPTIONAL bitmask, bit0 = primary, bit1 = secondary. Omitted → the button state is left unchanged (a pure move). Provide buttons=0 to release. Hold a button across /step frames (re-assert it each move) so a program that samples the pointer per frame sees the press.
  • Response: { "injected": true }. No pointer on the platform → 400.

Device-level, not driver-state: the injection MUST exercise the same path a real mouse takes (e.g. the KERNAL/VERA mouse on the X16, the firmware mouse on the Neo6502), so what the program reads is what a physical mouse would produce. Determinism: inject under /pause, then /step — the move lands at the frame boundary, so screenshot-diffing a hovered/clicked UI is reproducible.

GET /pointer (0.4)

Read back the current pointer state. Response:

{ "x": 42, "y": 24, "buttons": 1 }
  • x, y — current position in the same coordinate space as POST /pointer.
  • buttons — the current button bitmask (same bit assignment).
  • Lets a test distinguish "the input never reached the platform" from "it arrived but the program drew the wrong thing". No pointer on the platform → 400.

POST /pad?index=<n> [&buttons=<mask>] [&connected=0|1] (0.5)

Inject a game controller — the pad analogue of /key and /pointer. Query params (no body):

  • index — which pad, 0-based. OPTIONAL, default 0.
  • buttons — bitmask of what is HELD, in the canonical layout below. Omitted → the button state is left unchanged. buttons=0 releases everything while leaving the pad connected.
  • connected — OPTIONAL. 1 (the default whenever buttons is given) presents a virtual pad at index; 0 unplugs it. Unplugging matters: a program that asks "is a controller present" before offering two-player must be testable in both states, and on most platforms the count is what it asks.
  • Response: { "injected": true }. No controller support on the platform → 400.

Canonical button layout, low bit first:

bit01234567891011
LEFTRIGHTUPDOWNABXYSTARTSELECTLR

This is the SNES/Neo6502 native order, chosen for the same reason /pointer fixed bit0 = primary: one layout, defined once, so a test written against one platform reads the same on another. A backend whose hardware disagrees — the X16 KERNAL reports RIGHT/LEFT/DOWN/UP — converts in its own floor. A test never learns which machine it is on, which is the entire point.

Device-level, not driver-state: the injection MUST exercise the same path a real pad takes, so what the program reads is what physical hardware would produce. On an emulator that reads SDL joysticks, that means presenting a virtual pad the existing read path picks up — not driving SDL, which would need a joystick to exist and defeats the purpose. Headless CI has no joystick; that is the case this endpoint is for.

Hold across frames: a pad is a LEVEL, not an event. buttons stays as set until changed, so a press survives any number of /step frames without re-asserting — unlike /key, whose TAP is deliberately momentary. Release explicitly.

GET /pad?index=<n> (0.5)

Read back a pad. Response:

{ "index": 0, "connected": true, "buttons": 48 }
  • connected — whether anything is present at that index, real or virtual.
  • buttons — the current mask in the layout above.
  • Same job as GET /pointer: distinguishes "the input never reached the platform" from "it arrived and the program ignored it". No controller support on the platform → 400.

POST /reset (0.2)

Soft/cold reset the machine. Response { "reset": true }. The frame counter MAY reset here (the one case /status.frame is allowed to go backwards).

GET /audio (0.3)

Drain the audio the emulator has synthesised since the last /audio call — the audio analogue of /screenshot, but a window (everything since last drain) rather than a snapshot.

  • Response: audio/wav, a self-describing PCM WAV (RIFF, 16-bit signed, interleaved). The header carries the sample rate and channel count; clients read them from the WAV — do not hardcode (appendix lists native rates).
  • The server keeps a bounded ring of mixed output samples, teed from the same buffer that feeds the sound device. Each call empties the ring and returns it; an empty ring returns a valid zero-frame WAV.
  • Determinism: under /pause + /step, the drain covers exactly the audio of the stepped frames, so a test can pause → step(n) → GET /audio and get the sound of those n frames reproducibly.
  • X-Rrdc-Audio-Dropped: <n> — samples the ring dropped (oldest-first) to overflow since the last drain. Non-zero means drain more often. 0 when clean.

GET /perf [?windows=<list>] [&zones=0|1] [&frames=<n>] [&since=<frame>] (0.6)

How much of each frame the program uses: the measure a profiler gives, read by a test the way /mem is. Answered on the emulator thread (see Consistency), so it describes whole frames.

Query params, all OPTIONAL:

  • windows — comma-separated statistics windows, each a number of seconds or session (the whole history). Default 1,10,30,60,session.
  • zones — 0 leaves zones out. Default 1.
  • frames — return the last n completed frames' raw records in frames (oldest first). Default 0: none.
  • since — return the raw records of every frame after <frame> still in the history (oldest first; frames then caps how many, the newest kept). Lets a harness poll and never miss a frame: pass the last frame it saw.
  • A platform MAY accept more (the X16: lines=1, below); unknown params are ignored.

Every count is in guest CPU cycles unless its name says otherwise (…Us: host microseconds; …Pct: percent). The shape:

{
  "enabled": true,
  "budget": {
    "targetFps": 60, "vsyncHz": 59.52, "machineKhz": 8000,
    "cyclesPerFrame": 134400, "scanlinesPerFrame": 525, "cyclesPerScanline": 256,
    "budgetCycles": 134400, "framesPerBudget": 1,
    "historyFrames": 3600, "totalFrames": 7200, "idleMode": "auto"
  },
  "current": {
    "frame": 7199, "totalCycles": 134400, "workCycles": 31012,
    "idleCycles": 103388, "irqCycles": 3297, "mainCycles": 27715,
    "budgetWorkCycles": 31012, "instructions": 9120, "hostUs": 412,
    "utilizationPct": 23.1, "overBudget": false
  },
  "windows": [
    { "name": "1s", "seconds": 1, "frames": 60, "budgetCycles": 134400,
      "work":       {"min": 0, "mean": 0, "p50": 0, "p95": 0, "p99": 0, "max": 0},
      "budgetWork": {"min": 0, "mean": 0, "p50": 0, "p95": 0, "p99": 0, "max": 0},
      "total": {"…": 0}, "irq": {"…": 0}, "hostUs": {"…": 0},
      "overruns": 0, "overrunPct": 0.0, "worstOverrunCycles": 0, "worstFrame": 0,
      "meanUtilizationPct": 23.1, "p95UtilizationPct": 31.0, "maxUtilizationPct": 40.2 }
  ],
  "zones": [
    { "id": 0, "name": "sound", "kind": "marker", "markerId": 7, "idle": false,
      "current": 11520,
      "windows": { "1s": {"min": 0, "mean": 0, "p50": 0, "p95": 0, "p99": 0, "max": 0} } },
    { "id": 1, "name": "vblank wait", "kind": "address",
      "start": 49152, "end": 49408, "bank": -1, "idle": true, "current": 0, "windows": {} }
  ],
  "frames": [
    { "frame": 7198, "totalCycles": 134400, "workCycles": 30544,
      "idleCycles": 103856, "irqCycles": 3297, "instructions": 9001,
      "hostUs": 398, "budgetWorkCycles": 30544, "zones": [11520, 0] }
  ],
  "platform": { }
}
  • enabled — whether the backend is measuring. Off, every count is 0 and the history is empty; POST /perf {"enabled":true} turns it on. A backend MAY start measuring on the first /perf request.
  • budget — the frame and its budget. budgetCycles is the cycles the program may use per budget period of framesPerBudget frames (a 30 fps target on a 60 Hz machine: 2 frames). cyclesPerScanline converts a count to scanlines.
  • current — the last completed frame. workCycles = total − idle; mainCycles = work − irq. idleCycles are cycles the backend classifies as waiting (see Idle). budgetWorkCycles is the work over the trailing budget period, which utilizationPct and overBudget measure against budgetCycles.
  • windows — per requested window: the frames it covers and {min, mean, p50, p95, p99, max} of each series (budgetWork per budget period, the rest per frame); overruns (periods over budget), their share, and the worst.
  • zones — the program's own breakdown (see Zones). current is the zone's cycles in the last completed frame; windows its {min…max} per window, where the backend keeps a zone's history ({} where it does not). A cycle belongs to one zone at most: the innermost open marker region, else the address zone the PC is in.
  • frames — raw per-frame records, zones indexed by zone id.
  • platform — anything platform-specific, described in the Per-platform appendix (the X16's VERA bus bandwidth and sprite render time). A client that does not know the platform ignores it.

Idle. A backend counts as idle what the CPU spends waiting: a halt instruction (WAI, HALT), a recognised spin loop, an address zone marked idle, or what the guest marks so. idleMode says which: auto (all of them), markers (only what the guest marks), none.

Zones. Two kinds, both optional:

  • address zones — a PC range [start, end) (and bank, -1 any), set by POST /perf.
  • marker regions — the guest program writes markers to a platform-defined register as it enters and leaves a region of its own (the X16: $9FBC, see the appendix). A marker region exists once the guest first enters it, named region <n> unless POST /perf has named it.

POST /perf (0.6)

Settings. The body is a JSON object; every field is OPTIONAL, and a field left out is left as it is:

{
  "enabled": true,
  "targetFps": 60,
  "idleMode": "auto",
  "historyFrames": 3600,
  "reset": false,
  "zones": [
    { "name": "game", "marker": 1 },
    { "name": "vblank wait", "start": 49152, "end": 49408, "bank": -1, "idle": true }
  ]
}
  • enabled — start or stop measuring. Stopping MAY discard the history.
  • targetFps — the rate the program aims for, which sets budget.
  • idleMode — auto, markers or none (see Idle).
  • historyFrames — how many frames of history to keep.
  • reset — true empties the history and the statistics.
  • zones — replaces the zone list. An entry with marker names the guest's marker region <n>; one with start/end is an address zone.
  • A platform MAY accept more (the X16: sprites, below); unknown fields are ignored.
  • Response: 200 and the GET /perf body without windows, zones or frames, i.e. the settings as they now stand. A body that is not a JSON object, or a field of the wrong type, is 400.

Consistency (normative)

  • /mem, /regs, and /step MUST be serviced on the emulator thread at an instruction boundary — e.g. the HTTP handler enqueues a command that the main loop drains between instructions/frames and signals back. The HTTP thread MUST NOT read CPU/RAM state directly while the CPU is executing.
  • /perf MUST be answered on the emulator thread, and describe completed frames only (current is the last whole frame, never the one in progress).
  • /screenshot MUST serve a complete-frame snapshot (double-buffer: render thread writes the back buffer, swaps under a lock at frame end; HTTP thread reads the front buffer).
  • frame in /status MUST increment once per completed video frame and never go backwards (except across /reset).

Versioning

  • Semantic. MAJOR bump = breaking change to an existing endpoint's request/response. MINOR = additive (new endpoint / optional field). PATCH = clarification.
  • Clients read /status.contract and fail fast on a MAJOR mismatch.

Per-platform appendix

Commander X16 — x16emu

  • Flag: -controlport <N> (single-dash, matching x16emu's -dump style).
  • Framebuffer: 640×480, native order BGRA8888 (framebuffer[i*4+2]=R, +1=G, +0=B); the server swizzles to PPM RGB.
  • Address space: 16-bit CPU (0x0000–0xFFFF). The banked window 0xA000–0xBFFF is selected by bank → the emulator's read6502(addr, bank) / banked RAM (BRAM). addr < 0xA000 or ≥ 0xC000 ignores bank.
  • Registers: a x y (8), sp pc (16), status (8), plus ram_bank, rom_bank. Source: the regs struct + memory_get_ram_bank/rom_bank().
  • Frame source: video.c frame_count.
  • Audio: stereo (2 ch), native rate 25000000/512 ≈ 48828 Hz, teed from the mixed audio_render() output.
  • Pointer: /pointer maps to the KERNAL/VERA mouse — absolute/relative position and buttons fed through the same path the host mouse uses, so MOUSE_GET (and VERA sprite 0) see it. Coordinates are screen pixels.
  • Pad: /pad presents a virtual controller through the emulator's own joystick read path. The fork implements the full 0.5 callback set and advertises 0.5.0.
  • Perf (0.6): the enhanced X16 emulator (enhanced-x16-emulator, the one with the ImGui Performance panel) implements /perf from the same measurements the panel shows, and advertises 0.6.0; the plain fork does not measure, and stays at 0.5.0. The budget is the X16's: 8 MHz, 525 scanlines a frame at 59.52 Hz, 134,400 cycles, 256 a scanline. Idle: WAI, a short store-free backward branch taken twice, or markers.
    • Markers: the guest writes one byte to $9FBC (the emulator's register 12; reading it returns 1, the marker protocol, so a program can tell it is there): $01–$3F enter region n, $80|n leave region n, $00 leave the innermost, $F0 the frame's work begins, $F1 the frame's work ends (the rest of the frame is idle), $FE reset the statistics. Up to 8 regions nest; cycles go to the innermost. A write to a stock X16 or the plain fork does nothing.
    • Zones: up to 16; the first 8 keep a history (windows), the rest report current only.
    • platform:
      { "bandwidth": { "frame": 7198, "lineClocks": 800, "linesActive": 480,
          "peakLine": 12, "peakLineClocks": 480, "p95LineClocks": 480,
          "meanLineClocks": 320, "linesOverBudget": 0,
          "layers": [ {"fetches": 57600, "bytes": 230400, "peakLine": 0, "peakFetches": 120},
                      {"fetches": 57600, "bytes": 230400, "peakLine": 0, "peakFetches": 120} ],
          "port": {"readBytes": 0, "writeBytes": 64, "accesses": 64, "vramBytes": 64},
          "lines": [320, 320, "…480 entries"] },
        "sprites": { "enabled": true, "frame": 7198, "lineBudget": 798,
          "peakLine": 40, "peakBudget": 612, "peakDemand": 612,
          "linesExhausted": 0, "spritesDropped": 0, "budgetUsedTotal": 51200,
          "slotsUsed": 24, "lines": [0, 0, "…480 entries"] } }
      
      bandwidth is VERA's VRAM bus in bus clocks (800 a scanline; a layer fetch or a CPU data-port access takes 2), for the last completed frame; lines (only with ?lines=1) is each active line's clocks. sprites is the sprite renderer's time per line against its 798-clock budget, for the last frame; it is off until POST /perf {"sprites":true} (it costs the emulator time), {"enabled":false} until then.

Neo6502 — neo6502 emulator

  • Flag: --control-port <N>.
  • Address space: flat 64 KB; bank ignored.
  • Registers: 6502 a x y sp pc status.
  • Framebuffer: native resolution per the emulator's video mode.
  • Pointer: /pointer drives the firmware mouse — absolute → MSESetPosition, relative → MSEOffsetPosition, buttons → MSEUpdateButtonState; GET /pointer reads MSEGetState. Coordinates are firmware graphics pixels (the same space a program reads). Buttons: bit0 = left, bit1 = right (the firmware's native masks), matching the contract's primary/secondary. The emulator enables the mouse at reset, so injection works from boot.

Agon (Console8 / Light 2) — FAB Agon Emulator

  • Adoption: conform-only (Rust emulator — keeps its own HTTP server, matches this contract rather than vendoring the C core).
  • Flag: --control-port <N>.
  • Address space: eZ80 24-bit, flat 16 MB (addr up to 0xFFFFFF); bank tolerated and ignored. Program load base 0x040000.
  • Registers (ADL) — /regs example (16-bit regs; mbase 8-bit; adl 0/1):
    { "af":0, "bc":0, "de":0, "hl":0, "ix":0, "iy":0,
      "sp":0, "pc":262144, "mbase":0, "adl":1 }
    
  • Framebuffer: from vgabuf (the VDP output); the offscreen path already keeps video alive (the FAB equivalent of x16's SDL_VIDEODRIVER=dummy).
  • Audio: the VDP mixes 8-bit unsigned mono at 16384 Hz; /audio returns it as a 16-bit signed PCM WAV. The SDL audio callback (the sole getAudioSamples consumer) tees each block into the ring; headless capture needs SDL_AUDIODRIVER=dummy so that callback runs.
  • Input: /key?text=c maps the char to a fabgl virtual key (ascii2vk) and delivers it via sendVKeyEventToFabgl; ?code=<vk> passes a raw fabgl vkey. The control thread queues events; the render thread delivers them (same thread as normal keyboard input). /reset triggers the soft-reset atomic.
  • Pointer: /pointer feeds PS/2 mouse packets through sendHostMouseEventToFabgl — the same device path host mouse motion uses, so it drives the real fabgl/VDP mouse rather than a shortcut. PS/2 motion is relative, so the control server tracks the absolute position itself: x/y become a delta from that tracked point (split across packets past the ±127 int9 range), dx/dy apply directly, and GET /pointer reports it exactly. Coordinates are screen space (x right, y down; y is negated into the PS/2 up-positive convention). buttons bit0/bit1 map to the PS/2 left/right (primary/secondary) button bits. Like /key, packets are queued by the control thread and delivered by the render thread at the frame boundary, so pause → pointer → step(1) → screenshot is reproducible.

NEC PC-FX — mednafen (Doomsday One fork)

  • Adoption: vendors the shared core as a git submodule (src/extern/retro-remote-debug-controller), built in with ./configure --enable-rrdc. Dispatched per running console, so PC Engine and Saturn can reuse the same core once their backends land.
  • Control port: the MEDNAFEN_CONTROLPORT=<N> environment variable (Mednafen exposes no free-form CLI flag for a driver add-on). Unset ⇒ server off.
  • Address space: V810, flat 2 MB main RAM (0x000000–0x1FFFFF); addr wraps in that space and bank is ignored.
  • Registers: pc + r0–r31 (33 × 32-bit), from the V810 core.
  • Framebuffer: the active display, decoded to RGB888 by the fork with the surface's channel shifts (so it is correct regardless of Mednafen's display-dependent pixel order). The PC-FX varies horizontal resolution per scene (256 for the maze, up to 1024 for high-res text); the fork normalizes each scanline by Mednafen's per-line LineWidths, so the PPM is centred and complete at whatever width the scene uses. Height is the visible line count (~232). Clients read width/height from the PPM header.
  • Audio: stereo, at Mednafen's configured output rate (espec.SoundRate), teed from the per-frame SoundBuf.
  • Input: the PC-FX has a gamepad, not a keyboard, so /key?text=<c> and /key?code=<n> are both read as a character over a WASD-style pad map — w/a/s/d = up/left/down/right, space or Enter = RUN (start), c = SELECT, 1–6 = buttons I–VI. A held button is re-asserted each frame (the frontend rewrites the pad from physical input every frame), so holds persist across /step. /reset power-cycles the machine.
  • Pad: /pad remaps the canonical mask to the FX pad word (I=0 II=1 III=2 IV=3 V=4 VI=5 SELECT=6 RUN=7 UP=8 RIGHT=9 DOWN=10 LEFT=11; A/B → I/II, X/Y → III/IV, L/R → V/VI, START → RUN) and OR-merges it into the pad read every frame, as a level. Trap: that layout is implicit in the order of Mednafen's PCFX_GamepadIDII array — the third IDIIS_Button argument is ConfigOrder, not a bit offset. Using those numbers as bits binds every button wrongly and is a defect currently present in the fork.
  • Pointer: the PC-FX has no pointing device. Because 0.5.0 advertises cumulatively, the fork supplies refusing stubs (/pointer → 400) rather than NULL callbacks, so it can honestly advertise 0.5.0 for /pad.
  • Audio: /audio is fed by the mixer, which only runs when sound is enabled. Launching with -sound 0 yields a valid but header-only (44-byte) WAV, not an error — use -sound 1 -sound.driver dummy for headless audio capture.

Adoption paths

An emulator conforms by exposing exactly the HTTP surface above — nothing else is required. Two ways to get there:

  • Vendor the shared core (core/retro_control.c + .h) — for C/C++ emulators. Implement the backend callbacks (four are enough for 0.1; add inject_key/reset for 0.2, write_mem/capture_audio for 0.3, set_pointer/get_pointer for 0.4, set_pad/get_pad for 0.5, and get_perf/set_perf (which return allocated JSON) for 0.6 — a NULL callback makes its endpoint return 501 and lowers the advertised contract, so a platform lacking a device should supply a stub that returns 400 instead), wire three loop hooks, call retro_control_start(port, &backend); the socket loop, HTTP parsing, PPM/JSON encoding and pause/step control come for free. The Commander X16 fork does this.
  • Conform directly — for emulators in other languages (the FAB Agon emulator is Rust). Keep your own HTTP server; just match the contract and pass the conformance suite. Sharing the C core is an optimization, not a requirement — the contract is the asset.

Either way, conformance/ is the arbiter: pass it and you conform.

In this repo

  • core/retro_control.{c,h} — portable server + backend vtable (vendor path).
  • client/emu_control.py — the shared pytest client.
  • conformance/ — a platform-agnostic pytest that proves an emulator conforms.