OXYGENHUB

Contract 0.3.0: memory write (POST /mem) + audio drain (GET /audio) 674b8447022b

Two purely-additive endpoints on top of 0.2:

- POST /mem?addr=&bank= — poke bytes from the request body (write-sibling of
  the GET read); returns {"written":n}. New backend callback write_mem; NULL
  => 501. For RAM/state fixup (force lives, seed a level, restore a snapshot).
- GET /audio — drain-window PCM WAV of everything synthesised since the last
  call (the audio analogue of /screenshot). New callback capture_audio; NULL
  => 501. Deterministic under pause+step. X-Rrdc-Audio-Dropped reports ring
  overflow.

The core advertises the highest level whose callbacks are all present:
0.3.0 needs inject_key + write_mem + capture_audio, else 0.2.0, else 0.1.0 —
a partial backend stays honest. Client gains mem_write()/audio(); conformance
adds write-roundtrip, POST-required, and WAV-parse tests gated on minor >= 3.

Git-Commit: 9447f3a6ccdb8a5f8741b5f54c6b04ce68d129a8
Git-Author: Andy McCall <[email protected]>
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-07-20 16:03 UTC
Signature
◈ sealed by Andy McCall
Hash
674b8447022b15ee7aadad64b89f986e06b7907163d93ba3eab8852da46cbb5a
Depends on
42c217d9aaf7 689a9ccb0138 8ac19619e24b 961b3c610c66 d44319b1e712
Depended on by
8fd33bc520f9 398756c0e9b2 bed0f1e524fe 346ca3ee83f4 d69f9ef6bbac 7c83b415d7cc
In bookmarks
main perf-6
Edits
38 atoms, 0 tree operations, 5 files

Browse the tree at this change

modified SPEC.md +45 −7
1 1
# Retro Remote Debug Controller — HTTP Control Contract
2 2
3 -
**Contract version: 0.2.0** (semver; clients assert on the MAJOR). 0.2 adds
4 -
input injection (`POST /key`, `POST /reset`); it is purely additive, so 0.1
5 -
clients keep working and servers advertise their level via `/status.contract`.
3 +
**Contract version: 0.3.0** (semver; clients assert on the MAJOR). 0.3 adds
4 +
memory write (`POST /mem`) and an audio drain (`GET /audio`); 0.2 added input
5 +
injection (`POST /key`, `POST /reset`). Every minor is purely additive, so older
6 +
clients keep working and servers advertise the highest level whose callbacks are
7 +
all present via `/status.contract`.
6 8
7 9
A minimal, portable HTTP API that a retro-platform emulator exposes so an
8 10
external harness can screenshot the screen, read memory and registers, step the
…
32 unchanged lines
41 43
Liveness + contract negotiation. Always available.
42 44
```json
43 45
{
44 -
  "contract": "0.1.0",
46 +
  "contract": "0.3.0",
45 47
  "emulator": "x16emu",
46 48
  "platform": "x16",
47 49
  "frame": 12345,
…
31 unchanged lines
79 81
- Reads go through the emulator's debug-read path (no side effects, no I/O
80 82
  triggers, no access-flag mutation).
81 83
84 +
### `POST /mem?addr=<a>[&bank=<b>]`  *(0.3)*
85 +
Write (poke) memory. The write-sibling of `GET /mem`; same address/bank model.
86 +
- `addr`, `bank` — as for the read form. `len` is NOT a query param: the byte
87 +
  count is the request **body** length.
88 +
- Body — `application/octet-stream`, the raw bytes to write starting at `addr`.
89 +
  Servers MAY cap the body (recommend ≥ 65536).
90 +
- Response: `{ "written": <n> }` — bytes actually written. A server MAY write
91 +
  fewer than requested (e.g. it stops at a ROM/unwritable boundary); the count
92 +
  tells the truth.
93 +
- Writes go through the emulator's CPU write path. Intended for RAM/state
94 +
  fixup (force lives, seed a level, restore a snapshot). Writing an I/O register
95 +
  MAY trigger device side effects — that is the caller's responsibility.
96 +
- Determinism: poke while `paused` so the machine can't overwrite between the
97 +
  write and a subsequent read.
98 +
82 99
### `GET /regs`
83 100
CPU registers as JSON. Keys are platform-specific (appendix); values are decimal
84 101
unless suffixed `_hex`. Example (x16 / 65C02):
…
30 unchanged lines
115 132
### `POST /reset`  *(0.2)*
116 133
Soft/cold reset the machine. Response `{ "reset": true }`. The `frame` counter
117 134
MAY reset here (the one case `/status.frame` is allowed to go backwards).
135 +
136 +
### `GET /audio`  *(0.3)*
137 +
Drain the audio the emulator has synthesised since the last `/audio` call — the
138 +
audio analogue of `/screenshot`, but a *window* (everything since last drain)
139 +
rather than a snapshot.
140 +
- Response: `audio/wav`, a self-describing **PCM WAV** (RIFF, 16-bit signed,
141 +
  interleaved). The header carries the sample rate and channel count; clients
142 +
  read them from the WAV — do not hardcode (appendix lists native rates).
143 +
- The server keeps a bounded ring of mixed output samples, teed from the same
144 +
  buffer that feeds the sound device. Each call empties the ring and returns it;
145 +
  an empty ring returns a valid zero-frame WAV.
146 +
- Determinism: under `/pause` + `/step`, the drain covers exactly the audio of
147 +
  the stepped frames, so a test can `pause → step(n) → GET /audio` and get the
148 +
  sound of those `n` frames reproducibly.
149 +
- `X-Rrdc-Audio-Dropped: <n>` — samples the ring dropped (oldest-first) to
150 +
  overflow since the last drain. Non-zero means drain more often. `0` when clean.
118 151
119 152
## Consistency (normative)
120 153
…
28 unchanged lines
149 182
- Registers: `a x y` (8), `sp pc` (16), `status` (8), plus `ram_bank`,
150 183
  `rom_bank`. Source: the `regs` struct + `memory_get_ram_bank/rom_bank()`.
151 184
- Frame source: `video.c` `frame_count`.
185 +
- Audio: stereo (2 ch), native rate `25000000/512` ≈ **48828 Hz**, teed from the
186 +
  mixed `audio_render()` output.
152 187
153 188
### Neo6502 — neo6502 emulator
154 189
- Flag: `--control-port <N>`.
…
27 unchanged lines
182 217
is required. Two ways to get there:
183 218
184 219
- **Vendor the shared core** (`core/retro_control.c` + `.h`) — for C/C++
185 -
  emulators. Implement the four backend callbacks, wire three loop hooks, call
186 -
  `retro_control_start(port, &backend)`; the socket loop, HTTP parsing, PPM/JSON
187 -
  encoding and pause/step control come for free. The Commander X16 fork does this.
220 +
  emulators. Implement the backend callbacks (four are enough for 0.1; add
221 +
  `inject_key`/`reset` for 0.2 and `write_mem`/`capture_audio` for 0.3 — a NULL
222 +
  callback makes its endpoint return 501 and lowers the advertised contract),
223 +
  wire three loop hooks, call `retro_control_start(port, &backend)`; the socket
224 +
  loop, HTTP parsing, PPM/JSON encoding and pause/step control come for free.
225 +
  The Commander X16 fork does this.
188 226
- **Conform directly** — for emulators in other languages (the FAB Agon emulator
189 227
  is Rust). Keep your own HTTP server; just match the contract and pass the
190 228
  conformance suite. Sharing the C core is an optimization, not a requirement —
…
9 unchanged lines
modified client/emu_control.py +16 −3
1 1
"""emu_control.py — shared client for the Retro Remote Debug Controller.
2 2
3 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
4 +
../SPEC.md (0.3.x). Depends only on the stdlib; Pillow is optional and only
5 5
needed for .screenshot().
6 6
7 7
    from emu_control import EmuControl
…
57 unchanged lines
65 65
        except urllib.error.HTTPError as e:
66 66
            raise EmuControlError(f"GET {path} -> {e.code}: {e.read()!r}") from e
67 67
68 -
    def _post(self, path: str) -> bytes:
69 -
        req = urllib.request.Request(self.base + path, method="POST", data=b"")
68 +
    def _post(self, path: str, body: bytes = b"") -> bytes:
69 +
        req = urllib.request.Request(self.base + path, method="POST", data=body)
70 70
        try:
71 71
            with urllib.request.urlopen(req, timeout=self.timeout) as r:
72 72
                return r.read()
…
57 unchanged lines
130 130
        """Soft/cold reset the machine (contract 0.2)."""
131 131
        self._post("/reset")
132 132
133 +
    def mem_write(self, addr: int, data: bytes, bank: int | None = None) -> int:
134 +
        """Write/poke bytes at addr (contract 0.3). Returns the number written."""
135 +
        path = f"/mem?addr={addr}"
136 +
        if bank is not None:
137 +
            path += f"&bank={bank}"
138 +
        return int(json.loads(self._post(path, bytes(data)))["written"])
139 +
140 +
    def audio(self) -> bytes:
141 +
        """Raw WAV (PCM) of audio synthesized since the last call (contract 0.3).
142 +
        Drain regularly (e.g. after each step) so the ring doesn't overflow."""
143 +
        body, _ = self._get("/audio")
144 +
        return body
145 +
133 146
    def screenshot_ppm(self) -> bytes:
134 147
        """Return the live screen as raw PPM (P6) bytes. Stdlib-only."""
135 148
        body, _ = self._get("/screenshot")
…
29 unchanged lines
modified conformance/test_conformance.py +40 −2
1 -
"""Contract conformance tests (SPEC.md 0.1.x). Platform-agnostic: framebuffer
1 +
"""Contract conformance tests (SPEC.md 0.3.x). Platform-agnostic: framebuffer
2 2
dimensions come from the PPM header, addresses use $0000, and `bank` is
3 -
exercised but banking is not required."""
3 +
exercised but banking is not required. Tests gated on the server's advertised
4 +
minor (input injection 0.2; memory write + audio 0.3) skip on older servers."""
4 5
import urllib.error
5 6
import urllib.request
6 7
…
107 unchanged lines
114 115
    assert code == 405
115 116
116 117
118 +
# -- contract 0.3: memory write + audio (skipped on < 0.3 servers) -----------
119 +
120 +
def test_mem_write_roundtrip(emu):
121 +
    """POST /mem pokes bytes; GET /mem reads them back (contract 0.3)."""
122 +
    if _contract_minor(emu) < 3:
123 +
        pytest.skip("contract < 0.3 (no memory write)")
124 +
    emu.pause()               # freeze so the machine can't overwrite between write+read
125 +
    try:
126 +
        addr, payload = 0x0400, bytes([0xA5, 0x5A, 0x00, 0xFF])
127 +
        assert emu.mem_write(addr, payload) == len(payload)
128 +
        assert emu.mem(addr, len(payload)) == payload
129 +
    finally:
130 +
        emu.resume()
131 +
132 +
133 +
def test_mem_write_requires_post(emu):
134 +
    if _contract_minor(emu) < 3:
135 +
        pytest.skip("contract < 0.3")
136 +
    # GET /mem is the read path (200), never a write — writing needs POST.
137 +
    code, _ = _raw(emu, "GET", "/mem?addr=0&len=1")
138 +
    assert code == 200
139 +
140 +
141 +
def test_audio_is_valid_wav(emu):
142 +
    """GET /audio returns a parseable PCM WAV (contract 0.3)."""
143 +
    if _contract_minor(emu) < 3:
144 +
        pytest.skip("contract < 0.3 (no audio)")
145 +
    import io
146 +
    import wave
147 +
    emu.pause(); emu.step(4)          # produce a little audio deterministically
148 +
    w = wave.open(io.BytesIO(emu.audio()), "rb")
149 +
    assert w.getsampwidth() == 2      # signed 16-bit
150 +
    assert w.getnchannels() in (1, 2)
151 +
    assert w.getframerate() > 0
152 +
    emu.resume()
153 +
154 +
117 155
def test_reset(emu):
118 156
    """/reset responds and is POST-only. Runs last (it restarts the machine)."""
119 157
    if _contract_minor(emu) < 2:
…
4 unchanged lines
modified core/retro_control.c +127 −12
…
44 unchanged lines
45 45
static long g_frames_to_run = -1;
46 46
static int  g_step_waiting  = 0;
47 47
48 -
/* single-slot marshalled request (mem / regs / screenshot / key / reset) */
49 -
typedef enum { REQ_NONE = 0, REQ_MEM, REQ_REGS, REQ_SHOT, REQ_KEY, REQ_RESET } req_t;
48 +
/* single-slot marshalled request. */
49 +
typedef enum { REQ_NONE = 0, REQ_MEM, REQ_REGS, REQ_SHOT, REQ_KEY, REQ_RESET,
50 +
               REQ_WRITE, REQ_AUDIO } req_t;
50 51
static volatile req_t g_req = REQ_NONE;
51 52
static uint32_t g_req_addr, g_req_len;
52 53
static int32_t  g_req_bank;
54 +
static const uint8_t *g_write_data;   /* POST /mem body (HTTP-thread owned) */
53 55
static int      g_key_is_text;   /* 1 => g_key_value is a char code point */
54 56
static uint32_t g_key_value;
55 57
static int      g_key_action;    /* retro_key_action_t */
58 +
static char     g_resp_extra[64];     /* extra response header line, or "" */
56 59
57 60
static uint8_t    *g_resp_body  = NULL;
58 61
static size_t      g_resp_len   = 0;
…
35 unchanged lines
94 97
    g_resp_ctype = "image/x-portable-pixmap";
95 98
}
96 99
100 +
#define AUDIO_MAX_SAMPLES (1u << 18)   /* drain cap: 256K int16 (~2.7 s stereo @48.8k) */
101 +
102 +
static void le16(uint8_t *p, uint16_t v) { p[0] = v; p[1] = v >> 8; }
103 +
static void le32(uint8_t *p, uint32_t v) { p[0] = v; p[1] = v >> 8; p[2] = v >> 16; p[3] = v >> 24; }
104 +
105 +
/* Drain the backend's audio ring and wrap it as a canonical PCM WAV. */
106 +
static void build_wav(void)
107 +
{
108 +
    static int16_t samp[AUDIO_MAX_SAMPLES];
109 +
    int rate = 48000, channels = 1;
110 +
    uint32_t dropped = 0;
111 +
    uint32_t n = g_be->capture_audio(samp, AUDIO_MAX_SAMPLES, &rate, &channels, &dropped);
112 +
    if (channels < 1) channels = 1;
113 +
    if (rate < 1) rate = 1;
114 +
115 +
    uint32_t data_bytes = n * 2;               /* int16 samples */
116 +
    uint32_t total = 44 + data_bytes;
117 +
    uint8_t *out = (uint8_t *)malloc(total);
118 +
    if (!out) { g_resp_body = NULL; g_resp_len = 0; g_resp_status = 500; return; }
119 +
120 +
    uint32_t byte_rate = (uint32_t)rate * channels * 2;
121 +
    memcpy(out, "RIFF", 4);       le32(out + 4, 36 + data_bytes);
122 +
    memcpy(out + 8, "WAVE", 4);
123 +
    memcpy(out + 12, "fmt ", 4);  le32(out + 16, 16);
124 +
    le16(out + 20, 1);            le16(out + 22, (uint16_t)channels);
125 +
    le32(out + 24, (uint32_t)rate);
126 +
    le32(out + 28, byte_rate);    le16(out + 32, (uint16_t)(channels * 2));
127 +
    le16(out + 34, 16);
128 +
    memcpy(out + 36, "data", 4);  le32(out + 40, data_bytes);
129 +
    memcpy(out + 44, samp, data_bytes);
130 +
131 +
    g_resp_body = out; g_resp_len = total; g_resp_status = 200;
132 +
    g_resp_ctype = "audio/wav";
133 +
    snprintf(g_resp_extra, sizeof g_resp_extra,
134 +
             "X-Rrdc-Audio-Dropped: %u\r\n", (unsigned)dropped);
135 +
}
136 +
97 137
void retro_control_service(void)
98 138
{
99 139
    if (!g_be || g_req == REQ_NONE) return;   /* cheap unlocked fast-path */
100 140
101 141
    pthread_mutex_lock(&g_lock);
142 +
    g_resp_extra[0] = 0;
102 143
    switch (g_req) {
103 144
    case REQ_MEM: {
104 145
        uint32_t len = g_req_len > MEM_CAP ? MEM_CAP : g_req_len;
…
42 unchanged lines
147 188
        uint8_t *b = (uint8_t *)malloc(l + 1);
148 189
        if (b) memcpy(b, json, l + 1);
149 190
        g_resp_body = b; g_resp_len = b ? l : 0;
191 +
        g_resp_ctype = "application/json";
192 +
        break;
193 +
    }
194 +
    case REQ_WRITE: {
195 +
        const char *json; char *b;
196 +
        if (!g_be->write_mem) {
197 +
            json = "{\"error\":\"not implemented\"}"; g_resp_status = 501;
198 +
            size_t l = strlen(json); b = (char *)malloc(l + 1);
199 +
            if (b) memcpy(b, json, l + 1);
200 +
            g_resp_len = b ? l : 0;
201 +
        } else {
202 +
            uint32_t wrote = g_be->write_mem(g_req_addr, g_req_bank, g_req_len, g_write_data);
203 +
            b = (char *)malloc(48);
204 +
            g_resp_len = b ? (size_t)snprintf(b, 48, "{\"written\":%u}", (unsigned)wrote) : 0;
205 +
            g_resp_status = 200;
206 +
        }
207 +
        g_resp_body = (uint8_t *)b;
150 208
        g_resp_ctype = "application/json";
151 209
        break;
152 210
    }
211 +
    case REQ_AUDIO:
212 +
        if (g_be->capture_audio) build_wav();
213 +
        else {
214 +
            const char *json = "{\"error\":\"not implemented\"}";
215 +
            size_t l = strlen(json); uint8_t *b = (uint8_t *)malloc(l + 1);
216 +
            if (b) memcpy(b, json, l + 1);
217 +
            g_resp_body = b; g_resp_len = b ? l : 0; g_resp_status = 501;
218 +
            g_resp_ctype = "application/json";
219 +
        }
220 +
        break;
153 221
    default: break;
154 222
    }
155 223
    g_req = REQ_NONE;
…
38 unchanged lines
194 262
    }
195 263
}
196 264
265 +
/* `extra`, if non-NULL, is a complete header line ("Name: value\r\n"). */
197 266
static void send_http(int fd, int status, const char *ctype,
198 -
                      const uint8_t *body, size_t len)
267 +
                      const uint8_t *body, size_t len, const char *extra)
199 268
{
200 269
    const char *reason =
201 270
        status == 200 ? "OK" :
…
1 unchanged lines
203 272
        status == 404 ? "Not Found" :
204 273
        status == 405 ? "Method Not Allowed" :
205 274
        status == 501 ? "Not Implemented" : "Error";
206 -
    char hdr[256];
275 +
    char hdr[320];
207 276
    int hl = snprintf(hdr, sizeof hdr,
208 277
        "HTTP/1.1 %d %s\r\nContent-Type: %s\r\nContent-Length: %zu\r\n"
209 -
        "Connection: close\r\nAccess-Control-Allow-Origin: *\r\n\r\n",
210 -
        status, reason, ctype, len);
278 +
        "Connection: close\r\nAccess-Control-Allow-Origin: *\r\n%s\r\n",
279 +
        status, reason, ctype, len, extra ? extra : "");
211 280
    write_all(fd, hdr, hl);
212 281
    if (body && len) write_all(fd, body, len);
213 282
}
214 283
215 284
static void send_json(int fd, int status, const char *json)
216 285
{
217 -
    send_http(fd, status, "application/json", (const uint8_t *)json, strlen(json));
286 +
    send_http(fd, status, "application/json", (const uint8_t *)json, strlen(json), NULL);
218 287
}
219 288
220 289
/* value of query key, decimal or 0x-hex (strtol base 0); def if absent. */
…
42 unchanged lines
263 332
    while (!g_resp_ready) pthread_cond_wait(&g_cv, &g_lock);
264 333
    uint8_t *body = g_resp_body; size_t len = g_resp_len;
265 334
    int st = g_resp_status; const char *ct = g_resp_ctype;
335 +
    char extra[64]; memcpy(extra, g_resp_extra, sizeof extra);
266 336
    g_resp_body = NULL;
267 337
    pthread_mutex_unlock(&g_lock);
268 338
269 -
    send_http(fd, st, ct, body, len);
339 +
    send_http(fd, st, ct, body, len, extra[0] ? extra : NULL);
270 340
    free(body);
271 341
}
272 342
…
3 unchanged lines
276 346
    long f = g_frames_to_run;
277 347
    pthread_mutex_unlock(&g_lock);
278 348
    unsigned long long fc = g_be->get_frame_count ? g_be->get_frame_count() : 0;
279 -
    /* Advertise 0.2 only when the backend actually offers input injection. */
280 -
    const char *contract = g_be->inject_key ? "0.2.0" : "0.1.0";
349 +
    /* Advertise the highest level whose defining callbacks are all present. */
350 +
    const char *contract =
351 +
        (g_be->inject_key && g_be->write_mem && g_be->capture_audio) ? "0.3.0" :
352 +
        g_be->inject_key ? "0.2.0" : "0.1.0";
281 353
    char buf[320];
282 354
    snprintf(buf, sizeof buf,
283 355
        "{\"contract\":\"%s\",\"emulator\":\"%s\",\"platform\":\"%s\","
…
29 unchanged lines
313 385
    pthread_mutex_unlock(&g_lock);
314 386
    send_json(fd, 200, paused ? "{\"paused\":true}" : "{\"paused\":false}");
315 387
}
388 +
389 +
/* Read a request body (POST /mem). `req`/`n` = what handle_conn already recv'd
390 +
 * (headers + maybe some body); reads the rest per Content-Length. Returns a
391 +
 * malloc'd buffer of *out_len bytes (caller frees), capped at MEM_CAP. */
392 +
static uint8_t *read_body(int fd, char *req, size_t n, size_t *out_len)
393 +
{
394 +
    *out_len = 0;
395 +
    char *hdr_end = strstr(req, "\r\n\r\n");
396 +
    if (!hdr_end) return NULL;
397 +
    char *body_start = hdr_end + 4;
398 +
399 +
    long clen = 0;
400 +
    char *cl = strstr(req, "Content-Length:");
401 +
    if (!cl) cl = strstr(req, "content-length:");
402 +
    if (cl) clen = strtol(cl + 15, NULL, 10);
403 +
    if (clen < 0) clen = 0;
404 +
    if (clen > (long)MEM_CAP) clen = (long)MEM_CAP;
316 405
406 +
    uint8_t *body = (uint8_t *)malloc((size_t)clen ? (size_t)clen : 1);
407 +
    if (!body) return NULL;
408 +
409 +
    size_t have = (size_t)(req + n - body_start);
410 +
    if (have > (size_t)clen) have = (size_t)clen;
411 +
    memcpy(body, body_start, have);
412 +
    size_t got = have;
413 +
    while (got < (size_t)clen) {
414 +
        ssize_t r = recv(fd, body + got, (size_t)clen - got, 0);
415 +
        if (r <= 0) break;
416 +
        got += (size_t)r;
417 +
    }
418 +
    *out_len = got;
419 +
    return body;
420 +
}
421 +
317 422
static void handle_conn(int fd)
318 423
{
319 424
    char req[2048];
…
19 unchanged lines
339 444
    if (!strcmp(target, "/status"))          { do_status(fd); return; }
340 445
    if (!strcmp(target, "/regs"))            { do_marshalled(fd, REQ_REGS); return; }
341 446
    if (!strcmp(target, "/screenshot"))      { do_marshalled(fd, REQ_SHOT); return; }
447 +
    if (!strcmp(target, "/audio"))           { do_marshalled(fd, REQ_AUDIO); return; }
342 448
    if (!strcmp(target, "/mem")) {
343 449
        g_req_addr = (uint32_t)query_long(query, "addr", 0);
344 -
        g_req_len  = (uint32_t)query_long(query, "len", 0);
345 450
        g_req_bank = (int32_t)query_long(query, "bank", -1);
346 -
        do_marshalled(fd, REQ_MEM);
451 +
        if (is_post) {                        /* 0.3: write / poke */
452 +
            size_t blen = 0;
453 +
            uint8_t *body = read_body(fd, req, n, &blen);
454 +
            g_req_len = (uint32_t)blen;
455 +
            g_write_data = body;
456 +
            do_marshalled(fd, REQ_WRITE);
457 +
            free(body);
458 +
        } else {                              /* read */
459 +
            g_req_len = (uint32_t)query_long(query, "len", 0);
460 +
            do_marshalled(fd, REQ_MEM);
461 +
        }
347 462
        return;
348 463
    }
349 464
    if (!strcmp(target, "/step")) {
…
90 unchanged lines
modified core/retro_control.h +18 −4
…
23 unchanged lines
24 24
#include <stdint.h>
25 25
#include <stddef.h>
26 26
27 -
/* Highest contract the core implements. The server ADVERTISES 0.2.0 only when
28 -
 * the backend provides input injection (inject_key); otherwise it reports
29 -
 * 0.1.0, so a 0.1-only backend stays honest. */
30 -
#define RETRO_CONTROL_CONTRACT "0.2.0"
27 +
/* Highest contract the core implements. The server advertises the highest level
28 +
 * whose backend callbacks are all present: 0.3.0 needs inject_key + write_mem +
29 +
 * capture_audio; 0.2.0 needs inject_key; else 0.1.0. Keeps a partial backend
30 +
 * honest. */
31 +
#define RETRO_CONTROL_CONTRACT "0.3.0"
31 32
32 33
/* Native framebuffer pixel layout; the server swizzles to PPM RGB. */
33 34
typedef enum {
…
45 unchanged lines
79 80
80 81
    /* 0.2: soft/cold reset the machine. NULL => /reset returns 501. */
81 82
    void (*reset)(void);
83 +
84 +
    /* 0.3: write len bytes from `in` at addr (debug/state poke; bank<0 =
85 +
     * current). Intended for RAM/state — I/O-register writes may trigger device
86 +
     * side effects. Return bytes written. NULL => POST /mem returns 501. */
87 +
    uint32_t (*write_mem)(uint32_t addr, int32_t bank, uint32_t len,
88 +
                          const uint8_t *in);
89 +
90 +
    /* 0.3: drain up to `cap` interleaved signed-16-bit audio samples the
91 +
     * emulator has synthesised since the last call into `out`; set *rate and
92 +
     * *channels; set *dropped to samples lost to overflow since the last drain.
93 +
     * Return the number of int16 samples written. NULL => /audio returns 501. */
94 +
    uint32_t (*capture_audio)(int16_t *out, uint32_t cap,
95 +
                              int *rate, int *channels, uint32_t *dropped);
82 96
} retro_control_backend_t;
83 97
84 98
/* Start the server thread bound to 127.0.0.1:port. Non-blocking; 0 on success,
…
20 unchanged lines