OXYGENHUB
◆ main retro-remote-debug-controller / client / emu_control.py
11.8 KiB · Python · last changed feat: contract 0.6 — GET/POST /perf, the performance metrics (budget, current frame, windows, zones, raw frames since one, a platform section) and their settings, and /status.capabilities; backend callbacks get_perf and set_perf return allocated JSON (#6) 25 days ago source blame history raw
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.6.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.parse
24
import urllib.request
25
26
CONTRACT_MAJOR = 0
27
28
29
class EmuControlError(RuntimeError):
30
    pass
31
32
33
def parse_ppm_header(data: bytes):
34
    """Parse a binary PPM (P6) header. Returns (width, height, maxval, offset)
35
    where offset is the index of the first pixel byte. Raises on malformed
36
    input. Stdlib-only — no Pillow needed."""
37
    if data[:2] != b"P6":
38
        raise EmuControlError(f"not a P6 PPM (magic {data[:2]!r})")
39
    fields, i, n = [], 2, len(data)
40
    while len(fields) < 3 and i < n:
41
        while i < n and data[i:i + 1].isspace():
42
            i += 1
43
        if i < n and data[i:i + 1] == b"#":            # comment to end of line
44
            while i < n and data[i:i + 1] not in (b"\n", b"\r"):
45
                i += 1
46
            continue
47
        start = i
48
        while i < n and not data[i:i + 1].isspace():
49
            i += 1
50
        fields.append(int(data[start:i]))
51
    if len(fields) < 3:
52
        raise EmuControlError("truncated PPM header")
53
    return fields[0], fields[1], fields[2], i + 1        # +1: single whitespace
54
55
56
class EmuControl:
57
    def __init__(self, port: int, host: str = "127.0.0.1", timeout: float = 5.0):
58
        self.base = f"http://{host}:{port}"
59
        self.timeout = timeout
60
61
    # -- transport ---------------------------------------------------------
62
    def _get(self, path: str) -> tuple[bytes, str]:
63
        try:
64
            with urllib.request.urlopen(self.base + path, timeout=self.timeout) as r:
65
                return r.read(), r.headers.get_content_type()
66
        except urllib.error.HTTPError as e:
67
            raise EmuControlError(f"GET {path} -> {e.code}: {e.read()!r}") from e
68
69
    def _post(self, path: str, body: bytes = b"") -> bytes:
70
        req = urllib.request.Request(self.base + path, method="POST", data=body)
71
        try:
72
            with urllib.request.urlopen(req, timeout=self.timeout) as r:
73
                return r.read()
74
        except urllib.error.HTTPError as e:
75
            raise EmuControlError(f"POST {path} -> {e.code}: {e.read()!r}") from e
76
77
    # -- endpoints ---------------------------------------------------------
78
    def status(self) -> dict:
79
        """GET /status — liveness + contract dict; raises on a MAJOR mismatch."""
80
        body, _ = self._get("/status")
81
        st = json.loads(body)
82
        major = int(str(st.get("contract", "0")).split(".")[0])
83
        if major != CONTRACT_MAJOR:
84
            raise EmuControlError(
85
                f"contract major mismatch: emulator {st.get('contract')} "
86
                f"vs client {CONTRACT_MAJOR}.x"
87
            )
88
        return st
89
90
    def frame(self) -> int:
91
        """Current completed-frame counter (the determinism anchor)."""
92
        return int(self.status()["frame"])
93
94
    def regs(self) -> dict:
95
        """GET /regs — CPU registers as a dict (keys are platform-specific)."""
96
        body, _ = self._get("/regs")
97
        return json.loads(body)
98
99
    def mem(self, addr: int, length: int, bank: int | None = None) -> bytes:
100
        """GET /mem — read `length` bytes at `addr` (optional `bank`)."""
101
        path = f"/mem?addr={addr}&len={length}"
102
        if bank is not None:
103
            path += f"&bank={bank}"
104
        body, _ = self._get(path)
105
        return body
106
107
    def step(self, frames: int = 1) -> int:
108
        """POST /step — advance `frames` complete frames, halt; returns the new
109
        frame counter. Meaningful once paused."""
110
        body = self._post(f"/step?frames={frames}")
111
        return int(json.loads(body)["frame"])
112
113
    def pause(self) -> None:
114
        """POST /pause — halt the machine (advances only via step())."""
115
        self._post("/pause")
116
117
    def resume(self) -> None:
118
        """POST /resume — return the machine to free-running."""
119
        self._post("/resume")
120
121
    def key(self, text: str | None = None, code: int | None = None,
122
            down: bool | None = None) -> None:
123
        """Inject a key (contract 0.2). Provide `text` (a single character) or
124
        `code` (a raw platform key code). down=True presses/holds, False
125
        releases, None taps (press then release). Hold across step() frames for
126
        a program that samples input each frame."""
127
        parts = []
128
        if text is not None:
129
            # Percent-encode so a '+' isn't decoded to a space and '&'/'#'/'%'
130
            # don't truncate or corrupt the value. safe="" encodes '+' too.
131
            parts.append(f"text={urllib.parse.quote(text, safe='')}")
132
        if code is not None:
133
            parts.append(f"code={code}")
134
        if down is not None:
135
            parts.append(f"down={1 if down else 0}")
136
        if not parts:
137
            raise EmuControlError("key() needs text= or code=")
138
        self._post("/key?" + "&".join(parts))
139
140
    def reset(self) -> None:
141
        """Soft/cold reset the machine (contract 0.2)."""
142
        self._post("/reset")
143
144
    def mem_write(self, addr: int, data: bytes, bank: int | None = None) -> int:
145
        """Write/poke bytes at addr (contract 0.3). Returns the number written."""
146
        path = f"/mem?addr={addr}"
147
        if bank is not None:
148
            path += f"&bank={bank}"
149
        return int(json.loads(self._post(path, bytes(data)))["written"])
150
151
    def audio(self) -> bytes:
152
        """Raw WAV (PCM) of audio synthesized since the last call (contract 0.3).
153
        Drain regularly (e.g. after each step) so the ring doesn't overflow."""
154
        body, _ = self._get("/audio")
155
        return body
156
157
    def pointer(self, x: int | None = None, y: int | None = None,
158
                dx: int | None = None, dy: int | None = None,
159
                buttons: int | None = None) -> None:
160
        """Inject a pointer move/click (contract 0.4). Give absolute (x, y) or
161
        relative (dx, dy). `buttons` is a bitmask (bit0=primary, bit1=secondary);
162
        omit it for a pure move (leaves the button state unchanged). Hold buttons
163
        across step() frames for a program that samples the pointer each frame."""
164
        parts = []
165
        if x is not None and y is not None:
166
            parts.append(f"x={x}")
167
            parts.append(f"y={y}")
168
        elif dx is not None or dy is not None:
169
            parts.append(f"dx={dx if dx is not None else 0}")
170
            parts.append(f"dy={dy if dy is not None else 0}")
171
        else:
172
            raise EmuControlError("pointer() needs x= and y=, or dx=/dy=")
173
        if buttons is not None:
174
            parts.append(f"buttons={buttons}")
175
        self._post("/pointer?" + "&".join(parts))
176
177
    def pointer_get(self) -> dict:
178
        """GET /pointer — the current pointer as {x, y, buttons} (contract 0.4).
179
        `buttons` uses the same bitmask as pointer()."""
180
        body, _ = self._get("/pointer")
181
        return json.loads(body)
182
183
    # Canonical pad bitmask (SPEC "Canonical button layout"). The SNES /
184
    # Neo6502 native order, fixed across platforms so a test written on one
185
    # machine reads the same on another.
186
    PAD_LEFT   = 0x0001
187
    PAD_RIGHT  = 0x0002
188
    PAD_UP     = 0x0004
189
    PAD_DOWN   = 0x0008
190
    PAD_A      = 0x0010
191
    PAD_B      = 0x0020
192
    PAD_X      = 0x0040
193
    PAD_Y      = 0x0080
194
    PAD_START  = 0x0100
195
    PAD_SELECT = 0x0200
196
    PAD_L      = 0x0400
197
    PAD_R      = 0x0800
198
199
    def pad(self, buttons: int | None = None, index: int = 0,
200
            connected: bool | None = None) -> None:
201
        """Present a virtual controller and set what is held (contract 0.5).
202
203
        `buttons` is a mask of PAD_* — omit to leave the held state alone.
204
        `connected=False` unplugs the pad, which is testable behaviour in its
205
        own right: a program that asks whether a controller is present before
206
        offering two-player needs both answers.
207
208
        Unlike key(), a pad is a LEVEL: the state persists across step()
209
        frames until changed, so a press does not need re-asserting. Release
210
        with pad(0)."""
211
        parts = [f"index={index}"]
212
        if buttons is not None:
213
            parts.append(f"buttons={buttons}")
214
        if connected is not None:
215
            parts.append(f"connected={1 if connected else 0}")
216
        self._post("/pad?" + "&".join(parts))
217
218
    def pad_get(self, index: int = 0) -> dict:
219
        """GET /pad — {index, connected, buttons} (contract 0.5). Distinguishes
220
        'the input never arrived' from 'it arrived and was ignored'."""
221
        body, _ = self._get(f"/pad?index={index}")
222
        return json.loads(body)
223
224
    def capabilities(self) -> set[str]:
225
        """/status.capabilities as a set (contract 0.6); empty on older
226
        servers, which have no such field."""
227
        return set(self.status().get("capabilities") or ())
228
229
    def perf(self, windows=None, zones: bool = True, frames: int = 0,
230
             since: int | None = None, **extra) -> dict:
231
        """GET /perf — the performance metrics (contract 0.6).
232
233
        `windows` is a list of seconds or "session" (default: the server's,
234
        1, 10, 30, 60 and session). `frames=n` adds the last n frames' raw
235
        records; `since=f` every frame after f still in the history (pass the
236
        last frame you saw to never miss one). `extra` goes on the query
237
        string as it is (a platform's own params, e.g. lines=1 on the X16)."""
238
        parts = []
239
        if windows is not None:
240
            parts.append("windows=" + ",".join(str(w) for w in windows))
241
        if not zones:
242
            parts.append("zones=0")
243
        if frames:
244
            parts.append(f"frames={frames}")
245
        if since is not None:
246
            parts.append(f"since={since}")
247
        parts += [f"{k}={v}" for k, v in extra.items()]
248
        body, _ = self._get("/perf" + ("?" + "&".join(parts) if parts else ""))
249
        return json.loads(body)
250
251
    def perf_config(self, **settings) -> dict:
252
        """POST /perf — settings (contract 0.6): enabled, targetFps,
253
        idleMode, historyFrames, reset, zones (a list of {"name", "marker"} or
254
        {"name", "start", "end", "bank", "idle"}; replaces the list), and a
255
        platform's own (the X16: sprites). Returns the settings as they stand."""
256
        return json.loads(self._post("/perf", json.dumps(settings).encode()))
257
258
    def screenshot_ppm(self) -> bytes:
259
        """Return the live screen as raw PPM (P6) bytes. Stdlib-only."""
260
        body, _ = self._get("/screenshot")
261
        return body
262
263
    def screenshot(self):
264
        """Return the live screen as a PIL.Image (requires Pillow)."""
265
        body = self.screenshot_ppm()
266
        try:
267
            from PIL import Image
268
        except ImportError as e:  # pragma: no cover
269
            raise EmuControlError("screenshot() needs Pillow (pip install pillow)") from e
270
        return Image.open(io.BytesIO(body))
271
272
    # -- helpers -----------------------------------------------------------
273
    def wait_ready(self, tries: int = 100, delay: float = 0.05) -> dict:
274
        """Poll /status until the server answers (emulator boot race)."""
275
        last = None
276
        for _ in range(tries):
277
            try:
278
                return self.status()
279
            except (EmuControlError, urllib.error.URLError, ConnectionError) as e:
280
                last = e
281
                time.sleep(delay)
282
        raise EmuControlError(f"emulator control port never came up: {last}")
283
284
    def run_to_frame(self, target: int) -> int:
285
        """Step until the frame counter reaches >= target."""
286
        cur = self.frame()
287
        if target > cur:
288
            self.step(frames=target - cur)
289
        return self.frame()