| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
|
| 8 |
|
| 9 |
|
| 10 |
|
| 11 |
|
| 12 |
|
| 13 |
|
| 14 |
|
| 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 |
|
| 34 |
|
| 35 |
|
| 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"#": |
| 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 |
| 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 |
|
| 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) -> bytes: |
| 69 |
req = urllib.request.Request(self.base + path, method="POST", data=b"") |
| 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 |
|
| 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 screenshot_ppm(self) -> bytes: |
| 113 |
|
| 114 |
body, _ = self._get("/screenshot") |
| 115 |
return body |
| 116 |
|
| 117 |
def screenshot(self): |
| 118 |
|
| 119 |
body = self.screenshot_ppm() |
| 120 |
try: |
| 121 |
from PIL import Image |
| 122 |
except ImportError as e: |
| 123 |
raise EmuControlError("screenshot() needs Pillow (pip install pillow)") from e |
| 124 |
return Image.open(io.BytesIO(body)) |
| 125 |
|
| 126 |
|
| 127 |
def wait_ready(self, tries: int = 100, delay: float = 0.05) -> dict: |
| 128 |
|
| 129 |
last = None |
| 130 |
for _ in range(tries): |
| 131 |
try: |
| 132 |
return self.status() |
| 133 |
except (EmuControlError, urllib.error.URLError, ConnectionError) as e: |
| 134 |
last = e |
| 135 |
time.sleep(delay) |
| 136 |
raise EmuControlError(f"emulator control port never came up: {last}") |
| 137 |
|
| 138 |
def run_to_frame(self, target: int) -> int: |
| 139 |
|
| 140 |
cur = self.frame() |
| 141 |
if target > cur: |
| 142 |
self.step(frames=target - cur) |
| 143 |
return self.frame() |