0.5.0: game controller injection (POST/GET /pad) bed0f1e524fe
There is no way to test a game's controller handling without a human
holding hardware. /key covers the keyboard and /pointer the mouse; the
pad -- the input most games are actually played with -- had nothing.
POST /pad presents a VIRTUAL controller at an index and sets what is
held; GET /pad reads back {index, connected, buttons}. Backend callbacks
set_pad / get_pad, and the server advertises 0.5.0 only when both are
present, the same gate 0.4.0 applies to the pointer pair.
Three decisions worth stating.
CANONICAL LAYOUT. bit0 LEFT, 1 RIGHT, 2 UP, 3 DOWN, 4 A, 5 B, 6 X, 7 Y,
8 START, 9 SELECT, 10 L, 11 R -- the SNES/Neo6502 native order, fixed
across platforms. A backend whose hardware disagrees (the X16 KERNAL
reports RIGHT/LEFT/DOWN/UP) converts in its own floor. Same reasoning
that fixed bit0 = primary for the pointer: one layout defined once, so a
test written against one machine reads the same on another.
A LEVEL, NOT AN EVENT. The held mask persists across /step frames until
changed, unlike /key's momentary tap. That is the property that makes
this worth having rather than reusing /key, so conformance asserts it
directly rather than assuming it.
VIRTUAL, NOT SDL. A backend presents a device its existing read path
picks up; it does not drive the host's joystick API. Headless CI has no
joystick, and that is exactly the case this exists for.
Motivated by a bug that shipped: a port masked the firmware's merged
controller down to its d-pad and tried to recover the buttons from the
wrong controller index. Movement worked, fire did nothing, and nothing
could catch it without hardware in someone's hands.
Conformance covers the surface, the round trip, the hold-across-frames
property and that connected=0 is observable -- a backend that always
reports "connected" is not conformant, because a program offering
two-player only when a second pad is present needs both answers.
Core compiles clean; client and conformance parse. No backend implements
it yet, so every server keeps advertising 0.4.0 until one does.
Git-Commit: 8731e30a5faeb53a4b966e8b2605be7e91ba792d
Git-Author: Andy McCall <[email protected]>
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-07-28 20:50 UTC
- Signature
- ◈ sealed by Andy McCall
- Hash
bed0f1e524fef708c855791a30d54b455bbe957f85f87cf0879a8cf3ff44287e- Depends on
- 346ca3ee83f4 4c0b050e9a25 674b8447022b 8ac19619e24b 961b3c610c66 d44319b1e712
- Depended on by
- 8fd33bc520f9 1b40a077e41f
- In bookmarks
- main perf-6
- Edits
- 18 atoms, 0 tree operations, 7 files
Browse the tree at this change
modified CHANGELOG.md +21 −0
| … | 6 unchanged lines |
||
| 7 | 7 | advertises the highest level whose backend callbacks are all present via |
|
| 8 | 8 | `/status.contract`. |
|
| 9 | 9 | ||
| 10 | + | ## 0.5.0 |
|
| 11 | + | - `POST /pad?index=[&buttons=][&connected=]` — present a **virtual game |
|
| 12 | + | controller** and set what is held. `buttons` is the canonical mask (bit0 LEFT, |
|
| 13 | + | 1 RIGHT, 2 UP, 3 DOWN, 4 A, 5 B, 6 X, 7 Y, 8 START, 9 SELECT, 10 L, 11 R — |
|
| 14 | + | the SNES/Neo6502 native order, fixed across platforms). `connected=0` unplugs. |
|
| 15 | + | Backend callback: `set_pad`. |
|
| 16 | + | - `GET /pad?index=` — read back `{index, connected, buttons}`, so a test can tell |
|
| 17 | + | "the input never arrived" from "it arrived and was ignored". Backend callback: |
|
| 18 | + | `get_pad`. |
|
| 19 | + | - A pad is a **level, not an event**: the held mask persists across `/step` |
|
| 20 | + | frames until changed, unlike `/key`'s momentary tap. Conformance asserts this |
|
| 21 | + | directly, because it is the property that makes `/pad` worth having rather |
|
| 22 | + | than reusing `/key`. |
|
| 23 | + | - **Virtual, not SDL.** A backend presents a device its existing read path picks |
|
| 24 | + | up rather than driving the host's joystick API. Headless CI has no joystick, |
|
| 25 | + | and that is precisely the case this endpoint exists for. |
|
| 26 | + | - Motivation: a real bug that shipped. A port's pad read masked the firmware's |
|
| 27 | + | merged controller down to its d-pad and tried to recover the buttons from the |
|
| 28 | + | wrong controller index — movement worked, fire did nothing, and there was no |
|
| 29 | + | way to catch it without a human holding hardware. |
|
| 30 | + | ||
| 10 | 31 | ## 0.4.0 |
|
| 11 | 32 | - `POST /pointer?x=&y=[&buttons=]` (absolute) or `?dx=&dy=[&buttons=]` (relative) |
|
| 12 | 33 | — inject a pointer move/click through the platform's native mouse path. |
|
| … | 32 unchanged lines |
modified README.md +6 −2
| … | 18 unchanged lines |
||
| 19 | 19 | | `GET /audio` | drain synthesised PCM as a WAV | 0.3 | |
|
| 20 | 20 | | `POST /pointer?x=&y=&buttons=` | inject a pointer move/click | 0.4 | |
|
| 21 | 21 | | `GET /pointer` | read back pointer position + buttons | 0.4 | |
|
| 22 | + | | `POST /pad?index=&buttons=&connected=` | present a virtual controller, set what is held | 0.5 | |
|
| 23 | + | | `GET /pad?index=` | read back a pad's presence + held buttons | 0.5 | |
|
| 22 | 24 | ||
| 23 | - | Current contract: **0.4.0**. Minors are purely additive (a missing capability |
|
| 25 | + | Current contract: **0.5.0**. Minors are purely additive (a missing capability |
|
| 24 | 26 | returns 501 and lowers the advertised contract), so a client reads |
|
| 25 | 27 | `/status.contract` and fails fast only on a MAJOR mismatch. See [SPEC.md](SPEC.md). |
|
| 26 | 28 | ||
| … | 46 unchanged lines |
||
| 73 | 75 | #include "retro_control.h" |
|
| 74 | 76 | ||
| 75 | 77 | /* Four callbacks satisfy 0.1; add inject_key/reset for 0.2, |
|
| 76 | - | write_mem/capture_audio for 0.3, set_pointer/get_pointer for 0.4. |
|
| 78 | + | write_mem/capture_audio for 0.3, set_pointer/get_pointer for 0.4, |
|
| 79 | + | set_pad/get_pad for 0.5. |
|
| 77 | 80 | A NULL callback 501s its endpoint. */ |
|
| 78 | 81 | static const retro_control_backend_t backend = { |
|
| 79 | 82 | .platform = "x16", |
|
| … | 7 unchanged lines |
||
| 87 | 90 | .write_mem = x16_write_mem, /* 0.3 */ |
|
| 88 | 91 | .capture_audio = x16_capture_audio, /* 0.3 */ |
|
| 89 | 92 | .set_pointer = x16_set_pointer, /* 0.4 */ |
|
| 93 | + | .set_pad = x16_set_pad, /* 0.5 */ |
|
| 90 | 94 | .get_pointer = x16_get_pointer, /* 0.4 */ |
|
| 91 | 95 | }; |
|
| 92 | 96 | ||
| … | 22 unchanged lines |
modified SPEC.md +47 −0
| … | 161 unchanged lines |
||
| 162 | 162 | - Lets a test distinguish "the input never reached the platform" from "it |
|
| 163 | 163 | arrived but the program drew the wrong thing". No pointer on the platform → 400. |
|
| 164 | 164 | ||
| 165 | + | ### `POST /pad?index=<n> [&buttons=<mask>] [&connected=0|1]` *(0.5)* |
|
| 166 | + | Inject a game controller — the pad analogue of `/key` and `/pointer`. Query |
|
| 167 | + | params (no body): |
|
| 168 | + | - `index` — which pad, 0-based. OPTIONAL, default 0. |
|
| 169 | + | - `buttons` — bitmask of what is HELD, in the canonical layout below. Omitted → |
|
| 170 | + | the button state is left unchanged. `buttons=0` releases everything while |
|
| 171 | + | leaving the pad connected. |
|
| 172 | + | - `connected` — OPTIONAL. `1` (the default whenever `buttons` is given) presents |
|
| 173 | + | a **virtual pad** at `index`; `0` unplugs it. Unplugging matters: a program |
|
| 174 | + | that asks "is a controller present" before offering two-player must be |
|
| 175 | + | testable in both states, and on most platforms the count is what it asks. |
|
| 176 | + | - Response: `{ "injected": true }`. No controller support on the platform → 400. |
|
| 177 | + | ||
| 178 | + | Canonical button layout, low bit first: |
|
| 179 | + | ||
| 180 | + | | bit | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | |
|
| 181 | + | |---|---|---|---|---|---|---|---|---|---|---|---|---| |
|
| 182 | + | | | LEFT | RIGHT | UP | DOWN | A | B | X | Y | START | SELECT | L | R | |
|
| 183 | + | ||
| 184 | + | This is the SNES/Neo6502 native order, chosen for the same reason `/pointer` |
|
| 185 | + | fixed bit0 = primary: **one layout, defined once, so a test written against one |
|
| 186 | + | platform reads the same on another.** A backend whose hardware disagrees — the |
|
| 187 | + | X16 KERNAL reports RIGHT/LEFT/DOWN/UP — converts in its own floor. A test never |
|
| 188 | + | learns which machine it is on, which is the entire point. |
|
| 189 | + | ||
| 190 | + | Device-level, not driver-state: the injection MUST exercise the same path a real |
|
| 191 | + | pad takes, so what the program reads is what physical hardware would produce. On |
|
| 192 | + | an emulator that reads SDL joysticks, that means presenting a virtual pad the |
|
| 193 | + | existing read path picks up — **not** driving SDL, which would need a joystick |
|
| 194 | + | to exist and defeats the purpose. Headless CI has no joystick; that is the case |
|
| 195 | + | this endpoint is for. |
|
| 196 | + | ||
| 197 | + | Hold across frames: a pad is a LEVEL, not an event. `buttons` stays as set until |
|
| 198 | + | changed, so a press survives any number of `/step` frames without re-asserting — |
|
| 199 | + | unlike `/key`, whose TAP is deliberately momentary. Release explicitly. |
|
| 200 | + | ||
| 201 | + | ### `GET /pad?index=<n>` *(0.5)* |
|
| 202 | + | Read back a pad. Response: |
|
| 203 | + | ```json |
|
| 204 | + | { "index": 0, "connected": true, "buttons": 48 } |
|
| 205 | + | ``` |
|
| 206 | + | - `connected` — whether anything is present at that index, real or virtual. |
|
| 207 | + | - `buttons` — the current mask in the layout above. |
|
| 208 | + | - Same job as `GET /pointer`: distinguishes "the input never reached the |
|
| 209 | + | platform" from "it arrived and the program ignored it". No controller support |
|
| 210 | + | on the platform → 400. |
|
| 211 | + | ||
| 165 | 212 | ### `POST /reset` *(0.2)* |
|
| 166 | 213 | Soft/cold reset the machine. Response `{ "reset": true }`. The `frame` counter |
|
| 167 | 214 | MAY reset here (the one case `/status.frame` is allowed to go backwards). |
|
| … | 145 unchanged lines |
modified client/emu_control.py +41 −0
| … | 176 unchanged lines |
||
| 177 | 177 | body, _ = self._get("/pointer") |
|
| 178 | 178 | return json.loads(body) |
|
| 179 | 179 | ||
| 180 | + | # Canonical pad bitmask (SPEC "Canonical button layout"). The SNES / |
|
| 181 | + | # Neo6502 native order, fixed across platforms so a test written on one |
|
| 182 | + | # machine reads the same on another. |
|
| 183 | + | PAD_LEFT = 0x0001 |
|
| 184 | + | PAD_RIGHT = 0x0002 |
|
| 185 | + | PAD_UP = 0x0004 |
|
| 186 | + | PAD_DOWN = 0x0008 |
|
| 187 | + | PAD_A = 0x0010 |
|
| 188 | + | PAD_B = 0x0020 |
|
| 189 | + | PAD_X = 0x0040 |
|
| 190 | + | PAD_Y = 0x0080 |
|
| 191 | + | PAD_START = 0x0100 |
|
| 192 | + | PAD_SELECT = 0x0200 |
|
| 193 | + | PAD_L = 0x0400 |
|
| 194 | + | PAD_R = 0x0800 |
|
| 195 | + | ||
| 196 | + | def pad(self, buttons: int | None = None, index: int = 0, |
|
| 197 | + | connected: bool | None = None) -> None: |
|
| 198 | + | """Present a virtual controller and set what is held (contract 0.5). |
|
| 199 | + | ||
| 200 | + | `buttons` is a mask of PAD_* — omit to leave the held state alone. |
|
| 201 | + | `connected=False` unplugs the pad, which is testable behaviour in its |
|
| 202 | + | own right: a program that asks whether a controller is present before |
|
| 203 | + | offering two-player needs both answers. |
|
| 204 | + | ||
| 205 | + | Unlike key(), a pad is a LEVEL: the state persists across step() |
|
| 206 | + | frames until changed, so a press does not need re-asserting. Release |
|
| 207 | + | with pad(0).""" |
|
| 208 | + | parts = [f"index={index}"] |
|
| 209 | + | if buttons is not None: |
|
| 210 | + | parts.append(f"buttons={buttons}") |
|
| 211 | + | if connected is not None: |
|
| 212 | + | parts.append(f"connected={1 if connected else 0}") |
|
| 213 | + | self._post("/pad?" + "&".join(parts)) |
|
| 214 | + | ||
| 215 | + | def pad_get(self, index: int = 0) -> dict: |
|
| 216 | + | """GET /pad — {index, connected, buttons} (contract 0.5). Distinguishes |
|
| 217 | + | 'the input never arrived' from 'it arrived and was ignored'.""" |
|
| 218 | + | body, _ = self._get(f"/pad?index={index}") |
|
| 219 | + | return json.loads(body) |
|
| 220 | + | ||
| 180 | 221 | def screenshot_ppm(self) -> bytes: |
|
| 181 | 222 | """Return the live screen as raw PPM (P6) bytes. Stdlib-only.""" |
|
| 182 | 223 | body, _ = self._get("/screenshot") |
|
| … | 29 unchanged lines |
modified conformance/test_conformance.py +76 −0
| … | 199 unchanged lines |
||
| 200 | 200 | code, _ = _raw(emu, "GET", "/reset") |
|
| 201 | 201 | assert code == 405 |
|
| 202 | 202 | emu.reset() |
|
| 203 | + | ||
| 204 | + | ||
| 205 | + | # -- contract 0.5: pad injection (skipped on < 0.5 servers) ------------------ |
|
| 206 | + | ||
| 207 | + | def test_pad_injection(emu): |
|
| 208 | + | """/pad presents a virtual controller and sets what is held. The pad's |
|
| 209 | + | *effect* is platform-specific and tested per-port; the contract surface is |
|
| 210 | + | asserted here.""" |
|
| 211 | + | if _contract_minor(emu) < 5: |
|
| 212 | + | pytest.skip("contract < 0.5 (no pad injection)") |
|
| 213 | + | emu.pad(buttons=emu.PAD_A) # A held, pad plugged in |
|
| 214 | + | emu.pad(buttons=emu.PAD_A | emu.PAD_LEFT) # two at once |
|
| 215 | + | emu.pad(buttons=0) # all released, still there |
|
| 216 | + | emu.pad(index=1, buttons=emu.PAD_B) # a second pad |
|
| 217 | + | emu.pad(connected=False) # unplug pad 0 |
|
| 218 | + | emu.pad(connected=False, index=1) |
|
| 219 | + | ||
| 220 | + | ||
| 221 | + | def test_pad_readback(emu): |
|
| 222 | + | """GET /pad reports {index, connected, buttons} (contract 0.5).""" |
|
| 223 | + | if _contract_minor(emu) < 5: |
|
| 224 | + | pytest.skip("contract < 0.5") |
|
| 225 | + | emu.pause() |
|
| 226 | + | try: |
|
| 227 | + | emu.pad(buttons=emu.PAD_A | emu.PAD_RIGHT) |
|
| 228 | + | emu.step(1) |
|
| 229 | + | st = emu.pad_get() |
|
| 230 | + | assert isinstance(st["buttons"], int) |
|
| 231 | + | assert isinstance(st["connected"], bool) |
|
| 232 | + | assert st["index"] == 0 |
|
| 233 | + | assert st["connected"] is True, "injected pad reports itself absent" |
|
| 234 | + | # The mask must survive the round trip: this is the check that |
|
| 235 | + | # separates "the server accepted it" from "the backend stored it". |
|
| 236 | + | assert st["buttons"] == emu.PAD_A | emu.PAD_RIGHT, ( |
|
| 237 | + | f"held {st['buttons']:#x}, injected " |
|
| 238 | + | f"{emu.PAD_A | emu.PAD_RIGHT:#x}") |
|
| 239 | + | finally: |
|
| 240 | + | emu.pad(buttons=0) |
|
| 241 | + | emu.resume() |
|
| 242 | + | ||
| 243 | + | ||
| 244 | + | def test_pad_is_a_level_not_an_event(emu): |
|
| 245 | + | """A pad HOLDS. Unlike a key tap, an injected press must survive stepping — |
|
| 246 | + | a program that samples input once a frame has to still see it on frame 10. |
|
| 247 | + | This is the difference that makes /pad worth having rather than reusing |
|
| 248 | + | /key, so it is asserted rather than assumed.""" |
|
| 249 | + | if _contract_minor(emu) < 5: |
|
| 250 | + | pytest.skip("contract < 0.5") |
|
| 251 | + | emu.pause() |
|
| 252 | + | try: |
|
| 253 | + | emu.pad(buttons=emu.PAD_B) |
|
| 254 | + | emu.step(10) |
|
| 255 | + | assert emu.pad_get()["buttons"] == emu.PAD_B, ( |
|
| 256 | + | "the held mask did not survive 10 frames — /pad is behaving " |
|
| 257 | + | "like a momentary key tap") |
|
| 258 | + | finally: |
|
| 259 | + | emu.pad(buttons=0) |
|
| 260 | + | emu.resume() |
|
| 261 | + | ||
| 262 | + | ||
| 263 | + | def test_pad_unplug_is_observable(emu): |
|
| 264 | + | """connected=0 must actually unplug. A program that offers two-player only |
|
| 265 | + | when a second controller is present needs both answers to be reachable, so |
|
| 266 | + | a backend that always reports 'connected' is not conformant.""" |
|
| 267 | + | if _contract_minor(emu) < 5: |
|
| 268 | + | pytest.skip("contract < 0.5") |
|
| 269 | + | emu.pause() |
|
| 270 | + | try: |
|
| 271 | + | emu.pad(buttons=0) # plugged in |
|
| 272 | + | assert emu.pad_get()["connected"] is True |
|
| 273 | + | emu.pad(connected=False) |
|
| 274 | + | assert emu.pad_get()["connected"] is False, ( |
|
| 275 | + | "the pad still reports connected after connected=0") |
|
| 276 | + | finally: |
|
| 277 | + | emu.pad(connected=False) |
|
| 278 | + | emu.resume() |
modified core/retro_control.c +66 −1
| … | 47 unchanged lines |
||
| 48 | 48 | ||
| 49 | 49 | /* single-slot marshalled request. */ |
|
| 50 | 50 | typedef enum { REQ_NONE = 0, REQ_MEM, REQ_REGS, REQ_SHOT, REQ_KEY, REQ_RESET, |
|
| 51 | - | REQ_WRITE, REQ_AUDIO, REQ_PTR_SET, REQ_PTR_GET } req_t; |
|
| 51 | + | REQ_WRITE, REQ_AUDIO, REQ_PTR_SET, REQ_PTR_GET, |
|
| 52 | + | REQ_PAD_SET, REQ_PAD_GET } req_t; |
|
| 52 | 53 | static volatile req_t g_req = REQ_NONE; |
|
| 53 | 54 | static uint32_t g_req_addr, g_req_len; |
|
| 54 | 55 | static int32_t g_req_bank; |
|
| … | 4 unchanged lines |
||
| 59 | 60 | static int g_ptr_absolute; /* 1 => (x,y) absolute; 0 => relative deltas */ |
|
| 60 | 61 | static int32_t g_ptr_x, g_ptr_y; |
|
| 61 | 62 | static int g_ptr_buttons; /* button bitmask, or -1 = leave unchanged */ |
|
| 63 | + | static int g_pad_index; /* which pad (0-based) */ |
|
| 64 | + | static int g_pad_buttons; /* held mask, or -1 = leave unchanged */ |
|
| 65 | + | static int g_pad_connected; /* 1 plug, 0 unplug, -1 = unchanged */ |
|
| 62 | 66 | static char g_resp_extra[64]; /* extra response header line, or "" */ |
|
| 63 | 67 | ||
| 64 | 68 | static uint8_t *g_resp_body = NULL; |
|
| … | 199 unchanged lines |
||
| 264 | 268 | g_resp_ctype = "application/json"; |
|
| 265 | 269 | break; |
|
| 266 | 270 | } |
|
| 271 | + | case REQ_PAD_SET: { |
|
| 272 | + | const char *json; |
|
| 273 | + | if (!g_be->set_pad) { |
|
| 274 | + | json = "{\"error\":\"not implemented\"}"; g_resp_status = 501; |
|
| 275 | + | } else if (g_be->set_pad(g_pad_index, g_pad_buttons, g_pad_connected)) { |
|
| 276 | + | json = "{\"injected\":true}"; g_resp_status = 200; |
|
| 277 | + | } else { |
|
| 278 | + | json = "{\"error\":\"no controller\"}"; g_resp_status = 400; |
|
| 279 | + | } |
|
| 280 | + | size_t l = strlen(json); |
|
| 281 | + | uint8_t *b = (uint8_t *)malloc(l + 1); |
|
| 282 | + | if (b) memcpy(b, json, l + 1); |
|
| 283 | + | g_resp_body = b; g_resp_len = b ? l : 0; |
|
| 284 | + | g_resp_ctype = "application/json"; |
|
| 285 | + | break; |
|
| 286 | + | } |
|
| 287 | + | case REQ_PAD_GET: { |
|
| 288 | + | char *b; |
|
| 289 | + | if (!g_be->get_pad) { |
|
| 290 | + | const char *json = "{\"error\":\"not implemented\"}"; |
|
| 291 | + | size_t l = strlen(json); b = (char *)malloc(l + 1); |
|
| 292 | + | if (b) memcpy(b, json, l + 1); |
|
| 293 | + | g_resp_len = b ? l : 0; g_resp_status = 501; |
|
| 294 | + | } else { |
|
| 295 | + | int buttons = 0, connected = 0; |
|
| 296 | + | int ok = g_be->get_pad(g_pad_index, &buttons, &connected); |
|
| 297 | + | b = (char *)malloc(96); |
|
| 298 | + | if (!b) { g_resp_len = 0; g_resp_status = 500; } |
|
| 299 | + | else if (ok) { |
|
| 300 | + | g_resp_len = (size_t)snprintf(b, 96, |
|
| 301 | + | "{\"index\":%d,\"connected\":%s,\"buttons\":%d}", |
|
| 302 | + | g_pad_index, connected ? "true" : "false", buttons); |
|
| 303 | + | g_resp_status = 200; |
|
| 304 | + | } else { |
|
| 305 | + | g_resp_len = (size_t)snprintf(b, 96, |
|
| 306 | + | "{\"error\":\"no controller\"}"); |
|
| 307 | + | g_resp_status = 400; |
|
| 308 | + | } |
|
| 309 | + | } |
|
| 310 | + | g_resp_body = (uint8_t *)b; |
|
| 311 | + | g_resp_ctype = "application/json"; |
|
| 312 | + | break; |
|
| 313 | + | } |
|
| 267 | 314 | default: break; |
|
| 268 | 315 | } |
|
| 269 | 316 | g_req = REQ_NONE; |
|
| … | 125 unchanged lines |
||
| 395 | 442 | /* Advertise the highest level whose defining callbacks are all present. */ |
|
| 396 | 443 | const char *contract = |
|
| 397 | 444 | (g_be->inject_key && g_be->write_mem && g_be->capture_audio && |
|
| 445 | + | g_be->set_pointer && g_be->get_pointer && |
|
| 446 | + | g_be->set_pad && g_be->get_pad) ? "0.5.0" : |
|
| 447 | + | (g_be->inject_key && g_be->write_mem && g_be->capture_audio && |
|
| 398 | 448 | g_be->set_pointer && g_be->get_pointer) ? "0.4.0" : |
|
| 399 | 449 | (g_be->inject_key && g_be->write_mem && g_be->capture_audio) ? "0.3.0" : |
|
| 400 | 450 | g_be->inject_key ? "0.2.0" : "0.1.0"; |
|
| … | 161 unchanged lines |
||
| 562 | 612 | do_marshalled(fd, REQ_PTR_SET); |
|
| 563 | 613 | } else { /* 0.4: read back the pointer */ |
|
| 564 | 614 | do_marshalled(fd, REQ_PTR_GET); |
|
| 615 | + | } |
|
| 616 | + | return; |
|
| 617 | + | } |
|
| 618 | + | if (!strcmp(target, "/pad")) { |
|
| 619 | + | g_pad_index = (int)query_long(query, "index", 0); |
|
| 620 | + | if (is_post) { /* 0.5: present / set a pad */ |
|
| 621 | + | g_pad_buttons = (int)query_long(query, "buttons", -1); |
|
| 622 | + | /* `connected` defaults to plugging it in whenever buttons were |
|
| 623 | + | * given -- injecting a press on an absent pad is never what the |
|
| 624 | + | * caller meant -- and to leaving it alone otherwise. */ |
|
| 625 | + | g_pad_connected = (int)query_long(query, "connected", |
|
| 626 | + | g_pad_buttons >= 0 ? 1 : -1); |
|
| 627 | + | do_marshalled(fd, REQ_PAD_SET); |
|
| 628 | + | } else { /* 0.5: read a pad back */ |
|
| 629 | + | do_marshalled(fd, REQ_PAD_GET); |
|
| 565 | 630 | } |
|
| 566 | 631 | return; |
|
| 567 | 632 | } |
|
| … | 57 unchanged lines |
modified core/retro_control.h +35 −4
| … | 26 unchanged lines |
||
| 27 | 27 | #include <stddef.h> |
|
| 28 | 28 | ||
| 29 | 29 | /* Highest contract the core implements. The server advertises the highest level |
|
| 30 | - | * whose backend callbacks are all present: 0.4.0 needs set_pointer + get_pointer |
|
| 31 | - | * (on top of 0.3); 0.3.0 needs inject_key + write_mem + capture_audio; 0.2.0 |
|
| 32 | - | * needs inject_key; else 0.1.0. Keeps a partial backend honest. */ |
|
| 33 | - | #define RETRO_CONTROL_CONTRACT "0.4.0" |
|
| 30 | + | * whose backend callbacks are all present: 0.5.0 needs set_pad + get_pad (on |
|
| 31 | + | * top of 0.4); 0.4.0 needs set_pointer + get_pointer (on top of 0.3); 0.3.0 |
|
| 32 | + | * needs inject_key + write_mem + capture_audio; 0.2.0 needs inject_key; else |
|
| 33 | + | * 0.1.0. Keeps a partial backend honest. */ |
|
| 34 | + | #define RETRO_CONTROL_CONTRACT "0.5.0" |
|
| 34 | 35 | ||
| 35 | 36 | /* Native framebuffer pixel layout; the server swizzles to PPM RGB. */ |
|
| 36 | 37 | typedef enum { |
|
| … | 72 unchanged lines |
||
| 109 | 110 | * semantics as set_pointer's `buttons`). Return 1 on success (values |
|
| 110 | 111 | * written), 0 if no pointer is available. NULL => GET /pointer returns 501. */ |
|
| 111 | 112 | int (*get_pointer)(int32_t *x, int32_t *y, int *buttons); |
|
| 113 | + | ||
| 114 | + | /* 0.5: present a virtual game controller at `index` and set what is held. |
|
| 115 | + | * |
|
| 116 | + | * `buttons` is the canonical mask (SPEC "Canonical button layout"): bit0 |
|
| 117 | + | * LEFT, 1 RIGHT, 2 UP, 3 DOWN, 4 A, 5 B, 6 X, 7 Y, 8 START, 9 SELECT, |
|
| 118 | + | * 10 L, 11 R -- the SNES/Neo6502 native order. A backend whose hardware |
|
| 119 | + | * disagrees converts here, so a test never learns which machine it is on. |
|
| 120 | + | * -1 leaves the mask unchanged. |
|
| 121 | + | * |
|
| 122 | + | * `connected` is 1 to present the pad, 0 to unplug it, -1 to leave the |
|
| 123 | + | * presence unchanged. Unplugging is testable behaviour in its own right: |
|
| 124 | + | * a program that asks "is a pad there" before offering two-player needs |
|
| 125 | + | * both answers. |
|
| 126 | + | * |
|
| 127 | + | * A pad is a LEVEL, not an event -- the state persists across frames until |
|
| 128 | + | * changed, unlike inject_key's TAP. |
|
| 129 | + | * |
|
| 130 | + | * MUST drive the same path a real pad takes. On an emulator reading SDL |
|
| 131 | + | * joysticks that means presenting a virtual device the existing read path |
|
| 132 | + | * picks up, NOT calling into SDL: headless CI has no joystick, which is |
|
| 133 | + | * the case this exists for. |
|
| 134 | + | * |
|
| 135 | + | * Return 1 on success, 0 if the platform has no controller support |
|
| 136 | + | * (server -> 400). NULL => POST /pad returns 501. */ |
|
| 137 | + | int (*set_pad)(int index, int buttons, int connected); |
|
| 138 | + | ||
| 139 | + | /* 0.5: report a pad's presence and held mask (same layout as set_pad). |
|
| 140 | + | * Return 1 on success (values written), 0 if unsupported. NULL => GET /pad |
|
| 141 | + | * returns 501. */ |
|
| 142 | + | int (*get_pad)(int index, int *buttons, int *connected); |
|
| 112 | 143 | } retro_control_backend_t; |
|
| 113 | 144 | ||
| 114 | 145 | /* Start the server thread bound to 127.0.0.1:port. Non-blocking; 0 on success, |
|
| … | 20 unchanged lines |