OXYGENHUB
# d69f9ef6bbac retro-remote-debug-controller / client / emu_control.py
6.5 KiB · Python · last changed Contract 0.3.0: memory write (POST /mem) + audio drain (GET /audio) 3 months 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.3.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.request
24
25
CONTRACT_MAJOR = 0
26
27
28
class EmuControlError(RuntimeError):
29
    pass
30
31
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
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
68
    def _post(self, path: str, body: bytes = b"") -> bytes:
69
        req = urllib.request.Request(self.base + path, method="POST", data=body)
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:
78
        body, _ = self._get("/status")
79
        st = json.loads(body)
80
        major = int(str(st.get("contract", "0")).split(".")[0])
81
        if major != CONTRACT_MAJOR:
82
            raise EmuControlError(
83
                f"contract major mismatch: emulator {st.get('contract')} "
84
                f"vs client {CONTRACT_MAJOR}.x"
85
            )
86
        return st
87
88
    def frame(self) -> int:
89
        return int(self.status()["frame"])
90
91
    def regs(self) -> dict:
92
        body, _ = self._get("/regs")
93
        return json.loads(body)
94
95
    def mem(self, addr: int, length: int, bank: int | None = None) -> bytes:
96
        path = f"/mem?addr={addr}&len={length}"
97
        if bank is not None:
98
            path += f"&bank={bank}"
99
        body, _ = self._get(path)
100
        return body
101
102
    def step(self, frames: int = 1) -> int:
103
        body = self._post(f"/step?frames={frames}")
104
        return int(json.loads(body)["frame"])
105
106
    def pause(self) -> None:
107
        self._post("/pause")
108
109
    def resume(self) -> None:
110
        self._post("/resume")
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
133
    def mem_write(self, addr: int, data: bytes, bank: int | None = None) -> int:
134
        """Write/poke bytes at addr (contract 0.3). Returns the number written."""
135
        path = f"/mem?addr={addr}"
136
        if bank is not None:
137
            path += f"&bank={bank}"
138
        return int(json.loads(self._post(path, bytes(data)))["written"])
139
140
    def audio(self) -> bytes:
141
        """Raw WAV (PCM) of audio synthesized since the last call (contract 0.3).
142
        Drain regularly (e.g. after each step) so the ring doesn't overflow."""
143
        body, _ = self._get("/audio")
144
        return body
145
146
    def screenshot_ppm(self) -> bytes:
147
        """Return the live screen as raw PPM (P6) bytes. Stdlib-only."""
148
        body, _ = self._get("/screenshot")
149
        return body
150
151
    def screenshot(self):
152
        """Return the live screen as a PIL.Image (requires Pillow)."""
153
        body = self.screenshot_ppm()
154
        try:
155
            from PIL import Image
156
        except ImportError as e:  # pragma: no cover
157
            raise EmuControlError("screenshot() needs Pillow (pip install pillow)") from e
158
        return Image.open(io.BytesIO(body))
159
160
    # -- helpers -----------------------------------------------------------
161
    def wait_ready(self, tries: int = 100, delay: float = 0.05) -> dict:
162
        """Poll /status until the server answers (emulator boot race)."""
163
        last = None
164
        for _ in range(tries):
165
            try:
166
                return self.status()
167
            except (EmuControlError, urllib.error.URLError, ConnectionError) as e:
168
                last = e
169
                time.sleep(delay)
170
        raise EmuControlError(f"emulator control port never came up: {last}")
171
172
    def run_to_frame(self, target: int) -> int:
173
        """Step until the frame counter reaches >= target."""
174
        cur = self.frame()
175
        if target > cur:
176
            self.step(frames=target - cur)
177
        return self.frame()