Expand shared Sprinter MCP with managed launch, raw reads and key input
This commit is contained in:
+8
-5
@@ -280,13 +280,13 @@ Quick wins:
|
|||||||
## Прочий backlog
|
## Прочий backlog
|
||||||
|
|
||||||
- [ ] **Довести MCP C-уровня до полного безопасного контракта.** Первый
|
- [ ] **Довести MCP C-уровня до полного безопасного контракта.** Первый
|
||||||
stdio-адаптер с 18 инструментами использует общую sdbg-сессию и
|
stdio-адаптер с 26 инструментами использует общую sdbg-сессию и
|
||||||
owner ID личных точек. Полный план и матрица всех 30 raw-возможностей —
|
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
|
control lease для DAP/MCP и cleanup точек по истечении heartbeat
|
||||||
проверены живым прогоном. Далее проверка
|
проверены живым прогоном. Привязка RPC к session/build/generation и
|
||||||
session ID изменяющих команд, безопасные
|
экранные API и одиночный `press_key` уже добавлены. Далее очередь
|
||||||
input/screenshot/console API через session server, затем доказанные
|
строкового ввода, управляемые поля портов и мышь, затем доказанные
|
||||||
watchpoints, запись typed values, disassemble_src и batch-загрузка
|
watchpoints, запись typed values, disassemble_src и batch-загрузка
|
||||||
точек. Raw `mame_mcp.py` с `-plugin mamebridge` не подключать к тому же
|
точек. Raw `mame_mcp.py` с `-plugin mamebridge` не подключать к тому же
|
||||||
MAME в обход общей сессии. Старый `mame_bridge.lua` удалён из MAME.HT
|
MAME в обход общей сессии. Старый `mame_bridge.lua` удалён из MAME.HT
|
||||||
@@ -297,6 +297,9 @@ Quick wins:
|
|||||||
raw и C-адаптера разделены.
|
raw и C-адаптера разделены.
|
||||||
Проверять конкуренцию, ошибки и lifecycle живым DAP/MCP
|
Проверять конкуренцию, ошибки и lifecycle живым DAP/MCP
|
||||||
пробником; см. [sdbg-mcp.md](sdbg-mcp.md).
|
пробником; см. [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 (отдельная
|
- [ ] **Несколько одновременных экземпляров MAME и MCP bridge (отдельная
|
||||||
задача, позже).** Проверить два изолированных процесса с разными
|
задача, позже).** Проверить два изолированных процесса с разными
|
||||||
дискетами/CHD/state и двумя MCP-сессиями; в протоколе явно связывать
|
дискетами/CHD/state и двумя MCP-сессиями; в протоколе явно связывать
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ stdio MCP-адаптер C-уровня. Эксклюзивное владени
|
|||||||
проверяет сигнатуру entry и запускает session server. Закрытие DAP
|
проверяет сигнатуру entry и запускает session server. Закрытие DAP
|
||||||
завершает только созданные им server/MAME, удаляет временный каталог и
|
завершает только созданные им server/MAME, удаляет временный каталог и
|
||||||
свой Unix socket (с проверкой inode, чтобы не удалить новый сеанс).
|
свой Unix socket (с проверкой inode, чтобы не удалить новый сеанс).
|
||||||
- `sdbg_mcp.py` предоставляет 18 инструментов через официальный MCP SDK
|
- `sdbg_mcp.py` предоставляет 26 инструментов через официальный MCP SDK
|
||||||
2.x поверх того же session server: статус, C-позиция, регистры, logical
|
2.x поверх того же session server: статус, C-позиция, регистры, logical
|
||||||
memory, переменные, события/логи, личные точки и команды исполнения.
|
memory, переменные, события/логи, личные точки и команды исполнения.
|
||||||
MCP-точки имеют owner ID и не могут удалить точки VS Code; при обычном
|
MCP-точки имеют owner ID и не могут удалить точки VS Code; при обычном
|
||||||
@@ -136,6 +136,10 @@ stdio MCP-адаптер C-уровня. Эксклюзивное владени
|
|||||||
шага после клавиши.
|
шага после клавиши.
|
||||||
Повторный запуск на том же фиксированном socket после прежнего stale-файла
|
Повторный запуск на том же фиксированном socket после прежнего stale-файла
|
||||||
прошёл; после Stop путь удалён.
|
прошёл; после Stop путь удалён.
|
||||||
|
В автономном режиме доступны 28 инструментов: официальный MCP-клиент
|
||||||
|
запустил `hello`, ввёл `x` через публичный `press_key` во время `getchar()`
|
||||||
|
и остановился на следующей C-строке. Остальные raw-возможности ввода
|
||||||
|
перечислены в [матрице](mcp-capability-matrix.md).
|
||||||
Контракт запуска и ограничения — в
|
Контракт запуска и ограничения — в
|
||||||
[руководстве по MCP](sdbg-mcp.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 |
|
| 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 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` |
|
| Видимый 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 |
|
| 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` |
|
| 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 не участвует |
|
| Ошибочная debug-сборка, 2026-09-15 | Изолированный `fault.c` вернул код 2 от make, `file:1: error 20` сохранился, Python traceback удалён; MAME не участвует |
|
||||||
|
|||||||
@@ -536,12 +536,16 @@ console_log и загрузка набора точек. Возвращать bu
|
|||||||
в арбитраж, а не обходят его.
|
в арбитраж, а не обходят его.
|
||||||
|
|
||||||
Первый stdio MCP-адаптер уже работает через общий session server и отдаёт
|
Первый stdio MCP-адаптер уже работает через общий session server и отдаёт
|
||||||
18 проверенных C-инструментов. У личных MCP-точек owner ID; удаление чужой
|
26 проверенных C-инструментов (28 при автономном start/stop). У личных MCP-точек owner ID; удаление чужой
|
||||||
DAP-точки отклоняется. Совместный живой прогон подтвердил чтение состояния,
|
DAP-точки отклоняется. Совместный живой прогон подтвердил чтение состояния,
|
||||||
точки и последующее срабатывание DAP-точки. Добавлен control lease для
|
точки и последующее срабатывание DAP-точки. Добавлен control lease для
|
||||||
команд CPU и ввода; передача MCP→DAP и cleanup точки владельца без heartbeat
|
команд 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);
|
Настройка и точный список — [sdbg-mcp.md](sdbg-mcp.md);
|
||||||
результат удаления legacy Lua и перенос всех raw MCP-возможностей в общую сессию —
|
результат удаления legacy Lua и перенос всех raw MCP-возможностей в общую сессию —
|
||||||
[mcp-convergence-plan.md](mcp-convergence-plan.md).
|
[mcp-convergence-plan.md](mcp-convergence-plan.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 одновременно не проверялись.
|
||||||
@@ -11,7 +11,8 @@
|
|||||||
`MAME.HT/plugins/mamebridge` вместе с `src/mame_mcp.py` публикует 30
|
`MAME.HT/plugins/mamebridge` вместе с `src/mame_mcp.py` публикует 30
|
||||||
raw-инструментов. Удалённый на этапе А `src/mame_bridge.lua` использовал тот
|
raw-инструментов. Удалённый на этапе А `src/mame_bridge.lua` использовал тот
|
||||||
же Python frontend, но обрабатывал только 16 команд и не отвечал при hard-stop.
|
же 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-сессии, а не
|
**Цель — покрыть возможности всех 30 raw-инструментов в C-сессии, а не
|
||||||
механически скопировать имена и небезопасную семантику.** Полный raw MCP
|
механически скопировать имена и небезопасную семантику.** Полный raw MCP
|
||||||
остаётся отдельным режимом для задач без C-пакета и других машин MAME.
|
остаётся отдельным режимом для задач без C-пакета и других машин MAME.
|
||||||
@@ -107,9 +108,11 @@ control lease: команды CPU и ввод принимаются от тек
|
|||||||
удерживаемые клавиши после истечения heartbeat. Живой прогон с владельцем
|
удерживаемые клавиши после истечения heartbeat. Живой прогон с владельцем
|
||||||
без heartbeat подтвердил событие `owner_expired`, удаление точки и отсутствие
|
без heartbeat подтвердил событие `owner_expired`, удаление точки и отсутствие
|
||||||
ложной остановки в `hello.c:62` после возобновления DAP.
|
ложной остановки в `hello.c:62` после возобновления DAP.
|
||||||
**Этап Б не закрыт:** ещё нужны защита
|
RPC теперь привязывает последующие запросы DAP/MCP/CLI к session ID и build ID;
|
||||||
всех мутаций, проверка session ID в каждом изменяющем запросе и безопасная
|
мутации CPU и точек также требуют актуальную generation. Смена сессии/socket
|
||||||
invalidation при reset/load.
|
или устаревшая generation дают явный отказ, без автоматического повтора.
|
||||||
|
**Этап Б не закрыт:** ещё нужны проверка reset/load на живом MAME, контроль
|
||||||
|
новых типов мутаций и политика передачи lease между долгоживущими клиентами.
|
||||||
|
|
||||||
## В. Перенести 30 возможностей raw MCP по группам
|
## В. Перенести 30 возможностей raw MCP по группам
|
||||||
|
|
||||||
@@ -132,6 +135,20 @@ invalidation при reset/load.
|
|||||||
pass-through и особенности адресов перечислены поимённо. Число методов C-MCP
|
pass-through и особенности адресов перечислены поимённо. Число методов C-MCP
|
||||||
может быть больше 30 из-за разных адресных пространств и C-операций.
|
может быть больше 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
|
## Г. Сделать запуск пригодным для Codex и Claude без VS Code
|
||||||
|
|
||||||
Сейчас `sdbg_mcp.py` подключается лишь к уже работающему socket. Сам
|
Сейчас `sdbg_mcp.py` подключается лишь к уже работающему socket. Сам
|
||||||
@@ -157,6 +174,19 @@ pass-through и особенности адресов перечислены п
|
|||||||
**Выход Г:** C-отладка полностью доступна агенту без VS Code, а подключение
|
**Выход Г:** C-отладка полностью доступна агенту без VS Code, а подключение
|
||||||
агента к VS Code-сессии не создаёт второй MAME и не теряет точки DAP.
|
агента к 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`, собранном командой
|
- Живые прогоны выполнять на `MAME.HT/sprinter`, собранном командой
|
||||||
|
|||||||
+68
-5
@@ -44,8 +44,38 @@ pyenv exec python -m venv /tmp/sprinter-sdbg-mcp-venv
|
|||||||
MCP-сервер заново вместе с новой DAP-сессией. Альтернатива параметру
|
MCP-сервер заново вместе с новой DAP-сессией. Альтернатива параметру
|
||||||
`--socket` — переменная окружения `SDBG_SOCKET`.
|
`--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`,
|
Доступные инструменты: `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`,
|
`mame_console_tail`, `set_line_breakpoint`, `set_function_breakpoint`,
|
||||||
`clear_breakpoint`, `clear_my_breakpoints`, `claim_control`, `release_control`,
|
`clear_breakpoint`, `clear_my_breakpoints`, `claim_control`, `release_control`,
|
||||||
`continue_execution`,
|
`continue_execution`,
|
||||||
@@ -55,7 +85,8 @@ MCP-сервер заново вместе с новой DAP-сессией. А
|
|||||||
`recent_events` с курсором `last`. Логи `SDBG_LOG` приходят как события
|
`recent_events` с курсором `last`. Логи `SDBG_LOG` приходят как события
|
||||||
`output`; журнал ограничен 1024 событиями и сообщает `first`/`last`/`lost`.
|
`output`; журнал ограничен 1024 событиями и сообщает `first`/`last`/`lost`.
|
||||||
|
|
||||||
Число 16 не означает удаление возможностей более нового `-plugin mamebridge`:
|
Число 26 в attach-режиме (28 в автономном с `start_session`/`stop_session`)
|
||||||
|
не означает полного переноса более нового `-plugin mamebridge`:
|
||||||
его `src/mame_mcp.py` публикует 30 raw-инструментов, ориентированных на
|
его `src/mame_mcp.py` публикует 30 raw-инструментов, ориентированных на
|
||||||
машину MAME. Здесь инструменты сгруппированы по операциям C-сессии, а
|
машину MAME. Здесь инструменты сгруппированы по операциям C-сессии, а
|
||||||
некоторые функции (C-позиция, typed global/static, журнал с generation)
|
некоторые функции (C-позиция, typed global/static, журнал с generation)
|
||||||
@@ -64,15 +95,16 @@ MCP-сервер заново вместе с новой DAP-сессией. А
|
|||||||
| Группа raw MCP | Что требуется перед переносом в общую сессию |
|
| Группа raw MCP | Что требуется перед переносом в общую сессию |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `setmem`, raw `debugger_command`, watchpoints | Проверка прав, банка, диапазона и согласование с DAP-точками; произвольная debugger-команда может нарушить состояние сессии |
|
| `setmem`, raw `debugger_command`, watchpoints | Проверка прав, банка, диапазона и согласование с DAP-точками; произвольная debugger-команда может нарушить состояние сессии |
|
||||||
| VRAM/shares, screen pixels, screenshot | Явные адресные пространства, лимиты, формат ответа и изолированный каталог снимков |
|
| VRAM/shares, screen pixels, screenshot | Реализованы через общий `sdbgbridge`; точный tag, пределы 4096 байт/8192 пикселя, PNG только в каталоге сессии |
|
||||||
| Клавиатура, мышь, type/press | Один владелец ввода, корректное отпускание клавиш, ожидание running CPU и проверка прямого ввода с клавиатуры |
|
| Клавиатура, мышь, type/press | `list_ports` и одиночный `press_key` уже перенесены; набор строки, произвольные поля портов и мышь требуют отдельного контракта ввода |
|
||||||
| Raw disassembly | Связать адрес/банк с проверенной C-картой; не выдавать физический адрес за logical |
|
| Raw disassembly | Связать адрес/банк с проверенной C-картой; не выдавать физический адрес за logical |
|
||||||
|
|
||||||
Это следующий этап, а не запрет на функции raw MCP. Нельзя просто загрузить
|
Это следующий этап, а не запрет на функции raw MCP. Нельзя просто загрузить
|
||||||
`mamebridge` рядом с `sdbgbridge`: два независимых обработчика начнут менять
|
`mamebridge` рядом с `sdbgbridge`: два независимых обработчика начнут менять
|
||||||
CPU и точки без общего owner ID и журнала.
|
CPU и точки без общего owner ID и журнала.
|
||||||
Результаты удаления legacy Lua и этапы переноса всех 30 возможностей в общую
|
Результаты удаления legacy Lua и этапы переноса всех 30 возможностей в общую
|
||||||
C-сессию описаны в [плане сведения MCP](mcp-convergence-plan.md).
|
C-сессию описаны в [плане сведения MCP](mcp-convergence-plan.md) и
|
||||||
|
[матрице покрытия](mcp-capability-matrix.md).
|
||||||
|
|
||||||
Чтение памяти принимает десятичный адрес или `0xHEX`, 1–256 байт logical
|
Чтение памяти принимает десятичный адрес или `0xHEX`, 1–256 байт logical
|
||||||
Z80 memory, только при остановленном CPU, без side effects. Ответ содержит
|
Z80 memory, только при остановленном CPU, без side effects. Ответ содержит
|
||||||
@@ -81,6 +113,37 @@ generation и страницы банков; диапазон не может п
|
|||||||
байта, только когда нужный банк отображён. Локальные, стек, watchpoints,
|
байта, только когда нужный банк отображён. Локальные, стек, 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. Она может удалить только свои точки; при
|
У каждой MCP-копии свой owner ID. Она может удалить только свои точки; при
|
||||||
обычном закрытии stdio они очищаются. Живой тест подтвердил, что попытка
|
обычном закрытии stdio они очищаются. Живой тест подтвердил, что попытка
|
||||||
удалить DAP-точку отвергается и точка VS Code срабатывает после выхода MCP.
|
удалить DAP-точку отвергается и точка VS Code срабатывает после выхода MCP.
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ launcher задаёт этому процессу `SDL_NO_SIGNAL_HANDLERS=1`, е
|
|||||||
фиксированный `"socket": "/tmp/sprinter-sdbg-hello.sock"` и подключите
|
фиксированный `"socket": "/tmp/sprinter-sdbg-hello.sock"` и подключите
|
||||||
`toolchain/sdbg_mcp.py` после остановки в `main`. MCP использует тот же
|
`toolchain/sdbg_mcp.py` после остановки в `main`. MCP использует тот же
|
||||||
session server; его личные точки не заменяют точки VS Code. Порядок запуска,
|
session server; его личные точки не заменяют точки VS Code. Порядок запуска,
|
||||||
16 доступных инструментов и ограничения совместного управления описаны в
|
26 доступных инструментов и ограничения совместного управления описаны в
|
||||||
[руководстве по MCP](sdbg-mcp.md).
|
[руководстве по MCP](sdbg-mcp.md).
|
||||||
|
|
||||||
Команда палитры `Sprinter: Build Active Project` собирает приложение по
|
Команда палитры `Sprinter: Build Active Project` собирает приложение по
|
||||||
@@ -309,7 +309,8 @@ trampoline до первой C-позиции вызывающей функци
|
|||||||
|
|
||||||
## Ручной attach
|
## Ручной attach
|
||||||
|
|
||||||
Для общего MAME между CLI и VS Code сначала запустите `sdbg_server.py`, затем:
|
Для общего MAME между CLI и VS Code сначала запустите `sdbg_server.py` либо
|
||||||
|
автономный MCP `start_session` и дождитесь `session_status.phase=ready`, затем:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -321,7 +322,9 @@ trampoline до первой C-позиции вызывающей функци
|
|||||||
```
|
```
|
||||||
|
|
||||||
Команды server и диагностического CLI приведены в
|
Команды 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
|
## Ограничения MVP
|
||||||
|
|
||||||
|
|||||||
@@ -60,7 +60,10 @@ def main():
|
|||||||
raise RuntimeError('DAP завершился до авторского лога')
|
raise RuntimeError('DAP завершился до авторского лога')
|
||||||
if event.get('event') == 'output': outputs.append(event)
|
if event.get('event') == 'output': outputs.append(event)
|
||||||
socket = launched['body']['socket']
|
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']):
|
if not any('total=1' in line for line in tail['lines']):
|
||||||
raise RuntimeError('В debugger console MAME нет total=1: '+str(tail))
|
raise RuntimeError('В debugger console MAME нет total=1: '+str(tail))
|
||||||
print(json.dumps({'event': 'macro_dual_console_verified',
|
print(json.dumps({'event': 'macro_dual_console_verified',
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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()
|
||||||
@@ -22,7 +22,9 @@ async def probe(socket: str, source: str, foreign_id: int):
|
|||||||
names = {tool.name for tool in listed.tools}
|
names = {tool.name for tool in listed.tools}
|
||||||
expected = {'session_status', 'where', 'read_registers', 'read_memory',
|
expected = {'session_status', 'where', 'read_registers', 'read_memory',
|
||||||
'recent_events', 'set_line_breakpoint', 'clear_breakpoint',
|
'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:
|
if not expected <= names:
|
||||||
raise RuntimeError('Не хватает MCP-инструментов: ' + str(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 \
|
registers['registers']['PC'] != location['pc'] or \
|
||||||
len(memory['hex']) != 8 or not events['events']:
|
len(memory['hex']) != 8 or not events['events']:
|
||||||
raise RuntimeError('Неполный C-снимок MCP: ' + repr((status, location, memory)))
|
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})
|
own = await call('set_line_breakpoint', {'file': source, 'line': 62})
|
||||||
foreign = await client.call_tool('clear_breakpoint', {'identifier': foreign_id})
|
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'],
|
'build_id': status['build_id'], 'pc': location['pc'],
|
||||||
'tools': len(names), 'foreign_point_rejected': True,
|
'tools': len(names), 'foreign_point_rejected': True,
|
||||||
'owned_point_for_cleanup': own['id'],
|
'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)
|
ensure_ascii=False), flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ ROOT = Path(__file__).resolve().parents[2]
|
|||||||
PYTHON = Path.home()/'.pyenv/shims/python'
|
PYTHON = Path.home()/'.pyenv/shims/python'
|
||||||
sys.path.insert(0, str(ROOT/'toolchain'))
|
sys.path.insert(0, str(ROOT/'toolchain'))
|
||||||
from mame_interactive import resolve
|
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
|
from sdbg.session import SessionError
|
||||||
|
|
||||||
|
|
||||||
@@ -98,6 +98,10 @@ def main():
|
|||||||
help='Python 3.12 с MCP SDK 2.x для совместной живой пробы DAP/MCP')
|
help='Python 3.12 с MCP SDK 2.x для совместной живой пробы DAP/MCP')
|
||||||
parser.add_argument('--orphan-expiry', action='store_true',
|
parser.add_argument('--orphan-expiry', action='store_true',
|
||||||
help='проверить очистку точки владельца MCP без heartbeat через 30 с')
|
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',
|
parser.add_argument('--clear-while-running', action='store_true',
|
||||||
help='во время WAITKEY удалить личную точку; использовать с --waitkey --emulated-key')
|
help='во время WAITKEY удалить личную точку; использовать с --waitkey --emulated-key')
|
||||||
parser.add_argument('--exit-while-stopped', action='store_true',
|
parser.add_argument('--exit-while-stopped', action='store_true',
|
||||||
@@ -128,6 +132,14 @@ def main():
|
|||||||
launch['launchPath'] = options.launch_path
|
launch['launchPath'] = options.launch_path
|
||||||
send(process, 2, 'launch', launch)
|
send(process, 2, 'launch', launch)
|
||||||
launched, buffer, events = wait_response(process, buffer, 'launch', 80)
|
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):
|
if not any(item.get('event') == 'initialized' for item in events):
|
||||||
initialized, buffer = receive(process, buffer, 5)
|
initialized, buffer = receive(process, buffer, 5)
|
||||||
if initialized.get('event') != 'initialized':
|
if initialized.get('event') != 'initialized':
|
||||||
@@ -156,13 +168,13 @@ def main():
|
|||||||
raise RuntimeError('MCP SDK probe: '+probe.stderr[-4000:])
|
raise RuntimeError('MCP SDK probe: '+probe.stderr[-4000:])
|
||||||
if options.orphan_expiry:
|
if options.orphan_expiry:
|
||||||
owner = 'mcp:orphan-expiry-probe'
|
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})
|
'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)
|
print('Ожидание истечения MCP owner и очистки его точки...', flush=True)
|
||||||
deadline = time.monotonic()+36
|
deadline = time.monotonic()+36
|
||||||
while time.monotonic() < deadline:
|
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
|
if any(event['event'] == 'owner_expired' and
|
||||||
event['body']['owner'] == owner for event in events['events']):
|
event['body']['owner'] == owner for event in events['events']):
|
||||||
break
|
break
|
||||||
@@ -170,7 +182,7 @@ def main():
|
|||||||
else:
|
else:
|
||||||
raise RuntimeError('MCP owner не истёк за 36 с')
|
raise RuntimeError('MCP owner не истёк за 36 с')
|
||||||
try:
|
try:
|
||||||
rpc_call(launched['body']['socket'], 'clear_breakpoint', {
|
session_rpc(launched['body']['socket'], 'clear_breakpoint', {
|
||||||
'id': orphan['id'], 'owner': owner})
|
'id': orphan['id'], 'owner': owner})
|
||||||
except SessionError as error:
|
except SessionError as error:
|
||||||
if 'Неизвестная логическая точка' not in str(error):
|
if 'Неизвестная логическая точка' not in str(error):
|
||||||
@@ -284,37 +296,49 @@ def main():
|
|||||||
socket_path = launched['body']['socket']
|
socket_path = launched['body']['socket']
|
||||||
clear_point = None
|
clear_point = None
|
||||||
if options.clear_while_running:
|
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,
|
'file': str(ROOT/'tests/hello/hello.c'), 'line': 63,
|
||||||
'owner': 'probe:running-clear'})
|
'owner': 'probe:running-clear'})
|
||||||
started = time.monotonic()
|
started = time.monotonic()
|
||||||
send(process, 8, 'next')
|
send(process, 8, 'next')
|
||||||
_, buffer, _ = wait_response(process, buffer, 'next', 3)
|
_, buffer, _ = wait_response(process, buffer, 'next', 3)
|
||||||
response_ms = round((time.monotonic()-started)*1000, 2)
|
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:
|
if clear_point:
|
||||||
snapshot = rpc_call(socket_path, 'snapshot', timeout=5)
|
snapshot = session_rpc(socket_path, 'snapshot', timeout=5)
|
||||||
if snapshot['state'] != 'running':
|
if snapshot['state'] != 'running':
|
||||||
raise RuntimeError('CPU не выполняется перед очисткой точки')
|
raise RuntimeError('CPU не выполняется перед очисткой точки')
|
||||||
try:
|
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')
|
raise RuntimeError('Чтение памяти разрешено при running CPU')
|
||||||
except SessionError as error:
|
except SessionError as error:
|
||||||
if 'остановленного CPU' not in str(error):
|
if 'остановленного CPU' not in str(error):
|
||||||
raise
|
raise
|
||||||
rpc_call(socket_path, 'clear_breakpoint', {
|
session_rpc(socket_path, 'clear_breakpoint', {
|
||||||
'id': clear_point['id'], 'owner': 'probe:running-clear'})
|
'id': clear_point['id'], 'owner': 'probe:running-clear'})
|
||||||
if '--emulated-key' in sys.argv:
|
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)
|
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']:
|
if second['paused'] or second['time'] <= first['time']:
|
||||||
raise RuntimeError('MAME не обновляет input frames: '+
|
raise RuntimeError('MAME не обновляет input frames: '+
|
||||||
str((first, second)))
|
str((first, second)))
|
||||||
tag, mask, _ = resolve('x')
|
tag, mask, _ = resolve('x')
|
||||||
rpc_call(socket_path, 'input_key',
|
session_rpc(socket_path, 'input_key',
|
||||||
{'tag': tag, 'mask': mask, 'down': True}, timeout=5)
|
{'tag': tag, 'mask': mask, 'down': True}, timeout=5)
|
||||||
time.sleep(.15)
|
time.sleep(.15)
|
||||||
rpc_call(socket_path, 'input_key',
|
session_rpc(socket_path, 'input_key',
|
||||||
{'tag': tag, 'mask': mask, 'down': False}, timeout=5)
|
{'tag': tag, 'mask': mask, 'down': False}, timeout=5)
|
||||||
key_stop, buffer = wait_event(process, buffer, 'stopped', 10)
|
key_stop, buffer = wait_event(process, buffer, 'stopped', 10)
|
||||||
if not key_stop:
|
if not key_stop:
|
||||||
@@ -324,7 +348,7 @@ def main():
|
|||||||
after = after_response['body']['stackFrames'][0]
|
after = after_response['body']['stackFrames'][0]
|
||||||
if after['line'] != 63:
|
if after['line'] != 63:
|
||||||
raise RuntimeError('После клавиши ожидалась строка 63: '+str(after))
|
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
|
ascii_code = ascii_code['registers']['DE'] & 0xff
|
||||||
if ascii_code != ord('x'):
|
if ascii_code != ord('x'):
|
||||||
raise RuntimeError(f'WAITKEY вернул {ascii_code:#x}, ожидался x (0x78)')
|
raise RuntimeError(f'WAITKEY вернул {ascii_code:#x}, ожидался x (0x78)')
|
||||||
@@ -340,7 +364,7 @@ def main():
|
|||||||
return 0
|
return 0
|
||||||
if '--manual-key' in sys.argv:
|
if '--manual-key' in sys.argv:
|
||||||
socket_path = launched['body']['socket']
|
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:
|
if keyboard_state.get(':kbd:ms_naturl') is not True:
|
||||||
raise RuntimeError('Физическая PC-клавиатура MAME выключена: '+
|
raise RuntimeError('Физическая PC-клавиатура MAME выключена: '+
|
||||||
str(keyboard_state))
|
str(keyboard_state))
|
||||||
@@ -357,7 +381,7 @@ def main():
|
|||||||
after = after_response['body']['stackFrames'][0]
|
after = after_response['body']['stackFrames'][0]
|
||||||
if after['line'] != 63:
|
if after['line'] != 63:
|
||||||
raise RuntimeError('После клавиши ожидалась строка 63: '+str(after))
|
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
|
ascii_code = ascii_code['registers']['DE'] & 0xff
|
||||||
if ascii_code != ord('x'):
|
if ascii_code != ord('x'):
|
||||||
raise RuntimeError(f'WAITKEY вернул {ascii_code:#x}, ожидался x (0x78)')
|
raise RuntimeError(f'WAITKEY вернул {ascii_code:#x}, ожидался x (0x78)')
|
||||||
|
|||||||
@@ -14,8 +14,11 @@ class McpAdapterTests(unittest.TestCase):
|
|||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.calls = []
|
self.calls = []
|
||||||
|
|
||||||
def rpc(path, method, arguments, timeout=10):
|
def rpc(path, method, arguments, timeout=10, **identity):
|
||||||
self.calls.append((path, method, arguments, timeout))
|
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}
|
return {'method': method, 'arguments': arguments}
|
||||||
|
|
||||||
self.client = McpSession('/tmp/sprinter-test.sock', rpc=rpc)
|
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_breakpoint(7)
|
||||||
self.client.clear_owned_breakpoints()
|
self.client.clear_owned_breakpoints()
|
||||||
self.assertTrue(self.client.owner.startswith('mcp:'))
|
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(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',
|
['break_line', 'break_function', 'clear_breakpoint',
|
||||||
'clear_owned_breakpoints'])
|
'clear_owned_breakpoints'])
|
||||||
|
|
||||||
@@ -36,7 +43,7 @@ class McpAdapterTests(unittest.TestCase):
|
|||||||
self.client.read_memory('0xc000', 16)
|
self.client.read_memory('0xc000', 16)
|
||||||
self.assertEqual(self.calls[-1][2], {'address': 0xc000, 'length': 16})
|
self.assertEqual(self.calls[-1][2], {'address': 0xc000, 'length': 16})
|
||||||
self.client.events(after=4, timeout=12)
|
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,
|
('events', {'after': 4, 'timeout': 12,
|
||||||
'owner': self.client.owner}, 14))
|
'owner': self.client.owner}, 14))
|
||||||
before = len(self.calls)
|
before = len(self.calls)
|
||||||
@@ -48,6 +55,28 @@ class McpAdapterTests(unittest.TestCase):
|
|||||||
self.client.step_source('back')
|
self.client.step_source('back')
|
||||||
self.assertEqual(len(self.calls), before)
|
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__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ class DummyBridge:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.calls = []
|
self.calls = []
|
||||||
self.state = 'stopped'
|
self.state = 'stopped'
|
||||||
|
self.generation = 1
|
||||||
def close(self): pass
|
def close(self): pass
|
||||||
def request(self, command, **arguments):
|
def request(self, command, **arguments):
|
||||||
self.calls.append((command, arguments))
|
self.calls.append((command, arguments))
|
||||||
@@ -189,8 +190,26 @@ class ServerTests(unittest.TestCase):
|
|||||||
try:
|
try:
|
||||||
status = rpc_call(server.path, 'status')
|
status = rpc_call(server.path, 'status')
|
||||||
self.assertEqual(status['build_id'], 'test')
|
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')
|
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, 'Неизвестный'):
|
with self.assertRaisesRegex(SessionError, 'Неизвестный'):
|
||||||
rpc_call(server.path, 'unknown')
|
rpc_call(server.path, 'unknown')
|
||||||
finally:
|
finally:
|
||||||
@@ -290,6 +309,21 @@ class ServerTests(unittest.TestCase):
|
|||||||
finally:
|
finally:
|
||||||
controller.close()
|
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):
|
def test_idle_snapshot_detects_invalidation(self):
|
||||||
session = DummySession()
|
session = DummySession()
|
||||||
controller = SessionController(session)
|
controller = SessionController(session)
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ function exports.startplugin()
|
|||||||
local lfs = require("lfs")
|
local lfs = require("lfs")
|
||||||
local directory = assert(os.getenv("SDBG_IPC_DIR"), "Нужен SDBG_IPC_DIR")
|
local directory = assert(os.getenv("SDBG_IPC_DIR"), "Нужен SDBG_IPC_DIR")
|
||||||
local session = assert(os.getenv("SDBG_SESSION_ID"), "Нужен SDBG_SESSION_ID")
|
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 generation, sequence = 0, 0
|
||||||
local events, owned = {}, {}
|
local events, owned = {}, {}
|
||||||
local state, pending = "initializing", nil
|
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,"Недопустимое число")
|
assert(type(value)=="number" and value%1==0 and value>=0 and value<=limit,"Недопустимое число")
|
||||||
return value
|
return value
|
||||||
end
|
end
|
||||||
|
local function first_screen()
|
||||||
|
for _,screen in pairs(machine().screens) do return screen end
|
||||||
|
error("Экран MAME не найден")
|
||||||
|
end
|
||||||
|
local function share_info()
|
||||||
|
local result={shares={},regions={}}
|
||||||
|
for tag,share in pairs(machine().memory.shares) do
|
||||||
|
result.shares[#result.shares+1]={tag=tag,size=share.size}
|
||||||
|
end
|
||||||
|
for tag,region in pairs(machine().memory.regions) do
|
||||||
|
result.regions[#result.regions+1]={tag=tag,size=region.size}
|
||||||
|
end
|
||||||
|
table.sort(result.shares,function(a,b) return a.tag<b.tag end)
|
||||||
|
table.sort(result.regions,function(a,b) return a.tag<b.tag end)
|
||||||
|
return result
|
||||||
|
end
|
||||||
|
local function read_share(tag,address,length)
|
||||||
|
assert(type(tag)=="string" and #tag>0 and #tag<=128,"Нужен точный tag share")
|
||||||
|
local share=machine().memory.shares[tag]
|
||||||
|
assert(share,"Share не найден: "..tag)
|
||||||
|
local size=number(share.size,0x10000000)
|
||||||
|
address=number(address,size)
|
||||||
|
length=number(length,4096)
|
||||||
|
assert(length>=1 and address+length<=size,"Чтение за пределами share")
|
||||||
|
local bytes={}
|
||||||
|
for offset=0,length-1 do
|
||||||
|
bytes[#bytes+1]=string.format("%02x",share:read_u8(address+offset))
|
||||||
|
end
|
||||||
|
return {tag=tag,address=address,length=length,size=size,hex=table.concat(bytes),
|
||||||
|
generation=generation}
|
||||||
|
end
|
||||||
local invalidate
|
local invalidate
|
||||||
local function dispatch(request)
|
local function dispatch(request)
|
||||||
assert(request.session==session,"Чужая сессия")
|
assert(request.session==session,"Чужая сессия")
|
||||||
@@ -65,9 +98,13 @@ function exports.startplugin()
|
|||||||
session_started=true
|
session_started=true
|
||||||
return {protocol=1,session=session,capabilities={snapshot=true,memory=true,
|
return {protocol=1,session=session,capabilities={snapshot=true,memory=true,
|
||||||
instruction_step=true,step_over=true,step_out=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
|
elseif command=="snapshot" then
|
||||||
local result={state=state,time=now(),paused=machine().paused}
|
local result={state=state,time=now(),paused=machine().paused}
|
||||||
|
local screen=first_screen()
|
||||||
|
result.frame=screen:frame_number()
|
||||||
result.keyboards=setmetatable({}, {__jsontype="object"})
|
result.keyboards=setmetatable({}, {__jsontype="object"})
|
||||||
for tag,kbd in pairs(machine().natkeyboard.keyboards) do
|
for tag,kbd in pairs(machine().natkeyboard.keyboards) do
|
||||||
result.keyboards[tag]=kbd.enabled
|
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))
|
bytes[#bytes+1]=string.format("%02x",symbols:memory_value(":maincpu","p",address+offset,1,true))
|
||||||
end
|
end
|
||||||
return {hex=table.concat(bytes)}
|
return {hex=table.concat(bytes)}
|
||||||
|
elseif command=="program_memory" then
|
||||||
|
stopped(request)
|
||||||
|
local address=number(args.address,0x3ffff)
|
||||||
|
local length=number(args.length,4096)
|
||||||
|
assert(length>=1 and address+length<=0x40000,
|
||||||
|
"Чтение за пределами raw program space")
|
||||||
|
local symbols=emu.symbol_table(cpu())
|
||||||
|
local bytes={}
|
||||||
|
for offset=0,length-1 do
|
||||||
|
bytes[#bytes+1]=string.format("%02x",symbols:memory_value(
|
||||||
|
":maincpu","p",address+offset,1,true))
|
||||||
|
end
|
||||||
|
return {space="program",address=address,length=length,
|
||||||
|
hex=table.concat(bytes),generation=generation}
|
||||||
|
elseif command=="list_ports" then
|
||||||
|
local result={ports={},truncated=false}
|
||||||
|
local fields_seen=0
|
||||||
|
for tag,port in pairs(machine().ioport.ports) do
|
||||||
|
if #result.ports>=512 then result.truncated=true;break end
|
||||||
|
local item={tag=tag,fields={}}
|
||||||
|
for name,field in pairs(port.fields) do
|
||||||
|
if fields_seen>=4096 then result.truncated=true;break end
|
||||||
|
item.fields[#item.fields+1]={name=tostring(name),mask=field.mask}
|
||||||
|
fields_seen=fields_seen+1
|
||||||
|
end
|
||||||
|
table.sort(item.fields,function(a,b) return a.mask<b.mask end)
|
||||||
|
result.ports[#result.ports+1]=item
|
||||||
|
end
|
||||||
|
table.sort(result.ports,function(a,b) return a.tag<b.tag end)
|
||||||
|
return result
|
||||||
|
elseif command=="list_shares" then
|
||||||
|
return share_info()
|
||||||
|
elseif command=="read_share" then
|
||||||
|
return read_share(args.tag,args.address,args.length)
|
||||||
|
elseif command=="read_vram" then
|
||||||
|
local matches={}
|
||||||
|
for tag in pairs(machine().memory.shares) do
|
||||||
|
if tag:find("vram",1,true) then matches[#matches+1]=tag end
|
||||||
|
end
|
||||||
|
assert(#matches==1,"Нужен ровно один VRAM share; используйте list_shares")
|
||||||
|
return read_share(matches[1],args.address,args.length)
|
||||||
|
elseif command=="screen_pixels" then
|
||||||
|
local x=number(args.x,4095)
|
||||||
|
local y=number(args.y,4095)
|
||||||
|
local width=number(args.width,512)
|
||||||
|
local height=number(args.height,512)
|
||||||
|
assert(width>=1 and height>=1 and width*height<=8192,
|
||||||
|
"Прямоугольник должен содержать 1..8192 пикселей")
|
||||||
|
local screen=first_screen()
|
||||||
|
local values={}
|
||||||
|
for dy=0,height-1 do
|
||||||
|
for dx=0,width-1 do
|
||||||
|
values[#values+1]=string.format("%04X",screen:pixel(x+dx,y+dy)&0xffff)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return {x=x,y=y,width=width,height=height,format="pen16be-hex",
|
||||||
|
hex=table.concat(values),frame=screen:frame_number(),
|
||||||
|
emulated_time=now(),stale_frame=state=="stopped",generation=generation}
|
||||||
|
elseif command=="screen_snapshot" then
|
||||||
|
assert(snapdir and #snapdir>0,"Каталог снимков не настроен")
|
||||||
|
snapshot_sequence=snapshot_sequence+1
|
||||||
|
local screen=first_screen()
|
||||||
|
local path=string.format("%s/sdbg_%06d.png",snapdir,snapshot_sequence)
|
||||||
|
local err=screen:snapshot(path)
|
||||||
|
assert(err==nil,"Снимок не создан: "..tostring(err))
|
||||||
|
local size=lfs.attributes(path,"size")
|
||||||
|
assert(size and size>0 and size<=8388608,"Неверный размер снимка")
|
||||||
|
return {path=path,size=size,format="png",frame=screen:frame_number(),
|
||||||
|
emulated_time=now(),stale_frame=state=="stopped",generation=generation}
|
||||||
elseif command=="console_print" then
|
elseif command=="console_print" then
|
||||||
stopped(request)
|
stopped(request)
|
||||||
local text=args.text
|
local text=args.text
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .server import rpc_call
|
from .server import RPC_GENERATION_MUTATIONS, rpc_call
|
||||||
from .session import SessionError
|
from .session import SessionError
|
||||||
|
|
||||||
|
|
||||||
@@ -19,7 +19,14 @@ class DapEngine:
|
|||||||
return self._rpc_override(method, arguments or {})
|
return self._rpc_override(method, arguments or {})
|
||||||
if not self.rpc_path:
|
if not self.rpc_path:
|
||||||
raise SessionError('DAP ещё не подключён к session server')
|
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]]]:
|
def handle(self, command: str, arguments: dict) -> tuple[dict, list[tuple[str, dict]]]:
|
||||||
events = []
|
events = []
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from .server import rpc_call
|
from .server import RPC_GENERATION_MUTATIONS, rpc_call
|
||||||
from .session import SessionError
|
from .session import SessionError
|
||||||
|
|
||||||
|
|
||||||
@@ -13,9 +14,23 @@ class McpSession:
|
|||||||
self.socket_path = str(Path(socket_path).expanduser())
|
self.socket_path = str(Path(socket_path).expanduser())
|
||||||
self.owner = 'mcp:' + uuid.uuid4().hex
|
self.owner = 'mcp:' + uuid.uuid4().hex
|
||||||
self._rpc = rpc
|
self._rpc = rpc
|
||||||
|
self.identity: dict[str, str] = {}
|
||||||
|
|
||||||
def call(self, method: str, arguments: dict | None = None, timeout: float = 10):
|
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):
|
def status(self):
|
||||||
return self.call('status')
|
return self.call('status')
|
||||||
@@ -27,11 +42,86 @@ class McpSession:
|
|||||||
return self.call('registers')
|
return self.call('registers')
|
||||||
|
|
||||||
def read_memory(self, address: str, length: int = 16):
|
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:
|
try:
|
||||||
parsed = int(address, 16 if address.lower().startswith('0x') else 10)
|
return int(address, 16 if address.lower().startswith('0x') else 10)
|
||||||
except ValueError as error:
|
except (AttributeError, ValueError) as error:
|
||||||
raise SessionError('Адрес памяти должен быть десятичным или 0xHEX') from 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):
|
def variables(self):
|
||||||
return self.call('variables')
|
return self.call('variables')
|
||||||
|
|||||||
@@ -223,10 +223,13 @@ class SessionController:
|
|||||||
self._emit('control', {'owner': None})
|
self._emit('control', {'owner': None})
|
||||||
return {'released': owner}
|
return {'released': owner}
|
||||||
if self.running and method in ('where', 'registers', 'read_memory',
|
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')
|
raise SessionError('Операция требует остановленного CPU; сначала Pause')
|
||||||
if method == 'status':
|
if method == 'status':
|
||||||
return {**self.attached, 'running': self.running,
|
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,
|
'event_sequence': self.sequence, 'session_id': self.session_id,
|
||||||
'control_owner': self.control_owner if
|
'control_owner': self.control_owner if
|
||||||
self.control_deadline > time.monotonic() else None}
|
self.control_deadline > time.monotonic() else None}
|
||||||
@@ -235,6 +238,41 @@ class SessionController:
|
|||||||
if method == 'mame_console_tail':
|
if method == 'mame_console_tail':
|
||||||
return self.session.bridge.request(
|
return self.session.bridge.request(
|
||||||
'console_tail', count=int(arguments.get('count', 40)))
|
'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 method == 'input_key':
|
||||||
if not self.running and arguments['down'] is not False:
|
if not self.running and arguments['down'] is not False:
|
||||||
raise SessionError('Нажатие возможно только при running CPU')
|
raise SessionError('Нажатие возможно только при running CPU')
|
||||||
@@ -264,6 +302,16 @@ class SessionController:
|
|||||||
data = self.session.bridge.request('memory', address=address, length=length)
|
data = self.session.bridge.request('memory', address=address, length=length)
|
||||||
return {'address': address, 'length': length, 'hex': data['hex'],
|
return {'address': address, 'length': length, 'hex': data['hex'],
|
||||||
'generation': mapping.generation, 'bank_pages': mapping.bank_pages}
|
'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':
|
if method == 'variables':
|
||||||
return self.session.model.variables
|
return self.session.model.variables
|
||||||
if method == 'read_variable':
|
if method == 'read_variable':
|
||||||
@@ -556,6 +604,17 @@ class _ThreadedUnixServer(socketserver.ThreadingMixIn, socketserver.UnixStreamSe
|
|||||||
daemon_threads = True
|
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:
|
class SessionRpcServer:
|
||||||
def __init__(self, path, controller: SessionController):
|
def __init__(self, path, controller: SessionController):
|
||||||
self.path = Path(path)
|
self.path = Path(path)
|
||||||
@@ -584,8 +643,31 @@ class SessionRpcServer:
|
|||||||
arguments = request.get('arguments', {})
|
arguments = request.get('arguments', {})
|
||||||
if not isinstance(arguments, dict):
|
if not isinstance(arguments, dict):
|
||||||
raise ValueError('RPC arguments должен быть объектом')
|
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,
|
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:
|
except (BridgeError, SessionError, ValueError, TypeError, OSError, KeyError) as error:
|
||||||
response.update(error=str(error))
|
response.update(error=str(error))
|
||||||
self.wfile.write((json.dumps(response, ensure_ascii=False) + '\n').encode())
|
self.wfile.write((json.dumps(response, ensure_ascii=False) + '\n').encode())
|
||||||
@@ -604,8 +686,11 @@ class SessionRpcServer:
|
|||||||
self.path.unlink(missing_ok=True)
|
self.path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
def rpc_call(path, method: str, arguments=None, timeout=10):
|
def rpc_call(path, method: str, arguments=None, timeout=10, *,
|
||||||
request = {'id': 1, 'method': method, 'arguments': arguments or {}}
|
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:
|
with socket.socket(socket.AF_UNIX) as client:
|
||||||
client.settimeout(timeout)
|
client.settimeout(timeout)
|
||||||
client.connect(str(path))
|
client.connect(str(path))
|
||||||
@@ -617,4 +702,7 @@ def rpc_call(path, method: str, arguments=None, timeout=10):
|
|||||||
response = json.loads(raw)
|
response = json.loads(raw)
|
||||||
if not response.get('ok'):
|
if not response.get('ok'):
|
||||||
raise SessionError(response.get('error', 'Неизвестная RPC-ошибка'))
|
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']
|
return response['result']
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
"""Владелец автономного launcher для MCP без запущенного VS Code."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from .mcp_adapter import McpSession
|
||||||
|
from .session import SessionError
|
||||||
|
|
||||||
|
|
||||||
|
class SessionSupervisor:
|
||||||
|
def __init__(self, build: str, socket_path: str | None = None,
|
||||||
|
launcher_options: list[str] | None = None):
|
||||||
|
self.build = str(Path(build).expanduser().resolve())
|
||||||
|
self._temporary = None
|
||||||
|
if socket_path:
|
||||||
|
self.socket_path = str(Path(socket_path).expanduser())
|
||||||
|
else:
|
||||||
|
self._temporary = tempfile.TemporaryDirectory(prefix='sprinter-mcp-', dir='/tmp')
|
||||||
|
self.socket_path = str(Path(self._temporary.name) / 'session.sock')
|
||||||
|
self.launcher_options = list(launcher_options or [])
|
||||||
|
self.lock = threading.RLock()
|
||||||
|
self.phase = 'idle'
|
||||||
|
self.error: str | None = None
|
||||||
|
self.entry = None
|
||||||
|
self.mame_pid = None
|
||||||
|
self.process: subprocess.Popen | None = None
|
||||||
|
self.monitor: threading.Thread | None = None
|
||||||
|
self.log = None
|
||||||
|
self.client: McpSession | None = None
|
||||||
|
self.launch_id = None
|
||||||
|
|
||||||
|
def status(self) -> dict:
|
||||||
|
with self.lock:
|
||||||
|
result = {'phase': self.phase, 'socket': self.socket_path,
|
||||||
|
'launch_id': self.launch_id}
|
||||||
|
if self.error:
|
||||||
|
result['error'] = self.error
|
||||||
|
if self.phase == 'ready' and self.client is not None:
|
||||||
|
try:
|
||||||
|
result.update(self.client.status())
|
||||||
|
result['mame_pid'] = self.mame_pid
|
||||||
|
result['entry'] = self.entry
|
||||||
|
except (SessionError, OSError) as error:
|
||||||
|
self.phase = 'failed'
|
||||||
|
self.error = str(error)
|
||||||
|
result.update(phase='failed', error=self.error)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def require_client(self) -> McpSession:
|
||||||
|
with self.lock:
|
||||||
|
if self.phase != 'ready' or self.client is None:
|
||||||
|
raise SessionError('C-сессия ещё не готова: ' + self.phase)
|
||||||
|
return self.client
|
||||||
|
|
||||||
|
def start(self) -> dict:
|
||||||
|
with self.lock:
|
||||||
|
if self.phase in ('starting', 'ready', 'stopping'):
|
||||||
|
raise SessionError('Автономная сессия уже запущена: ' + self.phase)
|
||||||
|
if self.process is not None and self.process.poll() is None:
|
||||||
|
raise SessionError('Предыдущий launcher ещё работает')
|
||||||
|
if self.monitor is not None and self.monitor.is_alive():
|
||||||
|
raise SessionError('Предыдущий launcher ещё завершает работу')
|
||||||
|
if not Path(self.build).is_dir():
|
||||||
|
raise SessionError('Debug-пакет не найден: ' + self.build)
|
||||||
|
command = [sys.executable, str(Path(__file__).resolve().parents[1] /
|
||||||
|
'sdbg_launcher.py'), '--build', self.build,
|
||||||
|
'--socket', self.socket_path, *self.launcher_options]
|
||||||
|
if self.log is not None:
|
||||||
|
self.log.close()
|
||||||
|
self.log = tempfile.TemporaryFile(mode='w+t', encoding='utf-8')
|
||||||
|
self.phase = 'starting'
|
||||||
|
self.error = None
|
||||||
|
self.entry = None
|
||||||
|
self.mame_pid = None
|
||||||
|
self.client = None
|
||||||
|
self.launch_id = uuid.uuid4().hex
|
||||||
|
try:
|
||||||
|
self.process = subprocess.Popen(command, stdout=subprocess.PIPE,
|
||||||
|
stderr=self.log, text=True)
|
||||||
|
except OSError:
|
||||||
|
self.phase = 'failed'
|
||||||
|
raise
|
||||||
|
self.monitor = threading.Thread(target=self._monitor, name='sdbg-supervisor',
|
||||||
|
daemon=True)
|
||||||
|
self.monitor.start()
|
||||||
|
return {'accepted': True, 'phase': self.phase,
|
||||||
|
'socket': self.socket_path, 'launch_id': self.launch_id}
|
||||||
|
|
||||||
|
def _diagnostics(self) -> str:
|
||||||
|
if self.log is None:
|
||||||
|
return ''
|
||||||
|
self.log.flush()
|
||||||
|
self.log.seek(0)
|
||||||
|
return self.log.read()[-4000:].strip()
|
||||||
|
|
||||||
|
def _monitor(self) -> None:
|
||||||
|
process = self.process
|
||||||
|
assert process is not None and process.stdout is not None
|
||||||
|
try:
|
||||||
|
line = process.stdout.readline()
|
||||||
|
if not line:
|
||||||
|
raise SessionError('Launcher завершился до main: ' + self._diagnostics())
|
||||||
|
ready = json.loads(line)
|
||||||
|
if not ready.get('ready') or ready.get('socket') != self.socket_path:
|
||||||
|
raise SessionError('Launcher вернул неверный ready')
|
||||||
|
client = McpSession(self.socket_path)
|
||||||
|
client.status()
|
||||||
|
with self.lock:
|
||||||
|
if self.phase == 'starting':
|
||||||
|
self.client = client
|
||||||
|
self.entry = ready['entry']
|
||||||
|
self.mame_pid = ready['pid']
|
||||||
|
self.phase = 'ready'
|
||||||
|
process.wait()
|
||||||
|
with self.lock:
|
||||||
|
if self.phase not in ('stopping', 'failed'):
|
||||||
|
self.phase = 'stopped'
|
||||||
|
self.error = 'Launcher завершился: ' + self._diagnostics()
|
||||||
|
elif self.phase == 'stopping':
|
||||||
|
self.phase = 'stopped'
|
||||||
|
self.client = None
|
||||||
|
except (OSError, ValueError, KeyError, SessionError) as error:
|
||||||
|
with self.lock:
|
||||||
|
if self.phase == 'stopping':
|
||||||
|
self.phase = 'stopped'
|
||||||
|
else:
|
||||||
|
self.phase = 'failed'
|
||||||
|
self.error = str(error)
|
||||||
|
self.client = None
|
||||||
|
|
||||||
|
def stop(self) -> dict:
|
||||||
|
with self.lock:
|
||||||
|
if self.phase not in ('starting', 'ready'):
|
||||||
|
return {'accepted': False, 'phase': self.phase}
|
||||||
|
self.phase = 'stopping'
|
||||||
|
if self.process is not None and self.process.poll() is None:
|
||||||
|
self.process.terminate()
|
||||||
|
return {'accepted': True, 'phase': 'stopping',
|
||||||
|
'socket': self.socket_path}
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self.stop()
|
||||||
|
process = self.process
|
||||||
|
if process is not None:
|
||||||
|
try:
|
||||||
|
process.wait(timeout=8)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
process.kill()
|
||||||
|
process.wait()
|
||||||
|
if self.monitor is not None:
|
||||||
|
self.monitor.join(timeout=2)
|
||||||
|
if self.log is not None:
|
||||||
|
self.log.close()
|
||||||
|
if self._temporary is not None:
|
||||||
|
self._temporary.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
class ManagedMcpSession:
|
||||||
|
"""Совместимый с McpSession фасад, пока launcher проходит DSS."""
|
||||||
|
def __init__(self, supervisor: SessionSupervisor):
|
||||||
|
self.supervisor = supervisor
|
||||||
|
|
||||||
|
def status(self) -> dict:
|
||||||
|
return self.supervisor.status()
|
||||||
|
|
||||||
|
def __getattr__(self, name: str):
|
||||||
|
return getattr(self.supervisor.require_client(), name)
|
||||||
@@ -4,7 +4,7 @@ import argparse
|
|||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from sdbg.server import rpc_call
|
from sdbg.server import RPC_GENERATION_MUTATIONS, rpc_call
|
||||||
from sdbg.session import SessionError
|
from sdbg.session import SessionError
|
||||||
|
|
||||||
|
|
||||||
@@ -19,8 +19,16 @@ def main():
|
|||||||
arguments = json.loads(args.arguments)
|
arguments = json.loads(args.arguments)
|
||||||
if not isinstance(arguments, dict):
|
if not isinstance(arguments, dict):
|
||||||
raise ValueError('arguments должен быть JSON-объектом')
|
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,
|
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))
|
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||||
return 0
|
return 0
|
||||||
except (SessionError, ValueError, OSError) as error:
|
except (SessionError, ValueError, OSError) as error:
|
||||||
|
|||||||
@@ -221,7 +221,8 @@ def main():
|
|||||||
lua_script(lua,ready,function['start'],signature,events,args.launch_at,
|
lua_script(lua,ready,function['start'],signature,events,args.launch_at,
|
||||||
args.dss_timeout)
|
args.dss_timeout)
|
||||||
session_id=uuid.uuid4().hex
|
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
|
# SDL3 превращает SIGTERM в SDL_EVENT_QUIT, который MAME SDL3 OSD
|
||||||
# не обрабатывает. Оставляем стандартное действие сигнала, чтобы
|
# не обрабатывает. Оставляем стандартное действие сигнала, чтобы
|
||||||
# DAP Stop не ждал принудительного kill остановленного MAME.
|
# DAP Stop не ждал принудительного kill остановленного MAME.
|
||||||
|
|||||||
+86
-6
@@ -9,11 +9,14 @@ import sys
|
|||||||
import threading
|
import threading
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from mame_profile import add_arguments
|
||||||
from sdbg.mcp_adapter import McpSession
|
from sdbg.mcp_adapter import McpSession
|
||||||
from sdbg.session import SessionError
|
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:
|
try:
|
||||||
from mcp.server import MCPServer
|
from mcp.server import MCPServer
|
||||||
from mcp.server.mcpserver.exceptions import ToolError
|
from mcp.server.mcpserver.exceptions import ToolError
|
||||||
@@ -43,6 +46,17 @@ def make_server(client: McpSession):
|
|||||||
"""Получить build ID, generation, состояние CPU и ограничения текущей сессии."""
|
"""Получить build ID, generation, состояние CPU и ограничения текущей сессии."""
|
||||||
return client.status()
|
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)
|
@tool(annotations=reading)
|
||||||
def where() -> dict[str, Any]:
|
def where() -> dict[str, Any]:
|
||||||
"""Получить проверенное соответствие PC исходнику C и текущему банку."""
|
"""Получить проверенное соответствие PC исходнику C и текущему банку."""
|
||||||
@@ -58,6 +72,47 @@ def make_server(client: McpSession):
|
|||||||
"""Читать 1..256 байт logical Z80 memory без side effects; адрес decimal или 0xHEX."""
|
"""Читать 1..256 байт logical Z80 memory без side effects; адрес decimal или 0xHEX."""
|
||||||
return client.read_memory(address, length)
|
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)
|
@tool(annotations=reading)
|
||||||
def list_variables() -> dict[str, Any]:
|
def list_variables() -> dict[str, Any]:
|
||||||
"""Перечислить известные global/static переменные и поддержанность типов."""
|
"""Перечислить известные global/static переменные и поддержанность типов."""
|
||||||
@@ -134,15 +189,38 @@ def make_server(client: McpSession):
|
|||||||
def main() -> int:
|
def main() -> int:
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
parser.add_argument('--socket', default=os.environ.get('SDBG_SOCKET'),
|
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()
|
args = parser.parse_args()
|
||||||
if not args.socket:
|
if not args.socket and not args.build:
|
||||||
parser.error('укажите --socket или SDBG_SOCKET')
|
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_stop = threading.Event()
|
||||||
heartbeat = None
|
heartbeat = None
|
||||||
try:
|
try:
|
||||||
client.status() # Не публикуем инструменты для отсутствующей сессии.
|
if supervisor is None:
|
||||||
|
client.status() # Attach-режим требует уже работающий session server.
|
||||||
def renew_control():
|
def renew_control():
|
||||||
while not heartbeat_stop.wait(10):
|
while not heartbeat_stop.wait(10):
|
||||||
try:
|
try:
|
||||||
@@ -153,7 +231,7 @@ def main() -> int:
|
|||||||
heartbeat = threading.Thread(target=renew_control, name='sdbg-mcp-heartbeat',
|
heartbeat = threading.Thread(target=renew_control, name='sdbg-mcp-heartbeat',
|
||||||
daemon=True)
|
daemon=True)
|
||||||
heartbeat.start()
|
heartbeat.start()
|
||||||
server = make_server(client)
|
server = make_server(client, supervisor)
|
||||||
server.run(transport='stdio')
|
server.run(transport='stdio')
|
||||||
return 0
|
return 0
|
||||||
except (SessionError, OSError, RuntimeError) as error:
|
except (SessionError, OSError, RuntimeError) as error:
|
||||||
@@ -171,6 +249,8 @@ def main() -> int:
|
|||||||
client.clear_owned_breakpoints()
|
client.clear_owned_breakpoints()
|
||||||
except (SessionError, OSError):
|
except (SessionError, OSError):
|
||||||
pass
|
pass
|
||||||
|
if supervisor is not None:
|
||||||
|
supervisor.close()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
Reference in New Issue
Block a user