Adopting in an Emulator
An emulator gains the control API by implementing a small read-only backend
and wiring three hooks into its main loop. The shared server
(core/retro_control.c) owns everything else: the socket loop, HTTP parsing,
PPM/JSON encoding, and the pause/step/free-run control flow.
Two ways to adopt
- Vendor the core (this page) — C/C++ emulators add
core/retro_control.{c,h}and implement the backend + loop hooks below. - Conform directly — emulators in other languages keep their own HTTP server and just match the contract, verified by the conformance suite. (The FAB Agon emulator is Rust and takes this path.) The steps below are the vendor path; a conform-only port implements the same endpoints its own way, and the multi-threaded stepping notes below apply to both.
1. Vendor or submodule the core
Add core/retro_control.c + core/retro_control.h to your build. Prefer a git
submodule so it stays single-source; vendoring a copy is fine short-term if you
mark its provenance and sync from upstream (don't edit the copy).
2. Implement the backend (four callbacks)
#include "retro_control.h"
static uint32_t my_read_mem(uint32_t addr, int32_t bank, uint32_t len,
uint8_t *out, uint32_t cap) {
/* debug-read, side-effect free; bank<0 = current bank */
}
static void my_get_regs_json(char *buf, size_t cap) {
snprintf(buf, cap, "{\"a\":%u,\"pc\":%u,...}", ...);
}
static void my_get_framebuffer(retro_framebuffer_t *out) {
out->pixels = my_framebuffer; /* complete-frame buffer */
out->width = W; out->height = H;
out->fmt = RETRO_PIX_BGRA8888; /* or RGBA8888 / RGB888 */
}
static uint64_t my_get_frame_count(void) { return frame_counter; }
static const retro_control_backend_t backend = {
.platform = "myplatform",
.emulator = "myemu",
.read_mem = my_read_mem,
.get_regs_json = my_get_regs_json,
.get_framebuffer = my_get_framebuffer,
.get_frame_count = my_get_frame_count,
};
Any callback may be NULL → that endpoint returns 501 Not Implemented.
Those four are contract 0.1. Each later level adds callbacks; the server
advertises the highest level whose callbacks are all present, and lists each
group in /status.capabilities:
| Level | Callbacks |
|---|---|
| 0.2 | inject_key, reset |
| 0.3 | write_mem, capture_audio |
| 0.4 | set_pointer, get_pointer |
| 0.5 | set_pad, get_pad |
| 0.6 | get_perf(query), set_perf(body, &status) |
A platform without a device (no pointer on a PC-FX) supplies a stub that
returns 0 (→ 400) rather than NULL, so it can still reach the levels above.
get_perf and set_perf return a malloc'd JSON string the server
frees: the backend owns its measurements and builds the body (see
Performance Metrics for its shape). get_perf gets
the raw query string; set_perf the POST body and sets *status to 200, or
400 for a body it cannot apply. Both run on the emulator thread.
3. Start the server
Parse your control-port flag, then once, before the main loop:
if (control_port > 0)
retro_control_start(control_port, &backend); /* binds 127.0.0.1 */
4. Wire three loop hooks
for (;;) {
retro_control_service(); /* handle mem/regs/screenshot/perf */
if (!retro_control_running()) { /* paused with no steps left */
sleep_1ms();
continue; /* don't advance the machine */
}
... run one instruction / step peripherals ...
if (frame_completed)
retro_control_on_frame(); /* drives /step + frame count */
}
The same loop as a picture, with a POST /step?frames=2 going through it:
flowchart TB
top(["loop iteration"]) --> svc["retro_control_service()<br/>run any queued requests here,<br/>on this thread"]
svc --> run{"retro_control_running()?"}
run -- "no: paused,<br/>no steps left" --> nap["sleep 1 ms"] --> top
run -- "yes" --> cpu["run the machine:<br/>an instruction, the peripherals"]
cpu --> fr{"a video frame<br/>completed?"}
fr -- "no" --> top
fr -- "yes" --> onf["retro_control_on_frame()<br/>count the frame; one /step frame spent"]
onf --> done{"the /step budget<br/>reached zero?"}
done -- "no" --> top
done -- "yes" --> halt["halt, and answer the /step"] --> top
retro_control_service()executes pending requests (reads, writes, input,/perf) on your thread, so they're consistent. Cheap no-op when idle or when control is off.retro_control_running()returns 1 when control is off or the machine is free-running; 0 when paused with no steps remaining.retro_control_on_frame()decrements the/stepbudget and halts + answers when it reaches zero. Call it once per completed video frame.
Stepping in a multi-threaded emulator
The vendor-path hooks assume a single loop thread. If your emulator runs the CPU and the video on separate threads (e.g. the FAB Agon emulator: eZ80 on one thread, the VDP as a separate module), implement the same budget model by hand. Here's what the shared core does, stated thread-agnostically so you can map it:
The model (retro_control.c):
- A single monotonic frame counter, incremented once per completed frame.
- A run budget:
-1= free-run,0= halted,N>0= run N frames then halt. - The CPU only advances while the budget is non-zero (the gate).
- On each completed frame, decrement the budget if it's
> 0; when it reaches0, halt and wake the pending/stepresponder with the new frame count.
Mapping to two threads:
- Increment the frame counter and decrement the budget on the thread that
completes a frame (your render/VDP thread — where you already publish the
screenshot snapshot). That thread's counter is what
/statusreads. - The gate is your existing pause primitive on the CPU thread (FAB:
DebugCmd::Pause/Continue/Step)./stepsets the budget and resumes the CPU; when the budget hits0, the frame-completion handler issues Pause. /stepblocks until the halt is in effect, then returns the frame count, so a following/memor/screenshotobserves the exact N-frames state.
The one invariant to get right (and it's the determinism test): when the
budget hits 0 at the completion of frame N, the CPU must halt before it
can cause frame N+1. Don't reason about the race — measure it. step(3) must
advance the counter by exactly 3, twice in a row, which is exactly what
conformance/'s test_step_is_deterministic asserts. Run it against your build
and trust the green.
Gotchas
- Keep video running. The framebuffer and frame counter must keep advancing
for
/screenshotand/step. Don't route the control API through a "headless / no-video" mode that skips rendering — see Per-Platform Notes for the x16SDL_VIDEODRIVER=dummytrick that keeps video alive without a real display. - One request at a time. The server uses a single accept thread; that's intentional and keeps marshalling simple.
- Threads. The core needs pthreads (POSIX). On Windows the entry points are currently no-ops.