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 */ |