diff --git a/docs/TODO.md b/docs/TODO.md index 9cd5d53..4ca02ca 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -280,14 +280,15 @@ Quick wins: ## Прочий backlog - [ ] **Довести MCP C-уровня до полного безопасного контракта.** Первый - stdio-адаптер с 28 инструментами использует общую sdbg-сессию и + stdio-адаптер с 29 инструментами использует общую sdbg-сессию и owner ID личных точек. Полный план и матрица всех 30 raw-возможностей — [mcp-convergence-plan.md](mcp-convergence-plan.md). control lease для DAP/MCP и cleanup точек по истечении heartbeat проверены живым прогоном. Привязка RPC к session/build/generation и экранные API, одиночный `press_key` и читающий список C-точек уже - добавлены; logical Z80 disassembly также проверен. Далее очередь - строкового ввода, управляемые поля портов и мышь, затем доказанные + добавлены; logical Z80 disassembly и ограниченный `type_string` + проверены. Далее natural keyboard, управляемые поля портов и мышь, + затем доказанные watchpoints, запись typed values, disassemble_src и batch-загрузка точек. Raw `mame_mcp.py` с `-plugin mamebridge` не подключать к тому же MAME в обход общей сессии. Старый `mame_bridge.lua` удалён из MAME.HT diff --git a/docs/mame-source-debug-status.md b/docs/mame-source-debug-status.md index 41b0aa0..bc5a451 100644 --- a/docs/mame-source-debug-status.md +++ b/docs/mame-source-debug-status.md @@ -125,7 +125,7 @@ stdio MCP-адаптер C-уровня. Эксклюзивное владени проверяет сигнатуру entry и запускает session server. Закрытие DAP завершает только созданные им server/MAME, удаляет временный каталог и свой Unix socket (с проверкой inode, чтобы не удалить новый сеанс). -- `sdbg_mcp.py` предоставляет 28 инструментов через официальный MCP SDK +- `sdbg_mcp.py` предоставляет 29 инструментов через официальный MCP SDK 2.x поверх того же session server: статус, C-позиция, регистры, logical memory, переменные, события/логи, личные точки и команды исполнения. MCP-точки имеют owner ID и не могут удалить точки VS Code; при обычном @@ -136,7 +136,7 @@ stdio MCP-адаптер C-уровня. Эксклюзивное владени шага после клавиши. Повторный запуск на том же фиксированном socket после прежнего stale-файла прошёл; после Stop путь удалён. - В автономном режиме доступны 30 инструментов: официальный MCP-клиент + В автономном режиме доступен 31 инструмент: официальный MCP-клиент запустил `hello`, ввёл `x` через публичный `press_key` во время `getchar()` и остановился на следующей C-строке. Остальные raw-возможности ввода перечислены в [матрице](mcp-capability-matrix.md). @@ -296,6 +296,7 @@ commit `b0c4527c2edb1ee177fd09b3c412b65b385bf35b`: | Публичный MCP `press_key`, 2026-09-17 | В автономном видимом MAME официальный MCP-клиент поставил C-точку перед `getchar()`, продолжил `hello`, ввёл `x` через `press_key` и остановился на следующей строке 63. В автономном режиме доступны 28 инструментов; строковый ввод и мышь остаются в плане | | Список C-точек MCP, 2026-09-17 | В автономном видимом MAME MCP увидел собственную точку перед `getchar()` с owner и адресом; `list_breakpoints` также отдаёт guard-условие банка из bridge, если оно есть. Всего 29 автономных инструментов; вручную созданные debugger-точки пока не перечисляются | | Logical Z80 disassembly MCP, 2026-09-17 | Через официальный MCP-клиент в видимом MAME прочитаны инструкции `main` с `0x8224` (`call $852C`); байты памяти до/после совпали. Адрес/длина ограничены, возвращаются bank_pages и generation. Всего 30 автономных инструментов; raw program disassembly ещё не поддержано | +| Строковый ввод MCP, 2026-09-17 | `tests/gets` с C-картой в видимом MAME получил `Ab9` и Enter через один MCP `type_string`; скриншот показал ввод и печать строки, CPU остановился на строке 16 после `gets()`. 31 автономный инструмент; natural keyboard/мышь остаются в плане | | F10 через `getchar()`, 2026-09-15 | Асинхронный `next` ответил за 21,49 мс; при ручном `x` в окне MAME `hello.c:62` → `:63`, возврат `DE.low=0x78`; обе клавиатуры включены в изолированном cfg | | VS Code TaskProvider Build, 2026-09-15 | Одиннадцать Node-проверок: выбор Makefile рядом с пакетом/в `build/`, команда make с Python shim, SDCC matcher, успешный и неуспешный код задачи, pyenv вне workspace; реальный make в `tests/hello` прошёл, VS Code показал Build → MAME/DSS → `main` | | Ошибочная debug-сборка, 2026-09-15 | Изолированный `fault.c` вернул код 2 от make, `file:1: error 20` сохранился, Python traceback удалён; MAME не участвует | diff --git a/docs/mame-source-debug.md b/docs/mame-source-debug.md index 8e14f32..7ab3069 100644 --- a/docs/mame-source-debug.md +++ b/docs/mame-source-debug.md @@ -536,13 +536,14 @@ console_log и загрузка набора точек. Возвращать bu в арбитраж, а не обходят его. Первый stdio MCP-адаптер уже работает через общий session server и отдаёт -28 проверенных C-инструментов (30 при автономном start/stop). У личных MCP-точек owner ID; удаление чужой +29 проверенных C-инструментов (31 при автономном start/stop). У личных MCP-точек owner ID; удаление чужой DAP-точки отклоняется. Совместный живой прогон подтвердил чтение состояния, точки и последующее срабатывание DAP-точки. Добавлен control lease для команд CPU и ввода; передача MCP→DAP и cleanup точки владельца без heartbeat проверены живым прогоном. RPC привязан к session/build/generation; экран и VRAM доступны через общий bridge, что подтверждено снимком работающего `hello`; -одиночный `press_key` проверен на ожидающем `getchar()`; +одиночный `press_key` проверен на ожидающем `getchar()`, а `type_string` +ввёл `Ab9` и Enter в `gets()` с остановкой после печати строки; остальные интерфейсы этого раздела ещё не реализованы. Автономный MCP `start_session` поднимает DSS→EXE→`main` без VS Code; DAP attach к созданному socket и последующий `stop_session` проверены. diff --git a/docs/mcp-capability-matrix.md b/docs/mcp-capability-matrix.md index 267e628..495d581 100644 --- a/docs/mcp-capability-matrix.md +++ b/docs/mcp-capability-matrix.md @@ -30,7 +30,7 @@ | `read_screen_pixels` | `read_screen_pixels` | Проверено; до 8192 pen16 пикселей, кадр/время/stale в ответе | | `list_ports` | `list_ports` | Проверено; структурированные tag/маска/имя, предел 512 портов и 4096 полей | | `press_key` | `press_key` | Проверено на `hello` через `getchar()`: один символ/enter/space/tab на 1..60 кадров, только при running CPU и с control lease; удержанные клавиши отпускаются при stop/ошибке. Имена прочих физических клавиш пока не поддержаны | -| `type_string` | — | Нужна очередь ввода с release при stop/disconnect | +| `type_string` | `type_string` | Проверено на `tests/gets`: `Ab9` + Enter напечатаны, CPU остановился на C-строке после `gets()`. До 64 символов из PC-раскладки, предварительная проверка всей строки, пауза между клавишами; набор прекращается при stop. Асинхронная очередь пока не нужна для этого ограниченного вызова | | `type_text` | — | Нужна отдельная проверка natural keyboard и прямой физической клавиатуры | | `move_mouse` | — | Нужна owner-очередь относительных осей | | `click_mouse` | — | Нужна owner-очередь кнопок | @@ -46,6 +46,8 @@ `run_managed_mcp_probe.py` через официальный MCP-клиент проверяет `disassemble_logical` в `main`, `list_breakpoints`, `press_key("x")` и остановку на строке после `getchar()`. +`run_type_string_probe.py` вводит `Ab9` и Enter в `tests/gets`, проверяет +остановку после `gets()` и сохраняет снимок с напечатанной строкой. `run_raw_mcp_hello_probe.py` проверяет отдельный raw plugin на том же приложении. Несколько процессов MAME с MCP одновременно не проверялись. diff --git a/docs/mcp-convergence-plan.md b/docs/mcp-convergence-plan.md index a49198b..60b4a4b 100644 --- a/docs/mcp-convergence-plan.md +++ b/docs/mcp-convergence-plan.md @@ -11,8 +11,8 @@ `MAME.HT/plugins/mamebridge` вместе с `src/mame_mcp.py` публикует 30 raw-инструментов. Удалённый на этапе А `src/mame_bridge.lua` использовал тот же Python frontend, но обрабатывал только 16 команд и не отвечал при hard-stop. -`toolchain/sdbg_mcp.py` публикует 28 C-инструментов через общую с DAP сессию -(30 в автономном режиме с командами start/stop). +`toolchain/sdbg_mcp.py` публикует 29 C-инструментов через общую с DAP сессию +(31 в автономном режиме с командами start/stop). **Цель — покрыть возможности всех 30 raw-инструментов в C-сессии, а не механически скопировать имена и небезопасную семантику.** Полный raw MCP остаётся отдельным режимом для задач без C-пакета и других машин MAME. @@ -146,8 +146,14 @@ pass-through и особенности адресов перечислены п Одиночный публичный `press_key` теперь требует running CPU и control lease, удерживает клавишу 1..60 кадров и отпускает её при stop/ошибке. Автономный официальный MCP-клиент ввёл `x` в ожидающий `getchar()` и попал на следующую -C-строку. Очередь строки, другие имена физических клавиш и мышь ещё не -реализованы; поэтому строка raw `press_key` покрыта частично. +C-строку. Асинхронная очередь событий, другие имена физических клавиш и мышь +ещё не реализованы; поэтому строка raw `press_key` покрыта частично. +Ограниченный `type_string` уже выполняет последовательность из 1..64 +символов PC-раскладки с предварительной проверкой, покадровым удержанием и +отпусканием, прерыванием при stop. На `tests/gets` строка `Ab9` и Enter +дошли до `gets()`; программа напечатала её и остановилась на следующей +C-строке. Это покрывает типовой строковый ввод, но не natural keyboard и +не асинхронную очередь произвольных событий мыши/портов. Читающий `list_breakpoints` теперь показывает логические C-точки всех владельцев, адреса и полученные от MAME условия банковской страницы; удалять чужие точки он не позволяет. Живой MCP-прогон увидел личную точку diff --git a/docs/sdbg-mcp.md b/docs/sdbg-mcp.md index 0bc7e2b..58debcb 100644 --- a/docs/sdbg-mcp.md +++ b/docs/sdbg-mcp.md @@ -76,7 +76,8 @@ MCP-сервером MAME и удаляет socket. Закрытие самог Доступные инструменты: `session_status`, `where`, `read_registers`, `read_memory`, `read_program_memory`, `disassemble_logical`, `list_ports`, `list_shares`, `read_share`, `read_vram`, -`read_screen_pixels`, `screenshot`, `press_key`, `list_variables`, `read_variable`, `recent_events`, +`read_screen_pixels`, `screenshot`, `press_key`, `type_string`, +`list_variables`, `read_variable`, `recent_events`, `mame_console_tail`, `set_line_breakpoint`, `set_function_breakpoint`, `list_breakpoints`, `clear_breakpoint`, `clear_my_breakpoints`, `claim_control`, `release_control`, @@ -87,7 +88,7 @@ MCP-сервером MAME и удаляет socket. Закрытие самог `recent_events` с курсором `last`. Логи `SDBG_LOG` приходят как события `output`; журнал ограничен 1024 событиями и сообщает `first`/`last`/`lost`. -Число 28 в attach-режиме (30 в автономном с `start_session`/`stop_session`) +Число 29 в attach-режиме (31 в автономном с `start_session`/`stop_session`) не означает полного переноса более нового `-plugin mamebridge`: его `src/mame_mcp.py` публикует 30 raw-инструментов, ориентированных на машину MAME. Здесь инструменты сгруппированы по операциям C-сессии, а @@ -98,7 +99,7 @@ MCP-сервером MAME и удаляет socket. Закрытие самог |---|---| | `setmem`, raw `debugger_command`, watchpoints | Проверка прав, банка, диапазона и согласование с DAP-точками; произвольная debugger-команда может нарушить состояние сессии | | VRAM/shares, screen pixels, screenshot | Реализованы через общий `sdbgbridge`; точный tag, пределы 4096 байт/8192 пикселя, PNG только в каталоге сессии | -| Клавиатура, мышь, type/press | `list_ports` и одиночный `press_key` уже перенесены; набор строки, произвольные поля портов и мышь требуют отдельного контракта ввода | +| Клавиатура, мышь, type/press | `list_ports`, одиночный `press_key` и ограниченный `type_string` перенесены; natural keyboard, произвольные поля портов и мышь требуют отдельного контракта | | Raw disassembly | Текущее окно logical Z80 уже доступно через `disassemble_logical`; адреса raw program вне отображённого банка требуют отдельного безопасного режима | Это следующий этап, а не запрет на функции raw MCP. Нельзя просто загрузить @@ -146,8 +147,15 @@ CPU, после запуска EXE; применяется общий control le Ответ сообщает число прошедших кадров и признак остановки. Живой автономный MCP-прогон ввёл `x` в ожидающий `getchar()` и дошёл до следующей C-строки. Прямой физический ввод в MAME проверялся отдельно; `press_key` не заменяет -его. Набор строки, произвольные имена клавиш, мышь и порты ввода ещё не -публичны в C-MCP. +его. `type_string` принимает 1..64 символа той же PC-раскладки, включая +реальный перевод строки `\n` для Enter. Все символы проверяются до первого +нажатия. Каждый символ удерживается три кадра и затем отпускается; +между символами выдерживаются четыре кадра. При stop дальнейший набор +прекращается, ответ сообщает `requested`, `typed`, `complete` и `stopped`. +Живой тест `gets()` получил `Ab9` и Enter; снимок показал введённую и +напечатанную строку, а CPU остановился на следующей C-строке. Ввод через +natural keyboard, произвольные имена физических клавиш, мышь и поля портов +пока не публичны в C-MCP. `list_breakpoints` возвращает логические ID, владельца (`mcp:…`, `dap` или `build`), вид C-точки, адреса и фактические условия банковской страницы для diff --git a/docs/vscode-sprinter-debug.md b/docs/vscode-sprinter-debug.md index c0a3a67..5d536b5 100644 --- a/docs/vscode-sprinter-debug.md +++ b/docs/vscode-sprinter-debug.md @@ -175,7 +175,7 @@ launcher задаёт этому процессу `SDL_NO_SIGNAL_HANDLERS=1`, е фиксированный `"socket": "/tmp/sprinter-sdbg-hello.sock"` и подключите `toolchain/sdbg_mcp.py` после остановки в `main`. MCP использует тот же session server; его личные точки не заменяют точки VS Code. Порядок запуска, -28 доступных инструментов и ограничения совместного управления описаны в +29 доступных инструментов и ограничения совместного управления описаны в [руководстве по MCP](sdbg-mcp.md). Команда палитры `Sprinter: Build Active Project` собирает приложение по diff --git a/tests/sdbg/run_type_string_probe.py b/tests/sdbg/run_type_string_probe.py new file mode 100644 index 0000000..17e0ab4 --- /dev/null +++ b/tests/sdbg/run_type_string_probe.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Живой MCP-прогон: строка с Shift и Enter проходит через DSS gets().""" +from __future__ import annotations + +import asyncio +import json +import os +from pathlib import Path +import shutil +import sys +import tempfile +import time + +from mcp import Client, StdioServerParameters + + +ROOT = Path(__file__).resolve().parents[2] + + +async def probe() -> None: + home = Path(os.environ['MAME_HOME']).resolve() + binary = Path(os.environ.get('MAME_BIN', home / 'sprinter')).resolve() + package = ROOT / 'tests/gets/.sprinter-cc-gets' + with tempfile.TemporaryDirectory(prefix='sprinter-type-mcp-', dir='/tmp') as temp: + socket = str(Path(temp) / 'session.sock') + parameters = StdioServerParameters( + command=sys.executable, + args=[str(ROOT / 'toolchain/sdbg_mcp.py'), '--build', str(package), + '--socket', socket, '--mame-home', str(home), + '--mame-bin', str(binary)]) + async with Client(parameters) as client: + async def call(name: str, arguments: dict | None = None) -> dict: + result = await client.call_tool(name, arguments or {}) + if result.is_error or result.structured_content is None: + raise RuntimeError(name + ': ' + repr(result.content)) + return result.structured_content + + names = {tool.name for tool in (await client.list_tools()).tools} + if 'type_string' not in names: + raise RuntimeError('MCP не публикует type_string') + await call('start_session') + try: + deadline = time.monotonic() + 90 + while time.monotonic() < deadline: + status = await call('session_status') + if status['phase'] == 'ready': + break + if status['phase'] in ('failed', 'stopped'): + raise RuntimeError('Launcher: ' + repr(status)) + await asyncio.sleep(.2) + else: + raise TimeoutError('gets не дошёл до main') + + source = str(ROOT / 'tests/gets/gets.c') + point = await call('set_line_breakpoint', {'file': source, 'line': 16}) + await call('continue_execution') + await asyncio.sleep(.5) + if not (await call('session_status'))['running']: + raise RuntimeError('gets не ожидает строку') + typed = await call('type_string', {'value': 'Ab9\n'}) + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + status = await call('session_status') + if not status['running']: + break + await asyncio.sleep(.1) + else: + shot = await call('screenshot') + destination = Path('/private/tmp/sprinter-c-mcp-gets-timeout.png') + shutil.copyfile(shot['path'], destination) + raise TimeoutError('Enter не завершил gets: ' + + repr({'typed': typed, 'status': status, + 'screenshot': str(destination)})) + location = await call('where') + if typed['typed'] != 4 or not typed['complete'] or \ + not any(item['line'] == 16 for item in location['sources']): + raise RuntimeError('Неверный результат набора: ' + + repr((typed, location))) + shot = await call('screenshot') + destination = Path('/private/tmp/sprinter-c-mcp-gets.png') + shutil.copyfile(shot['path'], destination) + await call('clear_breakpoint', {'identifier': point['id']}) + print(json.dumps({'event': 'mcp_type_string_verified', + 'tools': len(names), 'typed': typed, + 'line': 16, 'screenshot': str(destination)}, + ensure_ascii=False), flush=True) + finally: + await call('stop_session') + + +if __name__ == '__main__': + asyncio.run(probe()) diff --git a/tests/sdbg/test_mcp_adapter.py b/tests/sdbg/test_mcp_adapter.py index 0443dd4..8db143b 100644 --- a/tests/sdbg/test_mcp_adapter.py +++ b/tests/sdbg/test_mcp_adapter.py @@ -77,6 +77,32 @@ class McpAdapterTests(unittest.TestCase): self.assertEqual(keys[1]['tag'], keys[-2]['tag']) self.assertTrue(all(item['owner'] == client.owner for item in keys)) + def test_type_string_checks_layout_before_input_and_releases_each_key(self): + calls = [] + frame = 0 + + def rpc(path, method, arguments, timeout=10, **identity): + nonlocal frame + calls.append((method, arguments)) + if method == 'status': + return {'session_id': 'test-session', 'build_id': 'test-build', + 'generation': 7, 'running': True} + if method == 'snapshot': + frame += 1 + return {'state': 'running', 'frame': frame} + return {} + + client = McpSession('/tmp/sprinter-test.sock', rpc=rpc) + with self.assertRaisesRegex(SessionError, 'нет раскладки'): + client.type_string('a€') + self.assertEqual(calls, []) + result = client.type_string('ab') + self.assertEqual(result, {'requested': 2, 'typed': 2, + 'complete': True, 'stopped': False}) + keys = [args for method, args in calls if method == 'input_key'] + self.assertEqual([item['down'] for item in keys], + [True, False, True, False]) + if __name__ == '__main__': unittest.main() diff --git a/toolchain/sdbg/mcp_adapter.py b/toolchain/sdbg/mcp_adapter.py index fcfb5dc..b824e92 100644 --- a/toolchain/sdbg/mcp_adapter.py +++ b/toolchain/sdbg/mcp_adapter.py @@ -130,6 +130,41 @@ class McpSession: self.call('input_key', {'tag': release_tag, 'mask': release_mask, 'down': False, 'owner': self.owner}) + def type_string(self, value: str): + """Послать строку по физической клавиатуре с паузами между клавишами.""" + from mame_interactive import resolve + + if not isinstance(value, str) or not value or len(value) > 64: + raise SessionError('type_string принимает 1..64 символа') + try: + for char in value: + resolve(char) + except ValueError as error: + raise SessionError(str(error)) from error + if not self.status()['running']: + raise SessionError('Ввод строки возможен только при running CPU') + typed = 0 + stopped = False + for char in value: + result = self.press_key(char) + typed += 1 + stopped = result['stopped'] + if stopped or typed == len(value): + break + snapshot = self.call('snapshot') + start = snapshot['frame'] + deadline = time.monotonic() + 4 + while snapshot['state'] == 'running' and snapshot['frame'] - start < 4: + if time.monotonic() >= deadline: + raise SessionError('Межклавишная пауза не получила кадры за 4 с') + time.sleep(.01) + snapshot = self.call('snapshot') + stopped = snapshot['state'] != 'running' + if stopped: + break + return {'requested': len(value), 'typed': typed, + 'complete': typed == len(value), 'stopped': stopped} + def variables(self): return self.call('variables') diff --git a/toolchain/sdbg_mcp.py b/toolchain/sdbg_mcp.py index 2790df3..d321510 100644 --- a/toolchain/sdbg_mcp.py +++ b/toolchain/sdbg_mcp.py @@ -118,6 +118,11 @@ def make_server(client: McpSession | ManagedMcpSession, """Нажать один символ/enter/space/tab на 1..60 кадров; отпустить при stop/ошибке.""" return client.press_key(key, frames) + @tool() + def type_string(value: str) -> dict[str, Any]: + """Ввести 1..64 символа физической PC-клавиатурой; '\\n' означает Enter.""" + return client.type_string(value) + @tool(annotations=reading) def list_variables() -> dict[str, Any]: """Перечислить известные global/static переменные и поддержанность типов."""