Expand shared Sprinter MCP with managed launch, raw reads and key input

This commit is contained in:
Александр Петров
2026-09-17 22:57:27 +03:00
parent a43e7bda89
commit 5dc7998324
22 changed files with 1120 additions and 67 deletions
+9 -2
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from pathlib import Path
from .server import rpc_call
from .server import RPC_GENERATION_MUTATIONS, rpc_call
from .session import SessionError
@@ -19,7 +19,14 @@ class DapEngine:
return self._rpc_override(method, arguments or {})
if not self.rpc_path:
raise SessionError('DAP ещё не подключён к session server')
return rpc_call(self.rpc_path, method, arguments, timeout)
identity = {}
if self.attached is not None:
identity = {'session_id': self.attached['session_id'],
'build_id': self.attached['build_id']}
if method in RPC_GENERATION_MUTATIONS:
current = rpc_call(self.rpc_path, 'status', **identity)
identity['generation'] = current['generation']
return rpc_call(self.rpc_path, method, arguments, timeout, **identity)
def handle(self, command: str, arguments: dict) -> tuple[dict, list[tuple[str, dict]]]:
events = []
+95 -5
View File
@@ -2,9 +2,10 @@
from __future__ import annotations
from pathlib import Path
import time
import uuid
from .server import rpc_call
from .server import RPC_GENERATION_MUTATIONS, rpc_call
from .session import SessionError
@@ -13,9 +14,23 @@ class McpSession:
self.socket_path = str(Path(socket_path).expanduser())
self.owner = 'mcp:' + uuid.uuid4().hex
self._rpc = rpc
self.identity: dict[str, str] = {}
def call(self, method: str, arguments: dict | None = None, timeout: float = 10):
return self._rpc(self.socket_path, method, arguments or {}, timeout=timeout)
if method != 'status' and not self.identity:
self.status()
generation = None
if method in RPC_GENERATION_MUTATIONS:
generation = self.status().get('generation')
identity = dict(self.identity)
if method in RPC_GENERATION_MUTATIONS:
identity['generation'] = generation
result = self._rpc(self.socket_path, method, arguments or {},
timeout=timeout, **identity)
if method == 'status' and 'session_id' in result:
self.identity = {'session_id': result['session_id'],
'build_id': result['build_id']}
return result
def status(self):
return self.call('status')
@@ -27,11 +42,86 @@ class McpSession:
return self.call('registers')
def read_memory(self, address: str, length: int = 16):
return self.call('read_memory', {'address': self._address(address),
'length': length})
@staticmethod
def _address(address: str) -> int:
try:
parsed = int(address, 16 if address.lower().startswith('0x') else 10)
except ValueError as error:
return int(address, 16 if address.lower().startswith('0x') else 10)
except (AttributeError, ValueError) as error:
raise SessionError('Адрес памяти должен быть десятичным или 0xHEX') from error
return self.call('read_memory', {'address': parsed, 'length': length})
def list_shares(self):
return self.call('list_shares')
def list_ports(self):
return self.call('list_ports')
def read_program_memory(self, address: str, length: int = 16):
return self.call('read_program_memory', {'address': self._address(address),
'length': length})
def read_share(self, tag: str, address: str, length: int = 16):
return self.call('read_share', {'tag': tag, 'address': self._address(address),
'length': length})
def read_vram(self, address: str, length: int = 16):
return self.call('read_vram', {'address': self._address(address),
'length': length})
def read_screen_pixels(self, x: int, y: int, width: int = 1, height: int = 1):
return self.call('read_screen_pixels', {'x': x, 'y': y,
'width': width, 'height': height})
def screenshot(self):
return self.call('screenshot')
def press_key(self, key: str, frames: int = 3):
"""Один физический PC-key с отпусканием даже при stop/ошибке."""
from mame_interactive import SHIFT_KEY, resolve
if not isinstance(key, str) or len(key) == 0 or len(key) > 16:
raise SessionError('Нужна одна клавиша или enter/space/tab')
if not isinstance(frames, int) or frames < 1 or frames > 60:
raise SessionError('frames должен быть 1..60')
named = {'enter': '\n', 'space': ' ', 'tab': '\t'}
symbol = named.get(key.lower(), key)
if len(symbol) != 1:
raise SessionError('Пока поддерживаются один символ, enter, space, tab')
try:
tag, mask, shift = resolve(symbol)
except ValueError as error:
raise SessionError(str(error)) from error
if not self.status()['running']:
raise SessionError('Нажатие возможно только при running CPU')
held = []
try:
if shift:
self.call('input_key', {'tag': SHIFT_KEY[0], 'mask': SHIFT_KEY[1],
'down': True, 'owner': self.owner})
held.append(SHIFT_KEY)
self.call('input_key', {'tag': tag, 'mask': mask,
'down': True, 'owner': self.owner})
held.append((tag, mask))
start = self.call('snapshot')['frame']
deadline = time.monotonic() + 4
last = start
while time.monotonic() < deadline:
snapshot = self.call('snapshot')
last = snapshot['frame']
if last - start >= frames or snapshot['state'] != 'running':
break
time.sleep(.01)
else:
raise SessionError('Клавиша не получила нужное число кадров за 4 с')
return {'key': key, 'requested_frames': frames,
'observed_frames': max(0, last - start),
'stopped': snapshot['state'] != 'running'}
finally:
for release_tag, release_mask in reversed(held):
self.call('input_key', {'tag': release_tag, 'mask': release_mask,
'down': False, 'owner': self.owner})
def variables(self):
return self.call('variables')
+92 -4
View File
@@ -223,10 +223,13 @@ class SessionController:
self._emit('control', {'owner': None})
return {'released': owner}
if self.running and method in ('where', 'registers', 'read_memory',
'read_variable', 'break_line', 'break_function'):
'read_program_memory', 'read_variable',
'break_line', 'break_function'):
raise SessionError('Операция требует остановленного CPU; сначала Pause')
if method == 'status':
return {**self.attached, 'running': self.running,
'generation': getattr(self.session.bridge, 'generation',
self.attached.get('generation')),
'event_sequence': self.sequence, 'session_id': self.session_id,
'control_owner': self.control_owner if
self.control_deadline > time.monotonic() else None}
@@ -235,6 +238,41 @@ class SessionController:
if method == 'mame_console_tail':
return self.session.bridge.request(
'console_tail', count=int(arguments.get('count', 40)))
if method == 'list_ports':
return self.session.bridge.request('list_ports')
if method == 'list_shares':
return self.session.bridge.request('list_shares')
if method in ('read_share', 'read_vram'):
address = int(arguments['address'])
length = int(arguments['length'])
if address < 0 or address > 0x10000000 or length < 1 or length > 4096:
raise SessionError('Share: адрес 0..0x10000000, длина 1..4096')
if method == 'read_share':
tag = arguments['tag']
if not isinstance(tag, str) or not tag or len(tag) > 128:
raise SessionError('Нужен точный tag share до 128 символов')
return self.session.bridge.request('read_share', tag=tag,
address=address, length=length)
return self.session.bridge.request('read_vram', address=address,
length=length)
if method == 'read_screen_pixels':
x, y = int(arguments['x']), int(arguments['y'])
width, height = int(arguments['width']), int(arguments['height'])
if x < 0 or y < 0 or x > 4095 or y > 4095 or \
width < 1 or height < 1 or width > 512 or height > 512 or \
width * height > 8192:
raise SessionError('Экран: координаты 0..4095, размер 1..512, максимум 8192 пикселей')
return self.session.bridge.request('screen_pixels', x=x, y=y,
width=width, height=height)
if method == 'screenshot':
result = self.session.bridge.request('screen_snapshot')
root = (Path(self.session.bridge.directory).parent / 'snapshot').resolve()
path = Path(result['path']).resolve()
if path.parent != root or path.suffix.lower() != '.png' or \
not path.is_file() or path.stat().st_size != result['size'] or \
result['size'] > 8 * 1024 * 1024:
raise SessionError('Снимок MAME вне каталога сессии или повреждён')
return result
if method == 'input_key':
if not self.running and arguments['down'] is not False:
raise SessionError('Нажатие возможно только при running CPU')
@@ -264,6 +302,16 @@ class SessionController:
data = self.session.bridge.request('memory', address=address, length=length)
return {'address': address, 'length': length, 'hex': data['hex'],
'generation': mapping.generation, 'bank_pages': mapping.bank_pages}
if method == 'read_program_memory':
address = int(arguments['address'])
length = int(arguments['length'])
if address < 0 or address > 0x3ffff or length < 1 or length > 4096 or \
address + length > 0x40000:
raise SessionError('Raw program: адрес 0..0x3ffff, длина 1..4096')
mapping = self.session.refresh()
result = self.session.bridge.request('program_memory',
address=address, length=length)
return {**result, 'bank_pages': mapping.bank_pages}
if method == 'variables':
return self.session.model.variables
if method == 'read_variable':
@@ -556,6 +604,17 @@ class _ThreadedUnixServer(socketserver.ThreadingMixIn, socketserver.UnixStreamSe
daemon_threads = True
RPC_MUTATIONS = frozenset({
'claim_control', 'renew_control', 'release_control', 'input_key',
'break_line', 'break_function', 'clear_breakpoint', 'clear_owned_breakpoints',
'set_source_breakpoints', 'set_function_breakpoints',
'continue', 'pause', 'step', 'source_step',
})
RPC_GENERATION_MUTATIONS = RPC_MUTATIONS - {
'claim_control', 'renew_control', 'release_control',
}
class SessionRpcServer:
def __init__(self, path, controller: SessionController):
self.path = Path(path)
@@ -584,8 +643,31 @@ class SessionRpcServer:
arguments = request.get('arguments', {})
if not isinstance(arguments, dict):
raise ValueError('RPC arguments должен быть объектом')
method = request['method']
with controller_ref.lock:
if method != 'unknown' and (method != 'status' or
request.get('session_id') is not None or
request.get('build_id') is not None):
if request.get('session_id') != controller_ref.session_id or \
request.get('build_id') != controller_ref.attached['build_id']:
raise SessionError('Устаревшая RPC-сессия или build ID; переподключитесь')
if method in RPC_GENERATION_MUTATIONS and \
request.get('generation') != \
getattr(controller_ref.session.bridge, 'generation', None):
raise SessionError('Устаревшая generation; обновите статус сессии')
if method != 'events':
result = controller_ref.call(method, arguments)
result_generation = getattr(controller_ref.session.bridge,
'generation', None)
if method == 'events':
result = controller_ref.call(method, arguments)
result_generation = getattr(controller_ref.session.bridge,
'generation', None)
response = {'id': request.get('id'), 'ok': True,
'result': controller_ref.call(request['method'], arguments)}
'session_id': controller_ref.session_id,
'build_id': controller_ref.attached['build_id'],
'generation': result_generation,
'result': result}
except (BridgeError, SessionError, ValueError, TypeError, OSError, KeyError) as error:
response.update(error=str(error))
self.wfile.write((json.dumps(response, ensure_ascii=False) + '\n').encode())
@@ -604,8 +686,11 @@ class SessionRpcServer:
self.path.unlink(missing_ok=True)
def rpc_call(path, method: str, arguments=None, timeout=10):
request = {'id': 1, 'method': method, 'arguments': arguments or {}}
def rpc_call(path, method: str, arguments=None, timeout=10, *,
session_id=None, build_id=None, generation=None):
request = {'id': 1, 'method': method, 'arguments': arguments or {},
'session_id': session_id, 'build_id': build_id,
'generation': generation}
with socket.socket(socket.AF_UNIX) as client:
client.settimeout(timeout)
client.connect(str(path))
@@ -617,4 +702,7 @@ def rpc_call(path, method: str, arguments=None, timeout=10):
response = json.loads(raw)
if not response.get('ok'):
raise SessionError(response.get('error', 'Неизвестная RPC-ошибка'))
if method != 'status' and (response.get('session_id') != session_id or
response.get('build_id') != build_id):
raise SessionError('RPC-ответ от другой сессии или сборки')
return response['result']
+173
View File
@@ -0,0 +1,173 @@
"""Владелец автономного launcher для MCP без запущенного VS Code."""
from __future__ import annotations
import json
from pathlib import Path
import subprocess
import sys
import tempfile
import threading
import uuid
from .mcp_adapter import McpSession
from .session import SessionError
class SessionSupervisor:
def __init__(self, build: str, socket_path: str | None = None,
launcher_options: list[str] | None = None):
self.build = str(Path(build).expanduser().resolve())
self._temporary = None
if socket_path:
self.socket_path = str(Path(socket_path).expanduser())
else:
self._temporary = tempfile.TemporaryDirectory(prefix='sprinter-mcp-', dir='/tmp')
self.socket_path = str(Path(self._temporary.name) / 'session.sock')
self.launcher_options = list(launcher_options or [])
self.lock = threading.RLock()
self.phase = 'idle'
self.error: str | None = None
self.entry = None
self.mame_pid = None
self.process: subprocess.Popen | None = None
self.monitor: threading.Thread | None = None
self.log = None
self.client: McpSession | None = None
self.launch_id = None
def status(self) -> dict:
with self.lock:
result = {'phase': self.phase, 'socket': self.socket_path,
'launch_id': self.launch_id}
if self.error:
result['error'] = self.error
if self.phase == 'ready' and self.client is not None:
try:
result.update(self.client.status())
result['mame_pid'] = self.mame_pid
result['entry'] = self.entry
except (SessionError, OSError) as error:
self.phase = 'failed'
self.error = str(error)
result.update(phase='failed', error=self.error)
return result
def require_client(self) -> McpSession:
with self.lock:
if self.phase != 'ready' or self.client is None:
raise SessionError('C-сессия ещё не готова: ' + self.phase)
return self.client
def start(self) -> dict:
with self.lock:
if self.phase in ('starting', 'ready', 'stopping'):
raise SessionError('Автономная сессия уже запущена: ' + self.phase)
if self.process is not None and self.process.poll() is None:
raise SessionError('Предыдущий launcher ещё работает')
if self.monitor is not None and self.monitor.is_alive():
raise SessionError('Предыдущий launcher ещё завершает работу')
if not Path(self.build).is_dir():
raise SessionError('Debug-пакет не найден: ' + self.build)
command = [sys.executable, str(Path(__file__).resolve().parents[1] /
'sdbg_launcher.py'), '--build', self.build,
'--socket', self.socket_path, *self.launcher_options]
if self.log is not None:
self.log.close()
self.log = tempfile.TemporaryFile(mode='w+t', encoding='utf-8')
self.phase = 'starting'
self.error = None
self.entry = None
self.mame_pid = None
self.client = None
self.launch_id = uuid.uuid4().hex
try:
self.process = subprocess.Popen(command, stdout=subprocess.PIPE,
stderr=self.log, text=True)
except OSError:
self.phase = 'failed'
raise
self.monitor = threading.Thread(target=self._monitor, name='sdbg-supervisor',
daemon=True)
self.monitor.start()
return {'accepted': True, 'phase': self.phase,
'socket': self.socket_path, 'launch_id': self.launch_id}
def _diagnostics(self) -> str:
if self.log is None:
return ''
self.log.flush()
self.log.seek(0)
return self.log.read()[-4000:].strip()
def _monitor(self) -> None:
process = self.process
assert process is not None and process.stdout is not None
try:
line = process.stdout.readline()
if not line:
raise SessionError('Launcher завершился до main: ' + self._diagnostics())
ready = json.loads(line)
if not ready.get('ready') or ready.get('socket') != self.socket_path:
raise SessionError('Launcher вернул неверный ready')
client = McpSession(self.socket_path)
client.status()
with self.lock:
if self.phase == 'starting':
self.client = client
self.entry = ready['entry']
self.mame_pid = ready['pid']
self.phase = 'ready'
process.wait()
with self.lock:
if self.phase not in ('stopping', 'failed'):
self.phase = 'stopped'
self.error = 'Launcher завершился: ' + self._diagnostics()
elif self.phase == 'stopping':
self.phase = 'stopped'
self.client = None
except (OSError, ValueError, KeyError, SessionError) as error:
with self.lock:
if self.phase == 'stopping':
self.phase = 'stopped'
else:
self.phase = 'failed'
self.error = str(error)
self.client = None
def stop(self) -> dict:
with self.lock:
if self.phase not in ('starting', 'ready'):
return {'accepted': False, 'phase': self.phase}
self.phase = 'stopping'
if self.process is not None and self.process.poll() is None:
self.process.terminate()
return {'accepted': True, 'phase': 'stopping',
'socket': self.socket_path}
def close(self) -> None:
self.stop()
process = self.process
if process is not None:
try:
process.wait(timeout=8)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
if self.monitor is not None:
self.monitor.join(timeout=2)
if self.log is not None:
self.log.close()
if self._temporary is not None:
self._temporary.cleanup()
class ManagedMcpSession:
"""Совместимый с McpSession фасад, пока launcher проходит DSS."""
def __init__(self, supervisor: SessionSupervisor):
self.supervisor = supervisor
def status(self) -> dict:
return self.supervisor.status()
def __getattr__(self, name: str):
return getattr(self.supervisor.require_client(), name)