OXYGENHUB
# 45a475bb3fa8 retro-remote-debug-controller / client / emu_control.py
6.1 KiB · Python · last changed client+server: URL-encode key text so it round-trips any character 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
4
../SPEC.md (0.1.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
45a475bb Andy McCall ↶ 23
import urllib.parse
961b3c61 Andy McCall 24
import urllib.request
25
26
CONTRACT_MAJOR = 0
27
28
29
class EmuControlError(RuntimeError):
30
    pass
31
32
8ac19619 Andy McCall ↶ 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
961b3c61 Andy McCall 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) -> bytes:
70
        req = urllib.request.Request(self.base + path, method="POST", data=b"")
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
        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:
90
        return int(self.status()["frame"])
91
92
    def regs(self) -> dict:
93
        body, _ = self._get("/regs")
94
        return json.loads(body)
95
96
    def mem(self, addr: int, length: int, bank: int | None = None) -> bytes:
97
        path = f"/mem?addr={addr}&len={length}"
98
        if bank is not None:
99
            path += f"&bank={bank}"
100
        body, _ = self._get(path)
101
        return body
102
103
    def step(self, frames: int = 1) -> int:
104
        body = self._post(f"/step?frames={frames}")
105
        return int(json.loads(body)["frame"])
106
107
    def pause(self) -> None:
108
        self._post("/pause")
109
110
    def resume(self) -> None:
111
        self._post("/resume")
8ac19619 Andy McCall ↶ 112
d44319b1 Andy McCall ↶ 113
    def key(self, text: str | None = None, code: int | None = None,
114
            down: bool | None = None) -> None:
115
        """Inject a key (contract 0.2). Provide `text` (a single character) or
116
        `code` (a raw platform key code). down=True presses/holds, False
117
        releases, None taps (press then release). Hold across step() frames for
118
        a program that samples input each frame."""
119
        parts = []
120
        if text is not None:
45a475bb Andy McCall ↶ 121
            # Percent-encode so a '+' isn't decoded to a space and '&'/'#'/'%'
122
            # don't truncate or corrupt the value. safe="" encodes '+' too.
123
            parts.append(f"text={urllib.parse.quote(text, safe='')}")
d44319b1 Andy McCall ↶ 124
        if code is not None:
125
            parts.append(f"code={code}")
126
        if down is not None:
127
            parts.append(f"down={1 if down else 0}")
128
        if not parts:
129
            raise EmuControlError("key() needs text= or code=")
130
        self._post("/key?" + "&".join(parts))
131
132
    def reset(self) -> None:
133
        """Soft/cold reset the machine (contract 0.2)."""
134
        self._post("/reset")
135
8ac19619 Andy McCall ↶ 136
    def screenshot_ppm(self) -> bytes:
137
        """Return the live screen as raw PPM (P6) bytes. Stdlib-only."""
138
        body, _ = self._get("/screenshot")
139
        return body
961b3c61 Andy McCall 140
141
    def screenshot(self):
142
        """Return the live screen as a PIL.Image (requires Pillow)."""
8ac19619 Andy McCall ↶ 143
        body = self.screenshot_ppm()
961b3c61 Andy McCall 144
        try:
145
            from PIL import Image
146
        except ImportError as e:  # pragma: no cover
147
            raise EmuControlError("screenshot() needs Pillow (pip install pillow)") from e
148
        return Image.open(io.BytesIO(body))
149
150
    # -- helpers -----------------------------------------------------------
151
    def wait_ready(self, tries: int = 100, delay: float = 0.05) -> dict:
152
        """Poll /status until the server answers (emulator boot race)."""
153
        last = None
154
        for _ in range(tries):
155
            try:
156
                return self.status()
157
            except (EmuControlError, urllib.error.URLError, ConnectionError) as e:
158
                last = e
159
                time.sleep(delay)
160
        raise EmuControlError(f"emulator control port never came up: {last}")
161
162
    def run_to_frame(self, target: int) -> int:
163
        """Step until the frame counter reaches >= target."""
164
        cur = self.frame()
165
        if target > cur:
166
            self.step(frames=target - cur)
167
        return self.frame()