OXYGENHUB
# bed0f1e524fe retro-remote-debug-controller / client / emu_control.py
9.9 KiB · Python · last changed 0.5.0: game controller injection (POST/GET /pad) 3 months ago source blame history raw
961b3c61 Andy McCall 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
346ca3ee Andy McCall ↶ 4
../SPEC.md (0.4.x). Depends only on the stdlib; Pillow is optional and only
961b3c61 Andy McCall 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
8ac19619 Andy McCall ↶ 32
def parse_ppm_header(data: bytes):
33
    """Parse a binary PPM (P6) header. Returns (width, height, maxval, offset)
34
    where offset is the index of the first pixel byte. Raises on malformed
35
    input. Stdlib-only — no Pillow needed."""
36
    if data[:2] != b"P6":
37
        raise EmuControlError(f"not a P6 PPM (magic {data[:2]!r})")
38
    fields, i, n = [], 2, len(data)
39
    while len(fields) < 3 and i < n:
40
        while i < n and data[i:i + 1].isspace():
41
            i += 1
42
        if i < n and data[i:i + 1] == b"#":            # comment to end of line
43
            while i < n and data[i:i + 1] not in (b"\n", b"\r"):
44
                i += 1
45
            continue
46
        start = i
47
        while i < n and not data[i:i + 1].isspace():
48
            i += 1
49
        fields.append(int(data[start:i]))
50
    if len(fields) < 3:
51
        raise EmuControlError("truncated PPM header")
52
    return fields[0], fields[1], fields[2], i + 1        # +1: single whitespace
53
54
961b3c61 Andy McCall 55
class EmuControl:
56
    def __init__(self, port: int, host: str = "127.0.0.1", timeout: float = 5.0):
57
        self.base = f"http://{host}:{port}"
58
        self.timeout = timeout
59
60
    # -- transport ---------------------------------------------------------
61
    def _get(self, path: str) -> tuple[bytes, str]:
62
        try:
63
            with urllib.request.urlopen(self.base + path, timeout=self.timeout) as r:
64
                return r.read(), r.headers.get_content_type()
65
        except urllib.error.HTTPError as e:
66
            raise EmuControlError(f"GET {path} -> {e.code}: {e.read()!r}") from e
67
674b8447 Andy McCall ↶ 68
    def _post(self, path: str, body: bytes = b"") -> bytes:
69
        req = urllib.request.Request(self.base + path, method="POST", data=body)
961b3c61 Andy McCall 70
        try:
71
            with urllib.request.urlopen(req, timeout=self.timeout) as r:
72
                return r.read()
73
        except urllib.error.HTTPError as e:
74
            raise EmuControlError(f"POST {path} -> {e.code}: {e.read()!r}") from e
75
76
    # -- endpoints ---------------------------------------------------------
77
    def status(self) -> dict:
4c0b050e Andy McCall ↶ 78
        """GET /status — liveness + contract dict; raises on a MAJOR mismatch."""
961b3c61 Andy McCall 79
        body, _ = self._get("/status")
80
        st = json.loads(body)
81
        major = int(str(st.get("contract", "0")).split(".")[0])
82
        if major != CONTRACT_MAJOR:
83
            raise EmuControlError(
84
                f"contract major mismatch: emulator {st.get('contract')} "
85
                f"vs client {CONTRACT_MAJOR}.x"
86
            )
87
        return st
88
89
    def frame(self) -> int:
4c0b050e Andy McCall ↶ 90
        """Current completed-frame counter (the determinism anchor)."""
961b3c61 Andy McCall 91
        return int(self.status()["frame"])
92
93
    def regs(self) -> dict:
4c0b050e Andy McCall ↶ 94
        """GET /regs — CPU registers as a dict (keys are platform-specific)."""
961b3c61 Andy McCall 95
        body, _ = self._get("/regs")
96
        return json.loads(body)
97
98
    def mem(self, addr: int, length: int, bank: int | None = None) -> bytes:
4c0b050e Andy McCall ↶ 99
        """GET /mem — read `length` bytes at `addr` (optional `bank`)."""
961b3c61 Andy McCall 100
        path = f"/mem?addr={addr}&len={length}"
101
        if bank is not None:
102
            path += f"&bank={bank}"
103
        body, _ = self._get(path)
104
        return body
105
106
    def step(self, frames: int = 1) -> int:
4c0b050e Andy McCall ↶ 107
        """POST /step — advance `frames` complete frames, halt; returns the new
108
        frame counter. Meaningful once paused."""
961b3c61 Andy McCall 109
        body = self._post(f"/step?frames={frames}")
110
        return int(json.loads(body)["frame"])
111
112
    def pause(self) -> None:
4c0b050e Andy McCall ↶ 113
        """POST /pause — halt the machine (advances only via step())."""
961b3c61 Andy McCall 114
        self._post("/pause")
115
116
    def resume(self) -> None:
4c0b050e Andy McCall ↶ 117
        """POST /resume — return the machine to free-running."""
961b3c61 Andy McCall 118
        self._post("/resume")
8ac19619 Andy McCall ↶ 119
d44319b1 Andy McCall ↶ 120
    def key(self, text: str | None = None, code: int | None = None,
121
            down: bool | None = None) -> None:
122
        """Inject a key (contract 0.2). Provide `text` (a single character) or
123
        `code` (a raw platform key code). down=True presses/holds, False
124
        releases, None taps (press then release). Hold across step() frames for
125
        a program that samples input each frame."""
126
        parts = []
127
        if text is not None:
128
            parts.append(f"text={text}")
129
        if code is not None:
130
            parts.append(f"code={code}")
131
        if down is not None:
132
            parts.append(f"down={1 if down else 0}")
133
        if not parts:
134
            raise EmuControlError("key() needs text= or code=")
135
        self._post("/key?" + "&".join(parts))
136
137
    def reset(self) -> None:
138
        """Soft/cold reset the machine (contract 0.2)."""
139
        self._post("/reset")
140
674b8447 Andy McCall ↶ 141
    def mem_write(self, addr: int, data: bytes, bank: int | None = None) -> int:
142
        """Write/poke bytes at addr (contract 0.3). Returns the number written."""
143
        path = f"/mem?addr={addr}"
144
        if bank is not None:
145
            path += f"&bank={bank}"
146
        return int(json.loads(self._post(path, bytes(data)))["written"])
147
148
    def audio(self) -> bytes:
149
        """Raw WAV (PCM) of audio synthesized since the last call (contract 0.3).
150
        Drain regularly (e.g. after each step) so the ring doesn't overflow."""
151
        body, _ = self._get("/audio")
152
        return body
153
346ca3ee Andy McCall ↶ 154
    def pointer(self, x: int | None = None, y: int | None = None,
155
                dx: int | None = None, dy: int | None = None,
156
                buttons: int | None = None) -> None:
157
        """Inject a pointer move/click (contract 0.4). Give absolute (x, y) or
158
        relative (dx, dy). `buttons` is a bitmask (bit0=primary, bit1=secondary);
159
        omit it for a pure move (leaves the button state unchanged). Hold buttons
160
        across step() frames for a program that samples the pointer each frame."""
161
        parts = []
162
        if x is not None and y is not None:
163
            parts.append(f"x={x}")
164
            parts.append(f"y={y}")
165
        elif dx is not None or dy is not None:
166
            parts.append(f"dx={dx if dx is not None else 0}")
167
            parts.append(f"dy={dy if dy is not None else 0}")
168
        else:
169
            raise EmuControlError("pointer() needs x= and y=, or dx=/dy=")
170
        if buttons is not None:
171
            parts.append(f"buttons={buttons}")
172
        self._post("/pointer?" + "&".join(parts))
173
174
    def pointer_get(self) -> dict:
175
        """GET /pointer — the current pointer as {x, y, buttons} (contract 0.4).
176
        `buttons` uses the same bitmask as pointer()."""
177
        body, _ = self._get("/pointer")
178
        return json.loads(body)
179
bed0f1e5 Andy McCall ↶ 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
8ac19619 Andy McCall ↶ 221
    def screenshot_ppm(self) -> bytes:
222
        """Return the live screen as raw PPM (P6) bytes. Stdlib-only."""
223
        body, _ = self._get("/screenshot")
224
        return body
961b3c61 Andy McCall 225
226
    def screenshot(self):
227
        """Return the live screen as a PIL.Image (requires Pillow)."""
8ac19619 Andy McCall ↶ 228
        body = self.screenshot_ppm()
961b3c61 Andy McCall 229
        try:
230
            from PIL import Image
231
        except ImportError as e:  # pragma: no cover
232
            raise EmuControlError("screenshot() needs Pillow (pip install pillow)") from e
233
        return Image.open(io.BytesIO(body))
234
235
    # -- helpers -----------------------------------------------------------
236
    def wait_ready(self, tries: int = 100, delay: float = 0.05) -> dict:
237
        """Poll /status until the server answers (emulator boot race)."""
238
        last = None
239
        for _ in range(tries):
240
            try:
241
                return self.status()
242
            except (EmuControlError, urllib.error.URLError, ConnectionError) as e:
243
                last = e
244
                time.sleep(delay)
245
        raise EmuControlError(f"emulator control port never came up: {last}")
246
247
    def run_to_frame(self, target: int) -> int:
248
        """Step until the frame counter reaches >= target."""
249
        cur = self.frame()
250
        if target > cur:
251
            self.step(frames=target - cur)
252
        return self.frame()