diff --git a/docs/TODO.md b/docs/TODO.md index 86d6114..695a904 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -280,13 +280,13 @@ Quick wins: ## Прочий backlog - [ ] **Довести MCP C-уровня до полного безопасного контракта.** Первый - stdio-адаптер с 18 инструментами использует общую sdbg-сессию и + stdio-адаптер с 26 инструментами использует общую sdbg-сессию и owner ID личных точек. Полный план и матрица всех 30 raw-возможностей — - [mcp-convergence-plan.md](mcp-convergence-plan.md). Следующее: + [mcp-convergence-plan.md](mcp-convergence-plan.md). control lease для DAP/MCP и cleanup точек по истечении heartbeat - проверены живым прогоном. Далее проверка - session ID изменяющих команд, безопасные - input/screenshot/console API через session server, затем доказанные + проверены живым прогоном. Привязка RPC к session/build/generation и + экранные API и одиночный `press_key` уже добавлены. Далее очередь + строкового ввода, управляемые поля портов и мышь, затем доказанные watchpoints, запись typed values, disassemble_src и batch-загрузка точек. Raw `mame_mcp.py` с `-plugin mamebridge` не подключать к тому же MAME в обход общей сессии. Старый `mame_bridge.lua` удалён из MAME.HT @@ -297,6 +297,9 @@ Quick wins: raw и C-адаптера разделены. Проверять конкуренцию, ошибки и lifecycle живым DAP/MCP пробником; см. [sdbg-mcp.md](sdbg-mcp.md). + Автономный MCP `start_session`→DSS→`main`→DAP attach→`stop_session` + уже проверен официальным SDK-клиентом; остаётся испытать Codex/Claude + как реальные MCP-клиенты и передачу lease между ними и VS Code. - [ ] **Несколько одновременных экземпляров MAME и MCP bridge (отдельная задача, позже).** Проверить два изолированных процесса с разными дискетами/CHD/state и двумя MCP-сессиями; в протоколе явно связывать diff --git a/docs/mame-source-debug-status.md b/docs/mame-source-debug-status.md index 7dbabf5..b87e586 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` предоставляет 18 инструментов через официальный MCP SDK +- `sdbg_mcp.py` предоставляет 26 инструментов через официальный MCP SDK 2.x поверх того же session server: статус, C-позиция, регистры, logical memory, переменные, события/логи, личные точки и команды исполнения. MCP-точки имеют owner ID и не могут удалить точки VS Code; при обычном @@ -136,6 +136,10 @@ stdio MCP-адаптер C-уровня. Эксклюзивное владени шага после клавиши. Повторный запуск на том же фиксированном socket после прежнего stale-файла прошёл; после Stop путь удалён. + В автономном режиме доступны 28 инструментов: официальный MCP-клиент + запустил `hello`, ввёл `x` через публичный `press_key` во время `getchar()` + и остановился на следующей C-строке. Остальные raw-возможности ввода + перечислены в [матрице](mcp-capability-matrix.md). Контракт запуска и ограничения — в [руководстве по MCP](sdbg-mcp.md). @@ -286,7 +290,10 @@ commit `b0c4527c2edb1ee177fd09b3c412b65b385bf35b`: | MAME.HT 0.289, 2026-09-17 | Чистая сборка `make SUBTARGET=sprinter SOURCES=src/mame/sinclair/sprinter.cpp` и отдельные рецепты `stock`/`sdbg` создали `sprinter`; DAP в `sdbg` и `osx` на patched, а также `osx` на stock дождался DSS и остановился в `hello.c:17` без ложных точек. `getchar()` получил `x`, общий DAP/MCP сохранил ownership точек. Raw `mamebridge` через официальный MCP stdio-клиент перечислил 30 инструментов и ответил на `status`/`read_registers` при остановленном CPU; устаревший `resp_1.txt` не подменил ответ после перезапуска frontend | | Raw MCP plugin/legacy, 2026-09-17 | Все 16 команд удалённого `mame_bridge.lua` найдены в plugin dispatch; MCP stdio-прогоны проверили общий running-набор. Plugin на `hello` прошёл DSS→`main`, запись/восстановление `errno`, реальное срабатывание watchpoint, ввод `x` до строки 63 после `getchar()`, машинные over/out, снимок и пиксели. После удаления legacy-файла оба plugin-пробника повторно прошли | | Видимый raw MCP `hello`, 2026-09-17 | `tests/sdbg/run_raw_mcp_hello_probe.py` запустил MAME.HT с `-video soft -window`; кадр работающего `hello` показал весь текст и `Press any key to exit...`, затем ввод `x` довёл PC до `hello.c:63` | -| Control lease DAP/MCP, 2026-09-17 | 18 C-инструментов, `claim_control`/`release_control`, 30-секундный lease с heartbeat; живой DAP+MCP-прогон подтвердил передачу управления обратно DAP и попадание в его точку. Второй видимый прогон дождался `owner_expired`, проверил удаление orphan-точки MCP и отсутствие ложной остановки; отпускание клавиш проверено unit-тестом | +| Control lease DAP/MCP, 2026-09-17 | `claim_control`/`release_control`, 30-секундный lease с heartbeat; живой DAP+MCP-прогон подтвердил передачу управления обратно DAP и попадание в его точку. Второй видимый прогон дождался `owner_expired`, проверил удаление orphan-точки MCP и отсутствие ложной остановки; отпускание клавиш проверено unit-тестом | +| RPC identity и экран C-MCP, 2026-09-17 | Запросы после handshake привязаны к session/build; мутации сверяют generation. 25 C-инструментов: в живом DAP+MCP-прогоне проверены raw program/logical memory, VRAM/share, порты, пиксели и PNG; при running `hello` MCP снял кадр с `Press any key to exit...`, затем `x` завершил шаг через `getchar()` | +| Автономный MCP, 2026-09-17 | `start_session` ответил за 5–7 мс, затем официальный MCP-клиент дождался DSS→`hello.exe`→`main`; DAP attach увидел тот же PC, disconnect не завершил MAME. Повторный start отклонён, stop удалил socket и процесс MAME. Codex/Claude UI ещё не проверены | +| Публичный MCP `press_key`, 2026-09-17 | В автономном видимом MAME официальный MCP-клиент поставил C-точку перед `getchar()`, продолжил `hello`, ввёл `x` через `press_key` и остановился на следующей строке 63. В автономном режиме доступны 28 инструментов; строковый ввод и мышь остаются в плане | | 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 031d98c..0eb3f78 100644 --- a/docs/mame-source-debug.md +++ b/docs/mame-source-debug.md @@ -536,12 +536,16 @@ console_log и загрузка набора точек. Возвращать bu в арбитраж, а не обходят его. Первый stdio MCP-адаптер уже работает через общий session server и отдаёт -18 проверенных C-инструментов. У личных MCP-точек owner ID; удаление чужой +26 проверенных C-инструментов (28 при автономном start/stop). У личных MCP-точек owner ID; удаление чужой DAP-точки отклоняется. Совместный живой прогон подтвердил чтение состояния, точки и последующее срабатывание DAP-точки. Добавлен control lease для команд CPU и ввода; передача MCP→DAP и cleanup точки владельца без heartbeat -проверены живым прогоном; +проверены живым прогоном. RPC привязан к session/build/generation; экран и +VRAM доступны через общий bridge, что подтверждено снимком работающего `hello`; +одиночный `press_key` проверен на ожидающем `getchar()`; остальные интерфейсы этого раздела ещё не реализованы. +Автономный MCP `start_session` поднимает DSS→EXE→`main` без VS Code; +DAP attach к созданному socket и последующий `stop_session` проверены. Настройка и точный список — [sdbg-mcp.md](sdbg-mcp.md); результат удаления legacy Lua и перенос всех raw MCP-возможностей в общую сессию — [mcp-convergence-plan.md](mcp-convergence-plan.md). diff --git a/docs/mcp-capability-matrix.md b/docs/mcp-capability-matrix.md new file mode 100644 index 0000000..885be63 --- /dev/null +++ b/docs/mcp-capability-matrix.md @@ -0,0 +1,50 @@ +# Покрытие raw MCP в общей C-сессии + +Эталон списка — 30 инструментов `MAME.HT/src/mame_mcp.py` с +`-plugin mamebridge`. Столбец «C-сессия» означает инструмент +`toolchain/sdbg_mcp.py`, который обращается к единственному `sdbg_server.py` +через `sdbgbridge`. Сходное имя само по себе не означает одинаковую адресацию. + +| Raw MCP | C-сессия | Состояние и отличие | +|---|---|---| +| `read_registers` | `read_registers` | Проверено при stop; C-ответ включает страницы банков и generation | +| `read_memory` | `read_program_memory` | Проверено при stop: `0x10000+PC` совпал с logical Z80; raw 0..0x3ffff, до 4096 байт | +| `read_logical_memory` | `read_memory` | Проверено при stop; предел C — 256 байт, проверяется загруженный C-код | +| `read_vram` | `read_vram` | Проверено в живом MAME; 1..4096 байт, точный VRAM share | +| `read_share` | `read_share` | Проверено; C требует точный tag из `list_shares`, raw принимает подстроку | +| `list_shares` | `list_shares` | Проверено; структурированные tag/размер для shares и regions | +| `write_memory` | — | Нужны lease, проверка адреса/банка и инвалидация C-карты | +| `set_breakpoint` | `set_line_breakpoint`, `set_function_breakpoint` | Только проверенные C-адреса; произвольный raw-адрес и условие ещё не покрыты | +| `clear_breakpoint` | `clear_breakpoint`, `clear_my_breakpoints` | Только логические ID текущего владельца, не MAME ID | +| `list_breakpoints` | — | Нужен структурированный список с owner и bank guard | +| `set_watchpoint` | — | Нужны owner, space/access, bank guard и проверка PC при попадании | +| `clear_watchpoint` | — | Зависит от owner-модели watchpoint | +| `step` | `step_instruction` | Один машинный шаг проверен; счётчик `count` ещё не поддержан | +| `step_over` | `step_source(kind="over")` | Проверен C-шаг F10; raw машинный `over count` пока отличается | +| `step_out` | `step_source(kind="out")` | Проверен C-выход; raw выход из одного машинного frame отличается | +| `resume` | `continue_execution` | Проверено с control lease и DAP-событиями | +| `pause` | `pause_execution` | Проверено с control lease | +| `status` | `session_status`, `where` | Проверено; C-статус содержит session/build/generation | +| `disassemble` | — | Нужен явный logical/raw адрес и банк; строковый debugger pass-through не используется | +| `screenshot` | `screenshot` | Проверено на выполняющемся `hello`; только каталог сессии, до 8 МиБ, при stop кадр помечается stale | +| `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_text` | — | Нужна отдельная проверка natural keyboard и прямой физической клавиатуры | +| `move_mouse` | — | Нужна owner-очередь относительных осей | +| `click_mouse` | — | Нужна owner-очередь кнопок | +| `press_input` | — | Нужен whitelist портов/масок и автоснятие удержания | +| `set_input` | — | Нужен whitelist портов/масок и cleanup owner | +| `debugger_command` | — | Произвольная команда обходит DAP; сначала только проверенные читающие команды | + +Живые доказательства для текущего покрытия: `run_vscode_dap_probe.py` +с `--mcp-python` проверяет stdio C-MCP и DAP, включая program/logical memory, +порты, shares/VRAM/PNG; +с `--waitkey --emulated-key --screen-mcp-python` проверяет экран работающего +`hello` во время `getchar()` и последующий ввод. Автономный +`run_managed_mcp_probe.py` через официальный MCP-клиент проверяет +`press_key("x")` и остановку на строке после `getchar()`. +`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 c2b630a..ed39343 100644 --- a/docs/mcp-convergence-plan.md +++ b/docs/mcp-convergence-plan.md @@ -11,7 +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` публикует 18 C-инструментов через общую с DAP сессию. +`toolchain/sdbg_mcp.py` публикует 26 C-инструментов через общую с DAP сессию +(28 в автономном режиме с командами start/stop). **Цель — покрыть возможности всех 30 raw-инструментов в C-сессии, а не механически скопировать имена и небезопасную семантику.** Полный raw MCP остаётся отдельным режимом для задач без C-пакета и других машин MAME. @@ -107,9 +108,11 @@ control lease: команды CPU и ввод принимаются от тек удерживаемые клавиши после истечения heartbeat. Живой прогон с владельцем без heartbeat подтвердил событие `owner_expired`, удаление точки и отсутствие ложной остановки в `hello.c:62` после возобновления DAP. -**Этап Б не закрыт:** ещё нужны защита -всех мутаций, проверка session ID в каждом изменяющем запросе и безопасная -invalidation при reset/load. +RPC теперь привязывает последующие запросы DAP/MCP/CLI к session ID и build ID; +мутации CPU и точек также требуют актуальную generation. Смена сессии/socket +или устаревшая generation дают явный отказ, без автоматического повтора. +**Этап Б не закрыт:** ещё нужны проверка reset/load на живом MAME, контроль +новых типов мутаций и политика передачи lease между долгоживущими клиентами. ## В. Перенести 30 возможностей raw MCP по группам @@ -132,6 +135,20 @@ invalidation при reset/load. pass-through и особенности адресов перечислены поимённо. Число методов C-MCP может быть больше 30 из-за разных адресных пространств и C-операций. +**Промежуточный статус В, 2026-09-17.** Через существующий `sdbgbridge` +добавлены `list_shares`, `read_share`, `read_vram`, `read_screen_pixels`, +`screenshot`, `read_program_memory`, `list_ports` с пределами размеров, +точным tag share, изолированным каталогом PNG и признаком устаревшего кадра +при stop. Живой DAP+MCP-прогон подтвердил совпадение raw program/logical Z80 +в `main`, VRAM/share, список портов и PNG; второй прогон снял экран работающего +`hello` во время `getchar()` и затем завершил шаг клавишей `x`. Полное +поимённое состояние — в [матрице 30 raw-инструментов](mcp-capability-matrix.md). +Одиночный публичный `press_key` теперь требует running CPU и control lease, +удерживает клавишу 1..60 кадров и отпускает её при stop/ошибке. Автономный +официальный MCP-клиент ввёл `x` в ожидающий `getchar()` и попал на следующую +C-строку. Очередь строки, другие имена физических клавиш и мышь ещё не +реализованы; поэтому строка raw `press_key` покрыта частично. + ## Г. Сделать запуск пригодным для Codex и Claude без VS Code Сейчас `sdbg_mcp.py` подключается лишь к уже работающему socket. Сам @@ -157,6 +174,19 @@ pass-through и особенности адресов перечислены п **Выход Г:** C-отладка полностью доступна агенту без VS Code, а подключение агента к VS Code-сессии не создаёт второй MAME и не теряет точки DAP. +**Промежуточный статус Г, 2026-09-17.** `sdbg_mcp.py --build` публикует +асинхронный `start_session`, фазовый `session_status` и `stop_session`. +Живой официальный MCP-клиент получил ответ старта за 5–7 мс, дождался +DSS→`hello.exe`→`main`, прочитал переменную и PNG. DAP подключился к тому же +socket; его disconnect оставил MAME работающим. Повторный MCP start был +отклонён, `stop_session` удалил socket и завершил свой MAME. При закрытии +MCP-процесса supervisor также завершает принадлежащий ему launcher. +Тем же официальным клиентом проверены установка C-точки перед `getchar()`, +ввод `x` через публичный MCP `press_key` и остановка на следующей C-строке. +**Этап Г не закрыт:** нужны реальные подключения из Codex и Claude, +проверка передачи control lease между их клиентами и DAP, а также устойчивость +к сбоям launcher/клиента на разных стадиях boot. + ## Проверки и ограничения на всём пути - Живые прогоны выполнять на `MAME.HT/sprinter`, собранном командой diff --git a/docs/sdbg-mcp.md b/docs/sdbg-mcp.md index a6efaf2..6340aad 100644 --- a/docs/sdbg-mcp.md +++ b/docs/sdbg-mcp.md @@ -44,8 +44,38 @@ pyenv exec python -m venv /tmp/sprinter-sdbg-mcp-venv MCP-сервер заново вместе с новой DAP-сессией. Альтернатива параметру `--socket` — переменная окружения `SDBG_SOCKET`. +Для работы **без VS Code** запустите тот же MCP-сервер с `--build`: + +```json +{ + "command": "/tmp/sprinter-sdbg-mcp-venv/bin/python", + "args": [ + "/ABS/PATH/C-Compiler/toolchain/sdbg_mcp.py", + "--build", "/ABS/PATH/C-Compiler/tests/hello/.sprinter-cc-hello", + "--socket", "/tmp/sprinter-hello-agent.sock", + "--mame-home", "/ABS/PATH/MAME/runtime", + "--mame-bin", "/ABS/PATH/MAME.HT/sprinter" + ] +} +``` + +MCP handshake не ждёт загрузку DSS. `start_session` сразу возвращает socket и +launch ID; `session_status` показывает `starting`, затем `ready` с build ID, +session ID, PC и PID MAME либо `failed` с диагностикой. Повторный start при +активном сеансе отклоняется. `stop_session` завершает только запущенный этим +MCP-сервером MAME и удаляет socket. Закрытие самого MCP-процесса также +завершает его автономный MAME. DAP/VS Code может подключиться к тому же socket; +его disconnect не завершает MCP-owned MAME. Конфигурация VS Code — в +[разделе attach](vscode-sprinter-debug.md#ручной-attach). +Доступны также `--mame-rompath`, `--mame-dss-image`, +`--mame-system-hdd-image`, `--mame-bios`, `--app-hdd`, `--launch-path`, +`--data`, `--debugger`, `--launch-at`, `--dss-timeout`. +Живой stdio MCP→DSS→`main`→DAP attach→stop прогон прошёл; подключение +непосредственно из UI Codex и Claude ещё не проверено. + Доступные инструменты: `session_status`, `where`, `read_registers`, -`read_memory`, `list_variables`, `read_variable`, `recent_events`, +`read_memory`, `read_program_memory`, `list_ports`, `list_shares`, `read_share`, `read_vram`, +`read_screen_pixels`, `screenshot`, `press_key`, `list_variables`, `read_variable`, `recent_events`, `mame_console_tail`, `set_line_breakpoint`, `set_function_breakpoint`, `clear_breakpoint`, `clear_my_breakpoints`, `claim_control`, `release_control`, `continue_execution`, @@ -55,7 +85,8 @@ MCP-сервер заново вместе с новой DAP-сессией. А `recent_events` с курсором `last`. Логи `SDBG_LOG` приходят как события `output`; журнал ограничен 1024 событиями и сообщает `first`/`last`/`lost`. -Число 16 не означает удаление возможностей более нового `-plugin mamebridge`: +Число 26 в attach-режиме (28 в автономном с `start_session`/`stop_session`) +не означает полного переноса более нового `-plugin mamebridge`: его `src/mame_mcp.py` публикует 30 raw-инструментов, ориентированных на машину MAME. Здесь инструменты сгруппированы по операциям C-сессии, а некоторые функции (C-позиция, typed global/static, журнал с generation) @@ -64,15 +95,16 @@ MCP-сервер заново вместе с новой DAP-сессией. А | Группа raw MCP | Что требуется перед переносом в общую сессию | |---|---| | `setmem`, raw `debugger_command`, watchpoints | Проверка прав, банка, диапазона и согласование с DAP-точками; произвольная debugger-команда может нарушить состояние сессии | -| VRAM/shares, screen pixels, screenshot | Явные адресные пространства, лимиты, формат ответа и изолированный каталог снимков | -| Клавиатура, мышь, type/press | Один владелец ввода, корректное отпускание клавиш, ожидание running CPU и проверка прямого ввода с клавиатуры | +| VRAM/shares, screen pixels, screenshot | Реализованы через общий `sdbgbridge`; точный tag, пределы 4096 байт/8192 пикселя, PNG только в каталоге сессии | +| Клавиатура, мышь, type/press | `list_ports` и одиночный `press_key` уже перенесены; набор строки, произвольные поля портов и мышь требуют отдельного контракта ввода | | Raw disassembly | Связать адрес/банк с проверенной C-картой; не выдавать физический адрес за logical | Это следующий этап, а не запрет на функции raw MCP. Нельзя просто загрузить `mamebridge` рядом с `sdbgbridge`: два независимых обработчика начнут менять CPU и точки без общего owner ID и журнала. Результаты удаления legacy Lua и этапы переноса всех 30 возможностей в общую -C-сессию описаны в [плане сведения MCP](mcp-convergence-plan.md). +C-сессию описаны в [плане сведения MCP](mcp-convergence-plan.md) и +[матрице покрытия](mcp-capability-matrix.md). Чтение памяти принимает десятичный адрес или `0xHEX`, 1–256 байт logical Z80 memory, только при остановленном CPU, без side effects. Ответ содержит @@ -81,6 +113,37 @@ generation и страницы банков; диапазон не может п байта, только когда нужный банк отображён. Локальные, стек, watchpoints, запись переменных и произвольные выражения пока не поддерживаются. +`read_program_memory` читает raw program space 0..0x3ffff без side effects, +до 4096 байт при остановленном CPU; это отдельный инструмент, так как +`read_memory` означает logical Z80. `list_ports` возвращает tag, имена полей +и битовые маски; результат ограничен 512 портами/4096 полями и помечает +`truncated`, если достигнут предел. Оба инструмента проверены в живом MAME. + +`list_shares` возвращает точные tag/размеры. `read_share` требует полный tag, +`read_vram` выбирает единственный VRAM share; обе операции читают до 4096 +байт без Z80 bank mapping. `read_screen_pixels` возвращает pen16-значения +в hex и номер кадра. `screenshot` сохраняет PNG до 8 МиБ в каталоге текущей +сессии и возвращает путь; после завершения сессии временный каталог удаляется. +Для обоих экранных инструментов `stale_frame=true` означает, что CPU +остановлен и показан последний нарисованный кадр. Экран работающего `hello` +во время `getchar()` проверен живым MCP-прогоном. + +`press_key` принимает один символ раскладки PC или `enter`, `space`, `tab` +и `frames` от 1 до 60 (по умолчанию 3). Он доступен только при работающем +CPU, после запуска EXE; применяется общий control lease. Shift удерживается +автоматически для заглавных букв и соответствующих символов. После нужного +числа кадров или остановки CPU клавиша отпускается, включая путь ошибки. +Ответ сообщает число прошедших кадров и признак остановки. Живой автономный +MCP-прогон ввёл `x` в ожидающий `getchar()` и дошёл до следующей C-строки. +Прямой физический ввод в MAME проверялся отдельно; `press_key` не заменяет +его. Набор строки, произвольные имена клавиш, мышь и порты ввода ещё не +публичны в C-MCP. + +После первого `session_status` клиент отправляет session ID и build ID в +каждом RPC; команды, меняющие CPU/точки, дополнительно сверяют generation. +При замене сеанса или устаревшей generation команда отклоняется без +автоматического повтора. + У каждой MCP-копии свой owner ID. Она может удалить только свои точки; при обычном закрытии stdio они очищаются. Живой тест подтвердил, что попытка удалить DAP-точку отвергается и точка VS Code срабатывает после выхода MCP. diff --git a/docs/vscode-sprinter-debug.md b/docs/vscode-sprinter-debug.md index 7484ccc..782560b 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. Порядок запуска, -16 доступных инструментов и ограничения совместного управления описаны в +26 доступных инструментов и ограничения совместного управления описаны в [руководстве по MCP](sdbg-mcp.md). Команда палитры `Sprinter: Build Active Project` собирает приложение по @@ -309,7 +309,8 @@ trampoline до первой C-позиции вызывающей функци ## Ручной attach -Для общего MAME между CLI и VS Code сначала запустите `sdbg_server.py`, затем: +Для общего MAME между CLI и VS Code сначала запустите `sdbg_server.py` либо +автономный MCP `start_session` и дождитесь `session_status.phase=ready`, затем: ```json { @@ -321,7 +322,9 @@ trampoline до первой C-позиции вызывающей функци ``` Команды server и диагностического CLI приведены в -[mame-source-debug-status.md](mame-source-debug-status.md). +[mame-source-debug-status.md](mame-source-debug-status.md). Автономный +MCP-запуск описан в [sdbg-mcp.md](sdbg-mcp.md); DAP disconnect не завершает +MAME, созданный MCP, а `stop_session` завершает его вместе с socket. ## Ограничения MVP diff --git a/tests/sdbg/run_macro_log_probe.py b/tests/sdbg/run_macro_log_probe.py index fef8fc8..ad9054c 100644 --- a/tests/sdbg/run_macro_log_probe.py +++ b/tests/sdbg/run_macro_log_probe.py @@ -60,7 +60,10 @@ def main(): raise RuntimeError('DAP завершился до авторского лога') if event.get('event') == 'output': outputs.append(event) socket = launched['body']['socket'] - tail = rpc_call(socket, 'mame_console_tail', {'count': 50}, timeout=6) + identity = rpc_call(socket, 'status') + tail = rpc_call(socket, 'mame_console_tail', {'count': 50}, timeout=6, + session_id=identity['session_id'], + build_id=identity['build_id']) if not any('total=1' in line for line in tail['lines']): raise RuntimeError('В debugger console MAME нет total=1: '+str(tail)) print(json.dumps({'event': 'macro_dual_console_verified', diff --git a/tests/sdbg/run_managed_mcp_probe.py b/tests/sdbg/run_managed_mcp_probe.py new file mode 100644 index 0000000..996aa10 --- /dev/null +++ b/tests/sdbg/run_managed_mcp_probe.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +"""MCP сам запускает hello, затем DAP подключается к тому же MAME.""" +from __future__ import annotations + +import asyncio +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import time + +from mcp import Client, StdioServerParameters + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from run_vscode_dap_probe import send, wait_response # noqa: E402 + + +async def probe() -> None: + home = Path(os.environ['MAME_HOME']).resolve() + binary = Path(os.environ.get('MAME_BIN', home / 'sprinter')).resolve() + package = ROOT / 'tests/hello/.sprinter-cc-hello' + with tempfile.TemporaryDirectory(prefix='sprinter-managed-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 not {'start_session', 'stop_session', 'where', 'read_variable', + 'press_key'} <= names: + raise RuntimeError('Нет инструментов автономного запуска') + initial = await call('session_status') + if initial['phase'] != 'idle': + raise RuntimeError('Ожидался idle: ' + repr(initial)) + started_at = time.monotonic() + accepted = await call('start_session') + start_ms = round((time.monotonic() - started_at) * 1000, 1) + if not accepted['accepted'] or start_ms > 3000: + raise RuntimeError('MCP start_session не ответил быстро: ' + repr(accepted)) + duplicate = await client.call_tool('start_session', {}) + if not duplicate.is_error or 'уже запущена' not in str(duplicate.content): + raise RuntimeError('Повторный start_session не был отклонён') + mame_pid = None + try: + deadline = time.monotonic() + 85 + 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('MCP session не дошла до main') + mame_pid = status['mame_pid'] + location = await call('where') + if location.get('function', {}).get('name') != 'main': + raise RuntimeError('MCP не остановился в main: ' + repr(location)) + await call('read_variable', {'name': 'errno'}) + shot = await call('screenshot') + if Path(shot['path']).read_bytes()[:8] != b'\x89PNG\r\n\x1a\n': + raise RuntimeError('MCP не создал PNG') + + dap = subprocess.Popen([sys.executable, str(ROOT / 'toolchain/sdbg_dap.py')], + cwd=ROOT, stdin=subprocess.PIPE, + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + buffer = b'' + try: + send(dap, 1, 'initialize') + _, buffer, _ = wait_response(dap, buffer, 'initialize', 10) + send(dap, 2, 'attach', {'socket': socket}) + _, buffer, _ = wait_response(dap, buffer, 'attach', 10) + send(dap, 3, 'configurationDone') + _, buffer, _ = wait_response(dap, buffer, 'configurationDone', 10) + send(dap, 4, 'stackTrace') + frame, buffer, _ = wait_response(dap, buffer, 'stackTrace', 10) + if frame['body']['stackFrames'][0]['name'] != 'main': + raise RuntimeError('DAP attach не увидел main') + send(dap, 5, 'disconnect') + wait_response(dap, buffer, 'disconnect', 10) + finally: + if dap.poll() is None: + dap.terminate() + dap.communicate(timeout=8) + + after_dap = await call('session_status') + if after_dap['phase'] != 'ready' or after_dap['session_id'] != status['session_id']: + raise RuntimeError('DAP disconnect завершил MCP-owned MAME') + source = str(ROOT / 'tests/hello/hello.c') + before = await call('set_line_breakpoint', {'file': source, 'line': 62}) + await call('continue_execution') + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + at_getchar = await call('session_status') + if not at_getchar['running']: + break + await asyncio.sleep(.1) + else: + raise TimeoutError('MCP не остановился перед getchar') + location = await call('where') + if not any(item['line'] == 62 for item in location['sources']): + raise RuntimeError('MCP остановился не перед getchar: ' + repr(location)) + await call('clear_breakpoint', {'identifier': before['id']}) + after = await call('set_line_breakpoint', {'file': source, 'line': 63}) + await call('continue_execution') + await asyncio.sleep(.4) + key = await call('press_key', {'key': 'x', 'frames': 3}) + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + after_key = await call('session_status') + if not after_key['running']: + break + await asyncio.sleep(.1) + else: + raise TimeoutError('MCP press_key не завершил getchar') + location = await call('where') + if not any(item['line'] == 63 for item in location['sources']): + raise RuntimeError('После клавиши ожидалась строка 63: ' + repr(location)) + await call('clear_breakpoint', {'identifier': after['id']}) + print(json.dumps({'event': 'mcp_launch_dap_attach_verified', + 'tools': len(names), 'start_ms': start_ms, + 'session_id': status['session_id'], + 'socket': socket, 'pc': location['pc'], + 'key': key['key'], 'after_getchar_line': 63}, + ensure_ascii=False), flush=True) + finally: + await call('stop_session') + deadline = time.monotonic() + 12 + while time.monotonic() < deadline: + ended = await call('session_status') + if ended['phase'] == 'stopped': + break + await asyncio.sleep(.1) + else: + raise RuntimeError('stop_session не завершил launcher') + if Path(socket).exists(): + raise RuntimeError('После stop_session остался Unix socket') + if mame_pid is not None: + deadline = time.monotonic() + 8 + while time.monotonic() < deadline: + try: + os.kill(mame_pid, 0) + except ProcessLookupError: + break + await asyncio.sleep(.1) + else: + raise RuntimeError('После stop_session остался процесс MAME') + + +if __name__ == '__main__': + asyncio.run(probe()) diff --git a/tests/sdbg/run_mcp_screen_probe.py b/tests/sdbg/run_mcp_screen_probe.py new file mode 100644 index 0000000..47b8915 --- /dev/null +++ b/tests/sdbg/run_mcp_screen_probe.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""Живой MCP 2.x пробник экрана выполняющегося hello во время getchar().""" +from __future__ import annotations + +import argparse +import asyncio +import json +from pathlib import Path +import shutil +import sys + +from mcp import Client, StdioServerParameters + +ROOT = Path(__file__).resolve().parents[2] + + +async def probe(socket: str, proof: Path | None) -> None: + parameters = StdioServerParameters( + command=sys.executable, + args=[str(ROOT / 'toolchain/sdbg_mcp.py'), '--socket', socket]) + 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 + + status = await call('session_status') + if not status['running']: + raise RuntimeError('Ожидался выполняющийся hello в getchar()') + pixels = await call('read_screen_pixels', { + 'x': 0, 'y': 0, 'width': 8, 'height': 8}) + if pixels['stale_frame'] or len(pixels['hex']) != 256: + raise RuntimeError('Нет свежих пикселей выполняющегося hello') + shot = await call('screenshot') + source = Path(shot['path']) + if shot['stale_frame'] or source.read_bytes()[:8] != b'\x89PNG\r\n\x1a\n': + raise RuntimeError('Нет PNG выполняющегося hello') + if proof: + proof.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source, proof) + print(json.dumps({'event': 'mcp_hello_running_screen', + 'frame': shot['frame'], 'size': shot['size'], + 'proof': str(proof) if proof else str(source)}, + ensure_ascii=False), flush=True) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--socket', required=True) + parser.add_argument('--proof', type=Path) + args = parser.parse_args() + asyncio.run(probe(args.socket, args.proof)) + + +if __name__ == '__main__': + main() diff --git a/tests/sdbg/run_mcp_sdk_probe.py b/tests/sdbg/run_mcp_sdk_probe.py index 2de5c1b..d3f3da4 100644 --- a/tests/sdbg/run_mcp_sdk_probe.py +++ b/tests/sdbg/run_mcp_sdk_probe.py @@ -22,7 +22,9 @@ async def probe(socket: str, source: str, foreign_id: int): names = {tool.name for tool in listed.tools} expected = {'session_status', 'where', 'read_registers', 'read_memory', 'recent_events', 'set_line_breakpoint', 'clear_breakpoint', - 'claim_control', 'release_control'} + 'claim_control', 'release_control', 'list_shares', + 'read_share', 'read_vram', 'read_screen_pixels', 'screenshot', + 'read_program_memory', 'list_ports'} if not expected <= names: raise RuntimeError('Не хватает MCP-инструментов: ' + str(expected - names)) @@ -44,6 +46,33 @@ async def probe(socket: str, source: str, foreign_id: int): registers['registers']['PC'] != location['pc'] or \ len(memory['hex']) != 8 or not events['events']: raise RuntimeError('Неполный C-снимок MCP: ' + repr((status, location, memory))) + program = await call('read_program_memory', { + 'address': hex(0x10000 + location['pc']), 'length': 4}) + if program['hex'] != memory['hex'] or program['space'] != 'program': + raise RuntimeError('Raw program и logical Z80 расходятся в main') + ports = await call('list_ports') + if ports['truncated'] or not any(':kbd:ms_naturl:' in item['tag'] + for item in ports['ports']): + raise RuntimeError('Порты PC-клавиатуры не найдены: ' + repr(ports)[:500]) + + shares = await call('list_shares') + vram = [item for item in shares['shares'] if 'vram' in item['tag']] + if len(vram) != 1 or vram[0]['size'] < 4: + raise RuntimeError('VRAM share не найден: ' + repr(shares)) + raw_vram = await call('read_vram', {'address': '0', 'length': 4}) + same_share = await call('read_share', { + 'tag': vram[0]['tag'], 'address': '0', 'length': 4}) + if len(raw_vram['hex']) != 8 or raw_vram['hex'] != same_share['hex']: + raise RuntimeError('Чтение VRAM/share различается') + pixels = await call('read_screen_pixels', { + 'x': 0, 'y': 0, 'width': 2, 'height': 2}) + if len(pixels['hex']) != 16 or not pixels['stale_frame']: + raise RuntimeError('Неверный снимок пикселей: ' + repr(pixels)) + shot = await call('screenshot') + snapshot = Path(shot['path']) + if snapshot.suffix != '.png' or snapshot.stat().st_size != shot['size'] or \ + snapshot.read_bytes()[:8] != b'\x89PNG\r\n\x1a\n': + raise RuntimeError('Снимок MAME не является PNG: ' + repr(shot)) own = await call('set_line_breakpoint', {'file': source, 'line': 62}) foreign = await client.call_tool('clear_breakpoint', {'identifier': foreign_id}) @@ -59,7 +88,9 @@ async def probe(socket: str, source: str, foreign_id: int): 'build_id': status['build_id'], 'pc': location['pc'], 'tools': len(names), 'foreign_point_rejected': True, 'owned_point_for_cleanup': own['id'], - 'control_claim_release': True}, + 'control_claim_release': True, + 'shares_pixels_png': True, + 'program_and_ports': True}, ensure_ascii=False), flush=True) diff --git a/tests/sdbg/run_vscode_dap_probe.py b/tests/sdbg/run_vscode_dap_probe.py index 13234a0..396d896 100644 --- a/tests/sdbg/run_vscode_dap_probe.py +++ b/tests/sdbg/run_vscode_dap_probe.py @@ -19,7 +19,7 @@ ROOT = Path(__file__).resolve().parents[2] PYTHON = Path.home()/'.pyenv/shims/python' sys.path.insert(0, str(ROOT/'toolchain')) from mame_interactive import resolve -from sdbg.server import rpc_call +from sdbg.server import RPC_GENERATION_MUTATIONS, rpc_call as raw_rpc_call from sdbg.session import SessionError @@ -98,6 +98,10 @@ def main(): help='Python 3.12 с MCP SDK 2.x для совместной живой пробы DAP/MCP') parser.add_argument('--orphan-expiry', action='store_true', help='проверить очистку точки владельца MCP без heartbeat через 30 с') + parser.add_argument('--screen-mcp-python', default=None, + help='Python с MCP SDK 2.x для снимка hello во время getchar') + parser.add_argument('--screen-proof', default=None, + help='сохранить PNG работающего hello вне временной сессии') parser.add_argument('--clear-while-running', action='store_true', help='во время WAITKEY удалить личную точку; использовать с --waitkey --emulated-key') parser.add_argument('--exit-while-stopped', action='store_true', @@ -128,6 +132,14 @@ def main(): launch['launchPath'] = options.launch_path send(process, 2, 'launch', launch) launched, buffer, events = wait_response(process, buffer, 'launch', 80) + session_identity = raw_rpc_call(launched['body']['socket'], 'status') + def session_rpc(path, method, arguments=None, timeout=10): + identity = {'session_id': session_identity['session_id'], + 'build_id': session_identity['build_id']} + if method in RPC_GENERATION_MUTATIONS: + current = raw_rpc_call(path, 'status', **identity) + identity['generation'] = current['generation'] + return raw_rpc_call(path, method, arguments, timeout, **identity) if not any(item.get('event') == 'initialized' for item in events): initialized, buffer = receive(process, buffer, 5) if initialized.get('event') != 'initialized': @@ -156,13 +168,13 @@ def main(): raise RuntimeError('MCP SDK probe: '+probe.stderr[-4000:]) if options.orphan_expiry: owner = 'mcp:orphan-expiry-probe' - orphan = rpc_call(launched['body']['socket'], 'break_line', { + orphan = session_rpc(launched['body']['socket'], 'break_line', { 'file': source, 'line': 62, 'owner': owner}) - rpc_call(launched['body']['socket'], 'claim_control', {'owner': owner}) + session_rpc(launched['body']['socket'], 'claim_control', {'owner': owner}) print('Ожидание истечения MCP owner и очистки его точки...', flush=True) deadline = time.monotonic()+36 while time.monotonic() < deadline: - events = rpc_call(launched['body']['socket'], 'events', {'after': 0}) + events = session_rpc(launched['body']['socket'], 'events', {'after': 0}) if any(event['event'] == 'owner_expired' and event['body']['owner'] == owner for event in events['events']): break @@ -170,7 +182,7 @@ def main(): else: raise RuntimeError('MCP owner не истёк за 36 с') try: - rpc_call(launched['body']['socket'], 'clear_breakpoint', { + session_rpc(launched['body']['socket'], 'clear_breakpoint', { 'id': orphan['id'], 'owner': owner}) except SessionError as error: if 'Неизвестная логическая точка' not in str(error): @@ -284,37 +296,49 @@ def main(): socket_path = launched['body']['socket'] clear_point = None if options.clear_while_running: - clear_point = rpc_call(socket_path, 'break_line', { + clear_point = session_rpc(socket_path, 'break_line', { 'file': str(ROOT/'tests/hello/hello.c'), 'line': 63, 'owner': 'probe:running-clear'}) started = time.monotonic() send(process, 8, 'next') _, buffer, _ = wait_response(process, buffer, 'next', 3) response_ms = round((time.monotonic()-started)*1000, 2) + if options.screen_mcp_python: + time.sleep(.6) + screen_command = [options.screen_mcp_python, + str(ROOT/'tests/sdbg/run_mcp_screen_probe.py'), + '--socket', socket_path] + if options.screen_proof: + screen_command += ['--proof', options.screen_proof] + screen_probe = subprocess.run(screen_command, capture_output=True, + text=True, timeout=20, check=False) + if screen_probe.returncode: + raise RuntimeError('MCP screen probe: '+screen_probe.stderr[-3000:]) + print(screen_probe.stdout.strip(), flush=True) if clear_point: - snapshot = rpc_call(socket_path, 'snapshot', timeout=5) + snapshot = session_rpc(socket_path, 'snapshot', timeout=5) if snapshot['state'] != 'running': raise RuntimeError('CPU не выполняется перед очисткой точки') try: - rpc_call(socket_path, 'read_memory', {'address': 0, 'length': 1}) + session_rpc(socket_path, 'read_memory', {'address': 0, 'length': 1}) raise RuntimeError('Чтение памяти разрешено при running CPU') except SessionError as error: if 'остановленного CPU' not in str(error): raise - rpc_call(socket_path, 'clear_breakpoint', { + session_rpc(socket_path, 'clear_breakpoint', { 'id': clear_point['id'], 'owner': 'probe:running-clear'}) if '--emulated-key' in sys.argv: - first = rpc_call(socket_path, 'snapshot', timeout=5) + first = session_rpc(socket_path, 'snapshot', timeout=5) time.sleep(1) - second = rpc_call(socket_path, 'snapshot', timeout=5) + second = session_rpc(socket_path, 'snapshot', timeout=5) if second['paused'] or second['time'] <= first['time']: raise RuntimeError('MAME не обновляет input frames: '+ str((first, second))) tag, mask, _ = resolve('x') - rpc_call(socket_path, 'input_key', + session_rpc(socket_path, 'input_key', {'tag': tag, 'mask': mask, 'down': True}, timeout=5) time.sleep(.15) - rpc_call(socket_path, 'input_key', + session_rpc(socket_path, 'input_key', {'tag': tag, 'mask': mask, 'down': False}, timeout=5) key_stop, buffer = wait_event(process, buffer, 'stopped', 10) if not key_stop: @@ -324,7 +348,7 @@ def main(): after = after_response['body']['stackFrames'][0] if after['line'] != 63: raise RuntimeError('После клавиши ожидалась строка 63: '+str(after)) - ascii_code = rpc_call(socket_path, 'registers', timeout=5) + ascii_code = session_rpc(socket_path, 'registers', timeout=5) ascii_code = ascii_code['registers']['DE'] & 0xff if ascii_code != ord('x'): raise RuntimeError(f'WAITKEY вернул {ascii_code:#x}, ожидался x (0x78)') @@ -340,7 +364,7 @@ def main(): return 0 if '--manual-key' in sys.argv: socket_path = launched['body']['socket'] - keyboard_state = rpc_call(socket_path, 'snapshot', timeout=5)['keyboards'] + keyboard_state = session_rpc(socket_path, 'snapshot', timeout=5)['keyboards'] if keyboard_state.get(':kbd:ms_naturl') is not True: raise RuntimeError('Физическая PC-клавиатура MAME выключена: '+ str(keyboard_state)) @@ -357,7 +381,7 @@ def main(): after = after_response['body']['stackFrames'][0] if after['line'] != 63: raise RuntimeError('После клавиши ожидалась строка 63: '+str(after)) - ascii_code = rpc_call(socket_path, 'registers', timeout=5) + ascii_code = session_rpc(socket_path, 'registers', timeout=5) ascii_code = ascii_code['registers']['DE'] & 0xff if ascii_code != ord('x'): raise RuntimeError(f'WAITKEY вернул {ascii_code:#x}, ожидался x (0x78)') diff --git a/tests/sdbg/test_mcp_adapter.py b/tests/sdbg/test_mcp_adapter.py index 79298b4..0443dd4 100644 --- a/tests/sdbg/test_mcp_adapter.py +++ b/tests/sdbg/test_mcp_adapter.py @@ -14,8 +14,11 @@ class McpAdapterTests(unittest.TestCase): def setUp(self): self.calls = [] - def rpc(path, method, arguments, timeout=10): - self.calls.append((path, method, arguments, timeout)) + def rpc(path, method, arguments, timeout=10, **identity): + self.calls.append((path, method, arguments, timeout, identity)) + if method == 'status': + return {'session_id': 'test-session', 'build_id': 'test-build', + 'generation': 7} return {'method': method, 'arguments': arguments} self.client = McpSession('/tmp/sprinter-test.sock', rpc=rpc) @@ -26,9 +29,13 @@ class McpAdapterTests(unittest.TestCase): self.client.clear_breakpoint(7) self.client.clear_owned_breakpoints() self.assertTrue(self.client.owner.startswith('mcp:')) - for _, _, arguments, _ in self.calls: + for _, method, arguments, _, identity in self.calls: + if method == 'status': + continue self.assertEqual(arguments['owner'], self.client.owner) - self.assertEqual([item[1] for item in self.calls], + self.assertEqual(identity['session_id'], 'test-session') + self.assertEqual(identity['build_id'], 'test-build') + self.assertEqual([item[1] for item in self.calls if item[1] != 'status'], ['break_line', 'break_function', 'clear_breakpoint', 'clear_owned_breakpoints']) @@ -36,7 +43,7 @@ class McpAdapterTests(unittest.TestCase): self.client.read_memory('0xc000', 16) self.assertEqual(self.calls[-1][2], {'address': 0xc000, 'length': 16}) self.client.events(after=4, timeout=12) - self.assertEqual(self.calls[-1][1:], + self.assertEqual(self.calls[-1][1:4], ('events', {'after': 4, 'timeout': 12, 'owner': self.client.owner}, 14)) before = len(self.calls) @@ -48,6 +55,28 @@ class McpAdapterTests(unittest.TestCase): self.client.step_source('back') self.assertEqual(len(self.calls), before) + def test_press_key_releases_shift_after_snapshot_error(self): + calls = [] + + def rpc(path, method, arguments, timeout=10, **identity): + calls.append((method, arguments)) + if method == 'status': + return {'session_id': 'test-session', 'build_id': 'test-build', + 'generation': 7, 'running': True} + if method == 'snapshot': + raise SessionError('Потеряна связь с MAME') + return {} + + client = McpSession('/tmp/sprinter-test.sock', rpc=rpc) + with self.assertRaisesRegex(SessionError, 'Потеряна связь'): + client.press_key('X') + keys = [args for method, args in calls if method == 'input_key'] + self.assertEqual([item['down'] for item in keys], + [True, True, False, False]) + self.assertEqual(keys[0]['tag'], keys[-1]['tag']) + self.assertEqual(keys[1]['tag'], keys[-2]['tag']) + self.assertTrue(all(item['owner'] == client.owner for item in keys)) + if __name__ == '__main__': unittest.main() diff --git a/tests/sdbg/test_server.py b/tests/sdbg/test_server.py index 428f4ab..0c4f85d 100644 --- a/tests/sdbg/test_server.py +++ b/tests/sdbg/test_server.py @@ -22,6 +22,7 @@ class DummyBridge: def __init__(self): self.calls = [] self.state = 'stopped' + self.generation = 1 def close(self): pass def request(self, command, **arguments): self.calls.append((command, arguments)) @@ -189,8 +190,26 @@ class ServerTests(unittest.TestCase): try: status = rpc_call(server.path, 'status') self.assertEqual(status['build_id'], 'test') - events = rpc_call(server.path, 'events', {'after': 0}) + identity = {'session_id': status['session_id'], + 'build_id': status['build_id']} + events = rpc_call(server.path, 'events', {'after': 0}, **identity) self.assertEqual(events['events'][0]['event'], 'stopped') + with self.assertRaisesRegex(SessionError, 'Устаревшая RPC-сессия'): + rpc_call(server.path, 'events', {'after': 0}) + with self.assertRaisesRegex(SessionError, 'Устаревшая RPC-сессия'): + rpc_call(server.path, 'events', {'after': 0}, + session_id='old', build_id=status['build_id']) + with self.assertRaisesRegex(SessionError, 'Устаревшая RPC-сессия'): + rpc_call(server.path, 'events', {'after': 0}, + session_id=status['session_id'], build_id='old-build') + with self.assertRaisesRegex(SessionError, 'Устаревшая generation'): + rpc_call(server.path, 'break_line', + {'file': '/src/main.c', 'line': 3}, + generation=0, **identity) + point = rpc_call(server.path, 'break_line', + {'file': '/src/main.c', 'line': 3}, + generation=status['generation'], **identity) + self.assertEqual(point['id'], 1) with self.assertRaisesRegex(SessionError, 'Неизвестный'): rpc_call(server.path, 'unknown') finally: @@ -290,6 +309,21 @@ class ServerTests(unittest.TestCase): finally: controller.close() + def test_screen_and_share_reads_reject_oversized_requests(self): + controller = SessionController(DummySession()) + try: + with self.assertRaisesRegex(SessionError, 'длина 1..4096'): + controller.call('read_share', {'tag': ':vram', 'address': 0, + 'length': 4097}) + with self.assertRaisesRegex(SessionError, 'точный tag'): + controller.call('read_share', {'tag': '', 'address': 0, + 'length': 1}) + with self.assertRaisesRegex(SessionError, '8192 пикселей'): + controller.call('read_screen_pixels', {'x': 0, 'y': 0, + 'width': 128, 'height': 128}) + finally: + controller.close() + def test_idle_snapshot_detects_invalidation(self): session = DummySession() controller = SessionController(session) diff --git a/toolchain/mcp/sdbgbridge/init.lua b/toolchain/mcp/sdbgbridge/init.lua index 1c1b450..1648c52 100644 --- a/toolchain/mcp/sdbgbridge/init.lua +++ b/toolchain/mcp/sdbgbridge/init.lua @@ -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.tag0 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=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 diff --git a/toolchain/sdbg/dap.py b/toolchain/sdbg/dap.py index 7c7b549..f21b75f 100644 --- a/toolchain/sdbg/dap.py +++ b/toolchain/sdbg/dap.py @@ -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 = [] diff --git a/toolchain/sdbg/mcp_adapter.py b/toolchain/sdbg/mcp_adapter.py index e108328..ad3a68d 100644 --- a/toolchain/sdbg/mcp_adapter.py +++ b/toolchain/sdbg/mcp_adapter.py @@ -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') diff --git a/toolchain/sdbg/server.py b/toolchain/sdbg/server.py index 644ebde..a7cc67f 100644 --- a/toolchain/sdbg/server.py +++ b/toolchain/sdbg/server.py @@ -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'] diff --git a/toolchain/sdbg/supervisor.py b/toolchain/sdbg/supervisor.py new file mode 100644 index 0000000..a6a26be --- /dev/null +++ b/toolchain/sdbg/supervisor.py @@ -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) diff --git a/toolchain/sdbg_client.py b/toolchain/sdbg_client.py index f4e3837..cc2b2bb 100644 --- a/toolchain/sdbg_client.py +++ b/toolchain/sdbg_client.py @@ -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: diff --git a/toolchain/sdbg_launcher.py b/toolchain/sdbg_launcher.py index 2f79493..1fb948c 100644 --- a/toolchain/sdbg_launcher.py +++ b/toolchain/sdbg_launcher.py @@ -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. diff --git a/toolchain/sdbg_mcp.py b/toolchain/sdbg_mcp.py index 98c607b..9b620f7 100644 --- a/toolchain/sdbg_mcp.py +++ b/toolchain/sdbg_mcp.py @@ -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__':