Contract 0.2.0: input injection (POST /key, POST /reset) d44319b1e712
Promotes the reserved /key + /reset paths to a normative, additive 0.2.0.
Purely additive, so 0.1 clients keep working; servers advertise their level
via /status.contract and the conformance tests skip on < 0.2.
- POST /key?text=<char>|code=<n>[&down=0|1] — query params (like /mem, /step),
no body. text maps a character to the platform keyboard; code is a raw
platform key code; down=1 holds, 0 releases, omitted taps. Holding across
/step frames is what lets a program that samples input each frame see it.
Response {"injected":true}; missing/unmapped key -> 400.
- POST /reset — soft/cold reset; response {"reset":true}. The one case
/status.frame may go backwards.
- client/emu_control.py: EmuControl.key()/reset()
- conformance/: test_key_injection, test_key_requires_post, test_reset
(version-gated; a 0.1 server skips them)
- SPEC.md: Agon appendix documents the fabgl vkey / ascii2vk mapping
The FAB Agon emulator implements and passes 0.2 conformance 12/12.
Git-Commit: 2105f2b6d41ba1df3f918eb31eca24134f9e3cce
Git-Author: Andy McCall <[email protected]>
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-07-19 19:30 UTC
- Signature
- ◈ sealed by Andy McCall
- Hash
d44319b1e712ffc17c3b35abeba39046ba94fe6a5336d7b91065ac35b7dfa631- Depends on
- 42c217d9aaf7 8ac19619e24b 961b3c610c66
- Depended on by
- 8fd33bc520f9 bed0f1e524fe 45a475bb3fa8 79333fa1febe 346ca3ee83f4 4c0b050e9a25 67065eaa1a13 674b8447022b
- In bookmarks
- main perf-6
- Edits
- 10 atoms, 0 tree operations, 3 files
Browse the tree at this change
modified SPEC.md +26 −9
| 1 | 1 | # Retro Remote Debug Controller — HTTP Control Contract |
|
| 2 | 2 | ||
| 3 | - | **Contract version: 0.1.0** (semver; clients assert on the MAJOR). |
|
| 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`. |
|
| 4 | 6 | ||
| 5 | 7 | A minimal, portable HTTP API that a retro-platform emulator exposes so an |
|
| 6 | 8 | 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. |
|
| 9 | + | machine deterministically, and inject input. The same contract is implemented |
|
| 10 | + | by the forked FAB Agon Emulator, the Neo6502 emulator, and the Commander X16 |
|
| 11 | + | emulator, so **one** pytest harness drives all three ports. |
|
| 10 | 12 | ||
| 11 | 13 | ## Principles |
|
| 12 | 14 | ||
| … | 84 unchanged lines |
||
| 97 | 99 | `--control-pause`-style option SHOULD boot paused so tests are deterministic |
|
| 98 | 100 | from frame 0. |
|
| 99 | 101 | ||
| 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. |
|
| 102 | + | ### `POST /key?text=<char> | ?code=<n> [&down=0|1]` *(0.2)* |
|
| 103 | + | Inject a key. Query params (like `/mem` and `/step`), no body: |
|
| 104 | + | - `text` — a single character, mapped to the platform's keyboard. |
|
| 105 | + | - `code` — a raw platform key code (e.g. fabgl virtual key on Agon, an SDL/PS2 |
|
| 106 | + | code on others). `text` and `code` are alternatives; provide one. |
|
| 107 | + | - `down` — `1` presses (holds), `0` releases; **omitted taps** (press then |
|
| 108 | + | release). Holding across `/step` frames is how a program that samples input |
|
| 109 | + | each frame (e.g. a game) actually sees the key. |
|
| 110 | + | - Response: `{ "injected": true }`. Missing/unmapped key → 400. |
|
| 105 | 111 | ||
| 112 | + | Injection plus `/step` retires scripted-autoexec input: press → step → assert → |
|
| 113 | + | release, deterministically. |
|
| 114 | + | ||
| 115 | + | ### `POST /reset` *(0.2)* |
|
| 116 | + | Soft/cold reset the machine. Response `{ "reset": true }`. The `frame` counter |
|
| 117 | + | MAY reset here (the one case `/status.frame` is allowed to go backwards). |
|
| 118 | + | ||
| 106 | 119 | ## Consistency (normative) |
|
| 107 | 120 | ||
| 108 | 121 | - `/mem`, `/regs`, and `/step` MUST be serviced on the emulator thread at an |
|
| … | 47 unchanged lines |
||
| 156 | 169 | ``` |
|
| 157 | 170 | - Framebuffer: from `vgabuf` (the VDP output); the offscreen path already keeps |
|
| 158 | 171 | video alive (the FAB equivalent of x16's `SDL_VIDEODRIVER=dummy`). |
|
| 172 | + | - Input: `/key?text=c` maps the char to a fabgl virtual key (`ascii2vk`) and |
|
| 173 | + | delivers it via `sendVKeyEventToFabgl`; `?code=<vk>` passes a raw fabgl vkey. |
|
| 174 | + | The control thread queues events; the render thread delivers them (same |
|
| 175 | + | thread as normal keyboard input). `/reset` triggers the soft-reset atomic. |
|
| 159 | 176 | ||
| 160 | 177 | --- |
|
| 161 | 178 | ||
| … | 21 unchanged lines |
modified client/emu_control.py +21 −0
| … | 108 unchanged lines |
||
| 109 | 109 | def resume(self) -> None: |
|
| 110 | 110 | self._post("/resume") |
|
| 111 | 111 | ||
| 112 | + | def key(self, text: str | None = None, code: int | None = None, |
|
| 113 | + | down: bool | None = None) -> None: |
|
| 114 | + | """Inject a key (contract 0.2). Provide `text` (a single character) or |
|
| 115 | + | `code` (a raw platform key code). down=True presses/holds, False |
|
| 116 | + | releases, None taps (press then release). Hold across step() frames for |
|
| 117 | + | a program that samples input each frame.""" |
|
| 118 | + | parts = [] |
|
| 119 | + | if text is not None: |
|
| 120 | + | parts.append(f"text={text}") |
|
| 121 | + | if code is not None: |
|
| 122 | + | parts.append(f"code={code}") |
|
| 123 | + | if down is not None: |
|
| 124 | + | parts.append(f"down={1 if down else 0}") |
|
| 125 | + | if not parts: |
|
| 126 | + | raise EmuControlError("key() needs text= or code=") |
|
| 127 | + | self._post("/key?" + "&".join(parts)) |
|
| 128 | + | ||
| 129 | + | def reset(self) -> None: |
|
| 130 | + | """Soft/cold reset the machine (contract 0.2).""" |
|
| 131 | + | self._post("/reset") |
|
| 132 | + | ||
| 112 | 133 | def screenshot_ppm(self) -> bytes: |
|
| 113 | 134 | """Return the live screen as raw PPM (P6) bytes. Stdlib-only.""" |
|
| 114 | 135 | body, _ = self._get("/screenshot") |
|
| … | 29 unchanged lines |
modified conformance/test_conformance.py +39 −0
| … | 3 unchanged lines |
||
| 4 | 4 | import urllib.error |
|
| 5 | 5 | import urllib.request |
|
| 6 | 6 | ||
| 7 | + | import pytest |
|
| 8 | + | ||
| 7 | 9 | from emu_control import parse_ppm_header |
|
| 8 | 10 | ||
| 9 | 11 | ||
| … | 71 unchanged lines |
||
| 81 | 83 | ||
| 82 | 84 | def test_step_requires_post(emu): |
|
| 83 | 85 | code, _ = _raw(emu, "GET", "/step?frames=1") |
|
| 86 | + | assert code == 405 |
|
| 87 | + | ||
| 88 | + | ||
| 89 | + | def _contract_minor(emu) -> int: |
|
| 90 | + | return int(str(emu.status()["contract"]).split(".")[1]) |
|
| 91 | + | ||
| 92 | + | ||
| 93 | + | # -- contract 0.2: input injection (skipped on 0.1 servers) ------------------ |
|
| 94 | + | ||
| 95 | + | def test_key_injection(emu): |
|
| 96 | + | """/key accepts text= and code= with an optional down=, and rejects a |
|
| 97 | + | request with neither. The key's *effect* is platform/program-specific and |
|
| 98 | + | tested per-port; here we assert only the contract surface.""" |
|
| 99 | + | if _contract_minor(emu) < 2: |
|
| 100 | + | pytest.skip("contract < 0.2 (no input injection)") |
|
| 101 | + | emu.key(text="a", down=True) |
|
| 102 | + | emu.key(text="a", down=False) |
|
| 103 | + | emu.key(text="a") # tap (press then release) |
|
| 104 | + | emu.key(code=32, down=True) |
|
| 105 | + | emu.key(code=32, down=False) |
|
| 106 | + | code, _ = _raw(emu, "POST", "/key") # neither text nor code |
|
| 107 | + | assert code == 400 |
|
| 108 | + | ||
| 109 | + | ||
| 110 | + | def test_key_requires_post(emu): |
|
| 111 | + | if _contract_minor(emu) < 2: |
|
| 112 | + | pytest.skip("contract < 0.2") |
|
| 113 | + | code, _ = _raw(emu, "GET", "/key?text=a") |
|
| 114 | + | assert code == 405 |
|
| 115 | + | ||
| 116 | + | ||
| 117 | + | def test_reset(emu): |
|
| 118 | + | """/reset responds and is POST-only. Runs last (it restarts the machine).""" |
|
| 119 | + | if _contract_minor(emu) < 2: |
|
| 120 | + | pytest.skip("contract < 0.2") |
|
| 121 | + | code, _ = _raw(emu, "GET", "/reset") |
|
| 84 | 122 | assert code == 405 |
|
| 123 | + | emu.reset() |