OXYGENHUB

HTTP control contract, portable server core, and shared client 961b3c610c66

Cross-platform emulator debug/control API for the Doomsday One retro ports.

- SPEC.md: contract v0.1.0 — /status /screenshot /mem /regs /step, localhost-only
- core/: portable C server (retro_control.c/.h) — read-only backend vtable,
  emulator-thread marshalling, PPM/JSON, deterministic frame stepping
- client/: emu_control.py shared pytest client
- x16emu is the reference implementation

Git-Commit: 7022c94e39c9bd1595ba7e0858b1994e976b3c84
Git-Author: Andy McCall <[email protected]>
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-07-19 18:17 UTC
Signature
◈ sealed by Andy McCall
Hash
961b3c610c66fcdfa5dbc05198d7be5e99f87d35b15d2107e243c879a5a7e129
Depends on
nothing: a root change
Depended on by
8fd33bc520f9 3bf01e6e95ae 398756c0e9b2 bed0f1e524fe 45a475bb3fa8 79333fa1febe 346ca3ee83f4 4c0b050e9a25 67065eaa1a13 3de55cb796cd c17f852c2a5a 7c83b415d7cc 674b8447022b e60badb602fb 69f777699a51 689a9ccb0138 d44319b1e712 42c217d9aaf7 8ac19619e24b
In bookmarks
main perf-6
Edits
7 atoms, 9 tree operations, 7 files

Browse the tree at this change

added .gitignore +15 −0
1 +
# Python
2 +
__pycache__/
3 +
*.pyc
4 +
.venv/
5 +
.pytest_cache/
6 +
7 +
# C build artifacts
8 +
*.o
9 +
*.a
10 +
*.so
11 +
build/
12 +
13 +
# editor/OS
14 +
*.swp
15 +
.DS_Store
added LICENSE +21 −0
1 +
MIT License
2 +
3 +
Copyright (c) 2026 Andy McCall
4 +
5 +
Permission is hereby granted, free of charge, to any person obtaining a copy
6 +
of this software and associated documentation files (the "Software"), to deal
7 +
in the Software without restriction, including without limitation the rights
8 +
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 +
copies of the Software, and to permit persons to whom the Software is
10 +
furnished to do so, subject to the following conditions:
11 +
12 +
The above copyright notice and this permission notice shall be included in all
13 +
copies or substantial portions of the Software.
14 +
15 +
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 +
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 +
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 +
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 +
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 +
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 +
SOFTWARE.
added README.md +74 −0
1 +
# retro-remote-debug-controller
2 +
3 +
A tiny, portable **HTTP control API for retro-platform emulators**, and the
4 +
shared tooling that speaks it. One contract → one test harness across every
5 +
Doomsday One port.
6 +
7 +
An emulator launched with a control-port flag exposes a localhost HTTP server:
8 +
9 +
| Endpoint | Returns |
10 +
|---|---|
11 +
| `GET /status` | contract version, platform, frame counter, paused state |
12 +
| `GET /screenshot` | PPM image of the live screen |
13 +
| `GET /mem?addr=&len=&bank=` | raw memory bytes |
14 +
| `GET /regs` | CPU registers (JSON) |
15 +
| `POST /step?frames=` | advance N frames deterministically |
16 +
| `POST /key`, `/reset` | *(0.2)* inject input |
17 +
18 +
## Why
19 +
20 +
Every one of these ports (Pac-Man on X16, Neo6502, Agon; and whatever's next) is
21 +
chasing **frame-exact** arcade fidelity, and every one had its own fragile,
22 +
platform-specific way to check state headless (write to the SD card, dump 64 KB
23 +
of RAM on a magic jump, grep stdout — which lies). This replaces all of that
24 +
with one contract:
25 +
26 +
- **`GET /mem`** → assert game state (sprite table, actor positions).
27 +
- **`GET /screenshot`** → Pillow → assert pixels, or just catch "nothing
28 +
  rendered" (the exact class of bug that costs an afternoon otherwise).
29 +
- **`POST /step`** → deterministic, frame-addressable capture, so CI diffs don't
30 +
  flake against a free-running 60 Hz loop.
31 +
32 +
The payoff is uniformity: the same `emu_control.py` and the same pytest patterns
33 +
drive the X16, Neo6502, and Agon emulators.
34 +
35 +
## Layout
36 +
37 +
```
38 +
SPEC.md                 the normative HTTP contract (start here)
39 +
core/retro_control.h    portable backend vtable each emulator implements
40 +
core/retro_control.c    shared server: sockets, HTTP, PPM/JSON, marshalling  [next]
41 +
client/emu_control.py   shared pytest client
42 +
conformance/            tests an emulator runs to prove it conforms          [next]
43 +
```
44 +
45 +
## Adopting it in an emulator (the shape)
46 +
47 +
Write the server once, adapt per fork. Each fork vendors `core/` and implements
48 +
~5 callbacks, then calls the server:
49 +
50 +
```c
51 +
#include "retro_control.h"
52 +
53 +
static retro_control_backend_t backend = {
54 +
    .platform       = "x16",
55 +
    .read_mem       = x16_read_mem,      /* debug-read, no side effects */
56 +
    .get_regs_json  = x16_get_regs_json,
57 +
    .get_framebuffer= x16_get_framebuffer,
58 +
    .step_frames    = x16_step_frames,
59 +
    .get_frame_count= x16_get_frame_count,
60 +
};
61 +
62 +
if (control_port)              /* set by -controlport / --control-port */
63 +
    retro_control_start(control_port, &backend);
64 +
```
65 +
66 +
The socket loop, HTTP parsing, PPM encoding, JSON framing, and the
67 +
enqueue-to-emulator-thread marshalling live in the shared core — not in three
68 +
different codebases.
69 +
70 +
## Status
71 +
72 +
Early. `SPEC.md` (0.1.0) and the interfaces below are the stable target the
73 +
forks build against; the server `.c` and conformance suite are landing next.
74 +
Reference implementation: the Commander X16 fork (`x16-emulator`).
added SPEC.md +162 −0
1 +
# Retro Remote Debug Controller — HTTP Control Contract
2 +
3 +
**Contract version: 0.1.0** (semver; clients assert on the MAJOR).
4 +
5 +
A minimal, portable HTTP API that a retro-platform emulator exposes so an
6 +
external harness can screenshot the screen, read memory and registers, step the
7 +
machine deterministically, and (later) inject input. The same contract is
8 +
implemented by the forked FAB Agon Emulator, the Neo6502 emulator, and the
9 +
Commander X16 emulator, so **one** pytest harness drives all three ports.
10 +
11 +
## Principles
12 +
13 +
- **Opt-in.** The server is OFF by default and starts only when the emulator is
14 +
  launched with a control-port flag. Zero cost to normal use.
15 +
- **Localhost only.** The server MUST bind `127.0.0.1` (never `0.0.0.0`).
16 +
  `/mem` and input injection are a memory-read / remote-control surface; they do
17 +
  not leave the machine.
18 +
- **The contract is the HTTP surface, not the flag.** Each emulator names its
19 +
  launch flag idiomatically (x16emu: `-controlport N`; FAB: `--control-port N`).
20 +
  Only the endpoints below are normative.
21 +
- **Determinism is a first-class requirement.** These are frame-exact ports.
22 +
  Every state read (`/mem`, `/regs`, `/screenshot`) MUST observe a consistent
23 +
  machine state — captured at an instruction/frame boundary on the emulator
24 +
  thread, never a torn mid-instruction read from the HTTP thread. See
25 +
  *Consistency* below.
26 +
27 +
## Conventions
28 +
29 +
- JSON responses are `application/json; charset=utf-8`.
30 +
- Binary responses use the stated content-type; no chunked encoding required.
31 +
- Errors: HTTP 4xx/5xx with body `{"error":"<message>"}`.
32 +
- Numbers in JSON are decimal unless a field is documented as a hex string.
33 +
- Methods are exactly as listed (`GET`/`POST`). Unknown path → 404. Unknown
34 +
  method on a known path → 405.
35 +
36 +
## Endpoints
37 +
38 +
### `GET /status`
39 +
Liveness + contract negotiation. Always available.
40 +
```json
41 +
{
42 +
  "contract": "0.1.0",
43 +
  "emulator": "x16emu",
44 +
  "platform": "x16",
45 +
  "frame": 12345,
46 +
  "paused": true,
47 +
  "running": true
48 +
}
49 +
```
50 +
- `contract` — this document's version. Clients MUST reject a mismatched MAJOR.
51 +
- `platform` — one of `x16`, `neo6502`, `agon` (extensible). Tells the client
52 +
  the memory/register model (see *Per-platform* appendix).
53 +
- `frame` — monotonic completed-frame counter since boot. The determinism
54 +
  anchor: a screenshot taken at `frame == N` shows the state after N complete
55 +
  frames.
56 +
- `paused` — true if the machine is halted (only advances via `/step`).
57 +
58 +
### `GET /screenshot`
59 +
The live screen as a Portable Pixmap.
60 +
- Response: `image/x-portable-pixmap`, binary **PPM P6**, 8-bit RGB, no alpha.
61 +
- Pixels are the most recent COMPLETE frame (never a half-scanned frame). The
62 +
  emulator keeps a snapshot the render thread fills at frame end; the HTTP
63 +
  thread serves that snapshot.
64 +
- Dimensions are the platform's native framebuffer (appendix). Clients read
65 +
  `width`/`height` from the PPM header — do not hardcode.
66 +
- Reserved: `?frame=N` (block until frame N) — not required in 0.1.
67 +
68 +
### `GET /mem?addr=<a>&len=<n>[&bank=<b>]`
69 +
Raw memory bytes.
70 +
- `addr` — start address, decimal or `0x`-hex. Interpreted in the platform's
71 +
  address space (appendix).
72 +
- `len` — byte count, decimal. Servers MAY cap `len` (recommend ≥ 65536); a
73 +
  capped request returns what it can with `Content-Length` telling the truth.
74 +
- `bank` — OPTIONAL bank selector for banked platforms (X16). Omitted → the
75 +
  machine's currently-selected bank. Ignored by flat-memory platforms.
76 +
- Response: `application/octet-stream`, exactly the requested bytes.
77 +
- Reads go through the emulator's debug-read path (no side effects, no I/O
78 +
  triggers, no access-flag mutation).
79 +
80 +
### `GET /regs`
81 +
CPU registers as JSON. Keys are platform-specific (appendix); values are decimal
82 +
unless suffixed `_hex`. Example (x16 / 65C02):
83 +
```json
84 +
{ "a":0, "x":16, "y":2, "sp":505, "pc":49152, "status":32,
85 +
  "ram_bank":1, "rom_bank":0 }
86 +
```
87 +
88 +
### `POST /step?frames=<n>`  *(recommended; required for frame-exact tests)*
89 +
Advance the machine by exactly `n` complete frames (default 1), then halt.
90 +
- Only meaningful when the machine is paused (see `/pause`). On a free-running
91 +
  machine, servers SHOULD pause, run `n` frames, and stay paused.
92 +
- Response: `{ "frame": <new frame counter> }`.
93 +
- `POST /step?cycles=<n>` MAY be offered for sub-frame stepping.
94 +
95 +
### `POST /pause` / `POST /resume`  *(recommended)*
96 +
Halt / free-run. `{ "paused": true|false }`. A machine launched with a
97 +
`--control-pause`-style option SHOULD boot paused so tests are deterministic
98 +
from frame 0.
99 +
100 +
### `POST /key` / `POST /reset`  *(reserved for 0.2)*
101 +
Input injection. Proposed shape — `POST /key` body
102 +
`{ "code": <keycode>, "down": true|false }` or `{ "text": "..." }`; `/reset`
103 +
performs a cold reset. Not required in 0.1, but reserve the paths. Injection
104 +
plus `/step` is what retires scripted-autoexec input.
105 +
106 +
## Consistency (normative)
107 +
108 +
- `/mem`, `/regs`, and `/step` MUST be serviced on the emulator thread at an
109 +
  instruction boundary — e.g. the HTTP handler enqueues a command that the main
110 +
  loop drains between instructions/frames and signals back. The HTTP thread MUST
111 +
  NOT read CPU/RAM state directly while the CPU is executing.
112 +
- `/screenshot` MUST serve a complete-frame snapshot (double-buffer: render
113 +
  thread writes the back buffer, swaps under a lock at frame end; HTTP thread
114 +
  reads the front buffer).
115 +
- `frame` in `/status` MUST increment once per completed video frame and never
116 +
  go backwards (except across `/reset`).
117 +
118 +
## Versioning
119 +
120 +
- Semantic. MAJOR bump = breaking change to an existing endpoint's
121 +
  request/response. MINOR = additive (new endpoint / optional field). PATCH =
122 +
  clarification.
123 +
- Clients read `/status.contract` and fail fast on a MAJOR mismatch.
124 +
125 +
---
126 +
127 +
## Per-platform appendix
128 +
129 +
### Commander X16 — `x16emu`
130 +
- Flag: `-controlport <N>` (single-dash, matching x16emu's `-dump` style).
131 +
- Framebuffer: **640×480**, native order BGRA8888 (`framebuffer[i*4+2]=R`,
132 +
  `+1=G`, `+0=B`); the server swizzles to PPM RGB.
133 +
- Address space: 16-bit CPU (`0x0000–0xFFFF`). The banked window `0xA000–0xBFFF`
134 +
  is selected by `bank` → the emulator's `read6502(addr, bank)` / banked RAM
135 +
  (`BRAM`). `addr < 0xA000` or `≥ 0xC000` ignores `bank`.
136 +
- Registers: `a x y` (8), `sp pc` (16), `status` (8), plus `ram_bank`,
137 +
  `rom_bank`. Source: the `regs` struct + `memory_get_ram_bank/rom_bank()`.
138 +
- Frame source: `video.c` `frame_count`.
139 +
140 +
### Neo6502 — neo6502 emulator
141 +
- Flag: `--control-port <N>`.
142 +
- Address space: flat 64 KB; `bank` ignored.
143 +
- Registers: 6502 `a x y sp pc status`.
144 +
- Framebuffer: native resolution per the emulator's video mode.
145 +
146 +
### Agon (Console8 / Light 2) — FAB Agon Emulator
147 +
- Flag: `--control-port <N>`.
148 +
- Address space: eZ80 24-bit, flat 16 MB (`addr` up to `0xFFFFFF`); `bank`
149 +
  ignored. Program load base `0x040000`.
150 +
- Registers (ADL): `af bc de hl ix iy sp pc mbase` + ADL-mode flag.
151 +
- Framebuffer: from `vgabuf` (the VDP output), server swizzles to PPM RGB.
152 +
153 +
---
154 +
155 +
## Reference implementations in this repo
156 +
157 +
- `core/retro_control.h` — the portable backend vtable each fork implements.
158 +
- `core/retro_control.c` — the shared server (socket loop, HTTP parse, PPM/JSON
159 +
  encode, command marshalling). Forks vendor/submodule this and call
160 +
  `retro_control_start(port, &backend)`. *(server .c lands next.)*
161 +
- `client/emu_control.py` — the shared pytest client.
162 +
- `conformance/` — tests an emulator runs to prove it conforms. *(next.)*
added client/emu_control.py +115 −0
1 +
"""emu_control.py — shared client for the Retro Remote Debug Controller.
2 +
3 +
One client for every port's pytest suite. Speaks the HTTP contract in
4 +
../SPEC.md (0.1.x). Depends only on the stdlib; Pillow is optional and only
5 +
needed for .screenshot().
6 +
7 +
    from emu_control import EmuControl
8 +
9 +
    emu = EmuControl(port=8080)      # emulator started with -controlport 8080
10 +
    emu.wait_ready()
11 +
    emu.step(frames=120)
12 +
    assert emu.regs()["a"] == 0
13 +
    data = emu.mem(0x0400, 256, bank=1)
14 +
    img = emu.screenshot()           # PIL.Image (requires Pillow)
15 +
"""
16 +
17 +
from __future__ import annotations
18 +
19 +
import io
20 +
import json
21 +
import time
22 +
import urllib.error
23 +
import urllib.request
24 +
25 +
CONTRACT_MAJOR = 0
26 +
27 +
28 +
class EmuControlError(RuntimeError):
29 +
    pass
30 +
31 +
32 +
class EmuControl:
33 +
    def __init__(self, port: int, host: str = "127.0.0.1", timeout: float = 5.0):
34 +
        self.base = f"http://{host}:{port}"
35 +
        self.timeout = timeout
36 +
37 +
    # -- transport ---------------------------------------------------------
38 +
    def _get(self, path: str) -> tuple[bytes, str]:
39 +
        try:
40 +
            with urllib.request.urlopen(self.base + path, timeout=self.timeout) as r:
41 +
                return r.read(), r.headers.get_content_type()
42 +
        except urllib.error.HTTPError as e:
43 +
            raise EmuControlError(f"GET {path} -> {e.code}: {e.read()!r}") from e
44 +
45 +
    def _post(self, path: str) -> bytes:
46 +
        req = urllib.request.Request(self.base + path, method="POST", data=b"")
47 +
        try:
48 +
            with urllib.request.urlopen(req, timeout=self.timeout) as r:
49 +
                return r.read()
50 +
        except urllib.error.HTTPError as e:
51 +
            raise EmuControlError(f"POST {path} -> {e.code}: {e.read()!r}") from e
52 +
53 +
    # -- endpoints ---------------------------------------------------------
54 +
    def status(self) -> dict:
55 +
        body, _ = self._get("/status")
56 +
        st = json.loads(body)
57 +
        major = int(str(st.get("contract", "0")).split(".")[0])
58 +
        if major != CONTRACT_MAJOR:
59 +
            raise EmuControlError(
60 +
                f"contract major mismatch: emulator {st.get('contract')} "
61 +
                f"vs client {CONTRACT_MAJOR}.x"
62 +
            )
63 +
        return st
64 +
65 +
    def frame(self) -> int:
66 +
        return int(self.status()["frame"])
67 +
68 +
    def regs(self) -> dict:
69 +
        body, _ = self._get("/regs")
70 +
        return json.loads(body)
71 +
72 +
    def mem(self, addr: int, length: int, bank: int | None = None) -> bytes:
73 +
        path = f"/mem?addr={addr}&len={length}"
74 +
        if bank is not None:
75 +
            path += f"&bank={bank}"
76 +
        body, _ = self._get(path)
77 +
        return body
78 +
79 +
    def step(self, frames: int = 1) -> int:
80 +
        body = self._post(f"/step?frames={frames}")
81 +
        return int(json.loads(body)["frame"])
82 +
83 +
    def pause(self) -> None:
84 +
        self._post("/pause")
85 +
86 +
    def resume(self) -> None:
87 +
        self._post("/resume")
88 +
89 +
    def screenshot(self):
90 +
        """Return the live screen as a PIL.Image (requires Pillow)."""
91 +
        body, ctype = self._get("/screenshot")
92 +
        try:
93 +
            from PIL import Image
94 +
        except ImportError as e:  # pragma: no cover
95 +
            raise EmuControlError("screenshot() needs Pillow (pip install pillow)") from e
96 +
        return Image.open(io.BytesIO(body))
97 +
98 +
    # -- helpers -----------------------------------------------------------
99 +
    def wait_ready(self, tries: int = 100, delay: float = 0.05) -> dict:
100 +
        """Poll /status until the server answers (emulator boot race)."""
101 +
        last = None
102 +
        for _ in range(tries):
103 +
            try:
104 +
                return self.status()
105 +
            except (EmuControlError, urllib.error.URLError, ConnectionError) as e:
106 +
                last = e
107 +
                time.sleep(delay)
108 +
        raise EmuControlError(f"emulator control port never came up: {last}")
109 +
110 +
    def run_to_frame(self, target: int) -> int:
111 +
        """Step until the frame counter reaches >= target."""
112 +
        cur = self.frame()
113 +
        if target > cur:
114 +
            self.step(frames=target - cur)
115 +
        return self.frame()
added core/retro_control.c +362 −0
1 +
/* retro_control.c — portable emulator control server. See retro_control.h
2 +
 * and ../SPEC.md. POSIX (Linux/macOS); on Windows the entry points are no-ops.
3 +
 *
4 +
 * One accept thread, one request in flight at a time. State reads are marshalled
5 +
 * to the emulator thread via retro_control_service() so they observe a
6 +
 * consistent machine; /step is driven cooperatively by retro_control_on_frame().
7 +
 */
8 +
#include "retro_control.h"
9 +
10 +
#include <stdio.h>
11 +
#include <stdlib.h>
12 +
#include <string.h>
13 +
14 +
#if defined(_WIN32)
15 +
/* No server on Windows for now — stub out so the fork still builds. */
16 +
int  retro_control_start(int port, const retro_control_backend_t *b) { (void)port; (void)b; return -1; }
17 +
void retro_control_service(void) {}
18 +
int  retro_control_running(void) { return 1; }
19 +
void retro_control_on_frame(void) {}
20 +
void retro_control_stop(void) {}
21 +
#else
22 +
23 +
#include <pthread.h>
24 +
#include <unistd.h>
25 +
#include <errno.h>
26 +
#include <signal.h>
27 +
#include <sys/socket.h>
28 +
#include <netinet/in.h>
29 +
#include <arpa/inet.h>
30 +
31 +
#ifndef MSG_NOSIGNAL
32 +
#define MSG_NOSIGNAL 0
33 +
#endif
34 +
35 +
#define MEM_CAP (1u << 20)   /* /mem hard cap: 1 MiB per request */
36 +
37 +
static const retro_control_backend_t *g_be = NULL;
38 +
static int       g_listen_fd = -1;
39 +
static pthread_t g_thread;
40 +
41 +
static pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER;
42 +
static pthread_cond_t  g_cv   = PTHREAD_COND_INITIALIZER;
43 +
44 +
/* run control: -1 free-run, 0 halted, >0 run this many frames then halt */
45 +
static long g_frames_to_run = -1;
46 +
static int  g_step_waiting  = 0;
47 +
48 +
/* single-slot marshalled request (mem / regs / screenshot) */
49 +
typedef enum { REQ_NONE = 0, REQ_MEM, REQ_REGS, REQ_SHOT } req_t;
50 +
static volatile req_t g_req = REQ_NONE;
51 +
static uint32_t g_req_addr, g_req_len;
52 +
static int32_t  g_req_bank;
53 +
54 +
static uint8_t    *g_resp_body  = NULL;
55 +
static size_t      g_resp_len   = 0;
56 +
static int         g_resp_status = 200;
57 +
static const char *g_resp_ctype = "application/octet-stream";
58 +
static int         g_resp_ready = 0;
59 +
60 +
/* ---- emulator-thread side ------------------------------------------------ */
61 +
62 +
static void build_ppm(void)
63 +
{
64 +
    retro_framebuffer_t fb;
65 +
    memset(&fb, 0, sizeof fb);
66 +
    g_be->get_framebuffer(&fb);
67 +
    int w = fb.width, h = fb.height;
68 +
    int bpp = (fb.fmt == RETRO_PIX_RGB888) ? 3 : 4;
69 +
70 +
    char hdr[64];
71 +
    int hl = snprintf(hdr, sizeof hdr, "P6\n%d %d\n255\n", w, h);
72 +
    size_t total = (size_t)hl + (size_t)3 * w * h;
73 +
    uint8_t *out = (uint8_t *)malloc(total ? total : 1);
74 +
    if (!out) { g_resp_body = NULL; g_resp_len = 0; g_resp_status = 500; return; }
75 +
76 +
    memcpy(out, hdr, hl);
77 +
    uint8_t *p = out + hl;
78 +
    const uint8_t *s = fb.pixels;
79 +
    for (int i = 0; i < w * h; i++) {
80 +
        const uint8_t *px = s + (size_t)i * bpp;
81 +
        if (fb.fmt == RETRO_PIX_BGRA8888) {
82 +
            p[0] = px[2]; p[1] = px[1]; p[2] = px[0];
83 +
        } else {                       /* RGB888 / RGBA8888 */
84 +
            p[0] = px[0]; p[1] = px[1]; p[2] = px[2];
85 +
        }
86 +
        p += 3;
87 +
    }
88 +
    g_resp_body = out;
89 +
    g_resp_len = total;
90 +
    g_resp_status = 200;
91 +
    g_resp_ctype = "image/x-portable-pixmap";
92 +
}
93 +
94 +
void retro_control_service(void)
95 +
{
96 +
    if (!g_be || g_req == REQ_NONE) return;   /* cheap unlocked fast-path */
97 +
98 +
    pthread_mutex_lock(&g_lock);
99 +
    switch (g_req) {
100 +
    case REQ_MEM: {
101 +
        uint32_t len = g_req_len > MEM_CAP ? MEM_CAP : g_req_len;
102 +
        uint8_t *buf = (uint8_t *)malloc(len ? len : 1);
103 +
        uint32_t got = 0;
104 +
        if (buf && g_be->read_mem) got = g_be->read_mem(g_req_addr, g_req_bank, len, buf, len);
105 +
        g_resp_body = buf; g_resp_len = got;
106 +
        g_resp_status = g_be->read_mem ? 200 : 501;
107 +
        g_resp_ctype = "application/octet-stream";
108 +
        break;
109 +
    }
110 +
    case REQ_REGS: {
111 +
        char *buf = (char *)malloc(1024);
112 +
        if (buf && g_be->get_regs_json) { g_be->get_regs_json(buf, 1024); }
113 +
        else if (buf) { buf[0] = 0; }
114 +
        g_resp_body = (uint8_t *)buf; g_resp_len = buf ? strlen(buf) : 0;
115 +
        g_resp_status = g_be->get_regs_json ? 200 : 501;
116 +
        g_resp_ctype = "application/json";
117 +
        break;
118 +
    }
119 +
    case REQ_SHOT:
120 +
        if (g_be->get_framebuffer) build_ppm();
121 +
        else { g_resp_body = NULL; g_resp_len = 0; g_resp_status = 501; }
122 +
        break;
123 +
    default: break;
124 +
    }
125 +
    g_req = REQ_NONE;
126 +
    g_resp_ready = 1;
127 +
    pthread_cond_broadcast(&g_cv);
128 +
    pthread_mutex_unlock(&g_lock);
129 +
}
130 +
131 +
int retro_control_running(void)
132 +
{
133 +
    if (!g_be) return 1;
134 +
    pthread_mutex_lock(&g_lock);
135 +
    long f = g_frames_to_run;
136 +
    pthread_mutex_unlock(&g_lock);
137 +
    return f != 0;
138 +
}
139 +
140 +
void retro_control_on_frame(void)
141 +
{
142 +
    if (!g_be) return;
143 +
    pthread_mutex_lock(&g_lock);
144 +
    if (g_frames_to_run > 0) {
145 +
        g_frames_to_run--;
146 +
        if (g_frames_to_run == 0 && g_step_waiting) {
147 +
            g_step_waiting = 0;
148 +
            g_resp_ready = 1;
149 +
            pthread_cond_broadcast(&g_cv);
150 +
        }
151 +
    }
152 +
    pthread_mutex_unlock(&g_lock);
153 +
}
154 +
155 +
/* ---- server-thread side -------------------------------------------------- */
156 +
157 +
static void write_all(int fd, const void *buf, size_t len)
158 +
{
159 +
    const char *p = (const char *)buf;
160 +
    while (len) {
161 +
        ssize_t w = send(fd, p, len, MSG_NOSIGNAL);
162 +
        if (w <= 0) return;
163 +
        p += w; len -= (size_t)w;
164 +
    }
165 +
}
166 +
167 +
static void send_http(int fd, int status, const char *ctype,
168 +
                      const uint8_t *body, size_t len)
169 +
{
170 +
    const char *reason =
171 +
        status == 200 ? "OK" :
172 +
        status == 400 ? "Bad Request" :
173 +
        status == 404 ? "Not Found" :
174 +
        status == 405 ? "Method Not Allowed" :
175 +
        status == 501 ? "Not Implemented" : "Error";
176 +
    char hdr[256];
177 +
    int hl = snprintf(hdr, sizeof hdr,
178 +
        "HTTP/1.1 %d %s\r\nContent-Type: %s\r\nContent-Length: %zu\r\n"
179 +
        "Connection: close\r\nAccess-Control-Allow-Origin: *\r\n\r\n",
180 +
        status, reason, ctype, len);
181 +
    write_all(fd, hdr, hl);
182 +
    if (body && len) write_all(fd, body, len);
183 +
}
184 +
185 +
static void send_json(int fd, int status, const char *json)
186 +
{
187 +
    send_http(fd, status, "application/json", (const uint8_t *)json, strlen(json));
188 +
}
189 +
190 +
/* value of query key, decimal or 0x-hex (strtol base 0); def if absent. */
191 +
static long query_long(const char *q, const char *key, long def)
192 +
{
193 +
    size_t kl = strlen(key);
194 +
    const char *p = q;
195 +
    while (p && *p) {
196 +
        if (!strncmp(p, key, kl) && p[kl] == '=')
197 +
            return strtol(p + kl + 1, NULL, 0);
198 +
        p = strchr(p, '&');
199 +
        if (p) p++;
200 +
    }
201 +
    return def;
202 +
}
203 +
204 +
/* marshal a read to the emulator thread, wait, send the response. */
205 +
static void do_marshalled(int fd, req_t r)
206 +
{
207 +
    pthread_mutex_lock(&g_lock);
208 +
    g_resp_ready = 0; g_resp_body = NULL; g_resp_len = 0;
209 +
    g_req = r;
210 +
    while (!g_resp_ready) pthread_cond_wait(&g_cv, &g_lock);
211 +
    uint8_t *body = g_resp_body; size_t len = g_resp_len;
212 +
    int st = g_resp_status; const char *ct = g_resp_ctype;
213 +
    g_resp_body = NULL;
214 +
    pthread_mutex_unlock(&g_lock);
215 +
216 +
    send_http(fd, st, ct, body, len);
217 +
    free(body);
218 +
}
219 +
220 +
static void do_status(int fd)
221 +
{
222 +
    pthread_mutex_lock(&g_lock);
223 +
    long f = g_frames_to_run;
224 +
    pthread_mutex_unlock(&g_lock);
225 +
    unsigned long long fc = g_be->get_frame_count ? g_be->get_frame_count() : 0;
226 +
    char buf[320];
227 +
    snprintf(buf, sizeof buf,
228 +
        "{\"contract\":\"%s\",\"emulator\":\"%s\",\"platform\":\"%s\","
229 +
        "\"frame\":%llu,\"paused\":%s,\"running\":true}",
230 +
        RETRO_CONTROL_CONTRACT,
231 +
        g_be->emulator ? g_be->emulator : "",
232 +
        g_be->platform ? g_be->platform : "",
233 +
        fc, (f == 0) ? "true" : "false");
234 +
    send_json(fd, 200, buf);
235 +
}
236 +
237 +
static void do_step(int fd, const char *query)
238 +
{
239 +
    long n = query_long(query, "frames", 1);
240 +
    if (n < 1) n = 1;
241 +
    pthread_mutex_lock(&g_lock);
242 +
    g_frames_to_run = n;
243 +
    g_step_waiting = 1;
244 +
    g_resp_ready = 0;
245 +
    while (!g_resp_ready) pthread_cond_wait(&g_cv, &g_lock);
246 +
    pthread_mutex_unlock(&g_lock);
247 +
248 +
    unsigned long long fc = g_be->get_frame_count ? g_be->get_frame_count() : 0;
249 +
    char buf[64];
250 +
    snprintf(buf, sizeof buf, "{\"frame\":%llu}", fc);
251 +
    send_json(fd, 200, buf);
252 +
}
253 +
254 +
static void do_setrun(int fd, long v, int paused)
255 +
{
256 +
    pthread_mutex_lock(&g_lock);
257 +
    g_frames_to_run = v;
258 +
    pthread_mutex_unlock(&g_lock);
259 +
    send_json(fd, 200, paused ? "{\"paused\":true}" : "{\"paused\":false}");
260 +
}
261 +
262 +
static void handle_conn(int fd)
263 +
{
264 +
    char req[2048];
265 +
    size_t n = 0;
266 +
    while (n < sizeof(req) - 1) {
267 +
        ssize_t r = recv(fd, req + n, sizeof(req) - 1 - n, 0);
268 +
        if (r <= 0) break;
269 +
        n += (size_t)r;
270 +
        if (strstr(req, "\r\n\r\n")) break;
271 +
    }
272 +
    req[n] = 0;
273 +
274 +
    char method[8] = {0}, target[1024] = {0};
275 +
    if (sscanf(req, "%7s %1023s", method, target) != 2) {
276 +
        send_json(fd, 400, "{\"error\":\"bad request\"}");
277 +
        return;
278 +
    }
279 +
    char *q = strchr(target, '?');
280 +
    const char *query = "";
281 +
    if (q) { *q = 0; query = q + 1; }
282 +
    int is_post = !strcmp(method, "POST");
283 +
284 +
    if (!strcmp(target, "/status"))          { do_status(fd); return; }
285 +
    if (!strcmp(target, "/regs"))            { do_marshalled(fd, REQ_REGS); return; }
286 +
    if (!strcmp(target, "/screenshot"))      { do_marshalled(fd, REQ_SHOT); return; }
287 +
    if (!strcmp(target, "/mem")) {
288 +
        g_req_addr = (uint32_t)query_long(query, "addr", 0);
289 +
        g_req_len  = (uint32_t)query_long(query, "len", 0);
290 +
        g_req_bank = (int32_t)query_long(query, "bank", -1);
291 +
        do_marshalled(fd, REQ_MEM);
292 +
        return;
293 +
    }
294 +
    if (!strcmp(target, "/step")) {
295 +
        if (!is_post) { send_json(fd, 405, "{\"error\":\"POST only\"}"); return; }
296 +
        do_step(fd, query); return;
297 +
    }
298 +
    if (!strcmp(target, "/pause")) {
299 +
        if (!is_post) { send_json(fd, 405, "{\"error\":\"POST only\"}"); return; }
300 +
        do_setrun(fd, 0, 1); return;
301 +
    }
302 +
    if (!strcmp(target, "/resume")) {
303 +
        if (!is_post) { send_json(fd, 405, "{\"error\":\"POST only\"}"); return; }
304 +
        do_setrun(fd, -1, 0); return;
305 +
    }
306 +
    send_json(fd, 404, "{\"error\":\"not found\"}");
307 +
}
308 +
309 +
static void *server_main(void *arg)
310 +
{
311 +
    (void)arg;
312 +
    for (;;) {
313 +
        int fd = accept(g_listen_fd, NULL, NULL);
314 +
        if (fd < 0) {
315 +
            if (errno == EINTR) continue;
316 +
            break;
317 +
        }
318 +
        handle_conn(fd);
319 +
        close(fd);
320 +
    }
321 +
    return NULL;
322 +
}
323 +
324 +
int retro_control_start(int port, const retro_control_backend_t *backend)
325 +
{
326 +
    g_be = backend;
327 +
    signal(SIGPIPE, SIG_IGN);
328 +
329 +
    int fd = socket(AF_INET, SOCK_STREAM, 0);
330 +
    if (fd < 0) return -1;
331 +
    int one = 1;
332 +
    setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one);
333 +
334 +
    struct sockaddr_in addr;
335 +
    memset(&addr, 0, sizeof addr);
336 +
    addr.sin_family = AF_INET;
337 +
    addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);   /* 127.0.0.1 only */
338 +
    addr.sin_port = htons((uint16_t)port);
339 +
    if (bind(fd, (struct sockaddr *)&addr, sizeof addr) < 0) {
340 +
        fprintf(stderr, "[retro_control] bind 127.0.0.1:%d failed: %s\n",
341 +
                port, strerror(errno));
342 +
        close(fd);
343 +
        return -1;
344 +
    }
345 +
    if (listen(fd, 4) < 0) { close(fd); return -1; }
346 +
347 +
    g_listen_fd = fd;
348 +
    if (pthread_create(&g_thread, NULL, server_main, NULL) != 0) {
349 +
        close(fd); g_listen_fd = -1; return -1;
350 +
    }
351 +
    pthread_detach(g_thread);
352 +
    fprintf(stderr, "[retro_control] listening on 127.0.0.1:%d (contract %s)\n",
353 +
            port, RETRO_CONTROL_CONTRACT);
354 +
    return 0;
355 +
}
356 +
357 +
void retro_control_stop(void)
358 +
{
359 +
    if (g_listen_fd >= 0) { close(g_listen_fd); g_listen_fd = -1; }
360 +
}
361 +
362 +
#endif /* !_WIN32 */
added core/retro_control.h +85 −0
1 +
/* retro_control.h — portable emulator control server (see ../SPEC.md).
2 +
 *
3 +
 * A retro-platform emulator implements the small read-only backend below and
4 +
 * calls retro_control_start(). The shared server (retro_control.c) owns the
5 +
 * socket loop, HTTP parsing, PPM/JSON encoding, and control flow
6 +
 * (pause / step / free-run). It always binds 127.0.0.1. Contract 0.1.0.
7 +
 *
8 +
 * Integration is three calls in the emulator's main loop:
9 +
 *   retro_control_service();                 // once per iteration (cheap)
10 +
 *   if (!retro_control_running()) { idle; }  // gate advancing the machine
11 +
 *   ... run one instruction / frame ...
12 +
 *   if (frame_completed) retro_control_on_frame();
13 +
 *
14 +
 * Threading: the server runs on its own thread but never touches machine state
15 +
 * there. State-reading endpoints (/mem, /regs, /screenshot) are executed inside
16 +
 * retro_control_service() ON THE EMULATOR THREAD, so every read is consistent.
17 +
 * The backend callbacks are therefore called from the emulator thread (except
18 +
 * get_frame_count, which the server may also read for /status — make it a plain
19 +
 * atomic-ish counter read).
20 +
 */
21 +
#ifndef RETRO_CONTROL_H
22 +
#define RETRO_CONTROL_H
23 +
24 +
#include <stdint.h>
25 +
#include <stddef.h>
26 +
27 +
#define RETRO_CONTROL_CONTRACT "0.1.0"
28 +
29 +
/* Native framebuffer pixel layout; the server swizzles to PPM RGB. */
30 +
typedef enum {
31 +
    RETRO_PIX_RGB888 = 0,   /* 3 bytes/px: R,G,B                       */
32 +
    RETRO_PIX_RGBA8888,     /* 4 bytes/px: R,G,B,A                     */
33 +
    RETRO_PIX_BGRA8888      /* 4 bytes/px: B,G,R,A (x16 framebuffer)   */
34 +
} retro_pix_fmt_t;
35 +
36 +
typedef struct {
37 +
    const uint8_t *pixels;  /* start of the current complete frame     */
38 +
    int width;
39 +
    int height;
40 +
    retro_pix_fmt_t fmt;
41 +
} retro_framebuffer_t;
42 +
43 +
/* The emulator implements these. Any may be NULL: the matching endpoint then
44 +
 * returns 501 Not Implemented. */
45 +
typedef struct retro_control_backend {
46 +
    const char *platform;   /* "x16" | "neo6502" | "agon" | ...        */
47 +
    const char *emulator;   /* human name, e.g. "x16emu"               */
48 +
49 +
    /* Debug-read up to min(len, out_cap) bytes at addr into out, side-effect
50 +
     * free. bank is the optional bank selector (<0 = current bank). Returns the
51 +
     * number of bytes written. */
52 +
    uint32_t (*read_mem)(uint32_t addr, int32_t bank, uint32_t len,
53 +
                         uint8_t *out, uint32_t out_cap);
54 +
55 +
    /* Write a JSON object of registers into buf (NUL-terminated). */
56 +
    void (*get_regs_json)(char *buf, size_t cap);
57 +
58 +
    /* Report the current complete-frame framebuffer. */
59 +
    void (*get_framebuffer)(retro_framebuffer_t *out);
60 +
61 +
    /* Monotonic completed-frame counter. */
62 +
    uint64_t (*get_frame_count)(void);
63 +
} retro_control_backend_t;
64 +
65 +
/* Start the server thread bound to 127.0.0.1:port. Non-blocking; 0 on success,
66 +
 * negative on error. backend must outlive the server. */
67 +
int  retro_control_start(int port, const retro_control_backend_t *backend);
68 +
69 +
/* Drain a pending control request. Call once per main-loop iteration, at an
70 +
 * instruction/frame boundary. Cheap no-op when idle and when control is off. */
71 +
void retro_control_service(void);
72 +
73 +
/* Nonzero => the machine should advance this iteration. Returns 1 always when
74 +
 * control is off or the machine is free-running; 0 when paused with no steps
75 +
 * left. */
76 +
int  retro_control_running(void);
77 +
78 +
/* Call when a video frame completes. Drives /step (decrements the step budget,
79 +
 * halts and answers when it reaches zero). No-op when control is off. */
80 +
void retro_control_on_frame(void);
81 +
82 +
/* Stop the server (optional; process exit is fine too). */
83 +
void retro_control_stop(void);
84 +
85 +
#endif /* RETRO_CONTROL_H */