| 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.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 |
|
| 35 |
|
| 36 |
|
| 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"#": |
| 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 |
| 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 |
|
| 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 |
|
| 78 |
def status(self) -> dict: |
| 79 |
|
| 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 |
|
| 92 |
return int(self.status()["frame"]) |
| 93 |
|
| 94 |
def regs(self) -> dict: |
| 95 |
|
| 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 |
|
| 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 |
|
| 109 |
|
| 110 |
body = self._post(f"/step?frames={frames}") |
| 111 |
return int(json.loads(body)["frame"]) |
| 112 |
|
| 113 |
def pause(self) -> None: |
| 114 |
|
| 115 |
self._post("/pause") |
| 116 |
|
| 117 |
def resume(self) -> None: |
| 118 |
|
| 119 |
self._post("/resume") |
| 120 |
|
| 121 |
def key(self, text: str | None = None, code: int | None = None, |
| 122 |
down: bool | None = None) -> None: |
| 123 |
|
| 124 |
|
| 125 |
|
| 126 |
|
| 127 |
parts = [] |
| 128 |
if text is not None: |
| 129 |
|
| 130 |
|
| 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 |
|
| 142 |
self._post("/reset") |
| 143 |
|
| 144 |
def mem_write(self, addr: int, data: bytes, bank: int | None = None) -> int: |
| 145 |
|
| 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 |
|
| 153 |
|
| 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 |
|
| 161 |
|
| 162 |
|
| 163 |
|
| 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 |
|
| 179 |
|
| 180 |
body, _ = self._get("/pointer") |
| 181 |
return json.loads(body) |
| 182 |
|
| 183 |
|
| 184 |
|
| 185 |
|
| 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 |
|
| 202 |
|
| 203 |
|
| 204 |
|
| 205 |
|
| 206 |
|
| 207 |
|
| 208 |
|
| 209 |
|
| 210 |
|
| 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 |
|
| 220 |
|
| 221 |
body, _ = self._get(f"/pad?index={index}") |
| 222 |
return json.loads(body) |
| 223 |
|
| 224 |
def capabilities(self) -> set[str]: |
| 225 |
|
| 226 |
|
| 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 |
|
| 232 |
|
| 233 |
|
| 234 |
|
| 235 |
|
| 236 |
|
| 237 |
|
| 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 |
|
| 253 |
|
| 254 |
|
| 255 |
|
| 256 |
return json.loads(self._post("/perf", json.dumps(settings).encode())) |
| 257 |
|
| 258 |
def screenshot_ppm(self) -> bytes: |
| 259 |
|
| 260 |
body, _ = self._get("/screenshot") |
| 261 |
return body |
| 262 |
|
| 263 |
def screenshot(self): |
| 264 |
|
| 265 |
body = self.screenshot_ppm() |
| 266 |
try: |
| 267 |
from PIL import Image |
| 268 |
except ImportError as e: |
| 269 |
raise EmuControlError("screenshot() needs Pillow (pip install pillow)") from e |
| 270 |
return Image.open(io.BytesIO(body)) |
| 271 |
|
| 272 |
|
| 273 |
def wait_ready(self, tries: int = 100, delay: float = 0.05) -> dict: |
| 274 |
|
| 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 |
|
| 286 |
cur = self.frame() |
| 287 |
if target > cur: |
| 288 |
self.step(frames=target - cur) |
| 289 |
return self.frame() |