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 |