OXYGENHUB
# 961b3c610c66 retro-remote-debug-controller / client / emu_control.py
3.9 KiB · Python · last changed HTTP control contract, portable server core, and shared client 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.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
23
import urllib.request
24
25
CONTRACT_MAJOR = 0
26
27
28
class EmuControlError(RuntimeError):
29
    pass
30
31
32
class EmuControl:
33
    def __init__(self, port: int, host: str = "127.0.0.1", timeout: float = 5.0):
34
        self.base = f"http://{host}:{port}"
35
        self.timeout = timeout
36
37
    # -- transport ---------------------------------------------------------
38
    def _get(self, path: str) -> tuple[bytes, str]:
39
        try:
40
            with urllib.request.urlopen(self.base + path, timeout=self.timeout) as r:
41
                return r.read(), r.headers.get_content_type()
42
        except urllib.error.HTTPError as e:
43
            raise EmuControlError(f"GET {path} -> {e.code}: {e.read()!r}") from e
44
45
    def _post(self, path: str) -> bytes:
46
        req = urllib.request.Request(self.base + path, method="POST", data=b"")
47
        try:
48
            with urllib.request.urlopen(req, timeout=self.timeout) as r:
49
                return r.read()
50
        except urllib.error.HTTPError as e:
51
            raise EmuControlError(f"POST {path} -> {e.code}: {e.read()!r}") from e
52
53
    # -- endpoints ---------------------------------------------------------
54
    def status(self) -> dict:
55
        body, _ = self._get("/status")
56
        st = json.loads(body)
57
        major = int(str(st.get("contract", "0")).split(".")[0])
58
        if major != CONTRACT_MAJOR:
59
            raise EmuControlError(
60
                f"contract major mismatch: emulator {st.get('contract')} "
61
                f"vs client {CONTRACT_MAJOR}.x"
62
            )
63
        return st
64
65
    def frame(self) -> int:
66
        return int(self.status()["frame"])
67
68
    def regs(self) -> dict:
69
        body, _ = self._get("/regs")
70
        return json.loads(body)
71
72
    def mem(self, addr: int, length: int, bank: int | None = None) -> bytes:
73
        path = f"/mem?addr={addr}&len={length}"
74
        if bank is not None:
75
            path += f"&bank={bank}"
76
        body, _ = self._get(path)
77
        return body
78
79
    def step(self, frames: int = 1) -> int:
80
        body = self._post(f"/step?frames={frames}")
81
        return int(json.loads(body)["frame"])
82
83
    def pause(self) -> None:
84
        self._post("/pause")
85
86
    def resume(self) -> None:
87
        self._post("/resume")
88
89
    def screenshot(self):
90
        """Return the live screen as a PIL.Image (requires Pillow)."""
91
        body, ctype = self._get("/screenshot")
92
        try:
93
            from PIL import Image
94
        except ImportError as e:  # pragma: no cover
95
            raise EmuControlError("screenshot() needs Pillow (pip install pillow)") from e
96
        return Image.open(io.BytesIO(body))
97
98
    # -- helpers -----------------------------------------------------------
99
    def wait_ready(self, tries: int = 100, delay: float = 0.05) -> dict:
100
        """Poll /status until the server answers (emulator boot race)."""
101
        last = None
102
        for _ in range(tries):
103
            try:
104
                return self.status()
105
            except (EmuControlError, urllib.error.URLError, ConnectionError) as e:
106
                last = e
107
                time.sleep(delay)
108
        raise EmuControlError(f"emulator control port never came up: {last}")
109
110
    def run_to_frame(self, target: int) -> int:
111
        """Step until the frame counter reaches >= target."""
112
        cur = self.frame()
113
        if target > cur:
114
            self.step(frames=target - cur)
115
        return self.frame()