OXYGENHUB

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()