From 82f82eba3e2c7681577fd284fbf2c1141b3299c1 Mon Sep 17 00:00:00 2001 From: sup39 Date: Thu, 9 Mar 2023 17:55:01 +0900 Subject: [PATCH] [v0.1.0] init --- .gitignore | 4 ++ LICENSE | 21 +++++++ README.md | 23 +++++++ pyproject.toml | 3 + setup.cfg | 27 +++++++++ src/supDolphinWS/__init__.py | 0 src/supDolphinWS/server/__init__.py | 0 src/supDolphinWS/server/__main__.py | 17 ++++++ src/supDolphinWS/server/api.py | 72 ++++++++++++++++++++++ src/supDolphinWS/server/dolphin.py | 93 +++++++++++++++++++++++++++++ src/supDolphinWS/server/err.py | 14 +++++ src/supDolphinWS/server/server.py | 51 ++++++++++++++++ src/supDolphinWS/server/utils.py | 17 ++++++ 13 files changed, 342 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 setup.cfg create mode 100644 src/supDolphinWS/__init__.py create mode 100644 src/supDolphinWS/server/__init__.py create mode 100644 src/supDolphinWS/server/__main__.py create mode 100644 src/supDolphinWS/server/api.py create mode 100644 src/supDolphinWS/server/dolphin.py create mode 100644 src/supDolphinWS/server/err.py create mode 100644 src/supDolphinWS/server/server.py create mode 100644 src/supDolphinWS/server/utils.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..63af5cd --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/dist +.venv/ +*.egg-info/ +__pycache__/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6788b2f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 sup39 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8b49ee6 --- /dev/null +++ b/README.md @@ -0,0 +1,23 @@ +# supDolphinWSServer +A WebSocket server for accessing memory of emulated games in Dolphin. + +This tool only runs on Windows currently. + +## Prerequisite +- [Dolphin beta/dev version](https://dolphin-emu.org/download/) (stable version is NOT available) +- Python >= 3.8 + +## Installation/Upgrade +``` +pip install -U supDolphinWS-server +``` + +## Usage +``` +python -m supDolphinWS.server +``` + +Use the following command to see the available options: +``` +python -m supDolphinWS.server --help +``` diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b0f0765 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools>=42"] +build-backend = "setuptools.build_meta" diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..066d579 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,27 @@ +[metadata] +name = supDolphinWS-server +version = 0.1.0 +author = sup39 +author_email = sms@sup39.dev +description = A WebSocket server for accessing memory of emulated games in Dolphin +long_description = file: README.md +long_description_content_type = text/markdown +url = https://github.com/sup39/supDolphinWS-server +license = MIT +project_urls = + Bug Tracker = https://github.com/sup39/supDolphinWS-server/issues +classifiers = + Programming Language :: Python :: 3 + License :: OSI Approved :: MIT License + Operating System :: Microsoft :: Windows + +[options] +include_package_data = True +packages = find: +python_requires = >=3.8 +install_requires = + wsocket >= 2.1.0 + psutil >= 5.9.4 + +[options.packages.find] +where = src diff --git a/src/supDolphinWS/__init__.py b/src/supDolphinWS/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/supDolphinWS/server/__init__.py b/src/supDolphinWS/server/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/supDolphinWS/server/__main__.py b/src/supDolphinWS/server/__main__.py new file mode 100644 index 0000000..4fbefae --- /dev/null +++ b/src/supDolphinWS/server/__main__.py @@ -0,0 +1,17 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2023 sup39 +import argparse +from .server import run, VERSION + +if __name__ == '__main__': + parser = argparse.ArgumentParser(prog='python -m supDolphinWS.server', add_help=False) + parser.add_argument('-h', '--host', default='localhost', help='the IP address that the server will listen on') + parser.add_argument('-p', '--port', default=35353, type=int, help='the port number that the server will listen on') + parser.add_argument('--exclude-pids', default=[], nargs='*', help='the pid list to exclude when finding dolphin') + parser.add_argument('-?', '--help', action='help', default=argparse.SUPPRESS, help='show this help message and exit') + parser.add_argument('--version', action='store_true', help='show the version of the server') + args = parser.parse_args() + if args.version: + print(VERSION) + else: + run(args) diff --git a/src/supDolphinWS/server/api.py b/src/supDolphinWS/server/api.py new file mode 100644 index 0000000..ff99283 --- /dev/null +++ b/src/supDolphinWS/server/api.py @@ -0,0 +1,72 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2023 sup39 +VERSION = '0.1.0' + +import struct +from .err import abort, ERR_DOLPHIN, ERR_BAD_REQ +from .dolphin import Dolphin + +_d = Dolphin() +def get_dolphin(): + if _d.m is None: + r = _d.hook() + if r is None: abort(ERR_DOLPHIN, 'Fail to find shared memory. Is the game running on Dolphin?') + return _d + +def get_version(body): + return VERSION.encode() + +def do_hook(body): + _d.unhook() + get_dolphin() + return b'' + +''' +u32 size +u32 base +s32[] addr segments +''' +def read_ram(body): + nbody = len(body) + if nbody < 4 or nbody % 4: abort(ERR_BAD_REQ, 'Bad payload') + ## args + size, = struct.unpack('>I', body[:4]) + addr = [ + MEM1_START if nbody < 8 else struct.unpack('>I', body[4:8])[0], + *(p[0] for p in struct.iter_unpack('>i', body[8:])), + ] + ## access + d = get_dolphin() + return d.read_bytes(addr, size) or b'' + +''' +u32 size +u8[] content +u32 base +s32[] addr segments +''' +def write_ram(body): + nbody = len(body) + if nbody < 4: abort(ERR_BAD_REQ, 'Bad payload') + ## size + size, = struct.unpack('>I', body[:4]) + i = 4+size + if nbody < i: abort(ERR_BAD_REQ, 'Bad payload') + content = body[4:i] + ## addr + if (nbody-i) % 4: abort(ERR_BAD_REQ, 'Bad payload') + addr = [ + MEM1_START if nbody == i else struct.unpack('>I', body[i:i+4])[0], + *(p[0] for p in struct.iter_unpack('>i', body[i+4:])), + ] + ## access + d = get_dolphin() + a = d.write_bytes(addr, content) + return b'' if a is None else struct.pack('>I', a) + +handlers = { + 0x00: get_version, + 0x01: do_hook, + 0x02: read_ram, + 0x03: write_ram, +} diff --git a/src/supDolphinWS/server/dolphin.py b/src/supDolphinWS/server/dolphin.py new file mode 100644 index 0000000..a54bb5d --- /dev/null +++ b/src/supDolphinWS/server/dolphin.py @@ -0,0 +1,93 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2023 sup39 +import os +import psutil +import struct +from multiprocessing.shared_memory import SharedMemory + +MEM1_START = 0x80000000 +MEM1_END = 0x81800000 +MEM2_START = 0x90000000 +MEM2_END = 0x93000000 +MEM2_OFFSET = 0x4040000 + +dolphinProcNames = \ + {'Dolphin.exe', 'DolphinQt2.exe', 'DolphinWx.exe'} if os.name == 'nt' \ + else {'dolphin-emu', 'dolphin-emu-qt2', 'dolphin-emu-wx'} + +def try_get_memory(pid): + try: return SharedMemory('dolphin-emu.'+str(pid)) + except FileNotFoundError: return None + +def find_memory(exclude_pid={}): + try: return next( + (p.pid, m) + for p in psutil.process_iter(['pid', 'name']) + if p.pid not in exclude_pid and p.name() in dolphinProcNames + for m in [try_get_memory(p.pid)] + if m is not None + ) + except StopIteration: return None + +class Dolphin(): + def __init__(self): + self.pid = None + self.m = None + def hook(self, exclude_pid={}): + r = find_memory(exclude_pid) + if r is None: return + ## success + self.pid, self.m = r + return r + def unhook(self): + self.pid = None + if self.m is not None: + self.m.close() + self.m = None + def _read_bytes(self, addr, size): + ''' + addr: int + size: int + ''' + if MEM1_START <= addr <= MEM1_END-size: + idx = addr-MEM1_START + elif MEM2_START <= addr <= MEM2_END-size and len(self.m) > MEM2_OFFSET: + idx = MEM2_OFFSET + addr-MEM2_START + else: return None + return self.m.buf[idx:idx+size].tobytes() + def _write_bytes(self, addr, payload): + ''' + addr: int + payload: bytes + ''' + size = len(payload) + if MEM1_START <= addr <= MEM1_END-size: + idx = addr-MEM1_START + elif MEM2_START <= addr <= MEM2_END-size and len(self.m) > MEM2_OFFSET: + idx = MEM2_OFFSET + addr-MEM2_START + else: return None + self.m.buf[idx:idx+size] = payload + return addr + def resolve_addr(self, addr): + ''' + addr: int|int[] + ''' + offs = [addr] if isinstance(addr, int) else addr + addr = 0 + for off in offs[:-1]: + raw = self._read_bytes(addr+off, 4) + if raw is None: return None + addr, = struct.unpack('>I', raw) + return addr + (offs[-1] if len(offs) else 0) + def read_bytes(self, addr, size): + ''' + addr: int|int[] + size: int + ''' + return self._read_bytes(self.resolve_addr(addr), size) + def write_bytes(self, addr, payload): + ''' + addr: int|int[] + payload: bytes + ''' + return self._write_bytes(self.resolve_addr(addr), payload) diff --git a/src/supDolphinWS/server/err.py b/src/supDolphinWS/server/err.py new file mode 100644 index 0000000..4027c3e --- /dev/null +++ b/src/supDolphinWS/server/err.py @@ -0,0 +1,14 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2023 sup39 + +ERR_UNKNOWN = 255 +ERR_CMD_NOT_FOUND = 254 +ERR_DOLPHIN = 253 +ERR_BAD_REQ = 252 + +class ErrorResponse(Exception): + def __init__(self, code, payload): + self.code = code + self.payload = payload +def abort(code, payload=''): + raise ErrorResponse(code, payload) diff --git a/src/supDolphinWS/server/server.py b/src/supDolphinWS/server/server.py new file mode 100644 index 0000000..fc42392 --- /dev/null +++ b/src/supDolphinWS/server/server.py @@ -0,0 +1,51 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2023 sup39 +from wsocket import WSocketApp, run as _run +from .utils import tryParseInt, parseAddrPath +from .api import VERSION, handlers, _d +from .err import * + +''' +u32 id +u8 cmdtype +''' +def on_message(msg, client): + if type(msg) != bytearray: return + if len(msg) < 5: return + ID, cmdtype, body = msg[:4], msg[4], msg[5:] + handler = handlers.get(cmdtype) + if handler is None: + return client.send(ID+struct.pack('>B', ERR_CMD_NOT_FOUND)) + try: + res = handler(body) + code = 0 + if res is None: + code = ERR_UNKNOWN + res = b'' + except ErrorResponse as err: + code, res = err.code, err.payload.encode() + client.send(ID+code.to_bytes(1, 'big')+res) + +class MyWSocketApp(WSocketApp): + def wsgi(self, environ, start_response): + wsock = environ.get("wsgi.websocket") + ## plain HTTP + if not wsock: + start_response() + return '

supDolphinWSServer

' + ## WebSocket + self.onconnect(wsock) + while True: + try: + message = wsock.receive() + if message != None: + self.onmessage(message, wsock) + except WebSocketError as e: + break + return [] + +def run(args): + app = WSocketApp() + app.onmessage += on_message + _d.hook() + _run(app, host=args.host, port=args.port) diff --git a/src/supDolphinWS/server/utils.py b/src/supDolphinWS/server/utils.py new file mode 100644 index 0000000..e66c745 --- /dev/null +++ b/src/supDolphinWS/server/utils.py @@ -0,0 +1,17 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2023 sup39 +def tryParseInt(x, radix=10): + try: + return int(x, radix) + except ValueError: + return None + +def parseAddrPath(s): + if s == '': return [MEM1_START] + try: + return [ + int(ch, 16) + for ch in s.split('/') + ] + except ValueError: + return None