Expand shared Sprinter MCP with managed launch, raw reads and key input
This commit is contained in:
@@ -8,6 +8,8 @@ function exports.startplugin()
|
||||
local lfs = require("lfs")
|
||||
local directory = assert(os.getenv("SDBG_IPC_DIR"), "Нужен SDBG_IPC_DIR")
|
||||
local session = assert(os.getenv("SDBG_SESSION_ID"), "Нужен SDBG_SESSION_ID")
|
||||
local snapdir = os.getenv("SDBG_SNAP_DIR")
|
||||
local snapshot_sequence = 0
|
||||
local generation, sequence = 0, 0
|
||||
local events, owned = {}, {}
|
||||
local state, pending = "initializing", nil
|
||||
@@ -53,6 +55,37 @@ function exports.startplugin()
|
||||
assert(type(value)=="number" and value%1==0 and value>=0 and value<=limit,"Недопустимое число")
|
||||
return value
|
||||
end
|
||||
local function first_screen()
|
||||
for _,screen in pairs(machine().screens) do return screen end
|
||||
error("Экран MAME не найден")
|
||||
end
|
||||
local function share_info()
|
||||
local result={shares={},regions={}}
|
||||
for tag,share in pairs(machine().memory.shares) do
|
||||
result.shares[#result.shares+1]={tag=tag,size=share.size}
|
||||
end
|
||||
for tag,region in pairs(machine().memory.regions) do
|
||||
result.regions[#result.regions+1]={tag=tag,size=region.size}
|
||||
end
|
||||
table.sort(result.shares,function(a,b) return a.tag<b.tag end)
|
||||
table.sort(result.regions,function(a,b) return a.tag<b.tag end)
|
||||
return result
|
||||
end
|
||||
local function read_share(tag,address,length)
|
||||
assert(type(tag)=="string" and #tag>0 and #tag<=128,"Нужен точный tag share")
|
||||
local share=machine().memory.shares[tag]
|
||||
assert(share,"Share не найден: "..tag)
|
||||
local size=number(share.size,0x10000000)
|
||||
address=number(address,size)
|
||||
length=number(length,4096)
|
||||
assert(length>=1 and address+length<=size,"Чтение за пределами share")
|
||||
local bytes={}
|
||||
for offset=0,length-1 do
|
||||
bytes[#bytes+1]=string.format("%02x",share:read_u8(address+offset))
|
||||
end
|
||||
return {tag=tag,address=address,length=length,size=size,hex=table.concat(bytes),
|
||||
generation=generation}
|
||||
end
|
||||
local invalidate
|
||||
local function dispatch(request)
|
||||
assert(request.session==session,"Чужая сессия")
|
||||
@@ -65,9 +98,13 @@ function exports.startplugin()
|
||||
session_started=true
|
||||
return {protocol=1,session=session,capabilities={snapshot=true,memory=true,
|
||||
instruction_step=true,step_over=true,step_out=true,
|
||||
bank_guard=true,deferred_breakpoints=true,console_print=true},state=state}
|
||||
bank_guard=true,deferred_breakpoints=true,console_print=true,
|
||||
shares=true,screen_pixels=true,screen_snapshot=snapdir~=nil,
|
||||
program_memory=true,list_ports=true},state=state}
|
||||
elseif command=="snapshot" then
|
||||
local result={state=state,time=now(),paused=machine().paused}
|
||||
local screen=first_screen()
|
||||
result.frame=screen:frame_number()
|
||||
result.keyboards=setmetatable({}, {__jsontype="object"})
|
||||
for tag,kbd in pairs(machine().natkeyboard.keyboards) do
|
||||
result.keyboards[tag]=kbd.enabled
|
||||
@@ -149,6 +186,75 @@ function exports.startplugin()
|
||||
bytes[#bytes+1]=string.format("%02x",symbols:memory_value(":maincpu","p",address+offset,1,true))
|
||||
end
|
||||
return {hex=table.concat(bytes)}
|
||||
elseif command=="program_memory" then
|
||||
stopped(request)
|
||||
local address=number(args.address,0x3ffff)
|
||||
local length=number(args.length,4096)
|
||||
assert(length>=1 and address+length<=0x40000,
|
||||
"Чтение за пределами raw program space")
|
||||
local symbols=emu.symbol_table(cpu())
|
||||
local bytes={}
|
||||
for offset=0,length-1 do
|
||||
bytes[#bytes+1]=string.format("%02x",symbols:memory_value(
|
||||
":maincpu","p",address+offset,1,true))
|
||||
end
|
||||
return {space="program",address=address,length=length,
|
||||
hex=table.concat(bytes),generation=generation}
|
||||
elseif command=="list_ports" then
|
||||
local result={ports={},truncated=false}
|
||||
local fields_seen=0
|
||||
for tag,port in pairs(machine().ioport.ports) do
|
||||
if #result.ports>=512 then result.truncated=true;break end
|
||||
local item={tag=tag,fields={}}
|
||||
for name,field in pairs(port.fields) do
|
||||
if fields_seen>=4096 then result.truncated=true;break end
|
||||
item.fields[#item.fields+1]={name=tostring(name),mask=field.mask}
|
||||
fields_seen=fields_seen+1
|
||||
end
|
||||
table.sort(item.fields,function(a,b) return a.mask<b.mask end)
|
||||
result.ports[#result.ports+1]=item
|
||||
end
|
||||
table.sort(result.ports,function(a,b) return a.tag<b.tag end)
|
||||
return result
|
||||
elseif command=="list_shares" then
|
||||
return share_info()
|
||||
elseif command=="read_share" then
|
||||
return read_share(args.tag,args.address,args.length)
|
||||
elseif command=="read_vram" then
|
||||
local matches={}
|
||||
for tag in pairs(machine().memory.shares) do
|
||||
if tag:find("vram",1,true) then matches[#matches+1]=tag end
|
||||
end
|
||||
assert(#matches==1,"Нужен ровно один VRAM share; используйте list_shares")
|
||||
return read_share(matches[1],args.address,args.length)
|
||||
elseif command=="screen_pixels" then
|
||||
local x=number(args.x,4095)
|
||||
local y=number(args.y,4095)
|
||||
local width=number(args.width,512)
|
||||
local height=number(args.height,512)
|
||||
assert(width>=1 and height>=1 and width*height<=8192,
|
||||
"Прямоугольник должен содержать 1..8192 пикселей")
|
||||
local screen=first_screen()
|
||||
local values={}
|
||||
for dy=0,height-1 do
|
||||
for dx=0,width-1 do
|
||||
values[#values+1]=string.format("%04X",screen:pixel(x+dx,y+dy)&0xffff)
|
||||
end
|
||||
end
|
||||
return {x=x,y=y,width=width,height=height,format="pen16be-hex",
|
||||
hex=table.concat(values),frame=screen:frame_number(),
|
||||
emulated_time=now(),stale_frame=state=="stopped",generation=generation}
|
||||
elseif command=="screen_snapshot" then
|
||||
assert(snapdir and #snapdir>0,"Каталог снимков не настроен")
|
||||
snapshot_sequence=snapshot_sequence+1
|
||||
local screen=first_screen()
|
||||
local path=string.format("%s/sdbg_%06d.png",snapdir,snapshot_sequence)
|
||||
local err=screen:snapshot(path)
|
||||
assert(err==nil,"Снимок не создан: "..tostring(err))
|
||||
local size=lfs.attributes(path,"size")
|
||||
assert(size and size>0 and size<=8388608,"Неверный размер снимка")
|
||||
return {path=path,size=size,format="png",frame=screen:frame_number(),
|
||||
emulated_time=now(),stale_frame=state=="stopped",generation=generation}
|
||||
elseif command=="console_print" then
|
||||
stopped(request)
|
||||
local text=args.text
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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)
|
||||
@@ -4,7 +4,7 @@ import argparse
|
||||
import json
|
||||
import sys
|
||||
|
||||
from sdbg.server import rpc_call
|
||||
from sdbg.server import RPC_GENERATION_MUTATIONS, rpc_call
|
||||
from sdbg.session import SessionError
|
||||
|
||||
|
||||
@@ -19,8 +19,16 @@ def main():
|
||||
arguments = json.loads(args.arguments)
|
||||
if not isinstance(arguments, dict):
|
||||
raise ValueError('arguments должен быть JSON-объектом')
|
||||
identity = {}
|
||||
if args.method != 'status':
|
||||
status = rpc_call(args.socket, 'status')
|
||||
identity = {'session_id': status['session_id'],
|
||||
'build_id': status['build_id']}
|
||||
if args.method in RPC_GENERATION_MUTATIONS:
|
||||
identity['generation'] = status['generation']
|
||||
result = rpc_call(args.socket, args.method, arguments,
|
||||
timeout=35 if args.method == 'events' else 10)
|
||||
timeout=35 if args.method == 'events' else 10,
|
||||
**identity)
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
except (SessionError, ValueError, OSError) as error:
|
||||
|
||||
@@ -221,7 +221,8 @@ def main():
|
||||
lua_script(lua,ready,function['start'],signature,events,args.launch_at,
|
||||
args.dss_timeout)
|
||||
session_id=uuid.uuid4().hex
|
||||
environment=dict(os.environ,SDBG_IPC_DIR=str(ipc),SDBG_SESSION_ID=session_id)
|
||||
environment=dict(os.environ,SDBG_IPC_DIR=str(ipc),SDBG_SESSION_ID=session_id,
|
||||
SDBG_SNAP_DIR=str(state/'snapshot'))
|
||||
# SDL3 превращает SIGTERM в SDL_EVENT_QUIT, который MAME SDL3 OSD
|
||||
# не обрабатывает. Оставляем стандартное действие сигнала, чтобы
|
||||
# DAP Stop не ждал принудительного kill остановленного MAME.
|
||||
|
||||
+86
-6
@@ -9,11 +9,14 @@ import sys
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
from mame_profile import add_arguments
|
||||
from sdbg.mcp_adapter import McpSession
|
||||
from sdbg.session import SessionError
|
||||
from sdbg.supervisor import ManagedMcpSession, SessionSupervisor
|
||||
|
||||
|
||||
def make_server(client: McpSession):
|
||||
def make_server(client: McpSession | ManagedMcpSession,
|
||||
supervisor: SessionSupervisor | None = None):
|
||||
try:
|
||||
from mcp.server import MCPServer
|
||||
from mcp.server.mcpserver.exceptions import ToolError
|
||||
@@ -43,6 +46,17 @@ def make_server(client: McpSession):
|
||||
"""Получить build ID, generation, состояние CPU и ограничения текущей сессии."""
|
||||
return client.status()
|
||||
|
||||
if supervisor is not None:
|
||||
@tool()
|
||||
def start_session() -> dict[str, Any]:
|
||||
"""Асинхронно запустить MAME→DSS→EXE→main из --build; готовность читать через session_status."""
|
||||
return supervisor.start()
|
||||
|
||||
@tool()
|
||||
def stop_session() -> dict[str, Any]:
|
||||
"""Завершить только MAME, созданный этим MCP-сервером."""
|
||||
return supervisor.stop()
|
||||
|
||||
@tool(annotations=reading)
|
||||
def where() -> dict[str, Any]:
|
||||
"""Получить проверенное соответствие PC исходнику C и текущему банку."""
|
||||
@@ -58,6 +72,47 @@ def make_server(client: McpSession):
|
||||
"""Читать 1..256 байт logical Z80 memory без side effects; адрес decimal или 0xHEX."""
|
||||
return client.read_memory(address, length)
|
||||
|
||||
@tool(annotations=reading)
|
||||
def read_program_memory(address: str, length: int = 16) -> dict[str, Any]:
|
||||
"""Читать 1..4096 байт raw program space 0..0x3ffff; Z80 окна находятся в 0x10000+."""
|
||||
return client.read_program_memory(address, length)
|
||||
|
||||
@tool(annotations=reading)
|
||||
def list_ports() -> dict[str, Any]:
|
||||
"""Перечислить порты ввода MAME и поля с масками; результат ограничен по размеру."""
|
||||
return client.list_ports()
|
||||
|
||||
@tool(annotations=reading)
|
||||
def list_shares() -> dict[str, Any]:
|
||||
"""Перечислить точные tag и размеры memory shares/regions MAME."""
|
||||
return client.list_shares()
|
||||
|
||||
@tool(annotations=reading)
|
||||
def read_share(tag: str, address: str, length: int = 16) -> dict[str, Any]:
|
||||
"""Читать 1..4096 байт из share по точному tag; адрес decimal или 0xHEX."""
|
||||
return client.read_share(tag, address, length)
|
||||
|
||||
@tool(annotations=reading)
|
||||
def read_vram(address: str, length: int = 16) -> dict[str, Any]:
|
||||
"""Читать 1..4096 байт VRAM без Z80 bank mapping."""
|
||||
return client.read_vram(address, length)
|
||||
|
||||
@tool(annotations=reading)
|
||||
def read_screen_pixels(x: int, y: int, width: int = 1,
|
||||
height: int = 1) -> dict[str, Any]:
|
||||
"""Читать до 8192 видимых pen16 пикселей; при stop кадр может быть старым."""
|
||||
return client.read_screen_pixels(x, y, width, height)
|
||||
|
||||
@tool()
|
||||
def screenshot() -> dict[str, Any]:
|
||||
"""Сохранить PNG последнего кадра в изолированном каталоге текущей сессии."""
|
||||
return client.screenshot()
|
||||
|
||||
@tool()
|
||||
def press_key(key: str, frames: int = 3) -> dict[str, Any]:
|
||||
"""Нажать один символ/enter/space/tab на 1..60 кадров; отпустить при stop/ошибке."""
|
||||
return client.press_key(key, frames)
|
||||
|
||||
@tool(annotations=reading)
|
||||
def list_variables() -> dict[str, Any]:
|
||||
"""Перечислить известные global/static переменные и поддержанность типов."""
|
||||
@@ -134,15 +189,38 @@ def make_server(client: McpSession):
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--socket', default=os.environ.get('SDBG_SOCKET'),
|
||||
help='Unix socket уже запущенного sdbg_server.py (или SDBG_SOCKET)')
|
||||
help='Unix socket сеанса; с --build можно не указывать')
|
||||
parser.add_argument('--build', help='Debug-пакет для автономного MCP запуска')
|
||||
add_arguments(parser)
|
||||
parser.add_argument('--app-hdd')
|
||||
parser.add_argument('--launch-path')
|
||||
parser.add_argument('--data', action='append', default=[])
|
||||
parser.add_argument('--debugger', default='sdbg')
|
||||
parser.add_argument('--launch-at', type=float)
|
||||
parser.add_argument('--dss-timeout', type=float)
|
||||
args = parser.parse_args()
|
||||
if not args.socket:
|
||||
if not args.socket and not args.build:
|
||||
parser.error('укажите --socket или SDBG_SOCKET')
|
||||
client = McpSession(args.socket)
|
||||
supervisor = None
|
||||
if args.build:
|
||||
options = []
|
||||
for name in ('mame_home', 'mame_bin', 'mame_rompath', 'mame_dss_image',
|
||||
'mame_system_hdd_image', 'mame_bios', 'app_hdd', 'launch_path',
|
||||
'debugger', 'launch_at', 'dss_timeout'):
|
||||
value = getattr(args, name)
|
||||
if value is not None:
|
||||
options += ['--' + name.replace('_', '-'), str(value)]
|
||||
for filename in args.data:
|
||||
options += ['--data', filename]
|
||||
supervisor = SessionSupervisor(args.build, args.socket, options)
|
||||
client = ManagedMcpSession(supervisor)
|
||||
else:
|
||||
client = McpSession(args.socket)
|
||||
heartbeat_stop = threading.Event()
|
||||
heartbeat = None
|
||||
try:
|
||||
client.status() # Не публикуем инструменты для отсутствующей сессии.
|
||||
if supervisor is None:
|
||||
client.status() # Attach-режим требует уже работающий session server.
|
||||
def renew_control():
|
||||
while not heartbeat_stop.wait(10):
|
||||
try:
|
||||
@@ -153,7 +231,7 @@ def main() -> int:
|
||||
heartbeat = threading.Thread(target=renew_control, name='sdbg-mcp-heartbeat',
|
||||
daemon=True)
|
||||
heartbeat.start()
|
||||
server = make_server(client)
|
||||
server = make_server(client, supervisor)
|
||||
server.run(transport='stdio')
|
||||
return 0
|
||||
except (SessionError, OSError, RuntimeError) as error:
|
||||
@@ -171,6 +249,8 @@ def main() -> int:
|
||||
client.clear_owned_breakpoints()
|
||||
except (SessionError, OSError):
|
||||
pass
|
||||
if supervisor is not None:
|
||||
supervisor.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
Reference in New Issue
Block a user